21
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

The Composable ArchitectureAdvent Calendar 2022

Day 25

[TCA]推しがv1.0にいってくれたらしぬ

Last updated at Posted at 2022-12-25

はじめに

この記事では、わたくしの推しOSSであるThe Composable Architecture(TCA)のバージョン1.0への道について、本家Discussionsに書かれていたことを軽く解説したり、これまでのバージョンを振り返ったりしてみます。

本題

なぜまだv1.0ではないのか

v0.42.0の現在、v1.0へと至るロードマップのようなものがGitHub上のDiscussionsでRoad to 1.0というタイトルですでに述べられています。

上記を読むと、

We’ve held off on 1.0 for this long not because we believe the library is in beta/unstable territory, but because there were a few key problem areas of library use that came up again and again, and we wanted to provide the nicest built-in solutions to them before considering the Composable Library a “complete” package.

  • ベータだからv1未満なのではない
  • 不安定だからv1未満なのではない
  • ライブラリの使用に関するいくつかの重要な問題領域が何度も出てくる(?)
  • 上記問題に対して最も優れた解決策として提供したいから

目の前に課題が見えていて、それを解決する前にv1.0を名乗れねーやという感じなんでしょうきっと。

具体的に足りないものはNavigation

TCAにはすでにNavigationのサンプルはあるがそれに満足できていない、というのが理由だそうです。そもそも通称SwiftUI 4(iOS 16)からはNavigationStackがあるので、それを取り入れたものにしたい模様です。

実際にブランチを見てみる

まだベータなので変更されるかもしれませんが、GitHubにはnavigationというブランチがpushされているのでそれを見てみましょう。

言うまでもないですが、これはWIPでありv1.0リリース時には全然違ったものになることもあるでしょう。そもそもnavigationブランチがプロトタイプなのかもよくわかりませんがまあそれっぽいので選びました。

まず注目すべきは次の2つです

  • NavigationStackStore
  • navigationDestination

NavigataionStackStore

SwiftUI 4から新しく追加されたNavigationStackは、.navigationDestinationモディファイアを使って遷移先を宣言的に書けるようになっていました。

TCAではNavigationStackを扱うNavigationStackStoreにStoreを渡し、Reducerを遷移先に指定します。メリットとしては遷移元がListを使い遷移先をその詳細にする場合、それをReducerの合成でつなげることができます。そのために、遷移先のアクションを遷移元で処理したりすることもできます(遷移先から強制的に戻るなどに使う)。また、遷移自体のアクションのpushやpopを取得できます。

navigationDestination

DesitnationsもReducerProtocolとして既存の作りでenumで作られています。
navigataionDestinationはNavigationStackStoreを使わずとも、NavigataionView@PresentationStateで使うことができます。ここらへんの遷移の話は、対応OSのバージョンのこともあり、やりかたの量が多いので正式版が出てから別途整理するほうが良いはずです。

型名の変更

v1.0への道はNavigationだけではないのです。

v1.0になる際に変更される型名についてもDisussionsで述べられています。なぜ型名を変更するかと言うと古来のCombineのやり方を徐々に互換性を気にしつつリネームしてきたから、それをv1.0でSwift Concurrency利用の現在のやり方にあわせ、シンプルな名前にしたいからでしょう。

解説する上で、彼らのロードマップの内容としては時系列に書かれているので途中の変更予定などについては複雑に見えなくもありません。なので、それは端折って最終的にどうなるのかについて書いておきます。

Reducer<State, Action>

  • なに?
    • v0.39.0以降から導入されたプロトコル protocol ReducerProtocol<State,Action>Reducer<State, Action>になる
  • なぜ?
    • v0.39.0未満ではすでにstruct Reducerが使われていたため、末尾にProtocolと付けてReducerProtocolを作っていた
      • v0.39.0未満のstruct ReducerはすでにAnyReducerとエイリアスがあるが、まだ互換性のためReducerをすぐには使えない
  • SPIとして隠しつつ残す
    • AnyReducerを使いたい場合はSPIとして利用することは可能です
      • @_spi(AnyReducer) import ComposableArchitecture

Effect<Action>

  • なに?
    • 主/副作用実行の結果(Reducerが外部とやりとりしシステムにフィードバックを送る型)
  • なぜ?
    • 現状のEffect<Action, Never>は(おそらくFailure側がNeverだから必要がないので)Actionのみとなる
    • 現状のEffect<Action, Error>はCombine.Publisherなので(Swift Concurrency前提なシステムではCombine必要ないため)
  • SPIとして隠しつつ残す
    • Combineを使いたい場合はEffectPublisher<Action, Failure>を使える
      • @_spi(EffectPublisher) import ComposableArchitecture

TCAのバージョンアップを勝手に振り返る

これで終わりだ、間違いもあるかもしれないのでそのときは教えて下さい、と思ったんですがもう少し書いておきます。

TCAの良いところは自由度が高く、バージョンアップによって予想以上の改良が加わるところだと感じています。そんなバージョンアップを振り返ってみます。

Swift Conncurrency

TCAのベースがCombineからSwift Concurrencyに対応しました。副作用を実行するClientは下記のように作れるようになったわけです。

struct NumberFactClient {
  var fetch: (Int) async throws -> String
}

ただClientがSwift Concurrencyなプロパティを使うこと自体は無理だったわけではなく、asyncな結果をCombine.Publisherに変換するだけでTCAを使いつつSwift Concurencyを使えたのでそれでいいかと思ってました。

しかし実際、TCAにSwift Concurrencyが組み込まれると、もちろんその良さはそのままで、SwiftUI.Viewのtaskとのキャンセル連携も凄まじく良くなりました。具体的に以下はLongLivingEffectsというサンプルコードで、ユーザーがスクリーンショットを撮るイベントを見張る例です。

ユーザーイベントを見張るのですが、その機能がオフにしてもいい状態では見張りを辞めなくてはいけません。なぜならユーザーのiPhoneの電池を無駄に消耗するのを避けるためでもあります。誰でもモバイル端末を持っているユーザーだからわかるとは思いますが、無駄に処理が動いているのは本当にダメです。

SwiftUIの.taskはAppleのドキュメントから

Use this modifier to perform an asynchronous task with a lifetime that matches that of the modified view. If the task doesn’t finish before SwiftUI removes the view or the view changes identity, SwiftUI cancels the task.

  • SwiftUIのView削除されたらキャンセル(Taskのキャンセルイベント)を送る
  • IDを変更する前にタスクが終了しない場合キャンセル(IDを指定しなかったら関係ない)

SwiftUIのTaskはdetacheされずにネストしたTaskがあれば、キャンセルが伝搬してくるのでSwiftUIからReducerへキャンセルを呼び出してくれるというわけです。

これによってViewが非表示になった場合にはTaskをキャンセルできます。逆に言うと、これがないとonAppearで有効化した監視はonDisapperなどで無効化しないといけないわけです。そもそもSwiftUI.ViewのonAppearがまともなタイミングで動くか?というとそうでもないことがiOSのバージョンではありましたから、結果かなり実用的に使えるようになったと思います。SwiftUIもたいがいだな。

ReducerProtocol

さらにReducerProtocolはStateとActionをassociatedtypeで持つようになり、利用時にはStateとActionをセットで指定することになりました。

struct Feature: ReducerProtocol {
    struct State: Equatable {
      var count = 0
    }
    enum Action {
      case decrement
      case increment
    }
    func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
      switch action {
      case .decrement:
        state.count -= 1
        return .none
      case .increment:
        state.count += 1
        return .none
    }
}

それまではStateとActionは別々に定義できてしまって、FooFeatureStateなど長めの型名になるため、enumでくくることをやったりしていました。

enum Feature {
    struct State: Equatable {
      var count = 0        
    }

    enum Action: Equatable {
      case decrement
      case increment    
    }

    struct Environment { 
    }
    
    static let reducer = Reducer<State, Action, Environment> { state, action, _ in
        switch action {
      case .decrement:
        state.count -= 1
        return .none
      case .increment:
        state.count += 1
        return .none
      }
    }
}

しかしこういう自前の工夫よりも、TCAのReducerProtocolはProtocolであるためにStateとActionそしてreducerメソッドの実装を強要できるわけです。

DI管理

DI管理もEnvironmentのバケツリレーがめんどくさかった&バケツリレーのために中間Reducerに必要ない依存が発生していたのですが、これがDepndenciesの導入によって解決されました。

開発者からはReducerProtocolにプロパティラッパーによる宣言を増やすこと、そしてそれ用にliveとして初期化することで使えます。

struct Feature: ReducerProtocol {
  struct State {  }
  enum Action {  }
  @Dependency(\.numberFact) var numberFact
  
}

extension NumberFactClient: DependencyKey {
  static let liveValue = Self(
    fetch: { number in
      let (data, _) = try await URLSession.shared
        .data(from: .init(string: "http://numbersapi.com/\(number)")!)
      return String(decoding: data, as: UTF8.self)
    }
  )
}

extension DependencyValues {
  var numberFact: NumberFactClient {
    get { self[NumberFactClient.self] }
    set { self[NumberFactClient.self] = newValue }
  }
}

もちろんこれも登場以前からプロパティラッパーを使う方は少なからずアイデアはあり試したことはありました。

しかし上記のやり方だとプロパティラッパー経由でどこからでも上書きできてしまったり、テスト用やプレビュー用に分ける方法はできていませんでした。それがTCAは完璧なソリューションを提示してきたよってわけです。やべーよ。想像の斜め上を行ってるよ。

おわりに

ここまででTCAの良さが分かったかもしれないし、まったく誰もついてきていないのかもしれないです。

ちなみにTCA以外のやり方が良くないとも思いません。そもそもiOSアプリ開発において、TCAは選択肢であって選択肢が増えることにて難しく見えるかもしれませんが、実際は複数の選択肢の中から選ぶことが難しいのであって、その難しさは自分の頭で考えて繰り返し測定し続けていくことで最善手が選べるようになるもんだよなと思います。

仕事で相談を受ける際に例えば「〜することが本当に良いのか悩んでいる」ということを聞くんですが、そのやり方をして実際にどのくらい長所/短所があるのか測定して評価できるようにしているのか、そこに答えがあるはずです。

実際は何を選んでもアプリは大抵動くので最低限の要件を満たせるでしょう。つまりアプリ開発自体が難しくなっているわけではないのでしょう。TCAのやり方を知っていると、それを使わない場合でもアプリ開発をする上でのヒントになる考えを得られるのもとても良いです。それについてはまた別の機会に整理したいと思っています。

推しがv1.0になっても誰もしなないし、むしろそれまでは生きたいということです。

21
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?