Offline first Android alkalmazás fejlesztés: kihívások és tapasztalatok

Tanácsok adatbázis felépítésre, adatok szinkronizálására, adatbázis kezelésre
LogiNet Mobile Dev Team

LogiNet Mobile Dev Team

Natív iOS, Android és cross-platform mobil szakértők

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:

Offline first Android alkalmazás fejlesztés - Hogyan húzzuk be a Room-ot egy Android projektbe

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:

Offline first Android alkalmazás fejlesztés - Entity létrehozása

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:

Offline first Android alkalmazás fejlesztés - DAO létrehozása

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:

Offline first Android alkalmazás fejlesztés - Database osztály létrehozása

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:

Offline first Android alkalmazás fejlesztés - Room adatbázis inicializálása

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.

Offline first Android alkalmazás fejlesztés - User objektum

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.

Offline first Android alkalmazás fejlesztés - type converterek

Offline first Android alkalmazás fejlesztés - Date esetében az objektumon elérhető time property -t használjuk

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.

Offline first Android alkalmazás fejlesztés - type converter -eket regisztrálni kell az adatbázis osztályunkon

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.

Offline first Android alkalmazás fejlesztés - JSON converter könyvtár

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ő:

Offline first Android alkalmazás fejlesztés - Task osztály

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.

Offline first Android alkalmazás fejlesztés - 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?