Dữ liệu trong phạm vi cục bộ với CompositionLocal

CompositionLocal là một công cụ giúp truyền dữ liệu thông qua Cấu phần (Composition) một cách ngầm ẩn. Trên trang này, bạn sẽ tìm hiểu chi tiết hơn về CompositionLocal, cách tạo CompositionLocal của riêng mình và liệu CompositionLocal có phải là giải pháp hiệu quả cho trường hợp sử dụng của bạn hay không.

Xin giới thiệu CompositionLocal

Thông thường trong Compose, dữ liệu sẽ chạy theo luồng thông qua cây giao diện người dùng dưới dạng tham số cho từng hàm có khả năng kết hợp. Điều này giúp các phần phụ thuộc của thành phần kết hợp (composable) trở nên rõ ràng. Tuy nhiên, việc này có thể cồng kềnh đối với dữ liệu rất thường xuyên và được sử dụng rộng rãi như màu sắc hoặc kiểu loại. Hãy xem ví dụ sau:

@Composable
fun MyApp() {
    // Theme information tends to be defined near the root of the application
    val colors = colors()
}

// Some composable deep in the hierarchy
@Composable
fun SomeTextLabel(labelText: String) {
    Text(
        text = labelText,
        color = colors.onPrimary // ← need to access colors here
    )
}

Để hỗ trợ việc không cần truyền các màu dưới dạng phần phụ thuộc tham số rõ ràng đến hầu hết thành phần kết hợp, Compose cung cấp CompositionLocal cho phép bạn tạo các đối tượng có tên trong phạm vi cây có thể được dùng như một cách ngầm ẩn để truyền dữ liệu qua cây giao diện người dùng.

Các thành phần CompositionLocal thường được cung cấp với một giá trị ở một nút nhất định trong cây giao diện người dùng. Giá trị con có thể kết hợp của hàm đó có thể sử dụng giá trị đó mà không cần khai báo CompositionLocal dưới dạng một tham số trong hàm có khả năng kết hợp

CompositionLocal là chế độ giao diện Material sử dụng nâng cao. MaterialTheme là đối tượng cung cấp ba phiên bản CompositionLocal: colorScheme, typographyshapes, cho phép bạn truy xuất các phiên bản này sau này trong bất kỳ phần con nào của Cấu phần. Cụ thể, đây là các thuộc tính LocalColorScheme, LocalShapesLocalTypography mà bạn có thể truy cập thông qua các thuộc tính MaterialTheme colorScheme, shapestypography.

@Composable
fun MyApp() {
    // Provides a Theme whose values are propagated down its `content`
    MaterialTheme {
        // New values for colorScheme, typography, and shapes are available
        // in MaterialTheme's content lambda.

        // ... content here ...
    }
}

// Some composable deep in the hierarchy of MaterialTheme
@Composable
fun SomeTextLabel(labelText: String) {
    Text(
        text = labelText,
        // `primary` is obtained from MaterialTheme's
        // LocalColors CompositionLocal
        color = MaterialTheme.colorScheme.primary
    )
}

Một thực thể CompositionLocal chỉ thuộc một phần của Cấu phần để bạn có thể cung cấp nhiều giá trị ở nhiều cấp trên cây. Giá trị current của CompositionLocal tương ứng với giá trị gần nhất mà đối tượng cấp trên trong Cấu phần đó cung cấp.

Để cung cấp giá trị mới cho CompositionLocal, hãy sử dụng CompositionLocalProvider và hàm infixprovides mà liên kết khoá CompositionLocal với value. Labda content của CompositionLocalProvider sẽ nhận được giá trị đã cung cấp khi truy cập vào thuộc tính current của CompositionLocal. Khi một giá trị mới được cung cấp, tính năng Compose sẽ kết hợp lại các phần của Cấu phần có đọc CompositionLocal.

Ví dụ: LocalContentColor CompositionLocal chứa màu nội dung ưu tiên được sử dụng cho văn bản và biểu tượng để đảm bảo màu này tương phản với màu nền hiện tại. Trong ví dụ sau, CompositionLocalProvider được dùng để cung cấp các giá trị khác nhau cho các phần khác nhau của Cấu phần.

@Composable
fun CompositionLocalExample() {
    MaterialTheme {
        // Surface provides contentColorFor(MaterialTheme.colorScheme.surface) by default
        // This is to automatically make text and other content contrast to the background
        // correctly.
        Surface {
            Column {
                Text("Uses Surface's provided content color")
                CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.primary) {
                    Text("Primary color provided by LocalContentColor")
                    Text("This Text also uses primary as textColor")
                    CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.error) {
                        DescendantExample()
                    }
                }
            }
        }
    }
}

@Composable
fun DescendantExample() {
    // CompositionLocalProviders also work across composable functions
    Text("This Text uses the error color now")
}

Hình 1. Bản xem trước thành phần kết hợp CompositionLocalExample.

Trong ví dụ cuối cùng, các phiên bản CompositionLocal đều được sử dụng trong nội bộ bởi các thành phần kết hợp Material. Để truy cập giá trị hiện tại của CompositionLocal, hãy sử dụng thuộc tính current. Trong ví dụ sau, giá trị Context hiện tại của LocalContextCompositionLocal thường dùng trong các ứng dụng Android sẽ được dùng để định dạng văn bản:

@Composable
fun FruitText(fruitSize: Int) {
    // Get `resources` from the current value of LocalContext
    val resources = LocalContext.current.resources
    val fruitText = remember(resources, fruitSize) {
        resources.getQuantityString(R.plurals.fruit_title, fruitSize)
    }
    Text(text = fruitText)
}

Tạo CompositionLocal của riêng bạn

CompositionLocal là một công cụ để truyền dữ liệu xuống thông qua Cấu phần một cách ngầm ẩn.

Một tín hiệu quan trọng khác để sử dụng CompositionLocal là khi tham số bị cắt chéo và các lớp triển khai trung gian không được biết rằng có sự tồn tại, vì việc làm cho các lớp trung gian đó biết được sẽ hạn chế phần mềm tiện ích của thành phần kết hợp. Ví dụ: truy vấn cho các quyền của Android được cung cấp bởi CompositionLocal nâng cao. Một thành phần kết hợp của công cụ chọn phương tiện có thể thêm chức năng mới để truy cập nội dung được bảo vệ bằng quyền trên thiết bị mà không cần thay đổi API và yêu cầu phương thức gọi công cụ chọn phương tiện phải biết ngữ cảnh bổ sung được sử dụng trong môi trường.

Tuy nhiên, CompositionLocal không phải lúc nào cũng là giải pháp tốt nhất. Chúng tôi không khuyến khích lạm dụng CompositionLocal vì nó có một số nhược điểm:

CompositionLocal khiến hành vi của thành phần kết hợp khó giải thích hơn. Khi tạo ra các phần phụ thuộc ngầm ẩn, trình gọi của các thành phần kết hợp mà sử dụng các phần phụ thuộc đó cần đảm bảo rằng một giá trị cho mỗi CompositionLocal đều được đáp ứng.

Hơn nữa, có thể không có nguồn thông tin rõ ràng về phần phụ thuộc này vì phần phụ thuộc này có thể thay đổi trong bất kỳ phần nào của Cấu phần. Do đó, việc gỡ lỗi ứng dụng khi xảy ra sự cố có thể khó khăn hơn vì bạn cần phải di chuyển lên Cấu phần để xem giá trị current được cung cấp ở đâu. Các công cụ như Tìm thông tin sử dụng trong IDE hoặc Layout Inspector của Compose cung cấp đủ thông tin để giảm thiểu vấn đề này.

Quyết định có sử dụng CompositionLocal hay không

Có một số điều kiện nhất định có thể khiến CompositionLocal trở thành giải pháp hiệu quả cho trường hợp sử dụng của bạn:

CompositionLocal phải có giá trị mặc định tốt. Nếu không có giá trị mặc định, bạn phải đảm bảo rằng nhà phát triển gặp khó khăn quá nhiều khi gặp phải tình huống mà giá trị cho CompositionLocal không được cung cấp. Việc không cung cấp giá trị mặc định có thể gây ra sự cố và thất vọng khi tạo các thử nghiệm hoặc xem trước một thành phần kết hợp mà sử dụng CompositionLocal sẽ luôn yêu cầu phải được cung cấp rõ ràng.

Tránh sử dụng CompositionLocal cho các khái niệm không được coi là áp dụng cho phạm vi cây hoặc thứ bậc. CompositionLocal là hợp lý khi nó có thể được sử dụng bởi bất kỳ thành phần con nào, chứ không phải một vài trong số đó.

Nếu trường hợp sử dụng của bạn không đáp ứng các yêu cầu này, hãy xem phần Phương án thay thế cần xem xét trước khi tạo CompositionLocal.

Ví dụ về một phương pháp không nên áp dụng là tạo một CompositionLocal chứa ViewModel của một màn hình cụ thể sao cho mọi thành phần kết hợp trong màn hình đó đều có thể tham chiếu đến ViewModel để triển khai một số logic. Đây là một phương pháp không nên áp dụng vì không phải tất cả thành phần kết hợp dưới một cây giao diện người dùng cụ thể đều cần biết về ViewModel. Cách tốt nhất là chỉ truyền cho các thành phần kết hợp thông tin mà họ cần theo mẫu mà trạng thái giảm xuống và các sự kiện tăng lên. Cách tiếp cận này sẽ giúp các thành phần kết hợp của bạn có thể sử dụng lại và thử nghiệm dễ dàng hơn.

Tạo CompositionLocal

Có hai API để tạo một CompositionLocal:

  • compositionLocalOf: Việc thay đổi giá trị đã cung cấp trong quá trình soạn lại sẽ chỉ làm mất hiệu lực nội dung đọc current.

  • staticCompositionLocalOf: Không giống như compositionLocalOf, bản đọc của một staticCompositionLocalOf không được theo dõi bằng ứng dụng Compose. Việc thay đổi giá trị sẽ khiến toàn bộ lambda contentCompositionLocal được cung cấp được kết hợp lại, thay vì chỉ áp dụng cho các vị trí mà giá trị current được đọc trong Thành phần.

Nếu giá trị bạn cung cấp cho CompositionLocal có nhiều khả năng không thay đổi hoặc không bao giờ thay đổi, hãy sử dụng staticCompositionLocalOf để nhận các lợi ích hiệu suất.

Ví dụ: hệ thống thiết kế của ứng dụng có thể giữ nguyên cách nâng cao các thành phần kết hợp bằng cách sử dụng hiệu ứng đổ bóng cho thành phần giao diện người dùng. Vì có nhiều độ nâng (elevation) của ứng dụng phải áp dụng trong toàn bộ cây giao diện người dùng, chúng tôi sử dụng CompositionLocal. Vì giá trị CompositionLocal được lấy theo điều kiện dựa trên giao diện hệ thống, chúng tôi sử dụng API compositionLocalOf:

// LocalElevations.kt file

data class Elevations(val card: Dp = 0.dp, val default: Dp = 0.dp)

// Define a CompositionLocal global object with a default
// This instance can be accessed by all composables in the app
val LocalElevations = compositionLocalOf { Elevations() }

Cung cấp giá trị cho CompositionLocal

Thành phần kết hợp CompositionLocalProvider liên kết các giá trị với phiên bản CompositionLocal cho Hệ phân cấp đã có. Để cung cấp giá trị mới cho CompositionLocal, hãy sử dụng hàm sửa lỗi infix provides liên kết khoá CompositionLocal với value như sau:

// MyActivity.kt file

class MyActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            // Calculate elevations based on the system theme
            val elevations = if (isSystemInDarkTheme()) {
                Elevations(card = 1.dp, default = 1.dp)
            } else {
                Elevations(card = 0.dp, default = 0.dp)
            }

            // Bind elevation as the value for LocalElevations
            CompositionLocalProvider(LocalElevations provides elevations) {
                // ... Content goes here ...
                // This part of Composition will see the `elevations` instance
                // when accessing LocalElevations.current
            }
        }
    }
}

Sử dụng CompositionLocal

CompositionLocal.current trả về giá trị do CompositionLocalProvider gần nhất cung cấp một giá trị cho CompositionLocal đó:

@Composable
fun SomeComposable() {
    // Access the globally defined LocalElevations variable to get the
    // current Elevations in this part of the Composition
    MyCard(elevation = LocalElevations.current.card) {
        // Content
    }
}

Lựa chọn thay thế đáng cân nhắc

CompositionLocal có thể là một giải pháp quá mức cho một số trường hợp sử dụng. Nếu trường hợp sử dụng của bạn không đáp ứng các tiêu chí quy định trong mục Quyết định có sử dụng CompositionLocal hay không, thì có một giải pháp khác có thể sẽ phù hợp hơn với trường hợp sử dụng của bạn.

Truyền các tham số tường minh

Bạn nên trình bày rõ ràng các phần phụ thuộc của thành phần kết hợp . Bạn nên truyền cho ác thành phần kết hợp chỉ những gì chúng cần. Để khuyến khích việc phân tách và sử dụng lại thành phần kết hợp, mỗi thành phần kết hợp phải chứa ít thông tin nhất có thể.

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    MyDescendant(myViewModel.data)
}

// Don't pass the whole object! Just what the descendant needs.
// Also, don't  pass the ViewModel as an implicit dependency using
// a CompositionLocal.
@Composable
fun MyDescendant(myViewModel: MyViewModel) { /* ... */ }

// Pass only what the descendant needs
@Composable
fun MyDescendant(data: DataToDisplay) {
    // Display data
}

Đảo ngược quyền kiểm soát

Một cách khác để tránh truyền các phần phụ thuộc không cần thiết đến một thành phần kết hợp là thông qua đảo ngược quyền kiểm soát. Thay vì thành phần con nhận một phần phụ thuộc để thực thi một logic, thành phần gốc nên làm điều đó.

Xem ví dụ sau đây, trong đó thành phần con cần kích hoạt yêu cầu để tải một số dữ liệu:

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    MyDescendant(myViewModel)
}

@Composable
fun MyDescendant(myViewModel: MyViewModel) {
    Button(onClick = { myViewModel.loadData() }) {
        Text("Load data")
    }
}

Tuỳ thuộc vào trường hợp, MyDescendant có thể phải chịu nhiều trách nhiệm. Ngoài ra, việc truyền MyViewModel dưới dạng phần phụ thuộc làm cho MyDescendant có thể sử dụng lại ít hơn vì các phần này hiện đã được kết hợp với nhau. Hãy xem xét phương án thay thế không truyền phần phụ thuộc vào thành phần con và sử dụng quy tắc kiểm soát đảo ngược khiến đối tượng cấp trên phải chịu trách nhiệm thực thi logic:

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    ReusableLoadDataButton(
        onLoadClick = {
            myViewModel.loadData()
        }
    )
}

@Composable
fun ReusableLoadDataButton(onLoadClick: () -> Unit) {
    Button(onClick = onLoadClick) {
        Text("Load data")
    }
}

Phương pháp này có thể phù hợp hơn cho một số trường hợp sử dụng vì nó lấy thành phần con khỏi đối tượng cấp trên trực tiếp. Các thành phần kết hợp của đối tượng cấp trên có xu hướng trở nên phức tạp hơn thay vì có nhiều hơn các thành phần kết hợp có tính linh hoạt ở cấp độ thấp hơn.

Tương tự, bạn có thể sử dụng @Composable nội dung lambda theo cách tương tự để nhận được các lợi ích tương tự:

@Composable
fun MyComposable(myViewModel: MyViewModel = viewModel()) {
    // ...
    ReusablePartOfTheScreen(
        content = {
            Button(
                onClick = {
                    myViewModel.loadData()
                }
            ) {
                Text("Confirm")
            }
        }
    )
}

@Composable
fun ReusablePartOfTheScreen(content: @Composable () -> Unit) {
    Column {
        // ...
        content()
    }
}