Android面试里有一类问题,表面问的是某个功能怎么做,实际是在考你从网络、IO到异常处理的一整条链路。“如何保证下载文件的完整性”就是典型代表。第一次被问到的时候,大多数人的第一反应是“算MD5”,然后面试官追问一句“如果文件有2GB呢”“如果下载到一半断网了呢”,很多人就卡住了。这篇内容就把这道面试题从头到尾拆一遍,不仅讲清楚校验算法怎么选、为什么这样选,还会给出可以直接用的Android实现方案,覆盖断点续传、分片下载这些真实场景。适合正在准备面试的开发者,也适合在做下载模块、版本更新、WebView离线包、模型文件下发的人参考。
1. 面试官到底在问什么:拆解“下载文件完整性”这道复合题
1.1 先搞清楚:TCP可靠不代表文件完整
很多人会把“下载文件完整性”理解成网络问题,觉得HTTP基于TCP,TCP本身有校验和、有重传机制,数据到了应用层一定是正确的。但实际工程里,“理论上可靠”和“实际上完整”之间隔着很大的距离。
中间代理或网关可能改写响应体,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,其实在真实项目里选型要看安全性和性能的平衡。
| 算法 | 摘要长度 | 计算速度 | 安全性 | 典型场景 |
|---|---|---|---|---|
| CRC32 | 32位 | 最快 | 弱,容易碰撞 | ZIP、PNG内部校验,网络传输检错,不防恶意篡改 |
| MD5 | 128位 | 快 | 已能构造碰撞,不适合安全对抗场景 | 旧系统、非对抗环境的简单完整性校验 |
| SHA-1 | 160位 | 较快 | 已被碰撞攻击弱化 | 老版本协议兼容 |
| SHA-256 | 256位 | 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-Sha256,CDN场景经常这么干。 - 版本清单文件:OTA升级、资源包更新时,manifest文件里针对每个文件都附上哈希。
这里有一个我在项目里踩过的坑:哈希必须和最终可分发产物绑定。如果服务端在构建流水线里先算哈希,之后又对文件做了一次签名、压缩、转存,那下发的哈希和用户实际下载到的文件就对不上。这个问题后面在实操避坑部分会再展开。
3. Android端完整实现:下载过程中同步计算SHA-256
3.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.update,Java还提供了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 = mutableListOf<Thread>() 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 大文件下载和校验阶段的OOM
OOM最常见的写法是:用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增量计算、断点续传状态管理、分片下载的哈希一致性、失败日志可观测”,就已经是一个有深度、有实战感的回答。