深入 Jetpack Compose CompositionLocal 全链路:从隐式数据传递到组合作用域的内部机制与工程实践

最近在一个大型 Compose 项目中做重构,发现 MaterialTheme 的 colors 和 typography 能在任意深度的 Composable 中直接访问,不需要层层传参。这种”全局可用但又不是真正全局”的机制就是 CompositionLocal——这套机制比表面上看起来要精巧得多。

问题:参数传递的”漏斗困境”

写 Compose 界面时,迟早会遇到这个场景:

@Composable
fun UserProfile(user: User, theme: Theme, locale: Locale) {
    Column {
        UserAvatar(user, theme)
        UserInfo(user, locale, theme)
    }
}

@Composable
fun UserAvatar(user: User, theme: Theme) {
    // 其实 theme 只是传给下一层
    AvatarImage(user.avatarUrl, theme)
}

每个中间层都要声明自己不需要的参数,只为了让下游 Composable 能拿到——这就是漏斗式传参。参数表膨胀到 5 个以上时,可读性和维护成本直线上升。

CompositionLocal 解决的就是这个问题:让数据跳过中间层,在 Composition 树的某个作用域内被所有子节点直接访问。

CompositionLocal 的核心机制

CompositionLocal 的运作依赖 Composition 树的层级作用域。它不是全局单例,也不是依赖注入,而是 Compose 在组合阶段维护的一个隐式数据槽位。

// 定义
val LocalContentColor = compositionLocalOf { Color.Black }

// 提供值——在某个 Composable 作用域内注入
CompositionLocalProvider(LocalContentColor provides Color.Red) {
    Text("这行文字是红色")  // 自动获取 Red
    Surface {
        Text("这行也是红色") // 继承上层作用域
    }
}

Compose 运行时会做这件事:把当前 CompositionLocalProvider 提供的值存入 Composer 内部的一个 CompositionLocalMap(本质是 PersistentHashMap)。当子 Composable 通过 LocalContentColor.current 读取时,Composer 沿着 Composition 树向上查找这个 Map 链,找到最近作用域中存入的值。

整个查找过程发生在组合阶段,值生命周期与 Composable 节点绑定,不存在跨线程问题和内存泄漏风险。

两种 Provider 策略:静态 vs 动态

两种创建方式的行为差异很大,文档通常一笔带过,但误解它们会直接导致不必要的重组。

compositionLocalOf

val LocalScrollState = compositionLocalOf { ScrollState(0) }

动态追踪:当值变化时,只有实际读取了 current 的 Composable 会重组,其他子节点不受影响。适合频繁变化的值——滚动位置、动画状态等。

实现上,Compose 会在读取 current 的 Composable 上标记隐式依赖,值变化时只通知这些节点。

staticCompositionLocalOf

val LocalDensity = staticCompositionLocalOf { Density(1f) }

无追踪:值变化时,整个 CompositionLocalProvider 作用域内所有子 Composable 都被重组,不管是否读取了这个 Local。适合基本不变的值——主题、Density、Configuration。

选择依据很明确:会频繁改变且读取范围窄的用 compositionLocalOf,基本不变的用 staticCompositionLocalOf。实际项目中 90% 的场景用 static 版本就够了,MaterialTheme 里的 LocalContentAlphaLocalTextStyle 都是 static。

容易踩的坑

CompositionLocalProvider 的 provide 表达式每次重组都会执行:

@Composable
fun ExpensiveScreen() {
    val heavyComputation = remember { computeSomething() } // ✅ 只算一次
    
    CompositionLocalProvider(
        LocalMyData provides computeSomething()  // ❌ 每次重组都执行!
    ) {
        Content()
    }
}

provides 右边的表达式不在 Composable 追踪范围内,remember 对它无效。正确做法是把计算提到外面:

val data = remember { computeSomething() }
CompositionLocalProvider(LocalMyData provides data) {
    Content()
}

还有一个作用域嵌套的问题。外层提供了 LocalContentColor,内层又提供一个不同值,内层 Composable 取最近的,外层完全不可见——行为符合预期,但排查问题时容易被嵌套关系搞晕。我在 debug 时会加一个自定义 lint 规则,检查同一作用域内是否重复 provide 了同一个 Local,避免无意义的覆盖。

工程选型:什么时候用隐式,什么时候用显式

团队里争论最多的就是这个问题。我的判断逻辑:

**显式参数(硬传)**适合:

  • 数据只被 1-2 层使用
  • 数据对组件行为有决定性影响
  • 需要明确表达”没有这个参数,组件无法工作”

CompositionLocal 适合:

  • 数据被 3 层以上消费,且每层都可能用到
  • 数据代表”环境上下文”而非”业务输入”
  • 有合理默认值,缺失时系统能正常降级

userId 做成 CompositionLocal 给所有子组件用,是个典型反例。乍看省了参数,实际上让任何 Composable 都隐含依赖了”当前用户”,测试和复用变得困难。我更倾向用显式参数传递业务数据,CompositionLocal 只承载基础设施数据——主题、尺寸、语言、无障碍配置。

Material 3 的做法也是如此:Colors、Typography、Shapes 这些设计 tokens 走 CompositionLocal,内容数据(text、onClick 等)走显式参数。

内部实现:Composer 中的 Slot Table

用一句话概括原理:CompositionLocal 的值存在 Composer 的 Slot Table 中。CompositionLocalProvider 入栈时,Composer 在 Slot Table 写入一个新 Group,包含一个 CompositionLocalMap。子 Composable 取 current 时,从当前位置向上遍历 Slot Table,找到最近的有效 map。

这个设计决定了几个行为约束:

  • 值查找随组合深度 O(n),但树深通常不过几十层,影响可忽略
  • 值变化引发的重组只在组合阶段发生,Layout 和 Draw 不受影响
  • CompositionLocalProvider 本身不产生节点,只是标记点,不会增加布局层级

理解了 Slot Table,自然就明白了为什么 CompositionLocalProvider 不能放在条件语句里,为什么 provides 表达式每次重组都执行——它本质上是 Composer 的一条记录指令,不是声明式 UI 节点。

几条实践准则

给每个 CompositionLocal 提供明确的默认值。compositionLocalOf { default } 的 default 参数,不要让它抛异常。未 provide 时至少有一个可预测的行为,调试会轻松很多。

命名统一带 Local 前缀。 Compose 社区约定,IDE 会据此给出 lint 提示,Code Review 时也能一眼识别隐式依赖。

控制数量。 一个模块超过 3 个 CompositionLocal 就要警惕了,考虑用显式参数替代一部分。隐式依赖太多,等于回到了全局变量的老路。

MaterialTheme 内部通过 CompositionLocalProvider 一次性注入了十几种 Local,但它封装得极好——对外只有一个 MaterialTheme 入口,使用者完全感觉不到隐式依赖的存在。封装才是关键。