多模块项目中的注解处理:跨模块符号解析与依赖管理

说实话,多模块项目里的注解处理,是很多Android开发者绕不过去的一道坎。我自己刚接触时也踩过不少坑——明明在单模块里跑得好好的,一拆成多模块,注解处理器要么找不到符号,要么依赖顺序乱成一团。今天我就把这块的经验掰开揉碎,跟你聊聊。

为什么多模块下注解处理会出问题?

先想一个问题:你在模块A里定义了一个注解,模块B里用这个注解标记了一个类,模块C想通过注解处理器拿到模块B的信息。嗯,这里就有问题了——注解处理器是编译时运行的,它只能看到当前模块的源码和依赖。模块C的处理器,默认是看不到模块B的符号的。

我遇到过最典型的情况:一个路由框架,每个模块都注册自己的页面,结果处理器死活拿不到其他模块的注解信息。说白了,就是跨模块符号不可见

核心矛盾:注解处理器在编译期工作,而多模块的编译是分步进行的。每个模块编译时,其他模块可能还没编译完,或者根本不在当前编译上下文中。

跨模块符号解析的三种方案

我个人习惯把方案分成三类,你可以根据项目规模选。别一上来就搞最复杂的,先看看够不够用。

方案一:聚合式处理(最简单)

把所有模块的注解信息,通过中间产物(比如JSON文件)聚合到主模块,然后在主模块的处理器里统一处理。

// 模块A的处理器:生成中间文件
@AutoService(Processor::class)
class ModuleAProcessor : AbstractProcessor() {
    override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {
        // 扫描注解,生成 module_a_routes.json
        val json = buildJson { ... }
        val file = filer.createResource(StandardLocation.CLASS_OUTPUT, "", "module_a_routes.json")
        file.openWriter().use { it.write(json) }
        return true
    }
}

// 主模块的处理器:读取所有中间文件
class MainProcessor : AbstractProcessor() {
    override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {
        // 从 classpath 中扫描所有 module_*_routes.json
        val allRoutes = mutableListOf<RouteInfo>()
        val resources = processingEnv.filer.getResource(StandardLocation.CLASS_OUTPUT, "", "module_a_routes.json")
        // ... 解析合并
        return true
    }
}

这个方案我用了很久,优点是简单直接,缺点也很明显——你得自己定义中间文件格式,而且每个模块都得配一个处理器。

方案二:编译期依赖注入(进阶)

如果你用的是KSP(Kotlin Symbol Processing),可以利用它的多轮处理特性。KSP允许你在不同轮次中逐步收集符号,最终合并。

class CrossModuleProcessor(private val symbolProcessorProvider: SymbolProcessorProvider) : SymbolProcessor {
    private val collectedSymbols = mutableListOf<KSAnnotated>()

    override fun process(resolver: Resolver): List<KSAnnotated> {
        val symbols = resolver.getSymbolsWithAnnotation("com.example.Route")
        collectedSymbols.addAll(symbols)

        // 如果还有未处理的符号,返回它们,下一轮继续
        return symbols.filter { /* 判断是否已处理 */ }.toList()
    }
}

这里有个坑:多轮处理不是无限循环的。KSP默认最多跑4轮,如果4轮后还有未处理的符号,就会报错。我曾经因为这个排查了半天,最后发现是某个模块的编译顺序有问题。

方案三:编译期服务注册(推荐)

我个人最推荐的方式——利用javax.annotation.processing.Processor的服务注册机制,配合Gradle的annotationProcessor配置,让每个模块的处理器自动注册到主模块的classpath上。

// 每个模块的 build.gradle.kts
dependencies {
    // 主模块的处理器依赖所有子模块的处理器
    annotationProcessor(project(":module-a"))
    annotationProcessor(project(":module-b"))
}

// 主模块处理器通过 ServiceLoader 加载所有子模块的处理器
class MainProcessor : AbstractProcessor() {
    override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {
        val processors = ServiceLoader.load(SubProcessor::class.java, processingEnv.filer.javaClass.classLoader)
        processors.forEach { subProcessor ->
            subProcessor.process(roundEnv)
        }
        return true
    }
}
小技巧:如果你用KSP,可以配合gradle.properties里的ksp.incremental=true开启增量编译,能大幅减少重复处理的时间。

依赖管理:别让顺序坑了你

多模块的依赖顺序,说白了就是谁先编译,谁后编译。注解处理器如果依赖了某个模块的符号,那个模块就必须先编译完。

我遇到过最头疼的问题:模块B的处理器依赖模块A生成的代码,但Gradle的依赖图里模块A又依赖模块B的API。这就形成了循环依赖,编译直接挂掉。

如何避免循环依赖?

依赖类型 解决方案 我踩过的坑
编译期依赖 使用annotationProcessor而非implementation implementation导致运行时也依赖,编译顺序乱套
符号依赖 将公共注解和接口抽到:core模块 一开始没抽,结果每个模块都定义了自己的注解,合并时崩溃
生成代码依赖 使用compileOnly + 延迟加载 直接依赖生成代码,结果生成代码还没生成,编译就开始了

Gradle配置的最佳实践

// 公共模块:只放注解和接口
dependencies {
    // 不需要任何处理器依赖
}

// 业务模块A:使用注解,但不处理
dependencies {
    implementation(project(":core"))
    // 处理器只在编译期生效
    annotationProcessor(project(":processor-module"))
}

// 处理器模块:依赖公共模块的注解
dependencies {
    implementation(project(":core"))
    // 不要依赖任何业务模块!
}
注意:处理器模块绝对不能依赖业务模块。否则业务模块编译时,处理器模块还没编译完,就会报ClassNotFoundException。我曾经因为这个被坑了一整天,最后发现是依赖写反了。

实战:跨模块路由注册

来,我们看一个完整的例子。假设我们要做一个跨模块的路由框架,每个模块注册自己的页面,主模块统一管理。

// :core 模块 - 定义注解
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class Route(val path: String)

// :module-a 模块 - 使用注解
@Route("/module-a/home")
class ModuleAHomeActivity : AppCompatActivity()

// :module-b 模块 - 使用注解
@Route("/module-b/detail")
class ModuleBDetailActivity : AppCompatActivity()

// :processor 模块 - 注解处理器
@AutoService(Processor::class)
class RouteProcessor : AbstractProcessor() {
    override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {
        val routes = mutableListOf<RouteInfo>()
        roundEnv.getElementsAnnotatedWith(Route::class.java).forEach { element ->
            val route = element.getAnnotation(Route::class.java)
            routes.add(RouteInfo(route.path, element as TypeElement))
        }
        // 生成路由表文件
        generateRouteTable(routes)
        return true
    }
}

// 主模块 - 加载所有路由
class MainApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // 通过反射加载所有模块生成的路由表
        val routeTables = loadRouteTables()
        Router.init(routeTables)
    }
}

这个架构里,每个模块的处理器只处理自己模块的注解,生成独立的文件。主模块在运行时通过反射加载所有文件。这样既避免了编译期依赖,又实现了跨模块符号解析。

SVG:跨模块注解处理流程

跨模块注解处理流程 模块A @Route("/a/home") ActivityA 模块B @Route("/b/detail") ActivityB 模块C @Route("/c/setting") ActivityC 注解处理器(编译期) 扫描 @Route 注解 → 生成中间文件(JSON/代码) module_a_routes.json module_b_routes.json module_c_routes.json 主模块:合并所有路由表 → 生成最终路由注册代码

这张图展示了整个流程:每个模块独立编译,处理器生成中间文件,最后主模块合并。注意看虚线箭头——那是编译期生成的中间产物,不是运行时才产生的。

避坑指南

最后,我把自己踩过的坑整理一下,你遇到了可以直接翻出来看:

  • 不要用implementation依赖处理器——用annotationProcessor或ksp,否则处理器会被打包进APK,而且编译顺序会乱。
  • 中间文件命名要唯一——我见过两个模块都生成routes.json,结果后一个覆盖了前一个。建议用module_{name}_routes.json这种格式。
  • 注意增量编译——KSP的增量编译默认是关闭的,记得在gradle.properties里加上ksp.incremental=true
  • 处理器不要依赖业务代码——处理器只依赖公共模块的注解和接口,否则会出现循环依赖。

嗯,多模块的注解处理,说白了就是解耦顺序两个问题。解耦靠中间产物,顺序靠依赖配置。把这两点想清楚,大部分问题都能迎刃而解。


公众号:蓝海资料掘金营,微信deep3321