本教程翻译自 Augmented Reality iOS Tutorial: Location Based ,已更新至 Swift 3 和 iOS 10。
增强现实是一项很酷、很流行的技术,通过一台设备(例如 iPhone 摄像头或微软的 HoloLens)来观察世界,设备会在真实世界视角上覆盖一些额外的信息。
我相信你已经见过标记追逐的 iOS App,把摄像头指向标记,就会弹出一个 3D 模型。
在本篇增强现实教程中,我们会编写一个 App,它可以接收用户的当前位置并识别出附近的兴趣点(我们把它们称之为 POI)。我们会把这些点添加到 MapView 中,并将它们覆盖在相机视图上。
要查找 POI,我们会使用谷歌的 Places API,然后使用 HDAugmentedReality 库在相机视图上显示 POI 并计算与用户当前位置的距离。
本教程假设你对 MapKit 有一定基本的了解。如果你完全不了解 MapKit,看看 Introduction to MapKit 这篇教程。
开始
首先下载 起始项目,熟悉一下里面的内容。在项目导航器里选择 Places 项目,editing pane 中选择 Places target,在 General 标签页,Signing 区,把 Team 设置为自己的开发者账号。现在应该可以编译项目了。Main.storyboard 包含了已经连接 MapView 和 UIButton 的场景。还包含了 HDAugmentedReality 库,有文件 PlacesLoader.swift 和 Place.swift。后面会使用它们以从谷歌 Places API 查询 POI,并把结果映射为容易处理的类。
在做其他事之前,需要先获取用户的当前位置。为此,我们会使用 CLLocationManager。打开 ViewController.swift 然后在 mapView outlet 下面添加一个属性,并命名为 locationManager。
fileprivate let locationManager = CLLocationManager()
这样就初始化了一个 CLLocationManager 对象属性。
下面给 ViewController.swift 添加下面这个类扩展。
extension ViewController: CLLocationManagerDelegate {
}
在获取当前位置之前,必须给 Info.plist 添加一个键。打开该文件,然后添加 NSLocationWhenInUseUsageDescription 键,值为 AR 所需。首次尝试访问位置服务时,iOS 会向用户显示警告,要求用户给予该权限。
现在万事俱备,可以获得位置了。打开 ViewController.swift 然后将 viewDidLoad()
替换为如下代码:
override func viewDidLoad() {
super.viewDidLoad()
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
locationManager.startUpdatingLocation()
locationManager.requestWhenInUseAuthorization()
}
这是 locationManager
的基础配置。manager 需要一个 delegate,以便在 iDevice 更新位置的时候获得通知。使用 self
将其设置为这个视图控制器。然后 manager 需要了解位置的精度应该是多少。我们将其设置为 kCLLocationAccuracyNearestTenMeters
,对于这个示例项目这已经足够精确了。最后一行启动了 manager,询问用户以准许权限来访问位置服务(如果没有被允许或禁止的话)。
注意:关于
desiredAccuracy
,应该设置为足够使用的最低精度。为什么呢?
假设你只需要数百米的精度——然后 LocationManager 就可以使用手机移动网络和 WLAN 来获得位置。这样就节省了电量,你知道,电量是 iDevices 上最大的限制因素之一。但如果你需要更好地确定位置,LocationManager 就会使用 GPS,会很快地耗尽电池。这也是为什么有一个可接受的值后,就要停止更新位置。
现在需要实现一个 delegate 方法来获得当前位置。在 ViewController.swift 的 CLLocationManagerDelegate
扩展里添加如下代码:
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
//1
if locations.count > 0 {
let location = locations.last!
print("精度: \(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
// 后面后有更多代码...
}
}
}
一步步解释此方法:
- 每次 LocationManager 更新了位置,就会发送消息给它的 delegate,给出更新后的位置。位置数组按时间顺序包含所有位置,因此最新的位置就是数组中的最后一个对象。首先检查一下数据里有没有位置,如果至少有一个的话,就用最新的那个。下一行获得了水平精度并输出到控制台。这个值是当前位置周围的半径。如果值为50,则表示实际位置就位于 location 中存储的位置周围半径50米的圆内。
- If 语句检查准确性是否足够高以达到我们的需要。100米对于这个例子已经足够好了,不需要等太久就会拿到这个精度。在一个真实的 App 中,你大概会需要小于等于10米的精度,但这样可能会需要几分钟以获得该精度(GPS 追踪需要时间)。
- 第一行停止更新位置以节省电量。下面三行把 mapView 放大到该位置。
在你的设备上构建并运行,眼睛盯着控制台,看看如何获得位置,以及精度是如何一步步变好的。最终你会看到地图放大到一个以当前位置为中心的区域上。
注意:还有一个叫做 verticalAccuracy 的属性,和 horizontalAccuracy 相同,只不过是针对位置的纬度的。所以值为50就意味着实际纬度可能大于或小于50米。对于这两个属性来说,负值都是非法的。
添加谷歌 Places
现在我们有了当前位置,可以加载 POI 列表了。为了获得这个列表,我们会使用谷歌的 Places API。
谷歌 Places API 需要注册才能访问。如果以前你已经创建过谷歌账号来访问类似 Maps 的 API,直接到 这里 然后选择 Services。然后跳过接下来的步骤,直接到启用 Places API 部分。
然而,如果你以前从没使用过谷歌 Places API,就需要 注册 一个账号。
可以跳过第二屏,然后在第三屏上,点击 Back to Developers Consoles。
现在在左上角点击 Project/Create Project 然后输入项目名。为了启用 Places API,搜索到 Google Places API Web Service 这一行然后点击链接。点击上面的 ENABLE。现在点击 Credentials 然后接着做下面的步骤来取得 API key。
加载兴趣点
现在我们有了 API key,打开 PlacesLoader.swift 然后找到 let apiKey = "Your API key"
这一行,把值替换为你的 API key。
这是再测试一次的好机会,但构建和编译之前,打开 ViewController.swift 然后在 locationManager
属性之前添加两个新属性。
fileprivate var startedLoadingPOIs = false
fileprivate var places = [Place]()
startedLoadingPOIs
追踪当前是否有正在进行的请求,CLLocationManagerDelegate 方法可能会被调用多次,哪怕在我们停止了更新位置之后。为了避免多次请求,我们使用了这个标志。places
会存储接收到的 POI。
现在找到 locationManager(manager: didUpdateLocations:)
。在 if
语句里面,添加下面的代码,就在 "后面后有更多代码..." 注释后面:
//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 列表,然后打印到控制台。
构建并运行,看看控制台的输出。应该看起来像这样,但是是不同的 POI:
{
"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" = (
"Ralph Peters"
);
"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";
},
Pardon my french! :]
如果响应返回 NULL,尝试增加半径。
到目前为止,我们的 App 可以确定用户的位置并加载当前区域的 POI 列表。我们有一个类可以存储列表里的地方,虽然我们目前并没使用它。真正缺少的能力是在地图上显示 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
协议的类,然后定义了两个属性和一个自定义初始化方法。
现在万事俱备,只需要在地图上显示 POI 了!
回到 ViewController.swift 然后完成 locationManager(manager: didUpdateLocations:)
方法。找到 print(dict)
行然后替换为如下代码:
//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
- 这几行从字典中获得了所需的信息。响应还包含了很多此 App 不需要的信息。
- 使用提取出来的信息,创建 Place 对象并将其附到
places
数组的末尾。 - 下一行创建了
PlaceAnnotation
用于在 map view 上显示注释。 - 终于注释被添加到了 map view 上。因为修改了 UI,所以必须在主线程上执行代码。
构建并运行。这次,一些注释出现在了地图上,点击一个,会看到地方的名字。这个 App 现在看起来棒棒的,但增强现实在哪里?
介绍 HDAugmentedReality
我们目前已经做了很多工作,已经为接下来的事情做好了准备:是时候带入增强现实了。
你应该已经看到了右下角的 Camera 按钮。现在按这个按钮不会有任何反应。在本节中,我们会给这个按钮添加一些动作,并显示摄像头的实时预览,而且带有一些增强实现元素。
为了让人生不要那么艰难,我们会使用 HDAugmentedReality
库。它已经被包含在你早前下载的启动项目中了,如果想要最新版本的,可以在 Github 上找到,但用这个 lib 也不会把你怎么样,是吧?
首先,HDAugmentedReality
会为你处理相机上的字幕,这样显示实时视频就很容易了。其次,它为你添加 POI 的叠加层并处理它们的位置。
马上就会看到,最后一点真是我们最大的福音,因为这样我们就不用做一些复杂的数学了!如果你想更多地了解 HDAugmentedReality 背后的数学知识,继续读吧。
如果不想的话,希望立即处理代码,完全可以跳过下面两节,直接到开始写代码部分。
警告,这里有数学!
你还在这里,说明你想多多了解 HDAugmentedReality 的数学。很棒棒!但还是要警告一下,这会比标准的算术复杂一点。在下面的例子中,我们假设有两个给定的点 A 和 B,存储地球上特定点的坐标。
点的坐标由两个值组成:经度和纬度。这些是 2D 笛卡尔系统中 x 和 y 值的地理名称。
- 经度指定了点位于英格兰格林威治参考点的以西还是以东。取值范围是 +180° 到 -180°。
- 纬度指定了点位于赤道以北还是以南。取值范围是北极的 90° 到南极的 -90°。
如果看一个标准的地球仪,就能看到从极点到极点的经度线——也被称为经线。还会看到全球各地的纬度线,也称为平行线。地理书里会讲两条平行线间的距离约为 111 公里,两条经线之间的距离也为约 111 公里。
有 360 条子午线,360 度的每一度,以及 180 条平行线。把这个记在脑子里,就可以使用以下公式计算地球上两点间的距离:
这样就得到了纬度和经度的距离,它们是直角三角形的两边。使用毕达哥拉斯定理,现在可以计算三角形的斜边以找出两点间的距离:
这太简单了,但很不幸,这是错的。
如果再看一眼你的地球仪,你会看到平行线之间的距离几乎相等,但经线在两极相交。所以越靠近极点,子午线之间的距离就会缩小,而在极点上是零。这意味着以上公式仅适用于赤道附近的点。点越接近极点,误差越大。
要更准确地计算距离,可以使用大圆距离。这是球体上两点之间的距离,众所周知,地球是一个球体。好吧,她近似是一个球体,但这种方法会计算出很好的结果。已知两个点的纬度和经度,可以使用下面的公式来计算大圆距离。
这个公式给出了两点之间的距离,精度约为 60 公里,如果你想知道东京和纽约的距离,这样就相当好了。但对于更近的点,结果将会更好。
唉——这太难了!好消息是,CLLocation 有一个方法,distanceFromLocation:,它会为你做这个计算。HDAugmentedReality 也是用这个方法。
为什么是 HDAugmentedReality
你可能在琢磨“嗯,我还是不明白我为什么应该使用 HDAugmentedReality。”使用框架并且显示它们真的没那么难,可以在它的网站上阅读有关信息。我们可以无痛使用 CLLocation 的方法来计算点之间的距离。
那我为什么要介绍这个库呢?当你需要计算屏幕上显示的叠加层的位置时,问题就来了。假设有一个 POI 在你的北边,然后你的设备正朝向东北。应该把 POI 显示在哪里呢——中心还是左侧?顶部还是底部?
这一切都取决于设备在房间中的当前位置。如果设备指向地面上的一个标题,附近的 POI 就应该显示在顶部。如果指向南边,就不应该显示所有 POI。这样很快就会变的相当复杂!
这就是 HDAugmentedReality 最有用的地方。它从陀螺仪和罗盘获取所需的所有信息,并计算设备指向的位置及其倾斜程度。借助这个知识,它决定是否和如何把 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
arViewController.headingSmoothingFactor = 0.05
//3
arViewController.setAnnotations(places)
self.present(arViewController, animated: true, completion: nil)
- 首先设置了 arViewController 的 dataSource。dataSource 为视图提供可视化 POI。
- 这是对 arViewController 的一些微调。maxVisibleAnnotations 定义了同时显示的视图数量。为了保持一切顺滑,值使用了 30,但这也意味着,如果你住在一个令人兴奋的地区,周围有很多 POI,但可能并不会全部都显示出来。
- headingSmoothingFactor 用于移动屏幕相关的 POI 视图。值为1意味着没有平滑,如果你的 iPhone 绕着视图转动,视图可能会从一个位置跳到另一个位置。较低的值表示移动是动画,但是视图可能会有一点滞后于“移动”。你应该多尝试这个值,以很好地平衡顺滑移动和速度。
- 显示 arViewController。
你还应该看看 ARViewController.swift 里面,找到更多类似 maxDistance 的属性,这个属性定义了以米计算的范围,以在其中显示视图。所以任何大于这个值的东西都不会被显示。
实现 Datasource 方法
Xcode 会在将 self 赋值为 dataSource 的那一行抱怨,为了让它开心,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)
}
}
}
- 首先添加了一个代理协议,后面会用。
- 创建了 ARAnnotationView 的子类,用于显示一个 POI 视图。
- 该 App 中的视图只显示一个带有 POI 名称的 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, with event: UIEvent?) {
delegate?.didTouch(annotationView: self)
}
- 每次需要重新绘制视图时,都会调用次方法,只要确保 labe 有正确的 frame 值,重置它们即可。
- 这里我们告诉 delegate 视图被触摸了,所以 delegate 可以决定如何以及哪个动作需要被执行。
现在回到 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
}
}
这里我们创建了一个新的 AnnotaionView 并在 return 它之前为其设置 delegate。
真正可以测试视图之前,还需要另一个扩展。
extension ViewController: AnnotationViewDelegate {
func didTouch(annotationView: AnnotationView) {
print("Tapped view for POI: \(annotationView.titleLabel?.text)")
}
}
激活相机之前,必须向 Info.plist 添加一个键。打开该文件并添加一个值为“AR 所需”的键 NSCameraUsageDescription,就像我们访问位置信息时做的一样。
构建并运行,点击地图视图上的相机按钮进入 ar 视图。第一次这样做时,系统将在访问相机之前弹出权限对话框。点击一个 POI 然后查看控制台。
结束触摸
我们现在有了一个能完整工作的 AR App,我们可以在摄像头视图上显示 POI 并检测 POI 上的点击,为了彻底完成这个 App 我们现在需要添加一些触摸的处理逻辑。
打开 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)
}
}
}
}
}
- 首先把 annotationViews 的 annotation 转为 Place。
- 加载此地的附加信息。
- 将其分配给合适的属性。
- 我们现在来实现 showInfoView(forPlace:) 这个方法。
在 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 视图以显示附加信息,POI 的名字是标题,info 文本是消息。
- 由于 ViewController 现在还不是视图层级的一部分,可以使用 arViewController 来显示 alert。
再次构建并运行,就可以看到最终的 App 了。
下一步?
这里可以下载 最终项目 ,带有上面全部的代码。
恭喜,你现在已经知道如何制作自己的基于位置的增强现实 App!另外,还看到了 Google Places API 的简单介绍。
如果有任何意见或问题,直接在下面评论!