引言
富媒體是指在即時通信過程中傳輸的圖檔、語音、視訊、檔案等媒體媒體的展示方式。
一、背景
客服一站式平台旨在為得物生态内的客服域服務人員提供一站式的服務辦公平台。我們有多條業務線,客服在和使用者聊天的過程中,有很多場景需要發送富媒體。跟普通的文本傳輸相比,富媒體可以直覺的讓使用者了解到消息内容,但是在傳輸過程中也面臨着檔案大、記憶體消耗大、傳輸過程漫長等問題。
二、面臨的挑戰
客服發送大檔案(視訊、圖檔)等消息給使用者的大緻流程如下:
- 首先通過檔案上傳服務上傳到CDN,同時傳回對應的CDN位址連結;
- 其次是擷取到CDN位址連結,通過IM網關将連結傳回給使用者界面渲染。
在整個傳輸過程中,前端必須等檔案上傳成功拿到連結之後,才能渲染,如果傳輸的檔案很大,客服需要會等待很長時間,這對于客服的接線效率有非常大的影響。比較理想的方式是當客服發送檔案的時候,檔案立馬在聊天視窗渲染,此時渲染的不是完整的檔案,而是檔案的畫像,比如檔案的名字、封面圖檔,通過消息的狀态進行上傳狀态的控制。
以視訊傳輸為例,如果直接把視訊放在緩存中展示在客服聊天内容區域,龐大的緩存會讓使用者的浏覽器分分鐘崩潰。比如大于70M的視訊,在網絡,電腦硬體等環境都較好的情況下,從讀取檔案到擷取到首幀圖檔傳輸的過程大概需要2~3s,如果在網絡一般,同一環境下有多人在發送視訊檔案,或者硬體裝置一般的情況下時間會更長。
如何在不影響客服接線效率的情況下,還能讓大檔案的傳輸做到如絲般順滑呢?
三、解決方案與成效
1、将fileReader.target.result作為視訊的url在頁面渲染
最初使用的方式是在視訊上傳CDN時,同時截取視訊首幀,然後将截取的視訊首幀也上傳到CDN,再通過長鍊(wss)發送給用戶端,因為截取首幀是一個同步的過程,需要拿到screenshot的url之後才能渲染到頁面,導緻客服在點選發送的第一時間在聊天界面看不到發送出去的視訊,如上圖視訊所示,客服無法感覺到視訊發送的進度。
通過FileReader讀取檔案資訊:
export function getFileInfo(file: File): Promise<any> {
return new Promise((resolve, reject) => {
try {
const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = (event: ProgressEvent<FileReader>) => {
resolve(event)
}
} catch (e) {
reject(e)
}
})
}
複制
通過傳回的檔案資訊進行屬性設定:
export function getVideoInfo(file) {
return new Promise((resolve, reject) => {
getFileInfo(file)
.then(fileReader => {
const target = fileReader.target.result
if (/video/g.test(file.type)) {
const video = document.createElement('video')
video.muted = true
video.setAttribute('autoplay', 'autoplay')
video.setAttribute('src', target)
video.addEventListener('loadeddata', () => {
// ...
})
video.onerror = e => reject(e)
}
})
.catch(e => reject(e))
})
}
複制
如上代碼 video.setAttribute('src', target),如果用target作為視訊的url在頁面渲染,頁面會分分鐘崩潰。可以看一下1M的視訊檔案,通過readAsDataURL(file)讀取檔案内容得到是一個data:url的base64字元串,用這個字元串進行渲染,等于在頁面加了一個1.4M的字元串内容,如下圖所示,這樣做的後果不可想象,在檔案稍微大一些的話會有更加明顯的卡頓。
是以這個方案在開發之初就被否定了。
2、采用的URL.createObjectURL(file) 擷取到URL
在第一種方案被否定之後,又調研了URL.createObjectURL的實作。采用的URL.createObjectURL(file) 擷取到URL(這個URL對象表示指定的 File 對象或 Blob 對象),然後放到聊天資料的緩存中,便于快速發送到客服聊天視窗頁面。其主要實作代碼如下:
if (/*******/) {
// ...
//. blob作為預覽視訊的url
state.previewVideoSrc = URL.createObjectURL(file)
state.previewVideo = true
state.cachePreviewVideoFile = file
nextTick(() => {
focus()
})
} else {
// ...
}
複制
經過這個改造很明顯的看到視訊發出之後,可以很快的展示在頁面上,讓客服感覺到視訊發送的狀态和進度,相對于方案一,視訊發送的過程有明顯的提升。渲染出來的代碼效果如下圖所示:
但是!
在給用戶端發送視訊資訊時,要攜帶首幀和視訊時長,作為展示封面,曆史的做法是:
- 首先前端擷取檔案資訊後通過canvas轉換成圖檔再上傳到CDN;
- 在擷取到首幀和檔案資訊之後,先上傳到CDN,傳回URL後再通過長鍊發送給使用者,同時更新頁面的URL位址為CDN傳回的真實位址。
取首幀時要讀取檔案,既然是讀取檔案,還是存在一定的耗時,如下代碼片段所示,這段耗時任務也會影響到客服的使用體驗。
export function getVideoInfo(file, msgid?: string) {
return new Promise((resolve, reject) => {
getFileInfo(file, msgid)
.then(fileReader => {
const target = fileReader.target.result
if (/video/g.test(file.type)) {
const video = document.createElement('video')
video.muted = true
video.setAttribute('autoplay', 'autoplay')
// target隻作為url建立視訊用于擷取視訊大小、播放時長等基本資訊,不用于頁面渲染
video.setAttribute('src', target)
video.addEventListener('loadeddata', () => {
const canvas = document.createElement('canvas')
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const width = video.videoWidth
const height = video.videoHeight
canvas.getContext('2d')!.drawImage(video, 0, 0, width, height)
const src = canvas.toDataURL('image/jpg')
const imgFile = dataURLtoFile(src, `視訊_${Math.random()}.png`)
return getImgInfo(imgFile, fileReader.msgid).then(
({ width: imgWidth, height: imgHeight, file: imgFile, size: imgSize, src: imgSrc, msgid }) => {
resolve({
// ...
})
}
)
})
video.onerror = e => {
// ...
reject(e)
}
}
})
.catch(e => {
reject(e)
})
})
}
複制
上傳視訊的時候,檔案伺服器提供了擷取首幀的方式拿到首幀圖檔,在連結位址上拼接對應的參數即可,如下所示:
// 拼接的擷取圖檔首幀的URL位址
export const thumbSuffix = `?x-oss-process=video/snapshot,****`
export function addOssImageParams(url, isThumb = false) {
const suffix = isThumb ? thumbSuffix : urlSuffix
if (!url) return ''
// ...
return url
}
複制
但在實際的使用場景中,隻擷取視訊首幀資訊是不夠的,還要擷取視訊的寬高、播放時長等資訊,并且通過網絡請求發送給網關,最終在用戶端展示。讀取檔案這個過程無法避免,耗時問題還需要解決。
3、Web Worker異步讀取檔案資訊
通過方案二雖然實作了檔案的快速渲染,但讀取檔案資訊如果在浏覽器的主線程去做,耗時長的話,還是會阻礙客服的操作。如果這個過程能通過異步去實作,那就很完美了。JS雖然是單線程,但是浏覽器提供了Web Worker的能力,讓JS也能通過異步的方式和主線程進行通信。首先對比下浏覽器主線程執行和主子線程執行的差別,如下圖所示:
- 浏覽器主線程在執行發送檔案的時候,如果發送檔案任務沒有結束,則會阻塞其他的任務,相當于發送期間,客服什麼事情也做不了;
- 浏覽器主子線程在執行發送檔案的時候,通過子線程讀取檔案,在讀取檔案期間,主線程可以繼續執行其他的任務,等到子線程讀取完檔案通過postMessage發送相關的資訊告知主線程檔案讀取完畢,主線程再開始渲染。整個過程對于客服沒有任何阻塞。
Web Worker主子線程實作的流程如下:
首先線上程訂閱中心建立子線程任務,如下:
// 子線程任務
export function subWork() {
self.onmessage = ({ data: { file } }) => {
try {
// 讀取檔案資訊
// ...
// 發送對應資訊
self.postMessage({ fileReader: **** })
} catch (e) {
self.postMessage({ fileReader: undefined })
}
}
}
複制
然後線上程訂閱中心初始化Worker,如下:
export const createWorker = (subWorker, file, resolve, reject) => {
const worker = new Worker(URL.createObjectURL(new Blob([`(${subWorker.toString()})()`])))
// 發到子線程
worker.postMessage({
file
})
// 監聽子線程傳回資料
worker.onmessage = ({ data: { fileReader } }) => {
resolve(fileReader)
// 擷取到結果後關閉線程
worker.terminate()
}
// 監聽異常
worker.onmessageerror = function () {
worker.terminate()
}
}
複制
最後在主線程裡面調用Worker擷取檔案資訊,如下:
// 建立主線程任務
export const getFileInfoFromSubWorker = files => {
return new Promise((resolve, reject) => {
createWorker(subWork, files, resolve, reject)
})
}
複制
通過上面的三個步驟,基本就可以在不影響客服操作的情況下擷取到檔案資訊。擷取到視訊資訊對象之後,再通過URL.createObjectURL(file)即可擷取到視訊相關的屬性資訊,如下:
export function getVideoInfo(file, blob, msgid?: string) {
return new Promise((resolve, reject) => {
if (/video/g.test(file.type)) {
const video = document.createElement('video')
video.muted = true
video.setAttribute('autoplay', 'autoplay')
// blob作為url: URL.createObjectURL(file)
video.setAttribute('src', blob)
video.addEventListener('loadeddata', () => {
const width = video.videoWidth
const height = video.videoHeight
resolve({
videoWidth: width,
videoHeight: height,
videoDuration: video.duration * 1000,
videoFile: file,
videoSize: file.size,
videoSrc: blob,
msgid
})
})
video.onerror = e => {
reject(e)
}
}
})
}
複制
如上所述,在擷取檔案對象資訊之後,再通過blob的方式直接擷取視訊的寬高作為第一幀圖檔的寬高,二者結合即達到了在不影響客服操作的情況下,讓視訊發送做到了如絲般順滑。
通過Web Worker+URL.createObjectURL(file)的方式,解決了富媒體檔案發送時,不管有沒有發送成功,都可以實作秒發的效果,即讓視訊資訊先展示到聊天框,再通過發送狀态來辨別目前的發送進度。
四、總結
富媒體發送在很多IM場景中均會涉及到,用什麼樣的技術實作能夠讓客服和使用者之間溝通和交流更便捷是本文闡述的重點。通過在實際客服業務場景中的實踐,本文的技術方案已經很好的解決了業務中的問題并且實際線上也一直比較穩定的在運作。從業務中發現問題,用技術手段解決問題,提升客服的解決效率,給使用者帶來好的體驗是我們不斷追求的目标,如果看了本文之後,你有更好的建議可以給我們留言。此外客服領域的技術點遠不止這些,腳踏實地,一步一個腳印,相信即時通訊在客服領域的沉澱會越來越好。
五、知識擴充
1、檔案讀取的實作差異
URL.createObjectURL() 和FileReader.readAsDataURL(file)都可以取到檔案的資訊,為什麼我們選擇使用前者而非後者?
兩者的主要差別在于:
- 通過FileReader.readAsDataURL(file)擷取到的是一段data:base64的字元串,base64位的字元串較大
- 通過URL.createObjectURL(blob)獲會建立一個DOMString,其中有包含了檔案資訊的URL(指定的 File 對象或 Blob 對象)
執行的時機的不同:
- createObjectURL是立即的執行
- FileReader.readAsDataURL是(過一段時間)異步執行
記憶體的使用不同:
- createObjectURL傳回一段帶hash的url,并且一直存儲在記憶體中,當document被觸發了unload或者執行revokeObjectURL進行記憶體釋放;
- FileReader.readAsDataURL傳回的是base64的字元串,比blob url消耗更多的記憶體,不過這個資料會通過垃圾回收機制自動清除。
使用選擇:
- 用createObjectURL能夠節省性能,擷取的速度也更快;
- 如果裝置性能足夠好,而且想要擷取圖檔的base64,可以用FileReader.readAsDataURL。
2、流媒體、富媒體、多媒體的概念
流媒體、富媒體、多媒體到底有什麼差別?
- 流媒體:一邊使用,背景一邊下載下傳後面可能要使用到的東西。
- 富媒體:文字、圖檔、視訊、音頻混排的頁面内容。
- 多媒體:圖檔、文字、音頻、視訊等資料。
其中流媒體是一種傳輸方式,富媒體是不同于純文字的一種展示方式,多媒體是展示内容的一種手段。