IM即时聊天场景中,在发送多媒体消息(图片/音频/视频等)时,如果选中了多个多媒体文件一起发送,如何更快地将消息发送出去,且消息顺序和多媒体文件选择顺序保持一致?
简单的实现并不难,主要是如何尽可能地优化性能和用户体验。
分享一下个人的实现及后续优化的过程。
(以图片为例)
一、简单实现
实现逻辑:
- 1、拿到所有图片文件的file对象列表
fileList
- 2、遍历
fileList
,调用上传接口uploadFile
,等待异步上传结果,依次串行上传。 - 3、每上传成功一张图片,就调用一次
sendMsg
接口将该图片消息发送出去。
示例代码:
async handleFileList (fileList) {
for (let i = 0; i < fileList.length; i++) {
const res = await api.uploadFile(fileList[i].file)
api.sendMsg(res.url)
}
}
注意事项:
- 这里利用
for
循环和async
、await
的特性,遍历fileList
实现等待上次循环异步结束后再执行下次循环。(不能使用forEach遍历,因为forEach是通过回调函数执行逻辑,每次循环的回调都相互独立)。
思考:
- 虽然使用串行上传能保证消息发送的顺序一致,但细想一下,如果使用并行上传肯定能节省很多时间,只是不同大小的图片上传时间不一,如何处理消息发送的时机,使消息顺序保持一致?
其他方案:
Promise.all
。Promise.all需等待所有接口成功后才能拿到结果,无法第一时间发送消息;且一个接口失败会中断所有异步接口。Promise.allSettled
。Promise.allSettled需等待所有接口完成后才能拿到结果,无法第一时间发送消息。
以上也是我刚开始做这个需求时的实现历程,不管怎么说,先实现需求效果,完成上线,后面再考虑优化。
二、性能优化
主要优化点就是将上述的图片串行上传改为并行上传。
逻辑思路:
- 使用消息队列存储所有图片文件的上传状态和结果,每次上传完成后都检查队列,判断顺序和状态,符合条件的依次发送消息。
先上 消息队列类msgQueue.js
的完整代码:
class MsgQueue {
constructor () {
this.list = [] // 队列存储数组
this.isExecuting = false // 是否正在执行队列(防止异步时队列执行重复)
this.onCompleteAll = () => {} // 消息队列全部完成后的回调
}
// 添加队列(length: 新添加的队列长度)
addList (length) {
const newList = new Array(length).fill().map(() => ({
status: 'pending', // pending:等待上传结果, success:上传成功, fail:上传失败, done:已发送消息
result: {},
}))
const insertIndex = this.list.length
this.list = this.list.concat(newList)
return insertIndex // 返回新添加队列在整个队列中的起始下标
}
// 更新队列里的特定项数据
updateItem (index, status, result) {
const item = this.list[index]
item.status = status
item.result = result || {}
}
// 检查队列,并执行(callback: 队列中每一项的执行函数,本文里就是调用消息发送接口)
async execute (callback) {
if (this.isExecuting) {
return
}
this.isExecuting = true
for (let i = 0; i < this.list.length; i++) {
const item = this.list[i]
if (item.status === 'pending') {
break
} else {
if (item.status === 'success') {
item.status = 'done'
await callback(item.result).catch(() => {})
}
if (i === this.list.length - 1) {
this.onCompleteAll()
}
}
}
this.isExecuting = false
}
}
export default MsgQueue
使用示例:
import MsgQueue from './msgQueue'
const msgQueue = new MsgQueue()
handleFileList (fileList) {
console.log('发送中...')
msgQueue.onCompleteAll = () => {
console.log('发送完成')
}
const insertIndex = msgQueue.addList(fileList.length)
fileList.forEach((v, i) => {
api.uploadFile(fileList[i].file)
.then(res => {
msgQueue.updateItem(insertIndex + i, 'success', res)
})
.catch(err => {
msgQueue.updateItem(insertIndex + i, 'fail', err)
})
.finally(() => {
msgQueue.execute(result => {
return api.sendMsg(result.url)
})
})
})
1、消息顺序控制
先看最初时的execute
方法逻辑(这种方式只能保证消息发送时的顺序一致):
execute () {
for (let i = 0; i < this.list.length; i++) {
const item = this.list[i]
if (item.status === 'pending') {
break
} else {
if (item.status === 'success') {
item.status = 'done'
api.sendMsg({ url: item.result })
}
}
}
}
- 首先在初始化添加list的时候,初始字段
status
设置了4种状态(pending:等待上传结果,success:上传成功,fail:上传失败,done:已发送消息)。 - 在每个上传接口完成后,调用
updateItem
根据数组下标更新消息队列list
中对应位置的数据(status
数据和result
数据)。 - 同时会执行消息队列的
execute
方法,遍历队列按顺序检查status
状态,pending
状态就跳出循环,success
就发送消息。每一个图片上传完成后都会重复以上步骤,确保消息发送的顺序,(这里可以概括为:list同步遍历、消息发送并行执行)。
然而实际中发现,即使消息发送的顺序保持一致了,但最终消息展示的顺序有时也不一致。这是因为后端是按消息发送接口拿到传输数据时的顺序插入数据库,前端的消息发送顺序和后端的接口数据接收顺序会因为网络服务器环境等原因无法保证一致。
由于消息发送的接口响应很快,所以考虑进行改造,采用list异步遍历,消息发送串行执行:
async execute () {
if (this.isExecuting) {
return
}
this.isExecuting = true
for (let i = 0; i < this.list.length; i++) {
const item = this.list[i]
if (item.status === 'pending') {
break
} else {
if (item.status === 'success') {
item.status = 'done'
await api.sendMsg({ url: item.result }).catch(() => {})
}
}
}
this.isExecuting = false
}
- 上述
execute
方法采用for
循环+async await
方式来遍历执行,等待消息发送接口响应完成后再往下执行代码,这样就能确保后端接收数据的顺序一致性。 - 每上传一个图片都会调用
execute
检查队列,同步遍历时没有问题,但改成异步遍历的方式后,如果重复执行,也可能存在后一顺序的消息发送接口在前一接口未完成响应时就调用的情况,有顺序错乱的可能,所以通过变量isExecuting
来加以控制,同一时间只会有一个list遍历的运行。
小结:图片上传接口并行进行,消息发送接口串行进行。
2、兼容接口异常
接口异常处理也是很重要的一环。在多选图片上传场景下,多个接口不应该受到单个接口异常的影响,如果有某个图片上传失败或某条消息发送失败,应该忽略,确保其他图片消息正常发送。
所以这里涉及两个地方的异常处理:
if (item.status === 'success') {
item.status = 'done'
await callback(item.result).catch(() => {})
}
- 图片上传的接口异常时,就把对应项的
status
状态置为fail
,在遍历消息队列list
时只针对success
状态的数据作为消息发送。 - 消息发送的接口异常处理,我这里是通过catch捕获,确保await 接口异常时后续的代码能继续往下执行,用try catch包裹也可以。
3、支持追加队列
如果用户在已选中一些图片正在上传发送中时还想继续再选一些图片发送,这种场景就需要考虑到消息队列需要无限追加的支持。
最初的做法只是初始化队列:
addList (length) {
this.list = new Array(length).fill().map(() => ({
status: 'pending',
result: {},
}))
}
- 这种做法不支持追加操作,无法满足上述需求。
改造后:
addList (length) {
const newList = new Array(length).fill().map(() => ({
status: 'pending',
result: {},
}))
const insertIndex = this.list.length
this.list = this.list.concat(newList)
return insertIndex
}
- 队列通过
concat
来支持追加。 addList
方法返回新追加队列的起始下标,用于在调用updateItem
更新队列项数据时能计算出到对应的下标。(msgQueue.updateItem(insertIndex + i, 'success', res)
)。
4、其他
(1)列表刷新防抖:
我项目这里没有使用websocket
,所以在消息发送完成后需要手动调用消息列表刷新接口。
由于多个图片消息一起发送时频率很快,就没必要发送一次消息就刷新一下,使用函数防抖处理一下,延后调用即可,延后时间不宜太长,我这里设的100ms。
这样,在不影响用户体验的情况下能显著降低消息列表刷新接口的调用频率。
(2)即时反馈用户:
我这里封装了onCompleteAll
事件,即消息队列全部完成后的回调,就是便于添加toast
用户提示,在开始时toast
提示'发送中...'
,结束时toast
提示'发送完成'
,有一个反馈过程,用户体验更佳。
- 展望:
参考微信里的图片发送效果,它是发送时前端即时在消息列表里展示出来,正在发送中的图片会给一个loading状态,发送完成再正常展示。这种的用户体验无疑是更加友好的。
要实现这种效果,首先消息顺序的控制逻辑仍然需要,只是消息列表的展示效果需额外处理,需要将前端的模拟展示消息和后端接口的真实消息关联,应该是需要一个id,这就需要前后端配合改造一下接口了。