Blog

kotlin coroutines flowでいいねボタン問題を解決しよう

複雑化するmobileアプリにおいて、アプリ内の状態をどう整合性を保って保持するかという議論は非常に重要です。

特に、画面を跨いだときにどうデータを同期するかは難しく、少し間違えば結合度が高く、メンテナンス性が低いコードになりがちです。

例えば、twitterのような一覧画面と詳細がある場合に、詳細画面で行った「いいね」を、どうツイート一覧画面に反映させるかという問題です。

いわゆる「いいねボタン問題」と呼ばれているものです。

最近では、OR Mapperの Room にて、内部の状態をLiveDataやFlowで出力することで、解消しているケースを多く見かける気がします。

-> 例: DroidKaigi 2020

一方、永続化する必要がない、一部のデータのみをやり取りしたい等の理由で、この手法を採用できないケースも多々あると思います。

今回は、roomを使わず、kotlin coroutines flowをふんだん使って、柔軟に対応できる設計を紹介したいと思います。

- 2020/11/15追記 -

この記事はSharedFlowがリリースされる前に書かれています。

BroadcastChannel等、一部古い書き方がされています。

詳しくはこちらの記事も参考にしてください。

kotlin coroutines flow / channelの簡単な紹介

詳しい説明については今回省きたいと思います。

公式ドキュメントはこちらです。

実装に入る前に、今回利用する重要なものを紹介します。

BroadcastChannel

flowを作るためにはいくつかの手法がありますが、今回はhot streamとしてchannelを使います。

通常のChannelだと、一箇所でしかobserveすることが出来ません。

そこで、複数箇所でobserveすることができるBroadcastChannelを使います。

BroadcastChannelはopenSubscriptionする度に、観測可能なReceiveChannelを作ることが出来ます。

Flow

次に、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にて販売しています。より詳しく学びたい方は、こちらも合わせて確認してみて下さい。

詳解 Kotlin Coroutines [2021] | Zenn

人気の記事

kotlin coroutinesのFlow, SharedFlow, StateFlowを整理する

Jetpack ComposeとKotlin Coroutinesを連携させる

Layout Composableを使って複雑なレイアウトを組む【Jetpack Compose】

テスト用Dispatcherの使い分け【Kotlin Coroutines】

Flow.combineの内部実装がすごい話

Jetpack ComposeのRippleエフェクトを深堀り、カスタマイズも