做 Android 开发这些年,网络请求这块的封装我前前后后改过不下五版。早期用原生 HttpURLConnection 手撸,后来换 Volley,再后来团队统一到 OkHttp,每一版封装都踩过不同的坑。这次想聊的这套 OkHttp 极简封装,是我在几个中小型项目里反复打磨后沉淀下来的方案,配合 Kotlin 的扩展函数和 KTX 风格,代码量压得很低,但该有的能力一个不少。它解决的核心问题很明确:让业务层发起一个网络请求时,不用再关心线程切换、回调嵌套、异常兜底、JSON 解析这些重复劳动,一行链式调用就能拿到干净的数据对象。适合已经了解 OkHttp 基本用法、想进一步把网络层做薄做稳的 Android 开发者,也适合刚接手老项目、面对一堆重复请求代码想重构的同学参考。下面我按设计思路、核心细节、实操落地、问题排查四个维度,把这套封装掰开揉碎讲清楚。
1. 整体设计与思路拆解
1.1 为什么选择 OkHttp 作为底座
网络库选型这件事,我吃过亏。早年项目里同时存在 Volley 和 OkHttp 两套请求逻辑,维护成本高得离谱,改一个超时配置要动两个地方。后来统一到 OkHttp,主要看中三点:一是连接池复用做得扎实,同一域名下的请求能共享 TCP 连接,减少握手开销;二是拦截器机制灵活,日志、鉴权、重试这些横切逻辑可以抽成独立拦截器,业务代码保持干净;三是社区成熟,遇到问题资料多,排查起来不慌。
这里要说明的是,OkHttp 本身已经足够底层和稳定,我们做的封装不是再造一个轮子,而是在它之上加一层薄薄的业务适配。这层适配的核心职责就三件事:把请求参数组织成 OkHttp 能识别的形式、把响应结果转换成业务需要的类型、把异常统一收敛成可处理的错误对象。想清楚这三点,封装就不会跑偏。
1.2 极简封装的边界在哪里
“极简”这个词容易被误解成功能残缺。我的理解是:把高频场景做到极致顺手,把低频场景留好扩展口子。具体来说,这套封装覆盖的能力包括 GET/POST 请求、表单与 JSON 两种请求体、请求头统一注入、超时与重试配置、响应自动解析、统一异常处理、请求取消。不覆盖的能力包括文件上传下载的进度回调、WebSocket、HTTP/2 服务端推送这些,需要时单独扩展,不往主链路里塞。
这样划分的好处是主链路足够轻,新人接手看半天就能上手;扩展点清晰,需要加功能时知道往哪加。我见过一些封装把几十个配置项全堆在一个 Builder 里,结果业务方每次调用都要传一堆默认值,反而违背了“极简”的初衷。
1.3 分层结构怎么切
整套封装我切成三层。最底层是客户端持有层,负责单例管理 OkHttpClient,配置连接池、超时、拦截器链,这一层全局只有一份,生命周期跟进程走。中间层是请求构建层,用 Kotlin 的扩展函数把 URL、方法、参数、请求头组织成 Request 对象,这一层是纯函数式的,无状态。最上层是结果回调层,负责线程切换、JSON 解析、异常映射,把原始的 Response 转换成业务可直接消费的结果。
分层的意义在于职责隔离。比如某天要换 JSON 解析库,只动结果回调层;要加一个新的公共请求头,只动请求构建层;要调整连接池参数,只动客户端持有层。三层之间通过明确的接口通信,互不越界。这种切法在项目规模变大后优势特别明显,改一处不会牵一发动全身。
1.4 协程还是回调,这是个问题
现在新项目基本都用协程了,但老项目里回调风格还大量存在。我的做法是两套都提供,但主推协程版本。协程版本用suspendCancellableCoroutine把 OkHttp 的异步回调桥接成挂起函数,业务层写起来就是顺序代码,异常用 try-catch 捕获,取消时自动触发 OkHttp 的 cancel。回调版本则保留一个精简的 Callback 接口,给还没迁移协程的模块用。
为什么不干脆只留协程?因为实际项目里总有历史包袱,强行一刀切会导致大量改造工作,风险不可控。两套并存、逐步迁移,是更务实的做法。协程版本的桥接代码也就二十来行,维护成本很低,值得保留。
2. 核心细节解析与实操要点
2.1 客户端单例的正确打开方式
OkHttpClient 官方明确建议全局共享一个实例,因为它内部维护连接池和线程池,频繁创建会导致资源浪费甚至连接泄漏。我的做法是用 Kotlin 的by lazy做懒加载单例,配合双重检查的语义保证线程安全。
object HttpClientHolder { val client: OkHttpClient by lazy { OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS) .retryOnConnectionFailure(true) .connectionPool(ConnectionPool(5, 5, TimeUnit.MINUTES)) .addInterceptor(HeaderInterceptor()) .addInterceptor(LoggingInterceptor()) .build() } }这里有几个参数值得说道。连接超时设 10 秒,是因为移动网络下 DNS 解析加 TCP 握手偶尔会慢,设太短容易误判失败;读写超时设 15 秒,给服务端留出处理时间。retryOnConnectionFailure开启后,OkHttp 会在连接层失败时自动换路由重试,注意它只重试连接失败,不重试业务错误。连接池设 5 个空闲连接、保活 5 分钟,对中小型应用足够,连接数设太大反而占资源。
注意:单例一旦创建,配置就固定了。如果某些请求需要不同的超时时间,不要试图改全局配置,而是用
client.newBuilder()派生一个临时客户端,这样既复用了连接池,又能覆盖个别参数。
2.2 拦截器链的设计取舍
拦截器是 OkHttp 的精髓,但加多少、加在哪,很讲究。我一般放两个应用拦截器:一个负责注入公共请求头,一个负责日志输出。公共头包括设备标识、版本号、语言这些,每个请求都要带,放拦截器里一劳永逸。日志拦截器只在 Debug 包启用,Release 包通过构建变体排除,避免泄露敏感信息。
class HeaderInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val origin = chain.request() val builder = origin.newBuilder() .header("X-Client-Version", BuildConfig.VERSION_NAME) .header("X-Platform", "android") return chain.proceed(builder.build()) } }日志拦截器我建议自己写一个轻量的,而不是直接引第三方库。原因是对日志格式有定制需求,比如要脱敏 token、要按请求 ID 串联日志,自己写反而更灵活。核心就是打印请求行、请求头、请求体、响应码、响应体,注意响应体只能读一次,读完要重新构造 Response 返回,否则下游拿不到数据。
2.3 请求构建的扩展函数写法
Kotlin 扩展函数是这套封装的灵魂。我把常用的请求组织逻辑写成扩展函数,业务层调用时就像在用 DSL。
fun String.asGetRequest(params: Map<String, String> = emptyMap()): Request { val urlBuilder = this.toHttpUrlOrNull()?.newBuilder() ?: throw IllegalArgumentException("invalid url: $this") params.forEach { (k, v) -> urlBuilder.addQueryParameter(k, v) } return Request.Builder().url(urlBuilder.build()).get().build() } fun String.asJsonPostRequest(json: String): Request { val body = json.toRequestBody("application/json; charset=utf-8".toMediaType()) return Request.Builder().url(this).post(body).build() }这种写法的好处是语义清晰,url.asGetRequest(mapOf("page" to "1"))一眼就知道在干什么。参数拼接用addQueryParameter而不是手动拼字符串,它会自动做 URL 编码,避免中文和特殊字符出问题。JSON 请求体用toRequestBody指定媒体类型,服务端才能正确识别。
2.4 响应解析与异常收敛
响应解析这块,我定义一个统一的结果包装类,把成功、业务错误、网络错误三种情况收敛成一个密封类。
sealed class ApiResult<out T> { data class Success<T>(val data: T) : ApiResult<T>() data class BizError(val code: Int, val message: String) : ApiResult<Nothing>() data class NetError(val throwable: Throwable) : ApiResult<Nothing>() }解析时先判断 HTTP 状态码,2xx 才进入业务解析,否则归为网络错误。业务解析阶段用 JSON 库把响应体转成目标类型,如果业务码非成功值,归为业务错误。这样业务层用when表达式就能覆盖所有分支,编译器还会强制你处理每种情况,不会漏掉异常路径。
异常收敛的关键是把 OkHttp 抛出的各种异常映射成有意义的错误。比如SocketTimeoutException映射成“请求超时”,UnknownHostException映射成“网络不可用”,JsonParseException映射成“数据格式异常”。用户看到的提示要友好,日志里要保留原始异常堆栈,两者不能混为一谈。
3. 实操过程与核心环节实现
3.1 从零搭起请求主链路
先把主链路跑通,再谈优化。第一步定义请求入口函数,接收 Request 对象,返回 ApiResult。
suspend fun execute(request: Request): ApiResult<String> = suspendCancellableCoroutine { continuation -> val call = HttpClientHolder.client.newCall(request) continuation.invokeOnCancellation { call.cancel() } call.enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { if (continuation.isActive) { continuation.resume(ApiResult.NetError(e)) } } override fun onResponse(call: Call, response: Response) { response.use { if (!it.isSuccessful) { continuation.resume( ApiResult.NetError(IOException("http ${it.code}")) ) return } val body = it.body?.string().orEmpty() continuation.resume(ApiResult.Success(body)) } } }) }这段代码有几个细节必须注意。invokeOnCancellation里调用call.cancel(),保证协程取消时底层请求也停掉,不然会白白消耗流量。response.use确保响应体被关闭,避免连接泄漏。continuation.isActive判断防止协程已取消还去 resume 导致崩溃。这些都是实际踩过坑才加上的。
3.2 泛型解析的落地写法
拿到字符串响应后,要转成业务对象。我用 reified 泛型配合 JSON 库做内联解析。
suspend inline fun <reified T> get( url: String, params: Map<String, String> = emptyMap() ): ApiResult<T> { val request = url.asGetRequest(params) return when (val raw = execute(request)) { is ApiResult.Success -> { try { ApiResult.Success(json.decodeFromString<T>(raw.data)) } catch (e: Exception) { ApiResult.NetError(e) } } is ApiResult.NetError -> raw is ApiResult.BizError -> raw } }reified让泛型在运行时可用,省去传 Class 对象的麻烦。解析失败要捕获异常并转成 NetError,不能让解析异常直接抛到业务层。这里用 kotlinx.serialization 做示例,换成 Gson 或 Moshi 思路一样,只是解析调用不同。
3.3 业务层的调用姿势
封装好不好用,看业务层代码就知道。一个列表页拉数据的调用大概长这样:
viewModelScope.launch { when (val result = get<List<User>>("/api/users", mapOf("page" to "1"))) { is ApiResult.Success -> adapter.submitList(result.data) is ApiResult.BizError -> toast(result.message) is ApiResult.NetError -> toast("网络异常,请稍后重试") } }对比没封装前的写法——创建 Request、enqueue、在回调里切主线程、手动解析 JSON、处理各种异常——现在这段代码干净得不像网络请求。业务方不用关心 OkHttp 的任何细节,只关注数据怎么用。这就是封装的价值所在。
3.4 超时与重试的差异化配置
全局超时不一定适合所有接口。比如上传头像这种大请求,15 秒读写超时可能不够;而首页配置这种小请求,3 秒拿不到就该降级。我的做法是给入口函数加一个可选的客户端参数。
suspend fun execute( request: Request, client: OkHttpClient = HttpClientHolder.client ): ApiResult<String> { ... }需要特殊超时的调用方,用HttpClientHolder.client.newBuilder().readTimeout(60, TimeUnit.SECONDS).build()派生一个临时客户端传进来。派生客户端共享底层连接池和线程池,开销很小,不用担心性能问题。重试逻辑我建议放在拦截器里做,针对特定错误码或异常类型重试,注意设置最大重试次数,避免死循环。
3.5 请求取消的完整闭环
取消是容易被忽略的环节。页面销毁时如果不取消请求,回调回来操作已销毁的 View 就会崩溃。协程方案里,viewModelScope在 ViewModel 销毁时自动取消所有子协程,配合前面invokeOnCancellation里的call.cancel(),形成完整闭环。回调方案则需要手动持有 Call 对象,在 onDestroy 里调用 cancel。
提示:取消后 OkHttp 会抛
IOException("Canceled"),这个异常要单独识别,不要当成网络错误提示给用户,否则用户会看到莫名其妙的报错。
4. 常见问题与排查技巧实录
4.1 典型问题速查表
| 现象 | 可能原因 | 排查方向 | 解决方式 |
|---|---|---|---|
| 请求一直转圈不返回 | 超时配置过长或服务端无响应 | 抓包看请求是否发出、服务端是否收到 | 缩短超时、加降级逻辑 |
| 偶发 UnknownHostException | DNS 解析失败或网络切换 | 检查网络状态、复现时机 | 加重试、提示用户检查网络 |
| 响应体解析为空 | 响应体被提前读取或流未关闭 | 检查拦截器是否读了 body | 拦截器读完重建 Response |
| 内存持续增长 | 响应体未关闭、连接泄漏 | 用 LeakCanary 观察 | 确保 response.use 包裹 |
| 取消后仍收到回调 | 未调用 call.cancel | 检查取消链路 | 补上 invokeOnCancellation |
| 中文参数乱码 | 手动拼接 URL 未编码 | 检查参数拼接方式 | 用 addQueryParameter |
4.2 响应体只能读一次的坑
这个坑我踩过两次。OkHttp 的 ResponseBody 是流式的,读一次就没了。如果在日志拦截器里response.body.string()打印了内容,下游业务层再读就是空字符串。解决办法是在拦截器里读完字符串后,用Response.Builder重新构造一个基于字符串的 ResponseBody 返回。
val bodyString = response.body?.string().orEmpty() val newBody = bodyString.toResponseBody(response.body?.contentType()) return response.newBuilder().body(newBody).build()注意大文件响应不要这么干,会把整个文件读进内存。日志拦截器只对文本类响应做这个处理,二进制流直接跳过。
4.3 主线程网络异常的真实原因
Android 严格模式禁止主线程做网络操作,一旦触发会抛NetworkOnMainThreadException。用协程时,如果忘了切到 IO 调度器,execute里的enqueue虽然是异步的不会阻塞主线程,但 JSON 解析如果放在主线程且数据量大,会造成卡顿。我的习惯是在入口函数里统一用withContext(Dispatchers.IO)包裹解析逻辑,业务层不用操心调度问题。
4.4 拦截器顺序引发的连锁反应
拦截器的添加顺序决定执行顺序。应用拦截器按添加顺序正序执行,网络拦截器按逆序执行。如果鉴权拦截器依赖日志拦截器注入的请求 ID,顺序放反了就拿不到。我的经验是把公共头注入放最前,日志放最后,鉴权放中间。另外,重试拦截器要放在鉴权之后,否则重试时可能带着过期的 token。
4.5 我个人的几条实操心得
第一,封装初期不要追求大而全,先把 GET 和 JSON POST 跑通,覆盖 80% 的场景,剩下的按需加。第二,异常提示文案要和日志分离,用户看友好提示,开发看堆栈,别把NullPointerException直接弹给用户。第三,给每个请求加一个可选的 tag,排查问题时能快速定位是哪个业务发起的。第四,Debug 包的日志拦截器一定要做敏感字段脱敏,token、手机号这些不能明文打印。第五,定期用抓包工具核对实际发出的请求,封装层容易在参数拼接上出隐蔽 bug,肉眼看不出来,抓包一看便知。
这套封装后续还能往几个方向扩展:把结果缓存做成可插拔的拦截器、给请求加统一的埋点上报、把错误码映射表抽成配置。但核心思路不变——底座用 OkHttp 保证稳定,封装层做薄保证灵活,业务层保持干净保证可维护。我在几个项目里用下来,网络相关的 bug 明显减少,新人上手也快,这套结构值得在中小型项目里直接抄作业。