依赖注入简介与Hilt概述

聊 Hilt 之前,得先聊聊依赖注入(DI)到底在解决什么问题。说白了,就是「我要用一个东西,不用自己 new」。你在 Activity 里要弹 Toast,调用 Toast.makeText() 就行——Android 系统已经把对象准备好了。但换成网络库、数据库、Repository,你就得自己管生命周期,对吧?手动 new 没毛病,但项目一大了,你会发现自己成了「人肉对象工厂」。

手动依赖注入 —— 年轻时候的硬编码

我刚入行时写过这样的代码:

class LoginViewModel {
    private val api = ApiService()
    private val repo = LoginRepository(api)

    fun login() {
        repo.login(...)
    }
}

一看就懂,对吧?但问题是:LoginViewModel 和 ApiService、LoginRepository 高度耦合。单元测试你得先 new 三个对象,mock 起来特别麻烦。更难受的是:一旦 ApiService 构造函数变了,所有 new 过它的地方都得改。我曾经维护过一个 30 多人的项目,光改个构造函数参数就改吐了。

DI 的核心价值

依赖注入本质就是「控制反转」—— 对象不要自己创建依赖,交给外部容器注入。好处很实在:

  • 解耦:View 只关心业务,不关心依赖怎么来的
  • 可测试:替换成 Mock 对象非常方便
  • 生命周期管理:单例、作用域等不用手写

一句话总结:DI 让我们把「对象怎么来」和「对象怎么用」彻底分开。

从 Dagger 到 Hilt —— 为什么需要「Hilt 注入隐藏开销」这个课程?

Android 生态里,Dagger 是最成熟的 DI 方案,但学习曲线陡峭。你想想看,声明 @Module、@Component、@Subcomponent,还要自己创建 Component 并管理生命周期……我当年学 Dagger 2 时,光一个「图依赖」的概念就想了两天。

Google 也意识到了这个问题,所以在 2020 年推出了 Hilt。它在 Dagger 基础上封装了一套 Android 专用的注解和组件,开发者只要加几个注解,剩下的事交给编译期代码生成。

但问题来了:这种「零样板代码」的背后,隐藏了哪些运行时开销? 很多人以为 Hilt 只是编译时代价,运行时跟手动 new 差不多。我最初也是这么想的,直到在一次线上卡顿排查中发现,某个页面初始化时 Hilt 的依赖图解析占了 30ms — 这就是隐藏开销。这门课的目的,就是把这些细节翻出来,一个一个看透。

Hilt 怎么帮你管理对象?—— 一张图看懂结构

先别急着写代码,要理解 Hilt,得先看懂它背后的组件树。我画了个图,把核心节点和关系标出来了:

Hilt 在 Android 中的组件树与注入流程 @HiltAndroidApp SingletonComponent ActivityComponent FragmentComponent ViewComponent @AndroidEntryPoint
Activity @Inject 构造 @Module/@Provides 注入流向:Activity/Fragment 通过 @AndroidEntryPoint 关联对应组件,组件从父组件(SingletonComponent)获取单例, @Inject 和 @Module 提供具体实例。

Hilt 的核心注解 —— 一张表快速过

注解 作用 使用位置
@HiltAndroidApp 触发 Hilt 代码生成,创建 SingletonComponent Application 类
@AndroidEntryPoint 为 Activity/Fragment/View 生成注入入口 Activity, Fragment, View, Service 等
@Inject 标记构造方法或字段,告诉 Hilt 如何提供实例 构造函数、字段
@Module 定义第三方或接口的依赖提供方式 类(配合 @Provides 或 @Binds)
@Provides 在 Module 中写方法,返回依赖实例 Module 内的方法
@Binds 接口绑定到实现类(更轻量) Module 内的抽象方法

实战第一步:添加 Hilt 到项目

我个人习惯先加 Gradle 依赖,再写 Application 和 Activity 的注解。给你看个最简配置:

// build.gradle (project)
plugins {
    id 'com.google.dagger.hilt.android' version '2.50' apply false
}

// build.gradle (module)
plugins {
    id 'kotlin-kapt'
    id 'com.google.dagger.hilt.android'
}
dependencies {
    implementation 'com.google.dagger:hilt-android:2.50'
    kapt 'com.google.dagger:hilt-compiler:2.50'
}
@HiltAndroidApp
class MyApplication : Application()

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    @Inject lateinit var repository: Repository
    // ...
}

这就完成了基本的依赖注入框架搭建。注意一点:@HiltAndroidApp 必须写,否则 Hilt 不会生成任何组件。我见过有人只加 @AndroidEntryPoint 没加 Application 的注解,编译报错半天找不到原因。

说实话,Hilt 到底「隐」了什么?

它隐藏了:

  • 组件生命周期管理:不用自己创建 DaggerComponent,不用写 component.inject(this)。
  • 依赖图构建:编译期帮你算好所有依赖顺序,运行时直接拿。
  • 作用域绑定:@Singleton 在 SingletonComponent 里,@ActivityScoped 在 ActivityComponent 里,不用手动管理。

但这不代表没有代价。比如:

  • 编译期 codegen 的时间:项目大了,每次加个 @Inject 都可能触发增量编译变慢。
  • 运行时组件查找:Hilt 内部通过 Map 或 Provider 查找实例,每次注入都有查找开销。
  • 内存占用:组件树本身也是对象,会吃掉一些堆空间。

一个小经验:我在一个大型模块化项目里测过,单个 Activity 初始化时 Hilt 的组件创建耗时约 10~40ms,主要取决于注入的字段数量和依赖深度。这通常不会影响用户体验,但如果你的启动页或冷启动场景超过 100ms,就要开始排查了。

避坑指南 —— 我踩过的第一个坑

我曾经在 @Module 里写了一个 @Provides 方法返回 null,结果 Hilt 编译通过了,但运行时 NPE。为什么?因为 Hilt 只检查类型,不检查值。记住:Hilt 不会阻止你返回 null,但它会直接吞掉异常栈吗?不,它会抛出 NullPointerException 告诉你“cannot return null from non-@Nullable provide method”。嗯,虽然提示了,但定位起来还是比正常崩溃麻烦一点。

另外,如果你用了 @Binds,记得方法必须是 abstract,并且只能有一个参数(接口、实现类)。我刚开始写的时候把返回类型写成了具体类,编译报错 “@Binds methods must have exactly one parameter”。

本章小结

这一章我们先铺了个底:DI 是什么、为什么需要 Hilt,以及 Hilt 的核心结构。你心里应该有个大概框架了。下一节会深入 Hilt 的编译期原理,看看那些注解到底生成了什么代码——这才是隐藏开销的真正源头。不过先别急,扎实的概念是后续分析的基础。

核心记忆点:Hilt = Dagger + Android 组件树。组件树分为 SingletonComponent、ActivityComponent、FragmentComponent 等,每个 @AndroidEntryPoint 会绑定到对应组件。