【OpenHarmony/HarmonyOS】ArkTS 游戏数据建模:玩家、房间、战绩与 JSON 序列化

在游戏 Demo 中,一个对象只要“能传进去、能显示出来”似乎就够了;但当项目同时拥有局内统计、本地用户、房间同步和 Cloud DB 模型时,同名字段会开始表达不同含义。更隐蔽的问题是:ArkTS 编译期类型不会自动保护 JSON.parse() 的结果,云端生成类的默认值也不一定等于业务上的空值。本文基于“迷宫坦克派对”的真实模型,梳理领域对象、传输对象与持久化快照的边界,并给出可渐进落地的序列化治理方案。🧩

一、为什么游戏数据不能只靠一个 interface?

这个项目中至少存在四种数据生命周期:

数据类别 典型对象 生命周期 主要消费者
局内运行状态 GameStatsGameConfig 单局开始到结算 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. 领域对象

领域对象服务于游戏规则。例如 GameStatstargetsDestroyedcoinsCollected 会在战斗过程中增长。它允许高频修改,也可以包含与业务规则有关的方法。

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

五、默认值不是小事:"""\"\""

GameRoomplayerAplayerB 以及 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.jsonBattleRecord.durationtimestamp 都声明为 Long;然而生成的 BattleRecord.tsObjectTypeInfoHelper.ts 都把它们声明为 String

字段 cloud_db_schema.json 生成类 ObjectTypeInfoHelper
duration Long string String
timestamp Long string String

这不是格式偏好,而是契约冲突。字符串时间可能出现 "10s""2026-07-23""10000" 多种格式,无法可靠排序和计算;Long 则应明确单位,例如毫秒。

建议把语义写进字段名:durationMstimestampMs。如果必须保留旧字段,可在一次版本迁移中完成:

  1. 确定唯一权威 Schema;
  2. 备份测试环境数据;
  3. 将可解析字符串转换为 Long;
  4. 对不可解析记录进入隔离队列,不猜值;
  5. 重新生成对象类型;
  6. 用旧版和新版客户端分别验证读写;
  7. 最后删除兼容分支。

七、JSON.parse() 通过,不代表数据可信

ArkTS 中常见写法是:

const config = JSON.parse(raw) as GameConfig;

as GameConfig 只是编译期断言,不会在运行时检查 mode 是否有效,也不会阻止 timeLimit: -1score: 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 一类问题。

十二、渐进治理顺序

不需要一次重写所有模型,可以按风险推进:

  1. 先给所有 JSON 边界增加 try/catch 和运行时校验;
  2. 修复 Schema 默认值与生成类不一致,生成文件不手改;
  3. 将页面本地 BattleRecord 重命名为视图模型;
  4. 结算时建立不可变快照,避免引用继续变化;
  5. 给联机与持久化数据加入版本号;
  6. 建立 DTO 显式映射和字段白名单;
  7. 在 CI 加入 Schema 契约和往返序列化测试。

这套顺序先控制输入风险,再处理命名与架构,最后形成自动化保护,适合仍在快速迭代的项目。

十三、总结 ✨

这个项目已经具备局内统计、页面历史、房间与云端对象模型,因此数据建模问题是真实存在的,不是为了“套架构”。当前最需要关注的事实有三点:生成模型与页面模型存在同名异义;GameRoom 等字段的默认值包含额外引号;BattleRecord 的时间字段在 JSON Schema 与生成类之间发生了 Long/String 漂移。

解决方案也不是给每个字段增加 getter/setter,而是明确边界:GameStats 服务局内规则,快照冻结结算结果,DTO 负责跨进程或网络传输,Cloud DB 生成类只承担持久化映射,页面再把它转换成适合展示的 ViewModel。配合运行时校验、显式白名单、版本迁移和契约测试,ArkTS 的静态类型才能真正延伸到 JSON 之外。🚀


推荐标签: OpenHarmony HarmonyOS ArkTS JSON序列化 数据建模 CloudDB 游戏开发

img

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐