[TCA] TCAでもWatchConnectivityを使いたい
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の通信を実装できる。
動機
TCAでWatchConnectivityを使用したアプリを作ろうと思った。
WatchConnectivityでは、WCSessionDelegate
を実装し、そのdelegateメソッドが呼ばれることで、データの送受信を行う。
TCAでは、発行したactionをReducerが受け取ることをきっかけにStateの更新が行われる。
WCSessionDelegateで受け取るイベントを、TCAのReducerとうまく組み合わす方法が必要だった。
作ったもの: ComposableWatchConnectivity
WatchConnectivityをTCAで使いやすくするためのSwiftPackageを作成した。
WatchConnectivityClientをTCAのDependencyとして作成している。
結果、ReducerでWCSessionDelegateのイベントを受け取ることができるようになった。
実装
ComposableCoreLocationのPRを参考に、WatchConnectivityClientを作成した。
ポイント
- WacthConnectivityClientでは、AsyncStreamを使用して、WCSessionDelegateのイベントを購読する。
- WatchConnectivityClientにWCSessionDelegateを準拠させ、Delegate methodが呼ばれたときに、AsyncStreamにイベントを送信する。
- 使用するときは、ReducerでwatchConnectivityClientが発行するイベントを受け取る。
動作検証
サンプルプロジェクトを作成し、実装の例を示す。
Simulatorで動作を確認できるsendMessage()
を使用した。
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側で受け取る。
参考リンク
Discussion