iOS Apprentice中文版-从0开始学iOS开发-第三十二课

试着更改下模拟器中的区域,比如从中国改到美国,然后看看会发生什么事。

注意一下,即使把模拟器的区域改为None,也不会报错,仍然会返回.locationUnknown错误代码,但是你可以忽略它,因为这个报错并不致命。

小帖士:你也可以在Xcode内调整模拟的地区。如果你的app使用了Core Location,那么你会在调试区域顶部看到一个三角状的导航图标,点击这个图标就可以切换地区了:

iOS Apprentice中文版-从0开始学iOS开发-第三十二课_第1张图片

理想情况,除了模拟器意外,你还应该在实际的设备上进行测试,这样你才可以把握真实情况下会发生什么。

改进返回结果

酷!你已经掌握了如何通过Core Location获得CLLocation对象,并且提高了app的容错能力,还有比这更牛掰的吗?

那么,接下来我们要这样干:在模拟器中你看到了Core Location不断的刷新位置信息,即使坐标根本没有发生改变。这是因为用户可能会移动,这种情况下GPS坐标就会发生变化。

然而,你要做的并不是导航类app,我们在得到精度匹配的位置信息后,就需要通知location manager停止更新地址信息。

这是非常重要的,因为持续更新位置信息,会非常耗电。我们的app并不需要实时的更新位置信息,所以当获得精度足够的位置信息后,就应该停止更新位置信息。

问题在于,精度到什么程度才算是获得了足够的精度呢?有个简单的判断方法,就是最后一次获得的位置信息与上一次比较,如果相同,则认为精度已经到达最高了。

改变一下locationManager(didUpdateLocations)方法:

func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        let newLocation = locations.last!
        print("didUpdateLocations \(newLocation)")
        
        //1
        if newLocation.timestamp.timeIntervalSinceNow < -5 {
            return
        }
        
        //2
        if newLocation.horizontalAccuracy < 0{
            return
        }
        
        //3
        if location == nil || location!.horizontalAccuracy > newLocation.horizontalAccuracy {
            //4
            lastLocationError = nil
            location = newLocation
            updateLabels()
        }
        
        //5
        if newLocation.horizontalAccuracy <= locationManager.desiredAccuracy {
            print("*** We're Done!")
            stopLocationManager()
        }
    }

我来为你们一一讲解:
注释1、如果获得location对象的时间过长,比如说5秒,那么这就是所谓的快照。
此时并不是返回了一个新的位置信息给你,而是返回给你最近一次得到的地址,此时app假定你在最后几秒内没有发生比较大的位移。
你要做的就是忽视这类结果,因为它们太旧了。

注释2、判断最新的位置精度是否比之前的一次要高,你使用了location对象中的horizontalAccuracy属性,有时horizontalAccuracy接近于0,这种时候,测量就是无效的,你也应该忽视这个结果。

注释3、这里你决定最后获得的这个位置是否比之前的一个更加精准。总体而言,Core Location一开始的精度很差,然后逐渐提高精度,但是这里没有保证,你也不能假设后面的就比前面的精度高。
注意一下,精度的值越大意味着越不精准,比如精度100米,和精度10米,明显精度10米的精确度高,但是数值上100比10大。
同时你也检查了location == nil的情况。回忆一下location是个可选型实例变量,用户存储你获得的CLLocation对象,如果它为nil,那就是说程序可能刚刚运行,还没有获得位置对象,这种情况下,我们需要继续执行获取位置信息。
所以,如果location为nil,或者最后的一个位置信息的精度比前一个高,我们就执行第四步。否则,则忽视掉位置更新。

注释4、我们之前见过这一部分代码。它清除了之前所有的错误信息,如果之前有的话。并且将新的CLLocation对象存入到location变量中。

注释5、如果获取到的位置的精度,比设定要求的精度还要高,你就可以直接退出location manager了。最初我们设置的是精度10米,这就够用了,对我们这个app而言。

短路

因为location是个可选型对象,所以你不能直接读取它的属性,你必须先对其进行解包。你可以用if let去解包,但是假如你确定这个可选型绝对不会为nil的话,你也可以用感叹号来强制解包。

这就是你在代码中的处理方式:

if location == nil || location!.horizontalAccuracy > newLocation.horizontalAccuracy {

location!.horizontalAccuracy这里有一个感叹号,而不仅仅是 location.horizontalAccuracy(不带感叹号)。

但是假如location等于nil,这个强制解包不会导致app挂掉吗?在这种情况下不会,因为它根本不会执行。

这里的||操作符(或操作符),用于判断两个条件之一是否为真。如果location == nil为真,那么后面的条件会被忽略掉根本不会执行,这就叫做短路。当第一个条件为真时,就不需要再校验后面的条件了。

所以app仅仅会在location不等于nil时才会执行location!.horizontalAccuracy,这时location得到了绝对不会为nil的保证。

运行app。首先设置模拟器的location为none,然后点击Get My Location按钮,屏幕上会显示“Searching...”

将location切换到Apple(不要再次点击Get My Location)。稍等片刻后,GPS坐标就会在屏幕上显示出来。

如果你观察一下调试区域,你大概会看到10几条location更新,然后就是“*** We're done!”,并且此时location的更新就停止了。

⚠️:也许你的执行结果和上面描述的不一样。如果屏幕上没有显示“Seraching...”而是展示了旧的坐标信息,那么就是模拟器的缓存中保留了上一次获取的旧的位置信息。
遇到这种问题可以尝试先退出模拟器,然后再次运行app,如果还是不行,也不要担心,由它去吧,模拟器有时不是很聪明

作为开发者,你可以从调试区域看到位置更新何时结束,但是用户是无法看到的。

只要你一获取到位置信息,Tag Location按钮立刻会可见,如果用户此时就保存地址,那么很可能保存的地址不是精度最高的那个。所以我们最好给用户展示一些信息,可以让用户判断当前的情况。

为了清晰的展示获取位置信息的进度,你需要在获取用户信息进行时,将Get My Location按钮的标题改变为stop,等获取位置信息结束后,重新将标题切换为Get My Location。在课程的后面,你会添加一个读取进度的动画来清晰的展示搜索没有结束。

为了切换按钮的状态,你需要添加一个configureGetButton()方法。

打开CurrentLocationViewController.swift添加这个方法:

func configureGetButton() {
        if updatingLocation {
            getButton.setTitle("Stop",for: .normal)
        } else {
            getButton.setTitle("Get My Location", for: .normal)
        }
    }

逻辑非常简单,当app还在搜索位置信息时,按钮标题显示为“Stop”,搜索结束后按钮标题显示为“Get My Location”。

在以下几个地方调用configureGetButton()方法:

override func viewDidLoad() {
        super.viewDidLoad()
        updateLabels()
        configureGetButton()
    }
@IBAction func getLocation() {
        ...
        startLocationManager()
        updateLabels()
        configureGetButton()
    }
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
        ...
        updateLabels()
        configureGetButton()
    }
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        ...
        if newLocation.horizontalAccuracy <= locationManager.desiredAccuracy {
            print("*** We're Done!")
            stopLocationManager()
            configureGetButton()
        }
    }

只要调用了updateLabels()的地方,都需要调用configureGetButton()。除了在locationManager(didUpdateLocations)中,你在搜索结束时,单独调用了一下configureGetButton()。

运行app,并且测试一下效果。当点击Get My Location按钮后,这个按钮的标题会改变为“Stop”,当搜索结束后,又会将标题切换回Get My Location。

iOS Apprentice中文版-从0开始学iOS开发-第三十二课_第2张图片

当按钮标题显示为“Stop”时,用户会非常自然的想要点击这个按钮来中断搜索。尤其是在信号不好,搜索了很长时间的时候。

眼下,当按钮显示为“Stop”时,点击它是没有任何作用的。你需要改动一下getLocation()按钮,将getLocation()中的startLocationManager()替换为下面的语句:

if updatingLocation {
            stopLocationManager()
        } else {
            location = nil
            lastLocationError = nil
            startLocationManager()
        }

你再一次使用了updatingLocation来判断app的运行状态。

如果按钮是在搜索期间被点击,那么你就中断掉location manager。

注意一下,在开始一次新的搜索前,你需要将旧的位置信息和报错信息都置空。

运行app,在搜索进行时点击“Stop”,试试效果。

⚠️:如果Stop显示的时间过短,短到你都来不及点击,那么你可以首先将location设置为None,这样就会搜索很久了。

地址解析

GPS坐标给出的结果仅仅是代表经纬度的数字。比如坐标37.33240904, -122.03051218,能从上面得到的信息很少。

使用一种叫做地址解析的过程,你可以将经纬度坐标转换成我们熟知的邮政地址。

你将使用CLGeocoder对象来将location数据转换为可读的地址信息,并且将这个地址信息通过addressLabel展现在屏幕上。

这是非常简单的,只需要记住一些规则。你不能一次性发送大量坐标请求转换。这个地址解析的进程会占用苹果服务器的一些空间并且消耗一定带宽,对于一个坐标,我们只发送一次请求。

地址解析需要设备接入互联网。

打开CurrentLocationViewController.swift,添加以下属性:

let geocoder = CLGeocoder()
var placemark: CLPlacemark?
var performingReverseGeocoding = false
var lastGeocodingError: Error?

CLGeocoder对象负责执行地址解析,CLPlacemark对象包含地址结果。

placemark变量是可选型是因为当没有location时,它不会有值,或者获取到的坐标无法被转换。(比如非洲撒哈拉大沙漠,可能无法被正常转换,虽然我没有亲自试过,不过你可以去撒哈拉沙漠试试,如果可以正常转换,请告知我下。)

当一个地址解析操作发生时,你将performingReverseGeocoding设置为true。

你将地址解析工作放入locationManager(didUpdateLocations)中:

func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
        ...
        if newLocation.horizontalAccuracy <= locationManager.desiredAccuracy {
            print("*** We're Done!")
            stopLocationManager()
            configureGetButton()
        }
        
        //新的代码从这里开始
        if !performingReverseGeocoding {
            print("*** Going to geocode")
            performingReverseGeocoding = true
            geocoder.reverseGeocodeLocation(newLocation, completionHandler: {
                placemarks,error in
                print("*** Found placemarks:\(placemarks),error:\(error)")
            })
        }
    }

我们的app应该在同一时间仅执行一次地址解析,所以首先你通过performingReverseGeocoding检查是否有地址解析正在执行。然后才开始地址解析

这段新的代码看上去非常简单明了,除了completionHandler这里你不太熟悉。

与location manager不同,CLGocoder不是使用委托来向你传递结果,而是通过一种叫做闭包(closure)的东西。

一个闭包就是一个代码块,和方法或者函数很像,闭包中的代码通常不是立即执行,而是保存在某个地方并且在某个稍后的点执行。

闭包是Swift的一个重要特色,你到处都可以看到它(闭包和OC中的块的概念很类似)

目前这个闭包中仅有一条print语句,这样你就可以通过打印结果来确认发生了什么。

与locationManager(didUpdateLocations)方法中的其他代码不同,闭包中的代码并不是立刻执行。毕竟你要在地址解析结束后才打印地址信息,而前者也许会花几秒钟时间。

闭包被CLGeocoder对象保管,直到CLGeocoder找到一个地址或者遇到一个error时,闭包中的代码才开始执行。

那么为什么CLGeocoder使用了闭包而不是委托呢?

使用委托提供反馈的问题是你必须写一个或者多个独立的方法。例如CLLocationManager对象,它的委托方法是:locationManager(didUpdateLocations)和locationManager(didFailWithError)。

通过使用不同的方法你将处理响应的代码和返回结果的代码分离开了。使用闭包,你可以把这些代码放到一起。这样使得代码更加紧凑并且易读。(有些API提供了闭包和委托可以让你选择)。

所以当你写下面的代码时:

geocoder.reverseGeocodeLocation(newLocation, completionHandler: {
  placemarks, error in
    // 在这里写语句
}

你告诉了CLGeocoder对象你想要对location进行地址解析,并且闭包中的代码应该在地址解析完成后立刻执行。

闭包本身是这个样子的:

{ placemarks, error in
    // put your statements here
}

in前面的关键字placemarks和error是闭包的参数,它们的工作原理和方法以及函数中的参数是一样的。

当地址解析从location对象中找到一个结果后,它唤醒闭包并且执行其中的代码。placemarks参数包含一个CLPlacemark对象的数组,用于描述地址信息,error变量当无法正常解析时包含报错的信息。

再重复一次:当调用locationManager(didUpdateLocations)方法时,闭包中的代码并不是立即执行,而是由CLGeocoder保管,直到地址解析结束后才执行。

委托方法的原则也是一样的,除了一个使用独立的方法,一个将代码放入闭包中。

如果你对闭包不太理解,也没有关系。以后我们会经常接触它,你有的是机会熟悉。

运行app,一旦第一个location被找到,你就可以在调试区域看到地址解析的信息了:

didUpdateLocations <+37.33240754,-122.03047460> +/- 65.00m (speed -1.00
mps / course -1.00) @ 7/19/16, 1:43:25 PM Central European Summer Time
didUpdateLocations <+37.33233141,-122.03121860> +/- 50.00m (speed -1.00
mps / course -1.00) @ 7/19/16, 1:43:30 PM Central European Summer Time
*** Going to geocode
*** Found placemarks: Optional([Apple Inc., Apple Inc., Cupertino, CA
95014, United States @ <+37.33202890,-122.02956600> +/- 100.00m, region
CLCircularRegion (identifier:'<+37.33214710,-122.03128175> radius
368.39', center:<+37.33214710,-122.03128175>, radius:368.39m)]), error:
nil

如果你的location选择为apple,你就可以看到和上面一样的信息,地址解析对每个地址仅执行一次,当一个精度较高的坐标获取到以后,才会再次进行地址解析,非常不错。

⚠️:部分读者反应,在中国运行代码时,如果选择了中国意外的地区,那么解析会报错,这种情况下你就选择一个位于中国内的location就可以了。

在闭包中添加以下代码,直接写在print语句后面:

self.lastLocationError = error
                if error == nil,let p = placemarks, !p.isEmpty {
                    self.placemark = p.last!
                } else {
                    self.placemark = nil
                }
                self.performingReverseGeocoding = false
                self.updateLabels()

你将error对象存储起来,以便以在需要的时候引用它,虽然这次你使用了一个新的实例变量lastLocationError。

下面这一行的样子,你以前没有见过:

if error == nil,let p = placemarks, !p.isEmpty {

前面你已经学过if let是用来解包可选型的。这里,placemark是个可选型,所以你需要在使用它之前对他进行解包。解包后的placemarks数组被存放到一个叫做p的临时容器中。

!p.isEmpty的意思是只有当placemark对象存在的时候,才执行if内的代码。

这一行的正确读法是:

if there’s no error and the unwrapped placemarks array is not empty {

//如果这里没有报错并且解包后的placemarks不为空

当然,Swift并不是英语,所以你要用Swift的术语来表达这些含义。

你也可以把这个单行的语句写成嵌套的if形式:

if error == nil {
  if let p = placemarks {
    if !p.isEmpty {

对比一下就会发现,还是写到一行又简单又好读。

我们在这里做了很多防御措施:首先检查这里不存在报错,然后确保解包后的placemarks数组中至少存在一个对象,而并不是假定placemarks数组中一定存在对象,好的程序员就要养成这样的习惯。

如果三个条件都满足了,没有error,placemarks不为nil,并且其中至少存在一个CLPlacemark对象,然后你获取数组中的最后一条CLPlacemark对象:

self.placemark = p.last!

last属性的作用就是引用数组中的最后一条记录。因为它是可选型,因为数组中可能不存在对象。所以你要用感叹号来解包。你还可以用placemarks[placemarks.count - 1]来获取数组中的最后一个对象,但是不推荐这样做。

通常数组中只会有一个CLPlacemark对象,但是有一种古怪的情况,就是一个坐标对应多个地址。但是一次只能处理一个地址,所以你挑出最后一个(也就是精度最高的那个)来处理。

如果地址解析报错,你就将self.placemark设置为nil。注意一下,你没有对location进行同样操作。如果这里有一个报错,你还保有前一个location对象,因为也许它的精度就足够高了。但是对于地址信息不能这样处理。

你不能展示旧的地址信息,仅当地址信息与目前的坐标一致,或者根本没有地址信息时才展示。

对于手机开发,没有什么事情是确定的。你也许会得到一个坐标,也许不会,假设得到了一个地址,也许精度达不到要求。地址解析必须在手机以某种方式接入网络才有用,但是你也要做好准备处理没有网络的情况。

并且你需要记住,不是所有的GPS坐标都能转换为地址(比如你身处撒哈拉沙漠中)

⚠️:你注意到没,在闭包中你对这个视图控制器的每一个实例变量和方法都使用了self关键字。这是Swift要求的。
闭包可以捕获它使用的变量,self就是其中之一,也许你过会就把这个事给忘了,但是你一定要记住,在闭包中,被捕获的变量一定要明确的指明它们的属主。
你之前见到过,在闭包的外面,你可以使用关键字self来引用一个实例变量,但这不是强制性的。然而,闭包内省略了self的话就会编译失败。

让我们把地址信息展示给用户看

改变一下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 = ""
            
            //新代码从这里开始
            if let placemark = placemark {
                addressLabel.text = string(from: placemark)
            } else if performingReverseGeocoding {
                addressLabel.text = "Searching for Address..."
            } else if lastGeocodingError != nil {
                addressLabel.text = "Error Finding Address"
            } else {
                addressLabel.text = "No Address Found"
            }
        } else {
          ...
               }
}

因为一旦app获得一个有效坐标你就会查找它对应的地址,所以你仅仅在第一个if内进行改动。如果你获得了一个地址信息,你就将它展示给用户,否则就展示状态信息。

方法string(placemark)是一个新建的方法,用来将CLPlacemark对象转换为字符串,我们现在来添加这个方法:

func string(from placemark: CLPlacemark) -> String {
        //1
        var line1 = ""
        //2
        if let s = placemark.subThoroughfare {
            line1 += s + " "
        }
        //3
        if let s = placemark.thoroughfare {
            line1 += s
        }
        //4
        var line2 = ""
        
        if let s = placemark.locality {
            line2 += s + " "
        }
        
        if let s = placemark.administrativeArea {
            line2 += s + " "
        }
        
        if let s = placemark.postalCode {
            line2 += s
        }
        
        //5
        return line1 + "\n" + line2
    }

我们来逐条讲解下:

1、为第一行文本创建了一个新的String型变量

2、如果placemark有一个子街道(subThoroughfare),就把它添加到字符串中。这是一个可选型属性,所以你首先用if let对其解包。和你知道的一样子街道是房屋号的别名(欧美的地址特征,国内地址不会有这个属性,子街道翻译的也不是很准,明白意思就好)

3、把街道名称(thoroughfare)添加进字符串。注意一下,子街道名称和街道名称间有一个空格。

4、对第二个字符串进行同样的逻辑处理,这次是添加城市、省份和邮政编码到字符串中。

5、最后把两个字符串拼接为一个字符串,\n的意思是换行。

小贴士:如果你想知道一个方法或者一条属性的意思,可以按住Option键,再降鼠标移动到上面点一下,就可以看到详细信息了:

iOS Apprentice中文版-从0开始学iOS开发-第三十二课_第3张图片

如果弹出的窗口是空的,那么就打开Xcode的设置面板找到Components子页,然后点击Documentation子页,先安装上iOS 10 documentation。

这个技巧同样可以用于你自己定义的变量。Swift的类型推断是很方便,但是有时候会使你搞不清变量的类型。用这个方法就可以查看变量的详细信息了:

iOS Apprentice中文版-从0开始学iOS开发-第三十二课_第4张图片

在getLocation()中,清空placemark和lastGeocodingError变量,这一步的作用是重置它们的初始状态。在调用startLocationManager()方法的上方,添加如下语句:

placemark = nil
lastGeocodingError = nil

运行app。现在你可以看到地址信息和位置坐标都展示出来了:

iOS Apprentice中文版-从0开始学iOS开发-第三十二课_第5张图片

街道号码或者一些其他信息丢失,是非常常见的,不必为此惊讶。因为CLPlacemark中的信息本来就不全,这就是为什么这个对象中的所有属性都是可选型的。

⚠️:由于某些原因,UK(英联邦)的邮政编码会丢失最后几个字符。上面截图地址的全部应该是London England W1J 9HP。这貌似是Core Location的一个bug。

练习:如果你自模拟器的Debug菜单中选择了City Bicycle Ride(环城自行车)或者City Run(环城跑),你应该会在调试区域看到一大堆不同的坐标跳出来(此时模拟器是在模拟不停的切换位置)。然而,屏幕上的地址和坐标并不会跟随变化,这是为什么呢?

答案:MyLocation app的设计是找到固定位置的精度最高的坐标。你仅仅在一个新的坐标比前一个更高时更新location变量。任何精度较低或者精通相同的坐标都会被忽视,而并不会在意用户的实际文位置在哪里。

在City Bicycle Ride(环城自行车)或者City Run(环城跑)模式下,app不会对同一个坐标缩紧精度,所以每个坐标的精度都是相同的。这就是说,这个app在移动中会表现很差,但是我们的设计就是用来测量固定位置。

⚠️:如果你在模拟器中切换地区,或者在Xcode的debug区域中切换地区的时候卡住了,那就就重启模拟器。有时模拟器不愿意切换到新的地区,这时你就要给它点教训,重启它。

你可能感兴趣的:(iOS Apprentice中文版-从0开始学iOS开发-第三十二课)