company_banner

Властелин модулей. Продолжение истории

    В 2018 году на одной из конференций я представил доклад «Властелин модулей». С тех пор утекло много воды, а многомодульность в нашем проекте приняла финальные очертания. В этой статье я расскажу о допущенных ранее ошибках, как выглядит работа с модулями сейчас и как проектировать сложные решения.

    Данная статья является пересказом истории, которую мы с @horseunnamed рассказали в нашем видео подкасте, если вы предпочитаете текстовый вариант, то вам сюда. 

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

    • В двух словах о том, в чем проблема многомодульности

    • Косяки старой реализации

      • Проблема 1. Управление жизненным циклом компонентов

      • Проблема 2. Оверинжиниринг

      • Проблема 3. Копипаста

      • Так в чем же был корень зла?

    • Новый подход с минимальным количеством boilerplate

      • Встречайте — Feature Facade!

      • Пример взаимодействия модуля профиля и модуля выбора фото

      • Общие зависимости и модели

    • Подводя итоги

      • Что же в конце концов мы получили?

      • Какие точки роста и что еще можно сделать?

      • Ничего не забыли?

      • Полезные ссылки

    В двух словах о том, в чем проблема многомодульности 

    В чем была соль старого доклада, и почему тема многомодульности до сих пор не закрыта?

    Существуют три иерархии:

    1. Иерархия фрагментов, которые видны на экране телефона;

    2. Иерархия зависимостей;

    3. Иерархия модулей, которая возникает при работе с многомодульностью.

    В 2018 году мы безуспешно пробовали соединить их в одну, а потом изобрели  довольно сложный подход с медиаторами и ScopeHolder-ами. Саурон пал, эльфы дружной вереницей потянулись на запад, а орки разбрелись по нашему коду.

    Косяки старой реализации

    Для начала давайте освежим в памяти старую реализацию. В ее основе лежали три принципа:

    1. Деление модулей на три слоя: application, feature-модули и core-модули;

    2. Каждый feature-модуль не зависит от других feature-модулей и имеет свою анатомию:

      1. Интерфейс Deps, описывающий зависимости фичи, которые ей нужны снаружи;

      2. Интерфейс API, описывающий то, что фича может отдать наружу;

      3. Внутренняя реализация фичи;

    3. На уровне application-модулей есть слой медиаторов -- «волшебная сущность», которая осуществляет склейку между зависимостями одних фич и API других.

    Проблема 1. Управление жизненным циклом компонентов

    При реализации концепции мы выделили абстракцию Component, которая объединяла в себе Deps, API и внутреннюю реализацию фичи. Эта структура держалась в памяти за счет ComponentHolder-а, к которому и обращался медиатор. Усугубляло картину то, что Component Holder – это штука с жизненным циклом (куда же без него в Android?). При уничтожении процесса система убивала ComponentHolder, а при следующем запуске не восстанавливала его, как и всю статику, при этом любезно вернув стек фрагментов к последнему состоянию. Как итог, нам приходилось «воскрешать» все holder-ы вручную, накинув поверх них абстракцию ForceComponentInitializer.

    Проблема 2. Оверинжиниринг

    Другая сложность заключалась в большом количестве вспомогательных классов и абстракций. Чем глубже в иерархии располагался фрагмент, тем больше boilerplate кода нам приходилось писать. Для того, чтобы держать инстансы DI-скоупов в памяти, мы ввели специальную абстракцию ScopeHolder. Она по своей сути дублировала механизм хранения и инициализации scope-ов зависимостей Toothpick-а. Из-за того, что ScopeHolder-ы инициализировались и чистились вручную, приходилось прокидывать параметры открытия экранов по всей цепочке в иерархии от верхнего в нижний.

    Проблема 3. Копипаста

    Как итог, в фичах часть инфраструктурного кода копировалась, вместе с бездумным копированием приходили глупые ошибки, а с ними и краши.

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

    Тут возникает желание спустить всех собак на то, что Toothpick - это runtime DI-фреймворк, и считать его рассадником крашей! Но нет, Toothpick-специфичных крашей на проде мы не ловили. Причиной крашей была именно кривая архитектура. Был бы на его месте Dagger 2, все разваливалось бы с тем же успехом!

    Так в чем же был корень зла? 

    Сложность архитектурных задач можно разложить на два компонента:

    • Естественная сложность задачи, на которую никак не можем повлиять, — устройство Android Framework с его жизненным циклом, сама специфика задачи, в рамках которой нам надо научиться передавать зависимости из одних модулей в другие, избегая их прямого подключения друг к другу.

    • Добавочная сложность, которую разработчики сами себе создали в процессе решения задачи, — использование инструментов, из-за которых и появились новые проблемы (медиаторы, ComponentHolder-ы, ComponentKeeper-ы и другие).

    Новый подход с минимальным количеством boilerplate

    Мы решили, что нужны дополнительные ограничения на структуру DI-скоупов, так как дополнительные ограничения упрощают контроль за системой. В результате разделили скоупы на два типа: структурные и присоединяемые.

    1. «Структурные» скоупы без жизненного цикла.

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

    Еще одна особенность «структурных» скоупов – вся информация для их открытия известна на момент запуска приложения.

    Структурные скоупы делятся на два подтипа: 

    • Первый существует в единственном экземпляре — это AppScope. В нем происходит склейка интерфейсов зависимостей фичей с их реализациями;

    • Второй тип — корневой scope фичи, который связывает API feature-модуля с его реализацией. 

    2. «Присоединяемые» скоупы.

    Это scope-ы, которые связаны с фрагментами при помощи фрагмент-плагинов - специальных делегатов жизненного цикла фрагментов, которые позволяют нам автоматически открывать и закрывать scope-ы на основании ЖЦ фрагмента. 

    Для таких scope-ов могут понадобиться аргументы, которые будут известны только в runtime-е. Эти аргументы передаются в скоупы исключительно из Bundle-ов фрагментов. Bundle - естественный механизм Android, который будет переживать смену конфигурации, следовательно, мы сможем восстановить scope-ы с помощью нужных аргументов. 

    Восстановление иерархии фрагментов осуществляется сверху вниз. Таким образом, можно будет восстановить всё DI-дерево с использованием исходных данных, сохранившихся в аргументах.

    Используя эту идею, мы получили следующие правила для иерархии scope-ов:

    1. Время жизни родительского скоупа включает в себя время жизни дочернего;

    2. Структурный скоуп может быть открыт только от структурного;

    3. Присоединяемый скоуп может быть открыт только от структурного или другого присоединяемого.

    Встречайте — Feature Facade!

    Мы избавились от холдеров, компонентов, медиаторов и сделали единую абстракцию на наши feature-модули, которую назвали FeatureFacade.

    FeatureFacade -  это удобная синтаксическая обёртка над деревом scope-ов, которая не хранит никакого состояния и служит только для построения нужной части дерева DI-scope-ов. Роль хранения и поддержки scope-ов в этом случае берёт на себя сам Toothpick.

    FeatureFacade работает в двух направлениях: он дает доступ к внешним зависимостям фичи изнутри и открывает доступ к API фичи для внешнего взаимодействия с ней. 

    Пример взаимодействия модуля профиля и модуля выбора фото

    Давайте рассмотрим типичное взаимодействие между фичами. В приложении есть профиль пользователя и фича выбора фотографии (которая может работать не только для профиля). Обе фичи находятся в отдельных модулях, между которыми нет прямой связи. 

    Мы хотим, чтобы при нажатии на аватарку в профиле запустился photo picker, через который пользователь сможет выбрать фотографию. После этого мы должны вернуть результат выбора на профиль.

    В случае приложения hh.ru, профиль – это резюме пользователя. Во-первых, профилей может быть больше одного, а во-вторых, их можно открывать одновременно на нескольких вкладках приложения. Мы хотим, чтобы при выборе фотки, она возвращалась к нужному фрагменту с нужным результатом ID профиля.

    Данный пример можно пощупать руками в репозитории.

    Для начала опишем интерфейс зависимостей, которые нужны фиче Profile. Нам требуются две вещи:

    1. Возможность встроить в экран профиля PhotoPicker – для этого снаружи будем запрашивать его фрагмент. Не будем ссылаться на PhotoPickerFragment, сошлемся на общий тип Fragment;

    2. Возможность реактивно слушать выбор фотографии на профиле и обновлять ее. Слушать мы можем только снаружи, соответственно, это тоже уходит в ProfileDeps. 

    interface ProfileDeps {
        fun photoPickerFragment(profileId: String): Fragment
        fun photoSelections(profileId: String): Observable<String>
    }

    Наружу модуль профиля будет предоставлять фрагмент, зависящий от ID пользователя:

    @InjectConstructor
    class ProfileApi {
        fun profileFragment(userProfile: UserProfile): Fragment = 
      		ProfileFragment.newInstance(userProfile)
    }

    ProfileFacade (реализация FeatureFacade для этого кейса) – это класс, который позволяет получить доступ к зависимостям модуля и его API. Через него мы сможем передать список модулей, которые опишут binding для реализации API-модуля:

    class ProfileFacade : FeatureFacade<ProfileDeps, ProfileApi>(
        depsClass = ProfileDeps::class.java,
        apiClass = ProfileApi::class.java,
        featureScopeName = "ProfileFeature",
        featureScopeModule = {
            Module().apply {
                bind<ProfileApi>().singleton().releasable()
            }
        }
    )

    При запуске фрагмента ProfileFragment мы сможем получить доступ к скоупу фичи через фиче-фасад. Это происходит автоматически через фрагмент-плагин, который откроет и закроет скоуп, когда нужно.

    internal class ProfileFragment : Fragment(R.layout.fragment_profile) {
    
        private val di = DiFragmentPlugin(
            fragment = this,
            parentScope = { ProfileFacade().featureScope },
            scopeNameSuffix = { userProfile.id },
            scopeModules = { arrayOf(ProfileScreenModule(userProfile)) }
        )
    
        private val viewModel by lazy { di.get<ProfileViewModel>() }
    }
    
    @InjectConstructor
    internal class ProfileViewModel(
        private val initialUserProfile: UserProfile,
        private val deps: ProfileDeps,
        disposable: CompositeDisposable
    )

    В модуле Photo Picker-а мы объявим структуру PhotoSelection, которая будет реактивным стримом возвращаться наружу. API фичи будет выглядеть следующим образом:

    data class PhotoSelection(
        val selectionId: String,
        val photo: Photo
    )
    
    @InjectConstructor
    
    class PhotoPickerApi {
    
        private val photoSelectionRelay = PublishRelay.create<PhotoSelection>()
       
        fun photoPickerFragment(args: PhotoPickerArgs): Fragment = PhotoPickerFragment.newInstance(args)
    
        fun photoSelections(): Observable<PhotoSelection> = photoSelectionRelay.hide()
    
        internal fun postPhotoSelection(photoSelection: PhotoSelection) = photoSelectionRelay.accept(photoSelection)
    
    }

    Уже знакомым способом объявляем FeatureFacade для фичи выбора фото:

    class PhotoPickerFacade : FeatureFacade<PhotoPickerDeps, PhotoPickerApi>(
        depsClass = PhotoPickerDeps::class.java,
        apiClass = PhotoPickerApi::class.java,
        featureScopeName = "PhotoPickerFeature",
        featureScopeModule = {
            Module().apply {
                bind<PhotoPickerApi>().singleton()
            }
        }
    )

    Теперь нам необходимо связать эти фичи воедино. Перейдем в application-модуль и реализуем интерфейс ProfileDeps. Мы можем напрямую обращаться к фасадам фич и использовать вызовы методов их API для реализации нужных зависимостей:

    @InjectConstructor
    
    internal class ProfileDepsImpl(
    
        // для реализации зависимостей feature-модуля, 
        // может понадобиться API другого feature-модуля
        private val photoPickerApi: PhotoPickerApi
    
    ) : ProfileDeps {
    
        override fun photoPickerFragment(profileId: String): Fragment =
            photoPickerApi.photoPickerFragment(PhotoPickerArgs((profileId)))
    
        override fun photoSelections(profileId: String): Observable<String> =
            photoPickerApi.photoSelections()
                .filter { it.selectionId == profileId }
                .map { it.photo.url }
    
    }

    Нам осталось в AppScope описать биндинг интерфейса ProfileDeps к ProfileDepsImpl:

    private fun initTp() {
    
        // Используем rootScope Toothpick-а в качестве AppScope
        // и устанавливаем туда зависимости для feature-модулей
        Toothpick.openRootScope().installModules(FeatureDepsModule())
    
    }
    
    /**
     * Здесь происходит описание связей для склейки feature-модулей
     */
    
    internal class FeatureDepsModule : Module() {
        init {
            bind<ProfileDeps>().toClass<ProfileDepsImpl>()
            bind<PhotoPickerApi>().toProviderInstance { PhotoPickerFacade().api }
        }
    }

    Общие зависимости и модели

    На этом месте у Вас, как внимательного читателя, возникают два закономерных вопроса: 

    • все ли зависимости передаются вот таким способом?

    • все ли модели конвертируются в реализации Deps? 

    Нет и нет. Некоторые зависимости (типа аналитики или абстракций для работы с сетью) протягиваются неявно через дерево Toothpick. Что касается моделей — некоторые из них достаточно развесистые, и конвертация их при передаче между модулями превратилась бы в переливание воды из пустого в порожнее! 

    Если мы захотим пошарить модель PhotoInfo, которая содержится внутри Photo Picker-а, между двумя фичами, мы переложим ее в core-модуль, который подключим к обоим фичам. Таким образом мы дадим им знание об этом классе, которое фичи смогут использовать на уровне своих контрактов.

    Таким образом, мы ввели core-модели приложения, которые описывают доменную область: резюме, вакансии, отклики и т.п. И есть временные модели, которые могут дублироваться в разных модулях. Application-модуль знает про эти модельки и может сконвертировать одну в другую.

    Подводя итоги

    Что же в конце концов мы получили?

    1. Мы сместили иерархию скоупов на уровень фич;

    2. Все абстракции свели до одного FeatureFacade;

    3. Отдельные сервис-локаторы для работы со скоупом мы заменили на Toothpick;

    4. Сделали шаблонную генерацию заглушки feature-модуля: для Deps, API и коротенькую реализацию Feature Facade.

    Какие точки роста, что еще можно сделать?

    1. Научиться хорошо делать Sample Apps, которые позволяли бы при разработке иметь дело только с частью кодовой базы;

    2. Попробовать вытащить инициализацию нашего AppScope-а и научить разворачиваться нужным способом в рамках других application;

    3. Написать плагин, который будет сразу генерить Sample Apps для нужной фичи и автоматически ее подключать;

    4. Попробовать отказаться от FeatureFacade в текущем виде. Возможно, сделаем две функции, чтобы не наследоваться каждый раз от базового класса.

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

    Ничего не забыли? 

    — А что, если использовать другой DI-фреймворк?

    Внутри feature-модулей наша идея связки фрагментов с DI-скоупами легко реализовалась бы и с Dagger 2.

    На уровне межмодульного взаимодействия можно сделать то же самое, но тогда механизм управления жизненным циклом инстансов dagger-компонентов нужно реализовать самостоятельно. Toothpick же нам такое предоставляет в виде глобального синглтона. Подробнее о варианте реализации можно посмотреть в докладе Миши.

    — Тема модулей закрыта?

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

    — Сколько модулей сейчас?

    260 штук.

    Полезные ссылки

    HeadHunter
    HR Digital

    Комментарии 0

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

    Самое читаемое