前面给大家讲解 http 包和 dio 包,涉及到一个知识点就是上传图片,那么图片哪里来?
每个移动设备都带有一个内置的相机应用程序,用于捕捉图片、录制视频以及一些特定于每个设备的吸引人的功能。但是如果你正在开发一个需要相机访问的应用程序,那么你必须自己实现相机功能。
你可能会问,当默认的相机应用程序已经可用时,为什么我需要再次实现相机功能?
答案是因为,如果您想提供适合您的应用的独特用户界面,或者添加设备默认相机应用中不存在的功能,那么它是必需的。
在本文中,您将学习使用支持 Android 和 iOS 平台的官方camera包为 Flutter 应用程序实现基本的相机功能。
应用概览
在深入研究代码之前,让我们回顾一下我们将要构建的应用程序。最终的应用程序将包含大部分基本的相机功能,包括:
捕获清晰度选择器
变焦控制
曝光控制
闪光模式选择器
翻转摄像头的按钮——后摄像头到前摄像头,反之亦然
用于捕获图像的按钮
用于从图像模式切换到视频模式的切换
视频模式控制——开始、暂停、恢复、停止
上次捕获的图像或视频预览
检索图像/视频文件
先看一眼最后的效果图。
入门
使用以下命令创建一个新的 Flutter 项目:
flutter create flutter_camera_demo
复制代码
您可以使用自己喜欢的 IDE 打开项目,但在本示例中,我将使用 VS Code:
将以下依赖项添加到您的文件中:pubspec.yaml
使用 camera 如果报错的话
尝试将 compileSdkVersion 和 targetSdkVersion 更新为 31。
将文件内容替换为以下内容:main.dart
import 'package:flutter/material.dart';import 'screens/camera_screen.dart';Future<void> main() async { runApp(MyApp());}class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', theme: ThemeData( primarySwatch: Colors.blue, ), debugShowCheckedModeBanner: false, home: CameraScreen(), ); }}
复制代码
该CameraScreen将包含所有的相机功能的代码与它的用户界面一起。我们将稍后添加它,但在我们这样做之前,我们必须在设备上安装可用的摄像头。
检索可用的相机
在 main.dart 文件中,定义一个全局变量,称为我们将存储可用摄像机列表的位置。这将有助于我们以后轻松引用它们。`cameras
import 'package:camera/camera.dart';List<CameraDescription> cameras = [];
复制代码
您可以在使用该方法初始化应用程序之前检索函数内部的相机——只需确保该函数是异步的,因为它必须等待检索设备的可用相机,并且通常 Flutter 的函数是一个简单的函数,只有调用:main()``availableCameras()``main()``runApp()
Future<void> main() async { try { WidgetsFlutterBinding.ensureInitialized(); cameras = await availableCameras(); } on CameraException catch (e) { print('Error in fetching the cameras: $e'); } runApp(MyApp());}
复制代码
初始化相机
创建一个名为 camera_screen.dart 的新文件并在其中定义有状态小部件 CameraScreen。
import 'package:camera/camera.dart';import 'package:flutter/material.dart';import '../main.dart';class CameraScreen extends StatefulWidget { @override _CameraScreenState createState() => _CameraScreenState();}class _CameraScreenState extends State<CameraScreen> { @override Widget build(BuildContext context) { return Scaffold(); }}
复制代码
为相机定义一个控制器,为isCameraInitialized布尔变量定义一个值,您可以使用它来轻松了解相机是否已初始化并相应地刷新 UI:
class _CameraScreenState extends State<CameraScreen> { CameraController? controller; bool _isCameraInitialized = false; @override Widget build(BuildContext context) { return Scaffold(); }}
复制代码
控制器将帮助您访问相机的不同功能,但在使用它们之前,您必须初始化相机。
创建一个名为 的新方法。此方法将有助于处理两种情况:onNewCameraSelected()
初始化一个新的相机控制器,这是启动相机屏幕所需要的
当用户翻转相机视图或改变相机清晰度时,处置之前的控制器并用具有不同属性的新控制器替换它
class _CameraScreenState extends State { // ...
void onNewCameraSelected(CameraDescription cameraDescription) async { final previousCameraController = controller; // Instantiating the camera controller final CameraController cameraController = CameraController( cameraDescription, ResolutionPreset.high, imageFormatGroup: ImageFormatGroup.jpeg, );
// Dispose the previous controller await previousCameraController?.dispose();
// Replace with the new controller if (mounted) { setState(() { controller = cameraController; }); }
// Update UI if controller updated cameraController.addListener(() { if (mounted) setState(() {}); });
// Initialize controller try { await cameraController.initialize(); } on CameraException catch (e) { print('Error initializing camera: $e'); }
// Update the boolean if (mounted) { setState(() { _isCameraInitialized = controller!.value.isInitialized; }); } }
@override Widget build(BuildContext context) { return Scaffold(); }}
复制代码
在 initState()方法内部调用此函数并作为. 列表的第一个索引通常是设备的后置摄像头。
索引0的cameras名单-后置摄像头
索引1的cameras名单-前置摄像头
lass _CameraScreenState extends State { // ...
@override void initState() { onNewCameraSelected(cameras[0]); super.initState(); }
@override Widget build(BuildContext context) { return Scaffold(); }}
复制代码
另外,不要忘记在相机未激活时释放方法中的内存:dispose()
@overridevoid dispose() { controller?.dispose(); super.dispose();}
复制代码
处理相机生命周期状态
在任何设备上运行相机都被认为是一项占用大量内存的任务,因此如何处理释放内存资源以及何时释放内存资源非常重要。应用程序的生命周期状态有助于了解状态变化,以便您作为开发人员可以做出相应的反应。
在 Flutter 中,您可以通过添加WidgetsBindingObserver mixin 并管理生命周期更改。didChangeAppLifecycleState()
class _CameraScreenState extends State<CameraScreen> with WidgetsBindingObserver {
// ...
@override void didChangeAppLifecycleState(AppLifecycleState state) { final CameraController? cameraController = controller;
// App state changed before we got the chance to initialize. if (cameraController == null || !cameraController.value.isInitialized) { return; }
if (state == AppLifecycleState.inactive) { // Free up memory when camera not active cameraController.dispose(); } else if (state == AppLifecycleState.resumed) { // Reinitialize the camera with same properties onNewCameraSelected(cameraController.description); } }
@override Widget build(BuildContext context) { return Scaffold(); }}
复制代码
添加相机预览
现在我们已经完成了相机状态的初始化和管理,我们可以定义一个非常基本的用户界面来预览相机输出。
Flutter 的 camera 插件自带一个方法,调用显示 camera 输出,用户界面可以定义如下:buildPreview()
class _CameraScreenState extends State<CameraScreen> with WidgetsBindingObserver {
// ...
@override Widget build(BuildContext context) { return Scaffold( body: _isCameraInitialized ? AspectRatio( aspectRatio: 1 / controller!.value.aspectRatio, child: controller!.buildPreview(), ) : Container(), ); }}
复制代码
预览将如下所示:
您会注意到设备状态栏在顶部可见;您可以通过在方法中添加以下内容来隐藏它以防止它阻碍相机视图:initState()
@overridevoid initState() { // Hide the status bar SystemChrome.setEnabledSystemUIOverlays([]);
onNewCameraSelected(cameras[0]); super.initState();}
复制代码
基本的相机预览已准备就绪!现在,我们可以开始向相机添加功能。
添加清晰度选择器
您可以使用ResolutionPreset来定义相机视图的清晰度。在初始化相机时,我们使用了.ResolutionPreset.high
要更改相机视图的清晰度,您必须使用新值重新初始化相机控制器。我们将在相机视图的右上角添加一个下拉菜单,用户可以在其中选择分辨率预设。
在类中添加两个变量,一个用于保存所有ResolutionPreset值,另一个用于存储currentResolutionPreset值。
final resolutionPresets = ResolutionPreset.values;ResolutionPreset currentResolutionPreset = ResolutionPreset.high;
复制代码
修改方法中的相机控制器实例以使用该变量:onNewCameraSelected()``currentResolutionPreset
final CameraController cameraController = CameraController( cameraDescription, currentResolutionPreset, imageFormatGroup: ImageFormatGroup.jpeg,);
复制代码
在DropdownButton可被定义为如下:
DropdownButton<ResolutionPreset>( dropdownColor: Colors.black87, underline: Container(), value: currentResolutionPreset, items: [ for (ResolutionPreset preset in resolutionPresets) DropdownMenuItem( child: Text( preset .toString() .split('.')[1] .toUpperCase(), style: TextStyle(color: Colors.white), ), value: preset, ) ], onChanged: (value) { setState(() { currentResolutionPreset = value!; _isCameraInitialized = false; }); onNewCameraSelected(controller!.description); }, hint: Text("Select item"),)
复制代码
调用该方法以使用新的清晰度值重新初始化相机控制器。onNewCameraSelected()
录制的图片有点大,大家下载预览吧
https://luckly007.oss-cn-beijing.aliyuncs.com/image/camera-quality-selector-demo.gif
变焦控制
您可以使用控制器上的方法并传递缩放值来设置相机的缩放级别。setZoomLevel()
在确定缩放级别之前,您应该知道设备相机的最小和最大缩放级别。
定义三个变量:
double _minAvailableZoom = 1.0;double _maxAvailableZoom = 1.0;double _currentZoomLevel = 1.0;
复制代码
检索这些值的最佳位置是在相机初始化后的方法内部。您可以使用以下方法获得最小和最大缩放级别:onNewCameraSelected()
cameraController .getMaxZoomLevel() .then((value) => _maxAvailableZoom = value);
cameraController .getMinZoomLevel() .then((value) => _minAvailableZoom = value);
复制代码
您可以实现一个滑块,让用户选择合适的缩放级别;构建的代码Slider如下:
Row( children: [ Expanded( child: Slider( value: _currentZoomLevel, min: _minAvailableZoom, max: _maxAvailableZoom, activeColor: Colors.white, inactiveColor: Colors.white30, onChanged: (value) async { setState(() { _currentZoomLevel = value; }); await controller!.setZoomLevel(value); }, ), ), Container( decoration: BoxDecoration( color: Colors.black87, borderRadius: BorderRadius.circular(10.0), ), child: Padding( padding: const EdgeInsets.all(8.0), child: Text( _currentZoomLevel.toStringAsFixed(1) + 'x', style: TextStyle(color: Colors.white), ), ), ), ],)
复制代码
每次拖动滑块时,都会调用该方法来更新缩放级别值。在上面的代码中,我们还添加了一个小部件来显示当前的缩放级别值。setZoomLevel()``Text
录制的图片有点大,大家下载预览吧
https://luckly007.oss-cn-beijing.aliyuncs.com/image/camera-zoom-demo.gif
曝光控制
您可以使用控制器上的方法并传递曝光值来设置相机的曝光偏移值。setExposureOffset()
首先,让我们检索设备支持的相机曝光的最小值和最大值。
定义三个变量:
ouble _minAvailableExposureOffset = 0.0;double _maxAvailableExposureOffset = 0.0;double _currentExposureOffset = 0.0;
复制代码
获取方法内的最小和最大相机曝光值:onNewCameraSelected()
cameraController .getMinExposureOffset() .then((value) => _minAvailableExposureOffset = value);
cameraController .getMaxExposureOffset() .then((value) => _maxAvailableExposureOffset = value);
复制代码
我们将构建一个用于显示和控制曝光偏移的垂直滑块。Material Design 不提供垂直Slider小部件,但您可以使用四分之三圈的RotatedBox类来实现这一点。
Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10.0), ), child: Padding( padding: const EdgeInsets.all(8.0), child: Text( _currentExposureOffset.toStringAsFixed(1) + 'x', style: TextStyle(color: Colors.black), ), ),),Expanded( child: RotatedBox( quarterTurns: 3, child: Container( height: 30, child: Slider( value: _currentExposureOffset, min: _minAvailableExposureOffset, max: _maxAvailableExposureOffset, activeColor: Colors.white, inactiveColor: Colors.white30, onChanged: (value) async { setState(() { _currentExposureOffset = value; }); await controller!.setExposureOffset(value); }, ), ), ),)
复制代码
在上面的代码中,我们Text在滑块顶部构建了一个小部件来显示当前的曝光偏移值。
录制的图片有点大,大家下载预览吧
https://luckly007.oss-cn-beijing.aliyuncs.com/image/camera-exposure-offset-demo.gif)
闪光模式选择器
您可以使用该方法并传递一个值来设置相机的闪光模式。setFlashMode()``FlashMode
定义一个变量来存储 flash 模式的当前值:
FlashMode? _currentFlashMode;
复制代码
然后获取方法内部的初始 flash 模式值:onNewCameraSelected()
_currentFlashMode = controller!.value.flashMode;
复制代码
在用户界面上,我们将连续显示可用的闪光模式,用户可以点击其中任何一种来选择该闪光模式。
Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ InkWell( onTap: () async { setState(() { _currentFlashMode = FlashMode.off; }); await controller!.setFlashMode( FlashMode.off, ); }, child: Icon( Icons.flash_off, color: _currentFlashMode == FlashMode.off ? Colors.amber : Colors.white, ), ), InkWell( onTap: () async { setState(() { _currentFlashMode = FlashMode.auto; }); await controller!.setFlashMode( FlashMode.auto, ); }, child: Icon( Icons.flash_auto, color: _currentFlashMode == FlashMode.auto ? Colors.amber : Colors.white, ), ), InkWell( onTap: () async { setState(() { _isCameraInitialized = false; }); onNewCameraSelected( cameras[_isRearCameraSelected ? 1 : 0], ); setState(() { _isRearCameraSelected = !_isRearCameraSelected; }); }, child: Icon( Icons.flash_on, color: _currentFlashMode == FlashMode.always ? Colors.amber : Colors.white, ), ), InkWell( onTap: () async { setState(() { _currentFlashMode = FlashMode.torch; }); await controller!.setFlashMode( FlashMode.torch, ); }, child: Icon( Icons.highlight, color: _currentFlashMode == FlashMode.torch ? Colors.amber : Colors.white, ), ), ],)
复制代码
选定的闪光模式将以琥珀色而不是白色突出显示。
翻转相机切换
要在前后摄像头之间切换,您必须通过向方法提供新值来重新初始化摄像头。onNewCameraSelected()
定义一个布尔变量来了解是否选择了后置摄像头,否则选择了前置摄像头。
bool _isRearCameraSelected = true;
复制代码
以前,我们使用后置摄像头进行初始化,因此我们将存储true在此布尔值中。
现在,我们将显示一个按钮来在后置摄像头和前置摄像头之间切换:
InkWell( onTap: () { setState(() { _isCameraInitialized = false; }); onNewCameraSelected( cameras[_isRearCameraSelected ? 0 : 1], ); setState(() { _isRearCameraSelected = !_isRearCameraSelected; }); }, child: Stack( alignment: Alignment.center, children: [ Icon( Icons.circle, color: Colors.black38, size: 60, ), Icon( _isRearCameraSelected ? Icons.camera_front : Icons.camera_rear, color: Colors.white, size: 30, ), ], ),)
复制代码
在上面的代码中,如果_isRearCameraSelected布尔值为true,则0作为索引传递给cameras(翻转到前置摄像头),否则1作为索引传递(翻转到后置摄像头)。
录制的图片有点大,大家下载预览吧
https://luckly007.oss-cn-beijing.aliyuncs.com/image/camera-exposure-offset-demo.gif
捕捉图像
您可以使用相机控制器上的方法使用设备相机拍照。捕获的图片作为 a (这是一个跨平台的文件抽象)返回。takePicture()``XFile
让我们定义一个函数来处理图片的捕获:
Future<XFile?> takePicture() async { final CameraController? cameraController = controller; if (cameraController!.value.isTakingPicture) { // A capture is already pending, do nothing. return null; } try { XFile file = await cameraController.takePicture(); return file; } on CameraException catch (e) { print('Error occured while taking picture: $e'); return null; }}
复制代码
该函数返回捕获的图片,XFile如果捕获成功,则返回null。
捕获按钮可以定义如下:
InkWell( onTap: () async { XFile? rawImage = await takePicture(); File imageFile = File(rawImage!.path);
int currentUnix = DateTime.now().millisecondsSinceEpoch; final directory = await getApplicationDocumentsDirectory(); String fileFormat = imageFile.path.split('.').last;
await imageFile.copy( '${directory.path}/$currentUnix.$fileFormat', ); }, child: Stack( alignment: Alignment.center, children: [ Icon(Icons.circle, color: Colors.white38, size: 80), Icon(Icons.circle, color: Colors.white, size: 65), ], ),)
复制代码
捕获成功后,会将图片保存到应用程序的文档目录中,并以时间戳作为图片名称,以便以后可以轻松访问所有捕获的图片。
在图像和视频模式之间切换
您可以TextButton连续使用两个 s 在图像和视频模式之间切换。
定义一个布尔变量来存储所选模式:
bool _isVideoCameraSelected = false;
复制代码
UI 按钮可以这样定义:
Row( children: [ Expanded( child: Padding( padding: const EdgeInsets.only( left: 8.0, right: 4.0, ), child: TextButton( onPressed: _isRecordingInProgress ? null : () { if (_isVideoCameraSelected) { setState(() { _isVideoCameraSelected = false; }); } }, style: TextButton.styleFrom( primary: _isVideoCameraSelected ? Colors.black54 : Colors.black, backgroundColor: _isVideoCameraSelected ? Colors.white30 : Colors.white, ), child: Text('IMAGE'), ), ), ), Expanded( child: Padding( padding: const EdgeInsets.only( left: 4.0, right: 8.0), child: TextButton( onPressed: () { if (!_isVideoCameraSelected) { setState(() { _isVideoCameraSelected = true; }); } }, style: TextButton.styleFrom( primary: _isVideoCameraSelected ? Colors.black : Colors.black54, backgroundColor: _isVideoCameraSelected ? Colors.white : Colors.white30, ), child: Text('VIDEO'), ), ), ), ],)
复制代码
视频录制
要使用设备摄像头管理视频录制,您必须定义四个函数来处理录制过程的状态:
startVideoRecording() 开始视频录制过程
stopVideoRecording() 停止视频录制过程
pauseVideoRecording() 暂停录制,如果它已经在进行中
resumeVideoRecording() 如果处于暂停状态,则恢复录制
此外,定义一个布尔变量来存储是否正在进行录制:
bool _isRecordingInProgress = false;
复制代码
开始录制
您可以通过调用相机控制器上的方法开始视频录制:startVideoRecording()
Future<void> startVideoRecording() async { final CameraController? cameraController = controller; if (controller!.value.isRecordingVideo) { // A recording has already started, do nothing. return; } try { await cameraController!.startVideoRecording(); setState(() { _isRecordingInProgress = true; print(_isRecordingInProgress); }); } on CameraException catch (e) { print('Error starting to record video: $e'); }}
复制代码
开始录制后,布尔值_isRecordingInProgress设置为true。
停止录制
可以通过调用控制器上的方法来停止已经在进行的视频录制:stopVideoRecording()
Future<XFile?> stopVideoRecording() async { if (!controller!.value.isRecordingVideo) { // Recording is already is stopped state return null; } try { XFile file = await controller!.stopVideoRecording(); setState(() { _isRecordingInProgress = false; print(_isRecordingInProgress); }); return file; } on CameraException catch (e) { print('Error stopping video recording: $e'); return null; }}
复制代码
录制停止后,布尔值_isRecordingInProgress设置为false。该方法以格式返回视频文件。stopVideoRecording()``XFile
暂停录制
您可以通过调用控制器上的方法暂停正在进行的视频录制:pauseVideoRecording()
Future<void> pauseVideoRecording() async { if (!controller!.value.isRecordingVideo) { // Video recording is not in progress return; } try { await controller!.pauseVideoRecording(); } on CameraException catch (e) { print('Error pausing video recording: $e'); }}
复制代码
恢复录制
您可以通过调用控制器上的方法来恢复暂停的视频录制:resumeVideoRecording()
Future<void> resumeVideoRecording() async { if (!controller!.value.isRecordingVideo) { // No video recording was in progress return; } try { await controller!.resumeVideoRecording(); } on CameraException catch (e) { print('Error resuming video recording: $e'); }}
复制代码
开始和停止录制的按钮
您可以通过检查_isVideoCameraSelected布尔值是否为真并在该位置显示视频开始/停止按钮来修改拍照按钮。
InkWell( onTap: _isVideoCameraSelected ? () async { if (_isRecordingInProgress) { XFile? rawVideo = await stopVideoRecording(); File videoFile = File(rawVideo!.path);
int currentUnix = DateTime.now().millisecondsSinceEpoch;
final directory = await getApplicationDocumentsDirectory(); String fileFormat = videoFile.path.split('.').last;
_videoFile = await videoFile.copy( '${directory.path}/$currentUnix.$fileFormat', );
_startVideoPlayer(); } else { await startVideoRecording(); } } : () async { // code to handle image clicking }, child: Stack( alignment: Alignment.center, children: [ Icon( Icons.circle, color: _isVideoCameraSelected ? Colors.white : Colors.white38, size: 80, ), Icon( Icons.circle, color: _isVideoCameraSelected ? Colors.red : Colors.white, size: 65, ), _isVideoCameraSelected && _isRecordingInProgress ? Icon( Icons.stop_rounded, color: Colors.white, size: 32, ) : Container(), ], ),)
复制代码
同样,在录制过程中,您可以检查布尔值是否_isRecordingInProgress为true并显示暂停/恢复按钮而不是相机翻转按钮。
上次捕获的预览
让我们在相机视图的右下角显示最后拍摄的图片或录制的视频的预览。
为了实现这一点,我们还必须定义一种视频播放方法。
定义一个视频播放器控制器:
VideoPlayerController? videoController;
复制代码
以下方法用于使用存储在_videoFile变量中的视频文件启动视频播放器:
Future<void> _startVideoPlayer() async { if (_videoFile != null) { videoController = VideoPlayerController.file(_videoFile!); await videoController!.initialize().then((_) { // Ensure the first frame is shown after the video is initialized, // even before the play button has been pressed. setState(() {}); }); await videoController!.setLooping(true); await videoController!.play(); }}
复制代码
另外,不要忘记释放方法中的内存:dispose()
@overridevoid dispose() { // ... videoController?.dispose(); super.dispose();}
复制代码
预览的用户界面可以定义如下:
Container( width: 60, height: 60, decoration: BoxDecoration( color: Colors.black, borderRadius: BorderRadius.circular(10.0), border: Border.all(color: Colors.white, width: 2), image: _imageFile != null ? DecorationImage( image: FileImage(_imageFile!), fit: BoxFit.cover, ) : null, ), child: videoController != null && videoController!.value.isInitialized ? ClipRRect( borderRadius: BorderRadius.circular(8.0), child: AspectRatio( aspectRatio: videoController!.value.aspectRatio, child: VideoPlayer(videoController!), ), ) : Container(),)
复制代码
检索图像/视频文件
由于我们已将所有捕获的图像和录制的视频存储在应用程序文档目录的单个文件夹中,因此您可以轻松检索所有文件。如果您想在画廊视图中显示它们,或者您只想在预览中显示最后捕获的图像或视频文件的缩略图,这可能是必要的。
我们将定义一个方法,当新的捕获或录制完成时,该方法也将刷新预览图像/视频。
// To store the retrieved filesList<File> allFileList = [];
refreshAlreadyCapturedImages() async { // Get the directory final directory = await getApplicationDocumentsDirectory(); List<FileSystemEntity> fileList = await directory.list().toList(); allFileList.clear();
List<Map<int, dynamic>> fileNames = [];
// Searching for all the image and video files using // their default format, and storing them fileList.forEach((file) { if (file.path.contains('.jpg') || file.path.contains('.mp4')) { allFileList.add(File(file.path));
String name = file.path.split('/').last.split('.').first; fileNames.add({0: int.parse(name), 1: file.path.split('/').last}); } });
// Retrieving the recent file if (fileNames.isNotEmpty) { final recentFile = fileNames.reduce((curr, next) => curr[0] > next[0] ? curr : next); String recentFileName = recentFile[1]; // Checking whether it is an image or a video file if (recentFileName.contains('.mp4')) { _videoFile = File('${directory.path}/$recentFileName'); _startVideoPlayer(); } else { _imageFile = File('${directory.path}/$recentFileName'); }
setState(() {}); }}
复制代码
总结
您已经创建了一个具有所有基本功能的成熟相机应用程序。您现在甚至可以向此应用程序添加自定义功能,并自定义用户界面以匹配您应用程序的设计调色板。
评论