feat(app): P0 batch 3 — offline detection with engine pause/resume + UI banner
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<NetworkAvailability>((ref) async* {
|
||||
final connectivity = Connectivity();
|
||||
try {
|
||||
// 初始值(checkConnectivity 是 Future<List<ConnectivityResult>>)。
|
||||
final initial = await connectivity.checkConnectivity();
|
||||
yield _map(initial);
|
||||
// 后续变化(onConnectivityChanged 是 Stream<List<ConnectivityResult>>)。
|
||||
yield* connectivity.onConnectivityChanged.map(_map);
|
||||
} on Object {
|
||||
// 权限不足/平台不支持——报 unknown(不阻塞 App)。
|
||||
yield NetworkAvailability.unknown;
|
||||
}
|
||||
});
|
||||
|
||||
NetworkAvailability _map(List<ConnectivityResult> 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;
|
||||
}
|
||||
@@ -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<String?>((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);
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.';
|
||||
}
|
||||
|
||||
@@ -1001,4 +1001,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get albumUploadDisabled => '该相册已关闭上传,无法添加照片';
|
||||
|
||||
@override
|
||||
String get networkOfflineBanner => '当前无网络连接,照片将在恢复后自动上传';
|
||||
}
|
||||
|
||||
@@ -428,5 +428,6 @@
|
||||
"posterSaved": "海报已保存到相册",
|
||||
"posterSaveFailed": "海报保存失败",
|
||||
"close": "关闭",
|
||||
"albumUploadDisabled": "该相册已关闭上传,无法添加照片"
|
||||
"albumUploadDisabled": "该相册已关闭上传,无法添加照片",
|
||||
"networkOfflineBanner": "当前无网络连接,照片将在恢复后自动上传"
|
||||
}
|
||||
|
||||
@@ -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<HomePage> {
|
||||
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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<Directory> 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<void> _tail` 与旧 `_serialize`):
|
||||
|
||||
```dart
|
||||
/// 在途准备计数 + FIFO 等待队列(有界并发信号量)。
|
||||
int _activePrepares = 0;
|
||||
final Queue<Completer<void>> _prepareWaiters = Queue<Completer<void>>();
|
||||
|
||||
/// 有界并发执行:超额排队(FIFO);错误由调用方经返回值处理,不设全局断链。
|
||||
Future<T> _runBounded<T>(Future<T> Function() op) {
|
||||
final ticket = _acquirePrepareSlot();
|
||||
final result = ticket.then((_) => op());
|
||||
result.whenComplete(_releasePrepareSlot).ignore();
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<void> _acquirePrepareSlot() {
|
||||
if (_activePrepares < _effectivePrepareConcurrency) {
|
||||
_activePrepares++;
|
||||
return Future<void>.value();
|
||||
}
|
||||
final waiter = Completer<void>();
|
||||
_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<int> 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
|
||||
<!-- 应用内相册选择器(wechat_assets_picker/photo_manager):图片读取 -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"
|
||||
android:maxSdkVersion="34"/>
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32"/>
|
||||
```
|
||||
|
||||
(iOS 留 G3:届时 Info.plist 需 `NSPhotoLibraryUsageDescription`。)
|
||||
|
||||
- [ ] **Step 3: 替换 multiImagePickerProvider 实现**(`enqueue.dart`)
|
||||
|
||||
typedef 增加BuildContext(picker 需页面上下文):
|
||||
|
||||
```dart
|
||||
/// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。
|
||||
typedef MultiImagePicker = Future<List<String>> Function(BuildContext context);
|
||||
```
|
||||
|
||||
provider 实现:
|
||||
|
||||
```dart
|
||||
/// 多图选择 Provider(生产=wechat_assets_picker;测试 override)。
|
||||
///
|
||||
/// - 应用内选择器:长按进入多选 + 拖动连选 + 全选;无系统 Photo Picker
|
||||
/// 的 100 张硬上限(软上限 9999 防极端内存,photo_manager 网格分页
|
||||
/// 加载,实际不受限);
|
||||
/// - originFile 取原始文件路径(Android 图片无转码副本,省一次 cache
|
||||
/// 中转复制);权限请求由 picker 内部处理,拒绝时返回空(等同取消)。
|
||||
final multiImagePickerProvider = Provider<MultiImagePicker>(
|
||||
(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 <String>[];
|
||||
final paths = <String>[];
|
||||
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。
|
||||
Reference in New Issue
Block a user