TECH PLAY

3D

イベント

マガジン

技術ブログ

はじめに こんにちは。グローバルプロダクト開発本部 グローバルアプリ部 アプリ基盤ブロックの桂川です。普段はZOZOFIT・ZOZOMETRYなどの計測アプリのAndroid開発に携わっています。本記事ではZOZOFITのAndroidアプリで取り組んだMVVMからMVIへの移行と、独自MVIライブラリの開発について紹介します。なお、独自MVIライブラリを使ったMVIアーキテクチャへの移行は2024年9月に開始しました。 目次 はじめに 目次 用語 ZOZOFIT MVVM SSOT UDF MVI 私たちのMVVMアーキテクチャの問題点 ViewModelでのState管理が複雑に ViewとViewModelの責務が曖昧に イベント通知と画面遷移の不統一 私たちのMVVMアーキテクチャの改善方針 UiStateによるState管理の単純化 ユーザー操作ごとのメソッド定義による責務の明確化 Channelによるイベント通知と画面遷移の統一 私たちのMVVMアーキテクチャの改善方針を運用できるか MVIアーキテクチャの導入と独自ライブラリの作成 データフロー 実装 インタフェースの定義 移譲を用いたインタフェースの実装 MVIアーキテクチャを独自MVIライブラリで実装する Contract: State・Action・SideEffectの定義 ViewModel: Actionの処理とState更新 View: MviContentによるCompose連携 テスト: Actionを送信してState・SideEffectを検証 MVVMアーキテクチャからMVIアーキテクチャに移行してみて チーム全体で一貫した実装ができるようになった PRレビューの質が向上した AIコーディングエージェントとの協業がしやすくなった まとめ 用語 まず、本記事で使用する用語を整理します。 ZOZOFIT ZOZOFITは、自宅で手軽に高精度な3Dボディスキャンができる体型管理サービスです。ZOZOSUITと専用スマートフォンアプリを活用し、全身3Dスキャンが可能です。計測データに基づき、体の変化を3Dモデルと数値で可視化できます。栄養素を記録・分析するフードジャーナル機能など、計測以外の機能でも総合的な健康管理をサポートしています。本記事ではアメリカなど海外で展開しているZOZOFITのAndroidアプリでの改善についてお話しします。 zozofit.com MVVM MVVM(Model-View-ViewModel)は、UIの状態を管理するアーキテクチャスタイルの1つです。Model・View・ViewModelの3要素で構成され、ViewModelがModelとViewの仲介役を担います。ViewはViewModelが公開する状態を監視して画面に反映し、ユーザー操作はViewModelのメソッドを呼び出すことで処理されます。Androidアプリ開発で広く採用されているアーキテクチャです。データの流れは次のとおりです。 Viewがユーザー操作をViewModelのメソッド呼び出しとして送る ViewModelが状態を更新し、StateFlowで公開する ViewがStateFlowを購読して画面に反映する SSOT SSOT(Single Source of Truth)は、各データ型に対して唯一の信頼できるデータソースを持つ考え方です。SSOTだけがデータを変更でき、不変の型で公開します。これによりデータの変更が1箇所に集約され、他の型による改ざんを防ぎ、バグの追跡を容易にします。 UDF UDF(Unidirectional Data Flow)は、SSOTと組み合わせて使用されるパターンです。状態(データ)は上位から下位へ一方向に流れ、状態を変更するイベントはその逆方向に流れます。具体的には次の流れでデータが更新されます。 Android公式ドキュメント でも、堅牢なアーキテクチャの原則としてSSOTとUDFが示されています。この2つをセットで守ることで、データの整合性が保たれ、デバッグ・テスト・レビューがしやすくなります。本記事で紹介するMVIアーキテクチャもこの原則に基づいており、SSOTとUDFの理解が必要です。 ユーザー操作(ボタン押下など)が下位スコープで発生する イベントが下位スコープから上位スコープ(SSOT)へ向かって流れる SSOTでデータが変更され、不変の型として公開される 変更された状態が上位スコープから下位スコープへ流れる 下位スコープが新しい状態を受け取り、表示を更新する MVI MVI(Model-View-Intent)は、UDFの原則に基づいてUIの状態を管理するアーキテクチャスタイルの1つです。データの流れが一方向に固定されるため、状態変更の起点と結果が追跡しやすくなります。MVIの名前はModel・View・Intentの頭文字に由来しており、以下の3要素で構成されます。なお、本記事では用語の紛らわしさを避けるため、以降ModelをState、IntentをActionと呼びます。 要素 役割 Model(State) 画面の現在状態を表すデータ。UIはこの値のみから構築される。 View Stateを受け取って画面に反映し、ユーザー操作をActionとして発行する。 Intent(Action) ユーザー操作や外部イベントなど、状態更新のきっかけとなる入力。 Viewがユーザーの操作をActionとして発行する ActionをもとにStateが更新される 更新されたStateがViewへ通知され、画面に反映される 私たちのMVVMアーキテクチャの問題点 ZOZOFITのAndroidアプリは2022年のリリース当初からJetpack Composeを採用しており、当時からMVVMアーキテクチャを採用して開発を続けていました。私たちのMVVMアーキテクチャではViewModelで定義したStateFlowをViewで購読し、ViewModelのメソッドをViewから呼び出して状態を更新する、というシンプルな設計でした。 class CounterViewModel : ViewModel() { private val _counter = MutableStateFlow( 0 ) val counter: StateFlow< Int > = _counter.asStateFlow() fun increment() { _counter.value + = 1 } fun decrement() { _counter.value - = 1 } fun reset() { _counter.value = 0 } } しかし開発が進み画面数や機能が増えるにつれて、Jetpack ComposeとMVVMの組み合わせにおいて、いくつかの問題が顕在化していきました。特にStateFlowの管理やイベント通知の設計がチーム内で統一されておらず、不具合やレビュー負荷の増加につながっていました。具体的には以下のような課題がありました。 ViewModelでのState管理が複雑に 表示データごとに個別のStateFlowを定義していたため、画面が複雑になるほど Flow.map や combine による合成が増えていきました。各Flowの更新タイミングが把握しづらくなり、意図しない再Composeや画面のチラつきが発生していました。 // CounterViewModel.kt: 表示データごとに個別のFlowが定義されている class CounterViewModel : ViewModel() { private val _counter = MutableStateFlow( 0 ) val counter: StateFlow< Int > = _counter.asStateFlow() // Flow.mapで派生StateFlowを作成 → 更新タイミングが分かりにくい val doubleCount: StateFlow< Int > = _counter.map { it * 2 } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 0 ) val tripleCount: StateFlow< Int > = _counter.map { it * 3 } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), 0 ) } またView側のComposable関数でも引数が増えていく傾向がありました。View側のコードに多くの collectAsState が定義され、見通しが悪く、管理が難しいコードになることも多々ありました。 // CounterScreen.kt: Flowごとに個別にcollectし、引数が増えていく @Composable fun CounterScreen(viewModel: CounterViewModel, navController: NavController) { val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() val counter by viewModel.counter.collectAsStateWithLifecycle() val doubleCount by viewModel.doubleCount.collectAsStateWithLifecycle() val tripleCount by viewModel.tripleCount.collectAsStateWithLifecycle() CounterScreenContent( isLoading = isLoading, counter = counter, doubleCount = doubleCount, tripleCount = tripleCount, onIncrement = { /* ... */ }, onDecrement = { /* ... */ }, onReset = viewModel :: reset, // ... ) } ViewとViewModelの責務が曖昧に ViewがViewModelの構造を知りすぎるコードになりがちで、本来ViewModelで完結すべきロジックがView側に漏れ出していました。ViewModelのプロパティを直接読み取って条件分岐する実装や、複数メソッドを特定の組み合わせで呼び出す実装が各所に存在していました。 // ViewがViewModelのプロパティを直接読み取ってToast表示を制御している val context = LocalContext.current Button( onClick = { viewModel.increment() if (viewModel.currentCount == 10 ) { Toast.makeText(context, "10に到達しました" , Toast.LENGTH_SHORT).show() } } ) { Text( "Increment" ) } // 1つのユーザー操作に対してView側が複数メソッドを組み合わせて呼んでいる Button( onClick = { viewModel.increment() viewModel.checkLimit() } ) { Text( "Increment" ) } このようにViewがViewModelの構造を知りすぎているため、機能変更時の影響範囲が広がりやすくなり、レビュー負荷や不具合の原因になっていました。 イベント通知と画面遷移の不統一 Toast表示や画面遷移といった一度きりの処理について、実装パターンが明確に統一されていませんでした。Toast表示ではViewModelからイベントを発行してView側で購読するパターンと、View側でStateを直接監視して処理するパターンが混在していました。 // CounterScreen.kt: ViewModelのイベント経由でToast表示 LaunchedEffect( Unit ) { viewModel.event.collect { event -> when (event) { is CounterEvent.ShowToast -> Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() } } } Button(onClick = { viewModel.increment() }) { Text( "Increment" ) } // CounterScreen.kt: View側でStateを直接監視してToast表示 val counter by viewModel.counter.collectAsStateWithLifecycle() LaunchedEffect(counter) { if (counter >= 10 ) { Toast.makeText(context, "10に到達しました" , Toast.LENGTH_SHORT).show() } } Button(onClick = { viewModel.increment() }) { Text( "Increment" ) } 画面遷移についてもViewModelのイベント経由で遷移するパターンと、Composable関数から直接Navigatorを呼び出すパターンが混在していました。 // CounterScreen.kt: ViewModelのイベント経由で画面遷移 LaunchedEffect( Unit ) { viewModel.event.collect { event -> when (event) { is CounterEvent.NavigateSetting -> navController.navigateSetting() } } } Button(onClick = { viewModel.navigateSetting() }) { Text( "Setting" ) } // CounterScreen.kt: Composable関数から直接Navigatorを呼び出して画面遷移 Button(onClick = { navController.navigateSetting() }) { Text( "Setting" ) } 方式が統一されていないため、新しい画面を実装する際にどの方式へ合わせるべきか判断しづらく、開発者ごとの実装のばらつきを招いていました。さらにStateを直接監視する方式では、画面に戻ってきた際にイベントが再発火して意図しない動作が発生する不具合も起きていました。 私たちのMVVMアーキテクチャの改善方針 これらの問題を放置すれば開発効率・品質ともに低下し続けるため、各課題に対して以下のような解決方針を考え、まずは既存のMVVMアーキテクチャの枠組みの中で改善できないか検討を進めました。 課題 解決方針 State管理の複雑化 画面の状態を1つのdata classに集約し、単一のStateFlowで管理する ViewとViewModelの責務が曖昧 ユーザー操作をイベントとして定義し、処理をViewModel内に集約する イベント通知と画面遷移の不統一 イベント通知をChannelに統一し、画面遷移もイベント経由に統一する UiStateによるState管理の単純化 SSOTの原則に従い、画面の状態を1つのdata classに集約して単一のStateFlowで管理する方針を考えました。Viewは信頼できる唯一のソースを購読して画面に反映するだけのシンプルな構造になります。また状態の更新が _state.update に集約されるため、 Flow.map や combine による合成が不要になり、更新タイミングも制御しやすくなると考えました。 // CounterUiState.kt: 画面の状態を1つのdata classに集約し、派生値もdata class内で計算する data class CounterUiState( val count: Int = 0 , ) { val doubleCount: Int get () = count * 2 val tripleCount: Int get () = count * 3 } // CounterViewModel.kt: 単一のStateFlowで管理し、ユーザー操作ごとにメソッドを定義 class CounterViewModel : ViewModel() { private val _state = MutableStateFlow(CounterUiState()) val state: StateFlow<CounterUiState> = _state.asStateFlow() fun onIncrementClicked() { _state.update { it.copy(count = it.count + 1 ) } } } // CounterScreen.kt: View側は単一のStateを購読するだけ @Composable fun CounterScreen(viewModel: CounterViewModel, /* ... */ ) { val state by viewModel.state.collectAsStateWithLifecycle() CounterScreenContent( state = state, onIncrement = viewModel :: onIncrementClicked, // ... ) } ユーザー操作ごとのメソッド定義による責務の明確化 UDFの原則に従い、ViewからのAction(ユーザー操作)に反応してStateが更新されるシンプルな構造を考えました。ユーザー操作ごとにメソッドを定義し、関連する更新処理をすべてそのメソッド内に集約します。これによりView側はユーザー操作をViewModelに伝えるだけの役割になり、具体的な処理はすべてViewModel側で完結するため、責務が明確になると考えました。 // CounterViewModel.kt: ユーザー操作(Action)ごとにメソッドを定義し、処理をViewModel内に集約 class CounterViewModel : ViewModel() { private val _state = MutableStateFlow(CounterUiState()) val state: StateFlow<CounterUiState> = _state.asStateFlow() fun onIncrementClicked() { viewModelScope.launch { _state.update { it.copy(count = it.count + 1 ) } checkLimit() } } private suspend fun checkLimit() { /* ... */ } } // CounterScreen.kt: ViewはActionを発行するだけ CounterScreenContent( state = state, onIncrement = viewModel :: onIncrementClicked, onDecrement = viewModel :: onDecrementClicked, onReset = viewModel :: onResetClicked, ) Channelによるイベント通知と画面遷移の統一 イベント通知と画面遷移の方式をChannelに統一する方針を考えました。一度限りのイベントをsealed classで定義し、Channelで配信することで、StateFlowのように状態として保持されず再受信による不具合を防げます。 画面遷移もイベントの一種として扱い、すべてViewModel経由で発行する形に統一します。単純な遷移であればViewから直接呼び出す方がシンプルですが、実際には遷移前の条件チェックやパラメータの組み立てが必要になるケースが多いです。そのためViewModel側に集約する方が一貫性を保ちやすいと判断しました。 // CounterEvent.kt: イベントと画面遷移をsealed classで定義 sealed class CounterEvent { data class ShowToast( val message: String ) : CounterEvent() data object NavigateSetting : CounterEvent() } // CounterViewModel.kt: イベント通知と画面遷移をChannelで統一的に配信 class CounterViewModel : ViewModel() { private val _state = MutableStateFlow(CounterUiState()) val state: StateFlow<CounterUiState> = _state.asStateFlow() private val _event = Channel<CounterEvent>(Channel.BUFFERED) val event: Flow<CounterEvent> = _event.receiveAsFlow() fun onIncrementClicked() { viewModelScope.launch { _state.update { it.copy(count = it.count + 1 ) } checkLimit() } } fun onSettingClicked() { viewModelScope.launch { _event.send(CounterEvent.NavigateSetting) } } private suspend fun checkLimit() { val count = _state.value.count if (count >= 10 ) { _event.send(CounterEvent.ShowToast( "10に到達しました" )) } } } // CounterScreen.kt: イベントをChannelで統一的に購読し、画面遷移やToastを一元的に処理 LaunchedEffect( Unit ) { viewModel.event.collect { event -> when (event) { is CounterEvent.ShowToast -> Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show() CounterEvent.NavigateSetting -> onNavigateSetting() } } } 私たちのMVVMアーキテクチャの改善方針を運用できるか ここまで紹介した改善方針は、SSOTに基づくState集約、UDFに基づくAction定義、Channelによるイベント通知の統一です。これらは既存のMVVMアーキテクチャの枠組みで実現できることがわかりました。しかしルールとして定めるだけでは、複数人開発の中で徐々に形骸化していくことが課題としてありました。 UiStateにまとめるルールがあっても、急ぎの対応で新しいStateFlowが追加され、元の設計に戻ってしまう ユーザー操作ごとにメソッドを定義する方針でも、View側から複数メソッドを直接呼び出す実装がレビューをすり抜けてしまう Channelに統一するルールがあっても、既存コードを参考にStateFlowでイベント通知を実装してしまう また改善方針を各画面で愚直に実装すると、StateFlowやChannelの定義・購読といったボイラープレートが画面ごとに増加することも課題でした。 MVIアーキテクチャの導入と独自ライブラリの作成 これらの課題から、ルールではなく仕組みとして正しい実装に導かれるよう、MVIアーキテクチャを導入することにしました。 MVIアーキテクチャの導入にあたり、既存のOSSライブラリも検討しました。しかし私たちが必要としているのはシンプルなMVIのデータフローであり、既存のOSSライブラリは多機能で学習コストが高いと感じました。実現に必要なコード量も少なく自分たちで開発できる規模だったため、プロジェクトの特性に合わせた独自MVIライブラリを作成することにしました。 データフロー 独自MVIライブラリでは、前述の改善方針をMVIの設計思想に沿って整理することにしました。MVIのState・View・Actionに加えて、画面遷移やToast表示といった一度限りのイベントを扱うSideEffectを導入しています。 要素 役割 対応する改善方針 State 画面の現在状態を表す単一のdata class。UIはこの値のみから構築される。 SSOTに基づくState集約 View Stateを受け取って画面に反映し、ユーザー操作をActionとして発行する。 - Action ユーザー操作をViewからViewModelへ伝える入力。 UDFに基づくAction定義 SideEffect 画面遷移やToast表示など、一度限りのイベント。ChannelでViewに配信される。 Channelによるイベント通知統一 ViewからActionが送信されると、ViewModelがそれを受け取ってStateを更新するか、SideEffectを発行します。このシンプルなデータフローにより、ユーザー操作がどのように処理されるかを一貫した流れで追えるようにしています。 実装 インタフェースの定義 まず、MVIの各要素に対応するマーカーインタフェースとして MVIState ・ MVIAction ・ MVISideEffect を定義しました。各画面のState・Action・SideEffectクラスへこれらを実装させることで、型パラメータの制約として利用し、誤った型の組み合わせをコンパイル時に検出できます。 次に、MVIのデータフローを実現するための MVI インタフェースを定義しました。Stateの購読( state )、Actionの受け取り( onAction )、Stateの更新( update )、SideEffectの発行( sideEffect )を集約しています。 interface MVIState interface MVIAction interface MVISideEffect interface MVI<State : MVIState, Action : MVIAction, SideEffect : MVISideEffect> { val state: StateFlow<State> val currentState: State val sideEffect: Flow<SideEffect> fun onAction(action: Action) suspend fun update(block: suspend (State) -> State) suspend fun sideEffect(effect: SideEffect) } 移譲を用いたインタフェースの実装 次に、このインタフェースの実装クラスとして MVIDelegate を用意しました。内部ではStateをMutableStateFlowで管理し、SideEffectをChannelで配信しています。ViewModelではKotlinのデリゲートパターン( by mvi(...) )を使うことで、 MVI インタフェースの機能をViewModelへ追加できるようにしました。 class MVIDelegate<State : MVIState, Action : MVIAction, SideEffect : MVISideEffect>( initialState: State, ) : MVI<State, Action, SideEffect> { private val _state = MutableStateFlow(initialState) override val state: StateFlow<State> = _state.asStateFlow() override val currentState: State get () = _state.value private val _sideEffect by lazy { Channel<SideEffect>() } override val sideEffect: Flow<SideEffect> by lazy { _sideEffect.receiveAsFlow() } override fun onAction(action: Action) {} override suspend fun sideEffect(effect: SideEffect) { ... } override suspend fun update(block: suspend (State) -> State) { ... } } fun <State : MVIState, Action : MVIAction, SideEffect : MVISideEffect> mvi( initialUiState: State, ): MVI<State, Action, SideEffect> = MVIDelegate( initialState = initialUiState, savedStateHandle = null , savedStateName = null , ) また、Jetpack ComposeとMVIを接続するための MviContent コンポーザブルも提供しています。内部でStateとSideEffectを購読し、Content層には state と onAction のみが渡されます。開発者は購読の仕方を意識せず純粋なComposable関数を書くだけで済むようにしました。 @Composable fun <State : MVIState, Action : MVIAction, SideEffect : MVISideEffect> MviContent( viewModel: MVI<State, Action, SideEffect>, sideEffect: suspend (SideEffect) -> Unit , content: @Composable (state: State, onMviAction: (Action) -> Unit ) -> Unit , ) { LaunchedEffect( Unit ) { viewModel.sideEffect.collect { sideEffect(it) } } val state by viewModel.state.collectAsStateWithLifecycle() content(state, viewModel :: onAction) } MVIアーキテクチャを独自MVIライブラリで実装する ここからは、独自MVIライブラリを使って実際にCounter画面をMVIアーキテクチャで実装した例を紹介します。Contract・ViewModel・Screen・テストの順に、改善方針がどのようにコードに反映されるかを確認していきます。 Contract: State・Action・SideEffectの定義 画面に必要なState・Action・SideEffectを、1つのContractファイルにまとめて定義します。SSOTの原則に従い画面の状態を CounterState に集約し、UDFの原則に従いユーザー操作を CounterAction として列挙しています。一度限りのイベントは CounterSideEffect として定義します。画面が扱うデータの全体像がこのファイルだけで把握できます。 // CounterContract.kt // SSOT: 画面の状態を1つのdata classに集約 data class CounterState( val count: Int = 0 , ) : MVIState { val doubleCount: Int get () = count * 2 val tripleCount: Int get () = count * 3 companion object { val initialState = CounterState() } } // UDF: ユーザー操作をActionとして型で定義 sealed class CounterAction : MVIAction { data object Increment : CounterAction() data object Decrement : CounterAction() data object Reset : CounterAction() data object ClickSetting : CounterAction() } // Channel: 一度限りのイベントと画面遷移をSideEffectとして定義 sealed class CounterSideEffect : MVISideEffect { data class ShowToast( val message: String ) : CounterSideEffect() data object NavigateSetting : CounterSideEffect() } ViewModel: Actionの処理とState更新 ViewModelでは MVI インタフェースをデリゲートパターン( by mvi(...) )で利用します。 by mvi() を使うことでStateFlowを用いたState管理とChannelを通じたSideEffect配信がライブラリ側で強制されるため、開発者が独自にFlowを定義する余地がなくなります。すべてのユーザー操作は onAction で一元的に受け取ります。Actionの種類に応じて update でStateを更新し、 sideEffect を通じてイベントを送信します。 // CounterViewModel.kt @HiltViewModel class CounterViewModel @Inject constructor () : ViewModel(), MVI<CounterState, CounterAction, CounterSideEffect> by mvi(CounterState.initialState) { override fun onAction(action: CounterAction) { viewModelScope.launch { when (action) { CounterAction.Increment -> reduceIncrement() CounterAction.Decrement -> reduceDecrement() CounterAction.Reset -> reduceReset() CounterAction.ClickSetting -> sideEffect(CounterSideEffect.NavigateSetting) } } } private suspend fun reduceIncrement() { update { it.copy(count = it.count + 1 ) } checkLimit() } private suspend fun reduceDecrement() { update { it.copy(count = it.count - 1 ) } } private suspend fun reduceReset() { update { CounterState.initialState } } private suspend fun checkLimit() { val count = currentState.count if (count == 10 ) { sideEffect(CounterSideEffect.ShowToast( "10に到達しました" )) } } } ViewからActionが送信され、 onAction 内でそのActionに対する処理がすべて完結します。View側が複数メソッドを組み合わせて呼び出す必要がなくなり、呼び忘れや順序ずれが構造的に発生しなくなります。画面遷移もSideEffectとして onAction 内から発行されるため、遷移の起点がViewModel側に集約されます。 View: MviContentによるCompose連携 この例では、View層をScreenとContentに分けて実装しています。Screenでは MviContent を使ってStateの購読とSideEffectの処理を接続します。 MviContent の内部でStateとSideEffectの購読が行われるため、Contentには state と onAction のみが渡されます。ContentはStateを表示してActionを送信するだけの純粋なComposable関数になります。 // CounterScreen.kt @Composable fun CounterScreen( mvi: MVI<CounterState, CounterAction, CounterSideEffect>, onNavigateSetting: () -> Unit , modifier: Modifier = Modifier, ) { val context = LocalContext.current MviContent( viewModel = mvi, sideEffect = { effect -> when (effect) { is CounterSideEffect.ShowToast -> Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show() CounterSideEffect.NavigateSetting -> onNavigateSetting() } }, ) { state, onAction -> CounterScreenContent( state = state, onIncrement = { onAction(CounterAction.Increment) }, onDecrement = { onAction(CounterAction.Decrement) }, onReset = { onAction(CounterAction.Reset) }, onSettingClick = { onAction(CounterAction.ClickSetting) }, modifier = modifier, ) } } @Composable private fun CounterScreenContent( state: CounterState, onIncrement: () -> Unit , onDecrement: () -> Unit , onReset: () -> Unit , onSettingClick: () -> Unit , modifier: Modifier = Modifier, ) { Column(modifier = modifier) { Text(text = "Count: ${ state.count } " , fontSize = 32 .sp) Button(onClick = onIncrement) { Text(text = "+" ) } Button(onClick = onDecrement) { Text(text = "-" ) } Button(onClick = onReset) { Text(text = "Reset" ) } Button(onClick = onSettingClick) { Text(text = "Setting" ) } } } Flowごとに collectAsState を並べる必要がなくなり、View側がnavControllerやViewModelの内部状態に依存する構造も解消されます。画面遷移やToast表示はすべてSideEffect経由のコールバックに統一されるため、Contentの責務がシンプルに保たれます。ViewModelに依存しないComposable関数を用意することで、Preview関数も定義しやすくなります。 テスト: Actionを送信してState・SideEffectを検証 MVIアーキテクチャではデータフローが一方向に固定されているため、テストも「Actionを送信して、Stateの変化またはSideEffectの発行を検証する」というパターンに統一されます。テスト対象の入力と出力が明確なので、何をテストすべきかが自然と定まります。 // CounterViewModelTest.kt class CounterViewModelTest { @get:Rule val mainDispatcherRule = MainDispatcherRule() private lateinit var target: CounterViewModel @BeforeTest fun setup() { target = CounterViewModel() } // Stateの変化を検証 @Test fun `Action - Increment - increases count by 1`() = runTest { target.state.test { assertEquals( 0 , awaitItem().count) target.onAction(CounterAction.Increment) val state = awaitItem() assertEquals( 1 , state.count) assertEquals( 2 , state.doubleCount) assertEquals( 3 , state.tripleCount) } } // SideEffectの発行を検証 @Test fun `Action - ClickSetting - emits NavigateSetting side effect`() = runTest { target.sideEffect.test { target.onAction(CounterAction.ClickSetting) assertEquals(CounterSideEffect.NavigateSetting, awaitItem()) } } // State更新とSideEffectの組み合わせを検証 @Test fun `Action - Increment - emits ShowToast when count reaches 10`() = runTest { repeat( 9 ) { target.onAction(CounterAction.Increment) } target.sideEffect.test { target.onAction(CounterAction.Increment) assertEquals(CounterSideEffect.ShowToast( "10に到達しました" ), awaitItem()) } } } MVVMアーキテクチャからMVIアーキテクチャに移行してみて このような独自MVIライブラリを使ったMVIアーキテクチャへの移行は2024年9月に開始しました。既存画面を一括で移行するのではなく、「新規画面は原則MVI」「既存画面は改修タイミングで置き換え」というルールにより画面単位で段階的に進めています。これにより開発を止めることなく移行を進められ、画面ごとのリスクを小さく保ったまま適用範囲を広げることができており、2026年2月現在も段階的な移行を継続しています。 2024年9月 2025年4月 2025年10月 現在 MVI 1(2.2%) 11(24.4%) 21(38.9%) 31(50.8%) MVVM 44(97.8%) 34(75.6%) 33(61.1%) 30(49.2%) 合計 45 45 54 61 このようにMVIの実装が徐々に増える中で、前述のアーキテクチャ上の課題が解消されたことに加え、開発工程そのものにも以下のようなメリットが出てきています。 チーム全体で一貫した実装ができるようになった 独自MVIライブラリを作り実装方針を決め、あわせてドキュメントを整備・公開したことで、ライブラリとドキュメントの両面からチーム全体で一貫した実装を進められるようになりました。 新しいメンバーが加わった際も、1つの画面のContract・ViewModel・Viewを読めばプロジェクト全体の実装パターンを理解できます。オンボーディングの負荷も軽減されていると感じています。 PRレビューの質が向上した チーム全体で実装方針を統一できるようになり、基本的なデータフローに関する指摘は大きく減りました。以前は、実装パターンの統一に関するコメントがレビューの多くを占めていました。MVIライブラリによってこれらが構造的に解消されたことで、レビューの焦点が変わりました。現在は、仕様の妥当性の確認やコードのブラッシュアップに、より多くの時間を使えるようになりました。 AIコーディングエージェントとの協業がしやすくなった 現在、AIコーディングエージェントのDevinを活用した既存画面のMVI移行にもチャレンジしています。MVIアーキテクチャではState・Action・SideEffectという明確な構造があるため、Devinが生成したコードでも処理の流れを追いやすく、レビューしやすいです。アーキテクチャが統一されていることは、人間同士の開発だけでなく、AIとの協業においても大きなメリットになると感じています。 まとめ 本記事では、ZOZOFITのAndroidアプリにおけるMVVMアーキテクチャの課題と、MVIアーキテクチャへの移行、独自MVIライブラリの開発について紹介しました。MVIアーキテクチャは、ユーザー体験の低下を未然に防ぐ仕組みとしても機能していると感じています。ZOZOFITの利用者が日々増えるなかでも体験を安定して支えられるよう、これからもアーキテクチャの改善を進めていきます。最後までお読みいただき、ありがとうございました。 ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。 corp.zozo.com
はじめに 人材領域でデータ分析を担当している羽鳥です。KaggleのVesuvius Challenge - Surface Detection コンペにソロで参加し、1391
視覚言語モデル 【連載】自然言語処理の研究動向 第9回 2026.3.25 株式会社Laboro.AI リードMLリサーチャー 趙 心怡 概 要 視覚言語モデル(VLM)の登場は、画像情報をベースとした言語生成を可能にし、視覚理解のあり方を劇的な変化へと導きました。かつては画像とテキストを対応付ける研究が中心でしたが、現在のモデルはゼロショット学習や自由度の高いマルチモーダル生成を実現するまでに至っています。本稿では、VLMのこれまでの進化を3段階に整理した上で、次なる「第4の波」として期待される視覚知能の展望について考察します。 連載第1回「自然言語処理の研究動向 全40トピックの俯瞰」は こちら 。 連載第8回「議論マイニング」は こちら 。 目 次 ・ 視覚言語モデル(VLM)とは ・ 主要な技術的進歩 ・ 今後の展望と課題 視覚言語モデル(VLM)とは これまで、自然言語処理(NLP)とコンピュータビジョン(CV)は、互いに独立した専門分野として発展してきました。NLPが契約書やメールといったテキスト解析を担う一方で、CVは監視カメラの映像解析や製造ラインの品質管理など、主に視覚情報の処理に特化してきた経緯があります。しかし、今日の技術革新はその境界線を曖昧にしつつあります。 深層学習とモデルアーキテクチャの急速な進展により、両者を統合した「視覚言語モデル(VLM)」が登場しました。従来のモデルとは一線を画し、VLMは視覚情報とテキスト入力を同一の系で処理することで、対象を見て読み、推論した結果をテキストとして出力します。VLMの根幹はNLPの原理にありますが、その構造は従来のアーキテクチャから大きく変化しました。複数のプログラムを組み合わせた脆弱なシステムから、大規模データを通じて画像やテキストを一つの頭脳で理解する、よりシンプルで高度な仕組みへと移行しています。 このパラダイムシフトは、ビジネスにおけるAIの可能性を大きく広げます。従来のCVツールは、特定の物体を検知し枠で囲むといった限定的なタスクに留まっていました。対照的に、現代のVLMは「アクティブ・アナリスト」としての役割を果たします。画像のキャプション生成や複雑なコンテキストへの回答、さらにはチャートからのトレンド分析など、視覚的な事象に基づいた高度な言語的洞察の提供が可能になったのです。 主要な技術的進歩 第1の波:画像読解型 / 「構成要素の解析」の時代 (2019–2020) ViLBERT (2019) 、 LXMERT (2019) 、 VisualBERT (2019) などに代表される第1の波は、テキストを読むために使用されるTransformerアーキテクチャが、画像も「読む」ことができることを証明しました。この時代のモデルは画像を文章のように扱い、写真をオブジェクト領域(例:「車」「木」「犬」)のシーケンスに分解。これらのビジョントークンをテキストと共に処理することで、両社の関連性を学習しました。 このアプローチによって、「車の色は何色か?」といった視覚的な問いに対し、用意された選択肢から回答する能力が実現しました。しかし、そこにはいくつかの限界も存在しました。第一に、視覚領域の特定を外部の物体検出器に依存していたこと。第二に、学習には厳密にラベル付けされた膨大なデータセットを必要としたこと。そして第三に、これらのモデルは識別的(discriminative)であり、人間のように自由な形式で回答を生成するのではなく、あらかじめ定義されたリストから正解を分類することしかできなかったのです。 第2の波:概念統合型 / 「ベクトル対応」の時代 (2021–2022) OpenAIの CLIP (2021) やGoogleの ALIGN (2021) が主導した第2の波は、モデルのスケーラビリティと汎用性への転換点となりました。これらのモデルは、外部の物体検出器への依存を排除し、汎用エンコーダを使用して画像をホリスティック(全体論的)に処理します。目的は文法を深く解析することではなく、画像のベクトル(例:犬の写真)が、テキスト記述のベクトル(例:「これは犬です」)と自然に一致するような共通の埋め込み空間を学習することにありました。 これを実現するために、研究者は厳密にラベル付けされたデータセットから離れ、インターネットから収集された数億組のノイズを含む「画像とテキストのペア」で学習を行いました。この大規模なスケールが、驚くべき新能力であるゼロショット学習を可能にしました。特定のカテゴリで学習することなく、画像のベクトルが「これはカモノハシです」というテキスト埋め込みと一致するかを確認するだけで、モデルは「カモノハシ」のような全く新しい概念を認識できるようになったのです。これにより、厳選された学習データやタスク固有の微調整を必要としない、拡張可能な画像検索および分類システムへの道が開かれました。 第3の波:生成・対話型 / 「マルチモーダル推論」の時代 (2023–現在) 現在進行中の第3の波は、 BLIP-2 (2023) 、 LLaVA (2023) 、 GPT-4V (2023) に例示される「生成的な統合」を象徴しています。モデルをゼロから構築するのではなく、研究者は高性能な「視覚の目」(第2の波の成果)を「言語の脳」(大規模言語モデル:LLM)と直接融合させる方法を見出しました。 この融合により、モデルの能力は単なる画像照合の域を遥かに超えるものとなりました。「言語の脳」を獲得したことで、屋外環境での光学文字認識(OCR)、複雑なデータチャートの解釈、視覚コンテンツに関する機微に触れた対話、さらにはミーム(インターネット上のネタ画像)のユーモアの解説までもが可能になったのです。この変革は、AIの役割を受動的なタグ付けから能動的な分析へとシフトさせました。視覚情報を単に見るだけでなく、深く理解し、具体的なアクションに繋げる必要があるエンタープライズ・インテリジェンスや製品検索、アクセシビリティ、コンテンツ・モデレーションといった広範な分野において、すでに多大な影響を及ぼしています。 今後の展望と課題 これまでに述べた3つの潮流は、VLMが向かうべき「第4の波」の輪郭を浮き彫りにしています。最新のトレンドに基づき、私たちは今後の有望な方向性として以下の要素を特定しました。 1.記憶と推論の導入 テキストLLMの進化を追随するように、視覚モデルも「記憶」と「推論」の実装へと舵を切っています。これには、 CoMemo (2025) に見られるマルチモーダル長期記憶の開発や、 Visual-CoT (2024) や LlamaV-o1 (2025) に代表される、回答前にモデルが内省を行う思考の連鎖(Chain-of-Thought)機能の高度化が含まれます。 2.ビデオ理解と推論 動画の中の文脈を読み解くうえで、処理の重さや時系列的な理解の限界は依然として大きな課題ですが、近年では VideoLLM-online (2024) や StreamingVLM (2025) といった、このギャップを埋めるための革新的な試みが続いています。 3.統合Transformer(Unified Transformers)   CM3Leon (2023) 、 Mogao (2025) 、 ShapeLLM-Omni (2025) といった新世代のモデルは、ネイティブなインターリーブ生成(テキストと非言語情報の混在生成)をサポートし始めています。単一のモデルがテキスト、画像、さらには3Dコンテンツをシームレスに読み書き・編集できるこの技術は、真のオムニモーダル知能への道を開くものです。 しかし、能力の拡大に伴い、安全性におけるリスクも増大しています。こうした脆弱性の多くは、システムの認知的なバックボーンであるテキストLLMから直接引き継がれたものです。例えば、根強い課題であるハルシネーション(幻覚)の影響は、 Yang et al. (2025) や Min et al. (2025) の最近の研究が示すように、現在は視覚ドメインにも及んでいます。 これら既存のリスクに加え、VLM特有のマルチモーダル設計が新たな課題を浮き彫りにしています。 Liu et al. (2025) は、視覚入力を追加するだけでモデルの安全性指示への準拠(アライメント)が弱まり、不安全な回答を生成する可能性が高まることを指摘しました。 結論として、これらのリスクを特定すること自体が、第4の波を乗りこなすためのロードマップとなります。慎重な設計と標的を絞ったセーフガードを講じることで、次世代のVLMはより有能になるだけでなく、より信頼できるものになると期待されています。 執筆者 エンジニアリング部 リードMLリサーチャー 趙 心怡 自然言語処理、機械学習、ナレッジグラフを中心とした研究に従事。これまで複数のオープンソースのデータセットとモデルの構築に貢献してきた。最近の研究ではLLMの実社会への応用を探求し、学術研究と実際のユースケースの橋渡しに情熱を注いでいる。 The post 視覚言語モデル 【連載】自然言語処理の研究動向 第9回 first appeared on 株式会社Laboro.AI .

動画

書籍