从 YaoYao 到 Tooboo:watchOS 开发避坑与实战

在苹果的生态版图中,每个硬件平台都有其独特的应用场景和开发体验。Apple Watch 虽然作为智能手表领域的领跑者拥有庞大用户群,但真正全身心投入其中、并取得商业成功的开发者却屈指可数。

作为一个长期的 Apple Watch 用户和 Swift 博主,我常感叹 watchOS 开发的“神秘”——文档之外细节繁多,且网络上真正有深度的实战文章寥寥无几。为此,我特别邀请了 watchOS 领域的头部独立开发者 Haozes,来分享他的开发体会。

这篇文章并非理论堆砌,而是他在维护千万级用户产品过程中,实打实踩过的“坑”和总结出的“独门秘籍”。这些一手经验对于 watchOS 开发者来说极具价值。非常感谢 Haozes 能如此无私地将其分享给社区。

作为 YaoYao 跳绳Tooboo(徒步骑行)DunDun(深蹲)的开发者,我开发的这些运动健身类产品,基本是以 Apple Watch App 为核心产品形态,而不是 iPhone App。以下是我这些年来一些 watchOS 零零散散的开发经验,希望对你有用。

Watch App 和 iOS App 协同问题

如果 Watch App 和 iOS App 需要协同,手机和手表系统版本不一致,或两边 App 版本不一致都有可能导致很多问题。

watchOS 和 iOS 版本不一致的问题

比如用户的 iOS 版本是 26.1,watchOS 版本是 26.0,这可能会导致一系列问题:

  • 手表端 App 无法安装: 新用户在安装手机 App 后,手表 App 未能同步自动安装。

  • Watch 端 HKWorkoutSession 结束后,手机端“健身” App 中看不到数据,健身圆环无法填充: 如果是健身 App,非常依赖 HealthKit 存储,这个问题相当普遍。

  • Watch Connectivity 通信失败: 手表和手机 App 无法使用 WCSession 通信。

Watch App 和 iOS App 版本不一致的问题

比如用户的 iOS App 是 1.1,Watch App 是 1.0:

  • Watch Connectivity 通信可能失败WCSession 相关方法可能会失效。

Watch App 和 iOS App 互相唤起

Watch App 唤起 iOS App

使用 Watch 端 WCSessionsendMessage 给 iOS App 发消息可直接唤起 iOS App。

如果 iOS App 未启动,会直接被拉起;否则 iOS App 会在后台运行。 这是一个极其强大的功能,比如 watchOS 并不支持 SFSpeechRecognizer甚至可以把语音流发到手机,在手机上完成语音转写,间接实现手表上的实时语音转写效果。

YaoYao 和 Tooboo 也是使用这种方法,发送需要语音合成的文本给手机 App,然后手机 App 使用语音合成再在手机上播报语音,这样用户可以在运动时一边在手机上听音乐,一边听运动语音提醒。

iOS App 唤起 Watch App

可使用 HKHealthStorestartWatchApp(with:) 唤起 Watch App。

Swift
class func launchEmptyWatchApp(onComplete: ((Bool, Error?) -> Void)? = nil) {
    let workoutConfiguration = HKWorkoutConfiguration()
    workoutConfiguration.activityType = .other
    workoutConfiguration.locationType = .outdoor
   
    let healthStore = HKHealthStore()
    healthStore.startWatchApp(with: workoutConfiguration) { (success, error) in
        print(">>> startWatchApp, launchEmptyWatchApp: \(success), error: \(String(describing: error))")
        onComplete?(success, error)
    }
}

在 Watch 端:

Swift
func handle(_ workoutConfiguration: HKWorkoutConfiguration) {
     // 此处不一定非要启动 HKWorkoutSession
}

:该方法仅可在 App Category 为 Healthcare & Fitness 时才可用,否则审核很可能驳回。

Watch App 和 iOS App 数据同步

方式支持情况说明
App GroupswatchOS 从 2.0 开始已经不支持这种方式
CloudKit支持类似 SwiftData 和老的 CloudKit 相关功能
iCloud DocumentwatchOS 并不支持 iCloud Ubiquitous Containers
iCloud key-value支持 NSUbiquitousKeyValueStore,并较为推荐
Watch Connectivity使用 WCSession 发送消息或文件是相对可靠稳定即时的方法同步两边的数据,较为推荐。更多参考:Apple Developer Documentation

  • WCSessiontransferFile 发送文件方法在模拟器不支持,需要真机测试。

异常重启与恢复

修改 iPhone App 配置会导致 Watch App 立即重启

在 iPhone 端 App 修改任意隐私权限(如通知、位置、健康隐私权限)会直接导致手表端 App 立即被 SIGKILL

这个问题在一般情况下影响不大,但当你的 Watch App 使用 Watch Connectivity 和手机实时通信时,如果用户在手机 App 上操作正好碰到需要授权时,Watch App 会被杀掉,这会非常影响体验。

运动会话 Watch App 的异常重启与恢复

使用 HKWorkoutSession 的 App,如果在运动过程中异常崩溃了,可以按照 Apple 的文档通过 WKExtensionDelegatehandleActiveWorkoutRecovery 方法重启运动会话。

  1. handleActiveWorkoutRecovery 方法不会在重启手表时被调用。 但比如像 Tooboo 这样的应用,用户可能一直用到没电,在充满电后,再打开手表,无法确保这个方法被调用。

  2. 建议在 applicationDidFinishLaunching 中检测 HKHealthStore().recoverActiveWorkoutSession

  3. recoverActiveWorkoutSession 仅能恢复 HKWorkoutSession 定义的那些一般数据(时间、距离、卡路里等),App 自己定义统计的指标需要自己备份和还原。

内存泄露问题

无论是 iOS 还是 watchOS App,在关闭时只是 Freeze 而不是真正 Restart,这会导致内存泄露问题非常隐蔽。会累积使用很多次后,才会导致 App 因内存占用过大被 Watchdog Kill。

watchOS 上 TabView 嵌套会导致内存泄露!

Swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        TabView {
            TabView {
                Text("Page V1")
                Text("Page V1")
            }.tabViewStyle(.verticalPage)
            Text("Page H2")
        }
    }
}

在 Watch App 可能你需要类似的架构布局,但目前会导致内存泄露,需要避免嵌套使用 TabView

电量优化

使用 HKWorkoutSession 的 App 可以获得相对其他 App 无法拥有的权限:后台运行。同时 App 的责任也更大,如果 UI 有高频刷新的动画、甚至普通数据驱动的 UI 刷新长时间运行,都会消耗大量电量。

主要优化思路

1. isLuminanceReduced 为 true 时减少刷新

用户在使用手表的时候,大部分时间手腕是放下的,不可能一直抬着手。这时候系统会将屏幕变暗,也就是 isLuminanceReduced 值会变为 true,仅在抬腕时高频刷新,其他时候尽可能降低刷新频率。

2. App 不在前台时减少刷新

监听 NotificationCenter.default.publisher(for: WKApplication.willResignActiveNotification),当 App 不在 Active 状态时减少刷新。

Tooboo 的性能优化

在 Tooboo 这种徒步场景下,用户可以连续导航 12 小时(GPS、心率传感始终工作),低电量模式下可以使用 16 小时(Apple Watch Ultra 2 测试)。

Tooboo Watch App 主要有 3 个线程:地图的渲染、UI 界面的主线程、导航算法。

每次 GPS 的刷新会触发导航算法的判断,判断是否偏离路线或者要不要转弯,但这时候地图不一定会渲染,地图的磁贴也不会下载。地图的下载渲染仅在用户的抬腕时进行,当在后台或者亮度降低时,高昂的 UI 操作都会是非常低频的。

在 UI 界面渲染方法上,Tooboo 并没有使用 SwiftUI 默认的数据驱动渲染方式,因为在运动过程中,运动的数据指标很多(心率、卡路里、距离、速度、位置等),任意一个指标变化都会导致 UI 重绘,这个刷新频率每秒会有几次。

Tooboo 使用了 TimelineSchedule 定时刷新的方式,如果抬腕亮屏将 Schedule 频率调高到 1Hz,否则降低到 10-20 秒刷新一次,这样也会大大减少 CPU 的消耗。

Swift
public struct MetricsTimelineSchedule: TimelineSchedule {
    var startDate: Date
    var isPaused: Bool
    var lowInterval: Double

    public init(from startDate: Date, isPaused: Bool, lowInterval: Double? = nil) {
        self.startDate = startDate
        self.isPaused = isPaused
        self.lowInterval = lowInterval ?? Double(AppSetting.shared.lowRefreshInterval)
    }

    public func entries(from startDate: Date, mode: TimelineScheduleMode) -> AnyIterator<Date> {
        var baseSchedule = PeriodicTimelineSchedule(from: self.startDate, by: (mode == .lowFrequency ? self.lowInterval : 1.0))
            .entries(from: startDate, mode: mode)
        
        return AnyIterator<Date> {
            guard !isPaused else { return nil }
            // print("MetricsTimelineSchedule next()")
            return baseSchedule.next()
        }
    }
}
Swift
struct Metric: View {
    @EnvironmentObject var viewModel: WorkoutViewModel
    @Environment(\.isLuminanceReduced) var isLuminanceReduced
    
    var body: some View {
        TimelineView(MetricsTimelineSchedule(from: self.viewModel.workoutData.startDate ?? Date(), isPaused: (self.viewModel.workoutState == .paused || isLuminanceReduced == true))) { timeline in
            ZStack(alignment: .topLeading) {
                VStack(alignment: .leading, spacing: 0) {
                    Spacer()
                    Text(viewModel.elapsedSec.shortFormatted)
                        .foregroundStyle(Color.yellow)
                    // other metric data
                    Spacer()
                }
                .frame(maxWidth: .infinity, alignment: .leading)
                .ignoresSafeArea(edges: .bottom)
                .scenePadding()
            }
        }
    }
}

比如这里在 App 运动暂停或 isLuminanceReducedtrue 时,App 是低频刷新的。

小结

  • Apple 从 watchOS 6 开始支持了 SwiftUI 来开发 Watch App 界面,watchOS 已经没有任何理由使用之前类似于 UIKit 的 WatchKit 方式来开发。 如果你是新产品,建议从 watchOS 9+ 开始支持。

  • 相对 iOS App 的调试,watchOS 的 App 调试相对困难,另外使用 Xcode > Window > Organizer > Crash 收集到的 Crash 报告会是 iOS 收到的日志的 1/10 - 1/20。 Watch App 最好配合使用 App 本地写日志的方式记录问题,通过 WCSession 将日志发送到手机再收集。

  • 在产品的部署方面,由于大量用户并不熟悉 Watch App 的安装方式,加上系统版本不一致可能导致 App 不能安装,致使这类产品在第一步就会遇到困难。 让用户方便联系上开发者,提供支持非常有必要。

订阅 Fatbobman 周报

每周精选 Swift 与 SwiftUI 开发技巧,加入众多开发者的行列。

立即订阅