What's new in AVFoundation
Audio & Video 进阶 40m

AVFoundation 新特性

What's new in AVFoundation

2025年6月11日

在 Apple 官方观看视频

一句话判断

如果你还在用 UIViewRepresentable 包相机预览,这场 Session 能让你删掉一半的胶水代码——但仅限 iOS 19+。

这场 Session 讲了什么

AVFoundation 这次的更新有一个非常清晰的主线:把 SwiftUI 从音视频开发的”二等公民”里捞出来。CameraPreview 是最直接的信号——一个原生 SwiftUI 视图,接收 AVCaptureSession 就完事了,不用再写那段每次都要写、每次都在细节上出错的 UIViewRepresentable 包装器。播放器那边也是同样的思路,VideoPlayer 终于开放了控件自定义,进度条、播放按钮、音量控制全都可以用 SwiftUI 视图替换,不再被系统默认样式绑架。

另一个值得关注的方向是”智能化”。AVAssetExportSession 现在能根据目标平台自动匹配编码参数,不用你自己调 AVVideoCodecKeyAVVideoCompressionPropertiesAVAudioRecorder 新增了语音活动检测(VAD),检测到人声才写入数据,安静的时候不占存储。智能章节功能更激进——直接调用 Apple Intelligence 分析音视频内容,自动生成带缩略图的章节导航。

音频引擎那边,AVAudioEngine 支持运行时动态插入和移除处理节点了,不用重建整条音频图。空间音频也开放了自定义 HRTF(Head-Related Transfer Function),游戏和 VR 应用可以基于用户头部模型做更精准的 3D 定位。多流捕获则让前后摄像头同时以不同分辨率输出成为内置能力,双机位录制不再是专业设备的专利。

值得深挖的点

CameraPreview:省的不只是代码量

表面上看,CameraPreview(session:) 就是把 UIViewRepresentable 的活儿包了一层。但真正省下来的是两件更麻烦的事:生命周期对齐和布局协调。

旧方案里,UIViewRepresentableupdateUIView 和 SwiftUI 的视图更新时机并不完全一致。你得手动处理 AVCaptureVideoPreviewLayerconnection.videoOrientation 随设备旋转同步,还得在 makeUIView 里设置 previewLayer.frame,然后在 updateUIView 里再设一次——因为 SwiftUI 的布局系统可能会在两次调用之间改变 view 的尺寸。这些代码不多,但每一行都是 bug 源头,而且很难写测试。

CameraPreview 内部把这些全处理了。它天然接入 SwiftUI 的 GeometryReader 布局体系,预览层的尺寸跟着容器走,旋转也自动同步。更关键的是它支持 SwiftUI 的视图组合——你可以在 CameraPreview 上面直接叠 VStackHStack,做取景器 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))
    }
}

坑:CMTimepreferredTimescale 设 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 的智能导出预设省去了手动调编码参数的麻烦,社交分享场景直接用 .socialMedia preset 就行。
  • 自定义 HRTF 让空间音频不再依赖固定的头部模型数据,游戏和 VR 应用可以提供更个性化的 3D 音频体验。
AVFoundation 音视频 相机 媒体播放 SwiftUI