升级 Gradle → 8.14、AGP → 8.11、Kotlin → 2.2.20 JVM 堆降到 -Xmx4G

This commit is contained in:
2026-06-04 13:35:10 +08:00
parent 250f21a2b8
commit 66435302b3
22 changed files with 755 additions and 291 deletions

View File

@@ -45,6 +45,8 @@ post_install do |installer|
'$(inherited)',
'PERMISSION_CAMERA=1',
'PERMISSION_MICROPHONE=1',
'PERMISSION_PHOTOS=1',
'PERMISSION_PHOTOS_ADD_ONLY=1',
]
end
end

View File

@@ -30,6 +30,8 @@
<string>需要访问相机以显示预览并录制视频。</string>
<key>NSMicrophoneUsageDescription</key>
<string>需要访问麦克风以录制视频声音;未授权时将静音录制。</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>需要将录制的视频保存到相册。</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>

View File

@@ -1,5 +1,6 @@
import AVFoundation
import Flutter
import Photos
import UIKit
private enum RecordingState: String {
@@ -109,6 +110,9 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
private var audioInput: AVCaptureDeviceInput?
private var configured = false
private var latestOutputPath: String?
private var latestGallerySaved = true
private var latestGalleryErrorMessage: String?
private var pendingDisplayName: String?
private var recordingStartedAt: Date?
private var elapsedTimer: Timer?
private var pendingStopResult: FlutterResult?
@@ -170,7 +174,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
}
}
func startRecording(withAudio: Bool, result: @escaping FlutterResult) {
func startRecording(withAudio: Bool, displayName: String?, result: @escaping FlutterResult) {
guard previewView != nil else {
result(FlutterError(code: "NO_PREVIEW", message: "Camera preview is not attached", details: nil))
return
@@ -192,8 +196,11 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
return
}
let outputURL = try self.createOutputURL()
self.latestOutputPath = outputURL.path
self.pendingDisplayName = displayName
self.latestGallerySaved = true
self.latestGalleryErrorMessage = nil
let outputURL = try self.createOutputURL(displayName: displayName)
self.latestOutputPath = outputURL.lastPathComponent
self.recordingStartedAt = Date()
self.updateStatus(RecordingStatus(state: .recording, outputPath: outputURL.path))
self.movieOutput.startRecording(to: outputURL, recordingDelegate: self)
@@ -226,10 +233,16 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
guard self.movieOutput.isRecording else {
DispatchQueue.main.async {
result([
"outputPath": self.latestOutputPath,
var payload: [String: Any] = [
"outputPath": self.latestOutputPath as Any,
"status": self.currentStatusMap(),
])
"gallerySaved": self.latestGallerySaved,
]
if !self.latestGallerySaved {
payload["galleryErrorMessage"] =
self.latestGalleryErrorMessage ?? "保存到相册失败"
}
result(payload)
}
return
}
@@ -291,24 +304,104 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
pendingStopResult = nil
if let error {
latestGallerySaved = false
latestGalleryErrorMessage = error.localizedDescription
updateStatus(RecordingStatus(state: .error, outputPath: latestOutputPath, message: error.localizedDescription))
} else {
updateStatus(
RecordingStatus(
state: .previewing,
outputPath: latestOutputPath,
elapsedMillis: elapsedMillis()
)
)
finishStopRecording(stopResult: stopResult)
return
}
saveVideoToPhotoLibrary(fileURL: outputFileURL) { [weak self] success, message in
guard let self else { return }
self.latestGallerySaved = success
self.latestGalleryErrorMessage = message
if success {
self.updateStatus(
RecordingStatus(
state: .previewing,
outputPath: self.latestOutputPath,
elapsedMillis: self.elapsedMillis()
)
)
} else {
self.updateStatus(
RecordingStatus(
state: .error,
outputPath: self.latestOutputPath,
message: message ?? "保存到相册失败"
)
)
}
self.finishStopRecording(stopResult: stopResult)
}
}
private func finishStopRecording(stopResult: FlutterResult?) {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.stopElapsedTimer()
stopResult?([
"outputPath": self.latestOutputPath,
var payload: [String: Any] = [
"outputPath": self.latestOutputPath as Any,
"status": self.currentStatusMap(),
])
"gallerySaved": self.latestGallerySaved,
]
if !self.latestGallerySaved {
payload["galleryErrorMessage"] =
self.latestGalleryErrorMessage ?? "保存到相册失败,请开启相册权限"
}
stopResult?(payload)
}
}
private func saveVideoToPhotoLibrary(
fileURL: URL,
completion: @escaping (Bool, String?) -> Void
) {
let performSave = {
PHPhotoLibrary.shared().performChanges({
PHAssetCreationRequest.forAsset().addResource(with: .video, fileURL: fileURL, options: nil)
}) { success, error in
if success {
try? FileManager.default.removeItem(at: fileURL)
completion(true, nil)
} else {
completion(false, error?.localizedDescription ?? "保存到相册失败")
}
}
}
if #available(iOS 14, *) {
let status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
switch status {
case .authorized, .limited:
performSave()
case .notDetermined:
PHPhotoLibrary.requestAuthorization(for: .addOnly) { newStatus in
if newStatus == .authorized || newStatus == .limited {
performSave()
} else {
completion(false, "未授予相册权限")
}
}
default:
completion(false, "未授予相册权限")
}
} else {
let status = PHPhotoLibrary.authorizationStatus()
switch status {
case .authorized:
performSave()
case .notDetermined:
PHPhotoLibrary.requestAuthorization { newStatus in
if newStatus == .authorized {
performSave()
} else {
completion(false, "未授予相册权限")
}
}
default:
completion(false, "未授予相册权限")
}
}
}
@@ -370,7 +463,7 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
}
private func createOutputURL() throws -> URL {
private func createOutputURL(displayName: String?) throws -> URL {
let baseURL = try FileManager.default.url(
for: .documentDirectory,
in: .userDomainMask,
@@ -380,11 +473,23 @@ private final class RecordingCameraController: NSObject, AVCaptureFileOutputReco
let recordingsURL = baseURL.appendingPathComponent("recordings", isDirectory: true)
try FileManager.default.createDirectory(at: recordingsURL, withIntermediateDirectories: true)
let fileName = Self.resolveFileName(displayName: displayName)
return recordingsURL.appendingPathComponent(fileName)
}
private static func resolveFileName(displayName: String?) -> String {
let trimmed = displayName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmed.isEmpty {
let lower = trimmed.lowercased()
if lower.hasSuffix(".mov") || lower.hasSuffix(".mp4") {
return trimmed
}
return "\(trimmed).mov"
}
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyyMMdd_HHmmss"
let filename = "REC_\(formatter.string(from: Date())).mov"
return recordingsURL.appendingPathComponent(filename)
return "REC_\(formatter.string(from: Date())).mov"
}
private func updateStatus(_ next: RecordingStatus) {
@@ -450,7 +555,8 @@ final class RecordingPlugin: NSObject, FlutterPlugin, FlutterStreamHandler {
case "startRecording":
let args = call.arguments as? [String: Any]
let withAudio = args?["withAudio"] as? Bool ?? true
controller.startRecording(withAudio: withAudio, result: result)
let displayName = args?["displayName"] as? String
controller.startRecording(withAudio: withAudio, displayName: displayName, result: result)
case "stopRecording":
controller.stopRecording(result: result)
case "disposePreview":