DEV Community

ViO Tech
ViO Tech

Posted on

Jetpack Compose Senior Handbook 13 Kỹ Thuật Jetpack Compose Mà Android Senior Developer Sử Dụng Hàng Ngày

Chương 2: rememberUpdatedState – Giải Quyết Stale Callback Trong Coroutine, Effect Và AdMob Events

Giới thiệu

Một trong những lỗi khó phát hiện nhất trong Jetpack Compose là Stale State hoặc Stale Callback.

Ứng dụng vẫn chạy.

Không crash.

Không báo lỗi.

Nhưng logic hoạt động sai.

Đây là lý do rất nhiều Developer gặp các bug kiểu:

  • Callback gọi dữ liệu cũ
  • Timer không nhận state mới
  • AdMob callback xử lý sai dữ liệu
  • Coroutine vẫn giữ reference cũ
  • Event bị mất sau recomposition

Compose cung cấp một API chuyên dụng để giải quyết vấn đề này:

rememberUpdatedState()
Enter fullscreen mode Exit fullscreen mode

Stale Callback là gì?

Giả sử chúng ta có:

@Composable
fun SplashScreen(
    onTimeout: () -> Unit
) {
    LaunchedEffect(Unit) {
        delay(3000)
        onTimeout()
    }
}
Enter fullscreen mode Exit fullscreen mode

Thoạt nhìn hoàn toàn bình thường.

Nhưng hãy tưởng tượng:

T = 0s
onTimeout = A
Enter fullscreen mode Exit fullscreen mode

Sau đó:

T = 1s
Screen recomposition
onTimeout = B
Enter fullscreen mode Exit fullscreen mode

Coroutine vẫn đang chạy.

Sau 3 giây:

Coroutine gọi A
Enter fullscreen mode Exit fullscreen mode

thay vì:

Coroutine gọi B
Enter fullscreen mode Exit fullscreen mode

Đây chính là stale callback.


Vì sao xảy ra?

Khi LaunchedEffect được tạo:

LaunchedEffect(Unit)
Enter fullscreen mode Exit fullscreen mode

nó capture toàn bộ biến đang tồn tại lúc đó.

Ví dụ:

LaunchedEffect(Unit) {
    onTimeout()
}
Enter fullscreen mode Exit fullscreen mode

thực chất tương tự:

val captured = onTimeout

launch {
    captured()
}
Enter fullscreen mode Exit fullscreen mode

Nếu callback thay đổi sau đó:

onTimeout = newCallback
Enter fullscreen mode Exit fullscreen mode

Coroutine không biết điều này.


Giải pháp với rememberUpdatedState

@Composable
fun SplashScreen(
    onTimeout: () -> Unit
) {

    val currentOnTimeout by rememberUpdatedState(
        onTimeout
    )

    LaunchedEffect(Unit) {

        delay(3000)

        currentOnTimeout()
    }
}
Enter fullscreen mode Exit fullscreen mode

Bây giờ:

Coroutine giữ nguyên
Enter fullscreen mode Exit fullscreen mode

nhưng:

currentOnTimeout
Enter fullscreen mode Exit fullscreen mode

luôn trỏ tới callback mới nhất.


Điều gì xảy ra bên trong?

Không dùng:

Coroutine
 └── Callback A
Enter fullscreen mode Exit fullscreen mode

Dù recomposition:

Callback B
Callback C
Callback D
Enter fullscreen mode Exit fullscreen mode

Coroutine vẫn gọi:

A
Enter fullscreen mode Exit fullscreen mode

Dùng rememberUpdatedState:

Coroutine
 └── currentCallback
Enter fullscreen mode Exit fullscreen mode

Mỗi lần recomposition:

currentCallback -> B
currentCallback -> C
currentCallback -> D
Enter fullscreen mode Exit fullscreen mode

Coroutine luôn lấy giá trị mới nhất.


Ví dụ thực tế: Splash Screen

@Composable
fun SplashScreen(
    navigateHome: () -> Unit
) {

    val currentNavigateHome by rememberUpdatedState(
        navigateHome
    )

    LaunchedEffect(Unit) {

        delay(2000)

        currentNavigateHome()
    }
}
Enter fullscreen mode Exit fullscreen mode

Đây là pattern được dùng rất nhiều trong production.


Ví dụ thực tế: Auto Refresh

@Composable
fun DashboardScreen(
    refresh: suspend () -> Unit
) {

    val currentRefresh by rememberUpdatedState(
        refresh
    )

    LaunchedEffect(Unit) {

        while (true) {

            delay(60_000)

            currentRefresh()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Nếu ViewModel thay đổi callback:

refresh = newRefresh
Enter fullscreen mode Exit fullscreen mode

Coroutine vẫn sử dụng callback mới nhất.


Ví dụ thực tế: AdMob Rewarded Ads

Một bug khá phổ biến:

RewardedAd.show(
    activity
) {
    onRewardEarned(userId)
}
Enter fullscreen mode Exit fullscreen mode

Giả sử:

userId = A
Enter fullscreen mode Exit fullscreen mode

Sau đó user chuyển account:

userId = B
Enter fullscreen mode Exit fullscreen mode

Reward callback được gọi muộn hơn.

Kết quả:

Reward cho A
Enter fullscreen mode Exit fullscreen mode

thay vì:

Reward cho B
Enter fullscreen mode Exit fullscreen mode

Giải pháp:

val currentUserId by rememberUpdatedState(
    userId
)

RewardedAd.show(
    activity
) {
    rewardUser(currentUserId)
}
Enter fullscreen mode Exit fullscreen mode

Ví dụ thực tế: Event Analytics

@Composable
fun AnalyticsTracker(
    screenName: String
) {

    val currentScreenName by rememberUpdatedState(
        screenName
    )

    LaunchedEffect(Unit) {

        delay(5000)

        analytics.logScreen(
            currentScreenName
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Nếu user điều hướng rất nhanh:

Home
Profile
Settings
Enter fullscreen mode Exit fullscreen mode

Analytics vẫn ghi nhận màn hình mới nhất.


Khi nào nên dùng?

Với LaunchedEffect

LaunchedEffect(Unit)
Enter fullscreen mode Exit fullscreen mode

là trường hợp phổ biến nhất.


Với DisposableEffect

DisposableEffect(Unit)
Enter fullscreen mode Exit fullscreen mode

Với SideEffect

SideEffect { }
Enter fullscreen mode Exit fullscreen mode

Với callback SDK

Ví dụ:

  • AdMob
  • Firebase
  • RevenueCat
  • Facebook SDK
  • AppsFlyer

Với Timer

delay()
Enter fullscreen mode Exit fullscreen mode
ticker()
Enter fullscreen mode Exit fullscreen mode
countdown()
Enter fullscreen mode Exit fullscreen mode

Khi nào không nên dùng?

Nếu effect cần restart khi dữ liệu thay đổi.

Ví dụ:

LaunchedEffect(userId)
Enter fullscreen mode Exit fullscreen mode

Trong trường hợp này:

userId
Enter fullscreen mode Exit fullscreen mode

chính là key.

Bạn muốn coroutine khởi động lại.

Không cần:

rememberUpdatedState()
Enter fullscreen mode Exit fullscreen mode

Sai lầm phổ biến

Sai lầm #1

Dùng cho mọi state.

val currentName by rememberUpdatedState(name)
Enter fullscreen mode Exit fullscreen mode

Nhưng không dùng trong Effect nào.

=> Hoàn toàn vô nghĩa.


Sai lầm #2

Thay thế key của LaunchedEffect.

Sai:

val currentUser by rememberUpdatedState(user)

LaunchedEffect(Unit) {
    loadUser(currentUser)
}
Enter fullscreen mode Exit fullscreen mode

Nếu mục tiêu là reload user:

LaunchedEffect(user)
Enter fullscreen mode Exit fullscreen mode

mới là giải pháp đúng.


Sai lầm #3

Nhầm lẫn với remember

remember { callback }
Enter fullscreen mode Exit fullscreen mode

khác hoàn toàn:

rememberUpdatedState(callback)
Enter fullscreen mode Exit fullscreen mode

remember

Giữ nguyên giá trị đầu tiên
Enter fullscreen mode Exit fullscreen mode

rememberUpdatedState

Luôn cập nhật giá trị mới nhất
Enter fullscreen mode Exit fullscreen mode

Quy tắc Senior Compose

Một nguyên tắc cực kỳ hữu ích:

Nếu Coroutine hoặc Effect tồn tại lâu hơn một lần recomposition và cần sử dụng dữ liệu mới nhất, hãy cân nhắc rememberUpdatedState.

Đặc biệt với:

  • Splash Screen
  • Timer
  • Analytics
  • AdMob Callback
  • SDK Listener
  • Delayed Action

Kết luận

rememberUpdatedState là một API nhỏ nhưng cực kỳ quan trọng trong Compose.

Lợi ích:

  • Tránh stale callback
  • Tránh stale state
  • Giảm bug khó phát hiện
  • Giúp Coroutine luôn sử dụng dữ liệu mới nhất
  • Hoạt động rất tốt với AdMob, Analytics và SDK bên thứ ba

Đây là một kỹ thuật mà rất nhiều Android Developer bỏ qua cho đến khi gặp bug trong production.

Top comments (0)