「Compose Compiler Reports」 recompose される条件とタイミングと範囲を知りたい

サンプル として、

Screen-level と その中に Button 3つの composable と ViewModel でカウンターを作ります。


@HiltViewModel
class HomeViewModel @Inject constructor() : ViewModel() {

  private val _countState = mutableStateOf(0)
  val countState: State<Int> = _countState

  private val _countStateFlow = MutableStateFlow(0)
  val countStateFlow = _countStateFlow.asStateFlow()

  fun incState() {
    _countState.value += 1
  }

  fun incStateFlow() {
    _countStateFlow.value += 1
  }

}


@Composable
fun HomeScreen(
  viewModel: HomeViewModel = hiltViewModel()
) {

  Timber.d("### HomeScreen composed.")

  var countA by remember { mutableStateOf(0) }
  val countB by viewModel.countState
  val countC by viewModel.countStateFlow.collectAsStateWithLifecycle()

  val onClickA = { countA += 1 }
  val onClickB = { viewModel.incState() }
  val onClickC = { viewModel.incStateFlow() }


  Text("${System.currentTimeMillis()}")

  ButtonA(countA, onClickA)

  Divider()

  ButtonB(countB, onClickB)

  Divider()

  ButtonC(countC, onClickC)

}

@Composable
private fun ButtonA(count: Int, onClick: () -> Unit) {

  Timber.d("### ButtonA(${count}, ${onClick.hashCode()}) composed.")

  Button(onClick = onClick) {
    Text("A: $count")
  }

}

@Composable
private fun ButtonB(count: Int, onClick: () -> Unit) {

  Timber.d("### ButtonB(${count}, ${onClick.hashCode()}) composed.")

  Button(onClick = onClick) {
    Text("B: $count")
  }

}

@Composable
private fun ButtonC(count: Int, onClick: () -> Unit) {

  Timber.d("### ButtonC(${count}, ${onClick.hashCode()}) composed.")

  Button(onClick = onClick) {
    Text("C: $count")
  }

}

ボタンを順番に押していきます。

挙動とログを予想できますか。

 

■ 結果

クリックのたびに、

Screen が recompose されています。

クリックしてないボタンも recompose されていますね!

Recompose Highlighter でもやってみました。



そうなるんですか。

なんでかな。

 

■ 「Compose Compiler Reports」 を使う

ツールを使って書き出してみます。

The Compose Compiler plugin can generate reports / metrics around certain compose-specific concepts that can be useful to understand what is happening with some of your compose code at a fine-grained level.

👉 androidx/compiler-metrics.md at androidx-main · androidx/androidx hatena-bookmark


// build.gradle

kotlinOptions {
  if (project.findProperty("composeCompilerReports") == "true") {
    freeCompilerArgs += [
        "-P",
        "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
            project.buildDir.absolutePath + "/compose_compiler"
    ]
  }
  if (project.findProperty("composeCompilerMetrics") == "true") {
    freeCompilerArgs += [
        "-P",
        "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
            project.buildDir.absolutePath + "/compose_compiler"
    ]
  }
}


./gradlew assembleRelease -PcomposeCompilerReports=true


./gradlew assembleRelease -PcomposeCompilerMetrics=true

👉 Jetpack Compose Stability Explained | by Ben Trengrove | Android Developers | Jul, 2022 | Medium hatena-bookmark

Compose Compiler Reports


// app_release-composables.txt

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun HomeScreen(
  unstable viewModel: HomeViewModel? = @dynamic hiltViewModel(null, $composer, 0, 0b0001)
)

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ButtonA(
  stable count: Int
  stable onClick: Function0<Unit>
)

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ButtonB(
  stable count: Int
  stable onClick: Function0<Unit>
)

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun ButtonC(
  stable count: Int
  stable onClick: Function0<Unit>
)

ViewModel が untable で @dynamic です。


// app_release-classes.txt

unstable class HomeViewModel {
  stable val _countState: MutableState<Int>
  stable val countState: State<Int>
  unstable val _countStateFlow: MutableStateFlow<Int>
  unstable val countStateFlow: StateFlow<Int>
  <runtime stability> = Unstable
}

Flow が unstable なので ViewModel として unstable となっているように見えます。


// app_release-composables.csv

name,composable,skippable,restartable,readonly,inline,isLambda,hasDefaults,defaultsGroup,groups,calls,
HomeScreen,1,1,1,0,0,0,1,0,2,2,
ButtonA,1,1,1,0,0,0,0,0,1,1,
ButtonB,1,1,1,0,0,0,0,0,1,1,
ButtonC,1,1,1,0,0,0,0,0,1,1,

Button たちは、すべて同じ数字。

Screen はそれらと違う。

なんとく、ViewModel が原因のような雰囲気。

しかし、気安く ViewModel に @Stable とかつけたくないし。

 

■ remember を使う

onClick* のラムダが毎回初期化されているというので、


// val onClickB = { viewModel.incState() }
// val onClickC = { viewModel.incStateFlow() }

val onClickB = remember { { viewModel.incState() } }
val onClickC = remember { { viewModel.incStateFlow() } }

とすべて remember 扱いにします。

イメージしていたログ出力になりました。

 

■ 「::」を使う

こんな記述はどう? というので書き換えてみます。


// val onClickB = { viewModel.incState() }
// val onClickC = { viewModel.incStateFlow() }

// val onClickB = remember { { viewModel.incState() } }
// val onClickC = remember { { viewModel.incStateFlow() } }

val onClickB = viewModel::incState
val onClickC = viewModel::incStateFlow

これもイメージしていたログ出力になりました。

少し驚きました。

 

■ State 取得部分はどうなの?

Screen 自体が recompose されてるのなら、こうしたほうがいいのでは?


// val countB by viewModel.countState
// val countC by viewModel.countStateFlow.collectAsStateWithLifecycle()

val countB by remember { viewModel.countState }
val countC by remember { viewModel.countStateFlow }.collectAsStateWithLifecycle()

...

もう、知らん...

(ここは後日検証します。)

 

■ まとめ

そもそも recompose の条件とタイミングをコード目視で把握できますかね。


「考えないほうがいい、きちんと行儀よくコードを書け」的な記事もよく見かけます。

しかし、それで SideEffect系 きちんと使える気がしません。

では、だれか decompose をお願いします。

👉 パフォーマンスに任せてコードの見通しを優先するほうが「recompose沼」にはまらなくていいのではという逆説。 - Qiita hatena-bookmark

👉 takahirom/decomposer: Gradle Plugin that allows you to decompile bytecode compiled with Jetpack Compose Compiler Plugin into Java and check it hatena-bookmark

👉 【Jetpack Compose】「Layout Inspector Recomposition counts」で re-compose 回数を確認する hatena-bookmark


【Jetpack Compose】rememberCoroutineScope() vs LaunchedEffect

Jetpack Compose で非同期処理を行う場合、どっちを使うべきか。

👉 android - Using rememberCoroutineScope() vs LaunchedEffect - Stack Overflow hatena-bookmark

悩ましい問題です。

2度押しボタン対策3秒待ちで考えてみます。

 

rememberCoroutineScope()


val scope = rememberCoroutineScope()
var enabled1 by remember { mutableStateOf(true) }

Button(
  onClick = {
    scope.launch {
      enabled1 = false
      delay(3000)
      enabled1 = true
    }
  },
  enabled = enabled1
) {
  Text("Button1")
}


起動

 ↓

ボタンを表示

 ↓

ボタンをクリック

 ↓

CoroutineScope.launch が実行される

   ↓

  enabled1 が false に更新されて3秒開始

   ↓

  ボタンが更新され無効化される

   ↓

  3秒経ったら enabled1 が true に更新される

 ↓

ボタンが更新され有効化される

@Composable のスコープでは書けない。


Calls to launch should happen inside a LaunchedEffect and not composition
→ @SuppressLint("CoroutineCreationDuringComposition")

実行のタイミングをユーザー操作に直接的に紐付けれる。

 

LaunchedEffect


var enabled2 by remember { mutableStateOf(true) }

Button(
  onClick = {
    enabled2 = false
  },
  enabled = enabled2
) {
  Text("Button2")
}

LaunchedEffect(key1 = enabled2) {
  if (!enabled2) {
    delay(3000)
    enabled2 = true
  }
}


起動

 ↓

ボタンを表示

 ↓

LaunchedEffect が実行される

   ↓

  enable2 が true なので何もしない

 ↓

ボタンをクリック

 ↓

enabled2 が false に更新される

 ↓

ボタンが更新され無効化される

 ↓

LaunchedEffect が key1 の変更により実行される

   ↓

  enabled2 が false なので3秒開始

   ↓

  3秒経ったら enabled2 が true に更新される

   ↓

ボタンが更新され有効化される

 ↓

LaunchedEffect が key1 の変更により実行される

   ↓

  enable2 が true なので何もしない

@Composable スコープで書く。

re/compose か キーの変化 で実行される。

 

まとめ

SideEffect系 は、今後、苦労することになると思う。

ViewModel を使ったほうが直感的でないか?

ライフサイクルはも合ってるし、

どっちみち、実際は、ViewModel → Repository の経路をたどるし。


// Screen-level Composable

val enabled3 by viewModel.enabled

Button(
  onClick = {
    viewModel.click()
  },
  enabled = enabled3
) {
  Text("Button3")
}


// ViewModel

private val _enabled = mutableStateOf(true)
val enabled: State<Boolean> = _enabled

fun click() {
  viewModelScope.launch {
    _enabled.value = false
    delay(3000) // request to repository suspend function
    _enabled.value = true
  }
}

肥大化していますよね、その Composable。

StateHolder としての ViewModel も考慮に入れてみるのもいいかもしれません。

👉 【Jetpack Compose】NavBackStackEntry - Composable のライフサイクルと ViewModel の状態を確認する hatena-bookmark
👉 【Jetpack Compose】 「Layout Inspector Recomposition counts」で re-compose 回数を確認する hatena-bookmark
👉 【Jetpack Compose】よくあるボタンの有効化/無効化 hatena-bookmark


【Jetpack Compose】「Layout Inspector Recomposition counts」で re-compose 回数を確認する

Jetpack Compose を使っていると、

いつ、どの composable が、 compose されているか、

パフォーマンスが重ければ重いほど気になります。

確認してみましょう。

 

Layout Inspector

すごく便利そうな AndroidStudio の機能です。

【 Jetpack Compose】Layout Inspector Recomposition counts

👉 Compose のパフォーマンス  |  Jetpack Compose  |  Android Developers hatena-bookmark


[Tools]

  ↓

[Layout Inspector]

 

利用条件

端末側で設定が必要です。


表示属性検査を有効にする

【 Jetpack Compose】Layout Inspector Recomposition counts

👉 Compose のツール  |  Jetpack Compose  |  Android Developers hatena-bookmark

R8 などは OFFにする。


debug {
  minifyEnabled false // for layout inspector
  proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}

あと、一番ネックになるのがこれ。

This is only available in Electric Eel at the moment:

"For even more cutting edge features, you can take a sneak peek at the Android Studio Electric Eel release in the Canary channel [...] These features will be promoted to more stable channels once we have your feedback and make improvements, so please try them out."

👉 android - Jetpack Compose: The layout inspector is not showing the menu for recomposition counts - Stack Overflow hatena-bookmark


Android Studio Electric Eel release in the Canary channel

今はまだ、非Statble な「Android Studio Electric Eel」でしか使えないようです。

JetBrains の「Toolbox」を使っていれば問題ありません。

JetBrains Toolbox で Android Studio の Stable/Beta/Canary が同時に管理できる?

使ってない人は、すぐインストールしておきましょう。

👉 JetBrains Toolbox で Android Studio の Stable/Beta/Canary が同時に管理できる? hatena-bookmark

 

結果

左下に Layout Inspector が開いたら以下。


[Component Tree]

  ↓

[View Option]

  ↓

[Show Recomposition counts]

compose counts

ヘビーな LazyVerticalGrid でやってみました。

re-compose されると、その部分が赤く変色します。



しかしこれ、

重すぎやせんか、ツールとして。

しかも、よく落ちる。

今後、期待しています。

👉 「Compose Compiler Reports」 recompose される条件とタイミングと範囲を知りたい hatena-bookmark
👉 【Jetpack Compose】LazyVerticalGrid - Profile GPU Rendering / Profile HWUI rendering - YouTube hatena-bookmark