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
+97
View File
@@ -0,0 +1,97 @@
# iOS 桥接说明(P3 Task 8——编译/真机验证留 G3)
> 本目录的 Swift 桥接代码在 Windows 开发机上完成(无 macOS,未编译)。
> 首次 macOS 构建前请通读本文档;所有「G3 验证」条目对应
> `docs/03-技术实施方案.md` §6.2/§6.4。
## 代码清单
| 文件 | 职责 | Android 对应 |
|---|---|---|
| `Runner/UploadBridge.swift` | 协议常量 + registerTask 参数解析(纯函数) | `UploadProtocol.kt` |
| `Runner/UploadEventBus.swift` | 事件总线:单调 seq、终态 ack 缓存重放(**iOS 增强落盘**) | `UploadEventBus.kt` |
| `Runner/UploadTaskStore.swift` | taskId→系统任务映射持久化(Application Support plist) | WorkManager 自持映射 |
| `Runner/UploadPlugin.swift` | MethodChannel/EventChannel 注册 + URLSession background 会话 + delegate 失败分类 + 原生重试 | `UploadPlugin.kt` + `UploadWorker.kt` + `UploadPutExecutor.kt` |
| `Runner/AppDelegate.swift` | 通道注册(implicit engine)+ 启动重建会话 + 后台拉起 completionHandler 暂存 | `MainActivity.kt` |
协议(通道名 `photolive/uploads`、事件流 `photolive/uploads/events`、
方法/事件名、失败码)与 Android 侧**逐字一致**;Dart 侧同一
`NativeTransport`(`lib/features/uploads/native_transport.dart`)驱动两平台,
无协议分歧登记项。
## Background Modes(计划偏差修正)
**有意不声明 `UIBackgroundModes`**(Info.plist 内有注释标记):上传采用
background URLSession(identifier `com.photolive.photo_live.uploads`),
传输与完成回调由系统调度,`fetch`/`processing` 等模式均非必需;计划原文
的「backgroundModes(fetch/processing 按文档)」按技术方案 §6.2 与 Apple
文档修正(多声明反而引入 App Store 审核 scrutiny)。无需 Push、无需
任何 Capability 开关。
## Xcode 步骤(G3 首次构建)
1. macOS 上 `source scripts/flutter-env.sh` 的等价环境(Flutter 3.44+),
`flutter precache --ios` 后 `cd apps/mobile && flutter pub get`。
2. 打开 `apps/mobile/ios/Runner.xcworkspace`。
3. Runner target → Signing & Capabilities:选择开发团队(个人免费证书
即可真机调试;无需勾选任何额外 Capability)。
4. `flutter run -d <deviceId>` 或 Xcode 直接 Run;新 Swift 文件已登记于
`project.pbxproj`(4 个 `Upload*.swift`),无需手动拖入。
5. 首次编译预期**零改动通过**;若 Swift 语法/API 有出入(未编译验证的
风险),修复后把差异登记进验收记录偏差表。
### 开发联调网络
Info.plist 已含 `NSAppTransportSecurity.NSAllowsLocalNetworking`:模拟器/
真机可访问宿主回环(`http://<宿主IP>:8080` 等)的本地 cn-api + MinIO;
生产仍要求 HTTPS。
## G3 验证清单(技术方案 §6.2 要点)
按序执行并记录(设备/系统版本/文件大小/成功数/恢复行为/重复记录数):
- [ ] **实际文件上传**:真实照片文件(非桩)经完整链路(presign →
URLSession PUT → complete)进入服务端;App 退后台/锁屏传输继续。
- [ ] **持久化原生回调**:上传完成时 App 在后台被系统拉起——确认终态
事件已落盘(`upload-unacked-events.plist`)并在 App 回前台
EventChannel 重听后重放、Dart ack 后清除;**在系统允许的执行窗口
内完成确认**(complete 调用),超窗场景由服务端对象通知/补偿扫描
推动发布(§5.6),App 侧不得标失败重传覆盖。
- [ ] **重启重联校准**:上传中杀进程 → 重新启动 → 会话重联
(`getAllTasks` + taskIdentifier 反查 taskId)、队列状态校准、
未完成任务续传(映射文件 `upload-task-map.plist`)。
- [ ] **强制退出**:用户强制退出后系统**不会**自动重启 App 承接会话
事件——需下次启动恢复(§6.2 明文语义,UI 不承诺后台完成)。
- [ ] **低电量/调度延迟**:低电量模式 + 断网恢复场景,任务挂起而非
失败;UI 显示等待状态(`waiting_network`),不承诺固定完成时间。
- [ ] **凭证过期**:presign 403 → `needsRenew` → Dart renew 换 URL 重注册
(含长时间后台后 renew 的可达性——§5.2 技术验证重点)。
- [ ] **原生重试上限**:断网/5xx 下原生侧重试 5 次(指数退避 10s 起)
后 `failed{NETWORK|SERVER_ERROR}`;退避定时器在 App 挂起期间暂停、
唤醒补跑(G3 确认行为符合预期)。
- [ ] **进度节流**:进度事件 500ms 节流 + 终值必发(与 Android 每 64KB
发事件的差异——Dart 侧 `onProgress` 语义不受影响)。
- [ ] **批量样本**:批量 100 张真实照片(§6.4 起始样本),覆盖前台切
后台、锁屏 ≥15min、网络中断恢复、强制退出重开、源文件缺失
(`failed{SOURCE_MISSING}`)、存储不足;记录重复记录数(幂等
clientTaskId 下应为 0)。
- [ ] **HEIC 真机样本**(§5.4,与 G2 清单合并执行)。
- [ ] 补充单测(可选):`UploadBridge.parseRegisterSpec` /
`UploadEventBus` 重放语义的 XCTest(Android 侧有 JVM 对应单测)。
## 与 Android 实现的已知差异(协议外,行为层)
1. **终态缓存落盘**:iOS 侧未 ack 终态事件持久化(系统可能后台拉起
App 投递回调而 Dart 引擎未运行);Android 侧内存缓存即可(WorkManager
自持任务,进程重启后 worker 重跑再发)。
2. **重试宿主**:iOS 由插件层重建 upload task(10s 指数退避,上限 5 次
含首发);Android 为 WorkManager `Result.retry` 同参数。App 挂起时
iOS 退避定时器暂停、唤醒补跑。
3. **进度节流**:iOS 在事件层 500ms 节流(终值必发);Android 每写
64KB 发事件、仅通知刷新节流 500ms。
4. **取消语义**:两侧均为「Dart 先行置 cancelled、原生静默停任务、无
终态事件」;iOS 额外经 `getAllTasks` 兜底取消未重挂任务。
5. **queryTask 状态枚举**:iOS 以 URLSessionTask.state 映射
RUNNING/ENQUEUED/CANCELLED(终态后条目清理,返回 null = 未知);
Android 为 WorkInfo 原名(SUCCEEDED/FAILED 可查)。Dart 侧仅作校准
参考,不依赖具体枚举。
@@ -12,6 +12,10 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
7884E8812EC3CC0A00C636F3 /* UploadBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8802EC3CC0A00C636F3 /* UploadBridge.swift */; };
7884E8832EC3CC0B00C636F3 /* UploadEventBus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8822EC3CC0B00C636F3 /* UploadEventBus.swift */; };
7884E8852EC3CC0C00C636F3 /* UploadTaskStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8842EC3CC0C00C636F3 /* UploadTaskStore.swift */; };
7884E8872EC3CC0D00C636F3 /* UploadPlugin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8862EC3CC0D00C636F3 /* UploadPlugin.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
@@ -50,6 +54,10 @@
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
7884E8802EC3CC0A00C636F3 /* UploadBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadBridge.swift; sourceTree = "<group>"; };
7884E8822EC3CC0B00C636F3 /* UploadEventBus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadEventBus.swift; sourceTree = "<group>"; };
7884E8842EC3CC0C00C636F3 /* UploadTaskStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadTaskStore.swift; sourceTree = "<group>"; };
7884E8862EC3CC0D00C636F3 /* UploadPlugin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadPlugin.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
@@ -123,6 +131,10 @@
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
7884E8802EC3CC0A00C636F3 /* UploadBridge.swift */,
7884E8822EC3CC0B00C636F3 /* UploadEventBus.swift */,
7884E8842EC3CC0C00C636F3 /* UploadTaskStore.swift */,
7884E8862EC3CC0D00C636F3 /* UploadPlugin.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
);
path = Runner;
@@ -284,6 +296,10 @@
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
7884E8812EC3CC0A00C636F3 /* UploadBridge.swift in Sources */,
7884E8832EC3CC0B00C636F3 /* UploadEventBus.swift in Sources */,
7884E8852EC3CC0C00C636F3 /* UploadTaskStore.swift in Sources */,
7884E8872EC3CC0D00C636F3 /* UploadPlugin.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+22
View File
@@ -7,10 +7,32 @@ import UIKit
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// 后台会话启动即重建:App 被系统在后台拉起(Dart 引擎未起)时,
// delegate 回调即可入总线缓存(终态落盘,引擎 onListen 后重放)。
UploadPlugin.shared.ensureSession()
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
// 上传桥接(P3 Task 8):MethodChannel 命令 + EventChannel 事件流
// (通道/协议与 Android UploadPlugin 一致,Dart 侧同一 NativeTransport)。
UploadPlugin.register(with: engineBridge.applicationRegistrar.messenger())
}
/// 系统完成后台会话事件投递前的拉起回调:暂存 completionHandler,
/// 供 urlSessionDidFinishEvents 消费(否则 App 被系统挂起惩罚)。
override func application(
_ application: UIApplication,
handleEventsForBackgroundURLSession identifier: String,
completionHandler: @escaping () -> Void
) {
super.application(
application, handleEventsForBackgroundURLSession: identifier,
completionHandler: completionHandler)
if identifier == UploadBridge.sessionIdentifier {
UploadPlugin.shared.ensureSession()
UploadPlugin.shared.storeBackgroundCompletionHandler(completionHandler)
}
}
}
+6
View File
@@ -26,6 +26,12 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<!-- UIBackgroundModes 有意未声明(P3 计划 Task 8 偏差登记):上传采用
background URLSession(identifier com.photolive.photo_live.uploads),
传输与完成回调由系统调度,无需 fetch/processing 等后台模式;计划
原文的「backgroundModes(fetch/processing)」按技术方案 §6.2 与
Apple 文档修正——多声明反而引入审核 scrutiny。见
ios/README-ios.md「Background Modes」。 -->
<!-- 开发/联调允许访问本地网络后端(模拟器宿主回环,Android 侧对应 10.0.2.2);
生产环境仍走 HTTPS,不放宽整体 ATS。 -->
<key>NSAppTransportSecurity</key>
+107
View File
@@ -0,0 +1,107 @@
import Foundation
/// 上传桥接协议常量与 registerTask 参数解析(P3 计划 Task 8)。
///
/// 与 Android 侧 `UploadProtocol.kt` 逐字对齐:通道名、方法名、事件名、
/// 失败码完全一致;Dart 侧 `lib/features/uploads/native_transport.dart`
/// 的 [UploadBridgeChannels] 为同一协议的单源镜像。
enum UploadBridge {
/// 命令通道(registerTask / cancelTask / queryTask / ackEvents)。
static let methodChannel = "photolive/uploads"
/// 事件流(progress / completed / needsRenew / failed,带单调 seq)。
static let eventChannel = "photolive/uploads/events"
static let methodRegister = "registerTask"
static let methodCancel = "cancelTask"
static let methodQuery = "queryTask"
static let methodAck = "ackEvents"
static let eventProgress = "progress"
static let eventCompleted = "completed"
static let eventNeedsRenew = "needsRenew"
static let eventFailed = "failed"
/// 网络不可达/超时(重试上限耗尽后上报)。
static let failNetwork = "NETWORK"
/// 4xx 客户端错误(status 随事件携带;403 单独走 needsRenew)。
static let failHttp4xx = "HTTP_4XX"
/// 5xx 服务端错误(重试上限耗尽后上报)。
static let failServerError = "SERVER_ERROR"
/// 源文件缺失(受管副本被清理,不可恢复)。
static let failSourceMissing = "SOURCE_MISSING"
/// 后台 URLSession 标识(App 重启后系统按此重联会话)。
static let sessionIdentifier = "com.photolive.photo_live.uploads"
}
/// registerTask 参数(Dart → 原生),字段与 Kotlin `UploadRegisterSpec` 一致。
struct UploadRegisterSpec {
let taskId: String
let url: String
let method: String
let headers: [String: String]
let filePath: String
let uploadId: String?
let attempt: Int
}
/// 参数形状不符(插件层转 bad_args 错误,镜像 Kotlin IllegalArgumentException)。
struct UploadBridgeBadArgsError: Error {
let message: String
}
/// registerTask 参数解析(纯函数;与 Kotlin `parseRegisterSpec` 同语义)。
///
/// 通道解码所得字典为 [String: Any](Flutter 标准编解码 null → NSNull),
/// 显式 null 与类型不符同样归为 bad_args。
func parseRegisterSpec(_ args: [String: Any]) throws -> UploadRegisterSpec {
func str(_ key: String) throws -> String {
guard let v = args[key] as? String else {
throw UploadBridgeBadArgsError(
message: args[key] == nil ? "missing '\(key)'" : "'\(key)' must be a String"
)
}
return v
}
let taskId = try str("taskId")
let url = try str("url")
let methodRaw = try str("method")
let method = methodRaw.isEmpty ? "PUT" : methodRaw
let filePath = try str("filePath")
var headers: [String: String] = [:]
if let raw = args["headers"] as? [String: Any] {
for (k, v) in raw {
guard let value = v as? String else {
throw UploadBridgeBadArgsError(message: "'headers' must be Map<String, String>")
}
headers[k] = value
}
} else if args["headers"] != nil {
throw UploadBridgeBadArgsError(message: "'headers' must be a Map")
}
var uploadId: String?
var attempt = 0
if let metadata = args["metadata"] as? [String: Any] {
uploadId = metadata["uploadId"] as? String
if let v = metadata["attempt"] as? NSNumber {
attempt = v.intValue
} else if let v = metadata["attempt"] as? Int {
attempt = v
} else if metadata["attempt"] != nil {
throw UploadBridgeBadArgsError(message: "'metadata.attempt' must be an int")
}
}
return UploadRegisterSpec(
taskId: taskId,
url: url,
method: method,
headers: headers,
filePath: filePath,
uploadId: uploadId,
attempt: attempt
)
}
+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]] ?? []
}
}
+531
View File
@@ -0,0 +1,531 @@
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()
}
}
@@ -0,0 +1,130 @@
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,
]
}
}
@@ -1,14 +1,17 @@
/// 上传传输抽象与双实现(P3 计划 Task 7)。
/// 上传传输抽象与双实现(P3 计划 Task 7;Task 8 扩展 iOS 选择分支)。
///
/// 引擎的传输步骤(presign PUT)经 [Transport] 接口委托:
/// - **[DartHttpTransport]**(Task 6 逻辑原样抽出):裸 dio 流式 PUT +
/// 进度计数流 + 取消令牌——iOS / 桌面 / 调试路径(dart-define
/// 进度计数流 + 取消令牌——桌面 / 调试路径(dart-define
/// `FORCE_DART_TRANSPORT=true` 强制启用);
/// - **[NativeTransport]**(Task 7 新增,Android):MethodChannel
/// `photolive/uploads` 注册 WorkManager 后台工作(expedited + dataSync
/// 前台服务),EventChannel `photolive/uploads/events` 回流
/// progress/completed/failed/needsRenew 事件——App 切后台/锁屏后续传
/// 由系统调度承担。
/// - **[NativeTransport]**(Task 7 新增,Android;Task 8 起含 iOS):
/// MethodChannel `photolive/uploads` 注册原生后台上传——Android =
/// WorkManager 后台工作(expedited + dataSync 前台服务);iOS =
/// URLSession background(identifier com.photolive.photo_live.uploads,
/// 编译留 G3)。两平台协议逐字一致(Kotlin `UploadProtocol.kt` /
/// Swift `UploadBridge.swift`),EventChannel `photolive/uploads/events`
/// 回流 progress/completed/failed/needsRenew 事件——App 切后台/锁屏后
/// 续传由系统调度承担。
///
/// 失败语义(两实现归一为 [TransportException] 族,引擎分类见
/// upload_engine.dart `_classifyFailure`):
@@ -197,10 +200,11 @@ class DartHttpTransport implements Transport {
}
}
// ── 原生(WorkManager)实现 ──────────────────────────────────────────────
// ── 原生(Android WorkManager / iOS URLSession background)实现 ─────────
/// 平台通道名(与 Android UploadPlugin 一致;Kotlin 侧常量见
/// UploadProtocol.kt)。
/// 平台通道名(与 Android `UploadPlugin.kt` / iOS `UploadPlugin.swift`
/// 一致;原生常量见 UploadProtocol.kt 与 UploadBridge.swift——Task 8 起
/// iOS 为同一协议的 Swift 镜像,无协议分歧)。
abstract final class UploadBridgeChannels {
/// 命令通道:registerTask / cancelTask / queryTask / ackEvents。
static const String methods = 'photolive/uploads';
@@ -243,11 +247,12 @@ class _NativeWaiter {
}
}
/// Android 原生传输:registerTask 入队 WorkManager(expedited + dataSync
/// 前台服务),事件流回流终态/进度。
/// Android/iOS 原生传输:registerTask 入队原生后台上传(Android =
/// WorkManager;iOS = URLSession background),事件流回流终态/进度。
///
/// 生命周期安全:事件带单调 seq;终态事件在 Dart ack 前由原生侧缓存,
/// 重新 listen 时重放——进程重启/切后台恢复后不丢完成信号。进度事件不
/// 重新 listen 时重放——进程重启/切后台恢复后不丢完成信号(iOS 侧缓存
/// 另落盘,系统后台拉起投递的终态在进程重启后同样重放)。进度事件不
/// 重放(瞬态)。
class NativeTransport implements Transport {
NativeTransport({NativeUploadChannel? channel})
@@ -400,21 +405,30 @@ class NativeTransport implements Transport {
// ── 传输选择 ─────────────────────────────────────────────────────────────
/// dart-define FORCE_DART_TRANSPORT=true:Android 也强制 Dart dio 路径
/// (桥接层问题排查用)。
/// dart-define FORCE_DART_TRANSPORT=true:Android/iOS 也强制 Dart dio
/// 路径(桥接层问题排查用)。
const bool kForceDartTransport = bool.fromEnvironment('FORCE_DART_TRANSPORT');
/// 传输选择纯函数(可测):Android 且未强制 Dart → 原生。
/// 传输选择纯函数(可测):Android 或 iOS 且未强制 Dart → 原生。
///
/// Task 8 起含 iOS:协议与 Android 逐字一致(同一 [NativeTransport]
/// 经同一通道名驱动 Swift 侧 UploadPlugin——iOS 编译/真机验证留 G3,
/// 代码路径在此之前即纳入原生选择)。
bool preferNativeTransport({
required bool isAndroid,
required bool isIOS,
bool forceDartTransport = kForceDartTransport,
}) =>
isAndroid && !forceDartTransport;
(isAndroid || isIOS) && !forceDartTransport;
/// 默认传输解析:Android → [NativeTransport](WorkManager 后台续传);
/// 其余平台 / 强制覆盖 → [DartHttpTransport]。
/// 默认传输解析:Android/iOS → [NativeTransport](WorkManager /
/// URLSession background 后台续传);其余平台 / 强制覆盖 →
/// [DartHttpTransport]。
Transport defaultUploadTransport() {
if (preferNativeTransport(isAndroid: !kIsWeb && Platform.isAndroid)) {
if (preferNativeTransport(
isAndroid: !kIsWeb && Platform.isAndroid,
isIOS: !kIsWeb && Platform.isIOS,
)) {
return NativeTransport();
}
return DartHttpTransport();
@@ -6,8 +6,8 @@
/// (progress/completed/needsRenew/failed×码表)、终态 ack、取消联动、
/// 注册失败归一、多任务路由;
/// - DartHttpTransport:dio 异常 → 传输异常归一(403→renew、取消、网络);
/// - 传输选择:Android 默认原生、FORCE_DART_TRANSPORT 覆盖、VM 缺省
/// DartHttp。
/// - 传输选择:Android/iOS 默认原生(Task 8 起 iOS 并入同一矩阵)、
/// FORCE_DART_TRANSPORT 覆盖、VM 缺省 DartHttp。
library;
import 'dart:async';
@@ -405,22 +405,39 @@ void main() {
});
});
group('传输选择(Android 原生 / FORCE_DART_TRANSPORT)', () {
test('选择矩阵:Android→native;强制 Dart / 非 Android→dart', () {
expect(preferNativeTransport(isAndroid: true), isTrue);
expect(preferNativeTransport(isAndroid: false), isFalse);
group('传输选择(Android/iOS 原生 / FORCE_DART_TRANSPORT)', () {
test('选择矩阵:Android/iOS→native;强制 Dart / 桌面→dart', () {
// Android / iOS:原生桥接(Task 7 / Task 8——协议一致,同一通道)。
expect(
preferNativeTransport(isAndroid: true, forceDartTransport: true),
isFalse,
reason: 'FORCE_DART_TRANSPORT 覆盖',
preferNativeTransport(isAndroid: true, isIOS: false),
isTrue,
reason: 'Android → WorkManager 原生',
);
expect(
preferNativeTransport(isAndroid: true, forceDartTransport: false),
preferNativeTransport(isAndroid: false, isIOS: true),
isTrue,
reason: 'iOS → URLSession background 原生(编译留 G3)',
);
// 桌面 / 测试 VM:Dart dio。
expect(preferNativeTransport(isAndroid: false, isIOS: false), isFalse);
// FORCE_DART_TRANSPORT 覆盖原生选择。
expect(
preferNativeTransport(isAndroid: true, isIOS: false, forceDartTransport: true),
isFalse,
reason: 'FORCE_DART_TRANSPORT 覆盖(Android)',
);
expect(
preferNativeTransport(isAndroid: false, isIOS: true, forceDartTransport: true),
isFalse,
reason: 'FORCE_DART_TRANSPORT 覆盖(iOS)',
);
expect(
preferNativeTransport(isAndroid: true, isIOS: false, forceDartTransport: false),
isTrue,
);
});
test('缺省解析:VM 测试环境(非 Android)→ DartHttpTransport', () {
test('缺省解析:VM 测试环境(非 Android/iOS)→ DartHttpTransport', () {
final resolved = defaultUploadTransport();
expect(resolved, isA<DartHttpTransport>());
resolved.dispose();
+2 -2
View File
@@ -65,8 +65,8 @@
## Task 8: iOS 桥接代码(编译留 G3)
- [ ] Swift:URLSession background(withIdentifier:) + AppDelegate wiring + MethodChannel 同协议;Info.plist backgroundModes(fetch/processing 按文档)、上传任务映射持久化(plist/sqlite 简版);README-ios.md(Xcode 步骤、签名、验证清单=技术方案 6.2 要点)
- [ ] 静态保证:dart 侧协议层与平台无关(同一 interface),iOS 分支代码完整、`flutter analyze` 通过(iOS 不编译不阻塞)
- [x] Swift:URLSession background(withIdentifier:) + AppDelegate wiring + MethodChannel 同协议;Info.plist backgroundModes(fetch/processing 按文档)、上传任务映射持久化(plist/sqlite 简版);README-ios.md(Xcode 步骤、签名、验证清单=技术方案 6.2 要点)——实现偏差:UIBackgroundModes 有意不声明(background URLSession 无需,见 README-ios.md 与 Info.plist 注释)
- [x] 静态保证:dart 侧协议层与平台无关(同一 interface),iOS 分支代码完整、`flutter analyze` 通过(iOS 不编译不阻塞)
- [ ] Commit `feat(mobile): ios background upload bridge (compile-gated at G3)`
## Task 9: 队列 UI 与连拍页