在苹果的生态版图中,每个硬件平台都有其独特的应用场景和开发体验。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 端 WCSession 的 sendMessage 给 iOS App 发消息可直接唤起 iOS App。
如果 iOS App 未启动,会直接被拉起;否则 iOS App 会在后台运行。
这是一个极其强大的功能,比如 watchOS 并不支持 SFSpeechRecognizer,甚至可以把语音流发到手机,在手机上完成语音转写,间接实现手表上的实时语音转写效果。
YaoYao 和 Tooboo 也是使用这种方法,发送需要语音合成的文本给手机 App,然后手机 App 使用语音合成再在手机上播报语音,这样用户可以在运动时一边在手机上听音乐,一边听运动语音提醒。
iOS App 唤起 Watch App
可使用 HKHealthStore 的 startWatchApp(with:) 唤起 Watch App。
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 端:
func handle(_ workoutConfiguration: HKWorkoutConfiguration) {
// 此处不一定非要启动 HKWorkoutSession
}
注:该方法仅可在 App Category 为 Healthcare & Fitness 时才可用,否则审核很可能驳回。
Watch App 和 iOS App 数据同步
| 方式 | 支持情况 | 说明 |
|---|---|---|
| App Groups | ❌ | watchOS 从 2.0 开始已经不支持这种方式 |
| CloudKit | ✅ | 支持类似 SwiftData 和老的 CloudKit 相关功能 |
| iCloud Document | ❌ | watchOS 并不支持 iCloud Ubiquitous Containers |
| iCloud key-value | ✅ | 支持 NSUbiquitousKeyValueStore,并较为推荐 |
| Watch Connectivity | ✅ | 使用 WCSession 发送消息或文件是相对可靠稳定即时的方法同步两边的数据,较为推荐。更多参考:Apple Developer Documentation |
注:
WCSession的transferFile发送文件方法在模拟器不支持,需要真机测试。
异常重启与恢复
修改 iPhone App 配置会导致 Watch App 立即重启
在 iPhone 端 App 修改任意隐私权限(如通知、位置、健康隐私权限)会直接导致手表端 App 立即被 SIGKILL。
这个问题在一般情况下影响不大,但当你的 Watch App 使用 Watch Connectivity 和手机实时通信时,如果用户在手机 App 上操作正好碰到需要授权时,Watch App 会被杀掉,这会非常影响体验。
运动会话 Watch App 的异常重启与恢复
使用 HKWorkoutSession 的 App,如果在运动过程中异常崩溃了,可以按照 Apple 的文档通过 WKExtensionDelegate 的 handleActiveWorkoutRecovery 方法重启运动会话。
-
handleActiveWorkoutRecovery方法不会在重启手表时被调用。 但比如像 Tooboo 这样的应用,用户可能一直用到没电,在充满电后,再打开手表,无法确保这个方法被调用。 -
建议在
applicationDidFinishLaunching中检测HKHealthStore().recoverActiveWorkoutSession。 -
recoverActiveWorkoutSession仅能恢复HKWorkoutSession定义的那些一般数据(时间、距离、卡路里等),App 自己定义统计的指标需要自己备份和还原。
内存泄露问题
无论是 iOS 还是 watchOS App,在关闭时只是 Freeze 而不是真正 Restart,这会导致内存泄露问题非常隐蔽。会累积使用很多次后,才会导致 App 因内存占用过大被 Watchdog Kill。
watchOS 上 TabView 嵌套会导致内存泄露!
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 的消耗。
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()
}
}
}
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 运动暂停或 isLuminanceReduced 为 true 时,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 不能安装,致使这类产品在第一步就会遇到困难。 让用户方便联系上开发者,提供支持非常有必要。