Kaspersky Mobile Talks #1. Многомодульность
В конце февраля мы запустили новый формат встреч Android-разработчиков Kaspersky Mobile Talks. Основное отличие от обычных митапов — здесь вместо сотни слушателей и красивых презентаций на несколько различных тем собрались «бывалые» разработчики, чтобы обсудить всего лишь одну тему: как они реализуют многомодульность в своих приложениях, с какими проблемами сталкиваются, и как их решают.
Содержание
- Предыстория
- Медиаторы в HeadHunter. Александр Блинов
- Domain-модули в «Тинькофф». Владимир Коханов, Александр Жуков
- Impact-анализ в «Авито». Евгений Кривобоков, Михаил Юдин
- Как в «Тинькофф» сократили время сборки на PR с сорока минут до четырех. Владимир Коханов
- Полезные ссылки
Перед тем как перейти к непосредственному содержанию прошедшей встречи в офисе «Лаборатории Касперского», давайте вспомним, откуда вообще пошла мода на разделение приложения на модули (здесь и далее под модулем понимается Gradle-модуль, а не Dagger, если не сказано обратное).
Тема многомодульности витает в головах Android-сообщества уже не первый год. Одним из основополагающих можно считать доклад Дениса Неклюдова на прошлогоднем питерском «Мобиусе». Он предлагал разделять монолит приложения, которое давно перестало быть тонким клиентом, на модули для увеличения скорости сборки.
Ссылка на доклад: Презентация, Видео
Затем был доклад Владимира Тагакова из «Яндекс.Карт» про связь модулей с помощью Dagger. Таким образом они решают проблему выделения единого компонента карт для переиспользования во многих других приложениях «Яндекса».
Ссылка на доклад: Презентация, Видео
«Лаборатория Касперского» также не осталась в стороне от тренда: в сентябре Евгений Мацюк написал статью, как связать модули с помощью Dagger и при этом выстраивать многомодульную архитектуру по горизонтали, не забывая следовать принципам Clean Architecture по вертикали.
Ссылка на статью
А на зимнем «Мобиусе» было сразу два доклада. Сначала Александр Блинов рассказывал про многомодульность в приложении HeadHunter с использованием Toothpick в качестве DI, а сразу за ним Артем Зиннатулин рассказал про боль от 800+ модулей в Lyft. Саша начал говорить о многомодульности, как способе улучшения архитектуры приложения, а не только ускорения сборки.
Доклад Блинова: Презентация, Видео
Доклад Зиннатулина: Видео
Почему я начал статью с ретроспективы? Во-первых, это поможет вам лучше изучить тему, если вы читаете про многомодульность в первый раз. А во-вторых, первое выступление на нашей встрече началось с минипрезентации Алексея Калайды из компании «Стрим», который показывал, как они разбивали свое приложение на модули, основываясь на статье Жени (и некоторые моменты мне показались похожими на подход Владимира).
Основной особенностью этого подхода стала привязка к UI: каждый модуль подключается как отдельный экран — фрагмент, которому зависимости передаются из основного app-модуля, в том числе FragmentManager. Сначала коллеги попробовали реализовать многомодульность через прокси-инжекторы, которые предлагал в статье Женя. Но этот подход показался тяжеловесным: возникли проблемы, когда одна фича зависит от другой, которая, в свою очередь, зависит от третьей, — приходилось писать прокси-инжектор для каждого фича-модуля. Подход, основанный на UI-компонентах, позволяет не писать никаких инжекторов, разрешая зависимости на уровне зависимостей target-фрагментов.
Основные ограничения, которые есть у данной реализации: фича обязательно должна быть фрагментом (или другим view); наличие вложенных фрагментов, что приводит к большому бойлерплейту. Если фича внедряет другие фичи, она должна быть добавлена в map зависимостей, которую Dagger проверяет при компиляции. Когда таких фич много — возникают сложности в момент связывания графа зависимостей.
После доклада Алексея слово взял Александр Блинов. По его мнению, привязанная к UI имплементация подошла бы для DI-контейнеров во Flutter. Затем дискуссия переключилась на обсуждение многомодульности в HeadHunter. Целью их разбиения на модули стала возможность архитектурной изоляции фичей и увеличение скорости сборки.
Перед разбиением на модули важно провести подготовку. Сначала можно построить граф зависимости — например, с помощью такого инструмента. Это поможет выделить компоненты с минимальным числом зависимостей и избавиться от лишних (разрубить). Только после этого наименее связанные компоненты можно выделять в модули.
Александр напомнил основные моменты, о которых он более подробно говорил на «Мобиусе». Одна из сложных задач, которую должна учитывать архитектура, — переиспользование одного модуля из различных мест приложения. В примере с hh-приложением — это модуль резюме, который должен быть доступен как модулю списка вакансий (VacanciesList), когда пользователь переходит в резюме, которое он подавал на эту вакансию, так и модулю откликов (Negotiation). Для наглядности я перерисовал картинку, которую Саша изобразил на флипчарте.
Каждый модуль содержит две основные сущности: Dependencies — зависимости, которые нужны этому модулю, и API — методы, которые модуль предоставляет наружу, другим модулям. Связь между модулями осуществляют медиаторы, которые представляют собой плоскую структуру в главном app-модуле. Каждой фиче соответствует один медиатор. Сами же медиаторы включаются в состав некоего MediatorManager в app-модуле проекта. В коде это выглядит примерно так:
object MediatorManager {
val chatMediator: ChatMediator by lazy { ChatMediator() }
val someMediator: ...
}
class TechSupportMediator {
fun provideComponent(): SuppportComponent {
val deps = object : SuppportComponentDependencies {
override fun getInternalChat{
MediatorManager.rootMediator.api.openInternalChat()
}
}
}
}
class SuppportComponent(val dependencies) {
val api: SupportComponentApi = ...
init {
SupportDI.keeper.installComponent(this)
}
}
interface SuppportComponentDependencies {
fun getSmth()
fun close() {
scopeHolder.destroyCoordinator < -ref count
}
}
Александр пообещал в скором времени опубликовать плагин для создания модулей в Android Studio, который используется для избавления от копипасты у них в компании, а также пример консольного многомодульного проекта.
Еще немного фактов о текущих результатах разделения на модули приложения hh:
- ~83 фича-модуля.
- Для проведения A/B-теста фичи можно подменить целиком фича-модуль на уровне медиатора.
- График Gradle Scan показывает, что после параллельной компиляции модулей происходит достаточно длительный процесс дексирования приложения (в данном случае двух: для соискателей и работодателей):
Следующими взяли слово Александр и Владимир из «Тинькофф»:
Схема их многомодульной архитектуры выглядит так:
Модули разделяются на две категории: feature-модули и domain-модули.
Feature-модули содержат в себе бизнес-логику и UI фичи. Они зависят от domain-модулей, но не могут зависеть друг от друга.
Domain-модули содержат в себе код для работы с источниками данных, то есть какие-то модели, DAO (для работы с БД), API (для работы с сетью) и репозитории (совмещают в себе работу API и DAO). Domain-модули, в отличие от feature-модулей, могут зависеть друг от друга.
Связь domain- и feature-модулей происходит целиком внутри feature-модулей (то есть, в терминологии hh, Dependecies и API-зависимости Domain-модулей целиком разрешаются в использующих их feature-модулях, без применения дополнительных сущностей типа медиаторов).
Далее последовала серия вопросов, которые я почти без изменений помещу здесь в формате «вопрос — ответ»:
— Как у вас сделана авторизация? Как вы ее протаскиваете в фича-модули?
— Фичи у нас не зависят от авторизации, потому что почти все действия приложения происходят в авторизованной зоне.— Как отслеживать и очищать неиспользуемые компоненты?
— У нас есть такая сущность, как InjectorRefCount (реализованный через WeakHashMap), которая при удалении последнего Activity (или фрагмента), использующего этот компонент, удаляет его.— Как померить «чистый» scan и время билда? Если кэши включены — получается достаточно грязный scan.
— Можно отключить Gradle Cache (org.gradle.caching в gradle.properties).— Как запустить Unit-тесты из всех модулей в debug-режиме? Если запустить просто gradle test — подтягиваются тесты из всех flavors и buildType.
(Этот вопрос вызвал дискуссию многих участников встречи.)
— Можно попробовать запустить testDebug.
— Тогда не подтянутся модули, для которых нет debug-конфигурации. Запускается либо слишком много, либо слишком мало.
— Можно написать Gradle task, которая для таких модулей будет переопределять testDebug, либо в build.gradle модуля сделать фейковую debug-конфигурацию.
— Реализовать этот подход можно примерно так:
withAndroidPlugin(project) { _, applicationExtension ->
applicationExtension.testVariants.all { testVariant ->
val testVariantSuffix = testVariant.testedVariant.name.capitalize()
}
}
val task = project.tasks.register < SomeTask > (
"doSomeTask",
SomeTask::class.java ) {
task.dependsOn("${project.path}:taskName$testVariantSuffix")
}
Следующим с импровизированной презентацией выступили Евгений Кривобоков и Михаил Юдин из «Авито».
Для визуализации своего рассказа они использовали mindmap.
Сейчас в проекте компании >300 модулей, при этом 97% кодовой базы написано на Kotlin. Основной целью разбиения на модули было ускорение сборки проекта. Разбиение на модули происходило постепенно, с выделением в модули наименее зависимых частей кода. Для этого был разработан инструмент разметки зависимостей исходников в графе для impact-анализа (доклад про impact-анализ в «Авито»).
С помощью этого инструмента можно пометить feature-модуль как final, чтобы от него не могли зависеть другие модули. Это свойство будет проверяться при impact-анализе и обеспечивает обозначение явных зависимостей и договоренностей с командами, которые отвечают за модуль. На основе построенного графа также проверяется распространение изменений для запуска юнит-тестов для затронутого кода.
В компании используется монорепозиторий, но только для Android-исходников. Код других платформ живет отдельно.
Для сборки проекта используется Gradle (хотя коллеги уже подумывают о более подходящем для многомодульных проектов сборщике типа Buck или Bazel). Они уже опробовали Kotlin DSL, а затем вернулись на Groovy в Gradle-скриптах, потому что неудобно поддерживать разные версии Kotlin в Gradle и в проекте — общую логику выносят в плагины.
Gradle умеет параллелить таски, кэшировать, не пересобирать бинарные зависимости, если их ABI не изменился, что и обеспечивает ускорение сборки многомодульного проекта. Для более эффективного кэширования используется Mainfraimer и несколько самописных решений:
- При переключении с ветки на ветку Git может оставлять пустые папки, которые ломают кэширование (Gradle issue #2463). Поэтому они удаляются вручную с помощью Git-hook’а.
- Если не контролировать окружение на машинах разработчиков, то разные версии Android SDK и другие параметры могут ухудшать кэширование. Во время сборки проекта скрипт сверяет параметры окружения с ожидаемыми: если установлены неправильные версии или параметры — билд упадет.
- Собирается аналитика на включенные/выключенные параметры и окружение. Это нужно для мониторинга и помощи разработчикам.
- Ошибки сборки также отправляются в аналитику. Известные и популярные проблемы заносят на специальную страничку с решением.
Все это помогает достигать 15% cache miss на CI и 60–80% локально.
Следующие советы по работе с Gradle также могут быть полезными, если в вашем проекте появляется большое количество модулей:
- Отключать модули через IDE флаги неудобно, эти флаги могут сбрасываться. Поэтому модули отключают через settings.gradle.
- В студии 3.3.1 есть флажок «Skip source generation on Gradle sync if a project has more than 1 modules». По умолчанию он выключен, лучше включить.
- Dependencies прописываются в buildSrc чтобы переиспользовать во всех модулях. Другой вариант — Plugins DSL, но тогда нельзя вынести применение плагина в отдельный файл.
Заканчивал нашу встречу Владимир из «Тинькофф» с кликбейтным названием доклада «Как уменьшить сборку на PR с 40 минут до четырех». На самом же деле речь зашла о распределении запусков gradle-тасок: сборки apk, тестов и статических анализаторов.
Изначально у ребят на каждом пулл-реквесте запускались статический анализ, непосредственно сборка и тесты. Этот процесс занимал 40 минут, из которых только Lint и SonarQube занимали 25 и падали только на 7% запусков.
Таким образом, было решено вынести их запуск в отдельную Job-у, которая запускается по расписанию раз в два часа и в случае ошибки отправляет сообщение в Slack.
Противоположная ситуация была с использованием Detekt. Он падал почти постоянно, из-за чего его вынесли в предварительную pre-push проверку.
Так в проверке пулл-реквеста остались только сборка apk и юнит-тесты. Тесты компилируют исходники перед запуском, но не собирают ресурсы. Поскольку мерж ресурсов практически всегда завершался успехом, от самой сборки apk также отказались.
В итоге на пулл-реквесте остался только запуск модульных тестов, что и позволило добиться обозначенных 4 минут. Сборка apk осуществляется при мерже пулл-реквеста в dev.
Несмотря на то что встреча продолжалась почти 4 часа, мы так и не успели обсудить животрепещущий вопрос организации навигации в многомодульном проекте. Возможно, это тема для следующего Kaspersky Mobile Talks. Тем более, что формат очень понравился участникам. Расскажите в опросе или комментариях, о чем вам было бы интересно поговорить.
И напоследок, полезные ссылки из того же чата: