Hilt进阶--一文吃透Hilt自定义与跨壁垒
前言
本文隶属于我归纳整理的Android知识体系的第四部分,属于DI中Hilt的进阶内容
如果您需要学习Hilt的基础内容,可以通过Android开发者官方提供的 MAD Skills 12-15篇 和 官方使用教程
文章按照以下内容展开:
文中涉及的代码和案例,均可以于 workshop 中获得。
非常重要:经过反复思考,我删除了原先编写的关于Hilt工作原理和生成代码的部分。或许加上这部分,会让部分读者获得更深刻地理解,但我担心会让更多的读者陷入困境 而不敢使用。
正如同Google要创建Hilt一样,他们希望开发者以更简单的方式接入Dagger2,本篇文章也希望读者朋友能够先掌握如何使用,并结合场景选用最佳实践方案。在此基础上再行理解背后的设计原理。
跨越 IOC容器的壁垒
使用依赖注入(DI)时,我们需要它对 实例
、依赖关系
、 生命周期
进行管理,因此DI框架会构建一个容器,用于实现这些功能。这个容器我们惯称为IOC容器。
在容器中,会按照我们制定的规则:
- 创建实例
- 访问实例
- 注入依赖
- 管理生命周期
但容器外也有访问容器内部的需求,显然这里存在一道虚拟的 边界、壁垒
。这种需求分为两类:
- 依赖注入客观需要的入口
- 系统中存在合理出现的、非DI框架管理的实例,但它不希望破坏其他实例对象的
生命周期
、作用域唯一性
,即它的依赖希望交由DI框架管理
但请注意,IOC容器内部也存在着 边界、壁垒
,这和它管理实例的机制有关,在Hilt(包括Dagger)中,最大颗粒度的内部壁垒是 Component
。
即便从外部突破IOC容器的壁垒,也只能进入某个特定的Component
使用EntryPoint跨越IOC容器壁垒
在Hilt中,我们可以很方便地
- 使用接口定义 进入点(EntryPoint),并使用
@EntryPoint
注解使其生效; - 用
@InstallIn
注解指明访问的Component; - 并利用
EntryPoints
完成访问,突破容器壁垒
下面的代码展示了如何定义:
UserComponent是自定义的Component,在下文中会详细展开
@EntryPoint
@InstallIn(UserComponent::class)
interface UserEntryPoint {
fun provideUserVO(): UserVO
}
下面的代码展示了如何获取进入点,注意,您需要先获得对应的Component实例。
对于Hilt内建的Component,均有其获取方法,而自定义的Component,需从外界发起生命周期控制,同样会预留实例访问路径
fun manualGet(): UserEntryPoint {
return EntryPoints.get(
UserComponentManager.instance.generatedComponent(),
UserEntryPoint::class.java
)
}
当获取进入点后,即可使用预定义的API,访问容器内的对象实例。
自定义Scope、Component
部分业务场景中,Hilt内建的Scope和Component并不能完美支持,此时我们需要进行自定义。
为了下文能够更顺利的展开,我们再花一定的笔墨对 Scope
、Component
、Module
的含义进行澄清。
Scope、Component、Module的真实含义
前文提到两点:
- DI框架需要
创建实例
、访问实例
、注入依赖
、管理生命周期
- IOC容器内部也存在着
边界、壁垒
,这和它管理实例的机制有关,在Hilt(包括Dagger)中,最大颗粒度的内部壁垒是Component
。
不难理解:
- 实例之间,也会存在依赖关系;
- DI框架需要管理内部实例的生命周期;
- 需要进行依赖注入的客户,本身也存在生命周期,它的依赖对象,应该结合实际需求被合理控制生命周期,避免生命周期泄漏;
因此,出现了 范围、作用域
即 Scope
的概念,它包含两个维度:实例的生命周期范围;实例之间的访问界限。
并且DI框架通过Component控制内部对象的生命周期。
举一个例子描述,以Activity为例,Activity需要进行依赖注入,并且我们不希望Activity自身需要的依赖出现生命周期泄漏,于是按照Activity的生命周期特点定义了:
ActivityRetainedScoped
ActivityRetainedComponent
,不受reCreate 影响ActivityScoped
、ActivityComponent
,横竖屏切换等配置变化引起reCreate 开始新生命周期
并据此对 依赖对象实例
实施 生命周期
和 访问范围
控制
可以记住以下三点结论:
- Activity实例按照
预定Scope对应的生命周期范围
创建、管理Component,访问Component中的实例; - Component内的实例可以互相访问,实例的生命周期和Component一致;
- Activity实例(需要依赖注入的客户)和 Component中的实例 可以访问
父Component
中的实例,父Component的生命周期完全包含子Component的生命周期
内建的Scope、Component关系参考:
而Module指导DI框架 创建实例
、选用实例进行注入
值得注意的是,Hilt(以及Dagger)可以通过 @Inject
注解类构造函数指导 创建实例
,此方式创建的实例的生命周期跟随宿主,与 通过Module方式
进行对比,存在生命周期管理粒度上的差异。
自定义
至此,已不难理解:因为有实际的生命周期范围管理需求,才会自定义。
为了方便行文以及编写演示代码,我们举一个常见的例子:用户登录的生命周期。
一般的APP在设计中,用户登录后会持久化TOKEN,下次APP启动后验证TOKEN真实性和时效性,通过验证后用户仍保持登录状态,直到TOKEN超时、登出。当APP退出时,可以等效认为用户登录生命周期结束。
显然,用户登录的生命周期完全涵盖在APP生命周期(Singleton Scope)中,但略小于APP生命周期;和Activity生命周期无明显关联。
定义Scope
import javax.inject.Scope
@Scope
annotation class UserScope
就是这么简单。
定义Component
定义Component时,需要指明父Component和对应的Scope:
import dagger.hilt.DefineComponent
@DefineComponent(parent = SingletonComponent::class)
@UserScope
interface UserComponent {
}
Hilt需要以Builder构建Component,不仅如此,一般构建Component时存在初始信息,例如:ActivityComponent需要提供Activity实例。
通常设计中,用户Component存在 用户基本信息、TOKEN
等初始信息
data class User(val name: String, val token: String) {
}
此时,我们可以在Builder中完成初始信息的注入:
import dagger.BindsInstance
import dagger.hilt.DefineComponent
@DefineComponent.Builder
interface Builder {
fun feedUser(@BindsInstance user: User?): Builder
fun build(): UserComponent
}
我们以 @BindsInstance
注解标识需要注入的初始信息,注意合理控制其可空性,在后续的使用中,可空性需保持一致
注意:方法名并不重要,采用习惯性命名即可,我习惯于将向容器喂入参数的API添加feed前缀
当我们通过Hilt获得Builder实例时,即可控制Component的创建(即生命周期开始)
使用Manager管理Component
不难想象,Component的管理基本为模板代码,Hilt中提供了模板和接口类:
如果您想避免模板代码编写,可以定义扩展模块,使用APT、KCP、KSP生成
此处展示非线程安全的简单使用Demo
@Singleton
class UserComponentManager @Inject constructor(
private val builder: UserComponent.Builder
) : GeneratedComponentManager<UserComponent> {
companion object {
lateinit var instance: UserComponentManager
}
private var userComponent = builder
.feedUser(null)
.build()
fun onLogin(user: User) {
userComponent = builder.feedUser(user).build()
}
fun onLogout() {
userComponent = builder.feedUser(null).build()
}
override fun generatedComponent(): UserComponent {
return userComponent
}
}
您也可以定义如下的线程安全的Manager,并使用 ComponentSupplier
提供实例
class CustomComponentManager(
private val componentCreator: ComponentSupplier
) : GeneratedComponentManager<Any> {
@Volatile
private var component: Any? = null
private val componentLock = Any()
override fun generatedComponent(): Any {
if (component == null) {
synchronized(componentLock) {
if (component == null) {
component = componentCreator.get()
}
}
}
return component!!
}
}
您可以根据实际需求选择最适宜的方法进行管理,不再赘述。
在生命周期范围更小的Component中使用
至此,我们已经完成了自定义Scope、Component的主要工作,通过Manager即可控制生命周期。
如果想在生命周期范围更小的Component中访问 UserComponent中的对象实例,您需要谨记前文提到的三条结论。
该需求很合理,但下面的例子并不足够典型
此时,您需要通过一个合理的Component实现访问,例如在Activity中需要注入相关实例时。
因为 ActivityRetainedComponent
和 UserComponent
不存在父子关系,Scope没有交集,所以 需要找到共同的父Component进行帮助,并通过EntryPoint突破壁垒 :
前文中,我们将 UserComponentManager
划入 SingletonComponent
, 他是两种的共同父Component,此时可以这样处理:
@Module
@InstallIn(ActivityRetainedComponent::class)
object AppModule {
@Provides
fun provideUserVO(manager: UserComponentManager):UserVO {
return UserEntryPoint.manualGet(manager.generatedComponent()).provideUserVO()
}
}
解决独立library的依赖初始化问题
此问题属于常见案例,通过研究它的解决方案,我们可以更深刻地理解前文内容,做到吃透。
当处理主工程时,没有代码隔离,我们可以很轻易的修改Application的代码,因此很多问题难以暴露。
例如,我们可以在Application中通过注解标明依赖 (满足Singleton Scope前提) ,DI框架会帮助我们进行注入,在注入后可以编写逻辑代码,将对象赋值给全局变量,便可以 "方便" 的使用。
为方便下文表述,我们称之 "方案1"
显然,这是有异味的代码,虽然它有效且方便。
因此,我们选取一些场景来说明该做法的弊端:
- 场景1:创建独立Library,其中使用Hilt作为DI框架,Library中存在自定义Component,需要初始化管理入口
- 场景2:项目采用了组件化,该Library按照渠道包需求,渠道包A集成、渠道包B不集成
- 场景3:项目采用了Uni-App、React-Native等技术,该Library中存在实例由反射方式创建、不受Hilt管理,无法借助Hilt自动注入依赖
以上场景并不相互孤立
在场景1中,我们仍然可以通过 方案1
完成需求,但在场景2中便不再可行。
常规的组件化、插件化,都会完成代码隔离&使用抽象,因此无法在主工程的Application中使用目标类。通过定制字节码工具曲线救国,则属实是大炮打蚊子、屎盆子镶金边
使用hilt的聚合能力解决问题
在 MAD Skills 系列文章的最后一篇中,简单提及了Hilt的聚合能力,它至少包含以下两个层面:
- 即便一个已经编译为aar的库,在被集成后,Hilt依旧能够扫描该库中Hilt相关的内容,进行依赖图聚合
- Hilt生成的代码,依旧存在着注解,这些注解可以被注解处理器、字节码工具识别、并进一步处理。可以是Hilt内建的处理器或您自定义的扩展处理器
依据第一个层面,我们可以制定一个约定:
子Library按照抽象接口提供Library初始化实例,主工程的Application通过DI框架获取后进行初始化
我们将其称为方案2
例如,在Library中定义如下初始化类:
class LibInitializer @Inject constructor(
private val userComponentManager: UserComponentManager
) : Function1<Application, Any> {
override fun invoke(app: Application): Any {
UserComponentManager.instance = userComponentManager
return Unit
}
}
不难发现,他是方案1的变种,将依赖获取从Application中挪到了LibInitializer中
并约定绑定实例&集合注入, 依旧在Library中编码 :
@InstallIn(SingletonComponent::class)
@Module
abstract class AppModuleBinds {
@Binds
@IntoSet
abstract fun provideLibInitializer(bind: LibInitializer): Function1<Application, Any>
}
在主工程的Application中:
@HiltAndroidApp
class App : Application() {
@Inject
lateinit var initializers: Set<@JvmSuppressWildcards Function1<Application, Any>>
override fun onCreate() {
super.onCreate()
initializers.forEach {
it(this)
}
}
}
如此即可满足场景1、场景2的需求。
但仔细思考一下,这种做法太 "强硬" 了,不仅要求主工程的Application进行配合,而且需要小心的处理初始化代码的分配。
在场景3中,这些技术均有相适应的插件初始化入口;组件化插件化项目中,也具有类似的设计。随集成方式的不同,很可能造成 初始化逻辑遗漏或者重复 。
注意:重复初始化可能造成潜在的Scope泄漏,滋生bug。
聚合能力+EntryPoint
前文中,我们已经讨论了使用EntryPoint突破IOC容器的壁垒,也体验了Hilt的聚合能力。而 SingletonComponent
作为内建Component,同样可以使用EntryPoint突破容器壁垒。
如果您对Hilt的源码或其设计有一定程度的了解,应当清楚:
内建Component均有对应的ComponentHolder,而SingletonComponent对应的Holder即为Application。
通过 Holder实例和 EntryPointAccessors
可以获得定义的 EntryPoint接口
为 SingletonComponent
自定义EntryPoint后,即可摆脱Hilt自定注入的传递链而通过逻辑编码获取实例。
@EntryPoint
@InstallIn(SingletonComponent::class)
interface UserComponentEntryPoint {
companion object {
fun manualGet(context: Context): UserComponentEntryPoint {
return EntryPointAccessors.fromApplication(
context, UserComponentEntryPoint::class.java
)
}
}
fun provideBuilder(): UserComponent.Builder
fun provideManager():UserComponentManager
}
通过这一方式,我们只需要获得Context即可突破壁垒访问容器内部实例,Hilt不再约束Library的初始化方式。
至此,您可以在原先的Library初始化模块中,按需自由的添加逻辑!
注意:Builder由Hilt生成实现,无法干预其生命周期,故每次调用时生成新的实例,从一般的编码需求,获取Manager实例即可。您可以在WorkShop项目中获得验证
问题衍生
在场景3中,我们继续进行衍生:
Library作为动态插件,并不直接集成,而是通过插件化技术,动态集成启用功能。又该如何处理呢?
在MAD Skills系列文章的第四篇中,简单提及了Hilt的扩展能力。考虑到篇幅以及AAB(Dynamic Feature)、插件化的背景,我们将在下一篇文章中对该问题展开解决方案的讨论。
末
先做到正确使用,再逐步理解原理。我会在后续系列文章中,同读者酣畅淋漓的讨论Hilt的工作原理和实现原理。