如何在 Swift 中取消一个后台任务

Swift 5.5中引入的 async/await 语法,允许用更可读的方式来编写异步代码。异步编程可以提高应用程序的性能,但必须取消不需要的任务,以确保不需要的后台任务不会干扰到应用程序。本文演示了如何明确地取消一个任务,并展示了子任务是如何自动取消的。

该代码建立在在 Swift 中使用 async let 并行的运行后台任务中编写的AsyncLetApp之上。

为什么要取消一个后台任务

与视图的交互可能会触发后台任务的运行,进一步的交互可能会使最初的请求过时,并触发后续的后台任务运行。除了浪费资源外,不取消初始任务可能会导致你的应用程序出现偶现和意外行为。

一个取消按钮被添加到视图中,其点击事件是在ViewModel中调用取消方法。在ViewModel中添加了一些日志记录,以便在文件下载增加时和文件isDownloading属性被设置为false时打印出来。可以看到,在下载被取消后,任务继续进行,并最终将isDownloading属性设置为false。如果一个下载被取消,而随后的下载又迅速开始,这可能会在用户界面上造成问题———第一个任务的isDownloading属性被设置为false,效果是停止了第二次下载。

View:

struct NaiveCancelView: View {
    @ObservedObject private var dataFiles: DataFileViewModel5
    @State var fileCount = 0
    
    init() {
        dataFiles = DataFileViewModel5()
    }
    
    var body: some View {
        ScrollView {
            VStack {
                TitleView(title: ["Naive Cancel"])
                
                HStack {
                    Button("Download All") {
                        Task {
                            let num = await dataFiles.downloadFile()
                            fileCount += num
                        }
                    }
                    .buttonStyle(BlueButtonStyle())
                .disabled(dataFiles.file.isDownloading)
                    Button("Cancel") {
                        dataFiles.cancel()
                    }
                    .buttonStyle(BlueButtonStyle())
                    .disabled(!dataFiles.file.isDownloading)
                }
                
                Text("Files Downloaded: \(fileCount)")
                
                HStack(spacing: 10) {
                    Text("File 1:")
                    ProgressView(value: dataFiles.file.progress)
                        .frame(width: 180)
                    Text("\((dataFiles.file.progress * 100), specifier: "%0.0F")%")
                    
                    ZStack {
                        Color.clear
                            .frame(width: 30, height: 30)
                        if dataFiles.file.isDownloading {
                            ProgressView()
                                .progressViewStyle(CircularProgressViewStyle(tint: .blue))
                        }
                    }
                }
                .padding()

                .onDisappear {
                    dataFiles.cancel()
                }
                
                Spacer().frame(height: 200)
                
                Button("Reset") {
                    dataFiles.reset()
                }
                .buttonStyle(BlueButtonStyle())
                
                Spacer()
            }
            .padding()
        }
    }
}

ViewModel:

class DataFileViewModel5: ObservableObject {
    @Published private(set) var file: DataFile
    
    init() {
        self.file = DataFile(id: 1, fileSize: 10)
    }
    
    func downloadFile() async -> Int {
        await MainActor.run {
            file.isDownloading = true
        }
        
        for _ in 0..<file.fileSize {
            await MainActor.run {
                guard file.isDownloading else { return }
                file.increment()
                print("  --   Downloading file - \(file.progress * 100) %")
            }
            usleep(300000)
        }
        
        await MainActor.run {
            file.isDownloading = false
            print(" ***** File : \(file.id) setting isDownloading to FALSE")
        }
        
        return 1
    }
    
    func cancel() {
        file.isDownloading = false
        self.file = DataFile(id: 1, fileSize: 10)
    }
        
    func reset() {
        self.file = DataFile(id: 1, fileSize: 10)
    }
}
第二个下载任务因第一个后台任务的完成而停止

使用取消标志

有多种方法可以取消后台任务中的工作。一种机制是向具有异步任务的对象添加状态标志,并在任务运行时监视此标志。不需要对 View 进行任何更改,取消按钮仍然调用 ViewModel 中的 cancel 函数。

对 ViewModel 的更改包括添加一个 cancelFlag 布尔属性,该属性必须用 MainActor 标记,因为它需要在主 UI 线程上更新。模拟文件下载的循环根据两个条件从 for 循环更新为 while 循环:

  • 取消标志的值是 false
  • 文件正在下载

这解决了这个问题,但是有一个额外的标志来取消下载似乎太多余了。

View:

struct CancelFlagView: View {
    @ObservedObject private var dataFiles: DataFileViewModel6
    @State var fileCount = 0

    init() {
        dataFiles = DataFileViewModel6()
    }
    
    var body: some View {
        ScrollView {
            VStack {
                TitleView(title: ["Use Cancel Flag"])
                
                HStack {
                    Button("Download All") {
                        Task {
                            let num = await dataFiles.downloadFile()
                            fileCount += num
                        }
                    }
                    .buttonStyle(BlueButtonStyle())
                .disabled(dataFiles.file.isDownloading)
                    Button("Cancel") {
                        dataFiles.cancel()
                    }
                    .buttonStyle(BlueButtonStyle())
                    .disabled(!dataFiles.file.isDownloading)
                }
                
                Text("Files Downloaded: \(fileCount)")
                
                HStack(spacing: 10) {
                    Text("File 1:")
                    ProgressView(value: dataFiles.file.progress)
                        .frame(width: 180)
                    Text("\((dataFiles.file.progress * 100), specifier: "%0.0F")%")
                    
                    ZStack {
                        Color.clear
                            .frame(width: 30, height: 30)
                        if dataFiles.file.isDownloading {
                            ProgressView()
                                .progressViewStyle(CircularProgressViewStyle(tint: .blue))
                        }
                    }
                }
                .padding()
                
                .onDisappear {
                    dataFiles.cancel()
                }

                Spacer().frame(height: 200)
                
                Button("Reset") {
                    dataFiles.reset()
                }
                .buttonStyle(BlueButtonStyle())
                
                Spacer()
            }
            .padding()
        }
    }
}

ViewModel:

class DataFileViewModel6: ObservableObject {
    @Published private(set) var file: DataFile
    
    @MainActor var cancelFlag = false
    
    init() {
        self.file = DataFile(id: 1, fileSize: 10)
    }
    
    func downloadFile() async -> Int {
        await MainActor.run {
            file.isDownloading = true
        }
        
        while await !cancelFlag, file.isDownloading {
            await MainActor.run {
                print("  --   Downloading file - \(file.progress * 100) %")
                file.increment()
            }
            usleep(300000)
        }
        
        await MainActor.run {
            file.isDownloading = false
            print(" ***** File : \(file.id) setting isDownloading to FALSE")
        }
        
        return 1
    }
  
    @MainActor
    func cancel() {
        self.cancelFlag = true
        print(" ***** File : \(file.id) setting cancelFlag to TRUE")
        self.reset()
    }
        
    @MainActor
    func reset() {
        self.file = DataFile(id: 1, fileSize: 10)
        self.cancelFlag = false
    }
}
在 ViewModel 中使用取消标志来结束后台循环

取消任务实例 - Task.checkCancellation()

一个更优雅的解决方案是为 Task 创建一个状态属性,并在下载按钮操作的视图中将任务分配给该属性。取消按钮可以取消这个任务。听起来很简单,对吧!好吧,还有一些事情要做,因为正如文档所说:

Tasks include a shared mechanism for indicating cancellation, but not a shared implementation for how to handle cancellation.

任务包括一个用于表示取消的共享机制,但是没有一个关于如何处理取消的共享实现。

这是因为任务的取消方式会因任务正在执行的操作而异。

在此示例中,ViewModel 中的 downloadFile 函数更改为在下载循环中使用 checkCancellation。这将检查是否取消,如果任务已被取消,则会抛出错误。抛出此错误时,可以将 isDownloading 标志设置为 false,并且可以选择重置 ViewModel。

这次,取消标志和所有相关代码都可以从 ViewModel 中完全删除。

View:

struct CancelTaskView: View {
    @ObservedObject private var dataFiles: DataFileViewModel7
    @State var fileCount = 0
    
    @State var fileDownloadTask: Task<Void, Error>?
    
    init() {
        dataFiles = DataFileViewModel7()
    }
    
    var body: some View {
        ScrollView {
            VStack {
                TitleView(title: ["Cancel Task", "", "<checkCancellation()>"])
                
                HStack {
                    Button("Download All") {
                        fileDownloadTask = Task {
                            let num = await dataFiles.downloadFile()
                            fileCount += num
                        }
                    }
                    .buttonStyle(BlueButtonStyle())
                    .disabled(dataFiles.file.isDownloading)
                    Button("Cancel") {
                        fileDownloadTask?.cancel()
                    }
                    .buttonStyle(BlueButtonStyle())
                    .disabled(!dataFiles.file.isDownloading)
                }
                
                Text("Files Downloaded: \(fileCount)")
                
                HStack(spacing: 10) {
                    Text("File 1:")
                    ProgressView(value: dataFiles.file.progress)
                        .frame(width: 180)
                    Text("\((dataFiles.file.progress * 100), specifier: "%0.0F")%")
                    
                    ZStack {
                        Color.clear
                            .frame(width: 30, height: 30)
                        if dataFiles.file.isDownloading {
                            ProgressView()
                                .progressViewStyle(CircularProgressViewStyle(tint: .blue))
                        }
                    }
                }
                .padding()
                
                .onDisappear {
                    fileDownloadTask?.cancel()
                    dataFiles.reset()
                }
                
                Spacer().frame(height: 200)
                
                Button("Reset") {
                    fileDownloadTask?.cancel()
                    dataFiles.reset()
                }
                .buttonStyle(BlueButtonStyle())
                
                Spacer()
            }
            .padding()
        }
    }
}

ViewModel:

class DataFileViewModel7: ObservableObject {
    @Published private(set) var file: DataFile
    
    init() {
        self.file = DataFile(id: 1, fileSize: 10)
    }
    
    func downloadFile() async  -> Int {
        await MainActor.run {
            file.isDownloading = true
        }
        
        while file.isDownloading {
            do {
                try await MainActor.run {
                    try Task.checkCancellation()
                    
                    print("  --   Downloading file - \(file.progress * 100) %")

                    file.increment()
                }
                usleep(300000)
            } catch {
                print(" *************** Catch - The task has been cancelled")
                // Set the isDownloading flag to false
                await MainActor.run {
                    file.isDownloading = false
                    // reset()
                }
            }
        }
        
        await MainActor.run {
            file.isDownloading = false
            print(" ***** File : \(file.id) setting isDownloading to FALSE")
        }
        
        return 1
    }
        
    @MainActor
    func reset() {
        self.file = DataFile(id: 1, fileSize: 10)
    }
}
在 SwiftUI 中取消任务实例

任务取消传播到子任务 - Task.isCancelled

使用 checkCancellation 引发异常的代替方法是使用 isCancelled 查看任务是否已取消。

此方法仍然使用Task的状态属性。它被分配给下载按钮中的 downloadFiles 函数,任务通过视图中的取消按钮取消。

View:

struct CancelTaskMultipleView: View {
    @ObservedObject private var dataFiles: DataFileViewModel8
    
    @State var fileDownloadTask: Task<Void, Error>?
    
    init() {
        dataFiles = DataFileViewModel8()
    }
    
    var body: some View {
        ScrollView {
            VStack {
                TitleView(title: ["Cancel Task", "", "<Task.isCancelled>"])
                
                HStack {
                    Button("Download All") {
                        fileDownloadTask = Task {
                            await dataFiles.downloadFiles()
                        }
                    }
                    .buttonStyle(BlueButtonStyle())
                    .disabled(dataFiles.isDownloading)
                    
                    Button("Cancel") {
                        fileDownloadTask?.cancel()
                    }
                    .buttonStyle(BlueButtonStyle())
                    .disabled(!dataFiles.isDownloading)
                }
                
                Text("Files Downloaded: \(dataFiles.fileCount)")
                
                ForEach(dataFiles.files) { file in
                    HStack(spacing: 10) {
                        Text("File \(file.id):")
                        ProgressView(value: file.progress)
                            .frame(width: 180)
                        Text("\((file.progress * 100), specifier: "%0.0F")%")
                        
                        ZStack {
                            Color.clear
                                .frame(width: 30, height: 30)
                            if file.isDownloading {
                                ProgressView()
                                    .progressViewStyle(CircularProgressViewStyle(tint: .blue))
                            }
                        }
                    }
                }
                .padding()
                
                .onDisappear {
                    fileDownloadTask?.cancel()
                    dataFiles.reset()
                }
                
                Spacer().frame(height: 150)
                
                Button("Reset") {
                    fileDownloadTask?.cancel()
                    dataFiles.reset()
                }
                .buttonStyle(BlueButtonStyle())
                
                Spacer()
            }
            .padding()
        }
    }
}

ViewModel:

class DataFileViewModel8: ObservableObject {
    @Published private(set) var files: [DataFile]
    @Published private(set) var fileCount = 0
    
    init() {
        files = [
            DataFile(id: 1, fileSize: 10),
            DataFile(id: 2, fileSize: 20),
            DataFile(id: 3, fileSize: 5)
        ]
    }
    
    var isDownloading : Bool {
        files.filter { $0.isDownloading }.count > 0
    }
    
    func downloadFiles() async {
        async let num1 = await downloadFile(0)
        async let num2 = await downloadFile(1)
        async let num3 = await downloadFile(2)
        let (result1, result2, result3) = await (num1, num2, num3)
        await MainActor.run {
            fileCount = result1 + result2 + result3
        }
    }
    
    private func downloadFile(_ index: Array<DataFile>.Index) async -> Int {
        await MainActor.run {
            files[index].isDownloading = true
        }
        
        while files[index].isDownloading, !Task.isCancelled {
            await MainActor.run {
                print("  --   Downloading file \(files[index].id) - \(files[index].progress * 100) %")
                files[index].increment()
            }
            usleep(300000)
        }
        
        await MainActor.run {
            files[index].isDownloading = false
            print(" ***** File : \(files[index].id) setting isDownloading to FALSE")
        }
        return 1
    }
    
    @MainActor
    func reset() {
        files = [
            DataFile(id: 1, fileSize: 10),
            DataFile(id: 2, fileSize: 20),
            DataFile(id: 3, fileSize: 5)
        ]
    }
}
取消任务实例会取消 SwiftUI 中的子任务
在 SwiftUI 中取消和恢复后台任务

结论

在异步编程中,重要的是停止任何不需要的后台任务以节省资源并避免后台任务干扰应用程序的任何不良副作用。 Swift Async 框架提供了多种方式来表示任务已被取消,但是任务中的代码的实现者在任务被取消时做出适当的响应取决于。任务一旦被取消,就无法取消。检查任务是否已被取消的一种方法是使用 checkCancellation,这将引发错误。另一种是简单地使用 isCancelled 作为布尔标志来查看任务是否已被取消。

在异步编程中,必须停止任何不需要的后台任务,以节省资源,并避免后台任务干扰App带来的任何不必要的副作用。Swift异步框架提供了许多方法来表明任务已被取消,但这取决于任务中的代码实现者在任务被取消时做出适当的反应。一旦一个任务被取消,就不能再取消了。检查一个任务是否被取消的一种方法是使用checkCancellation,这将抛出一个错误。另一种方法是简单地使用isCancelled作为一个布尔标志来查看任务是否已经被取消。

GitHub 上提供了 AsyncLetApp 的源代码。

译自 https://swdevnotes.com/swift/2023/how-to-cancel-a-background-task-in-swift/

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,185评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,445评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,684评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,564评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,681评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,874评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,025评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,761评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,217评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,545评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,694评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,351评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,988评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,778评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,007评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,427评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,580评论 2 349

推荐阅读更多精彩内容