Как не утонуть в хаосе: архитектурные паттерны в Kotlin
Вы когда-нибудь открывали свой проект через месяц после написания и не могли понять, где находится логика авторизации? Или пытались добавить новую кнопку, а она ломала работу списка? Знакомо? Добро пожаловать в мир приложений без архитектуры.
Архитектурные паттерны в Kotlin — это не дань моде и не абстрактные теории. Это спасение от «лапшичного кода», когда всё перемешано: бизнес-логика, UI, работа с сетью и базой данных. В этой статье мы разберём четыре основных паттерна: MVC, MVP, MVVM и MVI. Вы увидите их в деле на реальных примерах Kotlin, поймёте ключевые отличия и сможете выбрать правильный инструмент для своей задачи. Поехали!
Что такое архитектурный паттерн и зачем он нужен?
Архитектурный паттерн — это скелет вашего приложения. Он диктует, как классы общаются друг с другом, где хранить данные и кто отвечает за отображение. Без него рано или поздно вы получите God Object — класс, который делает всё. Или Callback Hell — когда одни колбэки порождают другие, и код становится нечитаемым.
Kotlin, с его лаконичным синтаксисом и нулевой безопасностью, идеально подходит для чистой архитектуры. Но язык — это только половина дела. Вторая половина — правильный выбор паттерна.
MVC (Model-View-Controller) — классика жанра
Самый старый и простой паттерн. Его суть:
- Model — данные и бизнес-логика.
- View — отображение (Activity, Fragment, Compose).
- Controller — связующее звено, обрабатывает ввод пользователя.
В Kotlin (особенно для Android) MVC часто превращается в «Толстый Controller». Activity становится и View, и Controller одновременно. Пример:
// Плохо: Activity — это и View, и Controller
class LoginActivity : AppCompatActivity() {
private lateinit var emailEditText: EditText
private lateinit var passwordEditText: EditText
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
// Controller логика прямо здесь
findViewById<Button>(R.id.loginButton).setOnClickListener {
val email = emailEditText.text.toString()
val password = passwordEditText.text.toString()
// Валидация и вызов API — всё в одном месте
if (email.isNotEmpty() && password.isNotEmpty()) {
loginUser(email, password)
}
}
}
private fun loginUser(email: String, password: String) {
// Работа с сетью — прямо в Activity
CoroutineScope(Dispatchers.IO).launch {
val result = api.login(email, password)
withContext(Dispatchers.Main) {
// Обновление UI — тоже тут
Toast.makeText(this@LoginActivity, result.message, Toast.LENGTH_SHORT).show()
}
}
}
}
Плюсы: простота, низкий порог входа.
Минусы: View и Controller жёстко связаны. Тестировать логику без эмулятора Android невозможно. Для больших проектов — ад.
MVP (Model-View-Presenter) — разделение властей
MVP решает главную проблему MVC: View отделяется от логики через интерфейсы. Presenter — это «мозг», который ничего не знает об Android. View — пассивная прослойка.
Пример на Kotlin:
// Интерфейсы для связи
interface LoginView {
fun showLoading()
fun hideLoading()
fun showError(message: String)
fun navigateToHome()
}
class LoginPresenter(private val view: LoginView, private val authRepository: AuthRepository) {
fun login(email: String, password: String) {
view.showLoading()
CoroutineScope(Dispatchers.IO).launch {
try {
val user = authRepository.login(email, password)
withContext(Dispatchers.Main) {
view.hideLoading()
if (user != null) {
view.navigateToHome()
} else {
view.showError("Неверный email или пароль")
}
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
view.hideLoading()
view.showError(e.message ?: "Ошибка сети")
}
}
}
}
}
// Activity реализует интерфейс View
class LoginActivity : AppCompatActivity(), LoginView {
private lateinit var presenter: LoginPresenter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
presenter = LoginPresenter(this, AuthRepository())
findViewById<Button>(R.id.loginButton).setOnClickListener {
presenter.login("test@test.com", "password")
}
}
override fun showLoading() { /* показать прогресс */ }
override fun hideLoading() { /* скрыть */ }
override fun showError(message: String) { Toast.makeText(this, message, Toast.LENGTH_SHORT).show() }
override fun navigateToHome() { /* переход */ }
}
Плюсы: чистый Presenter легко тестируется (нет зависимости от Android). Чёткое разделение.
Минусы: много бойлерплейта (интерфейсы, методы). При утечке контекста (поворот экрана) Presenter может ссылаться на мёртвую View.
Архитектурные паттерны в Kotlin для Android часто выбирают MVP, если хотят строгий контроль. Но есть нюанс — жизненный цикл.
MVVM (Model-View-ViewModel) — реактивность и Jetpack
Современный стандарт для Android-приложений. MVVM использует LiveData или StateFlow для реактивной связи. ViewModel переживает повороты экрана, а View подписывается на изменения.
Пример с StateFlow:
// ViewModel — чистая логика, без ссылок на View
class LoginViewModel(private val authRepository: AuthRepository) : ViewModel() {
private val _uiState = MutableStateFlow<LoginUiState>(LoginUiState.Idle)
val uiState: StateFlow<LoginUiState> = _uiState.asStateFlow()
fun login(email: String, password: String) {
_uiState.value = LoginUiState.Loading
viewModelScope.launch {
try {
val user = authRepository.login(email, password)
_uiState.value = if (user != null) {
LoginUiState.Success
} else {
LoginUiState.Error("Неверные данные")
}
} catch (e: Exception) {
_uiState.value = LoginUiState.Error(e.message ?: "Ошибка")
}
}
}
}
// Состояние экрана — sealed class
sealed class LoginUiState {
object Idle : LoginUiState()
object Loading : LoginUiState()
object Success : LoginUiState()
data class Error(val message: String) : LoginUiState()
}
// Activity подписывается на StateFlow
class LoginActivity : AppCompatActivity() {
private val viewModel: LoginViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
findViewById<Button>(R.id.loginButton).setOnClickListener {
viewModel.login("test@test.com", "password")
}
// Реактивное обновление UI
lifecycleScope.launch {
viewModel.uiState.collect { state ->
when (state) {
is LoginUiState.Loading -> showLoading()
is LoginUiState.Success -> navigateToHome()
is LoginUiState.Error -> showError(state.message)
else -> {}
}
}
}
}
}