Dagger 到 Hilt 的迁移检查清单
「Dagger 到 Hilt 的迁移检查清单」
Hilt 发布已经四年多了,但直到今天,我打开 GitHub 上那些 2018 年以前创建的 Android 项目,Dagger 的 @Component 和 @Module 依然随处可见。迁移不是不想做,而是每次评估都觉得风险大于收益——直到某个版本升级后,Dagger 的 APT 处理时间把 CI 构建拖到了十五分钟,或者新入职的工程师在 ApplicationComponent 和 ActivityComponent 的依赖关系里迷路了三天。
这篇文章不是 Hilt 的入门教程,也不是鼓吹全面迁移的布道文。我想整理的是一套基于实际项目踩坑的检查清单,覆盖迁移前的评估、迁移中的具体步骤、以及迁移后那些官方文档不会告诉你的隐性成本。所有工具和经验都来自过去两年参与的两个中型项目迁移,以及社区里一些真实的技术讨论。
迁移前的硬指标:什么项目值得动
先说一个反直觉的判断:不是所有用 Dagger 的项目都应该迁到 Hilt。
2022 年我参与的一个电商项目,代码量约 35 万行,Dagger 2.28 配合自定义的 DaggerAndroid 辅助类。评估迁移时,我们列了三个硬指标:构建时间、新成员上手成本、以及未来架构方向。最终结论是暂缓迁移,原因是项目深度依赖 Dagger 的自定义组件生命周期(比如一个跨 Activity 的 SessionComponent),而 Hilt 1.0 时代的组件体系对此支持很差。这个决定后来被证明是对的——Hilt 1.2 之后才逐步完善了 @EntryPoint 和自定义组件的正式支持。
判断迁移价值的具体方法,我推荐用 Android Studio 的 Build Analyzer 做量化。打开 Build > Analyze Build,找到 Dagger 相关的 APT 任务,记录 kaptDebugKotlin 或 kaptReleaseKotlin 的耗时。如果单次增量构建中 Dagger 的注解处理占了总时间的 30% 以上,且项目模块数超过 20 个,Hilt 的聚合编译优化(aggregating task)通常能带来可感知的提升。Google 在 Hilt 的官方文档里提到过这个优化,但实际效果取决于你的模块依赖图结构——扁平化的模块结构收益最大,而深层嵌套的依赖树可能反而让 Hilt 的聚合器做更多工作。
另一个评估工具是 Dagger 的 dagger.reflect 调试模式。在 build.gradle 里加上 implementation 'com.google.dagger:dagger:2.x' 的反射后端版本,运行应用后观察组件创建时的反射调用栈。如果看到大量 Class.forName 和 Method.invoke 出现在性能热点路径,说明你的 Dagger 组件在运行时承担了过重的反射负担,Hilt 的编译时代码生成能根治这个问题。不过这个工具本身有显著性能开销,只应该在 debug 构建里短暂使用,不能当真性能基准。
官方迁移指南的盲区
Google 的 Hilt 迁移文档结构清晰,但有几个关键省略点。
第一个盲区是 @BindsInstance 的等价替换。Dagger 里常见的模式是在 ApplicationComponent.Builder 里用 @BindsInstance 注入 Application 实例,Hilt 的 @HiltAndroidApp 自动处理了 Application 注入,但如果你之前用 @BindsInstance 注入了其他早期可用对象(比如通过 ContentProvider 初始化的 SDK 句柄),迁移时会卡住。Hilt 没有公开的 @BindsInstance 等价物,官方推荐改用 @EntryPoint 在 Application 的 onCreate 里手动获取依赖。这个方案在语义上并不等价——@BindsInstance 保证对象在组件创建时即绑定,而 @EntryPoint 访问时组件可能尚未完全初始化。我们在迁移 Firebase Crashlytics 的初始化逻辑时踩过这个坑,Crashlytics 的 DataCollection 配置需要在组件构建前完成,最终被迫用了一个不优雅的 @EarlyEntryPoint 折中。
第二个盲区是子组件的迁移策略。Dagger 的 @Subcomponent 在 Hilt 里没有直接对应物,官方文档建议用 @EntryPoint 和 EntryPoints 工具类替代。但这里有个性能陷阱:每次调用 EntryPoints.get() 都会触发一次组件查找,如果放在 RecyclerView 的 onBindViewHolder 里,主线程开销不可忽视。我们的解决方案是封装一个 EntryPointAccessor 单例,在 Application 初始化时预取常用 EntryPoint 的弱引用,但这也引入了生命周期同步的复杂度。Hilt 2.44 之后新增的 @EntryPointAccessors 生成代码优化了这个路径,不过需要开启 enableAggregatingTask = true 才能生效。
第三个盲区是测试迁移。官方示例里的 HiltAndroidRule 和 @UninstallModules 看起来简洁,但大规模测试迁移时,你会发现 Dagger 的 DaggerMyComponent.builder().testModule(...).build() 模式在 Hilt 里无法直接复制。特别是那些依赖具体模块实例进行行为验证的测试,Hilt 的模块替换机制是类级别的 @UninstallModules 加 @InstallIn 的替代模块,不能注入动态配置的 mock。我们项目里有 200 多个这样的测试,最终用了一个社区方案:Hilt 的 CustomTestApplication 配合反射修改 ApplicationComponent 的内部模块集合。这个方案在 Hilt 2.40 之后因为内部 API 变动失效,不得不锁定 Hilt 版本,直到 2.46 引入的 @CustomTestApplication 正式支持模块动态替换。
迁移执行的顺序:从边缘到核心
实际迁移时,我强烈建议不要按模块顺序推进,而是按依赖图的边缘到核心推进。
具体做法是先找到 Dagger 组件图中入度为零的模块——通常是纯工具类提供器,比如 GsonModule、OkHttpModule。这些模块不依赖其他自定义类型,迁移风险最低。用 Hilt 的 @Module 和 @InstallIn(SingletonComponent::class) 重写后,验证单元测试和集成测试通过,再逐步向内层推进。
这个顺序的关键原因是 Hilt 和 Dagger 的组件可以共存。Hilt 的 @InstallIn 模块可以被 Dagger 的组件引用,反之亦然,只要注意 Hilt 生成的组件名和包路径。我们项目里有一个过渡阶段持续了六周,Application 层用 Hilt 的 @HiltAndroidApp,但几个核心 Service 组件仍然保留 Dagger 的 @Component 定义,通过 @EntryPoint 桥接。这种混合状态在编译期完全合法,但调试时容易混淆——Android Studio 的 "Navigate to Dagger Dependency" 功能(需要 Dagger 插件 2.40+)在混合项目中经常指向错误的组件源。
迁移过程中一个实用的辅助工具是 dagger-hilt-android-gradle-plugin 的 enableExperimentalClasspathAggregation 选项。这个实验性功能在 Hilt 2.42 之前是默认关闭的,开启后能让 Hilt 正确处理 api 依赖传递的模块,避免 "Module was compiled with an incompatible version of Hilt" 的诡异错误。不过它在多模块项目里有个已知问题:如果模块 A 的 api 依赖传递了 Hilt 模块到模块 B,而模块 B 没有直接应用 Hilt 插件,编译会静默失败而不是报错。我们花了两个下午排查这个问题,最终在 gradle.properties 里强制所有模块应用 Hilt 插件解决。
另一个执行层面的细节是 kapt 到 ksp 的切换时机。Hilt 从 2.44 开始实验性支持 KSP(Kotlin Symbol Processing),但直到 2.48 才标记为稳定。如果项目同时迁移 Dagger 到 Hilt 和 kapt 到 ksp,建议先做 Hilt 迁移,稳定后再切 KSP。两个变更叠加时,KSP 的错误提示比 kapt 模糊得多,我们遇到过 Hilt 生成的组件代码缺失,但 KSP 只报 "Cannot find symbol" 而没有任何 Hilt 相关的诊断信息。JetBrains 的 KSP issue tracker 里 #1278 和 #1423 记录了这类问题,目前没有完全修复。
具体工具推荐:迁移辅助与验证
迁移过程中我依赖几个具体工具,这里详细说它们的用法和局限。
第一个是 dagger-reflect,前面提到过,但值得展开。这个库在 Maven Central 的坐标是 com.google.dagger:dagger-reflect:2.x,注意版本必须和主 Dagger 库一致。它的核心用途是在不重新编译的情况下验证 Dagger 绑定图的完整性——开启反射模式后,所有依赖注入在运行时通过反射解析,能暴露编译期被代码生成掩盖的循环依赖和缺失绑定。但局限也很明显:启动性能极差,我们的项目在反射模式下冷启动时间从 1.2 秒涨到 8 秒;而且它不兼容 Hilt 的生成代码,只能用于迁移前的 Dagger 基线验证。官方文档里把它标记为 "experimental and not for production use",实际体验下来,甚至连长期开发分支集成都不建议,只在需要快速验证绑定图结构时临时启用。
第二个工具是 hilt-android-testing 里的 HiltTestApplication。这个类在测试源集里自动生成,但生成规则有坑:如果 src/test 和 src/androidTest 同时存在,且都声明了 @HiltAndroidTest,Gradle 的源集合并可能产生重复的 HiltTestApplication 定义。解决方式是在 build.gradle 里显式指定 testApplicationId 和 testInstrumentationRunnerArguments,把两个测试域的生成类隔离到不同包。这个配置在 Hilt 2.45 之前的文档里没有提及,我是在 Square 的一个开源项目 workflow-kotlin 的构建脚本里找到的参考实现。
第三个工具是社区维护的 dagger-hilt-migration 辅助脚本,GitHub 地址是 https://github.com/ashley-figueiredo/dagger-hilt-migration。这个脚本不是官方项目,但作者维护得比较活跃,支持把 Dagger 的 @Component 接口自动转换为 @EntryPoint 定义。实际用下来,它能处理大约 70% 的机械转换,但遇到自定义组件作用域(比如 @PerSession)时会生成无效代码,需要手动调整。更重要的是,它不会分析组件间的依赖关系,可能把原本通过 dependencies = [...] 声明的组件父子关系,错误地扁平化为同一作用域的 EntryPoint。我的建议是用它做第一轮草稿,然后必须人工审查每个生成的 @EntryPoint 的 @InstallIn 目标是否正确。
第四个工具是 Android Studio 的 Dagger 插件,内置在 Android Studio Flamingo 及以后版本。这个插件能在编辑器里直接显示依赖提供路径,对理解复杂的 Hilt 生成代码很有帮助。但它对混合 Dagger/Hilt 项目的支持有缺陷:如果同一个 Kotlin 文件里同时存在 Dagger 的 @Inject 和 Hilt 的 @AndroidEntryPoint,插件的 "Show Dagger Dependency" 菜单会随机指向其中一个框架的生成代码,而不是根据实际使用的组件类型区分。Google Issue Tracker 的 #298437126 记录了这个问题,目前状态是 "Won't Fix (Infeasible)",因为插件的静态分析无法区分运行时的实际注入路径。
迁移后的隐性成本:那些没人提的维护负担
迁移完成后,真正的挑战才开始。
Hilt 的编译错误信息质量是个长期痛点。Dagger 的编译期错误通常直接指出缺失绑定的类型和请求位置,而 Hilt 的错误信息会经过多层生成代码的包装。一个典型的例子:如果 @Inject 构造函数的某个参数类型没有绑定,Dagger 报错 "Cannot find binding for com.example.Foo",Hilt 可能报错 "Cannot find symbol: DaggerApplicationComponent_HiltModules_SingletonC" 然后把原始错误埋在十层 cause 链之下。Hilt 2.47 之后引入了 enableAggregatingTask 的改进错误聚合,但实际效果取决于 Gradle 版本——Gradle 8.0 之前的错误格式化会让 cause 链截断,关键信息丢失。
另一个隐性成本是 Hilt 的组件生命周期与 Android 组件生命周期的绑定假设。Hilt 的 @ActivityRetainedScoped 对应 ViewModel 的生命周期,但 ViewModel 的 onCleared() 和 Hilt 组件的销毁时机并不严格同步。我们在一个视频播放模块里遇到过:用 @ActivityRetainedScoped 绑定的 ExoPlayer 实例,在配置变更后的 ViewModel 重建时,Hilt 提供了新组件实例,但旧的 ExoPlayer 资源释放延迟导致内存泄漏。最终排查发现是 Hilt 2.43 的一个已知行为:组件销毁只保证在 onCleared() 之后触发,但不保证立即触发。解决方案是改用 @Singleton 作用域配合自定义的引用计数,但这违背了使用 @ActivityRetainedScoped 的初衷。
Hilt 的 Gradle 插件与 Kotlin 版本兼容性也是持续维护点。2023 年 Kotlin 1.9 发布时,Hilt 2.46 的插件存在 ClassCastException: KaptContext$ITM 的崩溃,阻塞了 Kotlin 升级两周,直到 Hilt 2.46.1 紧急修复。这类问题在 Dagger 时代很少见,因为 Dagger 的 Gradle 插件功能极简,而 Hilt 插件深度参与模块依赖分析和生成代码的 classpath 聚合,与 Kotlin Gradle 插件的内部 API 耦合更紧密。现在我们的升级流程是:Kotlin 新版本发布后,先检查 Hilt 的 GitHub release notes 和 dagger/issues 标签,确认无阻塞 issue 再推进。
一个具体的回滚决策案例
最后说一个我们最终没有完成迁移的项目,作为反例。
这是一个 2017 年启动的 SDK 项目,对外暴露的 API 包含 Dagger 的 @Component 接口作为扩展点。评估迁移时发现,Hilt 的 @EntryPoint 不能替代这个设计——@EntryPoint 需要 EntryPoints.get() 调用,而 SDK 的扩展点要求调用方实现接口并传入实例。我们尝试过用 @EntryPoint 包装一层适配器,但生成的代码体积增加了 18%,对于 SDK 来说不可接受。另一个方案是保留 Dagger 的公开 API,内部实现用 Hilt,但这样需要维护两套组件系统,复杂度不降反升。
最终决策是冻结 Dagger 版本在 2.45,不再迁移。这个决策的代价是错过了 Hilt 的测试便利性和编译优化,但保住了 API 兼容性。这个案例想说明的是:技术迁移的终点不是"全部用上最新框架",而是"当前架构能可持续地满足业务需求"。Hilt 在很多场景下是更好的默认选择,但"默认"不等于"唯一正确"。
如果你正在评估自己的项目是否迁移,建议先花半天时间用 Build Analyzer 量化构建瓶颈,再用 dagger-reflect 验证绑定图复杂度,然后对照上面提到的几个盲区检查是否有阻塞性的自定义组件需求。迁移本身的技术工作通常只占总成本的 40%,测试修复和后续维护才是大头,这个预期要在一开始就校准好。