React Native 动画生态的重要作者 Krzysztof Magiera 对 SwiftUI 提出了一个颇具争议的判断:它不算“真正的原生”,而这正是部分 SwiftUI 界面偶尔卡顿的原因。考虑到 Magiera 是 Reanimated 和 react-native-gesture-handler 的核心作者,这个观点值得认真讨论,但不能简单理解成“SwiftUI 是跨平台框架”或“SwiftUI 没有调用系统能力”。真正的问题,是动画由谁驱动、每一帧依赖哪些计算,以及主线程繁忙时渲染还能不能继续稳定推进。
“原生”不是一个足够精确的技术指标
从产品归属看,SwiftUI 当然是苹果提供的系统 UI 框架;从开发体验看,它使用 Swift、访问系统控件,并与 iOS SDK 深度集成。因此,把它与 WebView 或常见跨平台运行时归为一类,并不准确。
但 Magiera 的质疑针对的不是框架出身,而是渲染模型。传统 UIKit 动画通常会把视图变化提交给 Core Animation 的图层系统。提交完成后,插值、合成等工作不必全部依赖应用主线程逐帧执行。这套架构从早期 iPhone 开始,就承担了维持触摸和动画流畅度的重要角色。
声明式 UI 增加了另一条链路:
- 状态发生变化;
- 框架计算受影响的视图;
- 更新布局、属性和显示内容;
- 将结果提交给底层渲染系统;
- GPU 完成合成并显示。
任何一步超过一帧的预算,用户都会看到停顿。在 60 Hz 屏幕上,一帧约有 16.7 毫秒;在 120 Hz 屏幕上,预算只剩约 8.3 毫秒。声明式 API 写起来更短,并不意味着状态求值、布局和提交自动变得更便宜。
所以,“SwiftUI 不原生”更适合被理解为一个架构层面的警告:不要因为框架来自苹果,就假定所有动画都天然拥有传统 Core Animation 动画相同的执行特征。SwiftUI 的内部实现还可能随 iOS 版本变化,不能仅凭 API 外观推断它在哪个线程、以什么方式完成每一步。
为什么 React Native 动画作者会特别关注这件事
Reanimated 和 react-native-gesture-handler 长期面对同一个工程约束:手势与动画不能持续等待 JavaScript 线程。如果每一帧都要跨线程传递状态,而 JavaScript 此时正在处理业务逻辑、序列化数据或重新渲染组件,动画就会抖动。
解决方向不是给“原生”贴标签,而是缩短关键路径:
- 让手势识别靠近平台输入系统;
- 让动画计算尽可能留在 UI 侧;
- 避免每帧都跨运行时同步;
- 将可预先描述的动画交给独立的合成机制;
- 减少动画期间的布局、对象分配和大范围状态失效。
这套判断同样适用于 SwiftUI。一个界面即使完全用系统 API 编写,只要每帧都触发昂贵的 body 求值、复杂布局、图片解码或同步 I/O,照样会超过帧预算。反过来,React Native 动画如果把关键工作移出拥堵的 JavaScript 路径,也可能比设计不当的 SwiftUI 页面更稳定。
框架名称不能替代性能分析。
做一个可重复的掉帧实验
下面的最小应用会执行一个持续旋转动画,同时通过 CADisplayLink 粗略统计回调间隔。按钮会故意阻塞主线程 200 毫秒,用来观察动画、FPS 和丢帧估算值如何变化。
在 Xcode 中新建一个 iOS App,界面选择 SwiftUI,然后用下面的内容替换自动生成的 App 文件。代码中的主线程阻塞只用于实验,不能放进生产代码。
import SwiftUI
import QuartzCore
import Combine
@main
struct AnimationProbeApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
final class FrameProbe: ObservableObject {
@Published private(set) var fps: Double = 0
@Published private(set) var estimatedDroppedFrames = 0
private var displayLink: CADisplayLink?
private var previousTimestamp: CFTimeInterval?
func start() {
guard displayLink == nil else { return }
let link = CADisplayLink(target: self, selector: #selector(tick(_:)))
link.add(to: .main, forMode: .common)
displayLink = link
}
func stop() {
displayLink?.invalidate()
displayLink = nil
previousTimestamp = nil
}
@objc private func tick(_ link: CADisplayLink) {
defer { previousTimestamp = link.timestamp }
guard let previousTimestamp else { return }
let elapsed = link.timestamp - previousTimestamp
let frameBudget = link.duration
guard elapsed > 0, frameBudget > 0 else { return }
fps = 1.0 / elapsed
if elapsed > frameBudget * 1.5 {
let elapsedFrames = Int((elapsed / frameBudget).rounded())
estimatedDroppedFrames += max(1, elapsedFrames - 1)
}
}
}
struct ContentView: View {
@StateObject private var probe = FrameProbe()
@State private var rotating = false
@State private var circleCount = 120
var body: some View {
VStack(spacing: 20) {
Text("FPS: \(probe.fps, specifier: \"%.1f\")")
.font(.system(.title2, design: .monospaced))
Text("估算丢帧:\(probe.estimatedDroppedFrames)")
.foregroundStyle(.secondary)
ZStack {
ForEach(0..<circleCount, id: \.self) { index in
let angle = Double(index) / Double(circleCount) * 2 * Double.pi
Circle()
.fill(index.isMultiple(of: 2) ? .blue : .cyan)
.frame(width: 8, height: 8)
.offset(
x: cos(angle) * 100,
y: sin(angle) * 100
)
}
}
.frame(width: 240, height: 240)
.rotationEffect(.degrees(rotating ? 360 : 0))
.animation(
.linear(duration: 2).repeatForever(autoreverses: false),
value: rotating
)
Stepper("圆点数量:\(circleCount)", value: $circleCount, in: 20...800, step: 20)
Button("故意阻塞主线程 200 ms") {
Thread.sleep(forTimeInterval: 0.2)
}
.buttonStyle(.borderedProminent)
}
.padding()
.onAppear {
probe.start()
rotating = true
}
.onDisappear {
probe.stop()
}
}
}
测试时建议这样做:
- 使用真机,并分别观察 60 Hz 与高刷新率设备;
- 使用 Release 构建复测,不要只看 Debug 表现;
- 逐步把圆点数量从 120 增加到 800;
- 点击阻塞按钮,观察旋转是否停顿以及计数是否跳变;
- 再用 Instruments 的 Time Profiler 和 Animation Hitches 定位主线程工作。
这个示例只能作为探针,不能证明某个框架整体更快。CADisplayLink 本身运行在主线程,统计值也会受到调度影响;它的价值在于快速暴露长任务,而不是替代 Instruments、系统性能指标和真机录屏分析。
选 SwiftUI 还是 UIKit,应看页面的性能边界
SwiftUI 在表单、设置页、数据驱动界面和常规导航中,往往能显著减少状态同步代码。为了追求抽象意义上的“真原生”而全面退回 UIKit,通常没有必要。更现实的做法是按交互风险拆分:
- 普通业务页面优先使用 SwiftUI;
- 高频手势、复杂转场、画布或时间轴编辑器需要尽早做真机原型;
- 避免在动画状态上挂接大范围视图依赖;
- 把图片解码、JSON 解析和同步存储移出主线程;
- 稳定、可预先描述的动画可以评估 Core Animation 或局部 UIKit 封装;
- 为关键路径设置帧率、卡顿次数和交互延迟指标,而不是依赖肉眼验收;
- 至少覆盖应用支持范围内的最低端设备和多个 iOS 版本。
这场争论最有价值的部分,不是决定 SwiftUI 有没有资格使用“原生”二字,而是提醒团队区分三件事:系统框架、声明式开发体验和可预测的渲染性能并不是同一个概念。真正可靠的技术选型,来自帧预算、线程模型和真机数据,而不是框架标签。