Замечали некий companion object в интерфейсах Hilt-модулей? Что он делает, как он работает под капотом, почему так популярен в Hilt-модулях, и почему нельзя обойтись обычными классами? Сегодня я развею эту магию!

Разбираться будем на этом примере:

@Module
@InstallIn(SingletonComponent::class)
interface DataModule {
    @Binds
    @Singleton
    fun bindUserRepository(
        impl: UserRepositoryImpl
    ): UserRepository

    companion object {
        @Provides
        @Singleton
        fun provideRetrofit(): Retrofit {
            return Retrofit.Builder()
               .baseUrl("https://api.example.com")
               .build()
        }
    }
}

Так как Hilt — библиотека с большой кодогенерацией, многое здесь происходит под капотом, и нам остаётся лишь вешать нужные аннотации. Для тех, кто забыл, что значат аннотации в Hilt, просмотрите документацию.

Давайте сделаем акцент на аннотациях @Provides и @Binds. Для Hilt это два противоположных понятия:

  • @Binds — аннотация, указывающая Hilt, что данный метод связывает интерфейс с его реализацией. Этот метод должен быть без реализации, так как Hilt сам напишет реализацию метода, возвращая интерфейс и передавая ему при необходимости зависимости!
    Ну и так как в методах с аннотацией @Binds мы не должны писать тело, данные методы должны быть абстрактными, то есть без реализации. Такие методы могут быть либо в interface, либо в abstract class.

@Module
@InstallIn(SingletonComponent::class)
interface DataModuleFirst {
    @Binds
    @Singleton
    fun bindUserRepository(
        impl: UserRepositoryImpl
    ): UserRepository
}

// или

@Module
@InstallIn(SingletonComponent::class)
abstract class DataModuleSecond {
    @Binds
    @Singleton
    abstract fun bindUserRepository(
        impl: UserRepositoryImpl
    ): UserRepository
}
  • @Provides — аннотация, указывающая Hilt, что данный метод создаёт и возвращает некий объект (зависимость). В методе с аннотацией @Provides мы должны сами сконструировать объект и вернуть его!
    Так как в методах с аннотацией @Provides мы должны писать тело, в котором создаём объект, данные методы должны быть не абстрактными, то есть с обязательной реализацией. Такие методы могут быть либо в object, либо в class.

@Module
@InstallIn(SingletonComponent::class)
object DataModuleFirst {
    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://api.example.com")
            .build()
    }
}

// или

@Module
@InstallIn(SingletonComponent::class)
class DataModuleSecond {
    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://api.example.com")
            .build()
    }
}

Данные аннотации требуют различного поведения методов! Вы не сможете запихнуть @Provides-метод в interface, в котором находятся @Binds-методы (не прибегая к помощи companion object), и не сможете написать @Binds-метод в object, в котором находятся @Provides-методы.

Пробуем добавить @Provides-метод в интерфейс и получаем незамысловатую ошибку.
Пробуем добавить @Provides-метод в интерфейс и получаем незамысловатую ошибку.
Пробуем добавить @Binds-метод в object и получаем соответствующую ошибку.
Пробуем добавить @Binds-метод в object и получаем соответствующую ошибку.

Какое решение пришло в голову первым? Может, написать отдельно interface со своими @Binds-методами, и отдельно object со своими @Provides-методами? Получится неудобно:

@Module
@InstallIn(SingletonComponent::class)
interface BindsDataModule {
    @Binds
    @Singleton
    fun bindUserRepository(
        impl: UserRepositoryImpl
    ): UserRepository
} 

@Module
@InstallIn(SingletonComponent::class)
object ProvidesDataModule {
    @Provides
    @Singleton
    fun provideRetrofit(): Retrofit {
        return Retrofit.Builder()
           .baseUrl("https://api.example.com")
           .build()
    }
} 

Если вспомнить изначальный пример и посмотреть на этот, первый кажется более привлекательным из-за своей компактности.

А может, писать только @Provides-методы? Но будет много лишнего кода, который за нас может генерировать Hilt.

Наша задача — комбинировать два типа методов DI в одном объекте. companion object — идеальное решение.

Что такое companion object?

сompanion object — это объект-компаньон внутри объекта в Kotlin. То есть, он позволяет создать внутри объекта вспомогательный класс, который является статическим, и методы в нем — тоже статические.

Иногда (в зависимости от версии Kotlin) в Hilt-модулях методам с @Provides-аннотациями, лежащим в companion object, нужно дополнительно навешивать аннотацию @JvmStatic, чтобы гарантировать статику метода в Java-коде.

Hilt требует, чтобы @Provides-методы в интерфейсах были статическими, потому что они не могут быть иными, ведь создать экземпляр класса-интерфейса не получится. Это и помогает сделать компаньон, создающий в интерфейсе класс со статическими методами.

Ошибка создания экземпляра класса-интерфейса
Ошибка создания экземпляра класса-интерфейса

Давайте посмотрим, что происходит (в общем плане) с Kotlin-интерфейсом с companion object при компиляции в Java-код:

public interface DataModule {
    public static final class Companion {
        public Retrofit provideRetrofit() {
            return new Retrofit.Builder().baseUrl("https://api.example.com").build();
        }
    }
}

Создаётся класс внутри интерфейса, в котором лежат созданные нами методы.

Теперь, думаю, вы понимаете, как нам сможет помочь companion object при создании Hilt-модуля, в котором нам нужно комбинировать методы с реализацией и без. Мы можем в интерфейс, где нужно создавать абстрактные методы без реализации, добавить companion object, в котором, как в обычном классе, создавать методы с реализацией, тем самым собирая разного типа методы в одном интерфейсе!

Итак, вот такое рассуждение должно помочь вам понять, что такое companion object, как он работает и почему мы его используем в Hilt-модулях. Очень надеюсь, что вы тоже поняли, и теперь будете писать код, зная, как он работает и для чего вы его пишете.