使用 CompositionLocal 的本機範圍資料

CompositionLocal 是可透過 Composition 隱密傳遞資料的工具。在這個頁面中,您將進一步瞭解 CompositionLocal、如何建立自己的 CompositionLocal,以及 CompositionLocal 是否為您用例的理想解決方案。

隆重推出 CompositionLocal

通常在 Compose 中,資料會做為每個可組合函式的參數,透過 UI 樹狀結構向下傳遞。這能讓可組合項的依附元件明確顯示。不過,對使用率高且用途廣泛的資料 (例如顏色或類型樣式) 來說,這可能並不容易。請參閱以下範例:

@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
    )
}

為避免需要將顏色做為明確的參數依附元件傳遞給大多數可組合項,Compose 提供了 CompositionLocal,讓您建立以樹狀結構範圍命名的物件,進而以隱含方式讓資料通過 UI 樹狀結構。

系統通常會為 CompositionLocal 在 UI 樹狀結構的特定節點中提供一個值。該值可用在相關的可組合項子系中,而無須將 CompositionLocal 宣告為可組合函式中的參數。

CompositionLocal 是 Material 主題在內部使用的方式。MaterialTheme 這個物件提供了 3 個 CompositionLocal 例項:colorSchemetypographyshapes,方便您之後在 Composition 的任何子系部分進行擷取。具體來說,這些是 LocalColorSchemeLocalShapesLocalTypography 資源,您可透過 MaterialThemecolorSchemeshapestypography 屬性存取。

@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
    )
}

CompositionLocal 執行個體的範圍限定為 Composition 的一部分,因此您可以在樹狀結構的不同層級提供不同的值。CompositionLocalcurrent 值會對應到祖系在 Composition 該部分中提供的最接近值。

如要為 CompositionLocal 提供新的值,請使用 CompositionLocalProvider 及相關的 provides 修正函式,後者可將 CompositionLocal 金鑰與 value 建立關聯。存取 CompositionLocalcurrent 資源時,CompositionLocalProvidercontent lambda 會取得提供的值。新值提供時,Compose 會重新編寫讀取 CompositionLocal 的 Composition 部分。

舉例來說,LocalContentColor CompositionLocal 包含偏好的內容顏色,用於文字和圖像,以確保與目前背景顏色形成對比。在以下範例中,我們將 CompositionLocalProvider 用來為 Composition 的不同部分提供不同的值。

@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")
}

圖 1. CompositionLocalExample 可組合函式的預覽畫面。

在上一例中,Material 可組合項在內部使用了 CompositionLocal 例項。如要存取 CompositionLocal 目前的值,請使用其 current 屬性。在以下範例中,我們可以看到 Android 應用程式常用的 LocalContext CompositionLocal,其中目前的 Context 值會用於設定文字格式:

@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)
}

自行建立 CompositionLocal

CompositionLocal可透過 Composition 隱密傳遞資料的工具

使用 CompositionLocal 的另一個關鍵信號是,當參數是跨參數時,實作中的中間層不應注意到該參數存在,這是因為假使讓這些中間層得知參數存在,會對可組合項的公用程式造成限制。舉例來說,Android 權限的查詢作業是由內部的 CompositionLocal 負責提供。為了存取裝置上受權限保護的內容,媒體選擇器可組合項可以新增功能,而無需更改其 API,也無須要求媒體選擇器的呼叫者注意在環境中使用的這項新增內容。

不過,CompositionLocal 不一定是最合適的解決方案。我們「不建議」過度使用 CompositionLocal 的原因是它有一些缺點:

CompositionLocal 會導致可組合項的行為難以理解。當它建立隱含的依附元件時,可組合項的呼叫者必須確保滿足每個 CompositionLocal 的值。

此外,這個依附元件可能沒有明確的可靠來源,因為可以在 Composition 的任何部分進行修改。因此,在問題發生時對應用程式進行偵錯可能會較為困難,因為您需要回到 Composition 頂端查看提供 current 值的位置。IDE 中的「找出用量」Compose 版面配置檢查器等工具提供了充足資訊,可以緩解這個問題。

決定是否要使用 CompositionLocal

在某些情況下,CompositionLocal 相當適合您使用:

CompositionLocal 應有正確的預設值。如果沒有預設值,您必須確保開發人員一般遇到的都是已提供 CompositionLocal 值的情況。建立測試或預覽使用該 CompositionLocal 的可組合項時,如未提供預設值,可能會導致問題和失敗,因此一律必須明確提供該值。

如果您的設計概念屬於非「樹狀範圍或子階層範圍」,請避免使用 CompositionLocalCompositionLocal 在所有子系 (而非只有少數) 都能使用的情況下才有意義。

如果您的用例不符合上述要求,請先參閱「可以考慮改用的替代方案」一節,然後再建立 CompositionLocal

建立 CompositionLocal 來保存特定畫面的 ViewModel,藉此讓該畫面中的所有可組合項取得對 ViewModel 的參照,以執行部分邏輯,這並不是好的做法。這是不良做法,因為並非特定 UI 樹狀結構下的所有可組合項都需要瞭解 ViewModel。建議您按照狀態下移和事件上移的模式,僅將必要資訊傳遞給可組合項即可,這樣才是最佳做法。這麼一來,您的可組合項將更容易重複使用,測試起來也會更簡單。

建立 CompositionLocal

有兩個 API 可用來建立 CompositionLocal

  • compositionLocalOf:更改在重組期間提供的值,「只會」使讀取其 current 值的內容失效。

  • staticCompositionLocalOf:與 compositionLocalOf 不同,Compose 不會追蹤 staticCompositionLocalOf 的讀取作業。變更此值會導致系統重新組合 content lambda 的整體性 (提供 CompositionLocal 的位置,而不只是在 Composition 中讀取 current 值的位置)。

如果提供給 CompositionLocal 的值不太可能變更,或一律不會變更,請使用 staticCompositionLocalOf 來獲取效能優勢。

舉例來說,應用程式的設計系統可能有一貫模式:藉由 UI 元件的陰影提升可組合項的高度。由於應用程式的不同高度應在整個 UI 樹狀結構中傳播,因此我們使用 CompositionLocal。由於 CompositionLocal 值會根據系統主題有條件地衍生,因此我們使用 compositionLocalOf API:

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

CompositionLocal 提供值

CompositionLocalProvider 可組合項會針對特定階層將值綁定到 CompositionLocal 例項。如要為 CompositionLocal 提供新值,請使用 provides 修正函式,該函式可將 CompositionLocal 金鑰與 value 建立關聯,如下所示:

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

使用 CompositionLocal

CompositionLocal.current 會傳回最接近的 CompositionLocalProvider 所提供的值 (為該 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
    }
}

可以考慮改用的替代方案

對某些用例而言,CompositionLocal 是極端的解決方案。如果您的用例不符合「判斷是否要使用 CompositionLocal」一節中指定的條件,可能就更適合採用其他解決方案。

傳送明確的參數

明確表明可組合項的依附元件是個好習慣。建議您「只」對可組合項傳遞所需項目。為了順利分解和重複使用可組合項,每個可組合項應盡可能減少儲存的資訊量。

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

控制反轉

避免將不必要的依附元件傳遞至可組合項的另一個方法,是透過「控制反轉」這個做法會讓父項 (而非子系) 接收依附元件,以便執行部分邏輯。

請參閱下例,瞭解子系為何須觸發要求來載入某些資料:

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

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

視情況而定,MyDescendant 的職責可能不只一個。此外,由於我們已搭配使用 MyDescendant,因此將 MyViewModel 做為依附元件傳送會增加重複使用的困難性。建議您改採替代方法,將依附元件傳遞到子系中,並利用控制反轉的原則讓祖系負責執行邏輯:

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

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

這個方法能將子項從其直接祖系中分離,因此可能更適合某些用例。為了提供較低層級的彈性可組合項,祖系可組合項通常會更為複雜。

同樣地,您可以透過相同方式使用 @Composable 內容 lambda,以享有相同的成果:

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

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