【OpenHarmony/HarmonyOs 】29|聊天附件建模:从“占位文本”到图片、文件、位置、联系人的可演进消息结构 📎
本文基于“灵犀互聊”项目
ChatDetailPage.ets与ChatMessage.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的解析方式例如:
msgType | content |
|---|---|
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已经有content和msgType。
使用 JSON 有四个现实优势:
- 不必立即修改 Cloud DB 对象类型;
- 旧文本消息仍可正常读取;
- 每种附件可以拥有不同字段;
- 页面可以根据
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; }其中:
latitude和longitude用于地图定位;address用于显示完整地址;placeName用于卡片标题;mapPreviewUrl可选,用于静态地图预览。
点击位置卡片时,页面可以:
- 打开应用内地图;
- 调用系统地图;
- 展示路线;
- 复制地址。
如果只保存“北京”,这些能力都无法稳定实现。
位置还是敏感数据。
发送前应明确由用户确认,不能静默读取并发送当前位置。
十五、联系人消息不要直接泄露完整通讯录数据
推荐联系人载荷:
interfaceContactPayload{ displayName:string; phoneSummary:string; appUid:string; avatarUrl:string; }这里使用phoneSummary,而不是默认发送所有电话号码。
更安全的交互是:
- 用户主动选择联系人;
- 页面展示即将分享的字段;
- 用户确认后发送;
- 接收方看到联系人卡片;
- 只有明确操作后才保存到通讯录。
如果联系人本身是应用用户,优先发送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 ↓ 文件上传最终失败接收方会看到无法下载的附件。
推荐顺序是:
- 上传到临时对象路径;
- 上传成功并获得元数据;
- 写入 Cloud DB 消息;
- 写入失败时记录待清理对象;
- 后台任务清理长期无引用文件。
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 保存带version的JSON附件文件上传到受保护云存储 页面订阅时把 ChatMessage 转为 MessageItem 转换阶段解析载荷并提供降级值 UI 按 msgType 分发不同 Builder 本地 transferState 不写入 Cloud DB 旧text消息完全兼容这套方案具备以下特点:
- 对现有模型影响小;
- 不破坏实时订阅;
- 能逐类上线附件;
- 符合 ArkTS 明确接口要求;
- 能处理历史数据;
- 为未来拆分附件表保留空间。
四十一、总结
“灵犀互聊”当前附件实现处于一个非常真实的工程阶段:
- UI 入口已经完成;
- Cloud DB 发送链路已经完成;
- 实时订阅已经完成;
msgType已经预留;- 附件仍以文本占位。
这不是终点,但它是合理起点。
下一步不应该简单地把本地文件路径塞进content。
而应该先建立清晰边界:
ChatMessage保存跨设备消息事实 AttachmentPayload 保存附件业务元数据 MessageViewModel 保存本地展示与传输状态 对象存储保存真正的二进制文件 msgType 决定载荷解析与气泡类型真正的附件能力,不是附件面板里有多少图标,而是消息协议能否稳定承载不同类型、不同生命周期和不同安全级别的数据。
占位文本可以帮助项目快速跑通主链路,但消息协议最终必须从“看起来像附件”演进到“能够上传、同步、解析、预览、下载、重试和授权的真实附件”。
只要沿着兼容旧消息、结构化载荷、传输状态分离、云端权限收紧这条路线演进,现有聊天模块就不需要推倒重来,也能逐步成长为完整的多媒体消息系统。