建议看原文,因为翻译带有个人的理解。
学习如何使用和Xcode的Intruments工具排除和优化您的代码!
无论你是已经做了很多个iOS apps还是刚开始接触:毫无疑问,你会想出新的功能,或者想知道你能做些什么来让你的应用程序变得更好。除了通过添加功能来改进你的应用程序之外,还有一件事是所有优秀的应用开发者都应该做的…优化(instrument)他们的代码!
这(instruments)优化之旅将会告诉你怎么使用XCode自带的一个叫做Instruments的工具的最重要的属性,它允许您检查代码的性能问题、内存问题、循环引用和其他问题。
在这教程中你将学习到:
- 如何使用时间分析器工具(Time Profiler)来确定代码中的热点,从而提高代码的效率
- 如何使用分配工具(Allocations)和可视化内存调试器(Visual Memory Debugger)来检测和修复内存管理问题,比如代码中的循环引用。
Note:本教程假设您熟悉Swift和iOS编程。如果你是iOS编程的新手,你可能想看看这个网站上的其他教程。本教程使用了一个storyboard,所以要确保您熟悉这个概念;storyboard。
都准备好了吗?准备好进入迷人的instruments世界吧!:]
###开始
对于本工具教程,您不会从头开始创建应用程序;相反,已经为您提供了一个示例项目。你的任务是通过应用程序来改进它,使用工具作为你的指南——非常类似于你如何优化你自己的应用程序!
下载开始项目,然后解压并用Xcode打开它。
这个简单的app使用了Flickr API来搜索图片。要使用这个API你需要一个API key。对于演示项目,您可以在Flickr的网站上生成一个示例key。只在http://www.flickr.com/services/api/explore/?method=flickr.photos.search执行行任何搜索并从URL中把API key复制出来,把它粘贴到FlickrAPI.swift
文件的顶部替代掉已存在的API key。
请注意,这个示例API key每天都更改,因此您可能需要重新生成一个新的key。当key不再有效的时候,应用程序会提醒你。
构建并运行应用程序,执行搜索,单击结果,您将看到如下内容:
浏览应用程序并查看基本功能。你可能会想,一旦UI看起来很好,应用程序就可以存储提交了。但是,您将看到使用Instruments可以添加到应用程序中的值。
本教程接下来将向您展示如何查找和修复应用程序中仍然存在的问题。您将会看到Instruments是如何使调试问题变得更加容易!:]
Time for Profiling(时间分析)
你要看的第一个Instrument是Time Profiler(时间分析器)。在测量的间隔内,Instruments将停止程序的执行,并在每个运行的线程上执行堆栈跟踪。可以把它看作是在Xcode的调试器中单击pause按钮。
以下是对Time Profiler的预览:
这个屏幕显示调用树(Call Tree)。调用树(Call Tree)显示了在应用程序中在各种方法中执行的时间量,每一行都是程序执行路径所遵循的不同方法。每个方法中花费的时间可以从每个方法中停止分析器的次数来确定。
例如,如果100个样本在1毫秒的间隔内完成,并且一个特定的方法被发现在10个样本的堆栈顶部,那么你就可以推断,在这个方法中花费的总执行时间的大约10%——10毫秒。这是一个相当粗糙的近似,但它确实有效!
Note:一般来说,你应该在真机上配置你的应用程序,而不是模拟器。iOS模拟器拥有你的Mac电脑的所有马力,而一个真机将拥有移动硬件的所有限制。你的应用程序可能在模拟器上运行得很好,但是当它运行在一个真机上时,可能会出现一个性能问题。
所以,没有任何的麻烦,时间开始instrumenting
在Xcode的菜单栏中,选择Product\Profile或者点击快捷键command + I
.
这将建立应用程序和发射仪器。您将得到一个选择窗口,看起来像这样:
这是Instruments上带的所有不同模板
选择Time Profiler工具并单击Choose。这将打开一份新的文书文件。点击左上角的红色记录按钮,开始录制和启动app。你可能会被要求你的密码授权工具来分析其他过程——不要害怕,在这里提供是安全的!:]
在仪器窗口中,你可以看到时间在计算,一个小箭头从左到右移动,在屏幕中央的图形上方。这表明应用程序正在运行。
现在,开始使用app,搜索一些图片,并深入到一个或多个搜索结果中。你可能已经注意到,进入搜索结果的速度非常慢,而且滚动搜索结果列表也令人难以置信——这是一个非常笨拙的应用程序!
好吧,你很幸运,因为你准备开始修理它了!然而,首先你很快就会对在Instruments上看到的东西感到失望。
首先,确保工具栏右边的视图两个选择器处于选中状态,像这样:
这将确保所有的面板都是打开的。现在研究下面的截图,并解释下面的每个部分:
- 这些是记录控制。红色的“记录”按钮将会停止并启动应用程序,当它被点击时(它在一个记录和停止图标之间切换)。暂停按钮完全按照你的预期执行,并暂停应用程序的当前执行。
- 这是运行计时器。计时器计算了app被剖析运行了多长时间,以及它运行了多少次。点击停止按钮,然后重新启动应用程序,你将看到现在显示的显示Run 2 of 2
- 这叫做轨道。在你选择的时间分析器(Time Profile)模板的情况下,只有一个工具,所以只有一条路径。在本教程的后面,您将了解有关图的细节的更多信息。
- 这是细节面板。它显示了您正在使用的特定instrument的主要信息。在这种情况下,它显示的方法是“最热的”——也就是说,使用最多CPU时间的方法。
单击该区域顶部的bar上的文字Profile,并选择Samples(示例)。这里你可以看到每一个样本。单击一些示例,您将看到捕获的堆栈跟踪出现在右侧的Extended Detail检查器中。完成后切换Profile。 - 这是检查器面板。有两个检查器:扩展细节和运行信息。你很快就会学到更多关于这些选项的知识。
深入研究
执行图像搜索,并深入研究结果。我个人喜欢搜索“猫”,但选择你想要的——你可能是其中的一只猫!:]
现在,在列表中上下滚动几次,这样就可以在时间分析器(Time Profile)中获得大量的数据。你应该注意到屏幕中间的数字在变化,图形填入;这告诉您CPU周期正在被使用。
没有 table view是准备发送,直到它像黄油一样滚动。
为了帮助查明问题,您将设置一些选项。点击停止按钮,在细节面板下方,点击 Call Tree 按钮。在弹出的窗口中,选择Separate by Thread(独立的线程),Invert Call Tree(倒转调用树)和Hide System Libraries(隐藏系统库)。它看起来是这样的:
下面是每个选项对在左表中显示的数据所做的工作:
- Separate by State:这个选项组是由应用程序的生命周期状态所产生的,这是一种检查应用程序做了多少工作和时间的有用方法。
- Separate by Thread:每个线程都应该单独考虑。这有助于您了解哪个线程占用了最大的CPU使用量。
- Invert Call Tree:有了这个选项,从上倒下跟踪堆栈,这意味着你看到的表中的方法,将已从第0帧开始取样,这通常你是想要的,只有这样你才能看到CPU中话费时间最深的方法.也就是说FuncA{FunB{FunC}} 勾选此项后堆栈以C->B-A 把调用层级最深的C显示在最外面
- Hide System Libraries:当选择此选项时,只显示来自您自己应用程序的符号。选择此选项通常很有用,因为通常您只关心CPU在您自己的代码中的花费时间——您不能在系统库使用多少CPU的情况下做很多事情!
- Flatten Recursion:此选项将递归函数(调用自己的函数)作为每个堆栈跟踪中的一个条目,而不是多个。
- Top Functions:启用这一功能,使得工具可以将在函数中使用的总时间看作是函数内的时间之和,包括了函数调用的函数的时间。如果函数A调用B,那么函数A的时间是函数A花费的时间 + 函数B所花费的时间 .这非常有用,因为它可以让你每次下到调用堆栈时挑最大的时间数字,归零在你最耗时的方法。
查看结果以确定哪些行在权重列中百分比最高。注意到主线程的行占用了很大一部分CPU周期。通过单击文本左边的小箭头展开这一行,然后向下钻取,直到看到您自己的方法(以“person”符号标记)。虽然有些值可能略有不同,但条目的顺序应该类似于下面的表:
当然,这看起来不太好。绝大多数时间都花在将“Tonal”滤镜应用于缩略图的方法上。这对您来说不应该太过震惊,因为表加载和滚动是UI中最笨拙的部分,而这正是表单元不断更新的时候。
要了解该方法中发生的情况,请双击表中的这一行,将会看到下图:
这非常有趣,难道不是吗?applyTonalFilter()
是个加在UIImage扩展上的方法,而且在应用这个滤镜后,大量时间用在调用创建 CGImage output 的方法上了
要加快这一速度并没有什么可做的:创造图像是一个非常密集的过程,而且要花很长时间。让我们尝试后退一步,看看调用applyTonalFilter()
的位置。点击Root,回到上一个屏幕:
现在点击表格顶部的applyTonalFilter行左边的小箭头。这将显示applyTonalFilter的调用者。你可能需要展开下一行;当对Swift进行分析时,在调用树中有时会有重复的行,前缀为@ objc。你感兴趣的第一行是“person”符号,这表明它属于你的app的目标:
现在,这一行指向了results collection view’s (_:cellForItemAt:)
,双击该行以查看项目中的相关代码。
现在你可以看到问题了。看一看74行;应用”tonal”滤镜的方法需要很长时间才能执行,它直接从collectionView(_:cellForItemAt:)调用,它会在每次请求过滤图像时阻塞主线程(以及整个UI)。
卸货工作
要解决这个问题,你讲需要执行两步:首先,将图片加载滤镜卸载到带有DispatchQueue.global().async
的后台线程上;然后在生成后缓存每个图像。在starter项目中,有一个小而简单的图片缓存类(带有catchy的名称ImageCache),它只是将图片存储在内存中,然后用给定的键检索它们。
现在你可以切换到Xcode,手动找到你正在查看的文件的源文件,在代码上方的面板中右上角可以找到方便打开Xcode的按钮,单击它:
开始吧!Xcode在正确的位置打开。Boom!
现在,在collectionView(_:cellForItemAt:)
中,将调用loadThumbnail(for:completion:)
替换为如下代码:
|
|
这会添加一张图片到字典中,key 就是 Flickr 照片的照片 ID。但如果你看过代码,你会发现字典中的图片永远不会被清除!
这就是内存无限增长的原因了:什么问题都没有,就是 app 不会清除缓存——它只会添加缓存!
要解决这个问题,你只需要让 ImageCache 监听由 UIApplication 发出的内存警告通知。当 ImageCache 收到这个通知,它会规规矩矩地清除缓存。
要让 ImageCache 监听这个通知,请打开 Cache.swift,为这个类添加初始化方法和反初始化方法:
|
|
这里注册了 UIApplicationDidReceiveMemoryWarningNotification 观察者,用于执行上面的那段闭包,将图片从缓存中删除。
闭包中的代码仅仅是移除了缓存中的所有对象,这会让 images 中什么也不剩下,同时它们将会被释放。
要测试这段代码,再次打开 Instruments(在 Xcode 中按 cmd+I),并重复之前的步骤。别忘了在最后模拟一个内存警告
Note:确保你是从 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 上的心形按钮被点击时,这个闭包会被调用。这里出现了一个强引用循环,但它很难被发现,除非你以前碰到过。但通过可视化内存调试器,你能够沿着蛛丝马迹找到这段代码!
在这个闭包中,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 吧!