Перевод статьи подготовлен в преддверии старта курса «Android Developer. Professional».




Официальное руководство по архитектуре приложений Android рекомендует использовать классы репозитории (Repository) для «предоставления чистого API, чтобы остальная часть приложения могла легко извлекать данные». Однако, на мой взгляд, если вы будете использовать в своем проекте этот паттерн, вы гарантированно увязнете в грязном спагетти-коде.

В этой статье я расскажу вам о «паттерне Репозиторий» и объясню, почему он на самом деле является антипаттерном для Android приложений.

Репозиторий


В вышеупомянутом руководстве по архитектуре приложений рекомендуется следующая структура для организации логики уровня представления:



Роль объекта репозитория в этой структуре такова:

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

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

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

Репозиторий в Android Architecture Blueprints v2


Около двух лет назад я рецензировал «первую версию» Android Architecture Blueprints. По идее они должны были реализовывать чистый пример MVP, но на практике эти блюпринты вылились в достаточно грязную кодовую базу. Они действительно содержали интерфейсы с именами View и Presenter, но не устанавливали никаких архитектурных границ, так что это по сути был не MVP. Вы можете посмотреть данный код ревью здесь.

С тех пор Google обновил архитектурные блюпринты с использованием Kotlin, ViewModel и других «современных» практик, включая репозитории. Эти обновленные блюпринты получили приставку v2.

Давайте же посмотрим на интерфейс TasksRepository из блюпринтов v2:

interface TasksRepository {
   fun observeTasks(): LiveData<Result<List<Task>>>
   suspend fun getTasks(forceUpdate: Boolean = false): Result<List<Task>>
   suspend fun refreshTasks()
   fun observeTask(taskId: String): LiveData<Result<Task>>
   suspend fun getTask(taskId: String, forceUpdate: Boolean = false): Result<Task>
   suspend fun refreshTask(taskId: String)
   suspend fun saveTask(task: Task)
   suspend fun completeTask(task: Task)
   suspend fun completeTask(taskId: String)
   suspend fun activateTask(task: Task)
   suspend fun activateTask(taskId: String)
   suspend fun clearCompletedTasks()
   suspend fun deleteAllTasks()
   suspend fun deleteTask(taskId: String)
}

Даже до начала чтения кода можно обратить внимание на размер этого интерфейса — это уже тревожный звоночек. Такое количество методов в одном интерфейсе вызвало бы вопросы даже в больших Android проектах, но мы говорим о приложении ToDo, в котором всего 2000 строк кода. Почему этому достаточно тривиальному приложению нужен класс с такой огромной поверхностью API?

Репозиторий как Божественный объект (God Object)


Ответ на вопрос из предыдущего раздела кроется в именах методов TasksRepository. Я могу примерно разделить методы этого интерфейса на три непересекающихся группы.

Группа 1:

fun observeTasks(): LiveData<Result<List<Task>>>
   fun observeTask(taskId: String): LiveData<Result<Task>>

Группа 2:

   suspend fun getTasks(forceUpdate: Boolean = false): Result<List<Task>>
   suspend fun refreshTasks()
   suspend fun getTask(taskId: String, forceUpdate: Boolean = false): Result<Task>
   suspend fun refreshTask(taskId: String)
   suspend fun saveTask(task: Task)
   suspend fun deleteAllTasks()
   suspend fun deleteTask(taskId: String)

Группа 3:

  suspend fun completeTask(task: Task)
   suspend fun completeTask(taskId: String)
   suspend fun clearCompletedTasks()
   suspend fun activateTask(task: Task)
   suspend fun activateTask(taskId: String)

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

Группа 1 — это в основном реализация паттерна Observer с использованием средства LiveData. Группа 2 представляет собой шлюз к хранилищу данных плюс два метода refresh, которые необходимы, поскольку за репозиторием скрывается удаленное хранилище данных. Группа 3 содержит функциональные методы, которые в основном реализуют две части логики домена приложения (завершение задач и активация).

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

У нас есть специальный термин для классов, которые объединяют так много обязанностей: Божественные объекты. Это широко распространенный антипаттерн в приложениях на Android. Activitie и Fragment являются стандартными подозреваемыми в этом контексте, но другие классы тоже могут вырождаться в Божественные объекты. Особенно, если их имена заканчиваются на “Manager”, верно?

Погодите… Мне кажется, я нашел более подходящее название для TasksRepository:

interface TasksManager {
   fun observeTasks(): LiveData<Result<List<Task>>>
   suspend fun getTasks(forceUpdate: Boolean = false): Result<List<Task>>
   suspend fun refreshTasks()
   fun observeTask(taskId: String): LiveData<Result<Task>>
   suspend fun getTask(taskId: String, forceUpdate: Boolean = false): Result<Task>
   suspend fun refreshTask(taskId: String)
   suspend fun saveTask(task: Task)
   suspend fun completeTask(task: Task)
   suspend fun completeTask(taskId: String)
   suspend fun activateTask(task: Task)
   suspend fun activateTask(taskId: String)
   suspend fun clearCompletedTasks()
   suspend fun deleteAllTasks()
   suspend fun deleteTask(taskId: String)
}

Теперь имя этого интерфейса намного лучше отражает его обязанности!

Анемичные репозитории


Здесь вы можете спросить: «Если я вынесу доменную логику из репозитория, решит ли это проблему?». Что ж, вернемся к «архитектурной диаграмме» из руководства Google.

Если вы захотите извлечь, скажем, методы completeTask из TasksRepository, куда бы вы их поместили? Согласно рекомендованной Google «архитектуре», вам нужно будет перенести эту логику в одну из ваших ViewModel. Это не кажется таким уж плохим решением, но как раз таким оно на самом деле и является.

Например, представьте, что вы помещаете эту логику в одну ViewModel. Затем, через месяц, ваш менеджер по работе с клиентами хочет разрешить пользователям выполнять задачи с нескольких экранов (это релевантно по отношению ко всем ToDo менеджерам, которые я когда-либо использовал). Логика внутри ViewModel не может использоваться повторно, поэтому вам нужно либо продублировать ее, либо вернуть в TasksRepository. Очевидно, что оба подхода плохи.

Лучшим подходом было бы извлечь этот доменный поток в специальный объект, а затем поместить его между ViewModel и репозиторием. Затем разные ViewModel смогут повторно использовать этот объект для выполнения этого конкретного потока. Эти объекты известны как «варианты использования» или «взаимодействия». Однако, если вы добавите варианты использования в свою кодовую базу, репозитории станут по сути бесполезным шаблоном. Что бы они ни делали, это будет лучше сочетаться с вариантами использования. Габор Варади уже освещал эту тему в этой статье, поэтому я не буду вдаваться в подробности. Я подписываюсь почти под всем, что он сказал о «анемичных репозиториях».

Но почему варианты использования намного лучше репозиториев? Ответ прост: варианты использования инкапсулируют отдельные потоки. Следовательно, вместо одного репозитория (для каждой концепции домена), который постепенно разрастается в Божественный объект, у вас будет несколько узконаправленных классов вариантов использования. Если поток зависит от сети, и от хранимых данных, вы можете передать соответствующие абстракции в класс варианта использования, и он будет «проводить арбитраж» между этими источниками.

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

Репозитории вне Android.


Теперь вы можете задаться вопросом, являются ли репозитории изобретением Google. Нет, не являются. Шаблон репозитория был описан задолго до того, как Google решил использовать его в своем «руководстве по архитектуре».

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

Эрик Эванс в своей книге Domain Driven Design также описывал репозитории. Он написал:

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

Обратите внимание, что вы можете заменить «репозиторий» в приведенной выше цитате на «Room ORM», и это все равно будет иметь смысл. Итак, в контексте Domain Driven Design репозиторий — это ORM (реализованный вручную или с использованием стороннего фреймворка).

Как видите, репозиторий не был изобретен в мире Android. Это очень разумный паттерн проектирования, на котором построены все ORM фреймворки. Однако обратите внимание, чем репозитории не являются: никто из «классиков» никогда не утверждал, что репозитории должны пытаться абстрагироваться от различия между доступом к сети и к базе данных.

На самом деле, я почти уверен, что они сочтут эту идею наивной и обреченной на провал. Чтобы понять, почему, вы можете прочитать другую статью, на этот раз Джоэла Спольски (основателя StackOverflow), под названием «Закон дырявых абстракций». Проще говоря: работа в сети слишком отличается от доступа к базе данных, чтобы ее можно было абстрагировать без значительных «утечек».

Как репозиторий стал антипаттерном в Android


Итак, неужели в Google неверно истолковали паттерн репозитория и внедрили в него «наивную» идею абстрагироваться от доступа к сети? Я в этом сомневаюсь.

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

Заключение


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

Например, в другом «блюпринте» Google, на этот раз для архитектурных компонентов, использование репозиториев в конечном итоге привело к таким жемчужинам, как NetworkBoundResource. Имейте в виду, что образец браузера GitHub по-прежнему является крошечным ~2 KLOC приложением.

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

Спасибо за прочтение и, как обычно, вы можете оставлять свои комментарии и вопросы ниже.