[Android] 017. Kotlin Coroutine

Developerの説明…ここだけだと理解しにくいのではじめから見ていこうと思います
https://developer.android.com/kotlin/coroutines?hl=ja

Coroutine(コルーチン)はある処理を中断/再開できるインスタンス…といった方が理解しやすいと思いました
CoroutineはThreadより軽量

事前準備

実際どのスレッドで動作しているのか確認するために、ログ出力に以下を使用します

inline fun Log(message: String) = android.util.Log.d(
    "TestCoroutine",
    "${Thread.currentThread().name ?: ""}[${Integer.toHexString(Thread.currentThread().id.toInt()).uppercase().padStart(4, '0')}] $message")

Coroutine Scope

CoroutineはいずれかのCoroutine Scopeに属してます
Coroutine ScopeはCoroutineが所属する仮想的な領域です

用意されているCoroutine Scope

CoroutineScope基底
GlobalScopeアプリ全体
delegate apiなのでメモリリークなどのリスクがあるので使い所を考える必要がある
MainScopeDispatchers.Main / SupervisorJob

レイヤーに属しているライフサイクル対応のCoroutine Scope

lifecycleScopeActivity / Fragmentで使用可能
onDestroy()で自動キャンセル
viewModelScopeViewModelで使用可能
ViewModel消滅時に自動キャンセル

以下のように使用する
CoroutineContextとlaunch / asyncなどのCoroutine Builderは後ほど

val scope = CoroutineScope(EmptyCoroutineContext)
val job = scope.launch {
    Log("launch")
}
val job2 = scope.async {
    Log("async")
}

Scope Builder

runBlocking起動された全てのCoroutinesが完了するまで自身を実行したスレッドをブロック
coroutineScopesuspend functionで使用可能
supervisorScopesuspend functionで使用可能 / SupervisorJob
// 戻値がない場合は<Unit>は省略可能
fun test() = runBlocking<Unit> {
    Log("runBlocking")
}
suspend fun test() = coroutineScope<Unit> {
    Log("coroutineScope")
}

Coroutine Builder

Coroutineの実装で使用

launch
async / await戻値がある場合
suspend fun test() = coroutineScope {
    launch { Log("launch") }.join()
    async { Log("async/await") }.await()
}

CoroutineContext

CoroutineContextには以下のElementsを渡せます

JobJob()
SupervisorJob()
CoroutineDispatcherDispatchers.Default
Dispatchers.Main
Dispatchers.IO
Dispatchers.Unconfined
CoroutineNameCoroutineName(“…”)
CoroutineExceptionHandlerrunBlockingでは使用できません

複数指定する場合は以下のように+でつなげます

CoroutineScope(Job() + Dispatchers.Default).launch {...}

Job

キャンセル可能で階層を持つことができます
子のJobにキャンセルが発生した場合に親のJobに通知されないようにするにはSupervisorJobを使用します

CoroutineDispatcher

Dispatchers.Defaultバックグラウンドスレッドの共有プールを使用でスレッド最大数はCPUコアの数
Dispatchers.Mainメインスレッド
Dispatchers.IOファイルのI/O、RoomなどのDB処理に使用
Dispatchers.Unconfined呼び出されたスレッド上で動作
通常使用しない

CoroutineName

Kotlinデバックが有効時にスレッド名に指定したNameを表示します
CoroutineName()で指定します

Kotlinデバックを有効にするには以下を呼び出します

System.setProperty(DEBUG_PROPERTY_NAME, DEBUG_PROPERTY_VALUE_ON)

// D/TestCoroutine: DefaultDispatcher-worker-1 @test#1[0038] CoroutineScope
CoroutineScope(CoroutineName("test")).launch { Log("CoroutineScope") }

CoroutineExceptionHandler

CoroutineScopeなどを使用時にExceptionをハンドリングするために指定します

val handler = CoroutineExceptionHandler {context, exception ->
    Log("$exception")
}
CoroutineScope(handler).launch { throw Exception("test!!!") }

実装

キャンセル/再開

Startボタンを押してループを開始してCancelボタンで停止させる実装です
scope.cancel()を実行した場合scopeを作り直さないと再開できないためjobをcancelしてます

withContextはCoroutineContextを切り替えるために使用します

delayはキャンセルを受け付けるのでyieldは不要ですが説明のためyieldも呼び出しています

cancelが受け付けられるとJobCancellationExceptionが発生します

この実装の場合画面が回転などActivityが再作成された場合はcancelされます
jobをsaveableにすればよさそうですがscopeが再作成されるためExceptionとなります

@Composable fun AppContent() {
    MaterialTheme {
        Surface {
            val scope = rememberCoroutineScope()
            var job: Job? by remember { mutableStateOf(null) }

            Row {
                Button(onClick = {
                    if (job == null || job!!.isCancelled || job!!.isCompleted) {
                        job = scope.launch {
                            withContext(Dispatchers.Default) {
                                try {
                                    repeat(100) {
                                        Log(it.toString())
                                        delay(100)
                                        // Coroutineに実行を譲渡
                                        yield()
                                    }
                                } catch (e: Exception) {
                                    Log(e.toString())
                                }
                            }
                        }
                    }
                }) {
                    Text("Start")
                }

                Button(onClick = { job?.cancel() }) {
                    Text("Cancel")
                }
            }
        }
    }
}

Jobの状態は以下のように遷移します

StateisActiveisCompletedisCancelled
New (optional initial state)falsefalsefalse
Active (default initial state)truefalsefalse
Completing (transient state)truefalsefalse
Cancelling (transient state)falsefalsetrue
Cancelled (final state)falsetruetrue
Completed (final state)falsetruefalse

runBlocking

runBlockingあれこれ
はじめrunBlockingの動作から追っていこうとしたけど逆に理解しにくいと思ったのでやめた名残…

runBlockingをそのまま使用した場合実行したスレッドで実行されます

class MainActivity : ComponentActivity() {
    // D/TestCoroutine: main[0002] runBlocking
    runBlocking { Log("runBlocking") }
    thread {
        // D/TestCoroutine: Thread-2[0038] runBlocking
        runBlocking { Log("runBlocking")  }
    }
}

これは引数のCoroutineContextEmptyCoroutineContextで動作しているためです

// D/TestCoroutine: main[0002] runBlocking
runBlocking(EmptyCoroutineContext) { Log("runBlocking") }

runBlockingは起動された全てのCoroutinesが完了するまで自身を実行したスレッドをブロックします

そのため以下のようにDispatchers.Mainを指定した場合スレッドがロックされて次の処理まで進まなくなります

class MainActivity : ComponentActivity() {
    // lockされる
    runBlocking(Dispatchers.Main) { Log("Main") }
}

違うスレッドのDispatcherを使います

// D/TestCoroutine: DefaultDispatcher-worker-1[0039] Default
runBlocking(Dispatchers.Default) { Log("Default")  }

// D/TestCoroutine: DefaultDispatcher-worker-1[0039] IO
runBlocking(Dispatchers.IO) { Log("IO") }

// D/TestCoroutine: main[0002] Unconfined
runBlocking(Dispatchers.Unconfined) { Log("Unconfined") }

Android Studio Dolphin 2021.3.1 Patch 1 built on September 30, 2022