最近在整理公司几个项目的网络层代码,发现很多同学把OkHttp3、Retrofit2、RxJava3挂在嘴边,但真到要自己从零搭一套网络框架时,还是会踩不少坑。尤其这三年Retrofit2和OkHttp3的大版本迭代虽然不激进,但RxJava3的切换把之前一堆老写法都废掉了。趁这次重构,我把整个网络请求层全部重写了一遍,把从选型到落地、从封装到不复原的完整思路整理出来,这篇博文会讲的比较细,读完完全可以照着在自己的项目里复刻一套。
为什么选“OkHttp3+Retrofit2+RxJava3”这三件套,而不是直接用Ktor或者其它新兴库?核心原因有三方面:一是团队现有技术栈的迁移成本最低,大家都会;二是OkHttp3的拦截器机制在这个生态里无人能敌,做日志、缓存、鉴权都非常顺手;三是Retrofit2把接口定义和HTTP协议绑定的非常优雅,配合RxJava3的响应式编程,业务层代码可以做到极简。这个组合不是最潮的,但一定是最稳定、最可控的,适合大多数商业项目。
这套框架解决的核心痛点是:业务代码里到处都是重复的线程切换、参数拼接、错误弹窗、Loading管理。把这些脏活累活全部下沉到框架层,业务侧只需要声明接口、发起订阅、接收结果,其它事情一概不管。
1. 方案选型与整体设计思路
1.1 网络库选型的底层考量
聊选型之前,先想清楚一个前提:你的项目到底需要什么样的网络框架。“需要”三个字很关键,因为很多项目并不是真的需要一套框架,只是想找一个能发请求的库。如果你的团队只有两三个人、接口不超过几十个、不需要统一处理Token刷新和公共参数,那就直接用Retrofit2裸写,别封装。封装是有代价的,每一层抽象都意味着排障成本的增加。
但如果你面临的是下面的场景,那就必须上框架:
- 接口上百个,且存在大量重复的Header、公共参数,改一处要动几十个接口
- 有统一的BaseUrl切换需求,比如正式环境和测试环境切换
- 需要统一的Token失效处理,刷新Token后自动重放失败请求
- 业务侧代码大量出现重复的Loading状态控制、错误Toast
- 接口返回有统一的代码和消息结构,需要转成统一的业务状态
我这次重构的目标就是在不引入过多黑魔法的情况下,把这几个问题全部解决。OkHttp3作为HTTP层负责真正的网络通信、连接池管理、拦截器机制;Retrofit2作为API层负责把Java接口方法映射成HTTP请求;RxJava3作为调度层负责线程切换和响应式订阅,把网络请求的异步过程变成流式操作。
1.2 三者的职责边界与数据流
有些同学会把三者的关系搞混,总觉得Retrofit2内部已经做了异步处理,为什么还要加RxJava3?这里必须理清楚。
OkHttp3本身支持同步Call和异步Call两种模式,它的异步是基于Callback回调。Retrofit2默认也支持Call回调方式,你可以直接传一个Callback进去,这其实就是最原始的Retrofit用法。但这样写出来的代码,嵌套几层回调之后可读性会急剧下降。RxJava3的介入不是替代OkHttp3的异步机制,而是在它之上再包一层,把回调变成流,让业务层可以用链式调用的方式操作异步数据。
整个请求链路是这样的:
- 业务层调用Retrofit接口方法,得到一个Observable对象
- Retrofit2的RxJava3CallAdapterFactory负责把Call适配成Observable
- Observable在IO线程执行真正的HTTP请求(底层走OkHttp3)
- 请求结果通过RxJava3的map操作符转成业务模型
- 通过subscribeOn和observeOn完成线程切换,UI线程接收最终结果
这样做的好处非常明显:线程切换代码在框架层完成,业务层不需要也不应该关心当前代码跑在哪个线程。这是一种强制性的纪律,反而减少了出错的概率。
1.3 为什么不用ViewModel+协程替代
现在用Kotlin的项目,协程+Flow其实也是很好的方案,而且Google官方更推荐协程。但如果你维护的是老项目,或者团队里还有人不太熟悉协程的挂起和调度原理,Retrofit2+RxJava3这套方案仍然有它的独特优势。
RxJava3相比协程来说,操作符更丰富,处理复杂的数据变换和组合时更顺手。比如多个请求并发合并、错误重试、限流、去抖,这些都是RxJava3的看家本领,用协程写需要更多手写逻辑。还有一个现实问题,很多老项目里的业务代码都是基于RxJava写的,切换到协程意味着所有接口调用全部要改一遍,这个迁移成本实在太高。
所以我的建议是:新项目不妨试试协程,但老项目或者团队习惯偏向RxJava的项目,直接用三件套是最理性的选择。别动不动想推翻重来,能用最低成本把问题解决才是成熟工程师的选择。
2. 网络框架分层设计与基础封装
2.1 整体分层结构
一套好的网络框架,从上到下应该是清晰分层的。我这次搭的分层结构如下:
- 接口定义层(API Interface) - 仓库层(Repository) - 统一响应处理层(Response Wrapper) - 网络核心层(Retrofit + OkHttp3) - 日志与监控层(Interceptor)每一层只负责自己的事情,下层不感知上层。接口定义层只写接口方法和注解,不关心数据怎么处理;仓库层负责数据转换和缓存逻辑;网络核心层负责请求的发起和重试;日志监控层只做横切关注点的处理。
2.2 Retrofit实例构建的细节
构建Retrofit实例是整套框架的起点,看似简单,但细节不少。先看代码:
object RetrofitClient { private const val BASE_URL = "https://api.example.com/" private const val TIME_OUT_SECONDS = 15L private val okHttpClient: OkHttpClient by lazy { OkHttpClient.Builder() .connectTimeout(TIME_OUT_SECONDS, TimeUnit.SECONDS) .readTimeout(TIME_OUT_SECONDS, TimeUnit.SECONDS) .writeTimeout(TIME_OUT_SECONDS, TimeUnit.SECONDS) .retryOnConnectionFailure(true) .addInterceptor(HttpHeaderInterceptor()) .addInterceptor(HttpLoggingInterceptor()) .addInterceptor(TokenAuthenticatorInterceptor()) .addInterceptor(CacheInterceptor()) .cache(cache) .build() } private val retrofit: Retrofit by lazy { Retrofit.Builder() .baseUrl(BASE_URL) .client(okHttpClient) .addConverterFactory(GsonConverterFactory.create()) .addCallAdapterFactory(RxJava3CallAdapterFactory.create()) .build() } fun <T> create(service: Class<T>): T { return retrofit.create(service) } }注意几个点。超时时间为什么要拆成三个分别设置?因为连接超时、读取超时、写入超时在实际网络环境中的表现差异很大,有时候弱网环境下连接很快但读取很慢,有时候上传大文件时写入超时,这三个参数分开设置才能在出现问题的时候快速定位。
retryOnConnectionFailure这里有个坑,官方默认是true,也就是连接失败会重试一次,但对于POST请求这个重试可能会造成重复提交。如果业务场景不能接受重复提交,这个参数建议显式设置为false,让上层用RxJava3的retry操作符精确控制重试时机和次数。
2.3 接口定义的最佳实践
接口定义这里踩过最多的坑是泛型滥用和RequestMapping混乱。我推荐的做法是定义一个统一的泛型包装类,这样泛型擦除的问题也能规避:
data class ApiResponse<T>( val code: Int, val message: String, val data: T? ) interface UserApi { @POST("user/login") fun login(@Body request: LoginRequest): Observable<ApiResponse<UserInfo>> @GET("user/profile") fun getProfile(@Query("userId") userId: String): Observable<ApiResponse<UserProfile>> @POST("user/logout") fun logout(@Header("Authorization") token: String): Observable<ApiResponse<Unit>> }ApiResponse这个包装类是整个框架的契约基础。很多接口不是统一的返回结构,这是一个非常头疼的业务现实。我的处理方式是:如果公司后端不统一返回格式,那就分两套接口定义,一套返回ApiResponse包装类型,另一套直接返回原始类型,在仓库层做适配。千万别试图用一个包装类强行套所有接口,泛型擦除和类型转换会让你焦头烂额。
3. OkHttp3拦截器层的核心细节
3.1 请求头注入拦截器的正确写法
请求头注入是最基础的需求,公共参数、设备信息、版本号都需要在请求发出前自动加到Header里。很多人写Header拦截器时都会犯一个错误:直接用request.newBuilder().addHeader(),这样会导致同一个Header出现多个值。
class HttpHeaderInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() val requestBuilder = originalRequest.newBuilder() .header("Content-Type", "application/json; charset=UTF-8") .header("Accept", "application/json") .header("App-Version", BuildConfig.VERSION_NAME) .header("Device-Id", DeviceUtils.getDeviceId()) .addHeader("Accept-Language", LocaleUtils.getCurrentLanguage()) .method(originalRequest.method, originalRequest.body) return chain.proceed(requestBuilder.build()) } }这里用header()不是addHeader(),两者的区别在于header()会覆盖同名的所有旧值,addHeader()是追加。对于Content-Type、Accept这种不能重复的Header必须用header(),但对于某些允许重复的Header字段才用addHeader()。动手写之前先想清楚这个Header允许不允许重复,这决定了你用哪个方法。
3.2 日志拦截器与进阶优化
OkHttp3官方提供了HttpLoggingInterceptor来做日志,这是最基础的做法。但实际项目中官方这个日志有几个痛点:一是在Release包没法自动关闭,二是日志内容在Logcat里太长会被截断,三是数据量太大影响性能。
我的做法是包装一层:
class HttpLoggingInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { if (!BuildConfig.DEBUG) return chain.proceed(chain.request()) val request = chain.request() logD("--> ${request.method} ${request.url}") val response = chain.proceed(request) val bodyString = response.peekBody(1024 * 1024).string() logD("<-- ${response.code} ${response.request.url}") if (bodyString.isNotEmpty()) { logJson(bodyString) } return response } }核心技巧是用peekBody()而不是body()。body()是流式读取,读取之后Response就不能再用了,会直接导致业务层解析失败。peekBody()会拷贝一小段内容,读取之后不影响后续正常使用。这里设置1MB的上限,因为一般接口响应不会超过这个值,超过1MB的说明你的接口设计有问题,或者压根不该走日志。
日志封装完要考虑一个问题:线上问题排查怎么办?我的做法是,Release包里保留一个可配置的日志开关,通过本地调试面板远程打开,日志写到文件而不是Logcat,当天文件超过一定大小自动滚动。这样既保证了线上性能,又能在用户反馈问题时快速抓日志。
3.3 Token失效自动刷新与请求重放
这个功能是拦截器层最复杂的部分,也是最能体现框架价值的地方。核心逻辑是:请求发出后如果返回401,说明Token失效,这时拦截器应该暂停当前请求,去刷新Token,刷新成功后再重放当前请求,刷新失败则通知上层退出登录。
class TokenAuthenticatorInterceptor( private val tokenManager: TokenManager, private val apiService: AuthApi ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val originalRequest = chain.request() val response = chain.proceed(originalRequest) if (response.code == 401 && !originalRequest.url.toString().contains("auth/refresh")) { synchronized(TokenLock) { val hasRefreshed = refreshToken() if (hasRefreshed) { return chain.proceed(newRequestWithNewToken(originalRequest)) } } } return response } private fun refreshToken(): Boolean { return try { val refreshToken = tokenManager.getRefreshToken() val newToken = apiService.refreshToken(refreshToken).execute().body() if (newToken != null) { tokenManager.saveTokens(newToken.accessToken, newToken.refreshToken) true } else { tokenManager.clear() false } } catch (e: Exception) { false } } }这里最关键的坑是并发问题。假设在同一时间有5个请求都返回401,如果不加锁,5个请求会同时去刷新Token,刷新5次,返回结果还是最后一次生效,前四次白白浪费。所以必须用synchronized或者更高级的锁机制,保证同一时间只有一个线程在刷新Token。
刷新Token时用的execute()方法是同步请求,这在拦截器里是故意的。因为拦截器方法本身就是同步执行的,如果你在拦截器里去用异步请求,线程模型会混乱,导致回调迟迟不执行。
另外要注意刷新Token的接口本身不能走这个拦截器,否则会死循环。我用url.contains("auth/refresh")来判断,但更严谨的做法是给这个API加一个专属的Retrofit实例,不走这个拦截器。
3.4 合理的缓存策略设计
OkHttp3的缓存策略需要服务器配合返回Cache-Control头。但很多时候后端同学根本配不齐缓存头,这时可以从客户端指定强制缓存时间。
class CacheInterceptor : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() val originalResponse = chain.proceed(request) val cacheControl: String = when (NetworkUtils.isNetworkAvailable()) { true -> "public, max-age=60" false -> "public, only-if-cached, max-stale=604800" } return originalResponse.newBuilder() .header("Cache-Control", cacheControl) .removeHeader("Pragma") .build() } }这个逻辑很直白:有网的时候允许缓存60秒,60秒内重复请求直接走缓存,不发出真实网络请求;没网的时候允许用7天内的缓存数据。这种策略对列表页、首页这种变化不频繁的接口太友好了,体验上给人的感觉就是“秒开”。
但要注意,有些接口是不能缓存的,比如用户隐私数据、余额信息。这种接口怎么办?我采取的方式是在API接口上做一个穿透标识,比如给接口请求URL的前面不加缓存拦截器,单独放行。代码层面可以通过判断请求的Header来做,给特定接口添加Tag,在拦截器里识别Tag后跳过缓存逻辑。
4. RxJava3线程调度与生命周期绑定的实操
4.1 线程调度的标准范式
RxJava2切换到RxJava3之后,绝大部分API是兼容的,但有一件事必须注意:RxJava3的包名从io.reactivex变成了io.reactivex.rxjava3,Maven坐标也不一样了。如果你在网上搜索资料的时候,看到说RxJava2的用法,代码大概率还能跑,但要手动改import。
线程调度的标准写法是:
apiService.login(LoginRequest(username, password)) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe({ response -> // 主线程接收结果 handleLoginSuccess(response) }, { throwable -> // 主线程处理异常 handleError(throwable) })这个写法背后的机制值得理解一下:subscribeOn指定的是Observable开始发送事件时执行的线程,observeOn指定的是下游观察者接收事件的线程。subscribeOn和observeOn可以出现多次,但subscribeOn只有第一次有效,而observeOn每一次都会生效。这是RxJava的经典陷阱,如果在复杂的链式操作里没搞明白这点,很容易出现线程切换和预期不符的问题。
4.2 生命周期绑定解决内存泄漏
用RxJava做网络请求最容易踩的坑就是内存泄漏。Activity销毁了,网络请求还没回来,回调已经引用着Activity的上下文,这就导致Activity无法被回收。
解决办法是使用AutoDispose或者RxLifecycle。我这次用的是AutoDispose,因为它的侵入性最小,不需要让Activity继承特定的基类。
apiService.getUserProfile(userId) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .as(AutoDispose.autoDisposable(AndroidEventListeners.provider(this))) .subscribe({ response -> // 安全接收结果 }, { throwable -> // 安全处理异常 })AutoDispose的原理是在RxJava的事件流中间插入一个处理器(Disposable),它会监听Activity或Fragment的生命周期,当onDestroy触发时会自动调用dispose()方法切断事件流。这样即使网络请求最终返回了,回调也不会被执行的,不会访问已销毁的View和上下文。
注意Fragment里用this的时候,要确保this确实实现了LifecycleOwner接口。AndroidX的Fragment和Activity都实现了这个接口,但如果你在自定义View或者其他非LifecycleOwner里使用AutoDispose,需要自己传入一个LifecycleProvider。
4.3 链式组合操作符的场景实战
RxJava3最爽的地方在于对多个请求做组合调度。这里分享三个我在实际项目中高频使用的场景。
场景一:两个接口完全并行,都返回之后合并结果。这种适合首页这种需要同时展示用户信息和配置信息的场景,两个接口没有依赖关系,可以并发请求:
Observable.zip( apiService.getUserInfo(), apiService.getAppConfig(), BiFunction { userInfo: UserInfo, appConfig: AppConfig -> HomeData(userInfo, appConfig) } ) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe({ homeData -> // 同时拿到两个数据 }, { error -> // 处理异常 })zip操作符会等待两个Observable都发射完毕再合并,如果其中一个接口特别慢,整个合并会等那个慢的。所以在业务层面要评估清楚,两个接口是否真的需要同时展示,还是一个可以先展示一部分内容。
场景二:第二个接口依赖第一个接口的返回值。比如先拿用户Token,再用Token去换用户详情:
apiService.getToken() .flatMap { token -> apiService.getUserDetail(token) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe({ userDetail -> // 拿到用户详情 }, { error -> // 处理异常 })flatMap是你在这里的老朋友,它把上游的每个事件转换成新的Observable,然后合并这些Observable的事件,最后发射到下游。这里要注意flatMap是乱序的,如果你关心顺序,要改用concatMap。
场景三:请求失败自动重试。网络抖动导致一次失败是很常见的事情,无脑重试3次并不优雅,更合理的做法是加上退避策略:
apiService.getData() .retryWhen { errors -> errors.zipWith(Observable.range(1, 3)) { error, retryCount -> if (retryCount >= 3) { throw error } retryCount }.flatMap { retryCount -> Observable.timer((retryCount * 2).toLong(), TimeUnit.SECONDS) } } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe({ data -> // 成功 }, { error -> // 重试3次后还是失败 })这里的逻辑是延迟重试,第1次等2秒,第2次等4秒,第3次等6秒,总共最多重试3次。这种退避策略比毫不停顿的立刻重试成功率要高得多,也减轻了对服务端的压力。
5. 统一数据解析与错误处理的全流程
5.1 多层级的数据解析器设计
网上的框架教程大多只展示GsonConverterFactory.create()一行代码,但实际业务需求远没这么简单。真实项目中接口返回未必是规范的JSON,可能有的字段是下划线风格,有的是驼峰风格,还可能是字符串里包着一个JSON。
我这次做了两层解析策略。外层用Gson响应body的原始类型,内层用自定义的TypeAdapter处理复杂字段:
class ApiResponseAdapter<T>(private val type: Type) : JsonDeserializer<ApiResponse<T>> { override fun deserialize( json: JsonElement, typeOfT: Type, context: JsonDeserializationContext ): ApiResponse<T> { val jsonObject = json.asJsonObject val code = jsonObject.get("code").asInt val message = jsonObject.get("message").asString val dataElement = jsonObject.get("data") val data: T? = if (dataElement != null && !dataElement.isJsonNull) { context.deserialize(dataElement, type) } else { null } return ApiResponse(code, message, data) } } class ApiResponseTypeAdapterFactory : TypeAdapterFactory { override fun <T> create(gson: Gson, type: TypeToken<T>): TypeAdapter<T>? { if (type.rawType != ApiResponse::class.java) return null val parameterizedType = type.type as ParameterizedType val argumentType = parameterizedType.actualTypeArguments[0] val adapter = gson.getAdapter(TypeToken.get(argumentType)) return ApiResponseTypeAdapter(adapter) as TypeAdapter<T> } }GsonConverterFactory里传入这个自定义的TypeAdapterFactory,这样Retrofit在反序列化ApiResponse时就会走我们的逻辑。这个设计的核心优势是,data字段的解析可以完全复用Gson的泛型机制,而code、message的处理逻辑完全收敛在框架层。
下划线转驼峰的问题,最简单的方案是反序列化时设置FieldNamingPolicy:
val gson = GsonBuilder() .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) .registerTypeAdapterFactory(ApiResponseTypeAdapterFactory()) .create()但这里要谨慎,全局设置下划线转驼峰后,如果你有的接口本来就返回驼峰,就会解析不出来。我见过很多项目在这个问题上翻车,所以建议还是要后端统一规范,规范不了就interface里给每个字段都加@SerializedName,明确标注,别指望靠一个全局策略解决问题。
5.2 错误码统一封装与业务异常
网络异常和业务异常应该明确区分。网络异常指连接超时、DNS解析失败这种底层问题,业务异常指接口返回了code=50001,表示用户不存在。这两类异常的捕获和处理逻辑完全不一样。
我的框架里定义了一个基类异常和一个业务异常:
class NetworkException( val errorCode: Int, val errorMessage: String, cause: Throwable? = null ) : Exception(errorMessage, cause) class BusinessException( val businessCode: Int, override val message: String ) : Exception()然后在请求结果回到业务层之前统一做转换:
fun <T> Observable<ApiResponse<T>>.handleResult(): Observable<T> { return this.map { response -> if (response.code == 200) { response.data } else { throw BusinessException(response.code, response.message) } } }这样业务层订阅时,onNext只接收成功的业务数据,onError里只需要做一次判断:如果是BusinessException就处理业务错误(弹Toast、跳登录),如果是NetworkException就统一提示网络错误。业务层不再需要每个接口都写一遍code判断,这是提升效率最明显的一点。
错误码的映射也是可以做的。比如401应该跳到登录页,403应该提示没有权限,502应该提示服务器开小差了。这些映射逻辑可以塞进一个ErrorMapper类里,统一处理。
5.3 Loading状态管理的下沉
业务层最烦的工作之一就是每个网络请求都要手动控制Loading的显示和隐藏。我见过很多项目在Activity里到处写着showLoading()和dismissLoading(),一旦忘记隐藏就会引发崩溃。
这次我把Loading管理也做进了框架。核心思路是在事件流发送开始时通知Loading展示,事件流终止时通知Loading消失:
fun <T> Observable<T>.bindLoading(loadingView: LoadingView): Observable<T> { return this.doOnSubscribe { loadingView.showLoading() }.doOnTerminate { loadingView.hideLoading() } } fun <T> Observable<T>.bindLoading( loadingView: LoadingView, showDelayMillis: Long = 300 ): Observable<T> { var showTask: Disposable? = null return this.doOnSubscribe { showTask = Observable.timer(showDelayMillis, TimeUnit.MILLISECONDS) .subscribe { loadingView.showLoading() } }.doOnTerminate { showTask?.dispose() loadingView.hideLoading() } }doOnSubscribe在订阅时执行,doOnTerminate在事件流结束时执行,无论成功还是失败都会执行。这里做了一个优化,延迟300毫秒才显示Loading,这样如果接口本身响应很快(比如200毫秒),用户根本感知不到Loading的存在,避免了界面闪烁。这个细节对用户体验的提升是很实在的。
6. 常见问题与排查技巧实录
6.1 构建期问题:如何从RxJava2迁移到RxJava3
如果你和我一样是从老项目升级过来的,迁移过程会比想象中麻烦。RxJava3不仅仅是换个包名这么简单,有一些方法签名变了,也有一些内置类被移除了。
我整理了几个最常见的迁移失败场景:
- import语句全部从io.reactivex改成io.reactivex.rxjava3
- Observable.empty()的行为变了,需要手动指定类型,否则编译不通过
- Maybe类型的处理方式不同,subscribe成功回调的入参发生了变化
- Flowable新增了更多的背压策略
一个偷懒但有效的办法是全局搜索“io.reactivex.”并全局替换成“io.reactivex.rxjava3.”,但替换完之后不要以为就完事了,编译一次,把报错的地方逐个击破。实测下来项目里90%的代码只需要换import就能跑通,剩下10%的兼容性问题集中在错误处理函数签名和自定义操作符上。
6.2 运行时问题:Gson解析LocalDate失败的坑
Retrofit2配合Gson时,如果你用的是Java 8的LocalDate类型,Gson默认是解析不了的,会直接报JsonSyntaxException。这是我踩得最深的坑之一。
解决方案是注册一个LocalDateTypeAdapter:
class LocalDateTypeAdapter : JsonDeserializer<LocalDate>, JsonSerializer<LocalDate> { override fun deserialize( json: JsonElement, typeOfT: Type, context: JsonDeserializationContext ): LocalDate { return LocalDate.parse(json.asString, DateTimeFormatter.ISO_LOCAL_DATE) } override fun serialize( src: LocalDate, typeOfSrc: Type, context: JsonSerializationContext ): JsonElement { return JsonPrimitive(src.format(DateTimeFormatter.ISO_LOCAL_DATE)) } } val gson = GsonBuilder() .registerTypeAdapter(LocalDate::class.java, LocalDateTypeAdapter()) .create()更全面的做法是把LocalDateTime和OffsetDateTime也一并注册适配器,否则早晚会在某个接口上踩雷。这个坑特别隐蔽,因为本地测试时明明没问题,一到线上某个接口返回了一个时间字段,整个解析就挂掉了,而且报错信息还不好定位。
6.3 排查技巧:怎么快速定位网络请求到底卡在哪一步
网络请求慢或者不返回,是最难排查的问题。排查的第一步是确认请求真发出去了。用日志拦截器看请求头和响应码,如果日志里没有任何输出,说明请求压根还没走到网络层,问题出在调用方;如果有请求日志但没有响应日志,说明响应真的很慢或者被服务端挂起。
第二步是看线程池。RxJava的Schedulers.io()默认使用无界线程池,正常情况下没问题,但如果你在代码里大量使用blockingXXX或者把耗时操作误放到IO调度器里,线程会被占满。在日志里打印一下线程名和数量,这是判断线程池健康程度的直接手段。
第三步是抓Chuck或者自己写一个网络面板。我的做法是在Debug模式下给OkHttp3加一个自定义的EventListener,把每次请求的DNS解析时间、连接时间、TLS握手时间、发送时间、等待时间、接收时间全部统计出来。可以看到一个请求到底慢在哪个阶段,DNS慢就优化DNS,等待慢就排查服务端逻辑。这一步信息量非常大,强烈建议有条件的话都做上。
6.4 常见问题速查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| Retrofit接口无法解析 | 接口没有加注解,或注解里缺少HTTP方法 | 检查@GET、@POST等注解是否完整 |
| 参数拼接错误 | @Query和@Path用错位置 | @Query用于拼接?参数,@Path用于替换路径 |
| 发送请求后无任何日志 | 拦截器顺序不对或日志开关被关闭 | 确认日志拦截器在Interceptor链中且BuildConfig.DEBUG为true |
| 回调不执行 | 未添加RxJava3CallAdapterFactory | 检查Builder里是否调用addCallAdapterFactory |
| 请求成功但解析为空 | 服务器返回非Json格式或Gson字段名不匹配 | 打开日志打印原始body,逐个对比字段名 |
| 内存泄漏 | 网络订阅未解除 | 使用AutoDispose或手动管理Disposable |
| Android 9以上无法访问HTTP | 系统默认禁止明文HTTP | 配置usesCleartextTraffic或networkSecurityConfig |
| 证书校验失败 | HTTPS证书不合法或日期过期 | 检查证书链和有效期,正式包不要关闭验证 |
6.5 几个独家避坑技巧
最后再分享几个我重写这套框架时踩出的心得,这些在官方文档里绝对找不到。
URL拼接上统一走HttpUrl。我在代码里看到过各种千奇百怪的URL拼接方式,有的直接字符串加号拼接,有的用String.format,一旦参数为空或者带特殊字符就直接崩掉。正确做法是:
val url = HttpUrl.Builder() .scheme("https") .host("api.example.com") .addPathSegment("user") .addQueryParameter("id", userId) .build() .toString()这样做URL编码全部由HttpUrl处理,永远不会出现中文乱码和特殊字符错误。
不要使用Call.enqueue。Retrofit2默认支持Call方式,但如果你已经用了RxJava3,就不要在代码里混着用Call.enqueue。两种异步模式混用会带来巨大的心智负担。我这次统一要求代码里禁止使用Call类型,全部走Observable。Review代码时看到Call直接打回。
文件上传下载单独处理。大文件上传不能走普通的JSON接口,要单独封装一个上传接口,用MultipartBody加自定义进度回调的RequestBody。下载则要单独处理流式读取,不能把整个文件读进内存。这些场景如果塞进通用框架里,会让框架变得臃肿,建议单独开服务类处理,框架只负责通用请求。
统一在框架层打印完整日志。日志不是Debug专属品,线上问题排查时日志可能是唯一的线索。我的做法是框架层预留一个LogSink接口,Debug时输出到Logcat,Release时可以配置输出到文件,同时支持远程开关。这个不会增加多少开发成本,但紧急故障时能救你一命。
经过这一轮重构,最直观的收益是新增一个接口的代码量大幅下降。定义好接口、仓库里一行调用、Activity里订阅,三步就完事。对团队来说,因为所有网络请求都收敛到统一的链路上,代码审查的负担也轻了很多,错误的可能性被限制在了框架内部。这套三件套方案也许不是最前沿的,但如果你想要一个稳定、可控、团队容易上手的网络层,它仍然是目前综合性价比最高的选择之一。