iOS架构实践干货:AOP替代基类 + MVVM + ReactiveObjC + JLRoutes组件化

前言:最近公司应用架构重构,受到反革命工程师去基类,去Model等影响,将一些已经做的架构工作和思想稍微做一些总结,故此有了这篇文章,如有错误,漏洞,或者傻x之出,请包涵一笑置之,或请留言

概览:文章主要内容如下:

  1. 采用AOP思想,使用 Aspects 来完成替换 Controller ,View,ViewModel基类,和基类说拜拜
  2. View层采用 MVVM 设计模式,使用 ReactiveObjC 进行数据绑定
  3. 网络层使用 YTKNetwork 配合 ReactiveCocoa 封装网络请求,解决如何交付数据,交付什么样的数据(去Model化)等问题
  4. 采用 JLRoutes 路由 对应用进行组件化解耦

Demo 代码github地址 :FxxkBaseClass-MVVM-ReactiveObjc 比较完整可以直接拿来写项目,如果觉得有用的话请点个star 感激不尽

一、采用AOP思想,使用 Aspects 来完成替换 Controller ,View,ViewModel基类,和基类说拜拜

Casa反革命工程师 iOS应用架构谈 view层的组织和调用方案 博客中提到一个疑问

是否有必要让业务方统一派生ViewController

Casa大神回答是NO,原因如下

  1. 使用派生比不使用派生更容易增加业务方的使用成本
  2. 不使用派生手段一样也能达到统一设置的目的

对于第一点,从 集成成本上手成本架构维护成本等因素入手,大神博客中也已经很详细。

框架不需要通过继承即能够对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:^(idaspectInfo){
            [self _viewDidLoad:aspectInfo.instance];
        }  error:nil];
        
        // 拦截 viewWillAppear:
        [UIViewController aspect_hookSelector:@selector(viewWillAppear:) withOptions:AspectPositionAfter usingBlock:^(id 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  viewModel;

#pragma mark - 通用类

/**
 返回Controller的当前bounds
 
 @param hasNav 是否有导航栏
 @param hasTabBar 是否有tabbar
 @return 坐标
 */
- (CGRect)fk_visibleBoundsShowNav:(BOOL)hasNav showTabBar:(BOOL)hasTabBar;

/**
 隐藏键盘
 */
- (void)fk_hideKeyBoard;
@end

可以自行添加一些配置方法,但是建议是:方法名前添加自定义前缀以示区分,老手请自动忽略

至此,我们已经实现了不继承基类来实现对ViewController的配置

到这时,我想是否也能够实现 ViewViewModel 的去基类 ?

我觉得可以,但是我觉得有必要牺牲一些动态性,来保证框架的稳定性,例如:我们 ViewModel 一般继承自 NSObject, 我们对 NSObject 进行拦截难免会拦截到一些不想要的东西,而且侵入性太强,容易出错。

我的做法是 :

/*     FKViewModelProtocol.h      */
@protocol FKViewModelProtocol 

@required

/**
 viewModel 初始化属性
 */
- (void)fk_initializeForViewModel;

@end
/*     FKLoginViewModel.h      */
@interface FKLoginViewModel : NSObject
/*     FKViewModelIntercepter.m      */
#pragma mark - Hook Methods
- (void)_initWithInstance:(NSObject  *)viewModel
{
    if ([viewModel respondsToSelector:@selector(fk_initializeForViewModel)]) {
        [viewModel fk_initializeForViewModel];
    }
}

通过协议判断是否需要执行hook方法,这样即使对 NSObject 也能够一定程度上降低风险,而我们需要做的仅仅是遵守协议,并且实现协议,兼顾迁移成本和风险

最终我们 .h 代码会变成如下,具体协议实现在 .m 中自行实现

@interface FKLoginViewModel : NSObject
@interface FKLoginViewController : UIViewController 
@interface FKLoginButton : UIButton 
@interface FKLoginAccountInputTableViewCell : UITableViewCell 

具体详细代码请见文章开头Demo

总结,我们通过 Category方法拦截 实现了方法的动态配置,对于 View ViewModel ViewController的基类,基本上可以说一句 Fxxk Off了

二、View层采用 MVVM 设计模式,使用 ReactiveObjC 进行数据绑定

前提,适合自己的才是最好的

iOS架构实践干货:AOP替代基类 + MVVM + ReactiveObjC + JLRoutes组件化_第1张图片
MVC

作为老牌思想MVC,大家早已耳熟能详,MVC素有 Massive VC之称,随着业务增加,Controller将会越来越复杂,最终Controller会变成一个"神类", 即有网络请求等代码,又充斥着大量业务逻辑,所以为Controller减负,在某些情况下变得势在必行

iOS架构实践干货:AOP替代基类 + MVVM + ReactiveObjC + JLRoutes组件化_第2张图片
MCVMVMV.gif

MVVM是基于胖Model的架构思路建立的,然后在胖Model中拆出两部分:Model和ViewModel (:胖Model 是指包含了一些弱业务逻辑的Model)

胖Model实际上是为了减负 Controller 而存在的,而 MVVM 是为了拆分胖Model , 最终目的都是为了减负Controller

我们知道,苹果MVC并没有专门为网络层代码分专门的层级,按照以往习惯,大家都写在了Controller 中,这也是Controller 变Massive得元凶之一,现在我们可以将网络请求等诸如此类的代码放到ViewModel中了 (文章后半部分将会描述ViewModel中的网络请求)

那么,ReactiveCocoa 是实现MVVM必要的吗 ?

NO, MVVM 重在是存在 ViewModel 而不是 使用了ReactiveCocoa

iOS架构实践干货:AOP替代基类 + MVVM + ReactiveObjC + JLRoutes组件化_第3张图片
数据流向

正常的网络请求获取数据,然后更新View自然不必多说,那么如果View产生了数据要怎么把数据给到Model,由于View不直接持有ViewModel,所以我们需要有个桥梁 ReactiveCocoa, 通过 Signal 来和 ViewModel 通信,这个过程我们使用 通知 或者 Target-Action也可以实现相同的效果,只不过没有 ReactiveCocoa 如此方便罢了

/*  View -> ViewModel 传递数据示例   */
#pragma mark - Bind ViewModel
- (void)bindViewModel:(id)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一些总结:

  1. View <-> C <-> ViewModel <-> Model 实际上应该称之为MVCVM
  2. Controller 将不再直接和 Model 进行绑定,而通过桥梁ViewModel
  3. 最终 Controller 的作用变成一些UI的处理逻辑,和进行View和ViewModel的绑定
  4. MVVMMVC 兼容
  5. 由于多了一层 ViewModel, 会需要写一些胶水代码,所以代码量会增加
三、网络层使用 YTKNetwork 配合 ReactiveCocoa 封装网络请求,解决如何交付数据,交付什么样的数据(去Model化)等问题

YTKNetwork 是猿题库 iOS 研发团队基于 AFNetworking 封装的 iOS 网络库,其实现了一套 High Level 的 API,提供了更高层次的网络访问抽象。

笔者对 YTKNetwork 进行了一些封装,结合 ReactiveCocoa,并提供 reFormatter 接口对服务器响应数据重新处理,灵活交付给业务层

接下来,本文会回答两个问题

  1. 以什么方式将数据交付给业务层?
  2. 交付什么样的数据 ?

对于第一个问题

一、以什么方式将数据交付给业务层?

虽然 iOS应用架构谈 网络层设计方案 中 Casa大神写到 尽量不要用block,应该使用代理

的确,Block难以追踪和定位错误,容易内存泄漏, YTKNetwork 也提供代理方式回调

@protocol YTKRequestDelegate 

@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中笔者两者都给出了代码, 大家可以自行选择和斟酌哈

我们看一下 YTKNetworkReactiveCocoa 结合的代码

- (RACSignal *)rac_requestSignal
{
    [self stop];
    RACSignal *signal = [[RACSignal createSignal:^RACDisposable * _Nullable(id  _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"];
}

至此我们解决了 如何交付数据的问题

二、交付什么样的数据 ?

现在诸如 JSONModelYYModel 之类的Json转Model的库也非常多,大多数Json对象,网络请求成功直接就被转成Model了

然而 iOS应用架构谈 网络层设计方案 中给出了两种有意思的交付思路

  1. 使用 reformer 对数据进行清洗
  2. 去特定对象表征 (去Model)
  1. 使用 reformer 对数据进行清洗

Casa文章中好处已经写得很详细了,通过不同的 reformer 来重塑和交付不同的业务数据,可以说是非常灵活了

iOS架构实践干货:AOP替代基类 + MVVM + ReactiveObjC + JLRoutes组件化_第4张图片
reformer

笔者在网络层封装 FKBaseRequest.h 中 给出了 FKBaseRequestFeformDelegate 接口来重塑数据

@protocol FKBaseRequestFeformDelegate 

/**
 自定义解析器解析响应参数

 @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形式交付数据,对于网络层而言,只需要保持住原始数据即可,不需要主动转化成数据原型

但是会存在一些小问题

  1. 去Model如何保持可读性?
  2. 复杂和多样的数据结构如何解析?

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)

好了,至此我们解决了两个问题

  1. 以什么方式将数据交付给业务层
    答:delegate 最佳,block为次
  2. 交付什么样的数据
    答:纯字典,去Model
四、采用 JLRoutes 路由 对应用进行组件化解耦

iOS应用架构谈 组件化方案 一文中 Casa 针对 蘑菇街组件化 提出了质疑,质疑点主要在这几方面

  1. App启动时组件需要注册URL
  2. URL调用组件方式不太好传递类似 UIImage 等非常规对象
  3. 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 * _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 * _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)];

虽然还是有点长,但是是不是好多了

总结与反思

  1. 适合自己的才是最好的,没最好,要刚好
  2. 脱离业务谈架构都是耍流氓,(请叫我小流氓 = =,逃)
  3. 自身功力还是不够,都是在他人基础上衍生或者照搬,惭愧惭愧,希望有自己变成大牛的那一天
  4. iOS寒冬路难且艰,大家且行且珍惜

Demo代码github地址 :FxxkBaseClass-MVVM-ReactiveObjc 大家按需取用

你可能感兴趣的:(iOS架构实践干货:AOP替代基类 + MVVM + ReactiveObjC + JLRoutes组件化)