在异步代码的背景下,管理应用程序的内存是一件特别棘手的事情,因为各种对象和值往往需要随着时间的推移被捕获和保留,以便我们的异步调用被执行和处理。
虽然Swift相对较新的async/await 语法确实使许多种类的异步操作更容易编写,但它仍然要求我们在管理这种异步代码中涉及的各种任务和对象的内存时要相当小心。
隐式捕获
async/await (以及我们在从同步上下文中调用此类代码时需要使用的Task 类型)的一个有趣方面是,当我们的异步代码被执行时,对象和值往往最终被隐式捕获。
例如,假设我们正在开发一个DocumentViewController ,它可以下载并显示从给定URL下载的Document 。为了使我们的下载在我们的视图控制器即将显示给用户时懒洋洋地执行,我们在我们的视图控制器的viewWillAppear 方法中启动该操作,然后我们要么渲染下载的文件,一旦可用,要么显示遇到的任何错误--像这样:
class DocumentViewController: UIViewController {
private let documentURL: URL
private let urlSession: URLSession
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task {
do {
let (data, _) = try await urlSession.data(from: documentURL)
let decoder = JSONDecoder()
let document = try decoder.decode(Document.self, from: data)
renderDocument(document)
} catch {
showErrorView(for: error)
}
}
}
private func renderDocument(_ document: Document) {
...
}
private func showErrorView(for error: Error) {
...
}
}
现在,如果我们只是快速地看一下上面的代码,可能看起来没有任何对象捕获的情况发生。毕竟,异步捕获传统上只发生在转义闭包中,而转义闭包又要求我们在访问闭包中的本地属性或方法时总是显式地引用self (当self 指向一个类实例时)。
所以我们可能会期望,如果我们开始显示我们的DocumentViewController ,但在它的下载完成之前就离开了它,一旦没有外部代码(比如它的父级UINavigationController )保持对它的强引用,它就会被成功地取消分配。但实际上情况并非如此。
这是因为前面提到的隐式捕获,当我们创建一个Task ,或使用await 来等待异步调用的结果时,就会发生这种情况。任何在Task 中使用的对象都会自动被保留,直到该任务完成(或失败),包括self ,只要我们引用它的任何成员,就像我们在上面做的。
在许多情况下,这种行为实际上可能不是一个问题,而且很可能不会导致任何实际的内存泄漏,因为所有捕获的对象最终都会在其捕获任务完成后被释放。然而,假设我们期望由我们的DocumentViewController 下载的文件可能相当大,而且如果用户在不同的屏幕之间快速导航,我们不希望多个视图控制器(以及它们的下载操作)留在内存中。
解决这类问题的经典方法是执行weak self 捕获,这通常伴随着捕获闭包本身的guard let self 表达式--以便将弱引用变成强引用,然后可以在闭包的代码中使用:
class DocumentViewController: UIViewController {
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task { [weak self] in
guard let self = self else { return }
do {
let (data, _) = try await self.urlSession.data(
from: self.documentURL
)
let decoder = JSONDecoder()
let document = try decoder.decode(Document.self, from: data)
self.renderDocument(document)
} catch {
self.showErrorView(for: error)
}
}
}
...
}
不幸的是,这在这种情况下是行不通的,因为当我们的异步URLSession 调用暂停时,我们的本地self 引用仍然会被保留,直到我们闭包的所有代码运行完毕(就像一个函数中的局部变量会被保留到该范围被退出)。
因此,如果我们真的想弱化自我,那么我们就必须在整个闭包中持续使用弱的self 。为了使我们的urlSession 和documentURL 属性的使用更加简单,我们可以单独捕获这些属性,因为这样做并不会阻止我们的视图控制器本身被解配置:
class DocumentViewController: UIViewController {
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task { [weak self, urlSession, documentURL] in
do {
let (data, _) = try await urlSession.data(from: documentURL)
let decoder = JSONDecoder()
let document = try decoder.decode(Document.self, from: data)
self?.renderDocument(document)
} catch {
self?.showErrorView(for: error)
}
}
}
...
}
好消息是,在上述情况下,如果我们的视图控制器最终在下载完成之前被驳回,它将被成功地解配。
然而,这并不意味着它的任务会被自动取消。在这个特定的情况下,这可能不是一个问题,但如果我们的网络调用导致了某种副作用(如数据库更新),那么即使在我们的视图控制器被取消后,该代码仍然会运行,这可能会导致错误或意外行为。
取消任务
有一种方法可以确保一旦我们的DocumentViewController ,任何正在进行的下载任务确实会被取消,那就是存储一个对该任务的引用,然后在我们的视图控制器被解配时调用其cancel 方法:
class DocumentViewController: UIViewController {
private let documentURL: URL
private let urlSession: URLSession
private var loadingTask: Task<Void, Never>?
...
deinit {
loadingTask?.cancel()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadingTask = Task { [weak self, urlSession, documentURL] in
...
}
}
...
}
现在一切都像预期的那样,我们所有的视图控制器的内存和异步状态将在它被取消后自动被清理 - 但我们的代码在这个过程中也变得相当复杂。要为每个执行异步任务的视图控制器编写所有的内存管理代码将是相当乏味的,它甚至会让我们怀疑async/await 是否真的比Combine、委托或闭包等技术给我们带来任何真正的好处。
值得庆幸的是,还有另一种方法来实现上述模式,它不涉及相当多的代码和复杂性。由于惯例是长期运行的async 方法如果被取消就会抛出一个错误(更多信息请看这篇关于延迟异步任务的文章),我们可以在我们的视图控制器即将被解散时简单地取消我们的loadingTask - 这将使我们的任务抛出一个错误,退出,并释放它所有的捕获对象(包括self )。这样,我们就不再需要弱化地捕获self ,或者做任何其他类型的手动内存管理工作--给我们提供以下实现:
class DocumentViewController: UIViewController {
private let documentURL: URL
private let urlSession: URLSession
private var loadingTask: Task<Void, Never>?
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
loadingTask = Task {
do {
let (data, _) = try await urlSession.data(from: documentURL)
let decoder = JSONDecoder()
let document = try decoder.decode(Document.self, from: data)
renderDocument(document)
} catch {
showErrorView(for: error)
}
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
loadingTask?.cancel()
}
...
}
请注意,当我们的任务被取消时,我们的showErrorView 方法现在仍然会被调用(因为一个错误会被抛出,而且self 在这一点上仍然在内存中)。然而,就性能而言,这个额外的方法调用应该是完全可以忽略不计的。
长期运行的观察
一旦我们开始使用async/await 来设置某种异步序列或流的长期运行观察,上述的一套内存管理技术应该变得更加重要。例如,这里我们让一个UserListViewController 观察一个UserList 类,以便在一个User 模型的数组被改变后重新加载它的表视图数据:
class UserList: ObservableObject {
@Published private(set) var users: [User]
...
}
class UserListViewController: UIViewController {
private let list: UserList
private lazy var tableView = UITableView()
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
Task {
for await users in list.$users.values {
updateTableView(withUsers: users)
}
}
}
private func updateTableView(withUsers users: [User]) {
...
}
}
请注意,上述实现目前并不包括我们之前在DocumentViewController 中实现的任何任务取消逻辑,在这种情况下,这实际上会导致内存泄漏。原因是(与我们之前的Document-loading任务不同)我们的UserList 观察任务将无限期地运行,因为它是在一个基于Publisher 的异步序列上迭代,不能抛出错误,也不能以任何其他方式完成。
好消息是,我们可以使用与之前防止我们的DocumentViewController 被保留在内存中的技术完全相同的技术,轻松解决上述内存泄漏问题--也就是说,一旦我们的视图控制器即将消失,就取消我们的观察任务:
class UserListViewController: UIViewController {
private let list: UserList
private lazy var tableView = UITableView()
private var observationTask: Task<Void, Never>?
...
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
observationTask = Task {
for await users in list.$users.values {
updateTableView(withUsers: users)
}
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
observationTask?.cancel()
}
...
}
注意,在这种情况下,在deinit 中执行上述取消是行不通的,因为我们正在处理一个实际的内存泄漏--这意味着除非我们打破我们的观察任务的无尽循环,否则deinit 将永远不会被调用。
结论
乍看起来,像Task 和async/await 这样的技术使异步的、与内存有关的问题成为过去,但不幸的是,在执行各种async 标记的调用时,我们仍然必须小心地对待对象的捕获和保留。虽然实际的内存泄漏和保留周期也许不像使用Combine或闭包时那样容易遇到,但我们仍然要确保我们的对象和任务的管理方式能使我们的代码健壮且易于维护。
谢谢你的阅读!