😬

[TCA] TCAでもWatchConnectivityを使いたい

2024/12/15に公開

TCA Advent Calendar 2024の記事です。

要約

iPhoneとAppleWatch間でのデータの送受信には、WatchConnectivityを使うのが一般的。
TCAでWatchConnectivityを使用するためのSwiftPackageを作成した。
作成に至った動機と、その方法、また使い方について紹介する。

環境

  • Xcode: 16.1
  • TCA: 1.17.0
  • Swift: 6

背景

WatchConnectivityとは、iPhoneとAppleWatch間でデータを送受信するためのフレームワーク。
WatchConnectivityを利用することで、少ないコードでペアリングされたiPhoneとWatchの通信を実装できる。
https://developer.apple.com/documentation/watchconnectivity

動機

TCAでWatchConnectivityを使用したアプリを作ろうと思った。
WatchConnectivityでは、WCSessionDelegateを実装し、そのdelegateメソッドが呼ばれることで、データの送受信を行う。
TCAでは、発行したactionをReducerが受け取ることをきっかけにStateの更新が行われる。

WCSessionDelegateで受け取るイベントを、TCAのReducerとうまく組み合わす方法が必要だった。

作ったもの: ComposableWatchConnectivity

WatchConnectivityをTCAで使いやすくするためのSwiftPackageを作成した。
WatchConnectivityClientをTCAのDependencyとして作成している。
結果、ReducerでWCSessionDelegateのイベントを受け取ることができるようになった。

https://github.com/Ueeek/ComposableWatchConnectivity

実装

ComposableCoreLocationのPRを参考に、WatchConnectivityClientを作成した。
https://github.com/pointfreeco/composable-core-location/pull/29

ポイント

  • WacthConnectivityClientでは、AsyncStreamを使用して、WCSessionDelegateのイベントを購読する。

https://github.com/Ueeek/ComposableWatchConnectivity/blob/main/Sources/ComposableWatchConnectivity/ComposableWatchConnectivity.swift#L76-L78

  • WatchConnectivityClientにWCSessionDelegateを準拠させ、Delegate methodが呼ばれたときに、AsyncStreamにイベントを送信する。

https://github.com/Ueeek/ComposableWatchConnectivity/blob/main/Sources/ComposableWatchConnectivity/ComposableWatchConnectivity.swift#L164-L167

  • 使用するときは、ReducerでwatchConnectivityClientが発行するイベントを受け取る。

動作検証

サンプルプロジェクトを作成し、実装の例を示す。
Simulatorで動作を確認できるsendMessage()を使用した。

https://github.com/Ueeek/ComposableWatchConnectivitySample

App側

Reducer
実装
import ComposableArchitecture
import ComposableWatchConnectivity
import SwiftUI

enum CancelID {
    case watchConnectivity
}

@Reducer
struct MainReducer {
    @ObservableState
    struct State: Equatable {
        var message: String = ""
    }
    
    enum Action:  Sendable {
        case appearTask
        case sendCurrentDate
        case watchConnectivity(WatchConnectivityClient.Action)
    }
    
    @Dependency(\.watchConnectivityClient) var watchClient
    
    var body: some Reducer<State, Action> {
        CombineReducers {
            watchClientReducer
            Reduce { state, action in
                switch action {
                    case .appearTask:
                        return .run { send in
                            // WatchConnectivityのSessionを開始
                            await watchClient.activate()

                            await withTaskGroup(of: Void.self) { group in
                                group.addTask {
                                    // WCSessionDelegateのmethodを購読し、actionとしてsendする。
                                    await withTaskCancellation(id: CancelID.watchConnectivity, cancelInFlight: true) {
                                        for await action in await watchClient.delegate() {
                                            await send(.watchConnectivity(action), animation: .default)
                                        }
                                    }
                                }
                            }
                        }
                    case .sendCurrentDate:
                        // WatchClientを使用して、dataを送る。
                        state.message = "send Data'"
                        return .run { _ in
                            if let data = try? JSONEncoder().encode(Date.now) {
                                await watchClient.sendMessage(("date", data))
                            }
                        }
                    case .watchConnectivity:
                        return .none
                }
            }
        }
    }
    
    // WatchClientのactionは分かりやすいように別Reducerに切り分ける
    @ReducerBuilder<State, Action>
    var watchClientReducer: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
                    // WatchとのSessionのStatus. `watchClient.activate()`の結果。
                case .watchConnectivity(.activationDidCompleteWith(let status)):
                    let activatedStatus = status == .activated ? "activated" :"not activated"
                    state.message = "session " + activatedStatus
                    return .none
                    // `watchClient.sendMessage((key:val))`が失敗した時に呼ばれる。
                case .watchConnectivity(.sendFail(let error)):
                    state.message = "sendFail with \(error.localizedDescription)"
                    return .none
                default:
                    return .none
            }
        }
    }
}
View
実装
struct MainView: View {
    var store: StoreOf<MainReducer>
    
    var body: some View {
        VStack(spacing: 20) {
            VStack(alignment: .leading) {
                Text("Log:")
                Text("\(store.message)")
            }
            Button(
                    action: {
                    // WatchConnectivityClientでデータを送る
                    store.send(.sendCurrentDate)
                }, label: {
                    Text("Send CurrentDate to Watch")
                    
                }
            )
        }
        // WatchConnectivityClientの初期化とイベントの購読
        .task { await store.send(.appearTask).finish() }
    }
}

Watch側

Reducer
実装
import ComposableArchitecture
import ComposableWatchConnectivity
import Foundation
import SwiftUI

enum CancelID {
    case watchConnectivity
}

@Reducer
struct MainReducer {
    @ObservableState
    struct State: Equatable {
        var receivedData: String = ""
    }
    
    enum Action:  Sendable {
        case appearTask
        case watchConnectivity(WatchConnectivityClient.Action)
    }
    
    @Dependency(\.watchConnectivityClient) var watchClient
    
    var body: some Reducer<State, Action> {
        CombineReducers {
            watchClientReducer
            Reduce { state, action in
                switch action {
                    case .appearTask:
                        return .run { send in
                            await watchClient.activate()
                            await withTaskGroup(of: Void.self) { group in
                                group.addTask {
                                    await withTaskCancellation(id: CancelID.watchConnectivity, cancelInFlight: true) {
                                        for await action in await watchClient.delegate() {
                                            await send(.watchConnectivity(action), animation: .default)
                                        }
                                    }
                                }
                            }
                        }
                    case .watchConnectivity:
                        return .none
                }
            }
        }
    }
    
    @ReducerBuilder<State, Action>
    var watchClientReducer: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
                case .watchConnectivity(.didReceiveMessage(let message)):
                    // WatchClientを使用して、dataを受け取る
                    if let data = message?["date"] as? Data,
                       let receivedDate = try? JSONDecoder().decode(Date.self, from: data) {
                        state.receivedData = receivedDate.description
                    } else {
                        state.receivedData = "fail to parse"
                    }
                    return .none
                default:
                    return .none
            }
        }
    }
}
View
実装
struct MainView: View {
    var store: StoreOf<MainReducer>
    
    var body: some View {
        VStack(spacing: 20) {
            VStack(alignment: .leading) {
                Text("Received Date:")
                Text("\(store.receivedData)")
            }
        }
        .task { await store.send(.appearTask).finish() }
    }
}

動作

現在の日時をWatchに送信し、Watch側で受け取る。

参考リンク

https://developer.apple.com/documentation/watchconnectivity
https://zenn.dev/naoya_maeda/articles/d63504a860c36c

Discussion