Open9

【Kotlin】Coroutineを理解したい!

下澤健太下澤健太

非同期処理を行いたいときに、async/awaitのように書けないかなと思い調べたらKotlinはcoroutineが有効みたい。

下澤健太下澤健太

viewModelScope

KTX拡張機能としてlifecycle-viewmodel-ktxが用意されているので、coroutineを扱うのはviewModelが良い。

スレッドをブロックしないためにも、実行を移動させ、新しいコルーチンを作成し、IOスレッドにて実行する

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {
        // Create a new coroutine to move the execution off the UI thread
        viewModelScope.launch(Dispatchers.IO) {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"
            loginRepository.makeLoginRequest(jsonBody)
        }
    }
}

viewModelScope: lifecycle-viewmodel-ktxで既に用意されているCoroutineScope。このスコープ内で全てのコルーチンを実行する
Dispatchers.IO: コルーチンがI/O処理用に予約されたスレッドで実行することを示す

下澤健太下澤健太

Dispatchersとは

コルーチンの実行に使用するスレッドを決定している

suspend fun fetchDocs() {                             // Dispatchers.Main
    val result = get("https://developer.android.com") // Dispatchers.IO for `get`
    show(result)                                      // Dispatchers.Main
}

suspend fun get(url: String) = withContext(Dispatchers.IO) { /* ... */ }

3つのdispatcherがある。

Dispatchers.Main - このディスパッチャを使用すると、コルーチンはメインの Android スレッドで実行されます。UI を操作して処理を手早く作業する場合にのみ使用します。たとえば、suspend 関数の呼び出し、Android UI フレームワーク オペレーションの実行、LiveData オブジェクトのアップデートを行う場合などです。

Dispatchers.IO - このディスパッチャは、メインスレッドの外部でディスクまたはネットワークの I/O を実行する場合に適しています。たとえば、Room コンポーネントの使用、ファイルの読み書き、ネットワーク オペレーションの実行などです。

Dispatchers.Default - このディスパッチャは、メインスレッドの外部で CPU 負荷の高い作業を実行する場合に適しています。ユースケースの例としては、リストの並べ替えや JSON の解析などがあります。

https://developer.android.com/kotlin/coroutines/coroutines-adv?hl=ja

下澤健太下澤健太

cancel scope

Androidではユーザーが違うActivityFragmentへ移動した時などに進行中の全てのcoroutineはキャンセルされる

On Android, you can use a scope to cancel all running coroutines when, for example, the user navigates away from an Activity or Fragment. Scopes also allow you to specify a default dispatcher. A dispatcher controls which thread runs a coroutine.

下澤健太下澤健太

Coroutine Test

class MainViewModelTest {
   @get:Rule
   val coroutineScope =  MainCoroutineScopeRule()
   @get:Rule
   val instantTaskExecutorRule = InstantTaskExecutorRule()

   lateinit var subject: MainViewModel

   @Before
   fun setup() {
       subject = MainViewModel(
           TitleRepository(
                   MainNetworkFake("OK"),
                   TitleDaoFake("initial")
           ))
   }
}

InstantTaskExecutorRuleは同期的にそれぞれのタスクをLiveDataに構成するJ Unitルール
MainCoroutineScopeRuleはコードベースのカスタムルール。テスト用の仮想を作成でき、単体テストでDispatchers.Mainを利用できるようになる

下澤健太下澤健太

Coroutine Test Example

@Test
fun whenMainClicked_updatesTaps() {
    subject.onMainViewClicked()
    Truth.assertThat(subject.taps.getValueForTest()).isEqualTo("0 taps")
    coroutineScope.advanceTimeBy(1_000)
    Truth.assertThat(subject.taps.getValueForTest()).isEqualTo("1 taps")
}

onMainViewClicked()でコルーチンを開始、最初は0 tapsと表示する。
1秒後に1 tapsと表示されていればOKというテストの例

Coroutineのテストは仮想の時間で進んでいる

下澤健太下澤健太

Coroutine /w Room, Retrofit

ともにsuspendを付与するだけでmain-safeな関数になる。
なので、Dispatchers.Mainで呼んでも大丈夫。
※networkからデータをfetchしたり、データを書き込む処理があってもDispatchers.IOは使ってはいけない!!!

Both Room and Retrofit use a custom dispatcher and do not use Dispatchers.IO.
Room will run coroutines using the default query and transaction Executor that's configured.
Retrofit will create a new Call object under the hood, and call enqueue on it to send the request asynchronously.

Room

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTitle(title: Title)

Retrofit

もしRetrofitのResultへフルにアクセスしたいなら、返り値の型をStringからResult<String>に変えると良い。

interface MainNetwork {
   @GET("next_title.json")
   suspend fun fetchNextTitle(): String
}
下澤健太下澤健太

suspend関数にしたことでRoomとRetrofitの処理をこのように変えることができる。
(main-safeな関数はwithContextは必要ない)
修正前:

suspend fun refreshTitle() {
    // interact with *blocking* networking and IO calls from a coroutine
    withContext(Dispatchers.IO) {
        val result = try {
            // Make network request using a blocking call
            network.fetchNextTitle().execute()
        } catch (cause: Throwable) {
            throw TitleRefreshError("Unable to refresh title", cause)
        }

        if (result.isSuccessful) {
            titleDao.insertTitle(Title(result.body()!!))
        } else {
            throw TitleRefreshError("Unable to refresh title", null)
        }
    }
}

修正後:

suspend fun refreshTitle() {
    // interact with *blocking* networking and IO calls from a coroutine
    try {
        // Make network request using a blocking call
        val result = network.fetchNextTitle()
        titleDao.insertTitle(Title(result))
    } catch (cause: Throwable) {
        throw TitleRefreshError("Unable to refresh title", cause)
    }
}

すげ〜