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 あり/なしの比較をしたいので、共通する部分のコードを先に出しておきます。
  1. class Screen1ViewModel : ViewModel() {  
  2.   
  3.     val values: List<Int> = (0 until 20).map { Random.nextInt() }  
  4.   
  5.     init {  
  6.         println("Screen1ViewModel : created : $this")  
  7.     }  
  8.   
  9.     override fun onCleared() {  
  10.         println("Screen1ViewModel : cleared : $this")  
  11.     }  
  12. }  
  13.   
  14. class Screen2ViewModel : ViewModel() {  
  15.   
  16.     val value = Random.nextFloat()  
  17.   
  18.     init {  
  19.         println("Screen2ViewModel : created : $this")  
  20.     }  
  21.   
  22.     override fun onCleared() {  
  23.         println("Screen2ViewModel : cleared : $this")  
  24.     }  
  25. }  
  26.   
  27.   
  28. @Composable  
  29. fun Screen1(  
  30.     viewModel: Screen1ViewModel,  
  31.     onClickItem: (Int) -> Unit,  
  32. ) {  
  33.     DisposableEffect(Unit) {  
  34.         println("Screen1 : composed : viewModel = $viewModel")  
  35.         onDispose {  
  36.             println("Screen1 : disposed")  
  37.         }  
  38.     }  
  39.     LazyColumn(modifier = Modifier.fillMaxSize()) {  
  40.         items(viewModel.values) { value ->  
  41.             Text(  
  42.                 text = value.toString(),  
  43.                 modifier = Modifier  
  44.                     .fillMaxWidth()  
  45.                     .clickable {  
  46.                         onClickItem(value)  
  47.                     }  
  48.                     .padding(16.dp)  
  49.             )  
  50.         }  
  51.     }  
  52. }  
  53.   
  54. @Composable  
  55. fun Screen2(  
  56.     viewModel: Screen2ViewModel,  
  57.     value1: Int  
  58. ) {  
  59.     DisposableEffect(Unit) {  
  60.         println("Screen2 : composed : viewModel = $viewModel")  
  61.         onDispose {  
  62.             println("Screen2 : disposed")  
  63.         }  
  64.     }  
  65.     Text(  
  66.         text = "value1 = $value1, value2 = ${viewModel.value}",  
  67.         modifier = Modifier  
  68.             .fillMaxWidth()  
  69.             .padding(16.dp)  
  70.     )  
  71. }  
  72.   
  73.   
  74. class MainActivity : ComponentActivity() {  
  75.     override fun onCreate(savedInstanceState: Bundle?) {  
  76.         super.onCreate(savedInstanceState)  
  77.         setContent {  
  78.             MaterialTheme {  
  79.                 Surface(color = MaterialTheme.colors.background) {  
  80.                     MyApp()  
  81.                 }  
  82.             }  
  83.         }  
  84.     }  
  85. }  

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

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

viewModel() は androidx.lifecycle:lifecycle-viewmodel-compose に定義されている拡張関数で、次のようになっています。
  1. @Composable  
  2. public inline fun <reified VM : ViewModel> viewModel(  
  3.     viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {  
  4.         "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"  
  5.     },  
  6.     key: String? = null,  
  7.     factory: ViewModelProvider.Factory? = null  
  8. ): VM = viewModel(VM::class.java, viewModelStoreOwner, key, factory)  
  9.   
  10. @Composable  
  11. public fun <VM : ViewModel> viewModel(  
  12.     modelClass: Class<VM>,  
  13.     viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {  
  14.         "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"  
  15.     },  
  16.     key: String? = null,  
  17.     factory: ViewModelProvider.Factory? = null  
  18. ): VM = viewModelStoreOwner.get(modelClass, key, factory)  
  19.   
  20. private fun <VM : ViewModel> ViewModelStoreOwner.get(  
  21.     javaClass: Class<VM>,  
  22.     key: String? = null,  
  23.     factory: ViewModelProvider.Factory? = null  
  24. ): VM {  
  25.     val provider = if (factory != null) {  
  26.         ViewModelProvider(this, factory)  
  27.     } else {  
  28.         ViewModelProvider(this)  
  29.     }  
  30.     return if (key != null) {  
  31.         provider.get(key, javaClass)  
  32.     } else {  
  33.         provider.get(javaClass)  
  34.     }  
  35. }  
LocalViewModelStoreOwner.current から ViewModelStoreOwner を取得し、ViewModelProvider() にそれを渡して ViewModel のインスタンスを取得しています。

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

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

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

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

navigation-compose を使った場合どうなるのか確認してみましょう。
  1. @Composable  
  2. fun MyApp() {  
  3.     val navController = rememberNavController()  
  4.   
  5.     NavHost(navController = navController, startDestination = "Screen1") {  
  6.         composable(  
  7.             route = "Screen1"  
  8.         ) {  
  9.             println("Screen1 : ViewModelStoreOwner = ${LocalViewModelStoreOwner.current}")  
  10.             Screen1(viewModel()) {  
  11.                 navController.navigate("Screen2/$it")  
  12.             }  
  13.         }  
  14.         composable(  
  15.             route = "Screen2/{value}",  
  16.             arguments = listOf(navArgument("value") { type = NavType.IntType })  
  17.         ) {  
  18.             val value1 = requireNotNull(it.arguments).getInt("value")  
  19.             println("Screen2 : ViewModelStoreOwner = ${LocalViewModelStoreOwner.current}")  
  20.             Screen2(viewModel(), value1)  
  21.         }  
  22.     }  
  23. }  
  1. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  2. Screen1ViewModel : created : com.sample.myapplication.Screen1ViewModel@f79f3f3  
  3. Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3  
  4. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  5.   
  6. -- Screen1 の LazyColumn のアイテムをタップ  
  7.   
  8. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  9. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a  
  10. Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@7cf0642  
  11. Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@7cf0642  
  12. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  13. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a  
  14. Screen1 : disposed  
  15. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a  
  16.   
  17. -- back キータップ   
  18.   
  19. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a  
  20. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  21. Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3  
  22. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@861e3e2a  
  23. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  24. Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@7cf0642  
  25. Screen2 : disposed  
  26. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  27.   
  28. -- Screen1 の LazyColumn のアイテムをタップ  
  29.   
  30. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  31. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1  
  32. Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@96e0d3b  
  33. Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@96e0d3b  
  34. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  35. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1  
  36. Screen1 : disposed  
  37. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1  
  38.   
  39. -- back キータップ   
  40.   
  41. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1  
  42. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  43. Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3  
  44. Screen2 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@27c16dd1  
  45. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  46. Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@96e0d3b  
  47. Screen2 : disposed  
  48. Screen1 : ViewModelStoreOwner = androidx.navigation.NavBackStackEntry@ba021bdb  
  49.   
  50. -- back キータップ(アプリ終了)  
  51.   
  52. Screen1 : disposed  
  53. Screen1ViewModel : cleared : com.sample.myapplication.Screen1ViewModel@f79f3f3  
LocalViewModelStoreOwner には MainActivity ではなく NavBackStackEntry が入っていることがわかりました。
ViewModelStoreOwner の部分を省くと
  1. Screen1ViewModel : created : com.sample.myapplication.Screen1ViewModel@f79f3f3  
  2. Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3  
  3.   
  4. -- Screen1 の LazyColumn のアイテムをタップ  
  5.   
  6. Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@7cf0642  
  7. Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@7cf0642  
  8. Screen1 : disposed  
  9.   
  10. -- back キータップ = popBackstack  
  11.   
  12. Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3  
  13. Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@7cf0642  
  14. Screen2 : disposed  
  15.   
  16. -- Screen1 の LazyColumn のアイテムをタップ  
  17.   
  18. Screen2ViewModel : created : com.sample.myapplication.Screen2ViewModel@96e0d3b  
  19. Screen2 : composed : viewModel = com.sample.myapplication.Screen2ViewModel@96e0d3b  
  20. Screen1 : disposed  
  21.   
  22. -- back キータップ = popBackstack  
  23.   
  24. Screen1 : composed : viewModel = com.sample.myapplication.Screen1ViewModel@f79f3f3  
  25. Screen2ViewModel : cleared : com.sample.myapplication.Screen2ViewModel@96e0d3b  
  26. Screen2 : disposed  
  27.   
  28. -- back キータップ(アプリ終了)  
  29.   
  30. Screen1 : disposed  
  31. Screen1ViewModel : cleared : com.sample.myapplication.Screen1ViewModel@f79f3f3  
Screen1 → Screen2 → Screen1 のとき(Screen2 が backstack からいなくなった)に Screen2ViewModel が破棄(onCleared)され、再度 Screen2 に遷移したときに別の Screen2ViewModel インスタンスが作られていることがわかります。

LocalViewModelStoreOwner へのセット

navigation-compose では NavHost composable で LocalViewModelStoreOwner に NavBackStackEntry をセットしています。
  1. @Composable  
  2. public fun NavBackStackEntry.LocalOwnersProvider(  
  3.     saveableStateHolder: SaveableStateHolder,  
  4.     content: @Composable () -> Unit  
  5. ) {  
  6.     CompositionLocalProvider(  
  7.         LocalViewModelStoreOwner provides this,  
  8.         LocalLifecycleOwner provides this,  
  9.         LocalSavedStateRegistryOwner provides this  
  10.     ) {  
  11.         saveableStateHolder.SaveableStateProvider(content)  
  12.     }  
  13. }  
  14.     
  15. @Composable  
  16. public fun NavHost(  
  17.     navController: NavHostController,  
  18.     graph: NavGraph,  
  19.     modifier: Modifier = Modifier  
  20. ) {  
  21.     ...  
  22.     if (backStackEntry != null) {  
  23.         ...  
  24.         Crossfade(backStackEntry.id, modifier) {  
  25.             val lastEntry = transitionsInProgress.lastOrNull { entry ->  
  26.                 it == entry.id  
  27.             } ?: backStack.lastOrNull { entry ->  
  28.                 it == entry.id  
  29.             }  
  30.   
  31.             lastEntry?.LocalOwnersProvider(saveableStateHolder) {  
  32.                 (lastEntry.destination as ComposeNavigator.Destination).content(lastEntry)  
  33.             }  
  34.             ...  
  35.         }  
  36.     }  
  37.   
  38.     ...  
  39. }  
backStackEntry には backstack の一番最後のものが入ります。

NavBackStackEntry の ViewModelStoreOwner 実装

では NavBackStackEntry が ViewModelStoreOwner をどのように実装しているのか見てみましょう。
  1. public class NavBackStackEntry private constructor(  
  2.     ...  
  3.     private val viewModelStoreProvider: NavViewModelStoreProvider? = null,  
  4.     ...  
  5. ) : LifecycleOwner,  
  6.     ViewModelStoreOwner,  
  7.     HasDefaultViewModelProviderFactory,  
  8.     SavedStateRegistryOwner {  
  9.   
  10.     ...  
  11.     
  12.     public override fun getViewModelStore(): ViewModelStore {  
  13.         ...  
  14.         checkNotNull(viewModelStoreProvider) {  
  15.              ...  
  16.         }  
  17.         return viewModelStoreProvider.getViewModelStore(id)  
  18.     }    
  19. }  
NavBackStackEntry の ViewModelStoreOwner 実装では NavViewModelStoreProvider から ViewModelStore を取得しています。

NavViewModelStoreProvider は interface で、backStackEntryId: String から ViewModelStore を返すメソッドが定義されています。
  1. @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)  
  2. public interface NavViewModelStoreProvider {  
  3.     public fun getViewModelStore(backStackEntryId: String): ViewModelStore  
  4. }  
NavViewModelStoreProvider を実装しているのは NavControllerViewModel です。
  1. internal class NavControllerViewModel : ViewModel(), NavViewModelStoreProvider {  
  2.     private val viewModelStores = mutableMapOf<String, ViewModelStore>()  
  3.   
  4.     fun clear(backStackEntryId: String) {  
  5.         // Clear and remove the NavGraph's ViewModelStore  
  6.         val viewModelStore = viewModelStores.remove(backStackEntryId)  
  7.         viewModelStore?.clear()  
  8.     }  
  9.   
  10.     override fun onCleared() {  
  11.         for (store in viewModelStores.values) {  
  12.             store.clear()  
  13.         }  
  14.         viewModelStores.clear()  
  15.     }  
  16.   
  17.     override fun getViewModelStore(backStackEntryId: String): ViewModelStore {  
  18.         var viewModelStore = viewModelStores[backStackEntryId]  
  19.         if (viewModelStore == null) {  
  20.             viewModelStore = ViewModelStore()  
  21.             viewModelStores[backStackEntryId] = viewModelStore  
  22.         }  
  23.         return viewModelStore  
  24.     }  
  25.   
  26.     ...  
  27.   
  28.     companion object {  
  29.         private val FACTORY: ViewModelProvider.Factory = object : ViewModelProvider.Factory {  
  30.             @Suppress("UNCHECKED_CAST")  
  31.             override fun <T : ViewModel> create(modelClass: Class<T>): T {  
  32.                 return NavControllerViewModel() as T  
  33.             }  
  34.         }  
  35.   
  36.         @JvmStatic  
  37.         fun getInstance(viewModelStore: ViewModelStore): NavControllerViewModel {  
  38.             val viewModelProvider = ViewModelProvider(viewModelStore, FACTORY)  
  39.             return viewModelProvider.get()  
  40.         }  
  41.     }  
  42. }  
NavControllerViewModel は backStackEntryId: String と ViewModelStore の Map を持っています。

NavControllerViewModel は NavHost composable が呼び出される時点での LocalViewModelStoreOwner にセットされている ViewModelStoreOwner を使って生成され、NavController にセットされます。
  1. @Composable  
  2. public fun NavHost(  
  3.     navController: NavHostController,  
  4.     graph: NavGraph,  
  5.     modifier: Modifier = Modifier  
  6. ) {  
  7.     val lifecycleOwner = LocalLifecycleOwner.current  
  8.     val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {  
  9.         "NavHost requires a ViewModelStoreOwner to be provided via LocalViewModelStoreOwner"  
  10.     }  
  11.   
  12.     ...  
  13.   
  14.     navController.setViewModelStore(viewModelStoreOwner.viewModelStore)  
  15.   
  16.     ...  
  17. }  
  18.   
  19. public open class NavHostController(context: Context) : NavController(context) {  
  20.     ...  
  21.     public final override fun setViewModelStore(viewModelStore: ViewModelStore) {  
  22.         super.setViewModelStore(viewModelStore)  
  23.     }  
  24. }  
  25.   
  26.     
  27. public open class NavController(  
  28.     ...  
  29. ) {  
  30.     ...  
  31.   
  32.     @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)  
  33.     public open fun setViewModelStore(viewModelStore: ViewModelStore) {  
  34.         if (viewModel == NavControllerViewModel.getInstance(viewModelStore)) {  
  35.             return  
  36.         }  
  37.         check(backQueue.isEmpty()) { "ViewModelStore should be set before setGraph call" }  
  38.         viewModel = NavControllerViewModel.getInstance(viewModelStore)  
  39.     }  
  40.     ...  
  41. }  
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
  1. @HiltAndroidApp  
  2. class MyApplication : Application()  
  3.   
  4. @AndroidEntryPoint  
  5. class MainActivity : ComponentActivity() {  
  6.     override fun onCreate(savedInstanceState: Bundle?) {  
  7.         super.onCreate(savedInstanceState)  
  8.         setContent {  
  9.             MaterialTheme {  
  10.                 Surface(color = MaterialTheme.colors.background) {  
  11.                     MyApp()  
  12.                 }  
  13.             }  
  14.         }  
  15.     }  
  16. }  
  17.   
  18. @Composable  
  19. fun MyApp() {  
  20.     val navController = rememberNavController()  
  21.   
  22.     NavHost(navController, startDestination = "Screen1") {  
  23.   
  24.         composable("Screen1") {  
  25.             Screen1(hiltViewModel()) {  
  26.                 navController.navigate("Screen2/$it")  
  27.             }  
  28.         }  
  29.   
  30.         composable(  
  31.             route = "Screen2/{value}",  
  32.             arguments = listOf(  
  33.                 navArgument("value") { type = NavType.IntType },  
  34.             )  
  35.         ) {  
  36.             val arguments = requireNotNull(it.arguments)  
  37.             val value = arguments.getInt("value")  
  38.   
  39.             Screen2(value)  
  40.         }  
  41.     }  
  42. }  
  43.   
  44. @Composable  
  45. fun Screen1(  
  46.     viewModel: Screen1ViewModel,  
  47.     onClickItem: (Int) -> Unit,  
  48. ) {  
  49.     LazyColumn(modifier = Modifier.fillMaxSize()) {  
  50.         items(viewModel.values) { value ->  
  51.             Text(  
  52.                 text = value.toString(),  
  53.                 modifier = Modifier  
  54.                     .fillMaxWidth()  
  55.                     .clickable {  
  56.                         onClickItem(value)  
  57.                     }  
  58.                     .padding(16.dp)  
  59.             )  
  60.         }  
  61.     }  
  62. }  
  63.   
  64. @Composable  
  65. fun Screen2(value: Int) {  
  66.     Text(  
  67.         text = "value = $value",  
  68.         modifier = Modifier  
  69.             .fillMaxWidth()  
  70.             .padding(16.dp)  
  71.     )  
  72. }  
  73.   
  74. @HiltViewModel  
  75. class Screen1ViewModel @Inject constructor(  
  76.     valueProvider: ValueProvider  
  77. ) : ViewModel() {  
  78.   
  79.     val values: List<Int> = valueProvider.getValues()  
  80.   
  81. }  
  82.   
  83. interface ValueProvider {  
  84.   
  85.     fun getValues(): List<Int>  
  86. }  
  87.   
  88. class DefaultValueProvider @Inject constructor() : ValueProvider {  
  89.   
  90.     override fun getValues(): List<Int> = (0 until 20).map { Random.nextInt() }  
  91. }  
  92.   
  93. @Module  
  94. @InstallIn(SingletonComponent::class)  
  95. interface ValueProviderModule {  
  96.   
  97.     @Binds  
  98.     @Singleton  
  99.     fun bindValueProvider(provider: DefaultValueProvider): ValueProvider  
  100. }  


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
  1. dependencies {  
  2.     ...  
  3.   
  4.     androidTestImplementation "androidx.compose.ui:ui-test-junit4:$compose_version"  
  5.     debugImplementation "androidx.compose.ui:ui-test-manifest:$compose_version"  
  6.   
  7.     androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version"  
  8.     kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version"  
  9. }  

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
  1. class CustomTestRunner : AndroidJUnitRunner() {  
  2.   
  3.     override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application {  
  4.         return super.newApplication(cl, HiltTestApplication::class.java.name, context)  
  5.     }  
  6. }  
build.gradle (app)
  1. android {  
  2.     ...  
  3.   
  4.     defaultConfig {  
  5.         ...  
  6.   
  7.         testInstrumentationRunner "com.sample.myapplication.CustomTestRunner"  
  8.     }  
  9.     
  10.     ...  
  11. }  
この設定をしないと

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
  1. @AndroidEntryPoint  
  2. class HiltTestActivity : ComponentActivity()  
app/src/debug/AndroidManifest.xml
  1. <?xml version="1.0" encoding="utf-8"?>  
  2. <manifest xmlns:android="http://schemas.android.com/apk/res/android"  
  3.     package="com.sample.myapplication">  
  4.   
  5.     <application>  
  6.         <activity  
  7.             android:name=".HiltTestActivity"  
  8.             android:exported="false" />  
  9.     </application>  
  10.   
  11. </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 に書いてある。
  1. @UninstallModules(ValueProviderModule::class)  
  2. @HiltAndroidTest  
  3. class MyAppTest {  
  4.   
  5.     @get:Rule(order = 0)  
  6.     val hiltRule = HiltAndroidRule(this)  
  7.   
  8.     @get:Rule(order = 1)  
  9.     val composeTestRule = createAndroidComposeRule<HiltTestActivity>()  
  10.   
  11.     @BindValue  
  12.     val repository: ValueProvider = object : ValueProvider {  
  13.         override fun getValues(): List<Int> {  
  14.             return listOf(100)  
  15.         }  
  16.     }  
  17.   
  18.     @Test  
  19.     fun test() {  
  20.         composeTestRule.setContent {  
  21.             MaterialTheme {  
  22.                 Surface(color = MaterialTheme.colors.background) {  
  23.                     MyApp()  
  24.                 }  
  25.             }  
  26.         }  
  27.   
  28.         // Screen1 に 100 が表示されている  
  29.         composeTestRule.onNodeWithText("100").assertIsDisplayed()  
  30.   
  31.         // Screen1 の 100 が表示されている Node をクリック  
  32.         composeTestRule.onNodeWithText("100").performClick()  
  33.   
  34.         // Screen2 に value = 100 が表示されている  
  35.         composeTestRule.onNodeWithText("value = 100").assertIsDisplayed()  
  36.     }  
  37. }  




2021年8月23日月曜日

Jetpack Compose + ViewModel + Navigation + Hilt + AssistedInject

Jetpack Compose + ViewModel + Navigation + Hilt の ViewModel で AssistedInject を使う方法。


ViewModelExt.kt
  1. @Composable  
  2. inline fun <reified VM : ViewModel> assistedViewModel(  
  3.     viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {  
  4.         "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"  
  5.     },  
  6.     crossinline viewModelProducer: (SavedStateHandle) -> VM  
  7. ): VM {  
  8.     val factory = if (viewModelStoreOwner is NavBackStackEntry) {  
  9.         object : AbstractSavedStateViewModelFactory(viewModelStoreOwner, viewModelStoreOwner.arguments) {  
  10.             @Suppress("UNCHECKED_CAST")  
  11.             override fun <T : ViewModel?> create(key: String, modelClass: Class<T>, handle: SavedStateHandle): T {  
  12.                 return viewModelProducer(handle) as T  
  13.             }  
  14.         }  
  15.     } else {  
  16.         // Use the default factory provided by the ViewModelStoreOwner  
  17.         // and assume it is an @AndroidEntryPoint annotated fragment or activity  
  18.         null  
  19.     }  
  20.     return viewModel(viewModelStoreOwner, factory = factory)  
  21. }  
  22.   
  23. fun Context.extractActivity(): Activity {  
  24.     var ctx = this  
  25.     while (ctx is ContextWrapper) {  
  26.         if (ctx is Activity) {  
  27.             return ctx  
  28.         }  
  29.         ctx = ctx.baseContext  
  30.     }  
  31.     throw IllegalStateException(  
  32.         "Expected an activity context for creating a HiltViewModelFactory for a " +  
  33.                 "NavBackStackEntry but instead found: $ctx"  
  34.     )  
  35. }  
MainActivity.kt
  1. @HiltAndroidApp  
  2. class MyApplication : Application()  
  3.   
  4. @AndroidEntryPoint  
  5. class MainActivity : ComponentActivity() {  
  6.     override fun onCreate(savedInstanceState: Bundle?) {  
  7.         super.onCreate(savedInstanceState)  
  8.         setContent {  
  9.             MaterialTheme {  
  10.                 Surface(color = MaterialTheme.colors.background) {  
  11.                     MyApp()  
  12.                 }  
  13.             }  
  14.         }  
  15.     }  
  16. }  
  17.   
  18. @Composable  
  19. fun MyApp() {  
  20.     val navController = rememberNavController()  
  21.   
  22.     NavHost(navController, startDestination = "Screen1") {  
  23.   
  24.         composable("Screen1") {  
  25.             LazyColumn(modifier = Modifier.fillMaxSize()) {  
  26.                 items(20) {  
  27.                     Text(  
  28.                         text = "Item : $it",  
  29.                         modifier = Modifier  
  30.                             .fillMaxWidth()  
  31.                             .clickable {  
  32.                                 navController.navigate("Screen2/$it")  
  33.                             }  
  34.                             .padding(16.dp)  
  35.                     )  
  36.                 }  
  37.             }  
  38.         }  
  39.   
  40.         composable(  
  41.             route = "Screen2/{id}",  
  42.             arguments = listOf(  
  43.                 navArgument("id") { type = NavType.IntType },  
  44.             )  
  45.         ) {  
  46.             val arguments = requireNotNull(it.arguments)  
  47.             val id = arguments.getInt("id")  
  48.             val viewModel = assistedViewModel { savedStateHandle ->  
  49.                 Screen2ViewModel.provideFactory(LocalContext.current)  
  50.                     .create(savedStateHandle, id)  
  51.             }  
  52.   
  53.             Screen2(viewModel)  
  54.         }  
  55.     }  
  56. }  
  57.   
  58. @Composable  
  59. fun Screen2(viewModel: Screen2ViewModel) {  
  60.     Text(  
  61.         text = viewModel.greet(),  
  62.         modifier = Modifier.padding(24.dp)  
  63.     )  
  64. }  
  65.   
  66. class Screen2ViewModel @AssistedInject constructor(  
  67.     private val nameProvider: NameProvider,  
  68.     @Assisted private val savedStateHandle: SavedStateHandle,  
  69.     @Assisted private val id: Int  
  70. ) : ViewModel() {  
  71.   
  72.     @AssistedFactory  
  73.     interface Factory {  
  74.         fun create(savedStateHandle: SavedStateHandle, id: Int): Screen2ViewModel  
  75.     }  
  76.   
  77.     @EntryPoint  
  78.     @InstallIn(ActivityComponent::class)  
  79.     interface ActivityCreatorEntryPoint {  
  80.         fun getScreen2ViewModelFactory(): Factory  
  81.     }  
  82.   
  83.     companion object {  
  84.         fun provideFactory(context: Context): Factory {  
  85.             val activity = context.extractActivity()  
  86.             return EntryPoints.get(activity, ActivityCreatorEntryPoint::class.java)  
  87.                 .getScreen2ViewModelFactory()  
  88.         }  
  89.     }  
  90.   
  91.     fun greet(): String {  
  92.         return "Hello ${nameProvider.name()} : id = $id"  
  93.     }  
  94. }  
  95.   
  96. @Singleton  
  97. class NameProvider @Inject constructor() {  
  98.   
  99.     fun name(): String {  
  100.         return "Android"  
  101.     }  
  102. }  
Issue (https://github.com/google/dagger/issues/2287) は 1月からあるけど、進んでなさそう。