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を更新するかということは考慮しなくても良いので、こちらの方が楽な可能性はありそう。

iOSDC 2018に参加しました

私用で日曜日はほとんど参加できなかったのですが、前夜祭〜日曜昼あたりまでの感想です。

スタッフ・スピーカーの皆さまお疲れ様でした!カンファレンス自体の雰囲気も楽しかったですし、すごいエンジニアのトークを聞いて意識がもりもり高まりました。あとWi-Fiも毎回ありがとうございます。

昨年も一般参加者として参加しましたが、今年も一般ノーマル参加者でした。今年は同期や会社の方々がたくさん登壇していたので、ちょっと寂しい気持ちでした。

今回はソースコードが書かれたおもしろTシャツを着て、LGTMと書かれた扇子を配るということをしていました。このおもしろTシャツの案は自分が出したのですが、意外と活躍する場面が少なく悲しかったです。なので、そのうち別の機会に触れるかもしれません。

余談ですが、普段はAndroidアプリを開発しています。

トークについて

WebでReactなどが多く使われていることもあり、iOSAndroidアプリのプレゼンテーション層にどうやって差分更新を取り入れていくかみたいな話は個人的にも興味があるし、パフォーマンスももちろんきになる。そういう流れで今回Diff系の発表が多かったように思える。 AndroidだとDiffUtilsというリストの差分更新ツールがSupport Libraryで提供されていたりするがUIKitが提供したりっていうのはまだなさそう。iOSだとDifferenceKitが最近イケてるライブラリっぽくて、使ってみたい。

差分計算アルゴリズムを用いた高速なUITableView描画 by fumito-ito | プロポーザル | iOSDC Japan 2018 - fortee.jp

5000行のUITableViewを差分更新する by ばんじゅん🍓 | プロポーザル | iOSDC Japan 2018 - fortee.jp


個人的にiPhoneを同期する話が一番グッときました。システムの時計は同期として使えず、Bluetoothを使って互いに時刻の差分を送りあって、時刻の誤差を減らすという手法。 そもそも時刻とはまで踏み込んでトークされていて、iOSDCとは、となりとても面白かった。

以前Twinkrunというゲームを作っていて、Wi-Fiが使えないなどの要件がすごい似ていたので親近感を感じました。 Twinkrunは結局通信による同期を諦めて、ユーザーが同時にボタンを押すことでゲームを成立させているんですが、この発表の内容と同様に実装しなおしたらより面白いゲーム性を提供できるかもしれないとワクワクしました。

このスピーカーの方はスタッフもされているようで力強さを感じました...

Synchronized iPhones! by TachibanaKaoru | プロポーザル | iOSDC Japan 2018 - fortee.jp


Rxの話が今年は少ない気がして個人的には悲しかった。これからはSwiftにもasync / awaitが入るしいいじゃんという感じの流れになっているかもしれない。個人的には、単純な非同期処理以外の機能にも助けられることが多いなと感じるので、来年はRxの良さを伝えられる発表をしたいな...(?)

WantedlyさんのReactorKitのライブコーディングはRx芸を感じて最高でした。

ごはん

最高でした。1日目のニューヨークチキンライスがうますぎて、夜もチキンライスを食べました。このニューヨークチキンライスはどこで食べられますか?

https://i.gyazo.com/635a382450739e285fab3c6d372da8e3.jpg

https://i.gyazo.com/b313a7b425412971eafaa1451f9ed522.jpg

あるある

あとは席に電源あると良いなと思うけど、あったら作業が捗ってしまうので、なくて良いかもしれない。

おわりに

去年iOSアプリを開発していて課題に感じていた部分が、多くの発表で解決の一例を見れた気がしました。 発表もブログも苦手だけど、来年は登壇できるように経験値を貯めていきたい。

https://i.gyazo.com/7b6e942f6a79f45fe5429228d0e07062.jpg