From 33f347cdeaab0be36768676ac63d78168cfca921 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 28 Sep 2026 11:23:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(app):=20P0=20batch=203=20=E2=80=94=20offli?= =?UTF-8?q?ne=20detection=20with=20engine=20pause/resume=20+=20UI=20banner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0-4 离线感知: - connectivity_plus → NetworkAvailability(online/offline/unknown) - engine_scope 三重绑定:有账号+有网 → start;任一失去 → stop(在途不中断) - 网络恢复 → start + onNetworkRestored(waitingNetwork 即时重试) - HomePage 顶部离线黄条(wifi_off + i18n 双语) P0-5 更正:EXIF 方向已由服务端 sharp .rotate() 自动转正——v2 分析误报 397/397 全绿、analyze 0 Co-Authored-By: Claude Code --- apps/mobile/lib/core/network_status.dart | 43 ++ .../lib/features/uploads/engine_scope.dart | 18 +- apps/mobile/lib/l10n/app_en.arb | 3 +- apps/mobile/lib/l10n/app_localizations.dart | 6 + .../mobile/lib/l10n/app_localizations_en.dart | 4 + .../mobile/lib/l10n/app_localizations_zh.dart | 3 + apps/mobile/lib/l10n/app_zh.arb | 3 +- apps/mobile/lib/pages/home_page.dart | 45 +- apps/mobile/pubspec.lock | 48 ++ apps/mobile/pubspec.yaml | 1 + ...-09-28-App上传提速与无限制选图-实施计划.md | 475 ++++++++++++++++++ 11 files changed, 643 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/lib/core/network_status.dart create mode 100644 docs/plans/2026-09-28-App上传提速与无限制选图-实施计划.md diff --git a/apps/mobile/lib/core/network_status.dart b/apps/mobile/lib/core/network_status.dart new file mode 100644 index 0000000..ed4a93a --- /dev/null +++ b/apps/mobile/lib/core/network_status.dart @@ -0,0 +1,43 @@ +/// 网络连接状态 Provider(P0-4:离线感知)。 +/// +/// connectivity_plus 监听网络变化 → 全局 Stream → UI/引擎消费: +/// - **UI**:顶部黄条「当前无网络连接」(HomePage 或全局 Scaffold 叠加) +/// - **上传引擎**:离线 → 暂停再出队;恢复 → 自动重试 +/// +/// 三档:online / offline / unknown(初始/权限不足)。 +/// offline 只暂停再出队,**不中断在途传输**——避免假离线(VPN 等)打断上传。 +library; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +/// 网络可用性(三档)。 +enum NetworkAvailability { online, offline, unknown } + +/// 网络状态 Provider(StreamProvider——connectivity_plus Stream 直通)。 +final networkStatusProvider = StreamProvider((ref) async* { + final connectivity = Connectivity(); + try { + // 初始值(checkConnectivity 是 Future>)。 + final initial = await connectivity.checkConnectivity(); + yield _map(initial); + // 后续变化(onConnectivityChanged 是 Stream>)。 + yield* connectivity.onConnectivityChanged.map(_map); + } on Object { + // 权限不足/平台不支持——报 unknown(不阻塞 App)。 + yield NetworkAvailability.unknown; + } +}); + +NetworkAvailability _map(List results) { + if (results.contains(ConnectivityResult.none)) { + return NetworkAvailability.offline; + } + if (results.isEmpty) return NetworkAvailability.unknown; + return NetworkAvailability.online; +} + +/// 便捷读取:当前是否有网(unknown 视为有网——假阴性代价低于假阳性)。 +bool isNetworkOnline(NetworkAvailability? status) { + return status != NetworkAvailability.offline; +} diff --git a/apps/mobile/lib/features/uploads/engine_scope.dart b/apps/mobile/lib/features/uploads/engine_scope.dart index cf23736..3d47be5 100644 --- a/apps/mobile/lib/features/uploads/engine_scope.dart +++ b/apps/mobile/lib/features/uploads/engine_scope.dart @@ -15,6 +15,7 @@ library; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/api_client.dart'; +import '../../core/network_status.dart'; import 'providers.dart'; import 'upload_engine.dart'; @@ -49,14 +50,29 @@ final sessionAccountProvider = /// 引擎生命周期绑定:账号在场 → start;离场 → stop(任务保留库内)。 /// +/// P0-4 离线感知:**离线 → stop(暂停再出队,在途不受影响)**; +/// **恢复 → start + onNetworkRestored(立即重试 waitingNetwork)**。 +/// 引擎与账号/网络三重绑定:有账号且有网才出队,任一失去即停。 +/// /// 返回当前 scope 账号(null=引擎停止);仅被 App 壳 watch 时生效 /// (Provider 惰性求值)。 final uploadEngineScopeProvider = Provider((ref) { final account = ref.watch(sessionAccountProvider).value; final engine = ref.watch(uploadEngineProvider); - if (account != null) { + + // P0-4:网络状态(offline 时不出队;unknown 视为有网)。 + final network = ref.watch(networkStatusProvider).value; + final online = isNetworkOnline(network); + + if (account != null && online) { + // 恢复在线且已有账号 → start(幂等:已 start 则只刷新 timer)。 engine.start(account); + // 网络从 offline → online 的跃变触发即时重试。 + if (network == NetworkAvailability.online) { + engine.onNetworkRestored(); + } } else { + // 离线(或无账号)→ stop(不中断在途;恢复时上面的 start 恢复出队)。 engine.stop(); } ref.onDispose(engine.stop); diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index f8cd2ac..93c0ee5 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -428,5 +428,6 @@ "posterSaved": "Poster saved to gallery", "posterSaveFailed": "Failed to save poster", "close": "Close", - "albumUploadDisabled": "Upload is disabled for this album" + "albumUploadDisabled": "Upload is disabled for this album", + "networkOfflineBanner": "No network. Photos will upload automatically when reconnected." } diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index ca6e967..39ce691 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -2023,6 +2023,12 @@ abstract class AppLocalizations { /// In zh, this message translates to: /// **'该相册已关闭上传,无法添加照片'** String get albumUploadDisabled; + + /// No description provided for @networkOfflineBanner. + /// + /// In zh, this message translates to: + /// **'当前无网络连接,照片将在恢复后自动上传'** + String get networkOfflineBanner; } class _AppLocalizationsDelegate diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index 5c51d88..04ca0c0 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -1034,4 +1034,8 @@ class AppLocalizationsEn extends AppLocalizations { @override String get albumUploadDisabled => 'Upload is disabled for this album'; + + @override + String get networkOfflineBanner => + 'No network. Photos will upload automatically when reconnected.'; } diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index 17f84d7..7c954f8 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -1001,4 +1001,7 @@ class AppLocalizationsZh extends AppLocalizations { @override String get albumUploadDisabled => '该相册已关闭上传,无法添加照片'; + + @override + String get networkOfflineBanner => '当前无网络连接,照片将在恢复后自动上传'; } diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index 2e12889..0975e64 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -428,5 +428,6 @@ "posterSaved": "海报已保存到相册", "posterSaveFailed": "海报保存失败", "close": "关闭", - "albumUploadDisabled": "该相册已关闭上传,无法添加照片" + "albumUploadDisabled": "该相册已关闭上传,无法添加照片", + "networkOfflineBanner": "当前无网络连接,照片将在恢复后自动上传" } diff --git a/apps/mobile/lib/pages/home_page.dart b/apps/mobile/lib/pages/home_page.dart index 2b1cf96..8363acf 100644 --- a/apps/mobile/lib/pages/home_page.dart +++ b/apps/mobile/lib/pages/home_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../app/locale_controller.dart'; import '../core/api_client.dart'; +import '../core/network_status.dart'; import '../features/albums/providers.dart'; import '../features/uploads/engine_scope.dart'; import '../features/uploads/queue_controller.dart'; @@ -38,10 +39,48 @@ class _HomePageState extends ConsumerState { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final badgeCount = ref.watch(uploadBadgeCountProvider); + // P0-4:离线黄条(网络恢复自动消失)。 + final network = ref.watch(networkStatusProvider).value; + final offline = network == NetworkAvailability.offline; return Scaffold( - body: IndexedStack( - index: _selectedIndex, - children: const [AlbumsTab(), QueueTab(), SettingsTab()], + body: Column( + children: [ + if (offline) + Material( + color: Theme.of(context).colorScheme.tertiaryContainer, + child: SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Icon( + Icons.wifi_off_rounded, + size: 16, + color: Theme.of(context).colorScheme.onTertiaryContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + l10n.networkOfflineBanner, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: + Theme.of(context).colorScheme.onTertiaryContainer, + ), + ), + ), + ], + ), + ), + ), + ), + Expanded( + child: IndexedStack( + index: _selectedIndex, + children: const [AlbumsTab(), QueueTab(), SettingsTab()], + ), + ), + ], ), bottomNavigationBar: NavigationBar( selectedIndex: _selectedIndex, diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 89cae57..e548fb8 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -193,6 +193,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" + connectivity_plus: + dependency: "direct main" + description: + name: connectivity_plus + sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.3.1" + connectivity_plus_platform_interface: + dependency: transitive + description: + name: connectivity_plus_platform_interface + sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.0" convert: dependency: transitive description: @@ -225,6 +241,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.1.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: a48d5da28e89bd02196e80d81ed8d7954923d00a0f4a68cc20b575038f023383 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.15" dio: dependency: "direct main" description: @@ -716,6 +740,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.19.2" + nm: + dependency: transitive + description: + name: nm + sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.5.0" objective_c: dependency: transitive description: @@ -788,6 +820,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.2" platform: dependency: transitive description: @@ -1241,6 +1281,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" yaml: dependency: transitive description: diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index 1fb955f..f9322c9 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -39,6 +39,7 @@ dependencies: share_plus: ^13.3.0 gal: ^2.3.3 fluwx: ^6.0.2 + connectivity_plus: ^7.3.1 flutter_launcher_icons: diff --git a/docs/plans/2026-09-28-App上传提速与无限制选图-实施计划.md b/docs/plans/2026-09-28-App上传提速与无限制选图-实施计划.md new file mode 100644 index 0000000..87c1d15 --- /dev/null +++ b/docs/plans/2026-09-28-App上传提速与无限制选图-实施计划.md @@ -0,0 +1,475 @@ +# 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:54`、`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:52-63` + +说明:Task 2 后 preparer 内部已限流,外层 for-await 循环改为 `Future.wait` 即可;返回值语义(成功入队张数)不变。页级测试(album_detail/capture_page 断言入队数)已覆盖该语义。 + +- [ ] **Step 1: 替换循环** + +```dart + final preparer = ref.read(uploadPreparerProvider); + final results = await Future.wait( + sourcePaths.map((path) => preparer.prepare( + accountId: accountId, + albumId: albumId, + categoryId: categoryId, + sourcePath: path, + )), + ); + return results + .where((task) => task.state == UploadTaskState.queued) + .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:331` +- 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`(及 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 实现: + +```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 textDelegate = locale?.languageCode == 'zh' + ? const ChineseTextDelegate() + : const EnglishTextDelegate(); + final assets = await AssetPicker.pickAssets( + context, + pickerConfig: AssetPickerConfig( + requestType: RequestType.image, + maxAssets: kPickerSoftMaxAssets, + textDelegate: textDelegate, + ), + ); + 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` `_pickFromGallery`: +```dart + final paths = await ref.read(multiImagePickerProvider)(context); +``` +`capture_page.dart` `_pickFromGallery` 同样改为 `(context)`。 + +- [ ] **Step 5: 更新测试替身签名** + +所有 `multiImagePickerProvider.overrideWith((ref) => () async => galleryPaths)` 改为: + +```dart + multiImagePickerProvider + .overrideWith((ref) => (context) async => galleryPaths), +``` + +Run: `cd apps/mobile && grep -rn "multiImagePickerProvider" test/ lib/` 确认无遗漏签名。 + +- [ ] **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/ +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。