こんにちは、グノシー Android アプリの開発担当の Liang です。
今回はグノシー Android アプリの開発において、Kotlin Coroutines を用いた実用的な Tips をいくつか紹介したいと思います。
ExoPlayer + Coroutines
ExoPlayer
にはLooper
を設置出来る、指定したProcess
の低い Priority で再生などを実行すれば、Main Thread に行うUIの更新と操作を優先させるCoroutineContext
にはLooper
を持った Thread も対応しており、asCoroutineDispatcher
することで、Coroutine にも相互の運用が出来る- 改善点:グノシーの運用では、
RecyclerView
内に自動再生の動画ViewHolder
を実装した場面で、スクロールの操作が更にスムーズになった - 注意点:
ExoPlayer
に関する処理は全て非同期になり、設置したLooper
を持った Thread で実行しなければならない
val priority = Process.THREAD_PRIORITY_BACKGROUND val handlerThread = HandlerThread("ExoPlayer", priority) val playerHandler = Handler(handlerThread.looper) val exoPlayer = ExoPlayer.Builder().setLooper(playerHandler.looper) val playerContext = playerHandler.asCoroutineDispatcher("PlayerDispatcher") // play by coroutine playerContext.launch { exoPlayer.playWhenReady = true }
- Reference exoplayer.dev kotlinlang.org
Dispatchers.Main + Yield
Dispatchers.Main
で実行した Coroutine の Main Thread 内には、処理を順番に行う決まりになっている- 処理毎に優先度を調整したい時、
yield()
を宣言した Coroutine には、該当 Thread を一時的に他の処理に実行を譲ることが出来る yield()
後の処理は一時的に中断され、同一 Thread が解放してから、該当 Coroutine を再開する
coroutineScope.launch(Dispatchers.Main) { println("low priority work launched") yield() // The work can be done lately println("low priority work done") } coroutineScope.launch(Dispatchers.Main) { println("high priority work launched") // The work must be done right now println("high priority work done") }
- 上記の処理結果により、
Low Priority
の Coroutine が後回しにされ、High priority
の Coroutine を完了してから実行することになった
Low priority work launched High priority work launched High priority work done Low priority work done
- 改善点:グノシーの運用では、アプリの起動時に Main Thread を使ったログの送信を後回しにして、フレーム描画によるフリーズが緩和された
- 注意点:
yield()
の処理コストは低くないため、使う場面の選定が重要になる - Reference kotlinlang.org
CoroutineDispatcher.limitedParallelism
- 該当
CoroutineContext
を用いた並行で実行する Coroutine の数を制限出来る newFixedThreadPoolContext
による特定 Thread Pool の利用とは違い、今空いている Thread を使うため、処理コストが低い- 改善点:グノシーの運用では、記事の複数
ViewHolder
を表示すると同時に、記事内容のキャッシュ保存に当たって、並列処理を最多2件に制限することで、実行の負担を減らした - 注意点:必ずしも特定の Thread を使うことではない、
delay
やasync
で一時的に suspend した間は他の Coroutine にも実行する - 現時点は
ExperimentalCoroutinesApi
val coroutineContext = Dispatchers.IO.limitedParallelism(2) coroutineScope.launch(coroutineContext) { // doing simultaneously } coroutineScope.launch(coroutineContext) { // doing simultaneously } coroutineScope.launch(coroutineContext) { // waiting to do until the parallelized work done }
- Reference kotlinlang.org
Flow
flatMapConcat
flatMapConcat
に変換したFlow
では、次の要素が入る時に、今の Coroutine 結果を出力されてから、次の Coroutine を開始する- 改善点:グノシーの運用では、ユーザーの連続クリックによる重複の処理を防ぐ
- 注意点:
map
にflattenConcat
を足す形として新しいFlow
内に順次に処理するため、公式によりmap
だけの利用も検討して良いでしょう - 現時点は
ExperimentalCoroutinesApi
setOnClickListener {
clickFlow.tryEmit()
}
clickFlow.flatMapConcat {
flow {
// Clicking until the previous clicked event is done
}
}
flatMapLatest
flatMapLatest
に変換したFlow
では、次の要素が入る時に、直ちに今の Coroutine をキャンセルし、次の Coroutine を開始する- 改善点:グノシーの運用では、RecyclerView
OnScrollListener.onScrolled
の処理が頻繁に呼ばれるため、すぐ上書きされる要素を破棄することで、無駄な処理を減らした - 注意点:Coroutine がキャンセルされていたことにより、
Flow
内にCancellationException
が発生するため、Exception の制御も必要になる - 現時点は
ExperimentalCoroutinesApi
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { scrollFlow.tryEmit() } scrollFlow.flatMapLatest { flow { // Scrolling be canceled if there is a next scrolled event } }
- Reference kotlinlang.org kotlinlang.org
scan / runningFold
Flow
内に一度出力された要素を保存し、関数を実行する度に、全ての要素を順番に返却するrunningReduce
とは違い、初期値の設置に別の値として出力出来る- 改善点:グノシーの運用では、
RecyclerView.Adapter
の要素の更新に当たって、歴史と最新の要素の比較:DiffResult
を出力するまで、一連の処理をStateFlow
内で完結出来た - 注意点:Thread の切り替え、Buffer との統合運用にも留意しましょう
itemsFlow .scan(emptyList<Item>() to DiffResult()) { (oldItems, diffResult), newItems -> newItems to DiffUtil.calculateDiff() // with the oldItems } .flowOn(Dispatchers.IO) .collect { (items, diffResult) -> // notify the adapter's items }
- Reference kotlinlang.org
感想
Kotlin Coroutines を使う場面が多くなる Android 開発においては、パフォーマンスには少々疑問を思う所もあるかもしれませんが、グノシーの運用としては開発効率の向上を実感しました。今後 Android との親和性も更に高まっていくことでしょう。