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


目录

  • 先说说模块化
  • 如何将中间层与业务层剥离
  • 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


最后

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,029评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,395评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,570评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,535评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,650评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,850评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,006评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,747评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,207评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,536评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,683评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,342评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,964评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,772评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,004评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,401评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,566评论 2 349