大师学SwiftUI第9章Part 2 - 异步并发之Actor、异步序列、任务组和异步图像

并发

异步任务对于希望释放资源让系统可以执行其它任务的场景非常有用,比如更新界面,但在希望同步执行两个任务时,就需要用到并发。为此,Swift标准库定义了async let语句。将异步任务变成多个并发任务,我们只需要使用async let语句声明处理,如下所示。

示例9-8:定义并发任务

swift 复制代码
struct ContentView: View {
    var body: some View {
        VStack {
            Text("Hello, world!")
                .padding()
        }
        .onAppear {
            let currentTime = Date()
            Task(priority: .background) {
               async let imageName1 = loadImage(name: "image1")
               async let imageName2 = loadImage(name: "image2")
               async let imageName3 = loadImage(name: "image3")
                
                let listNames = await "(imageName1), (imageName2), (imageName3)"
                print(listNames)
                print("Total Time: (Date().timeIntervalSince(currentTime))")
            }
        }
    }
    func loadImage(name: String) async -> String {
        try? await Task.sleep(nanoseconds: 3 * 1000000000)
        return "Name: (name)"
    }
}

每次完成async let所声明的处理,系统会创建一个并发任务与其它任务一起并行运行。在示例9-8中,我们创建了三个并发任务(imageName1imageName2imageName3)。过程与之前相同,它们调用loadImage()方法,方向会暂停任务3秒钟,返回一个字符串。但因为这次它们并行运行,完成任务所花费的时间大约为3秒(而不是前例中的9秒)。

✍️跟我一起做:使用示例9-8 中的代码更新ContentView结构体。在模拟器中运行代码。几秒后,会在控制台中打印出处理所耗费的时间。

Actor

在使用并发任务时,可能会碰到数据竞用 的问题。数据竞用出现在两个或两个以上并行运行的任务尝试访问相同的数据时。比如,它们同时尝试修改某一个属性的值。这可能会导致错误或严重的bug。为解决这一问题,Swift标准库中引入了actor

actor 是隔离并行任务的数据类型,因此任务在修改actor 的值时,另一个任务会强制等待。actor 是引用类型,定义类似类,但不是使用class关键字,而是通过actor关键字定义。它与类另一个重要的不同是属性和方法必须异步访问(我们必须使用await关键字等待)。这会确保代码等待actor释放(其它任务不能访问actor)。

下例演示了如何使用actor。这段代码声明了一个一家属性和方法的actor,创建了一个实例,然后在多个任务中调用其中的方法。

示例9-9:定义一个actor

swift 复制代码
import SwiftUI

actor ItemData {
    var counter: Int = 0
    
    func incrementCount() -> String {
        counter += 1
        return "Value: (counter)"
    }
}
struct ContentView: View {
    var item: ItemData = ItemData()
    
    var body: some View {
        Button("Start Process") {
            Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { (timer) in
                Task(priority: .background) {
                    async let operation = item.incrementCount()
                    print(await operation)
                }
            }
            Timer.scheduledTimer(withTimeInterval: 0.2, repeats: true) { (timer) in
                Task(priority: .high) {
                    async let operation = item.incrementCount()
                    print(await operation)
                }
            }
        }
    }
}

界面中的按钮会启动两个无限重复的定时器,一个间隔0.1秒,另一个间隔0.2秒。定时器执行任务并发调用actor中的incrementCount()方法。这样不同线程中的不同任务会调用该方法,最终会同时调用,产生数据竞用。如果我们将ItemData声明为类,会报错、出现预期外的行为甚至出现崩溃,但因为我们将这个数据类型声明为actor,代码正确运行。每次在任务调用incrementCount()方法时,actor会接管并确保一次只有一个任务能访问该方法。

✍️跟我一起做:使用示例9-9 中的代码更新ContentView.swift文件。在iPhone模拟器中运行代码、点击按钮。会看到incrementCount()方法所产生的值打印在控制台中。停止应用。将actor声明为类(将关键字actor替换成关键字class)。这时在方法同时由多个任务调用时会出现错误。

注意:默认Xcode不会在控制台显示异步错误。要监测异步操作的问题,必须激活Thread Sanitizer 。点击Xcode工具栏的Scheme按钮(图5-2,2号图)。在菜单中选择Edit Scheme选项(图5-8)。在新窗口中,选择Run选项并打开Diagnostics 标签。勾选复选框启用Thread Sanitizer。将actor声明为类,再次在iPhone模拟器中运行应用,点击按钮。在成功运行数次后,会在控制台中看到访问竞争的报错。

我们提到过,actor将属性和方法与其它的代码及线程隔离开。这表示我们只能异步访问actor(必须等待actor允许进行访问),但在某些场景下,不需要进行隔离。这时,我们可以使用如下的关键字反转隔离的状态。

  • nonisolated:该关键字打破属性或方法的隔离。

非隔离属性和方法可能遵循协议时要用到,也可以在actor中只需要访问不可变值时简化代码。例如,下例中我们对ItemData actor添加一个名为maximum的常量以及一个打印该值的方法。因为常量的值永不改变,我们可以将其声明为非隔离方法,调用时无需等待actor授权。

示例9-10:定义一个非隔离方法

swift 复制代码
actor ItemData {
    var counter: Int = 0
    let maximum: Int = 30
    
    func incrementCount() -> String {
        counter += 1
        return "Value: (counter)"
    }
    nonisolated func maximumValue() -> String {
        return "Maximum Value: (maximum)"
    }
}
struct ContentView: View {
    var item: ItemData = ItemData()
    
    var body: some View {
        Button("Start Process") {
            let value = item.maximumValue()
            print(value)
        }
    }
}

在前面的例子中,我们操作了actor所定义的值,但通常值也会发送给actor进行处理。将值发送给actor中的方法非常危险。因为actor的任务是确保两个或多个异步任务不能同步修改同一个值。值类型,包括自定义类型和IntString这样的原生数据类型,是线程安全的,因为会进行值拷贝。在使用这些值调用actor中的方法时,系统创建一个拷贝并将拷贝发送给方法,所以不会修改原始值。但对象是引用类型,所以发送给actor的是对象的指针,也就意味着对象可能会在代码的其它地方被修改,存在数据竞用的可能。为确保我们发送给方法的值是安全的,Swift标准库定义了如下协议和属性。

  • Sendable:这一协议告诉系统该数据类型创建的值可安全地在异步线程间共享。
  • @Sendable:该属性向系统表明某个方法或闭包可安全地在异步线程间共享。

Sendable协议没做什么工作,只是告诉编译器某一数据类型是安全的。在数据类型遵循该协议时,其中包含不安全的值时编译器就会报错。比如,虽然结构体是安全的,但我们可以让其遵循这一协议来确保之后不会添加任何不安全的属性。在只包含不可变值时类也是安全的,但子类却有可能不安全,因此可以使用final关键字来标记类,这样没人可以创建其子类,如下所示。

示例9-11:定义非隔离方法

swift 复制代码
final class Product: Sendable {
    let name: String
    
    init(name: String) {
        self.name = name
    }
}
actor ItemData {
    var stock: Int = 100
    
    func sellProduct(product: Product, quantity: Int) {
        stock = stock - quantity
        print("Stock: (stock) (product.name)")
    }
}
struct ContentView: View {
    var item: ItemData = ItemData()
    
    var body: some View {
        Button("Start Process") {
            Task(priority: .background) {
                let product = Product(name: "Lamp")
                await item.sellProduct(product: product, quantity: 5)
            }
        }
    }
}

上例中定义了一个名为Product的final类(不能对其创建子类),其中包含一个不可变属性(let)。同时,这个属性的类型为String,默认为可发送类型。这表示通过该类创建的对象是线程安全的,可发送给actor

注意 :带有可变值(var)的类也可以是可发送的,但我们需要负责保障它不会产生数据竞用。这一话题暂不在讨论范畴内。更多相关多信息,请参见本文的参考链接部分。

如果确实需要包含不安全的值,并且确定不会在其它线程中修改它,可以通过如下属性告诉编译器不做错误检查。

  • @unchecked :该属性要求编译器不检查指定数据类型是否遵循Sendable协议。

在使用不安全的数据类型或是向actor发送老框架所产生的值时这比较有用。例如,下例中我们将Product类转换成结构体,使用name属性存储NSString值。NSString数据类型是不可发送的,因此Product结构体不遵循Sendable协议的要求,但因为我们知道这个值不会在任何地方进行修改,所以通过@unchecked属性告诉编译器不要担心这个问题。

示例9-12 :要求编译器不检查是否遵循Sendable协议

swift 复制代码
struct Product: @unchecked Sendable {
    let name: NSString
}

注意@unchecked属性通常实现用于在将不安全的值发送给Main Actor前封装它。我们会在下一节学习如何使用Main Actor,以及在稍后在实际场景中实现这一属性。

Main Actor

我们已经讲到,任务会分配给执行线程,然后系统将这些线程分发到处理器的多核,尽快尽可能平滑地执行任务。一个线程可管理多个任务,一个应用可创建多个线程。除了为处理异步、并发任务初始化的那些线程,系统还会创建一个称为主线程 的线程,用于启动应用和运行非异步代码,包括创建和更新界面的代码。这表示如果尝试通过异步或并发任务修改界面,可能会导致数据竞用或是严重的bug。为避免这类冲突,Swift标准库定义了Main ActorMain Actor 是由系统创建的actor,用于确保每个希望与主线程交互或是修改界面元素的任务等待其它任务完成。Swift提供了两种方式来保障代码运行于Main Actor (主线程):@MainActor修饰符和run()方法。通过@MainActor修饰符我们可以标记整个方法运行于主线程上,而run()方法在主线程上运行闭包。例如,下例中我们使用@MainActor标记loadImage()方法,来确保其中的代码在主线程中运行,并且我们修改Text视图的值时不会出现问题。

示例9-13 :在Main Actor中执行方法

scss 复制代码
struct ContentView: View {
    @State private var myText: String = "Hello, world!"
    
    var body: some View {
        VStack {
            Text(myText)
                .padding()
        }.onAppear {
            Task(priority: .background) {
                await loadImage(name: "image1")
            }
        }
    }
    @MainActor func loadImage(name: String) async {
        myText = name
    }
}

这段代码和之前一样创建了一个异步任务,但这里方法使用@MainActor进行标记,因此代码在主线程中运行,可以安全地更新myText属性及界面。

大多数时候,只有一部分代码处理界面,但其它代码可在当前线程中执行。这时,我们可以实现run()方法。这是一个由MainActor结构体(用于创建Main Actor)定义的类型方法。该方法接收一个包含需要在主线程中运行的语句的闭包。

示例9-14 :在Main Actor中执行代码

scss 复制代码
struct ContentView: View {
    @State private var myText: String = "Hello, world!"
    
    var body: some View {
        VStack {
            Text(myText)
                .padding()
        }.onAppear {
            Task(priority: .background) {
                await loadImage(name: "image1")
            }
        }
    }
    func loadImage(name: String) async {
        await MainActor.run {
            myText = name
        }
        print(name)
    }
}

loadImage()的最后包含了一条语句,在控制台打印出字符串,但只有将新值赋给myText属性的语句需要在主线程中运行,因此我们把它放到了run()方法中。注意这个方法使用await进行了标记。需要用await的原因是该方法需要等待主线程空闲才能执行该语句。

run()方法也可以返回值。这对于进行复杂运算后报告结果比较有用。我们只需要记住必须声明闭包所返回值的类型,如下所示。

示例9-15 :通过Main Actor返回值

swift 复制代码
    func loadImage(name: String) async {
        let result: String = await MainActor.run {
            myText = name
            return "Name: (name)"
        }
        print(result)
    }

异步序列

有时信息以值的序列返回,但这些值并不是马上就绪。这时,我们可以创建一个异步序列。这种序列类似数组,但值是异步返回的,因此我们必须等待每次值都就绪。Swift标准库包含两个创建异步序列的协议:用于定义序列的AsyncSequence协议以及用于定义代码遍历序列以返回值的AsyncIteratorProtocol协议。AsyncSequence协议要求数据类型包含一个类型别名Element,表示序列返回的数据类型,以及下面这个方法。

  • makeAsyncIterator() :该方法返回复杂生成值的迭代器实例。返回的值是遵循AsyncIteratorProtocol协议的数据类型的实例。

AsyncIteratorProtocol协议只要求数据类型实现如下方法。

  • next() :该方法返回列表中的下一个元素。该方法被反复调用,直至返回的值是表示序列结束的nil

要创建异步序列,我们必须定义两个数据类型,一个遵循AsyncSequence,用于描述序列返回值的数据类型并初始化迭代器,另一个遵循AsyncIteratorProtocol协议,用于生成值。在下例中,我们定义了一个异步序列,逐一处理字符串数组并返回一个String序列。

示例9-16:定义一个异步序列

swift 复制代码
struct ImageIterator: AsyncIteratorProtocol {
    let imageList: [String]
    var current = 0
    
    mutating func next() async -> String? {
        guard current < imageList.count else {
            return nil
        }
        try? await Task.sleep(nanoseconds: 3 * 1000000000)
        let image = imageList[current]
        current += 1
        return image
    }
}

struct ImageLoader: AsyncSequence {
    typealias Element = String
    let imageList: [String]
    
    func makeAsyncIterator() -> ImageIterator {
        return AsyncIterator(imageList: imageList)
    }
}

struct ContentView: View {
    let list = ["image1", "image2", "image3"]
    
    var body: some View {
        VStack {
          Text("Hello World!")
                .padding()
        }.onAppear {
            Task(priority: .background) {
                let loader = ImageLoader(imageList: list)
                for await image in loader {
                    print(image)
                }
            }
        }
    }
}

示例9-16 中的代码模拟从网上异步下载图片。首先定义带next()方法的迭代器。在这个方法中,我们从list数组中读取字符串,并更新计数器来确定是否到达末尾(计算器的值等于或大于数组的元素数量时)。

接着由ImageLoader结构体定义异步序列。这个结构体包含一个类型别名Element,表示序列返回String值,还有一个makeAsyncIterator()方法用于初始化迭代器。

准备好读取序列中的值后,我们启动一个任务,创建ImageLoader序列的实例,然后使用for in循环遍历元素。注意for in循环要求用await关键字等待序列中的每个元素。循环一直运行到迭代器返回nil为止。

✍️跟我一起做:使用示例9-16 中的代码更新ContentView.swift文件。在模拟器中运行应用。会看到每3秒在控制中打印list数组中的值。

任务组

任务组是一个动态生成任务的容器。组创建好后,我们可以通过代码按应用要求添加和管理任务。Swift标准库定义了如下用于创建组的全局方法。

  • withTaskGroup (of : Type, returning : Type, body : Closure):该方法创建一个任务组。of参数定义由任务返回的数据类型,returning参数定义由组返回的数据类型,body参数是一个定义任务的闭包。如果没有要返回的值,可以忽略这些参数。
  • withThrowingTaskGroup (of : Type, returning : Type, body : Closure):该方法定义一个可以抛出错误的组of参数定义由任务返回的数据类型,returning参数定义由组返回的数据类型,body参数是一个定义任务的闭包。如果没有要返回的值,可以忽略这些参数。

组由TaskGroup结构体的实例定义,包含在组中管理任务的属性和方法。以下是一些最常用的属性和方法。

  • isCancelled:该属性返回一个表示组是否被取消的布尔值。
  • isEmpty:该属性返回一个表示组是否还有任务的布尔值。
  • addTask (priority : TaskPriority?, operation : Closure):该方法向组添加任务。priority参数是帮助系统决定何时执行任务的结构体。该结构体包含预定义标准权重的类型属性。当前可以使用的有backgroundhighlowmediumuserInitiatedutilityoperation参数是一个包含任务所执行语句的闭包。
  • cancelAll() :该方法取消组中的所有任务。

任务组是任务的异步序列。序列为泛型,也就是任务和组可以返回任意类型的值。这也是为什么创建任务组需要两个参数,一个用于指定任务返回的数据类型,另一个指定组返回的数据类型。

创建任务组的两个方法也一样。我们实现哪个取决于是否希望抛出错误。这些方法创建一个TaskGroup结构体,使用参数所指定的数据类型,并将实例返回给闭包。使用闭包中的值,我们可以向组添加任意需要添加的任务,如下所示。

示例9-17:定义任务组

javascript 复制代码
struct ContentView: View {
    var body: some View {
        VStack {
          Text("Hello World!")
                .padding()
        }.onAppear {
            Task(priority: .background) {
                await withTaskGroup(of: String.self) { group in
                    group.addTask(priority: .background) {
                        let imageName = await self.loadImage(name: "image1")
                        return imageName
                    }
                    group.addTask(priority: .background) {
                        let imageName = await self.loadImage(name: "image2")
                        return imageName
                    }
                    group.addTask(priority: .background) {
                        let imageName = await self.loadImage(name: "image3")
                        return imageName
                    }
                    for await result in group {
                        print(result)
                    }
                }
            }
        }
    }
    func loadImage(name: String) async -> String {
        try? await Task.sleep(nanoseconds: 3 * 1000000000)
        return "Name: (name)"
    }
}

本例中,我们创建了一个不抛出错误的任务组。这个组也不返回值,但任务返回字符串,所以我们将withTaskGroup()方法的of参数声明为String数据类型(String.self)。任务逐一添加到组中。每个任务执行与前面相同的处理。它们异步调用loadImage()方法,获取返回的字符串。

因为任务组是一个异步任务序列,我们可以使用for in循环遍历其中的值,这与前一节对所创建的异步序列操作相同。每次任务完成时,组返回由任务产生的值直至没有任务,这时会返回nil结束循环。

注意 :任务组以序列存储任务。我们可以删除、过滤甚至是检查组中是否包含指定的任务。这一话题暂不做讨论,请参见本章的参考链接部分。

异步图像

虽然本章所介绍的工具可用于执行各种类型的异步或并发任务,Swift还是单独地提供了AsyncImage视图简化我们处理图像的操作。这个视图负责从服务端下载图像并在就绪时在屏幕上显示图像。以下是最常用的初始化方法。

  • AsyncImage (url : URL, scale : CGFloat):该视图从服务端下载图像并在屏幕上显示。url参数是一个带图像url的URL 结构体,scale参数是我们希望对图像赋的放大比例(默认值是1)。
  • AsyncImage (url : URL, scale : CGFloat, content : Closure, placeholder: Closure):
  • 该视图从服务端下载图像并在屏幕上显示。url参数是一个带图像url的URL 结构体,scale参数是我们希望对图像赋的放大比例(默认值是1),content参数是处理图像的闭包,placeholder是在等待图像下载返回在图像的地方显示视图的闭包。

图片的位置由URL结构体决定。这些结构体用于存储远程地址和本地文档、文件及资源。以下是创建访问文档和网络资源URL所需要的初始化方法。

  • URL (string : String):使用string参数指定URL创建URL结构体的初始化方法。
  • URL (string : String, relativeTo : URL?):通过参数指定URL创建URL 结构体的初始化方法。将string参数值添加到relativeTo参数值来创建URL
  • URL (dataRepresentation : Data, relativeTo : URL?, isAbsolute : Bool):通过参数指定URL创建URL 结构体的初始化方法。将dataRepresentation参数的值添加到relativeTo参数值来创建URLisAbsolute是一个布尔值,指定URL是否是绝对链接(包含访问资源所需的所有信息)。

有两种类型的URL:安全的和不安全的。不安全的URL使用http协议(超文本传输协议)标识,安全的URL使用https协议(超文本安全传输协议)标识。默认允许安全URL,但如果需要打开不安全的URL,必须配置应用绕过Apple设备实现的称为ATS(App Transport Security)的安全系统。

配置ATS系统的配置项为App Transport Security Settings,通过Info面板添加至应用配置。我们介绍过这个面板(图5-13 ),使用它添加自定义字体(图5-34 )。之前也讲过,新选项通过右侧的+按钮添加。

图9-2:添加配置项的按钮

点击+按钮(图9-2圈出的部分)后,在选项下面会添加一个空的文本框。输入文字会在下拉框中显示可用的选项,可通过列表进行选择。

图9-3:应用传输安全选项

App Transport Security Settings选项只是一个容器。要配置这个选项,我们必须添加子项。点击左侧的箭头添加子项(图9-3圈出的部分),然后再次点击+按钮。允许应用打开不安全的URL的选项为Allow Arbitrary Loads

图9-4:配置应用传输安全允许访问不安全URL

Allow Arbitrary Loads接收由字符串YESNO(或是10)指定的布尔值。将其设置为YES1)允许打开任意URL。如果希望只允许指定域名,必须使用Exception Domains,添加希望包含的域名。这一子项又至少三个子项,键名分别为NSIncludesSubdomains(布尔值)、NSTemporaryExceptionAllowsInsecureHTTPLoads(布尔值)和NSTemporaryExceptionMinimumTLSVersion(字符串)。例如以下的配置允许打开alanhou.org域名下的文档。

图9-5 :配置应用传输安全允许打开来自alanhou.org的文档

配置应用传输安全系统是否必要取决于我们希望用户能够访问的URL类型。默认允许安全URL,但如果希望用户访问不安全的URL,必须添加应用配置选项,如上图所示。例如,下例加载了来自本站不安全版本的图片(http协议)。

示例9-18:异步加载图片

csharp 复制代码
struct ContentView: View {
    let website = URL(string: "http://alanhou.org/homepage/wp-content/uploads/2019/03/201903251411121.jpg")
    var body: some View {
        VStack {
            AsyncImage(url: website)
        }.padding()
    }
}

AsyncImage视图下载并显示图片只需要传一个URL。本例中,我们将URL存储在常量中,然后实现加载图像的视图。虽然有效加载并显示了图片,AsyncImage视图并不允许做任何配置,因此图片按原始大小进行显示。

图9-6:异步加载图片

如果希望配置图片,必须为content参数提供一个闭包。这个闭包接收一个Image视图,可以像之前那样通过视图修饰符进行配置。

示例9-19:配置下载完成的图片

scss 复制代码
struct ContentView: View {
    let website = URL(string: "http://alanhou.org/homepage/wp-content/uploads/2019/03/201903251411121.jpg")
    var body: some View {
        VStack {
            AsyncImage(url: website, content: { image in
                image
                    .resizable()
                    .scaledToFit()
            }, placeholder: {
                Image(.nopicture)
            })
            Spacer()
        }.padding()
    }
}

在提供了content参数后,AsyncImage视图会将图片显示的任务交给通过闭包所接收的Image视图,所以我们可以像之前一样配置该视图。本例中,我们使用resizable()修饰符重置图片的大小,通过scaleToFit()修饰符缩放图片适配在视图之内。注意我们还定义了placeholder参数在图片下载过程中作为临时显示图片。

图9-7:图片配置

✍️跟我一起做:创建一个多平台项目。下载nopicture.png,添加至Asset Catalog 。使用示例9-19 中的代码更新ContentView视图。点击顶部的导航区打开应用的配置面板(图5-4 ,编号6)。打开info面板,按照图9-29-39-4 所示的步骤操作。数秒后应该会看到nopicture.png被我们配置的图片替换掉。

代码请见:GitHub仓库

本文首发地址:AlanHou的个人博客,整理自2023年10月版《SwiftUI for Masterminds》

相关推荐
pf_data3 小时前
手机换新,怎么把旧iPhone手机数据传输至新iPhone16手机
ios·智能手机·iphone
键盘敲没电15 小时前
【iOS】KVC
ios·objective-c·xcode
吾吾伊伊,野鸭惊啼15 小时前
2024最新!!!iOS高级面试题,全!(二)
ios
吾吾伊伊,野鸭惊啼15 小时前
2024最新!!!iOS高级面试题,全!(一)
ios
不会敲代码的VanGogh16 小时前
【iOS】——应用启动流程
macos·ios·objective-c·cocoa
Swift社区19 小时前
Apple 新品发布会亮点有哪些 | Swift 周报 issue 61
ios·swiftui·swift
逻辑克20 小时前
使用 MultipeerConnectivity 在 iOS 中实现近场无线数据传输
ios
dnekmihfbnmv1 天前
好用的电容笔有哪些推荐一下?年度最值得推荐五款电容笔分享!
ios·电脑·ipad·平板
Magnetic_h2 天前
【iOS】单例模式
笔记·学习·ui·ios·单例模式·objective-c
归辞...2 天前
「iOS」——单例模式
ios·单例模式·cocoa