Привет, Хабр! Я Анатолий, главный инженер-программист разработки мобильных приложений в ПСБ. В этой статье делюсь опытом миграции крупного монолитного приложения «Мой Бизнес» на модули. Расскажу все ключевые моменты, и с какими сложностями мы столкнулись на этом пути.

Мы преследовали следующие цели:

  1. Независимость разработки. У продуктовой команды должен быть свой продукт, модуль, репозиторий. Никто не толкается локтями и не мешает друг другу. Каждый модуль это — мини-стартап в отдельно взятом огромном банковском приложении.

  2. Упрощение поддержки. Любая фича — маленькая и независимая.

  3. Распределение модулей между командами — на код ревью. Если что-то не так, лучше всего подскажет автор/владелец модуля. Хотелось бы видеть владельцев модулей в списке обязательных ревьюеров.

  4. Шаринг общего кода между проектами — один и тот же код может понадобиться в других проектах.

  5. Создание демоприложений — можно выделить мини-приложения для быстрого тестирования фичей в изоляции.

  6. Использование единой дизайн-системы — весь UI формируется в соответствии с дизайн-системой.

  7. Упрощение impact-анализа и его реализация вручную, без сторонних автоматизированных средств. Ускорение регресса за счет этого.

Существует три основных способа организации модульности в Android:

  1. Монорепозиторий — модули есть, но лежат в одном gradle проекте в рамках одного git репозитория.

  2. Публикация артефактов в артефакторий — собираем бинарник и подключаем его везде, где необходимо.

  3. Git submodules — модули расположены в разных проектах в разных git репозиториях, git их и связывает.

Исторически сложилось, что в наших проектах мы используем все 3 подхода. Расскажу, как мы дошли до этого.

Изначально наше приложение было монолитным и включало один модуль с оригинальным названием app. На первом этапе мы создали новый модуль application и подключили к нему app.

Первый модуль
Первый модуль

В Android модули бывают двух видов: application и library. Модуль типа application появляется при создании проекта и его цель собрать проект в бинарник apk или aab, чтобы его можно было запустить и отправить в стор. В свою очередь, library нельзя запускать, но можно подключить к другим модулям. На первом этапе мы решили выделить код, связанный со сборкой проекта в отдельный модуль. Модуль app был объявлен монолитом и из android-application мы разжаловали его до android-library. Модуль application стал модулем android-application, и его главной целью теперь является собирать все модули воедино. Таким образом, мы начали дробить приложение на модули в рамках одного проекта, и у нас получился монорепозиторий.

С этого момента было запрещено добавлять новый функционал в монолит app. Создание отдельного модуля app позволило нам видеть на ревью, внесены ли правки в монолит.

Далее мы выделяли из app фичи. Например, регистрация, аутентификация, кредиты, продукты, пуши и т. д. При этом иногда требовалось, чтобы одна фича вызывала какой-то код из другой фичи. В этом случае мы используем подход с делением фичи на интерфейс и реализацию. Идея не инновационная. Подробнее про этот подход и виды модулей с красивыми иллюстрациями можно почитать здесь. А в данной статье был разобран feature/api с практическими примерами по Dagger2.

Итак, выделяем интерфейс. Например, интерфейс модуля фичи кредитов:

package com.example.credits.core.navigation
 
import com.example.credits.core.domain.CreditOfferType
 
interface CreditsRouter {
    fun navigateToOffer(offerType: CreditOfferType)
}

Далее помещаем этот интерфейс и всё необходимое в core модуль (credits- core), а имплементацию — в фичемодуль (credits):

package com.example.credits.navigation
 
import com.example.credits.core.domain.CreditOfferType
import com.example.credits.core.navigation.CreditsRouter
import com.github.terrakok.cicerone.Router
 
internal class CreditsRouterImpl(private val router: Router) : CreditsRouter {
 
    override fun navigateToOffer(offerType: CreditOfferType) {
        router.navigateTo(CreditsScreens.CreditsList(offerType))
    }
}

Получается следующая картина:

Первая фича
Первая фича

Модуль credits-core подключается к credits через api, а не implementation. Это делается для того, чтобы при подключении credits к application не нужно было ещё отдельно подключать credits-core. На данном этапе у нас большинство кода всё ещё находится в монолите app, поэтому credits-core подключается и к app.

Предположим, что мы готовы к выносу фичи продуктов (products) из app. Модулю products нужна фича кредитов, поэтому схема будет следующей:

Взаимодействие products с credits
Взаимодействие products с credits

В этом случае модулю продуктов (products) необходимо взаимодействовать с кредитами (credits). Вместо того чтобы напрямую подключить credits к products, мы подключили credits-core к products.

При необходимости взаимодействовать из credits с products мы легко можем перейти к такому варианту:

Взаимодействие products и credits
Взаимодействие products и credits

При решении задачи в лоб мы бы получили циклическую зависимость между модулями, и gradle отказался бы даже собирать такое.

Циклическая зависимость
Циклическая зависимость

В рамках одного модуля эти проблемы не столь заметны, и циклические зависимости между пакетами всё же могут появляться. В таком случае можно попробовать использовать инструмент для их поиска:

Поиск циклических зависимостей
Поиск циклических зависимостей

Однако на момент написания статьи инструмент работает только для Java классов.

Некоторые циклические зависимости нам никак не мешают. Например, и presenter, и view скорее всего попадут в один модуль, потому что такова воля MVP. Но всё же в теории можно вынести интерфейс View в пакет presenter. Во всех остальных случаях может понадобится переместить файлы из одного пакета в другой или создать новый интерфейс, чтобы от него все зависели, и после этого разносить модули.

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

grep -R 'import com.example.modules' $@ | cut -d' ' -f2 | tr -d ';' | sort | uniq -c | sort --reverse

Где com.example.modules — пакет модуля монолита. В нашем случае отличительным признаком монолита являлось имя его пакета, поэтому мы просто заходили в каждый модуль и искали импорты с таким именем. Данный скрипт позволил посчитать количество связей с монолитом и даже составить топ классов, на которых больше всего ссылок. Такие классы мы старались выносить в отдельные модули в первую очередь.

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

С Kotlin всё просто:

import com.example.modules.R as appR

У нас также есть небольшой процент Java кода, но за счёт масштабов проекта кода получается довольно много, и быстро переписать его не выйдет. Чтобы поддержать со стороны Java, нам понадобилась прослойка:

object AppR {
 
    object Color {
 
        @JvmField
        @ColorRes
        val white = R.color.white
...

И можно использовать, например:

final int whiteColor = ContextCompat.getColor(this, AppR.Color.white);

Однако это всё не работает, если у нас есть ссылка на ресурс монолита из xml ресурсов модуля.

В таком случае проблемы можно заметить только при компиляции (грусть). Нас спасало то, что большинство ресурсов хранилось в модуле дизайн системы (ДС). Модуль ДС интересен тем, что это первый модуль, который мы начали публиковать в наш maven артефакторий. Давайте разберём этот подход.

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

При публикации обязательно нужно указать версию. Для этой цели у нас есть 2 версии SNAPSHOT_SUFFIX, например, MS.41833.2. Используется для публикации тестовых сборок. Состоит из сокращенного названия команды MS — микросервисы, номера задачи в Jira — 41833 и номера сборки - 2. При неудачном прохождении дизайн ревью или тестирования недочёты исправляются и номер сборки повышается. В данном случае номер сборки 2, потому что с первого раза не получилось. Ну никак не можем без багов обойтись)

После прохождения дизайн ревью и тестирования мы используем версию UICOMPONENTS_VERSION=3.1.4. В этом случае мы придерживаемся подхода semver. Если вкратце, то первая цифра справа увеличивается при любых незначительных изменениях. Вторая при добавлении нового компонента. Третья при изменениях, которые могут сломать обратную совместимость. При инкременте версии все цифры справа обнуляются.

Например:

2.14.2 → 2.15.0 — повысили вторую цифру

2.17.2 → 3.0.0 — повысили первую

Чтобы исключить человеческий фактор, был написан скрипт, использующий плагин binary-compatibility-validator, который проставляет версию сам. Логика следующая:

Алгоритм повышения версии
Алгоритм повышения версии

После успешного merge рабочей ветки в master на CI запускается скрипт, основанный на представленном алгоритме. Затем происходит запуск таски apiDump, чтобы зафиксировать текущее состояние публичного api. Файлы с изменённой версией и api коммитятся на CI.

При всём этом мы стараемся не ломать обратную совместимость и следовать принципу open/close, но при возникновении ломающих изменений нужно адаптировать проект к новой версии.
По возможности сначала используем аннотацию "@Deprecated" с информацией о том, как мигрировать на новую версию. Затем, после того, как приложение перейдёт на новый подход, удаляем всё устаревшее из библиотеки.

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

А с точки зрения проектов, в которые библиотека подключается, всё происходит аналогично тому, как вы подключаете androidx и прочие зависимости:

implementation("com.example.modules.uicomponents:uicomponents:$version")

В данном случае мы получаем отдельный проект с возможностью подключения его артефактов к любым другим проектам. К минусам я бы отнёс сложности с версионированием. Если не использовать автоматизацию, то нужно внимательно следить за внесёнными изменениями и перед релизом поднимать нужную версию. В данном случае можно легко ошибиться, например, вместо minor увеличить patch.

Данный подход основан на том, что git позволяет собирать несколько проектов в один. В этом случае весь код подключаемого проекта будет доступен для редактирования. Версией будет номер коммита. Рекомендую к чтению подробнейшее руководство.

В нашем проекте мы перевели на submodules несколько демо-приложений. Демо-приложение представляет собой приложение конкретной команды, содержащее только модуль этой команды, и минимальный базовый функционал основного приложения, такой, как авторизация или сетевой слой. Такое приложение используется для того, чтобы иметь быструю точку доступа к разработке конкретного функционала, без запуска всего приложения и поиска нужной кнопке в большом дереве компонентов всего банковского приложения. Каждый такой модуль образует маленький стартапчик в нашем огромном банковском приложении.

Основной проект оказался подключен к демо как сабмодуль:

Демо модуль подключает основной проект
Демо модуль подключает основной проект

Со стороны IDE это выглядит так:

Вид project в Android Studio
Вид project в Android Studio

При этом модуль основного приложения (application) тоже доступен, как и множество других модулей, но никак не используется в демо.

В демо настраивается свой DI граф, в котором подменяются реальные фичи заглушками. У демо также есть свои gradle скрипты, в которых прописан путь до каждого модуля с учётом вложенности в директорию msb.

Например, settings.gradle в основном проекте:

include(":products-core")

В демо:

include(":main:products-core")

Также, чтобы gradle мог собрать подключаемые модули, пришлось заменить в блоках dependencies в основном проекте пути.

Были относительно корня проекта:

implementation(project(":products-core"))

Теперь относительно родительской директории:

implementation(project("${parent!!.path}:products-core"))

Иначе в демо поиск зависимости будет происходить по пути /products-core, а не по /main/products-core, и очевидно gradle не сможет ничего найти:

Ошибка сборки при поиске модуля
Ошибка сборки при поиске модуля

Может возникнуть вопрос: почему бы демо не хранить в монорепозитории с основным проектом? Мы решили вынести демо в отдельный проект потому, что проект находится в стадии активной разработки и часто ломается компиляция creditsdemo. Приходилось тратить время всех разработчиков на починку, хотя в контексте находится владелец модуля. В демо периодически по необходимости обновляется master основного проекта.

К плюсам здесь я бы отнёс:

  1. Простоту подключения — не нужен артефакторий, как в предыдущем варианте.

  2. Не нужно думать о версионировании.

К минусам:

  1. При клонировании проекта с сабмодулями нужно не забыть добавить флаг --recurse-submodules.

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

  3. Можно сразу редактировать как основной код, так и код модуля. С одной стороны, это плюс к скорости и простоте внесения изменений, с другой – страдает изоляция и повышает риск ошибок.

Пошаговый пример доступен по ссылкам:

  1. Монолит https://github.com/Onotole1/Modules-Sample/tree/master

  2. Выделение application модуля https://github.com/Onotole1/Modules-Sample/tree/application_module

  3. Модули кредитов https://github.com/Onotole1/Modules-Sample/tree/credits_modules

  4. Модули предложений продуктов https://github.com/Onotole1/Modules-Sample/tree/products_modules

  5. Демо приложение фичи кредитов https://github.com/Onotole1/Submodule-Sample

Нам удалось распределить права на модули между всеми участниками Android команды с помощью разбивки проекта на модули. Сформировали файл CODEOWNERS со списком модулей и владельцев:

[Кредиты]
/credits/       @pupkinv
/credits-core/  @pupkinv
 
[Продукты]
/products/            @ivanovi
/products-core/       @ivonovi
...

И далее интегрирован в CI (а, впрочем, это уже совсем другая история).

Также появилась возможность шарить код между проектами, используя артефакты или submodules.

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

Приглашаю поделиться в комментариях, какие сложности возникали у вас при модуляризации проектов?

Комментарии (1)


  1. leon-mbs
    10.06.2024 11:31

    Хорошо что остановились на модулях - вовремя закончился хайп вокруг микросервислов.