OpenHarmony + React Native 大文件断点续传:分片与弱网实践 上个月我在给一批 OpenHarmony 设备做配套 App 时遇到的最头疼问题不是 UI 适配而是文件上传。设备端会持续产生日志包、离线媒体包单个文件经常超过 200MB需要稳定地传到服务端。最开始我用最原始的思路选完文件、一次性读进内存、一个请求丢出去。结果在弱网环境下成功率惨不忍睹网络稍微抖一下整个上传作废用户只能从头再来。后来我把整个模块重写成了基于 React Native 的 File 文件上传断点续传方案这一篇就把完整思路、代码实现和踩过的坑都摊开讲给你做项目时直接参考。先说清楚这篇文章适合谁你正在用 React Native 开发 OpenHarmony 应用需要做文件上传模块或者你已经在做上传但被大文件失败率高断电断网后要重传折磨过。我会从原理讲到代码再讲到服务端配合最后聊聊我在真机测试中遇到的几个诡异问题。如果你现在只是有模糊需求还没开始写代码我建议从头到尾读一遍如果你已经写了上传功能直接跳到第 5 节看踩坑部分大概率能找到你卡住的点。1. 先搞清楚OpenHarmony 上的 RN 到底怎么碰文件1.1 不是 H5不是小程序RN 在 OpenHarmony 上的运行方式很多做前端或者小程序出身的朋友第一次接触 OpenHarmony 上的 React Native 时容易把它的文件能力想象成浏览器里的 File API以为拿到 File 对象就能随便切片、随便读。实际上在 OpenHarmony 设备上React Native 的 JS 层运行在 ArkTS 运行时提供的 JavaScript 环境里真正干活的是原生侧。我用最直白的方式描述下这个结构JS 层你写的 TypeScript/JavaScript 代码负责业务逻辑、状态管理、发起请求。原生层OpenHarmony 的 ArkTS 系统能力负责真正读写文件、获取文件路径、处理 Content URI。桥接层JS 层和原生层之间通过社区适配层通信走的是类似 JSI/Bridge 的机制。这意味着什么意味着你在 Web 环境里熟悉的fs.readFile在 RN 里不一定存在即便存在很多 API 的能力也被限制在沙箱目录或者特定授权目录内。OpenHarmony 对文件访问的安全管控很严格应用不能随便读/data下的系统目录也不能直接读其他应用的私有目录。你在 RN 里拿到的文件路径通常是应用沙箱路径或者用户通过系统文件选择器选中的临时拷贝路径。所以做 OpenHarmony 的 RN 文件上传第一步不是写上传代码而是先摸清楚你这个文件怎么进来的、路径是什么、能不能被 File 模块读写。我在项目里是先做了一个很小的原生测试页把文件选择后拿到的路径原样打印出来确认了路径类型是file://还是content://才敢继续往下写。这一步看似简单但省了我后面排查三个小时的力气。1.2 为什么上传这种基础功能也要自己造轮子你可能会问文件上传不是基础功能吗社区里不是有一堆上传库吗是的Web 端确实一堆上传库React Native 生态也有一些成熟的网络库但放在 OpenHarmony 上情况就变了。第一OpenHarmony 的 React Native 适配层本身就是社区在推进很多第三方原生模块并没有针对 OpenHarmony 做适配。你找了个很流行的上传组件结果一查维护记录人家只支持 Android 和 iOSOpenHarmony 连编译都过不了。第二断点续传这件事核心不在网络请求而在状态管理。你要记录哪些分片传成功了要从哪一段继续传要处理并发上传时的顺序问题这些都需要你自己设计数据结构和存储方案。就算用一个现成的上传 SDK到了断点续传这一层你还是得自己写状态逻辑。第三OpenHarmony 设备形态很多从手机到平板再到行业终端网络环境差异极大。我在实际项目中遇到的目标设备经常跑在移动网络或者信号弱的区域通用网络库默认的超时策略根本不够用必须针对弱网做重试和超时控制。这种定制需求自己写反而比改第三方库更顺手。所以这篇文章说的自研上传模块不是矫情而是 OpenHarmony 上做 React Native 应用不得不走的一条路。你只有把分片逻辑、断点状态、恢复流程都掌握在自己手里才能在遇到网络波动和设备异常时快速定位问题。2. 断点续传的本质分片、偏移量与状态机2.1 大文件上传为什么会失败先说个生活化类比。假设你要从一楼往三十楼搬家如果雇一辆大卡车把所有家具一次性拉过去路上遇到堵车或者某个路口限高整辆车都进不了小区所有东西都得原路返回。但如果你把家具拆成一个个集装箱分批次运每次只运几个柜子哪怕路上有几箱出问题重新运出问题的柜子就行其他早就到位了。大文件上传跟搬家一模一样。弱网环境里一次性把一个 200MB 的文件通过一个 HTTP 请求传上去中间任何一次 TCP 抖动、信号切换、服务端超时都可能导致整个请求失败。你重试的时候又得从头传一遍成本极高。更不用说一次性把整个文件读进内存这件事本身在内存有限的设备上就是灾难。所以断点续传的核心思路就是别一口气吃完一整头大象切成小块一口口吃。把一个文件按固定大小切成多个分片每个分片单独上传记录哪些分片已经上传成功下次接着缺失的分片继续传而不是重新传整个文件。2.2 三个核心概念分片、偏移量与元数据这里我直接给结论你照着这个模型设计上传功能基本不会出方向性错误。分片Chunk/Part把整个文件按照固定大小切成的数据块。比如一个 200MB 的文件按 2MB 切一共 100 个分片。分片太小会导致 HTTP 请求数量太多效率低分片太大会失去断点续传的意义所以分片大小是第一个要设计的参数。偏移量Offset每个分片在原始文件中的起始位置。第 0 个分片从第 0 字节开始第 1 个分片从第 2MB 开始以此类推。服务端依赖这个偏移量来重组文件。偏移量一定是文件从头到某个绝对位置的字节数不能是分片序号乘以分片大小就完事——如果最后一个分片不满一个整分片大小你还是要靠真实的字节长度来计算。元数据Metadata围绕一次上传任务产生的描述信息包括文件唯一标识、文件总大小、分片大小、分片总数、已上传分片列表、上传状态等等。元数据是断点续传的灵魂上传功能能断点,靠的就是把状态持久化下来。这三者的关系我画个文字版的简化流程拿到文件计算唯一标识向服务端发起 init 请求。服务端返回 uploadId 和已经上传过的分片列表。客户端读取本地元数据对比服务端已上传分片找出缺失列表。按分片循环读取文件字节逐个上传每成功一个就更新本地元数据。全部上传完成后调用 complete 接口通知服务端合并文件并校验。2.3 服务端协议怎么约定断点续传不是纯客户端的事服务端必须配合。我在项目中定义了一套非常简单的接口协议供你参考。我把服务端也写成纯虚构的项目代号接口路径如下POST /api/v1/upload/init客户端提交文件名、文件大小、分片大小服务端创建一个上传任务返回 uploadId。POST /api/v1/upload/part客户端上传单个分片参数带上 uploadId、当前分片的起始偏移量、分片内容。服务端存储该分片。GET /api/v1/upload/parts?uploadIdxxx查询已上传的分片列表。客户端恢复续传时用来做状态比对。POST /api/v1/upload/complete客户端通知服务端所有分片都传完了服务端按偏移量合并文件返回最终文件地址。这套协议很简单但覆盖了断点续传需要的所有场景。要注意的是服务端返回已上传分片列表时一定要返回每个分片的起始偏移量而不是一个简单的第几个分片传完了。因为分片大小可能调整或者并发上传时状态有偏移按偏移量对账比按序号对账更可靠。下面这个表是我做方案评审时列的一次性上传和分片断点续传的对比维度一次性上传分片断点续传网络失败成本整个文件重传只重传失败分片内存占用大文件整体读入只读当前分片弱网成功率低受限于长连接高短请求易恢复状态复杂度无需要持久化管理服务端逻辑简单需要存储分片和合并结论很明显除非你的文件永远只有几百 KB 且网络环境极好否则只要上 10MB 以上的文件我都建议用断点续传。3. File 模块读写与分片实现React Native 代码实战3.1 RN 侧怎么拿到文件路径在你写分片逻辑之前先得解决文件路径从哪来的问题。在 OpenHarmony 上最通用的做法是通过系统的文件选择器或者媒体库授权拿到一个 URI然后通过文件模块读取。在 React Native 里我通常的做法是先用原生模块或系统文件选择器拿到文件 URI然后再通过文件模块提供的stat接口获取文件大小和路径信息。如果拿到的是content://类型的 URI比如系统文档选择器返回的临时授权路径通常在你确定要上传时可以先把源文件拷贝到应用沙箱目录下得到一个file://路径。这一步不是为了绕权限而是防止上传过程中原始文件被外部删除或覆盖。我贴上核心的前置处理伪代码实际开发中你根据适配层 API 做替换即可import { File, FileStat } from ohos/react-native-file; async function prepareFile(sourceUri: string): Promisestring { // 1. 确认路径类型 // 2. 如果是 content:// 类型复制到沙箱目录 const stat await File.stat(sourceUri); if (stat?.size 0) { return sourceUri; } const destPath ${this.getSandboxPath()}/upload_cache/${Date.now()}_source.bin; await File.copy(sourceUri, destPath); return destPath; }这里有个细节复制文件到沙箱目录会新增一倍磁盘占用有些设备磁盘紧张所以你要在文件上传完成后及时清理沙箱里的临时文件。如果你拿到的是持久化授权路径并且设备空间有限可以跳过复制这一步但在提交流程中要做好文件被外部删除的兜底判断。3.2 分片读取的两种姿势拿到文件路径之后下一步就是分片读取。在 React Native 层我见过两种主流姿势各有利弊第一种是Base64 读取。用文件模块的readAsBase64从指定偏移量读取指定长度把分片转成 Base64 字符串之后放进请求体上传。第二种是二进制流读取。通过文件模块打开一个文件句柄定位到偏移量读取指定字节然后以二进制格式通过 HTTP 请求的 body 传给服务端。Base64 方式实现简单但存在一个问题Base64 编码会让数据体积膨胀约 33%一个 2MB 的分片传出去实际网络负载约 2.67MB。二进制方式效率高但对原生 HTTP 请求承载二进制数据的能力要求较高而且代码写法更复杂一些。我给一个用二进制分片读取的示例这是我在项目里采用的方案import { File } from ohos/react-native-file; import { http } from ohos.net.http; // 用 OpenHarmony 网络能力封装 async function readChunk(filePath: string, offset: number, size: number): PromiseArrayBuffer { const fd await File.open(filePath); try { // 定位到偏移量 await File.seek(fd, offset, start); // 读取分片数据 const buffer await File.read(fd, size); return buffer; } finally { await File.close(fd); } }当然不同适配层的 API 名称和封装的层级可能不同我这边是示意写法。你在实际写的时候重点要关注三个细节文件句柄必须关闭、读取偏移量必须精确、最后一个分片如果不足分片大小要按实际剩余字节读取。3.3 分片上传与进度计算分片读取好后上传逻辑就有依托了。我采用的方式是控制并发数批量上传这样比一梭子全部发出去稳定得多。核心代码逻辑如下class UploadTask { private uploadId: string ; private chunkSize: number 2 * 1024 * 1024; // 2MB private uploadedParts: Setnumber new Set(); private totalChunks: number 0; async upload(filePath: string, totalSize: number) { this.totalChunks Math.ceil(totalSize / this.chunkSize); const concurrency 3; for (let start 0; start this.totalChunks; start concurrency) { const tasks []; for (let i start; i Math.min(start concurrency, this.totalChunks); i) { if (this.uploadedParts.has(i)) continue; const offset i * this.chunkSize; const size Math.min(this.chunkSize, totalSize - offset); const chunk await readChunk(filePath, offset, size); tasks.push(this.uploadPart(this.uploadId, offset, chunk) .then(() { this.uploadedParts.add(i); this.updateProgress(); }) .catch((err) { // 单个分片失败允许后面统一重试 }) ); } await Promise.all(tasks); } } }进度计算不需要特别复杂的逻辑。已上传字节数 已上传分片数 × 分片大小最后一个分片按实际大小进度百分比 已上传字节数 ÷ 文件总大小。要注意已上传字节数不要按当前最后一次请求的偏移量来算因为在并发场景下各个请求的完成顺序不是固定的你如果维护一个累加变量在高并发下很容易算错。所以我的做法是每次分片成功后向一个Set里添加该分片在任务里的绝对序号或者用偏移量做 key计算进度时遍历这个 Set。这是最稳妥的做法不会因为并发完成顺序导致进度回退。4. 断点信息持久化与续传恢复流程4.1 断点信息写在哪分片上传状态是易失的App 被杀掉或者用户退出重进如果状态不保存断点续传就名存实亡。我把断点信息的存储方案列几个常见选项存储位置优点缺点适用场景AsyncStorage简单、可靠RN 生态现成存大量元数据时性能一般体量小的状态比如 uploadId、分片大小应用沙箱文件容量大、可控性好需要自己处理并发写文件冲突分片信息量较大或需要保留上传日志服务端记录多端同步、准确依赖网络弱网时无法获取适合跨设备恢复比如同一账号多设备续传我在最初版本里只存了 AsyncStorage存一个 uploadId 和已上传分片序号列表。后来发现一个问题当并发上传达到 5 个以上分片成功回调到达顺序很乱如果直接往 AsyncStorage 里的数组 push 序号频繁写同一个 key 会导致状态覆盖。所以后来我改成先更新内存中的 Set再定期持久化并且持久化时攒一批分片序号一次性写入这样即使持久化慢一点也不影响上传主流程。4.2 恢复续传的判断逻辑恢复续传不能只信本地状态一定要在 App 重新进入时从服务端核对一遍。原因很简单本地记录可能因为 App 崩溃丢失部分数据或者实际上传已经全部完成但没来得及调用 complete 接口服务端分片还在。如果本地状态和实际状态不一致就会做出错误的续传决策。恢复流程我总结为四步从本地读取上次任务的信息uploadId、文件标识、文件路径、总大小。检查本地文件是否还存在大小是否与记录一致。如果文件被删或者大小变了任务作废重新 init。调用服务端GET /api/v1/upload/parts?uploadIdxxx拿到服务端已上传的分片列表。把本地和服务端已上传分片做并集或者交集进行处理。正常情况下二者一致但为了稳妥我采用服务端为准策略把服务端已有分片当作已上传本地有但服务端没有的分片重新上传。以服务端为准的好处在于避免因本地状态异常导致上传冲突也给服务端提供了分片去重的机会。4.3 并发上传下的状态一致性这里重点讲一下并发上传时状态的一致性这是我自己当初踩得比较深的一个点。设想一个场景你开启了 3 个并发上传任务分片 1、2、3 同时提交。服务端处理速度不一样分片 2 先完成回调成功分片 1 后续传成功分片 3 失败需要重试。如果你的状态维护是上一帧完成的分片序号那肯定会乱。因为你上一帧可能是 2然后突然变成 1进度从 66% 掉回 33%用户看到还以为是 bug。解决这个问题的标准解法是状态永远以已完成的绝对偏移量集合为准不做顺序收敛。你已经上传了分片 1 和分片 2 就是上传了哪怕它们返回顺序是 2、3、1 还是 1、3、2集合里的数据都是{1, 2, 3}。我在代码里统一用一个Setnumber来存偏移量 key并且上传重试逻辑只关心集合里缺失的 key。这里需要注意另一个细节并发数不是越大越好。分片上传的瓶颈往往不在客户端而在服务端的写入能力和网络带宽。我在真机设备上测过当并发数设为 6 时服务端偶尔出现分片写入冲突需要服务端加锁或者做幂等。后面我把并发数降到 3上传整体耗时不升反降因为服务器压力小了单个分片请求的平均耗时缩短了。5. 我在真实设备上踩过的坑生命周期、文件句柄与弱网状态5.1 应用切后台上传就断我在第一批测试设备上遇到的最诡异问题上传过程中把 App 切到后台再切回来进程还在Task 却已经停了。一开始我以为是 React Native 的 JS 线程被冻结后来排查发现是 OpenHarmony 对后台任务的限制——应用退到后台后部分网络通道和定时器被系统挂起。解决方案其实不复杂一个是确保应用持有长时任务权限同时把上传任务声明为前台服务让系统知道这是一个需要持续执行的任务另一个是保存断点信息后主动取消任务切回前台时恢复续传。在 RN 层我做了 App 状态监听的收尾逻辑监听到应用切到后台优先保存当前的持续状态监听到切回前台立刻检查本地缓存状态并调用恢复逻辑。这种做法看似简单但能避免系统强杀导致的状态丢失。5.2 文件句柄泄漏上传几次之后打开文件失败这是我印象最深的一个坑。代码看起来没问题每次读取分片后都在finally里写了file.close(fd)但连续上传几次大文件后App 突然报错无法打开文件句柄不足。排查过程很有意思。我先在原生侧加日志确认每次close都被调用了但系统文件句柄数量还是持续上涨。后来我打开了 React Native 侧的调试日志才发现问题出在并发上传时同一个文件被多次重复打开。我的readChunk函数虽然每次都会关句柄但连续多个异步任务在系统层面可能同时打开句柄而文件模块的open/close是异步操作互相同步不够及时导致句柄在某一瞬间被叠加打开几十个超出限制。解决方法是给文件读取操作加一个简单的互斥信号量或者统一由一个文件读线程串行读取分片不让并发任务同时打开文件。考虑到上传本身是 IO 密集型我干脆用一个串行读取队列先按分片偏移量排序一次只读一个分片然后交给上传线程去传。文件句柄压力立刻小了很多。5.3 弱网下的超时与重试策略弱网环境下单个分片请求超时是常态但超时后立刻重试往往效果很差。我在设备上抓包看到的现象是一次请求超时后如果马上重发同样的分片大概率还是超时因为网络链路还没来得及恢复。后来我给上传模块加了两层重试策略第一层单个分片请求超时后做指数退避重试。间隔从 1 秒开始连续失败后翻倍到 2 秒、4 秒、8 秒最多重试 5 次。第二层如果连续多个分片都失败说明网络状态很差此时暂停整个上传任务进入等待网络恢复状态而不是继续重试消耗资源和电量。等到网络恢复后再从断点位置继续。我把关键参数放在一个配置里方便不同设备形态调整const uploadConfig { chunkSize: 2 * 1024 * 1024, // 2MB concurrency: 3, // 并发分片数 timeout: 30000, // 单分片超时时间 retryCount: 5, // 最大重试次数 retryBaseDelay: 1000, // 重试基础间隔 pauseThreshold: 5, // 连续失败几个分片后暂停 };这里要特别提醒超时时间不要设得太短。在 4G/5G 弱信号环境下一个 2MB 分片的耗时经常达到 10 秒以上如果超时设成 5 秒几乎所有分片都会被判定为失败。我一开始设了 15 秒后来调到 30 秒成功率立刻提上来了。下面这个表是我在设备上记录的几组对比实验数据用的是固定大小的测试文件在不同配置下的结果配置弱网成功率平均耗时结论一次性上传30% 左右长且失败极高不可用分片 1MB并发 182%慢但稳定可接受分片 2MB并发 392%快推荐分片 4MB并发 588%快但内存压力大设备内存不足时不建议事实证明分片大小和并发数不是越大越好要结合目标设备的性能和网络环境中位数来定。6. 服务端合并、校验与整体调优建议6.1 分片大小与并发数怎么定这是很多团队在联调时纠结最多的问题。我给一个通用参考思路先看网络环境的中位数带宽。如果目标设备经常跑在 2Mbps 左右的网络那分片大小建议 512KB 到 1MB如果你在局域网或者 5G 环境下分片可以放大到 4MB。分片大小合理的原则是单分片请求的耗时控制在 5-15 秒之间太短则请求数量暴增太长则失去断点意义。并发数建议从 3 开始测起。并发太小的优点是稳定缺点是总耗时高并发太大设备内存和 Socket 数量都吃紧服务端也可能扛不住。我自己的经验是普通设备上并发 3-4 是最稳定的甜蜜点高性能设备可以尝试 5。还要注意总的分片数量。如果每个文件被切成 2000 个分片光索引和状态管理的复杂度就上来了。这时候你应该适当调大分片大小让全文件分片数不要超过几百个量级。算一笔账一个 200MB 的文件2MB 一个分片共 100 个分片这是我很舒服的数量级。如果你文件到了 2GB建议分片调到 8MB 或者 10MB压到 200-300 个分片左右。6.2 服务端如何校验分片并保证合并正确服务端做断点续传的合并逻辑核心是按偏移量落盘不做拼接顺序假设。具体做法是为每个上传任务创建一个目录里面放置各个分片文件命名可以使用{offset}.part这种形式。上传分片请求到达时根据偏移量直接写入对应的分片文件。合并时读取该目录下所有分片按偏移量排序依次拼接成完整文件。这里有一个我特别想强调的校验点必须校验每个分片的内容完整性。网络传输过程中的数据损坏是真实存在的哪怕概率很低。我的做法是为每个分片在 header 中带上该分片内容的哈希值服务端收到后先计算哈希匹配才落盘不匹配就返回一个明确错误码客户端针对该分片重试。整体文件合并完成后再对完整文件算一个哈希方便客户端或后端做总体验证。6.3 上传完成后的清理与幂等上传流程走完 complete 之后客户端和服务端都要做清理。服务端要做的把分片目录清理掉只保留最终合并的文件。这个如果漏了短期看不出问题时间长了会积累大量孤儿分片文件磁盘迟早被占满。客户端要做的清空本地缓存的断点状态删除沙箱目录里缓存的上传源文件副本。这里要注意一个坑complete 接口发送成功后客户端不能立刻删状态。因为 complete 请求可能丢失客户端收到超时后再次重试时服务端可能已经完成合并此时应该返回任务已完成而不是重新创建任务。我的做法是让 complete 接口幂等——服务端如果发现任务已经完成直接返回成功状态。客户端只有在明确收到任务已完成响应后才清空本地状态这样可以避免本地删干净了服务端却没事先合并的尴尬。6.4 一个简单但有效的整体优化上传日志最后再分享一个我后来加上去的模块——上传日志。这个日志不是给用户看的是给自己排查问题用的。我在上传任务里记录每次分片请求的发送时间、结果、耗时、重试次数并持久化到本地文件每 100 条分片记录或者一个任务结束时刷一次磁盘。其实不需要占很多空间一行一条 JSON 记录一个几百 KB 的文件就够存一个完整任务的所有日志了。这个日志帮了我大忙。有一次现场反馈上传卡在 67% 不动了远程拿不到断点状态我让设备侧把日志文件传回来一看所有失败分片都集中在同一个网络区域是设备信号盲区导致的网络间歇性不可用。之前没有日志时我只能靠猜有了日志我甚至能直接画出整个上传过程的耗时曲线哪里慢哪里快一目了然。断点续传听起来像是老生常谈但真正在一套新系统上用 React Native 从零写一遍过程中暴露的问题比我预期的多得多。上面这些经验和代码都是我在真机上实际跑过、调过、压过的结果。如果你也在 OpenHarmony 上做 RN 上传模块照着这套思路做至少能少走一大半弯路。