42e1c2e34b
Swift 侧(协议与 Android UploadPlugin.kt 逐字一致,编译/真机留 G3): - UploadBridge.swift:通道/方法/事件/失败码常量 + registerTask 参数解析 (镜像 UploadProtocol.kt) - UploadEventBus.swift:单调 seq + 终态 ack 缓存重放(镜像 UploadEventBus.kt),iOS 增强——未 ack 终态落盘(系统后台拉起投递的 回调在进程重启后同样重放,技术方案 §6.2 持久化原生回调) - UploadTaskStore.swift:taskId→URLSession taskIdentifier 映射 plist 持久化 (Application Support;对应 Android WorkManager 自持映射) - UploadPlugin.swift:URLSession background (com.photolive.photo_live.uploads)+ delegate 失败分类(2xx→completed+etag / 403→needsRenew / 4xx→HTTP_4XX / 网络→重试上限后 NETWORK,镜像 UploadPutExecutor/UploadWorker)、原生重试(5 次上限、10s 指数退避)、 进度 500ms 节流、queryTask/cancelTask/ackEvents、静默取消 - AppDelegate.swift:implicit engine 通道注册 + 启动重建会话 + handleEventsForBackgroundURLSession completionHandler 暂存 - Info.plist:UIBackgroundModes 有意不声明(background URLSession 无需; 计划措辞按技术方案 §6.2 修正,见 README-ios.md) - project.pbxproj 登记新 Swift 文件;README-ios.md(Xcode 步骤 + G3 验证 清单:实际文件上传/持久化回调窗口/服务端补偿/重启重联校准/强制退出/ 低电量延迟/100 张批量) Dart:NativeTransport 选择矩阵并入 iOS(Platform.isAndroid || isIOS, FORCE_DART_TRANSPORT 覆盖不变)——协议两侧一致,无新通道代码。 flutter analyze 0 issue;flutter test 219 全绿。 Co-Authored-By: Claude Code <noreply@anthropic.com>
174 lines
6.1 KiB
Swift
174 lines
6.1 KiB
Swift
import Foundation
|
||
|
||
/// 上传事件总线(session delegate → EventChannel,P3 计划 Task 8)。
|
||
///
|
||
/// 与 Android 侧 `UploadEventBus.kt` 同语义:
|
||
/// - 事件带单调 seq;**终态**(completed / needsRenew / failed)在 Dart
|
||
/// ack 前缓存(上限 [maxBuffered],超出丢最老),EventChannel 重新
|
||
/// listen 时重放——App 切后台恢复 / 引擎重建后不丢完成信号;
|
||
/// - 进度事件瞬态,不缓存不重放;
|
||
/// - 任务重新注册(renew 换 URL)或取消时 [purgeTask] 作废其缓存终态。
|
||
///
|
||
/// **进程死亡语义(iOS 增强并落盘)**:iOS 后台会话完成后系统可能在后台
|
||
/// 拉起 App(Dart 引擎未启动、无人监听事件)投递回调——终态事件除内存
|
||
/// 缓存外同步持久化到 Application Support plist(seq 计数器随行),下次
|
||
/// 进程内 EventChannel onListen 时一并重放(技术方案 §6.2「传输完成后
|
||
/// 持久记录原生回调」)。Android 侧等价场景由 WorkManager 自持任务、
|
||
/// 进程重启后 worker 重跑再发事件,无需落盘。
|
||
///
|
||
/// 事件负载为 [String: Any] 字典:null 值(etag/status 等)以 NSNull 表达
|
||
/// ——Flutter 标准编解码两侧均映射为 Dart null,与 Kotlin 显式 null 等价。
|
||
///
|
||
/// 线程模型:emit 可来自 session delegate 的后台队列;sink 由插件层包装
|
||
/// 为主线程投递(EventChannel 契约)。投递在锁内进行——sink 实现为非
|
||
/// 阻塞 DispatchQueue.main.async,锁内投递保证 seq 顺序。
|
||
final class UploadEventBus {
|
||
|
||
static let shared = UploadEventBus()
|
||
|
||
/// 缓存终态上限(防无限增长;超量丢最老——极端下 Dart 用 queryTask 校准)。
|
||
static let maxBuffered = 200
|
||
|
||
private let lock = NSLock()
|
||
private var sink: (([String: Any]) -> Void)?
|
||
private var seqGen = 0
|
||
private var unacked: [[String: Any]] = []
|
||
private let fileURL: URL
|
||
|
||
init(directory: URL? = nil) {
|
||
let dir =
|
||
directory
|
||
?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||
.appendingPathComponent("PhotoLiveUploads", isDirectory: true)
|
||
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||
fileURL = dir.appendingPathComponent("upload-unacked-events.plist")
|
||
loadPersisted()
|
||
}
|
||
|
||
// ── 事件出口(镜像 Kotlin UploadEventBus) ───────────────────────
|
||
|
||
func emitProgress(taskId: String, sent: Int64, total: Int64) {
|
||
emit(
|
||
[
|
||
"event": UploadBridge.eventProgress,
|
||
"taskId": taskId,
|
||
"sent": NSNumber(value: sent),
|
||
"total": NSNumber(value: total),
|
||
],
|
||
terminal: false
|
||
)
|
||
}
|
||
|
||
func emitCompleted(taskId: String, etag: String?) {
|
||
emit(
|
||
[
|
||
"event": UploadBridge.eventCompleted,
|
||
"taskId": taskId,
|
||
"etag": etag ?? NSNull(),
|
||
],
|
||
terminal: true
|
||
)
|
||
}
|
||
|
||
func emitNeedsRenew(taskId: String) {
|
||
emit(
|
||
[
|
||
"event": UploadBridge.eventNeedsRenew,
|
||
"taskId": taskId,
|
||
],
|
||
terminal: true
|
||
)
|
||
}
|
||
|
||
func emitFailed(taskId: String, code: String, status: Int? = nil) {
|
||
emit(
|
||
[
|
||
"event": UploadBridge.eventFailed,
|
||
"taskId": taskId,
|
||
"code": code,
|
||
"status": status.map { NSNumber(value: $0) } ?? NSNull(),
|
||
],
|
||
terminal: true
|
||
)
|
||
}
|
||
|
||
private func emit(_ event: [String: Any], terminal: Bool) {
|
||
lock.lock()
|
||
seqGen += 1
|
||
var stamped = event
|
||
stamped["seq"] = NSNumber(value: seqGen)
|
||
if terminal {
|
||
unacked.append(stamped)
|
||
while unacked.count > UploadEventBus.maxBuffered {
|
||
unacked.removeFirst()
|
||
}
|
||
persistLocked()
|
||
}
|
||
if let sink = sink {
|
||
sink(stamped)
|
||
}
|
||
lock.unlock()
|
||
}
|
||
|
||
/// EventChannel onListen:接管 sink 并重放缓存的终态事件。
|
||
func attach(_ sink: @escaping ([String: Any]) -> Void) {
|
||
lock.lock()
|
||
self.sink = sink
|
||
for event in unacked {
|
||
sink(event)
|
||
}
|
||
lock.unlock()
|
||
}
|
||
|
||
/// EventChannel onCancel。
|
||
func detach() {
|
||
lock.lock()
|
||
sink = nil
|
||
lock.unlock()
|
||
}
|
||
|
||
/// Dart ack:丢弃 seq ≤ 给定值的缓存终态。
|
||
func ack(seq: Int) {
|
||
lock.lock()
|
||
unacked.removeAll { (($0["seq"] as? NSNumber)?.intValue ?? 0) <= seq }
|
||
persistLocked()
|
||
lock.unlock()
|
||
}
|
||
|
||
/// 任务重新注册/取消:其缓存终态作废。
|
||
func purgeTask(taskId: String) {
|
||
lock.lock()
|
||
unacked.removeAll { $0["taskId"] as? String == taskId }
|
||
persistLocked()
|
||
lock.unlock()
|
||
}
|
||
|
||
// ── 终态落盘 / 恢复(进程死亡重放语义) ──────────────────────────
|
||
|
||
/// plist 不支持 null 值——持久化前剔除 NSNull(Dart 侧以缺失等同 null)。
|
||
private func persistLocked() {
|
||
let storable = unacked.map { event -> [String: Any] in
|
||
var out: [String: Any] = [:]
|
||
for (key, value) in event {
|
||
if let number = value as? NSNumber {
|
||
out[key] = number
|
||
} else if let string = value as? String {
|
||
out[key] = string
|
||
}
|
||
// NSNull(null 值)跳过。
|
||
}
|
||
return out
|
||
}
|
||
let raw: [String: Any] = ["seq": NSNumber(value: seqGen), "unacked": storable]
|
||
(raw as NSDictionary).write(to: fileURL, atomically: true)
|
||
}
|
||
|
||
private func loadPersisted() {
|
||
guard let raw = NSDictionary(contentsOf: fileURL) as? [String: Any] else {
|
||
return
|
||
}
|
||
seqGen = (raw["seq"] as? NSNumber)?.intValue ?? 0
|
||
unacked = raw["unacked"] as? [[String: Any]] ?? []
|
||
}
|
||
}
|