动态指定包含功能的SDK结构调研

最近在做一个 sdk,有这样一个需求是,sdk 中有5个功能模块,在对外打包的时候可以*的选择 sdk 中包含任意的几个功能模块。

比如给业务方 A 的 sdk 包中包含功能1/2/3,给业务方 B 的 sdk 包中包含功能2/3/4。

思考良久,觉得下面这种结构是符合需求的,简单描述一下。

  1. 最上层是 api 层,包含整个 sdk 初始化,以及每个模块对外提供服务的 api 代码,在这一层根据功能模块定义宏,使用宏区分初始化哪些部分代码,以及哪些 api 可用。
  2. 下一层是对应的5个功能模块各自的代码,互相之间无依赖。
  3. 再下层是涉及到多个模块的公用代码,有两种情况,一种是被部分模块依赖,比如 common-module-1,还有一种是被所有模块依赖,比如 common-module-3,和 api 层一样,在 common-module-3 这种全局依赖的模块里根据功能模块定义宏,使用宏区分初始化哪些代码可用。

动态指定包含功能的SDK结构调研

整个结构介绍完了,在实际编译的过程中,比如想编译包含功能1/4的sdk,则编译情况如下
动态指定包含功能的SDK结构调研

接下来就是代码实现,在Android和iOS中如何实现这种组织结构呢,仔细思考一下,这个方案其实涉及三个点

  • 灵活的依赖关系
  • 区分编译代码 - 动态修改宏定义
  • 可合并的目标产物

Android

1. 灵活的依赖关系

首先想到的是像 mPaaS 一样的模块化开发,mPaaS 提供核心层,其他 Bundle 之间的调用有两种形式。

  1. 通过依赖引入
  2. 通过在核心层注册 Service,其他模块可以 findService 后使用

但是 mPaaS 是基于 Project 来做的,每个模块的产物是一个类似 aar 的包含代码和资源的包。当前这个需求倒是不需要如此重量级,所以考虑基于同一个 Project,分 module 的形式来实现。

Android 的依赖是比较容易解决的,因为 Gradle 本身是支持可编程的。所以 build.gradle 脚本可以写成如下的样式(依赖类型的 embed 是另外一个原因,这个后面再讲)。

dependencies {
    embed project(':sdk-api')
    embed project(':sdk-common')
    if ("${SEC_SDK_FUNCS}".indexOf("APDID") != -1) {
        embed project(':sdk-apdid')
    }
    if ("${SEC_SDK_FUNCS}".indexOf("DYNAMIC") != -1) {
        embed project(':sdk-dynamic')
    }
    if ("${SEC_SDK_FUNCS}".indexOf("DEVICECOLOR") != -1) {
        embed project(':sdk-devicecolor')
    }
    if ("${SEC_SDK_FUNCS}".indexOf("SIGN") != -1) {
        embed project(':sdk-sign')
    }
    if ("${SEC_SDK_FUNCS}".indexOf("SECSTORE") != -1) {
        embed project(':sdk-secstore')
    }
}

2. 区分编译代码

另外一个需要解决的问题是顶层和底层的宏定义,因为顶层和底层代码是共用的,所以,在 Android 上有三种方法实现这种功能。

  • 通过反射实现
  • 通过 BuildConfig + if 语句实现功能分流
  • 通过编译器脚本实现类宏定义功能

首先反射实现是被我否定的,因为反射会增加额外的调用消耗和异常,而且通过反射实现会在 api 层暴露过多的代码细节,不利于代码的安全性。

其次通过 BuildConfig 和 if判断条件,BuildConfig 可以通过编译脚本生成到代码中。

android {
    defaultConfig {
        ...
        buildConfigField "String", "VERSION_NAME",   "1.0" // 主版本号
        ...
    }
}
public final class BuildConfig {
    ...
  public static final String VERSION_NAME = "1.0";
    ...
}

因为 BuildConfig 是 final static 的常量,所以这种常量加简单的判断条件,在 java 编译期是会被优化掉的,也就是说,如下代码。

@Override
protected void onCreate(Bundle savedInstanceState) {
    if (BuildConfig.VERSION_NAME == "1.0") {
        Log.d("test", "#1");
    } else {
        Log.d("test", "not #1");
    }
    if (BuildConfig.VERSION_NAME.equals("1.0")) {
        Log.d("test", "#2");
    } else {
        Log.d("test", "not #2");
    }
    if (BuildConfig.VERSION_NAME == "1.0" || BuildConfig.VERSION_NAME == "2.0") {
        Log.d("test", "#3");
    } else {
        Log.d("test", "not #3");
    }
    if (isMatch()) {
        Log.d("test", "#4");
    } else {
        Log.d("test", "not #4");
    }
}

public static boolean isMatch() {
        if (BuildConfig.VERSION_NAME == "1.0") {
            return true;
        }
        return false;
    }

编译之后的代码如下

public void onCreate(Bundle bundle) {
    Log.d("test", "#1");
    Log.d("test", "#2");
    Log.d("test", "#3");
    if (isMatch()) {
        Log.d("test", "#4");
    } else {
        Log.d("test", "not #4");
    }
}

public static boolean isMatch() {
    return true;
}

可以看出来,简单的判断,与或否都会在编译期优化掉,只保留可达的分支,但是如果通过方法调用,即使方法内部也是简单的与或否判断,依然无法优化掉。

所以可以看出来,BuildConfig存在以下一些问题

  • 判断条件多的时候无法提取公共方法
  • 无法在方法体外部生效,比如import、变量声明的地方

第二个问题是比较致命的,这就导致了一些依赖无法被彻底移除,所以还是寻找一种类似 C 宏定义的方案,在编译期生效,可以在任何地方使用,好在 github 上已经有人提供了对应的解决方案。

https://github.com/dannyjiajia/gradle-java-preprocessor-plugin

根据说明,只需要在build.gradle中声明symbols

preprocessor {
    verbose true
    sourceDir file("src/main/java") //required
    targetDir file("src/main/java") //required
    symbols "GLOBAL","GLOBAL_2" //symbols is  valid for each flavors
}

在代码中就可以使用了

//#ifdef FREE_VERSION
    Log.i("sample","I am Free Version");
//#else
    Log.i("sample","I am not Free Version");
//#endif

编译期,未符合条件的地方会被注释掉,如下

//#ifdef FREE_VERSION
//@    Log.i("sample","I am Free Version");
//#else
    Log.i("sample","I am not Free Version");
//#endif

这个方案存在什么问题呢,主要是易读和易维护性的问题,不是java的原生支持,所以IDE也不会有额外的显示优化,想想你的代码中有大量的注释代码是种什么感觉,好在我们只是在顶层和底层的共用代码中少量使用宏定义,还是可以接受的。

3. 可合并的目标产物

最后再说一下代码产物,期望是不管内部的功能模块有哪些,但是对外打出的 sdk 都是一个,但是我们知道采用 module 形式组织的项目,每个 module 都会产出一个 aar,所以这里就用到了一个 aar 合并的方案,依然是万能的 github。

https://github.com/vigidroid/fat-aar-plugin

前面依赖关系中提到的 embed 就是这种方案定制的依赖标识,通过 fat-aar 可以合并产出的所有 aar 为一个最终的 aar。

iOS

1. 灵活的依赖关系

和Android类似,iOS 的依赖关系也可以通过 Project 或者 Target(类似module)两种形式实现,基于这两种形式,Xcode 的模块化有三种形式

  • project
  • target - framework
  • target - static library

下面依次来研究一下这三种模式是否能满足我们的需求

Project模式

和 Android 一样,以 Project 的形式组织项目,会导致整个 SDK 的组织复杂度极高,代码分布在多个代码库里,对于我们这种开发 SDK 的需求是有点得不偿失的。

但是像MPaaS iOS版就是一Project来分割模块的,然后通过Pod管理依赖,所以,涉及的分组合作的大体量项目,可以考虑这种模式。

Target模式

如果项目在一个 Project 中,就要考虑以 Target 来分隔功能模块,Target 的产物分为两种形式 Framework 和 StaticLibrary,下面我们来看下这两种形式的利弊。

Target-Framework模式

Framework 的产物是 xxx.framework,头文件和源文件都被打进 Framework 包中,接入方依赖 Framework 后可以直接调用里面提供的方法。
动态指定包含功能的SDK结构调研
但是 Framework 不适用于当前我们的多模块需求,因为以 Framework 作为产物是无法合并,分模块后最终打出的产物是多个 Framework,业务方接入功能需要多个 Framework 接入,提高了接入的复杂度。

第二个问题是无法实现多层依赖,即 A依赖B、B依赖C,则A需要同时引入B和C的 Framework,在多模块的情况下也会导致整个项目依赖很复杂。

Target-StaticLibrary模式

StaticLibrary 模式的产物是 xxx.a,.c .m等源文件被编译进.a中,但是并不包含头文件,头文件需要单独提供,接入方依赖 .a 时需要手动设置 header files search path 来引用头文件才可以调用内部提供的功能。所以只用 StaticLibrary 也是无法满足需求的。

我们当前的需求是希望提供给业务方一个独立可用的包,像 Framework 这种既包含源文件又包含头文件,但是内部编译进来的源文件又是可选的,于是就需要组合 Framework 和 StaticLibrary 两种形式一起使用。

在最外层包一个 Framework 的target,依赖各个 StaticLibrary,在 Framework 中提供所有的.h文件,源文件是在各个 StaticLibrary 中的。但是需要注意的是,这样最终这种模式中,各个.c文件是各个StaticLibrary编译时单独编译的,所以在合并到 Framework 时,如果多个类同名会出现类冲突。

动态指定包含功能的SDK结构调研

最后的组织结构像这样

动态指定包含功能的SDK结构调研

动态依赖关系

XCode的所有配置都是写在project.pbxproj文件中的,所以编译脚本只需要修改project.pbxproj文件,然后手动调用xcodebuild命令进行编译。

project.pbxproj的格式看似杂乱,但其实是按照一定的规则进行组合的,如下图所示,project.pbxproj是通过index相互组织的,通过index就可以一层层的找到对应项目依赖设置的位置。
动态指定包含功能的SDK结构调研

简单一点,可以通过直接编辑文件的方式来修改,但是要一层层的记录index,然后找到对应的index再修改。遇事不决github搜一搜,发现python有一个解析project.pbxproj文件的库。

pip install pbxproj

于是整个过程就变成了

  project = XcodeProject.load(xxx.xcodeproj/project.pbxproj)
# 配置依赖
  APPSecuritySDK_Frameworks = 0
  # 找到依赖的index
  PBXNativeTarget = project.objects._sections["PBXNativeTarget"]
  for target in PBXNativeTarget:
    if target._get_comment() == "APPSecuritySDK":
      for phase in target.buildPhases:
        if phase._get_comment() == "Frameworks":
          APPSecuritySDK_Frameworks = phase
        
  # 修改依赖      
  for phase in project.objects._sections["PBXFrameworksBuildPhase"]:
    if phase.get_id() == APPSecuritySDK_Frameworks:
      for file in phase.files.copy():
        module_name = re.findall(r"(?<=libAPPSecuritySDK\-).+(?=\.a)", file._get_comment())
        if len(module_name) > 0:
          module_name = module_name[0]
        if module_name and module_name not in contain_functions and module_name != "Common":
          print('REMOVE MODULE DEPEDENCES :' + module_name)
          phase.files.remove(file)

2. 区分编译代码

这一块是比较简单的,因为oc原生支持宏定义,XCode对宏定义的显示优化也比较好。

需要解决的一个问题是如何通过编译脚本动态的修改宏定义,还是和上面动态修改依赖关系一样,我们先分析下宏定义在pbxproj中的结构

动态指定包含功能的SDK结构调研

然后用python来解析pbxproj文件

  project = XcodeProject.load(xxx.xcodeproj/project.pbxproj)
  functions_DEFINITIONS = configDefinitions(params)

  # 在XCBuildConfiguration中的每个GCC_PREPROCESSOR_DEFINITIONS模块都加入对应的宏定义
  functions_DEFINITIONS = ["MACRO_1", "MACRO_2"]

  XCBuildConfiguration = project.objects._sections["XCBuildConfiguration"]
  for obj in XCBuildConfiguration:
    GCC_PREPROCESSOR_DEFINITIONS = obj.buildSettings["GCC_PREPROCESSOR_DEFINITIONS"]
    if GCC_PREPROCESSOR_DEFINITIONS is None:
      obj.buildSettings.__setitem__("GCC_PREPROCESSOR_DEFINITIONS", functions_DEFINITIONS)
    else:
      if type(GCC_PREPROCESSOR_DEFINITIONS) is list:
        GCC_PREPROCESSOR_DEFINITIONS.extend(functions_DEFINITIONS)
      elif type(GCC_PREPROCESSOR_DEFINITIONS) is str:
        old_definition = GCC_PREPROCESSOR_DEFINITIONS
        functions_DEFINITIONS.append(old_definition)
        obj.buildSettings.__delitem__("GCC_PREPROCESSOR_DEFINITIONS")
        obj.buildSettings.__setitem__("GCC_PREPROCESSOR_DEFINITIONS", functions_DEFINITIONS)
      else:
        print('INVALID DEFINITIONS TYPE')

这样就可以在编译前在每个 Target 加上指定的宏定义。

3. 可合并的目标产物

之前讨论的依赖关系,最终选择 StaticLibrary 的模式有很大一部分原因也是因为 StaticLibrary 可以被合并到最终的 Framework 产物中。

StaticLibrary 的产物是 .a,.a 文件中只包含类编译后的 .o 文件,而当 StaticLibrary 被一个 Framework 依赖时,StaticLibrary 中的 .o 文件会被合并到 Framework 中,最终的编译产物就是一个 Framework。

总结

以上就是这种结构 SDK 在 Android 和 iOS 上各自的实现方案,如果有更好的方案,欢迎与我交流。

上一篇:活动回顾|阿里云ACE—智能制造和工业互联网专场


下一篇:antd-pro升级了,新增的UmiJs是个什么玩意儿??