Вот сценарий, который узнает большинство Android-разработчиков… Перед вами стоит задача добавить простую функцию в приложение, но для этого необходимо внести изменения в другую область, а затем еще и еще, пока след от вашего изменения не станет настолько громоздким, что его невозможно будет протестировать.
Вероятно, вы также работали с приложениями, в которых внести изменения халтурным способом значительно проще, чем понять, как сделать что-то правильно. Или приложения, в которых изменение в одной части приложения приводит к появлению сотен совершенно не связанных между собой ошибок.
Все это — признаки плохой архитектуры.
Поэтому эта статья, основанная на моем выступлении «Не боритесь с архитектурой», посвящена тому, как правильно спроектировать архитектуру вашего приложения.
Правильный способ сделать это
Когда вы хорошо спроектируете свое приложение, вы обнаружите, что оно безопасно, надежно, тестируемо и поддерживаемо. Вы сможете откладывать принятие решений, например, о том, какой бэкенд использовать, и с относительной легкостью менять такие решения в дальнейшем. И, что самое важное для нас, разработчиков, есть четкий «правильный способ» делать вещи, который правильно изолирует части, требующие изоляции. Это означает, что даже самые младшие разработчики могут быть полезны в команде.
Существует множество советов о «правильном» способе проектирования программного обеспечения. Многие из них противоречат друг другу. Поэтому в этой статье я расскажу вам о принципах архитектуры, чтобы вы могли сами решить, что подходит для вашего приложения. Итак, эта статья о принципах, а не о правилах.
Чтобы стать хорошим архитектором, изучайте принципы, а не правила. Так вы сможете адаптировать архитектуру к тому, что подходит для вашего программного обеспечения и команды.
Правила SOLID
Правила SOLID лежат в основе многих архитектурных фреймворков, поэтому их необходимо понимать в полной мере. Я не собираюсь слишком углубляться в них, потому что другие уже хорошо справились с этой задачей. Однако вкратце опишу их:
S = Separation of Responsibility
Это принцип, согласно которому класс или модуль должен иметь только одну причину для изменения. Или, эквивалентно, он должен быть ответственен только перед одним действующим лицом. По сути, это означает: разделите вещи, которые будут развиваться отдельно.
O = Open-Closed
Ваш код должен позволять вам добавлять новые возможности путем добавления кода, а не путем изменения существующего кода.
L = Liskov Substitution
Этот принцип гласит, что вы должны иметь возможность использовать любой производный класс вместо базового. Самое главное, вы не должны пытаться изменить смысл базового класса в своем производном классе.
I = Interface Segregation
Не следует заставлять клиентов использовать интерфейс, который им не подходит. Нет ничего плохого в том, чтобы иметь множество маленьких интерфейсов с одним-двумя методами вместо одного большого.
D = Dependency Inversion
Классы высокого уровня не должны зависеть от классов низкого уровня. Вместо этого они оба должны зависеть от абстракции.
Правильное применение принципа инверсии зависимостей приводит к правильному формированию архитектурных границ. Давайте более подробно рассмотрим, как это делается.
Архитектурные границы и инверсия зависимостей
Допустим, у нас есть приложение, которое позволяет людям создавать и сохранять свои профили. Для этого мы используем Firebase. Вот наивная реализация этого:
Здесь класс User вызывает метод в FirebaseProfileSaver, который сохраняет профиль с помощью Firebase. Класс User считается высокоуровневым, потому что он содержит бизнес-логику (т.е. это чистая логика, а не конкретика того, как данные считываются и записываются в систему). И напротив, FirebaseProfileSaver — это низкоуровневый класс, так называемый, потому что он содержит специфику реализации, т.е. код, написанный для конкретной технологии.
Такая компоновка нарушает принцип инверсии зависимостей, поскольку что-то высокоуровневое зависит от чего-то низкоуровневого. Когда я говорю «зависит от», я имею в виду в строгом смысле исходного кода: в классе User есть строка, в которой говорится import x.y.FirebaseProfileSaver или что-то подобное. Возможно, зависимость находится на пару уровней дальше — скажем, User импортирует X, который импортирует Y, который импортирует FirebaseProfileSaver — но суть в том, что вы можете нарисовать набор стрелок в направлении зависимостей, которые в конечном итоге указывают от User к FirebaseProfileSaver.
Почему это является проблемой? Ну, одна из проблем заключается в том, что изменения Firebase не изолированы. Если однажды Firebase SDK изменится, то, очевидно, FirebaseProfileSaver должен будет измениться; но ничто не мешает этому произойти таким образом, чтобы повлиять на User и все остальное, зависящее от него. Тестирование изменений будет означать тестирование всего.
И это тоже не очень гибко. Если мы захотим перейти от Firebase к какому-то другому провайдеру удаленных хранилищ, нам, возможно, придется переписать большую часть приложения.
Инверсия зависимостей: решение «вилка-розетка»
Решение заключается в том, чтобы FirebaseProfileSaver был своего рода «вилкой», а класс User — своего рода «розеткой». Класс User не должен ничего знать о FirebaseProfileSaver; но ему разрешено знать о сохранении профилей в абстрактном виде. Независимо от того, какую «вилку» мы вставим в «розетку» пользователя (это может быть FirebaseProfileSaver или RoomDatabaseProfileSaver или MyProprietaryAPIProfileSaver), «розетка» знает, как с ней разговаривать, потому что с ее точки зрения все они работают одинаково.
Поэтому FirebaseProfileSaver рефакторят в вилку, которая подходит к розетке.
Это выглядит следующим образом:
Здесь класс User знает, как разговаривать с «ProfileSaver» только в абстрактном виде. Важно отметить, что он не содержит никаких упоминаний о чем-либо, связанном с Firebase.
Затем, FirebaseProfileSaver реализует интерфейс ProfileSaver. Класс User не знает об этом и, что очень важно, не основывает свою логику на том, как работает Firebase.
Это изолирует логику Firebase. Мы можем провести красную линию, как на диаграмме выше, между низкоуровневым кодом и высокоуровневым кодом. Эта красная линия является архитектурной границей.
Обратите внимание, что стрелка зависимости теперь направлена вверх от низкого уровня к высокому. Больше нет последовательности стрелок зависимостей, которые можно было бы проследить, начиная с класса User и заканчивая Firebase.
Где должны проходить архитектурные границы?
Итак, очевидно, что правильное размещение архитектурных границ имеет большое значение для хорошей архитектуры. Из вышесказанного может показаться, что чем больше границ, тем лучше, но это не так.
Архитектурные границы требуют больших затрат на обслуживание. Они порождают больше кода, и после установления границы каждый будущий разработчик обязан ее соблюдать.
А код с границами гораздо менее читабелен. Из вышеприведенного не очевидно, что вызов profileSaver.saveProfile() класса User на самом деле вызывает логику Firebase. Таким образом, привлечение новых разработчиков становится немного сложнее, а обзоры кода — немного труднее.
Одна из попыток рационального определения архитектурных границ — Чистая Архитектура.
Как насчет чистой архитектуры для Android-приложений?
Набор принципов, собранных архитектором-ветераном Робертом К. Мартином, Чистая Архитектура отчасти предлагает разбиение программного обеспечения на определенный набор слоев, разделенных архитектурными границами.
Его знаменитая диаграмма выглядит следующим образом:
На этой многослойной диаграмме высокоуровневый код (то есть чистая логика) находится в центре, а низкоуровневый код — снаружи. Все управляется правилом зависимости (по сути, результат принципа инверсии зависимостей SOLID), который гласит, что низкоуровневый код может зависеть от кода более высокого уровня, но никогда наоборот. Поэтому стрелки, изображенные на рисунке выше, которые представляют зависимости, всегда направлены внутрь.
Из чего же состоят эти слои?
Примеры использования и объекты (желтый и красный круги)
Прямо в центре диаграммы Чистой Архитектуры находятся слои Use Cases и Entities. Они содержат бизнес-логику вашего приложения. Это только логика, определяющая поведение приложения, не имеющая ничего общего со спецификой реализации.
Это различие может запутать, поэтому приведем пример.
Пример, который сохраняет профиль пользователя, делает следующее:
- Выполняет некоторые проверки безопасности/согласованности. Убеждается, что сохраняемый профиль содержит достоверные данные и что пользователю будет разрешено выполнить эту операцию.
- Сохранить данные удаленно
- Кэширует новый профиль локально
- Сообщает UI о необходимости обновления
Можно сказать, что все это бизнес-логика, потому что речь идет о том, что мы делаем, а не о том, как мы это делаем. На шаге 2, например, мы не говорим, какой удаленный API мы используем для сохранения данных, а на шаге 4 нам все равно, какой пользовательский интерфейс нужно обновить — экран на Android-телефоне, веб-страницу или PDF.
Use Case представляет собой одно требование от одного актора (см. выше принцип единой ответственности SOLID). Это также полный список шагов — больше ничего не нужно делать, чтобы сохранить профиль, и нет смысла пытаться выполнить только часть этих шагов.
Интерфейсные адаптеры (зеленый круг)
Здесь рассматриваются конкретные примеры использования. Например, когда в сценарии использования требуется локально кэшировать некоторые данные, именно здесь мы можем говорить о базах данных SQL. Мы по-прежнему не говорим о конкретной модели базы данных — все, что является точной реализацией технологии, появится позже. Если существует несколько источников данных, то слой интерфейсных адаптеров должен сопоставлять их и управлять расхождениями.
Здесь же должна находиться почти вся топология MVVM, MVC, MVP и т.д. И снова никаких проприетарных технологий — поэтому мы не говорим здесь о Jetpack Compose или Android XML — но мы храним состояние, которое эти части будут использовать.
Фреймворки и драйверы (синий круг)
Сюда попадает все, что дает точную реализацию. Это детали.
Здесь находятся @Composable s вашего Jetpack Compose. Сюда попадает ваш HTML. И код Firebase, и специфика любого API, и команды SQL, и все, что помечено аннотацией Room (например, @Entity)…
Код в этом слое трудно тестировать, потому что он обычно полагается на проприетарные технологии. Тестирование Jetpack Compose, например, опирается на инструменты, написанные специально для Jetpack Compose (или, возможно, на инструменты, написанные для Android в целом, но смысл остается тем же). Поэтому делайте этот слой как можно тоньше. Логика должна быть в более высоких слоях. Это лишь минимум, необходимый для «перевода» требований интерфейсных адаптеров в конкретную технологию, которую вы используете.
Этот слой также непостоянен. Он может меняться и ломаться без вашего участия. Например, если API, который вы используете, вдруг потребует другого типа аутентификации, вам придется изменить свой код в соответствии с этим, независимо от того, есть ли у вам время на это или вы довольны изменениями. Опять же, если этот слой сделать как можно тоньше, это уменьшит влияние таких изменений на остальную часть кодовой базы.
Где в Чистой Архитектуре находится код, специфичный для Android?
Официально, согласно книге, Android является проприетарной технологией, поэтому ее использование должно быть ограничено слоем фреймворков и драйверов (синий). Ничто с «import android.x.y» или «import androidx.x.y» не должно выходить за пределы этого слоя.
На практике этого бывает очень трудно добиться.
Один из примеров — запрос разрешений, который иногда удобнее (и читабельнее) упоминать в модели представления, то есть в области адаптеров интерфейсов.
Это прекрасный пример того, почему я хотел, чтобы эта статья была о принципах, а не о правилах. Если вы перегибаете палку, чтобы соответствовать правилу, то подумайте о принципах, стоящих за ним — они могут быть уместны или неуместны в вашем случае.
В данном примере я лично считаю нормальным решение разрешить упоминание Android в интерфейсных адаптерах. В конце концов, вы создаете приложение для Android, и если только нет разумной вероятности того, что однажды вы будете использовать ту же самую базу данных, скажем, с iOS или веб-приложением, нет никакого смысла извращать свой код, чтобы не упоминать Android. Очевидно, что iOS и веб-приложения обычно имеют свои собственные, отдельные кодовые базы.
Что делает приложение хорошим и как его проектировать?
Приложение должно делать что-то одно и делать это хорошо. Его цель не сильно меняется со временем, и хотя в течение жизни оно может обзавестись множеством новых функций, его целевая аудитория почти никогда не меняется (у него один и тот же актор в соответствии с принципом единой ответственности). На самом деле, если заинтересованная сторона начинает требовать, чтобы ваше приложение было ориентировано на дополнительный тип пользователей, вам часто лучше создать новое приложение для них, чтобы оно могло правильно сфокусироваться на их потребностях. У Microsoft нет единого приложения Office; вместо этого у нее есть отдельные приложения Word, Excel и Powerpoint, каждое из которых используется разными людьми с разными требованиями.
Поэтому вы вполне можете сказать, что Чистая Архитектура — многие принципы которой призваны защитить вас от подобных изменений, которые вряд ли произойдут в приложении для Android — просто слишком тяжела для наших целей. Во многих случаях я с вами согласен.
Google, похоже, тоже согласен. Его собственные рекомендации по архитектуре — которые она называет Modern App Architecture — представляют собой несколько более мягкую версию Clean Architecture.
Архитектура современных приложений Google
Google упростил свою архитектуру до трех уровней.
В целом, уровень пользовательского интерфейса предназначен для обработки ввода и вывода данных от пользователей и обновления отображения. Доменный слой предназначен для вашей бизнес-логики — почти точно эквивалентной примерам использования Clean Architecture. А слой данных предназначен для чтения и записи данных в механизмы хранения приложения.
Это однонаправленная архитектура. Состояние течет только вверх, события — только вниз.
Давайте рассмотрим, что все это значит, более подробно.
Уровень пользовательского интерфейса: UI элементы и владельцы состояний
Слой пользовательского интерфейса делится на элементы пользовательского интерфейса и владельцев состояний.
Часть элементов пользовательского интерфейса содержит исключительно код, написанный для проприетарных технологий. Если вы используете Jetpack Compose, то разместите свои @Composables здесь. Если вы используете Фрагменты и XML, то это то место, куда они помещаются. Но больше ничего. Никакой логики и никаких данных.
(Правило «никакой логики» иногда вызывает трудности у тех, кто пользуется привязкой данных к XML. Например, дата биндинг позволит вам реализовать переключение между градусами Цельсия и Фаренгейта полностью в XML-коде. Не стоит.)
Логика и данные, напротив, помещаются во владельцев состояний. Они так называются, потому что хранят состояние пользовательского интерфейса. Вспомните контроллеры представления. Они содержат переменные, которые поддерживают элементы управления пользовательского интерфейса — так, если, скажем, в вашем пользовательском интерфейсе есть текстовое поле, то переменная, содержащая содержимое этого текстового поля, помещается сюда.
Отличная рекомендация — раскрывать такие переменные состояния как Kotlin Flows. Это аккуратно инкапсулирует их динамическую природу и предоставляет встроенный механизм для сигнализации пользовательскому интерфейсу о необходимости обновления.
Доменный уровень: примеры использования
Доменный слой содержит сценарии использования, которые точно такие же, как сценарии использования в Чистой Архитектуре. То есть, это полный список шагов, необходимых для выполнения одной задачи одним субъектом.
Но в архитектуре Google этот слой является необязательным. Это означает, что нет ничего плохого в том, чтобы разместить чистую бизнес-логику во владельцах состояний (скажем, в моделях представления).
Если бизнес-логика используется повторно в нескольких владельцах состояний, может быть полезно вытащить эту логику в слой домена, чтобы предотвратить дублирование кода. Например, несколько частей приложения позволяют обновлять профиль пользователя. В этом случае вы можете создать UpdateUserProfileUseCase и ссылаться на него везде, где это необходимо.
Уровень данных: хранилища и источники данных
Уровень данных разделяется на хранилища и источники данных.
Репозиторий отвечает за предоставление данных и их сохранение. Он будет содержать, скажем, методы getUserProfile() и saveUserProfile(…).
Источник данных выполняет собственную работу, например, вызывая API или выполняя команды SQL.
Часто хранилище отвечает за несколько источников данных. Например, у вас могут быть данные, хранящиеся в удаленном хранилище, и локальный кэш тех же данных. Каждый из них может быть реализован как отдельный источник данных. Тогда при чтении профиля пользователя хранилище может попытаться считать данные из локального кэша и вернуться к удаленной базе данных, если кэш пуст. Таким образом, хранилище, отвечающее за несколько источников данных, должно само решать, какой из них использовать и как их синхронизировать.
И снова хорошей практикой является предоставление данных вызывающим сторонам с помощью потоков Kotlin Flows.
Сравнение архитектуры Современной Архитектуры Приложений от Google с Чистой Архитектурой
Вы могли заметить, что в Modern App Architecture и Clean Architecture слово «слой» используется для обозначения совершенно разных вещей. Вот как они сочетаются друг с другом:
Слой пользовательского интерфейса Google, как и слой данных, соответствует двум внешним кольцам Чистой Архитектуры (адаптеры интерфейсов, фреймворки и драйверы). Его слой домена полностью эквивалентен примерам использования и сущностям в Чистой Архитектуре.
Некоторые из этих границ несколько более размыты, чем показано на диаграмме выше. Например, Google не имеет ничего против размещения бизнес-логики в слое пользовательского интерфейса, поэтому его собственный слой домена обозначен как необязательный.
Слои пользовательского интерфейса и данных эквивалентны слоям интерфейсных адаптеров и фреймворков/драйверов Чистой Архитектуры.
В заключение
Это было глубокое погружение в принципы, лежащие в основе хорошей архитектуры. Мы использовали для вдохновения две распространенные парадигмы — Чистую Архитектуру и Современная архитектура приложений Google.
Конечно, вы сами должны решить, что лучше всего подходит для вашего приложения. Я надеюсь, что, предоставив соображения, а не жесткие рамки, я дал вам набор инструментов для принятия собственных решений.
Мне нравится отвечать на конкретные вопросы об архитектуре, поэтому не стесняйтесь оставлять вопросы, и я отвечу, когда смогу. Самое интересное, когда нет единственного «правильного» ответа, и мы можем подискутировать.
В одной из следующих статей я использую все вышесказанное, чтобы шаг за шагом рассказать вам о создании хорошо структурированного примера приложения на Kotlin и Compose.