0

CompositionLocal trong Jetpack Compose

Mình đã sử dụng Jetpack Compose được một thời gian khá dài. Mặc dù thỉnh thoảng mình có sử dụng LocalContext hoặc các API liên quan như LocalResources, và cũng đã đọc các bài blog về CompositionLocal, nhưng mình chưa bao giờ thực sự hiểu thấu đáo cách nó hoạt động.

Gần đây, mình đã dành thời gian để thử nghiệm và cuối cùng đã nắm bắt được nó. Trong bài blog này, mình sẽ cho bạn thấy một số ví dụ từ quá trình vọc vạch của mình, thứ đã giúp mình hiểu cách thức vận hành của nó.

image.png

Giống như mình, có lẽ bạn đang sử dụng LocalContext khá thường xuyên. Mặc dù việc lấy chuỗi ký tự từ nó không được khuyến khích và chúng ta nên dùng LocalResources thay thế, nhưng vẫn có rất nhiều trường hợp trong code mà bạn cần đến đối tượng "God object" là Context như access system service (clipboard, vibrator…), start activity / intent, vân vân mà mây mây

Nhưng Context hay Resources thực sự được cung cấp như thế nào?

Để trả lời câu hỏi này, hãy cùng ôn lại nhanh cách Compose hoạt động thông qua một ví dụ đơn giản:

@Composable
fun Greeting(
    analytics: Analytics,
    modifier: Modifier = Modifier
) {
    analytics.trackEvent("Greeting (Re)composed")
    Text(
        modifier = modifier,
        text = "Hello World!"
    )
}

Về cơ bản, bạn phải truyền các thuộc tính — chẳng hạn như đối tượng Analytics trong trường hợp này — vào một hàm composable để chúng có thể được sử dụng. Ngoài ra, Compose runtime có thể phát hiện khi một thuộc tính thay đổi và nếu có, nó sẽ recompose hàm đó để sử dụng giá trị đã cập nhật.

Với LocalContext, điều này có vẻ khác biệt, phải không? Theo một cách nào đó, bạn không phải truyền Context vào hàm composable, bạn chỉ việc lấy nó và dùng.

Đây chính xác là mục đích mà CompositionLocal được thiết kế. Bạn có thể truy cập "bất cứ thứ gì" trong một hàm composable mà không cần cung cấp nó dưới dạng tham số. Tất cả các lợi ích khác của Composable runtime vẫn được giữ nguyên. Vì vậy, nếu Context thay đổi vì bất kỳ lý do gì, hàm composable cũng sẽ được recompose. Giống như thể nó được cung cấp dưới dạng một tham số vậy.

Hãy để mình trình bày cách nó hoạt động thông qua một ví dụ. Chúng ta sẽ tạo ra LocalAnalytics của riêng mình, để không còn phải truyền nó dưới dạng tham số vào hàm nữa.

Hãy bắt đầu với hàm composable Greeting đã được cập nhật, hàm này không còn yêu cầu truyền Analytics làm tham số nữa:

@Composable
fun Greeting(modifier: Modifier = Modifier) {
    val analytics = LocalAnalytics.current
    analytics.trackEvent("Greeting (Re)composed")
    Text(
        modifier = modifier,
        text = "Hello World!"
    )
}

Trong composable này, chúng ta sử dụng LocalAnalytics để theo dõi các sự kiện mỗi khi composable được compose hoặc recompose.

Analytics bản thân nó chỉ là một data class đơn giản nhận một Analytics.Provider và có hàm trackEvent để gửi eventName tương ứng đến Provider:

data class Analytics(private val provider: Provider) {

    sealed interface Provider {
        object LogCat : Provider
        object Println : Provider
        object Firebase : Provider
    }
    
    fun trackEvent(eventName: String) {
        when (provider) {
            Provider.LogCat -> println("Debug: LogCat - Tracking event: $eventName")
            Provider.Println -> println("Debug: Println - Tracking event: $eventName")
            Provider.Firebase -> println("Debug: Firebase - Tracking event: $eventName")
        }
    }
}

Để tạo LocalAnalytics, chúng ta có thể sử dụng hàm compositionLocalOf, hàm này nhận một giá trị mặc định:

val LocalAnalytics = compositionLocalOf { Analytics(Analytics.Provider.Println) }

Tại thời điểm này, chúng ta đã có thể sử dụng LocalAnalytics bên trong composable Greeting, nhưng nó sẽ luôn trả về giá trị mặc định là Analytics.Provider.Println. Nhiều khả năng, chúng ta muốn sử dụng tracking provider hiện tại đang được cung cấp, thứ có thể khác với giá trị mặc định.

Để cung cấp chính xác một instance Analytics với LocalAnalytics, chúng ta cần bọc composable Greeting từ phía người gọi vào trong một CompositionLocalProvider:

var analyticsProvider by remember { mutableStateOf<Analytics.Provider>(Analytics.Provider.Firebase) }
CompositionLocalProvider(LocalAnalytics provides Analytics(analyticsProvider)) {
    Greeting()
}

Lúc này, chúng ta cung cấp một đối tượng Analytics khác với mặc định, sử dụng Analytics.Provider.Firebase thay vì Analytics.Provider.Println.

“Nhưng cái này khác gì một singleton?” mình đã tự hỏi mình sau khi viết xong đoạn này. Việc biến Analytics thành một singleton, như đoạn code sau đây trình bày, cũng giải quyết được cùng một vấn đề, đúng không?

object Analytics {

    lateinit var provider: Provider
    /** Phần còn lại của code gốc */
}

Thay vì cung cấp triển khai Analytics thông qua một CompositionLocalProvider, mình chỉ việc gán một giá trị cho thuộc tính provider và sử dụng nó trong composable Greeting như trước. Nó hoàn toàn giống hệt, nhưng không có sự tham gia của Compose.

Vậy, tại sao không đi theo cách này? (Bỏ qua thực tế là singletons đôi khi cũng bị coi là một anti-pattern).

Sau khi nghiên cứu một chút, mình đã tìm ra hai lý do chính (và quan trọng):

  • Reactivity: Như mình đã đề cập trước đó, các thuộc tính Local* được Compose runtime theo dõi giống như các tham số trong một hàm composable. Vì vậy, nếu một giá trị thay đổi và được theo dõi như một đối tượng State, tất cả những nơi đọc nó sẽ tự động được recompose.
  • Overriding: Đây có lẽ là lập luận chính. Bạn có thể “ghi đè” giá trị được cung cấp (các thuộc tính Local*) bất kỳ lúc nào trong cấu trúc phân cấp view (view hierarchy) của mình. Đây có lẽ là lý do tại sao quy ước đặt tên Local được chọn. Bạn có thể lồng bao nhiêu CompositionLocalProvider tùy thích, cung cấp các giá trị Local*.current khác nhau cho các bên tiêu thụ chúng.
var analyticsProvider by remember { mutableStateOf<Analytics.Provider>(Analytics.Provider.Println) }
CompositionLocalProvider(LocalAnalytics provides Analytics(analyticsProvider)) {
    Column {
        RadioButton(
            text = "Println",
            selected = analyticsProvider is Analytics.Provider.Println,
            onClick = { analyticsProvider = Analytics.Provider.Println }
        )
        RadioButton(
            text = "LogCat",
            selected = analyticsProvider is Analytics.Provider.LogCat,
            onClick = { analyticsProvider = Analytics.Provider.LogCat }
        )
        RadioButton(
            text = "Firebase",
            selected = analyticsProvider is Analytics.Provider.Firebase,
            onClick = { analyticsProvider = Analytics.Provider.Firebase }
        )

        Greeting()
    }
}

Đừng bối rối bởi RadioButton. Nó không có gì cao siêu, chỉ là một wrapper đơn giản xung quanh material3.RadioButton:

@Composable
private fun RadioButton(
    text: String,
    selected: Boolean,
    onClick: () -> Unit,
) {
    Row {
        androidx.compose.material3.RadioButton(
            selected = selected,
            onClick = onClick
        )
        Text(
            text = text,
            modifier = Modifier
                .padding(start = 8.dp)
                .align(Alignment.CenterVertically)
        )
    }
}

Vậy đoạn code này đang làm gì? Mỗi khi một RadioButton được chọn, analyticsProvider thay đổi, và đối tượng Analytics cũng thay đổi theo. Vì điều này được theo dõi thông qua CompositionLocalProvider, bất kỳ nơi nào đọc LocalAnalytics.current sẽ nhận được thông báo về sự thay đổi, và hàm Greeting của mình sẽ thực hiện recompose.

Để giải thích trường hợp thứ hai, hãy tưởng tượng bạn muốn theo dõi việc nhấp vào một nút. Sự kiện nhấp chuột nên được đặt tên dựa trên vị trí trong cấu trúc phân cấp view, để bạn biết chính xác nút đó được nhấp ở đâu. Tên sự kiện theo dõi khi đó sẽ có dạng như view1_view2_viewN_buttonClick.

Cách tiếp cận đơn giản là xác định xem nút nằm ở đâu và đặt tên sự kiện tương ứng. Nhưng cách này rất mong manh. Điều gì xảy ra nếu bạn di chuyển các composable hoặc xóa một view trung gian? Bạn có nhớ cập nhật tên tracking không? Hoặc nếu nút đó là một component có thể tái sử dụng, nằm sâu trong một thư viện composable nào đó? Bạn có thực sự muốn truyền tên sự kiện dưới dạng tham số không? Không, bạn không muốn đâu 😉

Giải pháp với CompositionLocal là stack chúng. Bạn có thể đơn giản tạo một instance Analytics khác, cung cấp nó và bọc nút của bạn vào đó.

Với cách tiếp cận này, node của bạn — dù nó nằm trong view hiện tại hay đến từ một thư viện dùng chung — đều không cần instance Analytics hay tên sự kiện được truyền vào nó. Bản thân n không cần phải thay đổi. Nó chỉ đơn giản lấy instance Analytics được cung cấp cuối cùng thông qua LocalAnalytics.current và theo dõi sự kiện với tên chung là buttonClick.

Nói một cách tổng quát, thuộc tính Local*.current sẽ luôn trả về đối tượng “được cung cấp cuối cùng” (có thể gọi là “được cung cấp gần nhất”) trong cấu trúc phân cấp view.

Cả hai trường hợp mình mô tả đều không thể thực hiện dễ dàng với một singleton, chưa kể đến những vấn đề khác mà singleton có thể gây ra.

Vậy là xong! Mình hy vọng blog này đã giúp bạn hiểu rõ hơn một chút về LocalComposition.

Nguồn tham khảo : https://stefma.medium.com/compositionlocal-in-jetpack-compose-96c4052874eb


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí