HarmonyOS ArkTS 主题系统与架构设计:从骰子应用看设计 Token 与模块化实践
引子:为什么"换个颜色"这么难
一个完整的应用不能只有功能,还需要"好看"。朋友拿到骰子应用后,第一句话不是"功能不错",而是"这个深色主题能不能换成浅色的?"
这个问题听起来简单,但实现起来涉及了整个应用的颜色体系。如果颜色值散落在代码各处,改一个颜色就要改几十个地方;如果颜色集中管理,改一处就能全局生效。这就是"主题系统"存在的意义。
完整效果
Theme.ts:设计 Token 的定义
先看主题配置的核心代码:
export enum ThemeType {
DARK = 'dark',
LIGHT = 'light'
}
export interface ThemeColors {
bg: string;
surface: string;
primary: string;
accent: string;
text: string;
text2: string;
border: string;
}
export const Spacing = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 24
};
export const BorderRadius = {
sm: 4,
md: 8,
lg: 12,
xl: 16
};
export const FontSize = {
xs: 12,
sm: 14,
md: 16,
lg: 18,
title: 22,
xxl: 28
};

这段代码定义了四类设计 token:主题颜色、间距、圆角、字号。这些常量是整个应用的"设计语言",所有 UI 组件都应该引用这些常量,而不是硬编码颜色值。
什么是设计 Token?
设计 Token 是设计系统的最小单元,它把设计决策(比如"按钮用什么颜色"、“标题多大字号”)抽象成可复用的常量。好处是:
- 一致性:整个应用的颜色、间距、字号都遵循统一规范
- 可维护性:修改一处就能全局生效,不需要逐个查找替换
- 可协作性:设计师和开发者用同一套"语言"沟通
- 主题切换:切换主题只需替换 token 的值,不需要修改 UI 代码
为什么用枚举定义 ThemeType?
代码用枚举而不是字符串来定义主题类型:
export enum ThemeType {
DARK = 'dark',
LIGHT = 'light'
}
这样做有两个好处:
- 类型安全:只能使用枚举中定义的值,不会出现
'darkk'这样的拼写错误 - 代码提示:IDE 会自动提示可用的主题类型
但枚举也有缺点:编译后会生成额外的 JavaScript 代码,增加包体积。对于小项目影响不大,但如果要极致优化,可以用字符串字面量类型代替:
type ThemeType = 'dark' | 'light';
颜色配置的设计
ThemeColors 接口定义了 7 种颜色:
export interface ThemeColors {
bg: string; // 背景色
surface: string; // 卡片/表面色
primary: string; // 主色调(按钮、高亮等)
accent: string; // 强调色(数字、重要信息)
text: string; // 主要文字色
text2: string; // 次要文字色
border: string; // 边框色
}
这种分类方式参考了 Material Design 的颜色系统,但做了简化。对于这个小应用,7 种颜色足够了。如果要做更复杂的应用,可以扩展:
export interface ThemeColors {
// 基础色
bg: string;
surface: string;
card: string;
// 品牌色
primary: string;
primaryLight: string;
primaryDark: string;
// 强调色
accent: string;
accentLight: string;
// 文字色
text: string;
text2: string;
text3: string;
// 状态色
success: string;
warning: string;
error: string;
// 边框和分割线
border: string;
divider: string;
}
但要注意:颜色越多,主题切换越复杂。建议根据实际需求增减,不要过度设计。
间距、圆角、字号:统一的设计语言
除了颜色,主题配置还包括间距、圆角、字号等设计 token:
export const Spacing = {
xs: 4,
sm: 8,
md: 12,
lg: 16,
xl: 24
};
export const BorderRadius = {
sm: 4,
md: 8,
lg: 12,
xl: 16
};
export const FontSize = {
xs: 12,
sm: 14,
md: 16,
lg: 18,
title: 22,
xxl: 28
};
这些常量看起来简单,但它们是"设计系统"的基础。在大型项目中,这些值通常由设计师定义,开发者只引用常量。
为什么用数字而不是字符串?
间距和圆角用数字(比如 Spacing.sm = 8),而不是字符串(比如 '8px')。这是因为 ArkUI 的尺寸属性支持数字类型,单位默认是 vp(虚拟像素)。用数字更简洁,也更容易做计算:
// 好:数字可以计算
.padding(Spacing.md + Spacing.sm)
// 差:字符串需要解析
.padding('20vp')
命名规范:xs/sm/md/lg/xl
设计 token 的命名遵循"从小到大"的规范:
xs:extra small,最小sm:small,小md:medium,中等lg:large,大xl:extra large,最大
这种命名方式直观易懂,也方便扩展。如果需要更大的值,可以加 xxl;如果需要更小的值,可以加 xxs。
getThemeColors:主题切换的实现
export function getThemeColors(theme: ThemeType): ThemeColors {
if (theme === ThemeType.DARK) {
return {
bg: '#1A1A1A',
surface: '#2A2A2A',
primary: '#6366F1',
accent: '#F59E0B',
text: '#FFFFFF',
text2: '#9CA3AF',
border: '#374151'
};
}
// 浅色主题(预留)
return {
bg: '#FFFFFF',
surface: '#F3F4F6',
primary: '#4F46E5',
accent: '#D97706',
text: '#111827',
text2: '#6B7280',
border: '#E5E7EB'
};
}
这个函数根据主题类型返回对应的颜色配置。当前只实现了深色主题,浅色主题是预留的。
颜色值的选择
深色主题的颜色值经过精心选择:
- 背景色
#1A1A1A:不是纯黑#000000,而是深灰色。纯黑在 OLED 屏幕上会导致"发光"效果,看起来不舒服 - 表面色
#2A2A2A:比背景色稍浅,用于卡片、按钮等元素,形成层次感 - 主色调
#6366F1:靛蓝色,在深色背景上对比度足够,看起来也很舒服 - 强调色
#F59E0B:琥珀色,用于数字、重要信息,和主色调形成对比
这些颜色值不是随便选的,而是参考了 Material Design 的深色主题规范。如果要做浅色主题,也需要遵循类似的原则。
主题切换的触发
当前代码中,主题切换是通过修改 this.theme 状态来触发的:
@State theme: ThemeType = ThemeType.DARK;
但没有提供切换主题的 UI 控件。如果要支持浅色模式,需要:
- 添加设置页面或切换按钮
- 用
Preferences保存用户的选择 - 应用启动时读取保存的主题设置
颜色透明度的处理
代码中用字符串拼接来添加颜色透明度:
.backgroundColor(this.gc().primary + '20')
这依赖于颜色值是 6 位十六进制字符串。如果主题配置返回的是其他格式(比如 rgb() 或 rgba()),这种拼接就会失效。
更安全的做法是提供一个工具函数:
function withAlpha(hexColor: string, alpha: number): string {
// 确保输入是 6 位十六进制
if (!/^#[0-9A-Fa-f]{6}$/.test(hexColor)) {
return hexColor;
}
const alphaHex = Math.round(alpha * 255).toString(16).padStart(2, '0');
return hexColor + alphaHex;
}
// 使用示例
.backgroundColor(withAlpha(this.gc().primary, 0.12)) // 12% 透明度
这样不仅更安全,而且代码意图也更清晰——看到 withAlpha 就知道是在处理透明度,而不是字符串拼接。
模块化架构:文件组织的最佳实践
骰子应用的文件结构是这样的:
entry/src/main/ets/
├── model/
│ ├── Dice.ts # 骰子类型定义和核心逻辑
│ └── Database.ts # 数据持久化层
├── utils/
│ └── Theme.ts # 主题配置和颜色系统
├── pages/
│ └── Index.ets # 主页面组件
└── common/ # 公共工具
这种分层结构很常见:model 放数据定义,utils 放工具函数,pages 放 UI 组件。
为什么要分层?
分层的好处是:
- 关注点分离:UI 开发者只关心
pages,业务逻辑开发者只关心model - 可测试性:
model层的纯函数可以直接写单元测试,不需要启动 UI - 可复用性:如果将来要做命令行版本或 Web 版本,可以直接复用
model层的代码 - 可维护性:修改一个模块不会影响其他模块
依赖方向:单向依赖
好的分层架构应该遵循"单向依赖"原则:
pages → utils → model
pages可以引用utils和modelutils可以引用modelmodel不能引用pages或utils
如果 model 引用了 pages,就会产生循环依赖,导致代码难以维护。
模块的边界
每个模块应该有清晰的边界:
model/Dice.ts:只定义数据结构和纯函数,不依赖任何 UI 组件model/Database.ts:只封装存储逻辑,对外暴露简洁的 APIutils/Theme.ts:只提供颜色配置,不涉及业务逻辑pages/Index.ets:只负责 UI 渲染,业务逻辑交给model层
如果发现一个模块做了太多事情,就应该拆分。比如 Database.ts 既负责存储,又负责数据校验,可以拆成 Database.ts(存储)和 Validator.ts(校验)。
组件化设计:@Builder vs @Component
代码中大量使用了 @Builder 装饰器:
@Builder DiceTab() { ... }
@Builder CoinTab() { ... }
@Builder RandTab() { ... }
@Builder HistoryTab() { ... }
什么时候用 @Builder,什么时候用 @Component?
这是一个常见的决策问题。根据我的经验,可以从以下几个维度考虑:
- 状态需求:如果 UI 片段需要独立的状态,用
@Component;如果只是展示数据,用@Builder - 复用性:如果要在多处复用同一套 UI,用
@Component;如果只在一个地方用,用@Builder - 通信复杂度:如果需要和父组件频繁通信,用
@Builder(直接访问父组件状态);如果通信少,用@Component(通过 @Prop/@Link) - 性能:
@Component有独立的渲染上下文,性能开销稍大;@Builder共享父组件的渲染上下文,性能更好
组件的粒度
组件的粒度很重要——太粗了不好复用,太细了不好维护。一个经验法则是:一个组件只做一件事。
比如骰子页面,可以拆分成:
DiceTypeSelector:骰子类型选择器DiceCountControl:骰子数量控制器DiceResultDisplay:骰子结果显示RollButton:投掷按钮
每个组件职责单一,可以独立开发和测试。但在这个小应用中,把所有逻辑放在一个 @Builder 里也没问题,因为逻辑不复杂。
错误处理:分层防御
这个项目的错误处理比较薄弱,但在实际项目中,错误处理是必须的。建议采用"分层错误处理"策略:
数据层:捕获存储错误
class DiceDatabase {
async init(): Promise<void> {
try {
// 初始化逻辑
} catch (error) {
console.error('数据库初始化失败:', error);
throw error; // 向上抛出,让调用者处理
}
}
add(result: RollResult): void {
try {
// 添加逻辑
} catch (error) {
console.error('保存投掷结果失败:', error);
// 不抛出,避免影响用户体验
}
}
}
业务层:根据错误类型决定策略
private async doRoll(): Promise<void> {
try {
this.rolling = true;
// ... 投掷逻辑
if (this.db) {
this.db.add(createRollResult(this.diceType, fr));
this.history = this.db.getHistory();
}
} catch (error) {
console.error('投掷失败:', error);
// 降级处理:即使保存失败,也显示结果
this.showResult = true;
this.rolling = false;
}
}
UI 层:给用户友好的提示
// 用 Toast 提示用户
import { promptAction } from '@kit.ArkUI';
promptAction.showToast({
message: '保存失败,请重试',
duration: 2000
});
测试策略:从单元测试到集成测试
这个项目的测试可以分三层:
单元测试:测试纯函数
rollDice 和 createRollResult 是纯函数,很容易测试:
describe('Dice', () => {
it('rollDice 应该返回指定数量的结果', () => {
const results = rollDice(6, 3);
expect(results.length).toBe(3);
});
it('rollDice 的结果应该在 1 到 sides 之间', () => {
for (let i = 0; i < 100; i++) {
const results = rollDice(6, 1);
expect(results[0]).toBeGreaterThanOrEqual(1);
expect(results[0]).toBeLessThanOrEqual(6);
}
});
it('createRollResult 应该自动计算 total', () => {
const result = createRollResult('d6', [3, 4, 5]);
expect(result.total).toBe(12);
});
});
集成测试:测试数据库操作
describe('DiceDatabase', () => {
it('应该正确保存和读取历史记录', async () => {
const db = new DiceDatabase(mockContext);
await db.init();
const result = createRollResult('d6', [3, 4]);
db.add(result);
const history = db.getHistory();
expect(history.length).toBe(1);
expect(history[0].total).toBe(7);
});
});
UI 测试:测试交互逻辑
UI 测试需要 DevEco Studio 的测试框架支持,可以后续补充。核心是测试用户交互是否正确触发状态更新。
总结
这个骰子应用虽然简单,但涉及了 ArkTS 开发的多个核心知识点:主题系统、设计 token、模块化架构、组件化设计、错误处理、测试策略。在实际开发中,这些知识点会反复出现,只是复杂度不同。
适用边界:这个应用适合用作 ArkTS 入门学习项目,涵盖了 UI 开发、状态管理、数据持久化、主题系统等核心知识点。但如果要上架应用商店,还需要补充浅色主题、设置页面、错误提示、无障碍支持、单元测试等内容。建议在此基础上逐步扩展,而不是一次性做完所有功能。
对于 ArkTS 新手,建议从类似的小项目入手,逐步理解框架的设计哲学。ArkTS 的声明式 UI 和 React 有相似之处,但状态管理和生命周期有明显区别,需要花时间适应。
对于有经验的开发者,重点是理解 ArkTS 的约束——它不是 TypeScript 的简单扩展,而是一个有自己规则的框架。遵循框架的最佳实践,才能写出可维护、高性能的代码。
更多推荐



所有评论(0)