♨️

TCA × Firestore入門(実装編)

2022/12/04に公開約10,200字3件のコメント

この記事は、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の実装をみてきます。

まず、インターフェースです。

Client.swift
public struct SaunaCurrentStatesClient {
    public var listen: (_ saunaID: String) async throws ->AsyncThrowingStream<SaunaCurrentStates, Error>
}

FirestoreDBをListenし、値をViewに渡すasync関数をもつ、変数listenを定義しました。
また、アプリ内で値を取り扱うためにCodableに準拠(Firestoreの値をマッピングする際に便利)したSaunaCurrentStatesというStructを定義している想定です。

listenの中身を見ていきましょう。

LiveKey.swift
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の中身はこんなかんじ。

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を用意します。

SaunaTemperatureView.swift
public struct SaunaTemperature: ReducerProtocol {
// State,Action,Reducer and Dependency
}

ReducerProtocolは2022年に導入されたプロトコルで、このプロトコルに準拠させることによって、従来とは違った新しい方法でReducerを作成できます。
SaunaTemperatureは、TCAではお馴染みの、State,Action,Reducerを持ちます。

Stateはとてもシンプルです。

SaunaTemperatureView.swift
    public struct State: Equatable {
        public init() {}
        let temperature: String = ""
    }

サウナ室の温度を取り扱うためのtemperature変数をもっているだけです。

Actionでは、listen と、Firestoreから流れてきた値を扱うlistenSaunaCurrentStatesResponseが定義されています。

SaunaTemperatureView.swift
    public enum Action: Equatable {
        case listen
        case listenSaunaCurrentStatesResponse(TaskResult<SaunaCurrentStates>)
    }

ここで出てくるのがTCAが提供している成功または失敗を表すTaskResult型です。
見た感じ、ジェネリックは成功時の型を1つだけ使用していますが、失敗した場合のジェネリックは、型付けされないErrorとして存在しています。従来の書き方だとResult型を使っていましたが、Swift Concurrencyのasyncなどは型付けされていないエラーしか表現できないため、この型が用意されています。

Dependencyと、各Actionの処理を実装するReducerを見ていきます。

SaunaTemperatureView.swift
    @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
            }
        }
    }

listenActionが呼ばれるとsaunaCurrentStatesClient.listenしてFirestoreDBの監視が始まります。.listenのstreamはAsyncSequenceに準拠したAsyncThrowingStreamなので、コールポイントはfor-await-in構文を使用して、ストリームによって生成される各resultインスタンスを処理しています。

最後にViewの実装です。

SaunaTemperatureView.swift
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のサンプルが参考になるはずです

https://github.com/pointfreeco/swift-composable-architecture/blob/cc535c3e37e4693e6db3a44e126b4bdf2ac69047/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-LongLiving.swift#L91

ご指摘ありがとうございます!
サンプルを参考にして、修正してみます🙏

.task

Adds an asynchronous task to perform before this view appears.

なるほど。勉強になりました!

ログインするとコメントできます