Androidアプリ開発にFluxアーキテクチャを導入したら保守性も品質も上がりました

f:id:vasilyjp:20180927090637j:plain

こんにちは。フロントエンドエンジニアの茨木(@niba1122)です。
弊社のAndroidアプリ開発ではMVVMアーキテクチャを用いています。日々肥大化・複雑化していくViewModelが保守性や品質を担保する上で課題になっていましたが、Fluxアーキテクチャの導入により改善することができました。
本記事では、実際どのようにFluxアーキテクチャを導入したのかを、設計やコード例を交えながらご紹介します。

今までのMVP・MVVMの限界

アプリ開発ではMVP・MVVMといったアーキテクチャがよく用いられます。弊社のAndroidアプリ開発でもMVVMを用いています。これらのアーキテクチャはビューとドメインロジックを分割するのに役立っています。しかし、昨今のUIには多くのイベントや状態があり、更にそこにAPIリクエストなどの非同期処理が絡んできます。これらが関わるプレゼンテーション層のロジックをドメイン層に持ち込むことは難しく、結果的にPresenterやViewModelが肥大化・複雑化しがちです。弊社でも複雑な画面でよくViewModelが肥大化・複雑化し、実装・メンテナンス上の課題になっていました。それらを解決するための手法として、アプリ開発でも徐々に普及しつつあるFluxに着目しました。

Fluxについて

ここで、一旦Fluxについておさらいしておきましょう。FluxはFacebookが提唱する、イベントや状態を扱うアーキテクチャです。次の図に示すように、Fluxではデータフローを単一方向に扱います。

出典:https://github.com/facebook/flux

FluxではイベントやAPIリクエストの完了後、それを直接ビューで購読することなくDispatcherにActionオブジェクトで通知します。StoreはDispatcherを購読し、Actionオブジェクトに応じてState(状態)を更新します。ViewはStoreのStateをウォッチして、変更があった場合に自身を更新します。このようなデータフローにすることで、APIリクエストやリクエスト完了後の状態更新を切り分けることが出来ます。それにより、コードの見通しが良くなるだけでなくテストの書きやすさも向上します。

Fluxを導入してみる

基本設計

特定の画面の状態やイベントをFluxで管理したいので、FluxをMVVMのViewModelに適用します。AndroidにはメジャーなFluxのライブラリがないので、抽象クラスを自作することにしました。Fluxの実装例はReduxやVuexなどいくつかあります。しかし、これらのインタフェースは弊社で積極的に使っているRxJava2との相性があまり良くないです。そのため、抽象クラスのインタフェースも独自で設計しました。次の図は、Flux導入後のアプリのアーキテクチャです。

f:id:vasilyjp:20180309011008p:plain

図中のStoreの範囲がFluxに相当します。Fluxの各構成要素は他のライブラリを参考に定義しています。構成要素の定義は各ライブラリのドキュメントで示されていますが、抽象的なものが多いです。そこで、本記事における構成要素の定義を明確にしておきます。

Store

Fluxの構成要素を保持し、依存関係の解決やActionの伝達を行います。

Event

ボタンのクリックやタイマーなどのイベントに直接対応します。実体は後述するActionCreatorのメソッドです。

ActionCreator

イベントに応じてAPIリクエストなどの処理を実行します。非同期処理は全てここで行います。状態更新が必要なタイミングでActionのインスタンスを通知します。

Action

Actionはイベントの発生やAPIのリクエスト開始/完了/失敗といった状態変更の基点を表し、payloadと呼ばれるデータを併せ持ちます。APIリクエスト成功のActionの場合、payloadとしてレスポンスの値を渡したりします。

Reducer&State

Stateはビューの表示や更新に必要な状態変数で、Reducerのメンバーとして定義します。ReducerはActionを受け取り、それに応じてStateを更新します。単一方向のデータフローを守るため、Stateは外部からはimmutableにします。また、Stateには動的に取得するトークンなど、表示に使わない状態変数も含まれます。その為、ReducerはFluxの外に公開しません。更に、ReducerはActionのみに基づいてStateを更新するため、クラス外部へのアクセスも行いません。

Getter&View Property

View Propertyはビューの状態に直接対応する要素で、View PropertyはGetterのメンバーとして定義します。GetterはReducerのメンバーであるStateを購読し、View Propertyにマッピングして通知します。Stateと同様に外部からはimmutableにします。Getterはビューに通知する必要があるので、Fluxの外部に公開します。

抽象クラス

前述の設計に従って実装を行うための抽象クラスを定義しました。抽象クラスではActionCreator、Reducer、Getterクラスに依存関係を注入します。StateやViewPropertyの通知に関しては敢えて抽象化していません。実装者が柔軟にRxJava2のストリームを定義できるようにしています。StateやViewPropertyのimmutable性は、Subjectをクラス外に公開しない、Observableをvalで公開するというコーディング規約で担保しています。

package com.example.niba1122.flux.util.flux

import io.reactivex.Observable
import io.reactivex.disposables.CompositeDisposable
import io.reactivex.subjects.PublishSubject

abstract class Store<AT, out AC, R, out G> where AC : DisposableMapper, R : DisposableMapper, G : DisposableMapper {
    private val dispatcher: PublishSubject<AT> = PublishSubject.create()
    private val reducer: R by lazy {
        createReducer(dispatcher.observeOn(Schedulers.computation()))
    }
    val actionCreator: AC by lazy {
        createActionCreator({
            dispatcher.onNext(it)
        }, reducer)
    }
    val getter: G by lazy {
        createGetter(reducer)
    }

    protected abstract fun createActionCreator(dispatch: (AT) -> Unit, reducer: R): AC
    protected abstract fun createReducer(action: Observable<AT>): R
    protected abstract fun createGetter(reducer: R): G

    fun clearDisposables() {
        actionCreator.disposables.clear()
        reducer.disposables.clear()
        getter.disposables.clear()
    }
}

open class DisposableMapper {
    val disposables = CompositeDisposable()
}

Storeを実装する場合は上の抽象クラスを継承した具象クラスを定義します。この際に、ActionCreator、Reducer、Getterを生成するメソッドの定義を抽象クラスから要求されます。各構成要素でRepositoryなどを用いる場合は、具象クラスのコンストラクタからインスタンスを与えます。

ユーティリティ

RxJava2は高い表現力を持っていますが、より開発しやすくするために幾つかクラスを定義して使っています。

Variable

BehaviorSubjectに似たようなものです。.valueで値の取得だけでなく通知も可能な点が異なります。値を購読する場合には.observableで取得したObservableを使います。更に、immutableなインタフェースを持っているので、クラス外部へimmutableに公開することができます。

import io.reactivex.Observable
import io.reactivex.subjects.BehaviorSubject

interface ImmutableVariable<T> {
    val value: T
    val observable: Observable<T>
}

class Variable<T>(initialValue: T) : ImmutableVariable<T> {
    private val subject = BehaviorSubject.createDefault(initialValue)
    override var value: T
        get() = subject.value
        set(value) {
            subject.toSerialized().onNext(value)
        }

    override val observable: Observable<T>
        get() = subject.distinctUntilChanged()
}

NullableVariable

先程のVariableはRxJava2がnullを許容しないためにnullの値を扱うことが出来ません。 そこで、SwiftやRustのOptional型のように値をラップすることでnullも扱えるようにしたのがNullableVariableです。

import io.reactivex.Observable
import io.reactivex.subjects.BehaviorSubject

interface ImmutableNullableVariable<T> {
    val value: T?
    val observable: Observable<Wrapper<T>>
}

@Suppress("unused")
sealed class Wrapper<T> {
    class Some<T>(val value: T) : Wrapper<T>()
    class None<T> : Wrapper<T>()

    fun unwrap(): T? = when (this) {
        is Some -> this.value
        is None -> null
    }
}

class NullableVariable<T>(initialValue: T?) : ImmutableNullableVariable<T> {
    private val subject = BehaviorSubject.createDefault<Wrapper<T>>(
            if (initialValue != null) {
                Wrapper.Some(initialValue)
            } else {
                Wrapper.None()
            })

    override var value: T?
        get() = subject.value.let {
            when (it) {
                is Wrapper.Some -> it.value
                is Wrapper.None -> null
            }
        }
        set(value) {
            if (value != null) {
                subject.toSerialized().onNext(Wrapper.Some(value))
            } else {
                subject.toSerialized().onNext(Wrapper.None())
            }
        }

    override val observable: Observable<Wrapper<T>>
        get() = subject.toSerialized().distinctUntilChanged()
}

実装例

ここまでで説明してきたFluxの実装例として、スクロールによりページングする一覧画面の例をご紹介します。

f:id:vasilyjp:20180309032122g:plain

サンプルコードはGitHubにもアップロードしてあります。

ViewModel

先に述べたようにViewModelではEventをstoreに通知し、View Propertyを受け取ります。ListViewModelでは、ビューの初期化とスクロール完了のEventをStoreに通知しています。そして、View PropertyとしてRecyclerViewのアイテム、初期ローディング、エラー時のスナックバーに関する値を受け取ります。弊社で最近開発しているサービスに合わせ、RxBindingやDataBindingを使用せずにLiveDataをObserveしてViewを更新しています。

import android.arch.lifecycle.MutableLiveData
import android.arch.lifecycle.ViewModel
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.CompositeDisposable

class ListViewModel(repository: Repository) : ViewModel() {

    private val disposables: CompositeDisposable = CompositeDisposable()
    private val store = ListStore(repository)

    val listItems: MutableLiveData<List<ListItemType>> = MutableLiveData()
    val isShownPageLoading: MutableLiveData<Boolean> = MutableLiveData()

    init {
        store.getter.listItems
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe({
                    listItems.value = it
                }, {}).let { disposables.add(it) }

        store.getter.isShownPageLoading.observable
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe({
                    isShownPageLoading.value = it
                }, {}).let { disposables.add(it) }

        store.getter.errorSnackbar
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe({
                    errorSnackbar.value = it
                }, {}).let { disposables.add(it) }
    }

    override fun onCleared() {
        disposables.clear()
        store.clearDisposables()
    }

    fun onInitialize() {
        store.actionCreator.onInitialize()
    }

    fun onScrollToLast() {
        store.actionCreator.onScrollToLast()
    }
}

Store

Storeでは画面で用いるActionCreator、Reducer、Getterの注入を行います。Repositoryの注入も併せてここで行います。

import io.reactivex.Observable
import io.reactivex.Single

class ListStore(private val repository: Repository) : Store<ListActionType, ListActionCreator, ListReducer, ListGetter>() {
    override fun createActionCreator(dispatch: (ListActionType) -> Unit, reducer: ListReducer): ListActionCreator = ListActionCreator(dispatch, reducer, repository)

    override fun createReducer(action: Observable<ListActionType>): ListReducer = ListReducer(action)

    override fun createGetter(reducer: ListReducer): ListGetter = ListGetter(reducer)
}

Action

状態更新の基点になるActionを定義しています。Action毎に付随するデータが異なるので、enumではなくsealed classを用いています。

sealed class ListActionType {
    class StartInitialLoad : ListActionType()
    class SuccessInitialLoad(val elements: List<Element>) : ListActionType()
    class StartNextLoad : ListActionType()
    class SuccessNextLoad(val elements: List<Element>) : ListActionType()
    class Error(val error: Throwable) : ListActionType()
}

ActionCreator

Eventに応じてAPIリクエストなどの処理を行い、状態更新の基点でActionを通知します。

class ListActionCreator(private val dispatch: (ListActionType) -> Unit, private val reducer: ListReducer, private val repository: Repository) : DisposableMapper() {
    fun onInitialize() {
        dispatch(ListActionType.StartInitialLoad())
        repository.list(1)
                .subscribe({
                    dispatch(ListActionType.SuccessInitialLoad(it))
                }, {
                    dispatch(ListActionType.Error(it))
                }).let { disposables.add(it) }
    }

    fun onScrollToLast() {
        if (reducer.isNextLoading.value) return
        dispatch(ListActionType.StartNextLoad())
        repository.list(reducer.page + 1)
                .subscribe({
                    dispatch(ListActionType.SuccessNextLoad(it))
                }, {
                    dispatch(ListActionType.Error(it))
                }).let { disposables.add(it) }
    }
}

Reducer

Actionを購読し、Actionの種類に応じてStateを更新しています。ここではStateの型を、値の保持や通知が必要かどうかに応じて使い分けています。また、値を実際に保持・通知する変数と外部に公開する変数を分けることで、外部からのimmutable性を実現しています。

import io.reactivex.Observable
import io.reactivex.subjects.PublishSubject

class ListReducer(action: Observable<ListActionType>) : DisposableMapper() {
    init {
        action.ofType(ListActionType.StartInitialLoad::class.java)
                .subscribe {
                    mIsInitialLoading.value = true
                }.let { disposables.add(it) }

        action.ofType(ListActionType.SuccessInitialLoad::class.java)
                .subscribe {
                    mIsInitialLoading.value = false
                    mElements.value = it.elements
                }.let { disposables.add(it) }

        action.ofType(ListActionType.StartNextLoad::class.java)
                .subscribe {
                    mIsNextLoading.value = true
                }.let { disposables.add(it) }

        action.ofType(ListActionType.SuccessNextLoad::class.java)
                .subscribe {
                    mIsNextLoading.value = false
                    mElements.value += it.elements
                    page++
                }.let { disposables.add(it) }

        action.ofType(ListActionType.Error::class.java)
                .subscribe {
                    mError.onNext(it.error)
                }.let { disposables.add(it) }
    }

    var page: Int = 1
        private set

    private val mElements: Variable<List<Element>> = Variable(listOf())
    val elements: ImmutableVariable<List<Element>>
        get() = mElements

    private val mIsInitialLoading: Variable<Boolean> = Variable(false)
    val isInitialLoading: ImmutableVariable<Boolean>
        get() = mIsInitialLoading

    private val mIsNextLoading: Variable<Boolean> = Variable(false)
    val isNextLoading: ImmutableVariable<Boolean>
        get() = mIsNextLoading

    private val mError: PublishSubject<Throwable> = PublishSubject.create()
    val error: Observable<Throwable>
        get() = mError
}

Getter

Reducerから受け取ったStateをView Propertyにマッピングしています。ここでのlistItemsはこのままRecyclerViewに渡され、DiffUtilにより更新される想定です。

import io.reactivex.Observable
import io.reactivex.functions.BiFunction

class ListGetter(reducer: ListReducer) : DisposableMapper() {

    val listItems: Observable<List<ListItemType>> =
            Observable.combineLatest(
                    reducer.elements.observable,
                    reducer.isNextLoading.observable,
                    BiFunction { elements, isNextLoading ->
                        var listItems: List<ListItemType> = elements.map { ListItemType.Data(it) }
                        if (isNextLoading) {
                            listItems += ListItemType.Loading()
                        }
                        listItems
                    })

    val isShownPageLoading = reducer.isInitialLoading

    val errorSnackbar = reducer.error
}

まとめ

Fluxを導入してViewModelの処理を分割したメリットは想像以上でした。どこにどのような処理が書かれているかを把握しやすくなったので、コードの保守性がかなり向上しました。更に、複雑化したViewModelでは難しかったUI改善がFlux導入で可能になり、ユーザー体験も向上させることができました。

最後に

VASILYではこだわりをもってプロダクトを開発したいエンジニアを募集しています! 興味のある方は次のリンクよりお申し込み下さい。

カテゴリー