🐦 你不再需要别的SwiftUI性能优化了(工程实践)

与UIKit相比,SwiftUI采用声明式编程范式,使得UI开发更加简洁和高效。声明式编程允许开发者专注于描述UI的状态和行为,而不是具体的实现细节。这种方式不仅提高了代码的可读性和可维护性,还能更好地利用SwiftUI的自动化布局和状态管理功能,从而提升开发效率。但缺点也很明显,开发者对底层渲染和性能优化的控制较少,可能会遇到性能瓶颈,尤其是在复杂的UI和动画场景中。在复杂的应用中,性能问题可能会影响用户体验。本文将介绍一些SwiftUI性能优化的工程实践方法,帮助开发者提升应用的响应速度和流畅度。

本文总结了本人一个中型项目的SwiftUI性能优化经验,项目可以运行在macOS和iOS平台,包含大量列表、图表、动画效果以及视频渲染播放等功能。通过以下几个方面的优化措施,显著提升了应用的性能表现。

前言

2025年,不会有人有耐心手写重复、冗长的代码了,在AI迅速发展的背景下,个人开发一个中型
项目最能提高效率的是在设计良好的基础架构、提供代码说明文档和示例代码的前提下,利用AI生成需求文档、设计文档并编码实现。本文不讨论AI生成代码的细节,而是聚焦于如何通过一些工程实践来优化SwiftUI应用的性能,并在此过程中结合AI工具提升开发效率。

  1. 为什么切换到了SwiftUI?和UIKit相比有哪些优势?

目前来说,SwiftUI和UIKit全都要,从工程实践角度看,SwiftUI还无法做到完全替代UIKit。技术选型要考虑的几个问题:

  • 是否适合多人协作开发?合适的技术选型可以大大减少很多事故和Bug。比如减少合并冲突、代码风格不统一等问题。
  • 学习成本是否足够低?团队成员是否容易上手?技术选型要考虑团队成员的技术水平和学习能力。水平高和水平一般的团队成员,都应该要能写出比较接近的工程代码。
  • 效率是否足够高?能否配合AI迅速生成代码?AI能很好生成代码的前提是,有大量的高质量代码在训练集里。一些冷门技术栈,AI生成代码的效果就不理想。
  • 是否有足够的生态支持?是否有大量的第三方库和工具支持?

SwiftUI第一条就不是很符合,声明式编程风格,代码合并冲突会比较多。一点细微的UI改动,都可能会引起大面积的代码变动。第二条和第三条,SwiftUI相对来说学习成本更低,AI生成代码效率更高。第四条,SwiftUI生态还不够完善,很多第三方库和工具还不支持SwiftUI。但SwiftUI的优势也很明显,可以兼容UIKit和AppKit,swift提供了强大的语言特性,结合AI生成代码的能力,SwiftUI在个人开发中效率非常高。官方自带属性包装器(@State, @Binding, @ObservedObject, @EnvironmentObject等)和声明式UI编程范式,使得状态管理和UI更新更加简洁和高效。当然最重要的是兼容UIKit,这样可以充分利用现有的UIKit生态资源。全都要是当前的最佳选择,合适的地方用SwiftUI,不合适的地方用UIKit。

比如视频播放器里面的画面渲染,使用UIKit来实现,是比较合适的,而视频播放器的控制面板UI,使用SwiftUI来实现,是比较合适的。原因是视频画面渲染对性能要求比较高,使用UIKit可以更好地控制渲染流程和性能优化,而控制面板UI对性能要求相对较低,有很多UI状态的更新和布局用UIkit实现会比较复杂,使用SwiftUI可以大大简化代码量和提高开发效率。

另一方面,从工程角度看,声明式的组件适合小组件化开发,组件化开发可以提高代码的复用性和可维护性。但在组件拼装的上层,使用SwiftUI来实现,会比较复杂和繁琐,尤其是涉及到复杂的状态管理和数据流动时,代码会变得难以维护和理解。因此在组件拼装的上层,使用UIKit来实现,会比较合适。UIKit的约束布局和事件处理机制,更适合复杂的UI布局和交互逻辑。比如一个按需加载组件的场景,使用SwiftUI会在body里写大量的条件判断代码,代码量会比较大且难以维护,而使用命令式编程,则可以抽象出许多优秀的设计模式,比如责任链模式、观察者模式等,使得代码更加简洁和易于维护。

数据驱动的UI更新

SwiftUI采用数据驱动的UI更新机制,当数据发生变化时,SwiftUI会自动重新渲染相关的视图。这种机制简化了状态管理和UI更新的过程,提高了开发效率。然而,在复杂的应用中,频繁的数据变化可能导致性能问题。

  1. 如何检测视图刷新的原因?
    在SwiftUI中,可以使用下面的代码片段来检测视图刷新的原因:

    1
    let _ = Self._printChanges()

    将上述代码添加到视图的body属性中,可以在控制台输出视图刷新的详细信息,包括哪些属性发生了变化以及触发刷新的原因。这有助于开发者识别性能瓶颈并进行优化。

  2. 使用合适的属性包装器
    SwiftUI提供了多种属性包装器(如@State, @Binding, @ObservedObject, @EnvironmentObject等)来管理状态。选择合适的属性包装器可以有效减少不必要的视图刷新。例如,使用@State管理局部状态,而使用@ObservedObject或@EnvironmentObject管理全局状态,可以避免整个视图树的重绘。

  • @State:用于管理视图的局部状态,当状态变化时,只有依赖该状态的视图会重新渲染。
  • @Binding:用于在父子视图之间传递状态引用,避免不必要的状态复制。
  • @ObservedObject:用于观察外部数据模型的变化,当数据模型发生变化时,相关视图会重新渲染。对于初始化并持有的对象,应该使用@StateObject以避免重复创建对象。
  • @StateObject:用于在视图中创建并持有一个可观察对象,确保对象的生命周期与视图一致,避免重复创建。
  • @EnvironmentObject:用于在视图层次结构中共享数据模型,适用于跨多个视图共享状态的场景。

视图刷新,SwiftUI的数据更新检测是基于对象级别的,而不是属性级别的。也就是说,当一个ObservableObject的属性发生变化时,整个对象会被标记为“已更改”,从而触发所有依赖该对象的视图重新渲染。这意味着即使只有一个属性发生变化,所有依赖该对象的视图都会重新渲染,可能导致不必要的性能开销。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MediaState: ObservableObject {
@Published var title: String = ""
@Published var isPlaying: Bool = false
@Published var currentTime: Double = 0.0
@Published var duration: Double = 0.0
@Published var volume: Float = 1.0
}

struct ContentView: View {
@ObservedObject var mediaState = MediaState()

var body: some View {
VStack {
Text(mediaState.title)
Button(action: {
mediaState.isPlaying.toggle()
}) {
Text(mediaState.isPlaying ? "Pause" : "Play")
}
Slider(value: $mediaState.currentTime, in: 0...mediaState.duration)
}
}
}

在上述代码中,当mediaState的任何一个属性发生变化时,整个ContentView都会重新评估,即使只有isPlaying属性发生了变化。这可能导致不必要的性能开销,尤其是在复杂的视图层次结构中。

如何优化?将高频和低频更新的状态拆分成多个ObservableObject,分别管理不同的状态。例如,可以将播放状态和媒体信息拆分成两个不同的对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class PlaybackState: ObservableObject {
@Published var isPlaying: Bool = false
@Published var currentTime: Double = 0.0
}

class MediaInfo: ObservableObject {
@Published var title: String = ""
@Published var duration: Double = 0.0
}

class MediaState: ObservableObject {
let playbackState = PlaybackState()
let mediaInfo = MediaInfo()
}

这里使用let来定义不可变的属性,并且没有使用@Published属性包装器,因为我们不希望这些属性本身的属性变化触发MediaState对象的变化通知。相反,我们希望通过playbackState和mediaInfo对象的变化来管理状态更新。如何使用?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
struct SliderView: View {
@EnvironmentObject var playbackState: PlaybackState
@EnvironmentObject var mediaInfo: MediaInfo
var body: some View {
Slider(value: $playbackState.currentTime, in: 0...mediaInfo.duration)
}
}

struct MediaTitleView: View {
@EnvironmentObject var mediaInfo: MediaInfo
var body: some View {
Text(mediaInfo.title)
}
}

struct ContentView: View {
@StateObject var mediaState = MediaState()

var body: some View {
VStack {
let _ = Self._printChanges() // remove in production
MediaTitleView()
.environmentObject(mediaState.mediaInfo)
Button(action: {
mediaState.playbackState.isPlaying.toggle()
}) {
Text(mediaState.playbackState.isPlaying ? "Pause" : "Play")
}
SliderView()
.environmentObject(mediaState.playbackState)
.environmentObject(mediaState.mediaInfo)
}
}
}

对于状态发生改变,并不会触发整个ContentView重新评估,而是只会触发依赖相关状态的子视图重新评估,从而减少不必要的视图刷新,提高性能。

使用Equatable协议,可以进一步优化视图刷新。当视图的输入数据实现了Equatable协议时,SwiftUI会在数据变化时进行比较,只有当数据实际发生变化时才会触发视图的重新渲染。这有助于避免不必要的视图更新,提高性能。

使用可编程宏简化代码

SwiftUI里面的@Published包装后的属性,赋值时会自动触发对象变化通知,无论新值和旧值是否相同。对于一些高频更新的属性(如进度条、音量等),这种行为可能会导致不必要的视图刷新,影响性能。为了解决这个问题,可以使用可编程宏来简化代码,实现只有在新值和旧值不同时才触发变化通知的功能。

1
2
3
class SomeState: ObservableObject {
@Published var progress: Double = 0.0
}

合适的优化可以是:

1
2
3
4
5
6
7
8
class SomeState: ObservableObject {
@Published var progress: Double = 0.0
func setProgressIfNeeded(_ newValue: Double) {
if progress != newValue {
progress = newValue
}
}
}

上面的代码虽然实现了功能,但每次都要手动编写setProgressIfNeeded方法,比较繁琐。使用可编程宏,可以自动生成类似的代码,简化开发过程。例如,可以定义一个宏@PublishedIfChanged,用于替代@Published,自动生成只有在新值和旧值不同时才触发变化通知的逻辑。其次就是setProgressIfNeeded相较与progress =名称更冗长,对已有代码的修改成本更高。其实更推荐下面的写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class SomeState: ObservableObject {
@Published var progress: Double = 0.0

struct _Proxy {
unowned var target: SomeState

var progress: Double {
get { target.progress }
set {
if target.progress != newValue {
target.progress = newValue
}
}
}
}

var x: _Proxy {
return _Proxy(target: self)
}
}

这里其实用$符号更好,但SwiftUI已经用$符号表示Binding了,所以只能用其他符号代替。使用x的话,不用按Shift键就能输入,比较方便。x也是一个对称的字母。 使用时:

1
2
// someState.progress = newValue // 可能会触发不必要的视图评估
someState.x.progress = newValue

编写宏,自动生成上面的_Proxy结构体和x属性,可以大大简化代码编写过程,提高开发效率。(本文不展开介绍如何编写可编程宏,读者可以参考相关资料进行学习。实际上Swift的宏不允许生成比较随意的类/结构体名称,比如上面的_Proxy,有效的名称可以是SomeState_Proxy,这是为了避免开发者往系统里面乱扔垃圾🐶)

视图布局

虽然SwiftUI提供了一些基础的布局容器(如VStack, HStack, ZStack等),但在复杂的UI布局中,使用约束布局(Auto Layout)可能会更高效。约束布局允许开发者精确控制视图的位置和大小,从而减少为了满足特定布局而造成的视图层级过深问题。

(未完待续…)