HarmonyOS ArkTS 主题系统与架构设计:从骰子应用看设计 Token 与模块化实践

📅 2026/7/18 11:04:04
HarmonyOS ArkTS 主题系统与架构设计:从骰子应用看设计 Token 与模块化实践
引子为什么换个颜色这么难一个完整的应用不能只有功能还需要好看。朋友拿到骰子应用后第一句话不是功能不错而是这个深色主题能不能换成浅色的这个问题听起来简单但实现起来涉及了整个应用的颜色体系。如果颜色值散落在代码各处改一个颜色就要改几十个地方如果颜色集中管理改一处就能全局生效。这就是主题系统存在的意义。完整效果Theme.ts设计 Token 的定义先看主题配置的核心代码exportenumThemeType{DARKdark,LIGHTlight}exportinterfaceThemeColors{bg:string;surface:string;primary:string;accent:string;text:string;text2:string;border:string;}exportconstSpacing{xs:4,sm:8,md:12,lg:16,xl:24};exportconstBorderRadius{sm:4,md:8,lg:12,xl:16};exportconstFontSize{xs:12,sm:14,md:16,lg:18,title:22,xxl:28};这段代码定义了四类设计 token主题颜色、间距、圆角、字号。这些常量是整个应用的设计语言所有 UI 组件都应该引用这些常量而不是硬编码颜色值。什么是设计 Token设计 Token 是设计系统的最小单元它把设计决策比如按钮用什么颜色、“标题多大字号”抽象成可复用的常量。好处是一致性整个应用的颜色、间距、字号都遵循统一规范可维护性修改一处就能全局生效不需要逐个查找替换可协作性设计师和开发者用同一套语言沟通主题切换切换主题只需替换 token 的值不需要修改 UI 代码为什么用枚举定义 ThemeType代码用枚举而不是字符串来定义主题类型exportenumThemeType{DARKdark,LIGHTlight}这样做有两个好处类型安全只能使用枚举中定义的值不会出现darkk这样的拼写错误代码提示IDE 会自动提示可用的主题类型但枚举也有缺点编译后会生成额外的 JavaScript 代码增加包体积。对于小项目影响不大但如果要极致优化可以用字符串字面量类型代替typeThemeTypedark|light;颜色配置的设计ThemeColors接口定义了 7 种颜色exportinterfaceThemeColors{bg:string;// 背景色surface:string;// 卡片/表面色primary:string;// 主色调按钮、高亮等accent:string;// 强调色数字、重要信息text:string;// 主要文字色text2:string;// 次要文字色border:string;// 边框色}这种分类方式参考了 Material Design 的颜色系统但做了简化。对于这个小应用7 种颜色足够了。如果要做更复杂的应用可以扩展exportinterfaceThemeColors{// 基础色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;}但要注意颜色越多主题切换越复杂。建议根据实际需求增减不要过度设计。间距、圆角、字号统一的设计语言除了颜色主题配置还包括间距、圆角、字号等设计 tokenexportconstSpacing{xs:4,sm:8,md:12,lg:16,xl:24};exportconstBorderRadius{sm:4,md:8,lg:12,xl:16};exportconstFontSize{xs:12,sm:14,md:16,lg:18,title:22,xxl:28};这些常量看起来简单但它们是设计系统的基础。在大型项目中这些值通常由设计师定义开发者只引用常量。为什么用数字而不是字符串间距和圆角用数字比如Spacing.sm 8而不是字符串比如8px。这是因为 ArkUI 的尺寸属性支持数字类型单位默认是 vp虚拟像素。用数字更简洁也更容易做计算// 好数字可以计算.padding(Spacing.mdSpacing.sm)// 差字符串需要解析.padding(20vp)命名规范xs/sm/md/lg/xl设计 token 的命名遵循从小到大的规范xsextra small最小smsmall小mdmedium中等lglarge大xlextra large最大这种命名方式直观易懂也方便扩展。如果需要更大的值可以加xxl如果需要更小的值可以加xxs。getThemeColors主题切换的实现exportfunctiongetThemeColors(theme:ThemeType):ThemeColors{if(themeThemeType.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状态来触发的Statetheme:ThemeTypeThemeType.DARK;但没有提供切换主题的 UI 控件。如果要支持浅色模式需要添加设置页面或切换按钮用Preferences保存用户的选择应用启动时读取保存的主题设置颜色透明度的处理代码中用字符串拼接来添加颜色透明度.backgroundColor(this.gc().primary20)这依赖于颜色值是 6 位十六进制字符串。如果主题配置返回的是其他格式比如rgb()或rgba()这种拼接就会失效。更安全的做法是提供一个工具函数functionwithAlpha(hexColor:string,alpha:number):string{// 确保输入是 6 位十六进制if(!/^#[0-9A-Fa-f]{6}$/.test(hexColor)){returnhexColor;}constalphaHexMath.round(alpha*255).toString(16).padStart(2,0);returnhexColoralphaHex;}// 使用示例.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 → modelpages可以引用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装饰器BuilderDiceTab(){...}BuilderCoinTab(){...}BuilderRandTab(){...}BuilderHistoryTab(){...}什么时候用 Builder什么时候用 Component这是一个常见的决策问题。根据我的经验可以从以下几个维度考虑状态需求如果 UI 片段需要独立的状态用Component如果只是展示数据用Builder复用性如果要在多处复用同一套 UI用Component如果只在一个地方用用Builder通信复杂度如果需要和父组件频繁通信用Builder直接访问父组件状态如果通信少用Component通过 Prop/Link性能Component有独立的渲染上下文性能开销稍大Builder共享父组件的渲染上下文性能更好组件的粒度组件的粒度很重要——太粗了不好复用太细了不好维护。一个经验法则是一个组件只做一件事。比如骰子页面可以拆分成DiceTypeSelector骰子类型选择器DiceCountControl骰子数量控制器DiceResultDisplay骰子结果显示RollButton投掷按钮每个组件职责单一可以独立开发和测试。但在这个小应用中把所有逻辑放在一个Builder里也没问题因为逻辑不复杂。错误处理分层防御这个项目的错误处理比较薄弱但在实际项目中错误处理是必须的。建议采用分层错误处理策略数据层捕获存储错误classDiceDatabase{asyncinit():Promisevoid{try{// 初始化逻辑}catch(error){console.error(数据库初始化失败:,error);throwerror;// 向上抛出让调用者处理}}add(result:RollResult):void{try{// 添加逻辑}catch(error){console.error(保存投掷结果失败:,error);// 不抛出避免影响用户体验}}}业务层根据错误类型决定策略privateasyncdoRoll():Promisevoid{try{this.rollingtrue;// ... 投掷逻辑if(this.db){this.db.add(createRollResult(this.diceType,fr));this.historythis.db.getHistory();}}catch(error){console.error(投掷失败:,error);// 降级处理即使保存失败也显示结果this.showResulttrue;this.rollingfalse;}}UI 层给用户友好的提示// 用 Toast 提示用户import{promptAction}fromkit.ArkUI;promptAction.showToast({message:保存失败请重试,duration:2000});测试策略从单元测试到集成测试这个项目的测试可以分三层单元测试测试纯函数rollDice和createRollResult是纯函数很容易测试describe(Dice,(){it(rollDice 应该返回指定数量的结果,(){constresultsrollDice(6,3);expect(results.length).toBe(3);});it(rollDice 的结果应该在 1 到 sides 之间,(){for(leti0;i100;i){constresultsrollDice(6,1);expect(results[0]).toBeGreaterThanOrEqual(1);expect(results[0]).toBeLessThanOrEqual(6);}});it(createRollResult 应该自动计算 total,(){constresultcreateRollResult(d6,[3,4,5]);expect(result.total).toBe(12);});});集成测试测试数据库操作describe(DiceDatabase,(){it(应该正确保存和读取历史记录,async(){constdbnewDiceDatabase(mockContext);awaitdb.init();constresultcreateRollResult(d6,[3,4]);db.add(result);consthistorydb.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 的简单扩展而是一个有自己规则的框架。遵循框架的最佳实践才能写出可维护、高性能的代码。