- Глава 1. Kotlin: Фундамент Modern Android
- Глава 2. Android Core: Lifecycle, Context и Manifest
- Глава 3. Введение в Jetpack Compose: Декларативный UI
- Глава 4. State Management (Управление состоянием) в Compose
- Глава 5. Списки (Lazy Lists)
- Глава 6. Архитектура MVVM (Model - View - ViewModel)
- Глава 7. Асинхронность и Coroutines (Корутины)
- Глава 8. Работа с сетью: Retrofit & Parsing JSON
- Глава 9. Dependency Injection (Hilt)
- Глава 10. Локальная база данных (Room)
- Глава 11. Навигация (Jetpack Navigation Compose)
- Глава 12. Ресурсы, Темизация и Material Design 3
- Глава 13. Тестирование (Unit & UI Testing)
- Глава 14. Продвинутая сборка: Gradle, Multi-module и CI/CD
- Глава 15. Гарантированная фоновая работа (WorkManager)
- Глава 16. Разрешения (Permissions) и Уведомления (Notifications)
- Глава 17. Производительность (Performance) и Оптимизация
В современной разработке (Compose, Coroutines) мы пишем в функциональном стиле. Без понимания лямбд, scope-функций и null-безопасности код на Compose будет казаться магией.
В Java NullPointerException — главная причина крашей. В Kotlin система типов заставляет нас обрабатывать null на этапе компиляции.
Ключевые инструменты:
?— переменная может хранить null.?.— безопасный вызов (выполнить, только если не null).?:— Elvis-оператор (значение по умолчанию).
// Модель данных пользователя, где email может отсутствовать
data class User(val name: String, val email: String?)
fun printUserEmail(user: User?) {
// 1. Safe Call (?.):
// Если user == null, то user?.email вернет null, и программа НЕ упадет.
val email = user?.email
// 2. Elvis Operator (?:):
// Если выражение слева равно null, берем то, что справа.
// Часто используется для установки значений по умолчанию в UI.
val textToDisplay = user?.email ?: "Email не указан"
println(textToDisplay)
// 3. Not-null assertion (!!):
// "Я мамой клянусь, тут не null".
// ⚠️ В Android разработке старайтесь ИЗБЕГАТЬ этого оператора.
// Это гарантированный краш, если вы ошиблись.
// val unsafeEmail = user!!.email
}
Это сердце Jetpack Compose. Весь UI в Compose строится на функциях, которые принимают другие функции (лямбды) в качестве параметров (например, обработка клика или отрисовка содержимого).
Концепция: Функция может быть передана в другую функцию как переменная.
// Обычная функция
fun sum(a: Int, b: Int): Int {
return a + b
}
// Лямбда-выражение (анонимная функция)
// Тип переменной: (Int, Int) -> Int
val sumLambda: (Int, Int) -> Int = { a, b ->
a + b
}
// ФУНКЦИЯ ВЫСШЕГО ПОРЯДКА
// Принимает другую функцию (operation) как аргумент.
// Это база для таких вещей, как onClick в Compose.
fun calculate(a: Int, b: Int, operation: (Int, Int) -> Int): Int {
return operation(a, b)
}
fun main() {
// Передаем лямбду в функцию
val result = calculate(10, 5) { x, y ->
// Последняя строка в лямбде — это возвращаемое значение
x * y
}
// Обратите внимание: если лямбда идет последним аргументом,
// ее можно вынести за скобки (). Это называется Trailing Lambda syntax.
// Именно поэтому в Compose мы пишем Column { ... }, а не Column({ ... })
}
Эти 5 функций (let, run, with, apply, also) позволяют писать код компактнее. На собеседованиях на Middle/Senior спрашивают разницу между ними.
| Функция | Context Object | Return Value | Для чего чаще всего используется в Android |
|---|---|---|---|
| let | it |
Результат лямбды | Проверка на null (object?.let { ... }) |
| apply | this |
Сам объект | Настройка объекта (инициализация View, Intent, Builder) |
| with | this |
Результат лямбды | Группировка вызовов методов одного объекта |
| also | it |
Сам объект | Доп. действие (логирование) без изменения объекта |
| run | this |
Результат лямбды | Вычисление блока кода и возврат результата |
Примеры из реальной жизни:
class MyFragment {
fun setupUI() {
// APPLY: Идеально для настройки объектов.
// Мы обращаемся к свойствам TextView напрямую, без повторения имени переменной.
val titleView = TextView(context).apply {
text = "Привет, Андроид"
textSize = 20f
setTextColor(Color.BLACK)
// this.text - "this" подразумевается
}
// LET: Идеально для null-check.
// Блок выполнится только если getUer() вернет не null.
getUser()?.let { user ->
// Здесь user уже точно не null.
// Имя переменной можно сменить с "it" на "user" для читаемости.
titleView.text = user.name
}
// ALSO: Сделать что-то "заодно", не ломая цепочку вызовов.
val intent = Intent(context, SecondActivity::class.java).also {
println("Создан интент для перехода на SecondActivity: $it")
}
}
fun getUser(): User? = null // заглушка
}
В архитектуре MVI/MVVM мы постоянно передаем состояние экрана (State). Для этого используются именно эти классы.
Sealed Class (Изолированный класс): Это "Enum на стероидах". Позволяет ограничить иерархию классов. Идеально для описания состояний экрана (Загрузка, Ошибка, Успех).
// Описываем все возможные состояния UI
sealed class UiState {
// Объект (Singleton), так как у него нет полей (состояние одно для всех)
data object Loading : UiState()
// Data class, так как нам нужно передать данные (список новостей)
data class Success(val news: List<String>) : UiState()
// Data class для передачи ошибки
data class Error(val message: String) : UiState()
}
// Пример использования в ViewModel или UI
fun handleState(state: UiState) {
// Компилятор Kotlin заставит обработать ВСЕ ветки when,
// потому что класс sealed (замкнутый). Else писать не нужно!
when (state) {
is UiState.Loading -> showProgressBar()
is UiState.Success -> showList(state.news)
is UiState.Error -> showErrorMessage(state.message)
}
}
В Android мы часто хотим отложить создание тяжелых объектов до момента, когда они реально понадобятся (оптимизация старта приложения).
// Переменная heavyObject будет инициализирована ТОЛЬКО при первом обращении к ней.
// При повторных обращениях вернется уже созданный объект.
val heavyObject: HeavyCalculator by lazy {
println("Инициализация...")
HeavyCalculator()
}
Мы разобрали базу языка, на которой строится Android:
- Null Safety спасает от крашей.
- Лямбды позволяют писать декларативный UI (Compose).
- Scope Functions делают код чище.
- Sealed Classes — стандарт для управления состоянием (State Management).
Activity — это один экран приложения (в классическом понимании). Даже если мы пишем на Compose (где все в одной Activity), сама Activity никуда не делась. Она служит контейнером.
Система вызывает у Activity определенные методы, когда меняется ее состояние (юзер свернул приложение, развернул, повернул экран).
Основные колбэки (Callbacks):
- onCreate(): Вызывается при создании.
- Что делаем: Инициализируем UI (setContent), переменные, ViewModel.
- Важно: Вызывается только один раз за жизнь экземпляра.
- onStart(): Activity стала видимой, но еще не активной (юзер не может нажать).
- onResume(): Activity на переднем плане и готова к взаимодействию.
- Что делаем: Запускаем анимации, камеру, GPS, слушаем сенсоры.
- onPause(): Фокус потерян (например, поверх всплыло полупрозрачное окно или юзер начал сворачивать апп).
- Что делаем: Останавливаем видео, сохраняем легкие данные.
- onStop(): Activity полностью невидима.
- Что делаем: Останавливаем тяжелые операции, сетевые запросы UI.
- onDestroy(): Финиш. Система уничтожает Activity (или юзер нажал "Назад").
- Что делаем: Очищаем ресурсы (если не очистили ранее), закрываем соединения с БД.
class MainActivity : ComponentActivity() {
// 1. Самый важный метод. Входная точка.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Log.d("Lifecycle", "onCreate: Экран создается. Грузим UI.")
// В Modern Android здесь мы задаем Compose контент
setContent {
MyAppTheme {
MainScreen()
}
}
}
// 2. Экран стал виден
override fun onStart() {
super.onStart()
Log.d("Lifecycle", "onStart: Видим пользователю.")
}
// 3. Можно взаимодействовать (кликать)
override fun onResume() {
super.onResume()
Log.d("Lifecycle", "onResume: Активен. Запускаем анимации.")
}
// 4. Частичная потеря фокуса или начало ухода в фон
override fun onPause() {
super.onPause()
Log.d("Lifecycle", "onPause: На паузе. Сэйвим данные.")
}
// 5. Полный уход в фон
override fun onStop() {
super.onStop()
Log.d("Lifecycle", "onStop: Невидим. Освобождаем тяжелые ресурсы.")
}
// 6. Уничтожение
override fun onDestroy() {
super.onDestroy()
Log.d("Lifecycle", "onDestroy: Прощай, жестокий мир.")
}
}
Когда вы поворачиваете телефон, Android уничтожает вашу Activity (onDestroy) и создает заново (onCreate), чтобы загрузить ресурсы для новой ориентации (например, другой layout).
Проблема: Все обычные переменные внутри Activity сбрасываются. Решение:
- ViewModel (изучим в блоке Архитектуры) — переживает поворот экрана.
rememberSaveable(в Compose) — сохраняет состояние UI.
Это "ручка", через которую мы получаем доступ к ресурсам системы (файлам, базам данных, темам, запуску других экранов).
В Android есть два основных типа контекста. Путать их — значит создать Memory Leak (утечку памяти).
| Тип | Application Context | Activity Context |
|---|---|---|
| Жизнь | Живет пока живет приложение. | Живет пока открыт экран. |
| Доступ | applicationContext |
this (внутри Activity) |
| Для чего | Синглтоны, базы данных, Analytics. | Создание UI, Диалогов, Тостов, навигация. |
| Опасность | Если Activity Context передать в объект, который живет долго (Singleton), Activity никогда не удалится из памяти. | Безопасно для UI. |
// Пример правильного использования
class MyRepository(private val context: Context) {
// В репозитории (который живет долго) лучше хранить Application Context,
// чтобы не удерживать ссылку на закрытую Activity.
}
Паспорт вашего приложения. Файл, который система читает ПЕРЕД запуском кода.
Что там важно знать:
<uses-permission>: Запрос прав (Интернет, Камера, Геолокация).<application>: Глобальные настройки (иконка, тема).<activity>: Регистрация экранов.intent-filter: Говорит системе, что эта Activity — главная (Launcher), запускается первой.
<manifest ...>
<uses-permission android:name="android.permission.INTERNET" />
<application ... >
<activity android:name=".MainActivity" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Вы узнали правила игры, которые устанавливает Android:
- Lifecycle: Приложение не контролирует свою жизнь, это делает ОС. Мы лишь реагируем на колбэки (
onCreate,onResume,onPause). - Поворот экрана: Пересоздает Activity. Данные теряются, если их не сохранить (ViewModel/rememberSaveable).
- Context: Доступ к системе. Не храните ссылку на Activity в статических переменных (Singletone)!
- Manifest: Конфигурация и права доступа.
Раньше (в XML) мы использовали Императивный подход: мы говорили системе КАК менять UI.
"Найди TextView, затем поменяй его текст, затем сделай его видимым."
Compose использует Декларативный подход: мы описываем, ЧТО мы хотим видеть при определенном состоянии данных.
"Если данные загружены — покажи список. Если ошибка — покажи текст ошибки."
Формула UI в Compose:
Интерфейс — это результат выполнения функции от текущего состояния данных. Когда данные меняются, функция перезапускается (это называется Recomposition) и рисует новый UI.
В Compose интерфейс строится из обычных функций Kotlin, помеченных аннотацией @Composable.
Правила:
- Функция должна быть помечена
@Composable. - Имя функции пишется с БольшойБуквы (PascalCase), как классы (потому что мы создаем сущности UI).
- Функция ничего не возвращает (
Unit), она "эмитит" (излучает) UI.
// Простая функция, которая рисует текст
@Composable
fun Greeting(name: String) {
// Text - это стандартный Composable элемент (аналог TextView)
Text(text = "Привет, $name!")
}
В Compose нет огромного количества контейнеров. Основных "китов" всего три. С их помощью можно построить 90% интерфейсов.
- Column (Столбец): Размещает элементы вертикально (сверху вниз). Аналог
LinearLayout (vertical). - Row (Строка): Размещает элементы горизонтально (слева направо). Аналог
LinearLayout (horizontal). - Box (Коробка): Кладет элементы друг на друга (стопкой). Аналог
FrameLayout.
@Composable
fun UserProfile() {
// Рисуем рамку (Box можно использовать как подложку)
Box {
// Внутри располагаем элементы горизонтально
Row {
// Картинка (заглушка иконки)
Icon(imageVector = Icons.Default.Person, contentDescription = null)
// Рядом располагаем тексты вертикально
Column {
Text(text = "Алексей")
Text(text = "Android Developer")
}
}
}
}
Как задать отступы, цвет фона, размер, кликабельность? В XML для этого были десятки атрибутов. В Compose есть один универсальный инструмент — Modifier.
Модификатор передается аргументом почти в любой Composable элемент.
@Composable
fun ModifierExample() {
Text(
text = "Hello World",
modifier = Modifier
.background(Color.Yellow) // 1. Сначала красим фон самого текста
.padding(16.dp) // 2. Делаем отступ ВОКРУГ текста (внутри желтого)
.background(Color.Red) // 3. Красим фон ВОКРУГ отступа (красная рамка)
.padding(8.dp) // 4. Еще один отступ снаружи
.clickable { /* код клика */ } // 5. Делаем кликабельным ВСЁ, что внутри
)
}
Если вы поменяете .padding() и .background() местами, результат будет визуально другим.
Давайте соберем реальный компонент с использованием всего изученного.
@Composable
fun ProductCard(
productName: String,
price: String,
isOnSale: Boolean
) {
// Card - готовый компонент с тенью и скругленными углами
Card(
modifier = Modifier
.fillMaxWidth() // Занять всю ширину родителя
.padding(16.dp), // Внешний отступ
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
// Внутри карточки элементы идут вертикально
Column(
modifier = Modifier
.padding(16.dp) // Внутренний отступ (padding внутри карточки)
) {
// Заголовок
Text(
text = productName,
style = MaterialTheme.typography.titleLarge
)
// Разделитель (пробел высотой 8dp)
Spacer(modifier = Modifier.height(8.dp))
// Цена и значок "Скидка" в одной строке
Row(
verticalAlignment = Alignment.CenterVertically // Выровнять по центру вертикально
) {
Text(
text = price,
color = Color.Blue,
fontWeight = FontWeight.Bold
)
// Если есть скидка, рисуем значок
if (isOnSale) {
Spacer(modifier = Modifier.width(8.dp)) // Отступ между ценой и значком
// Box для красного фона значка
Box(
modifier = Modifier
.background(Color.Red, shape = RoundedCornerShape(4.dp))
.padding(horizontal = 6.dp, vertical = 2.dp)
) {
Text(text = "SALE", color = Color.White, fontSize = 10.sp)
}
}
}
}
}
}
В Compose не нужно каждый раз запускать эмулятор, чтобы увидеть верстку. Используйте аннотацию @Preview.
@Preview(showBackground = true)
@Composable
fun ProductCardPreview() {
// Этот код виден только в Android Studio (Split/Design window)
ProductCard(
productName = "MacBook Pro",
price = "$2000",
isOnSale = true
)
}
- Compose — это функции, а не объекты.
- UI строится на вложенности
Column,Row,Box. - Modifier управляет внешним видом и расположением. Порядок вызова функций в Modifier важен.
- Мы можем использовать обычные конструкции Kotlin (
if,for) прямо внутри UI кода для логики отображения.
В старом Android (XML) мы делали так: textView.text = "Новый текст".
В Compose нет сеттеров. Вы не можете обратиться к текстовому полю и изменить его свойство.
Принцип Recomposition (Рекомпозиция): Чтобы изменить UI, нужно вызвать Composable-функцию заново с новыми данными. Но как заставить функцию перезапуститься?
Compose следит за специальными объектами типа State. Как только значение внутри State меняется, Compose автоматически находит все функции, которые используют это значение, и запускает их заново.
Давайте напишем код, который НЕ будет работать, чтобы понять проблему:
@Composable
fun BrokenCounter() {
// ОШИБКА: Обычная переменная.
// При рекомпозиции (перерисовке) функция запускается с начала,
// и count снова становится 0.
var count = 0
Button(onClick = { count++ }) {
Text("Нажато $count раз") // Всегда будет показывать "0"
}
}
А теперь исправим это:
@Composable
fun WorkingCounter() {
// 1. mutableStateOf - создает "наблюдаемую" коробку с данными.
// 2. remember - говорит Compose: "Сохрани этот объект в памяти между перерисовками".
// 3. by - делегат (syntax sugar), позволяет читать count как Int, а не как State<Int>.
var count by remember { mutableStateOf(0) }
Button(onClick = {
count++ // Изменяем State -> Compose видит это -> Запускает Recomposition
}) {
Text("Нажато $count раз") // Теперь цифра обновляется!
}
}
Мы уже знаем из Главы 2, что при повороте экрана Activity уничтожается.
rememberхранит данные, пока Composable функция находится на экране. При повороте экрана данные потеряются (сбросятся в 0).rememberSaveableсохраняет данные вBundle(системное хранилище), поэтому они переживают поворот экрана и смерть процесса.
Правило: Для ввода пользователя (текст в поле, чекбоксы, счетчики) используйте rememberSaveable.
Это главный архитектурный паттерн в Compose.
Проблема: Если мы засунем состояние (var count by ...) внутрь кнопки, то другие части экрана не узнают об этом числе. А если нам нужно показать это число в заголовке экрана, а менять его в кнопке внизу?
Решение: Мы "поднимаем" состояние вверх, к общему родителю.
- Родитель владеет состоянием.
- Ребенок получает данные как параметр и шлет "события" (лямбды) наверх.
Это называется Unidirectional Data Flow (Однонаправленный поток данных): ⬇️ Данные (State) текут вниз. ⬆️ События (Events) летят наверх.
// 1. STATEFUL (Умный) компонент
// Он владеет состоянием и логикой
@Composable
fun CounterScreen() {
// Состояние живет здесь
var count by rememberSaveable { mutableStateOf(0) }
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Мы передаем данные вниз
Text(text = "Общий счет: $count", style = MaterialTheme.typography.displayMedium)
Spacer(modifier = Modifier.height(16.dp))
// Передаем событие вниз
CounterButton(
currentCount = count,
onIncrement = { count++ } // Лямбда: что делать при клике
)
}
}
// 2. STATELESS (Глупый) компонент
// Он просто рисует то, что ему дали, и сообщает о кликах.
// Такие компоненты легко переиспользовать и тестировать.
@Composable
fun CounterButton(
currentCount: Int, // Данные (State)
onIncrement: () -> Unit // Событие (Event)
) {
Button(onClick = { onIncrement() }) {
Text("Увеличить (сейчас $currentCount)")
}
}
Работа с текстом в Compose идеально демонстрирует State Hoisting. TextField сам по себе не хранит текст, который вы печатаете! Вы должны сами обновлять его состояние.
@Composable
fun SimpleInput() {
// Храним текст, который ввел пользователь
var text by remember { mutableStateOf("") }
TextField(
value = text, // 1. Что показывать? Текущее состояние.
onValueChange = { newText ->
// 2. Пользователь нажал клавишу. Пришло событие с новым текстом.
// Мы ОБЯЗАНЫ обновить наш state, иначе в поле ничего не появится.
text = newText
},
label = { Text("Введите имя") }
)
}
- Recomposition: Compose перерисовывает UI при изменении данных.
- State: Используйте
mutableStateOfдля создания наблюдаемых переменных. - Remember: Используйте
remember, чтобы переменная не сбрасывалась при перерисовке. - RememberSaveable: Используйте, чтобы пережить поворот экрана.
- State Hoisting: Делайте компоненты "глупыми" (Stateless), передавая данные в параметрах, а действия — в лямбдах. Это основа чистой архитектуры.
Если вы просто поместите 1000 элементов в Column и добавите .verticalScroll(), приложение начнет тормозить или упадет с ошибкой OutOfMemory. Почему?
Потому что Column создает и рендерит ВСЕ элементы сразу, даже те, которые находятся далеко за пределами экрана.
LazyColumn (ленивая колонка) работает умно: она создает только те элементы, которые видны на экране сейчас. Когда вы скроллите вниз, старые элементы сверху уничтожаются (или переиспользуются), а новые снизу создаются.
Синтаксис отличается от обычного Column. Вместо прямого вложения элементов мы используем DSL-блок (Domain Specific Language).
@Composable
fun SimpleList() {
LazyColumn(
modifier = Modifier.fillMaxSize(), // Растягиваем на весь экран
contentPadding = PaddingValues(16.dp), // Отступы по краям списка
verticalArrangement = Arrangement.spacedBy(8.dp) // Отступы МЕЖДУ элементами
) {
// 1. Одиночный элемент (как Header)
item {
Text(
text = "Список фруктов",
style = MaterialTheme.typography.headlineMedium
)
}
// 2. Список элементов (из массива/листа)
items(50) { index ->
Text("Элемент #$index")
}
}
}
Обычно у нас есть список объектов (Data Classes). Для этого используется функция items() (обратите внимание на импорт: import androidx.compose.foundation.lazy.items).
Допустим, у нас есть модель:
data class Fruit(val id: Int, val name: String, val price: Int)
val fruits = listOf(
Fruit(1, "Яблоко", 100),
Fruit(2, "Банан", 150),
Fruit(3, "Апельсин", 200),
// ... еще 100 фруктов
)
Верстка списка:
@Composable
fun FruitList(fruitList: List<Fruit>) {
LazyColumn {
items(
items = fruitList,
// ОПТИМИЗАЦИЯ: Указываем уникальный ключ для каждого элемента.
// Это помогает Compose не перерисовывать весь список при удалении/перемещении элемента.
key = { fruit -> fruit.id }
) { fruit ->
// Здесь мы вызываем Composable для отрисовки ОДНОЙ строки
FruitItem(fruit = fruit)
}
}
}
@Composable
fun FruitItem(fruit: Fruit) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(Color.LightGray)
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = fruit.name)
Text(text = "${fruit.price} ₽", fontWeight = FontWeight.Bold)
}
}
Помните про State Hoisting? Не обрабатывайте клик внутри элемента списка. Передавайте событие наверх.
@Composable
fun FruitListScreen() {
// В реальном приложении этот список придет из ViewModel
val fruits = remember { getSampleFruits() }
LazyColumn {
items(fruits, key = { it.id }) { fruit ->
FruitItem(
fruit = fruit,
onItemClick = { clickedFruit ->
Log.d("List", "Нажали на: ${clickedFruit.name}")
}
)
}
}
}
@Composable
fun FruitItem(
fruit: Fruit,
onItemClick: (Fruit) -> Unit // Лямбда для клика
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onItemClick(fruit) } // Передаем кликнутый объект наверх
.padding(16.dp)
) {
// ... содержимое
}
}
- LazyRow: Работает точно так же, как
LazyColumn, но список горизонтальный. - LazyVerticalGrid: Сетка (таблица).
Пример сетки (галерея фото):
LazyVerticalGrid(
// GridCells.Fixed(3) - 3 колонки фиксированной ширины
// GridCells.Adaptive(128.dp) - поместить столько колонок, сколько влезет (мин ширина 128dp)
columns = GridCells.Adaptive(minSize = 128.dp),
contentPadding = PaddingValues(8.dp)
) {
items(photos) { photo ->
PhotoItem(photo)
}
}
Списки — самое "узкое" место по производительности.
- **Всегда используйте
key**: Без ключей Compose может путаться при скролле и пересоздавать элементы лишний раз. - **Не делайте тяжелых вычислений в
item**: Не нужно форматировать даты или фильтровать списки прямо внутриLazyColumn. Делайте это заранее или во ViewModel. - Загрузка картинок: Используйте библиотеку Coil. Она автоматически кэширует картинки и освобождает память, когда элемент уходит с экрана.
// Пример с Coil
AsyncImage(
model = "https://example.com/image.jpg",
contentDescription = null,
modifier = Modifier.size(100.dp)
)
- Используйте LazyColumn для вертикальных списков и LazyRow для горизонтальных.
item {}— для одиночных блоков (заголовки).items(list) {}— для генерации списка из данных.- Всегда задавайте параметр
keyвitemsдля плавной работы анимаций и скролла.
В Android есть фундаментальная проблема: Activity/Composable умирают часто. Повернули экран — Activity умерла. Сменили язык — умерла. Система убила процесс — умерла.
Если вы храните данные (список загруженных пользователей) прямо в UI, то при повороте экрана вы будете загружать их заново. Это трата трафика и времени пользователя.
Решение: Вынести данные и логику в отдельный класс, который живет дольше, чем экран. Этот класс называется ViewModel.
- Model (Модель): Слой данных. Это ваши Data Classes, Базы данных (Room) и Сеть (Retrofit). Модель ничего не знает об экране. Она просто отдает данные ("Вот список пользователей").
- View (Представление): Ваш UI (Jetpack Compose). Он глупый. Он ничего не решает. Он просто:
- Подписывается на данные из ViewModel.
- Рисует их.
- Передает нажатия (клики) во ViewModel.
- ViewModel (Вью-Модель): Посредник.
- Хранит состояние экрана (
State). - Переживает поворот экрана.
- Принимает действия от View ("Юзер нажал кнопку"), обрабатывает их (обращается к Model) и обновляет State.
Для работы нам понадобится библиотека (обычно уже включена в новые проекты):
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.6.x")
Вместо кучи разрозненных переменных (isLoading, userList, errorText), мы создаем один data class, описывающий всё состояние экрана сразу.
// Состояние экрана "Список пользователей"
data class UsersUiState(
val isLoading: Boolean = false,
val users: List<String> = emptyList(),
val errorMessage: String? = null
)
Мы используем StateFlow. Это современная замена устаревшему LiveData.
Паттерн "Backing Property" (Скрытое свойство): Мы создаем две версии переменной состояния:
private val _uiState(Mutable) — чтобы менять состояние можно было только внутри ViewModel.val uiState(Immutable) — публичная версия, которую View может только читать.
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class UsersViewModel : ViewModel() {
// 1. Закрытое изменяемое состояние. Начальное значение - всё пусто.
private val _uiState = MutableStateFlow(UsersUiState())
// 2. Открытый поток для чтения (View подпишется на него)
val uiState: StateFlow<UsersUiState> = _uiState.asStateFlow()
// Симуляция загрузки данных (в реальности тут будет запрос в сеть)
fun loadUsers() {
// Ставим флаг загрузки
_uiState.update { currentState ->
currentState.copy(isLoading = true, errorMessage = null)
}
// ... тут должна быть асинхронная работа (Coroutines),
// пока сделаем вид, что данные пришли мгновенно
val fakeUsers = listOf("Alice", "Bob", "Charlie")
// Обновляем состояние: убираем загрузку, сохраняем список
_uiState.update {
it.copy(isLoading = false, users = fakeUsers)
}
}
fun deleteUser(user: String) {
// Логика удаления
_uiState.update { state ->
val newList = state.users - user
state.copy(users = newList)
}
}
}
В Composable функции мы получаем экземпляр ViewModel и "слушаем" поток данных.
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun UsersScreen(
// viewModel() - специальная функция, которая либо создаст новую VM,
// либо вернет существующую, если был поворот экрана.
viewModel: UsersViewModel = viewModel()
) {
// 1. Подписываемся на StateFlow.
// collectAsState преобразует Flow в Compose State.
// Теперь любое изменение в VM автоматически перерисует этот экран.
val state by viewModel.uiState.collectAsState()
// 2. Рисуем UI в зависимости от состояния
if (state.isLoading) {
CircularProgressIndicator() // Крутилка
} else {
Column {
Button(onClick = { viewModel.loadUsers() }) {
Text("Загрузить пользователей")
}
LazyColumn {
items(state.users) { user ->
UserRow(
name = user,
onDeleteClick = { viewModel.deleteUser(user) }
)
}
}
}
}
}
Обратите внимание на поток данных в коде выше. Это ключевой принцип современной разработки:
- Event: Пользователь нажал кнопку -> Вызвался метод
viewModel.loadUsers(). - Update: ViewModel сходила за данными и обновила
_uiState. - State: Новое состояние прилетело в UI через
collectAsState(). - UI: Экран перерисовался.
UI никогда не меняет данные сам. Он просит об этом ViewModel.
- MVVM разделяет логику (ViewModel) и отображение (View).
- ViewModel живет дольше, чем экран, и хранит данные при повороте устройства.
- UiState (Data Class) описывает всё, что происходит на экране.
- StateFlow — это "труба", по которой данные текут из ViewModel в UI.
Представьте, что ваше приложение — это однополосная дорога. По ней едут машины (отрисовка кадров UI). Если на дорогу выйдет грузовик и сломается (тяжелая операция: чтение файла, запрос в сеть), пробка образуется мгновенно. Экран застынет.
Чтобы этого избежать, мы должны "убрать грузовик" на соседнюю полосу (фоновый поток), а когда он доставит груз, вернуть результат на главную дорогу.
Корутины — это "легкие потоки". Они позволяют писать асинхронный код так, будто он выполняется последовательно, без лапши из колбэков (callback hell).
Если вы видите функцию с пометкой suspend — это значит, что эта функция может приостановить свое выполнение, не блокируя поток.
Аналогия: Вы варите суп (Main Thread). Вам нужно подождать, пока закипит вода. Blocking (Java Thread): Вы стоите и смотрите на кастрюлю 10 минут, ничего больше не делая. Кухня парализована. Suspending (Coroutines): Вы ставите таймер и уходите резать овощи. Вы (поток) свободны для других дел. Когда вода закипит, вы вернетесь к кастрюле.
Корутины должны знать, на каком пуле потоков им работать.
| Диспетчер | Для чего нужен | Примеры |
|---|---|---|
| Dispatchers.Main | Работа с UI | Обновление текста, анимации, вызов setValue. |
| Dispatchers.IO | Ввод/Вывод данных (Input/Output) | Запросы к серверу (Retrofit), работа с БД (Room), чтение файлов. |
| Dispatchers.Default | Тяжелые вычисления (CPU) | Обработка фото, парсинг большого JSON, сложные алгоритмы. |
Корутину нельзя запустить "в воздухе". Ей нужен Scope (Область жизни). Если мы запустим корутину во ViewModel, а пользователь закроет экран, корутина должна отмениться, чтобы не тратить батарею и память.
Для этого в Android есть готовый viewModelScope. Он автоматически отменяет все запущенные задачи, когда ViewModel умирает (onCleared).
Вернемся к примеру из прошлой главы и напишем его правильно.
class UsersViewModel : ViewModel() {
private val _uiState = MutableStateFlow(UsersUiState())
val uiState = _uiState.asStateFlow()
fun loadUsers() {
// 1. Запускаем корутину в скоупе ViewModel (на Main Thread по умолчанию)
viewModelScope.launch {
// Ставим загрузку (это быстро, можно на Main)
_uiState.update { it.copy(isLoading = true) }
try {
// 2. СМЕНА ПОТОКА.
// withContext приостанавливает функцию loadUsers,
// переключается на IO поток, выполняет блок кода,
// и возвращает результат обратно.
val usersFromNetwork = withContext(Dispatchers.IO) {
// Имитация долгого запроса (2 секунды)
delay(2000)
// Возвращаем результат
listOf("Alice", "Bob", "Charlie")
}
// 3. Мы снова на Main Thread (автоматически после withContext).
// Можем безопасно обновлять UI.
_uiState.update {
it.copy(isLoading = false, users = usersFromNetwork)
}
} catch (e: Exception) {
// Если пропал интернет или сервер упал
_uiState.update {
it.copy(isLoading = false, errorMessage = e.message)
}
}
}
}
}
Иногда нужно сделать два запроса одновременно (например, загрузить профиль юзера и список его друзей), а не ждать их по очереди.
launch— "Запустил и забыл". ВозвращаетJob.async— "Запустил и жду результат". ВозвращаетDeferred(отложенный результат).
fun loadDashboard() {
viewModelScope.launch {
// Запускаем две задачи ПАРАЛЛЕЛЬНО
val profileDeferred = async(Dispatchers.IO) { api.getProfile() }
val friendsDeferred = async(Dispatchers.IO) { api.getFriends() }
// await() приостановит эту корутину, пока результат не будет готов.
// Общее время выполнения будет равно времени самой долгой задачи,
// а не сумме времен (как было бы при последовательном вызове).
val profile = profileDeferred.await()
val friends = friendsDeferred.await()
_uiState.update { it.copy(profile = profile, friends = friends) }
}
}
- Главный поток священен. Не блокируйте его.
- Suspend функции позволяют писать асинхронный код в синхронном стиле.
- Dispatchers.IO — ваш лучший друг для работы с сетью и БД.
- viewModelScope — безопасное место для запуска корутин. При закрытии экрана все запросы отменяются автоматически.
- withContext используется для переключения между потоками внутри одной корутины.
Внутри Android есть встроенные средства для работы с сетью (HttpURLConnection), но пользоваться ими — это пытка. Нужно вручную открывать соединение, читать байты, превращать их в строку, а потом парсить JSON.
Retrofit делает все это за вас:
- Вы описываете API как обычный Kotlin интерфейс.
- Retrofit автоматически генерирует код для запросов.
- Он сам превращает JSON-ответ сервера в готовые объекты Kotlin (Data Classes).
Шаг 1. Разрешение на Интернет
Без этой строчки в AndroidManifest.xml приложение упадет при первой же попытке выйти в сеть.
<manifest ...>
<uses-permission android:name="android.permission.INTERNET" />
</manifest>
Шаг 2. Зависимости (build.gradle.kts) Нам нужны сам Retrofit и Converter (библиотека, которая переводит JSON в объекты). Самый популярный конвертер — Gson (от Google), но современный стандарт — Kotlin Serialization или Moshi. Для простоты начнем с Gson.
dependencies {
// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
// Конвертер для JSON (Gson)
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
// OkHttp Logging (чтобы видеть запросы в логах - мастхэв для отладки)
implementation("com.squareup.okhttp3:logging-interceptor:4.11.0")
}
Допустим, сервер возвращает нам такой JSON при запросе профиля:
{
"id": 101,
"username": "alex_coder",
"avatar_url": "https://example.com/img.png",
"is_admin": false
}
Мы создаем Data Class. Используем аннотацию @SerializedName, если имена полей в JSON (snake_case) отличаются от имен в Kotlin (camelCase).
import com.google.gson.annotations.SerializedName
data class UserProfile(
val id: Int,
// В JSON поле называется "username", мапим его в переменную name
@SerializedName("username")
val name: String,
@SerializedName("avatar_url")
val avatarUrl: String?, // Может быть null, если аватарки нет
@SerializedName("is_admin")
val isAdmin: Boolean
)
Мы создаем интерфейс и размечаем методы аннотациями HTTP (@GET, @POST, @PUT, @DELETE).
Важно: Мы делаем функции suspend, чтобы Retrofit понимал, что мы используем Coroutines.
import retrofit2.http.GET
import retrofit2.http.Path
import retrofit2.http.Query
interface ApiService {
// 1. Простой GET запрос: https://api.example.com/users/profile
@GET("users/profile")
suspend fun getMyProfile(): UserProfile
// 2. Запрос с динамическим путем: https://api.example.com/users/101
@GET("users/{id}")
suspend fun getUserById(@Path("id") userId: Int): UserProfile
// 3. Запрос с параметрами поиска: https://api.example.com/search?q=kotlin&page=1
@GET("search")
suspend fun searchUsers(
@Query("q") query: String,
@Query("page") page: Int
): List<UserProfile>
}
Обычно этот код пишется один раз в DI модуле (Hilt/Dagger). Но пока мы не изучили DI, создадим Singleton объект.
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
object RetrofitClient {
private const val BASE_URL = "https://api.example.com/"
// Настраиваем HTTP клиент (OkHttp)
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
// Логировать тело запроса и ответа (полезно при отладке)
level = HttpLoggingInterceptor.Level.BODY
})
.build()
// Создаем сам Retrofit
val api: ApiService by lazy {
Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okHttpClient) // Подключаем наш клиент с логами
.addConverterFactory(GsonConverterFactory.create()) // Подключаем парсер JSON
.build()
.create(ApiService::class.java) // Создаем реализацию интерфейса
}
}
Теперь соединяем всё вместе. ViewModel вызывает метод api, получает данные и кладет их в State.
class ProfileViewModel : ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState = _uiState.asStateFlow()
fun loadProfile() {
viewModelScope.launch {
_uiState.value = UiState.Loading
try {
// ВЫЗОВ СЕТИ
// Нам НЕ нужно переключаться на Dispatchers.IO вручную.
// Retrofit (версии 2.6.0+) делает это под капотом для suspend функций.
val profile = RetrofitClient.api.getMyProfile()
_uiState.value = UiState.Success(profile)
} catch (e: Exception) {
// Обработка ошибок (нет интернета, 404, 500)
_uiState.value = UiState.Error("Ошибка загрузки: ${e.message}")
}
}
}
}
- Retrofit превращает HTTP API в Kotlin Interface.
- Gson/Moshi/KotlinX превращают JSON текст в объекты Kotlin.
- В
ApiServiceиспользуемsuspendфункции, чтобы работать с корутинами. - OkHttp Interceptor — незаменимая вещь. Он позволяет видеть в Logcat (вкладка логов в Android Studio) всё, что отправляется и приходит от сервера.
Hilt — это библиотека, которая берет на себя создание и хранение объектов. Она строит "Граф зависимостей" (Dependency Graph).
Представьте, что ваше приложение — это автомобиль.
- Без DI: Двигатель сам создает поршни, поршни сами плавят металл... Хаос.
- С DI (Hilt): Есть сборочный конвейер. Он создает поршни, вставляет их в двигатель, а готовый двигатель ставит в машину. Машина просто "получает" двигатель и едет.
Hilt требует настройки Gradle.
build.gradle.kts (Project level):
plugins {
// ...
id("com.google.dagger.hilt.android") version "2.50" apply false
}
build.gradle.kts (Module: app):
plugins {
id("kotlin-kapt") // Или ksp, но kapt для Hilt пока стабильнее/привычнее
id("com.google.dagger.hilt.android")
}
dependencies {
implementation("com.google.dagger.hilt.android:2.50")
kapt("com.google.dagger.hilt.android.compiler:2.50")
}
Первое, что нужно сделать — создать класс Application и повесить аннотацию. Это запускает генерацию кода Hilt.
@HiltAndroidApp
class MyApplication : Application() {
// Этот класс должен быть прописан в AndroidManifest.xml:
// <application android:name=".MyApplication" ... >
}
Есть два способа объяснить Hilt, как создать объект.
Если класс написали ВЫ, просто добавьте @Inject перед конструктором.
// Мы сами написали этот класс, поэтому можем добавить @Inject
class AnalyticsService @Inject constructor() {
fun trackEvent(name: String) { /* ... */ }
}
Если класс чужой (например, Retrofit, OkHttpClient, RoomDatabase), вы не можете залезть в библиотеку и дописать @Inject.
Для этого создаются Модули (@Module). Это инструкции по сборке.
Давайте перепишем наш RetrofitClient из прошлой главы на Hilt.
@Module
// InstallIn говорит: "Где будут жить эти объекты?"
// SingletonComponent::class -> живут, пока живет приложение (единственные экземпляры).
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton // Создать один раз и переиспользовать
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
// Hilt сам найдет, как создать okHttpClient (через метод выше) и передаст сюда
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService {
return retrofit.create(ApiService::class.java)
}
}
Теперь самое приятное. Нашей ViewModel больше не нужно знать, как создается Retrofit. Она просто просит ApiService в конструкторе.
@HiltViewModel
class UsersViewModel @Inject constructor(
private val api: ApiService // Hilt сам найдет это в NetworkModule и положит сюда
) : ViewModel() {
fun loadUsers() {
viewModelScope.launch {
// Используем api. Никаких синглтонов RetrofitClient.api!
val users = api.getUsers()
}
}
}
Чтобы экран (Activity или Fragment) мог получить ViewModel с зависимостями, его нужно пометить аннотацией.
@AndroidEntryPoint // <--- ОБЯЗАТЕЛЬНО! Без этого упадет.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
// Hilt знает, как создать эту VM и внедрить в нее ApiService
val viewModel: UsersViewModel = viewModel()
UsersScreen(viewModel)
}
}
}
Hilt позволяет управлять временем жизни объектов.
| Компонент Hilt | Scope (Аннотация) | Время жизни |
|---|---|---|
| SingletonComponent | @Singleton |
Все время работы приложения (БД, Сеть) |
| ActivityRetainedComponent | @ActivityRetainedScoped |
Переживает поворот экрана (для ViewModel) |
| ActivityComponent | @ActivityScoped |
Пока жива Activity (Навигатор, UI-хелперы) |
| FragmentComponent | @FragmentScoped |
Пока жив Фрагмент |
Почему это важно: Если вы пометите объект @Singleton, он останется в памяти навсегда, даже если он нужен был на 5 минут. Если не пометите ничем — Hilt будет создавать новый экземпляр при каждом запросе (New Instance).
- Hilt избавляет нас от ручного создания объектов.
@HiltAndroidApp— ставим на класс Application.@AndroidEntryPoint— ставим на Activity/Fragment.@Injectв конструктор — для своих классов.- Modules (
@Module,@Provides) — для сторонних библиотек (Retrofit, Room). @HiltViewModel— магия для ViewModel.
Room — это не сама база данных. Это обертка (ORM) над старой доброй SQL-базой данных SQLite, которая встроена в каждый Android-телефон.
SQLite писать вручную больно (нужно писать SQL-запросы строками, парсить курсоры). Room позволяет работать с базой как с обычными объектами Kotlin, а SQL-код генерирует за вас.
Архитектура Room состоит из трех компонентов:
- Entity (Сущность): Таблица в базе данных.
- DAO (Data Access Object): Интерфейс с методами для чтения/записи (SQL-запросы).
- Database: Класс-точка входа, который хранит базу и отдает DAO.
Превращаем наш Data Class в таблицу базы данных.
Важно: Обычно не смешивают модели API (из Retrofit) и модели БД. Лучше создать отдельный класс UserEntity и мапить их друг в друга. Но для простоты примера используем один.
import androidx.room.Entity
import androidx.room.PrimaryKey
// @Entity говорит, что это таблица с именем "users"
@Entity(tableName = "users")
data class UserEntity(
// @PrimaryKey - уникальный ключ. autoGenerate = false, так как ID приходит с сервера.
@PrimaryKey
val id: Int,
val name: String,
// В базе имена колонок часто пишут в snake_case
@androidx.room.ColumnInfo(name = "avatar_url")
val avatarUrl: String?
)
Здесь мы описываем, что мы хотим делать с данными.
Магия Flow:
Если метод возвращает Flow<List<UserEntity>>, Room будет автоматически присылать новый список каждый раз, когда данные в базе изменятся. Вам не нужно делать повторный запрос getUsers()!
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface UserDao {
// Чтение всех юзеров.
// Возвращаем Flow -> мы подпишемся на обновления таблицы.
@Query("SELECT * FROM users")
fun getAllUsers(): Flow<List<UserEntity>>
// Запись списка.
// OnConflictStrategy.REPLACE -> Если юзер с таким ID уже есть, обновить его данные.
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(users: List<UserEntity>)
// Удаление всего (для очистки кэша)
@Query("DELETE FROM users")
suspend fun clearAll()
}
Абстрактный класс, который наследуется от RoomDatabase.
import androidx.room.Database
import androidx.room.RoomDatabase
// Указываем все сущности (таблицы) и версию базы (нужна для миграций)
@Database(entities = [UserEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
// Абстрактный метод, который вернет нам реализацию DAO
abstract fun userDao(): UserDao
}
Помните, мы не создаем объекты руками? Добавим базу данных в наш DI модуль.
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"my_app_database.db"
)
.fallbackToDestructiveMigration() // ВНИМАНИЕ: При смене версии БД старая удалится (для разработки ок)
.build()
}
// Предоставляем DAO отдельно, чтобы в репозитории не просить всю базу
@Provides
fun provideUserDao(database: AppDatabase): UserDao {
return database.userDao()
}
}
Теперь самое главное архитектурное правило Senior-разработчика: Repository Pattern.
UI не должен знать, откуда берутся данные (из сети или из базы). Репозиторий скрывает эту логику. Идеальная схема:
- UI подписывается на базу данных (через
Flow). - Репозиторий запрашивает свежие данные из сети.
- Репозиторий сохраняет данные в базу.
- База данных автоматически уведомляет UI о новых данных.
class UserRepository @Inject constructor(
private val api: ApiService,
private val dao: UserDao
) {
// 1. Источник данных для UI - всегда локальная база!
// UI получит данные мгновенно (если они были в кэше).
val users: Flow<List<UserEntity>> = dao.getAllUsers()
// 2. Метод обновления данных
suspend fun refreshUsers() {
try {
// Качаем с сети
val remoteUsers = api.getUsers()
// Мапим Network Model -> Database Entity (если классы разные)
val entities = remoteUsers.map {
UserEntity(it.id, it.name, it.avatarUrl)
}
// Сохраняем в базу.
// В ЭТОТ МОМЕНТ сработает Flow выше, и UI обновится сам!
dao.insertAll(entities)
} catch (e: Exception) {
// Ошибка сети. Но UI не пустой, там старые данные из кэша.
// Можно отправить ошибку в UI через отдельный StateFlow/Channel.
}
}
}
SQLite умеет хранить только примитивы (Int, String, Double). А что, если у юзера есть список тегов val tags: List<String>? SQLite упадет.
Нужен TypeConverter, который превратит List<String> в одну строку (например, JSON) при записи и обратно при чтении.
class Converters {
@TypeConverter
fun fromList(list: List<String>): String {
return list.joinToString(",") // "tag1,tag2,tag3"
}
@TypeConverter
fun toList(data: String): List<String> {
return data.split(",")
}
}
// Не забудьте добавить @TypeConverters(Converters::class) над классом AppDatabase
- Room позволяет хранить данные локально и работать офлайн.
- Flow в DAO делает базу реактивной: база сама сообщает об изменениях.
- Repository Pattern: Сеть только обновляет базу. UI читает только из базы. Это обеспечивает мгновенный показ контента (Offline First).
- Hilt создает экземпляр базы данных как Singleton.
В современном Android используется архитектура Single Activity. У вас есть только одна MainActivity. Внутри нее находится контейнер (NavHost), который меняет содержимое экрана в зависимости от "маршрута" (Route).
Маршруты в Compose работают как URL в браузере:
"home"— главный экран."users"— список пользователей."users/123"— детали пользователя с ID 123.
Добавляем зависимость в build.gradle.kts:
implementation("androidx.navigation:navigation-compose:2.7.x")
Чтобы не писать строки "home" вручную по всему коду (и делать опечатки), создадим Sealed Class. Это хорошая практика (Best Practice).
// Файл: Screen.kt
sealed class Screen(val route: String) {
// Простой маршрут
data object UserList : Screen("user_list")
// Маршрут с аргументом. {userId} - это плейсхолдер.
data object UserDetails : Screen("user_details/{userId}") {
// Вспомогательная функция, чтобы удобно подставлять ID
fun createRoute(userId: Int) = "user_details/$userId"
}
}
В MainActivity мы настраиваем схему навигации.
- NavController: Объект-дирижер. Он знает, где мы сейчас, и умеет переходить (
Maps) или возвращаться назад (popBackStack). - NavHost: Контейнер, где отображаются экраны.
@Composable
fun MainApp() {
// 1. Создаем контроллер. Он должен создаваться в корне иерархии.
val navController = rememberNavController()
// 2. Описываем граф навигации
NavHost(
navController = navController,
startDestination = Screen.UserList.route // С чего начать?
) {
// ЭКРАН 1: Список пользователей
composable(route = Screen.UserList.route) {
// Передаем контроллер вниз, чтобы экран мог вызвать навигацию
UserListScreen(
onUserClick = { userId ->
// Навигация: подставляем ID в маршрут
navController.navigate(Screen.UserDetails.createRoute(userId))
}
)
}
// ЭКРАН 2: Детали (принимает аргумент)
composable(
route = Screen.UserDetails.route,
// Описываем аргументы, чтобы Compose знал, что userId - это Int
arguments = listOf(navArgument("userId") { type = NavType.IntType })
) { backStackEntry ->
// Достаем аргумент из "рюкзака" (backStackEntry)
val userId = backStackEntry.arguments?.getInt("userId") ?: 0
UserDetailsScreen(
userId = userId,
onBackClick = {
navController.popBackStack() // Назад
}
)
}
}
}
Новички часто пытаются передать целый объект User (с именем, фото, биографией) в аргументах навигации.
⛔️ Так делать нельзя!
- Маршрут имеет лимит по длине (как URL).
- Если Android убьет процесс и восстановит его, большой объект может потеряться или вызвать переполнение памяти.
✅ Правильный подход (SSOT):
Передавайте только ID (userId).
Экран деталей (UserDetailsScreen) должен получить этот ID, передать его во ViewModel, а та загрузит полные данные из Базы Данных или Сети (как мы делали в прошлых главах).
Вопрос на миллион: Кто должен вызывать navController.navigate()?
- Вариант А: Передать
navControllerпрямо воViewModel.
- ❌ Плохо. ViewModel не должна знать о View и Android-компонентах. Это ломает Unit-тесты и вызывает утечки памяти.
- Вариант Б: Обрабатывать навигацию в UI (Composable).
- ✅ Хорошо. ViewModel шлет событие "Нужно перейти", а UI реагирует.
// Экран списка (UI)
@Composable
fun UserListScreen(
onUserClick: (Int) -> Unit, // Лямбда для навигации (колбэк)
viewModel: UserListViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
LazyColumn {
items(state.users) { user ->
UserRow(
user = user,
// UI просто сообщает наверх: "Кликнули".
// Он не знает, куда это приведет.
onClick = { onUserClick(user.id) }
)
}
}
}
Классическое меню снизу делается через Scaffold.
@Composable
fun AppWithBottomBar() {
val navController = rememberNavController()
Scaffold(
bottomBar = {
NavigationBar {
// ... элементы меню, вызывающие navController.navigate()
}
}
) { innerPadding ->
// NavHost должен учитывать отступы (innerPadding),
// иначе контент перекроется нижним меню.
NavHost(
navController = navController,
startDestination = "home",
modifier = Modifier.padding(innerPadding)
) {
// ... экраны
}
}
}
Примечание для уровня Senior. Начиная с версии Navigation 2.8.0, Google рекомендует использовать Type Safe Navigation (на базе Kotlin Serialization). Вместо строк мы передаем объекты.
// Вместо строк "user/123" мы используем Serializable объекты
@Serializable
data class Profile(val id: Int)
// В NavHost:
composable<Profile> { backStackEntry ->
val profile: Profile = backStackEntry.toRoute()
// ...
}
// Навигация:
navController.navigate(Profile(id = 123))
Это устраняет ошибки опечаток в строках маршрутов.
- NavHost — карта вашего приложения.
- NavController — водитель.
- Используйте Sealed Classes для хранения маршрутов (или Type Safe объекты).
- Передавайте между экранами только минимальные данные (ID).
- ViewModel не должна держать ссылку на
NavController. Используйте лямбды или каналы событий (Channels).
Даже в Modern Android мы продолжаем хранить статические данные (строки, картинки) в папке res. Это позволяет системе автоматически подставлять нужные ресурсы в зависимости от конфигурации (язык устройства, размер экрана).
Основные папки:
res/values/strings.xml: Тексты. Никогда не пишите текст хардкодом в коде (Text("Привет")). Используйте ресурсы! Это ключ к локализации.res/drawable: Векторные и растровые картинки.res/mipmap: Иконки приложения (для лаунчера).
Доступ к ресурсам в Compose:
Вместо R.string.hello (как ID) мы используем helper-функции:
// Текст
Text(text = stringResource(id = R.string.hello_world))
// Цвет (если он задан в colors.xml, хотя в Compose лучше использовать Theme)
val c = colorResource(id = R.color.teal_200)
// Картинка
Image(
painter = painterResource(id = R.drawable.ic_logo),
contentDescription = stringResource(R.string.logo_desc)
)
Compose "из коробки" заточен под Material Design 3 (он же Material You). Главная фишка M3 — Dynamic Colors. Если включить эту опцию, приложение будет окрашиваться в цвета обоев пользователя (на Android 12+).
Тема в Compose состоит из трех китов:
- Color Scheme (Цветовая схема).
- Typography (Шрифты).
- Shapes (Формы/Закругления).
Когда вы создаете проект в Android Studio, она генерирует файл ui/theme/Theme.kt. Давайте разберем его.
// Определяем темную палитру
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80,
background = Color(0xFF1C1B1F) // Почти черный
)
// Определяем светлую палитру
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40,
background = Color(0xFFFFFBFE) // Почти белый
)
@Composable
fun MyAppTheme(
// Автоматически определяем, включена ли темная тема в системе
darkTheme: Boolean = isSystemInDarkTheme(),
// Включать ли динамические цвета (Android 12+)
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
// Логика выбора цветов (динамические, темные или светлые)
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
// Обертка MaterialTheme передает эти настройки всем вложенным элементам
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
Главная ошибка новичка: Использование Color.Black или Color.White.
Если вы напишете Color.Black для текста, то в темной теме (где фон черный) текст исчезнет.
Правило: Используйте цвета из темы (MaterialTheme.colorScheme). Они семантические (смысловые).
| Имя цвета | Значение | Пример использования |
|---|---|---|
| Primary | Основной цвет бренда | Фон главной кнопки (FAB) |
| OnPrimary | Цвет на основном | Текст внутри главной кнопки |
| Background | Фон экрана | Подложка всего экрана |
| OnBackground | Цвет на фоне | Основной текст статьи |
| Surface | Цвет поверхностей | Карточки, BottomSheet, Меню |
| Error | Цвет ошибки | Красный текст ошибки или иконка |
Давайте перепишем нашу карточку товара из Главы 3, чтобы она поддерживала темную тему автоматически.
@Composable
fun ThemedProductCard(title: String) {
Card(
// Используем цвета из темы для фона карточки (Surface)
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
elevation = CardDefaults.cardElevation(4.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = title,
// Используем стиль заголовка из темы (Typography)
style = MaterialTheme.typography.headlineMedium,
// Цвет текста должен быть "OnSurface", так как фон "Surface".
// Compose обычно подставляет его сам, но можно явно:
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "Описание товара...",
style = MaterialTheme.typography.bodyMedium,
// Для вторичного текста можно использовать прозрачность
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(8.dp))
Button(
onClick = {},
// Кнопка автоматически возьмет цвет Primary
) {
Text(
text = "Купить",
// Текст автоматически возьмет цвет OnPrimary
)
}
}
}
}
Если вы переключите телефон в темный режим, MaterialTheme.colorScheme.surface станет темно-серым, а onSurface — белым. Магия!
Чтобы приложение заговорило на другом языке (например, на английском):
- В
resсоздайте папкуvalues-en(для английского). - Скопируйте туда
strings.xml. - Переведите значения.
values/strings.xml (Русский - дефолтный):
<string name="buy_button">Купить</string>
values-en/strings.xml (Английский):
<string name="buy_button">Buy</string>
В коде ничего менять не нужно. stringResource(R.string.buy_button) сам выберет нужный файл.
В Compose есть встроенный набор иконок Material.
implementation("androidx.compose.material:material-icons-extended:1.5.x")
Icon(
imageVector = Icons.Default.ShoppingCart, // Встроенная векторная иконка
contentDescription = null,
tint = MaterialTheme.colorScheme.primary // Красим иконку в цвет бренда
)
- Никакого хардкода: Тексты в
strings.xml, цвета и размеры черезMaterialTheme. - Семантические цвета: Используйте
backgroundиonBackground, а неWhiteиBlack. - Темная тема: Работает автоматически, если вы соблюдаете пункт 2.
- Типографика: Используйте стили (
headline,body,label) вместо ручного заданияfontSize = 24.spвезде. Это позволит менять шрифт во всем приложении в одном месте (Type.kt).
В Android, как и везде, существует пирамида тестирования.
- Unit Tests (70%): Маленькие, быстрые тесты логики. Запускаются на компьютере (JVM). Не требуют эмулятора.
- Integration Tests (20%): Проверка связки компонентов (например, Room + DAO).
- UI / E2E Tests (10%): Медленные тесты. Эмулятор "нажимает" на кнопки. Проверяют весь путь пользователя.
В build.gradle.kts (Module: app) добавляем библиотеки.
dependencies {
// --- UNIT TESTS (src/test) ---
// JUnit 4 (или 5) - движок для запуска тестов
testImplementation("junit:junit:4.13.2")
// Mockk - библиотека для создания фейковых объектов (наш бро)
testImplementation("io.mockk:mockk:1.13.8")
// Coroutines Test - для управления временем в корутинах
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3")
// --- UI TESTS (src/androidTest) ---
// Правила для тестирования Compose
androidTestImplementation("androidx.compose.ui:ui-test-junit4:1.5.1")
// Чтобы видеть дерево компонентов в логах
debugImplementation("androidx.compose.ui:ui-test-manifest:1.5.1")
}
Самое важное — протестировать бизнес-логику во ViewModel.
Проблема: ViewModel использует viewModelScope, который привязан к Dispatchers.Main (Главный поток Android). В Unit-тестах нет Android, нет Main потока. Тест упадет.
Решение: Нам нужно подменить Main диспетчер на тестовый.
Создадим вспомогательный класс (TestRule), который будет переключать потоки перед тестом.
// MainDispatcherRule.kt (в папке src/test)
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.*
import org.junit.rules.TestWatcher
import org.junit.runner.Description
class MainDispatcherRule(
val testDispatcher: TestDispatcher = UnconfinedTestDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
// Подменяем Main диспетчер на тестовый
Dispatchers.setMain(testDispatcher)
}
override fun finished(description: Description) {
// Возвращаем все как было
Dispatchers.resetMain()
}
}
Допустим, у нас есть UsersViewModel, которая берет данные из UserRepository.
Мы не хотим делать реальные запросы в сеть в тесте. Мы замокаем (mock) репозиторий.
import io.mockk.*
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
class UsersViewModelTest {
// Подключаем наше правило для корутин
@get:Rule
val mainDispatcherRule = MainDispatcherRule()
// Создаем фейк репозитория
private val repository = mockk<UserRepository>()
@Test
fun `when loadUsers success - state updates to Success`() = runTest {
// 1. GIVEN (Дано)
// Учим мок: "Когда вызовут getUsers(), верни список [Alice]"
val fakeUsers = listOf(UserEntity(1, "Alice", null))
coEvery { repository.getUsers() } returns fakeUsers
// Создаем тестируемую ViewModel
val viewModel = UsersViewModel(repository)
// 2. WHEN (Действие)
viewModel.loadUsers()
// 3. THEN (Проверка)
// Проверяем, что в State теперь лежат наши данные
val currentState = viewModel.uiState.value
assertEquals(false, currentState.isLoading)
assertEquals(fakeUsers, currentState.users)
// Проверяем, что метод репозитория действительно вызывался 1 раз
coVerify(exactly = 1) { repository.getUsers() }
}
@Test
fun `when loadUsers fails - state contains error`() = runTest {
// 1. Учим мок выбрасывать ошибку
coEvery { repository.getUsers() } throws RuntimeException("No Internet")
val viewModel = UsersViewModel(repository)
// 2. Действие
viewModel.loadUsers()
// 3. Проверка
val currentState = viewModel.uiState.value
assertEquals("No Internet", currentState.errorMessage)
}
}
UI тесты лежат в папке src/androidTest. Они запускаются на эмуляторе.
В Compose тестирование построено на Semantics Tree (Семантическое дерево). Мы ищем элементы не по ID, а по тексту, описанию или тегу.
Пример: Проверим, что при старте показывается кнопка, и при клике на нее появляется текст.
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.createComposeRule
import org.junit.Rule
import org.junit.Test
class UserScreenTest {
// Правило, которое запускает Compose контент
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun myFirstComposeTest() {
// 1. Запускаем наш Composable экран
composeTestRule.setContent {
// Можно передать фейковую ViewModel или просто UI-компонент
MyAppTheme {
UserListScreen( /* ... */ )
}
}
// 2. Проверяем, что заголовок виден
// onNodeWithText находит элемент с текстом "Users"
composeTestRule.onNodeWithText("Users").assertIsDisplayed()
// 3. Находим кнопку по тексту и кликаем
composeTestRule.onNodeWithText("Refresh").performClick()
// 4. Проверяем, что появилась крутилка загрузки
// Для этого в коде UI нужно добавить modifier.testTag("loading_wheel")
composeTestRule.onNodeWithTag("loading_wheel").assertExists()
}
}
Важно про testTag:
Иногда искать по тексту неудобно (текст меняется при локализации). Лучше использовать testTag.
В коде UI:
CircularProgressIndicator(
modifier = Modifier.testTag("loading_wheel")
)
В тесте:
onNodeWithTag("loading_wheel").assertIsDisplayed()
Уровень Senior — понимать проблемы тестов. Flaky Test (Моргающий тест) — это тест, который то проходит, то падает, хотя код не менялся. Причины:
- Анимации (тест кликнул, пока кнопка выезжала).
- Медленная сеть (в UI тестах лучше использовать фейковые данные, а не реальный интернет).
- Асинхронность (тест проверил результат раньше, чем корутина завершилась).
В Compose тесты синхронизированы с UI. Тест автоматически ждет (idle), пока все анимации и отрисовки закончатся, прежде чем выполнить следующую команду. Это делает их стабильнее, чем старый Espresso.
- Пирамида: Много Unit-тестов, мало UI-тестов.
- Mockk: Используйте
mockk, чтобы изолировать класс. Если тестируете ViewModel, замокайте Repository. - Coroutines: Используйте
runTestи подменяйтеDispatchers.Mainс помощьюTestRule. - Compose Rule: Используйте
onNodeWithText/Tagдля поиска элементов иperformClickдля действий.
По умолчанию Android-проект — это Монолит (один модуль app).
Проблемы монолита:
- Долгая сборка: Изменили одну строчку — Gradle пересобирает всё приложение.
- Спутанность кода: Можно случайно использовать класс из базы данных прямо во View, нарушая архитектуру.
- Конфликты: Разработчикам сложнее работать параллельно над разными фичами.
Решение — Multi-module: Мы разбиваем код на независимые библиотеки (модули).
- Изменили код в модуле "Профиль"? Gradle пересоберет только его и главный модуль
app. Остальные 20 модулей (Чат, Каталог, Настройки) пересобираться не будут. Это экономит часы времени.
Обычно модули делят по слоям или по фичам. Самый популярный подход — смешанный.
:app— Главный модуль. Он "глупый". Он просто знает про все остальные модули и связывает их (Dependency Injection graph).:core(Ядро) — Общие компоненты, которые нужны всем.
:core:network(Retrofit, OkHttp):core:database(Room):core:ui(Theme, общие кнопки, ресурсы):core:utils(Extensions, DateFormatter)
:feature(Фичи) — Экраны приложения. Фичи не должны зависеть друг от друга!
:feature:auth(Экран логина):feature:home(Главная лента):feature:profile(Профиль)
Правило:
:featureзависит от:core.:appзависит от:feature. Если:feature:homeхочет открыть:feature:profile, она делает это через интерфейс навигации (Navigator), который реализован в:app.
До 2022 года версии библиотек дублировались в каждом build.gradle файле. Обновлять их было адом.
Сейчас стандарт — Version Catalogs (libs.versions.toml). Это единый файл, где прописаны все версии.
**Файл: gradle/libs.versions.toml**
[versions]
# Здесь задаем версии один раз
kotlin = "1.9.0"
coreKtx = "1.10.1"
retrofit = "2.9.0"
room = "2.6.0"
[libraries]
# Группируем зависимости
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
[bundles]
# Можно объединять библиотеки в пачки
retrofit = ["retrofit-core", "retrofit-gson"]
Использование в build.gradle.kts (любого модуля):
dependencies {
// Обращаемся через libs
implementation(libs.androidx.core.ktx)
// Подключаем сразу пачку (retrofit + gson)
implementation(libs.bundles.retrofit)
}
В реальной работе у вас всегда есть как минимум два окружения:
- Dev (Develop): Тестовый сервер, логи включены, приложение имеет суффикс
.dev(чтобы можно было поставить рядом с боевым). - Prod (Production): Боевой сервер, логи выключены, R8 обфускация (защита кода).
Это настраивается через buildTypes и productFlavors.
// build.gradle.kts (:app)
android {
// ...
buildTypes {
getByName("release") {
isMinifyEnabled = true // Включить R8 (сжатие и обфускация)
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
}
getByName("debug") {
applicationIdSuffix = ".debug" // com.example.app.debug
}
}
flavorDimensions += "environment"
productFlavors {
create("dev") {
dimension = "environment"
// Переменная доступна в коде через BuildConfig.BASE_URL
buildConfigField("String", "BASE_URL", "\"https://dev-api.example.com/\"")
}
create("prod") {
dimension = "environment"
buildConfigField("String", "BASE_URL", "\"https://api.example.com/\"")
}
}
}
Senior не собирает APK руками на своем ноутбуке, чтобы скинуть его тестировщику в Telegram. Это делает робот.
CI (Integration):
Каждый раз, когда вы делаете git push, облако (GitHub Actions, GitLab CI):
- Запускает
./gradlew lint(проверка стиля кода). - Запускает
./gradlew test(Unit тесты). - Если тесты упали — ваш код не попадет в главную ветку.
CD (Delivery): Когда вы ставите тег версии (v1.0):
- Облако собирает Release Bundle (.aab).
- Подписывает его секретным ключом (который не хранится в репозитории).
- Автоматически загружает в Google Play Console во внутреннее тестирование.
Вы запустили загрузку фото в корутине (viewModelScope.launch). Пользователь смахнул приложение из недавних.
Результат: Процесс убит. Загрузка прервалась. Файл битый.
WorkManager — это библиотека, которая гарантирует, что задача выполнится, даже если приложение убито или устройство перезагрузилось.
- ✅ Отправка логов/аналитики.
- ✅ Резервное копирование базы данных.
- ✅ Синхронизация данных с сервером.
- ✅ Загрузка/Выгрузка тяжелых файлов.
- ⛔️ Не использовать для мгновенных действий (например, оплата в магазине). WorkManager не гарантирует мгновенный запуск, он гарантирует конечный результат.
Рабочий класс описывает саму задачу.
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
// Используем CoroutineWorker, чтобы внутри можно было запускать suspend функции
class UploadWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
// Получаем входные данные
val imageUri = inputData.getString("IMAGE_URI") ?: return Result.failure()
return try {
// Имитация тяжелой работы
uploadImage(imageUri)
// Успех!
Result.success()
} catch (e: Exception) {
// Ошибка. WorkManager попробует перезапустить задачу позже (Backoff Policy)
if (runAttemptCount < 3) {
Result.retry()
} else {
Result.failure()
}
}
}
private suspend fun uploadImage(uri: String) {
// ... код Retrofit или другой логики
}
}
Мы можем задать условия (Constraints). Например: "Запускать только когда есть Wi-Fi и телефон на зарядке".
// Во ViewModel или Repository
fun startUpload(imageUri: String, context: Context) {
// 1. Условия запуска
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED) // Только Wi-Fi
.setRequiresBatteryNotLow(true) // Не сажать батарею
.build()
// 2. Входные данные
val data = workDataOf("IMAGE_URI" to imageUri)
// 3. Создаем запрос (OneTime - одноразовый)
val uploadWorkRequest = OneTimeWorkRequest.Builder(UploadWorker::class.java)
.setConstraints(constraints)
.setInputData(data)
// Если ошибка - повторить через 10 секунд, затем через 20... (Exponential)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, TimeUnit.SECONDS)
.build()
// 4. Ставим в очередь
WorkManager.getInstance(context).enqueue(uploadWorkRequest)
}
Если нужно синхронизировать данные каждые 15 минут (минимальный интервал в Android).
val syncWork = PeriodicWorkRequest.Builder(
SyncWorker::class.java,
15, TimeUnit.MINUTES // Повторять каждые 15 мин
).build()
// enqueueUniquePeriodicWork гарантирует, что не создастся 10 дублей одной задачи
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"MySyncJob",
ExistingPeriodicWorkPolicy.KEEP, // Если уже есть такая задача - не трогать её
syncWork
)
Так как Worker создается системой Android, а не нами, мы не можем просто так написать @Inject в конструкторе.
Нужна специальная аннотация @HiltWorker и настройка в Application классе.
Шаг 1. Аннотация рабочего
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val repository: UserRepository // Теперь можно инжектить!
) : CoroutineWorker(appContext, workerParams) { ... }
Шаг 2. Application Class
@HiltAndroidApp
class MyApplication : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
override fun getWorkManagerConfiguration(): Configuration {
return Configuration.Builder()
.setWorkerFactory(workerFactory)
.build()
}
}
- WorkManager — единственный надежный способ выполнения отложенных задач.
- Constraints позволяют беречь батарею и трафик пользователя.
- Result.retry() автоматически перезапускает упавшие задачи с умной задержкой.
- Для внедрения зависимостей (Hilt) требуется
@HiltWorker.
Раньше (до Android 6.0) мы просто писали разрешения в Manifest, и при установке пользователь соглашался со всем сразу. Сейчас используются Runtime Permissions.
Разрешения делятся на два типа:
- Normal (Обычные): Интернет, Bluetooth, Вибрация. Система дает их автоматически, если они есть в манифесте.
- Dangerous (Опасные): Камера, Геолокация, Контакты, Микрофон, и (с Android 13) Уведомления. Их нужно запрашивать явно во время работы приложения.
В Compose нет метода requestPermissions, как в Activity. Мы используем Activity Result API через rememberLauncherForActivityResult.
Допустим, мы хотим отправить уведомление (Android 13+ требует разрешения POST_NOTIFICATIONS).
// AndroidManifest.xml
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
// NotificationPermissionScreen.kt
@Composable
fun NotificationPermissionScreen() {
val context = LocalContext.current
// 1. Создаем Лаунчер. Это колбэк, который сработает, когда юзер нажмет "Да" или "Нет".
val permissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(),
onResult = { isGranted ->
if (isGranted) {
Toast.makeText(context, "Спасибо!", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Без уведомлений вы пропустите важное :(", Toast.LENGTH_LONG).show()
}
}
)
Column(
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = {
// 2. Проверяем версию Android (на старых версиях разрешение не нужно)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// 3. Проверяем, может оно уже есть?
val hasPermission = ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
if (!hasPermission) {
// Запускаем системный диалог
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}) {
Text("Включить уведомления")
}
}
}
Совет Senior-разработчика: Никогда не запрашивайте права сразу при запуске приложения. Это раздражает. Запрашивайте их контекстно: "Чтобы отсканировать QR-код, нам нужен доступ к камере".
Уведомление — это сложная структура.
Чтобы отправить уведомление, нужно знать три понятия:
- Channel (Канал): Обязателен с Android 8.0. Позволяет пользователю отключать типы уведомлений (например, "Рекламу" отключить, а "Заказы" оставить).
- Builder: Конструктор внешнего вида.
- Manager: Системный сервис для отправки.
Каналы создаются один раз при старте приложения (обычно в Application.onCreate или DI модуле).
fun createNotificationChannel(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val name = "Заказы"
val descriptionText = "Уведомления о статусе доставки"
val importance = NotificationManager.IMPORTANCE_DEFAULT // Со звуком, но без всплывания поверх всего
val channel = NotificationChannel("ORDERS_CHANNEL_ID", name, importance).apply {
description = descriptionText
}
// Регистрация канала в системе
val notificationManager: NotificationManager =
context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
notificationManager.createNotificationChannel(channel)
}
}
Уведомление бесполезно, если по клику ничего не происходит. PendingIntent — это "отложенное намерение". Это токен, который мы отдаем системе, говоря: "Если юзер нажмет сюда, запусти вот эту Activity от моего имени".
Важно: С Android 12 обязательно указывать флаг FLAG_IMMUTABLE или FLAG_MUTABLE. Без этого приложение упадет.
fun showNotification(context: Context) {
// 1. Куда переходить при клике?
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
putExtra("screen_route", "orders") // Передаем данные для навигации
}
val pendingIntent: PendingIntent = PendingIntent.getActivity(
context,
0,
intent,
PendingIntent.FLAG_IMMUTABLE // <--- Критично важно для Android 12+
)
// 2. Строим уведомление
val builder = NotificationCompat.Builder(context, "ORDERS_CHANNEL_ID")
.setSmallIcon(R.drawable.ic_notification) // Маленькая иконка в статус баре (должна быть белой с прозрачностью!)
.setContentTitle("Ваш заказ в пути!")
.setContentText("Курьер будет у вас через 15 минут.")
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent) // Привязываем клик
.setAutoCancel(true) // Убрать уведомление после клика
// 3. Показываем (нужна проверка прав)
with(NotificationManagerCompat.from(context)) {
if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
) {
// ID (101) нужен, чтобы потом можно было обновить или удалить это уведомление
notify(101, builder.build())
}
}
}
- Foreground Service Notification: Несмахиваемое уведомление (Музыкальный плеер, Навигатор).
- Progress Notification: С полоской загрузки (скачивание файла).
- Expandable Notification: Можно развернуть и увидеть картинку или длинный текст.
- Call Style: Уведомление о звонке с кнопками "Принять" и "Сбросить".
То, что мы делали выше — это Локальные уведомления (Local Notifications). Приложение само их создает. Но чаще уведомления приходят с сервера (маркетинг, чаты).
Для этого используется Firebase Cloud Messaging (FCM).
- Приложение получает уникальный FCM Token.
- Отправляет токен на ваш бэкенд.
- Бэкенд шлет JSON на сервера Google.
- Google будит телефон и доставляет пуш.
В коде это обрабатывается через сервис:
class MyFirebaseMessagingService : FirebaseMessagingService() {
// Пришел новый токен (старый протух) - отправь на бэкенд
override fun onNewToken(token: String) {
sendRegistrationToServer(token)
}
// Пришло сообщение, пока приложение открыто
override fun onMessageReceived(remoteMessage: RemoteMessage) {
// Показать уведомление вручную (как в пункте 5)
}
}
- Runtime Permissions: Всегда проверяйте права перед действием. Используйте
rememberLauncherForActivityResultв Compose. - Channels: Без создания канала уведомление не покажется на Android 8+.
- PendingIntent: Используйте
FLAG_IMMUTABLE. - Icons: Иконка уведомления должна быть монохромной (белой на прозрачном фоне), иначе Android отобразит просто белый квадрат.
Экран телефона обновляется обычно 60 раз в секунду (60 Hz).
У вашего кода есть всего 16 миллисекунд, чтобы подготовить кадр. Если вы задумались на 20 мс (например, сортируете большой список в Main Thread), телефон не успеет отрисовать кадр. Пользователь увидит "фриз" (замирание). Это называется Jank.
Как избежать:
- Все тяжелое — в
Dispatchers.IO/Dispatchers.Default. - В
LazyColumnиспользуйтеkey, чтобы не перерисовывать лишнее. - Используйте R8 (см. пункт 4).
Это самая коварная ошибка.
Суть: Вы закрыли экран (Activity), но какой-то другой объект (например, Синглтон или фоновый поток) держит ссылку на этот экран.
Итог: Сборщик мусора (Garbage Collector) не может удалить Activity из памяти. Память забивается. Приложение падает с OutOfMemoryError (OOM).
object SingletonCache {
// ОШИБКА: Храним ссылку на Context или View вечно
var storedContext: Context? = null
}
class MainActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Activity передает ссылку на себя в вечный объект
SingletonCache.storedContext = this
}
// Когда MainActivity закроется, SingletonCache все еще держит её.
// GC не может очистить память.
}
Вам не нужно искать утечки глазами. Есть библиотека от Square, которая делает это автоматически.
Подключение (build.gradle.kts :app):
dependencies {
// debugImplementation означает, что библиотека будет ТОЛЬКО в дебаг-версии.
// В релиз она не попадет (и не будет пугать пользователей).
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
}
Как это работает:
- Вы запускаете приложение.
- Ходите по экранам, открываете/закрываете их.
- Если LeakCanary замечает, что закрытая Activity не удалилась из памяти, он присылает уведомление с иконкой желтой птички.
- Нажимаете на него — видите полный путь (Trace), кто именно держит ссылку.
Встроенный инструмент в Android Studio. (View -> Tool Windows -> Profiler).
Он показывает графики в реальном времени:
- CPU: Насколько загружен процессор. Если график постоянно "в полке", телефон греется и жрет батарею.
- Memory: Сколько RAM занято. Можно нажать "Capture Heap Dump" и посмотреть, какие объекты занимают больше всего места (обычно это Bitmap/Картинки).
- Energy: Расход батареи.
Когда вы собираете Release версию, включается компилятор R8. Он делает три вещи:
- Shrinking (Сжатие): Удаляет неиспользуемые классы и методы. (Если вы подключили библиотеку на 5 МБ, а используете одну функцию, R8 выкинет остальное).
- Obfuscation (Обфускация): Переименовывает классы в
a.b.c, методы вf(). Это уменьшает размер кода и защищает от реверс-инжиниринга (взломщикам сложнее читать код). - Optimization: Оптимизирует инструкции кода.
Включение в build.gradle.kts:
buildTypes {
release {
// Включает R8
isMinifyEnabled = true
isShrinkResources = true // Удаляет неиспользуемые картинки/xml
// Правила ProGuard. Если R8 случайно удалил нужный класс (например, используемый через Reflection),
// нужно прописать исключение в файле proguard-rules.pro
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
Тема для настоящего Senior. Приложения на Android компилируются "на лету" (JIT - Just In Time). При первом запуске система тратит время на анализ кода, поэтому старт медленный.
Baseline Profile — это файл, который говорит Android: "Вот эти методы будут нужны сразу при запуске. Скомпилируй их заранее (AOT - Ahead Of Time)".
Результат: Холодный старт ускоряется на 30-40%.
Как сгенерировать:
- Создать модуль
Benchmark. - Написать тест, который открывает приложение.
- Запустить генератор.
- Файл
baseline-prof.txtпоявится вsrc/main.
- 16 мс — ваш бюджет на кадр. Не блокируйте Main Thread.
- LeakCanary — мастхэв в любом проекте. Подключайте через
debugImplementation. - Bitmap — главные пожиратели памяти. Следите за их размером (используйте Coil/Glide).
- isMinifyEnabled = true — обязательно для релиза.
- Baseline Profiles — современный стандарт оптимизации стартапа.