在 DoorDash 公司(位于旧金山,是美国最大的外卖公司),我们一直在努力通过提高应用程序的稳定性来提高用户体验。这项工作的主要部分是防止、修复和消除庞大的代码库中的任何循环引用和内存泄漏。为了检测和修复这些问题,我们发现 Memory Graph Debugger 用起来简单又快捷。在大幅提高Dasher iOS 应用程序的无 OOM 会话率之后,我们想分享一些避免和修复循环引用的技巧,以及使用 Xcode memory graph debugger 的快速介绍,供不熟悉的用户使用。
如果您对找出问题内存的根本原因感兴趣,请查看我们的新博客文章《使用 BPF、perf 和 Memcheck 检查 C/C++ 应用程序中的问题内存》了解内存如何工作的详细说明。
I. 什么是循环引用和内存泄漏?
iOS 中的内存泄漏是指在内存中分配的由于循环引用而无法释放的空间量。由于 Swift 使用自动引用计数(ARC),当两个或多个对象彼此持有强引用时,就会发生循环引用。因为这些对象在内存中互相持有,所以它们的引用计数永远不会减少到0,这将导致 deinit
方法不会被调用且内存不会被释放。
II. 为什么我们要关注内存泄漏?
内存泄漏会逐渐增加应用程序中的内存占用量,当它达到某个阈值时,操作系统(iOS)会触发内存警告。如果不处理内存警告,您的应用程序将被强制终止,这就是 OOM(内存不足)崩溃。正如你所看到的,如果发生大量泄漏,内存泄漏可能会非常严重,因为在使用一段时间后,应用程序就会崩溃。
此外,内存泄漏可能会在应用程序中引入副作用。通常情况下,这发生在观察者本应被释放却被保留在内存中的时候。这些被泄漏的观察者仍然会听取通知,而在被触发时,应用程序很容易出现不可预测的行为或崩溃。在下一节中,我们将介绍 Xcode 的内存图调试器(Memory Graph Debugger),稍后将使用它在示例应用程序中查找内存泄漏。
III. Xcode 内存图调试器的介绍
要打开内存图调试器,请运行你的应用程序(在本例中,我正在运行一个演示应用程序),然后点击视觉调试器和位置模拟器按钮之间的有三个点的按钮。这将获取应用程序当前状态的内存快照。
Screen-Shot-2019-05-04-at-3.45.45-PM.png左侧面板显示此快照的内存中的对象,每个类名称旁边都跟着它的实例数。
例如:MainViewController(1)
图中显示快照时内存中只有一个 MainViewController
,后面是该实例在内存中的地址。
如果你在左侧面板上选择一个对象,你将看到将选定对象保存在内存中的引用链。例如,在 MainViewController
下选择 0x7f85204227c0
将显示如下图表:
- 粗线表示这是对它指向的对象的强引用。
- 浅灰色线表示这是对它指向的对象的未知引用(可能是 weak 或 strong)。
- 从左侧面板点击一个实例只会显示将选定对象保存在内存中的引用链。但它不会显示选定对象引用了哪些引用。
例如,要验证 MainViewController
强引用的对象中没有循环引用,您需要查看代码库找到被引用的对象,然后单独选择每个对象图以检查是否存在循环引用。
此外,内存图调试器可以自动检测简单的内存泄漏,并提示警告,如紫色 !
标记。点击它将在左侧面板上显示泄漏的实例。
请注意,Xcode 的自动检测并不总能捕捉到每一个内存泄漏,通常情况下,你必须自己找到它们。在下一节中,我将解释使用内存图调试器进行调试的方法。
IV. 使用内存图调试器的方法
捕捉内存泄漏的一个有用方法是运行应用程序通过一些核心流程操作,并为第一次和后续迭代拍摄内存快照。
- 运行一个核心流程或功能并离开它,然后重复几次并拍摄应用程序的内存快照。看看内存中有哪些对象,每个对象中有多少实例。
- 检查循环引用/内存泄漏的这些表征:
- 在左边的面板中,你是否看到列表中有任何不应该存在或应该被释放的对象/类/视图等?
- 内存中保存的类的同一实例是否越来越多?例如:
MainViewController(1)
在经过流程4次迭代后变成MainViewControl(5)
? - 查看左侧面板上的调试导航器,你注意到内存增加了吗?尽管恢复到原始状态,应用程序现在是否比以前消耗了更多的兆字节(MB)
- 如果你发现了一个不应该再存在于内存中的实例,那么你就发现了对象的泄漏实例。
- 点击泄漏的实例,并使用对象图追踪将其保留在内存中的对象。
- 你可能需要继续导航对象图以跟踪保持内存中对象链的父节点。
- 一旦你确信找到了父节点,请查看该对象的代码,找出循环强引用的来源并修复它。
在下一节中,我将介绍我个人看到的导致循环引用的代码的常见用例示例。要继续,请下载这个名为 LeakyApp 的示例项目。
V. 通过例子修正内存泄漏
下载相同的 Xcode 项目后,运行应用程序。我们将做一个使用内存图调试器的示范。
- 应用程序运行后,您将看到三个按钮。我们将做一个示范。那么,点击“Leaky Controller”。
- 这将显示
ObservableViewController
,它只是一个带有导航栏的空视图。 - 轻触返回导航键。
- 重复几次。
- 现在拍摄一个内存快照。
拍摄内存快照后,你将看到像这样的内容:
Screen-Shot-2019-05-04-at-3.51.25-PM.png由于我们多次重复这个流程,一旦我们返回主屏幕 MainViewController
,如果没有内存泄漏,那么 ObservableViewController
应该已经被释放。然而,我们在左侧面板中看到了 ObservableViewController(25)
,这意味着我们有25个视图控制器的实例仍在内存中!还要注意的是,Xcode 没有将此识别为内存泄漏!
现在,点击 ObservableViewController(25)
。你将看到对象图,它看起来像这样:
如你所见,它显示了一个 Swift 闭包上下文,是它将 ObservableViewController
保留在内存中。此闭包由 __NSObserver
在内存中持有。现在让我们查看代码并修复此漏洞。
现在我们来到文件 ObservableViewController.swift
。粗略看去,我们有一个非常常见的使用案例:
extension Notification.Name {
static let SomethingToObserveNotification = Notification.Name(rawValue: "SomethingToObserverNotification")
}
class ObservableViewController: UIViewController {
// MARK: - Init
deinit {
NotificationCenter.default.removeObserver(self)
}
// MARK: - View Life Cycle
override func viewDidLoad() {
super.viewDidLoad()
addObservers()
}
// MARK: - Add Observers
private func addObservers() {
NotificationCenter.default.addObserver(
forName: .SomethingToObserveNotification,
object: nil,
queue: .main,
completion: handleNotification
)
}
private func handleNotification(_ notification: Notification) {
// No-op but there is a leak in this controller!
}
}
我们在 viewDidLoad
中注册了一个观察者,并在 deinit
中删除自己作为观察者的身份。然而,这里有一个令人头大的代码用法:
NotificationCenter.default.addObserver(
forName: .SomethingToObserverNotification,
object: nil,
queue: .main,
using: handleNotification
)
我们正在传递一个函数作为闭包!默认情况下,这样做会捕获 self
,造成强引用。你可以回到对象图来证明这种情况。NotificationCenter
似乎保留了对闭包的强引用,而 handleNotification
函数持有了对 self
的强引用,这让 UIViewController
及其强引用持有的对象都保留在了内存中!
我们可以简单的这样修正,不要传递函数作为闭包,并将 weak self
添加到捕获列表中:
NotificationCenter.default.addObserver(
forName: .SomethingToObserveNotification,
object: nil,
queue: .main) { [weak self] notification in
self?.handleNotification(notification)
}
现在 rebuild 应用程序并重新运行该流程几次,并通过拍摄内存快照验证对象是否已被释放。
你应该可以看到,在你退出该流程后,ObservableViewController
不再在列表中了!
内存泄漏已经修复!🎉 请随意测试 LeakyApp
中的其他示例,并通读评论。我在每个文件中都添加了注释,解释每个循环引用/内存泄漏的原因。
VI. 避免循环引用的建议
-
请记住,使用函数作为闭包在默认情况下会保持强引用。如果你不得不将函数作为闭包传递,并且它导致了循环引用,你可以使用扩展或运算符重载来中断强引用。我不继续讨论这个话题,网上有很多关于这个的资源。
-
当使用具有通过闭包的操作处理程序的视图时,请注意不要在自己的闭包中引用视图!如果这样做,则必须使用捕获列表来保持对该视图的弱引用,而该视图强引用这个闭包。
例如,我们有一些像这样的可重用视图:
class SomeModalViewController: UIViewController { var actionHandler: (() -> Void)? @IBAction func onTappedAction(_ sender: Any) { actionHandler?() } }
在调用者中,我们有一些像这样的代码:
let someModalVC = SomeModalViewController() someModalVC.actionHandler = { someModalVC.dismiss(animated: true, completion: nil) } present(someModalVC, animated: true, completion: nil)
这里是一个循环引用,因为
someModalVC
的actionHandler
捕获了一个对someModalVC
的强引用。同时someModalVC
持有了对actionHandler
的强引用。这样修正:
let someModalVC = SomeModalViewController() someModalVC.actionHandler = { [weak someModalVC] in someModalVC?.dismiss(animated: true, completion: nil) } present(someModalVC, animated: true, completion: nil)
我们需要通过使用
[weak someModalVC] in
更新捕获列表以确保对someModalVC
是弱引用,来打破循环引用。 -
当你在对象上声明属性,并且您有一个协议类型的变量时,请确保添加一个类约束,并在需要时将其声明为弱约束!这是因为如果您不添加类约束,编译器将在默认情况下给您一个错误。尽管众所周知,委托模式中的委托应该是弱引用,但请记住,此规则仍然适用于其他抽象和设计模式,或你声明的任何协议变量。
例如,这里我们有一个简单的swift模式:
protocol OrdersListDisplayLogic {} protocol OrdersListBusinessLogic {} protocol OrdersListPresentationLogic {} class OrdersListViewController: OrdersListDisplayLogic { var interactor: OrdersListBusinessLogic ... } class OrdersListInteractor: OrdersListBusinessLogic { var presenter: OrdersListPresentationLogic ... } class OrdersListPresenter: OrdersListPresentationLogic { var view: OrdersListDisplayLogic ... }
// In some builder class: let view = OrdersListViewController() let interactor = OrdersListInteractor() let presenter = OrdersListPresenter() view.interactor = interactor interactor.presenter = presenter presenter.view = view
这里,我们需要
Screen-Shot-2019-05-13-at-11.09.14-PM.pngOrdersListPresenter
的view
属性必须是弱引用,否则我们将有一个来自View->Interacter->Presenter->View
的强循环引用。然而,当将该属性更新为weak var view: OrdersListDisplayLogic
时,我们将得到一个编译器错误。当将协议类型变量声明为弱变量时,这个编译器错误可能会让一些人灰心!而在这种情况下,你必须通过向协议添加类约束来解决这个问题!
protocol OrdersListDisplayLogic: class {} class OrdersListPresenter: OrdersListPresentationLogic { weak var view: OrdersListDisplayLogic? ... }
总的来说,我发现使用 Xcode 内存图调试器是一种快速简便的查找和修复循环引用和内存泄漏的方法!我希望这些信息对你有用,并在你的开发过程中经常记住这些提示!谢谢!
网友评论