Меня зовут Сергей, я тимлид команды андроид Тинькофф. 

В первой части этой серии статей я рассказывал про то, как изменились подходы к тестированию в мобильном приложении Тинькофф. 

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

Здесь я хочу рассказать о том, что мы имели ДО и что получилось сейчас. 

Как выглядела архитектура нашего проекта

Немного расскажу о проекте. У нас есть два приложения: Тинькофф и Тинькофф Джуниор. 

Основная часть кодовой базы — общая для обоих приложений. Но есть фичи и реализации, которые различаются. 

Схема зависимостей проекта
Схема зависимостей проекта

Так выглядела схема gradle-модулей нашего проекта:

  • Bank отвечал  за фичи или реализации для мобильного приложения Тинькофф. 

  • Kids отвечал  за фичи или реализации для приложения Тинькофф Джуниор. 

  • App — в нем хранилось все, что реализовано одинаково для обоих приложений. По большей части это были продуктовые фичи и общие компоненты.

  • Common — здесь также одинаковые для обоих приложений компоненты, но они были общими. Например, обвязки для списков, функции/классы для работы с изображениями, View. Это был большой утильный модуль.

  • Несколько фича-модулей, которые были не сильно разграничены, могли отвечать за несколько бизнес-фич, которые могли использоваться в разных частях приложения отдельно друг от друга.

Какие проблемы перед нами стояли

В ходе работы мы сгруппировали и выделили для себя ряд проблем, которые предстояло решить. 

Долгие сборки. Поскольку код был связан, практически любое изменение заставляло Android Studio пересобирать почти весь проект. 

Связность кода. Имелось большое количество god object-ов, которые решали слишком много задач. Если затрагивались такие объекты, мы получали полную пересборку проекта и аффектили большое количество фич. Это приводило к неожиданным багам и долгому регресс-тестированию. 

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

И другие проблемы. Например, невозможно было писать тесты в отдельных модулях, и это мешало писать UI-/интеграционные тесты. Новым разработчикам было трудно войти в проект без его четкой архитектуры.

Что планировали для решения задач

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

Из неравнодушных мы сформировали команду специалистов, готовых погрузиться в рефакторинг. В идеале мы хотели видеть такую структуру приложения:

Структура проекта к которой мы стремились
Структура проекта к которой мы стремились

Это очень упрощенная схема. Таких модулей значительно больше. Но концепция при этом не меняется. Чтобы схема стала понятнее, давайте попробуем  разложить по полочкам. Идея простая: в приложении могут быть четыре вида gradle-модулей.

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

Контракт таких модулей:

  • Они могут зависеть только от других common-модулей и библиотек, подключаемых извне. Эти модули не должны знать ничего о существовании каких-либо не common фич. В идеале количество зависимостей в них должно сводиться к минимуму.

  • Модули могут получать параметры извне и инициализировать их в Application нашего приложения. К примеру, можно передавать разные ключи для отправки аналитики в зависимости от того, из какого приложения мы подключаем модуль: kids или bank.

Feature-domain-модули. Эти модули содержат в себе доменные сущности разных фич. Например, в банковском приложении есть фича «Счета». Она содержит компоненты, такие как BankAccount (банковский счет пользователя) или Card (карты, привязанные к счету). Эти элементы используются по всему приложению как сущности, независимо от UI главного экрана. Соответственно, мы не хотим тащить UI-компоненты из модулей всякий раз, когда нам просто нужно узнать идентификатор счета пользователя для получения информации, не связанной с фичей «Счета».

Такие модули хранят в себе:

  • Модели, отражающие бизнес-сущности фичи.

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

  • Базы данных.

  • Сервисы для работы с сетью.

  • Мапперы для работы с моделями.

  • Use cases. Сущности, которые отражают бизнес-логику и скрывают от пользователей презентационного слоя внутреннюю реализацию.

  • Любые другие классы, не связанные с UI, которые могут быть нужны для работы фичи.

Контракт данных модулей:

  • Они могут зависеть только от common-модулей. Здесь не может быть зависимостей от UI-модулей.

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

  • Все, что можно скрыть, должно быть скрыто под модификаторами доступа internal или private.

  • Любое взаимодействие с фичей идет только через use cases. Интерфейсы репозиториев не должны торчать наружу (ниже мы подробнее разберем работу с фичами).

Feature-ui-модули содержат в себе UI фич. Сюда входят конкретные экраны или отдельные элементы пользовательского интерфейса. По сути это Presentation layer (Слой представления) нашей фичи.

В таких модулях мы храним:

  • активити/фрагменты;

  • презентеры, вью модели;

  • кастомные View;

  • классы для работы с UI.

Контракт модулей:

  • Модули могут зависеть от common- и feature-domain-модулей. UI-модули не должны зависеть от других UI-модулей, это достигается при помощи глобальной навигации.

  • Модуль не содержит в себе бизнес-логику, а получает из модулей, ее содержащих. Получать он может как из одного, так и из нескольких модулей.

Feature-ui-with domain модули. В некоторых случаях модули, содержащие экраны и бизнес-логику, могут быть объединены. Происходит это, если на этапе проектирования не предполагается использование бизнес-логики данной фичи в других модулях. То есть, если бизнес-логика нигде не используется без UI-части. Когда какие-то сущности начинают использоваться отдельно от UI, модуль следует разделить на domain и UI. По контракту модулей они относятся к модулям UI.

Как выделяли общее и частное, бизнес и монолит

Когда роадмап готов, можно заняться рефакторингом отдельных фич. Но сначала необходимо отделить от общего приложения те сущности, которые использовались во всем приложении, — это вышеописанные common-модули. 

Первыми под нож пошли самые основные модули: 

  • common-di — модуль с иерархией холдеров для предоставления di-компонентов.

  • common-util — включает в себя общие утилиты, необходимые большому количеству модулей. Предоставляет компоненты Context, ResourceProvider, AppInfo и другие сущности, которые нужны для работы с ресурсами приложения. 

  • common-api — предоставляет компонент для работы с API. Содержит в себе сущности для конфигурации работы с API.

  • common-database — нужен для работы с базой данных. Содержит в себе фабрики для создания экземпляров базы данных.

  • common-navigation — предоставляет компонент навигации между фичами. Содержит интерфейсы роутеров.

  • common-uikit — содержит UiKit-приложения: стили, отступы, цвета, кастомные вью.

После того как мы отделили эти модули, смогли продолжить работы по рефакторингу. Под распил пошли другие общие модули:

  • common-security;

  • common-analytics;

  • common-configs;

  • common-recycler и др.

Через полгода работы мы вынесли ключевые модули нашего приложения и смогли взяться за рефакторинг конкретных бизнес-фич. 

Несмотря на проделанный объем работы, оставалось еще отделить бизнес-фичи от монолита в изоляции. Понять, какие проблемы это может за собой нести, и разработать подходы, которыми можно поделиться со всей командой.

Пробным шагом стало несколько небольших бизнес-фич на 1—2 экрана с минимальным количеством логики. Они дали возможность без особой боли выявить частые проблемы и разработать решения для них. 

К моменту работы с более серьезными фичами у нас уже было два подхода  вырезания фичи из монолита: «от UI к бизнес-логике» и «от бизнес-логики к UI». Рассмотрим немного каждый.

От UI к бизнес-логике. В этом подходе берем экран и тянем за зависимости как за ниточки, последовательно вытягивая их из клубка.

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

Минусы: не видим картины в целом до тех пор, пока не распутаем весь клубок. И не всегда можно понять, что и куда лучше положить.

От бизнес-логики к UI — подход, противоположный предыдущему. Мы берем фичу и выделяем ее бизнес-логику в отдельный модуль. Подход требует больше работы перед началом рефакторинга: нам нужно проанализировать фичу до того, как что-то делать. Нужно определить сущности, от которых зависит наша фича, какие из этих сущностей нужны только этой фиче, а какие тянутся транзитивно, какие сущности зависят от нашей фичи. В идеале перед началом работы нужно составить схему, на которой видно, какие зависимости мы имеем и где они используются. Плюсы и минусы здесь обратные подходу «от UI к бизнес-логике»: мы видим общую картину, но нужно потратить больше времени на подготовку.

Трудно сказать, какой из этих подходов лучше. Скорее тут нужно смотреть, какую фичу мы будем рефакторить, и анализировать ее перед началом.

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

Как проводили аудит кода

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

Три сущности каждого компонента:

1. Интерфейс компонента.

interface BadgeComponent : DIComponent {
   fun badgeStatePublisher(): BadgeStatePublisher
}

Это  API нашего компонента, который сразу показывает, что из себя представляет фича. Каждый метод в нем говорит о том, какие сущности предоставляет наружу данный компонент. 

DIComponent — это просто маркерный интерфейс, необходимый для реализации базовых сущностей нашего DI.

2. Внутренняя реализация.

@Component(
   dependencies = [
       ApplicationComponent::class,
       SecurityComponent::class,
   ],
   modules = [
       BadgeModule::class
   ]
)

@Singleton
internal interface BadgeComponentInternal : BadgeComponent {

   @Component.Factory
   interface Factory {

       fun create(
           applicationComponent: ApplicationComponent,
           securityComponent: SecurityComponent,
       ): BadgeComponentInternal
   }
}

Это внутренняя часть компонента. Здесь видны те зависимости, которые нужны для корректной работы нашего компонента. Также подобная внутренняя реализация позволяет избежать проблемы со скоупами, когда компонент без скоупа не может зависеть от компонента со скоупом. 

Для создания компонента мы используем Factory, это позволяет определять отсутствующие зависимости на стадии компиляции. 

3. Component Holder.

object BadgeComponentHolder : ComponentHolder<BadgeComponent>() {

   override fun build(): BadgeComponent {
       return DaggerBadgeComponentInternal.factory().create(
           ApplicationComponentHolder.get(),
           SecurityComponentHolder.get()
       )
   }
}

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

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

Какие бывают холдеры и почему они для нас важны

Холдеры бывают трех видов:

СomponentHolder — позволяет получить компонент. Если компонента нет, то создается новый.

Реализация ComponentHolder
abstract class ComponentHolder<Component : DIComponent> : BaseComponentHolder<Component>, ClearedComponentHolder {

   private var component: Component? = null

   @MainThread
   override fun get(): Component {
       return component ?: build().also {
           component = it
       }
   }

   @VisibleForTesting
   override fun set(component: Component) {
       this.component = component
   }

   @MainThread
   protected abstract fun build(): Component

   @MainThread
   override fun clear() {
       component = null
   }
}

DataComponentHolder — позволяет получить компонент, который требует на вход какие-либо данные.

Реализация DataComponentHolder
abstract class DataComponentHolder<Component : DIComponent, Data : Any> : BaseComponentHolder<Component>, ClearedComponentHolder {

   private var component: Component? = null

   @MainThread
   override fun get(): Component {
       return requireNotNull(component) { "${javaClass.simpleName} — component not found" }
   }

   @MainThread
   fun init(data: Data) {
       component ?: build(data).also { component = it }
   }

   @VisibleForTesting
   override fun set(component: Component) {
       this.component = component
   }

   @MainThread
   override fun clear() {
       component = null
   }

   @MainThread
   protected abstract fun build(data: Data): Component
}

FeatureComponentHolder — холдер компонента с автоматической очисткой. Позволяет получить компонент. Если компонента нет, то создается новый. Этот холдер не подходит для компонентов, имеющих какой-либо скоуп (@Singleton, свой кастомный и др.). Так как данный холдер может очиститься при отсутствии ссылок на компонент, мы не можем гарантировать существование синглтонов. Для компонентов, имеющих скоуп, мы используем ComponentHolder.

Реализация FeatureComponentHolder
abstract class FeatureComponentHolder <Component : DIComponent> : BaseComponentHolder<Component>, ClearedComponentHolder {

   private var component: WeakReference<Component>? = null

   @MainThread
   override fun get(): Component {
       return component?.get() ?: build().also {
           component = WeakReference(it)
           HoldersUsageWatcher.onHolderActivated(this@FeatureComponentHolder)
       }
   }

   /**
    * Создает слабую ссылку на компонент
    *
    * <p class="caution"><strong>Внимание:</strong> Метод set не создает
    * сильной ссылки на компонет. Вы должны хранить ссылку на компонент в клиентском коде.
    * Метод должен использоваться только в тестах для подмены на тестовые сущности.</p>
    *
    * @param component Компонента DI
    */

   @VisibleForTesting
   override fun set(component: Component) {
       this.component = WeakReference(component)
   }

   @MainThread
   protected abstract fun build(): Component

   @MainThread
   override fun clear() {
       component = null
   }
}

Это «черепахи» нашего DI, на котором держится весь граф зависимостей. У себя мы используем Dagger, но ничто не мешает реализовать подобный подход с любым другим DI фреймворком или даже руками. Такой подход позволяет легко подключать фичи друг к другу и не иметь God object-a, который знает обо всем. 

Плюсы такого подхода:

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

  • Уменьшение времени сборки за счет независимости фич друг от друга. В частности это влияет на то, сколько модулей будет пересобрано при изменении кода одного из gradle-модулей.

  • Удобство тестирования. Фичи изолированы, а значит, намного проще создать окружения для тестирования функционала нашей фичи.

Как мы поступили с навигацией

Мы хотели сделать так, чтобы UI фичи А ничего не знал о UI фичи Б. Для этого имеется common-navigation-модуль. Его концепция достаточно проста: он хранит в себе компонент и интерфейсы для перехода на все экраны. Каждый интерфейс (он же роутер) имеет один метод, который возвращает какой-то Android-примитив вроде Intent или Fragment. 

interface OfferQrCodeAtmRouter {

   fun createIntent(context: Context): Intent
}

Реализации данных роутеров лежат в модулях:

  • app — если экран общий для взрослого и детского приложения;

  • kids или bank — если роутер специфичен для одного из приложений.

Остальные модули достаточно специфичны и в целом просты, чтоб описывать их здесь. 

В чем преимущества нашего подхода

  • Мы постепенно превращаем наше приложение из огромного монолита в конструктор, в котором каждый модуль явно и прозрачно говорит о том, что ему нужно для корректной работы. 

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

  • Команды стали меньше аффектить работу других команд. Так как большинство кода разрабатывается в отдельных модулях, мы тратим меньше времени на разрешение конфликтов при мерджах, фиксах багов во время интеграции фич. 

Какие у подхода недостатки

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

  • Можно умереть в рефакторинге. Можно уйти рефакторить один экран и попутно придется отрефакторить еще несколько соседних фич. 

  • Постоянная борьба за баланс. С одной стороны, мы не хотим, чтоб некоторые модули превращались в свалку, с другой — не хотим плодить модули, в которых 1—3 класса. 

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


  1. alexmnx
    25.08.2021 17:47
    +2

    Накину чутка) feature-domain не должны зависеть от других feature-domain


    1. s-buvaka Автор
      25.08.2021 17:48

      Спасибо. Надо бы поправить