Android 架构分层:MVVM 与 MVP 共存,网络层怎么切

一个工程里两套展示层模式并存,网络框架被彻底关在仓储后面------分层的目的,是让变化只影响一层。

前言

很多团队把"统一架构"奉为铁律:要么全部 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,你得自己处理生命周期和状态保留,累。架构是工具,不是信仰------按问题域选,比"全工程统一"更能写出可维护的代码。

三、并存的关键:守住边界

两套模式并存之所以不混乱,靠的不是运气,而是清晰的边界约定:

  1. 模式选型在页面(Feature)粒度决定,不跨层混用------一个 Feature 内部只用一种模式;
  2. 底层数据访问统一,不管哪个页面,都走同一套仓储接口,模式差异只体现在"展示层";
  3. 命名与目录能一眼看出模式,看到某目录结构就明白这是 MVP 还是 MVVM,不用猜。

这样"模式"被限制在很薄的展示层,底层共享同一套 Model 和 Repository,切换成本反而低。这也是"新旧并存而不重构"能长期成立的前提:不是不管,而是用约定把差异钉死在展示层。

3.1 一条实用的决策链

与其纠结"统一架构",不如建立一条决策链:

  1. 先问问题域:这个功能是"一次性问答"还是"持续状态"?
  2. 再看生命周期:是否需要跨配置变更保留状态?需要 → MVVM;
  3. 再看复杂度:逻辑是否简单到用一个回调函数就能说清?是 → MVP 更省;
  4. 最后看团队:同一模块多人维护,优先选团队最熟的模式。

记住:架构的一致性在于"决策有依据",而不是"模式唯一"。一个团队能说清楚"为什么这里用 MVVM、那里用 MVP",比机械地全工程统一更有价值。真正的重构,应该发生在"某一层已经撑不住它要表达的问题"时,而不是"我看它不顺眼"时。

四、数据层:把网络框架关进仓储

展示层讲完,轮到数据层。这里的目标和展示层同源:让业务只依赖"我要数据"这个抽象,不依赖"数据是用谁取的"。

4.1 分层长什么样

这套网络层大致是三层:

  1. 接口层:定义"这个业务能做什么",不出现任何网络框架类型;
  2. 客户端层:统一 Retrofit / OkHttp 单例,负责 baseUrl、超时、拦截器、日志;
  3. 仓储层:实现接口,内部完成"调接口 → 拆解响应 → 给业务"。

业务代码只依赖第 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 #依赖倒置

相关推荐
博风1 小时前
7、设计模式应用举例:策略模式:加密、解密文件
设计模式·策略模式
mmsx2 小时前
Android 本地优先的三级语义理解降级链
android·数码相机·策略模式
朱涛的自习室3 小时前
我让 Gemini 4 当高管、DeepSeek 当员工,开了一家软件公司(Day 1)
android·前端·人工智能
事圆则缓3 小时前
Android 中的 Socket 和 WebSocket:从一条聊天消息看清区别
android·websocket·网络协议
mmsx4 小时前
Android 流式对话实现:分层门面与 SSE 手工解析
android·github·sse
墨狂之逸才5 小时前
Android扫码异常排查小白教程
android
恋猫de小郭5 小时前
Kotlin 2.5 的 Companion 改造,对 KMP 的重要升级
android·前端·flutter
早点睡觉好了6 小时前
LangChain 源码入门 02:消息、ChatPromptTemplate 与 Chat Model 调用链
ai·langchain·策略模式
Kapaseker6 小时前
写了这么久 Kotlin,你真的理解 in、out 和 * 吗?
android·kotlin