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
}
}
}
}
线程安全的重要细节:
writeTo()在 OkHttp 的 IO 线程池被调用,不是主线程channelFlow的trySend()是锁自由的 CAS 操作,线程安全- 下游
flowOn(Dispatchers.IO)不影响collect跑在哪个协程上下文--collect跑在launch指定的上下文(默认主线程) - 不要在
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 机制。
流程:
- 客户端开始上传前调用
GET /upload/status?fileMd5=xxx - 服务端返回已成功接收的分片序号列表
[0, 1, 3, 4] - 客户端跳过这些分片,只补传缺失的(比如 2 号分片)
- 全部完成后调用
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("用户暂停")
}
关键细节:
- ETag 校验:服务端文件可能更新,ETag 变了说明是不同版本,断点续传续出来的是新旧文件拼凑的脏数据,必须从头下载。
- RandomAccessFile + FileChannel:支持定点写入,不破坏已有数据。
raf.seek(startBytes)找到断点位置。 - 暂停机制:不能暴力 kill 线程。设置标志位 +
ForwardingSink拦截每次写入检查,抛出PauseException在协程层面优雅停止。下游 catch 到这个异常不做错误处理。 - 进度频率控制: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/5G | 2-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()
四种超时的区别:
| 超时类型 | 含义 | 典型值 | 容易踩的坑 |
|---|---|---|---|
| connectTimeout | TCP 三次握手耗时上限 | 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 bytes | 45 bytes | 62% |
| 商品列表(20条) | 8.5 KB | 2.8 KB | 67% |
| 聊天消息(100条) | 35 KB | 9 KB | 74% |
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"
... />
抓包流程:
- 安装 Charles CA 证书到手机
- 设置手机代理指向 Charles
- App 必须是 debug 包(上面的配置只在 debug 生效)
- Release 包抓不到 HTTPS,这正是我们想要的安全效果
5. 总结与系列导航
实战要点清单
| 场景 | 核心技术 | 关键注意 |
|---|---|---|
| 文件上传 | MultipartBody + 压缩 + 分片 + 秒传 | 先压缩再上传,进度回调注意线程 |
| 上传断点续传 | 分片状态协商 + MD5 校验 | 服务端记录状态,客户端补传缺失分片 |
| 文件下载 | 流式读写 + 进度拦截 + 多线程 | 禁止 string(),用 byteStream() |
| 下载断点续传 | Range 头 + RandomAccessFile | ETag 校验文件变更,进度持久化 |
| 弱网优化 | 动态超时 + 指数退避 + 降级 | 幂等性判断,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,均已简化保留核心逻辑。