Как стать автором
Обновить

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

Уровень сложностиСложный
Время на прочтение9 мин
Количество просмотров1.7K

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

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

Качай да пользуйся!

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

Часть 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 прекрасно справляется сам. Пруф.

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

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

или

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

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

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

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

open class LazyController<T> {
	private var lazyObject: WeakReference<Lazy<T>>? = null
	protected lateinit var setLazyInstanceFunction: () -> T
	protected var strongRefInstance: Lazy<T>? = 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>?

Именно он позволяет использовать механику делегата 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<String, (Bundle?) -> Fragment >

	@Component.Builder
	abstract class Builder {

		abstract fun build(): ComposeRootComponent

		@BindsInstance
		abstract fun insertRoutes(routerMap: Map<String, ComposablePatchData>): Builder
	}

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

Перед вами уже элемент фреймворка Dagger2 а именно Component
( SubComponents я стараюсь не использовать по причине перерасхода ресурсов
вот тут один из умных мужей Яндекса рассказывает почему не надо юзать сабкомпоненты Пруф )

Для нашего понимания тут важны лишь 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) Пруф.

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

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

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

Теги:
Хабы:
Всего голосов 3: ↑3 и ↓0+3
Комментарии0

Публикации

Работа

Ближайшие события