👏

SwiftUIでTCAのiOSアプリをつくってみた

2021/09/30に公開

前回の続きにして、アーキテクチャーを実装する記事としては最終回です。
この記事の後に、全体的なまとめを書こうと思います。

前回の記事

https://zenn.dev/st43/articles/91fd6558e311b3

参考

今までは「iOSアプリ設計パターン入門」を参考書にして進めてきたのですが、TCAはこの本の出版後に登場したアーキテクチャーなので、当然載っていません。
なので、基本的にはネット上の情報をベースにして学習を進めました。

https://qiita.com/yimajo/items/77c204ab091223f9cb14#publisherをeffectに変換するには内部でどうやってる

たぶんみんな入り口はイマジョーさんの↑の記事(or iOSDCの講演)がきっかけでこのアーキテクチャーを知ったのではないでしょうか。
この記事は素晴らしいんですが、所々話が飛躍しているところがあってついていけなかったり、あくまで紹介記事なのでこれ読んだら実装できるかというとそういう訳ではなかったりというのがありました。

https://qiita.com/zeero/items/b77cb689d9a707d94ac7

実装ベースだと↑の記事から入る方がいいかもしれません。

ただ結局僕の場合、何読んでもちゃんと腹落ちできなかったので、結局公式のサンプルをひたすら読み解いて、疑問が出たら適宜解説を漁るというスタイルで進めることになりました。
たぶん本気でこのアーキテクチャー使うなら、公式の(ちょっと長い)動画があるので、それを追ってくのが良さそうです。

環境

  • Xcode: 13.0
  • iOS: 15.0
  • Swift: 5.5

TCAとは

  • The Composable Architecture
  • 2020年にPoint-Freeのサービス提供者たちによって提案されたアーキテクチャーであり、ライブラリ

https://github.com/pointfreeco/swift-composable-architecture

  • 公式の説明文だと、「A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.」
    • 「コンポーネント志向で、テスト容易性があって、人間工学的に、統一された、わかりやすい方法でアプリがつくれるライブラリ」
    • この説明はちょっと抽象的な気がする
  • もっとざっくり説明するなら、Redux-likeなアーキテクチャーをSwiftUI/Combineを上手く使って実現したライブラリ
  • Composableというだけあって、ReducerをViewに応じて分割することが可能

Reduxとは

Zennに書いてる記事の中で、Reduxに触れなかったので、一応書いておきます。

  • 2015年にリリースされたOSS

https://github.com/reduxjs/redux

  • Flux + Elm
  • Fluxのアーキテクチャーを、副作用のない純粋関数によって実現するアーキテクチャーであり、ライブラリ
  • JavaScriptによる実装は318行というコード量だったため、軽量な記述でFluxの思想を実現している点でも注目された
  • iOSでReducを導入するのであれば、ReSwiftというOSSがある

Reduxのアーキテクチャ

  • Action: Viewから発行されるイベント通知
  • Dispatch: Actionを発行するStoreのメソッド。ViewがStoreインスタンス経由で発行する
  • Reducer: Actionを入力として、新しいStateを出力する純粋関数。オブジェクトっぽい名前で、Stateの変更を司る重要な存在だけど、関数であることに注意
  • State: Viewが依存する状態
  • MiddleWare: 図では省略されているが、StoreがReducerにActionを渡す前に実行する関数。副作用のある処理を書くことが許容されており、ネットワーク通信やロギングなどを行う。ActionCreatorもMiddleWareと見做して良い?
  • Store: ActionをDispatchする、MiddleWareを実行する、ActionをReducerに渡す、Reducerが新たに生成したStateをPublishする、など、Reduxのデーターフローの中で中心的な存在

TCAのアーキテクチャー

Reduxの思想をざっくりまとめたところで、TCAを見ていきましょう。
公式は構成図を提供してくれていないので、こちらのQiita記事から借用します。

Store, State, Actionあたりは同じ概念です。
Viewは直接Stateを更新できなくて、Actionを発行して、ReducerがStoreのStateを更新する、という流れは同じです。
ただTCAではViewStoreという特殊なStoreがいて、これがSwfitUIのViewに特化していて、使い勝手がいいです。

Environmentというのが説明が難しいんですが、API clientsやanalytics clientsを指定するところらしいです。
SWiftUIにも@Environmentという書き方がありますが、完全に別モノなので気をつけましょう。
今回の実装作業のときはありがたさを感じませんでしたが、テスト書くときにDIできたりで嬉しいんだと思われます。

Reduxに存在したMiddleWareはTCAにはないですが、代わりにEffectというもので副作用を表現します。
APIクライアントの戻り値をEffect型で表現して、あたかも純粋関数であるかのような感じで処理できます。

実装してみた

前置きはこのぐらいにして、実装してみました。

https://github.com/0si43/iOS-architecture-training/tree/TCA

今回の実装ですが、実装していたらだいぶしんどかったので、元々あった要件を削ぎ落として、Githubのユーザー検索のところだけにしました。

まずSwift Package Managerでswift-composable-architectureを落とします。
僕はSPM使うのはじめてだったんですが、CocoaPodsに比べると、Xcodeに自然にビルトインされているために、めちゃくちゃ簡単でした。
[File] -> [Add Package] から、swift-composable-architecture(Githubのリポジトリ名)を指定して、落とすだけです。

↓こんな感じで表示されるので、個人的には新鮮でした。

今までのアーキテクチャーのお勉強だと、自分で全部ファイル用意していたので、ライブラリが使えて楽……かと思ったらそんなこともなく、
逆にTCAの仕様をちゃんと理解できてなくて、ライブラリの中がブラックボックスになってしまって、だいぶ苦戦しました。

絵で頑張って説明してみる

毎回絵で説明してたので今回も描いたんですが、あんまりいい絵になりませんでした……

いつものようにアプリが起動すると、GithubSearcherAppが最初に呼ばれまして、そこからUserSearchViewを起動します。

@main
struct GithubSearcherApp: App {
    var body: some Scene {
        WindowGroup {
            UserSearchView(
                store: Store(
                    initialState: AppState(),
                    reducer: appReducer.debug(),
                    environment: AppEnvironment()
                )
            )
        }
    }
}

この時点でUserSearchViewにStoreのインスタンスを渡します。
StoreのイニシャルにはState, Reducer,Environmentの三つがいるので、それぞれ指定します。

UserSearchViewのbodyには、WithViewStoreという構造体を指定してあげます。
WithViewStore自体もSwiftUIのViewになっています。
限りなくメソッドみたいな雰囲気ですが、構造体です。

struct UserSearchView: View {
    let store: Store<AppState, AppAction>

    var body: some View {
        WithViewStore(self.store) { viewStore in
	// ...
    }
}

WithViewStoreのプロパティにはViewStoreがいて、まさに名前の通り、本来開発者が表示したいViewにwith ViewStoreを返すことができます。

@ObservedObject private var viewStore: ViewStore<State, Action>

View - ViewStore間はCombineを使ったPub/Sub関係になっております。
で、ViewはViewStoreに自分のstoreプロパティを渡します。
これ以降、Viewが直接storeを操作することはありません。

今回、ActionはAppActionというEnumを定義しました。
StoreにActionを通知するときはこんな感じです。

viewStore.send(.searchQueryEditing(viewStore.searchQuery))

これでStoreはReducerを起動します。
今回Reducerは一つだけで、appReducerという名前にしています。
Storeの実装はComposableArchitectureの中にあるので、開発者が書く必要はありません。
以前Fluxのコードを全部書いたことがありますが、そのときと比べると半分くらい実装がライブラリに吸収されている感じがします。

appReducerは重要な要素ではありますが、実装としては単なる関数(クロージャー)です。
今回のサンプルアプリでは活用できませんでしたが、combinepullbackを活用すると、Reducerを一まとめにできます。
ComposableArchitectureのComposableたるゆえんですね。

https://qiita.com/yimajo/items/77c204ab091223f9cb14#reducer

詳しく知りたい方は↑を見てください。

ReducerはState, Action, Environmentの三つを指定します。

  public init(_ reducer: @escaping (inout State, Action, Environment) -> Effect<Action, Never>) {
    self.reducer = reducer
  }

Environmentの中にAPIクライアントは持たせるみたいです。
戻り値がEffect<Action, Never>になってますが、Reducerの実行後に別のActionを実行したい場合はなんかしら返します。

case let .searchQueryEditing(query):
    struct SearchUserId: Hashable {}

    return environment.githubApi
        .users(query)
        .receive(on: environment.mainQueue)
        .catchToEffect(AppAction.response)
        .cancellable(id: SearchUserId(), cancelInFlight: true)

実際のコードはこんな感じになります。
メソッド一つ一つの意味を正確に説明できるほど理解できてないです。
この鬼のメソッドチェーンはしんどく感じました。

そういえば、Flux書いたときのActionは、純粋関数で書かないといけない縛りがなかったので、userSearchで1 Actiionだったんですけど、
Redux/TCAになると、2 Actionにしないといけないことに気づきました。

case let .response(.success(users)):
    state.users = users
    return .none
case let .response(.failure(error)):
    print(error)
    return .none
}

後続のActionがない場合は、.noneを返せばOKです。
こっちはイージーですね。

ViewStore - Storeも、StateについてPub/Subでつながってるので、State更新によって、ViewStoreが検知して、Viewの更新がかかります。

長大な説明になりましたね……けどそういうことです。

サンプル通りにつくる

前述の通り、解説読んでも実装できる気がしなかったので、ひたすらサンプルを掘りました。

https://github.com/pointfreeco/swift-composable-architecture/tree/main/Examples/Todos

まずTCAのライブラリの使い方としては、これがシンプルでわかりやすかったです。
ただこのサンプル、API叩いてなかったので、

https://github.com/pointfreeco/swift-composable-architecture/tree/main/Examples/Search

これを見ました。

API叩くところがシンプルに難しかったです。
リアクティブプログラミングの知識がないので、メソッドチェーンがしんどくてしんどくて……

結局公式ほぼパクリで実装したAPIクライアントがこんな感じです。

import Foundation
import ComposableArchitecture

struct GithubApi {
    var users: (String) -> Effect<[User], ModelError>
}

extension GithubApi {
    static let live = GithubApi(
        users: { query in
            var components = URLComponents()
            components.scheme = "https"
            components.host = "api.github.com"
            components.path = "/search/users"
            components.queryItems = [URLQueryItem(name: "q", value: query)]

            return URLSession.shared.dataTaskPublisher(for: components.url!)
                .map { data, _ in data }
                .decode(type: Users.self, decoder: JSONDecoder())
                .mapError { error in ModelError.jsonParseError(error.localizedDescription) }
                .map { return $0.items }
                .eraseToEffect()
        }
    )
}

本当はModelErrorのそこそこちゃんと整えたエラー分類があったんですが、どう扱ったらいいのかわからなくなったので、Json Parse Errorしか扱えてません。

まとめ

というわけでTCAでした。
「SwiftUI時代のiOSアプリのアーキテクチャー」という触れこみで期待してたんですが、いざ触ってみると結構僕には合わないと感じました……
RxSwiftやってた人にはたぶん肌が合うんだと思うんですが……
(だからイマジョーさんは気に入ってるんでしょうか)

そもそもReduxの時点であんまり好きじゃなかったので、TCAも同じ感じでした。
リアクティブプロラミングのところは抜きにしたとしても、個人的には冗長感が強いんですよね。
うーん、慣れの問題なんでしょうか。

いいところに関しては、スケールしそうな仕組みだとは思います。
あとライブラリとして完成度が高いので、自前でガリガリ書かなくてもスケーラビリティのあるアーキテクチャーを入れられるのは魅力だなと思いました。
あとはTCA自体はアレだったとしても、SwiftUI/Combineをフル活用した設計として、別のアーキテクチャーを考える際の参考になるかもしれません。

Discussion