一、背景
地图空间可视化作为高德智慧交通前端业务中最重要的功能之一,承担着城市交通大脑、全境智能大屏等业务中大量的地图渲染需求。作为向用户展示交通数据的窗口,我们需要展现省、市、区、商圈、自定义区域多种场景,包括所有交通事件、拥堵指数、辖区等多种维度的数据,呈现着数据量大、元素种类多、逻辑展现重等特点。
JSAPI作为高德地图前端战线的引擎,涵盖着渲染地图、展示覆盖物等底层能力,但对于行业应用领域的开发来说,存在着开发难度大、适配成本高、纯原生JS实现与主流框架结合不紧密,无行业图层能力的问题。
基于以上原因,我们设计了具有适用于垂直行业的、可复用、可扩展、二次开发简单等特点的地图SDK,已经成为智慧交通地图空间可视化能力的首选方案。
二、方案设计
整体框架设计方案
高德智慧交通团队经过大量项目实践和思考,以交通行业为切入点,面向整个前端行业地图设计了一套地图空间可视化开发的SDK,整体功能架构设计如下图所示:
(1) MapContainer是整个SDK的基座,用于承载地图引擎,装载在其上渲染的覆盖物图层,加载所需要的框架模块,在整个架构中起到中流砥柱的作用。
(2) 配置控制器负责传入用户配置,包括地图应用key配置、加载可选功能配置、样式配置等,在用户变更这些配置后,它会把更新后的配置信息传递到流程中的其他模块中。
(3) 接受数据的工作由SourceLoader完成,设计了一套SDK内部使用的标准化的数据格式,Loader负责将用户传入的不同类型的数据(已经支持GeoJSON、WKT、数据列表等形式)转化成专用标准格式数据,分发到地图容器及各图层中。
(4) 为了支持不同的主流应用框架,将框架适配层单独拆分,由它将主要模块封装成Vue、React等框架兼容的组件形式,实现多框架扩展。
(5) 地图API调用有着严格的顺序限制,而封装框架对于图层各个生命周期的触发是异步的,乱序的,存在无法保证流程一致性的问题,为了应对这种情况我们在SDK中引入了事件队列机制。
状态驱动方案的实现
1.生命周期设计
地图JS API的调用逻辑与原生Javascript一样,是命令式调用设计,像下面这样:
// 创建地图 const map = new AMap.Map(options); // 添加覆盖物 const marker = new AMap.Marker(markerOptions); map.add(marker); // 修改覆盖物属性 marker.setContext(newContext); // 移除覆盖物 map.remove(marker); map.destroy();
这样的API调用方式,与上层项目的开发框架,如Vue、React等不匹配,如果在一个状态驱动的框架下充斥着大量命令式驱动的代码,会大幅度降低这个项目的可维护性、可扩展性。
为了更好地支撑开发的需要,所有业务图层抽象出了一套完整的生命周期流程。不同的图层,渲染逻辑的步骤不完全相同,各图层的额外能力,如支持交互事件的能力、动画能力也不尽相同,但都可以囊括在这一套生命周期结构内。
SDK图层组件生命周期定义如下:
(1)地图注册
在地图底座加载完毕后,会通知各个图层的RegisterMap流程,这是图层组件生命周期的第一步,图层中包含的所有元素都在这之后才会开始渲染。
(2)中间层加载
部分类型的元素需要分组批量加载,因此在渲染这些元素之前,需要先将对应的组图层加载出来。因此,我们设计了组图层相关的生命周期,相关逻辑只需要在beforeAppendGroup,appendGroup,afterAppendGroup这些流程中实现即可。
(3)元素加载
beforeAppendComponent,appendComponent,afterAppendComponent,这些是元素图层中最重要的流程,用于实现图层元素加载的主逻辑。
其中,对于一些元素需要有前置检查,有数据校验,可以把相应的检查逻辑放入beforeAppend中;有的元素需要注册交互事件,或者需要有添加动画scheme能力,这部分的实现逻辑可以放到afterAppend流程中。
与之对应的,还有元素的销毁流程,beforeRemoveComponent,removeComponent,afterRemoveComponent。如果元素绑定了交互事件,将会在beforeRemove的时候解绑;如果元素注册了动画或者周期调用,也会在beforeRemove的时候销毁周期timer。
(4)元素更新
shoudlUpdate,diff,updateComponent,用于实现组件数据动态更新后图层元素的diff、更新过程。
其中为了防止源数据中只有一小部分修改导致整个图层全部重绘的情况,我们在其中加入了diff的算法,通过各图层的校验数据key的方法,筛选出变更前后一致的数据项,只重绘不同的数据,大大提升了渲染流程的效率。
2.插件的实现
不同图层之间存在共同处理流程和共同的属性,对此,我们设计了各种可复用的内置插件,供各图层根据自身特性组合使用。
例如,有实现定时刷新效果的scheme插件,实现动画效果的animate插件,实现注册交互事件的event插件等。这些插件的设计,必须遵守组件生命周期的规范,插件功能的实现逻辑,也全部以注册上述的生命周期函数的方式完成。
这些生命周期需要与主流框架的生命周期设计适配。以目前我们项目中正在使用的Vue框架举例,Vue也有其自己的组件生命周期,它的设计基本能够与我们的周期函数相匹配。因此,针对Vue的适配过程其实并不怎么难:
Vue自身有一套不同层级的组件之间的加载控制流程,父子层级、兄弟层级之间的组件有着严格的触发顺序。例如,父组件的beforeCreate总是在子组件beforeCreate之前触发,而父组件的mounted又总是在子组件mounted之后才会响应,这与我们的多层级图层之间想要的触发顺序相符。因此,SDK图层的各生命周期总能在Vue中找到与之对应的触发时间点。
经过封装后,用SDK实现的地图模块在项目中生成的组件树结构如下:
3.异步流程的一致性设计
我们使用的底层地图引擎,对于流程逻辑的顺序有着严格的要求:
(1)地图底座的创建须在所有其他流程之前。
(2)Loca、L7底座的初始化须在地图底座创建完成之后。
(3)地图元素需要在地图底座加载完成后才能够开始加载,销毁也需要在地图底座销毁之前完成。
(4)需要确保状态与结果的一致性,如果在短时间内触发了大量的更新数据的操作,即使底层引擎处理需要很长的时间,也要保证最终的展示结果与更新的顺序完全一致。
不幸的是,虽然主流的框架有完善的生命周期管理机制,能够确保各个流程的执行顺序不出差错,但这些流程之间都是异步的、并发的,而绘图引擎在处理这些渲染指令时,会由于处理时长的不确定性,导致各指令返回的顺序有所变化,这可能会导致下面的情况出现:
- 地图容器的加载时间过长,导致加载后续元素时,地图仍没有渲染完成而出错;
- 在短时间内对同一份数据进行变更,如果引擎处理第一次变更花的时间比后一次更长,就会导致第一次更新的结果渲染出来时,会把更早完成的第二次渲染结果覆盖掉。
为了避免上述情况,我们在SDK中实现了事件队列控制器,处理顺序问题:
(1)所有图层组件中需要调用底座引擎的事件,例如append component,remove component等,不会直接调用底座的相关接口,而是在队列控制器中push一个对应类型的事件。
(2)队列控制器中的所有事件类型,全部封装成同步方法实现。由控制器收集所有涂层的调用消息,单线程逐一消费。
(3)在控制器中写入特殊的控制逻辑,地图基座的加载需要在其他图层加载之前,则把基座加载的事件的响应优先级设置为最高。
(4)引入筛选机制,针对队列中存在同一图层的互逆操作,如短时间内加载一份数据,之后又remove掉,由于这一对操作不会对当前的结果有任何影响,因此这一对操作将会被过滤逻辑删除,达到优化渲染性能的效果。
4.地图控制指令的优化
地图底座支持用户通过调用相关方法控制地图展示的视野,SDK在这种设计上加以优化,通过在地图底座组件上配置相应的属性状态,来实现定位到选定元素、定位到整个辖区范围、定位到特定地点及缩放级别等多种视野类型。
同时,地图的其他控制方法,例如设置周边避让区域、设置光标形状、设置自定义地图样式等方法,也全部改为传递props属性的方式实现。
三、其他优化
地图实例缓存
就我们使用的底层地图引擎来说,创建、销毁一个地图底座需要消耗大量的性能,而有时候这样的操作是可以避免的。有时候我们只是切换了一个页面路由,图面的上展示物并不需要有什么变化,但仍然会触发地图底座的销毁与重新生成。这个流程是多余的。
为了优化这个问题,我们设计了可以容纳2个底座实例的缓存容器。每次在执行销毁地图的命令时,我们并不会真正的销毁它,而是把它隐藏掉并存入缓存中。下次需要创建实例时,直接在缓存中找到符合要求的实例拿来用。
多实例环境隔离
随着下游业务项目的功能迭代,产品提出了在同一个页面内展示多个SDK底座实例的要求。对此,我们对SDK进行了一系列的优化:
- 改造消息队列控制器,原来的单线程模式已经不再适用,现在已可以支持实例隔离,不同实例之间独享事件队列和流程控制逻辑。
- 优化图层与底座的从属判定机制,在多个底座之间存在父子关系的情况下,能够让图层在最合适的底座上展现。
GL渲染Context没有正确GC回收导致的崩溃问题
在为L7编写加载器时,遇到了内存泄露的问题:如果在项目中使用了L7相关图层,销毁时L7使用的WebGLRenderingContext资源不会正确释放,反复创建销毁几次后,浏览器会因为内部的renderingContext资源不足而渲染崩溃。
分析L7源码后发现,L7为了实现与地图同步resize,在地图容器DOM上注册了一个resize事件,并把这个事件的处理函数绑定在了这个容器DOM的一个叫__resize__trigger__的属性上。
如果开发者在项目中使用Vue作为前端框架,Vue的模板更新机制会引起DOM的重绘,在一次数据变更之后,它会把原来的容器DOM销毁,替换为一个新的。
但由于注册的事件函数中含有DOM对象引用的缘故,虽然旧的DOM对象已经从DOM tree上移除,但并不会被GC回收,而是仍然被__resize__trigger__这个函数引用着,同时由于新生成的DOM不具有该属性,导致在L7引擎销毁的时候,由于L7找不到这个函数,resize事件解绑也会失败。在开发者触发多次切换引擎操作之后,有大量的未被实际引用的容器DOM无法被回收,而这些DOM中又都包含着webGL Canvas对象,导致浏览器的GLRendering资源不足的问题出现。
解决方法:我们无法修改L7的源码,因此也无法更改它注册、解绑事件的逻辑。但我们可以通过在每次Vue刷新之前,对即将被移除的canvas的width和height设置为0,以此来直接释放renderingContext资源,实测有效。
最佳解决方案:目前我们已经有自行实现的3D图形类,且也扩展了对Loca等其他可视化库的支持,可以摆脱对单一库的依赖,实现相同的能力。
四、多维数据比对
经过高德智慧交通大量的项目实践和数据比对,充分证明了地图空间可视化SDK开发的必要性,业务价值和技术价值都经历了项目的考验。以高德交通大脑和全境智能大屏的数据比对可以得到使用SDK之前和之后的数据比较:
项目落地效果:
使用SDK后的项目开发代码:
<template> <!-- 地图底座 --> <CommonMap :center="center" :zoom="zoom" :city="fitViewCity" view-mode="3D" :map-style="mapStyle" > <!-- 场景1 --> <TrafficScene> <!-- 地图元素层 --> <TrafficPointLayer :list="trafficPointList" /> <!-- 带有事件监听的地图元素层 --> <TrafficRoadMarkerLayer :list="trafficRoadList" @click="handleTrafficRoadMarkerClick" /> </TrafficScene> <!-- 场景2 --> <PublishScene> <PublishMarkerLayer :list="publishList" @mouseover="handlePublishMouseOver" @mouseout="handlePublishMouseOut" @click="handlePublishClick" /> </PublishScene> <!-- 可视化场景 --> <VisualizationScene use="amap-loca"> <!-- 可视化数据层 --> <TrafficRoadLineLayer :list="trafficRoadList" /> </VisualizationScene> </CommonMap> </template>
五、展望
经过高德智慧交通大量项目的实践,SDK的建设已经趋于成熟,其开发简单、稳定性高、性能好的特点可以很好地降低开发者使用高德开放平台JSAPI来开发地图空间可视化项目的成本。我们未来会以开发者官网的形式对外输出,更好地服务于开发者。
招聘
阿里巴巴高德地图技术中心长期招聘Java、Golang、Python、Android、iOS 前端资深工程师和技术专家,职位地点:北京。欢迎投递简历到gdtech@alibaba-inc.com,邮件主题为:姓名-应聘团队-应聘方向。