CA.swiftは、サイバーエージェントのABEMAやAmeba、AWA、tappleなどを担当しているiOSエンジニアによるiOSエンジニアのための勉強会です。 それぞれのチームで使用している技術や開発体制など、開発の中でのノウハウを惜しみなく発信していきます。 

第22回のテーマは 「Swiftの進化を活かした技術基盤への挑戦」 です。サイバーエージェントでは、Swift ConcurrencyやSwiftUI、TCAなど次世代技術を活用しながら、いかにして現場の技術基盤を進化させてきたのか。そのプロセスで生まれた試行錯誤や、技術選定のポイント、実例に基づく工夫の数々を共有する予定です。

 

本記事は、2025年01月08(水)に開催した「CA.swift #22」において発表された「The Composable Architecture (TCA) を用いたAmebaのリアーキテクチャ」に対して、社内の生成AI議事録ツール「コエログ」を活用して書き起こし、登壇者本人が監修役として加筆修正しました。


小田島 直樹 ( AmebaLIFE事業本部 ポイ活事業部)

GitHub : dazy1030
X : @devdazy

2022年4月に中途入社しAmebaアプリの開発を行っています。前職は19卒でライブ配信サービスの開発を行っていました。2/28からは狩人です。


TCAを用いたAmebaのリアアーキテクチャというタイトルで発表を始めさせていただきたいと思います。

こんばんは。サイバーエージェントの小田島と申します。22年に中途入社をして、AmebaアプリのiOSエンジニアをしております。先週末からはハンターを兼業しています。

本日の発表内容がこちらになります。

はじめはAmebaについて話しまして、Amebaのアーキテクチャとその課題について。そして、リアーキテクチャとTCAの導入、導入してみてのメリットデメリットについて説明します。最後にこれからについて話して終わりたいと思います。

時間の都合上、ある程度TCAの記法などの説明を省きますが、ご了承ください。

では早速、Amebaについて始めていきます。

実は、Amebaは去年の9月で20周年を迎えています。

アプリの方はすでに13年目を迎えており、現在の主な機能がこちらになります。メインとなるブログの機能はもちろんですが、無料で読むことができるAmebaマンガなども機能として有しています。

最近のリリースだとウィジェットを使ったアクセス数の表示機能や、より機能をリッチにするためのサブスクの機能などが追加されていて、iOSのプロダクト的な進化を続けております。

続きまして、これまでのアーキテクチャと課題について話していきます。

これまでのアプリを支えてきたアーキテクチャがこちらになります。かなりざっくりとした図ではありますが、割とベーシックなMVVMだと思います。ユースケース以降のレイヤーはClean Architectureを参考にしていることから「MVVM+Clean Architecture」としています。データフローにはRxSwiftを用いています。

bindメソッドでは注入されたUseCaseなどから、ViewのInputと結びつけてOutputへの変換を行います。

このOutputでは、Viewの状態は露出していなくて、ストリームとして表現されています。

実際このアーキテクチャに課題はあったのかというところなのですが

まずロジックの複雑さがかなり限界を迎えていたと思います。一つの画面に対してのインターフェースとしては、そこそこにうまく機能していました。ただ、画面とViewModel が原則一対一の構成しか想定していなかったので、複雑な画面では一つのViewに対するモデルの肥大化というところが顕著になっていました。

また、肥大化に加えて複雑なロジックをRxSwiftのメソッドチェーンで表現し続けたことで、著しく可読性が低下していたという問題もありました。

結果として保守性や拡張性が低下して開発速度の低下を招いてきました。

加えて、昨今のモダンな開発スタイルの導入にも課題がありました。ここ数年では開発スタイルが変化してきていて、特に SwiftUI と Swift Concurrency は大きな影響を及ぼしています。

先ほど申し上げた通り、View の状態を露出しないようにインターフェースを組んでいるので、State-Driven な SwiftUI と相性が悪い状況でした。

また、View モデルとユースケースのインターフェースが完全に RxSwift に依存してしまっていたため、Swift Concurrency の導入を妨げていました。

やはりこれから SwiftUI ネイティブな世代の採用を行っていくにあたって、これはディスアドバンテージとなってしまいます。

ざっくり言ってしまうと、効率よくモダンなコードが書きたいというところが、TCA導入のモチベーションとなっていました。

ここからリアーキテクチャの取り組みと TCAの導入についてお話します。

そもそもTCAはThe Composable Architectureの略で、FluxやReduxをもとに設計されたアーキテクチャになっています。

図のように単方向のデータフローでViewを更新していきます。各パーツの役割について詳しくは説明しませんが、この後の導入の流れを通して一部伝えられればと思います。

先ほどのBeforeの図と並べると具体的にはこのような違いになります。

どの箇所も修正は必要ですが、特にInputからOutputまでの区間はReduxベースの実装となるため、認識を合わせることが重要です。まずInputからActionに変換する部分について。ViewModelのInputはTCAでいうところのActionに含まれます。

ViewModelのインプットのときと同様に、ライフサイクルやユーザーの操作で起こるイベントを定義します。

ただしアクションの方では、ロジックの中で発生する通信処理の結果や、例えばログアウトしたというようなグローバルな通知など、その機能を実現する上で必要なイベントを定義する必要があります。

そしてインプットからアウトプットへ変換していたバインドの部分ですが、こちらはTCAではReducerとEffectにマイグレーションしていきます。

このコードではボタンを押したら読み込みを行うというようなロジックになっています。この通信処理のようにアクションに対して発生する副作用のことをTCAではEffectと呼びます。

特徴としてEffectは状態であるstateを変更することができないというところがあって、例えばここで言うと読み込みが終わった後stateを変更したいという時は、さらにEffectから読み込みが終わったというactionを発行して、そちらで状態を変更することが必要です。

ここまででTCAの概要とリアアーキテクチャの導入について紹介しました。ここから導入してみて感じたこと、メリット・デメリットについて話したいと思います。

まず第一のメリットとしてロジックが明確で見通しが良くなったという点があります。

先ほどのTCAの導入部分で紹介した通り、アクションにはその機能で発生するイベントを列挙的に定義する必要があります。また、非同期処理などのEffectでは、状態を変更できないという性質があるので、発生するイベントと起こることを簡潔に、かつ網羅的に表現することができます。

このロジックは短いですが、ボタンを押したら読み込みが開始されること、読み込みが成功したらタイトルを更新することがわかります。省いてはいますが、読み込みが失敗したときのことも書く必要があります。switch文のため、網羅的に書かざるを得ないという特徴があります。

このロジックの網羅性はテストの網羅性にも直結しています。先ほど説明したものとは少し異なりますが、ボタンを押すと読み込みが走り、その結果をstateに反映するというロジックのテストケースを考えてみましょう。

TCAでは右のコードにあるようなtestStoreというテスト用のランタイムにactionを仮想的に送信し、stateがどのように変化するかをテストします。

左のロジックでは、ボタンを押したら読み込み中であることを示すisLoadingがtrueになるというロジックを書いています。そのため、右のテストではボタンを押したらisLoadingがtrueになるという変化をチェックしています。この変化自体は正しいので、buttonPressedに対するstateのチェックはパスします。しかし、このテストメソッド全体としては失敗してしまいます。

その理由は、発行された非同期処理のeffectからさらに「読み込みが終わった」というactionが発生しているにも関わらず、そのテストが書かれていないからです。buttonPressedのactionからは、最終的に読み込みが終わってさらに状態を更新するという一連の流れがあるため、それを網羅的に記述する必要があります。

この例では、読み込みが成功したloadResultSuccessのactionが来ることを追加で記述することで、テストを通すことができるようになります。

また、UIKitとSwiftUIの画面を専用シームレスに移行できるようになるというところもメリットの一つです。

ある画面だけSwiftUI化しようとするとき、隣の画面が遷移先の画面と遷移関係でUIKitの遷移をしなければいけないということが数多くあると思います。

アーキテクチャの初期段階ではほとんどのアプリの画面がUIViewControllerで構成されているので、ViewControllerをUIViewControllerRepresentableでSwiftUI側に持ってくるのではなく、ViewをUIHostingControllerでラップして画面遷移させるのが一般的かなと思います。

この時、SwiftUIのように状態管理で画面遷移を実装したくなります。画面遷移も全て状態管理で実現しているため、SwiftUI側だけに関心を絞ることができて、フルSwiftUI化したときのマイグレーションが楽になります。

このコードでは、TCAのコードになっていますが、割と一般的なSwiftUIのナビゲーションの画面遷移と似ていると思います。

これをTCAの機能を用いてUIKitのUIHostingControllerで同じことをしようと思った場合、こちらのコードのようにほとんど同じインターフェースで実現することができます。こちらはホスティングコントローラを使った場合のコードです。(前の画面と比較)

navigationDestinationというTCAの提供しているメソッドが、ほとんど同じインターフェースとして機能しています。この機能自体はswift-navigationというライブラリで独立しているので、TCAを導入しなくても利用することができます。

また当初の課題でもあったロジックの分割についても簡単にできるところがメリットです。

SwiftUIのViewをこちらのコードのような小さなViewに分けるように、ロジックであるReducerも同様に別のReducerへ分割かつ合成するということができます。もちろんこのAction, Stateは親から観測できるので、分割前と同様のロジックを保つことができます。

ただし、デメリットももちろんあります。

よくTCAの導入する際に言われるのが、学習コストが高いという点です。個人的にはデメリットのほとんどはこの点に帰着しているかなと感じるほどです。

まず単純にTCAはフレームワークなので、そのフレームワークを丸々一つ理解するというところはコストが大きいです。特にチームで開発する際には、メンバーがFluxやRedux系のアーキテクチャが未経験だと特に負荷が大きくなってしまいます。

また、TCA独自の記法も存在しています。こちらの例ではCasePathableというenumのcaseをKeyPathのようにアクセスする機能なんですが、こちらはバニラのSwiftには存在しないものになります。

学習コストの課題についてはとにかく例を作ることを心がけています。あとは MVVM 時代からのマイグレーションドキュメントを作成することで対策を行っています。

加えて最近ではXcodeのテンプレート機能を用意して、なるべく覚えなくてもいいところをメンバーに負荷にならないようにするという努力もしています。ただどの方法も本人の学習要領次第になってしまうので、完全な解決ではありません。

デメリット2つ目としてパフォーマンスが DCA は最良ではないというところです。慣れるまでは小さなViewやロジックについてもReducerを分割してしまいますが、TCAのアクションの送信は負荷が重めということが知られています。この点には注意して実装する必要があります。

しかし、TCAの仕様上、アクションを送らないということはできないので、アクションを極力減らせるような定義になるように吟味する必要があります。例えばこのコードのように一度だけonAppearで処理したい場合などは、View側で一度だけ呼ばれるような「onFirstAppear」というモディファイヤーを作成して送信するようにしています。

また、ログのような実際のロジックから切り離しても問題ない部分については、View側で処理を完結させるようにしています。

そして最後になりますが、Point-freeへの依存リスクが高いという点があります。冒頭のRxSwiftの例と同様に、TCAは開発元のPoint-freeの支えが大きいものとなっています。特にViewとReducerをつなぐランタイム部分は利用者が全く意識していないため、万が一の場合には対応が難しくなっています。OSSとはいえ、サポートされなくなった時に更新の継続がうまくいく保証はありません。

こちらはメリットにもデメリットも取られることなんですが、記法のブレがかなり抑制されるところは個人的には嬉しいなと思っています。

チーム開発では書き方の部分で揺れることが結構あるかと思いますが、TCAはフレームワーク化されたアーキテクチャなので、APIにない書き方ができないというところがメリットでありデメリットかなと思います。

まずは何よりもやりきることが大事だと思います。中途半端に移行して複数のアーキテクチャが混ざることが何よりの技術的な負債になると考えています。しかし、Swift 6対応や、CocoaPodsの脱却など、必須で対応しなければならないことがたくさんあるので、中長期的な計画を立てて進めていくことが重要だと考えています。ここは責任を持ってやり遂げたいところです。

また、TCAはまだまだ進化し続けていますので、より良い方法を求めていくことも大切です。時間の都合上、ここで終了させていただきます。ありがとうございました。