iOS apprentice中文版 Chapter 35: 异步联网 Asynchronous networking

你的应用程序可以进行网络搜索,运行得很好。同步网络调用并没有那么糟糕,不是吗?
是的,我来告诉你为什么!你有没有注意到,每当你进行搜索时,应用程序就会变得没有响应?当网络请求发生时,您不能向上或向下滚动表视图,也不能在搜索栏中键入任何新内容。应用程序完全冻结了几秒钟。

如果你的网络连接非常快,你可能没有见过这种情况,但如果你在野外使用iPhone,网络速度会比家里或办公室的Wi-Fi慢得多,搜索很容易就会花费10秒甚至更多时间。

对于大多数用户来说,没有响应的应用程序就是崩溃的应用程序。用户可能会按下home键再试一次——或者更有可能的情况是,删除你的应用程序,在应用程序商店给它打个差评,然后切换到一个与之竞争的应用程序。
因此,在本章中,您将学习如何使用异步网络来解决UI响应问题。你可以这样做:

  1. 极端同步网络: 了解同步网络如何通过将同步网络调到最大来影响应用程序的性能。
  2. 活动指示器:添加一个活动指示器来显示正在进行的搜索,以便用户知道正在发生什么。
  3. 异步:更改web服务请求在后台线程上运行的代码,这样它就不会锁定应用程序。

极端同步网络

还不相信同步网络的弊端吗?”让我们放慢网络连接速度,假装这个应用程序运行在iPhone上,而某人可能在公交车或火车上使用这个应用程序,而不是在快速家庭或办公室网络的理想条件下。
首先,您将增加应用程序接收的数据量——通过向URL添加一个“limit”参数,您可以设置web服务将返回的结果的最大数量。默认值是50,最大值是200。

➤打开SearchViewController.swift并在iTunesURL(searchText:)中,将web服务URL更改为:

let urlString = String(format: 
  "https://itunes.apple.com/search?term=%@&limit=200", 
  encodedText)

您向URL添加&limit=200。正如您所知道的,url中的参数由&符号分隔,也称为“and”或“& and”符号。
如果你现在运行这个应用程序,搜索应该会慢一些。

网络连结调节器(The network link conditioner)

还是太快了,看不到任何应用程序响应问题?那就使用网络链接调节器network link conditioner。这是苹果提供的一个额外的开发工具,它允许你模拟不同的网络条件,比如糟糕的手机网络,以便测试你的iOS应用程序。
但首先,在使用它之前,您可能必须安装Network Link Conditioner,因为这不是默认安装的东西,既不是macOS的一部分,也不是Xcode安装的一部分。

➤从Xcode菜单中选择Open Developer Tool→More Developer Tool


这应该会在你的默认浏览器中打开苹果开发者的下载网页——你可能会被要求先登录苹果开发者门户,因为这是一个只有注册的苹果开发者才能使用的资源。


正如截图所示,搜索“additional tools”。您应该得到一个不同下载的列表。根据发布日期选择最新版本,下载,打开DMG文件,切换到DMG上的硬件文件夹,然后双击Network Link Conditioner.prefPane来安装它。
现在可以使用Network Link Conditioner作为系统首选项面板选项。



Let’s simulate a really slow connection.
➤ Click on Manage Profiles and create a new profile by clicking the plus button on the bottom left. Add the following settings:
Name: Very slow connection
Downlink Bandwidth: 48 Kbps
Downlink Packets Dropped: 0 %
Downlink Delay: 5000 ms, i.e. 5 seconds

Press OK to add this profile and return to the main screen. Make sure this new profile is selected and flick the switch to ON to start the Network Link Conditioner.
➤ Now run the app and search for something. The Network Link Conditioner tool will delay the HTTP request by 5 seconds in order to simulate a slow connection, and then downloads the data very slowly.

Tip: If the download still appears very fast, then try searching for some term you haven’t used before; the system may be caching the results from a previous search.

Notice how the app totally doesn’t respond during this time? It feels like something is wrong. Did the app crash or is it still doing something? It’s impossible to tell and very confusing to your users when this happens.
Even worse, if your program is unresponsive for too long, iOS may actually force kill it, in which case it really does crash. You don’t want that to happen!
“Ah,” you say, “let’s show some type of animation to let the user know that the app is communicating with a server. Then at least they will know that the app is busy.”
That sounds like a decent thing to do, so let’s get to it.

Tip: Even better than pretending to have a lousy connection on the Simulator is to use Network Link Conditioner on your device, so you can also test bad network connections on your actual iPhone. You can find it under Settings → Developer → Network Link Conditioner. Using these tools to test whether your app can deal with real-world network conditions is a must! Not every user has the luxury of broadband…
Also, if you do not see the Developer option in the iOS Settings app, you might need to connect your iPhone to your computer via a USB cable, and launch the Xcode Devices and Simulators window so that your device is recognized by Xcode as a developer device.

活动指标(The activity indicator)

你之前在MyLocations中使用过旋转活动指示器,向用户显示应用程序正在忙。让我们创建一个新的表格视图单元格,在应用程序查询iTunes商店时显示。它会是这样的:


活动指示表视图单元格
创建一个新的空nib文件。称之为LoadingCell.xib。
➤将一个新的TableViewCell拖放到画布上。设置宽度为320点,高度为80点。
➤将单元格的重用标识符设置为LoadingCell,并将Selection属性设置为None。
➤拖拽一个新的Label到单元格中。将标题设置为Loading…并将字体更改为System 15。标签的文本颜色应该是50%不透明的黑色。
➤将一个新的Activity Indicator View拖拽到单元格中,并将其放在标签旁边。把它的Style设为Gray,给它打上100的tag。


要使此单元格在较大的屏幕上正常工作,您将添加一些约束,以保持标签和活动微调器位于单元格的中心。最简单的方法是将这两项放到容器视图中并居中

➤选择Label和Activity Indicator View——按住⌘选择多个项目。从Xcode菜单栏中,选择Editor→Embed In→View Without Inset。这将在选定的视图后面放置一个白色视图。


注意:如果你想知道在 Editor菜单中Embed In → View 和Embed In → View Without Inset之间有什么不同,试试它,你应该会看到发生了什么。第一个选项添加了一个比它所包含的项稍大一些的视图,因为它已经嵌入了新视图,以便在所包含的项周围添加一些填充。第二个选项,就是你用的那个,简单地把所有的东西都包起来,不加任何标签。

选中这个新的容器视图后,单击Align按钮,复选标记Horizontally in Container与Vertically in Container以创建新的约束。


最终会出现一些红色约束。这是没有好;我们想看蓝色的。你的新约束是红色的原因是自动布局不知道这个容器视图应该有多大;您只是为视图的位置添加了约束,而不是它的大小。
要解决这个问题,您还需要向标签和活动指示器添加约束,这样容器视图的宽度和高度就由其中两个元素的大小决定。

这对于以后你要把应用翻译成另一种语言时尤其重要。如果Loading……文本变大或变小,那么容器视图也应该变大或变小,以便保持单元格内的居中。

➤选择标签并点击Add New Constraints按钮。只需将它钉在所有四个边,并按下添加4约束。

➤在活动指示器视图中重复这个动作。您不需要将它固定在左边,因为这个约束已经存在——将标签固定在左边添加了它。
现在标签和活动指示器的约束应该都是蓝色的。
此时,容器视图可能仍然有橙色的线,表示约束没有问题,但是视图的框架没有处于适当的位置。如果是,选择它并选择“Editor → Resolve Auto Layout Issues → Update Frames - under Selected Views。这将把容器视图移动到它的约束所指定的位置。
酷,你现在有了一个可以自动调整自身大小的屏幕。

使用活动指示Cell

要使这个特殊的表视图单元格出现,您将按照与“Nothing Found”单元格相同的步骤。
➤将下面的一行添加到SearchViewController.swift的TableView.Cellidentifier结构中。

static let loadingCell = "LoadingCell

➤并在viewDidLoad()中注册nib:

cellNib = UINib(nibName: TableView.CellIdentifiers.loadingCell, 
                bundle: nil)
tableView.register(cellNib, forCellReuseIdentifier: 
                   TableView.CellIdentifiers.loadingCell)

现在,您必须想办法让table view的数据源知道应用程序目前正处于从服务器下载数据的状态。最简单的方法是添加另一个布尔标志。如果该变量为真,则应用程序正在下载内容并显示新的load . cell;如果变量为false,则显示table视图的常规内容。

➤添加一个新的实例变量:

var isLoading = false

➤ 将tableView(_:numberOfRowsInSection:) 修改为:

func tableView(_ tableView: UITableView, 
               numberOfRowsInSection section: Int) -> Int {
  if isLoading {
    return 1
  } else if !hasSearched {
    . . . 
  } else if . . . 

if isLoading条件返回1,因为需要一行来显示单元格。

➤将tableView(_:cellForRowAt:)更新如下:

func tableView(_ tableView: UITableView, 
    cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  // New code 
  if isLoading {
    let cell = tableView.dequeueReusableCell(withIdentifier: 
        TableView.CellIdentifiers.loadingCell, for: indexPath)
        
    let spinner = cell.viewWithTag(100) as! 
                  UIActivityIndicatorView
    spinner.startAnimating()
    return cell
  } else 
  // End of new code
  if searchResults.count == 0 {
    . . .

您添加了一个if条件来返回新Loading…单元格的实例。它还通过tag查找UIActivityIndicatorView,然后告诉转轮开始动画。方法的其余部分保持不变。

➤将tableView(_:willSelectRowAt:)改为:

func tableView(_ tableView: UITableView, 
     willSelectRowAt indexPath: IndexPath) -> IndexPath? {
  if searchResults.count == 0 || isLoading {    // Changed
    return nil
  } else {
    return indexPath
  }
}

你在if语句中添加了||。就像您不希望用户选择“Nothing Found”单元格一样,您也不希望他们选择“Loading…”单元格,因此在这两种情况下都返回nil。
现在只剩下一件事:在向iTunes服务器发出HTTP请求之前,应该将isLoading设置为true,并重新加载table视图,使Loading…cell出现。

➤将searchBarSearchButtonClicked(_:)更改为:

func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
  if !searchBar.text!.isEmpty {
    searchBar.resignFirstResponder()
    // New code
    isLoading = true                    
    tableView.reloadData()
    // End of new code
    . . .
    isLoading = false                     // New code
    tableView.reloadData()
  }
}

在执行网络请求之前,将isLoading设置为true并重新加载表以显示活动指示器。
在请求完成并得到搜索结果之后,将isLoading设置回false并重新加载表以显示SearchResult对象。
很有道理,对吧?让我们启动这个应用程序,看看它是如何运行的!

测试新的加载单元

运行应用程序并执行搜索。当搜索正在进行时,带有旋转活动指示器的单元格应该出现…
……还是应该? !
可悲的事实是没有一个转轮可以看到。在不太可能的情况下它才会出现在你面前,它现在不会旋转——尝试启用Network Link Conditioner。

为了说明原因,首先改变searchBarSearchButtonClicked(_:)如下:

func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
  if !searchBar.text!.isEmpty {
    searchBar.resignFirstResponder()
    isLoading = true
    tableView.reloadData()
    /*
       . . . the networking code (commented out) . . . 
     */
  }
}

注意,您不需要从代码中删除任何内容——只需在第一次调用tableView.reloadData()之后注释掉所有内容。

运行应用程序并进行搜索。现在活动转轮出现了!
所以至少你知道这部分代码运行良好。但启用网络代码后,应用程序不仅对用户的任何输入完全没有响应,而且也不想重绘屏幕。这是怎么回事?

主线程

旧款iPhone和iPad的CPU(中央处理器)只有一个核心,这意味着它一次只能做一件事。最近的模型有一个CPU有两个核心,这允许同时进行两个惊人的计算。你的Mac可能有4个内核。
由于可用的内核如此之少,为什么现代计算机可以同时运行更多的应用程序和其他进程(我的Mac上现在有287个活动进程)?

为了克服只有一两个CPU核心的硬件限制,包括iPhone和iPad在内的大多数电脑都采用抢占式的多任务处理和多线程技术,给人一种可以同时做很多事情的错觉。

多任务处理是发生在不同应用程序之间的事情。每个应用程序都有自己的进程,每个进程每秒钟都有一小部分CPU时间来执行其任务。然后它被暂时停止,或被抢占,控制权被交给下一个进程。
每个进程包含一个或多个线程。我刚才提到每个进程都有一点CPU时间来完成它的工作。进程在它的线程之间分割时间。每个线程通常执行自己的工作,并且尽可能独立于该进程中的其他线程。
一个应用程序可以有多个线程,CPU在它们之间切换:


如果你进入Xcode调试器并暂停应用程序,调试器会显示当前哪些线程处于活动状态,以及在你停止它们之前它们在做什么。
对于StoreSearch应用程序,显然在截屏时有六个线程:


大多数线程都是由iOS自己管理的,你不必担心。此外,您可能会看到少于或多于6个线程。然而,有一个线程需要特别注意:主线程。在上面的图像中,这是线程1。

主线程是应用程序的初始线程,所有其他线程都从这里派生出来。主线程负责处理用户界面事件,并绘制UI。应用程序的大多数活动都是在主线程上进行的。每当用户点击应用程序中的按钮时,执行操作方法的线程就是主线程。

因为它非常重要,所以您应该小心不要阻塞主线程。如果action方法的运行时间超过几分之一秒,那么在主线程上执行所有这些计算就不是一个好主意,因为这会锁定主线程。

当你让主线程忙于做其他事情时,它无法处理任何UI事件,如果操作时间太长,应用程序甚至可能被系统杀死,因此应用程序变得无响应。

在StoreSearch中,您要在主线程上执行冗长的网络操作。它可能需要许多秒,甚至几分钟来完成。

将isLoading标志设置为true后,告诉tableView重新加载它的数据,这样用户就可以看到旋转动画。但这从未实现。告诉表视图重新加载调度了一个“重绘”事件,但是主线程没有机会处理该事件,因为您立即启动网络操作,使主线程长时间处于繁忙状态。

这就是为什么当前的同步网络方法是不好的:永远不要阻塞主线程。这是iOS编程的主要原罪之一!

使其异步

为了防止阻塞主线程,任何可能需要一段时间才能完成的操作都应该是异步的。这意味着操作发生在后台线程中,同时主线程可以自由地处理新事件。
这并不是说您应该创建自己的线程。如果你以前在其他平台上编程过,你可能会毫无犹豫的创建新线程,但在iOS上这通常不是最好的解决方案。

你看,线程是很棘手的。不是线程本身,而是并行地做事情。我不会在这里详细介绍,但是一般来说,您希望避免两个线程同时修改同一块数据的情况。这可能会导致非常令人惊讶但不是非常令人愉快的结果。

iOS有几种更方便的方式启动后台进程,而不是自己创建线程。对于这个应用程序,您将使用队列和Grand Central Dispatch (GCD)。GCD大大简化了需要并行编程的任务。您已经在MyLocations中简单地使用了GCD,但是现在您将更好地使用它。

简而言之,GCD有许多具有不同优先级的队列。要在后台执行作业,可以将作业放入闭包中,然后将该闭包传递给队列,然后忘记它。就这么简单。

GCD将一个接一个地从队列中获取闭包(或它所调用的“块”),并在后台执行它们的代码。它是如何做到的并不重要,你只能保证它发生在某个后台线程上。队列与线程并不完全相同,但是它们使用线程来完成它们的工作。


将web请求放在后台线程中

要使web服务请求异步,您需要将searchBarSearchButtonClicked(_:)中的网络部分放到一个闭包中,然后将该闭包放在一个中等优先级队列中。

➤将searchBarSearchButtonClicked(_:)修改如下:

func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
  if !searchBar.text!.isEmpty {
    . . .
    searchResults = []
    // Replace all code after this with new code be“low
    // 1
    let queue = DispatchQueue.global()
    let url = self.iTunesURL(searchText: searchBar.text!)
    // 2
    queue.async {
      
      if let data = self.performStoreRequest(with: url) {
        self.searchResults = self.parse(data: data)
        self.searchResults.sort(by: <)
        // 3
        print("DONE!")
        return
      }
    }
  }
}
  • 这将获取对队列的引用。您正在使用一个“全局”队列,这是一个由系统提供的队列。你也可以创建你自己的队列,但是使用一个标准的队列对于这个应用来说是可以的。

  • 一旦有了队列,就可以对它分派闭包——队列之间的所有内容。async{和close}是闭包。闭包中的任何代码都将放在队列上,并在后台异步执行。在调度此闭包之后,主线程立即可以继续。它不再被阻塞。

  • 在闭包中,我删除了在搜索完成后重新加载表视图的代码,以及错误处理代码。目前,这已经被print()语句所取代。这是有原因的,我们马上就会讲到。首先,让我们再次尝试应用程序。

运行应用程序并进行搜索。“Loading…”单元格应该是可见的,完成动画微调!过了一会儿,你应该看到“完成!”消息出现在控制台中。
当然,装载过程……Cell会一直存在,因为你还没有让它离开。

将UI更新放在主线程上

我从闭包中删除所有用户界面代码——并将搜索URL移到闭包之外——的原因是UIKit有一条规则,UI代码应该总是在主线程上执行。这是很重要的!

从多个线程访问相同的数据会造成各种各样的痛苦,因此UIKit的设计者决定不允许从其他线程更改UI。这意味着您不能从这个闭包中重新加载表视图,因为它运行在后台线程(而不是主线程)上的队列上。

碰巧,还有一个与主线程相关联的“主队列”。如果您需要从后台队列对主线程执行任何操作,您可以简单地创建一个新的闭包并在主线程上调度主线程操作。

➤将searchBarSearchButtonClicked(_:)中显示print(“完成!”)的一行替换为:

DispatchQueue.main.async {
  self.isLoading = false
  self.tableView.reloadData()
}

在DispatchQueue.main.async您可以在主队列上调度一个新的闭包。这个新的闭包将isLoading设置回false并重新加载表视图。注意self是必需的,因为这段代码位于闭包中。
试一试。有了这些变化,您的网络代码就不再占据主线程,应用程序就会突然变得响应性更强!

各种各样的队列

当处理GCD队列时,您经常会看到这样的模式:

let queue = DispatchQueue.global()
queue.async {
  // code that needs to run in the background
  
  DispatchQueue.main.async {
    // update the user interface
  }
}

基本上,当你在后台线程中工作时,你仍然必须切换到主线程来进行任何用户界面更新。事情就是这样。

还有排队。同步,没有“a”,它从队列中获取下一个闭包并在后台执行它,但是会让您等待闭包完成。在某些情况下,这可能很有用,但大多数情况下,您希望使用queue.async。没有人喜欢等待!

The main thread checker

我之前提到过,不应该在后台线程上运行UI代码。然而,在Xcode 9之前,没有一种简单的方法可以发现在后台线程上运行的UI代码,只能通过费力地逐行搜索源代码来确定哪些代码在主线程上运行,哪些在后台线程上运行。

在Xcode 9中,苹果引入了一个新的诊断设置,名为主线程检查器,如果后台线程上运行了任何UI代码,它会发出警告。默认情况下,这个设置应该是启用的,但如果没有启用,您可以很容易地启用它——我建议您随时启用它,因为它可能非常有价值。

➤点击Xcode工具栏中的scheme下拉菜单,选择Edit scheme……



➤在左侧面板中选择Run,切换到Diagnostics选项卡,并确保主线程检查器在运行时API检查下被选中。

现在,把下面这句话从结束语outside the closure中移开:

let url = self.iTunesURL(searchText: searchBar.text!)

在封闭的内部就像这样:

queue.async {
    let url = self.iTunesURL(searchText: searchBar.text!)
    ...
} 

运行StoreSearch并对一个项目进行搜索,你应该会在Xcode控制台看到如下内容

Main Thread Checker: UI API called on a background thread: -[UISearchBar text]
PID: 12986, TID: 11267540, Thread name: (none), Queue name: com.apple.root.default-qos, QoS: 0
Backtrace:
4   StoreSearch                         0x000000010bccfa75 $S11StoreSearch0B14ViewControllerC09searchBarB13ButtonClickedyySo08UISearchF0CFyycfU_ + 469
5   StoreSearch                         0x000000010bcd0101 $S11StoreSearch0B14ViewControllerC09searchBarB13ButtonClickedyySo08UISearchF0CFyycfU_TA + 17
6   StoreSearch                         0x000000010bcd02bd $SIeg_IeyB_TR + 45
7   libdispatch.dylib                   0x000000010f3a1225 _dispatch_call_block_and_release + 12
8   libdispatch.dylib                   0x000000010f3a22e0 _dispatch_client_callout + 8
9   libdispatch.dylib                   0x000000010f3a4d8a _dispatch_queue_override_invoke + 1028
10  libdispatch.dylib                   0x000000010f3b2daa _dispatch_root_queue_drain + 351
11  libdispatch.dylib                   0x000000010f3b375b _dispatch_worker_thread2 + 130
12  libsystem_pthread.dylib             0x000000010f791169 _pthread_wqthread + 1387
13  libsystem_pthread.dylib             0x000000010f790be9 start_wqthread + 13
2018-07-28 11:39:02.726132+0200 StoreSearch[12986:11267540] [reports] Main Thread Checker: UI API called on a background thread: -[UISearchBar text]
PID: 12986, TID: 11267540, Thread name: (none), Queue name: com.apple.root.default-qos, QoS: 0
Backtrace:
4   StoreSearch                         0x000000010bccfa75 $S11StoreSearch0B14ViewControllerC09searchBarB13ButtonClickedyySo08UISearchF0CFyycfU_ + 469
5   StoreSearch                         0x000000010bcd0101 $S11StoreSearch0B14ViewControllerC09searchBarB13ButtonClickedyySo08UISearchF0CFyycfU_TA + 17
6   StoreSearch                         0x000000010bcd02bd $SIeg_IeyB_TR +bcd02bd $SIeg_IeyB_TR + 45
7   libdispatch.dylib                   0x000000010f3a1225 _dispatch_call_block_and_release + 12
8   libdispatch.dylib                   0x000000010f3a22e0 _dispatch_client_callout + 8
9   libdispatch.dylib                   0x000000010f3a4d8a _dispatch_queue_override_invoke + 1028
10  libdispatch.dylib                   0x000000010f3b2daa _dispatch_root_queue_drain + 351
11  libdispatch.dylib                   0x000000010f3b375b _dispatch_worker_thread2 + 130
12  libsystem_pthread.dylib             0x000000010f791169 _pthread_wqthread + 1387
13  libsystem_pthread.dylib             0x000000010f790be9 start_wqthread + 13

你可能还注意到Xcode工具栏的activity视图现在有一个紫色的图标,在跳转栏的右下角有一个紫色的图标,通常会显示错误。


如果你点击activity视图中的图标,你将被带到问题导航器的Runtime选项卡,在那里你可以点击列出的问题,然后被带到源代码中有问题的那一行



你终于看到问题出在哪里了——你从后台线程的UI控件搜索栏访问数据。在主线程中这样做可能更好。由于我们创建这个问题是为了演示后台线程检查器,所以修复很简单,只需将代码行移回原来的位置:]

提交代码

我认为有了这个重要的改进,应用程序应该有一个新的版本号。因此,提交更改并为v0.2创建一个tag。您必须按照两个单独的步骤来完成此操作——首先创建一个包含适当消息的提交,然后为您的最新提交创建一个tag。

你可能感兴趣的:(iOS apprentice中文版 Chapter 35: 异步联网 Asynchronous networking)