引子:类型系统到底能帮我们什么
状态管理、自定义构建器、动画效果。但一个完整的应用不能只有 UI,数据层的设计同样重要。朋友拿到我写的骰子应用后,提了一个问题:“你这个RollResult接口是怎么设计的?为什么要加rolledAt字段?”
这个问题触及了 ArkTS 开发中一个容易被忽视的话题:类型系统和数据建模。很多开发者觉得"能跑就行",类型定义随便写写。但在实际项目中,好的类型设计能帮你在编译阶段就发现错误,而不是在线上环境崩溃后才去排查。
完整效果
项目文件结构
先看一下整个项目的文件组织:
entry/src/main/ets/ ├── model/ │ ├── Dice.ts # 骰子类型定义和核心逻辑 │ └── Database.ts # 数据持久化层 ├── utils/ │ └── Theme.ts # 主题配置和颜色系统 ├── pages/ │ └── Index.ets # 主页面组件 └── common/ # 公共工具这种分层结构很常见:model放数据定义,utils放工具函数,pages放 UI 组件。但"常见"不代表"好"——很多项目的分层是形式主义,实际上model和pages之间互相引用,耦合严重。
对于这个骰子应用,分层还算合理。Dice.ts只定义数据结构和纯函数,不依赖任何 UI 组件;Database.ts封装存储逻辑,对外暴露简洁的 API;Theme.ts提供颜色配置,不涉及业务逻辑。这种"单向依赖"是好架构的基础。
Dice.ts:类型定义的艺术
先看Dice.ts的核心定义:
exportinterfaceDiceType{key:string;// 骰子类型标识,如 'd4'、'd6'label:string;// 显示名称,如 '四面骰'emoji:string;// 显示图标sides:number;// 面数}exportconstDICE_TYPES:DiceType[]=[{key:'d4',label:'四面骰',emoji:'🔷',sides:4},{key:'d6',label:'六面骰',emoji:'🎲',sides:6},{key:'d8',label:'八面骰',emoji:'🔶',sides:8},{key:'d10',label:'十面骰',emoji:'🔟',sides:10},{key:'d12',label:'十二面骰',emoji:'⬡',sides:12},{key:'d20',label:'二十面骰',emoji:'🔮',sides:20},{key:'d100',label:'百面骰',emoji:'💯',sides:100}];这里用interface而不是type来定义DiceType,是有原因的。ArkTS(继承自 TypeScript)中,interface和type都可以定义对象结构,但有几个关键区别:
- 声明合并:
interface支持声明合并,多个同名interface会自动合并;type不支持 - extends vs &:
interface用extends继承,type用&交叉类型 - 语义:
interface通常用于定义对象的"形状",type更灵活,可以定义联合类型、元组等
在这个项目中,DiceType是一个简单的对象结构,用interface或type都可以。但考虑到未来可能需要扩展(比如添加"稀有骰子"类型),interface的声明合并特性更灵活。
RollResult:为什么需要 rolledAt 字段
exportinterfaceRollResult{diceType:string;// 骰子类型results:number[];// 各骰子的结果total:number;// 总和rolledAt:number;// 投掷时间戳}rolledAt字段看起来可有可无,但它是整个数据模型的关键。没有时间戳,历史记录就是一堆无序的数字;有了时间戳,就能做排序、筛选、统计分析。
在实际开发中,我见过很多项目在 V1 版本不加时间戳,等到 V2 要加筛选功能时才去改数据结构,然后痛苦地处理旧数据兼容问题。这个教训很深刻:数据模型要为未来的功能扩展留余地。
createRollResult:工厂函数的设计
exportfunctioncreateRollResult(diceType:string,results:number[]):RollResult{lettotal=0;for(leti=0;i<results.length;i++){total+=results[i];}return{diceType,results,total,rolledAt:Date.now()};}这个工厂函数封装了RollResult的创建逻辑。为什么不用直接构造对象?因为:
- 自动计算 total:调用者不需要手动计算总和
- 自动填充 rolledAt:时间戳由函数内部生成,保证一致性
- 单一职责:如果将来要添加校验逻辑(比如 results 不能为空),只需修改这一个函数
更重要的是,工厂函数提供了类型安全保障。如果直接构造对象,可能会漏掉某个字段或者类型写错;而工厂函数返回的是RollResult类型,编译器会自动检查。
rollDice:纯函数的价值
exportfunctionrollDice(sides:number,count:number):number[]{constresults:number[]=[];for(leti=0;i<count;i++){results.push(Math.floor(Math.random()*sides)+1);}returnresults;}这个函数没有任何副作用——不修改外部状态,不依赖外部变量,只根据输入返回输出。这种"纯函数"在测试时特别好用:你可以 mockMath.random()来控制输出,确保测试结果可预测。
但这里有个设计决策值得讨论:为什么把rollDice放在model/Dice.ts而不是pages/Index.ets?
从功能上讲,放在哪里都能跑。但从架构上讲,model层应该包含所有业务逻辑和数据操作,pages层只负责 UI 渲染。这样做的好处是:
- 可测试性:纯函数可以直接写单元测试,不需要启动 UI
- 可复用性:如果将来要做命令行版本或 Web 版本,可以直接复用
model层的代码 - 关注点分离:UI 开发者不需要关心随机数怎么生成,业务逻辑开发者不需要关心按钮怎么布局
Database.ts:数据持久化的封装
DiceDatabase类封装了底层的存储逻辑。虽然看不到完整代码,但从使用方式可以推断出它的接口设计:
classDiceDatabase{asyncinit():Promise<void>{...}add(result:RollResult):void{...}getHistory():RollResult[]{...}asyncclear():Promise<void>{...}}这个接口设计有几个值得注意的地方:
同步 vs 异步
init()和clear()是异步的,add()和getHistory()是同步的。这种设计合理吗?
- init():数据库初始化涉及文件 I/O 或网络操作,必须异步
- clear():清空数据也是 I/O 操作,必须异步
- add():如果用 Preferences API,写入操作可能是同步的(因为数据量小)
- getHistory():如果数据量小且在内存中缓存,同步返回是合理的
但这里有个潜在问题:add()是同步的,如果底层存储是异步的(比如 RDB),就会有问题。建议统一用异步 API,即使底层是同步的,也能保持接口一致性。
错误处理
当前代码几乎没有错误处理。在实际项目中,数据库操作可能失败(磁盘满、文件损坏等),应该添加 try-catch 和错误提示:
privateasyncclr():Promise<void>{if(this.db){try{awaitthis.db.clear();this.history=[];}catch(error){console.error('清空历史失败:',error);// 这里应该弹出 Toast 提示用户}}}数据校验
从数据库取出的数据不一定符合预期(比如旧版本的数据结构可能缺少rolledAt字段)。建议在getHistory()中添加数据校验:
getHistory():RollResult[]{constraw=this.getRawData();returnraw.filter((item:unknown):itemisRollResult=>{return(typeofitem==='object'&&item!==null&&'diceType'initem&&'results'initem&&'total'initem);});}这样即使数据库中有脏数据,也不会导致 UI 崩溃。
Theme.ts:配置驱动的 UI
主题系统的代码展示了"配置驱动"的设计思想:
exportinterfaceThemeColors{bg:string;surface:string;primary:string;accent:string;text:string;text2:string;border:string;}exportfunctiongetThemeColors(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'};}这种模式的好处是:
- 单一数据源:所有颜色都从
ThemeColors对象获取,不会出现"同一个颜色在不同地方用不同值"的情况 - 主题切换简单:只需修改
ThemeType的值,整个应用的颜色就会更新 - 类型安全:
ThemeColors接口保证了所有颜色字段都有定义
Spacing、BorderRadius、FontSize 的设计
除了颜色,主题配置还包括间距、圆角、字号等设计 token:
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};这些常量看起来简单,但它们是"设计系统"的基础。在大型项目中,这些值通常由设计师定义,开发者只引用常量。这样做的好处是:
- 一致性:整个应用的间距、圆角、字号都遵循统一规范
- 可维护性:如果要调整全局样式,只需修改这些常量
- 可协作性:设计师和开发者用同一套"语言"沟通
ArkTS 类型系统的几个关键特性
从这个项目中,可以总结出 ArkTS 类型系统的几个关键特性:
1. 接口(Interface)vs 类型别名(Type)
如前所述,interface适合定义对象的"形状",type更灵活。在实际项目中:
- 定义 API 响应结构、组件 Props → 用
interface - 定义联合类型、工具类型 → 用
type - 需要声明合并或继承 → 用
interface
2. 枚举(Enum)的使用
项目中用了ThemeType枚举:
exportenumThemeType{DARK='dark',LIGHT='light'}ArkTS 的枚举和 TypeScript 一样,支持数字枚举和字符串枚举。字符串枚举更直观,但数字枚举性能更好。在这个项目中,字符串枚举的可读性更重要。
3. 泛型(Generics)的潜在应用
当前项目没有用到泛型,但在更复杂的场景中,泛型很有用。比如DiceDatabase如果要支持不同类型的数据存储,可以用泛型:
classDatabase<T>{privatedata:T[]=[];add(item:T):void{this.data.push(item);}getAll():T[]{returnthis.data;}}// 使用示例constdiceDb=newDatabase<RollResult>();constsettingsDb=newDatabase<UserSettings>();4. 类型断言(Type Assertion)的谨慎使用
代码中有几处类型断言:
this.ctx=getContext(this)ascommon.UIAbilityContext;constd=DICE_TYPES.find((d2:DiceType)=>d2.key===this.diceType);returnd||DICE_TYPES[1];类型断言告诉编译器"我知道这个值的类型",但编译器不会检查。如果断言错误,运行时会崩溃。建议尽量用类型守卫(Type Guard)代替类型断言:
functionisDiceType(d:DiceType|undefined):disDiceType{returnd!==undefined;}constd=DICE_TYPES.find((d2:DiceType)=>d2.key===this.diceType);if(isDiceType(d)){returnd;}returnDICE_TYPES[1];工程化实践:从这个项目学到的
1. 模块化设计
这个项目的模块化做得不错:Dice.ts只管数据,Database.ts只管存储,Theme.ts只管样式。每个模块职责单一,依赖关系清晰。
在更大的项目中,模块化更重要。比如要做一个完整的桌游工具,可能需要:
dice/模块:骰子相关的数据和逻辑card/模块:卡牌相关的数据和逻辑timer/模块:计时器相关的数据和逻辑player/模块:玩家管理相关的数据和逻辑
每个模块独立开发、独立测试,最后在 UI 层组合。
2. 错误处理策略
这个项目的错误处理比较薄弱,但在实际项目中,错误处理是必须的。建议采用"分层错误处理"策略:
- 数据层:捕获存储错误,返回错误码或错误对象
- 业务层:根据错误码决定重试、降级或报错
- UI 层:给用户友好的提示(Toast、弹窗等)
3. 性能优化
ArkTS 的性能优化和 Web 前端类似,核心原则是"减少不必要的渲染"。在这个项目中:
ForEach要提供稳定的 key,避免不必要的 DOM 操作- 大列表用
List的懒加载,而不是ForEach渲染所有项 - 状态更新要批量处理,避免频繁触发重新渲染
4. 测试策略
这个项目的测试可以分三层:
- 单元测试:测试
rollDice、createRollResult等纯函数 - 集成测试:测试
DiceDatabase的增删改查 - UI 测试:测试 Tab 切换、按钮点击等交互
单元测试最容易写,建议优先覆盖。UI 测试需要 DevEco Studio 的测试框架支持,可以后续补充。
总结
这个骰子应用虽然简单,但涉及了 ArkTS 开发的多个核心知识点:类型系统、数据建模、模块化设计、错误处理、性能优化。在实际开发中,这些知识点会反复出现,只是复杂度不同。
适用边界:这个应用适合用作 ArkTS 入门学习项目,涵盖了 UI 开发、状态管理、数据持久化等核心知识点。但如果要上架应用商店,还需要补充错误处理、无障碍支持、性能优化、用户引导等内容。建议在此基础上逐步扩展,而不是一次性做完所有功能。
对于 ArkTS 新手,建议从类似的小项目入手,逐步理解框架的设计哲学。ArkTS 的声明式 UI 和 React 有相似之处,但状态管理和生命周期有明显区别,需要花时间适应。
对于有经验的开发者,重点是理解 ArkTS 的约束——它不是 TypeScript 的简单扩展,而是一个有自己规则的框架。遵循框架的最佳实践,才能写出可维护、高性能的代码。