iOS 基于JLRoute 改造的路由
之前有写了一篇博客《iOS 基于JLRoutes 改造的路由》那时候比较忙所以只是简单地画了一个大致的框图,这段时间有时间所以抽空再写一篇针对JRLRoute源码解析的文章。
JLRoute代码量不大,建议大家可以跟着这篇博客简单过下整个代码:
在对源码进行分析之前需要先了解下JLRoutes的大致结构,JLRoutes通过sheme将整个路由空间分隔开,每个sheme相当于一个命名空间,每个sheme对应一个JLRoutes,每个JLRoutes有一个属性mutableRoutes 用于存放它所能匹配的JLRRouteDefinition。每个JLRRouteDefinition针对一个路由匹配规则,负责路由的匹配。每个路由匹配都被封装为JLRRouteRequest,交给JLRRouteDefinition进行匹配,JLRRouteDefinition会针对该request 输出一个匹配结果JLRRouteResponse。里面包含了是否匹配的信息。大体就是这么一个流程。
我们下面将针对上面介绍的结构一步一步进行介绍:
通过sheme分隔路由空间
+ (instancetype)routesForScheme:(NSString *)scheme { |
每个路由都要被添加到指定的scheme空间,存储在JLRGlobal_routeControllersMap中,JLRGlobal_routeControllersMap是一个字典类型,key为scheme,value为指定的JLRoutes。默认的情况下JLRoutes帮我们提供了一个全局的命名空间globalRoutes。
注册指定的路由匹配规则
- (void)addRoute:(NSString *)routePattern handler:(BOOL (^)(NSDictionary<NSString *, id> *parameters))handlerBlock { |
我们以****/path/:thing/(/a)(/b)(/c)**** 为例子,这里有固定path /path/ 带参数的path **/:thing/*,可选的path **/(/a)(/b)(/c)。首先我们会通过expandOptionalRoutePatternsForPattern** 将可选的部分转换成固定的或者带参数的path。比如上面的*/path/:thing/(/a)(/b)(/c)** 会转换为如下几种:
/path/:thing/a/b/c |
然后再通过_registerRoute注册上面的routePattern。
- (void)_registerRoute:(JLRRouteDefinition *)route { |
所有的routePattern 都会被封装到JLRRouteDefinition 然后按照优先级添加到mutableRoutes中。
路由匹配
- (BOOL)routeURL:(NSURL *)URL withParameters:(NSDictionary *)parameters { |
- (BOOL)_routeURL:(NSURL *)URL withParameters:(NSDictionary *)parameters executeRouteBlock:(BOOL)executeRouteBlock { |
如果说路由规则是一个固定的内容,那么路由匹配则是拿着一个个具体的URL去规则里面去匹配:
路由匹配有几个阶段:
|
JLRRouteRequest路由请求
- (instancetype)initWithURL:(NSURL *)URL options:(JLRRouteRequestOptions)options additionalParameters:(nullable NSDictionary *)additionalParameters { |
@interface JLRRouteRequest : NSObject |
每个JLRRouteRequest包含了URL它是JLRRouteRequest其他部分的来源,pathComponents是URL中的path部分,queryParams是URL中的query部分,options是外部注入的选项配置,additionalParameters是外部注入的额外参数。进入下一阶段匹配的时候都是将这些参数与JLRRouteDefinition中定义的模版进行匹配。
JLRRouteDefinition 路由规则定义
我们接下来看下JLRRouts中的路由规则定义部分,以及路由匹配过程:
在JLRoutes中每个路由规则被定义为一个JLRRouteDefinition对象,我们先来看下它的结构:
/// 当前route 的 URL scheme |
- (instancetype)initWithPattern:(NSString *)pattern priority:(NSUInteger)priority handlerBlock:(BOOL (^)(NSDictionary *parameters))handlerBlock { |
pattern 是当前路由的匹配规则比如/path/:things/a/b/c,而patternPathComponents则是通过‘/’对pattern字符串进行分隔后的子pathPattern。我们接下来看下最重要的路由匹配过程。
- (JLRRouteResponse *)routeResponseForRequest:(JLRRouteRequest *)request { |
routeResponseForRequest 中最关键的部分在于routeVariablesForRequest,它会在提取path中变量的过程中,一边针对path进行匹配,一边提取,一旦发现不匹配就直接返回。所以我们重点来看下routeVariablesForRequest部分:
- (NSDictionary <NSString *, NSString *> *)routeVariablesForRequest:(JLRRouteRequest *)request { |
首先我们会遍历JLRRouteDefinition中的patternPathComponents,将它的每个元素取出来,如果发现当前的patternPathComponent是以冒号开头,那么表示,当前的path是一个变量,就从request中的pathComponents对应位置取出pathComponent。再将patternPathComponen开始的冒号以及结尾如果是#的去掉作为key,将requst pathComponent 作为value添加到routeVariables。
如果当前的匹配规则patternPathComponent是通配符,并且request的pathComponents长度大于当前的index,那么将request pathComponents中剩余的值都赋给routeVariables,这时候key为通配符符号*。
为什么能够这样做,以为我们规定通配符要放在路径的最后面。
除了变量,通配符之外只剩下静态component,这种只要判断是否相等就可以了。不相等就表示直接不匹配。
所以routeVariablesForRequest其实是要完成两部分任务,一部分负责匹配,一部分负责将path中的变量提取出来。
我们再回到routeResponseForRequest,提取完path中的全部变量后会执行下面这部分代码:
NSDictionary *matchParams = [self matchParametersForRequest:request routeVariables:routeVariables]; |
它其实是将上面从path提取出来的参数以及request.additionalParameters,request.queryParams合并起来作为最终的参数,并且在这最终的参数中添加request.URL,以及self.scheme。我们知道JLRRoutes的参数可以通过path指定,通过request.queryParams,通过request.additionalParameters 这三个地方指定。这里就是将这写参数合并起来,传递下去。后续的处理block拿到这些参数进行对应的逻辑处理。
- (NSDictionary *)matchParametersForRequest:(JLRRouteRequest *)request routeVariables:(NSDictionary <NSString *, NSString *> *)routeVariables { |
有了参数之后就将参数封装到JLRRouteResponse中传递给处理block:
[JLRRouteResponse validMatchResponseWithParameters:matchParams] |
JLRRouteResponse 十分简单就两个属性,一个用于标记是否匹配,另一个是当前路由的所有请求:
@interface JLRRouteResponse : NSObject <NSCopying> |
我们前面在介绍_routeURL的时候看到最终的response是传递到JLRRouteDefinition中的callHandlerBlockWithParameters方法中:
didRoute = [route callHandlerBlockWithParameters:response.parameters]; |
而callHandlerBlockWithParameters则是调用了handlerBlock将参数传递出去,在业务层进行处理:
- (BOOL)callHandlerBlockWithParameters:(NSDictionary *)parameters { |
所以整个路由的过程是这样的:
在编程中有两种比较重要的扩展方式,一种是面向对象编程中的继承方式,一种是面向切面编程,前者是纵向扩展,而后者是横向扩展,目前大多数的流行编程语音都有这两种扩展方式,在iOS开发中使用Aspect来对现有代码进行横向扩展,Aspect用于在当前selector之前或者之后插入代码块或者使用某个代码快替换当前selector,它通过OC的消息转发机制hook消息。所以会有一些性能开销,建议不要把Aspects加到经常被使用的方法里面。
1. Aspect对外接口
Aspect 代码比较精炼,就两个文件两个接口,我们先来看下它对外的接口:
+ (id<AspectToken>)aspect_hookSelector:(SEL)selector |
Aspects是NSObject的一个扩展,所以原则上只要是NSObject的实例都可以通过上面两个方法进行hook。
第一个参数selector是当前类需要hook的selector
第二个参数options用于指定调用切片方法的时机:
typedef NS_OPTIONS(NSUInteger, AspectOptions) { |
它可以指定在原方法实现之后插入,这也是默认的方式,还可以使用block替换原来方法的实现,还可以在原方法之前插入指定的block,还可以只进行一次hook,第一次hook之后就会自动被移除。
第三个参数block是用于替换被hook selector的代码块
第四个参数error用于获取hook过程中的错误。
2. Hook流程分析
static id aspect_add(id self, SEL selector/*需要hook的SEL*/, AspectOptions options/*切片的时机*/, id block/*切片的执行方法*/, NSError **error) { |
Hook的最开始会调用aspect_add,这里会先用一个自旋锁来保证线程的安全,自旋锁是效率比较高的一种锁,相比@synchronized来说效率高得多,紧接着会调用aspect_isSelectorAllowedAndTrack来判断是否允许hook。如果允许hook那么会使用aspect__selectName作为属性名创建一个AspectsContainer类型的关联属性,这里需要注意的是每hook一个selector就会在当前对象中多出一个对应的Aspects容器,这里面存放的都是针对一个selector的切片,因为一个selecor可能会有多个不同的切片,每种类型的AspectOptions作为一个AspectIdentifier存放在AspectsContainer容器中。
最后通过aspect_prepareClassAndHookSelector对方法进行Hook.在介绍aspect_prepareClassAndHookSelector之前我们看下上面遗留的一些问题:
判断是否允许Hook
static BOOL aspect_isSelectorAllowedAndTrack(NSObject *self, SEL selector, AspectOptions options, NSError **error) { |
aspect_isSelectorAllowedAndTrack方法中主要做两件事情:
首先会检查当前需要hook的selector是否在黑名单中,Aspect里面不允许hook “retain”, “release”, “autorelease”, “forwardInvocation:”4种方法,而且hook “dealloc”方法的时机必须是before,并且selector要能被找到,并且在对象继承层级上没有被hook过,如果这些条件都满足才允许Hook。
然后重建Tracker的层级关系,每个AspectTracker包含被跟踪的类以及这个类被hook替换的selector名称,以及它子类的tracker。
aspect_getContainerForObject 的逻辑
static AspectsContainer *aspect_getContainerForObject(NSObject *self, SEL selector) { |
aspect_getContainerForObject 方法中会在当前对象中为每个hook的selector添加一个关联属性,属性名称为aspect__selectorName 类型为AspectsContainer,AspectsContainer里面只存储一个selector相关的所有hook信息。
AspectsContainer 和 AspectIdentifier的结构
上面介绍过了AspectsContainer 代表的是一个selector 所包含的全部hook方法,包含在原有selector之前,之后,替换原有selector的block。
所以AspectsContainer 包含三个数组 beforeAspects,insteadAspects,afterAspects:
@interface AspectsContainer : NSObject |
在添加的时候会根据插入的位置分别存储到不同的数组:
- (void)addAspect:(AspectIdentifier *)aspect withOptions:(AspectOptions)options { |
AspectIdentifier 代表一个Aspect的切片的具体内容。里面包含了单个的 Aspect 的具体信息,包括执行时机,要执行 block 所需要用到的具体信息:包括方法签名、参数等等。
@interface AspectIdentifier : NSObject |
我们看下它的初始化方法:
+ (instancetype)identifierWithSelector:(SEL)selector object:(id)object options:(AspectOptions)options block:(id)block error:(NSError **)error { |
aspect_blockMethodSignature 用于获取block的签名信息,大家可以看下下面的注释,关于如何从Block中获取签名可以查看Aspects框架中Block的使用这篇博客。
|
拿到Block签名后就可以通过aspect_isCompatibleBlockSignature来比较block的签名和要hook的selector方法的签名是否兼容:
static BOOL aspect_isCompatibleBlockSignature(NSMethodSignature *blockSignature, id object, SEL selector, NSError **error) { |
aspect_isCompatibleBlockSignature首先会判断block和selector的参数个数是否一致,然后检查block的第一个参数是否是_cmd,如果这两个条件都满足后才会继续从第二个参数开始比较block和selector之间的参数是否匹配。
invokeWithInfo 是调度切片block的方法,这个留在后面介绍。
到目前位置我们做了如下工作:在hook一个selector的时候会先判断当前selector是否可以被hook,如果可以则进行下一步:
紧接着我们会使用关联属性来为当前hook方法添加一个AspectsContainer,AspectsContainer当中有三个关键数组,分别用于存放option类型为AspectPositionAfter,AspectPositionInstead,AspectPositionBefore 的 AspectIdentifier。每个AspectIdentifier都包含一个切片的全部信息,这里最关键的是在初始化的时候提取的block签名信息,并且在这里会使用block签名信息和selector签名信息进行兼容性检查。AspectIdentifier内部还包含触发对应block的方法。
接下来我们继续看下关键的Hook部分:
static void aspect_prepareClassAndHookSelector(NSObject *self, SEL selector, NSError **error) { |
aspect_prepareClassAndHookSelector方法中会先创建一个以_Aspects_为结尾的class作为当前的class的子类,如果当前方法不是消息分发方法_objc_msgForward或者_objc_msgForward_stret,就进行方法替换。比如我们要调用classA 的testA方法,并且testA已经被hook了,那么这时候我们会创建一个classA_Aspects_类作为classA的子类。在调用classA 的testA方法的时候会先看下classA_Aspects_是否有aspects_testA方法,如果没有则添加一个方法aspects_testA指向原来的testA,并且将classA_Aspects_的selector方法替换为aspect_getMsgForwardIMP(self, selector)走消息分发途径。消息具体怎么分发的我们看下aspect_hookClass:
static Class aspect_hookClass(NSObject *self, NSError **error) { |
aspect_hookClass 方法中会通过objc_allocateClassPair来创建一个“类名+_Aspects_”结尾的新类作为baseClass的子类。并且通过aspect_swizzleForwardInvocation将当前类forwardInvocation方法的实现为__ASPECTS_ARE_BEING_CALLED__。然后将当前实例对象的class替换为添加AspectsSubclassSuffix的类,所有的 swizzling 操作都发生在子类,这样做的好处是你不需要去更改对象本身的类,也就是,当你在remove aspects 的时候,如果发现当前对象的 aspect 都被移除了,那么我们可以将 isa 指针重新指回对象本身的类,从而消除了该对象的 swizzling ,同时也不会影响到其他该类的不同对象,这样对原来替换的类或者对象没有任何影响而且可以在子类基础上新增或者删除aspect。
我们看下消息转发方法的替换:
static NSString *const AspectsForwardInvocationSelectorName = @"__aspects_forwardInvocation:"; |
如果klass没有实现forwardInvocation就会添加forwardInvocation方法并指向__ASPECTS_ARE_BEING_CALLED__实现,这种情况originalImplementation返回的是nil,所以不会走下面的流程,也就是说如果原来方法没有forwardInvocation,则会添加forwardInvocation方法,如果有的话将会替换forwardInvocation的实现为__ASPECTS_ARE_BEING_CALLED__,并新建一个方法__aspects_forwardInvocation指向原先已经实现的forwardInvocation,也就是说经过aspect_swizzleForwardInvocation处理后,klass中一定有一个forwardInvocation方法指向__ASPECTS_ARE_BEING_CALLED__,如果原来有的情况下还会多出一个__aspects_forwardInvocation指向原来的forwardInvocation。
我们再来看下****ASPECTS_ARE_BEING_CALLED****
static void __ASPECTS_ARE_BEING_CALLED__(__unsafe_unretained NSObject *self, SEL selector, NSInvocation *invocation) { |
首先进入这里的时候会通过关联属性来获取aspect_selector对应的属性值,如果有被hook的话则会获取到存放hook切片信息的切片容器AspectsContainer。我们使用invocation信息创建AspectInfo,
然后调用aspect_invoke宏来执行AspectsContainer中的beforeAspects,insteadAspects,以及afterAspects,如果insteadAspects为空的话,则直接执行子类的aliasSelector。我们之前已经提到过了,在aspect_prepareClassAndHookSelector方法中会在子类添加一个aspect_selector的方法它指向selector实现,所以这里会执行原来方法中的selector。如果respondsToAlias = NO 表示instancesRespondToSelector返回NO,表示调用了一个没有实现的方法,所以走原来类的消息转发机制。
我们最后再来看下aspect_invoke这个宏:
#define aspect_invoke(aspects, info) \ |
这里会遍历aspects中的AspectIdentifier,并调用invokeWithInfo执行对应的block。如果切片类型为AspectOptionAutomaticRemoval,则触发后将它移除。
- (BOOL)invokeWithInfo:(id<AspectInfo>)info { |
invokeWithInfo 方法中主要将原先selector的方法参数,提取出来,传到block上,再通过invokeWithTarget执行block。
最后我们来做个总结:
在我们hook某个方法的时候会在当前类上添加一个名字为aspect_selector的关联属性,这个关联属性类型为AspectContainer它是用于存放该selector相关的切片的容器,容器里面有三个数组,分别存放着
不同AspectOptions的AspectIdentifier,每个AspectIdentifier都包含着一个切片的具体信息。紧接着会创建一个以_Aspects_结尾的类作为当前类的子类,这里类里面会添加一个方法名称为aspect_selector的方法,方法的实现指向原来的selector实现。并且会为该子类添加一个指向__ASPECTS_ARE_BEING_CALLED__的forwardInvocation方法。如果原来的类已经实现了forwardInvocation,就会添加一个__aspects_forwardInvocation方法指向原先已经实现的forwardInvocation。这个方法会在Aspect消息转发机制走不通的情况下走这里的消息转发。
这以后再将子类的selector方法调用替换为Aspect消息转发,这样我们调用这个selector的时候就会通过Aspect 消息转发进入__ASPECTS_ARE_BEING_CALLED__,在__ASPECTS_ARE_BEING_CALLED__中会先尝试取出之前的关联属性的切片容器,执行容器内的切片block,在这会如果instead切片为空的时候会调用aspect_selector,由于上面已经将aspect_selector指向原来的selector实现,所以这里会有机会调用原来类的selector方法。如果原来类不能响应selector方法,那么就走原来类的forwardInvocation,进行消息转发。
重要的类及关系介绍:
****Hook流程图:****:
JSPatch是iOS平台上的热修复方案,至于什么是热修复以及使用JSPatch是否会被苹果官方拒绝上架,这里不做介绍,这篇博客只关注JSPatch 源码本身。带大家过下JSPatch的源码。
用例情景
JSPatch核心代码很精简但是这不意味着简单,还是需要仔细啃才能够理解它的思想。
我们先看下JSPatchDemo中的用法例子:
[JPEngine startEngine]; |
在例子中首先启动JPEngine,紧接着会去加载本地的demo.js。然后在JPEngine中执行本地的demo.js脚本。
我们看下原先的RootViewController JPViewController:
|
整个界面上有一个按钮,点击它会执行handleBtn方法,但是handleBtn目前是一个空方法,但是如果你将整个demo项目跑起来后会发现点击的时候会弹出一个TableView。但是如果注释掉之前将的JPEngine相关代码,点击就不会有任何响应,所以可以确定是JPEngine搞的鬼。我们看下demo.js:
defineClass('JPViewController', { |
即使你不懂JavaScript 看到上面的代码估计也可以猜出个大概,它重写了handleBtn方法,在点击的时候会push一个JPTableViewController页面。并且使用JavaScript语言实现了JPTableViewController。也就是说它可以通过动态加载一个js文件,并且在这个js文件中改变现有代码的行为。既然本地的js文件也是需要转换为string后放到JSPatchEngine引擎中执行,如果将这个文件放到服务端下发下去,那么就可以通过后台动态控制我们应用的行为了,是不是很诱人的功能,当然我们不会将它用于实现大需求,一般如果用于修修线上的一些紧急bug还是比较方便的,特别是苹果平台审核周期有时候会比较长。这种情况如果遇到线上的一些崩溃没有热修复,只能和苹果官方沟通来缩短审核的时间,紧急发布修复版本。
JSPatch 引擎初始化
+ (void)startEngine{ |
由于JSPatch是基于JavaScriptCore的所以在进行JSPatch 引擎初始化的时候会先检查下当前系统中是否支持JavaScriptCore。iOS 7.0 以下不支持 JavaScriptCore 所以在iOS 7.0 以下调用startEngine不会继续执行,直接返回。否则会创建一个JSContext并开始往JSContext里面注册对应的block,这些block会在JSPatch.js 中被调用。然后会注册JSContext 执行出现异常时的回调.最后会加载 JSPatch.js 中的所有 js 代码到JSContext 运行。
在JSPatch.js中会向gloable环境下添加defineClass,require,defineProtocol,block,defineJSClass 这些方法,这些方法可以供我们在热修复js文件中调用。
我们再回到demo.js:
defineClass('JPTableViewController : UITableViewController <UIAlertViewDelegate>', ['data'], { |
上面的defineClass有三个参数,第一个参数是类定义的字符串,第二个[‘data’]代表的是一个类的属性列表,这里只有一个属性参数data。第三个参数是这个类的实例方法列表。
global.defineClass = function(declaration/*定义字符串*/, properties/*属性*/, instMethods/*实例方法*/, clsMethods/*类方法*/) { |
JSPatch.js 的defineClass方法中会调用 JSEngine startEngine方法中向JSContext注入的_OC_defineClass block。传入的是这个类的定义,以及实例方法,类方法。其中实例方法和类方法的结构如下:
key:方法名 |
我们先来看下_OC_defineClass再回过来看JSPatch.js
context[@"_OC_defineClass"] = ^(NSString *classDeclaration, JSValue *instanceMethods, JSValue *classMethods) { |
static NSDictionary *defineClass(NSString *classDeclaration, JSValue *instanceMethods, JSValue *classMethods) { |
我们以demo.js为例子,defineClass这里传入的classDeclaration为 JPTableViewController : UITableViewController
第二个参数instanceMethods和第三个参数classMethods分别为包含属性Setter/Getter在内的实例方法和类方法。上面提到了这两个参数在js方法中已经将它组合成
key:方法名 |
这种形式,我们以
tableView_numberOfRowsInSection: function(tableView, section) { |
为例子: key为 tableView_numberOfRowsInSection value为[2,tableView:numberOfRowsInSection:的实现] 我们知道OC中要构建一个selector需要知道这个方法是实例方法还是类方法,方法名,IMP
类名,IMP 都可以直接拿到,我们还缺一个关键的信息,就是selectorName. 我们从js带过来带的key为tableView_numberOfRowsInSection,它其实包含了selectorName所需要的必要信息,只不过它是以”“分隔,所以将将”“替换为”:”
然后检查通过”:”分隔的字符串数和传入的参数个数这个进行进行比较,如果少了则需要在最后加个”:”就可以拿到我们需要的selectorName了。
我们接下来看下热修复最关键的方法overrideMethod:
方法替换
/** |
如果大家看过Aspect源码这部分会比较好理解,两者在这部分思路是类似的,首先会将当前类的forwardInvocaiton替换为JSPatch实现的JPForwardInvocation:如果原来的类已经有实现了forwardInvocaiton那么就会将forwardInvocaiton替换成ORIGforwardInvocation
当前方法的selectorName 替换为_JPselectorName,如果原来有这个方法了,这里是在覆盖原有的方法,那么会将原来的方法selectorName改为ORIGselectorName。最后将selector方法的实现替换为_objc_msgForward 或 _objc_msgForward_stret 从而在代码中调用这个方法的时候,会触发消息下发。
走forwardInvocaiton,也就是会走到JSPatch实现的JPForwardInvocation:来重新控制方法调用的规则。
static void JPForwardInvocation(__unsafe_unretained id assignSlf, SEL selector, NSInvocation *invocation) { |
JPForwardInvocation 会先从 NSInvocation中取出selector 名称,现在selector名称之前加上_JP,然后看下当前对象是否有对应的方法,我们在overideMethod方法中了解到,如果我们热修复js脚本中有对应的方法,那么就会在该对象中多出一个_JP开头的方法,指向热修复补丁中的实现。
所以这里会先看下当前方法中是否有对应的补丁方法,如果没有就调用JPExecuteORIGForwardInvocation走原来的消息分发机制。
static void JPExecuteORIGForwardInvocation(id slf, SEL selector, NSInvocation *invocation) { |
如果有的话,会把NSInvocation中的参数提取出来,传递给对应的补丁方法。然后通过JP_FWD_RET_CALL_JS调用js方法。通过JP_FWD_RET_CALL_JS中的
jsval = [cb callWithArguments:args] |
获取到返回值后设置到NSInvocation中,完成方法的替换。
比如我们上面介绍的例子中有一个handleBtn. 原来的方法实现是空实现,后面通过JSPatch的demo.js 覆盖了这个方法,我们梳理下这是怎么实现的:
由于我们原有对象已经有了这个方法,只不过这个方法是空的,所以在overideMethod方法中会将原来的方法的selector名称改为_ORIGHandleBtn,指向原来的实现:
- (void)handleBtn:(id)sender { |
而会在该类中添加一个_JPhandleBtn,它的实现指向补丁js中的实现。
并且由于该对象没有实现forwardInvocaiton 所以会为该对象新增加一个JPForwardInvocation,并且将原来的对handleBtn的调用转换为_objc_msgForward。这一切工作完成后我们来看下整个调用过程:
我们点击按钮后会触发handleBtn,这时候由于handleBtn的实现被替换为_objc_msgForward所以会走消息分发途径,进而走到forwardInvocaiton,由于forwardInvocaiton的实现在上面被替换为JPForwardInvocation
在JPForwardInvocation中会先尝试将消息转发到_JPhandleBtn看下是否有这个实现,这里由于有这个实现所以就直接将invocation参数提取出来,传递给对应的js方法执行。这就达到了整个方法替换的目的。
如何在JS环境下执行OC方法
我们上面了解到了怎么通过消息转发来达到热修复的目的,接下来我们还有一个问题就是如何在JS中执行OC方法,我们看下_evaluateScript,它的作用是将补丁js方法加载到JSContext中执行。我们还是以demo.js为例子
+ (JSValue *)_evaluateScript:(NSString *)script withSourceURL:(NSURL *)resourceURL { |
demo.js加载后会先通过正则匹配处理后再送到JSContext中,我们看下匹配替换后的formatedScript是什么样的:
;(function(){try{ |
大家可以发现所有的xxx.XXXX 都被替换成了xxx.__c(“XXXX),为什么需要这样转换呢?我们到JSPatch.js寻找这个答案:
__c: function(methodName) { |
__c 里面会调用_methodFunc,_methodFunc方法里面会根据当前方法是实例方法还是类方法决定调用_OC_callI还是_OC_callC。最后将oc中的返回值转换为JS对象返回。
_OC_callI还是_OC_callC是在我们初始化JSPatch引擎的时候注入到JS中的。在js中调用_OC_callI或者_OC_callC会触发调用OC层的callSelector方法,通过它将js中的补丁方法中oc相关的方法交给oc层。
/** |
callSelector代码很长但是它所处理的任务比较比较明确,它主要是通过js传过来的参数来构建一个Invocation 通过[invocation invoke]来执行对应的方法。代码细节部分已经给出了较为详细的注解大家可以看下整个流程。
我们这里回顾下整个过程;
在启动JSPatch 引擎之后会向Context注入一系列的OC方法给JS回调。
紧接着加载JSPatch.js 它里面主要定义了一些关键方法,最主要的是__c,它会从中提取到OC消息转发所必须的一系列参数,通过_OC_callI或者_OC_callC 传给 OC 层, OC 层中调用callSelector 方法,通过js传过来的参数来构建一个Invocation 通过[invocation invoke]来执行对应的方法。
而在OC层中加载补丁js脚本后会将脚本中的xxx.XXXX 正则替换为xxx.__c(“XXXX”),这样通过上面的解释后调用OC层方法,如果忽略掉这部分的整个细节,整个过程是js 补丁脚本中的 xxx.XXXX 最后会转为 OC中的[invocation invoke]
是不是有点巧妙?
总结
这篇博客主要从如何通过消息转发机制来使用js补丁方法/类来替换现有的方法,从而达到热修复的目的。以及如何将js中OC相关的代码交给OC层来处理。第一部分思路有点和Aspect类似,大家可以细细体会下,很巧妙的一个方案。下面是JSPatch的一个简单的框图。
1:RACSigner基础知识点
|
常见的操作方法:
|
2:RACSubject基础知识点
RACSubject:信号提供者,自己可以充当信号,又能发送信号 使用场景:通常用来代替代理,有了它,就不必要定义代理了 |
3:RACSequence基础知识点
|
4:RACCommand基础知识点
|
5:RACMulticastConnection基础知识点
|
6:RAC结合UI一般事件
|
7:高阶操作知识内容
8:RAC并发编程知识点
|
9:冷信号跟热信号知识点
|
10:RACDisposable知识点
|
11:RACChannel知识点
RACChannelTerminal *channelA = RACChannelTo(self, valueA); |
12:RAC倒计时小实例
//倒计时的效果 |
13:常见的宏定义运用
|
MVVM模式和MVC模式一样,主要目的是分离视图(View)和模型(Model),有几大优点
低耦合。视图(View)可以独立于Model变化和修改,一个ViewModel可以绑定到不同的”View”上,当View变化的时候Model可以不变,当Model变化的时候View也可以不变。
可重用性。你可以把一些视图逻辑放在一个ViewModel里面,让很多view重用这段视图逻辑。
独立开发。开发人员可以专注于业务逻辑和数据的开发(ViewModel),设计人员可以专注于页面设计。
可测试。界面素来是比较难于测试的,而现在测试可以针对ViewModel来写。
单元测试这边主要采用两种方式,一种是XCode自动的XCTestCase进行,如下面这些就是它所对应的断言等,另外一种是采有KIWI的插件进行测试;项目中有针对viewController、viewModel、帮助类等的测试实例;运用快捷键(command+U)可以运行单元测试实例;
|
采用KiWi的单元测试效果:
|
关于kiwi中的操作类型可以直接查看:https://github.com/allending/Kiwi/wiki/Expectations
注意:发现在进行单元测试时,针对RAC就会报[RACStream(Operations) reduceEach:]_block_invoke,后来发现是Pod引入写法有问题,导致的【it usually means RAC is being linked twice. Make sure it’s only in your app target.】 所以测试的MobileProjectTests特别要注意;
|
|
今天给大家讲的主题是iOS组件化,提起组件化让我想起了在读研的时候用的一款图形化编程语言NI LabVIEW,它是由美国国家仪器公司开发的,它编写的程序是由一个个图形模块堆叠起来的,我们可以很轻松地将几个模块通过数据流连接起来构成一个比较复杂的程序,当然它还有和硬件通信控制硬件的功能,但这不是我们的重点。从整体角度来看,它编写的程序主要由下面几部分构成:
模块 -- 提供某个功能服务 |
我们在组件化某个项目的时候也就是也就是在搭建类似NI LabVIEW平台的过程,在这个过程中我们需要考虑如何划分和封装模块,模块间数据怎么交互,如何注册/查找对应服务的模块。
开发人员在组件化的项目上编写代码的时候只需要提供输入数据,然后调用合适的组件,将数据送入组件处理,组件处理完后将处理后的数据按照约定的交付形式输出,然后再流入下个组件,在项目中能够通过便捷的方式路由到任何一个组件,并且组件间没有任何耦合,拆开组件在不修改任何代码的情况下在另一个项目中复用组件。
那么大家思考过没:什么是组件化? 为啥要在项目上使用组件化技术?
在我看来组件化指的是将项目代码进行分层,解耦,并制定模块间通信的方式,从而实现解耦,复用。不可否认有些项目的组件化是KPI产物,但是很大程度来说组件化都是十分必要的,为啥?它有啥好处?
首先组件化带来的最大好处就是项目成果积累,一般一个公司都会不只一个项目,如果采用组件化将可共享的模块从各个项目中抽离出来,形成一个组件,那么其他项目需要这个功能的时候就可以直接复用,而不用重复开发,并且这些组件在多个项目的维护下会越来越健壮,这种积累从长远角度看对于一个公司来说是一个十分可观的成果。
其次从宏观角度上看,每个组件都相当于一个黑盒,我们只关心我们注入哪些数据,组件吐出哪些数据,里面的细节对于组件使用者来说不用太多关照,这样就会使得整个项目的结构更加清晰。同时这种黑盒结构很容易通过测试用例对组件进行测试。
在多人协作的团队可以将整个项目分割成多个模块分配给开发成员。
由于组件是可以单独编译的,开发测试过程中,可以只编译自己那部分代码,不需要编译整个项目代码,从这个角度出发,组件化可以很大程度上加快整个项目的编译速度。从测试角度可以在开发过程中先提交组件测试,测试通过后再进行组件集成测试,从开发流程角度可以更好得进行独立并行开发,各个组件的负责人在功能尚未开发完之前可以按照约定的组件接口结构,Mock假数据给调用者,这样我们在开发组件的同时,依赖方可以同时开发他们的组件,等到我们的组件开发完毕后使用真实数据替换Mock假数据就可以很轻松得将两个组件对接起来。
不同组件的开发人员可以选择自己喜欢的架构模式 (当然这里不推荐这样做,一个项目尽量保持架构一致)
可以针对单个组件或者模块进行编译测试,更好定位问题,我们可以通过对组件间接口进行测试,如果发现异常就可以针对组件进行排查。
组件化架构在各个模块之间天然形成了一道屏障,避免某个开发人员偷懒直接引用头文件,产生组件间的耦合,破坏整体架构,同时,因为每个人都负责自己的组件,代码提交也只提交自己负责模块的仓库,所以代码冲突的问题会变得很少,还有好处就是假设以后某个业务发生大的改变,需要对相关代码进行重构,可以在单个组件内进行重构,只要保证对外接口准确无误就可以了,组件化架构降低了重构的风险,保证了代码的健壮性。
但是组件化又有那些缺点呢?
其实倒不是组件化有啥缺点,组件化的唯一缺点就是会给项目带来复杂性,比如我们在非组件化项目中从一个对象拿到另一个对象是十分容易的,但是组件化项目中有可能这两个对象是跨组件的,这种情况下可能就需要绕个大弯去获取,这对于项目初期进度很赶的时候是十分恼火的一个问题,并且某个项目刚刚成立之前,往往对于项目的整体形态都没形成一个很系统的认知,这种情况下对项目进行组件化规划是比较困难的,因为如果组件划分不合理调整起来是很困难的一件事情,这对需求的把握能力以及前瞻能力都有很高的要求,这就导致一般项目初期很少考虑组件化,到了项目第一个demo出来,并且人员到齐的情况下才会将组件化纳入到项目日程。
组件简单说就是如何组织组件,如何添加组件,如何发现组件,如何交付数据,如何传递动作,把这些理清楚了组件框架就可以理出个头绪了,往详细得说组件化需要考虑如下几点:
|
在用组件化视角审视项目结构之前我们看下一般一个应用的通用部分有哪些,下面是我自己设计的一个框架的结构图,该框架是我的练手项目,目前完成大部分的基础工作,还有一部分正在开发中,后续整个完善后开源给大家,大家一起完善,一起学习,目前暂定名字为IDLFundation,之前有打算叫GKFundation(Geek Fundation) 但是后面觉得俗气,所以想了个更俗气的名字IDLFundation(idealist Fundation 理想主义者)
数据层主要包括两大部分:DataSource以及Data Processor。DataSource 又可以分成Local DataSource 以及Remote DataSource
* 数据库:一般用于存储结构性数据 |
* Http/Https: 这个是大多数的远程数据来源 |
在拿到数据后或者在存储数据之前一般会对数据做相应处理后交付给使用方:
常用的数据处理包括:
* 数据加解密: 一般在存储或者传输敏感数据的时候需要对数据进行一次加密,但是加密有时候会比较耗时,所以对于非关键数据一般不用加密传输。 |
在MVVM架构中和数据层关系最密切的就是Model层了,Model中一般都是一些获取数据的方法,在获取完数据后经过处理后放在Model层待使用方取用,Model层状态发生变化View层一般也会同步变化,比如开始获取数据的时候一般会呈现一个加载动画,数据拿到后会呈现一个用于显示数据的界面,获取数据失败会有错误信息界面,这里我们用一个ViewAction来向View层同步状态
视图层在各个项目中往往差异比较大,但是还是有可以提炼出的共同点:
* UI Components: 大多数项目中会用到列表,闪屏,Banner,按钮,圆角图片,弹窗,面板,Toast的界面相关的控件,可以将这些控件的公共部分给抽象出来,使用的时候通过配置就可以应用在不同项目中。 |
视图层的工作比较简单,IDLFundation 中会管理三种状态的视图分别是Loading View,Content View, Empty/Exception View,这三者的作用从名称上都可以看出来是干什么用的,它受ViewAction信号控制
如果说数据层是生产数据的,那么视图层的作用就是生产交互事件,在IDLFunation中会将事件通过RACCommand暴露出去,可以在ViewController中对其进行相关工作的绑定。
视图还有一个功能就是布局它的子控件。嗯,视图层大致就这么简单。
在IDLFundation中 ViewController层相当于MVVM的ViewModel层,它负责绑定View 和 Model层,同时处理View层生成的动作,它会监听一系列事件,比如主题更换,语言更换,网络状态切换,ViewController生命周期变化,通过感知这些事件触发对应的逻辑处理。
介绍完IDLFundation最核心的MVVM框架后,仅次于它的一个部分就是APM && Quality Center 模块,在这里包含了一系列性能检测,以及异常收集,Logger上报,埋点数据上报等工具,这个模块用于对整个应用进行检测。所以个人将这个模块也归入核心框架层。
应用配置层会以两种形式对应用进行配置,一种是本地项目配置,一种是云指令控制,后者可以根据服务端下发的配置文件对应用的功能进行配置。
这个一般同于存储一些全局的应用变量,比如登录后的登录态等,多个地方都需要使用,并且方便取用。
这里指的是路由的框架层,一般一个路由可以分成两部分一部分是强应用相关的部分,包括应用定义的路由协议,以及对某个协议的处理。
另一部分是通用的部分,包括路由的分发匹配,路由策略等,这部分是可以抽出来对多个项目共用的。
说到Hot Fix 大家一定会联想到JSPatch,但是其实Hot Fix 除了JSPath外还可以结合动态下发路由表来修复线上问题,这个后续有时间会向大家介绍下。
目前一般项目都会使用到ABTest据说这种开发方式在字节跳动会比较常用,也就是说同一个功能客户端会同时开发几套效果,通过对用户进行不同的分组,对不同的用户使用不同的展示方式,ABTest比较强调公平性,不然很难说服产品们他们的策略谁优谁劣,
但是一般小型项目一个功能往往只有一个产品负责,这种ABTest往往比较不那么严格,只要随机分组就好。对于字节跳动那种模式,分组的公平性就很重要,不然产品们吵着吵着就会将矛头指向ABTest开发工程师。哈哈,毕竟KPI相关。
我把这个成为应用的动态化模块,主要包括基于JavaScripCore的 JS桥,以及React Native.这些是可以动态变化的,动态指的是同一个地址里面的内容可以随时变化而不用通过发布版本的方式。
我们很经常在玩笑啥时候能给自动化需求,哈哈,估计等不到那一天吧,不过我们确实可以将开发中很大一部分工作通过自动化来完成,比如埋点key,参数,颜色表,字符表,资源索引等。这些脚本很容易提高我们的开发效率,并且不容易出错,个人感觉很值得。
这部分就相对比较大的模块,一般我们会将这些常用模块公共部分抽象出来,通过在业务层配置,达到复用的目的。
在我们介绍完一个项目的大致结构后我们重新回到组件化这个问题,在组件化这个问题上没有固定的模式,需要依照项目的实际情况来确定,但是在设计组件化框架的时候有一些问题是必须要思考的:
* 如何划分组件/组件如何解耦 |
关于组件划分我们需要思考如下几个问题:
* 划分组件需要遵循那些规则 |
一般我们在项目中会把项目划分成:业务模块,功能组件以及基础组件。
业务模块一般是一个具体的业务比如酒店业务模块,机票订阅业务模块,每个模块都是较大的一个需求,每一个模块都是由多个功能组件组合而成。通过调用不同的功能组件来获得相应的支持,数据从一个功能组件流向另一个功能组件,每个功能组件提供对应的服务,最终展现给用户。一般业务模块粒度比较粗,但是数量不宜太多。这一层往往变动比较频繁。一般会有单独的人来维护。
功能组件指的是从业务角度来看是一个相对独立的不可再分的部件,每个功能组件都能够提供某项独立的服务,比如搜索组件,比如用户信息管理组件。一般我们在开发过程中会不断从业务模块中去提取,一旦有较为稳定的可以归并为一个功能的逻辑出现的时候,我们就会将这部分逻辑封装后下沉到功能组件。
基础组件一般指的是和业务无关的,可以在多个项目中复用的,比如应用主题管理组件。网络组件等等。这个层的组件强调的是多项目的可复用性,所以对稳定性要求一般是最高的。一般考虑到它的可复用性在划分粒度上都会分得比较细。功能也会比较单一,基础层组件相互之间不应该产生任何依赖。
对于图片资源,可以单独剥出一个组件,这个组件中只存放图片文件不包含任何代码,这些图片可以通过Bundle,image Assets进行管理,并按不同业务模块建立不同的Bundle或者image Assets。
组件拆分原则:
对于组件的拆分,一般需要依据各个组件的特点,首先总体上应该去中心化,形成层级从上而下单向依赖,也就是说业务模块依赖功能组件,功能组件依赖基础组件。在发现有多个组件可以共用的逻辑或者数据模型的情况下,要考虑将这些公用的部分下沉,这种层级结构还有一种特点就是:越是上层的组件,越贴近业务,也就越不稳定,因此我们在划分的时候一般业务模块粒度会相对粗一些,功能组件划分粒度一般会细一点,而基础组件一般会考虑它的稳定性,只有十分必要的情况下才会考虑把它纳入到基础组件。对于项目中存在的公共资源和代码,应该将其下沉到下层。
总结:
在划分的过程中遵循: |
在组件化过程中,主要有两部分会产生耦合:一个是组件间调用,一个是组件间数据交付形式,这部分需要思考如下几个问题:
* 组件间通信选用何种形式才能降低耦合 |
关于组件解耦推荐大家阅读下bang的iOS 组件化方案探索,这篇博客很清晰地介绍了组件化组件间依赖的解耦过程。
组件间通信以及组件间数据交付形式是产生组件耦合的最根源原因。组件间通信一般有URL形式,class-protocals 形式,target-action形式。我们来看下理想的依赖模式是怎样的:
理想的依赖关系应该如上图所示,调用方依赖Mediator及对应模块的Mediator分类,但是被调用组件中不知道Mediator以及调用方的存在(只让其他模块对中间层产生耦合关系,中间层不对其他模块发生耦合),假定我们要移除被调用方直接移除就好,Mediator在没有找到被调用者的时候只是不响应而已不会对其他部分有任何影响,而被调用者由于不感知Mediator所以从项目中提取出来也不会对它有任何影响。对于调用者要从项目中移出只需要调整与Mediator相关的代码即可,甚至可以带着Mediator移出。到新环境中的时候只要对接Mediator接口即可。具体的实现方式可以看casatwy的iOS应用架构谈 组件化方案.在这种模式下,可以通过Mediator解决组件间头文件直接引用、依赖混乱的问题。需要注意的是Mediator应该只负责挂接节点的通信任务,不应该包含涉及具体业务的逻辑。也就是说Mediator中只应该负责找到响应方,将参数打包抛给响应方即可,至于具体的逻辑由调用方和响应方做具体的处理。
下面是CTMediator的大致结构:
还有一处可能会带来耦合的地方是组件间的数据交付形式,如果不用组件化的话我们一般在两个对象数据产生交付的时候一般会以一个具体的Model形式交付,但是应用组件化后这些Model存放在哪里就是个问题,模型类放在调用方和被调方的哪个组件都不太合适,因为以具体Model形式交付的化双方就都会依赖这个Model,这就导致必须将这个Model下沉。但是同一个Model可能是多个模块之间交付的数据形式,这就导致需要将所有的Model放置到一个单独的下层组件中去。也就是说每个层都会有一个Model层供上层使用,在这种情况下抽出某个组件都会有藕断丝连痛不欲生的感觉。可能大家会退而求其次在用到这个模型对象的所有组件中,都分别维护一份相同的模型类,或者各自维护不同结构的模型类,但是这样业务发生改变模型类就会很麻烦,这是不可取的。所以比较合理的方式是去Model化,以Dictionary形式进行交互,参数Dictionary在Mediator中封装。这样所有的变化都集中在Mediator中。这里需要注意的是组件内部还是可以使用Model的。
总结:
在解耦方面,主要有两部分会产生耦合: |
路由是组件化中很重要的一部分,我们有时候会自嘲我们日常做的工作就是从一个界面点击后跳转到另一界面拉取数据后,再次点击后再重复上面的工作,这个侧面反应了路由跳转再整个应用中的重要程度,但是我们再从比较宽泛的角度来看,其实通过路由不一定只是解决在页面之间的跳转问题,它其实是一个资源的索引,通过它不但可以跳转页面,还可以获得某些资源数据。
一个组件路由设计得好还是不好往往会有一定地标准去评判,下面列出个人认为的一些标准:
准确高效
路由最基本的要求就是准确高效,如果这一点都不能保证那么这个路由一般不会在项目中采用。
灵活跳转
在设计路由接口的时候一般需要思考如下几个点:
如何对外提供远程访问的功能:
这些接口包括:Web端访问的接口,其他App调用接口。
如何在应用内部提供应用内模块(组件)间的页面之间访问的功能。
如何统一同一个产品下不同端,不同页面形式(RN,Weex,H5,Native页面)之间的路由方式。
如何通过路由访问资源
如何通过路由进行模块调度,组件加载
动态性
一般我们在设计路由的时候都会有个路由映射表,可能你会说target-action方式没有这种路由映射表,但实际上它的target以及action selector就是它的路由映射表,有了路由映射表的概念,我们就可以通过动态下发对应的路由映射表来实现路由的动态化,在跳转之前查询下如果有在路由映射表里面那么就按照表里面的进行跳转,如果不在那么就按照原样跳转,通过这种机制在跳转前进行拦截,替换,这有什么好处呢?比如我们线上某个页面有奔溃异常那么可以将这个界面的路由指定到一个统一的异常页面页面,或者用某个特定的H5页面作为降级页面,这样就可以达到简单的热修复的目的。
在这里需要补充一点:有了路由后我们可以在路由上做很多工作,除了上面提到了热修复策略,页面黑白名单策略,还可以在路由上进行统一跳转埋点,鉴权,配合灰度进行风控逻辑。
跨应用时,需要注意注入攻击,做到敏感参数加密防篡改,同时需要注意路由层应提供能够实现风控的机制。
跨业务系统的时候,访问敏感页面或资源的时候需要通过Token等认证信息来实现路由层的身份认证。
下面是目前开源的一些比较好的路由开源代码,大家在设计过程中可以参照这些项目进行设计符合自己项目的路由器:
JLRoutes 5029 star
CTMediator 2638 star
MGJRouter 1946 star
routable-ios 1773 star
DeepLinkKit 3236 star
ABRouter 136 star
HHRouter 1574 star
FFRouter 157 star
STCRouter 19 star
上面的路由大体可以归为三类:URL形式,class-protocals 形式,target-action形式。个人比较偏向于target-action形式,关于target-action形式的组件化介绍可以看下iOS应用架构谈 组件化方案以及iOS 组件化方案探索。URL形式,class-protocals 形式大家可以看下蘑菇街 App 的组件化之路 蘑菇街 App 的组件化之路·续
下面我们分别简单对比下三种路由方式:
这种方式是借鉴前端的路由思想,它将应用内任何资源与一个URL对应在一起。它最大的优点就是可以统一三端路由形式。具有天然的动态性,适合经常开展运营活动的应用,它的缺点也是比较致命的:这种方式不能在模块间传递非常规数据。所以一般会在多端路由的情况下或者暴露给外部应用调用的情况下使用这种形式。
这种方式的优点是没有硬编码,但是它的缺点也比较明显处理不好会产生模块间耦合。
Target-Action方式的优点是无需注册,能够做到被调用模块不被依赖。但是它有个比较不足的地方就是在参数打包的时候会有硬编码,但是可以通过在Mediator中包装一层将这部分归并到Mediator统一处理。
下图是个人设计的一个组件化消息总线:
整个总线是集中式下发,消息中心包含了各个消息的注册信息,最主要包括某个事件的监听组件列表,每个eventType为key,value为当前事件的监听组件列表,另一个注册信息是某个事件的响应selector字符串,key为eventType,value为seletorString, 当某个模块需要发送消息的时候调用triggEvent方法,将要触发的事件以及要携带的参数传递到消息中心,消息中心通过查表找出哪些模块需要通知,然后再查着该事件的响应selector,通过performSeletor来调用监听模块的响应方法,从而达到通知消息的目的。
在实际项目中一般会将每个组件都分割成一个单独工程,通过git统一管理,主工程通过Cocoapods集成各个组件,关于Cocoapods的时候大家可以看之前的博客,之前有专门拎出一篇博客进行介绍,但是除了Cocoapods之外还可以有其他的方式。一种是在项目中直接集成源码这种的优点是在主工程调试比较方便,可以看到组件的内部实现,另一种是framework,它的好处是可以加快编译速度,并且每个组件的代码是不可见的,对于比较机密敏感的模块可以选用这种方式。
在项目相对稳定成型的时候我们就可以向项目引入组件化了,有了上面的介绍相信大家对整个组件化有了一定的了解了,作为文章的最后给出一个组件化的一个步骤供大家在项目实际开发过程中参考:
组件化过程不同人有不同的方式,有的人喜欢从下往上,先从细粒度的基本组件开始,然后再封装功能组件,而后才是业务模块,但是个人比较不推荐这种方式,这种方式比较容易陷入到细节中没有宏观的概念,建议找个纸和笔画出整个项目可以划分成哪些模块,这些模块就是我们的业务模块部分,然后在划分这部分模块的时候,再考虑这个模块有那些功能,一一穷举出来,这时候就大概知道有那些功能模块了,到了这一步就可以开始动手从代码层面上进行组件划分了,在划分过程中再把通用的部分下沉,形成一个一个基础组件。不断封装不断下沉公共部分。通过定义一些组件间接口,这些接口后续会在路由中实现,步骤完成后确认组件和其他部分代码没有耦合后就可以将这部分组件提交到组件库中进行管理。
基础层组件则在集成后直接依赖,例如资源文件和配置文件,这些都是直接在主工程或组件中使用的。第三方库则是通过功能组件层的业务封装,封装后由路由进行通信,业务组件层是直接依赖第三方库源码的。
个人建议在内部模块之间使用Target-Action方式,而对外接口或者有些需要H5跳转到客户端的采用URL形式(这主要考虑到H5跳客户端需要保持两端共同的跳转方式,而URL是这些之中唯一能够做到多端统一的路由方式)。
在开始解耦之前先列出上一步划分的组件之间的交互接口,然后通过组件路由来桥接这些接口,传递数据,这里数据的交付应该是去Model化的形式进行交付。
梳理出各个组件哪个组件发出那些消息,哪些组件监听这些消息,都将这些信息注册到组件化消息总线中。
经过上述的组件化,需要我们对整个项目进行自测,首先从各个组件内部先进行测试,然后再扩展到组件间的接口部分进行测试,最后再对项目整体进行测试。
上面介绍的主要针对项目成型的情况下,通过组件化来重构项目,如果是新起的项目,我们会先将项目配置文件等集成到主工程中,做一些基础的项目配置,随后集成需要的组件。之后各个业务根据业务需求实现各个业务组件,一旦遇到需要依赖基础组件库中的服务的时候,就往主项目中引入基础组件库,遇到要从某个组件开发者那里获得某项服务的时候可以到接口需求后台提单,支持组件开发人员根据需求单来提供服务。在这个过程中可以通过Mock数据来并行开发,组件开发结束后,测试先介入对组件进行功能测试,在所有组件完成后再进行组件集成测试。
总结:
这篇文章是写得算是比较久的一篇博客,主要涵盖了:什么是组件化,组件化的优缺点,从项目结构角度分析了一个项目该有的部分,而后正式从如何划分组件,如何从路由角度,数据交付角度来对组件解耦,再接着介绍了路由的功能,三种常见的路由实现方式以及如何设计路由。如何设计消息总线,组件的集成。最后给出了组件化的大致步骤。相信通过上面的介绍结合给出的开源方案,大家都应该会对组件化有个进一步的理解。组件化是一个很强义务相关的话题,需要对自己的项目业务有比较深刻的理解才能设计出比较优雅的组件化框架,本文只是针对比较通用的一些方面进行总结,文章的最后结合手机淘宝的客户端架构探索之路来做个结尾,我自己的组件化也从无锋的这篇文章中借鉴了很多思想,所以想分享给大家,首先建议大家先通读原文,然后再看接下来的内容。
首先我们看下整个方案的大题结构图:
这里涉及到了一切皆Bundle(组件)的概念,整个方案是围绕Bundle展开的,分成三层,最顶层是一系列Bundle,第二层是总线层,用于数据的交付,事件的通知,资源的路由。最后一层则是容器层.
如果将容器看成一个OS,那么一个个Bundle就相当于一个个App一样,它是可被部署的单元,里面可以涉及UI的部分,服务的部分。Bundle对容器是没有依赖的,它只会依赖中间的总线层。容器有完全独立的三大职能:启动加载、生命周期管理、组件管理。其中生命周期管理,负责将容器的生命周期通知到各个Bundle,让Bundle能够感知这些生命周期事件,并作出响应。组件管理器负责添加、删除、替换。在启动的时候,会首先由容器完成整个应用的引导流程,容器的初始化,核心中间件的初始化,启动入口的Bundle(入口Bundle是可配置的,一旦被配置就表示用户点击图标后首先会看到的界面).
总线方面主要分成三大部分,UI总线,服务总线,消息总线:
UI总线和上述的组件路由类似,负责资源的选址,为了三端统一,使用了以跨平台统一的URL作为路由方式,这里比较有意思的是自动降级机制:
没有Bundle承载的URL,将自动以Web 容器加载。这样就可以实现,当一个新的业务到达手机当中的时候,当手机客户端还没有实现这个功能,就会自动以Web的形式把它运作出来。当想要保证轻量化的时候,可能会裁剪掉一些Bundle,这时没有了这些Bundle,这些功能将会自动以Web的方式替换掉,使得在整个流程里面它是完整的。体验可能会有一些差别,但是功能是不受影响的。
服务总线主要用于提供某项服务的总线,通过往总线传入数据后,经过某项服务处理后将数据传出。
消息总线这里的消息总线Android里面使用Broadcast,iOS使用NSNotification。个人比较偏向于使用该博客上面介绍的那种消息总线的方式来通知事件。这里对手淘的具体项目需求不是很清楚所以不做评判。但是这三种总线的划分十分值得借鉴的。
最后手淘在组件化架构的基础上,淘宝提出Bundle App的概念,可以通过已有组件,进行简单配置后就可以组成一个新的app出来。解决了多个应用业务复用的问题,防止重复开发同一业务或功能。
这是源于个人在公司内部Flutter分享时候的PPT,同时也对跨平台方案做了个简单的总结比较,分享给大家希望对大家有帮助: