ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

MVP与MVVM怎么选?客户端架构的底层逻辑与落地实践

MVP与MVVM怎么选?客户端架构的底层逻辑与落地实践 做客户端开发这些年我见过太多团队在MVVM和MVP之间反复横跳。尤其是Android那边早几年大家还在用MVP手写接口回调后来Jetpack的ViewModel和DataBinding成了标配WPF和Qt圈子里又把MVVM当成最佳实践。每次架构评审总有人问“到底选哪个”这篇内容我不打算再抄一遍教科书定义而是从实际选型和落地的角度把MVP和MVVM的底层逻辑、优缺点、适配场景拆开讲透适合正在做架构方案对比、或者接手老项目准备技术重构的开发者。看完你会清楚这两个架构不是谁代替谁的关系而是各有各的取舍。为什么这个对比值得专门写一篇因为很多人对两者的理解停留在“MVVM有双向绑定MVP要写接口”这种表面区别真到项目里一上手就发现问题根本不在绑定不绑定而在状态由谁持有、生命周期谁来管、业务逻辑到底能不能测。搞清楚这几个真正有分歧的点选型就不会纠结了。1. 为什么客户端开发绕不开MVP和MVVM1.1 没有分层时界面代码为什么会变成修罗场先回忆一下最早期的写法。拿Android举例一个Activity里面可以干所有事情初始化控件、校验输入、发HTTP请求、解析JSON、更新列表、弹Toast、保存登录状态。三四百行的Activity还算克制碰上复杂的业务页面上千行也不奇怪。这种代码最大的问题不是长而是改动一个需求要在一整坨代码里反复找位置。你改一个登录逻辑可能要同时动布局、动校验、动网络层、动存储所有东西耦合在一起。而且没法测试Activity需要依赖Android环境想在JVM里跑单元测试基本不可能。后来大家意识到必须把“数据怎么处理”和“界面怎么显示”分开这就有了分层架构的需求。MVP和MVVM正是在这种背景下被大规模引入的。它们要解决的核心问题完全一样把业务逻辑从View层剥离出来让View只管展示和交互让逻辑层可以被单独测试和复用。区别在于两者用了完全不同的协作方式。1.2 MVC是起点但Controller会成为新的背锅侠聊MVP和MVVM之前绕不开MVC。Model、View、Controller三层经典中的经典在Web后端确实很香。但在客户端框架里MVC有个天然尴尬Controller这个角色经常和View混在一起。拿Android说Activity同时扮演View和Controller两个角色它既负责渲染UI又直接处理点击事件和业务逻辑等于Controller长在View身上。WPF的Code-Behind、WinForm的Form.cs也都是这个路数。写着写着Controller越来越胖最后还是回到了“代码全堆在视图层”的老路上。MVP和MVVM本质上是同一个进化方向的两条分支把控制逻辑从View里彻底拎出去。提拎出去之后怎么沟通一个选择了“手动打电话通知”也就是MVP一个选择了“自动发广播通知”也就是MVVM。理解了这一层后面所有的对比都顺了。2. MVP架构单向依赖的“接口驱动”模式2.1 MVP三件套和一次完整的用户操作MVP三个角色Model负责数据View负责展示和接收用户操作Presenter负责业务逻辑和调度。关键点在于View要暴露给Presenter的不是Activity本身而是一个接口。Android中常见的做法是定义IView接口Activity去实现它Presenter持有这个接口。整个数据流是这样的用户点击登录按钮View调用presenter.login(username, password)Presenter拿到账号密码后调用Model层的网络请求请求结果回来后Presenter再通过IView接口回调比如调用view.onLoginSuccess()或view.onLoginFailed(密码错误)最后View自己更新UI。用Kotlin写个最小例子LoginContract长这样interface LoginContract { interface View { fun showLoading() fun hideLoading() fun onLoginSuccess() fun onLoginFailed(msg: String) } interface Presenter { fun login(username: String, password: String) fun detachView() } } class LoginPresenter(private val view: LoginContract.View, private val model: LoginModel) : LoginContract.Presenter { private var isViewAttached true override fun login(username: String, password: String) { if (!isViewAttached) return view.showLoading() model.login(username, password) { success, msg - if (!isViewAttached) returnlogin view.hideLoading() if (success) view.onLoginSuccess() else view.onLoginFailed(msg) } } override fun detachView() { isViewAttached false } }Activity实现LoginContract.View接口在onCreate里new一个Presenter并把this传进去。这套结构非常直观新成员看一遍基本能懂。2.2 MVP为什么看起来简单落地全是坑说句公道话MVP是所有架构里最容易上手的因为它没有魔法全是显式调用。但也正因为全是显式调用坑特别多。第一个坑是生命周期管理。Activity销毁时如果Presenter还持有View引用异步回调中途跑回来轻则空指针重则内存泄漏。很多教程会让你在onDestroy里调presenter.detachView()但这只是把引用断开真正伤人的是回调里来不及判断View还活着就更新UI。我之前经历过一次网络请求慢用户提前退出页面回调回来后页面已经销毁直接Crash。后来规约里写死一条Presenter里所有异步回调第一行必须是attached判断否则直接return。第二个坑是接口爆炸。一个页面一个IView接口页面上有多少事件就加多少方法。项目大了以后一个接口几十个方法很正常看着就头疼。而且接口方法粒度很难拿捏太粗显得没有意义太细改起来成本极高。第三个坑是职责边界容易歪。很多人用着用着会把页面跳转、Dialog展示、Spinner选择这种纯UI行为写进Presenter因为顺手。一旦养成习惯Presenter其实还是在变相操纵View架构又慢慢走回去了。2.3 MVP实操要点生命周期、测试和依赖注入先说生命周期我在Google官方todo-mvp示例里学到的规范是这样的Presenter提供start()和stop()一个负责初始化数据一个负责清理Activity在onResume调startonStop调stop。不要依赖onDestroy做清理因为系统杀进程的时候不一定走它。还有一种做法是给View接口加一个isActive()方法判断当前View是否处于可安全更新UI的状态比单纯判断! null靠谱得多。再说测试MVP的核心优势就在这。Presenter不依赖Android环境纯JVM就能跑。用MockK或者Mockito把View接口mock掉就能验证登录失败时有没有调用onLoginFailed、网络请求有没有传递正确的参数、非法输入有没有被拦截。这套测试代码写起来极其顺畅因为所有交互都通过接口暴露出来了不需要启动Activity。最后说依赖注入。Google的todo-mvp用了Dagger2把Model和Presenter注入Activity但实际项目里不必一上来就上DI框架。Demo里直接val presenter LoginPresenter(this, LoginModel())完全够用等Presenter的构造参数多到三个以上再引入Hilt或者手动做一个AppContainer也不迟。为了架构而架构反而拖慢开发速度。3. MVVM架构数据驱动与双向绑定的“自动化”模式3.1 MVVM三件套和“数据即状态”的运转方式MVVM的三个角色是Model、ViewModel、View核心约定是ViewModel不持有View的引用只暴露可观察的数据View持有ViewModel实例并把界面元素和ViewModel的属性绑定起来。用户输入时View直接把值写进ViewModel的属性ViewModel里数据变了绑定机制自动把变化推到View上。举一个生活化的类比ViewModel像Excel里的公式单元格View像显示结果的表格。你改输入单元格公式自动重算结果单元格自动更新你不需要手动告诉表格“去刷新一下”。这就是MVVM和MVP最本质的区别MVP是手动驱动MVVM是数据驱动。拿Android Jetpack的写法说ViewModel里暴露一个MutableLiveDataString界面用observe去订阅。数据一变回调自动触发UI自动更新。WPF则是通过实现INotifyPropertyChanged接口里PropertyChanged事件来提醒UI刷新。无论哪个技术栈思想完全一致。3.2 不同技术栈中的MVVM实现差异MVVM在不同框架里的形态差别还挺大的很多人跨栈之后容易懵这里单独列一下。技术栈绑定/观察机制状态存储方式备注Android JetpackLiveData / Flow DataBinding / ComposeViewModel类配置变更时自动存活官方主推路线Compose已经是声明式UIWPFXAML Binding INotifyPropertyChanged ICommandViewModel类继承INotifyPropertyChanged依赖属性绑定MVVM是WPF社区共识Qt / QMLproperty 绑定赋值任意QObject属性QML声明式UI天然适合MVVM风格C# WinFormBindingSource INotifyPropertyChangedForm持有ViewModel能写但绑定能力弱不少团队用MVP更顺手其实不用把这些差异当成负担抓住一条主线就够了找“属性变化如何通知UI”的机制。Android看LiveData/FlowWPF看PropertyChanged事件QML看property绑定。找到主线跨栈理解MVVM很快。3.3 MVVM的优势和优势背后的代价MVVM最大的优势是ViewModel完全不认识View这让它的可测试性极好。想测登录逻辑直接new一个LoginViewModel喂数据、断言状态不需要mock任何UI接口。而且因为ViewModel不依赖View一份ViewModel逻辑可以给多个页面复用甚至跨端复用。第二个优势是样板代码大幅减少。MVP里要手写每个回调方法MVVM里只要暴露属性、写绑定表达式就够了。界面刷新这种最无聊的代码框架自动做了。但代价也很明确。首先绑定机制引入了“魔法”出问题的时候不好定位。WPF里绑定表达式打错一个属性名界面直接空白不报错Android DataBinding的报错信息还经常拐弯抹角一个XML写错编译错误能把你带偏到R类上。其次如果整个项目大量使用双向绑定状态流转会变得很难追踪你看着界面以为值变了实际上ViewModel里的状态早被别的地方改过了排查半天查不出来。最后绑定本身有运行时开销虽然现代框架优化得不错但在高频刷新和大列表场景下仍然需要小心。4. 正面硬刚MVP与MVVM的详细对比4.1 核心机制对比手动刷新vs自动通知这一节是整个对比的重点我先用一张表把最关键的差异列出来。对比维度MVPMVVMView与逻辑层通信View接口回调可观察属性 绑定UI刷新方式手动调用IView方法自动推送状态驻留位置Presenter持有随View生命周期手动管理ViewModel持有框架层面自动管理单元测试对象Presenter mock ViewViewModel直接测生命周期耦合高需手动detach低ViewModel自动感知Jetpack学习曲线平缓概念直白较高需要理解绑定原理调试难度断点清晰逻辑链路直观绑定失效时难定位性能开销接口调用几乎没有开销绑定生成代码和订阅有一定开销MVP的“手动刷新”意味着一切都在你掌控之下知道什么时候调用什么。适合喜欢显式逻辑、不太希望框架替你隐式做事的团队。MVVM的“自动通知”意味着写起来很爽但你需要对绑定机制有足够理解否则出了问题会抓瞎。4.2 可测试性和调试体验差别MVP的测试思路在2.3已经说过核心是mock View接口。但MVVM的测试更舒服因为ViewModel是纯Java/Kotlin类不需要mock任何东西。拿登录逻辑举例测试LoginViewModel就三步调用login(admin, 123)断言loading状态为true断言登录成功事件被发出。没有接口mock没有回调模拟一切都是状态断言。不过MVVM的隐患在于绑定层。你的ViewModel测试全绿但界面上就是不出来很可能是DataBinding表达式写错、属性没有通知、或者XML里面绑定路径少了一层。Android里常见的报错是Failed to call observer method看起来像反射问题其实就是绑定方法的签名不匹配调试起来非常挫败。相比之下MVP的断点链路很清晰用户在View层触发点击进入Presenter再通过IView回调出来每一步都有显式调用点非常适合新人排错。4.3 性能与内存开销对比很多人会忽略这一维度其实大项目里挺关键的。MVP的运行时开销几乎可以忽略。Presenter持有View接口调用就是普通方法调用没有额外的代理层和反射。内存风险点在于如果Presenter通过内部类或Lambda在变量捕获里隐式持有View回收不及时就会泄漏。规避手段是detach 判断attached我前面已经提过。MVVM的开销在几个地方。第一DataBinding会用注解处理器生成Binding类这些类体积不小编译期就已经开始“付费”了。第二LiveData和Flow的订阅需要维护观察者列表配置变更时要重新订阅和解除订阅。第三双向绑定时为了保证数据一致框架内部会多一层监听对文本框这种高频输入控件会有微小的性能损耗。我踩过一个大坑列表页把整个list塞进一个MutableLiveDataListItem每次刷新整体赋值结果列表任何一项变化都会触发全量UI刷新肉眼可见地卡顿。后来改成用DiffUtil只更新变化的item流畅度立刻回来了。这是MVVM实践里很容易忽略的优化点。4.4 典型框架中的倾向性选择不同技术栈其实已经给了非常明显的倾向。WPF做MVVM是社区共识模板里直接就是ViewViewModelDataTemplateQt的QML声明式UI如果不用MVVM那套思路去组织页面写久了全是回调地狱Android官方文档现在主推的路线是ComposeViewModel本质也是MVVM思想。但WinForm是个例外。原生没有依赖属性没有像样的绑定状态管理强行模拟MVVM会很别扭。我们组里有个老项目是WinForm最终用的是MVP因为Presenter拿一个View接口、手动操作控件属性比一股脑硬套绑定省心太多。Android传统View体系也是一个道理如果不启用DataBindingMVP反而更直接、更容易控制。5. 架构选型实战指南到底选哪个5.1 先回答“我的项目要不要上架构”很多人一上来就问MVP和MVVM选哪个但其实有些项目根本不需要这两者。如果一个页面只有三五个静态控件点个按钮换个文案没有任何状态流转和异步逻辑那MVC甚至不写模式都无所谓。我的判断标准是页面有没有“数据进来再出去”的过程。如果有网络请求、数据库读取、用户输入校验、多页面共享一份状态那就需要MVP或MVVM如果只是纯展示今天写个Activity明天删掉硬上架构只会让代码更臃肿。具体到选型中等复杂度的传统View项目不用启动大量绑定机制MVP就够了。数据密集、状态多的项目或者已经决定用Compose/QML这类声明式UI的项目直接MVVM别犹豫。5.2 从团队和框架两个维度做决策我一直强调技术选型要同时看三个因素团队习惯、框架倾向、项目生命周期。团队有没有系统地理解绑定机制这是MVVM能不能落地的决定性因素。我见过不止一个团队说上MVVM结果成员对PropertyChanged和DataBinding半懂不懂最后写出来的代码还是把Activity搞得很重ViewModel里塞一堆千奇百怪的操作。如果团队没有时间和预算做培训MVP几乎是零成本上手大家的接受度最高。框架倾向就得看技术栈。WPF、QML、Compose这种框架本身就是为数据驱动设计的你不选MVVM反而别扭。WinForm、传统Android View体系绑定支持偏弱MVP更容易驾驭。再补充一点MVP和MVVM都属于UI层架构解决的是界面内部的逻辑分离。到了系统级设计还有六边形架构、DDD、微服务这类更高维度的方案。两者不冲突完全可以在MVP/MVVM之上做领域建模和模块化关键是别把不同层级的架构混为一谈否则容易在项目规划阶段就吵起来。5.3 折中方案MVP绑定、MVVM轻量状态我实际做了那么多项目最大的体会是架构不是二选一中间地带完全可行而且很多时候是最优解。方案AMVP为主体页面内部数据刷新用LiveData或Flow。IView接口只保留页面跳转、Dialog显示这种一次性事件数据列表的刷新全部走监听。这样既保住了MVP的显式可控又免去了每次列表变化都要手动调接口的繁琐。方案BMVVM但不用双向绑定只用单向ViewState加UI事件。ViewModel暴露一个不可变的状态类View订阅状态变化后手动刷新局部UI一次性事件比如Toast、跳转用单独的事件流避免LiveData粘性事件带来的重复触发。这样状态是单向的排查起来比双向绑定清爽太多。如果团队正在从MVP往MVVM迁移我建议不要一步到位重写而是渐进替换先把IView接口的方法按事件类型收窄再把数据刷新改成可观察流最后把View层的引线拆掉。每一步都可以独立上线风险比一把梭小得多。6. 常见问题与踩坑经验速查6.1 MVP内存泄漏和空指针症状很典型页面销毁后异步回调还在跑回调里操作了已经销毁的View控件App直接闪退。根因是Presenter持有View接口引用超过了View的生命周期。解决办法就两条detachView()必写异步回调第一行判断attached。有人喜欢用WeakReference包View接口我试过靠不住因为弱引用在回调执行时可能已经被回收判断起来反而复杂。规范一点的做法是Activity在onDestroy里调用presenter.detachView()Presenter内部把view置为null所有回调先判空再操作。别嫌麻烦这是MVP的保命符。6.2 MVVM绑定失效和粘性事件MVVM最常见的现象就是“ViewModel数据变了界面死活不动”。排查顺序按照我吃过亏的经验来先确认绑定表达式有没有写错比如属性名大小写、路径层级再确认属性变化有没有通知MutableLiveData赋值用的是setValue而不是改内部字段最后确认订阅有没有生效是不是在onCreate里面还没走到订阅方法UI就已经初始化了。粘性事件也很坑。Android的LiveData有个特性先把数据emit出去之后新订阅者马上会收到旧值。解决一次性事件就用事件封装类我放一个极简版本class Eventout T(private val content: T) { var hasBeenHandled false private set fun getContentIfNotHandled(): T? if (hasBeenHandled) null else { hasBeenHandled true content } }订阅的时候通过getContentIfNotHandled()拿事件拿过一次之后不会重放避免重复跳转或者重复弹Toast。6.3 双向绑定循环和过度设计双向绑定用起来爽但也很容易进入死循环界面上EditText输入值回写到ViewModel属性属性变化又触发了UI刷新又回写……虽然框架有去重机制有些时候依然会出循环更新。我解决这类问题的原则是能不用双向就不用。默认用单向绑定加事件处理只有真正需要用户输入回写且不需要复杂联动的情况下才考虑。过度设计也是一个常见问题。有段时间我特别喜欢给每个页面都建ViewModel、Contract、UseCase结果一个只展示天气的页面开了七八个文件新增一个字段要改五六个地方。后来我的底线是一个页面新增字段需要动的文件超过三个就说明架构把简单事情搞复杂了要砍层次。6.4 排查调试的实用技巧MVP阶段排查问题断点放IView的每一个实现方法是最快的尤其是列表刷新和弹窗看有没有走到View层就知道回调链路对不对。MVVM阶段断点思路不一样。优先在ViewModel的属性赋值处加断点而不是在UI回调里。Android可以开DataBinding运行时的绑定列印日志里能看到onChanged回调WPF则在PropertyChanged事件里断点观察sender和PropertyName。最后分享一个最土但最有效的技巧遇到绑定问题先简化。把复杂的页面摘到一个测试入口只保留一个数据和一条绑定跑通了再加复杂度。架构问题看起来玄学本质上还是数据流的问题把数据来源、状态变更、UI展示这三条链路理清百分之九十的坑都能解开。我个人这几年的体会是MVP和MVVM不是敌人而是同一个目标的两条路。MVP更像一件趁手的工具你清楚它每一步在干什么代价是操心多MVVM更像一台自动化设备省心省力但你必须先搞懂它的运行机制。做选型的时候别被“主流”“潮流”带着走先看你的团队、你的框架、你的页面数据流有多复杂。我在Android上用过纯MVP也用过纯MVVM后来大部分时间用的是中间方案效果反而最好。架构是手段不是目的能把状态理顺、能让逻辑被测试、能让新成员快速上手那就是好架构。
返回列表