Flutter 基础体系 · 第 62/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 相机与媒体:权限、生命周期、编码、预览和资源释放

Flutter 中的“相机功能”不是一个单独的 UI 控件,而是一条跨越 Dart、Flutter 引擎、平台插件和原生相机框架的数据链路:

flowchart LR
    A[Flutter Widget] --> B[CameraController]
    B --> C[Platform Channel / Texture]
    C --> D[Android Camera2/CameraX<br/>iOS AVFoundation]
    D --> E[摄像头传感器]
    D --> F[预览帧]
    D --> G[照片或视频编码器]
    F --> B
    G --> H[XFile 或媒体流]

CameraController 负责协调初始化、预览、拍照、录像和图像流;原生平台负责真正访问摄像头、申请权限、管理传感器和编码器;Flutter 侧通常通过纹理(texture)显示预览。任何一层的状态不一致,都可能表现为黑屏、权限异常、录像失败、重复初始化或资源泄漏。

本文以 Flutter 官方生态中常用的 camera 插件为例。Flutter SDK 本身不直接提供跨平台相机 API,因此涉及摄像头的部分属于插件能力,而不是 dart:ui 或 Flutter Framework 的统一保证。


一、先区分四类数据:预览、照片、视频和图像流

相机应用中常见的“图像”并不是同一种数据。

1. 预览是实时显示通道

预览是摄像头连续产生的帧,经原生平台处理后显示在 Flutter 页面上的实时画面。它主要用于取景,不等同于最终照片或视频。

预览通常具有以下特征:

  • 持续产生;
  • 需要较低延迟;
  • 由纹理或平台视图显示;
  • 可能经过裁剪、旋转或缩放;
  • 不保证与拍照文件使用相同的分辨率和编码格式。

CameraPreview 的作用是把控制器关联的原生预览输出绘制到 Flutter Widget 树中。它不是从已经保存的 JPEG 文件反向生成画面。

2. 照片是一次性编码结果

调用 takePicture() 后,插件通常让原生相机捕获一张静态图像,再将其编码为平台支持的文件格式,并返回一个 XFile

final XFile image = await controller.takePicture();

print(image.path);
final bytes = await image.readAsBytes();

这里的 XFile 是一个跨平台文件抽象:

  • 移动端通常包含临时文件路径;
  • Web 上可能没有本地文件路径,而是以浏览器支持的字节或 Blob 形式存在;
  • path 不应被当作所有平台都可靠存在的持久路径;
  • 临时文件不等同于“已保存到系统相册”。

如果应用需要让用户在系统图库中看到照片,还需要使用系统相册或媒体库相关插件,并处理其额外权限和平台差异。

3. 视频是连续编码结果

录像不是把每一帧 JPEG 简单拼接起来。原生平台一般会:

  1. 从传感器读取连续帧;
  2. 将帧送入视频编码器;
  3. 生成视频码流,例如 H.264 或 H.265;
  4. 将码流封装到容器中,例如 MP4 或平台常见的其他容器;
  5. 在停止录像时完成文件尾部和索引信息的写入。

因此,stopVideoRecording() 返回文件之前,文件可能尚未完整可播放。

await controller.startVideoRecording();
final XFile video = await controller.stopVideoRecording();

print(video.path);

实际使用时不能只根据文件名推断编码格式。具体的视频编码器、容器、音频编码器和输出扩展名由平台、设备、系统版本及插件实现共同决定。

4. 图像流是原始帧通道

startImageStream() 提供的是连续的相机帧,而不是照片文件。它常用于二维码识别、机器学习和实时图像处理。

await controller.startImageStream((CameraImage image) {
  // image.planes 中包含平台相关的像素平面
});

这些帧通常是 YUV420、NV21、BGRA 等原始格式,具体格式依赖平台和 imageFormatGroup 配置。原始帧不能直接当作 JPEG 使用;如果要上传或保存,必须先转换和编码。

图像流的吞吐量可能很高。若每一帧都在 Dart isolate 中做昂贵的转换,主 isolate 可能掉帧,预览也可能卡顿。常见做法是:

  • 丢弃处理不过来的帧;
  • 限制处理频率;
  • 使用独立 isolate;
  • 尽量使用原生或硬件加速能力;
  • 在页面不可见时停止图像流。

二、权限不是一次性初始化,而是一个状态机

访问相机至少涉及两类权限:

  • 摄像头权限:允许读取摄像头传感器;
  • 麦克风权限:录制带声音的视频时需要。

拍照通常只需要摄像头权限。录像是否需要麦克风取决于 enableAudio

final controller = CameraController(
  description,
  ResolutionPreset.medium,
  enableAudio: false,
);

如果明确不录音,设置 enableAudio: false 可以避免不必要的麦克风权限请求。若使用默认配置或开启音频,平台可能在初始化或开始录像时要求麦克风权限。

权限流程可以抽象为:

stateDiagram-v2
    [*] --> 未请求
    未请求 --> 已授权: 用户允许
    未请求 --> 被拒绝: 用户拒绝
    被拒绝 --> 再次请求: 平台允许再次询问
    被拒绝 --> 永久拒绝: 用户关闭或系统不再提示
    永久拒绝 --> 设置页: 用户手动修改系统设置
    已授权 --> 暂时不可用: 应用切后台/其他应用占用
    暂时不可用 --> 已授权: 恢复并重新初始化

关键因果关系是:权限通过不代表相机已经可用。权限只允许应用访问设备;相机还可能被其他应用占用、被系统限制、被设备策略禁用,或者因为应用生命周期变化而需要重新建立会话。

Android 配置

在 Android 工程的 android/app/src/main/AndroidManifest.xml 中声明权限:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />

    <application
        android:label="camera_demo"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        <!-- activities -->
    </application>
</manifest>

CAMERA 是摄像头权限,RECORD_AUDIO 是麦克风权限。声明权限只是满足系统配置要求,Android 运行时仍可能需要向用户请求授权,插件通常会处理这一过程。

拍摄到应用私有目录的文件,通常不需要为了“保存照片”额外申请媒体读取权限。如果要把文件写入系统相册或公共媒体目录,则应根据目标 Android 版本和所用媒体库插件的要求配置权限。不能把“拍照权限”和“访问图库权限”混为一谈。

iOS 配置

ios/Runner/Info.plist 中添加用途说明:

<key>NSCameraUsageDescription</key>
<string>用于拍摄照片和视频</string>
<key>NSMicrophoneUsageDescription</key>
<string>用于录制带声音的视频</string>

如果应用只拍照,可以不声明麦克风用途;如果录像开启音频,缺少 NSMicrophoneUsageDescription 可能导致系统拒绝或应用异常。

iOS 的权限说明文字会直接显示给用户。它应准确解释用途,不能写成无意义的占位文本。

Web 和桌面差异

Web 相机通常依赖浏览器的 getUserMedia 能力:

  • 一般要求安全上下文,例如 HTTPS 或本机开发环境;
  • 权限由浏览器管理,不完全等同于 Android/iOS 的应用权限;
  • 用户可以在浏览器地址栏中撤销权限;
  • 设备枚举、摄像头切换和输出格式受浏览器限制;
  • XFile.path 等文件路径语义不能照搬移动端。

桌面平台没有 Flutter Framework 统一的相机实现。camera 插件的官方支持范围和具体版本能力需要以插件当前文档及平台实现为准;不能因为 Flutter 能运行在 Windows、macOS 或 Linux,就推断移动端相机代码可以直接运行。桌面项目往往需要平台专用插件,或者自行通过原生 API、Web 摄像头和 FFI 接入。


三、初始化相机:枚举、选择和建立控制器

完整初始化至少包含以下步骤:

  1. 查询可用摄像头;
  2. 选择前置或后置摄像头;
  3. 创建 CameraController
  4. 调用 initialize()
  5. 等待控制器进入可用状态;
  6. 再构建 CameraPreview

示例依赖:

dependencies:
  camera: any

真实项目应在 pubspec.yaml 中固定经过验证的版本,而不是长期使用 any。版本升级可能改变平台实现、权限行为和生命周期细节。

一个可运行的最小初始化入口如下:

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

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();

  final cameras = await availableCameras();

  runApp(
    MaterialApp(
      home: CameraPage(cameras: cameras),
    ),
  );
}

class CameraPage extends StatefulWidget {
  const CameraPage({
    super.key,
    required this.cameras,
  });

  final List<CameraDescription> cameras;

  @override
  State<CameraPage> createState() => _CameraPageState();
}

WidgetsFlutterBinding.ensureInitialized() 确保 Flutter 绑定在 runApp 前可用。availableCameras() 需要调用平台通道,因此不能把它当作一个纯同步常量读取。

摄像头列表可能为空,例如:

  • 设备没有摄像头;
  • 模拟器未配置摄像头;
  • Web 浏览器没有可用设备;
  • 平台访问失败;
  • 权限或设备策略导致枚举失败。

因此不应直接使用 cameras.first 而不检查结果。


四、生命周期:页面生命周期和应用生命周期不是一回事

Flutter 中至少要区分两种生命周期:

Widget 生命周期

initState()build()dispose() 描述 Widget 实例的创建、重建和销毁。Widget 重新 build() 并不意味着相机应该重新初始化。

应用生命周期

AppLifecycleState 描述应用进入前台、后台、非活动或暂停等状态。用户切换应用、锁屏、弹出系统权限页面时,应用生命周期可能发生变化。

相机是独占性较强的原生资源。应用进入后台后继续持有相机,可能导致:

  • 其他应用无法访问摄像头;
  • 恢复前台时预览黑屏;
  • 原生相机会话失效;
  • Android 或 iOS 抛出设备不可用错误;
  • 摄像头指示灯状态与 Flutter 页面不一致。

所以,生命周期管理的核心不是“在 dispose 里释放一次”,而是:

应用离开可用状态
    -> 停止正在进行的业务操作
    -> 释放原生相机会话和预览纹理
    -> 应用恢复
    -> 重新创建控制器并初始化

以下示例展示了一个带生命周期处理、初始化竞态保护和错误处理的页面骨架:

import 'dart:async';

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

class CameraPage extends StatefulWidget {
  const CameraPage({
    super.key,
    required this.cameras,
  });

  final List<CameraDescription> cameras;

  @override
  State<CameraPage> createState() => _CameraPageState();
}

class _CameraPageState extends State<CameraPage>
    with WidgetsBindingObserver {
  CameraController? _controller;
  Object? _error;
  int _generation = 0;
  bool _busy = false;

  CameraDescription? get _selectedCamera =>
      widget.cameras.isEmpty ? null : widget.cameras.first;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    unawaited(_openCamera());
  }

  Future<void> _openCamera() async {
    final camera = _selectedCamera;
    if (camera == null) {
      if (mounted) {
        setState(() => _error = '设备没有可用摄像头');
      }
      return;
    }

    final int generation = ++_generation;

    await _closeCamera();

    final controller = CameraController(
      camera,
      ResolutionPreset.medium,
      enableAudio: false,
    );

    try {
      await controller.initialize();

      // 初始化期间如果应用已经进入后台,或发生了下一次初始化,
      // 当前控制器就不能再交给 Widget 使用。
      if (!mounted || generation != _generation) {
        await controller.dispose();
        return;
      }

      setState(() {
        _controller = controller;
        _error = null;
      });
    } on CameraException catch (e) {
      await controller.dispose();

      if (!mounted || generation != _generation) {
        return;
      }

      setState(() {
        _error = '相机初始化失败:${e.code}';
      });
    } catch (e) {
      await controller.dispose();

      if (!mounted || generation != _generation) {
        return;
      }

      setState(() {
        _error = '相机初始化失败:$e';
      });
    }
  }

  Future<void> _closeCamera() async {
    final controller = _controller;
    _controller = null;

    if (controller == null) {
      return;
    }

    try {
      if (controller.value.isStreamingImages) {
        await controller.stopImageStream();
      }
    } catch (_) {
      // 释放阶段以继续释放控制器为主。
    }

    try {
      if (controller.value.isRecordingVideo) {
        await controller.stopVideoRecording();
      }
    } catch (_) {
      // 录像可能已被系统中断,继续执行 dispose。
    }

    await controller.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    final controller = _controller;

    if (state == AppLifecycleState.inactive ||
        state == AppLifecycleState.paused ||
        state == AppLifecycleState.detached) {
      ++_generation;
      unawaited(_closeCamera());
    } else if (state == AppLifecycleState.resumed &&
        controller == null) {
      unawaited(_openCamera());
    }
  }

  Future<void> _takePicture() async {
    final controller = _controller;

    if (controller == null ||
        !controller.value.isInitialized ||
        _busy ||
        controller.value.isTakingPicture) {
      return;
    }

    setState(() => _busy = true);

    try {
      final image = await controller.takePicture();

      if (!mounted) {
        return;
      }

      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('照片文件:${image.path}')),
      );
    } on CameraException catch (e) {
      if (mounted) {
        setState(() => _error = '拍照失败:${e.code}');
      }
    } finally {
      if (mounted) {
        setState(() => _busy = false);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    final controller = _controller;

    if (_error != null) {
      return Scaffold(
        body: Center(child: Text('$_error')),
        floatingActionButton: FloatingActionButton(
          onPressed: _openCamera,
          child: const Icon(Icons.refresh),
        ),
      );
    }

    if (controller == null || !controller.value.isInitialized) {
      return const Scaffold(
        body: Center(child: CircularProgressIndicator()),
      );
    }

    return Scaffold(
      body: Center(
        child: AspectRatio(
          aspectRatio: controller.value.aspectRatio,
          child: CameraPreview(controller),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _busy ? null : _takePicture,
        child: const Icon(Icons.camera_alt),
      ),
    );
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    ++_generation;

    final controller = _controller;
    _controller = null;

    if (controller != null) {
      unawaited(controller.dispose());
    }

    super.dispose();
  }
}

这个示例中的 _generation 是一个“初始化代数”。每次关闭或重新初始化时递增。假设第一次初始化尚未完成,用户迅速切后台又回到前台,可能同时存在两个异步 initialize()。旧任务完成后,如果没有代数检查,就可能把已经失效的控制器写回页面。代数检查保证只有当前任务可以提交结果。

代码中的 mounted 检查解决另一类问题:异步操作完成时,Widget 可能已经被移除;此时调用 setState 会导致异常。

实际项目还应把“启动录像”“停止录像”“切换前后摄像头”纳入同一套串行状态管理,而不是允许多个异步操作同时改写一个控制器。


五、预览:纹理、比例、旋转和裁剪

CameraPreview(controller) 显示的是控制器初始化后提供的相机预览。它要求:

controller.value.isInitialized == true

在初始化完成前构建预览,常见结果是断言失败、黑屏或平台纹理尚未准备好。

1. 为什么需要 AspectRatio

摄像头传感器的宽高比可能是 4:3、16:9 或其他比例。Flutter 布局则可能给 Widget 分配任意尺寸。如果直接把预览拉伸到任意矩形,画面中的圆形物体会变成椭圆。

AspectRatio(
  aspectRatio: controller.value.aspectRatio,
  child: CameraPreview(controller),
)

AspectRatio 保持预览比例,但不一定让画面铺满整个屏幕。如果产品要求全屏取景,通常需要在保持比例的前提下裁剪:

ClipRect(
  child: FittedBox(
    fit: BoxFit.cover,
    child: SizedBox(
      width: previewWidth,
      height: previewHeight,
      child: CameraPreview(controller),
    ),
  ),
)

这里的 BoxFit.cover 会裁掉一部分画面。它适合“铺满屏幕”,但不适合要求“完整显示传感器画面”的场景。

2. 预览比例不等于照片比例

预览分辨率由 ResolutionPreset 和平台能力共同决定;照片捕获可能使用另一条输出配置。即使预览看起来没有裁剪,最终照片也可能具有不同的尺寸和视角。

因此,取景框、二维码区域和人脸框不能只根据屏幕坐标推断照片坐标。需要知道:

  • 预览实际绘制区域;
  • 预览是否被 cover 裁剪;
  • 图像是否发生旋转;
  • 照片输出尺寸;
  • 前置摄像头是否镜像显示。

一个常见错误是把屏幕上的点直接乘以照片宽高比例。若预览被裁剪,映射关系还需要减去裁剪偏移量。

3. 前置摄像头的镜像问题

许多相机应用会镜像显示前置预览,让用户感觉像照镜子;但保存的照片是否镜像,取决于平台和应用处理方式。预览镜像与输出文件镜像是两个不同问题。

如果业务需要人脸关键点、签名或文字方向一致,必须分别验证:

  • 预览是否镜像;
  • 照片是否镜像;
  • 图像流是否镜像;
  • 坐标算法是否以镜像坐标为输入。

不能仅凭 UI 观察结果推断保存文件的方向。


六、编码:分辨率、格式、质量和容器不是同一概念

“编码格式”至少包含三个层次:

层次 例子 作用
像素格式 YUV420、BGRA 描述内存中的原始像素布局
编码格式 JPEG、H.264、H.265 将像素压缩为码流
容器格式 MP4、MOV 保存视频码流、音频码流和元数据

例如,一个视频可以是 H.264 视频码流封装在 MP4 容器中;“MP4”本身不是视频编码器。

1. ResolutionPreset 不是精确尺寸承诺

final controller = CameraController(
  camera,
  ResolutionPreset.high,
);

lowmediumhighveryHighultraHigh 等预设表达的是期望质量档位,不是所有设备都必须返回的固定像素尺寸。原生相机可能选择最接近的可用配置。

分辨率提升通常会带来:

  • 更大文件;
  • 更高内存占用;
  • 更高编码和上传成本;
  • 更高发热与功耗;
  • 更低的实时处理吞吐量。

如果应用只显示缩略图或上传头像,不应盲目选择最高预设。取舍应由最终用途决定。

2. 照片质量和视频码率

camera 插件的高层 API 不保证所有平台都暴露统一的 JPEG 质量、视频码率或编码器选择参数。需要精确控制时,通常有三种方案:

  1. 使用插件当前版本提供的能力;
  2. 拍摄后在应用侧重新压缩或转码;
  3. 编写平台专用实现。

重新压缩会产生额外 CPU、内存和耗时,并可能降低画质。视频转码还可能需要原生媒体框架或 FFmpeg 类方案,不能假设 Dart 层简单修改扩展名就完成了编码转换。

3. 文件路径和持久化

拍照返回的文件可能位于缓存目录或插件管理的临时目录。若业务需要长期保存,应在成功拍摄后将它复制到应用持久目录、上传到服务端,或交给系统媒体库。

final XFile image = await controller.takePicture();
final bytes = await image.readAsBytes();

// 例如上传 bytes;真正保存到图库需要额外的媒体库方案。

.jpg 改名为 .png 不会改变编码;把视频 .mov 改成 .mp4 也不会改变容器。扩展名必须与实际内容一致,否则播放器或图像解码器可能报错。


七、录像和拍照的状态约束

同一个控制器通常存在多个互斥或有顺序要求的操作:

未初始化
  -> 已初始化
  -> 拍照中
  -> 已初始化

已初始化
  -> 录像中
  -> 已暂停(平台支持时)
  -> 停止录像
  -> 已初始化

典型约束包括:

  • takePicture() 之前必须初始化;
  • 正在拍照时再次调用拍照,应避免并发;
  • 录像期间不能随意释放控制器;
  • 停止录像必须等待返回结果;
  • 图像流和录像是否能同时使用,取决于平台和插件实现;
  • 应用进入后台时,录像可能被系统中断,不能假设一定能正常生成文件。

一个简单的录像流程如下:

Future<XFile?> recordFiveSeconds(
  CameraController controller,
) async {
  if (!controller.value.isInitialized ||
      controller.value.isRecordingVideo) {
    return null;
  }

  await controller.startVideoRecording();

  await Future<void>.delayed(const Duration(seconds: 5));

  if (!controller.value.isRecordingVideo) {
    return null;
  }

  return controller.stopVideoRecording();
}

这个示例的前置条件是控制器已初始化且未录像。它并不保证五秒后一定仍在录像:用户切后台、电话打断、系统回收资源或平台错误都可能使录像提前结束。因此生产代码需要在每一步捕获 CameraException,并在页面离开时处理未完成的录像。

如果要求“只能有一个相机操作同时执行”,可以在拍照、录像、切换摄像头和释放之间使用互斥队列,而不是用多个独立布尔变量拼凑状态。布尔变量容易出现如下错误:

_takePicture() 已开始
用户点击切换摄像头
旧控制器被 dispose
_takePicture() 继续完成并更新页面

这类问题本质上是异步操作缺少取消或代数校验。


八、资源释放:dispose() 释放的到底是什么

相机控制器背后通常持有多种资源:

  • 原生相机会话;
  • 摄像头设备句柄;
  • 预览纹理;
  • 图像流回调;
  • 视频编码器;
  • 音频输入;
  • 临时文件和平台通道状态。

CameraController.dispose() 的意义不是普通 Dart 对象的垃圾回收。Dart 垃圾回收器不知道何时应该释放原生摄像头,也不能替代显式关闭。

正确的释放顺序

在没有特殊平台要求时,可以遵循:

停止图像流
    -> 停止或放弃录像
    -> 释放控制器
    -> 清空 Flutter 侧引用

释放过程应具有幂等性:调用一次或多次,都不应让应用进入不可恢复状态。因为在生命周期回调、页面销毁和错误处理路径中,关闭逻辑可能被多个地方触发。

常见泄漏表现

如果忘记释放控制器,可能出现:

  • 返回相机页面后再次打开失败;
  • Android 报相机设备被占用;
  • iOS 预览黑屏;
  • 内存持续增长;
  • 摄像头指示灯仍然亮;
  • 图像流回调继续执行;
  • 页面已经销毁,但异步回调仍尝试更新状态。

只在 build() 中创建控制器是高风险做法:

@override
Widget build(BuildContext context) {
  final controller = CameraController(...); // 错误示例
  return CameraPreview(controller);
}

build() 可能被调用很多次。这样会重复创建控制器,却没有可靠的释放时机。控制器应放在 State、专用业务对象或明确的状态管理层中,并由拥有它的对象负责释放。


九、错误路径必须和正常路径同等重要

相机错误不是只有“权限拒绝”一种。常见故障原因包括:

  • 用户拒绝摄像头权限;
  • 用户拒绝麦克风权限;
  • 权限被永久拒绝或受系统策略限制;
  • 摄像头被其他应用占用;
  • 模拟器没有摄像头;
  • 应用恢复后原生会话失效;
  • 当前分辨率或输出配置不被设备支持;
  • 录像过程中电话或系统事件中断;
  • Web 浏览器阻止了设备访问;
  • 文件写入失败或临时目录不可用。

CameraException 的错误码应以当前插件版本定义为准。工程代码应记录至少以下信息:

try {
  await controller.initialize();
} on CameraException catch (e, stackTrace) {
  debugPrint('camera error code=${e.code}, description=${e.description}');
  debugPrintStack(stackTrace: stackTrace);
}

用户界面不应直接展示内部堆栈,而应根据错误类别提供可执行反馈:

  • 权限拒绝:提示到系统设置或重新授权;
  • 无设备:提示设备不支持;
  • 暂时占用:提示重试;
  • 初始化失败:释放旧控制器后重新初始化;
  • 文件失败:检查存储和上传状态。

但不能看到“初始化失败”就无限自动重试。若权限永久拒绝或设备不存在,无限重试只会造成耗电和日志污染。


十、平台差异对设计的直接影响

Android

Android 设备厂商差异较大。相同的分辨率预设、摄像头方向和编码配置,在不同设备上可能对应不同的实际输出。应在真机上验证:

  • 前后摄像头切换;
  • 横竖屏;
  • 应用切后台再恢复;
  • 锁屏和解锁;
  • 录像带音频;
  • 低端设备上的图像流性能;
  • 其他应用占用摄像头后的恢复。

iOS

iOS 的 AVFoundation 会话对应用激活状态、音频会话和摄像头占用较敏感。权限用途说明缺失时,问题可能在首次访问时直接暴露。前后台切换、来电和系统相机占用也需要验证。

Web

Web 的能力由浏览器决定。浏览器权限、HTTPS、设备选择、页面可见性和自动播放策略都可能影响体验。不能把移动端临时文件路径、系统相册权限和浏览器媒体设备模型直接等价处理。

桌面

桌面端通常需要专用插件或原生实现。应先确认目标平台是否被相机插件支持,再决定是否复用移动端抽象。若插件只支持部分平台,代码需要通过条件导入、平台能力检测或独立实现隔离差异,而不是在运行时等待一个永远不会成功的初始化。


十一、一个完整的诊断思路

面对“相机黑屏”时,应按数据链路逐层判断,而不是先修改布局:

  1. availableCameras() 是否返回设备;
  2. 初始化是否完成;
  3. 是否发生了权限异常;
  4. controller.value.isInitialized 是否为 true
  5. 是否在应用恢复过程中复用了旧控制器;
  6. 是否在 dispose() 后仍使用旧控制器;
  7. 预览 Widget 是否被正确放入可见布局;
  8. 是否被 AspectRatioFittedBox 或裁剪组件显示成了零尺寸;
  9. 是否有其他应用占用摄像头;
  10. 当前设备和平台是否支持该输出配置。

面对“拍照成功但上传失败”,则应拆分:

相机捕获成功
    ≠ 文件已永久保存
    ≠ 文件格式符合服务端要求
    ≠ 文件大小符合限制
    ≠ 文件已上传成功

应分别检查 XFile 是否可读、实际字节数、内容类型、文件扩展名、上传请求和服务端解码结果。不要把相机异常、文件系统异常和网络异常统一显示成“拍照失败”。


十二、容易产生的错误认知

错误一:权限通过后就不需要重新初始化

权限解决的是访问资格,生命周期解决的是原生会话是否仍然有效。应用从后台恢复后,旧控制器可能已经失效,通常需要关闭并重新初始化。

错误二:CameraPreview 就是最终照片

预览可能使用不同分辨率、比例、方向和裁剪策略。UI 中看到的内容不必然等于 takePicture() 得到的文件内容。

错误三:文件扩展名决定编码

扩展名只是名称。真正决定解码方式的是文件内部格式和码流。重命名不能完成转码。

错误四:Dart 垃圾回收会自动关闭摄像头

原生资源必须显式释放。dispose() 是资源协议的一部分,不是可有可无的清理习惯。

错误五:桌面和 Web 与移动端完全相同

Flutter 的 Widget API 可以跨平台,但相机设备、权限、文件和编码能力并不因此统一。跨平台代码只能统一业务抽象,不能抹掉底层能力差异。


结语

可靠的 Flutter 相机功能可以归纳为一条严格的数据和状态链:

声明平台权限
    -> 检查可用设备
    -> 创建并初始化控制器
    -> 等待预览就绪
    -> 按状态执行拍照、录像或图像流
    -> 处理编码文件或原始帧
    -> 响应前后台变化
    -> 在所有退出和错误路径释放原生资源

其中,权限决定“能否访问”,生命周期决定“当前会话是否仍有效”,预览负责“实时显示”,编码负责“把媒体变成可存储或传输的数据”,资源释放负责“让下一次访问仍然可靠”。只有把这些概念分开,再通过明确的状态转换连接起来,相机代码才不会停留在“能打开预览”的演示阶段。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。