你好 我叫Sasha,我是制作VKontakte供稿的团队中的iOS开发人员。 现在,我将告诉您如何优化界面显示并解决与此相关的问题。
我想您可以想象什么是VK磁带。 在此屏幕上,您可以查看各种内容:文本,静态图片,GIF动画,嵌入式元素(视频和音乐)。 所有这些都应平稳显示,因此对解决方案的性能提出了很高的要求。
现在,让我们看看存在哪些使用映射的标准方法以及应该考虑哪些限制或优点。
如果您喜欢听多于阅读,请在此处查看报告的录像。

目录内容
- 布局描述和计算
1.1。 自动布局
1.2。 手动frame
计算 - 文字大小计算
2.1。 计算UILabel
/ UITextView
/ UITextField
大小的标准方法
2.2。 NSAttributedString
/ NSString
方法
2.3。 文字套件
2.4。 核心文字 - VKontakte提要如何工作?
- 如何获得更好的性能
4.1为什么出现性能问题
4.2。 CATransaction.commit
4.3。 渲染管线
4.4。 最脆弱的表演场所 - 测量工具
5.1。 金属系统痕迹
5.2。 我们在应用程序运行时修复代码中的性能下降
1.布局的描述和计算
首先,让我们回想一下如何使用常规工具创建可视界面结构( 布局 )。 为了节省空间,我们将不列出任何内容-我将仅列出解决方案并说明其功能。
1.1。 自动布局
在iOS中创建界面的最流行的方法也许是使用Apple的自动版式布局系统。 它基于 Cassowary算法,与约束概念密不可分。
现在,请记住,使用自动布局实现的界面是基于限制的。
该方法的特点:
使用自动版式的好处:
- 在简单映射上,线性计算复杂性是可能的 。
- 它是Apple的“本机”技术,它与所有标准元素都相处得很好。
- 开箱即用可与
UIView
。 - 在Interface Builder中可用,它允许您在情节提要或XIB中描述布局。
- 即使在过渡期间,它也保证了最新的解决方案。 这意味着每个
UIView
的frame
值始终UIView
(!)解决实际布局任务的方法。
该系统功能足以满足大多数显示器的需求。 但是它不适用于创建具有大量异构内容的磁带。 怎么了
重要的是要记住自动布局:
- 仅在主线程中有效 。 假设Apple工程师选择了Mainstream作为自动版式解决方案的同步点以及所有
UIView
的帧值。 否则,您将不得不在单独的线程中计算自动布局,并不断将值与主线程同步。 - 它基于复杂的算法,在最坏情况下的复杂度是指数级的,因此它可以在复杂的表示形式上缓慢运行 。
- 在iOS 6.0中 可用 。 现在这几乎不是问题,但值得考虑。
结论:使用自动版式可以方便地创建不带集合或不带集合的显示,但元素之间没有复杂的关系。
1.2。 手动frame
计算
该方法的本质:我们自己计算所有frame
值。 例如,我们实现方法layoutSubviews
, sizeThatFits
。 也就是说,在layoutSubviews
自己安排所有子元素,在sizeThatFits
我们计算与子元素和内容的所需位置相对应的大小。
它有什么作用? 我们可以将复杂的计算传递到Background流,并且可以在Main流中执行相对简单的计算。
怎么了 您必须自己实施计算,很容易出错。 您还需要确保子代的位置与sizeThatFits
返回的结果sizeThatFits
。
在以下情况下,自我评估是合理的:
- 我们已经遇到或预期会遇到自动版式性能限制。
- 该应用程序具有复杂的集合,并且很有可能使已开发的元素落入其单元格之一;
- 我们要计算Background线程中元素的大小;
- 我们会在屏幕上显示非标准元素,必须根据内容或环境不断重新计算其大小。

一个例子。 绘图工具提示会自动缩放以适合内容。 此任务中最有趣的部分是如何计算每个工具提示中文本的视觉大小。
2.计算文字大小
可以通过至少四种方式解决此问题,每种方式都依赖于其自己的方法集。 而且每种都有自己的特点和局限性。
2.1。 计算UILabel
/ UITextView
/ UITextField
大小的标准方法
sizeThatFits
(默认在sizeToFit
)和intrinsicContentSize
(在Auto Layout中使用)方法返回视图内容的首选大小。 例如,在他们的帮助下,我们可以找出用UILabel
编写的文本要占用多少空间。
缺点是这两种方法都只能在Main线程中工作-不能从后台调用它们。
标准方法什么时候有用?
- 如果我们已经使用
sizeToFit
或Auto Layout。 - 当显示中有标准元素时,我们希望在代码中获取它们的大小。
- 对于任何没有复杂集合的显示。
2.2。 NSAttributedString / NSString方法
注意boundingRect
和sizeWithAttributes
。 我不建议使用它们来读取UILabel
/ UITextView
/ UITextField
内容的大小。 我没有在文档信息中的任何地方找到NSString
方法和UIView
元素的布局方法基于同一代码(相同类)。 这两组类分别属于不同的框架:Foundation和UIKit。 也许您已经必须使boundingRect结果适合UILabel
大小? 还是您遇到 NSString
不考虑表情符号大小的事实? 这些是您可以获得的问题。
我还将告诉您哪些类负责绘制UILabel
/ UITextView
/ UITextField
文本,但现在UITextField
返回方法。
如果我们能够使用boundingRect和sizeWithAttributes是值得的:
- 我们使用
drawInRect
, drawAtPoint
或NSString
/ NSAttributedString
其他方法绘制非标准接口元素。 - 我们要考虑Background流中元素的大小。 同样,这仅在使用适当的渲染方法时。
- 在任意上下文上绘制,例如,在图像顶部显示一条线。
2.3。 文字套件
该工具由标准类NLayoutManager
, NSTextStorage
和NSTextContainer
。 布局UILabel
/ UITextView
/ UITextField
也基于它们。
当您需要详细描述文本的位置并指出它将围绕哪些形状流动时,TextKit十分方便。

使用TextKit,您可以计算后台队列中界面元素的大小以及行/字符的 frame
。 此外,该框架还允许您绘制字形并完全改变现有布局中文本的外观。 所有这些在iOS 7.0及更高版本中均有效。
当您需要:
- 显示具有复杂布局的文本;
- 在图像上绘制文字;
- 计算单个子字符串的大小;
- 计算行数;
- 使用
UITextView
的计算结果。
我再次强调。 如果需要提前计算UITextView
的大小,我们首先配置NSLayoutManager
, NSTextStorage
和NSTextContainer
实例 ,然后将这些实例传递给相应的 UITextView
,由它们负责布局。 只有这样,我们才能保证所有值的完全一致。
不要将TextKit与UILabel
和UITextField
! 对于它们(与UITextView
不同),您无法配置NSLayoutManager
, NSTextStorage
和NSTextContainer
。
2.4。 核心文字
这是iOS中最低级别的文本工具。 它可以最大程度地控制字体,字符,线条,缩进的呈现。 而且,他和TextKit一样,允许您计算文本的印刷参数,例如基线和每行的框架大小。
如您所知,自由越多,责任就越高。 为了使用CoreText获得良好的结果,您需要能够使用其方法。
CoreText为大多数对象上的操作提供线程安全性。 这意味着我们可以从不同的线程调用其方法。 为了进行比较,使用TextKit时,您自己必须考虑方法调用的顺序。
在以下情况下应使用CoreText:
- 直接访问文本参数需要非常简单的低级API。 我必须马上说,对于绝大多数任务,TextKit的功能就足够了。
- 单独的行(
CTLine
)和字符/元素有很多工作要做。 - 支持在iOS 6.0中很重要。
对于VKontakte提要,我们使用了CoreText。 怎么了 就在我们实现处理文本的基本功能时,TextKit还不存在。
3. VKontakte提要如何工作?
简要介绍我们如何从服务器接收数据,表单布局和显示。

首先,考虑在后台队列中执行的任务。 我们从服务器接收数据,对其进行处理并以声明方式描述随后的显示。 在这个阶段,我们还没有UIView
实例,我们仅使用声明工具来设置future接口的规则和结构,这与SwiftUI有点类似。 为了计算布局,我们在考虑当前限制(例如屏幕宽度)的情况下计算整个frame
。 我们更新当前的dataSource
( dataSourceUpdate
)。 在这里,我们在背景队列中准备图像:执行解压缩(有关更多详细信息,请参见性能部分),绘制阴影,倒圆角和其他效果。
现在转到主队列。 dataSourceUpdate
接收到的dataSourceUpdate
应用于UITableView
,重用并处理接口事件,填充单元格。
为了描述我们的布局系统,将需要另外一篇文章,但是在这里我将列出其主要功能:
- 声明性API是在其上构建接口的一组规则。
- 基本组件形成一棵树(
nodes
)。 - 基本组成部分的简单计算。 例如,在列表中,我们仅考虑所有子项的宽度/高度来计算
origin
偏移。 - 基本元素不会在层次结构中创建不必要的
UIView
“容器”。 例如,列表组件不会形成附加的UIView
,也不会为其添加子项。 相反,我们计算子项相对于父项(对于列表)元素的origin
偏移。 - 使用CoreText进行低级文本管理。
但是,即使采用这种方法,由于性能问题,磁带的观看可能仍然不够流畅。 怎么了
每个单元都有一个复杂的nodes
层次结构。 而且,尽管基本元素不会创建不必要的容器,但功能区UIView
仍显示许多UIView
。 并且在Main-queue中用“节点”(视图绑定)填充层次结构时,还有许多其他工作难以摆脱。
我们尝试将尽可能多的任务转移到“后台”队列中,现在继续这样做。 此外,必须考虑并规避CPU密集型和GPU密集型操作。
4.如何获得更好的性能
最简单的答案是卸载主线程,CPU和GPU。 为此,您需要深入了解iOS应用程序的工作。 最重要的是,找出问题的根源。
4.1为什么出现性能问题
核心动画, RunLoop
和滚动
让我们记住该接口是如何在iOS中构建的。 在顶层,有UIKit ,它负责与用户进行交互:处理手势,使应用程序从睡眠中唤醒以及类似的事情。 为了渲染界面,需要使用较低级别的工具- 核心动画 (如macOS中一样)。 这是一个具有自己的接口描述系统的框架。 考虑构建接口的基本概念。
对于Core Animation,整个界面都是CALayer
层。 它们形成了一个渲染树,通过CATransaction
事务进行管理。
事务是一组更改,更准确地说,是有关在显示的界面中更新某些内容的信息。 frame
或其他层参数的任何更改都属于当前事务。 如果还没有,系统本身会创建一个隐式事务 。
几个事务形成一个堆栈。 新的更新属于堆栈的最高事务。
现在我们知道要更新屏幕,我们需要使用层树的新参数来形成事务。

何时以及如何创建交易? 在我们的应用程序中,线程具有一个称为RunLoop
的实体 。 简单来说,这是一个无限循环,每次循环都会处理当前事件队列 。
在Main线程中,需要RunLoop
来处理来自不同来源的事件,例如接口(手势),计时器或从NSStream
和NSPort
接收数据的处理程序。

核心动画和RunLoop
? 我们在上面发现,在更改“渲染树”中的图层属性时,系统会在必要时创建隐式事务(因此,我们无需调用CATransaction.begin
。开始重新绘制内容)。 此外,在每次RunLoop
迭代时RunLoop
系统都会自动关闭未完成的事务并应用所做的更改( CATransaction.commit
)。
注意! RunLoop
的迭代次数不取决于屏幕的刷新率。 该循环根本不与屏幕同步,其工作方式类似于“ Endless while()
”。
现在,让我们看看滚动过程中Main线程的RunLoop
迭代中发生了什么:
... if (dispatchBlocks.count > 0) { // MainQueue doBlocks() } ... if (hasPanEvent) { handlePan() // UIScrollView change content offset -> change bounds } ... if (hasCATransaction) { CATransaction.commit() } ...
首先,执行通过dispatch_async
/ dispatch_sync
添加到Main队列中的块。 并且在完成之前,程序不会继续执行以下任务。
接下来,UIKit开始处理用户的手势 。 作为处理此手势的一部分, UIScrollView.contentOffset
更改,因此, UIScrollView.bounds
也会更改。 更改UIScrollView
(分别及其后代UITableView
和UICollectionView
)的bounds
更新内容的可见部分( viewport
)。
在RunLoop
迭代结束时,如果我们有未完成的事务,则会自动commit
或flush
。
要检查它是如何工作的,请在适当的位置放置断点。
手势处理如下所示:

这是handlePan
之后的CATransaction.commit
:

在滚动减速期间, UIScrollView
创建一个CADisplayLink
计时器,以将每秒对contentOffset
的更改数量与屏幕的刷新率同步。

我们CATransaction.commit
, CATransaction.commit
不在RunLoop
迭代结束时发生,而是直接在CADisplayLink
计时器的处理中CADisplayLink
。 但这无关紧要:

4.2。 CATransaction.commit
实际上, CATransaction.commit
中的所有操作都在CALayer
层上执行。 layoutSublayers
具有自己的更新布局( layoutSublayers
)和图像( drawLayer
)的方法。 这些方法的默认实现导致委托方法调用。 通过将新的UIView
实例添加到UIView
层次结构中,我们隐式地将相应的图层添加到Core Animation图层层次结构中。 在这种情况下,默认情况下, UIView
其图层的委托。 从调用堆栈中可以看到, UIView
作为CALayer
委托方法的实现的一部分,将执行其方法,将对此进行讨论:

由于我们通常使用UIView
层次结构,因此将以UIView
示例继续进行描述。
在CATransaction.commit
期间, setNeedsLayout
所有带有setNeedsLayout
标记的UIView
的布局。 请注意,我们自己也不再次调用layoutSubviews
或layoutIfNeeded
因为它们保证了CATransaction.commit
内部系统中的延迟执行。 即使在一次事务中(在调用CATransaction.begin
和CATransaction.commit
),您多次更改frame
并调用setNeedsLayout
,但每次更改都不会立即CATransaction.commit
。 最终更改仅在调用CATransaction.commit
之后CATransaction.commit
。 相关的CALayer
方法: setNeedsLayout
, layoutIfNeeded
和layoutSublayers
。
通过setNeedsDisplay
和drawRect
方法可以形成类似的绘图束。 对于CALayer
这是setNeedsDisplay
, displayIfNeeded
和drawLayer
。 CATransaction.commit
调用所有标记有setNeedsDisplay
元素的呈现方法。 此步骤有时称为屏幕外绘制。
一个例子 。 为方便起见,请使用UITableView
:
... // Layout UITableView.layoutSubviews() // , .. ... // Offscreen drawing UITableView.drawRect() // ...
UIKit重用layoutSubviews
UITableView
/ UICollectionView
:调用willDisplayCell
委托willDisplayCell
,依此类推。 在CATransaction.commit
期间,发生屏幕外绘制:标记为setNeedsDisplay
的所有层的drawInContext
方法或所有UIView
的drawRect
被setNeedsDisplay
。 我注意到,当我们在drawRect
绘制某些东西时,这发生在主线程上,我们迫切需要为新框架更改图层的显示。 显然,这样的解决方案可能效率很低。
CATransaction.commit
接下来会发生什么? 渲染树将发送到渲染服务器。
4.3。 渲染管线
回顾在iOS中形成界面框架的整个过程(渲染管道[WWDC 2014 Session419。iOS应用的高级图形和动画]):

不仅我们应用程序的过程负责帧的形成-Core Animation还可以在名为Render Server的单独系统过程中工作。
框架的形成方式。 我们(或我们的系统)在应用程序中创建一个带有接口更改描述的新事务( CATransaction
),“提交”该事务CATransaction
其传输到Render Server。 在应用程序方面,所有工作都已完成。 接下来,渲染服务器解码事务(渲染树),在视频芯片上调用必要的命令,绘制新帧并将其显示在屏幕上。
有趣的是,在创建框架时,使用了某种“多线程”。 如果屏幕刷新率为每秒60帧,则新形成的帧总数不是以1/60,而是以1/30秒。 这是因为在应用程序准备新帧时,Render Server仍在处理前一个帧:

粗略地说,在我们进行交易形成的过程中,在屏幕上显示之前帧形成的总时间为1/60秒,而在交易处理过程中,在Render Server进程中为1/60秒。
我想发表以下评论。 我们可以自己并行化图层的绘制,并在Background流中渲染 UIImage
/ CGImage
的内容。 之后,在主线程中,您需要将创建的图像分配给CALayer.contents
属性。 就性能而言,这是一种非常好的方法。 是使用它的开发人员Texture 。 但是由于我们只能在应用程序生成事务的过程中更改CALayer.contents
,因此在60帧时创建和替换新图像的时间只有1/60秒,而不是1/30秒(考虑到优化和渲染管道与Render Server的并行化) )
此外,Render Server仍可以处理混合(请参阅下文)和短期图层缓存[iOS核心动画:高级技术。 Nick Lockwood]. 1/60 CALayer.contents
, . .
: , .
4.4.
Main-thread

1. ( CATransaction.commit
) - UIView.layoutSubviews
UIView
(, CALayer
). , layoutSubviews
/ cellForRow
/ willDisplayCell
.
2. drawInContext
/ drawRect
. - Main- ( CATransaction.commit
) — . , .
3. . . CATransaction.commit
, , .
4. . UIImage
/ CGImage
.
5. . Main-thread , scroll. - , UI.
6. Main-. , RunLoop
Main- , , Main-. .
GPU

Blending . GPU ( Render Server GPU, ). , , Background-.
. , UIBlurEffect
, UIVibrancyEffect
, , (Render Pass). , , .
Offscreen rendering (Render Server)

Render Server . , , :
CALayer
, , Offscreen rendering. , UIVisualEffect
( , Render Server CPU, GPU).
, .
5.
, , Time Profiler. Metal System Trace — Time Profiler .
, ( ). , : , .
, Metal System Trace , . , Render Server. , Main-, — , .

- , :

Metal System Trace . 64- , iPhone 5s. , . , - , , UI.
5.2.
. , - - . , CADisplayLink
.
CADisplayLink
timestamp
— ( Render Server). CADisplayLink.timestamp
timestamp
. , (, 1/60 ) :
// CADisplayLink. link = [CADisplayLink displayLinkWithTarget:target selector:selector] [link addToRunLoop:[NSRunLoop mainRunLoop] forMode:UITrackingRunLoopMode] // CADisplayLink : diff = prevTimestamp - link.timestamp if (diff > 1/fps) { // freeze } prevTimestamp = link.timestamp
CADisplayLink
UITrackingRunLoopMode
, .
Rendering Pipeline:

UI-, . «» freezeFrameTimeRate
:
scrollTime // Scroll freezeFrameTime // , "", freezeFrameTimeRate = freezeFrameTime / scrollTime
, - UIView
. , «»:

, , « UIView
» . 怎么了 , . , , , : CADisplayLink
, Render Server link.timetamp
, Render Server , . 60 UI-, Render Server. Render Server , .
, , , Render Server . Metal , Render Server. , , iOS, Render Server .
.
, , . , .
: — ! — .
结论
— . , , .
, — . , .
, :
- Apple .
- Auto Layout .
- The Cassowary Linear Arithmetic Constraint Solving Algorithm .
- iOS Core Animation: Advanced Techniques. Nick Lockwood.
- WWDC 2014 Session 419. Advanced Graphics and Animations for iOS Apps.
