验证码: 看不清楚,换一张 查询 注册会员,免验证
  • {{ basic.site_slogan }}
  • 打开微信扫一扫,
    您还可以在这里找到我们哟

    关注我们

Hilt自定义与跨壁垒的方法是什么

阅读:487 来源:乙速云 作者:代码code

Hilt自定义与跨壁垒的方法是什么

      跨越 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关系参考:

      Hilt自定义与跨壁垒的方法是什么

      而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 {
          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 {
          @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 {
          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中:

      @HiltAndroidApp
      class App : Application() {
          @Inject
          lateinit var initializers: Set<@JvmSuppressWildcards Function1>
          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)、插件化的背景,我们将在下一篇文章中对该问题展开解决方案的讨论。

    分享到:
    *特别声明:以上内容来自于网络收集,著作权属原作者所有,如有侵权,请联系我们: hlamps#outlook.com (#换成@)。
    相关文章
    {{ v.title }}
    {{ v.description||(cleanHtml(v.content)).substr(0,100)+'···' }}
    你可能感兴趣
    推荐阅读 更多>