
One-Time Events trong Android Development: Giải Quyết Bài Toán Kinh Điển
Xử lý one-time event trong Android nghe có vẻ đơn giản, nhưng thực tế lại là một trong những bài toán kinh điển mà gần như mọi Android developer đều từng gặp phải.
One-Time Event là gì?
One-time event (sự kiện một lần) là những sự kiện chỉ nên được xử lý một lần duy nhất trên giao diện người dùng (UI). Các ví dụ phổ biến:
- Hiển thị thông báo (Toast, Snackbar)
- Điều hướng sang màn hình khác
- Hiển thị dialog, popup,...
Khác với state – có thể được lưu trữ và phục hồi khi vòng đời của Activity/Fragment thay đổi – one-time event không nên lặp lại khi UI được tạo lại (chẳng hạn khi xoay màn hình). Nếu không xử lý đúng, các sự kiện này có thể bị lặp lại hoặc bị mất, gây ra trải nghiệm không mong muốn cho người dùng.
Vấn đề: Tại sao xử lý one-time event lại khó?
Hãy tưởng tượng: người dùng nhấn nút → mở màn hình mới hoặc hiện một Toast. Trong lúc đó, nếu người dùng xoay màn hình hoặc thay đổi cấu hình thì sao?
Các tình huống có thể xảy ra:
- Sự kiện bị lặp: ViewModel phát lại event sau khi UI được recreate
- Sự kiện bị mất: UI chưa kịp đăng ký đã bỏ lỡ event
- Vòng đời phức tạp: Android có lifecycle phức tạp, khiến việc xử lý không đồng nhất
Một số giải pháp phổ biến
LiveData&SingleLiveEventStateFlow&SharedFlowChannel
Mỗi giải pháp có ưu và nhược điểm riêng, và phù hợp với những bối cảnh khác nhau.
LiveData & SingleLiveEvent: Cách tiếp cận truyền thống
val navigationLiveData = MutableLiveData<Event<String>>()
navigationLiveData.value = Event("go_to_profile")
- Dùng
Event wrapperđể đảm bảo UI chỉ xử lý sự kiện một lần. - Tuy nhiên, khi màn hình xoay → LiveData có thể phát lại → lặp lại sự kiện.
SingleLiveEvent giúp cải thiện:
- Chỉ phát tới một observer → giảm nguy cơ lặp
- Nhưng khó kiểm thử, khó tái sử dụng
- Có thể xảy ra race condition nếu không cẩn thận
StateFlow & SharedFlow: Giải pháp hiện đại
StateFlow: Lưu state tốt, nhưng dễ gây lặp event
private val _stateFlow = MutableStateFlow<Event<String>?>(null)
val stateFlow = _stateFlow.asStateFlow()
fun triggerEvent() {
_stateFlow.value = Event("navigate_to_home")
}
StateFlowgiữ lại giá trị cuối cùng → khi UI được recreate sẽ nhận lại event.- Cách khắc phục: reset về
nullsau khi xử lý → nhưng dễ sinh logic rối rắm.
SharedFlow: Phù hợp hơn nhưng cần cẩn trọng
- Không giữ lại giá trị trước đó → phù hợp với one-time event
- Nhưng nếu chưa có ai "listening" → event có thể bị mất
Cách khắc phục: thiết lập replay hoặc extraBufferCapacity
Channel: Giải pháp đơn giản và hiệu quả
Channel mang lại:
✅ Mỗi event chỉ được tiêu thụ một lần duy nhất
✅ Event xếp hàng chờ đến khi có UI collect
private val _events = Channel<String>()
val events = _events.receiveAsFlow()
fun sendEvent() {
viewModelScope.launch {
_events.send("open_settings")
}
}
Nhược điểm:
- Không phù hợp với mô hình MVI nghiêm ngặt
- Cần collect đúng theo vòng đời để tránh memory leak
Ví dụ thực tế: Channel kết hợp với Jetpack Compose
1. ViewModel
class MainViewModel : ViewModel() {
private val _eventChannel = Channel<UiEvent>()
val eventFlow = _eventChannel.receiveAsFlow()
fun onButtonClick() {
viewModelScope.launch {
_eventChannel.send(UiEvent.ShowToast("Hello from ViewModel!"))
}
}
sealed class UiEvent {
data class ShowToast(val message: String) : UiEvent()
}
}
2. Custom Composable: EventEffect
@Composable
fun <T> EventEffect(
flow: Flow<T>,
lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
lifecycleState: Lifecycle.State = Lifecycle.State.STARTED,
onEvent: (T) -> Unit
) {
LaunchedEffect(lifecycleOwner, flow) {
lifecycleOwner.lifecycle.repeatOnLifecycle(lifecycleState) {
withContext(Dispatchers.Main.immediate) {
flow.collect { event ->
onEvent(event)
}
}
}
}
}
3. UI: Lắng nghe event từ Channel
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
val context = LocalContext.current
EventEffect(flow = viewModel.eventFlow) { event ->
when (event) {
is MainViewModel.UiEvent.ShowToast -> {
Toast.makeText(context, event.message, Toast.LENGTH_SHORT).show()
}
}
}
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = { viewModel.onButtonClick() }) {
Text("Click me")
}
}
}
Kết quả:
- Người dùng nhấn nút → ViewModel gửi event → UI hiển thị Toast.
- Khi xoay màn hình → Toast không bị lặp lại.
- Event không bị mất nếu collect đúng cách.
Tóm tắt Best Practices
| Loại dữ liệu | Giải pháp phù hợp |
|---|---|
| State dài hạn | StateFlow |
| Sự kiện một lần | Channel hoặc SharedFlow (có buffer) |
Kết luận
Không có giải pháp nào là "hoàn hảo" cho mọi dự án. Hãy chọn giải pháp đơn giản, rõ ràng, phù hợp với kiến trúc và team của bạn. Quan trọng nhất, đừng để one-time event trở thành one-more-bug!

