Эта статья для тех кто занимается оптимизацией сборки или просто хочет получить расширяемый шаблон для своего стартапа!
Роли озвучивали В проекте-шаблоне используются ComposeUI, Retrofit c Моками, Многомодульность, Api/Impl, SOLID, CLEAN, lazy dependency holder, Dagger2, Kotlin dsl, гибридная система навигации AnimatedNavHost/FragmentManager.
Статья состоит из двух частей:
Часть 1. Обзор проекта, описание макро деталек
Часть 2. Собственно разбор этой вашей ленивой инициализации
Пару слов от автора: шаблон для для больших команд, так что не надо пытаться натянуть сову на глобус, а просто посмотри как это работает у больших и волосатых :)
Вдохновлялся статьей отсюда, все по той же теме, рекомендую к прочтению! Моё почтение автору.
Часть 1. Обзор проекта, описание макро деталек
Давайте немножко вспомним про CLEAN - вот шпаргалка.
Отсюда мы видим что:
Строгое деление на модули(Кэп:))
Модули строго ограничены по своему функционалу именно таким образом как на картинке — сильное расхождение уже не clean! так то!
Наш проект — кононичный 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")
}
}
...........
Тут три примера экранов:
с пробросом роутера,
пустой,
и в который можно попасть кликнув на пуш
При этом мы сохраняем гибридный бекстек (win!), получаем хорошую скорость сборки, свободу от иногда (часто) тормозящих бекэндеров, и прекрасно децентрализованную систему в которой большое количество команд не будут мешать друг другу.
О, ну и конечно, такую сладкую и супер классную ленивую инициализацию зависимостей!
которая будет скармливать сборщику мусора неиспользуемые в данный момент модули.
P. S.
Ссылку на проект шаблон прилагаю — пользуйся, честной народ, на здоровье!
На момент написания статьи фреймворк Dagger2 стало возможным обновить с процессора KAPT до процессора KSP(alpha), а это значит что с новыми возможностями Dagger2 станет еще быстрее!(win) Пруф.
Так же имеет смысл обновить этот шаблон‑проект до новой версии gradle потому что там тоже очень много вкусного подвезли :)
Ну что ж, друзья, надеюсь, эта информация была вам полезна, обязательно пишите свое мнение!
До новых встреч :-)