Mokelab Blog

#76 Jetpack ComposeのLaunchedEffectとFlow

本記事に関して、@adakodaさんにいろいろと助けていただきました。ありがとうございました。

LaunchedEffectとは

Jetpack ComposeのLaunchedEffectとは、コンポーザブルの中でsuspend関数を実行するための仕掛けです(公式ドキュメント)。コンポーザブルを表示した直後に何か非同期処理をしたいといった場面で役立ちます。

@Composable
fun MainScreen() {
  val scaffoldState = rememberScaffoldState()

  LaunchedEffect(Unit) {
    // 画面表示のタイミングでSnackbarを出したい
    scaffoldState.snackbarHostState.showSnackbar("Hello")
  }

  Scaffold(scaffoldState = scaffoldState) { }
}

Kotlin Flowとは

Kotlin Flowとは非同期でデータを流すための仕組みです(公式ドキュメント)。非同期でデータが流れてくるので、データの収集(collect)はコルーチンの中で行う必要があります。

Jetpack Composeでは、Flowに対してcollectAsState()を呼ぶことで状態として扱えるようになります。

@Composable
fun MainScreen(viewModel: MainViewModel) {
  // MainViewModel.tokenはFlow<String>
  // Flowはデータが流れてくるまで時間かかることがあるので、初期状態が必要
  val token by viewModel.token.collectAsState("")

  if (token.isEmpty() || token == "loading") {
    // ログインしてなさそうなので何も表示しない
    return 
  }

  Text("Welcome!")
}

ログインしていなかったらログイン画面を出したい

LaunchedEffectとFlowを組み合わせて、「ログインしていなかったらログイン画面に遷移させたい」という処理を書いてみます。

// next()はログイン画面に遷移する関数とします
@Composable
fun MainScreen(viewModel: MainViewModel, next: () -> Unit) {
  // 最初の値がやってくるまではLoadingという特別な値にしておこう
  val token by viewModel.token.collectAsState("loading")

  // 新しいトークン情報が来たら調べるか
  LaunchedEffect(key1 = token) {
    // 未ログインっぽいのでログイン画面を表示だ!
    if (token.isEmpty()) {
      next()
      return @LaunchedEffect
    }
  }

  // ログイン前だったり、読み込み中は何も出したくない
  if (token.isEmpty() || token == "loading") {
    return 
  }

  // ログインできたので何か表示しとこっと
  Text("Welcome!")
}

viewModel.tokenはログイン状態が変化したらトークン情報が更新されてやってくるものとします。

ログイン画面も同じように作ってみます。トークン情報がログイン後になっていたら、1つ前の画面に戻ってもらいます。

// back()は1つ前の画面に戻る関数とします。
@Composable
fun LoginScreen(viewModel: LoginViewModel, back: () -> Unit) {
  val token by viewModel.token.collectAsState("")

  LaunchedEffect(key1 = token) {
    // ログインできてんじゃん!戻ろう
    if (token.isNotEmpty()) {
      back()
      return @LaunchedEffect
    }
  }

  // login()の中でログイン処理やって、
  // トークンに空文字以外の何か値が入るものとします。
  Button(onClick = { viewModel.login() }) {
    Text("Login")
  }
}

ぱっと見、問題なさそうなコードですが、実際に動かしてみるとログイン後に「Welcome」が表示されずに真っ白になることがあります。

ここで@adakodaさんに助けていただきながら、原因の究明と解決まで蛇行を繰り返します。サポートありがとうございました。

ログを仕込む

まずはログを仕込んでみましょうとアドバイスをいただいたのでいろんな処理の前にログを追加してみました。ログイン画面が表示されるまではこんな感じ。

D/Debug: MainScreen call LaunchedEffect token=loading
D/Debug: MainScreen call if token=loading
D/Debug: MainScreen LaunchedEffect token=
D/Debug: MainScreen next()
D/Debug: MainScreen call LaunchedEffect token=
D/Debug: MainScreen call if token=
D/Debug: LoginScreen call LaunchedEffect token=
D/Debug: MainScreen LaunchedEffect token=
D/Debug: MainScreen next()
D/Debug: LoginScreen LaunchedEffect token=
D/Debug: MainScreen call LaunchedEffect token=
D/Debug: MainScreen call if token=
D/Debug: LoginScreen call LaunchedEffect token=
D/Debug: LoginScreen call LaunchedEffect token=
D/Debug: LoginScreen LaunchedEffect token=
D/Debug: LoginScreen call LaunchedEffect token=

このログを見て、「なんか思ってるのと違うな?」と感じ取れるでしょうか?

next()が2回呼ばれている

next()はMainScreenからLoginScreenに遷移する関数としています。navController.navigate()を呼ぶだけにすることが多いでしょう。

そしてログを見ると、なぜか2回呼ばれています。直感的なイメージだと、次のように処理が実行されそうです。

  1. token=loadingでLaunchedEffect()が呼ばれる
  2. 最初はキーの値がないのでLaunchedEffect()の中が実行される
  3. token=loadingなはずなので、何も起きない
  4. ディスク等からトークンの読み込みが終わり、token=""でLaunchedEffect()が呼ばれる
  5. キーが変化したので、中がもう一度実行される
  6. 空文字なのでnext()を呼ぶ

しかしログでは次のように動いているように見えます。

  1. token=loadingでLaunchedEffect()が呼ばれる
  2. token=""でLaunchedEffect()の中が実行される
  3. 空文字なのでnext()が呼ばれる
  4. 状態が変化しているので、再今ポーズ
  5. キーが変化したので、LaunchedEffect()の中が実行される(1つ前のLaunchedEffectの中身は実行が完了してる)
  6. 空文字なのでnext()を呼ぶ

非同期処理のよくある罠にかかっていました。LaunchedEffect内のif (token.isEmpty())ですが、このtokenは「状態」へのアクセスです。なので、LaunchedEffectのキーとして調べた時の値と別の値が入っていても文句は言えません。実際、ログを見ると最初のLaunchedEffectの中が実行されている時点で状態が空文字に変化しています。

LaunchedEffectのドキュメントを読む

ここで、LaunchedEffectのドキュメントを読んでみます。

This function should not be used to (re-)launch ongoing tasks
in response to callback events by way of storing callback data in
MutableState passed to key1.
Instead, see rememberCoroutineScope to obtain a CoroutineScope
that may be used to launch ongoing jobs scoped to the composition
in response to event callbacks.

「MutableStateをキーにいれて、コールバックのレスポンスとしてタスクを起動するような使い方はだめだよー」と書かれています。まさに今回のような使い方はだめということになります。

アイデア1を試す

状態の変化を使ってLaunchedEffectを使うのはまずそう(実際だめなケースがある)ので、Flowを直接collectする案を試してみます。

@Composable
fun MainScreen(viewModel: MainViewModel, next: () -> Unit) {
  val token by viewModel.token.collectAsState("loading")

  LaunchedEffect(Unit) {
    // 直接collectして調べれば大丈夫なはず。。。
    viewModel.token.collect { token ->
      if (token.isEmpty()) {
        next()
      }
    }
  }

  if (token.isEmpty() || token == "loading") {
    return 
  }

  Text("Welcome!")
}

一応想定通りの動作をしますが、@adakodaさんから「Composableの中でcollectするのは違和感がある」とコメントをいただきました。確かにこの方法だとcollectする期間はCompositionの生存期間となってしまいます。「画面」が終了するまでデータは読み続けてよいはずなので、ViewModelのinitでcollectするほうが正しそうです。

そして最終的に

変な動作の原因は「LaunchedEffectのキーにMutableStateを使っている」「中でその状態にアクセスしている」の2点でした。ということで状態ホイスティングでトークンの状態を1階層上に移動させてみます。

@Composable
fun MainScreen(viewModel: MainViewModel, next: () -> Unit) {
  // 状態はここでもたせる
  val token by viewModel.token.collectAsState("loading")
  MainScreenInternal(token, next)
}

@Composable
internal fun MainScreenInternal(token: String, next: () -> Unit) {
  // トークンの値が変化したときだけ実行
  LaunchedEffect(key1 = token) {
    // 状態ではないので、キーの値になる
    if (token.isEmpty()) {
      next()
      return @LaunchedEffect
    }
  }

  if (token.isEmpty() || token == "loading") {
    return 
  }

  Text("Welcome!")
}

「キーの値が変化したら、非同期で何か処理する」というLaunchedEffectの役割が戻ってきた気がします。

まとめ

LaunchedEffectと状態の取り扱いについて試行錯誤しました。LaunchedEffectの中でキーにした状態を扱いたいケースは割とありそうな気がするので、同様の問題でハマってる人の助けになればなと思います。。。

本サイトではサービス向上のため、Google Analyticsを導入しています。分析にはCookieを利用しています。