Flutter 基础体系 · 第 15/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 文件与媒体:选择、上传、图片、视频、相机和生命周期
文件与媒体功能通常被误认为是“调用一个插件,然后把路径传给服务器”。实际上,它包含至少五个不同问题:
- 来源:文件来自系统文件选择器、相册、相机,还是应用沙盒。
- 表示:数据是路径、字节、流,还是一个尚未读取的媒体对象。
- 展示:图片和视频分别需要不同的解码与播放组件。
- 传输:上传要处理大小、类型、进度、取消、重试和服务端校验。
- 生命周期:应用进入后台、相机被系统回收、页面销毁时,控制器和临时文件如何处理。
这些问题在 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 进程被回收;需要处理丢失数据 |
| 相机 | camera、image_picker |
XFile 或控制器生成的文件 |
权限、后台恢复、控制器释放 |
| 应用缓存 | path_provider |
路径 | 临时文件可能被系统删除 |
| 应用文档目录 | path_provider |
路径 | 适合长期保存,但需要自己清理 |
| 远程 URL | Image.network、VideoPlayerController.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,
);
}
这里有三个重要事实:
result == null通常表示用户取消,不应当当作系统错误。PlatformFile.bytes只有在请求了withData: true且插件能提供字节时才可靠。PlatformFile.path在桌面和移动端通常可用,但在 Web 上不能作为跨平台保证。
2. 读入内存的代价
如果文件大小为 字节,应用至少需要为文件内容分配接近 的内存;图片解码后还可能需要额外的像素缓冲区。
例如,一张压缩后的 8 MB JPEG:
- 原始文件字节约 8 MB;
- 解码为 4000 × 3000 的 RGBA 图像后约为:
也就是约 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:相册或相机;maxWidth、maxHeight:插件尝试限制输出尺寸;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 方向信息。某些显示组件会根据元数据旋转显示,但服务端或其他客户端可能不会。上传前如果需要统一处理,应在明确的图像处理流程中:
- 读取图片;
- 根据 EXIF 方向旋转;
- 缩放到服务端允许的尺寸;
- 删除不必要的元数据;
- 重新编码;
- 再上传。
“改变文件扩展名”不能完成格式转换。把 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_picker 的 ImageSource.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。
服务端应至少执行:
- 限制请求体和单文件大小;
- 检查文件魔数或使用可靠解码器;
- 验证实际格式与允许列表;
- 对图片重新编码,避免保留危险内容;
- 对视频执行媒体探测和转码;
- 将上传文件保存到不可执行目录;
- 不使用用户原始文件名作为唯一存储键;
- 下载时设置正确的
Content-Type和Content-Disposition; - 进行病毒或恶意内容扫描,具体取决于业务风险。
例如,文件名 ../../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
至少要防止两个竞态:
- 用户快速点击上传按钮,产生多个相同请求;
- 页面销毁后,上传回调仍然更新界面。
简单的互斥保护:
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 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 平台集成:Plugin、Platform Channel、原生生命周期和权限
- 下一篇:Flutter 测试体系:Unit、Widget、Golden、Integration 和 Mock 边界
- 延伸:Flutter 应用安全:Secret、网络、存储、WebView、证书和供应链
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论