Itt jársz most: Kezdőlap > Alkalmazásfejlesztés > Lightweight dependency injection: a Dagger használata Android alatt

Szűrő megjelenítése

Lightweight dependency injection: a Dagger használata Android alatt

Az Android SDK-ja - bár Java alapú, illetve most már sokkal inkább a Kotlin az Android natív nyelve - a rendszer szükségleteinek megfelelően lett kialakítva, így számos olyan package hiányzik belőle (például a java.beans csak részlegesen található meg), melyre a Spring alapvetően támaszkodik. Így tehát komoly problémákba fogunk ütközni az alkalmazás fordítása/indítása során. A Spring-nek egyébként is csak egy pici része a DI, így ha másra nincs szükségünk, érdemes más eszköz után néznünk. Szerencsére a Dagger nevű DI framework fejlesztői gondoltak az Android fejlesztőkre is, így a Dagger teljes Android támogatással és nyílt forráskóddal érhető el, maga a Google szárnyai alatt.

dependencies {
    
    // ...
 
    implementation "com.google.dagger:dagger:2.16"
    implementation "com.google.dagger:dagger-android:2.16"
    implementation "com.google.dagger:dagger-android-support:2.16"
    
    // ...
    
    annotationProcessor "com.google.dagger:dagger-compiler:2.16"
    annotationProcessor "com.google.dagger:dagger-android-processor:2.16"
}

A Dagger (illetve a jelenleg is aktív fejlesztés alatt álló Dagger 2) kisméretű, fordítás-idejű dependency injection framework. Mit is jelent ez számunkra? Nos, a Spring-en (de főleg Spring Boot-on) nevelkedett kollégáknak eleinte néhány ősz hajszálat, kisebb memóriafoglalást, gyors alkalmazás-kontextus indulást és már a fordításnál kibukó kontextus konfigurációs hibákat. Mindezt azért, mert míg a Spring futásidőben, reflection használatával építi fel az alkalmazás kontextusát, addig a Dagger előzetes annotáció-feldolgozás útján generál különböző factory és egyéb osztályokat, amelyeket aztán az alkalmazás maga fogja a belépési ponton példányosítani. Röviden tehát nincs reflection okozta overhead, nincs “reflection-magic”, egyszerű példányosítások történnek az alkalmazás indulásakor. Az ősz hajszálak a Dagger által is használt, annotáció alapú konfigurálásból, illetve az általa okozott hamis nyugalom érzetből erednek, miszerint az annotációk elhelyezésével gyakorlatilag végeztünk is, kész a kontextus.

Építőkövek

Ez egyrészt már csak azért sem igaz, mert Spring alatt is szükség van valamilyen konfigurációra, másrészt meg, bár az elv ugyanaz, a megvalósítás némiképp különbözik egymástól a két fronton. Az eltérés már csak a két framework eltérő működési elvéből fakadóan is nyilvánvalónak tűnik - párhuzamot vonni azonban lehet közöttük: a Dagger @Component-jei nagyjából a Spring ApplicationContext-jeinek (a DI container-ek) felelnek meg, a “bean”-eket Dagger-ben @Module osztályokban definiáljuk, ahol az azokat létrehozó factory metódusokat @Provides annotációval jelöljük meg. Felhívnám a figyelmet a @Component annotáció Dagger-beli szerepére, mely éles ellentétben áll a Spring-ben megszokott használatával - a @Component annotáció nem hoz létre bean-eket Dagger alatt, hanem egy olyan komponenst generál, amivel a kontextus elindítható. Az injektálás és a bean-ek “inline” megjelölése egyaránt az @Inject annotációval történik az osztály konstruktorán, ami a Spring után elég szokatlan lehet, továbbá a Spring-gel ellentétben a Dagger alapértelmezetten prototype scope-on példányosít, amit a @Singleton annotációval akadályozhatunk meg. Az @Inject annotációval megjelölt bean-ek felkutatása automatikusan történik, extra konfigurációt (Spring-ben @ComponentScan) nem igényel, de az azonos típusú bean-ek listába való összegyűjtését automatikusan nem tudja megoldani - kézzel persze meg lehet, erről majd később. Csak hogy a fentiek tiszták legyenek, alább néhány példa látható, összehasonlítva a Spring és a Dagger megoldását.

Inline singleton bean létrehozás Springben

@Component
public class CachingEntryNetworkRequestAdapter implements EntryAdapter {
 
    private EntryAdapter entryNetworkRequestAdapter;
    private EntrySummaryPageCacheWriteHelper entrySummaryPageCacheWriteHelper;
    private EntryDAO entryDAO;
 
    @Autowired
    public CachingEntryNetworkRequestAdapter(
            @Qualifier("entryNetworkRequestAdapter") EntryAdapter entryNetworkRequestAdapter,
            EntrySummaryPageCacheWriteHelper entrySummaryPageCacheWriteHelper,
            LeafletLocalCacheDatabase leafletLocalCacheDatabase) {
        this.entryNetworkRequestAdapter = entryNetworkRequestAdapter;
        this.entrySummaryPageCacheWriteHelper = entrySummaryPageCacheWriteHelper;
        this.entryDAO = leafletLocalCacheDatabase.entryDAO();
    }
 
    // ...
}

Ugyanez Daggerben

@Singleton // prototype-hoz hagyjuk el ezt az annotációt
public class CachingEntryNetworkRequestAdapter implements EntryAdapter {
 
    private EntryAdapter entryNetworkRequestAdapter;
    private EntrySummaryPageCacheWriteHelper entrySummaryPageCacheWriteHelper;
    private EntryDAO entryDAO;
 
    // az Inject egyben hordozza a Spring Component/Service annotációjának
    // és az Autowired annotációnak funkcionalitását
    @Inject
    public CachingEntryNetworkRequestAdapter(
            @Named("entryNetworkRequestAdapter") EntryAdapter entryNetworkRequestAdapter,
            EntrySummaryPageCacheWriteHelper entrySummaryPageCacheWriteHelper,
            LeafletLocalCacheDatabase leafletLocalCacheDatabase) {
        this.entryNetworkRequestAdapter = entryNetworkRequestAdapter;
        this.entrySummaryPageCacheWriteHelper = entrySummaryPageCacheWriteHelper;
        this.entryDAO = leafletLocalCacheDatabase.entryDAO();
    }
 
    // ...
}

Központosított konfiguráció - Spring

@Configuration
public class ApplicationModule {
 
    private static final String DATABASE_NAME = "leaflet_local_cache";
 
    @Bean
    public LeafletLocalCacheDatabase providesLeafletLocalCacheDatabase() {
        return Room.databaseBuilder(application, LeafletLocalCacheDatabase.class, DATABASE_NAME)
                .build();
    }
}

Központosított konfiguráció - Dagger

@Module
public class ApplicationModule {
 
    private static final String DATABASE_NAME = "leaflet_local_cache";
 
    // ...
    
    @Provides
    @Singleton
    public LeafletLocalCacheDatabase providesLeafletLocalCacheDatabase() {
        return Room.databaseBuilder(application, LeafletLocalCacheDatabase.class, DATABASE_NAME)
                .build();
    }
}

Mint említettem, listába összegyűjteni az azonos interface-hez tartozó beaneket a Dagger nem tudja, de rá lehet venni, hogy megtegye. Ehhez a @Module osztályban vegyünk fel egy új metódust, tegyük rá a @Provides annotációt, opcionálisan nevezzük át a @Named annotációval, a metódus paramétereibe pedig vegyük fel az implementációkat, amiket egy listába szeretnénk rendezni. A metódus visszatérése természetesen List legyen:

@Module
public class ViewModelFactoryModule {
 
    private static final String REPOSITORIES = "repositories";
 
    @Provides
    @Singleton
    @Named(REPOSITORIES)
    public List<Repository> provideRepositories(
            CategoryRepository categoryRepository, DocumentRepository documentRepository,
            EntryRepository entryRepository, SupportRepository supportRepository) {
        return Arrays.asList(categoryRepository, documentRepository,
                entryRepository, supportRepository);
    }
    
    // ...
}

További érdekesség, de fontos megemlíteni, hogy a Dagger nem tud azzal mit kezdeni, ha egy interfészbe próbálunk injektálni egy konkrét implementációt. Megoldás persze van rá: egy abstract @Module osztályra lesz szükségünk, benne kizárólag @Binds annotációval ellátott abstract metódusokkal. Minden metódus egy-egy összerendelést fog definiálni az interface és az implementáció között, méghozzá az alábbi módon:

@Module
public abstract class RepositoryModule {
 
    @Binds
    public abstract CategoryRepository bindCategoryRepository(
        CategoryRepositoryImpl categoryRepository);
    
    // ...
}

A kontextus felépítése

A kontextus létrehozásához először is szükségünk lesz a konfigurációs osztályokra, amiket @Module annotációval jelölünk meg. Bár ilyen osztályokra nem feltétlenül van szükség (ha inline hozunk létre minden beant az @Inject annotációval), de mivel most Androidhoz készítünk konfigurációt, egy @Module osztályra mindenképp szükségünk lesz. Ebben a modulban kell megadnunk azokat az Activity (és ha használunk, akkor Fragment) osztályokat, amik számára biztosítani szeretnénk a dependency injectiont. Az osztály ez esetben abstract lesz, a tényleges implementációt a Dagger fogja létrehozni a fordítás során. Minden Activity és Fragment osztályhoz létre kell hoznunk egy-egy abstract metódust az alább látható módon, és ellátni őket a @ContributesAndroidInjector annotációval. Ha a központosított konfiguráció mellett döntöttünk, vegyül fel a további @Module osztályokat, és a @Provides annotáció használatával hozzuk létre a beaneket.

@Module
public abstract class LeafletMobileApplicationModule {
 
    @ContributesAndroidInjector
    abstract MainActivity mainActivity();
 
    // ...
 
    @ContributesAndroidInjector
    abstract EntryListFragment entryListFragment();
}

A következő lépés a @Component interface létrehozása lesz, mely gyakorlatilag a kontextus containereként fog szolgálni. Fontos kihangsúlyozni, hogy megint csupán egy interface-re lesz szükségünk, az implementációt ismét a Dagger biztosítja. A @Component annotáció attribútumaként meg kell adnunk a dependency injectionben résztvevő modul osztályokat - nem Android alkalmazás esetén ezen a ponton már majdnem meg is állhatunk. A @Component annotált interface-ünkhöz még adjunk hozzá néhány metódust, melyek lehetőséget adnak majd a @Component által bekötött beanekhez való hozzáférésre (bár ez jelen esetben nem annyira szükséges). Ha ezzel megvagyunk, fordítsuk le az alkalmazást, egyelőre a @Component interface bekötése nélkül - ezzel le fog generálódni az implementációja, amivel aztán a belépési ponton létre tudjuk hozni a kontextust. Ha a @Module osztályok egyike sem vár konstruktoron keresztül függést, akkor a Dagger a @Component interface-ből generált implementációban elhelyez egy .create() metódust, amivel azonnal létrehozható a container. Ellenkező esetben a .builder() metódus meghívásával egy builder példányt kapunk, metódusaival a @Module osztályok egy-egy példányát tudjuk a kontextusban elhelyezni - ha valamelyiknek nincs függése, azt automatikusan tudja ez esetben is példányosítani. A végén a .build() metódussal készítjük el a kontextust.

@Singleton
@Component(modules = {
        ApplicationModule.class,
        BridgeClientConfigurationModule.class,
        BridgeServiceConfigurationModule.class,
        LeafletMobileApplicationModule.class,
        RepositoryModule.class,
        SpannableConfigurationModule.class,
        ViewModelFactoryModule.class})
public interface LeafletMobileApplicationComponent
    // erre az extendre a következő szekcióban visszatérünk
    extends AndroidInjector<LeafletMobileApplication> {
}

További lépések Android alatt

Android alkalmazás esetén egy picit még tovább kell mennünk. A korábban említett @Component interface-ünket alakítsuk át, hogy az kiterjessze a generikus AndroidInjector interface-t - típusparamétere egy android.app.Application implementáció kell, legyen. Egy ilyen Application minden Android alkalmazásban példányosítva van alapból, ez indítja a main activity-t. A saját implementációra azért lesz szükség, mert azzal fogjuk a Dagger-t utasítani a container példányosítására - a fentebb említett @Component implementáció meghívásával. Ha csak Activity osztályaink vannak, még implementáljuk ezzel az osztállyal a HasActivityInjector interface-t is, továbbá adjunk hozzá az osztályhoz egy DispatchingAndroidInjector mezőt is, majd jelöljük meg az @Inject annotációval. Az interface egy activityInjector() nevű metódus implementálását várja majd tőlünk, itt szimplán adjuk vissza a fentebb létrehozott mezőt. Amennyiben support fragment osztályokat is használunk (és persze azok számára is biztosítani akarjuk a dependency injectiont), még implementálnunk kell itt a HasSupportFragmentInjector interface-t is, hasonló módon, mint az Activity osztályok kapcsán jártunk el. Hogy ebben az osztályban is megtörténjen az injektálás, a komponens .build() vagy .create() hívása után hívjunk egy .inject(this)-t is. Fontos még megjegyezni, hogy az Application implementációt be kell jegyezni az AndroidManifest.xml file-ban: az application node android:name attribútuma értékének adjuk meg az osztály nevét. Ha az osztályt ugyanabba a package-be tettük, ami a manifest node package attribútumában szerepel, akkor az érték .Osztálynév lesz, ellenkező esetben az említett package-hez viszonyított relatív package hivatkozás (szintén ponttal kezdve), majd végül szintén .osztálynév.

public class LeafletMobileApplication extends Application
        implements HasActivityInjector, HasSupportFragmentInjector {
 
    @Inject
    DispatchingAndroidInjector<Activity> dispatchingAndroidActivityInjector;
 
    @Inject
    DispatchingAndroidInjector<Fragment> dispatchingAndroidFragmentInjector;
 
    @Override
    public void onCreate() {
        super.onCreate();
        DaggerLeafletMobileApplicationComponent.builder()
                .applicationModule(new ApplicationModule(this))
                .bridgeClientConfigurationModule(new BridgeClientConfigurationModule())
                .bridgeServiceConfigurationModule(new BridgeServiceConfigurationModule())
                .spannableConfigurationModule(new SpannableConfigurationModule(this))
                .viewModelFactoryModule(new ViewModelFactoryModule())
                .build()
                .inject(this);
    }
 
    @Override
    public AndroidInjector<Activity> activityInjector() {
        return dispatchingAndroidActivityInjector;
    }
 
    @Override
    public AndroidInjector<Fragment> supportFragmentInjector() {
        return dispatchingAndroidFragmentInjector;
    }
}

Ha eddig mindent jól csináltunk, az alkalmazás már le kell forduljon. Ahogy azt említettem is már, a Dagger fordítási időben “köti be” az alkalmazást, így ha a fordítás sikertelen, ellenőrizzük a Gradle logokat: valahol ott fog figyelni a Dagger annotáció feldolgozójától származó üzenet, miszerint a konfiguráció érvénytelen. Egy-két esetben az üzenet nem feltétlenül teljesen egyértelmű, így komolyabb elbaltázás eredménye több órányi Google/SoF túrás lehet - a legjobb, ha nagyon odafigyelünk a konfiguráció felépítése során és akkor nem nem lesz gond.

Mindenesetre még nem értünk a bekötés végére, mivel ugyan kontextusunk már van, de azt még nem érjük el az Activity és Fragment osztályokban - így tehát ez még hátravan. Két apróságra lesz szükségünk: egyrészt az adott Activity vagy Fragment osztályban hozzunk létre egy-egy fieldet, melynek típusa természetesen a szükséges függés típusa. Ha a konfigurációban létrehoztuk a megfelelő bindolásokat, interface-eket is megadhatunk. Fontos, hogy a field nem lehet private, ez esetben a Dagger nem fogja tudni elvégezni az injektálást. Szerencsére nem kell public láthatóságra sem állítanunk, illetve fölösleges setterekre sem lesz szükség, package-private már megfelel. A fieldet annotáljuk az @Inject annotációval, ezzel jelezve a Dagger számára, hogy ide majd függést kell beszúrnia. Végül az onCreate() callback metódusban hívjuk meg Activity osztály esetén az AndroidInjection, “support” Fragment esetén az AndroidSupportInjection osztály statikus inject() metódusát - paramétere mindkét esetben this lesz. Ezzel sikeresen a végére értünk a Dagger bekötésének.

public class MainActivity extends AppCompatActivity {
 
    // ...
 
    @Inject
    LocalCacheUpdaterService localCacheUpdaterService;
 
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        AndroidInjection.inject(this);
        
        // ...
    }
    
    // ...
}

Dagger referencia implementáció (Leaflet Mobile)

Dagger GitHub

Kommentek
Hozzászólok

Még senki nem szólt hozzá ehhez a bejegyzéshez.