web-to-app 暗亮色切换机制:从顶栏按钮到 DataStore 持久化与主题揭示动画
web-to-app 暗亮色切换机制从顶栏按钮到 DataStore 持久化与主题揭示动画本篇围绕 web-to-app 构建器顶栏的月亮/太阳按钮完整讲解构建器 UI 深色模式的切换逻辑、设置持久化方式与主题揭示circular reveal动画的实现细节。读完后你能掌握三值暗色模式设置模型SYSTEM/LIGHT/DARK如何在 Compose 界面中消费、用户选择如何通过 Jetpack DataStore 持久化并在下次启动时恢复以及按钮触发的圆形主题揭示动画是如何用PixelCopy截屏加 Canvas 裁剪实现的。工作方式构建器「我的应用」页面顶栏的月亮/太阳按钮是全局 UI 深色模式的唯一入口其行为在 HomeScreen.kt 中实现在深色与浅色之间翻转底层设置其实有第三个SYSTEM值跟随系统但顶栏按钮只在两个显式模式间切换——点击时计算switchToDark !isDarkNow随后写入DARK或LIGHT永远不会写回SYSTEM见 HomeScreen.kt。主题揭示动画切换时会从按钮处播放 circular 主题揭示动画然后应用新主题。实现位于 ThemeRevealAnimation.kt。持久化选择写入 DataStore 的dark_mode键下次启动时自动恢复。三值设置模型与 DataStore 持久化主题状态的唯一权威来源是 ThemeManager.kt它是一个基于Context的单例所有偏好存储在一个名为theme_settings的 Jetpack DataStore 中// ThemeManager.kt private val Context.themeDataStore: DataStorePreferences by preferencesDataStore(name theme_settings) companion object { private val KEY_DARK_MODE stringPreferencesKey(dark_mode) // ... } enum class DarkModeSettings { SYSTEM, LIGHT, DARK; fun getDisplayName(): String when (this) { SYSTEM - Strings.followSystem // 跟随系统 LIGHT - Strings.alwaysLight // 始终浅色 DARK - Strings.alwaysDark // 始终深色 } }关键设计点darkModeFlow是 Eagerly 启动的StateFlow在 ThemeManager.kt 中DataStore 的偏好流被map成DarkModeSettings以SharingStarted.Eagerly启动、初始值SYSTEM。这意味着应用进程一启动暗色模式值就会从磁盘加载并常驻内存任何 Composable 都通过collectAsStateWithLifecycle()订阅它。容错解析持久化的值是一个字符串读取时用DarkModeSettings.valueOf(modeName)解析键缺失或值非法时统一回退到SYSTEM保证旧版本数据或损坏数据不会导致崩溃。主线程安全读取currentDarkMode属性区分线程——主线程上直接返回darkModeFlow.value由 Eagerly 流保持最新非主线程如 APK 导出路径的 IO 线程才走runBlocking的readDarkModeBlocking()避免 UI 线程阻塞在 DataStore 上。写入与缓存同步setDarkMode()同时完成 DataStore 写入与内存缓存更新suspend fun setDarkMode(mode: DarkModeSettings) { context.themeDataStore.edit { prefs - prefs[KEY_DARK_MODE] mode.name } cachedDarkMode mode }这就是选择被持久化、下次启动时恢复的底层保证DataStore 落盘后下次进程启动时darkModeFlow的 Eagerly 冷启动会把磁盘值读回内存。Compose 侧的消费SYSTEM 值如何落地Theme.kt 中的WebToAppTheme是构建器所有界面的主题入口它订阅themeManager.darkModeFlow并把三值设置翻译成最终的布尔暗色判断val darkModeSetting by themeManager.darkModeFlow.collectAsStateWithLifecycle() val useDarkTheme when (darkModeSetting) { ThemeManager.DarkModeSettings.SYSTEM - darkTheme // darkTheme 默认为 isSystemInDarkTheme() ThemeManager.DarkModeSettings.LIGHT - false ThemeManager.DarkModeSettings.DARK - true }只有SYSTEM分支才依赖isSystemInDarkTheme()即 Android 系统的uiMode配置LIGHT/DARK是无条件常量。随后据此选择 Material3 配色LightColorScheme背景#FBFBFC/DarkColorScheme背景#0B0B0E定义见 Theme.kt并通过LocalIsDarkThemeCompositionLocal 向整个界面树广播当前是否暗色供各处组件做条件渲染。主题揭示动画截屏 圆形裁剪从按钮处播放圆形揭示动画再应用新主题并非简单的 alpha 过渡而是一套快照揭示实现核心在 ThemeRevealAnimation.kt触发与半径计算ThemeRevealState.triggerReveal()接收按钮中心坐标由onGloballyPositioned在 HomeScreen 中捕获以该点到四个屏幕角的最大距离作为maxRadius保证圆形最终能覆盖全屏。屏幕截屏captureScreen()在 API 26 优先使用PixelCopy.request(window, ...)抓取窗口像素这是唯一能捕获到 Compose 绘制内容的正确方式API 26 以下或抓取失败时回退到view.draw(canvas)再失败则退化为 1x1 位图动画直接跳过而不影响功能。圆形揭示CircularRevealOverlay在旧主题的新界面上方用Canvas绘制屏幕快照并通过clipPath(circlePath, ClipOp.Difference)挖出半径从 0 增长到maxRadius的圆孔480ms 的tweenCubicBezierEasing(0.22, 1.0, 0.36, 1.0)缓动驱动animationProgress。时序上有一个关键细节triggerReveal的onCaptureDone回调在截屏完成之后才调用themeManager.setDarkMode(newMode)见 HomeScreen.kt。也就是说主题写入发生在截屏之后——快照里保存的仍是旧主题的界面圆形区域内先露出新主题、圆形外维持旧主题画面最终形成从按钮处扩散开新主题的揭示效果。若LocalThemeRevealState不可用revealState null则跳过动画直接写入模式功能不受影响。作用范围构建器主题 ≠ 应用主题需要明确边界暗亮色设置只控制构建器本身的外观。你生成的应用拥有独立的主题设置亮 / 暗 / 跟随系统在 应用配置 中按应用配置与顶栏按钮写入的theme_settingsDataStore 完全隔离。从源码结构看这种隔离在数据模型中同样成立应用实体 WebApp.kt 带有自己的followSystemDarkMode: Boolean false字段属于按应用存储的配置不会读取也不会写入构建器的dark_mode键。小结web-to-app 的暗亮色机制可以概括为一条清晰的链路顶栏按钮HomeScreen.kt只在DARK/LIGHT间翻转 → 截屏成功后调用 ThemeManager.setDarkMode() 持久化到theme_settingsDataStore →darkModeFlow驱动 WebToAppTheme 重选 Material3 配色 → 圆形揭示动画用PixelCopy快照 ClipOp.Difference完成视觉过渡。三值枚举保留了SYSTEM作为底层默认与容错回退值但显式交互路径始终保持两态翻转的简单心智模型。创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考