HarmonyOS ArkTS 类型系统与数据建模:从骰子应用看工程化编程实践
引子:类型系统到底能帮我们什么
状态管理、自定义构建器、动画效果。但一个完整的应用不能只有 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 的核心定义:
export interface DiceType {
key: string; // 骰子类型标识,如 'd4'、'd6'
label: string; // 显示名称,如 '四面骰'
emoji: string; // 显示图标
sides: number; // 面数
}
export const DICE_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 字段
export interface RollResult {
diceType: string; // 骰子类型
results: number[]; // 各骰子的结果
total: number; // 总和
rolledAt: number; // 投掷时间戳
}
rolledAt 字段看起来可有可无,但它是整个数据模型的关键。没有时间戳,历史记录就是一堆无序的数字;有了时间戳,就能做排序、筛选、统计分析。
在实际开发中,我见过很多项目在 V1 版本不加时间戳,等到 V2 要加筛选功能时才去改数据结构,然后痛苦地处理旧数据兼容问题。这个教训很深刻:数据模型要为未来的功能扩展留余地。
createRollResult:工厂函数的设计
export function createRollResult(diceType: string, results: number[]): RollResult {
let total = 0;
for (let i = 0; i < results.length; i++) {
total += results[i];
}
return {
diceType,
results,
total,
rolledAt: Date.now()
};
}

这个工厂函数封装了 RollResult 的创建逻辑。为什么不用直接构造对象?因为:
- 自动计算 total:调用者不需要手动计算总和
- 自动填充 rolledAt:时间戳由函数内部生成,保证一致性
- 单一职责:如果将来要添加校验逻辑(比如 results 不能为空),只需修改这一个函数
更重要的是,工厂函数提供了类型安全保障。如果直接构造对象,可能会漏掉某个字段或者类型写错;而工厂函数返回的是 RollResult 类型,编译器会自动检查。
rollDice:纯函数的价值
export function rollDice(sides: number, count: number): number[] {
const results: number[] = [];
for (let i = 0; i < count; i++) {
results.push(Math.floor(Math.random() * sides) + 1);
}
return results;
}
这个函数没有任何副作用——不修改外部状态,不依赖外部变量,只根据输入返回输出。这种"纯函数"在测试时特别好用:你可以 mock Math.random() 来控制输出,确保测试结果可预测。
但这里有个设计决策值得讨论:为什么把 rollDice 放在 model/Dice.ts 而不是 pages/Index.ets?
从功能上讲,放在哪里都能跑。但从架构上讲,model 层应该包含所有业务逻辑和数据操作,pages 层只负责 UI 渲染。这样做的好处是:
- 可测试性:纯函数可以直接写单元测试,不需要启动 UI
- 可复用性:如果将来要做命令行版本或 Web 版本,可以直接复用
model层的代码 - 关注点分离:UI 开发者不需要关心随机数怎么生成,业务逻辑开发者不需要关心按钮怎么布局
Database.ts:数据持久化的封装
DiceDatabase 类封装了底层的存储逻辑。虽然看不到完整代码,但从使用方式可以推断出它的接口设计:
class DiceDatabase {
async init(): Promise<void> { ... }
add(result: RollResult): void { ... }
getHistory(): RollResult[] { ... }
async clear(): Promise<void> { ... }
}
这个接口设计有几个值得注意的地方:
同步 vs 异步
init() 和 clear() 是异步的,add() 和 getHistory() 是同步的。这种设计合理吗?
- init():数据库初始化涉及文件 I/O 或网络操作,必须异步
- clear():清空数据也是 I/O 操作,必须异步
- add():如果用 Preferences API,写入操作可能是同步的(因为数据量小)
- getHistory():如果数据量小且在内存中缓存,同步返回是合理的
但这里有个潜在问题:add() 是同步的,如果底层存储是异步的(比如 RDB),就会有问题。建议统一用异步 API,即使底层是同步的,也能保持接口一致性。
错误处理
当前代码几乎没有错误处理。在实际项目中,数据库操作可能失败(磁盘满、文件损坏等),应该添加 try-catch 和错误提示:
private async clr(): Promise<void> {
if (this.db) {
try {
await this.db.clear();
this.history = [];
} catch (error) {
console.error('清空历史失败:', error);
// 这里应该弹出 Toast 提示用户
}
}
}
数据校验
从数据库取出的数据不一定符合预期(比如旧版本的数据结构可能缺少 rolledAt 字段)。建议在 getHistory() 中添加数据校验:
getHistory(): RollResult[] {
const raw = this.getRawData();
return raw.filter((item: unknown): item is RollResult => {
return (
typeof item === 'object' &&
item !== null &&
'diceType' in item &&
'results' in item &&
'total' in item
);
});
}
这样即使数据库中有脏数据,也不会导致 UI 崩溃。
Theme.ts:配置驱动的 UI
主题系统的代码展示了"配置驱动"的设计思想:
export interface ThemeColors {
bg: string;
surface: string;
primary: string;
accent: string;
text: string;
text2: string;
border: string;
}
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'
};
}

这种模式的好处是:
- 单一数据源:所有颜色都从
ThemeColors对象获取,不会出现"同一个颜色在不同地方用不同值"的情况 - 主题切换简单:只需修改
ThemeType的值,整个应用的颜色就会更新 - 类型安全:
ThemeColors接口保证了所有颜色字段都有定义
Spacing、BorderRadius、FontSize 的设计
除了颜色,主题配置还包括间距、圆角、字号等设计 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
};
这些常量看起来简单,但它们是"设计系统"的基础。在大型项目中,这些值通常由设计师定义,开发者只引用常量。这样做的好处是:
- 一致性:整个应用的间距、圆角、字号都遵循统一规范
- 可维护性:如果要调整全局样式,只需修改这些常量
- 可协作性:设计师和开发者用同一套"语言"沟通
ArkTS 类型系统的几个关键特性
从这个项目中,可以总结出 ArkTS 类型系统的几个关键特性:
1. 接口(Interface)vs 类型别名(Type)
如前所述,interface 适合定义对象的"形状",type 更灵活。在实际项目中:
- 定义 API 响应结构、组件 Props → 用
interface - 定义联合类型、工具类型 → 用
type - 需要声明合并或继承 → 用
interface
2. 枚举(Enum)的使用
项目中用了 ThemeType 枚举:
export enum ThemeType {
DARK = 'dark',
LIGHT = 'light'
}
ArkTS 的枚举和 TypeScript 一样,支持数字枚举和字符串枚举。字符串枚举更直观,但数字枚举性能更好。在这个项目中,字符串枚举的可读性更重要。
3. 泛型(Generics)的潜在应用
当前项目没有用到泛型,但在更复杂的场景中,泛型很有用。比如 DiceDatabase 如果要支持不同类型的数据存储,可以用泛型:
class Database<T> {
private data: T[] = [];
add(item: T): void {
this.data.push(item);
}
getAll(): T[] {
return this.data;
}
}
// 使用示例
const diceDb = new Database<RollResult>();
const settingsDb = new Database<UserSettings>();
4. 类型断言(Type Assertion)的谨慎使用
代码中有几处类型断言:
this.ctx = getContext(this) as common.UIAbilityContext;
const d = DICE_TYPES.find((d2: DiceType) => d2.key === this.diceType);
return d || DICE_TYPES[1];
类型断言告诉编译器"我知道这个值的类型",但编译器不会检查。如果断言错误,运行时会崩溃。建议尽量用类型守卫(Type Guard)代替类型断言:
function isDiceType(d: DiceType | undefined): d is DiceType {
return d !== undefined;
}
const d = DICE_TYPES.find((d2: DiceType) => d2.key === this.diceType);
if (isDiceType(d)) {
return d;
}
return DICE_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 的简单扩展,而是一个有自己规则的框架。遵循框架的最佳实践,才能写出可维护、高性能的代码。
更多推荐



所有评论(0)