前言
之前接手的一个项目里有些代码看得云里雾里的,找了半天没有找到对象创建的地方,后来才发现原来使用了Hilt进行了依赖注入。Hilt相比Dagger虽然已经比较简洁,但对初学者来说还是有些门槛,并且网上的许多文章都是搬自官网,入手容易深入难,如果你对Hilt不了解或是想了解得更多,那么接下来的内容将助力你玩转Hilt。
通过本篇文章,你将了解到:
- 什么是依赖注入?
- Hilt 的引入与基本使用
- Hilt 的进阶使用
- Hilt 原理简单分析
- Android到底该不该使用DI框架?
1. 什么是依赖注入?
什么是依赖?
以手机为例,要组装一台手机,我们需要哪些部件呢?
从宏观上分类:软件+硬件。
由此我们可以说:手机依赖了软件和硬件。
而反映到代码的世界:
class FishPhone(){
val software = Software()
val hardware = Hardware()
fun call() {
//打电话
software.handle()
hardware.handle()
}
}
//软件
class Software() {
fun handle(){}
}
//硬件
class Hardware() {
fun handle(){}
}
FishPhone 依赖了两个对象:分别是Software和Hardware。
Software和Hardware是FishPhone的依赖(项)。
什么是注入?
上面的Demo,FishPhone内部自主构造了依赖项的实例,考虑到依赖的变化挺大的,每次依赖项的改变都要改动到FishPhone,容易出错,也不是那么灵活,因此考虑从外部将依赖传进来,这种方式称之为:依赖注入(Dependency Injection 简称DI)
有几种方式:
- 构造函数传入
- SetXX函数传入
- 从其它对象间接获取
构造函数依赖注入:
class FishPhone(val software: Software, val hardware: Hardware){
fun call() {
//打电话
software.handle()
hardware.handle()
}
}
FishPhone的功能比较纯粹就是打电话功能,而依赖项都是外部传入提升了灵活性。
为什么需要依赖注入框架?
手机制造出来后交给客户使用。
class Customer() {
fun usePhone() {
val software = Software()
val hardware = Hardware()
FishPhone(software, hardware).call()
}
}
用户想使用手机打电话,还得自己创建软件和硬件,这个手机还能卖出去吗?
而不想创建软件和硬件那得让FishPhone自己负责去创建,那不是又回到上面的场景了吗?
你可能会说:FishPhone内部就依赖了两个对象而已,自己负责创建又怎么了?
解耦
再看看如下Demo:
interface ISoftware {
fun handle()
}
//硬件
interface IHardware {
fun handle()
}
//软件
class SoftwareImpl() : ISoftware {
override fun handle() {}
}
//硬件
class HardwareImpl : IHardware {
override fun handle() {}
}
class FishPhone() {
val software: ISoftware = SoftwareImpl()
val hardware: IHardware = HardwareImpl()
fun call() {
//打电话
software.handle()
hardware.handle()
}
}
FishPhone 只关注软件和硬件的接口,至于具体怎么实现它不关心,这就达到了解耦的目的。
既然要解耦,那么SoftwareImpl()、HardwareImpl()就不能出现在FishPhone里。
应该改为如下形式:
class FishPhone(val software: ISoftware, val hardware: IHardware) {
fun call() {
//打电话
software.handle()
hardware.handle()
}
}
消除模板代码
即使我们不考虑解耦,假若HardwareImpl里又依赖了cpu、gpu、disk等模块:
//硬件
class HardwareImpl : IHardware {
val cpu = CPU(Regisgter(), Cal(), Bus())
val gpu = GPU(Image(), Video())
val disk = Disk(Block(), Flash())
//...其它模块
override fun handle() {}
}
现在仅仅只是三个模块,若是依赖更多的模块或者模块的本身也需要依赖其它子模块,比如CPU需要依赖寄存器、运算单元等等,那么我们就需要写更多的模板代码,要是我们只需要声明一下想要使用的对象而不用管它的创建就好了。
class HardwareImpl(val cpu: CPU, val gpu: GPU, val disk: Disk) : IHardware {
override fun handle() {}
}
可以看出,下面的代码比上面的简洁多了。
- 从解耦和消除模板代码的角度看,我们迫切需要一个能够自动创建依赖对象并且将依赖注入到目标代码的框架,这就是依赖注入框架
- 依赖注入框架能够管理依赖对象的创建,依赖对象的注入,依赖对象的生命周期
- 使用者仅仅只需要表明自己需要什么类型的对象,剩下的无需关心,都由框架自动完成
先想想若是我们想要实现这样的框架需要怎么做呢?
相信很多小伙伴最朴素的想法就是:使用工厂模式,你传参告诉我想要什么对象我给你构造出来。
这个想法是半自动注入,因为我们还要调用工厂方法去获取,而全自动的注入通常来说是使用注解标注实现的。
2. Hilt 的引入与基本使用
Hilt的引入
从Dagger到Dagger2再到Hilt(Android专用),配置越来越简单也比较容易上手。
前面说了依赖注入框架的必要性,我们就想迫不及待的上手,但难度可想而知,还好大神们早就造好了轮子。
以AGP 7.0 以上为例,来看看Hilt框架是如何引入的。
一:project级别的build.gradle 引入如下代码:
plugins {
//指定插件地址和版本
id 'com.google.dagger.hilt.android' version '2.48.1' apply false
}
二:module级别的build.gradle引入如下代码:
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
//使用插件
id 'com.google.dagger.hilt.android'
//kapt生成代码
id 'kotlin-kapt'
}
//引入库
implementation 'com.google.dagger:hilt-android:2.48.1'
kapt 'com.google.dagger:hilt-compiler:2.48.1'
实时更新最新版本以及AGP7.0以下的引用请参考:Hilt最新版本配置
Hilt的简单使用
前置步骤整好了接下来看看如何使用。
一:表明该App可以使用Hilt来进行依赖注入,添加如下代码:
@HiltAndroidApp
class MyApp : Application() {
override fun onCreate() {
super.onCreate()
}
}
@HiltAndroidApp 添加到App的入口,即表示依赖注入的环境已经搭建好。
二:注入一个对象到MyApp里:
有个类定义如下:
class Software {
val name = "fish"
}
我们不想显示的构造它,想借助Hilt注入它,那得先告诉Hilt这个类你帮我注入一下,改为如下代码:
class Software @Inject constructor() {
val name = "fish"
}
在构造函数前添加了@Inject注解,表示该类可以被注入。
而在MyApp里使用Software对象:
@HiltAndroidApp
class MyApp : Application() {
@Inject
lateinit var software: Software
override fun onCreate() {
super.onCreate()
println("inject result:${software.name}")
}
}
对引用的对象使用@Inject注解,表示期望Hilt帮我将这个对象new出来。
最后查看打印输出正确,说明Software对象被创建了。
这是最简单的Hilt应用,可以看出:
- 我们并没有显式地创建Software对象,而Hilt在适当的时候就帮我们创建好了
- @HiltAndroidApp 只用于修饰Application
如何注入接口?
一:错误示范
上面提到过,使用DI的好处之一就是解耦,而我们上面注入的是类,现在我们将Software抽象为接口,很容易就会想到如下写法:
interface ISoftware {
fun printName()
}
class SoftwareImpl @Inject constructor(): ISoftware{
override fun printName() {
println("name is fish")
}
}
@HiltAndroidApp
class MyApp : Application() {
@Inject
lateinit var software: ISoftware
override fun onCreate() {
super.onCreate()
println("inject result:${software.printName()}")
}
}
不幸的是上述代码编译失败,Hilt提示说不能对接口使用注解,因为我们并没有告诉Hilt是谁实现了ISoftware,而接口本身不能直接实例化,因此我们需要为它指定具体的实现类。
二:正确示范
再定义一个类如下:
@Module
@InstallIn(SingletonComponent::class)
abstract class SoftwareModule {
@Binds
abstract fun bindSoftware(impl: SoftwareImpl):ISoftware
}
- @Module 表示该类是一个Hilt的Module,固定写法
- @InstallIn 表示模块在哪个组件生命周期内生效,SingletonComponent::class指的是全局
- 一个抽象类,类名随意
- 抽象方法,方法名随意,返回值是需要被注入的对象类型(接口),而参数是该接口的实现类,使用@Binds注解标记,
如此一来我们就告诉了Hilt,SoftwareImpl是ISoftware的实现类,于是Hilt注入ISoftware对象的时候就知道使用SoftwareImpl进行实例化。
其它不变运行一下:
可以看出,实际注入的是SoftwareImpl。
@Binds 适用在我们能够修改类的构造函数的场景
如何注入第三方类
上面的SoftwareImpl是我们可以修改的,因为使用了@Inject修饰其构造函数,所以可以在其它地方注入它。
在一些时候我们不想使用@Inject修饰或者说这个类我们不能修改,那该如何注入它们呢?
一:定义Provides模块
@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
fun provideHardware():Hardware {
return Hardware()
}
}
- @Module和@InstallIn 注解是必须的
- 定义object类
- 定义函数,方法名随意,返回类型为我们需要注入的类型
- 函数体里通过构造或是其它方式创建具体实例
- 使用@Provides注解函数
二:依赖使用
而Hardware定义如下:
class Hardware {
fun printName() {
println("I'm fish")
}
}
在MyApp里引用Hardware:
虽然Hardware构造函数没有使用@Inject注解,但是我们依然能够使用依赖注入。
当然我们也可以注入接口:
interface IHardware {
fun printName()
}
class HardwareImpl : IHardware {
override fun printName() {
println("name is fish")
}
}
想要注入IHardware接口,需要定义provides模块:
@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
fun provideHardware():IHardware {
return HardwareImpl()
}
}
@Provides适用于无法修改类的构造函数的场景,多用于注入第三方的对象
3. Hilt 的进阶使用
限定符
上述 ISoftware的实现类只有一个,假设现在有两个实现类呢?
比如说这些软件可以是美国提供,也可以是中国提供的,依据上面的经验我们很容易写出如下代码:
class SoftwareChina @Inject constructor() : ISoftware {
override fun printName() {
println("from china")
}
}
class SoftwareUS @Inject constructor() : ISoftware {
override fun printName() {
println("from US")
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class SoftwareModule {
@Binds
abstract fun bindSoftwareCh(impl: SoftwareChina):ISoftware
@Binds
abstract fun bindSoftwareUs(impl: SoftwareUS):ISoftware
}
//依赖注入:
@Inject
lateinit var software: ISoftware
兴高采烈的进行编译,然而却报错:
也就是说Hilt想要注入ISoftware,但不知道选择哪个实现类,SoftwareChina还是SoftwareUS?没人告诉它,所以它迷茫了,索性都绑定了。
这个时候我们需要借助注解:@Qualifier 限定符注解来对实现类进行限制。
改造一下:
@Module
@InstallIn(SingletonComponent::class)
abstract class SoftwareModule {
@Binds
@China
abstract fun bindSoftwareCh(impl: SoftwareChina):ISoftware
@Binds
@US
abstract fun bindSoftwareUs(impl: SoftwareUS):ISoftware
}
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class US
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class China
定义新的注解类,使用@Qualifier修饰。
而后在Module里,分别使用注解类修饰返回的函数,如bindSoftwareCh函数指定返回SoftwareChina来实现ISoftware接口。
最后在引用依赖注入的地方分别使用@China @US修饰。
@Inject
@US
lateinit var software1: ISoftware
@Inject
@China
lateinit var software2: ISoftware
此时,虽然software1、software2都是ISoftware类型,但是由于我们指定了限定符@US、@China,因此最后真正的实现类分别是SoftwareChina、SoftwareUS。
@Qualifier 主要用在接口有多个实现类(抽象类有多个子类)的注入场景
预定义限定符
上面提及的限定符我们还可以扩展其使用方式。
你可能发现了,上述提及的可注入的类构造函数都是无参的,很多时候我们的构造函数是需要有参数的,比如:
class Software @Inject constructor(val context: Context) {
val name = "fish"
fun getWindowService(): WindowManager?{
return context.getSystemService(Context.WINDOW_SERVICE) as? WindowManager
}
}
//注入
@Inject
lateinit var software: Software
这个时候编译会报错:
意思是Software依赖的Context没有进行注入,因此我们需要给它注入一个Context。
由上面的分析可知,Context类不是我们可以修改的,只能通过@Provides方式提供其注入实例,并且Context有很多子类,我们需要使用@Qualifier指定具体实现类,因此很容易我们就想到如下对策。
先定义Module:
@Module
@InstallIn(SingletonComponent::class)
object MyContextModule {
@Provides
@GlobalContext
fun provideContext(): Context? {
return MyApp.myapp
}
}
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class GlobalContext
再注入Context:
class Software @Inject constructor(@GlobalContext val context: Context?) {
val name = "fish"
fun getWindowService(): WindowManager?{
return context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager
}
}
可以看出,借助@Provides和@Qualifier,可以实现全局的Context。
当然了,实际上我们无需如此麻烦,因为这部分工作Hilt已经预先帮我们弄了。
与我们提供的限定符注解GlobalContext类似,Hilt预先提供了:
@Qualifier
@Target({ElementType.METHOD, ElementType.PARAMETER, ElementType.FIELD})
public @interface ApplicationContext {}
因此我们只需要在需要的地方引用它即可:
class Software @Inject constructor(@ApplicationContext val context: Context?) {
val name = "fish"
fun getWindowService(): WindowManager?{
return context?.getSystemService(Context.WINDOW_SERVICE) as? WindowManager
}
}
如此一来我们无需重新定义Module。
- 除了提供Application级别的上下文:@ApplicationContext,Hilt还提供了Activity级别的上下文:@ActivityContext,因为是Hilt内置的限定符,因此称为预定义限定符。
- 如果想自己提供限定符,可以参照GlobalContext的做法。
组件作用域和生命周期
Hilt支持的注入点(类)
以上的demo都是在MyApp里进行依赖,MyApp里使用了注解:@HiltAndroidApp 修饰,表示当前App支持Hilt依赖,Application就是它支持的一个注入点,现在想要在Activity里使用Hilt呢?
@AndroidEntryPoint
class SecondActivity : AppCompatActivity() {
除了Application和Activity,Hilt内置支持的注入点如下:
除了Application和ViewModel,其它注入点都是通过使用@AndroidEntryPoint修饰。
注入点其实就是依赖注入开始的点,比如Activity里需要注入A依赖,A里又需要注入B依赖,B里又需要注入C依赖,从Activity开始我们就能构建所有的依赖
Hilt组件的生命周期
什么是组件?在Dagger时代我们需要自己写组件,而在Hilt里组件都是自动生成的,无需我们干预。
依赖注入的本质实际上就是在某个地方悄咪咪地创建对象,这个地方的就是组件,Hilt专为Android打造,因此势必适配了Android的特性,比如生命周期这个Android里的重中之重。
因此Hilt的组件有两个主要功能:
- 创建、注入依赖的对象
- 管理对象的生命周期
Hilt组件如下:
可以看出,这些组件的创建和销毁深度绑定了Android常见的生命周期。
你可能会说:上面貌似没用到组件相关的东西,看了这么久也没看懂啊。
继续看个例子:
@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
fun provideHardware():IHardware {
return HardwareImpl()
}
}
@InstallIn(SingletonComponent::class) 表示把模块安装到SingletonComponent组件里,SingletonComponent组件顾名思义是全局的,对应的是Application级别。因此安装的这个模块可在整个App里使用。
问题来了:SingletonComponent是不是表示@Provides修饰的函数返回的实例是同一个?
答案是否定的。
这就涉及到组件的作用域。
组件的作用域
想要上一小结的代码提供全局唯一实例,则可用组件作用域注解修饰函数:
@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
@Singleton
fun provideHardware():IHardware {
return HardwareImpl()
}
}
当我们在任何地方注入IHardware时,获取到的都是同一个实例。
除了@Singleton表示组件的作用域,还有其它对应组件的作用域:
简单解释作用域:
@Singleton 被它修饰的构造函数或是函数,返回的始终是同一个实例
@ActivityRetainedScoped 被它修饰的构造函数或是函数,在Activity的重建前后返回同一实例
@ActivityScoped 被它修饰的构造函数或是函数,在同一个Activity对象里,返回的都是同一实例
@ViewModelScoped 被它修饰的构造函数或是函数,与ViewModel规则一致
- Hilt默认不绑定任何作用域,由此带来的结果是每一次注入都是全新的对象
- 组件的作用域要么不指定,要指定那必须和组件的生命周期一致
以下几种写法都不符合第二种限制:
@Module
@InstallIn(SingletonComponent::class)
object HardwareModule {
@Provides
@ActivityScoped//错误,和组件的作用域不一致
fun provideHardware():IHardware {
return HardwareImpl()
}
}
@Module
@InstallIn(ActivityComponent::class)
object HardwareModule {
@Provides
@Singleton//错误,和组件的作用域不一致
fun provideHardware():IHardware {
return HardwareImpl()
}
}
@Module
@InstallIn(ActivityRetainedComponent::class)
object HardwareModule {
@Provides
@ActivityScoped//错误,和组件的作用域不一致
fun provideHardware():IHardware {
return HardwareImpl()
}
}
除了修饰Module,作用域还可以用于修饰构造函数:
@ActivityScoped
class Hardware @Inject constructor(){
fun printName() {
println("I'm fish")
}
}
@ActivityScoped表示不管注入几个Hardware,在同一个Activity里注入的实例都是一致的。
构造函数里无法注入的字段
一个类的构造函数如果被@Inject注入,那么构造函数的其它参数都需要支持注入。
class Hardware @Inject constructor(val context: Context) {
fun printName() {
println("I'm fish")
}
}
以上代码是无法编译通过的,因为Context不支持注入,而通过上面的分析可知,我们可以使用限定符:
class Hardware @Inject constructor(@ApplicationContext val context: Context) {
fun printName() {
println("I'm fish")
}
}
这就可以成功注入了。
再看看此种场景:
class Hardware @Inject constructor(
@ApplicationContext val context: Context,
val version: String,
) {
fun printName() {
println("I'm fish")
}
}
很显然String不支持注入,当然我们可以向@ApplicationContext 一样也给String提供一个@Provides和@Qualifier注解,但可想而知很麻烦,关键是String是动态变化的,我们确实需要Hardware构造的时候传入合适的String。
由此引入新的写法:辅助注入
class Hardware @AssistedInject constructor(
@ApplicationContext val context: Context,
@Assisted
val version: String,
) {
//辅助工厂类
@AssistedFactory
interface Factory{
//不支持注入的参数都可以放这,返回值为待注入的类型
fun create(version: String):Hardware
}
fun printName() {
println("I'm fish")
}
}
在引用注入的地方不能直接使用Hardware,而是需要通过辅助工厂进行创建:
@AndroidEntryPoint
class SecondActivity : AppCompatActivity() {
private lateinit var binding: ActivitySecondBinding
@Inject
lateinit var hardwareFactory : Hardware.Factory
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivitySecondBinding.inflate(layoutInflater)
setContentView(binding.root)
val hardware = hardwareFactory.create("3.3.2")
println("${hardware.printName()}")
}
}
如此一来,通过辅助注入,我们还是可以使用Hilt,值得一提的是辅助注入不是Hilt独有,而是从Dagger继承来的功能。
自定义注入点
Hilt仅仅内置了常用的注入点:Application、Activity、Fragment、ViewModel等。
思考一种场景:小明同学写的模块都是需要注入:
class Hardware @Inject constructor(
val gpu: GPU,
val cpu: CPU,
) {
fun printName() {
println("I'm fish")
}
}
class GPU @Inject constructor(val videoStorage: VideoStorage){}
//显存
class VideoStorage @Inject constructor() {}
class CPU @Inject constructor(val register: Register) {}
//寄存器
class Register @Inject() constructor() {}
此时小刚需要引用Hardware,他有两种选择:
- 使用注入方式很容易就引用了Hardware,可惜的是他没有注入点,仅仅只是工具类。
- 不选注入方式,则需要构造Hardware实例,而Hardware依赖GPU和CPU,它们又分别依赖VideoStorage和Register,想要成功构造Hardware实例需要将其它的依赖实例都手动构造出来,可想而知很麻烦。
这个时候适合小刚的方案是:
自定义注入点
方案实施步骤:
一:定义入口点
@InstallIn(SingletonComponent::class)
interface HardwarePoint {
//该注入点负责返回Hardware实例
fun getHardware(): Hardware
}
二:通过入口点获取实例
class XiaoGangPhone {
fun getHardware(context: Context):Hardware {
val entryPoint = EntryPointAccessors.fromApplication(context, HardwarePoint::class.java)
return entryPoint.getHardware()
}
}
三:使用Hardware
val hardware = XiaoGangPhone().getHardware(this)
println("${hardware.printName()}")
注入object类
定义了object类,但在注入的时候也需要,可以做如下处理:
object MySystem {
fun getSelf():MySystem {
return this
}
fun printName() {
println("I'm fish")
}
}
@Module
@InstallIn(SingletonComponent::class)
object MiddleModule {
@Provides
@Singleton
fun provideSystem():MySystem {
return MySystem.getSelf()
}
}
//使用注入
class Middleware @Inject constructor(
val mySystem:MySystem
) {
}
4. Hilt 原理简单分析
@AndroidEntryPoint
class SecondActivity : AppCompatActivity() {}
Hilt通过apt在编译时期生成代码:
public abstract class Hilt_SecondActivity extends AppCompatActivity implements GeneratedComponentManagerHolder {
private boolean injected = false;
Hilt_SecondActivity() {
super();
//初始化注入监听
_initHiltInternal();
}
Hilt_SecondActivity(int contentLayoutId) {
super(contentLayoutId);
_initHiltInternal();
}
private void _initHiltInternal() {
addOnContextAvailableListener(new OnContextAvailableListener() {
@Override
public void onContextAvailable(Context context) {
//真正注入
inject();
}
});
}
protected void inject() {
if (!injected) {
injected = true;
//通过manager获取组件,再通过组件注入
((SecondActivity_GeneratedInjector) this.generatedComponent()).injectSecondActivity(UnsafeCasts.<SecondActivity>unsafeCast(this));
}
}
}
在编译期,SecondActivity的父类由AppCompatActivity变为Hilt_SecondActivity,因此当SecondActivity构造时就会调用父类的构造器监听create()的回调,回调调用时进行注入。
由此可见,Activity.onCreate()执行后,Hilt依赖注入的字段才会有值
真正注入的过程涉及到不少的类,都是自动生成的类,有兴趣可以对着源码查找流程,此处就不展开说了。
5. Android到底该不该使用DI框架?
有人说DI比较复杂,还不如我直接构造呢?
又有人说那是你项目不复杂,用不到,在后端流行的Spring全家桶,依赖注入大行其道,Android复杂的项目也需要DI来解耦。
从个人的实践经验看,Android MVVM/MVI 模式还是比较适合引入Hilt的。
摘抄官网的:现代Android 应用架构
通常来说我们这么设计UI层到数据层的架构:
class MyViewModel @Inject constructor(
val repository: LoginRepository
) :ViewModel() {}
class LoginRepository @Inject constructor(
val rds : RemoteDataSource,
val lds : LocalDataSource
) {}
//远程来源
class RemoteDataSource @Inject constructor(
val myRetrofit: MyRetrofit
) {}
class MyRetrofit @Inject constructor(
) {}
//本地来源
class LocalDataSource @Inject constructor(
val myDataStore: MyDataStore
) {}
class MyDataStore @Inject constructor() {}
可以看出,层次比较深,使用了Hilt简洁了许多。
本文基于 Hilt 2.48.1
参考文档:
https://dagger.dev/hilt/gradle-setup
https://developer.android.com/topic/architecture/recommendations?hl=zh-cn
https://repo.maven.apache.org/maven2/com/google/dagger/hilt/android/com.google.dagger.hilt.android.gradle.plugin/