概要
Android 向けアプリ開発の現場では新規、アップデートを問わず「アーキテクチャ何にすれば良いのか」という疑問は付きません。実際に私も所属をしている会社以外の人から相談を受けることがときどきあります。私の経験としてはその時々でメリットやデメリットを説明してチームや開発する対象、アプリが解決をする課題に対して適切なものを選んでもらいたい所ですが、いかんせんデメリットもしっかりと説明してしまうので即決という流れにはならないことが多いです。
そんな中、ここ1年くらい MVI アーキテクチャがブームになってきている雰囲気があります。
画像は Google トレンドを使って android mvi
の検索人気度を調べたものです。2017年の4月くらいに注目度が上がった後に、2018年2月から右肩上がりで検索をされるようになっているように見えます。4月にピークを迎えてそこから下がっているのは、4月くらいが新規アプリや大幅アップデート案件への予算がつく場合が多いからかもしれません。
どうやら近年注目されてきている Android アプリのアーキテクチャとして MVI は挙げられるようです。では MVI アーキテクチャはシルバーバレットなのかと言うと少なくとも私はそう感じていません。他のアーキテクチャに良い部分や悪い部分があるように、 MVI アーキテクチャにも良い部分、悪い部分がありやはりチームや開発をする対象、アプリが解決をする課題に合わせて選択をするべきです。
この記事では MVI アーキテクチャを検討しているけれど採用してよいのか、そのメリットやデメリットは何があるのかを説明するためにコードを交えて説明をするものです。
なお、この記事で MVI アーキテクチャについて説明をしますが現段階での私の理解によるもので正しい MVI とは多少なりともずれている可能性もあることをお伝えしておきます。
対象
Android アプリの開発を新規または大幅なアップデートをするためにアーキテクチャの選定を行う人を対象とします。ただし、 Android のフレームワークの基本、例えば4大コンポーネントなどについて、そして本文中のコードはすべて kotlin なのでその言語の機能について、また RxJava2,RxKotlin, RxAndroid さらに Dagger2 を完璧に理解とは言わずとも公式のリファレンスなどを見ながらなんとか分かるという人を対象とします。
メリット・デメリット
最初の方で結論を書きますが、 MVI アーキテクチャのメリットとデメリットについて私が感じているものを説明します。
デメリット
登場人物が多い
「対象」の部分でも書きましたが、そもそも依存する必要のあるライブラリが多いです。RxJava2 はもはや必須。RxAndroid もあったほうが良いでしょう。kotlin の選択はオプショナルですが RxJava を使うのなら選んでおきたいです。そして、そうなると RxKotlin も使いたくなります。さらに、テストを書きやすくしようとすると Dagger2 もあったほうが良いでしょう。
依存するライブラリや言語だけで最大で5個は出てきました。Rx というパラダイムを覚える必要もありますね。
これだけではなく、更なる登場人物として Intent や View があります。これらは Android におけるそれとは異なるものです。名前が被っててやばい。 ユーザの意図(Intent)を実際の処理に変換した Action 、Action を Processor で処理した結果の Result。View と結びついて Result のデータを画面に反映させるための Stateと必要なコンポーネントがいっぱい出てきました。
これらについて言葉を使い分けチームの中で合意を取ることはそれなりに大変なことだと思います。
テストのしやすさは Dagger の有無が肝
MVI アーキテクチャと言えば View や ViewModel、データを格納する Repository などのコンポーネントの結びつきを Rx によって疎結合にすることができます。これはテストを行う中で結構な利点となります。その一方で、インスタンスを初期化するタイミングの関係で、個々のコンポーネントのユニットテストを行おうと思うとやっぱり Dagger などの DI を利用しないという選択肢は難しいと思います。
Activity や Fragment などの View と View が依存する ViewModel はライフサイクルの中で初期化を行う必要があるため、何らかの方法で外部からインスタンスをコンストラクタ以外の方法でセットしなければなりません。このとき Dagger で DI を利用する仕組みとなっていればテストのときにはテスト用のインスタンスを利用することが可能です。
MVI を利用することイコールテストが書きやすくなるという認識は少し違うのでは? ということです。
Rx を把握することの重要度が高い
MVI アーキテクチャでアプリを作り始めるまで知らなかった Rx のオペレーターが結構出てきました。私自身の不勉強だと言われたらそれまでですが、それなりに学習コストがかかってしまうことは事実だと思います。
すべての View が State で管理できるわけではない
やろうと思えばできますが。画面に表示をする View の状態を表す State でも例えばダイアログやスナックバー、トーストについての管理を行うことはちょっと難しいと思います。ダイアログやスナックバー、トーストを「表示」するという状態を State で表現することはできますが「消えている」という状態に State を変更するためにはちょっと面倒くさい仕組みを用意する必要があります。ダイアログなら画面から消すのもユーザーの操作なので Intent として表現できそうですが、スナックバーやトーストは OS が管理をする時間をトリガーに画面から消えることもあるため、ユーザー起点の Intent で表現をすることができ無さそうです。
このあたりつにいて、どう折り合いをつけるのかがチームによって判断がわかれる部分となりそうです。
メリット
一度仕組みを用意すれば流れに身を任せられる
デメリットの「登場人物が多い」と対になると思いますが、小分けにされた登場人物であるコンポーネントたちはそれぞれの仕事がはっきりとしているため、機能を作る段階となれば自ずとどのコンポーネントに何を作り込んでいけばいいのかが割と簡単に理解が進むと思います。
そのため、最初の障壁を突破することができれば、頭のリソースをあまり使わずとも機能を作り込んでいきやすいと思います。
消極的な言い方をすると、仕組みを作り込むことのできる人がチームに入れば周りの人はその流れに乗るだけとなる、かもしれません。
機能の役割分担がしっかりとしているため、人間の役割分担もしやすい
それなりに人数のいる開発の場合、機能ごとの役割の分担をしたくなります。View や ViewModel などのコンポーネントがしっかりと別れている MVI アーキテクチャではそれぞれのコンポーネントに対してメンバーをアサインすることで人間の役割分担もやりやすそうです。
がっつりとテストできるので、Rx のオペレーターの使い方を間違えていてもすぐに分かる
Rx を把握する重要度が高いデメリットへの対となりますが、ちゃんとテストを書いていればオペレータの使い方を間違っていてもすぐに問題に気がつくことができそうです。これは MVI のメリットなのかどうかは怪しいですが。
MVI アーキテクチャでアプリを作りたい
サンプルアプリを作りながら MVI アーキテクチャで実際にコードを書いていきます。その前にまずは MVI アーキテクチャそのものについてのおさらいをします。といっても @oldergod 氏の Droid Kaigi 2018の資料がわかりやすいので、それを参照しつつ。
Android における Model-View-Intent アーキテクチャ - Speaker Deck
flux なんかで言うところの「単一方向のデータのサイクル」を実装するものが MVI アーキテクチャだと思います。それを実現するために、
- ユーザーの操作を View が Intent として ViewModel へ通知。
- ViewModel は Intent を Action として必要なパラメータを付与して Processor で処理。
- Processor は外部のシステムや副作用を持ったシステムと連携をして Action を処理して Result に変換。ちなみに、副作用を挿入することができるのはここだけ。
- Result の結果を利用して画面に表示されている状態を表す State の内容を変更
- View は State の内容を使って画面の描画を変更
このような手順をユーザーの動作を起点にループをしています。
実際にコードを書いてみる
Todo アプリを作っても良いんですが、やりつくされた感もあるしなぁ、という事でちょっと内容を変えてみます。 みんなが知っているヤツのほうが理解が早いと思わないでもない
- 画面1: ListView に日本の都道府県の名前と現在の天気を表示する
- 画面2: ListView の項目を選ぶとこれから先の天気予報を表示する
天気情報の取得には OpenWeatherMap を使います。
Сurrent weather and forecast - OpenWeatherMap
また、都道府県の一覧は @mugifly 氏が gist で公開をしている CSV を利用して取得します。
日本の都道府県名のヘボン式ローマ字リスト (CSV) / The list of Roman alphabet (Hepburn system) of the prefectures in Japan
コードの内容は @oldergod 氏のoldergod/android-architecture: MVI architecture Implementation of the ToDo app. を参考にしながら MVI の仕組みを作ります。
リポジトリはこちら。
今の段階ではとりあえず都市一覧を取得する部分だけ実装しています。そのため、記事の内容もそこまで。コードのアップデートに伴って記事も新しくしていきます。
前準備
Android Studio で新しいプロジェクトを作ったら、早速依存関係を追加していきましょう。
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
// Dagger を利用してコードを生成するために必要
apply plugin: 'kotlin-kapt'
android {
compileSdkVersion 28
defaultConfig {
applicationId "net.numa08.mviweather"
minSdkVersion 21
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
}
kapt {
useBuildCache = true
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'com.android.support:appcompat-v7:28.0.0-beta01'
// Android Architecture Component を利用して ViewModel のライフサイクル管理などを行う
implementation 'android.arch.lifecycle:viewmodel:1.1.1'
implementation 'android.arch.lifecycle:extensions:1.1.1'
// Rx 関連
implementation "io.reactivex.rxjava2:rxjava:$rootProject.rxjavaVersion"
implementation "io.reactivex.rxjava2:rxandroid:$rootProject.rxandroidVersion"
implementation "io.reactivex.rxjava2:rxkotlin:$rootProject.rxKotlinVersion"
// http 通信関連
implementation "com.squareup.retrofit2:retrofit:$rootProject.retrofitVersion"
implementation "com.squareup.retrofit2:adapter-rxjava2:$rootProject.retrofixRxJavaVersion"
// Dagger 関連
implementation "com.google.dagger:dagger:$rootProject.daggerVersion"
implementation "com.google.dagger:dagger-android:$rootProject.daggerVersion"
implementation "com.google.dagger:dagger-android-support:$rootProject.daggerVersion"
implementation 'com.android.support.constraint:constraint-layout:1.1.2'
kapt "com.google.dagger:dagger-compiler:$rootProject.daggerVersion"
kapt "com.google.dagger:dagger-android-processor:$rootProject.daggerVersion"
testImplementation 'junit:junit:4.12'
testImplementation 'com.willowtreeapps.assertk:assertk:0.10'
testImplementation 'com.nhaarman.mockitokotlin2:mockito-kotlin:2.0.0-RC1'
androidTestImplementation 'com.android.support.test:runner:1.0.2'
androidTestImplementation 'com.android.support.test:rules:1.0.2'
androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}
依存関係を結構追加する必要があります。デメリットの部分でも書きましたが、まあまあ多いですね。ここから機能をさらに追加するためにまだ増えます。
パッケージの構成を考える
私はアーキテクチャのコンポーネントごとにパッケージを分けるのが好きなので、次のような構成としました。
.
└── mviweather
├── api # retrofit の interface を格納する場所
│ └── city
├── data # repository に相当する機能やデータの型を格納する場所
│ └── source
├── di # DI 関連の Module や Component など
│ └── activitymodule
├── mvibase # MVI を実現するために必要な View や ViewModel、 Action、 Intent、 Result、 State の interface を定義しておく
├── presentation # View や ViewModel とそれらが通信をする Action、 Intent、 Result、 State の実装
│ ├── action
│ ├── activity
│ ├── adapter # Adapter は View や ViewModel では無さそうだけど、 View っぽいのでこのパッケージの下に入れておく
│ ├── fragment
│ ├── intent
│ ├── result
│ ├── state
│ └── viewmodel
└── utils # その他。増えてきたら考える用
└── parser
コンポーネントの interface を定義する
mvibase パッケージ以下に各コンポーネントの interface を定義します。
// ビジネスロジックを処理するために必要な情報を持ったイミュータブルなデータ
interface MviAction
// View で発生したユーザの意図を表現するイミュータブルなデータ
interface MviIntent
// ビジネスロジックを処理した結果を表すイミュータブルなデータ
interface MviResult
// MviView で描画を行うときに必要なイミュータブルなデータ
interface MviViewState
/**
* UI を表現するオブジェクトで、次のことを行います
* 1) view model にユーザの意図(intent)を伝える
* 2) view model の UI 描画イベントを購読する
*
* @param I MviView が通知をする MviIntent のクラス
* @param S MviView に通知される MviViewState のクラス
*/
interface MviView<I : MviIntent, in S : MviViewState> {
/**
* MviViewModel へ通知を行うための Observable。
* MviView の MviIntent はこの Observable を利用して通知される必要があります。
*/
fun intents(): Observable<I>
/**
* MviView が MviViewState を利用して描画を行うためのエントリーポイント。
*/
fun render(state: S)
}
/**
* MviView の MviIntent を購読するオブジェクト。
* MviIntent を旅子して MviViewState を結果として通知します。
*
* MviView が通知をしてくる MviIntent のクラス
* MviView に通知をする MviViewState のクラス
*/
interface MviViewModel<I : MviIntent, S : MviViewState> {
/**
* MviIntent の処理を開始するためのエントリーポイント
*/
fun processIntents(intents: Observable<I>)
/**
* MviIntent を処理した結果の MviViewState の通知を行うための Observable
*/
fun states(): Observable<S>
}
型引数とかが出てきてそれっぽさが出てきましたね。それぞれの利用方法のイメージとしては MviView を Fragment や Activity が実装を行い、 ViewModel が MviViewModel の実装を行って、それぞれ Obsevable を通じて通信を行い合う仕組みとなっています。
雰囲気を図で表現するとこんな感じになります。
なんだか見覚えのあるサイクルができあがりましたね。これらのコンポーネントを実装し適切に依存させることで MVI アーキテクチャを利用したアプリが作られていきます。
とりあえず動かしてみる
とりあえず簡単なアプリを実装してみて MVI アーキテクチャにおけるデータ処理の流れの雰囲気を掴みます。画面の仕様は以下とします。
- 画面には TextView と Button のみを配置し、TextView には「Will show number of button clicked」、Button には「CLICK ME」と表示
- ボタンを押すと TextView の表示内容が「Count 1」「Count 2」「Count 3」...クリック回数を数えてインクリメントする
それでは関連するコンポーネントを作るところから。
コンポーネント作り
画面を作る前に MviAction, MviIntent, MviResult, MviState を決めます。今回は
- 初期状態の表示
- ボタンクリックの回数表示
という少なくとも2つの State があるように思えます。そこで、次のようにコンポーネントを作りました。
sealed class HelloViewAction: MviAction{
/** 初期状態の State を表示するためのアクション */
object ShowInitialMessage: HelloViewAction()
/** クリック数のインクリメントを行うためのアクション */
object IncrementCount: HelloViewAction()
}
sealed class HelloViewIntent: MviIntent {
/** ユーザが画面を開いたときに発行される Intent */
object InitialIntent: HelloViewIntent()
/** ユーザがカウント数を増やしたいときに発行される Intent */
object IncrementCountIntent: HelloViewIntent()
}
sealed class HelloViewResult: MviResult {
/** 初期状態の表示を行う */
object ShowInitialMessageResult: HelloViewResult()
/** ボタンが押された回数を返す */
data class IncrementCountResult(val numberOfIncrement: Int): HelloViewResult()
}
data class HelloViewState(
/** ボタンが押された回数 */
val numberOfButtonClicked: Int?,
/** 初期メッセージを表示するかどうか */
val showInitialMessage: Boolean
): MviViewState {
companion object {
/** 初期状態の State */
fun initial(): HelloViewState =
HelloViewState(numberOfButtonClicked = null, showInitialMessage = true)
}
}
ViewModel とテスト作り
ViewModel を実装します。まずは、 Android Studio でコードを生成します。
class HelloViewModel: ViewModel(), MviViewModel<HelloViewIntent, HelloViewState> {
override fun processIntents(intents: Observable<HelloViewIntent>) {
TODO("not implemented")
}
override fun states(): Observable<HelloViewState> {
TODO("not implemented")
}
}
この状態でコンパイルができることを確認したら、実装に入る前にテストを作ります。ViewModel は Android のフレームワークへ依存はしないため、 test ディレクトリ以下の JVM で実行されるテストとしてテストを実装できそうです。
@file:Suppress("RemoveRedundantBackticks")
class HelloViewModelTest {
private lateinit var viewModel: HelloViewModel
@Before
fun initViewModel() {
viewModel = HelloViewModel()
}
@Test
fun `初期状態のステータスをテスト`() {
// 期待される初期状態
val expected = HelloViewState(numberOfButtonClicked = null, showInitialMessage = true)
val subscriber = viewModel.states().test()
// InitialIntent を発行する
viewModel.processIntents(Observable.just(HelloViewIntent.InitialIntent))
// Intent を処理させる
subscriber.assertValue(expected)
}
@Test
fun `ボタンを押した回数がインクリメントされること`() {
// ボタンを3回クリックされた状態
val subscriber = viewModel.states().test()
// 初期状態→ボタンクリック*3 を実行する
viewModel.processIntents(
Observable.fromArray(
HelloViewIntent.InitialIntent,
HelloViewIntent.IncrementCountIntent,
HelloViewIntent.IncrementCountIntent,
HelloViewIntent.IncrementCountIntent
)
)
subscriber.assertValues(
HelloViewState.initial(),
HelloViewState(numberOfButtonClicked = 1, showInitialMessage = false),
HelloViewState(numberOfButtonClicked = 2, showInitialMessage = false),
HelloViewState(numberOfButtonClicked = 3, showInitialMessage = false)
)
}
}
今回は初期状態のステータスが想定したものになっているかどうか、とボタンを3回クリックしたときの動作をテストしています。 Intent というオブジェクトを利用してユーザー操作を表現し、 State というオブジェクトで画面の状態を表現しているため、 UI イベントの発生をコードに落とし込むことができていることがわかると思います。
テストの内容ですが、 RxJava2 の test() を利用することでどういったイベントが発生していたのかをテストすることができます。ボタンを押した回数がインクリメントされることを確かめるテストの方では、 State が
- 初期状態
- ボタンが1回押された
- ボタンが2回押された
- ボタンが3回押された
という流れで変化していくことのテストとなっています。
当然、これを実行するとkotlin.NotImplementedError: An operation is not implemented: not implemented
がスローされて失敗をするので、ViewModel の実装を行います。
ViewModel を実装する
一気に作り込むと大変なので、少しずつやっていきます。まず、どうやら Intent を Action に変換する必要があります。Intent は processIntent の中で Observable として与えられるので、 map オペレータを利用すして変換することになるでしょう。そのため、変換を行うための無名関数を定義します。
/** Intent -> Action に変換を行う関数 */
private val actionFromIntent
get() = { intent : HelloViewIntent ->
when(intent) {
HelloViewIntent.InitialIntent -> HelloViewAction.ShowInitialMessage
HelloViewIntent.IncrementCountIntent -> HelloViewAction.IncrementCount
}
}
HelloViewIntent は sealed class を使って実装しているため when 句によるチェックを書くことが可能です。変換自体は非常にシンプルで Intent に対応する Action を返すだけとなっています。
次に Action を処理して Result をレスポンスする必要があります。今回は単純な計算をするだけなので Observable の map オペレータを利用すればよさそうですが、実際には非同期処理なんかを行う部分となってくるため、 Observable をレスポンスするのが良いケースもあると思います。そのため、 compose オペレータを利用することができるように、関数ではなく ObservabeleTransformer を返す関数として定義します。
/** Action を処理して Result を返す */
private val processAction: ObservableTransformer<HelloViewAction, HelloViewResult>
get() = ObservableTransformer { actions ->
actions.flatMap { action ->
when(action) {
// 初期メッセージを表示するだけ
HelloViewAction.ShowInitialMessage ->
Observable.just(HelloViewResult.ShowInitialMessageResult)
// ボタンを押されたら 1 をカウントアップする
HelloViewAction.IncrementCount ->
Observable.just(1).map(HelloViewResult::IncrementCountResult)
}
}
}
最後に、 Result の内容を利用して画面の状態を更新する reducer を用意します。 reducer は前の State をと新しい Result を利用して新しい State を返す関数です。また、その名前の通り reduce オペレータで利用をする雰囲気なので BiFunction として実装します。
companion object {
/**
* MviView が描画を行うための State を返すための Reducer。
* previousState には1つ前の Result から生成された State が入ってくる。 result に与えられた
* 処理結果を利用して新しい State を作って返すことで、 MviView は描画を更新する。
* */
val reducer = BiFunction { previousState: HelloViewState, result: HelloViewResult ->
when(result) {
// 初期メッセージの表示は特に画面の更新は発生しないので、 previousState をそのまま返す
HelloViewResult.ShowInitialMessageResult -> previousState
// インクリメントが行われたら、previousState が管理しているクリック数に Result で得られた
// インクリメントをする数を足して新しい State を作る
is HelloViewResult.IncrementCountResult -> {
val previousCount = previousState.numberOfButtonClicked ?: 0
previousState.copy(
numberOfButtonClicked = previousCount + result.numberOfIncrement,
showInitialMessage = false
)
}
}
}
}
companion object の中で実装を行っているのはインスタンスのプロパティへのアクセスを行わせないためで、 state-less な関数として実現をしているためです。
さて、ここまで出てきた実装を組み合わせることで必要なものの実現はできそうですが、もう一つ手間を加えます。Android 特有の問題として画面回転がありますが、例えば InitialIntent を Activity の onCreate の中で発行した場合、画面回転が行われると回転のたびに InitialIntent が発行されてしまいます。これを Rx の仕組みを使って ViewModel の中で抑制します。
/** 複数発行された InitialIntent を抑制する */
private val intentFilter: ObservableTransformer<HelloViewIntent, HelloViewIntent>
get() = ObservableTransformer { intent ->
intent.publish { shared ->
Observable.merge(
// InitialIntent に関しては最初の1回だけを処理する
shared.ofType(HelloViewIntent.InitialIntent::class.java).take(1),
// それ以外はとくにフィルターしない
shared.notOfType(HelloViewIntent.InitialIntent::class.java)
)
}
}
@CheckReturnValue
@SchedulerSupport(SchedulerSupport.NONE)
fun <T : Any, U : Any> Observable<T>.notOfType(clazz: Class<U>): Observable<T> {
checkNotNull(clazz) { "clazz is null" }
return filter { !clazz.isInstance(it) }
}
ここまで用意した仕組みを使って、残りの実装を行います。
/** Intent を State に変換する */
private fun compose(): Observable<HelloViewState>
= intentSubject
// 複数発酵されてしまった InitialIntent を無視する
.compose(intentFilter)
// Intent を Action に変換する
.map(actionFromIntent)
// Action を処理する
.compose(processAction)
// scan を使うことで前の状態の State を利用して新しい State を作ることができる。
// reduce を使ってしまうと complete が呼ばれるまで reduce の中身は実行されないため、
// 適さない。
.scan(HelloViewState.initial(), reducer)
// State の状態が変わっていない場合には通知を出さない。
.distinctUntilChanged()
// 最後に発生したイベントを subscription に通知する。
// 画面が回転して再度 MviView で subscribe したときに、1つ前の State が通知されるため、
// 回転前の画面を維持できる。
.replay(1)
// subscribe されていなくても通知を発行して処理する。
.autoConnect(0)
/** 発酵された Intent を処理するための Observable.
* intentSubject の後のオペレータが Intent を変換して処理していく */
private val intentSubject: PublishSubject<HelloViewIntent> = PublishSubject.create()
override fun processIntents(intents: Observable<HelloViewIntent>) {
intents.subscribe(intentSubject)
}
/** 処理された Intent は State となって MviView に通知される */
override fun states(): Observable<HelloViewState>
= compose()
processIntent のパラメータで与えられた Intent は intentSubject へ通知されて処理されます。intentSubject の中のオペレータが Intent → Action → Result → State と変換と処理を行い、最後に State で Observable として返しているため、 MviView ではこれを subscribe することで画面の更新を実現できます。
さて、先に作った HelloViewModelTest がグリーンになることを確認したら最後に View を作ります。
class HelloActivity : AppCompatActivity(), MviView<HelloViewIntent, HelloViewState> {
private val viewModel: HelloViewModel by lazy(LazyThreadSafetyMode.NONE) {
ViewModelProviders.of(this).get(HelloViewModel::class.java)
}
private val disposables = CompositeDisposable()
/** クリック数インクリメントを行う Intent を通知する */
private val incrementCountIntentPublisher =
PublishSubject.create<HelloViewIntent.IncrementCountIntent>()
private lateinit var messageText: TextView
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_hello)
messageText = findViewById(R.id.text)
findViewById<Button>(R.id.button).setOnClickListener {
// ボタンがクリックされたら、クリック数インクリメントを ViewModel に通知する
incrementCountIntentPublisher.onNext(HelloViewIntent.IncrementCountIntent)
}
bind()
}
override fun onDestroy() {
super.onDestroy()
disposables.dispose()
}
private fun bind() {
// viewModel の処理結果を受け取って render で描画を行う
disposables.add(viewModel.states().subscribe(::render))
viewModel.processIntents(intents())
}
private fun initialIntent(): Observable<HelloViewIntent.InitialIntent>
= Observable.just(HelloViewIntent.InitialIntent)
private fun incrementCountIntent(): Observable<HelloViewIntent.IncrementCountIntent>
= incrementCountIntentPublisher
/**
* ViewModel が処理をする Intent の一覧を返す。
* Merge を使うことで、Intent が発生した順番で通知される*/
override fun intents(): Observable<HelloViewIntent>
= Observable.merge(
initialIntent(),
incrementCountIntent()
)
/**
* ViewModel が Intent を処理した結果を使って描画をする。
* */
override fun render(state: HelloViewState) {
if (state.showInitialMessage) {
messageText.setText(R.string.hello)
return
}
state.numberOfButtonClicked?.let {
messageText.text = getString(R.string.number_of_count, it)
}
}
}
intents() とを実装することで View のイベントを ViewModel に通知できるようになります。また、 viewModel.states().subscribe(::render)
を行うことで ViewModel 処理された結果の State を render で描画できます。これらの仕組みを利用することで View と ViewModel が相互に参照しあってデータのサイクルが完成します。
なお、このコードでは ViewModel を ViewModelProviders を利用して作っているためテスト中に 差し替えをすることができません。そのため、この Activity に対してのテストは省略をしています。
レイアウトや AndroidManifest などの完全なコードはリポジトリを参照してください。
numa08/MVIWeather at 9d81ef5837f8ecdff6fb52254a6c71f738c54f9d
ちゃんとしたアプリを作る
ここまでのセクションで MVI アーキテクチャを利用してアプリを作る時に必要な下準備は整いました。ここからはちゃんとしたアプリを作りつつ、テストコードも書きながら MVI アーキテクチャを利用したアプリ開発を見ていこうと思います。
ここで、ちょっと MVI アーキテクチャのメリット・デメリットを思い出します。すでに述べたデメリットの中で次のように書きました。
登場人物が多い
実際、ここにたどり着くまでに登場したコードも多いです。サンプルに挙げたような画面であれば普通に書けばもっと短いコードになるでしょう。 MVI アーキテクチャのデメリットも目立つサンプルを作ってしまいました。それでは逆に、 MVI アーキテクチャのメリットを活かすことができる機能とは何でしょうか。私は次のように考えます。
- 副作用のある外部システムと連携をする
- 複雑で動的な UI の状態を持っている
ひとまず、これらに該当する画面については MVI アーキテクチャの恩恵をうけることができると思います。「副作用のある外部システム」とは、例えば Web API であったり、 SharedPreferences や Realm, SQLite, firebase などのデータを永続化、キャッシュする物のことです。これらを利用する部分では非同期処理が肝になっていたり、あるいは人間の手によるテストよりも、ユニットテスト・ビヘイビアテストによる自動テストの恩恵が大きかったりします。
また、「複雑で動的なUIの状態」とは、ログインの状態とか外部システムとの連携を利用して画面のレイアウトを変更する必要がある画面です。これらについても State によって画面の状態を表現できる MVI アーキテクチャであればテストコードを用意していくことができそうです。
裏を返せば、次のような画面には MVI アーキテクチャは不要なのではないかと考えられます。
- 外部システムとの連携がない画面
- 静的な画面
こういった画面はアプリの中で度々登場すると思います。チュートリアル画面とかヘルプ画面、 WebView のみを表示する画面などがそうかと思います。こういった画面では MVI アーキテクチャのデメリットが目立ってしまい、あまり導入の効果が得られないと思います。
さて、このセクションでは「副作用のある外部システムと連携」や「動的な UI の状態」を持った画面として、日本の各都道府県とその場所の今の天気の一覧を同時にリストで表示する画面を考えます。
DI も使ってテストを書く
TODO