DroidKaigi 2024公式iOSアプリが良かった

はじめに

DroidKaigiはAndroid開発者のためのカンファレンスですが、公式iOSアプリがGitHubで公開され開発者コミュニティがPull Requestを通して貢献できるようにされていました。

https://github.com/DroidKaigi/conference-app-2024

DroidKaigi 2024のiOSアプリ開発はコミュニティとのつながりを深める良い機会でしたし、現在世の中の人たちがどういうふうにThe Composable Architecture(以下TCA)を使ってるかがわかったり、その他の後述する良いところが色々ありました。本記事では、私自身がDroidKaigiの公式iOSアプリにどのようにPull Requestをしたかについて、そして、もしかしたら世の中の誰かの開発の役に立つかもしれないので、ここからどのような改善をしたいと感じたかを共有します。

良かった点

本題の前に私が楽しめた点を軽く書いておきます

  • TCAで書かれてたので設計自体にコンセンサスが取れている
    • TCAじゃなかったら誰かが考えたオレオレフレームワークで作ることになると思う
      • そうなるとその初見のやり方を学び納得し同じやり方をすることになる
  • TCAでテストコードが書かれていた
    • TCAのReducerのテストがあると何をどうやりたいかがわかる
    • 副作用の分離ができていることを立証できる
  • 誰かが頑張ってるのがわかる
    • 朝イチくらいでPull Request一覧を開くと頑張って改善されてるのがわかって良かった
      • 「この人だいぶ頑張ってんなー」という感じ
  • iOSアプリのためのPull Requestは自動でラベル「 🍎」 がつくGitHub Actionsが良かった
    • ラベルを選択すればiOS系のPull Requestのみを絞り込めて楽

私自身はPull Requestを19個出して修正や改善を提案しました。

https://github.com/DroidKaigi/conference-app-2024/pulls?q=is%3Apr+author%3Ayimajo+is%3Aclosed

この中からいくつか紹介してみます。

protocol ViewActionを使うようにする

https://github.com/DroidKaigi/conference-app-2024/pull/1005

TCAではReducerのActionはenumであり自由度が高いのですが、それを用途ごとに区別することでコードの可読性が上がります。さらにカスタムなLintを使うことで安全性も上がるんですが、TCA公式でViewActionというprotocolを使った仕組みを導入し、ViewからのActionを区別して使いやすくなりました。

そのため、そのViewActionを利用するPull Requestを出しました。

OSSのアプリ開発では、関わる複数人がなんの縛りもなくActionを別々の思想でつくるとカオスです。そうならないようViewActionを使うと良いはずです。

自分がやるならFeatureActionプロトコルをReducerのActionに準拠させることもあります。

public protocol FeatureAction {
  associatedtype ViewAction
  associatedtype DelegateAction
  associatedtype InternalAction

  static func view(_: ViewAction) -> Self
  static func delegate(_: DelegateAction) -> Self
  static func `internal`(_: InternalAction) -> Self
}

ただし、これをしたからといってSwiftLintで警告/エラーに設定しない場合、見た目上の効果しかないためそこは留意する必要があります(見た目上の効果だけでもあれば違うとは思います)。そのため、ViewActionだけでも十分だとも思います。

副作用のLiveを別モジュールとする

https://github.com/DroidKaigi/conference-app-2024/pull/947

isowordsでもやっていることですが、副作用のDependencyClientについて応用があります

  • 副作用のモジュールはLiveと定義(およびPreview/Test用実装)に分ける
      • ~ClientLiveと~Client
    • Live側では実装を書きOSSを利用するならそちらから依存させ、定義側ではOSSには依存しない

昔描いた図で説明するとこんな感じ。GameFeatureはAPIClientに依存していますが、APIClientLiveには依存していません。

なぜこのような応用があるかというと大きく分けて2つの軸があると思います

  • ホストアプリケーション(アプリケーション本体)がOSSに依存するのはしょうがないが、FreatureモジュールがOSSに依存したりLive実装に依存する必要がないため
  • テスト時にテストモジュールがOSSに依存しないならOSSをビルドする必要がないため

ただ、このメリットはXcode 15.x時代のPreviewでのメリットのはずです。Xcode 16ではデバッグビルドした出力をそのままPreviewに引き継ぐはずで、依存関係を気にしてプレビューを高速化したりするのは意味がないかもしれません。

副作用のコードをDependencyClient外で公開しないようにする

https://github.com/DroidKaigi/conference-app-2024/pull/947

先述のLiveの分離を行うために必要だったことですが、DependencyClient外でシングルトン的な使い方でLive実装を外に公開していたContainer型をDependencyClientとしてContainerClientとしました。

もともと、KMPのデータを取得するRepositoryをContainer.sharedのかたちでDependency外で利用できるようにしてしまっていました。Container.sharedはKMPClientLiveでも利用されており、それをKMPClientLiveをLiveとして別ターゲットにすることができないようなっていました。そのため、公開されてしまっているContainerContainerClientとしてTCAの正規の方法に置き換えました。

アプリ開発序盤のコーディングの初めからLiveを分離することはやや面倒というのもあり、後でやればいいとは思いはするのですが、そうしてしまうとLiveの実装に依存したDomainができてしまい、モジュール分割で依存関係を切り分けることを目的にしているのに、実際はそうなっていないという状況が発生します。そのためLiveを初めから別モジュールにすることで、ビルドできるかどうかで必要のない依存が発生していないことを判断できるようになるはずです。モジュールをただ分けるだけでは、そこにメリットが少ないだけです。

ここでのContainerという名前もなんのコンテナやねんという感じでしょうから、KMPRepositoryContainerClientくらいの名前にリネームすればよかったなとは思います。

テストコードでActionの成功と失敗を明示する

https://github.com/DroidKaigi/conference-app-2024/pull/975

テストコードでAssertする際にそのActionの要素が細かく書かれていなかったため、要素としての成功失敗を明記することにしました。逆に細かく書かなくてもコンパイルできてたのかいうことを知って驚きました。

細かく書かないことのメリットもあるとは思いますが、偽陽性/偽陰性になってしまうデメリットのほうが圧倒的に高いため、本来成功か失敗かどうあるべきかをテストに書くことがベストでしょう。読み手にとっても情報量がないと先に書いた人が何を想定していたかがわからないと無駄に時間を使ってしまうことはよくあることです。

SwiftGenの生成するコードをextensionで@unchecked Sendableにする

https://github.com/DroidKaigi/conference-app-2024/pull/630

テンプレートを変更してSendableにすることもできるしそれがベストなんですが、ただのView用のコードなので、まずは警告数を200くらい出ているのを抑制し、他の警告を対応したいだけのため、そこまでする必要がないと判断しました。仕事であればテンプレートを変更するのがベストなんだろうとは思います。

私にはSwiftGen自体は更新が止まっていて、Sendableに対応したテンプレートを仕込むような状況には見えません。下記のPRが進捗がないのがその理由です。

https://github.com/SwiftGen/SwiftGen/pull/1119

SwiftGenを新規アプリに使いたいのであればforkしていくか、使わないかの2択だと思います。現状、Assetに対してならSwiftGenは使わなくてもあまり困らないと思います。

Pull Requestを出していないが改善したかったこと

Pull  Requestを出さなかったものの、自分ならこうするということも書いておきます。来年もiOSアプリがあったらPull Request出せたらいいなと思っています。

Swift FormatをGitHub Actionsに設定したかった

swiftlintでフォーマットしても良いんですが、フォーマッタが決められていないことでコードがバラバラでした。ただ私はそこまでフォーマットは気にしないので、楽なのはPull Requestされたコードに対してformatをするようなGitHub Actionsを設定し、それが漏れてmainにマージされたらmainをformatするActionでカバーしていくようにすれば良いはずです。ローカルでformatするようなのは結局漏れるものだし、リモートリポジトリでやればマシンパワーもそこから使うだけなので楽でしょう。

警告対応のissueを細分化したかった

警告を解決するというissueがあり、当初の実際の警告数は200以上くらいありました。そうなるとissueを担当する1人がやるのかというとできるはずもなく立候補できず、であればissueを分類し、細かく分類することでそのissue担当を立候補しやすくするというのを初手かその次かでやれば良かったかなと思います。だいたい慣れたので、もし次回も同じ状況であれば私が分類しておこうと思います。

記憶では分類としては

  • Xcode 16起因
    • retroactive
  • Xcode 15起因での警告(なので放置)
    • URLSession.shared.data(for: urlRequest)で `passing argument of non-sendable type '(any URLSessionTaskDelegate)?' outside of main actor-isolated context may introduce data races`
  • KMPで生成されたObjective-CのオブジェクトのSendable対応
  • SwiftコードのSendable対応
  • SwiftGen自動生成コードのSendable対応
  • 必要のないpreconcurrency

登壇者などのユーザーアイコンのファイルキャッシュにしたかった

オンメモリのキャッシュは実装されましたが、基本的にはモバイルアプリではみなさんもファイルキャッシュすると思います。オフライン利用というとちょっと話が進んでしまうのでそこまでは話題にしていなくて、オンメモリキャッシュがないアプリ起動時にユーザーアイコンのために通信する必要をなくするという利点のためです。

ただ、ユーザーが自分で変更できてしまうユーザーアイコンをファイルキャッシュを実装するとそれをリフレッシュするトリガーを考える必要があり、そのトリガーはアプリの見た目側にたくす場合もあります(具体的には設定画面にボタンを置いてキャッシュ削除とかね)。そのため誰が見た目側についての決断をするのかというのを知らない状態で複数の選択肢の中でそれをやっていくのは時間がなかったため、ファイルキャッシュというもの自体をPull Requestしませんでした。来年は時間があればつまりUIに依存しない方法は少なくてもやれるかなと思います。

モジュールをFeatureごとにはせずDomainごとにしたかった

元のコードではTimetableモジュールとTimetableDetailのモジュールが別モジュールになっていましたが、私ならそれらは1つのモジュールにします。なぜなら、それらはTimetableというドメインの一機能でありただの見た目の違いでしかないと感じるからです。また、単純にそれらはお互いに画面遷移しあってもおかしくないはずで、言い換えるとお互いが依存していても問題ないと考えます。別モジュールである場合というのは相互に依存することができなくなるわけで、もし画面遷移が発生するなら別モジュール通しが遷移できるように、やり方を工夫することになります。そのように、やり方を変えたり工夫しないといけないという縛りが発生することは選択肢を狭めるということであり、それは私だったらメリットを文章化できていないのなら実現の選択肢を狭めたいとは思いません。

そもそもあくまで私の設計についての考えですが、Module(Swift Package的にいうとTarget)をFeature(もしくはReducer)ごとに分割するのは早すぎる最適化でありメリットよりもデメリットのほうが大きいと感じています。「後から細かく分離することが難しいから先に細かく分離しなければいけない」というその考えはYAGNIでもあると思います。後から細かく分離することが難しいなら、それはドメインが共通しており分離する必要がないものかもしれません。

もし私が機能面でのモジュール分割をやるのであれば、それはドメインを分割し影響範囲を抑えるというメリットを重要視する場合です。たとえばTimetableモジュールとSettingモジュールは分割します。

その他よくわかってないこと

KMP自体についてあまりよくわからなかったのですが、メモっておきます

2回ビルドすると成功することがある

記憶違いかもしれませんが、1回ビルドするとビルドエラーで2回目に成功することがありました(プレビューも2回目に成功)。これはObjective-CとSwift混在のプロジェクトで昔見た記憶があります。何かそこら辺の連携の設計がうまくいってない気がします。

おわりに

DroidKaigi 2024の公式iOSアプリのGitHub公開は、プラットフォームを超えた開発者間の協力とより良い体験を提供するという感じが良かったです。KMPを使うことでどうなるのかもなんとなくわかりました。この取り組みに参加することで、たぶんみなさんも技術コミュニティへの貢献を実感することができるんじゃないかと思います。来年もDroidKaigiのiOSアプリが公開されるといいですね。

弊社にiOSアプリ開発/TCAなどのアドバイザーの依頼をしたい方のためにGoogle Formで入力できるようにしましたのでご利用をご検討ください。

https://forms.gle/pqRF4GxDL7k5rqvy5