XZMocoa 1.1.2

XZMocoa 1.1.2

Xezun 维护。



XZMocoa 1.1.2

  • Xezun

XZMocoa

CI Status Version License Platform

示例项目

要运行示例工程,请在拉取代码后,先在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 的数据设计了 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 认为它是一个独立的模块。因此,在启动页面时,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 建议 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自行处理,因此初始化它的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作为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 中。例如,具有视图管理功能的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 的适配化

《XZMocoaTableView》和《XZMocoaCollectionView》是经过适配化的列表视图,它们仅进行了对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];
}

通过自动差异分析,局部刷新的效果在“示例工程”中有完整的展示,可供参考。

作者

Xezun, [email protected]

许可

XZMocoa 可在 MIT 许可下使用。有关更多信息,请参阅 LICENSE 文件。