Android Kotlin и ООП: Путь к чистому коду

Курс ориентирован на разработчиков, желающих перейти от базового синтаксиса к профессиональному проектированию приложений. Вы освоите принципы ООП, архитектуру MVVM и современные инструменты Android-разработки для создания масштабируемых и поддерживаемых программ.

От переменных к объектам: зачем нужны классы

От переменных к объектам: зачем нужны классы

Представьте, что вы разрабатываете экран контактов для мессенджера. Любая программа начинается с данных, и для их хранения мы используем переменные.

В Kotlin для создания переменной мы используем ключевые слова val (для неизменяемых данных) или var (для тех, что могут меняться). Чтобы показать одного пользователя, вы пишете:

val userName = "Алексей"
var userStatus = "В сети"

Всё работает отлично: у нас есть имя, которое вряд ли изменится, и статус, который мы сможем обновлять. Но что, если пользователей в приложении сто или тысяча? Вы не можете создавать переменные userName1, userName2 и так далее — код превратится в бесконечную простыню.

Опираясь на базовые знания программирования, вы, скорее всего, решите сгруппировать данные в списки:

val names = listOf("Алексей", "Мария", "Иван")
var statuses = listOf("В сети", "Был час назад", "Печатает...")

А теперь представьте реальную ситуацию: пользователь нажал кнопку «Сортировать по алфавиту». Вы отсортировали список names, но забыли отсортировать список statuses. В результате Алексей внезапно получил статус Ивана. Данные перемешались, приложение показывает неправду, а вы тратите часы на поиск ошибки.

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

Проблема разрозненных данных

Когда мы описываем сложную сущность (например, пользователя мессенджера) отдельными переменными или списками, мы заставляем компьютер держать в уме связи, которых для него технически не существует.

Для Kotlin список names и список statuses — это просто два независимых набора строк. Компьютер не знает, что нулевой элемент первого списка и нулевой элемент второго списка описывают одного и того же человека.

В чистом коде данные, которые логически относятся к одной сущности, должны храниться вместе. Нам нужен надежный контейнер, который объединит имя, статус, аватарку и номер телефона в единое целое.

В реальном мире мы мыслим именно такими цельными категориями. Мы не говорим: «Я вижу красный цвет, четыре колеса и двигатель». Мы говорим: «Я вижу автомобиль». Автомобиль — это объект.

Что такое объект в программировании?

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

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

Но как объяснить языку Kotlin, какие именно переменные (имя, возраст, статус) должны лежать внутри объекта «Пользователь»? Для этого нам нужен чертеж.

Класс: чертеж для создания объектов

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

Класс — это шаблон, чертеж или инструкция. Он описывает, какими характеристиками (переменными) и поведением (функциями) будут обладать объекты этого типа.

Чтобы лучше понять разницу, давайте сравним эти два понятия на примерах из жизни:

Класс (Шаблон) Объект (Реализация)
Инженерный чертеж автомобиля Конкретная красная машина с полным баком и пробегом 10 км
Рецепт яблочного пирога Готовый горячий пирог, который стоит у вас на столе
Описание сущности User в коде Пользователь «Алексей» со статусом «В сети»

На чертеже нельзя уехать в магазин, а рецепт нельзя съесть — они существуют только как инструкции. В вашем приложении будет написан всего один класс пользователя. Но, опираясь на этот класс, программа сможет создать в памяти тысячи объектов — для Алексея, Марии, Ивана и всех остальных людей в базе данных.

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

Зачем это нужно для чистого кода?

Переход от простых переменных к объектам (этот подход называется Объектно-Ориентированным Программированием, или ООП) — это не просто прихоть создателей языков. Это главный способ справиться со сложностью программ.

  1. Защита от ошибок. Как мы видели в примере с мессенджером, инкапсуляция (объединение) данных в объекты исключает их рассинхронизацию. Вы больше не перепутаете статус одного пользователя с другим.
  2. Читаемость. Код начинает говорить на языке реального мира и бизнес-логики. Вместо манипуляций с безликими строками и числами, вы начинаете писать код, который оперирует понятными категориями: User, Message, ChatRoom.
  3. Основа Android. Вся разработка под Android построена на классах и объектах. Кнопка на экране — это объект. Сам экран приложения — это объект. Текст, который вводит пользователь — это тоже объект.

Вам больше не нужно держать в голове сотни разрозненных переменных. Вы создаете правила игры (классы), а затем заставляете сущности (объекты) взаимодействовать друг с другом по этим правилам. О том, как написать свой первый класс на Kotlin и создать из него реальный объект в коде, мы поговорим в следующем шаге.

Создание первого класса на Kotlin

Создание первого класса на Kotlin

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

Давайте перенесем абстрактную концепцию в реальный код Android-приложения. Представьте, что мы разрабатываем мессенджер. Нам нужна сущность пользователя.

Синтаксис класса

Создание шаблона начинается с имени. По общепринятому стандарту (и правилам хорошего тона) имена классов всегда пишутся с заглавной буквы в стиле PascalCase.

class User(
    val name: String,
    var isOnline: Boolean
)

Разберем этот лаконичный фрагмент:

  1. class User — мы объявляем новый тип данных. Теперь User для компилятора такой же полноправный тип, как String или Int.
  2. Круглые скобки (...) сразу после имени — это первичный конструктор. В нем мы перечисляем, какие именно данные обязаны быть у каждого пользователя при его создании.
  3. Внутри скобок мы объявляем свойства (properties) класса. Обратите внимание на val и var: имя пользователя (name) мы сделали неизменяемым (val), так как оно задается один раз, а вот статус в сети (isOnline) может меняться в процессе работы программы, поэтому это var.

Этот код — пока только чертеж. Он не занимает память под конкретного Алексея или Марию. Чтобы данные появились в памяти, нужно создать экземпляр.

Рождение объекта: без слова new

В старых языках вроде Java или C++ для создания объекта требовалось специальное слово new. Создатели Kotlin решили избавиться от визуального шума: вызов конструктора класса выглядит в точности как обычный вызов функции.

val user1 = User("Алексей", true)
val user2 = User("Мария", false)

В этот момент происходит магия выделения памяти. Программа берет чертеж User, выделяет в оперативной памяти смартфона место под строку и логическое значение, записывает туда «Алексей» и true, а затем связывает этот участок памяти с переменной user1.

Чтобы прочитать или изменить данные внутри капсулы, используется точечная нотация (dot notation). Точка буквально означает «загляни внутрь этого объекта и возьми его свойство»:

// Чтение данных
println(user1.name) // Выведет: Алексей

// Изменение данных (так как isOnline объявлено через var)
user2.isOnline = true

Если вы попытаетесь написать user1.name = "Иван", компилятор выдаст ошибку, защищая код от багов: мы сами указали в классе, что name — это val (неизменяемое свойство).

Ссылочная модель: пульт и телевизор

Чтобы писать по-настоящему чистый код и не получать утечек памяти в Android, необходимо с первого дня понимать, что именно хранится в переменной user1.

Переменная user1 не содержит самого Алексея. Она содержит лишь адрес (ссылку) на то место в памяти, где лежат данные.

Представьте, что объект в памяти (Heap) — это телевизор, а переменная user1 (Stack) — это пульт от него. Когда вы пишете val user3 = user1, вы не покупаете новый телевизор. Вы создаете второй пульт к тому же самому телевизору.

Понимание того, что объекты передаются по ссылке, — это фундамент. Именно поэтому мы можем передать объект User на другой экран Android-приложения, и если тот экран изменит статус isOnline, первый экран тоже увидит эти изменения (ведь телевизор один, просто пульты разные).

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

Свойства и методы: оживляем наш класс

Свойства и методы: оживляем наш класс

Пока наши объекты умеют только одно — пассивно хранить данные. Мы создали чертёж, выделили память, заполнили свойства, но для изменения этих данных нам пришлось бы писать логику снаружи объекта. В парадигме ООП объекты не должны быть просто «мешками с переменными». Настоящий объект сам управляет своими данными и выполняет действия.

Чтобы превратить пассивную структуру в активный механизм, нам нужно добавить в класс поведение.

Состояние и поведение

Любой полноценный объект в программировании состоит из двух неразрывных частей:

  1. Состояние (State) — это то, что объект знает о себе прямо сейчас. В Kotlin состояние хранится в свойствах (переменных внутри класса).
  2. Поведение (Behavior) — это то, что объект умеет делать. Поведение реализуется через методы.

Метод — это обычная функция, но объявленная внутри тела класса. Она привязана к объекту и имеет прямой доступ к его свойствам.

Представим класс CoffeeMachine. Его состоянием будут запасы воды и зёрен. А поведением — способность варить кофе, расходуя эти запасы.

Напишем этот класс на Kotlin. Обратите внимание на фигурные скобки { } после первичного конструктора — они открывают тело класса, где живут методы.

class CoffeeMachine(var waterMl: Int, var beansGrams: Int) {

    // Это метод. Он описывает поведение объекта.
    fun makeCoffee() {
        if (waterMl >= 200 && beansGrams >= 15) {
            waterMl -= 200
            beansGrams -= 15
            println("Кофе готов!")
        } else {
            println("Ошибка: не хватает ингредиентов")
        }
    }
}

Как метод понимает, с какими данными работать?

В коде выше метод makeCoffee() вычитает числа из переменных waterMl и beansGrams. Но ведь мы можем создать сто разных кофемашин. Как метод понимает, из какой именно машины вычитать воду?

Секрет в контексте вызова. Когда вы вызываете метод через точечную нотацию, он автоматически получает невидимую ссылку на тот конкретный объект, у которого был вызван. В Kotlin эта ссылка называется this (этот).

Хотя мы написали просто waterMl -= 200, компилятор читает это как this.waterMl -= 200 — «возьми воду из этого конкретного объекта и уменьши её».

Создадим две машины и посмотрим, как это работает:

val officeMachine = CoffeeMachine(1000, 100)
val homeMachine = CoffeeMachine(500, 50)

// Вызываем метод у первой машины
officeMachine.makeCoffee()

При вызове officeMachine.makeCoffee() метод обращается к свойствам объекта, на который ссылается переменная officeMachine. Состояние homeMachine при этом остаётся абсолютно нетронутым. Каждый экземпляр живёт в своём изолированном мире.

Путь к чистому коду: зачем прятать логику внутрь?

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

Представьте, что логика варки кофе написана не в классе, а разбросана по всему Android-приложению: на экране завтрака вы вручную отнимаете waterMl200waterMl - 200, на экране заказа в кафе делаете то же самое. Если завтра рецепт изменится и потребуется 250250 мл воды, вам придётся искать и менять эту математику по всему проекту.

Помещая метод makeCoffee() внутрь класса CoffeeMachine, мы достигаем важной цели ООП: объект сам отвечает за консистентность своих данных.

Другим частям программы больше не нужно знать рецепт кофе или уметь вычитать граммы. Им достаточно нажать «кнопку» — вызвать метод makeCoffee(). Вся сложная логика изменения состояния капсулируется (запирается) внутри класса.

Однако в нашем текущем коде есть уязвимость. Любой другой программист всё ещё может написать officeMachine.waterMl = -5000, в обход нашего метода, и сломать логику. О том, как запретить прямое изменение свойств и заставить всех общаться с объектом только через его методы, мы поговорим при изучении модификаторов доступа.

Отображение данных объекта на экране Android-активности

Отображение данных объекта на экране Android-активности

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

Пока мы не свяжем данные наших объектов с пикселями на экране, для пользователя приложение остается пустым. В этой статье мы разберем, как перекинуть мост между объектами бизнес-логики (нашими данными) и объектами пользовательского интерфейса (экраном).

Элементы экрана — это тоже объекты

Первый шаг к пониманию Android-разработки: всё, что вы видите на экране, является объектами.

Кнопки, текстовые поля, картинки — это не просто рисунки. Это полноценные экземпляры классов, которые написали инженеры Google. Когда вы добавляете на экран текст, под капотом работает класс TextView. Когда добавляете картинку — класс ImageView.

У этих классов есть свои свойства (цвет, размер текста, видимость) и свои методы (анимация, реакция на клик).

Как они появляются в памяти? Обычно мы верстаем экраны в XML-файлах. Когда запускается Activity (экран Android-приложения), вызывается специальный метод setContentView(). Он берет ваш XML-чертеж, читает его и автоматически вызывает конструкторы для каждого элемента, создавая дерево объектов UI в оперативной памяти.

Маппинг: связываем бизнес-логику и UI

У нас есть два независимых мира в памяти устройства:

  1. Объекты предметной области (Domain objects) — например, объект Character с данными игры.
  2. Объекты интерфейса (UI objects) — например, TextView на экране.

Наша задача — извлечь состояние из первого объекта и передать его в свойства второго. Этот процесс называется маппингом (от англ. mapping — отображение, связывание).

Рассмотрим класс персонажа:

class Character(val name: String, var health: Int) {
    fun takeDamage(amount: Int) {
        health = health - amount
        if (health < 0) health = 0
    }
}

Внутри Activity мы сначала создаем нашего персонажа, а затем находим объекты интерфейса с помощью встроенного метода findViewById. Он ищет созданный UI-объект по его ID и возвращает нам ссылку на него.

// 1. Создаем объект бизнес-логики
val hero = Character("Артур", 100)

// 2. Получаем ссылки на объекты интерфейса
val nameTextView = findViewById<TextView>(R.id.text_name)
val healthTextView = findViewById<TextView>(R.id.text_health)

// 3. Маппинг: копируем данные из свойств hero в свойства TextView
nameTextView.text = hero.name
healthTextView.text = "Здоровье: ${hero.health}"

Обратите внимание на точечную нотацию: hero.name читает значение, а nameTextView.text = ... записывает это значение в свойство text объекта TextView. Как только свойство text изменяется, TextView сам дает команду Android перерисовать пиксели на экране.

Иллюзия связи и проблема рассинхронизации

Здесь кроется главная ловушка для начинающих разработчиков.

Представьте, что персонаж получил урон. Мы вызываем метод:

hero.takeDamage(20)

В оперативной памяти свойство health объекта hero изменилось со 100 на 80. Что в этот момент произойдет на экране? Ничего. Экран по-прежнему будет показывать «Здоровье: 100».

Почему так происходит? В строке healthTextView.text = "Здоровье: ${hero.health}" мы не создали магическую постоянную связь между экраном и объектом. Мы просто взяли значение 100, превратили его в строку "Здоровье: 100" и отдали объекту TextView. TextView ничего не знает о существовании объекта hero и не следит за его методами.

Такой подход к построению интерфейса называется императивным. В императивном UI действует жесткое правило, которое можно выразить концептуальной формулой:

UI=f(State)UI = f(State)

Где UIUI — это то, что видит пользователь, StateState — состояние ваших объектов, а функция ff — это ручной процесс переноса данных. Если StateState изменился, UIUI устаревает до тех пор, пока вы снова не вызовете функцию ff.

Решение: инкапсуляция логики отрисовки

Поскольку UI не обновляется сам, мы должны обновлять его вручную после каждого изменения состояния. Чтобы не дублировать код маппинга по всему классу Activity, хорошей практикой является выделение его в отдельный метод.

class GameActivity : AppCompatActivity() {

    // Объявляем переменные на уровне класса, чтобы они были доступны во всех методах
    private lateinit var hero: Character
    private lateinit var healthTextView: TextView

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

        hero = Character("Артур", 100)
        healthTextView = findViewById(R.id.text_health)

        // Первичная отрисовка
        updateUI()

        // Имитация получения урона (например, по клику на кнопку)
        findViewById<Button>(R.id.btn_attack).setOnClickListener {
            hero.takeDamage(20)
            updateUI() // Обязательно вызываем после изменения состояния!
        }
    }

    // Тот самый f(State) из нашей формулы
    private fun updateUI() {
        healthTextView.text = "Здоровье: ${hero.health}"
    }
}

Теперь метод updateUI() служит единой точкой синхронизации. Каждый раз, когда мы меняем состояние объекта hero, мы обязаны вызвать updateUI(), чтобы актуализировать экран.

В современных архитектурах Android (таких как MVVM, которые мы разберем позже) этот процесс автоматизирован: мы заставляем UI-объекты «подписываться» на изменения бизнес-объектов. Но под капотом всегда лежит этот базовый принцип: экран — это лишь отражение состояния объектов в памяти в конкретный момент времени.

Наследование: избавляемся от дублирования кода

Наследование: избавляемся от дублирования кода

Представьте, что вы разрабатываете игру, и ваш код уже умеет отображать на экране базового персонажа с именем и запасом здоровья. Игра растет, и теперь вам нужны разные классы бойцов: Воин с показателем брони, Маг с запасом маны и Лучник с количеством стрел.

Самый очевидный и самый разрушительный путь — скопировать код. Вы создаете три новых класса, копируя в каждый из них свойства name, health и метод takeDamage(), а затем добавляете специфичные детали.

class Warrior(val name: String, var health: Int, var armor: Int) {
    fun takeDamage(amount: Int) {
        health -= amount
        // логика получения урона
    }
}

class Mage(val name: String, var health: Int, var mana: Int) {
    fun takeDamage(amount: Int) {
        health -= amount
        // точно такая же логика получения урона
    }
}

Спустя месяц геймдизайнер просит изменить механику получения урона: теперь здоровье не может опускаться ниже нуля. Вам придется искать метод takeDamage() во всех классах и менять код в трех, пяти или десяти местах. Если вы забудете обновить Лучника, он станет бессмертным при отрицательном здоровье. Это классическая проблема дублирования кода.

Объектно-ориентированное программирование предлагает элегантное решение: вынести общие свойства и поведение в единый «родительский» чертеж. Этот механизм называется наследованием.

Базовый класс: выделяем общее

Вместо того чтобы наделять каждого персонажа базовыми характеристиками с нуля, мы создадим фундамент — базовый класс (или суперкласс). Он будет содержать всё то, что объединяет любых персонажей в нашей игре.

В Kotlin есть важная архитектурная особенность: по умолчанию все классы «закрыты» для наследования. Язык защищает разработчика от случайного изменения поведения классов. Чтобы разрешить другим классам брать наш чертеж за основу, нужно явно добавить ключевое слово open.

open class Hero(val name: String, var health: Int) {
    fun takeDamage(amount: Int) {
        health -= amount
        if (health < 0) health = 0
    }
}

Теперь у нас есть полноценный шаблон героя. Метод takeDamage() написан один раз, и любые изменения в механике урона будут происходить только здесь.

Классы-наследники: расширяем функционал

Теперь создадим Мага, который унаследует всё от Hero и добавит свою специфику — ману.

Чтобы унаследовать класс в Kotlin, после имени нового класса ставится двоеточие :, а затем вызывается конструктор базового класса.

class Mage(name: String, health: Int, var mana: Int) : Hero(name, health) {
    fun castSpell() {
        if (mana >= 10) {
            mana -= 10
            // логика заклинания
        }
    }
}

Обратите внимание на синтаксис первичного конструктора Mage. Параметры name и health написаны без ключевых слов val или var. Почему?

Если бы мы написали val name, класс Mage создал бы собственное свойство name. Но нам это не нужно — свойство name уже есть в родительском классе Hero. Мы просто принимаем значения name и health при создании Мага и сразу передаем их «наверх», в конструктор Hero(name, health). А вот var mana — это уникальное свойство самого Мага, поэтому оно объявляется по всем правилам.

Что происходит в памяти, когда мы создаем объект Мага и вызываем методы?

val gandalf = Mage("Gandalf", 100, 50)
gandalf.takeDamage(20) // Метод из класса Hero
gandalf.castSpell()    // Метод из класса Mage

Когда мы вызываем gandalf.takeDamage(20), программа сначала ищет этот метод внутри класса Mage. Не находя его там, она автоматически поднимается на уровень выше — в класс Hero — и успешно выполняет код оттуда. Маг обладает поведением героя просто по праву рождения.

Главное правило наследования: тест «IS-A»

Наследование — мощный инструмент, но новички часто используют его неправильно, пытаясь сэкономить код там, где классы логически не связаны.

Как понять, можно ли наследовать один класс от другого? В ООП существует золотое правило: тест «IS-A» (является). Прочитайте фразу: «[Класс-наследник] является [Базовым классом]». Если фраза звучит логично в реальном мире, наследование оправдано.

  • Маг является Героем? Да. Наследование подходит.
  • Яблоко является Фруктом? Да. Наследование подходит.
  • Автомобиль является Двигателем? Нет. Автомобиль содержит двигатель (это отношение «HAS-A» — композиция). Если вы унаследуете Автомобиль от Двигателя только ради того, чтобы получить доступ к методу start(), вы сломаете архитектуру.

Наследование в Android UI

Вы уже сталкивались с наследованием, когда выводили данные на экран смартфона, даже если не подозревали об этом. Вся система пользовательского интерфейса Android построена на глубокой иерархии наследования.

Например, у вас есть текстовое поле TextView и кнопка Button. И то, и другое имеет ширину, высоту, координаты на экране, цвет фона и параметр видимости. Разработчики Android не писали логику отрисовки фона дважды.

В Android существует гигантский базовый класс View (представление). Он содержит сотни свойств и методов для работы с экраном. TextView наследуется от View, добавляя логику работы с текстом (размер шрифта, цвет текста). А класс Button... наследуется от TextView! Да, с точки зрения архитектуры Android, кнопка является текстовым полем, которое просто имеет другой стиль фона по умолчанию и реагирует на нажатия.

Благодаря наследованию, когда вы ищете элемент через findViewById() и хотите скрыть его с экрана, вы вызываете метод setVisibility(). Этот метод написан один раз в базовом классе View, но доступен абсолютно любому элементу интерфейса в вашем приложении.

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

Инкапсуляция: скрываем детали реализации с помощью модификаторов доступа

Инкапсуляция: скрываем детали реализации с помощью модификаторов доступа

Представьте, что вы создали идеального персонажа для игры. У него есть свойство health = 100. Вы передаете этот объект в другой модуль программы, который отвечает за отрисовку экрана. И вдруг, из-за ошибки в логике интерфейса, кто-то пишет одну строчку: player.health = -9999.

Программа не упадет с ошибкой компиляции. Переменная типа Int отлично вмещает отрицательные числа. Но логика игры сломана: полоска здоровья уедет за пределы экрана, а персонаж станет бессмертным «ходячим мертвецом». Это классическая проблема открытого состояния: когда кто угодно может изменить данные объекта напрямую, вы теряете контроль над тем, что происходит внутри вашей программы.

Решением этой проблемы является инкапсуляция — один из главных принципов объектно-ориентированного программирования.

Интерфейс и реализация

В реальном мире инкапсуляция окружает нас повсюду. Возьмем обычный телевизор. Внутри него находятся микросхемы, конденсаторы и высоковольтные провода. Это — реализация. Если дать пользователю прямой доступ к проводам, он либо сломает телевизор, либо получит удар током.

Вместо этого производитель прячет провода в пластиковый корпус и выдает вам пульт с кнопками «Включить» и «Громкость +». Пульт — это публичный интерфейс. Нажимая кнопку, вы отправляете сигнал, а телевизор сам решает, какие микросхемы задействовать для увеличения громкости.

В программировании мы делаем то же самое: прячем данные (провода) внутри класса и разрешаем изменять их только через специальные методы (кнопки пульта).

Модификаторы доступа: private и public

В Kotlin для управления видимостью свойств и методов используются модификаторы доступа. По умолчанию, если вы ничего не написали, все свойства и методы в Kotlin являются public (публичными). Это значит, что к ним можно обратиться из любой точки программы.

Чтобы спрятать свойство, используется модификатор private.

class Player(name: String) {
    // Теперь здоровье скрыто внутри класса
    private var health = 100

    val playerName = name
}

Если теперь мы попытаемся изменить здоровье извне, компилятор Kotlin выдаст ошибку и подчеркнет код красным:

val hero = Player("Артур")
hero.health = 50 // ОШИБКА: Cannot access 'health': it is private in 'Player'

Свойство health больше не существует для внешнего мира. Оно доступно только внутри фигурных скобок самого класса Player.

Защита инварианта через методы

Если свойство private, как тогда наносить урон персонажу? Ответ: предоставить публичный метод.

Главная сила метода в том, что внутри него мы можем написать логику проверок — защитить инвариант. Инвариант — это логическое условие, которое всегда должно оставаться истинным для корректной работы объекта. В нашем случае инвариант звучит так: здоровье не может быть меньше нуля и больше ста.

class Player(name: String) {
    private var health = 100

    // Публичный метод — "кнопка на пульте"
    fun takeDamage(amount: Int) {
        if (amount < 0) return // Игнорируем отрицательный урон

        health -= amount

        // Защита инварианта: здоровье не падает ниже нуля
        if (health < 0) {
            health = 0
        }
        println("Получен урон. Текущее здоровье: $health")
    }
}

Теперь внешний код не может сломать состояние персонажа. Он может только «попросить» объект получить урон, вызвав hero.takeDamage(5000). Объект сам обработает этот запрос и аккуратно опустит здоровье ровно до нуля.

Модификатор protected: мост к наследованию

Ранее мы выяснили, что классы могут наследоваться друг от друга. Возникает конфликт: если базовый класс сделал свойство private, то даже его классы-наследники не смогут к нему обратиться.

open class Hero {
    private var baseDamage = 10
}

class Warrior : Hero() {
    fun powerStrike() {
        // ОШИБКА: baseDamage недоступен, он private в Hero
        // println(baseDamage * 2)
    }
}

Для таких случаев существует модификатор protected. Свойство или метод с модификатором protected скрыто от внешнего мира (как private), но остается видимым для всех классов-наследников.

open class Hero {
    protected var baseDamage = 10
}

class Warrior : Hero() {
    fun powerStrike() {
        // Теперь работает! Наследник видит protected-свойство
        val totalDamage = baseDamage * 2
        println("Удар на $totalDamage урона!")
    }
}

fun main() {
    val conan = Warrior()
    // conan.baseDamage = 50 // ОШИБКА: извне protected не видно
}

Модификатор internal: границы модулей

В крупных Android-проектах код часто разбивают на независимые модули (например, модуль работы с сетью, модуль базы данных, модуль UI).

Иногда нужно, чтобы классы внутри одного модуля могли свободно общаться друг с другом, но внешний мир (другие модули) об этих деталях не знал. Для этого используется модификатор internal. Он делает свойство или класс публичным только внутри текущего модуля сборки. Для всех остальных модулей это будет выглядеть как private.

Сводная таблица модификаторов Kotlin

Модификатор Где доступен Когда использовать
public (по умолчанию) Везде Для методов, которые составляют внешний интерфейс объекта (API).
private Только внутри этого же класса Для хранения внутреннего состояния (свойств) и вспомогательных функций. Это ваш выбор по умолчанию для всех данных.
protected Внутри класса и во всех его наследниках Когда вы проектируете базовый класс и хотите дать наследникам доступ к внутренним механизмам.
internal Везде внутри текущего модуля При создании библиотек или многомодульной архитектуры Android-приложения.

Инкапсуляция и чистый код в Android

В Android-разработке инкапсуляция — это фундамент чистой архитектуры.

Представьте экран корзины покупок. У вас есть класс Cart, который хранит список товаров и считает общую сумму. Если список товаров public, разработчик интерфейса (Activity) может напрямую добавить элемент в список, забыв обновить итоговую сумму. Данные рассинхронизируются: на экране будет новый товар, но старая цена.

Скрывая список товаров за private и предоставляя метод addItem(item: Item), вы гарантируете, что при каждом добавлении товара класс Cart самостоятельно пересчитает сумму. Интерфейс (UI) становится глупым и безопасным: он только дергает за ручки методов, а вся сложная бизнес-логика и математика надежно заперта и защищена внутри объектов.

Полиморфизм: один интерфейс, много реализаций

Полиморфизм: один интерфейс, много реализаций

Представьте, что вы пишете логику рассылки уведомлений в приложении. Сначала у вас были только пуш-уведомления. Затем маркетологи попросили добавить email-рассылку, а потом — SMS.

Если решать задачу «в лоб», код отправки быстро превратится в неповоротливого монстра:

fun sendAll(items: List<Any>) {
    for (item in items) {
        if (item is PushNotification) {
            item.sendPush()
        } else if (item is EmailNotification) {
            item.sendEmail()
        } else if (item is SmsNotification) {
            item.sendSms()
        }
    }
}

Каждый раз, когда в приложении появляется новый тип уведомлений, вам придется лезть в эту функцию и дописывать новый else if. В реальном коммерческом проекте таких проверок могут быть десятки, и они будут разбросаны по всему коду. Забыли обновить хотя бы одну — получили баг.

Чтобы разорвать этот порочный круг, в объектно-ориентированном программировании существует полиморфизм.

Переопределение поведения (override)

В прошлой главе мы выяснили, что наследование позволяет вынести общие свойства и методы в базовый класс. Но что делать, если наследники должны выполнять одно и то же действие по-разному?

Пуш-уведомление обращается к серверам Google или Apple, Email требует SMTP-сервера, а SMS отправляется через шлюз сотового оператора. Суть действия одна — «отправить», но реализация кардинально отличается.

Здесь на сцену выходит ключевое слово override (переопределение). В Kotlin, чтобы разрешить наследникам менять поведение метода базового класса, этот метод нужно пометить словом open (точно так же, как мы открывали сам класс).

open class Notification {
    // Открываем метод для переопределения
    open fun send() {
        println("Отправка базового уведомления")
    }
}

class PushNotification : Notification() {
    // Переопределяем метод своей логикой
    override fun send() {
        println("Отправка Push через Firebase...")
    }
}

class EmailNotification : Notification() {
    override fun send() {
        println("Отправка Email через SMTP...")
    }
}

Слово override гарантирует, что мы не просто создали новый метод с похожим именем, а именно заменили реализацию родителя. Теперь у нас есть общий контракт (метод send()), но каждый наследник реализует его по-своему.

Магия восходящего преобразования (Upcasting)

Главная сила наследования не в том, чтобы просто сэкономить пару строк кода. Благодаря правилу «IS-A» (является), мы можем положить объект наследника в переменную типа базового класса.

// Переменная типа Notification, но внутри лежит PushNotification
val myAlert: Notification = PushNotification()

Это называется восходящим преобразованием (upcasting). Компилятор смотрит на переменную myAlert и видит только публичные методы класса Notification. Он знает, что у объекта точно есть метод send(). Но вот как именно он отработает, решается уже во время выполнения программы.

Полиморфизм в действии: избавляемся от проверок

Теперь мы можем собрать все наши объекты в единый список. Типом списка будет базовый класс Notification.

Посмотрим, как преобразится наша функция рассылки:

val notifications: List<Notification> = listOf(
    PushNotification(),
    EmailNotification(),
    PushNotification()
)

fun sendAll(items: List<Notification>) {
    for (item in items) {
        item.send() // Полиморфизм в чистом виде!
    }
}

Нам больше не нужны if или when. Код функции sendAll стал универсальным.

Полиморфизм (от греч. «много форм») — это способность программы работать с объектами разных классов через общий интерфейс (базовый класс), не зная их конкретного типа.

Если завтра маркетологи попросят добавить TelegramNotification, мы просто создадим новый класс, унаследуем его от Notification и переопределим метод send(). Функцию sendAll при этом менять не придется вообще. Она уже умеет работать с любым наследником Notification. Это делает код невероятно гибким и устойчивым к изменениям.

Как это работает под капотом (Динамическая диспетчеризация)

Когда компилятор видит строку item.send(), он не знает, какой именно код будет выполнен. Переменная item имеет тип Notification, но в памяти в этот момент может лежать EmailNotification.

Решение принимается в доли секунды прямо во время работы программы. Этот механизм называется динамической диспетчеризацией (dynamic dispatch).

Виртуальная машина (JVM) берет ссылку на объект, смотрит в оперативную память, определяет его реальный класс и ищет переопределенный метод send() именно в этом классе. Если наследник не переопределил метод, вызовется реализация из базового класса.

Полиморфизм в Android: как рисуется экран

Если вы думаете, что полиморфизм — это просто красивая теория, взгляните на то, как Android отрисовывает интерфейс на экране вашего телефона.

В Android есть базовый класс View, у которого есть метод draw() (нарисовать себя). От View унаследованы все элементы: TextView (текст), Button (кнопка), ImageView (картинка).

Когда операционной системе нужно обновить экран, она не проверяет: «Ага, это кнопка, значит рисуем тень и фон. А это текст, значит рендерим шрифт».

Вместо этого корневой контейнер экрана просто хранит список всех элементов как List<View>. В цикле он пробегается по списку и вызывает у каждого элемента child.draw(). TextView сам знает, как нарисовать буквы, а ImageView — как отрисовать пиксели картинки. Android использует полиморфизм, чтобы управлять миллионами возможных комбинаций интерфейса с помощью одного универсального механизма.

Конструкторы: первичные, вторичные и блоки init

Конструкторы: первичные, вторичные и блоки init

Представьте, что вы создаете объект NetworkClient(val timeout: Int). Что произойдет, если кто-то передаст отрицательное значение, например, -5? Объект успешно создастся, сохранив в памяти некорректные данные, и программа упадет гораздо позже — при попытке выполнить сетевой запрос. Чтобы писать чистый и надежный код, мы должны гарантировать: объект либо создается в полностью корректном состоянии, либо не создается вообще.

Для защиты состояния нам нужно выполнить логику проверок прямо в момент рождения объекта.

Блок init: тело первичного конструктора

Первичный конструктор в Kotlin объявляется прямо в заголовке класса. Это лаконично, но у него есть особенность: в круглых скобках нельзя написать логику (например, if или throw). Это просто список параметров.

Чтобы добавить логику, которая должна выполниться при создании объекта, используется блок init (от слова initialization). Это и есть фактическое «тело» первичного конструктора.

class NetworkClient(val timeout: Int) {

    init {
        if (timeout < 0) {
            throw IllegalArgumentException("Таймаут не может быть отрицательным!")
        }
        println("Сетевой клиент создан с таймаутом $timeout")
    }
}

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

Порядок сборки объекта

В классе может быть несколько свойств и несколько блоков init. Как Kotlin понимает, в каком порядке их запускать?

Правило предельно простое: свойства и блоки init выполняются строго сверху вниз, в том порядке, в котором они написаны в теле класса.

Посмотрите на этот пример:

class Logger(name: String) {
    val formattedName = "[LOG] $name"

    init {
        println("Инициализация логгера: $formattedName")
    }

    val timestamp = System.currentTimeMillis()

    init {
        println("Время создания: $timestamp")
    }
}

Когда вы создаете экземпляр Logger, Kotlin идет по коду как по инструкции:

  1. Выделяет память и принимает параметр name.
  2. Создает свойство formattedName.
  3. Выполняет первый init (ему уже доступно свойство formattedName).
  4. Вычисляет и создает свойство timestamp.
  5. Выполняет второй init.

Если вы попытаетесь использовать timestamp в первом блоке init, компилятор выдаст ошибку, потому что в этот момент времени переменная еще не существует.

Вторичные конструкторы: разные пути к одному результату

Иногда одного способа создать объект недостаточно.

Допустим, у нас есть класс UserProfile, который требует идентификатор и имя пользователя. Но в одной части приложения мы получаем эти данные от сервера, а в другой — пользователь вводит только свой email, из которого мы должны извлечь имя (все, что до символа @) и сгенерировать временный ID.

Для таких случаев существуют вторичные конструкторы (secondary constructors). Они объявляются внутри тела класса с помощью ключевого слова constructor.

class UserProfile(val id: String, val username: String) {

    // Вторичный конструктор
    constructor(email: String) : this(
        id = UUID.randomUUID().toString(),
        username = email.substringBefore("@")
    ) {
        println("Создан профиль на основе email: $email")
    }
}

Обратите внимание на конструкцию : this(...). Это делегирование.

В Kotlin действует жесткое архитектурное правило: если у класса есть первичный конструктор, любой вторичный конструктор обязан вызвать его (напрямую или через другой вторичный конструктор).

Зачем нужно это строгое правило? Ради чистоты кода и предсказуемости. Первичный конструктор — это «бутылочное горлышко», через которое проходят абсолютно все создаваемые объекты. Если у вас есть важная логика валидации в блоке init, вам не нужно дублировать ее во всех вторичных конструкторах. Независимо от того, какой конструктор вызвал программист, Kotlin сначала дойдет до первичного конструктора, выполнит все свойства и блоки init сверху вниз, и только потом выполнит код внутри фигурных скобок вторичного конструктора.

Практика в Android: кастомные View

Вторичные конструкторы — это не просто теоретическая концепция. В Android-разработке вы столкнетесь с ними при создании собственных элементов интерфейса (Custom Views).

Когда вы создаете свою кнопку CustomButton, наследуясь от стандартного Android-класса View, она может быть создана двумя путями:

  1. Из кода программы (тогда передается только Context).
  2. Системой Android при чтении XML-файла разметки (тогда передается Context и набор XML-атрибутов AttributeSet).

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

class CustomButton : View {

    // Вызывается при создании из кода: val btn = CustomButton(context)
    constructor(context: Context) : super(context) {
        setupButton()
    }

    // Вызывается системой при раздувании из XML
    constructor(context: Context, attrs: AttributeSet) : super(context, attrs) {
        setupButton()
        // Здесь можно дополнительно прочитать цвета и отступы из attrs
    }

    private fun setupButton() {
        // Единая логика настройки внешнего вида
    }
}

(Примечание: в Kotlin существует более короткий синтаксис для Android View через аннотацию @JvmOverloads, но под капотом он генерирует именно такую структуру перегруженных конструкторов).

Конструкторы позволяют гибко управлять тем, как объекты появляются на свет, гарантируя, что к моменту вызова первого метода объект уже полностью сформирован, проверен и готов к работе.

Геттеры и сеттеры в Kotlin: кастомные свойства

Геттеры и сеттеры в Kotlin: кастомные свойства

В языках вроде Java инкапсуляция часто превращается в рутину. Чтобы защитить данные, программист делает переменную приватной, а затем пишет два скучных метода: один для чтения (getName()), другой для записи (setName()). В итоге класс обрастает десятками строк шаблонного кода.

Когда вы начали писать на Kotlin, то наверняка заметили, что мы обращаемся к данным напрямую: player.name = "Alex". Выглядит так, будто мы нарушаем главное правило инкапсуляции и обращаемся к памяти напрямую. Но это иллюзия. Kotlin не отказался от инкапсуляции — он сделал её невидимой.

Свойство — это не просто переменная

В Kotlin то, что мы объявляем через val или var внутри класса, называется свойством (property), а не полем (field).

Поле — это просто ячейка в памяти. Свойство — это умная капсула. Когда вы пишете:

class User {
    var name: String = "Alex"
}

Под капотом компилятор Kotlin автоматически создает три вещи:

  1. Скрытое приватное поле в памяти (где реально хранится строка "Alex").
  2. Публичный метод-геттер (чтобы читать значение).
  3. Публичный метод-сеттер (чтобы изменять значение).

Когда в коде активности вы пишете user.name = "Max", вы не записываете данные в память напрямую. Компилятор незаметно подменяет эту строчку на вызов невидимого сеттера. Это значит, что мы можем в любой момент «вскрыть» это свойство и добавить в него свою логику, не меняя код там, где это свойство используется.

Кастомный геттер: вычисляем на лету

Иногда свойству вообще не нужно хранить данные в памяти. Его значение можно вычислить на основе других данных в момент запроса.

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

class UserProfile(val firstName: String, val lastName: String) {

    val fullName: String
        get() {
            return "$firstName $lastName"
        }
}

Здесь fullName — это свойство без собственного места в памяти. Каждый раз, когда вы обращаетесь к user.fullName, Kotlin выполняет блок кода после слова get(). Если firstName изменится, fullName автоматически вернет актуальную склеенную строку.

Кастомный геттер идеален для форматирования данных под UI. Вы храните в классе "сырые" числа, а через геттер отдаете готовую строку вида "Баланс: 150 USD", которую можно сразу передать в TextView.

Кастомный сеттер и ловушка бесконечной рекурсии

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

Допустим, мы пишем кастомный UI-элемент — индикатор прогресса. Его значение должно быть в диапазоне от 0 до 100. Если кто-то попытается передать отрицательное число, мы должны приравнять его к нулю.

Попробуем написать сеттер. Для этого под свойством добавляем блок set(value):

class ProgressBar {
    var progress: Int = 0
        set(value) {
            if (value < 0) {
                progress = 0 // ОШИБКА! Так делать нельзя
            } else {
                progress = value // ОШИБКА!
            }
        }
}

Этот код скомпилируется, но при попытке его выполнить приложение мгновенно упадет. Почему?

Вспомните: любое обращение вида объект.свойство = значение — это вызов сеттера. Когда внутри самого сеттера мы пишем progress = 0, мы снова вызываем этот же самый сеттер. Тот снова доходит до этой строки и вызывает себя еще раз. Возникает бесконечная рекурсия, которая переполняет стек вызовов (StackOverflowError).

Backing field: тайная комната

Чтобы разорвать этот бесконечный цикл, нам нужно как-то обратиться к той самой «физической ячейке памяти», минуя вызов сеттера. Для этого в Kotlin внутри геттеров и сеттеров доступно специальное ключевое слово field (backing field — теневое поле).

field — это прямой доступ к памяти свойства. Правильный код выглядит так:

class ProgressBar {
    var progress: Int = 0
        set(value) {
            if (value < 0) {
                field = 0
            } else if (value > 100) {
                field = 100
            } else {
                field = value
            }
        }
}

Теперь, если в активности мы напишем progressBar.progress = 150, вызовется наш сеттер. Он проверит условие value>100value > 100 и тихо запишет в реальную память (field) значение 100. Никакой рекурсии.

Разделение прав доступа: private set

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

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

Решение кроется в том, что у геттера и сеттера могут быть разные модификаторы доступа. Мы можем сделать сеттер приватным:

class GameSession {
    // Читать могут все, изменять - только методы внутри GameSession
    var score: Int = 0
        private set

    fun addPoints(points: Int) {
        if (points > 0) {
            score += points // Внутри класса мы можем менять свойство
        }
    }
}

Здесь мы не пишем кастомную логику внутри сеттера, мы просто вешаем на него "замок" private. Теперь код game.score = 10 в Activity вызовет ошибку компиляции, но чтение val current = game.score сработает отлично. Мы добились идеальной инкапсуляции: состояние надежно защищено, но доступно для отображения на экране.

Абстрактные классы против интерфейсов

Абстрактные классы против интерфейсов

Когда мы настраивали динамическую диспетчеризацию для уведомлений, базовым типом выступал обычный класс Notification. С точки зрения архитектуры это создает уязвимость: любой разработчик может написать val n = Notification() и попытаться отправить это «базовое» уведомление. Но как оно выглядит? Куда отправляется? Базовое уведомление — это абстракция, концепция в нашей голове. В реальности существуют только конкретные Push-уведомления или Email-письма.

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

Абстрактные классы: чертежи для чертежей

Чтобы запретить создание объектов базового класса, его нужно объявить абстрактным с помощью ключевого слова abstract.

Абстрактный класс — это незавершенный проект. Он может содержать готовую логику (обычные методы и свойства), но некоторые его части намеренно оставлены пустыми. Эти пустые части помечаются тем же словом abstract и называются абстрактными методами или свойствами.

abstract class Notification(val id: String) {
    // Готовое состояние (свойство)
    var isDelivered: Boolean = false
        protected set

    // Готовое поведение (метод)
    fun markAsDelivered() {
        isDelivered = true
        println("Уведомление $id доставлено")
    }

    // Абстрактное поведение (нет тела метода)
    abstract fun send()
}

Ключевое слово abstract автоматически делает класс и его абстрактные члены открытыми для наследования (как если бы мы написами open), поэтому писать open abstract не нужно.

Если класс-наследник хочет стать полноценным (конкретным) классом, объекты которого можно создавать в памяти, он обязан переопределить все абстрактные методы родителя. Компилятор строго за этим следит.

class PushNotification(id: String, val deviceToken: String) : Notification(id) {
    // Обязаны реализовать абстрактный метод
    override fun send() {
        println("Отправка пуша на токен $deviceToken")
        markAsDelivered() // Используем готовый метод родителя
    }
}

Теперь код val n = Notification("123") вызовет ошибку компиляции. Мы защитили инвариант системы: абстрактные концепции не должны существовать в виде реальных объектов.

Проблема ромба и предел наследования

Абстрактные классы отлично решают задачу выделения общего состояния. Но у наследования классов в Kotlin (как и в Java, C#) есть жесткое ограничение: класс может иметь только одного прямого родителя.

Почему нельзя унаследоваться сразу от двух классов? Представьте, что у нас есть класс Scanner с методом start() и класс Printer с методом start(). Если мы попытаемся создать класс Copier, унаследовав его от обоих, компилятор столкнется с неразрешимой дилеммой.

Эта ситуация в информатике называется «Проблемой ромба» (Diamond Problem). Если мы вызовем copier.start(), какую именно реализацию должен запустить процессор — ту, что сканирует, или ту, что печатает? Чтобы избежать этой путаницы на уровне выделения памяти и таблиц виртуальных методов, множественное наследование классов с состоянием запрещено.

Но что делать, если нашему PushNotification нужно быть не только уведомлением, но и, например, элементом, который можно логировать в аналитику, и объектом, который можно сериализовать в JSON? Здесь на сцену выходят интерфейсы.

Интерфейсы: контракты без состояния

Интерфейс (interface) — это чистый контракт поведения. Он описывает, что объект умеет делать, но не описывает, как он хранит данные для этого.

Главное техническое отличие интерфейса от абстрактного класса: интерфейс не может хранить состояние. В нем запрещены теневые поля (backing fields). Вы не можете положить в интерфейс переменную, которая будет занимать место в памяти объекта.

interface Loggable {
    // Можно: абстрактный метод
    fun logEvent(eventName: String)

    // Можно: метод с реализацией по умолчанию (но без использования свойств с состоянием)
    fun logError(error: String) {
        println("ERROR: $error")
    }

    // ОШИБКА: Интерфейс не может хранить состояние (backing field запрещен)
    // val logCount: Int = 0
}

Поскольку интерфейсы не выделяют память под свойства и не имеют конструкторов, проблема ромба исчезает. Если класс реализует два интерфейса с одинаковым методом, компилятор просто заставит вас написать одну конкретную реализацию этого метода в самом классе, разрешив конфликт.

Благодаря этому, класс в Kotlin может наследоваться от одного класса, но реализовывать бесконечное множество интерфейсов.

// Наследуем один класс (Notification) и реализуем два интерфейса
class EmailNotification(id: String, val email: String) : Notification(id), Loggable, Retryable {

    override fun send() {
        println("Отправка email на $email")
    }

    override fun logEvent(eventName: String) {
        println("Лог: $eventName для email $email")
    }

    override fun retry() {
        println("Повторная попытка отправки...")
    }
}

IS-A против CAN-DO: как выбирать

Выбор между абстрактным классом и интерфейсом — это не вопрос вкуса, а фундаментальное архитектурное решение. Оно базируется на двух разных типах отношений в объектно-ориентированном мире.

Абстрактный класс — это отношение IS-A («Является»). Он определяет ядро сущности, ее фундаментальную природу. PushNotification является уведомлением. У них общее ДНК, общее состояние (например, ID и статус доставки). Сущность не может быть одновременно Уведомлением и Пользователем.

Интерфейс — это отношение CAN-DO («Умеет делать» / «Играет роль»). Он определяет способности или роли, которые объект может на себя примерить. EmailNotification умеет логироваться (Loggable) и умеет повторять попытку (Retryable). Роли можно комбинировать как угодно. Пользователь тоже может быть Loggable, хотя у него нет ничего общего с уведомлениями.

Характеристика Абстрактный класс (abstract class) Интерфейс (interface)
Суть Кем объект является (идентичность) Что объект умеет (способность)
Состояние (память) Может иметь свойства с backing field Строго без состояния (нет backing field)
Конструктор Есть (первичные, вторичные, init) Нет конструктора
Лимит для наследника Только один базовый класс Любое количество интерфейсов

В Android-разработке это разделение видно повсеместно. Например, класс TextView (текст на экране) наследуется от базового класса View. Это отношение IS-A: TextView является элементом экрана, он наследует огромный пласт состояния (координаты xx и yy, ширину, высоту). Но при этом TextView реализует интерфейсы OnClickListener (умеет реагировать на клик) и OnLongClickListener (умеет реагировать на долгое нажатие). Это отношение CAN-DO. Кликнуть можно не только по View, но и, например, по уведомлению в шторке операционной системы, поэтому реакция на клик вынесена в независимый интерфейс.

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

Data-классы: идеальные контейнеры для данных в Android

Data-классы: идеальные контейнеры для данных в Android

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

class UserProfile(val id: Int, val name: String)

val user1 = UserProfile(1, "Alice")
val user2 = UserProfile(1, "Alice")

println(user1 == user2) // Выведет: false

Данные абсолютно идентичны, но программа утверждает, что объекты разные. Это происходит потому, что обычные классы по умолчанию не знают, как сравнивать свое содержимое. Они сравнивают адреса в памяти.

Ссылочная и структурная эквивалентность

В Kotlin существуют два типа равенства, для которых используются разные операторы:

  1. Ссылочная эквивалентность (===): проверяет, указывают ли две переменные на один и тот же физический объект в памяти.
  2. Структурная эквивалентность (==): проверяет, равны ли данные внутри объектов. Под капотом оператор == вызывает метод equals().

У обычных классов метод equals() реализован примитивно: он просто вызывает ===. Поэтому для обычного класса user1 == user2 означает «находятся ли они по одному адресу?». Поскольку мы вызывали конструктор дважды, в памяти создалось два независимых объекта.

Чтобы научить класс сравнивать свойства (idid с idid, namename с namename), в классическом ООП (например, в Java) разработчику приходилось вручную переопределять методы equals() и hashCode(). Это десятки строк шаблонного кода для каждого класса, хранящего данные.

В Kotlin эта проблема решается добавлением одного слова.

Магия слова data

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

data class UserProfile(val id: Int, val name: String)

val user1 = UserProfile(1, "Alice")
val user2 = UserProfile(1, "Alice")

println(user1 == user2) // Теперь выведет: true

Что именно компилятор создает за кулисами для data class:

  • equals(): сравнивает значения всех свойств из первичного конструктора.
  • hashCode(): генерирует уникальный числовой код на основе свойств (необходимо для корректной работы коллекций, таких как HashSet или HashMap).
  • toString(): формирует читаемую строку. Если обычный класс при печати выдаст что-то вроде UserProfile@2f4d3709, то data-класс вернет понятное UserProfile(id=1, name=Alice).
  • copy(): метод для клонирования объекта с возможностью изменения части свойств.
  • componentN(): функции для деструктуризации (разбора объекта на переменные).

Неизменяемость (Immutability) и метод copy()

В data-классах можно использовать как val (только для чтения), так и var (изменяемые свойства). Однако золотым стандартом чистой архитектуры является неизменяемость.

Если состояние объекта можно изменить из любой точки программы (используя var), отследить причину багов в UI становится крайне сложно. Представьте, что фоновый поток изменил имя пользователя, а UI-поток об этом не узнал и не перерисовал экран.

Поэтому свойства в data-классах почти всегда объявляют через val. Но как тогда обновить данные? Через создание нового объекта на основе старого с помощью метода copy().

data class UserProfile(val id: Int, val name: String, val status: String)

val currentUser = UserProfile(1, "Alice", "Offline")

// Создаем копию, меняя только статус. id и name скопируются из currentUser
val onlineUser = currentUser.copy(status = "Online")

Метод copy() принимает именованные аргументы. Вы указываете только то, что должно измениться. Остальные данные копируются как есть. Исходный объект currentUser остается нетронутым, что гарантирует безопасность данных при многопоточной работе.

Деструктуризация: распаковка данных

Часто бывает нужно извлечь данные из объекта в отдельные переменные. Data-классы поддерживают синтаксис деструктуризации благодаря автоматически сгенерированным методам component1(), component2() и так далее.

val user = UserProfile(42, "Bob", "Online")

// Распаковываем объект в три новые переменные
val (userId, userName, userStatus) = user

println("ID: $userId, Имя: $userName")

Порядок переменных при распаковке строго соответствует порядку свойств в первичном конструкторе. Если какое-то свойство вам не нужно, его можно пропустить, используя символ подчеркивания _:

val (_, justName, _) = user

Ограничения и "слепые зоны" data-классов

Чтобы компилятор мог безопасно генерировать методы, на data-классы наложены жесткие ограничения:

  1. Первичный конструктор должен содержать хотя бы один параметр.
  2. Все параметры первичного конструктора должны быть отмечены как val или var.
  3. Data-класс не может быть abstract, open, sealed или inner (он не может выступать базовым классом для наследования).

Самая частая ошибка новичков — непонимание того, какие данные участвуют в сравнении. Компилятор учитывает только те свойства, которые объявлены в первичном конструкторе. Свойства, объявленные в теле класса, игнорируются методами equals(), hashCode(), toString() и copy().

data class Product(val id: Int, val title: String) {
    var price: Double = 0.0
}

val p1 = Product(1, "Книга")
p1.price = 500.0

val p2 = Product(1, "Книга")
p2.price = 999.0

// Вернет true, потому что id и title совпадают.
// Разница в price игнорируется!
println(p1 == p2)

Если свойство влияет на логическую уникальность объекта, оно обязано находиться в первичном конструкторе. В теле класса стоит оставлять только вспомогательные данные (например, флаги загрузки изображений, которые не влияют на суть самих данных).

Почему это идеальные контейнеры для Android?

В современной Android-разработке (особенно в архитектуре MVVM, которую мы разберем позже) интерфейс строится на реактивном наблюдении за состояниями.

Экран получает объект состояния и отрисовывает его. Когда данные меняются, экран получает новый объект состояния. Чтобы Android понял, нужно ли тратить ресурсы на перерисовку, он сравнивает старое состояние с новым.

Инструменты вроде DiffUtil (для списков RecyclerView) или StateFlow (для управления состоянием) опираются именно на структурную эквивалентность (метод equals). Использование data-классов делает эту интеграцию бесшовной: вы просто передаете данные, а система сама понимает, изменились они или нет, сравнивая их содержимое под капотом.

Singleton и объекты-компаньоны (companion object)

Singleton и объекты-компаньоны (companion object)

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

Нам нужен механизм, который гарантирует: в любой момент времени во всем приложении существует строго один экземпляр этого класса. В объектно-ориентированном программировании эта задача решается паттерном Singleton (Одиночка).

Паттерн Singleton и ключевое слово object

В классических языках вроде Java реализация Singleton требует написания шаблонного кода: нужно скрыть конструктор (сделать его private), создать статическую переменную для хранения единственного экземпляра и написать метод для доступа к ней, параллельно решая проблемы потокобезопасности.

Kotlin берет эту архитектурную задачу на себя. Чтобы создать Singleton, достаточно заменить слово class на слово object:

object AnalyticsTracker {
    var sessionCount: Int = 0

    fun trackEvent(eventName: String) {
        println("Отправка события: $eventName. Сессия: $sessionCount")
    }
}

Что здесь произошло? Мы одновременно объявили тип данных AnalyticsTracker и сразу же создали его единственный экземпляр. У object не может быть конструктора, потому что мы не создаем объекты этого типа вручную.

Чтобы обратиться к свойствам или методам, мы используем имя самого объекта:

AnalyticsTracker.sessionCount = 1
AnalyticsTracker.trackEvent("User_Login")

Под капотом Kotlin гарантирует две вещи:

  1. Ленивая инициализация (Lazy initialization): Объект AnalyticsTracker не будет создан в памяти при запуске приложения. Он создастся ровно в тот момент, когда вы впервые к нему обратитесь.
  2. Потокобезопасность: Если два разных экрана (работающих в разных потоках) одновременно попытаются отправить событие в первую миллисекунду работы, Kotlin безопасно создаст только один экземпляр.

Если мы попытаемся сохранить ссылку на этот объект в разные переменные и сравним их по ссылке (оператор ===, который мы разбирали в теме data-классов), мы увидим, что они указывают на один и тот же участок памяти:

val tracker1 = AnalyticsTracker
val tracker2 = AnalyticsTracker

// Вернет true, так как tracker1 и tracker2 — это один и тот же объект
println(tracker1 === tracker2)

Проблема отсутствия static

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

Например, класс User. Мы хотим создавать сотни пользователей. Но у нас есть константа MAX_AGE, равная 120. Если мы положим ее внутрь класса как обычное свойство, каждый из сотен объектов User будет хранить свою копию этой константы в памяти. Это расточительно.

В других языках для этого используется ключевое слово static — оно отвязывает свойство или метод от конкретного экземпляра и привязывает его к самому классу. В Kotlin нет ключевого слова static.

Вместо этого Kotlin предлагает более элегантное и мощное ООП-решение: объекты-компаньоны.

Companion object: компаньон класса

Объект-компаньон — это Singleton, который физически вложен внутрь обычного класса и тесно с ним связан. Он объявляется с помощью ключевых слов companion object:

class User(val name: String, val age: Int) {

    // Этот блок — Singleton, привязанный к классу User
    companion object {
        const val MAX_AGE = 120

        fun isValidAge(age: Int): Boolean {
            return age in 0..MAX_AGE
        }
    }
}

Теперь мы можем обращаться к MAX_AGE и методу isValidAge, используя имя класса User, точно так же, как мы обращались к статическим методам в других языках:

val ageToTest = 125
if (!User.isValidAge(ageToTest)) {
    println("Возраст не может превышать ${User.MAX_AGE}")
}

Важно понимать разницу:

  • User("Alice", 25) создает экземпляр класса. У него есть свои name и age.
  • User.MAX_AGE обращается к объекту-компаньону. Экземпляр User для этого не нужен.

Фабричные методы (Factory Methods)

Одно из самых частых применений companion object в Android — это паттерн «Фабричный метод».

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

Компаньон решает эту проблему, предоставляя методы с понятными именами, которые возвращают экземпляр класса:

class User private constructor(val name: String, val isGuest: Boolean) {

    companion object {
        // Фабричный метод для обычного пользователя
        fun createRegistered(name: String): User {
            return User(name, false)
        }

        // Фабричный метод для гостя
        fun createGuest(): User {
            return User("Guest_${System.currentTimeMillis()}", true)
        }
    }
}

Обратите внимание: мы сделали первичный конструктор private. Теперь никто не может создать User напрямую через User(). Единственный способ получить объект — попросить компаньона:

val myUser = User.createRegistered("Bob")
val guest = User.createGuest()

Это делает код невероятно читаемым и защищает инвариант класса.

Почему companion object лучше, чем просто static?

Может показаться, что companion object — это просто усложненный синтаксис для статических методов. Но это не так. В отличие от static, компаньон является настоящим объектом.

А раз это настоящий объект, он может наследовать классы и реализовывать интерфейсы.

interface UserFactory {
    fun createDefault(): User
}

class User(val name: String) {
    // Компаньон реализует интерфейс!
    companion object : UserFactory {
        override fun createDefault(): User {
            return User("Unknown")
        }
    }
}

Теперь мы можем передать User (а точнее, его компаньон) в любую функцию, которая ожидает UserFactory. Статические методы в Java так не умеют — они не участвуют в полиморфизме.

Осторожно: Singleton и Android

Singleton (как object, так и companion object) живет в памяти с момента первого обращения и до полного уничтожения процесса приложения операционной системой.

Это порождает главное правило чистого кода в Android: никогда не сохраняйте в Singleton ссылки на элементы интерфейса (View) или экраны (Activity).

Если ваш object AnalyticsTracker сохранит ссылку на кнопку или экран, чтобы прочитать с них данные, сборщик мусора не сможет удалить этот экран из памяти после того, как пользователь его закроет. Экран останется висеть в памяти навсегда, потому что глобальный Singleton продолжает держать на него ссылку. Это называется утечкой памяти (Memory Leak), и мы подробно разберем механику этого процесса в главе про Контекст (Context).

Пока же запомните простое разделение:

  • Используйте object для глобальных сервисов без состояния UI (сеть, аналитика, утилиты).
  • Используйте companion object для констант и фабричных методов, тесно связанных с конкретным классом.

Перечисления (Enum) и Sealed-классы для управления состояниями экрана

Перечисления (Enum) и Sealed-классы для управления состояниями экрана

Представьте, что вы пишете экран профиля пользователя. Данные загружаются из сети, поэтому у экрана есть три логических состояния: загрузка, успешное отображение данных и ошибка сети. Как сохранить текущее состояние в коде?

Можно использовать обычную строку: var state = "LOADING". Но стоит опечататься и написать "LOADIN", как логика сломается, а компилятор этого не заметит. Можно использовать числа: val STATE_ERROR = 2. Но код быстро превратится в нечитаемый набор «магических чисел». Нам нужен строгий контракт, который не позволит создать несуществующее состояние и заставит обработать все возможные сценарии.

Enum: Жесткий список вариантов

Когда у сущности есть строго ограниченный набор вариантов, в ООП используют перечисления — enum class. Это класс, который содержит фиксированный список своих экземпляров.

enum class ScreenState {
    LOADING,
    SUCCESS,
    ERROR
}

Теперь переменная может принимать только одно из этих трех значений. Никаких опечаток или неожиданных чисел.

Главная сила enum в Kotlin раскрывается при использовании оператора when. Компилятор «знает» все возможные значения перечисления. Если вы проверяете состояние, он заставит вас написать исчерпывающий (exhaustive) код:

fun renderUi(state: ScreenState) {
    when (state) {
        ScreenState.LOADING -> showProgressBar()
        ScreenState.SUCCESS -> showProfile()
        ScreenState.ERROR -> showErrorScreen()
        // Ветка else не нужна! Компилятор знает, что других вариантов нет.
    }
}

Если завтра вы добавите в enum новое состояние EMPTY (пустой профиль), код перестанет компилироваться. Среда разработки подсветит when красным и потребует добавить обработку нового состояния. Это спасает от десятков багов при расширении функционала.

Проблема Enum: Нехватка гибкости

enum отлично работает как простой флаг. Но давайте посмотрим на наш экран профиля внимательнее. Состояние SUCCESS должно не просто сигнализировать об успехе, оно должно содержать сами данные пользователя (имя, аватарку). Состояние ERROR должно содержать текст ошибки или исключение. А вот LOADING никаких дополнительных данных не требует.

Попробуем добавить данные в наш enum:

enum class ScreenState(val data: UserProfile?, val errorMessage: String?) {
    LOADING(null, null),
    SUCCESS(UserProfile("Иван"), null),
    ERROR(null, "Нет подключения к сети")
}

Это выглядит ужасно. Почему? Потому что enum class — это обычный класс, а LOADING, SUCCESS и ERROR — это просто три его объекта (по сути, Singleton-ы). У них одинаковая структура. Если мы добавляем свойство data в класс, оно появляется у всех вариантов, даже если им не нужно. Нам приходится заполнять память null-значениями и усложнять логику проверок.

Нам нужен инструмент, который сохранит строгость enum (исчерпывающий when), но позволит каждому состоянию иметь свою уникальную структуру данных.

Sealed-классы: Иерархия с закрытым клубом

Для таких задач в Kotlin существуют изолированные классы — sealed class (или sealed interface).

Слово «sealed» переводится как «запечатанный». Это базовый класс, который знает всех своих прямых наследников на этапе компиляции. Никто не может создать класс-наследник за пределами того файла, где объявлен сам sealed class.

Поскольку наследники — это полноценные независимые классы, они могут иметь совершенно разную структуру:

sealed class UiState {
    // Состояние загрузки не требует данных.
    // Используем object (Singleton), чтобы не плодить экземпляры в памяти.
    object Loading : UiState()

    // Состояние успеха требует данных.
    // Используем data class для хранения профиля.
    data class Success(val user: UserProfile) : UiState()

    // Состояние ошибки требует текст.
    // Снова data class, но с другой структурой.
    data class Error(val message: String) : UiState()
}

Мы создали идеальный контейнер для состояния экрана. UiState выступает общим типом (полиморфизм в действии), но скрывает под собой совершенно разные по наполнению объекты.

Умное приведение типов (Smart Cast)

Посмотрим, как теперь выглядит функция отрисовки UI. Компилятор по-прежнему заставляет нас обработать все варианты в when, потому что класс «запечатан» и новых наследников появиться не может.

Но происходит еще одна магия Kotlin — Smart Cast (умное приведение типов):

fun renderUi(state: UiState) {
    when (state) {
        is UiState.Loading -> {
            // Здесь state - это просто объект Loading
            showProgressBar()
        }
        is UiState.Success -> {
            // Smart Cast: компилятор понял, что мы внутри ветки Success.
            // Теперь у переменной state автоматически появились свойства Success!
            val userName = state.user.name
            showProfile(userName)
        }
        is UiState.Error -> {
            // Smart Cast: здесь state автоматически стал типом Error
            showErrorScreen(state.message)
        }
    }
}

Обратите внимание на ключевое слово is. В enum мы проверяли точное совпадение значений (state == ScreenState.SUCCESS). В sealed class мы проверяем принадлежность к типу (state is UiState.Success), потому что Success — это класс, экземпляров которого может быть создано множество (с разными данными).

Что выбрать: Enum или Sealed?

Оба инструмента ограничивают свободу ради безопасности кода, но делают это на разных уровнях.

Характеристика enum class sealed class
Что ограничивает Количество конкретных объектов (экземпляров). Количество возможных типов (наследников).
Структура данных Строго одинаковая для всех вариантов. Уникальная для каждого варианта.
Сравнение в when По значению (без is). По типу данных (через is).
Когда использовать Простые флаги, дни недели, типы сортировки, темы (светлая/темная). Состояния UI, результаты сетевых запросов, события аналитики с разными параметрами.

Архитектура современных Android-приложений строится на реактивном подходе: бизнес-логика вычисляет новое состояние экрана и упаковывает его в sealed class, а UI-слой (Activity или Fragment) просто «слушает» эти изменения и перерисовывает себя через исчерпывающий when. Это делает код предсказуемым и защищает от ситуаций, когда на экране одновременно крутится лоадер и отображается текст ошибки.

Делегирование свойств: lazy и Observable

Делегирование свойств: lazy и Observable

Создание кастомных геттеров и сеттеров позволяет гибко управлять доступом к памяти объекта: проверять входящие данные, вычислять значения на лету или логировать изменения. Но что, если одну и ту же логику доступа нужно применить к десятку разных свойств в разных классах? Писать одинаковые сеттеры каждый раз — значит нарушать принцип DRY (Don't Repeat Yourself) и раздувать код.

Kotlin предлагает элегантное решение: делегирование свойств. Вместо того чтобы писать логику чтения и записи внутри самого класса, мы можем передать (делегировать) эту задачу специальному объекту-помощнику.

Ключевое слово by: как работает делегирование

Синтаксически делегирование выражается через ключевое слово by (от англ. «посредством», «через»).

class UserProfile {
    var username: String by DelegateObject()
}

Когда мы пишем by, компилятор делает скрытую работу. Он убирает у свойства username стандартное теневое поле (backing field) и привязывает к нему объект DelegateObject. Теперь каждый раз, когда код запрашивает user.username, компилятор незаметно вызывает метод getValue() у делегата. А при попытке записать user.username = "Alex" — вызывает метод setValue().

Свойство становится просто «фасадом». Вся реальная работа с памятью и дополнительная логика скрыты внутри объекта-делегата.

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

Отложенная инициализация с помощью lazy

Представьте класс, который при создании должен загрузить тяжелую конфигурацию из файла, установить соединение с базой данных или выделить большой объем памяти.

class AnalyticsEngine {
    // Тяжелая операция: создание занимает 2-3 секунды
    val database: DatabaseConnection = DatabaseConnection()

    fun logEvent(event: String) {
        database.save(event)
    }
}

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

Делегат lazy (ленивый) решает эту проблему. Он позволяет отложить инициализацию свойства до момента, когда к нему впервые обратятся.

class AnalyticsEngine {
    // Инициализация отложена!
    val database: DatabaseConnection by lazy {
        println("Подключение к БД...")
        DatabaseConnection() // Результат лямбды сохранится
    }

    fun logEvent(event: String) {
        database.save(event) // Только здесь сработает лямбда
    }
}

Как это работает под капотом:

  1. При создании AnalyticsEngine свойство database не инициализируется. В памяти создается только объект-делегат lazy, который хранит переданную ему лямбду.
  2. При первом вызове database.save(...) делегат выполняет код внутри лямбды, получает готовый объект DatabaseConnection и сохраняет его в свой внутренний кэш.
  3. При втором и всех последующих обращениях лямбда больше не выполняется. Делегат просто отдает сохраненный в кэше объект.

По умолчанию делегат lazy потокобезопасен (thread-safe). Если два фоновых потока одновременно попытаются впервые обратиться к свойству, Kotlin гарантирует, что лямбда инициализации выполнится строго один раз, а оба потока получат один и тот же экземпляр объекта.

Обратите внимание: lazy работает только со свойствами val (неизменяемыми). Логика ленивой инициализации подразумевает, что значение вычисляется один раз и больше никогда не меняется.

Реакция на изменения: Delegates.observable

Если lazy управляет чтением (val), то для управления записью (var) существуют другие делегаты. Часто в UI-разработке нужно выполнить какое-то действие сразу после того, как значение свойства изменилось — например, обновить экран или отправить лог.

Вместо того чтобы писать громоздкий кастомный сеттер, мы используем Delegates.observable().

import kotlin.properties.Delegates

class AudioPlayer {
    var volume: Int by Delegates.observable(50) { property, oldValue, newValue ->
        println("Громкость изменилась с $oldValue на $newValue")
        // Здесь можно вызвать метод обновления UI
    }
}

Функция observable принимает два аргумента:

  1. Начальное значение свойства (в нашем случае 50).
  2. Лямбду (обработчик), которая будет автоматически вызываться после каждого присваивания нового значения.

В лямбду передаются три параметра: само свойство (содержит его метаданные, например, имя), старое значение и новое значение. Теперь любое изменение громкости, откуда бы оно ни произошло (player.volume = 70), гарантированно вызовет наш обработчик. Нам не нужно расставлять вызовы updateUI() по всему коду.

Перехват и отмена: Delegates.vetoable

observable реагирует на изменение постфактум — когда новое значение уже записано в память. Но иногда изменение нужно предотвратить, если оно нарушает инвариант объекта (например, громкость не может быть отрицательной).

Для этого существует делегат Delegates.vetoable(). Он работает так же, как observable, но его лямбда вызывается до записи значения и должна вернуть логическое значение (Boolean).

class AudioPlayer {
    var volume: Int by Delegates.vetoable(50) { _, oldValue, newValue ->
        val isValid = newValue in 0..100
        if (!isValid) {
            println("Ошибка: недопустимая громкость $newValue")
        }
        isValid // Если true - значение запишется. Если false - будет отброшено.
    }
}

Если мы попытаемся выполнить player.volume = 150, лямбда вернет false. Делегат наложит «вето» на операцию, и свойство volume сохранит свое предыдущее значение. Это мощный инструмент инкапсуляции, который защищает состояние объекта без написания сложных if/else в сеттерах.

Делегирование свойств смещает фокус с того, как хранятся данные, на то, как они себя ведут. Понимание by lazy и паттерна делегирования — это фундамент, на котором строятся многие современные инструменты Android, включая инъекцию зависимостей и управление состоянием экранов.

Жизненный цикл Activity и Fragment как пример наследования

Жизненный цикл Activity и Fragment как пример наследования

Если вы когда-либо писали простые консольные программы, то знаете: выполнение кода всегда начинается с функции main(). Вы сами решаете, когда создать объект, когда вызвать его метод и когда завершить программу. Вы полностью контролируете поток выполнения.

Но в Android приложениях функции main() нет. Вы не пишете код, который запускает приложение. Вместо этого вы создаете классы (например, MainActivity), а операционная система Android сама решает, когда создать экземпляр вашего класса и когда его уничтожить.

Этот архитектурный подход называется инверсией контроля (Inversion of Control). Вы отдаете руль операционной системе, а она дергает за ниточки ваших объектов. И главный инструмент, через который ОС общается с вашими объектами — это механизм наследования.

Activity: гигантский базовый класс

Когда вы создаете новый экран в Android, вы пишете примерно такой код:

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

Здесь MainActivity — это ваш класс-наследник. А AppCompatActivity — это огромный базовый класс, написанный инженерами Google. Он содержит тысячи строк кода, которые умеют рисовать окно на экране телефона, обрабатывать нажатия системной кнопки «Назад» и общаться с ядром Android.

Операционная система ничего не знает про ваш MainActivity. Она умеет работать только с базовым типом Activity. Благодаря полиморфизму (который мы разбирали ранее), ОС создает объект вашего класса, но обращается с ним так, как будто это стандартная базовая Activity.

Жизненный цикл как контракт поведения

Поскольку ОС сама создает и уничтожает вашу Activity (например, когда пользователь сворачивает приложение или поворачивает экран), ей нужно как-то сообщать вашему объекту: «Эй, я тебя только что создала, настрой свой интерфейс!» или «Пользователь ушел на другой экран, останови воспроизведение видео!».

Для этого в базовом классе Activity определены специальные методы — коллбэки (callbacks). Это пустые или полупустые методы, которые ОС вызывает в строго определенные моменты времени.

Переопределяя (override) эти методы в своем классе-наследнике, вы встраиваете свою уникальную логику в стандартный конвейер операционной системы.

Почему мы всегда вызываем super?

Обратите внимание на первую строку внутри переопределенного метода:

super.onCreate(savedInstanceState)

Мы обращаемся к реализации метода onCreate в родительском классе. Зачем? Потому что базовый класс AppCompatActivity должен выполнить свою критически важную работу: привязать объект к системному окну, подготовить внутренние структуры данных и восстановить сохраненное состояние.

Если вы не вызовете super.onCreate(), ваш класс-наследник попытается работать, в то время как его базовый «фундамент» не инициализирован.

Три пары состояний: симметрия жизненного цикла

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

Создание (Вход) Уничтожение (Выход) За что отвечает пара
onCreate() onDestroy() Память и базовые объекты. Вызывается один раз. Здесь мы раздуваем XML через setContentView и создаем объекты. В конце ОС навсегда удаляет объект из памяти.
onStart() onStop() Видимость на экране. Экран становится виден пользователю (даже если перекрыт системным диалогом). Здесь логично запускать анимации.
onResume() onPause() Фокус ввода. Пользователь может нажимать на кнопки именно на этом экране. Если поверх открылось всплывающее окно — Activity ставится на паузу.

Понимание того, что эти методы — просто унаследованные функции, которые кто-то вызывает снаружи, снимает всю магию с Android-разработки.

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

Fragment: матрешка внутри Activity

Исторически экраны телефонов росли, появились планшеты, и держать всю логику сложного экрана в одном классе Activity стало невозможно — класс разрастался до десятков тысяч строк кода.

Инженеры Android применили тот же самый ООП-подход и создали класс Fragment. Фрагмент — это кусок пользовательского интерфейса с собственной логикой, который живет внутри Activity.

С точки зрения ООП, Fragment — это просто еще один базовый класс. Вы точно так же наследуетесь от него:

class ProfileFragment : Fragment() {
    override fun onCreateView(
        inflater: LayoutInflater,
        container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        // Возвращаем UI-объект, который фрагмент должен показать
        return inflater.inflate(R.layout.fragment_profile, container, false)
    }
}

Разница лишь в том, кто управляет жизненным циклом.

  • Методы жизненного цикла Activity вызывает операционная система Android.
  • Методы жизненного цикла Fragment вызывает его родительская Activity.

Когда ОС вызывает onStart() у Activity, эта Activity пробегается по списку всех своих фрагментов и вызывает onStart() у каждого из них. Это классическая делегация обязанностей.

У фрагмента есть специфичный метод onCreateView(). В отличие от Activity, которая сама устанавливает свой контент через setContentView(), фрагмент должен вернуть объект View своему владельцу (Activity), чтобы та встроила его в свой макет.

Подводные камни инверсии контроля

Поскольку мы не управляем созданием объектов Activity и Fragment, мы сталкиваемся с главной проблемой Android-разработки: уничтожением состояния при конфигурационных изменениях.

Если вы повернете телефон на 90 градусов, ОС Android решит, что интерфейс нужно перерисовать под новые пропорции. Как она это делает? Она безжалостно вызывает onDestroy() у вашей текущей Activity, убивая объект в памяти, а затем создает абсолютно новый экземпляр MainActivity и вызывает у него onCreate().

Все переменные, которые вы хранили внутри старого объекта MainActivity (например, счетчик кликов или скачанный из сети текст), будут уничтожены вместе с ним.

Попытка бороться с этим, сохраняя данные в статических переменных (Singleton), неминуемо приведет к утечкам памяти, так как старые объекты интерфейса зависнут в оперативной памяти навсегда. Как правильно разделять данные и объекты экрана с помощью архитектуры MVVM, мы подробно разберем в следующих главах.

Обработка кликов через интерфейсы и лямбда-выражения

Обработка кликов через интерфейсы и лямбда-выражения

Кнопка на экране смартфона — это просто закрашенный прямоугольник. Когда пользователь касается стекла, операционная система фиксирует лишь координаты нажатия (xx, yy) и передаёт их приложению. Класс Button был написан инженерами Google много лет назад, и он понятия не имеет, что в вашем приложении при нажатии на эту кнопку нужно вызвать метод loginUser().

Как передать специфичное поведение внутрь универсального компонента? Эта задача решается через механизм обратного вызова (коллбэка), где главным архитектурным мостом выступают интерфейсы.

Интерфейс как контракт на поведение

Чтобы кнопка могла вызвать ваш код, ей нужна точка опоры. Она не может принять объект MainActivity и вызвать у него loginUser(), потому что Button ничего не знает о MainActivity.

Вместо этого Button объявляет контракт: «Я готова сообщить о клике любому объекту, который умеет на этот клик реагировать». В коде Android этот контракт выражен через интерфейс OnClickListener.

Упрощённо внутри класса View (базового для Button) это выглядит так:

// Контракт, объявленный внутри Android SDK
interface OnClickListener {
    fun onClick(v: View)
}

class View {
    // Ссылка на объект, который реализует контракт
    private var listener: OnClickListener? = null

    // Метод для передачи объекта снаружи
    fun setOnClickListener(l: OnClickListener) {
        this.listener = l
    }

    // Внутренний метод, который ОС вызывает при физическом касании экрана
    private fun performClick() {
        // Если объект-слушатель передан, вызываем его метод
        listener?.onClick(this)
    }
}

Здесь интерфейс выступает в роли типа данных. Кнопке неважно, какой именно класс реализует OnClickListener. Ей важно лишь то, что у переданного объекта гарантированно есть метод onClick. Это классическое проявление полиморфизма.

Анонимные классы: классический подход

Чтобы подписаться на клик, нам нужно создать объект, реализующий OnClickListener, и передать его в кнопку. Мы могли бы заставить саму MainActivity реализовать этот интерфейс, но это плохой тон: экран не должен превращаться в свалку обработчиков всех кнопок.

Вместо этого мы создаём объект «на лету», прямо в месте вызова, используя ключевое слово object:

val loginButton = findViewById<Button>(R.id.btnLogin)

loginButton.setOnClickListener(object : View.OnClickListener {
    override fun onClick(v: View) {
        loginUser()
    }
})

Выражение object : View.OnClickListener создаёт анонимный класс. Компилятор генерирует полноценный класс без имени, который реализует нужный интерфейс, тут же создаёт его единственный экземпляр (объект) в памяти и передаёт ссылку на него в метод setOnClickListener.

Кнопка сохраняет эту ссылку. Когда происходит физический клик, она вызывает onClick() у этого безымянного объекта, а тот, в свою очередь, вызывает наш loginUser().

Лямбда-выражения: синтаксический сахар над ООП

Код с анонимным объектом работает безупречно, но он избыточен. Мы пишем много служебных слов (object, override, fun, onClick), хотя суть операции сводится к одной мысли: «вот код, который надо выполнить при клике».

В Kotlin есть правило: если интерфейс содержит ровно один абстрактный метод, он называется SAM-интерфейсом (Single Abstract Method). Для таких интерфейсов компилятор Kotlin позволяет заменить создание анонимного объекта на лямбда-выражение.

Тот же самый код обработки клика в современном Kotlin пишется так:

loginButton.setOnClickListener { view ->
    loginUser()
}

Если параметр view нам не нужен (а при клике на кнопку он нужен редко), мы можем его опустить:

loginButton.setOnClickListener {
    loginUser()
}

Лямбда-выражение { ... } в данном контексте — это не просто блок кода. Это полноправный объект в памяти.

Когда компилятор видит, что setOnClickListener ждёт объект типа OnClickListener, а вы передаёте ему лямбду, он автоматически проводит SAM-конверсию. Он сам незаметно для вас создаёт тот самый анонимный класс, реализует его единственный метод телом вашей лямбды, создаёт объект и передаёт его в кнопку.

Функциональные типы: лямбды как переменные

Поскольку лямбда компилируется в объект, мы можем обращаться с ней как с любым другим объектом: сохранять в переменные, передавать в функции и возвращать из них.

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

Синтаксис функционального типа выглядит так: (ТипыПараметров) -> ТипВозвращаемогоЗначения.

Например, мы пишем кастомный UI-компонент (карточку товара) и хотим дать возможность подписаться на клик по кнопке «Купить». Нам не обязательно создавать свой интерфейс. Мы можем использовать функциональный тип:

class ProductCardView {
    // Свойство, хранящее лямбду.
    // Принимает String (ID товара), ничего не возвращает (Unit).
    // По умолчанию равно null.
    var onBuyClicked: ((String) -> Unit)? = null

    private fun handleInternalClick() {
        val productId = "12345"
        // Вызываем лямбду, если она была передана
        onBuyClicked?.invoke(productId)
    }
}

Теперь в Activity мы можем передать поведение в эту карточку:

val productCard = findViewById<ProductCardView>(R.id.card)

// Передаём лямбду в свойство
productCard.onBuyClicked = { id ->
    println("Добавлен в корзину товар: $id")
}

Этот подход стирает границу между данными и поведением. В ООП мы привыкли, что переменные хранят состояние (числа, строки), а методы — поведение. Лямбда-выражения позволяют упаковать поведение в объект и передавать его как обычные данные. Это делает архитектуру гибкой: компоненты интерфейса остаются независимыми, а логика реакций внедряется в них снаружи.

Создание кастомных View-компонентов

Создание кастомных View-компонентов

Вы создаете экран профиля пользователя. На нем есть аватарка, имя и статус «в сети». Вы верстаете этот блок в XML. Затем дизайнер просит добавить точно такой же блок на экран настроек, а потом — в список контактов.

Первый порыв — скопировать кусок XML и вставить его на новые экраны. Но как только потребуется изменить цвет текста, вам придется искать и править этот код в трех разных местах. Это прямое нарушение принципа DRY (Don't Repeat Yourself).

В ООП мы решаем проблему дублирования созданием нового класса. Если нам нужен уникальный элемент интерфейса, мы можем создать свой собственный класс, который будет вести себя как стандартная кнопка или текст, но содержать нашу уникальную логику и дизайн. Это и есть кастомные View.

Глобально существует два подхода к созданию своих View:

  1. Композиция (Compound View) — сборка нового компонента из уже существующих (например, объединение ImageView и TextView в одну карточку).
  2. Кастомная отрисовка (Custom Drawing) — рисование компонента с нуля с помощью графических примитивов (линий, кругов, текста).

Разберем оба подхода с точки зрения архитектуры.

Композиция: собираем Compound View

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

Поскольку карточка содержит несколько элементов, логично унаследовать наш класс от контейнера, например, от ConstraintLayout.

class ProfileCardView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {

    init {
        // "Надуваем" XML-разметку и прикрепляем её к нашему классу
        View.inflate(context, R.layout.view_profile_card, this)
    }
}

Здесь мы встречаем аннотацию @JvmOverloads. Вы уже знаете, что для создания View из кода нужен один конструктор (только с Context), а для создания из XML — другой (с Context и AttributeSet). Чтобы не писать вторичные конструкторы вручную и не делегировать их друг другу через this(), мы используем @JvmOverloads. Компилятор Kotlin сам сгенерирует все необходимые перегрузки конструктора для Java-машины.

В блоке init мы вызываем View.inflate(). Третий параметр this означает, что корневым контейнером для элементов из файла view_profile_card.xml станет сам наш класс ProfileCardView.

Теперь мы можем использовать <com.example.app.ProfileCardView> в любом другом XML-файле точно так же, как стандартный TextView. Вся сложность внутренней разметки скрыта.

Чтение кастомных атрибутов

Стандартные View умеют принимать параметры из XML, например android:text="Привет". Наша карточка тоже должна уметь принимать имя пользователя прямо из разметки: app:userName="Иван".

Когда Android встречает наш тег в XML, он собирает все написанные в нем атрибуты в специальный объект AttributeSet и передает его в конструктор нашего класса.

Чтобы прочитать эти данные, нужно:

  1. Объявить атрибуты в файле res/values/attrs.xml.
  2. Извлечь их в блоке init с помощью метода obtainStyledAttributes.
init {
    View.inflate(context, R.layout.view_profile_card, this)

    // Безопасное извлечение атрибутов
    context.theme.obtainStyledAttributes(
        attrs,
        R.styleable.ProfileCardView,
        0, 0
    ).apply {
        try {
            val name = getString(R.styleable.ProfileCardView_userName)
            // Находим внутренний TextView и задаем ему текст
            findViewById<TextView>(R.id.tvName).text = name
        } finally {
            recycle() // Обязательно освобождаем память!
        }
    }
}

Объект TypedArray, который возвращает obtainStyledAttributes, резервирует системные ресурсы. Блок finally { recycle() } гарантирует, что память будет освобождена, даже если при чтении атрибутов произойдет ошибка.

Кастомная отрисовка: магия onDraw

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

Здесь мы наследуемся напрямую от базового класса View и берем кисть в свои руки, переопределяя метод onDraw().

Для рисования Android предоставляет два главных объекта:

  • Canvas (Холст) — отвечает на вопрос «Что рисуем?» (круг, линию, текст). Он предоставляет систему координат, где точка x=0x = 0, y=0y = 0 находится в левом верхнем углу компонента.
  • Paint (Кисть) — отвечает на вопрос «Как рисуем?» (каким цветом, какой толщиной, с заливкой или только контур).

Создадим кольцо прогресса:

class CircularProgressView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    // Создаем кисть один раз, а не внутри onDraw
    private val paint = Paint().apply {
        color = Color.BLUE
        style = Paint.Style.STROKE // Только обводка, без заливки внутри
        strokeWidth = 20f // Толщина линии
        isAntiAlias = true // Сглаживание краев
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        val centerX = width / 2f
        val centerY = height / 2f
        val radius = (Math.min(width, height) / 2f) - paint.strokeWidth

        // Рисуем круг на холсте
        canvas.drawCircle(centerX, centerY, radius, paint)
    }
}

Важное правило чистого кода: метод onDraw вызывается системой очень часто (до 60 раз в секунду при анимациях). Выделение памяти (создание новых объектов через Paint(), Rect() и т.д.) внутри onDraw приведет к постоянному срабатыванию сборщика мусора (Garbage Collector), что вызовет микрофризы интерфейса. Все объекты для рисования нужно создавать заранее, как свойства класса.

Связь состояния и перерисовки

Пока наш круг статичен. Добавим ему состояние — свойство progress, которое будет определять, какая часть дуги закрашена.

Мы уже знаем, что изменение данных в памяти не приводит к автоматическому обновлению экрана (императивный UI). Если мы просто изменим переменную progress, круг на экране останется прежним. Нам нужно принудительно сказать операционной системе: «Мое визуальное состояние устарело, вызови мой метод onDraw еще раз».

Для этого используется метод invalidate().

Идеальный способ связать данные и UI в кастомном View — использовать кастомный сеттер:

var progress: Int = 0
    set(value) {
        // Защищаем инвариант (прогресс от 0 до 100)
        field = value.coerceIn(0, 100)
        // Даем команду ОС перерисовать View
        invalidate()
    }

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    // Вычисляем угол дуги на основе прогресса (100% = 360 градусов)
    val sweepAngle = (progress / 100f) * 360f

    // Рисуем дугу (упрощенный код)
    canvas.drawArc(rect, -90f, sweepAngle, false, paint)
}

Каждый раз, когда кто-то извне вызывает customView.progress = 50, срабатывает наш сеттер. Он обновляет теневое поле (field), а затем вызывает invalidate(). Android ставит эту View в очередь на перерисовку, и в следующем кадре вызывается onDraw, который читает уже новое значение progress и рисует дугу нужной длины.

Инкапсуляция поведения через коллбэки

Кастомный View должен быть самостоятельным черным ящиком. Activity не должна знать, на какую именно внутреннюю кнопку нажал пользователь внутри ProfileCardView. Activity должна просто получать сигнал: «Пользователь хочет открыть профиль».

Для этого мы используем функциональные типы (лямбды) как публичный интерфейс нашего компонента:

class ProfileCardView @JvmOverloads constructor(...) : ConstraintLayout(...) {

    // Публичный коллбэк
    var onCardClicked: (() -> Unit)? = null

    init {
        View.inflate(context, R.layout.view_profile_card, this)

        val button = findViewById<Button>(R.id.btnAction)

        // Внутренняя обработка клика
        button.setOnClickListener {
            // Делегируем событие наружу
            onCardClicked?.invoke()
        }
    }
}

Теперь в Activity код выглядит максимально чисто:

val profileCard = findViewById<ProfileCardView>(R.id.profileCard)
profileCard.onCardClicked = {
    // Логика перехода на другой экран
    navigateToDetails()
}

Мы создали полноценный ООП-компонент. Он сам управляет своей разметкой, сам читает свои атрибуты, сам защищает свои данные через сеттеры и предоставляет удобный интерфейс для взаимодействия с внешним миром.

Адаптеры для RecyclerView: ООП в списках данных

Адаптеры для RecyclerView: ООП в списках данных

Представьте, что вы разрабатываете мессенджер. В истории переписки пользователя 10 000 сообщений. Если при открытии чата мы создадим 10 000 объектов TextView и поместим их в обычный прокручиваемый контейнер (например, ScrollView), приложение неминуемо зависнет, а затем упадет с ошибкой нехватки памяти (Out Of Memory).

Каждый элемент интерфейса — это тяжеловесный объект в памяти. Создавать их для данных, которые пользователь даже не видит на экране прямо сейчас, — непозволительная роскошь.

Для решения этой проблемы в Android используется компонент RecyclerView. В этой статье мы разберем, как он работает под капотом и как принципы ООП помогают ему отображать бесконечные списки, потребляя минимум ресурсов.

Механика переиспользования (Recycling)

Главная идея RecyclerView заложена в его названии — recycling (переиспользование).

Вместо того чтобы создавать 10 000 элементов интерфейса, RecyclerView создает ровно столько объектов View, сколько помещается на экране телефона, плюс еще парочку про запас (обычно 10–15 штук).

Когда пользователь скроллит список вверх, верхний элемент уходит за пределы видимости. Но операционная система не уничтожает этот объект. Вместо этого она отправляет его в специальный пул (кэш). В эту же миллисекунду снизу должен появиться новый элемент списка. RecyclerView берет свободный объект View из пула, подменяет в нем текст (и картинки) на данные нового сообщения и показывает снизу.

Физически на экране циркулирует один и тот же десяток объектов View, в которых просто на лету меняются данные. Это позволяет списку из миллиона элементов работать так же плавно и занимать столько же памяти, сколько списку из десяти элементов.

Паттерн Адаптер: разделение ответственности

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

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

Адаптер — это объект-переводчик. Он берет массив ваших бизнес-объектов (например, список дата-классов Message) и объясняет RecyclerView, как создать для них визуальное представление и как заполнить его данными.

В коде это реализуется через наследование от абстрактного класса RecyclerView.Adapter. RecyclerView общается с вашим кодом через строгий контракт — набор абстрактных методов, которые вы обязаны переопределить.

ViewHolder: кэширование ссылок на UI

Прежде чем писать сам адаптер, нам нужно решить проблему поиска элементов на экране.

Вспомним метод findViewById(). Он обходит дерево XML-разметки, чтобы найти нужный элемент по его ID. Это относительно медленная операция. Если мы будем вызывать findViewById() каждый раз, когда элемент списка появляется на экране (а при быстром скролле это происходит десятки раз в секунду), интерфейс начнет тормозить.

Для решения этой проблемы используется паттерн ViewHolder (Держатель вида). Это класс, задача которого — один раз найти все нужные UI-элементы внутри конкретной карточки списка и сохранить ссылки на них в свои свойства.

class MessageViewHolder(view: View) : RecyclerView.ViewHolder(view) {
    // Находим элементы один раз при создании объекта
    val textMessage: TextView = view.findViewById(R.id.textMessage)
    val textTime: TextView = view.findViewById(R.id.textTime)
}

Объект MessageViewHolder создается вместе с объектом карточки (View) и неразрывно живет с ним в пуле переиспользования. Когда карточка снова появляется на экране, нам не нужно искать TextView — мы просто берем готовую ссылку из свойства textMessage.

Три главных метода Адаптера

Теперь соберем всё вместе. Создавая свой адаптер, мы наследуемся от RecyclerView.Adapter и указываем наш ViewHolder в качестве generic-типа. Контракт базового класса обязывает нас реализовать три метода:

Метод Когда вызывается Что должен сделать
getItemCount() Постоянно Вернуть общее количество элементов в вашем списке данных.
onCreateViewHolder() Только когда пул пуст и нужно создать новую физическую карточку Раздуть XML-разметку (inflation) и вернуть новый объект ViewHolder.
onBindViewHolder() Каждый раз, когда карточка появляется на экране Взять данные конкретного элемента (по позиции) и записать их в UI-свойства ViewHolder.

Обратите внимание на разницу частоты вызовов. Если в списке 10 000 элементов, onCreateViewHolder вызовется всего около 15 раз (создастся 15 физических карточек). А вот onBindViewHolder будет вызываться тысячи раз при скролле, переиспользуя эти 15 карточек.

Вот как выглядит минималистичный, но полностью рабочий адаптер:

class MessageAdapter(
    private val messages: List<Message>,
    private val onMessageClick: (Message) -> Unit // Лямбда для обработки кликов
) : RecyclerView.Adapter<MessageViewHolder>() {

    override fun getItemCount(): Int {
        return messages.size
    }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MessageViewHolder {
        // 1. Читаем XML и создаем объект View
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_message, parent, false)

        // 2. Оборачиваем View в наш ViewHolder
        return MessageViewHolder(view)
    }

    override fun onBindViewHolder(holder: MessageViewHolder, position: Int) {
        // 1. Достаем данные для текущей строки
        val message = messages[position]

        // 2. Записываем данные в закэшированные UI-элементы
        holder.textMessage.text = message.text
        holder.textTime.text = message.timestamp

        // 3. Вешаем слушатель клика (передаем поведение через лямбду)
        holder.itemView.setOnClickListener {
            onMessageClick(message)
        }
    }
}

DiffUtil: магия обновления и data-классы

В базовом варианте, если в нашем списке сообщений появилось новое, мы должны обновить массив messages и вызвать метод адаптера notifyDataSetChanged().

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

Современный подход — использование класса ListAdapter (наследника обычного Adapter) в связке с механизмом DiffUtil.

DiffUtil — это алгоритм, который берет старый список, берет новый список, сравнивает их в фоновом потоке и вычисляет минимальный набор изменений: «элемент на позиции 2 удален, элемент на позиции 5 изменил текст».

Чтобы DiffUtil мог сравнивать элементы, мы передаем ему правила сравнения через объект DiffUtil.ItemCallback. И вот здесь раскрывается истинная мощь data-классов, которые мы изучали ранее.

class MessageDiffCallback : DiffUtil.ItemCallback<Message>() {

    // 1. Это один и тот же физический объект (сущность)?
    override fun areItemsTheSame(oldItem: Message, newItem: Message): Boolean {
        return oldItem.id == newItem.id
    }

    // 2. Изменились ли данные внутри этого объекта?
    override fun areContentsTheSame(oldItem: Message, newItem: Message): Boolean {
        // Благодаря data class, оператор == проверяет структурную эквивалентность
        // (сравнивает все поля автоматически)
        return oldItem == newItem
    }
}

Если бы Message был обычным классом, нам пришлось бы вручную сравнивать oldItem.text == newItem.text && oldItem.time == newItem.time и так далее. Использование data-классов делает метод areContentsTheSame тривиальным: оператор == под капотом вызывает автоматически сгенерированный метод equals(), который проверяет совпадение всех свойств.

Когда мы используем ListAdapter, нам больше не нужно хранить список messages внутри адаптера как свойство. Мы просто вызываем метод adapter.submitList(newList), а DiffUtil сам рассчитает разницу и анимированно обновит только те карточки, которые реально изменились.

Контекст Android (Context) и его правильное использование без утечек памяти

Контекст Android (Context) и его правильное использование без утечек памяти

Вы наверняка уже сталкивались с этим раздражающим требованием Android: чтобы получить строку из ресурсов (R.string.hello), показать всплывающее сообщение (Toast) или открыть базу данных, системе постоянно нужен какой-то context. Почему нельзя просто вызвать метод getString() откуда угодно? Зачем мы вынуждены прокидывать этот объект через все слои приложения?

Ответ кроется в архитектуре операционной системы. Вспомните: в Android нет функции main(), мы не контролируем запуск программы. Операционная система сама создает компоненты. И чтобы наши объекты могли общаться с этой внешней средой, им нужен легальный «пропуск».

Что такое Context с точки зрения ООП?

Если заглянуть в исходный код Android, мы увидим, что Context — это просто абстрактный класс.

Context — это абстрактный класс, предоставляющий глобальную информацию о среде приложения. Он служит мостом между вашим кодом и системными сервисами Android (файловой системой, ресурсами, базами данных, запуском других экранов).

Но откуда мы берем экземпляр этого класса? Когда вы пишете код внутри MainActivity и вызываете Toast.makeText(this, "Привет", Toast.LENGTH_SHORT), вы передаете this. Как Activity может быть Context?

Всё дело в наследовании. Иерархия классов выглядит так: Context \rightarrow ContextWrapper \rightarrow ContextThemeWrapper \rightarrow Activity.

По правилу IS-A (является), которое мы разбирали ранее, любая Activity является Контекстом. Когда ОС создает вашу Activity, она инициализирует базовую часть этого объекта, связывая его с ресурсами и системными процессами. Именно поэтому внутри Activity вы можете использовать this там, где требуется Context.

Два главных вида Контекста

В Android существует несколько реализаций Контекста, но 99% времени вы будете работать с двумя из них. Их жизненные циклы кардинально различаются, и путаница между ними — главная причина падений приложений.

Характеристика Activity Context (this) Application Context (applicationContext)
Жизненный цикл Живет, пока жив экран. Уничтожается при повороте устройства или закрытии экрана. Живет столько же, сколько весь процесс приложения в памяти ОС.
Знает о UI и темах? Да. Содержит информацию о текущей теме (цвета, шрифты), стилях и метриках экрана. Нет. Это «голый» системный контекст без привязки к дизайну.
Для чего использовать Создание View, показ диалогов и Toast, запуск других Activity (интенты). Доступ к базе данных, сетевые запросы, инициализация Singleton-объектов.

Анатомия утечки памяти (Memory Leak)

В Kotlin и Java нам не нужно вручную удалять объекты из памяти. За нас это делает Garbage Collector (GC) — сборщик мусора.

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

Представьте, что объект — это воздушный шарик. Пока вы держите его за ниточку (имеете ссылку на него), он не улетит. Как только вы отпускаете ниточку (переменная обнуляется или выходит из области видимости), сборщик мусора «сдувает» шарик и освобождает память.

А теперь вспомним жизненный цикл Activity. При повороте экрана ОС вызывает метод onDestroy(), ожидая, что объект старой Activity будет удален сборщиком мусора, и создает новую Activity.

Но что, если мы передадим ссылку на Activity в объект, который живет дольше самой Activity? Например, в Singleton (через object или companion object).

// Глобальный Singleton для аналитики
object AnalyticsTracker {
    private var currentContext: Context? = null

    fun init(context: Context) {
        // ОШИБКА: сохраняем переданный контекст в глобальную переменную
        currentContext = context
    }
}

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Передаем Activity Context (this) в Singleton
        AnalyticsTracker.init(this)
    }
}

Что произойдет при повороте экрана?

  1. ОС решает уничтожить MainActivity и вызывает onDestroy().
  2. Сборщик мусора приходит, чтобы удалить объект MainActivity из памяти.
  3. GC видит: глобальный бессмертный объект AnalyticsTracker всё ещё держит ссылку currentContext на эту MainActivity.
  4. Сборщик мусора отступает. MainActivity остается в памяти навсегда, хотя на экране её уже нет.

Это и есть утечка памяти (Memory Leak). Вместе с Activity в памяти зависает вся иерархия её View (кнопки, списки, картинки), что быстро приводит к исчерпанию памяти и аварийному завершению приложения с ошибкой OutOfMemoryError.

Опасность кроется не только в явной передаче this. Любой UI-компонент хранит ссылку на контекст, в котором был создан.

Золотое правило работы с Context

Чтобы писать чистый код без утечек памяти, запомните простое правило: никогда не сохраняйте Activity Context в объекты, чей жизненный цикл длиннее, чем у самой Activity.

Как тогда инициализировать глобальные компоненты, которым нужен доступ к системе (например, базу данных или трекер аналитики)? Использовать Application Context.

В классе Context есть метод applicationContext, который возвращает глобальный контекст всего приложения. Он гарантированно живет столько же, сколько и сам Singleton, поэтому утечки памяти не произойдет.

Исправим наш пример с аналитикой:

object AnalyticsTracker {
    private var appContext: Context? = null

    fun init(context: Context) {
        // ПРАВИЛЬНО: извлекаем безопасный глобальный контекст
        appContext = context.applicationContext
    }
}

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Даже если мы передаем this, Singleton сохранит только Application Context
        AnalyticsTracker.init(this)
    }
}

Почему мы не используем applicationContext вообще для всего? Потому что он ничего не знает о UI. Если вы попытаетесь создать диалоговое окно или инфлейтить кастомную View, передав туда Application Context, приложение либо упадет, либо отрисует элементы с неправильными цветами и шрифтами, так как глобальный контекст не имеет доступа к теме конкретного экрана.

Разделение ответственности очевидно: Activity Context — для всего, что вы видите на экране; Application Context — для всего, что работает под капотом.

Принципы SOLID на простых примерах в Kotlin

Принципы SOLID на простых примерах в Kotlin

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

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

Чтобы инструменты ООП работали на вас, а не против вас, Роберт Мартин (Дядя Боб) сформулировал пять архитектурных принципов, известных под акронимом SOLID. Это не строгие законы физики, а правила гигиены чистого кода. Давайте разберём каждый из них на понятных Android-примерах, выстраивая их в единую логическую цепочку.

S — Single Responsibility Principle (Принцип единственной ответственности)

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

Самая частая ошибка начинающих Android-разработчиков — писать весь код внутри Activity или Fragment. Там мы делаем сетевой запрос, там же парсим JSON, там же фильтруем данные и там же выводим их в TextView.

Если у класса много обязанностей, у него появляется много причин для изменения. Изменился дизайн экрана? Меняем Activity. Изменился адрес сервера? Меняем Activity. Изменился формат даты в JSON? Снова меняем Activity.

Как неправильно (Божественный класс):

class UserProfileActivity : AppCompatActivity() {
    fun loadAndShowUser() {
        // Обязанность 1: Работа с сетью
        val json = NetworkClient.get("https://api.example.com/user")

        // Обязанность 2: Парсинг данных
        val user = Gson().fromJson(json, User::class.java)

        // Обязанность 3: Форматирование и UI
        findViewById<TextView>(R.id.nameText).text = "${user.firstName} ${user.lastName}"
    }
}

Как правильно: Разделим обязанности. Пусть каждый класс делает только одну вещь.

  1. NetworkClient — только скачивает строку.
  2. UserRepository — запрашивает строку и превращает её в объект User.
  3. UserProfileActivity — только отображает готового User на экране.

Теперь, если бэкенд переедет на другой URL, мы изменим только UserRepository. UI-код останется нетронутым. SRP — это фундамент. Без разделения на независимые компоненты невозможно применить остальные принципы.

O — Open/Closed Principle (Принцип открытости/закрытости)

Программные сущности должны быть открыты для расширения, но закрыты для модификации.

Представьте, что вы пишете логику расчета стоимости доставки.

Как неправильно:

class DeliveryCalculator {
    fun calculate(type: String, distance: Int): Double {
        return when (type) {
            "POST" -> distance * 1.5
            "COURIER" -> distance * 3.0 + 50.0
            else -> 0.0
        }
    }
}

Бизнес просит добавить доставку дронами. Вам придется открыть класс DeliveryCalculator и дописать новое условие в when. Это модификация. А если типов доставки станет 50? Класс разрастется, и каждое вмешательство будет риском сломать старые расчеты.

Как правильно: Используем полиморфизм и интерфейсы, которые мы изучили ранее.

interface DeliveryType {
    fun calculateCost(distance: Int): Double
}

class PostDelivery : DeliveryType {
    override fun calculateCost(distance: Int) = distance * 1.5
}

class CourierDelivery : DeliveryType {
    override fun calculateCost(distance: Int) = distance * 3.0 + 50.0
}

// Ядро системы:
class DeliveryCalculator {
    fun calculate(delivery: DeliveryType, distance: Int): Double {
        return delivery.calculateCost(distance)
    }
}

Теперь, чтобы добавить доставку дронами, нам не нужно менять DeliveryCalculator. Мы просто создаем новый класс DroneDelivery : DeliveryType (система открыта для расширения) и передаем его в калькулятор. Старый код закрыт для модификации и находится в полной безопасности.

L — Liskov Substitution Principle (Принцип подстановки Барбары Лисков)

Объекты в программе должны быть заменяемыми на экземпляры их подтипов без изменения правильности выполнения программы.

Этот принцип диктует правила для наследования и реализации интерфейсов (которые мы использовали в OCP). Если функция ожидает базовый класс или интерфейс, вы можете передать туда любого наследника, и функция не должна сломаться или повести себя непредсказуемо.

Как неправильно: У нас есть интерфейс хранилища.

interface DocumentStorage {
    fun read(id: String): String
    fun write(id: String, content: String)
}

Мы создаем локальное хранилище LocalFileStorage, которое отлично реализует оба метода. А затем нам нужно добавить облачное хранилище, в котором у пользователя есть права только на чтение.

class ReadOnlyCloudStorage : DocumentStorage {
    override fun read(id: String): String {
        return "Данные из облака"
    }

    override fun write(id: String, content: String) {
        // Нарушение LSP!
        throw UnsupportedOperationException("Запись в облако запрещена")
    }
}

Если какая-то часть программы ожидает DocumentStorage и попытается вызвать write(), приложение упадет с ошибкой. Наследник нарушил контракт базового интерфейса.

Как правильно: Если поведение объектов отличается настолько кардинально, они не должны делить общий интерфейс с мутирующими методами. Разделите контракты:

interface ReadableStorage {
    fun read(id: String): String
}

interface WritableStorage {
    fun write(id: String, content: String)
}

Теперь облако реализует только ReadableStorage, а локальный файл — оба интерфейса. Программа больше не упадет из-за неожиданного исключения.

I — Interface Segregation Principle (Принцип разделения интерфейса)

Клиенты не должны зависеть от методов, которые они не используют.

Этот принцип логично вытекает из предыдущего. Огромные, "толстые" интерфейсы заставляют классы реализовывать методы, которые им совершенно не нужны.

Как неправильно: Допустим, мы пишем систему умного дома.

interface SmartDevice {
    fun turnOn()
    fun turnOff()
    fun setTemperature(temp: Int)
    fun startRecordingVideo()
}

Если мы создаем класс SmartBulb (умная лампочка), нам придется реализовать методы setTemperature и startRecordingVideo, оставив их пустыми (что является плохим тоном) или выбрасывая ошибки (что нарушает LSP).

Как правильно: Дробим интерфейсы на специфические роли (вспоминаем принцип CAN-DO из главы про абстракции).

interface Switchable {
    fun turnOn()
    fun turnOff()
}

interface TemperatureControlled {
    fun setTemperature(temp: Int)
}

interface CameraEquipped {
    fun startRecordingVideo()
}

class SmartBulb : Switchable {
    override fun turnOn() { /* ... */ }
    override fun turnOff() { /* ... */ }
}

class SmartAirConditioner : Switchable, TemperatureControlled {
    // Реализует только то, что реально умеет
}

В Android яркий пример нарушения этого принципа в самом фреймворке — интерфейс TextWatcher для поля ввода. В нём три метода, хотя в 90% случаев нам нужен только один — afterTextChanged. Чтобы обойти это, разработчики часто пишут свои функции-расширения, скрывающие лишние методы.

D — Dependency Inversion Principle (Принцип инверсии зависимостей)

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

Это венец SOLID, который связывает все предыдущие принципы воедино. Модуль верхнего уровня — это бизнес-логика приложения (например, оформление заказа). Модуль нижнего уровня — это конкретные инструменты (база данных, сеть, файловая система).

Как неправильно:

class OrderProcessor {
    // Жесткая зависимость от конкретного класса нижнего уровня
    private val paymentApi = StripePaymentApi()

    fun process(amount: Double) {
        paymentApi.charge(amount)
    }
}

Здесь OrderProcessor сам создает StripePaymentApi. Во-первых, мы нарушили OCP: чтобы перейти на PayPal, придется менять код процессора. Во-вторых, этот код невозможно протестировать без реального списания денег, так как мы не можем подменить StripePaymentApi на тестовую заглушку.

Как правильно: Инвертируем (переворачиваем) зависимость. Бизнес-логика больше не зависит от конкретного инструмента. Она диктует свои правила через интерфейс (абстракцию), а инструмент подстраивается под эти правила.

// Абстракция
interface PaymentGateway {
    fun charge(amount: Double)
}

// Модуль нижнего уровня зависит от абстракции
class StripePaymentApi : PaymentGateway {
    override fun charge(amount: Double) { /* Логика Stripe */ }
}

// Модуль верхнего уровня зависит от абстракции
class OrderProcessor(private val paymentGateway: PaymentGateway) {
    fun process(amount: Double) {
        paymentGateway.charge(amount)
    }
}

Теперь OrderProcessor получает нужный инструмент извне через конструктор. Это называется Внедрением зависимостей (Dependency Injection). В рабочей программе мы передадим туда StripePaymentApi, а в тестах — безопасный FakePaymentGateway, который ничего не списывает.

Резюме: как они работают вместе

SOLID — это не пять разрозненных правил, а единый механизм проектирования:

  1. Вы разделяете большую задачу на мелкие части (SRP).
  2. Вы хотите добавлять новые части, не ломая старые (OCP).
  3. Для этого вы используете полиморфизм и интерфейсы, следя за тем, чтобы наследники вели себя предсказуемо (LSP).
  4. Чтобы интерфейсы были удобными, вы делаете их узкими и специфичными (ISP).
  5. И наконец, вы связываете все эти независимые части вместе, передавая абстракции через конструкторы (DIP).

В следующих главах мы увидим, как эти принципы лежат в основе современной Android-архитектуры: от разделения слоев в MVVM до автоматического внедрения зависимостей.

Архитектура MVVM: разделение ответственности

Архитектура MVVM: разделение ответственности

Представьте, что вы написали экран профиля пользователя. Ваша MainActivity делает сетевой запрос, парсит JSON, форматирует дату рождения, скрывает прогресс-бар при успехе, показывает ошибку при сбое сети и обрабатывает клик по кнопке «Выйти». Код разросся до тысячи строк. А затем пользователь просто поворачивает экран смартфона — и приложение падает, потому что Activity уничтожилась в середине сетевого запроса.

Этот антипаттерн называется God Activity («Божественная активность»). В нем нарушен базовый принцип SOLID — принцип единственной ответственности (SRP). Activity берет на себя роль и отрисовщика интерфейса, и сетевого клиента, и менеджера данных.

Чтобы писать чистый, тестируемый и устойчивый к багам код, Android-разработчики используют архитектурный паттерн MVVM (Model — View — ViewModel).

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

MVVM делит код экрана на три изолированных слоя. У каждого слоя есть строгая зона ответственности и жесткие правила того, о чем ему разрешено «знать».

1. View (Представление)

Это ваши Activity, Fragment или кастомные View-компоненты.

  • Ответственность: Только отображение данных и регистрация действий пользователя (кликов, свайпов).
  • Правило: Ноль бизнес-логики. View не должна знать, откуда берутся данные (из сети или базы) и как они вычисляются. Она умеет делать только две вещи: «показать текст в TextView» и «сообщить, что на кнопку нажали».

2. ViewModel (Модель Представления)

Это мозг экрана, посредник между интерфейсом и данными.

  • Ответственность: Подготовка данных для View и обработка команд от пользователя.
  • Правило: Полная изоляция от Android-фреймворка. Во ViewModel категорически запрещено передавать Context, ссылки на View или Activity. Если ViewModel сохранит ссылку на кнопку, а Activity пересоздастся при повороте экрана, старая кнопка останется в памяти — произойдет утечка памяти (Memory Leak), механизм которой мы разбирали ранее.

3. Model (Модель)

Это слой данных и бизнес-логики всего приложения.

  • Ответственность: Получение, сохранение и обработка сырых данных.
  • Правило: Ничего не знает ни о ViewModel, ни о View. Здесь живут классы для работы с сетью (NetworkClient), базы данных и репозитории.

Однонаправленный поток данных (UDF)

Разделение на слои — это половина дела. Главная магия MVVM кроется в том, как эти слои общаются друг с другом. В правильной архитектуре зависимости направлены только в одну сторону: View знает о ViewModel, ViewModel знает о Model. Обратного пути нет.

Чтобы View могла получать обновления от ViewModel, о которой та ничего не знает, применяется концепция Unidirectional Data Flow (UDF) — однонаправленного потока данных.

  1. Событие (Event): Пользователь нажимает кнопку во View. View вызывает обычный метод у ViewModel (например, viewModel.loadProfile()).
  2. Бизнес-логика: ViewModel обращается к Model за данными.
  3. Состояние (State): Получив данные, ViewModel упаковывает их в объект состояния экрана. Помните паттерн UiState на основе sealed class, который мы создавали? Это его звездный час.
  4. Отрисовка (Render): View постоянно «наблюдает» за состоянием внутри ViewModel. Как только состояние меняется (например, с Loading на Success), View автоматически обновляет интерфейс.

Рефакторинг: от спагетти к MVVM

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

Как было (God Activity)

Вся логика смешана в одном классе. Activity сама управляет потоками и знает детали получения данных.

class ProfileActivity : AppCompatActivity() {
    private lateinit var progressBar: ProgressBar
    private lateinit var nameTextView: TextView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ... инициализация View ...

        loadUser()
    }

    private fun loadUser() {
        progressBar.visibility = View.VISIBLE

        // Симуляция прямого обращения к данным внутри UI-контроллера
        NetworkApi.fetchUser { user ->
            progressBar.visibility = View.GONE
            if (user != null) {
                nameTextView.text = "${user.firstName} ${user.lastName}"
            } else {
                showError("Пользователь не найден")
            }
        }
    }
}

Как стало (MVVM)

Сначала выделим состояние экрана. Используем sealed class, чтобы компилятор заставил нас обработать все возможные варианты (исчерпывающий when).

sealed class ProfileUiState {
    object Loading : ProfileUiState()
    data class Success(val fullName: String) : ProfileUiState()
    data class Error(val message: String) : ProfileUiState()
}

Теперь создадим ViewModel. Она ничего не знает про TextView или ProgressBar. Она просто меняет свое внутреннее состояние.

class ProfileViewModel(private val repository: UserRepository) {

    // Свойство, хранящее текущее состояние.
    // В реальном коде здесь используются LiveData или StateFlow.
    var state: ProfileUiState = ProfileUiState.Loading
        private set // Защищаем от изменения извне (инкапсуляция)

    fun loadProfile() {
        state = ProfileUiState.Loading

        repository.getUser { user ->
            state = if (user != null) {
                // ViewModel сама форматирует данные для View
                ProfileUiState.Success("${user.firstName} ${user.lastName}")
            } else {
                ProfileUiState.Error("Пользователь не найден")
            }
        }
    }
}

И, наконец, View. Она становится невероятно глупой и предсказуемой. У нее есть только одна задача — прочитать ProfileUiState и включить нужные элементы интерфейса.

class ProfileActivity : AppCompatActivity() {
    private lateinit var viewModel: ProfileViewModel

    // ... инициализация ...

    // Этот метод вызывается каждый раз, когда ViewModel меняет state
    private fun render(state: ProfileUiState) {
        when (state) {
            is ProfileUiState.Loading -> {
                progressBar.visibility = View.VISIBLE
                nameTextView.visibility = View.GONE
            }
            is ProfileUiState.Success -> {
                progressBar.visibility = View.GONE
                nameTextView.visibility = View.VISIBLE
                nameTextView.text = state.fullName // Данные уже отформатированы!
            }
            is ProfileUiState.Error -> {
                progressBar.visibility = View.GONE
                showError(state.message)
            }
        }
    }
}

Разделив ответственность, мы получили код, который легко читать и тестировать. Вы можете протестировать логику ProfileViewModel вообще без запуска эмулятора Android, ведь в ней нет ни одной зависимости от системных классов операционной системы.

В этом примере мы опустили механизм того, как именно View узнает, что переменная state внутри ViewModel изменилась. В Android для этого используются специальные классы-обертки — об этом мы поговорим в следующей главе.

ViewModel и LiveData/StateFlow: сохранение состояния

ViewModel и LiveData/StateFlow: сохранение состояния

Вы поворачиваете телефон, и операционная система безжалостно уничтожает вашу Activity, чтобы создать её заново под новые размеры экрана. Все переменные, текст в полях ввода и загруженные данные стираются из памяти. Чтобы защитить бизнес-логику от этого хаоса, мы вынесли её в отдельный слой — ViewModel.

Но возникает парадокс: ViewModel — это обычный класс в Kotlin. Почему уничтожение Activity не приводит к уничтожению созданной внутри неё ViewModel?

Секрет бессмертия: ViewModelStore

Если вы напишете в коде val vm = ProfileViewModel(), ваша ViewModel умрёт ровно в тот же момент, что и Activity, потому что это обычная ссылка на объект. Вся магия выживания кроется не в самом классе ViewModel, а в том, как именно мы просим систему его создать.

В современном Android для этого используется делегат свойства by viewModels():

class ProfileActivity : AppCompatActivity() {
    // Мы не вызываем конструктор напрямую!
    private val viewModel: ProfileViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // ...
    }
}

Под капотом этого делегата работает специальный механизм — ViewModelProvider. Когда Activity запускается впервые, провайдер видит, что нужной ViewModel ещё нет, создаёт её и помещает в специальное хранилище — ViewModelStore.

Это хранилище привязано не к конкретному экземпляру Activity, а к её системной записи. Когда вы поворачиваете экран:

  1. Старая Activity уничтожается.
  2. Объект ViewModel остаётся лежать в памяти внутри ViewModelStore.
  3. Создаётся новая Activity.
  4. Делегат by viewModels() снова обращается к хранилищу, находит там ту же самую ViewModel и возвращает ссылку на неё.

Проблема воссоединения

Итак, после поворота экрана у нас есть совершенно новая Activity и старая ViewModel, в которой лежат актуальные данные. Как новой Activity получить эти данные?

Если мы сделаем в ViewModel обычное свойство или метод-геттер, Activity придётся самой спрашивать: «Есть что-то новое?». Но Activity не знает, в какой момент данные изменятся (например, когда завершится сетевой запрос).

Нам нужен механизм, который работает по принципу подписки: Activity говорит «сообщай мне обо всех изменениях», а ViewModel сама проталкивает новые данные в UI, как только они появляются. Это называется реактивным программированием.

В Android для этого используются классы-контейнеры: исторически это была LiveData, но современным стандартом Kotlin является StateFlow (поток состояния).

StateFlow: труба для данных

StateFlow — это специальный объект, который делает две вещи:

  1. Хранит внутри себя одно актуальное значение (состояние).
  2. Позволяет другим объектам «подписаться» на себя и мгновенно рассылает им уведомления, если значение внутри изменилось.

Чтобы реализовать принцип однонаправленного потока данных (UDF), мы должны строго разделить права. ViewModel имеет право изменять состояние, а Activity — только читать его. Для этого мы применяем классическую ООП-инкапсуляцию, используя два разных интерфейса одного и того же потока.

В Kotlin существует MutableStateFlow (изменяемый, имеет свойство value, куда можно записать данные) и просто StateFlow (только для чтения).

class ProfileViewModel : ViewModel() {

    // 1. Приватный изменяемый поток. Доступен только внутри ViewModel.
    private val _uiState = MutableStateFlow<ProfileUiState>(ProfileUiState.Loading)

    // 2. Публичный неизменяемый поток. Activity видит только его.
    val uiState: StateFlow<ProfileUiState> = _uiState

    fun loadUser() {
        // ViewModel может менять значение через приватную переменную
        _uiState.value = ProfileUiState.Success(User("Алексей"))
    }
}

Здесь мы используем паттерн, похожий на backing field (теневое поле). Свойство uiState имеет тип StateFlow, поэтому любая попытка Activity написать viewModel.uiState.value = ... вызовет ошибку компиляции. Интерфейс StateFlow просто не имеет сеттера.

Подписка на состояние в UI

Теперь свяжем нашу "трубу" с экраном. В новой Activity мы должны подписаться на uiState. Процесс получения данных из потока в Kotlin называется collect (сбор).

class ProfileActivity : AppCompatActivity() {
    private val viewModel: ProfileViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Запускаем подписку в специальной области видимости Activity
        lifecycleScope.launch {
            // Собираем данные из потока
            viewModel.uiState.collect { state ->
                // Этот блок кода будет вызываться АВТОМАТИЧЕСКИ
                // каждый раз, когда ViewModel изменит _uiState.value
                render(state)
            }
        }
    }

    private fun render(state: ProfileUiState) {
        when (state) {
            is ProfileUiState.Loading -> showProgress()
            is ProfileUiState.Success -> showUserName(state.user.name)
            is ProfileUiState.Error -> showError(state.message)
        }
    }
}

Блок lifecycleScope.launch необходим, потому что сбор данных из потока — это процесс, который длится во времени. Android предоставляет этот механизм, чтобы подписка автоматически отменялась, когда Activity окончательно уничтожается (например, пользователь закрыл приложение). Это защищает нас от утечек памяти.

Теперь система работает безупречно:

  1. Activity создаётся и подписывается на viewModel.uiState.
  2. ViewModel загружает данные и делает _uiState.value = Success.
  3. Activity мгновенно получает это состояние и отрисовывает UI.
  4. Пользователь поворачивает экран. Старая Activity умирает, подписка обрывается.
  5. Создаётся новая Activity. Делегат by viewModels() выдаёт ей ту же самую ViewModel.
  6. Новая Activity снова подписывается на uiState. Так как StateFlow всегда хранит последнее значение, новая Activity моментально получает актуальное состояние Success и восстанавливает экран без повторного сетевого запроса.

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

Введение в Dependency Injection (Внедрение зависимостей)

Введение в Dependency Injection (Внедрение зависимостей)

Разделяя ответственность по принципам SOLID, мы пришли к инверсии зависимостей (DIP): классы больше не создают свои инструменты сами, а получают их через параметры конструктора. ProfileViewModel больше не инстанцирует UserRepository, а требует его при создании. Это сделало код тестируемым и независимым.

Но здесь возникает архитектурный парадокс. Если ProfileViewModel требует UserRepository, а UserRepository требует NetworkClient и Database, а NetworkClient требует Logger... кто-то в приложении должен собрать эту «матрешку». Если мы поручим эту сборку ProfileActivity, она мгновенно превратится в свалку технических деталей, нарушив принцип единой ответственности и правила MVVM.

Граф зависимостей

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

В реальном Android-приложении этот граф разрастается до сотен узлов. Когда пользователю нужно показать экран профиля, системе не нужна просто ViewModel. Ей нужна целая ветка этого графа, собранная в строгом порядке: от самых низкоуровневых компонентов (логгеры, настройки сети) к высокоуровневым (репозитории), и только затем — сама ViewModel.

Проблема ручной сборки графа заключается не только в объеме кода (boilerplate), но и в управлении памятью. Некоторые узлы графа (например, NetworkClient) должны существовать в единственном экземпляре на всё приложение (Singleton). Другие (например, ProfileViewModel) должны жить ровно столько, сколько живет конкретный экран.

Ловушка Service Locator

Первое инстинктивное желание разработчика, столкнувшегося с необходимостью прокидывать зависимости через пять слоев — создать глобальный объект-помощник, который будет выдавать нужные инструменты по запросу. Этот паттерн называется Service Locator.

// Антипаттерн: Service Locator
object ServiceLocator {
    val logger = Logger()
    val networkClient = NetworkClient(logger)
    val userRepository = UserRepository(networkClient)
}

class ProfileViewModel : ViewModel() {
    // ViewModel сама тянется за зависимостью
    private val repository = ServiceLocator.userRepository

    fun loadData() { ... }
}

На первый взгляд, проблема решена: Activity больше не собирает матрешку, а ViewModel получает свой репозиторий. Но Service Locator ломает фундаментальное свойство чистого кода — явность контракта.

Глядя на сигнатуру class ProfileViewModel(), вы не знаете, что для ее работы нужен UserRepository. Зависимость скрыта внутри тела класса. Если вы решите протестировать эту ViewModel или перенести ее в другой проект, она внезапно упадет с ошибкой, потому что вы забыли инициализировать глобальный ServiceLocator.

Вместо того чтобы класс требовал зависимости извне (push), он вытягивает их сам из глобального пространства (pull).

DI-контейнер: автоматическая фабрика

Истинное Внедрение зависимостей (Dependency Injection, DI) подразумевает, что класс остается пассивным. Он честно объявляет свои потребности в конструкторе, а некая внешняя сила создает его и передает всё необходимое.

Этой внешней силой выступает DI-контейнер — специализированный механизм, который берет на себя управление графом зависимостей.

Контейнер работает по принципу умной фабрики. Вы один раз объясняете ему правила создания базовых деталей:

  1. «Если кто-то попросит Logger, создай новый экземпляр Logger()
  2. «Если кто-то попросит NetworkClient, возьми Logger, передай его в конструктор NetworkClient и верни результат.»
  3. «Если кто-то попросит UserRepository, отдай ему тот же самый NetworkClient, который ты создал ранее.»

Когда Activity запрашивает у контейнера ProfileViewModel, контейнер рекурсивно обходит граф вниз, создает (или берет из кэша) все необходимые зависимости и собирает итоговый объект. Activity получает готовую к работе ViewModel, не зная ни о сети, ни о базах данных.

Manual DI: контейнер своими руками

Чтобы понять, что DI-контейнер — это не магия, а просто структурированный код, посмотрим, как реализуется Manual DI (ручное внедрение) в Android.

Роль глобального контейнера, который живет дольше любых Activity, берет на себя класс Application. Мы создаем класс AppContainer, в котором описываем правила сборки графа, используя знакомый нам делегат lazy для отложенной инициализации и кэширования (Singleton):

// 1. Создаем контейнер зависимостей
class AppContainer {
    val logger by lazy { Logger() }
    val networkClient by lazy { NetworkClient(logger) }
    val userRepository by lazy { UserRepository(networkClient) }

    // Фабрика для ViewModel, так как ViewModel создается заново для новой Activity
    fun profileViewModelFactory(): ProfileViewModel {
        return ProfileViewModel(userRepository)
    }
}

// 2. Привязываем контейнер к жизненному циклу приложения
class MyApplication : Application() {
    val container = AppContainer()
}

// 3. Activity запрашивает готовую ViewModel у контейнера
class ProfileActivity : AppCompatActivity() {
    private lateinit var viewModel: ProfileViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Получаем доступ к контейнеру уровня приложения
        val appContainer = (application as MyApplication).container

        // Получаем полностью собранную ViewModel
        viewModel = appContainer.profileViewModelFactory()
    }
}

В этом подходе соблюдены все принципы чистого кода:

  • ProfileViewModel имеет честный конструктор (val repository: UserRepository).
  • ProfileActivity не знает, как создается репозиторий, она просит готовую ViewModel.
  • AppContainer инкапсулирует логику сборки графа.

Зачем нужны DI-фреймворки?

Manual DI отлично работает в небольших проектах. Но по мере роста приложения AppContainer превращается в гигантский файл на тысячи строк. Появляется проблема управления областями видимости (Scopes): например, когда определенные зависимости (допустим, UserSession) должны существовать в памяти, только пока пользователь авторизован, и уничтожаться при выходе из аккаунта (Logout). Реализовывать такую логику очистки кэша вручную очень сложно и чревато утечками памяти.

Именно поэтому в Android-разработке используют DI-фреймворки (Dagger, Hilt, Koin). Они генерируют код контейнеров автоматически на основе аннотаций или коротких модулей, берут на себя управление жизненным циклом (Scopes) и проверяют целостность графа зависимостей еще на этапе компиляции, не давая приложению собраться, если вы забыли указать, как создавать какой-либо класс.

Написание тестируемого кода: зачем выделять интерфейсы

Написание тестируемого кода: зачем выделять интерфейсы

Представьте, что вы написали тест для экрана регистрации. Вы нажимаете кнопку «Run», тест проходит успешно, но на ваш телефон внезапно приходит реальная SMS с кодом подтверждения, а в рабочей базе данных появляется новый пользователь «Test Testovich». Или еще хуже: тест падает просто потому, что на сервере ведутся технические работы.

Тесты, которые зависят от внешнего мира — интернета, реальных баз данных или сторонних API — это бомба замедленного действия. Они медленные, нестабильные и загрязняют реальные данные.

В прошлых главах мы разобрали внедрение зависимостей (DI) и принцип инверсии зависимостей (DIP). Мы научились передавать объекты через конструктор, чтобы не создавать их жестко внутри класса. Но внедрение зависимостей раскрывает свою истинную мощь именно тогда, когда мы начинаем писать автоматизированные тесты.

Проблема конкретных зависимостей

Допустим, мы пишем ProfileViewModel, которая должна загрузить данные пользователя и передать их в UI. Мы уже знаем, что зависимости нужно передавать через конструктор (DI), поэтому пишем так:

class ProfileViewModel(
    private val networkClient: RetrofitNetworkClient
) : ViewModel() {

    fun loadProfile(userId: String) {
        // Логика обращения к сети и обновления StateFlow
        val user = networkClient.fetchUser(userId)
        // ...
    }
}

С точки зрения DI всё сделано верно: ViewModel не создает RetrofitNetworkClient сама. Но с точки зрения архитектуры здесь кроется фатальный изъян.

Если мы захотим написать Unit-тест (модульный тест) для проверки логики внутри ProfileViewModel, нам придется передать в её конструктор экземпляр RetrofitNetworkClient. А этот клиент по своей природе делает реальные HTTP-запросы.

Unit-тест (Модульный тест) — это автоматизированный тест, который проверяет минимальную единицу кода (обычно один класс или метод) в полной изоляции от остальной системы.

Если тест вызывает реальную сеть, он перестает быть Unit-тестом. Он становится интеграционным. Если такой тест упадет, вы не будете знать причину: ошибка в логике ViewModel или просто отвалился Wi-Fi?

Интерфейс как архитектурная граница

Чтобы разорвать эту жесткую связь, нам нужно вспомнить принцип DIP (Dependency Inversion Principle) из главы про SOLID. Модули верхнего уровня (ViewModel) не должны зависеть от модулей нижнего уровня (конкретный NetworkClient). Оба должны зависеть от абстракций.

Мы выделяем интерфейс (контракт):

interface UserRepository {
    fun getUser(userId: String): User
}

Теперь мы меняем конструктор нашей ViewModel. Она больше ничего не знает про сеть, Retrofit или HTTP-запросы. Она знает только, что есть некий объект, способный отдать ей пользователя по ID.

class ProfileViewModel(
    private val repository: UserRepository
) : ViewModel() {

    fun loadProfile(userId: String) {
        val user = repository.getUser(userId)
        // ...
    }
}

В реальном приложении (в AppContainer из прошлой главы) мы передадим туда настоящую реализацию:

class RealUserRepository(
    private val networkClient: RetrofitNetworkClient
) : UserRepository {
    override fun getUser(userId: String): User {
        return networkClient.fetchUser(userId)
    }
}

Но что это дает нам в тестах? Абсолютную свободу.

Тестовые двойники (Test Doubles) и Fakes

Поскольку ProfileViewModel теперь требует только соблюдения контракта UserRepository, в тестовом окружении мы можем создать специальную, «игрушечную» реализацию этого интерфейса.

Такие объекты называются тестовыми двойниками (Test Doubles) — по аналогии с каскадерами в кино, которые заменяют реальных актеров в опасных сценах.

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

Напишем фейк для нашего репозитория:

class FakeUserRepository : UserRepository {
    // Имитируем базу данных с помощью простого словаря в памяти
    private val users = mapOf(
        "1" to User("1", "Иван", "ivan@example.com"),
        "2" to User("2", "Анна", "anna@example.com")
    )

    override fun getUser(userId: String): User {
        return users[userId] ?: throw Exception("Пользователь не найден")
    }
}

Теперь посмотрите, как элегантно выглядит Unit-тест для нашей ViewModel:

fun testProfileViewModelLoadsUserCorrectly() {
    // 1. Подготовка (Arrange)
    val fakeRepository = FakeUserRepository()
    val viewModel = ProfileViewModel(fakeRepository)

    // 2. Действие (Act)
    viewModel.loadProfile("1")

    // 3. Проверка (Assert)
    // Здесь мы проверяем, что StateFlow внутри ViewModel
    // обновился правильными данными Ивана.
}

Этот тест выполнится за миллисекунды. Ему не нужен интернет. Он никогда не упадет из-за таймаута сервера. И самое главное: если этот тест падает, мы на 100% уверены, что ошибка кроется именно в логике ProfileViewModel, потому что всё остальное окружение полностью под нашим контролем.

Проектирование через тестирование

Часто разработчики задают вопрос: «Зачем мне создавать интерфейс UserRepository, если у меня в приложении всегда будет ровно одна его реализация — получение данных из сети? Разве это не избыточность?».

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

Если класс трудно протестировать (невозможно подменить зависимости, нужно поднимать базу данных) — это почти всегда индикатор плохой архитектуры. Выделение интерфейсов выступает инструментом, который заставляет вас делать код слабо связанным (loosely coupled).

Интерфейс здесь — это не просто способ реализовать полиморфизм. Это хирургический разрез, по которому вы можете отделить бизнес-логику от инфраструктуры, чтобы поместить её в стерильную среду модульного теста.

Асинхронное программирование: Введение в Kotlin Coroutines

Асинхронное программирование: Введение в Kotlin Coroutines

Каждый пользователь Android хотя бы раз видел диалоговое окно «Приложение не отвечает» (ANR — Application Not Responding). Оно появляется, когда интерфейс намертво зависает: кнопки не нажимаются, списки не скроллятся, анимации замирают. Причина этого кроется в фундаментальном правиле Android: вся работа с UI происходит в одном-единственном потоке.

Проблема главного потока (Main Thread)

Когда операционная система запускает ваше приложение, она создает Main Thread (Главный поток). Именно в нем вызываются методы жизненного цикла (onCreate, onResume), отрисовываются кастомные View на Canvas и срабатывают лямбды обработки кликов.

Main Thread работает как бесконечный цикл, который постоянно проверяет, нет ли новых событий (нажатий, необходимости перерисовать экран). Если вы напишете код, который скачивает файл из сети прямо внутри обработчика клика, поток остановится и будет ждать окончания загрузки. В этот момент приложение ослепнет и оглохнет.

Чтобы интерфейс оставался отзывчивым, тяжелые задачи (сеть, базы данных, обработка файлов) нужно убирать из Main Thread. Исторически для этого создавали новые потоки операционной системы (OS Threads). Но у потоков ОС есть две проблемы:

  1. Они тяжелые. На создание одного потока уходит около 1-2 мегабайт оперативной памяти. Создадите тысячу потоков — приложение упадет с ошибкой нехватки памяти (OOM).
  2. Переключение контекста. Процессору дорого обходится постоянное переключение между разными потоками ОС.

Здесь на сцену выходят корутины.

Что такое корутина?

Корутина (Coroutine) — это блок кода, который умеет приостанавливать свое выполнение, не блокируя при этом поток, в котором он работает.

Их часто называют «легковесными потоками», но технически корутина — это просто задача (объект в памяти), которую выполняет реальный поток. Когда корутине нужно подождать ответа от сервера, она говорит потоку: «Я пока на паузе, иди выполняй другие корутины».

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

Магия ключевого слова suspend

Чтобы функция могла «встать на паузу», в Kotlin используется модификатор suspend (приостановить).

suspend fun fetchUserProfile(userId: String): UserProfile {
    // Имитация долгого сетевого запроса
    delay(2000)
    return UserProfile(userId, "Alex")
}

Функция delay() — это встроенная suspend-функция Kotlin. В отличие от Thread.sleep(), которая намертво усыпляет весь поток, delay() делает умную вещь: она приостанавливает только текущую корутину. Поток освобождается и идет рисовать UI или обслуживать другие корутины. Через 2 секунды корутина просыпается, забирает поток обратно и продолжает работу со следующей строчки.

Запуск корутин: Scope и Builder

Вы не можете просто вызвать suspend-функцию из обычного метода, например, из onCreate() в Activity. Обычная функция не умеет вставать на паузу. Корутину нужно явно создать и запустить.

Для этого нужны две вещи: CoroutineScope (область видимости) и Coroutine Builder (строитель, обычно это функция launch).

CoroutineScope: контроль над жизнью корутины

Вспомните проблему утечек памяти. Если мы запустим загрузку данных, а пользователь закроет экран, результат нам больше не нужен. Загрузку нужно отменить, иначе она впустую потратит батарею и может попытаться обновить уже уничтоженный UI.

CoroutineScope — это объект, который следит за всеми запущенными в нем корутинами. Если мы отменяем Scope, он автоматически отменяет все свои корутины.

В архитектуре MVVM нам не нужно создавать Scope вручную. Библиотеки Android предоставляют готовый viewModelScope. Он живет ровно столько же, сколько живет ViewModel, и автоматически отменяется, когда ViewModel уничтожается.

Builder launch: мост в асинхронный мир

Функция launch — это строитель. Она создает новую корутину и запускает ее. Код внутри блока launch выполняется асинхронно, а код снаружи продолжает идти своим чередом, не дожидаясь завершения корутины.

Соберем всё вместе в нашей ProfileViewModel:

class ProfileViewModel(
    private val userRepository: UserRepository // Наш интерфейс-абстракция
) : ViewModel() {

    // Инкапсулированное состояние (из прошлых глав)
    private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
    val uiState: StateFlow<UiState> = _uiState

    fun loadData(userId: String) {
        // 1. Обращаемся к области видимости ViewModel
        // 2. Запускаем новую корутину через launch
        viewModelScope.launch {
            // Этот код выполняется внутри корутины
            _uiState.value = UiState.Loading

            // Вызываем suspend-функцию.
            // Корутина приостановится, пока данные не загрузятся.
            // Главный поток в это время свободен!
            val user = userRepository.fetchUser(userId)

            // Корутина возобновляется, когда данные получены
            _uiState.value = UiState.Success(user)
        }
    }
}

В этом примере мы связали воедино сразу несколько концепций. Мы используем интерфейс UserRepository (чтобы код был тестируемым), обновляем StateFlow (чтобы реактивно передать данные в UI), и делаем это внутри корутины, чтобы не заблокировать Main Thread.

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

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

Управление потоками с помощью Coroutine Dispatchers

Управление потоками с помощью Coroutine Dispatchers

Вы запускаете корутину через viewModelScope.launch, внутри читаете мегабайтный файл из файловой системы устройства, и... интерфейс приложения намертво зависает, выдавая ошибку ANR (Application Not Responding). Но ведь функция отмечена модификатором suspend, а корутины — это асинхронность. Почему UI заблокировался?

Дело в том, что suspend лишь дает корутине способность приостанавливаться. Но если внутри корутины вы вызываете тяжелый синхронный код (чтение файла, сложную математику), этот код будет выполняться на том потоке, где корутина была запущена. А viewModelScope по умолчанию запускает все корутины в Main Thread — главном потоке отрисовки интерфейса.

Чтобы тяжелая работа не мешала интерфейсу, корутину нужно отправить в другой поток. Этим занимаются Dispatchers (Диспетчеры).

Диспетчеры и пулы потоков

Диспетчер — это менеджер, который решает, на каком потоке операционной системы будет выполняться ваша корутина. Вместо того чтобы каждый раз создавать новый поток (что очень дорого для системы), диспетчеры используют пулы потоков (Thread Pools) — заранее созданные наборы потоков, которые ждут задач.

В Kotlin есть три основных диспетчера:

Диспетчер Назначение Особенности пула потоков
Dispatchers.Main Обновление UI, вызов функций View, запись в StateFlow. Привязан строго к одному потоку — Main Thread.
Dispatchers.IO Ввод/вывод: сеть, чтение файлов, работа с локальной базой данных. Большой пул потоков (по умолчанию от 64). Потоки здесь в основном ждут ответа от диска или сети, поэтому их может быть много.
Dispatchers.Default Тяжелые вычисления: сортировка огромных списков, обработка изображений (Bitmap), парсинг сложных данных. Размер пула равен количеству ядер процессора: Nthreads=NcoresN_{threads} = N_{cores}.

Разница между IO и Default критически важна. Если вы попытаетесь загрузить 100 файлов из сети, используя Dispatchers.Default на 4-ядерном процессоре, у вас будет всего 4 потока. Они быстро заблокируются ожиданием ответа от сервера, и загрузка будет идти медленно. С другой стороны, если вы запустите тяжелую математическую фильтрацию картинки на Dispatchers.IO, система создаст десятки потоков, которые начнут драться за процессорное время. Переключение между ними (Context Switch) съест все ресурсы, и вычисления замедлятся. Правило простое: ждем внешнюю систему — IO, считаем сами — Default.

Безопасное переключение: withContext

Мы выяснили, что бизнес-логику и работу с данными нужно уводить с Main Thread. Но как это сделать, если корутина уже стартовала во viewModelScope? Для этого существует suspend-функция withContext.

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

fun processUserAvatar() {
    viewModelScope.launch {
        // Мы в Main Thread. Можно показать индикатор загрузки
        _uiState.value = UiState.Loading

        // Перемещаемся в пул потоков Default для тяжелой математики
        val filteredBitmap = withContext(Dispatchers.Default) {
            applyHeavyBlurFilter(originalBitmap) // Выполняется в фоновом потоке
        }

        // withContext завершился. Мы АВТОМАТИЧЕСКИ вернулись в Main Thread!
        // Можно безопасно обновлять UI
        _uiState.value = UiState.Success(filteredBitmap)
    }
}

Синтаксис withContext избавляет нас от "ада коллбэков" (Callback Hell). Код выглядит как обычный синхронный, читается сверху вниз, но под капотом он прыгает между потоками, не блокируя UI.

Примечание: Если вы используете популярные библиотеки вроде Retrofit (для сети) или Room (для баз данных), вам чаще всего не нужно писать withContext(Dispatchers.IO) вручную. Эти библиотеки под капотом уже написаны так, чтобы самостоятельно уводить свои suspend-функции в безопасные пулы.

Чистая архитектура: где место диспетчеру?

С точки зрения ООП и разделения ответственности (SOLID), возникает вопрос: кто должен вызывать withContext? ViewModel или слой данных (Repository)?

Рассмотрим плохой пример:

// ПЛОХО: ViewModel управляет потоками репозитория
class UserViewModel(private val repository: UserRepository) : ViewModel() {
    fun loadData() {
        viewModelScope.launch {
            val data = withContext(Dispatchers.IO) {
                repository.getUser()
            }
            _uiState.value = UiState.Success(data)
        }
    }
}

Почему это нарушает архитектуру? ViewModel не должна знать, откуда репозиторий берет данные. Возможно, getUser() читает данные из оперативной памяти (кэш), и переключение на Dispatchers.IO здесь будет лишней тратой ресурсов.

Правильный подход: каждая suspend-функция должна быть "Main-safe" (безопасной для вызова из главного потока). Это значит, что функция сама отвечает за то, чтобы уйти на нужный диспетчер, если ей это необходимо.

// ХОРОШО: Репозиторий сам инкапсулирует работу с потоками
class UserRepository {
    suspend fun getUser(): User {
        // Репозиторий сам решает, что ему нужен IO
        return withContext(Dispatchers.IO) {
            database.userDao().fetch()
        }
    }
}

class UserViewModel(private val repository: UserRepository) : ViewModel() {
    fun loadData() {
        viewModelScope.launch {
            // ViewModel просто вызывает функцию. Никаких withContext.
            val data = repository.getUser()
            _uiState.value = UiState.Success(data)
        }
    }
}

Инъекция диспетчеров для тестирования

Если мы жестко пропишем Dispatchers.IO внутри репозитория, мы столкнемся с проблемой при написании Unit-тестов. В тестовой среде нет реального Android-устройства, и многопоточность делает тесты нестабильными. В тестах мы хотим, чтобы весь код выполнялся синхронно на одном тестовом потоке.

Вспоминаем принцип инверсии зависимостей (DIP): класс не должен сам создавать свои инструменты. Диспетчер — это тоже инструмент. Передадим его через конструктор:

class UserRepository(
    private val database: UserDatabase,
    // Внедряем диспетчер. По умолчанию ставим IO для реальной работы
    private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
    suspend fun getUser(): User {
        return withContext(ioDispatcher) {
            database.userDao().fetch()
        }
    }
}

Теперь в реальном приложении DI-контейнер передаст сюда Dispatchers.IO, а в Unit-тестах мы сможем передать специальный тестовый диспетчер (StandardTestDispatcher), который выполнит код мгновенно и предсказуемо.

Проектирование базы данных Room: ООП-представление сущностей

Проектирование базы данных Room: ООП-представление сущностей

Представьте, что вам нужно упаковать сложную трехмерную фигуру в плоскую двумерную коробку, а потом достать её оттуда без единой царапины. Именно с этой проблемой сталкиваются разработчики при сохранении объектов в базу данных. Оперативная память мыслит категориями ООП: объектами, ссылками, вложенными структурами и коллекциями. Реляционная база данных SQLite мыслит таблицами, строками и колонками.

Механизм, который переводит данные с языка таблиц на язык объектов и обратно, называется ORM (Object-Relational Mapping). В Android официальным и самым популярным ORM-решением является библиотека Room.

Room — это не сама база данных. Это умная ООП-обертка над встроенной в Android базой данных SQLite. Room берет на себя всю грязную работу по написанию SQL-запросов и парсингу результатов, позволяя вам работать с базой данных так, будто это просто коллекция Kotlin-объектов.

Сущность (Entity): от объекта к таблице

В реляционных базах данных информация хранится в таблицах. В Kotlin мы храним данные в data class. Чтобы Room понял, как превратить наш класс в таблицу, мы используем аннотации — специальные метки для компилятора.

Возьмем пример — задачу в трекере:

@Entity(tableName = "tasks")
data class Task(
    @PrimaryKey(autoGenerate = true)
    val id: Long = 0,

    @ColumnInfo(name = "task_title")
    val title: String,

    val isCompleted: Boolean = false
)

Что здесь происходит с точки зрения ООП и баз данных:

  1. @Entity: Эта аннотация превращает обычный data class в «сущность». Room создаст в SQLite таблицу с именем tasks. Каждое свойство класса станет колонкой в этой таблице.
  2. @PrimaryKey: В ООП объекты различаются по ссылкам в памяти. В базе данных строки различаются по первичному ключу. Мы говорим Room: «Поле id — это уникальный идентификатор». Флаг autoGenerate = true поручает базе данных самой назначать номера (1, 2, 3...) при создании новых объектов.
  3. @ColumnInfo: По умолчанию Room называет колонки в таблице так же, как свойства в классе. Если мы хотим, чтобы в базе колонка называлась иначе (например, для совместимости со старым кодом), мы явно задаем имя.

Обратите внимание: мы используем значения по умолчанию (id = 0, isCompleted = false). Это позволяет создавать новые задачи, передавая только заголовок: Task(title = "Купить молоко"). Room увидит id = 0 и поймет, что это новый объект, которому нужно сгенерировать настоящий ID при сохранении.

DAO: Интерфейс как контракт

Мы создали таблицу, теперь нам нужно как-то с ней взаимодействовать: сохранять, удалять и читать данные. В чистом SQLite для этого пришлось бы писать строковые SQL-запросы и вручную перебирать курсор с результатами.

В Room мы используем паттерн DAO (Data Access Object). Мы уже знаем, что интерфейсы в Kotlin служат контрактами. DAO — это именно такой контракт. Мы объявляем что хотим сделать, а Room сам пишет код, который определяет, как это сделать.

@Dao
interface TaskDao {
    @Insert
    suspend fun insertTask(task: Task)

    @Delete
    suspend fun deleteTask(task: Task)

    @Query("SELECT * FROM tasks WHERE isCompleted = 0")
    suspend fun getActiveTasks(): List<Task>
}

Здесь раскрывается мощь корутин, которые мы разбирали ранее. Операции с диском (чтение/запись БД) — это медленный процесс. Если вызвать их в Main Thread, интерфейс приложения зависнет (появится ошибка ANR).

Помечая функции модификатором suspend, мы сообщаем Room, что эти операции асинхронные. Room автоматически делает свои suspend-функции Main-safe — он сам переключит выполнение на фоновый пул потоков (под капотом используя диспетчеры, аналогичные Dispatchers.IO), выполнит SQL-запрос и вернет результат. Нам не нужно оборачивать вызовы DAO в withContext.

Столкновение миров: Type Converters

SQLite — очень простая база данных. Она поддерживает всего несколько базовых типов данных: целые числа (INTEGER), числа с плавающей точкой (REAL), текст (TEXT) и бинарные данные (BLOB).

Но в нашем ООП-коде мы используем богатые типы. Допустим, мы хотим добавить нашей задаче приоритет с помощью enum class и дату создания:

enum class Priority { LOW, MEDIUM, HIGH }

@Entity(tableName = "tasks")
data class Task(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val title: String,
    val priority: Priority, // Ошибка! SQLite не знает, что такое Priority
    val createdAt: java.util.Date // Ошибка! SQLite не знает Date
)

Если мы попытаемся скомпилировать этот код, Room выдаст ошибку. Он не знает, как сохранить Priority или Date в колонку таблицы.

Чтобы не портить нашу красивую ООП-модель (не менять Priority на обычный String прямо в data class), мы используем Type Converters (конвертеры типов). Это функции, которые объясняют Room, как превратить сложный объект в примитив при записи в базу, и как собрать сложный объект обратно из примитива при чтении.

Создадим класс с конвертерами:

class TaskConverters {
    // Конвертация для Date
    @TypeConverter
    fun fromTimestamp(value: Long?): Date? {
        return value?.let { Date(it) }
    }

    @TypeConverter
    fun dateToTimestamp(date: Date?): Long? {
        return date?.time
    }

    // Конвертация для Enum
    @TypeConverter
    fun fromPriority(priority: Priority): String {
        return priority.name // Превращаем enum в строку ("HIGH")
    }

    @TypeConverter
    fun toPriority(value: String): Priority {
        return Priority.valueOf(value) // Из строки обратно в enum
    }
}

Теперь Room знает: когда нужно сохранить Date, он вызовет dateToTimestamp и сохранит обычное число (Long). Когда мы запросим задачу из базы, Room прочитает число, вызовет fromTimestamp и вернет нам полноценный объект Date. Наша бизнес-логика остается чистой и продолжает работать с удобными типами.

Сборка воедино: класс Database

У нас есть сущность (Task), контракт для работы с ней (TaskDao) и правила перевода сложных типов (TaskConverters). Последний шаг — связать всё это в единую базу данных.

Для этого создается абстрактный класс, наследующий RoomDatabase:

@Database(entities = [Task::class], version = 1)
@TypeConverters(TaskConverters::class)
abstract class AppDatabase : RoomDatabase() {

    // Room сам сгенерирует реализацию этого метода
    abstract fun taskDao(): TaskDao
}

Почему класс абстрактный? Потому что мы описываем только архитектуру. Во время компиляции проекта Room анализирует эти аннотации и генерирует реальный Java/Kotlin код, который наследует AppDatabase, реализует метод taskDao() и содержит всю низкоуровневую логику работы с SQLite.

В этом суть Room как ORM-системы: она позволяет вам оставаться на уровне абстракций ООП. Вы оперируете объектами, интерфейсами и аннотациями, а рутина по перекладыванию байтов в таблицы генерируется автоматически.

Продвинутая работа с Room: связи между таблицами и транзакции

Продвинутая работа с Room: связи между таблицами и транзакции

В объектно-ориентированном коде объекты естественным образом содержат другие объекты. Категория включает в себя список задач, а задача содержит список тегов. В памяти приложения это выглядит как дерево. Но реляционные базы данных, такие как SQLite, под капотом Room, плоские. Они не умеют хранить списки внутри ячеек — они хранят только строки и числа, связывая их через идентификаторы.

Это фундаментальное противоречие называется объектно-реляционным несоответствием (Object-Relational Impedance Mismatch).

Если мы попытаемся добавить список в нашу сущность Task, Room выдаст ошибку компиляции: он не знает, как сохранить List в колонку таблицы. Чтобы решить эту проблему, сохраняя чистоту архитектуры, Room предлагает механизм агрегирующих классов и аннотацию @Relation.

Связь «Один ко многим» (1:N1:N)

Представим, что в нашем трекере задач появилась группировка. У нас есть таблица категорий (например, «Работа», «Дом») и таблица задач. Одна категория содержит много задач.

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

@Entity(tableName = "categories")
data class Category(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val name: String
)

@Entity(tableName = "tasks")
data class Task(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val title: String,
    val categoryId: Long // Ссылка на категорию
)

Обе сущности остаются плоскими. Но для бизнес-логики и UI нам нужен готовый объект, где внутри категории уже лежит список её задач.

Вместо того чтобы ломать структуру @Entity, мы создаем агрегирующий класс (POJO — Plain Old Java Object). Это обычный data class, который не является таблицей, но описывает то, как Room должен собрать данные вместе.

data class CategoryWithTasks(
    @Embedded
    val category: Category,

    @Relation(
        parentColumn = "id",         // Поле id в Category
        entityColumn = "categoryId"  // Поле categoryId в Task
    )
    val tasks: List<Task>
)

Разберем магию аннотаций:

  • @Embedded говорит Room: «Возьми все колонки таблицы categories и собери из них объект Category».
  • @Relation говорит: «Сделай отдельный SQL-запрос в таблицу tasks, найди все строки, где categoryId совпадает с id этой категории, и положи их в этот список».

Теперь в DAO мы можем запрашивать готовые деревья объектов:

@Dao
interface CategoryDao {
    @Query("SELECT * FROM categories")
    suspend fun getCategoriesWithTasks(): List<CategoryWithTasks>
}

Транзакции: принцип «Всё или ничего»

Когда вы вызываете метод getCategoriesWithTasks(), Room под капотом выполняет не один, а два SQL-запроса: сначала получает все категории, а затем отдельным запросом подтягивает задачи для них.

Представьте ситуацию: Room выполнил первый запрос и получил список категорий. В эту же миллисекунду фоновый процесс приложения удаляет одну категорию и её задачи. Room выполняет второй запрос, но данные уже изменились. В результате вы получите рассинхронизированный объект.

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

В Room для этого достаточно добавить аннотацию @Transaction:

@Dao
interface CategoryDao {
    @Transaction
    @Query("SELECT * FROM categories")
    suspend fun getCategoriesWithTasks(): List<CategoryWithTasks>
}

Для @Relation транзакция гарантирует изоляцию: на время выполнения обоих запросов база данных фиксирует свое состояние (создает снимок), и никакие параллельные изменения не повлияют на результат.

Транзакции критически важны и при записи. Если вам нужно сохранить новую категорию и сразу же список задач для неё, это должно происходить атомарно:

@Dao
interface CategoryDao {
    @Insert
    suspend fun insertCategory(category: Category): Long

    @Insert
    suspend fun insertTasks(tasks: List<Task>)

    @Transaction
    suspend fun insertCategoryWithTasks(category: Category, tasks: List<Task>) {
        // Если insertTasks упадет с ошибкой, insertCategory будет отменен автоматически
        val newCategoryId = insertCategory(category)
        val tasksWithId = tasks.map { it.copy(categoryId = newCategoryId) }
        insertTasks(tasksWithId)
    }
}

Связь «Многие ко многим» (N:MN:M)

Связь 1:N1:N решается добавлением идентификатора родителя в дочернюю таблицу. Но что делать с тегами? Одна задача может иметь теги «Срочно» и «Баг». При этом тег «Срочно» может быть прикреплен к десяткам разных задач.

Реляционные базы данных не позволяют хранить массивы идентификаторов. Единственный способ реализовать связь N:MN:M — создать третью, промежуточную таблицу (Cross-Reference table). Она хранит пары идентификаторов: «какая задача связана с каким тегом».

Шаг 1. Создаем сущность тега и промежуточную сущность:

@Entity(tableName = "tags")
data class Tag(
    @PrimaryKey(autoGenerate = true) val tagId: Long = 0,
    val name: String
)

@Entity(
    tableName = "task_tag_cross_ref",
    primaryKeys = ["taskId", "tagId"] // Композитный первичный ключ
)
data class TaskTagCrossRef(
    val taskId: Long,
    val tagId: Long
)

Композитный первичный ключ означает, что пара (taskId, tagId) должна быть уникальной — нельзя прикрепить один и тот же тег к одной задаче дважды.

Шаг 2. Создаем агрегирующий класс с использованием Junction (соединения):

data class TaskWithTags(
    @Embedded
    val task: Task,

    @Relation(
        parentColumn = "id", // id из Task
        entityColumn = "tagId", // tagId из Tag
        associateBy = Junction(TaskTagCrossRef::class) // Указание на промежуточную таблицу
    )
    val tags: List<Tag>
)

Здесь мы говорим Room: «Чтобы найти теги для задачи, сначала загляни в таблицу TaskTagCrossRef, найди там все tagId для текущего id задачи, а затем по этим tagId достань сами объекты из таблицы tags».

Шаг 3. В DAO снова используем @Transaction, так как под капотом Room будет выполнять сложные объединения (JOIN) и несколько запросов:

@Dao
interface TaskDao {
    @Transaction
    @Query("SELECT * FROM tasks")
    suspend fun getTasksWithTags(): List<TaskWithTags>
}

Разделение на @Entity (структура хранения) и агрегирующие классы (структура использования) — это отличный пример принципа единственной ответственности (SRP). База данных остается нормализованной и быстрой, а бизнес-логика получает удобные, вложенные друг в друга объекты, с которыми легко работать.

Репозиторий как ООП-абстракция над базой данных и сетью

Репозиторий как ООП-абстракция над базой данных и сетью

Представьте: ваша ViewModel отлично работает, получая задачи напрямую из базы данных через TaskDao. Но внезапно приходит требование — добавить облачную синхронизацию. Теперь при каждом обновлении экрана ViewModel должна проверить наличие интернета, сделать запрос к серверу, сравнить локальные данные с облачными, разрешить конфликты и только потом обновить UI.

Если мы напишем весь этот код внутри ViewModel, она мгновенно превратится в неподдерживаемый «божественный объект» (God Object), нарушив принцип единственной ответственности (SRP).

Чтобы спасти архитектуру, нам нужен посредник. В объектно-ориентированном проектировании эту роль играет паттерн Репозиторий (Repository).

Что такое Репозиторий?

Репозиторий — это ООП-абстракция, которая скрывает детали происхождения данных. Для остального приложения (например, для ViewModel) репозиторий выглядит как простая коллекция объектов в оперативной памяти.

ViewModel просто говорит: «Дай мне список задач». Ей абсолютно неважно, возьмет ли репозиторий эти данные из кэша, прочитает из SQLite через Room или скачает по сети через Retrofit.

Репозиторий решает три главные задачи:

  1. Инкапсуляция логики данных: объединяет работу с локальной БД и удаленным сервером.
  2. Разделение моделей: переводит данные из форматов сети и БД в удобный для UI формат.
  3. Единая точка истины (Single Source of Truth): гарантирует, что пользователь всегда видит консистентные данные.

Шаг 1: Проектирование контракта (DIP)

Вспоминаем принцип инверсии зависимостей (DIP) из SOLID: модули верхнего уровня (ViewModel) не должны зависеть от модулей нижнего уровня (конкретных реализаций баз данных). Обе стороны должны зависеть от абстракций.

Поэтому мы начинаем не с написания класса, а с создания интерфейса:

interface TaskRepository {
    // Возвращает реактивный поток сложных агрегированных объектов (из прошлой главы)
    fun observeAllTasks(): Flow<List<TaskWithTags>>

    // Команда на принудительное обновление данных с сервера
    suspend fun refreshTasks()

    // Сохранение новой задачи
    suspend fun addTask(title: String, priority: Priority)
}

Теперь ViewModel может принимать этот интерфейс через конструктор (Dependency Injection), вообще ничего не зная про Room или сеть. Это также позволит нам легко написать Unit-тесты, подсунув фейковый репозиторий.

Шаг 2: Реализация и Single Source of Truth

Теперь создадим конкретный класс, который реализует наш интерфейс. В него мы передадим инструменты для работы с данными: TaskDao (база данных) и TaskApi (сеть).

class OfflineFirstTaskRepository(
    private val dao: TaskDao,
    private val api: TaskApi
) : TaskRepository {

    override fun observeAllTasks(): Flow<List<TaskWithTags>> {
        return dao.observeAllTasks()
    }

    override suspend fun refreshTasks() {
        try {
            // 1. Скачиваем свежие данные из сети
            val remoteTasks = api.fetchTasks()
            // 2. Сохраняем их в локальную базу данных
            dao.insertAll(remoteTasks.map { it.toEntity() })
        } catch (e: Exception) {
            // Обработка отсутствия сети
        }
    }

    override suspend fun addTask(title: String, priority: Priority) {
        val newTask = TaskEntity(title = title, priority = priority)
        dao.insert(newTask) // Сначала сохраняем локально, чтобы UI обновился мгновенно
        try {
            api.pushTask(newTask.toDto()) // Затем отправляем на сервер
        } catch (e: Exception) {
            // Помечаем задачу как "ожидающую синхронизации"
        }
    }
}

Обратите внимание на метод observeAllTasks(). Он возвращает данные только из локальной базы данных (Room Flow). Метод refreshTasks() скачивает данные из сети, но не возвращает их напрямую, а сохраняет в базу.

Это реализация фундаментального паттерна Single Source of Truth (SSOT) — Единый источник истины.

При подходе SSOT локальная база данных выступает единственным источником правды для UI. Сеть используется только для того, чтобы обновлять базу данных. Как только refreshTasks() сохранит новые данные в Room, реактивный Flow автоматически заметит изменения в таблице и протолкнет новый список во ViewModel.

Шаг 3: Маппинг данных (DTO и Entity)

В коде выше вы могли заметить вызовы toEntity() и toDto(). Это еще одна важнейшая зона ответственности репозитория — перевод данных (маппинг).

В чистой архитектуре каждый слой должен иметь свои модели данных:

  • DTO (Data Transfer Object): Модель сети. Отражает структуру JSON, приходящего с сервера (часто содержит странные имена полей вроде task_desc_str).
  • Entity: Модель базы данных. Содержит аннотации Room (@PrimaryKey, @ColumnInfo) и внешние ключи.
  • Domain Model: Чистая бизнес-модель для UI. Не знает ни про JSON, ни про таблицы.

Если сервер изменит структуру JSON, мы поменяем только DTO и функцию-маппер в репозитории. База данных и ViewModel даже не узнают об этом изменении.

// Функция-расширение для перевода сетевой модели в модель БД
fun TaskDto.toEntity(): TaskEntity {
    return TaskEntity(
        id = this.serverId,
        title = this.taskName,
        priority = Priority.valueOf(this.priorityLevel)
    )
}

Репозиторий выступает своеобразной таможней: он берет грязные данные из внешнего мира (сети), очищает их, переводит в локальный формат (Entity) и складывает на склад (базу данных). А ViewModel получает уже чистые, готовые к отображению агрегированные объекты.

Итог

Внедрение репозитория завершает построение чистого слоя данных в нашем приложении.

  1. Мы использовали интерфейсы, чтобы отвязать бизнес-логику от конкретных технологий.
  2. Мы применили внедрение зависимостей (DI), передав DAO и API через конструктор.
  3. Мы реализовали SSOT, объединив Room и Coroutines Flow для создания реактивного, устойчивого к потере сети приложения.

Теперь наша ViewModel снова стала легкой и сфокусированной исключительно на подготовке данных для UI.

Введение в Kotlin Multiplatform (KMP): разделение бизнес-логики

Введение в Kotlin Multiplatform (KMP): разделение бизнес-логики

Представьте, что ваше приложение — трекер задач с идеально спроектированным OfflineFirstTaskRepository — стало хитом на Android. Бизнес ставит новую цель: «Нам срочно нужна версия для iOS». Классический подход диктует нанять команду iOS-разработчиков, которые с нуля перепишут на Swift всю вашу архитектуру: модели данных, логику кэширования, мапперы и сетевые запросы. Это удваивает бюджет, время разработки и количество потенциальных багов.

Но что, если можно написать бизнес-логику один раз, а пользовательский интерфейс оставить уникальным для каждой платформы? Именно эту задачу решает Kotlin Multiplatform (KMP).

Философия KMP: общая логика, нативный UI

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

Первый подход (например, Flutter) предлагает парадигму «напиши один раз, запускай везде». Вы пишете весь код, включая пользовательский интерфейс, на одном языке, а фреймворк сам рисует кнопки и списки на холсте экрана. Минус такого подхода — интерфейс может ощущаться «чужим» для операционной системы, а интеграция со специфичными системными функциями усложняется.

Второй подход — это Kotlin Multiplatform. Его философия звучит иначе: «Объединяй то, что одинаково, разделяй то, что отличается».

В KMP мы выносим в общую кодовую базу слой данных (сети, базы данных) и слой бизнес-логики (репозитории, модели, мапперы). Этот код компилируется в нативный формат для каждой платформы: в байт-код JVM для Android и в машинный код (через LLVM) для iOS. При этом слой пользовательского интерфейса (View) остается полностью нативным: для Android мы используем Jetpack Compose или XML, а для iOS — SwiftUI.

Пользователь получает приложение, которое выглядит и работает на 100% как родное, а разработчики поддерживают единый репозиторий с бизнес-правилами.

Анатомия KMP-проекта

Чтобы разделить код на общий и платформенный, проект KMP разбивается на специальные модули (source sets):

  1. commonMain — сердце проекта. Здесь лежит код, написанный на чистом Kotlin. Этот модуль ничего не знает о существовании Android или iOS. Вы не можете использовать здесь Context, Activity или классы из iOS SDK.
  2. androidMain — модуль, специфичный для Android. Он имеет доступ ко всему коду из commonMain, а также ко всем библиотекам Android (включая Context и Room).
  3. iosMain — модуль, специфичный для iOS. Он также видит commonMain, но имеет доступ к Apple SDK (Foundation, UIKit).

Именно в commonMain отправляется наш OfflineFirstTaskRepository. Вся логика — проверка наличия данных в кэше, запрос к сети, маппинг DTO в сущности — пишется здесь один раз.

Проблема платформенных зависимостей

Перенос репозитория в commonMain вскрывает фундаментальную архитектурную проблему.

Вспомним реализацию из предыдущих глав: репозиторий полагался на базу данных (Room) и сетевой клиент (Retrofit). Но Room и Retrofit — это Android-библиотеки (Java/JVM). Мы не можем добавить их в commonMain, потому что код из commonMain должен уметь компилироваться под iOS, где нет ни Java-машины, ни Room.

Как OfflineFirstTaskRepository, находясь в чистом commonMain, сможет сохранять данные в локальную базу, если реализация базы данных на Android и iOS будет разной?

Решение через ООП: Инверсия зависимостей (DIP)

Здесь на помощь приходит принцип инверсии зависимостей (DIP) из SOLID. Модуль верхнего уровня (commonMain) не должен зависеть от модулей нижнего уровня (androidMain или iosMain). Оба должны зависеть от абстракций.

Мы создаем абстракцию (интерфейс) в commonMain:

// Находится в commonMain
interface TaskDatabase {
    suspend fun saveTask(task: Task)
    suspend fun getAllTasks(): List<Task>
}

Теперь наш репозиторий в commonMain требует этот интерфейс через конструктор. Он не знает, как именно данные будут сохранены на диск, он знает только контракт:

// Находится в commonMain
class OfflineFirstTaskRepository(
    private val database: TaskDatabase, // Зависим от абстракции
    private val networkClient: NetworkClient
) {
    suspend fun syncTasks() {
        val remoteTasks = networkClient.fetchTasks()
        remoteTasks.forEach { database.saveTask(it) }
    }
}

Архитектура готова. Осталось предоставить конкретные реализации.

В модуле androidMain мы пишем класс, который реализует интерфейс TaskDatabase, используя привычный Android-разработчику Room (или передаем вызовы в DAO):

// Находится в androidMain
class RoomTaskDatabaseImpl(
    private val roomDao: TaskDao // Специфично для Android
) : TaskDatabase {

    override suspend fun saveTask(task: Task) {
        roomDao.insert(task.toEntity())
    }

    override suspend fun getAllTasks(): List<Task> {
        return roomDao.getAll().map { it.toDomain() }
    }
}

Для iOS-разработчиков в модуле iosMain будет написана своя реализация TaskDatabase, например, обертка над CoreData или SQLite.

На этапе сборки графа зависимостей (Dependency Injection) Android-приложение передаст в конструктор OfflineFirstTaskRepository экземпляр RoomTaskDatabaseImpl, а iOS-приложение — свою реализацию.

Использование интерфейсов — классический и самый надежный способ внедрить платформенно-зависимый код в общую бизнес-логику. Однако иногда создание целого интерфейса и классов-реализаций для одной простой функции (например, узнать версию операционной системы или сгенерировать UUID) кажется избыточным. Для таких точечных задач в Kotlin Multiplatform существует специальный языковой механизм на уровне компилятора, который позволяет требовать реализацию напрямую, без интерфейсов.

ООП в KMP: механизм expect/actual для платформенного кода

ООП в KMP: механизм expect/actual для платформенного кода

В прошлой главе мы решили проблему платформенных зависимостей (базы данных Room) элегантно и в духе SOLID: создали интерфейс в commonMain, написали его реализации в платформенных модулях и передали нужную через конструктор. Это классический принцип инверсии зависимостей (DIP).

Но представьте, что вам нужно получить уникальный идентификатор устройства (UUID), узнать версию операционной системы или просто вывести сообщение в системный лог. Создавать для каждой такой мелочи интерфейс, фабрику, реализовывать их в androidMain и iosMain, а затем прокидывать через граф Dependency Injection во все классы — это стрельба из пушки по воробьям. Код обрастает лишними абстракциями, которые усложняют чтение.

Для таких случаев в Kotlin Multiplatform встроен специальный языковой механизм — expect и actual.

Как работает expect/actual

Механизм expect/actual (ожидание/реализация) позволяет объявить «контракт» в общем коде, а его выполнение переложить на платформенные модули. В отличие от интерфейсов, это происходит не во время выполнения программы (runtime), а на этапе компиляции (compile-time).

Допустим, нам нужна функция для генерации UUID. В модуле commonMain мы пишем только сигнатуру функции, помечая её ключевым словом expect:

// Модуль: commonMain
expect fun randomUUID(): String

Для компилятора это обещание: «Я гарантирую, что когда ты будешь собирать приложение под конкретную платформу, ты найдешь реализацию этой функции». Внутри commonMain мы уже можем вызывать randomUUID(), код успешно скомпилируется.

Теперь мы обязаны выполнить обещание в платформенных модулях, используя слово actual:

// Модуль: androidMain
import java.util.UUID

actual fun randomUUID(): String {
    return UUID.randomUUID().toString()
}
// Модуль: iosMain
import platform.Foundation.NSUUID

actual fun randomUUID(): String {
    return NSUUID().UUIDString()
}

Обратите внимание: мы не создаем объекты и не передаем их через конструктор. Компилятор Kotlin просто берет вызов randomUUID() из commonMain и жестко «сшивает» его с нужной actual-функцией в зависимости от того, собираем ли мы APK для Android или IPA для iOS.

Ожидание классов и объектов

Механизм не ограничивается функциями. Мы можем использовать expect для классов, интерфейсов, объектов (Singleton) и свойств.

Например, нам нужен платформенный логгер. В commonMain мы объявляем ожидаемый класс:

// Модуль: commonMain
expect class PlatformLogger() {
    val tag: String
    fun logInfo(message: String)
}

Правило KMP гласит: actual-реализация должна полностью повторять структуру expect-декларации. Если в expect заявлен конструктор без аргументов, свойство tag и метод logInfo, то actual-класс обязан предоставить именно их:

// Модуль: androidMain
import android.util.Log

actual class PlatformLogger actual constructor() {
    actual val tag: String = "AndroidApp"

    actual fun logInfo(message: String) {
        Log.i(tag, message)
    }
}

Ключевое слово actual ставится перед самим классом, перед его конструктором и перед каждым членом класса, который был заявлен в expect.

Элегантный трюк: actual typealias

Иногда нужный нам класс уже существует в платформенной библиотеке, и писать для него actual-обертку — значит плодить лишний код. Kotlin позволяет напрямую связать expect-класс с существующим платформенным классом с помощью typealias (псевдонима типа).

Представим, что в commonMain нам нужен класс для потокобезопасного счетчика:

// Модуль: commonMain
expect class AtomicCounter() {
    fun incrementAndGet(): Int
}

В Android (Java) уже есть готовый класс AtomicInteger, который делает ровно то же самое. Вместо того чтобы писать класс-обертку, мы можем просто сказать компилятору: «Считай, что AtomicCounter — это и есть AtomicInteger».

// Модуль: androidMain
import java.util.concurrent.atomic.AtomicInteger

actual typealias AtomicCounter = AtomicInteger

Это работает, потому что у AtomicInteger есть пустой конструктор и метод incrementAndGet(), которые в точности совпадают с нашим expect-контрактом. Никаких накладных расходов в runtime — компилятор просто подставит оригинальный Java-класс.

Выбор архитектуры: Интерфейсы (DIP) против expect/actual

И интерфейсы, и expect/actual решают одну задачу — абстрагируют платформенный код от бизнес-логики. Но делают это на разных уровнях, и выбор неправильного инструмента приведет к проблемам с тестированием и архитектурой.

Характеристика Интерфейсы + DI expect / actual
Связывание Динамическое (Runtime) Статическое (Compile-time)
Состояние Отлично подходит для объектов с состоянием (БД, Сеть) Лучше для функций без состояния и простых утилит
Тестирование Легко подменить на Fake/Mock (передав другой объект) Сложно подменить (компилятор жестко привязывает реализацию)
Многословность Высокая (нужны фабрики, DI-контейнеры) Низкая (просто вызвал функцию)

Главное правило: если зависимость сложная, хранит состояние (например, подключение к базе данных) или вы захотите подменить её в Unit-тестах (например, сетевой клиент) — используйте Интерфейсы и DI.

Если зависимость — это простая платформенная утилита, математическая функция, форматирование даты или системный логгер, поведение которых не нужно подменять (мокать) в тестах — используйте expect/actual.

Механизм expect/actual — это мощный инструмент в арсенале Kotlin Multiplatform, который позволяет писать чистый код без избыточных абстракций, сохраняя при этом строгую типизацию и безопасность на этапе компиляции.

Глубокая настройка сборки Gradle: модульность и зависимости

Глубокая настройка сборки Gradle: модульность и зависимости

Сборка проекта завершена за 4 минуты 12 секунд. Вы изменили всего одну строчку текста на кнопке в UI, но Android Studio заново перекомпилировала базу данных, сетевой слой и логику профиля пользователя. Эта ситуация неизбежна, если весь код приложения лежит в одном гигантском модуле app.

Разделение кода на commonMain и платформенные модули показало, как физические границы защищают бизнес-логику от платформенных зависимостей. Тот же принцип применяется и внутри самого Android-приложения. По мере роста проекта один модуль превращается в «Божественный объект» (God Module) на уровне архитектуры.

Многомодульность — это применение принципов ООП (инкапсуляции и единой ответственности) к архитектуре всего проекта. Gradle выступает здесь главным дирижером.

Модуль как макро-объект

В ООП мы прячем детали реализации класса за приватными полями, выставляя наружу только публичный интерфейс. Модуль в Gradle работает точно так же, но на макроуровне.

Модуль — это независимая единица компиляции со своим собственным файлом build.gradle.kts. Он может быть собран и протестирован отдельно от остального приложения.

Разбивая монолит, мы обычно выделяем три типа модулей:

  1. Core-модули (Ядро): Базовые инфраструктурные вещи. core-network (работа с Retrofit), core-database (Room). Они не знают ни о чем, кроме своей узкой задачи.
  2. Feature-модули (Фичи): Изолированные экраны или сценарии. feature-profile, feature-auth. Они содержат свои ViewModel и UI.
  3. App-модуль (Контейнер): Тот самый модуль app. В чистой архитектуре он становится максимально «глупым» — его задача лишь собрать все фичи вместе и настроить DI-граф (Dependency Injection).

Когда feature-profile зависит от core-network, Gradle должен понимать характер этой связи. И здесь мы подходим к главному механизму инкапсуляции в сборке.

Инкапсуляция зависимостей: implementation против api

Каждый раз, подключая библиотеку или другой модуль в build.gradle.kts, вы используете ключевое слово. В 99% случаев это implementation. Но существует и api. Разница между ними — это разница между модификаторами private и public в ООП.

Допустим, у нас есть цепочка зависимостей: модуль AA зависит от модуля BB, а модуль BB зависит от библиотеки CC. ABCA \to B \to C

Ключевое слово implementation (Скрытая зависимость)

Если модуль BB подключает библиотеку $Cчерезimplementation`, он говорит Gradle: «Я использую эту библиотеку внутри себя, но не отдаю её наружу».

// build.gradle.kts модуля feature-profile (Модуль B)
dependencies {
    implementation(project(":core-network")) // Модуль C
}

Что происходит:

  • Код внутри feature-profile может использовать классы из core-network.
  • Модуль app (Модуль AA), который подключает feature-profile, не видит классы из core-network.
  • Плюс для сборки: Если вы измените код в core-network, Gradle перекомпилирует core-network и feature-profile. Модуль app перекомпилироваться не будет, потому что его публичный контракт (интерфейс) не изменился. Это кардинально ускоряет сборку.

Ключевое слово api (Публичная зависимость)

Если модуль BB подключает библиотеку $Cчерезapi`, он говорит: «Я использую эту библиотеку, и она является частью моего публичного интерфейса. Все, кто зависит от меня, тоже получат к ней доступ».

// build.gradle.kts модуля feature-profile
dependencies {
    api(project(":core-network"))
}

Что происходит:

  • Зависимость «протекает» наверх. Модуль app теперь может напрямую вызывать классы из core-network, хотя явно его не подключал.
  • Минус для сборки: Любое изменение в core-network вызовет цепную реакцию (каскадную компиляцию). Gradle придется пересобрать core-network, затем feature-profile, а затем и app.

Использование api нарушает принцип инкапсуляции. Выставлять зависимость через api стоит только тогда, когда типы из этой зависимости торчат в публичных интерфейсах вашего модуля (например, если метод вашего репозитория возвращает класс, определенный в другом модуле). По умолчанию всегда используйте implementation.

Version Catalogs: Единый источник истины для зависимостей

Когда модулей становится много, возникает новая проблема: рассинхронизация версий.

Представьте, что в feature-auth вы подключили корутины версии 1.7.1, а в feature-profile1.7.3. Gradle попытается разрешить конфликт, но это может привести к непредсказуемым ошибкам в рантайме. Нам нужен принцип Single Source of Truth (SSOT), который мы применяли к базам данных, но теперь — для конфигурации сборки.

Современный стандарт Gradle для решения этой задачи — Version Catalogs.

Вместо того чтобы хардкодить строки с зависимостями в каждом build.gradle.kts, мы создаем один центральный файл gradle/libs.versions.toml.

Структура файла .toml предельно проста и состоит из трех основных секций:

[versions]
# Здесь хранятся только номера версий
retrofit = "2.9.0"
room = "2.6.1"

[libraries]
# Здесь формируются сами зависимости, ссылаясь на версии выше
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }

[bundles]
# Группировка нескольких библиотек в один пакет
room-all = ["room-runtime", "room-compiler"]

Теперь в любом модуле проекта (будь то app или core-database) файл build.gradle.kts выглядит чисто и строго типизировано:

dependencies {
    // Gradle автогенерирует безопасный доступ через объект libs
    implementation(libs.retrofit.core)

    // Подключение сразу целого бандла (набора библиотек)
    implementation(libs.bundles.room.all)
}

Что это дает архитектуре?

  1. Безопасность типов: Опечатка в libs.retrofitt подсветится красным на этапе написания кода, а не во время сборки.
  2. Централизованное обновление: Чтобы обновить Room во всем приложении (во всех 20 модулях), достаточно изменить одну цифру в файле libs.versions.toml.
  3. Группировка (Bundles): Избавляет от необходимости копипастить портянки из 5-6 связанных библиотек (например, для тестирования или работы с сетью) из модуля в модуль.

Глубокая настройка Gradle превращает хаос зависимостей в строгую, предсказуемую систему. Модули становятся черными ящиками, общающимися только через разрешенные интерфейсы, а единый каталог версий гарантирует, что весь проект использует согласованный набор инструментов.

Оптимизация и защита кода с помощью ProGuard и R8

Оптимизация и защита кода с помощью ProGuard и R8

Вы завершили архитектурный рефакторинг, разбили приложение на модули, настроили базу данных и сеть. В режиме отладки (Debug) всё работает идеально. Вы собираете релизную версию (Release), загружаете её на телефон и запускаете. Приложение либо мгновенно падает, либо показывает пустые экраны вместо списка задач.

Почему код, который безупречно работал пять минут назад, ломается в релизе?

Ответ кроется в том, что код, который вы написали, и код, который реально выполняется на устройстве пользователя в релизной сборке — это два разных кода. Между ними стоит R8 — официальный компилятор Android, который сжимает, оптимизирует и запутывает ваше приложение перед публикацией.

Три этапа магии R8

В прошлом разработчики использовали сторонний инструмент ProGuard. Сегодня Google встроила его преемника — компилятор R8 — прямо в процесс сборки. Когда вы собираете релизный APK или App Bundle, R8 берет ваши красивые, структурированные классы и прогоняет их через три этапа.

1. Минификация (Shrinking / Tree Shaking)

R8 строит граф всех вызовов в приложении, начиная с точек входа (например, вашей MainActivity). Если какой-то класс, метод или переменная никогда не вызывается в этом графе, R8 безжалостно удаляет их. Это называется Tree Shaking (встряхивание дерева): мертвый код опадает, как сухие листья. Это критически важно, потому что подключенная библиотека (например, Retrofit) может весить мегабайты, а вы используете из неё лишь пару процентов функций.

2. Оптимизация (Optimization)

R8 анализирует логику. Если он видит интерфейс, у которого есть только одна реализация, он может удалить интерфейс и использовать класс напрямую. Если метод состоит из одной строки, R8 может встроить его код прямо в место вызова (inline), удалив сам метод. Ваш строгий SOLID-код на уровне байт-кода превращается в плоскую, но невероятно быструю структуру.

3. Обфускация (Obfuscation)

Это процесс запутывания (сокрытия) имен. R8 переименовывает ваши классы, методы и поля в короткие бессмысленные символы. Ваш класс OfflineFirstTaskRepository превратится в a.b.c, а метод getTasks() — в a().

У обфускации две цели:

  1. Уменьшение размера: Имя a занимает в скомпилированном файле меньше байт, чем OfflineFirstTaskRepository. На масштабе всего приложения это экономит мегабайты.
  2. Защита от реверс-инжиниринга: Android-приложения легко декомпилировать. Если злоумышленник вскроет ваш APK, без обфускации он увидит весь исходный код как на ладони. С обфускацией он увидит месиво из a.b.c(), в котором крайне сложно разобраться.

Почему чистый код ломается? Проблема Reflection

Если R8 делает приложение меньше, быстрее и безопаснее, почему оно падает? Проблема возникает на стыке статической типизации и динамического поведения, известного как Reflection (Рефлексия).

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

Вспомните работу с сетью из прошлых глав. У нас есть DTO (Data Transfer Object):

data class UserDto(
    val id: String,
    val firstName: String
)

Когда сервер присылает JSON {"id": "123", "firstName": "Alice"}, библиотека (например, Gson или Moshi) использует рефлексию. Она смотрит на ключи в JSON, затем ищет в классе UserDto поля с точно такими же именами и подставляет туда значения.

В отладочной сборке R8 отключен. Имена совпадают, всё работает. В релизной сборке R8 обфусцирует UserDto. Класс превращается в a, поле id — в b, поле firstName — в c.

Библиотека парсинга получает JSON, ищет в классе a поле с именем firstName, не находит его (ведь оно теперь c) и либо падает с ошибкой, либо возвращает пустой объект. Ваш экран остается пустым.

Правила игры: как защитить важный код

Чтобы приложение не ломалось, мы должны указать R8, какие части кода трогать нельзя. R8 использует формат конфигурации, унаследованный от ProGuard.

У нас есть три основных способа защитить код.

Способ 1: Аннотация @Keep

Самый простой способ защитить конкретный класс или метод — повесить на него системную аннотацию @Keep. Она говорит R8: «Не удаляй этот класс, даже если кажется, что он не используется, и не переименовывай его».

import androidx.annotation.Keep

@Keep
data class UserDto(
    val id: String,
    val firstName: String
)

Это быстро, но загрязняет код. Если у вас сотни DTO, вешать @Keep на каждый — нарушение чистоты кода.

Способ 2: Файл proguard-rules.pro

В каждом Android-модуле есть файл proguard-rules.pro. В нем пишутся глобальные правила. Вместо того чтобы помечать каждый класс, мы можем написать одно правило для всего пакета:

# Сохранить все классы (и их поля) в пакете network.models
-keep class com.example.app.network.models.** { *; }

Это чище с точки зрения архитектуры: бизнес-логика ничего не знает об инструментах сборки.

Способ 3: Явный маппинг (Best Practice для сети)

Для сериализации JSON есть более изящное решение. Библиотеки парсинга предоставляют свои аннотации, которые жестко связывают ключ JSON с полем, независимо от имени самого поля.

Например, в Gson это @SerializedName, в Kotlinx Serialization — @SerialName:

import com.google.gson.annotations.SerializedName

data class UserDto(
    @SerializedName("id") val id: String,
    @SerializedName("firstName") val name: String
)

В этом случае R8 может спокойно переименовать поле name во что угодно. Аннотация @SerializedName сохранится в байт-коде, и парсер будет искать ключ firstName в JSON, опираясь на аннотацию, а не на имя поля. Это самый надежный подход.

Расшифровка крашей: mapping.txt

Даже если вы настроили все правила, релизное приложение иногда будет падать у пользователей. В консоли разработчика (например, Firebase Crashlytics или Google Play Console) вы увидите StackTrace (след ошибки).

Без дополнительных действий он будет выглядеть так:

java.lang.NullPointerException:
    at a.b.c.d(SourceFile:42)
    at a.b.e.f(SourceFile:12)

Понять, где произошла ошибка, невозможно. Но R8 не просто так запутывает код — он оставляет для вас «карту сокровищ». При каждой релизной сборке в папке app/build/outputs/mapping/release/ создается файл mapping.txt.

Этот файл содержит словарь переводов:

com.example.app.OfflineFirstTaskRepository -> a.b.c:
    java.util.List getTasks() -> d

Системы сбора ошибок (Crashlytics/Play Console) позволяют загрузить этот mapping.txt. Они автоматически прогонят обфусцированный StackTrace через этот словарь и покажут вам оригинальные имена классов и методов, чтобы вы могли легко найти и исправить баг. Главное правило: никогда не теряйте файл mapping.txt для выпущенной версии приложения.

Паттерны проектирования в Kotlin: Фабрика, Наблюдатель и Декоратор

Паттерны проектирования в Kotlin: Фабрика, Наблюдатель и Декоратор

Архитектура приложения не состоит только из глобальных слоев вроде MVVM или DI-контейнеров. На микроуровне — внутри самих классов — мы постоянно сталкиваемся с типовыми задачами: как создать сложный объект, как уведомить другие классы об изменениях, как добавить новую функцию без изменения старого кода.

Паттерны проектирования — это проверенные временем шаблоны решений для таких задач. В Kotlin многие классические паттерны реализуются гораздо лаконичнее, чем в Java, благодаря встроенным возможностям языка. Разберем три самых востребованных паттерна в Android-разработке.

Фабрика (Factory): инкапсуляция создания объектов

В предыдущих главах мы использовали внедрение зависимостей (DI), чтобы классы получали готовые инструменты извне. Но кто-то должен эти инструменты создавать. Если логика создания объекта сложна (требует проверок, выбора реализации или сложной настройки), прямое использование конструктора загрязняет бизнес-логику.

Паттерн Фабрика выносит логику создания объектов в отдельный метод или класс.

Представьте, что в приложении есть система уведомлений. В зависимости от настроек пользователя мы должны показать либо системное Android-уведомление, либо кастомный In-App баннер внутри приложения.

Создадим общий интерфейс:

interface Notification {
    fun show(message: String)
}

class SystemNotification(private val context: Context) : Notification {
    override fun show(message: String) {
        // Логика работы с NotificationManager
    }
}

class InAppNotification : Notification {
    override fun show(message: String) {
        // Логика показа Snackbar или кастомного View
    }
}

Если мы будем писать if (isSystem) SystemNotification() else InAppNotification() по всему коду, при добавлении третьего типа уведомлений придется менять десятки файлов. Скроем это за фабрикой:

class NotificationFactory(private val context: Context) {

    fun createNotification(type: NotificationType): Notification {
        return when (type) {
            NotificationType.SYSTEM -> SystemNotification(context)
            NotificationType.IN_APP -> InAppNotification()
            NotificationType.SILENT -> SilentLogNotification()
        }
    }
}

Теперь DI-контейнер или ViewModel обращается только к NotificationFactory. Клиентский код не знает, какие классы реализуют интерфейс Notification и как именно они конструируются. Фабрика выступает единой точкой входа для создания семейства связанных объектов.

Наблюдатель (Observer): как работает реактивность под капотом

Мы уже активно использовали StateFlow и LiveData для связи ViewModel и UI. Эти инструменты — не магия, а классическая реализация паттерна Наблюдатель.

Суть паттерна: есть объект-источник (Subject), состояние которого меняется. Есть объекты-подписчики (Observers), которым нужно реагировать на эти изменения. Вместо того чтобы подписчики постоянно спрашивали «У тебя что-то изменилось?», источник сам рассылает им уведомления: «Мое состояние изменилось, вот новые данные».

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

// 1. Контракт для подписчика
interface ValueObserver {
    fun onValueChanged(newValue: String)
}

// 2. Объект-источник
class ValuePublisher {
    // Список всех, кто подписался на обновления
    private val observers = mutableListOf<ValueObserver>()

    // Текущее состояние
    private var state: String = ""

    // Метод для подписки
    fun subscribe(observer: ValueObserver) {
        observers.add(observer)
        // Сразу отдаем актуальное состояние при подписке
        observer.onValueChanged(state)
    }

    fun unsubscribe(observer: ValueObserver) {
        observers.remove(observer)
    }

    // Изменение состояния
    fun updateValue(newValue: String) {
        state = newValue
        // Уведомляем ВСЕХ подписчиков
        observers.forEach { it.onValueChanged(state) }
    }
}

Когда мы вызываем updateValue(), источник проходит циклом по списку observers и дергает метод onValueChanged у каждого.

Именно так работает MutableStateFlow под капотом, только он дополнительно решает проблемы многопоточности (потокобезопасная запись) и управления жизненным циклом корутин. Понимание паттерна Наблюдатель избавляет от страха перед реактивным программированием: это просто список интерфейсов, методы которых вызываются в цикле при изменении данных.

Декоратор (Decorator): расширение без наследования

Одна из главных проблем ООП — злоупотребление наследованием. Допустим, у нас есть класс NetworkAnalytics, отправляющий события аналитики на сервер.

Мы хотим добавить кэширование (чтобы не терять события без интернета). Мы создаем наследника CachedNetworkAnalytics. Затем нам понадобилось логировать события в консоль при отладке — мы создаем LoggedCachedNetworkAnalytics.

Если у нас есть SS вариантов отправки (сеть, локальная БД) и EE вариантов улучшений (кэш, лог, шифрование), наследование потребует создания комбинаций: N=S×EN = S \times E классов. Для 3 источников и 4 улучшений это 3×4=123 \times 4 = 12 новых классов-наследников.

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

В Kotlin Декоратор реализуется невероятно элегантно благодаря встроенному механизму делегирования через ключевое слово by.

// Базовый контракт
interface Analytics {
    fun trackEvent(name: String)
}

// Базовая реализация
class NetworkAnalytics : Analytics {
    override fun trackEvent(name: String) {
        println("Отправка в сеть: $name")
    }
}

// Декоратор для логирования.
// Ключевое слово 'by' автоматически пробрасывает все методы интерфейса в delegate,
// если мы их явно не переопределим.
class LoggingAnalytics(
    private val delegate: Analytics
) : Analytics by delegate {

    override fun trackEvent(name: String) {
        println("ЛОГ: Событие $name было вызвано")
        // Передаем работу обернутому объекту
        delegate.trackEvent(name)
    }
}

Теперь мы можем собирать нужную функциональность как матрешку прямо в момент создания объекта (например, в нашем DI-контейнере):

val baseAnalytics = NetworkAnalytics()
val loggedAnalytics = LoggingAnalytics(baseAnalytics)

// Вызов пройдет через LoggingAnalytics, а затем попадет в NetworkAnalytics
loggedAnalytics.trackEvent("User_Login")

Формула количества классов меняется. Декоратор требует всего N=S+EN = S + E классов. Для 3 источников и 4 улучшений это 3+4=73 + 4 = 7 независимых классов, которые можно комбинировать в любом порядке.

Паттерны Фабрика, Наблюдатель и Декоратор — это фундамент, на котором строятся гибкие системы. В следующих главах мы начнем применять эти и ранее изученные архитектурные концепции на практике, проектируя реальное приложение с нуля.

Проектирование приложения «Трекер задач»: от классов к UI

Проектирование приложения «Трекер задач»: от классов к UI

Самый сложный момент в разработке — это пустой проект в Android Studio. Вы уже знаете, как работают корутины, как Room сохраняет данные, а ViewModel переживает поворот экрана. Но когда перед вами мигает курсор в пустом файле, возникает вопрос: с чего начать? С верстки кнопок? С создания базы данных?

В чистой архитектуре мы никогда не начинаем с интерфейса или базы данных. Мы начинаем с предметной области (Domain) — с того, как наше приложение «думает». Сегодня мы спроектируем фундамент реального приложения «Трекер задач», связав разрозненные концепции ООП в единую систему.

Шаг 1. Моделирование предметной области

Предметная область — это суть вашего приложения, очищенная от технических деталей вроде Android Context, SQL-запросов или JSON. Трекер задач управляет задачами. Значит, наш первый класс должен описывать саму задачу.

Для хранения состояния идеально подходят data class. Нам нужно определить минимально необходимый набор свойств:

enum class Priority { LOW, MEDIUM, HIGH }

data class Task(
    val id: String,
    val title: String,
    val isCompleted: Boolean,
    val priority: Priority
)

Обратите внимание: здесь нет аннотаций @Entity из Room или @SerializedName из Gson. Это чистый Kotlin. Этот класс — ядро системы. Все остальные слои будут подстраиваться под него, а не наоборот.

Шаг 2. Определение контрактов (Data Layer)

Задачи нужно откуда-то получать и куда-то сохранять. Мы знаем, что в итоге будем использовать Room, но если мы сразу начнем писать TaskDao, мы намертво привяжем бизнес-логику к локальной базе данных.

Вместо этого мы применяем принцип инверсии зависимостей (DIP) и описываем что мы хотим делать, а не как. Мы создаем интерфейс репозитория:

interface TaskRepository {
    fun getAllTasks(): Flow<List<Task>>
    suspend fun addTask(task: Task)
    suspend fun toggleTaskCompletion(taskId: String)
}

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

  1. getAllTasks возвращает реактивный поток Flow. Это значит, что источник данных берет на себя обязательство автоматически уведомлять нас о любых изменениях в списке задач.
  2. Методы изменения состояния (addTask, toggleTaskCompletion) помечены как suspend. Они будут выполняться асинхронно, не блокируя главный поток.

Позже мы напишем реализацию этого интерфейса (OfflineFirstTaskRepository), но для проектирования остальной системы нам достаточно этого контракта.

Шаг 3. Управление состоянием экрана (Presentation Layer)

Теперь нам нужен посредник, который возьмет данные из репозитория и подготовит их для отображения. Эту роль берет на себя ViewModel.

Но как именно UI узнает, что нужно нарисовать? Интерфейс пользователя глуп — он должен просто отображать то, что ему сказали. Поэтому мы проектируем конечное состояние экрана с помощью sealed interface.

sealed interface TaskListUiState {
    data object Loading : TaskListUiState
    data class Success(val tasks: List<Task>) : TaskListUiState
    data class Error(val message: String) : TaskListUiState
}

Теперь ViewModel становится фабрикой состояний. Она получает зависимости через конструктор, запускает корутину и трансформирует данные из репозитория в TaskUiState:

class TaskViewModel(
    private val repository: TaskRepository
) : ViewModel() {

    // Скрываем изменяемый стейт
    private val _uiState = MutableStateFlow<TaskListUiState>(TaskListUiState.Loading)
    val uiState: StateFlow<TaskListUiState> = _uiState

    init {
        // Подписываемся на базу данных при создании ViewModel
        viewModelScope.launch {
            repository.getAllTasks().collect { tasks ->
                _uiState.value = TaskListUiState.Success(tasks)
            }
        }
    }

    fun onTaskClicked(taskId: String) {
        viewModelScope.launch {
            repository.toggleTaskCompletion(taskId)
        }
    }
}

Шаг 4. Сборка архитектурного моста

Давайте посмотрим на картину целиком. Мы выстроили строгую иерархию, где каждый слой решает только одну задачу (принцип SRP).

Эта структура реализует однонаправленный поток данных (UDF) в масштабе всего приложения:

  1. Пользователь кликает на задачу в UI.
  2. UI вызывает метод onTaskClicked() во ViewModel.
  3. ViewModel вызывает suspend-метод toggleTaskCompletion() в Репозитории.
  4. Репозиторий обновляет строку в базе данных Room.
  5. Room замечает изменение и автоматически выдает новый список в Flow.
  6. ViewModel получает новый список, оборачивает его в TaskListUiState.Success и кладет в StateFlow.
  7. UI реагирует на новое состояние и перерисовывает список.

Шаг 5. От классов к пикселям (UI Layer)

Только теперь, когда фундамент готов, мы переходим к Android-специфичному коду — Activity или Fragment. Задача UI-слоя сводится к банальному when-выражению, которое переводит ООП-состояния в видимые элементы.

lifecycleScope.launch {
    viewModel.uiState.collect { state ->
        when (state) {
            is TaskListUiState.Loading -> {
                progressBar.isVisible = true
                recyclerView.isVisible = false
            }
            is TaskListUiState.Success -> {
                progressBar.isVisible = false
                recyclerView.isVisible = true
                // Адаптер превращает объекты Task в View-элементы
                taskAdapter.submitList(state.tasks)
            }
            is TaskListUiState.Error -> {
                showToast(state.message)
            }
        }
    }
}

Здесь замыкается круг объектно-ориентированного проектирования. Адаптер списка (taskAdapter) получает список чистых объектов Task и применяет полиморфизм и инкапсуляцию Android View, чтобы нарисовать галочки, тексты и цвета на экране.

Мы спроектировали приложение, не написав ни строчки SQL-кода и не создав ни одного XML-макета. Мы мыслили категориями контрактов, состояний и потоков данных. В следующих главах мы начнем наполнять этот каркас реализациями: напишем DAO для локального хранения и свяжем слои воедино с помощью DI-фреймворка.

Реализация бизнес-логики и моделей данных

Реализация бизнес-логики и моделей данных

В прошлой главе мы спроектировали наше приложение «сверху вниз»: создали чистую доменную модель Task, определили интерфейс TaskRepository и описали состояния UI. Мы построили идеальный фасад. Но если сейчас запустить приложение, оно ничего не сделает — за фасадом пустота.

Пришло время спуститься на уровень данных и залить фундамент: реализовать работу с базой данных Room так, чтобы её технические детали не просочились в нашу чистую бизнес-логику.

Две стороны одной сущности: Domain против Data

Самый частый соблазн начинающего разработчика — взять готовую доменную модель Task и просто добавить к ней аннотации Room. Кажется, что это сэкономит время:

// ПЛОХО: Смешивание слоев
@Entity(tableName = "tasks")
data class Task(
    @PrimaryKey val id: String,
    @ColumnInfo(name = "task_title") val title: String,
    val isCompleted: Boolean
)

Почему это путь к «спагетти-коду»? Потому что теперь ядро вашего приложения (доменная модель) знает о существовании базы данных SQLite. Если завтра вы решите поменять базу данных на Realm или получать задачи только из сети по REST API, вам придется переписывать саму сущность Task, что неизбежно сломает UI-слой, который от неё зависит.

Чистая архитектура требует физического разделения моделей. У нас должна быть чистая модель для бизнес-логики и специальная модель для базы данных.

Создадим TaskEntity — класс, который существует только для того, чтобы Room знал, как сохранять данные в таблицы.

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity(tableName = "tasks")
data class TaskEntity(
    @PrimaryKey val id: String,
    val title: String,
    val description: String,
    val priorityLevel: Int, // В БД храним примитив (Int), а не Enum
    val isCompleted: Boolean,
    val completionTimestamp: Long? // Время завершения (null, если не завершена)
)

Обратите внимание: в базе данных мы храним приоритет как число (Int), а время как Long. База данных любит примитивы. А вот наша доменная модель Task оперирует удобными для Kotlin типами (Enum и Instant).

Мапперы: мосты между мирами

Если у нас есть две разные модели, нам нужен механизм перевода данных из одного формата в другой. Маппинг можно представить как строгую математическую функцию f(x)=yf(x) = y, где xx — это объект базы данных (TaskEntity), а yy — объект предметной области (Task).

В Kotlin мапперы изящнее всего реализуются через функции-расширения (extension functions). Создадим файл TaskMapper.kt:

// Превращаем сущность БД в чистую доменную модель
fun TaskEntity.toDomain(): Task {
    return Task(
        id = this.id,
        title = this.title,
        description = this.description,
        priority = Priority.fromInt(this.priorityLevel), // Конвертируем Int в Enum
        isCompleted = this.isCompleted
    )
}

// Превращаем доменную модель обратно в сущность БД для сохранения
fun Task.toEntity(): TaskEntity {
    return TaskEntity(
        id = this.id,
        title = this.title,
        description = this.description,
        priorityLevel = this.priority.value,
        isCompleted = this.isCompleted,
        completionTimestamp = null // Пока оставляем null, логику добавим позже
    )
}

Эти функции — изолированные переводчики. Благодаря им ни Room не знает про Task, ни Task не знает про Room.

DAO: Источник реактивных данных

Теперь опишем интерфейс TaskDao. Мы уже знаем, что Room отлично работает с корутинами. Для чтения данных мы будем использовать реактивный поток Flow, а для записи — suspend-функции.

@Dao
interface TaskDao {
    @Query("SELECT * FROM tasks ORDER BY isCompleted ASC, priorityLevel DESC")
    fun observeAllTasks(): Flow<List<TaskEntity>>

    @Query("SELECT * FROM tasks WHERE id = :taskId")
    suspend fun getTaskById(taskId: String): TaskEntity?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun saveTask(task: TaskEntity)
}

Функция observeAllTasks возвращает Flow<List<TaskEntity>>. Это значит, что при любом изменении в таблице tasks, Room автоматически сгенерирует новый список и протолкнет его в этот поток.

Реализация Репозитория: Встреча потоков

Настало время реализовать интерфейс TaskRepository, который мы спроектировали в прошлой главе. Создадим класс OfflineFirstTaskRepository.

Здесь нас ждет интересная алгоритмическая задача. TaskDao возвращает Flow<List<TaskEntity>>. Но наш интерфейс TaskRepository обещал вернуть Flow<List<Task>>. Нам нужно применить наш маппер toDomain() к каждому элементу списка, который находится внутри реактивного потока.

Для этого нам потребуется комбинация двух разных операторов map: один для потока, другой для коллекции.

class OfflineFirstTaskRepository(
    private val taskDao: TaskDao
) : TaskRepository {

    override fun getAllTasks(): Flow<List<Task>> {
        // 1. Получаем Flow из DAO
        return taskDao.observeAllTasks()
            // 2. map из пакета kotlinx.coroutines.flow (преобразует эмиссии потока)
            .map { entityList ->
                // 3. map из стандартной библиотеки Kotlin (преобразует элементы списка)
                entityList.map { entity ->
                    entity.toDomain()
                }
            }
    }

    // ... остальные методы
}

Разберем этот двойной map:

  1. Внешний map перехватывает каждую посылку из базы данных (каждый новый список List<TaskEntity>).
  2. Внутренний map пробегается по этому конкретному списку и превращает каждый TaskEntity в чистый Task.
  3. В итоге наружу вытекает готовый Flow<List<Task>>. ViewModel даже не подозревает, что под капотом была какая-то база данных.

Инкапсуляция бизнес-правил

Репозиторий — это не просто тупой передатчик данных от DAO к ViewModel. Это отличное место для инкапсуляции бизнес-логики.

Представим бизнес-правило: «Когда задача отмечается как выполненная, система должна зафиксировать точное время завершения. Если задача возвращается в работу, время завершения должно быть стерто».

ViewModel не должна заниматься вычислением времени или знать структуру таблицы. Она просто говорит: «Пользователь кликнул на чекбокс задачи X». Вся логика ложится на плечи репозитория:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

class OfflineFirstTaskRepository(
    private val taskDao: TaskDao
) : TaskRepository {

    // ... метод getAllTasks()

    override suspend fun toggleTaskCompletion(taskId: String) {
        // Переключаемся на фоновый поток для безопасности
        withContext(Dispatchers.IO) {
            // 1. Достаем текущую сущность из БД
            val entity = taskDao.getTaskById(taskId) ?: return@withContext

            // 2. Применяем бизнес-правило
            val newIsCompleted = !entity.isCompleted
            val newTimestamp = if (newIsCompleted) System.currentTimeMillis() else null

            // 3. Создаем обновленную копию (data class copy)
            val updatedEntity = entity.copy(
                isCompleted = newIsCompleted,
                completionTimestamp = newTimestamp
            )

            // 4. Сохраняем обратно в БД
            taskDao.saveTask(updatedEntity)
        }
    }
}

Обратите внимание на использование withContext(Dispatchers.IO). Хотя Room делает свои suspend-функции безопасными для главного потока, здесь мы выполняем дополнительную логику (вычисление времени, создание копий). Оборачивая весь блок бизнес-логики в Dispatchers.IO, мы гарантируем, что этот метод является абсолютно Main-safe (безопасным для вызова из UI-потока) независимо от того, насколько сложными станут вычисления в будущем.

Мы успешно реализовали слой данных. Наша бизнес-логика изолирована, данные конвертируются на лету, а UI-слой получит готовые к употреблению чистые объекты. Наш код стал модульным и легко тестируемым.

Связывание слоев приложения через интерфейсы

Связывание слоев приложения через интерфейсы

Представьте, что вы строите мост с двух берегов реки. На левом берегу у нас готов пользовательский интерфейс и TaskViewModel, на правом — база данных Room и OfflineFirstTaskRepository. Если начать строить их навстречу друг другу без точного чертежа, они могут просто не сойтись. В архитектуре приложений таким «чертежом», гарантирующим идеальную стыковку независимых слоев, выступает интерфейс.

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

Проблема прямого создания объектов

Самый очевидный (и самый ошибочный) способ получить данные во ViewModel — просто создать экземпляр репозитория внутри неё:

class TaskViewModel : ViewModel() {
    // АНТИПАТТЕРН: Жесткая связь (Tight Coupling)
    private val repository = OfflineFirstTaskRepository(
        dao = AppDatabase.getInstance(context).taskDao()
    )

    // ... логика
}

Почему этот код сводит на нет всю нашу архитектуру?

  1. Нарушение слоев: TaskViewModel (слой Presentation) теперь напрямую знает про OfflineFirstTaskRepository и AppDatabase (слой Data), а также требует Android Context.
  2. Невозможность тестирования: Мы не сможем написать Unit-тест для этой ViewModel, так как при её создании всегда будет запускаться реальная база данных SQLite, требующая эмулятора устройства.
  3. Хрупкость: Если завтра мы решим изменить конструктор репозитория (например, добавить сетевой клиент), нам придется переписывать код ViewModel.

Инъекция через конструктор (Constructor Injection)

Чтобы разорвать эту жесткую связь, ViewModel должна запрашивать свои зависимости извне, а не создавать их сама. Более того, она должна запрашивать абстракцию (интерфейс), а не конкретную реализацию.

Перепишем нашу ViewModel:

class TaskViewModel(
    private val repository: TaskRepository // Запрашиваем интерфейс!
) : ViewModel() {

    private val _uiState = MutableStateFlow<TaskUiState>(TaskUiState.Loading)
    val uiState: StateFlow<TaskUiState> = _uiState.asStateFlow()

    init {
        loadTasks()
    }

    private fun loadTasks() {
        viewModelScope.launch {
            repository.getAllTasks()
                .catch { _uiState.value = TaskUiState.Error(it.message ?: "Unknown error") }
                .collect { tasks ->
                    _uiState.value = TaskUiState.Success(tasks)
                }
        }
    }
}

Теперь TaskViewModel ничего не знает о Room, SQL или локальном хранилище. Она знает только то, что у неё есть объект, который умеет выполнять контракт TaskRepository.

Сборка графа: кто создает объекты?

Если ViewModel больше не создает репозиторий, кто это делает? Нам нужно место, где все слои встречаются и связываются воедино. Это место — DI-контейнер (Dependency Injection Container).

Вспомним концепцию Manual DI. Создадим класс TaskAppContainer, который будет жить на уровне всего приложения и отвечать за создание реальных объектов:

class TaskAppContainer(private val context: Context) {

    // 1. Создаем базу данных (лениво, только при первом обращении)
    private val database: AppDatabase by lazy {
        Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "tasks.db"
        ).build()
    }

    // 2. Создаем конкретную реализацию репозитория
    val taskRepository: TaskRepository by lazy {
        OfflineFirstTaskRepository(database.taskDao())
    }
}

Обратите внимание на последовательность. Контейнер собирает зависимости «снизу вверх»: сначала создается фундамент (БД), затем он передается в репозиторий.

Этот контейнер мы поместим в класс Application, чтобы он переживал любые повороты экрана и существовал в единственном экземпляре:

class TaskApplication : Application() {
    // Контейнер будет инициализирован при старте приложения
    lateinit var container: TaskAppContainer

    override fun onCreate() {
        super.onCreate()
        container = TaskAppContainer(this)
    }
}

Проблема фабрики ViewModel

Остался последний шаг: передать taskRepository из контейнера в конструктор TaskViewModel.

Здесь возникает специфика Android. Мы не создаем ViewModel напрямую через val vm = TaskViewModel(...). Жизненным циклом ViewModel управляет система (через делегат by viewModels()), чтобы сохранять её при смене конфигурации.

Поскольку система не знает, какие параметры нужны нашей ViewModel, мы должны предоставить ей инструкцию по сборке — ViewModelProvider.Factory.

Создадим фабрику:

class TaskViewModelFactory(
    private val repository: TaskRepository
) : ViewModelProvider.Factory {

    @Suppress("UNCHECKED_CAST")
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(TaskViewModel::class.java)) {
            // Вот здесь мы вызываем конструктор ViewModel и передаем зависимость
            return TaskViewModel(repository) as T
        }
        throw IllegalArgumentException("Unknown ViewModel class")
    }
}

Финальное связывание в Activity

Теперь всё готово. В нашей MainActivity мы достаем репозиторий из глобального контейнера, отдаем его в фабрику, а фабрику передаем стандартному делегату viewModels():

class MainActivity : AppCompatActivity() {

    // 1. Получаем доступ к DI-контейнеру приложения
    private val appContainer by lazy {
        (application as TaskApplication).container
    }

    // 2. Запрашиваем ViewModel, передавая нашу фабрику
    private val viewModel: TaskViewModel by viewModels {
        TaskViewModelFactory(appContainer.taskRepository)
    }

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

        // Подписываемся на состояние (UDF в действии)
        lifecycleScope.launch {
            viewModel.uiState.collect { state ->
                renderState(state)
            }
        }
    }

    private fun renderState(state: TaskUiState) {
        // Обновление UI...
    }
}

Что мы получили в итоге? MainActivity знает только про фабрику. TaskViewModel знает только про интерфейс TaskRepository. Реальная база данных Room надежно спрятана за интерфейсом и создается в TaskAppContainer. Слои полностью развязаны. Вы можете заменить локальную базу данных на сетевое API, создав новый класс-реализацию TaskRepository, и код ViewModel не изменится ни на один символ.

Рефакторинг «спагетти-кода» в чистую ООП-структуру

Рефакторинг «спагетти-кода» в чистую ООП-структуру

Представьте, что вы открываете файл MainActivity.kt и видите 1500 строк кода. Этот класс делает всё: скачивает JSON из сети, парсит его, сохраняет в базу данных Room, проверяет разрешения камеры, форматирует даты и анимирует кнопки. Стоит вам изменить цвет кнопки — и приложение падает из-за ошибки в SQL-запросе.

Такой код называют «спагетти» — его логика переплетена настолько плотно, что потянув за одну «макаронину», вы тащите за собой весь клубок. В этой статье мы возьмём типичный грязный код и шаг за шагом превратим его в чистую ООП-структуру, опираясь на интерфейсы, репозитории и ViewModel, которые мы спроектировали в предыдущих главах.

Суть рефакторинга и запахи кода

Мартин Фаулер

Любой дурак может написать код, понятный компьютеру. Хорошие программисты пишут код, понятный людям.

Мартин Фаулер, «Рефакторинг. Улучшение проекта существующего кода»

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

Главный симптом того, что вашему Android-приложению нужен рефакторинг — это наличие God Object (Божественного объекта). В Android эту роль чаще всего берет на себя Activity или Fragment.

Характеристики God Object:

  • Высокая связность (High Coupling): класс знает о сети, базе данных и UI одновременно.
  • Низкая сплочённость (Low Cohesion): методы внутри класса не связаны одной целью (один метод форматирует строку, другой делает HTTP-запрос).

Анатомия спагетти-кода

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

class TaskActivity : AppCompatActivity() {
    private lateinit var db: AppDatabase
    private lateinit var adapter: TaskAdapter

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

        db = Room.databaseBuilder(this, AppDatabase::class.java, "tasks").build()
        adapter = TaskAdapter { task -> onTaskClicked(task) }
        findViewById<RecyclerView>(R.id.recyclerView).adapter = adapter

        loadTasks()
    }

    private fun loadTasks() {
        // ОШИБКА АРХИТЕКТУРЫ: UI-компонент управляет корутинами и БД
        lifecycleScope.launch(Dispatchers.IO) {
            val tasks = db.taskDao().getAllTasksSync()
            withContext(Dispatchers.Main) {
                // БИЗНЕС-ЛОГИКА В UI: фильтрация и сортировка
                val activeTasks = tasks.filter { !it.isCompleted }
                    .sortedByDescending { it.priority }
                adapter.submitList(activeTasks)
            }
        }
    }

    private fun onTaskClicked(task: TaskEntity) {
        lifecycleScope.launch(Dispatchers.IO) {
            task.isCompleted = true
            db.taskDao().update(task)
            loadTasks() // Перезагрузка всего списка
        }
    }
}

Что здесь не так?

  1. Activity напрямую инстанцирует AppDatabase. Мы нарушили инверсию зависимостей.
  2. Activity сама решает, на каком потоке выполнять запросы (Dispatchers.IO).
  3. Бизнес-правила (фильтрация активных задач и сортировка по приоритету) зашиты прямо в коллбэк UI. Написать Unit-тест для этой логики невозможно без запуска эмулятора Android.

Шаг 1: Хирургическое извлечение данных (Data Layer)

Первое правило рефакторинга: никогда не переписывайте всё с нуля. Мы будем двигаться слоями, начиная с самого глубокого.

Нам нужно оторвать Activity от базы данных. Для этого мы используем паттерн Репозиторий, который ввели ранее. Мы уже знаем, что репозиторий должен скрывать источник данных.

Мы переносим логику работы с DAO и переключение потоков в OfflineFirstTaskRepository. Activity больше не знает про Room, TaskEntity и Dispatchers.IO. Она будет общаться только с интерфейсом TaskRepository и получать чистые доменные модели Task.

Шаг 2: Изоляция состояния (Presentation Layer)

Теперь, когда данные инкапсулированы, нам нужно убрать бизнес-логику и управление состоянием из Activity. Мы переносим это во ViewModel.

Вместо того чтобы Activity сама вызывала loadTasks() и фильтровала список, ViewModel подписывается на Flow из репозитория, применяет бизнес-правила (сортировку) и выдает наружу готовый StateFlow с UI-состоянием.

Шаг 3: Сборка через интерфейсы (DI)

Теперь у нас есть независимые компоненты: чистая Activity, TaskViewModel и TaskRepository. Но как их соединить, если они не создают друг друга? Здесь вступает в игру внедрение зависимостей (DI), которое мы разбирали в прошлой главе.

Мы используем наш AppContainer и ViewModelProvider.Factory, чтобы передать репозиторий во ViewModel в момент её создания.

Финал: Чистая ООП-структура

Посмотрим на нашу TaskActivity после рефакторинга:

class TaskActivity : AppCompatActivity() {
    // Получаем ViewModel через фабрику, которая внедряет зависимости
    private val viewModel: TaskViewModel by viewModels { TaskAppContainer.factory }
    private lateinit var adapter: TaskAdapter

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

        // UI знает только о том, как передать намерение (Intent) пользователя
        adapter = TaskAdapter { task -> viewModel.toggleTask(task) }
        findViewById<RecyclerView>(R.id.recyclerView).adapter = adapter

        // UI только пассивно наблюдает за состоянием
        lifecycleScope.launch {
            viewModel.uiState.collect { state ->
                renderState(state)
            }
        }
    }

    private fun renderState(state: TaskUiState) {
        when (state) {
            is TaskUiState.Loading -> showProgress()
            is TaskUiState.Success -> adapter.submitList(state.tasks)
            is TaskUiState.Error -> showError(state.message)
        }
    }
}

Сравним метрики до и после:

  • Размер Activity: сократился в разы. Она больше не содержит логики.
  • Тестируемость: мы можем покрыть Unit-тестами 100% бизнес-логики во ViewModel и Repository, просто передав им Fake-реализации (как мы делали в главе 24).
  • Связанность (Coupling): стремится к минимуму. Activity зависит только от ViewModel.
  • Сплочённость (Cohesion): максимальна. Каждый класс делает ровно одну вещь (Single Responsibility Principle).

Рефакторинг — это не разовая акция, а постоянный процесс. Заметив, что класс начинает брать на себя слишком много ответственности (превращаясь в God Object), выделите интерфейс, создайте новый класс и свяжите их через конструктор. В следующей главе мы разберём ещё одну частую проблему «грязного» кода — утечки памяти, и научимся находить их в Android-компонентах.

Поиск и устранение утечек памяти в Android-приложении

Поиск и устранение утечек памяти в Android-приложении

В прошлой главе мы провели масштабный рефакторинг: избавились от «спагетти-кода», разделили логику по слоям и внедрили чистую архитектуру. Код стал читаемым, тестируемым и красивым. Но представьте ситуацию: вы выпускаете приложение в релиз, и через несколько дней в консоли разработчика начинают появляться отчеты о падениях с пугающей ошибкой java.lang.OutOfMemoryError (OOM). Приложение просто исчерпало доступную оперативную память и было убито системой.

Как такое возможно в языке Kotlin, где нет ручного управления памятью, как в C или C++? Ответ кроется в понимании того, как объекты живут и умирают в виртуальной машине Android, и как наши архитектурные решения могут случайно сделать их бессмертными.

Иллюзия автоматической уборки: Garbage Collector

В Kotlin за освобождение памяти отвечает Garbage Collector (Сборщик мусора, или GC). Его алгоритм работы концептуально прост и основан на принципе достижимости.

Представьте себе дерево. У него есть корни (GC Roots) — это объекты, которые гарантированно нужны приложению прямо сейчас. К ним относятся:

  • Локальные переменные внутри выполняющихся в данный момент функций.
  • Активные потоки (Threads) и выполняющиеся корутины.
  • Статические переменные (в Kotlin это свойства внутри companion object или object).

Сборщик мусора периодически сканирует память. Он начинает от GC Roots и идет по всем ссылкам, как по веткам дерева. Любой объект, до которого он смог дотянуться, помечается как «живой». Все объекты, до которых пути нет (ветка отломлена), считаются мусором — память из-под них немедленно очищается.

Утечка памяти (Memory Leak) в Android — это ситуация, когда объект вам больше не нужен (например, экран уже закрыт пользователем), но до него все еще можно добраться от какого-либо GC Root по цепочке ссылок. Сборщик мусора видит ссылку, считает объект нужным и оставляет его в памяти навсегда.

Самая дорогая ошибка: Утечка Context и Activity

В Android не все объекты равны. Утечка маленького объекта Task (строка текста и пара чисел) отнимет несколько десятков байт — вы не заметите этого даже через год работы приложения. Но есть объект, утечка которого фатальна. Это Activity.

Activity — это не просто класс с логикой. Это гигантский контейнер, который хранит:

  1. Ссылку на Window (окно операционной системы).
  2. Всю иерархию View (кнопки, списки, тексты на экране).
  3. Все загруженные для этого экрана картинки (Bitmaps), которые могут весить десятки мегабайт.

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

Сценарий 1: Статическая ловушка

Самый простой способ организовать утечку — сохранить ссылку на UI-компонент в статическую переменную.

class ProfileActivity : AppCompatActivity() {

    companion object {
        // ОШИБКА: Статическая переменная живет всё время работы приложения!
        var cachedTextView: TextView? = null
    }

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

        val textView = findViewById<TextView>(R.id.usernameText)
        cachedTextView = textView // Утечка создана
    }
}

Что здесь произойдет? При повороте экрана Android уничтожает старую ProfileActivity и создает новую (чтобы перерисовать интерфейс под новую ориентацию). Старая Activity должна быть удалена сборщиком мусора. Но companion object является GC Root. Он держит ссылку на cachedTextView, который держит ссылку на старую Activity.

Если размер экрана в памяти составляет 5050 МБ, то после пяти поворотов устройства ваше приложение съест 250250 МБ оперативной памяти впустую.

Сценарий 2: Неявные ссылки в анонимных классах

В объектно-ориентированном программировании есть концепция внутренних классов. В Kotlin, если вы создаете анонимный объект (например, слушатель кликов или коллбэк для сети), этот объект неявно сохраняет ссылку на класс, внутри которого он был создан.

Представьте, что мы делаем долгий сетевой запрос прямо из Activity, используя устаревший подход с коллбэками:

class TaskActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        NetworkClient.downloadHugeFile(object : DownloadCallback {
            override fun onComplete(result: String) {
                // Анонимный объект коллбэка неявно хранит ссылку на TaskActivity,
                // чтобы иметь доступ к ее методам и свойствам.
                showToast(result)
            }
        })
    }
}

Метод downloadHugeFile выполняется в фоновом потоке и может занять 5 минут. Если пользователь закроет TaskActivity через секунду после старта скачивания, Activity не удалится из памяти. Фоновый поток (GC Root) держит анонимный объект DownloadCallback, а тот неявно держит TaskActivity. Экран будет висеть в оперативной памяти все 5 минут, пока скачивание не завершится.

Именно поэтому в главе 25 мы перешли на корутины и viewModelScope — этот механизм автоматически отменяет фоновые задачи при уничтожении ViewModel, обрубая цепочку ссылок и предотвращая утечки.

Инструменты: Как найти то, чего не видно

Искать утечки глазами в тысячах строк кода почти невозможно. В индустрии стандартом де-факто стала библиотека LeakCanary.

LeakCanary работает гениально просто. Она автоматически подписывается на жизненный цикл всех Activity и Fragment в вашем приложении. Когда экран уничтожается (вызывается onDestroy), LeakCanary создает для него WeakReference (слабую ссылку).

Слабая ссылка (WeakReference) — это особый тип ссылки в ООП, который не защищает объект от сборщика мусора. Это как наблюдение в бинокль: вы видите объект, но не держите его. Если на объект остались только слабые ссылки, Garbage Collector его уничтожит.

Через 5 секунд после закрытия экрана LeakCanary проверяет свою слабую ссылку. Если объект внутри нее все еще существует (не равен null), значит, кто-то держит его сильной ссылкой. LeakCanary бьет тревогу, делает снимок памяти (Heap Dump) и рисует для вас точную цепочку ссылок от GC Root до утекшей Activity, чтобы вы знали, какую строку кода нужно исправить.

Стратегии устранения утечек

Чтобы писать безопасный код, достаточно соблюдать три архитектурных правила:

  1. Никогда не передавайте View или Activity во ViewModel или Repository. Слои данных и бизнес-логики живут дольше, чем слой представления. Если ViewModel сохранит ссылку на кнопку, кнопка удержит весь экран. Используйте реактивные потоки (StateFlow), чтобы UI сам подписывался на данные.

  2. Используйте правильный Context. Если вам нужен Context в синглтоне (например, для инициализации базы данных Room), используйте applicationContext. Он живет столько же, сколько само приложение, и его невозможно «утечь».

  3. Очищайте подписки и коллбэки. Если вы вручную подписываетесь на какие-то события (например, на датчик GPS), обязательно отписывайтесь в методах onDestroy() или onStop().

Понимание утечек памяти — это переход от написания кода, который «просто работает», к созданию надежных инженерных систем. Чистая архитектура, которую мы строили в предыдущих главах, во многом создана именно для того, чтобы изолировать объекты с коротким жизненным циклом (UI) от объектов с длинным (данные), делая утечки архитектурно невозможными.

Финальная сборка и чек-лист чистого кода

Финальная сборка и чек-лист чистого кода

Вы нажимаете кнопку «Run», проект компилируется без ошибок, приложение запускается, данные загружаются из сети и сохраняются в базу. Кнопки нажимаются, утечек памяти нет. Кажется, это финал?

Для начинающего разработчика работающее приложение — это конечная цель. Для инженера — это лишь половина пути. Код читают гораздо чаще, чем пишут. Если через полгода вы или ваш коллега захотите добавить новую фичу, насколько легко будет это сделать? Не сломается ли старая логика?

В этой завершающей статье курса мы соберем воедино все архитектурные и объектно-ориентированные принципы, которые изучали, проведем финальный аудит нашего «Трекера задач» и подготовим релизный артефакт для публикации.

Чек-лист чистого Android-кода

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

1. Предметная область и ООП (Domain Layer)

Это сердце вашего приложения. Оно ничего не должно знать об Android.

  • Инкапсуляция соблюдена: Свойства классов закрыты (private), состояние изменяется только через публичные методы, которые гарантируют валидность данных (бизнес-правила).
  • Нет God Objects: Классы имеют одну зону ответственности (Single Responsibility). Если класс называется TaskManager, скорее всего, он делает слишком много. Разбейте его на TaskValidator, TaskSorter и TaskRepository.
  • Зависимости инвертированы (DIP): Высокоуровневая логика зависит от интерфейсов, а не от конкретных реализаций (например, мы зависим от TaskRepository, а не от OfflineFirstTaskRepository).

2. Слой данных (Data Layer)

Здесь живут Room, Retrofit и DataStore.

  • Единый источник истины (SSOT): UI никогда не читает данные напрямую из сети. Сеть обновляет базу данных (Room), а UI реактивно наблюдает за изменениями в базе.
  • Разделение моделей: Сетевые DTO, сущности базы данных (@Entity) и доменные модели — это разные классы. Мапперы изолируют изменения API от бизнес-логики.
  • Безопасность потоков: Все suspend-функции репозитория являются Main-safe. Они сами переключаются на Dispatchers.IO через withContext, не заставляя вызывающую сторону думать о потоках.

3. Слой представления (Presentation Layer)

Activity и Fragment должны быть максимально «глупыми».

  • Однонаправленный поток данных (UDF): UI только отправляет события (клики) во ViewModel и пассивно отрисовывает StateFlow. Никакой логики ветвления (фильтрации списков) внутри Activity.
  • Никакого Context во ViewModel: ViewModel не содержит ссылок на View, Activity или Context. Это гарантирует отсутствие утечек памяти при повороте экрана.
  • Управление корутинами: Корутины запускаются строго в viewModelScope, чтобы автоматически отменяться при уничтожении экрана.

Автоматизация проверок: Статический анализ кода

Держать весь чек-лист в голове сложно. К счастью, машины отлично справляются с рутинными проверками. В профессиональной разработке используются инструменты статического анализа — программы, которые читают ваш код до компиляции и ищут архитектурные запахи (Code Smells).

Два главных инструмента в экосистеме Kotlin:

  1. Ktlint — строгий надзиратель за форматированием. Он проверяет отступы, пробелы, порядок импортов и длину строк. Избавляет команду от споров о том, где ставить скобку.
  2. Detekt — умный анализатор архитектуры. Он найдет функции с огромным количеством параметров, классы-God Objects, слишком глубокую вложенность if/else и даже потенциальные утечки памяти.

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

Настроив Detekt в build.gradle, вы получаете автоматического ревьюера, который бьет по рукам за нарушение принципов SOLID и метрик сложности кода.

Финальная сборка: от кода к файлу

Когда код вычищен, протестирован и обфусцирован с помощью R8 (как мы делали в 33-й главе), настает время собрать релизный файл для загрузки в Google Play.

Исторически Android-приложения распространялись в формате APK (Android Package). Это ZIP-архив, содержащий скомпилированный код, ресурсы (картинки, строки) и манифест.

Проблема APK в том, что он содержит ресурсы для всех возможных устройств. Если пользователь скачивает APK на телефон с экраном среднего разрешения и процессором ARM64, он всё равно скачивает тяжелые 4K-картинки и библиотеки для процессоров x86, которые ему никогда не понадобятся.

Современный стандарт — AAB (Android App Bundle).

Характеристика APK (Android Package) AAB (Android App Bundle)
Суть Готовый к установке архив Исходный материал для серверов Google Play
Размер Большой (включает всё для всех экранов и языков) Оптимизированный (пользователь качает только то, что нужно его устройству)
Установка Можно установить напрямую на устройство Нельзя установить напрямую, требует обработки
Требования Google Устаревший формат (только для тестирования) Обязательный формат для всех новых приложений

При загрузке AAB в Google Play, сервер анализирует устройство конкретного пользователя (разрешение экрана, архитектуру процессора, язык системы) и «на лету» генерирует из AAB легковесный APK, идеально подходящий именно для этого смартфона. Это уменьшает размер скачиваемого приложения на 20–50%.

Подпись приложения (Keystore)

Ни одно приложение не может быть установлено на Android без цифровой подписи. Подпись гарантирует, что обновление приложения выпустили именно вы, а не злоумышленник, подменивший файл.

Для релиза создается Keystore — зашифрованный файл-хранилище, содержащий ваш приватный криптографический ключ.

  • Если вы потеряете Keystore, вы никогда не сможете выпустить обновление для своего приложения. Google Play просто не примет новый файл, так как подписи не совпадут.
  • Если Keystore украдут, хакер сможет выпустить вредоносное обновление от вашего имени.

Поэтому файл .jks (Java KeyStore) и пароли к нему хранятся в строжайшем секрете, не добавляются в систему контроля версий (Git) и передаются на сборочный сервер через защищенные переменные окружения.

Заключение

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

Вы увидели, что Объектно-Ориентированное Программирование — это не просто синтаксис с ключевыми словами class и interface. Это способ мышления. Это искусство разделять сложную систему на независимые, легко заменяемые компоненты, которые общаются друг с другом по строгим контрактам.

Чистый код не пишется с первого раза. Он рождается в процессе постоянного рефакторинга. Теперь у вас есть инструменты, метрики и архитектурное видение, чтобы превращать хаос в надежные и масштабируемые Android-приложения. Успешных релизов!