已开启
feat: mask camera-pipeline reconfigure latency on slow platforms (HarmonyOS) #9
发条创建于 6月3日
feat: mask camera-pipeline reconfigure latency on slow platforms (HarmonyOS) #9
已开启
共 2 个文件变更+140-117
| @@ -5,6 +5,7 @@ | |||
| 5 | import 'dart:async'; | 5 | import 'dart:async'; |
| 6 | import 'dart:io'; | 6 | import 'dart:io'; |
| 7 | import 'dart:math' as math; | 7 | import 'dart:math' as math; |
| 8 | +import 'dart:ui' show ImageFilter; | ||
| 8 | 9 | ||
| 9 | import 'package:camera/camera.dart'; | 10 | import 'package:camera/camera.dart'; |
| 10 | import 'package:camera_platform_interface/camera_platform_interface.dart'; | 11 | import '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 | stopwatch | 451 | stopwatch |
| @@ -456,71 +459,68 @@ class CameraPickerState extends State<CameraPicker> | |||
| 456 | stopwatch | 459 | 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( |
| 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), | ||
【AI-Review】【一般】【基础代码问题】【常量问题】模糊过渡参数硬编码为魔法数字 ● 问题: ● 影响:一般。未来需要调参或针对不同平台适配时,需在代码中搜索数字定位,增加维护成本;且无法通过配置覆盖。 ● 建议:将魔法数字提取为命名常量或配置项,并补充选取依据的注释:
![]() ![]() | |||
| 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( | ||
【AI-Review】【一般】【基础代码问题】【性能和效率问题】BackdropFilter 在慢速平台上的 GPU 开销 ● 问题: ● 影响:一般。在已慢的设备上引入额外 GPU 负担,可能导致模糊过渡期间出现新的卡顿,削弱本功能的视觉效果。 ● 建议:考虑替代方案以降低 GPU 开销:
![]() ![]() | |||
| 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.enableTapRecording | 177 | + 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, |


【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!),