iOS架构实践干货:AOP替代基类 + MVVM + React
前言:最近公司应用架构重构,受到反革命工程师去基类,去Model等影响,将一些已经做的架构工作和思想稍微做一些总结,故此有了这篇文章,如有错误,漏洞,或者傻x之出,请包涵一笑置之,或请留言
概览:文章主要内容如下:
- 采用AOP思想,使用 Aspects 来完成替换 Controller ,View,ViewModel基类,和基类说拜拜
- View层采用 MVVM 设计模式,使用 ReactiveObjC 进行数据绑定
- 网络层使用 YTKNetwork 配合 ReactiveCocoa 封装网络请求,解决如何交付数据,交付什么样的数据(去Model化)等问题
- 采用 JLRoutes 路由 对应用进行组件化解耦
Demo 代码github地址 :FxxkBaseClass-MVVM-ReactiveObjc 比较完整可以直接拿来写项目,如果觉得有用的话请点个star 感激不尽
一、采用AOP思想,使用 Aspects 来完成替换 Controller ,View,ViewModel基类,和基类说拜拜
Casa反革命工程师 iOS应用架构谈 view层的组织和调用方案 博客中提到一个疑问
是否有必要让业务方统一派生ViewController
Casa大神回答是NO,原因如下
- 使用派生比不使用派生更容易增加业务方的使用成本
- 不使用派生手段一样也能达到统一设置的目的
对于第一点,从 集成成本 ,上手成本 ,架构维护成本等因素入手,大神博客中也已经很详细。
框架不需要通过继承即能够对ViewController进行统一配置。业务即使脱离环境,也能够跑完代码,ViewController一旦放入框架环境,不需要添加额外的或者只需添加少量代码,框架也能够起到相应的作用 对于本人来说 ,具备这点的吸引力,已经足够让我有尝试一番的心思了。
对于OC来说,方法拦截很容易就想到自带的黑魔法方法调配 Method Swizzling, 至于为ViewController做动态配置,自然非Category莫属了
Method Swizzling 业界已经有非常成熟的三方库 Aspects, 所以Demo代码采用 Aspects 做方法拦截
ViewController拦截器部分示例代码 (与Casa大神中的如出一辙):
/* FKViewControllerIntercepter.m */
+ (void)load
{
[super load];
[FKViewControllerIntercepter sharedInstance];
}
// .... 单例初始化代码
- (instancetype)init
{
self = [super init];
if (self) {
/* 方法拦截 */
// 拦截 viewDidLoad 方法
[UIViewController aspect_hookSelector:@selector(viewDidLoad) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo>aspectInfo){
[self _viewDidLoad:aspectInfo.instance];
} error:nil];
// 拦截 viewWillAppear:
[UIViewController aspect_hookSelector:@selector(viewWillAppear:) withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo, BOOL animated){
[self _viewWillAppear:animated controller:aspectInfo.instance];
} error:NULL];
}
return self;
}
至于Category已经非常熟悉了
@interface UIViewController (NonBase)
/**
去Model&&表征化参数列表
*/
@property (nonatomic, strong) NSDictionary *params;
/**
ViewModel 属性
*/
@property (nonatomic, strong) id <FKViewControllerProtocol> viewModel;
#pragma mark - 通用类
/**
返回Controller的当前bounds
@param hasNav 是否有导航栏
@param hasTabBar 是否有tabbar
@return 坐标
*/
- (CGRect)fk_visibleBoundsShowNav:(BOOL)hasNav showTabBar:(BOOL)hasTabBar;
/**
隐藏键盘
*/
- (void)fk_hideKeyBoard;
@end
可以自行添加一些配置方法,但是建议是:方法名前添加自定义前缀以示区分,老手请自动忽略
至此,我们已经实现了不继承基类来实现对ViewController的配置
到这时,我想是否也能够实现 View 和 ViewModel 的去基类 ?
我觉得可以,但是我觉得有必要牺牲一些动态性,来保证框架的稳定性,例如:我们 ViewModel 一般继承自 NSObject, 我们对 NSObject 进行拦截难免会拦截到一些不想要的东西,而且侵入性太强,容易出错。
我的做法是 :
/* FKViewModelProtocol.h */
@protocol FKViewModelProtocol <NSObject>
@required
/**
viewModel 初始化属性
*/
- (void)fk_initializeForViewModel;
@end
/* FKLoginViewModel.h */
@interface FKLoginViewModel : NSObject<FKViewModelProtocol>
/* FKViewModelIntercepter.m */
#pragma mark - Hook Methods
- (void)_initWithInstance:(NSObject <FKViewModelProtocol> *)viewModel
{
if ([viewModel respondsToSelector:@selector(fk_initializeForViewModel)]) {
[viewModel fk_initializeForViewModel];
}
}
通过协议判断是否需要执行hook方法,这样即使对 NSObject 也能够一定程度上降低风险,而我们需要做的仅仅是遵守协议,并且实现协议,兼顾迁移成本和风险
最终我们 .h 代码会变成如下,具体协议实现在 .m 中自行实现
@interface FKLoginViewModel : NSObject<FKViewModelProtocol>
@interface FKLoginViewController : UIViewController <FKViewControllerProtocol>
@interface FKLoginButton : UIButton <FKViewProtocol>
@interface FKLoginAccountInputTableViewCell : UITableViewCell <FKViewProtocol>
具体详细代码请见文章开头Demo
总结,我们通过 Category 和 方法拦截 实现了方法的动态配置,对于 View ViewModel ViewController的基类,基本上可以说一句 Fxxk Off了
二、View层采用 MVVM 设计模式,使用 ReactiveObjC 进行数据绑定
MVC前提,适合自己的才是最好的
作为老牌思想MVC,大家早已耳熟能详,MVC素有 Massive VC
之称,随着业务增加,Controller将会越来越复杂,最终Controller会变成一个"神类", 即有网络请求等代码,又充斥着大量业务逻辑,所以为Controller减负,在某些情况下变得势在必行
MVVM是基于胖Model的架构思路建立的,然后在胖Model中拆出两部分:Model和ViewModel (注:胖Model 是指包含了一些弱业务逻辑的Model)
胖Model实际上是为了减负 Controller 而存在的,而 MVVM 是为了拆分胖Model , 最终目的都是为了减负Controller
我们知道,苹果MVC并没有专门为网络层代码分专门的层级,按照以往习惯,大家都写在了Controller 中,这也是Controller 变Massive得元凶之一,现在我们可以将网络请求等诸如此类的代码放到ViewModel中了 (文章后半部分将会描述ViewModel中的网络请求)
那么,ReactiveCocoa 是实现MVVM必要的吗 ?
NO, MVVM 重在是存在 ViewModel 而不是 使用了ReactiveCocoa
数据流向正常的网络请求获取数据,然后更新View自然不必多说,那么如果View产生了数据要怎么把数据给到Model,由于View不直接持有ViewModel,所以我们需要有个桥梁 ReactiveCocoa, 通过 Signal 来和 ViewModel 通信,这个过程我们使用 通知 或者 Target-Action也可以实现相同的效果,只不过没有 ReactiveCocoa 如此方便罢了
/* View -> ViewModel 传递数据示例 */
#pragma mark - Bind ViewModel
- (void)bindViewModel:(id<FKViewModelProtocol>)viewModel withParams:(NSDictionary *)params
{
if ([viewModel isKindOfClass:[FKLoginViewModel class]]){
FKLoginViewModel *_viewModel = (FKLoginViewModel *)viewModel;
// 绑定账号 View -> ViewModel 传递数据
@weakify(self);
RAC(_viewModel, userAccount) = [[self.inputTextFiled.rac_textSignal takeUntil:self.rac_prepareForReuseSignal] map:^id _Nullable(NSString * _Nullable account) {
@strongify(self);
// 限制账号长度
if (account.length > 25) {
self.inputTextFiled.text = [account substringToIndex:25];
}
return self.inputTextFiled.text;
}];
}
}
上面代码给出了View -> ViewModel 绑定的一个例子 具体一些详情,可以直接看Demo
MVVM一些总结:
- View <-> C <-> ViewModel <-> Model 实际上应该称之为MVCVM
- Controller 将不再直接和 Model 进行绑定,而通过桥梁ViewModel
- 最终 Controller 的作用变成一些UI的处理逻辑,和进行View和ViewModel的绑定
- MVVM 和 MVC 兼容
- 由于多了一层 ViewModel, 会需要写一些胶水代码,所以代码量会增加
三、网络层使用 YTKNetwork 配合 ReactiveCocoa 封装网络请求,解决如何交付数据,交付什么样的数据(去Model化)等问题
YTKNetwork 是猿题库 iOS 研发团队基于 AFNetworking 封装的 iOS 网络库,其实现了一套 High Level 的 API,提供了更高层次的网络访问抽象。
笔者对 YTKNetwork 进行了一些封装,结合 ReactiveCocoa,并提供 reFormatter 接口对服务器响应数据重新处理,灵活交付给业务层
接下来,本文会回答两个问题
- 以什么方式将数据交付给业务层?
- 交付什么样的数据 ?
对于第一个问题
一、以什么方式将数据交付给业务层?
虽然 iOS应用架构谈 网络层设计方案 中 Casa大神写到 尽量不要用block,应该使用代理
的确,Block难以追踪和定位错误,容易内存泄漏, YTKNetwork 也提供代理方式回调
@protocol YTKRequestDelegate <NSObject>
@optional
/// Tell the delegate that the request has finished successfully.
///
/// @param request The corresponding request.
- (void)requestFinished:(__kindof YTKBaseRequest *)request;
/// Tell the delegate that the request has failed.
///
/// @param request The corresponding request.
- (void)requestFailed:(__kindof YTKBaseRequest *)request;
@end
前文有说过,MVVM 并不等于 ReactiveCocoa , 但是想要体验最纯正的 ReactiveCocoa 还是Block较为酸爽,Demo中笔者两者都给出了代码, 大家可以自行选择和斟酌哈
我们看一下 YTKNetwork 和 ReactiveCocoa 结合的代码
- (RACSignal *)rac_requestSignal
{
[self stop];
RACSignal *signal = [[RACSignal createSignal:^RACDisposable * _Nullable(id<RACSubscriber> _Nonnull subscriber) {
// 请求起飞
[self startWithCompletionBlockWithSuccess:^(__kindof YTKBaseRequest * _Nonnull request) {
// 成功回调
[subscriber sendNext:[request responseJSONObject]];
[subscriber sendCompleted];
} failure:^(__kindof YTKBaseRequest * _Nonnull request) {
// 错误回调
[subscriber sendError:[request error]];
}];
return [RACDisposable disposableWithBlock:^{
// Signal销毁 停止请求
[self stop];
}];
}] takeUntil:[self rac_willDeallocSignal]];
//设置名称 便于调试
if (DEBUG) {
[signal setNameWithFormat:@"%@ -rac_xzwRequest", RACDescription(self)];
}
return signal;
}
写了一个简单的 Category FKBaseRequest+Rac.h
ViewModel中使用 RACCommand 封装调用:
- (RACCommand *)loginCommand
{
if (!_loginCommand) {
@weakify(self);
_loginCommand = [[RACCommand alloc] initWithSignalBlock:^RACSignal * _Nonnull(id _Nullable input) {
@strongify(self);
return [[[FKLoginRequest alloc] initWithUsr:self.userAccount pwd:self.password] rac_requestSignal];
}];
}
return _loginCommand;
}
Block方式交付业务
FKLoginRequest *loginRequest = [[FKLoginRequest alloc] initWithUsr:self.userAccount pwd:self.password];
return [[[loginRequest rac_requestSignal] doNext:^(id _Nullable x) {
// 解析数据
[[NSUserDefaults standardUserDefaults] setObject:@(YES) forKey:@"isLogin"];
}] materialize];
Delegate方式交付业务
FKLoginRequest *loginRequest = [[FKLoginRequest alloc] initWithUsr:self.userAccount pwd:self.password];
// 数据请求响应代理 通过代理回调
loginRequest.delegate = self;
return [loginRequest rac_requestSignal];
#pragma mark - YTKRequestDelegate
- (void)requestFinished:(__kindof YTKBaseRequest *)request
{
// 解析数据
[[NSUserDefaults standardUserDefaults] setObject:@(YES) forKey:@"isLogin"];
}
至此我们解决了 如何交付数据的问题
二、交付什么样的数据 ?
现在诸如 JSONModel ,YYModel 之类的Json转Model的库也非常多,大多数Json对象,网络请求成功直接就被转成Model了
然而 iOS应用架构谈 网络层设计方案 中给出了两种有意思的交付思路
- 使用 reformer 对数据进行清洗
- 去特定对象表征 (去Model)
- 使用 reformer 对数据进行清洗
Casa文章中好处已经写得很详细了,通过不同的 reformer 来重塑和交付不同的业务数据,可以说是非常灵活了
reformer笔者在网络层封装 FKBaseRequest.h 中 给出了 FKBaseRequestFeformDelegate 接口来重塑数据
@protocol FKBaseRequestFeformDelegate <NSObject>
/**
自定义解析器解析响应参数
@param request 当前请求
@param jsonResponse 响应数据
@return 自定reformat数据
*/
- (id)request:(FKBaseRequest *)request reformJSONResponse:(id)jsonResponse;
@end
然后在对应的 reformer 对数据进行重塑
#pragma mark - FKBaseRequestFeformDelegate
- (id)request:(FKBaseRequest *)request reformJSONResponse:(id)jsonResponse
{
if([request isKindOfClass:FKLoginRequest.class]){
// 在这里对json数据进行重新格式化
}
return jsonResponse;
}
也可以直接在子类的 RequestManager 中覆盖父类方法达到一样的效果
/* FKLoginRequest.m */
// 可以在这里对response 数据进行重新格式化, 也可以使用delegate 设置 reformattor
- (id)reformJSONResponse:(id)jsonResponse
{
}
2. 去特定对象表征 (去Model)
这思路可以说是业界的泥石流了
去Model也就是说,使用NSDictionary形式交付数据,对于网络层而言,只需要保持住原始数据即可,不需要主动转化成数据原型
但是会存在一些小问题
- 去Model如何保持可读性?
- 复杂和多样的数据结构如何解析?
Casa大神 提出了 使用EXTERN + Const 字符串形式,并建议字符串跟着reformer走,个人觉得很多时候API只需要一种解析格式,所以Demo跟着 APIManager 走,其他情况下常量字符串建议听从 Casa大神 的建议,
常量定义:
/* FKBaseRequest.h */
// 登录token key
FOUNDATION_EXTERN NSString *FKLoginAccessTokenKey;
/* FKBaseRequest.m */
NSString *FKLoginAccessTokenKey = @"accessToken";
个人觉得 在 .h 和 .m 文件中要同时写太多代码,我们也可以使用局部常量的形式,只要在 .h 文件中定义即可
// 也可以写成 局部常量形式
static const NSString *FKLoginAccessTokenKey2 = @"accessToken";
最终那么我们的reformer可能会变成这样子
- (id)request:(FKBaseRequest *)request reformJSONResponse:(id)jsonResponse
{
if([request isKindOfClass:FKLoginRequest.class]){
// 在这里对json数据进行重新格式化
return @{
FKLoginAccessTokenKey : jsonResponse[@"token"],
};
}
return jsonResponse;
}
复杂和多样的数据结构如何解析?
有时候,reformer 交付过来的数据,我们需要解析的可能是字符串类型,也可能是NSNumber类型,也有可能是数组
为此,笔者提供了一系列 Encode Decode方法,来降低解析的复杂度和安全性
#pragma mark - Encode Decode 方法
// NSDictionary -> NSString
FK_EXTERN NSString* DecodeObjectFromDic(NSDictionary *dic, NSString *key);
// NSArray + index -> id
FK_EXTERN id DecodeSafeObjectAtIndex(NSArray *arr, NSInteger index);
// NSDictionary -> NSString
FK_EXTERN NSString * DecodeStringFromDic(NSDictionary *dic, NSString *key);
// NSDictionary -> NSString ? NSString : defaultStr
FK_EXTERN NSString* DecodeDefaultStrFromDic(NSDictionary *dic, NSString *key,NSString * defaultStr);
// NSDictionary -> NSNumber
FK_EXTERN NSNumber * DecodeNumberFromDic(NSDictionary *dic, NSString *key);
// NSDictionary -> NSDictionary
FK_EXTERN NSDictionary *DecodeDicFromDic(NSDictionary *dic, NSString *key);
// NSDictionary -> NSArray
FK_EXTERN NSArray *DecodeArrayFromDic(NSDictionary *dic, NSString *key);
FK_EXTERN NSArray *DecodeArrayFromDicUsingParseBlock(NSDictionary *dic, NSString *key, id(^parseBlock)(NSDictionary *innerDic));
#pragma mark - Encode Decode 方法
// (nonull Key: nonull NSString) -> NSMutableDictionary
FK_EXTERN void EncodeUnEmptyStrObjctToDic(NSMutableDictionary *dic,NSString *object, NSString *key);
// nonull objec -> NSMutableArray
FK_EXTERN void EncodeUnEmptyObjctToArray(NSMutableArray *arr,id object);
// (nonull (Key ? key : defaultStr) : nonull Value) -> NSMutableDictionary
FK_EXTERN void EncodeDefaultStrObjctToDic(NSMutableDictionary *dic,NSString *object, NSString *key,NSString * defaultStr);
// (nonull Key: nonull object) -> NSMutableDictionary
FK_EXTERN void EncodeUnEmptyObjctToDic(NSMutableDictionary *dic,NSObject *object, NSString *key);
我们的reformer可以写成这样子
#pragma mark - FKBaseRequestFeformDelegate
- (id)request:(FKBaseRequest *)request reformJSONResponse:(id)jsonResponse
{
if([request isKindOfClass:FKLoginRequest.class]){
// 在这里对json数据进行重新格式化
return @{
FKLoginAccessTokenKey : DecodeStringFromDic(jsonResponse, @"token")
};
}
return jsonResponse;
}
解析有可能是这样子
NSString *token = DecodeStringFromDic(jsonResponse, FKLoginAccessTokenKey)
好了,至此我们解决了两个问题
- 以什么方式将数据交付给业务层
答:delegate 最佳,block为次- 交付什么样的数据
答:纯字典,去Model
四、采用 JLRoutes 路由 对应用进行组件化解耦
iOS应用架构谈 组件化方案 一文中 Casa 针对 蘑菇街组件化 提出了质疑,质疑点主要在这几方面
- App启动时组件需要注册URL
- URL调用组件方式不太好传递类似 UIImage 等非常规对象
- URL需要添加额外参数可读性差,所以没必要使用URL
对于 App启动时组件需要注册URL 顾虑主要在于,注册的URL需要在应用生存周期内常驻内存,如果是注册Class还好些,如果注册的是实例,消耗的内存就非常可观了
至于后面几点,大家就仁者见仁,智者见智了
依小的拙见,URL方式,还是利大于弊的,Demo中采用了 JLRoutes 三方库进行路由注册,仅仅注册路由表规则,不注册 Class,不注册实例,内存消耗并不高
#pragma mark - 路由表
NSString *const FKNavPushRoute = @"/com_madao_navPush/:viewController";
NSString *const FKNavPresentRoute = @"/com_madao_navPresent/:viewController";
NSString *const FKNavStoryBoardPushRoute = @"/com_madao_navStoryboardPush/:viewController";
NSString *const FKComponentsCallBackRoute = @"/com_madao_callBack/*";
而且JLRoutes 还支持 * 来进行通配,路由表如何编写大家可以自由发挥
对应的路由事件 handler
// push
// 路由 /com_madao_navPush/:viewController
[[JLRoutes globalRoutes] addRoute:FKNavPushRoute handler:^BOOL(NSDictionary<NSString *,id> * _Nonnull parameters) {
dispatch_async(dispatch_get_main_queue(), ^{
[self _handlerSceneWithPresent:NO parameters:parameters];
});
return YES;
}];
// present
// 路由 /com_madao_navPresent/:viewController
[[JLRoutes globalRoutes] addRoute:FKNavPresentRoute handler:^BOOL(NSDictionary<NSString *,id> * _Nonnull parameters) {
dispatch_async(dispatch_get_main_queue(), ^{
[self _handlerSceneWithPresent:YES parameters:parameters];
});
return YES;
}];
#pragma mark - Private
/// 处理跳转事件
- (void)_handlerSceneWithPresent:(BOOL)isPresent parameters:(NSDictionary *)parameters
{
// 当前控制器
NSString *controllerName = [parameters objectForKey:FKControllerNameRouteParam];
UIViewController *currentVC = [self _currentViewController];
UIViewController *toVC = [[NSClassFromString(controllerName) alloc] init];
toVC.params = parameters;
if (currentVC && currentVC.navigationController) {
if (isPresent) {
[currentVC.navigationController presentViewController:toVC animated:YES completion:nil];
}else
{
[currentVC.navigationController pushViewController:toVC animated:YES];
}
}
}
通过URL中传入的组件名动态注册,处理相应跳转事件,并不需要每个组件一一注册
使用URL路由,必然URL会散落到代码各个地方
NSString *key = @"key";
NSString *value = @"value";
NSString *url = [NSString stringWithFormat:@"/com_madao_navPush/%@?%@=%@", NSStringFromClass(ViewController.class), key, value];
[[UIApplication sharedApplication] openURL:[NSURL URLWithString:url]];
诸如此类丑陋的代码,散落在各个地方的话简直会让人头皮发麻, 所以笔者在 JLRoutes+GenerateURL.h 写了一些 Helper方法
/**
避免 URL 散落各处, 集中生成URL
@param pattern 匹配模式
@param parameters 附带参数
@return URL字符串
*/
+ (NSString *)fk_generateURLWithPattern:(NSString *)pattern parameters:(NSArray *)parameters;
/**
避免 URL 散落各处, 集中生成URL
额外参数将被 ?key=value&key2=value2 样式给出
@param pattern 匹配模式
@param parameters 附加参数
@param extraParameters 额外参数
@return URL字符串
*/
+ (NSString *)fk_generateURLWithPattern:(NSString *)pattern parameters:(NSArray *)parameters extraParameters:(NSDictionary *)extraParameters;
/**
解析NSURL对象中的请求参数
http://madao?param1=value1¶m2=value2 解析成 @{param1:value1, param2:value2}
@param URL NSURL对象
@return URL字符串
*/
+ (NSDictionary *)fk_parseParamsWithURL:(NSURL *)URL;
/**
将参数对象进行url编码
将@{param1:value1, param2:value2} 转换成 ?param1=value1¶m2=value2
@param dic 参数对象
@return URL字符串
*/
+ (NSString *)fk_mapDictionaryToURLQueryString:(NSDictionary *)dic;
宏定义Helper
#undef JLRGenRoute
#define JLRGenRoute(Schema, path) \
([NSString stringWithFormat: @"%@:/%@", \
Schema, \
path])
#undef JLRGenRouteURL
#define JLRGenRouteURL(Schema, path) \
([NSURL URLWithString: \
JLRGenRoute(Schema, path)])
最终我们的调用可以变成
NSString *router = [JLRoutes fk_generateURLWithPattern:FKNavPushRoute parameters:@[NSStringFromClass(ViewController.class)] extraParameters:nil];
[[UIApplication sharedApplication] openURL:JLRGenRouteURL(FKDefaultRouteSchema, router)];
虽然还是有点长,但是是不是好多了
总结与反思
- 适合自己的才是最好的,没最好,要刚好
- 脱离业务谈架构都是耍流氓,(请叫我小流氓 = =,逃)
- 自身功力还是不够,都是在他人基础上衍生或者照搬,惭愧惭愧,希望有自己变成大牛的那一天
- iOS寒冬路难且艰,大家且行且珍惜
Demo代码github地址 :FxxkBaseClass-MVVM-ReactiveObjc 大家按需取用