Highlight
Apple Watch 越来越独立——从 Series 3 的蜂窝网络、watchOS 6 的独立 App,到 watchOS 7 的家庭设置。这场 Session 系统梳理了五种数据传输方案:iCloud Keychain、Core Data + CloudKit、Watch Connectivity、URL Session 和 Socket,并给出了每种方案的适用场景和选择决策树。
核心内容
选择传输方案的五个问题
(02:08)
在决定用什么方式传输数据之前,先问自己五个问题:
- 数据是什么类型?敏感信息、结构化数据、还是大文件?
- 数据现在在哪,需要送到哪?
- 是否依赖配对的 iOS 应用?
- 是否需要支持家庭设置(Family Setup)?
- 数据需要多快到达目的地?
根据答案,可以从工具箱里选出最合适的方案。
iCloud Keychain:小量敏感数据的全设备同步
(02:53)
Keychain 提供密码、密钥等敏感凭证的安全存储。watchOS 6.2 开始支持 iCloud Keychain 同步,Keychain 项可以同步到用户的所有设备。
最简单的使用方式是密码自动填充(Password Autofill)。只需要在 TextField 上设置 textContentType:
TextField("User:", text: $username)
.textContentType(.username)
SecureField("Password", text: $password)
.textContentType(.password)
(04:20)
这需要在目标上添加 Associated Domains 能力,并在服务器上放置 apple-app-site-association 文件。watchOS 8 的新文本编辑体验让自动填充更好用了。
如果需要存储自定义数据(如 OAuth token),可以直接操作 Keychain API。关键是设置 kSecAttrSynchronizable 为 true,这样数据才会同步到所有设备。
Core Data + CloudKit:结构化数据的云端同步
(11:10)
Core Data 与 CloudKit 集成后,本地数据库可以同步到所有共享同一 CloudKit 容器的设备。SwiftUI 的 @FetchRequest 和 @Environment(\.managedObjectContext) 让在 Watch 应用中使用 Core Data 变得简单。
(11:59)
但需要注意:Watch 的存储和电池容量有限。开发跨平台应用时,要仔细考虑哪些数据真的需要在手表上。可以用多个 Core Data 配置来区分适合手表的数据和适合手机的数据。
Core Data + CloudKit 不依赖 iOS 配套应用,支持家庭设置。同步时机由系统根据网络、电量等条件决定,不要期望即时同步。
Watch Connectivity:配对设备间的实时通信
(13:19)
Watch Connectivity 在蓝牙范围内或同一 Wi-Fi 网络下,在 Watch 和 iPhone 之间传输数据。它最适合优化双端都安装应用时的用户体验,以及共享仅在一端可用的数据。
(14:38)
使用 Watch Connectivity 的最佳实践:
- 尽早激活 session,最好在应用启动时就完成
- 所有 delegate 回调都在非主队列上执行,更新 UI 时要切到主队列
- 理解 reachability:sendMessage 需要两端都 reachable,后台传输不需要
Watch Connectivity 提供了多种传输方式:
- Application Context:单字典,更新时替换旧值,适合频繁更新的配置数据
- User Info Transfer:队列式传输,按顺序送达,可取消
- File Transfer:文件队列传输,接收后放在 document inbox,处理完即删除
- transferCurrentComplicationUserInfo:专门用于 complication 数据,优先传输
- sendMessage:实时双向通信,需要两端 reachable
(20:31)
Watch Connectivity 不能用于家庭设置场景,因为它依赖配对的 iPhone。
URL Session:直接与服务器通信
(21:41)
对于大多数场景,URL Session 是直接与服务器通信的最佳选择。它分为前台和后台两种模式。
后台 URL Session 应该优先使用。它不需要应用保持在前台,系统会在合适时机执行传输。大文件传输时设置 isDiscretionary = true,让系统选择最佳时机。
(23:04)
后台传输完成后,系统会通过 WKURLSessionRefreshBackgroundTask 唤醒应用。必须在 Extension Delegate 中处理这个任务,并及时标记完成。
前台 URL Session 适合快速的服务器通信,比如获取当天的内容列表。但前台 session 有 2.5 分钟超时限制,且更耗电。
详细内容
Keychain 存储 OAuth Token 的完整流程
(06:25)
存储 token(存在则更新,不存在则添加):
func storeToken(_ token: OAuth2Token, for server: String, account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: server,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: true,
]
let tokenData = try encodeToken(token)
let attributes: [String: Any] = [kSecValueData as String: tokenData]
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
guard status != errSecItemNotFound else {
try addTokenData(tokenData, for: server, account: account)
return
}
guard status == errSecSuccess else {
throw OAuthKeychainError.updateError(status)
}
}
关键点:
kSecClassInternetPassword指定存储互联网密码类型kSecAttrSynchronizable设为true开启 iCloud 同步SecItemUpdate更新已有项,不存在时返回errSecItemNotFound- 不存在时调用
addTokenData添加新项
添加新项:
func addTokenData(_ tokenData: Data,
for server: String,
account: String) throws {
let attributes: [String: Any] = [
kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: server,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: true,
kSecValueData as String: tokenData,
]
let status = SecItemAdd(attributes as CFDictionary, nil)
guard status == errSecSuccess else {
throw OAuthKeychainError.addError(status)
}
}
(07:59)
关键点:
SecItemAdd添加新 Keychain 项- 所有属性必须在添加时一次性设置
- 返回
errSecSuccess表示成功
读取 token:
func retrieveToken(for server: String, account: String) throws -> OAuth2Token? {
let query: [String: Any] = [
kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: server,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: true,
kSecReturnAttributes as String: false,
kSecReturnData as String: true,
]
var item: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &item)
guard status != errSecItemNotFound else {
return nil
}
guard status == errSecSuccess else {
throw OAuthKeychainError.retrievalError(status)
}
guard let existingItem = item as? [String : Any],
let tokenData = existingItem[kSecValueData as String] as? Data else {
throw OAuthKeychainError.invalidKeychainItemFormat
}
return try JSONDecoder().decode(OAuth2Token.self, from: tokenData)
}
(08:25)
关键点:
kSecReturnData设为true返回数据内容SecItemCopyMatching搜索并复制匹配的项errSecItemNotFound表示没有存储该 token- 用
JSONDecoder将 Data 解码为 OAuth2Token
删除 token:
func removeToken(for server: String, account: String) throws {
let query: [String: Any] = [
kSecClass as String: kSecClassInternetPassword,
kSecAttrServer as String: server,
kSecAttrAccount as String: account,
kSecAttrSynchronizable as String: true,
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw OAuthKeychainError.deleteError(status)
}
}
(09:39)
关键点:
SecItemDelete删除匹配的 Keychain 项errSecItemNotFound也算成功,因为目标已经不存在
后台 URL Session 的完整实现
(23:04)
创建后台 URL Session:
class BackgroundURLSession: NSObject, ObservableObject, Identifiable {
private let sessionIDPrefix = "com.example.backgroundURLSessionID."
enum Status {
case notStarted
case queued
case inProgress(Double)
case completed
case failed(Error)
}
private var url: URL
var body: Data?
var contentType: String?
private(set) var id = UUID()
@Published var status = Status.notStarted
@Published var downloadedURL: URL?
private var backgroundTasks = [WKURLSessionRefreshBackgroundTask]()
private lazy var urlSession: URLSession = {
let config = URLSessionConfiguration.background(withIdentifier: sessionID)
config.isDiscretionary = false
config.sessionSendsLaunchEvents = true
return URLSession(configuration: config,
delegate: self, delegateQueue: nil)
}()
private var sessionID: String {
"\(sessionIDPrefix)\(id.uuidString)"
}
init(url: URL) {
self.url = url
super.init()
}
}
关键点:
URLSessionConfiguration.background创建后台配置sessionSendsLaunchEvents = true让任务完成后唤醒应用isDiscretionary = false表示不推迟传输(适合小数据)- 每个 session 需要唯一的 identifier
入队后台传输:
func enqueueTransfer() {
var request = URLRequest(url: url)
request.httpBody = body
if body != nil {
request.httpMethod = "POST"
}
if let contentType = contentType {
request.setValue(contentType, forHTTPHeaderField: "Content-type")
}
let task = urlSession.downloadTask(with: request)
task.earliestBeginDate = nextTaskStartDate
BackgroundURLSessions.sharedInstance().sessions[sessionID] = self
task.resume()
status = .queued
}
(24:22)
关键点:
downloadTask(with:)创建下载任务earliestBeginDate可设置最早开始时间task.resume()必须调用才能开始任务- 将 session 保存到全局列表,以便后续处理后台任务
处理后台任务(Extension Delegate):
class ExtensionDelegate: NSObject, WKExtensionDelegate {
func applicationDidFinishLaunching() {
WatchConnectivityModel.shared.activateSession()
}
func handle(_ backgroundTasks: Set<WKRefreshBackgroundTask>) {
for task in backgroundTasks {
switch task {
case let urlSessionTask as WKURLSessionRefreshBackgroundTask:
if let session = BackgroundURLSessions.sharedInstance()
.sessions[urlSessionTask.sessionIdentifier] {
session.addBackgroundRefreshTask(urlSessionTask)
} else {
urlSessionTask.setTaskCompletedWithSnapshot(false)
}
default:
task.setTaskCompletedWithSnapshot(false)
}
}
}
}
(25:45)
关键点:
- 尽早激活 Watch Connectivity session
- 根据 task 类型分类处理
- 找到对应的 session 后保存后台任务,等下载完成后再标记完成
- 找不到 session 时直接标记完成
连接 Extension Delegate 到 App:
@main
struct MyWatchApp: App {
@WKExtensionDelegateAdaptor(ExtensionDelegate.self) var extensionDelegate
var body: some Scene {
WindowGroup {
NavigationView {
ContentView()
}
}
}
}
(26:43)
处理下载完成:
extension BackgroundURLSession : URLSessionDownloadDelegate {
func urlSession(_ session: URLSession,
downloadTask: URLSessionDownloadTask,
didFinishDownloadingTo location: URL) {
// 移动或快速处理文件,文件在临时位置,会被删除
saveDownloadedData(location)
BackgroundURLSessions.sharedInstance().sessions[sessionID] = nil
DispatchQueue.main.async {
self.status = .completed
}
for task in backgroundTasks {
task.setTaskCompletedWithSnapshot(false)
}
}
}
(27:31)
关键点:
- 下载完成的文件在临时位置,必须立即处理或移动
- 处理完成后标记所有后台任务为完成
- 从全局列表中移除 session
- 不及时标记完成会导致应用被系统终止
传输方案选择总结
(30:21)
| 场景 | 推荐方案 | 支持家庭设置 |
|---|---|---|
| 小量敏感数据,全设备同步 | iCloud Keychain | 是 |
| 结构化数据库,全设备同步 | Core Data + CloudKit | 是 |
| 优化配对 iPhone + Watch 体验 | Watch Connectivity | 否 |
| 直接与服务器通信 | URL Session / Socket | 是 |
| 实时双向通信(两端都在前台) | Watch Connectivity sendMessage | 否 |
核心启发
-
做一个跨设备密码管理器 Watch 版:利用 iCloud Keychain 同步,在手表上快速查看常用密码。用
SecItemCopyMatching读取 Keychain 中存储的密码,配合textContentType实现自动填充。入口 API 是Security框架的 Keychain 函数。 -
做一个家庭共享的待办清单 Watch 应用:利用 Core Data + CloudKit,让家庭成员的 Apple Watch 之间同步待办事项。用
@FetchRequest读取数据,用NSPersistentCloudKitContainer自动同步。入口 API 是NSPersistentCloudKitContainer+@FetchRequest。 -
做一个运动数据同步应用:iPhone 端获取详细的运动路线数据,通过 Watch Connectivity 的
transferFile发送到 Watch,Watch 端在 complication 上展示简化版信息。入口 API 是WCSession.transferFile(_:metadata:)。 -
做一个后台数据上报工具:利用后台 URL Session,在手表充电且连接 Wi-Fi 时批量上报传感器数据到服务器。设置
isDiscretionary = true让系统选择最佳传输时机。入口 API 是URLSessionConfiguration.background+WKURLSessionRefreshBackgroundTask。 -
做一个即时消息 Watch 应用:利用 Watch Connectivity 的
sendMessage实现 iPhone 和 Watch 之间的实时消息同步。当用户在手机上收到消息时,立即推送到手表。入口 API 是WCSession.sendMessage(_:replyHandler:errorHandler:)。
关联 Session
- What’s new in watchOS 8 — watchOS 8 新特性,包括后台数据投递和蓝牙连接
- Connect Bluetooth devices to Apple Watch — 蓝牙设备与 Apple Watch 的连接和数据传输
- Build apps that share data through CloudKit and Core Data — 用 CloudKit 和 Core Data 在设备间同步数据
评论
GitHub Issues · utterances