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:

dependencies {
    def room_version = "2.5.2" // Ellenőrizd a legújabb verziót!
    
    implementation "androidx.room:room-runtime:${'$'}room_version"
    kapt "androidx.room:room-compiler:${'$'}room_version"

    // Kotlin Coroutines támogatás, ha szükséges
    implementation "androidx.room:room-ktx:${'$'}room_version"
}

 

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:

@Entity
data class User(
    @PrimaryKey(autoGenerate = true) val id: Int = 0,
    val name: String,
    val age: Int
)

 

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:

@Dao
interface UserDao {

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUser(user: User)

    @Query("SELECT * FROM user WHERE id = :userId")
    suspend fun getUserById(userId: Int): User?
    
}

 

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:

@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
    abstract fun userDao(): UserDao
}

 

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:

val db = Room.databaseBuilder(
    applicationContext,
    AppDatabase::class.java, "appDatabase"
).build()

val userDao = db.userDao()

 

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.

@Entity
@Parcelize
data class User(
    @PrimaryKey
    val userId: String = "",
    
    @Embedded(prefix = "personalInfo_")
    val personalInfo: PersonalInfo?,
    
    val dataSyncState: DataSyncState,
    val lastSyncDate: Date? = null,
) : Parcelable

@Parcelize
data class PersonalInfo(
    val firstName: String,
    val lastName: String,
    val email: String,
    val phoneNumber: String,
) : Parcelable

 

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.

class DateTypeConverter {

    @TypeConverter
    fun fromDate(value: Long?): Date? {
        return value?.let { Date(it) }
    }

    @TypeConverter
    fun toDate(date: Date?): Long? {
        return date?.time
    }
}

 

class DataSyncStateTypeConverter {

    @TypeConverter
    fun fromStatus(enum: DataSyncState?): String? {
        return enum?.name
    }

    @TypeConverter
    fun toStatus(value: String?): DataSyncState? {
        return value?.let {
            enumValueOf<DataSyncState>(it)
        }
    }
}

 

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.

@Database(
    entities = [
        User::class,
        Task::class,
    ],
    version = 1,
    exportSchema = false
)
@TypeConverters(
    DateTypeConverter::class,
    DataSyncStateTypeConverter::class,
)
abstract class AppDatabase : RoomDatabase() {

    abstract fun userDao(): UserDao

    abstract fun taskDao(): TaskDao

}

 

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.

class WeatherListTypeConverter {

    private val gson = GsonBuilder()
        .create()

    @TypeConverter
    fun listToString(list: List<DemoObject>): String {
        return gson.toJson(list)
    }

    @TypeConverter
    fun stringToList(data: String): List<DemoObject> {
        val type = object : TypeToken<List<DemoObject>>() {}.type
        return try {
            gson.fromJson(data, type)
        } catch (e: JsonParseException) {
            listOf()
        }
    }
}

 

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

@Entity(
    foreignKeys = [ForeignKey(
        entity = User::class,
        parentColumns = ["userId"],
        childColumns = ["taskId"],
        onDelete = ForeignKey.CASCADE,
    )]
)
data class Task(
    @PrimaryKey
    val taskId: String = "",
    
    val description: String,
    
    val userId: String, // Foreign key
)

 

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.

data class UserWithTasks(
    @Embedded val user: User,
    
    @Relation(
        parentColumn = "userId",
        entityColumn = "taskId"
    )
    val tasks: List<Task>,
)

 

A fenti kódrészlet elég egyértelmű, különösebb magyarázatra nem szorul.

Hogyan néz ki a UserDao?

@Dao
interface UserDao {

    @Query("select * from User")
    suspend fun getUsers(): List<User>

    @Query("select * from User where userId = :id")
    suspend fun getUser(id: String): User?

    @Query("SELECT * FROM User where userId = :id")
    suspend fun getUserWithTasks(id: String): UserWithTasks

    @Query("select * from User")
    fun getUsersFlow(): Flow<List<User>>

    @Query("select * from User where userId = :id")
    fun getUserFlow(id: String): Flow<User?>

    @Query("SELECT * FROM User where userId = :id")
    fun getUserWithTasksFlow(id: String): Flow<UserWithTasks>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertUser(user: User)

    @Update(onConflict = OnConflictStrategy.REPLACE)
    suspend fun updateUser(user: User)

    @Delete
    suspend fun deleteUser(user: User)

    @Query("delete from User")
    suspend fun deleteAll()
}

 

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.

@Dao
interface TaskDao {

    @Query("select * from Task where taskId = :id")
    suspend fun getTask(id: String): Task?

    @Query("select * from Task")
    suspend fun getTasks(): List<Task>

    @Query("select * from Task where taskId = :id")
    fun getTaskFlow(id: String): Flow<Task?>

    @Query("select * from Task")
    fun getTasksFlow(): Flow<List<Task>>

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertTask(task: Task)

    @Update(onConflict = OnConflictStrategy.REPLACE)
    suspend fun updateTask(task: Task)

    @Delete
    suspend fun deleteTask(task: Task)

    @Query("delete from Task")
    suspend fun deleteAll()
}

 

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

@Module
@InstallIn(SingletonComponent::class)
class DatabaseModule {

    @Singleton
    @Provides
    fun provideDataBase(
        @ApplicationContext context: Context,
        configuration: Configuration,
    ): AppDatabase {
        return Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            configuration.appDatabaseName
        ).build()
    }

    @Singleton
    @Provides
    fun provideUserDao(database: AppDatabase): UserDao {
        return database.userDao()
    }

    @Singleton
    @Provides
    fun provideTaskDao(database: AppDatabase): TaskDao {
        return database.taskDao()
    }
}

 

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:

interface UserStorage {

    suspend fun getUser(): User?

    suspend fun saveUser(user: User)

}

@Singleton
class UserStorageImpl @Inject constructor(
    private val userDao: UserDao,
): UserStorage {

    override suspend fun getUser() = userDao.getUser()

    override suspend fun saveUser(user: User) {
        userDao.insertUser(user)
    }
}

 

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

@Module
@InstallIn(SingletonComponent::class)
abstract class StorageModule {

    @Binds
    abstract fun provideUserStorage(userStorage: UserStorageImpl): UserStorage

    @Binds
    abstract fun provideTaskStorage(userStorage: TaskStorageImpl): TaskStorage

}

 


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?

class InternetConnectivityListener(private val context: Context) {

    val networkState = MutableStateFlow(NetworkState.UNKNOWN)

    private val connectivityManager: ConnectivityManager =
        context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager

    private val networkCallback = object : ConnectivityManager.NetworkCallback() {
        override fun onAvailable(network: Network) {
            super.onAvailable(network)
            networkState.value = NetworkState.CONNECTED
        }

        override fun onLost(network: Network) {
            super.onLost(network)
            networkState.value = NetworkState.NOT_CONNECTED
        }
    }

    init {
        registerNetworkCallback()
    }

    private fun registerNetworkCallback() {
        val networkRequest = NetworkRequest.Builder()
            .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
            .build()
        connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
    }

    fun unregisterNetworkCallback() {
        connectivityManager.unregisterNetworkCallback(networkCallback)
    }
}

 

interface GetNetworkStateUseCase {

    fun invoke(): Flow<NetworkState>
}

class GetNetworkStateUseCaseImpl @Inject constructor(
    @ApplicationContext
    private val applicationContext: Context,
) : GetNetworkStateUseCase {

    private val internetConnectivityListener by lazy {
        InternetConnectivityListener(applicationContext)
    }

    override fun invoke(): Flow<NetworkState> {
        return internetConnectivityListener.networkState
    }
}

 

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?

interface HasUnSyncedDataUseCase {

    fun invoke(): Flow<Boolean>
}

class HasUnSyncedDataUseCaseImpl @Inject constructor(
    private val userStorage: UserStorage,
    private val taskStorage: TaskStorage,
) : HasUnSyncedDataUseCase {

    override fun invoke(): Flow<Boolean> {
        val flows = listOf(
            userStorage.getUsersFlow(DataSyncState.IN_LOCAL_DATABASE),
            taskStorage.getTasksFlow(DataSyncState.IN_LOCAL_DATABASE),
        )

        return combine(flows) {
            val hasUnSyncedUsers = it[0].isNotEmpty()
            val hasUnSyncedTasks = it[1].isNotEmpty()

            hasUnSyncedUsers || hasUnSyncedTasks
        }
    }
}

 

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:

enum class DataSyncState {
    SYNCED_TO_CLOUD,
    IN_LOCAL_DATABASE,
    UNDEFINED,
}

 

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:

interface GetCurrentSyncStateUseCase {
    fun invoke(): Flow<SyncStateModel>
}

class GetCurrentSyncStateUseCaseImpl @Inject constructor(
    private val hasUnSyncedDataUseCase: HasUnSyncedDataUseCase,
    private val getNetworkStateUseCase: GetNetworkStateUseCase,
) : GetCurrentSyncStateUseCase {

    override fun invoke(): Flow<SyncStateModel> {
        return combine(
            getNetworkStateUseCase.invoke(),
            hasUnSyncedDataUseCase.invoke()
        ) { networkState, hasUnSyncedData ->
            when (networkState) {
                NetworkState.UNKNOWN, NetworkState.NOT_CONNECTED -> {
                    SyncStateModel(
                        state = SyncState.NO_INTERNET,
                    )
                }
                NetworkState.CONNECTED -> {
                    if (hasUnSyncedData) {
                        SyncStateModel(
                            state = SyncState.LAST_UPDATE,
                            lastUpdateTime = "Last update",
                        )
                    } else {
                        SyncStateModel(
                            state = SyncState.UP_TO_DATE,
                        )
                    }
                }
            }
        }
    }
}

enum class SyncState {
    UP_TO_DATE,
    NO_INTERNET,
    LAST_UPDATE;
}

data class SyncStateModel(
    val state: SyncState,
    val lastUpdateTime: String? = null
)

 

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.

@Query("select * from Batch where dataSyncState = :syncState")
suspend fun getUsers(syncState: DataSyncState): List<User>

 

Ha fel szeretnénk tölteni az adatokat, a következő módon tehetjük meg:

interface UploadUnSyncedDataUseCase {
    suspend fun invoke(): Job
}

class UploadUnSyncedDataUseCaseImpl @Inject constructor(
    private val userStorage: UserStorage,
    private val taskStorage: TaskStorage,
    private val uploadUserUseCase: UploadUserUseCase,
    private val uploadTaskUseCase: UploadTaskUseCase,
    private val updateLastSyncDateUseCase: UpdateLastSyncDateUseCase,
) : UploadUnSyncedDataUseCase {

    override suspend fun invoke() =
        CoroutineScope(Dispatchers.IO + SupervisorJob()).launch {

            // Sync users
            // Find all un synced user
            val unSyncedUsers = userStorage.getAllUser(DataSyncState.IN_LOCAL_DATABASE)

            // Upload all un synced user
            val userSyncJobs = unSyncedUsers.map { user ->
                async {
                    uploadUserUseCase.invoke(user)
                }
            }
            val userSyncResults = mutableListOf<Result<UserDTO>>()
            userSyncResults.addAll(userSyncJobs.awaitAll())

            // Sync tasks
            // Find all un synced task
            val unSyncedTasks = taskStorage.getAllTask(DataSyncState.IN_LOCAL_DATABASE)

            // Upload all un synced task
            val taskSyncJobs = unSyncedTasks.map { task ->
                async {
                    uploadTaskUseCase.invoke(task)
                }
            }
            val taskSyncResults = mutableListOf<Result<TaskDTO>>()
            taskSyncResults.addAll(taskSyncJobs.awaitAll())

            updateLastSyncDateUseCase.invoke()
        }
}

 

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!

A LogiNetnél natív és cross-platform mobil applikációk fejlesztésében is a segítségedre vagyunk. Professzionális szakembereink számos technológiát ismernek: natív iOS és Android applikációk készítése Kotlin, Swift technológiával, cross-platform megoldás Flutter technológiával. Mobil fejlesztői csapatunk erőforrás kiszervezési projektekben is részt vesz. Vedd fel kollégáinkkal a kapcsolatot!
LogiNet Mobile Dev Team

LogiNet Mobile Dev Team LogiNet Mobile Dev Team LinkedIn profilja

Natív iOS, Android és cross-platform mobil szakértők
Mobil app fejlesztői csapatunk tagjai komoly tapasztalattal rendelkeznek új, egyedi mobil applikáció készítésében, a meglévő mobil app továbbfejlesztésében, refaktorálásban. Naprakészek a legújabb technológiák terén. Céljuk, hogy a legmodernebb megoldásokat az elsők között építsék be a fejlesztett alkalmazásokba.