Flutter视频分片上传实战:从相册选择到断点续传的完整链路
发布时间:2026/10/17 16:30:12 锦皓数字建站

1. 从相册到服务端视频分片上传到底在解决什么问题做过移动端视频类功能的人大概都有过这种体验用户从相册里挑了一个两分钟的视频点击上传进度条卡在百分之几一动不动过一会儿直接报超时。换成小视频测试又一切正常。这个问题我第一次遇到的时候排查了大半天最后发现根因很简单——一次性把整个视频文件读进内存再发出去文件稍微大一点内存直接爆掉网络稍微抖一下整个请求就废了。视频分片上传要解决的就是这个场景。它的核心思路是把一个大视频文件切成若干个小块逐块上传最后在服务端把这些块拼回一个完整文件。这样做的好处很直接单次请求体积小内存占用可控某一块传失败了只需要重传那一块不用从头再来。对于移动端这种网络环境不稳定、内存又相对紧张的场景分片几乎是绕不开的方案。这篇文章围绕 Flutter 环境下视频选择之后并分片上传这条完整链路展开。我会从视频选择开始讲到读取文件、切片、逐片上传、断点续传、进度反馈再到服务端拼接的配合方式把中间那些文档里不会写、但实际开发一定会踩的坑都摊开说。适合正在做 Flutter 视频上传功能、或者已经写了一版但总出问题的开发者参考。不管你是刚接触 Flutter 的新手还是做过文件上传但没处理过大视频的老手应该都能从里面找到能直接抄的代码和能避开的坑。需要先说明一点分片上传不是银弹。它带来的是实现复杂度的上升服务端要配合做分片接收和合并客户端要管理分片状态和重试逻辑。所以如果你的场景里视频都很小比如几十秒的短视频一次性上传反而更简单可靠。判断标准我一般看两条文件是否经常超过 10MB以及网络环境是否可控。两条都踩中就上分片。2. 视频选择环节别小看这一步的坑2.1 选哪个插件为什么Flutter 里做视频/图片选择绕不开的就是那几个主流插件。我实际项目里用得最多的是image_picker它同时支持图片和视频API 简单iOS 和 Android 都能跑。另一个常见的是file_picker它的优势是能拿到更原始的文件信息比如文件路径、大小、扩展名对分片上传来说这些信息很关键。我的选型建议是这样如果只是从相册选视频image_picker足够它的pickVideo方法直接返回一个XFile。但如果你需要更精细地控制比如限制文件大小、拿到准确的字节数、或者支持从文件管理器选任意文件那file_picker更合适。实际项目里我经常两个都用——image_picker负责相册体验file_picker负责兜底。这里有个容易忽略的点image_picker返回的XFile在 iOS 上可能是一个临时路径系统随时可能清理。所以选完视频之后第一件事应该是把文件读出来或者复制到一个稳定目录而不是拿着路径到处传。我踩过一次坑用户选完视频后切到后台待了一会儿回来上传时路径已经失效直接报文件不存在。// 使用 image_picker 选择视频 final ImagePicker picker ImagePicker(); final XFile? video await picker.pickVideo( source: ImageSource.gallery, maxDuration: const Duration(minutes: 5), // 限制时长避免超大文件 ); if (video null) return; // 关键立刻读取字节或复制到稳定目录 final bytes await video.readAsBytes(); final fileSize await video.length();2.2 拿到视频之后必须确认的三件事选完视频别急着切片。有三件事必须先确认清楚否则后面全是坑。第一是文件真实大小。XFile.length()返回的是字节数这个值决定了你要切多少片。我一般按每片 1MB 到 2MB 来切太小了请求次数多、开销大太大了又失去了分片的意义。假设视频是 50MB按 2MB 一片就是 25 片这个量级比较合理。第二是文件是否可读。有些视频格式或者某些系统权限下文件能选中但读不出来。稳妥的做法是先尝试读第一块能读出来再继续。第三是视频时长和格式。时长影响用户体验太长的视频要提示用户格式影响服务端处理。虽然分片上传本身不关心格式但服务端合并后往往要做转码提前知道格式能省很多事。检查项方法不检查的后果文件大小XFile.length()分片数量算错最后一片越界可读性试读首块上传到一半才发现读不了时长格式插件返回的元数据服务端转码失败用户白等提示iOS 上从相册选的视频系统可能会做一次转码比如 HEVC 转 H.264这个过程是异步的。如果你选完立刻读文件可能读到的是转码前的临时文件。稳妥做法是选完后稍等或监听文件变化或者直接用file_picker拿原始文件。3. 分片策略切多大、怎么切、切完怎么记3.1 分片大小的取舍逻辑分片大小这个参数看起来随便定一个就行实际上它直接影响上传的成功率和速度。我试过从 256KB 到 10MB 的各种大小最后稳定在 1MB 到 2MB 之间。为什么是这个区间往小了说分片太小会导致请求数量暴增。一个 100MB 的视频按 256KB 切就是 400 片每片一个 HTTP 请求光是建立连接的开销就够呛而且服务端要维护 400 个临时文件合并时压力也大。往大了说分片太大就失去了意义一片 10MB 在弱网下照样容易超时重传成本也高。1MB 到 2MB 的好处是单次请求体小弱网下成功率高请求数量适中100MB 视频也就 50 到 100 片服务端合并时临时文件数量可控。当然这个值不是死的如果你的用户网络普遍很好可以适当放大到 4MB如果经常在弱网环境用就缩到 512KB。还有一个细节最后一片往往不满。比如 50MB 按 2MB 切正好 25 片但 51MB 就是 25 片满的加一片 1MB 的。切片逻辑必须能正确处理这个尾巴否则最后一片会越界或者丢数据。3.2 切片的实现方式Flutter 里切片本质就是把字节数组按固定长度切开。有两种做法一种是一次性把整个文件读进内存再切另一种是分块读取。前者简单但吃内存后者省内存但代码稍复杂。对于视频这种可能几十上百 MB 的文件我强烈建议用分块读取的方式也就是每次只读一片的大小处理完再读下一片。Dart 的RandomAccessFile支持按位置读取非常适合这个场景。Futurevoid uploadInChunks(XFile video, int chunkSize) async { final file File(video.path); final totalSize await file.length(); final totalChunks (totalSize / chunkSize).ceil(); final raf await file.open(); try { for (int i 0; i totalChunks; i) { final start i * chunkSize; final end (start chunkSize totalSize) ? totalSize : start chunkSize; final length end - start; await raf.setPosition(start); final chunk await raf.read(length); // 上传这一片 await uploadChunk(chunk, i, totalChunks); } } finally { await raf.close(); } }这段代码的关键在于read(length)每次只读一片的量内存里始终只有一片的数据。setPosition负责定位保证顺序正确。totalChunks用ceil是为了处理最后一片不满的情况。3.3 分片元数据怎么组织每一片上传时除了数据本身还得带上足够的元信息服务端才知道这片是谁、排第几、总共多少片。我一般会带这几个字段fileId整个上传任务的唯一标识客户端生成服务端用它来归集分片chunkIndex当前是第几片从 0 开始totalChunks总片数fileName原始文件名合并时用fileSize原始文件总大小用于校验这些信息放在请求的 form 字段或者 header 里都行。我习惯放 form 字段因为和文件数据一起提交服务端解析方便。fileId的生成要注意不能重复我一般用时间戳加随机数或者用 UUID。注意fileId一定要在开始上传前生成好并固定下来整个上传过程所有分片用同一个。如果每片都重新生成服务端根本没法把它们归到一起。4. 上传实现并发、重试、进度一个都不能少4.1 串行还是并发这是个问题分片上传最直观的实现是串行——一片传完再传下一片。简单、可控、进度好算。但缺点是慢尤其是分片多的时候每片都要等上一个的响应。并发上传能显著提速但同时传太多片又会把带宽占满反而导致每片都慢还容易触发服务端的限流。我的经验是并发数控制在 3 到 5 之间比较合适。这个量级既能利用带宽又不会把连接数打满。实现并发可以用Future.wait配合分批或者用信号量控制并发数。Dart 里没有内置的信号量但可以用Pool或者自己用Completer实现。我一般用分批的方式简单直接const concurrency 4; for (int i 0; i totalChunks; i concurrency) { final batch Future[]; for (int j i; j i concurrency j totalChunks; j) { batch.add(uploadChunkWithRetry(j)); } await Future.wait(batch); }这样每批最多 4 片同时传一批传完再传下一批。进度统计也方便每批完成更新一次。4.2 重试机制失败是常态不是意外移动端网络失败是常态。地铁里、电梯里、信号切换时请求失败太正常了。所以重试机制不是可选项是必选项。我的重试策略一般是每片最多重试 3 次每次重试间隔递增比如 1 秒、2 秒、4 秒避免在服务端已经压力很大时还疯狂重试。重试只针对网络类错误如果是服务端返回的业务错误比如文件格式不对重试也没用直接失败。Futurevoid uploadChunkWithRetry(int index, {int maxRetry 3}) async { int attempt 0; while (attempt maxRetry) { try { await uploadChunk(index); return; } catch (e) { attempt; if (attempt maxRetry) rethrow; await Future.delayed(Duration(seconds: 1 attempt)); // 1s, 2s, 4s } } }这里1 attempt是位移运算得到 2 的 attempt 次方实现指数退避。这个细节很重要固定间隔重试在服务端抖动时容易雪上加霜。4.3 进度反馈用户要的是确定性上传大文件用户最怕的是不知道还要多久。所以进度反馈必须做而且要做得准。进度有两种算法按分片数算和按字节数算。按分片数简单已完成片数除以总片数。但问题是每片大小可能不一样最后一片通常小按片数算的进度会跳。按字节数算更准已完成字节数除以总字节数。我一般用字节数算因为体验更好。维护一个已完成字节数的变量每片上传成功后累加这片的大小然后除以总大小得到进度。int uploadedBytes 0; final totalBytes await file.length(); // 每片成功后 uploadedBytes chunk.length; final progress uploadedBytes / totalBytes; // 通知 UI 更新进度更新频率也要注意。如果每片都触发一次 UI 刷新分片多的时候会频繁重建影响性能。我一般做节流比如进度变化超过 1% 才通知一次或者用ValueNotifier配合ValueListenableBuilder做局部刷新。提示进度到 100% 不代表上传完成。所有分片传完后还要等服务端合并。所以进度条最好分两段上传进度和合并进度。合并阶段可以显示正在处理避免用户以为卡住了。5. 断点续传让用户不用从头再来5.1 断点续传的核心思路断点续传的价值在于用户传了一半退出了、网络断了、App 崩了重新进来能接着传而不是从零开始。对于大视频这个体验差异是巨大的。实现断点续传的核心是记录已上传的分片。客户端在本地持久化一个上传任务的状态包括fileId、已成功的分片索引列表、文件路径等。下次进来先查这个状态跳过已成功的分片。服务端也要配合提供一个查询接口客户端传fileId过去服务端返回这个任务已经收到哪些分片。这样即使客户端本地状态丢了也能从服务端恢复。我一般两个都做本地存一份做快速恢复服务端查一份做兜底。本地状态用shared_preferences或者sqflite存简单场景用前者就够。5.2 状态持久化的时机状态什么时候存我的做法是每片上传成功后立刻存。不要攒着一起存因为崩溃往往发生在你没想到的时候。存的内容就是已成功的分片索引追加进去。Futurevoid markChunkUploaded(String fileId, int index) async { final prefs await SharedPreferences.getInstance(); final key upload_$fileId; final uploaded prefs.getStringList(key) ?? []; if (!uploaded.contains(index.toString())) { uploaded.add(index.toString()); await prefs.setStringList(key, uploaded); } }这里用StringList存索引简单够用。如果分片特别多比如上千片可以考虑用位图或者更紧凑的结构但一般视频分片也就几十上百字符串列表完全没问题。5.3 恢复时的校验恢复上传前一定要做校验。校验什么校验文件还在不在、大小变没变。用户可能把原视频删了或者换了个同名但内容不同的文件。如果不校验直接续传最后合并出来的文件是坏的。校验方式很简单比对文件大小和记录的原始大小。如果对不上就放弃续传重新开始。如果文件路径失效也要重新选文件。Futurebool canResume(String fileId, String path, int expectedSize) async { final file File(path); if (!await file.exists()) return false; final size await file.length(); return size expectedSize; }这个校验看起来简单但能避免很多传完了文件打不开的诡异问题。6. 服务端配合分片接收与合并的关键点6.1 分片接收接口的设计客户端分片传过来了服务端得接住。接口设计上我一般用一个统一的接收端点通过参数区分不同分片。接收逻辑是根据fileId建一个临时目录把每片按索引命名存进去。# 伪代码示意 def receive_chunk(file_id, chunk_index, chunk_data): temp_dir f/tmp/uploads/{file_id} os.makedirs(temp_dir, exist_okTrue) with open(f{temp_dir}/{chunk_index}.part, wb) as f: f.write(chunk_data) return {status: ok, index: chunk_index}这里有个关键点分片写入要保证原子性。如果写到一半服务端崩了会留下一个不完整的分片文件合并时就会出错。稳妥做法是先写临时文件写完再重命名重命名是原子操作。6.2 合并的触发与校验所有分片传完后客户端发一个合并请求带上fileId和totalChunks。服务端检查分片是否齐全齐全就按索引顺序拼接。合并前必须校验分片数量对不对、每片大小对不对、总大小对不对。我见过因为客户端算错总片数导致合并少一片的情况最后文件损坏。所以服务端不能完全信任客户端要自己校验一遍。def merge_chunks(file_id, total_chunks, expected_size): temp_dir f/tmp/uploads/{file_id} chunks sorted( [f for f in os.listdir(temp_dir) if f.endswith(.part)], keylambda x: int(x.split(.)[0]) ) if len(chunks) ! total_chunks: raise Exception(分片数量不匹配) output_path f/data/videos/{file_id}.mp4 with open(output_path, wb) as out: for chunk in chunks: with open(f{temp_dir}/{chunk}, rb) as f: out.write(f.read()) if os.path.getsize(output_path) ! expected_size: raise Exception(合并后大小不匹配) shutil.rmtree(temp_dir) # 清理临时文件 return output_path合并完成后清理临时目录避免磁盘堆积。这个清理很重要不然跑一段时间磁盘就满了。6.3 分片过期清理用户传了一半不传了临时分片就一直躺在服务器上。所以需要一个定时任务清理超过一定时间比如 24 小时没完成的上传任务。清理逻辑就是扫临时目录看最后修改时间超时的删掉。这个机制看起来不起眼但生产环境里非常必要。我见过因为没做清理临时目录堆了几十 G 垃圾文件的案例。7. 那些文档不会告诉你的实战坑7.1 内存与性能的隐形杀手分片上传最容易忽略的是内存。虽然我们按片读取但如果代码里不小心把整个文件读进来了内存照样爆。常见的错误是在切片前调用了readAsBytes()这一下就把整个文件加载进内存了。正确做法是用RandomAccessFile按需读取。另一个性能问题是频繁的 UI 刷新。进度更新如果每片都触发setState分片多的时候会卡。用ValueNotifier配合ValueListenableBuilder只刷新进度条那一小块性能好很多。还有一点上传过程中如果用户切到后台iOS 可能会暂停网络请求。需要在Info.plist里配置后台模式或者用后台任务插件。这个坑不踩一次很难想到。7.2 网络切换与超时设置移动端网络会在 WiFi 和蜂窝之间切换切换瞬间请求容易失败。重试机制能覆盖大部分情况但超时时间要设合理。太短了正常请求也被判超时太长了失败请求要等很久。我的经验是连接超时 10 秒读取超时 30 秒。分片上传的请求体不大30 秒足够传完一片。如果 30 秒还没传完多半是网络有问题早点失败早点重试反而更好。用dio的话可以这样配final dio Dio(BaseOptions( connectTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(seconds: 30), sendTimeout: const Duration(seconds: 30), ));7.3 服务端限流与客户端退让有些服务端会对上传接口做限流比如每秒最多几个请求。客户端并发太高就会触发限流返回 429。这时候客户端要能识别 429 并退让而不是傻傻地重试。我的做法是遇到 429 时降低并发数并增加重试间隔。如果连续多次 429就退化成串行上传。这样虽然慢但至少能传完。7.4 文件类型与安全校验服务端接收分片时不能只看客户端说的文件类型。客户端可以伪造。稳妥做法是合并完成后用文件头魔数校验真实类型。比如 MP4 文件头是ftyp如果对不上就拒绝。这个校验在安全敏感的场景很重要能防止用户上传伪装成视频的恶意文件。坑点表现解决方案整文件读入内存大视频 OOM 崩溃用 RandomAccessFile 按片读进度刷新过频上传时 UI 卡顿ValueNotifier 局部刷新后台被暂停切后台后上传中断配置后台模式或后台任务网络切换失败请求突然失败重试加指数退避服务端限流返回 429识别并降低并发文件类型伪造上传恶意文件服务端魔数校验8. 一套可复用的上传状态机设计把上面这些串起来其实整个上传流程就是一个状态机。我习惯把它抽象成几个状态idle空闲、preparing准备中读文件算分片、uploading上传中、merging合并中、success成功、failed失败、paused暂停。每个状态之间的转换都有明确的触发条件。比如preparing完成后进入uploading所有分片传完进入merging合并成功进入success。失败可以从uploading或merging进入failed暂停可以从uploading进入paused。用状态机的好处是逻辑清晰UI 可以根据状态显示不同的界面重试和恢复也有明确的入口。我一般用一个ChangeNotifier或者Bloc来管理这个状态UI 层只负责根据状态渲染。enum UploadStatus { idle, preparing, uploading, merging, success, failed, paused } class UploadController extends ChangeNotifier { UploadStatus status UploadStatus.idle; double progress 0; String? errorMessage; Futurevoid start(XFile video) async { status UploadStatus.preparing; notifyListeners(); // ... 准备逻辑 status UploadStatus.uploading; notifyListeners(); // ... 上传逻辑 } }这套设计我在几个项目里复用下来改动都不大主要是替换上传的具体实现。状态机把复杂度集中管理UI 层变得很薄维护起来轻松很多。最后分享一个我自己的习惯每次做上传功能我都会先用一个 100MB 左右的测试视频在弱网模拟器里跑一遍完整流程包括中途断网、切后台、杀进程重进。这几个场景能覆盖 90% 的线上问题。等这些都能稳定跑通再上真实环境基本就不会出大问题了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。