Side Effects trong Jetpack Compose
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ì?
Đị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?
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
@Composable
fun BadExample(viewModel: MyViewModel) {
viewModel.loadData() // Gọi mỗi recomposition!
Text("Loading...")
}
// ✅ ĐÚNG: Dùng Effect API
@Composable
fun GoodExample(viewModel: MyViewModel) {
LaunchedEffect(Unit) {
viewModel.loadData() // Chỉ gọi 1 lần
}
Text("Loading...")
}2. LaunchedEffect - Chạy suspend functions
Cách sử dụng
LaunchedEffect tạo coroutine scope và chạy suspend functions:
@Composable
fun 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
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
// Key là Unit: Chỉ chạy 1 lần khi enter composition
LaunchedEffect(Unit) {
loadInitialData()
}
// Key là state: Restart khi state thay đổi
LaunchedEffect(userId) {
loadUserData(userId)
}
// Nhiều keys: Restart khi bất kỳ key nào thay đổi
LaunchedEffect(userId, filter) {
loadFilteredData(userId, filter)
}
// Key là true: "Bật/tắt" effect
LaunchedEffect(isPolling) {
if (isPolling) {
while (true) {
pollData()
delay(5000)
}
}
}Ví dụ: Animation loop
@Composable
fun 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
Khi nào dùng?
Khi bạn cần launch coroutine từ event callback (onClick, etc.):
@Composable
fun 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
| 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
Khi nào dùng?
Khi bạn cần cleanup resources khi composable leave composition:
@Composable
fun 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
@Composable
fun 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
Khi nào dùng?
Khi bạn cần publish Compose state ra non-Compose code sau mỗi successful recomposition:
@Composable
fun AnalyticsScreen(screenName: String) {
// Chỉ chạy sau recomposition thành công
SideEffect {
Analytics.setCurrentScreen(screenName)
}
// UI content
}Ví dụ: Logging
@Composable
fun 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
Vấn đề
Khi LaunchedEffect chạy lâu, nó có thể giữ reference cũ:
// ❌ SAI: onTimeout là closure capture -> có thể outdated
@Composable
fun Timer(onTimeout: () -> Unit) {
LaunchedEffect(Unit) {
delay(5000)
onTimeout() // Có thể gọi callback cũ!
}
}Giải pháp
// ✅ ĐÚNG: Dùng rememberUpdatedState
@Composable
fun 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?
- 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
Cách dùng
produceState chuyển đổi Flow, callback-based APIs, etc. thành Compose State:
@Composable
fun 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
@Composable
fun 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
@Composable
fun <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
Vấn đề
// ❌ SAI: Tính toán mỗi recomposition (dù items không đổi)
@Composable
fun ShoppingCart(items: List<Item>) {
val total = items.sumOf { it.price } // Expensive!
Text("Total: $total")
}Giải pháp
// ✅ ĐÚNG: Chỉ tính lại khi items thay đổi
@Composable
fun ShoppingCart(items: List<Item>) {
val total by remember {
derivedStateOf { items.sumOf { it.price } }
}
Text("Total: $total")
}Ví dụ: Show FAB khi scroll
@Composable
fun 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
Cách dùng
@Composable
fun ScrollAnalytics(listState: LazyListState) {
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.collect { index ->
Analytics.logScroll(index)
}
}
}Ví dụ: Debounce search
@Composable
fun 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
| 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?
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 → snapshotFlowLast updated on