XZMocoa是一款规范 iOS 模块化及 MVVM 设计模式的框架,原生代码的设计风格,改造升级成本低,上手容易维护简单。
XZMocoa
示例项目
要运行示例工程,请在拉取代码后,先在Pods
目录下执行pod install
命令。
To run the example project, clone the repo, and run pod install
from the Pods directory first.
版本需求
iOS 11.0+,Xcode 14.0+
如何安装
推荐使用 CocoaPods 安装 XZMocoa 框架,在Podfile
文件中添加下面这行代码即可。
XZMocoa is available through CocoaPods. To install it, simply add the following line to your 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 认为它是一个独立模块,所以在启动页面时,提供了便利方法。
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 建议使用 MVVM 模式设计您的代码,包括控制器,而且列表页面中,每一个区块视图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
绑定的方法就会被触发。
// 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
机制,相当于使用keysEvents
代替了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];
}
自动差异分析从而,局部刷新的效果,在“示例工程”中有完整的展示以供参考。
Author
Xezun, developer@xezun.com
License
XZMocoa is available under the MIT license. See the LICENSE file for more info.