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

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

道势术,以势养道,以术谋势。 —— 《道德经》
阅读本文需要对 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 相关代码。

《快手组件化之术——IoC自注册》 编译开始前

上面黄色方框代表整个编译流程,以及快手当前架构下重要的 Module。下面蓝色方框详细描述了具体模块中的代码。每个流程发生变化的 module 和文件会标红

APT 生成 Factory

  在编译第一步,我们根据 @InjectModule 这个 Annotation 生成对应的 Factory 实现。生成的 Factory 主要有两个功能:构造实例和注册映射关系。
  首先会生成 newInstance 方法,其中直接转调对应 Plugin 的无参构造函数。把构造函数统一成 Factory 接口的不同实现,用来无反射的构造实例。
  同时,我们还生成了一个注册函数,直接调用 PluginConfigregister 方法注册自己。但这时,注册方法并没有被调用。想让这个方法能在需要的地方被调用,我们引入了另一个 Annotation: @InvokeBy

《快手组件化之术——IoC自注册》 APT 生成 Factory

@InvokeBy

  如图所示,直接正向注册需要最底层代码依赖上层代码,这是违背 Module 依赖关系的。而@InvokeBy,可以指明当前方法希望被哪个方法调用。在这里,我们直接指定 Factory 的注册方法被 PluginConfigdoRegister 方法调用,就做到了把由上到下的正向依赖变成了由下到上的依赖。实现 InovkeBy 语义的过程中,我们使用了 APT 和 Javassist 两项技术。

《快手组件化之术——IoC自注册》 InvokeBy 的作用

  首先,要解决的问题是怎么让
PluginConfig 在编译期不依赖上层代码(去掉左图中的蓝色箭头)。APT 是不能做到这一点的,因为 APT 发生在各 Module 的编译过程中,并不能打破 Module 间的依赖关系。这也是如此发达的 Spring 并没能干掉反射的原因。而
Android 在打包过程中有一个特殊的阶段:合成 APK。这时候,所有的类(.class) 对彼此都是可见的,与运行时一致。在这个阶段,我们可以修改字节码以实现
PluginConfig
Foo 的依赖。此时的依赖与运行时依赖是几乎等价的,并没有破坏组件化的隔离和封装。

  其次,我们还需要解决怎么才能把注册信息注入到对应的类中。这时候 Javassist 就登场了。
PluginConfig 为外部提供了一个注入点:
doRegister
Javassist 可以修改这个方法,使其调用所有的注册方法。这样透明的反向依赖就达成了。这也是
PluginConfig 单独存在的理由:尽量减少修改字节码的影响范围,方便 Debug。

《快手组件化之术——IoC自注册》 APT 收集信息

  落实到编译流程。在业务 Module 编译过程中,我们先用 APT 收集各个 Module 中
@InvokeBy 的信息,生成了一个 JSON 文件保存映射关系放到 jar 包中。

《快手组件化之术——IoC自注册》 修改 PluginConfig

  在合成 APK 时注册一个
Transform,遍历每个 jar 包,按照 APT 生成的信息修改对应的 class 文件。插入一行代码,让
PluginConfig 调用
Factory
init 方法。这里插入的代码是由 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 内业务分层、模块之间解耦合的技术方案。我们鼓励每一个开发同学以架构师的视角工作,鼓励重构。后续还会产出更多的,快手风格的方案和技术,后续分享尽请期待。

作者简介: 张天宇,快手 Android 开发工程师,17年加入快手。醉心于 App 架构和各种 Java 的奇技淫巧,欢迎各位大神交流指导。也非常欢迎把简历发到 zhangtianyu@kuaishou.com ,或者加我的微信:

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

    原文作者:kuaishou
    原文地址: https://www.jianshu.com/p/ea944773cbd5
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞