OpenWork Tech Blog

社員クチコミサービスを運営しているオープンワークエンジニアによるテックブログです。

iOSアプリのリアーキテクチャ MVVMからTCAへ

ネイティブアプリエンジニアの入江です。 OpenWorkのiOSアプリのアーキテクチャは、MVVMからTCA(TheComposableArchitecture)へ移行中です。 今回は初投稿ということで、実際TCA化やっていてどうかみたいな、広く浅いテーマの記事にしたいと思います。

※この記事でいう「テスト」とは、XCTestを利用したビジネスロジックのテストを指します。

技術選定

リファクタの一番最初のきっかけ

自分はTCA化がはじまった当初には在籍していなかったのので、リアーキテクチャキックオフ当時の資料を探してみました。 その資料によると、一貫して「規則が守られない」ということが残されていました。 MVVMとはいえ、一部RXを使用せずMVPになっている画面もあり、とにかくPresenterやらViewModelに色んな処理を詰め込むような実装になっており、実装者のやりたい放題な実装になってしまっていたということですね。

以下の2つは当時の実際の開発者の経験談です。

Rxの使い方が統一されておらず、画面によっても流儀が異なるのでアプリケーションコード全体の一体感が無い

Rxは、使えるインターフェースが豊富な分、色んな書き方ができてしまうので、実装を統一して行くのが難しいというのは確かに想像できます。 理解に必要な学習量が多いですし、コーディング規約やLintなどでルールを設ける+それをメンテするほどのコストを取るまでにはなかなか至らない・・みたいな状況はあるあるかもしれません。

コードの変更が辛くなりAndroidに比べて多くのリソースを割いても機能開発に遅れが生じていた

これもRxの弊害の1つではありますが、複雑な画面ではイベントのトリガーとサブスクライバーがごちゃごちゃで、入口と出口の影響範囲が非常にわかりにくい実装になってしまうことありますよね。影響ないと思って追加したストリームが、実は初期値が必要なサブスクライバーがいて、意図しない箇所でイベントが流れてこなくなった・・みたいな事故はちょいちょい見てきました。これを繰り返していくうちにどんどんボロボロの水道管のようになっていく・・・

TCAを選んだ理由

当時の選定検討資料や、メンバーの話を参考にまとめました。

最も大きな理由は、TCAによって単方向の状態管理を強制され、統一された実装にできることが期待できそうだった、とのことです。 独自改良版Fluxや、Reduxといったその他の単方向データフローのアーキテクチャも検討したが、やりたいことを最もシンプルに実現できそうなのはTCAだったので、採用したとのことでした。 実際今使っていて、この課題は解消できている実感があります。

SwiftUIとの親和性などもTCAの特徴の1つではありますが、当初はコードの一貫性を高めることを重視して、TCAを選んだようでした。

進め方

続いて、実際にどうTCA化を進めているかの話をしていきます。 機能開発も並行して進めているため、1画面ずつ、地道に進めています。また、現状はSwiftUIではなく、UIKitを使用しており、UIへのデータバインドはCombineを使用しています。

ざっくりとこんな作業イメージです。

  1. TCA化対象画面の処理フロー図を作成しておく
  2. APIClient周りから、Combineで実装し直す
  3. 最初に作成していたフロー図をもとに、機能をCore(State, Actionがまとまっている単位のもの)に移植していく
  4. View周りのバインディングをRXからCombineに変えて、Coreに繋ぎ直す
  5. UnitTestを実装する

補足していくと、

対象画面のフロー図を作成しておく

これ結構大事です。自分達はMermaidで書いたフロー図をGitのリポジトリで管理しています。MVVM->TCAレベルのリファクタでは、色々と吹っ飛ぶので、既存の仕様を落とさないようにまとめておく必要があります。また、フロー図にすることで、TCA化する際に、どういうActionとどういうStateが必要か、イメージしやすくなります。(仕様に基づいて書いたフロー図が、自然とTCA化後のコードと綺麗にリンクしていくのが気持ちよくて好き)

APIClient周りから、Combineで実装し直す 最初に作成していたフロー図をもとに、機能をCore(State, Actionがまとまっている単位のもの)に移植していく View周りのバインディングをRXからCombineに変えて、Coreに繋ぎ直す

慣れてくると、テンプレートのように書いていくことができます。 気をつけているポイントとして、1つのActionが行うことはなるべく1つにしています。例えば、永続化領域への保存など、Stateを直接的にmutateしない副作用については、別のActionに切り出して明示的に書くように注意しています。 これはTCAのAuthorであるPointFreeのブログで触れられていた内容を参考にしました。暗黙的な副作用は悪!!という内容です。

UnitTestを実装する

TCA化したことで、下記のメリットを享受しています。厳密には下記の全てがTCAでしか実現できないわけではなく、TCAに様々なライブラリがパッケージされているため、実現できます。

  • 非同期処理のスケジューリングが非常に簡単で、直感的にテストコードを書けます。
  • ReducerでmutateしているStateのみ、Assertionチェックすればいいので、テストするActionに関係のないStateの期待値を書く必要がありません。むしろ、関係のないStateにAssertionチェックをかけると、mutateされないはずですよと怒ってくれます。Assertionチェック漏れを気にせず、Actionが及ぼすState全体への影響範囲もテストで担保してくれるということです。
  • Action発行後に別のActionにつなげるようなケースで、期待される次のActionが受けれていないとテストが失敗します。つまり、Action -> StateのAssertion(1つのActionでStateが期待通り変わっているか)というinput -> outputの観点だけでなく、stateのmutateに関係なく、その過程で用意したActionを全て期待通りに通過しているか、をテストで担保することができます。これの何が嬉しいのかというと、副作用もテストで担保できることです。DBの更新など、stateをmutateしないけれども重要な処理については、副作用専用のActionに切り出すことで、漏れなく副作用を実行できているか・余計なタイミングで副作用が行われていないか、もテストすることができます。

ちょっとした工夫も

例えばお気に入り登録・解除機能や、ログ送信といった、利用頻度・再利用性高いものは、ReusableCoreという形で切り出してみました。TCAでは、Reducerの親子関係を簡単に作れます。 これにより、画面のライフサイクルに依存するような画面機能をHogeViewControllerCoreとし、例えばログ機能を追加したければHogeViewControllerCore+ReusableLogCoreにするといった組み立てが可能になりました。ResusableCoreにした機能はプロダクトコードもテストコードもシングルソースになるので、手間が減る・安全性が増すというメリットがありました。 UIKitなので、あくまでロジック部分のみですが、SwiftUIを導入すればさらにComponent化が進みそうな気配です。

大変だったこと

キャッチアップが大変

TCAのアップデートが大体1ヶ月に1回程度あります。PointFreeがCombineからSwiftConcurrencyに舵を切ったので、これへの対応も必要でした(SwiftConcurrencyへの移行対応はTCAに限った話ではないですが)。 とはいえ、モダンなSwiftの技術をフル活用しているので、大変さと引き換えに、最新のSwiftをプロダクトレベルで実践していける楽しさは存分に味わえます。

よかったこと

可読性が向上し、実装が統一された

Coreを見ただけでフロー図がパッと浮かんでくるくらい、複雑な画面であっても処理がわかりやすいです。最初のAPIはいつ呼ばれるか、副作用はないか、レスポンスを受けたら何が行われるのか、エラーの場合はどうなるのか。全ての処理がとてもわかりやすく、また、実装者によるバラツキもほとんどないので、可読性の改善幅はとても大きかったと思います。

テストが便利

進め方の補足に記載した通りです。だいぶ楽になったので、テストコードを書く時間も取りやすくなり、カバレッジが向上し、変更に強くなりました。また、テストコードの書き方も、半強制的に統一されるので、プロダクトコードだけでなくテストコードにも一貫性が生まれました。

これからやっていきたいこと

SwiftConcurrencyへの移行

TCAもCombineからSwiftConcurrencyへ移行しており、こちらの対応も徐々に進めています。 これまではGCD、Result型、PromiseKit、Rx、Combine・・などと、非同期処理や並行処理を実現するための道具が大量にあり、プロジェクトによって全然違う作りになっていました。しかし、言語仕様で実装できるのであれば、今後しばらく心配はありません(と信じたいです)。 また、正しいエラー処理や並行処理をコンパイラレベルで強制されることで、これまで潜在的に潜んでいたバグのリスクを安全に解消できるようになったことは大きなメリットだと思います。

SwiftUIの採用

今は、まず全ての画面をTCA化すること・Rxをなくすことを目標にしているので、当面はUIKit+Combineとお付き合いし、TCA化が落ち着き次第SwiftUIの導入を行う予定です。

まとめ

「TCA化してみて、実際どうか」というざっくりとしたテーマでしたが、まとめると、

  • 当初の課題だったレガシーなスパゲティコードが、TCA化によって統一された可読性の高いコードになった
  • テストのカバレッジが向上し変更に対して強くなった
  • 色々最新技術使えて楽しい
  • SwiftConcurrencyへの移行など、課題は残っている

こんなところでしょうか。

最後に

筆者はゲーム・アニメ大好きで、SteamDeck出ちゃったしホグワーツレガシー出るしARK2も出るらしいしブレワイの続編でるしロックマンエグゼがSwitch版で復活するしアニメも色々熱いし本格的に忙しくなってきました。 技術的な専門領域を広げていきたいという気持ちと、ゲームしたい・アニメみたいという気持ちが日々戦っています(勝率3:7くらい)。 そんな勝率の僕でも楽しく働けるOpenWorkですが、ご興味があればぜひ求人もご確認ください。 メンバー一同、ご一緒に働けることを楽しみにしております。

ここまで読んでいただき、ありがとうございました。

www.openwork.co.jp