Android
Kotlin
coroutine

Androidプロジェクトでのkotlin+coroutineの使い方


デバッグ

何もしないとcoroutineの中のコードにブレークポイント貼っても変数の値が取得できません。

ref: https://github.com/Kotlin/kotlinx.coroutines/blob/master/docs/coroutine-context-and-dispatchers.md#debugging-coroutines-and-threads

Android Studio で Run > Edit Configurations... > Kotlin > Configuration

VM optionsに Dkotlinx.coroutines.debug を追加するとデバッグ時に変数の値が見れるようになります。


CoroutineScope

ref: https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-scope/

以下のコードを見てみましょう。

class MyActivity : AppCompatActivity() {

var hoge = 1

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GlobalScope.launch(Dispatchers.Main) {
delay(10 * 1000)
hoge += 1
}
}
....
}

このactivityからすぐに別のactivityに移動するとmemory leakが発生します。GlobalScopeでのcoroutineはactivityのライフサイクルとは関係なく動くため、activityが破棄されたあともhogeに対する参照を持ち続けるからです。

activityやfragment下のcoroutineはGlobalScopeではなく、親のscopeを持ち、それを管理するようにしたいです。

またactivityやfragmentが破棄される際には、coroutineの処理が大抵の場合不要になるはずで、onDestroy()される際にはcancel()されるようにもしたいです。

各activity, fragmentでその処理を書くのは面倒なので、以下のように実装します。

abstract class ScopedActivity : AppCompatActivity(), CoroutineScope {

protected lateinit var job: Job
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
job = Job()
}

override fun onDestroy() {
super.onDestroy()
(job + Dispatchers.Default).cancel()
}
}

class MyActivity : ScopedActivity() {
val hoge = 1

override fun onDestroy() {
launch {
delay(10 * 1000)
hoge += 1
}
launch(Dispatchers.Default) {
delay(10 * 1000)
hoge += 1
}
super.onDestroy()
}
}

これでonDestroy内のcoroutineはcancelされるようになりました。少しこのコードの解説をします。

まず、Dispatchers.Mainはメインスレッドで動くことを示します。AndroidでのメインスレッドはUIスレッドなので、Viewに対してテキストを変更したいなどのUI操作をしたい場合にはこちらを用います。

Dispatchers.Defaultはバックグラウンドのスレッドで動くことを示します。APIから値を取得したい場合などUIスレッドで実行すると怒られるものはこちらに寄せていきます。

以下の部分で、このabstract classを継承したclassはデフォルトのcoroutineのscopeがMainスレッドで動くことを示します。

    override val coroutineContext: CoroutineContext

get() = job + Dispatchers.Main

なのでMyActivityでは、launch(Dispatchers.Main) {} と書く必要はなく launch {} だけで、Mainスレッドで動くcoroutineになります。Dispatchers.Defaultで動かしたい場合は、launch(Dispatchers.Default) {}と明示する必要があります。

onDestroyでcancelは以下のようにしています。

        (job + Dispatchers.Default).cancel()

job.cancel()だけではMainスレッドのcoroutineのみcancelされますが、バックグラウンドのcoroutineも存在するケースを考慮し、キャンセルされるようにしておきます。以下に例を示します。


View側でのバックグラウンドcoroutineの処理

例えば1秒ごとに1ずつカウントアップしていく画面を出したい場合などです。

delay(1000)してViewを変更したいですが、UIスレッドでdelay()をしたくありません。

以下のように書いてみます。

    override fun onCreate(savedInstanceState: Bundle?) {

super.onCreate(savedInstanceState)
launch(Dispatchers.Default) {
var counter = 0
while (true) {
launch(Dispatchers.Main) {
binding.tvCounter.text = counter
}
counter += 1
delay(1000)
}
}
}

launch(Dispatchers.Default) {}の中でTextViewのテキストを書き換えるとバックグラウンドからUIを操作するなと怒られるので、launch(Dispatchers.Default) {}を子供として作り、その中でUI操作します。これで上手く動きますがこれにも落とし穴があります。


子供はキャンセルされない

上の処理だと、launch(Dispatchers.Default) {}onDestroy()時にキャンセルされますが、その子供のlaunch(Dispatchers.Main) {}はcancelされていません。textviewを参照しているので、activityが破棄されるとこれもやはりmemory leakが発生します。

以下のようにすると良いかもしれません。

        (job + Dispatchers.Default).children?.forEach { it.cancel() }

(job + Dispatchers.Default).cancel()

孫が居たら? ひ孫がいたら? を考え出すとキリがありません。その場合は各activityでcounterJob = launch { ...孫まで作る処理... }をしておいて、onDestory()時にcounterJob?.children?.forEach { it.cancel() }などとするか、孫まで作らないようにするなどの判断が必要です。


ViewModel

AcitivityやFragmentでバックグラウンドスレッドで動かしたい処理を書く場合、launch(Dispatchers.Default) {}と常に書く必要があるため、少し手間です。このような処理は出来る限りViewModel側に寄せるようにしたいです。

ViewModel側もActivityと同じように以下のようにします。

abstract class ScopedViewModel : ViewModel(), CoroutineScope {

private var job = Job()
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Default

override fun onCleared() {
super.onCleared()
job.cancel()
}
}

class MyViewModel() : ScopedViewModel {
var fuga = 1
init {
launch {
fuga += 1
}
}
}

これでViewModel側もlaunch {}と記述を省略でき、破棄される時にcancelされmemory leakが発生しなくなります。

こちらはUIスレッドで操作するケースは少ないと判断し、素直にjob.cancel()だけです。必要であれば各ViewModelで処理する必要があります。