2

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

 1 year ago
source link: https://justinyan.me/post/5678
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
webp

SwiftUI底层默认走Core Animation渲染,它也可以直接用Metal,效率非常高。结构简单的App一般不会遇到性能问题,但SwiftUI的写法和刷新机制毕竟跟我们熟悉的UIKit/AppKit不同,过去的写法容易造成没有必要的View Redrawing,导致卡顿或闪动影响用户体验。

这种时候我们就需要调试下SwiftUI代码,看看影响体验的问题是怎么产生的。

我们写的SwiftUI代码是一个遵循了View协议的struct,它不是真正的view实例本身,而只是个“view应该长啥样”的描述。这就给了SwiftUI框架很高的优化自由度,开发者反而不太能干涉底层的渲染逻辑。同时SwiftUI又是闭源的,我们也无法通过阅读源码得知确切的渲染和优化逻辑。但通过苹果提供的调试工具以及对SwiftUI渲染原理的猜测,我们还是能在应用层做一些优化的。

一、影响SwiftUI性能的维度

webp

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尝试一下看看。

五、相关链接


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK