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

先插播一个小话题——异步操作(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按钮,然后你就会看到经纬度已经展示在界面上了。

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

当这个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仍然是空的。

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

处理错误

获取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...”,直到获取到一个位置信息或者一个报错信息为止。

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

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