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

There and back again: Data transfer on Apple Watch

元の動画を見る

ハイライト

Apple Watch はますます独立しています——Series 3 のセルラー、watchOS 6 のスタンドアロン App、watchOS 7 のファミリー設定まで。このセッションでは iCloud Keychain、Core Data + CloudKit、Watch Connectivity、URL Session、Socket の 5 つのデータ転送方式を体系的に整理し、それぞれの適用シーンと選択の決定木を示します。

主要内容

転送方式を選ぶ前の 5 つの質問

02:08

データ転送の方法を決める前に、次の 5 つを自問してください。

  1. データの種類は?機密情報、構造化データ、大きなファイル?
  2. データは今どこにあり、どこへ送る必要がある?
  3. ペアリングされた iOS App に依存するか?
  4. ファミリー設定をサポートする必要があるか?
  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 トークンなどカスタムデータを保存する場合は Keychain API を直接操作します。kSecAttrSynchronizabletrue に設定すれば全デバイスに同期されます。

Core Data + CloudKit: 構造化データのクラウド同期

11:10

Core Data と CloudKit を統合すると、ローカルデータベースが同じ CloudKit コンテナを共有する全デバイスに同期されます。SwiftUI の @FetchRequest@Environment(\.managedObjectContext) で Watch App でも Core Data を簡単に使えます。

11:59

Watch のストレージとバッテリーは限られています。クロスプラットフォーム App では、本当に時計上に必要なデータを慎重に選んでください。複数の Core Data 設定で、時計向けとスマホ向けのデータを分けられます。

Core Data + CloudKit は iOS コンパニオン App に依存せず、ファミリー設定をサポートします。同期タイミングはネットワークや電池などに基づきシステムが決定するため、即時同期は期待しないでください。

Watch Connectivity: ペアデバイス間のリアルタイム通信

13:19

Watch Connectivity は Bluetooth 範囲内または同一 Wi-Fi で Watch と iPhone 間のデータ転送を行います。両端に App があるときの UX 最適化や、片方にしかないデータの共有に最適です。

14:38

Watch Connectivity のベストプラクティス:

  • セッションは早く有効化する。できれば App 起動時に
  • すべての 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 が最適です。フォアグラウンドとバックグラウンドの 2 モードがあります。

バックグラウンド URL Session を優先してください。App をフォアグラウンドに保つ必要がなく、システムが適切なタイミングで転送を実行します。大きなファイルでは isDiscretionary = true を設定し、最適なタイミングをシステムに任せます。

23:04

バックグラウンド転送完了時、システムは WKURLSessionRefreshBackgroundTask で App を起動します。Extension Delegate で処理し、速やかに完了をマークしてください。

フォアグラウンド URL Session は当日のコンテンツ一覧取得など素早い通信向けです。ただし 2.5 分のタイムアウトがあり、消費電力も大きいです。

詳細

Keychain による OAuth トークン保存の完全フロー

06:25

トークンを保存(存在すれば更新、なければ追加):

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 でインターネットパスワード型を指定
  • kSecAttrSynchronizabletrue にして 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 が成功を示す

トークンの読み取り:

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

キーポイント:

  • kSecReturnDatatrue にするとデータ内容を返す
  • SecItemCopyMatching で一致項目を検索してコピー
  • errSecItemNotFound はトークンが保存されていないことを意味する
  • JSONDecoder で Data を OAuth2Token にデコード

トークンの削除:

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 でタスク完了時に App を起動
  • 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 を見つけたらバックグラウンドタスクを保存し、ダウンロード完了後に完了マーク
  • 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 を削除
  • 完了マークが遅れると App がシステムにより終了される可能性がある

転送方式選択のまとめ

30:21

シーン推奨方式ファミリー設定
少量の機密データ、全デバイス同期iCloud Keychain
構造化データベース、全デバイス同期Core Data + CloudKit
ペア iPhone + Watch の UX 最適化Watch Connectivity不可
サーバーとの直接通信URL Session / Socket
リアルタイム双方向(両端フォアグラウンド)Watch Connectivity sendMessage不可

重要ポイント

  • クロスデバイスパスワードマネージャーの Watch 版を作る: iCloud Keychain 同期で時計上からよく使うパスワードを素早く確認。SecItemCopyMatching で Keychain のパスワードを読み取り、textContentType で自動入力。入口 API は Security フレームワークの Keychain 関数。

  • ファミリー共有の ToDo Watch App を作る: Core Data + CloudKit で家族の Apple Watch 間で ToDo を同期。@FetchRequest で読み取り、NSPersistentCloudKitContainer が自動同期。入口 API は NSPersistentCloudKitContainer + @FetchRequest

  • 運動データ同期 App を作る: iPhone 側で詳細なルートデータを取得し、Watch Connectivity の transferFile で Watch に送信。Watch 側は complication に簡略版を表示。入口 API は WCSession.transferFile(_:metadata:)

  • バックグラウンドデータ報告ツールを作る: バックグラウンド URL Session で、時計が充電中かつ Wi-Fi 接続時にセンサーデータを一括アップロード。isDiscretionary = true で最適な転送タイミングをシステムに任せる。入口 API は URLSessionConfiguration.background + WKURLSessionRefreshBackgroundTask

  • インスタントメッセージ Watch App を作る: Watch Connectivity の sendMessage で iPhone と Watch 間のリアルタイムメッセージ同期。スマホでメッセージ受信時に即座に時計へプッシュ。入口 API は WCSession.sendMessage(_:replyHandler:errorHandler:)

関連セッション

コメント

GitHub Issues · utterances