【OpenHarmony/HarmonyOS】ArkTS 游戏数据建模:玩家、房间、战绩与 JSON 序列化
【OpenHarmony/HarmonyOS】ArkTS 游戏数据建模:玩家、房间、战绩与 JSON 序列化
在游戏 Demo 中,一个对象只要“能传进去、能显示出来”似乎就够了;但当项目同时拥有局内统计、本地用户、房间同步和 Cloud DB 模型时,同名字段会开始表达不同含义。更隐蔽的问题是:ArkTS 编译期类型不会自动保护
JSON.parse()的结果,云端生成类的默认值也不一定等于业务上的空值。本文基于“迷宫坦克派对”的真实模型,梳理领域对象、传输对象与持久化快照的边界,并给出可渐进落地的序列化治理方案。🧩
一、为什么游戏数据不能只靠一个 interface?
这个项目中至少存在四种数据生命周期:
| 数据类别 | 典型对象 | 生命周期 | 主要消费者 |
|---|---|---|---|
| 局内运行状态 | GameStats、GameConfig |
单局开始到结算 | GameEngine、HUD、结算弹窗 |
| 页面展示数据 | 页面内 BattleRecord |
页面组件存活期间 | 个人资料历史列表 |
| 联机传输数据 | GameRoom.lastFrameData、多人配置 |
一次连接或房间会话 | P2P/房间逻辑 |
| 云端持久化模型 | PlayerStats、云端 BattleRecord |
跨设备、跨版本 | Cloud DB 对象类型 |
它们都可以被称为“数据模型”,却不能共享完全相同的设计目标。局内对象强调更新效率;页面模型强调显示友好;传输对象强调协议稳定;云端模型强调字段类型、索引和兼容性。
如果把四者混在一个类里,常见后果包括:UI 为了显示时间去修改云端时间戳、联机协议把内部临时字段也序列化出去、旧版本读取新字段时崩溃,以及一个 BattleRecord 名字对应两套完全不同的数据。
二、先看项目中的真实模型地图 🔍
项目的 common/models 目录包含 Cloud DB 编译器生成的四个类:
PlayerStats:用户等级、经验、胜率、最高分和更新时间;BattleRecord:胜者、败者、时长和时间;GameRoom:房间双方、状态与最近一帧数据;MatchRequest:匹配请求、状态、房间号与创建时间。
生成类采用公开字段加 getter/setter 的形式:
export class PlayerStats {
uid: string = "";
level: number = 1;
exp: number = 0;
winRate: number = 0.0;
bestScore: number = 0;
updatedAt: number = 0;
userName: string = "昵称";
avatar: string = "头像资源路径";
setBestScore(bestScore: number) {
this.bestScore = bestScore;
}
getBestScore(): number {
return this.bestScore;
}
}
文件头明确写着 Generated by the CloudDB ObjectType compiler. DO NOT EDIT!。这句话非常重要:生成类不是适合持续堆业务方法的领域对象,Schema 重新生成时,手工修改可能被覆盖。校验、格式化、迁移与聚合逻辑应放在生成文件之外。
与此同时,common/types/GameStats.ts 定义了一个纯本地运行模型:
export class GameStats {
score: number = 0;
damageDealt: number = 0;
targetsDestroyed: number = 0;
nightmareTargetsDestroyed: number = 0;
playersDefeated: number = 0;
survivalTime: number = 0;
startTime: number = 0;
coinsCollected: number = 0;
}
这组字段由游戏引擎不断更新,再由页面同步到 HUD 和结算界面。它适合做“局内聚合对象”,但不应原样当成可信云端战绩:客户端可以修改分数,startTime 也是本机运行时概念,上传之前还需要身份、局号、版本和服务端校验信息。
三、同名 BattleRecord,其实是两种概念 ⚠️
仓库中出现了两个 BattleRecord。
云端生成模型是:
export class BattleRecord {
recordId: string = "";
winnerId: string = "";
loserId: string = "";
duration: string = "";
timestamp: string = "0";
}
而 Index.ets 页面内部还有一个仅供历史表格展示的类:
class BattleRecord {
date: string = '';
result: string = '';
score: number = 0;
wave: number = 0;
constructor(date: string, result: string,
score: number, wave: number) {
this.date = date;
this.result = result;
this.score = score;
this.wave = wave;
}
}
前者表达 PvP 对局关系,后者表达 PvE 历史列表行。二者字段、来源和可信度完全不同。当前页面历史还是写死的模拟数据,并没有从云端 BattleRecord 转换而来。
命名不冲突是因为页面类没有导出,编译器可以区分作用域;但阅读者和后续维护者很容易误解。更清晰的命名可以是:
| 当前名称 | 建议名称 | 说明 |
|---|---|---|
云端 BattleRecord |
CloudBattleRecord 或保留生成名 |
生成文件不直接手改,可在导入处使用别名 |
页面 BattleRecord |
PveHistoryRow |
明确只是视图行 |
| 局内结算数据 | GameResultSnapshot |
从 GameStats 冻结出的提交快照 |
导入别名是一种低成本办法:
import { BattleRecord as CloudBattleRecord }
from '../common/models/BattleRecord';
四、领域对象、DTO、持久化快照要分层
可以用一条数据流理解三者:
flowchart LR
A["GameStats 局内可变状态"] --> B["GameResultSnapshot 结算快照"]
B --> C["BattleRecordDTO 传输对象"]
C --> D["Cloud DB 生成模型"]
D --> E["HistoryRow 页面展示模型"]
1. 领域对象
领域对象服务于游戏规则。例如 GameStats 的 targetsDestroyed 与 coinsCollected 会在战斗过程中增长。它允许高频修改,也可以包含与业务规则有关的方法。
2. DTO
DTO 是跨边界的白名单。它只放允许进入网络或路由的数据,字段必须可序列化、含义稳定。不要把完整 GameEngine、Canvas 上下文或回调塞进 DTO。
interface BattleRecordDTO {
schemaVersion: number;
recordId: string;
winnerId: string;
loserId: string;
durationMs: number;
timestampMs: number;
}
3. 持久化快照
快照强调“某一时刻不可再变”。结算时应复制所需字段,而不是长期持有仍会被引擎修改的对象引用:
interface GameResultSnapshot {
version: number;
mode: string;
score: number;
targetsDestroyed: number;
survivalSeconds: number;
coinsCollected: number;
finishedAt: number;
}
function snapshot(stats: GameStats, mode: string): GameResultSnapshot {
return {
version: 1,
mode,
score: stats.score,
targetsDestroyed: stats.targetsDestroyed,
survivalSeconds: stats.survivalTime,
coinsCollected: stats.coinsCollected,
finishedAt: Date.now()
};
}
这里的示例是演进设计,并非仓库当前已经新增了 GameResultSnapshot。
五、默认值不是小事:"" 与 "\"\""
GameRoom 的 playerA、playerB 以及 MatchRequest.uid 当前默认值并非真正的空字符串,而是字面量 "\"\""。运行时内容是两个引号字符,即:
期望空值:长度 0,内容为空
当前默认:长度 2,内容为 ""
对应生成类片段:
export class GameRoom {
roomId: string = "";
playerA: string = "\"\"";
playerB: string = "\"\"";
state: string = "waiting";
lastFrameData: string = "";
}
如果业务写 if (room.playerA) 判断槽位是否有人,这个默认值会被视为真,导致空槽位变成“已占用”。它很可能来自 Schema 默认值在生成阶段多包了一层引号。
正确治理方式不是直接改生成文件,而是先确认 Cloud DB 控制台或对象类型源定义中的默认值,修正后重新生成,并对历史记录做兼容清洗。在边界层可临时归一化:
function normalizePlayerId(value: string): string {
return value === '""' ? '' : value.trim();
}
临时归一化必须有删除计划,否则错误格式会长期扩散。
六、字段类型漂移:时间到底是 string 还是 Long?
仓库根目录的 cloud_db_schema.json 把 BattleRecord.duration 和 timestamp 都声明为 Long;然而生成的 BattleRecord.ts 与 ObjectTypeInfoHelper.ts 都把它们声明为 String。
| 字段 | cloud_db_schema.json |
生成类 | ObjectTypeInfoHelper |
|---|---|---|---|
| duration | Long | string | String |
| timestamp | Long | string | String |
这不是格式偏好,而是契约冲突。字符串时间可能出现 "10s"、"2026-07-23"、"10000" 多种格式,无法可靠排序和计算;Long 则应明确单位,例如毫秒。
建议把语义写进字段名:durationMs、timestampMs。如果必须保留旧字段,可在一次版本迁移中完成:
- 确定唯一权威 Schema;
- 备份测试环境数据;
- 将可解析字符串转换为 Long;
- 对不可解析记录进入隔离队列,不猜值;
- 重新生成对象类型;
- 用旧版和新版客户端分别验证读写;
- 最后删除兼容分支。
七、JSON.parse() 通过,不代表数据可信
ArkTS 中常见写法是:
const config = JSON.parse(raw) as GameConfig;
as GameConfig 只是编译期断言,不会在运行时检查 mode 是否有效,也不会阻止 timeLimit: -1 或 score: NaN。网络、Preferences、路由参数和云端返回值都属于不可信边界,应遵循“解析、判形、校验、归一化”四步。
interface ParseResult<T> {
ok: boolean;
value?: T;
reason?: string;
}
function parseGameResult(raw: string): ParseResult<GameResultSnapshot> {
try {
const data = JSON.parse(raw) as Record<string, Object>;
const score = data['score'];
const version = data['version'];
if (typeof score !== 'number' || !Number.isFinite(score)) {
return { ok: false, reason: 'invalid score' };
}
if (typeof version !== 'number') {
return { ok: false, reason: 'missing version' };
}
return { ok: true, value: data as GameResultSnapshot };
} catch (_) {
return { ok: false, reason: 'invalid json' };
}
}
示例重点是边界思想。实际 ArkTS 工程可根据严格模式调整 Record 和联合类型写法,但不要用一次类型断言替代运行时检查。
八、序列化时要建立字段白名单
直接 JSON.stringify(object) 会序列化所有可枚举公开字段。生成类当前字段简单,暂时不会把方法写入 JSON;但随着对象扩展,内部状态、调试字段或敏感标识可能被一并发送。
更可控的做法是显式映射:
function toBattleRecordDTO(
stats: GameStats,
recordId: string,
winnerId: string,
loserId: string
): BattleRecordDTO {
return {
schemaVersion: 2,
recordId,
winnerId,
loserId,
durationMs: Math.max(0, stats.survivalTime * 1000),
timestampMs: Date.now()
};
}
白名单映射带来三点收益:字段删改可追踪、单位转换集中、不会意外暴露本地对象的其他属性。
九、lastFrameData 不应成为无限大的万能 JSON
GameRoom.lastFrameData 在对象类型中是 Text,看起来可以存任何 JSON。它适合原型期快速打通,但长期存在几个风险:
- 每帧全量序列化会产生字符串分配与带宽压力;
- 没有协议版本时,新旧客户端无法判断字段;
- 房间记录与高频帧数据更新频率差异巨大;
- 客户端上传的位置、生命值不能天然视为可信;
- 文本字段难以对内部属性建立索引。
更稳妥的联机协议应把房间元数据与实时帧分开。房间记录保留参与者、状态和版本;高频状态走 P2P/实时通道,必要时只在云端保存低频检查点。
interface FramePacketV1 {
protocol: 1;
roomId: string;
sequence: number;
sentAt: number;
players: Array<PlayerFrameDTO>;
}
sequence 可用于丢弃乱序包,sentAt 用于观测时延,但不能直接作为权威排序依据。
十、版本迁移:不要等线上数据坏了再补
推荐所有跨边界 JSON 都带版本:
{
"version": 2,
"mode": "pve",
"score": 1250,
"survivalSeconds": 96,
"coinsCollected": 18
}
迁移函数应是单向、可测试的:
function migrateResult(data: Record<string, Object>): Record<string, Object> {
const version = Number(data['version'] ?? 1);
if (version === 1) {
data['coinsCollected'] = data['coinsCollected'] ?? 0;
data['version'] = 2;
}
return data;
}
迁移时要区分“缺字段”和“字段为 0”。使用 || 会把合法的 0 当成空值,?? 更符合默认值语义。对于枚举值变更,应建立显式映射,不要默默落到第一个模式。
十一、测试应该覆盖哪些模型边界?🧪
| 测试类别 | 关键用例 | 预期结果 |
|---|---|---|
| 默认值 | 新建空 GameRoom |
空槽位不会被识别为玩家 |
| 类型校验 | score 为字符串 | 拒绝而不是强转 |
| 数值边界 | NaN、负时长、超大分数 |
拒绝或按规则归一化 |
| 兼容读取 | 缺少新增字段的 V1 快照 | 补默认值并升级到 V2 |
| 未知版本 | version 高于客户端支持值 | 提示升级,不破坏原数据 |
| 往返测试 | DTO stringify 后 parse | 关键字段保持一致 |
| Schema 契约 | 生成类与 Schema 类型比较 | CI 中发现漂移 |
尤其建议增加 Schema 契约检查:解析 cloud_db_schema.json,再与生成的 ObjectTypeInfoHelper 做字段名、类型、默认值对照。它能在云端部署之前发现这次 Long/String 一类问题。
十二、渐进治理顺序
不需要一次重写所有模型,可以按风险推进:
- 先给所有 JSON 边界增加
try/catch和运行时校验; - 修复 Schema 默认值与生成类不一致,生成文件不手改;
- 将页面本地
BattleRecord重命名为视图模型; - 结算时建立不可变快照,避免引用继续变化;
- 给联机与持久化数据加入版本号;
- 建立 DTO 显式映射和字段白名单;
- 在 CI 加入 Schema 契约和往返序列化测试。
这套顺序先控制输入风险,再处理命名与架构,最后形成自动化保护,适合仍在快速迭代的项目。
十三、总结 ✨
这个项目已经具备局内统计、页面历史、房间与云端对象模型,因此数据建模问题是真实存在的,不是为了“套架构”。当前最需要关注的事实有三点:生成模型与页面模型存在同名异义;GameRoom 等字段的默认值包含额外引号;BattleRecord 的时间字段在 JSON Schema 与生成类之间发生了 Long/String 漂移。
解决方案也不是给每个字段增加 getter/setter,而是明确边界:GameStats 服务局内规则,快照冻结结算结果,DTO 负责跨进程或网络传输,Cloud DB 生成类只承担持久化映射,页面再把它转换成适合展示的 ViewModel。配合运行时校验、显式白名单、版本迁移和契约测试,ArkTS 的静态类型才能真正延伸到 JSON 之外。🚀
推荐标签: OpenHarmony HarmonyOS ArkTS JSON序列化 数据建模 CloudDB 游戏开发

更多推荐



所有评论(0)