You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.11 21:13:11