动图魔方技术拆解 15:ArkTS 深浅色与跟随系统的应用级 ColorMode 实战

SEO 信息

  • SEO 标题:动图魔方技术拆解 15:ArkTS 深浅色与跟随系统的应用级 ColorMode 实战
  • SEO 摘要 :基于 HarmonyOS NEXT / ArkTS 项目"动图魔方",本文拆解工具类 App 里很容易做浅、却很难做稳的一层:应用级主题模式。文章结合 Index.etsStorageService.etsEntryAbility.ets 的真实代码,说明 system / light / dark 三态为什么不能只停留在按钮样式,setColorMode 怎样落到应用级,主题偏好如何持久化,以及页面背景、卡片、边框、正文色、毛玻璃导航与径向渐变怎样在深浅色之间保持统一。适合正在做 HarmonyOS 主题切换、ArkTS 工具类 App、ColorMode 适配和视觉验收闭环的开发者参考。
  • 关键词:HarmonyOS, ArkTS, ColorMode, 深色模式, 浅色模式, 跟随系统, Preferences, 动图魔方, Index.ets
  • 文章封面doc/csdn-series/covers/cover-15-color-mode-theme-system.jpg
  • 投稿方向:普通技术拆解 / HarmonyOS 应用级主题与视觉一致性
  • 项目环境 :HarmonyOS SDK 6.1.0(23)、ArkTS、DevEco Studio、GIFRubiksCube

第 13 篇把主题偏好放进了 Preferences,第 14 篇又把 themeMode 留在了工作台顶层状态里。真正到了第 15 篇,问题才变得具体起来:工具类 App 的主题切换不是"按钮点亮"就结束,而是要同时覆盖应用级 ColorMode、页面颜色 token、底部毛玻璃导航、统计卡片、空状态和预览区,否则浅色和深色只会变成两套互相打架的 UI。

一、真实工程问题背景

"动图魔方"不是纯展示型应用,而是一个高频在首页、编辑页、作品页、发现页和"我的"页之间来回切换的工具工作台。对这种产品来说,主题系统如果只做一半,会立刻暴露出几类问题:

  1. 用户在"我的"页点了深色,结果只是按钮变紫,页面主体仍然是浅底。
  2. 主题切换后底部导航仍用固定底色,毛玻璃和阴影在深色里发灰、在浅色里发脏。
  3. 下次冷启动又回到默认主题,之前的选择没有被真正保存。
  4. "跟随系统"如果只是一个标签,而没有调用应用级 setColorMode,系统换肤时 App 不会同步。
  5. 卡片、边框、正文色和渐变背景各写各的,最后形成"深色背景 + 浅色边框 + 浅色阴影"的割裂观感。

这也是为什么这一篇要把状态、持久化、能力调用和视觉 token 放在一起拆。ColorMode 在 HarmonyOS 里不是单一 API 问题,而是一个完整闭环。

二、目标与边界

这一篇要回答 5 个工程问题:

  1. 为什么主题模式必须保留 system / light / dark 三态,而不是简单布尔值。
  2. 应用级 setColorMode 该在什么时机调用,才能兼顾冷启动和运行时切换。
  3. 为什么主题偏好必须持久化,并且只接受合法枚举值。
  4. pageBg()cardBg()cardBorder()titleColor()bodyColor()softBg() 这类函数怎样服务整套页面。
  5. 底部导航、卡片和径向渐变怎样在浅色与深色里保持统一,不变成两套互不相关的视觉。

这一篇不展开的内容:

  1. Preferences 的整体模型设计,已在第 13 篇展开。
  2. 单页工作台状态拆分,已在第 14 篇展开。
  3. 更复杂的主题 token 文件拆分和设计系统沉淀,当前版本还在 Index.ets 内部收口,后续如继续膨胀再下沉。

三、第一步不是换颜色,而是保留三态主题模型

很多项目一开始会把主题写成 isDark: boolean,但工具类 App 很快就会撞墙,因为"跟随系统"并不是浅色和深色之间的第三个视觉,而是第三种控制策略。

当前项目在页面顶层显式保留了三态:

ts 复制代码
@State themeMode: string = 'system';
@State darkPreview: boolean = false;

private async loadThemeMode(): Promise<void> {
  const mode = await StorageService.loadThemeMode(this.ctx());
  this.themeMode = mode;
  this.darkPreview = mode === 'dark';
  this.applyColorMode(mode);
}

这里至少解决了两层问题:

  1. themeMode 负责表达用户真正选择的模式,是"系统 / 浅色 / 深色"三态。
  2. darkPreview 负责当前页面需要走哪套视觉 token,是"当前是否按深色绘制"的渲染态。

为什么不只保留 themeMode 一个状态?因为页面里有大量颜色判断需要快速落到深浅分支,比如背景、卡片、边框、导航和渐变。如果每次都拿 themeMode === 'dark' 去推导,局部代码会很碎;而 darkPreview 可以把"当前 UI 是否按深色渲染"压成统一判断口。

这也是第 14 篇里把 themeMode 留在 Index.ets 顶层的原因。主题不是某个局部卡片自己的事情,而是整个工作台共享状态。

四、应用级 ColorMode 不能只在按钮点击时处理

主题切换最常见的假实现,是点击按钮后只改本地状态,不碰应用级颜色模式。结果就是页面看起来变了,但系统层能力、窗口级配色和跟随系统行为没有真正接入。

"动图魔方"把应用级调用收在 applyColorMode() 里:

ts 复制代码
private applyColorMode(mode: string): void {
  try {
    if (mode === 'dark') {
      this.ctx().getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_DARK);
    } else if (mode === 'light') {
      this.ctx().getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT);
    } else {
      this.ctx().getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
    }
  } catch (err) {
  }
}

这段实现有两个关键点:

  1. darklight 不是只改页面颜色,而是显式写入应用级 ColorMode。
  2. system 不是自定义第三套颜色,而是回到 COLOR_MODE_NOT_SET,把最终决定权交还给系统。

这正是"跟随系统"与"深色 / 浅色覆盖"的本质区别。如果这里把 system 也强行映射成某个固定色值,用户在系统里切换主题时,App 并不会真正同步。

另外,冷启动也要把默认状态拉回正确位置。EntryAbility.etsonCreate() 里先把应用设回未覆盖状态:

ts 复制代码
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  try {
    this.context.getApplicationContext().setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET);
  } catch (err) {
    hilog.error(DOMAIN, 'testTag', 'Failed to set colorMode. Cause: %{public}s', JSON.stringify(err));
  }
}

这一步的价值是:无论上一次运行中间发生过什么,应用启动时先回到"允许系统接管"的安全基线,然后页面层再根据 Preferences 恢复用户实际偏好。这样不会出现窗口初始状态和页面状态互相打架的问题。

五、主题切换要同时更新 UI、ColorMode 和本地持久化

如果切主题只改颜色,不保存;或者只保存,不立刻应用;或者只调用 ColorMode,不更新页面状态,用户都会感知到"这个开关是假的"。

当前项目把三件事放进同一个入口:

ts 复制代码
private async setThemeMode(mode: string): Promise<void> {
  this.themeMode = mode;
  this.darkPreview = mode === 'dark';
  this.applyColorMode(mode);
  await StorageService.saveThemeMode(this.ctx(), mode);
  this.statusText = mode === 'dark'
    ? '已切换深色主题'
    : mode === 'light'
      ? '已切换浅色主题'
      : '已切换为跟随系统';
}

这个顺序很重要:

  1. 先更新页面状态,让用户立刻看到反馈。
  2. 再调用应用级 applyColorMode(),把系统层模式同步过去。
  3. 最后把模式写入 Preferences,保证下次冷启动还能恢复。

如果把顺序反过来,用户会感到点击后界面反馈变慢;如果漏掉其中任何一步,就会出现"当前看着对、下次重启又丢了"或者"状态文案变了、颜色却没变"的问题。

六、Preferences 不是附属功能,而是主题系统的一部分

第 13 篇已经拆过存储模型,但在主题这条链路里,还有一个容易忽略的细节:本地值不能盲信,必须做合法枚举兜底。

StorageService.ets 的实现如下:

ts 复制代码
const THEME_KEY = 'theme_mode';

static async loadThemeMode(context: common.UIAbilityContext): Promise<string> {
  try {
    const store = await preferences.getPreferences(context, PREF_NAME);
    const raw = await store.get(THEME_KEY, 'system');
    if (raw === 'light' || raw === 'dark' || raw === 'system') {
      return raw;
    }
    return 'system';
  } catch (err) {
    return 'system';
  }
}

static async saveThemeMode(context: common.UIAbilityContext, mode: string): Promise<void> {
  try {
    const store = await preferences.getPreferences(context, PREF_NAME);
    await store.put(THEME_KEY, mode);
    await store.flush();
  } catch (err) {
  }
}

这里的兜底非常有价值:

  1. 只接受 light / dark / system 三种合法输入。
  2. 任意异常值都回退到 system,不会把错误配置继续扩散到 UI。
  3. flush() 保证主题偏好不是只停在内存写入,而是尽快真正落盘。

这意味着主题系统不是"可有可无的小偏好",而是和作品、草稿一样,属于用户再次打开 App 时必须被恢复的工作上下文。

七、真正决定观感的是一组颜色函数,而不是一个主题按钮

应用级 ColorMode 解决的是"系统怎么认你",真正决定页面观感的,还是页面内部的视觉 token。

当前版本把核心颜色判断收在一组函数里:

ts 复制代码
private pageBg(): string {
  return this.darkPreview ? '#151420' : '#F8F6FF';
}

private cardBg(): string {
  return this.darkPreview ? '#B8242235' : '#BAFFFFFF';
}

private cardBorder(): string {
  return this.darkPreview ? '#55FFFFFF' : '#9FFFFFFF';
}

private titleColor(): string {
  return this.darkPreview ? '#F6F3FF' : '#171329';
}

private bodyColor(): string {
  return this.darkPreview ? '#C7C1DD' : '#746D8F';
}

private softBg(): string {
  return this.darkPreview ? '#88302A4A' : '#88F2EFFB';
}

这组函数的价值不在于"颜色值漂亮",而在于统一:

  1. 页面背景、卡片背景、卡片边框和正文色从同一状态口 darkPreview 出发。
  2. 浅色不是纯白铺满,深色也不是纯黑铺满,而是保留轻微染色,和产品本身的紫蓝视觉保持一致。
  3. softBg() 给未选中按钮、统计卡片和次级容器复用,避免每个组件自己猜一个"差不多"的浅底或深底。

如果没有这层统一,ThemeChoice、ProfileMetric、ProfileInfoCard 和底部导航会很快长成四套互不兼容的颜色逻辑。

八、主题入口组件必须和 token 同步,而不是自己再定义一套颜色

"我的"页里的主题切换入口,本质上是对整套 token 的一次直接验收。当前组件写法很克制:

ts 复制代码
@Builder
ThemeChoice(label: string, mode: string) {
  Text(label)
    .layoutWeight(1)
    .height(38)
    .fontSize(13)
    .fontWeight(this.themeMode === mode ? FontWeight.Bold : FontWeight.Medium)
    .fontColor(this.themeMode === mode ? '#FFFFFF' : this.bodyColor())
    .textAlign(TextAlign.Center)
    .borderRadius(14)
    .backgroundColor(this.themeMode === mode ? '#6A4DFF' : this.softBg())
    .onClick(() => this.setThemeMode(mode))
}

这里做对了三件事:

  1. 选中态直接走统一主色 #6A4DFF,而不是为主题按钮单独再造一套选中色。
  2. 未选中文字用 bodyColor(),底色用 softBg(),和页面其他次级模块共享视觉语言。
  3. 点击事件统一回到 setThemeMode(mode),而不是局部偷改 darkPreview,避免出现"按钮看起来变了,但应用级 ColorMode 没改"的分叉逻辑。

对应的真实页面也能看到深浅色差异已经落到了整页层级,而不只是局部按钮:

从这两张图里可以直接验证几件事:

  1. 顶部标题、说明文字、统计卡片和信息卡片都随主题变化。
  2. 主题入口未选中态在浅色和深色里都保留了可读性,不会糊成一片。
  3. 底部工作台导航没有脱离主题体系单独存在。

九、最容易翻车的是底部毛玻璃导航

主题切换里最难看的地方,往往不是普通卡片,而是半透明、模糊和阴影叠加的导航区。因为它同时依赖背景内容、透明色、边框和投影,任何一个值过重都会显脏。

"动图魔方"的底部导航延续了第 1 篇的毛玻璃策略,但在第 15 篇里可以更明确地看出它和主题系统的关系:

ts 复制代码
.backgroundBlurStyle(BlurStyle.COMPONENT_THICK)
.backgroundColor(this.darkPreview ? '#33242235' : '#38FFFFFF')
.border({ width: 1, color: this.darkPreview ? '#40FFFFFF' : '#80FFFFFF' })
.shadow({
  radius: 22,
  color: this.darkPreview ? '#66000000' : '#1F000000',
  offsetX: 0,
  offsetY: 8
})

这里的关键不只是"用了毛玻璃",而是下面这几个约束:

  1. 浅色和深色都只保留低透明度染色,不能把模糊材质盖死。
  2. 深色边框要足够轻,否则会变成一圈发灰描边。
  3. 阴影在浅色和深色里不能等强度,否则深色会显脏、浅色会发飘。

这也是为什么底部导航不能脱离主题系统单独 hardcode。它必须和 darkPreview 绑定,否则主题切换时最先出戏的就是这块。

十、整页统一感还依赖背景渐变,而不是单色铺底

如果主题切换只改 pageBg(),整页很容易变成"浅色一片白、深色一片黑"。工具类 App 想保留品牌感,还需要更柔和的一层背景氛围。

当前 build() 末尾给整页加了两套径向渐变:

ts 复制代码
.radialGradient(this.darkPreview ? {
  center: ['50%', '6%'],
  radius: '140%',
  colors: [['#2E2950', 0.0], ['#1B1929', 0.5], ['#121120', 1.0]]
} : {
  center: ['50%', '6%'],
  radius: '140%',
  colors: [['#FFFFFF', 0.0], ['#EFE9FF', 0.5], ['#E6E0F7', 1.0]]
})

这个实现解决的是视觉连续性问题:

  1. 浅色不至于白得发空,能和紫蓝主色保持呼应。
  2. 深色也不是纯黑背景,而是保留一点带紫色调的空间感。
  3. 页面切换时,首页、编辑页、作品页和"我的"页都能共用同一套背景基调。

对于"动图魔方"这种强调创作感和预览感的工具来说,这种背景策略比机械的单色切换更接近产品气质。

十一、调试与验收:主题系统要看真实页面,不只看代码

主题系统最容易犯的错,就是代码看起来全对,但真实页面里仍有局部没有同步。当前这篇文章对应的工程证据主要有三类:

  1. 应用级调用证据Index.ets 里的 loadThemeMode()applyColorMode()setThemeMode(),以及 EntryAbility.ets 启动时的 setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET)
  2. 持久化证据StorageService.ets 里的 THEME_KEY、合法枚举校验和 flush()
  3. 真实页面截图证据:浅色与深色个人页截图,能直接对比主题入口、统计卡片、信息卡片、背景和底部导航是否统一。

对应源码对象如下:

  1. entry/src/main/ets/products/main/Index.ets
  2. entry/src/main/ets/common/services/StorageService.ets
  3. entry/src/main/ets/entryability/EntryAbility.ets

十二、工程验收清单

验收项 结果 证据
主题模式保留 system / light / dark 三态 通过 themeMode 状态与 StorageService.loadThemeMode() 合法枚举校验
"跟随系统"真正回到应用级未覆盖模式 通过 applyColorMode()COLOR_MODE_NOT_SET
冷启动先回到系统模式基线 通过 EntryAbility.onCreate() 调用 setColorMode(COLOR_MODE_NOT_SET)
主题切换同时更新 UI、ColorMode 和 Preferences 通过 setThemeMode() 内同步执行三步
非法本地主题值会回退到 system 通过 loadThemeMode() 中的合法枚举判断
页面背景、卡片、边框、正文色有统一 token 出口 通过 pageBg()cardBg()cardBorder()titleColor()bodyColor()softBg()
主题入口组件不单独维护额外颜色体系 通过 ThemeChoice() 直接复用 bodyColor()softBg()
底部毛玻璃导航跟随深浅色同步变化 通过 backgroundColorbordershadow 都使用 darkPreview 分支
浅色与深色真实截图中页面层级保持一致 通过 gifrubik_profile_expanded_light.jpeggifrubik_profile_expanded_dark.jpeg 对比

十三、小结

这一篇真正拆开的,不是"怎么做一个深色开关",而是工具类 App 的主题系统该如何形成闭环:

  1. themeMode 保留用户真实意图,用 darkPreview 驱动页面渲染。
  2. setColorMode() 把主题选择真正落到应用级,而不是停留在局部样式。
  3. 用 Preferences 保存主题偏好,并对异常值做枚举兜底。
  4. 用一组统一 token 函数覆盖背景、卡片、边框、正文色、软底和导航材质。
  5. 用真实页面截图验收,而不是只在代码层自我感觉"逻辑已经闭环"。

对"动图魔方"这种工作台式工具来说,主题不是装饰项,而是用户每天都会反复感知的底层体验。如果这一层做散了,后续加任何功能都会继续放大割裂感。

十四、下一篇衔接

第 16 篇继续拆 《动图魔方技术拆解 16:ArkUI 操作卡片宽度统一与移动端视觉验收》,重点会落在:

  1. 为什么首页、编辑页和"我的"页的卡片容器需要统一宽度与横向留白。
  2. 操作卡片、统计卡片、信息卡片和底部工作台在手机端怎样建立一致的视觉节奏。
  3. 真实模拟器截图如何作为 UI 验收证据,而不是只看设计稿或局部组件。