为什么要用Hilt
我们做架构设计,核心的诉求就是"高内聚,低耦合"。高内聚,可以使得我们的代码可维护,高性能,代码更加健壮,减少线上风险。低耦合可以避免代码交织导致的代码劣化,这在多团队共同维护一个大型复杂业务项目时,显得更为重要。在此基础上,我们又可以延伸,进行分层架构(MVC,MVP,MVVM,MVI)和模块化拆分,将项目按功能划分为独立模块(Clean Architecture,Android Architecture)。
Hilt是Google基于Dagger2开发的Android依赖注入框架,旨在简化Dagger在Android开发中的依赖管理复杂程度。事实上,即便如此,Hilt的学习和使用成本依旧相当高。事实上,所有能够帮助分割业务点,内聚逻辑的方案,都会导致业务代码理解难度上升,无论是从早期的event bus,以及现在的flow。而Hilt可以说将业务颗粒度做的比所有的方案都细,因此,改造后的项目的理解难度会非常高。这在我看来是使用Hilt进行模块化的缺点。
这里简单说一下Hilt,最主要是Dagger2是做什么的。
Dagger2是一个依赖注入的工具。它可以帮助我们在不必调用构建函数的情况下,达到类与类之间的依赖。即如果A有一个成员对象B,我们可以将B的创建放在其他地方,而不需要显式地在A里面进行调用。 那么问题来了,就为这么个功能,至于费这么大的功夫吗?答案是需要。举个例子,在一个复杂的项目中,我们在一个很小的业务模块中,可以有100处地方要用到B的创建,那么我们要写100次 B.build的吗?更有可能,B还依赖C,D,并且它们的参数在每次使用场景还不同。如果是传统的写法,我们的dirty code会是指数级增长。
那么问题又来了,我把B,C,D搞一个单例,不就可以吗?一份代码,到处使用。答案确实是可以的,但是单例最大的问题是对资源的持有,你需要手动对其进行释放。所以这并不是一个好的选择。 那么好的选择,自然是使用Dagger2了。Dagger2里有scope的概念,它为每一个组件设置好了生效区域,scope之外,组件会自动释放,这完美得解决了内存泄露的问题。此外,Dagger2基于scope进行父子的作用域继承,我们可以基于此,按照不同的scope获取到不同的对象。
模块化设计
模块化设计的目的是将复杂的业务逻辑,拆解为独立和可复用的模块。按照职能边界或者功能,将业务拆分为合适颗粒度的业务单元,并且使得每个业务单元都能专注于特定的任务。分而治之,由小及大,最终将一个个业务模块构造成一个完整的系统。而Hilt起到的就是讲这一个巨大系统串联起来的链路功能。
也可以说,Hilt的目标就是复用,尽可能地写更少的代码。
举个例子,我们公司中主App线负责其中IM业务的一个团队。所有单聊,群聊,会话相关的业务都由我们团队负责。接下来,在已经接入了Hilt依赖并且已经掌握Hilt开发能力的前提下,我们讨论如何使用Hilt重构一个业务模块。
业务层级划分
在toy program中,Hilt当然可以直接进行使用,但是往往一个商业应用涉及到上百万行代码和几个业务团队的工作。即使一个业务团队所负责的业务,将整个业务逻辑都用一个scope来涵盖,也往往出现component数量爆炸的情况。
所以第一件事就是对业务进行划分。
以IM团队为例,我们的顶层业务是单聊,群聊和会话相关的业务。以及其他IM相关的业务场景,比如直播,聊天室之类的。在底层则是对IM SDK的封装和使用。而中层,则是涉及到了对IM业务层的封装,并且它们需要向外提供IM能力,为顶层业务提供通用能力。

我推荐按照三个层级进行划分,对应到Android工程中,就是每一个都是一个子module。上层依赖下层。在每一个module中,我们都可以使用Hilt进行业务颗粒拆解。
对于每个module,我们可以按照Android通用架构进行构造。
UI组件,Scope,Holder,Repo,DataStore就是我们实际开发时所用到的业务组件。
组件
一个Hilt 设计的业务Module是由哪些部分构造的呢?
UI Component
UI展示的最小颗粒,具备自身的状态State,并监听Service提供的State。UI原则上只和Service进行交互,向Service中提供StateFlow,并且接受Service中的StateFlow。
kotlin
class HeaderUiComponent @Inject constructor(
@ActivityScope private val scope: CoroutineScope,
private val UIflow: UIFlowService,
private val service: HeaderService,
)
HeaderUiComponent将创建一个Header UI,它会从UIflow中获取到UI group,并通过compose或者xml创建出View流入UIflow中。这是UI workflow,而HeaderService则具备管理HeaderUiComponent状态的能力。这使得当其他的UIComponent需要Header的状态时,可以对HeaderService进行依赖。
Scope
Scope是业务作用域,按照当前UI组件的生命周期,可以进行划分为ApplicationScope,ActivityScope。也可以按照业务逻辑生命周期进行定义,比如一次IM生命周期为IMScope,一次播放生命周期为MediaScope。
Scope是Hilt重构的核心和最大的优势所在。根据不同的业务场景,使用不同的Scope,可以避免内存泄漏的风险。在Hilt中,Scope的范围是随着依赖的Component走的,注意,这里的Component是Hilt自身的Component。当Component创建时,Scope生效,当Component生命周期结束时,Scope释放。
Service
Service是业务核心,主要用于处理业务逻辑和State状态变化。
我们原则上规定Service是最小的业务颗粒,即一个Service不应该依赖其他的Service,而是依赖数据和状态,即Repo。Service的创建和生命周期和Scope一样,也是通过Hilt框架本身进行管理的。
kotlin
class HeaderService @Inject constructor(
@ActivityScope private val scope: CoroutineScope,
private val repo: UserRepo,
)
Repo
Repo是数据的最小管理单元,用于进行数据的请求和存储,比如DataStore,网络数据以及Room数据库的存储。它负责这部分的数据存储和处理,并且在需要时,可以将处理后的数据flow出去。
kotlin
class UserDataRepo @Inject constructor(
@ActivityScope private val scope: CoroutineScope,
) : IDataRepo
Repo是业务层的数据存储和处理,具体实现,比如网络处理,Room和DataStore的实现,可以使用Holder进行额外封装,由Repo进行调用。我们应当尽量避免Repo之间的互相依赖。
Scope的驱动
我们说过Scope是核心,它的意义在于规范管理它所持有的资源和对象,在业务的不同时期,开发者可以获得不同的对象,从而避免数据异常。
举个例子,我们需要实现一个音视频的上下滑动的页面,每次上下切一页,都是不同的视频。对于当前业务场景来说,其上下文都是一个Activity生命周期,如果按照原有的协程而言,这是不满足业务需求的。当前的video信息在每次切换完视频之后,就应当更新。因此我们往往希望会有一个业务的卡片Scope。此外,在同一个视频播放时,我们也希望有一个独立的Scope,方便我们跟踪每一次播放的信息。
Scope越细致,我们所能获得数据就越精确。
Scope是由上往下,树形结构集成的。
以上图为例,如果我们想开发一个音视频的上下滑动的页面。
- ApplicationScope:全局生命周期,触发点是App启动
- UserScope:用户信息生命周期,触发点是用户登陆和登出
- ActivityScope:Activity启动生命周期
- SpaceScope:播放页面和个人空间生命周期
- CardScope:音视频卡片生命周期
- MediaScope:单个音视频生命周期。在当前音视频播放和播放结束为生命周期起始
在Hilt使用时,按照不用的业务场景,规定当前对象的生命周期是确保数据正确的关键。我们常常将Scope和Component绑定,通过Hilt框架自身来帮助我们进行Scope的创建和结束。 在业务层,我们在适当的业务节点,创建Component,从而驱动Scope的创建。 按照CardScope为例子,创建一个Component。
kotlin
@Component(modules = {
NetworkModule.class,
MakeModule.class,
HeadModule.class,
ScopeModule.class
})
interface CardComponent {
fun inject(activity:Activity);
@Component.Builder
interface Builder {
fun build():CardComponent;
}
}
在切换视频的时机进行Component的创建
kotlin
var component: CardComponent = DaggerAppComponent.builder()
.build()
至此,所有依赖CardComponent的对象的生命周期都从这时候开始。
kotlin
@Module
@InstallIn(SingletonComponent::class)
internal object ScopeModule {
@Provides
@Singleton
@UserScope
fun providesCoroutineScope(
@Dispatcher(Default) dispatcher: CoroutineDispatcher,
): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcher)
}
通过上面代码,这样我们可以绑定UserScope和CardComponent的生命周期。
在CardComponent中的所有的UiComponent,Service,Repo的生命周期,都将和CardComponent绑定,即是视频的切入和切出。
这是UserScope的一个例子,但是按照上图中,我们可以看到,我们创建了6个Scope,一个CardScope执行区间,可能会有多个不同的MediaScope进行创建。这其实是充分考虑到了当前业务的复杂性。开发者只要考虑到自身业务的边界线,然后根据边界线选取适合的Scope和组件进行开发,从而大大降低了业务开发成本。
这也是我们为什么说Scope是业务架构设计的核心,以及为什么要使用Scope进行业务驱动的原因。
UI flow
Scope是业务的基础,但是对于一般的业务开发来说,UI 组件才是开发者日常要面对的。在上面的工作中,我们对业务逻辑进行了模块化拆解。同时,我们也要对UI进行模块化拆解,将一个复杂的业务系统,拆分为View+Controller+Model的简易逻辑(万法归一,又看到了久违的MVC)。
按照UiComponent的拆分逻辑,我们建议按照XML+Compose的技术栈进行业务开发。对于主页面按照XML进行整体的骨架搭建,将每一个group作为UI Flow向外传递,例如BotoomFrameLayout。而通过UIFlow,我们可以通过UI组件化的形式,将UI Component作为一个最小的业务开发颗粒,进行实现。
kotlin
interface IUIComponent<T> {
fun plugin(info : T?)
fun plugout(info : T?)
}
abstract class ComposeView :IUIComponent<T> {
abstract fun createView():ComposeView
}
这么做的一个好处,在于我们更希望我们的骨架是固定的,但是我们的肌肉是灵活的。这里的肌肉即是View的具体实现。我们可以使用XML,dataBinding或者Compose来实现。
所以UiComponent在这个层面上讲,并不是一个View,它更像是一个容器。我们可以在此基础上进行再开发,使其适配更多的UI开发方式。
总结
Hilt拆分业务模块,进行组件化开发,可以达到极致的细粒度。在实际开发中,这大大降低了开发成本,但同时也增大了全局业务理解的心智成本。
目前业界还是朝着模块化开发模式的方向发展,尤其是上百万行代码的"巨仓项目",这使得Hilt的应用也更加的广泛。模块化和组件化的本质思想是一样,都是为了业务解耦和代码重用。
说几句题外话。
Hilt的争议一直都有。因为它会使得业务阅读变得复杂。面对很多复杂的,甚至于炫技的代码,很多人钟情于此,并以此为荣,也有人会觉得是屎山。
作为开发者而言,再抽象,再复杂的代码,本质上都是对业务共同点的聚合。我本身还是很厌恶冗杂和高度抽象的代码的。过度设计往往是我在过于开发中最头疼的事情,往往在很多情况下,我们会给自己假定一个复杂的未来可能会有的业务需求,然后逼迫自己搞出高度抽象的代码。
在这种情况,大家还是多多默念SOILD原则。软件开发所有的设计模式归根结底都可以归纳于此:
- (SRP) 单一职责原则 Single Responsibility Principle
- (OCP) 开闭原则 Open Closed Principle
- (LSP) 里氏替换原则 Liskov Substitution Principle
- (ISP) 接口隔离原则 Interface Segregation Principle
- (DIP) 依赖倒置原则 Dependency Inversion Principle
希望大家都不要为了写代码而写代码,而是要明白业务才是目标。