SwiftUI 与 RealityKit 的合体之路
Better together: SwiftUI and RealityKit
2025年6月9日
一句话判断
SwiftUI 和 RealityKit 终于从”各干各的”变成了”双向奔赴”——Entity 现在可以当数据模型用,SwiftUI 动画可以直接驱动 RealityKit 组件变化。但双向数据流是把双刃剑,用不好就会陷入无限循环。
这场 Session 讲了什么
visionOS 26 在 SwiftUI 和 RealityKit 的融合上做了一轮非常扎实的升级。核心变化有三个层面:
第一,Model3D 能力大幅增强。现在可以直接播放模型内置动画、支持 ConfigurationCatalog 切换不同配置(比如换装),而且 Model3DAsset 提供了 AnimationPlaybackController 来控制播放进度。Model3D 的定位更像是”3D 版的 Image View”——用来展示一个自包含的 3D 资源。
第二,当 Model3D 不够用时,可以平滑迁移到 RealityView。新增的 .realityViewLayoutBehavior 修饰符解决了之前切换时的布局错位问题——.fixedSize 让 RealityView 像 Model3D 一样紧凑包裹模型边界,而不是吃掉所有可用空间。
第三,也是最重要的:RealityKit 引入了三个 SwiftUI 风格的组件。ViewAttachmentComponent 直接在 Entity 上挂 SwiftUI 视图;GestureComponent 让 Entity 响应手势,坐标系默认是实体自身空间;PresentationComponent 可以在 RealityKit 场景里弹出 popover。另外,Entity 现在是 Observable 的,SwiftUI 视图可以直接观察 Entity 的位置、组件变化来驱动 UI 更新。
值得深挖的点
双向数据流与无限循环的博弈
Entity 变成 Observable 后,信息可以从 RealityKit 流向 SwiftUI(比如用 Entity 位置更新小地图),也可以从 SwiftUI 流向 RealityKit(通过 update 闭包或 gesture 回调)。但问题来了:如果你在 RealityView 的 update 闭包里修改了一个被 SwiftUI 观察的 Entity 属性,就会触发 SwiftUI 重新渲染 body,body 变化又触发 update 闭包……无限循环。
Session 给出的解法很明确:不要在 update 闭包里修改你正在观察的属性。如果你确实需要修改,先检查当前值是否已经相同,相同就跳过。更根本的做法是拆分视图——把大视图拆成多个小的、各自只持有必要状态的子视图,这样局部变化不会触发全局重渲染。
还有一个容易忽略的点:RealityView 的 make 闭包和 gesture 回调不在 SwiftUI 观察作用域内,所以在这里修改 Entity 属性是安全的。System 的 update 函数也是安全的。
Object Manipulation 的事件驱动设计
Object Manipulation API 的设计走的是事件订阅模式——ManipulationEvent 包括 DidStart、DidUpdate、DidEnd、DidRelease、DidHandOff。配合 SwiftUI 的 .animate() 方法,你可以做到”松手时实体弹回原位”这样的自定义释放行为。把 defaultReleaseBehavior 设成 .stay 然后在 WillRelease 事件里用动画把 Transform 复位,效果很自然。
统一坐标转换
CoordinateSpace3D 协议打通了 SwiftUI 和 RealityKit 的坐标体系。GeometryProxy3D 提供 .coordinateSpace3D() 方法获取坐标空间,Gesture 类型可以相对任意 CoordinateSpace3D 报告值。内部实现是先转到共享坐标空间再转到目标空间,自动处理 points-to-meters 转换和轴方向差异。
代码片段
Model3D 动画控制
struct RobotAnimationView: View {
@State private var asset = Model3DAsset(sceneName: "robot")
var body: some View {
VStack {
if let asset {
Model3D(asset: asset)
}
// 选择动画
Picker("Animation", selection: $asset.selectedAnimation) {
ForEach(asset.availableAnimations, id: \.self) { anim in
Text(anim.name).tag(anim)
}
}
// 播放控制
if let controller = asset?.animationPlaybackController {
Slider(value: Binding(
get: { controller.time / controller.duration },
set: { controller.time = $0 * controller.duration }
))
}
}
}
}
坑:AnimationPlaybackController 现在是 Observable,但它的 time 属性更新频率很高,频繁驱动 SwiftUI 重渲染可能有性能问题,建议只在用户拖动 scrubber 时才读取。
RealityView 里的 SwiftUI 组件与手势
RealityView { content in
let robot = try await Entity(named: "Bolts")
// 直接在 Entity 上挂 SwiftUI 视图
robot.components.set(ViewAttachmentComponent(
rootView: Text("Bolts").font(.title)
))
// 在 Entity 上挂 SwiftUI 手势
robot.components.set(GestureComponent(
TapGesture().onEnded { showName.toggle() }
))
// 一行启用拖拽操控
ManipulationComponent.configureEntity(robot, input: .all)
content.add(robot)
}
坑:用了 GestureComponent 的 Entity 必须同时有 InputTargetComponent 和 CollisionComponent,否则手势不响应。
最佳实践
Model3D 到 RealityView 的选择标准很清晰:如果你只是展示一个 3D 模型(可能带动画和配置切换),用 Model3D;如果需要添加组件(粒子发射器、自定义碰撞、物理模拟),必须用 RealityView。不要为了”以后可能需要”一开始就用 RealityView——过度设计比迁移成本更高。
双向数据流的架构上,建议把”需要双向同步”的 Entity 数量控制在最少。大部分情况下单向数据流就够了——SwiftUI 状态驱动 RealityView update。只有真正需要从 3D 场景反馈到 UI 的场景(小地图、距离感知触发效果)才启用反向流。
还有什么值得关注
realityViewLayoutBehavior有三个选项:.flexible(默认,吃满空间)、.centered(内容居中)、.fixedSize(紧凑包裹)。它们只改变 RealityView 自身的原点位置,不会重新缩放或定位内部实体。- ParticleEmitterComponent 只能在 RealityView 里用,Model3D 不支持运行时添加组件——这也是从 Model3D 迁移到 RealityView 的典型触发条件。
- 实体动画从 Model3D 切换到 RealityView 后,要改用 Entity 级别的 RealityKit 动画 API,不再走 Model3DAsset 的路径。
- Unified Coordinate Conversion 在处理跨框架距离计算时很有用——比如一个 SwiftUI 拖拽的 Model3D 和一个 RealityView 里的 Entity 之间的距离。