最近老闆來了一個新需求,地理位置組團。其中一個功能點,就是用戶能夠進入地圖頁面,查看當前自身位置,且掃描圈內和圈外其餘玩家,將玩家頭像顯示在地圖頁面上,標明玩家在哪一個位置。老闆說,在地圖上要能夠同時顯示 200 個玩家頭像,且保證頁面流暢;Android6 的手機上,在拖拽,縮小放大時,不能出現明顯的卡頓,必須保證用戶體驗。在拖拽,縮小或者放大等改變地圖可視範圍時,或則停留時間超過 60s,須要更新當前地圖上的用戶頭像。javascript
需求其實不復雜,可是要作到老闆的要求,就沒那麼簡單了。android 6 的機型,這個應該是幾年前的千元機了。這種機型的硬件原本就不好,不用想,在地圖上一次性繪製 200 個頭像,確定會很卡。而且拖拽地圖以後,又清空以前已經繪製的 200 個頭像,從新繪製新的 200 個,這個確定會使頁面出現明顯的卡頓。要達到老闆的要求,必須仔細的思考一下該怎麼作了。前端
瀏覽器對同一個域名的併發請求數量是有上限的,通常不會超過 8 個,就算一次發送 200 個圖片請求,也是分批返回的。結合這一點,我也能夠分批繪製,200 個頭像,我能夠分紅 40 次繪製,每次只繪製 5 個,上一次的 5 個繪製完成了,纔開始繪製下一批的 5 個。帶着這種想法,就去找 PM 商量,跟他說明瀏覽器請求限制和性能上的考慮,能不能不要一次性所有顯示,能夠慢慢顯示出來。PM 接受了這種方式,可是要優先顯示圈內的頭像。java
爲了達到能夠分批繪製,且圈內的要優先繪製,容易就想到了,優先級隊列,優先級高的先出隊列。這裏,圈內頭像就比圈外的優先級高。每一次從隊列裏取出 5 個頭像來繪製,直到隊列爲空。若是同步的循環調用每一批繪製,直到隊列爲空,那麼確定會使得當前幀執行時間超過 16ms,且會超過很長時間,瀏覽器一直會被阻塞,使得其餘用戶事件都不獲得響應,表現出來就是頁面卡死了,這樣確定不行的。利用javascript 的 event loop,能夠將每次繪製 5 個頭像,這樣一個功能包裝在一個任務裏,將 200 個頭像就能夠分紅 40 個這樣的任務,而後將每一個任務分別加入到 javascript 的執行隊列裏。這樣,能夠異步的方式,將頭像分批繪製出來,頁面也不會出現卡死。android
// 僞代碼,
// 將一次繪製5個頭像包裝成一個任務,放到event loop 裏
const PER_COUNT = 5
function paintMarker(type) {
// 優先獲取圈內數據
const userData = getUserData(type)
if (!userData.length) {
if (type === "nearbyMarker") {
// 圈內繪製完了,繼續繪製圈外的
this.engine.pushDraw(() => {
this.paintMarker("externalMarker")
})
}
return
}
// 一次繪製5個
let start = 0
let notAvailable = false
while (start < PER_COUNT && start < userData.length) {
const user = userData[start]
// 從實例池取出一個
const marker = this.pools.take()
if (!marker) {
notAvailable = true
break
}
// 開始繪製頭像
marker.draw(user)
start = start + 1
}
// 將下一次繪製任務加入到event loop裏
if (!notAvailable) {
this.engine.pushDraw(() => {
this.paintMarker()
})
}
}
複製代碼
對於地圖頁面,繪製的上限是 200 個頭像。每次改變了地圖的範圍,好比移動,縮小,放大地圖等操做,須要從新請求接口數據,得到當前新的地圖可視範圍內的玩家頭像數據,而後將新的玩家頭像繪製出來。因爲 google map 在繪製自定義圖形時,須要生成一個 google map 的 OverlayView 對象,實現它的 onAdd 和 onDraw 方法。若是,咱們每次繪製新的頭像都新建一個 OverlayView 對象,勢必會增長瀏覽器的內存使用,且新建 OverlayView 對象也是須要花費必定時間的。爲了高效繪製,且花費盡可能少的內存,能夠事先建立一個容量爲 200 的 OverlayView 對象池,在每次改變地圖範圍操做以後,能夠先回收那些不在可視範圍內的 OverlayView 對象,放入池中;而後在繪製新的頭像時,直接從池中取一個 OverlayView 對象使用就能夠了,這樣,即減小了內存的使用,也減去了每次新建 OverlayView 對象花費的時間。當池中沒有可用 OverlayView 對象時,說明當前頁面已經繪製了 200 個頭像,達到了上限,不須要在繪製其餘頭像了。算法
// 僞代碼
// 初始化pools
const MAX_COUNT = 200
function init() {
// 初始marker實例池
this.pools = new MarkerPool(MAX_COUNT)
// 監聽idle事件
google.maps.event.addListener(this.map, "idle", () => {
this.isIdle = true
// 回收可視區域外的marker
this.reclaimMarker()
// 設置定時刷新數據
this.initRefreshTimer()
// 請求用戶數據
this.fetchUserData("nearbyMarker", { users: [] }, true)
})
}
複製代碼
爲了保證在操做地圖的時候有最好的流暢度,比圖拖拽,縮小,放大等,咱們不作任何事情,即不繪製頭像,也不請求數據,就僅僅讓 google map 本身改變地圖。當 google map 狀態是 idle 時,咱們再去作繪製頭像或者更新數據等。google map 提供了 idle 事件,咱們只須要監聽這個事件就能夠了。chrome
更新數據,就是把接口請求來的數據,先作一些清洗工做,而後把合格的數據更新到待繪製頭像隊列裏;能夠把它的優先級降到最低,只有當前繪製頭像隊列爲空時,纔去執行更新數據任務。它的執行時間基本是固定可預估的,不會特別延誤到當前幀的繪製,能夠把它放在requestIdleCallback隊列裏去,經過增長一個超時執行時間內,只有當瀏覽器是 idle 時或者超過了某一個時間,纔會去執行。typescript
// 僞代碼
// 將更新數據操做放入到requestIdleCallback
function fetchUserData(type, userData, fromStart = true) {
// 加入到待繪製數組中
if (data.users.length) {
this.patchUserData(data.users, type)
}
const nextType
// ... //
this.fetchUserDataApi(
this.myLocation,
this.mapBounds,
fromStart,
nextType
).then(data => {
this.engine.pushRequest(() => {
this.fetchUserData(nextType, data)
})
})
}
複製代碼
瀏覽器的理想幀率是 60fps,若是一直穩定在 60fps 左右,那麼將是很是流暢的。對於 Android 6 這樣的機型,確定是達不到 60fps 的,只能儘量提升它的幀率,讓它能穩定在 30fps 左右,基本上就能夠達到要求了。對於一些細節的優化,特別是要避免 layout reflow 的狀況,一樣嚴重影響頁面流暢度。下面的 performance 分析,我都是將 CPU 下降 6 倍,且繪製了 200 個頭像,拖動地圖頁面獲得的。數組
剛開始,給每一個 OverlayView 都設置了zIndex = '50'
,當前用戶的 OverlayView 設置了zIndex = '80'
,這樣當前用戶老是顯示在最上層。這樣更改頭像樣式能達到設計稿的視覺效果,可是這將形成頁面很是卡頓,具體咱們經過 chrome performance 調試獲得結果。瀏覽器
頁面的幀率平均是 8fps,也就是繪製一幀須要花費平均 122ms 左右。先不看其餘影響幀率的地方,就看看 Composite Layers 步驟,它就花費了 17.56ms。理想 60fps 的狀況下,一幀的繪製總共才花費 16.67ms 左右。顯然,咱們的 Composite Layers 步驟嚴重影響性能。Composite Layers 是瀏覽器一幀繪製工做中的最後一個步驟,合成層。每當設置新的 zIndex 值,都將會建立新的 layer,同一個 zIndex 的值的元素,最後會被繪製在同一個 layer 中,具體能夠查看使用 zIndex。去掉 zIndex,咱們再來看看結果。數據結構
去掉了 zIndex 以後,如今頁面的幀率平均是 10fps,繪製一幀須要花費的平均時間是 100ms 了。在 Composite Layers 階段花費的時間基本是 9ms 左右了。顯然是有所提高的。
因爲在拖拽地圖時,google map 會不停的調用咱們實現的 onDraw 方法。在 onDraw 方法裏,能夠隨意設置當前 OverlayView 對象的樣式和位置。未優化以前,是根據當前容器 div 的寬高和當前經緯度換算出來的座標計算獲得當前 OverlayView 對象的 left 和 top。
// 部分代碼以下
/* 繼承 google.maps.OverlayView,實現draw */
function draw() {
const overlayProjection = this.overlayView.getProjection()
const posPixel = overlayProjection.fromLatLngToDivPixel(this.latLng)
const scale = this.computeScale()
// Resize the image's div to fit the indicated dimensions.
const div = this.el
let x = posPixel.x - div.offsetWidth / 2
let y = posPixel.y - (div.offsetHeight * (scale + 1)) / 2
div.style.transform = `scale(${scale})`
div.style.left = x + "px"
div.style.top = y + "px"
}
複製代碼
draw 方法中,訪問div.offsetWidth
和 div.offsetHeight
,強制觸發 reflow,這將很是影響性能。咱們能夠優化成,頭像顯示成固定寬高。例如,let x = posPixel.x - 32 / 2;
和let y = posPixel.y - 32 * (scale + 1) / 2;
。
能夠看到,在 draw 方法裏,如今就沒有 Layout 和 Recalculate Style 的操做了。如今幀率平均基本是 12fps 了,繪製一幀須要花費的平均時間是 84ms 了。又有所提升了。
對於 Android 6 等機型,徹底沒有必要還爲每一個頭像都繪製出一個底部三角形。底部三角形,會額外建立一個 div 元素,若是是 200 個頭像,頁面就會多出了 200 個元素。而且在拖拽等操做,頻繁的調用 onDraw,會從新繪製每一個頭像,也會從新繪製每一個底部三角形,這樣也增長了繪製所須要的時間。對於 android 6 如下等低端機型,能夠去掉底部三角形。
如今頁面的幀率平都可以達到了 15fps,繪製一幀須要花費的平均時間是 68ms 了。
在優化這些小細節以後,在 CPU 下降到 6 倍慢,且頁面繪製 200 個頭像時,幀率從以前的 8fps 提升了 15fps,足足提高了一倍的性能。對於 Android 6 等極端機型,其實還能夠再降級,從 200 個頭像減小到 100 個。
頭像降到 100 個以後,能夠看到如今頁面的幀率平都可以達到了 27fps,繪製一幀須要花費的平均時間是 36ms 了。幀率從以前的 8fps 提升了 27fps,足足提高了三倍多的性能。
前端也可使用一些基礎的數據結構和算法,結合前端的一些知識,能夠有比較好的實踐。在開始動手編碼以前,能夠先思考一下,大體的實現思路,是否能夠有更優方案。當在低端機型上沒法知足性能要求時,要學會與 PM 溝通,是否能夠降級處理。在遇到性能瓶頸時,學會使用工具分析和定位問題。