作者:nuomi1,现在上任于格隆汇,Swift with iOS,果粉 / 米家粉。
审阅:
四娘,iOS 开发,老司机技能周报成员。现在上任于格隆汇,对 Swift 和编译器相关领域感兴趣
Damonwong,iOS 开发,老司机技能周报编辑,上任于淘系技能部
本文根据 Session 10132 / 10134 / 10133 梳理,运用「获取缩略图」的比如对 Swift 5.5 新增的异步与并发编程进行讲解,如需了解更多,欢迎查看其它 Session。
异步
异步编程在 iOS 开发里是一个常见的操作,例如咱们经常需求在网络恳求回来之后更新数据模型和视图。可是当异步操作嵌套时,不仅简单出现逻辑过错,还或许会堕入回调阴间。
completion
回调
咱们用「获取缩略图」来举例,先看下下面这段代码,找下有哪些逻辑问题:
func fetchThumbnail(for id: String, completion: @escaping (UIImage?, Error?) -> Void) {
let request = thumbnailURLRequest(for: id)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(nil, error)
} else if (response as? HTTPURLResponse)?.statusCode != 200 {
completion(nil, FetchError.badID)
} else {
guard let image = UIImage(data: data!) else {
return
}
image.prepareThumbnail(of: CGSize(width: 40, height: 40)) { thumbnail in
guard let thumbnail = thumbnail else {
return
}
completion(thumbnail, nil)
}
}
}
task.resume()
}
是的,少部分人能够发现,以上代码有两处过错,都是在 guard
语句之后直接退出,没有调用 completion
,这样外部调用就无法处理一切或许的状况。而且不调用 completion
是一个合法(但不契合预期)的行为,编译器不会发生过错。
把留传的两个 completion
补全后,代码如下:
func fetchThumbnail(for id: String, completion: @escaping (UIImage?, Error?) -> Void) {
let request = thumbnailURLRequest(for: id)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(nil, error)
} else if (response as? HTTPURLResponse)?.statusCode != 200 {
completion(nil, FetchError.badID)
} else {
guard let image = UIImage(data: data!) else {
completion(nil, FetchError.badImage)
return
}
image.prepareThumbnail(of: CGSize(width: 40, height: 40)) { thumbnail in
guard let thumbnail = thumbnail else {
completion(nil, FetchError.badImage)
return
}
completion(thumbnail, nil)
}
}
}
task.resume()
}
为了处理这些异步函数的成果,需求运用五个 completion
来告诉调用方,而且调用方需求判别 UIImage?
和 Error?
的 4 种组合成果,但实际上有效的只需 2 种。
咱们先用 Swift 标准库里面的 Result
来处理这个问题,代码如下:
func fetchThumbnail(for id: String, completion: @escaping (Result<UIImage, Error>) -> Void) {
let request = thumbnailURLRequest(for: id)
let task = URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
} else if (response as? HTTPURLResponse)?.statusCode != 200 {
completion(.failure(FetchError.badID))
} else {
guard let image = UIImage(data: data!) else {
completion(.failure(FetchError.badImage))
return
}
image.prepareThumbnail(of: CGSize(width: 40, height: 40)) { thumbnail in
guard let thumbnail = thumbnail else {
completion(.failure(FetchError.badImage))
return
}
completion(.success(thumbnail))
}
}
}
task.resume()
}
只需调用方能够知道成果,那么成果一定只需 2 种,要么成功,要么失败,不需求判别其他状况,愈加契合运用习惯,可是这样依然无法防止 completion
被疏忽调用的状况。
运用 async
和 await
重构函数
为了处理 completion
被疏忽调用的状况,咱们能够尝试运用 Swift 5.5 新增的 async
和 await
来处理这个问题,代码如下:
func fetchThumbnail(for id: String) async throws -> UIImage {
let request = thumbnailURLRequest(for: id)
let (data, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else { throw FetchError.badID }
let maybeImage = UIImage(data: data)
guard let thumbnail = await maybeImage?.thumbnail else { throw FetchError.badImage }
return thumbnail
}
过程十分简单:
- 生成
request
恳求,这是一个同步操作; - 恳求数据,
try
符号或许抛出过错,await
符号潜在暂停点,这是一个异步操作; - 判别回来状况码是否 200,不是则抛出过错;
- 生成原始图片,这是一个同步操作;
- 生成缩略图,
await
符号潜在暂停点,无法生成则抛出过错,这是一个异步操作; - 回来成功生成的缩略图。
相较于前面 completion
版本的 23 行,这儿 async
版本仅需求 8 行,没有函数的层层嵌套,都是一步接一步线性的,易读性大大提升,一起编译器也能够查看过错。
它们是如何作业的
上图是一个普通函数调用,当 thumbnailURLRequest
完结之后,回来 fetchThumbnail
持续履行后续代码,假如这个函数是一个耗时使命,那么当前线程就会持续等候,直到完结。
上图是一个异步函数调用,当 data(for:)
调用后,函数被挂起进行等候,当使命完结后,体系恢复 data(for:)
的调用,回来 fetchThumbnail
持续履行后续代码。
async
/ await
实际上的意思:
-
async
允许一个函数被挂起; -
await
符号一个异步函数的潜在暂停点; - 在挂起期间或许会进行其他作业;
- 一旦等候的异步调用完结,在
await
之后恢复履行。
也便是说,咱们用 async
符号一个函数是异步函数,这仅仅是一个符号,并不意味着函数一定是异步操作,函数内能够是一个简单的加法(例如 1 + 1
),也能够是一个异步的网络恳求。当咱们调用 async
函数时,需求运用 await
关键字进行调用,await
符号一个潜在暂停点。异步函数在履行同步代码时,不能抛弃自己的线程,当履行到潜在暂停点时,会抛弃自己的线程,挂起并等候被调用的异步函数的成果。当被调用的异步函数完结时,操控回来本来的异步函数的潜在暂停点(需求注意这时的线程不一定和之前的相同),持续履行之后的代码。这些线程切换的作业不需求咱们手动处理,由 Swift 主动办理,极大地提高了编程功率。
小结
async
/ await
的参加让咱们得以运用与同步编程相似的操控流来进行异步编程,不仅能够处理 completion
简单被疏忽的问题,还能够更好地进行过错处理。与此一起,线性的操控流也防止了回调阴间的问题,大大提高代码的可读性。
并发
上个章节咱们介绍了如何获取单张图片的缩略图,但在咱们日常开发中更多的是展示列表,这就需求一起处理多张缩略图。
completion
回调处理多个缩略图
假如咱们一个接着一个地处理多个缩略图,或许会写出以下代码:
func fetchThumbnails(
for ids: [String],
completion handler: @escaping ([String: UIImage]?, Error?) -> Void
) {
guard let id = ids.first else { return handler([:], nil) }
let request = thumbnailURLRequest(for: id)
let dataTask = URLSession.shared.dataTask(with: request) { data, response, error in
guard let response = response,
let data = data
else {
return handler(nil, error)
}
UIImage(data: data)?.prepareThumbnail(of: thumbSize) { image in
guard let image = image else {
return handler(nil, ThumbnailFailedError())
}
fetchThumbnails(for: Array(ids.dropFirst())) { thumbnails, error in
// 增加图片到 thumbnails
}
}
}
dataTask.resume()
}
代码十分糟糕,递归调用顺次获取缩略图,而且难以整合这些缩略图。除此之外,代码可读性也很低。
运用 async
和 await
处理多个缩略图
咱们参考上个章节对单张缩略图的处理,试着运用 async
和 await
进行重构,代码如下:
func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
var thumbnails: [String: UIImage] = [:]
for id in ids {
let request = thumbnailURLRequest(for: id)
let (data, response) = try await URLSession.shared.data(for: request)
try validateResponse(response)
guard let image = await UIImage(data: data)?.byPreparingThumbnail(ofSize: thumbSize) else {
throw ThumbnailFailedError()
}
thumbnails[id] = image
}
return thumbnails
}
过程十分简单:
- 声明
thumbnails
保存回来值; - 运用
for-in
循环创立使命;- 创立恳求;
- 下载图片;
- 查看资源合法性;
- 生成缩略图;
- 保存缩略图。
- 回来
thumbnails
。
能够发现,仅仅是增加了 for-in
循环,就能够十分方便地次序处理多张缩略图。
运用 async-let
处理结构化并发
上面比如的 thumbSize
是本地供给的,处理起来比较简单。假设 thumbSize
也要从网络接口获取,那么在生成缩略图的过程中会有两个网络恳求,代码如下:
func fetchOneThumbnail(withID id: String) async throws -> UIImage {
let imageReq = imageRequest(for: id), metadataReq = metadataRequest(for: id)
let (data, _) = try await URLSession.shared.data(for: imageReq)
let (metadata, _) = try await URLSession.shared.data(for: metadataReq)
guard let size = parseSize(from: metadata),
let image = await UIImage(data: data)?.byPreparingThumbnail(ofSize: size)
else {
throw ThumbnailFailedError()
}
return image
}
能够看到,在 imageReq
恳求完结之后,才建议 metadataReq
恳求,只需两个恳求都完结之后,才能履行之后的代码。咱们能不能让这两个恳求一起进行呢?别的假如在两个恳求和运用他们的回来成果之间有其他的无关使命,无关使命的履行有必要等到两个恳求完结之后,这无疑是一种糟蹋。
咱们能够用 async-let
来处理这个问题,代码如下:
func fetchOneThumbnail(withID id: String) async throws -> UIImage {
let imageReq = imageRequest(for: id), metadataReq = metadataRequest(for: id)
async let (data, _) = URLSession.shared.data(for: imageReq)
async let (metadata, _) = URLSession.shared.data(for: metadataReq)
guard let size = parseSize(from: try await metadata),
let image = try await UIImage(data: data)?.byPreparingThumbnail(ofSize: size)
else {
throw ThumbnailFailedError()
}
return image
}
运用 async let
符号 data
和 metadata
,运用 await
进行拜访,假如函数能够抛出过错,那么还需求运用 try
关键字。两个网络恳求会一起进行,而且履行后续代码,即上文提到的无关使命。当需求拜访成果的时分,体系会进行等候直到完结或许抛出过错。这样便能更高效地完结整个使命。
需求注意的是,这两个网络恳求是并发的,假如第一个获取图片的使命失败了抛出过错需求退出函数,Swift 会主动将未等候的使命符号撤销,然后等候它完结再退出函数。使命被符号撤销并不意味着停止使命,仅仅告诉不需求该使命的回来值。
调用异步函数处理多个缩略图
既然 for-in
内部的逻辑便是处理单个缩略图的逻辑,自然而然咱们就会想到直接调用封装好的异步函数,代码如下:
func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
var thumbnails: [String: UIImage] = [:]
for id in ids {
try Task.checkCancellation()
thumbnails[id] = try await fetchOneThumbnail(withID: id)
}
return thumbnails
}
你应该发现了,在获取缩略图之前,调用了 checkCancellation
来进行使命查看。如上文所述,子使命被符号撤销并不会停止使命,咱们能够尽或许地查看使命是否被撤销,提早退出,不再恳求后续的缩略图。
假如希望使命撤销后回来之前现已处理的缩略图,能够运用 isCancelled
进行判别,代码如下:
func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
var thumbnails: [String: UIImage] = [:]
for id in ids {
if Task.isCancelled { break }
thumbnails[id] = try await fetchOneThumbnail(withID: id)
}
return thumbnails
}
运用使命组进行处理
即使图片数据和元数据现已能够一起恳求了,可是关于整个获取缩略图这个使命而言,依然是一个接一个进行的,假如咱们想要一起进行多个使命,就需求引进使命组进行并发编程,代码如下:
func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
var thumbnails: [String: UIImage] = [:]
try await withThrowingTaskGroup(of: (String, UIImage).self) { group in
for id in ids {
group.async {
return (id, try await fetchOneThumbnail(withID: id))
}
}
for try await (id, thumbnail) in group {
thumbnails[id] = thumbnail
}
}
return thumbnails
}
过程如下:
- 声明
thumbnails
保存回来值; - 调用
withThrowingTaskGroup
创立使命组;- 运用
for-in
循环顺次把使命放到使命组,使命被增加时会马上履行,到达最大并发数量后进行等候; - 运用
for-await-in
循环顺次取回已处理的缩略图,假如使命还没完结,等候直到完结,或许抛出过错。
- 运用
- 回来
thumbnails
。
运用使命组能够十分方便地履行并发使命,线程切换和派发使命都由 Swift 进行办理,不需求咱们编写杂乱的操控逻辑。
async-let
与使命组的关系
事实上,async-let
和使命组都有使命依靠树的概念,即一个父使命有一个或多个子使命,而这个父使命又是它的父使命的子使命。当一个子使命因为过错而导致父使命需求抛出过错退出时,这个子使命的兄弟使命会被符号撤销,父使命需求等候这些使命完结或许抛出过错(但会疏忽这些使命的回来值或许过错),才能真正退出。
async-let
能够看做简化版的使命组(实际上并不是使命组的语法糖),十分合适处理不同回来类型的异步使命,等候这些异步使命的回来成果,然后组合它们再持续后面的使命。
而使命组更合适处理数量不定的相同回来类型的异步使命,在遍历使命组的回来成果时能够运用一个或许多个回来成果。例如两个相同回来类型的使命,只需求处理第一个回来的成果,使命组十分方便,但 async-let
难以完成。
小结
只需 async
/ await
的代码是不具备并发能力的,结构化并发的参加让咱们得以用少量的代码在 async
/ await
的基础上完成并发编程,一起能够持续运用过错处理和线性操控流。
Actor
上两个章节咱们介绍了异步与并发,语法简练的一起功能强大。并发虽然高效,可是假如没有采纳手段加以操控,很简单发生数据竞赛的问题。
数据竞赛
幻想一下,下面代码的输出成果是什么:
class Counter {
var value = 0
func increment() -> Int {
value = value + 1
return value
}
}
let counter = Counter()
let queue1 = DispatchQueue(label: "queue_1")
let queue2 = DispatchQueue(label: "queue_2")
queue1.async {
print(counter.increment())
}
queue2.async {
print(counter.increment())
}
1,1
、1,2
、2,1
、2,2
,都有或许,取决于 value
的写入和读取机遇。
共享的可变状况会引发数据竞赛,一般咱们运用锁来处理数据竞赛,可是锁的粒度欠好操控,处理得欠好还会引发死锁。
Actor 模型
锁的粒度欠好操控,也简单形成死锁,咱们能够运用 Swift 5.5 新增的 actor
来处理数据竞赛的问题。
那什么是 Actor
呢?(摘自 这儿)
Actor
是一种并发模型,由状况(State)、行为(Behavior)、邮箱(Mailbox)三者组成。
- 状况:
actor
持有的变量,由本身办理,防止并发环境下的锁问题; - 行为:
actor
中的计算逻辑,通过actor
接收到的音讯来改动本身的状况; - 邮箱:
actor
之间通讯的桥梁,内部运用FIFO
队列来存储和处理音讯,接收方从邮箱中获撤销息。
Actor
模型描绘了一组为防止并发编程问题的公理。
- 一切
actor
状况都是本地的,外部无法拜访; -
actor
之间有必要通过音讯传递进行通讯; - 一个
actor
能够响应音讯、退出新的actor
、改动内部状况、把音讯发送给一个或多个actor
; -
actor
或许会堵塞自己可是不应该堵塞运转的线程。
在 Swift 中,actor
是一种引证类型,而且不能够被继承。actor
内界说的变量和办法都是阻隔的,在内部运用能够直接拜访,而在外部需求运用 await
进行异步拜访。对 actor
的变量拜访和办法调用都是音讯,在同一时间只需一条音讯能够被处理,多条音讯依照次序顺次被处理。当某一条音讯需求 await
等候其他异步使命时,音讯会被挂起进行等候,actor
得以处理邮箱中的后续音讯,表现为 actor
是可重入的。这就需求咱们运用额外的机制来处理重复的恳求,防止资源的糟蹋。
运用 actor 完成 Counter
咱们运用 actor
来处理 Counter
的数据竞赛问题,代码如下:
actor Counter {
var value = 0
func increment() -> Int {
value = value + 1
return value
}
}
let counter = Counter()
asyncDetached {
print(await counter.increment())
}
asyncDetached {
print(await counter.increment())
}
成果要么是 1,2
,要么是 2,1
,不会存在 1,1
和 2,2
的状况。
运用 actor 处理图片下载
咱们试着运用 actor
完成一个图片下载器,代码如下:
actor ImageDownloader {
private var cache: [URL: Image] = [:]
func image(from url: URL) async throws -> Image? {
if let cached = cache[url] {
return cached
}
let image = try await downloadImage(from: url)
cache[url] = image
return image
}
}
当咱们一起获取两种相同的图片时,图片下载器会先建议一个异步使命,当进行到 await
等候下载成果时,函数被挂起,然后建议第二个异步使命。假如先取回的图片是正确的,后取回的却是过错的,那么最终缓存下来的会是坏的。咱们需求处理这类问题,一种处理方案是把先取回的当做正确的,后取回的疏忽,代码如下:
actor ImageDownloader {
private var cache: [URL: Image] = [:]
func image(from url: URL) async throws -> Image? {
if let cached = cache[url] {
return cached
}
let image = try await downloadImage(from: url)
cache[url] = cache[url, default: image]
return cache[url]
}
}
更好的处理方案是把下载使命保存起来,先建议第一个异步使命并保存,假如在回来成果之前触发了第2次恳求,那么就把上面保存的使命取出,并等候它完结。因为使命是同一个,所以只会宣布一个网络恳求,代码如下:
actor ImageDownloader {
private enum CacheEntry {
case inProgress(Task.Handle<Image, Error>)
case ready(Image)
}
private var cache: [URL: CacheEntry] = [:]
func image(from url: URL) async throws -> Image? {
if let cached = cache[url] {
switch cached {
case .ready(let image):
return image
case .inProgress(let handle):
return try await handle.get()
}
}
let handle = async {
try await downloadImage(from: url)
}
cache[url] = .inProgress(handle)
do {
let image = try await handle.get()
cache[url] = .ready(image)
return image
} catch {
cache[url] = nil
throw error
}
}
}
小结
actor
的参加,让咱们得以用一种新的方式来进行异步与并发编程,一起不需求处理数据竞赛的问题。在 actor
内拜访自有属性和办法都是同步逻辑,对外则表现为异步逻辑,同一时间只能处理一条音讯,多条音讯次序处理,这些规则为 actor
的高并发性供给了确保。
总结
- 异步代码的操控流难以编写,难以阅览,Swift 引进
async
/await
处理异步编程的问题; - 异步不代表并发,只用
async
/await
编写的代码不具有并发性,Swift 引进结构化并发让async
/await
编写的代码履行并发使命,一起处理一些操控流上的问题; - 异步与并发虽好,随之而来的是数据竞赛,Swift 引进
actor
来处理共享可变状况的问题,状况被阻隔在actor
内部,修正只能通过向actor
发送信息并等候成果,音讯被actor
以同步的方式进行处理,防止同一时间对数据进行修正。
重视咱们
咱们是「老司机技能周报」,一个持续追求精品 iOS 内容的技能公众号。欢迎重视。
重视有礼,重视【老司机技能周报】,回复「2021」,领取 2017/2018/2019/2020 内参
支持作者
在这儿给我们引荐一下 《WWDC21 内参》 这个专栏,一共有 102 篇关于 WWDC21 的内容,本文的内容也来源于此。假如对其余内容感兴趣,欢迎戳链接阅览更多 ~
WWDC 内参 系列是由老司机牵头组织的精品原创内容系列。 现已做了几年了,口碑一直不错。 主要是针对每年的 WWDC 的内容,做一次精选,并召唤一群一线互联网的 iOS 开发者,结合自己的实际开发经历、苹果文档和视频内容做二次创造。