ButterKnife无反射优化
ButterKnife真是个让人又爱又恨的库,在Android技术混沌初开的年代,JakeWharton大神靠一己之力通过ButterKnife解放了无数人写findViewById的双手,我接触超过90%的Android项目都曾用过他。但随着项目越来越大,这个几乎作为Android开发标配的开源库又成为了略显鸡肋、性能堪忧的绊脚石。比如解析麻烦、管理混乱的R2设计,以及性能和安全设计都拙急的ViewBinder反射机制。
这里简单介绍下ButterKnife的原理,来引述ButterKnife坑爹的反射机制以及安全问题。
Principle
当我们一般在Viewer Class内使用ButterKnife来快速bind view。
public class FooViewer {
@Bind(R.id.tv_title)
View mTvTitle;
public void init() {
ButterKnife.bind(this, actualAndroidView);
}
}
编译时,会通过APT生成ViewBinding对象,在这个对象里,你可以看到真正的findViewById方法。
public class FooViewer_ViewBinding implements Unbinder {
public FooViewer_ViewBinding(final FooViewer target, View source) {
target.mTvTitle = (TextView) view.findViewById(R.id.tv_title);
}
}
然后,只需要在调用 ButterKnife.bind(target, actualAndroidView)
时,找到这个FooViewer_ViewBinding便能bind上。
所以,问题就来到了,怎么找到FooViewer_ViewBinding呢?
很简单,ButterKnife就是给你的类名加上"_ViewBinding",然后反射即可。
让我们看看ButterKnife的bind函数是怎么做的,
public static Unbinder bind(@NonNull Object target, @NonNull View source) {
Class<?> targetClass = target.getClass();
if (debug) Log.d(TAG, "Looking up binding for " + targetClass.getName());
// 寻找target对应的ViewBinding class
Constructor<? extends Unbinder> constructor = findBindingConstructorForClass(targetClass);
...
}
而在findBindingConstructorForClass里,你就会发现他的原理有多么简单,且坑!
private static Constructor<? extends Unbinder> findBindingConstructorForClass(Class<?> cls) {
Constructor<? extends Unbinder> bindingCtor = BINDINGS.get(cls);
...
try {
// 这里直接用className 加上后缀,就是对应ViewBinding的class了
Class<?> bindingClass = cls.getClassLoader().loadClass(clsName + "_ViewBinding");
//noinspection unchecked
bindingCtor = (Constructor<? extends Unbinder>) bindingClass.getConstructor(cls, View.class);
} catch (ClassNotFoundException e) {
// !!! 如果没有找到,就从target的父类继续找 !!!
bindingCtor = findBindingConstructorForClass(cls.getSuperclass());
}
...
return bindingCtor;
}
Problem
现在,你知道问题所在了吧。
-
安全性问题
按照这个如此简陋的名称匹配规则,为了反射到viewer对应的viewBinding,需要keep住viewer的className。我们一般会赋予viewer有意义的名称,如果不混淆,很容易暴露出逻辑意义,让黑产找到突破口。
笔者当年做过一款金融app,金融类app对安全的要求甚至包括包名需要完全混淆,因此不得不放弃ButterKnife。
同时过多的keep也会带来包体积增大的问题。
-
性能问题
通过上述源码,可以看到butterknife需要通过反射找到viewBinding,findclass就是一个很慢的过程。同时,当没找到target对应的viewBinding时,会递归的从其父类继续找,这样如果继承层级深了,又会造成多次反射。
另外,找到viewBinding class后还需要通过反射来为其实例化,这也是个不小的性能开销。
对于简单的app来说,多一两次反射不能称之为“问题”;而对快手来说,在mvps架构下,一个页面可能有上千个presenter(比如播放页面),然后presenter还有复杂的继承关系,每次打开新页面,极端情况下能带来数千次的反射。
根据我们19年的统计,ButterKnife带来的ANR达到x%(数据脱敏,反正很高),有些团队去掉presenter里的butterknife后,页面性能提升了一倍,可见问题有多严重。
(每个Presenter,都因为ButterKnife的冗余反射,带来数百毫秒的性能损失)
那么,有没有办法既能继续使用ButterKnife,又能解决安全和性能问题呢?
Solution
通过对ButterKnife的原理分析,我们知道ButterKnife是为了找到ViewBinding,才带来了有缺陷的反射机制。所以,只要换个方式去找ViewBinding不就完了?
假如我们这样设计:
扩充一个接口,
public interface ViewBindingProvider {
@Nullable
Unbinder getBinder(@NonNull Object target, @NonNull View source);
}
让我们的viewer实现它来返回真正的viewBinding,bind的时候,调用这个接口的getBinder方法不就拿到了吗?
public class FooViewer implement ViewBindingProvider {
@Bind(R.id.tv_title)
View mTvTitle;
Unbinder getBinder(@NonNull Object target, @NonNull View source) {
return new FooViewer_ViewBinding();
}
}
而刚才的ButterKnife.bind函数也可以换成无反射写法
public static Unbinder bind(@NonNull Object target, @NonNull View source) {
if (target instanceof ViewBindingProvider) {
Unbinder unbinder = ((ViewBindingProvider) target).getBinder(target, source);
if (unbinder != null) {
return unbinder;
}
} else {
return Unbinder.EMPTY;
}
}
当然肯定不能要求大家手动为每个viewer实现这个接口,这里我们就要祭出ASM大法了,编译期直接通过字节码技术,添加 ViewBindingProvider
接口,同时实现 getBinder
方法。
ASM
借用 Asuka 框架,我可以很简单的实现这个功能。
在Android里首先需要创建一个Transform,在里面对class做ASM操作即可 (以下为伪代码)
class ViewBindingInjectSubTransform(project: Project) : ParallelTransform(project) {
private val mUseButterKnifeClass = CopyOnWriteArrayList<String>()
// 这里我们先编译所有class,找到所有后缀为 _ViewBinding 的class,去掉后缀,是不是就是原来的viewer class呢?
override fun preProcessFile(inputFileEntity: FileEntity, input: InputStream?, status: Status) {
if (inputFileEntity.name.endsWith("_ViewBinding.class")) {
val target = inputFileEntity.relativePath.removeSuffix("_ViewBinding.class")
mUseButterKnifeClass.add(target)
}
}
// 然后我们遍历所有的class,匹配刚才找到的需要使用butterknife的class,使用ASM进行修改
override fun processFile(inputFileEntity: FileEntity, input: InputStream?, output: OutputStream?, status: Status): Boolean {
val className = inputFileEntity.name
if (className.endsWith("class")
&& mUseButterKnifeClass.contains(inputFileEntity.relativePath.removeSuffix(".class"))
) {
val classReader = ClassReader(input.readBytes())
val classWriter = ClassWriter(ClassWriter.COMPUTE_MAXS)
// 添加ButterKnife ViewBindingProvier接口的ClassVisitor
val classVisitor = ButterKnifeClassVisitor(classWriter)
classReader.accept(classVisitor, ClassReader.EXPAND_FRAMES)
val code = classWriter.toByteArray()
output.write(code)
return true
}
return false
}
}
class ButterKnifeClassVisitor(classVisitor: ClassWriter): ClassVisitor(Opcodes.ASM5, classVisitor) {
private var mNeedInsert = true
override fun visit(
version: Int,
access: Int,
name: String,
signature: String?,
superName: String?,
interfaces: Array<out String>?
) {
val provider = "butterknife/ViewBindingProvider"
// 给原来的class添加一个 butterknife/ViewBindingProvider 接口
super.visit(version, access, name, signature, superName, interfaces + provider)
// 获取class的名字 方便后面创建对象
mName = name ?: ""
}
override fun visitEnd() {
insertGetBinderMethod(cv as ClassWriter)
}
private fun insertGetBinderMethod(classWriter: ClassWriter) {
// 得到 xxx_ViewBinding 的 class name
val viewBindingName = mName + "_ViewBinding"
val params = "(L$mName;Landroid/view/View;)V"
// 为class创建一个getBinder方法,然后方法里实现返回 xxx_viewBinding 实例
val mv = classWriter.visitMethod(Opcodes.ACC_PUBLIC, "getBinder",
"(Ljava/lang/Object;Landroid/view/View;)Lbutterknife/Unbinder;",
null, null)
mv.visitCode()
mv.visitTypeInsn(Opcodes.NEW, viewBindingName)
mv.visitInsn(Opcodes.DUP)
mv.visitVarInsn(Opcodes.ALOAD, 1)
mv.visitTypeInsn(Opcodes.CHECKCAST, mName)
mv.visitVarInsn(Opcodes.ALOAD, 2)
mv.visitMethodInsn(Opcodes.INVOKESPECIAL, viewBindingName,
"<init>", params, false)
mv.visitInsn(Opcodes.ARETURN)
mv.visitMaxs(4, 3)
mv.visitEnd()
}
}
完整的实现详见 https://git.corp.kuaishou.com/android/kwai-butterknife
Result
记得19年底的时候,凯哥跟我说ButterKnife ANR很多,包括X3的沈老爷等一些同学深受其苦,也组织了一波去Butterknife的工作。但是喜欢ButterKnife的同学也很多,同时ButterKnife侵入量巨大, 想要完全去除也不可能。
那时候我正好为编译优化写了个Asuka框架,拿来实验性的优化了一波。后续看到由ButterKnife造成的ANR完全消失了,效果非常明显。不过为了稳定性,没有完全替换。现在一年过去了也没报出什么问题,因此决定在春节以后直接全量上线。
用同样的原理,我们还能解决EventBus等相似的反射问题。所以哪位同学要是有兴趣的话,可以把EventBus也改一波。
至于为什么19年就做了的优化,现在才写文章介绍,当然是为了“招队友!!!”
应用研发-基础架构-效率工具 求志同道合的朋友们加入,跟我们一起服务于快手这大几百号开发老爷。
包括:
- 编译优化编译优化编译优化
- 新技术拓展和布道
- 效率工具设计研发
- 插件化技术开疆辟土
- 在开发群里当客服,需要一颗坚强的心(认真脸)