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

【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再按时间倒序查询动态constzone CloudDBService.getInstance().getZone();if(!zone) { console.warn(CloudDB zone not ready);return; }constquery CloudDBZoneQuery.where(MoodPost) .orderByDesc(timestamp);constresult awaitzone.executeQuery(query);constmoodPosts result.getSnapshotObjects();查询完成之后项目没有直接执行this.displayMoodList moodPosts;而是建立了一个新的MoodItem[]const normalized: MoodItem[][];随后逐条完成过滤、解析、重命名和默认值处理for (leti 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 ↓ 执行私密动态过滤 ↓ 解析 tagsJSON字符串 ↓ 处理昵称等缺省值 ↓ 重命名展示字段 ↓ 生成 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 查询、排序与更新字符串、数值、布尔值能否稳定持久化。两者的目标不同对象服务对象核心职责MoodPostCloud DB存储、查询、更新、删除MoodItemArkUI 页面展示、交互、派生计算所以数据库对象“能被页面使用”不代表“应该被页面直接使用”。三、直接把 Cloud DB 对象交给 UI 会发生什么一种看起来省事的写法是StatedisplayMoodList: MoodPost[] [];然后在 UI 中直接调用 getterText(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 快照对象集合。项目在循环内部只做一次类型断言constpost moodPosts[i]asMoodPost;这个位置很关键。合理的原则是SDK 返回值只在边界处识别一次边界之后全部使用项目自己的明确类型。如果不建立边界页面各处会出现大量断言constpost itemasMoodPost;断言越分散排查类型问题越困难。转换完成后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));读取时则需要反序列化lettags: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;constmyMoods normalized.filter((item: MoodItem): boolean {returnitem.userName this.myName || item.userName getStr(this.currentLang).me; });this.calculateTrend(myMoods);this.calculateHeatmap(myMoods);这说明页面数据模型必须保持一致。如果动态列表用MoodPost[]趋势图用另一套临时对象热力图再解析一次字段三个模块就可能出现不同口径。统一成MoodItem[]后列表看到的数据和趋势图一致私密过滤只执行一次标签解析只执行一次默认值策略只维护一处后续排序和筛选有统一输入。十、一个值得修正的真实细节不要用昵称判断“是不是我”当前项目筛选个人趋势时使用了昵称item.userNamethis.myName这在 MVP 阶段可以运行但昵称并不是稳定身份。两个用户可能重名。用户也可能修改昵称。更可靠的方案是在MoodItem中保留发布者 UIDinterface 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() };筛选个人数据时改为constmyMoods normalized.filter((item: MoodItem): boolean {returnitem.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 不需要到处处理undefinedForEach(item.tags, (tag:string) {Text(tag)}, (tag:string):stringtag)ViewModel 的一个重要目标就是减少页面分支。十二、进一步演进把转换提取成明确方法当前转换写在refreshData()内部便于理解也适合小页面。随着逻辑增加可以提取为单一职责方法privateparseTags(rawTags:string):string[]{if(!rawTags) {return[]; }try{consttags JSON.parse(rawTags)asstring[];returntags; }catch(e) {return[]; } }再建立对象转换方法privatetoMoodItem(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; }刷新逻辑就会更清晰constnormalized: MoodItem[] [];for(let i 0; i moodPosts.length; i) {constpost moodPosts[i]asMoodPost;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 适合展示并不意味着它替代了持久化对象。项目点赞时会重新查询MoodPostconst query CloudDBZoneQuery.where(MoodPost) .equalTo(id,id); const result await zone.executeQuery(query); constlist result.getSnapshotObjects();if(list.length 0) { const post list[0]asMoodPost; post.setLikes(post.getLikes()1); await zone.executeUpsert(post); this.refreshData(); }删除时也会创建 Cloud DB 对象并设置主键const post newMoodPost(); post.setId(id); await zone.executeDelete(post); this.refreshData();这里形成了清晰分工读数据MoodPost → MoodItem → UI 写数据UI 事件 → 主键/参数 → MoodPost → CloudDB页面不应该把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): PromiseMoodItem[]页面只负责this.displayMoodList await repository.getVisibleMoods(this.myUid);但在当前真实项目中转换逻辑集中于一个页面直接放在refreshData()附近仍然容易理解。架构升级应由复杂度驱动。二十、Cloud DB 对象与 ViewModel 的字段设计清单设计转换层时可以逐项检查页面是否仍然直接依赖 SDK getterSDK 返回值是否只在边界处断言存储字段名是否适合页面语义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 模型也能更自由地演进。