iOS组件化方案实践

组件化的目的

“组件”指的是较大粒度的业务功能模块;一个APP通常由多个业务模块组成,或者是多个不同子业务线的APP通过模块的形式在主应用中进行集成;模块之间通常会有通信、互相调用;实现组件化的目的,就是为了让组件之前的互相调用可以更松散,消除组件之间由于互相依赖导致的耦合;

组件化的益处是可以使每个组件的开发过程都更加独立,更好的提升单一组件的迭代速度;开发人员可以只关注自己负责的部分,不需要全量编译整个工程,同时也提升了开发效率;

在不使用组件化的应用中,我们看到的不同模块间的相互调用通常如下所示:

#import "ModuleAViewController.h"
#import "ModuleBViewController.h"
#import "ModuleCViewController.h"

@implementation ModuleBViewController

- (void)gotoModuleAVC {
     ModuleAViewController *moduleAVC = [[ModuleAViewController alloc] init];
     //....参数传递
     [self.navigationController pushViewController: moduleAVC animated:YES];
}

- (void) gotoModuleCVC {
     ModuleCViewController *cVC = [[ModuleCViewController alloc] init];
     //...参数传递
     [self.navigationController pushViewController: cVC animated:YES];
}

@end

通过直接引用不同模块的Controller完成调用,这种方式在较小的项目里并没有什么问题;但是随着应用的扩展与版本迭代,APP会变得越来越大,这种方式很容易导致模块之间互相依赖越来越多, 模块之间无法清晰划分、形成强耦合,不利于后期的持续扩展与维护,从而降低了APP整体的迭代速度;且这种方式还不利于有单独业务线的开发模式,单个业务线上的开发需要编译整个工程,影响开发效率与编译速度;

iOS业内当前也形成了多种不同的组件化方案;通过对比不同的组件化设计方案、以及优缺点,可以加深对组件化实现的理解;增强我们自己的设计能力;

不同组件化方案的对比

前面提到过,组件化方案的主要目的就是解耦模块之间的直接调用;计算机领域里有一句话没有什么问题,是不能通过增加一个中间层来解决的;组件化方案的思路也类似,通过增加一个中间层,让不同的模块之间的互相依赖下沉到这个中间层;通过这个中间层进行通信,来解决模块之间的直接耦合问题;

因此通常的做法就是增加一个“ModuleManager”的组件管理中间层,并通过这个中间层来管理所有的组件间通信;类似于以下的结构:

组件化通信.png

通过中间层的实施组件化的实践做法有很多种,对应于不同的组件化方案;

方案一:通过提前在“ ModuleManager”中注册服务的方式,实现组件化;注册的方式至少包括以下几种:

  • 通过“URL-Instance”或“URL-Class” 注册实现组件化
  • 通过“URL-Block”注册实现组件化
  • 通过“Protocol-Class”注册实现组件化

这种做法会在每一个组件模块内部 增加一个与其他组件的通信类,通信类包含该组件需要与外部通信的所有回调接口(即组件自己提供的与外部通信的服务接口);然后通过“ ModuleManager”的中间层来管理所有的组件通信类,在应用启动时提前把这些通信类注册到“ ModuleManager”内部;

这里“ ModuleManager”要实现组件间的相互调用,需要解决以下两个问题:

  • 1、在“ ModuleManager”中如何发现这些组件通信类,并调用到通信类内部的通信接口;
  • 2、“ ModuleManager”提供什么形式的外部统一接口供跨组件通信调用;

对于第一个问题,具体的实现也存在多种不同的做法,一种比较简单和直观的做法是,在APP内维护一个组件模块的配置文件“ModulesConfig.json”,配置文件内部记录每个组件的通信类名称信息;在应用启动时读取配置文件,获取出所有的组件通信类信息;

第二个问题关联到在获取到所有的组件通信类后,通过什么样的方式把这些类注册到“ ModuleManager”模块中;注册的方式,就决定了可以提供什么样的与之对应的回调接口;这里就与以上提到过的三种注册方式相关;

通过“URL-Instance” 注册实现的组件化

这种方式通过注册“URL与通信对象”之间的Map关联,并在“ ModuleManager”中存储这个Map信息;在回调时通过URL在Map中获取出通信对象,并在通信对象内部根据指定的URL调用到对应的接口,跳转到对应的组件内页面;注册过程与调用过程的简化实现 类似如下:


// 注册的过程
// modulesConfigClass是 之前从“ModulesConfig.json”配置文件中读取出来的组件通信类信息
// 遍历每一个组件类,并在实例化通信对象后,通过URL方式的完成注册
for (Class cls in [ModuleManager sharedInstance]. modulesConfigClass) {
    //实例化通信对象
    id impl = [[cls alloc] init];

    // route的注册;
    // 通信类内部需要实现registModuleRoutes方法,方法内返回一个数组,数组内记录了所有需要注册的URL的硬编码
    if ([impl respondsToSelector:@selector(registModuleRoutes)]) {

        //遍历所有的URL
        [[impl registModuleRoutes] enumerateObjectsUsingBlock:^(NSString * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {

              //遍历所有的URL,并把url与通信实例impl进行绑定,存放在一个NSDictionary中
              if ([obj isKindOfClass:[NSString class]]) {
                  [ModuleManager.routeService  registRoute:obj  forModule:impl];
              }
          }];
      }
 }


//调用的过程,通过传入URL完成跨组件调用
- (BOOL)openRoute:(NSString *)urlStr withParams:(nullable NSDictionary *)userInfo {
        ...
        //解析urlStr
        ...
        //根据urlStr信息,在“URL与通信对象”的Map字典里获取出实例对象
        ...
        //通过获取到的实例对象,调用对象的路由跳转方法,并在通信类内部根据URL判断 完成对应页面的跳转;
        //
        ...
}

这种方式需要提前注册组件的通信实例对象,会有较大的内存常驻,且大量对象的初始化对应用的启动速度也会存在一定的影响;并会存在较多的URL硬编码,这部分编码的声明可以写在组件通信类的内部;可以在“URL与通信对象”Map字典里只做“URL与通信类”的关联,这可以减少内存开销,只要调用时在懒加载通信类的实例,这种方式就是“URL-Class”的注册方式了;

通过“URL-Block” 注册实现的组件化

“URL-Block”的方式与“URL-Instance”对比并没有本质上的差别;在应用启动时会注册所有的“URL与Block的Map关联”,回调时通过URL获取出对应的Block并执行,完成跨组件通信;与URL-Instance相比,在注册的内存占用上相对来说更节俭(可以不用实例化组件通信类);

蘑菇街的组件化实践 里有这种方式的具体实践;简化的实现方式类似如下:


// 注册的过程,某一个组件内部注册的实现
+ (void)registerComponent {
[[ModuleManager sharedInstance] registerURLPattern:@"url://PageA" withCallBack:^(NSDictionary *param) {
     PageAViewController *detailVC = [[ModuleAPageViewController alloc] init];
      //完成传参与跳转
      参数param解析与传递
      页面跳转
}



//在ModuleManager内部,注册与调用的实现
- (void)registerURLPattern:(NSString *)urlPattern withCallBack:(componentBlock)blk {
 [self.cache setObject:blk forKey:urlPattern];
}

//通过传入URL完成回调
- (BOOL)openRoute:(NSString *)urlStr withParams:(nullable NSDictionary *)userInfo {
        ...
        Block block = self.cache[urlStr];  //获取出回调
        block(userInfo);  //执行回调
        ...
}

以上两种通过URL的方式注册的回调,还需要解决应该怎么与调用方约定调用的参数传递的,回调的block里可以获取出正确的参数并做解析使用;蘑菇街内部为了解决这个问题,在内部做了一个网页统一管理所有的URL与参数传递的约定;

通过“Protocol-Class” 注册实现的组件化

这种方式通过给每一个组件提供一个满足通信协议(Protocol)的通信对象;并在应用启动时注册“Protocol与通信对象”之间的Map关联;回调时在通过Protocol获取出Map内部的通信对象,并调用通信对象内部对应的协议方法完成跨组件间的跳转;注册与调用的过程类似以下示例:

//注册的过程,buildModules是之前读取配置记录下来的组件通信类
//遍历每一个组件类,并在实例化通信对象后,通过Protocal方式的完成注册
for (Class cls in [ModuleManager sharedInstance].buildModules) {
    //实例化通信类
    id impl = [[cls alloc] init];

    // Protocol-Service的注册
    if ([impl respondsToSelector:@selector(registModuleServices)]) {
        //通信类内部需要实现registModuleServices方法,方法内登记所有需要注册的Protocol-Class的关系

        // service的注册,serviceArr接收组件内所有的Protocol-Class注册对象
        NSArray<TYModuleServiceInfo *> *serviceArr; 
        if ([impl respondsToSelector:@selector(registModuleServices)]) {
            serviceArr = [impl registModuleServices];
        }
        [serviceArr enumerateObjectsUsingBlock:^(ModuleServiceInfo * _Nonnull info, NSUInteger idx, BOOL * _Nonnull stop) {

            //遍历所有的Protocol对象,并把Protocol和info对象的关联 存放在一个NSDictionary中
            NSString *protocolStr = NSStringFromProtocol(serviceInfo.protocol);
            [self.servicesMapping  setObject: info  forKey:protocolStr];            
        }];
    }
 }


//调用的过程,以某一个组件间调用为例
//ModuleManager根据Protocol或取出通信对象,并调用通信对象内部实现的通信协议方法,完成跨组件调用
id< ModuleADetailProtocol > impl = [ModuleManager serviceOfProtocol:@protocol(ModuleADetailProtocol)];
[impl gotoMoudleASubPageViewController:nil];

//serviceOfProtocol方法内部的实现 
- (nullable id)serviceOfProtocol:(Protocol *)protocol {
    ModuleServiceInfo *info = self.servicesMapping[NSStringFromProtocol(protocol)];
    Class cls = info.implClass;
    id implInstance = nil;
    implInstance = [self singleInstanceOfClass:cls];

    return implInstance;
}

这种方式组件间的通信通过约定好的通信协议,并在组件的通信类中实现好协议方法;在跨组件调用时只需要根据对应的协议名称获取出组件的通信对象,调用对应的协议方法即可完成;

以上就是通过提前注册的方式实现的组件化方案,基本原理都是在“ ModuleManager”中通过 提前注册的方式让服务可以被ModuleManager层发现,之后根据不同的注册方式调用对应的组件通信方法,完成跨组件调用;

方案二:通过集成阿里的 BeeLive 实现组件化

BeeHive
是一个比较重量级的开源框架,关注点是方便APP实现模块化编程;能达到组件化的效果,比较适合大型APP的接入与使用;每一个独立的模块还能接受到所有的AppDelegate代理事件;

BeeHive主要也是通过模块注册的方式实现模块化,其中就包括“Protocol-Class”的形式;在应用启动时会完成所有模块服务的注册;

方案三:Casa提出的组件化方案

Casa的组件化方案相对而言是最简单也是耦合性最低的,组件化的目的同时能很好的达到;

前面提到过的几种方式都需要提前注册组件的服务类,提前注册的目的是为了可以根据注册的Key(不管是URL还是Protocol)去发现对应的服务;然后在调起对应的服务完成组件调用;Casa认为:在iOS领域,服务的发现不需要通过注册的方式,通过运行时就能做到;因此Casa实现的组件化方案是基于runtime的方式调用到对应的服务的;

这种方式的原理是通过封装“Target-Action”层实现组件通信模块,并通过中间层“CTMediator”完成跨组件间的调用;“Target”是对象,Action是对象内的方法;通过在每个组件内部封装一个Target层,来提供组件的对外通信;
在“CTMediator”中通过调用“performTarget: action: pramas:”方法调用到组件的通信方法,完成跨组件调用;方法的内部实现大概如下:


- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params shouldCacheTarget:(BOOL)shouldCacheTarget
{
    NSString *swiftModuleName = params[kCTMediatorParamsKeySwiftTargetModuleName];

    // generate target
    。。。。
 
    NSString *targetClassString = 由targetName转化而来;
    //初始化出对应Target对象
    Class targetClass = NSClassFromString(targetClassString);
    target = [[targetClass alloc] init];
    // 初始化出Action方法
    NSString *actionString = [NSString stringWithFormat:@"Action_%@:", actionName];
    SEL action = NSSelectorFromString(actionString);
    if (shouldCacheTarget) {
        //是否执行缓存处理
        。。。
    }

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        return [target performSelector:action withObject:params];   //调用到对应方法
#pragma clang diagnostic pop
}

这里不同组件的调用方法如果都封装在“CTMediator”类内部的话,会导致这个类过于庞大和复杂,难以维护;解决方式是给每一个Target层封装一个CTMediator的分类;达到不同组件模块的接口分离;具体的实践可以看这个demo

在这个组件化框架中还做了远程调用与本地调用的路径区分,方便处理一些远程与本地调用发生异常是的可能存在的不同处理方式等;因为本质上组件化是需要为远程调用服务的;

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