10、性能优化与避坑指南:反射性能开销、ProGuard混淆规则、多模块处理
反射和注解处理器,说白了就是两把双刃剑。用好了,能写出很优雅的框架代码;用不好,性能崩了、混淆炸了、多模块编译卡死,哪个都够你喝一壶的。
我这些年踩过的坑,十个手指头都数不过来。今天就把这些血泪教训整理出来,希望能帮你少走弯路。
10.1 反射的性能开销到底有多大?
很多人一听到反射就摇头,说它慢。但到底慢多少?我直接说结论:反射比直接调用慢 10 到 100 倍。但这只是裸调用的数据。
为什么会这样?因为反射要干这些额外的事:
- 安全检查:每次调用都要检查访问权限
- 方法查找:通过名称字符串去匹配方法
- 装箱拆箱:基本类型和对象类型来回转换
- JIT 优化失效:反射调用很难被 JIT 内联优化
我在项目中遇到过这样一个场景:一个列表页,每行数据都要通过反射去读取字段值。列表只有 20 条数据,但用户反馈滑动卡顿。一分析,好家伙,一次反射调用花了 0.5 毫秒,20 条就是 10 毫秒,再加上布局渲染,帧率直接掉到 40 帧以下。
核心结论:反射不是不能用,而是不能高频调用。单次调用无所谓,循环里用反射就是灾难。
10.2 反射性能优化三板斧
嗯,这里要注意,优化反射性能其实有套路。我总结了三招:
第一招:缓存 Method 和 Field 对象
很多人每次调用反射都去 getDeclaredMethod(),这是最笨的做法。方法查找本身就很慢,应该只查一次,然后缓存起来。
// ❌ 错误做法:每次调用都查找方法
fun callMethod(obj: Any, name: String) {
val method = obj.javaClass.getDeclaredMethod(name)
method.invoke(obj)
}
// ✅ 正确做法:缓存 Method 对象
object ReflectCache {
private val methodCache = ConcurrentHashMap<String, Method>()
fun callMethod(obj: Any, name: String) {
val key = "${obj.javaClass.name}#$name"
val method = methodCache.getOrPut(key) {
obj.javaClass.getDeclaredMethod(name).also { it.isAccessible = true }
}
method.invoke(obj)
}
}
我做过测试,加了缓存之后,反射调用的耗时从 0.5ms 降到了 0.02ms,基本可以忽略不计了。
第二招:用 MethodHandle 替代反射
Java 7 引入了 MethodHandle,它的性能比传统反射好很多。说白了,它更接近底层,跳过了很多安全检查。
val lookup = MethodHandles.lookup()
val methodHandle = lookup.findVirtual(
MyClass::class.java,
"myMethod",
MethodType.methodType(String::class.java, Int::class.java)
)
// 调用时直接 bind 到对象
val result = methodHandle.bindTo(myObj).invoke(42)
我个人的习惯是:如果项目里反射调用很频繁,优先考虑 MethodHandle。它的性能大约是传统反射的 3-5 倍。
第三招:用注解处理器生成代码,彻底避免反射
这是终极方案。你想想看,既然反射慢,那干脆就别用反射了。在编译期就把代码生成好,运行时直接调用。
像 ButterKnife、Dagger、Room 这些框架,早期版本都依赖反射,后来全部改成了注解处理器 + 代码生成。为什么?因为反射在低端机上真的太慢了。
我的建议:框架代码可以用反射,但应用层代码尽量用代码生成。如果非要用反射,记得加缓存。
10.3 ProGuard 混淆规则:反射的噩梦
我曾经被混淆坑过一次,印象特别深。线上版本发布后,某个页面打开就崩溃,报的是 NoSuchMethodException。查了半天,发现是混淆把方法名给改了,反射里写死的字符串找不到对应的方法了。
从那以后,我养成了一个习惯:所有涉及反射的类和方法,必须写混淆规则。
常见的混淆规则模板
# 保留被反射调用的类
-keep class com.example.reflection.** { *; }
# 保留被反射调用的方法
-keepclassmembers class * {
@com.example.annotations.MyAnnotation <methods>;
}
# 保留注解本身
-keep @interface com.example.annotations.*
# 保留泛型信息(某些反射需要)
-keepattributes Signature
# 保留注解信息
-keepattributes *Annotation*
这里有个细节要注意:-keep 和 -keepclassmembers 的区别。前者连类名一起保留,后者只保留类成员。如果你只是通过 Class.forName() 加载类,那必须用 -keep,否则类名被混淆了就找不到了。
| 场景 | 推荐规则 | 说明 |
|---|---|---|
| 通过类名字符串反射 | -keep class |
保留类名和方法名 |
| 通过注解反射 | -keepclassmembers |
只保留带注解的成员 |
| 序列化/反序列化 | -keepclassmembers |
保留字段名和构造方法 |
| 动态代理 | -keep class |
保留接口和实现类 |
警告:不要图省事写 -keep class **,这会禁用所有混淆,APK 体积会变大。精准保留才是正道。
10.4 多模块下的注解处理器配置
多模块项目里,注解处理器的配置比单模块复杂得多。我刚开始做模块化的时候,就踩过一个大坑:注解处理器在模块 A 里生成了代码,但模块 B 引用不到。
为什么会这样?因为每个模块的注解处理器是独立运行的。模块 A 的处理器只处理模块 A 的源文件,生成的文件也只放在模块 A 的 build 目录下。模块 B 根本看不到。
解决方案:模块间通信
我常用的方案有两种:
方案一:公共模块 + SPI 机制
把所有注解和处理器接口放在一个公共模块里,其他模块依赖它。处理器生成的代码通过 SPI 注册,运行时统一加载。
// 公共模块:定义注解
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class MyBindView(val layoutId: Int)
// 公共模块:定义接口
interface IViewBinder {
fun bind(host: Any)
}
// 处理器模块:生成实现类
// 生成代码示例:
// class MainActivity_ViewBinder : IViewBinder {
// override fun bind(host: Any) {
// val activity = host as MainActivity
// activity.textView = activity.findViewById(R.id.text)
// }
// }
方案二:聚合模块统一处理
在 App 模块里配置注解处理器,让它扫描所有子模块的源文件。但这种方式有个缺点:子模块的源文件路径需要额外配置,而且编译速度会变慢。
我的经验:如果项目模块数少于 10 个,用方案一就够了。超过 10 个模块,建议用方案二,但要做好增量编译的优化。
10.5 多模块编译性能优化
注解处理器在多模块下还有一个问题:编译时间。每个模块的处理器都要跑一遍,如果处理器逻辑复杂,编译时间会成倍增加。
我遇到过最夸张的情况:一个 20 个模块的项目,全量编译要 8 分钟,其中注解处理器占了 5 分钟。
优化方法其实不复杂:
- 增量编译:确保你的处理器支持增量编译。在
build.gradle里加上annotationProcessorOptions.incremental = true - 缓存中间结果:如果处理器需要扫描所有类,把扫描结果缓存起来,下次只处理变更的文件
- 并行处理:多模块之间可以并行运行处理器,在
gradle.properties里设置org.gradle.parallel=true
// build.gradle.kts (模块级别)
android {
defaultConfig {
javaCompileOptions {
annotationProcessorOptions {
arguments["incremental"] = "true"
arguments["moduleName"] = project.name
}
}
}
}
10.6 避坑指南:我踩过的那些坑
最后,分享几个我亲身踩过的坑,希望能帮你避开:
坑一:混淆后注解丢失
我曾经在混淆配置里忘了保留注解信息,结果运行时 isAnnotationPresent() 永远返回 false。排查了一整天才发现是 -keepattributes *Annotation* 没写。
坑二:多模块下处理器重复执行
每个模块的处理器都会独立运行,如果处理器里有全局扫描的逻辑,会被执行多次。我后来加了一个 processedModules 集合,记录已经处理过的模块,避免重复工作。
坑三:反射调用私有方法
在 Java 里,通过 setAccessible(true) 可以调用私有方法。但在 Android 9 以上,系统对 setAccessible 做了限制,会抛出 InaccessibleObjectException。解决方案是改用 MethodHandle 或者干脆把方法改成 public。
10.7 本章小结
反射和注解处理器,用好了是利器,用不好是坑。我个人的经验是:能编译期做的事,绝不拖到运行时。注解处理器虽然配置麻烦一点,但换来的性能提升是值得的。
至于混淆规则,我的建议是:每写一个反射调用,就顺手把混淆规则写上。别等到线上崩溃了再补,那时候就晚了。
多模块的处理,核心就是「隔离」和「通信」四个字。模块之间通过接口通信,处理器各自为政,互不干扰。
好了,这一章的内容就到这里。希望这些经验能帮你少踩几个坑。
公众号:蓝海资料掘金营,微信deep3321