一文吃透Hilt自定义与跨壁垒

目录
  • 前言
  • 跨越 IOC容器的壁垒
    • 使用EntryPoint跨越IOC容器壁垒
  • 自定义Scope、Component
    • Scope、Component、Module的真实含义
    • 自定义
    • 定义Scope
    • 定义Component
    • 使用Manager管理Component
    • 在生命周期范围更小的Component中使用
  • 解决独立library的依赖初始化问题
    • 使用hilt的聚合能力解决问题
    • 聚合能力+EntryPoint
    • 问题衍生

前言

本文隶属于我归纳整理的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并不能完美支持,此时我们需要进行自定义。

为了下文能够更顺利的展开,我们再花一定的笔墨对 ScopeComponentModule 的含义进行澄清。

Scope、Component、Module的真实含义

前文提到两点:

  • DI框架需要 创建实例访问实例注入依赖管理生命周期
  • IOC容器内部也存在着 边界、壁垒,这和它管理实例的机制有关,在Hilt(包括Dagger)中,最大颗粒度的内部壁垒是 Component

不难理解:

  • 实例之间,也会存在依赖关系;
  • DI框架需要管理内部实例的生命周期;
  • 需要进行依赖注入的客户,本身也存在生命周期,它的依赖对象,应该结合实际需求被合理控制生命周期,避免生命周期泄漏

因此,出现了 范围、作用域Scope 的概念,它包含两个维度:实例的生命周期范围;实例之间的访问界限。

并且DI框架通过Component控制内部对象的生命周期。

举一个例子描述,以Activity为例,Activity需要进行依赖注入,并且我们不希望Activity自身需要的依赖出现生命周期泄漏,于是按照Activity的生命周期特点定义了:

  • ActivityRetainedScoped ActivityRetainedComponent,不受reCreate 影响
  • ActivityScopedActivityComponent,横竖屏切换等配置变化引起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中需要注入相关实例时。 因为 ActivityRetainedComponentUserComponent 不存在父子关系,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的工作原理和实现原理。

更多关于Hilt自定义跨壁垒的资料请关注我们其它相关文章!

(0)

相关推荐

  • 移动端开发之Jetpack Hilt技术实现解耦

    目录 Hilt是什么 Hilt使用地方 依赖注入(DI)概念 Hilt使用 导入 Hilt是什么 Hilt 是基于 Dagger2 的针对 Android场景定制化 的框架. 这有点像什么? RxAndroid 是 RxJava 的Android平台定制化扩展.Andorid虽然由Java.Kotlin构成,但是它有很多平台的特性,比如它有 Java开发 所不知道的 Context 等. Dagger框架虽然很出名,在国外也很流行,但是在国内使用其的App少之又少,列举一些缺点: 上手难,众多A

  • 哔哩哔哩在Hilt组件化的使用技术探索

    目录 前言 接入Hilt Hilt在组件化 出现了点小问题 总结 前言 DI(Dependency Injection),即“依赖注入”:组件之间依赖关系由容器在运行期决定,形象的说,即由容器动态的将某个依赖关系注入到组件之中.依赖注入的目的并非为软件系统带来更多功能,而是为了提升组件重用的频率,并为系统搭建一个灵活.可扩展的平台.通过依赖注入机制,我们只需要通过简单的配置,而无需任何代码就可指定目标需要的资源,完成自身的业务逻辑,而不需要关心具体的资源来自何处,由谁实现. 最近业务同学需要接入

  • Android Hilt依赖注入的使用讲解

    目录 什么是依赖注入 使用依赖注入的好处 Hilt中常用的预定义限定符 @HiltAndroidApp @AndroidEntryPoint @Module @InstallIn @Provides @Inject @HiltViewModel Hilt的使用 依赖 建立实体类 添加Hilt入口 提供对象 获取对象 应用与ViewModel中 使用 总结 什么是依赖注入 首先,某个类的成员变量称为依赖,如若此变量想要实例化引用其类的方法,可以通过构造函数传参或者通过某个方法获取对象,此等通过外部

  • Android Hilt Retrofit Paging3使用实例

    目录 效果视频 简述 Hilt+Retrofit 访问接口 网络实例 PagingSource ViewModel View 效果视频 简述 本Demo采用Hilt+Retrofit+Paging3完成,主要为了演示paging3分页功能的使用,下列为Demo所需要的相关依赖 //retrofit implementation 'com.squareup.retrofit2:retrofit:2.9.0' implementation 'com.squareup.retrofit2:conver

  • Android Hilt的使用以及遇到的问题

    目录 简介 导入Hilt 组件层次 组件默认绑定 简单使用 @HiltAndroidApp 介绍 使用 @AndroidEntryPoint 介绍 使用 @Module 和 @InstallIn 介绍 使用 @Provides 和 @Binds 介绍 使用 @HiltViewModel 介绍 使用 @EntryPoint 介绍 小结 简介 Hilt 提供了一种将Dagger 依赖注入到Android 应用程序的标准方法.为Android 应用程序简化提供一组标准的.简化设置.可以读的组件:且为不

  • 一文吃透Hilt自定义与跨壁垒

    目录 前言 跨越 IOC容器的壁垒 使用EntryPoint跨越IOC容器壁垒 自定义Scope.Component Scope.Component.Module的真实含义 自定义 定义Scope 定义Component 使用Manager管理Component 在生命周期范围更小的Component中使用 解决独立library的依赖初始化问题 使用hilt的聚合能力解决问题 聚合能力+EntryPoint 问题衍生 末 前言 本文隶属于我归纳整理的Android知识体系的第四部分,属于DI中

  • 一文吃透Spring Cloud gateway自定义错误处理Handler

    目录 正文 AbstractErrorWebExceptionHandler isDisconnectedClientError方法 isDisconnectedClientErrorMessage方法: 小结 NestedExceptionUtils getRoutingFunction logError write 其他的方法 afterPropertiesSet renderDefaultErrorView renderErrorView DefaultErrorWebExceptionH

  • 微信公众帐号开发-自定义菜单的创建及菜单事件响应的实例

    微信开发公众平台自定义菜单需要花钱认证才能实现,不想花钱只能玩测试账号了,不过这并不影响开发.我的开发都是基于柳峰老师的微信公众平台应用开发做的. 只要我们使用公众平台测试账号就可以开发自定义菜单了,比较方便,测试账号开放了很多接口,很方便. 在开发自定义菜单的时候可以参考微信公众平台开发者文档的自定义菜单创建. 一.自定义菜单 1.自定义菜单最多包括3个一级菜单,每个一级菜单最多包含5个二级菜单. 2.一级菜单最多4个汉字,二级菜单最多7个汉字,多出来的部分将会以"..."代替. 3

  • SpringBoot之自定义Filter获取请求参数与响应结果案例详解

    一个系统上线,肯定会或多或少的存在异常情况.为了更快更好的排雷,记录请求参数和响应结果是非常必要的.所以,Nginx 和 Tomcat 之类的 web 服务器,都提供了访问日志,可以帮助我们记录一些请求信息. 本文是在我们的应用中,定义一个Filter来实现记录请求参数和响应结果的功能. 有一定经验的都知道,如果我们在Filter中读取了HttpServletRequest或者HttpServletResponse的流,就没有办法再次读取了,这样就会造成请求异常.所以,我们需要借助 Spring

  • java集成开发SpringBoot生成接口文档示例实现

    目录 为什么要用Swagger ? Swagger集成 第一步: 引入依赖包 第二步:修改配置文件 第三步,配置API接口 Unable to infer base url For input string: "" Swagger美化 第一步: 引入依赖包 第二步:启用knife4j增强 Swagger参数分组 分组使用说明 1.在bean对象的属性里配置如下注释 2.在接口参数的时候加入组规则校验 小结 大家好,我是飘渺. SpringBoot老鸟系列的文章已经写了两篇,每篇的阅读反

  • 详解QListWidget如何实现自定义Item效果

    首先,我们来看以下实现的最终效果吧! 我觉得这并不是一个很难得问题,最近新招了一个应届生,发现在实现上述效果时,被困扰住了,是不是刚刚接触Qt的这种稍微有难度的界面时,都会有些无头绪呢? 所以,我打算分享给大家实现的思路,以及会出现的问题,就我一个开发5年C++的员工而言,针对新手会遇到哪些不懂的问题. 当前的开发环境:win10 VS2017 + Qt5.14.2 x64 在实现过程中新手会出现的难点,如下 1:如何在QListWidget中添加带有按钮.文本等其它控件的一条数据? 2:选中每

  • Bootstrap入门书籍之(零)Bootstrap简介

    什么是Bootstrap? Bootstrap是一个用于快速开发 Web 应用程序和网站的前端框架.Bootstrap 是基于 HTML.CSS.JAVASCRIPT 的. Bootstrap 是 2011 年八月在 GitHub 上发布的开源产品.Bootstrap 是由 Twitter 的 Mark Otto 和 Jacob Thornton 开发的. 基于html5.css3的bootstrap,具有下面这些诱人特性: (1)移动设备优先: (2)漂亮的设计: (3)友好的学习曲线: (4

  • Bootstrap CSS使用方法

    Bootstrap中CSS的使用方法,供大家参考,具体内容如下 1.GitHub上这样介绍 bootstrap: ☑  简单灵活可用于架构流行的用户界面和交互接口的html.css.javascript工具集. ☑  基于html5.css3的bootstrap,具有大量的诱人特性:友好的学习曲线,卓越的兼容性,响应式设计,12列格网,样式向导文档. ☑  自定义JQuery插件,完整的类库,基于Less等. 2.bootstrap模板为使IE6.7.8版本(IE9以下版本)浏览器兼容html5

  • Python利用pdfplumber实现读取PDF写入Excel

    目录 一.Python操作PDF 13大库对比 二.pdfplumber模块 1.安装 2. 加载PDF 3. pdfplumber.PDF类 4. pdfplumber.Page类 三.实战操作 1. 提取单个PDF全部页数 2. 批量提取多个PDF文件 一.Python操作PDF 13大库对比 PDF(Portable Document Format)是一种便携文档格式,便于跨操作系统传播文档.PDF文档遵循标准格式,因此存在很多可以操作PDF文档的工具,Python自然也不例外. Pyth

  • 整理关于Bootstrap排版的慕课笔记

    整理自慕课笔记 GitHub上这样介绍 bootstrap: 简单灵活可用于架构流行的用户界面和交互接口的html.css.javascript工具集. 基于html5.css3的bootstrap,具有大量的诱人特性:友好的学习曲线,卓越的兼容性,响应式设计,12列格网,样式向导文档. 自定义JQuery插件,完整的类库,基于Less等. 标题 主标题 Bootstrap和普通的HTML页面一样,定义标题都是使用标签<h1>到<h6>,只不过Bootstrap覆盖了其默认的样式,

随机推荐