通过 Hilt 反观依赖注入

925 阅读8分钟

前言

通过总结 Hilt 的使用方法,感受一下依赖注入的魅力。

Android 中使用 Hilt

关于如何使用 Hilt,参考 Jetpack新成员,一篇文章带你玩转Hilt和依赖注入 作为入门教程即可。

无论是在 Java 还是 Kotlin 中,日常开发中,我们总是从类 Class 的维度去看待我们编写的代码文件。但是从依赖注入的维度出发,更关心的是容器这个概念。(不是 docker 那个容器哈,不要混了)。

通过在 Application 添加 @HiltAndroidApp 注解。 Application 就是一个应用级的容器了,这就意味着在这个容器里的所有依赖项全局通用。

同理对于 Hilt 官方支持的组件,通过添加 @AndroidEntryPoint (或者是 @HiltViewModel)注解,我们就拥有了一个支持依赖注入的类,也就是所谓的 Ioc容器。

有了容器(类)接下来就是容器内部字段的初始化,初始化这些字段之后调用相应的方法实现各种功能。

当我们要实现一个页面内容时,无论是 MVC/MVP 还是 MVVM 框架。其实就是在用组合的方式把各种功能组件窜到一起,完成各自的初始,开始调用各类方法 。从 MVC 一步步发展到 MVVM, 组合的方式也许变了,但不变的是依赖项的初始化。无论是创建 Presenter 还是实例化 ViewModel,这个步骤还是耦合在页面这一级别。当然了,导致这个问题的原因还是因为 Context,在 Android 中很多组件的实例化最终还是会依赖到 Context 上。

final-architecture.png

以上图官方推荐的 MVVM 架构示意图为例,每一次写一个新的业务时,写 UI(Activity/Frament) 时依赖 ViewModel,然后又去写 ViewModel ,ViewModel 又依赖 Repository ,Repository 又依赖 Retrofit/Http 或者是 Room。 最后就是按照依赖的反向顺序把所有的组件准备好,然后开始逐层创建依赖项的实例。这个过程非常繁琐,相当于是手动注入每一个依赖项。

而使用 Hilt 进行依赖注入的管理之后,就可以非常方便了。 以 Android 官方示例 sunflower 代码为例

@AndroidEntryPoint
class PlantListFragment : Fragment() {

    private val viewModel: PlantListViewModel by viewModels()

    ....

    private fun subscribeUi(adapter: PlantAdapter) {
        viewModel.plants.observe(viewLifecycleOwner) { plants ->
            adapter.submitList(plants)
        }
    }
}

@HiltViewModel
class PlantListViewModel @Inject internal constructor(
    plantRepository: PlantRepository,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {
}

@Singleton
class PlantRepository @Inject constructor(private val plantDao: PlantDao) {
  ...
}

@InstallIn(SingletonComponent::class)
@Module
class DatabaseModule {


    // AppDataBase 虽然是抽象类,但在 Room 标准使用方法中,可以通过创建单例的方式提供。
    // 唯一需要依赖的 Context ,通过内建的 @ApplicationContext 提供支持。
    @Singleton
    @Provides
    fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase {
        return AppDatabase.getInstance(context)
    }
    
    // PlantDao 是个接口,没有构造函数,通过 Provides 提供 
    // 这个方法依赖的 AppDatabase 通过上面的 provideAppDatabase 提供,并且都是单例
    @Provides
    fun providePlantDao(appDatabase: AppDatabase): PlantDao {
        return appDatabase.plantDao()
    }
}

一个列表页需要的数据,沿着 ViewModel-->Repository-->Dao-->Database-->Context 的依赖链,开发者只需要明确好各个依赖项具体实例化的方式就好,在实际运行时 Hilt 会沿着依赖链反向依次初始化各个依赖项。用 @Inject 注解的依赖项(属性、字段)都会自动的完成实例化。

依赖也不会无限套娃,在 Android 开发中,很多组件的实例化最终会依赖 Context (ApplicationContext 或者是 Activity Context),对于这些依赖,官方已经提供了内建的支持,需要的时候直接使用即可,如上面 provideAppDatabase 中使用 Context 一样。

对于实例具体创建的方式,普通的类可以直接修改构造函数,添加 @Inject 注解,其他的无法进行直接实例化的配合使用 @Module 和 @Provides 注解自定义方法即可。@Binds 感觉有些鸡肋,无法通过构造函数进行注入的类,统一用 @Provides 就好了。

依赖注入的本质就是将容器内实例的实例化过程剥离到了外部,Hilt 相比传统的构造函数、setXXX(Obj o) 注入的方式更彻底,把解耦做到了极致。

依赖注入容器

随着依赖项越来越多,依赖关系越来越复杂,我们需要考虑依赖项的生命周期作用域。 好在 Hilt 已经结合 Android 组件的生命周期提供了完善的支持。

hilt-hierarchy.svg

将模块安装到组件后,其绑定就可以用作该组件中其他绑定的依赖项,也可以用作组件层次结构中该组件下的任何子组件中其他绑定的依赖项

这里就需要就结合实际使用的场景,选择合适的方式进行 Install ,具体来说就是 @Installin 这个注解的参数选择了。选择单例,亦或是 Activity 作用域。对于依赖项,创建合适并且合理的 Ioc 容器,可以保证代码执行的性能和稳定性。

为同一类型提供多个绑定

Hilt 中提供的一个非常实用的功能是为同一个实例提供不同的实现方式。还是以之前的列表页为例,对于 RecyclerView 来说,adapter 就可以决定列表具体显示的内容(数据相同的情况下)。因此,我们可以实现 adapter 的注入,简化 Android 中实现一个列表的操作。

  • 定义不同的 Adapter

class AlbumAdapter(val context: Context, val imageList: ArrayList<Uri>, val imageSize: Int) :
    RecyclerView.Adapter<AlbumAdapter.ViewHolder>() {
    ...
}

class GifAdapter(val context: Context, val imageList: ArrayList<Uri>, val imageSize: Int) :
    RecyclerView.Adapter<GifAdapter.ViewHolder>() {
    ...
}
  • Adapter 的注入提供不同的实现

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AlbumAdapterAnnotation

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class GifAdapterAnnotation


@Module
@InstallIn(FragmentComponent::class)
object AlbumAdapterProvider {

    private lateinit var list: ArrayList<Uri>

    @Provides
    fun provideList(): ArrayList<Uri> {
        list = ArrayList()
        return list
    }

    @AlbumAdapterAnnotation
    @Provides
    fun providerAdapter(
        @ActivityContext context: Context,
    ): AlbumAdapter {
        val size = context.resources.displayMetrics.widthPixels / 3
        return AlbumAdapter(context, list, size)
    }

    @GifAdapterAnnotation
    @Provides
    fun providerGifAdapter(
        @ActivityContext context: Context,
    ): GifAdapter {
        val size = context.resources.displayMetrics.widthPixels / 3
        return GifAdapter(context, list, size)
    }
}

前面我们提到,很多实例的初始化需要依赖 Context (有些还必须是 Activity 级别的 Context)。因此,在手动注入依赖的时候,我们始终绕不开 Activity 。 这里通过内建的 @ActivityContext 直接注入了 Activity 的 Context。简直有种玩游戏时对面直接开了一个大,凭空就来一个 Context,官方出手就是吊。

  • 基于参数使用不同的 adapter
@AndroidEntryPoint
class PictureBottomDialog(val type: GalleryType) : BaseBottomSheetDialog() {

    @AlbumAdapterAnnotation
    @Inject
    lateinit var albumAdapter: AlbumAdapter

    @GifAdapterAnnotation
    @Inject
    lateinit var gifAdapter: GifAdapter

    private lateinit var adapter: RecyclerView.Adapter<*>

    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?
    ): View? {
        return inflater.inflate(R.layout.bottom_sheet_layout, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        adapter = if (type == GalleryType.GIF) gifAdapter else albumAdapter
        val recyclerView = view.findViewById<RecyclerView>(R.id.recyclerView)
        recyclerView.layoutManager = GridLayoutManager(context, columns)
        recyclerView.adapter = adapter

}

可以看到这里我们可以基于 Hilt 的自定义注解定义多种类型的 adapter ,在运行时基于实际情况决定使用哪种 adapter。尤其是在 Adapter 的初始化依赖运行时其他依赖项的时候,结合 Hilt 的特性可以帮助我们更好的维护各个组件的依赖关系和生命周期,尽可能的避免空指针之类的问题。

EntryPoint

Hilt 提供的另一个利器就是 @EntryPoint 了。上面提到的所有内容都支持 Android 开发者非常熟悉的 Android 组件。对于官方默认没有提供支持的其他组件,我们可以通过 @EntryPoint 让他和 Hilt 支持的标准组件产生联系。

通过 EntryPoint 我们可以实现接口的注入,非常方便的实现面向接口编程。所有组件都依赖于接口,而具体的实现在哪里并不重要。

假设我们现在有一个控制播放器行为的接口

定义接口

interface IVideoPlayer {
    fun play()
    fun pause()
    fun resume()
    fun stop()
}

实现接口

@Singleton
class IVideoPlayerImpl : IVideoPlayer {
    override fun play() {
        Log.d(TAG, "play() called")
    }

    override fun pause() {
        Log.d(TAG, "pause() called")
    }

    override fun resume() {
        Log.d(TAG, "resume() called")
    }

    override fun stop() {
        Log.d(TAG, "stop() called")
    }

    @Module
    @InstallIn(SingletonComponent::class)
    class VideoPlayerModule {

        @Singleton
        @Provides
        fun provideVideoPlayer(): IVideoPlayer {
            return IVideoPlayerImpl()
        }
    }
}
  • 接口的行为应该是唯一的,因此我们用 @Singleton 注解确保这个注入只会全局实例化统一的一个
  • 通过 @Module 注解定义的类,提供 IVideoPlayer 这个接口的具体实现
  • 这个接口的实现可以在任意模块中,只要确保使用到的地方对这个模块有依赖关系即可

注入接口

定义 Hilt 可以执行到入口点。

@EntryPoint
@InstallIn(SingletonComponent::class)
interface MiniEntryPoint {

   fun videoPlayer(): IVideoPlayer
}

在一个项目里,如果说 Hint 通过 @HiltAndroidApp 构建了一个依赖容器依赖关系树的话,那么通过 @EntryPoint 就是把这里定义的内容和这棵树搭了一条线。

使用接口

EntryPointAccessors.fromApplication(MinApp.INSTANCE, MiniEntryPoint::class.java).videoPlayer().play()

我们可以在任何需要使用 IVideoPlayer 的地方通过上述方式获取到这个接口真正的实现并调用。

这里再次强调一下,IVideoPlayer 的实现可以在任意模块中。通过 @EntryPoint 入口点提供注入方式后,就可以使用 EntryPointAccessors.fromApplication() 方法获取到这个接口的实现了。 Hilt 通过编译期的工作,为运行期提供了非常便利的实现。

依赖注入

每次说到依赖注入,总会面临一个尴尬的问题。为什么需要使用?上面举的所有例子,没有一个是缺少依赖注入框架干不了的。甚至有些内容的实现使用依赖注入框架有种在炫技的感觉。同时这类依赖注入框架的使用,会增加编译耗时,甚至是报错。在复杂度较低的项目中,使用依赖注入的确有点过渡设计,为了用而用的意味。但是在项目规模加大,需要多人协作开发的时候,合理使用依赖注入框架是可以提升效率的。

参考文档