我见过不少技术评审,总有人在会上问:我们用MVP就够了,为什么还要搞三层架构?也有人反过来问:三层架构的代码里放了MVP,这东西到底怎么体现?这两个问题其实是同一个理解盲区——把三层架构和MVP当成两个可以互相替代的“架构方案”了。
实际上,三层架构回答的是“整个系统从界面到数据库,应该纵向切成几段”;MVP回答的是“表现层内部,一个屏幕上的代码应该怎么再分工”。两条线本来就是垂直的,放在一起不是二选一,而是各自解决一个维度的组织问题。这篇文章就把这个结合关系讲透,顺便给出一套可以直接落地的代码形态和验收标准。适合正在搭新项目、准备重构老代码、或者被面试题问到“三层架构和MVP有什么区别”的开发。
1. 三层架构和MVP,先搞清楚是两个维度的事
1.1 "纵向切"和"横向切"到底是什么意思
三层架构把整个系统纵向切成三段:表现层负责界面,业务逻辑层负责规则,数据访问层负责存取。它关心的是系统从用户手指点到数据落库,这中间要分几个环节,每个环节由谁负责。这个切法跟技术栈无关,Java Web项目这么分,客户端项目也可以这么分。
MVP则是横向切。它盯着“一个界面内部的代码”怎么组织:View只管显示和转发事件,Presenter管交互编排,Model管数据和业务动作。它不关心数据库在哪里、不关心远端接口怎么定义,它只关心一个页面上的代码不要全部糊在一坨Activity或者ViewController里。
一个是楼层的划分,一个是楼层内部再分工。拿办公楼打比方:三层架构是一楼接待、二楼办公、三楼库房,楼层职责清清楚楚;MVP是二楼里面再配一个前台、一个经理、一个库管。你说“已经有二楼了,为什么还要分前台和经理”,这两句话根本不是同一个问题。
1.2 这个误区的根源出在哪里
根源多半是教程的目录结构造成了误解。很多技术文章把MVP单独列为一章,配上一张Model、View、Presenter三个框的示意图,读者看完就默认MVP是一套完整的系统架构。实际上MVP只覆盖了表现层内部,它管不到业务规则应该放在哪一层,更管不到数据访问层怎么写。
另一个原因是,早期Android开发里Activity天然承担了View的职责,而那时候大家连项目分包都还没理清楚,MVP被当成了解决代码混乱的万能药。用着用着发现,光靠MVP,Model还是要面对网络请求、缓存、本地数据库这些杂事,业务规则最后还是找不到合适的家。于是有人喊MVP背锅,有人说还是三层架构实在。吵来吵去,其实两边说的是不同尺度的东西。
把两个维度分开想清楚,后面的结合就顺理成章了。纵向切分保证系统有清晰的层界,横向切分保证每个界面内部不乱,两条线交叉出来的格子,就是我们要的架构骨架。
2. 三层架构的每一层到底在守住什么
2.1 表现层:把"笨"当成一种修养
表现在层最容易犯的毛病是“顺手干活”。Activity里顺手加一个判断,顺手拼个URL,顺手写个SharedPreferences,代码当时很爽,维护起来全是雷。表现层的核心职责只有两件:把用户的输入转发出去,把来自底层的结果渲染出来。它不关心数据从哪来,不关心业务规则有多复杂。
我见过一个登录页面,Activity里直接写了“用户名长度小于6位提示错误”的判断。这个判断本质上是一条业务规则,它应该属于业务逻辑层。如果第二天产品说规则改成8位,你就要去界面代码里翻,如果界面上还有第二个入口、第三个入口都写了同样的判断,改起来就是连环踩雷。表现层“笨”一点,恰恰是架构干净的体现。
2.2 业务逻辑层:规则只能有一个家
业务逻辑层是三层架构里的灵魂。折扣计算、状态流转、权限校验、订单金额超过多少需要审批,这些规则都该在这一层被显式地表达出来。它不关心界面是Activity还是Compose,也不关心数据存MySQL还是Redis,它只需要定义“我要调用什么能力”,然后面向接口去调用。
这一个层最见功底的地方是命名。很多人写Service或者Manager,方法名动不动就是getUserById,这其实是数据访问层的口味。业务层的命名应该带业务语义,比如submitOrder、applyDiscount、checkoutWithBalance,因为业务层表达的是“用户正在做什么事”,而不是“我在查哪张表”。
2.3 数据访问层:屏蔽"数据从哪来"
数据访问层负责跟数据库、缓存、文件、第三方接口打交道。它在三层的底部,职责是屏蔽细节。上层调用一个findById,它到底是查了一张表、调了一个远程接口、还是先从缓存读再回源,调用方不需要知道,也不应该知道。
这一层经常被忽略的价值在于“变化隔离”。线上数据库从Oracle换成PostgreSQL、网络接口从REST换成了gRPC、本地缓存从磁盘换成内存,只要数据访问层对外暴露的接口不变,上面的业务层和表现层可以完全不动。层存在的真正意义,就是把变化关在一个盒子里。
2.4 分层真正生效的两个前提
光是建了controller、service、dao三个包,不等于分层生效。分层要成立,至少满足两个约束。
第一个是单向依赖。表现层依赖业务层,业务层依赖数据访问层,这个箭头只能朝下。哪天你在数据访问层里看到它引用了一个View接口,或者在业务层里跑去import了Activity,那这套分层就已经名存实亡了。
第二个是层间通过接口协作。业务层定义自己需要的数据能力接口,数据访问层去实现它。这样业务层在被测试的时候,可以注入一个假实现,不会真的去连数据库。这跟依赖注入是两回事,但它们是伴生关系,依赖注入解决的是“谁去实现”,依赖方向解决的是“接口定义在哪、谁依赖谁”。
3. MVP到底在解决什么病
3.1 MVP三个角色各自的职责
MVP脱胎于MVC,但它把GUI场景下的交互逻辑重新收拢了。三个角色拆开看:
Model:数据和业务动作的来源。它可能是拉取用户信息、可能是提交订单、可能是把本地的草稿同步到服务器。注意,这里的Model不是单纯的数据类,它是“有行为的数据”。
View:定义了界面能做哪些事。它是一层接口,比如能显示加载框、能展示登录失败的错误提示。Activity、Fragment这些真正的界面,只是View接口的实现者。
Presenter:整个交互的中枢。它监听View转发过来的事件,把它翻译成Model的调用,再把Model的结果翻译成View能显示的状态。Presenter不认识具体控件,它只面向View接口编程。
三个角色里,最容易被误解的是Presenter。它不是一个把所有逻辑都装进去的大杂烩,它更像是一个翻译官:把用户的操作翻译成业务动作,把业务结果翻译成界面状态。真正的业务规则该在Model那一侧,而不是全堆在Presenter身上。
3.2 一次完整交互在MVP里的流向
以登录为例,用户点了登录按钮之后,事情是这样的:按钮所在的界面把“用户点了登录,用户名和密码是什么”这个事件转发给Presenter,Presenter调用Model里的登录动作,这个动作经过业务规则的校验、数据访问层的存取之后,把结果返回给Presenter,Presenter再把结果换算成“显示成功页”还是“弹出错误提示”这两个UI状态,最后通过View接口让界面刷新。
这条链路里有一个关键特征:View不主动跟Model说话,Model也不会反过来调用View。所有通信都经过Presenter这个站。所以MVP最核心的价值不是代码量变少,而是依赖方向变清晰了——界面依赖Presenter接口,Presenter依赖Model接口,方向始终向下或者向内收拢,没有环。
3.3 MVP的代价:没有免费的午餐
MVP不是银弹,它的成本也很真实。每个页面至少多出View接口、Presenter类,结构上会多出好几个文件。团队小、项目急的时候,有人会觉得“就为了一个列表页,拆这么多类,太麻烦了”。
如果项目里所有页面都无脑套MVP,就会产生大量永远不会被替换的接口、十几个长得差不多的BasePresenter,维护成本反而更重。所以MVP要解决的问题是有针对性的:界面复杂、交互逻辑多、需要单测的场景,才值得引入。简单静态页面硬套MVP,属于花钱买罪受。
4. 结合的落点:三层架构与MVP的映射关系
4.1 一张映射表看懂两者关系
三层架构的每一层都能在MVP的结构里找到对应坐标,它的落点不在表现层的外部,而在“表现层内部如何组织”和“Model内部如何分层”这两个地方。
| 三层架构 | MVP中的对应 | 关系说明 |
|---|---|---|
| 表现层 | View + Presenter + 组装器 | View负责渲染,Presenter负责交互编排,两者一起构成表现层的内部结构 |
| 业务逻辑层 | Model中的行为部分(UseCase / Domain Service) | Presenter把用户操作翻译成业务动作,调用UseCase完成真正的规则编排 |
| 数据访问层 | Model中的数据能力实现(Repository实现 / DataSource) | 业务层定义Repository接口,数据访问层提供实现,屏蔽存储细节 |
表格之外必须强调一句:MVP里的View和Presenter都还属于表现层的范畴,也就是说,MVP并没有替代三层架构,它只是把三层架构中最容易烂掉的那一层又做了二次治理。
4.2 Presenter在三层架构中的真实位置
有些人以为Presenter属于业务逻辑层,这是个挺常见的错位。Presenter坐在表现层的内部,它的职责是“编排交互”,不是“实现业务”。它知道登录这个动作叫什么,但它不该知道“用户名为什么不能包含特殊字符”这种规则。
换句话说,Presenter是业务逻辑层暴露给表现层的一个门面。表现层不直接去调UseCase接口,还是先经过Presenter这个站,再由Presenter调UseCase。这样做的好处是,界面上的每次点击都统一走一个入口,交互的变化被收在Presenter里,业务规则的变化被收在UseCase里,两者各自有一道防火墙。
4.3 Model的"内部结构",才是三层架构融入MVP的关键
MVP里Model这个词太模糊了。把它拆开看,Model内部其实横跨了三层架构的后两层。它既有业务行为的部分——比如一个LoginUseCase、一个PlaceOrderService;也有数据能力的部分——比如一个UserRepository接口和它的实现类。
很多MVP项目烂就烂在把Model当成一个纯数据Bean。一个data class LoginUser躺在那里,无任何行为,网络请求写在Activity里,数据库操作写在Activity里,这样的MVP只是在View层和Presenter层之间划了条线,Model的底盘根本没搭起来。
要让三层架构在MVP里真正体现,就要把Model内部再细分:业务逻辑抽成UseCase或者Domain Service,数据访问抽成Repository接口和实现,数据实体留在业务层作为返回结果。这样做完,MVP的Model才真正接住了三层架构的后半截。
4.4 完整依赖链:从点击到数据
结合之后的完整依赖链长这样:表现层的View把点击事件交给Presenter,Presenter调用业务逻辑层的UseCase,UseCase依赖Repository接口(接口定义在业务层),Repository接口的实现在数据访问层,数据访问层再去操作网络或数据库。
这条链路上的每个箭头都是朝下的,没有任何一环反向引用。用代码的组织方式来描述就是:presentation -> domain -> data。这是MVP与三层架构结合后最核心的骨架,比任何模式的名字都重要。依赖方向干净,架构就活着;依赖方向乱了,用什么架构名词都救不回来。
5. 用登录场景看结合后的代码形态
5.1 目录结构:每一层都有自己的地盘
光讲道理不够,实际写代码才看得明白。下面用一个典型的登录功能做例子,目录结构按结合后的思路来组织:
app/ ├── data/ # 数据访问层 │ ├── datasource/ │ │ ├── RemoteDataSource.kt │ │ └── LocalDataSource.kt │ └── repository/ │ └── UserRepositoryImpl.kt ├── domain/ # 业务逻辑层 │ ├── model/ │ │ └── User.kt │ ├── repository/ │ │ └── UserRepository.kt │ └── usecase/ │ └── LoginUseCase.kt └── presentation/ # 表现层(MVP) └── login/ ├── LoginActivity.kt ├── LoginView.kt └── LoginPresenter.kt这里有一个容易被忽视的细节:UserRepository这个接口放在了domain包里,实现类UserRepositoryImpl放在了data包里。这跟传统三层架构里的写法不同——传统三层常把接口和实现都放在数据访问层,业务层直接依赖它;结合MVP的干净写法,是让业务层定义能力边界,让数据访问层来实现。两种做法的效果差异在于测试,接口在业务层时,业务层测试可以完全不碰真实环境。
5.2 表现层内的View与Presenter
View接口定义界面能力,Activity负责实现它:
interface LoginView { fun showLoading() fun hideLoading() fun onLoginSuccess(user: User) fun onLoginFailed(errorMsg: String) } class LoginActivity : AppCompatActivity(), LoginView { private lateinit var presenter: LoginPresenter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // 伪代码,示意组装过程 presenter = LoginPresenter(this, Injection.provideLoginUseCase()) findViewById<Button>(R.id.btn_login).setOnClickListener { presenter.onLoginClicked( username = etUsername.text.toString(), password = etPassword.text.toString() ) } } override fun showLoading() { // 展示加载进度 } override fun hideLoading() { // 隐藏加载进度 } override fun onLoginSuccess(user: User) { // 跳转主页 } override fun onLoginFailed(errorMsg: String) { // 弹出错误提示 } }Presenter这边,只面向View接口和UseCase接口,不碰任何Activity的细节:
class LoginPresenter( private val view: LoginView, private val loginUseCase: LoginUseCase ) { fun onLoginClicked(username: String, password: String) { if (username.isBlank() || password.isBlank()) { view.onLoginFailed("用户名和密码不能为空") return } view.showLoading() loginUseCase.execute(username, password, object : Callback<User> { override fun onSuccess(user: User) { view.hideLoading() view.onLoginSuccess(user) } override fun onError(e: Exception) { view.hideLoading() view.onLoginFailed(e.message ?: "登录失败") } }) } }注意这里“用户名和密码不能为空”这个判断故意留在Presenter里,因为它属于“交互前置校验”,不属于核心业务规则。真正有含金量的规则,比如“用户名只能由字母和数字组成”“连续输错5次要冻结”,必须下沉到UseCase。
5.3 业务逻辑层的边界:LoginUseCase
class LoginUseCase( private val userRepository: UserRepository ) { fun execute(account: String, password: String, callback: Callback<User>) { if (!isAccountValid(account)) { callback.onError(BusinessException("账号格式不合法")) return } userRepository.login(account, password, callback) } private fun isAccountValid(account: String): Boolean { return account.length in 6..20 && account.all { it.isLetterOrDigit() } } }这个类不关心界面长什么样,不关心密码是存在远端还是本地,它只负责一条业务链路:先校验账号格式,再让Repository去执行登录。如果后来产品说账号允许包含下划线,改的是这一个类,Activity和Presenter都不用动。这才是业务逻辑层该有的样子。
5.4 数据访问层的收敛:UserRepositoryImpl
class UserRepositoryImpl( private val remoteDataSource: RemoteDataSource, private val localDataSource: LocalDataSource ) : UserRepository { override fun login(account: String, password: String, callback: Callback<User>) { remoteDataSource.login(account, password) { result -> if (result.isSuccess) { localDataSource.cacheUser(result.data) } callback.onComplete(result) } } }数据访问层的代码只关心一件事:怎么把数据拿到。远程登录成功就顺手写一份本地缓存,失败就原样上报。它不知道界面有什么按钮,也不知道账号规则是什么。以后如果把远程登录从HTTP换成其他协议,改的是RemoteDataSource,整个上层纹丝不动。
5.5 这段代码体现出来的架构特征
用表格再把对应关系标一遍,方便照着抄:
| 文件 | 所属层 | 职责 |
|---|---|---|
| LoginActivity | 表现层(View实现) | 负责UI渲染、把点击事件交给Presenter |
| LoginPresenter | 表现层(Presenter) | 负责交互编排、状态翻译 |
| LoginUseCase | 业务逻辑层 | 负责登录链路编排与规则校验 |
| UserRepository接口 | 业务逻辑层 | 定义数据能力边界,供UseCase调用 |
| UserRepositoryImpl | 数据访问层 | 落地远程/本地数据的具体实现 |
整体看,LoginActivity没有业务判断,LoginPresenter没有数据库操作,LoginUseCase不认识Activity,UserRepositoryImpl只关心数据怎么拿。每个类的职责边界都很朴素,但合在一起就是三层架构和MVP的结合形态。
6. 结合之后最容易走样的几个坑
6.1 Presenter膨胀成"业务层的替身"
最常见、也最致命的走样,是Presenter里塞满了业务判断。项目初期大家图省事,登录规则写Presenter里,下单判断写Presenter里,缓存策略也写Presenter里。一个版本迭代下来,Presenter从80行膨胀到800行,Activity是瘦了,但代码复杂度一点没减,只是换了个地方堆。
问题根子在于对Model的理解太弱。Model一旦被当成纯数据结构,所有逻辑就都往上跑到Presenter里去了。修正办法是强制把规则赶回UseCase或Domain Service,Presenter只留“调用动作+翻译状态”的薄逻辑。如果看到Presenter里出现超过一屏的if-else,就该警觉:业务逻辑又跑错层了。
6.2 Model被降级成纯数据Bean
很多MVP教程里,Model就是几个Class,每个Class里一堆字段和getter。这套示例看多了,代码里就到处都是没有行为的“数据袋子”。真正的Model必须承载行为:用户保存自己的登录态、订单计算自己的总额、草稿把自己的内容同步到服务器,这些行为都该放在Model侧。
三层架构融入MVP之后,Model的肚子里是有结构的:它内部划分出业务动作(UseCase)和数据能力(Repository接口/实现)。如果项目里的Model层只是一个存放DTO的包,那说明三层架构的地基还没打进来,MVP只是浮在表现层表面。
6.3 View里的业务逻辑"回潮"
代码烂掉的典型过程是这样的:某次迭代很急,开发直接在Activity里写了一个业务条件判断,写完觉得还挺方便;下一个人看到“原来可以直接在Activity里写”,于是照做;半年后,同样的规则在六个Activity里各有各的写法,改一处漏一处。
回潮的根源是纪律问题,不是架构问题。解决也不难:评审时对“表现层出现业务规则”零容忍,发现一个下沉一个。可以在工程里加一个简单lint规则,或者至少在Code Review里盯死这段。短期看是慢了一点,长期看是替团队省了不知道多少排查时间。
6.4 依赖方向颠倒,架构形同虚设
有些项目分层建了,但箭头是反的。数据访问层里引用着表现层的回调接口,表现层直接new了数据访问层的实现类,业务层跑到View接口里去拿数据。这些写法的共同点是环依赖,代码依然能跑,但每次改动都会牵一发动全身。
检查方法并不复杂:在IDE里随手抽查几个文件的import,看看它们引用了哪些包,把引用方向画出来。如果发现data包里的文件import了presentation包,这就是明显的结构哨兵。依赖方向必须始终向下:presentation -> domain -> data,任何一环反向都是债。
6.5 接口泛滥:为架构而架构
还有一个坑是把架构做成了仪式感。每个页面都要先定义View接口、再定义Presenter接口、还要套一个Contract接口,十几个接口文件放在那里,一半从没被第二个实现类实现过。这是典型的把别人项目长什么样直接照搬,没有思考接口存在的意义。
务实的做法是:View接口可以留,因为它隔离了Activity和Presenter的依赖;Presenter接口则没什么必要,绝大部分项目里Presenter就一个实现类,抽象了反而不利于维护。架构是约束,不是摆件,每多一个抽象都要问一句“它到底隔离了什么变化”。
7. 如何验收你的项目真的结合到位了
7.1 顺着一次点击走一遍链路
最简单可靠的办法,是找一个真实的交互场景,沿着用户点击走一遍,每走一步问一句“当前这段代码属于哪一层”。一个登录按钮点下去:哪个类接住了点击事件?哪个类负责转发?哪个类做了规则校验?哪个类真正发出了网络请求?哪个类更新了界面?
如果每一步都能理直气壮地回答“这是表现层的View”“这是表现层的Presenter”“这是业务逻辑层的UseCase”“这是数据访问层的RepositoryImpl”,那这套结构就是成立的。但凡中间有一环说不清楚,比如点击事件直接从Activity跳到了网络请求,分层就有洞。
7.2 用"三个如果"验证隔离度
除了顺着链路走,还可以做三个假设测试:
如果界面的实现方式从Activity换成Compose或者Flutter重新实现,表现层之外的东西能不能完全不动?如果能,说明MVP的View隔离做对了。
如果数据源从网络接口换成纯本地数据库,业务逻辑层和表现层会不会被波及?如果能,说明数据访问层的屏蔽做对了。
如果登录规则从“长度6到20位”改成“必须包含特殊字符”,View层和数据访问层要不要跟着改?如果能,说明业务逻辑层的边界做对了。
这三个“如果”还有一个实际意义:它们能直接转化成面试题。能把这三个问题说明白,说明你不是在背架构名词,是真的理解自己在做什么。
7.3 一套务实的落地路径
如果你是正在接手一个已经有点乱的项目,别指望一次重构把所有页面都改成标准形态。务实的做法是挑一个最烂的功能模块做试点,先把依赖方向理直,再把逻辑一层层下沉。重构老代码的时候,保持一次只移动一类职责,不要借着重构顺手改业务逻辑,否则出了Bug都不知道是结构问题还是业务问题。
团队层面一定要把依赖规则写成自动检查,而不是靠人肉评审。用一个简单的架构守护脚本或者插件,在CI里跑一下,有人违反依赖方向就亮红灯。人工评审会漏,脚本不会。
说到底,架构不是哪个模式名字好听,而是约束长期被遵守之后换来的可维护性。MVP加三层架构这组组合拳,在GUI项目里足够实用。不要追求花哨,只要依赖方向永远朝下、职责边界永远清楚,这个项目跑上几年都不会烂到你不敢动,这就算成了。
我在实际项目里见过太多“伪MVP”——一个Presenter几百行、Model只剩空壳、Activity里到处都是散装业务判断。代码库变成这样,不是架构名字的问题,是落地时把规则丢了。所以最后给你一个建议:从今天起,给自己定一条底线,任何点击事件都按“View转发、Presenter编排、UseCase执行业务、Repository存取数据”这条单向流走。这条底线守住,三层架构和MVP的结合就真正落地了。