Архитектура и дизайн Android приложения (мой опыт)

в 15:18, , рубрики: android architecture components, android development, clean architecture, Разработка под android

Сегодня я хочу рассказать об архитектуре, которой я следую в своих Android приложениях. За основу я беру Clean Architecture, а в качестве инструментов использую Android Architecture Components (ViewModel, LiveData, LiveEvent) + Kotlin Coroutines. К статье прилагается код вымышленного примера, который доступен на GitHub.

Disclaimer

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

Проблема: зачем нам нужна архитектура?

Большинство проектов, в которых мне доводилось участвовать, имеют одну и ту же проблему: внутрь андроид окружения помещается логика приложения, что приводит к большому объему кода внутри Fragment и Activity. Таким образом код обрастает зависимостями, которые совсем не нужны, модульное тестирование становится практически невозможным, так же, как и повторное использование. Фрагменты со временем становятся God-объектами, даже мелкие изменения приводят к ошибкам, поддерживать проект становится дорого и эмоционально затратно.

Есть проекты, которые вообще не имеют никакой архитектуры (тут все понятно, к ним вопросов нет), есть проекты с претензией на архитектуру, но там все равно появляются точно такие же проблемы. Сейчас модно использовать Clean Architecture в Android. Часто видел, что Clean Architecture ограничивается созданием репозиториев и сценариев, которые вызывают эти репозитории и больше ничего не делают. Того хуже: такие сценарии возвращают модели из вызываемых репозиториев. И в такой архитектуре смысла нет вообще. И т.к. сценарии просто вызывают нужные репозитории, то часто логика ложится на ViewModel или, еще хуже, оседает во фрагментах и активностях. Все это потом превращается в кашу, не поддающуюся автоматическому тестированию.

Цель архитектуры и дизайна

Цель архитектуры – отделить нашу бизнес-логику от деталей. Под деталями я понимаю, например, внешние API (когда мы разрабатываем клиент для REST сервиса), Android – окружение (UI, сервисы) и т.д. В основе я использую Clean architecture, но со своими допущениями в реализации.

Цель дизайна – связать вместе UI, API, Бизнес-логику, модели так, чтобы все это поддавалось автоматическому тестированию, было слабо связанным, легко расширялось. В дизайне я использую Android Architecture Components.

Для меня архитектура должна удовлетворять следующим критериям:

  1. UI — максимально простой, и у него есть только три функции:
  2. Представлять данные пользователю. Данные приходят уже готовые для отображения. Это основная функция UI. Тут виджеты, анимации, фрагменты и т.д.
  3. Реагировать на события. Здесь отлично помогает ViewModel и LiveData.
  4. Отправлять команды от пользователя. Для этого я использую свой простой framework, основанный на командах. Об этом чуть позже.
  5. Бизнес-логика зависит только от абстракций. Это нам позволяет менять реализацию произвольных компонентов.

Решение

Принципиальная схема архитектуры представлена на рисунке ниже:

image

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

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

Слой логики приложения содержит сценарии работы самого приложения. Именно здесь определяются все связи приложения, выстраивается его суть.

Слой api, android – это лишь конкретная реализация нашего приложения в Android – среде. В идеале этот слой можно менять на что угодно.

Причем, когда я приступаю к разработке приложения, я начинаю с самого нижнего слоя — домена. Потом появляется второй слой сценариев. На 2-ом слое все зависимости от внешних деталей реализуются через интерфейсы. Вы абстрагированы от деталей, можно сконцентрироваться только на логике приложения. Тут же уже можно начинать писать тесты. Это не TDD подход, но близко к этому. И только в самом конце появляется сам Android, API с реальными данными и т.д.

Теперь более развернутая схема дизайна Android-приложения.

image

Итак, слой логики является ключевым, он и есть приложение. Только слой логики может ссылаться на домен и взаимодействовать с ним. Также слой логики содержит интерфейсы, которые позволяют логике взаимодействовать с деталями приложения (api, android и т.д.). Это так называемый принцип инверсии зависимости, который позволяет логике не зависеть от деталей, а наоборот. Слой логики содержит в себе сценарии использования приложения (Use Cases), которые оперируют разными данными, взаимодействуют с доменом, репозиториями и т.д. В разработке мне нравится мыслить сценариями. На каждое действие пользователя или событие от системы запускается некоторый сценарий, который имеет входные и выходные параметры, а также всего лишь один метод – запустить сценарий.

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

Общая схема работы приложения следующая:

image

  1. Создается android-окружение (активити, фрагменты, и т.д.).
  2. Создается ViewModel (одна или несколько).
  3. ViewModel создает необходимые сценарии, которые можно запустить из этой ViewModel. Сценарии лучше инжектить с помощью DI.
  4. Пользователь совершает действие.
  5. Каждый компонент UI связан с командой, которую он может запустить.
  6. Запускается сценарий с необходимыми параметрами, например, Login.execute(login,password).
  7. Сценарий также с помощью DI получает нужные репозитории, провайдеры. Сценарий делает запрос на получение необходимых данных (может быть несколько асинхронных запросов api, да что угодно). Репозиторий выполняет запросы и возвращает данные сценарию. Причем у репозитория есть свои модели данных, которые он использует для своей внутренней работы, например, репозиторий для REST будет содержать модели со всякими JSON конверторами. Но перед тем, как отдать результат в сценарий, репозиторий всегда преобразовывает данные в модели данных сценария. Таким образом, логика ничего не знает о внутреннем устройстве репозитория и не зависит от него. Получив все необходимые данные, сценарий может создать необходимые объекты из домена. Выполнить какую-то логику на домене. Когда сценарий закончит работу, он обязательно преобразует своей ответ в очередную модель представления. Сценарий прячет уровень домена, он отдает данные, которые сразу понятны слою представления. Сценарий использования также может содержать в себе служебные сценарии, например, обработка ошибок.
  8. Сценарий вернул данные или ошибку в команду. Теперь можно обновить состояние ViewModel, которая в свою очередь обновит UI. Я обычно это делаю с помощью LiveData (п.9 и 10).

Т.е. ключевую роль у нас занимает логика и ее модели данных. Мы увидели двойное преобразование: первое – это преобразование репозитория в модель данных сценария и второе – преобразование, когда сценарий отдает данные в окружение, как результат своей работы. Обычно результат работы сценария отдается во viewModel для отображения в UI. Сценарий должен отдать такие данные, с которыми viewModel и UI ничего больше не делает.

Команды

UI запускает выполнение сценария с помощью команды. В моих проектах я использую собственную реализацию команд, они не являются частью архитектурных компонент или еще чего-либо. В общем, их реализация несложная, в качестве более глубокого знакомства с идеей можете посмотреть реализацию команд в reactiveui.net для C#. Я, к сожалению, не могу выложить свой рабочий код, только упрощенную реализацию для примера.

Основная задача команды — это запускать некоторый сценарий, передав в него входные параметры, а после выполнения вернуть результат команды (данные или сообщение об ошибке). Обычно все команды выполняются асинхронно. Причем команда инкапсулирует метод background-вычислений. Я использую корутины, но их легко заменить на RX, и сделать это придется всего лишь в абстрактной связке command+use case. В качестве бонуса команда может сообщать свое состояние: выполняется ли она сейчас или нет и может ли она выполниться в принципе. Команды легко решают некоторые проблемы, например, проблему двойного вызова (когда пользователь кликнул несколько раз на кнопку, пока операция выполняется) или проблемы видимости и отмены выполнения.

Пример

Реализовать фичу: вход в приложение с помощью логина и пароля.
Окно должно содержать поля ввода логина и пароля и кнопку “Вход”. Логика работы следующая:

  1. Кнопка “Вход” должна быть неактивной, если логин и пароль содержат менее 4 символов.
  2. Кнопка “Вход” должна быть неактивной во время выполнения процедуры входа.
  3. Во время выполнения процедуры входа должен отображаться индикатор (лоадер).
  4. Если вход выполнен успешно, то должно отобразиться приветственное сообщение.
  5. Если логин и/или пароль неверные, то должна появиться надпись об ошибке над полем ввода логина.
  6. Если надпись об ошибке отображена на экране, то любой ввод символа в полях логин или пароль, убирают эту надпись до следующей попытки.

Эту задачу можно решить разными способами, например, поместить все в MainActivity.
Но я всегда слежу за выполнением моих двух главных правил:

  1. Бизнес-логика не зависит от деталей.
  2. UI максимально простой. Он занимается только своей задачей (представляет данные, которые ему переданы, а также транслирует команды от пользователя).

Так выглядит приложение:

image

MainActivity выглядит следующим образом:

class MainActivity : AppCompatActivity() {

   private val vm: MainViewModel by viewModel()

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)

       bindLoginView()
       bindProgressBar()

       observeAuthorization()
       observeRefreshView()
   }

   private fun bindProgressBar() {
       progressBar.bindVisibleWithCommandIsExecuting(this, vm.loginCommand)
   }

   private fun bindLoginView() {
       loginEdit.bindAfterTextChangedWithCommand(vm.loginValidityCommand)
       passwordEdit.bindAfterTextChangedWithCommand(vm.passwordValidityCommand)

       loginButton.bindCommand(this, vm.loginCommand) {
           LoginParameters(loginEdit.text.toString(), passwordEdit.text.toString())
       }
   }

   private fun observeAuthorization() {
       vm.authorizationSuccessLive.observe(this, Observer {
           showAuthorizeSuccessMsg(it?.data)
       })
       vm.authorizationErrorLive.observe(this, Observer {
           showAuthorizeErrorMsg()
       })
   }

   private fun observeRefreshView() {
       vm.refreshLoginViewLive.observe(this, Observer {
           hideAuthorizeErrorMsg()
       })
   }

   private fun showAuthorizeErrorMsg() {
       loginErrorMsg.isInvisible = false
   }

   private fun hideAuthorizeErrorMsg() {
       loginErrorMsg.isInvisible = true
   }

   private fun showAuthorizeSuccessMsg(name : String?) {
       val msg = getString( R.string.success_login, name)
       Toast.makeText(this, msg, Toast.LENGTH_LONG).show()
   }
}

Активити достаточно прост, правило UI выполняется. Я написал несколько простых расширений, типа bindVisibleWithCommandIsExecuting, чтобы связывать команды с элементами UI и не дублировать код.

Код этого примера с комментариями доступен на GitHub, если интересно, можете скачать и ознакомиться.

На этом все, спасибо за внимание!

Автор: Алексей Рожков

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js