如何通过单元测试来发现并避免 Swift 中的内存泄露?

发表于:2017-12-01 14:03

字体: | 上一篇 | 下一篇 | 我要投稿

 作者:Mr.9.9    来源:独立开发日记

  内存管理和避免内存泄露对于任何程序而言都是至关重要的一部分。好在 Swift 在这部分做的在大多数情况下都相对的比较简单,多亏了自动引用计数(ARC)。然而,还是有几种情况会非常容易导致内存泄露的发生的。
  在 Swift 中内存泄露通常就是循环引用 (retain cycle,居然还有翻译成「保留环」的……)的结果,也就是两个(或者多个)对象互相保持了对方的一个强引用。这种情况通常很难被定位到,造成的崩溃也不容易重现。
  我们来看看如何通过单元测试既可以发现内存泄露,又能够轻而易举地在将来避免导致泄露错误的发生。
  代理(Delegates)
  代理模式在苹果的开发平台中非常常见,也是一个非常漂亮的模式——简单而且可以让两个对象拥有低耦合关系的同时还可以互相通信。
  假定 app 中有一个视图控制器来显示用户所有朋友的列表,为了让视图控制器能够将事件发回给它的拥有者(owner),我们需要添加一个类似于这样的代理 API:
  class
  FriendListViewController
  :
  UIViewController
  {
  var
  delegate
  :
  FriendListViewControllerDelegate
  }
  乍一看上去,代码好像没什么问题啊,但是只要你再仔细看一下的话你就会发现问题在哪里了,没错,代理是个强引用。这很有可能会导致循环引用,因为 FriendListViewController 实例的拥有者也有可能就是代理,这就造成了两个对象互相强引用。
  为了保证不会再犯这种错误,我们可以搭建一个单元测试来确保 FriendListViewController 对其代理不是强引用:
  class
  FriendListViewControllerTests
  :
  XCTestCase
  {
  func testDelegateNotRetained
  ()
  {
  let
  controller
  =
  FriendListViewController
  ()
  // 创建一个代理的强引用,将其赋给视图控制器
  var
  delegate
  =
  FriendListViewControllerDelegateMock
  ()
  controller
  .
  delegate
  =
  delegate
  // 将这个强引用重新分配给一个新的对象,这样就应该会释放原对象,
  // 因此视图控制器的代理也应该为 nil
  delegate
  =
  FriendListViewControllerDelegateMock
  ()
  XCTAssertNil
  (
  controller
  .
  delegate
  )
  }
  }
  测试第一次运行会失败,但并不是坏事,因为这个测试之后会保证我们确实修复了问题。针对的修复也很简单,只需要把代理声明为 weak就可以了:
  class
  FriendListViewController
  :
  UIViewController
  {
  weak
  var
  delegate
  :
  FriendListViewControllerDelegate
  ?
  }
  这时我们再次运行测试的话,测试就会通过了。我们不仅修复了 app 中的一处内存泄露,同时还保证了这个 bug 在将来也不会再发生了(这对于重构的工作来说超级有用)。
  还有一个方法就是使用 SwiftLint,这个工具会在这种情况下警告你没有把代理的属性设为 weak 。(其实不止也一点用途了,lint 类工具能做的事情非常多)
  观察者(Observers)
  观察者模式也是一种常见的模式,通常用于让一个对象给其他对象发送各种通知事件。和代理一样,我们也不想拥有观察者的强引用,因为这些观察者对象通常会保持所观察对象的强引用。
  假定有一个 UserManager,在数组里存着所有观察者的引用:
  class
  UserManager
  {
  private
  var
  observers
  =
  [
  UserManagerObserver
  ]()
  func addObserver
  (
  _ observer
  :
  UserManagerObserver
  )
  {
  observers
  .
  append
  (
  observer
  )
  }
  }
  就像实现代理模式那样,代码特别容易造成观察者对象的强引用,也就有可能造成内存泄露。
  好在这个问题在测试用例中可以简单地被重现:
  class
  UserManagerTests
  :
  XCTestCase
  {
  func testObserversNotRetained
  ()
  {
  let
  manager
  =
  UserManager
  ()
  // 分别创建观察者的一个强引用和弱引用
  // 将强引用添加到 UserManager 中
  var
  observer
  =
  UserManagerObserverMock
  ()
  weak
  var
  weakObserver
  =
  observer
  manager
  .
  addObserver
  (
  observer
  )
  // 如果给强引用重新分配一个新的对象,期望的结果是弱引用变为 nil
  // 因为观察者数组不应该保存观察者的强引用
  observer
  =
  UserManagerObserverMock
  ()
  XCTAssertNil
  (
  weakObserver
  )
  }
  }
  同样,这个测试第一次会失败,这也是件好事,因为我们可以重现这个问题。为了修复这个问题,我们需要一个小的 wrapper 来保存观察者的弱引用(因为数组总是保存元素的强引用):
  private
  extension
  UserManager
  {
  struct
  ObserverWrapper
  {
  weak
  var
  observer
  :
  UserManagerObserver
  ?
  }
  }
  接着,对 UserManager 稍作修改,将观察者的数组变成 wrapper 的数组,这就可以通过测试了:
  class
  UserManager
  {
  private
  var
  observers
  =
  [
  ObserverWrapper
  ]()
  func addObserver
  (
  _ observer
  :
  UserManagerObserver
  )
  {
  let
  wrapper
  =
  ObserverWrapper
  (
  observer
  :
  observer
  )
  observers
  .
  append
  (
  wrapper
  )
  }
  }
  这种写法在许多需要在集合中存储弱引用对象的情况下特别有用。需要特别留心的是清理那些观察者被释放的 wrapper。一个比较好的做法是遍历后过滤出那些不再需要的 wrapper,比如在发送通知事件的时候:
  private
  func notifyObserversOfUserChange
  ()
  {
  observers
  =
  observers
  .
  filter
  {
  wrapper
  in
  guard
  let
  observer
  =
  wrapper
  .
  observer
  else
  {
  return
  false
  }
  observer
  .
  userManager
  (
  self
  ,
  userDidChange
  :
  user
  )
  return
  true
  }
  }
  闭包(Closures)
  最后,我们看一下如何在基于闭包的 API 中发现并阻止内存泄露。闭包是许多与内存相关的 bug 和泄露的常见源头,因为闭包对内部的所有对象都保持着强引用。
  假定有一个 ImageLoader ,功能是通过网络加载远程的图片,然后在加载完毕后执行一个 completion handler:
  class
  ImageLoader
  {
  func loadImage
  (
  from
  url
  :
  URL
  ,
  completionHandler
  :
  @escaping
  (
  UIImage
  )
  ->
  Void
  )
  {
  ...
  }
  }
  代码中的一个常见的错误就是在操作结束后仍然保持 completion handler 的引用。可能为了能够取消或者批处理的操作需要,我们希望在某种集合中存储这些 completion handler,然后就忘了移除,最终导致内存泄露。
  那么应该如何通过单元测试来确保 completion handler 能够在执行完被立即移除呢?上面两种情况我们都是通过使用弱引用来达到目的的,但是弱引用不能用在闭包上啊??(多说一句,函数也不可以,Swift 中只有类的实例可以使用弱引用)。
  我们采用的做法是通过对象捕获(capturing)来和一个带有闭包的对象关联,然后用这个对象来验证闭包是否被释放:
  class
  ImageLoaderTests
  :
  XCTestCase
  {
  func testCompletionHandlersRemoved
  ()
  {
  // 用一个模拟的 network manager 来搭建一个 image loader
  let
  networkManager
  =
  NetworkManagerMock
  ()
  let
  loader
  =
  ImageLoader
  (
  networkManager
  :
  networkManager
  )
  // 根据给定的 URL 来模拟一个响应
  let
  url
  =
  URL
  (
  fileURLWithPath
  :
  "image"
  )
  let
  data
  =
  UIImagePNGRepresentation
  (
  UIImage
  ())
  let
  response
  =
  networkManager
  .
  mockResponse
  (
  for
  :
  url
  ,
  with
  :
  data
  )
  // 创建一个对象(任何类型都可以),然后保持对其的强引用和弱引用
  var
  object
  =
  NSObject
  ()
  weak
  var
  weakObject
  =
  object
  loader
  .
  loadImage
  (
  from
  :
  url
  )
  {
  [
  object
  ]
  image
  in
  // 在闭包中捕获这个对象
  _
  =
  object
  }
  // 发送响应,这就会让上面的闭包执行,然后移除并释放
  response
  .
  send
  ()
  // 然后给对象的强引用重新分配一个新的对象,这样弱引用应该变为 nil,
  // 因为这时候闭包应该已经执行完并移除了
  object
  =
  NSObject
  ()
  XCTAssertNil
  (
  weakObject
  )
  }
  }
  这下我们就可以保证 image loader 不会再保持久的闭包了,而且我们有干掉了另一个内存泄露的隐患。
  结论
  这么使用单元测试可能一开始看起来有些大材小用,但是这确实是一个利器,特别是在你想要重现内存泄露或者在将来可能会出现的问题上多加一层保护的时候。
  尽管上面的测试不一定会完全避免所有的内存泄露,但是却能够在无形中节省大量查找问题根源的时间。
《2023软件测试行业现状调查报告》独家发布~

关注51Testing

联系我们

快捷面板 站点地图 联系我们 广告服务 关于我们 站长统计 发展历程

法律顾问:上海兰迪律师事务所 项棋律师
版权所有 上海博为峰软件技术股份有限公司 Copyright©51testing.com 2003-2024
投诉及意见反馈:webmaster@51testing.com; 业务联系:service@51testing.com 021-64471599-8017

沪ICP备05003035号

沪公网安备 31010102002173号