Files
photo-live/apps/mobile/ios/Runner/UploadTaskStore.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

131 lines
4.7 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
/// 上传任务映射持久化(P3 计划 Task 8)。
///
/// `taskId → {注册参数, URLSession taskIdentifier, 执行计数}` 存 Application
/// Support 下 plist;对应 Android 侧 taskId→workId 映射由 WorkManager 自持
/// 的职责——iOS 无系统级自持映射,改为本插件显式落盘:
///
/// - App 被系统在后台拉起(或用户重启)后,`getAllTasks` 返回的系统任务经
/// taskIdentifier 反查 taskId,重挂事件归属(技术方案 §6.2「重新启动时
/// 重连系统会话并校准本地任务」);
/// - 仅存映射所需最小字段;进度快照不落盘(重联后 didSendBodyData 会
/// 继续产生新进度)。
///
/// 线程模型:通道回调在主线程、session delegate 在后台队列访问——全部
/// 经 [lock] 串行化;写盘频率低(注册/终态/取消),同步写原子 plist。
final class UploadTaskStore {
/// 单条任务映射(plist 可序列化字段)。
struct Entry {
var url: String
var method: String
var headers: [String: String]
var filePath: String
var uploadId: String?
var attempt: Int
/// 已执行传输次数(含首发;镜像 Kotlin MAX_WORK_ATTEMPTS 语义)。
var executions: Int
/// 对应 URLSessionTask.taskIdentifier(-1 = 尚未创建系统任务)。
var sessionTaskIdentifier: Int
}
private let lock = NSLock()
private let fileURL: URL
private var entries: [String: Entry] = [:]
init(directory: URL? = nil, fileName: String = "upload-task-map.plist") {
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(fileName)
if let raw = NSDictionary(contentsOf: fileURL) as? [String: [String: Any]] {
entries = raw.mapValues { Entry(plist: $0) }
}
}
// ── 读写 API(锁内) ──────────────────────────────────────────────
func entry(taskId: String) -> Entry? {
lock.lock()
defer { lock.unlock() }
return entries[taskId]
}
func taskId(sessionTaskIdentifier: Int) -> String? {
lock.lock()
defer { lock.unlock() }
return entries.first { $0.value.sessionTaskIdentifier == sessionTaskIdentifier }?.key
}
func allTaskIds() -> [String] {
lock.lock()
defer { lock.unlock() }
return Array(entries.keys)
}
func upsert(taskId: String, entry: Entry) {
lock.lock()
entries[taskId] = entry
persistLocked()
lock.unlock()
}
/// 原地修改并落盘(不存在则忽略)。
func mutate(taskId: String, _ transform: (inout Entry) -> Void) {
lock.lock()
guard var entry = entries[taskId] else {
lock.unlock()
return
}
transform(&entry)
entries[taskId] = entry
persistLocked()
lock.unlock()
}
func remove(taskId: String) {
lock.lock()
entries.removeValue(forKey: taskId)
persistLocked()
lock.unlock()
}
// ── plist 编解码 ─────────────────────────────────────────────────
private func persistLocked() {
let raw = entries.mapValues { $0.asPlist() } as NSDictionary
raw.write(to: fileURL, atomically: true)
}
}
private extension UploadTaskStore.Entry {
init(plist raw: [String: Any]) {
self.init(
url: raw["url"] as? String ?? "",
method: raw["method"] as? String ?? "PUT",
headers: (raw["headers"] as? [String: String]) ?? [:],
filePath: raw["filePath"] as? String ?? "",
uploadId: raw["uploadId"] as? String,
attempt: (raw["attempt"] as? NSNumber)?.intValue ?? 0,
executions: (raw["executions"] as? NSNumber)?.intValue ?? 1,
sessionTaskIdentifier: (raw["sessionTaskIdentifier"] as? NSNumber)?.intValue ?? -1
)
}
func asPlist() -> [String: Any] {
[
"url": url,
"method": method,
"headers": headers,
"filePath": filePath,
"uploadId": uploadId ?? "",
"attempt": attempt,
"executions": executions,
"sessionTaskIdentifier": sessionTaskIdentifier,
]
}
}