在本文中,我正在写下我的经验和代码,这些代码旨在使在Swift UI中使用该应用程序,该应用程序使用用户摄像头并在屏幕上显示实时供稿。该应用在MacOS(经过苹果硅测试)和iOS中起作用。
开始,在Xcode中创建一个新的多平台项目。我在M1上使用了Xcode版本14.2(14C18)。
设置 - 权限
由于我们需要在Xcode中使用摄像头,因此我们应该通过xcodeproj
文件启用它。单击XCode中的此文件,它将打开一个不错的视图,以查看和编辑项目的设置。该文件以您的项目(例如myapp.xcodeproj
)命名。我将我的项目命名为 expiry ,所以我的文件是expiry.xcodeproj
。
单击签名和能力。那里启用了一个相机的复选框。
然后单击 info ,然后添加一个新条目。这个与编辑info.plist文件相同。我们正在通过UI进行。要在此处添加新密钥,请将悬停在任何现有条目上。它将显示一个 +按钮;单击下面添加下面的新条目。现在,对于 key 列,type 隐私 - 摄像机用法描述。一旦您开始键入,UI将向您显示可用键的下拉。
添加键后,单击 value 列,然后键入$(PRODUCT_NAME) camera use
。您可以跳过 type 列,因为它是自动集合到String
的。
应用结构
由于这是一个简单的相机应用程序,因此我们将只有视图,ViewModel和应用程序中的经理。 MACOS上的最终应用将如下所示:
应用结构 - 视图
我们将在应用程序中具有以下视图:
-
ContentView
-这是应用程序的主视图。 Xcode将此文件自动创建为ContentView.swift
。我们将在短期内编辑 -
FrameView
-此视图显示相机输出。我们将为此 创建一个新文件
-
ErrorView
-当我们有一些与用户显示给用户的摄像机相关错误时,这将显示。对于此视图,我们将创建一个新文件 -
ControlView
-此视图将有一个按钮可以拍摄照片。对于此视图,我们将创建一个新文件
应用结构 - ViewModels
要处理视图的业务逻辑,我们将拥有单独的类。对于此应用,我们只需要一个视图模型:
-
ContentViewModel
-这要照顾主要的视频和错误状态流逻辑。我们将在一个名为ViewModel
的文件夹中创建一个新文件(也是我们新创建的)
应用结构 - 经理
要处理设备,流,图像和文件io等的复杂逻辑,我们将拥有经理类。这些将存储在一个名为Camera
的文件夹中,因为所有这些类都将与相机及其输出管理有关。
-
CameraManager
-此管理设备,配置,会话,队列(用于视频输出),权限和错误状态 -
FrameManager
-这将启动相机管理器并读取相机输出 -
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
需要一些解释-checkPermissions
和configureCaptureSession
。让我们详细讨论这两个。
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)
}
}
文件: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()
函数调用并运行该应用以查看外观。它应该要求您在启动时和授予访问时访问摄像头访问权限,应该向您显示实时相机供稿。
现在,让我们通过将功能为相机按钮来完成应用程序。单击按钮,我们希望它将我们在屏幕上看到的照片存储为照片。这将通过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
。
这是一个示例应用程序,我作为我正在研究的更大应用程序的学习练习完成了。
您在本文中看到的代码具有巨大的改进范围。当您尝试这些代码创建自己的应用程序时,您将获得一些重构和增强想法。在评论中,我将链接添加到了我发现有用的一些资源。
ð—随意评论您的想法。愉快的编码!