快手组件化之术——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 相关代码。
编译开始前
上面黄色方框代表整个编译流程,以及快手当前架构下重要的 Module。下面蓝色方框详细描述了具体模块中的代码。每个流程发生变化的 module 和文件会标红
APT 生成 Factory
在编译第一步,我们根据 @InjectModule
这个 Annotation 生成对应的 Factory
实现。生成的 Factory
主要有两个功能:构造实例和注册映射关系。
首先会生成 newInstance
方法,其中直接转调对应 Plugin 的无参构造函数。把构造函数统一成 Factory
接口的不同实现,用来无反射的构造实例。
同时,我们还生成了一个注册函数,直接调用 PluginConfig
的 register
方法注册自己。但这时,注册方法并没有被调用。想让这个方法能在需要的地方被调用,我们引入了另一个 Annotation: @InvokeBy
。
@InvokeBy
如图所示,直接正向注册需要最底层代码依赖上层代码,这是违背 Module 依赖关系的。而@InvokeBy
,可以指明当前方法希望被哪个方法调用。在这里,我们直接指定 Factory
的注册方法被 PluginConfig
的 doRegister
方法调用,就做到了把由上到下的正向依赖变成了由下到上的依赖。实现 InovkeBy
语义的过程中,我们使用了 APT 和 Javassist 两项技术。
首先,要解决的问题是怎么让
PluginConfig
在编译期不依赖上层代码(去掉左图中的蓝色箭头)。APT 是不能做到这一点的,因为 APT 发生在各 Module 的编译过程中,并不能打破 Module 间的依赖关系。这也是如此发达的 Spring 并没能干掉反射的原因。而 Android 在打包过程中有一个特殊的阶段:合成 APK。这时候,所有的类(.class) 对彼此都是可见的,与运行时一致。在这个阶段,我们可以修改字节码以实现 PluginConfig
对 Foo
的依赖。此时的依赖与运行时依赖是几乎等价的,并没有破坏组件化的隔离和封装。其次,我们还需要解决怎么才能把注册信息注入到对应的类中。这时候 Javassist 就登场了。
PluginConfig
为外部提供了一个注入点:doRegister
,Javassist 可以修改这个方法,使其调用所有的注册方法。这样透明的反向依赖就达成了。这也是 PluginConfig
单独存在的理由:尽量减少修改字节码的影响范围,方便 Debug。APT 收集信息
落实到编译流程。在业务 Module 编译过程中,我们先用 APT 收集各个 Module 中
@InvokeBy
的信息,生成了一个 JSON 文件保存映射关系放到 jar 包中。修改 PluginConfig
在合成 APK 时注册一个
Transform
,遍历每个 jar 包,按照 APT 生成的信息修改对应的 class 文件。插入一行代码,让 PluginConfig
调用 Factory
的 init
方法。这里插入的代码是由 Javassist 编译生成的,这样代码的编译期校验是仍然有效的。这个过程发生在合并 Apk 时,此时发生变化的只有处于 Framework 层的 PluginConfig
类的字节码。对于行数影响最小。
遇到的问题
过程中主要遇到了几个问题:InvokeBy 维护困难,热修复失效等等。
InvokeBy
希望表达的是让 AClass
的 aMethod
调用 BClass
的 bMethod
,其中 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 ,或者加我的微信:
网友评论