WWDC Quick Look 💓 By SwiftGGTeam
There and back again: Data transfer on Apple Watch

There and back again: Data transfer on Apple Watch

观看原视频

Highlight

Apple Watch 越来越独立——从 Series 3 的蜂窝网络、watchOS 6 的独立 App,到 watchOS 7 的家庭设置。这场 Session 系统梳理了五种数据传输方案:iCloud Keychain、Core Data + CloudKit、Watch Connectivity、URL Session 和 Socket,并给出了每种方案的适用场景和选择决策树。

核心内容

选择传输方案的五个问题

02:08

在决定用什么方式传输数据之前,先问自己五个问题:

  1. 数据是什么类型?敏感信息、结构化数据、还是大文件?
  2. 数据现在在哪,需要送到哪?
  3. 是否依赖配对的 iOS 应用?
  4. 是否需要支持家庭设置(Family Setup)?
  5. 数据需要多快到达目的地?

根据答案,可以从工具箱里选出最合适的方案。

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。关键是设置 kSecAttrSynchronizabletrue,这样数据才会同步到所有设备。

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

评论

GitHub Issues · utterances