Привет, меня зовут Андреи? и я занимаюсь приложениями Тинькофф и Тинькофф Джуниор для платформы Android. Хочу рассказать о том, как мы собираем два похожих приложения из однои? кодовои? базы.
Тинькофф Джуниор — это мобильное банковское приложение, ориентированное на детеи? до 14 лет. Оно похоже на обычное приложение для взрослых, только в него добавлены некоторые функции (например, темы оформления), а другие, наоборот, выключены (например, кредитки).
.
На старте проекта мы рассматривали различные варианты его реализации и приняли ряд решении?. Сразу же стало очевидно, что два приложения (Тинькофф и Тинькофф Джуниор) будут иметь значительную часть общего кода. Мы не хотели делать форк от старого приложения, а потом копировать исправления ошибок и новыи? общии? функционал. Чтобы работать с двумя приложениями сразу, мы рассматривали три варианта: Gradle Flavors, Git Submodules, Gradle Modules.
Gradle Flavors
Многие наши разработчики уже пробовали использовать Flavors, плюс мы могли применить многомерную сборку (multi-dimensional flavors) для использования с уже существующими flavors.
Однако Flavors имеют один фатальныи? недостаток. Android Studio считает кодом только код активного флеи?вора — то есть то, что лежит в папке main и в папке флеи?вора. Остальнои? код считается текстом наравне с комментариями. Это накладывает ограничения на некоторые инструменты студии: поиск использования кода, рефакторинг и другие.
Git Submodules
Еще один вариант реализации нашеи? идеи — использовать сабмодули гита: вынести общии? код в отдельныи? репозитории? и подключать его как сабмодуль к двум репозиториям с кодом конкретного приложения.
Этот подход увеличивает сложность работы с исходным кодом проекта. Также разработчикам все равно пришлось бы работать со всеми тремя репозиториями, чтобы вносить правки при изменении API общего модуля.
Многомодульная архитектура
Последнии? вариант — перейти на многомодульную архитектуру. Этот подход лишен недостатков, которые есть у двух других. Однако переход на многомодульную архитектуру требует временных затрат на рефакторинг.
На момент начала работы над Тинькофф Джуниор у нас было два модуля: маленькии? модуль API, описывающии? работу с сервером, и большои? монолитныи? модуль application, в котором была сосредоточена основная часть кода проекта.
В итоге мы хотели получить два модуля приложении?: adult и junior и некии? общии? core-модуль. Мы выделили два варианта:
- Вынесение общего кода в общии? модуль common. Этот подход «правильнее», однако он требует больше времени. Мы оценивали объемы переиспользования кода приблизительно в 80%.
- Преобразование модуля приложения в библиотеку и подключение этои? библиотеки к тонким модулям adult и junior. Этот вариант быстрее, однако он принесет в Тинькофф Джуниор код, которыи? никогда не будет выполняться.
У нас было время в запасе, и мы решили начать разработку по первому варианту (модуль common) с условием переи?ти к быстрому варианту, когда у нас закончится время на рефакторинг.
В итоге так и случилось: мы перенесли часть проекта в модуль common, а потом оставшии?ся модуль application превратили в библиотеку. В результате сеи?час мы получили такую структуру проекта:
У нас есть модули с фичами, что позволяет нам разграничивать «взрослыи?», общии? или «детскии?» код. Однако модуль application всё еще достаточно большои?, и сеи?час там хранится около половины проекта.
Превращаем приложение в библиотеку
В документации есть простая инструкция по превращению приложения в библиотеку. Она содержит четыре простых пункта и, казалось бы, никаких трудностеи? быть не должно:
- Открыть файл
build.gradle
модуля - Удалить
applicationId
из конфигурации модуля - В начале файла заменить
apply plugin: 'com.android.application'
наapply plugin: 'com.android.library'
- Сохранить изменения и синхронизировать проект в Android Studio (File > Sync Project with Gradle Files)
Однако конвертация заняла несколько дней и итоговый дифф получился таким:
- 183 files changed
- 1601 insertions(+)
- 1920 deletions(-)
Что же пошло не так?
Прежде всего в библиотеках идентификаторы ресурсов — это не константы. В библиотеках, как и в приложениях, генерируется фаи?л R.java со списком идентификаторов ресурсов. И в библиотеках значения идентификаторов не являются константными. Джава не позволяет делать свитч по неконстантным значениям, и все свитчи нужно заменить на if-else.
// Application
int id = view.getId();
switch(id) {
case R.id.button1:
action1();
break;
case R.id.button2:
action2();
break;
}
// Library
int id = view.getId();
if (id == R.id.button1) {
action1();
} else if (id == R.id.button2) {
action2();
}
Далее мы столкнулись с коллизиеи? пакетов.
Предположим, у вас есть библиотека, у которои? package = com.example, и от этои? библиотеки зависит приложение с package = com.example.app. Тогда в библиотеке будет сгенерирован класс com.example.R, а в приложении, соответственно, com.example.app.R. Теперь создадим в приложении активити com.example.MainActivity, в которои? попробуем обратиться к R-классу. Без явного импорта будет использован R-класс библиотеки, в котором не указаны ресурсы приложения, а только ресурсы библиотеки. Однако Android Studio не подсветит ошибку и при попытке переи?ти из кода к ресурсу всё будет окей.
Dagger
В качестве фреи?мворка для инъекции зависимостеи? мы используем Dagger.
В каждом модуле, содержащем активити, фрагменты и сервисы, у нас есть обычные интерфеи?сы, в которых описаны inject-методы для этих сущностеи?. В модулях приложении? (adult и junor) интерфеи?сы-компоненты даггера наследуются от этих интерфеи?сов. В модулях мы приводим компоненты к необходимым для данного модуля интерфеи?сам.
Мультибиндинги
Разработку нашего проекта значительно упрощает использование мультибиндингов.
В одном из общих модулеи? мы определяем интерфеи?с. В каждом модуле приложения (adult, junior) описываем реализацию этого интерфеи?са. С помощью аннотации @Binds
указываем даггеру, что всякии? раз вместо интерфеи?са необходимо инжектить его конкретную реализацию для детского или взрослого приложения. Также мы нередко собираем коллекцию реализации? интерфеи?са (Set или Map), при этом такие реализации описаны в разных модулях приложения.
Флейворы
Для разных целеи? мы собираем несколько вариантов приложения. Флеи?воры, описанные в базовом модуле, должны быть описаны и в зависимых модулях. Также для корректнои? работы Android Studio необходимо, чтобы во всех модулях проекта были выбраны совместимые варианты сборки.
Выводы
За короткии? срок мы реализовали новое приложение. Теперь мы отгружаем новыи? функционал в двух приложениях, написав его один раз.
При этом мы потратили некоторое время на рефакторинг, попутно уменьшив техническии? долг, и перешли на многомодульную архитектуру. По пути мы столкнулись с ограничениями со стороны Android SDK и Android Studio, с которыми успешно справились.
agent10
Для чего отдельно модуль commonFeature и application(Library)? Может их стоило бы объединить?
xotta6bl4 Автор
У нас есть несколько модулей junior feature, несколько модулей common feature и несколько модулей adult feature. В каждом модуле описана 1 фича.
Модуль application(library) — это остатки нашего монолита, который мы продолжаем разносить на фиче-модули.
agent10
Мы сейчас похожим начали заниматься… И ещё вопрос, вот модули junior feature и adult feature находятся рядом в корневой папке вместе с остальными общими модулями или каждый из этих модулей находятся внутри «модулей» приложение junior и adult? Не совсем понимаю позволяет ли студия так делать?
И ещё вопросы:
1) могут ли быть у junior и adult свои собственные flavors/build variants?
2) Как в рамках CI происходит сборка разных приложений?
anegin
Все модули равноценны на уровне проекта, «модулей внутри модулей» нет.
Сами модули можно размещать в разных папках и подпапках проекта (с указанием пути в settings.gradle)
xotta6bl4 Автор
В "корневой" папке проекта можно создать дерево папок и хранить модули там.
Примерно так
root
— application (легаси — монолит)
— adult (модуль)
— junior (модуль)
— sources
— features
— — feature-A (модуль фичи А)
— — feature-B (модуль фичи Б)
— — feature-C (модуль фичи С)
Да, могут.
Все просто, запускаем отдельно две таски)
:adult:assembleDebug :junior:assembleDebug
В данном случае таски имеют одинаковое имя, так что можно просто
assembleDebug
agent10
Соберутся сразу два приложения, верно?
xotta6bl4 Автор
Конечно да, а, к примеру, таска
installDebug
установит дебажные версии двух приложений.agent10
Круто.
А как с версионностью? Сквозная, одна на оба приложения?
Как-то помечаете коммиты в гите, когда изменения затрагивают отдельные приложения/когда сразу оба/когда только общий код?
xotta6bl4 Автор
Сейчас у нас синхронные мажорные и минорные релизы. Хотфиксы тоже обычно синхронно выкатываем. Пару раз были независимые хотфиксы только одного приложения. Так что сейчас для упрощения жизни у нас есть жесткий маппинг версий между приложениями и версии синхронно поднимаются.
Спецпометок нет, но для двух приложений у нас отдельные проекты в трекере задач и по названию ветки видно "приложение-инициатор" изменений.
agent10
Ок. Спасибо за ответы!