关于 iOS 组件通信的思考

3,963 阅读9分钟

最近这几天一直在调研市场上,关于组件通信这一块的实施方案和技术选型,关于路由方式和target-action的方式,因为硬编码问题,担心后续维护硬编码可能会耗费大量精力,还有就是基于runtime的通信方式编译期难以检查是否有错,这可能会产生运行时问题,所以 Pass 掉了。我们项目目前 VC 之间通过路由方式进行跳转,实际内部就是通过字符串反射出 Class 进行实例化跳转,提供给 JS 的接口也是基于 runtime 进行了插件化,原理是差不多的,都是通过硬编码在运行时拿到真实类型,再去调用。虽然这两种方案都能进行模块间的解耦,但是在实践过程中,我们发现,在进行回归测试的时候,因为同事解决代码冲突,确实发生过路由丢失、插件丢失的情况,所以这次我直接调研了基于 Protocol 构建 Service 的方式(下面 Service 指 Protocol ),以及总结了一下自己的看法。

下面主要分析下两个框架,这两个框架是典型的基于 Service 构建的组件通信,内部有着很多实用技巧,透过这两个框架,我们去探究下 Service 构建组件通信的原理,目前我知道的,高德、天猫还有有赞的一系列App都是基于此方式(可能还有其他我还未接触到)。

阿里:《BeeHive》

有赞:《Bifrost》

基本原理

关于组件化的介绍网上文章非常多,讲的也很详细,并非本篇重点。本篇主要分析上述两个框架在组件通信的优劣,以及一些个人的思考,供技术选型使用。

《Bifrost》有赞将它比喻为彩虹桥,对组件间进行连接通信。代码相当清晰、简单。有赞的思路大概是这样:

  • 1:每一个业务组件,都定义一个Module和一个Service,Module用来实现对外提供的一些功能,Service用来定义组件对外暴漏的接口,旨在对外提供服务。
  • 2:再通过一个管理类,在+load方法内将他们之间的映射关系注册到字典里。
  • 3:app启动的时候,将所有Module进行实例化,实际所有Module皆为单例,支持同步、异步初始化,支持加载优先级。

这样其他模块想要获取Module实例,只需要通过它的Service,将Service作为key,去管理类中注册的字典,即可拿到,从而实现了组件间依赖解除,大致调用流程如下:

id<xxxService> module = [[Bifrost moduleByService:@protocol(xxxService)] doSomething:xxx];
+ (id<BifrostModuleProtocol> _Nullable)moduleByService:(Protocol*_Nonnull)serviceProtocol {
    // 映射String
    NSString *protocolStr = NSStringFromProtocol(serviceProtocol);
    ... 
    // moduleDict 之前注册的字典取Class
    Class class = BFInstance.moduleDict[protocolStr];
    // 单例,此时已经是在启动的时候初始化好的了
    id instance = [class sharedInstance];
    return instance;
}

《BeeHive》和它的思路实际上大体一致,代码相对多些,功能也相对细些,大概思路如下:

  • 1:从源码上来看,BeeHive认为每个需要对其他组件提供接口的类,都可以注册一个Service,旨在哪里需要对外提供服务,哪里进行注册,相对灵活。例如组件A的某个类需要提供一个接口给组件B,那么组件A的这个类需要对组件B提供一个Service(定义接口),再将这个Service和这个类注册到BeeHive中。这样B组件或者其他组件只需要引用Service即可。BeeHive将所有Service抽离处理放到一起让其他组件引用。
  • 2:BeeHive通过多种方式用来注册Module和Service的映射关系,不管是哪种方式最后都会通过管理类单例注册到字典中。
  • 3:组件间接口调用的时候,会通过管理类找到注册的字典,再将注册的Service为key,获取到对应的Module实例,Module实例支持单例和多例的初始化形式,在获取的过程中,还支持将其缓存到字典,这样拿到实例就可以直接调用了,从源码来看有通过递归锁保证在多线程访问的情况下,按序访问数据安全。代码大致流程如下:
id<xxxServiceProtocol> module = [[BeeHive shareInstance] createService:@protocol(xxxServiceProtocol)];
- (id)createService:(Protocol *)service
{
    return [self createService:service withServiceName:nil];
}

- (id)createService:(Protocol *)service withServiceName:(NSString *)serviceName {
    return [self createService:service withServiceName:serviceName shouldCache:YES];
}
- (id)createService:(Protocol *)service withServiceName:(NSString *)serviceName shouldCache:(BOOL)shouldCache {
    ...
    NSString *serviceStr = serviceName;
    // 支持缓存,先去缓存中查找,存在返回,不存在继续往下走
    if (shouldCache) {
        id protocolImpl = [[BHContext shareInstance] getServiceInstanceFromServiceName:serviceStr];
        if (protocolImpl) {
            return protocolImpl;
        }
    }
    // 去管理类的字典中找module类名字符串并转为Class
    NSString *serviceImpl = [[self servicesDict] objectForKey:NSStringFromProtocol(service)];
    if (serviceImpl.length > 0) {
        Class implClass = NSClassFromString(serviceImpl);
    }
    // 如果实现了singleton
    if ([[implClass class] respondsToSelector:@selector(singleton)]) {
        if ([[implClass class] singleton]) {
            if ([[implClass class] respondsToSelector:@selector(shareInstance)])
                // 实现了shareInstance就设置为单例
                implInstance = [[implClass class] shareInstance];
            else
                implInstance = [[implClass alloc] init];
            // 设置了缓存那就存储一下    
            if (shouldCache) {
                [[BHContext shareInstance] addServiceWithImplInstance:implInstance serviceName:serviceStr];
                return implInstance;
            } else {
                return implInstance;
            }
        }
    }
    // 未实现singleton直接返回为多例
    return [[implClass alloc] init];
}
  • 4:BeeHive还有一些解耦AppDelegate的逻辑,这里暂不展开。

对比选型

总结一下,大体上看这两个框架思路差不多,但是有些小细节需要再梳理下(以下Module全部表示为Service的具体实现类):

Module划分

《Bifrost》基于外观模式,组件间的调用关系全部都有外观类来实现,一个外观类对应一个Service,也就是说一个组件一个Service,有赞认为这样一来,组件间的复杂关系由外观角色来实现,降低了系统的耦合度。它将所有的外观类,也就是Module类都设置为了单例。

《BeeHive》就我从源码分析来看偏向于主张哪个类有接口需要被其他组件使用,哪个类注册一个Service,这个类可以是单例,也可以是多例,但是我觉得灵活一点为每个组件定义一个外观类也可以实现,不然可能Service文件会过多,维护困难。

这一块个人认为两者思路基本一致,相比之下《BeeHive》更灵活。

Module注册

《Bifrost》注册全部在+load方法中,每一个Module均要实现其+load方法并对Service进行注册,以达到这种映射关系。

+ (void)load {
    [Bifrost registerService:@protocol(xxxServiceProtocol) withModule:self.class];
}

相比之下《BeeHive》注册有多种方式,最新颖的是通过__attribute()函数在编译期将这种映射关系添加到 Mach-O 的数据段,在 App 启动的时候将其取出注册到字典中,具体实现都在 BHAnnotation 中。

Module管理

《Bifrost》在 App 启动的时候,在 AppDelegate 的 willFinishLaunchingWithOptions 中,将所有 Module,按照顺序进行初始化,且全部为单例。有赞在实践的过程中组件最多在20几个,所以这些单例不会带来内存问题。初始化支持异步。《Bifrost》在组件间调用的时候实际上拿到的实例已经是被初始化好的单例了。

+ (void)setupAllModules {
    NSArray *modules = [self allRegisteredModules];
    for (Class<BifrostModuleProtocol> moduleClass in modules) {
        ...省略一些代码
        if (setupSync) {
                [[moduleClass sharedInstance] setup];
            } else {
                dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
                    [[moduleClass sharedInstance] setup];
                });
            }
    }
}

《BeeHive》在 App 启动的时候,并未将所有 Module 实例化,而是将其类名及对应的 Service 名添加到管理类的字典中,在组件间真正要实施通讯的时候,根据 Service 名称,去字典中取 Module 类名,才去进行实例化,实例化的过程中支持将其设置为单例或者多例。

- (void)registerService:(Protocol *)service implClass:(Class)implClass {
    ... 
    NSString *key = NSStringFromProtocol(service);
    NSString *value = NSStringFromClass(implClass);
    
    if (key.length > 0 && value.length > 0) {
        [self.lock lock];
        // 实际上只是将string存储了起来并未将其实例化
        [self.allServicesDict addEntriesFromDictionary:@{key:value}];
        [self.lock unlock];
    }
}

在管理这一块个人感觉他们之间有着本质区别,《Bifrost》将所有 Module 设置为单例,在实践中我发现这样配置使用起来确实非常的方便,通过 Service 直接获取单例即可,尤其在需要 Module 存储某些状态时。但是这样做也发现了一个问题,因为 Module 的定位是整个组件所有对外暴漏接口的包装层,但我往往因为一些业务场景,需要 Module 持有那个具体的实现类,这时会发现被单例持有的这个类内存释放会比较麻烦,绕点弯也可以解决,但总觉得不那么美观。所以这里我相对来说偏向于《BeeHive》对组件的外观类添加多例的实现,需要的时候进行初始化,用完即释放。

传参处理

《BeeHive》在传参处理上,未看到对 model 传递的处理,如果我们需要将一个 model 从组件 A 传递到组件 B,至少在 BeeHive 的 Demo 里,如果想要传递整个 model,需要将 model 所有字段都以参数的形式传递给组件 B 使用,这样会让接口显得非常的长,也不够直观。如果组件 B 可以直接拿到 model,那么组件 B 将会很轻松的知道这个接口传递的参数来源于哪,具体是做什么的,也会侧面加强业务关联性,另外还可以通过点语法来获取参数值,这其实将非常利于读写。《Bifrost》就提供了一个很好的思路,它为 model 也构建了 Service,代码编写在 Module 所在的那个 Service 中,如下所示:

@interface GoodsModel : NSObject<GoodsProtocol>

@property(nonatomic, strong) NSString *goodsId;
@property(nonatomic, strong) NSString *name;
@property(nonatomic, assign) CGFloat price;
@property(nonatomic, assign) NSInteger inventory;

@end
#pragma mark - Model Protocols
@protocol GoodsProtocol <NSObject>
- (NSString*)goodsId;
- (NSString*)name;
- (CGFloat)price;
- (NSInteger)inventory;
@end

使用起来也很方便:

id<GoodsProtocol> goods = [BFModule(GoodsModuleService) goodsById:item.goodsId];

BFModule宏定义展开:

#define BFModule(service_protocol) ((id<service_protocol>)[Bifrost moduleByService:@protocol(service_protocol)])

总的来说,在《Bifrost》的基础上,Module管理这块,融汇一下《BeeHive》的注册方式,支持多例,在使用时创建用完释放等思想会不会更好些。

总结

额外的再说下基于 Protocol 的方式最主要的优势,就是出问题编译期就能报错,编译器帮我们检查了是否有文件缺失,是否有引用缺失,我想这也是很多公司采用这种方式的最主要原因。两个模块,通过 id <xxxServiceProtocol> xxx = ... 即可拿到其中一个模块的实例,而不需要对模块的头文件引用,从而达到模块间编译隔离和模块间通信。

接触少的同学可能会觉得这有点绕,这实际上和我们常用的代理原理一致,当我们编写一个工具类对外提供一个代理的时候,你会关心调用你的这个工具类具体是哪一个类吗?答案当然是不会的,我们只需要关心调用方是否遵循了 xxxServiceProtocol 协议并且实现了其中的方法,如果是的话我们自然就可以调用这些方法了。

ref:

《BeeHive,一次 iOS 模块化解耦实践》

《有赞移动 iOS 组件化(模块化)架构设计实践》