Многомодульность и UI Kit
Зачем это нужно
В прошлой главе 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.
- Зачем это нужно
- Дублирование до модуля, переиспользование после
- Анатомия multi-module проекта
- Что живёт в ui-kit: дизайн-система
- implementation(project("")) и разница api vs implementation
- .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%— результат одинаковый во всех трёх случаях, разница только в том, откуда экраны берут эту логику: из своей копии или из общего модуля.
Анатомия 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, а не один общий на всех.
com.android.application и com.android.library: разница в одно слово
У каждого Android-модуля в build.gradle.kts есть блок plugins { }, и именно id плагина в нём определяет, каким модуль будет — исполняемым приложением или переиспользуемой библиотекой. У модуля :app это com.android.application, у модулей :ui-kit и :net — com.android.library.
Различие — буквально одно слово, application вместо library, — но оно меняет весь смысл модуля: в проекте ровно один модуль с com.android.application (точка входа, которую запускает пользователь) и сколько угодно модулей с com.android.library (переиспользуемые части, из которых эта точка входа собрана).
Прежде чем читать дальше — собери граф зависимостей сам: какие стрелки между :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).
Теперь проверь то же самое с другой стороны: разложи сущности проекта по модулям, в которых им место.
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 компиляции потребителей»).
Это не абстрактное правило — проверим его вживую на настоящем трёхмодульном проекте (: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:
ImplementationStyleRepository—fetchInternal()помечена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.
import ru.champs.lib.LibClassBUILD SUCCESSFUL — :lib есть на classpath компиляции :app
.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-ресурс».
Команда сборки для реального .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 — другой плагин с другим соглашением об именах папок, а не потому, что принцип другой.
Типичные ошибки
Все четыре ошибки этой главы — настоящие, снятые с живого трёхмодульного 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).
Слова главы
Потренируйся печатать
Строка подключения модуля в settings.gradle.kts — с неё начинается любой multi-module проект:
Цель: скорость ≥ 100 зн/мин, точность ≥ 90%
include(":app", ":ui-kit", ":net")
Объявление зависимости, которую видит только сам модуль, но не его потребители:
Цель: скорость ≥ 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 |
.aar | Android-архив | артефакт библиотечного Android-модуля: код + ресурсы + манифест | ui-kit-release.aar |
.jar | Java-архив | артефакт обычного JVM-модуля: только скомпилированный код, без ресурсов | net.jar |
Углубиться
Почему циклические зависимости запрещены не только «по вкусу», а технически. Gradle строит граф задач перед тем, как начать сборку, и должен заранее определить порядок их выполнения — какие задачи можно запускать сразу, какие только после других. Цикл в этом графе делает такой порядок математически невозможным: у задачи A нет способа одновременно быть «раньше» и «позже» задачи B. Именно поэтому ошибка про циклическую зависимость — не предупреждение, а отказ от сборки: продолжать попросту не с чего начать. В многомодульных Android-проектах эта же проблема на уровне архитектуры (не только Gradle-задач) подробно разобрана в статье «Принципы построения многомодульных Android-приложений» — там же показано типичное решение: выносить общий код в третий, более низкоуровневый модуль, от которого зависят обе стороны конфликта, вместо зависимости друг от друга напрямую.
Что нужно настоящей библиотеке сверх самого кода. В этой главе :ui-kit собирается и им пользуется тот же самый проект. Но если библиотеку готовят к публикации отдельно от проекта — например, чтобы несколькими приложениями пользоваться одним и тем же :ui-kit из разных репозиториев, — появляются дополнительные требования: namespace, версия, публикация через maven-publish в Maven-репозиторий, за которой уже приходят потребители. Официальная документация Android описывает этот процесс в разделе «Prepare your library for release».
Куда дальше: проверенные ресурсы
Русские материалы
- Многомодульный BDSM: стоит ли внедрять Gradle модули и какие типы модулей бывают? — открой за развёрнутым разбором мотивации: инкапсуляция, снижение связности, параллельная работа команды и модульность как «статический анализатор», удерживающий архитектуру.
- Gradle: Api vs Implementation на Kotlin — открой за отдельной статьёй именно про разницу api/implementation, с собственными примерами из нескольких библиотек.
- Своя библиотека под Android за один вечер — открой, чтобы увидеть вживую, как создаётся Android Library модуль через мастер New Module в Android Studio (тема не входит в эту главу — здесь весь упор на Gradle-механику, а не на клики по IDE).
- Принципы построения многомодульных Android-приложений — открой за более глубоким разбором типов модулей (feature/data/core) и направления зависимостей между ними.
Официальная документация (на английском)
-
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 — вход прямо в виджете выше.