内存泄漏在 iOS 中是永恒的话题,如果你在开发过程中不小心对待的话,那么总有一天他会以 Crash 的形式提醒你它的存在。内存泄漏不仅破坏用户体验,而且会影响性能甚至应用的安全。既然内存泄漏如此的重要,所以这篇文章在这篇文章将说一说 Swift 闭包中的内存泄漏问题。
Apple 在文章中详细介绍了循环强引用的概念、何为内存泄漏、如何避免。但是文章中的实例太过于简单,在真正的应用过程中情况远比这个复杂,接下来的内容就是介绍其中最为复杂的闭包中的泄露分析。
闭包中的引用
首先,我们需要清楚的理解闭包的概念:闭包是自包含的函数代码块,可以在代码中被传递和使用。简单来说:闭包是一段可执行的代码块并且它能自动捕获上下文的变量和常量,然后在需要的时候被执行。详细内容可参见地址。
我们从这个简单的实例开始:ViewController 中有一个 CustomView 类型的成员属性变量,同时 CustomView 有一个点击事件的闭包函数 onTap :
class CustomView:UIView{ var onTap:(()->Void)? ... } class ViewController:UIViewController{ let customView = CustomView() var buttonClicked = false func setupCustomView(){ var timesTapped = 0 customView.onTap = { _ in timesTapped += 1 print("button tapped \(timesTapped) times") self.buttonClicked = true } } }
在给闭包函数 onTap 赋值的语句中我们对 buttonClicked 进行了赋值,这就导致了对 self 的强引用。但是我们仔细思考后就不难发现其中的问题: self 引用了 customView 变量,然后 customView 变量的饮用了 onTap 闭包,最后 onTap 闭包引用了 self 。其结果类似下图:
上图中你能清晰的看见循环结构,这导致程序退出的时候不能正常的销毁内存导致内存泄漏的发生。
隐藏的循环
除了上面那种明显的循环引用有些闭环隐藏的更深也更隐蔽。解决这个问题的关键就是:在对闭包赋值的时候问自己谁是闭包的拥有者,然后向上溯源到根节点。
下面我们来看最常见 UITableView 中隐藏的循环(最常见的往往越容易被忽略)。一般情况下我们都是在 UIViewController 中新建 UITableView 实例少数情况下也会使用 UITableViewController ,但是不管哪种情形我们都会新建自定义的 UITableViewCell 。
下面的代码中我们新建了一个名为 CustomCell 的 UITableViewCell 子类,该类中包含了一个 UIButton实例属性以及按键点击事件的闭包属性 onButtonTap。
class CustomCell: UITableViewCell { @IBOutlet weak var customButton: UIButton! var onButtonTap:(()->Void)? @IBAction func buttonTap(){ onButtonTap?() } }
然后我们在 ViewController 对该闭包赋值:
class ViewController: UITableViewController { ... override func tableView(_ tableView: UITableView,cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "CustomCell",for: indexPath) as! CustomCell cell.onButtonTap = { _ in self.navigationController?.pushViewController(NewViewController(),animated: true) } } }
这里我们对 onButtonTap闭包进行溯源:谁拥有该闭包?毫无疑问是 CustomCell类的实例 cell。而 cell 又是属于 tableView,tableView又属于 self 所代表的UITableViewController 实例。
正如下图表现的那样,这里也有一个循环引用,只不过分析路线更长所以显得更隐蔽。
GCD 中的闭包分析
如果你以前用过 GCD 的话,那么你能一眼判断下面代码是否有循环引用。
override func viewDidLoad() { super.viewDidLoad() dispatchQueue.main.asyncAfter(deadline: .Now() + 2) { self.navigationController?.pushViewController(NewViewController()) } }
同样的使用溯源法分析闭包:拥有闭包的对象是 dispatchQueue 单例,该单例并不被 ViewController 任何属性所引用,但dispatchQueue 单例的闭包中却持有了 self。虽然我们不知道该单例的具体实现,但是我们清楚该异步闭包会在2s后被执行一次,执行完成之后该闭包就会释放对 self 的引用。所以我们由此可以断定这段闭包代码是不存在循环引用问题的。
这部分的代码逻辑和分析同样适用于 UIView 的动画闭包函数中
Alamofire 中的闭包
Alamofire 可以说是 Swift 网络处理中最常用的第三方库了,其中的请求处理中同样涉及到闭包函数。下面这段代码是请求登陆接口:
Alamofire.request("https://yourapi.com/login",method: .post,parameters: ["email":"test@gmail.com","password":"1234"]).responseJSON { (response:DataResponse<Any>) in if response.response?.statusCode == 200 { self.navigationController?.pushViewController(NewViewController(),animated: true) } else { //Show alert } }
上诉代码中的闭包又是属于哪个对象?这里我们需要深入 Alamofire 的实现中去探寻。首先 request方法会返回一个 DataRequest类型对象,而该对象的 responseJSON方法中将闭包作为参数 completionHandler传入,最后该闭包存入了 OperationQueue 类型的队列 queue 中,闭包执行完成后会自动从队列中移除。由此我们可知:闭包被 queue所持有并且一次执行后就移除了,此处不存在循环引用。
循环引用的解决
为了打破循环引用带来的内存泄漏问题,根本途径就是破坏该循环,将某个对象对另一个对象的强引用去除。在闭包环境的循环问题,我们都倾向于将闭包中的强引用去除,毕竟这简单而且看起来更直观。
为了实现该目的,我们在闭包捕获的上下文变量中做文章。我们使用关键词 weak、uNowned 来打破循环。例如上文中提到的 UITableView :
cell.onButtonTap = { [uNowned self] in self.navigationController?.pushViewController(NewViewController(),animated: true) }
上诉两个关键词存在着明显的区别 weak 是可选值而 uNowned 则一定不为可选值,换句话说 weak 关键词所指对象可能为 nil 而 uNowned 则一定不能是 nil,因此在选用的时候需要认真考虑一下。一般来说如果闭包生命周期不长于其捕获的上下文变量的生命周期我们会使用 uNowned,否则我们选择 weak 。
内存泄漏的调试
上面我们分析了大部分闭包中的循环引用问题,我们得知并不是所有的情况下都会导致内存泄漏。如果在我们使用了第三方库尤其是一些私有实现库的情况下,这部分的分析在代码层面将变的很困难并且工作量很大。好在Xcode为我们提供的调试工具,在工程运行的情况下,我们在调试区域可以找到如下图所示按键:
在 UITableView 的示例中,如果我们移除闭包中的 uNowned 或者 weak 的话,你就能在左侧看见下图
上图中的左侧感叹号表明了这里存在着内存泄漏的情况,这样你就要去查看代码了。当然你又内存泄漏但是没有感叹号标记的情况也是完全有可能的,此时你就要启用内存分析工具了并且分析内存中的对象,这些对象是否应该存在。