Архитектура Android-приложений: Паттерны и Best Practices

Курс посвящен изучению современных подходов к проектированию масштабируемых и тестируемых мобильных приложений на Android. Вы освоите принципы Clean Architecture, популярные паттерны презентации (MVVM, MVI) и инструменты для управления зависимостями.

1. Введение в архитектуру: жизненный цикл, проблемы монолита и паттерны MVC, MVP

Введение в архитектуру: жизненный цикл, проблемы монолита и паттерны MVC, MVP

Добро пожаловать в курс «Архитектура Android-приложений». Если вы когда-либо открывали проект, видели класс MainActivity на 3000 строк кода и испытывали желание немедленно закрыть ноутбук, то вы попали по адресу.

В этой первой статье мы разберем фундамент: почему в Android вообще нужна архитектура, как жизненный цикл компонентов усложняет нам жизнь, и рассмотрим первые попытки сообщества структурировать код с помощью паттернов MVC и MVP.

Почему архитектура важна?

Когда вы пишете небольшое приложение «Hello World» или простой калькулятор, архитектура кажется излишеством. Вы пишете логику прямо в методе onCreate, обращаетесь к базе данных по нажатию кнопки и обновляете UI там же. Это работает.

Но по мере роста приложения возникают проблемы:

  • Сложность поддержки. Добавление новой фичи ломает две старые.
  • Тестируемость. Как написать Unit-тест для логики, которая намертво приклеена к кнопке Android?
  • Масштабируемость. Два разработчика не могут работать над одним экраном, не создавая конфликтов слияния (merge conflicts).
  • Архитектура — это не просто красивые диаграммы. Это способ организовать код так, чтобы стоимость его поддержки не росла экспоненциально со временем.

    Проклятие жизненного цикла Android

    Главное отличие разработки под Android от, скажем, бэкенда или десктопа — это Жизненный цикл (Lifecycle). В Android вы не управляете точкой входа в приложение (main()). Система сама решает, когда создать, остановить или уничтожить ваш экран.

    Проблема поворота экрана

    Представьте сценарий: пользователь заполняет форму регистрации и поворачивает экран. Что происходит?

  • Система уничтожает текущую Activity.
  • Система создает новую Activity с новой конфигурацией (ландшафтная ориентация).
  • Все локальные переменные в старой Activity теряются.
  • !При повороте устройства Activity пересоздается, теряя свое состояние, если оно не было сохранено специально.

    Если ваша бизнес-логика (например, сетевой запрос) находится прямо внутри Activity, то при повороте экрана запрос либо прервется, либо, что хуже, вернет результат в уже уничтоженную Activity, вызвав утечку памяти или краш (Crash).

    God Object и спагетти-код

    Если не использовать архитектурные паттерны, мы получаем так называемый Monolith или God Activity.

    Это класс, который знает и умеет слишком много: * Он управляет UI (отображает кнопки, списки). * Он ходит в сеть (Retrofit/OkHttp). * Он пишет в базу данных (Room/SQLite). * Он содержит сложную бизнес-логику (валидация, расчеты).

    Пример такого подхода:

    Такой код невозможно тестировать (как вы сымитируете клик кнопки в Unit-тесте?) и очень сложно читать. Чтобы решить эту проблему, разработчики начали применять паттерны разделения ответственности.

    MVC: Model-View-Controller

    Один из старейших паттернов, пришедший из мира веба и десктопа.

    Классическая теория

    * Model (Модель): Данные и бизнес-логика (База данных, API, сущности). * View (Представление): Отображение данных пользователю (UI). * Controller (Контроллер): Принимает ввод от пользователя и обновляет Модель.

    MVC в Android

    В ранней Android-разработке попытка применить MVC выглядела так: * Model: Ваши Java/Kotlin классы данных. * View: XML-файлы разметки. * Controller: Activity или Fragment.

    Однако, это разделение оказалось фикцией. Activity в Android — это не чистый контроллер. Она слишком сильно связана с UI. Она знает, как найти кнопку (findViewById), как установить текст, как обработать анимацию. В итоге Activity становилась и Контроллером, и Представлением одновременно.

    !В Android-реализации MVC Activity часто берет на себя слишком много, управляя и данными, и отрисовкой.

    Проблемы MVC в Android:

  • Activity все еще остается Божественным объектом.
  • Логику сложно тестировать, так как она «зашита» в Android-компоненты.
  • MVP: Model-View-Presenter

    Чтобы решить проблему «толстой» Activity и сделать код тестируемым, появился паттерн MVP. Это была первая настоящая революция в архитектуре Android-приложений.

    Главная идея: Полностью отделить логику от Android-фреймворка.

    Компоненты MVP

  • Model (Модель): Поставщик данных. Она ничего не знает ни о View, ни о Presenter.
  • View (Представление): Пассивный интерфейс. Она не содержит логики. Она умеет только две вещи: отображать то, что скажет Presenter, и сообщать Presenter'у о действиях пользователя (кликах).
  • Presenter (Представитель): «Мозг» экрана. Он получает события от View, обращается к Model за данными, обрабатывает их и командует View, что показать.
  • Контракт (Contract)

    Ключевая особенность MVP — использование интерфейсов (Контракта) для связи между View и Presenter. Это разрывает жесткую зависимость.

    Пример контракта:

    Реализация Presenter

    Обратите внимание: в коде Presenter нет ни одного импорта из пакета android.*. Это чистый Kotlin/Java код.

    Реализация View (Activity)

    Activity теперь становится «глупой». Она просто реализует интерфейс.

    !Presenter выступает посредником, изолируя View от Model и бизнес-логики.

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

  • Тестируемость. Поскольку Presenter не зависит от android.content.Context или android.view.View, мы можем легко написать Unit-тесты, подменив View моком (Mock).
  • Разделение ответственности. View занимается отрисовкой, Presenter — логикой.
  • Недостатки MVP

  • Много кода (Boilerplate). Для каждого экрана нужно создавать интерфейс View, интерфейс Presenter и класс Presenter.
  • Жизненный цикл. Presenter должен знать, когда View уничтожается, чтобы отписаться от нее (view = null), иначе будет утечка памяти или краш при попытке вызвать метод у уничтоженной Activity.
  • Presenter может стать огромным. Если экран сложный, Presenter со временем тоже превращается в «God Object», хоть и без Android-зависимостей.
  • Заключение

    MVP был золотым стандартом Android-разработки долгое время. Он научил разработчиков разделять UI и логику. Однако необходимость вручную управлять жизненным циклом и обилие шаблонного кода привели к поиску новых решений.

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

    2. Стандарт индустрии: реализация паттерна MVVM с использованием Android Architecture Components

    Стандарт индустрии: реализация паттерна MVVM с использованием Android Architecture Components

    В предыдущей статье мы разобрали, как паттерн MVP помог нам разделить UI и бизнес-логику, разорвав порочный круг «Божественной Activity». Однако мы также столкнулись с его недостатками: необходимостью вручную управлять жизненным циклом, писать много шаблонного кода (интерфейсы Contract) и риском утечек памяти, если забыть обнулить ссылку на View.

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

    Эволюция: от MVP к MVVM

    Если в MVP Presenter был «командиром», который говорил View, что делать (императивный стиль), то в MVVM ViewModel — это «хранитель состояния», за которым View наблюдает (реактивный стиль).

    Основные компоненты MVVM

  • Model (Модель): Как и в MVP, это слой данных (Repository, Database, API). Она ничего не знает о UI.
  • View (Представление): Activity, Fragment или Custom View. Она подписывается на изменения данных в ViewModel и реагирует на них. View никогда не содержит логики.
  • ViewModel (Модель Представления): Класс, который хранит данные для UI и переживает смену конфигурации. Она не знает, кто на нее подписан.
  • !В MVVM поток данных направлен от Model к View через ViewModel, при этом ViewModel ничего не знает о существовании View.

    Главное отличие от MVP: ViewModel не имеет ссылки на View. Это автоматически решает проблему утечек памяти и NullPointerException, когда Activity уничтожается.

    Android Architecture Components: Инструменты Google

    До 2017 года реализация MVVM в Android была сложной задачей, требующей сторонних библиотек. Google выпустила набор библиотек Architecture Components, который дал нам два мощных инструмента:

  • ViewModel (класс фреймворка)
  • LiveData (хранилище данных)
  • Магия класса ViewModel

    Вы помните проблему поворота экрана? Когда пользователь поворачивает устройство, Activity уничтожается и создается заново. Все переменные теряются.

    Класс androidx.lifecycle.ViewModel спроектирован так, чтобы жить дольше, чем Activity или Fragment.

    Жизненный цикл ViewModel:

  • Создается при первом запуске Activity.
  • Не уничтожается при повороте экрана.
  • Уничтожается только тогда, когда Activity окончательно закрывается (метод finish()).
  • !ViewModel переживает пересоздание Activity при смене конфигурации, сохраняя данные.

    LiveData: Реактивный клей

    LiveData — это контейнер для данных, который умеет оповещать подписчиков об изменениях. Но его главная фишка — он Lifecycle-Aware (знает о жизненном цикле).

    Это означает, что LiveData: * Оповещает View только тогда, когда View находится в активном состоянии (на экране). * Автоматически удаляет подписку, когда View уничтожается. Больше никаких view = null в onDestroy!

    Реализация MVVM на практике

    Давайте перепишем пример с экраном входа из прошлой статьи, используя MVVM.

    Шаг 1: Создание ViewModel

    Мы не передаем Context или View в конструктор ViewModel. Это строгое правило. Нарушение этого правила приведет к утечкам памяти.

    Обратите внимание на паттерн Backing Property (использование _loginState и loginState). Это позволяет нам менять данные внутри ViewModel, но запрещает View случайно изменить их. View может только читать.

    Шаг 2: Настройка View (Activity)

    В Activity мы получаем экземпляр ViewModel через специальный провайдер и подписываемся на изменения.

    Преимущества подхода MVVM

  • Сохранение состояния. Если пользователь ввел логин, нажал кнопку, пошла загрузка, и он повернул экран — новая Activity подпишется на ту же самую ViewModel. Она мгновенно получит последнее состояние (LoginState.Loading) и покажет прогресс-бар. Данные не потеряются.
  • Отсутствие утечек памяти. ViewModel ничего не знает об Activity. Когда Activity умирает, ViewModel просто продолжает жить (при повороте) или очищается (при выходе).
  • Тестируемость. ViewModel — это просто класс с данными и логикой. Его легко тестировать JUnit-тестами, не запуская эмулятор Android.
  • Разделение ответственности. UI занимается только отрисовкой (render), ViewModel — только принятием решений.
  • Unidirectional Data Flow (UDF)

    MVVM способствует внедрению принципа Однонаправленного потока данных.

    * События (Events) текут вверх: от View к ViewModel (нажатие кнопки). * Состояние (State) течет вниз: от ViewModel к View (через LiveData).

    Это делает поведение приложения предсказуемым. Вы всегда знаете, что единственное место, где меняется состояние — это ViewModel.

    Частые ошибки при использовании MVVM

    Даже с хорошими инструментами можно написать плохой код. Вот чего стоит избегать:

    * Передача Context во ViewModel. Никогда не делайте этого. Если ViewModel будет хранить ссылку на Context, а Activity уничтожится, произойдет утечка памяти. Если вам нужен Context (например, для доступа к строковым ресурсам или SharedPreferences), используйте класс AndroidViewModel и Application Context. * Логика во View. Если вы пишете if в Activity, который решает, какие данные показать — вы нарушаете паттерн. View должна быть максимально «глупой». * Слишком много LiveData. Иногда разработчики создают отдельную LiveData для каждого поля (текст кнопки, цвет фона, видимость). Лучше объединять их в один объект состояния (State), как мы сделали с LoginState.

    Заключение

    MVVM с использованием Architecture Components — это мощный стандарт, который решает большинство проблем жизненного цикла Android. Он делает код чище, надежнее и проще для тестирования.

    Однако, по мере роста приложения, ViewModel может стать слишком большой, а работа с зависимостями (создание репозиториев вручную в фабриках) — утомительной. В следующих статьях мы узнаем, как Clean Architecture помогает структурировать код внутри слоев, и как Dependency Injection (Hilt/Dagger) избавляет нас от написания шаблонного кода для создания объектов.

    3. Принципы Clean Architecture: правильное разделение на слои Data, Domain и Presentation

    Принципы Clean Architecture: правильное разделение на слои Data, Domain и Presentation

    Мы прошли долгий путь от хаоса в MainActivity до структурированного MVVM. В предыдущей статье мы научились использовать ViewModel и LiveData, чтобы пережить поворот экрана и отделить UI от данных. Казалось бы, жизнь наладилась.

    Но давайте представим, что наше приложение растет. Логика входа в систему усложняется: нужно не просто отправить запрос на сервер, но и захешировать пароль, проверить валидность email, сохранить токен в зашифрованное хранилище и, возможно, обновить аналитику. Если мы напишем все это во ViewModel, она превратится в такого же монстра, каким раньше была Activity.

    Здесь на сцену выходит Clean Architecture (Чистая Архитектура) — концепция, популяризированная Робертом Мартином (Дядюшка Боб). Это не просто набор правил, это философия построения систем, которые легко тестировать, поддерживать и масштабировать.

    Три кита Чистой Архитектуры

    Главная цель Clean Architecture — разделение ответственности. В контексте Android-разработки мы обычно делим приложение на три основных слоя:

  • Presentation Layer (Слой представления)
  • Domain Layer (Слой бизнес-логики)
  • Data Layer (Слой данных)
  • !Схематичное изображение слоев Clean Architecture, где Domain является центром вселенной приложения.

    Самое важное правило: Dependency Rule

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

    Это значит, что: * Presentation знает о Domain. * Data знает о Domain. * Domain не знает НИ О КОМ.

    Слой бизнес-логики (Domain) — это святая святых. Он не должен зависеть ни от базы данных, ни от UI, ни от Android SDK. Это чистый Kotlin/Java код.

    1. Domain Layer: Сердце приложения

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

    Что находится внутри: * Entities (Сущности): Простые классы данных, отражающие бизнес-модели (например, User, Product). Repository Interfaces (Интерфейсы репозиториев): Контракты, описывающие, как мы получаем данные. Важно: только интерфейсы*, не реализация! * Use Cases (Варианты использования): Классы, инкапсулирующие одну конкретную бизнес-задачу.

    Магия Use Case (Интерактора)

    В MVVM без Clean Architecture ViewModel часто обращается напрямую к Repository. В Clean Architecture между ними встает Use Case.

    Один Use Case = Одно действие пользователя.

    Пример:

    Зачем это нужно?

  • Переиспользование. Логику валидации email можно использовать и при входе, и при регистрации.
  • Читаемость. Открыв папку domain/usecases, новый разработчик сразу поймет, что умеет делать приложение: GetNewsUseCase, DeleteProfileUseCase, BuyItemUseCase.
  • 2. Data Layer: Детали реализации

    Этот слой отвечает за то, откуда берутся данные. Domain слой говорит: «Мне нужен пользователь». Data слой отвечает: «Ок, я достану его из сети через Retrofit или из кэша через Room».

    Что находится внутри: * Repository Implementations: Реализация интерфейсов из Domain слоя. * Data Sources: Источники данных (API сервисы, DAO базы данных). * DTO (Data Transfer Objects): Модели, которые приходят с сервера. Они часто содержат технические поля (id_str, created_at_timestamp), которые не нужны бизнес-логике. * Mappers: Классы, преобразующие DTO в Domain Entities.

    Проблема DTO и Mapping

    Почему нельзя использовать одни и те же классы данных везде? Представьте, что бэкенд переименовал поле userName в full_name. Если вы используете этот класс прямо в UI, вам придется переписывать все экраны.

    В Clean Architecture изменения в API затрагивают только Data слой и Mapper. Domain и Presentation остаются нетронутыми.

    3. Presentation Layer: Витрина

    С этим слоем мы уже знакомы. Это наши Activity, Fragment и ViewModel.

    Единственное отличие в Clean Architecture: ViewModel теперь общается не с репозиториями, а с Use Cases.

    Dependency Inversion (Инверсия зависимостей)

    Вы могли заметить странность. UserRepositoryImpl находится в Data, но реализует интерфейс UserRepository, который находится в Domain.

    Как Data может зависеть от Domain, если данные — это фундамент?

    Это и есть принцип Dependency Inversion.

  • Domain определяет правила игры (Интерфейс): «Мне нужен способ получить пользователя».
  • Data подчиняется правилам (Реализация): «Я умею получать пользователя, вот код».
  • Благодаря этому, Domain слой остается независимым. Мы можем полностью заменить Retrofit на GraphQL, Room на Realm, или вообще использовать фейковые данные для тестов, не меняя ни строчки кода в бизнес-логике.

    !Иллюстрация инверсии зависимостей: реализация в Data слое подчиняется интерфейсу в Domain слое.

    Пример структуры проекта

    В Android Studio это обычно выглядит как разделение по пакетам (или даже отдельным Gradle-модулям):

    Преимущества и недостатки

    Как и любой инструмент, Clean Architecture имеет цену.

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

  • Тестируемость. Вы можете протестировать бизнес-логику (Use Cases), вообще не запуская эмулятор Android.
  • Независимость от UI. Вы можете полностью переписать интерфейс (например, перейти с XML на Jetpack Compose), не трогая логику.
  • Независимость от БД/API. Замена базы данных становится рутинной задачей, а не катастрофой.
  • Недостатки:

  • Много кода (Boilerplate). Вам придется писать много классов-оберток, мапперов и интерфейсов. Для простых приложений это может быть избыточно (Overengineering).
  • Кривая обучения. Новичкам сложно понять, почему нужно создавать три разных класса User (UserDto, UserDomain, UserUiModel).
  • Заключение

    Clean Architecture — это стандарт для средних и крупных проектов. Она вносит порядок там, где MVVM начинает захлебываться от сложности.

    Разделяя приложение на Data, Domain и Presentation, вы инвестируете в будущее своего проекта. Да, писать код придется дольше, но поддерживать его станет в разы дешевле.

    В следующей статье мы разберем, как соединить все эти слои воедино, не создавая объекты вручную, с помощью Dependency Injection.

    4. Внедрение зависимостей (Dependency Injection) и модульность с помощью Hilt

    Внедрение зависимостей (Dependency Injection) и модульность с помощью Hilt

    В предыдущей статье мы построили красивую, но сложную структуру Clean Architecture. Мы разделили приложение на слои Data, Domain и Presentation. Однако, если вы попытались реализовать это на практике, то наверняка столкнулись с новой проблемой: создание объектов.

    Чтобы создать один экземпляр LoginViewModel, нам нужно создать LoginUseCase. Чтобы создать LoginUseCase, нужен UserRepository. А для репозитория нужны ApiService и UserDao. В итоге, в нашей Activity или Fragment появляется огромный кусок кода, который просто собирает этот конструктор «Лего».

    Этот код нарушает принцип единственной ответственности. UI не должен знать, как собирать сложные объекты. Решение этой проблемы — паттерн Dependency Injection (DI) и библиотека Hilt.

    Что такое Dependency Injection?

    Dependency Injection (Внедрение зависимостей) — это паттерн проектирования, при котором объекты не создают свои зависимости сами, а получают их извне.

    Аналогия с автомобилем

    Представьте, что вы собираете автомобиль (класс Car).

    * Без DI: Автомобиль сам знает, как создать двигатель. Внутри конструктора Car написано this.engine = new V8Engine(). Это плохо. Если вы захотите поставить электродвигатель, вам придется переписывать класс Car. * С DI: Вы говорите: «Мне нужен двигатель». И кто-то (сборщик) дает вам готовый двигатель в конструктор. Car просто использует то, что ему дали.

    !Визуализация принципа инъекции: объект не создает зависимость, она предоставляется извне.

    Зачем нам DI в Android?

  • Упрощение тестирования. Если ViewModel получает Repository в конструктор, мы можем в тесте передать туда фейковый репозиторий (Mock). Если же ViewModel создает репозиторий сама внутри себя, подменить его невозможно.
  • Управление жизненным циклом. Кто должен хранить единственный экземпляр базы данных (Singleton)? DI-контейнер берет это на себя.
  • Чистота кода. Мы избавляемся от фабрик и ручной сборки объектов в Activity.
  • Знакомство с Hilt

    Долгое время стандартом в Android был Dagger 2. Это мощная, но невероятно сложная библиотека с высоким порогом входа. Google осознала проблему и выпустила Hilt.

    Hilt — это надстройка над Dagger, специально созданная для Android. Она убирает 80% шаблонного кода, который требовался для настройки Dagger, и автоматически интегрируется с Android-компонентами (Activity, Fragment, ViewModel).

    Основные аннотации Hilt

    Чтобы начать использовать Hilt, нужно запомнить несколько ключевых аннотаций:

  • @HiltAndroidApp
  • @AndroidEntryPoint
  • @Inject
  • @Module, @InstallIn, @Provides
  • Разберем их по порядку.

    Настройка приложения: Контейнер

    Все начинается с класса Application. Hilt должен знать, где находится корень вашего приложения, чтобы создать граф зависимостей.

    Эта одна строчка кода запускает генерацию кода Hilt. Теперь у нас есть глобальный контейнер зависимостей.

    Внедрение в Android-компоненты

    Чтобы Activity или Fragment могли запрашивать зависимости, их нужно пометить аннотацией @AndroidEntryPoint.

    Если вы забудете эту аннотацию, приложение упадет с ошибкой при попытке внедрения.

    Способы внедрения (Injection)

    Существует два основных способа получить зависимость.

    1. Constructor Injection (Внедрение через конструктор)

    Это самый правильный и рекомендуемый способ. Мы просто говорим Hilt, как создавать наши собственные классы.

    Ключевое слово @Inject constructor говорит Hilt: «Когда кому-то понадобится LoginUseCase, создай его так. А UserRepository возьми из своего графа».

    2. Field Injection (Внедрение в поля)

    Android-компоненты (Activity, Fragment, Service) создает система, а не мы. Мы не можем изменить их конструктор. Поэтому мы используем внедрение в поля.

    Важно: Поля для инъекции не могут быть private.

    Модули (Modules): Работа со сторонними библиотеками

    Мы можем поставить @Inject на конструктор своих классов. Но мы не можем поставить аннотацию на классы из библиотек (например, Retrofit, OkHttpClient, RoomDatabase) или на интерфейсы.

    Для этого существуют Модули.

    Модуль — это инструкция для Hilt, как создавать объекты, которые мы не можем аннотировать напрямую.

    Разберем магию: * @Module: Говорит, что это модуль Hilt. * @InstallIn(SingletonComponent::class): Говорит, что эти зависимости будут жить столько же, сколько живет все приложение (Singleton scope). * @Provides: Метод, который создает и возвращает объект. Если методу нужны аргументы (например, baseUrl для Retrofit), Hilt сам найдет их в графе.

    Связываем интерфейсы: @Binds

    В Clean Architecture мы используем интерфейсы для репозиториев (UserRepository). Но Hilt не умеет создавать экземпляры интерфейсов. Ему нужна реализация (UserRepositoryImpl).

    Мы можем использовать @Provides, но есть более эффективный способ — @Binds.

    Это читается так: «Если кто-то просит UserRepository, дай ему UserRepositoryImpl».

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

    Hilt и ViewModel

    Самое приятное в Hilt — это работа с ViewModel. Помните фабрики? Забудьте о них.

    В ViewModel мы используем специальную аннотацию @HiltViewModel.

    А в Activity или Fragment мы просто используем делегат viewModels():

    Вся цепочка зависимостей (Retrofit -> ApiService -> UserRepository -> LoginUseCase -> LoginViewModel) собирается автоматически.

    Scopes (Области видимости)

    В Hilt есть иерархия компонентов, определяющая время жизни объектов.

  • SingletonComponent (@Singleton): Объект живет, пока живет приложение. Пример: Retrofit, Database.
  • ActivityRetainedComponent (@ActivityRetainedScoped): Переживает повороты экрана, как ViewModel.
  • ActivityComponent (@ActivityScoped): Живет, пока живет Activity. Уничтожается вместе с ней.
  • FragmentComponent (@FragmentScoped): Живет, пока живет Fragment.
  • Если вы не укажете Scope (аннотацию), Hilt будет создавать новый экземпляр объекта каждый раз, когда он запрашивается.

    Модульность

    В крупных проектах код часто разбивают на модули Gradle (:app, :data, :domain, :feature-login). Hilt отлично работает в такой среде.

    * Модуль :domain остается чистым (Kotlin only). Там мы используем стандартную аннотацию javax.inject.Inject, которая является частью Java стандарта (JSR-330), поэтому нам не нужно подключать Android-зависимости Hilt в чистый домен. * Модуль :data подключает Hilt для настройки Retrofit и Room. * Модуль :app собирает все вместе через @HiltAndroidApp.

    Заключение

    Внедрение зависимостей с Hilt — это тот клей, который собирает слои Clean Architecture воедино.

    Что мы получили:

  • Убрали Boilerplate: Никаких фабрик и ручного создания объектов.
  • Слабая связность: Классы зависят от абстракций, а не от конкретных реализаций.
  • Легкость изменений: Заменить реализацию репозитория можно в одном месте (в Модуле), и это применится ко всему приложению.
  • Теперь, когда у нас есть надежная архитектура и автоматическое управление зависимостями, мы готовы перейти к следующему важному этапу профессиональной разработки — асинхронной работе и многопоточности.

    5. Современные тренды: однонаправленный поток данных (UDF) и архитектура MVI в Jetpack Compose

    Современные тренды: однонаправленный поток данных (UDF) и архитектура MVI в Jetpack Compose

    Мы прошли большой путь: от спагетти-кода в MainActivity до чистой архитектуры с использованием MVVM и Hilt. Казалось бы, у нас есть идеальный рецепт создания приложений. Однако мир Android-разработки не стоит на месте. С появлением Jetpack Compose — нового декларативного UI-фреймворка от Google — правила игры снова изменились.

    В этой статье мы разберем, почему классический MVVM с множеством LiveData становится неудобным в эпоху Compose, что такое Unidirectional Data Flow (UDF) и как архитектурный паттерн MVI (Model-View-Intent) помогает сделать состояние экрана предсказуемым и надежным.

    Смена парадигмы: от императивного к декларативному

    Чтобы понять, зачем нам MVI, нужно вспомнить, как работает Jetpack Compose. В старой системе View (XML) мы меняли UI императивно: находили TextView и вызывали setText(). В Compose мы описываем UI как функцию от состояния.

    Где — это то, что видит пользователь, — ваши Composable-функции, а — данные в конкретный момент времени.

    Когда состояние меняется, Compose полностью перерисовывает (рекомпозирует) экран. Это накладывает строгие требования к управлению состоянием. Если в MVVM у нас могло быть 10 разных LiveData (одна для загрузки, другая для списка, третья для ошибок), то в Compose синхронизировать их становится сложно. Вы можете случайно показать ошибку и список одновременно, если не будете осторожны.

    Unidirectional Data Flow (UDF)

    Фундамент современной архитектуры — это Однонаправленный поток данных (UDF). Этот принцип гласит, что данные и события должны двигаться строго по кругу в одном направлении.

  • State (Состояние): Течет вниз от ViewModel к UI.
  • Events (События): Текут вверх от UI к ViewModel.
  • !Данные спускаются вниз, а события поднимаются вверх, образуя бесконечный цикл обновления интерфейса.

    Это гарантирует, что UI никогда не меняет сам себя. UI — это просто «глупое» отображение текущего состояния. Только ViewModel имеет право изменить состояние в ответ на событие.

    Что такое MVI?

    MVI (Model-View-Intent) — это архитектурный паттерн, который доводит принцип UDF до абсолюта. Он пришел из веба (вдохновлен Redux) и отлично прижился в Android благодаря реактивным стримам (Kotlin Flow).

    Давайте расшифруем аббревиатуру, так как в MVI эти термины значат немного другое, чем в MVC:

    Model (Модель): Это не слой данных (как Repository), а UI State. Это единый неизменяемый (immutable) объект, который описывает всё*, что есть на экране в данный момент. * View (Представление): Это наш UI (Composable функции), который рендерит Модель. * Intent (Намерение): Это не android.content.Intent. Это намерение пользователя совершить действие (клик, ввод текста, свайп).

    Главное отличие от MVVM

    В классическом MVVM мы часто вызываем методы ViewModel напрямую:

    В MVI у нас есть единая точка входа для всех событий:

    Реализация MVI на практике

    Давайте перепишем наш экран логина, используя MVI и Jetpack Compose. Нам понадобятся три компонента: Состояние, Намерения и ViewModel.

    1. State (Состояние)

    Мы объединяем все поля экрана в один data class. Важно, чтобы он был неизменяемым (val).

    Теперь у нас есть Single Source of Truth (Единственный источник истины). Невозможно, чтобы isLoading был true, а error показывался одновременно, если мы правильно напишем логику обновления.

    2. Intent (Намерение)

    Мы описываем все возможные действия пользователя с помощью sealed interface или sealed class.

    3. ViewModel и Reducer

    В ViewModel мы используем StateFlow для хранения текущего состояния. Обработка интентов происходит в методе-редьюсере (от англ. reduce — сводить), который берет старое состояние, применяет к нему действие и получает новое состояние.

    Обратите внимание на метод copy(). Поскольку LoginState неизменяем, мы создаем его копию с измененными полями. Это ключевой момент для корректной работы Compose: он сравнивает объекты, и если ссылка на объект изменилась, происходит рекомпозиция.

    !Циклический процесс обновления состояния: Намерение пользователя трансформирует текущее состояние в новое.

    Проблема Side Effects (Побочных эффектов)

    В примере выше есть поле isLoginSuccessful. Если оно станет true, UI должен выполнить навигацию на следующий экран. Но StateFlow хранит состояние. Если мы перейдем на новый экран и вернемся назад, isLoginSuccessful все еще будет true, и навигация сработает снова! Это баг.

    События вроде «Показать Toast», «Перейти на экран», «Сыграть звук» — это Single Live Events или Side Effects. Они должны произойти один раз и не являются частью постоянного состояния экрана.

    Для этого в MVI часто выделяют отдельный поток данных — Effects.

    Мы используем Channel, потому что он отлично подходит для событий «выстрелил и забыл».

    Интеграция с Jetpack Compose

    Как это выглядит в UI коде?

    Преимущества и недостатки MVI

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

  • Предсказуемость. Состояние меняется только в одном месте и только через copy(). Вы всегда знаете, почему экран выглядит именно так.
  • Отладка. Вы можете логировать все входящие Intent и все изменения State. Это позволяет легко воспроизводить баги (Time Travel Debugging).
  • Потокобезопасность. StateFlow и атомарные обновления защищают от гонок потоков.
  • Масштабируемость. Добавление нового поля на экран требует добавления его в State и обработки в Reducer. Структура остается прежней.
  • Недостатки

  • Много кода (Boilerplate). Для каждого экрана нужно создавать State, Intent, Effect и настраивать when конструкции.
  • Сложность для простых экранов. Для экрана «О приложении» с одним текстом MVI будет явным переусложнением (Overengineering).
  • Управление памятью. При очень частых обновлениях (например, таймер или анимация) создание копий больших объектов State может создавать нагрузку на Garbage Collector, хотя в современных реализациях JVM это редко является проблемой.
  • Заключение

    MVI в связке с Jetpack Compose — это мощный стандарт современной Android-разработки. Он заставляет разработчика думать о состоянии приложения как о целостной картине, а не как о наборе разрозненных переменных.

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

    В следующей, заключительной статье курса, мы поговорим о том, как поддерживать качество этого кода: рассмотрим стратегии модульного тестирования (Unit Testing) и UI-тестирования в контексте нашей чистой архитектуры.