SuperAPI AI 工具集

chrome-drawn-outside-the-theme-scope

编程与开发

查看项目

更新于 10-17 16:16

技能介绍

用途

这份资料针对 Kotlin/Compose 桌面应用中的一个具体缺陷:应用自己绘制的窗口装饰(自定义标题栏、启动画面、崩溃对话框等)被组合在应用主题之外,因此读到的 MaterialTheme.colorScheme 是框架内置的默认浅色方案,而不是用户选择的主题。资料的目标是说明该问题的成因、修复方式、相关陷阱以及验证方法。

问题成因

应用自身的窗口装饰会先于主题被组合,因为它们包裹着主题:

// adapted — the desktop window body
Column(Modifier.fillMaxSize()) {
    CustomTitleBar(…)     // ← here, MaterialTheme.colorScheme is the FRAMEWORK DEFAULT
    App()                 // ← AppTheme(…) is entered in here
}

在第二行之上,MaterialTheme.colorScheme 既不是错误也不是 null:当没有提供主题时,该对象会回退到内置的浅色方案,因此标题栏读到的是完全合法、但与用户选择无关的颜色。

修复方式

修复不是再套一层主题,而是把决策解析一次,把最终颜色交给装饰:

// adapted — pulled out of AppTheme so chrome living outside it can ask the same question
@Composable
fun isDarkTheme(themeMode: String): Boolean = when (themeMode) {
    THEME_MODE_LIGHT -> false
    THEME_MODE_SYSTEM -> isSystemInDarkTheme()
    else -> true
}

val isDark = isDarkTheme(themeMode)          // above the theme
CustomTitleBar(containerColor = if (isDark) Color.Black else Color.White,
               titleColor = if (isDark) Color.White else Color.Black, …)

陷阱

  • 失败是静默的,看起来像另一个 bug。没有异常、没有 lint、没有缺失 provider 的警告——装饰只是用默认调色板渲染。常见反馈是“标题栏忽略深色模式”和“窗口白了一秒”,两者都会让人去查装饰自身的代码,而那段代码单独看是正确的。
  • 在装饰外再套一个 MaterialTheme 是错误的修复。它会重新运行调色板生成,给装饰一个不属于应用的 scheme 对象,并且一旦应用主题新增了某个修饰符而副本没有,就会产生漂移。装饰只是几种颜色,直接传进去。
  • 模式到布尔值的解析必须只存在一份、被调用两次。把 when 复制进装饰是典型的漂移:以后新增一个模式,只有一份副本会知道。抽出来还能让两条路径可证明地一致——单一定义加两个调用点,比两个相同的 when 块更容易被 grep 到。
  • 它必须保持为 @Composable 函数,且不能在启动时解析。跟随系统的分支调用 isSystemInDarkTheme(),这是组合期读取,会在系统切换时重新运行。如果在启动时缓存成普通值,装饰在用户更改系统主题前是对的,之后就是错的,直到下次冷启动——即“滞后一次启动”的症状。同理,强制深色的子树也不能调用它:在这种子树里系统的答案是错的,因此该决策应作为局部值发布(force-dark-immersive-subtree)。
  • 有些颜色由约定固定,不应跟随主题。窗口控制按钮的圆点靠颜色而非对比度被识别,因此它们是 scheme 之外的命名常量,旁边写明原因。
  • 应用自绘的装饰在窗口内部,因此也在上报尺寸之内。应用渲染的标题栏占据其下所有布局所测量的容器的一部分,这与窗口外的原生装饰不同。任何从窗口计算高度的逻辑都必须有条件地减去它。
  • 装饰必须能够禁用自身。在不适合自定义标题栏的环境(虚拟桌面、不支持合成的宿主)中会整体分支掉;颜色解析必须放在该分支内部,否则会为一个没人绘制的标题栏计算 scheme,并在每次跳过它的启动中付出偏好读取的代价。
  • 第二个窗口也是装饰。作为主题窗口的兄弟节点被组合的窗口——迷你播放器、偏好设置窗口、选择器——在构造上就位于主题之外,和标题栏完全一样。它必须要么自己重新进入主题,要么承诺使用显式颜色。
  • 非 Compose 的装饰最容易被遗忘。在任何组合存在之前就弹出平台对话框的崩溃处理器根本无法读取主题——它的颜色来自工具包默认值。
  • 枚举主题根,而不是屏幕。grep 主题 composable:一个定义加一个调用点,意味着当时绘制的其他一切都在它之外,这个数字就是整个审计。
  • 不要读取两次偏好。装饰和主题应从同一个存储值收集;同一偏好的两个独立收集器在启动期间可能相差一次发射,表现为标题栏用旧主题、内容用新主题持续一两帧。
  • 装饰是最先绘制的,而偏好是最后到达的。存储的模式异步到达组合,因此最初几帧使用收集器的初始值。用存储的默认值而非硬编码的浅色来初始化该收集器,否则深色模式下的每次冷启动都会在窗口顶部闪白。
  • 永不跟随主题的颜色仍应放在颜色文件里。把窗口控制常量放在 scheme 旁边便于查找,也让理由就在旁边。

验证方法

# 1. The shared decision: one definition, and every place that calls it. Two callers is the healthy
#    shape — the theme, and the chrome above it. A `when (themeMode)` anywhere else is a copy.
grep -rn --include='*.kt' 'isDarkTheme(' . | grep -v '/build/'

# 2. Chrome taking colours as parameters rather than reading a scheme. Read the default values:
#    they are the fallback the chrome shows if a caller forgets, so they should be the safe ones.
grep -rn -B6 --include='*.kt' -E '(containerColor|titleColor): Color = Color\.' . | grep -v '/build/'

# 3. Colours deliberately outside the scheme, each of which needs its reason on the line above.
grep -rn -B3 --include='*.kt' -E 'val window[A-Z][A-Za-z]*Button' . | grep -v '/build/'

资料还建议用肉眼双向验证:以深色启动,运行中把应用切到浅色,观察装饰随内容一起变化而不是等到下次启动;然后设为跟随系统,在前台翻转操作系统主题;最后截取深色模式冷启动的第一帧,白闪说明在偏好到达之前发生了默认 scheme 读取。

适用场景与限制

该资料适用于使用 Compose 构建桌面应用、且自绘窗口装饰的开发者。资料未提供具体的库版本、平台支持范围或性能数据;部分引用(如 responsive-gate-size-not-platform、force-dark-immersive-subtree)指向同一来源的其他条目,本资料未展开其内容。