资讯详情

Android下载文件完整性校验:SHA-256、断点续传与分片实践

📅 2026/10/21 19:29:30 | 华诺云谱 👁 阅读
Android下载文件完整性校验:SHA-256、断点续传与分片实践
Android面试里有一类问题表面问的是某个功能怎么做实际是在考你从网络、IO到异常处理的一整条链路。“如何保证下载文件的完整性”就是典型代表。第一次被问到的时候大多数人的第一反应是“算MD5”然后面试官追问一句“如果文件有2GB呢”“如果下载到一半断网了呢”很多人就卡住了。这篇内容就把这道面试题从头到尾拆一遍不仅讲清楚校验算法怎么选、为什么这样选还会给出可以直接用的Android实现方案覆盖断点续传、分片下载这些真实场景。适合正在准备面试的开发者也适合在做下载模块、版本更新、WebView离线包、模型文件下发的人参考。1. 面试官到底在问什么拆解“下载文件完整性”这道复合题1.1 先搞清楚TCP可靠不代表文件完整很多人会把“下载文件完整性”理解成网络问题觉得HTTP基于TCPTCP本身有校验和、有重传机制数据到了应用层一定是正确的。但实际工程里“理论上可靠”和“实际上完整”之间隔着很大的距离。中间代理或网关可能改写响应体CDN节点可能缓存了一个损坏版本运营商在某些网络环境下做内容注入时偶尔会破坏非文本资源服务端发布流程可能把旧文件和新哈希说明一起上线本地下载到一半进程被杀、磁盘写满、文件被其他进程占用都会让临时文件截断或写坏。这些场景下TCP层看到的都是正常连接但应用层拿到的文件已经不是目标文件。所以完整性问题本质上不是网络协议层面的问题而是应用层必须自己兜底的问题。这道题在Android面试里高频出现还有一个现实背景Android应用下载APK安装包、应用内资源包、WebView离线包、模型文件、日志上报附件几乎都会遇到文件损坏导致的“解析包错误”“解压失败”“加载异常”。面试官问这道题想知道的不是你会不会背某个API而是你有没有真的在Android端处理过完整的下载链路。1.2 面试官的追问路线图这道题没有标准答案但回答的分层基本决定了面试官对你的判断。我整理了一张追问路线图你可以对照自测第一层问题常见追问实际考察点怎么保证文件完整用什么算法为什么选它是否知道哈希校验是标准做法而不是靠文件大小直接算MD5文件有2GB怎么办有没有大文件处理经验懂不懂流式计算和OOM风险下载到一半断网续传后怎么校验状态管理、HTTP Range、失败恢复能力多线程分片下载分片之间怎么保证最后文件正确并发控制、分片合并、哈希一致性校验失败怎么办重试几次要不要删文件工程思维、线上问题处理、日志上报意识一个比较完整的回答链路应该是先说清楚为什么要校验再讲SHA-256是当前推荐方案然后重点说明下载过程中用流式哈希边下边算避免二次读文件接着根据面试官追问展开断点续传和分片下载场景下的处理方式最后提一下失败后的重试、删除临时文件、日志记录这些工程细节。能讲到最后一层基本就过关了。1.3 哈希校验是“标准答案”但不够面试中如果直接回答“用MD5校验”不能说错但显得单薄。因为面试官马上会追问MD5有碰撞攻击风险为什么还在用大文件怎么避免OOM哈希在哪个环节计算服务端的哈希从哪里来这其实是在考察你是不是只知道结论不知道原理。接下来的内容我会把算法选型、原理、代码实现、边界场景全部展开。2. 校验算法怎么选MD5、SHA-256、CRC32的底层差别2.1 三种常见算法的对比面试时一旦说到“文件校验”必然要提算法。最常被提及的是CRC32、MD5、SHA-1、SHA-256。很多人只知道MD5其实在真实项目里选型要看安全性和性能的平衡。算法摘要长度计算速度安全性典型场景CRC3232位最快弱容易碰撞ZIP、PNG内部校验网络传输检错不防恶意篡改MD5128位快已能构造碰撞不适合安全对抗场景旧系统、非对抗环境的简单完整性校验SHA-1160位较快已被碰撞攻击弱化老版本协议兼容SHA-256256位Android上足够快目前推荐下载文件校验、签名校验、安全敏感场景从Android实际开发角度看我推荐默认使用SHA-256。一方面Android标准库原生支持MessageDigest.getInstance(SHA-256)不需要引入额外依赖另一方面现代手机CPU计算SHA-256的速度可以轻松达到每秒数百MB对一个几十MB到几百MB的下载文件来说增加的耗时几乎可以忽略。CRC32虽然快但只有32位碰撞概率大到不适用于文件完整性校验MD5在普通场景下其实还能用但如果面试官故意往“安全性”方向引你要能说出MD5的碰撞攻击背景以及为什么生产环境要逐渐迁移到SHA-256。2.2 为什么哈希一致就能代表文件完整哈希函数可以把任意长度的数据映射成一个固定长度的摘要而且有“雪崩效应”——原始内容哪怕只改一个字节摘要也会面目全非。所以两个文件的哈希一致基本可以认定内容一致。这里的“基本”是概率意义上的。SHA-256输出256位理论上要尝试约2^128次才能找到一次碰撞这个数量级在实际工程中可以当成不可能。面试时如果你能补充一句“哈希校验是概率保证不是绝对保证但SHA-256的碰撞概率低到工程上可以忽略”会显得你理解得更深。要注意哈希覆盖的是文件内容本身不包括文件名、修改时间这些元数据。所以校验时必须保证服务端和客户端算的是同一个东西——比如服务端给的是APK文件的SHA-256客户端就必须算APK文件的SHA-256而不是算包了一层zip压缩包或签名后另一个文件的哈希。2.3 生产环境的哈希从哪里来面试聊到实现时经常忽略一个问题客户端拿什么作为期望哈希实际上生产环境常见有三种来源独立接口返回下载前先请求一个元数据接口拿到文件地址、大小、SHA-256。下载接口响应头服务端在HTTP响应头里带自定义字段比如X-Checksum-Sha256CDN场景经常这么干。版本清单文件OTA升级、资源包更新时manifest文件里针对每个文件都附上哈希。这里有一个我在项目里踩过的坑哈希必须和最终可分发产物绑定。如果服务端在构建流水线里先算哈希之后又对文件做了一次签名、压缩、转存那下发的哈希和用户实际下载到的文件就对不上。这个问题后面在实操避坑部分会再展开。3. Android端完整实现下载过程中同步计算SHA-2563.1 整体流程设计Android端实现“下载文件并保证完整性”核心流程并不复杂获取文件下载地址和期望哈希。创建临时文件比如xxx.apk.tmp。建立HTTP连接拿到输入流。边读取输入流、边写入临时文件、边更新SHA-256摘要。下载完成后把摘要转成十六进制字符串。与服务端下发的期望哈希比对。一致把临时文件重命名为正式文件不一致删除临时文件走重试或上报逻辑。为什么要先写临时文件再重命名因为校验失败的场景下不能把一个只下载了一半的文件直接暴露给业务方否则下次启动可能读取到坏文件导致更隐蔽的崩溃。临时文件加上最终rename可以保证正式目录里只出现完整文件。需要注意的是rename在同一个文件系统内是原子操作但如果临时文件和目标文件不在同一分区renameTo可能失败。代码里要兜底用copy加delete的方式处理。3.2 核心代码边下载边计算哈希下面是一个可以直接用的Kotlin实现。关键点不是代码本身而是理解为什么用MessageDigest.update做增量计算。fun downloadAndVerify( url: String, expectedHash: String?, targetFile: File, algorithm: String SHA-256, onProgress: (downloaded: Long, total: Long?) - Unit { _, _ - } ): Boolean { val tmpFile File(targetFile.parentFile, targetFile.name .tmp) tmpFile.parentFile?.mkdirs() if (tmpFile.exists()) tmpFile.delete() var connection: HttpURLConnection? null try { connection (URL(url).openConnection() as HttpURLConnection).apply { connectTimeout 15_000 readTimeout 30_000 instanceFollowRedirects true } if (connection.responseCode !in 200..299) { return false } val total connection.contentLengthLong val digest MessageDigest.getInstance(algorithm) connection.inputStream.use { input - FileOutputStream(tmpFile).use { output - val buffer ByteArray(64 * 1024) var downloaded 0L while (true) { val read input.read(buffer) if (read -1) break digest.update(buffer, 0, read) output.write(buffer, 0, read) downloaded read onProgress(downloaded, if (total 0) total else null) } } } val actualHash digest.digest().toHex() if (!expectedHash.isNullOrBlank()) { val expected try { expectedHash.hexToByteArray() } catch (e: IllegalArgumentException) { tmpFile.delete() return false } val actual actualHash.hexToByteArray() if (!MessageDigest.isEqual(expected, actual)) { tmpFile.delete() return false } } return if (tmpFile.renameTo(targetFile)) { true } else { tmpFile.copyTo(targetFile, overwrite true) tmpFile.delete() true } } catch (e: Exception) { tmpFile.delete() return false } finally { connection?.disconnect() } } fun ByteArray.toHex(): String joinToString() { %02x.format(it) } fun String.hexToByteArray(): ByteArray { require(length % 2 0) { Invalid hex string } return ByteArray(length / 2) { ((Character.digit(this[it * 2], 16) shl 4) Character.digit(this[it * 2 1], 16)).toByte() } }这段代码里有几个值得展开的点。缓冲区大小选64KB是我实测下来比较均衡的值。16KB会让IO调用更频繁1MB对大文件不算什么但每个下载任务都开1MB数组内存压力会变大。下载是IO密集型操作缓冲区大小对速度有影响但不至于成为瓶颈关键是不要用单字节循环读取。digest.update(buffer, 0, read)是核心。它把每次读到的数据块喂给MessageDigest这样哈希计算是随着下载进度一起完成的不需要下载完再重新读一遍文件。对于2GB的文件如果先下载完再单独算哈希意味着磁盘要再读2GB耗时和电量都翻倍边下边算只增加CPU运算几乎不影响下载速度。面试时能主动说出这个理由基本能打动面试官。MessageDigest.isEqual是常量时间比较可以避免普通字符串比较可能带来的时间侧信道问题。虽然下载文件校验场景的实际攻击面不大但这是一个体现细节功底的加分点。它不是绝对保险因为MessageDigest.isEqual在长度不一致时返回更快但对64字节的哈希来说长度是固定的可以放心用。3.3 用DigestInputStream写简化版除了手动调用digest.updateJava还提供了DigestInputStream可以在读取流时自动更新摘要。实现会更简洁。fun downloadWithDigestInputStream( url: String, targetFile: File ): String { val connection URL(url).openConnection() as HttpURLConnection val digest MessageDigest.getInstance(SHA-256) val digestInput DigestInputStream(connection.inputStream, digest) FileOutputStream(targetFile).use { output - val buffer ByteArray(64 * 1024) while (true) { val read digestInput.read(buffer) if (read -1) break output.write(buffer, 0, read) } } // 注意这里才取摘要必须在流读完之后 return digest.digest().toHex() }使用DigestInputStream时有一个容易踩的坑digest.digest()必须在整个输入流读完并关闭之后调用否则摘要不完整。还有一个细节是digest()调用之后MessageDigest会被复位如果你想接着用同一个对象计算下一个文件的哈希必须重新调用reset()或者重新创建一个新实例。实际项目里我倾向直接新建实例避免状态被共享。3.4 校验失败之后怎么办校验失败不只是一个布尔值还要考虑后续动作。我的经验是至少做到三点第一重试要有策略。直接无限重试会浪费带宽指数退避是常见做法比如第一次等1秒、第二次等2秒、第三次等4秒最多重试3次。如果连续多次失败应该放弃并提示用户检查网络而不是让下载任务一直挂在后台。第二日志信息要足够。失败时至少记录期望哈希、实际哈希、文件大小、下载耗时、异常类型、业务版本号。没有这些信息线上出现“下载失败”反馈时你根本没法定位是网络问题、服务端问题还是代码问题。第三临时文件清理要及时。校验失败后如果留着xxx.apk.tmp下次下载前必须删掉否则可能出现临时文件堆积占用存储空间甚至被其他模块误读。4. 断点续传与分片下载场景下的校验设计4.1 断点续传时MessageDigest状态无法保存面试官如果在流式计算哈希后继续追问“下载一半断网了怎么办”就进入断点续传的话题。很多人的第一反应是“那就在断点处继续下载然后接着算哈希”如果只停留在这个层面会踩一个隐蔽的坑MessageDigest是内存对象进程被系统杀掉之后它的中间状态就丢了。即使用户重新打开App你在代码里也不可能把上次那个“算到一半的摘要状态”原样恢复出来。所以断点续传下的校验策略一般有两个方向。方向一简单可靠断点续传时只保证“数据不重不漏”地继续写入临时文件等整个文件全部下载完成后再对临时文件做一次整体哈希比对。这个方案代码简单、逻辑清晰缺点是要重新完整读一遍磁盘上的文件。对几百MB以内的APK来说这个额外耗时通常可以接受。方向二分块校验把文件按固定大小切块比如每1MB一块下载完一块就算一次哈希并保存到本地状态文件里。续传时只需要从断点块继续已经校验过的块哈希列表可以作为进度依据。如果服务端也提供分块哈希表还可以精确判断是哪一个块损坏损坏了就只重下那一个块。这个方案适合体量很大、弱网环境频繁中断的场景。我在实际项目里是这样取舍的文件不超过500MB且网络相对稳定的场景用方向一文件几个GB、需要长时间下载的场景用方向二。面试时能把这两个方向讲清楚比只说“支持断点续传”要加分得多。4.2 多线程分片下载的校验到底怎么做多线程分片下载是另一个高频追问点。常见的套路是把文件切成多个区间每个线程通过HTTP Range请求下载一个区间再通过RandomAccessFile按offset写入同一个临时文件。这里有个很多人不知道的坑多个线程不能共用一个MessageDigest来算整包哈希。原因有两点第一MessageDigest不是线程安全的第二即使你用锁保护各线程读到的数据块到达共享摘要的先后顺序取决于CPU调度并不是文件中的自然顺序。也就是说线程A下载了文件头部线程B下载了文件尾部谁能先抢到锁把数据喂给MessageDigest完全不确定最后算出来的摘要自然不等于整包按顺序哈希。那分片下载怎么保证最终文件完整我的推荐做法是每个分片线程用独立的MessageDigest只计算自己那个分片区间的哈希。如果服务端提供分片哈希表每下载完一个分片就立即比对分片哈希不一致就只重下这个分片。所有分片完成后对合并后的完整文件重新做一次整体SHA-256与整包期望哈希比对。这里要特别强调一个原则千万不要把各分片的哈希再哈希一次当成整包哈希。分片哈希列表只是定位坏块的索引不等于整包内容的摘要。想要得到整包哈希必须基于完整文件重新计算。下面是一个简化版的分片下载骨架重点看校验思路fun downloadWithParts( url: String, targetFile: File, totalSize: Long, threadCount: Int, expectedWholeFileSha256: String ): Boolean { val tmpFile File(targetFile.parentFile, targetFile.name .part) RandomAccessFile(tmpFile, rw).use { raf - raf.setLength(totalSize) } val partSize totalSize / threadCount val threadList mutableListOfThread() for (i in 0 until threadCount) { val start i * partSize val end if (i threadCount - 1) totalSize - 1 else (i 1) * partSize - 1 threadList.add(Thread { try { val conn URL(url).openConnection() as HttpURLConnection conn.setRequestProperty(Range, bytes$start-$end) if (conn.responseCode 206) { conn.inputStream.use { input - RandomAccessFile(tmpFile, rw).use { raf - raf.seek(start) val buffer ByteArray(32 * 1024) while (true) { val read input.read(buffer) if (read -1) break raf.write(buffer, 0, read) } } } } } catch (_: Exception) { // 分片失败需要记录状态并重试 } }) } threadList.forEach { it.start() } threadList.forEach { it.join() } // 所有分片完成后必须对完整文件重新算一次整体哈希 val actual sha256OfFile(tmpFile) if (actual.equals(expectedWholeFileSha256, ignoreCase true)) { return tmpFile.renameTo(targetFile) } tmpFile.delete() return false } fun sha256OfFile(file: File): String { val digest MessageDigest.getInstance(SHA-256) FileInputStream(file).use { input - val buffer ByteArray(64 * 1024) while (true) { val read input.read(buffer) if (read -1) break digest.update(buffer, 0, read) } } return digest.digest().toHex() }这段代码故意省略了分片失败重试、进度回调、线程池管理是为了让你看清校验设计分片下载过程只负责把数据写到正确位置真正的完整性确认发生在所有分片完成之后对完整文件再做一次整体哈希。面试中如果能画出这个流程并且主动说出“不能共享MessageDigest”和“分片哈希再哈希不等于整包哈希”这两个点已经远超平均水平。4.3 HTTP层和TLS层能帮忙到什么程度有人会问HTTPS都用了数据不是安全的吗为什么还需要应用层哈希HTTPS解决的是传输过程中第三方窃听、篡改的问题它保护的是通道不是内容本身。源站文件本身坏了、CDN节点缓存了脏数据、本地磁盘写入失败这些TLS都管不了。HTTP响应头里的Content-Length只能表示服务端声称的字节数不能证明收到的内容字节完全正确ETag通常代表资源版本适合做缓存协商但不能替代内容校验。所以在面试里正确的说法是TLS和HTTP是传输层辅助手段应用层哈希校验是最后一道兜底两者是互补关系不是替代关系。5. 实战避坑校验失败、OOM、断点异常的排查速查5.1 哈希对不上但从日志里看不出问题这是我在维护下载模块时遇到最多的一类问题。文件大小对、请求也返回200、下载过程中没有异常但最终哈希就是不一致。排查思路通常分几步先确认期望哈希的来源是否可靠。是不是服务端在文件签名、压缩之前就算好了哈希是不是CDN缓存了旧文件但元数据接口返回的是新哈希这些在代码里查不出来要去找发布链路核对。再确认客户端算哈希的对象是不是用户最终要用的文件。有时候重命名逻辑有误校验时读的是临时文件正式文件是另外一份或者多个下载任务写了同一个临时文件导致内容被覆盖。最后确认字符串转换是否一致。期望哈希可能是Base64编码客户端却按Hex解析可能是大写客户端比较时没忽略大小写可能带sha256:前缀直接拿去hexToByteArray就抛异常。这些看似低级的问题在真实项目里出现的频率一点都不低。5.2 大文件下载和校验阶段的OOMOOM最常见的写法是用InputStream.readBytes()把整个响应体读进内存再转成ByteArray去算哈希或者用ByteArrayOutputStream把所有分片先攒在内存里。文件一大必然OOM。正确的做法就是前面代码里的流式处理固定大小缓冲区加上MessageDigest.update整个过程内存占用基本是个常数不随文件大小增长。面试时可以引申一句“下载模块的内存占用应该是O(1)级的除了缓冲区不应该出现O(n)级的数据结构。”这句话会很有分量。5.3 read和digest的几个常见误用第一不要用单字节读取。InputStream.read()每次返回一个字节对一个几十MB的文件来说会产生几十万次IO调用性能极差。一定要用带缓冲区数组的read(buffer)。第二不要重复调用digest.digest()。第一次调用会返回最终摘要并复位MessageDigest第二次调用会得到一个空摘要或错误摘要。如果发现“第一次哈希对第二次哈希不对”多半是这个问题。第三使用DigestInputStream时如果没有把流读完就提前跳出循环摘要自然不完整。比如下载过程中用户取消任务你直接关闭了流但没有重新计算已写入文件的哈希就会留下一个半截状态。5.4 断点续传成功但整体校验失败这种情况常见于服务端对Range请求的实现不标准。正常逻辑是服务端返回206 Partial Content响应体从start字节开始。但有些服务端或代理可能忽略Range头直接返回200 OK和整个文件。客户端如果没判断responseCode 206就会把整个文件内容又从临时文件开头写入一遍数据直接错位整体哈希必然失败。所以断点续传代码里必须检查响应码。如果请求了Range但返回的是200要么重新从零开始下载要么明确拒绝继续。另一个细节是续传前要确认本地临时文件的长度和本地记录一致。如果临时文件被系统清理过或上次下载根本没有成功写入盲目从某个Offset继续会得到错乱数据。5.5 常见问题速查表症状可能原因处理建议文件大小正确但哈希不一致中间代理篡改、CDN脏缓存、源文件本身损坏记录期望哈希与实际哈希联系服务端核对发布链路下载过程OOM一次性读取整个文件到内存固定缓冲区 MessageDigest增量计算哈希比对总失败期望哈希格式不对、大小写不一致、带前缀统一Hex格式并忽略大小写解析前做格式校验断点续传后数据错乱服务端忽略Range请求返回200检查响应码非206时重新下载重命名失败跨分区、目标目录不存在、权限不足copy delete兜底提前mkdirs校验通过但文件无法使用哈希计算对象与最终文件不一致核对临时文件名、重命名时机、多任务并发写冲突5.6 Android工程层面的三个额外建议第一下载和校验一定要放到后台线程。MessageDigest和文件IO都是耗时操作放到主线程会卡UI严重的直接ANR。Kotlin协程或者线程池都能做重点是要保证取消下载时能够正确关闭流。第二能用WorkManager就不用自建Service。版本更新、资源包下载这种任务天然适合WorkManager它能在网络恢复后自动重试还能约束充电状态下下载。你只需要在Worker里复用上面的下载校验函数。第三如果文件要写入公共下载目录比如系统Downloads就不能直接操作File而是要通过ContentResolver插入并写入。这种情况下哈希计算仍然基于流只要把写入目标从FileOutputStream换成contentResolver.openOutputStream(uri)校验逻辑不用改。最后分享一个真实踩过的坑我在做一个应用内版本更新模块时线上反馈说“安装包解析失败”。排查日志发现实际下载的APK大小完全正常期望SHA-256和实际SHA-256就是不一致。一开始怀疑网络运营商注入后来拿着两个哈希去问服务端团队才发现发布流水线对最终APK做了渠道签名而元数据接口里下发的哈希是在签名之前算出来的两个文件内容当然不一样。从那以后我定了一条规矩哈希必须在最终可分发产物上计算计算和发布必须在同一环节完成客户端校验失败时日志里必须记录期望哈希、实际哈希、版本号、渠道这些信息否则线上问题只能靠猜。这道面试题看着像八股文但把原理和工程细节吃透之后你会发现它和真实项目里的下载模块强相关。面试时如果能从“算MD5”进阶到“SHA-256增量计算、断点续传状态管理、分片下载的哈希一致性、失败日志可观测”就已经是一个有深度、有实战感的回答。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑