【OpenHarmony/HarmonyOs 】22|为什么不要把 Cloud DB 对象直接喂给 UI?从 MoodPostMoodItem 的 ViewModel 转换 🧱

本文基于“灵犀互聊”项目中的 MoodPage.etsMoodPost.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 渲染列表、趋势图与热力图

二、MoodPostMoodItem 根本不是同一种对象

先看页面真正使用的展示模型:

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 与存储字段命名耦合

数据库使用 nicknameimageUrl

页面真正表达的语义却是 userNameimageUri

如果所有组件都直接使用数据库命名,存储设计会渗透整个 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 中判断,至少存在三个问题:

  1. 原始私密数据已经进入页面内存状态;
  2. 趋势统计可能在渲染过滤之前读到它;
  3. 每个消费方都要重复实现可见性判断。

当前过滤能保护页面展示,但还要明确一个安全边界:

客户端过滤只能防止误展示,不能替代 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 保持可选很合理。

tagsisPrivate 已经在转换阶段有明确默认值。

因此更稳定的定义是:

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[]

这段转换同时完成了:

  1. SDK 类型边界收口;
  2. 私密动态过滤;
  3. 标签 JSON 解析;
  4. 错误数据降级;
  5. 字段语义重命名;
  6. 昵称默认值补齐;
  7. 列表与统计数据口径统一。

真正值得记住的不是某个接口名称,而是这条原则:

Cloud DB 对象适合存,不一定适合画;ViewModel 转换层的价值,不在于多写几行代码,而在于把存储语义、安全语义和展示语义在进入 UI 前提前分开。

当页面只接收稳定、清晰、可预测的数据时,ArkUI 组件会更简单,业务规则会更集中,Cloud DB 模型也能更自由地演进。

img

Logo

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

更多推荐