Skip to Content

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 } } } ) }
@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

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, DataStore

UI 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

PatternMục đích
UDFState down, Events up
UI StateSingle source of truth cho UI
State HolderQuản lý UI state phức tạp
ViewModelBusiness logic, survive config changes
Stateless ScreensDễ test, reusable
DILoose 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