Architecture trong Jetpack Compose
Xây dựng kiến trúc tốt giúp ứng dụng dễ test, bảo trì và mở rộng. Bài này hướng dẫn các patterns tốt nhất khi làm việc với Compose.
1. Unidirectional Data Flow (UDF)
Nguyên tắc
- State flows down (từ ViewModel xuống UI)
- Events flow up (từ UI lên ViewModel)
┌─────────────────────────────────────────┐
│ │
│ ViewModel ────────▶ State ──▶ UI │
│ ▲ │ │
│ │ ▼ │
│ └────── Events ◀───── User │
│ │
└─────────────────────────────────────────┘Implementation
// UI State - represents what to display
data class HomeUiState(
val items: List<Item> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null
)
// ViewModel - manages state
class HomeViewModel : ViewModel() {
private val _uiState = MutableStateFlow(HomeUiState())
val uiState: StateFlow<HomeUiState> = _uiState.asStateFlow()
fun onRefresh() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
val items = repository.getItems()
_uiState.update { it.copy(items = items, isLoading = false) }
} catch (e: Exception) {
_uiState.update { it.copy(error = e.message, isLoading = false) }
}
}
}
}
// UI - displays state and sends events
@Composable
fun HomeScreen(viewModel: HomeViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsState()
HomeContent(
uiState = uiState,
onRefresh = viewModel::onRefresh
)
}2. UI State Pattern
Sealed class cho các states
sealed interface HomeUiState {
object Loading : HomeUiState
data class Success(val items: List<Item>) : HomeUiState
data class Error(val message: String) : HomeUiState
}
@Composable
fun HomeScreen(uiState: HomeUiState) {
when (uiState) {
is HomeUiState.Loading -> LoadingIndicator()
is HomeUiState.Success -> ItemList(uiState.items)
is HomeUiState.Error -> ErrorMessage(uiState.message)
}
}Data class với multiple fields
data class HomeUiState(
val items: List<Item> = emptyList(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val error: String? = null,
val selectedItem: Item? = null
)3. State Holders
Screen-level State Holder
class HomeScreenState(
val listState: LazyListState,
val scaffoldState: ScaffoldState,
private val scope: CoroutineScope
) {
val showFab: Boolean
get() = listState.firstVisibleItemIndex > 0
fun scrollToTop() {
scope.launch {
listState.animateScrollToItem(0)
}
}
fun showSnackbar(message: String) {
scope.launch {
scaffoldState.snackbarHostState.showSnackbar(message)
}
}
}
@Composable
fun rememberHomeScreenState(
listState: LazyListState = rememberLazyListState(),
scaffoldState: ScaffoldState = rememberScaffoldState(),
scope: CoroutineScope = rememberCoroutineScope()
) = remember(listState, scaffoldState, scope) {
HomeScreenState(listState, scaffoldState, scope)
}
@Composable
fun HomeScreen() {
val screenState = rememberHomeScreenState()
// Use screenState
}4. Event Handling Patterns
Events as functions
@Composable
fun ProductScreen(
uiState: ProductUiState,
onAddToCart: (Product) -> Unit,
onNavigateToDetail: (String) -> Unit,
onRefresh: () -> Unit
) {
// UI implementation
}Events as sealed class
sealed interface ProductEvent {
data class AddToCart(val product: Product) : ProductEvent
data class NavigateToDetail(val productId: String) : ProductEvent
object Refresh : ProductEvent
}
class ProductViewModel : ViewModel() {
fun onEvent(event: ProductEvent) {
when (event) {
is ProductEvent.AddToCart -> addToCart(event.product)
is ProductEvent.NavigateToDetail -> { /* Navigation handled by UI */ }
is ProductEvent.Refresh -> refresh()
}
}
}
@Composable
fun ProductScreen(
viewModel: ProductViewModel,
onNavigate: (String) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
ProductContent(
uiState = uiState,
onEvent = { event ->
when (event) {
is ProductEvent.NavigateToDetail -> onNavigate(event.productId)
else -> viewModel.onEvent(event)
}
}
)
}5. ViewModel trong Compose
Basic usage
@Composable
fun UserScreen(
viewModel: UserViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsState()
UserContent(uiState)
}Với Hilt
@HiltViewModel
class UserViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
// ...
}
@Composable
fun UserScreen(
viewModel: UserViewModel = hiltViewModel()
) {
// ...
}Với SavedStateHandle
@HiltViewModel
class DetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repository: Repository
) : ViewModel() {
private val id: String = checkNotNull(savedStateHandle["id"])
val uiState = repository.getItem(id)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
}6. Navigation Patterns
Stateless screens
// Screen composable không biết về navigation
@Composable
fun ProfileScreen(
uiState: ProfileUiState,
onEditClick: () -> Unit,
onLogoutClick: () -> Unit
)
// Navigation wrapper xử lý navigation
@Composable
fun ProfileRoute(
navController: NavController,
viewModel: ProfileViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsState()
ProfileScreen(
uiState = uiState,
onEditClick = { navController.navigate("edit") },
onLogoutClick = {
viewModel.logout()
navController.navigate("login") {
popUpTo(0) { inclusive = true }
}
}
)
}NavHost setup
@Composable
fun AppNavHost(navController: NavHostController) {
NavHost(navController, startDestination = "home") {
composable("home") {
HomeRoute(navController)
}
composable("profile") {
ProfileRoute(navController)
}
composable(
route = "detail/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType })
) {
DetailRoute(navController)
}
}
}7. Dependency Injection với Compose
Hilt setup
// Application class
@HiltAndroidApp
class MyApp : Application()
// Activity
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyAppTheme {
AppNavHost()
}
}
}
}
// ViewModel
@HiltViewModel
class HomeViewModel @Inject constructor(
private val repository: HomeRepository
) : ViewModel()
// In Composable
@Composable
fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel()
)Provide dependencies với CompositionLocal
val LocalAnalytics = staticCompositionLocalOf<Analytics> {
error("No Analytics provided")
}
@Composable
fun App() {
CompositionLocalProvider(
LocalAnalytics provides AnalyticsImpl()
) {
AppNavHost()
}
}
// Usage
@Composable
fun SomeScreen() {
val analytics = LocalAnalytics.current
analytics.logScreen("some_screen")
}8. Layers và Separation
Recommended Structure
app/
├── ui/
│ ├── home/
│ │ ├── HomeScreen.kt # UI composables
│ │ ├── HomeViewModel.kt # ViewModel
│ │ └── HomeUiState.kt # UI state model
│ ├── detail/
│ └── theme/
├── domain/
│ ├── model/ # Domain models
│ ├── repository/ # Repository interfaces
│ └── usecase/ # Use cases (optional)
└── data/
├── repository/ # Repository implementations
├── remote/ # API, Network
└── local/ # Room, DataStoreUI Layer independence
// UI không biết về data layer
@Composable
fun UserCard(
user: User, // Domain model
onFollowClick: () -> Unit
)
// ViewModel chuyển đổi data → UI state
class UserViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
val users = userRepository.getUsers()
.map { users -> users.map { it.toUiModel() } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}📝 Tóm tắt
| Pattern | Mục đích |
|---|---|
| UDF | State down, Events up |
| UI State | Single source of truth cho UI |
| State Holder | Quản lý UI state phức tạp |
| ViewModel | Business logic, survive config changes |
| Stateless Screens | Dễ test, reusable |
| DI | Loose coupling, testability |
Architecture Checklist
- UI State là single source of truth
- State flows down, Events flow up
- Screen composables stateless (nhận state, emit events)
- ViewModel xử lý business logic
- Navigation logic tách khỏi UI
- Dependencies được inject (Hilt/Koin)
Last updated on