fix: 修复扫码相机竞态并调整金额与浅色外观

串行处理相机配置及启停,补充四项生命周期回归测试。快捷金额去除多余小数,固定浅色模式,版本更新至 2.1.1(2010101)。
This commit is contained in:
2026-09-18 09:37:00 +08:00
parent c4b0e7798b
commit 3823d10423
6 changed files with 216 additions and 40 deletions
@@ -338,7 +338,7 @@ final class OfflineCollectionRegistrationViewController: BaseViewController {
}
@objc private func quickAmountTapped(_ sender: UIButton) {
viewModel.updateAmount(OfflineCollectionMoney.apiAmount(sender.tag))
viewModel.updateAmount(OfflineCollectionMoney.displayAmount(sender.tag))
amountField.becomeFirstResponder()
moveAmountCursorToEnd()
}
@@ -0,0 +1,56 @@
import AVFoundation
/// 扫码采集会话的配置与运行接口,便于独立验证生命周期顺序。
protocol QRCodeCaptureSession: AnyObject {
/// 当前是否正在采集。
var isRunning: Bool { get }
/// 开始批量配置。
func beginConfiguration()
/// 提交批量配置。
func commitConfiguration()
/// 启动采集。
func startRunning()
/// 停止采集。
func stopRunning()
}
extension AVCaptureSession: QRCodeCaptureSession {}
/// 在同一串行队列中配置和启停相机,避免配置提交与启动交叉执行。
final class QRCodeCaptureSessionController {
private let session: QRCodeCaptureSession
private let queue: DispatchQueue
private var isConfigured = false
/// 创建会话生命周期控制器;队列必须为串行队列。
init(session: QRCodeCaptureSession,
queue: DispatchQueue = DispatchQueue(label: "com.zhifly.suixinkan.qr-capture")) {
self.session = session
self.queue = queue
}
/// 在配置事务内添加输入输出,提交完成后在主线程通知结果。
func configure(_ configuration: @escaping () -> Bool, completion: @escaping (Bool) -> Void) {
queue.async { [self] in
if !isConfigured {
session.beginConfiguration()
isConfigured = configuration()
session.commitConfiguration()
}
let succeeded = isConfigured
DispatchQueue.main.async { completion(succeeded) }
}
}
/// 按提交顺序更新运行状态,运行状态的读取也限定在会话队列内。
func setRunning(_ shouldRun: Bool) {
queue.async { [self] in
if shouldRun {
guard isConfigured, !session.isRunning else { return }
session.startRunning()
} else if session.isRunning {
session.stopRunning()
}
}
}
}
@@ -10,11 +10,15 @@ import UIKit
/// AVFoundation 二维码扫描页。
final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOutputObjectsDelegate {
/// 识别到有效二维码后返回内容,每次展示仅回调一次。
var onScanResult: ((String) -> Void)?
private let captureSession = AVCaptureSession()
private lazy var captureController = QRCodeCaptureSessionController(session: captureSession)
private var previewLayer: AVCaptureVideoPreviewLayer?
private var isCameraConfigured = false
private var isCameraConfiguring = false
private var isScannerVisible = false
private var hasHandledResult = false
private let maskView = UIView()
@@ -63,6 +67,7 @@ final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOu
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
isScannerVisible = true
updatePreviewLayout()
startCaptureSessionIfNeeded()
}
@@ -75,6 +80,7 @@ final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOu
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
isScannerVisible = false
stopCaptureSession()
}
@@ -105,32 +111,44 @@ final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOu
return
}
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device) else {
showToast("无法打开相机")
return
}
guard !isCameraConfiguring else { return }
isCameraConfiguring = true
captureController.configure({ [weak self, captureSession] in
guard let self,
let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
captureSession.canAddInput(input) else { return false }
captureSession.addInput(input)
captureSession.beginConfiguration()
defer { captureSession.commitConfiguration() }
guard captureSession.canAddInput(input) else { return }
captureSession.addInput(input)
let output = AVCaptureMetadataOutput()
guard captureSession.canAddOutput(output) else { return }
captureSession.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
output.metadataObjectTypes = [.qr]
let layer = AVCaptureVideoPreviewLayer(session: captureSession)
layer.videoGravity = .resizeAspectFill
view.layer.insertSublayer(layer, at: 0)
previewLayer = layer
isCameraConfigured = true
updatePreviewLayout()
startCaptureSessionIfNeeded()
let output = AVCaptureMetadataOutput()
guard captureSession.canAddOutput(output) else {
captureSession.removeInput(input)
return false
}
captureSession.addOutput(output)
guard output.availableMetadataObjectTypes.contains(.qr) else {
captureSession.removeOutput(output)
captureSession.removeInput(input)
return false
}
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
return true
}, completion: { [weak self] succeeded in
guard let self else { return }
self.isCameraConfiguring = false
guard succeeded else {
self.showToast("无法打开相机")
return
}
let layer = AVCaptureVideoPreviewLayer(session: self.captureSession)
layer.videoGravity = .resizeAspectFill
self.view.layer.insertSublayer(layer, at: 0)
self.previewLayer = layer
self.isCameraConfigured = true
self.updatePreviewLayout()
self.startCaptureSessionIfNeeded()
})
}
private func updatePreviewLayout() {
@@ -138,17 +156,12 @@ final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOu
}
private func startCaptureSessionIfNeeded() {
guard isCameraConfigured, !captureSession.isRunning else { return }
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.captureSession.startRunning()
}
guard isScannerVisible, isCameraConfigured, !hasHandledResult else { return }
captureController.setRunning(true)
}
private func stopCaptureSession() {
guard captureSession.isRunning else { return }
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
self?.captureSession.stopRunning()
}
captureController.setRunning(false)
}
private func updateMask() {