Kotlin Coroutinesパターン&アンチパターン
Coroutineを理解するため、以下のサイトを日本語訳しました。
https://proandroiddev.com/kotlin-coroutines-patterns-anti-patterns-f9d12984c68e
目次
- 非同期呼び出しをcoroutineScopeでラップするか、SupervisorJobを使用して例外を処理します。
- ルートコルーチンのメインディスパッチャを優先する
- 不要なasync / awaitの使用を避ける
- スコープジョブをキャンセルしない
- 暗黙のディスパッチャを使って中断関数を書かないようにする
- グローバルスコープの使用を避ける
非同期呼び出しをcoroutineScopeでラップするか、SupervisorJobを使用して例外を処理します
非同期ブロックが例外をスローする可能性がある場合は、try / catchブロックでラップすることに頼らないでください。
val job: Job = Job()
val scope = CoroutineScope(Dispatchers.Default + job)
// may throw Exception
fun doWork(): Deferred<String> = scope.async { ... } // (1)
fun loadData() = scope.launch {
try {
doWork().await() // (2)
} catch (e: Exception) { ... }
}
記の例では、doWork関数はnew coroutine(1)を起動し、これが未処理の例外を投げます。
doWorkをtry / catchブロック(2)でラップしようとしても、まだクラッシュします。
クラッシュを回避する方法の1つは、SupervisorJob(1)を使用することです。
子の失敗またはキャンセルは、SupervisorJの仕事を失敗させることはなく、他の子にも影響しません。
val job = SupervisorJob()
val scope = CoroutineScope(Dispatchders.Default + job)
// 例外をスローする可能性があります
fun doWork(): Deferred<String> = scope.async { ... }
fun loadData() = scope.launch {
try {
doWork().await()
} catch (e: Exception){ ... }
}
ノート:SupervisorJobを使用してCoroutineScopeで非同期を明示的に実行した場合にのみ機能します。
そのため、以下のコードはasyncがparent coroutine(1) の範囲内で起動されるため、アプリケーションをクラッシュさせます。
val job = SupervisorJob()
val scope = CoroutineScope(Dispatchers.Default job)
fun loadData() = scope.launch {
try {
async { // (1)
// 例外をスローする可能性があります
}
} catch (e: Exception) { ... }
}
クラッシュを避けるためのもう1つの方法は、coroutineScope(1)を使ってasyncをラップすることです。
async内部で例外が発生すると、外部スコープに触れることなく、このスコープ内に作成された他のすべてのコルーチンをキャンセルします。 (2)
val job = SupervisorJob()
val scope = CoroutineScope(Dispatchers.Default + job)
// 例外をスローする可能性あり
fun doWork(): Deferred<String> = coroutineScope { // (1)
async { .... }
}
fun loadData() = scope.launch { // (2)
try {
doWork().await()
} catch (e: Exception) { ... }
}
あるいは、非同期ブロック内で例外を処理することもできます。
ルートコルーチンのメインディスパッチャを優先する
バックグラウンドで作業を行い、ルートコルーチン内でUIを更新する必要がある場合は、メイン以外のディスパッチャを使用して起動しないでください。
val scope = CoroutineScope(Dispatchers.Default) // (1)
fun login() = scope.launch {
withContext(Dispatcher.Main) { view.showLoading() } // (2)
networkClient.login(...)
withContext(Dispatcher.Main) { view.hideLoading() } // (2)
}
記の例では、Default dispatcher(1)のスコープを使用してルートコルーチンを起動します。
このアプローチでは、ユーザーインターフェイスに触れる必要があるたびに、コンテキストを切り替える必要があります(2)。
ほとんどの場合、メインディスパッチャを使用してスコープを作成することをお勧めします。これにより、コードが単純になり、コンテキストの切り替えが明確になります。
val scope = CoroutineScope(Dispatchers.Main)
fun login() = scope.launch {
view.showLoading()
withContext(Dispatcher.IO) { networkClient.login(...) }
view.hideLoading()
}
不要なasync / awaitの使用を避ける
async機能を使用していてすぐに待つのであれば、これをやめるべきです
launch {
val data = async(Dispatchers.Default) { /* code */ }.await()
}
コルーチンコンテキストを切り替えてすぐに親コルーチンを中断したい場合はwithContextを使うのが望ましい方法です。
launch {
val data = withContext(Dispatchers.Default) { /* code */ }
}
パフォーマンス面では大きな問題ではありませんが(asyncが新しいコルーチンを作成して作業を行うと考えられていても)、意味的にasyncはバックグラウンドでいくつかのコルーチンを起動して待機することを意味します。
スコープジョブをキャンセルしない
コルーチンをキャンセルする必要がある場合は、最初にスコープジョブをキャンセルしないでください。
class WorkManager {
val job = SupervisorJob()
val scope = CoroutineScope(Dispatchers.Default + job)
fun doWork1() {
scope.launch { /* do work */ }
}
fun doWork2() {
scope.launch { /* do work */ }
}
fun cancelAllWork() {
job.cancel()
}
}
fun main() {
val workManager = WorkManager()
workManager.doWork1()
workManager.doWork2()
workManager.cancelAllWork()
workManager.doWork1() // (1)
}
上記のコードの問題は、jobをキャンセルすると、完了状態になることです。完了したジョブの範囲内で起動されたコルーチンは実行されません(1)。
特定のスコープのすべてのコルーチンをキャンセルしたい場合は、cancelChildren関数を使用できます。
また、個々の仕事をキャンセルする可能性を提供するのは良い習慣です(2)。
class WorkManager {
val job = SupervisorJob()
val scope = CoroutineScope(Dispatchers.Default + job)
fun doWork1(): Job = scope.launch { /* do work */ } // (2)
fun doWork2(): Job = scope.launch { /* do work */ } // (2)
fun cancelAllWork() {
scope.coroutineContext.cancelChildren() // (1)
}
}
fun main() {
val workManager = WorkManager()
workManager.doWork1()
workManager.doWork2()
workManager.cancelAllWork()
workManager.doWork1()
}
暗黙のディスパッチャを使って中断関数を書かないようにする
特定のコルーチンディスパッチャからの実行に依存するサスペンド機能を書かないでください。
suspend fun login(): {
view.showLoading()
val result = withContext(Dispacher.IO) {
someBlocikngCall()
}
view.hideLoading()
return result
}
上記の例では、login関数はサスペンド関数で、メイン以外のディスパッチャを使用するコルーチンから実行するとクラッシュします。
launch(Dispatcher.Main) { // (1) no crash
val loginResult = login()
...
}
launch(Dispatcher.Default) { // (2) cause crash
val loginResult = login()
...
}
CalledFromWrongThreadException:そのビューに触れることができるのは、ビュー階層を作成した元のスレッドだけです。
任意のコルーチンディスパッチャから実行できるように中断関数を設計します。
suspend fun login(): Result = withContext(Dispatcher.Main) {
view.showLoading()
val result = withContext(Dispatcher.IO) {
someBlockingCall()
}
view.hideLoading()
return result
}
これで、どのディスパッチャからもログイン関数を呼び出すことができます。
launch(Dispatcher.Main) { // (1) no crash
val loginResult = login()
...
}
launch(Dispatcher.Default) { // (2) no crash ether
val loginResult = login()
...
}
グローバルスコープの使用を避ける
Androidアプリのいたる所でGlobalScopeを使っているのなら、これをやめるべきです。
GlobalScope.launch {
// code
}
グローバルスコープは、アプリケーションの有効期間全体にわたって動作し、時期尚早にキャンセルされない最上位レベルのコルーチンを起動するために使用されます。
アプリケーションコードは通常、アプリケーション定義のCoroutineScopeを使用する必要があります。GlobalScopeのインスタンスで非同期または起動を使用することは強くお勧めできません。
Androidでは、コルーチンはActivity、Fragment、ViewまたはViewModelライフサイクルに簡単にスコープ設定できます。
class MainActivity : AppCompatActivity(), CoroutineScope {
private val job = SupervisorJob()
override val coroutineContext: CoroutineContext
get() = Dispatchers.Main + job
override fun onDestroy() {
super.onDestroy()
coroutineContext.cancelChildren()
}
fun loadData() = launch {
// code
}
}