引子:类型系统到底能帮我们什么

状态管理、自定义构建器、动画效果。但一个完整的应用不能只有 UI,数据层的设计同样重要。朋友拿到我写的骰子应用后,提了一个问题:“你这个 RollResult 接口是怎么设计的?为什么要加 rolledAt 字段?”

这个问题触及了 ArkTS 开发中一个容易被忽视的话题:类型系统和数据建模。很多开发者觉得"能跑就行",类型定义随便写写。但在实际项目中,好的类型设计能帮你在编译阶段就发现错误,而不是在线上环境崩溃后才去排查。

完整效果
在这里插入图片描述

项目文件结构

先看一下整个项目的文件组织:

entry/src/main/ets/
├── model/
│   ├── Dice.ts          # 骰子类型定义和核心逻辑
│   └── Database.ts      # 数据持久化层
├── utils/
│   └── Theme.ts         # 主题配置和颜色系统
├── pages/
│   └── Index.ets        # 主页面组件
└── common/              # 公共工具

这种分层结构很常见:model 放数据定义,utils 放工具函数,pages 放 UI 组件。但"常见"不代表"好"——很多项目的分层是形式主义,实际上 modelpages 之间互相引用,耦合严重。

对于这个骰子应用,分层还算合理。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)中,interfacetype 都可以定义对象结构,但有几个关键区别:

  1. 声明合并interface 支持声明合并,多个同名 interface 会自动合并;type 不支持
  2. extends vs &interfaceextends 继承,type& 交叉类型
  3. 语义interface 通常用于定义对象的"形状",type 更灵活,可以定义联合类型、元组等

在这个项目中,DiceType 是一个简单的对象结构,用 interfacetype 都可以。但考虑到未来可能需要扩展(比如添加"稀有骰子"类型),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 的创建逻辑。为什么不用直接构造对象?因为:

  1. 自动计算 total:调用者不需要手动计算总和
  2. 自动填充 rolledAt:时间戳由函数内部生成,保证一致性
  3. 单一职责:如果将来要添加校验逻辑(比如 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 渲染。这样做的好处是:

  1. 可测试性:纯函数可以直接写单元测试,不需要启动 UI
  2. 可复用性:如果将来要做命令行版本或 Web 版本,可以直接复用 model 层的代码
  3. 关注点分离: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'
  };
}

在这里插入图片描述

这种模式的好处是:

  1. 单一数据源:所有颜色都从 ThemeColors 对象获取,不会出现"同一个颜色在不同地方用不同值"的情况
  2. 主题切换简单:只需修改 ThemeType 的值,整个应用的颜色就会更新
  3. 类型安全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
};

这些常量看起来简单,但它们是"设计系统"的基础。在大型项目中,这些值通常由设计师定义,开发者只引用常量。这样做的好处是:

  1. 一致性:整个应用的间距、圆角、字号都遵循统一规范
  2. 可维护性:如果要调整全局样式,只需修改这些常量
  3. 可协作性:设计师和开发者用同一套"语言"沟通

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. 测试策略

这个项目的测试可以分三层:

  • 单元测试:测试 rollDicecreateRollResult 等纯函数
  • 集成测试:测试 DiceDatabase 的增删改查
  • UI 测试:测试 Tab 切换、按钮点击等交互

单元测试最容易写,建议优先覆盖。UI 测试需要 DevEco Studio 的测试框架支持,可以后续补充。

总结

这个骰子应用虽然简单,但涉及了 ArkTS 开发的多个核心知识点:类型系统、数据建模、模块化设计、错误处理、性能优化。在实际开发中,这些知识点会反复出现,只是复杂度不同。

适用边界:这个应用适合用作 ArkTS 入门学习项目,涵盖了 UI 开发、状态管理、数据持久化等核心知识点。但如果要上架应用商店,还需要补充错误处理、无障碍支持、性能优化、用户引导等内容。建议在此基础上逐步扩展,而不是一次性做完所有功能。

对于 ArkTS 新手,建议从类似的小项目入手,逐步理解框架的设计哲学。ArkTS 的声明式 UI 和 React 有相似之处,但状态管理和生命周期有明显区别,需要花时间适应。

对于有经验的开发者,重点是理解 ArkTS 的约束——它不是 TypeScript 的简单扩展,而是一个有自己规则的框架。遵循框架的最佳实践,才能写出可维护、高性能的代码。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐