AVFoundation 新特性
What's new in AVFoundation
2025年6月11日
一句话判断
如果你还在用 UIViewRepresentable 包相机预览,这场 Session 能让你删掉一半的胶水代码——但仅限 iOS 19+。
这场 Session 讲了什么
AVFoundation 这次的更新有一个非常清晰的主线:把 SwiftUI 从音视频开发的”二等公民”里捞出来。CameraPreview 是最直接的信号——一个原生 SwiftUI 视图,接收 AVCaptureSession 就完事了,不用再写那段每次都要写、每次都在细节上出错的 UIViewRepresentable 包装器。播放器那边也是同样的思路,VideoPlayer 终于开放了控件自定义,进度条、播放按钮、音量控制全都可以用 SwiftUI 视图替换,不再被系统默认样式绑架。
另一个值得关注的方向是”智能化”。AVAssetExportSession 现在能根据目标平台自动匹配编码参数,不用你自己调 AVVideoCodecKey 和 AVVideoCompressionProperties。AVAudioRecorder 新增了语音活动检测(VAD),检测到人声才写入数据,安静的时候不占存储。智能章节功能更激进——直接调用 Apple Intelligence 分析音视频内容,自动生成带缩略图的章节导航。
音频引擎那边,AVAudioEngine 支持运行时动态插入和移除处理节点了,不用重建整条音频图。空间音频也开放了自定义 HRTF(Head-Related Transfer Function),游戏和 VR 应用可以基于用户头部模型做更精准的 3D 定位。多流捕获则让前后摄像头同时以不同分辨率输出成为内置能力,双机位录制不再是专业设备的专利。
值得深挖的点
CameraPreview:省的不只是代码量
表面上看,CameraPreview(session:) 就是把 UIViewRepresentable 的活儿包了一层。但真正省下来的是两件更麻烦的事:生命周期对齐和布局协调。
旧方案里,UIViewRepresentable 的 updateUIView 和 SwiftUI 的视图更新时机并不完全一致。你得手动处理 AVCaptureVideoPreviewLayer 的 connection.videoOrientation 随设备旋转同步,还得在 makeUIView 里设置 previewLayer.frame,然后在 updateUIView 里再设一次——因为 SwiftUI 的布局系统可能会在两次调用之间改变 view 的尺寸。这些代码不多,但每一行都是 bug 源头,而且很难写测试。
CameraPreview 内部把这些全处理了。它天然接入 SwiftUI 的 GeometryReader 布局体系,预览层的尺寸跟着容器走,旋转也自动同步。更关键的是它支持 SwiftUI 的视图组合——你可以在 CameraPreview 上面直接叠 VStack、HStack,做取景器 HUD 不需要额外的坐标计算。
但有一个 trade-off 需要注意:CameraPreview 是 iOS 19+ 的 API。如果你的 App 需要支持 iOS 17/18,你不能删掉旧的 UIViewRepresentable 代码,只能用 if #available 做分支。这意味着短期内你的代码量可能反而增加。建议的做法是把 UIViewRepresentable 包装器封装成一个内部工具,等最低部署版本提升后再彻底删除。
播放器控件自定义:插件式设计的取舍
VideoPlayer 的控件自定义采用了一种”插件式”架构:你不是从零搭建播放器 UI,而是在系统框架提供的结构上,用 SwiftUI 视图替换特定的控件组件。这比完全自定义省事得多,比系统默认灵活得多,是一个务实的中间地带。
这个设计的聪明之处在于它保留了系统播放器的状态管理能力。播放状态、缓冲进度、AirPlay 路由这些复杂的内部状态不需要你自己管理,你只需要声明”我要长什么样的播放按钮”、“我要长什么样的进度条”。对于大多数应用来说,这就够了——你真正需要定制的往往只是视觉样式和几个交互细节。
但如果你的需求超出”替换控件外观”的范围——比如要加手势交互(双击快进、滑动调亮度)、要做画中画的自适应布局、要集成弹幕系统——插件式架构可能反而成为障碍。你拿到的是一个受限的插槽,不是完整的控制权。这时候你可能还是得回到 AVPlayerLayer + 自己搭建 UI 的老路上。所以迁移之前,先想清楚你的播放器需求到底是”长得不一样”还是”行为不一样”。
代码片段
示例 1:SwiftUI 原生相机预览
场景:短视频 App 需要全屏取景器,叠上录制按钮和摄像头切换按钮。
import AVFoundation
import SwiftUI
struct CameraView: View {
@StateObject private var model = CameraModel()
var body: some View {
ZStack {
CameraPreview(session: model.captureSession)
.ignoresSafeArea()
VStack {
Spacer()
HStack {
Button("切换") { model.switchCamera() }
Button("录制") { model.toggleRecording() }
}
.padding()
}
}
.onAppear { model.startSession() }
.onDisappear { model.stopSession() }
}
}
@MainActor
class CameraModel: ObservableObject {
let captureSession = AVCaptureSession()
func startSession() {
captureSession.beginConfiguration()
captureSession.sessionPreset = .high
guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
let input = try? AVCaptureDeviceInput(device: camera),
captureSession.canAddInput(input) else { return }
captureSession.addInput(input)
captureSession.commitConfiguration()
captureSession.startRunning()
}
func switchCamera() { /* 前后切换逻辑 */ }
func toggleRecording() { /* 录制逻辑 */ }
func stopSession() { captureSession.stopRunning() }
}
坑:AVCaptureSession 必须放在 @StateObject 的 ViewModel 里,不能用 @State 持有,否则 SwiftUI 视图重建时 session 会被意外释放。
示例 2:自定义播放器控件
场景:教育类 App 需要在播放器下方放自定义进度条和章节导航,不使用系统默认控件。
import AVFoundation
import SwiftUI
struct CustomVideoPlayer: View {
let player: AVPlayer
@State private var isPlaying = false
@State private var currentTime: Double = 0
@State private var duration: Double = 0
var body: some View {
VStack(spacing: 0) {
VideoPlayer(player: player)
.aspectRatio(16/9, contentMode: .fit)
VStack(spacing: 12) {
// 自定义进度条
Slider(value: $currentTime, in: 0...max(duration, 1)) { editing in
if !editing {
player.seek(to: CMTime(seconds: currentTime, preferredTimescale: 600))
}
}
HStack(spacing: 40) {
Button { seekRelative(-15) } label: {
Image(systemName: "gobackward.15")
}
Button { togglePlayback() } label: {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.title)
}
Button { seekRelative(15) } label: {
Image(systemName: "goforward.15")
}
}
}
.padding()
}
}
func togglePlayback() {
isPlaying ? player.pause() : player.play()
isPlaying.toggle()
}
func seekRelative(_ seconds: Double) {
let target = max(0, min(currentTime + seconds, duration))
player.seek(to: CMTime(seconds: target, preferredTimescale: 600))
}
}
坑:CMTime 的 preferredTimescale 设 600 是最佳实践,用浮点数直接算时间精度会出问题,尤其是 seek 操作时容易出现音画不同步。
示例 3:语音活动检测录制
场景:会议记录 App,检测到人声才录制,安静时自动暂停,节省存储。
import AVFoundation
class VoiceActivityRecorder: ObservableObject {
private var recorder: AVAudioRecorder?
@Published var isRecording = false
func startMonitoring() {
let settings: [String: Any] = [
AVFormatIDKey: kAudioFormatMPEG4AAC,
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 1,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
]
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("voice.m4a")
guard let rec = try? AVAudioRecorder(url: url, settings: settings) else { return }
rec.isMeteringEnabled = true
rec.voiceActivityDetectionEnabled = true
rec.onVoiceActivityDetected = { [weak self] isActive in
DispatchQueue.main.async {
if isActive && !(self?.isRecording ?? false) {
self?.isRecording = true
self?.recorder?.record()
}
}
}
rec.record()
recorder = rec
}
}
坑:VAD 在嘈杂环境下误触发率不低,生产环境一定要加灵敏度调节入口,否则用户会觉得录制文件里全是噪声片段。
最佳实践
先检查项目的最低部署版本。如果还是 iOS 17/18,CameraPreview 和播放器自定义控件都用不了,这场 Session 的价值在于”了解新 API、为未来做准备”,不要急着重构。
如果最低版本已经是 iOS 19,CameraPreview 的迁移优先级最高——删掉 UIViewRepresentable 包装器能直接减少 30-50 行维护成本最高的代码。播放器控件自定义排第二,但迁移前先回答一个问题:播放器需求到底是”长得不一样”还是”行为不一样”?如果只是换个皮肤,插件式 API 够用;如果要加手势、弹幕、画中画自定义,继续用 AVPlayerLayer 自己搭更合适。
VAD 和智能章节属于锦上添花,不是必选项。VAD 适合语音备忘录、会议记录这类”录音为主”的场景,短视频 App 没必要碰。智能章节依赖 Apple Intelligence,离线不可用,如果用户经常在弱网环境使用,需要设计降级方案——比如用 AVAsset 的元数据做简单的静音检测来切分章节,粗糙但够用。
还有什么值得关注
- 多流捕获模式让前后摄像头同时输出不同分辨率成为内置能力,但对内存和 GPU 有硬性要求,低端设备建议降级为单流。
AVAssetExportSession的智能导出预设省去了手动调编码参数的麻烦,社交分享场景直接用.socialMediapreset 就行。- 自定义 HRTF 让空间音频不再依赖固定的头部模型数据,游戏和 VR 应用可以提供更个性化的 3D 音频体验。