В 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 штук.

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