引子:数据设计好了,UI 就是套模板

星空运势应用经历了两次数据结构的变化。第一版是四个文件——Types.ts、SignsData.ts、TarotData.ts、Constants.ts,每个文件职责单一。重构后合并成了一个 DataModel.ets,12 星座的数据从 5 个字段扩展到 8 个(加了优势、弱点、最佳配对),运势数据从两行文字扩展到四维评分+幸运物。

改动的动机很简单:数据量变大了,文件太多反而麻烦——每次改星座数据要打开 SignsData.ts,改运势要打开另一个文件,改颜色又要打开 Constants.ts。合并成一个文件后,所有数据定义在一个地方,改起来方便。

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

数据模型的完整结构

DataModel.ets 里定义了六个核心类和四个常量数组:

DataModel.ets
├── class Pos2D              # 坐标点
├── class StarPt             # 星点(坐标+大小)
├── class SignData           # 星座完整数据
├── class TarotInfo          # 塔罗牌数据
├── class FortuneDetail      # 运势维度数据
├── class LuckyItem          # 幸运物数据
├── const SIGNS: SignData[]  # 12 星座数据
├── const TAROT: TarotInfo[] # 21 张塔罗牌
├── const WHEEL_COLORS       # 12 种转盘颜色
├── const FORTUNE_LABELS     # 12 个运势标签
└── const LUCKY_ITEMS        # 4 个幸运物

为什么合并成一个文件

重构前拆成四个文件的理由是"单一职责"——每个文件只做一件事。但实际开发中发现:

  1. 数据量不大:总共也就 200 多行代码,拆成四个文件每个只有几十行,反而增加了文件切换成本
  2. 数据关联紧密:SignData 依赖 StarPt,FortuneDetail 依赖 LuckyItem,拆开后 import 路径复杂
  3. 修改频率高:星座数据和运势数据经常一起改,放在一个文件里更方便

合并的原则是"数据量小且关联紧密时,单文件比多文件更高效"。

六个核心类的设计

Pos2D:最简单的坐标点

class Pos2D {
  x: number = 0;
  y: number = 0;
  constructor(x: number, y: number) {
    this.x = x;
    this.y = y;
  }
}

只有 x 和 y 两个属性,用于转盘的星座图标定位和星图的星点定位。

为什么不用系统自带的 Point?

ArkUI 没有内置的 Point 类。如果用 { x: number, y: number } 对象,每次都要写类型注解。用 class 可以通过 new Pos2D(x, y) 快速创建实例,代码更简洁。

StarPt:带大小的星点

class StarPt {
  x: number = 0;
  y: number = 0;
  sz: number = 0;
  constructor(x: number, y: number, sz: number) {
    this.x = x;
    this.y = y;
    this.sz = sz;
  }
}

比 Pos2D 多一个 sz(size)属性,用于控制星点的大小。sz 的值是 2-5,渲染时乘以 2.5 映射到字体大小(5px-12.5px)。

为什么 sz 不直接存像素值?

存原始尺寸(比如 5、7.5、10)更直观,但存 2-5 的小数字更紧凑。渲染时再乘以系数,方便统一调整——如果要让所有星点变大,只需改系数,不需要改 12 个星座的数据。

SignData:星座的完整信息

class SignData {
  name: string = '';        // 名称
  emoji: string = '';       // 符号
  date: string = '';        // 日期范围
  elem: string = '';        // 元素
  trait: string = '';        // 特质
  stars: StarPt[] = [];     // 星点坐标
  strengths: string = '';   // 优势
  weaknesses: string = '';  // 弱点
  matches: string[] = [];   // 最佳配对
  
  constructor(n: string, e: string, d: string, el: string, t: string,
    s: StarPt[], str: string = '', wk: string = '', mt: string[] = []) {
    this.name = n; this.emoji = e; this.date = d; this.elem = el;
    this.trait = t; this.stars = s;
    this.strengths = str; this.weaknesses = wk; this.matches = mt;
  }
}

在这里插入图片描述

这是最核心的数据类,包含了星座的所有信息。9 个字段分三类:

类别 字段 用途
基础信息 name, emoji, date, elem, trait 星座网格、详情标签
可视化数据 stars 星点渲染
富文本数据 strengths, weaknesses, matches 详情卡片

为什么用位置参数而不是对象参数

构造函数用了 9 个位置参数,而不是传一个对象:

// 位置参数(当前实现)
new SignData('白羊座', '♈', '3.21-4.19', '火', '热情勇敢', [...], '充满活力', '冲动急躁', ['狮子座'])

// 对象参数(另一种方式)
new SignData({
  name: '白羊座', emoji: '♈', date: '3.21-4.19', elem: '火',
  trait: '热情勇敢', stars: [...], strengths: '充满活力',
  weaknesses: '冲动急躁', matches: ['狮子座']
})

位置参数的优点是写起来短,缺点是参数顺序不能错。对象参数的优点是参数顺序随意,缺点是写起来长。

对于这种"数据量固定、很少修改"的场景,位置参数更高效。如果字段经常增减,对象参数更安全。

默认参数的作用

后三个参数(strengths、weaknesses、matches)有默认值(空字符串和空数组)。这样在定义星座数据时,如果某个星座暂时没有详细信息,可以省略这些参数:

new SignData('测试座', '☆', '1.1-1.31', '火', '测试', [...])
// strengths、weaknesses、matches 自动为空

TarotInfo:塔罗牌数据

class TarotInfo {
  name: string = '';
  emoji: string = '';
  key: string = '';
  text: string = '';
  constructor(n: string, e: string, k: string, t: string) {
    this.name = n; this.emoji = e; this.key = k; this.text = t;
  }
}

在这里插入图片描述

四个字段:名称、emoji、关键词、解读文本。比 SignData 简单得多,因为塔罗牌不需要星点坐标和优劣势。

FortuneDetail:运势维度数据

class FortuneDetail {
  category: string = '';
  emoji: string = '';
  stars: number = 0;
  text: string = '';
  constructor(c: string, e: string, s: number, t: string) {
    this.category = c; this.emoji = e; this.stars = s; this.text = t;
  }
}

用于运势详解的四维评分:维度名称、图标、星级(1-5)、解读文本。

LuckyItem:幸运物数据

class LuckyItem {
  emoji: string = '';
  label: string = '';
  value: string = '';
  constructor(e: string, l: string, v: string) {
    this.emoji = e; this.label = l; this.value = v;
  }
}

用于今日幸运物:图标、标题、具体内容。

四个常量数组的设计

SIGNS:12 星座数据

const SIGNS: SignData[] = [
  new SignData('白羊座', '♈', '3.21-4.19', '火', '热情勇敢·行动力强',
    [new StarPt(100,30,3), new StarPt(60,70,2), ...],
    '充满活力,勇于开拓,天生的领导者',
    '冲动急躁,缺乏耐心,容易半途而废',
    ['狮子座', '射手座']),
  // ... 共 12 个
];

每个星座 9 个参数,12 个星座总共 108 个参数。数据量不小,但都在一个数组里,维护方便。

TAROT:21 张塔罗牌

const TAROT: TarotInfo[] = [
  new TarotInfo('愚者', '🃏', '新开始·冒险', '新的旅程即将开始...'),
  new TarotInfo('魔术师', '🪄', '创造力·技巧', '你拥有实现目标的所有资源...'),
  // ... 共 21 张
];

21 张大阿尔卡纳,每张 4 个参数。数据量比星座少,但每张牌的解读文本较长。

WHEEL_COLORS:12 种转盘颜色

const WHEEL_COLORS: string[] = [
  '#7B4FBF','#3B6FC2','#20A39E','#E89240',
  '#C44569','#5D5FEF','#2E9C7A','#D4594C',
  '#6B5CE7','#3D8FD1','#18A085','#E07030'
];

12 种颜色和 12 个星座一一对应。颜色选择考虑了对比度——在深色背景上都能看清。

FORTUNE_LABELS:12 个运势标签

const FORTUNE_LABELS: string[] = [
  '大吉','中吉','小吉','末吉','大吉','中吉',
  '小吉','大吉','中吉','末吉','小吉','中吉'
];

12 个运势标签和 12 个星座一一对应。运势结果由转盘停止位置决定,不是随机的。

LUCKY_ITEMS:4 个幸运物

const LUCKY_ITEMS: LuckyItem[] = [
  new LuckyItem('🎨', '幸运色', '蓝色'),
  new LuckyItem('💎', '守护石', '青金石'),
  new LuckyItem('🕐', '吉时', '下午4点'),
  new LuckyItem('🧭', '方位', '北方'),
];

4 个固定幸运物,不随星座变化。如果要做更精细的运势,可以让不同星座有不同的幸运物。

数据如何驱动整个应用

整个应用的 UI 完全由数据驱动:

SIGNS → 转盘的星座图标(ForEach + wheelPos)
      → 星图的星座网格(ForEach + Grid)
      → 星图的详情卡片(selectedSign 索引)

TAROT → 塔罗的洗牌结果(tarotIdx 索引)
      → 塔罗的牌面显示(ForEach + tarotOpen)

WHEEL_COLORS → 转盘的背景色(ForEach + backgroundColor)

FORTUNE_LABELS → 转盘的运势标签(resultIdx 索引)

LUCKY_ITEMS → 运势详解的幸运物(ForEach)

修改任何数据,对应的 UI 自动更新。这就是"数据驱动"的核心思想。

数据流向的单向性

数据流是单向的:

数据 → UI

没有反向流(UI → 数据),除非用户交互触发状态变化。这种单向数据流让代码更可预测——看到数据就知道 UI 长什么样,看到 UI 就知道数据是什么。

数据的不可变性

常量数组(SIGNS、TAROT 等)用 const 声明,不可修改。如果要修改数据,必须创建新数组:

// 错误:const 数组不能修改
SIGNS[0].name = '新名字';

// 正确:创建新数组
const newSIGNS = [...SIGNS];
newSIGNS[0] = new SignData('新名字', ...);

不可变性的好处是避免意外修改——如果某个组件不小心改了 SIGNS 数据,会影响所有使用 SIGNS 的组件。

从多文件到单文件的演进过程

第一版:四个文件

model/
├── Types.ts        # 类定义
├── SignsData.ts    # 星座数据
├── TarotData.ts    # 塔罗数据
└── Constants.ts    # 颜色、运势标签

优点:每个文件职责单一,修改一个不影响另一个。
缺点:文件切换频繁,import 路径复杂。

第二版:两个文件

model/
├── DataModel.ts    # 类定义 + 常量数据
└── index.ts        # 统一导出

优点:减少文件切换,import 更简洁。
缺点:DataModel.ts 还是有点大。

第三版(当前):一个文件

model/
└── DataModel.ets   # 所有数据定义

优点:所有数据在一个文件里,改起来最方便。
缺点:文件有点大(200+ 行),但还在可接受范围内。

演进的判断标准

什么时候该合并,什么时候该拆分?判断标准是:

  1. 数据量:<300 行可以合并,>500 行应该拆分
  2. 修改频率:经常一起改的数据应该合并,很少同时改的数据可以拆分
  3. 关联性:关联紧密的数据应该合并,独立的数据可以拆分

当前 DataModel.ets 大约 200 行,在可接受范围内。如果以后加更多星座(比如 24 星座)或更多塔罗牌(78 张完整牌组),可能需要再拆分。

踩坑记录

坑 1:类的默认值必须在声明时赋值

class SignData {
  name: string = '';  // 必须在声明时赋值
  // name: string;    // 错误:编译器会报错
}

ArkTS 要求类的属性在声明时赋默认值,不能只声明类型。这是因为 ArkTS 编译后会生成 JavaScript 代码,如果没有默认值,属性会是 undefined

坑 2:const 数组的深拷贝问题

const a = [{ x: 1 }, { x: 2 }];
const b = [...a];  // 浅拷贝
b[0].x = 100;      // a[0].x 也会变成 100!

展开运算符 [...] 只做浅拷贝,数组里的对象还是同一个引用。如果要深拷贝,需要用 JSON 序列化:

const b = JSON.parse(JSON.stringify(a));

但当前应用的数据都是只读的,不需要深拷贝。

坑 3:类的构造函数参数顺序

构造函数有 9 个参数,顺序不能错。如果记不住顺序,可以看 IDE 的参数提示,或者用对象参数代替。

坑 4:export 的写法

ArkTS 的 export 语法和 TypeScript 略有不同:

// TypeScript
export class SignData { ... }

// ArkTS
class SignData { ... }
export { SignData };

如果在类声明前加 export,编译器可能会报错。

代码改进建议

1. 数据的外部化

如果星座数据经常变动(比如从网络获取),可以考虑把数据放到外部文件(JSON 或数据库),运行时加载。这样修改数据不需要重新编译。

2. 类型的强化

当前类的属性都是 stringnumber,没有更精确的类型。比如 elem 只能是 ‘火’ | ‘土’ | ‘风’ | ‘水’,可以用枚举或联合类型:

type Element = '火' | '土' | '风' | '水';

3. 数据的校验

当前数据没有校验——如果某个星座的 stars 数组为空,渲染时会出错。可以加校验逻辑:

constructor(...) {
  if (s.length === 0) { console.warn(`${n} has no stars`); }
  // ...
}

4. 数据的可扩展性

如果以后要支持"自定义星座"或"编辑运势",需要把数据存到本地。可以用 Preferences API 或关系型数据库。

总结

DataModel.ets 是整个应用的数据基础——转盘的旋转结果、星图的星座详情、塔罗的洗牌结果,都依赖这里的定义。数据结构设计好了,UI 就是套模板;数据结构没设计好,UI 怎么改都别扭。

适用边界:这个部分适合用作 ArkTS 数据模型设计的学习案例,涵盖了类定义、常量数组、构造函数、数据驱动、文件组织等核心知识点。但如果要上架应用商店,还需要补充数据校验、类型强化、外部化存储、可扩展性设计等内容。建议在此基础上逐步扩展,而不是一次性做完所有功能。

Logo

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

更多推荐