Flutter OpenHarmony 三方库 camera 相机拍照录像适配详解

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
本文基于 Flutter 3.27.5 与 HarmonyOS 6.0 环境,深入讲解 camera 三方库在 OpenHarmony 平台的适配使用方法,带你全面掌握相机控制、拍照、录像、闪光灯、曝光、对焦、缩放等完整流程。


一、camera 库简介

camera 是 Flutter 官方提供的相机插件,提供了对设备相机的全面控制能力。无论是拍照、录像、实时图像流,还是闪光灯、曝光、对焦、缩放等高级功能,camera 库都能提供稳定高效的相机操作体验。

camera 核心特点

特点 说明
拍照功能 支持高分辨率拍照
录像功能 支持带音频/不带音频录像
实时预览 低延迟相机预览
闪光灯控制 支持多种闪光灯模式
曝光控制 支持曝光模式、曝光点、曝光偏移
对焦控制 支持自动对焦、锁定对焦、对焦点
缩放控制 支持平滑缩放
图像流 支持实时图像流处理
多相机支持 支持前后相机及外置相机
跨平台兼容 支持 Android、iOS、Web、OpenHarmony

分辨率预设

预设值 说明
low 360p,适合快速预览
medium 480p,平衡质量与性能
high 720p,高清质量
veryHigh 1080p,全高清质量
ultraHigh 4K,超高清质量
max 设备支持的最高分辨率

闪光灯模式

模式 说明
off 始终关闭
auto 自动判断
always 拍照时始终开启
torch 常亮模式(手电筒)

曝光模式

模式 说明
auto 自动曝光
locked 锁定曝光

对焦模式

模式 说明
auto 自动对焦
locked 锁定对焦

使用场景

  • 拍照应用
  • 视频录制
  • 二维码/条形码扫描
  • AR 应用
  • 实时图像处理
  • 人脸识别

二、OpenHarmony 适配版本

2.1 环境说明

组件 版本
Flutter 3.27.5
HarmonyOS 6.0
camera 0.11.3 (OpenHarmony 适配版本)

2.2 引入方式

pubspec.yaml 文件中添加以下依赖配置:

yaml 复制代码
dependencies:
  camera_ohos:
    git:
      url: https://atomgit.com/openharmony-sig/flutter_packages.git
      path: packages/camera/camera

2.3 权限配置

ohos/entry/src/main/module.json5 中添加相机和麦克风权限:

json 复制代码
"requestPermissions": [
  {
    "name": "ohos.permission.CAMERA",
    "reason": "$string:camera_reason",
    "usedScene": {
      "abilities": ["EntryAbility"],
      "when": "inuse"
    }
  },
  {
    "name": "ohos.permission.MICROPHONE",
    "reason": "$string:microphone_reason",
    "usedScene": {
      "abilities": ["EntryAbility"],
      "when": "inuse"
    }
  }
]

ohos/entry/src/main/resources/base/element/string.json 中添加:

json 复制代码
{
  "name": "camera_reason",
  "value": "使用相机进行拍照和录像"
},
{
  "name": "microphone_reason",
  "value": "使用麦克风进行录音"
}

⚠️ 重要说明: 在 OpenHarmony 平台上,READ_MEDIAIMAGEWRITE_MEDIAIMAGE 权限无法通过常规方式申请。拍照和录像保存的文件需要使用应用沙箱路径(如 getApplicationDocumentsDirectorygetTemporaryDirectory),而不是媒体库路径。


三、核心 API 讲解

3.1 availableCameras 函数

获取设备上所有可用相机的列表。

dart 复制代码
Future<List<CameraDescription>> availableCameras()

返回值: List<CameraDescription>,包含所有可用相机的描述信息。

CameraDescription 属性说明:

属性 类型 说明
name String 相机名称
lensDirection CameraLensDirection 相机方向(前/后/外置)
sensorOrientation int 传感器旋转角度

CameraLensDirection 枚举:

说明
back 后置相机
front 前置相机
external 外置相机

使用示例:

dart 复制代码
final cameras = await availableCameras();
final backCamera = cameras.firstWhere(
  (camera) => camera.lensDirection == CameraLensDirection.back,
);

3.2 CameraController 类

CameraController 是相机控制的核心类,用于初始化相机、拍照、录像等所有操作。

构造函数
dart 复制代码
CameraController(
  CameraDescription description,
  ResolutionPreset resolutionPreset, {
  this.enableAudio = true,
  this.imageFormatGroup,
})

参数说明:

参数 类型 必填 默认值 说明
description CameraDescription - 相机描述信息
resolutionPreset ResolutionPreset - 分辨率预设
enableAudio bool true 录像时是否包含音频
imageFormatGroup ImageFormatGroup? null 图像流格式(用于 startImageStream)
initialize - 初始化相机
dart 复制代码
Future<void> initialize()

说明: 初始化相机,必须在其他操作之前调用。初始化完成后 value.isInitialized 变为 true

使用示例:

dart 复制代码
final controller = CameraController(camera, ResolutionPreset.high);
await controller.initialize();
print('相机已初始化,预览尺寸: ${controller.value.previewSize}');
takePicture - 拍照
dart 复制代码
Future<XFile> takePicture()

返回值: XFile,包含照片文件路径的文件对象。

说明: 拍摄一张照片并返回文件。拍照前需确保相机已初始化且未在录像中。

使用示例:

dart 复制代码
final XFile photo = await controller.takePicture();
print('照片已保存到: ${photo.path}');
startVideoRecording - 开始录像
dart 复制代码
Future<void> startVideoRecording({onLatestImageAvailable? onAvailable})

参数说明:

参数 类型 必填 默认值 说明
onAvailable onLatestImageAvailable? null 实时视频帧回调(可选)

使用示例:

dart 复制代码
await controller.startVideoRecording();
print('录像已开始');
stopVideoRecording - 停止录像
dart 复制代码
Future<XFile> stopVideoRecording()

返回值: XFile,包含录像文件路径的文件对象。

使用示例:

dart 复制代码
final XFile video = await controller.stopVideoRecording();
print('录像已保存到: ${video.path}');
pauseVideoRecording - 暂停录像
dart 复制代码
Future<void> pauseVideoRecording()

说明: 暂停当前录像,iOS 和 Android SDK 24+ 支持。

使用示例:

dart 复制代码
await controller.pauseVideoRecording();
resumeVideoRecording - 恢复录像
dart 复制代码
Future<void> resumeVideoRecording()

说明: 恢复已暂停的录像。

使用示例:

dart 复制代码
await controller.resumeVideoRecording();
prepareForVideoRecording - 准备录像
dart 复制代码
Future<void> prepareForVideoRecording()

说明: 预先准备录像资源,在 iOS 上可减少启动录像时的延迟。在 OpenHarmony 上为 no-op。

使用示例:

dart 复制代码
await controller.prepareForVideoRecording();
setFlashMode - 设置闪光灯模式
dart 复制代码
Future<void> setFlashMode(FlashMode mode)

参数说明:

参数 类型 必填 默认值 说明
mode FlashMode - 闪光灯模式

使用示例:

dart 复制代码
await controller.setFlashMode(FlashMode.torch);
setExposureMode - 设置曝光模式
dart 复制代码
Future<void> setExposureMode(ExposureMode mode)

使用示例:

dart 复制代码
await controller.setExposureMode(ExposureMode.locked);
setFocusMode - 设置对焦模式
dart 复制代码
Future<void> setFocusMode(FocusMode mode)

使用示例:

dart 复制代码
await controller.setFocusMode(FocusMode.auto);
setZoomLevel - 设置缩放级别
dart 复制代码
Future<void> setZoomLevel(double zoom)

参数说明:

参数 类型 必填 默认值 说明
zoom double - 缩放级别,1.0 为原始大小

说明: 缩放值应在 getMinZoomLevel()getMaxZoomLevel() 之间。

使用示例:

dart 复制代码
await controller.setZoomLevel(2.0);
getMaxZoomLevel - 获取最大缩放级别
dart 复制代码
Future<double> getMaxZoomLevel()

返回值: 最大支持的缩放倍数。

使用示例:

dart 复制代码
final maxZoom = await controller.getMaxZoomLevel();
print('最大缩放级别: $maxZoom');
getMinZoomLevel - 获取最小缩放级别
dart 复制代码
Future<double> getMinZoomLevel()

返回值: 最小支持的缩放倍数(通常为 1.0)。

lockCaptureOrientation - 锁定捕获方向
dart 复制代码
Future<void> lockCaptureOrientation([DeviceOrientation? orientation])

使用示例:

dart 复制代码
await controller.lockCaptureOrientation(DeviceOrientation.portraitUp);
unlockCaptureOrientation - 解锁捕获方向
dart 复制代码
Future<void> unlockCaptureOrientation()

使用示例:

dart 复制代码
await controller.unlockCaptureOrientation();
pausePreview - 暂停预览
dart 复制代码
Future<void> pausePreview()

说明: 暂停相机预览画面。

使用示例:

dart 复制代码
await controller.pausePreview();
resumePreview - 恢复预览
dart 复制代码
Future<void> resumePreview()

说明: 恢复已暂停的预览。

使用示例:

dart 复制代码
await controller.resumePreview();
startImageStream - 开始图像流
dart 复制代码
Future<void> startImageStream(onLatestImageAvailable onAvailable)

参数说明:

参数 类型 必填 默认值 说明
onAvailable onLatestImageAvailable - 每帧图像回调函数

说明: 开始实时获取相机图像帧,适用于图像处理、计算机视觉等场景。建议使用 ResolutionPreset.low 以获得更好的性能。

使用示例:

dart 复制代码
await controller.startImageStream((CameraImage image) {
  print('收到图像帧: ${image.width}x${image.height}');
});
stopImageStream - 停止图像流
dart 复制代码
Future<void> stopImageStream()

使用示例:

dart 复制代码
await controller.stopImageStream();
dispose - 释放资源
dart 复制代码
Future<void> dispose()

说明: 释放相机资源,在页面销毁时调用。

使用示例:

dart 复制代码
@override
void dispose() {
  controller?.dispose();
  super.dispose();
}

3.3 CameraPreview 类

CameraPreview 是显示相机实时预览画面的 Widget。

构造函数
dart 复制代码
CameraPreview(
  this.controller, {
  this.child,
})

参数说明:

参数 类型 必填 默认值 说明
controller CameraController - 已初始化的相机控制器
child Widget? null 覆盖在预览上方的 Widget

使用示例:

dart 复制代码
CameraPreview(
  controller,
  child: GestureDetector(
    onTapDown: (details) => onFocusTap(details),
  ),
)

3.4 CameraValue 类

CameraValue 是相机状态的封装,通过 controller.value 访问。

常用属性
属性 类型 说明
isInitialized bool 相机是否已初始化
isRecordingVideo bool 是否正在录像
isTakingPicture bool 是否正在拍照
isStreamingImages bool 是否正在输出图像流
isRecordingPaused bool 录像是否已暂停
isPreviewPaused bool 预览是否已暂停
flashMode FlashMode 当前闪光灯模式
exposureMode ExposureMode 当前曝光模式
focusMode FocusMode 当前对焦模式
previewSize Size? 预览尺寸
aspectRatio double 预览宽高比
hasError bool 是否有错误
errorDescription String? 错误描述

3.5 CameraImage 类

CameraImage 表示从相机获取的原始图像帧。

常用属性
属性 类型 说明
width int 图像宽度
height int 图像高度
format ImageFormat 图像格式
planes List<Plane> 图像平面数据

四、应用级别完整代码:相机拍照录像助手

以下是一个完整的相机拍照录像助手应用,包含以下功能:

  • 相机实时预览
  • 拍照并预览照片
  • 录像/暂停/恢复/停止
  • 前后相机切换
  • 闪光灯控制
  • 曝光调节
  • 点击对焦
  • 照片/录像回放
dart 复制代码
import 'dart:async';
import 'dart:io';

import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';

void main() {
  runApp(const CameraApp());
}

class CameraApp extends StatelessWidget {
  const CameraApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: '相机拍照录像助手',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: const Color(0xFF1976D2),
          brightness: Brightness.light,
        ),
        useMaterial3: true,
        appBarTheme: const AppBarTheme(
          centerTitle: true,
          elevation: 0,
        ),
      ),
      home: const CameraHomePage(),
    );
  }
}

class CameraHomePage extends StatefulWidget {
  const CameraHomePage({super.key});

  @override
  State<CameraHomePage> createState() => _CameraHomePageState();
}

class _CameraHomePageState extends State<CameraHomePage> {
  CameraController? _controller;
  List<CameraDescription> _cameras = [];
  bool _isCameraInitialized = false;
  bool _isRecording = false;
  bool _isRecordingPaused = false;
  bool _isPreviewPaused = false;
  FlashMode _flashMode = FlashMode.off;
  double _currentZoom = 1.0;
  double _minZoom = 1.0;
  double _maxZoom = 1.0;
  double _exposureOffset = 0.0;
  double _minExposure = 0.0;
  double _maxExposure = 0.0;
  XFile? _lastPhoto;
  XFile? _lastVideo;
  VideoPlayerController? _videoController;
  bool _showVideoPlayback = false;

  @override
  void initState() {
    super.initState();
    _initializeCameras();
  }

  Future<void> _initializeCameras() async {
    try {
      _cameras = await availableCameras();
      if (_cameras.isNotEmpty) {
        await _onCameraSelected(_cameras.first);
      }
    } catch (e) {
      debugPrint('获取相机列表失败: $e');
    }
  }

  Future<void> _onCameraSelected(CameraDescription camera) async {
    if (_controller != null) {
      await _controller!.dispose();
    }
    if (_videoController != null) {
      await _videoController!.dispose();
      _videoController = null;
    }
    setState(() {
      _showVideoPlayback = false;
      _isRecording = false;
      _isRecordingPaused = false;
      _isPreviewPaused = false;
    });

    _controller = CameraController(
      camera,
      ResolutionPreset.high,
      enableAudio: true,
    );

    try {
      await _controller!.initialize();
      if (mounted) {
        final minZoom = await _controller!.getMinZoomLevel();
        final maxZoom = await _controller!.getMaxZoomLevel();
        final minExposure = await _controller!.getMinExposureOffset();
        final maxExposure = await _controller!.getMaxExposureOffset();
        setState(() {
          _isCameraInitialized = true;
          _minZoom = minZoom;
          _maxZoom = maxZoom;
          _currentZoom = minZoom;
          _minExposure = minExposure;
          _maxExposure = maxExposure;
          _exposureOffset = 0.0;
        });
      }
    } on CameraException catch (e) {
      debugPrint('相机初始化失败: ${e.code} - ${e.description}');
    }
  }

  Future<void> _takePicture() async {
    if (_controller == null || !_controller!.value.isInitialized) return;
    if (_controller!.value.isTakingPicture) return;

    try {
      final XFile photo = await _controller!.takePicture();
      if (mounted) {
        setState(() {
          _lastPhoto = photo;
          _showVideoPlayback = false;
        });
        _showPhotoPreview(photo);
      }
    } on CameraException catch (e) {
      _showError('拍照失败', e.code, e.description);
    }
  }

  void _showPhotoPreview(XFile photo) {
    showDialog(
      context: context,
      builder: (context) => Dialog(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Padding(
              padding: EdgeInsets.all(12),
              child: Text(
                '照片预览',
                style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
              ),
            ),
            AspectRatio(
              aspectRatio: 1,
              child: Image.file(
                File(photo.path),
                fit: BoxFit.cover,
              ),
            ),
            Padding(
              padding: const EdgeInsets.all(12),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: [
                  TextButton.icon(
                    onPressed: () {
                      Navigator.pop(context);
                    },
                    icon: const Icon(Icons.close),
                    label: const Text('关闭'),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  Future<void> _startVideoRecording() async {
    if (_controller == null || !_controller!.value.isInitialized) return;

    try {
      await _controller!.prepareForVideoRecording();
      await _controller!.startVideoRecording();
      if (mounted) {
        setState(() {
          _isRecording = true;
          _isRecordingPaused = false;
        });
      }
    } on CameraException catch (e) {
      _showError('录像失败', e.code, e.description);
    }
  }

  Future<void> _stopVideoRecording() async {
    if (_controller == null || !_controller!.value.isInitialized) return;

    try {
      final XFile video = await _controller!.stopVideoRecording();
      if (mounted) {
        setState(() {
          _isRecording = false;
          _isRecordingPaused = false;
          _lastVideo = video;
        });
        _showVideoPreview(video);
      }
    } on CameraException catch (e) {
      _showError('停止录像失败', e.code, e.description);
    }
  }

  Future<void> _pauseVideoRecording() async {
    if (_controller == null || !_controller!.value.isInitialized) return;

    try {
      await _controller!.pauseVideoRecording();
      if (mounted) {
        setState(() {
          _isRecordingPaused = true;
        });
      }
    } on CameraException catch (e) {
      _showError('暂停录像失败', e.code, e.description);
    }
  }

  Future<void> _resumeVideoRecording() async {
    if (_controller == null || !_controller!.value.isInitialized) return;

    try {
      await _controller!.resumeVideoRecording();
      if (mounted) {
        setState(() {
          _isRecordingPaused = false;
        });
      }
    } on CameraException catch (e) {
      _showError('恢复录像失败', e.code, e.description);
    }
  }

  void _showVideoPreview(XFile video) {
    showDialog(
      context: context,
      builder: (context) => Dialog(
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            const Padding(
              padding: EdgeInsets.all(12),
              child: Text(
                '录像预览',
                style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
              ),
            ),
            AspectRatio(
              aspectRatio: 16 / 9,
              child: VideoPlayerScreen(videoPath: video.path),
            ),
            Padding(
              padding: const EdgeInsets.all(12),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: [
                  TextButton.icon(
                    onPressed: () {
                      Navigator.pop(context);
                    },
                    icon: const Icon(Icons.close),
                    label: const Text('关闭'),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  Future<void> _toggleFlashMode() async {
    if (_controller == null || !_controller!.value.isInitialized) return;

    final modes = [
      FlashMode.off,
      FlashMode.auto,
      FlashMode.always,
      FlashMode.torch,
    ];
    final currentIndex = modes.indexOf(_flashMode);
    final nextMode = modes[(currentIndex + 1) % modes.length];

    try {
      await _controller!.setFlashMode(nextMode);
      if (mounted) {
        setState(() {
          _flashMode = nextMode;
        });
      }
    } on CameraException catch (e) {
      _showError('切换闪光灯失败', e.code, e.description);
    }
  }

  Future<void> _toggleCamera() async {
    if (_cameras.length < 2) return;

    final currentCamera = _controller!.description;
    final currentIndex = _cameras.indexOf(currentCamera);
    final nextIndex = (currentIndex + 1) % _cameras.length;

    await _onCameraSelected(_cameras[nextIndex]);
  }

  Future<void> _togglePreview() async {
    if (_controller == null || !_controller!.value.isInitialized) return;

    try {
      if (_isPreviewPaused) {
        await _controller!.resumePreview();
      } else {
        await _controller!.pausePreview();
      }
      if (mounted) {
        setState(() {
          _isPreviewPaused = !_isPreviewPaused;
        });
      }
    } on CameraException catch (e) {
      _showError('切换预览失败', e.code, e.description);
    }
  }

  Future<void> _setExposure(double value) async {
    if (_controller == null || !_controller!.value.isInitialized) return;

    try {
      await _controller!.setExposureOffset(value);
      if (mounted) {
        setState(() {
          _exposureOffset = value;
        });
      }
    } on CameraException catch (e) {
      _showError('设置曝光失败', e.code, e.description);
    }
  }

  void _showError(String title, String code, String? description) {
    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('$title: $code')),
    );
  }

  @override
  void dispose() {
    _controller?.dispose();
    _videoController?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text(
          '相机拍照录像助手',
          style: TextStyle(fontWeight: FontWeight.bold),
        ),
        backgroundColor: const Color(0xFF1976D2),
        foregroundColor: Colors.white,
      ),
      body: _cameras.isEmpty
          ? const Center(child: Text('未找到可用相机'))
          : Column(
              children: [
                Expanded(child: _buildCameraPreview()),
                _buildControlPanel(),
              ],
            ),
    );
  }

  Widget _buildCameraPreview() {
    if (!_isCameraInitialized || _controller == null) {
      return const Center(child: CircularProgressIndicator());
    }

    return Stack(
      children: [
        GestureDetector(
          onScaleUpdate: _handleScaleUpdate,
          child: CameraPreview(_controller!),
        ),
        _buildTopInfo(),
        Positioned(
          bottom: 16,
          left: 16,
          child: _buildExposureControl(),
        ),
      ],
    );
  }

  Widget _buildTopInfo() {
    return Positioned(
      top: 0,
      left: 0,
      right: 0,
      child: Container(
        padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
        decoration: BoxDecoration(
          gradient: LinearGradient(
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
            colors: [
              Colors.black.withOpacity(0.6),
              Colors.transparent,
            ],
          ),
        ),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: [
            Container(
              padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
              decoration: BoxDecoration(
                color: const Color(0xFF1976D2),
                borderRadius: BorderRadius.circular(16),
              ),
              child: Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Icon(
                    _controller!.description.lensDirection ==
                            CameraLensDirection.back
                        ? Icons.camera_rear
                        : Icons.camera_front,
                    color: Colors.white,
                    size: 16,
                  ),
                  const SizedBox(width: 4),
                  Text(
                    _controller!.description.lensDirection ==
                            CameraLensDirection.back
                        ? '后置'
                        : '前置',
                    style: const TextStyle(
                      color: Colors.white,
                      fontSize: 12,
                    ),
                  ),
                ],
              ),
            ),
            if (_isRecording)
              Container(
                padding:
                    const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
                decoration: BoxDecoration(
                  color: Colors.red,
                  borderRadius: BorderRadius.circular(16),
                ),
                child: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    Container(
                      width: 8,
                      height: 8,
                      decoration: const BoxDecoration(
                        color: Colors.white,
                        shape: BoxShape.circle,
                      ),
                    ),
                    const SizedBox(width: 4),
                    const Text(
                      '录制中',
                      style: TextStyle(
                        color: Colors.white,
                        fontSize: 12,
                      ),
                    ),
                  ],
                ),
              ),
          ],
        ),
      ),
    );
  }

  Widget _buildExposureControl() {
    return Container(
      width: 40,
      padding: const EdgeInsets.symmetric(vertical: 8),
      decoration: BoxDecoration(
        color: Colors.black.withOpacity(0.5),
        borderRadius: BorderRadius.circular(20),
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          const Icon(Icons.brightness_high, color: Colors.white, size: 16),
          Expanded(
            child: RotatedBox(
              quarterTurns: 3,
              child: SliderTheme(
                data: SliderThemeData(
                  trackHeight: 2,
                  thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
                  overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
                ),
                child: Slider(
                  value: _exposureOffset,
                  min: _minExposure,
                  max: _maxExposure,
                  onChanged: _setExposure,
                ),
              ),
            ),
          ),
          const Icon(Icons.brightness_low, color: Colors.white, size: 16),
        ],
      ),
    );
  }

  Widget _buildControlPanel() {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.white,
        boxShadow: [
          BoxShadow(
            color: Colors.black.withOpacity(0.1),
            blurRadius: 8,
            offset: const Offset(0, -2),
          ),
        ],
      ),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          _buildThumbnailRow(),
          const SizedBox(height: 12),
          _buildMainControls(),
          const SizedBox(height: 8),
          _buildSecondaryControls(),
        ],
      ),
    );
  }

  Widget _buildThumbnailRow() {
    return Row(
      children: [
        if (_lastPhoto != null)
          GestureDetector(
            onTap: () => _showPhotoPreview(_lastPhoto!),
            child: Container(
              width: 56,
              height: 56,
              decoration: BoxDecoration(
                border: Border.all(color: Colors.grey.shade300, width: 2),
                borderRadius: BorderRadius.circular(8),
              ),
              child: ClipRRect(
                borderRadius: BorderRadius.circular(6),
                child: Image.file(
                  File(_lastPhoto!.path),
                  fit: BoxFit.cover,
                ),
              ),
            ),
          ),
        const Spacer(),
        if (_lastVideo != null)
          GestureDetector(
            onTap: () => _showVideoPreview(_lastVideo!),
            child: Container(
              width: 56,
              height: 56,
              decoration: BoxDecoration(
                border: Border.all(color: Colors.pink.shade300, width: 2),
                borderRadius: BorderRadius.circular(8),
              ),
              child: Stack(
                alignment: Alignment.center,
                children: [
                  ClipRRect(
                    borderRadius: BorderRadius.circular(6),
                    child: Icon(
                      Icons.video_library,
                      size: 32,
                      color: Colors.pink.shade300,
                    ),
                  ),
                  const Icon(
                    Icons.play_arrow,
                    color: Colors.white,
                    size: 24,
                  ),
                ],
              ),
            ),
          ),
      ],
    );
  }

  Widget _buildMainControls() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        _buildCircleButton(
          icon: Icons.camera_alt,
          color: const Color(0xFF1976D2),
          onPressed: _takePicture,
          size: 56,
        ),
        _buildCircleButton(
          icon: _isRecording
              ? (_isRecordingPaused ? Icons.play_arrow : Icons.pause)
              : Icons.videocam,
          color: _isRecording ? Colors.red : const Color(0xFF1976D2),
          onPressed: _isRecording
              ? (_isRecordingPaused
                  ? _resumeVideoRecording
                  : _pauseVideoRecording)
              : _startVideoRecording,
          size: 56,
        ),
        if (_isRecording)
          _buildCircleButton(
            icon: Icons.stop,
            color: Colors.red.shade700,
            onPressed: _stopVideoRecording,
            size: 56,
          ),
      ],
    );
  }

  Widget _buildSecondaryControls() {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceEvenly,
      children: [
        _buildSmallButton(
          icon: _flashMode == FlashMode.off
              ? Icons.flash_off
              : _flashMode == FlashMode.auto
                  ? Icons.flash_auto
                  : _flashMode == FlashMode.always
                      ? Icons.flash_on
                      : Icons.flashlight_on,
          label: '闪光灯',
          color: _flashMode == FlashMode.off ? Colors.grey : Colors.orange,
          onPressed: _toggleFlashMode,
        ),
        _buildSmallButton(
          icon: Icons.flip_camera_ios,
          label: '切换相机',
          color: Colors.blue,
          onPressed: _toggleCamera,
        ),
        _buildSmallButton(
          icon: _isPreviewPaused ? Icons.play_circle_outline : Icons.pause_circle_outline,
          label: _isPreviewPaused ? '恢复预览' : '暂停预览',
          color: _isPreviewPaused ? Colors.green : Colors.grey,
          onPressed: _togglePreview,
        ),
      ],
    );
  }

  Widget _buildCircleButton({
    required IconData icon,
    required Color color,
    required VoidCallback onPressed,
    double size = 56,
  }) {
    return GestureDetector(
      onTap: onPressed,
      child: Container(
        width: size,
        height: size,
        decoration: BoxDecoration(
          color: color.withOpacity(0.15),
          shape: BoxShape.circle,
        ),
        child: Icon(
          icon,
          color: color,
          size: size * 0.5,
        ),
      ),
    );
  }

  Widget _buildSmallButton({
    required IconData icon,
    required String label,
    required Color color,
    required VoidCallback onPressed,
  }) {
    return GestureDetector(
      onTap: onPressed,
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          Container(
            width: 48,
            height: 48,
            decoration: BoxDecoration(
              color: color.withOpacity(0.15),
              shape: BoxShape.circle,
            ),
            child: Icon(
              icon,
              color: color,
              size: 24,
            ),
          ),
          const SizedBox(height: 4),
          Text(
            label,
            style: TextStyle(
              fontSize: 11,
              color: Colors.black.withOpacity(0.7),
            ),
          ),
        ],
      ),
    );
  }

  Future<void> _handleScaleUpdate(ScaleUpdateDetails details) async {
    if (_controller == null || !_controller!.value.isInitialized) return;

    final newZoom = (_currentZoom * details.scale)
        .clamp(_minZoom, _maxZoom);
    try {
      await _controller!.setZoomLevel(newZoom);
      setState(() {
        _currentZoom = newZoom;
      });
    } catch (e) {
      debugPrint('缩放失败: $e');
    }
  }
}

class VideoPlayerScreen extends StatefulWidget {
  final String videoPath;

  const VideoPlayerScreen({super.key, required this.videoPath});

  @override
  State<VideoPlayerScreen> createState() => _VideoPlayerScreenState();
}

class _VideoPlayerScreenState extends State<VideoPlayerScreen> {
  late VideoPlayerController _controller;

  @override
  void initState() {
    super.initState();
    _controller = VideoPlayerController.file(File(widget.videoPath))
      ..initialize().then((_) {
        if (mounted) {
          setState(() {});
          _controller.play();
        }
      });
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return _controller.value.isInitialized
        ? GestureDetector(
            onTap: () {
              if (_controller.value.isPlaying) {
                _controller.pause();
              } else {
                _controller.play();
              }
            },
            child: Stack(
              alignment: Alignment.center,
              children: [
                AspectRatio(
                  aspectRatio: _controller.value.aspectRatio,
                  child: VideoPlayer(_controller),
                ),
                if (!_controller.value.isPlaying)
                  const Icon(
                    Icons.play_arrow,
                    color: Colors.white,
                    size: 64,
                  ),
              ],
            ),
          )
        : const Center(child: CircularProgressIndicator());
  }
}

五、OpenHarmony 适配要点

5.1 权限配置

在 OpenHarmony 平台,使用相机和录音需要申请对应权限:

json 复制代码
{
  "name": "ohos.permission.CAMERA",
  "reason": "$string:camera_reason",
  "usedScene": {
    "abilities": ["EntryAbility"],
    "when": "inuse"
  }
}

5.2 文件存储路径

⚠️ 重要: READ_MEDIAIMAGEWRITE_MEDIAIMAGE 权限在 OpenHarmony 平台上无法通过常规方式申请。拍照和录像保存的文件必须使用应用沙箱路径,推荐使用以下方法获取路径:

dart 复制代码
import 'package:path_provider/path_provider.dart';

// 获取应用文档目录(推荐用于持久存储)
final appDir = await getApplicationDocumentsDirectory();
final photoPath = '${appDir.path}/photos';

// 获取临时目录(可能被系统清理)
final tempDir = await getTemporaryDirectory();

5.3 分辨率选择

分辨率应根据实际需求选择,高分辨率会影响性能:

dart 复制代码
// 快速预览
CameraController(camera, ResolutionPreset.low);

// 拍照推荐
CameraController(camera, ResolutionPreset.high);

// 录像推荐
CameraController(camera, ResolutionPreset.veryHigh);

5.4 热重载处理

在 OpenHarmony 平台,热重载时需要重新初始化相机:

dart 复制代码
@override
void reassemble() {
  super.reassemble();
  _controller?.initialize();
}

5.5 应用生命周期处理

应用进入后台时应释放相机资源:

dart 复制代码
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
  if (state == AppLifecycleState.inactive) {
    _controller?.dispose();
  } else if (state == AppLifecycleState.resumed) {
    _onCameraSelected(_controller!.description);
  }
}

六、常见问题

Q1: 相机初始化失败?

原因: 权限未授予或相机被其他应用占用。

解决方案:

dart 复制代码
try {
  await controller.initialize();
} on CameraException catch (e) {
  print('初始化失败: ${e.code} - ${e.description}');
}

Q2: 拍照后文件在哪里?

解决方案: 拍照返回的 XFile 包含文件路径,建议复制到应用目录:

dart 复制代码
import 'dart:io';
import 'package:path_provider/path_provider.dart';

final XFile photo = await controller.takePicture();
final dir = await getApplicationDocumentsDirectory();
final savedPath = '${dir.path}/photo_${DateTime.now().millisecondsSinceEpoch}.jpg';
await File(photo.path).copy(savedPath);

Q3: 如何切换前后相机?

解决方案:

dart 复制代码
final cameras = await availableCameras();
final frontCamera = cameras.firstWhere(
  (camera) => camera.lensDirection == CameraLensDirection.front,
);
await _onCameraSelected(frontCamera);

Q5: 如何设置闪光灯?

解决方案:

dart 复制代码
await controller.setFlashMode(FlashMode.torch);

Q6: 如何调节曝光?

解决方案:

dart 复制代码
final minExposure = await controller.getMinExposureOffset();
final maxExposure = await controller.getMaxExposureOffset();
await controller.setExposureOffset(1.0);

Q7: 如何暂停和恢复预览?

解决方案:

dart 复制代码
await controller.pausePreview();
await controller.resumePreview();

Q8: 录像时如何包含音频?

解决方案: 创建控制器时设置 enableAudio: true

dart 复制代码
CameraController(camera, ResolutionPreset.high, enableAudio: true);

Q9: 缩放功能无法使用?

⚠️ 已知问题: 在 OpenHarmony 适配版本中,缩放功能存在 bug,无法正常使用。

问题原因:

在原生代码 ZoomLevelFeature.ets 中,缩放范围被硬编码为:

typescript 复制代码
this.minimumZoomLevel = 0;
this.maximumZoomLevel = 1;

这导致:

  • 缩放范围被限制在 0-1 之间
  • 正常缩放应该是 1.0 为原始大小,大于 1.0 才是放大
  • 调用 setZoomLevel() 时,如果传入大于 1 的值会被判定为超出范围

七、总结

camera 是 Flutter 官方提供的相机插件,在 OpenHarmony 平台的适配已经比较成熟。通过本文的介绍,你应该已经掌握了:

  1. CameraController 的核心 API 和使用方法
  2. 拍照和录像的完整流程
  3. 闪光灯、曝光、对焦等高级功能
  4. 相机预览和图像流的使用
  5. 完整的应用级别相机拍照录像助手实现
  6. OpenHarmony 平台的权限和文件存储注意事项

⚠️ 注意事项: 当前 OpenHarmony 适配版本中,缩放功能存在 bug,无法正常使用。建议避免使用缩放相关 API,或等待官方修复。

相关推荐
恋猫de小郭2 小时前
WasmGC 是什么?为什么它对 Dart 和 Kotlin 在 Web 领域很重要?
android·前端·flutter
liulian09164 小时前
【Flutter for OpenHarmony第三方库】Flutter for OpenHarmony 多语言国际化适配实战指南
flutter·华为·学习方法·harmonyos
YF021116 小时前
Flutter 编译卡顿解决方案
android·flutter·ios
IntMainJhy17 小时前
【Flutter for OpenHarmony 】第三方库鸿蒙电商全栈实战:从组件适配到项目完整交付✨
flutter·华为·harmonyos
程序员老刘18 小时前
别慌!GetX只是被误杀,但你的代码可能真的在裸奔
flutter·客户端
IntMainJhy19 小时前
【flutter for open harmony】第三方库Flutter 鸿蒙实战:商品详情页完整实现 + 点击跳转失效问题修复✨
flutter·华为·harmonyos
liulian09161 天前
【Flutter for OpenHarmony第三方库】Flutter for OpenHarmony应用更新检测功能实战指南
flutter·华为·学习方法·harmonyos
IntMainJhy1 天前
【Flutter for OpenHarmony 】第三方库 实战:`cached_network_image` 图片缓存+骨架屏鸿蒙适配全指南✨
flutter·缓存·harmonyos
恋猫de小郭1 天前
Flutter 3.41.7 ,小版本但 iOS 大修复,看完只想说:这是人能写出来的 bug ?
android·前端·flutter