ARTICLE DETAIL

资讯详情

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

鸿蒙Flutter大文件上传:分片传输与断点续传适配实战

鸿蒙Flutter大文件上传:分片传输与断点续传适配实战 做上传功能最怕的就是文件传到一半网断了尤其是几十上百 MB 的日志包和视频素材。你在 Flutter 项目里找了半天发现 chunked_uploader 这个三方库正好支持分片传输和断点续传本来以为加上去就能收工结果一到鸿蒙设备上就各种水土不服路径不对、权限报错、请求失败、插件没有 HarmonyOS 实现。这篇指南就是把我这次适配的完整过程、踩过的坑、改过的代码和最终跑通的方案整理出来给要在鸿蒙上做 Flutter 大文件上传的开发者一个可以直接抄作业的参考。1. 项目整体思路为什么绕不开分片传输与断点续传1.1 完整上传的最大痛点重来成本太高很多人会问断点续传和完整上传到底有什么区别其实一句话就能说清楚完整上传是一个请求从头传到尾中间断了就全部作废分片上传是把文件切成 N 块每块单独请求已经传成功的块不需要再传。听起来很简单但真要在生产环境里落地区分点很关键。我这次接的业务是需要上传 100MB 到 1GB 级别的安装包和日志文件首次提交时用的就是 Flutter 自带的http.MultipartFile直接post。最开始小文件没问题文件一大问题立刻来手机网络切换导致连接超时、弱网环境下 TCP 连接被重置、上传进度只能靠一个简陋的onProgress回调去猜一旦失败整包重传。用户那边 4G 网络一波动单文件传到 80% 就断重来一次又是十几分钟这是完整上传方案在移动端完全行不通的根本原因。分片传输要做对不只是把文件拆开那么简单还涉及分片大小怎么定、失败后怎么重试、暂停恢复后如何跳过已传分片、服务端如何合并这些片。核心目标只有一个让每一次网络失败的成本被限制在一个可控的小范围内。这也是我最后选择基于 chunked_uploader 方案去改造的根本动机。1.2 chunked_uploader 到底解决了什么问题chunked_uploader 是一个纯 Dart 实现的 Flutter 三方库核心思路是把大文件拆成多个 chunk然后并行或串行上传这些分片并提供暂停、继续、进度回调这些能力。它没有绑定 Android/iOS 原生代码这也是我在鸿蒙上敢碰它的重要原因——纯 Dart 的三方库在鸿蒙 Flutter 引擎里有很大概率直接编译通过。从实际功能上看它解决的是“切块”和“单块重试”的问题你把文件路径传进去它内部按指定 chunk 大小读取文件内容封装成可以重复提交的分片请求某个分片失败了只需要重试那一片而不是重新传整个文件。对于断点续传它把上传库的基础能力做出来了但真正要支撑“稳健”两个字光靠它自身还不够需要在外围补齐鸿蒙的权限、路径、网络策略和服务端的合并协议。我拿它做底座再在外面包了一层自己的上传管理器原因后面会展开。但必须承认没有它把“文件分块”和“逐块请求”这件事抽象好我这次适配要写的底层代码会多得多。1.3 鸿蒙环境带来的额外复杂度鸿蒙设备上跑 Flutter底层不再是 Android 的 ART 和 Bionic而是 OpenHarmony 体系的运行时和自绘引擎。理论上 Dart 代码跨平台没问题但一旦代码里用到dart:io、依赖某个第三方插件的原生实现问题就来了。chunked_uploader 本身是纯 Dart 的所以编译问题不大但它依赖的http包要发网络请求读写文件要依赖dart:io的File这两块在鸿蒙 Flutter SDK 里虽然都有实现实际表现却和 Android 并不完全一样。再加上获取文件路径通常要依赖file_picker这类插件这些插件如果没有 HarmonyOS 的安装包实现就会直接抛MissingPluginException连编译都过不了。我把整体适配拆成了四层环境层Flutter SDK 与鸿蒙工程配置、权限层网络权限、明文流量策略、数据层文件路径获取与断点记录持久化、协议层与服务端对接的分片上传和合并规则。这种拆法让每个问题都有独立的排查范围实际调试时省了很多时间。2. 环境准备与依赖分析2.1 鸿蒙 Flutter 开发环境怎么搭做适配之前不要急着改代码先把环境跑扎实。鸿蒙 Flutter 开发需要 DevEco Studio 和对应的鸿蒙 Flutter SDK具体版本号变化很快建议直接装官方推荐的搭配。装完后执行flutter doctor有很大概率看到一条 “The current configured Flutter SDK is not known to be fully supported. Please...” 的警告。这条警告别慌它大多数时候只是版本匹配检测不到位造成的提示只要你的 Flutter SDK 版本和 DevEco Studio 中配置的鸿蒙 SDK 版本对得上通常可以继续开发。我的建议是把 Flutter SDK 版本固定住不要随手升级因为鸿蒙的适配版本往往滞后于 Flutter 官方版本升一个新版本可能带出编译器和运行时兼容问题。工程上还要注意鸿蒙应用的工程结构里会有entry/src/main/module.json5这样的配置文件Flutter 生成的ohos目录是鸿蒙侧原生工程的根。后续改权限、配网络策略都要在这里改不是在android/目录里改了。2.2 chunked_uploader 依赖兼容性检查在用flutter pub add chunked_uploader之前先查一下它的pubspec.yaml依赖了哪些包。一般来说它会依赖http、path这类常用包如果鸿蒙 Flutter SDK 自带的 Dart SDK 版本比较老有可能出现依赖版本不满足的情况。遇到版本冲突时不用急着换库先看有没有兼容版本。我这次就遇到通过dependency_overrides把http固定到一个同时兼容 chunked_uploader 和鸿蒙 Flutter SDK 的版本后编译通过的情况。建议把你的最小 Flutter/Dart 版本约束设成和你正在用的鸿蒙 SDK 匹配不要全局升级依赖。另外要确认你翻到的示例代码里没有用到 Android 专属的原生插件。chunked_uploader 的资料本来就少网上很多 demo 是自己用FilePicker.platform.pickFiles()去拿路径这部分在鸿蒙上最容易炸。纯上传库本身没问题外围代码才是重灾区。2.3 文件路径获取是第一个坎鸿蒙应用的文件沙盒路径和 Android 不一样第三方文件选择器在鸿蒙上也没有默认的安装包实现。如果你的代码还是照抄file_picker的调用方式大概率会在鸿蒙上直接抛MissingPluginException。我的处理方案是不要在一个流程里同时依赖多个原生插件而是分两步走第一步先拿到一个可用的文件沙盒目录比如应用缓存目录第二步把文件先复制到沙盒目录然后交给分片上传逻辑读取。文件从哪里来可以单独走系统分享面板、深度链接或者自己的文件管理页面这样chunked_uploader的输入就变成了一个纯文件路径少掉一大部分插件兼容风险。获取缓存目录如果path_provider没有鸿蒙实现可以自己写一个极其简单的MethodChannel用 ArkTS 在原生侧返回应用缓存目录。这个通道只做一件事就算不用path_provider也能稳定运行。3. 鸿蒙适配改造实操3.1 网络权限声明与明文流量策略鸿蒙应用默认不允许访问网络必然要显式声明ohos.permission.INTERNET。在module.json5里加上对应的requestPermissions配置这一步不做后面所有网络请求都是空中楼阁。{ module: { requestPermissions: [ { name: ohos.permission.INTERNET } ] } }权限只是第一步。如果你的上传服务端是 HTTP 明文地址鸿蒙默认的网络安全策略大概率会拦截。不要试图在代码里绕过系统限制最稳的做法是生产环境用 HTTPS开发环境用本地 HTTPS 测试服务器或按鸿蒙文档显式配置网络安全策略。我这次在开发阶段就被这个坑拖了一天Dart 层拿到的错误码是2300056查了半天才发现是网络策略在拦截。3.2 文件路径读取的兼容实现如果你必须用平台通道拿路径实际上就几个步骤。Dart 侧定义一个MethodChannel调用原生方法获取路径class HarmonyPath { static const MethodChannel _channel MethodChannel(com.example.upload/path); static FutureString getCacheDirectory() async { final String? path await _channel.invokeMethod(getCacheDirectory); if (path null || path.isEmpty) { throw Exception(获取鸿蒙缓存目录失败); } return path; } }鸿蒙原生侧用 ArkTS 或 Java/Kotlin 兼容层实现同名通道返回应用沙盒目录即可。重点提醒Dart 侧不要做任何Platform.isAndroid的假设因为你没法保证鸿蒙 Flutter SDK 里这个值一定返回什么很多人在这个问题上栽过跟头。更稳妥的做法是通道方法返回一个系统标识字符串比如HarmonyOS你的业务代码只认这个字符串。3.3 断点记录持久化别依赖 shared_preferences断点续传最怕的是 App 被杀或用户手动退出后进度丢失。记录“哪些分片已经传成功”这件事很多人会下意识用shared_preferences但它在鸿蒙上如果插件没有实现就会直接MissingPluginException。更可靠的做法是直接落本地文件毕竟上传记录本质是一小段 JSON。我自己维护了一个UploadPersistence类class UploadPersistence { Futurevoid saveUploadRecord(String uploadId, MapString, dynamic record) async { final File file File(${cacheDir.path}/upload_records/$uploadId.json); await file.create(recursive: true); await file.writeAsString(jsonEncode(record)); } FutureMapString, dynamic? loadUploadRecord(String uploadId) async { final File file File(${cacheDir.path}/upload_records/$uploadId.json); if (!await file.exists()) return null; final String content await file.readAsString(); return jsonDecode(content) as MapString, dynamic; } Futurevoid removeUploadRecord(String uploadId) async { final File file File(${cacheDir.path}/upload_records/$uploadId.json); if (await file.exists()) { await file.delete(); } } }为什么不用数据库因为上传状态结构非常简单文件 ID、分片大小、总分片数、已上传分片索引列表。一套 JSON 足够数据库反而引入额外依赖。把记录文件放在应用沙盒的缓存目录里用户清除缓存时自动清掉逻辑上也说得通。3.4 暂停与恢复的状态机设计chunked_uploader本身提供了暂停和继续的底层能力但在鸿蒙上我建议把它包装成一个任务状态机避免 UI 和上传逻辑互相乱调。我的状态定义是idle任务已创建尚未开始。uploading正在上传分片。paused用户主动暂停已上传分片列表已持久化。completed所有分片上传完成并且服务端合并成功。failed出现不可自动恢复的错误。真正执行暂停时不是暴力中断当前网络请求而是设置一个取消标志让当前分片发送完成后停住然后立即保存记录。这样恢复时只需要读取本地记录跳过已经完成的分片索引。代码上我会用一个简单的UploadTaskController来管这个状态UI 层只调用start/pause/resume三个方法不直接碰 http 对象。3.5 核心改造代码一套可落地的分片上传器虽然 chunked_uploader 给了基础能力但鸿蒙适配过程中我最终更依赖自己封装的分片上传器因为它更容易处理服务端协议和本地状态记录。下面是一个精简但可直接运行的结构class ChunkedUploadService { ChunkedUploadService({ required this.httpClient, required this.uploadUrl, required this.initUrl, required this.completeUrl, required this.chunkSize, }); final http.Client httpClient; final String uploadUrl; final String initUrl; final String completeUrl; final int chunkSize; Futurevoid uploadFile({ required String filePath, required String fileName, required UploadProgressCallback onProgress, required UploadTaskController controller, }) async { final File file File(filePath); final int fileSize await file.length(); final int totalChunks (fileSize / chunkSize).ceil(); // 1. 初始化上传获取服务端已接收的分片列表 final UploadSession session await _initUpload(fileName, fileSize, totalChunks); for (int chunkIndex 0; chunkIndex totalChunks; chunkIndex) { if (controller.isPaused || controller.shouldCancel) { // 2. 暂停保存当前索引停止循环 await _persistSession(session); return; } // 3. 跳过已经上传成功的分片 if (session.uploadedChunkIndexes.contains(chunkIndex)) { continue; } // 4. 读取文件块 final int start chunkIndex * chunkSize; final int end ((start chunkSize) fileSize) ? fileSize : (start chunkSize); final Uint8List bytes await file.readAsBytes(start: start, end: end); // 5. 上传分片失败时做有限重试 await _uploadChunkWithRetry( session: session, chunkIndex: chunkIndex, bytes: bytes, retryCount: 3, ); onProgress((chunkIndex 1) / totalChunks); await _persistSession(session); } // 6. 通知服务端合并文件 await _completeUpload(session, totalChunks); await _clearSession(session); } }这段代码相当于把 chunked_uploader 的“分块 重试”理念落地成了鸿蒙上可控的实现。关键点在于每次循环结束都持久化一次 session让崩溃恢复也能接着传。4. 服务端协议与断点续传的可靠性设计4.1 分片上传协议怎么设计客户端再怎么优化服务端不支持分片合并也是白搭。我给这次项目设计的是三个接口POST /upload/init body: { fileName, fileSize, chunkSize, fileMd5 } resp: { uploadId, uploadedChunkIndexes: [], totalChunks } POST /upload/part body: { uploadId, chunkIndex, chunkData, chunkMd5 } resp: { ok } POST /upload/complete body: { uploadId, totalChunks, fileMd5 } resp: { fileUrl }init接口返回服务端已经存了哪些分片这是续传的关键。客户端只需要把自己本地记录的 index 和服务端返回的 index 做一次并集就能决定哪些分片不用传避免“本地记录丢了就只能全量重传”的尴尬。这里不需要造复杂的轮子HTTP JSON MultipartFile 就够用。4.2 分片大小要综合权衡分片大小是改造初期就要拍板的事情我最后选的是 4MB。原因很简单分片太大会让单次失败成本变高比如 100MB 文件切成 8MB一次失败要重传 8MB分片太小会让请求数量爆炸比如 1MB 分片一个大文件要传几百次服务端接口压力大网络握手开销也高。用公式换算一下更直观一个 200MB 文件4MB 分片就是 50 片断点一次最多损失 4MB如果服务端限制单请求体不超过 8MB4MB 分片也很安全。具体业务里你可以根据服务端网关限制和用户网络质量在 2MB 到 8MB 之间调但要记住这个参数必须在服务端也做同样配置两端约定好不能客户端自动就改。4.3 续传不丢不进度的四个细节第一个细节是幂等。客户端网络超时后重试可能服务端其实已经收到这一片了客户端却拿不到响应。所以upload/part接口必须允许对同一个uploadId chunkIndex重复提交服务端覆盖存储即可不能报错。第二个细节是分片校验。每个分片带上chunkMd5服务端校验不通过就返回失败客户端直接重传这一片。如果跳过校验文件合并后损坏的概率会随着文件大小增加明显上升。第三个细节是服务端分片过期清理。很多用户上传一半就放弃服务端如果无限期保留所有孤儿分片存储会越来越膨胀。我一般设置 7 天有效期过期后由定时任务清理对应分片和元数据。第四个细节是合并时机。complete接口触发文件合并后要校验整体文件的fileMd5校验不通过要能让客户端重新上传损坏的分片而不是静默成功返回一个坏文件。4.4 断点续传和完整上传的边界设计协议时也别做过度的东西。小文件比如 5MB 以下我建议直接走完整上传接口没必要初始化分片会话文件超过设定阈值后再切到分片链路。业务层可以做一次阈值分流这样小文件上传更快大文件收益最大服务端也不用为了所有文件都维护分片元数据。这个判断在线上的感受非常明显1MB 的文件走分片流程光 init 和 complete 的两次请求就浪费几百毫秒但如果整包失败重传代价又太高。阈值放在 10MB 或者 20MB 都可以根据你服务的真实网络环境测一下再定。5. 常见问题与排查技巧实录5.1 请求 2300056 的排查路径鸿蒙开发过程中我遇到最多的错误码就是某个网络请求报2300056无论你怎么看 Flutter 侧的异常对象都很难拿到更具体的底层信息。执行以下排查顺序通常能定位问题查module.json5里有没有声明ohos.permission.INTERNET。查请求 URL 是不是 HTTP 明文鸿蒙网络安全策略是否放行。查服务端地址能不能在鸿蒙设备上用浏览器或原生请求直接访问。如果你开着抓包工具先把抓包断开再测一次确定不是调试环境干扰。出现错误码不代表一定是代码问题很多时候只是我在 DevEco 里配置漏了权限或者本地服务地址写成了localhost设备端根本访问不到开发机的服务。5.2 抓包工具看不到鸿蒙请求鸿蒙应用抓包和 Android 不太一样。如果你的测试环境是 HTTPS抓包工具需要信任对应证书而鸿蒙系统默认不信任用户安装的证书导致你能看到 TCP 连接却看不到明文内容。我实际的做法是开发阶段给本地测试服务配一个正式受信证书或者直接使用 HTTP 在局域网内做连通性验证上线再转向 HTTPS。抓包能看到请求头、分片序号和返回码就够了不要为了解密密文在抓包工具上花太多时间。5.3 Platform.isAndroid 不可靠鸿蒙 Flutter 运行时对Platform.isAndroid的处理在不同版本可能不一致依赖这个值做业务分支是危险的。我有一次就是因为代码里写了if (Platform.isAndroid)走 Android 路径结果在鸿蒙上走到了完全错误的分支。建议统一封装一个SystemPlatformUtil通过 MethodChannel 拿真实系统标识或者把“是否为鸿蒙”作为一种显式的运行时配置注入而不是靠 dart:io 的Platform猜测。5.4 插件报 MissingPluginException 的批量解法file_picker、path_provider、shared_preferences这三个插件是最常见的报错来源。批量检查方案是在鸿蒙工程里搜索有没有对应的.hvigor插件实现目录没有就一律换掉。我最终的依赖清单里原生类插件只保留了两个一个用于平台通道拿系统目录一个用于拉起系统文件选择器。其余全部用纯 Dart 方案或文件落盘方案解决。这样依赖越少鸿蒙适配的问题就越少。5.5 hdc 日志与文件检查技巧鸿蒙调试使用hdc命令和 Android 的adb很像。排查上传问题时我经常用下面几个命令# 查看应用缓存目录里有没有生成上传记录文件 hdc shell ls -l /data/app/el2/100/base/{包名}/cache/upload_records/ # 抓取包含关键字的上传日志 hdc shell hilog | grep upload日志是解决“进度倒退”和“记录丢失”这类问题的核心手段。本地记录写了没有、服务端返回的 uploadedChunkIndexes 是什么都在同一段时间里对照日志看很快就能看出问题出在客户端还是服务端。6. 实操总结与经验沉淀这套方案做下来我个人最大的体会是在鸿蒙上做 Flutter 三方库适配第一件事不是改代码而是先判断这个库的“纯 Dart 程度”。chunked_uploader 能做到编译通过、核心逻辑能跑是因为它没有绑定 Android/iOS 原生能力但这种幸运不会每次都发生遇到携带原生代码的库时更高效的手段是找 HarmonyOS 版本实现或者用 platform channel 自己补一层。第二个经验是把断点续传的可靠性完全押在客户端持久化上是不行的一定要让服务端在 init 阶段返回已上传分片列表。因为用户清理缓存、重装应用、换设备登录都会让本地记录不复存在只有服务端的分片状态能兜底。最后一个小建议鸿蒙 Flutter 的版本升级一定要克制不要看到新版本就升。把一个版本组合稳定跑通后固定 Flutter SDK 版本和鸿蒙 SDK 版本把升级当成一次独立的重构任务来做而不是顺手操作。上传这种链路长、依赖多、用户感知强的功能稳定比新潮重要得多。
返回列表