首页 / 资讯中心 / 文章详情

MVP架构模式核心解析:Presenter职责、对比MVVM与面试实战

MVP架构模式核心解析:Presenter职责、对比MVVM与面试实战 ★ FEATURED ARTICLE
1. MVP到底在解决什么问题从一次难看的重构说起先聊一个我印象特别深的场景。好几年前我接手一个维护期的老项目功能不复杂就是商品列表加详情加购物车但代码已经乱得让人不敢动。Activity里塞了网络请求、JSON解析、SharedPreferences读写、列表适配器初始化还有几个按钮的点击逻辑一个文件轻松突破一千行。改一个需求牵一发动全身调整接口字段要先找到Activity里那一大坨回调换一个数据源列表适配器和加载状态全得跟着改。更要命的是想给业务写单元测试发现根本无从下手——逻辑全贴在Android的View上离开了模拟器什么都跑不了。那段时间我就在想问题到底出在哪。其实不是某个文件写得烂而是没有任何约束在阻止代码往Activity里堆。Android给了开发者很大的自由Activity既能当View用、又能当Controller用结果就是大家都习惯了一个页面一个全能类。后来我接触到了MVP开发模式才意识到自己之前缺的不是编码能力而是对职责边界的认知。MVP不是什么高深的新框架它没引入任何新概念也没有必须依赖的第三方库它只是一套约定把界面展示、业务逻辑、数据模型强行拆开让每一层只做自己该做的事。这个介于View和Model之间的Presenter就是整个模式的核心它负责把用户点了按钮翻译成业务要做什么再把业务返回的数据翻译成界面该怎么显示。这篇文章就是写给准备面试的朋友看的。MVP、MVC、MVVM几乎是客户端面试绕不开的必考题面了十家至少八家会问。我不打算只列概念而是结合我自己重构项目的真实经过把这套模式从为什么存在讲到怎么落地再聊聊面试官通常在追问里藏的那些考点。如果你已经写过几个MVP项目但对答不流畅或者只是背了概念但没真正理解这篇应该都能帮上忙。2. MVP的分工规则Model、View、Presenter各自管什么2.1 三层各管一摊事边界怎么划MVP三个字母分别对应Model、View、Presenter职责划分是这套模式的基石我在面试中多次被人用各种方式确认是否真正理解这一点。Model层数据来源和业务规则。网络请求、数据库读写、首选项存取、文件操作以及对这些数据做加工计算的逻辑都属于Model的范畴。一个容易忽略的细节是Model是不知道UI存在的它不关心数据被谁消费、怎么展示只负责把数据给我。这带来的直接好处是Model可以被独立测试也可以被多个页面复用。View层负责所有和界面相关的事。渲染数据、处理用户输入、展示加载状态、弹出错误提示这些都是View的职责。在Android里View层通常是Activity、Fragment或自定义View。有一个关键点需要拎出来强调View不直接请求数据也不直接做业务判断它只做两件事——把用户的操作告诉Presenter以及按Presenter的指示刷新界面。Presenter层View和Model之间的协调者。它接收View传来的用户意图调用Model拿到数据再把数据加工成View可以直接展示的状态返回给View。Presenter是纯逻辑层不持有任何Android相关依赖这才是它能做单元测试的根本原因。打个比方来理解这三者的关系Model是厨房负责把菜做出来View是餐桌负责把菜端上来给客人吃Presenter是服务员客人点了什么菜由它传达后厨菜做好了由它端回餐桌。客人不用自己跑去厨房厨房也不用关心客人的口味偏好。2.2 双向通信的两种实现路径MVP里的View和Presenter是双向关联的View持有Presenter的引用把用户事件传给它Presenter也持有View的引用把数据状态传回去。因为两者要互相调用所以必须有明确的接口约定。一个方向是View → Presenter这个最简单View直接调用Presenter暴露的方法即可比如presenter.loadData()或presenter.onItemClicked(item)。因为View持有Presenter的具体实现这种调用没有任何弯弯绕绕。另一个方向是Presenter → View这个稍显棘手。如果让Presenter直接持有Activity或Fragment的具体类型那它就和Android耦合死了不复存在脱离环境跑测试的优势。所以正确的做法是给View定义一个窄接口Presenter只依赖这个接口不知道对面具体是什么实现。一个典型的接口定义长这样public interface UserListView { void showLoading(); void hideLoading(); void showUsers(ListUser users); void showError(String message); }Presenter里持有的是UserListView调用view.showUsers(users)时它并不知道具体实现者是Activity还是别的什么。Activity负责实现这个接口在方法里操作真实的UI控件。这里有一个关键认知View层的接口要尽量窄只暴露Presenter真正需要调用的方法不要图省事把整个Activity的全部能力都塞进去。接口越宽耦合越紧这一点在后续维护时会明显感受得到。2.3 为什么View一定要定义一个窄接口很多人第一次写MVP时会困惑我直接在Presenter里new一个Activity对象不就行了为什么非要多定义一个接口这不是脱裤子放屁吗答案藏在可替换性和可测试性这两个词里。先说可测试性Presenter持有的是接口而非具体实现那么在单元测试里就能用一个Mock的View替身去验证Presenter的行为逻辑——比如调用loadData()后Mock View是否收到了showLoading()和showUsers()的调用传进去的数据对不对。所有这些验证都无需启动模拟器一个普通的JVM测试就能完成。而如果Presenter直接持有Activity的引用同样的测试必须在Android环境里跑成本会高很多。再说可替换性接口允许同一个Presenter服务于不同的View实现。最常见的场景是横竖屏切换Activity被重建了但业务数据还在合理设计下Presenter可以继续复用同一份数据状态Activity只是重新挂接上去而已。所以在回答MVP怎么实现时不要只说三层各自做什么一定要把面向接口编程这条带出来。面试官很清楚写出三个类人人都行能守住接口边界才说明真正内化了MVP。3. MVP与MVC、MVVM的对比面试官问区别时想听到什么3.1 从MVC到MVP变化的不只是名字面试里最经典的问题就是MVP和MVC有什么区别很多人的回答止步于MVC是Model-View-ControllerMVP是Model-View-Presenter这种答案约等于没答。面试官想听的是这两种模式下View和Model之间的依赖关系发生了什么变化。在传统的MVC架构里View和Model之间存在直接通信。View读取Model的数据来渲染界面用户在View上操作Controller负责更新ModelModel的变化又会反过来影响View的显示。三者形成一个互相感知的闭环这意味着Model一旦出问题View会直接感知到反过来View的代码里也常常混入业务判断。MVP对这个结构的改造是激进的它彻底斩断了View和Model之间的直接联系。View不认识ModelModel也不认识View所有交互都必须经过Presenter中转。View想做任何事只能告诉PresenterModel有任何新数据也只能回报给Presenter。这个一刀切的设计看着笨重却带来一个巨大的工程收益——每一层的内部改动都不会渗漏到其他层因为唯一的信息通道被收窄到了接口方法上。我自己的体会是MVC适合业务简单、数据流单向清晰的场景写起来确实比MVP省代码一旦页面复杂度上来各种状态需要联动MVC里View直接观察Model的做法很快就会让代码失控Model的一处改动往往引出一串连锁修改。MVP用多写一层接口的代价换来了清晰的依赖方向和可单元测试的能力对于中大型项目来讲非常划算。3.2 MVP与MVVM的权衡数据绑定是解放还是新坑MVP之后必然被追问的是MVVM你了解MVVM吗它和MVP相比有什么优劣。这两个模式的核心意图是一致的——分离关注点、消灭View和Model的直接耦合区别集中在如何把数据的变化同步到View。MVP是手动的Presenter把数据拿出来调用view.showXxx()来刷新界面来回的每一个环节都由开发者显式编写看得见摸得着。而在MVVM模式中ViewModel和View之间通过数据绑定机制自动同步ViewModel里的字段一旦变化绑定了该字段的UI控件立即自动更新写起来的确很爽少了一大截样板代码。但是给View加上自动数据绑定之后我遇到过一个比较隐蔽的坑数据流变得隐式。出了bug或者逻辑不对的时候你需要沿着绑定链去追踪是哪一层的数据更新触发了UI改变排查成本比MVP直接看方法调用明显更高。MVVM的学习门槛也是它的一个成本RxJava、协程或者Jetpack Compose这些技术栈各有各的绑定和生命周期机制新手从MVP转到MVVM很容易把业务逻辑写进可观察对象里弄出一个难以维护的大杂烩。面试里的稳妥答法是对MVVM不要持有偏见也不要无脑吹。一句MVVM在中小型页面和表单场景下能极大减少样板代码但隐式数据流会提高排查门槛在复杂项目中需要对数据流向有更强的把控能力就足够展示你的深度了。如果要我再加一点我会补上自己在项目中因为数据绑定的双向联动出现过多次定位困难的实例以此说明任何架构都有它的适用范围不存在银弹。4. 完整案例用MVP重写一个登录模块4.1 定义契约类先把交互协议写清楚理论讲了不少是时候看一份能直接落地的代码了。拿最常见的登录功能来演示因为它麻雀虽小、五脏俱全——有用户交互、有异步校验、有成功跳转和错误回显每个环节都能体现MVP的协作方式。一个被广泛认可的习惯是先写契约类把View和Presenter之间需要协作的方法一次性定清楚然后再去实现具体类。这就像两人协作写代码先商量好接口签名再各自实现可以有效避免实现到一半发现对不上。public interface LoginContract { interface View { void showLoading(); void hideLoading(); void onLoginSuccess(); void onLoginError(String message); } interface Presenter { void attachView(View view); void detachView(); void login(String username, String password); boolean isViewAttached(); } }注意这个契约里没有给Model规定接口因为Model是被Presenter直接调用的服务它不需要回头看View。实际开发中Model层往往是一堆可复用的数据服务类不必为每个页面单独抽象接口。登录成功后要跳转页面这个动作应该由Presenter触发还是View触发很多人会纠结这个。我的建议是跳转属于UI行为应该由View来执行。Presenter只负责告诉View登录成功了具体是跳转到哪个页面、怎么带参数View自己决定。4.2 Presenter的核心逻辑与生命周期应对Presenter是整个登录模块的枢纽。它要做的事情包括接收View传来的用户名密码、做前置校验、调用Model的登录接口、根据结果回调View。下面是核心逻辑部分public class LoginPresenter implements LoginContract.Presenter { private LoginContract.View mView; private UserDataSource mDataSource; private CompositeDisposable mDisposables new CompositeDisposable(); public LoginPresenter(UserDataSource dataSource) { mDataSource dataSource; } Override public void attachView(LoginContract.View view) { mView view; } Override public void detachView() { mView null; mDisposables.clear(); } Override public void login(String username, String password) { if (!isValid(username, password)) { mView.onLoginError(用户名或密码不能为空); return; } mView.showLoading(); mDisposables.add(mDataSource.login(username, password) .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(user - { if (isViewAttached()) { mView.hideLoading(); mView.onLoginSuccess(); } }, throwable - { if (isViewAttached()) { mView.hideLoading(); mView.onLoginError(throwable.getMessage()); } })); } private boolean isValid(String username, String password) { return username ! null !username.isEmpty() password ! null !password.isEmpty(); } private boolean isViewAttached() { return mView ! null; } }这里有三个细节值得展开。第一attachView和detachView是一对强制配套动作。Activity在onCreate里attach在onDestroy里detach绝对不允许只做一半。detach的核心作用一个是置空View引用另一个是清理还在进行中的异步任务这样才能最大程度避免内存泄漏和回调打到已死页面的尴尬。第二每次回调前都要检查isViewAttached()。异步请求发出去的这段时间里用户可能已经旋转屏幕导致Activity重建了旧View已经被detach了。如果Presenter不该检查就调用旧View的方法轻则白跑一趟重则触发NPE或者更新了一个已经不在前台展示的界面。这个小习惯在MVP里称得上是保命级的。第三前置校验放在Presenter而不是View。登录按钮是否可点什么时候弹用户名不能为空这些虽然涉及界面交互但它本质是业务规则的判断放进Presenter方便进行单元测试。View里只做最简单的控件操作比如把点击事件传给Presenter。4.3 View层的实现与防泄漏处理View的角色是被动执行Activity实现LoginContract.View接口完成界面的展示工作public class LoginActivity extends AppCompatActivity implements LoginContract.View { private EditText mUsernameInput; private EditText mPasswordInput; private ProgressBar mLoadingView; private LoginPresenter mPresenter; Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_login); mUsernameInput findViewById(R.id.et_username); mPasswordInput findViewById(R.id.et_password); mLoadingView findViewById(R.id.progress_bar); mPresenter new LoginPresenter(new UserRepository()); mPresenter.attachView(this); findViewById(R.id.btn_login).setOnClickListener(v - mPresenter.login(mUsernameInput.getText().toString(), mPasswordInput.getText().toString())); } Override protected void onDestroy() { super.onDestroy(); mPresenter.detachView(); } Override public void showLoading() { mLoadingView.setVisibility(View.VISIBLE); } Override public void hideLoading() { mLoadingView.setVisibility(View.GONE); } Override public void onLoginSuccess() { startActivity(new Intent(this, HomeActivity.class)); finish(); } Override public void onLoginError(String message) { Toast.makeText(this, message, Toast.LENGTH_SHORT).show(); } }View层的代码量虽然不大但它和老写法最大的差异在于没有任何业务判断。页面上不会有如果用户名是什么就干什么的分支逻辑不会直接拿数据层的结果来自己处理所有决策都交给了Presenter。这样的Activity拿到另一个开发者手里理解成本极低改需求时也能快速定位该动哪一层。还有一个我踩过的细节别忘了在onDestroy里先调用super.onDestroy()再detachView()虽然顺序问题不大但保持直觉一致的顺序能少一些莫名的疑虑。另外如果页面在栈里被覆盖但没销毁onStop或onPause阶段是不需要detach的只处理onDestroy就够了太早detach会导致界面重新可见时逻辑状态丢失。5. 面试高频追问从基础概念到架构演进5.1 常被追问的细节考点MVP相关的面试题除了三层各自干什么、MVC和MVP的区别这种基础款我还整理过一批面试官爱追问的细节考点提前准备能让临场反应快不少。第一为什么MVP的Presenter不持有Context这个问题的核心考察你对耦合的理解。Presenter一旦持有Context就意味着它被绑定到了Android环境单元测试就得靠模拟器跑而且Context本身是一个重量级对象间接持有可能引发内存泄漏。所以中央答案是Presenter保持纯Java才能在普通JVM里测试也才能和具体界面解耦。第二Activity旋转屏幕后Presenter和View如何处理标准的过渡方案是Activity重建时旧的View调detachView断开连接待新Activity创建后再attachView接回去。如果Presenter持有数据和状态它本身不销毁的话新View重建后就能立刻拿到最新数据而不需要重新发起请求。想深入的话可以提一下ViewModelStore机制和Presenter的配合但这属于加分项把attach/detach讲清楚已经足够。第三MVP怎么处理网络请求的生命周期请求在Presenter里发起理应在Presenter里取消。用RxJava的话让Presenter持有CompositeDisposabledetach时统一clear用协程就是持有Job并在cancel时取消。核心思想同样是那句不要让回调打到已经销毁的View。第四onSaveInstanceState和Presenter有冲突吗这个问题问的人少但问出来特别加分。瞬时数据比如输入框内容用SavedInstanceState恢复没问题而重量级数据比如已经加载好的列表放在Presenter里比放在Bundle里更合适因为Bundle有大小限制和序列化开销。能说出两者的分工面试官的印象会明显不一样。5.2 如何回答MVP有什么缺点面试官如果只问优点反而说明问题不深。真正有经验的面试官一定会追问缺点这其实是在考察你有没有经历过架构演进中的权衡。我给出的短板清单一般包含这几条每一条也都配上应对思路。代码量明显增加。原本一个Activity搞定的事现在要拆出接口、实现类、Model文件数目翻倍还带拐弯。好处是责任清晰了代价是样板代码多了。应对思路是引入契约类把协议集中管理并且不要让Model为每个页面单独抽象一个接口多用通用数据服务。Presenter变胖的风险。所有业务逻辑都在Presenter里页面一复杂Presenter照样可能变成一个新的上帝类。应对思路是进一步拆分为UseCase或Interactor保持单个Presenter只聚焦一个页面场景。手动同步View状态。没有数据绑定机制每次Presenter调用View的刷新方法都要显式书写。页面里状态多了以后这种手动回调容易漏掉某个分支。应对思路是列出一份完整的状态清单保证所有状态都有对应的View方法。View层接口粒度难拿捏。接口定义得太细方法满天飞定义得太粗又退回高耦合。这个只能靠经验迭代在重构中逐渐找到平衡点。面试官听到你能主动说出这些缺点并且每个缺点都有合理的应对措施时他对你架构思维的评判会明显高于那些只背概念的人。5.3 一分钟说清MVP的讲述框架想给面试官留下好印象最好的方式是把回答组织成问题 → 方案 → 细节 → 反思的四段式。第一段问题。说清楚没有架构约束时你会遇到什么Activity越写越臃肿、业务逻辑没法脱离Android环境测试、改需求时牵一发而动全身。这个铺垫极其重要因为它让MVP的出现显得顺理成章。第二段方案。一句话概括MVP把页面拆成Model、View、Presenter三层View和Model不直接通信全部经由Presenter中转交互协议用接口限定。第三段细节。拿出你写过的某个真实场景比如登录模块讲一下Contract类怎么定义、View如何实现接口、Presenter如何发起异步请求、旋转屏幕时如何处理attach和detach。这一部分是让面试官相信你真写过的关键证据。第四段反思。主动说这个方案也有代价比如代码量增加、Presenter维护成本上升然后补充一句所以在实际项目里我会根据页面的复杂程度来决定是否使用MVP而不是无脑套用。这句话能把你的段位和只会背概念的候选人拉开差距。6. 实践中踩过的坑内存泄漏、胖Presenter、回调地狱6.1 内存泄漏MVP最容易翻车的地方MVP最著名的坑就是内存泄漏而且往往跟Activity和Presenter的生命周期错配绑定在一起。举个我真实遇到过的场景。某个页面发起了一个网络请求请求还没回来用户就退出了。如果此时Presenter还持有Activity的引用而这个Presenter又被请求回调持有不死那Activity就会一直被一条泄漏链拽住无法被系统回收。久而久之多次进出页面后内存就不够用了。常规解法已经在前面提到在onDestroy时调用detachView()把View引用置空并同时取消所有未完成的异步任务。但我想补充一个更隐蔽的细节——静态引用。有人把Presenter写成静态类的单例想要全局复用结果间接拿着Activity飞了一天一夜。getApplicationContext()场景至少还安全一些但这种静态Presenter持有Activity View的设计最好在评审阶段就必须拦掉。如果你在项目里排查泄漏最直接的工具就是LeakCanary。装上之后多切换几次页面它会在LeakTrace里明确告诉你哪个对象被谁持有顺着引用链改掉问题即可。这个工具在面试中提一嘴也会加分说明你有实际排查经历。6.2 胖Presenter当业务逻辑开始失控MVP部署了一段时间后另一个深有体会的问题就像水底浮出一样浮现出来Presenter开始变胖。页面的业务逻辑并不少——首次加载数据、下拉刷新、加载更多、空态判断、点击收藏、分享跳转……如果全部塞进同一个Presenter它的方法数量很快就爆炸了。我在一个二手交易页面里就犯过这个错那段时间改需求几乎全在跟大Presenter的文件长滚动条作对每加一个功能都要小心翼翼找到对应的分支生怕改错一个状态影响其他逻辑。后来的经验是当Presenter里职责超过一个用户场景时就该拆分了。把一个页面涉及的业务动作拆成独立的UseCase或Interactor类比如GetUserInfoUseCase、SubmitOrderUseCase它们各自管理自己的数据源和异步流程。Presenter负责编排这些UseCase而不是亲自把所有逻辑都写完。这里也有一个平衡问题脚本规模小的页面拆UseCase反而显得过度设计我的经验边界是——当Presenter超过两百行或者方法超过八个时才考虑动手拆分。6.3 异步回调的时序问题最后一个坑在真实项目中虽然不一定频繁出现但一旦出现往往查到人怀疑人生回调的时序错乱。场景还原如下列表页快速上滑触发多次分页加载第一次请求比第二次晚返回。如果Presenter不做控制后发先至的数据会把前面请求回来的最新数据盖掉列表渲染错乱。还有登录页用户连续点了两次登录按钮发出两个登录请求第一个失败、第二个成功最后一个回调的状态覆盖了前一个页面状态就无法反映真实结果。这类问题的通用解法是引入版本号或请求序列号——每次新请求递增一个计数回调回来时检查是否还是自己发起的最后一次如果不是就直接丢弃。用RxJava的switchMap也能做到新的请求自动取消或忽略旧的。对于时效性比较强的场景还应该考虑给请求加超时和重试的控制避免状态一直挂起。面试时如果能主动提到这类时序问题并说出自己的解决方案会让面试官认为你不是只写过Demo级别项目的人。这也是我从垃圾代码堆里爬出来之后最想告诉后来者的一句话架构模式的价值不在于画出来的图多好看而在于它能不能逼你把边界问题想清楚。7. 最后分享一点个人体会做完这次梳理我自己的最大收获其实不是知识本身而是一种确认感。以前面试时被问到MVP我总担心答得不够标准于是去背各种定义。后来发现真正让自己不慌的不是那些定义而是自己亲手重构过的项目——我知道三层协作时有哪些坑知道横竖屏切换时开一个detach有多重要知道回调先回来的数据会引发怎样诡异的界面状态。这些经验和教训是背不出来的只能靠踩坑和复盘换回来。所以如果你在准备面试我的建议是别只抱着博文和概念背打开自己的项目找到任何一个写了几年、还没有架构约束的页面用MVP重写一遍重写过程中把本篇提到的坑都故意踩一遍再修复掉。这个动作花不了几天但带给你的理解深度远远超过看十篇梳理文章。这篇文章只是帮你把知道变成系统化真正让系统化变成肌肉记忆的还是动手这件事本身。祝准备面试的朋友都能顺利也希望你们在架构这条路上越走越稳。
阅读完成 · 觉得有帮助?
咨询建站