Clean architecture multimodule android

Clean Architecture в Android для начинающих

Feb 17 · 7 min read

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

“Задача архитектуры программного обеспечения — минимизация человеческих ресурсов, необходимых для создания и обслуживания требуемой системы”

Однако не всегда просто написать такой код, который легко тестировать и поддерживать, который облегчает всей команде совместную работу.

Теоретически обосновал достижение этих целей Robert Martin (он же Uncle Bob). Он написал три книги о применении «чистого» подхода при разработке программного обеспечения (ПО). Одна из этих книг называется «Чистая архитектура, профессиональное руководство по структуре и дизайну программного обеспечения (ПО )», она и явилась источником вдохновения при создании этой статьи.

Кто-то скажет, это так, но меня это не касается, ведь в моем приложении уже есть архитектура MVVM?

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

Немного теории

Вероятно, вы много раз видели эту послойную диаграмму. Правда, мне она не очень помогла понять, как преобразовать эти слои в подготовленном проекте Android. Но сначала разберемся с теорией и определениями:

· Сущности ( Entities): инкапсулируют наиболее важные правила функционирования корпоративного уровня. Сущности могут быть объектом с методами или набором из структур данных и функций.

· Сценарии использования ( Use cases): организуют поток данных к объектам и от них.

· Контроллеры, сетевые шлюзы, презентеры ( Controllers, Gateways, Presenters): все это набор адаптеров, которые наиболее удобным способом преобразуют данные из сценариев использования и формата объектов для передачи в верхний слой (обычно пользовательский интерфейс).

· UI, External Interfaces, DB, Web, Devices: самый внешний слой архитектуры, обычно состоящий из таких элементов, как пользовательские и внешние интерфейсы, базы данных и веб-фреймворки.

После прочтения этих определений я всегда оказывался в замешательстве и не был готов реализовать «чистый» подход в своих Android проектах.

Прагматичный подход

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

· Домен: содержит определения логики функционирования приложения, модели данных сервера, абстрактное определение репозиториев и определение сценариев использования. Это простой, чистый модуль kotlin (независимый от Android).

· Данные: содержит реализацию абстрактных определений доменного слоя. Может быть повторно использован любым приложением без модификаций. Он содержит репозитории и реализации источников данных, определение базы данных и ее DAO, определения сетевых API, некоторые средства преобразования для конвертации моделей сетевого API в модели базы данных и наоборот.

· Приложение (или слой представления): он зависит от Android и содержит фрагменты, модели представления, адаптеры, действия и т.д. Он также содержит указатель служб для управления зависимостями, но при желании вы можете использовать Hilt.

Практическая часть — небольшое приложение для книжного каталога

Чтобы применить на практике все эти “абстрактные” понятия, я разработал простое приложение, которое демонстрирует список книг, написанных дядей Бобом, и которое дает пользователям возможность помечать некоторые из них как “избранные”.

Модуль домена

Чтобы получить список книг, я использовал API Google книги. Это API возвращает список книг, отфильтрованных по параметру строки запроса:

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

data class Volume(val id: String, val volumeInfo: VolumeInfo)

Простая сущность класса данных:

Абстрактное определение репозитария книг

Сценарии использования “Get books”

Модуль домена

Чтобы получить список книг, я использовал API Google книги. Это API возвращает список книг, отфильтрованных по параметру строки запроса:

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

Простой объект (entity) класса данных:

data class Volume(val id: String, val volumeInfo: VolumeInfo)

Абстрактное определение репозитария книг:

Сценарии использования “Get books”:

Слой данных

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

Поскольку мы определили источник данных для управления постоянством (persistence), на этом уровне нам также необходимо определить базу данных (можно использовать Room) и ее объекты. Кроме того, рекомендуется создать несколько модулей (mappers) для сопоставления ответа API с соответствующим объектом базы данных. Помните, нам нужно, чтобы доменный слой был независим от слоя данных, поэтому мы не можем напрямую аннотировать объект доменного тома ( Volume ) с помощью аннотации @Entity room . Нам определенно нужен еще один класс BookEntity , и мы определим маппер (mapper) между Volume и BookEntity .

Читайте также:  Как заблокировать контакт android

Слой презентации или приложения

В этом слое нам нужен фрагмент для отображения списка книг. Мы можем сохранить наш любимый подход MVVM. Модель представления принимает сценарии использования в своих конструкторах и вызывает соответствующий сценарий использования соответственно действиям пользователя (get books, bookmark, unbookmark).

Каждый сценариё использования вызывает соответствующий метод в репозитории:

Этот фрагмент только наблюдает за изменениями в модели представления и обнаруживает действия пользователя в пользовательском интерфейсе:

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

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

Источник

Еще раз про многомодульность Android-приложений

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

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

Стоит также помнить, что многомодульность не панацея и несет в себе отрицательные последствия. Среди них, например, увеличенное время конфигурации проекта. Для этого даже пытаются поменять систему сборки Gradle на что-то более производительное на стадии конфигурации, типа Buck или Bazel. Подробнее об этой проблеме и способах ее решения можно узнать у коллег, в чьем проекте больше 300 модулей.

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

Когда начали выделять первые модули, у нас использовалась только Java, а значит, не было полезного модификатора доступа internal . Каждый модуль делился на два: api + impl . Это позволяло более явно обозначить контракт, доступный внешним пользователям. Об этой первоначальной концепции более подробно написано в статье Евгения Мацюка.

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

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

Основные причины для использования многомодульности

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

Во-первых, скорость сборки. Мобильные приложения перестают быть тонким клиентом, решающим одну конкретную проблему пользователя. Вместо этого наблюдается тенденция к превращению в супераппы. Если при этом оставить весь код в одном модуле, а все зависимости в одном AppComponent , время сборки (включая работу KAPT) будет улетать в космос. Также не будем забывать, что многомодульность — не единственный выход, и стоит периодически обновлять версии Gradle и Android Gradle Plugin, включая различные оптимизационные конфиги, а также обновлять железо.

Во-вторых, архитектура. Большинство разработчиков так или иначе научились разбивать приложение на слои. Но такие архитектуры не помогают обособлению отдельных фич и сохранению их контракта. Что может как обернуться неожиданными побочными эффектами в работе приложения и багами, так и помешать возможному выделению фичи для шаринга кода между проектами. И с развитием Kotlin Multiplatform это могут быть совсем неожиданные для вас проекты. Также хотелось бы более явно управлять временем жизни фичей, которые не нужны на всем протяжении работы приложения.

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

Отдельный модуль (или их набор, как в нашем случае) — лишь один из вариантов решения. Можно также выделить общую фичу в библиотеку и выложить ее во внутренний Maven-репозиторий. Но тогда ее придется поддерживать отдельно от кода основного приложения, что влечет дополнительные трудозатраты.

Читайте также:  Расплачиваться картой с android

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

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

Контракты и структура модулей

Все модули приложения можно условно разделить на четыре категории:

App-модуль — остатки монолита, который связывает в себе все модули и который имеет зависимости на Feature-модули.

Feature-модуль — модуль, содержащий конкретную фичу, изолированную от остальных в соответствии с бизнес-логикой. В общем случае он включает в себя все слои вашей архитектуры, но может быть и вырожденным, без какой-то части слоев (например, когда это чисто UI-фича либо, наоборот, фича без UI). Feature-модуль может иметь зависимости только на API других Feature-модулей либо на Core-модули.
Зависимость одного Feature-модуля от API другого может быть достаточно спорной, когда API и имплементация фичи объединены в одном модуле. Разработчики могут забывать использовать internal для всех классов, не входящих в API, и они могут «утечь» в другие Feature-модули. С другой стороны, частое изменение имплементации этой фичи будет вести к пересборке всех зависимых модулей. Для решения этих проблем можно подумать о разделении API и Impl фичи, от которой зависят другие, в два модуля.
Пример такого разделения будет ниже.

Core-модуль — модуль, содержащий вспомогательный код, необходимый для нескольких Feature-модулей. Это может быть логгер или полезные обертки над используемыми библиотеками, или иные утилиты. Core-модули ни от кого не зависят. Но есть исключение: module-injector.

Module-injector — основное отличие от структуры, предложенной в статье Жени. В нем хранятся три базовых интерфейса, которые наследуют интерфейсы всех других модулей. Поэтому все они зависят от него.

Второе отличие — склейка API и Impl в Feature-модулях, если они не используются другими Feature-модулями и нет проблемы регулярно утекающих деталей имплементации. Это стало возможным благодаря internal -видимости в Kotlin.

Также бывает полезно иметь в приложении отдельные Example-модули для фич, которые по сути являются App-модулями. Они также предоставляют в себе зависимости (обычно моки) для конкретной фичи и позволяют разрабатывать ее изолированно, без необходимости пересобирать всё приложение. Это сильно экономит время и нервы при разработке.

Схематично эту структуру можно изобразить так:

Module-Injector

Начнем с модуля, о котором знают все другие. Как было упомянуто ранее, нам важно управлять жизненным циклом компонентов. То есть нужно уметь их создавать, предоставлять им зависимости и освобождать, когда они больше не нужны. При это совсем не обязательно, что этот компонент писали люди из вашей команды, которые так же сильно любят Dagger (или подставьте сюда любой другой DI-фреймворк). На основе этих соображений появился набор из трех интерфейсов, показанных ниже:

Данный модуль не содержит ничего больше. В каждом Feature-модуле нужно имплементировать эти интерфейсы. При этом все остальное содержимое (кроме классов, используемых в интерфейсе) можно пометить модификатором internal, так как про них остальные модули знать не должны.

Для иллюстрации работы этого приема я не стал придумывать что-то новое, а просто взял пример приложения из статьи Жени, форкнул, перевел на Kotlin (как же без этого в 2020-м) и применил module-injector . Теперь оно выглядит так. В этом же репозитории по коммитам можно восстановить весь путь трансформации.

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

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

Здесь можно увидеть пример фичи покупок, про API которой должны знать два других Feature-модуля. Поэтому фича разбита на два Feature-модуля: :feature_purchase_api и :feature_purchase_impl . API-модуль не знает ни про какие другие модули, кроме :module-injector . Фичи сканера и антивора знают только про API-модуль.

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

Теперь предлагаю посмотреть на имплементацию одного из ComponentHolder -ов:

Этот объект состоит из четырех частей:

  • Нулабельная переменная для хранения компонента.
  • Функция init() , которая принимает на вход интерфейс с зависимостями этого модуля и инициализирует переменную с компонентом.
  • Функция get() , которую остальные модули могут использовать после инициализации для доступа к реализации API модуля.
  • Функция reset() , которая зануляет компонент, когда он больше не нужен.

В остальных модулях этот объект будет примерно таким же. Эта структура позволяет хранить ссылку на компонент в единственном месте, и при ее обнулении сборщик мусора сможет удалить весь компонент из памяти.

Читайте также:  Samsung a510 frp android 7 загрузчик 8

Код классов PurchaseFeatureApi и PurchaseFeatureDependencies достаточно простой, и его можно посмотреть в репозитории.

Внутри модуля ComponentHolder может включать в себя непосредственно Dagger-компонент:

Здесь нам важна только функция initAndGet() , которую использует ComponentHolder . Еще раз отмечу, что Dagger тут только для примера. Вместо него может быть любой DI-фреймворк или вообще ручное предоставление зависимостей для несложных модулей.

Наконец, модули склеиваются внутри app следующим образом:

Здесь первая функция provideScannerFeatureDependencies() используется для заполнения интерфейса ScannerFeatureDependencies , который применяется внутри второй функции provideFeatureScanner() для инициализации ComponentHolder -а фичи.

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

Осталось рассмотреть более детально, как обнуляются компоненты.

Как было сказано выше, в ComponentHolder каждого модуля есть функция reset() , которая зануляет ссылку на компонент внутри него. Если время жизни модуля должно быть связано с UI, можно вызвать reset() внутри соответствующей функции условного Lifecycle Observer -а (который может быть также презентером или непосредственно Activity, как в нашем примере):

Казалось бы, все хорошо, и компонент можно удалить сборщиком мусора. Но ссылка на компонент утекает из модуля через функцию get() .

Поэтому важно, чтобы DI-фреймворк в app-модуле каждый раз пытался проинициализировать и получить ссылку на компонент вместо ее кеширования (например, при использовании Singleton в Dagger). Функция init() проверяет, был ли инициализирован компонент, поэтому до вызова reset() будет возвращаться одна и та же ссылка.

Второе важное условие для корректного освобождения ссылки на API-компонент — использование Provider в месте инжекта:

Склейка UI

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

Для старта Activity можно вынести в API интерфейс такого типа:

С его помощью можно передать контекст текущего Activity внутрь модуля, который сам сформирует и запустит необходимый Intent:

В случае с фрагментами можно либо передать внутрь модуля FragmentManager и уже внутри создать и запустить нужный фрагмент, либо вынести в API функцию, возвращающую готовый фрагмент. Такой подход позволит использовать любой инструмент для навигации: будь то Cicerone, Navigation Component либо ручная работа с FragmentManager.

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

Лайфхаки по работе с модулями

Core-ui

Мы повторно используем некоторые общие UI-компоненты между приложениями, а также общий набор стилей и тем, соответствующих UI-гайдлайнам компании. Для этого задействуем внутреннюю библиотеку UIKit. В проекте эта тема может кастомизироваться, а затем применяется на уровне Application.

Мы заметили, что с выделением модулей стали дублироваться файлы theme.xml , в которых кастомизируется та самая корневая тема (например, для Example-модулей, чтобы выглядеть, как основное приложение). Поэтому решили выделить core-ui модуль с темами, стилями, цветами, которые относятся ко всему проекту. Он подключает UIKit как транзитивную зависимость ( api вместо implementation ) и подключается ко всем Feature-модулям, содержащим UI. Сам по себе этот модуль вообще не содержит кода.

Core-strings

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

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

Core-native

Для шаринга общего кода между платформами в компании мы используем нативные C++-библиотеки. Для работы с ними нужны JNI-обертки, которые вызываются из Java-кода. Те уже собираются и линкуются вместе с нативными библиотеками, приходящими в составе антивирусного SDK. Весь этот код достаточно редко изменяется, но при это занимает значительное время при сборке, особенно если не ограничить локально ABI . Поэтому сейчас мы планируем вынести эти компоненты и тонкие обертки над ними в отдельный модуль, который будет редко пересобираться и позволит ускорить время сборки.

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

Хочу также поблагодарить за помощь в создании статьи Евгения Мацюка, Мансура Бирюкова и Павла Сидякина.

Источник

Оцените статью