Những hiệu ứng phụ trong ứng dụng Compose

Hiệu ứng phụ là sự thay đổi về trạng thái của ứng dụng diễn ra bên ngoài phạm vi của một hàm có khả năng kết hợp. Do vòng đời và các thuộc tính của thành phần kết hợp như các thành phần kết hợp lại không thể đoán trước, việc thực thi các thành phần kết hợp lại theo thứ tự khác nhau hoặc các thành phần kết hợp có thể bị loại bỏ, nên thành phần kết hợp tốt nhất là không có hiệu ứng phụ .

Tuy nhiên, đôi khi cũng cần có các hiệu ứng phụ, chẳng hạn để kích hoạt các sự kiện một lần như hiển thị thanh thông báo nhanh hoặc điều hướng đến một màn hình khác trong điều kiện trạng thái nhất định. Các hành động này nên được gọi từ một môi trường được kiểm soát và có nhận thức về vòng đời của thành phần kết hợp đó. Trong trang này, bạn sẽ tìm hiểu về một số hiệu ứng phụ khác của API Jetpack Compose.

Các trường hợp sử dụng trạng thái và hiệu ứng

Như đã nêu trong tài liệu Suy nghĩ trong Compose, các thành phần kết hợp không nên có hiệu ứng phụ (side-effect). Khi bạn cần thay đổi trạng thái của ứng dụng (như được mô tả trong tài liệu Tài liệu quản lý trạng thái), bạn nên sử dụng Effect API để những hiệu ứng phụ đó được thực hiện theo cách dễ dự đoán.

Do khả năng các hiệu ứng được mở ra trong ứng dụng Compose là khác nhau, chúng có thể dễ dàng bị lạm dụng. Hãy đảm bảo rằng công việc bạn thực hiện trong đó có liên quan đến giao diện người dùng và không làm hỏng luồng dữ liệu một chiều như đã giải thích trong tài liệu về Quản lý trạng thái.

LaunchedEffect: chạy các hàm tạm ngưng trong phạm vi của một thành phần kết hợp

Để gọi các chức năng tạm ngưng một cách an toàn từ bên trong một thành phần kết hợp, hãy sử dụng thành phần kết hợp LaunchedEffect Khi LaunchedEffect nhập một thành phần, công cụ này sẽ khởi chạy một coroutine với khối mã được truyền dưới dạng tham số. Coroutine sẽ bị huỷ nếu LaunchedEffect thoát khỏi thành phần đó. Nếu LaunchedEffectđược kết hợp lại với các khoá khác nhau (xem phần Hiệu ứng khởi động lại bên dưới), coroutine hiện có sẽ bị huỷ và chức năng tạm ngưng mới sẽ được khởi chạy trong coroutine mới.

Ví dụ, việc hiển thị Snackbar trong Scaffold được hoàn tất với hàm SnackbarHostState.showSnackbar, đây là chức năng tạm ngưng.

@Composable
fun MyScreen(
    state: UiState<List<Movie>>,
    snackbarHostState: SnackbarHostState
) {

    // If the UI state contains an error, show snackbar
    if (state.hasError) {

        // `LaunchedEffect` will cancel and re-launch if
        // `scaffoldState.snackbarHostState` changes
        LaunchedEffect(snackbarHostState) {
            // Show snackbar using a coroutine, when the coroutine is cancelled the
            // snackbar will automatically dismiss. This coroutine will cancel whenever
            // `state.hasError` is false, and only start when `state.hasError` is true
            // (due to the above if-check), or if `scaffoldState.snackbarHostState` changes.
            snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        }
    }

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        // ...
    }
}

Trong đoạn mã trên, một coroutine được kích hoạt nếu trạng thái đó chứa lỗi và sẽ được huỷ trong trường hợp không phải. Do nơi hàm được gọi LaunchedEffect nằm trong một câu lệnh if, khi câu lệnh này sai, nếu LaunchedEffect nằm trong thành phần, thì câu lệnh đó sẽ bị xoá và do đó, coroutine sẽ bị huỷ.

rememberCoroutineScope: lấy phạm vi nhận biết thành phần để khởi chạy coroutine bên ngoài một thành phần kết hợp

Do LaunchedEffect là một hàm có thể kết hợp, nên bạn chỉ có thể sử dụng hàm này bên trong các hàm có thể kết hợp khác. Để khởi chạy một coroutine bên ngoài một thành phần kết hợp, nhưng trong phạm vi mà nội dung đó tự động được huỷ sau khi rời khỏi thành phần, hãy sử dụng rememberCoroutineScope. Ngoài ra, hãy sử dụng rememberCoroutineScope bất cứ khi nào bạn cần kiểm soát vòng đời của một hoặc nhiều coroutine theo cách thủ công, ví dụ như việc huỷ một ảnh động khi sự kiện người dùng xảy ra.

rememberCoroutineScope là một hàm có thể kết hợp có thể trả về giới hạn CoroutineScope tới điểm thành phần được gọi. Phạm vi này sẽ bị huỷ khi cuộc gọi thoát khỏi thành phần đó.

Tiếp theo sau ví dụ trước, bạn có thể sử dụng mã này để hiển thị một Snackbarkhi người dùng nhấn vào một Button:

@Composable
fun MoviesScreen(snackbarHostState: SnackbarHostState) {

    // Creates a CoroutineScope bound to the MoviesScreen's lifecycle
    val scope = rememberCoroutineScope()

    Scaffold(
        snackbarHost = {
            SnackbarHost(hostState = snackbarHostState)
        }
    ) { contentPadding ->
        Column(Modifier.padding(contentPadding)) {
            Button(
                onClick = {
                    // Create a new coroutine in the event handler to show a snackbar
                    scope.launch {
                        snackbarHostState.showSnackbar("Something happened!")
                    }
                }
            ) {
                Text("Press me")
            }
        }
    }
}

rememberUpdatedState: tham chiếu một giá trị trong một hiệu ứng không nên khởi động lại nếu giá trị đó thay đổi

LaunchedEffect khởi động lại khi một trong các tham số chính thay đổi. Tuy nhiên, trong một số trường hợp, bạn có thể muốn thu thập một giá trị trong hiệu ứng của mình. Nếu giá trị đó thay đổi, bạn không muốn hiệu ứng khởi động lại. Để làm điều này, bạn phải sử dụng rememberUpdatedState để tạo tệp tham chiếu đến giá trị này. Giá trị này có thể được thu thập và cập nhật. Cách tiếp cận này hữu ích đối với những hiệu ứng chứa các thao tác dài hạn có thể gây tốn kém hoặc hạn chế khôi phục lại và bắt đầu lại.

Ví dụ, giả sử ứng dụng của bạn có một LandingScreen biến mất sau một khoảng thời gian. Ngay cả khi LandingScreen được kết hợp lại, không nên khởi chạy lại hiệu ứng cần chờ một thời gian và thông báo về thời gian đã trôi qua:

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

    // This will always refer to the latest onTimeout function that
    // LandingScreen was recomposed with
    val currentOnTimeout by rememberUpdatedState(onTimeout)

    // Create an effect that matches the lifecycle of LandingScreen.
    // If LandingScreen recomposes, the delay shouldn't start again.
    LaunchedEffect(true) {
        delay(SplashWaitTimeMillis)
        currentOnTimeout()
    }

    /* Landing screen content */
}

Để tạo một hiệu ứng phù hợp với vòng đời của nơi hàm được gọi, một hằng số không bao giờ thay đổi như Unit hoặc true sẽ được truyền dưới dạng một tham số. Trong mã trên, LaunchedEffect(true) được sử dụng. Để đảm bảo rằng lambda onTimeout luôn chứa giá trị mới nhấtLandingScreen được kết hợp lại, onTimeout cần được gói với hàm rememberUpdatedState. State, currentOnTimeout được trả về trong mã, phải được sử dụng trong hiệu ứng.

DisposableEffect: những hiệu ứng yêu cầu xoá

Đối với các hiệu ứng phụ cần được loại bỏ sau khi các khoá thay đổi hoặc nếu thành phần kết hợp thoát khởi thành phần, hãy sử dụng DisposableEffect. Nếu các khoá DisposableEffect thay đổi, thì thành phần kết hợp cần phải xử lý (thực hiện việc dọn dẹp) hiệu ứng hiện tại và đặt lại bằng cách gọi lại hiệu ứng đó.

Ví dụ, bạn có thể muốn gửi các sự kiện phân tích dựa trên các sự kiện Lifecycle bằng cách sử dụng LifecycleObserver. Để theo dõi các sự kiện đó trong ứng dụng Compose, hãy sử dụng DisposableEffect để đăng ký và huỷ đăng ký người quan sát khi cần.

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // Safely update the current lambdas when a new one is provided
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    // If `lifecycleOwner` changes, dispose and reset the effect
    DisposableEffect(lifecycleOwner) {
        // Create an observer that triggers our remembered callbacks
        // for sending analytics events
        val observer = LifecycleEventObserver { _, event ->
            if (event == Lifecycle.Event.ON_START) {
                currentOnStart()
            } else if (event == Lifecycle.Event.ON_STOP) {
                currentOnStop()
            }
        }

        // Add the observer to the lifecycle
        lifecycleOwner.lifecycle.addObserver(observer)

        // When the effect leaves the Composition, remove the observer
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    /* Home screen content */
}

Trong mã trên, hiệu ứng sẽ thêm observer vào lifecycleOwner. Nếu lifecycleOwner thay đổi, hiệu ứng này sẽ được xử lý và được bắt đầu lại với lifecycleOwner mới.

DisposableEffect phải bao gồm mệnh đề onDispose làm câu lệnh cuối cùng trong khối mã. Nếu không, IDE sẽ hiển thị lỗi thời gian tạo.

SideEffect: xuất bản trạng thái Compose thành các mã không phải Compose

Để chia sẻ trạng thái Compose với các đối tượng không được quản lý bằng Compose, hãy sử dụng thành phần kết hợp SideEffect. Việc sử dụng SideEffect đảm bảo rằng hiệu ứng này sẽ thực thi sau mỗi lần kết hợp lại thành công. Mặt khác, việc thực hiện hiệu ứng trước khi đảm bảo quá trình kết hợp lại thành công sẽ không chính xác, đây là trường hợp khi viết hiệu ứng trực tiếp trong một thành phần kết hợp.

Ví dụ, thư viện phân tích của bạn có thể cho phép bạn phân đoạn dân số người dùng bằng cách đính kèm siêu dữ liệu tuỳ chỉnh ("thuộc tính người dùng" trong ví dụ này) vào tất cả các sự kiện phân tích tiếp theo. Để truyền đạt thông tin loại người dùng của người dùng hiện tại cho thư viện phân tích của bạn, hãy sử dụng SideEffect để cập nhật giá trị.

@Composable
fun rememberFirebaseAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        FirebaseAnalytics()
    }

    // On every successful composition, update FirebaseAnalytics with
    // the userType from the current User, ensuring that future analytics
    // events have this metadata attached
    SideEffect {
        analytics.setUserProperty("userType", user.userType)
    }
    return analytics
}

produceState: chuyển đổi trạng thái không phải Compose sang trạng thái Compose

produceState khởi chạy một coroutine trong phạm vi mà thành phần có thể đẩy các giá trị vào State được trả về. Sử dụng nó để chuyển đổi trạng thái không phải Compose sang trạng thái Compose, ví dụ như đưa trạng thái đăng ký bên ngoài như Flow, LiveData hoặc RxJava vào thành phần.

Nguồn được khởi chạy khi produceState tham gia thành phần và sẽ bị huỷ khi thoát khỏi thành phần. Giá trị State được trả về sẽ kết hợp; nên việc đặt cùng một giá trị sẽ không kích hoạt tái cấu trúc.

Mặc dù produceState tạo một coroutine, nhưng nó cũng có thể được sử dụng để quan sát các nguồn dữ liệu không tạm ngưng. Để xoá gói đăng ký khỏi nguồn đó, hãy sử dụng hàm awaitDispose.

Ví dụ sau cho biết cách sử dụng produceState để tải hình ảnh từ mạng. Hàm có thể kết hợp loadNetworkImage trả về một State có thể được sử dụng trong các thành phần kết hợp khác.

@Composable
fun loadNetworkImage(
    url: String,
    imageRepository: ImageRepository = ImageRepository()
): State<Result<Image>> {

    // Creates a State<T> with Result.Loading as initial value
    // If either `url` or `imageRepository` changes, the running producer
    // will cancel and will be re-launched with the new inputs.
    return produceState<Result<Image>>(initialValue = Result.Loading, url, imageRepository) {

        // In a coroutine, can make suspend calls
        val image = imageRepository.load(url)

        // Update State with either an Error or Success result.
        // This will trigger a recomposition where this State is read
        value = if (image == null) {
            Result.Error
        } else {
            Result.Success(image)
        }
    }
}

derivedStateOf: chuyển đổi một hoặc nhiều đối tượng trạng thái thành trạng thái khác

Trong Compose, việc kết hợp lại (recomposition) xảy ra mỗi khi một đối tượng trạng thái được quan sát hoặc dữ liệu đầu vào của thành phần kết hợp thay đổi. Đối tượng trạng thái hoặc dữ liệu đầu vào có thể thay đổi thường xuyên hơn mức mà giao diện người dùng thực sự cần cập nhật, dẫn đến việc kết hợp lại một cách không cần thiết.

Bạn nên dùng hàm derivedStateOf khi dữ liệu đầu vào cho một thành phần kết hợp thay đổi nhiều hơn mức cần thiết để kết hợp lại. Điều này thường xảy ra khi một nội dung nào đó thường xuyên thay đổi (ví dụ: một vị trí cuộn) nhưng thành phần kết hợp chỉ cần phản ứng lại với nội dung đó khi vượt qua một ngưỡng nhất định. derivedStateOf tạo một đối tượng trạng thái Compose mới mà bạn có thể quan sát thấy rằng đối tượng này chỉ cập nhật theo tần suất cần thiết. Theo cách này, đối tượng này hoạt động tương tự như toán tử distinctUntilChanged() của Luồng Kotlin.

Cách sử dụng đúng

Đoạn mã sau đây cho thấy một trường hợp sử dụng thích hợp cho derivedStateOf:

@Composable
// When the messages parameter changes, the MessageList
// composable recomposes. derivedStateOf does not
// affect this recomposition.
fun MessageList(messages: List<Message>) {
    Box {
        val listState = rememberLazyListState()

        LazyColumn(state = listState) {
            // ...
        }

        // Show the button if the first visible item is past
        // the first item. We use a remembered derived state to
        // minimize unnecessary compositions
        val showButton by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex > 0
            }
        }

        AnimatedVisibility(visible = showButton) {
            ScrollToTopButton()
        }
    }
}

Trong đoạn mã, firstVisibleItemIndex thay đổi bất cứ khi nào mục hiển thị đầu tiên thay đổi. Khi cuộn, giá trị này trở thành 0, 1, 2, 3, 4, 5, v.v. Tuy nhiên, chỉ cần kết hợp lại nếu giá trị đó lớn hơn 0. Sự không phù hợp về tần suất cập nhật này có nghĩa đây là trường hợp sử dụng tốt cho derivedStateOf.

Cách sử dụng sai

Một sai lầm phổ biến là cho rằng nên sử dụng derivedStateOf khi kết hợp hai đối tượng trạng thái Compose, vì bạn đang ở "trạng thái dẫn xuất". Tuy nhiên, việc này thuần tuý là hao tổn tài nguyên và không bắt buộc, như thể hiện trong đoạn mã sau:

// DO NOT USE. Incorrect usage of derivedStateOf.
var firstName by remember { mutableStateOf("") }
var lastName by remember { mutableStateOf("") }

val fullNameBad by remember { derivedStateOf { "$firstName $lastName" } } // This is bad!!!
val fullNameCorrect = "$firstName $lastName" // This is correct

Trong đoạn mã này, fullName cần cập nhật với tần suất bằng với tần suất của firstNamelastName. Do đó, không xảy ra tình trạng kết hợp lại quá mức và việc sử dụng derivedStateOf là không cần thiết.

snapshotFlow: chuyển đổi Trạng thái của Compose thành Luồng

Sử dụng snapshotFlow để chuyển đổi các đối tượng State<T> thành Flow lạnh. snapshotFlow chạy khối của mình khi được thu thập và tạo ra kết quả của các đối tượng State được đọc trong đó. Khi một trong các đối tượng State được đọc bên trong khối snapshotFlow thay đổi, Flow sẽ tạo ra giá trị mới cho bộ sưu tập của nó nếu giá trị mới không bằng giá trị được tạo ra trước đó (hành vi này tương tự như hành vi của Flow.distinctUntilChanged).

Ví dụ sau đây cho thấy một hiệu ứng phụ ghi lại thời điểm người dùng cuộn qua mục đầu tiên trong danh sách để gửi tới phân tích

val listState = rememberLazyListState()

LazyColumn(state = listState) {
    // ...
}

LaunchedEffect(listState) {
    snapshotFlow { listState.firstVisibleItemIndex }
        .map { index -> index > 0 }
        .distinctUntilChanged()
        .filter { it == true }
        .collect {
            MyAnalyticsService.sendScrolledPastFirstItemEvent()
        }
}

Trong mã trên, listState.firstVisibleItemIndex được chuyển đổi thành một Flow có thể hưởng lợi từ sức mạnh của các toán tử của Flow.

Các hiệu ứng tái khởi động

Một số hiệu ứng trong ứng dụng Compose, như LaunchedEffect, produceState, hoặc DisposableEffect, sử dụng một số lượng biến số, các khoá để huỷ bỏ hiệu ứng đang chạy và bắt đầu một hiệu ứng mới bằng các khoá mới.

Biểu mẫu thông thường cho các API này là:

EffectName(restartIfThisKeyChanges, orThisKey, orThisKey, ...) { block }

Do sự tinh vi của hành vi này, các sự cố có thể xảy ra nếu các tham số được sử dụng để khởi động lại hiệu ứng không phải là tham số phù hợp:

  • Việc tái khởi động các hiệu ứng ít hơn mức cần thiết có thể gây ra lỗi trong ứng dụng của bạn.
  • Việc tái khởi động các hiệu ứng nhiều hơn mức cần thiết có thể không hiệu quả.

Theo quy tắc chung, các biến có thể thay đổi và không thể thay đổi được dùng trong khối hiệu ứng của mã nên được thêm dưới dạng tham số vào thành phần kết hợp hiệu ứng. Ngoài những tham số đó, bạn có thể thêm nhiều tham số hơn để thúc đẩy tái khởi động hiệu ứng. Nếu biến thay đổi không làm hiệu ứng khởi động lại, biến đó nên được bọc trong rememberUpdatedState. Nếu biến không bao giờ thay đổi do biến được bọc trong một remember không có khoá, bạn không cần truyền biến dưới dạng khoá cho hiệu ứng đó.

Trong mã DisposableEffect hiển thị ở trên, hiệu ứng này được lấy làm một tham số mà lifecycleOwner đã sử dụng trong khối của nó, vì mọi thay đổi đối với chúng sẽ khiến hiệu ứng khởi động lại.

@Composable
fun HomeScreen(
    lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
    onStart: () -> Unit, // Send the 'started' analytics event
    onStop: () -> Unit // Send the 'stopped' analytics event
) {
    // These values never change in Composition
    val currentOnStart by rememberUpdatedState(onStart)
    val currentOnStop by rememberUpdatedState(onStop)

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            /* ... */
        }

        lifecycleOwner.lifecycle.addObserver(observer)
        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }
}

Bạn không cần dùng khóa currentOnStartcurrentOnStop làm khóa DisposableEffect, vì giá trị của các khóa này không bao giờ thay đổi trong thành phần do việc sử dụng rememberUpdatedState. Nếu bạn không truyền lifecycleOwner dưới dạng tham số và tham số đó sẽ thay đổi, thì HomeScreen sẽ tái cấu trúc nhưng DisposableEffect không được xử lý và khởi động lại. Điều đó gây ra các sự cố vì lifecycleOwner sai được sử dụng từ thời điểm đó trở đi.

Hằng số dưới dạng các khoá

Bạn có thể sử dụng một hằng số như true làm khoá hiệu ứng để khiến nó tuân theo vòng đời của nơi hàm được gọi . Có các trường hợp sử dụng hợp lệ cho thuộc tính này, như ví dụ LaunchedEffect ở trên. Tuy nhiên, trước khi thực hiện, hãy nghĩ kỹ và chắc chắn là bạn cần làm vậy.