İçeriğe geç
Muhammed Ali
Kocabey

Android Uygulamalarını Modüllere Bölmek: Feature, Core ve Data Sınırları

Android uygulamasını modülerleştirmek, tek Gradle modülünü her birinin işi ve neye bağımlı olabileceği belli olan daha küçük modüllere ayırmak demek. İyi işleyen bir düzen şöyle: ince bir :app modülü, her ürün alanı için bir :feature:* modülü, ortak :core:* kütüphaneleri ve repository'leri ile veri kaynaklarını sahiplenen data modülleri.

Modül graph'ı projenin başında bir kez çizilip bırakılan bir şey değil. Eklenen her implementation(project(...)) satırı onu değiştiriyor, o yüzden code review'da diğer kodlar gibi takip edilmesi gerekiyor. Bu yazıda önemli sınırları, okların hangi yöne bakması gerektiğini, Hilt ve navigasyonun modüller arasında nasıl çalıştığını ve modüler bir projeyi yavaş yavaş monolite geri döndüren hataları anlatıyorum.

Kısaca

  • :app her şeyi birbirine bağlar, :feature:* modülleri dikey ürün parçalarıdır, :core:* modülleri yatay ortak kütüphanelerdir, data modülleri de repository'leri sahiplenir.
  • Bağımlılıklar tek yöne akar. Feature modülleri birbirinin implementasyonuna bağımlı olmaz; konuşmaları gerekiyorsa küçük bir :api modülü ya da :app üzerinden konuşurlar.
  • Varsayılan implementation'dır; api yalnız modülün public imzalarında görünen tipler içindir.
  • build-logic included build'indeki convention plugin'ler modül build dosyalarını kısa ve tutarlı tutar.
  • Sık görülen hatalar: her şeyin atıldığı bir :core modülü, döngüsel bağımlılıklar ve data katmanı modellerinin UI'ya sızması. Çok erken ya da çok ince bölmek de bir hata.

Modülerleştirme ne kazandırır?

İlk kazanç build süresi, ama bazı şartlarla. Gradle girdileri değişmeyen task'ları atlar; modüler bir projede :feature:settings içindeki bir değişiklik o modülü ve ona bağımlı olanları yeniden derler, diğer feature modülleri up-to-date kalır. Değişiklik modülün public ABI'sine dokunmuyorsa Gradle ve Kotlin derleyicisi çoğu zaman bağımlı modülleri yeniden derlemekten kaçınabilir ya da işi incremental tutabilir. Bağımsız modüller paralel de derlenebilir, ama yalnız paralel çalıştırma açıksa. :app tarafında dex birleştirme ve paketleme yine her değişiklikten sonra çalışır, yani kazanç daha çok derleme tarafında görülür. Ne kadar kazanacağınız projeye bağlı; öncesini ve sonrasını build scan veya --profile ile ölçün.

İkinci kazanç sahiplik. :feature:checkout ve :feature:search üzerinde çalışan iki geliştirici daha az çakışır, reviewer da bir değişikliği dokunduğu modüle ve o modülün public yüzeyine bakarak değerlendirebilir. Bir eID SDK'sında okuma, doğrulama ve entegrasyonu birbirinden ayırırken kullandığım fikir de buydu: sınırın arkasındakini, çağıranlar fark etmeden değiştirebilmek.

Modüllerin size vermediği şey ise iyi tasarım. Modül çizgilerini temiz çizip içlerinde yine karmaşık kod yazabilirsiniz.

Modül haritası

Google'ın modülerleştirme rehberi app, feature, data, common (core) ve test modüllerini anlatıyor. Now in Android örneği de aynı ayrımı kullanıyor; bir şeyin nereye ait olduğundan emin olmadığınızda iyi bir referans.

:app her şeyi birbirine bağlar

App modülü feature modüllerine bağımlıdır, kök NavHost'u barındırır ve Hilt component'i burada üretilir (@HiltAndroidApp). İçinde çok az iş mantığı olmalı. Birden fazla form factor için uygulama çıkarıyorsanız her birinin kendi app modülü olur (örneğin :app ve :wear), böylece platforma özel bağımlılıklar ayrı kalır.

// settings.gradle.kts
pluginManagement {
    includeBuild("build-logic")
}

include(":app")
include(":core:designsystem")
include(":core:domain")
include(":core:data")
include(":core:model")
include(":core:network")
include(":core:testing")
include(":feature:articles")
include(":feature:bookmarks")

:feature modülleri dikey parçalardır

Feature modülü bir ürün alanının ekranlarını, ViewModel'lerini, UI state'ini ve navigasyon giriş noktalarını taşır. Bir feature tek ekran olmak zorunda değil: sepet, adres adımı ve onay ekranından oluşan bir checkout akışı tek modülde durabilir, çünkü bu ekranlar birlikte değişir.

Bir feature modülü başka bir feature modülünün implementasyonuna bağımlı olmamalı. :feature:checkout, :feature:search içinden bir sınıf import ettiği anda birbirinden bağımsız değişmesi gereken iki parça birbirine bağlanır ve döngüsel bağımlılığa da bir adım kalır. İki feature gerçekten aynı koda ihtiyaç duyuyorsa o kod feature değildir, bir core modülüne aittir. Birbirlerine navigasyon yapmaları gerekiyorsa aşağıdaki navigasyon bölümüne bakın.

:core modülleri yataydır ve ortaktır

Core modülleri birçok feature'ın kullandığı kodu taşır: design system, network, ortak yardımcılar, analytics. :core:designsystem modülü temayı, tipografiyi ve ortak composable'ları sahiplenir; böylece her feature aynı uygulamanın parçası gibi görünür. Ortak UI yardımcıları da buraya aittir, OnBackPressedDispatcher'a geçiş yazısında anlattığım yeniden kullanılabilir geri tuşu extension'ı gibi.

Core modülleri başka core modüllerine bağımlı olabilir, ama hiçbir zaman bir feature'a ya da :app'e bağımlı olmaz.

Data modülleri repository'leri sahiplenir

Data modülü belli bir veri alanına dair her şeyi kapsar: repository, uzak ve yerel veri kaynakları, network ve veritabanı modelleri. Çağıran taraf veriyi repository'den ister; verinin Retrofit'ten mi, Room'dan mı, yoksa bellekteki bir cache'ten mi geldiğini bilmez. Bir cache eklemeyi ya da serialization biçimini değiştirmeyi hiçbir feature'a dokunmadan yapabilmenizi sağlayan da bu.

Bağımlılık yönü

Kural tek bir şemaya sığıyor:

:app ──▶ :feature:*  ──▶ :core:*
  │                         ▲
  └──────▶ data modülleri ──┘

feature ──▶ başka bir feature'ın implementasyonu : yasak
core / data ──▶ feature ya da :app               : yasak

:app feature'ları görür; feature'lar core'u görür; data modülleri core'u görür; hiçbir ok yukarı dönmez. Bu kural korunduğunda graph'ta döngü olmaz, bağımsız modüller paralel derlenebilir ve her modülü tek başına düşünebilirsiniz.

Gradle bu kuralın yalnız bir kısmını zorlar. Döngü olduğunda build'i durdurur, ama :feature:checkout'un :feature:search'e bağımlı olmasına ses çıkarmaz. Bu kuralın zorlanmasını istiyorsanız convention plugin'inize bir kontrol ekleyin ya da CI'da bir dependency analysis plugin'i çalıştırın.

Gradle'ın asıl verdiği şey, api ve implementation ile görünürlük kontrolü. implementation bağımlılığı sizin modülünüzü derlemek için kullanılır ama size bağımlı modüllerin compile classpath'ine geçmez. api bağımlılığı ise geçer; tüketici modüller de ona karşı derlenir.

// core/network/build.gradle.kts
dependencies {
    // Types from :core:model appear in this module's public functions,
    // so consumers need them on their compile classpath -> api.
    api(project(":core:model"))

    // Retrofit and OkHttp are internal plumbing. Keep them off
    // every consumer's compile classpath -> implementation.
    implementation(libs.retrofit)
    implementation(libs.okhttp)
}

api'yi yalnız bir tip modülün public API'sinde gerçekten görünüyorsa kullanın. Alışkanlıkla her yere api yazmak, her şeyin her yere sızdığı eski compile configuration'ını geri getirir; Gradle compile'ı tam da bu yüzden deprecate etti ve sonra tamamen kaldırdı. implementation build süresine de yardım eder: bir implementation bağımlılığındaki değişiklik, sizin modülünüzü kullananları yeniden derlemeye zorlamaz.

Clean Architecture katmanları nereye düşer?

Google'ın mimari rehberi bir UI katmanından, bir data katmanından ve ikisinin arasında opsiyonel bir domain katmanından bahsediyor. Bu modelde, Now in Android'de de olduğu gibi, domain katmanı data katmanına bağımlı: use case'ler repository'leri çağırır, repository'ler de data katmanındaki sınıflardır.

Clean Architecture ise başka bir yerden bakar: bağımlılıklar içeriye, domain'e doğru akar. Repository arayüzlerini domain tanımlar, data katmanı bunları uygular; yani Gradle seviyesinde data domain'e bağımlıdır, tersi değil. İkisi de çalışır. Ben büyük kod tabanlarında ters çevrilmiş versiyonu tercih ediyorum; domain modülü Android'den ve persistence kodundan bağımsız kalıyor, feature'lar da data implementasyonlarını hiç görmüyor. Yazının geri kalanı bu versiyonu kullanıyor:

  • Presentation :feature:* içinde: ViewModel'ler, UI state, composable'lar.
  • Domain Android bağımlılığı olmayan bir Kotlin/JVM modülü, :core:domain. Use case'leri, domain modellerini ve repository arayüzlerini taşır.
  • Data :core:data (ya da :data:* modülleri) içinde; repository implementasyonlarını ve veri kaynaklarını taşır.
// :core:domain — plain Kotlin. No Android, no Retrofit, no Room.
data class Article(
    val id: String,
    val title: String,
    val publishedAt: Instant,
)

interface ArticleRepository {
    fun observeArticles(): Flow<List<Article>>
    suspend fun refresh()
}
// :core:data — depends on :core:domain and implements its contract.
internal class DefaultArticleRepository @Inject constructor(
    private val remote: ArticleRemoteDataSource,
    private val local: ArticleDao,
) : ArticleRepository {

    override fun observeArticles(): Flow<List<Article>> =
        local.observeAll().map { entities -> entities.map(ArticleEntity::toDomain) }

    override suspend fun refresh() {
        val network = remote.fetchArticles()
        local.upsert(network.map(NetworkArticle::toEntity))
    }
}

:feature:articles içindeki ViewModel yalnız ArticleRepository arayüzüne bağımlı. Retrofit'ten de Room'dan da haberi yok, bu yüzden fake bir repository ile test edilebiliyor. Use case'lerin neden sade suspend function'lar olarak iyi çalıştığını, scope ve dispatcher yönetimini üst katmanlarda tutmayı RxJava'dan Kotlin Coroutines'a geçiş yazısında anlatmıştım.

// :feature:articles — presentation layer, sees only the domain contract.
@HiltViewModel
class ArticlesViewModel @Inject constructor(
    repository: ArticleRepository,
) : ViewModel() {

    val uiState: StateFlow<ArticlesUiState> =
        repository.observeArticles()
            .map { articles -> ArticlesUiState.Success(articles.map(Article::toUiModel)) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = ArticlesUiState.Loading,
            )
}

Modüller arasında Hilt

Yukarıdaki kod henüz çalışan bir uygulamaya dönüşmüyor: Hilt, ArticleRepository istendiğinde hangi sınıfı vereceğini bilmiyor. Binding, implementasyonun yanında, :core:data içinde duruyor. Implementasyon sınıfı da binding fonksiyonu da internal kalıyor:

// :core:data
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {

    @Binds
    internal abstract fun bindArticleRepository(
        repository: DefaultArticleRepository,
    ): ArticleRepository
}

Hilt component'i :app içinde üretildiği için :app'in :core:data'yı görmesi gerekiyor. Bu düzende ona doğrudan bağımlı olan tek modül :app:

// app/build.gradle.kts
dependencies {
    implementation(project(":feature:articles"))
    implementation(project(":feature:bookmarks"))

    // The only module that sees the data implementations,
    // so Hilt can find their @Binds modules.
    implementation(project(":core:data"))
}

Güncel sürümlerde varsayılan olarak açık olan Hilt Gradle plugin'inin aggregating task'ı, transitive implementation bağımlılıklarıyla gelen @InstallIn modüllerini de component'e dahil eder. Hilt bulabilsin diye bağımlılıkları api'ye çevirmenize gerek yok.

Feature'lar arası navigasyon

Feature'lar birbirine bağımlı olamıyorsa bookmarks ekranı bir makaleyi nasıl açacak? En basit cevap, bu işi :app'e bırakmak. Her feature kendi hedefini NavGraphBuilder üzerinde bir extension olarak sunar ve navigasyon callback'lerini parametre olarak alır:

// :feature:bookmarks
fun NavGraphBuilder.bookmarksScreen(
    onArticleClick: (articleId: String) -> Unit,
) {
    composable<BookmarksRoute> {
        BookmarksScreen(onArticleClick = onArticleClick)
    }
}

:app bütün feature'ları gördüğü için bunları NavHost içinde birbirine bağlar:

// :app
NavHost(navController = navController, startDestination = ArticlesRoute) {
    articlesScreen(onArticleClick = navController::navigateToArticle)
    articleDetailScreen()
    bookmarksScreen(onArticleClick = navController::navigateToArticle)
}

Feature'ların birbirinin hedeflerine doğrudan referans vermesi gerekiyorsa her birini küçük bir :feature:x:api modülüne ve bir :feature:x:impl modülüne bölün. :api modülü yalnız route tipini ve belki bir navigateTo... extension'ını taşır; ekranlar :impl modülündedir. :feature:bookmarks:impl, :feature:articles:api'ye bağımlı olabilir ama :feature:articles:impl'e asla. Modülerleştirme rehberi aynı fikri dependency inversion başlığı altında anlatıyor: implementasyona değil, küçük bir soyutlama modülüne bağımlı olmak.

// :feature:articles:api — no screens, no ViewModels.
@Serializable
data class ArticleDetailRoute(val articleId: String)

fun NavController.navigateToArticle(articleId: String) {
    navigate(ArticleDetailRoute(articleId))
}

Her feature'ı ilk günden api ve impl diye bölmezdim. Bu ayrımı, ikinci bir feature gerçekten birinciye işaret etmek zorunda kaldığında ekleyin.

Convention plugin'ler ve version catalog

On modül, on build.gradle.kts dosyası demek ve biraz disiplin olmazsa bunlar zamanla birbirinden ayrışır: biri farklı bir compileSdk kullanır, diğeri Compose compiler plugin'ini unutur. Convention plugin'ler ortak ayarları tek bir yerde toplayarak bu sorunu çözer.

Gradle, build mantığını paylaşmak için buildSrc'yi ya da bir included build'i kullanmanıza izin veriyor. Now in Android, build-logic adında bir included build içinde her biri tek iş yapan küçük plugin'ler kullanıyor; modüller ihtiyaç duyduklarını birleştiriyor. Şunu akılda tutmak lazım: hangisini seçerseniz seçin, bir convention plugin'deki değişiklik onu uygulayan her projenin build classpath'ini değiştirir; o projeler yeniden configure edilir ve task'larının birçoğu yeniden çalışabilir. Plugin'leri küçük tutun ve bilerek değiştirin.

Bağımlılık sürümlerinin yeri version catalog (gradle/libs.versions.toml). Build script'leri type-safe bir libs accessor'ı alır; convention plugin içinde bu accessor üretilmediği için catalog'u adıyla okursunuz:

// build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
class AndroidFeatureConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            pluginManager.apply("myapp.android.library")
            pluginManager.apply("myapp.android.compose")
            pluginManager.apply("myapp.hilt")

            val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")

            dependencies {
                add("implementation", project(":core:designsystem"))
                add("implementation", project(":core:domain"))
                add("implementation", libs.findLibrary("androidx.lifecycle.viewmodel.compose").get())
                add("testImplementation", project(":core:testing"))
            }
        }
    }
}

Feature modülünün build dosyasında artık yalnız ona özgü kısımlar kalıyor:

// feature/articles/build.gradle.kts
plugins {
    id("myapp.android.feature")
}

android {
    namespace = "com.example.feature.articles"
}

Her feature modülü artık aynı SDK seviyelerini, aynı Compose kurulumunu ve aynı temel bağımlılıkları alıyor; bunları tek bir dosyadan değiştiriyorsunuz. Yalnız bir modülün ihtiyaç duyduğu mantık ise ortak plugin'e değil, o modülün kendi build dosyasına gider.

Modül sayısı arttıkça configuration ve build süreleri de artar. gradle.properties içindeki şu üç ayarı açmaya ve plugin'lerinizle uyumunu kontrol etmeye değer:

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

Paralel çalıştırma bağımsız modüllerin aynı anda derlenmesini sağlar, build cache task çıktılarını build'ler ve branch'ler arasında yeniden kullanır, configuration cache da build kurulumunda bir şey değişmediyse configuration aşamasını atlar. Bazı üçüncü parti plugin'ler henüz configuration cache ile uyumlu değil; Gradle bu sorunları raporluyor, yani açın ve işaretlediklerini düzeltin.

Test sınırları

Modül sınırları aynı zamanda iyi test sınırlarıdır. ViewModel :core:domain'deki bir arayüze bağımlı olduğu için, bir unit test'te Hilt'e, cihaza ya da emülatöre ihtiyaç duymadan fake bir repository verebilirsiniz. Test dispatcher rule'u ya da FakeArticleRepository gibi ortak fake'ler ve test yardımcıları, yalnız test source set'lerinin bağımlı olduğu bir :core:testing modülünde durabilir. Now in Android da böyle yapıyor.

Hilt ile çalışan instrumented test'lerde bütün data binding'ini @TestInstallIn ile değiştirebilirsiniz:

@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [DataModule::class],
)
abstract class FakeDataModule {

    @Binds
    abstract fun bindArticleRepository(
        repository: FakeArticleRepository,
    ): ArticleRepository
}

Önceki bölümde DataModule'ün kendisinin public olmasının sebebi de bu: replaces ona başka bir modülden referans vermek zorunda.

Sık yapılan hatalar

Her şeyin atıldığı :core modülü

Tek bir :core (ya da :common, :utils) modülü ortak kodu koymak için pratik bir yer olarak başlar ve her modül ona bağımlı hale gelene kadar büyür. Sonra ilgisiz bir helper'daki değişiklik uygulamanın büyük kısmını yeniden derletir ve build süresi kazancı ortadan kalkar. Core'u gerçek sorumluluklara göre bölün, :core:designsystem, :core:network, :core:model ve :core:common gibi; bir modül alakasız kod toplamaya başladığında tekrar bölün.

Döngüsel bağımlılıklar

A, B'ye bağımlı; B de doğrudan ya da bir zincir üzerinden A'ya. Gradle bunu derlemeyi reddeder, işin iyi tarafı bu. Çözüm genelde build dosyasında değil tasarımdadır, çünkü döngü bir sınırın yanlış yerde olduğunu gösterir. Ortak parçayı ikisinin de bağımlı olabileceği bir modüle taşıyın ya da yukarıdaki domain/data ayrımında olduğu gibi bağımlılıklardan birini bir arayüzle tersine çevirin.

Data modellerinin UI'ya sızması

@Serializable bir network DTO'su ya da Room @Entity'si el altında olduğu için ViewModel'e, oradan da bir composable'a taşınır. Artık UI, wire format'a bağımlıdır: JSON'daki bir alanın adı değişir, bir ekran bozulur. Her katmanın kendi işine göre şekillenmiş modelleri olmalı ve dönüşüm sınırda yapılmalı.

// :feature:articles — a UI model, separate from the data layer's types.
data class ArticleUiModel(
    val id: String,
    val title: String,
    val relativeTime: String,
)

internal fun Article.toUiModel(): ArticleUiModel =
    ArticleUiModel(
        id = id,
        title = title,
        relativeTime = publishedAt.toRelativeTime(),
    )

Bu mapper'lar API değişip düzeltme tek bir dosyada kalana kadar boilerplate gibi görünür.

Çok erken ya da çok ince bölmek

Her modülün bir maliyeti var: kendi build dosyası, kendi configuration süresi, Android library modüllerinde de manifest merge ve resource işleme gibi ek işler. Bir iki geliştiricinin çalıştığı küçük bir uygulama çoğu zaman :app ve birkaç core modülünden fazlasına ihtiyaç duymaz. Sınırlar netleşmeden onlarca küçük modüle bölmek, sonradan kodu modüller arasında taşımak demek. Modülerleştirme rehberi de bu konuda uyarıyor: fazla ince modüller ek yük getirir, fazla kaba modüller de başka bir isimle monolit olur. :core:domain veya :core:model gibi Android bağımlılığı olmayan modülleri düz Kotlin/JVM modülü yapın; derlemesi ve test etmesi daha ucuz.

Nereden başlamalı?

Tek modüllü bir uygulamanız varsa onu bir seferde yirmi modüle bölmeye çalışmayın. Birçok yerin kullandığı ve uygulama koduna bağımlı olmayan modüllerle başlayın, :core:designsystem ve :core:model gibi. Sonra iyi anladığınız bir feature'ı kendi modülüne taşıyın ve gerçekte hangi bağımlılıklara ihtiyaç duyduğunu görün. İki üç modül aynı build ayarlarını tekrarlamaya başladığında convention plugin'leri ekleyin.

Bundan sonra en işe yarayan alışkanlık, modül bağımlılıklarını kod gibi review etmek. Yanlış yöne bakan bir ok, onu ekleyen pull request'te düzeltildiğinde ucuzdur; başka modüller ona dayanmaya başladıktan sonra pahalı.

BİRLİKTE ÇALIŞALIM

İyi bir problemle başlayalım.

Ekibiniz için bir Android mühendisi arıyorsanız veya bir teknik problemi birlikte değerlendirmek istiyorsanız konuşalım.

30 dakika görüşelim[email protected]Tüm iletişim ve podcast bağlantıları