27
2017
09

MapKit 进阶教程: 自定义瓦片

原文:Advanced MapKit Tutorial: Custom Tiles
作者:Michael Katz
译者:kmyhy

在现代 app 中,地图的使用无处不在。地图可以提供 POI(兴趣点),为公园和城镇中的用户进行导航,寻找附近的人,跟踪旅程,或者在增强现实游戏中提供背景。

不幸的是,这也导致了每个 app 中的地图千篇一律。烦!

本教程介绍如何使用手绘地图,而不是用代码生成的地图,就像精灵宝可梦中的地图。

手绘地图是一个非常浩大的工程。考虑到地球太大,这种方法只适用于定义良好的、地理学意义上的小片区域。如果需要在你的地图上显示一个定义良好的小片地图,那么自定义地图能够为你的 app 增色不少。

开始

这里下载开始项目。

MapQuest 的原型是一个有趣的冒险游戏。主角沿着纽约的中央公园奔跑(实景中),然后进行冒险,和怪物搏斗,并在现实交替(Alernate Reality)中搜集宝物。它采用了可爱的、童趣的设计,让玩家感觉舒适并表示游戏不会太危险。

游戏中有几个 POI,暗示了玩家能够和游戏发生交互的地方。会有一些谜题、怪物、情节或其它游戏元素。进入 POI 10米范围内会启动事件。处于教程的缘故,地图渲染才是主题,游戏玩法是次要的。

在这个项目中,有两个负责主要工作的类:

  • MapViewController.swift: 负责处理 Map View 及 UI 逻辑和状态改变。
  • Game.swift: 包含了游戏逻辑,以及管理一些游戏对象的坐标。

游戏的主要视图是一个 MKMapView。MapKit 通过各种缩放级别的瓦片图来填充视图,以及提供关于地理特征的信息,比如道路等。

map view 可以显示为传统的街道图,也可以显示为卫星图。对于在城市中导航来说,这是有用的,但如果你想在中世纪的世界中进行冒险,那就不合适了。因此,MapKit 允许你用自己的地图图片来定制美工和要显示的信息。

一个 map view 由许多张瓦片构成,它们在你平移视图时动态加载。瓦片图是256x256 大小,以网格方式铺开,这个网格对应于麦卡脱地图投影。

Build & run,亲眼看一下这张地图。

哇!漂亮的小镇。游戏的第一个界面是一个定位,也就是说,如果不到达中央公园的话,你什么都看不见、什么都不能做。

测试定位

和别的教程不同,MapQuest 是一个“接近完成”的准 app!但是,除非你正在纽约市生活,否则你就没那么幸运了。幸好,Xcode 提供了至少两种模拟位置的方法。

模拟位置

在 iPhone 模拟器上运行 app,设置用户位置。

打开 Debug\Location\Custom Location… 菜单。

将纬度设置为 40.767769 ,经度设置为 -73.971870。这会显示出用户位置的小蓝点,并将地图中心置于中央公园动物园。 那里住着一只哥布林;你必须和它战斗,然后获取它的财物。

一旦你击败这只可怜的哥布林,你应当位于动物园中了(注意小蓝点)。

模拟一次冒险

一个固定的位置对于许多基于定位的 app 来说是非常有用的。但是,这个游戏在冒险中需要拜访许多位置。模拟器可以模拟一次奔跑、骑行和驾驶的变化的位置。这些内置的旅程位于 丘珀蒂诺,但 MapQuest 只是在纽约。

这种位置的模拟需要用到 GPX 文件(GPS 交换格式)。这个文件中定义了许多路径点,而模拟器会在它们之间插入线段。

这个文件的制作不是本文的范围,不过在示例项目中已经包含了你测试用到的那个 GPX 文件了。

通过 Product\Scheme\Edit Scheme… 菜单,打开 Xcode 的 scheme 编辑器。

选中左窗格中的 Run,再选择右边的 Options tab 按钮。在 Core Location 一栏,勾上 Allow Location Simulation 选项。在 Default Location 下拉框中,选择 Game Test。

这样,app 会用 Game Test.gpx 文件中定义的路径点来模拟运动。

Build & run。

模拟器会模拟从第五大道向中央公园动物园进行移动,在那里你将再次和哥布林战斗。然后,你可以去水果公司的旗舰店买一把升级的宝剑。当这个循环结束,冒险将重新开始。

用 OpenStreetMap 替换瓦片图

OpenStreetMap 是一个由社区支持的地图数据的开放式数据库。这些数据可以用于生成苹果地图所用的同一类地图瓦片。Open Street Map 社区提供除了基本街道图之外许多东西,比如专门针对地型地貌、自行车的地图,以及艺术绘制。

注意:Open Street Map 瓦片许可协议对数据的使用、所有权和 API 访问有严格限制。在教程中使用还没有什么问题,但如果要在商业 app 中使用的话,请确认你没有违反它。

创建覆盖物

替换地图瓦片需要使用 MKTileOverlay 去显示新的瓦片图到苹果地图上面。

打开 MapViewController.swift,将 setupTileRenderer() 替换为:

func setupTileRenderer() {
  // 1
  let template = "https://tile.openstreetmap.org/{z}/{x}/{y}.png"

  // 2
  let overlay = MKTileOverlay(urlTemplate: template)

  // 3
  overlay.canReplaceMapContent = true

  // 4
  mapView.add(overlay, level: .aboveLabels)

  //5
  tileRenderer = MKTileOverlayRenderer(tileOverlay: overlay)
}

默认,MKTileOverlay 支持以 URL 模板来指定瓦片路径,并加载瓦片图。

  1. 用 Open Street Map API 抓取一个地图瓦片。 {x}, {y}, {z} 会在运行时用瓦片坐标替换。z 坐标或者缩放级别指定了用户缩放地图的倍数。x 和 y 是瓦片在地图上的区块索引。对于某个x,y 而言,每个缩放级别都需要提供一个瓦片。
  2. 创建覆盖物。
  3. 设置瓦片为不透明,这样就会替换默认的地图瓦片。
  4. 将覆盖物添加到 mapView。定制的瓦片会在街道上或者标签上(比如街道和地名)。Open Street Map 的瓦片是带地图标记的,因此它们会盖住苹果的地图标记。
  5. 新建一个 title render,用它绘制瓦片图。

在 viewDidLoad() 方法最后添加一句:

mapView.delegate = self

设置 MapViewController 为 mapView 的委托对象。

在 MapView Delegate 扩展中,添加方法:

func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
  return tileRenderer
}

一个覆盖物 renderer 用于描述 map view 应该如何绘制覆盖物。tile renderer 是它的一个子类,用于加载和绘制瓦片。

好了!Build & run,你会看到标准苹果地图被换成了 Open Street 地图:

现在,你发现开源地图和苹果地图确实不一样了吧!

分割地球

瓦片覆盖物的关键技术是能够将瓦片路径转换成特定的图片资源。瓦片路径是用坐标来表示的:x、y、z。x、y 表示了地图表面的索引,0,0 表示左上角的瓦片。z 坐标是缩放级别,这决定了整个地图需要用多少个瓦片构成。

缩放级别为 0 时,整个世界用一张 1x1 网格表示,只会用到一张瓦片:

缩放级别 1,整个世界被分割成 2x2 网格,需要 4 张瓦片:

缩放级别 2, 行数和列数分别乘以 2,需要 16 张瓦片:

以此类推,每个缩放级别的详细程度和瓦片数以 4 倍递增。每个缩放级别需要 2*2*z 张瓦片图,所有贴图从 0-19 缩放级别总共需要 274,877,906,944 张瓦片!

创建自定义瓦片

因为 map view 是跟随用户位置的,默认缩放级别为 16,这会给用户一个能够看到所处环境的比较好的细节显示。而在缩放级别 16 上,整个地球需要 4,294,967,296 张瓦片。如果用手画的话,一辈子也画不完!

将范围缩小一点,比如一个城市或者一座公园,才有可能使自定义瓦片成为可能。而超过这个范围,瓦片通过源数据程式化的生成。

因为在项目资源包中已经包含了游戏中所用的瓦片,你只需要将它们加载出来就可以了。但糟糕的是,普通的那种 URL 模板已经行不通了,因为如果 renderer 在请求数十亿张瓦片中的某张时发现 app 中没有,当然就只会返回失败。

要解决这个问题,你需要定义一个 MKTileOverlay 子类。打开 AdventureMapOverlay.swift 添加代码:

class AdventureMapOverlay: MKTileOverlay { override func url(forTilePath path: MKTileOverlayPath) -> URL { let tileUrl = "https://tile.openstreetmap.org/\(path.z)/\(path.x)/\(path.y).png" return URL(string: tileUrl)! } }

我们定义了一个子类,覆盖基类的 URL 方法,用特殊的 URL 生成器来生成模板 URL。

为了测试自定义覆盖物,暂时先保持 Open Street Map 的瓦片不变。

打开 MapViewController.swift,修改 setupTileRenderer()方法:

func setupTileRenderer() {
  let overlay = AdventureMapOverlay()

  overlay.canReplaceMapContent = true
  mapView.add(overlay, level: .aboveLabels)
  tileRenderer = MKTileOverlayRenderer(tileOverlay: overlay)
}

这里换上了自定义的子类。

Build & run。如果一切正常,游戏应该和之前显示没有什么不同。耶✌️!

提前渲染瓦片

来点有趣的事情。打开 Open AdventureMapOverlay.swift,修改 url(forTilePath:) 方法:

override func url(forTilePath path: MKTileOverlayPath) -> URL {

  // 1
  let tilePath = Bundle.main.url(
    forResource: "\(path.y)",
    withExtension: "png",
    subdirectory: "tiles/\(path.z)/\(path.x)",
    localization: nil)

  guard let tile = tilePath else {

    // 2
    return Bundle.main.url(
      forResource: "parchment",
      withExtension: "png",
      subdirectory: "tiles",
      localization: nil)!
  }
  return tile
}

这些代码加载了游戏中的自定义瓦片。

  1. 首先,从资源束中根据命名规则查找匹配的瓦片图。
  2. 如果找不到,则用一张羊皮纸图案代替,以营造一种中世纪地图的氛围。这样就避免了为每个地图路径都提供一个唯一资源。

Build & run。自定义的地图显示了。

放大、缩小地图,你会看到细度不同的地图。

限制缩放级别

别放得太大或缩得太小,否则你会根本看不见自定义地图。

这个问题很容易搞定。打开 MapViewController.swift,在 setupTileRenderer() 方法最后添加:

overlay.minimumZ = 13
overlay.maximumZ = 16

这会告诉 mapView,只提供这两这个缩放级别之间的瓦片图。如果缩放级别超出此范围,将会用 app 中提供的瓦片图片进行拉伸。显示的图片不会更精细,但至少图片的大小对了。

创建瓦片

这一节是可选的,因为它讨论的是如何绘制自定义瓦片。要跳到后面的 MapKit 相关的技术部分,请直接跳到“修饰地图”一节。

整个练习中最困难的部分是如何创建正确尺寸的瓦片并正确地摆放它们。要画出自己的瓦片,你需要用到数据源和图片编辑器。

打开项目文件夹,看一眼 MapQuest/tiles/14/4825/6156.png 这张图片。这个瓦片显示的是中央公园动物园的底部,缩放级别 14。这个 app 中有许多这样的小图片,它们组成了纽约市这个游戏场景的地图,每个瓦片都是用非常简陋的技巧和工具手绘的。

你需要什么样的地图?

首先,需要明确你需要画出什么样的地图。你可以从 Open Street Map 下载源数据,然后用 MapNik 之类的工具生成瓦片图。不幸的是,要下载的源数据有 57 GB 之巨。同时工具有一点难用,也超出了本文的范畴。

对于有限的区域,比如中央公园,有一个简单的方法。

在 AdventureMapOverlay.swift 中在 url(forTilePath:) 后添加下句:

print("requested tile\tz:\(path.z)\tx:\(path.x)\ty:\(path.y)")

Build & run。当你缩放或平移地图时,瓦片路径会在控制台中输出。

接下来我们要读取源瓦片图并定制它。你可以重用和之前获取 Open Street Map 瓦片的 URL scheme。

下面的终端命令用于抓取并保存瓦片到本地。你可以修改 URL,将 x,y,z 替换成具体的地图路径。

curl --create-dirs -o z/x/y.png https://tile.openstreetmap.org/z/x/y.png

对于中央公园的南边,用:

curl --create-dirs -o 14/4825/6156.png https://tile.openstreetmap.org/14/4825/6156.png

通过 “缩放级别/x-坐标/y-坐标” 这种目录结构,到后面我们查找和使用瓦片图时会比较方便。

自定义外观

接下来是用原来的图片作为自定义的基础。用任意图片编辑器打开瓦片图,例如 Pixelmator:

你可以用铅笔或笔刷去描绘街道、路径等有意义的特征。

如果工具支持图层,你可以在单独的图层中绘制不同的特征,这样你就可以通过调整它们达到最佳效果。图层的使用让绘图更加轻松,你可以将凌乱的线条隐藏到其它图形之下。

对所有的瓦片图重复同样步骤,这样就准备好了。你知道的,这是件相当费时间的事情。

你可以将这个过程变成更简单一点:

  • 首先,将所有的瓦片合并成一整张。
  • 描绘自定义地图。
  • 将地图重新切成瓦片。

保存瓦片图

做好自己的瓦片之后,将它们重新放到项目的 “tiles/缩放级别/x-坐标/y-坐标” 文件夹。这会将它们变得有序,容易访问。

这样你可以轻易访问它们了,就先之前添加在 url(forTilePath:) 方法中的代码那样。

let tilePath = Bundle.main.url(
    forResource: "\(path.y)",
    withExtension: "png",
    subdirectory: "tiles/\(path.z)/\(path.x)",
    localization: nil)

OK。现在你就准备来画几张漂亮的地图吧!

修饰地图

这个地图不错,就适合那种中世纪时代的游戏。但我们还应该定制更多的内容!

你的主角现在只是一个小蓝点,你可以用一张自定义的图片来替换它。

User Annotation

打开 MapViewController.swift 在 MapView Delegate 扩展中增加方法:

func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
  switch annotation {

  // 1
  case let user as MKUserLocation:

    // 2
    let view = mapView.dequeueReusableAnnotationView(withIdentifier: "user")
      ?? MKAnnotationView(annotation: user, reuseIdentifier: "user")

    // 3
    view.image = #imageLiteral(resourceName: "user")
    return view

  default:
    return nil
  }
}

这里创建了一个自定义 view 用作用户标注。

  1. 用户标注用 MKUserLoaction 对象表示。
  2. MapView 维护了一个重用的 annotation view 以提升性能。如果 dequeue 方法不能取出可重用的对象,则创建新对象。
  3. 标准的 MKAnnotationView 是非常灵活的,但要表示一位冒险者的话只需要用一张图片就可以了。

Build & run。现在小蓝点被小火柴人所代替。

特定位置的标注

MKMapView 允许你自己标注出你感兴趣的位置。MapQuest 的剧情是沿着纽约地铁展开的,你可以把地铁系统看成是一个巨大的曲变网络。

在地铁站附近添加一些标注。打开 MapViewController.swift, 在 viewDidLoad() 最后添加:

mapView.addAnnotations(Game.shared.warps)

Build & run, 选中一个地铁站,现在会显示大头钉。

和小蓝点一样,这些标准的大头钉和游戏的审美不太一致。可以通过定制标注来解决这个问题。

在 mapView(_:viewFor:) 在 switch 的 default 分支之上插入一个 case 分支:

case let warp as WarpZone:
  let view = mapView.dequeueReusableAnnotationView(withIdentifier: WarpAnnotationView.identifier)
    ?? WarpAnnotationView(annotation: warp, reuseIdentifier: WarpAnnotationView.identifier)
  view.annotation = warp
  return view

Build & run。自定义的 annotation view 会使用一张模板图片,并在特定的地铁线路上标记出颜色。

在真实世界中,是否会存在一条能够瞬间曲变的地铁呢?

定制覆盖物的绘制

MapKit 有许多方法来打扮游戏地图。接下来,用 MKPolygonRenderer 在水塘上绘制出渐变的闪耀效果。

修改 setupLakeOverlay() 方法为:

func setupLakeOverlay() {

 // 1
 let lake = MKPolygon(coordinates: &Game.shared.reservoir, count: Game.shared.reservoir.count)
 mapView.add(lake)

 // 2
 shimmerRenderer = ShimmerRenderer(overlay: lake)
 shimmerRenderer.fillColor = #colorLiteral(red: 0.2431372549, green: 0.5803921569, blue: 0.9764705882, alpha: 1)

 // 3
 Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
   self?.shimmerRenderer.updateLocations()
   self?.shimmerRenderer.setNeedsDisplay()
 }
}

这里创建了一个新的覆盖物:

  1. 创建一个 MKPolygon 标注,和水塘的形状一模一样。这些坐标定义在 Game.swift 中。
  2. 创建一个自定义的 Renderer,用指定的效果来绘制多边形。
  3. 因为覆盖物的 renderer 是不会动的,需要创建一个 100ms 的定时器去刷新覆盖物。

然后,修改 mapView(_:rendererFor:) 方法:

func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
  if overlay is AdventureMapOverlay {
    return tileRenderer
  } else {
    return shimmerRenderer
  }
}

这会根据不同的覆盖物类型返回正确的 renderer。

Build & run。平移到水池附近,你会看到粼粼水光!

结束

你可以从这里下载最终完成的项目。

绘制手绘地图是比较花时间的,但它能给 app 带来一种与众不同的感觉。但是一旦创建了这些图片资源,使用起来就非常简单了。

除了基本的瓦片,Open Street Map 还提供了一个特殊瓦片提供器列表,包含了诸如自行车和地形等内容。Open Street Map 也提供了必要的数据,可以允许你以代码方式设计自己的瓦片图。

如果你需要一个自定义同时现实主义的地图,不需要任何手绘内容,请看一下第三方的工具 MapBox。它允许你通过工具定制地图外观,而且价格也比较适中。

要了解更多关于自定义覆盖物和标注的内容,请参考其它教程

如果你有任何问题和评论,请在下面留言!

Open Street Map 的数据和图片由 © OpenStreetMap 提供。

上一篇:iOS 按需加载资源教程 下一篇:android bitmap拼接