HarmonyOS 大文件断点续传:Range、流式落盘与完整性校验
大文件下载不能简单地把普通HTTP请求换成更长超时。一次把响应读进内存会抬高峰值;网络中断后从头开始浪费流量;已有半个文件时若服务端忽略Range并返回200,继续追加会直接生成损坏文件;下载完成后若不校验,用户只能在打开时才发现内容不可用。
HarmonyOS的requestInStream适合流式响应。本文设计一个可恢复协议:临时文件记录已写长度与ETag,请求使用Range和If-Range,只有206才允许续写;200代表资源已变化或服务端不支持续传,必须清空临时文件重新开始;最终通过长度与摘要后再原子替换正式文件。
1. 续传依赖服务端HTTP语义
客户端发出Range: bytes=1048576-后,服务端若接受范围请求,应返回206和Content-Range;若返回200,响应体通常是完整文件,不能追加到已有1MB后面。
GET /packages/map.bin HTTP/1.1 Range: bytes=1048576- If-Range: "etag-v7" HTTP/1.1 206 Partial Content Content-Range: bytes 1048576-5242879/5242880 ETag: "etag-v7"服务端返回416时,可能是本地偏移超过远端长度,也可能文件已完整。先读取Content-Range: bytes */total再决定校验或清空,不能无条件认定成功。
2. 临时文件与元数据必须成对存在
正式路径旁保存.part文件,另存一份小型元数据。元数据至少包含URL、已写长度、ETag、期望总长度和摘要算法。应用重启后先核对实际文件长度,不能只相信元数据。
interfaceResumeMeta{url:string;downloadedBytes:number;totalBytes?:number;etag?:string;sha256?:string;updatedAt:number;}functionnormalizeMeta(meta:ResumeMeta,actualSize:number):ResumeMeta{return{...meta,downloadedBytes:Math.max(0,Math.min(meta.downloadedBytes,actualSize)),updatedAt:Date.now()};}若元数据URL与当前任务不一致,应创建新任务,不能复用旧临时文件。
3. 大响应使用requestInStream
官方FAQ建议普通request用于不超过5MB的响应,更大内容使用requestInStream。流式接口通过dataReceive连续提供ArrayBuffer,应用边收边写,不保存完整响应。
import{http}from'@kit.NetworkKit';functionbuildHeaders(meta:ResumeMeta):Record<string,string>{constheaders:Record<string,string>={};if(meta.downloadedBytes>0){headers['Range']=`bytes=${meta.downloadedBytes}-`;if(meta.etag){headers['If-Range']=meta.etag;}}returnheaders;}If-Range很重要:资源未变化时继续返回剩余范围;资源变化时服务端可返回完整新文件,客户端据200状态重启下载。
4. 断点下载闭环先判断状态码
读取断点只是第一步。收到状态码后才能决定追加还是清空;数据进入临时文件;结束后校验长度与摘要;全部通过才替换正式文件。任何错误都保留一致的临时状态供下次恢复。
5. 文件写入器只负责顺序落盘
下面使用应用沙箱路径打开临时文件。续传时写入位置从当前文件长度开始;重新下载时先截断为0。
import{fileIoasfs}from'@kit.CoreFileKit';classPartFileWriter{privatefile:fs.File;privateoffset:number;constructor(path:string,offset:number){this.file=fs.openSync(path,fs.OpenMode.CREATE|fs.OpenMode.READ_WRITE);this.offset=offset;}reset():void{fs.truncateSync(this.file.fd,0);this.offset=0;}append(buffer:ArrayBuffer):number{constwritten=fs.writeSync(this.file.fd,buffer,{offset:0,length:buffer.byteLength,position:this.offset});this.offset+=written;returnwritten;}getlength():number{returnthis.offset;}close():void{fs.closeSync(this.file);}}不同SDK版本的文件导入入口可能不同,请以项目SDK声明为准。核心约束是检查实际写入字节数;若少于buffer.byteLength,应继续写剩余部分或终止任务,不能直接增加完整长度。
6. 状态码必须在写入前确定
requestInStream返回HTTP响应码。事件回调可能很快到达,因此实现中应在业务层串行化首个数据块,确保状态码规则已经确定。下面突出206与200的分支。
asyncfunctiondecideWriteMode(status:number,localBytes:number):Promise<'append'|'reset'>{if(localBytes===0&&(status===200||status===206)){return'append';}if(localBytes>0&&status===206){return'append';}if(localBytes>0&&status===200){return'reset';}thrownewError(`unexpected download status:${status}`);}若已有部分文件却收到200,最安全的实现是销毁当前请求、截断文件后重新发起一个不带Range的新请求,而不是在同一事件流中边重置边接收,避免前几个数据块时序不清。
7. 下载器要固定回调引用并统一销毁
classStreamDownloadSession{privaterequest:http.HttpRequest=http.createHttp();privatereceivedBytes:number=0;constructor(privatewriter:PartFileWriter){}privatereadonlyonData=(chunk:ArrayBuffer):void=>{constwritten=this.writer.append(chunk);if(written!==chunk.byteLength){thrownewError('partial file write');}this.receivedBytes+=written;};asyncstart(url:string,headers:Record<string,string>):Promise<number>{this.request.on('dataReceive',this.onData);returnthis.request.requestInStream(url,{method:http.RequestMethod.GET,header:headers,connectTimeout:15000,readTimeout:60000});}close():void{this.request.off('dataReceive',this.onData);this.request.destroy();this.writer.close();}}回调中抛错不应成为唯一取消方式。完整工程要把写入失败传给会话状态机,主动销毁请求并落盘最新元数据。
8. 续传责任边界避免正式文件半成品
下载任务负责重试与状态;HTTP层只提供响应码、头与数据流;临时文件允许中断;正式文件只接收校验通过的完整结果。播放器、解压器或地图引擎只能读取正式路径,不能窥探.part文件。
typeDownloadState=|'idle'|'requesting'|'streaming'|'verifying'|'completed'|'paused'|'failed';interfaceDownloadSnapshot{state:DownloadState;receivedBytes:number;totalBytes?:number;failureReason?:string;}状态迁移应单向且可记录,例如streaming -> verifying -> completed。失败后回到paused还是failed,取决于错误是否可重试。
9. Content-Range必须与本地偏移吻合
续传响应为206时,解析起始位置并与本地长度比较。服务端若从其他位置返回,继续写入会产生缝隙或重叠。
interfaceContentRange{start:number;end:number;total:number;}functionparseContentRange(value:string):ContentRange|undefined{constmatch=/^bytes (\d+)-(\d+)\/(\d+)$/.exec(value.trim());if(!match)returnundefined;conststart=Number(match[1]);constend=Number(match[2]);consttotal=Number(match[3]);if(start<0||end<start||total<=end)returnundefined;return{start,end,total};}头字段名大小写不应硬编码到单一形式,读取响应头时先做统一归一化。
10. 完整性至少校验长度,重要文件再校验摘要
下载结束先比较临时文件实际长度与Content-Length或Content-Range总长度。安装包、离线地图和模型文件还应使用服务端提供的SHA-256。摘要必须来自可信元数据,不能来自同一不受信下载通道的可修改字段。
functionverifyLength(actual:number,expected?:number):void{if(expected===undefined||expected<0){thrownewError('missing expected file length');}if(actual!==expected){thrownewError(`file length mismatch:${actual}/${expected}`);}}摘要计算也要流式读取文件,不能为了校验再次把大文件全部载入内存。
11. 校验成功后再替换正式路径
临时文件与正式文件最好位于同一文件系统,校验通过后使用rename替换。若目标已存在,可先保存旧版本或按产品策略删除,任何时候都不要让正式路径指向半成品。
asyncfunctioncommitPartFile(partPath:string,finalPath:string):Promise<void>{conststat=fs.statSync(partPath);if(stat.size<=0){thrownewError('empty part file');}awaitfs.rename(partPath,finalPath);}元数据清理放在rename成功之后。应用崩溃在rename之前,仍可续传;崩溃在rename之后,下次通过正式文件校验恢复完成状态。
12. 重试策略按错误类别区分
网络断开、超时 -> 保留断点,退避后续传 HTTP 401/403 -> 刷新授权,不自动高频重试 HTTP 404 -> 任务失败,提示资源不存在 已有断点却返回200 -> 清空临时文件,重新完整下载 Content-Range不一致 -> 停止追加,重新建立任务 磁盘空间不足 -> 保留可解释状态,用户处理后再试 摘要不一致 -> 删除不可信临时文件,从头下载13. 断点续传验收清单
[ ] 大于5MB响应使用requestInStream [ ] 本地偏移来自真实临时文件长度 [ ] 续传只接受起点一致的206响应 [ ] 200响应不会追加到旧临时文件 [ ] 数据块写入后核对实际字节数 [ ] 退出时解除事件并destroy HttpRequest [ ] 完成前校验总长度与可信摘要 [ ] 校验通过后才替换正式文件 [ ] 杀进程重启后可从一致断点恢复14. 流式下载资料索引
- Network Kit大文件请求FAQ
HttpRequest.requestInStream、dataReceive与文件接口:以本机HarmonyOS SDK API 23声明为准。
断点续传的难点不在Range字符串,而在每个边界都要可证明:响应码证明能否追加,Content-Range证明起点一致,临时文件证明中断可恢复,长度与摘要证明内容完整,最终rename证明正式路径只暴露完成结果。