大文件上传这块儿,几乎每个做Web开发的朋友迟早都会撞上。网盘传视频、后台传安装包、医疗影像系统传DICOM文件、企业OA传设计稿,凡是超过一两百兆的文件,动不动就是上传超时、进度条卡死、网络一抖整个文件报废。我早年在某公司做过一个跨平台文件管理系统的上传模块,里面有GB级别的海外设计原片需要从各地分公司回传总部,那时候真的是被大文件上传折磨得够呛,后来老老实实把秒传、断点续传、分片上传这套体系全部落地,才算真正治好了这个心病。
这篇文章我想把整套方案的来龙去脉讲透:为什么说分片上传是大文件上传的地基,秒传背后依赖的是什么原理,断点续传要做到哪一步才算真正的“断点续传”。不是丢一堆概念给你,而是把每一步怎么设计、参数怎么定、前后端怎么配合、会踩哪些坑,都摊开来说清楚。
1. 先从整体聊起:这套方案到底解决了什么问题
1.1 大文件上传的三个致命痛点
常规的表单上传,本质就是一次HTTP请求把整个文件塞进请求体里。文件小还好说,一旦文件到了几百兆甚至几个GB,三个问题就会接踵而来。
第一个是网络稳定性。HTTP请求虽然底层走TCP,但一次请求的生命周期很长,中间任何一次抖动、断网、路由器重启,都可能导致整条连接断开。而连接一旦断开,浏览器端的请求状态立刻变成失败,用户只能重新选择文件再传一次。这个体验在弱网环境下几乎等于不可用。
第二个是服务端限制。绝大多数网关、Web服务器默认对请求体体积有上限,比如Nginx默认的client_max_body_size是1MB,Tomcat有maxPostSize限制,云厂商的SLB也有类似约束。就算你调大这些参数,单请求传GB级别的数据也会占用巨大的内存缓冲,后端接口很容易直接被拖垮。
第三个是时间成本。一个500MB的文件在普通带宽下可能要好几分钟,整个请求期间前端拿不到任何精细的进度反馈。浏览器对XHR有进度事件,但也只能看到“当前总进度”,一旦失败全部归零,根本谈不上“进度管理”。
这三个问题叠加在一起,就形成了一个结论:大文件不能走单请求上传的老路,必须把文件拆小、把上传过程变成可重试、可校验、可追踪的一系列小任务。
1.2 三套机制的分工与协同
我落地这套体系时,把整个目标拆分成了三个层次,它们之间不是替代关系,而是逐层叠加的关系。
分片上传是地基。它把一个大文件按固定大小切成一串分片,比如每片5MB,逐个上传。每个分片都是独立的HTTP请求,单个分片失败了只需要重传这个分片,不会影响其他分片。分片全部上传完成后,由后端触发合并流程,还原成完整文件。
断点续传是在分片基础上的状态记录机制。它把“哪些分片已经上传成功了”这个状态持久化下来。刷新页面、关闭浏览器、甚至换一台电脑,只要你能拿到之前记录的状态,就能跳过已上传的分片,只传缺失的部分。
秒传则是一个前置判断机制。它不做真正的上传,而是根据文件的唯一标识去后端查询是否已经存在相同内容的文件。如果存在,后端直接把这个文件关联到当前用户,相当于“上传”瞬间完成。
一句话总结就是:秒传负责“不用传”,分片负责“拆开传”,断点续传负责“接着传”。三者组合起来,才是完整的大文件上传方案。
1.3 这套方案适配哪些场景
从我的实践经验来看,以下这些场景非常适合直接引入这套方案:
- 网盘类产品:用户上传视频、压缩包、镜像文件,文件体积天然很大,且需要支持暂停恢复。
- 企业协作平台:设计稿、高清图片、项目资料动辄几百MB,需要一个可靠的上传通道。
- 音视频处理系统:上传素材后需要转码,文件完整性直接影响转码成功率。
- 后台管理系统的附件模块:如果业务里经常出现大附件,不能指望用户一次性传完。
如果你的业务场景里,文件通常在几十MB以内、网络环境非常稳定、内网带宽充足,那确实没必要过度设计。但只要是面向公网、面对不特定用户、文件可能超过100MB,这套方案就不是可选项,而是必选项。
2. 核心细节解析与实操要点
2.1 分片大小怎么定:不是越碎越好
分片大小是整个方案里第一个需要拍板的参数,我见过不少人在这里栽过跟头。分片定太大,比如50MB一片,和整体上传没有本质区别,断点续传的粒度太粗;分片定太小,比如512KB一片,分片数量爆炸,请求开销和数据库记录数都会成倍增长。
我的建议是:常规公网环境下,4MB到10MB是一个比较甜点的区间。
举个例子,假设文件是200MB,分片大小定为5MB,那么分片总数是40片。每一片上传大约需要1到3秒,40片的总请求量对后端完全可接受。如果把分片定为1MB,分片数变成200片,虽然重试粒度更细,但每次请求的握手开销、鉴权开销累加起来,效率反而不如5MB分片。
实际业务里可以做两档适配:默认5MB,对于网络环境非常好的内网用户可以用10MB;对于弱网用户,可以做成前端根据网速动态调整,检测到下载速度低于某个阈值时自动把分片降为2MB。这个方案我测试过,效果很理想,但初期版本没必要一上来就做动态调整,先把固定分片跑通再说。
2.2 文件唯一标识:秒传和断点续传的共同基石
秒传和断点续传都离不开文件唯一标识,否则后端无法判断“这个文件是不是之前传过”。
最常用的做法是取文件的MD5值。前端读取文件内容,通过Web Crypto API或者第三方库计算MD5。这里要注意一个问题:对大文件一次性计算MD5,内存会爆掉。比如一个2GB的文件,如果直接把它读进一个ArrayBuffer再计算摘要,浏览器很可能直接崩溃。
正确做法是分块读取,逐块累加计算摘要。使用SparkMD5这个库,可以这样操作:
const spark = new SparkMD5.ArrayBuffer(); const reader = new FileReader(); let currentChunk = 0; const chunkSize = 2 * 1024 * 1024; // 每次读取2MB function processNextChunk() { const start = currentChunk * chunkSize; const end = Math.min(file.size, start + chunkSize); const slice = file.slice(start, end); reader.readAsArrayBuffer(slice); } reader.onload = function(e) { spark.append(e.target.result); currentChunk++; if (currentChunk * chunkSize < file.size) { processNextChunk(); } else { const md5 = spark.end(); // 用计算出的md5去后端查询/注册 } }; processNextChunk();这个逐块读取累加的过程,本质上和分片上传是同一个思路,都是把“一次处理一个大任务”改为“多次处理小任务”。这也算是一种设计上的一致性。
2.3 秒传的判定逻辑:别只比对文件名
很多人会误以为秒传是拿文件名去比对,这是一个很常见的误区。文件名毫无可靠性可言,同一个文件可以叫任何名字,不同文件也可以重名。
正确的秒传逻辑分三层:
第一层,用文件内容的MD5去查文件指纹表。这张表记录全网或当前系统里所有已存在文件的MD5、文件大小、存储路径、引用计数。
第二层,同时校验文件大小。MD5相同但文件大小不同,几乎不可能,但加上大小作为辅助维度,可以过滤掉极低概率的哈希碰撞。
第三层,对于命中指纹的文件,做秒传关联。也就是在用户和文件之间建立一条引用记录,而不是真的去复制一份存储。
这里我额外强调一个业务上的细节:秒传命中后,文件的属主可能是另一个用户。如果是公共文件,直接关联没问题;如果涉及权限和隐私,就需要注意文件粒度的访问控制。大多数网盘产品会选择在秒传的同时给文件增加一条“我拥有这个文件”的记录,在文件系统层面做去重,在业务层做归属隔离。
2.4 分片上传的流程设计:从初始化到合并
一次完整的分片上传,严格来说包含五个阶段:初始化、预检、分片上传、合并、清理。
初始化阶段,前端把文件名、文件大小、分片大小、文件MD5一并提交给后端。后端返回一个uploadId,这个ID贯穿整个上传流程,后续所有分片请求都要带上它。
预检阶段,后端根据uploadId检查该文件已经收到了哪些分片。这个阶段对断点续传至关重要。前端拿到已上传分片的列表,就能知道哪些分片需要补传。
分片上传阶段,前端逐个上传分片。每个分片请求带上uploadId、分片序号(chunkIndex)、分片内容。后端收到后写入临时目录或临时对象存储,并记录分片状态。
合并阶段,前端告知后端“所有分片都传完了”,后端检查分片完整性,然后按序号把分片合并成完整文件。合并完成后再做一次MD5校验,确认文件的完整性。
清理阶段,把临时分片数据删除,释放存储空间。这一步经常被忽略,但如果不做,临时目录会越来越大,磁盘很快被占满。
这五个阶段看起来多,其实每个阶段都很薄。一定不要把阶段合并或者简化掉,尤其是预检阶段,那是断点续传的命根子。
3. 实操过程与核心环节实现
3.1 前端整体架构:上传管理器
前端这部分我用一个UploadManager来管理整个上传流程,而不是把逻辑散落在各个组件里。原因很简单:大文件上传涉及并发控制、状态维护、事件回调,如果每个页面写一套,后期维护成本会很高。
下面是一个简化版的结构:
class UploadManager { constructor() { this.tasks = new Map(); // uploadId -> uploadTask this.concurrentLimit = 3; // 并发分片数 } addFile(file) { const fileMd5 = null; // 实际由2.2节方法先计算 // 创建上传任务 const task = new UploadTask(file); this.tasks.set(task.uploadId, task); task.onProgress = this.updateUI.bind(this); return task.start(); } updateUI(task) { // 计算总进度:已合并文件大小 + 已上传分片大小 const loaded = task.uploadedChunks * task.chunkSize; const total = task.file.size; task.progress = (loaded / total * 100).toFixed(2); } }这里的核心思想是,每一个上传任务内部维护自己的状态机:等待计算MD5、查询秒传、初始化上传、上传分片、合并文件、完成或失败。UI层只需要监听任务的状态变化来刷新进度条。
3.2 分片与并发控制:跑满带宽但不压垮服务端
并发分片的数量设计需要兼顾两端:前端希望尽可能多地并发请求以跑满带宽;后端的Tomcat线程池、网关连接数、数据库TPS又不可能无限扩大。
我在实际项目里把并发数设成了3。这个数字看起来不高,但对单个文件来说,3个并发分片请求已经能很充分地利用常规宽带的上行带宽。如果同时有多个文件在上传,总并发量还会叠加,所以把单文件并发控制得保守一点是合理的。
分片上传的代码逻辑如下:
async function uploadChunk(task, chunkIndex) { const chunk = task.file.slice( chunkIndex * task.chunkSize, Math.min(task.file.size, (chunkIndex + 1) * task.chunkSize) ); const formData = new FormData(); formData.append('uploadId', task.uploadId); formData.append('chunkIndex', chunkIndex); formData.append('file', chunk); const response = await fetch('/api/upload/chunk', { method: 'POST', body: formData }); return response.json(); } async function uploadChunksInBatches(task) { const totalChunks = Math.ceil(task.file.size / task.chunkSize); let uploadedIndexes = await queryUploadedChunks(task.uploadId); // 断点续传依赖这里 let index = 0; while (index < totalChunks) { const batch = []; while (batch.length < CONCURRENT_LIMIT && index < totalChunks) { if (!uploadedIndexes.includes(index)) { batch.push(uploadChunk(task, index).catch(err => { // 记录失败分片,稍后重试 task.failedChunks.push(index); })); } index++; } await Promise.all(batch); } }这里有两个细节值得一提。
第一,失败分片不要立刻退出去重传,而是放在一个failedChunks数组里。等这一批并发请求全部结束后,统一对失败分片做重试。这样可以避免某一片失败时,其他还在飞的请求被中断。
第二,查询已上传分片列表这一步一定要在开跑之前做。如果你先启动了一批分片请求再去查询,就会有多余的分片被重复上传,虽然后端可以做幂等,但白白浪费了带宽。
3.3 后端接口设计:初始化、分片上传、合并
后端我用Spring Boot为例,把三个核心接口的骨架和关键逻辑写清楚。
首先是初始化接口,前端提交文件名、大小、MD5,后端返回uploadId和已上传分片列表:
@PostMapping("/api/upload/init") public Result initUpload(@RequestBody InitRequest request) { // 1. 根据md5查询文件指纹表,命中则走秒传 FileRecord record = fileMapper.findByMd5(request.getMd5()); if (record != null) { return Result.success(InitResponse.quickUpload(record.getId())); } // 2. 未命中,生成uploadId,初始化上传状态 String uploadId = UUID.randomUUID().toString().replace("-", ""); uploadMapper.createUploadRecord(uploadId, request.getFileName(), request.getSize()); return Result.success(InitResponse.normalUpload(uploadId, Collections.emptyList())); }然后是分片上传接口。分片文件的存储路径由uploadId和分片序号共同决定,这样后续合并时可以直接按序号读取:
@PostMapping("/api/upload/chunk") public Result uploadChunk(@RequestParam("uploadId") String uploadId, @RequestParam("chunkIndex") int chunkIndex, @RequestParam("file") MultipartFile file) { String chunkPath = uploadDir + "/" + uploadId + "/" + chunkIndex + ".part"; File targetFile = new File(chunkPath); if (!targetFile.getParentFile().exists()) { targetFile.getParentFile().mkdirs(); } // 已存在说明之前传过,可以跳过 if (targetFile.exists()) { return Result.success(); } file.transferTo(targetFile); // 记录分片上传状态到数据库 uploadMapper.markChunkUploaded(uploadId, chunkIndex); return Result.success(); }最后是合并接口。合并前需要做一个完整性的校验,判断已上传的分片数量是否等于总应上传数量,然后再按序号合并:
@PostMapping("/api/upload/merge") public Result merge(@RequestParam("uploadId") String uploadId, @RequestParam("fileName") String fileName) { Integer uploadedCount = uploadMapper.countChunks(uploadId); Integer totalCount = uploadMapper.getTotalChunks(uploadId); if (!uploadedCount.equals(totalCount)) { return Result.error("分片不完整,已上传 " + uploadedCount + "/" + totalCount); } File mergedFile = new File(uploadDir, fileName); OutputStream out = new FileOutputStream(mergedFile); for (int i = 0; i < totalCount; i++) { File part = new File(uploadDir + "/" + uploadId + "/" + i + ".part"); Files.copy(part.toPath(), out); } out.close(); // 合并后做MD5校验,防止分片文件损坏 String mergedMd5 = DigestUtils.md5DigestAsHex(new FileInputStream(mergedFile)); if (!mergedMd5.equals(initMd5)) { return Result.error("文件校验失败"); } // 删除临时分片目录 deleteDirectory(uploadDir + "/" + uploadId); return Result.success(); }合并过程用的是简单的流复制。分片数量几百个以内时,这种方式完全够用,没必要引入复杂的工具。真正需要关注的是合并后的MD5校验,这是保障文件完整性的最后一道防线。
3.4 断点续传的完整状态落地方案
要支持真正的断点续传,光靠后端存储分片文件还不够。用户刷新页面后,前端的内存状态全部丢失,必须有一个地方能记录“这个文件传到一半了”。
我用的方案是localStorage加后端状态双重记录。前端在初始化上传时,把文件的md5、uploadId、分片信息写入localStorage。用户重新选择同一个文件时,前端先计算md5,然后去localStorage里找有没有未完成的任务记录。如果找到,直接用旧的uploadId发起预检,从后端拉取已上传分片列表,跳过已传分片继续上传。
这里有一个关键判断点:同一个文件的md5只能作为索引线索,不能当作uploadId。原因是可能有多个用户上传同一个文件,或者同一个用户并发选择了一个文件两次。每次都新建uploadId,避免任务之间互相污染。
后端也需要定期清理那些长时间未完成、且没有任何分片新上传的upload任务。不然数据库的记录会越积越多。我是用一个定时任务,扫描超过24小时没有新分片活动的uploadId,将其临时目录删除、数据库记录标记为过期,再定期清理过期数据。
4. 常见问题与排查技巧实录
4.1 分片乱序和重复提交
在实际操作中,分片上传接口被重复调用是常态。前端并发发起3个请求,其中某一个请求因为超时被前端判断为失败,但实际上后端可能已经写入了分片。当前端重试时,同一个分片就会被再次提交。
这个问题有两个解法,都要做。
第一个是后端的幂等处理。我在分片上传接口里,先检查目标分片文件是否已存在,存在就直接返回成功,不做重复写入。这个判断虽然简单,但能挡住绝大多数重复提交。
第二个是唯一索引约束。在数据库里给uploadId和chunkIndex加联合唯一索引,即使并发请求同时到达,数据库层面也能保证只有一条记录成功。我遇到过文件系统判断和写入之间出现并发间隙的情况,靠数据库约束兜底才稳下来。
4.2 秒传误判与文件损坏
秒传依赖MD5,而MD5理论上存在碰撞可能。虽然概率极低,但如果是对付费资料、合同文件这种不容有失的场景,我建议在秒传接口命中后,增加一个随机抽样校验的步骤:后端打开已存在的文件,读取前1KB、中间1KB、后1KB,和前端上传时记录的对应片段做比对。比对通过才允许秒传,不通过就走正常上传流程。
这个抽样校验的成本极低,但能把误判概率压到几乎为零。我并不是担心MD5碰撞,更多是为了防止之前上传成功但因为各种原因存储里的文件被部分写坏的情况。
4.3 进度条为什么会回跳
前端进度条回跳是最容易被用户察觉的体验问题。它通常发生在这种场景:分片A和分片B并发上传,分片A先完成并触发进度更新,此时进度条涨到了10%;随后分片B因网络问题失败,重试后再次上传,进度条又跌回8%。
处理办法是进度计算时只统计“后端确认已持久化”的分片数量。失败重试中的分片不计入已完成数量。这样进度条只会上涨或者停滞,绝不会回跳。
另一个细节是:合并阶段进度条要保持在95%或99%,而不是直接跳回0%。很多人在合并阶段忘记维护前端状态,导致后端合并时前端进度条清零,用户以为上传失败了。合并本身花费的时间可能不短,对GB级别的文件,合并操作也讲究效率。可以改用按序读流合并加缓冲写入的方式,或者并发合并,但这里不展开,我一般让前端在分片全部上传完毕后,就把进度条锁定在99%,等合并完成后再跳到100%。
4.4 服务端临时存储空间暴涨
分片全部存起来之后,在合并完成前,这些分片占据的磁盘空间总和相当于整个文件的大小。如果同时有大量用户在传大文件,服务端磁盘很快会被撑满。
我的经验是要设置双上限:
- 单个upload任务的分片总大小不能超过业务允许的最大文件体积,比如10GB;
- 所有未完成upload任务在磁盘上的占用总量设一个阈值,比如总磁盘的30%,超过后拒绝新的上传任务并提示用户稍后再试。
另外,一定要记得清理机制兜底。服务重启、用户关闭浏览器导致上传中断等场景,后端无感知,只能靠定时清理。我的清理频率是每小时一次,扫描超过2小时没有新分片写入的任务直接清理。
4.5 前后端时间与网络波动导致的长尾请求
最后一个常见问题是长尾请求。即使你设置3个并发分片,理论上总耗时应该接近总大小除以带宽,但实际上总会有某一个分片卡在TCP重传、网络切换这些坑里,导致整个批次等待它完成。
我加了一个每片超时控制。前端fetch请求设置超时时间,默认60秒。超过60秒未返回的分片直接判失败,放入失败队列重试。重试次数我设了3次,再失败就暂停整个任务,提示用户检查网络后点击继续。这个策略比无限重试要好得多,无限重试在弱网环境下会一直消耗设备电量和网络资源。
给分片请求设置超时还有一个附带的好处:用户体验上,失败反馈更快,而不是傻等一两分钟。
5. 进阶优化与扩展实践
5.1 文件合并的效率优化
上面提到的合并方案,在分片数量很大的时候会存在瓶颈。比如说1GB文件、5MB分片,需要合并200个分片,逐个打开和关闭文件流,会有很多不必要开销。改成BufferedOutputStream并加大缓冲区后,性能会有明显提升。对于分片数过千的场景,可以考虑先把小分片合并成若干中分片,再对中分片做二次合并,减少文件句柄的频繁开关。
我还有一个实践是,合并时实时计算整体文件的MD5,而不是合并完后再重新读一遍。这样少一次全文件IO,对GB级别文件的体验差异巨大。
5.2 后端存储选型:本地磁盘还是对象存储
如果项目部署在单机或自建机房,用本地磁盘存放临时分片没问题。合并后的文件可以再转移到另一个存储区域,或者直接交由后续业务使用。
如果项目在云上,我更建议把分片直接放到对象存储里,比如OSS、COS这一类的产品。对象存储原生支持分片上传,服务端只需作为调度中心,把前端的分片请求转换成对对象存储的Part上传请求,最后由服务端触发合并。这种架构的好处是:
- 临时分片不占应用服务器磁盘;
- 合并由对象存储内部完成,不占应用服务器带宽和CPU;
- 天然支持跨地域容灾。
当然,引入对象存储之后,上传接口的超时逻辑、签名机制、权限模型都会变复杂,需要团队对基础设施的掌控力更强。根据团队规模选型,不建议为了用对象存储而强行改架构。
5.3 任务恢复与多端同步
如果产品需要支持“在A设备上传了一半,换B设备继续传”,localStorage的方案就不够了。需要把上传任务状态同步到服务端,并且让用户能够在另一个设备上通过文件列表找回未完成任务。
我在项目里是这么做的:用户登录状态下,初始化上传时把uploadId、文件md5、文件名、大小、总分片数、已上传分片列表持久化到服务端。换设备后,用户选择同一文件,前端计算md5后先调服务端的任务恢复接口,找到未完成任务并拿到已上传分片列表,然后直接继续上传。
这里有个细节值得注意:弹窗提示用户恢复之前的上传进度,是把“选择文件”这个动作从重新开始变成了接续执行。这个交互设计对用户体验的提升非常明显,用户会直观地感受到断点续传的价值。
5.4 上传限速与流量控制
某些场景下,上传模块不能占满用户所有上行带宽。比如用户在办公网络中同时需要视频会议,如果上传任务占满了带宽,会议体验会很差。
我实现过一版基于令牌桶的限速逻辑。上传管理器维护一个令牌桶,每个分片上传前先获取令牌,令牌按照设定的速率生成。分片大小除以令牌生成速率,就大致等于期望的上传速率。
限制上传速度的代码大概长这样:
class TokenBucket { constructor(ratePerSecond, capacity) { this.ratePerSecond = ratePerSecond; this.capacity = capacity; this.tokens = capacity; this.lastFilled = Date.now(); } take() { this.refill(); if (this.tokens >= 1) { this.tokens -= 1; return true; } return false; } refill() { const now = Date.now(); const elapsed = (now - this.lastFilled) / 1000; this.tokens = Math.min(this.capacity, this.tokens + elapsed * this.ratePerSecond); this.lastFilled = now; } }上传每个分片前,如果拿不到令牌就等待一段时间再试。把ratePerSecond设置为分片大小除以期望速率,就能把实际上传速率控制在期望值附近。实测下来流量曲线平稳,适合对网络占用有要求的办公场景。
5.5 上传完后的处理链
上传成功不等于业务完成。文件合并落盘后,通常还需要接一堆后续动作:病毒扫描、文件格式识别、缩略图生成、内容审核、归档转储等。我建议把合并完成后的动作都放到消息队列里异步处理,不要让前端请求卡在合并接口里等待全部完成。
这样接口响应更快,而且一旦后续处理失败可以单独重试,不会影响已上传成功的文件记录。我最初实现时是在合并接口里同步生成缩略图,遇到超大视频文件时,合并接口要跑几十秒才返回,前端差点被用户骂死。改成异步之后整个世界清净了。
收个尾:这套方案的坑与体会
做了几年大文件上传,我的最大体会是:技术上真没什么高深莫测的东西,分片、MD5、合并、去重,每个点单独拿出来都不难。难点在于把这些环节串成一个完整闭环,并且把每一个边界情况都考虑进去。
有几个点我要单独拎出来再强调一遍。MD5计算必须分块读,避免大文件内存溢出;秒传必须按内容标识而不是文件名;分片接口后端必须幂等;合并后必须校验MD5;服务端必须有清理机制;前端必须管理好并发和超时;进度条必须基于已确认的分片数。这些点里面随便漏掉一个,方案都会在某个场景下出问题。
另外,我建议做这套功能时,先从固定分片大小、固定并发数、单机的磁盘存储开始,跑通全链路后再考虑对象存储、动态分片、多端同步这些增强能力。一上来就把架构铺得很大,往往会在调试时被各种外部因素干扰,很难判断问题到底出在前端还是后端,还是网络。先小步快跑,把核心链路做实,再逐步叠加能力,这才是稳妥的落地路线。
如果你也正准备做或者正在做大文件上传,希望这篇文章能帮你少踩几个坑。尤其是断点续传那一节,做好状态持久化,用户的体验会有质的飞越。