feat(mobile): ios background upload bridge (compile-gated at G3)

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>
This commit is contained in:
浅念
2026-09-19 03:42:49 +08:00
parent 4e35bdc0b2
commit 42e1c2e34b
11 changed files with 1146 additions and 33 deletions
+173
View File
@@ -0,0 +1,173 @@
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]] ?? []
}
}