XZMocoa 1.0.3

XZMocoa 1.0.3

Maintained by Xezun.



XZMocoa 1.0.3

  • By
  • Xezun

XZMocoa

CI Status Version License Platform

示例项目

要运行示例工程,请在拉取代码后,先在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的数据设计了XZMocoaTableModelXZMocoaTableViewSectionModel数据标准协议,以增加数据的通用性。

@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 提供的基类外,ViewModel是完全自由的,协议XZMocoaTableViewCellXZMocoaTableViewCellModel提供了默认实现,可以直接使用。

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渲染列表的一个简单示例就完成了,再运行代码,就可以看到效果。

在这个示例中,我们只有一种类型的sectioncell,不需要具名,所以直接使用.section.cell注册,更多详细用法,可参考“Example”示例工程。

使用 Mocoa 渲染列表,与使用原生的UITableView相比:

  • 不用编写delegatedataSource方法。
  • 不用先编写cell,Mocoa 会先用占位视图替代,直到cell模块编写完成。
  • cell模块完全独立,编写cell后,仅需注册模块,不需在tableViewcollectionView中注册。

还有,我们再也不用一遍遍地触发UITableViewCrash去调试数据、列表、cell的连通性了。

模块化

不论采用何种设计模式,都应该让你的代码模块化。这样在更新维护时,变动就可以控制在模块内,从而避免牵一发而动全身。

Mocoa 使用 MVVM 设计模式进行模块化,因为在 MVVM 设计模式下,视图可以通过自身的ViewModel管理逻辑,可以避免控制器变得越来越重。

Mocoa 为模块提供了基于 URL 的模块管理方案 XZMocoaDomain,任何模块都可以通过URLXZMocoaDomain中注册。

[[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 模块的ViewModelViewModel三个部分了。

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/ name1table模块的子模块
  • https://mocoa.xezun.com/table/name1/name2/ name2name1模块的子模块,name1table模块的子模块

如果子模块有分类,使用:分隔,比如:

  • https://mocoa.xezun.com/table/section/header:name1/ name1section模块的header子模块
  • https://mocoa.xezun.com/table/section/footer:name2/ name2section模块的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自行处理的,所以初始化它的modelnil,在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作为keyEventsView在绑定的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 中使用。 比如具有视图管理功能的UITableViewUICollectionView列表视图,Mocoa 将它们封装为更适合在 MVVM 设计模式中使用的XZMocoaTableViewXZMocoaCollectionView视图。

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 的适配化

XZMocoaTableViewXZMocoaCollectionView是适配化后的列表视图,仅对UITableViewUICollectionView进行了一次简单的封装。

  1. 通过ViewModel管理cell的高度。
@interface XZMocoaTableCellViewModel : XZMocoaListityCellViewModel
@optional
@property (nonatomic) CGFloat height;
@end
  1. 列表事件,重新转发给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中自行实现。

  1. 同步更新视图。

当数据变化后,调用ViewModel相应的方法,即可更新视图。

[_dataArray removeObjectAtIndex:0];
[_tableViewModel deleteSectionAtIndex:0];
  1. 批量更新及自动差异分析。

在传统的列表展示页面中,由于数据是通过服务端请求的,我们很少分析数据进行局部更新,而是在获取到数据后,直接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, [email protected]

License

XZMocoa is available under the MIT license. See the LICENSE file for more info.