Bỏ qua để đến nội dung

Architecture Patterns

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.

  • State flows down (từ ViewModel xuống UI)
  • Events flow up (từ UI lên ViewModel)
┌─────────────────────────────────────────┐
│ │
│ ViewModel ────────▶ State ──▶ UI │
│ ▲ │ │
│ │ ▼ │
│ └────── Events ◀───── User │
│ │
└─────────────────────────────────────────┘
// 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
)
}

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 HomeUiState(
val items: List<Item> = emptyList(),
val isLoading: Boolean = false,
val isRefreshing: Boolean = false,
val error: String? = null,
val selectedItem: Item? = null
)

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
}

@Composable
fun ProductScreen(
uiState: ProductUiState,
onAddToCart: (Product) -> Unit,
onNavigateToDetail: (String) -> Unit,
onRefresh: () -> Unit
) {
// UI implementation
}
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)
}
}
)
}

@Composable
fun UserScreen(
viewModel: UserViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsState()
UserContent(uiState)
}
@HiltViewModel
class UserViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
// ...
}
@Composable
fun UserScreen(
viewModel: UserViewModel = hiltViewModel()
) {
// ...
}
@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)
}

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

// 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()
)
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")
}

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

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
  • 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)