TCA × Firestore入門(実装編)
この記事は、The Composable Architecture Advent Calendar 2022 12/5の記事です。
1.はじめに
アマゾネスです。この季節がやってまいった🎄
今年はiOSエンジニアとしてどっぷりTCA書いてるので、TCAネタいきます。
この記事では、Firestoreを使用したTCAの実装方法を紹介します。記事を書いてると段々長くなってきたので、「実装編」と「テスト編」の二部構成でいかせていただきやす🙏
この記事は「実装編」です。
この記事でわかること
- Firestoreでできること
- TCAでFirestoreをつかったアプリを実装する基本的な流れ
- ReducerProtcolに準拠したTCAの実装
- SwiftConcurrencyを使ったTCAの実装
目次
1.はじめに
2.Firestore概要
3.iOS側の実装
- 登場モジュール
- Clientモジュール
- Featureモジュール
4.まとめ
2.Firestore概要
Cloud Firestore は、モバイルアプリやウェブアプリのデータの保存、同期、照会がグローバルスケールで簡単にできる NoSQL ドキュメント データベースです。
Firebase公式
Firestoreは、データベースの変更をリアルタイムにクライアントに伝える仕組みを提供しています。
私は現在HomeControllerというスマートホームアプリの開発を行なっています。その中では、例えば「サウナ室の温度」をリアルタイムでアプリに反映するためにFirestoreを使用しています。
簡単な流れとしては、アプリ側からFirestoreDBをListenし、DBの値に変更があったら、それがアプリ側に流れてきて画面を変更する、というような感じです。
3.iOS側の実装
サウナの室内温度をリアルタイムで表示するアプリを想定して、実装について説明していきます🧖
登場モジュール
一つのアプリを作るためにはいろんなモジュールが必要ですが、今回は「サウナの室内温度をリアルタイムで表示する機能」だけにフォーカスしていきます。本記事で登場するモジュールは以下の2つです。
-
SaunaCurrentStateClient
FirestoreDBをListenし、値をViewに渡す役目を持つモジュールです。
TCAだと、Dependencyの立ち位置になります。 -
SaunaSettingFeature
SaunaCurrentStateClientから渡された値を実際に表示する役目をもつモジュールです。
TCAのState、Action、Reducer、Storeを含むモジュールです。
Clientモジュール
SaunaCurrentStateClientの実装をみてきます。
まず、インターフェースです。
public struct SaunaCurrentStatesClient {
public var listen: (_ saunaID: String) async throws ->AsyncThrowingStream<SaunaCurrentStates, Error>
}
FirestoreDBをListenし、値をViewに渡すasync関数をもつ、変数listen
を定義しました。
また、アプリ内で値を取り扱うためにCodableに準拠(Firestoreの値をマッピングする際に便利)したSaunaCurrentStates
というStructを定義している想定です。
listen
の中身を見ていきましょう。
extension SaunaCurrentStatesClient: DependencyKey {
public static let liveValue = Self(
listen: { saunaID in
AsyncThrowingStream { continuation in
let listener = Firestore.firestore().collection("sauna")
.whereField(.documentID(), in: saunaID)
.addSnapshotListener { querySnapshot, error in
if let error {
continuation.finish(throwing: error)
}
if let querySnapshot {
do {
let states = try querySnapshot.data(as: SaunaCurrentStatesClient.self)
continuation.yield(states)
} catch {
continuation.finish(throwing: error)
}
}
}
continuation.onTermination = { @Sendable _ in
listener.remove()
}
}
}
)
}
最後にTestKey.swift
の中身はこんなかんじ。
extension DependencyValues {
public var SaunaCurrentStatesClient: SaunaCurrentStatesClient {
get { self[SaunaCurrentStatesClient.self] }
set { self[SaunaCurrentStatesClient.self] = newValue }
}
}
extension SaunaCurrentStatesClient: TestDependencyKey {
public static let testValue = Self(
listen: unimplemented("\(Self.self).listen")
)
public static let previewValue = Self.noop
}
extension SaunaCurrentStatesClient {
public static let noop = Self(
listen: { _ in .never }
)
}
ここでは、 Swift ConcurrencyのAsyncThrowingStream
と、TCAのDependency
が重要になってきます。
AsyncThrowingStream
この記事はTCAにフォーカスしているので、AsyncThrowingStreamについては少しだけ説明させていただきます。
AsyncThrowingStreamはAsyncSequenceに準拠しているので、まずはAsyncとAsyncSequenceの違いについて見てみます。
The listPhotos(inGallery:) function in the previous section asynchronously returns the whole array at once, after all of the array’s elements are ready. Another approach is to wait for one element of the collection at a time using an asynchronous sequence. Asynchronous Sequences
通常のasync関数だと非同期処理が成功もしくはエラーで完了した後、値を一つだけ返します。
AsyncSequenceを使うと、非同期的に反復して値を返すことができます。つまり、Sequenceのasync/await対応版です。実際の処理の流れは、各要素でsuspendし、基礎となるイテレータが値を生成するか、スローしたときにresumeする、という感じになります。
An asynchronous sequence generated from an error-throwing closure that calls a continuation to produce new elements.
AsyncThrowingStream
AsyncThrowingStreamを使うと、独自の非同期ストリームを作ることができます。
LiveKey.swift
の実装をみると、
AsyncThrowingStream { continuation in
値を複数回生成、終了、中断を行うcontinuation
を受け取り、
continuation.yield(data)
Firestoreから値が流れてきたら、その値をyieldで返し、
continuation.finish(throwing: error)
Firestoreでエラーが起こったら、エラーをthrowし、終了しています。
continuation.onTermination = { @Sendable _ in
listener.remove()
}
また、非同期ストリームが終了したときは、上記のようにハンドリングしています。ここでは、Firestoreのリスナーをデタッチして、イベントコールバックが呼び出されないようにしています。
このようにして、リアルタイムに更新されるDBの値を、Viewに伝えるための実装を行なっています。
雑談☕️
Swift Concurrency苦戦してますか?私は大苦戦中です😭
実際のアプリに実装した際も、大分調べまくって時間かかりました。初期投資だよ..ね💰
Dependency
後述するSaunaSettingFeature内にでてくるのですが、TCAで依存関係を注入するときは、以下のように、依存関係にアクセスするためのプロパティラッパーである@Dependency
を使用します。
@Dependency(\.saunaCurrentStatesClient) private var SaunaCurrentStatesClient
TCAで実装する場合、依存関係はTCAのライブラリ内にあるDependencyValuesに格納されていて、このプロパティラッパーを使用してアクセスします。
SaunaCurrentStatesClient
のような独自の依存関係も、ライブラリに登録して扱うために、
DependencyKeyプロトコルが用意されています。登録するやり方はとてもシンプルです。TCAのDependencyはSwiftUIのEnvironmentValuesからインスパイアされたようなので、登録の方法も似ています。
まず、DependencyKeyに準拠するTypeを作成します。Client.swift
がまさにそれです。
次に、依存関係のcomputed propertyを公開するためにextensionを作成します。それが、TestKey.swift
の冒頭に記載した処理の以下の部分です。
extension DependencyValues {
public var SaunaCurrentStatesClient: SaunaCurrentStatesClient {
get { self[SaunaCurrentStatesClient.self] }
set { self[SaunaCurrentStatesClient.self] = newValue }
}
}
TestDependencyKey
はテストで使用するための依存関係の登録になります。詳細についてはThe Composable Architecture Advent Calendar 2022 12/9の記事をお楽しみに..!
以上で登録完了です。
これで、プロパティーラッパーを使用して、任意Featureモジュールから必要な依存関係にアクセスできるようになります。シンプルですよね。こうやって、依存関係の管理もTCAライブラリに含まれていることも、TCAを導入するモチベーションの一つになるのではないかと思います😄
Featureモジュール
SaunaSettingFeatureを見ていきましょう。
まず、ReducerProtocol
に準拠したStructであるSaunaTemperature
を用意します。
public struct SaunaTemperature: ReducerProtocol {
// State,Action,Reducer and Dependency
}
ReducerProtocol
は2022年に導入されたプロトコルで、このプロトコルに準拠させることによって、従来とは違った新しい方法でReducerを作成できます。
SaunaTemperatureは、TCAではお馴染みの、State,Action,Reducerを持ちます。
Stateはとてもシンプルです。
public struct State: Equatable {
public init() {}
let temperature: String = ""
}
サウナ室の温度を取り扱うためのtemperature
変数をもっているだけです。
Actionでは、listen
と、Firestoreから流れてきた値を扱うlistenSaunaCurrentStatesResponse
が定義されています。
public enum Action: Equatable {
case listen
case listenSaunaCurrentStatesResponse(TaskResult<SaunaCurrentStates>)
}
ここで出てくるのがTCAが提供している成功または失敗を表すTaskResult
型です。
見た感じ、ジェネリックは成功時の型を1つだけ使用していますが、失敗した場合のジェネリックは、型付けされないError
として存在しています。従来の書き方だとResult型を使っていましたが、Swift Concurrencyのasyncなどは型付けされていないエラーしか表現できないため、この型が用意されています。
Dependencyと、各Actionの処理を実装するReducerを見ていきます。
@Dependency(\.saunaCurrentStatesClient) private var saunaCurrentStatesClient
public var body: Reduce<State, Action> {
Reduce { state, action in
switch action {
case .listen:
return .run { send in
for try await result in try await self.saunaCurrentStatesClient.listen("aufguss") {
await send(.listenSaunaCurrentStatesResponse(.success(result)))
}
} catch: { error, send in
await send(.listenSaunaCurrentStatesResponse(.failure(error)))
}
case let .listenSaunaCurrentStatesResponse(.success(states)):
state.temperature = states.temperature
return .none
case let .listenSaunaCurrentStatesResponse(.failure(error)):
return .none
}
}
}
listen
Actionが呼ばれるとsaunaCurrentStatesClient.listen
してFirestoreDBの監視が始まります。.listen
のstreamはAsyncSequenceに準拠したAsyncThrowingStreamなので、コールポイントはfor-await-in構文を使用して、ストリームによって生成される各resultインスタンスを処理しています。
最後にViewの実装です。
public struct SaunaTemperatureView: View {
public init(store: StoreOf<SaunaTemperature>) {
self.store = store
}
let store: StoreOf<SaunaTemperature>
public var body: some View {
WithViewStore(self.store) { viewStore in
VStack {
Text(viewStore.temperature)
}
.task { await viewStore.send(.listen).finish() }
}
}
}
Viewは従来のTCAの書き方とほとんど変わらないですが、ReducerProtcolを使用しているので StoreOf<R: ReducerProtocol>
を使用して、storeを作っています。
以上で、「サウナの室内温度をリアルタイムで表示する機能」が実装できました🎉
4.まとめ
「TCA × Firestore入門(実装編)」いかがでしたでしょうか?
Firestoreはクライアント向けの公式ドキュメントも充実しているので、どなたでも使用して面白いアプリを作ることができそうな気がします。
TCAは、ReducerProtcolやSwift Concurrencyの登場で、よりシンプルで強力なライブラリに進化していると感じます。ただ、学習コストは結構高い印象があります。Reactive Programmingで「うわぁー!」となっていたあの頃を思い出す日々です。毎日知識欲に満たされている!!
来年も、沢山勉強しないといけない。では、良いお年を🍺
...いや、まだ私のアドベントカレンダーは終わらない。
とりあえず、実装編、完。
Discussion
説明のためのコードなので省略されてるのかもしれませんが、イベントリスナーを登録するような場合、SwiftUI.Viewの
onAppear
でやるのではなくtask
でやるほうがおすすめだなと思いました。TCAのLongLivingEffectsのサンプルが参考になるはずです
ご指摘ありがとうございます!
サンプルを参考にして、修正してみます🙏
.task
なるほど。勉強になりました!