【SwiftUI】Watch Connectivity を利用して Apple Watch からバックグラウンドで iPhone にデータを送って画面更新する

## はじめに

iPhone でとある記録を取るアプリを作っていて，
Apple Watch でも記録取れると楽でいいよね〜ということになり，
機能実現できるかを簡単なサンプルアプリを SwiftUI で作って確認してみました。

使う技術は **Watch Connectivity** を使います。
以前同じような内容で記事書いたのですが，こちらは iPhone と Watch 側が
それぞれアプリを起動してアクティブであることを前提にしていました。

https://qiita.com/MilanistaDev/items/319e80033a185f31111e

ただ，実際に記録を取る際には急いでいたり，片手が塞がっていたりするため，
Watch 側で送信だけ行い iPhone 側のアプリを起動した際にデータを受信して
画面も更新する形の方が良さそうです。

今回は Apple Watch 側でバックグラウンドのデータ送信を行い，
iPhone 側でちゃんと受信して画面更新ができるかを確認します。

## Watch Connectivity

[Watch Connectivity](https://developer.apple.com/documentation/watchconnectivity) とは，iOS アプリと
そのペアの watchOS アプリの間での双方向通信を可能にする機能です。 

今回は，バックグラウンド送信を行うので
下記のメソッドの中で最適なものを選択します。

* [updateApplicationContext(_:)](https://developer.apple.com/documentation/watchconnectivity/wcsession/1615621-updateapplicationcontext)
  * `[String : Any]` の辞書型のデータ送信
  * 再送信時にデータを上書きするため最新データのみ送信される
* [transferUserInfo(_:)](https://developer.apple.com/documentation/watchconnectivity/wcsession/1615671-transferuserinfo)
  * `[String : Any]` の辞書型のデータ送信
  * キューにスタックされるため溜まった分のデータを送信
* [transferFile(_:metadata:)](https://developer.apple.com/documentation/watchconnectivity/wcsession/1615667-transferfile)
  * 画像またはファイルベースのドキュメントを送信する際に利用

今回は，辞書型で十分なデータでかつ，
リストのアニマルセルをタップした分だけ送信したいので
`transferUserInfo(_:)` メソッドを利用して
Watch アプリからデータを送信します。

受信する iOS アプリ側は `WCSessionDelegate` の[デリゲートメソッド](https://developer.apple.com/documentation/watchconnectivity/wcsessiondelegate/1615633-session)を利用します。

```swift
optional func session(_ session: WCSession, 
   didReceiveUserInfo userInfo: [String : Any] = [:])
```

## 実装イメージ

Watch 側のアニマルリストをタップすると・・・
選択されたアニマルの絵文字とテキストおよびタップ時の時刻を
iPhone 側にバックグラウンドで送信し，
iPhone のアプリ起動時に受信して画面更新するといったシンプルなものです。

![スクリーンショット 2022-02-14 17 14 56](https://user-images.githubusercontent.com/8732417/153825535-e13a3b2b-cc5b-4c5d-9a15-037faec5a065.png)

今回は受け取ったデータをリスト表示するだけですが，
実際のユースケースでは Realm などのデータベースに保存したりといった感じになると思います。

両方のアプリを起動してアクティブ状態でもリアルタイムでうまくいくので
前回の記事の内容の上位互換だなぁと思いました。

## 開発環境

* Xcode 13 以上
* iOS 15 以上
* watchOS 8 以上
* ペアリングした iPhone と Apple Watch

`NavigationView` の Large Title 無効の実装を変更すれば，
watchOS 7 ＆ iOS 14 でも動作します。

> Always test Watch Connectivity data transfers on paired devices. The Simulator app doesn’t support the transferUserInfo(_:) method.

[レファレンス](https://developer.apple.com/documentation/watchconnectivity/wcsession/1615671-transferuserinfo) にある通り，シミュレータでは動作確認ができません。
なので実機が必要です。

## サンプルコード

今回の実装は GitHub にプッシュしているので気になる方は確認お願いします。
動作確認をするのに実機が必要だったので，動画を撮ってます。
今回 GIF は載せないのでリポジトリの README を参照してみてください。

https://github.com/MilanistaDev/WatchConnectivitySampleForSwiftUI2

## 下準備

### データまわり

アニマルリスト用の構造体は下記の通りです。
`Data` 型に変換するため `Codable` にも準拠させておきます。
今回は静的なリストなため `Array` で表示用のアニマルリストを用意します。

```swift:Animal.swift
struct Animal: Hashable, Codable {
    var name: String
    var emoji: String
}

let animals: [Animal] = [
    Animal(name: "ネコ", emoji: "🐱"),
    Animal(name: "イヌ", emoji: "🐶"),
    Animal(name: "ハムスター", emoji: "🐹"),
    Animal(name: "ドラゴン", emoji: "🐲"),
    Animal(name: "ユニコーン", emoji: "🦄")
]
```

今回送信するデータ用の構造体は下記です。
ボタンタップ時のアニマル情報と時刻を格納します。
こちらも同様に `Codable` に準拠させます。

```swift:Record.swift
struct Record: Hashable, Codable {
    var animal: Animal
    var timeStamp: Date
}
```

### Watch App 画面実装

Watch 側のアニマルリスト表示の実装は下記です。
`List` を用います。Watch 専用の `CarouselListStyle`😍を使ってます。
ボタンタップ時の処理をこの後実装していきます。

```swift:AnimalListView.swift
import SwiftUI

struct AnimalListView: View {

    private let viewModel = AnimalListViewModel()

    var body: some View {
        List {
            ForEach(animals, id: \.self) { animal in
                Button {
                    // Send Animal
                    viewModel.transfer(animal: animal)
                } label: {
                    HStack(spacing: 16.0) {
                        Text(animal.emoji)
                            .font(.title)
                        Text(animal.name)
                    }
                    .padding(.vertical, 20.0)
                }
            }
        }
        .listStyle(CarouselListStyle())
    }
}
```

### iOS App 画面実装

受信したデータを `List` に展開する iPhone 側の実装は下記です。
受信してデータを更新する処理はこの後 ViewModel に実装していきます。

```swift:ReceiverView.swift
import SwiftUI

struct ReceiverView: View {
    
    @StateObject var viewModel = ReceiverViewModel()
    
    var body: some View {
        List {
            ForEach(viewModel.records, id: \.self) { record in
                VStack(alignment: .leading) {
                    Text(record.animal.emoji + record.animal.name)
                        .font(.body)
                        .padding(.vertical, 4.0)
                    Text(record.timeStamp.toString())
                        .font(.footnote)
                        .foregroundColor(.gray)
                }
            }
        }
    }
}
```

<details><summary>Date 型を String 型に変換するコード</summary><div>

```swift
extension Date {
    func toString() -> String {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        formatter.timeStyle = .medium
        formatter.locale = Locale(identifier: "ja-JP")
        return formatter.string(from: self)
    }
}
```

</div></details>

### ViewModel 実装(Watch 側)

`WCSession` を利用できるようにしておきます。
この後，データ送信用の実装を追加します。

```swift:AnimalListViewModel.swift
import WatchConnectivity

final class AnimalListViewModel: NSObject {

    private let session: WCSession
    
    init(session: WCSession  = .default) {
        self.session = session
        super.init()
        self.session.delegate = self
        session.activate()
    }

    /// タップされたアニマル名と絵文字，時刻を iPhoneに送信
    /// - Parameter animal: タップされたアニマル
    func transfer(animal: Animal) {
        // iPhone に送信する処理
    }
}

extension AnimalListViewModel: WCSessionDelegate {
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        if let error = error {
            print(error.localizedDescription)
        } else {
            print("The session has completed activation.")
        }
    }
}
```

### ViewModel 実装(iPhone 側)

`WCSession` を利用できるようにしておきます。
この後，データを受信してリスト更新をできるように実装していきます。
`records` に受信したデータを格納していく形になります。
よって受信するたびにリストが更新されるようになります。

```swift:ReceiverViewModel.swift
import WatchConnectivity

final class ReceiverViewModel: NSObject, ObservableObject {

    @Published var records: [Record] = []

    private let session: WCSession
    
    init(session: WCSession = .default) {
        self.session = session
        super.init()
        self.session.delegate = self
        session.activate()
    }
}

extension ReceiverViewModel: WCSessionDelegate {
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        if let error = error {
            print(error.localizedDescription)
        } else {
            print("The session has completed activation.")
        }
    }
    func sessionDidBecomeInactive(_ session: WCSession) { }
    func sessionDidDeactivate(_ session: WCSession) { }
}

```

## 本実装

### Watch 側の送信処理実装

`transfer` 関数内を実装します。
タップされたアニマルと時刻から `Record` のデータを生成し，
`Data` 型に変換して辞書に突っ込みます。
先ほどの `transferUserInfo(_:)` を利用して送信します。これだけです😮

```swift:AnimalListViewModel.swift
import WatchConnectivity

final class AnimalListViewModel: NSObject {

    private let session: WCSession
    
    init(session: WCSession  = .default) {
        self.session = session
        super.init()
        self.session.delegate = self
        session.activate()
    }
    
    /// タップされたアニマル名と絵文字，時刻を iPhoneに送信
    /// - Parameter animal: タップされたアニマル
    func transfer(animal: Animal) {
        let record = Record(animal: animal, timeStamp: Date())
        guard let data = try? JSONEncoder().encode(record) else {
            // Error handring if need
            return
        }
        let userInfo: [String: Any] = ["record": data]
        self.session.transferUserInfo(userInfo)
    }
}
```

### iPhone 側の受信・更新処理実装

受信側の処理を実装します。
`WCSessionDelegate` のデリゲートメソッドを実装します。
受信時に呼ばれる関数で `userInfo` のデータから
送信時と逆の手順で `Record` 型を取得します。
取得できたら `records` に追加します。
追加時にデータバインディングによってリストが更新されます。

```swift:ReceiverViewModel.swift
import WatchConnectivity

final class ReceiverViewModel: NSObject, ObservableObject {

    @Published var records: [Record] = []
    
    private let session: WCSession
    
    init(session: WCSession = .default) {
        self.session = session
        super.init()
        self.session.delegate = self
        session.activate()
    }
}

extension ReceiverViewModel: WCSessionDelegate {
    func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
        if let error = error {
            // Error handring if need
            print(error.localizedDescription)
        } else {
            print("The session has completed activation.")
        }
    }
    func sessionDidBecomeInactive(_ session: WCSession) { }
    func sessionDidDeactivate(_ session: WCSession) { }
    
    func session(_ session: WCSession, didReceiveUserInfo userInfo: [String : Any] = [:]) {
        guard let data = userInfo["record"] as? Data,
              let record = try? JSONDecoder().decode(Record.self, from: data) else {
            // Error handring if need
            return
        }
        self.records.append(record)
    }
}
```

今回は SwiftUI 版でしたが，UIKit 版で TableView などに
反映させるにはメインスレッドで更新してやる必要あります。
バックグラウンドで処理されるため明確にメインスレッドで画面更新が必要ってことですね。

## おわりに

今回は Apple Watch 側でバックグラウンドのデータ送信を行い，
iPhone 側でちゃんと受信して画面更新ができるかを確認しました。
`Notification` の送受信と同じテイストで
とても少ないコードで実現できるのでとても良いですね。

iPhone-Apple Watch 間だけではないのですが，
異なるデバイス間で通信成功したら謎に嬉しくなりますね。

ご覧いただきありがとうございました。