Swift

图解 Swift async/await

Swift 开发者终于在 WWDC21 中盼来了 async/await。作为现代编程语言的标志之一,async/await 可以让我们像编写常规代码一样,轻松地编写异步代码,这样能更直观且更安全地表达我们的思路。同时,async/await 是整个 Swift 结构化并发的基础,所以我们从这个 Session 开始,一起来探索 Swift 新的并发框架。

加载缩略图步骤


这个 Session 通过加载缩略图片为我们演示了 async/await 的使用。加载缩略图片分为以下几个步骤:

  • 从 URL 字符串创建一个 URLRequest 对象;
  • URLSession 的 dataTask(with:completion:) 方法获取要请求图片数据;
  • UIImage(data:) 从图片数据中创建一个图像;
  • UIImage 的 prepareThumbnail 方法从原始图像中渲染一个缩略图

这些操作的每一步都依赖于前一个步骤的结果,所以必须按顺序执行。在这四步操作中,第二步和第四步会比较费时,所以这两步操作一般通过异步调用来完成。

原始版本代码


原始的调用方式如上代码所示,这里定义了一个 fetchThumbnail 函数,其中有一个 completion 参数,用于将输出返回给调用者。

fetchThumbnail 函数体中规中举地按以上步骤来完成任务

  • 调用 thumbnailURLRequest,该操作是同步的;
  • 调用 URLSession 的 dataTask(with:completion:),这是个异步操作,需要调用 resume() 方法启动异步工作;fetchThumbnail 返回执行其它任务
  • 在 dataTask(with:completion:) 的完成处理程序中,处理获取数据后的操作;
  • 正常获取数据的情况下,通过 UIImage(data:) 来创建一个图像,这个操作是同步的;
  • 调用 UIImage 对象的 prepareThumbnail 方法生成缩略图,该方法也是个异步方法;

这里有两个明显的问题:

  • 必须时刻注意在哪里需要调用 completion(这里就有 5 处),如果忘记调用 completion 来通知调用者失败(Swift 不能保证强制执行 completion),则可能导致流程异常;
  • 这里大约 20 行的代码,看似是按流程来走,但层层嵌套会让代码显示更加晦涩(所谓的回调地狱);

有一些现成的改进方案,但效果并不理想:

  • completion 的入参可以使用标准库的 Result,会更安全一点,但也只是一点点;
  • 类 future 方法,但代码没有更简洁和安全

现在,必须祭出 async/await 了。

async/await 版本代码


async/await 版本代码如上所示。

函数签名有几处明显的变化:

  • 入参不再的 completion 处理程序;
  • 返回值是 UIImage 类型,表示函数返回的缩略图,同时通过关键字 throws 标识可以抛出一个异常;
  • 在 throws 前面添加了关键字 async,表明这是一个异步函数(注意:如果没有 throws 关键字,则 async 直接放在 -> 前面)

fetchThumbnail 的实现更加简洁明了:

  • 调用依然从 thumbnailURLRequest 开始,该操作是同步的,阻塞线程;
  • 调用 URLSession 的 data(for:) 开始下载数据,这里有几个变化
   • 使用 await 标记方法调用,表明这是一个异步操作;如果一个表达式里面有多个异步函数调用,则只需要写一次 await
   • data(for: ) 方法是可等待的,调用后,会挂起自己,解除线程阻塞;
   • 使用 try 是因为 fetchThumbnail 被标记为 throws,如果网络请求有异步,则直接抛出异常;
   • data(for:) 完成后,恢复 fetchThumbnail,并将返回的数据及请求响应赋值给 data 和 response,就像普通的赋值操作一样;
  • 判断响应是否有效,如果响应码不为 200,则抛出异步,这一步为同步操作;
  • 正常获取数据后,通过 UIImage(data:) 来创建一个图像,这个操作是同步的;
  • 调用 UIImage 对象的 thumbnail 属性生成缩略图,这是个可等待的属性(此处为非 SDK 内置的属性,而是自定义的属性),这里同样使用 await 来标识异步操作;如果 thumbnail 失败,则抛出一个异常;

我们可以看到,短短的 6 行代码就实现了上面大约 20 行代码的功能,优点显而易见:

  • 更安全:整个过程能确保出错时抛出异常;
  • 更简洁:避免的代码的层层嵌套;
  • 更能体现意图:整个代码基本是和我们预定的流程保持了一致;

可等待属性


如上代码,是上面使用的 thumbnail 属性的实现。它是 UIImage 的一个扩展属性。

这里需要注意几点:

  • 异步属性必须是只读的,可写属性不能声明为异步属性;
  • 异步属性需要有一个明确的 getter,async 关键字位于 get 后;
  • 从 Swift 5.5 开始,getter 也可以抛出异常,如果同时是异步的,则 async 关键字位于 throws 前面;
  • await 可用于属性 body 中的表达式,以表明操作的异步性;

调用流程对比


普通函数的调用流程如上图所示:

  • 调用函数;
  • 函数获取线程的控制权,并完全占用该线程;
  • 函数执行完成返回或者抛出错误,将控制权交还调用方

这里普通函数放弃线程控制权的唯一方式就是执行完成

而异步函数的调用流程则如上图所示:

  • 调用函数;
  • 函数获得线程控制权;
  • 函数运行后,挂起,同时放弃对线程的控制,并将控制权交给系统,系统可自由支配该线程;
  • 系统确定何时恢复函数;
  • 函数恢复后重新获得控制权,并继续工作;
  • 函数执行完成或抛出异常后,返回调用方,将控制权交还给调用方

这里需要注意几点:

  • 一个异步函数挂起时,也会挂起它的调用者,所以调用者也必须是异步的;
  • 异步函数可以多次挂起,就像上面的 fetchThumbnail 方法一样使用了两个 await 关键字;
  • 异步函数挂起时,不会阻塞线程;
  • 异步函数可能会在一个完全不同的线程上恢复;
  • async 函数并不一定会挂起;

单元测试


以往,我们想要对网络请求任务做一些单元测试时,需要借助 XCTestExpectation 这个类。

而有了 async/await 后,异步函数的单元测试更加简单了。

异步任务


我们上面提到,异步函数的调用者也需要是异步函数。但有些情况下,确实需要在同步方法中调用异步函数,这时就需要用到异步任务功能了。

即如上代码,通过 async 闭包将任务打包,以执行异步操作。

代码迁移


对于老代码,我们更关注的是如何更好地使用上新特性。对于 async/await,Swift 团队也为我们做了很多准备:

• 提供了大量的异步 API,以替代采用完成处理程序的 API

  • 许多委托方法也有相应的异步替代方法

Continuation 模式


Swift 是如何与系统协作,完成异步代码的恢复呢。答案就是 Continuation 模式,方法的调用者等待函数调用的结果并提供一个闭包来指定下一步要做什么。当函数调用完成时,调用完成处理程序恢复调用者想要对结果执行的操作。这种协同执行正是 Swift 中异步函数的工作方式。

如果之前使用的completion handler方式的方法,或者第三方的库中使用的completion 方式。需要进行包装后使用。swift 提供了 withUnsafeContinuationwithCheckedThrowingContinuationwithCheckedContinuation 函数。

为此,Swift 提供了 withCheckedThrowingContinuation 函数

Suspends the current task, then calls the given closure with a checked throwing continuation for the current task.

以及 CheckedContinuation 结构体

A mechanism to interface between synchronous and asynchronous code, logging correctness violations.

通过这些结构体和函数,调用方可以访问可用于恢复挂起的异步函数的延续值。CheckedContinuation 结构体还提供了多个 resume 方法,用来回传结果值。

Continuation 提供了一种强大的方式来手动控制异步函数的执行,不过有一点需要记住:

resume 在每个代码分支上必须且只能调用一次

public func resume(returning x: T) 接收 completion 中的数据返回,转换成async 函数返回。

public func resume(throwing x: E) 进行抛出异常
withCheckedContinuation 方法中的checked 会在运行时对操作进行检查:是否调用resume进行返回。如果不调用会造成资源泄露。多次调用也会造成问题。
continuation 有且只能 resume 一次。

withUnsafeContinuation 的工作机制和withCheckedContinuation 一致,唯一区别在于运行时不会对操作进行检查。但性能更好。实际使用中withCheckedContinuation测试没有问题后,正式发布时使用withUnsafeContinuation

withCheckedContinuation:

func sendCodeContinuation() async -> Data{
    
    await withCheckedContinuation{ continuation in
        NET.GET(url: "").success { data in
            continuation.resume(returning: data)
        }
    } as! Data
}

如果有抛出异常
withCheckedThrowingContinuation:

func sendCodeThrowsContinuation() async throws -> Data{
    
    try await withCheckedThrowingContinuation { (continuation:CheckedContinuation<Data,Error>) in
       NET.POST(url: "").success { data in
           continuation.resume(returning: data as! Data)

       }.failed { error in
           continuation.resume(throwing: error as! Error)
       }
    }

如果某个分支没有调用 resume,异步调用将永远挂起;而如果某个分支调用了多次,则可能会破坏程序数据。这两种情况 Swift 都会给出警告或错误。

例子:


在5.5之前进行异步操作,调用返回时,使用completion handler参数进行处理。现在提供了async/awiat 进行异步并发处理。

基本使用方式:

func sendCode() async throws -> Data{

    let request = URLRequest.init(url: URL(string: "")!)
    let (data, response) = try await URLSession.shared.data(for: request)
    
    guard (response as? HTTPURLResponse)?.statusCode == 200 else {
        throw RequestError.notdata
    }
    
    return data      
}

方法后面跟上async 表示是一个异步函数。如果调用异步函数

func groupTaskLoadData() async throws {   
    do{
        try await sendCode()
    }catch let error as RequestError{
       if error == RequestError.urlerror {
           print("xxxx")
        }
    }       
}

在正常返回的函数中使用 async 修饰的func时,需要用Task{} 进行包装,否则报错。也就是说异步函数只能在异步函数中调用,如果正常函数调用的话,需要用Task{} 进行包装。

Cannot pass function of type ‘() async -> Void’ to parameter expecting synchronous function type

使用方式:

Button("Test Async") {
     
  Task{ 
     await sendCode()   
    }
}

属性也可以 async properties(使用异步属性,必须只能是get属性。可写属性不能使用异步属性。)

var isAuth: Bool {
    get async {
        await self.sendCode()
    }
}

可以看到下面的例子,print(“AAAA”)和 print(“BBBB”)先执行了,然后才执行的异步函数。

小结

以上就是对《Meet async/await in Swift》的整理,展示了 async/await 的使用及运行机制,欢迎来到 Swift 新世界。