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
}
}
取消任务实例 - 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)
}
}
任务取消传播到子任务 - 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)
]
}
}
结论
在异步编程中,重要的是停止任何不需要的后台任务以节省资源并避免后台任务干扰应用程序的任何不良副作用。 Swift Async 框架提供了多种方式来表示任务已被取消,但是任务中的代码的实现者在任务被取消时做出适当的响应取决于。任务一旦被取消,就无法取消。检查任务是否已被取消的一种方法是使用 checkCancellation,这将引发错误。另一种是简单地使用 isCancelled 作为布尔标志来查看任务是否已被取消。
在异步编程中,必须停止任何不需要的后台任务,以节省资源,并避免后台任务干扰App带来的任何不必要的副作用。Swift异步框架提供了许多方法来表明任务已被取消,但这取决于任务中的代码实现者在任务被取消时做出适当的反应。一旦一个任务被取消,就不能再取消了。检查一个任务是否被取消的一种方法是使用checkCancellation,这将抛出一个错误。另一种方法是简单地使用isCancelled作为一个布尔标志来查看任务是否已经被取消。
GitHub 上提供了 AsyncLetApp 的源代码。
译自 https://swdevnotes.com/swift/2023/how-to-cancel-a-background-task-in-swift/