已开启
feat: mask camera-pipeline reconfigure latency on slow platforms (HarmonyOS) #9
feat: mask camera-pipeline reconfigure latency on slow platforms (HarmonyOS) #9
已开启
发条创建于 6月3日
共 2 个文件变更+140-117
@@ -5,6 +5,7 @@
5import 'dart:async';5import 'dart:async';
6import 'dart:io';6import 'dart:io';
7import 'dart:math' as math;7import 'dart:math' as math;
8+import 'dart:ui' show ImageFilter;
8 9 
9import 'package:camera/camera.dart';10import 'package:camera/camera.dart';
10import 'package:camera_platform_interface/camera_platform_interface.dart';11import 'package:camera_platform_interface/camera_platform_interface.dart';
@@ -104,6 +105,10 @@ class CameraPickerState extends State<CameraPicker>
104 /// 当长按拍照按钮时,会进入准备录制视频的状态,此时需要执行动画。105 /// 当长按拍照按钮时,会进入准备录制视频的状态,此时需要执行动画。
105 bool isShootingButtonAnimate = false;106 bool isShootingButtonAnimate = false;
106 107 
108+ /// 录制启动期(原生切到录像管线那一刻,预览会短暂定格)显示模糊过渡,把定格遮成
109+ /// "刻意虚化"。鸿蒙端单会话方案下这一下约 166ms;置 true 时预览叠加模糊动画。
110+ bool isRecordStarting = false;
111+ 
107 /// Whether the [buildCaptureButton] is being tapped down.112 /// Whether the [buildCaptureButton] is being tapped down.
108 /// 拍照按钮是否已经按下113 /// 拍照按钮是否已经按下
109 bool isCaptureButtonTapDown = false;114 bool isCaptureButtonTapDown = false;
@@ -440,9 +445,7 @@ class CameraPickerState extends State<CameraPicker>
440 final Stopwatch stopwatch = Stopwatch()..start();445 final Stopwatch stopwatch = Stopwatch()..start();
441 await newController.initialize();446 await newController.initialize();
442 stopwatch.stop();447 stopwatch.stop();
443- realDebugPrint(448+ realDebugPrint("${stopwatch.elapsed} for controller's initialization.");
444- "${stopwatch.elapsed} for controller's initialization.",
445- );
446 // Call recording preparation first.449 // Call recording preparation first.
447 if (shouldPrepareForVideoRecording) {450 if (shouldPrepareForVideoRecording) {
448 stopwatch451 stopwatch
@@ -456,71 +459,68 @@ class CameraPickerState extends State<CameraPicker>
456 stopwatch459 stopwatch
457 ..reset()460 ..reset()
458 ..start();461 ..start();
459- await Future.wait(462+ await Future.wait(<Future<void>>[
460- <Future<void>>[463+ wrapControllerMethod(
461- wrapControllerMethod(464+ 'getExposureOffsetStepSize',
462- 'getExposureOffsetStepSize',465+ () => newController.getExposureOffsetStepSize(),
463- () => newController.getExposureOffsetStepSize(),466+ camera: camera,
464- camera: camera,467+ fallback: exposureStep,
465- fallback: exposureStep,468+ ).then((value) => exposureStep = value!),
466- ).then((value) => exposureStep = value!),469+ wrapControllerMethod(
467- wrapControllerMethod(470+ 'getMaxExposureOffset',
468- 'getMaxExposureOffset',471+ () => newController.getMaxExposureOffset(),
469- () => newController.getMaxExposureOffset(),472+ camera: camera,
470- camera: camera,473+ fallback: maxAvailableExposureOffset,
471- fallback: maxAvailableExposureOffset,474+ ).then((value) => maxAvailableExposureOffset = value!),
472- ).then((value) => maxAvailableExposureOffset = value!),475+ wrapControllerMethod(
473- wrapControllerMethod(476+ 'getMinExposureOffset',
474- 'getMinExposureOffset',477+ () => newController.getMinExposureOffset(),
475- () => newController.getMinExposureOffset(),478+ camera: camera,
476- camera: camera,479+ fallback: minAvailableExposureOffset,
477- fallback: minAvailableExposureOffset,480+ ).then((value) => minAvailableExposureOffset = value!),
478- ).then((value) => minAvailableExposureOffset = value!),481+ wrapControllerMethod(
479- wrapControllerMethod(482+ 'getMaxZoomLevel',
480- 'getMaxZoomLevel',483+ () => newController.getMaxZoomLevel(),
481- () => newController.getMaxZoomLevel(),484+ camera: camera,
482- camera: camera,485+ fallback: maxAvailableZoom,
483- fallback: maxAvailableZoom,486+ ).then((value) => maxAvailableZoom = value!),
484- ).then((value) => maxAvailableZoom = value!),487+ wrapControllerMethod(
485- wrapControllerMethod(488+ 'getMinZoomLevel',
486- 'getMinZoomLevel',489+ () => newController.getMinZoomLevel(),
487- () => newController.getMinZoomLevel(),490+ camera: camera,
488- camera: camera,491+ fallback: minAvailableZoom,
489- fallback: minAvailableZoom,492+ ).then((value) => minAvailableZoom = value!),
490- ).then((value) => minAvailableZoom = value!),493+ wrapControllerMethod(
cpf-manager
cpf-managercpf-manager6月3日

【AI-Review】【严重】【基础代码问题】【代码逻辑错误】getMinZoomLevel 重复调用

● 问题:Future.wait 列表中 getMinZoomLevel 被连续调用了两次(第487行和第493行),参数完全相同,第二次调用是冗余的。虽然这是 PR 重构前就存在的问题,但本次 PR 对该代码块做了格式调整,应当一并修复。

● 影响:严重。导致对原生层发起一次不必要的平台通道调用,在慢速平台上增加额外延迟;同时 minAvailableZoom 被赋值两次,虽然值相同不会产生功能错误,但属于逻辑冗余。

● 建议:删除第493-498行的重复调用:

// 删除以下重复调用
wrapControllerMethod(
  'getMinZoomLevel',
  () => newController.getMinZoomLevel(),
  camera: camera,
  fallback: minAvailableZoom,
).then((value) => minAvailableZoom = value!),
likedislike
491- wrapControllerMethod(494+ 'getMinZoomLevel',
492- 'getMinZoomLevel',495+ () => newController.getMinZoomLevel(),
493- () => newController.getMinZoomLevel(),496+ camera: camera,
494- camera: camera,497+ fallback: minAvailableZoom,
495- fallback: minAvailableZoom,498+ ).then((value) => minAvailableZoom = value!),
496- ).then((value) => minAvailableZoom = value!),499+ if (pickerConfig.lockCaptureOrientation != null)
497- if (pickerConfig.lockCaptureOrientation != null)500+ wrapControllerMethod<void>(
498- wrapControllerMethod<void>(501+ 'lockCaptureOrientation',
499- 'lockCaptureOrientation',502+ () => newController.lockCaptureOrientation(
500- () => newController.lockCaptureOrientation(503+ pickerConfig.lockCaptureOrientation,
501- pickerConfig.lockCaptureOrientation,
502- ),
503- camera: camera,
504 ),504 ),
505- // Do not set flash modes for the front camera.505+ camera: camera,
506- if (camera.lensDirection != CameraLensDirection.front)506+ ),
507- Future(() async {507+ // Do not set flash modes for the front camera.
508- final flashMode = pickerConfig.preferredFlashMode;508+ if (camera.lensDirection != CameraLensDirection.front)
509- if (flashMode != FlashMode.auto &&509+ Future(() async {
510- validFlashModes[camera]?.contains(flashMode) != false) {510+ final flashMode = pickerConfig.preferredFlashMode;
511- return wrapControllerMethod<void>(511+ if (flashMode != FlashMode.auto &&
512- 'setFlashMode',512+ validFlashModes[camera]?.contains(flashMode) != false) {
513- () => newController.setFlashMode(flashMode),513+ return wrapControllerMethod<void>(
514- camera: camera,514+ 'setFlashMode',
515- onError: () {515+ () => newController.setFlashMode(flashMode),
516- validFlashModes[camera]?.remove(flashMode);516+ camera: camera,
517- },517+ onError: () {
518- );518+ validFlashModes[camera]?.remove(flashMode);
519- }519+ },
520- }),520+ );
521- ],521+ }
522- eagerError: false,522+ }),
523- );523+ ], eagerError: false);
524 stopwatch.stop();524 stopwatch.stop();
525 realDebugPrint("${stopwatch.elapsed} for config's update.");525 realDebugPrint("${stopwatch.elapsed} for config's update.");
526 innerController = newController;526 innerController = newController;
@@ -964,19 +964,13 @@ class CameraPickerState extends State<CameraPicker>
964 return Navigator.of(context).pop(entity);964 return Navigator.of(context).pop(entity);
965 }965 }
966 }966 }
967- wrapControllerMethod<void>(967+ wrapControllerMethod<void>('setFocusMode', () async {
968- 'setFocusMode',968+ await innerController?.setFocusMode(FocusMode.auto);
969- () async {969+ });
970- await innerController?.setFocusMode(FocusMode.auto);
971- },
972- );
973 if (previousExposureMode != ExposureMode.locked) {970 if (previousExposureMode != ExposureMode.locked) {
974- wrapControllerMethod<void>(971+ wrapControllerMethod<void>('setExposureMode', () async {
975- 'setExposureMode',972+ await innerController?.setExposureMode(previousExposureMode);
976- () async {973+ });
977- await innerController?.setExposureMode(previousExposureMode);
978- },
979- );
980 }974 }
981 await innerController?.resumePreview();975 await innerController?.resumePreview();
982 } catch (e, s) {976 } catch (e, s) {
@@ -1024,6 +1018,8 @@ class CameraPickerState extends State<CameraPicker>
1024 return;1018 return;
1025 }1019 }
1026 isControllerBusy = true;1020 isControllerBusy = true;
1021+ // 拉起模糊过渡,遮住原生切录像管线那一刻(~166ms)的预览定格。
1022+ safeSetState(() => isRecordStarting = true);
1027 try {1023 try {
1028 await controller.startVideoRecording();1024 await controller.startVideoRecording();
1029 if (isRecordingRestricted) {1025 if (isRecordingRestricted) {
@@ -1050,8 +1046,10 @@ class CameraPickerState extends State<CameraPicker>
1050 recordStopwatch.stop();1046 recordStopwatch.stop();
1051 }1047 }
1052 } finally {1048 } finally {
1049+ // 录制已真正开始(或失败),预览恢复出帧,淡出模糊。
1053 safeSetState(() {1050 safeSetState(() {
1054 isControllerBusy = false;1051 isControllerBusy = false;
1052+ isRecordStarting = false;
1055 });1053 });
1056 }1054 }
1057 }1055 }
@@ -1285,14 +1283,13 @@ class CameraPickerState extends State<CameraPicker>
1285 ),1283 ),
1286 onPressed: () => switchCameras(),1284 onPressed: () => switchCameras(),
1287 icon: Icon(1285 icon: Icon(
1288- switch (defaultTargetPlatform) {1286+ switch (defaultTargetPlatform) {
1289- TargetPlatform.iOS ||1287+ TargetPlatform.iOS ||
1290- TargetPlatform.macOS =>1288+ TargetPlatform.macOS =>
1291- Icons.flip_camera_ios_outlined,1289+ Icons.flip_camera_ios_outlined,
1292- _ => Icons.flip_camera_android_outlined,1290+ _ => Icons.flip_camera_android_outlined,
1293- },1291+ },
1294- size: 24,1292+ size: 24),
1295- ),
1296 ),1293 ),
1297 );1294 );
1298 }1295 }
@@ -1761,10 +1758,7 @@ class CameraPickerState extends State<CameraPicker>
1761 excludeSemantics: true,1758 excludeSemantics: true,
1762 child: GestureDetector(1759 child: GestureDetector(
1763 onTapUp: (TapUpDetails d) {1760 onTapUp: (TapUpDetails d) {
1764- requestFocusAndExposureOnPosition(1761+ requestFocusAndExposureOnPosition(d.localPosition, constraints);
1765- d.localPosition,
1766- constraints,
1767- );
1768 },1762 },
1769 onLongPressStart: (LongPressStartDetails d) {1763 onLongPressStart: (LongPressStartDetails d) {
1770 requestFocusAndExposureOnPosition(1764 requestFocusAndExposureOnPosition(
@@ -1820,12 +1814,8 @@ class CameraPickerState extends State<CameraPicker>
1820 );1814 );
1821 1815 
1822 // Make a transformed widget if it's defined.1816 // Make a transformed widget if it's defined.
1823- final Widget? transformedWidget =1817+ final Widget? transformedWidget = pickerConfig.previewTransformBuilder
1824- pickerConfig.previewTransformBuilder?.call(1818+ ?.call(context, controller, preview);
1825- context,
1826- controller,
1827- preview,
1828- );
1829 if (!enableScaledPreview) {1819 if (!enableScaledPreview) {
1830 preview = Stack(1820 preview = Stack(
1831 children: <Widget>[1821 children: <Widget>[
@@ -1840,10 +1830,7 @@ class CameraPickerState extends State<CameraPicker>
1840 ),1830 ),
1841 if (pickerConfig.foregroundBuilder != null)1831 if (pickerConfig.foregroundBuilder != null)
1842 Positioned.fill(1832 Positioned.fill(
1843- child: pickerConfig.foregroundBuilder!(1833+ child: pickerConfig.foregroundBuilder!(context, innerController),
1844- context,
1845- innerController,
1846- ),
1847 ),1834 ),
1848 ],1835 ],
1849 );1836 );
@@ -1856,13 +1843,39 @@ class CameraPickerState extends State<CameraPicker>
1856 );1843 );
1857 // Rotated the preview if the turns is valid.1844 // Rotated the preview if the turns is valid.
1858 if (isCameraRotated) {1845 if (isCameraRotated) {
1859- preview = RotatedBox(1846+ preview = RotatedBox(quarterTurns: -cameraQuarterTurns, child: preview);
1860- quarterTurns: -cameraQuarterTurns,
1861- child: preview,
1862- );
1863 }1847 }
1864 }1848 }
1865- return RepaintBoundary(child: preview);1849+ return RepaintBoundary(
1850+ child: Stack(
1851+ fit: StackFit.expand,
1852+ children: <Widget>[preview, buildRecordStartBlur()],
1853+ ),
1854+ );
1855+ }
1856+ 
1857+ /// 录制启动期的模糊过渡层:原生切录像管线那一刻预览会短暂定格(鸿蒙端约 166ms),
1858+ /// 用一层淡入/淡出的高斯模糊把定格帧"虚化"掉,看起来像刻意的对焦过渡而非卡顿。
1859+ /// [isRecordStarting] 为 false 且动画归零时返回空 widget,平时零开销。
1860+ Widget buildRecordStartBlur() {
1861+ return Positioned.fill(
1862+ child: IgnorePointer(
1863+ child: TweenAnimationBuilder<double>(
1864+ tween: Tween<double>(begin: 0, end: isRecordStarting ? 1 : 0),
cpf-manager
cpf-managercpf-manager6月3日

【AI-Review】【一般】【基础代码问题】【常量问题】模糊过渡参数硬编码为魔法数字

● 问题:buildRecordStartBlur() 中的关键参数均为硬编码的魔法数字:动画时长 140ms、最大 sigma 16、遮罩透明度 0.06、阈值 0.01。这些值缺乏命名和注释说明选取依据,且不同平台的管线重配延迟差异较大(鸿蒙端约166ms,其他平台可能仅数ms),固定参数无法适应各平台。

● 影响:一般。未来需要调参或针对不同平台适配时,需在代码中搜索数字定位,增加维护成本;且无法通过配置覆盖。

● 建议:将魔法数字提取为命名常量或配置项,并补充选取依据的注释:

// 录制启动模糊过渡参数
static const double _recordBlurMaxSigma = 16;       // 最大高斯模糊半径
static const double _recordBlurOverlayOpacity = 0.06; // 叠加暗色遮罩最大透明度
static const Duration _recordBlurDuration = Duration(milliseconds: 140); // 模糊过渡动画时长
static const double _recordBlurVisibilityThreshold = 0.01; // 低于此值不渲染模糊
likedislike
1865+ duration: const Duration(milliseconds: 140),
1866+ curve: Curves.easeOut,
1867+ builder: (_, double t, __) {
1868+ if (t <= 0.01) {
1869+ return const SizedBox.shrink();
1870+ }
1871+ return BackdropFilter(
cpf-manager
cpf-managercpf-manager6月3日

【AI-Review】【一般】【基础代码问题】【性能和效率问题】BackdropFilter 在慢速平台上的 GPU 开销

● 问题:buildRecordStartBlur() 使用 BackdropFilter + ImageFilter.blur(sigmaX: 16*t, sigmaY: 16*t) 对整个相机预览区域进行实时高斯模糊。BackdropFilter 是 Flutter 中 GPU 开销最高的 widget 之一,每一帧都需要对底层内容重新采样和模糊。本 PR 目标平台正是"慢速平台"(鸿蒙端约166ms管线重配延迟),在此类设备上 BackdropFilter 可能导致帧率下降,与"掩盖卡顿"的初衷相悖。

● 影响:一般。在已慢的设备上引入额外 GPU 负担,可能导致模糊过渡期间出现新的卡顿,削弱本功能的视觉效果。

● 建议:考虑替代方案以降低 GPU 开销:

  1. 使用 AnimatedOpacity 叠加半透明遮罩替代模糊(几乎零 GPU 开销,视觉上接近"对焦过渡"效果)
  2. 如果确实需要模糊效果,可在 isRecordStarting 变为 true 时用 RenderRepaintBoundary + toImage() 捕获一帧快照,对该静态图像应用一次模糊,然后用 AnimatedOpacity 淡入淡出,避免每帧实时模糊
  3. 至少应添加平台判断,仅在 Platform.isOhos 等已知慢速平台上启用模糊过渡,在 iOS/Android 上使用更轻量的方案
likedislike
1872+ filter: ImageFilter.blur(sigmaX: 16 * t, sigmaY: 16 * t),
1873+ child: ColoredBox(color: Color.fromRGBO(0, 0, 0, 0.06 * t)),
1874+ );
1875+ },
1876+ ),
1877+ ),
1878+ );
1866 }1879 }
1867 1880 
1868 Widget buildInitializeWrapper({1881 Widget buildInitializeWrapper({
@@ -2012,8 +2025,10 @@ class CameraPickerState extends State<CameraPicker>
2012 ),2025 ),
2013 if (pickerConfig.foregroundBuilder != null)2026 if (pickerConfig.foregroundBuilder != null)
2014 Positioned.fill(2027 Positioned.fill(
2015- child:2028+ child: pickerConfig.foregroundBuilder!(
2016- pickerConfig.foregroundBuilder!(context, innerController),2029+ context,
2030+ innerController,
2031+ ),
2017 ),2032 ),
2018 ],2033 ],
2019 if (innerController == null)2034 if (innerController == null)
@@ -174,7 +174,7 @@ class CameraPickerViewerState extends State<CameraPickerViewer> {
174 androidPermission: AndroidPermission(174 androidPermission: AndroidPermission(
175 type: switch ((175 type: switch ((
176 pickerConfig.enableRecording,176 pickerConfig.enableRecording,
177- pickerConfig.enableTapRecording177+ pickerConfig.enableTapRecording,
178 )) {178 )) {
179 (true, false) => RequestType.common,179 (true, false) => RequestType.common,
180 (true, true) => RequestType.video,180 (true, true) => RequestType.video,
@@ -245,10 +245,7 @@ class CameraPickerViewerState extends State<CameraPickerViewer> {
245 color: Colors.white,245 color: Colors.white,
246 shape: BoxShape.circle,246 shape: BoxShape.circle,
247 ),247 ),
248- child: const Icon(248+ child: const Icon(Icons.keyboard_return_rounded, color: Colors.black),
249- Icons.keyboard_return_rounded,
250- color: Colors.black,
251- ),
252 ),249 ),
253 ),250 ),
254 );251 );
@@ -290,9 +287,7 @@ class CameraPickerViewerState extends State<CameraPickerViewer> {
290 height: 32,287 height: 32,
291 padding: const EdgeInsets.symmetric(horizontal: 20),288 padding: const EdgeInsets.symmetric(horizontal: 20),
292 color: Theme.of(context).colorScheme.secondary,289 color: Theme.of(context).colorScheme.secondary,
293- shape: RoundedRectangleBorder(290+ shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(3)),
294- borderRadius: BorderRadius.circular(3),
295- ),
296 onPressed: createAssetEntityAndPop,291 onPressed: createAssetEntityAndPop,
297 materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,292 materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
298 child: Text(293 child: Text(
@@ -392,7 +387,20 @@ class CameraPickerViewerState extends State<CameraPickerViewer> {
392 );387 );
393 }388 }
394 if (!hasLoaded) {389 if (!hasLoaded) {
395- return const SizedBox.shrink();390+ // 视频解码器初始化期间(OHOS 上可达数百 ms)显示加载指示,避免一段纯黑屏。
391+ return const ColoredBox(
392+ color: Colors.black,
393+ child: Center(
394+ child: SizedBox(
395+ width: 24,
396+ height: 24,
397+ child: CircularProgressIndicator(
398+ strokeWidth: 2,
399+ color: Colors.white,
400+ ),
401+ ),
402+ ),
403+ );
396 }404 }
397 return PopScope(405 return PopScope(
398 canPop: true,406 canPop: true,