Реализация Lazy Dependency Holder (Продвинутая ленивая инициализация зависимостей) для больших команд

Эта статья для тех кто занимается оптимизацией сборки или просто хочет получить расширяемый шаблон для своего стартапа!

Роли озвучивали В проекте-шаблоне используются ComposeUI, Retrofit c Моками, Многомодульность, Api/Impl, SOLID, CLEAN, lazy dependency holder, Dagger2, Kotlin dsl, гибридная система навигации AnimatedNavHost/FragmentManager.

Качай да пользуйся! →https://github.com/nonSlipMike/LazyDepHolder.git

Статья состоит из двух частей:

Часть 1. Обзор проекта, описание макро деталек

Часть 2. Собственно разбор этой вашей ленивой инициализации

Пару слов от автора: шаблон для для больших команд, так что не надо пытаться натянуть сову на глобус, а просто посмотри как это работает у больших и волосатых :)

Вдохновлялся статьей отсюда, все по той же теме, рекомендую к прочтению! Моё почтение автору.

Часть 1. Обзор проекта, описание макро деталек

Давайте немножко вспомним про CLEAN — вот шпаргалка.

рис.1 CLEAN ARCHITECTURE

рис. 1 CLEAN ARCHITECTURE

Отсюда мы видим что:

  1. строгое деление на модули (Кэп:))

  2. модули строго ограничены по своему функционалу именно таким образом как на картинке — сильное расхождение уже не clean! так то!

  3. наш проект — кононичный clean! и у него даже есть несколько фича-модулей.

в среднем каждый проект про который говорят что он сделан по clean должен выглядеть примерно так как на рис. 1

И так с шаблоном архитектуры разобрались теперь немножко поговорим про навигацию. Тут используется Compose c библиотекой навигации основанной на классе -AnimatedNavHost.

Так как статья не про эту либо отмечу лишь одну важную вещь — AnimatedNavHost есть надстройка над NavHost и основной проблемой ее внедрения стала невозможность прямого доступа к backStackEntry, а этот доступ в свою очередь, нужен был для создания сложных сценариев таких как возврат назад с подменой destination (оно же backStackEntry).

Познакомиться со строением графа вы можете в файле ComposeRootFragment.

В общем, кто как решил эту проблем, у пишите в комментариях будет очень интересно!

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

где в файле Routing вы сможете найти такую конструкцию:

  val content: ((String, NavOptionsBuilder.() -> Unit) -> Unit) -> @Composable AnimatedVisibilityScope.(NavBackStackEntry) -> Unit

Проще сделать к сожалению не получилось :-(

Так что если у вас есть решение смело выкладывайте в комментариях! :))

Навигаторы лежат в MainActivity как методы routeToCompose (…) и roteToFragment (…)

Пожалуйста, напишите кто и как решал такие вот примеры на своем опыте!

И если вы хотите чтобы я рассказал вам про реализацию этой навигации подробнее то обязательно напишите в комментарии об этом тоже!

Отдельно стоить отметить про сетевое взаимодействие — оно реализовано c использованием системы заглушек так, чтобы мы могли полностью отвязаться от сервера и вести разработку в своем таймлайне. Это чрезвычайно удобно!

Реализация лежит в файле MockConfig и все файлы в этой папке так же задействованы.

Собирает это все наш прекрасный Gradle под оберткой KotlinDSL. Про этот подход как мне кажется уже должны знать все и если не используют его то смело на него переходить!
а ну быстро давай переходи на KotlinDSL! :)

И если коротко то подход этот позволяет реализовать следующую конструкцию в gradle файлах зависимых модулей:

plugins {
    id("com.android.library")
    kotlin("android")
    kotlin("kapt")
}
android { compileSdk = compileSdkVersionConf }

initLibDependencies()

Ну разве сие не прекрасно?!

plugins {
    id("com.android.library")
    kotlin("android")
    kotlin("kapt")
}
android { compileSdk = compileSdkVersionConf }

initLibDependencies()
dependencies {
    implementation(project(":common"))
    implementation(project(":features:bfeatureapi"))
}

А так выглядит подключение модулей, предоставляющих зависимости в наш модуль!
естественно то все зависимости хитрым образом убраны по дальше и представляют из себя коллекции коллекций (мапы мап) объединены по принципу подключаемого фреймворка. Все это лежит в Config.kt обязательно ознакомься.

Самые внимательные заметят что тут используется подход impl/api
про него можно так же написать отдельную статью, однако я лишь помечу, что это нужно для для того, чтобы более удобно вести разработку в больших и пересекающихся командах. Раньше мы могли увидеть прирост производительности инкрементальной компиляции с помощью правильного построения архитектуры, используя как раз impl api. Но согласно последней информации — новый (на тот момент) Gradle прекрасно справляется сам. Пруф → https://blog.gradle.org/compilation-avoidance

По моим тестам из прошлого я лишь могу сказать, что подход impl/api действительно сокращал время компиляции и довольно ощутимо, ну, а без этого подхода я программы не пишу так что кто хочет может скинуть свои тесты в комментарии! Это было бы круто!

Часть 2 — Собственно разбор этой вашей ленивой инициализации

или

Реализация Lazy Dependency Holder (Ленивая инициализация зависимостей) в многомодульном проекте для больших команд

Итак, Котятки! Мы подошли к самому вкусному!

А именно как же была реализована наша прекрасная ленивая инициализация модулей,
которая позволит нам освобождать память, которую наше приложение так безмерно любит кушац.

Начнем с базовой конструкции которую вы можете посмотреть в файлах класса LazyController

open class LazyController {
	private var lazyObject: WeakReference>? = null
	protected lateinit var setLazyInstanceFunction: () -> T
	protected var strongRefInstance: Lazy? = null

	fun setLazyInstance(setLazyInstanceFunction: () -> T) {
		this.setLazyInstanceFunction = setLazyInstanceFunction
	}

	open fun getLazyInstance(): T {
		if (lazyObject == null || lazyObject?.get() == null) {
			strongRefInstance =
				lazy { setLazyInstanceFunction() } // Инициализация strongRef
			lazyObject = WeakReference(strongRefInstance)
			CoroutineScope(Job()).launch {
				delay(1000) // Задержка на 1 секунду
				//кейс маловероятный но возможно удаление при очень быcтрой работе gc
				//чтобы gc не собрал его сразу делаем сильную ссылку и очищаем ее через секунду
				strongRefInstance = null // Удаление strongRef
			}
		}
		return lazyObject!!.get()!!.value
			?: throw IllegalArgumentException(
				"instance not yet initialized ( need to use method .setLazyInstance() first )"
			)
	}
}

Тут нужно обратить внимание на generic — WeakReference?

Именно он позволяет использовать механику делегата Lazy и отдавать всю работу по управлению памятью виртуальной машине java c помощью WeakReference.

Логика довольно проста: дай мне ссылку на холдер (мы используем подход Dependecy Holder) если она у тебя есть, а если ее нет то создай новую. При этом возвращается именно слабая ссылка, а сильная затирается, делая возможной сборку неиспользуемого модуля Сборщиком Мусора (GC) .

Cтоит упомянуть, что на базе LazyController реализован класс LazyControllerSingleton который инициализируется всего 1 раз и нужен для инициализации модуля Network и других модулей уровня ядра.

Теперь давайте рассмотрим то где и как этот контроллер применяется

@Component(
	modules = [ComposeRootModule::class]
)
@MyModuleScope
interface ComposeRootComponent {
	fun inject(composeFragment: ComposeRootFragment)

	fun getFragmentPatches(): Map Fragment >

	@Component.Builder
	abstract class Builder {

		abstract fun build(): ComposeRootComponent

		@BindsInstance
		abstract fun insertRoutes(routerMap: Map): Builder
	}

	companion object {
		private var instance = LazyController()
		fun getInstance() = instance.getLazyInstance()
		fun setInstance(setLazyInstanceFunction: () -> ComposeRootComponent) =
			instance.setLazyInstance { setLazyInstanceFunction() }
	}
}

Перед вами уже элемент фреймворка Dagger2, а именно Component
(SubComponents я стараюсь не использовать по причине перерасхода ресурсов
вот тут один из умных мужей Яндекса рассказывает почему не надо юзать сабкомпоненты Пруф → https://youtu.be/pMEAD6jjbaI? feature=shared)

Для нашего понимания тут важны лишь 2 метода getInstance () и setInstance () где
getInstance используется для инициализации холдера, а setInstance для подготовки холдера

инициализировать holder мы будем в классе DaggerComponentsInitializer

object DaggerComponentsInitializer {
	fun daggerComponentsInit(context: Context) {
		NetworkComponent.setInstance {
			DaggerNetworkComponent.builder()
				.insertAppContext(context)
				.build()
		}

		CFeatureComponent.setInstance { DaggerCFeatureComponent.builder().build() }

		AFeatureComponent.setInstance {
			DaggerAFeatureComponent.builder()
				.insertNetworkClient(NetworkComponent.getInstance().provideRetrofitClient())
				.build()
		}

		ComposeRootComponent.setInstance {
			DaggerComposeRootComponent.builder().insertRoutes(
				//тут подключаются пути для новых композ дисплеев
				arrayListOf(CFeatureComponent.getInstance().fileExporters1()).getOneMap()
			).build()
		}

		MainActivityComponent.setInstance {
			DaggerMainActivityComponent.builder()
				.insertRoutes(
					//тут подключаются пути для новых фрагментов
					arrayListOf(
						ComposeRootComponent.getInstance().getFragmentPatches(),
						AFeatureComponent.getInstance().getFragmentPatches()
					).getOneMap()
				).build()
		}
	}
}

В этом инициализаторе происходит вот что: фактически мы описываем таблицу какие компоненты от каких компонентов зависят.

Получилось всё довольно интуитивно и понятно!

И теперь, чтобы все это заработало во фрагменте, нам достаточно просто написать такой код:

class AFeatureFragment : Fragment() {

	private val viewModel: AFeatureViewModel by lazyViewModel { stateHandle ->
		AFeatureComponent.getInstance().provideViewModel().create(stateHandle)
	}

    .....
}

я намеренно опускаю реализацию провайда вьюмоделей и работу роутинга так как это бы заняло материала еще на пару статей, отмечу однако что что с compose экранами все немножко по-другому:

инициализация compose экрана состоит из двух этапов

@Composable
fun CFeatureMainComposeScreen(
	routeHandler: (String, NavOptionsBuilder.() -> Unit) -> Unit,
	viewModel: CFeatureViewModel
) {

В коде сверху мы видим собственно экран и уже прокинутую в него вью модель которая уже синхронизирована с жизненным циклом нашего экрана!

И так на первом этапе мы формируем нашу вьюмодель в модуле Dagger2 так:

object CFeatureModule {

	@Provides
	@IntoMap
	@StringKey(C_FEATURE_PATCH_NAME)
	fun getNavHostConfig1(): ComposablePatchData {
		return ComposablePatchData(
			C_FEATURE_PATCH_NAME, transitions = DownTransitions,
			content = { routeHandler ->
				{
					CFeatureMainComposeScreen(routeHandler,
						provideViewModelWithDependency { CFeatureComponent.getInstance().getViewModel() })
				}
			}
		)
	}

    ...
  
  }

Опять же функционирование роутинга в этой статье опускается и если будет интересно как все это работает то обязательно пишите в комментариях ! Расскажу и про это тоже!

На текущий момент нужно понять что такая конструкция нужна для того чтобы дагер смог прокинуть зависимости вьюмодели и роутеры, а так же и выдать это в хост (который AnimatedNavHost) по ключу C_FEATURE_PATCH_NAME и задача эта довольно нетривиальная учитывая гибридную природу навигации нашего шаблона.

Далее в строке номер 10 у нас происходит магия Dagger2 и мы получаем возможность лаконичного использования экранов compose с подвязанной вью моделью.

Все сложности и боли ради того, чтобы получить возможность так записывать граф!
И при этом команда могла вести разработку только в своем модуле и не тревожить остальных!


        .......

                navController = rememberAnimatedNavController()
				AnimatedNavHost(
					navController = navController,
					startDestination = "homescreen",
					modifier = Modifier.weight(1f)
				) {
					// инжект модулей с помошью Dagger @InToMap из Feature модуля
					routes.forEach { registerInNavHost(it.value, ::composeRouteHandler) }

					composable("orders") { OrdersScreen(::composeRouteHandler) }

					composable("homescreen") { HomeScreen() }

					composable(
						"details?{argument}",
						arguments = listOf(navArgument("argument") {
							type = NavType.StringType
						}),
						deepLinks = listOf(navDeepLink {
							uriPattern = "https://vvx.com?{argument}"
						}),
					) { backStackEntry ->
						val article = backStackEntry.arguments?.getString("argument")
						OrdersScreen(::composeRouteHandler, "Showing $article")
					}
				}

    ...........
                

Тут три примера экранов:

  1. с пробросом роутера,

  2. пустой ,

  3. и в который можно попасть кликнув на пуш

При этом мы сохраняем гибридный бекстек (win!), получаем хорошую скорость сборки, свободу от иногда (часто) тормозящих бекэндеров, и прекрасно децентрализованную систему в которой большое количество команд не будут мешать друг другу.

О, ну и конечно, такую сладкую и супер классную ленивую инициализацию зависимостей!
которая будет скармливать сборщику мусора неиспользуемые в данный момент модули.

P.S.

Ссылку на проект шаблон прилагаю — пользуйся, честной народ, на здоровье!

На момент написания статьи фреймворк Dagger2 стало возможным обновить с процессора KAPT до процессора KSP (alpha), а это значит что с новыми возможностями Dagger2 станет еще быстрее!(win) Пруф → https://dagger.dev/dev-guide/ksp.html

Так же имеет смысл обновить этот шаблон-проект до новой версии gradle потому что там тоже очень много вкусного подвезли :)

Ну что ж, друзья, надеюсь, эта информация была вам полезна, обязательно пишите свое мнение!

До новых встреч :-)

© Habrahabr.ru