欢迎加入开源鸿蒙跨平台社区: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_MEDIAIMAGE和WRITE_MEDIAIMAGE权限无法通过常规方式申请。拍照和录像保存的文件需要使用应用沙箱路径(如getApplicationDocumentsDirectory或getTemporaryDirectory),而不是媒体库路径。
三、核心 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_MEDIAIMAGE和WRITE_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 平台的适配已经比较成熟。通过本文的介绍,你应该已经掌握了:
- CameraController 的核心 API 和使用方法
- 拍照和录像的完整流程
- 闪光灯、曝光、对焦等高级功能
- 相机预览和图像流的使用
- 完整的应用级别相机拍照录像助手实现
- OpenHarmony 平台的权限和文件存储注意事项
⚠️ 注意事项: 当前 OpenHarmony 适配版本中,缩放功能存在 bug,无法正常使用。建议避免使用缩放相关 API,或等待官方修复。