Swift UI相机应用不使用UIVIEW或UI*
#macos #ios #swift #swiftui

在本文中,我正在写下我的经验和代码,这些代码旨在使在Swift UI中使用该应用程序,该应用程序使用用户摄像头并在屏幕上显示实时供稿。该应用在MacOS(经过苹果硅测试)和iOS中起作用。

开始,在Xcode中创建一个新的多平台项目。我在M1上使用了Xcode版本14.2(14C18)。

设置 - 权限

由于我们需要在Xcode中使用摄像头,因此我们应该通过xcodeproj文件启用它。单击XCode中的此文件,它将打开一个不错的视图,以查看和编辑项目的设置。该文件以您的项目(例如myapp.xcodeproj)命名。我将我的项目命名为 expiry ,所以我的文件是expiry.xcodeproj

Image showing xcodeproj file in Xcode left sidebar

单击签名和能力。那里启用了一个相机的复选框。

Image showing UI of Signing & Capabilities

然后单击 info ,然后添加一个新条目。这个与编辑info.plist文件相同。我们正在通过UI进行。要在此处添加新密钥,请将悬停在任何现有条目上。它将显示一个 +按钮;单击下面添加下面的新条目。现在,对于 key 列,type 隐私 - 摄像机用法描述。一旦您开始键入,UI将向您显示可用键的下拉。

添加键后,单击 value 列,然后键入$(PRODUCT_NAME) camera use。您可以跳过 type 列,因为它是自动集合到String的。

Image showing UI of Info

应用结构

由于这是一个简单的相机应用程序,因此我们将只有视图,ViewModel和应用程序中的经理。 MACOS上的最终应用将如下所示:

Image showing screenshot of the app on macOS

应用结构 - 视图

我们将在应用程序中具有以下视图:

  1. ContentView-这是应用程序的主视图。 Xcode将此文件自动创建为ContentView.swift。我们将在短期内编辑
  2. FrameView-此视图显示相机输出。我们将为此
  3. 创建一个新文件
  4. ErrorView-当我们有一些与用户显示给用户的摄像机相关错误时,这将显示。对于此视图,我们将创建一个新文件
  5. ControlView-此视图将有一个按钮可以拍摄照片。对于此视图,我们将创建一个新文件

应用结构 - ViewModels

要处理视图的业务逻辑,我们将拥有单独的类。对于此应用,我们只需要一个视图模型:

  1. ContentViewModel-这要照顾主要的视频和错误状态流逻辑。我们将在一个名为ViewModel的文件夹中创建一个新文件(也是我们新创建的)

应用结构 - 经理

要处理设备,流,图像和文件io等的复杂逻辑,我们将拥有经理类。这些将存储在一个名为Camera的文件夹中,因为所有这些类都将与相机及其输出管理有关。

  1. CameraManager-此管理设备,配置,会话,队列(用于视频输出),权限和错误状态
  2. FrameManager-这将启动相机管理器并读取相机输出
  3. PhotoManager-这将捕获视频流中的照片,并在设备上存储

让我们快速吧

代码 - 摄影师

在您的应用程序中创建一个名为Camera的文件夹。在文件夹中,创建一个新的Swift文件CameraManager.swift。该文件将保存从ObservableObject类派生的类。

用以下代码替换本文件的自动生成内容:

import AVFoundation

class CameraManager: ObservableObject {

    /** enums to represent the CameraManager statuses */
    enum Status {
        case unconfigured
        case configured
        case unauthorized
        case failed
    }

    /** enums to represent errors related to using, acessing, IO etc of the camera device */
    enum CameraError: Error {
        case cameraUnavailable
        case cannotAddInput
        case cannotAddOutput
        case deniedAuthorization
        case restrictedAuthorization
        case unknownAuthorization
        case thrownError(message: Error)
    }

    /** ``error`` stores the current error related to camera */
    @Published var error: CameraError?

    /** ``session`` stores camera capture session */
    let session = AVCaptureSession()

    /** ``shared`` a single reference to instance of CameraManager
     All the other codes in the app must use this single instance */
    static let shared = CameraManager()

    private let sessionQueue = DispatchQueue(label: "yourdomain.expiry.SessionQ")

    private let videoOutput = AVCaptureVideoDataOutput()

    private var status = Status.unconfigured

    /** ``set(_:queue:)`` configures `delegate` and `queue`
     this should be configured before using the camera output */
    func set(
      _ delegate: AVCaptureVideoDataOutputSampleBufferDelegate,
      queue: DispatchQueue
    ) {
      sessionQueue.async {
        self.videoOutput.setSampleBufferDelegate(delegate, queue: queue)
      }
    }

    private func setError(_ error: CameraError?) {
        DispatchQueue.main.async {
            self.error = error
        }
    }

    private init() {
        configure()
    }

    private func configure() {
        checkPermissions()
        sessionQueue.async {
          self.configureCaptureSession()
          self.session.startRunning()
        }
    }

    private func configureCaptureSession() {
        guard status == .unconfigured else {
            return
        }
        session.beginConfiguration()
        defer {
            session.commitConfiguration()
        }
        let device = AVCaptureDevice.default(
            .builtInWideAngleCamera,
            for: .video,
            position: .back)
        guard let camera = device else {
            setError( .cameraUnavailable)
            status = .failed
            return
        }
        do {
            let cameraInput = try AVCaptureDeviceInput(device: camera)

            if session.canAddInput(cameraInput) {
                session.addInput(cameraInput)
            } else {
                setError( .cannotAddInput)
                status = .failed
                return
            }
        } catch {
            debugPrint(error)
            setError(.thrownError(message: error))
            status = .failed
            return
        }

        if session.canAddOutput(videoOutput) {
            session.addOutput(videoOutput)

            videoOutput.videoSettings =
            [kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA]

            let videoConnection = videoOutput.connection(with: .video)
            videoConnection?.videoOrientation = .portrait
        } else {
            setError( .cannotAddOutput)
            status = .failed
            return
        }
        status = .configured
    }

    private func checkPermissions() {
        switch AVCaptureDevice.authorizationStatus(for: .video) {
        case .notDetermined:
            sessionQueue.suspend()
            AVCaptureDevice.requestAccess(for: .video) { authorized in
                if !authorized {
                    self.status = .unauthorized
                    self.setError(.deniedAuthorization)
                }
                self.sessionQueue.resume()
            }
        case .restricted:
            status = .unauthorized
            setError(.restrictedAuthorization)
        case .denied:
            status = .unauthorized
            setError(.deniedAuthorization)
        case .authorized:
            /** ``Status.authorized-enum.case`` represents all success to get the camera access */
            break
        @unknown default:
            status = .unauthorized
            setError(.unknownAuthorization)
        }
    }
}

上面的代码中记录了公共属性和方法。有两个主要的私人func需要一些解释-checkPermissionsconfigureCaptureSession。让我们详细讨论这两个。

func checkPermission-文件./Camera/CameraManager.swift

此功能检查我们的应用程序是否有权访问相机。如果未授予权限,则该应用程序将询问用户许可。如果用户授予使用该应用程序的权限,我们都将设置ð,如果用户拒绝许可,我们将设置适当的.error.status值。

总共需要处理4个案例(包括未知未知):

  • .notDetermined-由于尚未确定访问权限,这种情况将继续前进,并要求用户允许我们使用摄像头
  • .restricted-用户提供了访问权,但受到限制。我们将其与.denied相同
  • .denied-用户拒绝访问
  • @unknown default-韦尔普,我们所知道的一切都将与.denied相同
  • .authorized-啊!我们的应用程序需要运行所需的Sweet Sweet Sweet Access授予的状态

func configureCaptureSession-文件./Camera/CameraManager.swift

为了使用相机的输出,我们必须配置会话并设置相机视频馈电的输入和输出。输出将发送到self.videoOutput,该self.videoOutput将按照set公共方法配置。

session.beginConfiguration()
defer {
    session.commitConfiguration()
}

这两行启动了会话配置,并通过configureCaptureSession方法的结尾提交配置。

然后,我们制作一个音频视频捕获device并设置该视频以捕获视频输入。关于成功,我们将设备的值分配给camera

接下来,我们尝试创建视频的音频视频输入捕获输入,并将其添加到我们的会话中。该方法中的最后一个块将向会话添加视频输出并进行配置。这完成了会话配置。在这一点上,当我们到达方法的末尾时,session.commitConfiguration()的递延呼叫将执行。

代码-FrameManager

Camera文件夹中,创建一个新的Swift文件FrameManager.swift。该文件将保存从NSObject, ObservableObject派生的类,并通过实现captureOutput方法作为扩展名来符合AVCaptureVideoDataOutputSampleBufferDelegate

用以下代码替换本文件的自动生成的内容:

import AVFoundation

class FrameManager: NSObject, ObservableObject {

    /** ``shared`` a single instance of FrameManager.
    All the other codes in the app must use this single instance */
    static let shared = FrameManager()

    /** ``current`` stores the current pixel data from camera */
    @Published var current: CVPixelBuffer?

    /** ``videoOutputQueue`` a queue to receive camera video output */
    let videoOutputQueue = DispatchQueue(
        label: "yourdomain.expiry.VideoOutputQ",
        qos: .userInitiated,
        attributes: [],
        autoreleaseFrequency: .workItem)

    private override init() {
        super.init()
        CameraManager.shared.set(self, queue: videoOutputQueue)
    }
}

extension FrameManager: AVCaptureVideoDataOutputSampleBufferDelegate {
    /** ``captureOutput(_:didOutput:from:)`` sets the buffer data to ``current`` */
    func captureOutput(
        _ output: AVCaptureOutput,
        didOutput sampleBuffer: CMSampleBuffer,
        from connection: AVCaptureConnection
    ) {
        if let buffer = sampleBuffer.imageBuffer {
            DispatchQueue.main.async {
                self.current = buffer
            }
        }
    }
}

在上面的代码中,公共属性和方法的注释表示其目的。在captureOutput方法中,它在主线程上,我们更新self.current的值。

代码-contentViewModel

现在是时候创建我们的视图模型了。在项目的根部中制作一个新文件夹ViewModel。在此文件夹中,创建一个新的Swift文件ContentViewModel.swift,并用以下代码替换其Xcode自动生成的内容:

import CoreImage

class ContentViewModel: ObservableObject {

    @Published var frame: CGImage?
    @Published var error: Error?

    private let cameraManager = CameraManager.shared

    private let frameManager = FrameManager.shared

    init() {
        setupSubscriptions()
    }

    func setupSubscriptions() {
        cameraManager.$error
          .receive(on: RunLoop.main)
          .map { $0 }
          .assign(to: &$error)

        frameManager.$current
            .receive(on: RunLoop.main)
            .compactMap { buffer in
                if buffer != nil {
                    let inputImage = CIImage(cvPixelBuffer: buffer!)
                    let context = CIContext(options: nil)
                    return context.createCGImage(inputImage, from: inputImage.extent)
                } else {
                    return nil
                }
            }
            .assign(to: &$frame)
    }
}

在这里,我们设置了两个已发布的变量frame,该变量具有像素缓冲区数据和error,其中包含有关相关错误的信息。这两个变量将在Method setupSubscriptions中的设置中从Cameramanager接收其实时值。在frameManager.$current.compactMap街区内,我们将CVPixelBuffer转换为CGImage并将其分配给帧变量。将来,如果您想使用CIFilter,则该块是添加这些块的地方。

代码-FrameView,ControlView和ErrorView

让我们在项目的根位置中创建三个新文件。这些将是Swift UI视图文件。创建文件后,将其Xcode自动生成的内容替换为以下给出的内容:

文件:frameview.swift

import SwiftUI

struct FrameView: View {
    var image: CGImage?
    private let label = Text("Camera feed")

    var body: some View {
        if let image = image {
          GeometryReader { geometry in
            Image(image, scale: 1.0, orientation: .up, label: label)
              .resizable()
              .scaledToFill()
              .frame(
                width: geometry.size.width,
                height: geometry.size.height,
                alignment: .center)
              .clipped()
          }
        } else {
          Color.black
        }
    }
}

struct FrameView_Previews: PreviewProvider {
    static var previews: some View {
        FrameView()
    }
}

文件:errorview.swift

import SwiftUI

struct ErrorView: View {
    var error: Error?

    var body: some View {
        self.error != nil ? ErrorMessage(String(describing: self.error)) : nil
    }
}

func ErrorMessage(_ text: String) -> some View {
    return VStack {
        VStack {
            Text("Error Occured").font(.title).padding(.bottom, 5)
            Text(text)
        }.foregroundColor(.white).padding(10).background(Color.red)
        Spacer()
    }
}

struct ErrorView_Previews: PreviewProvider {
    static var previews: some View {
        ErrorView(error: CameraManager.CameraError.cameraUnavailable as Error)
    }
}

Image showing how error view looks on iOS

文件:controlview.swift

import SwiftUI

struct ControlView: View {
    var body: some View {
        VStack{
            Spacer()
            HStack {
                Button {
                    PhotoManager.take()
                } label: {
                    Image(systemName: "camera.fill")
                }.font(.largeTitle)
                    .buttonStyle(.borderless)
                    .controlSize(.large)
                    .tint(.accentColor)
                    .padding(10)
            }
        }
    }
}

struct ControlView_Previews: PreviewProvider {
    static var previews: some View {
        ControlView()
    }
}

请注意,我们尚未创建Photomanager。

此刻,您可以评论PhotoManager.take()函数调用并运行该应用以查看外观。它应该要求您在启动时和授予访问时访问摄像头访问权限,应该向您显示实时相机供稿。

Image showing screenshot of the app on macOS

现在,让我们通过将功能为相机按钮来完成应用程序。单击按钮,我们希望它将我们在屏幕上看到的照片存储为照片。这将通过Photomanager完成。在Camera文件夹中创建一个新的文件PhotoManager.swift。用以下代码替换其Xcode自动生成的内容:

import CoreImage
import Combine
import UniformTypeIdentifiers

class PhotoManager {
    private static var cancellable: AnyCancellable?
    static func take() {
        debugPrint("Clicked PhotoManager.take()")
        cancellable = FrameManager.shared.$current.first().sink { receiveValue in
            guard receiveValue != nil else {
                debugPrint("[W] PhotoManager.take: buffer returned nil")
                return
            }

            let inputImage = CIImage(cvPixelBuffer: receiveValue!)
            let context = CIContext(options: nil)
            let cgImage = context.createCGImage(inputImage, from: inputImage.extent)
            guard cgImage != nil else {
                debugPrint("[W] PhotoManager.take: CGImage is nil")
                return
            }
            self.save(image: cgImage!, filename: "my-image-test.png")
        }

    }

    static func save(image: CGImage, filename: String) {
        let cfdata: CFMutableData = CFDataCreateMutable(nil, 0)
        if let destination = CGImageDestinationCreateWithData(cfdata, String(describing: UTType.png) as CFString, 1, nil) {
            CGImageDestinationAddImage(destination, image, nil)
            if CGImageDestinationFinalize(destination) {
                debugPrint("[I] PhotoManager.save: saved image at \(destination)")
                do {
                    try (cfdata as Data).write(to: self.asURL(filename)!)
                    debugPrint("[I] PhotoManager.save: Saved image")
                } catch {
                    debugPrint("[E] PhotoManager.save: Failed to save image \(error)")
                }
            }
        }
        debugPrint("[I] PhotoManager.save: func completed")
    }

    static func asURL(_ filename: String) -> URL? {
        guard let documentsDirectory = FileManager.default.urls(
            for: .documentDirectory,
            in: .userDomainMask).first else {
            return nil
        }

        let url = documentsDirectory.appendingPathComponent(filename)
        debugPrint(".asURL:", url)
        return url
    }
}

如果在前面的步骤中,您从ControlView评论了PhotoManager.take(),现在可以删除它并再次运行该应用程序。这次点击相机图标按钮,将在您的设备上创建文件png图像。在日志输出中,您可以看到图像的完整路径。对我来说,那是在file:///Users/ash/Library/Containers/yourdomain.expiry/Data/Documents/my-image-test.png

这是一个示例应用程序,我作为我正在研究的更大应用程序的学习练习完成了。

您在本文中看到的代码具有巨大的改进范围。当您尝试这些代码创建自己的应用程序时,您将获得一些重构和增强想法。在评论中,我将链接添加到了我发现有用的一些资源。

ð—随意评论您的想法。愉快的编码!