iOS--谈一谈模块化架构(附Demo)

1,135 阅读8分钟
原文链接: www.jianshu.com

目录

  • 先说说模块化
  • 如何将中间层与业务层剥离
  • performSelector与协议的异同
  • 调用方式
  • 中间件的路由策略
  • 模块入口
  • 低版本兼容
  • 重定向路由
  • 项目的结构
  • 模块化的程度
  • 哪些模块适合下沉
  • 关于协作开发
  • 效果演示

先说说模块化

网上有很多谈模块化的文章、这里有一篇《IOS-组件化架构漫谈》有兴趣可以读读。

总之有三个阶段

MVC模式下、我们的总工程长这样:
加一个中间层、负责调用指定文件
将中间层与模块进行解耦

如何将中间层与业务层剥离

  • 刚才第二张图里的基本原理:

将原本在业务文件(KTHomeViewController)代码里的耦合代码

KTAModuleUserViewController * vc = [[KTAModuleUserViewController alloc]initWithUserName:@"kirito" age:18];
[self.navigationController pushViewController:vc animated:YES];

转移到中间层(KTComponentManager)中

//KTHomeViewController.h  

UIViewController * vc = [[KTComponentManager sharedInstance] ModuleA_getUserViewControllerWithUserName:@"kirito" age:18];
[self.navigationController pushViewController:vc animated:YES];

//KTComponentManager.h
return [[KTAModuleUserViewController alloc]initWithUserName:userName age:age];

看似业务之间相互解耦、但是中间层将要引用所有的业务模块。
直接把耦合的对象转移了而已。

  • 解耦的方式

想要解耦、前提就是不引用头文件。
那么、通过字符串代替头文件的引用就是了。
简单来讲有两种方式:

1. - (id)performSelector:(SEL)aSelector withObject:(id)object;

具体使用上

Class targetClass = NSClassFromString(@"targetName");
SEL action = NSSelectorFromString(@"ActionName");
return [target performSelector:action withObject:params];

但这样有一个问题、就是返回值如果不为id类型、有几率造成崩溃。
不过这可以通过NSInvocation进行弥补。
这段代码摘自《iOS从零到一搭建组件化项目架构》

- (id)safePerformAction:(SEL)action target:(NSObject *)target params:(NSDictionary *)params
{
    NSMethodSignature* methodSig = [target methodSignatureForSelector:action];
    if(methodSig == nil) {
        return nil;
    }
    const char* retType = [methodSig methodReturnType];

    if (strcmp(retType, @encode(void)) == 0) {
        NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
        [invocation setArgument:&params atIndex:2];
        [invocation setSelector:action];
        [invocation setTarget:target];
        [invocation invoke];
        return nil;
    }

    if (strcmp(retType, @encode(NSInteger)) == 0) {
        NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
        [invocation setArgument:&params atIndex:2];
        [invocation setSelector:action];
        [invocation setTarget:target];
        [invocation invoke];
        NSInteger result = 0;
        [invocation getReturnValue:&result];
        return @(result);
    }

    if (strcmp(retType, @encode(BOOL)) == 0) {
        NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
        [invocation setArgument:&params atIndex:2];
        [invocation setSelector:action];
        [invocation setTarget:target];
        [invocation invoke];
        BOOL result = 0;
        [invocation getReturnValue:&result];
        return @(result);
    }

    if (strcmp(retType, @encode(CGFloat)) == 0) {
        NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
        [invocation setArgument:&params atIndex:2];
        [invocation setSelector:action];
        [invocation setTarget:target];
        [invocation invoke];
        CGFloat result = 0;
        [invocation getReturnValue:&result];
        return @(result);
    }

    if (strcmp(retType, @encode(NSUInteger)) == 0) {
        NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSig];
        [invocation setArgument:&params atIndex:2];
        [invocation setSelector:action];
        [invocation setTarget:target];
        [invocation invoke];
        NSUInteger result = 0;
        [invocation getReturnValue:&result];
        return @(result);
    }

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
    return [target performSelector:action withObject:params];
#pragma clang diagnostic pop
}
  1. 利用协议的方式调用未知对象方法(这也是我使用的方式)

首先你需要一个协议:

@protocol KTComponentManagerProtocol <NSObject>

+ (id)handleAction:(NSString *)action params:(NSDictionary *)params;

@end

然后调用:

if ([targetClass respondsToSelector:@selector(handleAction:params:)]) {
     //向已经注册的对象发送Action信息
     returnObj = [targetClass handleAction:actionName params:params];
}else {
     //未注册的、进行进一步处理。比如上报啊、返回一个占位对象啊等等
     NSLog(@"未注册的方法");
}

如果有返回基本类型可以在具体入口文件里处理:

+ (id)handleAction:(NSString *)action params:(NSDictionary *)params {
    id returnValue = nil;

    if ([action isEqualToString:@"isLogin"]) {
        returnValue = @([[KTLoginManager sharedInstance] isLogin]);
    }
    if ([action isEqualToString:@"loginIfNeed"]) {
        returnValue = @([[KTLoginManager sharedInstance] loginIfNeed]);
    }
    
    if ([action isEqualToString:@"loginOut"]) {
        [[KTLoginManager sharedInstance] loginOut];
    }
    return returnValue;
}

performSelector与协议的异同

以上两种方式的中心思想基本相同、也有许多共同点:
  1. 需要用字典方式传递参数
  2. 需要处理返回值为非id的情况
    只不过一个交给路由、一个交给具体模块。
协议相比performSelector当然也有不同:
  1. 突破了performSelector最多只能传递一个参数的限制、并且你可以定制自己想要的格式
+ (id)handleAction:(NSString *)action params:(NSDictionary *)params;
  1. 具体方法的调用、协议要多一层调用
    handleAction方法根据具体的action代替performSelector进行动作的分发。

不过我还是觉得第二种方便、因为你的performSelector与实际调用的方法、也解耦了。
比如有一天你换了方法:
performSelector的方式还需要修改整个url、以保证调用到正确的Selector
而协议则不然、你可以在handleAction方法的内部进行二次路由。


调用方式

  • 中间件调用模块

这里我做了两种方案、一种纯Url一种带参

UIViewController *vc = [self openUrl:[NSString stringWithFormat:@"https://www.bilibili.com/KTModuleHandlerForA/getUserViewController?userName=%@&age=%d",userName,age]];

NSNumber *value = [self openUrl:@"ModuleHandlerForLogin/loginIfNeed" params:@{@"delegate":delegate}];

这两种方式都会用到、区别随后再说。

  • 模块间调用

用上面的方式直接调用也可以、但是容易写错。
通过为中间件加入Category的方式、对接口进行约束。
并且将url以及参数的拼装工作交给对应模块的开发人员。

@interface KTComponentManager (ModuleA)

- (UIViewController *)ModuleA_getUserViewControllerWithUserName:(NSString *)userName age:(int)age;

@end

然后直接代用中间件的Category接口

UIViewController * vc = [[KTComponentManager sharedInstance] ModuleA_getUserViewControllerWithUserName:@"kirito" age:18];
    [self.navigationController pushViewController:vc animated:YES];

中间件的路由策略

  • 远程路由 && 降级路由
- (id)openUrl:(NSString *)url{
    id returnObj;
    
    NSURL * openUrl = [NSURL URLWithString:url];
    NSString * path = [openUrl.path substringWithRange:NSMakeRange(1, openUrl.path.length - 1)];
    
    NSRange range = [path rangeOfString:@"/"];
    NSString *targetName = [path substringWithRange:NSMakeRange(0, range.location)];
    NSString *actionName = [path substringWithRange:NSMakeRange(range.location + 1, path.length - range.location - 1)];
    
    //可以对url进行路由。比如从服务器下发json文件。将AAAA/BBBB路由到AAAA/DDDD或者CCCC/EEEE这样
    if (self.redirectionjson[path]) {
        path = self.redirectionjson[path];
    }
    
    //如果该target的action已经注册
    if ([self.registeredDic[targetName] containsObject:actionName]) {
        returnObj = [self openUrl:path params:[self getURLParameters:openUrl.absoluteString]];
    }else if ([self.webUrlSet containsObject:[NSString stringWithFormat:@"%@%@",openUrl.host,openUrl.path]]){
        //低版本兼容
        //如果有某些H5页面、打开H5页面
        //webUrlSet可以由服务器下发
        NSLog(@"跳转网页:%@",url);
        
    }
    
    return returnObj;
}

远程路由需要考虑由于本地版本过低导致需要跳转H5的情况。
如果本地支持、则直接使用本地路由。

  • 本地路由
- (id)openUrl:(NSString *)url params:(NSDictionary *)params {
    id returnObj;
    
    if (url.length == 0) {
        return nil;
    }
    
    //可以对url进行路由。比如从服务器下发json文件。将AAAA/BBBB路由到AAAA/DDDD或者CCCC/EEEE这样
    if (self.redirectionjson[url]) {
        url = self.redirectionjson[url];
    }
    
    
    NSRange range = [url rangeOfString:@"/"];
    
    NSString *targetName = [url substringWithRange:NSMakeRange(0, range.location)];
    NSString *actionName = [url substringWithRange:NSMakeRange(range.location + 1, url.length - range.location - 1)];
    

    Class targetClass = NSClassFromString(targetName);
    
    
    if ([targetClass respondsToSelector:@selector(handleAction:params:)]) {
        //向已经实现了协议的对象发送Target&&Action信息
        returnObj = [targetClass handleAction:actionName params:params];
    }else {
        //未注册的、进行进一步处理。比如上报啊、返回一个占位对象啊等等
        NSLog(@"未注册的方法");
    }

    return returnObj;
}

通过调用模块入口模块targetClass遵循的中间件协议方法handleAction:params:将动作action以及参数params传递。


模块入口

模块入口实现了中间件的协议方法handleAction:params:
根据不同的Action、内部自己负责逻辑处理。

#import "ModuleHandlerForLogin.h"
#import "KTLoginManager.h"
#import "KTComponentManager+LoginModule.h"

@implementation ModuleHandlerForLogin

/**
 相当于每个模块维护自己的注册表
 */
+ (id)handleAction:(NSString *)action params:(NSDictionary *)params {
    id returnValue = nil;
    if ([action isEqualToString:@"getUserViewController"]) {
        
        returnValue = [[KTAModuleUserViewController alloc]initWithUserName:params[@"userName"] age:[params[@"age"] intValue]];
    }
    return returnValue;
}

低版本兼容

有时低版本的App也可能被远程进行路由、但却并没有原生页面。

这时、如果有H5页面、则需要跳转H5

//如果该target的action已经注册
if ([self.registeredDic[targetName] containsObject:actionName]) {
    returnObj = [self openUrl:path params:[self getURLParameters:openUrl.absoluteString]];
}else if ([self.webUrlSet containsObject:[NSString stringWithFormat:@"%@%@",openUrl.host,openUrl.path]]){
    //低版本兼容
    //如果有某些H5页面、打开H5页面
    //webUrlSet可以由服务器下发
    NSLog(@"跳转网页:%@",url);
}

registeredDic负责维护注册表、记录了本地模块实现了那些Target && Action。
这个注册动作、交给每个模块的入口进行:

/**
 在load中向模块管理器注册
 
 这里其实如果引入KTComponentManager会方便很多
 但是会依赖管理中心、所以算了
 
 */
+ (void)load {

    #pragma clang diagnostic push
    #pragma clang diagnostic ignored "-Warc-performSelector-leaks"

    Class KTComponentManagerClass = NSClassFromString(@"KTComponentManager");
    SEL sharedInstance = NSSelectorFromString(@"sharedInstance");
    id KTComponentManager = [KTComponentManagerClass performSelector:sharedInstance];
    SEL addHandleTargetWithInfo = NSSelectorFromString(@"addHandleTargetWithInfo:");
    
    NSMutableSet * actionSet = [[NSMutableSet alloc]initWithArray:@[@"getUserViewController"]];
    
    NSDictionary * targetInfo = @{
                                  @"targetName":@"KTModuleHandlerForA",
                                  @"actionSet":actionSet
                                  };
    
    [KTComponentManager performSelector:addHandleTargetWithInfo withObject:targetInfo];

    #pragma clang diagnostic pop

}

重定向路由

由于某些原因、有时我们需要修改某些Url路由的指向(比如顺风车?)

//可以对url进行路由。比如从服务器下发json文件。将AAAA/BBBB路由到AAAA/DDDD或者CCCC/EEEE这样
if (self.redirectionjson[path]) {
    path = self.redirectionjson[path];
}

这个redirectionjson由服务器下发、本地路由时如果发现有需要被重定向的Path则进行重定向动作、修改路由的目的地。


项目的结构

模块全部以私有Pods的形式引入、单个模块内部遵循MVC(随便你用什么MVP啊、MVVM啊。只要别引入其他模块的东西)。

我只是写一个demo、所以嫌麻烦没有搞Pods。意会吧。


模块化的程度

每个模块、引入了公共模块之后。
可以在自己的Target工程独立运行。


哪些模块适合下沉

可以跨产品使用的模块

日志、网络层、三方SDK、持久化、分享、工具扩展等等。


关于协作开发

pods一定要保证版本的清晰、比如Category哪怕只更新了一个入口、也要当做一个新的版本。

于是开发的阶段由于要经常更新代码、最好还是不要用pods。
大家可以写好Category在自己模块的Target先工作。

最后调试上线的时候再统一上传pods并且打包。


效果演示

写了三个按钮

- (IBAction)pushToModuleAUserVC:(UIButton *)sender {
    
    if (![[KTComponentManager sharedInstance] loginIfNeedWithDelegate:self]) {
        return;
    }
    
    UIViewController * vc = [[KTComponentManager sharedInstance] ModuleA_getUserViewControllerWithUserName:@"kirito" age:18];
    [self.navigationController pushViewController:vc animated:YES];
    
}
- (IBAction)LoginBtnClick:(UIButton *)sender {
    
    if ([[KTComponentManager sharedInstance] loginIfNeedWithDelegate:self]) {
        [[KTComponentManager sharedInstance] loginOutWithDelegate:self];
    }
    
}

- (IBAction)openWebUrl:(id)sender {
    [[KTComponentManager sharedInstance] openUrl:[NSString stringWithFormat:@"https://www.bilibili.com/video/av25305807"]];
}

//这里应该用通知获取的
- (void)didLoginIn {
    [self.loginBtn setTitle:@"退出登录" forState:UIControlStateNormal];
}

- (void)didLoginOut {
    [self.loginBtn setTitle:@"登录" forState:UIControlStateNormal];
}

Demo


最后

本文主要是自己的学习与总结。如果文内存在纰漏、万望留言斧正。如果愿意补充以及不吝赐教小弟会更加感激。