SVG的动态之美-搜狗地铁图重构散记

Posted 前端架构与工程

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了SVG的动态之美-搜狗地铁图重构散记相关的知识,希望对你有一定的参考价值。

 

搜狗地图发布了新版的移动端地铁图,改版初衷是为了用户交互体验的提升以及性能的改善。原版地铁图被用户吐槽最多的是pinch缩放不流畅、无过渡动画、拖拽边界不合理等等,大体上都是交互体验上的问题。实际上原版的问题不仅仅存在于交互体验上,源代码也是一团糟:

  • 无模块化概念;
  • 存在冗余逻辑和文件;
  • 滥用第三方库&工具;
  • UI的更新仍旧是直接操作DOM;
  • 构建&发布流程不规范。

以上问题其实跟业务以及技术选型无关,可以说是任何一个“历史悠久”的项目都难以避免的问题。针对以上问题的重构方案不是本文要阐述的核心,所以就一笔带过。如下:

  • 重构模块化架构;
  • 删除冗余逻辑和文件;
  • 规范并尽量减少第三方库&工具的使用;
  • 使用Vue作为View层框架,尽量减少直接操作DOM;
  • 规范构建&发布流程,完善工程体系。

本文重点讨论搜狗地铁图对SVG的使用和优化方案。在讨论技术细节之前,我们先说明一下为什么要使用SVG。

为什么使用SVG

不论是从业务类型还是操作方式的角度考虑,地铁图都可以被视为一种微型或者简易的地图。我们可以先回想一下手机地图的一些基本操作,举几个简单的例子:

  • 可以缩放地图查看微观或者宏观的内容;
  • 可以点击地图上的一个POI点展示其信息,同时此POI点居中;
  • 可以通过搜索查看某个地点的完整轮廓,同时地图缩放到适合展示此地点完整轮廓的等级。

以上几种操作的技术实现需要遵循以下几个基本原则:

  • 缩放后的地图不能展示模糊的内容,必须看上去是清晰的。也就是说,地图必须是“矢量的”[注];
  • 居中某一个点则必须知道此点的坐标信息,然后结合浏览器坐标体系和viewport尺寸计算出正确的展示内容;
  • 完整展示某个轮廓则必须知道此轮廓的尺寸以及坐标,然后结合浏览器坐标体系和viewport尺寸计算出正确的展示内容;

注:之所以将“矢量”加引号是因为地图的实现包括栅格瓦片和矢量瓦片两种不同的技术方案。顾名思义,矢量瓦片是真正意义上的矢量地图,由OpenGL或者WebGL实现;而由栅格瓦片实现的地图并不是矢量的,缩放时会看到明显的模糊效果,但是缩放动作完成后会展示对应等级的栅格图片,也就是说缩放后的内容是清晰的,只是缩放过程中存在模糊效果。随着WebGL的普及,栅格瓦片技术逐渐退出了历史舞台。

简单概括,地图必须是:

  • 矢量的;
  • 动态的。

即使是栅格瓦片地图,POI点也是动态绘制的,感兴趣的读者可以自行查阅相关信息。

地铁图同样如此,而Web展示矢量内容只有两种方案:WebGL和SVG。虽然WebGL更富有视觉表现力,但是地铁图业务的体量较小,并没有达到值得用WebGL实现的程度,所以SVG便成了唯一的选择。

旧版地铁图的核心问题

旧版的搜狗地铁图虽然也是使用SVG绘制UI,但是并没有将SVG的动态优势发挥出来,而是将其视为静态的图片。图1是旧版地铁的DOM结构:

蓝色框的svg是地铁图的UI内容,除了尺寸以外没有任何其他的属性。红色框是地铁图外层容器,可以看到所有的偏移、缩放等交互都是借由外层容器的transform实现。黑色框的各个DOM节点包括了定位、求路、信息气泡等内容,这些DOM往往需要跟随用户操作被改动,而且某些操作可能需要同时操作多个DOM。

接下来我们看看这样的DOM结构存在什么问题。

定位、求路、信息气泡等内容是与地铁图强耦合的,假设我选中了某个地铁站,如图2

红色框内的信息气泡对应到上图的container3节点,地铁底图对应container1节点。如果此时我们拖拽地铁图,底图和信息气泡都会随着手势而改变位置,那么就需要同时改变container1container3的位置。

我们把同样的问题带入到求路,如图3

我并没有画出每个UI对应的节点,因为实在是太多了。上图中包括了2个转乘节点、2个起终节点和3个气泡节点,拖拽过程中这7个DOM节点全部需要被操作。并且不仅仅是改写DOM属性那么简单,而是需要先获取每个节点的坐标然后再进行计算,而我们都知道,获取DOM的offset是非常消耗性能的。此外,求路状态下的地铁图必须缩放到完整展示求路路线的等级,那么就需要计算求路路线的轮廓尺寸,其中也会涉及到大量的计算和DOM操作。

其实拖拽是非常基本的操作,如果是缩放呢?抛开大量的计算和DOM操作不谈,从视觉上表现如图4所示:

为什么气泡和起终点等节点没有同比例缩放?因为这些节点不是矢量的SVG,缩放会失真。如果想得到“矢量”的缩放效果只能重新计算这些节点的尺寸,这样的代价太大了。所以我们不得不忍受这些问题。

总结以上的问题可以概括出两点:

  • 坐标和求路轮廓的获取非常消耗性能;
  • 部分UI不能缩放。

以上问题的症结可以归纳为:

  • 缩放和拖拽操作全部借由container1实现,坐标的获取只能借助于常规的DOM API;
  • DOM结构不合理,定位、求路、信息气泡等节点应该是矢量的,且应该被同步缩放。

简单来讲,旧版地铁图的核心问题是DOM结构不合理,并且没有把SVG的动态特性发挥出来。

重构方案

重构后的DOM结构如图5所示:

 

  • handler节点负责直接响应手势操作,拖拽、缩放等操作首先会改变handlertransform样式;
  • container节点是svg容器,负责以浏览器窗口为参考将地铁图居中;
  • view节点是所有与地铁图展示相关内容的容器,包括底图、定位、气泡、求路等等等等。同时,手势操作最终会修改view的transform属性,以实现地铁图本身的缩放。

以上说明可能有些难以理解,我们用具象的图形加以说明。分层的结构大致如图6所示,从外到里分别是handler/container/view:

此时如果用户进行了手势操作,以pan-拖动为例:

  1. panstart事件触发后记录拖动的初始坐标,不影响分层结构中的任何一层,也就是说不改变任何一层的任何属性或样式;
  2. panmove事件频繁触发,即拖动过程中,映射为handler层transform的改动,container和View无任何变化。如下图7

  3. pancancel/panend事件触发后修正handler合理的偏移量(详情请阅读下文的边界控制),同时将修正后的transform属性值换算为view的transform,最后将handler的transform归零。如图8

 

代码如下:

 1 /**
 2    * @constant PrevOffset 前一次拖拽的坐标偏移量
 3    * @type {Object}
 4    */
 5   const PrevOffset = {
 6     x: undefined,
 7     y: undefined
 8   };
 9 
10   EventRuntime.on(\'panstart panmove pancancel panend\', e => {
11     e.preventDefault();
12     e.srcEvent.stopPropagation();
13     // panstart事件记录初始坐标
14     if (e.type === \'panstart\') {
15       PrevOffset.x = e.deltaX;
16       PrevOffset.y = e.deltaY;
17     } else if (e.type === \'panmove\') {
18       // handler位移设置增量
19       subway.setTranslate(e.deltaX - PrevOffset.x, e.deltaY - PrevOffset.y);
20       PrevOffset.x = e.deltaX;
21       PrevOffset.y = e.deltaY;
22     } else {
23       // 拖拽结束后换算hander和view的transform,同时修正合理偏移量
24       subway.adjustTransform(\'translate\');
25     }
26   });

分层结构中三者的作用可以简单概括为:

  • handler负责展示用户操作进行中的动态地铁图;
  • container只是容器,一经设定不再改动;
  • view负责展示用户操作状态下的静态地铁图。

可能你会疑问为什么不直接改变view的transform?额外加一层handler的作用是什么?在回答这个问题之前我们不妨先思考一下如果直接改变view的transform来响应拖动和缩放会有哪些不足。

Handler - 缓动动画与GPU加速

动画是前端交互中的重点,为了提供顺畅的操作体验,最典型的优化动画方向是:

  • 使用缓动;
  • 优化性能。

缓动动画

搜狗地铁图有三种基本的操作: 
1) 点击某个站点,将此站点居中,期间有缓动动画如下图9
 

2) 拖动到地铁图边界后,拖动结束(即手指离开屏幕)后需要修正拖动边界,否则会停留在拖动结束的状态可能造成大面积空白。这种修正类似Safari IOS的橡皮筋效果。修正过程中有缓动动画如下图10

3) 与拖动类似,缩放同样有边界限制,否则会无限制的放大/缩小。修正缩放边界期间有缓动动画如下图11

 

GIF图片表现力有限,不能表现完美的效果。体验真实的效果请下载搜狗地图APP进入到地铁图查看。

回到最初的问题:如果直接改变view的transform如何实现缓动效果?

这里需要注明两个前提知识点:

  1. SVG的transform是一个属性,与CSS的transform是两个不同的概念,两者使用的坐标体系有一定差异;
  2. SVG没有类似CSS transition的属性,也就是说SVG没有原生支持过渡动画的功能。

关于SVG transform的详细知识可以参考理解SVG transform坐标变换

所以如果我们在view的transform上下功夫实现缓动动画的话,只能通过JS结合缓动公式和requestAnimationFrame计算每一帧的SVGtransform值,或者使用第三方现有的动画工具库,比如TweenJStransform的计算非常复杂,尤其是同时存在scaletransiton的场景下。既然CSS的transiton可以使用浏览器提供的缓动动画,那我们为什么不把复杂的工作交给浏览器呢?transiton作为偏移、缩放的缓动动画媒介必须搭配CSS的transform,但是我们不能直接通过view的style修改transform。原因有二:

  1. CSS的transform和SVG的transform不能等同;
  2. 我们需要借助SVG的transform进行边界控制(下文详述),也就是说偏移和缩放的效果最终需要换算为SVG的transform但在动画执行期间不能修改

那么我们便得出了handler存在必要性的证明之一,也就是优化动画的第一条:缓动。接下来我们尝试进一步优化动画的性能。

GPU加速

我们都知道CSS的3Dtransform可以强制启用GPU加速以优化动画的表现,自然会想到SVG可不可以使用GPU加速呢?很可惜,答案是否定的。SVG是一种表现2D矢量图形的技术,它在设计之初便没有考虑3D的场景,所以SVG并没有3Dtransform,也无法借助GPU对动画进行加速。

那么我们便得出了handler存在必要性的第二个证明:GPU加速

其实业内对于借助GPU加速动画的方案褒贬不一,即便是启用GPU加速也有方案的优劣。我们此次重构只是第一步,后续仍旧会不断探索进一步的优化方案。

transform-origin

SVG没有transform-origin概念,transform的原点永远都是自身的左上角,即(0,0)

大家可以想象一下在手机上用两根手指缩放地铁图的场景,我们需要知道地铁图应该以屏幕上的哪一点作为中心进行缩放。从技术角度来讲,我们需要知道两个触控点的中心位置坐标。不论是ios系统原生的gesture事件,还是通过touch事件模拟的pinch事件(如HammerJS)使用的都是浏览器坐标系,也就是CSS坐标系。

如果一定要把中心点坐标映射到SVG坐标系,则需要一定的计算量(下文详述)。在缩放操作过程中需要频繁地改变被缩放DOM的transform从而引起重绘(re-render),这期间浏览器本身就进行着大量计算,所以在应用程序层面应该尽可能减少计算量。

关于重绘和重排,可以参考浏览器的重绘与重排

这也是handler节点存在必要性的第三个证明:减轻计算量

有了handler节点的辅助,缩放操作进行中(请注意是进行中,不包括起始和结束时刻)唯一的计算便是handler的transform,无需将其转换为SVG的transform。当然,换算仍然是必须的,但是我们将其推迟到缩放操作结束之后进行,这样便可以在一次完整的操作流程中只进行一次换算工作,大大减少了总体的计算量。具体的换算公式下文详述。

Container - 地铁图居中

上文并没有过多的描述container节点,因为它的作用非常简单。container作为svg的容器,同时在初始化时以浏览器窗口为参考将地铁图居中。如下图12所示:

 

  • 灰色的部分为svg节点;
  • 白色的部分为地铁图线路的真实区域;
  • 中间的长方形为浏览器窗口,同时也是handler节点的尺寸。

container节点的高宽均为2000,决定这个数字的唯一原则是:只要比view节点的尺寸大即可。所以我们设置了一个比较大的值。container节点的尺寸会影响它自身的lefttop,上图中红色标注是container节点居中的偏移量:

1 Offset.x = (container.width - window.innerWidth)/2;
2 Offset.y = (container.height - window.innerHeight)/2;

那么container节点的CSS便是:

1 container.style.cssText = [
2   \'postion: absolute;\',
3   `left: -${Offset.x};`,
4   `top: -${Offset.y};`
5 ].join(\'\');

transform是应用到view节点,边界控制同样是以view节点的尺寸为计算因子。所以,在初始化之后container不再进行任何改动,它的作用至此便完全体现了。

transform是应用到view节点,边界控制同样是以view节点的尺寸为计算因子。所以,在初始化之后container不再进行任何改动,它的作用至此便完全体现了。

View - 静态展示与边界控制

CSS与SVG的transform换算

可能你会冒出这样一个疑问:handler使用的是CSS的坐标体系,那么它的transform要换算成SVG坐标的计算一定很复杂吧?这个问题的有两个难点:

  1. CSS与SVG坐标的差异性;
  2. SVG没有transform-origin的概念和功能,但是我们需要借助CSS的transform-origin计算缩放中心,这进一步复杂化了换算逻辑。
必要知识点
CSS与SVG坐标的差异性

如果SVG设置了viewBox属性,那么它所使用的坐标系便不同于CSS坐标系。此外,SVG的preserveAspectRatio也会影响坐标系的细节。这两个属性在实现SVG缩放时非常关键,但搜狗地铁图并没有借助viewBox实现缩放,而是将全部的展示交给了view节点的transform,一定程度上减轻了CSS和SVG坐标差异性造成的计算复杂度。同时,我们将preserveAspectRatio属性值设置为"xMinYMin meet",即强制宽高等比例缩放。

远于SVG坐标系的更多细节可以参考理解SVG坐标系和变换:视窗,viewBox和preserveAspectRatio

剩下的问题就是如何将CSS的transform-origin换算成SVG的transform了。

SVG的“transform-origin

SVG与CSStransform的相同点是:两者都是以自身为变换坐标系。但SVG的transform原点不能改变,永远都是自身的左上角,即(0,0)

那么SVG如何实现类似CSStransform-origin效果呢?

假设我想让SVG以点(50,30)为原点放大1.5倍,我需要按照下述顺序依次对SVG进行变换:translate(50 30) ->scale(1.5 1.5) -> translate(-50 -30)。先将SVG偏移到点(50,30);然后再将SVG放大1.5倍(请谨记SVGtransform的原点是自身的左上角);最后再将SVG反向偏移(50,30)。具体变换过程可以参考图13

 

更多技术细节请参考这篇文章

SVG的transform属性值为translate(50 30) scale(1.5 1.5) translate(-50 -30)。由于地铁图的操作频繁是,涉及到大量变换,所以我们用matrix表示。以上的transform属性值换算为matrix表示为matrix(1.5 0 0 1.5 ${(1-1.5)*50} ${(1-1.5)*30})

至此我们便总结出SVG以点(ox,oy)为原点进行缩放的transform计算公式:

transform = matrix(sx 0 0 sy (1-sx)*ox (1-sy)*oy)

接下来我们根据以上的前提知识点推导出具体的换算公式。

换算公式

为了更清晰地推算换算公式,我们假设在缩放地铁图之前已经有了一定的偏移量和缩放比例,如下图14

 

假设此时View节点的transform属性值为matrix(scale 0 0 scale dx dy),简化为:

  • View.scale - view节点的初始缩放值;
  • View.dx&View.dy - View节点的初始偏移量。

因为我们为SVG设置了preserveAspectRatio="xMinYMin meet",即强制宽高等比例缩放,所以scaleX = scaleY,我们统一使用scale表示。

同时我们将handler的样式设置为:

1 `transform: translate3d(${dx}, ${dy}, 0px) scale(${scale});`
2 `transform-origin: ${ox} ${oy} 0px;`

即:

  • Handler.dx&Handler.dy - handler节点的偏移量;
  • Handler.scale - handler节点的缩放值;
  • Handler.ox&Handler.oy - handler节点的transform-origin坐标。

需要特别注意的一点是,handler节点的transform我们并未使用matrix表示,而是直接用translate3dscale非matrix表示transform时的变换顺序非常重要,按照从左往右的顺序后面的变换是以前面的变换为基础。也就是说,handler节点的transform是先进行translate3d-偏移变换,然后在偏移之后的状态基础上再进行scale-缩放变换。

另外还有一个重要前提:目前版本我们将缩放和拖动操作割裂开,同一时间只能进行缩放或者拖动操作。也就是说,缩放操作只改变Handler.scale和Handler.ox&Handler.oy,拖动操作只改变Handler.dx&Handler.dy。后续版本会探索将两种操作耦合的可行性方案。

scale换算

接下来我们详细讲解一下scale的换算公式,大家请先仔细研究下图15所示的缩放状态

 

  • 白色区域内的黑色虚线框为View节点的初始化位置,也就是在用户进入页面后没有任何操作的状态;
  • 白色区域内的蓝色虚线框为上文我们假设的缩放之前的状态,假定此时View节点的transform属性值为matrix(scale 0 0 scale dx dy)
  • 白色区域内的红色虚线框为缩放1.2倍之后的View节点(大框)和Handler节点(小框)尺寸。请注意此时我们还未将Handler节点的transform换算为View节点,由于View是Handler的子节点,所以它继承了Handler的transform样式,被同比例缩放;
  • 黑色实线框代表浏览器窗口,灰色区域为Container节点,两者在缩放过程中均未改变。

此时对应的DOM状态如下图16所示

  • Handler节点以(50px,40px)为原点缩放了1.2倍;
  • 缩放之前View节点的初始transform="matrix(1.1 0 0 1.1 194 75)",即缩放了1.1倍,X轴偏移194,Y轴偏移75。

接下来要做的事情是吧Handler的transform以及transform-origin换算为SVG的transform,然后将Handler节点transformtransform-origin归零。换算公式如下:

1 View.scale = View.scale * Handler.scale;
2 View.dx = View.dx + (1 - Handler.scale)*(Handler.ox + Offset.x - View.dx);
3 View.dy = View.dy + (1 - Handler.scale)*(Handler.oy + Offset.y - View.dy);

 

公式的推导过程并不复杂,因为我们并没有改变SVG的Viewbox,所以其坐标系与CSS坐标系并无二致。所以只需要将场景代入CSS坐标系,同时将transform-origin设置为(0,0),在此前提下进行推导公式便非常简单了。

 

将CSS的transform-origin设置为’0,0’后,transform的规则与SVG的transform便完全一样了。如果你熟悉CSS的transform,SVG的transform便不会有任何问题。因为CSS的transform属性本身就是从SVG的transform借鉴而来,只是加入了transform-origin这个语法糖。

边界控制

顾名思义,边界控制的作用是限制地铁图的可操作边界,包括拖拽边界和缩放边界。拖拽边界指的是地铁图上下左右四个方向上的可拖动的最大距离。缩放边界指的是地铁图可被缩放的最大和最小比例。两种边界控制的具体的交互表现可参考上文“缓动动画”一节的图10和图11。

拖拽边界

从图12很容易得出初始的拖拽边界,请参考以下伪代码:

ViewBox <- 计算View的坐标和尺寸
Viewport <- 获取浏览器的尺寸
Offset <- 计算Container相对浏览器的偏移量

THEN
  往右拖动的最大距离MaxX = Offset.x - BBox.x
  往左拖动的最大距离MinX = ViewBox.width-(Offset.x - BBox.x + Viewport.width)
  往下拖动的最大距离MaxY = Offset.y - BBox.y
  往上拖动的最大距离MinY = ViewBox.height-(Offset.y - BBox.y + Viewport.height)

注意,因为拖拽的边界最终映射到translate上,所以左拖动边界和上拖动边界的值是上述伪代码所计算出来结果的相反数,即始终为负数或者0。

随后用户进行拖拽和缩放操作后,拖拽边界便随之动态变化。计算动态拖拽边界的时候需要考虑两点:

  1. 缩放中心点坐标,即transform-origin,是重要的计算因子;
  2. 左拖动边界始终为负数或者0,并且必须小于右拖动边界,上下拖动边界同理。

将以上规则带入计算,伪代码如下:

Viewport <- 获取浏览器的尺寸
TransformOrigin <- transform-origin的值
Scale <- 缩放比例
Translate <- 偏移量

THEN
  往右拖动的最大距离MaxX = Prev_MaxX*Scale + TransformOrigin.x*(Scale-1) - Translate.dx;
  往左拖动的最大距离MinX = Prev_MinX*Scale - (Viewport.width-TransformOrigin.x)*(Scale-1) - Translate.dx;
  往下拖动的最大距离MaxY = Prev_MaxY*Scale + TransformOrigin.y*(Scale-1) - Translate.dy;
  往上拖动的最大距离MinY = Prev_MinY*Scale - (Viewport.height-TransformOrigin.y)*(Scale-1) - Translate.dy;

THEN 修正
  MinX: MinX<MaxX?MinX:Math.min(0,MinX)
  MaxX: MaxX>MinX?MaxX:Math.max(1,MaxX)
  MinY: MinY<MaxY?MinY:Math.min(0,MinY)
  MaxY: MaxY>MinY?MaxY:Math.max(1,MaxY)

这些公式的推导过程说复杂也复杂,说简单其实也很简单。道理与上文的scale换算一样,因为SVG的viewBox没有改变,所以只需将SVG带入CSS坐标系即可迎刃而解。篇幅所限,具体的推导过程便不再赘述。

缩放边界

与拖拽边界不同的是,缩放边界是固定的,一经初始化便不会再改动。具体如何控制缩放的边界其实并没有统一的方案,不同的团队可能有不同的见解,比如高德和百度的地铁图最小缩放比例小仍然无法展示底图的全貌。搜狗地铁图在评审和开发过程中有过几次商讨,最终定下的方案是:

  • 最大缩放比例写死为1.5倍;
  • 最小缩放比例以完整展示当前城市的地铁全貌为准。

也就是说,不同城市地铁图的最小缩放比例是不同的,因为每个城市的地铁线路个数、长度均有所差异,需要动态计算。计算的方法很简单,唯一需要注意的是一定要将浏览器的宽高比作为计算的因子。请参考以下伪代码:

ViewBox <- 计算View的坐标和尺寸
Viewport <- 获取浏览器的尺寸
AspectRatioOfWindow <- 浏览器的宽高比

THEN
  最大缩放比例 = 1.5
  最小缩放比例 =  ViewBox.width/ViewBox.height < AspectRatioOfWindow ? Viewport.height/ViewBox.height : Viewport.width/ViewBox.width;

其实我个人觉得高德和百度的方案更佳,因为手机屏幕尺寸比较小,即使展示地铁全貌也看不清楚细节,索性不如将最小比例写死为一个能够看清楚细节的临界值。这样不仅能减少计算量,而且从整体交互上也比较人性化。但是胳膊拧不过大腿,最终还是信了PM的邪。。。

直接操作DOM更快

为什么要把这一条单拎出来讲,是想提醒一下大家千万不要一味的追求所谓的流行技术和框架。我曾经见过很多前端工程师在介绍React/Vue的优点时一定要唾弃直接操作DOM和jQuery/PrototypeJS等“老家伙们”。不可否认React/Vue确实很大程度上解放了生产力,但是并非所有的场景均适合使用它们,比如地铁图的手势操作。地铁图响应手势操作的过程中需要频繁的改变底图的transform,那么请大家思考以下两种方式哪个性能更好:

  • 使用Vue的v-bind:transform="transform";
  • 直接操作DOMthis.$refs.handler.cssText=transform

第二种实现是不是Vue的“反模式”?仁者见仁。但是从实际效果来看第二种具有绝对的性能优势,其背后的道理很简单。对于手势操作这种几乎每一帧都需要响应的场景来说,逻辑越少越好,而Vue在改变DOM之前需要处理一系列复杂的逻辑,与直接操作DOM相比,性能孰好孰坏显而易见。

Vue的动态绑定把DOM操作封装在框架内部,高内聚的框架让开发者无需关心具体实现,但是基本的原理仍然未脱离DOM这一核心因素。

数据优化

加载优化

旧版数据加载流程及问题

首先加载主逻辑文件index.js,然后index.js中的逻辑获取url的城市参数名称,随后异步加载对应城市的数据文件,加载完成后进行解析和渲染。如下图:

 

这种流程对于常规的web站点没有任何问题,因为常规的web网站所有城市共用一套代码,只能从参数区分城市名称。但是Hybrid地铁图使用的是离线包而不是web站点,每个城市均打包为对应名称的离线包,比如北京的源码被打包为beijing.zip。也就是说,每个城市的代码是互不影响的,这是优化的重要前提。

优化方案

针对离线包的构建流程中加入额外的功能,即把每个城市的数据js引用在构建阶段注入到index.html中。如下:

 

这样可以实现数据文件的同步加载,与旧版的对比节省了以下时间:

  • index.js从URL中获取城市名称的时间;
  • index.js创建引用源为城市数据文件script标签的时间,这属于耗时的DOM操作;
  • 异步加载数据文件的时间。

需要说明的是,虽然单纯加载数据文件,不论是同步还是异步方式,两者的时间完全一致。但是如果按照原本的异步加载流程,数据文件便无法利用浏览器http并行加载的优势,即使这个时间可能微乎其微。

解析优化

旧版数据解析流程及问题

历史原因,地铁数据被制备为XML格式的字符串,解析数据需要先将其转换为XML对象,然后再转换为JSON格式。且所有的解析工作均在客户端浏览器执行,如下:

优化方案

将数据的解析工作提前到源码构建阶段,客户端直接接触的是解析后的JSON格式数据,减少客户端负载和用户的等待时间。如下:

 

此外,旧版的解析数据中存在大量冗余的字段,本次重构将这些冗余字段删除,进一步减小了文件体积。

优化前后对比

以北京的地铁数据为例,分别对比优化前后的数据文件的体积以及解析所消耗的时间。

1> 文件体积

- XML JSON-未优化 JSON-优化
未压缩 145KB 288KB 149KB
压缩 30KB 58KB 31KB

结论:单纯从文件体积衡量,优化前后的差距几乎可以忽略。

2> 解析时间

设备信息:

  • 平台:Macbook
  • CPU:2.7 GHz Intel Core i5
  • 内存:8 GB 1867 MHz DDR3

模拟环境:Chrome

测试结果(取十次平均值):

设备性能 原始 慢4倍 慢6倍
解析时间-优化前 45.6ms 281.2ms 294.3ms
解析时间-优化后 0 0 0

结论:优化后无需解析,直接进行底图渲染。设备性能越差,优化前后的对比越明显

总结

技术栈本身并无好坏之分,优劣体现在与业务的契合度上。老版本搜狗地铁图的问题核心并非在于技术栈的不合理,甚至以当时开发第一版地铁图的时间节点来看,其技术栈算得上优秀。技术架构和实现方式上的混乱是造成老版本地铁图性能和交互问题的根本。

优化技术架构是重构的第一步,但完成架构的升级只算完成了一半。特殊的运行方式(离线包)决定了不能将地铁图等同为常规的Web站点,这种特殊性也提供了进一步优化的空间,这是重构工作的第二步。所以在本次地铁图重构项目过程中可以提炼出重构的两个基本点:

  1. 从技术架构的角度思考;
  2. 从业务特征的角度思考。

 

以上是关于SVG的动态之美-搜狗地铁图重构散记的主要内容,如果未能解决你的问题,请参考以下文章

动态替换另一个SVG AnimateMotion

设计模式之美——重构

设计模式之美——重构

代码之美——《重构》《代码整洁之道》

设计模式之美 精华总结 笔记

设计模式之美 设计原则与思想:规范与重构30 | 理论四:如何通过封装抽象模块化中间层等解耦代码?