Dependency Injection'ın yanlış kullanımı: Android'de DI container anti-pattern'leri

Hilt ya da Koin kurulu diye bağımlılıklarınız yönetiliyor sayılmaz. Her şeyin @Singleton olduğu graph'ler, kılık değiştirmiş service locator'lar ve 80 @Provides'lı AppModule nasıl oluşur, adım adım nasıl toparlanır, anlatıyoruz.

Bir Android projesini incelerken ilk açtığımız dosyalardan biri AppModule.kt olur. Dosyanın uzunluğu, projenin geri kalanı hakkında çoğu mimari dokümandan daha dürüst bilgi verir. 900 satır, 80 @Provides, hepsinin üstünde @Singleton: bu tabloyu gördüğümüzde ekibin DI kullandığını, ama bağımlılıklarını yönetmediğini anlarız. Container kuruludur, graph derlenir, uygulama açılır. Buna rağmen nesnelerin ne kadar yaşadığını, kimin kimi tanıdığını ve logout'tan sonra bellekte ne kaldığını kimse söyleyemez.

Bu yazı Android tarafını anlatıyor: Kotlin, Hilt/Dagger ve Koin. Aynı hastalıkların Swift'teki hâli için iOS yazısına bakabilirsiniz. Örneklerde varsayımsal bir seyahat rezervasyon uygulaması kullanacağız: uçuş arama, rezervasyon, ödeme, profil.

Gerçekçi bir "önce" tablosu

Büyük projelerde sık gördüğümüz tablonun küçültülmüş hâli şöyle:

@Module
@InstallIn(SingletonComponent::class)
object AppModule {

    @Provides @Singleton
    fun provideAuthInterceptor(prefs: SharedPreferences): Interceptor {
        val token = prefs.getString("token", null)      // graph kurulurken bir kez okunur
        return Interceptor { chain ->
            chain.proceed(
                chain.request().newBuilder()
                    .header("Authorization", "Bearer $token")
                    .build()
            )
        }
    }

    @Provides @Singleton
    fun provideBookingRepository(api: BookingApi, dao: BookingDao): BookingRepository =
        BookingRepositoryImpl(api, dao)

    @Provides @Singleton
    fun provideSearchFlightsUseCase(repo: FlightRepository): SearchFlightsUseCase =
        SearchFlightsUseCase(repo)

    @Provides @Singleton
    fun provideProfileCache(): ProfileCache = ProfileCache()

    @Provides @Singleton
    fun providePaymentManager(api: PaymentApi): PaymentManager = PaymentManager(api)

    // ... 75 tane daha
}

class PaymentManager(private val api: PaymentApi) {
    private var activity: Activity? = null
    private var bookingId: String? = null

    fun init(activity: Activity, bookingId: String) {   // çağırmayı unutursanız NPE
        this.activity = activity
        this.bookingId = bookingId
    }
}

class PriceFormatter {
    fun format(context: Context, amount: Money): String {
        val entryPoint = EntryPointAccessors.fromApplication(
            context.applicationContext, FormatterEntryPoint::class.java
        )
        return entryPoint.currencyRepository().format(amount)
    }
}

Bu kodda strawman yok; her satırın makul bir geçmişi var. @Singleton "garanti olsun" diye eklenmiş, init() "Activity lazım oldu" diye yazılmış, EntryPointAccessors "constructor'a parametre eklemek on dosyayı değiştiriyordu" diye girmiş. Sonuç dört ayrı hata sınıfı: yanlış ömür, sızan Activity, gizli bağımlılık ve kimsenin sahiplenmediği dev bir module. Sırayla gidelim.

Her şeyi @Singleton yapmak ömür kararını çöpe atar

Scope, bir nesnenin ne kadar yaşayacağına dair verdiğiniz karardır. Her binding'e @Singleton yazdığınızda bu kararı vermemiş olursunuz, sadece "process ölene kadar" seçeneğini varsayılan yaparsınız. Bunun üç somut bedeli var.

Birincisi bellek. SingletonComponent'teki scoped nesne process yok edilene kadar tutulur. Kullanıcının bir kez açtığı ödeme akışının yardımcı sınıfları, o akış kapandıktan saatler sonra hâlâ heap'tedir. Hilt dokümantasyonu da scope'un bedava olmadığını açıkça söyler: scoped binding üretilen kodun boyutunu ve çalışma zamanı maliyetini artırır, bu yüzden scope'u performans için değil doğruluk gerektirdiğinde kullanmanızı önerir. State tutmayan bir SearchFlightsUseCase için @Singleton hiçbir şey kazandırmaz.

İkincisi oturum ömrü. Yukarıdaki provideAuthInterceptor token'ı graph kurulurken bir kez okur. Kullanıcı çıkış yapıp başka hesapla girdiğinde interceptor hâlâ eski token'ı taşır. ProfileCache de aynı şekilde önceki kullanıcının adını ve kayıtlı kartlarını bellekte tutar. Logout hatalarının büyük kısmı bu kalıptan çıkar: kullanıcıya ait state, uygulama ömürlü nesnelere dağılmıştır ve logout sırasında hepsini tek tek temizlemeyi hatırlamak gerekir. Hatırlanmayan ilk cache, "çıkış yaptım ama eski hesabın rezervasyonlarını gördüm" başlıklı hata kaydına dönüşür.

Üçüncüsü test. Singleton state testler arasında taşınır; sıraya bağlı test hatalarının olağan şüphelisi budur. Singleton'ların genel maliyetini ayrı bir yazıda anlattık, burada container'ın bu maliyeti nasıl gizlediğine odaklanıyoruz.

Doğru component'i seçmek

Hilt'in component'leri ve ömürleri dokümantasyonda şöyle tanımlı:

Component Scope Oluşur Yok olur
SingletonComponent @Singleton Application#onCreate() Process yok edilince
ActivityRetainedComponent @ActivityRetainedScoped İlk Activity#onCreate() Son Activity#onDestroy()
ViewModelComponent @ViewModelScoped ViewModel oluşunca ViewModel yok olunca
ActivityComponent @ActivityScoped Activity#onCreate() Activity#onDestroy()
FragmentComponent @FragmentScoped Fragment#onAttach() Fragment#onDestroy()
ServiceComponent @ServiceScoped Service#onCreate() Service#onDestroy()

ActivityRetainedComponent configuration change'den sağ çıkar; ActivityComponent her döndürmede yeniden kurulur. Bizim kullandığımız karar sırası şu:

  1. Nesne state tutmuyorsa scope yazmayın. Unscoped binding her istekte yeni instance üretir ve çoğu use case, mapper, validator için doğru cevap budur.
  2. State bir ekranın işine aitse @ViewModelScoped. Rezervasyon taslağı, sihirbaz adımları arasında paylaşılan form state'i buraya girer.
  3. Aynı Activity altındaki birden fazla ViewModel aynı state'i paylaşıyorsa @ActivityRetainedScoped.
  4. Nesne bir Activity referansına ihtiyaç duyuyorsa @ActivityScoped ve ActivityComponent.
  5. @Singleton yalnızca gerçekten process ömürlü olanlara: OkHttpClient, Room database, analytics istemcisi, oturum deposu.

Kullanıcıya bağlı state için Hilt'te hazır bir "kullanıcı scope'u" yoktur. @DefineComponent ile özel component tanımlanabilir, fakat Hilt dokümantasyonu bunun bedellerini kendisi sayar: her yeni component zihinsel yük ekler, hiyerarşi tek ebeveynli olduğu için diamond bağımlılıklara sıkışabilirsiniz ve component'in ömrünü sizin yönetmeniz gerekir. Çoğu uygulamada daha sade bir kural yetiyor: kullanıcıya ait bütün state tek bir nesnenin altında yaşar ve onunla birlikte ölür.

data class Session(val userId: String, val accessToken: String)

@Singleton
class SessionStore @Inject constructor() {
    private val _session = MutableStateFlow<Session?>(null)
    val session: StateFlow<Session?> = _session.asStateFlow()

    fun start(session: Session) { _session.value = session }
    fun end() { _session.value = null }
}

class AuthInterceptor @Inject constructor(
    private val sessionStore: SessionStore,
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val token = sessionStore.session.value?.accessToken   // her istekte okunur
        val request = chain.request().newBuilder().apply {
            if (token != null) header("Authorization", "Bearer $token")
        }.build()
        return chain.proceed(request)
    }
}

Interceptor artık değeri yakalamıyor, kaynağı tanıyor. Kullanıcıya bağlı cache'ler de session akışını dinleyip null geldiğinde kendini boşaltır ya da anahtarına userId koyar. Logout tek satıra iner: sessionStore.end().

Activity ve Context yanlış scope'tan sızar

Hilt iki ayrı Context binding'i verir: @ApplicationContext ve @ActivityContext. İkincisi ActivityComponent'te yaşar, bu yüzden @Singleton bir sınıfın constructor'ına @ActivityContext yazarsanız graph derlenmez. Bu, scope sisteminin sizi koruduğu nadir anlardan biridir ve hata mesajını susturmanın yolu qualifier'ı değiştirmek olmamalı.

Sızıntı genelde container'ın göremediği yerden gelir: setter'dan. Yukarıdaki PaymentManager.init(activity, …) process ömürlü bir nesneye Activity yerleştirir. Ekran kapanır, Activity yok edilir, ama singleton onu tutmaya devam eder; view hiyerarşisiyle birlikte. LeakCanary bunu yakalar, ama kodu inceleyen kişi AppModule'e bakarak göremez.

Koin'de aynı hatanın daha sinsi bir biçimi var. single tanımına parametersOf ile Activity verirseniz, ilk çağrıdaki Activity saklanır ve sonraki çağrılardaki parametre yok sayılır, çünkü instance zaten oluşmuştur:

val paymentModule = module {
    // İlk Activity sonsuza kadar tutulur
    single { (activity: Activity) -> PaymentLauncher(activity, get()) }
}

Düzeltme iki framework'te de aynı fikre dayanır: nesnenin ömrü, tuttuğu şeyin ömründen uzun olamaz.

@ActivityScoped
class PaymentLauncher @Inject constructor(
    private val activity: Activity,              // ActivityComponent'in varsayılan binding'i
    private val paymentApi: PaymentApi,
)

class ReceiptStorage @Inject constructor(
    @ApplicationContext private val context: Context,   // dosya sistemi için uygulama context'i yeter
)

Pratik kural: tema, inflate, dialog ya da startActivity sonucu gerekiyorsa Activity gerekir ve sınıf ActivityComponent'e aittir. Dosya, tercih, veritabanı, sistem servisi için @ApplicationContext yeterlidir. Koin tarafında karşılığı androidContext() ile scope<…>/activityScope tanımlarıdır.

Kılık değiştirmiş service locator

Dependency injection'ın sağladığı tek şey şudur: bir sınıfın neye ihtiyacı olduğu constructor'ında yazar. Container'ı sınıfın ortasından çağırdığınız anda bu bilgi kaybolur ve elinizde bir service locator kalır, ne kadar modern bir kütüphaneyle yazılmış olursa olsun.

Hilt: her yerde EntryPointAccessors

Hilt dokümantasyonu entry point'in ne zaman gerektiğini dar tutar: Hilt'in desteklemediği Android bileşenleri ya da Dagger kullanmayan kütüphanelerle konuşurken. ContentProvider, bir kütüphanenin reflection ile oluşturduğu sınıf, WorkManager dışı bir callback buna örnektir. PriceFormatter gibi sıradan bir sınıf bu listede yer almaz. EntryPointAccessors çağrısı olan bir sınıfı test etmek için Hilt'i ayağa kaldırmanız gerekir ve sınıfın bağımlılıklarını öğrenmek için gövdesini okumanız gerekir.

Düzeltme sıkıcı ve doğru olandır:

class PriceFormatter @Inject constructor(
    private val currencyRepository: CurrencyRepository,
) {
    fun format(amount: Money): String = currencyRepository.format(amount)
}

Entry point gerçekten gerektiğinde dokümantasyonun önerisi, arayüzü ihtiyaç duyan sınıfın içinde tanımlamaktır. Paylaşılan, yirmi metotlu bir AppEntryPoint global değişkenin kibar adıdır.

Koin: domain sınıfında KoinComponent

Koin DSL'i service locator'a geçişi Hilt'ten de kolaylaştırır:

class BookingRepository : KoinComponent {
    private val api: BookingApi by inject()
    private val dao: BookingDao by inject()

    suspend fun confirm(draft: BookingDraft): Booking {
        val pricing: PricingEngine = get()      // metodun ortasında çözülüyor
        return api.confirm(draft.toRequest(pricing.quote(draft)))
    }
}

Bu sınıf artık Koin'e bağımlıdır, test etmek için startKoin gerekir ve PricingEngine tanımı eksikse bunu confirm() ilk çağrıldığında, yani kullanıcı "Onayla"ya bastığında öğrenirsiniz. Koin'in kendi dokümantasyonu da aynı şeyi söyler: KoinComponent, Koin'in oluşturmadığı sınıflar içindir (framework'ün örneklediği giriş noktaları, BroadcastReceiver, kontrol etmediğiniz callback'ler) ve kenarlarda tutulmalıdır; altındaki her şey constructor injection kullanmalıdır.

class BookingRepository(
    private val api: BookingApi,
    private val dao: BookingDao,
    private val pricing: PricingEngine,
)

val bookingModule = module {
    singleOf(::BookingRepository)
}

Koin'de graph'i çalıştırmadan doğrulamak

Dagger eksik binding'i derleme hatasına çevirir. Koin'de bu güvence varsayılan olarak yoktur, sizin eklemeniz gerekir. Güncel yol verify() API'sidir; eski checkModules() ve checkKoinModules() deprecated durumda.

class KoinGraphTest {
    @Test
    fun `bütün tanımların bağımlılıkları mevcut`() {
        appModule.verify(
            extraTypes = listOf(HttpClientEngine::class)   // çalışma zamanında verilen tipler
        )
    }
}

verify() tanımları örneklemez; her tanımın constructor'ını okur ve her parametre tipi için module ağacında bir tanım olup olmadığına bakar. Android projelerinde koin-android-test içindeki androidVerify(), Context, Activity, SavedStateHandle gibi tipleri kendiliğinden ekler. Sınırını da bilmek gerekir: dokümantasyona göre verify() yalnızca constructor'ları okur; qualifier'ları ve bir tanım lambda'sının ya da sınıf gövdesinin içine yazılmış get() çağrılarını kontrol etmez. Yani service locator kullanımı, elinizdeki tek doğrulama aracını da kör eder.

Koin 4.2 ile gelen Koin Compiler Plugin bu boşluğu derleme zamanında kapatmayı hedefliyor: eksik tanımları, qualifier uyuşmazlıklarını ve get()/inject() çağrı noktalarını build sırasında denetler. Kotlin 2.3.20 ve üstünü (K2) şart koşar. Yeni bir compiler plugin'i olduğu için Kotlin sürümünüzle uyumunu kendi projenizde denemeden CI'ı ona emanet etmeyin; o zamana kadar verify() testi CI'da zorunlu adım olmalı.

Tanrı module'ü parçalamak

80 @Provides'lı AppModule'ün asıl sorunu sahipsiz olmasıdır. Her feature ekibi aynı dosyaya dokunur, her merge'de çakışma çıkar ve dosya bütün feature'ların implementasyon sınıflarını import ettiği için hiçbir feature bağımsız derlenemez.

İçindeki @Provides'ların büyük kısmı da gereksizdir. Kuralımız üç satır:

  • Sınıf sizinse ve constructor'ı varsa: @Inject constructor. Module'e hiçbir şey yazılmaz.
  • Bir arayüzü implementasyona bağlıyorsanız: @Binds.
  • Tip sizin değilse ya da kurulumu builder gerektiriyorsa (Retrofit, Room, OkHttpClient): @Provides.
// Önce: elle yazılmış fabrika
@Provides @Singleton
fun provideBookingRepository(api: BookingApi, dao: BookingDao): BookingRepository =
    BookingRepositoryImpl(api, dao)

// Sonra
class BookingRepositoryImpl @Inject constructor(
    private val api: BookingApi,
    private val dao: BookingDao,
) : BookingRepository

@Module
@InstallIn(SingletonComponent::class)
internal abstract class BookingDataModule {
    @Binds
    abstract fun bindBookingRepository(impl: BookingRepositoryImpl): BookingRepository
}

@Provides versiyonunda constructor'a parametre eklediğinizde iki dosya değişir; @Binds versiyonunda yalnızca sınıfın kendisi. Dagger tarafında da @Binds için ayrı bir fabrika gövdesi üretmeye gerek kalmaz.

Aynı temizlikte field injection'ı da ele alın. @AndroidEntryPoint ile işaretli Activity, Fragment ve Service için @Inject lateinit var kaçınılmazdır, çünkü bu sınıfları framework oluşturur. Onların dışındaki her sınıfta lateinit enjeksiyon, bağımlılığı constructor'dan gizler, val olabilecek alanı var yapar ve nesneyi "oluşturuldu ama henüz kullanılamaz" hâlinde bırakır.

Çoklu module'de api/impl ayrımı

Tek AppModule parçalandığında parçaların nereye gideceği sorusu çıkar. Cevabımız: her binding, implementasyonunun yaşadığı Gradle module'üne gider.

:app                      → bütün impl module'lerine bağımlı, @HiltAndroidApp burada
:feature:booking:api      → BookingRepository, BookingNavigator (arayüzler, modeller)
:feature:booking:impl     → BookingRepositoryImpl, ekranlar, BookingDataModule (@InstallIn)
:feature:payment:api
:feature:payment:impl     → :feature:booking:api'ye bağımlı, :booking:impl'e DEĞİL

@InstallIn taşıyan module impl içinde ve internal kalır. :feature:payment:impl, rezervasyonu BookingRepository arayüzü üzerinden tanır; BookingRepositoryImpl sınıfını göremez, dolayısıyla ona bağımlı olamaz. Feature'lar birbirinin implementasyonunu gördüğünde iki şey olur: booking:impl içindeki her değişiklik payment:impl'i yeniden derletir ve bir gün biri "nasılsa erişebiliyorum" diyerek arayüzü atlayıp somut sınıfı kullanır. Graph'i birleştirmek :app'in işidir. Hilt'in gereksinimi de tam olarak budur: Application sınıfını derleyen Gradle module'ü, Hilt kullanan bütün module'leri transitive bağımlılıklarında görmelidir.

Bu ayrımın bedeli vardır: module sayısı ikiye katlanır. Üç feature'lı bir uygulamada bu bedeli ödemeyin; ayrımın ne zaman kendini amorti ettiğini modülerleşmenin maliyeti yazısında tartıştık.

Build tarafı: kapt yerine KSP

Dagger dokümantasyonuna göre KSP desteği Dagger 2.60 ve KSP 2.3.9 ile birlikte stable. kapt, Kotlin kodundan Java stub'ları üretip Java annotation processor'larını bu stub'lar üzerinde çalıştırır; bu da her module'de ek bir derleme adımı demektir. Yeni kurulumda kapt kullanmanın bir gerekçesi kalmadı.

// feature/booking/impl/build.gradle.kts
plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.ksp)
    alias(libs.plugins.hilt)            // com.google.dagger.hilt.android
}

dependencies {
    api(projects.feature.booking.api)
    implementation(libs.hilt.android)   // com.google.dagger:hilt-android:2.60.1
    ksp(libs.hilt.compiler)             // com.google.dagger:hilt-compiler:2.60.1
}

Geçişte dikkat edilecek üç nokta var. Hilt Gradle plugin'i 2.59'dan itibaren AGP 9 ister; AGP 8'de kalan projeler önce onu çözmeli. KSP processor'ları, başka bir kapt processor'ının ürettiği tipleri göremez; Dagger'ın incelediği sınıfları üreten diğer processor'lar da (Room, Moshi codegen gibi) KSP'ye taşınmalıdır. Son olarak geçişi module module yapın ve kazancı kendi projenizde ölçün: ./gradlew :app:assembleDebug --scan çıktısında kapt* ve ksp* task sürelerini geçişten önce ve sonra karşılaştırmak yeterlidir.

Çalışma zamanı parametreleri için assisted injection

PaymentManager.init(activity, bookingId) kalıbı, container'ın bilmediği bir değeri sonradan içeri sokmanın yoludur. Sorunu şudur: nesne iki aşamada kurulur ve ikinci aşamayı derleyici zorunlu tutmaz. Dagger'ın assisted injection'ı aynı ihtiyacı constructor'da çözer.

@HiltViewModel(assistedFactory = BookingDetailViewModel.Factory::class)
class BookingDetailViewModel @AssistedInject constructor(
    @Assisted private val bookingId: String,
    private val repository: BookingRepository,
) : ViewModel() {

    @AssistedFactory
    interface Factory {
        fun create(bookingId: String): BookingDetailViewModel
    }
}

// Fragment içinde
private val viewModel by viewModels<BookingDetailViewModel>(
    extrasProducer = {
        defaultViewModelCreationExtras.withCreationCallback<BookingDetailViewModel.Factory> { factory ->
            factory.create(requireArguments().getString("bookingId")!!)
        }
    }
)

bookingId artık val, null olamaz ve onsuz ViewModel oluşturmak mümkün değil. Compose tarafında hiltViewModel'in creation callback alan overload'ı aynı işi görür; artifact adı androidx.hilt sürümleri arasında değiştiği için kendi sürümünüzde kontrol edin.

Üç kısıtı bilin. @AssistedInject tipleri scope alamaz ve doğrudan enjekte edilemez, yalnızca fabrikaları enjekte edilir. Aynı tipte iki assisted parametre varsa @Assisted("ad") ile hem constructor'da hem fabrika metodunda ayırmanız gerekir. Hilt dokümantasyonu da ViewModel fabrikasından Activity, Fragment ya da View geçirmemeniz konusunda uyarır; bir önceki bölümdeki sızıntının assisted versiyonu olur. Navigasyon argümanı gibi process ölümünden sonra da gereken değerler için SavedStateHandle hâlâ daha doğru adrestir, çünkü assisted parametreler kalıcı değildir.

Koin'de karşılığı parametersOf ile factory/viewModel tanımıdır; single ile kullanmayın.

Testlerin çoğu DI görmemeli

Constructor injection'ın en az konuşulan faydası, testlerde container'a ihtiyaç bırakmamasıdır:

class BookingRepositoryImplTest {
    private val api = FakeBookingApi()
    private val dao = InMemoryBookingDao()
    private val repository = BookingRepositoryImpl(api, dao)

    @Test
    fun `onaylanan rezervasyon yerel veritabanına yazılır`() = runTest {
        val booking = repository.confirm(sampleDraft)
        assertEquals(booking, dao.find(booking.id))
    }
}

Hilt yok, Robolectric yok, test runner ayarı yok. Unit testlerinizde @HiltAndroidTest görüyorsak, bunu test edilen sınıfın bağımlılıklarını constructor'dan almadığının işareti sayarız.

Hilt'e gerçekten ihtiyaç duyan testler, graph'in kendisini kullanan UI ve entegrasyon testleridir. Orada binding değiştirmenin önerilen yolu @TestInstallIn'dir:

@Module
@TestInstallIn(
    components = [SingletonComponent::class],
    replaces = [BookingDataModule::class],
)
abstract class FakeBookingDataModule {
    @Binds
    abstract fun bindBookingRepository(fake: FakeBookingRepository): BookingRepository
}

Bu module test source set'indeki bütün testler için geçerlidir. @UninstallModules ve @BindValue tek bir teste özel değişiklik yapmanızı sağlar, ama dokümantasyonun da belirttiği gibi bunları kullanan her test sınıfı için ayrı bir component üretilir ve build süresi uzar. Varsayılanımız @TestInstallIn; @BindValue'yu gerçekten o teste özel bir sahte gerektiğinde kullanıyoruz. Burada küçük module'lerin bir faydası daha ortaya çıkar: replaces module bazında çalışır, yani 80 binding'lik AppModule'ü değiştirmek için 80 binding'i yeniden yazmanız gerekir.

Metro ve diğer derleme zamanı seçenekleri

Dagger ekosisteminin etrafı son iki yılda hareketlendi, bu yüzden kısa bir durum tespiti yapalım. Square'in Anvil'i deprecated: README'sine göre yalnızca K1 için desteklenecek ve K2 zorunlu olduğunda geliştirme bitecek. kotlin-inject aktif, ama hâlâ 0.x sürüm numarası taşıyor (yazı tarihinde 0.9.0). Metro, Dagger, Anvil ve kotlin-inject fikirlerini tek bir Kotlin compiler plugin'inde birleştiriyor; kapt ya da KSP gerektirmiyor, Dagger interop'u sunuyor ve 1.x sürümlerine ulaştı (yazı tarihinde 1.4.5). Compiler plugin olduğu için desteklediği Kotlin sürüm aralığına bağlısınız; Kotlin yükseltmelerinizi Metro'nun uyumluluk tablosuyla birlikte planlamanız gerekir.

Görüşümüz şu: çalışan bir Hilt kurulumunuz varsa ve sorununuz bu yazıdaki anti-pattern'lerse, araç değiştirmek hiçbirini çözmez. Her şeyi @SingleIn(AppScope::class) yapmak, her şeyi @Singleton yapmakla aynı hatadır. Önce graph'i düzeltin; Kotlin Multiplatform ya da build süresi gibi somut bir gerekçeniz varsa geçişi ondan sonra değerlendirin.

Adım adım toparlama planı

Bu işi tek bir büyük PR'la yapmayın. Her adım kendi başına yayınlanabilir olmalı.

  1. Envanter çıkarın. grep -rc "@Singleton", EntryPointAccessors, KoinComponent, by inject() ve Android sınıfları dışındaki @Inject lateinit var sayılarını bir tabloya yazın. Bu sayılar ilerlemeyi ölçeceğiniz tek metriktir.
  2. State tutmayanlardan scope'u kaldırın. Use case, mapper, formatter, validator. Risk düşüktür; davranış değişmez, yalnızca ömür kısalır. Kalan her @Singleton için "neden process ömürlü" sorusuna tek cümlelik cevap yazabilmelisiniz.
  3. Oturum state'ini tek yere toplayın. SessionStore benzeri bir kaynak kurun, token'ı ve kullanıcıya bağlı cache'leri ona bağlayın. Logout sonrası heap dump'ta önceki kullanıcıya ait nesne kalmadığını doğrulayın.
  4. @Provides yerine @Inject constructor ve @Binds. Mekanik bir dönüşümdür; module başına bir PR yeter.
  5. Service locator çağrılarını kenarlara itin. EntryPointAccessors ve KoinComponent yalnızca framework'ün oluşturduğu sınıflarda kalsın. Koin kullanıyorsanız bu adımın sonunda verify() testini CI'a ekleyin.
  6. init() metotlarını assisted injection'a çevirin ve Activity tutan sınıfları ActivityComponent'e taşıyın. LeakCanary'yi debug build'de açık tutun.
  7. AppModule'ü sahiplerine dağıtın. Önce aynı Gradle module'ü içinde dosyalara bölün, sonra api/impl ayrımı yapan feature'larda impl'e taşıyın.
  8. kapt'tan KSP'ye geçin ve build scan ile süreyi karşılaştırın.

İşe yaradığını nasıl anlarsınız: envanterdeki sayılar düşer, unit testlerin çoğu Hilt ya da Koin başlatmadan çalışır, logout sonrası heap temizdir ve bir sınıfın bağımlılıklarını öğrenmek için yalnızca constructor'ına bakmak yeter.

Bir sınıfın neye ihtiyacı olduğunu constructor'ından okuyamıyorsanız, elinizdeki şey annotation eklenmiş global değişkenlerdir.

Kontrol listesi

  • Her @Singleton için process ömrünü gerektiren somut bir gerekçe var; state tutmayan sınıflar unscoped.
  • Kullanıcıya ait state tek bir oturum kaynağına bağlı; logout tek çağrıyla temizliyor.
  • Activity tutan hiçbir sınıf SingletonComponent'te ya da Koin single tanımında yaşamıyor; uzun ömürlü sınıflar @ApplicationContext kullanıyor.
  • EntryPointAccessors ve KoinComponent yalnızca framework'ün oluşturduğu sınıflarda; domain ve data sınıflarında yok.
  • Koin projelerinde verify() (ya da Compiler Plugin denetimi) CI'da zorunlu.
  • Kendi sınıflarımız @Inject constructor, arayüzler @Binds, @Provides yalnızca üçüncü parti tipler için.
  • Field injection yalnızca @AndroidEntryPoint sınıflarında.
  • Hiçbir module dosyası birden fazla feature'ın binding'ini taşımıyor; @InstallIn module'leri impl içinde ve internal.
  • Feature impl module'leri birbirine bağımlı değil, yalnızca api module'lerine bağımlı.
  • Çalışma zamanı parametreleri init() yerine @AssistedInject ya da SavedStateHandle ile geliyor.
  • Unit testler container başlatmıyor; Hilt testlerinde varsayılan yöntem @TestInstallIn.
  • Annotation processing KSP ile çalışıyor; kapt kalan module yok.
Seviye 3 tamamlandıİlerlemen bu tarayıcıda saklanıyor.
Sıradaki: BookingFlowContext gibi global state object'leri neden tehlikelidir?

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.