もやもやエンジニア

IT系のネタで思ったことや技術系のネタを備忘録的に綴っていきます。フロント率高め。

AACのViewModel使ってFluxする

結論

出落ちですがAndroidのモダンな設計周りの話はPEAKSで出ているこの本を読んだ方が手っ取り早く吸収できます。AACとFluxの話も載ってます。

Android アプリ設計パターン入門

Android アプリ設計パターン入門

  • 著者:日高 正博,小西裕介,藤原聖,吉岡 毅,今井 智章,
  • 製本版,電子版
  • PEAKSで購入する

とはいえ、いろいろ自分でも試行錯誤しないと理解が進まないので、上記を踏まえた上で自分なりに実装してみた話です。自分でリリースしているアプリ↓をいじっています。

play.google.com

構成

  • パターン本にも書いてありますがFluxで状態を管理するところのStoreはAACのViewModelが担うことができます。ここを軸に以下の構成で作ってみます。

全体の構成

  • お題となる画面は解答で表示する札を表示するだけのやつ

札

Dispatcher

  • dispatchで渡されたActionをpublisherに流し込むだけの責務を持ってます。

action/Dispacher.kt

@Singleton
class Dispatcher @Inject constructor() {

    private val processor = PublishProcessor.create<Action>()

    fun dispatch(action: Action) {
        Logger.action(action)
        processor.onNext(action)
    }

    fun <T : Action> on(clazz: Class<T>): Observable<T> = processor.onBackpressureBuffer()
            .ofType(clazz)
            .observeOn(AndroidSchedulers.mainThread())
            .toObservable()
}

ActionDispatcher

  • 名前はActionCreatorのほうが良かったかも。DBにアクセスしたりユーザーからの入力を処理したりなどのビジネスロジックが詰まっています。
  • SchedulerProviderはDroidKaigiのアプリ参考にさせてもらいました。テスト時にはtrampolineなテスト用のスケジューラーを差し込めます。
  • dispatcherに流しているFetchKarutaActionはpayloadと例外情報を持つだけのclassです。

action/karuta/KarutaActionDispatcher.kt

@Singleton
class KarutaActionDispatcher @Inject constructor(
        private val karutaRepository: KarutaRepository,
        private val dispatcher: Dispatcher,
        private val schedulerProvider: SchedulerProvider
) : SingleExt {
    fun fetch(karutaId: KarutaIdentifier) {
        karutaRepository.findBy(karutaId).scheduler(schedulerProvider).subscribe({
            dispatcher.dispatch(FetchKarutaAction(it))
        }, {
            dispatcher.dispatch(FetchKarutaAction(null, it))
        })
    }
}

Store

  • Dispacherから流れてくるActionを監視して自分の状態を更新します。ReduxのReducer的な責務も兼ねてますね。
  • StoreはAACのViewModelを継承した抽象クラスでDispatcher監視用の共通メソッドが生えてます。onClearedでregisterで登録した監視を解除しているので、ライフサイクルで死ぬときに 監視は解除されます。

presentation/karuta/KarutaStore.kt

class KarutaStore(dispatcher: Dispatcher) : Store() {

    private val karutaLiveData = MutableLiveData<Karuta?>()
    val karuta: LiveData<Karuta?> = karutaLiveData

    private val notFoundKarutaEventLiveData = SingleLiveEvent<Void>()
    val notFoundKarutaEvent = notFoundKarutaEventLiveData

    init {
        register(dispatcher.on(FetchKarutaAction::class.java).subscribe {
            if (it.error == null) {
                karutaLiveData.value = it.karuta
            } else {
                notFoundKarutaEventLiveData.call()
            }
        })
    }

    class Factory @Inject constructor(private val dispatcher: Dispatcher) : ViewModelProvider.Factory {
        @Suppress("UNCHECKED_CAST")
        override fun <T : ViewModel> create(modelClass: Class<T>): T {
            return KarutaStore(dispatcher) as T
        }
    }
}

ViewModel

  • Storeを監視してViewにStoreの状態を加工して反映させます。AACのViewModelではありません。。。名前付け難しい。。。

presentation/karuta/KarutaViewModel.kt

class KarutaViewModel(
        store: KarutaStore,
        actionDispatcher: KarutaActionDispatcher,
        karutaId: KarutaIdentifier
) : LiveDataExt {

    val isLoading: LiveData<Boolean> = store.karuta.map { it == null }

    val karutaNo: LiveData<Int?> = store.karuta.map { it?.identifier()?.value }

    val karutaImageNo: LiveData<String?> = store.karuta.map { it?.imageNo?.value }

    val creator: LiveData<String?> = store.karuta.map { it?.creator }

    val kimariji: LiveData<Int?> = store.karuta.map { it?.kimariji?.value }

    val kamiNoKuKanji: LiveData<String?> = store.karuta.map { it?.kamiNoKu?.kanji }

    val shimoNoKuKanji: LiveData<String?> = store.karuta.map { it?.shimoNoKu?.kanji }

    val kamiNoKuKana: LiveData<String?> = store.karuta.map { it?.kamiNoKu?.kana }

    val shimoNoKuKana: LiveData<String?> = store.karuta.map { it?.shimoNoKu?.kana }

    val translation: LiveData<String?> = store.karuta.map { it?.translation }

    val notFoundKarutaEvent = store.notFoundKarutaEvent

    init {
        if (store.karuta.value == null) {
            actionDispatcher.fetch(karutaId)
        }
    }

    class Factory @Inject constructor(private val actionDispatcher: KarutaActionDispatcher) {
        fun create(store: KarutaStore, karutaId: KarutaIdentifier): KarutaViewModel =
                KarutaViewModel(store, actionDispatcher, karutaId)
    }
}

Fragment

  • FragmentはStoreを取得して、それを元にViewModelを作ってDataBinidngにセットするまでを仕事します(必要そうなところだけ抜粋)。

presentation/karuta/KarutaFragment.kt

class KarutaFragment : DaggerFragment(), FragmentExt {

    @Inject
    lateinit var storeFactory: KarutaStore.Factory

    @Inject
    lateinit var viewModelFactory: KarutaViewModel.Factory

    private var listener: OnFragmentInteractionListener? = null

    private val karutaId: KarutaIdentifier
        get() = requireNotNull(arguments?.getParcelable(ARG_KARUTA_ID)) {
            "$ARG_KARUTA_ID is missing"
        }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        val viewModel = viewModelFactory.create(obtainActivityStore(KarutaStore::class.java, storeFactory), karutaId)
        viewModel.notFoundKarutaEvent.observe(this, Observer {
            listener?.onError()
        })

        val binding = FragmentKarutaBinding.inflate(inflater, container, false).apply {
            setLifecycleOwner(this@KarutaFragment)
        }

        binding.viewModel = viewModel

        return binding.root
    }

...

}

おしまい。

  • という感じでリリース済のアプリを書き換えてみました。(まだリリースしていなくて、テスト周りを充実させているところ)各クラスの役割は分担できていて自分以外の人も追いやすく読める形になってるのかなと。例えばここに歌を編集するような操作を追加したいときはactionDispatcherにeditとか生やしてそこに歌を編集して永続化する処理を実装、Actionは編集後の歌をpayloadとして流す、storeはそれを受け取って自身の状態を変更する、というような実装の流れになります。ViewModelはstoreの監視対象は変わらないので、ユーザーのactionとactionDispatcherのeditを結びつけるだけですね。
  • READMEにも書きましたがアプリの規模に対してはやや過剰な設計なので、興味があればあまり参考にしないで話半分にコードを読むのがいいと思います。

GitHub - rei-m/android_hyakuninisshu