【译】Hilt 和 Koin谁更适合 Compose?

1,970 阅读9分钟

原文:patrykkosieradzki.com/dagger-hilt…

前言

Dagger/Hilt 和 Koin 之间的永恒斗争又来了。让我们看看哪个更适合 Jetpack Compose 应用程序!这篇文章主要是为了向你展示它们之间的区别以及应用在 compose 上的区别。它们都很棒,希望看完这篇文章让您更轻松地做出选择!

首先假定您已经了解了什么是依赖注入(Dependency Injection),以及 Dagger/Hilt 和 Koin 的主要区别是什么,比如:

  • 是否会在编译期生成代码
  • 对构建时间的影响 vs 对运行时时间和注入时间的影响
  • Hilt 是由 Google 官方推荐和维护的,而 Koin 只是民间开源项目,代码质量和迭代速度都有可能差异。不过,Google 并没有说 Koin 不好,而是建议你使用最契合自己项目的东西。

以下是 Manuel Vivo (Android Developer Relations Engineer)对 Koin 的看法

Google recommends Hilt for Android apps but that’s just a recommendation! Use what you think it’s good for you.

谷歌推荐 Android App 使用 Hilt,但这仅仅是个建议,你觉得什么合适就选择什么。

如果以上您都不太熟悉,那么您可能应该暂时停止阅读,先弄清楚依赖注入、Dagger/Hilt、Koin 这些概念。

开始吧

现在我们来比较 Dagger/Hilt 和 Koin。

为了更好的展示这些开源库的优缺点,我会从 2 种编写 Jetpack Compose 的案例来比较。

  • 第一种情况 : 你的 App 完全使用 Compose 编写,没有使用 Fragment,所以路由组件你应该会使用 navigation-compose library
  • 第二种情况:使用 Fragment 和 ComposeView 来兼容桥接使用 Compose,对我来说我觉得这是目前最好的选择,主要是因为 Navigation-Compose Api 很难用,并且缺乏类型安全。这个原因有一些争议性,仅作为我个人对 Navigation-Compose Api 的观点。

关于 Navigation-Compose 的争议请查看我的文章:Why Using Navigation-Compose in Your Jetpack Compose App is a Bad Idea

使用 Hilt 的优点

Jetpack 成员,官方支持

首先,Hilt 是 Jetpack 的一员,目前谷歌推荐我们在 Android 使用。谷歌创造了 Hilt,极大的简化了 Dagger 的使用。众所周知,对于当开始学习 依赖注入的新手来说,Dagger 的门槛太高了。

比 Dagger 更易用

如果你之前喜欢用 Dagger,你一定会爱上 Hilt!

  • 新的注解,例如 @AndroidEntryPoint 或者 @HiltViewModel 让我们十分容易的在 Android 中管理依赖注入的代码
  • 使用专为 Android 创建模块的组件,比如 SingletonComponent,ViewModelComponent,创建modules(模块)十分简单。
  • 还有更多,请在这里查看🙂。

编译时异常

Dagger 和 Hilt 是编译时依赖注入框架。这意味着如果我们不小心少写一些依赖或者某个地方出错了,构建编译就会失败,我们的应用程序将根本无法运行。

而 Koin 不会生成任何代码,如果我们把 DI 搞错了,不影响项目运行,但是会在 App 运行时抛异常,crash。这是未知的。

所以使用 Koin,开发人员必须仔细检查 DI 部分的代码没有问题,相比来说,使用 Dagger/Hilt 则要安全很多。

可测试性

用 Dagger 编写单元测试、UI 、E2E 等测试已经成为可能,但是 Hilt 给我们提供了更多可用的测试,忘记 Dagger 那复杂的测试编写,现在你可以使用 HiltAndroidRule 来管理 component (组件)的状态并且很容易的注入测试依赖。

@HiltAndroidTest
class SettingsActivityTest {

    @get:Rule  
    var hiltRule = HiltAndroidRule(this)  
    
    // UI tests here.
}

你可以方便的替换和 mock 依赖,甚至整体模块。你可以在一个特殊的测试容器 launchFragmentInHiltContainer 中启动 Fragment。如果你不使用 Fragment 或者你只想对 Composable 测试,使用 ComposeTestRule 也可以很容易做到,直接把你的 ViewModel 和其他依赖传递给 Composable Function(Github示例

进程意外死亡时的数据保存

你可以简单的将 SaveStateHandle 注入到 ViewModel 中,ViewModel 默认会自动生成相关的构造工厂方法,来保存和恢复意外销毁时的数据。

使用 Hilt 的缺点

Hilt 是完美无缺的吗?我们来看一下🤔。

较长的编译时间

Hilt 在编译时会生成一些文件,项目越大,相关 di 组件越多,生成的代码越多,构建的时间也越长。

有时不得不使用 Dagger 写代码

如果要在多模块项目使用,必须使用 Dagger 的老方法来编写一些代码。首先,创建一个自定义的 EntryPoint 模块,在组件的构建器内将其连接,然后以手写代码编程的方式将其注入到 Activiy/Fragment 中,你可以阅读更多

Composable 只能注入 ViewModel(目前是这样)

对我来说是致命的缺陷,Compose 编写的 UI 只能注入 ViewModel。

可能有人会问:你是什么场景需要在 Compose UI 中注入其他的东西?

首先,这是一个功能的缺失,因为 Fragment/Activity 是可以的,而现在 Fragment/Activity 被 Compose 取代,为什么这个功能就丢了?

你能举一些这种场景的例子吗?

以下是几个例子:

  • 在 Compose UI 中使用代理类分担职责功能,这样你的代码会具有可复用性和可读性。
  • 根据不同条件显示不同的 UI,比如说根据服务器 json 下发动态生成不同 UI;debug 时显示一些测试 ui。
  • 还有另外一个例子:假设你需要使用多个 Coil ImageLoaders,你无法在 Compose UI 决定你要使用哪种 Coil ImageLoaders,只能通过上层(NavGraph/Activity)把对象以参数传递过来,或者使用 CompositionLocalProvider 传递。

这个问题可以被解决吗?可以。

正如我前面提到的,我更喜欢 Fragment + ComposeView 结合的方式开发。可以在 Fragment 中注入实例,然后在传递给 Compose。

如果我想将依赖直接传递给整个 Compose 树呢?

  1. 通过参数一层一层的传递给所有的子 Compose
  2. 使用 CompositionLocalProvider 存取实例

koin 的优点

比 Dagger 和 Hilt 使用门槛更低

Koin 更易于使用和学习,看过一遍 sample 基本是都会使用,没有什么复杂的概念要理解,对于想要学习依赖注入的新手来说,可能是个不错的选择。

可以自由注入到 Composables

koin 允许自由注入实例到 Composable 中,而不仅仅是 ViewModel。这一点解决了之前我提到的问题。

@Composable
fun SomeComposable(myService: MyService = get()) {
   // ...
}

错误日志更详细

如果你之前使用过 Dagger/Hilt,特别是 Dagger,你可能会注意到它们的错误日志不是很详细,所以你不得不去自己猜测真正错误的地方在哪里。

例如,在某些情况下,Hilt 只会告诉你:

[Hilt]

就这么简单,什么错误都看不出来。

当然,随着版本不断的迭代,这些问题得到了一些改善,现在的版本错误信息相对多一点。但是仍然不如 Koin 详细。

没有生成代码

Koin 在编译期间不会生成代码,相应的构建时间也会缩短。

Koin 的缺点

Koin 的是开箱易用的,但是代价是-需要编写更多的代码。每一个单例、工厂、viewModel 等都必须在初始化时预先添加到 module 中。

例如:

val appModule = module {
   single { DogRepository(get()) } 
   
   factory { GetDogUseCase(get()) }
   
   viewModel {
      DogDetailsViewModel(get())
   }
}

这样的后果就是,如果 module 中有很多依赖,依赖又有很多参数,最终的代码可能像这样:

val appModule = module {
   single { 
      DogRepository(get(), get(), get(), get(), get())
   } 
   
   factory { 
      GetDogUseCase(
         repo = get()
         cacheRepo = get(),
         service = get(),
         somethingElse = get()
      ) 
   }
   
   viewModel {
      DogDetailsViewModel(
         imagine = get(),
         a = get(),
         lot = get(),
         of = get(),
         dependencies = get(),
         here = get()
      )
   }
}

这个 module 里面只有 1 个 single,1 个 factory,1 个 viewModel,想象一下在大型项目中会是多么恐怖,一堆 get() 看的头昏眼花。

无法直接将 SavedStateHandle 注入 Composables

如果你尝试注入,会报错。这个 feature 应该很快会加上,如果你想在 App 意外被杀掉时保存一些 UI 数据,必须考虑这一点。

对运行时性能的影响

Koin 会影响运行时的时间,因为它在运行时解析 DI 的依赖关系。

来源:github.com/Sloy/androi…

依赖注入性能比较:

github.com/Sloy/androi…

严格来说,不属于真正的 DI(个人补充,原作者没有提到)

Jake Wharton 认为 Koin 属于手动注入,不是一个真正的依赖注入框架。

Jake Wharton 的一些观点:

DI itself requires no library/framework because it's a pattern generally simplified to: pass things in constructors. The purpose of a DI library/framework then is to do that automatically. Koin does not, and is still manual DI, unless you use its reflection-based add-on.

依赖注入本身不需要额外的库或者框架,因为依赖注入是一种简单的设计模式:在构造函数中传递东西。依赖注入相关的库的功能是自动执行注入操作。而 Koin 不是自动注入,是手动注入,除非你使用基于反射的附加组件。

Because it doesn't do DI. It's a really easy and obvious distinction to make. It neither looks nor quacks like a duck. Imagine saying that StringBuilder is a threading library because when I create a new thread I build its name using StringBuilder.

Koin 没有做任何依赖注入的操作。这是一个判断它是否是 DI 的非常明显的特征, 它既不像鸭子,也不像鸭子的嘎嘎叫声。想象一下,我说 StringBuidler 是一个线程库,仅仅是因为我创建一个线程时,使用 StringBuilder 命名了线程名。

Does it? All of the usage I've seen is property delegates pulling instances or lambdas pulling instances for constructor parameters. That quacks HARD like a service locator.

Koin 的所有用法都是靠 by 属性委托获取实例,或者通过 lumbdas 获取构造函数参数的实例。这很像一个服务定位器。

True. Not sure what you could call it. Members injection is a thing, but you're supposed to do it from outside. Android has all these components like activities and fragments that people self members inject into instead of having all of their code separately constructor-injected.

当你向 Android 的 Activity/Fragment 注入实例时,应该从外部注入。而不是把所有的代码都单独注入构造函数中。

简单来说,Koin 其实是一个 ServiceLoader,预先注册好了相关构造函数。组件用的时候再去 module 拉取相关构造实例。而 DI 则是在组件需要某个实例的时候,从外部生成去自动注入到组件。这是最明显的区别。虽然 Koin 最后达到了 DI 的效果。

概括

那么你应该选择哪种库呢?你必须自己决定。关键是哪个可以编写易于测试和维护的代码。我在之前几个项目中都使用过上述的库,我认为他们都符合这个标准。如果特别在意 Koin 是否是 DI,那就选择 Hilt。