Android 网络实战:文件上传下载、断点续传、弱网优化与网络监控

15 阅读14分钟

Android 网络深度系列 · 第 6 篇

系列导航:第1篇:HTTP 协议全解 | 第2篇:HTTPS 与网络安全 | 第3篇:OkHttp 架构剖析 | 第4篇:Retrofit 原理与实战 | 第5篇:WebSocket 与长连接 | 第6篇:网络实战场景

前言

前5篇我们聊了 HTTP 协议、HTTPS 安全、OkHttp 架构、Retrofit 封装和 WebSocket 长连接。理论够了,这篇全是"用起来"的东西--文件上传(压缩、分片、秒传、断点续传)、文件下载(断点续传、流式写入、多线程)、弱网优化、网络监控。面试高频,工作天天用,每个场景配完整 Kotlin 代码。


1. 文件上传

1.1 Multipart 上传原理

文件上传底层用的是 multipart/form-data,它本质上是把表单字段和文件拼在一个请求体里,用 boundary 分隔不同部分。

报文结构示例:

POST /upload HTTP/1.1
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW
​
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="description"
​
用户头像
------WebKitFormBoundary7MA4YWxkTrZu0gW
Content-Disposition: form-data; name="avatar"; filename="photo.jpg"
Content-Type: image/jpeg
​
[binary data...]
------WebKitFormBoundary7MA4YWxkTrZu0gW--

每个 part 有自己的 Content-Disposition 头和 Content-Type,boundary 由客户端随机生成,保证不跟正文数据冲突。服务端收到请求后根据 boundary 切分各 part,分别解析字段值和文件数据。

OkHttp 构建 MultipartBody:

val fileBody = file.asRequestBody("image/jpeg".toMediaType())
val requestBody = MultipartBody.Builder()
    .setType(MultipartBody.FORM)  // 必须设,否则默认是 multipart/mixed
    .addFormDataPart("description", "用户头像")
    .addFormDataPart("avatar", "photo.jpg", fileBody)
    .build()
​
val request = Request.Builder()
    .url("https://api.example.com/upload")
    .post(requestBody)
    .build()

Retrofit 注解方式:

interface UploadService {
    @Multipart
    @POST("upload")
    suspend fun uploadImage(
        @Part description: MultipartBody.Part,
        @Part image: MultipartBody.Part
    ): Response<UploadResult>
}
​
// 调用
val descPart = MultipartBody.Part.createFormData("description", "用户头像")
val imgPart = MultipartBody.Part.createFormData(
    "avatar", "photo.jpg",
    file.asRequestBody("image/jpeg".toMediaType())
)
val result = uploadService.uploadImage(descPart, imgPart)

@Multipart 告诉 Retrofit 用 multipart/form-data 编码,每个 @Part 参数对应一个 part。注意 MultipartBody.Part.createFormData() 的第三个参数是可选的--不传则只发文本字段。


1.2 上传前预处理:图片压缩

图片压缩是上传前的预处理步骤--不压缩直接上传原图是新手常见错误。一张 12MP 照片 3-5MB,按流量收费的移动端必须压缩。

质量压缩 vs 尺寸压缩:

维度质量压缩尺寸压缩
原理降低编码质量,保留像素数缩小宽高,减少像素数
Bitmap 尺寸不变变小
适合场景上传到服务器后还要原尺寸展示缩略图、头像
压缩极限压缩到 quality=0 仍有 ~100KB可以压到几 KB

实战策略:先缩尺寸,再压质量。 先保证宽高不超过 1920px(常见屏幕上限),再用质量压缩控制文件大小。

完整压缩函数:

suspend fun compressImage(
    context: Context,
    uri: Uri,
    maxWidth: Int = 1920,
    maxHeight: Int = 1920,
    maxSizeBytes: Long = 300 * 1024 // 目标 300KB
): File {
    val inputStream = context.contentResolver.openInputStream(uri)
        ?: throw IOException("无法读取图片")
    val bitmap = BitmapFactory.decodeStream(inputStream)
    inputStream.close()
​
    // 1. 缩尺寸(先缩,减少后续内存和计算量)
    val scaled = scaleWithinBounds(bitmap, maxWidth, maxHeight)
    if (bitmap !== scaled) bitmap.recycle()
​
    // 2. 保留 EXIF 方向信息
    val rotation = getExifRotation(context, uri)
    val rotated = if (rotation != 0) {
        rotateBitmap(scaled, rotation)
    } else scaled
​
    // 3. 质量压缩到目标大小
    val outputFile = File(context.cacheDir, "compressed_${System.currentTimeMillis()}.jpg")
    compressToTargetSize(rotated, outputFile, maxSizeBytes)
    rotated.recycle()
​
    return outputFile
}
​
private fun scaleWithinBounds(bitmap: Bitmap, maxW: Int, maxH: Int): Bitmap {
    val (w, h) = bitmap.width to bitmap.height
    if (w <= maxW && h <= maxH) return bitmap
    val ratio = minOf(maxW.toFloat() / w, maxH.toFloat() / h)
    return Bitmap.createScaledBitmap(bitmap, (w * ratio).toInt(), (h * ratio).toInt(), true)
}
​
private fun getExifRotation(context: Context, uri: Uri): Int {
    return try {
        val exif = ExifInterface(context.contentResolver.openInputStream(uri)!!)
        @Suppress("DEPRECATION")
        when (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
            ExifInterface.ORIENTATION_ROTATE_90 -> 90
            ExifInterface.ORIENTATION_ROTATE_180 -> 180
            ExifInterface.ORIENTATION_ROTATE_270 -> 270
            else -> 0
        }
    } catch (_: Exception) { 0 }
}
​
private fun rotateBitmap(bitmap: Bitmap, degrees: Int): Bitmap {
    val matrix = android.graphics.Matrix().apply { postRotate(degrees.toFloat()) }
    return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
}
​
private fun compressToTargetSize(bitmap: Bitmap, output: File, maxBytes: Long) {
    var quality = 85
    output.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, quality, it) }
    if (output.length() <= maxBytes) return
​
    // 二分法逼近目标大小(找满足条件的最高 quality)
    var low = 10
    var high = quality
    var bestQuality = low
    while (low <= high) {
        quality = (low + high) / 2
        output.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, quality, it) }
        if (output.length() <= maxBytes) {
            bestQuality = quality   // 当前 quality 可行,尝试更高
            low = quality + 1
        } else {
            high = quality - 1      // 当前 quality 太大,降低
        }
    }
    // 用最佳 quality 输出最终结果
    if (bestQuality != quality) {
        output.outputStream().use { bitmap.compress(Bitmap.CompressFormat.JPEG, bestQuality, it) }
    }
}

坑点: BitmapFactory.decodeStream() 默认不读 EXIF 方向信息,手机拍照的照片可能旋转 90 度或 270 度。必须用 ExifInterface 读取 TAG_ORIENTATION 矫正后再上传,否则服务端看到的全是歪的。

格式选择:

  • JPEG:兼容性最好,质量 85 是视觉无损的工程共识
  • WebP:同等质量比 JPEG 小 25-35%,Android 4.0+ 原生支持
  • 通用方案推荐 JPEG quality=85,视觉无区别,文件体积可控

1.3 上传进度监听

OkHttp 的 RequestBody 默认不暴露进度,需要自己包装 writeTo() 方法。

class ProgressRequestBody(
    private val delegate: RequestBody,
    private val onProgress: (Float) -> Unit
) : RequestBody() {
    override fun contentType(): MediaType? = delegate.contentType()
    override fun contentLength(): Long = delegate.contentLength()
​
    override fun writeTo(sink: BufferedSink) {
        val total = contentLength()
        if (total == -1L) {
            delegate.writeTo(sink)
            return
        }
        val countingSink = object : ForwardingSink(sink) {
            var bytesWritten = 0L
            override fun write(source: Buffer, byteCount: Long) {
                super.write(source, byteCount)
                bytesWritten += byteCount
                onProgress(bytesWritten.toFloat() / total)
            }
        }
        val bufferedSink = countingSink.buffer()
        delegate.writeTo(bufferedSink)
        bufferedSink.flush()
    }
}

与协程 Flow 结合的完整方案:

fun uploadWithProgress(
    url: String,
    file: File,
    mimeType: String = "image/jpeg"
): Flow<Float> = channelFlow {
    val requestBody = ProgressRequestBody(
        file.asRequestBody(mimeType.toMediaType())
    ) { progress ->
        // channelFlow 的 trySend 是线程安全的,非阻塞
        trySend(progress)
    }
​
    val request = Request.Builder()
        .url(url)
        .post(requestBody)
        .build()
​
    val response = OkHttpClient().newCall(request).await()
    if (!response.isSuccessful) {
        throw IOException("Upload failed: ${response.code} ${response.message}")
    }
    trySend(1f) // 100%
    channel.close()
}
​
// ViewModel 中使用
class UploadViewModel : ViewModel() {
    private val _uploadProgress = MutableStateFlow(0f)
    val uploadProgress: StateFlow<Float> = _uploadProgress.asStateFlow()
​
    fun upload(file: File) {
        viewModelScope.launch {
            uploadWithProgress("https://api.example.com/upload", file)
                .flowOn(Dispatchers.IO)
                .catch { e -> _uploadProgress.value = -1f /* 错误状态 */ }
                .collect { progress ->
                    _uploadProgress.value = progress
                }
        }
    }
}

线程安全的重要细节:

  1. writeTo() 在 OkHttp 的 IO 线程池被调用,不是主线程
  2. channelFlowtrySend() 是锁自由的 CAS 操作,线程安全
  3. 下游 flowOn(Dispatchers.IO) 不影响 collect 跑在哪个协程上下文--collect 跑在 launch 指定的上下文(默认主线程)
  4. 不要在 onProgress 回调里直接更新 UI,必须切换到主线程

1.4 分片上传与秒传

为什么需要分片: 大文件(视频 >100MB)单次上传一旦中断就全废了。弱网环境下尤其痛苦。分片上传将文件切成小块,每片独立上传,失败只重传那一片。

分片方案设计:

字段说明
fileMd5完整文件 MD5,用于服务端校验和秒传
chunkIndex当前分片序号(从 0 开始)
totalChunks总分片数
chunkSize每片大小(通常 1-4MB)

完整分片上传代码:

data class ChunkUploadResult(
    val fileMd5: String,
    val receivedChunks: List<Int>,  // 已接收的分片序号
    val complete: Boolean,          // 是否全部上传完成
    val fileUrl: String?            // 完成后的文件 URL
)
​
suspend fun uploadFileInChunks(
    file: File,
    api: UploadApi,
    chunkSize: Long = 2 * 1024 * 1024, // 2MB
    onProgress: (Float) -> Unit
): String = withContext(Dispatchers.IO) {
    val fileMd5 = file.md5()
    val totalChunks = (file.length() + chunkSize - 1) / chunkSize
    var uploadedChunks = 0L
​
    // 1. 秒传检测:服务端已有相同文件直接返回
    val checkResult = api.checkFileExists(fileMd5)
    if (checkResult.exists) {
        return@withContext checkResult.fileUrl
    }
​
    // 2. 断点续传:获取已上传的分片列表
    val received = api.getUploadStatus(fileMd5).receivedChunks.toSet()
​
    // 3. 逐片上传(跳过已完成的)
    RandomAccessFile(file, "r").use { raf ->
        for (i in 0 until totalChunks) {
            if (i in received) {
                uploadedChunks++
                onProgress(uploadedChunks.toFloat() / totalChunks)
                continue
            }
​
            val start = i * chunkSize
            val size = minOf(chunkSize, file.length() - start)
            val chunkData = ByteArray(size.toInt())
            raf.seek(start)
            raf.readFully(chunkData)
​
            // 上传分片(自动重试 3 次)
            var success = false
            for (retry in 0 until 3) {
                try {
                    api.uploadChunk(fileMd5, i, totalChunks, chunkData)
                    success = true
                    break
                } catch (e: IOException) {
                    if (retry == 2) throw e
                    delay(1000L shl retry) // 1s, 2s, 4s
                }
            }
            if (!success) throw IOException("分片 $i 上传失败")
​
            uploadedChunks++
            onProgress(uploadedChunks.toFloat() / totalChunks)
        }
    }
​
    // 4. 通知服务端合并
    api.mergeChunks(fileMd5, totalChunks)
}
​
// 工具:计算文件 MD5
fun File.md5(): String {
    val digest = MessageDigest.getInstance("MD5")
    inputStream().use { input ->
        val buffer = ByteArray(8192)
        var read: Int
        while (input.read(buffer).also { read = it } != -1) {
            digest.update(buffer, 0, read)
        }
    }
    return digest.digest().joinToString("") { "%02x".format(it) }
}

服务端合并流程: 收到所有分片后,按 chunkIndex 顺序拼接文件,再对整个合并后的文件算一遍 MD5,与客户端上传的 fileMd5 比对。不一致说明某片数据损坏,通知客户端重传。

秒传逻辑已经集成在上面代码的第 1 步:上传前先计算文件 MD5 发给服务端查询,如果服务端已存在完全相同的文件,直接返回已有 URL,上传过程 0 流量 0 耗时。对大文件场景能节省大量流量和时间。


1.5 断点续传(上传)

上传断点续传本质是分片上传的配套能力,不需要单独的 Range 机制。

流程:

  1. 客户端开始上传前调用 GET /upload/status?fileMd5=xxx
  2. 服务端返回已成功接收的分片序号列表 [0, 1, 3, 4]
  3. 客户端跳过这些分片,只补传缺失的(比如 2 号分片)
  4. 全部完成后调用 POST /upload/merge 通知服务端合并

已经在 1.4 节的 getUploadStatus() + received 完整实现了这个逻辑。核心思想是服务端负责记录状态,客户端负责协商


1.6 完整上传架构

上面分别讲了压缩、分片、秒传、断点续传,实际项目中需要一个统一的 UploadManager 把它们串起来:

class UploadManager(
    private val api: UploadApi,
    private val context: Context
) {
    suspend fun upload(uri: Uri, onProgress: (Float) -> Unit): String {
        // 1. 压缩(图片才压缩)
        val file = if (isImage(uri)) compressImage(context, uri)
                   else uriToFile(context, uri)
​
        // 2. 秒传检查
        val md5 = file.md5()
        val quickResult = api.checkFile(md5)
        if (quickResult.exists) return quickResult.url  // 秒传成功
​
        // 3. 获取已上传分片(断点续传)
        val uploadedChunks = api.getUploadStatus(md5).uploadedChunks
​
        // 4. 分片上传
        val chunks = file.chunked(2 * 1024 * 1024)  // 2MB/片
        chunks.forEachIndexed { index, chunk ->
            if (index !in uploadedChunks) {
                api.uploadChunk(md5, index, chunk.toRequestBody())
            }
            onProgress((index + 1f) / chunks.size)
        }
​
        // 5. 合并
        return api.mergeChunks(md5, chunks.size).url
    }
}

完整流程:压缩 → 秒传检查 → 断点查询 → 分片上传 → 合并。每一步都在前面章节详细讲过,这里只是把它们组装成一个可直接使用的入口。


1.7 多图并发上传

选多张图片时逐个上传太慢,全部并发又可能线程爆炸和内存溢出。需要控制并发度

suspend fun uploadMultipleImages(
    files: List<File>,
    maxConcurrent: Int = 3
): List<Result<String>> = supervisorScope {
    val semaphore = Semaphore(maxConcurrent)
​
    files.map { file ->
        async {
            semaphore.withPermit {
                runCatching {
                    uploadFileInChunks(file, api) { /* progress */ }
                }
            }
        }
    }.awaitAll()
}

关键设计:

组件作用替代方案
supervisorScope一个任务失败不影响其他任务coroutineScope 会取消所有协程
Semaphore限制最大并发数也可以用 FixedThreadPool,但 Semaphore 更轻
runCatching异常转为 Result,调用方可以区分成功/失败try-catch 在每个 async 里也行
async + awaitAll并发执行并等待全部完成coroutineScope 内部 launch 不能返回结果

并发数选择: WiFi 环境 3-5 效果最好,移动网络 2-3。超过 6 个连接时 TCP 拥塞控制反而导致总吞吐下降。这不是理论猜测--在 Android 上实测,并发 6 以上的总下载速度反而低于并发 3。


2. 文件下载与断点续传

2.1 HTTP Range 头原理

断点续传的 HTTP 基础是 Range 请求头,让服务器只返回文件的一部分。

客户端请求:

GET /file.zip HTTP/1.1
Range: bytes=500-999

服务端响应:

HTTP/1.1 206 Partial Content
Content-Range: bytes 500-999/10000
Content-Length: 500

检测服务端是否支持 Range:

GET /file.zip HTTP/1.1
Range: bytes=0-0
​
// 返回 206 Partial Content → 支持
// 返回 200 OK 且返回完整文件 → 不支持

Accept-Ranges: bytes 响应头也表明服务器支持,但有些 CDN 不返回这个头仍然支持 Range,所以最好的检测方式就是实际发一个 Range 请求试试。


2.2 断点续传下载

class BreakpointDownloader(
    private val context: Context,
    private val prefs: SharedPreferences = context.getSharedPreferences("download_prefs", Context.MODE_PRIVATE),
    private val client: OkHttpClient = OkHttpClient()
) {
    private var isPaused = false
    private var currentCall: Call? = null
​
    data class DownloadProgress(
        val downloadedBytes: Long,
        val totalBytes: Long,
        val speedBytesPerSec: Long
    )
​
    suspend fun download(
        url: String,
        targetFile: File,
        onProgress: (DownloadProgress) -> Unit
    ) = withContext(Dispatchers.IO) {
        isPaused = false
​
        // 1. 获取文件总大小和 ETag(只请求头,不下载内容)
        val headRequest = Request.Builder().url(url).head().build()
        val headResponse = client.newCall(headRequest).execute()
        val totalSize = headResponse.body?.contentLength()
            ?: throw IOException("无法获取文件大小")
        val etag = headResponse.header("ETag")
        val lastModified = headResponse.header("Last-Modified")
​
        // 2. ETag 校验:文件是否已变更
        val savedEtag = prefs.getString(prefKey(url, "etag"), null)
        if (savedEtag != null && etag != null && savedEtag != etag) {
            // 文件已更新,本地缓存不可用,重新下载
            targetFile.delete()
        }
​
        // 3. 计算断点位置
        val startBytes = if (targetFile.exists()) targetFile.length() else 0L
        if (startBytes >= totalSize) {
            // 已下载完成
            onProgress(DownloadProgress(totalSize, totalSize, 0))
            return@withContext
        }
​
        // 4. 发送 Range 请求
        val request = Request.Builder()
            .url(url)
            .header("Range", "bytes=$startBytes-")
            .build()
​
        currentCall = client.newCall(request)
        val response = currentCall!!.await()
​
        when (response.code) {
            206 -> {
                // 服务端支持断点续传
                RandomAccessFile(targetFile, "rw").use { raf ->
                    raf.seek(startBytes)
​
                    var totalRead = startBytes
                    var lastUpdateTime = System.currentTimeMillis()
                    var lastBytes = startBytes
​
                    val source = response.body?.source()
                    ?: throw IOException("响应体为空")
                val buf = ByteArray(8192)
                while (true) {
                    if (isPaused) throw PauseException()
                    val read = source.read(buf)
                    if (read == -1) break
                    raf.write(buf, 0, read)
                    totalRead += read
​
                    val now = System.currentTimeMillis()
                    if (now - lastUpdateTime >= 300) {
                        val deltaBytes = totalRead - lastBytes
                        val speedMs = now - lastUpdateTime
                        val speed = if (speedMs > 0) deltaBytes * 1000 / speedMs else 0L
                        onProgress(DownloadProgress(totalRead, totalSize, speed))
                        lastUpdateTime = now
                        lastBytes = totalRead
                    }
                }
                }
​
                // 保存 ETag
                if (etag != null) {
                    prefs.edit().putString(prefKey(url, "etag"), etag).apply()
                }
            }
            200 -> {
                // 服务端不支持 Range,全量下载
                targetFile.outputStream().use { output ->
                    response.body?.byteStream()?.use { input ->
                        input.copyTo(output)
                    }
                }
            }
            else -> throw IOException("下载失败: ${response.code}")
        }
    }
​
    fun pause() {
        isPaused = true
        currentCall?.cancel()
    }
​
    fun resume(url: String, targetFile: File, onProgress: (DownloadProgress) -> Unit) {
        // 重新调用 download,会自动从已下载位置续传
        // 可以外包给协程启动
    }
​
    private fun prefKey(url: String, suffix: String) = "${url}_$suffix"
    private class PauseException : IOException("用户暂停")
}

关键细节:

  1. ETag 校验:服务端文件可能更新,ETag 变了说明是不同版本,断点续传续出来的是新旧文件拼凑的脏数据,必须从头下载。
  2. RandomAccessFile + FileChannel:支持定点写入,不破坏已有数据。raf.seek(startBytes) 找到断点位置。
  3. 暂停机制:不能暴力 kill 线程。设置标志位 + ForwardingSink 拦截每次写入检查,抛出 PauseException 在协程层面优雅停止。下游 catch 到这个异常不做错误处理。
  4. 进度频率控制:300ms 更新一次足够 UI 层渲染,太快反而浪费性能。

2.3 流式写入与进度监听

错误示范(面试经典送命题):

// ❌ OOM 风险!整个文件读进内存
val response = client.newCall(request).execute()
val json = response.body?.string()
// 下载 500MB 视频?直接 OOM 了兄弟

response.body?.string()response.body?.bytes() 都会把整个响应体全部加载到内存。对大文件下载这是灾难。

正确做法:

suspend fun downloadFile(url: String, targetFile: File) = withContext(Dispatchers.IO) {
    val request = Request.Builder().url(url).build()
    val response = OkHttpClient().newCall(request).await()
​
    if (!response.isSuccessful) throw IOException("下载失败: ${response.code}")
​
    response.body?.let { body ->
        targetFile.outputStream().use { output ->
            body.byteStream().use { input ->
                // 内部用 8KB buffer 逐块复制,内存占用恒定
                input.copyTo(output)
            }
        }
    }
}

copyTo() 默认使用 8KB 缓冲区逐块复制,任何时候内存中只有 8KB 数据。换成 Okio 写法也一样:

// Okio 风格,性能略好(避免 JNI bridge)
response.body?.source()?.use { source ->
    targetFile.sink().buffer().use { sink ->
        source.readAll(sink)
    }
}

进度监控: 包装 ResponseBody,拦截 source() 方法:

class ProgressResponseBody(
    private val delegate: ResponseBody,
    private val onProgress: (Float) -> Unit
) : ResponseBody() {
    override fun contentType(): MediaType? = delegate.contentType()
    override fun contentLength(): Long = delegate.contentLength()
​
    override fun source(): BufferedSource {
        val total = contentLength()
        return delegate.source().let { source ->
            if (total == -1L) return source  // 不知道总大小,没法计算进度
            object : ForwardingSource(source) {
                var totalBytesRead = 0L
                override fun read(sink: Buffer, byteCount: Long): Long {
                    val bytesRead = super.read(sink, byteCount)
                    if (bytesRead != -1L) {
                        totalBytesRead += bytesRead
                        onProgress(totalBytesRead.toFloat() / total)
                    }
                    return bytesRead
                }
            }.buffer()
        }
    }
}
​
// 使用
val progressBody = ProgressResponseBody(response.body) { progress ->
    // 这里在 OkHttp IO 线程
}
// 接下来用 progressBody 读取数据流...

通知栏进度更新:

class DownloadService : Service() {
    private val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
​
    // 下载中:确定进度模式
    private fun showProgressNotification(progress: Int, downloaded: Long, total: Long) {
        val notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("下载中")
            .setContentText("${formatSize(downloaded)} / ${formatSize(total)}")
            .setSmallIcon(android.R.drawable.stat_sys_download)
            .setProgress(100, progress, false)
            .setOngoing(true)
            .build()
        nm.notify(NOTIFICATION_ID, notification)
    }
​
    // 下载完成
    private fun showCompleteNotification(fileName: String) {
        val notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle("下载完成")
            .setContentText(fileName)
            .setSmallIcon(android.R.drawable.stat_sys_download_done)
            .setAutoCancel(true)
            .build()
        nm.notify(NOTIFICATION_ID, notification)
    }
}

注意: 每次 nm.notify() 都有 IPC 开销,300ms 更新一次足矣。


2.4 多线程下载

原理: 把文件分成 N 段,每段起一个协程用 Range 头下载,都完成后合并。

suspend fun multiThreadDownload(
    url: String,
    targetFile: File,
    threadCount: Int = 3
): File = withContext(Dispatchers.IO) {
    val totalSize = getFileSize(url)
    val partSize = totalSize / threadCount
    val parts = List(threadCount) { i ->
        val start = i * partSize
        val end = if (i == threadCount - 1) totalSize - 1 else (i + 1) * partSize - 1
        val partFile = File(targetFile.parentFile, "${targetFile.name}.part$i")
​
        async {
            val request = Request.Builder()
                .url(url)
                .header("Range", "bytes=$start-$end")
                .build()
            val response = OkHttpClient().newCall(request).await()
            partFile.outputStream().use { output ->
                response.body?.byteStream()?.use { input ->
                    input.copyTo(output)
                }
            }
            partFile
        }
    }
​
    // 等待所有分片下载完成,然后按顺序合并
    val partFiles = parts.awaitAll()
    targetFile.outputStream().use { output ->
        partFiles.forEach { part ->
            part.inputStream().use { input ->
                input.copyTo(output)
            }
            part.delete()
        }
    }
​
    targetFile
}

线程数选择:

网络类型推荐线程数原因
WiFi 稳定3-4充分利用带宽,但对 TCP 连接有开销
4G/5G2-3移动网络抖动大,多连接反而互相干扰
3G/弱网1-2多线程没意义,瓶颈在服务端带宽

实际效果分析: 多线程下载在某些场景(服务端对每个连接单独限速 2Mbps)有用,但在绝大多数场景单线程就够了。多线程带来的开销(TCP 慢启动、连接建立握手、文件合并的 IO 成本)往往抵消了并发收益。面试经常问,但实际项目里别为了炫技而用。


3. 弱网优化

3.1 超时策略

OkHttp 有四种超时,很多人只设 connectTimeout 一个,其他三个用默认值。

val client = OkHttpClient.Builder()
    .connectTimeout(10, TimeUnit.SECONDS)   // TCP 连接建立超时
    .readTimeout(30, TimeUnit.SECONDS)      // 读数据时相邻两个数据包之间的间隔超时
    .writeTimeout(30, TimeUnit.SECONDS)     // 写入数据的阻塞超时
    .callTimeout(60, TimeUnit.SECONDS)      // 整个请求从开始到结束的最大耗时
    .build()

四种超时的区别:

超时类型含义典型值容易踩的坑
connectTimeoutTCP 三次握手耗时上限10s弱网下新连接建立慢,有时要 15s+
readTimeout收到部分数据后,等待下一个数据包的超时30s不是请求总用时上限,如果数据持续在传输,永远不会超时
writeTimeout写入操作阻塞超时30s上传大文件时必须调大,否则写一半超时
callTimeout整个请求从开始到结束的最大耗时60s一个请求受全部 4 个超时共同约束

动态超时:根据网络类型自动调整

fun createClient(networkType: NetworkType): OkHttpClient {
    val builder = OkHttpClient.Builder()
    return when (networkType) {
        NetworkType.WIFI -> builder
            .connectTimeout(10, TimeUnit.SECONDS)
            .readTimeout(20, TimeUnit.SECONDS)
            .build()
        NetworkType.MOBILE_4G -> builder
            .connectTimeout(15, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .build()
        NetworkType.MOBILE_3G -> builder
            .connectTimeout(20, TimeUnit.SECONDS)
            .readTimeout(45, TimeUnit.SECONDS)
            .build()
        NetworkType.WEAK -> builder
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(60, TimeUnit.SECONDS)
            .build()
    }
}

注意: 弱网下超时不是越长越好。60 秒没响应大概率是挂了,等 60 秒用户早走了。建议配合重试机制:短超时 + 快速重试比"等很久才失败"的用户体验好得多。


3.2 重试策略

OkHttp 内置重试: OkHttp 默认对路由问题(DNS 解析失败、连接拒绝)会重试其他路由。但对 HTTP 错误(5xx、408、429)不会自动重试。

// OkHttp 默认行为:连接失败时重试其他 IP
OkHttpClient.Builder()
    .retryOnConnectionFailure(true) // 默认 true

自定义重试拦截器(完整实现):

class RetryInterceptor(
    private val maxRetries: Int = 3,
    private val initialDelayMs: Long = 1000,
    private val multiplier: Double = 2.0
) : Interceptor {
​
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        var lastException: IOException? = null
​
        for (attempt in 0..maxRetries) {
            try {
                if (attempt > 0) {
                    val delay = calculateBackoffDelay(attempt)
                    Thread.sleep(delay)
                }
​
                val response = chain.proceed(request)
​
                // 只有可重试的状态码才重试
                if (response.isSuccessful) return response
                if (!isRetryableCode(response.code)) return response
​
                response.close() // 关闭本次响应体,否则泄漏
                lastException = null
​
            } catch (e: IOException) {
                lastException = e
                if (!isIdempotent(request)) throw e
            }
        }
​
        throw lastException ?: IOException("重试 $maxRetries 次后仍然失败")
    }
​
    private fun calculateBackoffDelay(attempt: Int): Long {
        // 指数退避 + 小随机 jitter,防止惊群效应
        val base = initialDelayMs * Math.pow(multiplier, (attempt - 1).toDouble())
        val jitter = Random.nextLong(0, initialDelayMs)
        return (base + jitter).toLong()
    }
​
    private fun isRetryableCode(code: Int): Boolean =
        code in listOf(408, 429, 500, 502, 503, 504)
​
    private fun isIdempotent(request: Request): Boolean =
        request.method in listOf("GET", "HEAD", "DELETE", "PUT", "OPTIONS")
}
​
// 注册
OkHttpClient.Builder()
    .addInterceptor(RetryInterceptor())
    .build()

指数退避 + jitter 为什么必要?

假设弱网恢复后有 1000 个客户端同时重试。如果没有 jitter,它们的退避时间完全一样--会在第 1s、第 3s、第 7s 同时发请求,形成惊群效应(thundering herd),服务器瞬间被冲垮。加了 jitter 后,请求时间散开,服务器平稳恢复。

幂等性判断为什么重要?

  • GET / HEAD / DELETE / PUT:安全幂等,重试多少次结果都一样
  • POST:不保证幂等。用户提交订单的 POST 请求重试可能导致重复扣款
  • 解决方案:POST 请求带上 idempotency-key 头,服务端根据这个 key 去重

3.3 请求优先级与降级

请求分级:

enum class RequestPriority(val level: Int) {
    CRITICAL(0),    // 支付、登录、消息发送
    HIGH(1),        // 列表首页、核心数据
    NORMAL(2),      // 普通列表、详情
    LOW(3)          // 预加载、日志上报、统计分析
}
​
// 优先级队列 Dispatcher
class PriorityDispatcher {
    private val queues = Array(4) { ConcurrentLinkedQueue<Request>() }
​
    fun enqueue(request: Request, priority: RequestPriority) {
        queues[priority.level].add(request)
    }
​
    fun next(): Request? {
        // 从高优先级队列开始取
        for (queue in queues) {
            queue.poll()?.let { return it }
        }
        return null
    }
}

弱网降级策略:

class NetworkDegradeInterceptor(
    private val networkQuality: () -> NetworkQuality
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val request = chain.request()
        val quality = networkQuality()
​
        val degradedRequest = when (quality) {
            NetworkQuality.POOR -> {
                // 弱网:降低图片质量、减少预加载
                request.newBuilder()
                    .header("X-Image-Quality", "low")    // 告诉服务端返回低质量图
                    .header("X-Page-Size", "10")         // 减少每页数量
                    .build()
            }
            NetworkQuality.MODERATE -> {
                request.newBuilder()
                    .header("X-Image-Quality", "medium")
                    .build()
            }
            else -> request
        }
​
        return chain.proceed(degradedRequest)
    }
}
​
enum class NetworkQuality { EXCELLENT, GOOD, MODERATE, POOR }

网络质量检测:

class NetworkQualityDetector(context: Context) {
    private val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE)
        as ConnectivityManager
​
    fun detect(): NetworkQuality {
        val caps = cm.getNetworkCapabilities(cm.activeNetwork) ?: return NetworkQuality.POOR
​
        val downBandwidth = caps.linkDownstreamBandwidthKbps
        val upBandwidth = caps.linkUpstreamBandwidthKbps
​
        return when {
            downBandwidth > 10_000 -> NetworkQuality.EXCELLENT  // >10Mbps
            downBandwidth > 2_000 -> NetworkQuality.GOOD         // >2Mbps
            downBandwidth > 500 -> NetworkQuality.MODERATE        // >500Kbps
            else -> NetworkQuality.POOR
        }
    }
​
    // 更精确:实际 ping 延迟探测
    suspend fun measureLatency(url: String = "https://www.google.com"): Long {
        return withContext(Dispatchers.IO) {
            val start = System.nanoTime()
            try {
                val socket = Socket()
                socket.connect(InetSocketAddress(URL(url).host, 443), 5000)
                socket.close()
                TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)
            } catch (e: Exception) {
                Long.MAX_VALUE  // 连接失败
            }
        }
    }
}

3.4 数据压缩

gzip 请求体压缩:

class GzipRequestInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val original = chain.request()
        val body = original.body ?: return chain.proceed(original)
​
        // 小于 1KB 不压缩(压缩开销 > 收益)
        if (body.contentLength() in 0..1024) return chain.proceed(original)
​
        val compressed = original.newBuilder()
            .header("Content-Encoding", "gzip")
            .method(original.method, gzip(body))
            .build()
        return chain.proceed(compressed)
    }
​
    private fun gzip(body: RequestBody): RequestBody {
        return object : RequestBody() {
            override fun contentType() = body.contentType()
            override fun contentLength() = -1L  // 压缩后长度未知
            override fun writeTo(sink: BufferedSink) {
                val gzipSink = GzipSink(sink).buffer()
                body.writeTo(gzipSink)
                gzipSink.close()
            }
        }
    }
}

Protocol Buffers vs JSON 体积对比:

数据场景JSON 大小Protobuf 大小压缩比
用户信息(5字段)120 bytes45 bytes62%
商品列表(20条)8.5 KB2.8 KB67%
聊天消息(100条)35 KB9 KB74%

Protobuf 在字段多、数据量大的场景优势明显。但对 Android 客户端来说,引入 Protobuf 增加包体积约 300-500KB,需要权衡。

3.5 预加载与离线

// 预加载策略:用户在列表页时,预加载前 3 个详情页数据
class PreloadManager(
    private val api: ApiService,
    private val cache: PreloadCache
) {
    private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
​
    fun preloadDetails(ids: List<String>) {
        ids.take(3).forEach { id ->
            scope.launch {
                if (!cache.has(id)) {
                    try {
                        val data = api.getDetail(id)
                        cache.put(id, data)
                    } catch (_: Exception) { /* 预加载失败不影响用户 */ }
                }
            }
        }
    }
}
​
// 离线同步队列:离线时操作入队,有网时自动同步
@Entity(tableName = "pending_sync")
data class PendingSyncTask(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val action: String,         // "create_order", "update_profile"
    val payload: String,        // JSON 序列化的请求体
    val createdAt: Long = System.currentTimeMillis(),
    val retryCount: Int = 0
)
​
class OfflineSyncManager(
    private val db: AppDatabase,
    private val api: ApiService
) {
    // 网络恢复时触发
    suspend fun syncAll() {
        val tasks = db.syncDao().getAllPending()
        tasks.forEach { task ->
            try {
                executeTask(task)
                db.syncDao().delete(task)
            } catch (e: Exception) {
                if (task.retryCount < 3) {
                    db.syncDao().update(task.copy(retryCount = task.retryCount + 1))
                } else {
                    db.syncDao().markFailed(task.id)
                }
            }
        }
    }
}

4. 网络监控与排障

4.1 OkHttp EventListener 全链路耗时

data class NetworkMetrics(
    val url: String,
    val dnsMs: Long = 0,
    val connectMs: Long = 0,
    val tlsMs: Long = 0,
    val requestMs: Long = 0,
    val responseMs: Long = 0,
    val totalMs: Long = 0,
    val protocol: String = "",
    val success: Boolean = true,
    val errorType: String? = null
)
​
class MetricsEventListener : EventListener() {
    private var dnsStart = 0L
    private var connectStart = 0L
    private var tlsStart = 0L
    private var callStart = 0L
    private val metrics = mutableMapOf<String, Long>()
​
    override fun callStart(call: Call) { callStart = now() }
    override fun dnsStart(call: Call, domainName: String) { dnsStart = now() }
    override fun dnsEnd(call: Call, domainName: String, ips: List<InetAddress>) {
        metrics["dns"] = now() - dnsStart
    }
    override fun connectStart(call: Call, addr: InetSocketAddress, proxy: Proxy) {
        connectStart = now()
    }
    override fun connectEnd(call: Call, addr: InetSocketAddress, proxy: Proxy, protocol: Protocol?) {
        metrics["connect"] = now() - connectStart
    }
    override fun secureConnectStart(call: Call) { tlsStart = now() }
    override fun secureConnectEnd(call: Call, handshake: Handshake?) {
        metrics["tls"] = now() - tlsStart
    }
    override fun callEnd(call: Call) {
        val total = now() - callStart
        val result = NetworkMetrics(
            url = call.request().url.toString(),
            dnsMs = metrics["dns"] ?: 0,
            connectMs = metrics["connect"] ?: 0,
            tlsMs = metrics["tls"] ?: 0,
            totalMs = total
        )
        MetricsReporter.report(result)
    }
    override fun callFailed(call: Call, ioe: IOException) {
        MetricsReporter.report(NetworkMetrics(
            url = call.request().url.toString(),
            totalMs = now() - callStart,
            success = false,
            errorType = ioe.javaClass.simpleName
        ))
    }
​
    private fun now() = System.nanoTime() / 1_000_000
}

4.2 网络质量分级

object NetworkGrader {
    data class Grade(
        val level: NetworkQuality,
        val latencyMs: Long,
        val description: String
    )
​
    fun grade(latencyMs: Long, lossRate: Float): Grade = when {
        latencyMs < 50 && lossRate < 0.01f -> Grade(
            NetworkQuality.EXCELLENT, latencyMs, "网络优秀"
        )
        latencyMs < 150 && lossRate < 0.03f -> Grade(
            NetworkQuality.GOOD, latencyMs, "网络良好"
        )
        latencyMs < 500 && lossRate < 0.10f -> Grade(
            NetworkQuality.MODERATE, latencyMs, "网络一般"
        )
        else -> Grade(
            NetworkQuality.POOR, latencyMs, "网络较差"
        )
    }
}

4.3 慢请求告警

class SlowRequestInterceptor(
    private val thresholdMs: Long = 3000  // 超过 3 秒算慢请求
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val start = System.nanoTime()
        val response = chain.proceed(chain.request())
        val elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start)
​
        if (elapsed > thresholdMs) {
            Log.w("SlowRequest", "${chain.request().url} took ${elapsed}ms")
            // 上报到监控平台
            MetricsReporter.reportSlow(
                url = chain.request().url.toString(),
                elapsedMs = elapsed,
                responseCode = response.code
            )
        }
        return response
    }
}

4.4 Charles/Fiddler 抓包技巧

Android 7.0+ 默认不信任用户安装的 CA 证书,需要额外配置:

<!-- res/xml/network_security_config.xml -->
<network-security-config>
    <!-- Debug 模式下信任用户证书(用于 Charles 抓包) -->
    <debug-overrides>
        <trust-anchors>
            <certificates src="user" />
        </trust-anchors>
    </debug-overrides>
​
    <!-- Release 模式只信任系统证书 -->
    <base-config cleartextTrafficPermitted="false">
        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
    </base-config>
</network-security-config>
<!-- AndroidManifest.xml -->
<application
    android:networkSecurityConfig="@xml/network_security_config"
    ... />

抓包流程:

  1. 安装 Charles CA 证书到手机
  2. 设置手机代理指向 Charles
  3. App 必须是 debug 包(上面的配置只在 debug 生效)
  4. Release 包抓不到 HTTPS,这正是我们想要的安全效果

5. 总结与系列导航

实战要点清单

场景核心技术关键注意
文件上传MultipartBody + 压缩 + 分片 + 秒传先压缩再上传,进度回调注意线程
上传断点续传分片状态协商 + MD5 校验服务端记录状态,客户端补传缺失分片
文件下载流式读写 + 进度拦截 + 多线程禁止 string(),用 byteStream()
下载断点续传Range 头 + RandomAccessFileETag 校验文件变更,进度持久化
弱网优化动态超时 + 指数退避 + 降级幂等性判断,jitter 防惊群
网络监控EventListener + 慢请求告警上报采样,避免监控本身成为负担

面试高频场景题

Q:如何实现图片上传进度监听? A:自定义 RequestBody,在 writeTo() 中包装 Sink,每次 write 时累计已发送字节并通过回调通知。注意回调在 OkHttp 线程,更新 UI 需要切主线程。Retrofit 可以通过自定义 @Part 参数的 RequestBody 实现。

Q:断点续传的原理是什么? A:利用 HTTP Range 头。客户端记录已下载字节数,下次请求时发送 Range: bytes=已下载-,服务端返回 206 Partial Content 和剩余数据。客户端用 RandomAccessFile seek 到对应位置继续写入。需要用 ETag 校验文件是否变更。

Q:弱网环境下如何优化网络请求? A:五个方向:1动态调整超时(弱网加大);2指数退避重试+jitter 防惊群;3请求分优先级,弱网下砍掉低优先级请求;4降级图片质量、减少数据量;5离线队列+有网时自动同步。

Q:大文件下载为什么不能用 response.string()? A:string() 会把整个响应体加载到内存。100MB 文件就是 100MB 字符串,直接 OOM。必须用 byteStream() 流式读取 + 写入文件,内存中始终只有 8-64KB 缓冲区。

Q:多线程下载真的更快吗? A:不一定。理论上多个 Range 请求并行可以提高吞吐量(充分利用带宽),但实际受制于:1服务端限速(单 IP 限速则多线程无效);2TCP 慢启动(每个连接都要从低速开始);3移动网络下多连接反而增加信令开销。WiFi 环境 3-4 线程有一定效果,移动网络 2 线程足够。

Q:如何实现秒传? A:上传前先计算文件 MD5 发给服务端查询。如果服务端已存在相同 MD5 的文件,直接返回成功,无需实际传输。节省时间和带宽,但 MD5 计算本身也有开销,适合大文件。 |


本文代码基于 OkHttp 4.x / Retrofit 2.x / Kotlin Coroutines 1.7.x,均已简化保留核心逻辑。