[Разработка мобильных приложений, Разработка под Android] Властелин модулей. Продолжение истории

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
07-Июл-2021 13:30

В 2018 году на одной из конференций я представил доклад «Властелин модулей». С тех пор утекло много воды, а многомодульность в нашем проекте приняла финальные очертания. В этой статье я расскажу о допущенных ранее ошибках, как выглядит работа с модулями сейчас и как проектировать сложные решения.
Данная статья является пересказом истории, которую мы с @horseunnamed рассказали в нашем видео подкасте, если вы предпочитаете текстовый вариант, то вам сюда. Для удобства и экономии времени приведу краткое содержание статьи:
  • В двух словах о том, в чем проблема многомодульности
  • Косяки старой реализации
    • Проблема 1. Управление жизненным циклом компонентов
    • Проблема 2. Оверинжиниринг
    • Проблема 3. Копипаста
    • Так в чем же был корень зла?
  • Новый подход с минимальным количеством boilerplate
    • Встречайте — Feature Facade!
    • Пример взаимодействия модуля профиля и модуля выбора фото
    • Общие зависимости и модели
  • Подводя итоги
    • Что же в конце концов мы получили?
    • Какие точки роста и что еще можно сделать?
    • Ничего не забыли?
    • Полезные ссылки
В двух словах о том, в чем проблема многомодульности В чем была соль старого доклада, и почему тема многомодульности до сих пор не закрыта?Существуют три иерархии:
  • Иерархия фрагментов, которые видны на экране телефона;
  • Иерархия зависимостей;
  • Иерархия модулей, которая возникает при работе с многомодульностью.
В 2018 году мы безуспешно пробовали соединить их в одну, а потом изобрели  довольно сложный подход с медиаторами и ScopeHolder-ами. Саурон пал, эльфы дружной вереницей потянулись на запад, а орки разбрелись по нашему коду.Косяки старой реализации
Для начала давайте освежим в памяти старую реализацию. В ее основе лежали три принципа:
  • Деление модулей на три слоя: application, feature-модули и core-модули;
  • Каждый feature-модуль не зависит от других feature-модулей и имеет свою анатомию:
    • Интерфейс Deps, описывающий зависимости фичи, которые ей нужны снаружи;
    • Интерфейс API, описывающий то, что фича может отдать наружу;
    • Внутренняя реализация фичи;
  • На уровне 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. Нам требуются две вещи:
  • Возможность встроить в экран профиля PhotoPicker – для этого снаружи будем запрашивать его фрагмент. Не будем ссылаться на PhotoPickerFragment, сошлемся на общий тип Fragment;
  • Возможность реактивно слушать выбор фотографии на профиле и обновлять ее. Слушать мы можем только снаружи, соответственно, это тоже уходит в 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-модуль знает про эти модельки и может сконвертировать одну в другую.Подводя итогиЧто же в конце концов мы получили?
  • Мы сместили иерархию скоупов на уровень фич;
  • Все абстракции свели до одного FeatureFacade;
  • Отдельные сервис-локаторы для работы со скоупом мы заменили на Toothpick;
  • Сделали шаблонную генерацию заглушки feature-модуля: для Deps, API и коротенькую реализацию Feature Facade.
Какие точки роста, что еще можно сделать?
  • Научиться хорошо делать Sample Apps, которые позволяли бы при разработке иметь дело только с частью кодовой базы;
  • Попробовать вытащить инициализацию нашего AppScope-а и научить разворачиваться нужным способом в рамках других application;
  • Написать плагин, который будет сразу генерить Sample Apps для нужной фичи и автоматически ее подключать;
  • Попробовать отказаться от FeatureFacade в текущем виде. Возможно, сделаем две функции, чтобы не наследоваться каждый раз от базового класса.
Отмечу, что вышеперечисленные доработки уже косметические и, возможно, никогда в работу не пойдут.Ничего не забыли? — А что, если использовать другой DI-фреймворк?Внутри feature-модулей наша идея связки фрагментов с DI-скоупами легко реализовалась бы и с Dagger 2.На уровне межмодульного взаимодействия можно сделать то же самое, но тогда механизм управления жизненным циклом инстансов dagger-компонентов нужно реализовать самостоятельно. Toothpick же нам такое предоставляет в виде глобального синглтона. Подробнее о варианте реализации можно посмотреть в докладе Миши.— Тема модулей закрыта?Нет, мы еще расскажем, как разделяли модули и раскладывали их по репозиторию. Недостаточно придумать способ соединения модулей друг с другом, нужно и придумать, как следить за всей структурой модулей, чтобы не нарушалась корректность связей— Сколько модулей сейчас?260 штук.Полезные ссылки
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_razrabotka_mobilnyh_prilozhenij (Разработка мобильных приложений), #_razrabotka_pod_android (Разработка под Android), #_moduli (модули), #_mnogomodulnost (многомодульность), #_mnogomodulnost_v_android (многомодульность в Android), #_vlastelin_modulej (властелин модулей), #_arhitektura (архитектура), #_ohehennye_istorii (охэхэнные истории), #_blog_kompanii_headhunter (
Блог компании HeadHunter
)
, #_razrabotka_mobilnyh_prilozhenij (
Разработка мобильных приложений
)
, #_razrabotka_pod_android (
Разработка под Android
)
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 06:51
Часовой пояс: UTC + 5