一个工程里两套展示层模式并存,网络框架被彻底关在仓储后面------分层的目的,是让变化只影响一层。
前言
很多团队把"统一架构"奉为铁律:要么全部 MVVM,要么全部 MVP,谁引入第二种谁就挨批。理由听起来也很充分------降低理解成本、保证一致性。可现实里架构选型跟"问题域"强相关:有的页面天然适合数据驱动,有的页面本质是一次性操作,硬套一种模式反而别扭。
展示层之外还有一层更容易被忽略:网络。用 Retrofit 的人都知道它省事,但很多工程写着写着就"Retrofit 泄露"了:Repository 返回 Response<T>、界面层直接拿 Call、甚至 OkHttpClient 被传来传去。哪天想换框架、想统一加缓存和鉴权头,就得把整条调用链翻一遍。
我见过一个工程,MVVM 和 MVP 两套模式并存 ,而且是有意为之,不是历史遗留;它的数据层则把 Retrofit 彻底藏在接口后面。这篇文章把它们放在一起讲,是因为两者其实是同一条主线:分层的目的就是让变化只影响一层------换展示模式只动展示层,换网络框架只动数据层。
一、共同根因:视图和"它不该知道的东西"焊死了
MVP 的痛点是把逻辑写在 Activity 里,MVVM 的痛点是把网络细节写在 ViewModel 里,本质是一回事:某一层知道了它不该知道的东西。
- 视图层直接
if (resp.isSuccessful),于是它必须知道 HTTP 状态码的语义; - ViewModel 直接
import retrofit2.Call,于是它被绑死在某个网络框架上; - Presenter 里直接处理厂商错误码,于是换一家 SDK 就得改 Presenter。
所以后面两节的检验方式是同一条:把下层替换掉,上面要改几行? 展示层要回答"换一种模式要不要动 Model",数据层要回答"换一个网络框架要不要动业务"。
二、展示层:MVP 与 MVVM 各自的舒适区
2.1 MVP:接口回传,适合一次性操作
先说 MVP(Model-View-Presenter) 。它的核心是把"逻辑"从"视图"里抽到 Presenter,视图只做展示和转交事件,逻辑结果通过接口回调回传:
kotlin
interface TransformView {
fun onResult(r: Result)
}
class TransformPresenter(private val view: TransformView) {
fun compute(x: Double, y: Double) {
val result = Transformer.transform(x, y) // 同步算法
view.onResult(result) // 直接回传
}
}
它适合"一次性、命令式"的场景:点个按钮、算个坐标、返回结果。状态少,用接口回传最直白,代码量最小。
2.2 MVVM:状态流驱动,适合持续状态
再说 MVVM(Model-View-ViewModel) 。它的核心是"数据驱动":ViewModel 持有一堆可观察状态,视图订阅这些状态自动刷新,逻辑与视图通过"观察"而不是"调用"连接:
kotlin
class DeviceViewModel : ViewModel() {
private val _state = MutableStateFlow(DeviceUiState())
val state: StateFlow<DeviceUiState> = _state.asStateFlow()
fun refresh() {
viewModelScope.launch {
_state.value = repository.load().let { DeviceUiState(it) }
}
}
}
它适合"状态多、响应式、生命周期长"的场景:设备列表、设置面板、实时状态,ViewModel 能扛住配置变更(旋转、切后台),状态不丢。
2.3 为什么不能"一刀切"
看上面两个例子就明白:选型的本质是"视图和逻辑之间是同步问答还是持续观察"。
- 坐标换算这种,逻辑是同步的、一次性的,用 MVP 的接口回传最省事,代码量最小;
- 设备状态这种,状态是持续变化的、要响应式刷新、还要在配置变更后保留,用 MVVM 的状态流最合适。
硬把坐标换算塞进 MVVM,你得包一层"假装的状态流",绕;硬把设备列表塞进 MVP,你得自己处理生命周期和状态保留,累。架构是工具,不是信仰------按问题域选,比"全工程统一"更能写出可维护的代码。
三、并存的关键:守住边界
两套模式并存之所以不混乱,靠的不是运气,而是清晰的边界约定:
- 模式选型在页面(Feature)粒度决定,不跨层混用------一个 Feature 内部只用一种模式;
- 底层数据访问统一,不管哪个页面,都走同一套仓储接口,模式差异只体现在"展示层";
- 命名与目录能一眼看出模式,看到某目录结构就明白这是 MVP 还是 MVVM,不用猜。
这样"模式"被限制在很薄的展示层,底层共享同一套 Model 和 Repository,切换成本反而低。这也是"新旧并存而不重构"能长期成立的前提:不是不管,而是用约定把差异钉死在展示层。
3.1 一条实用的决策链
与其纠结"统一架构",不如建立一条决策链:
- 先问问题域:这个功能是"一次性问答"还是"持续状态"?
- 再看生命周期:是否需要跨配置变更保留状态?需要 → MVVM;
- 再看复杂度:逻辑是否简单到用一个回调函数就能说清?是 → MVP 更省;
- 最后看团队:同一模块多人维护,优先选团队最熟的模式。
记住:架构的一致性在于"决策有依据",而不是"模式唯一"。一个团队能说清楚"为什么这里用 MVVM、那里用 MVP",比机械地全工程统一更有价值。真正的重构,应该发生在"某一层已经撑不住它要表达的问题"时,而不是"我看它不顺眼"时。
四、数据层:把网络框架关进仓储
展示层讲完,轮到数据层。这里的目标和展示层同源:让业务只依赖"我要数据"这个抽象,不依赖"数据是用谁取的"。
4.1 分层长什么样
这套网络层大致是三层:
- 接口层:定义"这个业务能做什么",不出现任何网络框架类型;
- 客户端层:统一 Retrofit / OkHttp 单例,负责 baseUrl、超时、拦截器、日志;
- 仓储层:实现接口,内部完成"调接口 → 拆解响应 → 给业务"。
业务代码只依赖第 1 层的接口,这就是"依赖倒置"在数据访问层的落地。
4.2 接口层:业务只看到抽象
接口只声明业务能力,返回值是干净的数据模型,不是 Call、不是 Response<T>:
kotlin
interface DeviceGateway {
fun queryStatus(deviceId: String): Flow<DeviceStatus>
fun uploadLog(log: ByteArray, onProgress: (Float) -> Unit)
}
界面和 ViewModel 调用它时,完全不知道背后是 Retrofit 还是手写 Socket。换实现不影响上层------这是分层最直接的红利。
顺带说清一个容易被混在一起的扩展点:Retrofit 里 Converter 负责"把 @Body 序列化成 RequestBody、把 ResponseBody 反序列化成返回类型"(如 Gson / Moshi),CallAdapter 负责"把 Call<T> 适配成别的返回类型"(如 suspend 函数、Flow)。记住这条分工,就知道"返回 Flow 该配哪个"了------是 CallAdapter 的事,不是 Converter 的事。
4.3 客户端层:单例 + 拦截器
Retrofit 单例统一负责所有网络配置:baseUrl、超时、日志、鉴权头,通常通过一组 OkHttp 拦截器挂上去。这样"统一加头、统一打日志"集中在一处,而不是散落在每个调用点:
kotlin
val okHttpClient = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(AuthInterceptor()) // 统一加鉴权头
.addInterceptor(HttpLoggingInterceptor()) // 统一打日志
.build()
val retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient)
.build()
鉴权头的拦截器长这样,注意它是"应用级"拦截器:
kotlin
class AuthInterceptor(private val tokenProvider: () -> String?) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val token = tokenProvider()
val request = if (token.isNullOrEmpty()) chain.request()
else chain.request().newBuilder()
.header("Authorization", "Bearer $token")
.build()
return chain.proceed(request)
}
}
这里有个必须分清的区别(OkHttp 官方对两者的定义):
| 对比项 | Interceptor(应用级) |
NetworkInterceptor(网络级) |
|---|---|---|
| 调用次数 | 每次 Call 只调用一次 |
每个网络请求都调用一次 |
| 重定向 / 重试 | 不重复调用 | 会重复调用 |
| 命中缓存时 | 仍会调用 | 不会调用 |
| 适合干什么 | 加鉴权头、统一日志、统计 | 观察真实网络流量、改 Cache-Control |
结论很直接:加鉴权头用 Interceptor ,因为一个 Call 只需加一次;如果错用成 network interceptor,重定向后可能会重复加头。
还有一个常被和拦截器混淆的扩展点:Authenticator 。它不是"每次请求都跑",而是只有在收到 401 响应时 才被调用,用来返回一个带新凭证的 Request 让 OkHttp 重试;返回 null 表示放弃。它和拦截器的区别是"按需触发"而非"每次触发":
kotlin
class TokenAuthenticator(private val refresh: () -> String?) : Authenticator {
override fun authenticate(route: Route?, response: Response): Request? {
// 已经重试过一次就放弃,避免 401 死循环
if (response.priorResponse != null) return null
val newToken = refresh() ?: return null
return response.request.newBuilder()
.header("Authorization", "Bearer $newToken")
.build()
}
}
4.4 上传下载进度:包装请求体
文件上传/下载的进度,也在这个单例里统一处理。核心思路是包装请求体/响应体,把字节读写切成长度回调:
kotlin
class ProgressRequestBody(
private val delegate: RequestBody,
private val onProgress: (Float) -> Unit,
) : RequestBody() {
override fun contentType() = delegate.contentType()
override fun contentLength() = delegate.contentLength()
override fun writeTo(sink: BufferedSink) {
val counting = object : ForwardingSink(sink) {
private var total = 0L
override fun write(source: Buffer, byteCount: Long) {
super.write(source, byteCount)
total += byteCount
onProgress(total.toFloat() / contentLength())
}
}
delegate.writeTo(Buffer().let { counting; counting })
}
}
进度回调通过拦截器或包装器注入,对上层透明 ------业务调一个 uploadLog,进度自己冒出来,不用业务方碰 OkHttp 的 Sink。
4.5 响应映射层:把"服务端约定"和"业务语义"解耦
网络响应的包裹结构(成功码、错误码、数据体)通常和业务字段混在一起。这一层负责把包裹层拆掉、把业务数据吐出来 ,用一个统一的 ApiResult 收敛成功/失败判定:
kotlin
sealed class ApiResult<out T> {
data class Success<T>(val data: T) : ApiResult<T>()
data class Error(val code: Int, val message: String) : ApiResult<Nothing>()
}
// 仓储内部:Call 的响应 -> 统一的 ApiResult,再给上层
suspend fun <T> safeCall(block: suspend () -> Response<T>): ApiResult<T> {
return try {
val resp = block()
if (resp.isSuccessful) {
ApiResult.Success(resp.body()!!)
} else {
ApiResult.Error(resp.code(), resp.message())
}
} catch (e: IOException) {
ApiResult.Error(-1, e.message ?: "network error")
}
}
这样业务拿到的是干净的 ApiResult<T>,要么有数据、要么有错误码,成功/失败的判定完全统一 ,不用每个调用方各自 if (resp.isSuccessful)。
五、把两层接起来:展示层怎么消费仓储
分层的价值要在"两层接起来"时才兑现。同一份 DeviceGateway,MVVM 侧和 MVP 侧的消费方式完全不同,但都不碰网络细节。
MVVM 侧:把 Flow 收集进状态流,随 ViewModel 一起被取消:
kotlin
class DeviceViewModel(private val gateway: DeviceGateway) : ViewModel() {
private val _state = MutableStateFlow(DeviceUiState())
val state: StateFlow<DeviceUiState> = _state.asStateFlow()
fun observeDevice(deviceId: String) {
viewModelScope.launch {
gateway.queryStatus(deviceId).collect { status ->
_state.value = _state.value.copy(status = status)
}
}
}
}
MVP 侧:Presenter 拿到结果后回调给视图,协程作用域由外部传入并在视图销毁时取消:
kotlin
class DevicePresenter(
private val view: DeviceView,
private val gateway: DeviceGateway,
private val scope: CoroutineScope,
) {
fun load(deviceId: String) {
scope.launch {
gateway.queryStatus(deviceId).collect { status ->
view.showStatus(status)
}
}
}
}
注意这里的分工:网络细节(超时、鉴权、错误码、进度)全部在 DeviceGateway 实现里;展示层只负责"订阅状态"或"接收回调"。所以换一种展示模式,数据层一行不动;换一个网络框架,展示层一行不动------这就是分层要的效果。
六、什么时候值得这么做,失败路径怎么验
6.1 判断标准
"接口 + 仓储"这套不是每个项目都要上。判断标准:
- 项目要统一错误码 / 统一超时 / 统一鉴权头,值得有这一层集中处理;
- 可能要换网络框架 / 加缓存 / 做离线优先,值得把细节关进仓储;
- 团队大、多人并行,接口隔离能减少互相
import网络类的冲突。
代价是:多一层抽象、多一些样板。网络只是访问数据的一种手段------如果业务视角是"我要数据",那么实现用 Retrofit、用 Ktor、甚至用 SQLite,都不该影响业务代码,这才是分层的意义。
6.2 分层的失败路径
- 网络失败的类型不止一种 :
Response.isSuccessful == false是 HTTP 层面的失败(拿到响应但状态码非 2xx);IOException(含连接超时、读超时)是根本没拿到可用响应。两者在ApiResult里要能区分,否则"网络断了"和"服务端返回 404"会混成一条错误提示。 - 超时的三个维度 :
connectTimeout管"建立连接",readTimeout管"两次读之间等待多久",writeTimeout管"两次写之间等待多久"。它们都是"间隔"语义,不是"整个请求总时长"。 - 协程取消要能传到网络层 :Retrofit 的
suspend支持会在协程被取消时取消对应的Call,所以viewModelScope在 ViewModel 清理时取消,正在飞的请求也会被一并取消------这正是"两层接起来"时最该依赖的行为。 - 解析失败是第三种失败 :
body()!!在 body 为 null 时会抛异常,而反序列化失败通常抛的是JsonSyntaxException一类运行时异常。safeCall只捕获了IOException,如果你的 Converter 可能失败,要么扩大捕获范围,要么先让ApiResult多一个"解析失败"的语义。
怎么验证 :给 DeviceGateway 换一个"永远返回固定错误"的假实现跑一遍,看上层拿到的是不是统一的 ApiResult.Error;再换一个"永远抛 IOException"的实现,看界面是否显示网络错误而不是崩溃。分层做得好不好,用这两个假实现就能试出来。
结论
- 架构的一致性在于"决策有依据",而不是"模式唯一"------能说清为什么用哪种,比强行统一更有价值。
- **MVP 适合"一次性、命令式、状态少"**的场景,用接口回传最直白。
- **MVVM 适合"状态多、响应式、需跨配置变更保留"**的场景,用状态流驱动视图。
- 两套模式共存的关键:模式在页面粒度选型,底层统一仓储,展示层边界清晰。
- 业务层只依赖接口抽象 ,不 import 任何 Retrofit / OkHttp 类型;客户端用单例 + 拦截器统一 baseUrl、超时、鉴权头、日志。
- 区分
Interceptor与Authenticator:前者每次Call执行一次,用来加头打日志;后者只在 401 时触发,用来刷新凭证重试。 - 上传下载进度通过包装请求体 + 回调实现 ,对上层透明;用统一
ApiResult收敛成功/失败判定。 - 判断标准:要统一网络行为、可能要换实现、团队并行------值得分层;小项目别过度设计。
你的项目里 MVVM 和 MVP 是强制统一,还是按场景并存?网络层是把框架细节关进仓储,还是直接裸调 Retrofit?欢迎在评论区聊聊你的取舍。
关键词标签 :#Android #MVVM #MVP #架构分层 #网络层 #Retrofit #OkHttp #依赖倒置