🐻‍❄️

Koin と Navigation Composeを組み合わせたときに画面のライフサイクルに合わせて ViewModel を生成する方法

2022/01/26に公開

はじめに

Koin と Navigatio Compose を組み合わせたときに画面のライフサイクルに従って ViewModel を生成する方法についてまとめます。本記事では以下のバージョンのライブラリを利用して動作確認しています。

dependencies {
			︙
    implementation 'androidx.core:core-ktx:1.7.0'
    implementation "androidx.compose.ui:ui:1.0.5"
    implementation "androidx.compose.material:material:1.0.5"
    implementation "androidx.compose.ui:ui-tooling-preview:1.0.5"
    implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.4.0'
    implementation 'androidx.activity:activity-compose:1.4.0'
    implementation "io.insert-koin:koin-android:3.1.5"
    implementation "androidx.navigation:navigation-compose:2.4.0-rc01"}

実装方法

画面のライフサイクルに従ってViewModel を生成できるようにするため以下の拡張関数を定義する。

@Composable
fun getComposeViewModelOwner(): ViewModelOwner {
    return ViewModelOwner.from(
        LocalViewModelStoreOwner.current!!,
        LocalSavedStateRegistryOwner.current
    )
}

@Composable
inline fun <reified T : ViewModel> getNavComposeViewModel(
    qualifier: Qualifier? = null,
    noinline parameters: ParametersDefinition? = null,
): T {
    val viewModelOwner = getComposeViewModelOwner()
    return getKoin().getViewModel(qualifier, { viewModelOwner }, parameters)
}

あとはNavHost の composable で定義した拡張関数を呼び出して ViewModel を生成する

@Composable
fun getComposeViewModelOwner(): ViewModelOwner {
    return ViewModelOwner.from(
        LocalViewModelStoreOwner.current!!,
        LocalSavedStateRegistryOwner.current
    )
}

@Composable
inline fun <reified T : ViewModel> getNavComposeViewModel(
    qualifier: Qualifier? = null,
    noinline parameters: ParametersDefinition? = null,
): T {
    val viewModelOwner = getComposeViewModelOwner()
    return KoinJavaComponent.getKoin().getViewModel(qualifier, { viewModelOwner }, parameters)
}

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            KoinNavigationComposeTheme {
                val navController = rememberNavController()
                NavHost(navController, startDestination = "main") {
                    composable("main") {
                        MainScreen(
                            viewModel = getNavComposeViewModel(),
                            onNext = {
                                navController.navigate("sub")
                            }
                        )
                    }
                    composable("sub") {
                        SubScreen(
                            viewModel = getNavComposeViewModel(),
                            onBack = {
                                navController.popBackStack()
                            }
                        )
                    }
                }
            }
        }
    }
}

class MainApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            modules(appModule)
        }
    }
}

val appModule = module {
    viewModel {
        MainViewModel()
    }

    viewModel {
        SubViewModel()
    }
}

@Composable
fun MainScreen(viewModel: MainViewModel, onNext: () -> Unit) {
    val count by viewModel.count.collectAsState()

    Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
        Column(modifier = Modifier.wrapContentSize(align = Alignment.Center)) {
            Text("TITLE: ${viewModel.title}")
            Text("CREATED AT: ${viewModel.createdAt}")
            Text("COUNT: $count")
            Button(onClick = { viewModel.increment() }, modifier = Modifier.padding(8.dp)) {
                Text(text = "INCREMENT")
            }
            Button(onClick = { viewModel.decrement() }, modifier = Modifier.padding(8.dp)) {
                Text(text = "DECREMENT")
            }
            Button(onClick = { onNext() }, modifier = Modifier.padding(8.dp)) {
                Text(text = "Next Screen")
            }
        }
    }
}

class MainViewModel : ViewModel() {
    val title = "HOME"
    val createdAt = Date().time

    private val _count: MutableStateFlow<Int> = MutableStateFlow(0)
    val count: StateFlow<Int> = _count

    fun increment() {
        _count.value = _count.value + 1
    }

    fun decrement() {
        _count.value = _count.value - 1
    }
}

@Composable
fun SubScreen(viewModel: SubViewModel, onBack: () -> Unit) {
    val count by viewModel.count.collectAsState()

    Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colors.background) {
        Column(modifier = Modifier.wrapContentSize(align = Alignment.Center)) {
            Text("TITLE: ${viewModel.title}")
            Text("CREATED AT: ${viewModel.createdAt}")
            Text("COUNT: $count")
            Button(onClick = { viewModel.increment() }, modifier = Modifier.padding(8.dp)) {
                Text(text = "INCREMENT")
            }
            Button(onClick = { viewModel.decrement() }, modifier = Modifier.padding(8.dp)) {
                Text(text = "DECREMENT")
            }
            Button(onClick = { onBack() }, modifier = Modifier.padding(8.dp)) {
                Text(text = "Back Screen")
            }
        }
    }
}

class SubViewModel : ViewModel() {
    val title = "SUB"
    val createdAt = Date().time

    private val _count: MutableStateFlow<Int> = MutableStateFlow(0)
    val count: StateFlow<Int> = _count

    fun increment() {
        _count.value = _count.value + 1
    }

    fun decrement() {
        _count.value = _count.value - 1
    }
}

動作

このような実装をすると画面のライフサイクルにあわせて ViewModel が生成されるようになる。

  • メイン画面→サブ画面に遷移したら新しいViewModel が生成される
  • サブ画面→メイン画面→サブ画面と遷移しても新しい ViewModel が生成される
  • メイン画面はバックスタックから消えていないので最初に生成された ViewModel が使われ続ける

Videotogif.gif

おわりに

今回の記事では拡張関数の詳細については触れていません。拡張機能で何をやっているのかは以下の記事を読み進めていくとわかると思います。詳細を知りたい方は以下の記事を熟読してみてください。

Discussion