こんにちは、クライアントエンジニアの竹澤(@to4iki)です。
MirrativのiOSチームでは、開発効率の最大化を狙い以下に取り組んでいます。
- データフローの単方向化 (Fluxアーキテクチャの強制)
- 宣言的UIによるView実装 (SwiftUIの部分適用)
- 責務分割 (モジュール分割)
今回の記事では、どのような構成でSwiftUIを適用し始めたか、また、MirrativのiOSプロジェクトの構成やプロダクト特性の課題感からセットでモジュール分割を進めている点を紹介します。
背景
Androidのマルチモジュール導入の記事で触れているように、Mirrativのアプリは配信/視聴機能、アバター機能、リッチなアニメーションなど複雑な要素を一つの画面で表現することがあります。
状態の更新・管理に関しては単方向データフローを強制するReactorKitを用いて実装をしていますが、状態を反映するViewはUIKitベースのレガシーな実装が残り続け開発時の障害となっており改善の余地がありました。
ReactorKitの導入に関して詳しくは、以下の記事をご参照ください。
Mirrativ iOSアプリではウィジェット機能で既にSwiftUIを使用しています。 本体アプリへの適用はViewから扱う下位レイヤーの整理を優先し後回しになっていましたが、サポートOSが上がり最大限SwiftUIの機能を活用することが可能であり、中長期の開発効率に寄与する見込みがあったため、SwiftUIへの置き換えと新規実装への積極活用にチャレンジすることにしました。 具体的には、既存のInterfaceBuilder・UIKitによるコードレイアウトの場合と比較し、要するコード量は少なく見通しも良いため、新規の画面作成だけでなくその後の修正もスムーズになると感じています。
以降では、View実装の開発者体験向上に目的を絞った上でどのようにSwiftUIとモジュール分割を進めているかお話します。
前提となるプロジェクト構成
MirrativのiOSアプリはマルチターゲットメンバーシップを採用しており、以下のようなTargetに分かれています。
- mirrorman: 旧コードネームを踏襲しているメインターゲット
- broadcast-upload: 配信プロセス、AppExtension
- widget: ウィジェット、AppExtension
- shared: 本体アプリとAppExtensionで利用する共通実装
非常にシンプルな構成ですが、それが故に機能追加に伴う複数コンテキストの処理を分離しきれず責任範囲の広い実装が生まれがちです。また、垂直・水平方向の依存に関して制限が緩いことで意図しない結合が生まれやすく、コードの可読性を維持・向上し不具合が発生したときに影響範囲を限定することが難しくなってきました。*1
そこで、前述したSwiftUIの適用とセットで、責務分割による影響範囲の限定化を狙いSwiftPackageを利用したモジュール分割に本格的に着手することにしました。
導入に向けて決めたこと
SwiftUIの適用とモジュール分割に向けて色々と整理したくなるのですが、既存のプロジェクト構成やアーキテクチャを大きく触らず進めていく方針にしました。以下で詳しく説明します。
SwiftUIでどこまで実装するか
まず始めに、課題を解決するためにどこまでをSwiftUIで実装するか決める必要があります。
iOSプロジェクトでは前述したUIKitから扱うことを想定したReactorKit・RxSwiftにViewの実装が依存しています。これを剥がし置き換えることも検討したのですが、SwiftUIの宣言的なUI開発における実装のシンプル化・可読性・保守性の向上が目的となるので、適用の初期フェーズにおいては既存の仕組みをベースに SwiftUI.View
をUIKit製の画面から扱うViewの一部ないしはView全体の位置付けで描画に特化させて扱うことに決めました。
SwiftUIでどこまで実装するかはiOSDC2022「SwiftUI in UIKit で開発する世界」を参考にさせて頂きました。
※ 後ほど触れますが、現在はPDSで言うところのDomainレイヤーの整理を進めながら、SwiftUI単体でのView全体 + ロジックの実装を検討しています。
本体アプリではなくSwiftPackageを活用する
Xcode Previews を活用することでViewの開発・デザインに関わる調整時間が削減できるはずです。
しかし、前提で触れた構成の通りほとんどの実装がメインターゲットに集約されているので、この中で SwiftUI.View
を実装し Xcode Previews を利用した場合はビルドタイムのオーバーヘッドがかかります。
そのため、UIの開発サイクルを素早く回すことを優先し、メインターゲットではなく新規SwiftPackage内でSwiftUIによる画面開発を進め且つモジュールごとに Xcode Previews を動作させることにしました。
メインコードをSwiftPackageに寄せていくことによるメリットは他にも、中長期的にはレガシーなメインターゲットの実装をフリーズし隔離することで複数人開発による project.pbxproj
のコンフリクト機会の減少にも繋がりますし、既存実装に全く依存しない独立した形で実装した方がメンテナンスがしやすいと考えているのでこちらの方針を取りました。
また、Mirrativアプリでは短命なイベント用の画面や、使い回しを想定しないシーン毎に最適化した画面を実装するケースがあります。賛否あると思いますが、ゲーム配信というプロダクトの特性上、色数や既存の概念・コンポーネントに縛られず局所最適なUI開発を意図的に行っています*2。そのため、既存実装においてUtility的なコンポーネントが少なく、まとまった単位でモジュールに分割しやすいというのも選択した理由の一つです。
まとめると、前述した通りSwiftUIを描画に特化させて扱うので、それを格納するSwiftPackage自体がUIレイヤーの位置付けになります。メインターゲットに置いているUI実装と責務が重複するように見えますが、メインターゲットでは遷移やロジックを実装する UIViewController
- Reactor
を定義し、SwiftPackage側で中身のViewを定義することで役割分担をすることに決めました。
垂直分割するか水平分割するか
次に、モジュールをどのような形式で分割するかを決めます。
pointfreeco/isowords で行われているように関連する画面のまとまりごとに機能単位でモジュールを垂直に分割する方法や、The Clean Architectureのように UI, Domain, Infrastructure のような責務単位(クラスの役割)毎に水平方向に分割する方法がありますが、メインターゲットが機能単位でフォルダ分けしていたのと、プロダクトの特性上一定のコンテキストで機能が閉じていることが多かったので、大きな方針としては機能単位でモジュールを分けることにしました。また、現段階では未検討ですが、機能単位で切られていた方が確認サイクルの高速化を狙ったミニアプリ化に着手しやすいだろうと考えたのも要因の1つです。
一方で気になる点としては、どのモジュールにファイルを配置するかはドメインの知識も求められますし議論に時間を要することが考えられます。この点においては、ある程度の強制力により影響範囲が限定化されたら良いのと、細かくするのは後からも出来るだろうと判断し、Featureモジュールの数を制限し大まかに分割することにしました。
繰り返しになりますが、SwiftPackageによるモジュール分割の初期フェーズでは SwiftPackage = UIレイヤー の位置付けで利用することになります。今後は、SwiftPackage内でのロジックを含む開発が出来るように、メインターゲットに存在するどのモジュールからも参照するNetworkの実装などは水平方向のモジュールとしてSwiftPackageに積極的に切り出し、参考にさせて頂いた「小さなチームでマルチモジュール開発をしてみた話 - Timee Product Team Blog 」で触れているような垂直-水平方向のハイブリッド形式に調整していこうと考えています。
ここまで話した内容から、従来のプロジェクト構成をもとに拡張した依存関係と Package.swift
の一部を紹介します。
以下の図はメインターゲットのXxxFeatureディレクト内の UIViewController
からSwiftPackage内のXxxFeatureモジュールを参照する例となります(Package名はAppPackageとしています)。
今の所は、メインモジュールへ埋め込むフレームワーク数を抑え単純化したいのとモジュール数を制限する強制力としAppFeatureというライブラリに固めるようにしています。こちらは、Xcode Previews のビルド時間を見つつ今後モジュール単位でのライブラリ化を検討するかもしれません。
let package = Package( name: "AppPackage", defaultLocalization: "ja", platforms: [.macOS(.v10_15), .iOS(.v15)], products: [ .library( name: "AppFeature", targets: [ "LiveGameFeature" // 複数のFeatureモジュール ] ) ], dependencies: [ .package(url: "https://github.com/airbnb/lottie-spm.git", exact: "4.3.3") ], targets: [ // Feature Layer .target( name: "LiveGameFeature", dependencies: [ "UICore" ], path: "./Sources/Feature/Catalog" ), ... // Core Layer // Feature間で共通利用するUIやドメインの実装 .target( name: "UICore", dependencies: [ "Assets", "DesignSystem" ], path: "./Sources/Core/UI", resources: [ .process("Resources") ] ), // DesignSystem .target( name: "DesignSystem", dependencies: [ "Assets", ], resources: [ .process("Resources") ] ), // Assets .target( name: "Assets", path: "./Sources/Resource/Assets", resources: [ .process("Resources") ] ) // ... ] )
方針やTipsを明文化し育てる
SwiftUIに限らずですが、要件を実現する過程で複数の記述があり迷うポイントやPR上で議論になる箇所があります。それらはドキュメントとして書き起こし随時定例などの場でiOSチームとしてどれを選択するか走りながら決め育てていくことにしました。
また、モジュール分割の推進に関しても土台はリードエンジニアが固めるにしても特定の個々人でのみ進め続けるのではなく、チーム全体を巻き込むように勉強会の時間で周知を行ったり指針となる考えをまとめています。*3
新規開発部分のUI開発をFeatureモジュールで行う
影響範囲を限定したFeatureモジュール内でSwiftUIを適用する方針が立ちこの形でやっていくぞとなったので、試しにボタン操作を伴うシンプルな画面の実装を考えてみます。*4
SwiftPackage: SwiftUI.Viewの実装
はじめにSwiftPackage内のView実装を考えます。 MirrativのiOSではAndroidのアーキテクチャガイドラインにあるように表示状態を示すUiStateを定義するように決めているので、まずは表示モデルから考えます。このモデルはUIのレンダリングに必要な情報のスナップショットとしてAPIレスポンスからの変換や静的な情報であればViewの描画前に確定するような実装を想定しています。
/// UiStateを慣習的にXxxItemとしている public struct LiveGamePurchaseGameItem { public let title: String public let price: Int public let quantity: Int public let iconUrl: URL? }
SwiftUI.Viewの再描画用にViewModelを定義しUiStateを保持します。suffixにViewModelとつけていますがデータの更新ロジックは後述のReactorが持つことになるので、個人的にはViewHolderのような名前付けでも良いかなと考えておりチーム内での議論の対象になりそうです。
/// シーン切り替え用の状態+外部引数として受け取るステートレスな表示要素を保持するViewModel public final class LiveGamePurchaseViewModel: ObservableObject { public enum Screen { case top case coinShortagePurchase case coinShortageError } let item: LiveGamePurchaseGameItem @Published var screen: Screen = .top public func setScreen(to screen: Screen) { self.screen = screen } }
UiStateを表示するViewの実装は以下です。
public struct LiveGamePurchaseView: View { // ハンドラの取違を防ぐためのphantom type // 本実装においては直接的に関係がないので解説は省略する enum ItemPurchaseEvent: ViewEventType {} enum CoinPurchaseEvent: ViewEventType {} enum PopupCloseEvent: ViewEventType {} @ObservedObject private var viewModel: LiveGamePurchaseViewModel private let didTapItemPurchaseButton: ViewEvent<ItemPurchaseEvent> private let didTapCoinPurchaseButton: ViewEvent<CoinPurchaseEvent> private let didTapCloseButton: ViewEvent<PopupCloseEvent> ... /// シーン毎に表示内容が切り替わる画面 public var body: some View { switch viewModel.screen { case .top: LiveGamePurchasePopupView( viewModel: viewModel, didTapCloseButton: didTapCloseButton, didTapItemPurchaseButton: didTapItemPurchaseButton ) case .coinShortagePurchase: ... case .coinShortageError: ... } } }
メインターゲット: UIViewControllerの実装
SwiftPackage > モジュール内に定義した SwiftUI.View
をメインターゲットから参照します。UIHostingController
を実装した UIViewController
を利用するだけなのですが、SwiftUI.View
内でのイベント通知を Reactor.Action
に接続し、従来通り状態の変更を始めとした副作用をReactor内部で取り扱うようにしています。
import LiveGameFeature import ReactorKit import RxRelay import SwiftUI final class LiveGamePurchaseViewController: UIHostingController<LiveGamePurchaseView>, ReactorKit.View { typealias Reactor = LiveGamePurchasePopupReactor private var viewModel: LiveGamePurchaseViewModel fileprivate let presentCoinPurchase: PublishRelay<Void> var disposeBag = DisposeBag() init(reactor: Reactor, item: LiveGamePurchaseGameItem) { let presentCoinPurchase = PublishRelay<Void>() self.viewModel = LiveGamePurchaseViewModel(item: item) let view = LiveGamePurchaseView(viewModel: viewModel) { [weak reactor] in reactor?.action.onNext(.purchase) } didTapCoinPurchaseButton: { [weak presentCoinPurchase] in presentCoinPurchase?.accept(()) } didTapCloseButton: { [weak reactor] in reactor?.action.onNext(.purchaseCancel) } self.presentCoinPurchase = presentCoinPurchase super.init(rootView: view) self.reactor = reactor } }
ViewModelで保持している状態更新のトリガーとなるようなイベント通知は従来通りメインターゲットの Reactor.State
で管理し、ストリームとしてViewに通知します。
func bind(reactor: LiveGamePurchasePopupReactor) { reactor.pulse(\.$isCoinShortage) .compactMap { $0 } .filter { $0 } .observe(on: MainScheduler.asyncInstance) .bind(with: self) { `self`, _ in // シーンの切り替えで SwiftUI.View の再描画が実行される self.viewModel.setScreen(to: .coinShortagePurchase) } .disposed(by: disposeBag) }
以上、Viewに絞った簡単な解説ですが、従来通り単方向のデータフローを実現するReactorを使ったFluxアーキテクチャに沿いながら SwiftUI.View
を適用しています。
SwiftUI適用・モジュール分割の現状
SwiftUIに関して、デザインや実装の手段に依存しますが、UIKitと比較した際のパフォーマンス劣化が想定できます。*5 *6 ユーザービリティを犠牲にしてまでの適用は本末転倒なので、SwiftUIで実装することによる効果が高い画面かどうかは相談しながら進めている段階で、正直まだまだスタートしたばかりです。
モジュール分割に関しては、切り出しのタイミングでFeatureモジュールを作成しつつ、共通利用するカスタムビューやライブラリ実装のモジュールを作成しています。こちらもスタートしたばかりであり、実装事例を増やしチームとして慣れていくのと並行し、メインターゲットの実装がかなり巨大なため関連要素を引き剥がし粛々とモジュールに切り出していくのが目下取り組んでいるアクションとなります。
終わりに
今回紹介したSwiftUI+モジュール分割に関しては真新しいことは行なっていません。保守的なアプローチかもしれませんが、先行しモジュール分割やJetpackCompose化を進めているAndroidチーム始め他社の事例を参考にしながらも、iOSチームの状況を踏まえ最小構成で適用することで今のところメリットを享受できています。
次のフェーズでは、メインターゲット・AppExtensionから利用する下位レイヤーのSharedターゲットの処理をSwiftPackageに移動しモジュール化を検討しています(所謂Coreモジュールという存在)。また、巨大なシングルトンクラスへの依存を整理し各モジュールに注入するようなDI対応も必要になりますが、これらを実現することでモジュール内に閉じたSwiftUIによるView全体+ロジックの実装が実現出来るはずです。
その際には、メインターゲットで適用しているアーキテクチャや依存しているライブラリ(ReactorKit, RxSwift)を移行するのか、はたまたレガシーな実装として隔離することになるかもしれません。いずれにせよ、SwiftUIやモジュール内での実装の練度を上げて実用例を増やしていくのと並行しUIレイヤーから下のDomain層の調整は必要なので、こちらを地道に整理していこうと言うのがiOSチームの現在地です。
このようなユーザーに素早く良い形で価値を届けるための開発改善系の取り組みはメリットを感じるところから徐々に行っていくので、いつかまたブログにて状況を報告できればと思います!!!
We’re Hiring!!
ミラティブのアプリチームではユーザーにより価値を届けるためにチームを巻き込んで共に成長していける方を募集中です!
技術を深掘りして成長したい方お待ちしています!
参考資料
- https://developer.android.com/topic/modularization/patterns?hl=ja
- https://speakerdeck.com/d_date/swift-package-centered-project-build-and-practice
- https://buildersbox.corp-sansan.com/entry/2021/12/23/110000
- https://techlife.cookpad.com/entry/2021/06/16/110000
- https://github.com/uhooi/Loki
*1:ReactorはあくまでもViewと1:1で紐付く存在であり画面単位での実装方針は立ちましたが、システム全体で見た時の方針に関しては課題感があります
*2:https://note.com/mirrativ/n/nc2c55247bc80#8doVO
*3:本ブログもチーム内への周知を兼ねているため、敢えて繰り返しの表現をしていたり冗長な言い回しが多くなっている箇所があります
*4:簡略化したサンプルコードとなります
*5:https://kth.diva-portal.org/smash/record.jsf?pid=diva2%3A1789094&dswid=8933
*6:https://speakerdeck.com/shimastripe/configuration?slide=43