Files
photo-live/apps/mobile/ios/Runner/UploadEventBus.swift
T
浅念 42e1c2e34b 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>
2026-09-19 03:42:49 +08:00

174 lines
6.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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]] ?? []
}
}