ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

鸿蒙Flutter大文件分片上传与断点续传实战:chunked_uploader适配全解析

鸿蒙Flutter大文件分片上传与断点续传实战:chunked_uploader适配全解析 那次适配的起因很直接公司内部一个鸿蒙平板上面出现了上传崩溃一个600多MB的压缩包传到70%左右直接卡死重试以后又从0开始。当时第一反应是换一个更稳的传输方案但市面上常见的分片上传插件要么依赖特定平台实现要么在鸿蒙上根本没人验证过。最后我选择基于Flutter的三方库chunked_uploader做鸿蒙适配把大文件分片传输和断点续传彻底跑通才把这个坑填上。本文不是翻译官方文档而是把我在鸿蒙设备上实际适配chunked_uploader时的完整思路、参数取舍、踩坑过程和验证方法写出来。如果你正在做Flutter应用往鸿蒙生态迁移或者单纯被大文件上传折磨过这篇文章应该能帮你省掉至少一周的试错时间。1. 为什么必须分片上传单请求方案的根本死穴先说清楚一个容易被忽视的问题很多团队觉得上传失败重试不就行了但在大文件场景下单请求方案有四个根本死穴这不是重试能解决的。1.1 从一次上传卡死说起我们当时的场景是平板通过4G网络上传一个离线资源包600MB左右。单请求直传服务端用的是一般的反向代理。前几次测试数据都还可以后来在电梯、地库这类信号波动区域测试问题全暴露了。第一个问题是连接超时。600MB在普通4G下要传几分钟一旦TCP层出现长时间阻塞服务端网关基本都会在30秒到60秒断开连接。客户端收到的是连接重置但进程内缓存的数据又得重新读。第二个问题是内存压力。用Dart侧一次性把这个文件读进Uint8List再交给HTTP层是不现实的600MB直接把内存打爆。通常的方案是用流式请求但流式请求一旦中断流的接收端没法知道你已经传了多少数据。第三个问题是服务端临时文件管理。单请求上传过程中服务端必须落一个临时文件连接断开以后这个临时文件既不能删除怕用户其实还在传也不能一直保留占磁盘。多数团队选择半小时自动清理于是用户的进度就跟着临文件一起没了。第四个问题才是不上分片的最致命原因没有可恢复的进度上下文。断点续传的前提是你和服务端都明确知道哪个文件的哪一段已经完整到达。单请求模型里这个信息只有在连接断开的瞬间才知道而且往往已经迟了因为很多数据还在内核缓冲区里没刷出去。所以分片上传不是锦上添花而是大文件传输的必选项。chunked_uploader做的事情就是把文件切成多个可独立校验的分片每个分片都能单独重试服务端按顺序组装。这从根本上规避了上面四个问题。1.2 分片传输需要确认的三个核心指标在动手适配之前我建议你先回答三个问题这三个指标直接决定你选哪个参数。第一个是目标文件的大小分布。如果你的文件普遍在100MB以内分片大小可以设小一点比如2MB这样重试的成本低。如果经常超过500MB分片建议5MB起步否则分片数量太多服务端处理和客户端队列都会带来额外开销。第二个是服务端接口是否支持分片语义。一个普通的POST /upload接口是不能直接用chunked_uploader的因为它需要一个初始化接口创建上传会话拿到fileId一个分片上传接口接收fileId index chunk一个完成接口通知服务端所有分片已就绪可以做合并如果你的服务端只支持单请求整包上传那之前的所有讨论都不成立必须先改服务端。第三个是网络环境的上限带宽。我始终建议把分片大小设定成在目标网络环境下约2到5秒能传完的数据量。比如你的目标用户大多在4G网络上行带宽约有1MB每秒那分片大小2MB-5MB是合理的。设太大会导致单分片超时概率上升设太小则HTTP请求数量暴增效率下降。这几个问题在鸿蒙适配时也一样成立因为底层网络能力没有变变的只是接入方式和平台权限。2. 把 chunked_uploader 拉进鸿蒙工程依赖、权限与验证这个库本身是用纯Dart写的核心逻辑不依赖Android/iOS原生代码。理论上只要Flutter引擎能在鸿蒙上跑它就能用。但实际操作中还是会遇到工程接入的问题下面按我的操作顺序讲。2.1 添加依赖与版本锁定细节先把依赖加进pubspec.yamldependencies: flutter: sdk: flutter chunked_uploader: ^0.1.2 dio: ^5.3.2这里有个容易踩的细节chunked_uploader的早期版本内部依赖http包如果你的项目里同时有dio两个HTTP库共存问题不大但如果你自己也用了http要注意版本冲突。我遇到过因为http: ^1.0.0传递依赖导致编译期类型不匹配的问题后来统一用dependency_overrides锁定dependency_overrides: http: 1.1.0还有一个更隐蔽的点chunked_uploader的API设计中有一些回调参数比如onProgress在不同版本里签名会有变化。建议先跑一个最小用例确认你锁定的版本与项目里其它依赖兼容再继续做功能改造。鸿蒙侧Flutter工程入口不需要额外引入插件因为chunked_uploader不是platform channel插件它没有原生的Android/iOS实现。这一点很关键你不需要为它写鸿蒙端的原生代码只需要保证宿主应用能正常加载Flutter引擎。2.2 鸿蒙原生侧的网络与文件权限配置虽然是纯Dart但鸿蒙的安全模型不会因为你是Dart代码就网开一面。在鸿蒙的module.json5里必须显式声明网络权限{ module: { requestPermissions: [ { name: ohos.permission.INTERNET } ] } }另外如果你要读取的大文件来自用户选择的媒体文件或沙箱外路径需要按照鸿蒙的存储权限规则处理。常见的做法是让用户通过系统文件选择器拿到文件URI再把URI传给Flutter侧。这里说一个我踩过的坑不要在鸿蒙Flutter侧直接用dart:io的File(/storage/emulated/0/...)去读外部存储鸿蒙的沙箱规则与Android不同这条路走不通。正确路径是先通过鸿蒙原生侧把文件拷贝到应用的沙箱目录再在Dart侧用这个沙箱路径。2.3 快速验证分片逻辑真的跑在鸿蒙引擎上接完依赖之后不要急着写完整的上传流程。先用一个最小用例验证两端Dart侧与鸿蒙原生侧能正常协同import package:chunked_uploader/chunked_uploader.dart; import package:http/http.dart as http; final client ChunkedUploader( headers: {Authorization: Bearer test-token}, chunkSize: 1024 * 1024, // 1MB retries: 2, ); final uploader client.uploadFromFile( filePath: /data/storage/el2/base/haps/entry/files/test.bin, endpoint: https://your-server.com/api/upload/chunk, fieldName: file, onProgress: (received, total) { print(${received / total}); }, ); await uploader.upload();跑通之后再用一个10MB的测试文件做完整校验确认分片数量正确、服务端能拼出原文件。到这一步分片上传在鸿蒙上能跑这件事才算确认。3. 分片参数与Endpoint设计不是越大越快也不是越小越稳chunked_uploader给了你几个旋钮chunkSize、retries、uploadTimeout、headers。很多人一上来就随手填参数结果上线后被现实教育。我把自己调参时反复验证过的规律写下来。3.1 分片大小与并发的关系我前面提到分片大小要按2到5秒能传完来定还有一个原则是单个分片不要超过内存预算的十分之一。这个库在上传分片时会把分片数据读进内存如果你在手机这种内存受限设备上设了一个128MB的分片内存当场就能涨上去。文件大小建议分片大小分片数量说明100MB以下1MB-2MB50-100重试成本低进度更新更平滑100MB-500MB2MB-5MB50-250平衡请求数量与单片耗时500MB以上5MB-10MB50-200减少服务端合并压力我在项目里用的是5MB分片实测在4G网络下单分片耗时约4秒这个节奏比较舒服。另外并发数也是一个被忽略的点。chunked_uploader库本身是串行上传分片的一个失败会重试但不会同时传多片。如果你希望利用宽带上限可以基于它做一层并发封装但要注意服务端对分片乱序的处理能力。很多服务端合并逻辑是依赖分片索引的有序到达的乱序会导致合并时频繁寻址。我建议前两版先保持串行跑通业务再考虑并发。3.2 Endpoint 对接方式与字段约定chunked_uploader的uploadFromFile只负责把文件切成块并按顺序POST到你给的endpoint。它不帮你处理分片会话的创建和完成确认。所以常规做法是你自己写三个服务端接口或者找到服务端已有的分片接口然后通过初始化接口拿到fileId。我封装了一层UploadSessionclass UploadSession { final String fileId; final int chunkSize; final int totalChunks; UploadSession({required this.fileId, required this.chunkSize, required this.totalChunks}); } FutureUploadSession initUploadSession(String fileName, int fileSize) async { final res await dio.post( /api/upload/init, data: {fileName: fileName, fileSize: fileSize}, ); final data res.data as MapString, dynamic; return UploadSession( fileId: data[fileId], chunkSize: data[chunkSize] ?? defaultChunkSize, totalChunks: (fileSize / chunkSize).ceil(), ); }然后每个分片请求带上fileId和chunkIndexFuturevoid uploadChunk(String fileId, int index, Listint bytes) async { final formData FormData.fromMap({ fileId: fileId, chunkIndex: index, chunk: MultipartFile.fromBytes(bytes, filename: chunk_$index), }); await dio.post(/api/upload/chunk, data: formData); }这里的一个重要约定是服务端必须做分片去重。同一片因为网络波动可能被客户端重试多次如果服务端每次都把数据追加到临时文件后面必然造成文件损坏。我给服务端的要求是收到带chunkIndex的请求时先看这个索引的分片是否已经完整写入如果是直接返回成功不重复写入。3.3 握手参数与服务端合并语义三个接口里最容易出问题的其实是完成接口的服务端合并逻辑。有的服务端要求客户端把每个分片的哈希都传过去有的则通过chunkIndex连续判断。建议在初始化接口的返回值里带上服务端接受的分片大小和期望总分片数客户端以服务端返回值作为准而不是自己写死。服务端在合并时的推荐做法是把所有临时分片按索引拼接后再对总文件做一次MD5校验并把校验值返回给客户端。有了这个值客户端才能在业务层面确信上传成功而不是单纯收到200就以为没事。4. 断点续传的正确打开方式重试不等于续传很多人把重试和断点续传混为一谈。重试只是让当前请求重新发起而断点续传要回答的问题是在进程可能已被杀掉、网络完全断开的前提下如何以最少的重复流量把没传完的部分传到服务端。4.1 本地状态记录的写入时机chunked_uploader本身不帮你记录进度你需要自己维护状态文件。我在项目里用一个独立的JSON文件记录{ fileId: a1b2c3..., fileName: update_package.bin, fileSize: 629145600, fileHash: md5-of-whole-file, chunkSize: 5242880, totalChunks: 120, uploadedChunks: [0, 1, 2, 5, 6] }uploadedChunks这个数组是整个断点续传的命脉。写入时机不是分片请求发出后而是分片请求成功返回后。如果请求发出你就标记成功那么当服务端实际没有写完时续传就会跳过这片的校验导致最终文件损坏。我开发时用了一个简单的规则只有收到服务端明确的成功响应才把索引写入状态文件。写入频率也不要太夸张。每传一片写一次文件在弱网下没问题但在高速网络下会频繁触发IO。我按10个分片批量写一次代价是进程突然被杀最多丢失10片的进度重传成本可接受。4.2 续传判定本地状态与服务端真实进度对齐断点续传最大的坑在于本地状态可能和服务端真实状态不一致。比如你上传到第50片后服务端临时文件被运维清理了但本地还认为已经传到50续传时会直接跳到51最后合并时发现文件缺了中间部分。解决思路是续传之前先向服务端查询一个确认边界。在初始化接口里加一个参数fileId服务端如果发现这个fileId已经存在就返回当前已连续接收到的最大索引。这里强调连续——中间如果有空洞即使后面的分片已上传也必须在空洞处续传。我封装了这么一段逻辑Futureint fetchUploadedOffset(String fileId) async { final res await dio.get(/api/upload/status, queryParameters: {fileId: fileId}); return res.data[continuousIndex] as int; }然后客户端只从continuousIndex 1开始传。这样即便本地状态被写入晚了、丢了也能用服务端数据对齐。4.3 文件变更检测与session清理还有一个特别隐蔽的问题用户可能在断点期间把源文件换掉了。本地记录的fileSize和fileHash如果跟当前文件不一致绝不能直接续传。我在每次续传前会重新计算目标文件的MD5对大文件来说这个计算本身会耗时但值得与状态文件里的fileHash比对不一致就丢弃整个session重新初始化。session清理策略上我的做法是上传成功后立即删除本地状态文件与服务端临时分片。状态文件超过7天未更新的视为僵尸session下次启动时清理。每次上传前如果发现同一个文件有多个历史session保留最新的删除旧的。5. 鸿蒙真机上踩到的4个坑排查链路记录这部分是我最想写的。适配过程里真正耗时间的不是写代码而是排查那些在鸿蒙上特有的、文档里根本没有的现象。5.1 网络权限导致的静默失败现象在鸿蒙模拟器上跑得好好的上真机后所有上传请求直接失败没有任何日志。我用http的拦截器打印请求发现请求根本没发出去。排查链路先怀疑是DNS问题换IP直连仍然失败。然后怀疑是代理设置关闭代理也失败。最后才想起去看module.json5发现网络权限只加在了debug目录下的模块配置里release目录下的module.json5没有加。鸿蒙工程的src/main/module.json5和src/debug/module.json5是分开的我只改了debug。这提醒我检查鸿蒙权限时要同时检查ohosTest、debug、release等所有构建变体下的配置。5.2 外部存储路径读取问题现象在Android上可以直接把用户选择的文件路径交给chunked_uploader在鸿蒙上却抛FileSystemException。排查链路先打印路径发现是file://media/...这种URI格式。鸿蒙的媒体库URI和普通文件路径不是一回事不能直接当作File路径打开。后来我改了流程选择文件后先用鸿蒙的FilePicker拿到URI通过原生侧把文件复制到应用沙箱目录再用沙箱路径上传。这个方案虽然多一次IO但是逻辑更可控。因为大文件上传本身就耗时拷贝的时间相对比例很小而且拷贝结束后还能顺便算出文件的MD5为断点续传提供校验。5.3 dart:io 的HttpClient在鸿蒙上的行为差异现象用chunked_uploader默认的HTTP客户端上传大分片时偶尔会出现连接被重置或者Content-Length不匹配。排查链路chunked_uploader内部默认用http包而http包默认走HttpClient。鸿蒙的Flutter引擎底层网络栈在实现上跟Android的OkHttp体系不一样。对大多数请求没影响但对大体积、长时间的分片上传TCP行为的细节差异会放大。我的解决方案是不再依赖chunked_uploader内部的默认客户端而是自定义一个基于dio的传输层把chunked_uploader用作分片与进度的管理壳把实际的HTTP发送交给dio。这样做的好处是dio的连接复用、超时配置、重试机制都比裸http包要精细。改造方案里我把每个分片包装成dio的MultipartFile通过dio的onSendProgress回调反馈进度整体的适配就顺畅很多。5.4 进程被杀后状态文件损坏现象用户在后台界面被系统回收后再次打开应用续传直接失败有时甚至抛JSON解析错误。排查链路查看状态文件内容发现最后几个字节是半截的显然是写文件过程中进程被杀把JSON写坏了。对策有两个一是写状态文件时先写临时文件再原子替换final tempPath $stateFile.tmp; final file File(tempPath); await file.writeAsString(jsonEncode(state)); await file.rename(stateFile);二是把已上传分片索引的存储换成增量追加的方式每传完一片向一个独立的日志文件追加一行[index, md5]续传时读取这个日志恢复进度。这样即使某一行写坏了也只影响那一片重新传即可。实际项目中我把两种方式结合主状态文件用原子替换日志文件做次要恢复来源。6. 回归验证与上线前检查清单适配完成不代表可以上线。我在交付前做了一套比较完整的回归验证覆盖正常流程、异常流程和极端流程。6.1 用弱网工具模拟真实断流我在鸿蒙设备上连接一个可调的Wi-Fi热点分别模拟30ms、100ms、300ms延迟以及限速512kbps、1Mbps、2Mbps的带宽。重点验证三件事单分片超时后重试逻辑是否按预期走。连续失败达到retries上限时是否回抛错误并中断整个上传。断网后恢复是否能从正确的位置续传而不是从头传。实测发现一个规律重试次数设成2次比较合适。第1次重试通常能解决瞬时的网络波动第2次重试解决路由切换。到第3次说明当前网络确实不可用应该停下来提示用户而不是让用户的流量和电池在无意义的重试中白白消耗。6.2 多设备与进程回收验证鸿蒙的进程回收策略在不同设备上有差异。我测试时专门做了这几个动作上传到一半杀掉App进程立即重进验证续传。杀掉进程后等10分钟再进触发服务端临时分片可能已被清理的场景。上传完成后立刻退出应用再重进验证不会重复上传。还有一个容易被忽略的点分片上传过程中如果用户切换了Wi-Fi到4GTCP连接会断但HTTP请求可能没有报错。这时候要依赖服务端返回的分片长度校验来判定该片是否成功而不是单纯看状态码。6.3 上线前检查清单检查项判断标准鸿蒙权限配置debug/release 两种构建下都能正常发起网络请求分片初始化服务端返回的chunkSize与客户端一致分片去重相同chunkIndex多次提交不会导致重复写入进度恢复断点续传后服务端continuousIndex与本地一致文件完整性合并后的MD5与原始文件一致异常覆盖文件被改动、session过期、分片损坏都能走到正确分支退出与恢复上传中断后重进App恢复进度不丢不乱以上每一项我都建议用脚本或自动化用例跑一遍不要只靠手动测试。因为等到用户真在电梯里传大文件时才出问题代价就太大了。最后分享一个个人感触比较深的小技巧适配鸿蒙和适配Android/iOS的最大区别不是语言也不是API而是你要时刻记得沙箱和权限的边界在哪里。纯Dart的三方库往往默认走标准路径这在Android上问题不大但在鸿蒙上经常会踩沙箱的雷。所以拿到任何一个Flutter库准备迁移到鸿蒙时第一件事不是看它的API文档而是确认它依赖了哪些dart:io的底层能力。文件路径、网络Socket、持久化存储这三类能力是鸿蒙适配里最容易翻车的地方。chunked_uploader能顺利跑通不是因为我的运气好而是把所有这类边界问题都提前堵住了。
返回列表