SwiftUI底层默认走Core Animation渲染,它也可以直接用Metal,效率非常高。结构简单的App一般不会遇到性能问题,但SwiftUI的写法和刷新机制毕竟跟我们熟悉的UIKit/AppKit不同,过去的写法容易造成没有必要的View Redrawing,导致卡顿或闪动影响用户体验。
这种时候我们就需要调试下SwiftUI代码,看看影响体验的问题是怎么产生的。
我们写的SwiftUI代码是一个遵循了View
协议的struct
,它不是真正的view实例本身,而只是个“view应该长啥样”的描述。这就给了SwiftUI框架很高的优化自由度,开发者反而不太能干涉底层的渲染逻辑。同时SwiftUI又是闭源的,我们也无法通过阅读源码得知确切的渲染和优化逻辑。但通过苹果提供的调试工具以及对SwiftUI渲染原理的猜测,我们还是能在应用层做一些优化的。
一、影响SwiftUI性能的维度
Xcode Instruments提供了SwiftUI专属模板,除了我们熟悉的Tim Profile维度以外,还提供了View Body, View Properties和Core Animation Commits三个维度。
SwiftUI的刷新机制是以body
为单位计算和重绘的,优化时减少View Body的重绘符合直觉。
除了body
重绘,Instruments也提供了View Properties维度的报告,可以细化分析哪些Properties发生了变化。
最后是Core Animation Commits。SwiftUI默认用Core Animation来渲染,这种实现非常聪明,每次我们的View发生变化,SwiftUI都会计算关键帧然后作为CATransactoin提交,开发者实现UI元素的过渡动画就像呼吸一样简单。但是当CA Commit过于频繁的时候,也容易产生掉帧的问题。
二、优化SwiftUI List
我们List优化为例子,看看如何实现SwiftUI的调试和优化。
上面是我的一个SwiftUI Mac练习作,可以选择Mac上的图片进行压缩。可以看到点开"Open"按钮弹出文件选择窗口时,底下任务列表的缩略图会闪一下,说明它们都被刷新了一遍或多遍。
2.1 私有Debug接口: Self._printChanges()
这个界面有两个SwiftUI View组成,CompressionView
里有一个List
,包含了多个CompressViewCell
(上图代码简化过)。
如何得知这些View因为什么而被刷新的呢?最简单的方法可以用Xcode的断点:
但看堆栈不够直观,如果想知道是哪个property的更新导致View刷新了怎么办?有一个private API我们可以用于调试:
Self._printChanges()
是一个私有API,所以没有文档,根据这个回答,该函数是Apple engineer在WWDC21的Session回答的。以及据说Xcode有一段Summary(我的Xcode 14.2是看不到这一段了):
Summary
When called within an invocation of body of a view of this type, prints the names of the changed dynamic properties that caused the result of body to need to be refreshed. As well as the physical property names, “@self” is used to mark that the view value itself has changed, and “@identity” to mark that the identity of the view has changed (i.e. that the persistent data associated with the view has been recycled for a new instance of the same type).
在本例子中,我先选择10个图片,所以List里有10个Cell。但是SwiftUI的List Content应该都是lazy-loading,所以我们预期只初始化其中能被看到的4个。当fileImporter
展示的时候,CompressionView
的viewModel property isPresentingFilePicker从false变为true,所以CompressionView
会redraw,但是CompressViewCell
的viewModel没有发生任何变化,所以我们预期Cells都不应该被redraw。
现在开启这个API,打印出来的结果如下:
切换fileImporter
的时候,有6个cells被刷新,每个cell被刷了两次。第一次是@self
changed, 第二次是_viewModel
changed。
2.2 在macOS上用LazyVStack实现lazy-loading
根据这里和这里的讨论,初步推断虽然List Content都应该lazy-loading,但至少在macOS上还没有完美实现。用Ventura 13.2.1 + Xcode 14.2 我的测试结果是多渲染了2个,在旧的系统或SDK上可能会初始化全部cells。所以如果为了获得明确的lazy loading,我们可以使用ScrollView+LazyVStack
来替代List
。
实测使用LazyVStack只会渲染4个Cells。
2.3 给View Model增加Identifiable, Equatable
SwiftUI内部做了不少事情,在redraw之前会判断body是否相同以减少重绘次数。相同与否的判断跟View所绑定的@State
, @ObservedObject
等动态属性有关。
如果是POD views (POD = plain data, see Swift’s _isPOD() function.),SwiftUI会直接判断view的每个字段,如果不是POD views就优先取它的==
方法,没有再fall back回去。The SwiftUI Lab的这篇文章对此有深入探讨。不过令我感兴趣的是Core Animation的设计者John Harper的现身说法。
他非常低调,Google到的信息不多,只有AppleInsider和Daring Fireball 2014年对他离开Apple加入Facebook的报道。看来他后来又回到了Apple并参加SwiftUI项目,WWDC19他在这个Session出现。如此说来,使用Core Animation作为SwiftUI的默认渲染就非常合理了。
回到我们的优化来,因为我的App采用MVVM架构,以前写RxSwift的时候就习惯从ViewController分一个ViewModel属性出来,现在把它作为View的一个@ObservedObject
非常自然。但也因此让这个view struct不再是一个POD view,所以我们需要给ViewModel实现Identifiable, Equatable。
如此一来,SwiftUI在决定哪些sub-views需要被redraw的时候就可以通过我们自定义的比较函数来判断,这里我的应用场景是只要id
相同它就不需要改变,但诸位读者要视具体情况来实现自己的比较函数。
2.4 优化后的效果
只有CompressionView
自己因为isPresentingFilePicker
变化而刷新,所有的Cell都不会二次重绘了,Nice!👏🥳
三、使用Instruments
上述例子只是一个非常简单的案例,如果App变得复杂了就需要Instruments相助了。
测试时已禁止进度条刷新
上图选取的时间段是一次fileImporter
展开,引发了12次CompressViewCell
的body重绘,符合Self._printChanges()
的日志结果。
优化后同样是一次fileImporter
展开,不再有CompressViewCell
重绘。
View Properties展示了所有Properties的变化记录,拖动顶部的小三角形可以展示所有Properties的变化过程,有点厉害。不过目前它只能显示Propert Type,比如State<Bool>
,没有变量名,如果你有多个同类型的Properties就有点难找到对应的变量是哪个。希望今年的WWDC可以带来更多更强大的Debug功能。
Core Animation Commits可以告诉我们哪些地方可能有渲染上的卡顿,Hacking with Swift这篇文章有不错的介绍。
Time Profile就无需多言了,平时用来查各种卡顿的必备工具,不再赘述。
四、下一步?
既然@ObservedObject
会导致不好管理的view redrawing,那我们有没有更好的解决方案呢?
SwiftUI发布以来,开发者们有过不少讨论。Alexey Naumov在Why I quit using the ObservableObject中介绍他用Combine包装的AppState取代@ObservedObject
,OneV Cat也分享过TCA - SwiftUI 的救星?。
目前我还在使用@ObservedObject
作为View Model的方案,还没尝试自己封装基于Combine的View Model,未来可以尝试一下。虽然对于使用Redux全局单一Store实现Single Source of Truth的方法我还持怀疑态度,一旦App大了这个State同样是要爆炸的。
P.S. @Livid 开发的Planet就是采用这种方式实现的,有兴趣的读者可以看一下,这是GitHub Repo。哪天我也找个Side Project尝试一下看看。