你正盯着一段 Compose 代码,嘴里吃着早上准备的水果零食,突然看到了这个:
CompositionLocalProvider(LocalContentColor provides Color.Red) {
// 各种 UI 代码
}
此时的你,一定心里犯嘀咕:“这是什么黑魔法?”
别慌。看完这篇文章,你不仅能彻底搞懂 CompositionLocalProvider,甚至还能把它讲得明明白白。
我奶奶听了都能搞懂。
Let's Go。
迷宫里穿针引线
想象你正在盖一栋房子。一栋超级大的房子,比如有 50 个房间的豪宅。
现在你需要给每个房间铺设电线。传统做法是什么?你得把电线穿过每一堵墙,一个房间接着一个房间,一层接着一层地硬拉。
电线没铺过,总见过铺网线吧!
在 Compose 的世界里,这就等同于下面这种噩梦般的代码:
@Composable
fun App(themeColor: Color) {
Screen(themeColor = themeColor)
}
@Composable
fun Screen(themeColor: Color) {
Content(themeColor = themeColor)
}
@Composable
fun Content(themeColor: Color) {
Card(themeColor = themeColor)
}
@Composable
fun Card(themeColor: Color) {
Title(themeColor = themeColor)
}
@Composable
fun Title(themeColor: Color) {
Text("Hello!", color = themeColor) // 终于用上了!
}
看出问题了吗?
这个 themeColor 被迫穿过了四层根本不需要它的函数。它们只是像传烫手山芋一样把数据往下递。
这确实是一种能用的通用做法,如果一个函数一个参数,那么就使用它的所有的外层函数都需要把这个参数传递下去。这被称为 属性透传 (Prop Drilling),如果你做过 Compose 开发,这将是每个 UI 开发者的梦魇。
救星:CompositionLocal
如果我告诉你,你的豪宅里早就建好了一个随时可用的电力系统呢?
你不需要再穿墙打洞拉电线,而是可以直接……“广播”电力,就像无线充电那样!任何需要用电的房间只要接入这个广播网络就行,根本不需要硬连线。
这正是 CompositionLocal 的作用。
它允许你在 UI 树的某个节点提供数据,然后该节点下方任何位置的 Composable 都可以直接获取这个数据——完全不需要通过一层层的函数去传递参数。
// 定义你的频道
val LocalThemeColor = compositionLocalOf { Color.Black }
@Composable
fun App() {
// “广播”这个值
CompositionLocalProvider(LocalThemeColor provides Color.Red) {
Screen() // 不需要再传参了!
}
}
@Composable
fun Screen() {
Content() // 依然不需要传参!
}
@Composable
fun Content() {
Card() // 继续往下!
}
@Composable
fun Card() {
Title() // 马上就到了!
}
@Composable
fun Title() {
// 直接接受到对应频道
val themeColor = LocalThemeColor.current
Text("Hello!", color = themeColor)
}
魔法吗?不,这是非常巧妙的 Compose 设计。
核心角色
来认识一下我们的主角团:
1. CompositionLocal:广播频道
把它想象成一个命名的无线电频率。它本身不包含任何数据——它只是一个标识符、一个 Key,或者说是一个“频道号”。
val LocalThemeColor = compositionLocalOf { Color.Black }
// ^^^^^^^^^^^
// 默认值(作为兜底)
2. CompositionLocalProvider:广播信号塔
这是真正用来广播数值的工具。
它的作用就是宣布:“嘿,我下方的所有节点听好了,当你们调频到 LocalThemeColor 时,你们收到的颜色将会是 Color.Red。”
CompositionLocalProvider(LocalThemeColor provides Color.Red) {
// 这里面的所有内容都会接收到 Color.Red
}
3. current:收音机接收器
任何 Composable 都是通过它来“调频”并接收广播值的。
val color = LocalThemeColor.current // “这个频道现在在播什么?”
你其实一直在用
如果你写过较多的 Compose 代码,你一定见过:
Text(
text = "Hello World",
color = MaterialTheme.colorScheme.primary
)
或者 LocalContext.current。
猜猜 MaterialTheme.colorScheme 的真身是什么?
object MaterialTheme {
val colorScheme: ColorScheme
@Composable
@ReadOnlyComposable
get() = LocalColorScheme.current // LocalColorScheme 又是一个 Local
}
没错。MaterialTheme 其实就是对 CompositionLocal 的一层华丽封装。
当你用 MaterialTheme { ... } 包裹你的应用时,它在悄悄的干这种事儿:
CompositionLocalProvider(
LocalColorScheme provides colorScheme,
LocalTypography provides typography,
LocalShapes provides shapes,
) {
content()
}
你一直都在使用这个广播系统,只是你之前并不知道而已。
原理
好,是时候看看内部构造了,这里可能有一些大家熟悉的老朋友。
组合树 (The Composition Tree)
当 Compose 运行你的代码时,它不仅仅是在执行函数。它在内存中构建了一棵树,这是你整个 UI 结构的轻量级映射。这棵树记录了:
- 存在哪些 Composable
- 它们在层级树中的位置
- 它们持有什么状态
你可以把它当成是你 UI 组件的“族谱”。
插槽表 (The Slot Table)
Compose 中还有一个核心是插槽表 (Slot Table)。它是一个扁平的、连续的内存结构,能高效地存储所有的组合数据。它就像一个超级优化的电子表格,追踪着你 UI 的一切。
CompositionLocal 是如何介入的
当你调用 CompositionLocalProvider 时:
- Compose 会记录这个绑定关系:“在树的这个节点,
LocalThemeColor=Color.Red”。 - 子节点默认继承。该节点下方的所有 Composable 都能看到这个绑定。
- 查找是层级化的。当子节点调用
.current时,Compose 会沿着树向上查找,直到找到一个Provider(或者使用默认值)。
这就像是家族继承制。孩子默认继承父母的资产(还不会有遗产税),除非有人明确修改了分配规则。
Local 也有两种风味
有趣的地方来了。创建 CompositionLocal 有两种方式:
1. compositionLocalOf
val LocalThemeColor = compositionLocalOf { Color.Black }
特点:
- 追踪读取者:Compose 清楚地知道哪些 Composable 正在“收听”。
- 智能重组:当值发生变化时,只有读取了该值的节点会发生重组 (Recomposition)。
- 读取成本:稍高(因为有追踪开销)。
- 写入成本:低(精准的局部失效)。
适用场景:该值在应用的生命周期内可能会发生变化(如主题切换、用户偏好、动态配置)。
2. staticCompositionLocalOf
val LocalContext = staticCompositionLocalOf<Context> {
error("No Context provided")
}
特点:
- 不追踪:Compose 根本不关心谁在读取它。
- 核弹级重组:当值发生变化时,整个子树都会被重组!
- 读取成本:极低(没有追踪开销)。
- 写入成本:极高(会使下方的所有内容失效)。
适用场景:该值永远不会改变或者说极少变更的场景(如 Android Context、字体加载器、静态配置)。
怎么理解
把它想象成一个通知系统:
compositionLocalOf= 发短信。Compose 存了每个人的手机号。当发生变化时,它只给需要知道的人发短信。staticCompositionLocalOf= 防空警报。当发生变化时,全城所有人都会被惊动并做出反应,不管他们到底需不需要知道。
实战
1. 主题
val LocalAppColors = staticCompositionLocalOf { lightColors() }
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (darkTheme) darkColors() else lightColors()
CompositionLocalProvider(LocalAppColors provides colors) {
content()
}
}
// 在你应用的任何地方:
val colors = LocalAppColors.current
2. 提供 Android Context
val LocalAppContext = staticCompositionLocalOf<Context> {
error("No Context provided")
}
// 在根节点:
CompositionLocalProvider(LocalAppContext provides applicationContext) {
App()
}
// 任何你需要 Context 的地方:
val context = LocalAppContext.current
Toast.makeText(context, "Hello!", Toast.LENGTH_SHORT).show()
3. Navigation Controller (导航控制器)
val LocalNavController = staticCompositionLocalOf<NavController> {
error("No NavController provided")
}
// 随处可用:
val navController = LocalNavController.current
Button(onClick = { navController.navigate("settings") }) {
Text("Go to Settings")
}
在 Navigation3 中,不再有 LocalNavControler,使用一个 List 就行。
4. Feature Flags (功能开关)
val LocalFeatureFlags = compositionLocalOf { FeatureFlags() }
// 根据条件显示功能:
val features = LocalFeatureFlags.current
if (features.newCheckoutEnabled) {
NewCheckoutButton()
} else {
OldCheckoutButton()
}
5. 轻量级依赖注入
val LocalAnalytics = staticCompositionLocalOf<AnalyticsService> {
error("No AnalyticsService provided")
}
// 随时随地记录事件:
val analytics = LocalAnalytics.current
analytics.logEvent("button_clicked")
避战
能力越大,责任越大。CompositionLocal 也不能一把梭哈,下面是 CompositionLocal 的错误使用方式:
1. 单纯为了图省事
// 绝对别这么干
val LocalUserName = compositionLocalOf { "" }
@Composable
fun UserProfile() {
val userName = LocalUserName.current // 隐式依赖!
Text(userName)
}
为什么这是错的:如果 UserProfile 总是需要一个用户名才能工作,请让这个依赖显式可见:
// 应该这么写
@Composable
fun UserProfile(userName: String) { // 依赖一目了然!
Text(userName)
}
2. 把业务逻辑塞进去
// 绝对别这么干
val LocalUserRepository = compositionLocalOf<UserRepository> { ... }
@Composable
fun SomeScreen() {
val repo = LocalUserRepository.current
LaunchEffect(Unit){
val user = repo.fetchUser() // 在组合阶段请求数据!
}
}
为什么这是错的:CompositionLocal 是为 UI 层面的关注点(主题、导航、Context)设计的,而不是用来处理业务逻辑的。请使用合适的架构(如 ViewModel、UseCase)来管理业务数据。
3. 依赖默认值
// 有风险
val LocalUserSession = compositionLocalOf<UserSession?> { null }
@Composable
fun ProfileScreen() {
val session = LocalUserSession.current
// 万一是 null 呢?
Text(session!!.userName) // 极有可能 💥
}
更好的做法:
val LocalUserSession = compositionLocalOf<UserSession> {
error("UserSession not provided! Wrap with SessionProvider.")
}
现在,如果你忘了提供这个值,你会得到一个明确的错误提示,而不是一个莫名其妙的崩溃。
决策指南
你可能会问我,什么时候该用 CompositionLocal?
在用之前,先问自己这些问题:
推荐使用的情况:
- 有很多 Composable 都需要这个值:比如被 50+ 个组件使用的全局主题色。
- 这个值确实是“环境”属性:它是环境上下文,而不是具体的业务数据。
- 层层传参已经变得不切实际:比如要往下透传 10 层以上的属性。
- 这个值与 UI 行为息息相关:颜色、排版、间距、导航。
绝对不要使用的情况:
- 只有极少数 Composable 需要它:老老实实传参就行。
- 它是业务/领域数据:请使用
ViewModel或状态管理。 - 你只是想少写几层参数传递:这不是聪明,这是偷懒。
- 这个依赖应该是显式的:如果一个组件必须依赖某个东西才能工作,把它作为参数。
主题应该用哪个
在很多文章或者教程中,会把主题(Theme)作为 compositionLocalOf 的典型例子。
这里我们不说谁对谁错,当你碰到这个问题的时候,请你从性能开销和重组范围(Recomposition Scope)这两个维度来思考这个问题:
1. 昂贵的“订阅追踪”开销
前面文章提到,compositionLocalOf 会“追踪读取者”。这就意味着,如果你的某个 Text 组件读取了主题颜色,Compose 引擎就会在内部的 Slot Table(插槽表)中记录下:“组件 A 订阅了颜色,组件 B 订阅了颜色……”。
你想想,一个稍微复杂点的页面,有多少个组件会读取主题颜色、字体大小、圆角大小?几乎是所有组件! 如果使用 compositionLocalOf,Compose 会为页面上的成百上千个 UI 节点建立依赖追踪。这会消耗大量的内存,并且在初始组合(Initial Composition)时拖慢渲染速度。
2. “核弹级重组”
staticCompositionLocalOf 的缺点是:一旦值发生改变,它下方的整个 UI 树都会被“核弹级”全部重组。 但在“主题切换(如从日间模式切到夜间模式)”这个具体场景下,我们问自己两个问题:
- 频率高吗? 极低。用户可能几天才切一次,或者跟随系统早晚切一次。它绝不是像动画那样每秒 60 次的高频操作。
- 需要局部重组吗? 根本不需要!当你把日间模式切成夜间模式时,页面上的背景、文字、按钮、卡片……几乎 99% 的 UI 都需要重新绘制颜色。就算你用
compositionLocalOf做到“精准重组”,结果依然是 99% 的组件被触发重组。
现在看来主题切换是个极低频的操作,且一旦切换本来就需要全屏刷新,我们凭什么要在 99.9% 不切换主题的时间里,去白白承担 compositionLocalOf 带来的高昂的“订阅追踪”开销呢?
因此,这里我认为,直接使用 staticCompositionLocalOf 放弃追踪,换取日常运行时的极致性能,才是最明智的买卖。
进阶技巧
1. 统一用 Local 作为命名前缀
val LocalAppTheme = compositionLocalOf { ... } // ✅ 规范
val AppTheme = compositionLocalOf { ... } // ❌ 容易混淆
这是业界的约定俗成。遵守它,你的队友会感谢你的。
2. 把相关的 Local 分组管理
object AppLocals {
val LocalAppTheme = compositionLocalOf { AppTheme() }
val LocalAnalytics = staticCompositionLocalOf<Analytics> { ... }
val LocalNavController = staticCompositionLocalOf<NavController> { ... }
}
3. 创建辅助属性
val LocalAppTheme = compositionLocalOf { AppTheme() }
// 别再到处写 LocalAppTheme.current 了:
val appTheme: AppTheme
@Composable
@ReadOnlyComposable
get() = LocalAppTheme.current
// 现在你可以直接这么写:
Text(color = appTheme.primaryColor)
4. 一次性 Provide 多个值
CompositionLocalProvider(
LocalTheme provides darkTheme,
LocalAnalytics provides analytics,
LocalNavigation provides navController,
LocalUserSession provides session,
) {
App()
}
干净又清爽。
5. 在测试中注入自定义 Provider
@Test
fun testUserProfile() {
composeTestRule.setContent {
CompositionLocalProvider(
LocalUserSession provides mockUserSession
) {
UserProfile()
}
}
// 断言验证...
}
CompositionLocal 让测试变得更容易,因为你可以轻松替换掉这些依赖。
6. 提前预览组件
和上面的 #5 类似,我们可以通过自定义 Provider 来提前预览你的组件。
@Preview
@Composable
fun PreviewBigTag() {
CompositionLocalProvider(
LocalCommonBackground provides mockBackground
) {
BigTag(text = "I Love Compose")
}
}
CompositionLocal 让组件预览变得更容易,因为你可以轻松为 Preview 提供环境 Mock 数据,而不必修改组件的参数签名。
总结
我希望你用这种方式永远记住 CompositionLocal:
把你的 Compose 树想象成一栋大楼。
- 常规传参 = 抱着快递,一个楼层一个房间地挨个手递手派送。
- CompositionLocal = 物流管道。你在 1 楼把东西丢进去,大楼里的任何房间都能直接提取。
- compositionLocalOf = 智能管道,它只会通知那些正在等快递的房间。
- staticCompositionLocalOf = 傻瓜管道,只要有快递进来,它就用大喇叭对整栋楼广播。
如果这篇文章帮你搞懂了 CompositionLocal,帮忙当个赞吧,转发给需要的小伙伴,一起来 Compose!