Поздравляю, вы прошли испытательных срок, показав себя на задачках сложных и не очень. Тимлиду вы успешно обосновали неоходимость использовать DI без скоупов. А значит наступило время подойди к фреймворку и изучить его инструментарий и работу основательнее.

Библиотеке исполнился 1.0.3 релиз. Испытав себя на более тысячи тестах, он чувствует себя более чем стабильно. Однако библиотеке присущ особый характер работы. Будучи созданным разработчиком в одиночку, вы можете встретить нестандартные взгляды на архитектуру. Автору предстоит непростая задача — раскрыть особенности работы библиотеки.

А пока наша библиотека подтягивается из репозитория https://jitpack.io. Мы с вами пройдемся по основным элементам библиотеки.

dependencies {
    implementation("com.github.klee0kai.stone:stone_lib:1.0.3")
    kapt("com.github.klee0kai.stone:stone_processor:1.0.3")
}

Основные элементы и аннотации

В статье рассмотрим основные элементы и аннотации DI Stone:

  • @Module - модули - классы, предоставляющие зависимости, кэширующие эти самые зависимости.

  • @Provide - необязательная аннотация к методам в модулях, указывающая предоставления зависимости в DI, а также кэширование предоставляемой зависимости

  • @Component - обобщающий компонент DI, содержит все модули для предоставления зависимостей, а также реализует методы инъекции.

  • @Inject - метод или поле в которой запрашиваются зависимости.

  • @Dependencies - Классы, предоставляющие внешние зависимости без настроек кэширования.

  • @BindInstance - методы в модулях или в компонентах, выполняющие сохранение и предоставление объектов, созданных вне DI.

  • @Init - Методы в компоненте DI, выполняющие инициализацию классов модулей или классов внешних зависимостей.

UML
UML

Схематично DI компонент содержит классы модулей и внешних зависимостей и реализует методы инъекций для предоставления зависимостей в проекте.

Рассмотрим поподробнее.

Компонент и модули

Гениальные идеи рождаются из других гениальных идей. Наш фреймворк DI не планировал сильно отдаляться от базовых принципов фреймворков DI, хоть автору и пришлось в некоторых местах это сделать (нарушить, к примеру, JSR-330 стандарт). Базовые элементы могут перекликаться с общеизвестными библиотеками DI.

Работа DI начинается с описания генерируемых объектов. Все объекты, что DI должен предоставлять, должны описываться в модулях DI. Они могут явно создаваться в классе модуля.

@Module
open class DatabasesModule {

    @Provide(cache = Provide.CacheType.Strong)
    open fun room(context: Context): AppDB = AppDB.create(context)

    @Provide(cache = Provide.CacheType.Soft)
    open fun settings(db: AppDB): SettingsDao = db.settingsDao()

}

Либо их создание может быть опущено. Аргументы для конструктора, в данном случае, будут предоставляться из аргументов метода.

@Module
interface RepositoriesModule {

    @Provide(cache = Provide.CacheType.Soft)
    fun settingsRepository(settingsDao: SettingsDao): SettingsRepository

    @Provide(cache = Provide.CacheType.Soft)
    fun userRepository(): UserRepository

}

В известных фреймворках использование объекта ограничивается его скоупом. Каждый элемент можно сохранять как глобальный синглтон, локальный синглтон, либо вообще его не сохранять и генерировать фабрикой.

В Stone, кроме этого, все объекты можно сохранять и предоставлять по их фактическому использованию. Их можно сохранять различными видами ссылок, доступными в Java. Отсюда, при декларировании предоставления объекта в модуле, можно указать способ его кэширования:

  • Factory - не кэшировать вовсе. Предоставлять объект фабрикой.

  • Strong - кэширование strong ссылкой. Тут в зависимости от разделения компонента на фичевые или общего пользования объект становится либо глобальным синглтоном либо локальным сингтоном.

  • Weak - кэшировать weak ссылкой. Как только объект перестает кем-то использоваться и удерживаться - он может быть уничтожен сборщиком мусора.

  • Soft - кэширование soft ссылкой. Все тоже самое, что и у weak ссылки, однако фактическое удаление объекта может быть позже чем у weak кэшированного объекта.

Компонент DI при этом, должен указать доступные модули, декларированием методов.

@Component
interface AppComponent : DependenciesProvider {

    fun databaseModule(): DatabasesModule

    fun repositoriesModule(): RepositoriesModule

    fun presentersModule(): PresenterModule

}

Разработчик библиотеки придерживается нескольких принципов в разработке: меньше кода, больше открытости, выше скорость сборки. В связи с этим введена поддержка описаний создания объектов в модуле без явного описания, так как в большинстве случаем это очевидно, а нестыковки вызовов сразу отлавливаются компилятором.

Берем в руки Stone и идем дальше изучать.

val DI = Stone.createComponent(AppComponent::class.java)

Скорость в простоте

Когда то переведя все фреймворки с работы на основе рефлексии на кодогенерацию, все столкнулись с проблемами долгой сборки проекта. Для кого-то это оказалось несущественно, а кто-то увидел проблему в другом. Любой проект растет, объем кода и количество используемых библиотек в проекте с каждым месяцем все прибавляется.

Все могут увидеть влияние DI на скорость сборки по-разному, однако если всматриваться в сами механизмы работы DI, то можно увидеть, что DI решает 2 задачи: генерацию кода для предоставления зависимостей, а также непосредственно разрешение этих самых зависимостей для генерации объектов. Теоретически для обеих задач сложность алгоритмов - линейная, но на практике, если все объекты в DI будут иметь зависимости, то половина самой работы DI - это непосредственное разрешение этих самых зависимостей.

Разработчик оставил возможность использовать библиотеку только для непосредственной задачи - предоставление объектов.

class UserRepository {
    
    private val settingsDao = DI.databaseModule().settings();

    private val userDao = DI.databaseModule().user();

}

Все предоставление объектов теперь сводится, только к описанию механизмов кэширования.

@Module
open class DatabaseModule {

    @Provide(cache = Provide.CacheType.Strong)
    open fun room(): AppDB = AppDB.create(DI.context())

    @Provide(cache = Provide.CacheType.Soft)
    open fun settings(): SettingsDao = DI.databaseModule().room().settingsDao()

    @Provide(cache = Provide.CacheType.Soft)
    open fun user(): UserDao = DI.databaseModule().room().userDao()

}

Такой подход может показаться для некоторых не по душе, однако он еще раз мотивирует разработчика структурировать архитектуру и укладывать все по полочкам. Объекты предоставлять и использовать только в конкретных модулях.

Hidden text

При таком использовании Stone генерирует только фабрику и имплементацию модулей в DI, которые занимаются непосредственным кэшированием объектов.
Все объекты должны быть открытыми, так как Stone использует все описываемые классы как сигнатуры для наследовании и расширений.

public class DataBaseModule_MStone extends DataBaseModule implements IModule, IDataBaseModule_CCMStone {
    private DataBaseModule factory = new DataBaseModule();

    private final SingleItemHolder<IDataBaseModule_CCMStone> overridedModule = new SingleItemHolder<IDataBaseModule_CCMStone>(StoneRefType.WeakObject);

    private final SingleItemHolder<AppDB> room0 = new SingleItemHolder<AppDB>(StoneRefType.StrongObject);

    private final SingleItemHolder<SettingsDao> settings1 = new SingleItemHolder<SettingsDao>(StoneRefType.SoftObject);

    private final SingleItemHolder<UserDao> user2 = new SingleItemHolder<UserDao>(StoneRefType.SoftObject);

    // ------  some code -------- 

    @Override
    public synchronized AppDB room() {
        if (overridedModule.get() != null) {
            AppDB cached = overridedModule.get().__room_cache(null);
            if (cached != null) return cached;
        }
        Ref<AppDB> creator = () -> overridedModule.get() != null ? overridedModule.get().room() : factory.room();
        room0.set(() -> creator.get(), true);
        return room0.get();
    }

    @Override
    public synchronized SettingsDao settings() {
        if (overridedModule.get() != null) {
            SettingsDao cached = overridedModule.get().__settings_cache(null);
            if (cached != null) return cached;
        }
        Ref<SettingsDao> creator = () -> overridedModule.get() != null ? overridedModule.get().settings() : factory.settings();
        settings1.set(() -> creator.get(), true);
        return settings1.get();
    }

    @Override
    public synchronized UserDao user() {
        if (overridedModule.get() != null) {
            UserDao cached = overridedModule.get().__user_cache(null);
            if (cached != null) return cached;
        }
        Ref<UserDao> creator = () -> overridedModule.get() != null ? overridedModule.get().user() : factory.user();
        user2.set(() -> creator.get(), true);
        return user2.get();
    }

    // ------  some code -------- 

}

Все готовое на стол

Использование DI с повсеместными перебором модулей может быть неудобным. Да и сам разработчик выше говорил о принципах: "меньше кода, меньше проблем", а тут при каждом объявлении переменной надо его инициализировать, причем перебирая модули в компоненте DI.

Закроем глаза на принципы открытости и закрытости. Вооружившись чудесным модификатором lateinit, инициализируем инъекцией все наши зависимости в классе.

class MainFragment : BaseFragment(R.layout.fragment_main) {

    @Inject
    lateinit var presenter: MainPresenter

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        DI.inject(this)

    }

}

Кроме поддержки инъекций, Stone также поддерживает предоставления объектов в самом компоненте, для этого достаточно его объявить в DI компоненте.

@Component
interface AppComponent {

    fun presenterModule(): PresenterModule
    
    fun mainPresenter(): MainPresenter

}
Hidden text

Самым большим файлом в проекте может неожиданно оказаться сгенерированный файл DI компонента. Для каждого метода инъекции, а также предоставления зависимости, в компоненте решаются зависимости на месте. Не помешает взглянуть, пока там не накопилось 1000 строк.

public class AppComponentStoneComponent implements AppComponent, IPrivateComponent {

    private final WeakList<IPrivateComponent> __related = new WeakList<IPrivateComponent>();

    private final AppComponent_HMStone __hiddenModule = new AppComponent_HMStone();

    private DataBaseModule_MStone databaseModule = new DataBaseModule_MStone();

    private BluetoothModule_MStone bluetoothModule = new BluetoothModule_MStone();

    private RepositoriesModule_MStone repsModule = new RepositoriesModule_MStone();

    private PresenterModule_MStone presentersModule = new PresenterModule_MStone();


    @Override
    public DataBaseModule_MStone databaseModule() {
        return this.databaseModule;
    }

    @Override
    public BluetoothModule_MStone bluetoothModule() {
        return this.bluetoothModule;
    }

    @Override
    public RepositoriesModule_MStone repsModule() {
        return this.repsModule;
    }

    @Override
    public PresenterModule_MStone presentersModule() {
        return this.presentersModule;
    }

    @Override
    public Lazy<SettingsDao> settingsDao() {
        return AppComponent_TWStone.__wrapperCreator0.wrap(Lazy.class, () -> databaseModule().settings());
    }

    @Override
    public void inject(MainFragment mainFragment) {
        mainFragment.setPresenter(new ProvideBuilder<MainPresenter>((_lc0) -> {
            Ref<List<SettingsDao>> _lc2 = () -> new ProvideBuilder<SettingsDao>((_lc9) -> {
                _lc9.add(databaseModule().settings());
            }).all();
            UserRepository _lc3 = repsModule().userRepository();
            Ref<List<SettingsRepository>> _lc6 = () -> new ProvideBuilder<SettingsRepository>((_lc11) -> {
                _lc11.add(repsModule().settingsRepository(ListUtils.first(NullGet.let(_lc2, Ref::get))));
            }).all();
            MainPresenter _lc7 = presentersModule().mainPresenter(ListUtils.first(NullGet.let(_lc6, Ref::get)), _lc3);
            _lc0.add(_lc7);

        }).first());
    }

    // ----------------- Some Code -------------------------------------
}

Биндинги

Объекты генерируются на ура, зависимости подтягиваются. Однако в реальной разработке для каких-либо платформ и фреймворков многие объекты (классы контекстов и окружений) нельзя сгенерировать на пустом месте - они предоставляются самой платформой.Чтобы их использование с зависимостями было проще, их нужно явно объявить для DI.

Тут библиотека Stone взяла максимум, что могла взять от языка kotlin, и воспользовалась возможностью декларировать методы с дефолтными аргументами.

@Component
interface AppComponent {

    @BindInstance(cache = BindInstance.CacheType.Weak)
    fun context(context: Context? = null): Context

}

Согласитесь, как лаконично, инициализация и использование только через один метод. Все просто и понятно, и дублировать ничего нигде не надо. Как и с генерацией объектов, биндинги могут кэшироваться различными ссылками: Strong, Soft, Weak. Казалось бы, очевидная вещь! Однако автор хотел бы проговорить: контексты и классы окружений предоставляются платформенными библиотеками, их жизненные циклы могут быть особенными. Не стоит подпирать стену, которая не рухнет - объект, который уже удерживается в библиотеке платформы, не будет удален раньше времени, даже если вы будете использовать weak ссылку.

Hidden text

На самом деле биндинги также можно объявлять и в модулях DI.

@Module
interface BindsModule {

    @BindInstance(cache = BindInstance.CacheType.Weak)
    fun context(context: Context? = null): Context

}

При объявлении же в компоненте, биндинг предоставляется через скрытый модуль.

public class AppComponent_HMStone implements IModule, IAppComponent_HMStone_CCMStone {
    private AppComponent_HMStone factory;

    private final SingleItemHolder<IAppComponent_HMStone_CCMStone> overridedModule = new SingleItemHolder<IAppComponent_HMStone_CCMStone>(StoneRefType.WeakObject);

    private final SingleItemHolder<Context> context0 = new SingleItemHolder<Context>(StoneRefType.WeakObject);


    @Override
    public synchronized Context context(Context context) {
        if (overridedModule.get() != null) {
            Context cached = overridedModule.get().__context_cache(null);
            if (cached != null) return cached;
        }
        if (context != null) {
            context0.set(() -> context, false);
        }
        return context0.get();
    }

}

public class AppComponentStoneComponent implements AppComponent, IPrivateComponent {
    private boolean __protectRecursive = false;

    private final WeakList<IPrivateComponent> __related = new WeakList<IPrivateComponent>();

    private final AppComponent_HMStone __hiddenModule = new AppComponent_HMStone();


    @Override
    public AppComponent_HMStone __hidden() {
        return this.__hiddenModule;
    }

    @Override
    public Context context(Context context) {
        __hidden().__context_cache(CacheAction.setValueAction(context));
        __eachModule((module) -> {
            module.__updateBindInstancesFrom(__hidden());
        });
        return __hidden().context(context);
    }


}

Инициализация

Как вы заметили, для инициализации биндингов в библиотеке не используются билдеры. Одной из основных особенностей библиотеки является то, что в ней поддерживается горячая замена фабрик, модулей, зависимостей и биндингов. Для инициализации модулей и провайдеров внешних зависимостей используется аннотация @Init.

@Component
interface FeatureComponent {

    fun presenterModule(): PresenterModule
    
    fun mainPresenter(): MainPresenter

    fun otherFeatureDependencies(): OtherFeatureDependencies

    @Init
    fun initPresenterModule(module: PresenterModule)
    
    @Init
    fun initMainPresenterModule(module: MainPresenter)

    @Init
    fun initOtherFeatureDependecies(module: OtherFeatureDependencies)

}

Вы можете выполнять инициализацию компонента не только в момент создания DI, но и при обновлении контекста приложения. Это к примеру может пригодиться при подгруздке динамических фич, либо при обновлении моков во время UI тестов. Думаю можно даже реализовать окно для тестирования и непосредственно там менять конфигурацию приложения.

// init stage
DI = Stone.createComponent(FeatureComponent::class.java)
DI.initMainPresenterModule(mainPresenterModule)
DI.context(this)
// some work 
// dynamic feature loaded 
val loadedMainPresenterModule = LoadedLibMainPresenterModule()
DI.initMainPresenterModule(loadedMainPresenterModule)

DI компонент будет также хранить уже предоставленные объекты в соответствии с правилами и скоупами кэширования. А после их уничтожения и пересоздания сгенерирует уже новые, описанные в loadedMainPresenterModule модуле.

Для приложения это будет выглядеть так:

  • Пользователь ходит по экранам. Каждый экран является держателем объектов (предоставленных из DI)

  • Пользователь на одном из экранов видит предложение установить новую динамическую фичу. Устанавливает ее, DI инициализируется с новым модулем.

  • После установки пользователь также видит на экране старый функционал, так как экран все еще является держателем объектов. И DI их не вычистил из кэша.

  • Пользователь выходит из экрана. Для DI вызывается GC, все отпущенные объекты чистятся.

  • Пользователь заходит на экран и видит новый функционал.

Внешние зависимости

Хотелось бы дополнительно рассказать о внешних зависимостях в DI. Кроме инициализации модулей, библиотека позволяет настроить внешние зависимости для каждого компонента DI. Это может пригодиться при разработке фичевых модулей проекта, каждый из который содержит свой DI компонент. Каждый из которых используют зависимости других фичевых модулей проекта.

@Dependencies
public interface AuthDependencies {

    authUserCase(): AuthUserCase

}

Зависимости при этом предоставляются через другой компонент, где уже есть кэширование. По-сути внешние зависимости могут быть также предоставляться через те же самые модули DI только без описания кэширования.

Сухой остаток

В этой статье мы с вами познакомились с базовыми принципами работы библиотеки Stone. В ней вы можете встретить уникальные решения, решающие проблемы, с которыми вы каждый день можете сталкиваться на работе, которые не позволяют вашему проекту полноценно развиваться. Присоединяйтесь к проекту. Автор же для вас будет готовить все новые и новые статьи про фичи библиотеки, которые могут подвинуть в сторонку излюбленные фреймворки DI.

Комментарии (2)


  1. IL_Agent
    24.10.2023 07:45

    Правильно ли я понял, что тут не используется кодогенерация? Создание и валидация графа происходят в рантайме?

    На первый взгляд непонятно, зачем нужен еще один di. Есть какие-то киллер фичи?


    1. klee0kai Автор
      24.10.2023 07:45

      Используется кодогенерация на основе процессора аннотаций в gradle.
      Валидация графа происходит на этапе компиляции, если пользоваться инжектами и провайдингом через компоненты
      Киллер фичи описаны на вики проекта