inSmartBank

B/43を運営する株式会社スマートバンクのメンバーによるブログです

iOS版B/43における画面実装のモジュール分割戦略

こんにちは。スマートバンクで iOS / Android エンジニアをしている nakamuuu です。

現在、iOS版B/43 では SwiftUI への移行やモジュール分割を伴う、画面実装のリファクタリングに継続的に取り組んでいます。まだまだ半ばではあるのですが、開発において抱えていた課題感やこれまで取り組んできたアクションについて紹介していきます。

モジュール分割に至るまでの背景

リリース当初からすべての画面が Jetpack Compose で実装されていた Android版B/43 と異なり、開発開始が2年ほど早かった iOS版B/43 においては UIKit で実装された画面が大半を占めていました。宣言的UIフレームワークの利点としてよく挙げられる高いViewの再利用性や簡潔な状態管理の旨みを享受できないほか、 iOS / Android の両アプリ間で設計を参考にできない部分があるなど、少なからず開発効率の低下に繋がっていました。

iOS版B/43では 2022年6月ごろから新規画面については SwiftUI での実装を進めてきました。しかし、その過程では画面遷移ロジックを中心とした方針転換やデザインシステムの整備(後述)を進めていった経緯もあり、 “複数の画面実装パターンの混在” “デザインシステムへの不完全な準拠” の課題もチーム内で認識されていました。

ここからは、チームが抱いていた “複数の画面実装パターンの混在” “デザインシステムへの不完全な準拠” の2つの課題感をそれぞれもう少し深ぼっていきます。

課題その1 : 複数の画面実装パターンの混在

SwiftUI の導入期に実装した画面では SwiftUI で実装された画面間の遷移に NavigationView を用いる構成でした。その後、 UIKit ベースの既存画面との相互運用性や将来的な NavigationStack への移行を考慮する上で課題が生じたため、早期に方針を転換しています。

直近実装した画面においては以下のように UIHostingController を継承したクラスを画面ごとに用意し、画面遷移のロジックはUIKit (UINavigationController etc) のまま留めるようにしています。

// 📝 画面単位で UIHostingController を継承したクラスを用意する
final class ExampleViewController: UIHostingController<ExampleScreen> {
  init() {
    super.init(rootView: ExampleScreen(viewModel: ExampleViewModel()))

    rootView.onButtonTapped = { [weak self] in
      // 📝 `UINavigationController#pushViewController` / `present` などを用いて画面遷移を構成
    }
  }
}

struct ExampleScreen: View {
  @ObservedObject private var viewModel: ExampleViewModel

  var onButtonTapped: (() -> Void)!

  init(viewModel: ExampleViewModel) {
    self.viewModel = viewModel
  }

  var body: some View {
    ExampleContent(onButtonTapped: onButtonTapped)
  }
}

@MainActor
final class ExampleViewModel: ObservableObject {
  @Published private(set) var uiState: ExampleUIState = .initial

  ...
}

方針の転換後も NavigationView での遷移を前提とした古い実装が一定残り続けていました。これらの画面は、古いiOSバージョンのサポートを前提とした書き方になっている(Deprecatedな .alert Modifierの使用など) / Xcode Previewsでのプレビューを考慮した実装が不完全など、細かい点においても直近実装された画面との差異が生じていました。

💭 Off Topic : 実装パターンの混在と技術トレンドの変遷

UIKitベースの画面も含めた “複数の画面実装パターンの混在” の課題からは長い目で捉えると以下に挙げるような課題が顕在化してくると考えています。

  • 複数の技術要素への理解が求められることでのキャッチアップコストの増大
  • 旧来の技術に触れ続けざるを得ないことによる開発者のモチベーション低下

また、モバイルアプリ領域の技術トレンドの移り変わりの速さも意識すべきポイントだと考えています。この “技術トレンドの変遷” のスパンについて筆者は漠然と2〜3年のイメージを持っています。UI周りの設計にフォーカスしても “AutoLayoutが登場し、MVVMなどのアーキテクチャ論が盛り上がり、SwiftUIが登場し、TCAが広がり…” と3年前後のスパンでトレンドが変遷してきた印象です。

昨年参加した iOSDC Japan 2023 ではメルカリの @motokiee さんの発表の中で「新しい技術要素も2〜3年後にはスタンダードに変化していくサイクル」との言及 *1 がありました。技術トレンドの変遷に追従しながらも、実装パターンが混在しないように継続的に整理・集約し続ける姿勢が求められるのかもしれません。

課題その2 : デザインシステムへの不完全な準拠

モジュール分割の大きな動機として、iOS版B/43 では “デザインシステムへの不完全な準拠” というサービス固有の課題もありました。

B/43 ではデザインシステムがトークンレベルからシステマチックに整備され、デザインと実装に活用しています。このデザインシステムはエンジニアとデザイナーだけが用いるに留まらず、PM / リサーチャーも交えたプロトタイピングでも積極的に活用されています。スマートバンクにおけるプロダクトの設計プロセスに深く根差したものになっています。

blog.smartbank.co.jp blog.smartbank.co.jp

デザインシステムに含まれるデザイントークンやコンポーネント群の iOS / Android アプリへの組み込みは2023年1月から開始され、夏ごろには大半のコンポーネント群の整備が完了していました。

しかし、iOS版B/43 ではこのコンポーネント群をすべて SwiftUI で整備しているため、UIKit で実装された旧来の画面からは活用できない状況になっています。また、 SwiftUI で実装された画面でも、適切にモジュールが分けられていない現状では、デザインシステムの導入以前に整備した古いコンポーネント群を参照できてしまう課題がありました。

本体モジュールに存在する各画面の実装から古いコンポーネント群を参照できてしまう

新しく整備されたデザインシステムの存在は、モジュール分割や画面実装パターンの整理を促す強いモチベーションにもなりました。

画面実装のFeatureモジュールへの分割

モジュール分割に至るまでの背景や課題感の説明はここまでとし、ここからは直近半年ほどチームで取り組んできたことについて触れていきます。

“複数の画面実装パターンの混在” “デザインシステムへの不完全な準拠” の2つの課題に際し、私たちのチームは「SwiftUIで実装されたすべての画面のFeatureモジュールへの分割」を進めることとしました。…と書くと、かなりカロリーの高い書き換えを力技で進めていくように聞こえるかもしれませんが、取り組んだこと自体は比較的シンプルです。

冒頭で UIHostingController を継承したクラスを画面ごとに用意する実装例を以下のように示しましたが、この例における ExampleScreen ExampleViewModel をそのまま新設したFeatureモジュールに分割しました。NavigationView での遷移を前提とした画面は事前に UIHostingController を用いる構成に書き換えています。

final class ExampleViewController: UIHostingController<ExampleScreen> {
  init() {
    super.init(rootView: ExampleScreen(viewModel: ExampleViewModel()))
  }
}

// 🚚 以下の View / ViewModel の実装をFeatureモジュールに分割 🚚

struct ExampleScreen: View {
  @ObservedObject private var viewModel: ExampleViewModel

  var body: some View {
    ExampleContent()
  }
}

@MainActor
final class ExampleViewModel: ObservableObject {
  ...
}

デザインシステムの導入以前に整備した古いUIコンポーネント群やリソースは本体モジュールに存在します。モジュール分割の過程で、それらに依存した画面実装はビルドが通らなくなり修正を強制されることから、自ずと “デザインシステムへの不完全な準拠” の課題も解消されていきました。

💭 Off Topic : モジュール分割された ViewModel の依存をどう注入するか

画面実装のモジュール分割を行う上で悩ましいポイントの一つは ViewModel が持つ依存をどう注入するかです。

iOS版B/43 では DIコンテナに Factory を用いています。データソースを扱う Repository なども含めた依存関係の宣言は、以下のように全て本体モジュールに存在します。ViewModel への依存の注入は Factory が提供する @Injected の Property Wrapper を用いて行っていました。

// Factoryによる依存関係の宣言の例(本体モジュールに存在)
extension Container {
  var apiClient: Factory<B43ApiClient> {
    Factory(self) { B43ApiClient() }
  }
  var exampleRepository: Factory<ExampleRepository> {
    Factory(self) { DefaultExampleRepository(apiClient: self.apiClient()) }
  }
}

// ViewModelへの依存の注入例
final class ExampleViewModel: ObservableObject {
  @Injected(\.exampleRepository) private var exampleRepository: ExampleRepository
}

依存関係の宣言が本体モジュールにあるため、ViewModel を別モジュールへ移動すると @Injected に渡す KeyPath (上述の例では \.exampleRepository )が参照できなくなります。これを解消するには依存関係の宣言を専用の別モジュールに切り出すなどの解決策が考えられますが、 iOS版B/43 では UIHostingController を継承した実装から Initializer Injectionで依存を注入するというシンプルなアプローチに留めています。

final class ExampleViewController: UIHostingController<ExampleScreen> {
  init() {
    super.init(
      rootView: ExampleScreen(
        viewModel: ExampleViewModel(
          // 本体モジュールの UIHostingController を継承した実装の中で依存を解決する
          exampleRepository: Container.shared.exampleRepository()
        )
      )
    )
  }
}

@MainActor
final class ExampleViewModel: ObservableObject {
  private let exampleRepository: ExampleRepository
  
  init(exampleRepository: ExampleRepository) {
    self.exampleRepository = exampleRepository
  }
}

モジュール分割への取り組み方

画面実装のFeatureモジュールへの分割方針は決まりましたが、各メンバーが新規機能の開発タスクも抱える中で、闇雲に進めていくのではモチベーションを維持して取り組み続けるのは難しいでしょう。

私たちがまず最初に取り組んだのは “進捗の可視化” です。モジュール分割の方針を議論する際に、簡単に実装パターンを集計するスクリプト *2 を作成していましたが、それを流用する形で毎朝 Slack に進捗状況を通知するようにしています。

毎朝、移行状況がSlackに通知されるように設定

チームでは ”3月末にはモジュール分割を完了させたい” という目標を立てていましたが、逆算する形で月や週単位の移行ペースを意識することができたと思っています。新規機能の開発タスクが忙しい中でも毎週2時間のエンジニア自由研究活動(※ ネタ切れなどにより一時休止中)の枠も用いて、着実に移行を進めてきました。

SwiftUI ベースの全画面のモジュール分割を達成した時のSlack通知 🎉

目標時期を前倒す形で2月中旬には SwiftUI ベースの全画面のモジュール分割を完了することができました。SwiftUI で実装された画面における “複数の画面実装パターンの混在” “デザインシステムへの不完全な準拠” の2つの課題感は大きく解消されました。

今、私たちの目の前にあるのは “実装や依存関係が適切に整理されてデザインシステムに準拠できたSwiftUIの画面” と “100以上のUIKitの画面” の2種類です。改善の取り組みはまだまだ続きそうですね… 💪

まとめ

このエントリーでは iOS版B/43 における画面実装のモジュール分割に至るまでの課題認識とそれを解消していくために取り組んできたことを紹介してきました。

SwiftUI ベースの画面実装のモジュール分割やデザインシステムへの準拠は達成できましたが、前述の通り “画面実装パターンの集約” の観点ではまだ UIKit ベースの画面が半数以上残っています。今後の取り組みの中でも新たな知見や気づきを得られれば、またこのブログかどこかで紹介させていただこうと思っています。


スマートバンクではモバイルアプリエンジニアを積極採用中です 🔥

私たちのチームや B/43 のアプリ開発については、以下のエントリーもぜひご覧ください。スマートバンクの強みであるユーザーに寄り添ったプロダクトの開発プロセスはもちろん、モバイルアプリ開発の第一線で活躍しているパートナー2名にチームへ加わっていただいているなど、多くの魅力ある環境だと自負しております…! blog.smartbank.co.jp

カジュアル面談も以下のリンクから受け付けています!皆さまの応募、お待ちしております 🙌 smartbank.co.jp

*1:https://speakerdeck.com/motokiee/mercari-10years-ios-development?slide=141

*2:プロジェクト内のディレクトリを探索し、ファイル名あるいはコード中に含まれる文字列を基に画面数を集計するSwiftスクリプト

We create the new normal of easy budgeting, easy banking, and easy living.
In this blog, engineers, product managers, designers, business development, legal, CS, and other members will share their insights.