【OpenHarmony/HarmonyOS】从本地模型到云端 Schema:AGC 对象类型、字段映射与版本治理
【OpenHarmony/HarmonyOS】从本地模型到云端 Schema:AGC 对象类型、字段映射与版本治理
把一个 ArkTS class 放进
common/models,并不代表云数据库已经接通;拥有一份cloud_db_schema.json,也不代表客户端生成类与云端控制台保持一致。在“迷宫坦克派对”中,Cloud DB 对象类型、生成类、ObjectTypeInfoHelper和 AGC 排行榜指南已经形成了清晰的接入骨架,但仓库内也真实存在字段缺失、时间类型漂移和权限边界偏宽等问题。本文不把原型写成上线能力,而是从现有文件出发,讲透 Schema 如何成为跨端契约。☁️
一、先说明当前能力边界
项目当前本地排行榜由 ScoreManager 与 Preferences 承担。docs/AGC_Leaderboard_Guide.md 的开头也明确写着:未来计划接入 AGC,目前项目使用本地 ScoreManager 进行模拟;示例依赖和 API 还要求接入时查询实际版本。
仓库同时存在:
cloud_db_schema.json:四个 Cloud DB 对象类型的 JSON 描述;common/models/*.ts:Cloud DB ObjectType 编译器生成的类;ObjectTypeInfoHelper.ts:客户端对象类型、字段、索引与版本信息;AGC_Leaderboard_Guide.md:从本地排行演进到云排行的设计指南。
因此准确表述应是“已有云端对象模型和接入资料”,而不是“已完成生产级云排行”。Cloud DB 对象存储与 AGC 游戏排行榜也是两个不同方向:前者可保存自定义实体,后者是面向排行榜业务的服务。选型前要先确定需求,不能因为都属于 AGC 就混为同一套 API。
二、四个对象类型各自解决什么问题?
| 对象类型 | 主键 | 主要字段 | 设计意图 |
|---|---|---|---|
PlayerStats |
uid | level、exp、winRate、bestScore、updatedAt | 玩家成长与最佳成绩 |
MatchRequest |
requestId | uid、status、roomId、createdAt | 匹配队列请求 |
GameRoom |
roomId | playerA、playerB、state、lastFrameData | 双方房间状态 |
BattleRecord |
recordId | winnerId、loserId、duration、timestamp | 对局结果记录 |
这是一种典型的“玩家 → 匹配请求 → 房间 → 战绩”链路:
flowchart LR
A["PlayerStats 玩家"] --> B["MatchRequest 匹配请求"]
B --> C["GameRoom 房间"]
C --> D["BattleRecord 战绩"]
D --> A
这张图表达的是对象设计关系,不代表当前客户端已经跑通完整云端匹配。尤其是真人 3v3、服务端权威判定和生产排行,在仓库中仍没有完整闭环。
三、ObjectTypeInfoHelper 是客户端侧契约
ObjectTypeInfoHelper.getObjectTypeInfo() 返回对象类型元数据,其中包括:
objectTypeName:云端对象名称;objectTypeClass:对应 ArkTS 类;fields:字段类型、主键、非空和默认值;indexes:索引名称、字段与排序方向;schemaVersion:当前对象类型版本。
当前文件末尾是:
return {
"objectTypes": [
// BattleRecord / PlayerStats / GameRoom / MatchRequest
],
"schemaVersion": 7
};
版本号为 7,说明这份生成元数据并非“第一版随手定义”。问题在于,仓库里另一份 JSON Schema 与它并不完全一致。如果不知道哪一份是从控制台导出的权威版本,仅看 schemaVersion 无法保证契约正确。
四、真实差异一:BattleRecord 的时间类型漂移 ⚠️
根目录 cloud_db_schema.json 声明:
{
"fieldName": "duration",
"fieldType": "Long"
},
{
"fieldName": "timestamp",
"fieldType": "Long",
"notNull": true
}
但 ObjectTypeInfoHelper.ts 中二者都是 String:
"duration": {
"fieldName": "duration",
"fieldType": "String",
"isPrimaryKey": false,
"notNull": false
},
"timestamp": {
"fieldName": "timestamp",
"fieldType": "String",
"isPrimaryKey": false,
"notNull": true,
"defaultValue": "0"
}
生成的 BattleRecord.ts 也使用 string。三份契约对照如下:
| 字段 | JSON Schema | Helper | ArkTS 生成类 |
|---|---|---|---|
| duration | Long | String | string |
| timestamp | Long | String | string |
这种漂移可能导致写入失败、排序异常、历史数据转换困难,或不同开发环境生成出不兼容代码。timestamp 如果以字符串排序,"100" 可能排在 "20" 前面;如果字符串还混入日期格式,问题会更严重。
建议统一为带单位的数值字段,例如 durationMs 与 timestampMs。字段迁移前先确认云端实际数据类型,不要仅修改本地 JSON 后假设云端随之变化。
五、真实差异二:PlayerStats 多了两个字段
生成的 PlayerStats.ts 与 Helper 都包含:
userName: string = "昵称";
avatar: string = "头像资源路径";
Helper 中也有对应的 String 字段和默认值。但是根目录 cloud_db_schema.json 的 PlayerStats 只到 updatedAt,没有 userName 和 avatar。
| 字段 | JSON Schema | Helper/生成类 | 风险 |
|---|---|---|---|
| userName | 不存在 | 存在 | 客户端认为可写,云端可能拒绝或忽略 |
| avatar | 不存在 | 存在 | 头像映射无法跨端保持一致 |
这里不能简单下结论说“JSON 一定旧”或“生成类一定错”,因为仓库无法证明哪一次控制台导出更晚。正确动作是建立来源信息:每次生成记录控制台环境、Schema 版本、导出时间与生成工具版本,然后只允许权威源生成其他文件。
六、索引不是装饰:它必须对应查询模式
当前 Helper 给 PlayerStats.bestScore 定义了降序索引:
"indexes": [
{
"indexName": "index_score_desc",
"indexList": [
{
"fieldName": "bestScore",
"sortType": "DESC"
}
]
}
]
这个索引适合“按最高分倒序取前 N 名”。但根目录 JSON Schema 的 PlayerStats 没有保存该索引,仍是一处差异。
MatchRequest 则定义了 (status ASC, createdAt ASC) 复合索引:
"indexName": "index_status_created",
"indexList": [
{ "fieldName": "status", "sortType": "ASC" },
{ "fieldName": "createdAt", "sortType": "ASC" }
]
它对应“筛选 matching 状态,再按最早请求优先”的队列查询。字段顺序非常关键:如果实际查询只按 createdAt,或经常先按 uid 查请求,这个索引不一定覆盖。
索引设计应从查询清单反推:
| 查询 | 过滤 | 排序 | 建议索引 |
|---|---|---|---|
| 全球最高分 | 无/赛季 | bestScore DESC | score 或 season+score |
| 待匹配请求 | status=matching | createdAt ASC | status+createdAt |
| 玩家最近战绩 | playerId | timestamp DESC | playerId+timestamp |
| 房间恢复 | roomId | 无 | 主键已覆盖 |
当前 BattleRecord 没有 playerId + timestamp 一类索引,而它又拆成 winner/loser 两个字段。若未来要查“我的全部战绩”,可能需要调整数据模型或分别查询再合并。
七、权限模型需要和“谁拥有这条数据”对齐
cloud_db_schema.json 为四类对象配置了相同权限:
"permissions": [
{
"role": "World",
"rights": ["Read"]
},
{
"role": "Authenticated",
"rights": ["Read", "Upsert", "Delete"]
}
]
从文件字面看,世界角色可读,已认证角色可读、写入和删除。对于公开排行榜,世界可读可能符合展示需求;但如果“任何已认证用户”都能更新任意 PlayerStats、删除战绩或修改房间,就不符合最小权限原则。
生产设计至少应回答:
- 用户是否只能写自己的
uid记录? bestScore是否允许客户端直接提交?BattleRecord由客户端还是可信服务端创建?- 匹配请求能否被其他用户删除?
lastFrameData是否含有不应公开的网络或会话信息?
权限不能只在客户端校验,因为修改客户端即可绕过。高价值分数、奖励与胜负结果应有服务端验证、签名事件或可信计算链路。本文提出的是演进要求,不表示仓库当前已经部署了相应服务端。
八、不要把客户端分数天然当成可信数据
当前 GameStats.score 由本地引擎计算,结算后交给本地 ScoreManager。将这一路径直接换成云写入,能实现多设备展示,却不能自动防作弊。
sequenceDiagram
participant C as "客户端"
participant V as "校验服务"
participant D as "Cloud DB/排行榜"
C->>V: 提交局号、事件摘要、分数、幂等键
V->>V: 校验身份、时长、规则与重复提交
alt 校验通过
V->>D: 写入权威战绩/更新最佳分
D-->>C: 返回排名结果
else 校验失败
V-->>C: 返回稳定错误码
end
轻量项目可以先接受“娱乐性排行榜”的弱可信度,但要在产品说明中承认边界,并把奖励发放与排行榜展示分离。只要排行关联虚拟资产或竞赛奖励,服务端权威就不再是可选优化。
九、生成文件为什么不应该手改?
四个模型文件头都有 DO NOT EDIT。直接把 duration: string 改成 number,短期能让本地编译通过,却会制造新的三方不一致:
云端实际对象类型 ≠ 本地 JSON ≠ 手改生成类
正确链路应该是:
flowchart LR
A["权威 Schema"] --> B["对象类型编译器"]
B --> C["生成模型类"]
B --> D["ObjectTypeInfoHelper"]
C --> E["客户端构建"]
D --> E
A --> F["Schema 契约测试"]
C --> F
D --> F
如果必须在业务中使用更友好的字段名或类型,应新增 Mapper/DTO,而不是让生成层承担显示格式和领域规则。
十、Schema 演进要区分兼容与破坏性变化
| 变更 | 通常兼容性 | 处理建议 |
|---|---|---|
| 新增可空字段 | 较好 | 客户端提供默认回退 |
| 新增非空字段 | 有风险 | 先补历史数据,再收紧约束 |
| 字符串改 Long | 破坏性 | 双写/迁移/灰度读 |
| 删除字段 | 破坏性 | 先停止写入,跨版本观察后删除 |
| 修改主键 | 高风险 | 新对象类型或完整迁移 |
| 修改索引 | 影响性能 | 先验证查询与数据量 |
| 收紧权限 | 影响旧客户端 | 提供版本门槛和错误处理 |
以 BattleRecord.timestamp 为例,推荐采用扩展迁移,而不是原地强转:
- 新增
timestampMsLong 字段; - 新客户端双写旧字段与新字段;
- 后台任务回填历史记录;
- 读路径优先新字段,缺失时解析旧字段;
- 观察旧客户端占比;
- 停止旧字段写入并最终清理。
如果数据尚未真实上线,可直接统一 Schema 并重新生成,但仍应保留一次契约检查,避免下次再次漂移。
十一、客户端 Mapper 隔离云模型
页面不应直接依赖 Cloud DB 生成类的每一个细节。可以将云模型转换为应用模型:
interface LeaderboardItem {
uid: string;
displayName: string;
avatarId: string;
bestScore: number;
updatedAtMs: number;
}
function toLeaderboardItem(source: PlayerStats): LeaderboardItem {
return {
uid: source.getUid(),
displayName: source.getUserName() || '玩家',
avatarId: source.getAvatar() || 'avatar_default',
bestScore: Math.max(0, source.getBestScore()),
updatedAtMs: Math.max(0, source.getUpdatedAt())
};
}
这样,即使云端默认值、字段名或 SDK 对象形式变化,ArkUI 页面仍消费稳定的 LeaderboardItem。Mapper 也是处理 userName/avatar 是否存在、时间单位和默认头像的唯一位置。
十二、本地 + 云端的同步策略
AGC 指南提出断网或未登录时使用本地排行,登录后展示云端排行。要让它真正可用,还需定义:
- 写策略:本地先写还是云端先写;
- 离线队列:失败提交保存哪些字段,何时重试;
- 幂等键:同一局重试不能产生多条战绩;
- 冲突策略:最高分可取 max,昵称则需按版本/更新时间处理;
- 读取状态:缓存数据、刷新中、云端失败分别如何显示;
- 退出登录:是否清除云缓存,如何保留本地个人数据。
最高分适合使用单调合并:bestScore = max(local, cloud),但总局数、胜率不能简单取最大值。它们需要基于不可重复的对局记录聚合,否则多设备同步会重复计数。
十三、把 Schema 契约检查放进 CI 🧪
这次仓库中的差异完全可以自动发现。检查器至少应比较:
对象类型集合
├─ 字段名集合
├─ 字段类型
├─ 主键与 notNull
├─ 默认值
├─ 索引字段及顺序
└─ Schema 版本/来源元数据
推荐测试用例:
| 用例 | 失败条件 |
|---|---|
| BattleRecord 类型契约 | JSON 为 Long、Helper 为 String |
| PlayerStats 字段契约 | 一侧缺 userName/avatar |
| 索引契约 | JSON 缺失 bestScore DESC |
| 默认值契约 | 空字符串生成成字面量双引号 |
| 映射往返 | Long 时间转换后精度丢失 |
| 旧数据读取 | 新增字段导致旧记录无法展示 |
生成代码可不参与普通风格检查,但必须参与契约检查和编译检查。“生成的”不等于“天然正确”,它只意味着错误可能来自上游 Schema 或生成输入。
十四、上线前检查清单 ✅
- 明确 Cloud DB 与游戏排行榜服务的选型,不混用概念;
- 确认哪份 Schema 是唯一权威源;
- 统一
duration/timestamp的 Long/String 类型; - 对齐
PlayerStats.userName/avatar字段; - 修正带额外引号的字符串默认值;
- 复核
bestScore与匹配队列索引; - 将权限收敛到数据所有者和可信服务;
- 建立本地缓存、离线队列和幂等策略;
- 对客户端分数定义可信度与防作弊边界;
- 用测试环境验证旧版/新版客户端兼容;
- 发布前运行 Schema 契约检查;
- 日志中不记录 Token、完整设备信息和个人数据。
十五、总结 ✨
“迷宫坦克派对”已经为云端演进准备了四个对象类型、生成类、Helper、索引和一份 AGC 接入指南,这是值得继续完善的工程基础。但源码也明确告诉我们:BattleRecord 的两个时间字段存在 Long/String 漂移,PlayerStats 的 userName/avatar 在两份 Schema 中不一致,索引和权限也需要以真实查询和数据所有权重新审视。
从本地模型走向云端,最重要的不是多写一个上传方法,而是建立唯一权威 Schema、可重复的代码生成、DTO/Mapper 隔离、版本迁移、最小权限、离线幂等与契约测试。完成这些之前,应把云排行称为“接入设计”或“原型准备”;完成这些之后,云端数据才有机会成为可维护、可升级、可解释的生产契约。🚀
推荐标签: OpenHarmony HarmonyOS ArkTS AppGalleryConnect CloudDB Schema 云数据库 版本治理


更多推荐



所有评论(0)