メルペイ Android のアーキテクチャとテスト

こんにちは。Merpay Advent Calendar 2019 の18日目を担当する、メルペイ Android チームの @KeithYokoma です。

前回の記事ではマルチモジュール構成なプロジェクトにおける画面遷移の実装について、簡単な設計の方針から解説しました。
今回の記事では、メルペイ Android 全体として採用しているアーキテクチャやテストの方針について解説します。

全体像

はじめに、メルペイ Android で採用しているアーキテクチャを解説する際に出てくる登場人物を整理しておきます。

メルペイ Android では、Redux の考え方と MVVM を組み合わせて使っています。PresentationActivityFragment に相当する画面のことで、メルペイ Android の場合は前回の記事で触れたとおり bluelinelabs/Conductor を利用しているので ControllerPresentation になります。

f:id:KeithYokoma:20191213163559p:plain
Redux の考え方と MVVM を組み合わせた構成

ここであえてRedux の考え方と表現しているのは、仕組みとしてはほぼ Redux のそれを踏襲しているものの、Redux と違い画面ごと個別に StoreState を持っているからです。
メルペイの機能自体が多岐にわたり画面数も多いため、アプリケーション全体でひとつの State を管理すると、とても巨大な State オブジェクトをもつことになってしまい管理が複雑化します。これを避けるため、どちらかというと Flux に寄せたような作り方をしています。メルペイ Android で利用している Redux の実装は mercari/RxReduxK です。

このアーキテクチャでは、おおまかな流れとして、次の順に処理を進めていくことになります。

  1. Presentation でクリックイベント等をトリガーにして ViewModel に入力をする
  2. ViewModelPresentation からの入力やデータ入出力の終わりなどを Action に変換し Store に送る
  3. Reducer は現在の State と渡された Action を見て、State の状態を変える
  4. Store は新しい StatePresentation に通知する

またメルペイ Android では、API 通信の状態をうまく表現するために RemoteDataK を使っています。
API 通信の状態を扱えるようにしつつ、最終的な通信の結果を保持するための Either のようなものです。RemoteData という型で、API 通信の初期状態・通信中・成功・失敗が表現できます。詳しい使い方は以後の解説の中で触れていきます。

Reducer での状態管理

Redux では、アプリケーションがもつ状態を State として定義し、Action ごとにどのように State を変化させるかを Reducer に定義します。
ReducerActionState を受け取って、状態を変えて State を返す関数です。

interface Reducer<S: State, A: Action> {
fun reduce(currentState: S, action: A): S
}
f:id:KeithYokoma:20191215003824p:plain
Reducer の役割

例として、何かしらの文字列データを API 通信でとってきて表示する画面の状態を次のような State オブジェクトで表現してみましょう (以後の説明もこの例をもとにしています) 。

import com.mercari.remotedata.RemoteData
import com.mercari.rxredux.State
data class SampleState(
val text: RemoteData<String, Exception> = RemoteData.Initial
) : State

この SampleState がもつ text プロパティは初期値として RemoteData.Initial を設定しています。このプロパティを Reducer で書き換えることによって、他の状態へ変化させることになりますが、Reducer による状態の変化を起こすには Action の定義が必要です。
状態の変化を起こすタイミングとして、API 通信を開始したタイミングと、通信が終わって結果を得たタイミングの 2 つがあると考えると、次のような Action の定義ができます。

import com.mercari.remotedata.RemoteData
import com.mercari.rxredux.Action
sealed class SampleAction : Action
class StartLoadingTextAction : SampleAction()
class FinishLoadingTextAction(
// API 通信の結果: RemoteData.Success<String> または RemoteData.Failure<Exception> のいずれか
val result: RemoteData<String, Exception>
) : SampleAction()

これらの Action をもとに、状態をどう変化させるかを Reducer に記述します。

import com.mercari.rxredux.Reducer
class SampleReducer : Reducer<SampleState, SampleAction> {
override fun reduce(currentState: SampleState, action: SampleAction): SampleState = when (action) {
is StartLoadingTextAction -> {
currentState.copy(
text = RemoteData.Loading()
)
}
is FinishLoadingTextAction -> {
currentState.copy(
text = action.result
)
}
}
}

Reducer のユニットテスト

先ほどの SampleReducer のユニットテストでは次のことを確認します。

  1. StartLoadingTextAction を与えたら SampleStatetextReomteData.Loading になる
  2. FinishLoadingTextAction を与えたら SampleStatetextReomteData.Success または ReomteData.Failure になる

これらを確認するためのテストを Spek Framework で記述すると次のようになります*1。モックフレームワークは使う必要がなくシンプルに記述できることがわかります。

import com.mercari.remotedata.RemoteData
import org.amshove.kluent.shouldEqual
import org.spekframework.spek2.Spek
import org.spekframework.spek2.style.gherkin.Feature
object SampleReducerTest : Spek({
Feature("SampleReducer") {
val reducer = SampleReducer()
// 1. StartLoadingTextAction を与えたら SampleStatetextReomteData.Loading になる
Scenario("Reducing StartLoadingTextAction") {
lateinit var originalState: SampleState
lateinit var newState: SampleState
Given("An original state") {
originalState = SampleState()
}
When("Reduce the state with StartLoadingTextAction") {
newState = reducer.reduce(originalState, StartLoadingTextAction())
}
Then("New state has loading state for text") {
newState.text.isLoading shouldEqual true
}
}
// 2. FinishLoadingTextAction を与えたら SampleStatetextReomteData.Success になる
Scenario("Reducing FinishLoadingTextAction") {
lateinit var originalState: SampleState
lateinit var newState: SampleState
Given("An original state") {
originalState = SampleState()
}
When("Reduce the state with StartLoadingTextAction") {
newState = reducer.reduce(originalState, FinishLoadingTextAction(RemoteData.Success("some data")))
}
Then("New state has success state for text") {
newState.text.isSuccess shouldEqual true
}
And("The value is delivered from the action") {
newState.text.value shouldEqual "some data"
}
}
// 2. FinishLoadingTextAction を与えたら SampleStatetextReomteData.Failure になる
Scenario("Reducing FinishLoadingTextAction") {
lateinit var originalState: SampleState
lateinit var newState: SampleState
Given("An original state") {
originalState = SampleState()
}
When("Reduce the state with StartLoadingTextAction") {
newState = reducer.reduce(originalState, FinishLoadingTextAction(RemoteData.Failure(RuntimeException())))
}
Then("New state has success state for text") {
newState.text.isFailure shouldEqual true
}
}
}
})

ここまでで、Redux の仕組みを使った状態の管理が実装できました。

ViewModel で Action と API 通信を Redux につなぎこむ

次に ViewModelStoreAction を送る部分を見てみましょう。

f:id:KeithYokoma:20191214235107p:plain
ViewModel の役割

例として、ボタンのクリックをトリガーとして先ほど説明した Redux による状態の遷移を動かしてみます。

Presentation からの入力は次に示す Input というオブジェクトを介して受け取ります。
InputPublishSubject をラップしたもので、input メソッドは Subject#onNext へ委譲し、valueSubjectObservable に変換して返すようになっています。

class Input<T> {
private val subject: Subject<T> = PublishSubject.create()
// Input を受け取るメンバー。ViewModel で使う。
val value: Observable<T> = subject.hide()
// Input に入力をするメンバー。概ね Presentation でのイベントをトリガーにして使う。
fun input(value: T) = subject.onNext(value)
}
// SampleInput は画面で必要な Input を定義する
class SampleInput {
val buttonClick: Input<Unit> = Input()
}

また非同期処理で API 通信を担う部分は次のようなインタフェースを持っていることとします。

interface SampleDataSource {
fun fetchText(): Single<RemoteData<String, Exception>>
}

これらを用いて、ボタンをクリックした直後に StartLoadingTextAction を、API からのレスポンスがかえってきたときに FinishLoadingTextAction を通知する部分を次のように表現します。

// Observable<SampleAction>
// buttonClick をトリガーにして、StartLoadingTextAction を通知し非同期処理を行い、
// レスポンスが返ってきたら FinishLoadingTextAction を通知する
input.buttonClick
.value
.flatMap {
dataSource.fetchText()
.map<SampleAction>(::FinishLoadingTextAction)
.toObservable()
.startWith(StartLoadingTextAction())
}

この結果の Observable<SampleAction>Store#dispatch(Observable<SampleAction>) にわたすと StoreAction が通知されます。

次のコード例は ViewModel の実装の全体を示しています。
内部に Store をもち、状態の初期値と Reducer を設定している部分と、変化した StateObservable にのせて公開するためのメンバーとして state を定義しています((これらは他の ViewModel でも同じように作っていくことになるため、適宜インタフェースとして括りだすとよいでしょう))。

class SampleViewModel(
private val input: SampleInput,
private val dataSource: SampleDataSource
) : LifecycleObserver {
private val compositeDisposable = CompositeDisposable()
private val store = Store(SampleState(), SampleReducer())
val states: Observable<SampleState> = store.states
@OnLifecycleEvent(Lifecycle.Event.ON_CREATE)
fun onCreate() {
store.dispatch(
input.buttonClick
.value
.flatMap {
dataSource.fetchText()
.map<SampleAction>(::FinishLoadingTextAction)
.toObservable()
.startWith(StartLoadingTextAction())
}
).addTo(compositeDisposable)
}
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun onDestroy() {
compositeDisposable.clear()
}
}

この ViewModelAndroid Architecture Components にある ViewModel とは異なりますが、ActivityView など Context への参照を持たないため比較的簡単に Android Architecture Components のものへ対応できます*2

ViewModel のテスト

SampleViewModel を例として、ViewModel のユニットテストでどんなことを確認したらよいかを説明します。

ViewModel の役割は Presentation からの入力や API 通信の結果などをきっかけとして Action を作って Store にわたすことです。
SampleViewModel ではこの結果として State が初期状態から2回変わることになるので、State が onNext で通知される回数は合計 3 回です。
テストでは TestObserverstates を subscribe し、合計 3 回 onNext が呼ばれる (初期状態、StartLoadingTextAction の結果、FinishLoadingTextAction の結果) ことを確かめます。

object SampleViewModelTest : Spek({
// RxJava のしくみで、テストから非同期な Scheduler を同期的な Scheduler に差し替え
beforeGroup {
RxJavaPlugins.setSingleSchedulerHandler { Schedulers.trampoline() }
}
Feature("Loading text from sample data source") {
// input はそのままつかう
val input = SampleInput()
// DataSource のモックを作り、適宜成功時・失敗時などを再現できるようにしておく
val dataSource: SampleDataSource = mockk(relaxed = true) {
every { fetchText() } returns Single.just(RemoteData.Success("test"))
}
val viewModel = SampleViewModel(input, dataSource)
val test = viewModel.states.test()
Scenario("On input by button click") {
When("Start a view model lifecycle") {
viewModel.onCreate()
}
And("Input#buttonClick") {
input.buttonClick.input(Unit)
}
Then("Emit state 3 times") {
// onNext が 3 回呼ばれることを検証
test.awaitCount(3)
.assertValueCount(3)
}
// 以降、N 回目に emit された state が期待した状態になっているか確かめる
And("First one should have initial state") {
test.assertValueAt(0) {
it.text.isInitial
}
}
And("Second one should have loading state") {
test.assertValueAt(1) {
it.text.isLoading
}
}
And("Third one should have success state") {
test.assertValueAt(2) {
it.text.isSuccess
}
}
}
}
})

状態の観測から UI 操作の委譲 (ViewDelegate)

Reducer によって状態遷移が定義され、ViewModel によって様々な入力が Action 変換され状態遷移を動かせるようになりました。
最後に、Presentation で状態を観測し、適切に View を更新する部分を見てみましょう。

Presentation には、API から取得した文字列を表示するための TextView と、通信状態を表現する ProgressBar、そして読み込みを開始する Button があるとします。
次の例では、SampleViewModel を作成して SampleController のライフサイクルと同期させ、レイアウトを読み込んだときに View の構築 (この例ではボタンにリスナーを設定して Input のトリガーにする) と View の操作 (この例では SampleStatetext を見て読み込み中の表示を切り替えたり、結果を表示したりする) をするよう記述しています。

class SampleController : Controller() {
// Controller のライフサイクルを LifecycleObserver に通知するしくみ
private val lifecycleProducer = ControllerLifecycleProducer(this)
private val input = SampleInput()
private val viewModel = SampleViewModel(input, SampleRepository)
private val compositeDisposable = CompositeDisposable()
init {
lifecycleProducer.addObserver(viewModel)
}
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup): View =
inflater.inflate(R.layout.zmp_controller_sample, container, false).also { v ->
// View の構築と state に応じた View の操作
val progressView = v.findViewById<ProgressBar>(R.id.progress)
val textView = v.findViewById<TextView>(R.id.textView)
val button = it.findViewById<Button>(R.id.button)
viewModel.states
.takeUntil(lifecycleProducer.filterEvent(Lifecycle.Event.ON_STOP))
.observeOn(AndroidSchedulers.mainThread())
.map { state ->
state.text
}
.subscribe { text ->
// View の操作
progressView.isVisible = text.isLoading
when (text) {
is RemoteData.Success -> {
textView.text = text.value
}
is RemoteData.Failure -> {
textView.setText(R.string.api_error_loading_text)
}
}
}.addTo(compositeDisposable)
// View の構築
button.setOnClickListener {
input.buttonClick.input(Unit)
}
}
override fun onDestroy() {
compositeDisposable.clear()
super.onDestroy()
}
}

今回の例では State が非常に単純化してありますが、画面に表示するものや通信する API が増えていくと、このコードは簡単に肥大化していってしまいます。
実際 Controller はユニットテストの手段に乏しいため、メルペイ Android では Presentation が受け持つ「View の構築」と「State に応じた View の操作」を委譲する ViewDelegate というインタフェースを用意して、これら2つの役割に対してユニットテストを書くようにしています。

SampleController にベタ書きした View の構築と操作のロジックを委譲するインタフェースの例を次に示します。View の構築と View の操作はそれぞれメソッドに切り出して名前をつけて管理しています。この他、companion object で簡単なファクトリメソッドを作り、SampleController から扱いやすくしています。

// 委譲のためのインタフェース
interface ViewDelegate {
val input: SampleInput
val progressView: ProgressBar
val textView: TextView
val button: Button
// View の構築にあたるメソッド
fun setUpButtonClick()
// State に応じた View の操作にあたるメソッド
fun subscribeTextStateUpdates(states: Observable<SampleState>): Disposable
}

そしてこのインタフェースを実装したクラスを次に示します。

class SampleViewDelegate @VisibleForTesting internal constructor(
override val input: SampleInput,
override val progressView: ProgressBar,
override val textView: TextView,
override val button: Button
) : ViewDelegate {
override fun setUpButtonClick() {
button.setOnClickListener {
input.buttonClick.input(Unit)
}
}
override fun subscribeTextStateUpdates(states: Observable<SampleState>): Disposable =
states.map { state ->
state.text
}
.subscribe { text ->
progressView.isVisible = text.isLoading
when (text) {
is RemoteData.Success -> {
textView.text = text.value
}
is RemoteData.Failure -> {
textView.setText(R.string.zmp_error_desc)
}
}
}
companion object {
// Controller から ViewDelegate のインスタンス生成を簡単にするメソッド
fun of(input: SampleInput, view: View): ViewDelegate = SampleViewDelegate(
input,
view.findViewById(R.id.progress),
view.findViewById(R.id.textView),
view.findViewById(R.id.purchase_button)
)
}
}

ViewDelegate の導入によって、SampleController は次のように簡素化されます。

class SampleController : Controller() {
// ...
private lateinit var viewDelegate: ViewDelegate
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup): View =
inflater.inflate(R.layout.zmp_controller_empty_view, container, false).also { v ->
val states = viewModel.states
.takeUntil(lifecycleProducer.filterEvent(Lifecycle.Event.ON_STOP))
.observeOn(AndroidSchedulers.mainThread())
viewDelegate = SampleViewDelegate.of(input, v)
viewDelegate.subscribeTextStateUpdates(states).addTo(compositeDisposable)
viewDelegate.setUpButtonClick()
}
}

ViewDelegate のテスト

ViewDelegate のテストでは、View の構築が期待通りかどうかと、State の更新があったときに適切な View が操作されているかを確認します。

先ほどの SampleViewDelegate の場合、View の構築にあたる部分はボタンに OnClickListener を設定し、ボタンクリック時に Input を呼び出すようにするところです。
次の例では、mockk の機能を使って setUpButton メソッドで設定している OnClickListener のインスタンスを取り出し、コールバックメソッドを呼び出して Input が呼び出されるかどうかまでを確認しています。

Spek の制約として Robolectric の仕組みの上でテストができないため、Android Framework で定義されている各種 View はすべて自分でモックしています。

object SampleViewDelegateTest : Spek({
Feature("Compose associated view") {
val input: SampleInput by memoized(CachingMode.EACH_GROUP) {
mockk(relaxed = true)
}
val progress: ProgressBar by memoized(CachingMode.EACH_GROUP) {
mockk(relaxed = true)
}
val textView: TextView by memoized(CachingMode.EACH_GROUP) {
mockk(relaxed = true)
}
val button: Button by memoized(CachingMode.EACH_GROUP) {
mockk(relaxed = true)
}
lateinit var viewDelegate: ViewDelegate
Scenario("Compose a button") {
// OnClickListener#onClick での処理を検証するため
// setOnClickListener で設定するリスナーのインスタンスをキャプチャする
lateinit var clickListenerSlot: CapturingSlot<View.OnClickListener>
Given("A view delegate") {
clickListenerSlot = slot()
every { button.setOnClickListener(capture(clickListenerSlot)) } just Runs
viewDelegate = SampleViewDelegate(input, progress, textView, button)
}
// Button の構築をすると……
When("Setup button") {
viewDelegate.setUpButtonClick()
}
// Button に OnClickListener が設定されて……
Then("Click listener is set to the button") {
verify { button.setOnClickListener(any()) }
}
// 設定した OnClickListener にコールバックすると……
When("Call onClick") {
clickListenerSlot.captured.onClick(mockk())
}
// 期待した Input に入力があるはず
Then("A new input comes into buttonClick") {
verify { input.buttonClick.input(any()) }
}
}
}
}

次にState の更新があったときに適切な View が操作されているかを確認します。SampleViewDelegate では SampleState#text の状態が変わるごとに ProgressBarTextView の操作をしているので、状態ごとに対応する操作が行われているかどうかを確認します。

object SampleViewDelegateTest : Spek({
beforeGroup {
RxAndroidPlugins.setMainThreadSchedulerHandler { Schedulers.trampoline() }
}
Feature("View operations by state updates") {
val input: SampleInput by memoized(CachingMode.EACH_GROUP) {
mockk(relaxed = true)
}
val progress: ProgressBar by memoized(CachingMode.EACH_GROUP) {
mockk(relaxed = true)
}
val textView: TextView by memoized(CachingMode.EACH_GROUP) {
mockk(relaxed = true)
}
val button: Button by memoized(CachingMode.EACH_GROUP) {
mockk(relaxed = true)
}
val disposables: CompositeDisposable by memoized(CachingMode.EACH_GROUP) { CompositeDisposable() }
// ViewModel からの State の更新を簡単にシミュレートするための Subject
val stateSubject: Subject<SampleState> by memoized(CachingMode.EACH_GROUP) { BehaviorSubject.create<SampleState>() }
lateinit var viewDelegate: ViewDelegate
afterGroup {
disposables.clear()
}
Scenario("Updating text") {
Given("A view delegate") {
viewDelegate = SampleViewDelegate(input, progress, textView, button)
}
// State の更新を受け付ける準備をして……
When("Subscribe text updates") {
viewDelegate.subscribeTextStateUpdates(stateSubject.hide()).addTo(disposables)
}
// 新しい State を通知すると……
And("Emit a new state describing text is loading") {
stateSubject.onNext(SampleState(text = RemoteData.Loading()))
}
// Loading 状態では ProgressBar が表示され……
Then("Progress bar becomes visible") {
verify { progress.visibility = eq(View.VISIBLE) }
}
// その後さらに新しい State を通知すると……
When("Emit a state describing text is loaded successfully") {
stateSubject.onNext(SampleState(text = RemoteData.Success("ok")))
}
// Success 状態になったので ProgressBar が非表示になり……
Then("Progress bar goes away") {
verify { progress.visibility = eq(View.GONE) }
}
// 得られた文字列を TextVeiw にセットする
And("Text is set to text view") {
verify { textView.text = eq("ok") }
}
}
}
})

おわりに

この記事では、メルペイ Android が Redux の考え方を使って状態遷移を表現していること、ViewModel でうまく Action に変換して状態遷移を動かしていること、ViewDelegate を使って Presentation レイヤーで起こりがちなコードの肥大化を防ぐことを解説しました。あわせて、それぞれのクラスに対してどのようなことをユニットテストすべきかも整理しています。メルペイが始まった当初は特に Presentation レイヤーの設計が足りずユニットテストしづらい状態でしたが、View に対してなにをしているのかを整理し名前をつけて管理できるようにしたことで、テストカバレッジが全体として10%向上しました。機能によっては20%~30%向上した部分もあります。

一方でまだまだ課題もあり、たとえば RemoteData をもった StateBundle に保存したいときにアドホックなコードを書かざるを得なかったり、役割が違うとはいえ実装すべきものが多いため、どうしてもボイラープレートが多くなりがちといった課題があります。これは現在改善に向けて取り組んでいるところです。

明日のMerpay Advent Calendar は @t10471 さんのクリーンアーキテクチャについての記事となります。お楽しみに!

*1:Spek で Gherkin 記法を使ってテストを書くことで、テストの「前提条件」「操作」「検証」を DSL で記述していけるのでわかりやすくなります

*2:メルペイ Android では Android Architecture Component 対応版も作成済みで、順次導入を進めているところです

  • X
  • Facebook
  • linkedin
  • このエントリーをはてなブックマークに追加