Экран в Jetpack Compose — это Kotlin-функции с аннотацией @Composable, которые описывают интерфейс по текущему состоянию: изменили значение в mutableStateOf — Compose сам перерисует нужные части. В уроке собираем экран списка услуг: модель на Kotlin, карточку из Row, Column и Box, выбор через remember, список на LazyColumn, оформление Material 3 и ViewModel, чтобы выбор пережил поворот экрана.
После материала вы сможете
- описать модель на 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 г.
Содержание
- Что получится и что понадобится
- Kotlin за пять минут: модель данных
- Проект в Android Studio и Gradle
- @Composable, Modifier и превью: карточка услуги
- Состояние: remember и mutableStateOf
- Список и Material 3: LazyColumn, Scaffold, тема
- ViewModel: состояние, которое переживает поворот
- Проверка, типичные ошибки и практика
Что получится и что понадобится
Итог урока — экран нативного 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. Так модель и логику можно проверить обычным тестом на компьютере, а экран останется «тонким»: он лишь показывает данные.
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"
}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
Новый проект
File → New → New Project → шаблон Empty Activity. Язык — Kotlin (для Compose другого варианта нет), минимальный API — в уроке 24.
Пакет и имя
Имя
Services, пакетcom.example.services. Для своего приложения заменитеcom.exampleна домен компании задом наперёд — этоapplicationIdв магазине.Синхронизация Gradle
Studio скачает зависимости. Код приложения лежит в
app/src/main/java/…, локальные тесты — вapp/src/test/….Версии в каталоге
Шаблон хранит версии в
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" }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.
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 сохраняет это значение между рекомпозициями.
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, пока список не прокрутили к ней.
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 = {})
}
}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; ниже — сокращённая версия с динамическим цветом.
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)
}VERSION_CODES.S); на старых версиях — своя светлая или тёмная схема.ViewModel: состояние, которое переживает поворот
ViewModel — держатель состояния уровня экрана: он хранит данные и бизнес-логику и переживает смену конфигурации. Состояние отдают наружу через StateFlow — поток, у которого всегда есть текущее значение. Изменяемый MutableStateFlow остаётся приватным, наружу смотрит версия только для чтения.
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 меняет одно поле.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.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.
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)
}
}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()
}
}./gradlew assembleDebug testDebugUnitTestBUILD 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.