【OpenHarmony/HarmonyOs 】22|为什么不要把 Cloud DB 对象直接喂给 UI?从 `MoodPost` 到 `MoodItem` 的 ViewModel 转换
【OpenHarmony/HarmonyOs 】22|为什么不要把 Cloud DB 对象直接喂给 UI?从 MoodPost 到 MoodItem 的 ViewModel 转换 🧱
本文基于“灵犀互聊”项目中的
MoodPage.ets与MoodPost.ets真实代码展开,完整分析 Cloud DB 查询对象为什么不应直接进入 ArkUI、项目如何完成可见性过滤与字段转换,以及怎样把这段逻辑演进为稳定、可测试的数据适配层。
一、先看项目中的真实数据链路
心情页面的数据并不是本地写死的。
它来自 Cloud DB 中的 MoodPost 对象。
页面加载时,项目先获取 Cloud DB Zone,再按时间倒序查询动态:
const zone = CloudDBService.getInstance().getZone();
if (!zone) {
console.warn('CloudDB zone not ready');
return;
}
const query = CloudDBZoneQuery.where(MoodPost)
.orderByDesc('timestamp');
const result = await zone.executeQuery(query);
const moodPosts = result.getSnapshotObjects();
查询完成之后,项目没有直接执行:
this.displayMoodList = moodPosts;
而是建立了一个新的 MoodItem[]:
const normalized: MoodItem[] = [];
随后逐条完成过滤、解析、重命名和默认值处理:
for (let i = 0; i < moodPosts.length; i++) {
const post = moodPosts[i] as MoodPost;
if (post.getIsPrivate() && post.getUid() !== this.myUid) {
continue;
}
let tags: string[] = [];
try {
if (post.getTags()) {
tags = JSON.parse(post.getTags());
}
} catch (e) {
tags = [];
}
const item: MoodItem = {
id: post.getId(),
userName: post.getNickname() || 'Unknown',
moodRate: post.getMoodRate(),
content: post.getContent(),
imageUri: post.getImageUrl(),
timestamp: post.getTimestamp(),
likes: post.getLikes(),
tags: tags,
isPrivate: post.getIsPrivate()
};
normalized.push(item);
}
this.displayMoodList = normalized;
这段看似只是“倒腾字段”的代码,其实承担了整个页面的数据边界。
完整链路可以概括为:
Cloud DB 查询
↓
SDK 返回快照对象
↓
断言为 MoodPost
↓
执行私密动态过滤
↓
解析 tags JSON 字符串
↓
处理昵称等缺省值
↓
重命名展示字段
↓
生成 MoodItem[]
↓
赋值给 @State
↓
ArkUI 渲染列表、趋势图与热力图
二、MoodPost 和 MoodItem 根本不是同一种对象
先看页面真正使用的展示模型:
interface MoodItem {
id: string;
userName: string;
moodRate: number;
content: string;
imageUri?: string;
timestamp: number;
likes: number;
tags?: string[];
isPrivate?: boolean;
}
MoodItem 的目标非常明确:让 ArkUI 好用。
页面需要用它完成:
- 显示发布者名称;
- 显示心情等级;
- 展示正文与图片;
- 遍历标签数组;
- 格式化发布时间;
- 显示点赞数;
- 判断是否展示私密标记;
- 生成趋势图和热力图数据。
而 MoodPost 是 Cloud DB 持久化对象。
它更关心:
- 字段是否符合对象类型定义;
- 主键
id能否唯一定位记录; uid能否关联发布者;- getter/setter 是否满足 SDK 读写方式;
- 字段能否被 Cloud DB 查询、排序与更新;
- 字符串、数值、布尔值能否稳定持久化。
两者的目标不同:
| 对象 | 服务对象 | 核心职责 |
|---|---|---|
MoodPost |
Cloud DB | 存储、查询、更新、删除 |
MoodItem |
ArkUI 页面 | 展示、交互、派生计算 |
所以,数据库对象“能被页面使用”,不代表“应该被页面直接使用”。
三、直接把 Cloud DB 对象交给 UI 会发生什么?
一种看起来省事的写法是:
@State displayMoodList: MoodPost[] = [];
然后在 UI 中直接调用 getter:
Text(item.getNickname())
Text(item.getContent())
Text(item.getLikes().toString())
短期看,它少写了一段转换代码。
长期却会制造四类耦合。
1. UI 与 SDK 对象形态耦合
页面必须知道 MoodPost 使用 getter,而不是普通属性。
如果以后 Cloud DB 模型生成方式变化,页面也要跟着改。
2. UI 与存储字段命名耦合
数据库使用 nickname 和 imageUrl。
页面真正表达的语义却是 userName 和 imageUri。
如果所有组件都直接使用数据库命名,存储设计会渗透整个 UI。
3. UI 与序列化格式耦合
数据库中的 tags 是字符串。
页面中的标签应该是 string[]。
直接使用 MoodPost,意味着每个展示标签的组件都可能重复 JSON.parse()。
4. UI 与权限规则耦合
私密动态不能只靠组件“不画出来”。
如果原始数组已经进入页面状态,趋势图、统计卡片或其他派生逻辑仍可能误用这些数据。
因此,过滤必须发生在数据进入 UI 状态之前。
四、真实转换的第一项职责:建立 SDK 边界
getSnapshotObjects() 返回的是 SDK 快照对象集合。
项目在循环内部只做一次类型断言:
const post = moodPosts[i] as MoodPost;
这个位置很关键。
合理的原则是:
SDK 返回值只在边界处识别一次,边界之后全部使用项目自己的明确类型。
如果不建立边界,页面各处会出现大量断言:
const post = item as MoodPost;
断言越分散,排查类型问题越困难。
转换完成后,displayMoodList 始终是 MoodItem[]。
ArkUI 只需要相信这个接口,不再关心数据从哪里来。
五、真实转换的第二项职责:私密动态过滤
项目中的过滤逻辑是:
if (post.getIsPrivate() && post.getUid() !== this.myUid) {
continue;
}
它表达了清晰的业务规则:
公开动态 → 可以展示
私密动态且发布者是自己 → 可以展示
私密动态且发布者是别人 → 必须跳过
为什么过滤要放在转换阶段?
因为转换阶段正是“云端数据能否成为页面数据”的审核入口。
如果先把全部数据赋给 @State,再在 ForEach 中判断,至少存在三个问题:
- 原始私密数据已经进入页面内存状态;
- 趋势统计可能在渲染过滤之前读到它;
- 每个消费方都要重复实现可见性判断。
当前过滤能保护页面展示,但还要明确一个安全边界:
客户端过滤只能防止误展示,不能替代 Cloud DB 服务端安全规则。
生产环境仍应通过云端权限确保用户无法查询无权读取的数据。
客户端过滤负责 UI 语义。
服务端规则负责真正的数据访问安全。
两者不能互相替代。
六、真实转换的第三项职责:把字符串标签还原为数组
Cloud DB 中的 tags 使用 JSON 字符串保存。
发布时可以把数组序列化:
post.setTags(JSON.stringify(this.selectedTags));
读取时则需要反序列化:
let tags: string[] = [];
try {
if (post.getTags()) {
tags = JSON.parse(post.getTags());
}
} catch (e) {
tags = [];
}
这一步不只是类型转换,还承担了容错。
云端数据可能出现:
- 空字符串;
- 历史版本写入的非 JSON 文本;
- 手工测试产生的错误值;
- 数据迁移时留下的不完整记录。
如果页面渲染期间直接解析:
ForEach(JSON.parse(item.getTags()), ...)
一条坏数据就可能中断整个列表构建。
而转换层把失败结果统一降级为空数组后,页面只需要处理:
ForEach(item.tags ?? [], (tag: string) => {
Text(tag)
})
这就是 ViewModel 的价值:
它把“不可信的持久化数据”转换成“可预测的页面数据”。
七、真实转换的第四项职责:字段语义重塑
项目做了两个很典型的字段重命名:
nickname → userName
imageUrl → imageUri
为什么不原样保留?
因为同一个值在不同层有不同语义。
nickname 是存储字段
它描述用户资料冗余到动态记录中的昵称。
userName 是展示字段
它描述当前卡片上应显示的发布者名称。
这个值未来不一定只来自 nickname。
它还可能来自:
- 当前用户的本地名称;
- 好友备注;
- 匿名化名称;
- 国际化兜底文本;
- 用户资料服务的最新结果。
同样,imageUrl 强调网络地址。
imageUri 则允许页面未来同时消费:
- HTTPS 地址;
- 本地临时 URI;
- 文件选择器返回的 URI;
- 缓存后的沙箱 URI。
ViewModel 命名应该围绕“页面如何理解这个值”,而不是“数据库如何保存这个值”。
八、真实转换的第五项职责:统一缺省值
项目对昵称做了兜底:
userName: post.getNickname() || 'Unknown'
如果没有这一步,每个文本组件都要写:
Text(item.getNickname() || 'Unknown')
当列表卡片、详情页、分享卡片都需要名字时,重复判断会迅速增加。
转换层可以建立稳定约定:
进入 UI 的 userName 永远是可显示字符串
进入 UI 的 tags 永远可以安全遍历
进入 UI 的 likes 永远是数值
进入 UI 的 timestamp 永远可用于格式化
页面组件越“笨”,越容易复用和测试。
九、转换完成后,一份数据服务多个 UI 模块
normalized 不只用于动态列表。
项目还把它交给趋势图和热力图计算:
this.displayMoodList = normalized;
const myMoods = normalized.filter((item: MoodItem): boolean => {
return item.userName === this.myName ||
item.userName === getStr(this.currentLang).me;
});
this.calculateTrend(myMoods);
this.calculateHeatmap(myMoods);
这说明页面数据模型必须保持一致。
如果动态列表用 MoodPost[],趋势图用另一套临时对象,热力图再解析一次字段,三个模块就可能出现不同口径。
统一成 MoodItem[] 后:
- 列表看到的数据和趋势图一致;
- 私密过滤只执行一次;
- 标签解析只执行一次;
- 默认值策略只维护一处;
- 后续排序和筛选有统一输入。
十、一个值得修正的真实细节:不要用昵称判断“是不是我”
当前项目筛选个人趋势时使用了昵称:
item.userName === this.myName
这在 MVP 阶段可以运行,但昵称并不是稳定身份。
两个用户可能重名。
用户也可能修改昵称。
更可靠的方案是在 MoodItem 中保留发布者 UID:
interface MoodItem {
id: string;
ownerUid: string;
userName: string;
moodRate: number;
content: string;
imageUri?: string;
timestamp: number;
likes: number;
tags: string[];
isPrivate: boolean;
}
转换时赋值:
const item: MoodItem = {
id: post.getId(),
ownerUid: post.getUid(),
userName: post.getNickname() || 'Unknown',
moodRate: post.getMoodRate(),
content: post.getContent(),
imageUri: post.getImageUrl(),
timestamp: post.getTimestamp(),
likes: post.getLikes(),
tags: tags,
isPrivate: post.getIsPrivate()
};
筛选个人数据时改为:
const myMoods = normalized.filter((item: MoodItem): boolean => {
return item.ownerUid === this.myUid;
});
这体现了一个重要原则:
ViewModel 可以改变字段名,但不能丢掉页面业务真正需要的稳定标识。
十一、把可选字段变成稳定字段,UI 会更简单
当前接口中有几个可选字段:
imageUri?: string;
tags?: string[];
isPrivate?: boolean;
其中图片确实可以不存在,所以 imageUri 保持可选很合理。
但 tags 和 isPrivate 已经在转换阶段有明确默认值。
因此更稳定的定义是:
interface MoodItem {
id: string;
ownerUid: string;
userName: string;
moodRate: number;
content: string;
imageUri?: string;
timestamp: number;
likes: number;
tags: string[];
isPrivate: boolean;
}
这样 UI 不需要到处处理 undefined:
ForEach(item.tags, (tag: string) => {
Text(tag)
}, (tag: string): string => tag)
ViewModel 的一个重要目标就是减少页面分支。
十二、进一步演进:把转换提取成明确方法
当前转换写在 refreshData() 内部,便于理解,也适合小页面。
随着逻辑增加,可以提取为单一职责方法:
private parseTags(rawTags: string): string[] {
if (!rawTags) {
return [];
}
try {
const tags = JSON.parse(rawTags) as string[];
return tags;
} catch (e) {
return [];
}
}
再建立对象转换方法:
private toMoodItem(post: MoodPost): MoodItem {
const item: MoodItem = {
id: post.getId(),
ownerUid: post.getUid(),
userName: post.getNickname() || 'Unknown',
moodRate: post.getMoodRate(),
content: post.getContent(),
imageUri: post.getImageUrl(),
timestamp: post.getTimestamp(),
likes: post.getLikes(),
tags: this.parseTags(post.getTags()),
isPrivate: post.getIsPrivate()
};
return item;
}
刷新逻辑就会更清晰:
const normalized: MoodItem[] = [];
for (let i = 0; i < moodPosts.length; i++) {
const post = moodPosts[i] as MoodPost;
if (post.getIsPrivate() && post.getUid() !== this.myUid) {
continue;
}
normalized.push(this.toMoodItem(post));
}
this.displayMoodList = normalized;
这里没有为了“形式漂亮”过度拆分。
parseTags() 负责格式容错。
toMoodItem() 负责字段映射。
refreshData() 负责查询、过滤和提交状态。
职责边界足够清晰。
十三、为什么不建议在 build() 中做转换?
有些开发者会直接在 UI 中写:
Text(post.getNickname() || 'Unknown')
ForEach(JSON.parse(post.getTags()), ...)
这会让渲染过程承担数据清洗工作。
问题包括:
- 每次重建 UI 都可能重复解析 JSON;
- 异常处理很难嵌入声明式结构;
- 页面布局代码被业务规则淹没;
- 多个组件容易采用不同兜底策略;
- 无法在赋值
@State前统一过滤。
更稳妥的时序是:
查询阶段处理不可信数据
↓
转换阶段建立稳定页面模型
↓
状态层只保存可渲染数据
↓
build() 专注描述 UI
十四、点赞和删除为什么仍然要回到 Cloud DB 对象?
ViewModel 适合展示,并不意味着它替代了持久化对象。
项目点赞时会重新查询 MoodPost:
const query = CloudDBZoneQuery.where(MoodPost)
.equalTo('id', id);
const result = await zone.executeQuery(query);
const list = result.getSnapshotObjects();
if (list.length > 0) {
const post = list[0] as MoodPost;
post.setLikes(post.getLikes() + 1);
await zone.executeUpsert(post);
this.refreshData();
}
删除时也会创建 Cloud DB 对象并设置主键:
const post = new MoodPost();
post.setId(id);
await zone.executeDelete(post);
this.refreshData();
这里形成了清晰分工:
读数据:MoodPost → MoodItem → UI
写数据:UI 事件 → 主键/参数 → MoodPost → Cloud DB
页面不应该把 MoodItem 直接传给 executeUpsert()。
因为 MoodItem 不是 Cloud DB 注册对象,也不承担持久化契约。
十五、刷新云端数据,而不是只相信本地乐观结果
项目在点赞或删除成功后执行:
this.refreshData();
这种策略让 Cloud DB 继续作为事实来源。
优点是:
- 最终展示值来自真实写入结果;
- 服务端规则或默认字段会被重新读取;
- 多端修改后更容易保持一致;
- ViewModel 转换流程始终只有一套。
如果追求更快反馈,也可以先做本地不可变更新,再后台刷新校正。
但需要明确:
本地更新负责即时反馈
云端刷新负责最终一致
失败时还要回滚或提示用户。
对于当前项目规模,写入成功后统一刷新是一种简单可靠的方案。
十六、错误数据应该如何处理?
转换层常见的三种策略如下。
策略一:字段级降级
适合非关键字段。
例如标签解析失败时返回空数组:
return [];
策略二:记录级跳过
适合缺失关键身份或主键的数据。
例如 id 为空时,不应进入使用 id 作为 ForEach key 的列表。
策略三:整体失败
适合查询结果本身不可用,或者数据错误会破坏核心业务一致性的场景。
当前项目对昵称和标签采用字段级降级,对私密数据采用记录级跳过,对查询异常采用整体捕获。
这是比较合理的层次。
十七、如何让转换逻辑可测试?
如果 toMoodItem() 只依赖输入对象,它就很容易测试。
可以重点覆盖以下情况:
| 场景 | 期望结果 |
|---|---|
| 正常标签 JSON | 转成对应 string[] |
| 标签为空 | 返回空数组 |
| 标签 JSON 损坏 | 返回空数组,不抛到 UI |
| 昵称为空 | 使用默认名称 |
| 图片为空 | imageUri 不影响文本渲染 |
| 私密且非本人 | 在转换前被过滤 |
| 私密且本人 | 正常进入列表 |
| 点赞数为 0 | 页面得到明确数值 0 |
测试重点不是 Cloud DB SDK 本身。
而是验证项目定义的数据规则有没有被稳定执行。
十八、ViewModel 不是一定要引入框架
提到 ViewModel,很多人会联想到完整 MVVM 框架。
但本文中的 MoodItem 只是一个明确接口。
它已经解决了最关键的问题:
- 隔离 SDK 对象;
- 统一页面字段;
- 提供安全默认值;
- 承载展示语义;
- 为派生计算提供稳定输入。
因此,ViewModel 首先是一种边界意识,而不是某个库。
小项目可以用接口加转换方法。
中型项目可以把 Mapper 放入独立服务。
大型项目可以进一步增加 Repository、缓存和领域模型。
不要为了架构名称增加层级。
要为了变化方向建立边界。
十九、什么时候应该增加 Repository?
当页面出现以下信号时,可以把查询与转换移出页面:
- 多个页面都查询
MoodPost; - 私密过滤规则在多处重复;
- 需要分页和本地缓存;
- 需要离线数据与云端数据合并;
- 点赞后要做乐观更新与失败回滚;
- 需要单元测试而不想初始化 UI;
- 数据来源可能从 Cloud DB 切换或组合。
Repository 可以向页面暴露:
getVisibleMoods(currentUid: string): Promise<MoodItem[]>
页面只负责:
this.displayMoodList = await repository.getVisibleMoods(this.myUid);
但在当前真实项目中,转换逻辑集中于一个页面,直接放在 refreshData() 附近仍然容易理解。
架构升级应由复杂度驱动。
二十、Cloud DB 对象与 ViewModel 的字段设计清单
设计转换层时,可以逐项检查:
- 页面是否仍然直接依赖 SDK getter;
- SDK 返回值是否只在边界处断言;
- 存储字段名是否适合页面语义;
- JSON 字符串是否在进入 UI 前解析;
- 解析失败是否有稳定降级值;
- 私密数据是否在进入状态前过滤;
- 服务端是否还有真正的访问控制;
- ViewModel 是否保留稳定业务主键;
- 是否错误地用昵称代替 UID;
- 可选字段是否真的需要可选;
- 列表、趋势图、热力图是否使用同一数据口径;
- 写回 Cloud DB 时是否重新使用持久化对象;
- 转换逻辑是否能被独立测试;
- 页面
build()是否仍然承担复杂清洗逻辑。
二十一、适用于其他业务的通用映射思路
这套方式不仅适用于心情动态。
用户资料
AGCUser → UserCardItem
可以完成头像兜底、好友备注优先级和在线状态格式化。
聊天消息
ChatMessage → MessageItem
可以完成发送方向判断、时间文本格式化和消息类型解析。
好友申请
FriendRequest → RequestItem
可以完成状态文案、按钮可用性和申请方向判断。
文件记录
CloudFileRecord → FileListItem
可以完成字节数到可读大小、后缀图标和下载状态转换。
共同规律始终是:
持久化对象描述“如何保存”
业务对象描述“如何理解”
ViewModel 描述“如何展示”
简单页面可以合并后两者。
但不要让持久化对象无边界地穿透到 UI。
二十二、总结
“灵犀互聊”的 MoodPage 做了一件很值得保留的事:
它没有把 Cloud DB 返回的 MoodPost[] 直接交给 ArkUI,而是先转换成页面专用的 MoodItem[]。
这段转换同时完成了:
- SDK 类型边界收口;
- 私密动态过滤;
- 标签 JSON 解析;
- 错误数据降级;
- 字段语义重命名;
- 昵称默认值补齐;
- 列表与统计数据口径统一。
真正值得记住的不是某个接口名称,而是这条原则:
Cloud DB 对象适合存,不一定适合画;ViewModel 转换层的价值,不在于多写几行代码,而在于把存储语义、安全语义和展示语义在进入 UI 前提前分开。
当页面只接收稳定、清晰、可预测的数据时,ArkUI 组件会更简单,业务规则会更集中,Cloud DB 模型也能更自由地演进。

更多推荐



所有评论(0)