2021年9月5日日曜日

Jeptack Compose : DrawScope に描画したものを Bitmap にする。Bitmap を DrawScope に描画する。

ImageBitmap から Bitmap への変換には ImageBitmap.asAndroidBitmap() を使います。
Bitmap から ImageBitmap への変換には Bitmap.asImageBitmap() を使います。

@Preview @Composable fun SampleScreen() { val density = LocalDensity.current val layoutDirection = LocalLayoutDirection.current val bitmap = remember { mutableStateOf<Bitmap?>(null) } Column { Button( onClick = { bitmap.value = createBitmap(density, layoutDirection) }, modifier = Modifier.padding(16.dp) ) { Text(text = "click") } AndroidView( factory = { context -> ImageView(context) }, update = { imageView -> imageView.setImageBitmap(bitmap.value) }, modifier = Modifier.padding(16.dp) ) androidx.compose.foundation.Canvas( modifier = Modifier.padding(16.dp) .size(with(LocalDensity.current) { 512.toDp() }) ) { val bmp = bitmap.value if (bmp != null) { drawImage(bmp.asImageBitmap()) drawCircle(Color.White, radius = size.minDimension / 4f) } } } } private fun createBitmap( density: Density, layoutDirection: LayoutDirection ): Bitmap { val targetSize = 512 val imageBitmap = ImageBitmap(targetSize, targetSize) val size = Size(targetSize.toFloat(), targetSize.toFloat()) CanvasDrawScope().draw(density, layoutDirection, Canvas(imageBitmap), size) { drawCircle(Color.Red) } return imageBitmap.asAndroidBitmap() }

2021年8月29日日曜日

navigation-compose は ViewModel のライフサイクルをどう管理しているのか

twitter で言及したこれの解説です。

使用ライブラリ

以下の解説は

compose_version : 1.0.1
androidx.lifecycle:lifecycle-viewmodel-compose:1.0.0-alpha07
androidx.navigation:navigation-compose:2.4.0-alpha07
のコードをもとにしています。

内部の実装は今後変わる可能性があります。

共通コード

navigation-compose あり/なしの比較をしたいので、共通する部分のコードを先に出しておきます。 class Screen1ViewModel : ViewModel() { val values: List<Int> = (0 until 20).map { Random.nextInt() } init { println("Screen1ViewModel : created : $this") } override fun onCleared() { println("Screen1ViewModel : cleared : $this") } } class Screen2ViewModel : ViewModel() { val value = Random.nextFloat() init { println("Screen2ViewModel : created : $this") } override fun onCleared() { println("Screen2ViewModel : cleared : $this") } } @Composable fun Screen1( viewModel: Screen1ViewModel, onClickItem: (Int) -> Unit, ) { DisposableEffect(Unit) { println("Screen1 : composed : viewModel = $viewModel") onDispose { println("Screen1 : disposed") } } LazyColumn(modifier = Modifier.fillMaxSize()) { items(viewModel.values) { value -> Text( text = value.toString(), modifier = Modifier .fillMaxWidth() .clickable { onClickItem(value) } .padding(16.dp) ) } } } @Composable fun Screen2( viewModel: Screen2ViewModel, value1: Int ) { DisposableEffect(Unit) { println("Screen2 : composed : viewModel = $viewModel") onDispose { println("Screen2 : disposed") } } Text( text = "value1 = $value1, value2 = ${viewModel.value}", modifier = Modifier .fillMaxWidth() .padding(16.dp) ) } class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MaterialTheme { Surface(color = MaterialTheme.colors.background) { MyApp() } } } } }

navigation-compose を使わないときの ViewModel のライフサイクル

まず navigation-compose を使わないときの動作を確認してみましょう。 @Composable fun MyApp() { val screenState = remember { mutableStateOf<Screen>(Screen.Screen1) } when (val screen = screenState.value) { Screen.Screen1 -> { Screen1(viewModel()) { screenState.value = Screen.Screen2(it) } } is Screen.Screen2 -> { BackHandler { screenState.value = Screen.Screen1 } Screen2(viewModel(), screen.value) } } } Screen1ViewModel : created : com.sample.myapplication.Screen1ViewModel@7826ac6 Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@7826ac6 -- Screen1 の LazyColumn のアイテムをタップ Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@4d64aba Screen1 : disposed Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@4d64aba -- back キータップ Screen2 : disposed Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@7826ac6 -- Screen1 の LazyColumn のアイテムをタップ Screen1 : disposed Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@4d64aba -- back キータップ Screen2 : disposed Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@7826ac6 -- back キータップ(アプリ終了) Screen1 : disposed Screen1ViewModel : cleared : com.sample.myapplication.Screen1ViewModel@7826ac6 Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@4d64aba Screen1ViewModel、Screen2ViewModel いずれも1回しか生成されておらず、画面上の表示は Screen1 → Screen2 → Screen1 → Screen2 → Screen1 と遷移していますが、viewModel() では 同じ ViewModel インスタンスが返ってきていることがわかります。

viewModel() は androidx.lifecycle:lifecycle-viewmodel-compose に定義されている拡張関数で、次のようになっています。 @Composable public inline fun <reified VM : ViewModel> viewModel( viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" }, key: String? = null, factory: ViewModelProvider.Factory? = null ): VM = viewModel(VM::class.java, viewModelStoreOwner, key, factory) @Composable public fun <VM : ViewModel> viewModel( modelClass: Class<VM>, viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" }, key: String? = null, factory: ViewModelProvider.Factory? = null ): VM = viewModelStoreOwner.get(modelClass, key, factory) private fun <VM : ViewModel> ViewModelStoreOwner.get( javaClass: Class<VM>, key: String? = null, factory: ViewModelProvider.Factory? = null ): VM { val provider = if (factory != null) { ViewModelProvider(this, factory) } else { ViewModelProvider(this) } return if (key != null) { provider.get(key, javaClass) } else { provider.get(javaClass) } } LocalViewModelStoreOwner.current から ViewModelStoreOwner を取得し、ViewModelProvider() にそれを渡して ViewModel のインスタンスを取得しています。

ViewModelProvider() に ViewModelStoreOwner を渡して ViewModel のインスタンスを取得するのは、今まで Activity や Fragment でやっていたのと同じです。 Activity や Fragment は ViewModelStoreOwner を実装しています。

では Screen1 や Screen2 で viewModel() を呼ぶとき LocalViewModelStoreOwner には何がセットされているのでしょうか。 Screen.Screen1 -> { println("Screen1 : ViewModelStoreOwner = ${LocalViewModelStoreOwner.current}") Screen1(viewModel()) { screenState.value = Screen.Screen2(it) } } Screen1 : ViewModelStoreOwner = com.sample.myapplication.MainActivity@7826ac6 ... Screen2 : ViewModelStoreOwner = com.sample.myapplication.MainActivity@7826ac6 LocalViewModelStoreOwner には MainActivity が入っていることがわかりました。

つまり Screen1ViewModel、Screen2ViewModel いずれも MainActivity に紐づいており、表示上は Screen1, Screen2 と遷移していますが、常に同じ MainActivity にいるので viewModel() で同じインスタンスが返ってきていたということだったのです。

navigation-compose での ViewModel のライフサイクル

navigation-compose を使った場合どうなるのか確認してみましょう。 @Composable fun MyApp() { val navController = rememberNavController() NavHost(navController = navController, startDestination = "Screen1") { composable( route = "Screen1" ) { println("Screen1 : ViewModelStoreOwner = ${LocalViewModelStoreOwner.current}") Screen1(viewModel()) { navController.navigate("Screen2/$it") } } composable( route = "Screen2/{value}", arguments = listOf(navArgument("value") { type = NavType.IntType }) ) { val value1 = requireNotNull(it.arguments).getInt("value") println("Screen2 : ViewModelStoreOwner = ${LocalViewModelStoreOwner.current}") Screen2(viewModel(), value1) } } } Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen1ViewModel : created : com.sample.myapplication.Screen1ViewModel@f79f3f3 Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3 Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb -- Screen1 の LazyColumn のアイテムをタップ Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@7cf0642 Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@7cf0642 Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a Screen1 : disposed Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a -- back キータップ Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3 Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@7cf0642 Screen2 : disposed Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb -- Screen1 の LazyColumn のアイテムをタップ Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1 Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@96e0d3b Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@96e0d3b Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1 Screen1 : disposed Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1 -- back キータップ Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1 Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3 Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1 Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@96e0d3b Screen2 : disposed Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb -- back キータップ(アプリ終了) Screen1 : disposed Screen1ViewModel : cleared : com.sample.myapplication.Screen1ViewModel@f79f3f3 LocalViewModelStoreOwner には MainActivity ではなく NavBackStackEntry が入っていることがわかりました。
ViewModelStoreOwner の部分を省くと Screen1ViewModel : created : com.sample.myapplication.Screen1ViewModel@f79f3f3 Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3 -- Screen1 の LazyColumn のアイテムをタップ Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@7cf0642 Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@7cf0642 Screen1 : disposed -- back キータップ = popBackstack Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3 Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@7cf0642 Screen2 : disposed -- Screen1 の LazyColumn のアイテムをタップ Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@96e0d3b Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@96e0d3b Screen1 : disposed -- back キータップ = popBackstack Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3 Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@96e0d3b Screen2 : disposed -- back キータップ(アプリ終了) Screen1 : disposed Screen1ViewModel : cleared : com.sample.myapplication.Screen1ViewModel@f79f3f3 Screen1 → Screen2 → Screen1 のとき(Screen2 が backstack からいなくなった)に Screen2ViewModel が破棄(onCleared)され、再度 Screen2 に遷移したときに別の Screen2ViewModel インスタンスが作られていることがわかります。

LocalViewModelStoreOwner へのセット

navigation-compose では NavHost composable で LocalViewModelStoreOwner に NavBackStackEntry をセットしています。 @Composable public fun NavBackStackEntry.LocalOwnersProvider( saveableStateHolder: SaveableStateHolder, content: @Composable () -> Unit ) { CompositionLocalProvider( LocalViewModelStoreOwner provides this, LocalLifecycleOwner provides this, LocalSavedStateRegistryOwner provides this ) { saveableStateHolder.SaveableStateProvider(content) } } @Composable public fun NavHost( navController: NavHostController, graph: NavGraph, modifier: Modifier = Modifier ) { ... if (backStackEntry != null) { ... Crossfade(backStackEntry.id, modifier) { val lastEntry = transitionsInProgress.lastOrNull { entry -> it == entry.id } ?: backStack.lastOrNull { entry -> it == entry.id } lastEntry?.LocalOwnersProvider(saveableStateHolder) { (lastEntry.destination as ComposeNavigator.Destination).content(lastEntry) } ... } } ... } backStackEntry には backstack の一番最後のものが入ります。

NavBackStackEntry の ViewModelStoreOwner 実装

では NavBackStackEntry が ViewModelStoreOwner をどのように実装しているのか見てみましょう。 public class NavBackStackEntry private constructor( ... private val viewModelStoreProvider: NavViewModelStoreProvider? = null, ... ) : LifecycleOwner, ViewModelStoreOwner, HasDefaultViewModelProviderFactory, SavedStateRegistryOwner { ... public override fun getViewModelStore(): ViewModelStore { ... checkNotNull(viewModelStoreProvider) { ... } return viewModelStoreProvider.getViewModelStore(id) } } NavBackStackEntry の ViewModelStoreOwner 実装では NavViewModelStoreProvider から ViewModelStore を取得しています。

NavViewModelStoreProvider は interface で、backStackEntryId: String から ViewModelStore を返すメソッドが定義されています。 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public interface NavViewModelStoreProvider { public fun getViewModelStore(backStackEntryId: String): ViewModelStore } NavViewModelStoreProvider を実装しているのは NavControllerViewModel です。 internal class NavControllerViewModel : ViewModel(), NavViewModelStoreProvider { private val viewModelStores = mutableMapOf<String, ViewModelStore>() fun clear(backStackEntryId: String) { // Clear and remove the NavGraph's ViewModelStore val viewModelStore = viewModelStores.remove(backStackEntryId) viewModelStore?.clear() } override fun onCleared() { for (store in viewModelStores.values) { store.clear() } viewModelStores.clear() } override fun getViewModelStore(backStackEntryId: String): ViewModelStore { var viewModelStore = viewModelStores[backStackEntryId] if (viewModelStore == null) { viewModelStore = ViewModelStore() viewModelStores[backStackEntryId] = viewModelStore } return viewModelStore } ... companion object { private val FACTORY: ViewModelProvider.Factory = object : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun <T : ViewModel> create(modelClass: Class<T>): T { return NavControllerViewModel() as T } } @JvmStatic fun getInstance(viewModelStore: ViewModelStore): NavControllerViewModel { val viewModelProvider = ViewModelProvider(viewModelStore, FACTORY) return viewModelProvider.get() } } } NavControllerViewModel は backStackEntryId: String と ViewModelStore の Map を持っています。

NavControllerViewModel は NavHost composable が呼び出される時点での LocalViewModelStoreOwner にセットされている ViewModelStoreOwner を使って生成され、NavController にセットされます。 @Composable public fun NavHost( navController: NavHostController, graph: NavGraph, modifier: Modifier = Modifier ) { val lifecycleOwner = LocalLifecycleOwner.current val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) { "NavHost requires a ViewModelStoreOwner to be provided via LocalViewModelStoreOwner" } ... navController.setViewModelStore(viewModelStoreOwner.viewModelStore) ... } public open class NavHostController(context: Context) : NavController(context) { ... public final override fun setViewModelStore(viewModelStore: ViewModelStore) { super.setViewModelStore(viewModelStore) } } public open class NavController( ... ) { ... @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) public open fun setViewModelStore(viewModelStore: ViewModelStore) { if (viewModel == NavControllerViewModel.getInstance(viewModelStore)) { return } check(backQueue.isEmpty()) { "ViewModelStore should be set before setGraph call" } viewModel = NavControllerViewModel.getInstance(viewModelStore) } ... } NavController にセットされた NavControllerViewModel は、NavBackStackEntry を生成するときに NavViewModelStoreProvider として渡されます。

つまり NavBackStackEntry を ViewModelStoreOwner として ViewModel を取得する場合、NavControllerViewModel に ViewModelStore が作られ、対応する backStackEntryId との Map で保持されます。

NavController では NavBackStackEntry が backstack から pop されたときに、NavControllerViewModel で保持されている対応する ViewModelStore をクリアしています。


2021年8月24日火曜日

Jetpack Compose + ViewModel + Navigation + Hilt で UI テスト

テスト対象の Compose @HiltAndroidApp class MyApplication : Application() @AndroidEntryPoint class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MaterialTheme { Surface(color = MaterialTheme.colors.background) { MyApp() } } } } } @Composable fun MyApp() { val navController = rememberNavController() NavHost(navController, startDestination = "Screen1") { composable("Screen1") { Screen1(hiltViewModel()) { navController.navigate("Screen2/$it") } } composable( route = "Screen2/{value}", arguments = listOf( navArgument("value") { type = NavType.IntType }, ) ) { val arguments = requireNotNull(it.arguments) val value = arguments.getInt("value") Screen2(value) } } } @Composable fun Screen1( viewModel: Screen1ViewModel, onClickItem: (Int) -> Unit, ) { LazyColumn(modifier = Modifier.fillMaxSize()) { items(viewModel.values) { value -> Text( text = value.toString(), modifier = Modifier .fillMaxWidth() .clickable { onClickItem(value) } .padding(16.dp) ) } } } @Composable fun Screen2(value: Int) { Text( text = "value = $value", modifier = Modifier .fillMaxWidth() .padding(16.dp) ) } @HiltViewModel class Screen1ViewModel @Inject constructor( valueProvider: ValueProvider ) : ViewModel() { val values: List<Int> = valueProvider.getValues() } interface ValueProvider { fun getValues(): List<Int> } class DefaultValueProvider @Inject constructor() : ValueProvider { override fun getValues(): List<Int> = (0 until 20).map { Random.nextInt() } } @Module @InstallIn(SingletonComponent::class) interface ValueProviderModule { @Binds @Singleton fun bindValueProvider(provider: DefaultValueProvider): ValueProvider }

MyApp composable では最初に Screen1が表示される。
Screen1 にはランダムな Int 値のリストが表示され、タップすると Screen2 に遷移し、タップした値が表示される。

Screen1 のリストをタップして Screen2 に遷移し、タップしたところの値が表示されていることをテストしたいとする。

Screen1 の引数の Screen1ViewModel は hiltViewModel() で取得しているので、テストでも Hilt が動くようにしたい。
ただし、Screen1ViewModel で使っている ValueProvidier はテスト用のものに差し替えたい。


1. テスト用のライブラリを追加する

参考 (Hilt) : https://developer.android.com/training/dependency-injection/hilt-testing#testing-dependencies
参考 (Compose) : https://developer.android.com/jetpack/compose/testing#setup dependencies { ... androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version" debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version" androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version" kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version" }
2. テスト時に HiltTestApplication が使われるように CustomTestRunner を用意する

参考 : https://developer.android.com/training/dependency-injection/hilt-testing#instrumented-tests

(継承元のApplicationが必要な場合は https://developer.android.com/training/dependency-injection/hilt-testing#custom-application に方法が書いてある)

app/src/androidTest class CustomTestRunner : AndroidJUnitRunner() { override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { return super.newApplication(cl, HiltTestApplication::class.java.name, context) } } build.gradle (app) android { ... defaultConfig { ... testInstrumentationRunner "com.sample.myapplication.CustomTestRunner" } ... } この設定をしないと

java.lang.IllegalStateException: Hilt test, com.sample.myapplication.MyAppTest, cannot use a @HiltAndroidApp application but found com.sample.myapplication.MyApplication. To fix, configure the test to use HiltTestApplication or a custom Hilt test application generated with @CustomTestApplication.

のようなエラーがでる


3. @AndroidEntryPoint がついたテスト用の Activity を debug に用意する

app/src/debug @AndroidEntryPoint class HiltTestActivity : ComponentActivity() app/src/debug/AndroidManifest.xml <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.sample.myapplication"> <application> <activity android:name=".HiltTestActivity" android:exported="false" /> </application> </manifest>
4. テストを書く

テストクラスに @HiltAndroidTest をつける

HiltAndroidRule が先になるように oder を指定する。
参考 :
https://developer.android.com/training/dependency-injection/hilt-testing#multiple-testrules

createAndroidComposeRule() に 3. で作った HiltTestActivity を指定する。createComposeRule() だと

Given component holder class androidx.activity.ComponentActivity does not implement interface dagger.hilt.internal.GeneratedComponent or interface dagger.hilt.internal.GeneratedComponentManager

のようなエラーがでる

binding の差し替えは https://developer.android.com/training/dependency-injection/hilt-testing#testing-features に書いてある。 @UninstallModules(ValueProviderModule::class) @HiltAndroidTest class MyAppTest { @get:Rule(order = 0) val hiltRule = HiltAndroidRule(this) @get:Rule(order = 1) val composeTestRule = createAndroidComposeRule<HiltTestActivity>() @BindValue val repository: ValueProvider = object : ValueProvider { override fun getValues(): List<Int> { return listOf(100) } } @Test fun test() { composeTestRule.setContent { MaterialTheme { Surface(color = MaterialTheme.colors.background) { MyApp() } } } // Screen1 に 100 が表示されている composeTestRule.onNodeWithText("100").assertIsDisplayed() // Screen1 の 100 が表示されている Node をクリック composeTestRule.onNodeWithText("100").performClick() // Screen2 に value = 100 が表示されている composeTestRule.onNodeWithText("value = 100").assertIsDisplayed() } }