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 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 chức năng 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>>,
    scaffoldState: ScaffoldState = rememberScaffoldState()
) {

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

        // `LaunchedEffect` will cancel and re-launch if
        // `scaffoldState.snackbarHostState` changes
        LaunchedEffect(scaffoldState.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.
            scaffoldState.snackbarHostState.showSnackbar(
                message = "Error message",
                actionLabel = "Retry message"
            )
        }
    }

    Scaffold(scaffoldState = scaffoldState) {
        /* ... */
    }
}

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: thu được một 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(scaffoldState: ScaffoldState = rememberScaffoldState()) {

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

    Scaffold(scaffoldState = scaffoldState) {
        Column {
            /* ... */
            Button(
                onClick = {
                    // Create a new coroutine in the event handler to show a snackbar
                    scope.launch {
                        scaffoldState.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 loại bỏ

Đố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ởi Compose, hãy sử dụng thành phần kết hợp SideEffect vì nó được dẫn ra trong tất cả những lần tái cấu trúc thành công.

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 rememberAnalytics(user: User): FirebaseAnalytics {
    val analytics: FirebaseAnalytics = remember {
        /* ... */
    }

    // 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
}

productsState: chuyển 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
): 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

Sử dụng derivedStateOf khi một trạng thái nhất định được tính toán hoặc lấy từ các đối tượng trạng thái khác. Việc sử dụng hàm này đảm bảo rằng việc tính toán sẽ chỉ xảy ra bất cứ khi nào một trong các trạng thái dùng trong phép tính thay đổi.

Ví dụ sau đây trình bày một danh sách Việc cần làm cơ bản có các tác vụ với từ khoá có mức độ ưu tiên cao do người dùng xác định xuất hiện đầu tiên:

@Composable
fun TodoList(highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")) {

    val todoTasks = remember { mutableStateListOf<String>() }

    // Calculate high priority tasks only when the todoTasks or highPriorityKeywords
    // change, not on every recomposition
    val highPriorityTasks by remember(highPriorityKeywords) {
        derivedStateOf { todoTasks.filter { it.containsWord(highPriorityKeywords) } }
    }

    Box(Modifier.fillMaxSize()) {
        LazyColumn {
            items(highPriorityTasks) { /* ... */ }
            items(todoTasks) { /* ... */ }
        }
        /* Rest of the UI where users can add elements to the list */
    }
}

Trong đoạn mã trên, derivedStateOf đảm bảo rằng bất cứ khi nào todoTasks thay đổi, phép tính highPriorityTasks sẽ được thực hiện và giao diện người dùng sẽ được cập nhật tương ứng. Nếu highPriorityKeywords thay đổi, khối remember sẽ được thực thi và một đối tượng trạng thái phát sinh mới sẽ được tạo và được ghi nhớ thay cho khối cũ. Do quá trình lọc để tính toán highPriorityTasks có thể tốn kém, chỉ nên thực thi bộ lọc này khi bất kỳ điều gì của các danh sách này thay đổi, chứ không phải trên mọi tái cấu trúc.

Hơn nữa, việc cập nhật trạng thái bởi derivedStateOf sẽ không gây ra thành phần kết hợp nơi nó được thông báo tái cấu trúc, Compose sẽ chỉ tái cấu trúc những thành phần kết hợp khi trạng thái hoàn lại của nó được đọc, trong LazyColumn như trong ví dụ này.

Mã này cũng giả định rằng highPriorityKeywords thay đổi ít hơn đáng kể so với todoTasks. Nếu không phải trường hợp đó, mã này có thể sử dụng remember(todoTasks, highPriorityKeywords) thay vì derivedStateOf.

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

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 làm điều đó, hãy nghĩ kỹ và đảm bảo đó là điều bạn cần.