Обзор основных архитектурных паттернов проектирования мобильных приложений

Привет! Я - Илья Воскобойников, Android-разработчик департамента автоматизации бизнеса ГК "Технологии Надежности". Сегодня я расскажу про основные архитектурные паттерны проектирования мобильных приложений. На живых примерах разберемся, когда нужно использовать тот или иной подход, какие у каждого из них есть плюсы и минусы. Погнали!

Шаблоны проектирования — это типичные решения часто встречающихся проблем при проектировании программного обеспечения. Они похожи на готовые чертежи, которые вы можете настроить для решения повторяющейся проблемы проектирования в вашем коде.

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

– цель шаблона кратко описывает как проблему, так и решение;

– мотивация дополнительно объясняет проблему и решение;

– структура классов показывает каждую часть шаблона и то, как они связаны.

Далее будем рассматривать пример реализации паттернов на прототипе приложения с авторизацией, полный листинг программ: https://gitlab.com/500a5/example-architectures-mvc-mvp-mvvm

1. The Model—View—Controller (MVC) Pattern

Цель шаблона:

Разделить приложение на три компонента (Model, View, Controller) (рисунок 1), для упрощения разработки и поддержки.

Мотивация:

– упрощение разработки: позволяет командам работать над различными компонентами независимо;

– поддерживаемость: четкая структура облегчает обновления и исправления;

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

Структура:

Рисунок 1. Схема взаимодействия слоев в MVС
Рисунок 1. Схема взаимодействия слоев в MVС

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

View — то, что мы видим. Пользовательский интерфейс может быть реализован с использованием HTML/CSS/XML, UIKit, Compose, SwiftUI. Интерфейс общается с контроллером и иногда взаимодействует с моделью. Он передаёт некоторые динамические представления через контроллер.

Controller — Уровень, который содержит основную логику. Он взаимодействует с представлением и моделью. Controller принимает пользовательский ввод из служб просмотра/REST. Обрабатывает запрос, получает данные из модели и передаёт в представление.

Преимущества:

– разделяет бизнес-логику в модели;

– поддерживает асинхронные методы;

– модификация не затрагивает всю модель;

– более быстрый процесс разработки.

Недостатки:

– из-за большого количества кода в контроллере он может быть захламлен;

– мешает модульному тестированию.

Пример реализации:

В качестве View выступает activity_mvc.xml.

<?xml version="1.0" encoding="utf-8"?> <layout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools"> <data> </data> <androidx.constraintlayout.widget.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".mvc.controller.MvcActivity"> <EditText android:id="@+id/textEmail" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:layout_marginTop="16dp" android:layout_marginEnd="16dp" android:inputType="textEmailAddress" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <EditText android:id="@+id/textPassword" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="16dp" android:layout_marginTop="16dp" android:layout_marginEnd="16dp" android:inputType="textPassword" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textEmail" /> <Button android:id="@+id/btnLogin" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginStart="32dp" android:layout_marginTop="16dp" android:layout_marginEnd="32dp" android:text="Login" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/textPassword" /> </androidx.constraintlayout.widget.ConstraintLayout> </layout>

Model, которая ничего не знает о view, представляет интерфейс AuthRepository.kt и его реализацию AuthRepositoryImpl.kt.

interface AuthRepository { suspend fun login(email: String, password: String): Deferred<String> }

В качестве контроллера выступает MvcActivity.kt. Этот класс установит связь между представлением и моделью. Данные, предоставленные Моделью, будут использоваться View, и в действие будут внесены соответствующие изменения.

class MvcActivity : AppCompatActivity() { private lateinit var binding: ActivityMvcBinding private val authRepository = AuthRepositoryImpl() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMvcBinding.inflate(layoutInflater) setContentView(binding.root) binding.btnLogin.setOnClickListener { if (!validateEmail()) return@setOnClickListener if (!validatePassword()) return@setOnClickListener login() } } private fun login() { CoroutineScope(Dispatchers.IO).async { val errorMessage = authRepository.login( email = binding.textEmail.text.toString(), password = binding.textPassword.text.toString() ).await() if (errorMessage.isEmpty()) launch(Dispatchers.Main) { Toast.makeText(this@MvcActivity, "Success login", Toast.LENGTH_LONG).show() } else launch(Dispatchers.Main) { Toast.makeText(this@MvcActivity, errorMessage, Toast.LENGTH_LONG).show() } } } //toy example private fun validateEmail(): Boolean { val email = binding.textEmail.text.toString() return (email.contains("@") && email.contains(".")) } //toy example private fun validatePassword(): Boolean { val password = binding.textPassword.text.toString() return password.length > 6 } }

Скриншоты иерархии примера на рисунке 2 и рисунке 3:

Рисунок 2. Иерархия MVC<br />
Рисунок 2. Иерархия MVC
Рисунок 3. Иерархия MVC
Рисунок 3. Иерархия MVC

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

2. The Model—View—Presenter (MVP) Pattern

Цель шаблона:

Изолировать логику представления от интерфейса для упрощения тестирования и поддержки.

Мотивация:

– лучшая тестируемость: presenter можно тестировать без необходимости взаимодействия с UI.

– чистота кода: логика представления отделена от кода интерфейса, что упрощает изменения.

– упрощение сложных сценариев: presenter может управлять сложной логикой взаимодействия пользователя.

Структура:

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

Model — Это бизнес-логика и состояние данных. Получая и обрабатывая данные, общается с presenter, взаимодействует с базой данных. Model не взаимодействует с view.

View — Состоит из пользовательского интерфейса, активности и фрагмента. View взаимодействует с presenter.

Presenter — Представляет данные из модели. Управляет всем поведением, которое хочет отобразить из приложения. Presenter сообщает view, что делать. Любое взаимодействие между моделью и представлением обрабатывается presenter.

В схеме (рисунок 4) MVP View и Presenter тесно связаны и ссылаются друг на друга.

Рисунок 4. Схема взаимодействия слоёв в MVP
Рисунок 4. Схема взаимодействия слоёв в MVP

Преимущества:

– делает view абстрактной, так что бы ее можно было легко поменять;

– переиспользование View и Presenter;

– код более читабельный и удобный в сопровождении;

– простое тестирование, так как бизнес-логика отделена от пользовательского интерфейса.

Недостатки:

– тесная связь между View и Presenter;

– огромное количество интерфейсов для взаимодействия между слоями;

– размер кода довольно избыточен.

Пример реализации:

Для установления связи между View-Presenter и Presenter-Model необходим интерфейс. Этот класс интерфейса будет содержать все абстрактные методы, которые будут определены позже в классе View, Model и Presenter. LoginContract.kt

interface LoginPresenter { fun login(email: String, password: String) } interface LoginView { fun showSuccess() fun showError(errorMessage: String) }

Presenter - методы этого класса содержат основную бизнес-логику, которая будет решать, что отображать и как отображать. Он запускает класс View для внесения необходимых изменений в пользовательский интерфейс. LoginPresenterImpl.kt

class LoginPresenterImpl : LoginPresenter { private val authRepository = AuthRepositoryImpl() private var viewState: WeakReference<LoginView>? = null fun attachView(view: LoginView) { viewState = WeakReference(view) } override fun login(email: String, password: String) { if (!validateEmail(email)) return if (!validatePassword(password)) return CoroutineScope(Dispatchers.IO).async { val errorMessage = authRepository.login(email, password).await() if (errorMessage.isEmpty()) launch(Dispatchers.Main) { viewState?.get()?.showSuccess() } else launch(Dispatchers.Main) { viewState?.get()?.showError(errorMessage) } } } //toy example private fun validateEmail(email: String): Boolean { return (email.contains("@") && email.contains(".")) } //toy example private fun validatePassword(password: String): Boolean { return password.length > 6 } }

Класс View отвечает за обновление пользовательского интерфейса в соответствии с изменениями, вызванными уровнем Presenter. Данные, предоставленные моделью, будут использоваться View, и в действие будут внесены соответствующие изменения. MvpActivity.kt

class MvpActivity : AppCompatActivity(), LoginView { private lateinit var binding: ActivityMvpBinding private val loginPresenter = LoginPresenterImpl() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMvpBinding.inflate(layoutInflater) setContentView(binding.root) loginPresenter.attachView(this) binding.btnLogin.setOnClickListener { loginPresenter.login(binding.textEmail.text.toString(), binding.textPassword.text.toString()) } } override fun showSuccess() { Toast.makeText(this@MvpActivity, "Success login", Toast.LENGTH_LONG).show() } override fun showError(errorMessage: String) { Toast.makeText(this@MvpActivity, errorMessage, Toast.LENGTH_LONG).show() } }

В качестве Model выступают AuthRepository.kt, AuthRepositoryIml.kt

interface AuthRepository { suspend fun login(email: String, password: String): Deferred<String> }​

Скриншоты иерархии примера на рисунке 5:

Рисунок 5. Иерархия MVP
Рисунок 5. Иерархия MVP

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

3. The Model—View—ViewModel (MVVM) Pattern

Цель шаблона:

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

Мотивация:

– двусторонняя привязка: Изменения в ViewModel автоматически отражаются в View, что упрощает управление состоянием.

– тестируемость: ViewModel можно тестировать независимо от UI.

– удобство разработки: Использование библиотек (например, LiveData) упрощает создание интерактивных интерфейсов.

Структура:

Этот шаблон основан на MVC и MVP, который пытается более четко отделить разработку пользовательского интерфейса от разработки бизнес-логики и поведения в приложении (рисунок 6).

Рисунок 6. Схема взаимодействия слоев кода в MVVM
Рисунок 6. Схема взаимодействия слоев кода в MVVM

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

Model — Содержит бизнес-логику, локальный и удаленный источник данных и репозиторий. Репозиторий: взаимодействует с локальными или удаленными источниками данных в соответствии с запросом от ViewModel.

View — Взаимодействует только с пользователем, без бизнес-логики. Цель этого слоя — информировать ViewModel о действиях пользователя. View наблюдает за ViewModel и не содержит никакой логики приложения.

ViewModel — Большая часть логики пользовательского интерфейса сосредоточена здесь. Это мост между представлением и бизнес-логикой. У него нет никакой привязки, какое представление должно его использовать. Поскольку viewModel не имеет прямой ссылки на представление, она хорошо тестируется и имеет слабую связанность. Обновление пользовательского интерфейса реализуется через компоненты‐наблюдатели, когда данные изменяются viewModel сигнализирует об этом своим подписчикам.

Шаблоны MVVM и MVP очень похожи, поскольку оба эффективно абстрагируются от состояния и поведения уровня представления. В MVVM представления могут привязываться к потокам данных, предоставляемым ViewModel.

В схеме MVVM View информирует ViewModel о различных действиях. Представление имеет ссылку на ViewModel, в то время как ViewModel не имеет информации о представлении. Связь «многие к одному», которая существует между View и ViewModel и MVVM, поддерживает двустороннюю привязку данных между ними.

Преимущества:

– Разделение логики приложения и представления

– Гибкость и переиспользование

– Связывание данных

– Упрощение тестирования

Недостатки:

– Увеличение объема кода

Пример реализации:

В качестве Model выступает AuthRepository.kt и AuthRepositoryImpl.kt

interface AuthRepository { suspend fun login(email: String, password: String): Deferred<String> }

ViewModel содержит все методы, которые необходимо вызывать в макете приложения. Он преобразует данные в потоки и уведомляет View. LoginViewModel.kt

sealed class LoginState { data object DefaultState : LoginState() data object SendingLoginState : LoginState() data object LoginSucceededLoginState : LoginState() class ErrorLoginState(val message: String) : LoginState() } class LoginViewModel : ViewModel() { private var authRepository = AuthRepositoryImpl() val state = MutableLiveData<LoginState>().apply { value = LoginState.DefaultState } fun login(email: String, password: String) { if (!validateEmail(email)) return if (!validatePassword(password)) return CoroutineScope(Dispatchers.IO).async { val errorMessage = authRepository.login(email, password).await() if (errorMessage.isEmpty()) launch(Dispatchers.Main) { state.apply { value = LoginState.LoginSucceededLoginState } } else launch(Dispatchers.Main) { state.apply { value = LoginState.ErrorLoginState(errorMessage) } } } } //toy example private fun validateEmail(email: String): Boolean { return (email.contains("@") && email.contains(".")) } //toy example private fun validatePassword(password: String): Boolean { return password.length > 6 } }

В качестве view выступает MvvmActivity.kt.

class MvvmActivity : AppCompatActivity() { private lateinit var binding: ActivityMvpBinding private val viewModel by viewModels<LoginViewModel>() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMvpBinding.inflate(layoutInflater) setContentView(binding.root) viewModel.state.observe( this@MvvmActivity ) { state -> when (state) { is LoginState.LoginSucceededLoginState -> { Toast.makeText(this, "Success login", Toast.LENGTH_LONG).show() } is LoginState.SendingLoginState -> { enableUi(false) } is LoginState.ErrorLoginState -> { Toast.makeText(this, state.message, Toast.LENGTH_LONG).show() enableUi(true) } is LoginState.DefaultState -> { enableUi(true) } } } binding.btnLogin.setOnClickListener { viewModel.login(binding.textEmail.text.toString(), binding.textPassword.text.toString()) } } private fun enableUi(value: Boolean) { binding.textEmail.isEnabled = value binding.textPassword.isEnabled = value binding.btnLogin.isEnabled = value } }

Скриншоты иерархии примера на рисунке 7:

Рисунок 7. Иерархия MVVM
Рисунок 7. Иерархия MVVM

В настоящее время архитектурный паттерн MVVM (Model-View-ViewModel) является одним из самых популярных выборов для разработки мобильных приложений. Это связано с тем, что MVVM предоставляет отличную модульную тестируемость и поддерживает модульный принцип, что позволяет разработчикам создавать более гибкие и масштабируемые приложения. Кроме того, компания Google активно рекомендует использовать паттерн MVVM для разработки приложений под Android, предоставляя различные библиотеки и инструменты для его реализации, такие как Architecture Components и Jetpack. Этот патттен чаще других можно встретить в проде.

Вывод

Данный полученные исходя из анализа всех рассматриваемых паттернов архитектур приложения приведены в таблице 1.

Таблица 1. Сравнение архитектурных паттернов
Таблица 1. Сравнение архитектурных паттернов

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

MVC является классическим паттерном, который подходит для небольших проектов и прототипов, так как позволяет быстро начать разработку благодаря своей простой структуре. Однако, по мере роста приложения, код контроллера может стать захламлённым, что затруднит поддержку и тестирование. MVP, в свою очередь, предлагает лучшее разделение логики, что упрощает тестирование и делает код более управляемым. Тем не менее, он требует больше усилий для создания дополнительных интерфейсов, что может усложнить архитектуру. MVVM, рекомендованный Google для разработки Android-приложений, предлагает наилучшие возможности для двусторонней привязки данных, модульного тестирования и разделения логики. Этот паттерн идеально подходит для крупных и сложных приложений, где требуется высокая гибкость и поддерживаемость.

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

66
33
2 комментария

Не понял описание 1-го паттерна.
Если "Model — это бизнес-логика", то "Controller... содержит основную логику" чего, какой? Вообще всей? Тогда как бизнес-логика оказалась в Model, если тут вся "основная"?
Если мы вводим классификацию чего-то (логики), то необходимо в каждом случае следовать своей же классификации

Ответить

В MVC Model отвечает за бизнес-логику, которая связана с обработкой данных. Это операции с базой данных, работа с API или другая логика, связанная с манипуляцией данными.
Controller, управляет взаимодействием между Model и View. То есть, Controller обрабатывает запросы от пользователя (например, нажатие кнопки), запрашивает нужные данные у Model и передает их обратно во View для отображения. Основная логика Controller — это именно логика взаимодействия, но не бизнес-логика данных.

Ответить