原文:Augmented Reality iOS Tutorial: Location Based
译者:kmyhy
更新说明:本教程由 Jean-Pierre Distler 升级至 Swift3 和 iOS 10。
增强现实是一种很酷的流行技术,你可以通过特定设备(比如iPhone 摄像头或者微软的 Hololens)来观察世界,这个设备会在真实世界的画面上叠加额外的信息。
我猜你也许看过标志物跟踪之类的 App,在这个 App 中,当你将摄像头指向某个标志物时,会立即弹出一个 3D 模型。
在这篇 iOS 增强现实教程中,我们会编写一个 App,利用用户当前位置来找出附近的兴趣点(即 POI)。你可以将这些兴趣点添加到 MapView 上,并在镜头图像中显示它们。
要找出 POI,我们可以使用 Google 的 Places API,然后用 HDAugmentedReality 库将 POI 放到镜头视图中,并计算出用户当前位置到 POI 的距离。
本教程假设你熟悉 MapKit。如果你没用过 MapKit,请先阅读我们的MapKit 教程。
开始
请先下载开始项目并自行熟悉其中的内容。在项目导航窗口中选中 Places 项目,在 Target 的 General 窗口中,找到 Siging 栏,将 Team 修改为你自己的开发者账号。然后就可以编译项目了。Main.storyboard 中有一个 Scene 包含了一个 MapView 和一个 UIButton 按钮,它们都已经正确连接了。HDAugmentedReality 库已经导入,另外还有两个 Swift 文件:PlacesLoader.swift 和 Place.swift。这两个类后面我们会用来通过 Google 的 Places API 搜索兴趣点并将搜索结果映射成便于使用的对象。
在开始下一步之前,我们首先需要获取用户当前位置。也就是使用 CLLocationManager。打开 ViewController.swift 在 mapView 属性下面添加一个 locationManager 属性。
fileprivate let locationManager = CLLocationManager()
这里我们用一个 CLLocationManager 对象来初始化 locationManager。
为 ViewController 添加下列扩展:
extension ViewController: CLLocationManagerDelegate {
}
在获取用户位置之前必须在 Info.plist 中添加一个 key。打开 Info.plist 增加一个键值对,键名为 NSLocationWhenInUseUsageDescription 键值为 a value of Needed for AR。当你第一次访问 iOS 的定位服务时,会显示一个对话框要求用户授权。
准备好这一切之后,我们来获取用户位置。打开 ViewController.swift ,将 viewDidLoad() 修改为:
override func viewDidLoad() {
super.viewDidLoad()
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
locationManager.startUpdatingLocation()
locationManager.requestWhenInUseAuthorization()
}
locationManager 就基本配置好了。locationManager 需要一个委托对象,这样当设备位置发生变化时它会通知委托对象。这里我们将委托设置为 View Controller 自身。此外还需要指定定位所需的精度。这里我们设置为 KCLLocationAccuracyNearestTenMeters,对于本项目来说这个精度就够了。最后2行是打开 locationManager 以及向用户获取授权,如果用户还没有进行授权或者拒绝授权的话。
注意:对于 desiredAccuracy 属性,你应该根据使用目的选择能够满足需要的最低精度就可以了。为什么?
假设你只需要百米级别的精度,则 LocationManager 会用蜂窝幸好和网络来获取位置。因为这能延长电池寿命,对于 iPhone 来说至关重要。如果你需要更高的精度,LocationManager 会使用 GPS 进行定位,这是非常耗电的。同理,一旦我们收到一个可以接受的位置信息之后,就应当立即停止获取定位信息。
接下来实现委托方法。在 ViewController 的 CLLocationManagerDelegate 扩展中加入代码:
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
//1
if locations.count > 0 {
let location = locations.last!
print("Accuracy: \(location.horizontalAccuracy)")
//2
if location.horizontalAccuracy < 100 {
//3
manager.stopUpdatingLocation()
let span = MKCoordinateSpan(latitudeDelta: 0.014, longitudeDelta: 0.014)
let region = MKCoordinateRegion(center: location.coordinate, span: span)
mapView.region = region
// More code later...
}
}
}
代码解释如下:
- 每当 LocationManager 收到一个位置更新信息,它就会立即通知委托对象,将新的位置信息传递给委托。locations 数组中包含了按时间排序的所有位置,因此最新的位置应当是数组中的最后一个值。首先判断数组中是否包含了至少一个值,并从中获取最新的一个。然后在控制台中打印水平精度。这个值表示了当前位置的位置半径。如果这个值为 50,则说明真实的位置就在这个 50 米半径范围内。
- 这里判断位置精度是否满足我们的需要。在本例中,100 米就足够了。在真实 App,你可能想要 10 米或者更少的精度,但要达到这种精度很可能要花好几分钟的时间(GPS 定位是耗时的)。
- 首先停止位置更新以减少电池消耗。然后将 mapView 的中心缩放到这个位置。
在设备上运行 App,注意控制台消息,你会发现不停地接收到位置信息,同时精度会变得越来越高。最后地图中心会缩放到你的当前位置。
注意:除了 horizontalAccuracy 以外还有一个 verticalAccuracy 属性。不同的是这个属性是针对海拔的。如果这个值为 50,表示真实的海拔应该在上下 50 米范围内。这两个属性如果为负,则表明值无效。
添加 Google Places 框架
现在我们有了用户位置,可以来加载 POI 列表了。这需要用到 Google 的 Places API。
Google 的 PLaces API 需要注册才能使用。如果你已经注册过 Google 账号,比如之前为了使用 Maps API 注册的 Google 账号,则只需要在这里选择 Services。然后跳过后面的几步直到看到 Enabling the Places API。
但是,如果你之前没有用过 Google Places API,你必须注册 Google 账号。
你可以跳过第二个界面,来到第三个界面,点击 Back to Developer Consoles。
然后点击左上角的 Project/Create Project,输入一个项目名称。要使用 Places API,请找到 Google Places API Web Service 一行并点击链接。接着点击上方的 ENABLE。然后点击 Crendentials 并继续后面几步以获取 API key。
加载 POI
现在,你有了 API key,请打开 PlacesLoader.swift,然后找到 let apiKey = “Your API key” 一行,将其中的内容替换为你的 API key。
这时最好能进行一个测试,但在运行 App 之前,我们需要打开 ViewController.swift 并在 locationManager 属性后面添加两个属性:
fileprivate var startedLoadingPOIs = false
fileprivate var places = [Place]()
startedLoadingPOIs 用于标记请求是否仍然在进行,因为 CLLocationManagerDelegate 方法可能被调用多次,哪怕你停止了位置更新。通过这个标志能避免重复请求。places 属性用于存储收到的 POI。
现在找到 locationManager(manager: didUpdateLocations:) 方法。在 if 语句内,在 “More code later …” 注释之后加入下列代码:
//1
if !startedLoadingPOIs {
startedLoadingPOIs = true
//2
let loader = PlacesLoader()
loader.loadPOIS(location: location, radius: 1000) { placesDict, error in
//3
if let dict = placesDict {
print(dict)
}
}
}
这段代码加载了当前用户位置半径 1000 米范围内的 POI,然后打印到控制台中。
运行 App,查看控制台输出。输出内容类似于如下例子:
{
"html_attributions" = (
);
"next_page_token" = "CpQCAgEAAJWpTe34EHADqMuEIXEUvbWnzJ3fQ0bs1AlHgK2SdpungTLOeK21xMPoi04rkJrdUUFRtFX1niVKCrz49_MLOFqazbOOV0H7qbrtKCrn61Lgm--DTBc_3Nh9UBeL8h-kDig59HmWwj5N-gPeki8KE4dM6EGMdZsY1xEkt0glaLt9ScuRj_w2G8d2tyKMXtm8oheiGFohz4SnB9d36MgKAjjftQBc31pH1SpnyX2wKVInea7ZvbNFj5I8ooFOatXlp3DD9K6ZaxXdJujXJGzm0pqAsrEyuSg3Dnh3UfXPLdY2gpXBLpHCiMPh90-bzYDMX4SOy2cQOk2FYQVR5UUmLtnrRR9ylIaxQH85RmNmusrtEhDhgRxcCZthJHG4ktJk37sGGhSL3YHgptN2UExsnhzABwmP_6L_mg";
results = (
{
geometry = {
location = {
lat = "50.5145334";
lng = "8.3931416";
};
viewport = {
northeast = {
lat = "50.51476485000001";
lng = "8.393168700000002";
};
southwest = {
lat = "50.51445624999999";
lng = "8.3930603";
};
};
};
icon = "https://maps.gstatic.com/mapfiles/place_api/icons/lodging-71.png";
id = c64c6c1abd02f4764d00a72c4bd504ab6d152a2b;
name = "Schlo\U00df-Hotel Braunfels";
photos = (
{
height = 4160;
"html_attributions" = (
"<a href=\"https://maps.google.com/maps/contrib/113263673214221090182/photos\">Ralph Peters</a>"
);
"photo_reference" = "CoQBdwAAABZT7LYlGHmdep61gMOtwpZsYtVeHRWch0PcUZQOuICYHEWnZhKsSkVdMLx3RBTFIz9ymN10osdlqrPcxhxn-vv3iSsg6YyM18A51e3Sy0--jO2u4kCC05zeMyFp-k7C6ygsDsiOK4Dn3gsu_Bf5D-SZt_SrJqkO0Ys6CwTJ75EPEhDcRLUGnYt2tSODqn_XwxKWGhRMrOG9BojlDHFSoktoup1OsbCpkA";
width = 3120;
}
);
"place_id" = ChIJdadOzRdPvEcRkItOT1FMzdI;
rating = "3.8";
reference = "CmRSAAAAgvVO1e988IpXI7_u0IsRFCD1U1IUoSXlW7KfXvLb0DDtToodrGbiVtGZApSKAahnClm-_o-Nuixca_azt22lrT6VGwlJ1m6P0s2TqHAEmnD2QasXW6dCaDjKxesXCpLmEhAOanf32ZUsfX7JNLfNuuUXGhRrzQg-vvkQ0pGT-iSOczT5dG_7yg";
scope = GOOGLE;
types = (
lodging,
"point_of_interest",
establishment
);
vicinity = "Hubertusstra\U00dfe 2, Braunfels";
},
原谅我糟糕的法语吧 :]
如果返回结果为 NULL,请尝试增加半径值。
现在,我们的 App 获取了用户的位置并加载它附近的 PIO 列表。我们拥有一个用于保存 PIO 的类 Place,虽然我们根本没有用到它。现在我们应该做的就是将 PIO 显示到地图上!
显示 POI
为了在 mapView 上显示标注,我们还需要用另外一个类。依次点击 File\New\File… 菜单,选择 iOS\Swift File 然后点 Next。命名文件为 PlaceAnnotation.swift 然后点击 Create。
编辑 PlaceAnnotation.swift 的代码为:
import Foundation
import MapKit
class PlaceAnnotation: NSObject, MKAnnotation {
let coordinate: CLLocationCoordinate2D
let title: String?
init(location: CLLocationCoordinate2D, title: String) {
self.coordinate = location
self.title = title
super.init()
}
}
这里我们让类实现 MKAnnotation 协议并定义了两个属性和一个 init 方法。
接下来应该做的事情就是将 POI 显示到地图上!
回到 ViewController.swift 继续编辑 locationManager(manager: didUpdateLocations:) 方法。找到 print(dict) line 并将它替换为:
//1
guard let placesArray = dict.object(forKey: "results") as? [NSDictionary] else { return }
//2
for placeDict in placesArray {
//3
let latitude = placeDict.value(forKeyPath: "geometry.location.lat") as! CLLocationDegrees
let longitude = placeDict.value(forKeyPath: "geometry.location.lng") as! CLLocationDegrees
let reference = placeDict.object(forKey: "reference") as! String
let name = placeDict.object(forKey: "name") as! String
let address = placeDict.object(forKey: "vicinity") as! String
let location = CLLocation(latitude: latitude, longitude: longitude)
//4
let place = Place(location: location, reference: reference, name: name, address: address)
self.places.append(place)
//5
let annotation = PlaceAnnotation(location: place.location!.coordinate, title: place.placeName)
//6
DispatchQueue.main.async {
self.mapView.addAnnotation(annotation)
}
}
代码解释如下:
- guard 语句用于判断返回结果的格式是否正确。
- 遍历所有 POI。
- 从 Dictionary 中检索我们需要的数据。返回数据中包含了许多我们并不需要的信息。
- 用获取的数据创建 Place 对象,然后插入到 places 数组中。
- 创建 PlaceAnnotation,用于在地图上显示一个标注。
- 将标注添加到 map view,因为这个操作和 UI 相关,所以需要在主线程中进行。
运行 App。这次,地图上会显示几个大头钉,当你点击其中一个,你会看到地点的名称。这个 App 看起来不错,但说好的增强现实呢?
HDAugmentedReality简介
虽然我们做了不少工作,但我们仍然还有重要的事情没有完成:是时候让增强现实出场了。
在右下角有一颗 Camera 按钮。当我们点击这个按钮,什么也不会发生。在本节,我们会实现这个按钮的动作处理,在摄像头的视野中增加增强现实体验。
使用 HDAugmentedReality 库,能够大大节省我们的时间。这个框架已经包含在我们的开始项目中了。你可以在 Github 中找到它的最新版本,但这个东东是干嘛的?
首先,HDAugmentedReality 能为你在摄像头中增加字幕,以便显示实时视频。
第二,它可以为你添加一个 POI 遮罩层,用于显示它们的位置。
等会你会看到,最后一个功能是我们使用它的最大目的,因为它为我们节省了大量复杂的数学计算。如果你想了解 HDAugmentedReality 背后的数学,请继续。
如果你想立即进入代码,请跳过后面两节,直接阅读“开始编码”一节。
警告,数学来了!
如果你看到这里,表明你想学习 HDAugmentedReality 中的数学。非常好!值得夸奖,但是,这个数学可不是一般的基础数学。在下面的示例中,我们假设有两个给定的点 A 和 B,分别表示地球上的两个坐标。
A 的坐标包含了两个值:经度和纬度。这两个地理学名词用来表示笛卡尔二维坐标系中某个点的 x/y 坐标。
- 经度表示位于英国格林威治以东或者以西的某个点。这个值在 +180o 到 -180o 之间。
- 纬度表示位于赤道以南或以北的某个点。这个值在 90o(表示北极) 到 -90o (表示南极)之间,
如果你看一眼标准地球仪,会发现经度线从一极画到另一极——即所谓的子午线。纬度线则是围绕地球平行分布,即所谓的纬圈。你可以翻一下地理课本,两条纬度线大约距离 111 千米,两条子午线之间的距离也是 111 千米。
有 360 条纬度线,每条纬度线表示 360 度中的一度。这样,你可以用以下公式计算地球上任意两点之间的距离:
计算出纬度和经度距离,分别构成直角三角的两条直角边。通过勾股定律,我们可以计算出斜边,即两点间的距离:
很简单,是吧?但不幸的是,这个答案是错误的。
再看一眼地球仪,你会发现,两条纬度线之间的距离总是相等的,但经度线会在两极发生交叉。因此两条相邻经度线之间的距离是变化的,越靠近两级,距离就会越近,当到达极点,距离为 0。也就是说上述公式只在两点位于赤道时才适用。当两点靠两极约近,误差就越大。
为了精确起见,你可以使用“大圆距离”。即两点在球体上的距离,因为地球也是一个球体,准确说接近于球体。这个方法非常实用。已知两点经纬度,计算两点间“大圆距离”的公式为:
这个公式可以计算出两点间的距离,精度为大约 60 千米,对于你想知道东京和纽约之间的距离来说,这已经足够好了。两点间的距离越近,结果越精确。
呃——最困难的工作终于完成了。幸运的是 CLLocation 中有一个 distanceFromLocation: 方法,可以为我们计算出两点间距离。HDAugmentedReality 也使用这个方法。
为什么使用 HDAugmentedReality
你可能会问“切,我还是不明白为什么要使用 HDAugmentedReality ?”确实,创建和显示 frame 并不难,你可以从本站找到有关文章。计算两点间距离也可以调用 CLLocation 的方法实现,没有任何难度。
那为什么我要介绍这个框架?问题在于你必须计算出要在什么地方以及何时显示每个 POI 的覆盖物。假设在设备朝向东北时,有一个 POI 刚好位于你的北方。你应当将 POI 显示在什么地方 —— 中心还是左边?顶端还是下方?
这完全取决于设备在空间中的当前位置。如果设备倾斜向下,你必须将 POI 稍微向上靠一点。如果设备指向南,你根本不能显示这个 POI。这就变得复杂了。
这就是 HDAugmentdReality 最大的功能。它从陀螺仪和指南针读取有用的信息,计算出设备的朝向和倾斜角度。通过这些参数来决定一个 POI 是否需要显示以及显示的位置在哪。
另外,你不需要操心如何显示实时视频并进行复杂和容易出错的数学计算,你只需要将精力集中在如何编写一个让用户乐于使用的 app。
开始编码
现在,看一眼 HDAugmentedReality\Classes 文件夹中的几个文件:
- ARAnnotation: 这个类定义了 POI。
- ARAnnotationView: 用于提供 POI 的视图。
- ARConfiguration: 提供了几个基本的配置方法和助手方法。
- ARTrackingManager: 负责了最“沉重”的工作。幸运的是,这些工作我已经为你做好了。
- ARViewController: 为你处理所有“可视对象”的控制器。它显示了一个视频直播界面并将标注放到这个视图上。
创建 AR 视图
打开 ViewController.swift 在 places 属性后定义新属性:
fileprivate var arViewController: ARViewController!
找到 @IBAction func showARController(_ sender: Any) 方法,加入以下代码:
arViewController = ARViewController()
//1
arViewController.dataSource = self
//2
arViewController.maxVisibleAnnotations = 30
//3
arViewController.headingSmoothingFactor = 0.05
arViewController.setAnnotations(places)
// 4
self.present(arViewController, animated: true, completion: nil)
- 设置 arViewController 的数据源。数据源负责提供需要显示的 POI。
- 修改 arViewController 的属性。maxVisibleAnnotations 定义在同一时刻最多能显示几个标注。为了让 App 能够不卡顿,我们将它设置为30,但如果你位于一个极度活跃的区域时,很可能无法显示全位于你附近的所有 POI。
- headingSmoothingFactor 用于将 POI 的视图移动到屏幕上。如果这个值为 1,意味着不使用任何平滑过渡效果,如果你移动你的 iPhone 视图将直从一个地方跳到另一个地方。小于 1 表明这个移动将是动画的,但值越低可能因为动画带来的“滞后感”越强。你可以调整这个值在平滑和速度之间进行取舍。
- 显示 arViewController。
你可以看一眼 ARViewController.seift 的其他属性,比如 maxDistance,这个属性指定了一个范围,以米为单位,在这个范围中的标注将被显示,而在这个值之外的不被显示。
实现数据源方法
Xcode 在将 dataSource 设置为 self 这行报错,要避免这个错误必须让 ViewController 实现 ARDataSource 协议。这个协议只有一个方法是必须实现的,这个方法需要返回一个 POI 视图。多数情况下你需要提供一个自定义的视图。用 cmd+N 快捷键新建一个文件。选择 iOS\Swift File 并为新文件取名为 AnnotationView.swift。
编辑新文件的代码:
import UIKit
//1
protocol AnnotationViewDelegate {
func didTouch(annotationView: AnnotationView)
}
//2
class AnnotationView: ARAnnotationView {
//3
var titleLabel: UILabel?
var distanceLabel: UILabel?
var delegate: AnnotationViewDelegate?
override func didMoveToSuperview() {
super.didMoveToSuperview()
loadUI()
}
//4
func loadUI() {
titleLabel?.removeFromSuperview()
distanceLabel?.removeFromSuperview()
let label = UILabel(frame: CGRect(x: 10, y: 0, width: self.frame.size.width, height: 30))
label.font = UIFont.systemFont(ofSize: 16)
label.numberOfLines = 0
label.backgroundColor = UIColor(white: 0.3, alpha: 0.7)
label.textColor = UIColor.white
self.addSubview(label)
self.titleLabel = label
distanceLabel = UILabel(frame: CGRect(x: 10, y: 30, width: self.frame.size.width, height: 20))
distanceLabel?.backgroundColor = UIColor(white: 0.3, alpha: 0.7)
distanceLabel?.textColor = UIColor.green
distanceLabel?.font = UIFont.systemFont(ofSize: 12)
self.addSubview(distanceLabel!)
if let annotation = annotation as? Place {
titleLabel?.text = annotation.placeName
distanceLabel?.text = String(format: "%.2f km", annotation.distanceFromUser / 1000)
}
}
}
- 一上来就定义一个委托协议,这个协议会在后面用到。
- 定义类继承自 ARAnnotaionView,表明这个类将用于作为 POI 的展现视图。
- 这个视图包含一个用于显示 POI 名字的 Label ,一个用于显示距离的 Label。这些代码声明了两个 Label 属性,第三个属性会在后面用到。
- loadUI() 方法用于加载和配置两个 Label。
还需要在这个类中定义两个方法:
//1
override func layoutSubviews() {
super.layoutSubviews()
titleLabel?.frame = CGRect(x: 10, y: 0, width: self.frame.size.width, height: 30)
distanceLabel?.frame = CGRect(x: 10, y: 30, width: self.frame.size.width, height: 20)
}
//2
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
delegate?.didTouch(annotationView: self)
}
- 这个方法在视图重绘时调用,这里你需要正确设置 Label 的位置大小以便重置它们。
- 这个方法通知委托对象这个视图被点击了,以便委托进行必要的处理。
回到 ViewController.swift,添加一个扩展:
extension ViewController: ARDataSource {
func ar(_ arViewController: ARViewController, viewForAnnotation: ARAnnotation) -> ARAnnotationView {
let annotationView = AnnotationView()
annotationView.annotation = viewForAnnotation
annotationView.delegate = self
annotationView.frame = CGRect(x: 0, y: 0, width: 150, height: 50)
return annotationView
}
}
这里,我们创建了一个 AnnotationView 并设置它的委托属性,然后返回它。
在可以测试之前,我们还需要实现另外一个扩展:
extension ViewController: AnnotationViewDelegate {
func didTouch(annotationView: AnnotationView) {
print("Tapped view for POI: \(annotationView.titleLabel?.text)")
}
}
在调用相机之前,我们必须在 Info.plist 中添加键值。打开 Info.plist,加入一个键 NSCameraUsageDescription,值设置为 Needed for AR, just like you did for accessing location information。
运行 App,点击 camera 按钮进入 AR 界面。当你第一次这样做的时候,系统会弹出一个请求授权访问摄像头的对话框。点击某个 POI,然后注意看控制台窗口。
收尾工作
现在我们有了一个 AR app,我们能够在摄像头视图上显示 POI 并能够监听到 POI 的点击事件。接下来我们需要定义这个事件的处理逻辑。
打开 ViewController.swift 将 AnnotationViewDelegate 协议扩展修改为:
extension ViewController: AnnotationViewDelegate {
func didTouch(annotationView: AnnotationView) {
//1
if let annotation = annotationView.annotation as? Place {
//2
let placesLoader = PlacesLoader()
placesLoader.loadDetailInformation(forPlace: annotation) { resultDict, error in
//3
if let infoDict = resultDict?.object(forKey: "result") as? NSDictionary {
annotation.phoneNumber = infoDict.object(forKey: "formatted_phone_number") as? String
annotation.website = infoDict.object(forKey: "website") as? String
//4
self.showInfoView(forPlace: annotation)
}
}
}
}
}
- 将 annotationView 的 annotation 转换成 Place 对象。
- 加载这个 Place 对象附加属性。
- 对 Place 对象的相关属性进行赋值。
- showInfoView 方法会在后面进行实现。
在 showARController(sender:) 后实现方法:
func showInfoView(forPlace place: Place) {
//1
let alert = UIAlertController(title: place.placeName , message: place.infoText, preferredStyle: UIAlertControllerStyle.alert)
alert.addAction(UIAlertAction(title: "OK", style: UIAlertActionStyle.default, handler: nil))
//2
arViewController.present(alert, animated: true, completion: nil)
}
- 为了,我们使用一个 Alert View 来显示 POI 的详细信息,标题显示 POI 的地名,消息则显示 POI 的 infoText。
- 因为 ViewController 现在不存在于当前视图树中,我们用 arViewController 来显示 alert。
运行 App,查看效果。
结束
最后的完成项目在这里下载。
恭喜你,你已经知道如何创建自己的基于定位的 AR app! 另外,你在本教程中也简单了解了 Google Places API。
如果有任何问题或建议,请在下面留言!