Side Effects
Side effects là các thao tác ảnh hưởng đến bên ngoài scope của composable function. Compose cung cấp các APIs đặc biệt để xử lý side effects một cách an toàn.
1. Side Effects là gì?
Phần tiêu đề “1. Side Effects là gì?”Định nghĩa
Phần tiêu đề “Định nghĩa”Side effect là bất kỳ thay đổi nào visible bên ngoài composable function:
- Gọi API hoặc database
- Cập nhật shared state
- Ghi log, analytics
- Đăng ký/hủy đăng ký listeners
- Navigate
Tại sao Composable không nên có Side Effects?
Phần tiêu đề “Tại sao Composable không nên có Side Effects?”Composables có thể:
- Chạy nhiều lần (recomposition)
- Chạy song song trên nhiều threads
- Bị hủy giữa chừng
- Chạy theo bất kỳ thứ tự nào
// ❌ SAI: Side effect trực tiếp trong composable@Composablefun BadExample(viewModel: MyViewModel) { viewModel.loadData() // Gọi mỗi recomposition! Text("Loading...")}
// ✅ ĐÚNG: Dùng Effect API@Composablefun GoodExample(viewModel: MyViewModel) { LaunchedEffect(Unit) { viewModel.loadData() // Chỉ gọi 1 lần } Text("Loading...")}2. LaunchedEffect - Chạy suspend functions
Phần tiêu đề “2. LaunchedEffect - Chạy suspend functions”Cách sử dụng
Phần tiêu đề “Cách sử dụng”LaunchedEffect tạo coroutine scope và chạy suspend functions:
@Composablefun SnackbarDemo(message: String?) { val snackbarHostState = remember { SnackbarHostState() }
// Chạy khi message thay đổi (và không null) LaunchedEffect(message) { message?.let { snackbarHostState.showSnackbar(it) } }
Scaffold( snackbarHost = { SnackbarHost(snackbarHostState) } ) { }}Lifecycle của LaunchedEffect
Phần tiêu đề “Lifecycle của LaunchedEffect”LaunchedEffect enters composition │ ├── Coroutine bắt đầu │ ├── Key thay đổi? │ ├── Yes: Cancel coroutine cũ → Start mới │ └── No: Tiếp tục chạy │ └── Leave composition → Cancel coroutineCác loại Keys
Phần tiêu đề “Các loại Keys”// Key là Unit: Chỉ chạy 1 lần khi enter compositionLaunchedEffect(Unit) { loadInitialData()}
// Key là state: Restart khi state thay đổiLaunchedEffect(userId) { loadUserData(userId)}
// Nhiều keys: Restart khi bất kỳ key nào thay đổiLaunchedEffect(userId, filter) { loadFilteredData(userId, filter)}
// Key là true: "Bật/tắt" effectLaunchedEffect(isPolling) { if (isPolling) { while (true) { pollData() delay(5000) } }}Ví dụ: Animation loop
Phần tiêu đề “Ví dụ: Animation loop”@Composablefun PulsingDot(pulseRateMs: Long) { val alpha = remember { Animatable(1f) }
LaunchedEffect(pulseRateMs) { while (isActive) { delay(pulseRateMs) alpha.animateTo(0.3f) alpha.animateTo(1f) } }
Box( modifier = Modifier .size(50.dp) .alpha(alpha.value) .background(Color.Red, CircleShape) )}3. rememberCoroutineScope - Coroutine từ callbacks
Phần tiêu đề “3. rememberCoroutineScope - Coroutine từ callbacks”Khi nào dùng?
Phần tiêu đề “Khi nào dùng?”Khi bạn cần launch coroutine từ event callback (onClick, etc.):
@Composablefun ScrollToTopButton(listState: LazyListState) { // Tạo scope gắn với lifecycle của composable này val scope = rememberCoroutineScope()
Button(onClick = { // Launch từ callback, không phải từ composition scope.launch { listState.animateScrollToItem(0) } }) { Text("Scroll to Top") }}So sánh LaunchedEffect vs rememberCoroutineScope
Phần tiêu đề “So sánh LaunchedEffect vs rememberCoroutineScope”| LaunchedEffect | rememberCoroutineScope |
|---|---|
| Chạy trong composition | Chạy từ callbacks |
| Tự động trigger bởi key | Manual trigger |
| Không dùng được trong onClick | Dùng trong onClick |
4. DisposableEffect - Setup và Cleanup
Phần tiêu đề “4. DisposableEffect - Setup và Cleanup”Khi nào dùng?
Phần tiêu đề “Khi nào dùng?”Khi bạn cần cleanup resources khi composable leave composition:
@Composablefun LifecycleObserver(onStart: () -> Unit, onStop: () -> Unit) { val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) { val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_START -> onStart() Lifecycle.Event.ON_STOP -> onStop() else -> {} } }
// Setup: đăng ký observer lifecycleOwner.lifecycle.addObserver(observer)
// Cleanup: hủy đăng ký khi leave composition hoặc key thay đổi onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } }}Ví dụ: Back press handler
Phần tiêu đề “Ví dụ: Back press handler”@Composablefun BackHandler(enabled: Boolean = true, onBack: () -> Unit) { val currentOnBack by rememberUpdatedState(onBack) val backCallback = remember { object : OnBackPressedCallback(enabled) { override fun handleOnBackPressed() { currentOnBack() } } }
val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
DisposableEffect(backDispatcher) { backDispatcher?.addCallback(backCallback)
onDispose { backCallback.remove() // Cleanup! } }
// Update enabled state LaunchedEffect(enabled) { backCallback.isEnabled = enabled }}5. SideEffect - Sync với non-Compose code
Phần tiêu đề “5. SideEffect - Sync với non-Compose code”Khi nào dùng?
Phần tiêu đề “Khi nào dùng?”Khi bạn cần publish Compose state ra non-Compose code sau mỗi successful recomposition:
@Composablefun AnalyticsScreen(screenName: String) { // Chỉ chạy sau recomposition thành công SideEffect { Analytics.setCurrentScreen(screenName) }
// UI content}Ví dụ: Logging
Phần tiêu đề “Ví dụ: Logging”@Composablefun CounterWithLogging() { var count by remember { mutableStateOf(0) }
// Log sau mỗi successful recomposition SideEffect { Log.d("Counter", "Current count: $count") }
Button(onClick = { count++ }) { Text("Count: $count") }}6. rememberUpdatedState - Giữ reference mới nhất
Phần tiêu đề “6. rememberUpdatedState - Giữ reference mới nhất”Vấn đề
Phần tiêu đề “Vấn đề”Khi LaunchedEffect chạy lâu, nó có thể giữ reference cũ:
// ❌ SAI: onTimeout là closure capture -> có thể outdated@Composablefun Timer(onTimeout: () -> Unit) { LaunchedEffect(Unit) { delay(5000) onTimeout() // Có thể gọi callback cũ! }}Giải pháp
Phần tiêu đề “Giải pháp”// ✅ ĐÚNG: Dùng rememberUpdatedState@Composablefun Timer(onTimeout: () -> Unit) { val currentOnTimeout by rememberUpdatedState(onTimeout)
LaunchedEffect(Unit) { delay(5000) currentOnTimeout() // Luôn gọi callback mới nhất! }}Khi nào dùng?
Phần tiêu đề “Khi nào dùng?”- Callback có thể thay đổi nhưng bạn không muốn restart effect
- Long-running effects cần giữ reference mới nhất
7. produceState - Chuyển đổi sang Compose State
Phần tiêu đề “7. produceState - Chuyển đổi sang Compose State”Cách dùng
Phần tiêu đề “Cách dùng”produceState chuyển đổi Flow, callback-based APIs, etc. thành Compose State:
@Composablefun NetworkImage(url: String): State<ImageResult> { return produceState<ImageResult>( initialValue = ImageResult.Loading, key1 = url ) { value = try { val image = imageLoader.load(url) ImageResult.Success(image) } catch (e: Exception) { ImageResult.Error(e) } }}
// Sử dụng@Composablefun Avatar(url: String) { val imageState by NetworkImage(url)
when (val result = imageState) { is ImageResult.Loading -> CircularProgressIndicator() is ImageResult.Success -> Image(result.image) is ImageResult.Error -> ErrorPlaceholder() }}Với Flow
Phần tiêu đề “Với Flow”@Composablefun <T> Flow<T>.collectAsStateWithLifecycle( initial: T): State<T> { val lifecycleOwner = LocalLifecycleOwner.current
return produceState(initialValue = initial) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { collect { value = it } } }}8. derivedStateOf - Tính toán từ state khác
Phần tiêu đề “8. derivedStateOf - Tính toán từ state khác”Vấn đề
Phần tiêu đề “Vấn đề”// ❌ SAI: Tính toán mỗi recomposition (dù items không đổi)@Composablefun ShoppingCart(items: List<Item>) { val total = items.sumOf { it.price } // Expensive! Text("Total: $total")}Giải pháp
Phần tiêu đề “Giải pháp”// ✅ ĐÚNG: Chỉ tính lại khi items thay đổi@Composablefun ShoppingCart(items: List<Item>) { val total by remember { derivedStateOf { items.sumOf { it.price } } } Text("Total: $total")}Ví dụ: Show FAB khi scroll
Phần tiêu đề “Ví dụ: Show FAB khi scroll”@Composablefun ScrollableFab() { val listState = rememberLazyListState()
// Chỉ update khi điều kiện thay đổi, không phải mỗi scroll pixel val showFab by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } }
Scaffold( floatingActionButton = { if (showFab) { FloatingActionButton(onClick = { }) { Icon(Icons.Default.KeyboardArrowUp, null) } } } ) { LazyColumn(state = listState) { items(100) { Text("Item $it") } } }}9. snapshotFlow - Chuyển State thành Flow
Phần tiêu đề “9. snapshotFlow - Chuyển State thành Flow”Cách dùng
Phần tiêu đề “Cách dùng”@Composablefun ScrollAnalytics(listState: LazyListState) { LaunchedEffect(listState) { snapshotFlow { listState.firstVisibleItemIndex } .distinctUntilChanged() .collect { index -> Analytics.logScroll(index) } }}Ví dụ: Debounce search
Phần tiêu đề “Ví dụ: Debounce search”@Composablefun SearchScreen() { var query by remember { mutableStateOf("") } var results by remember { mutableStateOf<List<Result>>(emptyList()) }
LaunchedEffect(Unit) { snapshotFlow { query } .debounce(300) // Debounce 300ms .filter { it.length >= 3 } .distinctUntilChanged() .collectLatest { searchQuery -> results = searchApi.search(searchQuery) } }
Column { TextField(value = query, onValueChange = { query = it }) LazyColumn { items(results) { ResultItem(it) } } }}📝 Tóm tắt
Phần tiêu đề “📝 Tóm tắt”| Effect | Mục đích | Key behavior |
|---|---|---|
| LaunchedEffect | Suspend functions | Restart khi key thay đổi |
| rememberCoroutineScope | Coroutine từ callbacks | Manual trigger |
| DisposableEffect | Setup/cleanup resources | onDispose được gọi |
| SideEffect | Sync với non-Compose | Sau mỗi successful recomposition |
| rememberUpdatedState | Giữ reference mới nhất | Không trigger restart |
| produceState | Chuyển thành Compose State | Automatic State |
| derivedStateOf | Tính toán từ states | Lazy evaluation |
| snapshotFlow | State → Flow | Emit khi state thay đổi |
Quyết định dùng Effect nào?
Phần tiêu đề “Quyết định dùng Effect nào?”Cần chạy suspend function?├── Từ composition → LaunchedEffect└── Từ callback → rememberCoroutineScope
Cần cleanup?└── DisposableEffect với onDispose
Cần sync state ra ngoài?└── SideEffect
Cần chuyển đổi data sources?├── Non-Compose → Compose State → produceState├── State → State (derived) → derivedStateOf└── State → Flow → snapshotFlow