Compile-time dependency graph'i yönetmek: Android

Kırk module'e böldüğünüz proje tek satırlık değişiklikte hâlâ dakikalarca derleniyorsa bakmanız gereken yer graph'in şeklidir. Gradle'da api ile implementation farkını, api/impl ayrımını, fark etmeden sızan şeyleri ve kazancı nasıl ölçeceğinizi anlatıyoruz.

Module'lere bölünmüş bir Android projesinde tek bir data class'a alan eklersiniz ve Gradle çıktısında kırk tane compileDebugKotlin task'ının sırayla çalıştığını izlersiniz. Proje kâğıt üzerinde modülerdir, build ise hâlâ monolit gibi davranır. Büyük kod tabanlarında sık gördüğümüz tablo bu: module sayısı artmış, ama module'ler arasındaki okların yönü ve türü hiç tasarlanmamış.

Build süresini belirleyen şey module sayısından çok graph'in şeklidir. Bu yazı o şekli Gradle tarafında nasıl okuyacağınızı, nasıl düzelteceğinizi ve düzeldiğini nasıl kanıtlayacağınızı anlatıyor. Aynı konunun Xcode ve SwiftPM tarafı için iOS yazısına bakabilirsiniz. Örnekler AGP 9 ve Gradle 9 varsayıyor: AGP 9'da Kotlin desteği yerleşik geldiği için org.jetbrains.kotlin.android plugin'ini uygulamıyoruz. AGP 8'deyseniz o satırı eklemeniz yeterli, yazının geri kalanı aynen geçerli.

Graph dediğimiz şey configuration'lardan oluşur

Gradle'da bir module'ün "bağımlılıkları" tek bir liste halinde durmaz. Her dependencies satırı bir configuration'a yazılır (implementation, api, compileOnly, ksp) ve Gradle bunlardan her variant için iki ayrı classpath çözer: derleyicinin gördüğü debugCompileClasspath ve APK'ya giren debugRuntimeClasspath. Incremental build süresini ilgilendiren, birincisidir. Bir module'ün compile classpath'inde ne kadar çok başka module varsa, o module o kadar çok değişiklikten etkilenir.

Bu yüzden graph'e bakarken üç ölçüye dikkat ediyoruz.

Derinlik: :app'ten en uzak leaf'e kadar olan en uzun zincir. Zincirdeki her module bir öncekinin derlenmesini bekler, yani derinlik paralel çalıştırmanın tavanını belirler. Gradle'ın kendi performans rehberi de aynı şeyi söyler: subproject'ler arasında çok fazla bağımlılık varsa --parallel kazancı küçük kalır.

Fan-in: bir module'e kaç module'ün bağlı olduğu. Herkesin bağlandığı :core, tanım gereği her değişikliğin herkese yayıldığı yerdir.

Yayılma: bir değişikliğin kaç module'ün compile task'ını çalıştırdığı. Bunu api ve implementation seçimi belirler ve yazının büyük kısmı bununla ilgili.

Örnek olarak varsayımsal bir seyahat uygulaması kullanacağız, adı Rota olsun. Bugünkü graph'i şöyle:

:app
 ├── :feature:search ───api──▶ :feature:booking ───api──▶ :feature:checkout
 ├── :feature:profile
 └── (hepsi) ───api──▶ :core
                        ├─ Retrofit servisleri, OkHttp ayarları
                        ├─ Room veritabanı, DAO'lar (kapt)
                        ├─ tema, ortak Compose bileşenleri, strings.xml
                        ├─ domain modelleri
                        └─ StringExtensions.kt (1.400 satır)

Dört feature, bir :core. Kimse bu graph'i böyle çizmedi; her ok bir pull request'te "şimdilik" eklendi.

api ve implementation: compile classpath'e ne konduğu

Gradle dokümantasyonu farkı net tanımlar: api ile bildirilen bağımlılık tüketicilere transitif olarak açılır ve onların compile classpath'inde görünür. implementation ile bildirilen bağımlılık tüketicinin compile classpath'ine sızmaz, yalnızca runtime classpath'ine girer.

Bunun build süresine etkisi compile avoidance üzerinden gelir. Gradle bir module'ün compile task'ını yeniden çalıştırıp çalıştırmayacağına, classpath'indeki module'lerin ABI'sinin değişip değişmediğine bakarak karar verir. Bir metodun gövdesini, bir yorumu ya da private bir üyeyi değiştirmek ABI'yi değiştirmez ve tüketiciler yeniden derlenmez. Public bir fonksiyon eklemek, bir imzayı ya da public bir tipin alanlarını değiştirmek ABI'yi değiştirir. O zaman o module'ü compile classpath'inde gören her module'ün compile task'ı çalışır.

"Gören her module" kısmı api ile implementation arasındaki farkın tamamıdır. Android dokümantasyonundaki özet şöyle: bir implementation bağımlılığının API'si değiştiğinde Gradle yalnızca o bağımlılığı ve ona doğrudan bağlı module'leri yeniden derler. Bir api bağımlılığının API'si değiştiğinde ise ona compile-time'da erişebilen bütün module'ler yeniden derlenir.

Rota'nın :feature:booking module'ü bugün şöyle:

// feature/booking/build.gradle.kts (önce)
plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.legacy.kapt)
}

dependencies {
    api(project(":core"))
    api(project(":feature:checkout"))   // "ödeme ekranını açmamız lazım"
    api(libs.retrofit)
    api(libs.androidx.room.runtime)
    kapt(libs.androidx.room.compiler)
}

Buradaki api(project(":feature:checkout")) satırı yüzünden :feature:search, hiç kullanmadığı halde :feature:checkout'u compile classpath'inde görür. Checkout ekibindeki biri public bir fonksiyonun imzasını değiştirdiğinde booking, search ve :app yeniden derlenir. Zincir uzadıkça leaf'teki bir değişiklik uygulamanın tamamına yayılır. Üstelik sızan classpath bir de davet çıkarır: search'te biri IDE'nin otomatik tamamlamasında CheckoutViewModel'i görür ve kullanır. Artık o api'yi kaldırmak derleme hatası üretir ve ok kalıcı hale gelir.

Düzeltilmiş hali:

// feature/booking/build.gradle.kts (sonra)
plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.ksp)
}

dependencies {
    api(project(":core:model"))            // Booking tipi public imzalarımızda geçiyor
    implementation(project(":core:network"))
    implementation(project(":core:database"))
    implementation(project(":core:designsystem"))
    implementation(project(":feature:checkout:api"))

    implementation(libs.retrofit)
    implementation(libs.androidx.room.runtime)
    ksp(libs.androidx.room.compiler)
}

Kural Gradle'ın kendi tanımından çıkar: bir bağımlılığın tipleri module'ünüzün public imzalarında geçiyorsa (public fonksiyon parametresi, dönüş tipi, üst sınıf, public alan) o bağımlılık api'dir, çünkü tüketici o tip olmadan sizi derleyemez. Yalnızca fonksiyon gövdelerinde ve internal/private kodda kullanılıyorsa implementation'dır.

Varsayılan implementation'dır. Her api satırı, yanında gerekçesiyle durmalıdır.

Bir uyarı: Kotlin tarafında compile task'ının çalışması, module'ün baştan sona derlenmesi anlamına gelmez. Kotlin Gradle plugin'i classpath snapshot'ları ile hangi sınıfların etkilendiğini hesaplar ve yalnızca onları derler. Yine de task çalışır, snapshot'lar karşılaştırılır, annotation processing adımları girdileri değiştiği için tekrar devreye girebilir. Kırk module'de küçük maliyetlerin toplamı hissedilir bir bekleme süresidir. Kendi projenizde ne kadar olduğunu tahmin etmek yerine aşağıda anlattığımız şekilde ölçün.

Feature'ları api ve impl olarak ayırmak

implementation'a geçmek yayılmayı bir adım kısaltır, ama feature'lar birbirine doğrudan bağlı olduğu sürece zincir yerinde durur. Booking'in checkout'a bağlanma sebebi çoğu zaman tek bir şeydir: o ekranı açmak. Bunun için checkout'un ViewModel'ini, repository'sini ve Room entity'lerini compile classpath'e almak pahalı bir yoldur.

Çözüm, başka module'lerin ihtiyaç duyduğu sözleşmeyi küçük bir :api module'üne, geri kalan her şeyi :impl module'üne koymak:

:feature:checkout:api    route tipi, feature'ın dışarıya açtığı interface'ler (küçük, seyrek değişir)
:feature:checkout:impl   ekranlar, ViewModel'ler, repository, DI module'leri

:api module'ü Navigation Compose'un type-safe route'larıyla (Navigation 2.8.0 ve sonrası) birkaç satırdan ibarettir:

// feature/checkout/api/src/main/kotlin/com/rota/checkout/api/CheckoutRoute.kt
package com.rota.checkout.api

import kotlinx.serialization.Serializable

@Serializable
data class CheckoutRoute(val bookingId: String)

:impl bu route'u graph'e kaydeden fonksiyonu sunar:

// feature/checkout/impl/src/main/kotlin/com/rota/checkout/impl/CheckoutNavigation.kt
package com.rota.checkout.impl

import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.rota.checkout.api.CheckoutRoute

fun NavGraphBuilder.checkoutScreen(onPaid: () -> Unit) {
    composable<CheckoutRoute> { entry ->
        val route: CheckoutRoute = entry.toRoute()
        CheckoutScreen(bookingId = route.bookingId, onPaid = onPaid)
    }
}

Bağlantıyı kuran taraf :app'tir. Bütün :impl module'lerini gören tek yer orasıdır ve composition root olarak görevi de budur:

// app/src/main/kotlin/com/rota/RotaNavHost.kt
NavHost(navController, startDestination = SearchRoute) {
    searchScreen(onFlightSelected = { id -> navController.navigate(BookingRoute(id)) })
    bookingScreen(onProceed = { bookingId -> navController.navigate(CheckoutRoute(bookingId)) })
    checkoutScreen(onPaid = { navController.popBackStack(SearchRoute, inclusive = false) })
}

Bu yapıda :feature:booking:impl geçişi bir lambda olarak dışarı verdiği için :feature:checkout:api'ye bile ihtiyaç duymayabilir. Geçişe booking'in içinden karar vermek istiyorsanız yalnızca :feature:checkout:api'ye implementation ile bağlanır. Android'in modülerleştirme rehberi aynı yaklaşımı önerir: feature'lar birbirine bağlanmaz, aralarında :app gibi bir module aracılık eder ve argüman olarak nesne yerine id taşınır.

Kazanç şu: checkout ekibi :impl içinde ne değiştirirse değiştirsin, ABI değişikliği dahil, yeniden derlenen module'ler :feature:checkout:impl ve :app olur. Booking'in classpath'inde yalnızca tek dosyalık :api vardır.

Her feature'ı bölmüyoruz. Ayrım module sayısını ikiye katlar, her module'ün configuration maliyeti vardır. Başka feature'ların bağlandığı feature'ları bölün, leaf olanları tek module bırakın. Bu maliyet hesabını modülerleştirmenin maliyeti yazısında ayrıca ele aldık.

Fark etmeden sızan dört şey

build.gradle.kts dosyalarınız temiz görünse bile module sınırlarını aşan başka kanallar var.

R sınıfları

Transitif R sınıflarında her module'ün R sınıfı, bağlı olduğu bütün module'lerin kaynaklarına referans içerir. :core:designsystem'e bir string eklediğinizde ona bağlı her module'ün R sınıfı değişir. Non-transitive R'da her module'ün R sınıfı yalnızca kendi kaynaklarını içerir. Bu davranış AGP 8.0.0'dan beri varsayılandır. Eski bir projeyi taşırken gradle.properties dosyasına android.nonTransitiveRClass=false yazıp "sonra bakarız" denmiş olabilir; o satır hâlâ duruyorsa graph'inizde görünmeyen bir api zinciri var demektir. Kaldırdığınızda başka module'lerin kaynaklarına tam nitelikli adla erişmeniz gerekir (com.rota.core.designsystem.R.string.retry). Android Studio'nun Refactor menüsündeki "Migrate to Non-Transitive R Classes" aracı bu dönüşümü yapar.

Annotation processing

kapt, Java annotation processor'larını çalıştırabilmek için Kotlin kaynaklarından Java stub'ları üretir. Android dokümantasyonu kapt'ın KSP'den belirgin biçimde yavaş olduğunu söyler ve KSP destekleyen her processor için geçişi önerir. AGP 9'un yerleşik Kotlin desteğinde org.jetbrains.kotlin.kapt plugin'i çalışmaz, yerine com.android.legacy-kapt gelir; plugin'in adı niyeti yeterince anlatıyor. Graph açısından önemli olan, processor'ın girdisinin module'ün compile classpath'i olmasıdır: classpath api zincirleriyle şiştikçe stub üretimi ve processing daha sık tetiklenir. Bir de şu var: bir module'de tek bir kapt processor'ı kalsa bile stub'lar üretilmeye devam eder, yani kazanç module'deki son kapt satırı gidince gelir.

Kaynaklar

implementation yalnızca compile classpath'i daraltır. Kaynaklar paketleme sırasında tek bir isim alanında birleşir: aynı isimli kaynak hem app'te hem library'de varsa app'teki kullanılır, iki library çakışırsa dependencies bloğunda üstte olan kazanır. İki feature'da title adında birer string varsa biri diğerinin metnini sessizce gösterir. Android dokümantasyonunun önerdiği gibi module'e özgü bir önek kullanın (booking_title, checkout_title).

Hilt aggregation

Hilt, @InstallIn ile işaretlenmiş module'leri ve @AndroidEntryPoint sınıflarını Application'ı derleyen module'de toplar. Bunun için o module'ün Hilt kullanan bütün module'leri transitif bağımlılıklarında görmesi gerekir. Eskiden bu ihtiyaç ekipleri implementation'ları api'ye çevirmeye itiyordu ve graph tam da bu yüzden bozuluyordu. Hilt Gradle plugin'i bugün toplamayı ayrı bir Gradle task'ında yapıyor (enableAggregatingTask, varsayılan olarak açık), yani :impl module'lerinize implementation ile bağlanmanız yeterli. Dokümantasyon eski enableExperimentalClasspathAggregation bayrağını deprecated olarak işaretliyor; build dosyalarınızda duruyorsa kaldırın.

Aggregation'ın graph'e iki yansıması var. Birincisi, :app her feature'ın :impl module'üne bağlı olmak zorundadır, ki composition root için bu zaten doğru olandır. İkincisi, classpath'e giren her @InstallIn module'ü kimse açıkça referans vermeden uygulamanın DI graph'ine katılır. Bir test yardımcı module'ünü yanlışlıkla implementation ile eklerseniz içindeki sahte binding'ler de gelir. Bu tür hataları Android'de DI anti-pattern'leri yazısında ayrıntılı işledik.

Build seviyesindeki kollar neyi düzeltir, neyi düzeltmez

gradle.properties dosyasındaki üç satır çoğu projede açık olmalı:

org.gradle.configuration-cache=true
org.gradle.caching=true
org.gradle.parallel=true

Üçü de değerlidir ve hiçbiri graph'i değiştirmez.

Configuration cache, configuration aşamasının sonucunu (task graph'i ve task'ların yapılandırılmış hali) saklar ve build script'leri değişmediyse o aşamayı tamamen atlar. Gradle 9'dan itibaren tercih edilen çalışma modudur. Module sayısı arttıkça configuration süresi de artar, dolayısıyla api/impl ayrımının getirdiği ek module'lerin maliyetini büyük ölçüde bu kapatır. Hangi task'ların çalışacağına dokunmaz.

Build cache, girdileri aynı olan bir task'ın çıktısını başka bir build'den (CI'dan ya da başka bir branch'ten) geri alır. Girdinin içinde compile classpath de vardır. :core'un ABI'si değiştiyse ona bağlı her module'ün cache anahtarı da değişir ve cache ıskalar. Geniş fan-in, cache'in isabet oranını düşürür.

Paralel çalıştırma, farklı project'lerdeki task'ları aynı anda yürütür. Zincir halindeki bir graph'te sırada bekleyen module'leri paralelleştiremez. Graph'i yassılaştırmanın bir kazancı da paralelliğe yer açmaktır.

Convention plugin'ler ve version catalog'lar aynı kategoridedir. Kırk build.gradle.kts dosyasındaki tekrarı tek yere toplar, sürümleri gradle/libs.versions.toml'da birleştirirler. Bunlar olmadan api/impl ayrımını yönetmek eziyettir, o yüzden kullanın. Ama rota.android.feature adında bir convention plugin'in içine api(project(":core")) yazarsanız hatayı kırk module'e tek satırla dağıtmış olursunuz.

Build ayarları mevcut graph'i daha hızlı çalıştırır. Graph'i yalnızca dependencies blokları değiştirir.

Graph'i görmek ve kurala bağlamak

İlk bakış için Gradle'ın kendi raporu yeterli:

# :app'in derleme sırasında gördüğü her şey
./gradlew :app:dependencies --configuration debugCompileClasspath

# Bir module compile classpath'e hangi yoldan girmiş?
./gradlew :feature:search:dependencyInsight \
    --dependency :feature:checkout --configuration debugCompileClasspath

debugCompileClasspath ile debugRuntimeClasspath çıktılarını karşılaştırmak öğreticidir. implementation düzgün kullanılan bir projede bir feature'ın compile classpath'i runtime classpath'inden belirgin biçimde küçüktür. İkisi neredeyse aynıysa her şey api ile sızıyordur. Build scan (./gradlew assembleDebug --scan) aynı bilgiyi aranabilir biçimde verir, ayrıca timeline görünümünde hangi task'ların gerçekten paralel çalıştığını gösterir. Scan, kendi Develocity sunucunuz yoksa Gradle'ın barındırdığı servise yüklenir; şirket politikanıza uygun olduğundan emin olun.

Gözle bakmak bir hafta sonra unutulur. Kalıcı olan, kuralı build'e yazmaktır. İki araç kullanıyoruz.

Dependency Analysis Gradle Plugin, her module için kullanılmayan bağımlılıkları, transitif olarak kullanılıp doğrudan bildirilmemiş olanları ve yanlış configuration'ları (api olması gerekirken implementation, ya da tersi) raporlar. Yazının yazıldığı tarihte güncel sürüm 3.19.2:

// settings.gradle.kts
plugins {
    id("com.autonomousapps.build-health") version "3.19.2"
}
// build.gradle.kts (root)
dependencyAnalysis {
    issues {
        all {
            onIncorrectConfiguration { severity("fail") }
            onUnusedDependencies { severity("fail") }
            onUsedTransitiveDependencies { severity("warn") }
        }
    }
}

./gradlew buildHealth bütün proje için, ./gradlew :feature:booking:projectHealth tek module için tavsiye üretir. Bir tavsiyenin gerekçesini ./gradlew :feature:booking:reason --id :core:network ile sorabilirsiniz. fixDependencies task'ı önerileri build dosyalarına uygular; büyük bir projede bunu module module yapıp her adımda derlemenizi öneririz. Plugin'i ayrıntılı ayarlarken (root script mi, settings plugin'i mi) kullandığınız sürümün wiki sayfasına bakın, DSL sürümler arasında değişti.

Module Graph Assert ise graph'in şekline kural koyar: izin verilen oklar, yasak oklar ve en büyük yükseklik. README yalnızca Groovy örneği verir; Kotlin DSL'de allowed ve restricted birer Array<String> olduğu için şöyle yazılır:

// app/build.gradle.kts
plugins {
    id("com.jraska.module.graph.assertion") version "2.9.1"
}

moduleGraphAssert {
    maxHeight = 4
    allowed = arrayOf(
        ":app -> .*",
        ":feature:.*:impl -> :feature:.*:api",
        ":feature:.* -> :core:.*",
        ":core:.* -> :core:model",
    )
    restricted = arrayOf(
        ":feature:.*:api -X> :core:(network|database)",
    )
}

assertModuleGraph task'ı check'e bağlıdır, yani CI'da zaten çalışır. allowed tanımladığınızda listedeki kalıplardan hiçbirine uymayan her ok build'i kırar. Kuralları yazdıktan sonra bilerek yanlış bir bağımlılık ekleyip task'ın gerçekten kırıldığını görün; regex'ler çoğu zaman düşündüğünüzden fazlasına izin verir. Mimari kuralları sınıf seviyesinde de denetlemek istiyorsanız Android'de mimari kuralları zorunlu kılmak yazısı devamı niteliğinde.

Ölçmek: leaf'e dokun, core'a dokun

Graph değişikliğinin kazancını clean build ile ölçemezsiniz, çünkü clean build'de her şey zaten derlenir. Ölçmeniz gereken, geliştiricinin gün içinde yüz kere yaptığı şeydir: bir dosyayı değiştirip tekrar derlemek.

Tek seferlik bir bakış için ./gradlew :app:assembleDebug --profile yeterlidir; rapor root project'in build/reports/profile dizinine yazılır ve hangi task'ların çalıştığını, hangilerinin UP-TO-DATE kaldığını listeler. Android Studio'daki Build Analyzer da aynı soruya IDE içinden cevap verir.

Tekrarlanabilir sayılar için gradle-profiler kullanıyoruz (brew install gradle-profiler ya da SDKMAN). Senaryo dosyası, her iterasyonda belirttiğiniz dosyaya otomatik bir değişiklik uygular:

# rota.scenarios
leaf_non_abi {
    title = "Leaf: fonksiyon gövdesi değişti"
    tasks = [":app:assembleDebug"]
    apply-non-abi-change-to = "feature/checkout/impl/src/main/kotlin/com/rota/checkout/impl/CheckoutViewModel.kt"
}

leaf_abi {
    title = "Leaf: public fonksiyon eklendi"
    tasks = [":app:assembleDebug"]
    apply-abi-change-to = "feature/checkout/impl/src/main/kotlin/com/rota/checkout/impl/CheckoutViewModel.kt"
}

core_abi {
    title = "Core: public fonksiyon eklendi"
    tasks = [":app:assembleDebug"]
    apply-abi-change-to = "core/model/src/main/kotlin/com/rota/model/Booking.kt"
}

core_resource {
    title = "Core: string eklendi"
    tasks = [":app:assembleDebug"]
    apply-android-resource-change-to = "core/designsystem/src/main/res/values/strings.xml"
}
gradle-profiler --benchmark --project-dir . --scenario-file rota.scenarios \
    --warmups 4 --iterations 10

apply-abi-change-to her iterasyonda sınıfa yeni bir public metot ekler ve bir öncekini kaldırır; apply-non-abi-change-to public bir metodun gövdesini değiştirir. İkisi de Java ve Kotlin kaynaklarında çalışır. Sonuçlar profile-out/benchmark.html ve benchmark.csv dosyalarına yazılır; her senaryo için ortalama, medyan ve standart sapmayı görürsünüz.

Dört senaryonun birbirine oranı graph'inizin röntgenidir. leaf_abi süresi core_abi süresine yakınsa leaf'iniz fiilen leaf olmaktan çıkmıştır, bir yerden api ile sızıyordur. leaf_non_abi bile uzun sürüyorsa sorun graph'ten önce o module'ün kendi içindedir (kapt, çok büyük tek module) ya da :app'in paketleme adımlarındadır. core_resource süresi core_abi'ye yakınsa transitif R'a bakın.

Rota'yı düzeltmek

Yukarıdaki kötü graph'i hangi sırayla düzelteceğimizi adım adım yazalım. Sıra önemli, çünkü ilk adımlar ucuz ve kazancın büyük kısmını getiriyor.

  1. Dört profiler senaryosunu mevcut main üzerinde çalıştırın ve profile-out dizinini saklayın. Bu sizin referansınız.
  2. buildHealth çalıştırın ve bütün gereksiz api'leri implementation'a çevirin. Kırılan yerler, transitif bağımlılığa farkında olmadan yaslanmış module'lerdir; onlara eksik bağımlılığı açıkça ekleyin. Bu adım hiçbir module'ü taşımayı gerektirmez.
  3. :core'u değişme sıklığına göre bölün: :core:model, :core:network, :core:database, :core:designsystem. Amaç, her feature'ın yalnızca kullandığı parçayı classpath'ine alması. 1.400 satırlık StringExtensions.kt için ayrı bir :core:common açmadan önce içindekilerin kaçının hâlâ kullanıldığına bakın.
  4. Başka feature'ların bağlandığı feature'ları (burada booking ve checkout) :api ve :impl olarak ayırın, navigation bağlantısını :app'e taşıyın. Feature'dan feature'a giden project(...) satırı kalmasın.
  5. kapt'tan KSP'ye geçin, android.nonTransitiveRClass=false satırı varsa kaldırın.
  6. Module Graph Assert kurallarını ve buildHealth'i CI'a ekleyin. Bu adım olmadan graph altı ay içinde eski haline döner.

Ortaya çıkan şekil:

:app ──▶ :feature:search            ──▶ :core:model, :core:network, :core:designsystem
     ──▶ :feature:profile           ──▶ :core:model, :core:database, :core:designsystem
     ──▶ :feature:booking:impl      ──▶ :feature:booking:api, :feature:checkout:api, :core:*
     ──▶ :feature:checkout:impl     ──▶ :feature:checkout:api, :core:*
     ──▶ :feature:booking:api       ──▶ :core:model
     ──▶ :feature:checkout:api

Doğrulama için aynı senaryoları tekrar çalıştırın ve iki benchmark.csv dosyasını yan yana koyun. Beklediğimiz değişim şudur: leaf_abi, leaf_non_abi'ye yaklaşır, çünkü :feature:checkout:impl'deki bir ABI değişikliği artık yalnızca o module'ü ve :app'i etkiler. core_abi kısalır ama sıfırlanmaz; :core:model hâlâ herkesin bağlandığı yerdir ve öyle kalması normaldir. Oradaki hedef, o module'ü küçük ve seyrek değişen tutmaktır. Süreden bağımsız ikinci bir kanıt da var: leaf_abi senaryosundan sonra --profile raporunda çalışan compile task'larını sayın. Sayı, graph'ten beklediğinizle uyuşmalı.

Bu çalışmanın değmediği yer de var. Altı yedi module'lü, incremental build'i zaten saniyelerle ölçülen bir projede api/impl ayrımı yalnızca tören ekler. Orada ikinci adım (gereksiz api'leri temizlemek) yeterlidir. Ayrım, birden fazla ekibin aynı feature'lara bağlandığı ve core_abi senaryosunun kahve molasına dönüştüğü projelerde karşılığını verir.

Kontrol listesi

  • leaf_non_abi, leaf_abi, core_abi ve core_resource senaryoları gradle-profiler ile ölçüldü, sonuçlar saklandı.
  • Her api satırının gerekçesi var: tip, module'ün public imzasında geçiyor.
  • buildHealth temiz ya da kalan istisnalar bilerek bırakıldı.
  • Feature'dan feature'a project(...) bağımlılığı yok; geçişler :app üzerinden ya da :api module'leriyle kuruluyor.
  • :core tek module olmaktan çıktı; en çok bağlanan parça (:core:model) küçük ve seyrek değişiyor.
  • android.nonTransitiveRClass=false ve enableExperimentalClasspathAggregation build dosyalarında yok.
  • kapt kullanan module kalmadı ya da kalanların listesi ve geçiş planı var.
  • Kaynak adları module önekiyle başlıyor.
  • Configuration cache, build cache ve paralel çalıştırma açık.
  • Convention plugin'ler api(project(...)) dağıtmıyor.
  • assertModuleGraph CI'da çalışıyor ve bilerek eklenen yanlış bir bağımlılıkla kırıldığı doğrulandı.
  • Refactor sonrası senaryolar tekrar çalıştırıldı, leaf_abi sonrası çalışan compile task sayısı beklenenle uyuşuyor.
Seviye 11 tamamlandıİlerlemen bu tarayıcıda saklanıyor.
Sıradaki: Architecture dependency rule'larını compiler'a enforce ettirmek: iOS

Bu konuda desteğe mi ihtiyacınız var?

Uygulamanızda bu kontrollerin doğru kurulup kurulmadığını birlikte inceleyelim. Güvenlik denetimi ya da danışmanlıkla buradayız.

Yorumlar ve sorular

Katılmadığınız bir nokta ya da projenizde takıldığınız bir yer mi var? Yazın, konuşalım.