先插播一个小话题——异步操作(Asynchronous operations)
有时app做某件事的时候会花一点时间。在你开始一个操作后,你必须等待它给你返回结果,如果你运气不够好,也许永远都等不到返回结果。
比如Core Location这个例子,它会花费两到三秒得到第一个精度不太高的位置信息,然后再一点点的提高精度。
异步的意思就是,当你执行类似这样的操作后,app不再等待,而是该干嘛干嘛,用户界面依旧可以响应用户的其他操作,新的事件可以立即被处理,用户仍然可以点击app中的其他部分。
异步进程通俗的讲,就是把操作放入后台。一旦这个操作结束,app会通过委托注意到它。
与异步相反的是同步(synchronous)。如果一个操作是同步的,那么app不会再接收其他操作,直到等待这个同步操作完成为止。从效果上看,就是app被不会动了。
假如CLLocationManager是同步操作的话,那么它会带来一些大问题:当你得到一个位置信息前,app会有几秒钟时间完全失去响应。类似于这种假死,对用户是非常不友好的。
例如:MyLocation app的底部有一个tab bar。如果在获取位置信息时,app假死了,那么你点击tab bar切换到其他页面就不会有响应。对用户而言,应该在任何时候都可以随意切换页面才是良好的使用体验,但是现在你的app却假死了,甚至挂了。
iOS的设计师决定了类似这种行为是无法接受的,因此所有需要花费一点时间执行的东西都以异步的方式管理。
在下一个课程中,我们会讲一些对于网络的操作,比如从internet上下载点东西,那时我们会更加深入的讨论异步进程的内容。
顺便说一下,iOS有一种叫做“看门狗定时器”的东西。如果你的app假死太长时间,看门狗计时器会杀掉你的app。
结论就是:任何会耗费一定时间的东西都应该用异步的方式处理,把它们放到后台去。
将坐标展示在界面上
locationManager(didUpdateLocations)委托方法会给你返回一个数组,里面是包含着当前经纬度的CLLocation对象列表。(这些对象还有些额外信息,比如高度和速度,但是在我们这个app中用不到这些。)
你要把数组中最后一条CLLocation对象取出来,因为这是最近的一条,并且将它展示在之前你在界面上添加好的label中。
打开CurrentLocationViewController.swift,添加一个新的实例变量:
var location: CLLocation?
你会把用户目前的位置存到这个变量里。
这个变量必须是可选型,因为有可能获取位置信息会失败,比如用户目前身处撒哈拉沙漠这种既没有GPS也没有基站和Wifi的地方。
将locationManager(didUpdateLocations)方法中的内容改变为:
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
let newLocation = locations.last!
print("didUpdateLocations \(newLocation)")
location = newLocation
updateLabels()
}
你将从location manager中得到的CLLocation对象保存到了实例变量中,并且调用了一个新的updateLabels()方法。
保留print语句,因为后面我们还会用到它。
现在我们来添加updateLabels():
func updateLabels() {
if let location = location {
latitudeLabel.text = String(format: "%.8f",location.coordinate.latitude)
longitudeLabel.text = String(format: "%.8f", location.coordinate.longitude)
tagButton.isHidden = false
messageLabel.text = ""
} else {
latitudeLabel.text = ""
longitudeLabel.text = ""
addressLabel.text = ""
tagButton.isHidden = true
messageLabel.text = "Tap 'Get My Location' to start"
}
}
因为location实例变量是可选型的,所以你使用if let语句来对它进行解包。
注意一下,解包后的变量名称和可选型变量名称一样,是ok的,这里它们都叫做location。在if语句内,location引用一个实际的CLLocation对象,它不会为nil。
如果location对象是有效的,那么你就将latitude和longitude从Double转换为String,然后将它们放入标签中。
之前我们可以通过字符串插值的方式将值放入字符串,所以为什么我们在这里没有使用字符串插值呢?比如像下面这样:
latitudeLabel.text = "\(location.coordinate.latitude)"
这样做确实可以,但是有个坏处是你无法控制latitude的精度。对于我们这个app,我们希望经纬度都保留8位小数就可以了。
为了达到这个目的,你需要使用所谓的字符串格式化。
和字符串插值一样,字符串格式化中的占位符会在运行时被实际的值替代。这些占位符,或者说格式说明符,有点复杂。
为latitude标签创建文本时,你使用了:
String(format: "%.8f",location.coordinate.latitude)
这行代码创建了一个新的字符串对象,使用"%.8f"格式说明符,字符串中插入的值就是location.coordinate.latitude。
占位符一定是由一个百分号%开始。比如常见的%d用于整数,%f用于浮点数,%@用于任意对象。
字符串格式化在Object-C中非常常见,但是在Swift中就非常少,因为字符串插值非常简单,而且足以应对大多数情况,但是字符串插值存在死角,比如这里,就必须用字符串格式化。
%.8f格式说明符的作用和%f一样,都是将浮点数放入字符串,.8的意思是该浮点数仅保留8位小数。
运行app,点击Get My Location按钮,然后你就会看到经纬度已经展示在界面上了。
当这个app启动时,location对象并不存在(location为nil)因此我们应该在app的顶部展示“Tap ‘Get My Location’ to Start”信息来提示用户。但是现在app并没有展示这个信息,那是因为你在没有接受到坐标信息时,都没有调用updateLabels()方法。
你应该在viewDidLoad()中调用updateLabels():
override func viewDidLoad() {
super.viewDidLoad()
updateLabels()
}
运行app。这时在app一开始运行,你就可以看到“Tap ‘Get My Location’ to Start”信息了,此时latitude和longitude仍然是空的。
处理错误
获取GPS坐标,是非常容易出错的。也许你在一些场所比如高楼大厦中,信号不好,无法接受GPS信号。也许你的附近没有Wifi,情况太多了。
能成功的接受GPS信号和设备的关系也很大,比如iPhone在这方面的表现就比iPod要强的多。
所以你的app比如在接受GPS信号时,具备很强的容错性能,否则任何一个细节都会让你的app挂掉,这肯定不是你和你的用户想看到的结果。并且没有任何保证说你可以顺利接受GPS信号。
这正是真实的软件开发所遭遇的问题。你必须提供容错能力,来处理GPS信息获取失败的情况。
在CurrentLocationViewController.swift中添加两个实例变量:
var updatingLocation = false
var lastLocationError: Error?
将locationManager(didFailWithError)修改为:
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
print("didFailWithError \(error)")
if (error as NSError).code == CLError.locationUnknown.rawValue {
return
}
lastLocationError = error
stopLocationManager()
updateLabels()
}
location manager可以在多种情况下提供错误信息,你可以从Error对象中的code属性看到它们,并且从而得知错误的详细情况。(首先你要扮演NSError角色,它是Error的子类,其中包含具体的错误信息)
Core Location发生错误的一些情况:
CLError.locationUnknown:目前的位置信息未知,但是Core Location还在努力搜索。
CLError.denied:用户拒绝了app访问位置信息。
CLError.network:找不到可用的网络。
还有其他的一些情况,就不一一例举了,你要明白的是很多原因都会导致Core Location报错。
⚠️:所有的报错信息都在CLError的枚举中有定义。回忆一下,一个枚举(an enumeration, or enum),是值和名称的一个列表。
Core Location使用整数来表示错误代码以外,还使用CLError枚举给所有的错误都给了一个名称,使得错误的信息比较容易理解。
将这些名称转换为整数时,你需要使用rawValue。
在你的locationManager(didFailWithError)方法中,你使用了如下代码:
if (error as NSError).code == CLError.locationUnknown.rawValue {
return
}
CLError.locationUnknown错误意味着location manager无法立即获取一个位置信息,但是还在努力搜索,并不是说获取失败了。
当你遇到这种错误,你需要保持搜索状态,直到你获得一个地址信息,或者获得其他的报错信息。
对于其他报错信息,你将error对象存储到新的实例变量lastLocationError中:
lastLocationError = error
这样,你就可以得知你面对的是什么情况了。在这里使用updateLabels()非常必要。你要向用户展示这些错误的信息,因为用户得不到反馈信息的话,会立刻卸载掉你的app。
练习:你能解释为什么lastLocationError是个可选型吗?
答案:当没有报错时,lastLocationError不会有值。也就是说它可能会为nil,而只有可选型变量才可以将nil作为其值。
最后,你调用了两个方法:
stopLocationManager()
updateLabels()
这里的stopLocationManager()是新添加的。如果无法获取用户的位置信息,那么你应该停止location manager。为了节省电池的电量,应该在不需要GPS功能时,立刻将其中断掉。
如果是那种需要持续获取用户位置信息的app,则需要保持location manager持续工作,说不定走几公里就有信号了呢。
但是对于我们这个app而言,用户如果发生了位置变动,那么他只需要再点击一次Get My Location按钮就可以了,无须持续保持location manager工作。
我们现在来添加具体的stopLocationManager()方法:
func stopLocationManager() {
if updatingLocation {
locationManager.stopUpdatingLocation()
locationManager.delegate = nil
updatingLocation = false
}
}
这里有一个if语句,用来检查bool型变量updatingLocation是true还是false。如果为false,那么就是说location manager没有工作,也没有必要去中断它。
updatingLocation作用是,在获取位置信息时,用它来改变Get My Location按钮的状态,以及message标签的信息,这样用户就可以知道app的工作状态,而不是一无所知。
改变一下updateLabels()方法,添加展示错误信息的部分:
func updateLabels() {
if let location = location {
...
} else {
latitudeLabel.text = ""
longitudeLabel.text = ""
addressLabel.text = ""
tagButton.isHidden = true
//新代码从这里开始
let statusMessage: String
if let error = lastLocationError as? NSError {
if error.domain == kCLErrorDomain && error.code == CLError.denied.rawValue {
statusMessage = "Location Services Disabled"
} else {
statusMessage = "Error Getting Location"
}
} else if !CLLocationManager.locationServicesEnabled() {
statusMessage = "Location Services Disabled"
} else if updatingLocation {
statusMessage = "Searching..."
} else {
statusMessage = "Tap 'Get My Location' to Start"
}
messageLabel.text = statusMessage
}
}
新增的代码决定展示在屏幕上的信息是什么。它使用了一大堆if来指出目前app的状态。
如果location manager报错了,那么message标签会显示出报错信息。
首先我们检查CLError.denied,看看用户是否授权你的app可以使用位置信息。这是最要紧的一点,所以你要优先检查它。
如果报错为其他信息,你则展示“Error Getting Location”来表示无法获取到用户的位置信息。
然后你要检查的是,用户是直接关闭了位置服务,并不是针对某个app,而是在设置中直接关闭了位置服务,你通过locationServicesEnabled()方法来检查这个状态。
假设没有报错,一切都正常进行,那么标签应该现实“Searching...(搜索中)”。
没人喜欢等待,所以最好每一步都向用户展示出来,这样用户就会知道app此时正在做什么,而不是急于卸载它。
⚠️:这里我们将所有的逻辑都放入了一个方法中,是因为便于统一管理,以后界面上要展示什么新的东西,直接改这一个地方就可以了,当获取到位置信息时,或者获得一个报错时,都是调用updateLabels()。
同时添加一个startLocationManager()方法。我建议你就将这个方法写在stopLocationManager()上面,将相关功能的方法放在一起,便于阅读。
func startLocationManager() {
if CLLocationManager.locationServicesEnabled() {
locationManager.delegate = self
locationManager.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
locationManager.startUpdatingLocation()
updatingLocation = true
}
}
之前我们使用getLocation()方法来启动location manager。因为你现在有了一个stopLocationManager()方法,所以我们最好将相关的功能都分别归类,添加这个startLocationManager()是为了保持逻辑上的平衡。
改动一下getLocation()方法:
@IBAction func getLocation() {
let authStatus = CLLocationManager.authorizationStatus()
if authStatus == .notDetermined {
locationManager.requestWhenInUseAuthorization()
return
}
if authStatus == .denied || authStatus == .restricted {
showLocationServicesDeniedAlert()
return
}
startLocationManager()
updateLabels()
}
还有一点微小的细节需要注意。假设有一个报错并且没能获取到一个位置信息,于是你四处移动了一下,突然就可以获得位置信息了,这种情况下,最好把之前的报错信息清除掉。
在locationManager(didUpdateLocations)方法的底部,仅仅是在调用updateLabels()前,增加一行代码:
lastLocationError = nil
这样就可以在获取到一个位置信息时,把之前的错误信息全部清除掉。
运行app,当app等待获取位置信息时,顶部标签会显示“Searching...”,直到获取到一个位置信息或者一个报错信息为止。