稀土掘金技术社区

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

❝

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

❞
❝

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

❞

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

Image

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

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

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

Image
Image

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

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

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


业务的关注点

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

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

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

Image

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

  • 点
  • 线
  • 区域

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

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

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

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

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

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

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

Image

选择画点的方法

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

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

  • JS API
    • 按距离聚合MarkerCluster:需要维护权重
    • 按索引聚合IndexCluster:需要维护索引规则
  1. 默认点标记Marker
  2. 圆形标记CircleMarker
  3. 灵活点标记ElasticMarker
  4. 海量标注LabelMarker:需要维护图层、维护避让等级、自定义样式实现起来比较麻烦
  5. 海量点标记MassMarker:无法显示文字label
  6. 点聚合
  • 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. 特性

    Image

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

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

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

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

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

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

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

    假如你用了某个现代化的前端框架在开发你的系统,用到了高德地图,并且想画一些漂漂亮亮的点在你需要标注的地方。在翻阅了文档之后,发现似乎直接传入HTML字符串这种方法是最快的,于是你开开心心的输入了一个<div>My Marker</div>试试水,接着,保存、等待hot reload,并把期待的目光投向了屏幕...

    Image

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

    Image

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

    Image

    你:

    Image

    把手指从ctrl C V三个键拿下来之后,你陷入了沉思:「我能不能用XXX UI组件库来自定义Marker?」

    答案当然是肯定的~下面请允许我用[email protected] + [email protected]来做个示范~~

    首先,他接收的是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)

    打印一下:

    Image
    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文档,发现了一句了不得的话:

    Image

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

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

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

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

    Image

    Image

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

    来一个无旋转时的笔触顺序动图,我尽力了Image

    Image

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

    Image

    前


    Image

    后


    Image

    前


    Image

    后

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

    效果如下:

    Image
    test.gif

    控制显示隐藏没有用自带的show()和hide()方法,而是选择直接重设数据源,是因为:海量点PointSimplifier在show状态下时对地图进行缩放,会自动重绘适应尺寸;hide状态下则不会。从show变为hide时,会保存当前zoom下点的尺寸,供下次hide到show时用。如果地图缩放的太快,当前的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进行点的简化处理,提高性能
    • 支持识别线上的点做信息展示
    • 内置巡航器
    • 样式配置更加丰富

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

    Image

    折线


    Image

    轨迹展示


    ❝

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

    ❞

    Loading的区域与时机

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

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

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

    但您猜我看到了什么?

    Image

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

    Image

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

    Image

    而我做了哪些改变

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

    Image

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

    Image

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

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

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

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

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

    Image

    为了不遮挡地图,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
    <template>
      // your template here
    </template>
    <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
    image.png

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

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

    而在新版本中:

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

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


    结语

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

    Image

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

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


    彩蛋

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

    Image