SwiftUI学习笔记04 – 如何调试SwiftUI?

2023年4月19日 · 1 year ago

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到的信息不多,只有AppleInsiderDaring 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尝试一下看看。

五、相关链接