Itt jársz most: Kezdőlap > Alkalmazásfejlesztés > Offline-first architektúra implementálása Android alkalmazásokban

Szűrő megjelenítése

Offline-first architektúra implementálása Android alkalmazásokban

Az "offline-first", mint architekturális minta meglehetősen elterjedt fogalomnak számít az okoseszközök világában. Valószínűleg nem számít nagy meglepetésnek azt mondanom, hogy szinte mindenki telefonján lapul 1-2 olyan alkalmazás, mely képes biztosítani az említett architektúra képességeit - ilyen kliens alkalmazások a GMail, a Messenger, az Instagram és még sorolhatnám. Közös jellemzőjük, hogy aktív hálózati kapcsolat nélkül is képesek megjeleníteni az eszközön egyszer már feldolgozott adatokat.

Az offline-first alkalmazások nagyjából az alábbi "irányelvek" mentén működnek:

  1. A hálózati forgalom "káros" és/vagy lassú (vagy nem is lehetséges) a felhasználó számára: bár ma már meglehetősen hétköznapi dolognak számít, hogy okoseszközünk folyamatosan csatlakozik az internetre mobil adatcsomagon vagy Wi-Fi hálózaton keresztül, természetesen még mindig vannak olyan esetek, amikor nem lehetséges a kapcsolódás. Azonban a legjobb felhasználói élmény elérése érdekében a már korábban letöltött tartalmakat szeretnénk biztosítani a felhasználó számára. Ennek elérése érdekében:
  2. Használjunk lokális cache-t: az alkalmazás a már korábban letöltött tartalmakat tárolja a belső táron (legalább átmenetileg). Az adatok így olyan esetekben is elérhetően maradnak, mikor a felhasználónak nem áll rendelkezésére semmilyen vezeték nélkül hálózati kapcsolódási lehetőség.
  3. Az alkalmazás előbb mindig a cache-t használja - olyan esetekben is, amikor egyébként van hálózati kapcsolat. Ez ugyancsak egy lehetősége a felhasználói élmény javításának, mivel mindaddig míg az eszköz elvégzi az adatok frissítését (a háttérben), a korábban már letöltött tartalmakat a felhasználó tudja böngészni.

A fentieket figyelembe véve alkalmazásunkat offline-first képessé implementálhatjuk. Ehhez technikai oldalról a következőket kell megtennünk:

  1. Természetesen implementálnunk kell egy kliens réteget, mely képes az adatok hálózatról történő beszerzésére. Legvalószínűbb, hogy egy REST klienst kell implementálnunk az adatokat kiszolgáló API eléréséhez, így a példa is erre fog kitérni.
  2. Szükségünk lesz egy perzisztens tár implementálására. Itt siet majd segítségünkre a Room nevű, a Google által fejlesztett SQLite alapú perzisztencia library. Aki esetleg már dolgozott natív Hibernate-tel, vagy Spring Data JPA-val, az sok hasonlóságot fog majd felfedezni az említett library-k között.
  3. Bár a perzisztens tár megvalósítása a Room segítségével gyerekjáték lesz, természetesen a logikát, mely eldönti, melyik forrásból jelenítsük meg az adatokat a felhasználó számára, nekünk kell megírnunk. Ugyanitt szükségünk lesz továbbá az elévülési és a fallback logika implementálására, különös figyelmet fordítva a kivételek kezelésére, főleg a hálózati kommunikáció tekintetében.

Az implementálandó logika szekvencia ábrája a következő: Offline-first alkalmazás high-level flow

Mint az a fenti ábrán látható, egy async (pontosabban reaktív) hívással kezdődik az egész folyamat. Nem véletlenül emeltem ki ezt a lépést, mivel mobilos alkalmazásainkban különös figyelmet kell fordítanunk arra, hogy az alkalmazás főszála ne legyen terhelve, azon számolás- és/vagy IO-igényes műveletek ne fussanak (emlékeztetőül, itt egy hálózati hívás fog történni sok esetben), ennek megfelelően pedig minden ilyen műveletet külön szálon ajánlott futtatni. Ellenkező esetben a főszál (ahol az alkalmazás felhasználói felületének renderelése fut) minden ilyen művelet közben "megfagyna" - nem egészen ez az a felhasználói élmény amire bármelyikünk vágyna. Ennek elkerülésére pedig kifejezetten hasznos eszköz a reaktív programozási modell, illetve a különböző, (nem csak) mobil alkalmazások fejlesztésére szánt tervezési minták használata. A Leaflet Mobile kliens alkalmazás esetében ez az MVVM, azaz Model-View-ViewModel minta, melyet hamarosan bemutatok alább az alkalmazásból kiemelt forráskód részleteken keresztül.

Fontos megemlíteni, hogy ebben az esetben nem volt szükség a háttérben történő frissítésre, mivel itt inkább csak a cachelés volt lényeges, semmint a folyamatos frissítés a háttérben, ezért a logika nem is próbálkozik a frissítéssel, miután a cacheből sikeresen adott vissza adatokat.

A DAO réteg implementálása

Induljunk el a hívási lánc gyökerétől, tehát a két adatforrás implementációjából. (Az alább látható kódrészletek a Leaflet Mobile-on megjelenő blogbejegyzések kezelését mutatja majd be, a teljes implementáció pedig megtekinthető a cikk végén mellékelt repository-ban.)

Az első adatforrásunk a Room-alapokon nyugvó SQLite adatbázis lesz. A Hibernate/Spring JPA megoldásaihoz hasonlóan itt is szükségünk lesz egy interfészre, melyet a @Dao annotációval látunk el, benne pedig a létrehozott metódus specifikációk lesznek a DAO belépési pontjaink. Írási műveletek esetén az @Insert és az @Update annotációkra lesz szükségünk, olvasáshoz a @Query annotációt használhatjuk - utóbbinál sajnos kézzel kell megírnunk a releváns, SQLite dialektikájú query kifejezésünket. A blogbejegyzések kezelésére az alábbi interfészt használom:

@Dao
public interface EntryDAO {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    void insert(EntryDetails entryDetails);

    @Query("select * from cache_entry_details where link = :link;")
    Optional<EntryDetails> findEntry(String link);
}

Mint az fentebb látható, egy beszúrás és egy szelekció művelet lett implementálva, előbbi a cache populálására szolgál, míg utóbbi a cache-ből történő olvasásra. Insert esetben látható az onConflict paraméter használata, ezzel gyakorlatilag insert helyett update műveletet tudunk végrehajtani, ha már létezik a kulcs alatt egy másik rekord, mely a cache frissítésénél lesz fontos. A select műveletben az Optional típus használatával nem létező rekord esetében empty optionalt kapunk majd null helyett, amit mindenképp érdemes fontolóra venni.

A helyzet azért nem ennyire egyszerű, nyilván a Room ebben az esetben még nem tudja, hogy neki egy adatbázist kell építeni az alkalmazásunk számára. Erre szolgál az alábbi konfiguráció:

@Database(version = 1,
        exportSchema = false,
        entities = {EntryDetails.class, /* többi entity osztály */})
public abstract class LeafletLocalCacheDatabase extends RoomDatabase {
    //...
    public abstract EntryDAO entryDAO();
    // ...
}

Mint az látható, egy abstract osztályt kell létrehoznunk, mely a RoomDatabase osztályt terjeszti ki, a @Database annotációval pedig megjelöljük ezt az osztályt az adatbázisunk konfigurációjaként. Az adatbázis verziózható, illetve az aktuális verzió sémáját a Room képes kiexportálni egy fileba (aminek köszönhetően a séma verziókezelhetővé válik). Az entities attribútumban kell tömbben megadnunk az adatbázis által használt entity osztályokat - alább látható a bejegyzéseket leíró osztály:

@Entity(tableName = DatabaseConstants.TABLE_ENTRY_DETAILS)
public class EntryDetails implements Serializable {

    @NonNull
    @PrimaryKey
    @ColumnInfo(name = DatabaseConstants.FIELD_LINK)
    private String link;

    @ColumnInfo(name = DatabaseConstants.FIELD_TITLE)
    private String title;

    @ColumnInfo(name = DatabaseConstants.FIELD_CONTENT)
    private String content;

    @ColumnInfo(name = DatabaseConstants.FIELD_AUTHOR)
    private String author;

    @ColumnInfo(name = DatabaseConstants.FIELD_CREATED_DATE)
    private String createdDate;

    // setter/getter illetve standard domain metódusok (equals/hashCode/toString)
}

A fent látható annotációk magukért beszélnek:

  • @Entity - megjelöli az osztályt a Room által kezelendő entitás osztályként, benne a tableName attribútum határozza meg a létrejövő tábla nevét
  • @PrimaryKey - megjelöli a mezőt elsődleges kulcsként
  • @ColumnInfo - extra szabályzási lehetőségeket biztosít az adatbázis mezők számára

Természetesen a fenti példában nem látszik minden, a Room által biztosított lehetőség, így az API dokumentáció megtekintését tudom javasolni mielőtt belevágunk a fejlesztésbe.

Az adatbázis használatba vétele előtt természetesen még példányosítani kell azt:

// Az alábbi példában a Dagger IoC frameworkre bízzuk a példányosítást,
// így az alábbi metódusnak egy `@Module` osztályban kell helyet foglalnia.
// Persze, természetesen Dagger nélkül is használhatjuk a Room-ot.
@Provides
@Singleton
public LeafletLocalCacheDatabase providesLeafletLocalCacheDatabase() {
    return Room.databaseBuilder(application, LeafletLocalCacheDatabase.class, DATABASE_NAME)
            .build();
}

A Room egy statikus buildert biztosít az adatbázis inicializálására, melyet a fentebb látható módon hívhatunk meg. Az application paraméter itt egy android.content.Context objektum kell, legyen, mely hozzáférhető az activity-kből, vagy a Dagger-ön keresztül.

Az adatok beszerzése

A DAO réteggel ezzel meg is volnánk, persze, így a lehetőség már megvan az adatok tárolására, azonban azokkal még nem rendelkezik az alkalmazás. Itt jön képbe egy másik nagyon hasznos library, a Retrofit. A Retrofit egy nagyon kényelmesen használható REST kliens library, melyben - a Room-hoz hasonlóan - csupán interfészek definiálására van szükség. A bejegyzések beszerzése az alábbi interfésszel történik:

@GET("/entries/link/{link}")
Call<WrapperBodyDataModel<ExtendedEntryDataModel>> getEntryByLink(@Path("link") String link);

És ennyi. A többit a Retrofit intézi. A @GET (@POST, @DELETE, ...) annotációval definiáljuk az útvonalat és a HTTP metódust, melyet a kliensnek majd hívnia kell, benne az esetleges path variable-ökkel, majd a @Path annotációval rendeljük hozzá az interfész megfelelő paraméterét. Hasonlóképp működnek a query paraméterek, request body, és a többi - a dokumentáció figyelmes megtekintése ez esetben is erősen ajánlott.

Természetesen a konfigurálást itt sem ússzuk meg, hasonlóan a Room-hoz alább a Dagger IoC számára szükséges konfiguráció látható:

@Provides
@Singleton
public EntryRESTClient entryRESTClient(Retrofit retrofit) {
    return retrofit.create(EntryRESTClient.class);
}

@Provides
@Singleton
public Retrofit baseRetrofitClient(OkHttpClient okHttpClient) {

    return new Retrofit.Builder()
            .baseUrl(BuildConfig.API_HOST_URL)
            .addConverterFactory(JacksonConverterFactory.create(objectMapper()))
            .client(okHttpClient)
            .build();
}

Az addConverterFactory(...) és a client(...) hívások alapvetően kihagyhatóak a Retrofit builderének meghívásából. A Leaflet Mobile-ban azért volt szükség a felülbírálásukra, mert az OkHttp klienshez új interceptorokat kellett hozzáadnom, illetve ZonedDateTime-ot használ az API, ami miatt az ObjectMapper-t felül kellett bírálni. Ha nincs ilyenekre szükségünk, az a két hívás teljesen elhagyható, csupán a REST API-unk hostjának címét kell megadnunk, illetve az interfészt "implementálni" az így létrejövő Retrofit objektumon keresztül tudjuk.

Adapterek - adapterek mindenhol

A következő lépések tulajdonképpen opcionálisak, de ha jól strukturált kódot szeretnénk kapni, érdemes kis időt szánni a tervezésre. Az adapter és a decorator tervezési minták használatával elkészültek a belépési pontok, azaz 2+1 EntryAdapter implementáció:

@Singleton
public class EntryLocalCacheAdapter implements EntryAdapter {

    // ...

    @Override
    public Optional<EntryDetails> getEntry(String link) {
        LogUtility.debug(LOG_TAG, "Retrieving entry '%s' from local cache", link);
        return entryDAO.findEntry(link);
    }

    // ...
}

A fenti implementáció kéri le a lokális cache-ünkben tárolt adatokat, tehát a Room által létrehozott SQLite adatbázissal kommunikál.

@Singleton
public class EntryNetworkRequestAdapter extends AbstractBaseNetworkRequestAdapter implements EntryAdapter {

    // ...

    @Override
    public Optional<EntryDetails> getEntry(String link) {

        LogUtility.debug(LOG_TAG, "Requesting entry '%s' from API service", link);
        EntryDetails entryDetails = callBackend(
                () -> entryRESTClient.getEntryByLink(link),
                entryConverter::convert);

        return Optional.ofNullable(entryDetails);
    }

    // ...
}

Ez az adapter a Retrofit kliensen keresztül a REST API meghívásával foglalkozik.

@Singleton
public class CachingEntryNetworkRequestAdapter implements EntryAdapter {

     // ...

    @Override
    public Optional<EntryDetails> getEntry(String link) {

        Optional<EntryDetails> optionalEntryDetails = entryNetworkRequestAdapter.getEntry(link);
        optionalEntryDetails.ifPresent(entryDAO::insert);

        return optionalEntryDetails;
    }

    // ...
}

Az utolsó pedig (ami inkább egy decorator, mint adapter) a hálózati hívás eredményét tárolja a lokális cache-ben, amennyiben persze az jelen van. Ez egy nagyon fontos lépés, hiszen a lokális cache populálása kizárólag a hálózati forrás válaszaiból történhet. Így amennyiben volt egy sikeres hálózati hívásunk, tehát a REST API vissza tudott adni számunkra értékes információt, azt letároljuk a cache-ben, a következő kérésnél pedig azt adjuk majd vissza. És pontosan ezt teszi az alábbi látható Repository implementáció:

@Singleton
public class EntryRepositoryImpl implements EntryRepository {

    // ...

    @Override
    public Observable<EntryDetails> getEntry(String link) {
        return offlineFirstCallFactory.createStrict(
                () -> entryLocalCacheAdapter.getEntry(link),
                () -> cachingEntryNetworkRequestAdapter.getEntry(link));
    }

    // ...
}

@Singleton
public class OfflineFirstCallFactory {

    // ...
    
    public <T extends Serializable> Observable<T> createStrict(Supplier<Optional<T>> offlineCall,
                                                               Supplier<Optional<T>> onlineCall) {
        return observableFactory.create(() -> offlineCall.get()
                .orElseGet(() -> onlineCall.get()
                        .orElseGet(getNotFoundSupplier())));
    }

    // ...
}

Mint az látható az implementációban, egy Observable objektumot hozunk létre, melyet először az offline forrás válaszával próbálunk "feltölteni" - amennyiben empty optional az eredmény, megpróbáljuk az online forrást is, ez esetben pedig ha az sem sikerül, kivételt váltunk ki (ezt persze az alkalmazásnak később kezelnie kell).

Ideje megjeleníteni a választ

Ezzel nagyjából elértünk a folyamat végére, de még meg kell jelenítenünk a választ, illetve persze, még meg sem hívtuk a Repository-t, szóval az még hátravan. Az MVVM minta szerint, ezt egy ViewModel nevű komponens kell elvégezze, melyet az Android fog kezelni. A ViewModel implementáció ebben az esetben viszonylag egyszerű, de itt kellene történjen például a request-response konverzió, amennyiben az indokolt.

public class EntryDetailsViewModel extends ViewModel {

    private EntryRepository entryRepository;

    public EntryDetailsViewModel(EntryRepository entryRepository) {
        this.entryRepository = entryRepository;
    }

    public Observable<EntryDetails> getEntryDetails(String link) {
        return entryRepository.getEntry(link);
    }
}

Ez a réteg azért szükséges, hogy a megjelenítés és a "backend" műveletek (MVC patternben a View és a Model a megfelelőik) kellőképp el legyenek választva, köztük ne legyen szigorú függés, kapcsolat. Ez segít a felelősségkörök szeparálásában, amit később, az alkalmazás bővítése, módosítása során meg fogunk magunknak hálálni.

A fenti metódus már hívható a view rétegből, a válaszra feliratkozva pedig, amint az elérhetővé válik, frissíthetjük a felhasználói felületet. Ez a Leaflet Mobile-ban egy viszonylag bonyolult, több komponensen átívelő folyamat, így javaslom a releváns osztályok megtekintését a repository-ban, de a lényeges részei a hívási láncnak az alább láthatóak:

public class EntryDetailsPageContentLoader extends AbstractDefaultContentLoader<EntryDetails> {

    public EntryDetailsPageContentLoader(Fragment fragment, View view, ViewModelProvider.Factory viewModelFactory, 
                                         SpannableConfiguration spannableConfiguration) {
        // ...
        // a ViewModel inicializálását ez esetben az Androidra kell bízzuk
        entryDetailsViewModel = ViewModelProviders.of(getFragment(), viewModelFactory)
                .get(EntryDetailsViewModel.class);
    }

    @Override
    protected Observable<EntryDetails> callViewModel() {
        // itt kérjük le a megnyitott cikk linkjét
        return extractLink()
                // majd meghívjuk a ViewModel implementációt
                .map(entryDetailsViewModel::getEntryDetails)
                .orElseGet(() -> Observable.error(new IllegalArgumentException(exceptionMessageLinkNotSpecified)));
    }
public abstract class AbstractDefaultContentLoader<T> implements ContentLoader {

    // ...

    @Override
    public void loadContent() {
        // itt hívjuk a fentebb látható metódust
        Disposable subscriptionResult = callViewModel()
                // amíg a válaszra várunk, a felhasználó egy progress bar-t lát
                .doOnSubscribe(disposable -> handleInProgress())
                // a válaszra feliratkozva, legyen az sikeres vagy sikertelen,
                // kezelni tudjuk azt
                .subscribe(this::doHandleSuccess, this::handleException);
        disposables.add(subscriptionResult);
    }

A sikeres válasz kezelése lényegében a válasz tartalmának megjelenítése lesz a felületen - tehát konkrétan a TextView és egyéb UI komponensek frissítése történik itt. A sikertelen válaszról pedig értesíthetjük a felhasználót például egy Toast sáv megjelenítésével. Mindez persze már az alkalmazás felhasználói felületének designján múlik.

Elévülés és frissítés

Egy nagyon fontos dologról még nem beszéltem, ami nem más, mint az elévülés kezelése, tehát a cache kényszerített frissítése. Egyrészt az alkalmazás kell, hogy tárolja, mikor történt legutóbb frissítés. Ezzel tudunk implementálni automatikus elévülést, például, ha egy hete nem volt már semmilyen frissítés, akkor egyszerűen kiürítjük a cache-t, a következő tartalom lekérésnél pedig ezzel hálózati hívást kényszerítünk majd ki (persze előtte érdemes ellenőrizni, van-e aktív hálózati kapcsolat).

Az adatbázis kiürítése akár egészen kényelmetlen is lehetne, ha túl sok táblánk van, de szerencsére a Room biztosít a RoomDatabase osztályban egy clearAllTables() nevű metódust . Mivel a saját adatbázisunk ki kell, terjessze ezt az osztályt, azon keresztül ez a metódus meghívható és pontosan ezt is tehetjük ebben az ezesetben.

public Observable<Boolean> update(boolean forced) {

    Observable<Boolean> result;
    if (forced || updateRequired()) {
        // a supportRepository csak Observable-be csomagolja a RoomDatabase.clearAllTable() hívást
        result = supportRepository.clearLocalCache();
        applicationPreferencesProvider.updateLastUpdateDate();
        LogUtility.debug(LOG_TAG, "Updated local cache with forced mode = %s", forced);
    } else {
        result = Observable.just(false);
        LogUtility.debug(LOG_TAG, "Updating local cache skipped");
    }

    return result;
}

A trigger lehet a "klasszikus" swipe-down művelet (forced = true), vagy az alkalmazás betöltése (ez esetben a MainActivity.onCreate() metódusa hívja majd, ez azonban nem "forced", így csak akkor lesz kiürítve a cache, ha az adatok elévültek a beállítások szerint).

A Leaflet Mobile implementációja

Room dokumentáció

Retrofit dokumentáció

MVVM design pattern

Kommentek

Komment írásához jelentkezz be
Bejelentkezés

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