Offline first Android alkalmazás fejlesztés: kihívások és tapasztalatok
Manapság már ritka az olyan alkalmazás, amelynek a működéséhez ne lenne szükség internet kapcsolatra. Az adatok valamilyen szintű cache-elése ugyan gyakori, de ez általában csak a felhasználói élmény javítását szolgálja. Az viszont, hogy minden adatot elsődlegesen az eszközön tárolunk, és a szervert csak backup-ként kezeljük, nem jellemző. Ez is szerepet játszik abban, hogy a témával kapcsolatban kevés információ található az interneten, így egy ilyen rendszer megalkotása sokkal több tervezést és kutató munkát igényel. Mi is ebben a helyzetben találtuk magunkat, amikor egy nagyon érdekes és egyedi igénnyel talált meg az egyik ügyfelünk Android alkalmazás fejlesztés kapcsán. Részletek a cikkben!
Ahogy arra cikksorozatunk első részében is rámutattunk, az internet lefedettség már igen nagy, és nem túl gyakori az az eset, hogy az eszközünk ne tudjon csatlakozni valamilyen wifi vagy mobil hálózatra.
Az egyik ügyfelünk viszont a földrajzi adottságok miatt olyan egyedi igénnyel keresett meg minket, hogy a mobil appnak offline first módon kell működnie, az adatok fel- és letöltésére csak viszonylag ritkán, akár napok elteltével van lehetőség.
Szerencsére könnyebb dolgunk van az adatbázis kezelés téren, amióta megjelent a Room. Aki régebb ideje fejleszt Androidra, talán még emlékszik rá, hogy a Room előtt milyen sok boilerplate kód árán lehetett megvalósítani az adatbázis kezelést, illetve milyen kockázatokat rejtett az adatbázis lekérdezések írása.
Mi is pontosan a Room?
A Room egy perzisztencia könyvtár, az Android Jetpack részét képezi, és megkönnyíti az SQLite adatbázis használatát. Három fő komponense van:
- Database: Az adatbázis osztály, amely az SQLite adatbázist kezeli.
- Entity: Az adatok modelljét meghatározó osztály, ami az adatbázis tábláit képviseli.
- DAO (Data Access Object): Az interfész, ami meghatározza az adatbázis műveleteit, például beszúrás, lekérdezés, frissítés és törlés.
Hogyan húzzuk be a Room-ot egy Android projektbe?
Először hozzá kell adni a Room függőségeit a `build.gradle` fájlban:
Egyszerű tábla létrehozása a Room segítségével
1. Entity létrehozása
Az `Entity` annotációval ellátott osztály egy adatbázis táblát képvisel. Például egy egyszerű `User` entitás:
2. DAO létrehozása
A DAO meghatározza, hogyan férsz hozzá az adatbázisban az adatokhoz. Például egy egyszerű lekérdezés a felhasználókhoz:
3. Database osztály létrehozása
A `RoomDatabase` osztályból származó adatbázis osztály a Room adatbázist kezeli. Itt összekapcsoljuk az `Entity` és `DAO` osztályokat:
4. Room adatbázis inicializálása
Az adatbázist a `Room.databaseBuilder()` használatával hozhatod létre az `Application` vagy `Activity` osztályban:
Milyen kihívások merülnek fel?
Ezen a ponton túl, ha további információkat szeretnénk még szerezni, akadályokba ütközünk. Találhatunk egy nagyon egyszerű példát arról, hogyan lehet egy olyan osztályt elmenteni az adatbázisba, amelyik egy-két primitív adattípust tartalmaz, vagy hogyan kell inicializálni magát az adatbázist, ám ezt követően a fejlesztő saját magára marad.
Számos olyan kérdésre nem kapunk választ, amik egy valós probléma megoldása során felmerülhetnek. Ide tartozhat például, hogyan tudjuk ezeket a példa kódokat beilleszteni egy olyan architektúrába, ahol dependency injection van, vagy ha coroutine-okat használunk - ami már alap dolognak számít -, hogyan tudjuk beilleszteni az adatbázis hívásokat. Továbbá arra sem kapunk választ, támogatja-e a Room a LiveData vagy a Flow alkalmazását, hogyan tudunk a táblák között kapcsolatokat létrehozni, netán hogyan tudunk olyan táblát bementeni, ami nem csak primitív adattagokat tartalmaz.
Ha ezeket a kérdéseket szeretnénk megválaszolni, akkor sok időt igénylő kutató munkára van szükségünk, ami különböző megoldások kipróbálásával járhat.
A cikk további része ezen a folyamaton próbál meg egy kicsit gyorsítani, méghozzá egy komplex problémára adott működő megoldás bemutatásán keresztül.
Offline first mód: milyen igények merülhetnek fel?
A következő példában az egyszerűségre próbálunk törekedni: bemutatjuk a leggyakrabban előforduló igényekre a megoldást, legyen az adatbázis felépítése, objektumok közötti kapcsolat, beágyazott objektum.
Adatbázis felépítése
Maradunk a User objektumnál, viszont kicsit kibővítjük azt.
Mint látható a User-be bekerült egy PersonalInfo is, ezt az @Embedded annotációval tudtuk megadni. Ez azt fogja eredményezni, hogy a Room, amikor létrehozza a User táblát, akkor a PersonalInfo objektumban lévő property-ket is hozzáadja a User táblához a personalInfo_ előtaggal, például personalInfo_firstName, personalInfo_lastName, stb.. Ez a megoldás akkor lehet jó, ha nem szeretnénk külön táblát fenntartani az objektumunk tárolására, mivel az szorosan kapcsolódik egy másik objektumhoz.
Továbbá bekerült még egy Date típusú paraméter és egy új enum típus is. Ha most megpróbálnánk lebuildelni a projektet akkor hibaüzenetet kapnánk. Ez arról szólna, hogy a Room csak primitív típusokat tud bementeni alapból, viszont a táblánk már nem csak ilyeneket tartalmaz. Ezért valahogy a Room tudomására kell hozni, hogy ezeket az adat típusokat hogyan mentse be. Erre a type converterek valók.
Ahogy a fenti példából jól látszik, a Date esetében az objektumon elérhető time property-t használjuk, amely egy Long típust ad vissza. Mivel ez már egy primitív típus, így a Room be tudja menteni. Az enum érték esetében pedig az enum-ból String-et csinálunk.
Ezeket a type converter-eket regisztrálni kell az adatbázis osztályunkon, hasonlóan az entity-khez.
Ahogyan az a példakódban jól látható, a type converter-ek regisztrálása egyszerűen a @TypeConverters annotáció segítségével történik. Előfordulhat olyan eset, hogy valamilyen komplex objektum, vagy objektumok listáját is egy type converter létrehozásával szeretnénk megoldani. Ilyenkor használhatunk valamilyen JSON converter könyvtárat (gson, moshi), melynek segítségével egy JSON String-et csinálunk, és azt mentjük az adatbázisba. Ez viszont nem a legszebb megoldás, használatát csak végső esetben javasoljuk.
Itt a DemoObject listából csinálunk egy JSON String-et. Ilyen eretnekséget akkor alkalmazhatunk, ha nem szeretnénk külön táblát létrehozni a DemoObject-ünk számára, és “egy a sokhoz” kapcsolattal összekapcsolni az adott objektumunkkal.
Aki figyelmesebb volt, már észlelhette, hogy az AppDatabase osztály entities listája kibővült egy Task osztállyal. Ezen a példán keresztül fogjuk bemutatni, hogyan lehet kapcsolatot létrehozni két objektum között.
Két objektum közötti kapcsolat létrehozása
A Task osztály a következő:
Itt eléggé szembetűnő a lényeg. Akik minimális tudással rendelkeznek relációs adatbázisok terén, azoknak ismerősen cseng a külső kulcs fogalma. A példában egy felhasználóhoz több feladat is tartozhat, tehát a két objektum között “egy a sokhoz” kapcsolatot kell létrehozni. Ezt a Task osztályunkban egy, a User táblára visszamutató id-val tudjuk megtenni. Amikor létrehozunk egy Task objektumot, a hozzá tartozó userId-t is meg kell adnunk.
Továbbá az is jól látszik, hogy az @Entity annotációban hogyan kell megadni a külső kulcsot. Meg kell adni melyik osztállyal kapcsoljuk össze - jelen esetben ez a User osztály -, a User osztály id-ját, valamint a Task osztály id-ját is. Az utolsó onDelete megadása arra vonatkozik, hogy mi történjen a task rekorddal az adatbázisban, ha a hozzá tartozó user törlődik.
A példában szereplő ForeignKey.CASCADE azt mondja meg az adatbázisnak, hogy ha törlődik egy user rekord, akkor az összes hozzá tartozó task is törlődjön. Természetesen van lehetőségünk más beállítást is megadni.
Az adatbázisból történő egyszerűbb kikérdezés miatt létre tudunk hozni egy olyan osztályt, amely egyszerre tartalmazza a User-t és a Task-ok listáját.
A fenti kódrészlet elég egyértelmű, különösebb magyarázatra nem szorul.
Hogyan néz ki a UserDao?
A getUserWithTasks() metódussal egyszerűen kérdezhető ki a UserWithTasks objektum, amely tartalmazza a User-t és a Task-ok listáját. Tehát összességében elmondható, hogy viszonylag kevés kóddal sikerült összekapcsolnunk a két táblát.
Érdemes ugyanakkor nagy figyelmet fordítani arra, hogy az összekapcsolásra használt id-kat helyesen adjuk meg: a típus és a név is nagyon fontos. Gyakori hiba, hogy userId helyett csak simán id-t írunk. Ha ez történik, akkor a Room egyből szólni fog, hogy nem tudja elvégezni az összekapcsolást.
Bekerült még néhány olyan metódus is a DAO-ba, amely Flow típussal tér vissza, illetve nem suspended function. Ezek szerepéről később, a szinkronizációnál lesz szó.
A teljesség kedvéért nézzük meg a TaskDao-t, ami nagyon hasonló lesz a UserDao-hoz.
Korábban szó volt róla, hogy az adatbázis létrehozására, és a dependency injection framework-kel való integrációjára is hozunk példát. Számos kiváló dependency injection framework létezik, közülük a Google által is ajánlott és valószínűleg a legelterjedtebb, Hilt névre hallgató framework-et mutatjuk be.
Röviden annyit róla, hogy annotációkkal dolgozik és Androidra lett optimalizálva. Ha egy Activity-ben vagy Fragment-ben szeretnénk valamilyen függőséget használni, akkor elég csak rájuk rakni az`@AndroidEntryPoint` annotációt, view modellek esetében pedig a `@HiltViewModel` annotációt. A framework lelkének a modulok számítanak, ezekben kell megadnunk, hogyan kell kipéldányosítani az egyes objektumokat, amelyeket injektálni szeretnénk majd.
Modul az adatbázis létrehozására
Itt megadjuk a dependency injection framework számára, hogy az AppDatabase objektumot hogyan kell előállítani, valamint a UserDao, illetve a TaskDao hogyan áll elő. Röviden, akkor érhető el egy objektum a dependency gráfban, ha valamelyik modulban van olyan @Provides vagy @Binds metódus, amelynek a visszatérési értéke az adott objektum.
Megjegyzés: a UserDao, illetve a TaskDao már használható így a view modelljeinkben, vagy use case-einkben, viszont szeretünk még egy réteget húzni a DAO-k elé. Például:
Így nem közvetlenül a DAO-kon lévő adatbázis metódusokat hívjuk, illetve további logikát tudunk beépíteni.
Dependency Injection a storage-ra vonatkozóan:
Módszer adat szinkronizálásra
Tudjuk már hogyan mentsük be az adatbázisba az adatokat. Előfordulhat ugyanakkor olyan igény, hogy az offline módon adatbázisba bementett adatokat bizonyos időközönként töltsük fel a szerverre - nyilván olyankor, amikor épp van internet kapcsolat.
Itt egyből több kérdés is felmerülhet. Mikor, hogyan és mit szinkronizáljunk? Ha van internet, akkor automatikusan történjen ez? Netán legyen egy indikátor, ami jelzi a felhasználó számára, hogy van internet kapcsolat, illetve, van szinkronizálandó adat? Hogyan döntjük el milyen adatokat kell feltölteni?
A következő részben egy olyan esetet mutatunk be, ahol automatikusan detektáljuk, miszerint van-e internet, illetve van-e szinkronizálandó adat. Ennek az eredménye lehet egy, az alkalmazásban jól látszó indikátor állapotainak a változtatása. Ha a feltételek megfelelőek, akkor egy szinkronizációs képernyő megjelenítése a feltöltendő adatokkal, és magának a szinkronizációnak az elvégzése.
Van-e internet lefedettség?
A példában egy internet connectivity listener-t regisztrálunk, bevezetünk egy enum-ot, amely az internet éppen aktuális állapotát tükrözi, majd ezt egy use case-en keresztül elérhetővé tesszük.
Amire érdemes még figyelni, hogy ez egy Flow típussal tér vissza. Az Android Flow a Kotlin Coroutines része, és egy aszinkron-adatfolyamok kezelésére szolgáló könyvtár. A Flow használatával adatfolyamokat hozhatunk létre, és valós időben reagálhatunk az adatváltozásokra. A Flow-t az Androidban olyan helyzetekben használják jellemzően, amikor folyamatosan frissülő adatokat kell lekérni vagy figyelni, mint például adatbázis-változásokat, hálózati hívások eredményeit, vagy más eseményeket, ami jelen esetben az internet lefedettségének a változása.
Mit szinkronizálunk?
Itt kerülnek képbe a DAO-kon megírt olyan adatbázis metódusok, amelyek Flow-val térnek vissza. A fenti use case segítségével könnyen értesülhetünk azokról az eseményekről, ha egy új rekord kerül be az adatbázisba.
Itt essen pár szó a DataSyncState enum-ról:
Ezzel az enum-mal tudjuk nyomon követni, hogy az adott adatbázis rekordot felszinkronizálták már, vagy sem. Amikor létrehozzuk a rekordot, akkor ezt az enum-ot IN_LOCAL_DATABASE-re állítjuk. Miután végbement a sikeres szinkronizáció, az enum értéket SYNCED_TO_CLOUD-ra billentve látjuk majd, hogy az adott rekord feltöltésre került.
És ahol a kettő összeér:
Látható, hogy egyszerűen összekombinálhatjuk a két, dinamikusan változó adatot. A GetCurrentSyncStateUseCase könnyedén ráköthető egy UI-on lévő indikátorra, ahonnan a felhasználó számára világosan kiderül, hogy szükséges-e valamit szinkronizálni, illetve ez épp lehetséges-e.
Milyen adatokat kell szinkronizálni?
Egy képernyőn tudjuk listázni a felhasználó számára azt, hogy milyen adatokat kell szinkronizálni. Például a UserDao-hoz hozzáadhatjuk a következő metódust, amellyel ki tudjuk kérdezni csak azokat a user rekordokat, amelyek még nem lettek szinkronizálva.
Ha fel szeretnénk tölteni az adatokat, a következő módon tehetjük meg:
Itt kiolvassuk az összes szinkronizálatlan adatot, és aszinkron módon feltöltjük őket. Az UploadUserUseCase és az UploadTaskUseCase, ahogy a nevükből is könnyen kitalálható, a feltöltést végző api hívásokat tartalmazza. Illetve ha sikeres a feltöltés, akkor ezekben tudjuk frissíteni az adatbázisunkat is a sync state-re vonatkozóan.
A fenti kódrészletekkel jelezni tudtuk a felhasználó számára a feltöltendő adatok, illetve az internet állapotát, kilistáztuk az adatokat és végül fel is töltöttük őket.
Bejelentkezés offline módon
Egy másik érdekes dolgot, az offline módból adódó igényt mutatjuk be. Hogyan tudjuk elérni, hogy a felhasználót authentikáljuk a szerver felé bejelentkezéskor, viszont minden nap automatikusan jelentkeztessük is ki, és offline módon is be tudjon jelentkezni.
Nyilván ez a két igény ellentmond egymásnak. Ha nincs internet, akkor nem tudunk szerver hívást intézni, viszont a bejelentkezést akkor is meg kell oldani, ha nincs internet.
A megoldás a következő lehet. Bejelentkezés esetén megnézzük, hogy van-e internet lefedettség. Ha igen, akkor következhet a normál login. Ha sikerült a login, akkor EncryptedSharedPreferences-ben elmentjük a felhasználóhoz tartozó felhasználónév és jelszó párost hash elven. Nyilván a jelszó miatt van szükség a hasheslésre, és az EncryptedSharedPreferences-re. Így el tudjuk érni, hogy a legutoljára bejelentkezett felhasználó offline módon be tudjon lépni.
A napi szintű kiléptetés eléréséhez a bejelentkezés után elindítunk egy worker-t, amely a felhasználót 24 óra letelte után kijelentkezteti.
Ismerd meg hogyan támogatta a Zebra ökoszisztéma az app fejlesztéseinket cimkenyomtatás, QR kód beolvasás, Kiosk mód, illetve PDF generálás esetén!