Как делать11 октября 2026 г.9 мин чтения
Мобильная разработкаШаг 7 из 12

Android на Kotlin и Jetpack Compose: первый экран

Коротко

Экран в Jetpack Compose — это Kotlin-функции с аннотацией @Composable, которые описывают интерфейс по текущему состоянию: изменили значение в mutableStateOf — Compose сам перерисует нужные части. В уроке собираем экран списка услуг: модель на Kotlin, карточку из Row, Column и Box, выбор через remember, список на LazyColumn, оформление Material 3 и ViewModel, чтобы выбор пережил поворот экрана.

Пошаговый урокУровень: НачальныйKotlin и AndroidПрактика: ~1,5–3 ч

После материала вы сможете

  • описать модель на Kotlin с data class, nullable-полем и чистыми функциями и покрыть её локальным тестом
  • собрать карточку из Row, Column, Box и цепочки Modifier и показать её в @Preview
  • хранить выбор в remember { mutableStateOf(…) }, поднимать состояние и выводить список в LazyColumn с ключами
  • перенести состояние во ViewModel со StateFlow, чтобы оно пережило поворот экрана

Проверено на

Kotlin 2.4.21Android Gradle Plugin 9.4.1Gradle 9.6.0Compose BOM 2026.09.00Compose UI 1.12.1Material 3 1.4.0Lifecycle 2.11.0Android SDK 37JDK 21

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Что получится и что понадобится
  2. 02Kotlin за пять минут: модель данных
  3. 03Проект в Android Studio и Gradle
  4. 04@Composable, Modifier и превью: карточка услуги
  5. 05Состояние: remember и mutableStateOf
  6. 06Список и Material 3: LazyColumn, Scaffold, тема
  7. 07ViewModel: состояние, которое переживает поворот
  8. 08Проверка, типичные ошибки и практика

Что получится и что понадобится

Итог урока — экран нативного Android-приложения со списком услуг студии. Каждая услуга — карточка с буквой-аватаром, названием, описанием и необязательной меткой «Популярное». Нажатие на карточку отмечает услугу, а нижняя панель считает выбранное с правильным склонением: «В заявке: 3 услуги». Выбор переживает поворот экрана, потому что хранится во ViewModel.

Интерфейс пишем на Jetpack Compose — декларативном UI-тулките Android: вместо XML-разметки и ручного обновления View вы описываете функциями, как экран выглядит при текущих данных, а Compose сам перерисовывает изменившиеся части. Compose работает только с Kotlin: в таблице поддержки на странице Android «Kotlin-first» у Jetpack Compose напротив Java стоит «No». Поэтому первый раздел — короткий Kotlin-минимум.

Среда разработки

Версия в уроке
Android Studio Rabbit 1 | 2026.2.1
Откуда
актуальная стабильная на developer.android.com/studio

Kotlin

Версия в уроке
2.4.21
Откуда
последний стабильный релиз в Maven Central

Android Gradle Plugin

Версия в уроке
9.4.1
Откуда
Google Maven; AGP 9.4 требует Gradle 9.6.0 и JDK 17+

Compose BOM

Версия в уроке
2026.09.00
Откуда
Compose UI 1.12.1, Material 3 1.4.0

compileSdk / targetSdk

Версия в уроке
37
Откуда
Compose 1.12 требует compileSdk 37 и AGP 9

minSdk

Версия в уроке
24
Откуда
выбор урока; документация Compose требует не ниже 21

Kotlin за пять минут: модель данных

Начнём с файла без единой строки Android — только Kotlin. Так модель и логику можно проверить обычным тестом на компьютере, а экран останется «тонким»: он лишь показывает данные.

app/src/main/java/com/example/services/Services.kt
package com.example.services

data class Service(
    val id: Int,
    val title: String,
    val description: String,
    val badge: String? = null, // не у каждой услуги есть метка — тип допускает null
)

val sampleServices = listOf(
    Service(1, "Сайт компании", "Страницы услуг, форма заявки, SEO-основа", badge = "Популярное"),
    Service(2, "Мобильное приложение", "Нативное приложение для Android"),
    Service(3, "CRM-система", "Клиенты, сделки и задачи в одном месте"),
    Service(4, "Интеграции", "Связка сайта с 1С, оплатой и мессенджерами"),
    Service(5, "Аудит сайта", "Скорость, SEO и доступность", badge = "Новое"),
)

/** Возвращает новое множество: убирает id, если он был, или добавляет. */
fun toggle(selectedIds: Set<Int>, id: Int): Set<Int> =
    if (id in selectedIds) selectedIds - id else selectedIds + id

/** «В заявке: 1 услуга», «… 3 услуги», «… 5 услуг». */
fun selectedLabel(count: Int): String {
    if (count == 0) return "Выберите услуги"
    val word = when {
        count % 10 == 1 && count % 100 != 11 -> "услуга"
        count % 10 in 2..4 && count % 100 !in 12..14 -> "услуги"
        else -> "услуг"
    }
    return "В заявке: $count $word"
}
Модель, тестовые данные и две чистые функции. Ни одного импорта из Android — файл компилируется и тестируется на обычной JVM.

val / var

Что делает
val — ссылку нельзя переназначить, var — можно. По умолчанию пишите val
Где в коде
val title: String, val word = …

data class

Что делает
класс-значение: equals, hashCode, toString и copy() генерируются компилятором
Где в коде
data class Service(…)

String?

Что делает
тип, который допускает null. Без ? присвоить null нельзя — ошибка компиляции
Где в коде
val badge: String? = null

значения по умолчанию и именованные аргументы

Что делает
параметр можно не передавать или назвать по имени
Где в коде
Service(5, …, badge = …)

when

Что делает
ветвление по условиям, возвращает значение
Где в коде
выбор формы слова «услуга»

функция-выражение

Что делает
тело функции — одно выражение после =
Где в коде
fun toggle(…) = if (…) … else …

Обратите внимание на toggle: функция не меняет переданное множество, а возвращает новое (selectedIds - id или selectedIds + id). Множество Set<Int> в Kotlin по умолчанию только для чтения. Это пригодится через пару разделов: Compose замечает изменение состояния, когда в него записывают новое значение, а не когда «тихо» правят старый объект.

Поле badge объявлено как String?, и компилятор не даст обратиться к нему как к обычной строке. В карточке мы используем безопасный вызов service.badge?.let { … }: блок выполнится, только если метка есть. Оператор !! («я уверен, что тут не null») документация Kotlin советует применять, только когда вы действительно уверены, — иначе получите NullPointerException во время работы.

Проект в Android Studio и Gradle

  1. Новый проект

    File → New → New Project → шаблон Empty Activity. Язык — Kotlin (для Compose другого варианта нет), минимальный API — в уроке 24.

  2. Пакет и имя

    Имя Services, пакет com.example.services. Для своего приложения замените com.example на домен компании задом наперёд — это applicationId в магазине.

  3. Синхронизация Gradle

    Studio скачает зависимости. Код приложения лежит в app/src/main/java/…, локальные тесты — в app/src/test/….

  4. Версии в каталоге

    Шаблон хранит версии в gradle/libs.versions.toml. Сверьте их с таблицей выше или поднимите до актуальных.

gradle/libs.versions.toml
[versions]
agp = "9.4.1"
kotlin = "2.4.21"
coreKtx = "1.19.1"
activityCompose = "1.13.0"
lifecycle = "2.11.0"
composeBom = "2026.09.00"
junit = "4.13.2"
robolectric = "4.17"

[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" }

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Каталог версий проверенного проекта. Robolectric нужен только для UI-тестов на JVM — в шаблоне Studio его нет.
app/build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.compose)
}

android {
    namespace = "com.example.services"
    compileSdk = 37

    defaultConfig {
        applicationId = "com.example.services"
        minSdk = 24
        targetSdk = 37
        versionCode = 1
        versionName = "1.0"
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    buildFeatures {
        compose = true
    }
    testOptions {
        unitTests.isIncludeAndroidResources = true
    }
}

dependencies {
    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.activity.compose)
    implementation(libs.androidx.lifecycle.viewmodel.compose)
    implementation(libs.androidx.lifecycle.runtime.compose)

    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.material3)
    implementation(libs.androidx.compose.ui.tooling.preview)
    debugImplementation(libs.androidx.compose.ui.tooling)

    testImplementation(libs.junit)
    // Compose UI-тесты на JVM через Robolectric — только для проверки урока
    testImplementation(platform(libs.androidx.compose.bom))
    testImplementation(libs.androidx.compose.ui.test.junit4)
    testImplementation(libs.robolectric)
    debugImplementation(libs.androidx.compose.ui.test.manifest)
}
Модуль приложения. Плагина org.jetbrains.kotlin.android здесь нет — так и задумано.
  • В AGP 9 Kotlin встроен в Android-плагин и включён по умолчанию, поэтому отдельный плагин org.jetbrains.kotlin.android больше не подключают. Если в старом руководстве он есть — это инструкция для AGP 8.
  • Компилятор Compose подключается плагином org.jetbrains.kotlin.plugin.compose; его версия совпадает с версией Kotlin (доступен с Kotlin 2.0).
  • BOM (Bill of Materials) — «список совместимых версий»: вы указываете одну версию compose-bom, а библиотеки Compose подключаете без номеров. BOM 2026.09.00 даёт Compose UI 1.12.1 и Material 3 1.4.0.
  • buildFeatures { compose = true } включает Compose в модуле. ui-tooling подключён как debugImplementation — инструменты превью не попадут в релизную сборку.

@Composable, Modifier и превью: карточка услуги

Composable-функция — обычная Kotlin-функция с аннотацией @Composable. Она ничего не возвращает: вызывая внутри себя Text, Row или Card, она описывает кусок интерфейса. Вызывать composable-функции можно только из других composable-функций — корень дерева задаёт setContent в Activity. Имена таких функций по соглашению пишут с заглавной буквы, как существительные: ServiceCard, а не showCard.

app/src/main/java/com/example/services/ServiceCard.kt
package com.example.services

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.services.ui.theme.ServicesTheme

@Composable
fun ServiceCard(
    service: Service,
    selected: Boolean,
    onToggle: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Card(
        modifier = modifier.fillMaxWidth(),
        colors = CardDefaults.cardColors(
            containerColor = if (selected) {
                MaterialTheme.colorScheme.primaryContainer
            } else {
                MaterialTheme.colorScheme.surfaceContainerHigh
            },
        ),
    ) {
        Row(
            modifier = Modifier
                .toggleable(value = selected, role = Role.Checkbox, onValueChange = { onToggle() })
                .padding(16.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Box(
                modifier = Modifier
                    .size(40.dp)
                    .clip(CircleShape)
                    .background(MaterialTheme.colorScheme.primary),
                contentAlignment = Alignment.Center,
            ) {
                Text(
                    text = service.title.take(1),
                    color = MaterialTheme.colorScheme.onPrimary,
                    style = MaterialTheme.typography.titleMedium,
                )
            }
            Spacer(Modifier.width(16.dp))
            Column(modifier = Modifier.weight(1f)) {
                Text(service.title, style = MaterialTheme.typography.titleMedium)
                Text(
                    text = service.description,
                    style = MaterialTheme.typography.bodyMedium,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                )
                service.badge?.let { badge ->
                    Text(
                        text = badge,
                        style = MaterialTheme.typography.labelMedium,
                        color = MaterialTheme.colorScheme.primary,
                    )
                }
            }
            Checkbox(checked = selected, onCheckedChange = null)
        }
    }
}

@Preview(showBackground = true)
@Composable
fun ServiceCardPreview() {
    ServicesTheme {
        ServiceCard(service = sampleServices[0], selected = true, onToggle = {})
    }
}
Карточка не хранит состояние: что выбрано и что делать при нажатии, ей сообщают параметры selected и onToggle.

Column

Раскладка
дети сверху вниз
В карточке
название, описание, метка

Row

Раскладка
дети слева направо
В карточке
аватар, текст, чекбокс

Box

Раскладка
дети друг поверх друга, выравнивание через contentAlignment
В карточке
буква по центру круга

Spacer

Раскладка
пустое место заданного размера
В карточке
16 dp между аватаром и текстом

Modifier.weight(1f)

Раскладка
занять оставшееся место в Row
В карточке
текст сжимается, чекбокс остаётся справа

Modifier — цепочка настроек размера, отступов, фона, формы и поведения. Порядок в цепочке важен: модификаторы применяются по очереди. У аватара clip(CircleShape) стоит перед background(…), поэтому фон рисуется уже в круге; поменяйте их местами — получите квадрат. В строке toggleable(…) стоит перед padding(16.dp), поэтому нажимается вся карточка, включая отступы. Принято, чтобы composable принимал параметр modifier: Modifier = Modifier и применял его к корневому элементу — так вызывающий код может задать размеры снаружи.

Функция с @Preview рисует карточку прямо в Android Studio без запуска эмулятора. Превью строит @Composable без параметров (или с параметрами по умолчанию), поэтому для ServiceCard нужна отдельная обёртка ServiceCardPreview с тестовыми данными. Превью рендерится библиотекой Layoutlib вне устройства: без сети, без доступа к файлам и не со всеми API Context.

Состояние: remember и mutableStateOf

Чтобы карточки отмечались, экрану нужна память — какие услуги выбраны. Обычная локальная переменная не подойдёт: composable-функция вызывается заново при каждом обновлении (это называется рекомпозицией), и переменная будет создаваться с нуля. Нужны две вещи: mutableStateOf создаёт наблюдаемое значение — при его изменении Compose перерисует функции, которые его читают, — а remember сохраняет это значение между рекомпозициями.

app/src/main/java/com/example/services/ServicesScreen.kt
package com.example.services

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
fun ServicesScreen() {
    var selectedIds by remember { mutableStateOf(setOf<Int>()) }

    ServicesContent(
        services = sampleServices,
        selectedIds = selectedIds,
        onToggle = { id -> selectedIds = toggle(selectedIds, id) },
    )
}
Экран с состоянием в remember. getValue и setValue импортируются явно — без них не работает делегат by.

ServicesScreen владеет состоянием, а ServicesContent (следующий раздел) и ServiceCard только получают значения и сообщают о событиях через лямбды. Это подъём состояния (state hoisting): состояние заменяется парой «значение + колбэк» — selected: Boolean и onToggle: () -> Unit. Документация называет эту схему однонаправленным потоком данных: состояние идёт вниз, события — вверх. Такие функции без состояния легко переиспользовать, показывать в превью и тестировать.

val state = remember { mutableStateOf(x) }

Когда удобна
нужен сам объект состояния: читать state.value

var value by remember { mutableStateOf(x) }

Когда удобна
самый частый вариант: работаете с переменной как с обычной

val (value, setValue) = remember { mutableStateOf(x) }

Когда удобна
удобно передать сеттер дальше отдельной функцией

Ограничение remember: значение живёт, пока функция находится в композиции, и не переживает смену конфигурации — например, поворот экрана, при котором Activity пересоздаётся. Для этого есть rememberSaveable (упражнение 2) или ViewModel (раздел ниже).

Список и Material 3: LazyColumn, Scaffold, тема

Пять карточек поместились бы и в Column, но Column создаёт и раскладывает все элементы сразу — для длинного или заранее неизвестного списка это проблема производительности. LazyColumn компонует только видимые элементы. В нашей проверке это было видно буквально: UI-тест не находил пятую карточку на маленьком экране Robolectric, пока список не прокрутили к ней.

app/src/main/java/com/example/services/ServicesContent.kt
package com.example.services

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.example.services.ui.theme.ServicesTheme

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ServicesContent(
    services: List<Service>,
    selectedIds: Set<Int>,
    onToggle: (Int) -> Unit,
    modifier: Modifier = Modifier,
) {
    Scaffold(
        modifier = modifier,
        topBar = { TopAppBar(title = { Text("Услуги") }) },
        bottomBar = {
            BottomAppBar {
                Text(selectedLabel(selectedIds.size), modifier = Modifier.padding(horizontal = 16.dp))
            }
        },
    ) { innerPadding ->
        LazyColumn(
            modifier = Modifier.padding(innerPadding),
            contentPadding = PaddingValues(16.dp),
            verticalArrangement = Arrangement.spacedBy(12.dp),
        ) {
            items(services, key = { it.id }) { service ->
                ServiceCard(
                    service = service,
                    selected = service.id in selectedIds,
                    onToggle = { onToggle(service.id) },
                )
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun ServicesContentPreview() {
    ServicesTheme {
        ServicesContent(services = sampleServices, selectedIds = setOf(1, 3), onToggle = {})
    }
}
Экран без состояния: каркас Material 3, верхняя и нижняя панели, ленивый список с ключами.
  • items(services, key = { it.id }) — внутри LazyColumn не обычный composable-блок, а DSL: вы описываете элементы, а список сам решает, какие создать. Импорт androidx.compose.foundation.lazy.items нужен для варианта со списком.
  • key — стабильный уникальный ключ. Без него состояние элемента привязано к позиции, и при вставке или сортировке «переезжает» на соседа. Тип ключа должен сохраняться в Bundle — Int, String, enum подходят.
  • contentPadding добавляет отступы содержимому (первый и последний элементы не прилипнут к краям при прокрутке), Arrangement.spacedBy(12.dp) — промежутки между элементами.
  • Scaffold расставляет панели и передаёт innerPadding — отступы, которые занимают панели. Их обязательно применить к содержимому, иначе первая карточка уедет под TopAppBar.

Цвета, типографику и формы задаёт MaterialTheme. Внутри темы компоненты берут значения через MaterialTheme.colorScheme и MaterialTheme.typography — поэтому в карточке нет ни одного «зашитого» цвета, и тёмная тема работает без доработок. Шаблон Studio создаёт файлы темы в ui/theme; ниже — сокращённая версия с динамическим цветом.

app/src/main/java/com/example/services/ui/theme/Theme.kt
package com.example.services.ui.theme

import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext

private val LightColors = lightColorScheme(primary = Color(0xFF3949AB))
private val DarkColors = darkColorScheme(primary = Color(0xFF9FA8DA))

@Composable
fun ServicesTheme(
    darkTheme: Boolean = isSystemInDarkTheme(),
    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 -> DarkColors
        else -> LightColors
    }
    MaterialTheme(colorScheme = colorScheme, content = content)
}
Динамический цвет (палитра из обоев пользователя) доступен на Android 12+ (API 31, VERSION_CODES.S); на старых версиях — своя светлая или тёмная схема.

ViewModel: состояние, которое переживает поворот

ViewModel — держатель состояния уровня экрана: он хранит данные и бизнес-логику и переживает смену конфигурации. Состояние отдают наружу через StateFlow — поток, у которого всегда есть текущее значение. Изменяемый MutableStateFlow остаётся приватным, наружу смотрит версия только для чтения.

app/src/main/java/com/example/services/ServicesViewModel.kt
package com.example.services

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update

data class ServicesUiState(
    val services: List<Service> = sampleServices,
    val selectedIds: Set<Int> = emptySet(),
)

class ServicesViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(ServicesUiState())
    val uiState: StateFlow<ServicesUiState> = _uiState.asStateFlow()

    fun onToggle(id: Int) {
        _uiState.update { state -> state.copy(selectedIds = toggle(state.selectedIds, id)) }
    }
}
update { … } атомарно заменяет состояние новой копией: copy() от data class меняет одно поле.
app/src/main/java/com/example/services/ServicesRoute.kt
package com.example.services

import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun ServicesRoute(viewModel: ServicesViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    ServicesContent(
        services = uiState.services,
        selectedIds = uiState.selectedIds,
        onToggle = viewModel::onToggle,
    )
}
Связка ViewModel и экрана. viewModel() — из lifecycle-viewmodel-compose, collectAsStateWithLifecycle() — из lifecycle-runtime-compose.
app/src/main/java/com/example/services/MainActivity.kt
package com.example.services

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.example.services.ui.theme.ServicesTheme

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ServicesTheme {
                ServicesRoute()
            }
        }
    }
}
Точка входа: setContent задаёт корень дерева Compose. enableEdgeToEdge() растягивает приложение под системные панели — отступы под них раздаёт Scaffold.
  • ServicesRoute — единственное место, которое знает про ViewModel. Сам ViewModel не передают глубже, в карточки и списки: документация советует держать его на уровне экрана, чтобы нижние компоненты не получали лишних данных и логики.
  • ViewModel не должен хранить Context, Activity или Resources: он живёт дольше экрана, и такая ссылка приводит к утечке памяти.
  • Превью плохо работают с ViewModel — система превью не умеет создать все его зависимости. Поэтому превью показываем у ServicesContent, а не у ServicesRoute: ещё одна причина делать экран без состояния.
  • collectAsStateWithLifecycle() собирает поток только пока экран виден и превращает его в состояние Compose.

Проверка, типичные ошибки и практика

Логику проверяем локальными тестами: они лежат в app/src/test, выполняются на JVM вашего компьютера и не требуют эмулятора. Чистые функции из Services.kt и ViewModel без Android-зависимостей тестируются обычным JUnit.

app/src/test/java/com/example/services/ServicesTest.kt
package com.example.services

import org.junit.Assert.assertEquals
import org.junit.Test

class ServicesTest {
    @Test
    fun toggleAddsAndRemoves() {
        val once = toggle(emptySet(), 3)
        assertEquals(setOf(3), once)
        assertEquals(emptySet<Int>(), toggle(once, 3))
    }

    @Test
    fun labelUsesRussianPlurals() {
        assertEquals("Выберите услуги", selectedLabel(0))
        assertEquals("В заявке: 1 услуга", selectedLabel(1))
        assertEquals("В заявке: 3 услуги", selectedLabel(3))
        assertEquals("В заявке: 5 услуг", selectedLabel(5))
        assertEquals("В заявке: 11 услуг", selectedLabel(11))
        assertEquals("В заявке: 21 услуга", selectedLabel(21))
    }

    @Test
    fun viewModelTogglesSelection() {
        val viewModel = ServicesViewModel()
        viewModel.onToggle(2)
        viewModel.onToggle(5)
        assertEquals(setOf(2, 5), viewModel.uiState.value.selectedIds)
        viewModel.onToggle(2)
        assertEquals(setOf(5), viewModel.uiState.value.selectedIds)
    }
}
app/src/test/java/com/example/services/ServicesScreenTest.kt
package com.example.services

import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.hasScrollAction
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.isToggleable
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollToNode
import com.example.services.ui.theme.ServicesTheme
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

// Проверка урока на JVM (Robolectric), не на устройстве
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class ServicesScreenTest {
    @get:Rule
    val composeRule = createComposeRule()

    @Test
    fun tapOnCardTogglesSelectionAndLabel() {
        composeRule.setContent { ServicesTheme(dynamicColor = false) { ServicesScreen() } }

        composeRule.onNodeWithText("Выберите услуги").assertExists()
        composeRule.onNodeWithText("Сайт компании").performClick()
        composeRule.onNodeWithText("В заявке: 1 услуга").assertExists()

        composeRule.onNodeWithText("Мобильное приложение").performClick()
        composeRule.onNodeWithText("В заявке: 2 услуги").assertExists()

        composeRule.onNodeWithText("Сайт компании").performClick()
        composeRule.onNodeWithText("В заявке: 1 услуга").assertExists()
    }

    @Test
    fun cardIsOneToggleableNode() {
        composeRule.setContent { ServicesTheme(dynamicColor = false) { ServicesScreen() } }

        val card = composeRule.onNode(hasText("Сайт компании") and isToggleable())
        card.assertIsOff()
        card.performClick()
        card.assertIsOn()
    }

    @Test
    fun lastItemIsReachableByScroll() {
        composeRule.setContent { ServicesTheme(dynamicColor = false) { ServicesScreen() } }

        composeRule.onNode(hasScrollAction()).performScrollToNode(hasText("Аудит сайта"))
        composeRule.onNodeWithText("Аудит сайта").performClick()
        composeRule.onNodeWithText("В заявке: 1 услуга").assertExists()
    }
}
UI-тест Compose на JVM через Robolectric: нажатия, состояние «вкл/выкл» и прокрутка ленивого списка. Это проверка логики интерфейса, а не замена проверки на устройстве.
bash
./gradlew assembleDebug testDebugUnitTest
Сборка APK и все локальные тесты. Результат проверки: BUILD SUCCESSFUL, 6 тестов из 6, файл app/build/outputs/apk/debug/app-debug.apk.

«Property delegate must have a 'getValue(…)' method» у by

Причина
нет импортов делегата
Исправление
import androidx.compose.runtime.getValue / setValue

нажатие ничего не меняет на экране

Причина
состояние — mutableListOf или переменная без remember
Исправление
remember { mutableStateOf(…) } и новое неизменяемое значение

выбор сбрасывается при повороте

Причина
remember не переживает пересоздание Activity
Исправление
rememberSaveable или ViewModel

первая карточка под верхней панелью

Причина
не применён innerPadding из Scaffold
Исправление
Modifier.padding(innerPadding)

ошибка «This material API is experimental»

Причина
экспериментальный компонент Material 3 без согласия
Исправление
@OptIn(ExperimentalMaterial3Api::class)

Compose требует compileSdk 37 / AGP 9

Причина
BOM с Compose 1.12 на старом проекте
Исправление
поднять compileSdk и AGP или взять BOM постарше

Следующий шаг — тот же экран на iOS в SwiftUI: сравните, как там устроены состояние и списки. А когда приложение будет готово к релизу, пригодится материал о публикации в Google Play и App Store.

Как проверено4 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
  • запущено, работает

    проект Services (RU): libs.versions.toml, app/build.gradle.kts, Services.kt, ServiceCard.kt, ServicesScreen.kt, ServicesContent.kt, Theme.kt, ServicesViewModel.kt, ServicesRoute.kt, MainActivity.kt, ServicesTest.kt, ServicesScreenTest.kt

    свой Docker-образ на eclipse-temurin:21 + Android cmdline-tools (platforms;android-37.0, build-tools;36.0.0); Gradle Wrapper 9.6.0: `./gradlew assembleDebug testDebugUnitTest` — BUILD SUCCESSFUL, app-debug.apk собран, 6/6 тестов (3 JUnit: toggle, склонения, ViewModel; 3 Compose UI на Robolectric 4.17 @Config(sdk=35): нажатия и счётчик, карточка как один toggleable-узел, прокрутка к 5-й карточке). Фактические версии из dependencies/buildEnvironment: AGP 9.4.1, KGP 2.4.21, kotlin-stdlib 2.4.21, Compose UI 1.12.1, Material 3 1.4.0, lifecycle 2.11.0, activity-compose 1.13.0

  • запущено, работает

    EN-копия проекта (те же файлы с английскими строками, английская функция склонения)

    копия сгенерирована скриптом замены строк, тот же образ: assembleDebug + 6/6 тестов

  • запущено, работает

    решения упражнений (onClear + кнопка «Сбросить»; rememberSaveable; поиск filterServices/onQueryChange/OutlinedTextField) — RU и EN

    проекты ru-ex/en-ex: assembleDebug + 11/11 тестов (5 ExercisesTest: onClear в ViewModel и в UI, восстановление выбора через StateRestorationTester.emulateSavedInstanceStateRestore, filterServices, ввод в поле поиска). Код решений в статье — фрагменты проверенных файлов, вхождение проверяется генератором

  • запущено, работает

    утверждения о сообщениях компилятора (TopAppBar без @OptIn; by без импорта getValue)

    временные копии проекта: без @OptIn(ExperimentalMaterial3Api::class) — `e: This material API is experimental…`; без import getValue — `Property delegate must have a 'getValue(…)' method`

  • не запускалось

    запуск на эмуляторе/устройстве и превью в Android Studio

    эмулятора и Android Studio на этой машине нет; UI проверен только UI-тестами Compose на JVM (Robolectric), что не равно проверке на устройстве — это сказано в статье

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

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

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

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