Trạng thái nâng cao và hiệu ứng phụ trong Jetpack Compose

1. Giới thiệu

Trong lớp học lập trình này, bạn sẽ tìm hiểu các khái niệm nâng cao liên quan đến các AIP Trạng tháiHiệu ứng phụ API trongJetpack Compose , Chúng ta sẽ tìm hiểu cách tạo trình giữ trạng thái cho các hàm composable có tính trạng thái với logic đáng kể, cách tạo coroutine và các hàm tạm ngưng cuộc gọi từ mã Compose cũng như cách kích hoạt hiệu ứng phụ để thực hiện các trường hợp sử dụng khác nhau.

Kiến thức bạn sẽ học được

Bạn cần có

Ứng dụng bạn sẽ tạo

Trong lớp học lập trình này, chúng ta sẽ bắt đầu từ một ứng dụng chưa hoàn thiện, ứng dụng Nghiên cứu tài liệu Chim hạc, chúng ta sẽ thêm các tính năng để cải thiện ứng dụng.

1fb85e2ed0b8b592.gif

2. Thiết lập

Lấy mã

Bạn có thể tìm thấy mã dành cho lớp học lập trình này trong android-compose-codelabs trên kho lưu trữ GitHub. Để sao chép, hãy chạy:

$ git clone https://github.com/googlecodelabs/android-compose-codelabs

Ngoài ra, bạn có thể tải kho lưu trữ ở dạng định dạng tệp zip:

Xem xét ứng dụng mẫu

Mã bạn vừa tải xuống có chứa mã dành cho tất cả lớp học lập trình Compose hiện có. Để hoàn tất lớp học lập trình này, hãy mở dự án AdvancedStateAndSideEffectsCodelab trong Android Studio Arctic Fox.

Nên bắt đầu bằng mã trong nhánh và làm theo hướng dẫn từng bước của lớp học lập trình theo tốc độ của bạn.

Xuyên suốt lớp học lập trình, bạn sẽ thấy các đoạn mã bạn cần thêm vào dự án. Có lúc, bạn cũng sẽ cần phải xoá mã được đề cập rõ ràng trong các nhận xét trên đoạn mã.

Làm quen với mã và chạy ứng dụng mẫu

Hãy dành chút thời gian tìm hiểu cấu trúc dự án và chạy ứng dụng.

37d39b9ac4a9d2fa.png

Khi chạy ứng dụng từ nhánh main (chính), bạn sẽ thấy rằng một số chức năng như ngăn hoặc tải điểm đến chuyến bay không hoạt động! Đó là những gì chúng ta sẽ thực hiện trong các bước tiếp theo của lớp học lập trình.

1fb85e2ed0b8b592.gif

Kiểm thử giao diện người dùng

Ứng dụng này sử dụng các bài kiểm thử giao diện người dùng rất cơ bản có trong thư mục androidTest. Chúng phải luôn chuyển cho cả nhánh mainend.

[Không bắt buộc] Hiển thị bản đồ trên màn hình chi tiết

Hoàn toàn không cần thiết phải theo đuổi việc hiển thị bản đồ thành phố trên màn hình chi tiết. Tuy nhiên, nếu muốn xem nó, bạn cần phải có khóa API cá nhân như được nêu trên tài liệu Maps (Bản đồ). Thêm khóa đó vào tệp local.properties như sau:

// local.properties file
google.maps.key={insert_your_api_key_here}

Giải pháp cho lớp học lập trình

Để nhận nhánh end sử dụng git, hãy dùng lệnh sau:

$ git clone -b end https://github.com/googlecodelabs/android-compose-codelabs

Ngoài ra, bạn có thể tải mã giải pháp từ đây:

Câu hỏi thường gặp

3. Sử dụng luồng dữ liệu từ ViewModel

Như bạn thấy khi chạy ứng dụng từ nhánh main, danh sách các điểm đến chuyến bay bị trống! Để biết điều gì đang xảy ra, mở tệp home/CraneHome.kt và xem thành phần kết hợp (composable) CraneHomeContent.

Có một nhận xét trong mục TODO (Việc cần làm) phía trên định nghĩa về suggestedDestinations được gán cho danh sách trống được ghi nhớ. Đây là nội dung sẽ hiển thị trên màn hình: một danh sách trống! Trong bước này, chúng ta sẽ khắc phục vấn đề đó và hiển thị các điểm đến đề xuất mà MainViewModel hiển thị.

9cadb1fd5f4ced3c.png

Mở home/MainViewModel.kt và xem suggestedDestinations StateFlow được khởi chạy lên destinationsRepository.destinations và cập nhật khi các hàm updatePeople hoặc toDestinationChanged được gọi.

Chúng ta muốn giao diện người dùng trong thành phần kết hợp (composable) CraneHomeContent có thể cập nhật bất cứ khi nào có một mục mới được xuất vào luồng dữ liệu suggestedDestinations. Chúng ta có thể sử dụng hàm StateFlow.collectAsState(). Khi được sử dụng trong hàm có khả năng kết hợp, collectAsState() sẽ thu thập các giá trị từ StateFlow và thể hiện giá trị mới nhất thông qua API State (Trạng thái) của Compose. Điều này sẽ khiến mã Compose đọc giá trị trạng thái đó tương ứng với phát xạ mới.

Quay lại thành phần kết hợp (composable) CraneHomeContent thay thế dòng chỉ định suggestedDestinations bằng lệnh gọi tới collectAsState trên thuộc tính suggestedDestinations của ViewModel:

import androidx.compose.runtime.collectAsState

@Composable
fun CraneHomeContent(
    onExploreItemClicked: OnExploreItemClicked,
    openDrawer: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: MainViewModel = viewModel(),
) {
    val suggestedDestinations by viewModel.suggestedDestinations.collectAsState()
    // ...
}

Nếu chạy ứng dụng, bạn sẽ thấy danh sách các điểm đến được điền sẵn và danh sách này sẽ thay đổi bất cứ khi nào bạn nhấn vào số người đi lại.

4ec666a2d1ac0903.gif

4. LaunchedEffect và rememberUpdatedState

Trong dự án, hiện có một tệp home/LandingScreen.kt chưa được dùng. Chúng ta phải thêm màn hình đích vào ứng dụng. Màn hình này có thể được sử dụng để tải ngầm tất cả dữ liệu cần thiết.

Màn hình đích sẽ chiếm toàn bộ màn hình và hiển thị biểu trưng của ứng dụng ở giữa màn hình. Lý tưởng nhất là chúng ta sẽ hiển thị màn hình và sau khi tải xong tất cả dữ liệu, chúng ta sẽ thông báo cho người gọi có thể bỏ qua màn hình đích bằng cách sử dụng lệnh gọi lại onTimeout.

Bạn nên sử dụng coroutine Kotlin để thực hiện các thao tác không đồng bộ trên Android. Một ứng dụng thường sẽ sử dụng coroutine để tải ngầm mọi thứ khi ứng dụng khởi động. Jetpack Compose cung cấp các API giúp sử dụng coroutine an toàn trong lớp giao diện người dùng. Do ứng dụng này không giao tiếp với một chương trình phụ trợ, chúng ta sẽ dùng hàm delay của coroutine để mô phỏng việc tải ngầm các nội dung.

Hiệu ứng phụ trong Compose là sự thay đổi về trạng thái của ứng dụng bên ngoài phạm vi một hàm kết hợp (composable). Việc thay đổi trạng thái để hiển thị/ẩn màn hình đích sẽ xảy ra trong lệnh gọi lại onTimeout và vì trước khi gọi onTimeout, chúng ta cần tải mọi thứ bằng coroutine, nên việc thay đổi trạng thái cần diễn ra trong ngữ cảnh coroutine!

Để gọi các chức năng tạm ngưng một cách an toàn từ bên trong thành phần kết hợp (composable), hãy sử dụng API LaunchedEffect để kích hoạt hiệu ứng phụ trong phạm vi coroutine trong ứng dụng Compose.

Khi LaunchedEffect nhập một Thành phần, nó 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ị hủy nếu LaunchedEffect thoát khỏi thành phần đó.

Mặc dù mã tiếp theo không đúng, nhưng hãy tìm hiểu cách sử dụng API này và thảo luận về lý do khiến mã sau không đúng. Chúng tôi sẽ gọi thành phần kết hợp (composable) LandingScreen sau trong bước này.

// home/LandingScreen.kt file

import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // Start a side effect to load things in the background
        // and call onTimeout() when finished.
        // Passing onTimeout as a parameter to LaunchedEffect
        // is wrong! Don't do this. We'll improve this code in a sec.
        LaunchedEffect(onTimeout) {
            delay(SplashWaitTime) // Simulates loading things
            onTimeout()
        }
        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

Một số API có hiệu ứng phụ như LaunchedEffect sẽ lấy một số lượng biến khóa làm tham số dùng để khởi động lại hiệu ứng mỗi khi một trong các khóa đó thay đổi. Bạn đã phát hiện thấy lỗi? Chúng ta không muốn khởi động lại hiệu ứng nếu onTimeout thay đổi!

Để chỉ kích hoạt hiệu ứng phụ một lần trong vòng đời của thành phần kết hợp (composable) này, sử dụng hằng số làm khóa, ví dụ: LaunchedEffect(true) { ... }. Tuy nhiên, hiện chúng ta sẽ không bảo vệ các thay đổi đối với onTimeout!

Nếu onTimeout thay đổi trong khi hiệu ứng phụ đang diễn ra, không có gì đảm bảo onTimeout cuối cùng sẽ được gọi khi hiệu ứng kết thúc. Để đảm bảo điều này bằng cách thu thập và cập nhật giá trị mới, sử dụng API rememberUpdatedState:

// home/LandingScreen.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // 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 or onTimeout changes,
        // the delay shouldn't start again.
        LaunchedEffect(true) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

Hiển thị màn hình đích

Bây giờ, chúng ta cần hiển thị màn hình đích khi ứng dụng được mở. Mở tệp home/MainActivity.kt và xem thành phần kết hợp (composable) MainScreen được gọi đầu tiên.

Trong thành phần kết hợp (composable) MainScreen, chúng ta chỉ cần thêm trạng thái nội bộ theo dõi liệu trang đích có hiển thị hay không:

// home/MainActivity.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
private fun MainScreen(onExploreItemClicked: OnExploreItemClicked) {
    Surface(color = MaterialTheme.colors.primary) {
        var showLandingScreen by remember { mutableStateOf(true) }
        if (showLandingScreen) {
            LandingScreen(onTimeout = { showLandingScreen = false })
        } else {
            CraneHome(onExploreItemClicked = onExploreItemClicked)
        }
    }
}

Nếu bạn chạy ứng dụng ngay bây giờ, bạn sẽ thấy LandingScreen xuất hiện và biến mất sau 2 giây.

fda616dda280aa3e.gif

5. rememberCoroutineScope

Ở bước này, chúng ta sẽ khiến ngăn điều hướng hoạt động. Hiện tại, sẽ không có gì xảy ra nếu bạn nhấn vào trình đơn ba đường kẻ.

Mở tệp home/CraneHome.kt và xem thành phần kết hợp (composable) CraneHome để biết chúng ta cần mở ngăn điều hướng ở đâu: trong lệnh gọi lại openDrawer!

Trong CraneHome, chúng ta có một scaffoldState chứa DrawerState. DrawerState có các phương thức để mở và đóng ngăn điều hướng theo phương thức lập trình. Tuy nhiên, nếu bạn muốn ghi scaffoldState.drawerState.open() trong lệnh gọi lại openDrawer, bạn sẽ gặp lỗi! Nguyên nhân vì hàm open là hàm tạm ngưng. Chúng ta một lần nữa ở trong coroutine.

Ngoài API để giúp coroutine gọi an toàn từ lớp giao diện người dùng, một số API Compose là chức năng tạm ngưng. Một ví dụ cho trường hợp này là API để mở ngăn điều hướng. Các hàm tạm ngưng, ngoài việc có thể chạy mã không đồng bộ còn giúp biểu diễn các khái niệm xảy ra theo thời gian. Do việc mở ngăn đòi hỏi thời gian, nên chuyển động và ảnh động tiềm năng cần được phản ánh hoàn hảo bằng chức năng tạm ngưng. Thao tác này sẽ tạm ngừng việc thực thi coroutine ở vị trí nó được gọi cho đến khi kết thúc và tiếp tục thực thi.

Phải gọi scaffoldState.drawerState.open() trong coroutine. Chúng ta có thể làm gì? openDrawer là hàm gọi lại đơn giản, do đó:

  • Chúng ta không thể gọi các hàm tạm ngưng trong đó bởi openDrawer không được thực thi trong bối cảnh một coroutine.
  • Chúng ta không thể sử dụng LaunchedEffect như trước vì chúng ta không thể gọi các thành phần kết hợp (composable) trong openDrawer. Chúng ta không ở trong Composition.

Nếu muốn khởi động một coroutine, chúng ta nên dùng phạm vi nào? Lý tưởng nhất là nên có CoroutineScope tuân theo vòng đời của trang web cuộc gọi. Để làm điều này, hãy sử dụng API rememberCoroutineScope. Phạm vi sẽ tự động bị hủy sau khi thoát khỏi Composition. Trong phạm vi đó, bạn có thể khởi động coroutine khi không ở trong Composition, chẳng hạn như trong lệnh gọi lại openDrawer.

// home/CraneHome.kt file

import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch

@Composable
fun CraneHome(
    onExploreItemClicked: OnExploreItemClicked,
    modifier: Modifier = Modifier,
) {
    val scaffoldState = rememberScaffoldState()
    Scaffold(
        scaffoldState = scaffoldState,
        modifier = Modifier.statusBarsPadding(),
        drawerContent = {
            CraneDrawer()
        }
    ) {
        val scope = rememberCoroutineScope()
        CraneHomeContent(
            modifier = modifier,
            onExploreItemClicked = onExploreItemClicked,
            openDrawer = {
                scope.launch {
                    scaffoldState.drawerState.open()
                }
            }
        )
    }
}

Nếu chạy ứng dụng này, bạn sẽ thấy ngăn điều hướng mở ra khi nhấn vào biểu tượng trình đơn ba đường kẻ.

ad44883754b14efe.gif

LaunchedEffect so với rememberCoroutineScope

Chúng ta không thể sử dụng LaunchedEffect trong trường hợp này vì chúng ta cần kích hoạt cuộc gọi để tạo một coroutine trong lệnh gọi lại thông thường nằm ngoài Composition.

Xem lại bước trên màn hình đích đã dùng LaunchedEffect, bạn có thể sử dụng rememberCoroutineScope và gọi scope.launch { delay(); onTimeout(); } thay vì sử dụng LaunchedEffect không?

Bạn có thể làm vậy và dường như cách này có thể hiệu quả nhưng nó không đúng. Như đã giải thích trong tài liệu về Thinking in Compose (Tư duy trong Compose), bạn có thể gọi các thành phần kết hợp (composable) bất cứ lúc nào. LaunchedEffect đảm bảo rằng hiệu ứng phụ này sẽ được thực thi khi lệnh gọi thành phần kết hợp (composable) khiến nó nằm trong Composition. Nếu bạn sử dụng rememberCoroutineScopescope.launch trong phần thân của LandingScreen, thì coroutine sẽ được thực thi mỗi khi LandingScreen được gọi bằng Compose bất kể lệnh gọi đó có khiến nó nằm trong Composition hay không. Do đó, bạn sẽ lãng phí tài nguyên và sẽ không thực thi hiệu ứng phụ này trong môi trường được kiểm soát.

6. Tạo trình giữ trạng thái

Bạn có thấy rằng nếu nhấn vào Choose Destination (Chọn điểm đến), bạn có thể chỉnh sửa trường này và lọc các thành phố dựa trên nội dung tìm kiếm đã nhập không? Bạn cũng có thể nhận thấy bất cứ khi nào bạn sửa đổi Choose Destination (Chọn điểm đến), kiểu văn bản sẽ thay đổi.

99dec71d23aef084.gif

Mở tệp base/EditableUserInput.kt. Thành phần kết hợp (composable) CraneEditableUserInput trạng thái có thể nhận được một vài thông số như hintcaption tương ứng với văn bản tùy chọn bên cạnh biểu tượng. Ví dụ: caption To (Tới) xuất hiện khi bạn tìm kiếm một điểm đến.

// base/EditableUserInput.kt file - code in the main branch

@Composable
fun CraneEditableUserInput(
    hint: String,
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null,
    onInputChanged: (String) -> Unit
) {
    // TODO Codelab: Encapsulate this state in a state holder
    var textState by remember { mutableStateOf(hint) }
    val isHint = { textState == hint }

    ...
}

Tại sao?

Logic để cập nhật textState và xác định xem nội dung đã hiển thị có tương ứng với gợi ý hay không đều nằm ở phần thân của thành phần kết hợp (composable) CraneEditableUserInput. Điều này cũng gây ra một số nhược điểm:

  • Giá trị của TextField không được nâng nên không thể bị kiểm soát từ bên ngoài, điều này khiến việc kiểm thử khó khăn hơn.
  • Logic của thành phần kết hợp (composable) này có thể phức tạp hơn và trạng thái nội bộ có thể thoát đồng bộ dễ dàng hơn.

Bằng cách tạo trình giữ trạng thái chịu trách nhiệm về trạng thái nội bộ của thành phần kết hợp (composable) này, bạn có thể tập trung tất cả các thay đổi về trạng thái ở cùng một nơi. Với cách này, trạng thái khó bị thoát đồng bộ hơn và logic liên quan sẽ được nhóm lại với nhau trong một lớp duy nhất. Hơn nữa, trạng thái này có thể dễ dàng được nâng lên và có thể được sử dụng từ người gọi của thành phần kết hợp (composable) này.

Trong trường hợp đó, bạn nên nâng trạng thái này do đây là thành phần giao diện người dùng cấp thấp có thể được sử dụng lại trong các phần khác của ứng dụng. Do đó, càng linh hoạt và có thể kiểm soát nhiều thì càng tốt.

Tạo trình giữ trạng thái

Do CraneEditableUserInput là thành phần có thể sử dụng lại, hãy tạo một lớp thông thường làm trình giữ trạng thái có tên EditableUserInputState trong cùng tệp như sau:

// base/EditableUserInput.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue

class EditableUserInputState(private val hint: String, initialText: String) {

    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint
}

Lớp phải có các đặc điểm sau:

  • text là trạng thái khả biến thuộc loại String, tương tự như trạng thái ta có trong CraneEditableUserInput. Quan trọng là bạn phải sử dụng mutableStateOf để Compose theo dõi các thay đổi liên quan đến giá trị và biên soạn lại khi có thay đổi.
  • text là một var, do đó bạn có thể thay đổi trực tiếp từ bên ngoài lớp.
  • Lớp này lấy initialText làm phần phụ thuộc dùng để khởi chạy text.
  • Logic để biết liệu text có phải là gợi ý hay không nằm trong thuộc tính isHint thực hiện việc kiểm tra theo yêu cầu.

Nếu logic ngày một phức tạp hơn, chúng ta chỉ cần thực hiện các thay đổi cho một lớp: EditableUserInputState.

Ghi nhớ trình giữ trạng thái

Các trình giữ trạng thái luôn cần được ghi nhớ để có thể giữ chúng trong Composition và không phải lúc nào cũng tạo kênh mới. Nên tạo phương thức trong cùng một tệp để thực hiện việc này nhằm xóa bản mẫu và tránh mọi lỗi có thể xảy ra. Trong tệp base/EditableUserInput.kt, thêm mã sau:

// base/EditableUserInput.kt file

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    remember(hint) {
        EditableUserInputState(hint, hint)
    }

Nếu chỉ có remember trạng thái này, nó sẽ không tồn tại khi tạo lại hoạt động. Để đạt được điều đó, chúng ta có thể sử dụng API rememberSaveable thay vì API hoạt động tương tự như remember. Tuy nhiên, giá trị được lưu trữ cũng vẫn tồn tại trong quá trình tạo lại hoạt động và xử lý. Trong cục bộ, nó sử dụng cơ chế trạng thái của phiên bản đã lưu.

rememberSaveable thực hiện tất cả thao tác này mà không cần phải thực hiện thêm thao tác nào cho các đối tượng có thể được lưu trữ trong Bundle. Đây không phải trường hợp cho lớp EditableUserInputState mà chúng ta đã tạo trong dự án. Do đó, chúng ta cần cho rememberSaveable biết cách lưu và khôi phục bản sao của lớp này sử dụng Saver.

Tạo trình lưu tùy chỉnh

Saver mô tả cách chuyển đổi một đối tượng thành Saveable. Triển khai Saver cần phải ghi đè hai hàm:

  • save để chuyển đổi giá trị ban đầu thành một giá trị có thể lưu.
  • restore để chuyển đổi giá trị được khôi phục sang bản sao lớp ban đầu.

Đối với trường hợp của chúng ta, thay vì tạo triển khai tùy chỉnh Saver cho lớp EditableUserInputState, có thể sử dụng một vài API Compose hiện có, chẳng hạn như listSaver hoặc mapSaver (lưu trữ các giá trị để lưu vào List hoặc Map) để giảm số lượng mã cần viết.

Nên đặt các định nghĩa Saver gần với lớp mà chúng tương tác. Vì cần truy cập tĩnh, nên hãy thêm Saver cho EditableUserInputState trong companion object. Trong tệp base/EditableUserInput.kt, thêm triển khai Saver:

// base/EditableUserInput.kt file

import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver

class EditableUserInputState(private val hint: String, initialText: String) {
    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint

    companion object {
        val Saver: Saver<EditableUserInputState, *> = listSaver(
            save = { listOf(it.hint, it.text) },
            restore = {
                EditableUserInputState(
                    hint = it[0],
                    initialText = it[1],
                )
            }
        )
    }
}

Trong trường hợp này, chúng ta sẽ sử dụng listSaver làm chi tiết triển khai để lưu trữ và khôi phục bản sao EditableUserInputState trong trình lưu.

Bây giờ, chúng ta có thể sử dụng trình lưu này trong rememberSaveable (thay vì remember) trong phương thức rememberEditableUserInputState chúng ta đã tạo trước đây:

// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.rememberSaveable

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    rememberSaveable(hint, saver = EditableUserInputState.Saver) {
        EditableUserInputState(hint, hint)
    }

Bằng cách này, trạng thái đã ghi nhớ EditableUserInput sẽ vẫn tồn tại khi tạo lại quy trình và hoạt động.

Sử dụng trình giữ trạng thái

Chúng ta sẽ sử dụng EditableUserInputState thay vì textisHint, nhưng chúng ta không muốn chỉ sử dụng nó làm trạng thái nội bộ trong CraneEditableUserInput do thành phần kết hợp (composable) của người gọi không thể kiểm soát trạng thái. Thay vào đó, chúng ta muốn nâng EditableUserInputState để các trình gọi có thể kiểm soát trạng thái của CraneEditableUserInput. Nếu nâng trạng thái thì thành phần kết hợp (composable) có thể được sử dụng ở chế độ xem trước và kiểm thử dễ dàng hơn bởi bạn có thể sửa đổi trạng thái từ trình gọi.

Để thực hiện điều này, chúng ta cần thay đổi các thông số của hàm thành phần kết hợp (composable) và cung cấp giá trị mặc định nếu cần. Do chúng ta có thể sẽ muốn cho phép CraneEditableUserInput có gợi ý trống, nên hãy thêm đối số mặc định:

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) { /* ... */ }

Có thể bạn đã nhận thấy tham số onInputChanged không còn tồn tại! Do trạng thái có thể được nâng lên nên nếu người gọi muốn biết liệu đầu vào có thay đổi hay không, họ có thể kiểm soát trạng thái và chuyển trạng thái đó vào hàm này.

Tiếp theo, chúng ta cần tinh chỉnh phần thân hàm để sử dụng trạng thái đã nâng thay vì trạng thái nội bộ đã dùng trước đó. Sau khi được cải tiến cấu trúc, hàm sẽ có dạng như sau:

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) {
    CraneBaseUserInput(
        caption = caption,
        tintIcon = { !state.isHint },
        showCaption = { !state.isHint },
        vectorImageId = vectorImageId
    ) {
        BasicTextField(
            value = state.text,
            onValueChange = { state.text = it },
            textStyle = if (state.isHint) {
                captionTextStyle.copy(color = LocalContentColor.current)
            } else {
                MaterialTheme.typography.body1.copy(color = LocalContentColor.current)
            },
            cursorBrush = SolidColor(LocalContentColor.current)
        )
    }
}

Người gọi của trình giữ trạng thái

Do đã thay đổi API của CraneEditableUserInput, nên chúng ta cần kiểm tra tất cả các vị trí API được gọi để đảm bảo chuyển đúng tham số.

Địa điểm duy nhất trong dự án mà chúng ta gọi API này là trong tệp home/SearchUserInput.kt. Mở tệp đó và đến hàm thành phần kết hợp (composable) ToDestinationUserInput, bạn sẽ thấy lỗi bản dựng ở đó. Do gợi ý hiện là một phần của trình giữ trạng thái và chúng ta muốn gợi ý tùy chỉnh cho bản sao này của CraneEditableUserInput trong Composition, nên sẽ cần ghi nhớ trạng thái ở cấp độ ToDestinationUserInput và chuyển trạng thái đó vào CraneEditableUserInput:

// home/SearchUserInput.kt file

import androidx.compose.samples.crane.base.rememberEditableUserInputState

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )
}

snapshotFlow

Mã ở trên thiếu chức năng thông báo cho người gọi của ToDestinationUserInput khi đầu vào thay đổi. Do cấu trúc của ứng dụng nên chúng ta không muốn nâng EditableUserInputState lên cấp cao hơn bởi chúng ta muốn ghép các thành phần kết hợp (composable) khác như FlySearchContent với trạng thái này. Chúng ta có thể gọi hàm Lambda onToDestinationChanged từ ToDestinationUserInput như thế nào để vẫn có thể sử dụng lại thành phần kết hợp này?

Chúng ta có thể kích hoạt hiệu ứng phụ sử dụng LaunchedEffect mỗi khi thay đổi dữ liệu đầu vào và gọi hàm lambda onToDestinationChanged:

// home/SearchUserInput.kt file

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )

    val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
    LaunchedEffect(editableUserInputState) {
        snapshotFlow { editableUserInputState.text }
            .filter { !editableUserInputState.isHint }
            .collect {
                currentOnDestinationChanged(editableUserInputState.text)
            }
    }
}

Chúng ta đã sử dụng LaunchedEffectrememberUpdatedState trước đó, nhưng mã ở trên cũng sử dụng API mới! Chúng ta sử dụng snapshotFlow API để chuyển đổi các đối tượng Compose State<T> thành Flow (Luồng). Khi thông số trạng thái bên trong snapshotFlow thay đổi, Flow (Luồng) sẽ phát ra giá trị mới trình thu thập. Trong trường hợp này, chúng ta chuyển đổi trạng thái thành luồng để sử dụng sức mạnh của toán tử luồng. Bằng cách đó, chúng ta filter khi text không phải là hintcollect các mục được tạo ra để thông báo cho cha mẹ điểm đến hiện tại đã thay đổi.

Không có thay đổi nào về hình ảnh trong bước này của lớp học lập trình, nhưng chúng ta đã cải thiện chất lượng của phần mã này. Nếu chạy ứng dụng ngay bây giờ, bạn sẽ thấy mọi thứ hoạt động như trước đây.

7. DisposableEffect

Khi nhấn vào một điểm đến, màn hình chi tiết sẽ mở ra và bạn có thể thấy vị trí của thành phố trên bản đồ. Mã đó nằm trong tệp details/DetailsActivity.kt. Trong thành phần kết hợp (composable) CityMapView, chúng ta gọi hàm rememberMapViewWithLifecycle. Nếu mở hàm này vốn nằm trong tệp details/MapViewUtils.kt, bạn sẽ thấy hàm không được kết nối với bất kỳ vòng đời nào! Chrome chỉ ghi nhớ MapView và gọi onCreate trên đó:

// details/MapViewUtils.kt file - code in the main branch

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    // TODO Codelab: DisposableEffect step. Make MapView follow the lifecycle
    return remember {
        MapView(context).apply {
            id = R.id.map
            onCreate(Bundle())
        }
    }
}

Mặc dù ứng dụng chạy bình thường, nhưng đây là một vấn đề do MapView không tuân theo đúng vòng đời. Do đó, bạn sẽ không biết khi nào ứng dụng chuyển sang chạy ngầm, khi nào nên tạm dừng View (Chế độ xem), v.v. Hãy cùng khắc phục lỗi này!

Do MapView là View (Chế độ xem) chứ không phải thành phần kết hợp (composable) nên chúng ta muốn nó tuân theo vòng đời của Activity (Hoạt động) được sử dụng thay vì vòng đời của Composition. Điều đó có nghĩa là chúng ta cần tạo LifecycleEventObserver để theo dõi các sự kiện trong vòng đời và gọi các phương thức phù hợp trên MapView. Sau đó, chúng ta cần thêm trình quan sát này vào vòng đời của hoạt động hiện tại.

Hãy bắt đầu bằng cách tạo một hàm trả về LifecycleEventObserver gọi phương thức tương ứng ở một MapView trong một sự kiện nhất định:

// details/MapViewUtils.kt file

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver

private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
    LifecycleEventObserver { _, event ->
        when (event) {
            Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
            Lifecycle.Event.ON_START -> mapView.onStart()
            Lifecycle.Event.ON_RESUME -> mapView.onResume()
            Lifecycle.Event.ON_PAUSE -> mapView.onPause()
            Lifecycle.Event.ON_STOP -> mapView.onStop()
            Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
            else -> throw IllegalStateException()
        }
    }

Bây giờ, chúng ta cần thêm trình quan sát này vào vòng đời hiện tại để có thể sử dụng LifecycleOwner hiện tại với thành phần (composition) LocalLifecycleOwner cục bộ. Tuy nhiên, như vậy vẫn chưa đủ để thêm trình quan sát, chúng ta cũng cần phải xóa nó! Chúng ta cần hiệu ứng phụ để biết thời điểm hiệu ứng đó rời khỏi Composition để có thể thực hiện mã dọn dẹp. API hiệu ứng phụ mà chúng ta đang tìm kiếm là DisposableEffect.

DisposableEffect dành cho các hiệu ứng phụ cần được dọn dẹp sau khi các khóa thay đổi hoặc thành phần kết hợp (composable) rời khỏi Composition. Mã rememberMapViewWithLifecycle cuối cùng sẽ hoạt động chính xác như vậy. Triển khai các dòng sau trong dự án của bạn:

// details/MapViewUtils.kt file

import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalLifecycleOwner

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    val mapView = remember {
        MapView(context).apply {
            id = R.id.map
        }
    }

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    DisposableEffect(key1 = lifecycle, key2 = mapView) {
        // Make MapView follow the current lifecycle
        val lifecycleObserver = getMapLifecycleObserver(mapView)
        lifecycle.addObserver(lifecycleObserver)
        onDispose {
            lifecycle.removeObserver(lifecycleObserver)
        }
    }

    return mapView
}

Trình quan sát sẽ được thêm vào lifecycle hiện tại và sẽ bị xóa khi vòng đời hiện tại thay đổi hoặc thành phần kết hợp (composable) này rời khỏi Composition. Với key trong DisposableEffect, nếu lifecycle hoặc mapView thay đổi, trình quan sát sẽ bị xóa và thêm lại vào lifecycle bên phải.

Với những thay đổi chúng tôi vừa thực hiện, MapView sẽ luôn tuân theo lifecycle của LifecycleOwner hiện tại và hành vi của nó sẽ như được sử dụng trong View (Chế độ xem).

Hãy thoải mái chạy ứng dụng này và mở màn hình chi tiết để đảm bảo MapView vẫn hiển thị chính xác. Không có thay đổi nào về hình ảnh trong bước này.

8. produceState

Trong phần này, chúng ta sẽ cải thiện cách màn hình chi tiết khởi động. Thành phần kết hợp (composable) DetailsScreen trong tệp details/DetailsActivity.kt sẽ nhận cityDetails đồng bộ từ ViewModel và gọi DetailsContent nếu kết quả thành công.

Tuy nhiên, cityDetails có thể sẽ tốn nhiều phí tải hơn trên luồng giao diện người dùng và có thể sử dụng coroutine để chuyển dữ liệu sang một luồng khác. Hãy cải thiện mã này để thêm màn hình tải và hiển thị DetailsContent khi dữ liệu đã sẵn sàng.

Một cách để mô hình hóa trạng thái màn hình là sử dụng lớp sau đây trong đó có tất cả các khả năng: dữ liệu cần hiển thị trên màn hình cũng như tín hiệu tải và lỗi. Thêm lớp DetailsUiState vào tệp DetailsActivity.kt:

// details/DetailsActivity.kt file

data class DetailsUiState(
    val cityDetails: ExploreModel? = null,
    val isLoading: Boolean = false,
    val throwError: Boolean = false
)

Chúng ta có thể liên kết những nội dung màn hình cần hiển thị và UiState trong lớp ViewModel sử dụng luồng dữ liệu, StateFlow thuộc loại DetailsUiState mà ViewModel cập nhật khi thông tin đã sẵn sàng và Compose thu thập bằng API collectAsState() bạn đã biết.

Tuy nhiên, để thực hiện bài tập này, chúng ta sẽ triển khai một phương án thay thế. Nếu muốn di chuyển logic ánh xạ uiState sang thế giới Compose, chúng ta có thể sử dụng API produceState.

produceState cho phép bạn chuyển đổi trạng thái không phải Compose thành Trạng thái Compose. Tệp này sẽ khởi chạy một coroutine trong phạm vi Composition, coroutine này có thể đẩy các giá trị vào State được trả về bằng thuộc tính value. Tương tự như LaunchedEffect, produceState cũng lấy các khóa để hủy và khởi động lại việc tính toán.

Trong trường hợp này, chúng ta có thể sử dụng produceState để phát hành các bản cập nhật uiState có giá trị ban đầu là DetailsUiState(isLoading = true) như sau:

// details/DetailsActivity.kt file

import androidx.compose.runtime.produceState

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {

    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        // In a coroutine, this can call suspend functions or move
        // the computation to different Dispatchers
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    // TODO: ...
}

Tiếp theo, tùy thuộc vào uiState, chúng ta sẽ hiển thị dữ liệu, hiển thị màn hình tải hoặc báo cáo lỗi. Dưới đây là mã hoàn chỉnh cho thành phần kết hợp (composable) DetailsScreen:

// details/DetailsActivity.kt file

import androidx.compose.foundation.layout.Box
import androidx.compose.material.CircularProgressIndicator

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {
    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    when {
        uiState.cityDetails != null -> {
            DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
        }
        uiState.isLoading -> {
            Box(modifier.fillMaxSize()) {
                CircularProgressIndicator(
                    color = MaterialTheme.colors.onSurface,
                    modifier = Modifier.align(Alignment.Center)
                )
            }
        }
        else -> { onErrorLoading() }
    }
}

Nếu chạy ứng dụng, bạn sẽ thấy cách vòng quay tải hiển thị trước khi hiển thị thông tin chi tiết về thành phố.

18956feb88725ca5.gif

9. derivedStateOf

Cải tiến cuối cùng chúng ta sẽ thực hiện với ứng dụng Crane là hiển thị nút Scroll to top (Di chuyển lên đầu) bất cứ khi nào bạn di chuyển đến danh sách điểm đến của chuyến bay sau khi bạn đã chuyển thành phần đầu tiên của màn hình. Khi nhấn vào nút đó, bạn sẽ được chuyển đến thành phần đầu tiên trong danh sách.

59d2d10bd334bdb.gif

Mở tệp base/ExploreSection.kt chứa mã này. Thành phần kết hợp (composable) ExploreSection tương ứng với những gì bạn thấy trong nền của giàn giáo.

Bạn nên lường trước giải pháp thực hiện hành vi hiển thị trên video. Tuy nhiên, có một API mới mà chúng ta chưa từng thấy, nó rất quan trọng trong trường hợp sử dụng này: API derivedStateOf.

Sử dụng derivedStateOf khi bạn muốn một State Compose được hệ thống tạo từ State khác. Sử dụng hàm này đảm bảo việc tính toán sẽ chỉ xảy ra khi nào một trong các trạng thái được sử dụng trong phép tính có sự thay đổi.

Để tính toán liệu người dùng có vượt qua mục đầu tiên hay không sử dụng listState cũng đơn giản như việc kiểm tra xem listState.firstVisibleItemIndex > 0 có hay không. Tuy nhiên, firstVisibleItemIndex được bao bọc trong API mutableStateOf, điều này khiến nó trở thành Trạng thái Compose có thể quan sát. Tính toán của chúng ta cũng phải là một Trạng thái Compose vì chúng ta muốn soạn lại giao diện người dùng để hiển thị nút!

Ví dụ sau sẽ cho thấy cách triển khai đơn thuần và không hiệu quả. Đừng sao chép nó vào dự án; cách triển khai chính xác sẽ được sao chép vào dự án của bạn cùng phần còn lại của logic cho màn hình sau đó:

// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0

Một cách khác hiệu quả hơn là sử dụng API derivedStateOf chỉ tính toán showButton khi listState.firstVisibleItemIndex thay đổi:

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

Mã mới cho thành phần kết hợp (composable) ExploreSection có thể đã quen thuộc với bạn. Hãy xem lại cách chúng tôi sử dụng rememberCoroutineScope để gọi hàm tạm ngưng listState.scrollToItem trong lệnh gọi lại onClick của Button. Chúng tôi sẽ sử dụng Box để đặt Button hiển thị có điều kiện lên đầu ExploreList:

// base/ExploreSection.kt file

import androidx.compose.material.FloatingActionButton
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import com.google.accompanist.insets.navigationBarsPadding
import kotlinx.coroutines.launch

@Composable
fun ExploreSection(
    modifier: Modifier = Modifier,
    title: String,
    exploreList: List<ExploreModel>,
    onItemClicked: OnExploreItemClicked
) {
    Surface(modifier = modifier.fillMaxSize(), color = Color.White, shape = BottomSheetShape) {
        Column(modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp)) {
            Text(
                text = title,
                style = MaterialTheme.typography.caption.copy(color = crane_caption)
            )
            Spacer(Modifier.height(8.dp))
            Box(Modifier.weight(1f)) {
                val listState = rememberLazyListState()
                ExploreList(exploreList, onItemClicked, listState = 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
                    }
                }
                if (showButton) {
                    val coroutineScope = rememberCoroutineScope()
                    FloatingActionButton(
                        backgroundColor = MaterialTheme.colors.primary,
                        modifier = Modifier
                            .align(Alignment.BottomEnd)
                            .navigationBarsPadding()
                            .padding(bottom = 8.dp),
                        onClick = {
                            coroutineScope.launch {
                                listState.scrollToItem(0)
                            }
                        }
                    ) {
                        Text("Up!")
                    }
                }
            }
        }
    }
}

Nếu chạy ứng dụng, bạn sẽ thấy nút xuất hiện ở dưới cùng sau khi bạn di chuyển và chuyển phần tử đầu tiên của màn hình.

10. Xin chúc mừng!

Xin chúc mừng, bạn đã hoàn tất thành công lớp học lập trình này và tìm hiểu các khái niệm nâng cao về API trạng thái và hiệu ứng phụ trong ứng dụng Jetpack Compose!

Bạn đã tìm hiểu cách tạo trình giữ trạng thái, API hiệu ứng phụ như LaunchedEffect, rememberUpdatedState, DisposableEffect, produceStatederivedStateOf cũng như cách sử dụng coroutine trong Jetpack Compose.

Tiếp theo là gì?

Hãy xem các lớp học lập trình khác trên Lộ trình Compose và các mã mẫu khác bao gồm Crane.

Tài liệu

Để biết thêm thông tin và hướng dẫn về các chủ đề này, vui lòng xem tài liệu sau: