Files
photo-live/docs/plans/2026-09-28-App上传提速与无限制选图-实施计划.md
T
浅念 33f347cdea 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>
2026-09-28 11:23:51 +08:00

19 KiB
Raw Blame History

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-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
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 追加)
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-define PREPARE_CONCURRENCY 覆盖;1=串行)。
const int kMaxConcurrentPrepares =
    int.fromEnvironment('PREPARE_CONCURRENCY', defaultValue: 3);
  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):

  /// 在途准备计数 + 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
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: 替换循环
  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
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 内加常量:

        /** 进度回调最小步长(字节);末块必报。 */
        private const val PROGRESS_STEP_BYTES = 256L * 1024

writeTo 改为:

            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)
    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
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 选图/连拍分组追加:

  # 选图 / 连拍
  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 已满足包要求):

    <!-- 应用内相册选择器(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 需页面上下文):

/// 多图选择(相册多选入队);返回源文件路径列表(空=取消)。
typedef MultiImagePicker = Future<List<String>> Function(BuildContext context);

provider 实现:

/// 多图选择 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:

import 'package:flutter/widgets.dart' show BuildContext, Localizations;
import 'package:wechat_assets_picker/wechat_assets_picker.dart';
  • Step 4: 更新两处调用点

album_detail_page.dart _pickFromGallery:

    final paths = await ref.read(multiImagePickerProvider)(context);

capture_page.dart _pickFromGallery 同样改为 (context)。

  • Step 5: 更新测试替身签名

所有 multiImagePickerProvider.overrideWith((ref) => () async => galleryPaths) 改为:

            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
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

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。