使用OkHttp下载大文件调用Kotlin copyTo()触发OutOfMemoryError求替代方案
解决OkHttp下载大文件时的OutOfMemoryError问题
问题原因
你的代码中使用Kotlin默认的copyTo()方法处理大文件下载时,虽然表面是流式写入,但结合OkHttp的流包装(比如Chucker的TeeSource)和默认缓冲区的使用,容易造成内存碎片——即使总空闲内存足够,也会因为无法分配连续的小内存块触发OOM。另外直接使用byteStream()会增加流的包装层级,不如OkHttp底层的OkIO API高效。
改进方案
方案1:使用OkIO原生API(推荐)
OkHttp底层基于OkIO实现,直接用OkIO的Source和Sink可以减少流的包装开销,更高效地处理大文件,同时精准控制内存使用:
override suspend fun download( url: String, targetFile: File, onProgressChanged: (Int) -> Unit ) { if (!targetFile.exists()) { targetFile.parentFile?.mkdirs() } // 确保响应体和流都被正确关闭 okHttpClient.newCall( Request.Builder() .get() .url(url) .build() ).execute().use { response -> response.body?.use { body -> val totalBytes = body.contentLength() var downloadedBytes = 0L // 初始化文件写入流 targetFile.sink().buffer().use { sink -> body.source().use { source -> val buffer = Buffer() // 每次读取8KB数据,可根据设备内存调整 while (source.read(buffer, 8192) != -1) { sink.write(buffer) downloadedBytes += buffer.size buffer.clear() // 计算并回调进度,避免频繁触发UI更新 val progress = ((downloadedBytes * 100) / totalBytes).toInt() onProgressChanged(progress) } sink.flush() } } } ?: throw IOException("响应体为空") } }
方案2:自定义缓冲区的流式写入
如果不想切换到OkIO,可以手动指定copyTo()的缓冲区大小,同时加入进度追踪,避免默认实现的潜在内存问题:
override suspend fun download( url: String, targetFile: File, onProgressChanged: (Int) -> Unit ) { if (!targetFile.exists()) { targetFile.parentFile?.mkdirs() } okHttpClient.newCall( Request.Builder() .get() .url(url) .build() ).execute().use { response -> response.body?.let { body -> val totalBytes = body.contentLength() startDownload(body.byteStream(), targetFile, totalBytes, onProgressChanged) } ?: throw IOException("响应体为空") } } private suspend fun startDownload( source: InputStream, targetFile: File, totalBytes: Long, onProgressChanged: (Int) -> Unit ) = withContext(Dispatchers.IO) { source.use { ins -> targetFile.outputStream().use { out -> val buffer = ByteArray(8192) // 固定8KB缓冲区,平衡速度和内存占用 var bytesRead: Int var downloadedBytes = 0L while (ins.read(buffer).also { bytesRead = it } != -1) { out.write(buffer, 0, bytesRead) downloadedBytes += bytesRead val progress = ((downloadedBytes * 100) / totalBytes).toInt() onProgressChanged(progress) } out.flush() } } }
额外优化建议
- 进度回调如果需要更新UI,要切换到
Dispatchers.Main执行,避免在IO线程操作UI:withContext(Dispatchers.Main) { onProgressChanged(progress) } - 调试阶段可以暂时关闭Chucker拦截器,它的
TeeSource会复制一份响应数据,可能增加内存压力; - 对于超大文件(>1GB),可以考虑分块下载(断点续传),但需要服务器支持Range请求,实现复杂度更高;
- 确保下载任务在用户离开页面时能及时取消,避免不必要的内存占用。
内容的提问来源于stack exchange,提问作者grgg7
相关产品推荐
相关产品推荐

