Files
photo-live/docs/plans/2026-09-28-App上传提速与无限制选图-实施计划.md
2026-09-28 16:02:57 +08:00

44 KiB
Raw Permalink 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 张上限、支持长按拖动连选与全选;新增封面/宣传图上传前的裁剪调整页;相机预览改所见即所得并支持捏合变焦;完成服务端查重(409 PHOTO_DUPLICATE / force)的 App 侧集成闭环。

Architecture: 三处提速均为参数/局部改动,不动上传状态机与传输协议;选图替换仅改 multiImagePickerProvider 注入点实现 + Android 权限声明,队列/准备器/引擎零改动(Provider 注入架构已就位);裁剪为独立纯 Flutter 页面(crop_your_image),仅接相册表单两个选图入口,上传链路复用既有 initAlbumAsset → presign → complete;相机改动限于 CaptureSource/CameraPluginSource 与预览手势层;服务端查重集成走「force 任务级透传 + 409 归类不自动重试 + 专用文案」三件套,不动服务端。

实施前置检查(必做,已两次发生会话期间合入新提交): 同步最新 main;对本计划 Files 清单里的文件重跑 grep 核对行号与结构(尤其 enqueue/capture/album_detail/engine);确认没有并行会话在做 P0-1 批次 5(App 侧 force 接线是 P0-1 文档 §81 行既定剩余项,本计划 Task 8 承接——如另有会话在做,删除 Task 8 并保留其余)。

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 满足)、crop_your_image ^2.0.0 + image ^4.3.0(封面/宣传图裁剪,纯 Dart)。

已确认决策(2026-09-28):

  • ① 接受应用内选择器带来的存储权限弹窗(READ_MEDIA_IMAGES);
  • ② 不做客户端压缩(原图直传保持不变);
  • ③ 封面/宣传图增加上传前裁剪:选型见 Task 6(方案 A crop_your_image 为主,方案 B image_cropper 为回退);裁剪仅作用于封面/宣传图两张资产,照片上传不裁剪不压缩。
  • ④ 相机预览改「所见即所得」:按传感器比例(纵向 4:3 类)AspectRatio 居中显示,上下露出页面深色背景;不裁切、不拍后裁剪(拍后裁剪丢像素,与②保原片冲突,已否决);同时新增双指捏合变焦 + 双击复位。

明确不做(Out of scope):

  • 前台快路径传输(前台直连 dio、后台 WorkManager 接管)——待本次并发提升数据出来后再评估;
  • 批量 initUpload 契约扩展;
  • 并发 worker 通知合并(4 并发 = 4 条低优先级通知,可接受,观察后再定);
  • 队列页失败项的「仍要上传(force)」快捷动作——force 透传只做到入队弹窗层(Task 8);队列页 force 重试留后续;
  • 相册照片列表 UI 的 _localFilterAutoLoadCap = 100(album_photos_page.dart:534)与本计划无关——那是照片网格懒加载上限,不是上传限制,验收时勿混淆。

(注:相册详情页 _pickFromGallery 已在 638dd9b 接入 checkDuplicates 弹窗——两处入队入口查重均已就位,无需处理。)


背景(问题 → 根因速查)

问题 根因 证据
上传慢 并行传输上限=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 不支持区间拖选/全选,平台限制无解 同上
封面/宣传图无裁剪 相册表单选图后原样上传,16:9/3:1 展示区靠 BoxFit.cover 硬裁切显示,用户无法控制取景 album_form_page.dart:499-510(_pickCoverImage/_pickBannerImage 直接存路径)
相机预览「被放大」 预览用 FittedBox(cover) 全屏铺满:传感器纵向 4:3 vs 屏幕 ~20:9,横向裁掉 ~38% 画面,观感放大;且 takePicture 出的是全幅照片 → 预览所见 ≠ 照片所得 capture_page.dart:88-103(CameraPluginSource.buildPreview)
相机无变焦 未接 setZoomLevel,无捏合/倍数控制 capture_page.dart 全文无 zoom 相关代码
重复上传 409 死胡同 服务端查重(90dcc58)已上线但 App 侧未接线:initUpload 409 的 wire code=VALIDATION_FAILED(message=PHOTO_DUPLICATE),引擎归桶 4 自动重试 2 次后永久失败、队列页显示「参数校验失败」误导文案;force 字段存在但无人赋值——弹窗「全部上传」也会被 409 uploads.service.ts:124-143、upload_engine.dart:500-511(桶 4)、models.dart:121(force 无调用方)、error_l10n.dart:27-28

测试基线(Task 9 验证时先采旧包数据):同批 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: 首跑 FAIL——既有「串行执行」用例(preparer_test.dart:219-243,断言 createdAt 按提交顺序非降)在默认并发 3 下交错。处置:该用例构造处补 maxConcurrentPrepares: 1(保留串行语义回归保护),同步更新其分组名/注释为新语义;随后全部 PASS。

  • 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:152-167

说明:Task 2 后 preparer 内部已限流,外层 for-await 循环改为 Future.wait 即可。注意:enqueueForUpload 已是 P0-1 结构(返回 EnqueueResult,skipPaths 过滤在函数内)——并行化只改循环段,保留 effective 过滤与返回结构。

  • Step 1: 替换循环段(152-161 行)
  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
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: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 选图/连拍分组追加:

  # 选图 / 连拍
  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"/>
    <!-- API 34 引入、无上限——勿加 maxSdkVersion(加了会在 Android 15/16
         上消失,部分访问「选择照片」选项随之失效;photo_manager README 同款声明) -->
    <uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>
    <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 实现(文案代理用包内置的 locale 解析助手,勿手写 <语言>TextDelegate 类名——10.x 导出名不含 ChineseTextDelegate):

/// 多图选择 Provider(生产=wechat_assets_picker;测试 override)。
///
/// - 应用内选择器:长按进入多选 + 拖动连选 + 全选;无系统 Photo Picker
///   的 100 张硬上限(软上限 9999 防极端内存,photo_manager 网格分页
///   加载,实际不受限);
/// - originFile 取原始文件路径(Android 图片无转码副本,省一次 cache
///   中转复制);**路径解析有界并行**(批量数百张时避免串行平台通道
///   往返阻塞入队启动);
/// - 权限由 picker 内部请求;永久拒绝时 picker 自带 denied/limited 视图
///   (非「返回空」);返回 null 仅发生在用户取消。
final multiImagePickerProvider = Provider<MultiImagePicker>(
  (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 <String>[];
    final files = await Future.wait(assets.map((a) => a.originFile));
    return [
      for (final file in files)
        if (file != null) file.path,
    ];
  },
);

/// 选图软上限(用户要求不设硬限制;9999 为防误触极端值)。
const int kPickerSoftMaxAssets = 9999;

文件头补 import:

import 'package:flutter/widgets.dart' show BuildContext, Localizations;
import 'package:wechat_assets_picker/wechat_assets_picker.dart';

同步更新文件头 doc 注释(1-7 行):「image_picker 的平台调用」描述改为 wechat_assets_picker、「准备器内部串行复制」改为有界并行(与 Task 2 落地后的实际行为一致)。

  • Step 4: 更新两处调用点

album_detail_page.dart:341 _pickFromGallery(picker 需要 context,且此处在 await _currentAccountId() 之后——补 mounted 守卫防 use_build_context_synchronously lint):

    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) 改为:

            multiImagePickerProvider
                .overrideWith((ref) => (context) async => galleryPaths),

集成测试替身(integration_test/helpers/app_boot.dart:96)同步改签名:

        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
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: 相册封面/宣传图上传前裁剪(crop_your_image)

Files:

  • Modify: apps/mobile/pubspec.yaml
  • Create: apps/mobile/lib/pages/image_crop_page.dart
  • Modify: apps/mobile/lib/pages/album_form_page.dart:499-510
  • Modify: apps/mobile/lib/l10n/app_zh.arb、apps/mobile/lib/l10n/app_en.arb
  • Test: apps/mobile/test/pages/image_crop_page_test.dart

方案选型(决策 ③):

方案 优点 缺点
A(选定)crop_your_image ^2.0.0 纯 Flutter Crop 组件(固定纵横比、交互式裁剪框);UI/中英文文案完全走 App 主题与 l10n(与 UI03R 高保真一致);唯一依赖 image 包;iOS G3 无需 pods 大图像素级裁剪/编码在 Dart 层(12MP 约 1~3s,需「处理中」遮罩);image 包不解码 HEIC
B(回退备选)image_cropper ^12.2.1 原生 uCrop/TOCropViewController,开箱即用、原生性能 原生 UI 与 App 主题割裂;需 manifest 注册 UCropActivity + AppCompat 主题;iOS 侧留 G3

裁剪规格(对齐表单展示区,输出上限只作用于这两张资产,与「照片不压缩」决策无关):

  • 封面:16:9(表单预览区比例),输出宽 ≤1920(1920×1080);

  • 宣传图:3:1(观众页顶部横幅),输出宽 ≤2400(2400×800);

  • 输出 JPEG quality 90、.jpg 扩展名(保持 _guessContentType → image/jpeg 白名单)。

  • Step 1: 加依赖

pubspec.yaml 追加(image 需显式声明——页面直接 import 它编码输出,不能只靠传递依赖):

  # 封面/宣传图裁剪(纯 Flutter)
  crop_your_image: ^2.0.0
  image: ^4.3.0

Run: cd apps/mobile && flutter pub get

  • Step 2: l10n 文案(两个 arb 同步加,flutter gen-l10n 随构建自动生成)
  "cropTitle": "裁剪图片",
  "cropConfirm": "完成",
  "cropCancel": "取消",
  "cropProcessing": "处理中…",
  "cropUnsupportedFormat": "暂不支持该图片格式,请选择 JPG/PNG"
  • Step 3: 写失败测试(test/pages/image_crop_page_test.dart)
// 夹具:8×6 JPG 小图(测试资产目录已有 jpg 夹具可复用/新增)。
testWidgets('取消 → pop(null)', (tester) async {
  final popped = await pumpCropPage(tester, fixtureJpg, aspectRatio: 16 / 9);
  await tester.tap(find.text('取消'));
  await tester.pumpAndSettle();
  expect(popped.value, isNull);
});

testWidgets('确认 → pop 出 .jpg 路径', (tester) async {
  final popped = await pumpCropPage(tester, fixtureJpg, aspectRatio: 16 / 9);
  await tester.tap(find.text('完成'));
  await tester.pumpAndSettle(const Duration(seconds: 5));
  expect(popped.value, endsWith('.jpg'));
  expect(await File(popped.value!).exists(), isTrue);
});

Run: cd apps/mobile && flutter test test/pages/image_crop_page_test.dart Expected: FAIL(页面不存在)

  • Step 4: 实现 ImageCropPage
/// 封面/宣传图裁剪页:固定纵横比交互裁剪 → 等比缩放上限 → JPEG 落盘。
///
/// 输入 image_picker 选中的本机路径;输出裁剪后的临时文件路径
/// ({tmp}/album_assets/crop_{ms}.jpg);取消/失败返回 null。
class ImageCropPage extends StatefulWidget {
  const ImageCropPage({
    super.key,
    required this.sourcePath,
    required this.aspectRatio,
    this.maxOutputWidth,
  });

  /// 源图本机路径(image_picker 单选返回)。
  final String sourcePath;

  /// 目标宽高比(宽/高):封面 16/9、宣传图 3/1。
  final double aspectRatio;

  /// 输出宽度上限(等比缩放;null=保持裁剪原分辨率)。
  final int? maxOutputWidth;

  @override
  State<ImageCropPage> createState() => _ImageCropPageState();
}

要点(crop_your_image 2.0.0 是回调式 API,不是同步返回):

  • FutureBuilder 读源文件 bytes → Crop 组件(aspectRatio、interactive: true、initialRectBuilder 初始全图、fixCropRect: true 锁外框)+ controller: CropController() + onCropped: (CropResult result) 回调;

  • AppBar 标题 l10n.cropTitle,底部「取消」(pop null)/「完成」(_controller.crop()——void,结果走 onCropped);

  • onCropped 收到 CropSuccess(image: bytes) 后:Isolate.run 内做 image 包 decode → 超 maxOutputWidth 则 copyResize(width:) → encodeJpg(quality: 90) → 写 {tmp}/album_assets/crop_{ms}.jpg → pop(path)。像素处理必须进 isolate:12MP 源全量 decode+encode 在主 isolate 会卡死 UI 数秒,50MP 源约 200MB 内存峰值有 OOM 风险(Crop 组件自身已解码一次,这里避免二次解码叠在 UI 线程);

  • onCropped 收到 CropFailure / decode 失败(HEIC 等不支持的格式)→ 遮罩解除 + l10n.cropUnsupportedFormat SnackBar,不 pop;

  • 编码期间 _processing 遮罩 + l10n.cropProcessing(防重复点击);

  • 主题用项目 tokens(与 UI03R 一致),不引入新风格。

  • Step 5: 接线相册表单(album_form_page.dart:499-510 替换)

  Future<void> _pickCoverImage() async {
    final path = await ref.read(singleImagePickerProvider)();
    if (path == null || !mounted) return;
    final cropped = await Navigator.of(context).push<String>(
      MaterialPageRoute(
        builder: (_) => ImageCropPage(
          sourcePath: path,
          aspectRatio: 16 / 9,
          maxOutputWidth: 1920,
        ),
      ),
    );
    if (cropped != null && mounted) setState(() => _coverFilePath = cropped);
  }

_pickBannerImage 同构(aspectRatio: 3 / 1、maxOutputWidth: 2400)。语义:选图后必经裁剪(无「跳过」——原样上传入口由裁剪框拉到全图自然达成)。队列页 preparationFailed 重选源(queue_tab:742)不接裁剪——那是任务级重选,不是资产裁剪场景。

  • Step 6: 测试 + 静态检查

Run: cd apps/mobile && flutter analyze && flutter test test/pages/image_crop_page_test.dart test/pages/album_form_page_test.dart Expected: analyze 无新增告警;测试 PASS(album_form_page_test 现无用例直接驱动封面/宣传图选择——如实施中发现有,需补「pick → 裁剪页出现 → tap 完成」驱动链)

  • Step 7: Commit
git add apps/mobile/pubspec.yaml apps/mobile/pubspec.lock apps/mobile/lib/pages/image_crop_page.dart apps/mobile/lib/pages/album_form_page.dart apps/mobile/lib/l10n/ apps/mobile/test/pages/image_crop_page_test.dart
git commit -m "feat(app): crop cover & banner before upload (crop_your_image, 16:9 / 3:1)"

Task 7: 相机预览所见即所得 + 捏合变焦

Files:

  • Modify: apps/mobile/lib/pages/capture_page.dart:39-120(CaptureSource 接口 + CameraPluginSource)
  • Test: apps/mobile/test/pages/capture_page_test.dart(追加用例,现有 fake source 零改动——接口给默认实现)

根因(写给人看的为什么): buildPreview() 用 FittedBox(cover) 把纵向 4:3 的传感器画面铺满 ~20:9 的屏幕,scale = max(屏宽/图宽, 屏高/图高) 由高度撑满决定,宽度溢出 → 左右各裁约 19%(合计 ~38%)画面,所以「像被放大了」。且照片是全幅 4:3,预览所见 ≠ 照片所得。修法:预览按传感器比例 AspectRatio 居中(上下露出 kColorPhotoBackdrop 深色背景,与深色摄影界面浑然一体),不裁切不变形,所见即所得。

接口扩展策略: zoom 能力以默认实现挂在 CaptureSource 抽象类上(minZoom=1/maxZoom=1/setZoom no-op),只有 CameraPluginSource 覆写真实现——现有测试替身(fake source)不改不破。

  • Step 1: 写失败测试(capture_page_test.dart 追加)
testWidgets('捏合变焦:双指张开驱动 setZoom 并显示倍数徽标', (tester) async {
  // fake source 覆写 zoom 能力(min 1 / max 8,记录 setZoom 调用)。
  ...
  await tester.pumpWidget(bootCapturePage(zoomFake));
  // ⚠️ 必须两根指针:ScaleGestureRecognizer 的 scale = 当前指距/初始指距,
  // 单指针 span=0 → scale 恒为 1.0,永远测不到变焦。
  final p1 = await tester.startGesture(center.translate(-40, 0));
  final p2 = await tester.startGesture(center.translate(40, 0));
  await p1.moveBy(const Offset(-40, 0));
  await p2.moveBy(const Offset(40, 0));
  await tester.pump();
  await p1.up();
  await p2.up();
  expect(zoomFake.calls.last, greaterThan(1.0));
  expect(find.textContaining('×'), findsOneWidget);
});

testWidgets('双击复位 1.0×', (tester) async { ... expect(zoomFake.calls.last, 1.0); });

Run: cd apps/mobile && flutter test test/pages/capture_page_test.dart Expected: FAIL(无 zoom 能力/手势)

  • Step 2: 接口默认实现(CaptureSource,capture_page.dart:33-52 区段追加)
  /// 变焦能力下限(默认 1=不支持变焦;子类按硬件覆写)。
  double get minZoom => 1;

  /// 变焦能力上限(默认 1=不支持变焦)。
  double get maxZoom => 1;

  /// 设置变焦倍数(默认 no-op——能力为 1 时 UI 不启用手势)。
  Future<void> setZoom(double level) async {}
  • Step 3: CameraPluginSource 覆写
  @override
  Widget buildPreview() {
    // 所见即所得(§4 页面 13 修订,决策 ④):按传感器纵向比例居中显示,
    // 上下露出深色背景——不再 FittedBox cover 全屏(那会横向裁 ~38% 画面,
    // 且预览所见 ≠ takePicture 全幅照片)。
    final size = _controller.value.previewSize;
    Widget preview = CameraPreview(_controller);
    if (size != null && size.width > 0 && size.height > 0) {
      preview = Center(
        child: AspectRatio(
          // 交换:竖屏用 height 作宽、width 作高(传感器横向 → 纵向)。
          aspectRatio: size.height / size.width,
          child: preview,
        ),
      );
    }
    return preview;
  }

  double _zoomLevel = 1.0;

  @override
  double get minZoom => _minZoom;
  double _minZoom = 1;
  double _maxZoom = 1;

  @override
  double get maxZoom => _maxZoom;

  @override
  Future<void> setZoom(double level) async {
    final clamped = level.clamp(minZoom, maxZoom).toDouble();
    if (clamped == _zoomLevel) return;
    await _controller.setZoomLevel(clamped);
    _zoomLevel = clamped;
  }

  double get currentZoom => _zoomLevel;

create() 里 initialize() 后读能力(失败按不支持处理):

    await controller.initialize();
    final source = CameraPluginSource._(controller);
    try {
      source._minZoom = await controller.getMinZoomLevel();
      source._maxZoom = await controller.getMaxZoomLevel();
    } on Object {
      // 能力探测失败(部分设备/模拟器):保持 1/1,捏合不启用。
    }
    return source;
  • Step 4: 页面手势 + 倍数徽标

  • _buildCameraArea ready 分支:预览外包 GestureDetector(onScaleStart 记 _pinchBaseZoom,onScaleUpdate → setZoom(base * scale),onDoubleTap → setZoom(1)),仅 maxZoom > 1 时挂;

  • setZoom 节流与串行化:onScaleUpdate 每帧触发(60-120/s),不能每次都打平台通道——目标值与上次已发值差 ≥0.05 才调用;setZoom 在途时缓存最新目标值、完成后补发(防乱序导致徽标与硬件漂移);

  • 变焦徽标:捏合期间显示当前倍数(1.0× 格式,四舍五入 1 位小数),停止 1s 后淡出(复用 _feedbackTimer 模式);

  • _flipCamera/_initCamera 重开相机后 zoom 回 1(新 source 实例天然复位,徽标状态同步清理)。

  • Step 5: 测试 + 静态检查

Run: cd apps/mobile && flutter analyze && flutter test test/pages/capture_page_test.dart Expected: analyze 无新增告警;全部 PASS(含既有用例——fake source 未覆写 zoom 走默认实现)

  • Step 6: Commit
git add apps/mobile/lib/pages/capture_page.dart apps/mobile/test/pages/capture_page_test.dart
git commit -m "feat(app): camera preview WYSIWYG aspect + pinch zoom with double-tap reset"

Task 8: 服务端查重 App 侧集成(force 接线 + 409 分类与文案)

Files:

  • Modify: apps/mobile/lib/core/error_codes.dart(+photoDuplicate 常量)
  • Modify: apps/mobile/lib/core/api_client.dart(initUpload 409 归一)
  • Modify: apps/mobile/lib/core/error_l10n.dart(+文案)
  • Modify: apps/mobile/lib/features/uploads/task_store.dart(schema +force 列 + 迁移;task_store.g.dart build_runner 重生成)
  • Modify: apps/mobile/lib/features/uploads/{preparer,enqueue,upload_engine}.dart(force 透传 + 分类)
  • Modify: apps/mobile/lib/pages/{capture_page,album_detail_page}.dart(弹窗「全部上传」传 force)
  • Test: 上述各层对应测试

背景(为什么必须有这个任务): 90dcc58 服务端查重已上线:initUpload 同 albumId+uploaderId+bytes 已存在 → 409(wire code=VALIDATION_FAILED、message=PHOTO_DUPLICATE——契约未设独立错误码;force=true 仅 owner/admin 跳过)。App 侧只有 models 加了 force 字段,未接线。现状死胡同:引擎把该 409 归桶 4 → 自动重试 2 次(每次重新 init 又 409)→ 永久 retryableFailed;队列页 lastError 映射到「参数校验失败」类文案——完全误导;去重弹窗「全部上传」也无法把 force 传到服务端(guaranteed 409)。本计划解除选图上限后,重装/跨设备/改名文件的重复上传是高频路径,必须闭环。本任务承接 P0-1 文档既定的 App 侧剩余项(其 §文件清单「api_client.dart initUpload 加参数;409 PHOTO_DUPLICATE 处理」)——实施前按头部「实施前置检查」确认无并行会话撞车。

  • Step 1: 写失败测试(引擎分类)

engine 测试追加:ApiClient 替身对 initUpload 返回 409、body {code: 'VALIDATION_FAILED', message: 'PHOTO_DUPLICATE', requestId: 'r'} → 断言任务终态 retryableFailed 且 nextRetryAt == null(不自动重试)、lastError == 'PHOTO_DUPLICATE'。

Run: cd apps/mobile && flutter test test/features/uploads/ Expected: FAIL(现状归桶 4:nextRetryAt 非 null 且 lastError=VALIDATION_FAILED)

  • Step 2: 错误归一 + 文案

  • ErrorCodes 加 static const String photoDuplicate = 'PHOTO_DUPLICATE';

  • ApiClient 统一错误归一处:HTTP 409 且 message 含 PHOTO_DUPLICATE → AppException(code: photoDuplicate, statusCode: 409)(按 message 识别——wire code 复用 VALIDATION_FAILED 是 P0-1 契约现状;若契约日后设独立 code,此识别自动兼容);

  • error_l10n.dart 加映射:zh「照片可能重复:同相册已存在相同内容的照片」/ en「Possible duplicate: an identical photo already exists in this album」(队列页 lastError 展示链路复用)。

  • Step 3: 引擎分类(不自动重试桶)

_classifyFailure 桶 3(不自动重试,人工 retryNow 语义保留)条件追加 code == ErrorCodes.photoDuplicate。注意:retryNow 重试仍不带 force——用户想强传需重走选图弹窗选「全部上传」;队列页不做 force 快捷动作(Out of scope)。

  • Step 4: force 任务级透传(TDD:store 层先写失败测试)

  • UploadTasks 表加 force BOOLEAN NOT NULL DEFAULT 0;drift schemaVersion +1 + 迁移(照 task_store.dart 既有迁移样例);flutter pub run build_runner build --delete-conflicting-outputs 重生成 .g.dart;补旧库升级迁移测试(既有行保留、force=0);

  • UploadTaskDraft/UploadTask +force;preparer.prepare +force 参数透传 create;

  • enqueueForUpload +List<String>? forcePaths(默认空集):命中路径建任务 force=true;

  • 两处弹窗 'all' 分支(capture_page / album_detail_page 的 _showDuplicateDialog 调用侧):enqueueForUpload(..., forcePaths: dupPaths)——「全部上传」= 用户已确认强传;'skip' 分支不变(跳过重复项)。协作者选「全部上传」仍会被服务端 409 → 走 Step 2/3 的可见失败 + 明确文案(权限语义:force 仅 owner/admin 生效,服务端裁决);

  • engine _transferSteps:UploadInitRequest(..., force: task.force)。

  • Step 5: 测试更新

  • 页面测试:弹窗「全部上传」路径断言 force 透传(fake preparer 捕获 draft.force=true);

  • engine 测试:force=true 的任务 init 请求体携带 force: true。

  • Step 6: 全量测试 + 静态检查

Run: cd apps/mobile && flutter analyze && flutter test Expected: analyze 无新增告警;全部 PASS

  • Step 7: Commit
git add apps/mobile/lib apps/mobile/test
git commit -m "feat(app): wire server dedup - force passthrough + PHOTO_DUPLICATE classification & l10n"

Task 9: 测试服务器验证(测试服务器优先工作流)

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)耗时:PREPARE_CONCURRENCY 1 vs 3 各测一次;

  • 记录:入队耗时、传完总耗时、并发上传中通知条数;上传延迟异常时用 queryTask(engine 已有)核对 WorkManager expedited 是否降级排队;

  • 服务端观察:大批量(150+ 张)连续上传后水印处理是否积压(管理后台/照片可见延迟)——并发提升会放大既有水印队列水位,仅记录不处理。

  • Step 3: 功能核对清单

  • 首次进选择器:系统存储权限弹窗,允许后网格正常;部分访问(「选择照片」)路径可用——picker 的 limited 视图与管理入口正常;永久拒绝时 picker 自带 denied 视图(含设置引导),不崩溃

  • 长按进入多选 + 拖动连选;「全选」可用;zh/en 文案随 App 语言

  • 一次选 >100 张(如 150 张)入队成功,队列页计数正确;记录「确定 → 入队 SnackBar」耗时(originFile 路径解析 O(N),150+/500+ 张时观察;Android 10 设备走 ScopedCache 复制会显著更慢——有条件单独测)

  • 坏资产隔离:批次中混入一个不可解析项(如选择后立刻从系统相册删除该照片)→ 仅跳过该张,整批不丢、无崩溃

  • 连拍页(capture)相册入口同样生效

  • 换选择器后查重语义:重复选择同一批已上传照片(filename+bytes 命中)→ 去重弹窗仍出现(originFile 文件名与 image_picker cache 名可能不同,重点核对匹配)

  • 上传中切后台:WorkManager 续传、通知进度正常

  • 中途断网→恢复:waitingNetwork 退避与立即重试语义不变

  • 完成后源文件按保留期清理(队列页无异常残留)

  • 封面/宣传图裁剪:选图 → 拖动/缩放裁剪框 → 完成后表单预览即裁剪结果 → 保存后上传成功;观众页横幅 3:1 显示无变形;编辑相册重复流程正常

  • HEIC 源图:确认时提示「暂不支持该图片格式」(不崩溃、不 pop);选 JPG/PNG 正常

  • GIF/截断图片源(封面/宣传图裁剪):GIF → 预检即提示不支持(不进转圈);签名合法但内容损坏的 JPEG/PNG → 若出现永久转圈记录为已知包缺陷(观察项,处理留后续)

  • 服务端查重闭环(Task 8):owner 重复选同一批照片 → 弹窗「全部上传」→ 上传成功(force 生效);弹窗「跳过重复」→ 只传非重复项;改名后的重复文件(本地 filename 查重不命中)→ 服务端 409 → 队列页失败项显示「照片可能重复」文案(非「参数校验失败」)、不自动重试烧次数;协作者账号「全部上传」重复项 → 409 失败可见(force 权限拦截)

  • 相机预览所见即所得:取景画面与拍出照片内容一致(无「放大/视野变窄」感);上下留边为深色背景不突兀;连拍、闪光、前后摄切换、分类切换全部正常

  • 变焦:双指捏合流畅放大缩小、徽标显示当前倍数、双击复位 1.0×;切前摄/重开相机后复位;变焦后拍照照片与预览取景一致;不支持变焦的设备(模拟器)捏合无响应且无崩溃

  • Step 4: 记录验收结果 + Commit

git add docs/plans/progress/
git commit -m "docs(app): upload speedup + asset picker + crop + camera 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 逐行写;队列页既有分页渲染
image 包纯 Dart 编码性能 像素处理全部进 Isolate.run(Crop 组件已解码一次,确认链路二次解码+缩放+编码不再占用 UI isolate);输出宽度上限 1920/2400 界定耗时;「处理中」遮罩防重复触发;不可接受则回退方案 B(image_cropper 原生)
与并行会话 P0-1 批次 5 撞车(Task 8 承接其 App 侧剩余项) 头部「实施前置检查」确认;撞车则删除 Task 8 保留其余(任务间无依赖)
image 包不支持 HEIC 解码 裁剪确认时报 cropUnsupportedFormat,引导改选 JPG/PNG;后续如需 HEIC 支持再评估归一化插件(不在本次)
crop_your_image 2.0.0 发布已 21 个月 纯 Dart 组件 API 稳定、无原生面;若遇阻整体切方案 B,接线点不变(只换 ImageCropPage 内部实现)
setZoomLevel 设备差异(部分设备 min>1 或不支持) initialize 后能力探测,探测失败/能力为 1 时禁用手势与徽标(默认实现兜底)
AspectRatio 留边在异形屏的观感 背景即 kColorPhotoBackdrop(深色摄影界面既有色),上下留边与 UI 层同域;真机核对列入 Task 8 清单

回退开关:并发(MAX_PARALLEL_UPLOADS)、准备并发(PREPARE_CONCURRENCY)均可用 dart-define 无代码回退;选图器回退 = revert Task 5 单个 commit;裁剪回退 = revert Task 6 单个 commit(接线点只在两个 _pickXxxImage,或仅把 ImageCropPage 内部实现换成方案 B);相机回退 = revert Task 7 单个 commit(预览/变焦互不耦合,也可只回退手势层);查重集成回退 = revert Task 8 代码(drift 迁移不可回滚删除——schema 版本保留前滚,revert 时仅回退逻辑代码、迁移空壳留置,force 列默认 0 无害)。