产品经理:为什么你做的地图比以前丝滑了许多?
从业多年第一次接触地图相关的需求,开发过程中产生了一些思考,遂记录下来,欢迎讨论
Vue3 + 高德地图 JS API 2.0 + 高德地图 AMapUI组件库
近两年前端大家是真的不好混,在职的人呢被极限压榨,待业的人呢投简历都是【未读不回】。
照常理来说,地图相关的需求都是由组内的地图大佬负责的,但眼瞅着公司里前端同学越来越少,这“泼天的富贵”终于有一天也落到了我头上。
需求的内容倒是很简单:要在地图上绘制一些轨迹点
和一条轨迹线
,以及一个目标点KeyPoint
,让使用者来审查轨迹线
是否经过KeyPoint
,以及系统中记录KeyPoint
的信息是否正确。当轨迹未经过
或 KeyPoint信息不正确
时,会再提供一些辅助点SubPoint
供用户选择,替换掉KeyPoint
。(轨迹点
也属于一种SubPoint
)
本着能CV就不手写的原则,我打开了项目代码(Vue2)寻找之前类似的地图需求,看看能不能套用一下然后快速下班,结果我看到了若干个大几千行的文件,以及这样的渲染效果(轨迹点上的箭头表示当前移动的方向):
大哥喂,咱就是说,方向盘打不正的话要抓紧去修,上路是要出事故的
得,言归正传,且不说那加起来几万行的代码我能不能捋顺喽,就是这个效果,干脆我还是用Vue3重新实现一下吧。
别忘了,前端的老本行是什么
。
业务的关注点
开始之前,我们先思考一个问题:业务的关注点是什么?
想明白了这个问题,我们在设计地图样式以及一些交互细节时,才能有更好的针对性。
(让我看看有多少人是默认样式+内置组件一把梭的)
ok那既然涉及到了地图,归根结底我们的关注点无非是这三方面:
- 点
- 线
- 区域
如果按照关注点的归属
粗略的分为两类:外部添加的、地图自身的。
当业务更关注外部添加的元素时(如maker、轨迹),随着地图缩放、地形改变、POI显隐,我们添加的元素是否始终有一个比较醒目的显示效果?
当业务更关注地图自身的元素时(如兴趣点),对于POI的 pick 动作,是否贴合业务流程?是否足够智能与便捷?(可参考高德自己的效果)
这里针对第一类推荐两个初始化地图的可选配置项:
相信很多人可能都没关注过这两个配置项,而这两个东西组合起来使用,不仅能使你添加的外部元素始终处于一个高醒目的level,也可以与你项目本身的风格主题更搭,如何抉择,诸君自行思量。
(浅浅吐槽一下,高德提供的功能和配置项非常丰富,但文档真的是一言难尽。。。一样的功能在不同的地方都有文档,有些内容还不一致)
选择画点的方法
当你大概明白自己要做什么样的地图之后,让我们稍微进入一点正题:怎么选择合理的画点方法?
高德提供了哪些画点的方法呢?
- JS API
- 默认点标记
Marker
- 圆形标记
CircleMarker
- 灵活点标记
ElasticMarker
- 海量标注
LabelMarker
:需要维护图层、维护避让等级、自定义样式实现起来比较麻烦 - 海量点标记
MassMarker
:无法显示文字label - 点聚合
- 按距离聚合
MarkerCluster
:需要维护权重 - 按索引聚合
IndexCluster
:需要维护索引规则
- 按距离聚合
- 默认点标记
- JS API UI组件库
- 简单标注
SimpleMarker
- 字体图标标注
AwesomeMarker
- 矢量标注
SvgMarker
- 海量点
PointSimplifier
:可使用Canvas
- 简单标注
至于像文本标记、折线、多边形等等一些通过某些黑科技实现类似点标记的方法(GPT说的),和Native端的画点方法,不在本文的讨论范围中。
美国五星上将麦克阿瑟曾说过,一切抛开实际背景去讨论问题的行为都是耍流氓。
画点的方法找到了很多,那我们要画什么样的点呢?
1. 从数量上看
动辄上万
为什么我要先看数量呢,因为可自定义样式的画点方法很多,但是要支持大数量级渲染且性能良好,就把上边一多半方法给pass掉了。
还剩下这些可供选择:海量标注LabelMarker
、海量点标记MassMarker
、按距离聚合MarkerCluster
、按索引聚合IndexCluster
、海量点PointSimplifier
2. 从样式上看
需要自定义。从上边的截图中可以看出,点的形状为圆形,黑边黄底,中心有个箭头,且整体随着当前运动方向有一个rotate deg
。
上述画点方法至少都支持(图片 或 HTML String 或 CSS Style)中的一种方式,而这三种方式理论上也都能实现我们想要的效果,所以下一个。
3. 特性
虽然我们需要关注轨迹点,但并不是所有状态下都需要。比如在地图的缩放等级很小时(看到的是省、国家级别),并不需要把每一个轨迹点都展示出来。所以可以看到,之前的实现效果中,放大缩小都会重新适应尺寸,并且临近的点有自动合并的效果。
海量标注LabelMarker
和海量点标记MassMarker
退出了游戏,他俩是全量绘制并且没有外部接入的话点是始终展示的。
至此,只有按距离聚合MarkerCluster
、按索引聚合IndexCluster
、海量点PointSimplifier
三者进入了决赛圈。
现在来综合对比一下这三种方法:
方法 | 1w+点渲染性能 | 自定义样式 | 合并逻辑 |
---|---|---|---|
按距离聚合MarkerCluster | 渲染迅速,操作不卡顿 | HTML String或图片 | 距离+权重就近合并 |
按索引聚合IndexCluster | 渲染迅速,操作不卡顿 | HTML String或图片 | 距离+索引分组合并 |
海量点PointSimplifier | 渲染迅速,操作不卡顿 | Canvas或图片 | TopN |
渲染方面在1w+
点的竞赛中大家表现得都不错,官网示例中心可以看到,这里不再赘述。
自定义样式则有三种途径,图片、HTML字符串和新出现的Canvas。图片和Canvas比较简单,我们先讲一讲这个HTML字符串,也就是原生的HTML。
假如你用了某个现代化的前端框架在开发你的系统,用到了高德地图,并且想画一些漂漂亮亮的点在你需要标注的地方。在翻阅了文档之后,发现似乎直接传入HTML字符串这种方法是最快的,于是你开开心心的输入了一个
My Marker
好家伙,这小Marker不仔细看,还真有点找不到呢...你决定继续添加一些样式
之后代码可能逐渐变成了这样...
你:
把手指从ctrl
C
V
三个键拿下来之后,你陷入了沉思:我能不能用XXX UI组件库来自定义Marker?
答案当然是肯定的~下面请允许我用Vue@3.3.4 + ant-design-vue@3.2.20
来做个示范~~
首先,他接收的是HTML字符串,所以直接传进去一个vue组件肯定是不行的
import MyComponent from './MyComponent.vue'
// 以普通点标记举例
new Marker({
content: MyComponent,
// ...other configs
})
// not work
所以我们要做的就是把MyComponent
给转成原生HTML,最简单的办法当然就是Vue实例的mount()
API啦:
以MyComponent
作为根组件创建一个新的Vue实例
import {createApp} from 'vue'
import MyComponent from './MyComponent.vue'
const app = craeteApp(MyComponent)
将实例挂载到一个div
上,得到原生的HTML
const div = document.createElement("div")
app.mount(div)
console.log('div: ', div)
打印一下:
使用:
// 以普通点标记举例
const marker = new Marker({
content: div,
// ...other configs
});
效果图就不放啦,有几个注意的点要提一下:
- 最重要的放在最前边:如果你的点在整个页面的生命周期内仅会绘制一次,那你可以跳过这一条。否则
一定要记得app.unmount()
。一种比较好的实践是,把点数据
、画点的方法
、移除点的方法
写进一个hook里。
// example
import { createGlobalState } from "@vueuse/core";
import { ref, createApp } from "vue";
import Map from './Map.js' // 地图实例
import MyComponent from "./MyComponent.vue";
export const useCustomPoints = createGlobalState(() => {
const pointData = ref([]);
const removePointsCb = [];
const setPoint = () => {
const data = pointData.value.map(point => {
const div = document.createElement("div");
const app = createApp(MyComponent);
app.mount(div);
removePointsCb.push(() => app.unmount()) // 清除图标实例的回调
// 以普通点标记举例
const marker = new Map.Constructors.Marker({
map: Map,
content: div,
// ...other config
});
return marker
})
Map.add(data); // 将点添加到地图上
removePointsCb.push(() => Map.remove(data)); // 移除点的回调
}
const removePoints = () => {
while (removePointsCb.length) {
removePointsCb.pop()();
}
};
return {
pointData,
setPoint,
removePoint
}
})
- 可以通过
createApp
的第二个参数传递props进去,这些props是响应式的 - 新创建的Vue实例与你项目自身的实例不共享全局的配置,比如
路由
、组件
、Store
等,需要单独配置 - Vue2以及其他的一些框架,实现思路类似
好了,言归正传。
看起来似乎三种方法都可以实现需求,但是仔细翻看点聚合方法的文档,发现使用图片自定义点时没有提供旋转的配置,也就是说我们可能需要准备n张图片(取决于你想实现角度渲染的精确度),不,这太不优雅了。而如果使用原生HTML去自定义,要么接受丑炸的效果(纯手工css),要么面临着卡顿的风险(大量的app实例)
没办法,只好被(xin)迫(ran)接受用海量点PointSimplifier
的Canvas去做了~毕竟能用Canvas画就约等于能画一切嘛~~
抱着视死如归的心情去翻了一下JS API UI组件库的海量点PointSimplifier
canvas绘制function文档,发现了一句了不得的话:
划重点:通常只是描绘路径、尽量不要fill或者stroke、引擎自己、一次性
翻译:该函数通常只是描绘路径,但是也能描绘形状。尽量不要fill或者stroke,除非你能搞明白我们的描绘机制。所有点的路径描绘完成后,引擎自己会在尾部调用fill以及stroke,一次性绘出所有路径,所以你要注意尾部的这次操作,避免冲突。
三个字总结海量点PointSimplifier
的描绘机制就是:连笔画。
不是每个点都创建一个新的Canvas画布,绘制完成后立即渲染;而是所有的点都共用一个Canvas画布,以当次你能绘制的区域坐标作为参数,重复绘制n(点的数量)次,最后一把全渲染出来。
明白了这个,我们在书写function的逻辑的时候,只要注意保证每次绘制开始、结束时笔触的落点和绘制上下文状态即可。绘制一个有旋转角度的、中间有箭头的圆形(圆形的背景色是通过海量点PointSimplifier
的lineStyle配置的),示例代码如下:
由于叠加了变换,处理状态时偷懒使用了
save()、restore()
renderOptions: {
// 这里使用了样式分组引擎:https://lbs.amap.com/demo/amap-ui/demos/amap-ui-pointsimplifier/group-style-render
// 以点的旋转角作为组id入参,方便操作
// 无需分组时,renderOptions.pointStyle.content = renderOptions.groupStyleOptions.pointStyle.content 逻辑一致
groupStyleOptions: function (gid) {
return {
pointStyle: {
content: function (ctx, x, y, width, height) {
// 存了一个坐标,画箭头的时候用
const startX = x + width / 2;
const startY = y + height / 4;
// 移动到画布的最右侧、中间位置
ctx.moveTo(x + width, y + height / 2);
// 画圆
ctx.arc(
x + width / 2,
y + height / 2,
width / 2,
0,
Math.PI * 2,
true
);
// 变换前保存一下状态
ctx.save();
// 以圆心为旋转的中心点
ctx.translate(x + width / 2, y + height / 2);
// 按照轨迹方向旋转
ctx.rotate((Math.PI / 180) * gid);
// 重置中心点
ctx.translate(-(x + width / 2), -(y + height / 2));
// 画箭头
ctx.moveTo(startX, startY);
ctx.lineTo(x + width / 4, y + height / 2);
ctx.moveTo(startX, startY);
ctx.lineTo(startX, y + (height * 3) / 4);
ctx.moveTo(startX, startY);
ctx.lineTo(x + (width * 3) / 4, y + height / 2);
// 由于箭头需要在旋转的状态下绘制,所以在箭头绘制完成后再恢复状态
ctx.restore();
},
},
};
},
}
来一个无旋转时的笔触顺序动图,我尽力了
画完之后,看一下对比效果:
前
后
前
后
OK,点画出来了。
上边特性中有提到:当我们距离很远时,就不需要再关注某个具体的轨迹点。所以可以再进一步优化,当地图的缩放等级zoom
小于某个阈值时,清空point:
import {computed, watch, ref} from 'vue'
import {Map, PointSimplifierIns} from 'Map.js' // 地图实例、海量点实例
const zoom = ref(null);
const showPoint = computed(() => zoom.value > 10);
const pointData = ref([ /* ...赋值逻辑省略 */]);
Map.on("zoomchange",
debounce(() => {
zoom.value = Map.getZoom();
}, 200)
);
watch(showPoint, (show) => {
PointSimplifierIns.setData(show ? pointData.value : []);
})
效果如下:
控制显示隐藏没有用自带的show()
和hide()
方法,而是选择直接重设数据源,是因为:海量点PointSimplifier
在show
状态下时对地图进行缩放,会自动重绘适应尺寸;hide
状态下则不会。从show
变为hide
时,会保存当前zoom
下点的尺寸,供下次hide
到show
时用。如果地图缩放的太快,当前的zoom
与上次保存尺寸时的zoom
跨度太大,可能会导致点位不匹配现象。
选择画轨迹的方法
画线的选择过程就简单了很多,之前需求中是用折线Polyline
实现的,画出来的效果总感觉差点意思,所以就去翻了翻高德的文档,共找到常规画线方法3种:
- JS API
- 折线
Polyline
- 贝塞尔曲线
BesizerCurve
- 折线
- JS API UI组件库
- 轨迹展示
PathSimplifier
- 轨迹展示
基本上毫无疑问了嘛~我们本身就是要画轨迹,还有什么好选的~~ 必须用轨迹展示
哇
不过这里还是分享一些对三种方法实际体验之后的感受:
- 折线
Polyline
:无法识别线上的点。如果轨迹数据没有经过噪点清除,画出来之后在细节处会有比较严重的锯齿。不过整体上感觉,倒也不是不能用~ - 贝塞尔曲线
BesizerCurve
:无法识别线上的点。但理论上是唯一可以绘制出完全符合真实运动轨迹的、贴合地图路线的方法了,代价也是相当的大——至少要在原本轨迹点的基础上额外维护n-1个控制点
,放弃~~ - 轨迹展示
PathSimplifier
:性能好,相同数据量下的显示效果要比折线画出来的平滑
许多。以及来自官网的优点罗列:- 使用Simplify.js进行点的简化处理,提高性能
- 支持识别线上的点做信息展示
- 内置巡航器
- 样式配置更加丰富
实现过程比较简单,照着文档撸就行,可以对比下折线和轨迹展示两种方式,在拐角细节处的差异:
折线
轨迹展示
小tips: 适当增加线宽
lineWidth
可以有效的缓解锯齿现象
Loading的区域与时机
当我第一次打开上文提到的老版本地图页面时,除了渲染效果不够理想外,最大的一个感受就是:Loading太长
不是想像中那样常规的:打开页面,给一个满屏Spin等待加载各种数据、等待绘制点、线的动作,所有准备工作完成后,取消Spin允许用户开始操作。
咱就是说,像这样的交互逻辑,其实也没啥问题。毕竟谁还没个业务繁忙的时候,最简单最原始最暴力的满屏Spin虽然在体验上不尽如人意,但我觉得是符合上线标准的。
但您猜我看到了什么?
Form、Map、Action Bar三个区域各自一个小Spin,整体有个大Spin,可以透过大Spin的透明遮罩层看到下面的小spin们反复交替进行,以及大Spin自己也时不时的闪现一下...
Spin为什么会闪现?回到需求当中来:
地图上点、线的绘制依赖了多个数据源
- 轨迹点、轨迹线数据源
- KeyPoint数据源
- SubPoint数据源
- Type 1
- Type 2
这些接口一部分是并发请求,但也有个别的接口请求参数依赖于其他接口的返回值
以及,使用高德地图提供的API绘制点、线时,也共用了接口请求时的Spin。
还有诸多类似这样的代码:
setTimeout(() => {
loading = false
}, 2000)
对渲染流程管理混乱、对数据流向不了解、对自己代码不自信,故意延迟loading的结束时机,防止用户过早操作导致报错
const interval = setInterval(() => {
if(conditon) {
clearInterval(interval);
loading = false
}
}, 1000)
依赖第三方的内容加载,或将多个小loading合并为一个大loading
最终的结果就是让人一整个loading住...
而我做了哪些改变
首先,将单个loading覆盖的区域尽可能的缩小
举个例子,上边提到的Action Bar
,假设里边既有展示KeyPoint
信息的列表,又有展示所有SubPoint
信息的列表。在之前的处理方案中,Action Bar
区域只有一个整体的Spin
,所以整个区域loading的流程大概是:
%%{init: { 'theme': 'base', 'themeVariables': {
'cScale0': '#996666', 'cScaleLabel0': '#ffffff',
'cScale1': '#996633','cScaleLabel1': '#ffffff',
'cScale2': '#999999', 'cScaleLabel2': '#ffffff'
}}}%%
timeline
title Loading 状态
section 阶段一
show : request for KeyPoint data
hide : request success
section 阶段二
show : request for SubPoint type1 data
hide : request success
section 阶段三
show : request for SubPoint type2 data
hide : request success
而我则是把每个数据源对应的列表都单独分配了一个loading组件
。
聪明的看官老爷可能会问,同时存在多个Spin,不也很奇怪吗?
所以我选择了骨架屏Skeleton
作为loading组件:
受gif图的帧率影响,实际效果还是很丝滑的。(但使用骨架屏时也有一个注意的点:骨架屏的占位高度需要配置段落占位图行数来调整,避免loading结束时真实的渲染内容与骨架屏高度相差太大产生视差)
然后,在Map
区域用其他形式的提示代替传统的loading
与上边类似,Map
区域不仅同时用到了KeyPoint
、SubPoint
数据源,而且在绘制点、线时也有loading。并且也是一个整体的Spin
,你应该能想象出每次数据初始化时,Map
上闪来闪去的Spin
。。。
而地图
本身,是高德提供出来可以开箱即用
的组件,我们所添加的点、线只是附加属性
,并不应该使用整体的Spin
遮罩阻止用户使用地图的其他功能。在某些附加属性
成功添加之前,用户只需要知道与之相关的功能是不可用
状态即可。
我的方案是:图例化
提供一个loading-box
,里边展示了每个数据源的加载状态
为了不遮挡地图,loading-box
不是始终展示的,基础显示逻辑是:
- watch监听
loadings
数组 - 只要有一个数据源loading中,则显示。
- 全部数据源都不在loading中,则debounce
n
秒后隐藏。
显示动作是实时
的,只要有一个数据源在loading中,就应该立刻让用户感知到。
而隐藏动作如果也是实时的,loading-box
的显隐切换会比较频繁,显得很突兀。
- 如果使用
setTimeout
做延时,期望是发出hide
指令n
秒后执行,但无法保证n
秒后没有新的loading正在进行,导致显隐切换逻辑紊乱。 - 如果使用
throttle
做延时,导致的问题与setTimeout
相同,只是发生概率会小一些。 - 相比之下
debounce
最适合做这个场景的解决方案。
再结合上边提到的hook写法,把loading状态
也放进去,方便loading-box
使用:
// example
import { createGlobalState } from "@vueuse/core";
import { ref, createApp } from "vue";
export const useCustomPoints = createGlobalState(() => {
const pointData = ref([]);
const pointLoading = ref(false);
const removePointsCb = [];
const getPoint = async () => {
pointLoading.value = true;
const data = await requestPointData();
pointLoading.value = false;
pointData.value = data;
}
const setPoint = () => {
// ...
}
const removePoint = () => {
// ...
};
return {
pointData,
pointLoading,
getPoint,
setPoint,
removePoint
}
})
// loading-box.vue
<script setup>
import { watch } from 'vue';
import { useCustomPoints } from 'useCustomPoints.js';
const { pointLoading } = useCustomPoints();
watch([pointLoading, /* and other loadings */], () => {
// do loading-box show/hide logic
})
script>
应用了上述loading相关的优化后,虽然跟核心业务逻辑相关的代码改动几乎为0
,但用户的体验却有相当大的提升,究其原因:
在老版本的实现中,因为全屏Spin
的存在,任何一项页面准备工作完成前,页面都无可交互区域
;拆分loading后,把一大部分无可交互区域
的时间变成了局部可交互区域
的时间,甚至在Map
模块替换了loading的形式,完全避免了Spin
遮罩层这种阻隔用户的效果。加上Spin
动画本身的耗时、显示/隐藏Spin
的耗时,积少成多,产生质变。
可以看到,在局部loading耗时完全一样的情况下,老版本中:
无可交互区域时间 = 全屏Spin时间 = 局部loading的最大时间
而在新版本中:
无可交互区域时间 = 几个all loading片段的时间之和
而这,也是一些复杂应用做体验优化的思路之一。
结语
okok,先写到这,毕竟马上就要下班了。
没什么高大上理论也没什么八股文,只是一个从业多年一事无成小前端在重构需求时的一些感想~~
还是那句话,欢迎真诚交流,但如果你来抬杠?阿,对对对~ 你说的都对~
彩蛋
文章标题来自需求上线后,产品经理的真实评价
来源:juejin.cn/post/7371633297153687606