快手组件化之术——IoC自注册

自己在公司简书上的文章,转一发:https://www.jianshu.com/p/ea944773cbd5
######道势术,以势养道,以术谋势。 —— 《道德经》

######阅读本文需要对 Java 组件化、Annotation processing 和 Javassist 有一定了解。

  当一个 App 发展到多业务组合的阶段,组件化都是必经之路,此为道。实践中组件之间的通信,方案大多是接口 + 实现的强类型通信,这种方案被称为 IoC(Inversion of control),此为势。如何使用简单的接口、高效的实现 IoC 的核心逻辑,是各个组件化框架最大的差异所在,此为术。
  而真正制约着架构推广和发展的恰恰是不被重视的术,只有有足够优雅易用的术,才能谋势进而养道,推动整个架构的实施。
##IoC 做了什么
  IoC 对外接口非常简单,入参是接口类型,出参是该接口的实现实例。从接口上看,IoC 的核心逻辑也非常简单,只有两个功能:

  • 通过接口找出对应的实现类,即维护一个接口类型到实现类型的映射关系
  • 根据映射关系查找到的实现类型,构造实例

  如此简单的逻辑,置于整个组件化的大背景下却不容易实现的很优雅。每个接口和实现可能被定义在不同的 Module 中,而 IoC 一定是在最底层,并不能直接依赖到接口和实现所在的 module,由此提出了反向依赖的要求。类 Spring 的 IoC 几乎都使用手动注册和反射来打破原有依赖关系。而这就导致了几个问题:

  • 每次增加一个新的实现,需要手动注册到 IoC 模块中。注册代码一般在最上层或者 IoC 层,这两层对修改并不是封闭的,违背了开闭原则
  • 反射自身带来的类名、方法名字面量的维护成本,所有错误都只能靠运行时而非编译期校验
  • 反射多多少少会影响运行速度

  为了解决上面的问题,我们使用了 APT 和 Javassist,深入到编译的每个流程中,实现了一个使用简单、实现优雅、脱离了反射的 IoC 模块。

##快手的 IoC 实践

  在快手,IoC 有一套我们自己的命名体系。我们把 IoC 的接口称为 Plugin,IoC 管理器称为 PluginManager。下面我们看一下快手是怎样实现一个没有反射,方便使用的 PluginManager。Talking is cheap, show me the code!

###我们的做法
####对外接口
  我们的对外接口借鉴了 Spring 中 Annotation 注册的方式。Plugin 的实现类仅需要打一个 @InjectModule Annotation 即完成了注册。

@InjectModule
public class FooImpl implements FooPlugin ...

  而使用时只需要使用 PluginManager 拿到对应的实现即可。这套服务发现机制可以简单的融入到各种注入框架中。

PluginManager.get(FooPlugin.class).bar();

  这个层面,其实很多 IoC 实现都做到了,而快手 PluginManager 的简洁高效是现有 IoC 实现所不具备的,这里是真正让我们 IoC 实现与众不同的地方。
####PluginManager 实现

class PluginManager{
  private static final Map<Class<?>, Factory<?>> sPluginFactories = PluginConfig.getConfig();
  public static <T> T get(Class<T> intf) {
    return (T) sPluginFactories.get(c).newInstance();
  }
}

  首先解决构造实例所需要的反射。我们的 PluginManager 并不直接保存实现类的类,而是持有其对应的 Factory。原本需要反射构造函数进行的构建对象,被替换为调用 Factory 接口的 newInstance 方法,解决了构建实例过程中的反射。当然为了方便使用,Factory 是不需要手写的。
  进一步降低维护成本的是,我们映射关系的初始化既没有反射也没有文件操作,只是将 PluginConfig 中的看似是空的映射关系直接复制过来的。而 PluginConfig 也是个非常简单的类,主要代码只有下面的这几行:

private static final Map<Class, Factory> sMappings = new HashMap<>();
public static Map<Class, Factory> getConfig() {
 doRegister();
 return sMappings;
}
public static void doRegister() {// 不需要写代码,空方法}
public static <T> void register(Class<T> intf, Factory<? extends T> impl) {
...//只是把入参中的 intf 和 impl 放到 map 中
}

  熟悉 IoC 的读者应该会觉得 PluginManager 不论是使用还是实现的代码都非常熟悉,而又比常见的要更加的简洁。特别是 PluginConfig,完全没有依赖任何文件或者配置表,似乎只靠 doRegister 一个空函数就完成了映射关系的创建。下面我们一步一步探究简洁背后的技术。

##简洁的背后
  为了达到上面的效果,我们主要用到了两个技术:Annotation processing 和 Javassist。通过 APT 和 Javassist,将传统做法中手动维护字面量映射关系,运行期使用字面量反射构建实例,变成了编辑期根据 Annotation 实现映射注册和实例构建。这里介绍一下快手 IoC 的实现,看一下 Plugin 和它的实现在编译过程中都经历了什么。
  最开始,我们的工程如图所示,各层的相关类都只有很少的 IoC 相关代码。
编译开始前

######上面黄色方框代表整个编译流程,以及快手当前架构下重要的 Module。下面蓝色方框详细描述了具体模块中的代码。每个流程发生变化的 module 和文件会标红
###APT 生成 Factory
  在编译第一步,我们根据 @InjectModule 这个 Annotation 生成对应的 Factory 实现。生成的 Factory 主要有两个功能:构造实例和注册映射关系。
  首先会生成 newInstance 方法,其中直接转调对应 Plugin 的无参构造函数。把构造函数统一成 Factory 接口的不同实现,用来无反射的构造实例。
  同时,我们还生成了一个注册函数,直接调用 PluginConfigregister 方法注册自己。但这时,注册方法并没有被调用。想让这个方法能在需要的地方被调用,我们引入了另一个 Annotation: @InvokeBy
APT 生成 Factory

@InvokeBy

  如图所示,直接正向注册需要最底层代码依赖上层代码,这是违背 Module 依赖关系的。而@InvokeBy,可以指明当前方法希望被哪个方法调用。在这里,我们直接指定 Factory 的注册方法被 PluginConfigdoRegister 方法调用,就做到了把由上到下的正向依赖变成了由下到上的依赖。实现 InovkeBy 语义的过程中,我们使用了 APT 和 Javassist 两项技术。
InvokeBy 的作用
  首先,要解决的问题是怎么让 PluginConfig 在编译期不依赖上层代码(去掉左图中的蓝色箭头)。APT 是不能做到这一点的,因为 APT 发生在各 Module 的编译过程中,并不能打破 Module 间的依赖关系。这也是如此发达的 Spring 并没能干掉反射的原因。而 Android 在打包过程中有一个特殊的阶段:合成 APK。这时候,所有的类(.class) 对彼此都是可见的,与运行时一致。在这个阶段,我们可以修改字节码以实现 PluginConfigFoo 的依赖。此时的依赖与运行时依赖是几乎等价的,并没有破坏组件化的隔离和封装。
  其次,我们还需要解决怎么才能把注册信息注入到对应的类中。这时候 Javassist 就登场了。PluginConfig 为外部提供了一个注入点:doRegisterJavassist 可以修改这个方法,使其调用所有的注册方法。这样透明的反向依赖就达成了。这也是 PluginConfig 单独存在的理由:尽量减少修改字节码的影响范围,方便 Debug。
APT 收集信息
  落实到编译流程。在业务 Module 编译过程中,我们先用 APT 收集各个 Module 中 @InvokeBy 的信息,生成了一个 JSON 文件保存映射关系放到 jar 包中。
修改 PluginConfig
  在合成 APK 时注册一个 Transform,遍历每个 jar 包,按照 APT 生成的信息修改对应的 class 文件。插入一行代码,让 PluginConfig 调用 Factoryinit 方法。这里插入的代码是由 Javassist 编译生成的,这样代码的编译期校验是仍然有效的。这个过程发生在合并 Apk 时,此时发生变化的只有处于 Framework 层的 PluginConfig 类的字节码。对于行数影响最小。
###遇到的问题
  过程中主要遇到了几个问题:InvokeBy 维护困难,热修复失效等等。
  InvokeBy 希望表达的是让 AClassaMethod 调用 BClassbMethod,其中 AClass 是不能依赖 BClass 的。这个语境下,AClass#aMethod 被成为 Invoker, BClass#bMethod 被成为 Target。InvokeBy 就需要一个方法指定 aMethod 是 invoker。如果使用方法名字面量,与反射的维护成本基本一致,相较反射并没有明显的优势。所以我们加了一个 MethodId 的概念,在 Invoker 和 Target 的方法上标记相同的 MethodId,APT 通过 MethodId 关联起 Invoker 和 Target 。以维护常量池为代价,做到了无字面量。
  在代码生成、修改的过程中,实际上多次编译的顺序是无法保证的,这样热修复工具在算 diff 时可能出现异常大的差异。为了解决这个问题,我们 App 中所有的 APT 和 Javassist 都强制根据类名/方法名进行了排序。依靠多次编译过程中不变的量保证整体编译有序
#快手的技术
  在快手探索组件化的过程中,我们一直以简洁的接口,优雅的使用为最基本要求。不论是外部工具的引入还是自研框架工具,都有着我们自己的追求和标准。我们产出了很多面向通用问题的的基础组件、APP 内业务分层、模块之间解耦合的技术方案。我们鼓励每一个开发同学以架构师的视角工作,鼓励重构。后续还会产出更多的,快手风格的方案和技术,后续分享尽请期待。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值