Перейти к основному содержимому
07МОБИЛКА · ГЛАВА 07Многомодульность и UI Kit

Многомодульность и UI Kit

Прочитано 0%Квизы 0/6Тренажёры 0/5Уровень 1 · Новичок

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

В прошлой главе ProductCard жил в одном-единственном файле внутри модуля :app: сам composable, все цвета, все размеры шрифта, объект Dimens — всё вместе. Пока в проекте одна карточка и один разработчик, это нормально. Но чемпионатное (и любое настоящее) приложение так не остаётся: появляется вторая карточка, экран избранного, экран заказа — и каждому из них нужны те же цвета, те же отступы, тот же шрифт заголовка. Либо копировать object Dimens и хардкод-цвета в каждый файл заново, либо вынести их в одно место, откуда их будет импортировать кто угодно.

Это место — отдельный Gradle-модуль. Модуль (module) в Gradle — не файл и не папка сама по себе, а самостоятельная единица сборки: у неё свой build.gradle.kts, свой набор исходников, свой граф зависимостей, и Gradle компилирует её отдельно от остальных модулей. Разбить один модуль :app на несколько — :app, :ui-kit, :net — даёт четыре практические вещи, важные и на чемпионате, и в любой команде:

  • Границы. Код в :ui-kit физически не может обратиться к деталям экрана из :app — Gradle такую зависимость просто не даёт объявить в обратную сторону. Значит, дизайн-система не обрастает случайными связями с конкретным экраном.
  • Переиспользование. Одна и та же функция форматирования бейджа скидки, один и тот же цвет фона карточки — используются из скольких угодно экранов, но объявлены один раз.
  • Инкрементальная сборка. Gradle помнит, какие модули от каких зависят, и при изменении реализации внутри :ui-kit (не её публичного API) пересобирает только то, что действительно затронуто, — не весь проект целиком.
  • Параллельная работа. На чемпионате в команде из нескольких участников один может дорабатывать экран в :app, а другой — компонент в :ui-kit, и они не наступают друг другу на git-конфликты в одном и том же файле.

Разберём это не только на словах — вживую: соберём настоящий (пусть и упрощённый) multi-module Gradle-проект, увидим настоящий BUILD SUCCESSFUL, настоящие ошибки сборщика и настоящую разницу между .aar и .jar.

Дублирование до модуля, переиспользование после

Прежде чем говорить о Gradle, стоит увидеть сам эффект переиспользования на чистом Kotlin — без единой строчки конфигурации сборки. Представим, что в :app пока живут два экрана, и обоим нужен текст бейджа скидки.

fun discountBadgeTextInProductCard(percent: Int): String = "-${percent}%" fun discountBadgeTextInFavoritesScreen(percent: Int): String = "-${percent}%" object UiKitFormatting { fun discountBadgeText(percent: Int): String = "-${percent}%" } fun main() { println("ДО (ProductCard): ${discountBadgeTextInProductCard(20)}") println("ДО (Favorites): ${discountBadgeTextInFavoritesScreen(20)}") println("ПОСЛЕ (оба экрана): ${UiKitFormatting.discountBadgeText(20)} / ${UiKitFormatting.discountBadgeText(20)}") }

Разбор по строкам

  • Строки 1–2 — та же самая логика ("-${percent}%") продублирована буквально: у экрана карточки и у экрана избранного — свои собственные функции с одинаковым телом. Опечатка или изменение формата в одной из них не затронет вторую — а должна была бы.
  • Строки 4–6: object UiKitFormatting — та же логика, но объявлена один раз, в объекте, который в реальном проекте лежал бы в модуле :ui-kit.
  • Строки 9–11 — печать. Что выведет: ДО (ProductCard): -20%, ДО (Favorites): -20%, ПОСЛЕ (оба экрана): -20% / -20% — результат одинаковый во всех трёх случаях, разница только в том, откуда экраны берут эту логику: из своей копии или из общего модуля.
Отвечено вопросов: 0 из 4
Что в Gradle физически означает "модуль" (module)?
Почему разбиение на :app/:ui-kit/:net помогает параллельной работе команды?
Что демонстрирует демо discountBadgeTextInProductCard / discountBadgeTextInFavoritesScreen ДО объединения в UiKitFormatting?
Что из перечисленного НЕ входит в практическую выгоду от разбиения проекта на модули, разобранную в этом разделе?
Отвечено верно: 0 из 4

Анатомия multi-module проекта

Multi-module Gradle-проект начинается не с кода, а с одного файла в корне проекта — settings.gradle.kts. Именно он говорит Gradle, какие папки вообще считать самостоятельными модулями.

Нажми на часть выражения
Интересный факт

Официальная документация Gradle описывает include(...) ровно так же — как функцию, принимающую пути проектов, «by default a project path corresponds to the relative physical location of the project directory» («путь проекта по умолчанию соответствует относительному физическому расположению папки проекта»). И там же подтверждается вторая важная деталь: у каждого подпроекта — свой собственный build-файл, app/build.gradle.kts, ui-kit/build.gradle.kts, net/build.gradle.kts, а не один общий на всех.

GRADLE · МОДУЛИ:app:ui-kit:netimplementationapi | implэкраны приложениядизайн-системасеть
Три модуля проекта: :app зависит от :ui-kit, :ui-kit — от :net. Каждая стрелка — строка implementation(project(...)) или api(project(...)) в build.gradle.kts.

com.android.application и com.android.library: разница в одно слово

У каждого Android-модуля в build.gradle.kts есть блок plugins { }, и именно id плагина в нём определяет, каким модуль будет — исполняемым приложением или переиспользуемой библиотекой. У модуля :app это com.android.application, у модулей :ui-kit и :netcom.android.library.

Нажми на часть выражения
Нажми на часть выражения
Важно

Различие — буквально одно слово, application вместо library, — но оно меняет весь смысл модуля: в проекте ровно один модуль с com.android.application (точка входа, которую запускает пользователь) и сколько угодно модулей с com.android.library (переиспользуемые части, из которых эта точка входа собрана).

GRADLE · com.android.application ПРОТИВ .librarycom.android.application:app.apk / .aabточка входа — ставится на устройствоcom.android.library:ui-kit:net.aarнельзя запустить — только подключить как зависимостьровно один application-модуль, сколько угодно library-модулей вокруг
Один com.android.application — точка входа приложения; сколько угодно com.android.library вокруг — переиспользуемые части.
Отвечено вопросов: 0 из 4
Что произойдёт, если папка с исходниками лежит в проекте, но её пути нет в include(...) внутри settings.gradle.kts?
Чем отличаются build.gradle.kts у разных модулей одного multi-module проекта?
В чём разница между com.android.application и com.android.library на уровне итогового артефакта?
Сколько модулей с плагином com.android.application обычно бывает в одном Android-проекте?
Отвечено верно: 0 из 4

Прежде чем читать дальше — собери граф зависимостей сам: какие стрелки между :app, :ui-kit и :net дадут рабочую сборку, а какие её сломают.

:app:ui-kit:net
Клик по модулю выбирает его, клик по второму рисует зависимость «первый → второй» (повторный клик по той же паре убирает стрелку). Цель: :app зависит от :ui-kit и от :net.

Что живёт в ui-kit: дизайн-система

В прошлой главе object Dimens, цвета и размеры шрифта были объявлены прямо внутри ProductCard.kt в модуле :app. Если появится вторая карточка или другой экран — тем же числам и цветам либо копироваться заново, либо переезжать в модуль, из которого их сможет импортировать кто угодно. Это и есть работа :ui-kit: не «ещё один модуль ради модуля», а конкретное место для того, что должно повторно использоваться, — цветов, типографики, отступов и самих переиспользуемых @Composable-функций.

Прежде чем смотреть на настоящий Compose-код (он не исполняется в браузере — см. прошлые главы), стоит увидеть сам принцип «разрозненные числа → именованные объекты в одном месте» на исполняемом аналоге, продолжающем рефакторинг Dimens из прошлой главы — только теперь рядом с отступами появляются цвета и размеры шрифта.

object DimensBefore { val CardPadding = 12 val ImageHeight = 120 } fun describeBadgeBefore(): String { val badgeColor = 0xFFE53935 val badgeFontSizeSp = 12 return "бейдж: цвет=${badgeColor.toString(16)}, шрифт=${badgeFontSizeSp}sp, отступ карточки=${DimensBefore.CardPadding}" } object UiKitColors { const val Badge = 0xFFE53935 const val CardBackground = 0xFFFFFFFF } object UiKitTypography { const val BadgeFontSizeSp = 12 const val TitleFontSizeSp = 16 } object UiKitDimens { val CardPadding = 12 val ImageHeight = 120 } fun describeBadgeAfter(): String = "бейдж: цвет=${UiKitColors.Badge.toString(16)}, шрифт=${UiKitTypography.BadgeFontSizeSp}sp, отступ карточки=${UiKitDimens.CardPadding}" fun main() { println("ДО: ${describeBadgeBefore()}") println("ПОСЛЕ: ${describeBadgeAfter()}") }

Разбор по строкам

  • Строки 1–9: «ДО»DimensBefore уже вынесен (это уровень прошлой главы), но цвет и размер шрифта бейджа всё ещё живут как локальные переменные внутри функции — если бейдж понадобится на другом экране, эти две строки придётся написать заново.
  • Строки 11–14: object UiKitColors — цвета, которые в реальном проекте использовались бы из нескольких экранов сразу, теперь существуют один раз, под именем.
  • Строки 16–19: object UiKitTypography — та же идея для размеров шрифта: не «12» без контекста, а UiKitTypography.BadgeFontSizeSp.
  • Строки 21–24: object UiKitDimens — тот же Dimens из прошлой главы, просто теперь рядом с ним есть соседи по той же роли — цвета и типографика, а не он один.
  • Строки 26–27: describeBadgeAfter() — читает значения из трёх объектов вместо локальных переменных.

Что выведет: ДО: бейдж: цвет=ffe53935, шрифт=12sp, отступ карточки=12, затем ПОСЛЕ: бейдж: цвет=ffe53935, шрифт=12sp, отступ карточки=12 — числа одинаковые, как и в прошлой главе с рефакторингом Dimens: смысл не в новом поведении, а в том, что теперь есть ровно одно место, куда идти за каждым из этих трёх значений — и это место можно вынести в отдельный модуль, откуда его подключат сразу несколько экранов.

Теперь — то же самое, но настоящим (нерабочим в браузере) Compose-кодом, ровно тем, что переехал бы в ui-kit/src/main/kotlin/.../Theme.kt и ui-kit/src/main/kotlin/.../ProductCard.kt:

// ui-kit/src/main/kotlin/ru/champs/uikit/Theme.kt — модуль :ui-kit
package ru.champs.uikit

import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

object Colors {
val CardBackground = Color(0xFFFFFFFF)
val ImagePlaceholder = Color(0xFFEEEEEE)
val Badge = Color(0xFFE53935)
}

object Typography {
val TitleFontSize = 16.sp
val PriceFontSize = 18.sp
val BadgeFontSize = 12.sp
}

object Dimens {
val CardPadding = 12.dp
val ImageHeight = 120.dp
val BadgePadding = 4.dp
}
// ui-kit/src/main/kotlin/ru/champs/uikit/ProductCard.kt — модуль :ui-kit
package ru.champs.uikit

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier

@Composable
fun ProductCard(title: String, price: String, discountPercent: Int) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(Colors.CardBackground)
.padding(Dimens.CardPadding)
) {
Box {
Box(
modifier = Modifier
.fillMaxWidth()
.height(Dimens.ImageHeight)
.background(Colors.ImagePlaceholder)
)
Text(
text = "-$discountPercent%",
fontSize = Typography.BadgeFontSize,
modifier = Modifier
.align(Alignment.TopEnd)
.background(Colors.Badge)
.padding(Dimens.BadgePadding)
)
}
Text(text = title, fontSize = Typography.TitleFontSize)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(text = price, fontSize = Typography.PriceFontSize)
Button(onClick = { }) {
Text(text = "В корзину")
}
}
}
}

А в :app от всей прошлой главы остаётся один импорт и один вызов на каждый экран:

// app/src/main/kotlin/ru/champs/app/CatalogScreen.kt — модуль :app
package ru.champs.app

import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.Composable
import ru.champs.uikit.ProductCard

@Composable
fun CatalogScreen() {
Column {
ProductCard(title = "Кроссовки", price = "4990 ₽", discountPercent = 20)
ProductCard(title = "Рюкзак", price = "2490 ₽", discountPercent = 10)
}
}

Ни одного 0xFF..., ни одного голого .dp/.sp в :app не осталось — CatalogScreen вообще не знает, какого цвета фон карточки или какой у неё отступ, он просто просит ui-kit нарисовать карточку с конкретными данными о товаре. Это и есть тот самый эффект «границы» из первого раздела: детали дизайн-системы физически не видны экрану, который её использует, — только то, что ui-kit явно сделал публичным (сам ProductCard).

Отвечено вопросов: 0 из 4
Что именно переезжает из :app в :ui-kit по итогам этого раздела?
Что осталось в CatalogScreen (:app) после переноса ProductCard в :ui-kit?
Почему рефакторинг describeBadgeBefore/After печатает ОДИНАКОВЫЕ числа до и после выноса в отдельные object?
Почему в примере с :ui-kit цвета объявлены как val Colors = Color(0xFF...), а не как const val, аналогично тому, что уже разбиралось для Dimens в прошлой главе?
Отвечено верно: 0 из 4

Теперь проверь то же самое с другой стороны: разложи сущности проекта по модулям, в которых им место.

:appэкраны и точка входа
:ui-kitдизайн-система
:netсетевой слой
Кликни по карточке, затем по колонке-модулю. Карточку из колонки можно вернуть кликом.

implementation(project(":ui-kit")) и разница api vs implementation

Чтобы :app мог вызывать ProductCard() из :ui-kit, одного include(":ui-kit") в settings.gradle.kts недостаточно — include только говорит Gradle «такой модуль существует», но не «этот модуль нужен вот этому». Связь между конкретными модулями объявляется в dependencies { } внутри build.gradle.kts того модуля, которому эта зависимость нужна.

Нажми на часть выражения

Но у зависимости между модулями, в отличие от include, есть выбор: implementation или api. Разница касается не самого :ui-kit, а того, что видно модулям, которые зависят от :ui-kit — прежде всего :app. Официальная документация Gradle формулирует это предельно чётко: зависимости implementation «will not be exposed to consumers, and therefore not leak into the consumers' compile classpath» («не будут раскрыты потребителям и, соответственно, не просочатся в classpath компиляции потребителей»), тогда как зависимости api «will be transitively exposed to consumers of the library, and as such will appear on the compile classpath of consumers» («будут транзитивно раскрыты потребителям библиотеки и, соответственно, появятся в classpath компиляции потребителей»).

Нажми на часть выражения
GRADLE · api ПРОТИВ implementation — ЧТО ВИДНО ДАЛЬШЕui-kit: api(project(":net")):net:ui-kit:appвидит ApiResponseui-kit: implementation(project(":net")):net:ui-kit:appApiResponse не виден
api пробрасывает тип дальше по цепочке модулей, implementation останавливает его на границе — :app видит ApiResponse только через api.

Это не абстрактное правило — проверим его вживую на настоящем трёхмодульном проекте (:app:ui-kit:net, ровно та структура, что и в settings.gradle.kts из первого раздела), собранном и запущенном в этой же сессии на Gradle 9.5.1 и Kotlin 2.4.0. Модельный проект использует не сам Android Gradle Plugin (ему нужен Android SDK, а нас интересует именно механизм Gradle-зависимостей), а его прямые JVM-аналоги: application вместо com.android.application у :app и java-library вместо com.android.library у :ui-kit/:net — та же роль (исполняемый модуль против переиспользуемой библиотеки), только без Android-специфики.

:net объявляет один класс:

// net/src/main/kotlin/ApiResponse.kt — модуль :net
package ru.champs.net

data class ApiResponse(val status: Int, val body: String)

:ui-kit подключает :net через api:

// ui-kit/build.gradle.kts — модуль :ui-kit
plugins {
kotlin("jvm")
`java-library`
}

dependencies {
api(project(":net"))
}
// ui-kit/src/main/kotlin/ProductCardStyle.kt — модуль :ui-kit
package ru.champs.uikit

import ru.champs.net.ApiResponse

object Dimens {
const val CardPadding = 12
}

fun describeResponse(response: ApiResponse): String =
"статус=${response.status}, отступ карточки=${Dimens.CardPadding}"

А :app зависит только от :ui-kit — про :net в app/build.gradle.kts не сказано ни слова:

// app/build.gradle.kts — модуль :app
dependencies {
implementation(project(":ui-kit"))
}
// app/src/main/kotlin/Main.kt — модуль :app
package ru.champs.app

import ru.champs.net.ApiResponse
import ru.champs.uikit.describeResponse

fun main() {
val response = ApiResponse(status = 200, body = "ok")
println(describeResponse(response))
}
Важно

Обрати внимание: Main.kt в :app импортирует ru.champs.net.ApiResponse напрямую, хотя :app зависит только от :ui-kit, а не от :net. Это компилируется и работает только потому, что :ui-kit подключил :net через api, а не implementation — зависимость «просочилась» на classpath :app транзитивно, ровно как и написано в документации Gradle.

$ gradle build --console=plain
> Task :net:compileKotlin
> Task :net:jar
> Task :ui-kit:compileKotlin
> Task :ui-kit:jar
> Task :app:compileKotlin
> Task :app:jar
> Task :app:assemble
...
BUILD SUCCESSFUL in 29s
9 actionable tasks: 9 executed

$ gradle run --console=plain -q
статус=200, отступ карточки=12

Оба вывода сняты вживую в этой сессии на настоящем трёхмодульном проекте. Теперь смоделируем ту же самую идею — «зависимость либо просвечивает наружу, либо остаётся спрятанной» — уже на чистом Kotlin, одним файлом, без Gradle вообще. ApiStyleRepository возвращает наружу тип чужого «модуля» напрямую (аналог api), а ImplementationStyleRepository прячет этот тип за private-деталью и наружу отдаёт только то, что сама явно решила показать (аналог implementation).

class NetResponse(val code: Int) class ApiStyleRepository { fun fetch(): NetResponse = NetResponse(200) } class ImplementationStyleRepository { private fun fetchInternal(): NetResponse = NetResponse(200) fun fetch(): String = "код ${fetchInternal().code}" } fun main() { val apiStyle = ApiStyleRepository() val response: NetResponse = apiStyle.fetch() println("api: получили NetResponse с кодом ${response.code}") val implStyle = ImplementationStyleRepository() println("implementation: ${implStyle.fetch()}") }

Разбор по строкам

  • Строка 1: class NetResponse(val code: Int) — играет роль публичного типа из «модуля» :net.
  • Строки 3–5: ApiStyleRepository — как и :ui-kit с api(project(":net")), эта функция возвращает NetResponse напрямую: вызывающий код получает доступ к этому типу, даже не подключая :net сам.
  • Строки 7–9: ImplementationStyleRepositoryfetchInternal() помечена private: она играет роль зависимости, подключённой через implementation — существует внутри класса, но снаружи недоступна вообще. Наружу класс отдаёт fetch(): String — уже готовый результат, без самого типа NetResponse в сигнатуре.
  • Строки 12–14 — вызывающий код получает NetResponse и обращается к его полю code напрямую — ровно то, что :app сделал с ApiResponse из :net в примере выше.
  • Строки 16–17 — вызывающий код никогда не видит NetResponse — только строку, которую ImplementationStyleRepository сама решила отдать.

Что выведет: api: получили NetResponse с кодом 200, затем implementation: код 200 — проверено live-запуском через API Kotlin Playground. Результат тот же самой формы, что и у живого Gradle-проекта: «api»-стиль раскрывает тип наружу, «implementation»-стиль — нет.

А что случится, если код снаружи всё же попробует дотянуться до fetchInternal() напрямую — то есть попробует получить у «implementation»-зависимости то же самое, что api-зависимость отдаёт бесплатно?

class NetResponse(val code: Int) class ImplementationStyleRepository { private fun fetchInternal(): NetResponse = NetResponse(200) fun fetch(): String = "код ${fetchInternal().code}" } fun main() { val implStyle = ImplementationStyleRepository() val response: NetResponse = implStyle.fetchInternal() println(response.code) }
Cannot access 'fun fetchInternal(): NetResponse': it is private in 'ImplementationStyleRepository'.

Перевод: «нет доступа к функции fetchInternal(): NetResponse — она приватная в ImplementationStyleRepository». Это точная механика Kotlin-модификатора private (подтверждена официальной документацией Kotlin: «private means that the member is visible inside this class only» — «private означает, что член виден только внутри этого класса»), а не Gradle — но результат по духу тот же самый, что и у настоящей implementation-зависимости: то, что не решили сделать публичным, снаружи не достать. Разница в механизме важна: private — это языковый барьер уровня Kotlin (член существует, но доступ к нему запрещён компилятором); implementation — это барьер уровня Gradle (модуль :net в случае :app физически отсутствует на classpath компиляции, поэтому импорт ru.champs.net.ApiResponse в :app вообще не найдёт, что импортировать). К этой, второй, разновидности — и к её настоящему сообщению об ошибке — вернёмся в разделе «Типичные ошибки».

А пока пощёлкай переключатель сам: та же цепочка модулей, что и в живом проекте выше (третий модуль здесь назван обобщённо :lib), и одно слово в ui-kit/build.gradle.kts, решающее судьбу импорта в :app.

:app
implementation
:ui-kit
:lib
Внутри :app (Main.kt)
import ru.champs.lib.LibClass
LibClass — виден из :app: зависимость просочилась транзитивно через api
BUILD SUCCESSFUL — :lib есть на classpath компиляции :app
Переключи зависимость ui-kit → lib на implementation и посмотри, что случится с импортом в :app.
Отвечено вопросов: 0 из 4
Что означает implementation(project(":ui-kit")) в app/build.gradle.kts?
Почему Main.kt в :app смог написать import ru.champs.net.ApiResponse, хотя app/build.gradle.kts зависит только от :ui-kit, а не от :net?
В аналоге на чистом Kotlin — чем принципиально отличается ошибка "Cannot access ... it is private" от реальной Gradle-ошибки "Unresolved reference", которая возникла бы у :app при попытке использовать implementation-зависимость :net напрямую?
Если бы ui-kit/build.gradle.kts подключал :net через implementation, а не через api, что случилось бы со строкой import ru.champs.net.ApiResponse в Main.kt модуля :app?
Отвечено верно: 0 из 4

.aar: чем архив библиотеки отличается от .jar

Когда собирается обычный JVM-модуль (java-library, как в нашей модели), результат — .jar (Java ARchive): zip-архив со скомпилированными .class-файлами и ничем больше. В нашей же живой сборке :ui-kit и :net — именно java-library-модули, и вот их настоящие, полученные в этой сессии артефакты:

$ find . -name "*.jar" -path "*/build/*"
./app/build/libs/app.jar
./net/build/libs/net.jar
./ui-kit/build/libs/ui-kit.jar

Реальный Android-модуль с com.android.library, в отличие от JVM-модуля с java-library, собирает не .jar, а .aar (Android ARchive) — и разница не только в расширении файла. Официальная документация Android прямо перечисляет, чего не хватает .jar, но есть в .aar: «While a JAR file is useful for many projects — especially when you want to share code with other platforms — it doesn't let you include Android resources or manifest files» («JAR-файл полезен для многих проектов — особенно когда нужно поделиться кодом с другими платформами, — но он не позволяет включить Android-ресурсы или manifest-файлы»). .aar — тот же zip-архив, что и .jar, но внутри у него, помимо скомпилированного кода (classes.jar), лежат вещи, которых у .jar просто не может быть:

Внутри .aarЗачем
/AndroidManifest.xml (обязателен)манифест библиотеки — например, разрешения, которые она объявляет
/classes.jarскомпилированный код — то немногое общее, что есть и у .jar
/res/ресурсы: разметка экранов, drawable, строки — то, ради чего чаще всего и заводят :ui-kit как раз
/R.txt, /public.txtописания идентификаторов ресурсов и того, что из них публично для потребителей
/jni/<abi>/*.soнативные библиотеки на C/C++, если они есть

Именно этим по факту и полезен com.android.library для настоящего :ui-kit: он умеет упаковать не только Kotlin-код дизайн-системы, но и её ресурсы — иконки, strings.xml, если где-то в проекте есть XML-разметка рядом с Compose. java-library в нашей модели такого не умеет вообще: у него просто нет понятия «Android-ресурс».

GRADLE · .aar ПРОТИВ .jar.jarclasses.jarтолько скомпилированный код.aarclasses.jarres/Manifest.xmlR.txt · jni/*.soкод плюс всё, что не код+ ресурсыcom.android.library собирает .aar; java-library — только .jar
`.jar` — только скомпилированный код. `.aar` — тот же код плюс манифест, ресурсы и нативные библиотеки: всё, что нужно настоящей Android-библиотеке.

Команда сборки для реального .aar — тот же ./gradlew, что и во всех предыдущих главах трека, только с другой задачей:

Нажми на часть выражения

Итоговый файл ложится по пути ui-kit/build/outputs/aar/ui-kit-release.aar — та же самая логика <модуль>/build/outputs/…, что подтверждена и для APK: официальная документация формулирует это как «All APKs you build are saved in project_name/module_name/build/outputs/apk/» («Все собранные APK сохраняются в project_name/module_name/build/outputs/apk/»), просто для библиотечного модуля вместо outputs/apk/outputs/aar/. Ровно та же структура «результат сборки модуля лежит в его собственной папке build/» видна и в нашей живой JVM-модели — только там она называется build/libs/, а не build/outputs/aar/, потому что java-library — другой плагин с другим соглашением об именах папок, а не потому, что принцип другой.

Отвечено вопросов: 0 из 4
Что может содержать .aar, чего не может содержать .jar?
Почему для модуля :ui-kit в настоящем Android-проекте выбирают com.android.library (результат — .aar), а не просто java-library (результат — .jar), как в модели этой главы?
По какому пути в живой JVM-модели этой главы реально лежит собранный артефакт модуля :net?
Какая задача (Gradle task) собирает релизный .aar для модуля :ui-kit?
Отвечено верно: 0 из 4

Типичные ошибки

Все четыре ошибки этой главы — настоящие, снятые с живого трёхмодульного Gradle-проекта (:app / :ui-kit / :net, Gradle 9.5.1, Kotlin-плагин 2.4.0) в этой же сессии, а не пересказанные по памяти.

1. project ':ui-kit' not found — модуль не включён в settings.gradle.kts

Если в app/build.gradle.kts написано implementation(project(":ui-kit")), а в settings.gradle.kts строка include(":ui-kit") отсутствует (например, include(":app", ":net") — без ui-kit):

FAILURE: Build failed with an exception.

* Where:
Build file 'app/build.gradle.kts' line: 11

* What went wrong:
Project with path ':ui-kit' could not be found.

Перевод: «Проект с путём ':ui-kit' не найден». project(":ui-kit") ссылается на модуль по пути, но Gradle узнаёт о существовании модулей только из include(...) в settings.gradle.kts — не из наличия папки на диске. Папка ui-kit/ может физически лежать рядом с app/ и net/, содержать код и свой build.gradle.kts, и всё равно быть «невидимой» для Gradle, если её пути нет в include(...).

Как починить: добавить путь в settings.gradle.kts: include(":app", ":ui-kit", ":net"). Порядок значения не имеет, важно только, чтобы каждый путь, который используется в project(...), был перечислен.

2. Циклическая зависимость

Если :ui-kit зависит от :net (api(project(":net"))), а кто-то в net/build.gradle.kts по ошибке добавит api(project(":ui-kit")) — модуль, который должен быть «низкоуровневым» и ни от чего не зависеть, вдруг зависит от того, кто зависит от него самого:

FAILURE: Build failed with an exception.

* What went wrong:
Circular dependency between the following tasks:
:net:compileJava
+--- :net:compileJava (*)
+--- :net:compileKotlin
| +--- :net:compileJava (*)
| +--- :net:compileKotlin (*)
| +--- :ui-kit:compileJava
| | +--- :net:compileJava (*)
| | +--- :net:compileKotlin (*)
| | +--- :ui-kit:compileJava (*)
| | \--- :ui-kit:compileKotlin
| | (...)

Перевод: «Циклическая зависимость между следующими задачами». Чтобы собрать :net, Gradle сначала должен собрать :ui-kit (потому что :net теперь зависит от него) — но чтобы собрать :ui-kit, нужно сначала собрать :net (потому что зависимость :ui-kit → :net никуда не делась). Замкнутый круг без начала: Gradle не может решить, с чего начинать, и честно отказывается пытаться, вместо того чтобы зависнуть навсегда.

Как починить: убрать зависимость в одном из двух направлений — решить, кто на самом деле низкоуровневый модуль, а кто высокоуровневый, и оставить зависимость только в одну сторону (:ui-kit → :net, но никогда не наоборот). Если обоим модулям действительно нужен общий код — его выносят в третий, ещё более низкоуровневый модуль, от которого зависят уже оба.

3. Неверный id плагина в библиотечном модуле

Если в ui-kit/build.gradle.kts написать id("com.android.library") в проекте, где сам Android Gradle Plugin нигде не подключён и не провижен версией (как в нашей чистой Kotlin/JVM-модели — там просто нет AGP):

FAILURE: Build failed with an exception.

* Where:
Build file 'ui-kit/build.gradle.kts' line: 1

* What went wrong:
Plugin [id: 'com.android.library'] was not found in any of the following sources:

- Gradle Core Plugins (plugin is not in 'org.gradle' namespace)
- Included Builds (No included builds contain this plugin)
- Plugin Repositories (plugin dependency must include a version number for this source)

Перевод: «Плагин с id 'com.android.library' не найден ни в одном из следующих источников» — дальше перечислены три места, где Gradle его искал, и почему не нашёл ни в одном. В настоящем Android-проекте та же ошибка появляется при опечатке в id плагина (com.android.libary вместо com.android.library) или когда версия Android Gradle Plugin не объявлена в корневом build.gradle.kts/settings.gradle.kts — Gradle не может подключить плагин, о версии которого нигде не сказано.

Как починить: проверить точное написание id (com.android.library, без опечаток) и убедиться, что версия AGP объявлена один раз в корневом файле проекта — там же, где в этой главе kotlin("jvm") version "2.4.0" apply false объявляет версию Kotlin-плагина для всех модулей сразу.

4. Unresolved reference — implementation не пробрасывает зависимость дальше

Если ui-kit/build.gradle.kts подключает :net через implementation(project(":net")), а не через api(...), — та же самая строка import ru.champs.net.ApiResponse в Main.kt модуля :app, которая работала в разделе про api/implementation, перестаёт компилироваться:

> Task :app:compileKotlin FAILED
e: Main.kt:3:18 Unresolved reference 'net'.
e: Main.kt:7:20 Unresolved reference 'ApiResponse'.
e: Main.kt:8:13 Cannot access class 'ru.champs.net.ApiResponse'. Check your module classpath for missing or conflicting dependencies.

Перевод: «Неразрешённая ссылка 'net'» / «Неразрешённая ссылка 'ApiResponse'» / «Нет доступа к классу 'ru.champs.net.ApiResponse'. Проверьте module classpath на предмет отсутствующих или конфликтующих зависимостей». Как только :ui-kit перестаёт объявлять :net через api, :net пропадает с classpath компиляции у :app полностью — компилятор :app не может даже разобрать import ru.champs.net..., потому что пакета ru.champs.net с его точки зрения попросту не существует.

Как починить: вернуть :ui-kit на api(project(":net")), если тип ApiResponse действительно должен быть виден потребителям :ui-kit, — либо, если наружу он торчать не должен, оставить implementation, но убрать прямое использование ApiResponse из :app и работать только с тем, что :ui-kit явно решил показать (ровно так, как ImplementationStyleRepository.fetch() в разделе про api/implementation отдавал String, а не сам NetResponse).

Отвечено вопросов: 0 из 4
Модуль :payments есть на диске, у него есть свой build.gradle.kts и код, но при сборке Gradle пишет "Project with path ':payments' could not be found." Что нужно проверить в первую очередь?
Чем циклическая зависимость между :net и :ui-kit отличается от простой ошибки компиляции вроде опечатки в имени?
Сообщение "Plugin [id: 'com.android.library'] was not found in any of the following sources" — какая из типичных причин НЕ подходит под это сообщение?
После смены api(project(":net")) на implementation(project(":net")) в ui-kit/build.gradle.kts компиляция :app падает на строке import ru.champs.net.ApiResponse. Что именно при этом происходит на уровне classpath, а не просто "где-то ошибка"?
Отвечено верно: 0 из 4

Слова главы

module
1 / 8

Потренируйся печатать

Строка подключения модуля в settings.gradle.kts — с неё начинается любой multi-module проект:

Цель: скорость ≥ 100 зн/мин, точность ≥ 90%

include(":app", ":ui-kit", ":net")
клавиша ``клавиша 11клавиша 22клавиша 33клавиша 44клавиша 55клавиша 66клавиша 77клавиша 88клавиша 99клавиша 00клавиша --клавиша ==клавиша ⌫клавиша TabTabклавиша QQклавиша WWклавиша EEклавиша RRклавиша TTклавиша YYклавиша UUклавиша IIклавиша OOклавиша PPклавиша [[клавиша ]]клавиша \\клавиша Caps LockCaps Lockклавиша AAклавиша SSклавиша DDклавиша FFклавиша GGклавиша HHклавиша JJклавиша KKклавиша LLклавиша ;;клавиша ''клавиша EnterEnterклавиша ShiftShiftклавиша ZZклавиша XXклавиша CCклавиша VVклавиша BBклавиша NNклавиша MMклавиша ,,клавиша ..клавиша //клавиша ShiftShiftклавиша CtrlCtrlклавиша WinWinклавиша AltAltклавиша ПробелПробелклавиша AltAltклавиша WinWinклавиша MenuMenuклавиша CtrlCtrl
следующая: I — средний правой руки

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

Цель: скорость ≥ 110 зн/мин, точность ≥ 90%

implementation(project(":ui-kit"))

Символы и приёмы главы

Символ / приёмКак называетсяЧто делаетПример
include(...)объявление модулейговорит Gradle, какие папки — самостоятельные модули проектаinclude(":app", ":ui-kit")
id("...")подключение плагинауказывает Gradle, какой плагин применить к модулю, по строковому идентификаторуid("com.android.library")
project(":x")ссылка на модуль проектауказывает на другой модуль ВНУТРИ этого же multi-module проекта, а не на внешнюю библиотекуimplementation(project(":ui-kit"))
implementation(...)конфигурация зависимостиподключает зависимость к текущему модулю, не пробрасывая её потребителямimplementation(project(":net"))
api(...)конфигурация зависимостиподключает зависимость и транзитивно раскрывает её потребителям модуляapi(project(":net"))
:moduleпуть модулядвоеточие перед именем — обозначение конкретного модуля в командах и зависимостях./gradlew :ui-kit:assembleRelease
.aarAndroid-архивартефакт библиотечного Android-модуля: код + ресурсы + манифестui-kit-release.aar
.jarJava-архивартефакт обычного JVM-модуля: только скомпилированный код, без ресурсовnet.jar
Углубиться

Почему циклические зависимости запрещены не только «по вкусу», а технически. Gradle строит граф задач перед тем, как начать сборку, и должен заранее определить порядок их выполнения — какие задачи можно запускать сразу, какие только после других. Цикл в этом графе делает такой порядок математически невозможным: у задачи A нет способа одновременно быть «раньше» и «позже» задачи B. Именно поэтому ошибка про циклическую зависимость — не предупреждение, а отказ от сборки: продолжать попросту не с чего начать. В многомодульных Android-проектах эта же проблема на уровне архитектуры (не только Gradle-задач) подробно разобрана в статье «Принципы построения многомодульных Android-приложений» — там же показано типичное решение: выносить общий код в третий, более низкоуровневый модуль, от которого зависят обе стороны конфликта, вместо зависимости друг от друга напрямую.

Что нужно настоящей библиотеке сверх самого кода. В этой главе :ui-kit собирается и им пользуется тот же самый проект. Но если библиотеку готовят к публикации отдельно от проекта — например, чтобы несколькими приложениями пользоваться одним и тем же :ui-kit из разных репозиториев, — появляются дополнительные требования: namespace, версия, публикация через maven-publish в Maven-репозиторий, за которой уже приходят потребители. Официальная документация Android описывает этот процесс в разделе «Prepare your library for release».

Куда дальше: проверенные ресурсы

Русские материалы

Официальная документация (на английском)

  • Create an Android library — первоисточник про .aar: чем отличается от .jar, что внутри архива, зачем вообще нужен библиотечный модуль.

  • The Java Library Plugin — официальное определение api и implementation, источник цитат из этой главы.

  • Structuring and building a software project — официальное описание settings.gradle.kts, include(...) и того, что у каждого подпроекта свой build-файл.

  • Build your app from the command line — команда ./gradlew assembleDebug и путь, куда Gradle кладёт собранные артефакты.

  • Visibility modifiers | Kotlin — официальное описание private и остальных модификаторов видимости, задействованных в аналоге api/implementation на чистом Kotlin.

  • Видео и материалы сообщества — ниже — ролики по теме и ссылки от студентов и преподавателей смотри в самом низу страницы.

Челлендж ⭐

ApiStyleRepository и ImplementationStyleRepository из раздела про api/implementation уже готовы.

Ступень 1. В проекте появляется третий класс — AnalyticsStyleRepository, который тоже должен вести себя как api-зависимость: отдавать NetResponse наружу напрямую, а не прятать его за строкой. Допиши тело класса одной строкой по образцу ApiStyleRepository.

class NetResponse(val code: Int) class ApiStyleRepository { fun fetch(): NetResponse = NetResponse(200) } class AnalyticsStyleRepository { // ступень 1: допиши так, чтобы NetResponse "просвечивал" наружу — как в api-стиле } fun main() { val apiStyle = ApiStyleRepository() println("api: код ${apiStyle.fetch().code}") // ступень 1: раскомментируй и дополни, когда допишешь AnalyticsStyleRepository // val analytics = AnalyticsStyleRepository() // val response: NetResponse = analytics.fetch() // println("analytics: код ${response.code}") }

Ступень 2 ⭐. Без запуска кода объясни своими словами: если в ui-kit/build.gradle.kts зависимость на :net сменить с api(project(":net")) на implementation(project(":net")), какая именно строка в app/src/main/kotlin/Main.kt (из раздела про api/implementation, там, где Main.kt печатает describeResponse(response)) перестанет компилироваться первой и почему — с точностью до конкретного сообщения компилятора из раздела «Типичные ошибки».

Решение (сначала попробуй сам)

Ступень 1:

class AnalyticsStyleRepository {
fun fetch(): NetResponse = NetResponse(404)
}

С этим дополнением и раскомментированными строками в main() программа печатает api: код 200, затем analytics: код 404 — оба класса одинаково отдают NetResponse напрямую, как и положено api-стилю; конкретное значение code — просто разные подставленные числа, сама механика видимости у обоих одна.

Ступень 2 ⭐:

Первой перестанет компилироваться строка import ru.champs.net.ApiResponse в Main.kt — та же самая ошибка, что разобрана в пункте 4 раздела «Типичные ошибки»:

e: Main.kt:3:18 Unresolved reference 'net'.

Причина: Main.kt импортирует ApiResponse из :net напрямую, хотя app/build.gradle.kts зависит только от :ui-kit, а не от :net. Это работало исключительно потому, что :ui-kit подключал :net через api(...) — зависимость транзитивно просачивалась в classpath :app. Как только :ui-kit переключается на implementation(...), :net пропадает с classpath :app полностью, и компилятор :app не может разрешить сам пакет ru.champs.net — падает уже импорт, до того, как компилятор вообще дойдёт до строк, где ApiResponse используется.

Что должен уметь

  • Объяснять, зачем проект дробят на модули: границы, переиспользование, инкрементальная сборка, параллельная работа команды.
  • Читать settings.gradle.kts и понимать, что модуль существует для Gradle только после include(...), а не по факту наличия папки на диске.
  • Различать com.android.application и com.android.library по итоговому артефакту и по тому, можно ли модуль запустить напрямую.
  • Объяснять разницу между implementation(project(...)) и api(project(...)) и предсказывать, какие импорты будут доступны потребителям модуля в каждом случае.
  • Объяснять, что живёт в :ui-kit (цвета, типографика, Dimens, переиспользуемые @Composable-функции) и почему это выносится из :app отдельным модулем, а не остаётся файлом внутри экрана.
  • Формулировать, чем .aar отличается от .jar, что внутри .aar и какой командой (./gradlew :module:assembleRelease) и куда (module/build/outputs/aar/) он собирается.
  • Узнавать по сообщению сборщика четыре типичные ошибки этой главы — модуль не в settings.gradle.kts, циклическую зависимость, неверный id плагина, Unresolved reference из-за implementation-видимости — и знать, как каждую починить.

Комментарии

Комментарии появятся после настройки. Нужен аккаунт GitHub — вход прямо в виджете выше.