SystemUI Dagger2 精读 · 第 2 章 @Inject:1200 处构造注入,和 1 处例外
一句话结论 :
@Inject只做一件事------告诉 Dagger "这个类的这个位置由你来填"。填在构造函数上叫构造注入,填在字段上叫成员注入,两者的时机和约束完全不同。本章解决的问题 :为什么我加了
@Inject字段,运行时还是 null?
2.1 机制讲清
构造注入
java
@SysUISingleton
public class InitController {
@Inject
public InitController() { }
}
Dagger 看到 @Inject 构造,就为这个类生成 InitController_Factory。任何地方需要 InitController,Dagger 就调用这个构造,并递归解决参数。
三条硬规则:
- 一个类只能有一个
@Inject构造(否则 Dagger 不知道该用哪个)。 - 无参构造 +
@Inject等价于"我随时可以被造出来"。 - 构造注入的字段可以全 final,这是它最大的价值。
成员(字段)注入
java
@SysUISingleton
public class Dependency {
@Inject Lazy<ActivityStarter> mActivityStarter;
@Inject Lazy<BroadcastDispatcher> mBroadcastDispatcher;
// ...还有 138 个
@Inject
public Dependency() { }
}
成员注入不会 在 new Dependency() 时发生。它必须由组件主动执行:
java
Dependency dependency = mSysUIComponent.createDependency();
dependency.start();
createDependency() 这个组件方法内部做的才是:new Dependency() → 遍历所有 @Inject 字段 → 逐个赋值。字段注入的类,必须 在组件接口里声明一个 inject(...) 方法(或像这样通过组件方法返回实例)。
方法注入
@Inject 标注在方法上,由 Dagger 在成员注入阶段调用。SystemUI 里极少用,见到时通常是为了在注入完成后做一次初始化。
2.2 源码定位
| 文件 | 行 | 看什么 |
|---|---|---|
src/com/android/systemui/InitController.java |
34--38 | 最标准的构造注入:@SysUISingleton + @Inject 空构造 |
src/com/android/systemui/Dependency.java |
245--374 | 140 个 @Inject Lazy<...> 字段,全书的"反例中心" |
src/com/android/systemui/Dependency.java |
376--378 | @Inject public Dependency() {}------空构造,依赖全靠字段 |
src/com/android/systemui/Dependency.java |
383--386 | start() 里手工把 Lazy 注册进 mProviders |
src/com/android/systemui/dagger/SysUIComponent.java |
245--260 | 四个 void inject(Xxx) 成员注入声明 |
src/com/android/systemui/dagger/ReferenceSysUIComponent.java |
52 | void inject(CustomizationProvider)------厂商加的注入点 |
注意 SysUIComponent 里这一串:
java
void inject(SystemUIAppComponentFactoryBase factory); // 245
void inject(KeyguardSliceProvider keyguardSliceProvider); // 250
void inject(ClockOptionsProvider clockOptionsProvider); // 255
void inject(PeopleProvider peopleProvider); // 260
这四个全是 Android 框架帮忙 new 出来的类(ContentProvider 及其子类),构造被系统调用,你没法让 Dagger 参与,只能在系统交给你实例之后补一次成员注入。第 9 章会展开这条链路。
2.3 依赖图:两条完全不同的解析路径
【构造注入】
A 需要 B
┌──────┐ new B(dep1, dep2) ┌────────────┐
│ A │ ────────────────────► │ B_Factory │
└──────┘ └─────┬──────┘
│ 递归解析 dep1, dep2
▼
(图里继续找 Provider)
【成员注入】
框架 new 出 B(构造参数为空)
┌──────────┐ ┌──────────────────────────┐
│ new B() │ ──► 字段全是 null ──►│ component.inject(b) │
└──────────┘ │ → B_MembersInjector │
│ .injectXxx(b, provider)│
└──────────────────────────┘
│
▼
字段被赋值(此刻才可用)
关键差别:构造注入的依赖在对象"存在的那一刻"就齐了;成员注入的对象存在时是个空壳。
2.4 为什么这么设计
为什么 SystemUI 主流是构造注入
InitController 这种类,依赖简单、可 final、可单测------构造注入把"依赖齐全"变成了类型系统的保证,你不可能拿到一个半成品。
为什么 Dependency 偏偏用字段注入
看它的规模:140 个字段。如果改成构造注入:
java
public Dependency(Lazy<ActivityStarter> a,
Lazy<BroadcastDispatcher> b,
/* ... 再写 138 个 ... */) { }
这段代码没法读、没法维护、每次新增一个依赖都要改 140 行的签名。更要命的是,Dependency 是迁移期产物------它的注释自己承认了:
Class to handle ugly dependencies throughout sysui until we determine the long-term dependency injection solution.
翻译:这是个临时方案,作者知道它丑。用字段注入 + Lazy 是当时唯一能把 140 个依赖"塞进去而不炸"的写法。
为什么字段注入在 SystemUI 里应该被当成"红旗"
因为成员注入必须由外部驱动。忘记调用 inject() 就是一堆 null ,而且编译期不报错。第 12 章会给出判定:看到 @Inject 字段,先问"谁负责触发注入",答不出来就是定时炸弹。
2.4.1 一段迁移史:为什么工程里会同时存在两种风格
2017 年之前的 SystemUI 只有一种拿依赖的方式:Dependency.get(Xxx.class)。所有单例塞在一个类里,谁都能静态取。后来引入 Dagger,官方的迁移策略写在 docs/dagger.md 里:
Dagger 2 has been turned on for SystemUI and much of Dependency.java has been converted to use Dagger. Since a lot of SystemUI depends on Dependency, stubs have been added to Dependency to proxy any gets through to the instances provided by dagger, this will allow migration of SystemUI through a number of CLs.
翻译成一句话:不动调用方,先换供料方。
具体做法就是 Dependency 变成一个巨大的"字段注入壳":
- 它自己
@Inject140 个Lazy,这些Lazy由 Dagger 提供------供料方换成了 Dagger。 start()把这些Lazy塞进mProviders表。- 老的
Dependency.get(X.class)依然能用------调用方一行没改。
于是工程里就出现了两种风格并存的局面,而且会持续很多年(直到今天还有 96 个调用点)。这不是"设计得不好",而是大规模代码库迁移的唯一现实路径:先换内核,再逐个改调用点。
这段历史对你有直接用处 :当你在某个类里看到 @Inject 字段,不要立刻判定"这是坏代码",先看它是不是 2017---2019 年迁移的产物。判断标准不是"用了字段注入",而是"这个类有没有构造注入的通道":
| 类的形态 | 有构造注入通道吗 | 结论 |
|---|---|---|
普通业务类,自己 new 或被 Dagger 造 |
有 | 应该改成构造注入,字段注入是债 |
Dependency 本身 |
有,但 140 个参数不现实 | 保留,但别再往里加 |
| XML inflate 出来的 View | 没有 (LayoutInflater 造实例) |
只能用 Dependency.get() 或 setter |
| 由系统造的 Activity/Service/Provider | 前三者有(见第 9 章),Provider 没有 | 用 AppComponentFactory / 反射 |
这张表就是第 12 章决策树的雏形。
2.4.2 一个容易被忽略的顺序问题
构造注入和字段注入还有一个隐蔽差异:构造注入的依赖在构造完成时一定可用,字段注入的依赖在构造完成时一定不可用。
这会导致一种很难查的 bug:
java
@SysUISingleton
public class Foo {
@Inject Bar mBar; // 字段注入
@Inject
public Foo() {
mBar.doWork(); // ← NPE,此时 mBar 还是 null
}
}
它甚至不是"偶尔 NPE"------是必然 NPE,但因为这段代码只在某些机型/某些启动路径上被走到,很容易被误判成偶发问题。
规则:任何"构造里就要用依赖"的类,必须用构造注入。 字段注入只适合"构造之后才用"的场景,比如 Dependency 这种纯容器------它构造时什么都不做,所有工作都在 start() 里。
2.4.3 Java 与 Kotlin 的写法对照
工程里两种语言混用,@Inject 的写法差异是常见卡点:
| 场景 | Java | Kotlin |
|---|---|---|
| 构造注入 | @Inject public Foo(Bar b) |
class Foo @Inject constructor(private val b: Bar) |
| 字段注入 | @Inject Bar mBar; |
@set:Inject lateinit var bar: Bar 或 @JvmField @Inject |
| 无参构造 | @Inject public Foo() {} |
class Foo @Inject constructor() |
| 接口绑定 | @Binds abstract Foo bind(Impl i) |
@Binds abstract fun bind(i: Impl): Foo |
两个 Kotlin 专有坑:
坑一:@Inject 必须写在 constructor 关键字前。
kotlin
class Foo @Inject constructor(val b: Bar) // ✓
class Foo(val b: Bar) // ✗ 没有注入点
坑二:Kotlin 的 lateinit 字段注入需要 @set: 前缀。
kotlin
@set:Inject lateinit var helper: Helper // ✓ 注在 setter 上
@Inject lateinit var helper: Helper // 可能注到字段,行为不一致
SystemUIAppComponentFactoryBase.kt:49 用的就是 @set:Inject,这是 Kotlin 里成员注入的推荐写法。
为什么是 setter? 因为 Kotlin 的属性默认编译成 private field + getter/setter,@Inject 直接标在属性上时,注解的落点由编译器决定(可能落到 field,也可能落到 getter),而 Dagger 需要明确的 field 或 method。@set: 去掉歧义。
2.5 动手练习
练习 2-A:验证构造注入的"自动"(10 分钟)
新建一个类:
java
@SysUISingleton
public class BookProbe {
@Inject
public BookProbe(InitController initController) {
android.util.Log.d("BookProbe", "init=" + initController);
}
}
然后在 SysUIComponent 里加一行 BookProbe getBookProbe();,在 SystemUIInitializer.init() 末尾调一次 mSysUIComponent.getBookProbe()。
预期:编译通过、日志打印一次。你没有写任何"注册"代码------构造注入不需要注册,这是它和字段注入最大的体感差别。
练习 2-B:制造一次循环依赖(5 分钟)
让 BookProbe 依赖 InitController,同时让 InitController 依赖 BookProbe。
预期 :编译期报 [Dagger/DependencyCycle] Found a dependency cycle。这个错误是编译期 出现的,这是 Dagger 相对反射 DI 的又一大优势------第 11 章会用 Lazy 打破它。
2.6 常见坑
| 症状 | 根因 | 处理 |
|---|---|---|
@Inject 字段运行时是 null |
没人调用 component.inject(instance) |
在组件接口声明 void inject(X),并在拿到实例后调用 |
| 报 "cannot be provided without an @Inject constructor" | 类没有 @Inject 构造,也没有 @Provides |
二选一:加 @Inject 构造,或在 Module 里写 @Provides |
| Kotlin 类注入不进去 | 注解落在次构造或没写 constructor 关键字 |
写成 class Foo @Inject constructor(...) |
| 每次注入拿到不同实例 | 类没加作用域 | 需要单例就加 @SysUISingleton(第 5 章) |
| 字段注入的 final 字段编译不过 | 字段注入靠反射/生成代码赋值,不能是 final | 用构造注入,或去掉 final |
| 在父类构造里用注入字段,拿到 null | 字段注入发生在整个对象构造完成之后 | 把逻辑移到 init() 或 @Inject 方法里 |
同一个类在两处被 inject() 但字段不同 |
成员注入器是同一份,不会有差异 | 检查是不是两个不同的类加载器/两个组件实例 |
本章小结
@Inject只有三种落点:构造、字段、方法。SystemUI 里 1200 处绝大多数是构造。- 构造注入是"自动"的;字段注入必须由组件显式驱动,忘记驱动就是 null。
Dependency的字段注入不是最佳实践,是一个 140 参数构造无法成立时的妥协------它是理解 SystemUI DI 现状的钥匙。- Kotlin 项目要注意注解位置:
class X @Inject constructor()。
下一章:当对象不能由你
new(framework 类、第三方类、需要构造后立刻注册的类),@Inject就无能为力了,这时必须上@Module+@Provides。