注册
web

产品经理:为什么你做的地图比以前丝滑了许多?

从业多年第一次接触地图相关的需求,开发过程中产生了一些思考,遂记录下来,欢迎讨论

Vue3 + 高德地图 JS API 2.0 + 高德地图 AMapUI组件库

近两年前端大家是真的不好混,在职的人呢被极限压榨,待业的人呢投简历都是【未读不回】。

1.gif

照常理来说,地图相关的需求都是由组内的地图大佬负责的,但眼瞅着公司里前端同学越来越少,这“泼天的富贵”终于有一天也落到了我头上。

需求的内容倒是很简单:要在地图上绘制一些轨迹点和一条轨迹线,以及一个目标点KeyPoint,让使用者来审查轨迹线是否经过KeyPoint,以及系统中记录KeyPoint的信息是否正确。当轨迹未经过 或 KeyPoint信息不正确时,会再提供一些辅助点SubPoint供用户选择,替换掉KeyPoint。(轨迹点也属于一种SubPoint

本着能CV就不手写的原则,我打开了项目代码(Vue2)寻找之前类似的地图需求,看看能不能套用一下然后快速下班,结果我看到了若干个大几千行的文件,以及这样的渲染效果(轨迹点上的箭头表示当前移动的方向):

1.png

2.png

大哥喂,咱就是说,方向盘打不正的话要抓紧去修,上路是要出事故的 3.jpg

得,言归正传,且不说那加起来几万行的代码我能不能捋顺喽,就是这个效果,干脆我还是用Vue3重新实现一下吧。

别忘了,前端的老本行是什么


业务的关注点

开始之前,我们先思考一个问题:业务的关注点是什么?

想明白了这个问题,我们在设计地图样式以及一些交互细节时,才能有更好的针对性。

(让我看看有多少人是默认样式+内置组件一把梭的)

image.png

ok那既然涉及到了地图,归根结底我们的关注点无非是这三方面:

  • 线
  • 区域

如果按照关注点的归属粗略的分为两类:外部添加的地图自身的

当业务更关注外部添加的元素时(如maker、轨迹),随着地图缩放、地形改变、POI显隐,我们添加的元素是否始终有一个比较醒目的显示效果?

当业务更关注地图自身的元素时(如兴趣点),对于POI的 pick 动作,是否贴合业务流程?是否足够智能与便捷?(可参考高德自己的效果)

这里针对第一类推荐两个初始化地图的可选配置项:

  1. features:地图显示要素(查看效果
  2. mapStyle:地图主题(查看效果

相信很多人可能都没关注过这两个配置项,而这两个东西组合起来使用,不仅能使你添加的外部元素始终处于一个高醒目的level,也可以与你项目本身的风格主题更搭,如何抉择,诸君自行思量。

(浅浅吐槽一下,高德提供的功能和配置项非常丰富,但文档真的是一言难尽。。。一样的功能在不同的地方都有文档,有些内容还不一致)

4.png

选择画点的方法

当你大概明白自己要做什么样的地图之后,让我们稍微进入一点正题:怎么选择合理的画点方法?

高德提供了哪些画点的方法呢?

  • JS API
    1. 默认点标记Marker
    2. 圆形标记CircleMarker
    3. 灵活点标记ElasticMarker
    4. 海量标注LabelMarker:需要维护图层、维护避让等级、自定义样式实现起来比较麻烦
    5. 海量点标记MassMarker:无法显示文字label
    6. 点聚合
      • 按距离聚合MarkerCluster:需要维护权重
      • 按索引聚合IndexCluster:需要维护索引规则
  • JS API UI组件库
    1. 简单标注SimpleMarker
    2. 字体图标标注AwesomeMarker
    3. 矢量标注SvgMarker
    4. 海量点PointSimplifier:可使用Canvas

至于像文本标记、折线、多边形等等一些通过某些黑科技实现类似点标记的方法(GPT说的),和Native端的画点方法,不在本文的讨论范围中。


美国五星上将麦克阿瑟曾说过,一切抛开实际背景去讨论问题的行为都是耍流氓。

画点的方法找到了很多,那我们要画什么样的点呢?

1. 从数量上看

动辄上万

为什么我要先看数量呢,因为可自定义样式的画点方法很多,但是要支持大数量级渲染且性能良好,就把上边一多半方法给pass掉了。

还剩下这些可供选择:海量标注LabelMarker海量点标记MassMarker按距离聚合MarkerCluster按索引聚合IndexCluster海量点PointSimplifier

2. 从样式上看

需要自定义。从上边的截图中可以看出,点的形状为圆形,黑边黄底,中心有个箭头,且整体随着当前运动方向有一个rotate deg

上述画点方法至少都支持(图片 或 HTML String 或 CSS Style)中的一种方式,而这三种方式理论上也都能实现我们想要的效果,所以下一个。

3. 特性

test.gif

虽然我们需要关注轨迹点,但并不是所有状态下都需要。比如在地图的缩放等级很小时(看到的是省、国家级别),并不需要把每一个轨迹点都展示出来。所以可以看到,之前的实现效果中,放大缩小都会重新适应尺寸,并且临近的点有自动合并的效果。

海量标注LabelMarker海量点标记MassMarker退出了游戏,他俩是全量绘制并且没有外部接入的话点是始终展示的。

至此,只有按距离聚合MarkerCluster按索引聚合IndexCluster海量点PointSimplifier三者进入了决赛圈。

现在来综合对比一下这三种方法:

方法1w+点渲染性能自定义样式合并逻辑
按距离聚合MarkerCluster渲染迅速,操作不卡顿HTML String或图片距离+权重就近合并
按索引聚合IndexCluster渲染迅速,操作不卡顿HTML String或图片距离+索引分组合并
海量点PointSimplifier渲染迅速,操作不卡顿Canvas或图片TopN

渲染方面在1w+点的竞赛中大家表现得都不错,官网示例中心可以看到,这里不再赘述。

自定义样式则有三种途径,图片、HTML字符串和新出现的Canvas。图片和Canvas比较简单,我们先讲一讲这个HTML字符串,也就是原生的HTML。

假如你用了某个现代化的前端框架在开发你的系统,用到了高德地图,并且想画一些漂漂亮亮的点在你需要标注的地方。在翻阅了文档之后,发现似乎直接传入HTML字符串这种方法是最快的,于是你开开心心的输入了一个

My Marker
试试水,接着,保存、等待hot reload,并把期待的目光投向了屏幕...

353ad3a27c3ac95c86c30e66f1b4f15.png

好家伙,这小Marker不仔细看,还真有点找不到呢...你决定继续添加一些样式

2M2we.gif

之后代码可能逐渐变成了这样...

微信图片_20240514153858.png

你:

WzgGg.png

把手指从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)

打印一下:

223.png

使用:

// 以普通点标记举例
const marker = new Marker({
content: div,
// ...other configs
});

效果图就不放啦,有几个注意的点要提一下:

  1. 最重要的放在最前边:如果你的点在整个页面的生命周期内仅会绘制一次,那你可以跳过这一条。否则一定要记得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
}
})
  1. 可以通过createApp的第二个参数传递props进去,这些props是响应式的
  2. 新创建的Vue实例与你项目自身的实例不共享全局的配置,比如路由组件Store等,需要单独配置
  3. Vue2以及其他的一些框架,实现思路类似

好了,言归正传。

看起来似乎三种方法都可以实现需求,但是仔细翻看点聚合方法的文档,发现使用图片自定义点时没有提供旋转的配置,也就是说我们可能需要准备n张图片(取决于你想实现角度渲染的精确度),不,这太不优雅了。而如果使用原生HTML去自定义,要么接受丑炸的效果(纯手工css),要么面临着卡顿的风险(大量的app实例)

没办法,只好被(xin)迫(ran)接受用海量点PointSimplifier的Canvas去做了~毕竟能用Canvas画就约等于能画一切嘛~~

抱着视死如归的心情去翻了一下JS API UI组件库的海量点PointSimplifiercanvas绘制function文档,发现了一句了不得的话:

微信图片_20240514173923.png

划重点:通常只是描绘路径尽量不要fill或者stroke引擎自己一次性

翻译:该函数通常只是描绘路径,但是也能描绘形状。尽量不要fill或者stroke,除非你能搞明白我们的描绘机制。所有点的路径描绘完成后,引擎自己会在尾部调用fill以及stroke,一次性绘出所有路径,所以你要注意尾部的这次操作,避免冲突。

三个字总结海量点PointSimplifier的描绘机制就是:连笔画

不是每个点都创建一个新的Canvas画布,绘制完成后立即渲染;而是所有的点都共用一个Canvas画布,以当次你能绘制的区域坐标作为参数,重复绘制n(点的数量)次,最后一把全渲染出来。

微信图片_20240515171523.png

微信图片_20240516093003.png

明白了这个,我们在书写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();
},
},
};
},
}

来一个无旋转时的笔触顺序动图,我尽力了6j4l.png

test.gif

画完之后,看一下对比效果:

0200939e10cfc011c2bfe0ebcdce38cd.jpg


108f6ea188e1fd9b9955668f982f34a7.jpg


195311026be32a5565b40ac63c8ad836.jpg


b6901cbdacd10123b24904f046b4a221.jpg

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 : []);
})

效果如下:

test.gif

控制显示隐藏没有用自带的show()hide()方法,而是选择直接重设数据源,是因为:海量点PointSimplifiershow状态下时对地图进行缩放,会自动重绘适应尺寸;hide状态下则不会。从show变为hide时,会保存当前zoom下点的尺寸,供下次hideshow时用。如果地图缩放的太快,当前的zoom与上次保存尺寸时的zoom跨度太大,可能会导致点位不匹配现象。


选择画轨迹的方法

画线的选择过程就简单了很多,之前需求中是用折线Polyline实现的,画出来的效果总感觉差点意思,所以就去翻了翻高德的文档,共找到常规画线方法3种:

  • JS API
    1. 折线Polyline
    2. 贝塞尔曲线BesizerCurve
  • JS API UI组件库
    1. 轨迹展示PathSimplifier

基本上毫无疑问了嘛~我们本身就是要画轨迹,还有什么好选的~~ 必须用轨迹展示

不过这里还是分享一些对三种方法实际体验之后的感受:

  1. 折线Polyline:无法识别线上的点。如果轨迹数据没有经过噪点清除,画出来之后在细节处会有比较严重的锯齿。不过整体上感觉,倒也不是不能用~
  2. 贝塞尔曲线BesizerCurve:无法识别线上的点。但理论上是唯一可以绘制出完全符合真实运动轨迹的、贴合地图路线的方法了,代价也是相当的大——至少要在原本轨迹点的基础上额外维护n-1个控制点,放弃~~
  3. 轨迹展示PathSimplifier:性能好,相同数据量下的显示效果要比折线画出来的平滑许多。以及来自官网的优点罗列:
    • 使用Simplify.js进行点的简化处理,提高性能
    • 支持识别线上的点做信息展示
    • 内置巡航器
    • 样式配置更加丰富

实现过程比较简单,照着文档撸就行,可以对比下折线和轨迹展示两种方式,在拐角细节处的差异:

cef03e89fe8a876e52c34c8bc089c1c8.jpg

折线


564af32ac8c7a14950c7b2e532697084.jpg

轨迹展示


小tips: 适当增加线宽lineWidth可以有效的缓解锯齿现象


Loading的区域与时机

当我第一次打开上文提到的老版本地图页面时,除了渲染效果不够理想外,最大的一个感受就是:Loading太长

不是想像中那样常规的:打开页面,给一个满屏Spin等待加载各种数据、等待绘制点、线的动作,所有准备工作完成后,取消Spin允许用户开始操作。

咱就是说,像这样的交互逻辑,其实也没啥问题。毕竟谁还没个业务繁忙的时候,最简单最原始最暴力的满屏Spin虽然在体验上不尽如人意,但我觉得是符合上线标准的。

但您猜我看到了什么?

微信图片_20240520171349.png

Form、Map、Action Bar三个区域各自一个小Spin,整体有个大Spin,可以透过大Spin的透明遮罩层看到下面的小spin们反复交替进行,以及大Spin自己也时不时的闪现一下...

7D91.gif

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住...

pj7dW.png

而我做了哪些改变

首先,将单个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,不也很奇怪吗?

test.gif

所以我选择了骨架屏Skeleton作为loading组件:

test.gif

受gif图的帧率影响,实际效果还是很丝滑的。(但使用骨架屏时也有一个注意的点:骨架屏的占位高度需要配置段落占位图行数来调整,避免loading结束时真实的渲染内容与骨架屏高度相差太大产生视差)

然后,在Map区域用其他形式的提示代替传统的loading

与上边类似,Map区域不仅同时用到了KeyPointSubPoint数据源,而且在绘制点、线时也有loading。并且也是一个整体的Spin,你应该能想象出每次数据初始化时,Map上闪来闪去的Spin。。。

地图本身,是高德提供出来可以开箱即用的组件,我们所添加的点、线只是附加属性,并不应该使用整体的Spin遮罩阻止用户使用地图的其他功能。在某些附加属性成功添加之前,用户只需要知道与之相关的功能是不可用状态即可。

我的方案是:图例化提供一个loading-box,里边展示了每个数据源的加载状态

test.gif

为了不遮挡地图,loading-box不是始终展示的,基础显示逻辑是:

  1. watch监听loadings 数组
  2. 只要有一个数据源loading中,则显示。
  3. 全部数据源都不在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的耗时,积少成多,产生质变。

image.png

可以看到,在局部loading耗时完全一样的情况下,老版本中:

无可交互区域时间 = 全屏Spin时间 = 局部loading的最大时间

而在新版本中:

无可交互区域时间 = 几个all loading片段的时间之和

而这,也是一些复杂应用做体验优化的思路之一。


结语

okok,先写到这,毕竟马上就要下班了

1b006c53e15945a09253df289b6192cc~tplv-k3u1fbpfcp-jj-mark_3024_0_0_0_q75.awebp

没什么高大上理论也没什么八股文,只是一个从业多年一事无成小前端在重构需求时的一些感想~~

还是那句话,欢迎真诚交流,但如果你来抬杠?阿,对对对~ 你说的都对~


彩蛋

文章标题来自需求上线后,产品经理的真实评价

image.png


作者:Elecat
来源:juejin.cn/post/7371633297153687606

0 个评论

要回复文章请先登录注册