Dependency Injection with Dagger-Hilt
Herkese selamlar, yeni bir yazı ile tekrar karşınızdayım. İlk zamanlarda aklımı çok karıştıran, konu içerisine girip daha fazla bilgi edindikten sonra gerçekten bizi birçok dertten kurtardığını anladığım Dependency Injection(D.I.) nedir? Android özelinde Hilt ile bunu nasıl yapıyoruz elimden geldiğince anlatmaya çalışacağım.
Dependency Injection SOLID‘ in 5.ayağını oluşturan “Dependency Inversion” prensibinin uygulanmasını içeren bir patterndir.
Bu pattern temel olarak bağımlılıkların kontrolünü ve yönetimini sağlamaktadır.
Dependency Injection’ın kelime anlamına bakarsak; Bağımlılık Enjeksiyonu gibi pek de anlam veremediğimiz bir karşılığa denk geldiğini görürüz. Bir şeyi bir yere enjekte ediyoruz ama nedir bu bağımlılık? Burada bahsedilen Dependency yani “Bağımlılık”; belirli bir sınıfın bağlı olduğu nesnelerdir. “Injection” ise bağımlı olunan nesnenin bu nesneyi kullanacak olan class’a iletilmesidir.
Özetle Dependency Injection, yazılım bileşenlerinin bağımlılıklarını dışarıdan almasını ve enjekte etmesini sağlayan bir patterndir. Bu pattern, daha esnek, test edilebilir ve sürdürülebilir kod yazmayı destekler.
Bunu daha genel bir şekilde günlük hayattan anlatmak istersek bir bilgisayarın düzgün bir şekilde çalışması için ihtiyacı olan parçaları gibidir. Bunlar işlemci(CPU), bellek(RAM), dahili depolama(SSD ya da HDD) veya ekran kartıdır(GPU). Tanımladığımız bu her bir bileşen bilgisayarın bağımlılıklarıdır. Çünkü bilgisayar bu bileşenlere bağlıdır onlar olmadan düzgün çalışmaz. Bu bilgisayarı bir programlama dilinin bir Class’ı olarak düşünürsek;
class Computer(){
private val cpu = CPU("Intel i9 13900k")
private val ram = RAM(64)
private val ssd = SSD(1024)
private val gpu = GPU("RTX4090")
}
Bilgisayar sınıfının içerisinde bileşenleri bu şekilde oluşturabiliriz ancak bunu yaparsak oluşturduğumuz her bilgisayar nesnesi aynı özelliklere sahip olur ve örneğin ssd’yi değiştirirsek bu özellik tüm bilgisayarlarda değişir. Pratik olarak bu çok kötü bir kullanımdır. Çünkü bu değişimin sonucunda Class’ı kullandığımız her yerde SSD ile yaptığımız işlemlerin sonuçları değişeceğinden bu kısımları yeniden hesaplamak gerekir.
Örneğin 2–3 ekranlı kişisel bir projemizde ilgili yeri değiştirmek çok vaktimizi almayabilir ancak profesyonel bir projede yüzlerce yerde kullanım yapmış olabiliriz ve her birini tek tek değiştirmek, oluşan hatayı düzeltmek ciddi vakit ve kaynak harcaması demektir. İşte Dependency Injection’ın çözdüğü sorun budur. Birbirine sıkı sıkıya bağlı(tightly coupled) olan bu classları daha az bağımlı hale(loosely coupled) getiririz.
Constructor Injecton bunu sağlamak için kullanılan D.I. yöntemlerinden birisidir. Yukarıda oluşturduğumuz class’ı buna uygun olması için şu şekilde düzenleriz:
class Computer (
private val cpu: CPU,
private val ram: RAM,
private val ssd: SSD,
private val gpu: GPU
) {
}
Bu kullanım bilgisayar bileşenlerini; her bir bilgisayar için farklı bileşenler olacak şekilde tanımlayabilmemize izin verir. Bu yöntem çok daha esnek bir yöntemdir çünkü bilgisayarımızı farklı ekran kartı modelleriyle kullanmak ya da test etmek istediğimizde bunu yapabilmemizi sağlar. Bu kullanım sayesinde oluşturduğumuz her nesne bir diğerinin aynısı olmak zorunda değildir. Hangi marka ekran kartı seçeceğimize kendimiz karar verebiliriz, keza diğer özellikler için de aynısı geçerlidir.
fun main() {
val firstDevice = Computer(cpu = CPU("i9 12900k"),
ram = RAM(32),
ssd = SSD(512),
gpu = GPU("RTX3070"))
}
Bununla birlikte constructor injection dışında D.I. yapmak için farklı yöntemler de vardır. Inject ettiğimiz ilgili nesne, lifecycle içerisinde ne kadar hayatta kalacak? Singleton olarak mı tutulacak yoksa fragment yaşam döngüsü sona erdiğinde ilgili nesne öldürülecek mi? Bunların hepsinin kararını verebiliriz.
Bu ihtiyacımızın sebebini bilgisayar örneği ile açıklayacak olursak; mesela ofiste çalışırken veya oyun oynamadığımız zamanlar ekran kartına o kadar çok ihtiyaç duymayız. Ekran kartı bu durumlarda oyun içinde olduğu kadar çok çalışmaz. D.I. ile ekran kartının ne zaman çalışıp ne zaman görevinin son bulacağına karar verebiliyoruz, projemiz içerisinde bu durum gereksiz memory israfını önleyip uygulamamızın daha performanslı çalışmasını sağlar.
#D.I. kullanmamızın bazı avantajları:
- Bir bileşenin(sınıf veya modül) başka bir bileşeni doğrudan yaratması yerine, dışarıdan bağımlılıklarını almasını ve enjekte etmesini sağlar.
- Bileşenlerin birbirleriyle sıkı bir şekilde bağlanmasını engeller ve bileşenlerin daha kolay test edilmesini ve değiştirilmesini sağlar. Ayrıca, kodun daha temiz, sürdürülebilir ve yeniden kullanılabilir olmasına yardımcı olur. Bu sayede kodu daha kolay refactor edebiliriz.
Manuel olarak D.I. yapabiliyorum, neden Dagger-Hilt kullanımına ihtiyaç duyayım?
Hilt, Google tarafından geliştirilen ve Jetpack’in bir parçası olan, Dagger üzerine inşa edilmiş bir Dependency Injection kütüphanesidir. Dagger’ın öğrenme ve projeye uygulama süreçlerini kolaylaştırarak, aynı zamanda boilerplate kodları azaltarak ihtiyaç duyulan bir çözüm sunar.
Evet manuel olarak D.I. yapabiliriz. Ancak Hilt, Koin gibi D.I. yapmak için kullanılan kütüphanelerin bazı avantajları vardır. Bu yazı Hilt özelinde olduğundan daha çok ona odaklanacağım. Hilt kullanımının bazı avantajları:
- Hilt, D.I. işlemini kolaylaştırmak için birçok otomatikleştirilmiş özellik sunar, bileşenlerin ve bağımlılıklarının oluşturulmasını ve yönetilmesini büyük ölçüde basitleştirir. Böylece, daha az tekrar eden kod yazarız.
- Bağımlılıkların doğru bir şekilde inject edilmesi için derleme zamanı hata kontrolü yapar. Bu sayede, compile time safety sağlamış olur. Kod hatası varsa daha compile time’da görebiliriz.
- Uygulamamızın modüler bir şekilde tasarlanmasını ve büyümesini kolaylaştırır. Bileşenlerin ve bağımlılıkların doğru bir şekilde organize edilmesini ve yönetilmesini sağlar. Böylece, uygulamanızı daha kolay bir şekilde parçalara ayırabilir ve farklı modüller arasında bağımlılıkları daha rahat yönetebiliriz.
- Hilt; test yazımını kolaylaştırmak için tasarlanmıştır. Testlerde mock nesnelerini(mock: gerçek nesnelerin yerine geçen ve belirli bir davranışı taklit eden yapay bir nesnedir) kullanarak bağımlılıkları taklit edebilir ve testlerinizi daha izole bir şekilde gerçekleştirebilirsiniz.
Bazı dezavantajları:
- Öğrenme eğrisi yüksektir.
- Arka planda generate ettiği classlar ve otomatik olarak yaptığı işlemler vardır. Bu süreç projenin derlenme süresini uzatabilir, ancak run time performansı yüksektir.
- Projeye ek bir karmaşıklık getirebilir
Hilt Annotations:
Hilt kullanırken oluşacak nesneleri inject etmek, nesne Android Lifecycle içerisinde ne kadar bir life time’a sahip olacak, nesne nasıl oluşturulacak gibi şeyleri belirtmek için Annotation’lar kullanıyoruz. Bu sayede kütüphane nesne oluştururken nelere dikkat etmesi gerektiğini anlıyor.
Bunlar:
-@HiltAndroidApp: Hilt kullanan tüm uygulamalar bu annotation’ı eklemelidir. Bu annotation Hilt’in çalışması için gerekli olan code’ları generate eder. Kütüphaneyi bu annotation ile ayağa kaldırıyoruz gibi düşünebiliriz. Oluşturulan bu Hilt componenti, Uygulama nesnesinin yaşam döngüsüne eklenir ve ona bağımlılıklar sağlar.
@HiltAndroidApp
class ExampleApplication : Application() { ... }
-@AndroidEntryPoint: Bağımlılıkları Android Sınıflarına inject etmek için kullanılır. Bu annotation’ı eklediğimiz Android Classları içerisine inject edebileceğimiz nesneler olduğu anlamına gelir.
@AndroidEntryPoint
class ExampleActivity : AppCompatActivity() { ... }
Hilt’in desteklediği Android Sınıfları:
- Application (@HiltAndroidApp kullanarak)
- ViewModel (@HiltViewModel kullanarak)
- Activity
- Fragment
- View
- Service
- BroadcastReceiver
@AndroidEntryPoint kullandığımız bir fragment’ınız varsa bu fragment’ın bağlı olduğu activity’i de bu annotation ile işaretlemeniz gerektiğini unutmayın.
@AndroidEntryPoint, projenizdeki her Android sınıfı için ayrı bir Hilt componenti oluşturur. Bu componentler, component hiyerarşisinde ilgili üst sınıflarından bağımlılıklar alabilir.
-@Inject: Bağımlı olan class’a bu annotation ile; ‘oluşturulacak olan nesneyi’ paslamak için yani inject etmek için kullanırız. Constructor’da yapmıyorsak buna field injection denir.
@AndroidEntryPoint
class HomeFragment : Fragment(R.layout.fragment_home) {
@Inject lateinit var defaultLocationClient: DefaultLocationClient
...
}
Not: Field injection yaparak oluşturmaya çalıştığımız nesneler private olamaz. Bu şekilde bir kullanım yapmaya çalışırsak derleyici hatası alırız.
@Inject annotation’ını sadece field injection olarak değil constructor injection olarak da kullanabiliriz.
-@HiltViewModel: Hilt kullanmadığımız bir projede ViewModel clasımıza constructor injection yapmak için özel bir fabrika sınıfı olan ViewModelProvider
‘ın Factory’si kullanılır. Bu fabrika sınıfı, ViewModel'in bağımlılıklarını enjekte etmek için gerekli nesneleri oluşturur ve ViewModel'in doğru bir şekilde oluşturulmasını sağlar.
class DetailsViewModelFactory(private val repository: Repository) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
if(modelClass.isAssignableFrom(DetailsViewModel::class.java)){
return DetailsViewModel(repository) as T
}else{
throw IllegalStateException("Can not create instance of detailsViewModel")
}
}
}
// fragment/activity içerisinde
val repository = Repository()
val detailsViewModelFactory = DetailsViewModelFactory(repository)
detailsViewModel = ViewModelProvider(this, detailsViewModelFactory)[DetailsViewModel::class.java]
@HiltViewModel
annotasyonunu kullanarak, bu süreci Hilt bizim için otomatik olarak yapar ve böylece boilerplate kodlardan kurtuluruz.
@HiltViewModel
class DetailsViewModel @Inject constructor(
private val repository: Repository
) : ViewModel() {
...
}
-@Module: Bağımlılık duyulan nesnenin nasıl oluşturulacağını belirttiğimiz classların başına eklenir.
-@InstallIn: Bu annotation modüle class’ımızın ne tür bir komponent olduğunu belirttiğimiz yerdir. Örneğin SingletonComponent gibi.
-@Provides: Direkt olarak constructor injection yaparak nesnesini inject edemediğimiz type’lar olabilir. Hilt’e bir nesneyi neleri kullanarak oluşturmasını gerektiğini belirttiğimiz modül içerisindeki fonksiyonlarımızın başına yazdığımız annotationdır.
Module, InstallIn ve Provides annotationları bir kod örneğiyle görelim:
@Module
@InstallIn(SingletonComponent::class)
object MyModule {
@Provides
@Singleton
fun provideDependency(): MyDependency {
return MyDependency()
}
}
Burada @InstallIn içerisinde SingletonComponent::class kullanarak, modülün bir Singleton bileşenine bağlı olacağını belirtmiş olursunuz. Bu, modülün yalnızca bir kez oluşturulacağı ve uygulamanın yaşam döngüsü boyunca aynı örneğin paylaşılacağı anlamına gelir.
@Provides ile istenilen Dependency’nin yazılan fonksiyon ile hilt’e nasıl oluşturmasını gerektiğini söylemiş oluruz.
Şimdi burada fonksiyon üzerinde 2. Kere @Singleton yazmamızın amacı nedir? Install’in içerisinde bu modülün bir Singleton bileşenine bağlı olacağını söylemiştik diyebilirsiniz. Bu kullanımın sebebi:
Hilt modülünde @Singleton annotation’ını sadece modül seviyesinde kullanmak yeterli değildir. Modül içindeki her fonksiyon, ayrı ayrı bağımlılıkları sağladığı için, @Singleton annotation’ını ayrıca provideDependency() fonksiyonuna da eklemeniz gerekir.
NOT: @Singleton annotation’ı, ‘bir bağımlılığın’ uygulama içerisinde herhangi bir ‘T’ anında tek bir nesnesinin olmasını sağlamak için kullanılan bir Dagger/Hilt annotation’ıdır.
Burada Module’ü değiştirerek @InstallIn(FragmentComponent::class) yapıp; istenilen nesneyide fonksiyon üzerinde @Singleton yerine @FragmentScope olarak da tanımlayabiliriz. Bu kullanım bize ihtiyaç duyulan bağımlılığın uygulama lifecycle’ı içerisinde lifetime’ının ne olacağını belirtmemize yarar. Aynı bilgisayar örneğinde söylediğim gibi.
-@Binds: Bir arayüzü yani bir interface’i implement edildiği class’a bağlamak için kullanılır. Bu sayede interface’in concrete class’ının ne olduğu hilt tarafından bilinir. İnterface’i constructor injection veya field injection kullanarak başka bir class’a inject edersek bu sayede metodlarını kullanabiliriz.
Logger adında basit loglama işlemleri yaptığımız bir interface’imiz olsun. Bunun Hilt ile kullanımına bir bakalım:
interface Logger {
fun log (message:String?)
}
class LoggerImpl @Inject constructor(): Logger {
override fun log(message: String?) {
println("Logger: $message")
}
}
@Module
@InstallIn(ActivityComponent::class)
abstract class LoggerModule {
@Binds
@ActivityScoped
abstract fun bindLogger(loggerImpl : LoggerImpl): Logger
}
// activity içerisinde
@Inject
lateinit var logger: Logger
override fun onCreate(savedInstanceState: Bundle?) {
logger.log("onCreateCalled")
super.onCreate(savedInstanceState)
…
}
Örneğin Hilt modülü kullanarak aynı interface’i implement etmiş 2 farklı class’ı inject etmek istiyorsam ne yapmalıyım?
Bu durumda bir karışıklık olacağını da düşünen yaradanına gurban olduklarım çözüm olarak annotation class çıkartmışlar.
Modül içerisinde aynı tipi döndüren 2 farklı provider tanımlayıp injection yapmak istiyorsak bu tipleri kendi oluşturduğumuz annotationlar ile sap ile samanı ayırır gibi ayırmamız gerekiyor.
Bu annotationlar ve ne işe yaradıklarına bakalım:
- @Qualifier: Dagger/Hilt, bağımlılıkları çözerken, aynı tip ve aynı scope’a sahip farklı bağımlılıkları ayırt etmek için @Qualifier annotation’ını kullanır.
- @Retention: Bir annotation’ın ne zaman korunacağını belirtmek için kullanılan enum değerleridir. Bu enum değerleri, @Retention annotation’ına parametre olarak geçirilir.
AnnotationRetention.SOURCE: Annotation’ın yalnızca kaynak kodunda korunacağını ifade eder.
AnnotationRetention.BINARY: Annotation’ın derleme sürecinde korunacağını ifade eder.
AnnotationRetention.RUNTIME: Annotation’ın hem derleme sürecinde hem de çalışma zamanında korunacağını ifade eder.
Qualifier ve Retention annotationlarını kod örneği üzerinde görmek için Animal interface’ini implement etmiş Kedi ve Köpek classlarını Hilt modülü kullanarak oluşturalım.
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class Cat
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
annotation class Dog
interface Animal {
fun makeSound()
}
class Cat @Inject constructor() : Animal {
override fun makeSound() {
println("Meow!")
}
}
class Dog @Inject constructor() : Animal {
override fun makeSound() {
println("Woof!")
}
}
@Module
@InstallIn(FragmentComponent::class)
object AnimalModule {
@Provides
@FragmentScoped
@Cat
fun provideCat(): Animal {
return Cat()
}
@Provides
@FragmentScoped
@Dog
fun provideDog(): Animal {
return Dog()
}
}
class MyFragment : Fragment() {
@Inject
@Cat
lateinit var cat: Animal
@Inject
@Dog
lateinit var dog: Animal
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
cat.makeSound() // output : Meow!
dog.makeSound() // output : Woof!
}
}
Son olarak:
D.I. Sürecini otomatikleştirmeyi sağlayan kütüphaneleri 2 kategoriye ayırabiliriz:
- Run Time’da bağımlılıkları birbirine bağlayan
- Compile time’da bağımlılıkları birbirine bağlayan
En popüler kütüphanelerden olan Hilt ile Koin’in bazı farkları:
- Hilt bağımlılıkların yönetimini compile time’da gerçekleştirir. Bağımlılıklar compile time’da oluşturulduğundan bu işlem compile time’ı uzatır. Uygulamanın derlenmesi Koin’e göre daha uzun sürer ancak bunun karşılığında compile time safety sağlar. Bu da hatanın erken tespiti için önemlidir.
- Koin ise bağımlılıkların yönetimini Run Time’da yapar. Bir hata olursa bunu Run Time’da görürüz ancak derleme süresi daha kısadır.
- Hilt, Dependency Injection Pattern kullanır
- Koin ise Service Locator Pattern kullanır.
- Hilt, Google tarafından resmi olarak desteklenen bir kütüphanedir. Android Jetpack kapsamındadır.
- Koin, JetBrains tarafından geliştirilmiştir. Kotlin projeleri için bir bağımlılık enjeksiyon çözümü olarak sunulmaktadır.
Bu yazım burada son buluyor. Bir hata gördüyseniz veya görüş/önerileriniz varsa lütfen linkedIn üzerinden mesaj atıp benimle iletişime geçmekten çekinmeyin :). Başka bir yazıda görüşmek üzere.
Kaynak:
https://developer.android.com/training/dependency-injection/hilt-android