You need to enable JavaScript to run this app. SwiftUI 自定义相机实时预览持续崩溃问题排查
优惠活动
大模型
产品
解决方案
定价
更多

SwiftUI 自定义相机实时预览持续崩溃问题排查

崩溃根因

该问题和SwiftUI本身无关,由配置缺失、不规范写法共同导致:

  • 缺失相机权限声明:iOS 系统强制要求调用摄像头前,在 Info.plist 中添加 NSCameraUsageDescription 键。缺项时触发相机流程会被系统直接杀死应用,不会弹出授权提示。
  • 空值强制解包:AVCaptureDevice.default(...) 在模拟器运行、摄像头被占用/硬件故障时会返回 nil,原代码直接用 device! 强制解包,立刻触发崩溃。
  • 线程违规:相机授权回调默认在后台线程执行,原代码直接在回调中修改 @Published 状态、配置会话,违反SwiftUI状态必须在主线程更新的规则,会触发偶发崩溃。
    此外即使不崩溃也看不到预览:原代码没有把 CameraPreview 加入 CameraView 的视图层级,ZStack中只有黑色背景和操作按钮。
修复步骤

1. 添加权限声明

打开项目对应Target的「Info」标签页,在自定义iOS目标属性中添加一行:

  • 键名:Privacy - Camera Usage Description
  • 内容:填写面向用户的相机用途说明,例如「需要访问相机完成拍摄」

2. 修复相机模型

移除不必要的隐式解包预览层属性,替换强制解包为安全判空,所有会话操作放在独立串行队列,避免阻塞主线程:

class CameraModel: ObservableObject {
    @Published var isTaken  = false
    @Published var session = AVCaptureSession()
    @Published var alert = false
    @Published var output = AVCapturePhotoOutput()
    
    // 相机会话专用调度队列,避免阻塞UI线程
    private let sessionQueue = DispatchQueue(label: "camera.session.queue")
    
    func check() {
        switch AVCaptureDevice.authorizationStatus(for: .video) {
        case .authorized:
            setUp()
        case .notDetermined:
            AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
                // 回调默认在后台,UI操作必须切回主线程
                DispatchQueue.main.async {
                    guard granted else {
                        self?.alert = true
                        return
                    }
                    self?.setUp()
                }
            }
        case .denied, .restricted:
            alert = true
        @unknown default:
            break
        }
    }
    
    func setUp() {
        sessionQueue.async { [weak self] in
            guard let self = self else { return }
            self.session.beginConfiguration()
            
            // 安全获取摄像头,不做强制解包
            guard let device = AVCaptureDevice.default(
                .builtInWideAngleCamera,
                for: .video,
                position: .front
            ) else {
                self.session.commitConfiguration()
                print("无法获取摄像头设备,请使用真机调试")
                return
            }
            
            do {
                let input = try AVCaptureDeviceInput(device: device)
                if self.session.canAddInput(input) {
                    self.session.addInput(input)
                }
                if self.session.canAddOutput(self.output) {
                    self.session.addOutput(self.output)
                }
                self.session.commitConfiguration()
                // 配置完成后再启动会话
                if !self.session.isRunning {
                    self.session.startRunning()
                }
            } catch {
                // 配置失败回滚,避免会话处于异常状态
                self.session.rollbackConfiguration()
                print("相机配置失败:\(error.localizedDescription)")
            }
        }
    }
    
    // 视图消失时停止会话,降低电量消耗
    func stopSession() {
        sessionQueue.async { [weak self] in
            if self?.session.isRunning == true {
                self?.session.stopRunning()
            }
        }
    }
}

3. 修复预览视图

通过重写layerClass让预览层自动匹配父视图尺寸,不要写死屏幕宽高,同时不需要将预览层声明为可观察状态:

struct CameraPreview: UIViewRepresentable {
    let session: AVCaptureSession
    
    func makeUIView(context: Context) -> PreviewContainerView {
        let view = PreviewContainerView()
        view.previewLayer.session = session
        view.previewLayer.videoGravity = .resizeAspectFill
        return view
    }
    
    func updateUIView(_ uiView: PreviewContainerView, context: Context) {}
}

// 预览层容器,自动适配布局变化
class PreviewContainerView: UIView {
    override class var layerClass: AnyClass {
        AVCaptureVideoPreviewLayer.self
    }
    
    var previewLayer: AVCaptureVideoPreviewLayer {
        layer as! AVCaptureVideoPreviewLayer
    }
}

4. 将预览加入视图层级

把预览放在ZStack最底层,补充权限弹窗和会话生命周期管理,保留原有业务逻辑:

struct CameraView: View {
    @Environment(\.presentationMode) var presentationMode
    @StateObject var camera  = CameraModel()
    
    var body: some View {
        ZStack {
            // 相机预览放在最底层
            CameraPreview(session: camera.session)
                .edgesIgnoringSafeArea(.all)
            
            VStack {
                Button(action: {
                    presentationMode.wrappedValue.dismiss()
                }, label: {
                    Image(systemName: "multiply")
                        .font(.system(size: 40, weight: .semibold))
                        .foregroundColor(Color.white.opacity(0.9))
                        .shadow(color: Color.black.opacity(0.6), radius: 1, x: 0, y: 2)
                })
                .frame(maxWidth: .infinity, alignment: .trailing)
                .padding(.trailing, 30)
                
                Spacer()

                HStack {
                    Button(action: {}) {
                        Image(systemName: "arrow.triangle.2.circlepath.camera")
                            .font(.largeTitle)
                            .foregroundColor(.white)
                    }
                    .padding(.leading)
                    
                    Spacer()
                    
                    if camera.isTaken {
                        Spacer()
                        Button(action: {
                            camera.isTaken.toggle()
                        }, label: {
                            ZStack {
                                Circle()
                                    .fill(Color.red)
                                    .frame(width: 65, height: 65)
                                Circle()
                                    .stroke(Color.white, lineWidth: 2)
                                    .frame(width: 75, height: 75)
                            }
                        })
                        Spacer()
                    } else {
                        Button(action: {
                            camera.isTaken.toggle()
                        }, label: {
                            ZStack {
                                Circle()
                                    .fill(Color.white)
                                    .frame(width: 65, height: 65)
                                Circle()
                                    .stroke(Color.white, lineWidth: 2)
                                    .frame(width: 75, height: 75)
                            }
                        })
                    }
                    
                    Spacer()
                    
                    Button(action: {}) {
                        Image(systemName: "bolt.circle")
                            .font(.largeTitle)
                            .foregroundColor(.white)
                    }
                    .padding(.trailing)
                }
                .frame(height: 75)
            }
        }
        .onAppear(perform: {
            camera.check()
        })
        .onDisappear {
            camera.stopSession()
        }
        .alert("需要相机权限", isPresented: $camera.alert) {
            Button("去设置") {
                guard let url = URL(string: UIApplication.openSettingsURLString) else { return }
                UIApplication.shared.open(url)
            }
            Button("取消", role: .cancel) {}
        } message: {
            Text("请在系统设置中开启相机权限后重试")
        }
    }
}
注意事项
  • 相机功能必须在真机上调试,模拟器无对应硬件,仅能验证流程不崩溃,无法输出实时画面。
  • 后续如果要录制带声音的视频,需要额外添加NSMicrophoneUsageDescription权限声明。
  • 原代码中切换摄像头、闪光灯按钮为空实现,不影响预览功能,后续补充对应逻辑即可。

内容的提问来源于stack exchange,提问作者Joshua Gibson-Bascombe

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.10.22 06:57:01