Android ViewModelのフィールドをView側で購読するときの工夫

Androidで1年くらいViewModelを書いてきてつらいなと思ったことと、それを解決するための工夫を軽くまとめました。

AndroidでViewModelを書くとき、こんな感じにして、

class ProfileViewModel : ViewModel() {
    private val nameSubject: BehaviorSubject<String> = BehaviorSubject.create()
    val name = nameSubject.hide()!!
    ...
}

こういう感じでView側で購読することが多いと思うが、

class ProfileFragment : Fragment() {
    ...
    private fun setup() {
        profileViewModel.name
            .observeOn(AndroidSchedulers.mainThread)
            .subsribeBy {
                nameTextView.text = it
            }
            .addTo(disposables)
    }
    ...
}

これだと、ProfileViewModelのフィールドが増えたときに(nameだけでなくbio, imageUrl, headerImageUrl, ...)、たくさんsubsribeを書かなければいけなくてつらい。 何がつらいかというと、視覚的な問題(コードの見通しの悪さ)もあると思っていて、例えばMVPで書く場合、この部分(なんか用語があった気がする)はメソッド単位に切り分けられていて、名前も onNameUpdated など適切なものになっているので分かりやすい。しかし、上記のようなやり方だと、処理がnameに紐づいていると認識するまでに多少目grepしなければいけない。

例ではRxだが、LiveDataでも同様だと思っている。(xmlに直接bindingできるようになれば、解決できそう)

もちろん、適切な単位でViewModelを分けることをまずすべきだけど、どうしてもフィールドが増えてしまう場合がある。

なので、フィールドを一つの data class にまとめてしまう。

class ProfileViewModel : ViewModel() {
    data class State(val name: String, ...)

    private val stateSubject: BehaviorSubject<State> = BehaviorSubject.create()
    val state = stateSubject.hide()!!
    ...
}
class ProfileFragment : Fragment() {
    ...
    private fun setup() {
        profileViewModel.state
            .observeOn(AndroidSchedulers.mainThread)
            .subsribeBy {
                updateViews(it)
            }
            .addTo(disposables)
    }
    ...
}

こうすることで、subscribeを書くの一回で良くなるが、Stateが更新されるたびに全てのViewを書き換えることになる。かといって、以下のような感じに以前の値と比較してViewを更新するかの判定を全ての処理に書いていくのは煩雑になって見づらい。また、これを書かない場合、パフォーマンスに問題が出てくるかもしれない。

if (prevState.name != state.name) {
    nameTextView.name = state.name
}
...

そこで、ViewModel→Viewの間にDiffを検出してDispatchする層(DiffDispatch層)を追加することにした。層を追加する手間は若干あるものの、subscribe地獄とViewに判定処理を書く煩雑さからは逃れることができる。アイデアは以下のライブラリが元になっています。

github.com

ただし、自分の作っているプロジェクトでは上記のライブラリがうまく動きませんでした。(再現条件や原因が分かればIssueやPRを建てたいところですが...)

また、上記のライブラリではListの追加や削除といったDiffをDispatchすることはできないです。自分のプロジェクトではDiffUtilsを使ってListのDiffを検出していたため、それもDiffDispatch層に含めてしまうことにしました。ただし、RecyclerViewを使う場合はRecyclerViewのAdapterに書く方が良い方が多いと思います。

interface ProfileStateRenderer {
    fun renderName(name: String)
}
class ProfileStateDiffDispatcher(private val renderer: ProfileStateRenderer) {
    fun dispatch(state: ProfileViewModel.State, previousState: ProfileViewModel.State?) {
        if (state.name != previousState?.name) {
            renderer.renderName(state.nname)
        }
        // DiffUtilsを使う場合もここに書いていく
        ....
    }
}
class ProfileFragment : Fragment(), ProfileStateRenderer {
    ...
    private val profileDiffDispathcer = ProfileStateDiffDispatcher(this)
    private val prevProfileState: ProfileViewModel.State? = null

    private fun setup() {
        // 余談: なんかこれzipとかでprevProfileStateをまとめられないかな...
        profileViewModel.state
            .observeOn(AndroidSchedulers.mainThread)
            .subsribeBy {
                profileDiffDispatcher.dispatch(it, prevProfileState)
                prevProfileState = it
            }
            .addTo(disposables)
    }

    override fun renderName(name: String) {
        nameTextView.text = name
    }
    ...
}

これが工夫という話でした。


「これだったら最初っからMVVMっぽくしなくても、MVPで良くない?」 → そうかもしれない、がPresenterでどのタイミングでViewを更新するかということは考慮しなくても良いので、こちらの方が楽な可能性はありそう。