资讯动态

MVP架构实战:Android项目代码重构与职责分离指南

发布时间:2026/10/9 9:09:00 来源:尧图企业网站定制
软件开发越做越久我越发觉得一个问题值得反复琢磨一个项目在最开始设计架构时代码写得再爽都不如三年后还能让人看懂、改得动来得实在。这个道理放在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转发给PresenterPresenter决定“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”这类细节。举个例子你做一个天气AppView需要的是“北京今天的气温、湿度、天气状态”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的强引用否则会内存泄漏。因为ViewActivity可以被系统销毁重建但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 实现PresenterPresenter是登录模块的大脑我贴一段核心逻辑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 : CallbackUser { 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层接入ActivityActivity在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/WPFView对应用户控件和窗口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) returnlogin 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打好这个底子后面的路会轻松很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑