【OpenHarmony/HarmonyOs 】29|聊天附件建模:从“占位文本”到图片、文件、位置、联系人的可演进消息结构
2026/7/19 9:00:53 网站建设 项目流程

【OpenHarmony/HarmonyOs 】29|聊天附件建模:从“占位文本”到图片、文件、位置、联系人的可演进消息结构 📎

本文基于“灵犀互聊”项目ChatDetailPage.etsChatMessage.ets的真实实现展开。当前附件面板、Cloud DB 发送和实时订阅链路已经跑通,但图片、文件、位置、联系人仍以文本占位。本文将从这个真实阶段出发,设计一套兼容现有数据、适合 ArkTS 严格类型、能够逐步演进的附件消息方案。


一、先看真实项目已经完成了什么

聊天详情页已经有完整的附件入口。

项目通过getAttachItems()返回六类操作:

getAttachItems(): AttachItem[] {conststr=getStr(this.currentLang);return[ { name:str.camera, icon:'📷',color:'#E3F2FD'}, { name:str.photos, icon:'🖼️',color:'#FFF3E0'}, { name:str.document, icon:'📄',color:'#F3E5F5'}, { name:str.location, icon:'📍',color:'#E8F5E9'}, { name:str.contact, icon:'👤',color:'#E0F2F1'}, { name:str.poll, icon:'📊',color:'#FFEBEE'} ]; }

从 UI 看,附件能力已经具备清晰入口:

相机 相册 文件 位置 联系人 投票

点击入口后,当前项目会构造一段占位文本:

handleAttachClick(name: string) { conststr= getStr(this.currentLang); let msgContent ='';if(name===str.camera ||name===str.photos) { msgContent ='【图片】🖼️'; }elseif(name===str.location) { msgContent ='【位置】📍 北京'; }elseif(name===str.document) { msgContent ='【文件】📄 项目报告.pdf'; }elseif(name===str.contact) { msgContent ='【联系人】👤 张三'; }else{ AlertDialog.show({ message:str.feature_coming_soon }); return; } this.doSendMessage(msgContent,'text'); this.showAttachPanel =false; }

这段代码没有真正选择和上传文件。

但它已经打通了:

附件面板 ↓ 入口识别 ↓ 生成消息内容 ↓ doSendMessage ↓ CloudDBexecuteUpsert ↓ 快照订阅回调 ↓ 聊天列表刷新

对于 MVP,这条链路非常有价值。


二、占位文本不是错误,而是阶段性协议

很多开发者看到:

this.doSendMessage('【文件】📄 项目报告.pdf','text');

会立刻认为设计失败。

其实它解决了早期开发中的几个关键问题:

  • 验证底部附件面板交互;
  • 验证不同入口能否正确分支;
  • 验证消息写入 Cloud DB;
  • 验证实时订阅是否能同步;
  • 验证发送后列表是否滚动到底部;
  • 验证发送方与接收方气泡方向。

如果一开始就接入相机、相册、文件选择器、云存储、上传进度、权限申请和缩略图,任何问题都可能阻塞聊天主链路。

所以合理的研发顺序是:

第一阶段:文本消息跑通 第二阶段:附件入口跑通 第三阶段:占位文本验证消息链路 第四阶段:引入结构化载荷 第五阶段:接入文件上传与下载 第六阶段:补齐进度、失败重试与权限

占位文本的意义是隔离风险,而不是最终形态。


三、当前ChatMessage已经预留了扩展入口

真实模型包含以下核心字段:

exportclassChatMessage{id:string;senderId:string;receiverId:string;content:string;msgType:string;timestamp:number;isRead:boolean;constructor() {this.id='default_id';this.senderId='default_senderId';this.receiverId='default_receiverId';this.content='';this.msgType='text';this.timestamp=newDate().getTime();this.isRead=false; } }

发送方法会创建真实 Cloud DB 对象:

const msg =newChatMessage(); msg.setId(Date.now().toString()); msg.setSenderId(this.myUid); msg.setReceiverId(this.targetUid); msg.setContent(content); msg.setMsgType(type); msg.setTimestamp(Date.now()); msg.setIsRead(false); await zone.executeUpsert(msg);

msgType就是协议演进的入口。

当前主要值可以理解为:

类型content当前语义
text普通文本或附件占位文本
emoji表情字符
image已预留但尚未完整落地

这意味着不需要推翻现有模型。

可以从msgType出发逐步扩展。


四、为什么不能永远把附件伪装成文本?

占位文本适合验证链路,但无法承载真实附件行为。

例如:

【文件】📄 项目报告.pdf

它只能告诉用户“看起来有一个文件”。

却无法回答:

  • 文件存在哪里;
  • 文件有多大;
  • MIME 类型是什么;
  • 是否可以下载;
  • 下载是否完成;
  • 文件是否已失效;
  • 是否需要显示缩略图;
  • 点击后应该预览还是保存;
  • 上传失败后如何重试。

位置占位文本也有类似问题:

【位置】📍 北京

它没有经纬度,无法打开地图或导航。

联系人占位文本只有名字,无法稳定识别联系人卡片中的号码或应用用户。

所以最终协议必须从“描述附件”升级为“承载附件”。


五、附件建模首先要区分三类数据

完整附件消息至少包含三层信息。

1. 消息通用字段

所有消息都需要:

  • 消息 ID;
  • 发送者;
  • 接收者;
  • 时间戳;
  • 已读状态;
  • 消息类型。

这些字段继续由ChatMessage承载。

2. 附件业务元数据

例如图片宽高、文件名、经纬度、联系人名称。

这些字段决定 UI 如何展示和交互。

3. 本地传输状态

例如上传中、上传失败、下载进度、本地缓存 URI。

这类字段通常不应该直接写入 Cloud DB 的消息记录。

因为它们属于当前设备状态。

因此可以形成:

ChatMessage云端消息事实 AttachmentPayload 附件业务载荷 MessageViewModel 页面展示与本地传输状态

六、第一种演进方案:继续复用content,存 JSON 载荷

如果暂时不想修改 Cloud DB 对象类型,可以约定:

msgType 决定content的解析方式

例如:

msgTypecontent
text普通文本
emoji表情字符
image图片载荷 JSON
file文件载荷 JSON
location位置载荷 JSON
contact联系人载荷 JSON

图片载荷接口:

interface ImagePayload { remoteUrl: string; thumbnailUrl: string;width: number;height: number; size: number; mimeType: string;caption: string; }

文件载荷接口:

interfaceFilePayload{ remoteUrl:string; fileName:string; size: number; mimeType:string; extension:string; }

位置载荷接口:

interface LocationPayload { latitude: number; longitude: number;address: string; placeName: string; }

联系人载荷接口:

interfaceContactPayload{ displayName:string; phoneSummary:string; appUid:string; avatarUrl:string; }

发送时执行:

const payload:FilePayload = {remoteUrl:uploadedUrl,fileName:selectedFileName,size:selectedFileSize,mimeType:selectedMimeType,extension:selectedExtension}; this.doSendMessage(JSON.stringify(payload), 'file');

这是一种低成本演进方案。


七、为什么 JSON 载荷适合当前项目?

当前ChatMessage已经有contentmsgType

使用 JSON 有四个现实优势:

  1. 不必立即修改 Cloud DB 对象类型;
  2. 旧文本消息仍可正常读取;
  3. 每种附件可以拥有不同字段;
  4. 页面可以根据msgType选择明确解析接口。

它尤其适合当前阶段:

UI 和实时链路已完成 CloudDB模型已投入使用 附件字段仍在快速变化 希望最小化数据库结构调整

但 JSON 方案也有代价:

  • Cloud DB 无法直接按载荷内部字段查询;
  • JSON 格式错误需要客户端容错;
  • 字段升级要处理版本兼容;
  • 服务端规则难以精细校验内部字段。

所以它适合中小规模附件协议,而不是所有场景的最终答案。


八、第二种演进方案:为通用附件增加明确字段

如果 Cloud DB 对象类型允许稳定升级,可以在ChatMessage中增加通用字段:

mediaUrl:string;thumbnailUrl:string;fileName:string;fileSize:number;mimeType:string;extra:string;

字段语义可以约定为:

字段作用
mediaUrl原始附件云端地址
thumbnailUrl图片或视频缩略图
fileName文件展示名称
fileSize字节数
mimeType内容类型
extra类型特有 JSON 扩展

优点是常用字段可以直接访问。

缺点是消息对象会逐渐膨胀。

位置消息不需要文件大小。

联系人消息不需要缩略图。

大量字段对多数消息为空,会降低模型清晰度。

因此通用字段只应放真正跨类型复用的属性。


九、第三种演进方案:消息表与附件表分离

当附件功能继续增长,可以使用两个 Cloud DB 对象:

ChatMessageidsenderId receiverId msgType content timestamp isRead ChatAttachmentidmessageId attachmentType remoteUrl thumbnailUrl fileName fileSize mimeType metadata

一条消息可以对应一个或多个附件。

这种方案适合:

  • 一次发送多张图片;
  • 图文混合消息;
  • 一个消息包含多个文件;
  • 附件需要独立更新审核状态;
  • 附件生命周期与消息不同;
  • 需要独立统计存储用量。

但它也引入关联查询和一致性问题。

对于当前单附件 MVP,直接把结构化载荷放入消息更简单。

架构演进应服从真实复杂度。


十、推荐的消息类型常量

当前模型使用自由字符串:

msg.setMsgType(type);

如果页面到处手写'image''file',很容易出现拼写不一致。

可以建立字符串常量类:

exportclassChatMessageType{staticreadonlyTEXT:string='text';staticreadonlyEMOJI:string='emoji';staticreadonlyIMAGE:string='image';staticreadonlyFILE:string='file';staticreadonlyLOCATION:string='location';staticreadonlyCONTACT:string='contact';staticreadonlyPOLL:string='poll'; }

发送时:

this.doSendMessage(content, ChatMessageType.FILE);

渲染时:

if (message.msgType===ChatMessageType.FILE) { this.buildFileMessage(message);}

这样可以减少魔法字符串。

如果项目已有同类枚举或常量,应优先复用现有风格。


十一、图片消息应该保存哪些字段?

最小图片消息只需要远程地址。

但真实体验通常还需要:

  • 原图地址;
  • 缩略图地址;
  • 宽度;
  • 高度;
  • 字节大小;
  • MIME 类型;
  • 可选说明文字。

推荐载荷:

interface ImagePayload { remoteUrl: string; thumbnailUrl: string;width: number;height: number; size: number; mimeType: string;caption: string; }

为什么要存宽高?

因为图片未下载完成前,气泡也需要预估比例。

如果没有宽高,图片加载完成后布局会突然跳动。

为什么要有缩略图?

因为聊天列表应优先加载小图,而不是直接下载原图。

点击缩略图后再进入大图预览。


十二、相机和相册入口应该共享后半段流程

项目目前把相机和相册都映射为:

msgContent='【图片】🖼️';

真实实现中,两者只在“获取本地 URI”阶段不同。

后续流程应该复用:

相机拍摄 ─┐ ├→ 本地图片 URI → 校验 → 压缩 → 上传 → 构造 ImagePayload → 发送 相册选择 ─┘

可以抽象一个统一方法:

privateasyncsendImageFromUri(localUri:string): Promise<void>{constimageInfo =awaitthis.prepareImage(localUri);constuploaded =awaitthis.uploadImage(imageInfo);constpayload =this.createImagePayload(imageInfo, uploaded);awaitthis.doSendMessage( JSON.stringify(payload), ChatMessageType.IMAGE ); }

入口层只负责拿到 URI。

上传和建模不应在两个分支中重复。


十三、文件消息不仅是“文件名加 URL”

推荐文件载荷:

interfaceFilePayload{ remoteUrl:string; fileName:string; size: number; mimeType:string; extension:string; checksum:string; }

其中checksum可用于:

  • 上传后完整性校验;
  • 下载后校验;
  • 识别相同文件;
  • 防止缓存错配。

文件卡片可以显示:

项目报告.pdf2.4 MB · PDF 下载 / 打开

而不是仅显示:

【文件】项目报告.pdf

还要对文件名进行安全处理。

不要把本地完整路径发送给对方。

例如不要发送:

/data/storage/el2/base/files/private/report.pdf

消息中只保存展示名称与云端对象标识。


十四、位置消息必须保存经纬度

推荐位置载荷:

interface LocationPayload { latitude: number; longitude: number;address: string; placeName: string; mapPreviewUrl: string; }

其中:

  • latitudelongitude用于地图定位;
  • address用于显示完整地址;
  • placeName用于卡片标题;
  • mapPreviewUrl可选,用于静态地图预览。

点击位置卡片时,页面可以:

  • 打开应用内地图;
  • 调用系统地图;
  • 展示路线;
  • 复制地址。

如果只保存“北京”,这些能力都无法稳定实现。

位置还是敏感数据。

发送前应明确由用户确认,不能静默读取并发送当前位置。


十五、联系人消息不要直接泄露完整通讯录数据

推荐联系人载荷:

interfaceContactPayload{ displayName:string; phoneSummary:string; appUid:string; avatarUrl:string; }

这里使用phoneSummary,而不是默认发送所有电话号码。

更安全的交互是:

  1. 用户主动选择联系人;
  2. 页面展示即将分享的字段;
  3. 用户确认后发送;
  4. 接收方看到联系人卡片;
  5. 只有明确操作后才保存到通讯录。

如果联系人本身是应用用户,优先发送appUid

这样接收方可以进入应用内资料页,而不必依赖电话号码。


十六、投票消息为什么比其他附件更像业务对象?

项目附件面板中还有投票入口。

当前它提示功能开发中。

投票与图片、文件不同。

它不仅有静态载荷,还有持续变化的业务状态:

  • 选项列表;
  • 是否多选;
  • 截止时间;
  • 投票人;
  • 每项票数;
  • 是否匿名;
  • 是否已结束。

如果把全部结果写进消息 JSON,每次投票都需要更新同一条消息,容易产生并发覆盖。

更合理的结构是:

ChatMessagemsgType =pollcontent =pollId ChatPollpollId question options deadline status ChatPollVotepollId voterUid optionId

这说明不同“附件”未必都应该采用相同存储方式。

文件是不可变资源。

投票是持续变化的业务实体。


十七、发送附件前必须先上传,还是可以先发消息?

有两种主流策略。

策略一:上传成功后发送消息

流程:

选择文件 ↓ 上传云存储 ↓ 获得 remoteUrl ↓ 写入 ChatMessage

优点:

  • 接收方看到的消息一定可下载;
  • Cloud DB 消息记录结构简单;
  • 不需要同步上传中状态。

缺点:

  • 大文件上传期间聊天列表没有消息;
  • 用户可能误以为点击无效。

策略二:先创建本地临时消息

流程:

选择文件 ↓ 本地列表插入 uploading 消息 ↓ 后台上传 ↓ 上传成功后写 CloudDB↓ 快照消息替换本地临时消息

优点是反馈即时。

缺点是状态管理更复杂。

当前项目更适合先采用策略一,再逐步增加本地临时消息。


十八、上传状态不应直接等同于消息已读状态

ChatMessage当前有:

isRead:boolean;

它描述接收方是否阅读消息。

上传状态则属于另一条状态轴:

preparing uploading sending sent failed

下载状态又是第三条状态轴:

notDownloaded downloading downloaded downloadFailed

不要复用isRead表示上传成功。

推荐在页面 ViewModel 中增加本地状态:

interface MessageItem {id:string; content:string; msgType:string; isSelf:boolean;time:string; transferState:string; progress:number; localUri:string; }

云端消息模型负责跨设备一致状态。

ViewModel 负责本设备传输状态。


十九、Cloud DB 消息到页面模型也需要转换

真实订阅回调当前只转换四个字段:

newMessages.push({ id: msg.getId(), content: msg.getContent(), isSelf: msg.getSenderId() === this.myUid, time: newDate(msg.getTimestamp()).toLocaleTimeString([], { hour:'2-digit', minute:'2-digit'}) });

要支持附件,页面模型至少需要保留msgType

interfaceMessageItem{ id:string; content:string; msgType:string; isSelf:boolean; time:string; }

转换时:

const item: MessageItem = {id: msg.getId(), content: msg.getContent(), msgType: msg.getMsgType(), isSelf: msg.getSenderId() === this.myUid, time: this.formatMessageTime(msg.getTimestamp()) };newMessages.push(item);

否则 Cloud DB 即使保存了image,UI 仍然只会把它当普通文字画出来。


二十、解析附件时必须容错

历史消息、旧版本客户端和异常数据都可能导致 JSON 不合法。

不要在build()中直接执行:

const payload=JSON.parse(message.content);

可以在转换层解析:

privateparseFilePayload(content: string): FilePayload |null{if(!content) {returnnull; }try{constpayload = JSON.parse(content)asFilePayload;if(!payload.remoteUrl || !payload.fileName) {returnnull; }returnpayload; }catch(e) {returnnull; } }

解析失败时使用降级文案:

[文件消息暂时无法显示]

不能让一条损坏消息阻塞整个聊天列表。


二十一、为载荷增加版本号

附件协议上线后仍然会变化。

例如第一版图片消息没有宽高。

第二版增加缩略图。

第三版增加加密信息。

建议载荷包含版本号:

interface ImagePayload { version: number; remoteUrl: string; thumbnailUrl: string;width: number;height: number; size: number; mimeType: string;caption: string; }

解析时根据版本处理:

version=1→ 使用原图地址并采用默认比例version=2→ 使用缩略图与真实宽高 未知版本 → 显示兼容提示

版本号比“猜字段是否存在”更容易长期维护。


二十二、兼容现有占位文本的迁移策略

现有 Cloud DB 中可能已经保存:

msgType=textcontent=【图片】🖼️

新版本不能让这些消息消失。

建议采用向后兼容策略:

msgType=text → 按普通文本展示,包括旧占位文本msgType=image/file/location/contact → 尝试解析结构化载荷 解析失败 → 显示类型对应的降级卡片

不建议仅根据文本前缀判断类型:

content.startsWith('【图片】')

因为普通用户也可能输入相同文本。

消息协议应以msgType为准。

旧占位消息继续作为文本保留即可。


二十三、消息气泡应该按类型分发 Builder

当前文字消息可以使用统一气泡。

附件增加后,可以建立分发构建器:

@Builder buildMessageContent(message: MessageItem){if(message.msgType===ChatMessageType.IMAGE) { this.buildImageMessage(message)}elseif(message.msgType===ChatMessageType.FILE) { this.buildFileMessage(message)}elseif(message.msgType===ChatMessageType.LOCATION) { this.buildLocationMessage(message)}elseif(message.msgType===ChatMessageType.CONTACT) { this.buildContactMessage(message)}else{ this.buildTextMessage(message)} }

每个 Builder 只处理一种展示结构。

外层消息行继续统一处理:

  • 左右方向;
  • 头像;
  • 时间;
  • 发送状态;
  • 长按菜单。

这样不会为每种附件复制整套消息布局。


二十四、图片消息的 UI 解决方案

图片气泡建议包含:

  • 缩略图;
  • 圆角裁剪;
  • 上传或下载进度遮罩;
  • 失败重试入口;
  • 可选说明文字;
  • 点击预览。

布局宽度应有上限。

同时根据原始宽高计算比例,避免超长图撑满页面。

加载失败时不能留空白。

应显示:

图片加载失败 点击重试

接收方点击图片时,可以优先读取本地缓存。

没有缓存再下载远程资源。


二十五、文件消息的 UI 解决方案

文件卡片通常包含:

文件类型图标 文件名 可读大小 下载状态

字节数需要转换:

856B24.5KB2.4MB1.1GB

不要在 Cloud DB 中保存格式化后的“2.4 MB”。

应保存原始字节数size

因为格式化规则可能受语言和精度影响。

文件类型图标可以根据 MIME 类型或扩展名选择。

但扩展名只用于展示,安全判断应以实际内容和 MIME 校验为准。


二十六、位置与联系人卡片的 UI 解决方案

位置卡片建议显示:

  • 地点名称;
  • 地址摘要;
  • 静态地图预览;
  • 点击打开地图。

联系人卡片建议显示:

  • 头像;
  • 展示名称;
  • 脱敏联系方式;
  • 查看资料按钮。

卡片中不要默认展示完整敏感号码。

点击后再进入明确的详情和授权流程。

UI 只是协议的消费方。

只有载荷字段明确,卡片才能稳定实现。


二十七、文件上传与 Cloud DB 写入不是同一个事务

一个常见失败场景是:

文件上传成功 ↓ CloudDB消息写入失败

此时云存储中会留下孤立文件。

反过来也可能出现:

CloudDB先写入 URL ↓ 文件上传最终失败

接收方会看到无法下载的附件。

推荐顺序是:

  1. 上传到临时对象路径;
  2. 上传成功并获得元数据;
  3. 写入 Cloud DB 消息;
  4. 写入失败时记录待清理对象;
  5. 后台任务清理长期无引用文件。

Cloud DB 与对象存储通常无法构成真正的跨服务事务。

因此需要补偿机制,而不是假设两步永远同时成功。


二十八、附件 URL 不应默认永久公开

如果把永久公开 URL 直接写进消息,任何拿到地址的人都可能访问文件。

更安全的方式包括:

  • 使用受权限保护的对象存储;
  • 保存对象 key,而不是永久公开 URL;
  • 下载时由服务生成短期签名地址;
  • 服务端校验请求者是否属于会话;
  • 敏感附件增加加密与过期策略。

当前 Cloud DB 查询还在客户端过滤会话消息。

生产环境还应收紧查询权限,确保用户只能读取自己参与的会话。

附件权限不能比消息权限更宽松。


二十九、文件安全校验不能只看扩展名

用户选择report.pdf,不代表文件内容一定是 PDF。

发送前至少应检查:

  • 文件大小是否超限;
  • MIME 类型是否允许;
  • 文件名是否包含异常字符;
  • 实际内容与声明类型是否匹配;
  • 是否命中禁止类型;
  • 是否需要服务端病毒扫描。

接收端也不能因为扩展名是图片就直接按图片解码。

客户端校验提升体验。

服务端校验才是最终安全边界。


三十、消息 ID 不能只依赖毫秒时间戳

当前项目使用:

msg.setId(Date.now().toString());

MVP 中通常够用。

但附件上传可能并发完成。

同一毫秒内发送多条消息时,纯时间戳存在冲突风险。

更稳妥的 ID 应包含:

发送者UID时间戳 随机片段或序列号

例如逻辑形式:

uid_timestamp_random

如果平台提供可靠 UUID API,应优先使用官方能力。

稳定主键还用于:

  • ForEach key;
  • 本地临时消息与云端消息对齐;
  • 上传失败重试;
  • 去重;
  • 消息撤回。

三十一、一条消息需要conversationId

当前聊天订阅按时间查询全部消息,再在内存中过滤双方 UID。

MVP 可以工作,但附件消息通常体积更大,继续订阅全量数据会放大成本。

可以增加稳定会话 ID:

conversationId=两个 UID 排序后组合

例如:

uidA_uidB

发送消息时写入conversationId

订阅时只查询当前会话。

收益包括:

  • 减少无关快照数据;
  • 降低附件元数据传输;
  • 更容易实施会话权限;
  • 支持会话列表最后消息;
  • 支持按会话清理历史记录。

附件演进不只是增加字段,也会推动查询模型升级。


三十二、多设备与本地 URI 的边界

文件选择器返回的 URI 通常只在当前设备可用。

不能把本地 URI 当作附件地址写入 Cloud DB:

msg.setContent(localUri);

接收方设备无法访问发送方沙箱。

正确链路是:

本地 URI ↓ 当前设备读取文件 ↓ 上传云存储 ↓ 获得远程对象标识 ↓ 写入消息载荷

本地 URI 可以放在MessageViewModel.localUri中,用于发送方预览和缓存。

远程标识才属于跨设备消息协议。


三十三、断点续传和重试应该如何建模?

大文件上传可能因网络切换失败。

本地任务可以记录:

interfaceTransferTask{taskId:string;messageId:string;localUri:string;remoteKey:string;progress:number;state:string;retryCount:number;}

这类任务不必全部放进ChatMessage

它属于本地传输队列。

重试时应复用同一个messageId或客户端临时 ID,避免每次重试都产生重复消息。

上传成功后再把最终载荷写入 Cloud DB。

页面可以通过任务 ID 更新进度。


三十四、已读、送达、上传完成是三种不同状态

消息系统常见状态至少有:

本地创建 上传中 发送中 服务端已保存 对方设备已收到 对方已读

当前isRead只覆盖最后一部分。

不要用一个布尔值表达所有阶段。

可以逐步增加:

  • 本地transferState
  • 云端deliveryState
  • 云端或独立记录中的阅读回执。

初期不必一次完成全部状态。

但字段语义必须单一,不能让isRead = true同时表示“上传成功”。


三十五、撤回和删除附件时如何处理文件?

删除消息不一定意味着立即删除云端文件。

要考虑:

  • 仅自己删除;
  • 双方撤回;
  • 文件是否被其他消息引用;
  • 缓存是否仍需保留;
  • 合规保留期限;
  • 对象存储清理延迟。

一种常见策略是:

消息标记已撤回 ↓ 客户端不再展示附件入口 ↓ 后台检查文件引用 ↓ 超过保留期且无引用后删除对象

不要在 UI 点击删除时盲目同步删除远程文件。

跨端消息状态可能尚未完成同步。


三十六、推荐的渐进式落地路线

结合当前真实项目,可以按下面顺序实施。

阶段一:保留现有占位消息

保证聊天主链路持续可用。

阶段二:页面模型增加msgType

让 UI 具备类型分发能力。

阶段三:先落地图片消息

相机与相册共享上传、载荷和渲染逻辑。

阶段四:增加文件消息

补充文件选择、大小限制、下载与打开。

阶段五:增加位置和联系人

重点处理权限、确认与隐私。

阶段六:增加传输 ViewModel

支持进度、失败、重试与本地缓存。

阶段七:升级会话查询

增加conversationId,缩小订阅范围。

阶段八:补齐云端安全

保护对象存储、限制 Cloud DB 查询、清理孤立文件。

这样的顺序不会推翻已有代码。

每一步都能独立验证。


三十七、附件协议测试清单

上线前至少覆盖以下场景:

图片

  • 相机拍摄成功;
  • 相机权限拒绝;
  • 相册选择取消;
  • 超大图片压缩;
  • 上传失败重试;
  • 缩略图加载失败;
  • 原图预览;
  • 横图、竖图和长图布局。

文件

  • 文件选择取消;
  • 文件超过大小限制;
  • 禁止类型拦截;
  • 中文和超长文件名;
  • 下载中断;
  • 下载后校验失败;
  • 本地无对应打开应用。

位置与联系人

  • 定位权限拒绝;
  • 无定位结果;
  • 用户取消确认;
  • 联系人权限拒绝;
  • 敏感字段脱敏;
  • 接收方无法识别应用用户。

协议兼容

  • 旧文本消息正常显示;
  • 旧占位附件仍按文本显示;
  • JSON 载荷损坏时降级;
  • 未知msgType显示兼容提示;
  • 未知载荷版本不会导致崩溃;
  • 多端同时在线时消息不重复。

三十八、建模时最容易犯的错误

错误一:把本地 URI 写入云端

接收方设备无法读取。

错误二:永久公开附件 URL

消息权限与文件权限脱节。

错误三:所有附件都塞进一个巨大接口

大量可选字段导致语义模糊。

错误四:在build()中直接解析 JSON

一条坏消息可能影响整个列表。

错误五:只保存格式化文件大小

无法重新按语言或精度格式化。

错误六:用文件扩展名作为安全依据

文件内容可能与扩展名不一致。

错误七:上传失败仍写入最终消息

接收方会看到不可用附件。

错误八:忽略旧版本消息

升级后历史聊天记录无法展示。

错误九:复用isRead表示发送状态

不同状态轴被混为一谈。

错误十:没有稳定消息 ID

重试和快照同步会产生重复项。


三十九、项目级职责划分建议

随着附件落地,可以按职责组织现有代码,而不必一次重构所有页面。

职责建议位置
附件入口与面板状态ChatDetailPage
相机、相册、文件选择选择器适配服务
文件校验与元数据提取附件服务
上传、下载与删除存储服务
Cloud DB 消息读写消息 Repository 或现有服务
JSON 载荷编解码消息 Mapper/Codec
气泡渲染各类型 Builder 或组件
上传进度和本地 URI页面 ViewModel/传输任务

当前项目可以先把方法放在页面附近。

当图片与文件出现重复逻辑时,再提取服务。

不要为了目录漂亮提前制造空架构。


四十、一个适合当前项目的最小可行方案

如果只做第一版真实附件,可以采用:

Cloud DB ChatMessage 保持现有字段 msgType 扩展 image/file/location/contact content 保存带versionJSON附件文件上传到受保护云存储 页面订阅时把 ChatMessage 转为 MessageItem 转换阶段解析载荷并提供降级值 UI 按 msgType 分发不同 Builder 本地 transferState 不写入 Cloud DB 旧text消息完全兼容

这套方案具备以下特点:

  • 对现有模型影响小;
  • 不破坏实时订阅;
  • 能逐类上线附件;
  • 符合 ArkTS 明确接口要求;
  • 能处理历史数据;
  • 为未来拆分附件表保留空间。

四十一、总结

“灵犀互聊”当前附件实现处于一个非常真实的工程阶段:

  • UI 入口已经完成;
  • Cloud DB 发送链路已经完成;
  • 实时订阅已经完成;
  • msgType已经预留;
  • 附件仍以文本占位。

这不是终点,但它是合理起点。

下一步不应该简单地把本地文件路径塞进content

而应该先建立清晰边界:

ChatMessage保存跨设备消息事实 AttachmentPayload 保存附件业务元数据 MessageViewModel 保存本地展示与传输状态 对象存储保存真正的二进制文件 msgType 决定载荷解析与气泡类型

真正的附件能力,不是附件面板里有多少图标,而是消息协议能否稳定承载不同类型、不同生命周期和不同安全级别的数据。

占位文本可以帮助项目快速跑通主链路,但消息协议最终必须从“看起来像附件”演进到“能够上传、同步、解析、预览、下载、重试和授权的真实附件”。

只要沿着兼容旧消息、结构化载荷、传输状态分离、云端权限收紧这条路线演进,现有聊天模块就不需要推倒重来,也能逐步成长为完整的多媒体消息系统。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询