XZMocoa
示例项目
要运行示例工程,请在拉取代码后,先在Pods
目录下执行pod install
命令。
运行示例项目,首先克隆仓库,然后从 Pods 目录运行 pod install
。
版本需求
iOS 11.0+,Xcode 14.0+
如何安装
建议使用 CocoaPods 安装 XZMocoa 框架,只需在 Podfile
文件中添加以下代码即可。
XZMocoa通过 CocoaPods 提供。要安装它,只需在您的 Podfile 中添加以下行。
pod 'XZMocoa'
如何使用
UITableView
是 iOS 开发中常用的组件,下面以编写 UITableView
列表为例,介绍如何使用 Mocoa 进行开发。
由于 UITableView
在设计上并不完全符合 MVVM 设计模式,因此 Mocoa 将其简单地封装为 XZMocoaTableView
。封装只是简单地将它放在 UIView
中,没有其他处理,本质上与 UITableView
没有区别。
1、设计数据
将数据设计成符合 UITableView
两层数据结构的形式,将极大地简化数据处理过程,但实际开发过程中,肯定会遇到各种不同的数据格式。因此 Mocoa 为 UITableView
的数据设计了 XZMocoaTableModel
和 XZMocoaTableViewSectionModel
数据标准协议,以增加数据的通用性。
@protocol XZMocoaTableModel <XZMocoaModel>
@property (nonatomic, readonly) NSInteger numberOfSectionModels;
- (nullable id<XZMocoaTableViewSectionModel>)modelForSectionAtIndex:(NSInteger)index;
@end
@protocol XZMocoaTableViewSectionModel <XZMocoaModel>
@optional
@property (nonatomic, readonly) NSInteger numberOfCellModels;
- (nullable id)modelForCellAtIndex:(NSInteger)index;
- (NSInteger)numberOfModelsForSupplementaryKind:(XZMocoaKind)kind;
- (nullable id)modelForSupplementaryKind:(XZMocoaKind)kind atIndex:(NSInteger)index;
@end
严格来说,数据不应当承担业务逻辑,但很明显,这两个协议只是为了统一获取 UITableView
列表数据的接口,可以算也可以不算作业务逻辑,而将数据的标准由数据自身处理,维护起来也更方便。
另外,Mocoa 会自动使用数组 NSArray
的元素,而非数组本身;如果是二维数组,一维元素作为 section
数据,二维元素作为 cell
数据。
2、创建列表
// model, replace it with real data
NSArray *dataArray;
// viewModel
XZMocoaTableViewModel *tableViewModel = [[XZMocoaTableViewModel alloc] initWithModel:dataArray];
tableViewModel.module = XZMocoa(@"https://mocoa.xezun.com/table/");
[tableViewModel ready];
// view
XZMocoaTableView *tableView = [[XZMocoaTableView alloc] initWithFrame:self.view.bounds style:(UITableViewStyleGrouped)];
tableView.viewModel = tableViewModel;
[self.view addSubview:tableView];
现在,你可以运行代码来渲染列表。虽然我们没有创建 cell
,但在 DEBUG 环境下,Mocoa 会使用“占位视图”渲染目标 cell
。占位视图不仅可以帮助我们验证数据格式是否正确,还可以帮助我们防止 UITableView
数据源带来的各种 crash
问题。
3、开发 cell
模块
使用 Mocoa,你可以将每个 cell
都看作是一个完全独立的模块进行开发,然后将其注册到相应的 tableView
模块中即可。
开发 cell
模块的过程与开发普通 MVVM 模块基本相同,只需按照 MVVM 的基本要求编写即可。
3.1 定义 View、ViewModel、Model
@interface ExampleCell : UITableViewCell <XZMocoaTableViewCell>
@property (weak, nonatomic) IBOutlet UILabel *nameLabel;
@end
@interface ExampleCellViewModel : XZMocoaTableViewCellViewModel
@property (nonatomic, copy) NSString *name;
@end
@interface ExampleCellModel : NSObject <XZMocoaTableViewCellModel>
@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;
@end
除了 ViewModel
需要使用 Mocoa 提供的基类外,View
和 Model
是完全自由的,协议 XZMocoaTableViewCell
和 XZMocoaTableViewCellModel
提供了默认实现,可以直接使用。
3.2 处理数据
ViewModel
将数据处理为 View
展示所需类型。
@implementation ExampleCellViewModel
- (void)prepare {
[super prepare];
self.height = 44.0;
ExampleModel *data = self.model;
self.name = [NSString stringWithFormat:@"%@ %@", data.firstName, data.lastName];
}
- (void)tableView:(XZMocoaTableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
/// 处理 cell 的点击事件
}
@end
3.3 渲染视图
View
根据 ViewModel
提供的数据进行展示。
@implementation ExampleCell
- (void)viewModelDidChange {
ExampleViewModel *viewModel = self.viewModel;
self.nameLabel.text = viewModel.name;
}
@end
viewModelDidChange
方法是 Mocoa 提供的方法,一般在这里加载视图内容。
3.4 注册模块
尽管 section
是逻辑层,在 UITableView
中没有直接视图,但 Mocoa 仍然保留了它,因此 cell
应该注册在 section
下面。
@implementation ExampleCellModel
+ (void)load {
XZMocoa(@"https://mocoa.xezun.com/table/").section.cell.modelClass = self;
}
@end
@implementation ExampleCell
+ (void)load {
XZMocoa(@"https://mocoa.xezun.com/table/").section.cell.viewNibClass = self;
}
@end
@implementation ExampleCellViewModel
+ (void)load {
XZMocoa(@"https://mocoa.xezun.com/table/").section.cell.viewModelClass = self;
}
@end
至此,使用 XZMocoaTableView
渲染列表的简单示例就完成了,再运行代码,就可以看到效果。
在这个示例中,我们只有一种类型的 section
和 cell
,不需要命名,所以直接使用 .section.cell
注册,更多详细用法,请参考“Example”示例工程。
使用 Mocoa 渲染列表,与使用原生的 UITableView
相比:
- 无需编写
delegate
或dataSource
方法。 - 无需编写
cell
,Mocoa 会先使用占位视图代替,直到cell
模块编写完成。 cell
模块完全独立,编写cell
后,只需注册模块,无需在tableView
或collectionView
中注册。
此外,我们再也不需要多次触发 UITableView
的 Crash
去调试数据、列表、cell
的连通性了。
模块化
不论采用何种设计模式,都应该让你的代码模块化。这样,在更新和维护时,变化就可以控制在模块内,从而避免牵一发而动全身。
Mocoa 使用 MVVM 设计模式进行模块化,因为在 MVVM 设计模式下,视图可以通过自身的 ViewModel
来管理逻辑,这可以避免控制器变得越来越重。
Mocoa 为模块提供了基于 URL 的模块管理方案 XZMocoaDomain
,任何模块都可以通过 URL
在 XZMocoaDomain
中进行注册。
[[XZMocoaDomain doaminForName:@"mocoa.xezun.com"] setModule:yourModule forPath:@"your/module/path"];
上面的例子中的模块地址为 https://mocoa.xezun.com/your/module/path/
,其中 URL 的 scheme
是任意的。
id yourModule = [[XZMocoaDomain doaminForName:@"mocoa.xezun.com"] moduleForPath:@"your/module/path"];
XZMocoaDomain
实际上就是简单地使用 NSMutableDictionary
进行模块管理,因此你不必担心它的性能问题。
在实际开发中,有些提供了各种各样方法的“模块”,通过上面的注册方式获取到匿名 id
类型,似乎显得有些不必要。但在 Mocoa 看来,这样的“模块”并不是真正的模块,而只是一个组件或提供方法的工具类,因为真正的模块应该是能够独立完成功能的,不需要或仅需少量简单明了的参数。例如,每个 App 实际上就是一个独立的模块,main(int, char *)
是它们的统一入口函数。
Mocoa 将每个 MVVM 单元 Model-View-ViewModel
视为一个 Mocoa 模块,即 XZMocoaModule
模块,并做如下约定。
Model
使用-init
作为初始化方法,或者开发者自行约定统一的方法。ViewModel
使用-initWithModel:
作为初始化方法。View
中的UIViewController
使用-initWithNibName:bundle:
作为初始化方法。View
中的UIView
一般使用-initWithFrame:
作为初始化方法,而像UITableViewCell
等被管理的视图,则由它们自身决定。
然后,我们就可以在 Mocoa 中注册 MVVM 模块的 View
、Model
、ViewModel
三个部分了。
XZMocoa(@"https://mocoa.xezun.com/").modelClass = Model.class;
XZMocoa(@"https://mocoa.xezun.com/").viewClass = View.class;
XZMocoa(@"https://mocoa.xezun.com/").viewModelClass = ViewModel.class;
注:函数 XZMocoa(url)
是 +[XZMocoaModule moduleForURL:]
的便利写法。
在注册 MVVM 模块后,我们就可以根据规定的规则来使用它们了。例如,对于一个普通的视图模块,在获取数据后,我们可以这样使用它。
NSDictionary *data;
XZMocoaModule *module = XZMocoa(@"https://mocoa.xezun.com/");
id<XZMocoaModel> model = [module.modelClass yy_modelWithDictionary:data]; // 这里使用了 YYModel 组件
XZMocoaViewModel * viewModel = [[module.viewModelClass alloc] initWithModel:model];
UIView<XZMocoaView> * view = [[module.viewClass alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
[self.view addSubview:view];
对于页面 UIViewController
模块,Mocoa 认为它是一个独立的模块。因此,在启动页面时,Mocoa 提供了便利方法。
UIView<XZMocoaView> *view;
NSURL *url = [NSURL URLWithString:@"https://mocoa.xezun.com/main"];
[view.navigationController pushViewControllerWithMocoaURL:url animated:YES];
也就是说,可以直接通过 URL 打开目标页面。使用 View
打开控制器在 MVC 设计模式中是不合理的,但在 MVVM 设计模式中,UIViewController
只是一个特殊的 View
。
最后,注册模块的最佳时机应在所有业务逻辑开始之前,因此 +load
方法是最合适的选择。
+ (void)load {
XZMocoa(@"https://mocoa.xezun.com/examples/20/content/").viewNibClass = self;
}
但如果项目组对 +load
方法的使用有限制,可以通过实现 XZMocoaDomainModuleProvider
协议来自定义 XZMocoaDomain
的模块提供方式,例如读取配置文件。
@protocol XZMocoaDomainModuleProvider <NSObject>
- (nullable id)domain:(XZMocoaDomain *)domain moduleForName:(NSString *)name atPath:(NSString *)path;
@end
在层级关系中,子模块的路径通常是它的名字,例如:
https://mocoa.xezun.com/table/
——table
模块https://mocoa.xezun.com/table/name1/
——name1
是table
模块的子模块https://mocoa.xezun.com/table/name1/name2/
——name2
是name1
模块的子模块,name1
是table
模块的子模块
如果子模块有分类,使用 :
分隔,例如:
https://mocoa.xezun.com/table/section/header:name1/
——name1
是section
模块的header
子模块https://mocoa.xezun.com/table/section/footer:name2/
——name2
是section
模块的footer
子模块
模块也可以没有名字和分类,但在路径中,没有分类可以省略 :
,没有名字则不能省略 :
,例如:
https://mocoa.xezun.com/table/name/
—— 合法https://mocoa.xezun.com/table/kind:name/
—— 合法https://mocoa.xezun.com(table/kind:/
—— 合法https://mocoa.xezun.com/table/:/
—— 合法https://mocoa.xezun.com/table/kind/
—— 不合法
Mocoa MVVM
Mocoa 建议 DNA 模式来设计您的代码,包括控制器,而且在列表页面中,每个区块视图 >cell 应当设计为独立的 MVVM 模块。
Mocoa 为了更好地在页面中使用 MVVM 模式,扩展了一些原生功能。
XZMocoaView
协议,Model 遵循此协议,以表明 Model 是 MVVM 中的 >Model 元素。XZMocoaModel
协议,View 遵循此协议,以表明 View 是 MVVM 中的 >View 元素。XZMocoaViewModel
基类,由于 >ViewModel 提供的功能较为复杂,无法通过协议来呈现,因此提供了一个基类。
Mocoa 更像是一个规范而非框架,它通过协议规范 MVVM 的实现方法。
层级机制
在页面模块中,子视图模块与父视图模块或控制器模块之间存在明显的上下级关系。充分利用这种层级关系可以提高处理页面中一些上下级交互的方便性。因此,Mocoa 设计了 >ViewModel 的层级关系。
[superViewModel addSubViewModel:viewModel];
[viewModel insertSubViewModel:viewModel atIndex:1]
然后我们可以通过层级关系,发送和接收 >emit 事件。
// send the emition
- (void)emit:(NSString *)name value:(id)value;
// handle the emition
- (void)subViewModel:(XZMocoaViewModel *)subViewModel didEmit:(XZMocoaEmition *)emition;
例如,在 UITableView
列表中,当 >cell 模块更改内容时,希望 >UITableView 模块刷新页面时,可以像下面这样处理。
// 在 cell 中
- (void)handleUserAction {
// change the data then
self.height = 100; // a new height
[self emit:XZMocoaEmitUpdate value:nil];
}
// 在 UITableView 模块中
- (void)subViewModel:(__kindof XZMocoaViewModel *)subViewModel didEmit:(XZMocoaEmition *)emition {
if ([emition.name isEqualToString:XZMocoaEmitUpdate]) {
[self reloadData];
}
}
当前这样做需要一些默认的约定,例如,将 >XZMocoaEmitUpdate 用作刷新视图的事件。在 MVC 中,解决上述问题通常是通过 >delegate 实现,这会明显破坏模块的整体性,导致上层模块与下层模块的 >delegate 形成耦合。但利用层级关系处理可以很好地避免这一点。
同时,层级关系事件的局限性也很明显,只适用于处理相对明确的事件。但在模块封装完整的情况下,下层模块也不应该有其他事件需要传递给上级处理。
ready 机制
在模块层级关系中,模块创建时可能不需要立即初始化,或者可能需要额外的初始化参数,例如在 >UIViewController 中,应该在 >viewDidLoad 中初始化。因此,Mocoa 设计了 >ready 机制来延迟 >ViewModel 的初始化。
在 >ready 机制下,开发者应在 >ViewModel 的 >-prepare 方法中进行初始化。
- (void)prepare {
[super prepare];
// 执行当前模块的初始化
}
如果顶层模块,建议在合适的时机调用 >ViewModel 的 >-ready 方法。例如,页面模块通常是顶层模块,建议在 >-viewDidLoad 中执行。
- (void)viewDidLoad {
[super viewDidLoad];
Example20ViewModel *viewModel = [[Example20ViewModel alloc] initWithModel:nil];
[viewModel ready];
self.viewModel = viewModel;
self.tableView.viewModel = viewModel.tableViewModel;
}
由于控制器顶层模块,引用模块时无需准备数据,其数据由ViewModel
自行处理,因此初始化它的model
为nil
,在View
中自行创建ViewModel
也是合理的。同时,Mocoa还约定:
- 在顶层独立的
UIViewController
页面模块中,应由View
(即UIViewController
)在合适的时机自行创建ViewModel
。
外部提供数据的不完全独立的页面模块,加载和使用方式与UIView
基本一致。
XZMocoaModule *module = XZMocoa(@"https://mocoa.xezun.com/");
id model;
XZMocoaViewModel *viewModel = [[module.viewModelClass alloc] initWithModel:model];
UIViewController<XZMocoaView> *nextVC = [module instantiateViewControllerWithOptions:nil];
nextVC.viewModel = viewModel; // not ready here, and nextVC must call -ready in -viewDidLoad method before use it.
[view.navigationController pushViewController:nextVC animated:YES];
Mocoa为独立的顶层模块提供了便利的进入方法。
// UIViewController
- (void)presentViewControllerWithMocoaURL:(nullable NSURL *)url animated:(BOOL)flag completion:(void (^_Nullable)(void))completion;
- (void)addChildViewControllerWithMocoaURL:(nullable NSURL *)url;
// UINavigationController
- (void)pushViewControllerWithMocoaURL:(nullable NSURL *)url animated:(BOOL)animated;
target-action
在 MVVM 设计模式中,View
通过监听ViewModel
的属性来展示页面,但实际上,大多数情况下View
不需要持续监听,因为大多数View
只需要渲染一次。因此,Mocoa没有设计实现监听的代码,因为大多数页面渲染在viewModelDidChange
中即可完成。
在少数情况下,我们可以通过delegate
方式来实现,这比监听更直观且易于维护。然而,使用delegate
需要定义协议,使用起来相对麻烦。为了简化少量事件的处理,Mocoa设计了target-action
机制。
这是一种半自动的机制,使用NSString
作为keyEvents
,当View
绑定到某个keyEvents
之后,在ViewModel
调用-sendActionsForKeyEvents:
方法时,绑定的方法会被触发。
// view 监听了 viewModel 的 isHeaderRefreshing 属性
[viewModel addTarget:self action:@selector(headerRefreshingChanged:) forKeyEvents:@"isHeaderRefreshing"];
- (void)headerRefreshingChanged:(Example20ViewModel *)viewModel {
if (viewModel.isHeaderRefreshing) {
[self.tableView.contentView.xz_headerRefreshingView beginAnimating];
} else {
[self.tableView.contentView.xz_headerRefreshingView endAnimating];
}
}
// viewModel 发送事件
[self sendActionsForKeyEvents:@"isHeaderRefreshing"];
target-action
机制相当于使用keyEvents
替代了delegate
协议,用于处理一些简单的事件。
MVVM 化适配
原生的大部分视图控件在 MVVM 设计模式下使用都是合适的,但某些特定类型的视图在进行 MVVM 化后才适合使用在 MVVM 中。例如,具有视图管理功能的UITableView
和UICollectionView
列表视图,Mocoa将它们封装成更适合在 MVVM 设计模式中使用的XZMocoaTableView
和XZMocoaCollectionView
视图。
UIView 的适配化
在 MVVM 中,UIViewController
的角色是View
,因此在前Mocoa中可以通过View
直接获取对应的控制器。
@protocol XZMocoaView <NSObject>
@property (nonatomic, readonly, nullable) __kindof UIViewController *viewController;
@property (nonatomic, readonly, nullable) __kindof UINavigationController *navigationController;
@property (nonatomic, readonly, nullable) __kindof UITabBarController *tabBarController;
@end
UITableView/UICollectionView 的适配化
《XZMocoaTableView》和《XZMocoaCollectionView》是经过适配化的列表视图,它们仅进行了对UITableView
和UICollectionView
的一次简单封装。
- 通过
ViewModel
管理cell
的高度。
@interface XZMocoaTableCellViewModel : XZMocoaListityCellViewModel
@optional
@property (nonatomic) CGFloat height;
@end
- 将列表事件重新转发给
cell
,然后再转发给ViewModel
处理。
@interface XZMocoaTableCellViewModel : XZMocoaListityCellViewModel
@optional
- (void)tableView:(XZMocoaTableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath;
- (void)tableView:(XZMocoaTableView *)tableView willDisplayRowAtIndexPath:(NSIndexPath *)indexPath;
- (void)tableView:(XZMocoaTableView *)tableView didEndDisplayingRowAtIndexPath:(NSIndexPath*)indexPath;
@end
Mocoa 目前默认仅转发基本的三个事件,如需更多事件,开发者需要重写或在自己的Category
中实现。
- 同步更新视图。
数据变化后,调用ViewModel
的相关方法即可更新视图。
[_dataArray removeObjectAtIndex:0];
[_tableViewModel deleteSectionAtIndex:0];
- 批量更新及自动差异分析。
在传统的列表展示页面中,由于数据是通过服务端请求的,我们很少分析数据进行局部更新,通常是获取数据后直接使用reloadData
刷新页面。
现在通过 Mocoa 的自动差异分析功能,可以直接实现局部更新。
[_tableViewModel performBatchUpdates:^{
[_dataArray removeAllObjects];
[_dataArray addObjectsFromArray:newData];
} completion:^(BOOL finished) {
// do something
}];
将更新数据的操作放入batchUpdates
块中,将自动进行差异分析,并进行局部刷新。
Mocoa 的差异分析功能依赖于数据的-isEqual:
方法,因此需要在Model
中重写此方法。如果数据层已经进行了数据管理,例如从数据层获取的数据,始终是同一个对象,或者已经进行了-isEqual:
处理,则可以省略此步。
- (BOOL)isEqual:(Example20Group102CellModel *)object {
if (object == self) return YES;
if (![object isKindOfClass:[Example20Group102CellModel class]]) return NO;
return [self.nid isEqualToString:object.nid];
}
通过自动差异分析,局部刷新的效果在“示例工程”中有完整的展示,可供参考。
作者
Xezun, [email protected]
许可
XZMocoa 可在 MIT 许可下使用。有关更多信息,请参阅 LICENSE 文件。