原文:Instruments Tutorial with Swift: Getting Started
作者:Nicholas Sakaimbo
译者:kmyhy更新说明:本教程由 Nicholas Sakaimbo 升级至 iOS 11\Xcode 9\Swift 4。原文作者是 Matt Galloway。
无论你是否开发过多少 iOS app,还是正在写你的第一个 app:毫无疑问你都设想过新功能以及怎样才能让你的 app 变得更好。
除了通过添加新功能来改进 app,还有一件事情是有好的 app 开发者都应该干的……那就是 instrument 他们的代码!
这篇 Instruments 教程会教你如何使用 Xcode 内置 Instruments 工具的最重要的功能。它能够检查出代码中的性能问题、内存问题、引用循环和其它问题。
在本教程中你将学习:
- 如何用 Time Profiler instrument 找出代码中的热点,让你的代码更加高效,以及
- 如何检查和修复内存管理问题,比如用 Allocations instrument 和 Visual Memory Debugger 解决代码中的强引用循环。
注意:本教程假设你属性 Swift 和 iOS 编程。如果你刚刚接触 iOS 编程,你可以参考本站其它教程。本教程使用故事板,请确保你熟悉该技术,你可以参考本站的这篇教程。
准备好了吗?准备进入 Instruments 的精彩世界吧!:]
开始
这是 Instruments 的教程,因此不会从头走一遍 app 创建的流程,相反,我们为你提供了一个示例项目。你的任务是查看这个 app 并在 Instruments 的帮助下改进它——就像是你在优化你自己的 app!
下载开始项目,解压缩,用 Xcode 打开。
示例 app 会调用 Flickr API 搜索图片。要使用该 API 你需要用到一个 API key。针对示例项目,你可以在 Flickr 的网站上生成一个测试 key。你可以到 http://www.flickr.com/services/api/explore/?method=flickr.photos.search 中查找,将 URL 中的 API key 拷贝出来——也就是从“&api_key=”开始到下一个“&”之间的字符串。
例如,如果 URL 是 http://api.flickr.com/services/rest/?method=flickr.photos.search&api_key=6593783 efea8e7f6dfc6b70bc03d2afb&format=rest&api_sig=f24f4e98063a9b8ecc8b522b238d5e2f
那么这个 API key 就是:6593783efea8e7f6dfc6b70bc03d2afb
将它粘贴到 FlickrAPI.swift 文件头部,替换已有的 API key。
注意,测试 API key 每天都会变,因此你有时候必须重新生成新的 key。当 key 无效时,app 会有提示。
Build & run,执行一个搜索,点击搜索结果,你会看到类似这样的结果:
用一下这个 app,浏览它的基本功能。你可能觉得 UI 看起来很好,app 已经可以提交商店了。但是,你将会看到 Instruments 的价值并在你的 app 中使用它。
接下来会教你如何查找并修复 app 中存在的问题。你会发现 Instruments 可以让调试问题变得轻松的多!
Time for Profiling
我们的第一次 instrument(分析)用的是 Time Profiler。在指定的时间间隔上,Instruments 会停止 app 执行并抽取每个线程上的栈帧记录。
可以把它看成是点击了 Xcode 调试器上的暂停按钮。
这是 Time Profiler 的样子:
上图显示了调用树。调用树显示一个 app 中各个方法执行所花销的时间。每一行都是一个不同的方法,这些方法是按照程序执行路径来罗列的。每个方法的时间花销通过 profiler 在每个方法中停止时的时间来计算。
例如,在 1 毫秒内进行 100 次抽样,而一个方法在栈顶占用了 10 次抽样,你可以推导出它大约是整个执行时间的 10% —— 10 毫秒——这是该方法的时间花销。这是一个很粗的近似值,但这是可以的!
注意:一般,你应该在真机上运行 profile,而不是在模拟器上。iOS 模拟器实际上是用你的 Mac 进行模拟的,而设备则不同,它会拥有所有移动硬件所应有的限制。你的 app 在模拟器中可能运行良好,但在真机上却可能出现性能问题。
此外,Xcode 9 beta 中使用模拟器进行 Instrument 也会导致一些问题。
好了,闲话少说,让我们来 instrument 吧!
从 Xcode 的菜单中选择 Product\Profile,或者快捷键 cmd+I。这将编译 app 并启动 Instrument。你会看到弹出一个选择窗口:
所有 Instrument 的模板都在这里。
选择 Time Profiler,点击 Choose。这会打开一个新的 Instruments 文档。点击左上角的红色的记录按钮,开始记录并打开 app。可能会询问到你的密码,用于授权 Instruments 分析其他进程——别担心,这种授权是安全的!
在 Instruments 窗口,你可以看到计时开始了,在屏幕中央的图标上面,有一个小箭头从左向右开始移动,这表示 app 正在运行。
现在,开始使用 app,搜索几张图片,进入一个或多个搜索结果查看详情。你可能会看到进入搜索结果时会超级慢,在搜索结果列表中进行滚动也是非常让人痛苦——这是一个非常迟钝的 app!
好了,你真幸运,因为你马上就会解决这个问题了!但是,首先你应该看一下 Instruments 的当前报告。
首先,确认工具栏右手边的视图选择按钮两个都处于选中状态:
这表示所有面板都会打开。现在来看一下下面的截图,解释一下每个区域:
- 录制按钮。红色的“记录”按钮用于停止/开始当前 app(它会在记录/停止之间切换)。暂停按钮就如你想的,暂停当前 app 的执行。
- 运行时间计时器。该计时器计算 app 被 profile 的时间,以及它运行了多少次。点击“停止”按钮,然后又重新启动 app,你会发现这里会显示 Run 2 of 2。
- 这被叫做一条轨迹。在当前 Time Profiler 当中,只有一次 instrument,因此只有一条轨迹。在后面,你会了解更多关于这张图中这方面的内容。
这是详情面板。它显示了这次 instrument 的主要内容。这里,它显示了”最热的” 方法列表——占用最多 cpu 时间的方法。
点击这个地方顶部的 bar 的 Profile,然后选择 Samples。你就可以在这里看到每个单独的抽样。在某个抽样上点击,就可以在右边的附加详情检查器中看到抓取到的栈帧。完成后回到 Profile。
- 这是检查器模板。有两个检查器:附加详情和运行信息。你会在后面进一步学习。
进阶
执行一次搜索,查看结果。我个人喜欢使用的是“dog”,但你喜欢用什么都可以——也许你是爱猫一族 :]
现在,上下滚动列表几次,以便你的 Time Profiler 获得更多数据。你可能看到屏幕中央的数字在改变,图形开始填充,这预示着 cpu 时间开始被使用了。
除非 table view 能够像黄油一样顺滑地滚动,否则不应该提交商店!要找出问题之所在,你需要设置几个选项。点击”停止”按钮,然后在详情面板下方点击 Call Tree 按钮。在弹出菜单中,选择 Separate by Thread、Invert Call Tree 和 Hide System Libraries。如下图所示:
下面是每个选项会对表格数据显示产生的影响:
- Separate by State: 这个选项将结果安装 app 的说明周期状态进行分组,这方便你查看 app 正在做什么以及做的时间。
- Separate by Thread: 每个线程会被单独地对待。这方便你查看哪个线程会是 cpu 占用最多的。
- Invert Call Tree: 栈帧按照时间顺序从近到远罗列。
- Hide System Libraries: 使用这开关后,只有来自于你自己的 app 的符号会被列出。这个选项通常都很有用,因为你只关心自己代码的 cpu 时间花销——对于 app 中用到的系统库的 cpu 占用时间,你根本不能做太多事情。
- Flatten Recursion: 这个选项会将递归函数(调用自身的函数)以每个栈帧一行,而不是多个栈帧一行列出。
- Top Functions: 这个选项让 Instruments 将一个函数的整个 cpu 时间看成是在该函数中用去的 cpu 时间的总合,也就是该函数调用了的所有函数所花去的时间。比如函数 A 调用了函数 B,则 A 的时间就是 A 花去的时间加上 B 花去的时间。这真的很有用,因为它允许你在追溯调用栈时获得最大的时间数,在你最耗时的方法上归零。
看一下结果,找出哪一行的 Weight 列上有最大的百分比。注意 Main Thread 一行使用了一个很大的 cpu 百分比。点击左边的小箭头,展开这行,不停向下展开知道看到你自己的方法(用一个人像图标标出)。可能某些数字略有不同,但各行数据的顺序应当类似如下所示:
好,看起来不太好。大量的时间被花在了在对缩略图应用“tonal”滤镜的方法上。这对你来说不足为奇,因为表格的加载和滚动是 UI 中最容易卡顿的部分,那时表格单元格会不断地刷新。
要找出这个方法中到底做了些什么,双击这行即可。这会打开这个界面:
太有意思了!applyTonalFilter() 方法是 UIImage 扩展中的方法,大量的时间花在了这个方法的调用上,即在应用完图片滤镜之后,创建用于输出的 CGImage 的这个方法。
这其实不太好提升性能:创建 CGImage 是在是一个资源密集的过程,要花费的时间就是这么长。让我们回去看看到底什么地方调用了 applyTonalFilter()。在代码视图的顶部,点击面包屑尾部的 Root,回到之前的界面:
现在点击顶部 applyTonalFilter 左边的小箭头。这会显示出 applyTonalFilter 的调用者。你还需要展开下一行,在 Profile Swift 时,有时候会在调用树中存在一些相同的行,前缀中包含 @objc。你感兴趣的是第一行,它以一个人像图标作为前缀,这表明它属于你 app 的 target:
这里,这一行指向的会是结果 collection view 的(_:cellForItemAt:)方法。双击这行查看相关源代码。
现在知道问题在哪儿了吧。注意第 74 行,这个方法使用了耗时的 tonal 滤镜,而且它是直接在 collectionView(_:cellForItemAt:) 方法中调用的,这样每当获取一张滤镜图片的时候就会对主线程(以及整个 UI)造成阻塞。
拆解工作
要解决这个问题,需要采取两个措施:首先,使用 DispatchQueue.global().async 方法将图像滤镜的生成拆解到后台线程中进行;然后在图像生成后对图像进行缓存。在开始项目中已经包含了一个小的、简单的图片缓存类(类名很直观就叫做 ImageCache),它直接将图片缓存在内存中,可以通过 key 索引它们。
现在你可以回到 Xcode,手动找出你在 Instruments 中看到的源代码,但有一种更简单的方法,你可以看到一个 Open in Xcode 按钮。从面板的代码上面可以找到它,点击它:
又见面了!Xcode 会自动打开并定位到正确的地方。哇哦!
现在,在 collectionView(_:cellForItemAt:) 方法中,将调用 loadThumbnail(for:completion:) 的代码修改为:
ImageCache.shared.loadThumbnail(for: flickrPhoto) { result in
switch result { case .success(let image): if cell.flickrPhoto == flickrPhoto { if flickrPhoto.isFavourite { cell.imageView.image = image } else { if let cachedImage = ImageCache.shared.image(forKey: "\(flickrPhoto.id)-filtered") { cell.imageView.image = cachedImage } else { DispatchQueue.global().async { if let filteredImage = image.applyTonalFilter() { ImageCache.shared.set(filteredImage,forKey: "\(flickrPhoto.id)-filtered") DispatchQueue.main.async { cell.imageView.image = filteredImage } } } } } } case .failure(let error): print("Error: \(error)") } }
第一段代码和之前没有区别,主要是从 web 加载 Flickr 照片缩略图。如果照片被收藏,这个 cell 照常显示缩略图。但是,如果图片没有被收藏,tonal 滤镜将被用上。
改动的地方是这里:首先,判断缓存中是否存在这张照片的滤镜应用后的图片。如果有,在 image view 中显示。如果没有,用一个 dispatch 在后台 queue 中调用 tonal 滤镜。这就使得在图片滤镜进行处理的过程中 UI 继续保持响应。当滤镜应用完之后,将效果图片保存到缓存,然后在主 queue 中更新图片。
这只是滤镜图片要考虑的地方,缩略图原图也需要进行处理。打开 Cache.swift,找到 loadThumbnail(for:completion:)。将它修改为:
func loadThumbnail(for photo: FlickrPhoto,completion: @escaping FlickrAPI.FetchImageCompletion) {
if let image = ImageCache.shared.image(forKey: photo.id) {
completion(Result.success(image))
}
else {
FlickrAPI.loadImage(for: photo,withSize: "m") { result in
if case .success(let image) = result {
ImageCache.shared.set(image,forKey: photo.id)
}
completion(result)
}
}
}
这和对滤镜图片的处理过程差不多。如果图片已经在缓存中存在,直接用缓存中的图片调用 completion 闭包。否则,从 Flickr 抓取图片并保存到缓存中。
点击 Product\Profile(或者 cmd+I),用 Instruments 重新运行 app。
注意,这会 Xcode 不会询问你要使用哪一种 instrument 了。因为你已经有一个窗口打开了这个 app,Instruments 会以为你想用同样的参数运行。
进行几个搜索,注意这次 UI 不再那么迟钝了!现在图片的滤镜处理是异步进行的,同时图片在后台进行了缓存,它们只会进行一次滤镜的处理。在调用树中你会看到大量的 dispatch_worker_threads——它们负责处理滤镜处理的重活。
看起来不错!可以打包了吗?不!
Allocations,Allocations,Allocations
接下来又是什么 Bug?
项目中有一些隐藏的问题你还不知道。听说过内存泄漏吧?但你可能不知道实际上有两种内存泄漏:
真内存泄漏——某个对象不再被任何对象引用,但它仍然占用内存——这意味着这块内存永远无法被再次使用。
哪怕 Swift 和 ARC 都能对内存进行管理,但循环引用或者强引用循环仍然是最为常见的一种内存泄漏。当两个对象相互强引用时,每个对象都会防止对方被释放。这意味着它们永远不会被释放。
内存无限增长——内存不断的被分配,永远没有机会被解除分配。如果这个过程一直继续,最终在某个时刻系统内存被占满,就会出现严重的内存问题。在 iOS 上这表明系统会杀死 app。
接下来要接触的 instrument 技术是 Allocations。它向你提供所有对象创建和对象内存的信息;还会显示每个对象的 retain 次数。
退出 Instruments app,重新打开一个新的 instruments profile,不需要保存这个特殊的执行。现在按 cmd+I,选择 Allocations instrument,然后点击 Choose。
你就会看到 Allocations instrument 了。你会感到它有点像 Time Profiler。
点击录制按钮,运行 app。这次你将看到两个轨迹。出于本教程的目的,你将关注的是第一个即 All Heap and Anonymous VM。
在对 app 进行 Allocations instrument 时,在 app 中执行 5 次不同的搜索,但不要进入到结果页面。确保每次都会有不同的结果集。现在,让 app 歇一会,等上几秒钟。
注意观察在 All Heap and Anonymous VM 轨迹上的图形升高了。这是告诉你内存正在被分配。这个功能能让你找出无限内存增长问题。
现在你需要进行一个 “世代分析”(Generation Analysis)。点击一个 Mark Generation (世代标记)按钮。它就在详情面板的底部。
点击这颗按钮,你会看到在这个轨迹上有红色小旗子出现:
Generation 分析的目的是执行同一动作多次,查看内存是否在无限增长。进入精细搜索,等图片加载几秒,然后回到主界面。然后再次点击世代标记(Mark Generation)按钮。重复这个动作进行不同的搜索。
进行几次精细查找之后,Intruments 会变成这个样子:
这里,你可能会有点疑惑。注意,每当你进行精细搜索时蓝色的图表会上涨。呵,这不是个好现象。但别急,内存警告是怎么回事?你当然知道这个东东。内存警告是 iOS 告诉 app 内存单元趋于紧张的一种方法,你必须要清理内存了。
很可能内存的增长并不是你的 app 导致的,它也可能是 UIKit 底层的某些东西吃掉了内存。让系统框架和你的 app 先有一个机会清理它们的内存,在它们互相指责对方之前。
可以用 Instruments 菜单中的 Instrument\Simulate Memory Warning 来模拟内存警告,或者用模拟器菜单中的 Hardware\Simulate Memeory Warning。你会注意内存占用会稍微下降了一点,或者一点也没有减少。内存用量当然不会回到原来的时候。因此肯定有什么地方出现了内存无限增长问题。
Instruments:说说我这一代
之所以要在每次精细搜索之后标记一“代”(generation),是因为你可以看到每代中分配了多少内存。看一眼详情面板,你会看到有许多“代”。
在每一代中,你会看到所有已分配的对象会一直驻留在所标记的这一“代”中。后面的“代”会包含这些上一代的对象。
查看 Growth 列,你会看到无限内存增长是哪里出现的。打开一代,你会看到:
呃,有这么多东西?从那个对象开始呢?
很简单,点击 Growth 头,按照大小排序,确保最大的对象位于前面。就在每一代的顶部,你会看到写有 ImageIO_jpeg_Data 的那行,这好像是你的 app 中的东东。点击 ImageIO_jpeg_Data 左边的箭头,显示相关的内存地址。选择第一个内存地址,会在右边的附加详情检查器面板中显示与之对应的栈帧:
这个栈帧显示某个对象是在什么地方创建的。灰色的栈帧是系统库的;黑色部分是你 app 的代码。呃,有的地方看起来很熟悉样子:有几个黑色的表格行提到了你的老朋友 collectionView(_:cellForItemAt:)。双击这些列表行,Instruments 会根据相关上下文中打开源代码。
来看一下这个方法,你会看到它在第 81 行调用了 set(_:forKey:) 方法。记住,这个方法缓存了那些后面会用到的图片。哈!好像发现问题在哪了。
再次点击 Open in Xcode 按钮跳到 Xcode。打开 Cache.swift,看看 set(_:forKey:) 的实现。
func set(_ image: UIImage,forKey key: String) { images[key] = image }
这会添加一张图片到字典中,key 就是 Flickr 照片的照片 ID。但如果你看过代码,你会发现字典中的图片永远不会被清除!
这就是内存无限增长的原因了:什么问题都没有,就是 app 不会清除缓存——它只会添加缓存!
要解决这个问题,你只需要让 ImageCache 监听由 UIApplication 发出的内存警告通知。当 ImageCache 收到这个通知,它会规规矩矩地清除缓存。
要让 ImageCache 监听这个通知,请打开 Cache.swift,为这个类添加初始化方法和反初始化方法:
init() {
NotificationCenter.default.addObserver(forName: Notification.Name.UIApplicationDidReceiveMemoryWarning,object: nil,queue: .main) { [weak self] notification in
self?.images.removeAll(keepingCapacity: false)
}
}
deinit {
NotificationCenter.default.removeObserver(self)
}
这里注册了 UIApplicationDidReceiveMemoryWarningNotification 观察者,用于执行上面的那段闭包,将图片从缓存中删除。
闭包中的代码仅仅是移除了缓存中的所有对象,这会让 images 中什么也不剩下,同时它们将会被释放。
要测试这段代码,再次打开 Instruments(在 Xcode 中按 cmd+I),并重复之前的步骤。别忘了在最后模拟一个内存警告。
注意:确保你是从 Xcode 中启动,执行一次编译,而不是点击 Instruments 中的红色按钮。这样能确保你使用的是最新代码。你也可以在 Profiling 之前 Build & Run,因为有时候仅仅是 Profile 的话 Xcode 不会更新模拟器中的 app 的 build。
这次的世代分析应该是这个样子了:
你会看到在内存警告之后内存用量会降低。内存涨幅仍然会有一些,但已经之前相比差得很多了。
仍然会有一点内存涨幅的原因是系统库,你对此表示无能为力。显然系统库没有释放所有的内存,这可能是故意的,也可能是一个 Bug。所以你只能在你的 app 中尽可能多地释放内存,就像你所做的一样!
干得不错!有一个问题解决了!现在来打包吧!哦,稍等——还有另一种内存泄漏问题没有解决(第一种)。
强引用循环
前面提过,当两个对象彼此强引用对方时会导致强引用循环,导致内存无法被释放。你可以采用另外的一种不同的方式提过 Allocations instrument 来检查出引用循环。
关闭 Instruments 回到 Xcode。再次点击 Product\Profile,选择 Allocations 模板。
这次不使用世代分析。这次,你将看到内存中有多少不同类型的交缠在一起的对象。点击录制按钮开始运行。你会看到在详情面板中有大量的对象——多的看不过来!要将这些对象缩减到我们的目标对象,在左下角的文本框中输入 Instruments 作为过滤词。
在 Instruments 中有两列值得注意:# Persistent 和 # Transient。前者记录了当前内存中每种类型的对象数。后者显示曾经存在但已经被释放的对象数。Persistent 对象是正在使用内存的,Transient 对象是已经被释放的。
你应该看到这里有一个 ViewController 的 persisent 对象——这是对的,因为它就是你当前正在看的屏幕。此外还有一个 app 的 AppDelegate 实例。
回到 app !执行一次搜索并进入精确的结果中。注意在 Instruments 中多出了一堆对象显示:SearchResultsViewController 和 ImageCache。ViewController 对象仍然是 persistent 的,因为它是 navigation controller 要用的。这没问题。
现在点击 app 的返回按钮。SearchResultsViewController 现在从导航栈中弹出,它应当被释放。但它仍然有一个 # Persistent 数为 1 的记录在 Allocations Summary 中!怎么回事?
在操作两次搜索并在每次搜索后点击返回按钮。出现了 3 个 SearchResultsViewController?! 这些 view controller 都在内存中,说明有什么东西保持了一个对它们的强引用。你制造了一个强引用循环!
这种情况不仅仅存在于 SearchResultsViewController,也存在于 SearchResultsCollectionViewCell。很可能是这两个类之间出现了引用循环。
值得庆幸的是,在 Xcode 8 以后引入了可视化内存调试器,这是一个很好的工具,能够帮助你进一步诊断内存泄漏和引用循环。可视化内存调试器不属于 Xcode Instrument 套件的一部分,但仍然是一个很好用的工具,值得在本教程中介绍。交叉使用 Allocations instrument 和可视化内存调试器能让你的调试工作更加高效。
“看见”内存
退出 Allocations instrument 和 Instruments 套件。
在启动可视化内存调试器之前,先在 Xcode 的 scheme 编辑器中打开 Malloc Stack logging:在窗口左上角点击 Instruments Tutorial scheme(在停止按钮的右边),选择 Edit Scheme。在弹出界面中,点击 Run 一栏,切换到 Diagnostics 标签页。勾选 Malloc Stack,并选择 Live Allocations Only,然后点击关闭。
直接从 Xcode 中打开 app。和之前一样,操作 3 次以上的搜索获得一些数据。
然后用这种方式激活可视化内存调试器:
- 切换到 Debug 导航器。
- 点击这个图标,选择弹出菜单中的 View Memory Graph Hierachy。
- 点击列表中 SearchResultsCollectionViewCell 这行。
- 点击图中的某个对象,然后在检查器面板中查看细节。
- 可以从这个地方查看细节。这是 Memory 检查器面板。
可视化内存调试器会暂停你的 app,显示内存对象中的可视化形式,以及它们之间的引用情况。
在上图的加亮部分,可视化内存调试器显示了下列信息:
- 堆信息 (Debug 导航器面板): 列出所有 app 暂停瞬间内存中分配了的类型和对象的列表。点击类型,可以展开这个类型的所有单个实例。
- 内存图(主窗口):显示对象在内存中的可视化表示。两个对象之间的箭头表示它们的引用关系(强弱引用关系)
- 内存检查器(工具面板):包含一些细节,比如类名和继承,以及引用是否是强引用还是弱引用。
注意在 Debug 导航器中有些行会在一对括号中标注一个数字。这个数字表示这种类型的实例在内存中有多少个。在上图中,你会看到进行几次搜索后,可视化内存调试器会中会看到和在 Allocations instrument 中一样的结果,比如每个 SearchResultsViewController 对象会在内存中产生 20-60 个(如果你滚动到搜索结果的末尾)SearchResultsCollectionViewCell 内存对象。
通过每行左边的箭头,可以展开这个类型,显示出内存中的每个 SearchResultsViewController 对象。点击每个对象可以在主窗口中显示出这个对象及其引用。
注意这些指向了 SearchResultsViewConroller 对象的箭头。好像有几个 Swift 闭包上下文对象引用了同一个 view controller 对象。不敢相信,是吗?来细看一下。选中其中一个箭头,工具面板中查看关于其中一个闭包和 SearchResultsViewController 之间的引用信息。
在这个内存检查器中,你可以看到这个 Swift 闭包上下文和这个 SearchResultsViewController 之间的引用是强引用。如果你选择了 SearchResultsCollectionViewCell 和 Swift 闭包上下文之间的引用,你会看到仍然是强引用。你还会看到这个闭包的名字是 heartToggleHandler。哈,它是在 SearchResultsCollectionViewCell 类中定义的嘛!
在主窗口中选择 SearchResultsCollectionViewCell 对象,以便在检查器面板中显示更多细节。
在调用栈中,你会看到这个 cell 是在 collectionView(_:cellForItemAt:) 方法中实例化的。当你将鼠标放到栈帧中的这一行时,会出现一个小箭头。点击这个小箭头,将会跳转到 Xcode 编辑器中的这个方法上。太棒了!
在 collectionView(_:cellForItemAt:) 方法中,找到设置 cell 的 heartToggleHandler 属性的地方。你会看到:
cell.heartToggleHandler = { isStarred in
self.collectionView.reloadItems(at: [indexPath])
}
当 cell 上的心形按钮被点击时,这个闭包会被调用。这里出现了一个强引用循环,但它很难被发现,除非你以前碰到过。但通过可视化内存调试器,你能够沿着蛛丝马迹找到这段代码!
在这个闭包中,cell 使用了 self 来引用了 SearchResultsViewController,因此会创建一个强引用。这个闭包会捕获 self。Swift 其实强迫你在闭包中使用 self 一词(反之,如果你引用当前对象的属性和方法时,你通常省略 self)。这会让你更容易意识到你正在捕获它。SearchResultsViewController 也通过 collectionView 对 cell 有一个强引用。
要打断强引用循环,你需要在闭包的定义中指定一个捕获列表。所谓捕获列表,允许你声明闭包需要捕获的对象,是以(Weak)还是(unowned)来捕获这些对象:
- Weak:当所捕获的引用在未来允许变成 nil 时,可以用 weak。如果它所引用的对象被释放,这个引用会变成 nil。也就是说,它们是可空类型。
- Unowned:当闭包和它引用的这个对象总是拥有相同的生命周期时,以及在同时释放时,应当使用 unowned 引用。一个 unowned 引用永远不会变成 nil。
要解决这个强引用循环问题,需要为 heartToggleHandler 添加一个捕获列表:
cell.heartToggleHandler = { [weak self] isStarred in
self?.collectionView.reloadItems(at: [indexPath])
}
将 self 声明为 weak,表明 SearchResultsViewController 会在 collection view cell 仍然引用它的情况下被释放,因为它们之间现在是弱引用关系了。销毁 SearchResultsViewController 就会销毁它的 collection view 及其 cell。
在 Xcode 中,用 cmd+I 再次编译并用 Instruments 来运行 app。
再次像之前一样,用 Allocations instrument 测试 app(记得过滤结果,只显示 starter 项目中的类)。操作一次搜索,进入结果页,然后返回。你会看到 SearchResultsViewController 和它的 cell 现在会在返回时 deallocate 了。它们显示为 transient 对象而不是 persistent 对象。
循环被打断了,还是打包吧!
接下来做什么
从这里下载最后优化过的项目代码,感谢 Instruments。
现在你已经牢牢掌握了本教程中的知识,去 instrument 你自己的代码看看会发生什么有趣的事情!同时,努力将 Instruments 当做你日常开发工作中的一部分。
你应当经常用 Instruments 来运行你的代码,在发布之前执行一个全面的扫描,确保你尽可能解决了内存问题和性能问题。
现在,去编写更酷——同时性能更高的 app 吧!