複雑化するmobileアプリにおいて、アプリ内の状態をどう整合性を保って保持するかという議論は非常に重要です。
特に、画面を跨いだときにどうデータを同期するかは難しく、少し間違えば結合度が高く、メンテナンス性が低いコードになりがちです。
例えば、twitterのような一覧画面と詳細がある場合に、詳細画面で行った「いいね」を、どうツイート一覧画面に反映させるかという問題です。
いわゆる「いいねボタン問題」と呼ばれているものです。
最近では、OR Mapperの Room にて、内部の状態をLiveDataやFlowで出力することで、解消しているケースを多く見かける気がします。
-> 例: DroidKaigi 2020
一方、永続化する必要がない、一部のデータのみをやり取りしたい等の理由で、この手法を採用できないケースも多々あると思います。
今回は、roomを使わず、kotlin coroutines flowをふんだん使って、柔軟に対応できる設計を紹介したいと思います。
この記事はSharedFlowがリリースされる前に書かれています。
BroadcastChannel等、一部古い書き方がされています。
詳しくはこちらの記事も参考にしてください。
詳しい説明については今回省きたいと思います。
公式ドキュメントはこちらです。
実装に入る前に、今回利用する重要なものを紹介します。
flowを作るためにはいくつかの手法がありますが、今回はhot streamとしてchannelを使います。
通常のChannelだと、一箇所でしかobserveすることが出来ません。
そこで、複数箇所でobserveすることができるBroadcastChannelを使います。
BroadcastChannelはopenSubscriptionする度に、観測可能なReceiveChannelを作ることが出来ます。
次に、Flow についてです。
実は、coroutines channelも単体でevent busのように使うことができるので、flowを使わなくとも実装はできます。
しかし、演算のオペレータがなかったり、closeを忘れるとリークしたりするので、あまり扱いやすいものではありません。
そのため、様々なオペレータが用意されており、coroutines scopeに従ってobserve(collect)することができるflowを用います。
基本的に、クラスの外に公開する場合はchannelからflowに変換したほうが良いと思っています。
BroadcastChannelはasFlowを使って簡単にflowを作ることが出来ます。
内部では、先程述べた openSubscription()
が使われており、複数箇所でObserveされても問題ありません。
@FlowPreview
public fun <T> BroadcastChannel<T>.asFlow(): Flow<T> = flow {
emitAll(openSubscription())
}
それでは、実際に実装していきたいと思います。
今回はViewModel、Repositoryのみを記載し、View(Activity / Fragment)やAPIの実装は省略します。
まず、こちらがTweetのdata classです。
data class Tweet(
val id: String,
val text: String,
val isLiked: Boolean
)
この中のisLikedをアプリケーション内で同期していきたいと思います。
次に一覧画面のViewModelです。
class ListViewModel: ViewModel() {
private val _list = MutableLiveData<List<Tweet>>()
val list: LiveData<List<Tweet>> = _list
init {
_list.value = (初期リスト)
}
}
初期リストの指定はapiを叩いたりするんだろうと思いますが、今回は省略します。
さて、こっちが詳細画面です。
class DetailViewModel: ViewModel() {
private val _tweet = MutableLiveData<Tweet>()
val tweet: LiveData<Tweet> = _tweet
fun setup(tweet: Tweet) {
_tweet.value = tweet
}
fun like() {
TODO()
}
fun unLike() {
TODO()
}
}
setup()
はactivity / fragmentのonCreateとかで呼んでください。
この中の like()
と unLike()
を実装する必要があります。
その前に、Repositoryを作っておきましょう。
class LikeRepository {
private val likeChannel = BroadcastChannel<String>(Channel.BUFFERED)
val likeFlow = likeChannel.asFlow()
private val unLikeChannel = BroadcastChannel<String>(Channel.BUFFERED)
val unLikeFlow = unLikeChannel.asFlow()
suspend fun like(tweet: Tweet) {
// (ここでAPIを叩く等をする)
likeChannel.send(tweet.id)
}
suspend fun unLike(tweet: Tweet) {
// (ここでAPIを叩く等をする)
unLikeChannel.send(tweet.id)
}
}
このrepositoryはsingletonで使います。
DI等を使っていい感じに配ってください。
like, unLikeのイベントをchannelにsendし、flowとして公開します。
BufferSizeは適当ですが、デフォルトの Channel.BUFFERED
で問題は無いと思います。
これをもとに、ViewModelを書き換えていきます。
まず、詳細画面でlikeとunLikeをしたときの実装をします。
class DetailViewModel(
private val likeRepository: LikeRepository
) : ViewModel() {
private val _tweet = MutableLiveData<Tweet>()
val tweet: LiveData<Tweet> = _tweet
fun setup(tweet: Tweet) {
_tweet.value = tweet
}
fun like() {
val tweet = _tweet.value ?: return
viewModelScope.launch {
likeRepository.like(tweet)
_tweet.value = _tweet.value?.copy(
isLiked = true
)
}
}
fun unLike() {
val tweet = _tweet.value ?: return
viewModelScope.launch {
likeRepository.unLike(tweet)
_tweet.value = _tweet.value?.copy(
isLiked = false
)
}
}
}
両方、repositoryのmethodを実行し、自分自身の状態を書き換えます。
次に、一覧画面です。
class ListViewModel(
likeRepository: LikeRepository
) : ViewModel() {
private val _list = MutableLiveData<List<Tweet>>()
val list: LiveData<List<Tweet>> = _list
init {
_list.value = (初期リスト)
likeRepository.likeFlow
.onEach { update(it, true) }
.launchIn(viewModelScope)
likeRepository.unLikeFlow
.onEach { update(it, false) }
.launchIn(viewModelScope)
}
private fun update(targetId: String, isLiked: Boolean) {
_list.value = _list.value?.map {
if (it.id == targetId) {
it.copy(isLiked = isLiked)
} else {
it
}
}
}
}
likeFlowとunLikeFlowを受け取って、リストの更新を行います。
個人的には、launchしてcollectするより、onEachに処理を書いてlaunchIn させたほうがネストが浅くなって好きです。
こんな感じでマージさせることも出来ますね。
merge(
likeRepository.likeFlow.map {
it to true
},
likeRepository.unLikeFlow.map {
it to false
}
)
.onEach { (targetId, isLiked) ->
update(targetId, isLiked)
}
.launchIn(viewModelScope)
以上で、詳細画面でのいいねが一覧画面でも反映されるようになりました。
singletonのrepositoryから様々なイベントを送ることで、柔軟な対応を行うことが出来ます。
いいね以外のイベントもまとめて送るようにするには、repositoryからTweet modelを送るようにすることも出来ますし、
class LikeRepository {
private val updatedChannel = BroadcastChannel<Tweet>(Channel.BUFFERED)
val updatedFlow = updatedChannel.asFlow()
}
更に別の画面(例えばユーザ画面)と同期させるために、詳細画面でもeventをobserveするようにすることもできます。
class DetailViewModel(
private val likeRepository: LikeRepository
) : ViewModel() {
…
init {
merge(
likeRepository.likeFlow
.map { it to true },
likeRepository.unLikeFlow
.map { it to false }
)
.onEach { (targetId, isLiked) ->
update(targetId, isLiked)
}
.launchIn(viewModelScope)
}
…
private fun update(targetId: String, isLiked: Boolean) {
val tweet = _tweet.value ?: return
if (tweet.id != targetId) return
_tweet.value = tweet.copy(
isLiked = isLiked
)
}
}
今回、flowを使っていわゆる「いいねボタン問題」に取り組みました。
flowはまだExperimentalなAPIが多く残っていたり、ドキュメントや記事が少なかったりしますが、比較的簡単に今回のような複雑な処理を書けると思います。
今回の書き方は自由度が高い代わりに、記述量が増えたり、イベントのflowがむやみに増えたりする可能性があると思っています。
イベントに関しては、必要最低限のものを流すように心がけ、必要に応じて再設計を行うようにしましょう(YAGNI)
また、ViewModelでリストやデータを整形するとだんだん肥大化していくと思います。
僕はRepository層とViewModel層の間にUseCase層を作成し、そこで整形を行っています。
また、更新のメソッドも必要に応じて共通化しましょう。
以上、今後もflowを使った様々な例を扱ってみたいと思います。
- 2021/01/31追記-
Kotlin Coroutinesの解説本をZennにて販売しています。より詳しく学びたい方は、こちらも合わせて確認してみて下さい。