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 倍。

第三招:用注解处理器生成代码,彻底避免反射

这是终极方案。你想想看,既然反射慢,那干脆就别用反射了。在编译期就把代码生成好,运行时直接调用。

ButterKnifeDaggerRoom 这些框架,早期版本都依赖反射,后来全部改成了注解处理器 + 代码生成。为什么?因为反射在低端机上真的太慢了。

我的建议:框架代码可以用反射,但应用层代码尽量用代码生成。如果非要用反射,记得加缓存。

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