fix: 修复扫码相机竞态并调整金额与浅色外观
串行处理相机配置及启停,补充四项生命周期回归测试。快捷金额去除多余小数,固定浅色模式,版本更新至 2.1.1(2010101)。
This commit is contained in:
@@ -436,7 +436,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1040101;
|
CURRENT_PROJECT_VERSION = 2010101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 2.1.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -502,7 +502,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1040101;
|
CURRENT_PROJECT_VERSION = 2010101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
"FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = (
|
||||||
@@ -526,7 +526,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 2.1.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
@@ -741,7 +741,7 @@
|
|||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
CODE_SIGN_ENTITLEMENTS = suixinkan/suixinkan.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1040101;
|
CURRENT_PROJECT_VERSION = 2010101;
|
||||||
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
DEVELOPMENT_TEAM = 56GVN5RNVN;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
ENABLE_USER_SCRIPT_SANDBOXING = NO;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
@@ -761,7 +761,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.4.1;
|
MARKETING_VERSION = 2.1.1;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"-ObjC",
|
"-ObjC",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>UIUserInterfaceStyle</key>
|
||||||
|
<string>Light</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ final class OfflineCollectionRegistrationViewController: BaseViewController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func quickAmountTapped(_ sender: UIButton) {
|
@objc private func quickAmountTapped(_ sender: UIButton) {
|
||||||
viewModel.updateAmount(OfflineCollectionMoney.apiAmount(sender.tag))
|
viewModel.updateAmount(OfflineCollectionMoney.displayAmount(sender.tag))
|
||||||
amountField.becomeFirstResponder()
|
amountField.becomeFirstResponder()
|
||||||
moveAmountCursorToEnd()
|
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 二维码扫描页。
|
/// AVFoundation 二维码扫描页。
|
||||||
final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOutputObjectsDelegate {
|
final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOutputObjectsDelegate {
|
||||||
|
|
||||||
|
/// 识别到有效二维码后返回内容,每次展示仅回调一次。
|
||||||
var onScanResult: ((String) -> Void)?
|
var onScanResult: ((String) -> Void)?
|
||||||
|
|
||||||
private let captureSession = AVCaptureSession()
|
private let captureSession = AVCaptureSession()
|
||||||
|
private lazy var captureController = QRCodeCaptureSessionController(session: captureSession)
|
||||||
private var previewLayer: AVCaptureVideoPreviewLayer?
|
private var previewLayer: AVCaptureVideoPreviewLayer?
|
||||||
private var isCameraConfigured = false
|
private var isCameraConfigured = false
|
||||||
|
private var isCameraConfiguring = false
|
||||||
|
private var isScannerVisible = false
|
||||||
private var hasHandledResult = false
|
private var hasHandledResult = false
|
||||||
|
|
||||||
private let maskView = UIView()
|
private let maskView = UIView()
|
||||||
@@ -63,6 +67,7 @@ final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOu
|
|||||||
|
|
||||||
override func viewDidAppear(_ animated: Bool) {
|
override func viewDidAppear(_ animated: Bool) {
|
||||||
super.viewDidAppear(animated)
|
super.viewDidAppear(animated)
|
||||||
|
isScannerVisible = true
|
||||||
updatePreviewLayout()
|
updatePreviewLayout()
|
||||||
startCaptureSessionIfNeeded()
|
startCaptureSessionIfNeeded()
|
||||||
}
|
}
|
||||||
@@ -75,6 +80,7 @@ final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOu
|
|||||||
|
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
isScannerVisible = false
|
||||||
stopCaptureSession()
|
stopCaptureSession()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,32 +111,44 @@ final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOu
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let device = AVCaptureDevice.default(for: .video),
|
guard !isCameraConfiguring else { return }
|
||||||
let input = try? AVCaptureDeviceInput(device: device) else {
|
isCameraConfiguring = true
|
||||||
showToast("无法打开相机")
|
captureController.configure({ [weak self, captureSession] in
|
||||||
return
|
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()
|
let output = AVCaptureMetadataOutput()
|
||||||
defer { captureSession.commitConfiguration() }
|
guard captureSession.canAddOutput(output) else {
|
||||||
|
captureSession.removeInput(input)
|
||||||
guard captureSession.canAddInput(input) else { return }
|
return false
|
||||||
captureSession.addInput(input)
|
}
|
||||||
|
captureSession.addOutput(output)
|
||||||
let output = AVCaptureMetadataOutput()
|
guard output.availableMetadataObjectTypes.contains(.qr) else {
|
||||||
guard captureSession.canAddOutput(output) else { return }
|
captureSession.removeOutput(output)
|
||||||
captureSession.addOutput(output)
|
captureSession.removeInput(input)
|
||||||
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
return false
|
||||||
output.metadataObjectTypes = [.qr]
|
}
|
||||||
|
output.setMetadataObjectsDelegate(self, queue: .main)
|
||||||
let layer = AVCaptureVideoPreviewLayer(session: captureSession)
|
output.metadataObjectTypes = [.qr]
|
||||||
layer.videoGravity = .resizeAspectFill
|
return true
|
||||||
view.layer.insertSublayer(layer, at: 0)
|
}, completion: { [weak self] succeeded in
|
||||||
previewLayer = layer
|
guard let self else { return }
|
||||||
isCameraConfigured = true
|
self.isCameraConfiguring = false
|
||||||
|
guard succeeded else {
|
||||||
updatePreviewLayout()
|
self.showToast("无法打开相机")
|
||||||
startCaptureSessionIfNeeded()
|
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() {
|
private func updatePreviewLayout() {
|
||||||
@@ -138,17 +156,12 @@ final class QRCodeScannerViewController: BaseViewController, AVCaptureMetadataOu
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startCaptureSessionIfNeeded() {
|
private func startCaptureSessionIfNeeded() {
|
||||||
guard isCameraConfigured, !captureSession.isRunning else { return }
|
guard isScannerVisible, isCameraConfigured, !hasHandledResult else { return }
|
||||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
captureController.setRunning(true)
|
||||||
self?.captureSession.startRunning()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopCaptureSession() {
|
private func stopCaptureSession() {
|
||||||
guard captureSession.isRunning else { return }
|
captureController.setRunning(false)
|
||||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
|
||||||
self?.captureSession.stopRunning()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateMask() {
|
private func updateMask() {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user