# App 上传提速与无限制选图 实施计划 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** 提升 App 批量上传吞吐(并发 2→4、准备阶段有界并行、进度回调节流),并用应用内相册选择器(wechat_assets_picker)替换系统照片选择器,解除 100 张上限、支持长按拖动连选与全选。 **Architecture:** 三处提速均为参数/局部改动,不动上传状态机与传输协议;选图替换仅改 `multiImagePickerProvider` 注入点实现 + Android 权限声明,队列/准备器/引擎零改动(Provider 注入架构已就位)。 **Tech Stack:** Flutter/Dart(dio、drift、Riverpod)、Kotlin(WorkManager/OkHttp)、`wechat_assets_picker ^10.1.3`(依赖 `photo_manager ^3.5.0`,要求 Flutter ≥3.27,本项目 Dart ^3.12.2 满足)。 **已确认决策(2026-09-28):** - ① 接受应用内选择器带来的存储权限弹窗(READ_MEDIA_IMAGES); - ② 不做客户端压缩(原图直传保持不变)。 **明确不做(Out of scope):** - 前台快路径传输(前台直连 dio、后台 WorkManager 接管)——待本次并发提升数据出来后再评估; - 批量 initUpload 契约扩展; - 并发 worker 通知合并(4 并发 = 4 条低优先级通知,可接受,观察后再定)。 --- ## 背景(问题 → 根因速查) | 问题 | 根因 | 证据 | |---|---|---| | 上传慢 | 并行传输上限=2;准备阶段两层串行;进度回调每 64KB 一次 | `upload_engine.dart:55`、`enqueue.dart:153`(for-await 串行)、`preparer.dart:117`(`_serialize`)、`UploadPutExecutor.kt:74` | | 一次最多 100 张 | Android 系统 Photo Picker 平台上限 `MediaStore.getPickImagesMaxLimit()`(多数设备=100),app/服务端无此限制 | image_picker_android `ImagePickerUtils.getMaxItems()` | | 只能逐张点选 | 系统 Photo Picker UI 不支持区间拖选/全选,平台限制无解 | 同上 | 测试基线(Task 6 验证时先采旧包数据):同批 20 张照片入队+传完的总耗时、入队阶段(全部变 queued)耗时。 --- ### Task 1: 传输并发默认值 2 → 4 **Files:** - Modify: `apps/mobile/lib/features/uploads/upload_engine.dart:53-56` 说明:dart-define `MAX_PARALLEL_UPLOADS` 覆盖机制保持不变(A/B 实测用);引擎测试均显式注入 `maxParallel`,不受默认值影响。 - [ ] **Step 1: 修改常量与注释** ```dart /// 并行传输上限(计划:批量并发为可配置小值;dart-define /// MAX_PARALLEL_UPLOADS 覆盖)。2026-09-28 上传提速:2 → 4 /// (4G/Wi-Fi 上行可支撑;Android 侧每任务独立 WorkManager 通知, /// 并发=通知数,观察后再定是否合并)。 const int kMaxParallelUploads = int.fromEnvironment('MAX_PARALLEL_UPLOADS', defaultValue: 4); ``` - [ ] **Step 2: 跑上传相关测试确认不回归** Run: `cd apps/mobile && flutter test test/features/uploads/` Expected: 全部 PASS - [ ] **Step 3: Commit** ```bash git add apps/mobile/lib/features/uploads/upload_engine.dart git commit -m "feat(app): raise default upload parallelism 2 -> 4" ``` --- ### Task 2: UploadPreparer 有界并行(默认 3) **Files:** - Modify: `apps/mobile/lib/features/uploads/preparer.dart` - Test: `apps/mobile/test/features/uploads/preparer_test.dart` 设计:把 `_tail` 全局串行链改为**有界并发执行器**(信号量 + FIFO 等待队列)。并发默认 3(dart-define `PREPARE_CONCURRENCY` 覆盖),1 = 等价旧行为。防连拍 IO 风暴的初衷保留(上限钳位),只是不再退化为全串行。 - [ ] **Step 1: 写失败测试**(`preparer_test.dart` 追加) ```dart test('有界并发:并发 prepare 数不超过 maxConcurrentPrepares 且全部完成', () async { var active = 0; var peak = 0; final store = ... // 按现有测试构造方式(内存 db + tmp 目录) final preparer = UploadPreparer( store, baseDirProvider: () async => tmp, maxConcurrentPrepares: 3, // 用可注入的复制观察:直接跑真实 prepare,统计峰值没有钩子—— // 改为构造 9 个任务并发触发,断言全部终态 + 耗时显著小于串行版。 ); final tasks = await Future.wait( List.generate(9, (i) => preparer.prepare( accountId: 'acc', albumId: 'alb', sourcePath: fixtureJpg.path, // 复用现有测试的 jpg 夹具 )), ); expect(tasks.every((t) => t.state == UploadTaskState.queued), isTrue); }); ``` 注:若现有夹具不便统计峰值,验收改为「9 个并发 prepare 全部 queued + 串行对照耗时 > 并行耗时」;峰值断言可通过对 `maxConcurrentPrepares: 1` 的对照行为(同参数 1 时任务仍全部成功)兜底。 - [ ] **Step 2: 跑测试确认失败** Run: `cd apps/mobile && flutter test test/features/uploads/preparer_test.dart` Expected: FAIL(`maxConcurrentPrepares` 参数不存在) - [ ] **Step 3: 实现(preparer.dart)** 构造函数与常量: ```dart /// 准备阶段有界并发上限(dart-define PREPARE_CONCURRENCY 覆盖;1=串行)。 const int kMaxConcurrentPrepares = int.fromEnvironment('PREPARE_CONCURRENCY', defaultValue: 3); ``` ```dart UploadPreparer( this._store, { Future Function()? baseDirProvider, Random? random, this.maxBytes = kMaxUploadBytes, this.maxConcurrentPrepares = kMaxConcurrentPrepares, this.onEnqueued, }) : ... // 其余不变 /// 准备并发上限(运行时 <1 钳位为 1)。 final int maxConcurrentPrepares; int get _effectivePrepareConcurrency => maxConcurrentPrepares < 1 ? 1 : maxConcurrentPrepares; ``` 替换 `_tail` 串行链(删除 `Future _tail` 与旧 `_serialize`): ```dart /// 在途准备计数 + FIFO 等待队列(有界并发信号量)。 int _activePrepares = 0; final Queue> _prepareWaiters = Queue>(); /// 有界并发执行:超额排队(FIFO);错误由调用方经返回值处理,不设全局断链。 Future _runBounded(Future Function() op) { final ticket = _acquirePrepareSlot(); final result = ticket.then((_) => op()); result.whenComplete(_releasePrepareSlot).ignore(); return result; } Future _acquirePrepareSlot() { if (_activePrepares < _effectivePrepareConcurrency) { _activePrepares++; return Future.value(); } final waiter = Completer(); _prepareWaiters.addLast(waiter); return waiter.future; } void _releasePrepareSlot() { final waiter = _prepareWaiters.isEmpty ? null : _prepareWaiters.removeFirst(); if (waiter == null) { _activePrepares--; return; } // 名额直接移交队首等待者(计数不变)。 waiter.complete(); } ``` `prepare`/`retry` 内 `_serialize(...)` 调用点改名为 `_runBounded(...)`(签名同形,两处替换)。`import 'dart:async';` 已有(Completer 需要);补 `Queue` 需 `import 'dart:collection';`。 同步更新类级 doc 注释:「串行执行」段改为「有界并发执行(默认 3):文件复制仍是 IO 最重环节,上限钳位防连拍风暴;传输并发由 UploadEngine 另行控制」。 - [ ] **Step 4: 跑测试确认通过** Run: `cd apps/mobile && flutter test test/features/uploads/preparer_test.dart` Expected: 全部 PASS(含既有串行语义测试——显式传 `maxConcurrentPrepares: 1` 的用例如依赖顺序应仍通过;若有用例依赖默认串行顺序,将其构造处补 `maxConcurrentPrepares: 1`) - [ ] **Step 5: Commit** ```bash git add apps/mobile/lib/features/uploads/preparer.dart apps/mobile/test/features/uploads/preparer_test.dart git commit -m "feat(app): bounded-parallel prepare (default 3) replaces serial queue" ``` --- ### Task 3: enqueueForUpload 并行收集 **Files:** - Modify: `apps/mobile/lib/features/uploads/enqueue.dart:152-167` 说明:Task 2 后 preparer 内部已限流,外层 for-await 循环改为 `Future.wait` 即可。**注意:`enqueueForUpload` 已是 P0-1 结构(返回 `EnqueueResult`,`skipPaths` 过滤在函数内)**——并行化只改循环段,保留 `effective` 过滤与返回结构。 - [ ] **Step 1: 替换循环段(152-161 行)** ```dart final results = await Future.wait( effective.map((path) => preparer.prepare( accountId: accountId, albumId: albumId, categoryId: categoryId, sourcePath: path, )), ); final enqueued = results .where((task) => task.state == UploadTaskState.queued) .length; ``` (返回 `EnqueueResult(enqueued: enqueued, duplicates: [], skipped: sourcePaths.length - effective.length)` 原样保留。) - [ ] **Step 2: 跑页级测试** Run: `cd apps/mobile && flutter test test/pages/album_detail_page_test.dart test/pages/capture_page_test.dart` Expected: 全部 PASS - [ ] **Step 3: Commit** ```bash git add apps/mobile/lib/features/uploads/enqueue.dart git commit -m "feat(app): enqueue gallery batch concurrently via Future.wait" ``` --- ### Task 4: 进度回调节流(64KB → 256KB / 完成必报) **Files:** - Modify: `apps/mobile/android/app/src/main/kotlin/com/photolive/photo_live/UploadPutExecutor.kt` - Modify: `apps/mobile/lib/features/uploads/native_transport.dart`(DartHttpTransport) - Test: `apps/mobile/android/app/src/test/java/com/photolive/photo_live/UploadPutExecutorTest.kt` 语义:仅减少回调频率(256KB 步长 + 末次必报 sent==total),单调性与最终 total 不变——现有「monotonic bytes up to total」断言兼容。 - [ ] **Step 1: Kotlin 侧** `UploadPutExecutor` object 内加常量: ```kotlin /** 进度回调最小步长(字节);末块必报。 */ private const val PROGRESS_STEP_BYTES = 256L * 1024 ``` `writeTo` 改为: ```kotlin override fun writeTo(sink: BufferedSink) { file.inputStream().use { input -> val buffer = ByteArray(64 * 1024) var sent = 0L var lastReported = 0L while (true) { val read = input.read(buffer) if (read < 0) { break } sink.write(buffer, 0, read) sent += read if (sent - lastReported >= PROGRESS_STEP_BYTES || sent == total) { lastReported = sent onProgress?.invoke(sent, total) } } sink.flush() } } ``` - [ ] **Step 2: Dart dio 路径同节流**(`native_transport.dart` `DartHttpTransport.send`) ```dart final file = File(filePath); final length = await file.length(); var sent = 0; var lastReported = 0; const progressStep = 256 * 1024; final progressStream = file.openRead().map((List chunk) { sent += chunk.length; if (sent - lastReported >= progressStep || sent == length) { lastReported = sent; onProgress?.call(sent, length); } return chunk; }); ``` - [ ] **Step 3: 双侧测试** Run: `cd apps/mobile/android && ./gradlew :app:testDebugUnitTest --tests "com.photolive.photo_live.UploadPutExecutorTest"` Expected: PASS(若有断言逐 64KB 回调次数的用例,改为断言步长 ≥256KB 且末次 sent==total) Run: `cd apps/mobile && flutter test test/features/uploads/` Expected: PASS - [ ] **Step 4: Commit** ```bash git add apps/mobile/android/app/src/main/kotlin/com/photolive/photo_live/UploadPutExecutor.kt apps/mobile/android/app/src/test/java/com/photolive/photo_live/UploadPutExecutorTest.kt apps/mobile/lib/features/uploads/native_transport.dart git commit -m "perf(app): throttle upload progress callbacks to 256KB steps" ``` --- ### Task 5: wechat_assets_picker 集成(解除 100 张上限 + 连选/全选) **Files:** - Modify: `apps/mobile/pubspec.yaml` - Modify: `apps/mobile/android/app/src/main/AndroidManifest.xml` - Modify: `apps/mobile/lib/features/uploads/enqueue.dart` - Modify: `apps/mobile/lib/pages/album_detail_page.dart:341` - Modify: `apps/mobile/lib/pages/capture_page.dart:307` - Test: `apps/mobile/test/pages/album_detail_page_test.dart:338`、`test/pages/capture_page_test.dart:179`、`integration_test/helpers/app_boot.dart:96`(及 grep 命中的其余 override) - [ ] **Step 1: 加依赖** `pubspec.yaml` 选图/连拍分组追加: ```yaml # 选图 / 连拍 image_picker: ^1.2.3 camera: ^0.12.1 # 应用内相册选择器(连选/全选/无系统 100 张上限;基于 photo_manager) wechat_assets_picker: ^10.1.3 ``` Run: `cd apps/mobile && flutter pub get` Expected: 解析成功(photo_manager ^3.5.0 随依赖引入) - [ ] **Step 2: Android 权限声明** `AndroidManifest.xml` 权限区追加(minSdk 24 / targetSdk 36 已满足包要求): ```xml ``` (iOS 留 G3:届时 Info.plist 需 `NSPhotoLibraryUsageDescription`。) - [ ] **Step 3: 替换 multiImagePickerProvider 实现**(`enqueue.dart`) typedef 增加BuildContext(picker 需页面上下文): ```dart /// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。 typedef MultiImagePicker = Future> Function(BuildContext context); ``` provider 实现(文案代理用包内置的 locale 解析助手,勿手写 `<语言>TextDelegate` 类名——10.x 导出名不含 `ChineseTextDelegate`): ```dart /// 多图选择 Provider(生产=wechat_assets_picker;测试 override)。 /// /// - 应用内选择器:长按进入多选 + 拖动连选 + 全选;无系统 Photo Picker /// 的 100 张硬上限(软上限 9999 防极端内存,photo_manager 网格分页 /// 加载,实际不受限); /// - originFile 取原始文件路径(Android 图片无转码副本,省一次 cache /// 中转复制);权限请求由 picker 内部处理,拒绝时返回空(等同取消)。 final multiImagePickerProvider = Provider( (ref) => (context) async { final locale = Localizations.maybeLocaleOf(context); final assets = await AssetPicker.pickAssets( context, pickerConfig: AssetPickerConfig( requestType: RequestType.image, maxAssets: kPickerSoftMaxAssets, textDelegate: assetPickerTextDelegateFromLocale(locale), ), ); if (assets == null) return const []; final paths = []; for (final asset in assets) { final file = await asset.originFile; if (file != null) paths.add(file.path); } return paths; }, ); /// 选图软上限(用户要求不设硬限制;9999 为防误触极端值)。 const int kPickerSoftMaxAssets = 9999; ``` 文件头补 import: ```dart import 'package:flutter/widgets.dart' show BuildContext, Localizations; import 'package:wechat_assets_picker/wechat_assets_picker.dart'; ``` - [ ] **Step 4: 更新两处调用点** `album_detail_page.dart:341` `_pickFromGallery`(picker 需要 context,且此处在 `await _currentAccountId()` 之后——补 mounted 守卫防 `use_build_context_synchronously` lint): ```dart final account = await _currentAccountId(); if (account == null || !mounted) return; final paths = await ref.read(multiImagePickerProvider)(context); ``` `capture_page.dart:307` `_pickFromGallery` 同样改为 `(context)`(该处在任何 await 之前取 context,无 mounted 问题)。 - [ ] **Step 5: 更新测试替身签名** 所有 `multiImagePickerProvider.overrideWith((ref) => () async => galleryPaths)` 改为: ```dart multiImagePickerProvider .overrideWith((ref) => (context) async => galleryPaths), ``` **集成测试替身**(`integration_test/helpers/app_boot.dart:96`)同步改签名: ```dart multiImagePickerProvider .overrideWithValue((context) async => picker.consume()), ``` Run: `cd apps/mobile && grep -rn "multiImagePickerProvider" lib/ test/ integration_test/` 确认无遗漏签名。 - [ ] **Step 6: 全量测试 + 静态检查** Run: `cd apps/mobile && flutter analyze && flutter test` Expected: analyze 无新增告警;测试全 PASS - [ ] **Step 7: Commit** ```bash git add apps/mobile/pubspec.yaml apps/mobile/pubspec.lock apps/mobile/android/app/src/main/AndroidManifest.xml apps/mobile/lib/features/uploads/enqueue.dart apps/mobile/lib/pages/album_detail_page.dart apps/mobile/lib/pages/capture_page.dart apps/mobile/test/ apps/mobile/integration_test/ git commit -m "feat(app): in-app asset picker (wechat_assets_picker) - unlimited multi-select with range selection" ``` --- ### Task 6: 测试服务器验证(测试服务器优先工作流) **Files:** 无代码改动;产出验证记录 `docs/plans/progress/`(按项目验收记录惯例)。 - [ ] **Step 1: 构建指向测试服务器的包** Run: `cd apps/mobile && flutter build apk --release --dart-define=API_BASE=<测试服务器 cn-api 入口,见 docs/plans/2026-09-21-测试服务器部署方案.md 与 memory(端口 32005)>` Expected: 产物 `build/app/outputs/flutter-apk/app-release.apk` - [ ] **Step 2: 基线 A/B(并发效果量化)** - 同一批 20 张照片(同网络环境):新包默认(并发 4)vs `--dart-define=MAX_PARALLEL_UPLOADS=2` 各测一次; - 记录:入队阶段(全部 queued)耗时、传完总耗时、并发上传中通知条数。 - [ ] **Step 3: 功能核对清单** - [ ] 首次进选择器:系统存储权限弹窗,允许后网格正常;永久拒绝路径返回空(等同取消,无崩溃) - [ ] 长按进入多选 + 拖动连选;「全选」可用;zh/en 文案随 App 语言 - [ ] 一次选 **>100 张**(如 150 张)入队成功,队列页计数正确 - [ ] 连拍页(capture)相册入口同样生效;去重弹窗(filename+bytes)不受影响 - [ ] 上传中切后台:WorkManager 续传、通知进度正常 - [ ] 中途断网→恢复:waitingNetwork 退避与立即重试语义不变 - [ ] 完成后源文件按保留期清理(队列页无异常残留) - [ ] **Step 4: 记录验收结果 + Commit** ```bash git add docs/plans/progress/ git commit -m "docs(app): upload speedup + asset picker verification on test server" ``` --- ## 风险与回退 | 风险 | 缓解 | |---|---| | 并发 4 在弱网引发更多 waitingNetwork 抖动 | dart-define 回调 2/3 实测定值;失败分类/退避机制不变 | | 4 条并发上传通知观感 | 低优先级 channel 已设;观察后决定是否做汇总通知(Out of scope) | | Android 15+ dataSync FGS 6 小时上限影响超大尾批 | 既有幂等重传保障;本次不动 | | photo_manager 权限被永久拒绝 | 返回空列表=取消语义,不阻断主流程;后续可加「去设置」引导(不在本次) | | 大批量(数百张)DB/内存压力 | photo_manager 分页加载;drift 逐行写;队列页既有分页渲染 | 回退开关:并发(MAX_PARALLEL_UPLOADS)、准备并发(PREPARE_CONCURRENCY)均可用 dart-define 无代码回退;选图器回退 = revert Task 5 单个 commit。