☰
MVP架构实战:Android项目代码重构与职责分离指南
2026/10/9 9:08:58 网站建设 项目流程

软件开发越做越久,我越发觉得一个问题值得反复琢磨:一个项目在最开始设计架构时,代码写得再爽,都不如三年后还能让人看懂、改得动来得实在。这个道理放在Android和客户端开发上尤为明显,因为界面、状态、数据来回交织,稍不注意,一个Activity就能膨胀成上千行的大泥球。今天想和你聊聊MVP架构,一个经典到几乎快被“遗忘”,但在中小型项目里依然非常能打的模式,尤其适合刚入行或者正在重构旧项目的朋友。

MVP全称是Model-View-Presenter,它核心要做的事情是:把界面显示、业务逻辑、数据处理这三件事拆开,让它们各自待在自己的房间里,互不越界。你不需要一开始就背概念,我们可以从一个具体的场景出发,看看没有架构之前代码是怎么乱的,然后一步步把这个架构搭起来。这篇文章不会堆术语,我会尽量用实际代码和踩过的坑来说话,适合Android初学者,也适合想重新梳理自己项目的朋友。

1. MVP架构的定位与设计思路

1.1 从界面代码乱成一团说起

很多新手写Android项目,最自然的做法是:Activity里写网络请求、解析数据、更新控件、再监听按钮事件,所有逻辑全部塞在onCreate和接口回调里。前两个月项目小,你觉得挺爽,后来需求一多,Activity就到了两千行。再加一个需求,改一个数据格式,你可能要翻屏幕找断点,改完还要担心别的地方引用同一个变量。

这种代码的基本特征就是“职责不分”。UI描述、业务状态、数据获取、异常处理全都堆在一起,导致三个典型问题。

第一个是测试困难。业务逻辑藏在Activity里,你要测就必须起模拟器、走页面、点按钮,自动化测试无从下手。第二个是复用困难。你在登录页写了一套用户信息解析的逻辑,到了首页或者设置页想复用,要么复制黏贴,要么就得抽出公共类,而Activity本身又不能继承别的类。第三个是维护困难。一个界面一个类,改的时候处处牵动,业务变化稍微复杂一点,onCreate就变成灾难现场。

MVP的提出其实就是冲着这三点去的。它的核心套路是:View只管显示界面和把用户操作抛出去,Presenter负责处理业务逻辑、决定“接下来该干什么”,Model负责管理和提供数据。View和Model之间绝不直接对话,全部通过Presenter中转。

1.2 MVP到底在解决什么问题

如果你去翻官方文档能发现,Android本身是不强制你用任何架构的,MVP、MVC、MVVM都是社区实践的产物。MVP核心解决的问题不是“代码怎么写得好看”,而是“变更发生时,影响面到底有多大”。

举一个很实际的例子:产品经理说“登录按钮在点击后要先展示loading再跳转”,这个需求在MVP里你只需要改Presenter一个文件,因为按钮点击由View转发给Presenter,Presenter决定“view.showLoading()”“model.login()”“view.redirect()”。界面样式、按钮颜色这些改动只会落在View;数据来源从本地换成远端,只影响Model。每一层只关心自己那一摊事,这个就是“关注点分离”。

另一个MVP很拿手的方向是可测试性。因为View和Presenter都抽象成接口,你可以在单元测试里传一个假的View对象进去,验证Presenter在收到错误码的时候有没有调用view.showError(),完全不需要启动真机。

我经常给新人打一个比方:MVP就像开餐馆,View是服务员,只负责记录客人要什么并把菜端上来;Model是后厨,只管把原材料做成菜;Presenter是店长,客人点了什么、后厨该做什么、菜做得慢要怎么安抚客人,都归店长协调。服务员不会跑到后厨亲自炒菜,后厨也不会出来和客人解释菜为什么晚了。

1.3 MVP的边界与分工原则

MVP看上去只是三个字母,但真正容易出问题的是边界划分。很多初学者写着写着就把View里塞满逻辑,或者让Model直接返回UI状态,最后整个架构又糊了。

在我的实践里,明确的边界规则是下面几条:

  • View层只做四件事:展示数据、展示状态(loading/error/empty)、接收用户输入、把事件转交给Presenter。它不写业务规则,不判断数据该不该存,更不关心请求是走的HTTP还是走的缓存。
  • Model层只负责和“数据”打交道。它可以是本地数据库,可以是网络请求,也可以是一个纯内存的仓库。Model的返回值应该是纯数据对象,不应该包含任何和Android控件相关的东西。
  • Presenter层是唯一一个可以“指挥别人”的层。它从View得到事件,从Model取数据,然后把结果再交还给View。它不关心你的控件怎么画,也尽量不持有Activity的Context,因为它一旦过度依赖界面,测试性和复用性都会下降。

每次写一个新页面,先问自己三个问题:这个数据从哪来?这个结果要不要给用户看?用户操作后业务上应该做什么?答案分别指向Model、View、Presenter,这就是MVP落地的基本盘。

2. 三个组件的核心细节与交互规则

2.1 Model:数据与业务规则的容器

Model在MVP里容易被理解成“数据库类”,其实它涵盖的范围更多。一个合理的Model可以包含网络接口封装、缓存策略、数据仓库,以及部分纯计算逻辑。它对外暴露的应该是高层次的业务语义,比如“获得用户信息”“登录”“提交订单”,而不是“执行GET请求”“解析JSON”这类细节。

举个例子,你做一个天气App,View需要的是“北京今天的气温、湿度、天气状态”,Model给Presenter的应该是一个Weather对象,而不是一个JSON String。解析JSON、处理异常、判断返回码,这都属于Model该做的事情。

我自己的习惯是Model层再拆一层:Repository(数据仓库)和DataSource(数据源)。DataSource负责真正的网络请求/数据库操作;Repository负责决定从本地取还是从远端取,以及缓存策略。这一层拆分在MVP里不是必须的,但项目复杂度上来之后,它能让Model层内部也保持清晰。

2.2 View:只做展示,不做决策

View层是离用户最近的一层,所以很多人觉得“我写点判断逻辑方便”,比如“如果用户名是空的,我就隐藏按钮”,这其实是个危险的开始。一旦View里出现了“if (data == null || data.isEmpty())”这样的判断,说明业务逻辑已经开始泄漏到界面层。

View层应该做到“无脑执行指令”。Presenter叫它showLoading(),它就弹loading;叫它showError("网络不给力"),它就显示错误文案;叫它在某个输入框回填数据,它就执行回填。View对业务状态的理解仅限于“当前是loading,还是error,还是有数据”这些表现层状态。

在Android里,View通常是Activity、Fragment或自定义View。我建议你在MVP中把View定义成接口,而不是直接定义一个Activity基类。用接口的好处是:Presener拿到的只是抽象的View操作,不依赖具体实现;测试时还能传一个MockView进去。

2.3 Presenter:中间的“翻译官”

Presenter是MVP里工作量最大,也最容易被写坏的层。它负责接收View传来的用户操作,调用Model获取数据,然后决定View当前应该呈现什么状态。说得形象一点,Presenter是店长,掌握整个业务流程的节奏。

一个合格Presenter的代码结构通常是这种状态:

用户点击登录 -> presenter.login() -> model.login(username, password) -> 成功: view.showLoginSuccess(user) -> 失败: view.showLoginError(msg)

这里有很重要的一点:Presenter不能持有View的强引用,否则会内存泄漏。因为View(Activity)可以被系统销毁重建,但Presenter的生命周期可能比View更长。如果Presenter持有了Activity引用,在配置变更或页面关闭后,这个Activity就永远无法被回收。

我一般是这样做的:Presenter通过弱引用持有View,每次取View时判空。虽然这会让代码多一点防御性判断,但比起内存泄漏带来的崩溃,这点麻烦非常值。

2.4 接口设计与调用时序

MVP三个角色之间靠接口通信,所以接口设计得好不好,直接决定架构清不清晰。最常用的做法是定义一个契约接口(Contract),把某个页面的View和Presenter都放在这个契约里统一管理。

比如登录页的契约可能长这样:

interface LoginContract { interface View : BaseView { fun showLoading() fun hideLoading() fun showLoginSuccess(user: User) fun showLoginError(message: String) } interface Presenter : BasePresenter { fun login(username: String, password: String) } }

为什么要用一个契约类把它们装起来?因为一个页面的View和Presenter本来就应该是配套出现的。放一起,别人看代码时一眼就知道这个模块的交互边界在哪,也避免了类数量爆炸后找不到对应关系的麻烦。

调用时序上,流程是死的:View捕获用户操作 -> 调用Presenter对应方法 -> Presenter调用Model获取数据 -> Presenter把结果转换为界面状态 -> 调用View的方法展示。记住这个顺序,后面的代码就不会写乱。

3. 从零到一的MVP落地实操

3.1 先搭一个标准项目结构

这里我用Kotlin写一个用户登录模块,用一个真实的例子把MVP串起来。项目结构我会按下面这样组织:

com.example.mvpdemo ├── base # 基础接口 │ ├── BasePresenter │ └── BaseView ├── data # Model层 │ ├── api │ ├── model │ └── repository ├── ui │ ├── login # 登录模块 │ │ ├── LoginContract │ │ ├── LoginPresenter │ │ └── LoginActivity

先定义两个基础接口:

interface BasePresenter { fun start() // 页面初始化时调用 fun destroy() // 页面销毁时调用 } interface BaseView { // 如果需要Context相关操作,可以在这里定义抽象的ShowToast方法 }

为什么需要start()和destroy()?因为MVP里需要管理生命周期。start()里可以做初始化加载,destroy()里取消网络请求、释放资源。没有这两个方法,你就要把生命周期逻辑到处散写,Presenter就失去了控制节奏的意义。

3.2 定义登录模块的契约接口

接下来是LoginContract。这里我故意加了些更详细的接口方法,把真实业务里的loading、错误提示、输入校验都包含进来:

interface LoginContract { interface View : BaseView { fun showLoading() fun hideLoading() fun setLoginButtonEnabled(enabled: Boolean) fun showUsernameError(message: String) fun showPasswordError(message: String) fun showLoginSuccess(user: User) fun showLoginFailed(message: String) } interface Presenter : BasePresenter { fun onUsernameChanged(username: String) fun onPasswordChanged(password: String) fun onLoginClick() } }

你可能注意到我加了onUsernameChanged和onPasswordChanged,这对应着输入框的实时监听。在很多App里,登录按钮在输入为空时是置灰的,这就是典型的业务状态,Presenter处理它最合适。

接口粒度不要过细,也不要过粗。我的经验是:一次UI展示状态对应一个方法,比如“showLoading”和“showLoginSuccess”是两级不同的状态,分开定义比混在一个方法里更清晰。但也不要细到一个TextView的每个属性的变化都定义一个方法,那个程度交给View自己处理就够了。

3.3 实现Presenter

Presenter是登录模块的大脑,我贴一段核心逻辑:

class LoginPresenter( private val view: LoginContract.View, private val userRepository: UserRepository ) : LoginContract.Presenter { private var username = "" private var password = "" override fun start() { // 初始状态:按钮不可点击 view.setLoginButtonEnabled(false) } override fun destroy() { userRepository.cancel() // 取消异步请求,这是一个加分项 } override fun onUsernameChanged(username: String) { this.username = username view.setLoginButtonEnabled(username.isNotEmpty() && password.isNotEmpty()) } override fun onPasswordChanged(password: String) { this.password = password view.setLoginButtonEnabled(username.isNotEmpty() && password.isNotEmpty()) } override fun onLoginClick() { // 简单二次校验 if (username.isEmpty()) { view.showUsernameError("请输入用户名") return } if (password.length < 6) { view.showPasswordError("密码至少6位") return } view.showLoading() view.setLoginButtonEnabled(false) userRepository.login(username, password, object : Callback<User> { override fun onSuccess(data: User) { view.hideLoading() view.showLoginSuccess(data) } override fun onError(e: Exception) { view.hideLoading() view.setLoginButtonEnabled(true) view.showLoginFailed(e.message ?: "登录失败") } }) } }

这段代码诠释了规范的流程:不直接操作控件、不手写网络请求细节、所有分支结果通过View的接口方法传递出去。按钮置灰逻辑虽然看起来是个“UI状态”,但它本质上是业务约束(输入合法性),所以放在Presenter里。

需要补充的是UserRepository的注入方式。为了演示方便,我直接传的构造函数,实际项目中应该配合依赖注入框架或者手写的Service Locator。依赖注入的好处是可以随时替换成MockRepository,进一步方便测试。

3.4 实现View层,接入Activity

Activity在MVP里退化成纯粹的View实现,不再包揽业务逻辑:

class LoginActivity : AppCompatActivity(), LoginContract.View { private lateinit var presenter: LoginContract.Presenter private lateinit var btnLogin: Button private lateinit var etUsername: EditText private lateinit var etPassword: EditText override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_login) btnLogin = findViewById(R.id.btn_login) etUsername = findViewById(R.id.et_username) etPassword = findViewById(R.id.et_password) presenter = LoginPresenter(this, UserRepository()) presenter.start() // View只做事件转发 btnLogin.setOnClickListener { presenter.onLoginClick() } etUsername.addTextChangedListener(SimpleTextWatcher { s -> presenter.onUsernameChanged(s) }) etPassword.addTextChangedListener(SimpleTextWatcher { s -> presenter.onPasswordChanged(s) }) } override fun onDestroy() { super.onDestroy() presenter.destroy() } override fun showLoading() { btnLogin.text = "登录中..." } override fun hideLoading() { btnLogin.text = "登录" } override fun setLoginButtonEnabled(enabled: Boolean) { btnLogin.isEnabled = enabled } override fun showUsernameError(message: String) { etUsername.error = message } override fun showPasswordError(message: String) { etPassword.error = message } override fun showLoginSuccess(user: User) { Toast.makeText(this, "欢迎,${user.name}", Toast.LENGTH_SHORT).show() // 跳转首页... } override fun showLoginFailed(message: String) { Toast.makeText(this, message, Toast.LENGTH_SHORT).show() } }

Activity全程没有出现任何一次网络请求、没有解析数据、没有写业务判断,所有工作都是“设置控件状态”。这就是MVP该有的样子:View看起来像一叠简单的控件操作,所有决策都能在Presenter里被单测覆盖。

Web/桌面开发也有类似场景:

  • Web前端:View对应页面和DOM操作,Presenter负责处理用户交互、请求后端接口并更新页面状态。
  • 桌面客户端(C#/Java Swing/WPF):View对应用户控件和窗口,Presenter通过事件绑定界面动作。

MVP不是Android专属,只要是一个界面加一套业务逻辑的图形化程序,都能套用这套规则,只是Android的资料最多,大家接触最频繁而已。

4. 实际开发中的常见问题与排查技巧

4.1 内存泄漏:MVP最大的坑

MVP被吐槽最多的就是内存泄漏,而且确实踩的人特别多。典型场景是:Activity销毁后,Presenter里还有个网络回调没回来,回调里又调用了view.showXxx(),结果view已经是半死的了。

解决方案分三层:

  • 第一层:Presenter持有的是弱引用(WeakReference),每次调用方法前判空;
  • 第二层:在destroy()里取消网络请求。OkHttp的Call有cancel()方法,RxJava有dispose(),协程有cancel(),总之要在界面销毁时中断任务;
  • 第三层:基础框架上做保障,比如BasePresenter里定义一个CompositeDisposable统一管理订阅关系,destroy()时全部清理。

你说漏了为什么会发生?根本原因是异步任务的释放时机和界面生命周期不同步。网络请求发出去了,用户按返回键走了,但异步任务该退不退,说明持有Activity的引用链断不掉。所以MVP里的生命周期管理不是可选项,是必选题。

4.2 异步回调与界面销毁竞态

掉进这个坑的表现是:网络很快返回了,但你登录界面已经finish(),回调里还在showLoginSuccess,直接空指针或者状态混乱。

这种竞态问题,光判空还不够,因为有时候Activity还在,只是Fragment已经detach了。所以我的做法是给View增加一个isActive()判断方法,或者用runOnUiThread包一层再判活。

更严谨一点:在Presenter里维护一个状态标记,比如“destroyed”布尔值。destroy()之后,任何回调过来都直接忽略,不进入view调用。这个标记比在View端各种判活要简单可靠。实际写起来就像这样:

override fun destroy() { destroyed = true ... } override fun onLoginClick() { ... userRepository.login(...) { result -> if (destroyed) return@login view.hideLoading() ... } }

这个小小的状态标记能挡掉大量偶现崩溃。很多时候你看到的“偶现崩溃”其实就是异步回调没做生命周期检查造成的,加一行destroyed判断,立竿见影。

4.3 接口爆炸:如何精简MVP样板代码

MVP被诟病的另一个点就是代码量增多。一个页面三个类再加一个契约接口,确实比只写一个Activity要麻烦。但如果每次都手动去敲这些重复结构,那确实是给自己加负。

我用来缓解这个问题的手段有三招:

  • 用契约接口把View和Presenter放一起,减少类的查找成本;
  • 给Presenter做基类,把View的attach、detach、弱引用管理等公共逻辑统一收拢;
  • 如果项目很成熟,可以尝试用Kotlin协程Delegate或者IconGenerator之类的工具减少模板,但我个人不推荐新手一上来就搞代码生成,理解MVP本质比追求“少写代码”更优先。

还有一个更实用的替代方案:如果Presenter的方法大量都是“调用Model -> 直接返回View”,这种是纯数据搬运,可以考虑用MVVM的StateFlow/LiveData替代。但这是后话,MVP作为学习跳板,先把职责边界吃透,再去MVVM会很顺。

4.4 调试切入点与日志埋点技巧

MVP架构跑起来之后出问题了,怎么看代码?

我建议的顺序是:先看View层状态对不对,再看Presenter层的方法有没有被执行,最后看Model返回的数据对不对。如果你能在View层每个方法入口打一行日志(或者用Timber统一输出tag),定位问题的速度会非常快。

比如登录按钮点了没反应,你先看日志里onLoginClick有没有打出来。没有,说明事件没绑好;有,接着看model.login有没有调用;再接着看回调里error还是success。一层一层滤下去,问题一定出在某一层的某一行。

还有一种情况:界面显示的数据和预期不符,大概率是Model返回的数据对象被污染了,比如序列化字段名对不上、后端返回null没有处理。这种问题V层和P层其实没责任,直接用日志把Repository的返回值打出来对比最快。

我个人习惯在调试期给每条网络请求打上耗时、请求参数和返回状态,Postman和Chrome DevTools虽然也能看,但App内日志在发版环境里查线上问题更有用。

5. 我的实操心得与扩展建议

MVP这套架构,我用了很多年。从最初写Android就开始搭MVP,中间换过MVVM、用过Compose的状态管理,最后发现MVP沉淀下来的其实不只是Mode-View-Presenter这三个字母,而是一个更底层的习惯:写代码之前先分清楚“哪部分是界面、哪部分是逻辑、哪部分是数据”。这个习惯一旦养成,写什么都顺,不管是安卓、前端还是后端。

如果让我给新手一个具体建议,MVP适合在项目工程量达到“Activity经常上千行”或者“团队需要并行开发UI和逻辑”的时候引入。项目太小,只有一个页面,MVP确实显得笨重;项目一大,没有边界约束,代码维护的痛苦你会加倍体验。建议可以用一个中等复杂度的页面先试点,比如登录页,或者一个信息列表页,等手感对了再铺开。

再分享一个细节:MVP的View接口不要为了统一强制所有方法都出现在基类里。不同页面有不同的展示状态,基类只放公共的Loading/Error这类方法,页面级的专属状态写在各自的Contract里,才能保持灵活。过度统一就和过度设计一样,最后都是坑。

如果未来你想继续进阶,建议沿着这条线往下走:MVP -> MVVM(用LiveData/StateFlow替代Presenter的回调) -> 单向数据流(类似Flux/Redux的思路)。你现在在MVP里学到的Model、View分离、面向接口编程、状态机式管理,到后面任何架构里都用得上。先用MVP打好这个底子,后面的路会轻松很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询