Published on

One-Time Events trong Android: Giải Pháp Hiệu Quả với Channel

One-Time Events trong Android: Giải Pháp Hiệu Quả với Channel

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 & SingleLiveEvent
  • StateFlow & SharedFlow
  • Channel

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")  
}
  • StateFlow giữ 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ề null sau 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ệuGiải pháp phù hợp
State dài hạnStateFlow
Sự kiện một lầnChannel 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!