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

532 lines
21 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 Flutter
import Foundation
/// 上传桥接插件(P3 计划 Task 8;协议与 Android `UploadPlugin.kt` 逐字对齐)。
///
/// - MethodChannel `photolive/uploads`:registerTask(入队后台 URLSession
/// upload task)/ cancelTask / queryTask / ackEvents;
/// - EventChannel `photolive/uploads/events`:[UploadEventBus] 终态/进度
/// 事件(终态未 ack 缓存重放 + **跨进程落盘**,见总线文档);
/// - 实现 = URLSession background(identifier `com.photolive.photo_live.uploads`):
/// 传输由系统调度,App 退后台/锁屏后继续;完成后系统在后台拉起 App 投递
/// 回调(`handleEventsForBackgroundURLSession` → 重建会话重挂 delegate)。
///
/// **进程死亡语义**:iOS 对后台会话事件会在需要时重新拉起 App(区别于
/// Android WorkManager 自持重跑)——本插件启动即重建会话(`ensureSession`),
/// delegate 回调照常入总线;终态已落盘,Dart 引擎下次 onListen 时重放,
/// 镜像 Android 的缓存重放语义。用户强制退出后系统不再代跑,重新启动时
/// 由重联 + 重放校准(技术方案 §6.2)。
///
/// 失败分类(didCompleteWithError,镜像 Kotlin `UploadPutExecutor`):
/// 2xx → completed(带 ETag);403 → needsRenew;其余 4xx → failed
/// HTTP_4XX;5xx / NSURLErrorDomain 网络错误 → 原生侧重试(上限
/// [maxWorkAttempts],指数退避初始 10s——镜像 WorkManager 语义;App 挂起
/// 时退避定时器暂停,唤醒后续跑),耗尽后 failed SERVER_ERROR / NETWORK;
/// 源文件缺失(注册时预检)→ failed SOURCE_MISSING。
final class UploadPlugin: NSObject {
static let shared = UploadPlugin()
/// 传输尝试上限(含首发;对齐 Kotlin UploadWorker.MAX_WORK_ATTEMPTS)。
static let maxWorkAttempts = 5
/// 重试退避初始间隔(秒;指数翻倍,对齐 Android WorkManager 10s 初始)。
static let retryBackoffBaseSeconds: Double = 10
/// 进度事件节流(秒;Android 侧每 64KB 块发事件、通知 500ms 节流——
/// iOS 统一在事件层 500ms 节流,终值(sent==total)必发)。
static let progressThrottleSeconds: Double = 0.5
private let lock = NSLock()
private let bus = UploadEventBus.shared
let store = UploadTaskStore()
private var session: URLSession?
private var sessionDelegate: UploadSessionDelegate?
/// 运行期内存态(锁内):活动系统任务 / 进度快照 / 静默取消标记 /
/// 进度节流时间戳 / 系统后台回调。
private var activeTasks: [Int: URLSessionTask] = [:]
private var lastProgress: [String: (sent: Int64, total: Int64)] = [:]
private var quietCancelIdentifiers: Set<Int> = []
private var progressEmittedAt: [String: Double] = [:]
private var backgroundCompletionHandler: (() -> Void)?
// ── 通道注册(AppDelegate 调用) ─────────────────────────────────
static func register(with messenger: FlutterBinaryMessenger) {
FlutterMethodChannel(
name: UploadBridge.methodChannel,
binaryMessenger: messenger
)
.setMethodCallHandler { call, result in
UploadPlugin.shared.handleMethodCall(call, result: result)
}
FlutterEventChannel(
name: UploadBridge.eventChannel,
binaryMessenger: messenger
)
.setStreamHandler(UploadEventStreamHandler())
}
/// 后台会话重建(启动 / 系统后台拉起时调用;幂等)。
func ensureSession() {
lock.lock()
guard session == nil else {
lock.unlock()
return
}
let config = URLSessionConfiguration.background(
withIdentifier: UploadBridge.sessionIdentifier
)
config.isDiscretionary = false
config.sessionSendsLaunchEvents = true
// 无网时挂起等待而非立即失败(镜像 Android NetworkType.CONNECTED 约束)。
config.waitsForConnectivity = true
let delegate = UploadSessionDelegate(plugin: self)
sessionDelegate = delegate
session = URLSession(configuration: config, delegate: delegate, delegateQueue: nil)
lock.unlock()
}
/// 系统后台拉起回调暂存(urlSessionDidFinishEvents 时消费)。
func storeBackgroundCompletionHandler(_ handler: @escaping () -> Void) {
lock.lock()
backgroundCompletionHandler = handler
lock.unlock()
}
func finishBackgroundEvents() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.lock.lock()
let handler = self.backgroundCompletionHandler
self.backgroundCompletionHandler = nil
self.lock.unlock()
handler?()
}
}
// ── MethodChannel(镜像 Kotlin handleMethodCall) ────────────────
private func handleMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case UploadBridge.methodRegister:
handleRegister(call, result: result)
case UploadBridge.methodCancel:
handleCancel(call, result: result)
case UploadBridge.methodQuery:
handleQuery(call, result: result)
case UploadBridge.methodAck:
handleAck(call, result: result)
default:
result(FlutterMethodNotImplemented)
}
}
private func handleRegister(_ call: FlutterMethodCall, result: FlutterResult) {
guard let raw = call.arguments as? [String: Any] else {
result(FlutterError(code: "bad_args", message: "arguments must be a Map", details: nil))
return
}
do {
let spec = try parseRegisterSpec(raw)
// 重注册(renew 换 URL / 崩溃恢复重驱):作废旧缓存终态 +
// REPLACE 旧系统任务(防同任务双跑;镜像 Kotlin unique work REPLACE)。
bus.purgeTask(taskId: spec.taskId)
try startTask(spec: spec)
result(true)
} catch let error as UploadBridgeBadArgsError {
result(FlutterError(code: "bad_args", message: error.message, details: nil))
} catch {
result(FlutterError(code: "enqueue_failed", message: "\(error)", details: nil))
}
}
private func handleCancel(_ call: FlutterMethodCall, result: FlutterResult) {
guard let taskId = taskChannelArg(call, key: "taskId") as? String else {
result(FlutterError(code: "bad_args", message: "missing 'taskId'", details: nil))
return
}
cancelQuietly(taskId: taskId)
bus.purgeTask(taskId: taskId)
result(true)
}
private func handleQuery(_ call: FlutterMethodCall, result: FlutterResult) {
guard let taskId = taskChannelArg(call, key: "taskId") as? String else {
result(FlutterError(code: "bad_args", message: "missing 'taskId'", details: nil))
return
}
guard let entry = store.entry(taskId: taskId) else {
// 无映射(未注册 / 已终态清理):镜像 Kotlin 无 WorkInfo 的 null。
result(nil)
return
}
lock.lock()
let task = activeTasks[entry.sessionTaskIdentifier]
let progress = lastProgress[taskId]
lock.unlock()
let state: String
if let task = task {
switch task.state {
case .running: state = "RUNNING"
case .suspended: state = "ENQUEUED"
case .canceling: state = "CANCELLED"
case .completed: state = "SUCCEEDED"
@unknown default: state = "ENQUEUED"
}
} else {
// 未重挂(进程重启后):系统仍持有任务,等同等待系统调度。
state = "ENQUEUED"
}
result([
"state": state,
"attempt": entry.executions,
"progress": progress.map { ["sent": $0.sent, "total": $0.total] },
])
}
private func handleAck(_ call: FlutterMethodCall, result: FlutterResult) {
let seq: Int?
if let v = taskChannelArg(call, key: "seq") as? NSNumber {
seq = v.intValue
} else if let v = taskChannelArg(call, key: "seq") as? Int {
seq = v
} else {
seq = nil
}
guard let seq = seq else {
result(FlutterError(code: "bad_args", message: "missing or invalid 'seq'", details: nil))
return
}
bus.ack(seq: seq)
result(true)
}
/// cancelTask/queryTask/ackEvents 的字符串参数取值。
private func taskChannelArg(_ call: FlutterMethodCall, key: String) -> Any? {
(call.arguments as? [String: Any])?[key]
}
// ── 任务生命周期 ─────────────────────────────────────────────────
/// 创建并启动后台 upload task(registerTask / 原生重试共用;
/// [executions] 为已执行计数——重试路径沿用,注册路径自 1 起)。
private func startTask(spec: UploadRegisterSpec, executions: Int = 1) throws {
ensureSession()
guard let session = session else {
throw NSError(
domain: "com.photolive.photo_live.uploads", code: 1,
userInfo: [NSLocalizedDescriptionKey: "session unavailable"]
)
}
// REPLACE:作废旧系统任务(静默取消,不产生终态事件)。
cancelQuietly(taskId: spec.taskId)
guard let url = URL(string: spec.url) else {
throw UploadBridgeBadArgsError(message: "'url' invalid")
}
// 源文件缺失预检(镜像 Kotlin UploadPutExecutor 的 File.exists 分类;
// 交由 URLSession 才失败会被误归 NETWORK)。
guard FileManager.default.fileExists(atPath: spec.filePath) else {
bus.emitFailed(taskId: spec.taskId, code: UploadBridge.failSourceMissing, status: nil)
store.remove(taskId: spec.taskId)
return
}
var request = URLRequest(url: url)
request.httpMethod =
spec.method.isEmpty ? "PUT" : spec.method.uppercased()
for (name, value) in spec.headers {
// Content-Length 由 uploadTask(fromFile:) 按文件长度自带;
// Content-Type URLSession 不会自动设置,原样透传(与 Kotlin
// 经 RequestBody.contentType 表达等价)。
if name.lowercased() == "content-length" {
continue
}
request.setValue(value, forHTTPHeaderField: name)
}
let task = session.uploadTask(
with: request,
fromFile: URL(fileURLWithPath: spec.filePath)
)
store.upsert(
taskId: spec.taskId,
entry: UploadTaskStore.Entry(
url: spec.url,
method: request.httpMethod ?? "PUT",
headers: spec.headers,
filePath: spec.filePath,
uploadId: spec.uploadId,
attempt: spec.attempt,
executions: executions,
sessionTaskIdentifier: task.taskIdentifier
)
)
lock.lock()
activeTasks[task.taskIdentifier] = task
lastProgress[spec.taskId] = nil
progressEmittedAt[spec.taskId] = nil
lock.unlock()
task.resume()
}
/// 静默取消(用户取消 / 重注册替换):无终态事件(镜像 Kotlin 取消语义
/// ——Dart 引擎先行置 cancelled 终态,本侧不发事件)。
private func cancelQuietly(taskId: String) {
let identifier = store.entry(taskId: taskId)?.sessionTaskIdentifier ?? -1
if identifier >= 0 {
lock.lock()
quietCancelIdentifiers.insert(identifier)
if let task = activeTasks[identifier] {
task.cancel()
}
lock.unlock()
// 未重挂的任务(进程重启后)经系统任务表兜底取消。
session?.getAllTasks { [weak self] tasks in
for task in tasks where task.taskIdentifier == identifier {
task.cancel()
}
self?.lock.lock()
self?.quietCancelIdentifiers.remove(identifier)
self?.lock.unlock()
}
}
store.remove(taskId: taskId)
lock.lock()
lastProgress[taskId] = nil
progressEmittedAt[taskId] = nil
lock.unlock()
}
/// 终态/静默结束后的运行期清理(store 条目由调用方决定去留)。
private func taskRuntimeCleanup(taskId: String, identifier: Int) {
lock.lock()
activeTasks[identifier] = nil
quietCancelIdentifiers.remove(identifier)
lastProgress[taskId] = nil
progressEmittedAt[taskId] = nil
lock.unlock()
}
// ── delegate 回调入口(UploadSessionDelegate 转发) ──────────────
fileprivate func taskId(forSessionTask identifier: Int) -> String? {
store.taskId(sessionTaskIdentifier: identifier)
}
/// 进度节流(500ms;终值必发)。返回是否应发事件。
fileprivate func shouldEmitProgress(taskId: String, sent: Int64, total: Int64) -> Bool {
lock.lock()
lastProgress[taskId] = (sent, total)
let now = Date().timeIntervalSince1970
let last = progressEmittedAt[taskId] ?? 0
let terminalValue = total > 0 && sent >= total
if terminalValue || now - last >= UploadPlugin.progressThrottleSeconds {
progressEmittedAt[taskId] = now
lock.unlock()
return true
}
lock.unlock()
return false
}
/// 静默取消判定(用户取消 / 替换;系统 NSURLErrorCancelled 归同处理)。
fileprivate func isQuietlyCancelled(identifier: Int) -> Bool {
lock.lock()
defer { lock.unlock() }
return quietCancelIdentifiers.contains(identifier)
}
/// 传输结束分类(didCompleteWithError;分类表见类注释)。
fileprivate func handleTaskCompletion(
taskId: String,
identifier: Int,
response: NSHTTPURLResponse?,
error: Error?
) {
if isQuietlyCancelled(identifier: identifier) {
taskRuntimeCleanup(taskId: taskId, identifier: identifier)
return
}
if let nsError = error as NSError? {
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled {
taskRuntimeCleanup(taskId: taskId, identifier: identifier)
return
}
// 网络层错误(镜像 Kotlin IOException → 可重试网络类)。
handleRetryable(taskId: taskId, identifier: identifier, serverError: false, status: nil)
return
}
guard let status = response?.statusCode else {
handleRetryable(taskId: taskId, identifier: identifier, serverError: false, status: nil)
return
}
switch status {
case 200...299:
bus.emitCompleted(taskId: taskId, etag: response?.value(forHTTPHeaderField: "ETag"))
taskRuntimeCleanup(taskId: taskId, identifier: identifier)
store.remove(taskId: taskId)
case 403:
bus.emitNeedsRenew(taskId: taskId)
taskRuntimeCleanup(taskId: taskId, identifier: identifier)
store.remove(taskId: taskId)
case 400...499:
bus.emitFailed(taskId: taskId, code: UploadBridge.failHttp4xx, status: status)
taskRuntimeCleanup(taskId: taskId, identifier: identifier)
store.remove(taskId: taskId)
default:
handleRetryable(taskId: taskId, identifier: identifier, serverError: true, status: status)
}
}
/// 可重试结局:未达尝试上限 → 指数退避后重建任务;达上限 → 终态事件
/// (SERVER_ERROR / NETWORK;status 语义对齐 Kotlin——5xx 带 status,
/// 网络类不带)。退避定时器经主队列 dispatchAfter——App 挂起期间暂停、
/// 唤醒后补跑(系统已按需后台拉起,G3 真机校准点)。
private func handleRetryable(
taskId: String,
identifier: Int,
serverError: Bool,
status: Int?
) {
taskRuntimeCleanup(taskId: taskId, identifier: identifier)
guard let entry = store.entry(taskId: taskId) else { return }
if entry.executions >= UploadPlugin.maxWorkAttempts {
bus.emitFailed(
taskId: taskId,
code: serverError ? UploadBridge.failServerError : UploadBridge.failNetwork,
status: status
)
store.remove(taskId: taskId)
return
}
var next = entry
next.executions += 1
store.upsert(taskId: taskId, entry: next)
let delay =
UploadPlugin.retryBackoffBaseSeconds * pow(2, Double(entry.executions - 1))
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
self?.recreate(taskId: taskId)
}
}
/// 重试重建(退避到期;期间被取消/重注册则 entry 已变——按 URL 等字段重建)。
private func recreate(taskId: String) {
guard let entry = store.entry(taskId: taskId) else { return }
let spec = UploadRegisterSpec(
taskId: taskId,
url: entry.url,
method: entry.method,
headers: entry.headers,
filePath: entry.filePath,
uploadId: entry.uploadId,
attempt: entry.attempt
)
do {
try startTask(spec: spec, executions: entry.executions)
} catch {
bus.emitFailed(taskId: taskId, code: UploadBridge.failNetwork, status: nil)
store.remove(taskId: taskId)
}
}
/// delegate 持有任务引用(重挂/取消用)。
fileprivate func trackActiveTask(_ task: URLSessionTask) {
guard let taskId = store.taskId(sessionTaskIdentifier: task.taskIdentifier) else { return }
store.mutate(taskId: taskId) { entry in
entry.sessionTaskIdentifier = task.taskIdentifier
}
lock.lock()
activeTasks[task.taskIdentifier] = task
lock.unlock()
}
}
// ── EventChannel 流(终态缓存重放见 UploadEventBus) ────────────────────
/// EventChannel `photolive/uploads/events`:onListen 接管总线 sink(事件
/// 统一主线程投递,镜像 Kotlin mainHandler.post);onCancel 摘除。
private final class UploadEventStreamHandler: NSObject, FlutterStreamHandler {
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink)
-> FlutterError?
{
UploadEventBus.shared.attach { event in
DispatchQueue.main.async {
events(event)
}
}
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
UploadEventBus.shared.detach()
return nil
}
}
// ── URLSession delegate(系统回调 → 插件分类) ──────────────────────────
/// 后台会话 delegate(独立对象 + weak 回指,避免 session↔plugin 环)。
/// 回调在系统串行队列;所有插件入口自带锁。
private final class UploadSessionDelegate: NSObject, URLSessionDataDelegate {
weak var plugin: UploadPlugin?
init(plugin: UploadPlugin) {
self.plugin = plugin
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didSendBodyData bytesSent: Int64,
totalBytesSent: Int64,
totalBytesExpectedToSend: Int64
) {
guard let plugin = plugin,
let taskId = plugin.taskId(forSessionTask: task.taskIdentifier)
else { return }
plugin.trackActiveTask(task)
if plugin.shouldEmitProgress(
taskId: taskId,
sent: totalBytesSent,
total: totalBytesExpectedToSend
) {
UploadEventBus.shared.emitProgress(
taskId: taskId,
sent: totalBytesSent,
total: totalBytesExpectedToSend
)
}
}
/// 上传任务本质是 data task:响应体(MinIO XML/空)读取后丢弃,防连接滞留。
func urlSession(
_ session: URLSession,
dataTask: URLSessionDataTask,
didReceive data: Data,
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
) {
completionHandler(.discard)
}
func urlSession(
_ session: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
guard let plugin = plugin,
let taskId = plugin.taskId(forSessionTask: task.taskIdentifier)
else { return }
plugin.handleTaskCompletion(
taskId: taskId,
identifier: task.taskIdentifier,
response: task.response as? NSHTTPURLResponse,
error: error
)
}
func urlSessionDidFinishEvents(forBackgroundURLSession session: URLSession) {
plugin?.finishBackgroundEvents()
}
}