最近老板来了一个新需求,地理位置组团。其中一个功能点,就是用户能够进入地图页面,查看当前自身位置,且扫描圈内和圈外其余玩家,将玩家头像显示在地图页面上,标明玩家在哪一个位置。老板说,在地图上要能够同时显示 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 沟通,是否能够降级处理。在遇到性能瓶颈时,学会使用工具分析和定位问题。