From 3823d10423ce9aac542f2952c804175f44605caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B1=89=E7=A7=8B?= <497055328@qq.com> Date: Fri, 18 Sep 2026 09:37:00 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=89=AB=E7=A0=81?= =?UTF-8?q?=E7=9B=B8=E6=9C=BA=E7=AB=9E=E6=80=81=E5=B9=B6=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E9=87=91=E9=A2=9D=E4=B8=8E=E6=B5=85=E8=89=B2=E5=A4=96=E8=A7=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 串行处理相机配置及启停,补充四项生命周期回归测试。快捷金额去除多余小数,固定浅色模式,版本更新至 2.1.1(2010101)。 --- suixinkan.xcodeproj/project.pbxproj | 12 +- suixinkan/Info.plist | 2 + ...CollectionRegistrationViewController.swift | 2 +- .../Scan/QRCodeCaptureSessionController.swift | 56 ++++++++++ .../UI/Scan/QRCodeScannerViewController.swift | 79 +++++++------ .../QRCodeCaptureSessionControllerTests.swift | 105 ++++++++++++++++++ 6 files changed, 216 insertions(+), 40 deletions(-) create mode 100644 suixinkan/UI/Scan/QRCodeCaptureSessionController.swift create mode 100644 suixinkanTests/QRCodeCaptureSessionControllerTests.swift diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index e6ec0c7..67d9242 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -436,7 +436,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1040101; + CURRENT_PROJECT_VERSION = 2010101; DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_USER_SCRIPT_SANDBOXING = NO; "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( @@ -460,7 +460,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.1; + MARKETING_VERSION = 2.1.1; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -502,7 +502,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1040101; + CURRENT_PROJECT_VERSION = 2010101; DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_USER_SCRIPT_SANDBOXING = NO; "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( @@ -526,7 +526,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.1; + MARKETING_VERSION = 2.1.1; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -741,7 +741,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1040101; + CURRENT_PROJECT_VERSION = 2010101; DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; @@ -761,7 +761,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.4.1; + MARKETING_VERSION = 2.1.1; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/suixinkan/Info.plist b/suixinkan/Info.plist index f1405ba..efa0b4f 100644 --- a/suixinkan/Info.plist +++ b/suixinkan/Info.plist @@ -2,6 +2,8 @@ + UIUserInterfaceStyle + Light CFBundleURLTypes diff --git a/suixinkan/UI/OfflineCollection/OfflineCollectionRegistrationViewController.swift b/suixinkan/UI/OfflineCollection/OfflineCollectionRegistrationViewController.swift index 9e3b7d6..51e466f 100644 --- a/suixinkan/UI/OfflineCollection/OfflineCollectionRegistrationViewController.swift +++ b/suixinkan/UI/OfflineCollection/OfflineCollectionRegistrationViewController.swift @@ -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() } diff --git a/suixinkan/UI/Scan/QRCodeCaptureSessionController.swift b/suixinkan/UI/Scan/QRCodeCaptureSessionController.swift new file mode 100644 index 0000000..d217eb7 --- /dev/null +++ b/suixinkan/UI/Scan/QRCodeCaptureSessionController.swift @@ -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() + } + } + } +} diff --git a/suixinkan/UI/Scan/QRCodeScannerViewController.swift b/suixinkan/UI/Scan/QRCodeScannerViewController.swift index 54c38e4..884c554 100644 --- a/suixinkan/UI/Scan/QRCodeScannerViewController.swift +++ b/suixinkan/UI/Scan/QRCodeScannerViewController.swift @@ -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() { diff --git a/suixinkanTests/QRCodeCaptureSessionControllerTests.swift b/suixinkanTests/QRCodeCaptureSessionControllerTests.swift new file mode 100644 index 0000000..48438a4 --- /dev/null +++ b/suixinkanTests/QRCodeCaptureSessionControllerTests.swift @@ -0,0 +1,105 @@ +import XCTest +@testable import suixinkan + +/// 回归验证相机配置期间启动、快速关闭和重复启动的顺序。 +final class QRCodeCaptureSessionControllerTests: XCTestCase { + func testStartWaitsForConfigurationCommit() { + let session = RecordingCaptureSession() + let queue = DispatchQueue(label: "qr-capture-test") + let controller = QRCodeCaptureSessionController(session: session, queue: queue) + let entered = DispatchSemaphore(value: 0) + let release = DispatchSemaphore(value: 0) + let completed = expectation(description: "configuration committed") + + controller.configure({ + entered.signal() + _ = release.wait(timeout: .now() + 5) + return true + }, completion: { succeeded in + XCTAssertTrue(Thread.isMainThread) + XCTAssertTrue(succeeded) + completed.fulfill() + }) + XCTAssertEqual(entered.wait(timeout: .now() + 5), .success) + controller.setRunning(true) + release.signal() + queue.sync { + XCTAssertEqual(session.events, ["begin", "commit", "start"]) + XCTAssertFalse(session.startedDuringConfiguration) + } + wait(for: [completed], timeout: 5) + } + + func testImmediateCloseStopsPendingStart() { + let session = RecordingCaptureSession() + let queue = DispatchQueue(label: "qr-capture-test") + let controller = QRCodeCaptureSessionController(session: session, queue: queue) + controller.configure({ true }, completion: { _ in }) + controller.setRunning(true) + controller.setRunning(false) + queue.sync { + XCTAssertEqual(session.events, ["begin", "commit", "start", "stop"]) + XCTAssertFalse(session.isRunning) + } + } + + func testRepeatedStartAndStopAreIdempotent() { + let session = RecordingCaptureSession() + let queue = DispatchQueue(label: "qr-capture-test") + let controller = QRCodeCaptureSessionController(session: session, queue: queue) + controller.configure({ true }, completion: { _ in }) + controller.setRunning(true) + controller.setRunning(true) + controller.setRunning(false) + controller.setRunning(false) + queue.sync { + XCTAssertEqual(session.events, ["begin", "commit", "start", "stop"]) + } + } + + func testFailedConfigurationCommitsButDoesNotStart() { + let session = RecordingCaptureSession() + let queue = DispatchQueue(label: "qr-capture-test") + let controller = QRCodeCaptureSessionController(session: session, queue: queue) + let completed = expectation(description: "configuration failed") + controller.configure({ false }, completion: { succeeded in + XCTAssertFalse(succeeded) + completed.fulfill() + }) + controller.setRunning(true) + queue.sync { + XCTAssertEqual(session.events, ["begin", "commit"]) + XCTAssertFalse(session.isRunning) + } + wait(for: [completed], timeout: 5) + } +} + +/// 记录调用顺序并检测配置事务内启动的会话替身,仅在测试串行队列读写。 +private final class RecordingCaptureSession: QRCodeCaptureSession { + var isRunning = false + var events: [String] = [] + var startedDuringConfiguration = false + private var configuring = false + + func beginConfiguration() { + configuring = true + events.append("begin") + } + + func commitConfiguration() { + configuring = false + events.append("commit") + } + + func startRunning() { + startedDuringConfiguration = configuring + isRunning = true + events.append("start") + } + + func stopRunning() { + isRunning = false + events.append("stop") + } +}