阅读 1789

全方面分析 Hilt 和 Koin 性能

前言

Koin、Dagger、Hilt 目前都是非常流行的库,面对这么多层出不穷的新技术,我们该做如何选择,是一直困扰我们的一个问题,之前我分析过 Koin 和 Dagger 的性能对比,Hilt 与 Dagger 的不同之处,可以点击下方链接前往查看。

本文主要来一起分析一下 Hilt 和 Koin 的性能,如果你之前对 Hilt 和 Koin 不了解也没有关系,对阅读本文没有什么影响,接下来将会从以下几个方面来分析 Hilt 和 Koin 不同之处。

  • 依赖注入的优点?
  • Koin 为什么可以做到无代码生成、无反射?
  • AndroidStudio 支持 Hilt 和 Koin 在关联代码间进行导航吗?
  • Hilt 和 Koin 谁的编译速度更快?以及为什么?
  • 比较 Hilt 和 Koin 代码行数?
  • Hilt 和 Koin 在使用上的区别,谁上手最快?

依赖注入的优点

Koin 是为 Kotlin 开发者提供的一个实用型轻量级依赖注入框架,采用纯 Kotlin 语言编写而成,仅使用功能解析,无代理、无代码生成、无反射。

Hilt 是在 Dagger 基础上进行开发的,减少了在项目中进行手动依赖,Hilt 集成了 Jetpack 库和 Android 框架类,并删除了大部分模板代码,让开发者只需要关注如何进行绑定,同时 Hilt 也继承了 Dagger 优点,编译时正确性、运行时性能、并且得到了 Android Studio 的支持。

Hilt、Dagger、Koin 等等都是依赖注入库,依赖注入是面向对象设计中最好的架构模式之一,使用依赖注入库有以下优点:

  • 依赖注入库会自动释放不再使用的对象,减少资源的过度使用。
  • 在指定范围内,可重用依赖项和创建的实例,提高代码的可重用性,减少了很多模板代码。
  • 代码变得更具可读性。
  • 易于构建对象。
  • 编写低耦合代码,更容易测试。

Hilt VS Koin

接下来将从 AndroidStudio 基础支持、项目结构、代码行数、编译时间、使用上的不同,这几个方面对 Hilt 和 Koin 进行全方面的分析。

Android Studio 强大的基础支持

Android Studio >= 4.1 的版本,在编辑器和代码行号之间,增加了一个新的 "间距图标",可以在 Dagger 的关联代码间进行导航,包括依赖项的生产者、消费者、组件、子组件以及模块。

Hilt 是在 Dagger 基础上进行开发的,所以 Hilt 自然也拥有了 Dagger 的优点,在 Android Studio >= 4.1 版本上也支持在 Hilt 的关联代码间进行导航,如下图所示。

hilt

PS: 我用的版本是 Android Studio 4.1 Canary 10,命名和图标在不同版本上会有差异。

有了 Android Studio 支持,在 Android 应用中 Dagger 和 Hilt 在关联代码间进行导航是如此简单。

这两个图标的意思如下:

  • 左边(向上箭头)的图标: 提供类型的地方 (即依赖项来自何处)
  • 右边的图标: 类型被当作依赖项使用的地方

遗憾的是 Koin 不支持,其实 Koin 并不需要这个功能,Koin 并不像 Hilt 注入代码那么分散,而且 Koin 注入关系很明确,可以很方便的定位到与它相关联的代码,并且 Koin 提供的 Debug 工具,可以打印出其构建过程,帮助我们分析。

Koin 构建过程

而 Hilt 不一样的是 Hilt 采用注解的方式,在使用 Hilt 的项目中,如果想要弄清楚其依赖项来自 @Inject 修饰的构造器、@Binds 或者 @Provides 修饰的方法?还是限定符?不是一件容易的事,尤其在一个大型复杂的的项目中,想要弄清楚它们之间的依赖关系是非常困难的,而 Android Studio >= 4.1 的版本,增加的 "间距图标",帮助我们解决了这个问题。

比较 Hilt 和 Koin 项目结构

为了能够正确比较这两种方式,新建了两个项目 Project-Hilt 和 Project-Koin, 分别用 Hilt 和 Koin 去实现,Project-Hilt 和 Project-Koin 两个项目的依赖库版本管理统一用 Composing builds 的方式(关于 Composing builds 请参考这篇文章 再见吧 buildSrc, 拥抱 Composing builds 提升 Android 编译速度),除了它们本身的依赖库,其他的依赖库都是相同的,如下图所示:

项目 Project-Hilt 和 Project-Koin 都分别实现了 Room 和 Retrofit 进行数据库和网络访问,统一在 Repository 里面进行处理,它们的依赖注入都放在了 di 下面,这应该是一个小型 App 基础架构,如下图所示:

如上图所示,这里需要关注 di 包下的类,Project-Hilt 和 Project-Koin 分别注入了 Room、Retrofit 和 Repository,以 Hilt 注入的方式至少需要三个文件才能完成,但是如果使用 Koin 的方式只需要一个文件就可以完成,后面我会进行详细的分析。

比较 Koin 和 Hilt 代码行数

项目 Project-Hilt 和 Project-Koin 除了它们本身的依赖之外,其他的依赖都是相同的。

我使用 Statistic 工具来进行代码行数的统计,反复对比了项目编译前和编译后,它们的结果如下所示:

代码行数 Hilt Koin
编译之前 2414 2414
编译之后 149608 138405

正如你所见 Hilt 生成的代码多于 Koin,随着项目越来越复杂,生成的代码量会越来越多。

比较 Koin 和 Hilt 编译时间

为了保证测试的准确性,每次编译之前我都会先 clean 然后才会 rebuild,反复的进行了三次这样的操作,它们的结果如下所示。

第一次编译结果:

Hilt:
BUILD SUCCESSFUL in 28s
27 actionable tasks: 27 executed

Koin:
BUILD SUCCESSFUL in 17s
27 actionable tasks: 27 executed
复制代码

第二次编译结果:

Hilt:
BUILD SUCCESSFUL in 22s
27 actionable tasks: 27 executed

Koin:
BUILD SUCCESSFUL in 15s
27 actionable tasks: 27 executed
复制代码

第三编译结果:

Hilt:
BUILD SUCCESSFUL in 35s
27 actionable tasks: 27 executed

Koin:
BUILD SUCCESSFUL in 18s
27 actionable tasks: 27 executed
复制代码

每次的编译时间肯定是不一样的,速度取决于你的电脑的环境,不管执行多少次,结果如上所示 Hilt 编译时间总是大于 Koin,这个结果告诉我们,如果在一个非常大型的项目,这个代价是非常昂贵。

为什么 Hilt 编译时间总是大于 Koin

因为在 Koin 中不需要使用注解,也不需要用 kapt,这意味着没有额外的代码生成,所有的代码都是 Kotlin 原始代码,所以说 Hilt 编译时间总是大于 Koin,从这个角度上同时也解释了,为什么会说 Koin 仅使用功能解析,无额外代码生成。

Koin 和 Hilt 使用上的不同

为了节省篇幅,这里只会列出部分代码,具体详细使用参考我之前写的 Hilt 入门三部曲,包含了 Hilt 所有的用法以及实战案例。

在项目中使用 Hilt

如果我们需要在项目中使用 Hilt,我们需要添加 Hilt 插件和依赖库,首先在 project 的 build.gradle 添加以下依赖。

buildscript {
    ...
    dependencies {
        ...
        classpath 'com.google.dagger:hilt-android-gradle-plugin:2.28-alpha'
    }
}
复制代码

然后在 App 模块中的 build.gradle 文件中添加以下代码。

...
apply plugin: 'kotlin-kapt'
apply plugin: 'dagger.hilt.android.plugin'

android {
    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }

    // For Kotlin projects
    kotlinOptions {
        jvmTarget = JavaVersion.VERSION_1_8.toString()
    }
}

dependencies {
    implementation "com.google.dagger:hilt-android:2.28-alpha"
    kapt "com.google.dagger:hilt-android-compiler:2.28-alpha"
}
复制代码

注意: 这里有一个坑,对于 Kotlin 项目,需要添加 kotlinOptions,这是 Google 文档 Dependency injection with Hilt 中没有提到的,否则使用 ViewModel 会编译不过。

完成以上步骤就可以在项目中使用 Hilt 了,所有使用 Hilt 的 App 必须包含一个使用 @HiltAndroidApp 注解的 Application,这是依赖注入容器的入口。

@HiltAndroidApp
class HiltApplication : Application() {
    /**
     * 1. 所有使用 Hilt 的 App 必须包含一个使用 @HiltAndroidApp 注解的 Application
     * 2. @HiltAndroidApp 将会触发 Hilt 代码的生成,包括用作应用程序依赖项容器的基类
     * 3. 生成的 Hilt 组件依附于 Application 的生命周期,它也是 App 的父组件,提供其他组件访问的依赖
     * 4. 在 Application 中设置好 @HiltAndroidApp 之后,就可以使用 Hilt 提供的组件了,
     *    Hilt 提供的 @AndroidEntryPoint 注解用于提供 Android 类的依赖(Activity、Fragment、View、Service、BroadcastReceiver)等等
     *    Application 使用 @HiltAndroidApp 注解
     */
}
复制代码

@HiltAndroidApp 注解将会触发 Hilt 代码的生成,用作应用程序依赖项容器的基类,这下我们就可以在 di 包下注入 Room、Retrofit 和 Repository,其中 Room 和 Retrofit 比较简单,这里我们看一下 如何注入 Repository, Repository 有一个子类 TasksRepository,代码如下所示。

class TasksRepository @Inject constructor(
    private val localDataSource: DataSource,
    private val remoteDataSource: DataSource
) : Repository
复制代码

TasksRepository 的构造函数包含了 localDataSource 和 remoteDataSource,需要构建这两个 DataSource 才能完成 TasksRepository 注入,代码如下所示:

@Module
@InstallIn(ApplicationComponent::class)
object QualifierModule {

    // 为每个声明的限定符,提供对应的类型实例,和 @Binds 或者 @Provides 一起使用
    @Qualifier
    // @Retention 定义了注解的生命周期,对应三个值(SOURCE、BINARY、RUNTIME)
    // AnnotationRetention.SOURCE:仅编译期,不存储在二进制输出中。
    // AnnotationRetention.BINARY:存储在二进制输出中,但对反射不可见。
    // AnnotationRetention.RUNTIME:存储在二进制输出中,对反射可见。
    @Retention(AnnotationRetention.RUNTIME)
    annotation class RemoteTasksDataSource // 注解的名字,后面直接使用它

    @Qualifier
    @Retention(AnnotationRetention.RUNTIME)
    annotation class LocalTasksDataSource

    @Singleton
    @RemoteTasksDataSource
    @Provides
    fun provideTasksRemoteDataSource(): DataSource { // 返回值相同
        return RemoteDataSource() // 不同的实现
    }

    @Singleton
    @LocalTasksDataSource
    @Provides
    fun provideTaskLocalDataSource(appDatabase: AppDataBase): DataSource { // 返回值相同
        return LocalDataSource(appDatabase.personDao()) // 不同的实现
    }

    @Singleton
    @Provides
    fun provideTasksRepository(
        @LocalTasksDataSource localDataSource: DataSource,
        @RemoteTasksDataSource remoteDataSource: DataSource
    ): Repository {
        return TasksRepository(
            localDataSource,
            remoteDataSource
        )
    }
}
复制代码

这只是 Repository 注入代码,当然这并不是全部,还有 Room、Retrofit、Activity、Fragment、ViewModel 等等需要注入,随着项目越来越复杂,多模块化的拆分,还有更多的事情需要去做。

Hilt 和 Dagger 比起来虽然简单很多,但是 Hilt 相比于 Koin,其入门的门槛还是很高的,尤其是 Hilt 的注解,需要了解其每个注解的含义才能正确的使用,避免资源的浪费,但是对于注解的爱好者来说,可能更偏向于使用 Hilt,接下来我们来看一下如何在项目中使用 Koin。

在项目中使用 Koin

如果要在项目中使用 Koin,需要在项目中添加 Koin 的依赖,我们只需要在 App 模块中的 build.gradle 文件中添加以下代码。

implementation “org.koin:koin-core:2.1.5”
implementation “org.koin:koin-androidx-viewmodel:2.1.5”
复制代码

如果需要在项目中使用 Koin 进行依赖注入,需要在 Application 或者其他的地方进行初始化。

class KoinApplication : Application() {

    override fun onCreate() {
        super.onCreate()
        startKoin {
            AndroidLogger(Level.DEBUG)
            androidContext(this@KoinApplication)
            modules(appModule)
        }
    }
}
复制代码

当初始化完成之后,就可以在项目中使用 Koin 了,首先我们来看一下如何在项目中注入 Repository, Repository 有一个子类 TasksRepository,代码和上文介绍的一样,需要在其构造函数构造 localDataSource 和 remoteDataSource 两个 DataSource。

class TasksRepository @Inject constructor(
    private val localDataSource: DataSource,
    private val remoteDataSource: DataSource
) : Repository
复制代码

那么在 Koin 中如何注入呢,很简单,只需要几行代码就可以完成。

val repoModule = module {
    single { LocalDataSource(get()) }
    single { RemoteDataSource() }
    single { TasksRepository(get(), get()) }
}
// 添加所有需要在 Application 中进行初始化的 module
val appModule = listOf(repoModule)
复制代码

和上面 Hilt 长长的代码比起来,Koin 是不是简单很多,那么 Room、Retrofit、ViewModel 如何注入呢,也很简单,代码如下所示。

// 注入 ViewModel
val viewModele = module {
    viewModel { MainViewModel(get()) }
}

// 注入 Room
val localModule = module {
    single { AppDataBase.initDataBase(androidApplication()) }
    single { get<AppDataBase>().personDao() }
}

// 注入 Retrofit
val remodeModule = module {
    single { GitHubService.createRetrofit() }
    single { get<Retrofit>().create(GitHubService::class.java) }
}

// 添加所有需要在 Application 中进行初始化的 module
val appModule = listOf(viewModele, localModule, remodeModule)
复制代码

上面 Koin 的代码严格意义上讲,其实不太规范,在这里只是为了和 Hilt 进行更好的对比。

到这里是不是感觉 Hilt 相比于 Koin 是不是简单很多,在阅读 Hilt 文档的时候花了好几天时间才消化,而 Koin 只需要花很短的时间。

我们在看一下使用 Hilt 和 Koin 完成 Room、Retrofit、Repository 和 ViewModel 等等全部的依赖注入需要多少行代码。

依赖注入框架 Hilt Koin
代码行数 122 42

正如你所见依赖注入部分的代码 Hilt 多于 Koin,示例中只是一个基本的项目架构,实际的项目往往比这要复杂的很多,所需要的代码也更多,也越来越复杂。

不仅仅如此而已,根据 Koin 文档介绍,Koin 不需要用到反射,那么无反射 Koin 是如何实现的呢,因为 Koin 基于 kotlin 基础上进行开发的,使用了 kotlin 强大的语法糖(例如 Inline、Reified 等等)和函数式编程,来看一个简单的例子。

inline fun <reified T : ViewModel> Module.viewModel(
    qualifier: Qualifier? = null,
    override: Boolean = false,
    noinline definition: Definition<T>
): BeanDefinition<T> {
    val beanDefinition = factory(qualifier, override, definition)
    beanDefinition.setIsViewModel()
    return beanDefinition
}
复制代码

内联函数支持具体化的类型参数,使用 reified 修饰符来限定类型参数,可以在函数内部访问它,由于函数是内联的,所以不需要反射。

但是在另一方面 Koin 相比于 Hilt 错误提示不够友好,Hilt 是基于 Dagger 基础上进行开发的,所以 Hilt 自然也拥有了 Dagger 的优点,编译时正确性,对于一个大型项目来说,这是一个非常严重的问题,因为我们更喜欢编译错误而不是运行时错误。

总结

我们总共从以下几个方面对 Hilt 和 Koin 进行全方面的分析:

  • AndroidStudio 支持 Hilt 在关联代码间进行导航,支持在 @Inject 修饰的构造器、@Binds 或者 @Provides 修饰的方法、限定符之间进行跳转。

  • 项目结构:完成 Hilt 的依赖注入需要的文件往往多于 Koin。

  • 代码行数:使用 Statistic 工具来进行代码统计,反复对比了项目编译前和编译后,Hilt 生成的代码多于 Koin,随着项目越来越复杂,生成的代码量会越来越多。

    代码行数 Hilt Koin
    编译之前 2414 2414
    编译之后 149608 138405
  • 编译时间:Hilt 编译时间总是大于 Koin,这个结果告诉我们,如果是在一个非常大型的项目,这个代价是非常昂贵。

    Hilt:
    BUILD SUCCESSFUL in 35s
    27 actionable tasks: 27 executed
    
    Koin:
    BUILD SUCCESSFUL in 18s
    27 actionable tasks: 27 executed
    复制代码
  • 使用上对比:Hilt 使用起来要比 Koin 麻烦很多,其入门门槛高于 Koin,在阅读 Hilt 文档的时候花了好几天时间才消化,而 Koin 只需要花很短的时间,依赖注入部分的代码 Hilt 多于 Koin,在一个更大更复杂的项目中所需要的代码也更多,也越来越复杂。

    依赖注入框架 Hilt Koin
    代码行数 122 42

为什么 Hilt 编译时间总是大于 Koin?

因为在 Koin 中不需要使用注解,也不需要 kapt,这意味着没有额外的代码生成,所有的代码都是 Kotlin 原始代码,所以说 Hilt 编译时间总是大于 Koin,从这个角度上同时也解释了,为什么会说 Koin 仅使用功能解析,无额外代码生成。

为什么 Koin 不需要用到反射?

因为 Koin 基于 kotlin 基础上进行开发的,使用了 kotlin 强大的语法糖(例如 Inline、Reified 等等)和函数式编程,来看一个简单的例子。

inline fun <reified T : ViewModel> Module.viewModel(
    qualifier: Qualifier? = null,
    override: Boolean = false,
    noinline definition: Definition<T>
): BeanDefinition<T> {
    val beanDefinition = factory(qualifier, override, definition)
    beanDefinition.setIsViewModel()
    return beanDefinition
}
复制代码

内联函数支持具体化的类型参数,使用 reified 修饰符来限定类型参数,可以在函数内部访问它,由于函数是内联的,所以不需要反射。

正在建立一个最全、最新的 AndroidX Jetpack 相关组件的实战项目 以及 相关组件原理分析文章,目前已经包含了 App Startup、Paging3、Hilt 等等,正在逐渐增加其他 Jetpack 新成员,仓库持续更新,可以前去查看:AndroidX-Jetpack-Practice, 如果这个仓库对你有帮助,请仓库右上角帮我点个赞。

结语

致力于分享一系列 Android 系统源码、逆向分析、算法、翻译、Jetpack 源码相关的文章,正在努力写出更好的文章,如果这篇文章对你有帮助给个 star,如果文章中有什么没有写明白的地方,欢迎留言,一起来学习,期待与你一起成长。

算法

由于 LeetCode 的题库庞大,每个分类都能筛选出数百道题,由于每个人的精力有限,不可能刷完所有题目,因此我按照经典类型题目去分类、和题目的难易程度去排序。

  • 数据结构: 数组、栈、队列、字符串、链表、树……
  • 算法: 查找算法、搜索算法、位运算、排序、数学、……

每道题目都会用 Java 和 kotlin 去实现,并且每道题目都有解题思路、时间复杂度和空间复杂度,如果你同我一样喜欢算法、LeetCode,可以关注我 GitHub 上的 LeetCode 题解:Leetcode-Solutions-with-Java-And-Kotlin,一起来学习,期待与你一起成长。

Android 10 源码系列

正在写一系列的 Android 10 源码分析的文章,了解系统源码,不仅有助于分析问题,在面试过程中,对我们也是非常有帮助的,如果你同我一样喜欢研究 Android 源码,可以关注我 GitHub 上的 Android10-Source-Analysis,文章都会同步到这个仓库。

Android 应用系列

精选译文

目前正在整理和翻译一系列精选国外的技术文章,不仅仅是翻译,很多优秀的英文技术文章提供了很好思路和方法,每篇文章都会有译者思考部分,对原文的更加深入的解读,可以关注我 GitHub 上的 Technical-Article-Translation,文章都会同步到这个仓库。

工具系列