作者: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 被疏忽调用的状况。

运用 asyncawait 重构函数

为了处理 completion 被疏忽调用的状况,咱们能够尝试运用 Swift 5.5 新增的 asyncawait 来处理这个问题,代码如下:

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
}

过程十分简单:

  1. 生成 request 恳求,这是一个同步操作;
  2. 恳求数据,try 符号或许抛出过错,await 符号潜在暂停点,这是一个异步操作;
  3. 判别回来状况码是否 200,不是则抛出过错;
  4. 生成原始图片,这是一个同步操作;
  5. 生成缩略图,await 符号潜在暂停点,无法生成则抛出过错,这是一个异步操作;
  6. 回来成功生成的缩略图。

相较于前面 completion 版本的 23 行,这儿 async 版本仅需求 8 行,没有函数的层层嵌套,都是一步接一步线性的,易读性大大提升,一起编译器也能够查看过错。

它们是如何作业的

【老司机精选】认识 Swift 中的异步与并发

上图是一个普通函数调用,当 thumbnailURLRequest 完结之后,回来 fetchThumbnail 持续履行后续代码,假如这个函数是一个耗时使命,那么当前线程就会持续等候,直到完结。

【老司机精选】认识 Swift 中的异步与并发

上图是一个异步函数调用,当 data(for:) 调用后,函数被挂起进行等候,当使命完结后,体系恢复 data(for:) 的调用,回来 fetchThumbnail 持续履行后续代码。

【老司机精选】认识 Swift 中的异步与并发

async / await 实际上的意思:

  1. async 允许一个函数被挂起;
  2. await 符号一个异步函数的潜在暂停点;
  3. 在挂起期间或许会进行其他作业;
  4. 一旦等候的异步调用完结,在 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()
}

代码十分糟糕,递归调用顺次获取缩略图,而且难以整合这些缩略图。除此之外,代码可读性也很低。

运用 asyncawait 处理多个缩略图

咱们参考上个章节对单张缩略图的处理,试着运用 asyncawait 进行重构,代码如下:

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
}

过程十分简单:

  1. 声明 thumbnails 保存回来值;
  2. 运用 for-in 循环创立使命;
    1. 创立恳求;
    2. 下载图片;
    3. 查看资源合法性;
    4. 生成缩略图;
    5. 保存缩略图。
  3. 回来 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 符号 datametadata,运用 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
}

过程如下:

  1. 声明 thumbnails 保存回来值;
  2. 调用 withThrowingTaskGroup 创立使命组;
    1. 运用 for-in 循环顺次把使命放到使命组,使命被增加时会马上履行,到达最大并发数量后进行等候;
    2. 运用 for-await-in 循环顺次取回已处理的缩略图,假如使命还没完结,等候直到完结,或许抛出过错。
  3. 回来 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,11,22,12,2,都有或许,取决于 value 的写入和读取机遇。

共享的可变状况会引发数据竞赛,一般咱们运用锁来处理数据竞赛,可是锁的粒度欠好操控,处理得欠好还会引发死锁。

Actor 模型

锁的粒度欠好操控,也简单形成死锁,咱们能够运用 Swift 5.5 新增的 actor 来处理数据竞赛的问题。

那什么是 Actor 呢?(摘自 这儿)

Actor 是一种并发模型,由状况(State)、行为(Behavior)、邮箱(Mailbox)三者组成。

  1. 状况:actor 持有的变量,由本身办理,防止并发环境下的锁问题;
  2. 行为:actor 中的计算逻辑,通过 actor 接收到的音讯来改动本身的状况;
  3. 邮箱:actor 之间通讯的桥梁,内部运用 FIFO 队列来存储和处理音讯,接收方从邮箱中获撤销息。

Actor 模型描绘了一组为防止并发编程问题的公理。

  1. 一切 actor 状况都是本地的,外部无法拜访;
  2. actor 之间有必要通过音讯传递进行通讯;
  3. 一个 actor 能够响应音讯、退出新的 actor、改动内部状况、把音讯发送给一个或多个 actor
  4. 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,12,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 的高并发性供给了确保。

总结

  1. 异步代码的操控流难以编写,难以阅览,Swift 引进 async / await 处理异步编程的问题;
  2. 异步不代表并发,只用 async / await 编写的代码不具有并发性,Swift 引进结构化并发让 async / await 编写的代码履行并发使命,一起处理一些操控流上的问题;
  3. 异步与并发虽好,随之而来的是数据竞赛,Swift 引进 actor 来处理共享可变状况的问题,状况被阻隔在 actor 内部,修正只能通过向 actor 发送信息并等候成果,音讯被 actor 以同步的方式进行处理,防止同一时间对数据进行修正。

重视咱们

咱们是「老司机技能周报」,一个持续追求精品 iOS 内容的技能公众号。欢迎重视。

重视有礼,重视【老司机技能周报】,回复「2021」,领取 2017/2018/2019/2020 内参

支持作者

在这儿给我们引荐一下 《WWDC21 内参》 这个专栏,一共有 102 篇关于 WWDC21 的内容,本文的内容也来源于此。假如对其余内容感兴趣,欢迎戳链接阅览更多 ~

WWDC 内参 系列是由老司机牵头组织的精品原创内容系列。 现已做了几年了,口碑一直不错。 主要是针对每年的 WWDC 的内容,做一次精选,并召唤一群一线互联网的 iOS 开发者,结合自己的实际开发经历、苹果文档和视频内容做二次创造。