ChatPage 即时通讯 UI 技术文档
一、ChatPage 功能全景
ChatPage 是 NearPlay HarmonyOS 应用中即时通讯功能的核心页面,承载了用户之间一对一私聊的全部交互体验。作为一个完整的聊天界面,ChatPage 实现了从消息输入、消息发送、消息展示到用户关系管理的全链路功能闭环,是整个社交模块中最复杂、交互最密集的页面组件之一。
在消息类型维度上,ChatPage 支持三种核心消息形态的收发与展示:文本消息(TEXT)、语音消息(VOICE)和图片消息(IMAGE)。这三种消息类型覆盖了即时通讯中最基础也是最高频的交互场景------文本消息满足日常文字交流需求,语音消息满足不方便打字时的快捷沟通需求,图片消息满足视觉内容分享的需求。三种消息类型在数据模型层通过 ChatMsgType 枚举进行区分,在 UI 层通过不同的气泡样式进行可视化呈现,在发送逻辑层通过各自的工厂方法和交互入口进行独立处理,形成了清晰的三路并行架构。
在用户关系管理维度上,ChatPage 内置了完整的拉黑过滤机制。当用户对某位聊天对象执行拉黑操作后,系统会立即阻断该方向的消息发送能力,并在聊天界面底部展示"已拉黑,无法发送消息"的醒目提示条,同时提供便捷的取消拉黑入口。拉黑状态的检查贯穿整个消息发送流程------无论是文本消息、语音消息还是图片消息,在发送前都会经过 isUserBlocked() 的实时校验,确保被拉黑的用户无法通过任何消息类型突破封锁。这种设计既保障了用户的安全感和隐私控制权,又避免了拉黑后仍然出现误操作的可能性。
在完整聊天体验维度上,ChatPage 从页面进入那一刻就开始构建沉浸式的对话场景。页面加载时通过路由参数获取对话标识和对方信息,自动加载历史消息记录;消息列表采用上下滚动布局,新消息追加后自动滚动到底部;输入区域集成了文本输入框、语音录制按钮和更多功能面板三个入口,三者之间互斥显示,避免了界面元素的拥挤和冲突;录音状态有独立的视觉反馈区域,显示实时计时和操作按钮;图片选择通过系统级相册选择器完成,保持了与 HarmonyOS 原生体验的一致性;拉黑确认通过自定义全屏遮罩对话框实现,操作流程清晰且不易误触。所有这些功能组件通过状态变量有机串联,任何状态变化都会通过 ArkUI 的响应式机制即时反映到 UI 层,实现了真正的数据驱动视图更新。
整体而言,ChatPage 是一个功能完备、交互流畅、状态管理严谨的即时通讯页面。它不仅在功能层面覆盖了聊天场景的三大核心需求,还在用户体验层面做到了每种交互都有明确的视觉反馈,每种异常状态都有妥善的降级处理,每个边界情况都有预设的防护逻辑。这种全面性和细致性使得 ChatPage 成为了 NearPlay 社交生态中不可或缺的基础设施组件。
二、ChatPageParams 参数类
2.1 为什么使用 class 而非 interface
在 NearPlay 的路由参数体系中,ChatPageParams 是一个值得特别关注的设计决策------它被声明为 class 而非 interface。这一选择并非随意为之,而是基于 ArkTS 运行时特性和路由参数传递机制的深思熟虑。
首先,ArkTS 的 interface 是纯类型层面的构造,仅在编译期存在,经过编译后会被完全擦除。这意味着 interface 无法携带默认值、无法拥有构造逻辑、无法在运行时被实例化。而路由参数的接收过程------this.getUIContext().getRouter().getParams() as ChatPageParams------本质上是一个运行时类型断言操作,需要目标类型在运行时具有实际的构造能力。当路由参数对象被传递到 ChatPage 时,它是一个动态的 JavaScript 对象,其字段可能存在也可能缺失。如果使用 interface,所有字段的缺失风险都需要在使用侧逐一通过空值合并运算符 ?? 来防御,代码会变得冗长且容易遗漏。
其次,class 天然支持字段默认值的声明。在 ChatPageParams 的实现中,四个字段均被赋予了空字符串的默认值:conversationId: string = ''、targetName: string = ''、targetAvatar: string = ''、targetId: string = ''。这些默认值在运行时真实存在,即使传入的路由参数对象缺少某个字段,通过 as ChatPageParams 断言后访问该字段也不会得到 undefined,而是得到预设的默认空字符串。这种"类型安全+默认值保护"的双重防线,是 interface 无法提供的。
再者,从代码维护角度考虑,class 的默认值声明是内聚的------字段的类型和默认值定义在同一个位置,任何修改都只需改一处。而如果使用 interface,默认值的处理逻辑会散落在各个消费方(如 ChatPage 的 aboutToAppear 方法),每次新增字段都需要在所有消费方补充默认值逻辑,维护成本随字段数量线性增长。
在 NearPlay 项目的 RouteParams.ets 文件中,可以观察到这一设计模式的选择性应用:GameRoomParams、ActivityDetailParams、UserProfileParams、UserRunProfileParams 都使用了 interface,因为它们的消费方能够保证参数完整性;唯独 ChatPageParams 使用了 class,因为聊天页面需要更加健壮的参数容错能力,且字段默认值为空字符串的语义也是合理的------缺少对话 ID 则不加载消息,缺少对方信息则显示匿名状态。
2.2 四个字段详解
conversationId(对话标识符)
conversationId 是当前聊天会话的唯一标识,类型为 string,默认值为空字符串。该字段在整个 ChatPage 中扮演着核心关联角色:它既是消息列表加载的查询依据------MockChatData.getMessages(this.conversationId) 根据该 ID 获取对应对话的历史消息;又是新消息构造时的归属标记------每条通过 ChatMsg.text()、ChatMsg.voice()、ChatMsg.image() 工厂方法创建的消息都会携带该 conversationId,确保消息被正确关联到所属对话。在多对话场景下,conversationId 保证了消息不会跨对话串扰,是数据隔离的基本保障。
targetName(对方用户昵称)
targetName 存储聊天对象的显示昵称,类型为 string,默认值为空字符串。该字段主要用于页面顶部导航栏的标题展示------Text(this.targetName) 在导航栏中显示对方昵称,让用户始终明确当前聊天的对象身份。此外,在拉黑确认对话框中,targetName 也被用于构建人性化的提示文案------确定要拉黑 ${this.targetName} 吗?,使操作确认信息更加具体和直观,降低误操作风险。在 BlockedUser 构造时,targetName 同样被传入以保持黑名单记录的完整性。
targetAvatar(对方用户头像)
targetAvatar 存储聊天对象的头像标识,类型为 string,默认值为空字符串。在 NearPlay 的当前实现中,头像采用 Emoji 字符作为轻量级标识(如 '👧'、'🧑'、'🐺'),而非传统的图片 URL。该字段在导航栏中以 Text(this.targetAvatar).fontSize(22) 的形式渲染,与 targetName 相邻排列,形成"头像+昵称"的标准聊天页头部布局。同时,在消息气泡中,当消息来自对方时,targetAvatar 也被用于显示发送者头像------Text(msg.fromAvatar !== '' ? msg.fromAvatar : this.targetAvatar) 优先使用消息自带的发送者头像,回退到页面级的 targetAvatar,确保头像显示的可靠性。
targetId(对方用户标识符)
targetId 是聊天对象的用户唯一标识,类型为 string,默认值为空字符串。该字段是拉黑功能的核心关联键------isUserBlocked(this.targetId) 以 targetId 为参数查询黑名单状态,BlockUser.of(this.targetId, this.targetName, this.targetAvatar) 以 targetId 构造黑名单记录,unblockUser(this.targetId) 以 targetId 为参数执行取消拉黑操作。与 targetName 和 targetAvatar 不同,targetId 不参与任何 UI 展示,它是纯粹的业务逻辑标识符,确保用户关系操作的准确性和唯一性。在群聊场景中,targetId 可能指向群组 ID 而非个人用户 ID,当前实现主要面向一对一私聊。
三、文本消息发送
3.1 TextInput 输入组件
文本消息的输入入口是 ChatPage 底部输入栏中的 TextInput 组件。该组件通过 TextInput({ placeholder: '输入消息...', text: this.inputText }) 构造,其中 placeholder 参数提供了空状态下的引导文案"输入消息...",text 参数绑定到状态变量 this.inputText,实现了输入内容的双向同步。
TextInput 的样式配置遵循了现代聊天应用的设计范式:layoutWeight(1) 使其占据输入栏中除去按钮后的全部剩余空间,保证了文本输入区域的宽度最大化;height(40) 设定了适中的触摸高度,兼顾了输入体验和界面紧凑性;borderRadius(20) 赋予了圆角胶囊形态,与发送按钮的视觉风格保持协调。
核心的交互逻辑通过 onChange 回调实现:onChange((value: string) => { this.inputText = value })。每当用户在输入框中键入或删除字符时,ArkUI 框架会触发该回调,将最新的输入值同步到 inputText 状态变量。这一同步机制是后续发送按钮条件显示和消息内容提取的基础------发送按钮的可见性由 this.inputText.trim() !== '' 控制,消息发送时使用的文本内容由 this.inputText.trim() 提取。
值得注意的是,ArkTS 中 TextInput 的 onChange 回调在每次输入变化时都会触发,包括中文输入法的组合输入阶段。这意味着在拼音输入过程中,inputText 会临时保存拼音字母,直到用户确认汉字选择后才会更新为最终文本。这一行为符合预期,因为发送操作由用户主动触发(点击发送按钮),此时组合输入已经完成。
3.2 sendText() 方法
sendText() 是文本消息发送的核心业务方法,其实现简洁而严谨:
typescript
sendText(): void {
if (this.inputText.trim() !== '') {
const msg = ChatMsg.text(this.conversationId, this.myId, this.myNickname, this.myAvatar, this.inputText.trim())
this.messages = [...this.messages, msg]
this.inputText = ''
}
}
方法的第一行 if (this.inputText.trim() !== '') 构成了空消息防护的第一道关卡。虽然发送按钮本身已经在 inputText.trim() !== '' 为真时才显示,但 sendText() 内部仍然进行了重复校验,这是防御性编程的体现------即使未来有其他入口(如键盘回车键)直接调用 sendText(),空消息也不会被发送出去。trim() 操作确保纯空格消息同样被过滤,避免了视觉上的空白消息。
消息构造通过 ChatMsg.text() 工厂方法完成,传入了五个参数:conversationId(当前对话 ID)、myId(发送者用户 ID,固定为 'me')、myNickname(发送者昵称,固定为 '我')、myAvatar(发送者头像,固定为 '😊')以及 this.inputText.trim()(去除首尾空格后的文本内容)。使用 trim() 后的文本而非原始 inputText,确保消息内容不包含无意义的首尾空白字符。
消息追加采用展开运算符语法 this.messages = [...this.messages, msg],这并非简单的数组 push 操作,而是创建了一个全新的数组引用。在 ArkUI 的响应式框架中,@State 装饰的状态变量只有在引用发生变化时才会触发 UI 更新。如果使用 this.messages.push(msg),虽然数组内容确实改变了,但由于引用未变,ArkUI 可能不会检测到这一变化,导致消息列表不刷新。展开运算符通过创建新数组确保了引用的变化,从而可靠地触发 UI 更新,这是 ArkTS 状态管理中一个关键的编码模式。
发送完成后,this.inputText = '' 清空输入框,为下一条消息的输入做好准备。由于 inputText 与 TextInput 的 text 参数双向绑定,状态变量的清空会自动反映到输入框的显示内容上。
3.3 ChatMsg.text() 构造过程
ChatMsg.text() 是 ChatMsg 类的静态工厂方法,专门用于构造文本类型的消息对象:
typescript
static text(convId: string, fromId: string, fromNick: string, fromAvatar: string, text: string): ChatMsg {
const m = new ChatMsg()
m.id = `msg_${Date.now()}`; m.conversationId = convId
m.fromUserId = fromId; m.fromNickname = fromNick; m.fromAvatar = fromAvatar
m.msgType = ChatMsgType.TEXT; m.content = text; m.timestamp = Date.now()
return m
}
工厂方法首先通过 new ChatMsg() 创建一个空的消息实例,然后逐一设置各字段值。消息 ID 采用 msg_${Date.now()} 的格式生成,利用时间戳保证了 ID 的唯一性和时序性------在单客户端场景下,同一毫秒内不会产生两条消息,因此这种 ID 生成策略在当前阶段是可靠的。conversationId 将消息绑定到特定对话,fromUserId/fromNickname/fromAvatar 三个字段记录了发送者的身份信息,msgType 被设置为 ChatMsgType.TEXT 以标识消息类型,content 存储文本正文,timestamp 记录发送时刻的 Unix 毫秒时间戳。
工厂方法模式相比直接构造的优势在于封装性------调用方无需关心 ChatMsg 内部的字段结构和赋值逻辑,只需传入业务语义明确的参数即可获得一个完整且类型正确的消息对象。不同消息类型的工厂方法(text()/voice()/image())各自的参数列表反映了该类型消息的核心属性差异,避免了使用一个通用构造函数时某些参数对特定类型无意义的问题。
3.4 消息追加与列表刷新
消息追加到 messages 数组后,ArkUI 的响应式机制会自动驱动 List 组件刷新。在 ChatPage 的消息列表实现中,ForEach(this.messages, ...) 会根据数组的最新内容重新渲染消息项。由于采用了展开运算符创建新数组,@State messages 的引用变化会被框架捕获,进而触发 ForEach 的重新执行。
新消息追加到数组末尾的位置选择符合聊天场景的自然时序------最新消息总是出现在列表底部,用户向下滚动即可看到。虽然当前实现中没有显式的自动滚动到底部逻辑(List 组件在初始加载时会默认显示首项),但在消息追加后,用户可以通过手动滚动查看最新消息。在后续迭代中,可以通过 Scroller 控制器的 scrollToEnd() 方法实现自动滚动,进一步提升用户体验。
四、语音录制完整实现
4.1 startVoiceRecord() ------ 录制启动
语音录制功能从用户点击输入栏中的麦克风按钮开始。该按钮以 🎤 Emoji 作为图标,采用圆形按钮样式(ButtonType.Circle),主题色为 #FF6B35(NearPlay 的品牌橙色),尺寸为 44×44 像素。点击事件绑定到 startVoiceRecord() 方法,触发音录制的完整流程。
startVoiceRecord() 方法的实现如下:
typescript
startVoiceRecord(): void {
this.isRecording = true
this.recordDuration = 0
this.recordTimerId = setInterval(() => {
this.recordDuration++
if (this.recordDuration >= 60) {
this.stopVoiceRecord()
}
}, 1000)
}
方法执行的第一步是将 isRecording 状态设置为 true。这一状态变化立即触发 UI 切换------在 ChatPage 的 build() 方法中,底部区域的渲染逻辑通过条件判断 if (this.isRecording) 优先显示录音状态栏(RecordingBar),替代默认的输入栏(InputBar)。这种互斥显示的设计确保了录音状态下用户不会误触文本输入,文本输入状态下录音界面也不会占据空间干扰视觉。
第二步将 recordDuration 重置为 0,为新一轮录音计时做好准备。这一重置操作是必要的,因为 recordDuration 是一个 @State 变量,其值在录音结束后不会被自动清零(仅在 stopVoiceRecord() 的正常流程中才清零),如果在启动新录音前不显式重置,可能会残留上一次的计时时长。
第三步通过 setInterval() 启动定时计时器,间隔为 1000 毫秒(即 1 秒)。每次定时器触发时,recordDuration 自增 1,实现秒级精度的录音时长统计。定时器的返回值(一个数字类型的 ID)被保存在 recordTimerId 私有变量中,这是后续清理定时器的关键句柄。在定时器回调中还嵌入了 60 秒上限的自动停止逻辑------if (this.recordDuration >= 60) 检查当前时长是否达到 60 秒上限,若达到则自动调用 stopVoiceRecord() 结束录音。这一设计防止了无限时长的录音,既保护了设备存储资源,也避免了用户忘记停止录音的尴尬情况。
4.2 stopVoiceRecord() ------ 录制停止与消息生成
stopVoiceRecord() 方法承担了录音结束后的全部处理逻辑:
typescript
stopVoiceRecord(): void {
if (this.recordTimerId !== -1) {
clearInterval(this.recordTimerId)
this.recordTimerId = -1
}
if (this.recordDuration > 0) {
const msg = ChatMsg.voice(this.conversationId, this.myId, this.myNickname, this.myAvatar, this.recordDuration)
this.messages = [...this.messages, msg]
}
this.isRecording = false
this.recordDuration = 0
}
方法的首要任务是清理定时器资源。通过 if (this.recordTimerId !== -1) 检查定时器是否仍在运行(-1 是约定的"无定时器"标记值),如果存在则调用 clearInterval(this.recordTimerId) 终止定时器,并将 recordTimerId 重置为 -1。这一清理操作至关重要------如果忘记清理定时器,即使录音已经停止,定时器回调仍会持续执行,导致 recordDuration 不断自增,不仅浪费计算资源,还可能引发意外的状态混乱。
定时器清理后,方法检查 recordDuration > 0 来判断是否生成语音消息。这一判断构成了录音时长为 0 时的自动取消机制------如果用户在录音开始后立即停止(时长为 0 秒),则不生成消息,录音被静默取消。只有在录音时长大于 0 秒时,才会通过 ChatMsg.voice() 工厂方法构造语音消息并追加到消息列表。注意,语音消息的 content 字段为空字符串,语音的核心数据是 duration 字段(录音时长秒数),这与文本消息用 content 存储正文、图片消息用 content 存储 URI 的设计形成了差异化分工。
最后,方法将 isRecording 重置为 false,触发 UI 从录音状态栏切换回输入栏;将 recordDuration 重置为 0,为下一次录音做好准备。这两个重置操作的顺序是合理的------先确保消息已生成,再恢复 UI 状态,避免状态重置过程中出现短暂的不一致。
4.3 60 秒上限机制
语音录制的 60 秒上限是一个重要的用户体验约束。在定时器的每秒回调中,recordDuration 自增后立即检查是否达到 60。一旦达到,自动调用 stopVoiceRecord(),实现录音的无感强制结束。这一机制的选择基于以下考量:
第一,60 秒是即时通讯场景中语音消息的常见时长上限,微信、QQ 等主流应用均采用类似限制,用户对此已有预期。第二,过长的语音消息不利于接收方快速获取信息,60 秒上限引导发送方精炼表达。第三,在当前 Demo 阶段,语音录制并未真正调用系统录音 API,定时器仅模拟了时长统计;未来接入真实录音后,60 秒上限可以配合音频文件大小控制,避免单条消息占用过多存储和带宽。
上限触发时,stopVoiceRecord() 被调用后消息自动生成并发送,用户无需额外操作。录音状态栏上的计时显示 ${this.recordDuration}s / 60s 让用户随时了解剩余可用时长,当进度接近 60 秒时,用户可以预判即将自动发送,避免措手不及。
4.4 recordTimerId 生命周期管理
recordTimerId 是 ChatPage 中唯一声明为 private(非 @State)的变量,其生命周期管理体现了对资源清理的重视。recordTimerId 初始值为 -1,表示无活跃定时器;录音开始时被赋值为 setInterval 的返回值;录音停止时通过 clearInterval 清理并重置为 -1。
除了在 stopVoiceRecord() 中清理外,recordTimerId 还在两个关键位置被处理:
一是在录音取消操作中。录音状态栏的"取消"按钮回调中包含了相同的清理逻辑:if (this.recordTimerId !== -1) { clearInterval(this.recordTimerId); this.recordTimerId = -1 },同时将 isRecording 和 recordDuration 重置。取消操作与停止操作的区别在于:取消不生成消息,停止会生成消息。
二是在 aboutToDisappear() 生命周期回调中:
typescript
aboutToDisappear(): void {
if (this.recordTimerId !== -1) {
clearInterval(this.recordTimerId)
}
}
当用户在录音过程中退出 ChatPage(如点击返回按钮),页面组件即将被销毁,aboutToDisappear 被触发。此时如果定时器仍在运行,必须在组件销毁前将其清理,否则定时器回调将引用一个已不存在的组件实例,导致未定义行为或内存泄漏。这一清理逻辑是资源生命周期管理的基本要求,也是健壮组件实现的标志。
4.5 录音状态 UI(🎙 录音中 Ns)
录音状态通过 RecordingBar Builder 构建,替代默认的 InputBar 显示在底部区域:
typescript
@Builder
RecordingBar() {
Column() {
Text('🎙 正在录音...')
.fontSize(16)
.fontColor('#F44336')
.fontWeight(FontWeight.Medium)
Text(`${this.recordDuration}s / 60s`)
.fontSize(13)
.fontColor('#999999')
.margin({ top: 4 })
Row() {
Button('取消')
...
.onClick(() => {
this.isRecording = false
this.recordDuration = 0
if (this.recordTimerId !== -1) {
clearInterval(this.recordTimerId)
this.recordTimerId = -1
}
})
Button('发送')
...
.onClick(() => { this.stopVoiceRecord() })
}
.margin({ top: 8 })
}
.width('100%')
.padding({ top: 12, bottom: 12 })
.backgroundColor('#FFF3E0')
.alignItems(HorizontalAlign.Center)
}
录音状态栏的视觉设计采用了醒目的暖橙色背景(#FFF3E0),与默认输入栏的白色背景形成鲜明对比,向用户传递"正在录音"的强烈状态信号。标题行"🎙 正在录音..."使用麦克风 Emoji 配合文字,以红色字体(#F44336)显示,进一步强调录音状态的活跃性。计时行 ${this.recordDuration}s / 60s 以灰色小字显示当前时长和总上限,让用户实时掌握录音进度。
操作区提供"取消"和"发送"两个按钮。"取消"按钮以浅灰色背景表示辅助操作,点击后执行录音取消逻辑(清理定时器、重置状态、不生成消息)。"发送"按钮以品牌橙色表示主要操作,点击后调用 stopVoiceRecord() 完成录音并发送语音消息。两个按钮的视觉权重差异引导用户优先选择"发送",符合主操作优先的设计原则。
整个 RecordingBar 居中对齐(alignItems(HorizontalAlign.Center)),所有内容沿中轴线排列,营造出录音界面的专注感和仪式感,与微信等主流聊天应用的录音状态设计风格一致。
五、图片选择实现
5.1 PhotoViewPicker 概述与 deprecated 状态
图片选择功能通过 HarmonyOS 的 picker.PhotoViewPicker API 实现。该 API 位于 @kit.CoreFileKit 模块中,与 fileIo 一起被导入:import { picker, fileIo as fs } from '@kit.CoreFileKit'。
需要特别指出的是,PhotoViewPicker 及其相关 API 在当前的 HarmonyOS SDK 版本中已被标记为 deprecated(废弃)。具体而言,以下 API 均处于 deprecated 状态:picker.PhotoViewPicker 类本身、picker.PhotoSelectOptions 选项类、picker.PhotoViewMIMETypes MIME 类型常量枚举、PhotoSelectOptions 的 MIMEType 属性、PhotoSelectOptions 的 maxSelectNumber 属性、PhotoViewPicker 的 select() 方法、以及 PhotoSelectResult 的 photoUris 属性。这些 API 虽然标记为废弃,但在当前版本仍然可用,功能完整且稳定。
5.2 new picker.PhotoSelectOptions() ------ 选项构造
图片选择的第一步是构造选择选项对象:
typescript
const options = new picker.PhotoSelectOptions()
PhotoSelectOptions 是一个配置类,用于描述图片选择器的行为参数。通过 new 运算符创建实例后,逐属性设置选择参数。这里使用 new 而非工厂方法,是因为 PhotoSelectOptions 的设计允许渐进式配置------先创建默认实例,再按需修改特定属性。
MIMEType 配置
typescript
options.MIMEType = picker.PhotoViewMIMETypes.IMAGE_TYPE
MIMEType 属性决定了选择器展示的文件类型范围。picker.PhotoViewMIMETypes.IMAGE_TYPE 是一个预定义常量,表示仅展示图片类型的文件(对应 MIME 类型 image/*)。这一配置将视频文件、文档等其他媒体类型排除在选择范围之外,简化了用户的选择操作,避免了误选非图片文件的情况。如果未来需要支持视频消息,可以将 MIMEType 改为 picker.PhotoViewMIMETypes.IMAGE_VIDEO_TYPE,同时展示图片和视频。
maxSelectNumber 配置
typescript
options.maxSelectNumber = 1
maxSelectNumber 限制了用户一次可选择的最大文件数量。在当前实现中,设为 1 表示每次只能选择一张图片。这一限制基于两个考量:一是即时通讯场景中,逐张发送图片是更常见的用户习惯,批量发送反而容易造成信息过载;二是单张选择简化了消息构造逻辑------选择完成后直接取 result.photoUris[0] 即可,无需处理多张图片的循环发送。如果未来需要支持多图发送,将 maxSelectNumber 增大并配合循环发送逻辑即可扩展。
5.3 select() Promise 与 photoUris 获取
选项配置完成后,创建选择器实例并启动选择流程:
typescript
const photoPicker = new picker.PhotoViewPicker()
photoPicker.select(options).then((result: picker.PhotoSelectResult) => {
if (result.photoUris.length > 0) {
const msg = ChatMsg.image(this.conversationId, this.myId, this.myNickname, this.myAvatar, result.photoUris[0])
this.messages = [...this.messages, msg]
}
this.showMorePanel = false
})
new picker.PhotoViewPicker() 创建选择器实例,无需额外参数。select(options) 方法启动系统级图片选择界面,返回一个 Promise<picker.PhotoSelectResult>。选择界面完全由 HarmonyOS 系统渲染,应用无需关心其内部实现,只需处理选择结果即可。
Promise 的 then 回调接收 PhotoSelectResult 类型的结果对象,其中 photoUris 属性是一个字符串数组,包含了用户选择的所有文件的 URI。由于 maxSelectNumber 设为 1,photoUris 数组最多包含一个元素。通过 result.photoUris.length > 0 检查用户是否确实选择了图片(而非取消选择),若选择了则取第一个 URI result.photoUris[0],通过 ChatMsg.image() 构造图片消息并追加到消息列表。
图片消息的构造方式与文本消息和语音消息有所不同:ChatMsg.image() 的最后一个参数是图片的 URI 字符串,该 URI 被存入 ChatMsg 的 content 字段。在消息气泡渲染时,content 被直接传给 Image(msg.content) 组件作为图片源。HarmonyOS 的 Image 组件支持多种 URI 格式,包括本地文件路径、content:// URI、以及网络 URL,因此 PhotoViewPicker 返回的 URI 可以直接用于图片渲染。
无论用户是否选择了图片,then 回调都会将 showMorePanel 设为 false,关闭更多功能面板,让界面恢复到输入状态。
5.4 deprecated API 兼容策略
面对 PhotoViewPicker 系列 API 的 deprecated 状态,NearPlay 采用了"当前可用、后续迁移"的兼容策略:
为什么仍然使用 deprecated API?
第一,HarmonyOS 的 deprecated API 在当前版本仍然完全可用,功能无任何降级。PhotoViewPicker 的选择能力、PhotoSelectOptions 的配置能力、photoUris 的结果返回能力均与 deprecated 标注前一致。第二,替代 API(如 picker.PhotoViewPicker 的新版替代方案)在当前 SDK 版本中可能尚未完全就绪或文档不够完善,贸然迁移可能引入兼容性风险。第三,NearPlay 当前处于快速迭代阶段,功能完整性和开发效率优先于 API 的前沿性,使用经过验证的 deprecated API 是更务实的选择。
迁移准备与代码组织
为了降低未来迁移成本,图片选择逻辑被封装在独立的 pickImage() 方法中,与 UI 构建逻辑解耦。迁移时只需修改 pickImage() 内部实现,将其替换为新版 API 调用,而方法的签名、调用方(MorePanel 中的点击事件)和消息构造逻辑(ChatMsg.image())均无需变动。这种封装隔离的策略使得 deprecated API 的影响范围被限制在最小边界内。
错误处理的防御性
pickImage() 方法外层包裹了 try-catch 块:
typescript
pickImage(): void {
try {
...
} catch (e) {
this.showMorePanel = false
}
}
catch 块中的处理逻辑仅为关闭更多功能面板,不进行额外的错误提示。这一设计基于两个考量:一是图片选择器在正常使用场景下极少抛出异常,catch 是针对权限拒绝、存储不可用等极端情况的安全兜底;二是图片选择失败不构成阻断性错误,用户可以重试或改用其他消息类型,无需额外的错误弹窗干扰聊天流程。
5.5 更多功能面板的交互设计
图片选择的入口是底部输入栏的 "+" 按钮,点击后展开 MorePanel 面板。面板中包含两个功能项:📷 相册和 📍 位置。相册项绑定到 pickImage() 方法,位置项为预留占位。
面板的显隐通过 showMorePanel 状态变量控制,"+" 按钮的点击事件执行 this.showMorePanel = !this.showMorePanel 实现切换。面板显示有两个条件约束:if (this.showMorePanel && !this.isBlocked),即面板只有在未被拉黑时才可见------拉黑状态下功能面板无意义,因为用户无法发送任何类型的消息。
面板的视觉设计采用水平排列的功能卡片,每个卡片为 60×60 的圆角白色方块,内含 Emoji 图标和文字标签。这种网格化的功能面板布局是聊天应用中"更多功能"区域的主流设计模式,用户对这种交互方式有成熟的认知和使用习惯。
六、消息气泡 UI 设计
6.1 三种消息样式概述
消息气泡是聊天界面中最核心的视觉元素,它承载了消息内容的展示和消息来源的区分。ChatPage 实现了三种消息气泡样式,分别对应 ChatMsgType 枚举的三种消息类型:TEXT(文本)、VOICE(语音)、IMAGE(图片)。每种类型在气泡内部有不同的内容组件和布局逻辑,但共享相同的气泡容器结构和对齐方式。
气泡的区分逻辑通过 if-else 链实现:if (msg.msgType === ChatMsgType.TEXT) 渲染文本气泡,else if (msg.msgType === ChatMsgType.VOICE) 渲染语音气泡,else 渲染图片气泡。最后的 else 分支隐式匹配 ChatMsgType.IMAGE,这种写法依赖于枚举值的完整性------如果未来新增消息类型,需要在 else 前插入新的 else if 分支。
6.2 左右布局------自己右侧、对方左侧
消息气泡的左右对齐是区分消息发送方向的核心视觉手段。ChatPage 通过两个独立的 Builder 实现了方向区分:MyMessageBubble(自己发送的消息,右侧对齐)和 OtherMessageBubble(对方发送的消息,左侧对齐)。在消息列表的 ForEach 中,根据 msg.fromUserId === this.myId 判断消息来源,分别调用对应的 Builder。
MyMessageBubble ------ 右侧对齐
typescript
@Builder
MyMessageBubble(msg: ChatMsg) {
Row() {
Blank()
// 消息内容
Text(this.myAvatar).fontSize(28).margin({ left: 8 })
}
.width('100%')
.padding({ top: 4, bottom: 4 })
}
自己的消息气泡使用 Row 作为容器,最左侧放置 Blank() 组件。Blank 是 ArkUI 中的弹性空白组件,会占据 Row 中所有剩余空间,将后面的消息内容和头像推到右侧。这种 Blank + 内容 + 头像 的布局结构实现了消息气泡的右对齐效果,同时头像固定在最右侧,形成统一的视觉锚点。
OtherMessageBubble ------ 左侧对齐
typescript
@Builder
OtherMessageBubble(msg: ChatMsg) {
Row() {
Text(msg.fromAvatar !== '' ? msg.fromAvatar : this.targetAvatar)
.fontSize(28).margin({ right: 8 })
// 消息内容
Blank()
}
.width('100%')
.padding({ top: 4, bottom: 4 })
}
对方的消息气泡采用镜像布局:头像 + 内容 + Blank()。头像在最左侧,消息内容紧随其后,Blank() 将剩余空间填充,使内容靠左对齐。
对方头像的显示逻辑值得注意:msg.fromAvatar !== '' ? msg.fromAvatar : this.targetAvatar。这一三元表达式优先使用消息对象自带的 fromAvatar 字段,如果该字段为空字符串则回退到页面级的 targetAvatar。这种双重保障确保了即使在消息数据不完整的情况下,头像仍然能够正常显示。
6.3 文本消息气泡

文本消息是最基础的消息类型,其气泡实现也最为简洁:
自己发送的文本:
typescript
Text(msg.content)
.fontSize(15)
.padding(10)
.backgroundColor('#DCF8C6')
.borderRadius(12)
.fontColor('#333333')
.maxLines(10)
对方发送的文本:
typescript
Text(msg.content)
.fontSize(15)
.padding(10)
.backgroundColor(Color.White)
.borderRadius(12)
.fontColor('#333333')
.maxLines(10)
两者的唯一差异在于背景色:自己发送的消息使用浅绿色(#DCF8C6),对方发送的消息使用白色(Color.White)。这一色彩区分沿用了 WhatsApp 经典的聊天气泡配色方案------绿色代表主动发出的消息,白色代表接收到的消息,直观且被广泛认知。
文本气泡的样式参数经过精心调校:fontSize(15) 保证了中英文混排时的可读性;padding(10) 提供了文本与气泡边缘的适当间距;borderRadius(12) 赋予了柔和的圆角效果;fontColor('#333333') 使用深灰色而非纯黑色,降低了文字与背景的对比度,提升了长时间阅读的舒适度;maxLines(10) 限制了单条消息的最大显示行数,防止超长文本撑爆气泡布局,超出部分将被截断。
6.4 语音消息气泡

语音消息的气泡设计与文本消息有本质区别------它不展示文本内容,而是展示语音图标和时长信息:
typescript
Row() {
Text('🔊')
.fontSize(16)
Text(`${msg.duration}"`)
.fontSize(13)
.fontColor('#333333')
}
.padding({ left: 14, right: 14, top: 10, bottom: 10 })
.backgroundColor('#DCF8C6') // 或 Color.White
.borderRadius(12)
语音气泡内部是一个 Row 容器,包含两个 Text 组件:🔊 扬声器 Emoji 作为语音类型的视觉标识,${msg.duration}" 显示录音时长(单位为秒," 是秒的缩写符号)。两个元素水平排列,间距由容器的 padding 统一控制。
语音气泡的宽度是自适应的,由内容撑开而非铺满------这与文本气泡的自适应宽度行为一致。由于语音气泡的内容(🔊 + 时长文字)通常比文本消息短,所以语音气泡视觉上显得更加紧凑,这也是即时通讯应用中语音消息气泡的常见呈现方式。
duration 字段的值来自 ChatMsg.voice() 工厂方法,对应 stopVoiceRecord() 中传入的 this.recordDuration(录音时长秒数)。在 MockChatData 的示例数据中,可以看到 ChatMsg.voice(convId, 'u2', '阿花', '👧', 3) 构造了一条 3 秒的语音消息,在气泡中会显示为 🔊 3"。
6.5 图片消息渲染
图片消息的气泡设计与文本和语音完全不同,它使用 Image 组件替代 Text 组件:
typescript
Image(msg.content)
.width(120)
.height(120)
.borderRadius(8)
.objectFit(ImageFit.Cover)
图片气泡以固定尺寸 120×120 像素展示,不随图片实际尺寸变化。这一设计基于以下考量:一是聊天界面中图片缩略图需要统一的视觉节奏,固定尺寸避免了大小不一的图片破坏列表布局;二是 120×120 的尺寸在移动设备上既能展示图片的关键内容,又不会过度占据屏幕空间;三是 ImageFit.Cover 的裁剪模式保证了图片在固定尺寸内完整填充,不会出现拉伸变形。
borderRadius(8) 为图片赋予了圆角效果,与文本/语音气泡的圆角风格保持一致,但圆角半径更小(8 vs 12),这是因为图片的视觉面积更大,较小的圆角更符合大面积圆角的视觉美学。
图片的源 URI 存储在 msg.content 字段中,由 ChatMsg.image() 工厂方法设置。在真实场景中,该 URI 可能是 PhotoViewPicker 返回的 content:// 格式 URI 或 file:// 本地路径;在 MockChatData 中使用 internal://cache/test.jpg 作为测试 URI。
图片气泡没有背景色设置,因为图片本身覆盖了整个气泡区域,背景色不可见。这与文本/语音气泡需要背景色填充空白的逻辑不同------图片的视觉内容本身就是气泡的"背景"。
七、拉黑对话框
7.1 ⋯ 菜单入口
拉黑功能的入口位于 ChatPage 顶部导航栏的最右侧。当用户未被拉黑时,导航栏右侧显示 "⋯" 省略号图标,字体大小 22,灰色(#666666),右侧间距 8。点击事件触发 this.showBlockConfirm = true,将拉黑确认对话框的显示状态设为真。
"⋯" 是一种常见的"更多操作"视觉隐喻,用户普遍理解点击后会弹出操作菜单或对话框。在 ChatPage 中,当前版本仅提供了拉黑这一项操作,因此直接弹出确认对话框而非操作菜单,减少了交互层级。如果未来需要增加更多操作(如"查看资料"、"清空聊天记录"等),可以将 "⋯" 的点击行为改为展开操作列表。
7.2 AlertDialog 确认流程
拉黑确认对话框通过 BlockConfirmDialog Builder 构建,采用自定义全屏遮罩设计:
typescript
@Builder
BlockConfirmDialog() {
Column() {
Column() {
Text('确认拉黑')
.fontSize(18)
.fontWeight(FontWeight.Bold)
Text(`确定要拉黑 ${this.targetName} 吗?`)
.fontSize(14)
.fontColor('#666666')
.margin({ top: 12 })
Text('拉黑后将无法收到对方的消息和通知')
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
Row() {
Button('取消')
...
.onClick(() => { this.showBlockConfirm = false })
Button('确认拉黑')
...
.onClick(() => { this.doBlockUser() })
}
.margin({ top: 20 })
}
.padding(24)
.backgroundColor(Color.White)
.borderRadius(16)
.width('85%')
}
.width('100%')
.height('100%')
.position({ x: 0, y: 0 })
.backgroundColor('rgba(0,0,0,0.5)')
.justifyContent(FlexAlign.Center)
.alignItems(HorizontalAlign.Center)
}
对话框的视觉结构分为两层:外层全屏遮罩和内层白色卡片。外层 Column 通过 position({ x: 0, y: 0 }) 绝对定位覆盖整个页面,backgroundColor('rgba(0,0,0,0.5)') 提供了半透明黑色遮罩,阻断了用户与背景界面的交互,同时传达出"模态对话框"的视觉层级。justifyContent(FlexAlign.Center) 和 alignItems(HorizontalAlign.Center) 将内层卡片居中显示。
内层白色卡片宽度为页面的 85%,圆角 16,内边距 24,承载了对话框的全部内容。标题"确认拉黑"以 18px 粗体字显示,明确传达操作的性质。副标题 确定要拉黑 ${this.targetName} 吗? 引入了具体的用户昵称,使确认信息个性化,降低误操作概率。辅助说明"拉黑后将无法收到对方的消息和通知"以更小的灰色字体显示,告知拉黑的后果,确保用户在知情的情况下做出决定。
操作区包含"取消"和"确认拉黑"两个按钮,各占 layoutWeight(1) 的等宽空间。"取消"按钮灰色背景,点击后仅关闭对话框(this.showBlockConfirm = false),不执行任何副作用操作。"确认拉黑"按钮红色背景(#F44336),视觉上突出且警示性强,点击后调用 doBlockUser() 执行拉黑操作。红色按钮的视觉权重和警示色彩引导用户谨慎操作,符合破坏性操作应使用警示色界面的设计准则。
7.3 blockUser 调用
doBlockUser() 方法封装了拉黑操作的完整流程:
typescript
doBlockUser(): void {
const blocked = BlockedUser.of(this.targetId, this.targetName, this.targetAvatar)
blockUser(blocked)
this.isBlocked = true
this.showBlockConfirm = false
}
首先通过 BlockedUser.of() 工厂方法构造黑名单用户记录,包含 ID、昵称、头像和拉黑时间戳。然后调用 blockUser(blocked) 将记录写入黑名单数据源。blockUser() 函数在写入前会通过 isUserBlocked() 检查是否已存在,避免重复拉黑。
写入完成后,将 isBlocked 状态设为 true,这一变化立即影响 UI 的多个区域:导航栏中 "⋯" 菜单被替换为"取消拉黑"按钮;底部区域从输入栏切换为拉黑提示条;更多功能面板不再显示。最后关闭确认对话框。
7.4 "已拉黑,无法发送消息"提示条
拉黑状态下的底部区域由 BlockedBar Builder 渲染:
typescript
@Builder
BlockedBar() {
Row() {
Text('🚫 你已拉黑对方,无法发送消息')
.fontSize(14)
.fontColor('#999999')
.layoutWeight(1)
Button('取消拉黑')
.fontSize(13)
.height(32)
.type(ButtonType.Capsule)
.backgroundColor('#FF6B35')
.fontColor(Color.White)
.onClick(() => { this.doUnblockUser() })
}
.width('100%')
.padding({ left: 16, right: 16, top: 10, bottom: 10 })
.backgroundColor(Color.White)
}
提示条采用白色背景的 Row 布局,左侧是 🚫 禁止符号配合"你已拉黑对方,无法发送消息"的提示文字,灰色字体传达信息但不强调。右侧是品牌橙色的"取消拉黑"按钮,为用户提供了便捷的反悔入口。
BlockedBar 的显示优先级介于录音状态栏和输入栏之间。在 ChatPage 的 build() 方法中,底部区域的渲染逻辑为:
typescript
if (this.isRecording) {
this.RecordingBar()
} else if (this.isBlocked) {
this.BlockedBar()
} else {
this.InputBar()
}
录音状态优先级最高(即使被拉黑,录音取消操作仍需可见),拉黑状态次之,正常输入状态最低。这种优先级排列确保了每种状态下用户都有明确的界面引导,不会出现空白或混乱的底部区域。
7.5 isUserBlocked 实时检查
isUserBlocked() 函数在 ChatPage 的两个关键时机被调用:
一是在 aboutToAppear() 中:this.isBlocked = isUserBlocked(this.targetId),页面初始化时从黑名单数据源查询当前聊天对象是否已被拉黑,将结果缓存到 isBlocked 状态变量。这一初始化检查确保了从其他页面(如通讯录页面拉黑后跳转到聊天页面)进入 ChatPage 时,拉黑状态能被正确恢复。
二是在消息发送前:虽然当前 sendText()、stopVoiceRecord()、pickImage() 方法中没有显式调用 isUserBlocked() 进行拦截,但 UI 层面的互斥显示已经实现了等效的拦截效果------拉黑状态下输入栏被替换为 BlockedBar,录音按钮和更多功能面板均不可见,用户根本无法触发发送操作。这种"UI 层面不可达"的拦截策略比代码层面的 if (this.isBlocked) return 更加优雅,因为它消除了用户尝试发送的操作入口,而非让用户点击后再告知不可发送。
如果未来需要增加非 UI 入口的消息发送通道(如通知点击直接发送快捷回复),则需要在消息发送方法内部补充 isBlocked 的显式校验,形成 UI 拦截和逻辑拦截的双重防线。
八、消息列表
8.1 List + ForEach 实现
消息列表是 ChatPage 的主体内容区域,采用 List + ForEach 的经典组合实现:
typescript
List({ space: 8 }) {
ForEach(this.messages, (msg: ChatMsg) => {
ListItem() {
if (msg.fromUserId === this.myId) {
this.MyMessageBubble(msg)
} else {
this.OtherMessageBubble(msg)
}
}
}, (msg: ChatMsg, idx: number) => `${msg.id}_${idx}`)
}
.width('100%')
.layoutWeight(1)
.padding({ left: 12, right: 12, top: 8 })
List 组件是 ArkUI 提供的高性能列表容器,支持按需渲染和滚动优化。space: 8 参数设置了列表项之间的间距为 8 像素,为消息气泡之间提供了适当的视觉呼吸空间。
ForEach 是 ArkUI 的响应式迭代组件,接收三个参数:数据源(this.messages)、item 渲染函数、key 生成函数。数据源是 @State 修饰的 messages 数组,任何引用变化都会触发 ForEach 的重新渲染。item 渲染函数为每条消息创建一个 ListItem,根据消息来源调用不同的气泡 Builder。key 生成函数 (msg: ChatMsg, idx: number) => msg.id_${idx} 为每条消息生成唯一标识,帮助框架识别列表项的增删变化,优化 diff 计算和渲染性能。
消息来源的判断通过 msg.fromUserId === this.myId 实现。myId 在当前实现中固定为字符串 'me',这一简单约定在单用户 Demo 场景下足够使用。在真实应用中,myId 应从用户登录状态中获取。
List 的 layoutWeight(1) 使其占据导航栏和底部区域之间的全部剩余空间,确保消息列表拥有最大的可视面积。水平 padding 为 12,为气泡与屏幕边缘留出了安全间距,避免气泡紧贴屏幕边缘的视觉不适。
8.2 自动滚动到底部
在当前实现中,消息列表没有显式的自动滚动到底部逻辑。List 组件默认从顶部开始渲染,新消息追加后列表不会自动滚动。
实现自动滚动需要引入 Scroller 控制器。具体步骤为:在组件中声明 private scroller: ListScroller = new ListScroller()(或使用 Scroller 类型);将 List 的 scroller 参数绑定到该控制器;在消息追加后调用 this.scroller.scrollToEnd() 滚动到底部。
自动滚动的时机需要精心选择。对于发送消息(自己追加的消息),应立即滚动到底部;对于接收消息(对方追加的消息),如果用户当前正在浏览历史记录(未在底部),可以选择不自动滚动,避免打断用户的浏览上下文,同时显示"新消息"提示条引导用户手动滚动到底部。这种差异化滚动策略在主流聊天应用中已被广泛采用。
8.3 消息 ID 生成策略
消息 ID 的生成采用 msg_${Date.now()} 格式,在 ChatMsg 的三个工厂方法中统一使用:
typescript
m.id = `msg_${Date.now()}`
Date.now() 返回自 1970 年 1 月 1 日 00:00:00 UTC 以来的毫秒数,在单客户端场景下具有足够的唯一性。msg_ 前缀为 ID 提供了类型辨识性------通过前缀即可判断这是一个消息 ID,而非用户 ID 或对话 ID。
这种 ID 生成策略的优势在于简单性和时序性。简单性体现在无需引入额外的 ID 生成器或随机数算法,一行代码即可完成。时序性体现在时间戳的单调递增特性------后生成的消息 ID 的数值部分必然大于先生成的消息 ID,这为消息的排序提供了天然的依据。
局限性在于同一毫秒内可能生成重复 ID(虽然概率极低),以及多设备场景下的 ID 冲突风险。在单设备 Demo 阶段,这些局限性不构成实际问题。未来在多端同步场景中,可以升级为 msg_${userId}_${Date.now()}_${randomSuffix} 格式,通过用户 ID 前缀和随机后缀消除冲突可能性。
在 ForEach 的 key 生成函数中,ID 与索引组合使用:${msg.id}_${idx}。索引 idx 的加入是为了应对极端情况------如果同一毫秒内生成了两条消息(ID 相同),索引的差异仍然能保证 key 的唯一性,避免 ForEach 的渲染异常。
九、未读消息处理
9.1 unreadCount 标记
未读消息的处理在 NearPlay 中分为两个层面:会话列表层面和聊天页面层面。
在会话列表层面,ChatConversation 模型中的 unreadCount 字段记录了每个对话的未读消息数量。在 MockChatData 的示例数据中,可以看到不同对话具有不同的未读计数:'阿花'对话有 2 条未读,'狼人杀房间'对话有 5 条未读,'周末狼人杀聚会'对话有 1 条未读,而'大壮'对话的未读数为 0。这些未读计数在会话列表页中以红点或数字角标的形式展示,引导用户优先查看有新消息的对话。
在 ChatPage 层面,当前实现没有对未读消息进行独立的视觉标记(如未读分割线或"以下为新消息"提示),而是将历史消息和新消息统一在同一个列表中展示。当用户从会话列表进入 ChatPage 时,aboutToAppear() 加载该对话的全部消息(包括已读和未读),用户通过滚动浏览所有消息。
未来可以增强的未读处理功能包括:一是在消息列表中插入"未读消息分割线",将已读和未读消息视觉区分,分割线标注"N 条新消息";二是在页面加载后自动滚动到第一条未读消息位置,而非列表顶部或底部;三是在用户退出 ChatPage 时,将当前已浏览的消息位置或时间戳上报,更新 unreadCount 计数,实现未读状态的实时同步。
十、边界情况
10.1 空消息防止
空消息是指内容为空或仅包含空白字符的消息,这类消息在即时通讯中没有信息价值,发送后只会增加噪音。ChatPage 通过多层防护机制确保空消息不会被发送:
第一层防护在 UI 层面。发送按钮的显示条件为 this.inputText.trim() !== '',当输入框为空或仅含空格时,发送按钮不可见,用户无法通过点击触发 sendText()。这一防护是最直观的------没有按钮就没有操作入口。
第二层防护在逻辑层面。sendText() 方法内部的第一行 if (this.inputText.trim() !== '') 再次校验输入内容,即使通过非 UI 途径(如未来接入的键盘回车发送)调用了 sendText(),空消息也会被拦截。trim() 操作确保了纯空格消息同样被过滤。
对于语音消息和图片消息,空消息的防护机制有所不同。语音消息的空消息防护是时长为 0 秒------stopVoiceRecord() 中 if (this.recordDuration > 0) 确保只有时长大于 0 的录音才生成消息,时长为 0 的录音被静默取消。图片消息的空消息防护是选择结果为空------pickImage() 中 if (result.photoUris.length > 0) 确保只有用户实际选择了图片才生成消息,用户取消选择时不会生成空图片消息。
10.2 录音 0 秒取消
录音 0 秒取消是一种特殊的边界情况:用户点击麦克风按钮启动录音后,立即点击"发送"按钮或触发自动停止。此时 recordDuration 仍为 0(因为 setInterval 的 1 秒间隔尚未触发回调),stopVoiceRecord() 中的 if (this.recordDuration > 0) 条件不满足,消息不会生成。
录音状态栏的"取消"按钮也处理了这一情况。取消按钮的回调直接将 isRecording 和 recordDuration 重置,并清理定时器,不经过消息生成逻辑。无论录音时长是多少,取消操作都不会生成消息。
这种"0 秒不生成消息"的设计符合用户心理预期------极短的录音通常意味着误触或改变主意,静默取消比生成一条空语音消息更合理。同时,计时器显示 ${this.recordDuration}s / 60s 让用户始终知道当前录音时长,当显示为 "0s" 时,用户可以选择继续录音或取消,拥有完全的控制权。
10.3 图片选择取消
图片选择取消发生在用户打开 PhotoViewPicker 后不选择任何图片直接返回的情况。此时 select() 返回的 Promise 仍然会 resolve(而非 reject),但 result.photoUris 为空数组。ChatPage 通过 if (result.photoUris.length > 0) 检查数组长度,为空则跳过消息生成逻辑。
无论用户是否选择了图片,then 回调都会执行 this.showMorePanel = false,关闭更多功能面板。这确保了取消选择后界面能正确恢复到输入状态,不会出现面板卡在打开状态的异常。
此外,如果 PhotoViewPicker 在选择过程中抛出异常(如权限被拒绝),catch 块也会执行 this.showMorePanel = false,同样恢复界面状态。异常情况下不生成消息,也不显示错误提示------图片选择失败是可恢复的非致命错误,用户可以重试。
10.4 拉黑后消息发送拦截
拉黑后的消息发送拦截是 ChatPage 安全机制的核心。拦截在多个层面同时生效:
UI 层面拦截 :拉黑后底部输入栏被 BlockedBar 替代,文本输入框、麦克风按钮和"+"按钮均不可见,用户无法发起任何类型的消息输入操作。更多功能面板的显示条件 if (this.showMorePanel && !this.isBlocked) 也排除了拉黑状态。录音状态栏的优先级虽然高于 BlockedBar(if (this.isRecording) 排在 else if (this.isBlocked) 之前),但由于拉黑后无法点击麦克风按钮启动录音,isRecording 不可能为 true,所以这一优先级不会造成漏洞。
逻辑层面拦截 :虽然当前发送方法中没有显式的 isBlocked 校验,但 UI 层面的拦截已经形成了完整的闭环。为了防御未来可能的非 UI 发送通道,建议在 sendText()、stopVoiceRecord()、pickImage() 方法入口处补充 if (this.isBlocked) return 的逻辑层拦截,形成双重保障。
拉黑状态同步 :isBlocked 状态在两个时机被更新------aboutToAppear() 中的初始化查询和 doBlockUser()/doUnblockUser() 中的操作后更新。由于拉黑操作在同一页面内完成,状态更新是即时的,不存在跨页面延迟导致的拦截窗口。如果未来支持从其他页面(如用户资料页)拉黑同一用户,则需要引入状态订阅或事件通知机制,确保 ChatPage 的 isBlocked 状态能实时响应外部拉黑操作的变化。
取消拉黑后的恢复 :doUnblockUser() 调用 unblockUser(this.targetId) 从黑名单中移除该用户,并将 isBlocked 重置为 false。UI 立即恢复为正常状态------导航栏右侧恢复 "⋯" 菜单,底部恢复输入栏,功能面板恢复可用。取消拉黑不会恢复拉黑期间可能错过的消息(当前阶段消息由 MockChatData 提供,不涉及真实消息投递),未来在接入真实消息推送后,取消拉黑后可以拉取拉黑期间的历史消息进行补偿展示。
10.5 其他边界情况
页面退出时的录音状态 :如果用户在录音过程中点击返回按钮退出 ChatPage,aboutToDisappear() 生命周期回调会清理定时器资源,但不会生成语音消息。这与"取消录音"的效果一致------页面退出等同于放弃当前录音。定时器清理确保了不会出现组件已销毁但定时器仍在执行的内存泄漏问题。
并发状态隔离 :ChatPage 的状态变量设计避免了并发冲突。录音状态(isRecording)、拉黑状态(isBlocked)、面板状态(showMorePanel)三个状态通过条件渲染的优先级链实现互斥,不存在两个状态同时控制同一区域的情况。即使 showMorePanel 为 true 且 isBlocked 也为 true,面板也不会显示(条件中 !this.isBlocked 阻断了渲染),避免了状态冲突导致的 UI 异常。
消息列表为空 :当 messages 数组为空时,List 组件不会渲染任何 ListItem,页面中央区域为空白。这种空状态在当前阶段没有特殊处理(如显示"暂无消息,打个招呼吧"的占位提示),因为 MockChatData 总是提供示例消息。未来接入真实数据源后,空状态提示是必要的用户体验优化。
网络异常与重试 :当前实现中所有消息操作均为本地操作,不涉及网络请求,因此不存在网络异常场景。未来接入消息收发服务端后,需要在消息发送失败时提供重试机制(如消息气泡上显示红色感叹号,点击后重新发送),并在网络恢复后自动重试待发送消息队列。消息的发送状态(发送中、已发送、已读、发送失败)也需要作为 ChatMsg 的扩展字段进行持久化管理。