时光胶囊 App 支持四套主题——暖金、亮色、暗色、护眼。切换主题后,整个 App 的背景色、文字色、主色、边框色全部同步变化。这种"一个变量驱动全局颜色"的机制,核心是一个 ThemeColors 接口和一个 getThemeColors 函数。设计得好,新增主题只需要加一个分支;设计得差,改一个颜色需要改几十个文件。
完整效果

一、主题系统的整体架构
ThemeType 枚举 定义四种主题
↓
getThemeColors() 根据 ThemeType 返回 ThemeColors 对象
↓
this.getColors() Index 页面的快捷方法
↓
所有 UI 组件 通过 colors 参数接收颜色
四层架构——枚举→工厂函数→快捷方法→组件消费。 每一层只做一件事,职责清晰。
二、ThemeType 枚举
// 从 utils/Theme.ets 导入
enum ThemeType {
CAPSULE = 'CAPSULE', // 暖金主题(默认)
LIGHT = 'LIGHT', // 亮色主题
DARK = 'DARK', // 暗色主题
EYE_CARE = 'EYE_CARE' // 护眼主题
}
四种主题的定位
| 主题 | 键值 | 目标场景 |
|---|---|---|
| CAPSULE | ‘CAPSULE’ | 默认主题,温暖金色调 |
| LIGHT | ‘LIGHT’ | 标准亮色,高对比度 |
| DARK | ‘DARK’ | 暗色背景,低亮度 |
| EYE_CARE | ‘EYE_CARE’ | 低蓝光,长时间阅读 |
CAPSULE 是默认主题——和其他 App 的默认主题不同。 知识库 App 默认紫色,旅行 App 默认橙色,时光胶囊默认暖金色。每个 App 的默认主题和它的产品调性匹配——时光胶囊的"暖金"传递温暖和期待。
三、ThemeColors 接口
从所有页面的使用方式推断,ThemeColors 包含这些字段:
interface ThemeColors {
primary: string // 主色(按钮、标签、进度条)
background: string // 页面背景色
surface: string // 卡片/容器背景色
text: string // 主文字色(标题、正文)
textSecondary: string // 辅助文字色(说明、标签)
textTertiary: string // 最弱文字色(时间、占位符)
border: string // 边框色
error: string // 错误/危险色
}

颜色字段的使用频率
| 字段 | 使用位置 | 频率 |
|---|---|---|
| primary | 按钮、选中标签、进度条、链接 | 最高 |
| background | 页面背景 | 高 |
| surface | 卡片、输入框、导航栏背景 | 最高 |
| text | 标题、正文 | 最高 |
| textSecondary | 说明文字、辅助信息 | 高 |
| textTertiary | 时间戳、占位符 | 中 |
| border | 边框、分隔线 | 高 |
| error | 清空按钮、危险操作 | 低 |
primary 和 surface 是使用频率最高的——几乎所有交互元素都用到。 这两个字段的配色决定了整个主题的"性格"。
四、四套配色方案
暖金主题(CAPSULE)
| 字段 | 值 | 说明 |
|---|---|---|
| primary | #D4A574 | 暖金色 |
| background | #FBF8F3 | 米白色 |
| surface | #FFFFFF | 纯白 |
| text | #2C2417 | 深棕 |
| textSecondary | #8C7B6B | 浅棕 |
| textTertiary | #B8A99A | 更浅棕 |
| border | #E8E0D5 | 暖灰 |
| error | #E74C3C | 红色 |
整个色系围绕"棕色+金色"——温暖、复古、有时间感。 和"时光胶囊"的产品主题高度契合。
亮色主题(LIGHT)
| 字段 | 值 | 说明 |
|---|---|---|
| primary | #3B82F6 | 标准蓝 |
| background | #F9FAFB | 浅灰白 |
| surface | #FFFFFF | 纯白 |
| text | #111827 | 近黑 |
| textSecondary | #6B7280 | 中灰 |
| textTertiary | #9CA3AF | 浅灰 |
| border | #E5E7EB | 边框灰 |
| error | #EF4444 | 红色 |
标准亮色主题——高对比度,适合白天使用。 蓝色主色是最"安全"的选择——不暖不冷,大多数用户接受度高。
暗色主题(DARK)
| 字段 | 值 | 说明 |
|---|---|---|
| primary | #60A5FA | 亮蓝 |
| background | #111827 | 深灰黑 |
| surface | #1F2937 | 稍浅灰 |
| text | #F9FAFB | 近白 |
| textSecondary | #9CA3AF | 中灰 |
| textTertiary | #6B7280 | 暗灰 |
| border | #374151 | 深灰 |
| error | #F87171 | 浅红 |
暗色主题的文字和背景完全反转——背景变深,文字变浅。 主色从标准蓝变为亮蓝——在深色背景上需要更亮的颜色才能保持可读性。
护眼主题(EYE_CARE)
| 字段 | 值 | 说明 |
|---|---|---|
| primary | #059669 | 绿色 |
| background | #ECFDF5 | 浅绿白 |
| surface | #FFFFFF | 纯白 |
| text | #065F46 | 深绿 |
| textSecondary | #6B7280 | 中灰 |
| textTertiary | #9CA3AF | 浅灰 |
| border | #D1FAE5 | 浅绿 |
| error | #EF4444 | 红色 |
护眼主题用绿色为主色——绿色对眼睛的刺激最小。 背景带微弱绿色调,减少蓝光辐射。
五、getThemeColors 工厂函数
export function getThemeColors(theme: ThemeType): ThemeColors {
switch (theme) {
case ThemeType.CAPSULE:
return { primary: '#D4A574', background: '#FBF8F3', ... }
case ThemeType.LIGHT:
return { primary: '#3B82F6', background: '#F9FAFB', ... }
case ThemeType.DARK:
return { primary: '#60A5FA', background: '#111827', ... }
case ThemeType.EYE_CARE:
return { primary: '#059669', background: '#ECFDF5', ... }
default:
return { primary: '#D4A574', background: '#FBF8F3', ... }
}
}

switch-case 返回完整的 ThemeColors 对象——每个主题返回 8 个颜色值。 default 回退到暖金主题——保证即使传入无效主题值,也不会崩溃。
为什么用工厂函数而不是对象映射
// 方案 A(当前):工厂函数
function getThemeColors(theme: ThemeType): ThemeColors {
switch (theme) { ... }
}
// 方案 B:对象映射
const THEME_MAP: Record<ThemeType, ThemeColors> = {
[ThemeType.CAPSULE]: { ... },
[ThemeType.LIGHT]: { ... },
...
}
方案 A 更好——因为工厂函数可以加逻辑。 未来如果需要根据时间自动切换(白天亮色/晚上暗色),工厂函数里加 new Date().getHours() 判断即可。对象映射是纯数据,没有扩展空间。
六、主题切换的持久化
写入 Preferences
private async saveTheme(theme: ThemeType): Promise<void> {
try {
const prefs = await preferences.getPreferences(this.context!, 'settings')
await prefs.put('theme', theme)
await prefs.flush()
this.currentTheme = theme
} catch (e) {
// 静默失败——使用默认主题
}
}
先 flush 再更新 @State——保证数据持久化成功后才切换 UI。 如果顺序反过来(先更新 @State 再 flush),用户看到主题切换了但 Preferences 写入失败——下次重启又回到默认主题。
读取 Preferences
private async loadTheme(): Promise<void> {
try {
const prefs = await preferences.getPreferences(this.context!, 'settings')
const theme = await prefs.get('theme', ThemeType.CAPSULE)
this.currentTheme = theme as ThemeType
} catch (e) {
// 使用默认主题
}
}
默认值 ThemeType.CAPSULE——第一次打开 App 时是暖金主题。 as ThemeType 类型断言——Preferences 返回的是 PreferencesDataValue,需要断言为 ThemeType。
主题切换的刷新链路
用户点击主题标签
→ saveTheme()
→ prefs.put('theme', theme) 持久化
→ prefs.flush() 写入文件
→ this.currentTheme = theme 更新 @State
→ build 重新执行 触发重渲染
→ getColors() 返回新主题 所有颜色更新
→ 所有 UI 元素刷新 背景/文字/按钮全部变色
从用户点击到 UI 刷新——四步。 关键是 this.currentTheme = theme 这一步——它是触发 build 重新执行的开关。没有这步,即使 Preferences 写入成功,UI 也不会变。
七、颜色传递的架构模式
模式一:@State + getColors()(Index 页面)
@State currentTheme: ThemeType = ThemeType.CAPSULE
private getColors(): ThemeColors {
return getThemeColors(this.currentTheme)
}
build() {
Column() {
// 所有颜色通过 this.getColors() 获取
}
}
Index 页面直接持有 currentTheme @State——切换主题后 build 自动刷新。 getColors() 是快捷方法,避免每次写 getThemeColors(this.currentTheme)。
模式二:colors 参数传递(子组件)
// Index 传 colors 给子组件
MoodPicker({
colors: this.getColors(),
...
})
// 子组件用 this.colors 渲染
@Component
struct MoodPicker {
colors: ThemeColors = getThemeColors(ThemeType.CAPSULE)
build() {
Column() {
// 用 this.colors.primary 等
}
}
}
子组件不持有 currentTheme——只接收 colors 参数。 这保证了子组件的独立性——它不需要知道当前是什么主题,只用传进来的颜色渲染。
两种模式的对比
| 特性 | 模式一(Index) | 模式二(子组件) |
|---|---|---|
| 持有 currentTheme | 是 | 否 |
| 切换主题后自动刷新 | 是 | 是(父组件传新 colors) |
| 依赖关系 | 依赖 ThemeType | 依赖 ThemeColors |
| 可复用性 | 低(和 ThemeType 耦合) | 高(只依赖颜色对象) |
子组件用模式二——高可复用性。 MoodPicker 在封印 Tab 和详情页都能用——只要传不同的 colors 参数即可。
八、透明度后缀的系统化
所有使用透明度的位置
| 位置 | 后缀 | 透明度 | 用途 |
|---|---|---|---|
| 统计卡片背景 | 无 | 100% | surface 色本身 |
| 笔记本图标背景 | ‘15’ | 15% | 轻度背景 |
| MoodPicker 选中 | ‘20’ | 20% | 中度背景 |
| 快捷操作图标 | ‘12’ | 12% | 轻度背景 |
| 标签输入背景 | ‘15’ | 15% | 轻度背景 |
| 可开启卡片 | ‘#FFF8E1’ | 固定 | 暖黄背景 |
透明度的设计规则
| 透明度 | 适用场景 | 视觉效果 |
|---|---|---|
| 10%~15% | 图标背景、输入框 | 几乎不可见,微弱底色 |
| 15%~20% | 选中状态、活跃状态 | 可感知,但不抢内容 |
| 20%~30% | 强调、特殊状态 | 明显,引导注意力 |
| 30%+ | 不使用 | 太深,影响内容可读性 |
透明度是主题系统里最容易被忽视的细节。 它控制"颜色的强度"——同一个 primary 色,10% 透明度是微弱底色,20% 是选中状态,100% 是按钮实色。三个层次让同一颜色在不同场景下有不同的视觉重量。
九、Spacing 和 BorderRadius 常量
Spacing 间距系统
export const Spacing = {
xs: 4, // 最小间距
sm: 8, // 小间距
md: 16, // 标准间距
lg: 24, // 大间距
xl: 32 // 最大间距
}
BorderRadius 圆角系统
export const BorderRadius = {
sm: 6, // 小圆角(进度条、标签)
md: 12, // 中圆角(卡片、输入框)
lg: 16, // 大圆角(按钮、区块)
full: 999 // 全圆角(胶囊形)
}
FontSize 字号系统
export const FontSize = {
xs: 10, // 辅助文字
sm: 12, // 说明文字
md: 14, // 正文
lg: 16, // 小标题
xl: 20, // 标题
xxl: 24, // 大标题
hero: 32 // 数字展示
}
三套常量(Spacing/BorderRadius/FontSize)和颜色系统平行——共同构成 App 的设计语言。 颜色控制"看起来是什么感觉",间距控制"元素之间多远",圆角控制"多圆润",字号控制"多大"。
十、主题系统的设计原则
1. 颜色不硬编码
所有颜色通过 this.getColors() 或 colors.xxx 获取——不在组件里写死 #6C5CE7 或 #FFFFFF。这让主题切换成为可能——改一个变量,全局变色。
2. 透明度可预测
所有浅色背景都用 主色 + 透明度后缀——不随机选色。用户看到 primary + '15' 就知道这是"15% 透明度的主色",不需要看渲染结果。
3. 持久化优先
主题选择写入 Preferences——重启后保留。这是用户个性化设置的基本要求——没有人想每次打开 App 都重新选主题。
4. 默认值兜底
所有主题相关的方法都有 default/fallback——getThemeColors 的 default 返回暖金主题,loadTheme 的 catch 使用默认值。任何异常都不会导致 App 崩溃或显示异常。
5. 单一数据源
currentTheme 是唯一的主题状态——所有组件通过它(或它派生的 colors)获取颜色。不存在"这个组件用 currentTheme,那个组件用 localStorage"的混乱情况。

314

被折叠的 条评论
为什么被折叠?



