1. 为什么 iOS 开发者需要 MVP,而不是把 ViewController 当垃圾桶
聊 MVP 之前,得先面对一个现实:iOS 开发里绝大多数项目,Controller 层都处于一种失控状态。网络请求在 Controller 里,数据解析在 Controller 里,UI 刷新逻辑在 Controller 里,甚至弹窗提示、空态判断、用户点击事件分发也全堆在 Controller 里。一个稍微复杂的页面,ViewDidLoad 里塞一千行代码是很常见的事。这种写法在项目初期完全没问题,因为页面简单、改动频繁、需求变化快,MVP 反而显得繁琐。但当页面进入第二个迭代周期,开始加状态、加埋点、加异常分支时,Controller 就会变成一座随时可能崩塌的危楼。
我参与过的一个电商项目就是典型案例。首页业务从最初的三四个接口扩展到十几个,首页 Controller 从两千行膨胀到五千多行。每次改需求,光是定位代码就要花很长时间。更难受的是,产品经理经常要求多个页面复用同样的逻辑——比如限时抢购的倒计时逻辑,首页有、商品详情页有、订单结算页也有。在传统 MVC 里,你只能把这套逻辑复制三份,或者写一堆难以维护的基类方法。直到有一天,一个同事改首页的倒计时逻辑时,不小心把详情页的开团逻辑也带崩了,我们才认真考虑引入 MVP。
MVP 的核心思想其实一句话就能说清:把视图和业务逻辑彻底拆开,让两者通过协议(Protocol)通信。比起 MVC 里的 Controller 既是中介又是业务执行者,MVP 里的 Presenter 是纯粹的业务逻辑输出单元,不持有任何 UIKit 相关的类。这意味着 Presenter 可以被独立测试、独立复用、独立替换,而 View 只是一个听话的展示工具。
在 iOS 的语境下,MVP 还有一个非常现实的价值:它是从 MVC 到 MVVM 的过渡站。MVVM 里的 ViewModel 用 KVO 或 Combine 做绑定,很多刚转 Swift 的团队会不习惯;MVP 的 Presenter 则完全用代理回调,上手成本低得多。如果你现在的项目还在用 Objective-C,MVP 几乎是重构 Controller 的最优解,因为 OC 的 KVO 写起来太痛苦,而代理是 OC 开发者的肌肉记忆。
下面这套内容,是我在几个真实项目里实践 MVP 后总结的完整方案,包含代码落地、内存管理、数据绑定、复杂页面拆分的全套经验。适合已经会写基础 iOS 页面、但对架构还没有清晰思路的开发者,也适合项目已经膨胀、正准备做技术重构的团队。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MVP 三件套的角色边界:Model、View、Presenter 各管什么
要真正用好 MVP,首先得把三个角色的边界画清楚。很多文章只讲"MVP 是 Model-View-Presenter",但真到自己写代码时,最迷惑的就是"这个逻辑到底该放 Presenter 还是 View?""这个状态是 Model 还是 Presenter 的?"。
2.1 View 只做两件事:展示数据和转发事件
MVP 里的 View 通常指 ViewController + View + Cell,但我的建议是,用协议 ViewInterface 抽象出一个"可被 Presenter 驱动的视图",然后 ViewController 实现这个协议。View 的职责是纯粹的 UI 表现层:
- 接收 Presenter 给的 viewModel 对象,把它们渲染到界面控件上
- 把用户点击、滚动手势、页面生命周期事件转交给 Presenter
View 不应该做任何业务判断,不要写 if 判断网络状态,不要计算价格是否凑满减,不要字典转模型。这些逻辑全部向上抛给 Presenter。View 里允许保留的只有纯粹的 UI 操作,比如收起键盘、滚动到指定位置、控制控件显隐。
这里容易踩一个坑:很多人把"刷新列表"和"决定刷新哪一行"混在一起。刷新列表(reloadData)是 View 的事,但决定是刷新整个列表还是局部刷新、刷新后显示空态页还是加载失败页,这是 Presenter 的职责。View 只负责执行刷新动作,至于为什么刷新、刷新成什么样,它不关心。
2.2 Model 不只是一个数据类,它要能承载业务规则
MVP 里的 Model 容易被人简化成"一个 JSON 对应的数据模型"。理论上这样能用,但会不小心把业务逻辑塞进 Presenter 或 ViewController。
更合理的 Model 设计是:数据模型 + 业务规则模型。
数据模型很简单,就是 Codable/YYModel 解析出来的结构体,放着商品名、价格、库存这些字段。业务规则模型则负责纯粹的领域逻辑,比如计算商品是否可售、根据优惠策略计算实际支付金额、判断当前是否处于抢购窗口期。这些规则不依赖 UIKit,可以独立单测。
拿电商项目举例子,优惠金额计算逻辑我强烈建议放进 Model 层而不是 Presenter:
objc复制// 商品模型 + 价格计算规则
@interface ProductModel : NSObject
@property (nonatomic, copy) NSString *productId;
@property (nonatomic, assign) double originalPrice;
@property (nonatomic, assign) double discountPrice;
// 是否可售
- (BOOL)isAvailable;
// 根据优惠策略计算最终价格
- (double)finalPriceWithCoupon:(CouponModel *)coupon;
@end
这样 Presenter 只是把这些规则的结果拿过来拼装成 viewModel,而不是自己重写一遍价格计算逻辑。以后优惠策略变了,只动 Model,Presenter 和 View 都不用改。
2.3 Presenter 是唯一的业务决策中心
Presenter 是整个模式的核心,它接收 View 转发来的事件,调用 Model 层的数据请求或业务规则,处理完成后把结果包装成 viewModel,通过协议回调给 View。
Presenter 里禁止出现任何 import UIKit 的代码。不能直接给 UILabel 赋值,不能 push 一个新的 ViewController(那是 View 或 Router 干的活),不能在回调里使用 weak self 去操作界面——它只能通过代理方法告诉 View"你应该展示这些数据了"。
我见过太多"伪 MVP"代码,Presenter 里写 self.viewController.titleLabel.text = @"xxx",或者直接 #import "GoodsDetailViewController.h" 然后 push。这样写还不如不引入 MVP,因为你把 View 的实现细节泄漏到了业务层,一旦 UI 调整,业务层也得跟着改。
2.4 通信规则:View 和 Presenter 的双向代理,Model 对两者无感
MVP 的通信机制是双向代理。View 持有 Presenter 对象,Presenter 通过 ViewInterface 协议反向驱动 View。Model 是被动数据源,它的变化要么由 Presenter 主动拉取,要么通过回调上报给 Presenter。
这里给出一个协议定义示例:
objc复制// 视图协议:Presenter 通过它驱动 View 更新
@protocol GoodsListViewInterface <NSObject>
- (void)showLoading;
- (void)hideLoading;
- (void)reloadGoodsList:(NSArray<GoodsCellViewModel *> *)viewModels;
- (void)showEmptyView;
- (void)showErrorViewWithMessage:(NSString *)message tip:(NSString *)tip;
@end
// Presenter 协议:View 通过它向 Presenter 传递事件
@protocol GoodsListPresenterInterface <NSObject>
- (void)viewDidLoad;
- (void)didPullToRefresh;
- (void)didReachBottom;
- (void)didTapCellAtIndex:(NSInteger)index;
@end
View 持有的是遵守 PresenterInterface 的对象,Presenter 持有的是遵守 ViewInterface 的对象。双方都面向协议编程,不依赖具体类。这样以后你想换掉 ViewController,或者换掉 Presenter,都是单独换,不会互相牵连。
3. 代码落地:用一个商品列表页跑通完整 MVP 链路
概念讲再多,不落地都是纸上谈兵。下面我用一个非常常见的场景——商品列表页——来演示 MVP 的完整实现。这个页面包含下拉刷新、上拉加载更多、点击跳转、空态和错误态展示。这些都是电商和内容类 App 的高频需求,用 MVP 写一遍,你能直观感受到和 MVC 的差异。
3.1 ViewModel:让 View 拿到的是现成的展示数据
MVP 里有个争议点:Presenter 到底应该回传原始 Model,还是回传包装后的 ViewModel?
我的建议是:永远使用 ViewModel。因为 View 层要做的是展示,它不应该知道 Model 里的字段名,更不应该自己去拼接展示文案。
objc复制// 商品 Cell 的 ViewModel
@interface GoodsCellViewModel : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *priceText;
@property (nonatomic, copy) NSString *tagText;
@property (nonatomic, assign) BOOL onSale;
@end
你可以看到,ViewModel 里 priceText 已经拼好了带货币符号的字符串,tagText 已经根据商品状态生成了"包邮""限时秒杀"之类的标签文案。View 拿到 ViewModel 之后,只需要做一件非常机械的事:把它们赋值给对应的控件。
这么做有个额外好处:界面改版时,比如把价格从"¥99"改成"99元",只需要改 Presenter 里构造 ViewModel 的地方,View 完全不用动。
3.2 Presenter 实现:全流程业务编排
objc复制@interface GoodsListPresenter () <GoodsListPresenterInterface>
@property (nonatomic, strong) id<GoodsListViewInterface> view;
@property (nonatomic, strong) GoodsListModel *model;
@property (nonatomic, strong) NSMutableArray<GoodsCellViewModel *> *viewModels;
@property (nonatomic, assign) NSInteger page;
@property (nonatomic, assign) BOOL loading;
@end
@implementation GoodsListPresenter
- (instancetype)initWithView:(id<GoodsListViewInterface>)view {
self = [super init];
if (self) {
_view = view;
_model = [[GoodsListModel alloc] init];
_viewModels = [NSMutableArray array];
}
return self;
}
- (void)viewDidLoad {
[self.view showLoading];
[self loadGoodsListFromScratch];
}
- (void)didPullToRefresh {
// 防止重复请求
if (self.loading) return;
[self loadGoodsListFromScratch];
}
- (void)didReachBottom {
if (self.loading) return;
[self loadNextPage];
}
- (void)didTapCellAtIndex:(NSInteger)index {
if (index >= self.viewModels.count) return;
GoodsCellViewModel *vm = self.viewModels[index];
// 通过 Router 或协议让 View 跳转
[self.view navigateToGoodsDetailWithId:vm.productId];
}
// 核心拉取逻辑,注释写得详细点
- (void)loadGoodsListFromScratch {
self.loading = YES;
self.page = 1;
__weak typeof(self) weakSelf = self;
[self.model fetchGoodsListWithPage:self.page success:^(NSArray<ProductModel *> *products, BOOL hasMore) {
__strong typeof(weakSelf) self = weakSelf;
if (!self) return;
self.loading = NO;
[self.view hideLoading];
if (products.count == 0) {
[self.view showEmptyView];
return;
}
// 把 Model 数组转成 ViewModel 数组
[self.viewModels removeAllObjects];
for (ProductModel *p in products) {
[self.viewModels addObject:[self viewModelFromProduct:p]];
}
// 这里的回调要回到主线程,后面内存和线程部分会细说
[self.view reloadGoodsList:self.viewModels];
} failure:^(NSError *error) {
__strong typeof(weakSelf) self = weakSelf;
if (!self) return;
self.loading = NO;
[self.view hideLoading];
BOOL hasData = self.viewModels.count > 0;
// 有数据时失败不整页错误态,只提示
if (hasData) {
[self.view showToastMessage:@"网络异常,请稍后重试"];
} else {
[self.view showErrorViewWithMessage:@"加载失败" tip:@"点击重试"];
}
}];
}
@end
这一段代码里发生了几件很关键的事:
- Presenter 完全不知道 View 是 UITableViewController 还是 UIViewController,它只调用协议里的方法
- 空态和错误态的判断逻辑在 Presenter 里完成,View 只负责展示两种不同状态
- "有数据失败"和"无数据失败"被区分处理,这是一个很容易被忽视的体验细节
- 防止重复加载用 loading 标记,这个也是 Presenter 管的,View 不关心请求状态
3.3 View 实现:做一只乖巧的展示者
objc复制@interface GoodsListViewController () <GoodsListViewInterface>
@property (nonatomic, strong) GoodsListPresenter *presenter;
@property (nonatomic, strong) UITableView *tableView;
@end
@implementation GoodsListViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.title = @"商品列表";
[self setupTableView];
self.presenter = [[GoodsListPresenter alloc] initWithView:self];
[self.presenter viewDidLoad];
}
// 协议方法实现
- (void)showLoading {
[self showLoadingIndicator]; // 简单封装一下,放到某个 category 里
}
- (void)hideLoading {
[self hideLoadingIndicator];
}
- (void)reloadGoodsList:(NSArray<GoodsCellViewModel *> *)viewModels {
self.dataSource = [viewModels mutableCopy];
[self.tableView reloadData];
}
- (void)showEmptyView {
// 展示空态图 + 文案
self.tableView.backgroundView = [[EmptyView alloc] init];
}
- (void)showErrorViewWithMessage:(NSString *)message tip:(NSString *)tip {
ErrorView *errorView = [[ErrorView alloc] init];
errorView.messageLabel.text = message;
errorView.tipLabel.text = tip;
__weak typeof(self) weakSelf = self;
errorView.tapBlock = ^{
__strong typeof(weakSelf) self = weakSelf;
// 把"点击重试"事件转交给 Presenter
[self.presenter viewDidLoad];
};
self.tableView.backgroundView = errorView;
}
- (void)navigateToGoodsDetailWithId:(NSString *)productId {
GoodsDetailViewController *detailVC = [[GoodsDetailViewController alloc] initWithProductId:productId];
[self.navigationController pushViewController:detailVC animated:YES];
}
// 列表代理方法里,点击事件也要转发给 Presenter
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[self.presenter didTapCellAtIndex:indexPath.row];
}
@end
看这段代码你会发现,ViewController 里没有任何业务 if-else,没有网络请求,没有数据转换。所有的逻辑都被"提"到了 Presenter。View 真正变成了一个"可被替换的皮肤"。
3.4 为什么用协议回传而不是 block 回调
写到这里可能有人会问:MVP 里 View 和 Presenter 通信,用 block 不是更简洁吗?为什么非要定义协议?
我的观点是:如果只有一两个回调,block 完全没问题。但现实中的页面回调往往多于五个:loading 状态、刷新列表、空态、错误态、跳转、弹窗。这时候你用 block 去传递,初始化方法会变成这样:
objc复制- (instancetype)initWithViewDidLoad:(void(^)(void))viewDidLoad
didRefresh:(void(^)(void))didRefresh
didTapCell:(void(^)(NSInteger index))didTapCell ...;
阅读性极差,Swift 里闭包多到传参都能写十行。协议的优势在于语义清晰:ViewInterface 里有哪些方法,就是这个 View 能展示的所有能力。而且 Xcode 的自动补全对协议方法很友好,跳转定义也方便。
当然,在 Swift 项目里你可以用闭包数组或 Combine 来管理,但那是另一种架构风格了。Objective-C 项目的 MVP,我最推荐协议。
4. MVP 落地最容易踩的坑:循环引用、线程切换与数据回显
前面讲的是 MVP 的正常姿势,这部分是实战中最折磨人的地方。很多团队引入 MVP 后,第一版写得很爽,第二版开始频繁遇到崩溃、内存泄漏、界面不刷新,最后又退回 MVC,然后得出结论"MVP 不行"。其实问题不在模式本身,而在于几个细节没处理好。
4.1 循环引用:View 持有 Presenter,Presenter 持有 View,谁先释放?
这是 MVP 最经典的死结。View 里强持有 Presenter 对象,Presenter 里为了回调 View,也强持有 view 属性。两个对象互相强引用,谁都无法释放,就会导致内存泄漏。
正确的解法是:View 强持有 Presenter,Presenter 弱持有 View。
objc复制// Presenter 里用 weak 持有 view
@interface GoodsListPresenter ()
@property (nonatomic, weak) id<GoodsListViewInterface> view;
@end
为什么 View 可以强持有 Presenter?因为 View 的生命周期通常比 Presenter 更长,Presenter 是跟随 View 创建的,也应该跟随 View 销毁而销毁。反过来,Presenter 不能强持有 View,因为 Presenter 可能会执行耗时操作,如果 View 已经退出了,Presenter 还拽着它不放,就会造成泄漏。
实际操作中还有个容易被忽略的点:ViewController 的 dealloc 之前,最好把 presenter 的网络回调置空,或者在 dealloc 里调用 [_presenter setView:nil]。虽然 weak 属性在对象销毁后会自动置 nil,但在多线程回调的边界场景里,手动置空可以避免一种诡异的野指针问题。
4.2 异步回调的主线程切换:为什么页面明明 reloadData 了却不刷新
MVP 模式下,网络请求往往在 Model 层发起,回调可能落在子线程。如果在子线程里写了 [self.view reloadGoodsList:...],而 View 的协议实现是直接操作 UI,就会遇到"数据变了但 UI 没动"的诡异现象,严重时直接崩溃。
你可能会觉得"我用 dispatch_async(dispatch_get_main_queue()) 包一下不就行了",但问题是:MVP 的每一层都该管好自己那一层的线程职责。我推荐的规范是:
- Model 层请求完数据,可以在任意线程回调,但最好明确文档里写"回调线程不保证"
- Presenter 在收到回调后,统一切到主线程再驱动 View
上面代码里我特意注释了"这里的回调要回到主线程",实际写的时候应该这样:
objc复制- (void)loadGoodsListFromScratch {
__weak typeof(self) weakSelf = self;
[self.model fetchGoodsListWithPage:self.page success:^(NSArray<ProductModel *> *products, BOOL hasMore) {
dispatch_async(dispatch_get_main_queue(), ^{
__strong typeof(weakSelf) self = weakSelf;
if (!self) return;
// ... 处理数据
});
} failure:^(NSError *error) {
dispatch_async(dispatch_get_main_queue(), ^{
__strong typeof(weakSelf) self = weakSelf;
if (!self) return;
// ... 错误处理
});
}];
}
每次回调都切主线程看起来啰嗦,但这是保证 UI 操作安全的唯一可靠方式。不要指望网络库帮你切,因为不同网络库的线程策略不一样,而且将来如果 Model 层换成别的实现,你也不希望 Presenter 的逻辑跟着变。
4.3 回调时如何正确使用 weakSelf 和 strongSelf
MVP 里 Presenter 回调 View 的方法时,如果 View 已经销毁了,weak 属性会自动变成 nil,调用 nil 对象的方法不会崩溃。但有一种情况很危险:Presenter 持有的是 id<ViewInterface> 弱引用,当你把它赋值给一个临时强引用时,如果 View 正好销毁,就会得到一个 nil,然后你以为调用了方法,实际上像是"神隐"了。
我见过的最隐蔽 bug 是这样的:
objc复制- (void)onFetchSuccess {
// 有的同事这样写
[self.view reloadGoodsList:self.viewModels];
// 有的同事这样写
id<ViewInterface> strongView = self.view;
[strongView reloadGoodsList:self.viewModels];
}
区别在于:如果 self.view 已经为 nil,第一种写法是 [nil reloadGoodsList:],在 OC 里向 nil 发消息是安全的,什么都不会发生;第二种写法里 strongView 也是 nil,同样是安全的。看起来都没问题。
但如果你在业务里对 self.view 做了"存在性判断":
objc复制id<ViewInterface> strongView = self.view;
if (strongView) {
[strongView showToastMessage:@"成功"];
[strongView reloadGoodsList:self.viewModels];
}
这段代码里,strongView 在 if 之前被赋值,多线程环境下可能正好在赋值之后、if 判断之前被释放。不过在 ARC 下,strong 局部变量会持有它,所以不会立即释放。真正要注意的是不要在回调里写 __weak id<ViewInterface> weakView = self.view; 然后连续多次使用 weakView,因为每次调用可能拿到不同的对象状态。正确做法是我上面这个 strongSelf 模式:先 strong,再使用。
4.4 Cell 里的按钮点击怎么传给 Presenter
MVP 落地时,列表 Cell 的事件转发是个高频痛点。Cell 里的"加入购物车"按钮被点击,数据要一路从 Cell 传到 Presenter,中间不能经过 ViewController 的 if-else 分发。
我的做法是用一个点击事件闭包,把事件从 Cell 传递到 ViewController,再由 ViewController 转发给 Presenter:
objc复制// Cell 里
typedef void(^GoodsCellButtonActionBlock)(NSInteger tag);
@property (nonatomic, copy) GoodsCellButtonActionBlock buttonActionBlock;
// ViewController 里 cellForRow 配置 Cell
cell.buttonActionBlock = ^(NSInteger tag) {
[self.presenter didTapAddToCartAtIndex:indexPath.row];
};
有人说 Cell 不该持有 block,担心循环引用。这里 block 持有的是 ViewController,ViewController 持有 TableView,TableView 持有 Cell,Cell 持有 block,block 又持有 ViewController,确实形成了一条强引用环。解决办法是在 Cell 的 prepareForReuse 里清空 block,或者在 configure 入口把 block 置空。最稳妥的是用 weak 引用:
objc复制__weak typeof(self) weakSelf = self;
cell.buttonActionBlock = ^(NSInteger tag) {
__strong typeof(weakSelf) self = weakSelf;
[self.presenter didTapAddToCartAtIndex:indexPath.row];
};
其实这不是 MVP 特有的问题,只要是 Cell 事件回调都会遇到,但 MVP 因为多了一层转发,特别容易在这里写乱。建议团队里统一约定:Cell 的事件一律用 block 上抛,不要在 Cell 里写代理,更不要让 Cell 直接持有 Presenter。
4.5 页面生命周期事件应该怎么通知 Presenter
MVP 里 viewWillAppear、viewDidDisappear 这些生命周期方法要不要传给 Presenter?
我的回答是:要,但要有选择。如果你在 vieDidDisappear 里要暂停播放、清空角标、统计页面停留时长,这些都属于业务逻辑,应该让 Presenter 知道。但你不能把 ViewController 的整个生命周期都透传给 Presenter,要保持最小接口。
实践中我一般只给 Presenter 暴露几个关键时刻:
objc复制- (void)viewDidLoad; // 初始化数据
- (void)viewWillAppear; // 刷新数据、曝光埋点
- (void)viewDidDisappear; // 停止播放、清状态
如果一个页面确实不需要某种生命周期,协议里就不声明,保证 Presenter 只关心它需要的时刻。这里不建议用 NSNotification 去监听页面生命周期,因为会引入隐式依赖,排查问题的时候很难追踪。
5. 进阶:多 Presenter 协同、路由解耦与 MVVM 对比
基础 MVP 写熟练之后,会开始遇到新问题:页面太复杂,一个 Presenter 膨胀了怎么办?多个模块要共享同一个 Presenter 怎么办?MVP 和 MVVM 到底怎么选?
5.1 一个页面多个 Presenter:按业务板块拆分
我之前做过一个颇复杂的首页,聚合了 banner、金刚区、反哺 feed 流、签到入口、消息通知。如果全塞进一个 Presenter,那它很快就变成新的"ViewController",照样几千行。
这时候按业务板块拆多个 Presenter 就很合适。每个板块一个 Presenter,负责自己的数据和展示逻辑。页面的 Presenter 变成"主 Presenter",负责协调各个板块 Presenter。
主 Presenter 持有子 Presenter,子 Presenter 持有各自的 View 代理。View 层则是一个大 View 协议,每个板块的小 View 协议是它的一部分。用 OC 的多继承(协议组合)可以组织得很优雅:
objc复制@protocol HomePageViewInterface <BannerViewInterface,
GoodListViewInterface,
SignInViewInterface,
MessageViewInterface>
@end
这样做的好处是,每个子 Presenter 可以独立测试,板块之间的耦合降到最低。比如签到的 UI 改版了,只需要替换 SignInPresenter 和对应的签到 View 实现,主 Presenter 完全不用动。
5.2 路由层:让 Presenter 和 ViewController 彻底解耦
MVP 里跳转逻辑有一个很常见的位置争论:Who triggers navigation? 是 View 还是 Presenter?
我个人推荐的做法是引入一个轻量路由。Presenter 只要知道"我要跳转到商品详情页,带上 productId",具体怎么构建 ViewController、怎么导航,全部交给路由层处理。
objc复制// Presenter 里
[self.view navigateToGoodsDetailWithId:vm.productId];
// View 的实现里
- (void)navigateToGoodsDetailWithId:(NSString *)productId {
UIViewController *detailVC = [[Router shared] viewControllerForGoodsDetailWithId:productId];
[self.navigationController pushViewController:detailVC animated:YES];
}
路由把"跳转目标"和"跳转方式"解耦,以后如果要改成 present 或者换新的页面容器,只需要改路由内部逻辑。Presenter 和 View 都不受影响。
不过要注意,路由层不要搞得过于重量级。如果你的跳转就几十个页面,随手写个工厂方法就够了,没必要引入 URL router 那套重型方案。
5.3 MVP 和 MVVM 的选择:什么时候别硬上 MVP
很多人问我:既然 MVVM 是更流行的方向,为什么还要用 MVP?
我的回答是:MVP 和 MVVM 不是互斥关系,而是递进关系。如果你的团队刚接触分层架构、对响应式编程不熟,MVP 更直观可控。如果你本身就在 Swift 项目里、用 Combine/RxSwift 已经得心应手,那 MVVM 可能是更好的选择。
从代码量看,MVP 比 MVC 多出协议定义、Presenter 类、ViewModel 类,刚开始确实显得"重"。但从可维护性看,一旦页面状态超过三种(加载中、有数据、空态、错误态、部分失败),MVP 的优势就会指数级放大。MVC 代码在状态一多之后会陷入 if-else 地狱,而 MVP 把这些状态都变成了 Presenter 里明确的流程控制。
给一个简单的判断标准:如果你的项目里某个页面 ViewController 超过 800 行,并且还在持续加需求,建议换成 MVP;如果你的页面代码已经精简到 300 行以内,并且需求稳定,继续用 MVC 完全没有问题。
6. 单元测试:MVP 相比 MVC 最直观的优势
最后聊聊测试,这是 MVP 被引入 iOS 开发的原始动机之一。在 MVC 下,你要测试 Controller 里的逻辑,必须构建整个界面环境,非常痛苦。MVP 里 Presenter 是纯业务类,不依赖 UIKit,可以直接用 XCTest 跑单元测试。
6.1 用 Mock 对象测试 Presenter 的业务逻辑
写一个简单的测试用例,验证"商品列表请求成功后,view 的 reloadGoodsList 被调用":
objc复制#import <XCTest/XCTest.h>
@interface MockGoodsListView : NSObject <GoodsListViewInterface>
@property (nonatomic, assign) BOOL reloadCalled;
@property (nonatomic, strong) NSArray *receivedViewModels;
@end
@implementation MockGoodsListView
- (void)reloadGoodsList:(NSArray *)viewModels {
self.reloadCalled = YES;
self.receivedViewModels = viewModels;
}
// 其他协议方法空实现
- (void)showLoading {}
- (void)hideLoading {}
- (void)showEmptyView {}
- (void)showErrorViewWithMessage:(NSString *)message tip:(NSString *)tip {}
@end
@interface GoodsListPresenterTests : XCTestCase
@end
@implementation GoodsListPresenterTests
- (void)testReloadWhenFetchSuccess {
MockGoodsListView *mockView = [[MockGoodsListView alloc] init];
GoodsListPresenter *presenter = [[GoodsListPresenter alloc] initWithView:mockView];
[presenter loadGoodsListFromScratch];
// 等异步完成,这里用 expectation
// 断言 mockView.reloadCalled == YES
}
@end
有了这套测试,以后改业务逻辑再也不用担心改坏别的功能。跑一次测试就能知道所有页面的业务逻辑是否都还在正常工作。
6.2 为什么 Swift 项目用 MVP 会更顺手
如果你在 Swift 项目里尝试 MVP,会发现有些语言特性让它比 OC 更优雅:
- Protocol 里可以定义 associatedType,让 ViewModel 类型更严格
- Extension 可以把协议方法实现按模块组织,代码可读性更好
- 避免写
__weak/__strong那一堆麻烦,Swift 的 weak 语义更清晰 - Combine 或 SnapKit 等库可以和 MVP 配合,让 View 层更薄
不过 Swift 有个坑:Protocol 里如果用 associatedType,ViewInterface 的定义复杂度会上升。新手建议先用最朴素的 protocol + class 方式,等理解透了再上 associatedType。
对于正在从 MVC 转向 MVVM 的团队,我强烈建议先把 MVP 跑熟。因为 MVP 逼着你想清楚"业务逻辑和 UI 的边界在哪",这个思考过程是 MVVM 也需要的。等你想清楚了,再引入绑定框架,会发现一切都是顺理成章的事。
我在实际项目中还有个小习惯,分享给大家:MVP 的协议方法命名,尽量用动词 + 业务名,比如 showGoodsDetail、reloadOrders、presentConfirmDialog,而不是 updateUI、refreshView 这种毫无信息量的名字。协议命名清晰了,整个项目的架构可读性会提升好几个档次。另外,MVP 重构不要一上来就全量推倒,挑一两个最混乱的页面先试点,让团队感受到"改需求变快了、Bug 变少了"之后,再逐步铺开。所有架构落地的本质都是先解决痛点,而不是为了用某种模式而用某种模式。
