HarmonyOS:ArkTS数据模型的设计与演进实战
引子:数据设计好了,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 个幸运物
为什么合并成一个文件
重构前拆成四个文件的理由是"单一职责"——每个文件只做一件事。但实际开发中发现:
- 数据量不大:总共也就 200 多行代码,拆成四个文件每个只有几十行,反而增加了文件切换成本
- 数据关联紧密:SignData 依赖 StarPt,FortuneDetail 依赖 LuckyItem,拆开后 import 路径复杂
- 修改频率高:星座数据和运势数据经常一起改,放在一个文件里更方便
合并的原则是"数据量小且关联紧密时,单文件比多文件更高效"。
六个核心类的设计
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+ 行),但还在可接受范围内。
演进的判断标准
什么时候该合并,什么时候该拆分?判断标准是:
- 数据量:<300 行可以合并,>500 行应该拆分
- 修改频率:经常一起改的数据应该合并,很少同时改的数据可以拆分
- 关联性:关联紧密的数据应该合并,独立的数据可以拆分
当前 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. 类型的强化
当前类的属性都是 string 或 number,没有更精确的类型。比如 elem 只能是 ‘火’ | ‘土’ | ‘风’ | ‘水’,可以用枚举或联合类型:
type Element = '火' | '土' | '风' | '水';
3. 数据的校验
当前数据没有校验——如果某个星座的 stars 数组为空,渲染时会出错。可以加校验逻辑:
constructor(...) {
if (s.length === 0) { console.warn(`${n} has no stars`); }
// ...
}
4. 数据的可扩展性
如果以后要支持"自定义星座"或"编辑运势",需要把数据存到本地。可以用 Preferences API 或关系型数据库。
总结
DataModel.ets 是整个应用的数据基础——转盘的旋转结果、星图的星座详情、塔罗的洗牌结果,都依赖这里的定义。数据结构设计好了,UI 就是套模板;数据结构没设计好,UI 怎么改都别扭。
适用边界:这个部分适合用作 ArkTS 数据模型设计的学习案例,涵盖了类定义、常量数组、构造函数、数据驱动、文件组织等核心知识点。但如果要上架应用商店,还需要补充数据校验、类型强化、外部化存储、可扩展性设计等内容。建议在此基础上逐步扩展,而不是一次性做完所有功能。
更多推荐



所有评论(0)