Feb 26

AppDelegate大礼包解耦思路

Lrdcq , 2020/02/26 00:36 , 程序 , 閱讀(1501) , Via 本站原創
背景

参考mPaaS等比较理想化的客户端native组件解藕落地方案,我们已经把各个基础SDK解耦了,非常理想的情况下我们可以在podfile里指啥引啥,不依赖别的任何东西了。但是这样做会有一个副作用。比如我有一个定位sdk,原本初始化的方式是:
- (BOOL)initLocationServiceWithUUID:(NSString *)uuid
                           clientID:(NSString *)cilentID
                            authKey:(NSString *)authKey;

看起来很自然。但是由于定位下游会进行网络请求,因此在定位sdk的解耦过程中,为了把网络请求解开,初始化过程变为了:
- (BOOL)initLocationServiceWithUUID:(NSString *)uuid
                           clientID:(NSString *)cilentID
                            authKey:(NSString *)authKey
                networkServiceBlock:(LocationNetworkServiceBlock)networkServiceBlock;

出事化的时候直接惊了,用宿主app去实现网络请求的过程。隔壁Android也更过分,需要传入一个OkHttpClient对象。

随着这样的sdk在AppDelegate初始化得越来越多,及时通过拆category,拆pod,还是会清晰的面临这个问题:即虽然sdk本身解耦开了,sdk初始化逻辑还是会将可能耦合的东西耦合在一起,并且将app的初始化程序段落变成超级大礼包。

并且,理想的情况下,sdk拆解后,sdk的初始化理应也是可以自由拆解的,就是说,如果我引入了A C D F四个sdk,那我再引入4个对应的代码段落,设置一些appid,key之类的无痛无痒的东西,就可以让项目快速跑起来了。

问题分析

再次清晰review这个问题,其实发现,要实现依赖解耦,如上举例子的定位组件的设计本身就有问题:

1. 我们说的解耦并不是直接解除某个依赖让宿主去实现就好。更合理的方式是通过依赖反转的方式,通过适配器,服务注入或者别的方式将可能存在依赖项目加载进来,并且在找不到依赖项目的时候主动发出异常。

2. 另一种合理的方式是保持接口模式但是实现可选,简单的说,定位sdk提供一个依赖干净的主体组件,但是同时提供多个耦合xxx网络库的实现组件。比如提供了一个LocationNetworkURLSessionService和一个LocationNetworkAFService,用户可以根据自己选择使用的网络框架去引入对应的组件,实在不行再考虑通过接口自己去实现NetworkService,这样从而实现避免大礼包的依赖解耦合,并且用户用起来也很方便。

除去每个sdk本身,sdk与其他sdk,与其他业务流程的协作也会有问题。

1. 还是对于定位sdk依赖网络sdk的场景,假设定位sdk初始化后立刻会发起网络请求,必然依赖网络sdk初始化完成后才能进行定位sdk初始化。但是网络sdk,比如是一个长链框架,很有可能初始化本身是一个异步的。因此在解耦的同时,需要对个多启动流程根据他们的依赖关系进行同步/异步的顺序编排,才能较为完整的合理实现整个启动流程。

2. 还会注意到目前的sdk初始化写法中往往会出现携带实际业务含义的逻辑,如定位sdk的网络实现中,手动耦合了用户模块塞入了userid,当然这个用户模块每个app都是不太一样的,因此成为了难以标准化/模版化的部分。类似于web容器的初始化,在初始化的同时,启动了preload工具,并且硬编码了几个指定的业务离线包进行检查更新和下载。因此关注到实际上启动流程中会有不少动态的或者静态的业务信息插入,如果启动流程进行了模版化或者标准化,相关业务信息的插入方式也需要进行抽象。

收口起来,我们要解决三个大问题:

1. task中对其他技术库的耦合
2. task中对业务代码的耦合
3. task对其他task的依赖耦合

设计思路

为了使得我们的启动Task尽量解耦,首先从文件上我们需要保证每一个task全能。

传统的做法每一个task是一个纯函数,通__attribute__used section的方式将相关函数统一反向注册,例如:
/*** task任务定义部分 ***/
#define APP_INIT_STEP(NAME, FUNCTION) \
    KLN_FUNCTIONS_EXPORT(APPINIT) \
    () \
    { \
        [[AppInitManager sharedManager] installWithStep: NAME block: FUNCTION]; \
    }
    
/*** HahahaSDK启动编码部分 ***/
APP_INIT_STEP(HahahaSDK, ^{

  [HahahaSDK config].appId = 123456;
  [HahahaSDK config].appName = @"hahaha";
  [HahahaSDK config].userId = ^NSString *{
    return [User shared].userId;
  };
  [HahahaSDK config].onload = ^(NSData *data) {
    BOOL success = [[SomeOther shared] setData:data];
    if (!success) {
      [[SomeOther2 shared] setDataToo:data];
    }
  }
  [HahahaSDK setup];
})

/*** HahahaSDK启动注册部分 ***/
//some流程编码部分
AddStep(XSDK)
AddStep(YSDK)
AddStep(HahahaSDK)
AddStepAsync(MSDK)
AddStep(NSDK)

目前业界绝大部分注册编排工具应该都是这样注册的,但是显然,这个注册方式多多少少信息是不足的。

1. 即这段代码有外部输入(userid),向外输出(data),然而还是传统写法,无法做到依赖解耦。同时输出部分包含业务逻辑,并且耦合多个外部库。
2. APP_INIT_STEP的注册形式虽然是方法封装入参,但是毕竟是宏,注册起来还是很不友好的。同时对应的向框架输入信息非常有限。
3. 启动流程编排以代码的形式,或者类似配置文件的形式集中管理,有管理成本的同时,逻辑含义上也无法解耦合。

要解决以上技术问题,每个SDK本身提供的启动流程task模版理应有前置原则:

- 启动流程的耦合依赖不应该超过sdk本身的依赖范围,这里不只是代码依赖,好包含逻辑上的依赖,通过NSClassFromString这样的方式解除依赖没有任何意义。
- 启动流程不应该包含业务逻辑,业务逻辑该独立成库还是独立出去。但是对应sdk应该提供足够多样的输入数据输出形式。
- task注册形式丰富化,一个方法不够的,要一个类。

注册形式部分

例如:
@interface HahahaSDKTask : NSObject <AppInitDelegate>
@end

@implementation HahahaSDKTask

APP_INIT_STEP(HahahaSDKTask)

+ (BOOL)requiresMainQueueSetup {
  return YES;
}

+ (BOOL)requiresSyncSetup {
  return YES;
}

+ (NSArray<NSString> *)dependencyBeforeStep {
  return @[@"ASDK", @"BSDK"];
}

+ (NSArray<NSString> *)dependencyAfterStep {
  return @[];
}

+ (BOOL)requiresPriority {//默认为500,建议0-1000
  return 0;
}

//初始化方法
- (void)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    //something
}

- (void)applicationWillEnterForeground:(UIApplication *)application {
    //something too
}

@end

整个写法有点像RN的桥,核心思路是:

1. 注册信息接口化。除了类名依旧通过__attribute__形式注册之外,其他的信息通过明确的代理接口返回,虽然繁琐了一点,但是更加明确清晰,也可以在特殊情况通过编译配置(比如是否是debug)添加更丰富的逻辑了。目前一个正常的启动task会包含以下信息:
a. task的名字,字符串。
b. task是否必须在前台进程运行。
c. task是否支持异步运行。
d. task是否依赖其他task初始化完成,如果依赖,会等待其他task完成之后再进行初始化。
e. task是否依赖其他task初始化未开始,如果依赖,其他task会等待这个task初始化完成。
f. task要求的优先级,如果多个task均可开始,将选择优先级高即Priority值低的task进行。这个点当然也需要各个task通力协作达成默契,或者有统一的review保证一定准入标准,才可以实施。
通过完整的赋予以上信息,就可以进行自动启动流程编排了,也就不需要手动编排文件了。同时requiresPriority本质上也保留了手动统一编排的能力。

2. 启动task实际逻辑和正常的didFinishLaunchingWithOptions一致,并且期望task类其实可以承接appdelegate绝大部分生命周期事件。因此一个独立sdk的初始化,包括需要启动参数的sdk,需要关注前后台生命周期的sdk比如长链接,需要关注push事件的pushsdk,都可以在统一的类中进行启动收口。

3. 当然,用类与实例的另一个好处即可以一定程度上持有状态,避免了以往appdelegate上加一堆奇怪的属性,或者通过category加一堆更奇怪的属性,而且偶尔还会有冲突的窘迫。当然整个设计看起来更像是SceneDelegate——对的,确实是相同的解耦思路。

信息解耦

启动流程的形式确定了,接下来再看解耦也就清晰了。我们要解耦合的主要是动态信息如userid和生产的data,当然还行静态信息如appid。

1. 静态信息好办,代码集成改成从配置文件读取即可,类似于:
/** 建设工具:jsonconfigreader **/
@interface JsonConfigReader : NSObject

- (instancetype)initWithConfig:(NSString *)filename;
- (NSString *)stringForKey:(NSString *)key;
//and so on

@end

/** HahahaSDK配置文件准备 **/
{
  "appId": 123456,
  "appName":"hahaha"
}

/** HahahaSDK启动编码部分 **/
    JsonConfigReader *reader = [[JsonConfigReader alloc] initWithConfig:@"hahaha.json"];
  [HahahaSDK config].appId = [reader integerForKey:@"appId"];
  [HahahaSDK config].appName = [reader stringForKey:@"appName"];

这样以后,HahahaSDK的静态信息初始化部分都是标准化的了,工程实际依赖的是配置文件的写入与改变。

2. 动态信息从多个App之间的关系来看可以区分为标准参数或者通用参数或者基础参数,和业务参数即乱七八糟的。

如果要找一个词来形容什么叫通参的话,含义不会造成误解的词,比如我们说uuid,他定义是设备id,每个app里应该只会通用一个uuid,说userid,即登录的用户id,即使我们登录的形式有多种,单个状态下登录人一定是唯一的,当然userid也是唯一的,我们把这样的参数设定为通参,目前列举出来包括:
uuid:设备id
idfa:苹果广告符,估计后续没有了
apnsToken:苹果推送token
pushToken:第三方pushtoken
channel:启动渠道
userId:用户id
userName:用户名
userPhoneNumber:用户电话号码
userToken:用户验权key
fingerprint:用户指纹
locationLat:
locationLng:经纬度
cityId:城市id
cityName:城市名称
environmentMap:环境变量map,自定义字段

//同时加上一些比较通用的操作
httpclient:复用的网络实体,多用于android上
requestblock:iOS的话可以定义一个输入request输出response的block
logblock:发起打log的block

以上标准的信息,我们可以进行收到一个中间类进行提供,即设计一个InitCommonValue,那么HahahaSDK可以只依赖这个工具即:
  [HahahaSDK config].userId = ^NSString *{
    return [InitCommonValue shared].userId;
  };
  
//User模块则需要在自己的初始化过程中主动为框架提供这个数据
  [User shared].onChange = ^{
    [InitCommonValue shared].userId = [User shared].userId;
  }

当然,这里涉及到一个数据的提供与消费,通用数据的消费方当然是多个,但是如果我们引入的数据提供方也是多个咋整。这里userId属性即应改为实现getUserId的block,即使在初始化过程中发生竞态,实际也会锁定到一个实现上,不会出现大问题。

业务参数就麻烦了,比如我们的HahahaSDK会输出一个data给其他多个业务模块,叫xxxdata的共享数据多的去了,并且这些模块的获取方式要有一定耦合,那我们的启动解耦框架是万万不能对这类数据进行xjb编排的。因此这里的基础原则是:

a. 还是sdk自己想办法解耦,框架。
b. 基础sdk依赖业务sdk不合理(假设这里hahaha是基础sdk,SomeOther是业务sdk),需要依赖反转。

因此实际解决问题的方法需要一个不可复用的业务初始化task去承接相关的task,定位上确认为业务组件。并且比如这个场景上,这个task需要优先接住hahaha的onload,hahaha才能初始化。因此实际代码上:
@implementation HahahaOtherLogicTask

APP_INIT_STEP(HahahaOtherLogicTask)

+ (BOOL)requiresMainQueueSetup {
  return YES;
}

+ (NSArray<NSString> *)dependencyAfterStep {
  return @[@"HahahaSDKTask"];//HahahaSDKTask必须在这个task之后执行
}

- (void)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    HahahaSDK config].onload = ^(NSData *data) {
    BOOL success = [[SomeOther shared] setData:data];
    if (!success) {
      [[SomeOther2 shared] setDataToo:data];
    }
  }}
@end

这样HahahaSDKTask至少成为一个标准的初始化task了。HahahaSDK的输出完全交由某容器特有的业务代码处理。

创建/调度/销毁

整个启动框架还是很有可能有一个非常长的过渡使用期,同时它理应与app的生命周期一致,因此它应该是附着到appdelegate上的一个对象。以最小入侵的设计,看起来是这样的:
@implementation AppDelegate {
  AppInitManager *_appIniter;
}

- (instancetype)init {
  if (self = [super init]) {
    _appIniter = [AppInitManager managerWithDelegate:self];
  }
  return self;
}
@end

为了实现尽量少的入侵,AppInitManager收听AppDelegate的通过类似于reactivecocoa的rac_signalForSelector的形式去监听各个delegate的调用。即如果方法存在,通过方法交换去完成方法前后切面拦截,如果方法不存在,直接去实现对应的方法即可。

后续流程里保持:

1. task中的didFinishLaunchingWithOptions方法为核心初始化方法,该方法同步执行完成将视为该task初始化完成。
2. task按依赖顺序与优先级进行初始化调度与各个生命周期内的调度。如果一个task未初始化完成,也不会进行生命周期调用。
3. task通过taskmanager通过appdelegate保持持有。并且与整个生命周期一致。

再配合上我们常见的即有task调度逻辑,task即可驱动起来。

同时基于拆分的设定,我们将各个sdk的初始化task进行了标准化,因此实际拆分逻辑包括:

1. sdk初始化task,应随基础sdk引入项目并自动驱动。

2. 业务逻辑task,有一个区分:
2.1. 有一些业务逻辑是有明确的业务相关的,因此相关业务逻辑task应随着native对应的业务频道包进行分发。
2.2. 有一些业务逻辑确实是启动only的一些逻辑,比如测量启动时间,启动数量核心埋点,crash安全模式兜底页面等。相关逻辑确实和主app业务逻辑不想关,但是对应的他们是非常纯粹有一定抽象价值的公共组件,因此为他们抽出小组件,并且独立发布并引入。

落地结果

基于以上设定,我们最顶部的HahahaSDK的初始化task完成启动流程解耦之后,大概会变成如下信息的组合:

1. 配置文件
{
  "appId": 123456,
  "appName":"hahaha"
}

2. SDK启动Task
@implementation HahahaSDKTask

APP_INIT_STEP(HahahaSDKTask)

+ (BOOL)requiresMainQueueSetup {
  return YES;
}

+ (BOOL)requiresSyncSetup {
  return YES;
}

+ (NSArray<NSString> *)dependencyBeforeStep {
  return @[@"XSDK", @"YSDK"];
}


- (void)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    JsonConfigReader *reader = [[JsonConfigReader alloc] initWithConfig:@"hahaha.json"];
  [HahahaSDK config].appId = [reader integerForKey:@"appId"];
  [HahahaSDK config].appName = [reader stringForKey:@"appName"];
  [HahahaSDK config].userId = ^NSString *{
    return [InitCommonValue shared].userId;
  };
  [HahahaSDK setup];
}
@end

3. 为其提供信息的UserTask
@implementation UserTask

APP_INIT_STEP(UserTask)

+ (BOOL)requiresMainQueueSetup {
  return YES;
}

+ (BOOL)requiresSyncSetup {
  return YES;
}

- (void)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
  [User shared].onChange = ^{
    [InitCommonValue shared].userId = [User shared].userId;
  }
}
@end

4. 消费其输出信息的OtherTask,必须在Hahaha之前初始化
@implementation HahahaOtherLogicTask

APP_INIT_STEP(HahahaOtherLogicTask)

+ (BOOL)requiresMainQueueSetup {
  return YES;
}

+ (NSArray<NSString> *)dependencyAfterStep {
  return @[@"HahahaSDKTask"];
}

- (void)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    HahahaSDK config].onload = ^(NSData *data) {
    BOOL success = [[SomeOther shared] setData:data];
    if (!success) {
      [[SomeOther2 shared] setDataToo:data];
    }
  }}
@end

用依赖关系图描述整个动作,即为

點擊在新視窗中瀏覽此圖片

至此为止,之前还乱成一团的启动流程task也被解耦干净了。
关键词:ios
logo