跟着 Swift 5.5 引入了 async/await 特性,苹果也为 SwiftUI 增加了 task 视图润饰器,以方便开发者在视图中运用依据 async/await 的异步代码。本文将对 task 视图润饰器的特点、用法、留意事项等内容做以介绍,并供给了将其移植到老版别 SwiftUI 的办法。

原文宣布在我的博客 wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】

task vs onAppear

SwiftUI 供给了两个版别的 task 润饰器,版别一的效果和调用机遇与 onAppear 十分相似:

public func task(priority: TaskPriority = .userInitiated, _ action: @escaping @Sendable () async -> Void) -> some View

image-20220806084339042

image-20220806084436930

经过 task 润饰器开发者能够增加在视图“呈现之前”的异步操作。

用 “呈现之前” 来描绘 onAppear 或 task 闭包的调用机遇属于无法之举。在不同的上下文中,“呈现之前”会有不同的解说。概况请参阅 SwiftUI 视图的生命周期研究 一文中有关 onAppear 和 onDisappear 的章节

SwiftUI 为了判别视图的状态是否发生了改动,它会在视图的存续期内,反复地生成视图类型实例以达成此目的。因而,开发者应防止将一些会对功能造成影响的操作放置在视图类型的结构函数之中,而是在 onAppear 或 task 中进行该类型的操作。

struct TaskDemo1:View{
    @State var message:String?
    let url = URL(string:"https://news.baidu.com/")!
    var body: some View{
        VStack {
            if let message = message {
                Text(message)
            } else {
                ProgressView()
            }
        }
        .task {  // VStack “呈现之前” 履行闭包中的代码
            do {
                var lines = 0
                for try await _ in url.lines { // 读取指定 url 的内容
                    lines += 1
                }
                try? await Task.sleep(nanoseconds: 1_000_000_000) // 模拟更杂乱的使命
                message = "Received \(lines) lines"
            } catch {
                message = "Failed to load data"
            }
        }
    }
}

咱们能够经过 priority 参数来设定创立异步使命时要运用的使命优先级( 默许优先级为 userInitiated )。

.task(priority: .background) {
    // do something
}

使命优先级并不会影响创立使命所运用的线程

task vs onChange

另一个版别的 task 润饰器则供给了相似 onChange + onAppear 的联合才能。

public func task<T>(id value: T, priority: TaskPriority = .userInitiated, _ action: @escaping @Sendable () async -> Void) -> some View where T : Equatable

除了在视图“呈现之前”履行一次异步使命外,还会在其调查的值( 契合 Equatable 协议 )发生变化时,从头履行一次使命( 创立一个新的异步使命 ):

struct TaskDemo2: View {
    @State var status: Status = .loading
    @State var reloadTrigger = false
    let url = URL(string: "https://source.unsplash.com/400x300")! // 获取随机图片的地址
    var body: some View {
        VStack {
            Group {
                switch status {
                case .loading:
                    Rectangle()
                        .fill(.secondary)
                        .overlay(Text("Loading"))
                case .image(let image):
                    image
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                case .error:
                    Rectangle()
                        .fill(.secondary)
                        .overlay(Text("Failed to load image"))
                }
            }
            .padding()
            .frame(width: 400, height: 300)
            Button(status.loading ? "Loading" : "Reload") {
                reloadTrigger.toggle()  // 读取新图
            }
            .disabled(status.loading)
            .buttonStyle(.bordered)
        }
        .animation(.easeInOut, value: status)
        .task(id: reloadTrigger) { // 在 VStack “呈现之前” 以及当 reloadTrigger 发生变化时,履行如下内容。
            do {
                status = .loading
                var bytes = [UInt8]()
                for try await byte in url.resourceBytes {
                    bytes.append(byte)
                }
                if let uiImage = UIImage(data: Data(bytes)) {
                    let image = Image(uiImage: uiImage)
                    status = .image(image)
                } else {
                    status = .error
                }
            } catch {
                status = .error
            }
        }
    }
    enum Status: Equatable {
        case loading
        case image(Image)
        case error
        var loading: Bool {
            switch self {
            case .loading:
                return true
            default:
                return false
            }
        }
    }
}

task_onChange_Recording_iPhone_12_Pro_15.5_2022-08-06_10.50.13.2022-08-06 10_51_57

task 的生命周期

上文中的两段演示代码,即便算上网络延迟, task 闭包的运转继续时刻也不会太长。这并没有充分发挥 task 的优势,因为咱们还能够用 task 润饰器创立能够继续运转的异步使命:

struct TimerView:View{
    @State var date = Date.now
    @State var show = true
    var body: some View{
        VStack {
            Button(show ? "Hide Timer" : "Show Timer"){
                show.toggle()
            }
            if show {
                Text(date,format: .dateTime.hour().minute().second())
                    .task {
                        let taskID = UUID()  // 使命 ID
                        while true { // 继续运转
                            try? await Task.sleep(nanoseconds: 1_000_000_000) // 间隔一秒
                            let now = Date.now // 每隔一秒更新一次时刻
                            date = now
                            print("Task ID \(taskID) :\(now.formatted(date: .numeric, time: .complete))")
                        }
                    }
            }
        }
    }
}

这段代码将经过 task 润饰器创立一个继续运转的异步使命,每秒更新一次 date 变量,而且在操控台中显示当时的使命 ID 及时刻。

task_longrun1_2022-08-07_09.07.44.2022-08-07 09_09_38

咱们的原意是经过按钮来开启和封闭计时器的显示以操控使命的生命周期( 封闭时结束使命 ),但在点击 Hide Timer 按钮后,app 呈现了无法呼应且操控台仍在继续输出( 不依照原定的间隔时刻 )的情况,为什么会呈现这样的问题呢?

app 无法呼应是因为当时 task 是在主线程上运转的,假如依照下文中的办法将 task 运转在后台线程之中,那么 app 将能够继续呼应,但会在不显示日期文字的情况下,继续更新 date 变量,而且会在操控台继续输出

Swift 选用的是协作式使命撤销机制,也就是说,SwiftUI 是无法直接中止掉咱们经过 task 润饰器创立的异步使命的。当满意了需求中止由 task 润饰器创立的异步使命条件时,SwiftUI 会给该使命发送使命撤销信号,使命有必要自行呼应该信号并中止作业。

在以下两种情况下,SwiftUI 会给由 task 创立的异步使命发送使命撤销信号:

  • 视图( task 润饰器绑定的视图 )满意 onDisappear 触发条件时
  • 绑定的值发生变化时( 选用 task 调查值变化时 )

为了让之前的代码能够呼应撤销信号,咱们需做如下调整:

// 将
while true {
// 修正成 
while !Task.isCancelled { // 仅在当时使命没被撤销时履行以下代码

task_longrun2_2022-08-07_09.39.21.2022-08-07 09_40_53

开发者也能够运用 Swift 这种协作式撤销的机制来完成一些相似 onDisappear 的操作。

.task {
    let taskID = UUID()
    defer {
        print("Task \(taskID) has been cancelled.")
        // 做一些数据的善后工作
    }
    while !Task.isCancelled {
        try? await Task.sleep(nanoseconds: 1000000000)
        let now = Date.now
        date = now
        print("Task ID \(taskID) :\(now.formatted(date: .numeric, time: .complete))")
    }
}

task 运转的线程

运用 task 润饰器在视图中创立异步使命,除了方便运用依据 async/await 语法的 API 外,开发者也期望能够让这些使命运转在后台线程中,以削减主线程的负担。

十分惋惜,当时上文中所有的运用 task 创立的异步使命都是运转在主线程傍边的。你能够经过在闭包中增加如下句子查看当时使命运转的线程:

print(Thread.current)
// <_NSMainThread: 0x6000011d0b80>{number = 1, name = main}

为什么会呈现这样的情况呢?task 为什么没有默许运转在后台线程中?

运用 url.lines 和 url.resourceBytes 获取网络数据时,系统 API 会跳转到后台线程,不过最终仍会回到主线程上

想要了解并处理这个问题,咱们还要从 task 润饰器的界说中下手。以下是 task 润饰器愈加完好的界说( 从 swiftinterface 文件中取得 ):

@inlinable public func task(priority: _Concurrency.TaskPriority = .userInitiated, @_inheritActorContext _ action: @escaping @Sendable () async -> Swift.Void) -> some SwiftUI.View {
    modifier(_TaskModifier(priority: priority, action: action))
}

其间 @_inheritActorContext 编译特点将为咱们带来答案。

image-20220807111608120

当一个 @Sendable async 闭包被标记 @_inheritActorContext 特点后,闭包将依据其声明的地址来承继 actor 上下文( 即它应该在哪个 actor 上运转 )。那些没有特别声明需运转在某特定 actor 上的闭包,它们能够运转于恣意地址( 任何的线程之中 )。

回到当时的问题,因为 View 协议限定了 body 特点有必要运转于主线程中( 运用了 @MainActor 进行标示 ),因而,假如咱们直接在 body 中为 task 润饰器增加闭包代码,那么该闭包只能运转于主线程中( 闭包承继了 body 的 actor 上下文 )。

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
public protocol View {
    associatedtype Body : View
    @ViewBuilder @MainActor var body: Self.Body { get }
}

假如咱们想让 task 润饰器中的闭包不运转在主线程上,只需求将其声明在没有要求运转于 @MainActor 的地方即可。例如,将上面的计时器代码修正为:

struct TimerView: View {
    @State var date = Date.now
    @State var show = true
    var body: some View {
        VStack {
            Button(show ? "Hide Timer" : "Show Timer") {
                show.toggle()
            }
            if show {
                Text(date, format: .dateTime.hour().minute().second())
                    .task(timer) 
            }
        }
    }
    // 在 body 外面界说异步函数
    @Sendable
    func timer() async {
        let taskID = UUID()
        print(Thread.current)
        defer {
            print("Task \(taskID) has been cancelled.")
            // 做一些数据的善后工作
        }
        while !Task.isCancelled {
            try? await Task.sleep(nanoseconds: 1000000000)
            let now = Date.now
            date = now
            print("Task ID \(taskID) :\(now.formatted(date: .numeric, time: .complete))")
        }
    }
}

task_thread1_2022-08-07_15.21.25.2022-08-07 15_23_01

务必留意,假如将 .task(timer) 写为 .task{ await timer() } ,则仍会运转于主线程中

假如你的视图中声明了其他契合 DynamicProperty 协议的 Source of Truth ( 将 wrappedValue 和 projectedValue 标示为 @MainActor ),那么上面的办法将不再适用。因为 SwiftUI 会将视图类型的实例默许揣度为标示了 @MainActor ,并限定运转于主线程( 不仅仅是 body 特点 )

struct TimerView: View {
    @State var date = Date.now
    @State var show = true
    // 在 StateObject 的界说中,wrappedValue 和 projectedValue 被标示了 @MainActor
    @StateObject var testObject = TestObject() // 导致 SwiftUI 会将视图类型的实例默许揣度为运转于主线程
    var body: some View {
        VStack {
            Button(show ? "Hide Timer" : "Show Timer") {
                show.toggle()
            }
            if show {
                Text(date, format: .dateTime.hour().minute().second())
                    .task(timer) 
            }
        }
    }
    // 在 body 外面界说异步函数
    @Sendable
    func timer() async {
       print(Thread.current) // 仍然会运转于主线程
       ....
    }
}

咱们能够经过将异步办法移到视图类型之外来处理这个问题。

SwiftUI 对 @State 做了特别的处理,咱们能够在恣意线程中对其进行安全的修正。但关于其他契合 DynamicProperty 协议的 Source of Truth ( 将 wrappedValue 和 projectedValue 标示为 @MainActor ),在修正前有必要切换到主线程上:

struct TimerView: View {
    @StateObject var object = TestObject()
    var body: some View {
        VStack {
            Button(object.show ? "Hide Timer" : "Show Timer") {
                object.show.toggle()
            }
            if object.show {
                Text(object.date, format: .dateTime.hour().minute().second())
                    .task(object.timer)
            }
        }
    }
}
class TestObject: ObservableObject {
    @Published var date: Date = .now
    @Published var show = true
    @Sendable
    func timer() async {
        let taskID = UUID()
        print(Thread.current)
        defer {
            print("Task \(taskID) has been cancelled.")
            // 做一些数据的善后工作
        }
        while !Task.isCancelled {
            try? await Task.sleep(nanoseconds: 1000000000)
            let now = Date.now
            await MainActor.run { // 需求切换回主线程
                date = now
            }
            print("Task ID \(taskID) :\(now.formatted(date: .numeric, time: .complete))")
        }
    }
}

task vs onReceive

通常,咱们会用 onReceive 润饰器在视图中呼应 Notification Center 的音讯。作为一个事情源类型的 Source of Truth,每逢接收到一个新的音讯时,它都会导致 SwiftUI 对视图的 body 从头求值。

请阅览 防止 SwiftUI 视图的重复计算 一文,以了解更多有关事情源方面的内容

假如,你想有选择性的处理音讯,能够考虑用 task 来替代 onReceive,例如:

struct NotificationHandlerDemo: View {
    @State var message = ""
    var body: some View {
        Text(message)
            .task(notificationHandler)
    }
    @Sendable
    func notificationHandler() async {
        for await notification in NotificationCenter.default.notifications(named: .messageSender) where !Task.isCancelled {
            // 判别是否满意特定条件
            if let message = notification.object as? String, condition(message) {
                self.message = message
            }
        }
    }
    func func condition(_ message: String) -> Bool { message.count > 10 }
}
extension Notification.Name {
    static let messageSender = Notification.Name("messageSender")
}

在当时场景中,运用 task 替换 onReceive 能够取得两个优点:

  • 削减视图不必要的刷新( 防止重复计算 )
  • 在后台线程呼应音讯,削减主线程的负荷

为老版别的 SwiftUI 增加 task 润饰器

当时,Swift 现已将 async/await 特性向后移植至 iOS 13,但并没有在低版别的 SwiftUI 中供给 task 润饰器( 原生的 task 润饰器最低要求 iOS 15 )。

在了解了两个版别的 task 润饰器的工作原理和调用机制后,为老版别的 SwiftUI 增加 task 润饰器将不再有任何困难。

#if canImport(_Concurrency)
import _Concurrency
import Foundation
import SwiftUI
public extension View {
    @available(iOS, introduced: 13.0, obsoleted: 15.0)
    func task(priority: TaskPriority = .userInitiated, @_inheritActorContext _ action: @escaping @Sendable () async -> Void) -> some View {
        modifier(_MyTaskModifier(priority: priority, action: action))
    }
    @available(iOS, introduced: 14.0, obsoleted: 15.0)
    func task<T>(id value: T, priority: TaskPriority = .userInitiated, @_inheritActorContext _ action: @escaping @Sendable () async -> Void) -> some View where T: Equatable {
        modifier(_MyTaskValueModifier(value: value, priority: priority, action: action))
    }
}
@available(iOS 13,*)
struct _MyTaskModifier: ViewModifier {
    @State private var currentTask: Task<Void, Never>?
    let priority: TaskPriority
    let action: @Sendable () async -> Void
    @inlinable public init(priority: TaskPriority, action: @escaping @Sendable () async -> Void) {
        self.priority = priority
        self.action = action
    }
    public func body(content: Content) -> some View {
        content
            .onAppear {
                currentTask = Task(priority: priority, operation: action)
            }
            .onDisappear {
                currentTask?.cancel()
            }
    }
}
@available(iOS 13,*)
struct _MyTaskValueModifier<Value>: ViewModifier where Value: Equatable {
    var action: @Sendable () async -> Void
    var priority: TaskPriority
    var value: Value
    @State private var currentTask: Task<Void, Never>?
    public init(value: Value, priority: TaskPriority, action: @escaping @Sendable () async -> Void) {
        self.action = action
        self.priority = priority
        self.value = value
    }
    public func body(content: Content) -> some View {
        content
            .onAppear {
                currentTask = Task(priority: priority, operation: action)
            }
            .onDisappear {
                currentTask?.cancel()
            }
            .onChange(of: value) { _ in
                currentTask?.cancel()
                currentTask = Task(priority: priority, operation: action)
            }
    }
}
#endif

你能够自行增加一个 onChange 的向后移植版别( 支撑 iOS 13 ),让第二个版别的 task 润饰器( onAppear + onChange )支撑到 iOS 13

总结

task 润饰器将 async/await 和 SwiftUI 视图的生命周期连接起来,让开发者能够在视图中高效地构建杂乱的异步使命。但过度地经过 task 润饰器在视图声明中对副效果进行操控,也会对视图的纯粹度、可测验度、复用性等造成影响。开发者应拿捏好运用的尺度。

期望本文能够对你有所帮助。

原文宣布在我的博客 wwww.fatbobman.com

欢迎订阅我的公共号:【肘子的Swift记事本】