ViewModel Multiplatform
Hướng dẫn sử dụng ViewModel trong Compose Multiplatform để quản lý state và logic.
Các lựa chọn ViewModel cho KMP
| Library | Mô tả | Pros/Cons |
|---|---|---|
| Voyager ScreenModel | Của Voyager | ✅ Đơn giản, ❌ Gắn với Voyager |
| MOKO MVVM | Của IceRock | ✅ Đầy đủ, ⚠️ Setup phức tạp |
| Custom | Tự viết | ✅ Linh hoạt, ⚠️ Phải tự quản lý |
| AndroidX ViewModel | Của Google | ✅ Stable, ❌ Android-centric |
Cách 1: Voyager ScreenModel (Khuyến nghị)
Setup
// build.gradle.kts
commonMain.dependencies {
implementation(libs.voyager.screenmodel)
implementation(libs.voyager.koin) // Nếu dùng Koin
}Tạo ScreenModel
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.screenModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class UserListScreenModel(
private val repository: UserRepository
) : ScreenModel {
// UI State
private val _state = MutableStateFlow<UserListState>(UserListState.Loading)
val state: StateFlow<UserListState> = _state.asStateFlow()
init {
loadUsers()
}
fun loadUsers() {
screenModelScope.launch {
_state.value = UserListState.Loading
try {
val users = repository.getUsers()
_state.value = if (users.isEmpty()) {
UserListState.Empty
} else {
UserListState.Success(users)
}
} catch (e: Exception) {
_state.value = UserListState.Error(e.message ?: "Unknown error")
}
}
}
fun deleteUser(user: User) {
screenModelScope.launch {
repository.deleteUser(user.id)
loadUsers() // Refresh
}
}
override fun onDispose() {
// Cleanup resources nếu cần
}
}
sealed class UserListState {
object Loading : UserListState()
data class Success(val users: List<User>) : UserListState()
data class Error(val message: String) : UserListState()
object Empty : UserListState()
}Sử dụng trong Screen
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.koin.getScreenModel
class UserListScreen : Screen {
@Composable
override fun Content() {
val screenModel = getScreenModel<UserListScreenModel>()
val state by screenModel.state.collectAsState()
val navigator = LocalNavigator.currentOrThrow
UserListContent(
state = state,
onRefresh = { screenModel.loadUsers() },
onUserClick = { user ->
navigator.push(UserDetailScreen(user.id))
},
onDeleteUser = { user ->
screenModel.deleteUser(user)
}
)
}
}Cách 2: Custom ViewModel
Base ViewModel
// commonMain/presentation/ViewModel.kt
import kotlinx.coroutines.*
import kotlin.coroutines.CoroutineContext
abstract class ViewModel {
private val job = SupervisorJob()
protected val viewModelScope = CoroutineScope(job + Dispatchers.Main)
open fun onCleared() {
job.cancel()
}
}Implement ViewModel
class HomeViewModel(
private val userRepository: UserRepository,
private val postRepository: PostRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
init {
loadData()
}
fun loadData() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
val users = async { userRepository.getTopUsers(5) }
val posts = async { postRepository.getLatestPosts(10) }
_uiState.update {
it.copy(
isLoading = false,
topUsers = users.await(),
latestPosts = posts.await()
)
}
} catch (e: Exception) {
_uiState.update {
it.copy(isLoading = false, error = e.message)
}
}
}
}
}
data class HomeUiState(
val isLoading: Boolean = false,
val topUsers: List<User> = emptyList(),
val latestPosts: List<Post> = emptyList(),
val error: String? = null
)Composable Extension
@Composable
fun <T : ViewModel> rememberViewModel(factory: () -> T): T {
return remember {
factory()
}.also { viewModel ->
DisposableEffect(viewModel) {
onDispose {
viewModel.onCleared()
}
}
}
}
// Sử dụng
@Composable
fun HomeScreen() {
val viewModel = rememberViewModel { HomeViewModel(get(), get()) }
val state by viewModel.uiState.collectAsState()
// ...
}Cách 3: MOKO MVVM
Setup
// build.gradle.kts
commonMain.dependencies {
implementation("dev.icerock.moko:mvvm-core:0.16.1")
implementation("dev.icerock.moko:mvvm-compose:0.16.1")
}
androidMain.dependencies {
implementation("dev.icerock.moko:mvvm-flow:0.16.1")
}
iosMain.dependencies {
implementation("dev.icerock.moko:mvvm-flow:0.16.1")
}Tạo ViewModel
import dev.icerock.moko.mvvm.viewmodel.ViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class ProductViewModel(
private val repository: ProductRepository
) : ViewModel() {
private val _products = MutableStateFlow<List<Product>>(emptyList())
val products: StateFlow<List<Product>> = _products.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
fun loadProducts() {
viewModelScope.launch {
_isLoading.value = true
_products.value = repository.getProducts()
_isLoading.value = false
}
}
}Sử dụng
import dev.icerock.moko.mvvm.compose.getViewModel
import dev.icerock.moko.mvvm.compose.viewModelFactory
@Composable
fun ProductScreen() {
val viewModel = getViewModel(
key = "product-vm",
factory = viewModelFactory { ProductViewModel(get()) }
)
val products by viewModel.products.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
// UI...
}State Management Patterns
Single State vs Multiple Flows
// Pattern 1: Single UI State (Khuyến nghị)
data class UserUiState(
val isLoading: Boolean = false,
val users: List<User> = emptyList(),
val error: String? = null,
val selectedUser: User? = null
)
class UserViewModel : ScreenModel {
private val _uiState = MutableStateFlow(UserUiState())
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
fun selectUser(user: User) {
_uiState.update { it.copy(selectedUser = user) }
}
}
// Pattern 2: Multiple Flows (Cho cases đơn giản)
class SimpleViewModel : ScreenModel {
private val _users = MutableStateFlow<List<User>>(emptyList())
val users: StateFlow<List<User>> = _users
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
}One-time Events
class AuthViewModel : ScreenModel {
private val _uiState = MutableStateFlow(AuthUiState())
val uiState: StateFlow<AuthUiState> = _uiState.asStateFlow()
// SharedFlow cho one-time events
private val _events = MutableSharedFlow<AuthEvent>()
val events: SharedFlow<AuthEvent> = _events.asSharedFlow()
fun login(email: String, password: String) {
screenModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
authRepository.login(email, password)
_events.emit(AuthEvent.NavigateToHome)
} catch (e: Exception) {
_events.emit(AuthEvent.ShowError(e.message ?: "Login failed"))
} finally {
_uiState.update { it.copy(isLoading = false) }
}
}
}
}
sealed class AuthEvent {
object NavigateToHome : AuthEvent()
data class ShowError(val message: String) : AuthEvent()
}
// Collect events trong Screen
@Composable
fun LoginScreen() {
val viewModel = getScreenModel<AuthViewModel>()
val navigator = LocalNavigator.currentOrThrow
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
is AuthEvent.NavigateToHome -> navigator.replaceAll(HomeScreen())
is AuthEvent.ShowError -> {
// Show snackbar
}
}
}
}
// UI...
}Koin Integration
// di/ViewModelModule.kt
val viewModelModule = module {
// Voyager ScreenModels
factory { UserListScreenModel(get()) }
factory { UserDetailScreenModel(get()) }
factory { (userId: Long) -> UserEditScreenModel(userId, get()) }
// HomeScreenModel với nhiều dependencies
factory { HomeScreenModel(get(), get(), get()) }
}
// Sử dụng với parameters
class UserEditScreen(private val userId: Long) : Screen {
@Composable
override fun Content() {
val screenModel = getScreenModel<UserEditScreenModel> {
parametersOf(userId)
}
// ...
}
}📝 Tóm tắt
| Approach | Khi nào dùng |
|---|---|
| Voyager ScreenModel | Dùng Voyager cho navigation |
| Custom ViewModel | Cần flexibility cao |
| MOKO MVVM | Cần full-featured MVVM |
Best Practices
- Single source of truth - một StateFlow cho UI state
- Unidirectional data flow - State down, Events up
- SharedFlow cho one-time events (navigation, snackbar)
- Inject via DI - Không tạo trực tiếp trong Composable
Tiếp theo
Học về Tích hợp với iOS.
Last updated on