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

Flutter 文件与媒体:选择、上传、图片、视频、相机和生命周期

文件与媒体功能通常被误认为是“调用一个插件,然后把路径传给服务器”。实际上,它包含至少五个不同问题:

  1. 来源:文件来自系统文件选择器、相册、相机,还是应用沙盒。
  2. 表示:数据是路径、字节、流,还是一个尚未读取的媒体对象。
  3. 展示:图片和视频分别需要不同的解码与播放组件。
  4. 传输:上传要处理大小、类型、进度、取消、重试和服务端校验。
  5. 生命周期:应用进入后台、相机被系统回收、页面销毁时,控制器和临时文件如何处理。

这些问题在 Android、iOS、桌面和 Web 上并不等价。一个在 Android 上成立的 File(path) 示例,可能在 Web 上完全无法编译或运行。


一、先区分文件、媒体和文件来源

1. 文件是什么

文件是由字节组成的持久化数据。一个文件通常具有:

  • 名称,例如 avatar.jpg
  • MIME 类型,例如 image/jpeg
  • 大小,例如 2_048_000 字节;
  • 内容字节;
  • 可选的本地路径;
  • 可选的远程 URL;
  • 可选的创建时间、修改时间等元数据。

其中,路径不是文件本身。路径只是当前平台访问文件的一种方式。

在移动端,路径可能指向:

  • 应用沙盒中的真实文件;
  • Android 内容 URI 映射后的临时路径;
  • iOS 图片选择器复制出的临时文件;
  • 插件创建的缓存文件。

在 Web 中,浏览器不允许 Flutter 代码像桌面程序一样任意访问本机路径。浏览器通常只提供:

  • Blob
  • File
  • 字节数组;
  • 可读流;
  • 一个仅用于展示或下载的对象 URL。

因此,跨平台代码不应把“有路径”作为“已经有文件”的必要条件。

2. 媒体是什么

媒体是具有特定编码和语义的文件,例如:

  • 图片:JPEG、PNG、WebP、HEIF、GIF;
  • 视频:MP4、MOV、WebM;
  • 音频:AAC、MP3、WAV、M4A。

媒体除了字节,还需要解码器。例如:

  • Image 组件负责图片解码和绘制;
  • VideoPlayerController 负责视频播放器状态;
  • CameraController 负责相机预览、拍照和录像。

“文件选择成功”不代表“文件一定能被图片组件或视频组件解码”。扩展名和 MIME 类型都不能完全保证内容格式正确。

3. 文件来源决定数据形态

常见来源及其特点如下:

来源 常见 API 典型结果 主要风险
系统文件选择器 file_picker 路径、字节或流 Web 没有本地路径;大文件不应直接读入内存
相册 image_picker XFile Android 进程被回收;需要处理丢失数据
相机 cameraimage_picker XFile 或控制器生成的文件 权限、后台恢复、控制器释放
应用缓存 path_provider 路径 临时文件可能被系统删除
应用文档目录 path_provider 路径 适合长期保存,但需要自己清理
远程 URL Image.networkVideoPlayerController.networkUrl URL 网络、缓存、鉴权和证书问题

XFile 是 Flutter 插件生态中常见的跨平台文件抽象。它可以提供文件名、长度、字节读取和流式读取能力,但不能假设它总是有可用路径


二、文件选择:路径、字节和流的取舍

1. 使用 file_picker 选择任意文件

pubspec.yaml 中加入依赖。版本号应根据项目当前稳定版本选择,不要盲目复制旧文章中的版本:

dependencies:
  file_picker: any
  http: any

实际项目应使用 flutter pub add file_picker http,由 Pub 解析兼容版本,并在提交代码时锁定 pubspec.lock

下面的示例允许用户选择图片或视频,并同时取得字节:

import 'dart:typed_data';

import 'package:file_picker/file_picker.dart';

class SelectedFile {
  const SelectedFile({
    required this.name,
    required this.bytes,
    required this.size,
  });

  final String name;
  final Uint8List bytes;
  final int size;
}

Future<SelectedFile?> pickMediaIntoMemory() async {
  final result = await FilePicker.platform.pickFiles(
    type: FileType.custom,
    allowedExtensions: <String>[
      'jpg',
      'jpeg',
      'png',
      'webp',
      'heic',
      'mp4',
      'mov',
      'webm',
    ],
    withData: true,
  );

  if (result == null || result.files.isEmpty) {
    return null; // 用户取消选择
  }

  final file = result.files.single;
  final bytes = file.bytes;

  if (bytes == null) {
    throw StateError('当前平台没有返回文件字节');
  }

  return SelectedFile(
    name: file.name,
    bytes: bytes,
    size: file.size,
  );
}

这里有三个重要事实:

  1. result == null 通常表示用户取消,不应当当作系统错误。
  2. PlatformFile.bytes 只有在请求了 withData: true 且插件能提供字节时才可靠。
  3. PlatformFile.path 在桌面和移动端通常可用,但在 Web 上不能作为跨平台保证。

2. 读入内存的代价

如果文件大小为 SS 字节,应用至少需要为文件内容分配接近 SS 的内存;图片解码后还可能需要额外的像素缓冲区。

例如,一张压缩后的 8 MB JPEG:

  • 原始文件字节约 8 MB;
  • 解码为 4000 × 3000 的 RGBA 图像后约为:

4000×3000×4=480000004000 \times 3000 \times 4 = 48\,000\,000

也就是约 45.8 MiB,尚未计入框架和 GPU 缓冲。

所以,“文件只有 8 MB”不代表“展示它只需要 8 MB 内存”。

适合 withData: true 的情况:

  • 小头像;
  • 小型表单附件;
  • Web 文件上传;
  • 已经明确限制文件大小的场景。

不适合的情况:

  • 大视频;
  • 未知大小的用户文件;
  • 多文件批量选择;
  • 内存受限的移动设备。

3. 使用路径或流处理大文件

在支持 dart:io 的平台上,可以使用路径上传,避免先把整个文件读入 Dart 堆:

import 'dart:io';

import 'package:file_picker/file_picker.dart';

Future<File?> pickLocalFile() async {
  final result = await FilePicker.platform.pickFiles(
    type: FileType.any,
    withData: false,
  );

  if (result == null || result.files.single.path == null) {
    return null;
  }

  return File(result.files.single.path!);
}

但是这段代码不能用于 Web,因为 dart:io 不属于 Web 应用可用的通用 API。

跨平台组件应抽象数据来源,而不是强行抽象成 File

sealed class UploadSource {
  const UploadSource();
}

final class BytesSource extends UploadSource {
  const BytesSource({
    required this.bytes,
    required this.fileName,
  });

  final List<int> bytes;
  final String fileName;
}

final class PathSource extends UploadSource {
  const PathSource({
    required this.path,
    required this.fileName,
  });

  final String path;
  final String fileName;
}

调用方可以根据平台和文件大小决定使用哪一种表示。


三、图片:选择、预览、解码和压缩不是同一件事

1. 从相册或相机取得图片

image_picker 返回 XFile,适合“选择一张图片”或“拍一张照片”的简单场景:

import 'package:image_picker/image_picker.dart';

final ImagePicker picker = ImagePicker();

Future<XFile?> chooseImage() {
  return picker.pickImage(
    source: ImageSource.gallery,
    maxWidth: 2048,
    maxHeight: 2048,
    imageQuality: 85,
  );
}

参数含义:

  • source:相册或相机;
  • maxWidthmaxHeight:插件尝试限制输出尺寸;
  • imageQuality:通常影响 JPEG 等有损格式的质量。

这些参数是处理提示,不是服务端安全限制。客户端可能:

  • 不支持某些格式;
  • 保留原始元数据;
  • 在不同平台使用不同的编码器;
  • 因平台实现差异产生不同结果。

服务端仍然必须限制大小、检查真实内容并重新编码或扫描。

2. 图片预览的三种方式

使用字节

Image.memory(
  bytes,
  fit: BoxFit.cover,
  errorBuilder: (context, error, stackTrace) {
    return const Center(child: Text('图片无法解码'));
  },
)

适合 Web 或已经在内存中的文件。

使用本地路径

import 'dart:io';

Image.file(
  File(path),
  fit: BoxFit.cover,
  errorBuilder: (context, error, stackTrace) {
    return const Center(child: Text('本地图片不存在或无法解码'));
  },
)

只适合支持 dart:io 的平台。不要在包含 Web 编译目标的共享文件中无条件导入 dart:io

使用远程 URL

Image.network(
  imageUrl,
  fit: BoxFit.cover,
  errorBuilder: (context, error, stackTrace) {
    return const Center(child: Text('远程图片加载失败'));
  },
)

如果图片需要登录鉴权,不能假设简单的 Image.network 就能自动携带应用层 Token。应根据后端设计:

  • 使用带短期签名的 URL;
  • 使用自定义请求头并自行下载字节;
  • 或通过应用服务端做代理。

3. 图片方向和缩略图

手机照片常包含 EXIF 方向信息。某些显示组件会根据元数据旋转显示,但服务端或其他客户端可能不会。上传前如果需要统一处理,应在明确的图像处理流程中:

  1. 读取图片;
  2. 根据 EXIF 方向旋转;
  3. 缩放到服务端允许的尺寸;
  4. 删除不必要的元数据;
  5. 重新编码;
  6. 再上传。

“改变文件扩展名”不能完成格式转换。把 a.png 重命名为 a.jpg 不会把 PNG 编码成 JPEG。


四、视频:文件预览和视频播放是两个层次

1. 选择视频

import 'package:image_picker/image_picker.dart';

Future<XFile?> chooseVideo() {
  return ImagePicker().pickVideo(
    source: ImageSource.gallery,
    maxDuration: const Duration(minutes: 5),
  );
}

maxDuration 是选择或录制流程的限制提示,不能代替服务端限制。用户可以通过其他来源上传超出限制的文件。

2. 使用 video_player 播放远程视频

依赖:

dependencies:
  video_player: any

状态必须先初始化,再构建播放组件:

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

class RemoteVideoView extends StatefulWidget {
  const RemoteVideoView({
    super.key,
    required this.url,
  });

  final Uri url;

  @override
  State<RemoteVideoView> createState() => _RemoteVideoViewState();
}

class _RemoteVideoViewState extends State<RemoteVideoView> {
  late final VideoPlayerController _controller;
  late final Future<void> _initialized;

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

    _controller = VideoPlayerController.networkUrl(widget.url);
    _initialized = _controller.initialize();
  }

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

  @override
  Widget build(BuildContext context) {
    return FutureBuilder<void>(
      future: _initialized,
      builder: (context, snapshot) {
        if (snapshot.connectionState != ConnectionState.done) {
          return const Center(child: CircularProgressIndicator());
        }

        if (snapshot.hasError || !_controller.value.isInitialized) {
          return const Center(child: Text('视频初始化失败'));
        }

        return AspectRatio(
          aspectRatio: _controller.value.aspectRatio,
          child: Stack(
            alignment: Alignment.bottomCenter,
            children: <Widget>[
              VideoPlayer(_controller),
              IconButton(
                onPressed: () {
                  if (_controller.value.isPlaying) {
                    _controller.pause();
                  } else {
                    _controller.play();
                  }
                  setState(() {});
                },
                icon: Icon(
                  _controller.value.isPlaying
                      ? Icons.pause
                      : Icons.play_arrow,
                ),
              ),
            ],
          ),
        );
      },
    );
  }
}

初始化失败的原因可能包括:

  • URL 无效;
  • 网络不可用;
  • 服务端返回非视频内容;
  • 编码格式或容器不受平台解码器支持;
  • 需要鉴权但请求没有凭据;
  • Web 浏览器的自动播放策略阻止播放;
  • 视频流没有正确的 HTTP Range 支持。

VideoPlayerController.file 依赖本地文件能力,通常不适用于 Web。Web 应优先使用 URL、浏览器对象 URL 或插件提供的 Web 适配方式,并单独验证目标浏览器。

3. 播放器状态不是 Widget 状态

VideoPlayerController 内部有自己的状态:

  • 是否初始化;
  • 是否播放;
  • 当前播放位置;
  • 视频总时长;
  • 是否发生错误。

Widget 的 setState 只负责让界面重新构建,不会替代播放器控制器。控制器必须在 dispose 中释放,否则可能留下原生播放器资源、纹理或监听器。


五、相机:权限、预览、拍照和录像

1. 简单拍照与完整相机界面

如果只需要“点击按钮拍照”,image_pickerImageSource.camera 通常足够:

final photo = await ImagePicker().pickImage(
  source: ImageSource.camera,
);

如果需要:

  • 实时预览;
  • 切换前后摄像头;
  • 闪光灯;
  • 对焦;
  • 连续拍摄;
  • 拍照和录像控制;

则应使用 camera 插件。

2. 相机控制器的基本流程

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

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

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

class _CameraPageState extends State<CameraPage>
    with WidgetsBindingObserver {
  CameraController? _controller;
  List<CameraDescription> _cameras = const <CameraDescription>[];
  Future<void>? _initialization;
  String? _error;

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    _initialization = _initializeCamera();
  }

  Future<void> _initializeCamera() async {
    try {
      _cameras = await availableCameras();

      if (_cameras.isEmpty) {
        throw StateError('设备没有可用相机');
      }

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

      _controller = controller;
      await controller.initialize();

      if (!mounted) {
        await controller.dispose();
        return;
      }

      setState(() {});
    } catch (error) {
      if (!mounted) return;
      setState(() {
        _error = '$error';
      });
    }
  }

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

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

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

      if (!mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('照片已生成:${file.name}')),
      );
    } on CameraException catch (error) {
      if (!mounted) return;
      setState(() {
        _error = '${error.code}: ${error.description}';
      });
    }
  }

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

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

    if (state == AppLifecycleState.inactive) {
      controller.dispose();
      _controller = null;
    } else if (state == AppLifecycleState.resumed) {
      _initialization = _initializeCamera();
    }
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _controller?.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (_error != null) {
      return Center(child: Text(_error!));
    }

    final controller = _controller;

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

    return Stack(
      fit: StackFit.expand,
      children: <Widget>[
        CameraPreview(controller),
        Align(
          alignment: Alignment.bottomCenter,
          child: Padding(
            padding: const EdgeInsets.all(24),
            child: FloatingActionButton(
              onPressed: _takePicture,
              child: const Icon(Icons.camera_alt),
            ),
          ),
        ),
      ],
    );
  }
}

这个流程中,状态变化是:

未创建
  -> 创建 CameraController
  -> initialize 成功
  -> 可预览
  -> takePicture
  -> 得到 XFile
  -> 页面销毁或应用进入后台
  -> dispose

关键约束是:

  • CameraPreview 只能在控制器初始化成功后构建;
  • takePicture 期间不能重复调用;
  • 异步操作返回后必须检查 mounted
  • 相机控制器是原生资源,不应只依赖 Dart 垃圾回收;
  • 进入后台后,相机可能被系统或其他应用占用,恢复时需要重新初始化。

camera 插件的具体平台支持、生命周期行为和配置项会随插件版本变化。应以当前版本的 API 文档和示例为准;不能假设所有桌面平台都有同等相机实现。

3. 录像

录像流程和拍照类似,但资源占用更高:

Future<XFile?> recordShortVideo(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();
}

这段示例的预期结果是得到一个 XFile,但它不适合作为真实交互界面,因为固定延迟会阻塞业务控制逻辑。生产界面应将“开始录像”和“停止录像”绑定到两个状态转换:

空闲 -> 录像中 -> 停止中 -> 得到视频文件

如果应用在“录像中”进入后台,应明确选择:

  • 停止并保存当前视频;
  • 停止并删除临时视频;
  • 暂停(如果平台和插件支持,并且业务允许)。

不能把后台恢复后的控制器当作仍然可用的对象。


六、应用生命周期和页面生命周期

1. 两种生命周期不是一回事

页面生命周期描述 Widget 是否仍在 Widget 树中:

  • initState:State 创建;
  • didChangeDependencies:依赖变化;
  • build:构建界面;
  • dispose:State 永久移除。

应用生命周期描述整个 Flutter 应用与系统的关系:

  • resumed:通常可交互;
  • inactive:暂时不可交互,例如系统界面覆盖;
  • paused:通常已进入后台;
  • detached:引擎仍存在但不再附着到宿主视图;
  • 某些平台还可能出现插件或版本相关状态。

应用生命周期的具体回调序列不是所有平台完全一致。它不是一个可以假设“必然收到每一步”的事务协议。

2. 为什么媒体资源特别依赖生命周期

相机、麦克风、视频纹理和原生播放器都属于外部资源。应用进入后台时,系统可能:

  • 收回相机;
  • 停止麦克风;
  • 暂停视频纹理;
  • 释放 GPU 资源;
  • 终止应用进程;
  • 让相册选择器覆盖当前 Activity 或 ViewController。

因此,正确模型不是:

页面打开 -> 初始化一次 -> 永远使用

而是:

页面可见且应用可用 -> 持有资源
应用失去使用权 -> 释放或暂停资源
应用恢复 -> 重新验证并初始化
页面销毁 -> 必须释放资源

3. 异步操作与销毁的竞态

下面的代码存在常见错误:

Future<void> load() async {
  final result = await loadMedia();
  setState(() {
    media = result;
  });
}

如果用户在 await 期间离开页面,State 可能已经 dispose。此时调用 setState 会产生异常。

应改为:

Future<void> load() async {
  final result = await loadMedia();

  if (!mounted) {
    return;
  }

  setState(() {
    media = result;
  });
}

mounted 只能防止更新已销毁的 Widget,不能防止:

  • 文件已经被删除;
  • 相机权限在等待期间被撤销;
  • 网络请求已经超时;
  • 控制器已经被其他生命周期逻辑释放。

因此还需要检查具体资源状态和异常类型。

4. Android 相册选择器的丢失数据

Android 在启动外部 Activity,例如相册或文件选择器时,系统可能因为内存压力杀死 Flutter Activity。用户完成选择后,应用重新创建,原来的 Future 不一定还能正常返回。

image_picker 提供了 retrieveLostData() 机制。启动时应考虑恢复:

import 'package:image_picker/image_picker.dart';

Future<XFile?> recoverLostImage() async {
  final picker = ImagePicker();
  final response = await picker.retrieveLostData();

  if (response.isEmpty) {
    return null;
  }

  if (response.files != null && response.files!.isNotEmpty) {
    return response.files!.first;
  }

  if (response.exception != null) {
    throw response.exception!;
  }

  return null;
}

应用启动或相关页面初始化时,可以先恢复丢失结果,再让用户重新选择。恢复结果和新选择结果必须避免重复处理。


七、权限和平台配置

1. 权限不是 Dart 代码自动获得的

相机、麦克风、照片库和文件访问都可能需要宿主平台声明或运行时授权。

iOS

相机和麦克风通常需要在 Info.plist 中提供用途说明,例如:

<key>NSCameraUsageDescription</key>
<string>用于拍摄头像和视频</string>
<key>NSMicrophoneUsageDescription</key>
<string>用于录制视频声音</string>

如果业务需要访问照片库,还要根据插件和系统版本配置对应的照片权限说明。用途说明必须真实、清晰;缺少必要配置可能导致应用被系统终止,而不是返回一个普通 Dart 异常。

Android

相机和录音涉及 Android 权限声明与运行时授权。系统版本较新时,照片选择器可能采用系统 Photo Picker,而不是传统的完整媒体库权限。实际需要哪些权限取决于:

  • Android 版本;
  • 使用的插件;
  • 是否只选择用户主动指定的文件;
  • 是否需要持续扫描整个媒体库;
  • 是否录音。

不要仅凭旧项目的 AndroidManifest.xml 推断当前权限模型。构建后应检查合并后的 Manifest,并在真实设备上验证“首次拒绝、永久拒绝、撤销权限、系统设置重新授权”路径。

2. 桌面和 Web

桌面平台通常更接近文件系统模型:

  • 可获得路径;
  • 可以访问本地文件;
  • 权限模型由操作系统和插件决定;
  • 相机、视频硬件能力不一定与移动端一致。

Web 受浏览器安全模型限制:

  • 没有可泛化的本地路径;
  • 相机需要浏览器权限和安全上下文,通常要求 HTTPS 或本地开发环境;
  • 浏览器可能阻止自动播放;
  • 大文件直接 readAsBytes 会增加内存压力;
  • 跨域、Range 请求、CORS 和媒体编码都会影响播放。

因此应在需求中明确支持矩阵,而不是只写“Flutter 全平台支持”。


八、将文件上传到服务器

1. Multipart 上传的结构

最常见的文件上传格式是 multipart/form-data。它把一次 HTTP 请求分成多个 part:

--boundary
Content-Disposition: form-data; name="title"

头像
--boundary
Content-Disposition: form-data; name="file"; filename="avatar.jpg"
Content-Type: image/jpeg

<文件字节>
--boundary--

文件上传通常还需要:

  • Authorization
  • 业务字段;
  • 客户端生成的请求 ID;
  • 服务端返回的文件 ID;
  • 超时、取消和重试策略。

2. 使用字节上传,适合 Web 和小文件

import 'dart:typed_data';

import 'package:http/http.dart' as http;

Future<String> uploadBytes({
  required Uri endpoint,
  required Uint8List bytes,
  required String fileName,
  required String mimeType,
  required String accessToken,
}) async {
  final request = http.MultipartRequest('POST', endpoint)
    ..headers['Authorization'] = 'Bearer $accessToken'
    ..fields['purpose'] = 'profile-image'
    ..files.add(
      http.MultipartFile.fromBytes(
        'file',
        bytes,
        filename: fileName,
        contentType: _parseMediaType(mimeType),
      ),
    );

  final streamedResponse = await request.send();
  final response = await http.Response.fromStream(streamedResponse);

  if (response.statusCode < 200 || response.statusCode >= 300) {
    throw Exception(
      '上传失败:HTTP ${response.statusCode},响应:${response.body}',
    );
  }

  return response.body;
}

http.MediaType _parseMediaType(String value) {
  final parts = value.split('/');
  if (parts.length != 2) {
    return http.MediaType('application', 'octet-stream');
  }
  return http.MediaType(parts[0], parts[1]);
}

使用时:

final selected = await pickMediaIntoMemory();

if (selected != null) {
  final result = await uploadBytes(
    endpoint: Uri.parse('https://api.example.com/uploads'),
    bytes: selected.bytes,
    fileName: selected.name,
    mimeType: 'image/jpeg',
    accessToken: token,
  );
}

这段代码的输入是文件字节、文件名、声明的 MIME 类型和访问令牌;成功时返回服务端响应正文。实际服务端可能返回 JSON,应使用 dart:convert 解码并检查业务错误码。

fromBytes 的限制是:整个文件已经在内存中。视频或大附件应采用路径、流式请求或分块上传。

3. 不要信任客户端传来的 MIME 类型

客户端传来的 Content-Type 只是声明,不是证明。攻击者可以把任意内容声明为 image/jpeg

服务端应至少执行:

  1. 限制请求体和单文件大小;
  2. 检查文件魔数或使用可靠解码器;
  3. 验证实际格式与允许列表;
  4. 对图片重新编码,避免保留危险内容;
  5. 对视频执行媒体探测和转码;
  6. 将上传文件保存到不可执行目录;
  7. 不使用用户原始文件名作为唯一存储键;
  8. 下载时设置正确的 Content-TypeContent-Disposition
  9. 进行病毒或恶意内容扫描,具体取决于业务风险。

例如,文件名 ../../app.dart 不应影响服务端存储路径。更安全的存储键通常是随机 ID,原始名称只作为显示元数据。

4. Token、签名 URL 和大文件上传

不要把云存储长期 Secret、数据库密码或服务端私钥放进 Flutter 包中。Flutter 应用分发后,客户端中的字符串、配置和代码都可能被提取。

常见安全流程是:

Flutter 请求上传凭证
       |
       v
业务服务端验证用户并返回短期签名信息
       |
       v
Flutter 直接上传到对象存储
       |
       v
业务服务端确认上传结果并绑定业务记录

短期签名 URL 仍然需要:

  • 限制有效时间;
  • 限制对象路径;
  • 限制内容大小;
  • 限制允许的方法;
  • 服务端再次确认对象内容。

如果上传失败,客户端应区分:

  • 用户取消;
  • 网络不可达;
  • 请求超时;
  • 401/403 鉴权失败;
  • 413 文件过大;
  • 415 类型不支持;
  • 5xx 服务端暂时失败。

只有具有幂等条件的请求才适合自动重试。对于已生成对象但客户端未收到响应的情况,应使用上传 ID 或对象校验值查询服务端状态,避免盲目重复上传。


九、上传状态、取消和并发控制

文件上传不是一个单一的布尔值。至少需要区分:

sealed class UploadState {
  const UploadState();
}

final class UploadIdle extends UploadState {
  const UploadIdle();
}

final class Uploading extends UploadState {
  const Uploading(this.sent, this.total);

  final int sent;
  final int total;

  double get progress => total <= 0 ? 0 : sent / total;
}

final class UploadSucceeded extends UploadState {
  const UploadSucceeded(this.fileId);

  final String fileId;
}

final class UploadFailed extends UploadState {
  const UploadFailed(this.error);

  final Object error;
}

状态转换可以表示为:

Idle
  -> Uploading
  -> Succeeded

Idle
  -> Uploading
  -> Failed

Uploading
  -> Cancelled

至少要防止两个竞态:

  1. 用户快速点击上传按钮,产生多个相同请求;
  2. 页面销毁后,上传回调仍然更新界面。

简单的互斥保护:

bool _uploading = false;

Future<void> startUpload() async {
  if (_uploading) return;

  setState(() {
    _uploading = true;
  });

  try {
    await doUpload();
  } catch (error) {
    if (!mounted) return;
    // 展示错误
  } finally {
    if (!mounted) return;
    setState(() {
      _uploading = false;
    });
  }
}

这只能防止当前页面内的重复点击,不能解决跨页面、应用重启或服务端幂等问题。生产上传通常还要保存任务状态、支持取消,并让服务端以上传 ID 去重。


十、临时文件和持久化

相机、选择器和压缩工具经常生成临时文件。临时目录的含义是:

  • 应用可以短期使用;
  • 系统可能自动清理;
  • 不能作为用户永久数据的唯一位置。

如果文件必须在下次启动继续使用,应复制到应用文档目录或上传到服务端,并保存业务记录。

典型流程:

相机生成临时文件
   -> 校验文件存在和大小
   -> 复制到持久目录或开始上传
   -> 上传成功后删除临时副本
   -> 上传失败时根据重试策略保留或清理

清理不能只依赖页面 dispose。页面可能在上传完成前被销毁,也可能因为崩溃无法执行清理。应用启动时应扫描自己的临时目录,根据创建时间和任务状态清理孤儿文件。


十一、常见误解与诊断方法

误解一:拿到路径就能在所有平台使用

表现:Android 正常,Web 上 path == null 或编译失败。

原因:Web 文件由浏览器对象或字节表示,不提供通用本地路径。

诊断

debugPrint('name=${file.name}');
debugPrint('size=${file.size}');
debugPrint('path=${file.path}');
debugPrint('hasBytes=${file.bytes != null}');

解决方式是把上传接口抽象为字节、流或平台路径,而不是强制使用 File

误解二:扩展名就是文件类型

表现:服务端接受 .jpg,但图片解码失败或安全扫描发现异常内容。

原因:扩展名和 MIME 都可由客户端伪造。

解决方式:服务端读取内容、使用解码器验证,并根据需要重新编码。

误解三:dispose 只是释放 Dart 对象

表现:返回相机页面后再次进入,初始化失败;视频纹理泄漏;Android 报相机被占用。

原因:控制器背后持有原生资源。Dart 对象被回收不等于原生资源及时释放。

解决方式

  • dispose 中释放控制器;
  • 应用进入后台时释放或暂停;
  • 恢复时重新初始化;
  • 不复用已经 dispose 的控制器。

误解四:await 返回后页面一定还存在

表现:选择器、相机初始化或上传结束后出现 setState() called after dispose()

原因:异步操作期间用户已经离开页面或路由被替换。

解决方式:在每个可能跨越异步边界的 UI 更新前检查 mounted

误解五:客户端设置 maxWidth 就完成了安全限制

表现:服务端仍收到超大文件或异常尺寸图片。

原因:客户端参数可以被绕过,且插件实现可能因平台不同而不同。

解决方式:服务端再次限制大小、像素总数、解码时间和实际格式。

误解六:视频能在一个平台播放就能在所有平台播放

表现:Android 播放正常,iOS、Safari 或桌面浏览器失败。

原因:底层解码器、容器、编码、音视频轨道和浏览器策略不同。

诊断

  • 检查 HTTP 状态码和 Content-Type
  • 检查是否支持 Range 请求;
  • 使用媒体探测工具确认容器和编码;
  • 在目标真实设备和浏览器测试;
  • 检查浏览器控制台的 CORS 和自动播放错误。

十二、一个完整的跨平台媒体处理原则

可以把媒体功能建模为四个独立阶段:

获取 Source
   |
   v
验证 Metadata 和实际内容
   |
   v
预览、压缩或转码
   |
   v
上传并持久化业务状态

其中 Source 不能只定义为本地路径,而应允许:

BytesSource   -> 适合 Web 和小文件
PathSource    -> 适合移动端、桌面端的大文件
StreamSource  -> 适合持续读取和大文件传输
RemoteSource  -> 适合已上传媒体的再次展示

当应用进入后台或页面销毁时,再加入生命周期约束:

资源状态 = 持有
应用不可用 -> 释放或暂停
应用恢复   -> 重新检查并初始化
页面销毁   -> 释放所有控制器和监听器

最终,一个可靠的 Flutter 文件与媒体实现应满足以下因果关系:

  • 文件选择器负责取得用户选择,不负责保证内容安全;
  • 图片组件负责解码展示,不负责完成上传;
  • 视频播放器负责播放状态,不负责管理业务生命周期;
  • 相机控制器负责访问原生相机,不负责替应用永久保存文件;
  • 客户端负责良好交互和初步校验,服务端负责最终信任边界;
  • 路径、字节和流是不同的数据表示,不能互相假定;
  • 生命周期回调不是附加功能,而是相机、视频和异步上传正确性的组成部分。

只要把来源、表示、资源状态、生命周期和服务端边界分别建模,文件选择、图片预览、视频播放、相机拍摄和上传就不会被错误地压缩成一个“选文件并上传”的函数。


系列导航与关联阅读

官方资料

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