Zhihao's Studio.

基于百度离线地图时空可视分析系统的实现与性能优化

Word count: 3,298 / Reading time: 13 min
2019/05/20 Share

前言

近期实现了一个时空可视分析系统的前端部分,帮助警方看到特定地区(主要为昆明)、特定时间段【时】、特定分局、派出所、街道【空】的各种类型警情统计;并提供对未来一段时间的警情预测,对警情高发区域进行重点部署管控。整体界面如下图所示。

界面截图(概念图)

实现这个系统和优化性能的过程中有了一些感悟和心得,在这篇博客中进行记录。

百度地图和百度离线地图

其实在2015年飞利浦实习时开发一款iOS跑步软件过程中有用过百度地图进行跑步路径绘制,但在web开发过程中还是第一次遇到GIS开发相关的任务,好在团队中有一个会写代码的宠物店老板,有丰富的离线地图开发经验,指导我完成了这次的开发任务。

可能大家对百度地图都不陌生,他可以支持地图样式定制化,各种风格的样式都可以进行配置,而且现在已经强大到你上传一幅图片,可以自动识别出你的风格进行识图配色,并导出为JSON文件。主要可配置的有:陆地、水系、陆地、文字描边、道路,这个功能可以让设计师也直接参与到地图风格的定型,减少工程师的工作量。

百度地图底层技术

大家可能注意到了,百度地图可以缩放到不同层级的,比如最大的层级是可以看到街道一级的,最小的层级是洲级别的,就百度地图而言,层级大约是0-18级。

地图的每一层又是可以左右挪动的,每一层都是由一张张底图组成的,他们的专业名称叫做瓦片。例如下图就是有9张瓦片组成的。

明白了上面两点,我们就可以用三个变量:瓦片等级(level)和瓦片坐标编号(X,Y)唯一确定瓦片,这也是离线抓取相应图片并放置到正确文件夹路径的关键所在,比如第一层级的最左上角的瓦片路径就是/0(level)/1(x)/1.png(y)。

当然,层级越高,我们看到的细节也就越多,例如用谷歌地图放大到最大层级就是可以看到你家的。这也意味着我们需要更多的瓦片来描述更高层级的地图,通常下一级的瓦片,是由上一级瓦片切割为4片组成的,就形成了下图的瓦片金字塔

百度离线地图瓦片的获取

由于我们系统需要部署在公安网内,不能访问外网,所以我们并不能实时获取百度地图的瓦片,只能提前将百度地图瓦片缓存下来,以上一节提到的方式进行保存。爬取百度离线地图,已经有很多人提供了代码甚至是exe文件,这里就不展开说了。这个过程是比较慢的,下载到16级,通常就需要三四天,因为碎片文件太多了,仅昆明周边,到15级大约有400万张图片了。

跟前线部署人员交流过程中发现,在文件传输和拷贝的过程中,也有文件碎片引起的速度慢的问题,这个问题后面想通过插external盘解决,毕竟让大家现场等三四天成本还是比较高的。

后面优化了爬虫的逻辑,每秒大概能下10M的图片,但很快百度就打电话过来问责了,说严重影响到了他们的服务了,然后就发现号和ip都被百度封了。到了第二天,百度直接加密了,获取的图片都是错误符EOF。

也就是说,自此之后,这种爬虫获取的方式将永久行不通了。百度离线地图获取的路被永远堵死了。

其他地图

其实不局限于百度地图,有很多其他选择,比如高德地图、谷歌地图等都是不错的选择,需要注意的是地图直接的经纬度标准不同,需要转换,否则会发生偏移甚至不可用。

例如我在做项目的时候拿到的是高德地图的数据,放到百度地图上,发生了明显的偏移。我使用nodejs写了一个读文件然后转换的逻辑,并写回文件。

转换前后轮廓对比(分局层面)

转换前后轮廓对比(派出所层面)

关于各大地图厂商的经纬度转换规则及实现,请参考这篇博客

项目总体架构

项目是使用的umi+dva作为整体框架,使用数据流的方式管理的,当时空选项发生改变时,dispatch相应的action,通过异步网络请求出发Effects然后流向Reducers并改变State,然后通过state去改变绑定的View值。

性能优化策略1:组件

作为React组件,并非每次轮询,组件都需要重新渲染一次的,通过重写shouldComponentUpdate方法可以判断是否真的需要刷新,这在状态比较多的时候,是很有效的性能提升手段。

性能优化策略2:地图层级的防抖

在监听地图Zoom的过程中,我发现会经常会发生抖动,造成了界面非常卡的现象。其实与防抖相对的还有节流,具体实现细节可以看JavaScript函数节流和函数防抖之间的区别

通过加入防抖机制,控制只有足够多空闲时间才会触发进入特定层级的事件,让界面流畅了许多。

性能优化策略3:预渲染栅格线

在我们的业务需求中,有个特殊的需求是要将地图切分为边长为500米的方格,用绿橙黄红四色展示这块区域内的警情发生频率。

由于这个需求是后面加的,我前面并没有绘制栅格线,因此选用了对百度地图更为友好的mapV。兄弟公司实现这个需求的时候选用的是高德地图+openLayer。后期又接到了任务让加上栅格,于是我进行了调研,想出了两种备选方案。

方案1是让后台将所有格子都返回,即使这里面没有警情,我们这边让他不填充,但是边界线也是会有的,这样实现需要的时间比较短,考虑到实际出现警情的区域比较稀疏,因此为了减少网络流量和后台工作量,由我这边产生,时间复杂度大约是O(NM)

方案二是引入openlayer,进行绘制,但是这样的话需要上手一下openlayer,而且配合百度地图离线版也是有一定的难度的,很可能会有坑(网上已经看到了不少人提问),但是时间复杂度可以优化到O(N+M)

出于效率考虑,我先选择了方案一,并实现了该想法,但是系统不出意料地卡了起来,用户体验非常差,首次绘制需要等待大约5秒,每次移动都会卡一秒,卡顿感非常明显。于是我引入了滑动窗口的概念,仅将当前视图范围内和周边一点点范围所需展示的进行渲染,沿着拖动窗口方向预渲染,优化过后,非常流畅。

但事情并不总是一直顺利,很快就遇到了栅格与绘制出的正方形对不上的问题(见下图),多次尝试仍有一点点的偏移量,最后我不得不人工干预,定位到栅格的四个顶点,进行绘制,这样才算是完美匹配了。只能说我有点完美主义+强迫症,喜欢做到完美。

图层:用隐藏代替重绘/用过滤代替加Layer实现内存的高效分配

最早实现的版本中,每次zoom到不同图层,我都是将Layer移除然后重新绘制,这样效率比较低,但在数据量不大的时候影响也确实在忍受范围之内。后面增加了对特定分局的过滤需求后发现如果还这么搞,就会非常麻烦。

所以通过对overlay增加Type和分局字段,控制当前需要过滤掉的分局和层级图层,实现内存的高效分配(不需要反复释放和生成图层)。

参考

  1. node-canvas实现百度地图个性化底图绘制
  2. http://cntchen.github.io/2016/05/09/国内主要地图瓦片坐标系定义及计算原理/
  3. 百度地图离线API及地图数据下载工具-尝鲜篇
  4. JavaScript函数节流和函数防抖之间的区别
  5. 高德地图和百度地图之间的坐标转换

爬虫代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
package main
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"math"
"net/http"
"os"
"os/exec"
"strconv"
"strings"
"sync"
"time"
)
// 当前时间
var nowTime string = time.Now().Format("20060102150405")
// 阻塞队列
var waitgroup sync.WaitGroup
// 地球半径
var R float64 = 6378137
func main() {
fmt.Println()
var mins, maxs, confirm, ok, fls string
fmt.Printf("输入最小、大层级(半角逗号隔开):")
fmt.Scanln(&fls)
fmt.Printf("输入最小经、纬度(半角逗号隔开):")
fmt.Scanln(&mins)
fmt.Printf("输入最大经、纬度(半角逗号隔开):")
fmt.Scanln(&maxs)
if fls == "" || mins == "" || maxs == "" {
fmt.Println("输入值为空!")
return
}
// 切割字符串
mina := strings.Split(mins, ",")
maxa := strings.Split(maxs, ",")
fla := strings.Split(fls, ",")
minLng, _ := strconv.ParseFloat(mina[0], 64)
minLat, _ := strconv.ParseFloat(mina[1], 64)
maxLng, _ := strconv.ParseFloat(maxa[0], 64)
maxLat, _ := strconv.ParseFloat(maxa[1], 64)
startZ, _ := strconv.Atoi(fla[0])
endZ, _ := strconv.Atoi(fla[1])
// 取得地图中心坐标经纬度
lngCen := (minLng + maxLng) / 2.0
latCen := (minLat + maxLat) / 2.0
fmt.Printf("数据输入正确,是否开始下载?(Y/n):")
fmt.Scanln(&confirm)
if confirm == "n" {
fmt.Println("程序退出!")
return
}
ColorPrintln("---------------------下载开始---------------------", 5)
// 开始执行时间
startTime := time.Now()
GetAllFloor(minLng, maxLng, minLat, maxLat, startZ, endZ)
// 计算执行耗时
allTime := time.Since(startTime)
fmt.Println(allTime)
// 复制预览文件
// _, _ = CopyFile("./demo.html", "./"+nowTime+"/index.html")
// 生成地图索引文件
MkIndex(lngCen, latCen, fla[0], fla[1])
fmt.Println("下载完成!")
fmt.Printf("是否预览地图?(Y/n):")
fmt.Scanln(&ok)
if ok == "n" {
fmt.Println("程序退出!")
return
} else {
cmd := exec.Command("cmd", "/C", "start ./"+nowTime+"/index.html")
cmd.Run()
}
}
// 获得所有层级瓦片图
func GetAllFloor(minLng float64, maxLng float64, minLat float64, maxLat float64, startZ int, endZ int) {
// 创建存储瓦片图总文件夹
os.MkdirAll("./"+nowTime+"/tiles", os.ModePerm)
for z := startZ; z <= endZ; z++ {
waitgroup.Add(1)
go GetOneFloor(minLng, maxLng, minLat, maxLat, z, nowTime)
fmt.Println(strconv.Itoa(z))
}
waitgroup.Wait()
}
// 获得一个层级瓦片图
func GetOneFloor(minLng float64, maxLng float64, minLat float64, maxLat float64, z int, nowTime string) {
url := "http://online1.map.bdimg.com/tile/?qt=tile&styles=pl&scaler=1&udt=20180810&z=" + strconv.Itoa(z)
minX, maxX, minY, maxY := GetBound(minLng, maxLng, minLat, maxLat, z)
var path string
var dir string
fdir := "./" + nowTime + "/tiles/" + strconv.Itoa(z) + "/"
os.Mkdir(fdir, os.ModePerm)
for i := minX; i <= maxX; i++ {
func(i int, minY int, maxY int) {
dir = fdir + strconv.Itoa(i)
os.Mkdir(dir, os.ModePerm)
for j := minY; j <= maxY; j++ {
path = url + "&x=" + strconv.Itoa(i) + "&y=" + strconv.Itoa(j)
resp, _ := http.Get(path)
body, _ := ioutil.ReadAll(resp.Body)
out, _ := os.Create(dir + "/" + strconv.Itoa(j) + ".png")
io.Copy(out, bytes.NewReader(body))
}
}(i, minY, maxY)
}
waitgroup.Done()
}
// 根据经纬度和层级转换瓦片图范围
func GetBound(minLng float64, maxLng float64, minLat float64, maxLat float64, z int) (minX int, maxX int, minY int, maxY int) {
minX = int(math.Floor(math.Pow(2.0, float64(z-26)) * (math.Pi * minLng * R / 180.0)))
maxX = int(math.Floor(math.Pow(2.0, float64(z-26)) * (math.Pi * maxLng * R / 180.0)))
minY = int(math.Floor(math.Pow(2.0, float64(z-26)) * R * math.Log(math.Tan(math.Pi*minLat/180.0)+1.0/math.Cos(math.Pi*minLat/180.0))))
maxY = int(math.Floor(math.Pow(2.0, float64(z-26)) * R * math.Log(math.Tan(math.Pi*maxLat/180.0)+1.0/math.Cos(math.Pi*maxLat/180.0))))
return
}
// 复制文件
func CopyFile(src, dst string) (w int64, err error) {
srcFile, err := os.Open(src)
if err != nil {
fmt.Println(err.Error())
return
}
defer srcFile.Close()
dstFile, err := os.Create(dst)
if err != nil {
fmt.Println(err.Error())
return
}
defer dstFile.Close()
return io.Copy(dstFile, srcFile)
}
// 生成地图索引文件
func MkIndex(lngCen, latCen float64, startZ string, endZ string) {
fname := "./" + nowTime + "/index.html"
f, err := os.OpenFile(fname, os.O_CREATE|os.O_RDWR|os.O_APPEND, os.ModeAppend|os.ModePerm)
if err != nil {
fmt.Println(err)
}
content := `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>地图预览</title>
<link rel="stylesheet" href="../js/leaflet/leaflet.css" />
<style>
#map {height: 800px;}
</style>
</head>
<body>
<div id="map"></div>
<script src="../js/leaflet/leaflet.js"></script>
<script src="../js/proj4-compressed.js"></script>
<script src="../js/proj4leaflet.js"></script>
<script>
var center = {
lng: "` + strconv.FormatFloat(lngCen, 'f', -1, 64) + `",
lat: "` + strconv.FormatFloat(latCen, 'f', -1, 64) + `"
}
// 百度坐标转换
var crs = new L.Proj.CRS('EPSG:3395',
'+proj=merc +lon_0=0 +k=1 +x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs',
{
resolutions: function () {
level = 19
var res = [];
res[0] = Math.pow(2, 18);
for (var i = 1; i < level; i++) {
res[i] = Math.pow(2, (18 - i))
}
return res;
}(),
origin: [0, 0],
bounds: L.bounds([20037508.342789244, 0], [0, 20037508.342789244])
}),
map = L.map('map', {
crs: crs
});
L.tileLayer('./tiles/{z}/{x}/{y}.png', {
maxZoom: ` + endZ + `,
minZoom: ` + startZ + `,
subdomains: [0,1,2],
tms: true
}).addTo(map);
new L.marker([center.lat, center.lng]).addTo(map);
map.setView([center.lat, center.lng], ` + startZ + `);
</script>
</body>
</html>`
f.WriteString(content)
f.Close()
}
// 彩色输出
func ColorPrintln(s string, i int) {
fmt.Println(s)
}
CATALOG
  1. 1. 前言
  2. 2. 百度地图和百度离线地图
    1. 2.1. 百度地图底层技术
    2. 2.2. 百度离线地图瓦片的获取
    3. 2.3. 其他地图
  3. 3. 项目总体架构
    1. 3.1. 性能优化策略1:组件
    2. 3.2. 性能优化策略2:地图层级的防抖
    3. 3.3. 性能优化策略3:预渲染栅格线
    4. 3.4. 图层:用隐藏代替重绘/用过滤代替加Layer实现内存的高效分配
  4. 4. 参考
  5. 5. 爬虫代码