|
|
import SwiftUI
|
|
|
import AVFoundation
|
|
|
import AudioToolbox
|
|
|
import Combine
|
|
|
|
|
|
// MARK: - 扫描器视图模型
|
|
|
class ScannerViewModel: NSObject, ObservableObject, AVCaptureMetadataOutputObjectsDelegate {
|
|
|
@Published var detectedCodes: [DetectedCode] = []
|
|
|
@Published var showAlert = false
|
|
|
@Published var cameraAuthorizationStatus: AVAuthorizationStatus = .notDetermined
|
|
|
@Published var showPermissionAlert = false
|
|
|
@Published var isTorchOn = false
|
|
|
|
|
|
var captureSession: AVCaptureSession!
|
|
|
private var metadataOutput: AVCaptureMetadataOutput?
|
|
|
private var videoDevice: AVCaptureDevice?
|
|
|
private var isProcessingDetection = false // 添加处理状态标记
|
|
|
|
|
|
override init() {
|
|
|
super.init()
|
|
|
checkCameraPermission()
|
|
|
}
|
|
|
|
|
|
// MARK: - 相机权限管理
|
|
|
|
|
|
private func checkCameraPermission() {
|
|
|
logInfo("🔍 检查相机权限状态", className: "ScannerViewModel")
|
|
|
|
|
|
switch AVCaptureDevice.authorizationStatus(for: .video) {
|
|
|
case .authorized:
|
|
|
logInfo("✅ 相机权限已授权", className: "ScannerViewModel")
|
|
|
cameraAuthorizationStatus = .authorized
|
|
|
setupCaptureSession()
|
|
|
|
|
|
case .notDetermined:
|
|
|
logInfo("❓ 相机权限未确定,请求权限", className: "ScannerViewModel")
|
|
|
requestCameraPermission()
|
|
|
|
|
|
case .denied, .restricted:
|
|
|
logWarning("❌ 相机权限被拒绝或受限", className: "ScannerViewModel")
|
|
|
cameraAuthorizationStatus = AVCaptureDevice.authorizationStatus(for: .video)
|
|
|
showPermissionAlert = true
|
|
|
|
|
|
@unknown default:
|
|
|
logWarning("❓ 未知的相机权限状态", className: "ScannerViewModel")
|
|
|
cameraAuthorizationStatus = .notDetermined
|
|
|
}
|
|
|
}
|
|
|
|
|
|
private func requestCameraPermission() {
|
|
|
logInfo("🔐 请求相机权限", className: "ScannerViewModel")
|
|
|
|
|
|
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
|
|
|
DispatchQueue.main.async {
|
|
|
if granted {
|
|
|
logInfo("✅ 相机权限请求成功", className: "ScannerViewModel")
|
|
|
self?.cameraAuthorizationStatus = .authorized
|
|
|
self?.setupCaptureSession()
|
|
|
} else {
|
|
|
logWarning("❌ 相机权限请求被拒绝", className: "ScannerViewModel")
|
|
|
self?.cameraAuthorizationStatus = .denied
|
|
|
self?.showPermissionAlert = true
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func openSettings() {
|
|
|
logInfo("⚙️ 打开系统设置", className: "ScannerViewModel")
|
|
|
|
|
|
if let settingsUrl = URL(string: UIApplication.openSettingsURLString) {
|
|
|
UIApplication.shared.open(settingsUrl) { success in
|
|
|
if success {
|
|
|
logInfo("✅ 成功打开系统设置", className: "ScannerViewModel")
|
|
|
} else {
|
|
|
logWarning("⚠️ 打开系统设置失败", className: "ScannerViewModel")
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func refreshCameraPermission() {
|
|
|
logInfo("🔍 重新检查相机权限状态", className: "ScannerViewModel")
|
|
|
checkCameraPermission()
|
|
|
}
|
|
|
|
|
|
// MARK: - 相机设置
|
|
|
|
|
|
private func setupCaptureSession() {
|
|
|
captureSession = AVCaptureSession()
|
|
|
|
|
|
guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else {
|
|
|
showAlert = true
|
|
|
return
|
|
|
}
|
|
|
|
|
|
// 保存视频设备引用,用于手电筒控制
|
|
|
videoDevice = videoCaptureDevice
|
|
|
|
|
|
let videoInput: AVCaptureDeviceInput
|
|
|
|
|
|
do {
|
|
|
videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
|
|
|
} catch {
|
|
|
showAlert = true
|
|
|
return
|
|
|
}
|
|
|
|
|
|
if captureSession.canAddInput(videoInput) {
|
|
|
captureSession.addInput(videoInput)
|
|
|
} else {
|
|
|
showAlert = true
|
|
|
return
|
|
|
}
|
|
|
|
|
|
metadataOutput = AVCaptureMetadataOutput()
|
|
|
|
|
|
if let metadataOutput = metadataOutput,
|
|
|
captureSession.canAddOutput(metadataOutput) {
|
|
|
captureSession.addOutput(metadataOutput)
|
|
|
metadataOutput.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
|
|
metadataOutput.metadataObjectTypes = [.qr, .ean8, .ean13, .code128, .code39, .upce, .pdf417, .aztec]
|
|
|
} else {
|
|
|
showAlert = true
|
|
|
return
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 扫描控制
|
|
|
|
|
|
func startScanning() {
|
|
|
logInfo("🔄 开始扫描", className: "ScannerViewModel")
|
|
|
|
|
|
// 检查会话是否已经在运行
|
|
|
if captureSession?.isRunning == true {
|
|
|
logInfo("ℹ️ 扫描会话已经在运行", className: "ScannerViewModel")
|
|
|
return
|
|
|
}
|
|
|
|
|
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
|
guard let self = self else { return }
|
|
|
|
|
|
self.captureSession?.startRunning()
|
|
|
|
|
|
// 检查启动状态
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
|
if self.captureSession?.isRunning == true {
|
|
|
logInfo("✅ 扫描会话启动成功", className: "ScannerViewModel")
|
|
|
} else {
|
|
|
logWarning("⚠️ 扫描会话启动失败", className: "ScannerViewModel")
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func stopScanning() {
|
|
|
logInfo("🔄 停止扫描", className: "ScannerViewModel")
|
|
|
|
|
|
// 立即设置处理标记,防止新的检测被处理
|
|
|
isProcessingDetection = true
|
|
|
|
|
|
// 检查会话是否在运行
|
|
|
if captureSession?.isRunning == true {
|
|
|
// 立即停止扫描会话
|
|
|
captureSession?.stopRunning()
|
|
|
logInfo("✅ 扫描会话已停止", className: "ScannerViewModel")
|
|
|
} else {
|
|
|
logInfo("ℹ️ 扫描会话已经停止", className: "ScannerViewModel")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// 暂停相机功能(用于预览暂停状态)
|
|
|
func pauseCamera() {
|
|
|
logInfo("⏸️ 暂停相机功能", className: "ScannerViewModel")
|
|
|
|
|
|
// 设置处理标记
|
|
|
isProcessingDetection = true
|
|
|
|
|
|
// 停止会话(但保留配置)
|
|
|
if captureSession?.isRunning == true {
|
|
|
captureSession?.stopRunning()
|
|
|
logInfo("✅ 相机会话已暂停", className: "ScannerViewModel")
|
|
|
} else {
|
|
|
logInfo("ℹ️ 相机会话已经停止", className: "ScannerViewModel")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// 恢复相机功能(用于恢复扫描)
|
|
|
func resumeCamera() {
|
|
|
logInfo("▶️ 恢复相机功能", className: "ScannerViewModel")
|
|
|
|
|
|
// 检查相机权限
|
|
|
guard cameraAuthorizationStatus == .authorized else {
|
|
|
logWarning("❌ 相机权限未授权,无法恢复相机", className: "ScannerViewModel")
|
|
|
return
|
|
|
}
|
|
|
|
|
|
// 检查会话配置
|
|
|
if captureSession == nil || captureSession.inputs.isEmpty || captureSession.outputs.isEmpty {
|
|
|
logInfo("🔄 重新设置相机会话", className: "ScannerViewModel")
|
|
|
setupCaptureSession()
|
|
|
}
|
|
|
|
|
|
// 重置处理标记
|
|
|
isProcessingDetection = false
|
|
|
|
|
|
// 启动相机会话
|
|
|
if captureSession?.isRunning != true {
|
|
|
logInfo("🚀 启动相机会话", className: "ScannerViewModel")
|
|
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
|
self?.captureSession?.startRunning()
|
|
|
|
|
|
DispatchQueue.main.async {
|
|
|
if self?.captureSession?.isRunning == true {
|
|
|
logInfo("✅ 相机会话启动成功", className: "ScannerViewModel")
|
|
|
} else {
|
|
|
logWarning("⚠️ 相机会话启动失败", className: "ScannerViewModel")
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
logInfo("✅ 相机功能已恢复", className: "ScannerViewModel")
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func resetDetection() {
|
|
|
DispatchQueue.main.async {
|
|
|
logInfo("🔄 重置检测状态,清空 detectedCodes", className: "ScannerViewModel")
|
|
|
self.detectedCodes = []
|
|
|
self.isProcessingDetection = false // 重置处理标记
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func isSessionRunning() -> Bool {
|
|
|
return captureSession?.isRunning == true
|
|
|
}
|
|
|
|
|
|
func checkSessionStatus() {
|
|
|
let isRunning = captureSession?.isRunning == true
|
|
|
logInfo("📊 扫描会话状态检查: \(isRunning ? "运行中" : "已停止")", className: "ScannerViewModel")
|
|
|
|
|
|
if !isRunning {
|
|
|
logWarning("⚠️ 扫描会话未运行,尝试重新启动", className: "ScannerViewModel")
|
|
|
startScanning()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
func restartScanning() {
|
|
|
logInfo("🔄 重新开始扫描", className: "ScannerViewModel")
|
|
|
|
|
|
// 先停止当前会话
|
|
|
if captureSession?.isRunning == true {
|
|
|
logInfo("🔄 停止当前运行的扫描会话", className: "ScannerViewModel")
|
|
|
captureSession?.stopRunning()
|
|
|
}
|
|
|
|
|
|
// 重置检测状态
|
|
|
resetDetection()
|
|
|
|
|
|
// 延迟后重新启动会话
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
|
|
|
logInfo("🔄 准备重新启动扫描会话", className: "ScannerViewModel")
|
|
|
|
|
|
// 在后台线程启动会话
|
|
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
|
self?.captureSession?.startRunning()
|
|
|
|
|
|
// 检查会话状态
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
|
|
|
if self?.captureSession?.isRunning == true {
|
|
|
logInfo("✅ 扫描会话已成功重新启动", className: "ScannerViewModel")
|
|
|
} else {
|
|
|
logWarning("⚠️ 扫描会话启动失败", className: "ScannerViewModel")
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - AVCaptureMetadataOutputObjectsDelegate
|
|
|
|
|
|
func metadataOutput(_ output: AVCaptureMetadataOutput,
|
|
|
didOutput metadataObjects: [AVMetadataObject],
|
|
|
from connection: AVCaptureConnection) {
|
|
|
|
|
|
// 防止重复处理检测结果
|
|
|
guard !isProcessingDetection else {
|
|
|
logInfo("⚠️ 正在处理检测结果,忽略新的检测", className: "ScannerViewModel")
|
|
|
return
|
|
|
}
|
|
|
|
|
|
logInfo("metadataOutput 被调用,检测到 \(metadataObjects.count) 个对象", className: "ScannerViewModel")
|
|
|
|
|
|
// 设置处理标记
|
|
|
isProcessingDetection = true
|
|
|
|
|
|
// 震动反馈
|
|
|
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
|
|
|
|
|
|
// 停止扫描
|
|
|
stopScanning()
|
|
|
|
|
|
// 处理所有检测到的条码
|
|
|
var codes: [DetectedCode] = []
|
|
|
|
|
|
for metadataObject in metadataObjects {
|
|
|
if let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject,
|
|
|
let stringValue = readableObject.stringValue {
|
|
|
|
|
|
let codeType = getBarcodeTypeString(from: readableObject.type)
|
|
|
let bounds = readableObject.bounds
|
|
|
|
|
|
let detectedCode = DetectedCode(
|
|
|
type: codeType,
|
|
|
content: stringValue,
|
|
|
bounds: bounds
|
|
|
)
|
|
|
|
|
|
codes.append(detectedCode)
|
|
|
logInfo("创建 DetectedCode: 类型=\(codeType), 内容=\(stringValue)", className: "ScannerViewModel")
|
|
|
}
|
|
|
}
|
|
|
|
|
|
logInfo("准备更新 detectedCodes,数量: \(codes.count)", className: "ScannerViewModel")
|
|
|
|
|
|
// 更新检测到的条码列表
|
|
|
DispatchQueue.main.async {
|
|
|
logInfo("在主线程更新 detectedCodes", className: "ScannerViewModel")
|
|
|
self.detectedCodes = codes
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 手电筒控制
|
|
|
|
|
|
/// 检查设备是否支持手电筒
|
|
|
var isTorchAvailable: Bool {
|
|
|
guard let device = videoDevice else { return false }
|
|
|
return device.hasTorch && device.isTorchAvailable
|
|
|
}
|
|
|
|
|
|
/// 切换手电筒状态
|
|
|
func toggleTorch() {
|
|
|
guard let device = videoDevice else {
|
|
|
logWarning("❌ 没有可用的视频设备", className: "ScannerViewModel")
|
|
|
return
|
|
|
}
|
|
|
|
|
|
guard device.hasTorch && device.isTorchAvailable else {
|
|
|
logWarning("❌ 设备不支持手电筒", className: "ScannerViewModel")
|
|
|
return
|
|
|
}
|
|
|
|
|
|
do {
|
|
|
try device.lockForConfiguration()
|
|
|
|
|
|
if isTorchOn {
|
|
|
// 关闭手电筒
|
|
|
device.torchMode = .off
|
|
|
isTorchOn = false
|
|
|
logInfo("🔦 手电筒已关闭", className: "ScannerViewModel")
|
|
|
} else {
|
|
|
// 打开手电筒
|
|
|
try device.setTorchModeOn(level: 1.0)
|
|
|
isTorchOn = true
|
|
|
logInfo("🔦 手电筒已打开", className: "ScannerViewModel")
|
|
|
}
|
|
|
|
|
|
device.unlockForConfiguration()
|
|
|
|
|
|
} catch {
|
|
|
logError("❌ 手电筒控制失败: \(error.localizedDescription)", className: "ScannerViewModel")
|
|
|
device.unlockForConfiguration()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/// 关闭手电筒
|
|
|
func turnOffTorch() {
|
|
|
guard let device = videoDevice else { return }
|
|
|
|
|
|
do {
|
|
|
try device.lockForConfiguration()
|
|
|
device.torchMode = .off
|
|
|
isTorchOn = false
|
|
|
device.unlockForConfiguration()
|
|
|
logInfo("🔦 手电筒已关闭", className: "ScannerViewModel")
|
|
|
} catch {
|
|
|
logError("❌ 关闭手电筒失败: \(error.localizedDescription)", className: "ScannerViewModel")
|
|
|
device.unlockForConfiguration()
|
|
|
}
|
|
|
}
|
|
|
|
|
|
// MARK: - 条形码类型转换
|
|
|
|
|
|
/// 获取条形码类型的可读字符串
|
|
|
private func getBarcodeTypeString(from metadataType: AVMetadataObject.ObjectType) -> String {
|
|
|
switch metadataType {
|
|
|
case .ean8:
|
|
|
return "EAN-8"
|
|
|
case .ean13:
|
|
|
return "EAN-13"
|
|
|
case .upce:
|
|
|
return "UPC-E"
|
|
|
case .code39:
|
|
|
return "Code 39"
|
|
|
case .code93:
|
|
|
return "Code 93"
|
|
|
case .code128:
|
|
|
return "Code 128"
|
|
|
case .itf14:
|
|
|
return "ITF-14"
|
|
|
case .pdf417:
|
|
|
return "PDF417"
|
|
|
case .qr:
|
|
|
return "QR Code"
|
|
|
case .dataMatrix:
|
|
|
return "Data Matrix"
|
|
|
case .aztec:
|
|
|
return "Aztec"
|
|
|
default:
|
|
|
// 处理可能包含前缀的类型字符串
|
|
|
let typeString = metadataType.rawValue
|
|
|
if typeString.contains("org.gs1.") {
|
|
|
// 移除 org.gs1. 前缀
|
|
|
let cleanType = typeString.replacingOccurrences(of: "org.gs1.", with: "")
|
|
|
return cleanType.uppercased()
|
|
|
}
|
|
|
return typeString
|
|
|
}
|
|
|
}
|
|
|
} |