【现代 Android APP 架构】09. 聊一聊依赖注入在 Android 开发中的应用

移动应用与桌面应用有本质上的不同------用户每次只能访问一个应用,而且它们往往很小,只专注于一个特定的任务。------ 《安卓传奇 安卓缔造团队回忆录》,Chet Haase

依赖注入(Dependency Injection,下文以DI简称)是控制反转(Inverse Of Control)思想的一种实践,笔者在早期基于Spring进行后端开发时,就已经尝试使用过类似工具。DI 打破了常规的 "创建对象->使用对象" 顺序,将对象的生成过程进行解耦,赋予软件更大的灵活性,同时也能在功能发生变化/扩展时,将发生变化的部分与不变部分进行分离,减少变更带来的工作量。

1. 什么是依赖注入

要想了解什么是 "依赖注入" ,首先需要理解什么是 "非依赖注入"

1.1 非依赖注入

在面向对象的编程语言中,"依赖注入"可以分为两个词,"依赖""注入" 。所谓"依赖",是指两个对象之间存在的 调用关系对象A调用对象B,则可认为A对B有依赖 ,而B对A不存在依赖。在这种场景下,"注入"则描述了"将对象B提供给对象A的方式",是通过"注入"而非直接 new 出来对象。

"非依赖注入"就是最平实的 "创建对象->使用对象" 方式。我们以经典的"汽车"和"发动机"两者关系为例,汽车对象持有发动机对象实例,因此存在 "汽车->发动机" 的依赖关系。

【汽车持有发动机依赖】

kotlin 复制代码
class Car {

    private val engine = Engine()

    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val car = Car()
    car.start()
}

在上述代码中,可以看到Car对象内部 创建并持有 了一个Engine对象,两者耦合在一起,如果未来从"汽油发动机"升级为"电机",则必须对Car类的代码进行修改。这违背了"开放-关闭"(对扩展开放,对修改关闭)的原则。因此,这是一种不利于扩展的设计模式。

1.2 手动依赖注入

【手动依赖注入Engine参数】

既然"发动机"对象未来有变化的可能,那么我们把它作为 构造函数的一个参数 传给Car不就可以了?这便是"手动依赖注入",其代码如下:

kotlin 复制代码
class Car(private val engine: Engine) {
    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val engine = Engine()
    val car = Car(engine)
    car.start()
}

手动依赖注入分为两种方式

  • 构造函数注入:如上述代码,对象作为构造函数的参数传入,不支持动态变化
  • setter注入 :提供setter函数,可以随时设置对象
kotlin 复制代码
// setter注入
class Car {
    lateinit var engine: Engine

    fun start() {
        engine.start()
    }
}

fun main(args: Array) {
    val car = Car()
    car.engine = Engine()
    car.start()
}

1.3 现代架构中的手动依赖注入

【AndroidAPP架构】

在 AndroidAPP 架构图中,对象延伸出的箭头指向其依赖的对象,从图中可以看出:

  • Activity 依赖 ViewModel
  • ViewModel 依赖 Repository
  • Repository 依赖 DataSource(Model、RemoteDataSource)

以使用 Repository 为例,我们需要手动为其注入 DataSource 实例。

kotlin 复制代码
class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // In order to satisfy the dependencies of LoginViewModel, you have to also
        // satisfy the dependencies of all of its dependencies recursively.
        // First, create retrofit which is the dependency of UserRemoteDataSource
        val retrofit = Retrofit.Builder()
            .baseUrl("https://example.com")
            .build()
            .create(LoginService::class.java)

        // Then, satisfy the dependencies of UserRepository
        val remoteDataSource = UserRemoteDataSource(retrofit)
        val localDataSource = UserLocalDataSource()

        // Now you can create an instance of UserRepository that LoginViewModel needs
        val userRepository = UserRepository(localDataSource, remoteDataSource)

        // Lastly, create an instance of LoginViewModel with userRepository
        loginViewModel = LoginViewModel(userRepository)
    }
}

在 Activity 中,我们需要创建一系列的依赖关系对象,这样会导致项目中出现大量的 样板代码Boilerplate code)。想象一下,对于每个 Activity 都需要创建一系列相似的代码,这是一件枯燥并且容易出错的事情。

所以我们思考,是否可以将创建依赖关系的代码抽出来,做成工厂模式呢?

1.4 使用工厂模式进行手动依赖注入

可以设计一个AppContainer类,作为 Repository 对象的容器,在每一个 Activity 需要使用 Repository 时,从该工厂里面获取。

kotlin 复制代码
// Application 中创建工厂类
// Container of objects shared across the whole app
class AppContainer {

    // Since you want to expose userRepository out of the container, you need to satisfy
    // its dependencies as you did before
    private val retrofit = Retrofit.Builder()
                            .baseUrl("https://example.com")
                            .build()
                            .create(LoginService::class.java)

    private val remoteDataSource = UserRemoteDataSource(retrofit)
    private val localDataSource = UserLocalDataSource()

    // userRepository is not private; it'll be exposed
    val userRepository = UserRepository(localDataSource, remoteDataSource)
}

// Custom Application class that needs to be specified
// in the AndroidManifest.xml file
class MyApplication : Application() {

    // Instance of AppContainer that will be used by all the Activities of the app
    val appContainer = AppContainer()
}

// Activity 中使用工厂类
class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Gets userRepository from the instance of AppContainer in Application
        val appContainer = (application as MyApplication).appContainer
        loginViewModel = LoginViewModel(appContainer.userRepository)
    }
}

更进一步,将 ViewModel 也通过工厂模式生成,同样在 AppContainer 中对其进行管理。

kotlin 复制代码
// Definition of a Factory interface with a function to create objects of a type
interface Factory<T> {
    fun create(): T
}

// Factory for LoginViewModel.
// Since LoginViewModel depends on UserRepository, in order to create instances of
// LoginViewModel, you need an instance of UserRepository that you pass as a parameter.
class LoginViewModelFactory(private val userRepository: UserRepository) : Factory {
    override fun create(): LoginViewModel {
        return LoginViewModel(userRepository)
    }
}

// AppContainer can now provide instances of LoginViewModel with LoginViewModelFactory
class AppContainer {
    ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)

    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Gets LoginViewModelFactory from the application instance of AppContainer
        // to create a new LoginViewModel instance
        val appContainer = (application as MyApplication).appContainer
        loginViewModel = appContainer.loginViewModelFactory.create()
    }
}

1.5 容器的生命周期管理

在使用容器的过程中,如果只在 Application.onCreate() 时初始化,而不去销毁它,此时该容器的生命周期就是 跟随进程的,是常驻于内存中的实例。在实际应用场景里,并不是所有注入对象都要维持如此之长的生命周期。

  • 页面销毁后,注入对象/容器就不再有存在的意义
  • 业务逻辑同时存在多个实例,对于注入的类,此时需要生成多个对象

因此,"生命周期管理" 也是进行依赖注入时必须要考虑的问题,在手动依赖注入过程中,可以通过LifecycleObserver,将容器和 Activity/Application 的生命周期绑定。

如下例,在 LoginActivity 中,onCreate()时创建LoginContainer,它用来生成 LoginViewModelonDestroy() 时则销毁 LoginContainer

kotlin 复制代码
class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel
    private lateinit var loginData: LoginUserData
    private lateinit var appContainer: AppContainer


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        appContainer = (application as MyApplication).appContainer

        // Login flow has started. Populate loginContainer in AppContainer
        appContainer.loginContainer = LoginContainer(appContainer.userRepository)

        loginViewModel = appContainer.loginContainer.loginViewModelFactory.create()
        loginData = appContainer.loginContainer.loginData
    }

    override fun onDestroy() {
        // Login flow is finishing
        // Removing the instance of loginContainer in the AppContainer
        appContainer.loginContainer = null
        super.onDestroy()
    }
}

2. 自动依赖注入

终于来到了本文最激动人心的部分------ 使用 Hilt 进行依赖注入

2.1 搭建 Hilt 环境

我们需要在项目里引入 Hilt,分为两个步骤:

2.1.1 在gradle文件中接入依赖

在项目根目录的 build.gradle 中增加 Hilt 插件。

kotlin 复制代码
plugins {
  ...
  id("com.google.dagger.hilt.android") version "2.57.1" apply false
}

随后在 主moduleapp/build.gradle 文件里面使用上一步的插件,并声明hilt-android依赖。

kotlin 复制代码
plugins {
  id("com.google.devtools.ksp")
  id("com.google.dagger.hilt.android")
}

android {
  ...
}

dependencies {
  implementation("com.google.dagger:hilt-android:2.57.1")
  ksp("com.google.dagger:hilt-android-compiler:2.57.1")
}

到这一步,就完成了gradle层面的接入。

2.1.2 在Kotlin类上创建入口点

接下来我们要在使用 Hilt 的地方创建入口点,对于 Android 来说,首当其冲的便是 Application,使用 @HiltAndroidApp 进行注解,这一步实际上是创建了跟随 Application 生命周期的 Hilt 组件,它是应用的根组件,其他 Hilt 组件可以使用它。

kotlin 复制代码
@HiltAndroidApp
class ExampleApplication : Application() { ... }

在 Activity 中,我们使用 @AndroidEntryPoint 创建入口点。

kotlin 复制代码
@AndroidEntryPoint
class ExampleActivity : AppCompatActivity() { ... }

对于Android项目来说,支持在以下地方创建Hilt的入口点。

  • Application (by using @HiltAndroidApp)
  • ViewModel (by using @HiltViewModel)
  • Activity
  • Fragment
  • View
  • Service
  • BroadcastReceiver

@AndroidEntryPoint实际上是创建了一个独立的Hilt组件,并将其加入到组件树当中。在组件树里面,最上方是根节点的@HiltAndroidApp组件,它向下生长,位于低层的组件可以接收到上层组件管理的对象,整株组件树如下图所示。

2.2 使用自动依赖注入

在完成搭建环境后,就可以在代码里通过注入自动创建依赖了。我们先看使用最多的,即作为构造参数的场景,通过 @Inject constructor 注解,告知系统该构造函数的参数需要通过 Hilt 进行注入。

kotlin 复制代码
class AnalyticsAdapter @Inject constructor(
  private val service: AnalyticsService
) { ... }

在上例中,AnalyticsAdapter 需要依赖 AnalyticsService,此时无需 new AnalyticsService(),而是通过 Hilt 的对象管理机制自动提供。

接下来你可能会问,对于 AnalyticsService,Hilt 又如何知道要如何创建它的实例?即使该类也是通过注解自动生成,但归根结底总会追溯到某个原点,此时就涉及到了 Hilt 中的另一个重要概念------

Module

2.3 module(模块)在Hilt中的作用

在 Hilt 搭建的依赖注入体系中,module 承担了 "创建实例" 的职责------并不是所有的实例都可以通过@Inject constructor注解来自动创建,总有一些实例是无法通过 Hilt 的组件树自动进行构建的,例如那些位于 叶子结点 的类。

此时可以借助 module,完成叶子结点的实例化,有两种写法。

  • @Binds注解:用于注解在抽象类/接口上面,为该抽象类/接口提供具体实现
  • @Provides注解:用于注解在单例上面,手动创建被注解的对象
kotlin 复制代码
interface AnalyticsService { // ===> 待注入的接口,下文分别通过@Binds、@Provides提供两种注入方式
  fun analyticsMethods()
}

// Constructor-injected, because Hilt needs to know how to
// provide instances of AnalyticsServiceImpl, too.
class AnalyticsServiceImpl @Inject constructor( // ===> AnalyticsServiceImpl.kt类在我们的项目中,可以修改其源码(增加@Inject注解)
  ...
) : AnalyticsService { ... }

// 首先是@Binds方式
@Module // ===> 所有module必须声明
@InstallIn(ActivityComponent::class) // ===> 注入到Activity组件
abstract class AnalyticsModule { // ===> 抽象类 or 接口

  @Binds // ===> 开始注入
  abstract fun bindAnalyticsService(
    analyticsServiceImpl: AnalyticsServiceImpl
  ): AnalyticsService
}

// 随后是@Provides方式
@Module // ===> 同样需要声明
@InstallIn(ActivityComponent::class) // ===> 同上
object AnalyticsModule { // ===> 注意这里是单例

  @Provides
  fun provideAnalyticsService(
    // Potential dependencies of this type
  ): AnalyticsService {
      return Retrofit.Builder() // ===> 手动创建实例进行注入,生命周期为ActivityComponent
               .baseUrl("https://example.com")
               .build()
               .create(AnalyticsService::class.java)
  }
}

2.4 对同一类型采用多种注入实现

在实际的开发进程中,存在这样的场景:对于同一个接口,APP开发阶段使用 mock实现 ,在进入前后端联调时,切换为 后端接口实现。Hilt对此场景也提供了注解的方式,便于进行切换。

kotlin 复制代码
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthInterceptorOkHttpClient // ===> 注解1拦截器

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class OtherInterceptorOkHttpClient // ===> 注解2拦截器

生成注入对象时,使用上面两个注解,生成不同的OkHttpClient对象。

kotlin 复制代码
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

  @AuthInterceptorOkHttpClient // ===> 注解1生成的对象
  @Provides
  fun provideAuthInterceptorOkHttpClient(
    authInterceptor: AuthInterceptor
  ): OkHttpClient {
      return OkHttpClient.Builder()
               .addInterceptor(authInterceptor)
               .build()
  }

  @OtherInterceptorOkHttpClient // ===> 注解2生成的对象
  @Provides
  fun provideOtherInterceptorOkHttpClient(
    otherInterceptor: OtherInterceptor
  ): OkHttpClient {
      return OkHttpClient.Builder()
               .addInterceptor(otherInterceptor)
               .build()
  }
}

进行对象注入时,则可以方便地在@AuthInterceptorOkHttpClient@OtherInterceptorOkHttpClient两者之间切换。

kotlin 复制代码
// As a dependency of another class.
@Module
@InstallIn(ActivityComponent::class)
object AnalyticsModule {

  @Provides
  fun provideAnalyticsService(
    @AuthInterceptorOkHttpClient okHttpClient: OkHttpClient // ===> module函数参数注入
  ): AnalyticsService {
      return Retrofit.Builder()
               .baseUrl("https://example.com")
               .client(okHttpClient)
               .build()
               .create(AnalyticsService::class.java)
  }
}

// As a dependency of a constructor-injected class.
class ExampleServiceImpl @Inject constructor(
  @AuthInterceptorOkHttpClient private val okHttpClient: OkHttpClient // ===> 构造函数参数注入
) : ...

// At field injection.
@AndroidEntryPoint
class ExampleActivity: AppCompatActivity() {

  @AuthInterceptorOkHttpClient // ===> 成员变量注入
  @Inject lateinit var okHttpClient: OkHttpClient
}

2.5 可供安装的Android组件(Component)

在上面的例子中,你可能会留意到,声明 @Module 后,又使用了 @InstallIn(ActivityComponent::class),这一行声明的作用是指定该module进行安装的目标,该目标对应着Android系统中的不同组件,这也是作为依赖注入框架,Hilt在Dagger基础上所增加的内容。

Hilt 内置了一系列 Component,如下表所示:

Hilt component 组件 Scope 作用域 Inject for 对应的Android组件 创建时机 销毁时机
SingletonComponent @Singleton Application Application#onCreate() Application destoryed
ActivityRetainedComponent @ActivityRetainedScoped Application Activity#onCreate() Activity#onDestroy()
ViewModelComponent @ViewModelScoped ViewModel ViewModel created ViewModel destroyed
ActivityComponent @ActivityScoped Activity Activity#onCreate() Activity#onDestroy()
FragmentComponent @FragmentScoped Fragment Fragment#onAttach() Fragment#onDestroy()
ViewComponent @ViewScoped View View#super() View destroyed
ViewWithFragmentComponent @ViewScoped View annotated with @WithFragmentBindings View#super() View destroyed
ServiceComponent @ServiceScoped Service Service#onCreate() Service#onDestroy()

组件的生命周期描述了它自己创建/销毁的时机,通过该组件进行注入的对象上,如果没有声明作用域,则每次访问该对象,都会创建一个新的实例 。这样做的好处是即用即抛,可以避免对象泄露。但业务上难免会有维持、共享状态的场景,此时就要为注入实例加上域注解,声明该实例的管理方式。在日常开发中,使用比较多的是单例域 @Singleton 和 Activity 域 @ActivityScoped

2.6 获取内置注入对象的简化写法

在声明依赖注入时,对于Hilt内置的Android组件,可以直接在构造器中获取它,如下例。

kotlin 复制代码
// 直接获取 ApplicationContext 对象
class AnalyticsServiceImpl @Inject constructor(
  @ApplicationContext context: Context
) : AnalyticsService { ... }

// The Application binding is available without qualifiers.
class AnalyticsServiceImpl @Inject constructor(
  application: Application // 也可以省略掉注解,仍然生效
) : AnalyticsService { ... }

// 直接获取Activity对象
class AnalyticsAdapter @Inject constructor(
  @ActivityContext context: Context
) { ... }

// The Activity binding is available without qualifiers.
class AnalyticsAdapter @Inject constructor(
  activity: FragmentActivity // 省略注解的写法
) { ... }

3. 对依赖注入的再思考

【六大设计原则】

依赖注入不仅仅是一项技术、一个框架,更是架构设计思想的具象化 。它将常规的 "创建A,把A传给B" 升级成 "创建B时,使其自动获取A" 。在我看来,它有以下3点优势:

1. 解耦

毫无疑问,自动化依赖注入能够简化对象的创建过程,将对象的依赖关系自动化处理,降低代码耦合。作为开发和维护人员,我们能够自由地替换接口实现,对修改关闭,对扩展开放(开闭原则),有利于软件版本的长期迭代。

2. 生命周期管理

在"依赖倒置"的基础上,Hilt还与Android的生命周期紧密结合,自动生成与Application、Activity 等组件一致的组件实现。在自动提供组件注入的基础上,防止由于生命周期管理不当导致的对象泄露。

举一个我在真实业务中遇到的场景,当时对依赖注入的组件树理解还不透彻,误将一个ActivityContext注入给SingletonComponent,编码时未提示异常,但构建时会报错------生命周期冲突,Hilt可以在构建时自动识别这种泄露问题,为编码安全增加一道保障。

3. 使架构更加清晰

对于使用 Hilt 的项目来说,还有一个并不明显但十分重要的优点,那就是 Hilt 的依赖注入写法,能够让架构分层更加清晰。项目中每一个关键组件的依赖方都可以在依赖关系中一眼看出,这些组件的生命周期也会更加直观地呈现出来。

参考资料

相关推荐
敲上瘾5 小时前
Docker镜像构建优化指南:CMD/ENTRYPOINT、多阶段构建与缓存优化
运维·缓存·docker·容器·架构
♡喜欢做梦5 小时前
MyBatis XML 配置文件:从配置规范到 CRUD 开发实践
xml·java·java-ee·mybatis
爱吃烤鸡翅的酸菜鱼5 小时前
Spring Boot 实现 WebSocket 实时通信:从原理到生产级实战
java·开发语言·spring boot·后端·websocket·spring
J不A秃V头A5 小时前
Maven的分发管理与依赖拉取
java·maven
一只会写代码的猫8 小时前
面向高性能计算与网络服务的C++微内核架构设计与多线程优化实践探索与经验分享
java·开发语言·jvm
萤丰信息9 小时前
智慧园区能源革命:从“耗电黑洞”到零碳样本的蜕变
java·大数据·人工智能·科技·安全·能源·智慧园区
曹牧9 小时前
Eclipse为方法添加注释
java·ide·eclipse
我叫张小白。10 小时前
Spring Boot拦截器详解:实现统一的JWT认证
java·spring boot·web·jwt·拦截器·interceptor
0***1410 小时前
PHP在微服务中的架构设计
微服务·云原生·架构
uzong11 小时前
Mermaid: AI 时代画图的魔法工具
后端·架构