こんにちは、モバイル請求書チームで iOS エンジニアをしている yaya です。
freee請求書のモバイルアプリが2023年9月6日にリリースされました! www.freee.co.jp そのモバイルアプリの開発について、全3回に渡ってお届けしていきます。 今回はその第一回目、iOS アプリの開発についてです。
請求書アプリの概要
まず初めに、freee請求書のサービスを紹介したいと思います。 freee請求書は、請求書を始めとする帳票を閲覧・作成・発行できるサービスです。 2023年10月時点で対応している帳票は、請求書、見積書、発注書、納品書、領収書の5種類です。 2023年10月に開始されたインボイス制度にも、もちろん対応しています。 freee会計やfreee販売と取引先情報の連携が可能で、作成した帳票はこれらの取引先へメールで送付することもできます。 Web 版は2022年12月13日に既にリリースされていますが、インボイス制度の開始に併せてアプリ版もリリースされる運びとなりました。
アーキテクチャ
タイトルにもあるように、freee請求書 iOS アプリではアーキテクチャとして TCA (The Composable Architecture) を全面的に採用しました。 チームでどのような議論をして採用に至ったのか、日々の開発をどのように進めているのかを見ていきたいと思います。
TCA とは
TCA (The Composable Architecture) は、Combine を上手く使用した Redux のようなアーキテクチャフレームワークです。 Redux と同じように単一方向のデータフローであり、State、Action、Store、View の4つで構成されています。 OSS として GitHub に公開されており、積極的な開発が行われています。 github.com
選定理由
開発前に、請求書アプリの開発に携わる iOS エンジニアで公式サンプルを触ってみた上で、メリット・デメリットを出して議論しました。
メリット
書き方がきっちり決まっていて、誰が書いてもある程度同じような実装になる点が、チームとしてはとても良いと思っています。 TCA においてモジュールごとに実装が必要なのは、主に View と Reducer、State、Action の4つです。 これらを、TCA の制約上、おおよそ以下のように実装することになります。
public struct HogeReducer: Reducer { public struct State: Equtable { // implementation } public enum Action: Equtable { // implementation } public var body: some ReducerOf<Self> { Reduce { state, action -> // switch による action ごとの条件分岐 } } } public struct HogeView: View { public var body: some View { // implementation } }
また、isowords やリポジトリ内のサンプルといった、公式のリファレンスがとても充実しています。 実際、これらで実際に実装されている実装を、開発前に iOS エンジニアで読み合わせしたり、開発中も良いものを見つけたら都度共有しました。 その結果、チーム内での実装方針が常に統一された状態で開発を進めていくことができました。
TCA は Redux ベースのアーキテクチャであるため、Redux の良さも引き継いでいます。 データの流れが単方向で分かりやすいことや、State が常に immutable であり、かつ State を変更できるのは Reducer のみであることなど、複雑になりがちな iOS アプリの状態管理をシンプルに保つことができます。
API 通信を始めとする副作用を DI できる点も魅力的です。 こうした DI の機構が TCA のフレームワークとして提供されているため扱いやすく、コードの見通しを良くすることができます。 自然とテストも書きやすくなります。
デメリット
もちろんメリットばかりではなく、デメリットも存在します。
まずはなんといっても、TCA がライブラリである点です。 Android Architecture Component のように公式から提供されているフレームワークであれば安心して使うことができますが、TCA は OSS として開発されており、Apple が提供しているものではありません。 メンテ終了のリスクだけでなく、 Apple が公式のアーキテクチャフレームワークを提供した場合、乗り換えることを検討しないといけません。
2023年7月31日、TCA は 1.0.0 がリリースされましたが、請求書アプリの開発当初は 0.x であったため、新機能や破壊的変更が頻繁に入ってくる状況でした。 ライブラリの動向をきちんと監視することや、TCA のバージョンアップデートは慎重に行うなどして、予期せぬ影響をなるべく出さぬようにチームとして心がけています。 実際、開発中に deprecated となりリファクタリングをすることになった API は多々ありました…
その他にも、Redux ベースで一般的な iOS アプリとは異なる書き方になることによる学習コストの高さも懸念点として挙げられます。 特に、新しいメンバーを迎える時には、既存のメンバーによるサポートが重要になることでしょう。
TCA 採用の決め手
こうしたメリット・デメリットがある中で、最後まで迷ったのは MVVM でした。 既存の会計アプリで慣れ親しんでいるアーキテクチャであることが最大の理由です。 しかし、MVVM は柔軟に書ける反面、実装者による差異が大きく出てしまいます。 インボイス制度の開始が2023年10月に迫る中、スピード重視で開発を進めていくためには、きっちりと書き方が決まっているほうが良いだろうと判断しました。 また、より良いコードを保ってモダンでイケイケなアプリにしていくぞという、メンバーの心意気もありました。こうしたところから TCA を採用するに至りました。
開発で大変だったところ
複雑な画面を1つの state、reducer で実装することはせず、セクション単位などの適切な単位で分割していくのが一般的かと思います。 1つの親と複数の子が存在するような形式です。 こうした形式で実装していくと、幾つか実装の大変な場面が出てきました。
親と子、子と子の state の同期
子Aが変更した state を子Bや親の state と同期するにはどうすればいいでしょうか? freee請求書iOSアプリでは以下のように実装しました。
struct Parent: Reducer { struct State: Equatable { // 画面全体のデータ var payload: Payload private var _child1State: Child1.State var child1State: Child1.State { get { _child1State.applied(payload: payload) } set { _child1State = newValue // Child1 で更新したデータを payload に反映 payload.hoge = newValue.hoge payload.fuga = newValue.fuga } } private var _child2State: Child2.State var child2State: Child2.State { get { _child2State.applied(payload: payload) } set { _child2State = newValue // Child2 で更新したデータを payload に反映 payload.piyo = newValue.piyo payload.woo = newValue.woo } } } ... } struct Child1: Reducer { struct State: Equatable { // Child1 で更新されるデータ var hoge: Hoge var fuga: Fuga // Child2 で更新されるデータ var piyo: Piyo func applied(payload: Payload) { self.hoge = payload.hoge self.fuga = payload.fuga self.piyo = payload.piyo } } ... } struct Child2: Reducer { struct State: Equatable { ... } ... }
TCA ではデータフローがきっちりと決まっています。
それゆえ、子Aが子Bのデータを直接変更することはできません。
親を経由して上手く同期してやる必要があります。子 state を保持する Stored Property (_child1State
_child2State
) と、参照するための Computed Property (child1State
child2State
) の2種類を実装しました。
子 store は Computed Property の state を参照するようにします。
上の実装例にあるChild2
で更新されたデータがどのようにChild1.State
まで伝播するかを見ていきいましょう。
Child2.State
は struct であり、Child2
が参照しているのはParent.State#child1State
です。
Child2
の中でState
が更新されると、インスタンスが置き換わるのでchild1State
の setter が自動的に走ります。
payload
も更新され、Parent
の画面更新処理がスタートします。
この時、Child1
が参照しているParent.State#child1State
の getter で得られるインスタンスも新しい値のものに変わっているため、Child1
の表示も更新されます。
データフローが固まっていることは大きなメリットです。 しかし、それゆえ複雑な画面にはこうしたトリッキーな実装が必要になることがあります。
データのバケツリレー
大変なところをもう一つ挙げるとすると、親から子、孫へデータのバケツリレーが発生し得る点です。 TCA には DI の仕組みが存在していますが、主に副作用を注入するためのものであり、親のデータを子や孫へ渡すためのものではありません。 データを必要としている state まで、親からデータを順々に渡していく必要があります。 ちなみに、Android の宣言的 UI フレームワークである Jetpack Compose には CompositionLocal という仕組みが用意されており、バケツリレーの問題が解決されています。
アクセシビリティ
freee請求書アプリでは、アクセシビリティの国際基準を全て満たしています。 そのためには、アクセシビリティ対応のための追加実装が必要になります。 例えば、画面遷移によるフォーカスの制御や、VoiceOver のカスタムアクション、Dynamic Type でのサイズによる UI 表示の切り替えがありました。 苦戦した実装は様々ありますが、この記事では説明しません。 技術書典15にて発売された「freee技術の本」にて、実装例も含めた詳しい解説しています。 ぜひこちらも併せてご覧ください! techbookfest.org
まとめ
さて、freee請求書 iOS アプリの技術選定や、実際に開発してみて感じたところを紹介しました。 実際にこの記事で書いた構成で開発してリリースをしましたが、Firebase Crashlytics で観測できるクラッシュフリー率は高い値を維持できています。 苦労したところも多々ありましたが、良い品質でリリースできたというのは嬉しいです。 より良いアプリに育つよう、機能の拡充やさらなる品質向上に取り組んでいけたらと思います。