A mese arról, hogyan győzött a kaszkádtörlés a Realmban egy hosszú indítás felett

Minden felhasználó magától értetődőnek tekinti a mobilalkalmazások gyors indítását és érzékeny felhasználói felületét. Ha az alkalmazás elindítása sokáig tart, a felhasználó szomorú és dühös lesz. Könnyedén elronthatja a felhasználói élményt, vagy teljesen elveszítheti a felhasználót, még azelőtt, hogy elkezdené használni az alkalmazást.

Egyszer felfedeztük, hogy a Dodo Pizza alkalmazás átlagosan 3 másodperc alatt indul el, néhány „szerencsésnek” pedig 15-20 másodperc.

A vágás alatt egy happy enddel végződő történet: a Realm adatbázis növekedéséről, egy memóriaszivárgásról, arról, hogyan halmoztunk fel egymásba ágyazott objektumokat, majd összeszedtük magunkat és mindent megjavítottunk.

A mese arról, hogyan győzött a kaszkádtörlés a Realmban egy hosszú indítás felett

A mese arról, hogyan győzött a kaszkádtörlés a Realmban egy hosszú indítás felett
A cikk szerzője: Maxim Kachinkin — Android fejlesztő a Dodo Pizza-nál.

Három másodperc az alkalmazás ikonjára való kattintástól az első tevékenység onResume()-ig a végtelenségig tart. Néhány felhasználónál az indítási idő elérte a 15-20 másodpercet. Hogyan lehetséges ez egyáltalán?

Nagyon rövid összefoglaló azoknak, akiknek nincs idejük olvasni
A Realm adatbázisunk végtelenül bővült. Néhány beágyazott objektum nem törlődött, hanem folyamatosan felhalmozódott. Az alkalmazás indítási ideje fokozatosan nőtt. Aztán kijavítottuk, és az indítási idő elérte a célt - kevesebb, mint 1 másodperc volt, és már nem nőtt. A cikk a helyzet elemzését és két megoldást tartalmaz - egy gyors és egy normál.

A probléma keresése és elemzése

Ma minden mobilalkalmazásnak gyorsan el kell indulnia, és érzékenynek kell lennie. De ez nem csak a mobilalkalmazásról szól. A szolgáltatással és a céggel való interakció felhasználói élménye összetett dolog. Például a mi esetünkben a szállítási sebesség az egyik legfontosabb mutató a pizza szolgáltatásnál. Ha gyors a kiszállítás, akkor a pizza forró lesz, és nem kell sokáig várnia annak a vásárlónak, aki most szeretne enni. Az alkalmazásnál viszont fontos a gyors kiszolgálás érzésének megteremtése, hiszen ha csak 20 másodpercet vesz igénybe az alkalmazás indulása, akkor mennyit kell várni a pizzára?

Eleinte mi magunk is szembesültünk azzal, hogy néha pár másodpercig tart az alkalmazás elindítása, majd más kollégáktól is elkezdtünk panaszkodni, hogy mennyi ideig tart. De nem tudtuk következetesen megismételni ezt a helyzetet.

Meddig? Alapján Google dokumentáció, ha egy alkalmazás hidegindítása 5 másodpercnél rövidebb ideig tart, akkor ez „normálisnak” minősül. Elindult a Dodo Pizza Android-alkalmazás (a Firebase mutatói szerint _app_start) nál nél hideg indítás átlagosan 3 másodperc alatt - „Nem nagyszerű, nem szörnyű”, ahogy mondják.

De aztán panaszok kezdtek megjelenni, hogy az alkalmazás elindítása nagyon-nagyon-nagyon sokáig tartott! Először úgy döntöttünk, hogy megmérjük, mi az a „nagyon, nagyon, nagyon hosszú”. És ehhez Firebase nyomkövetést használtunk Alkalmazás indítási nyomkövetése.

A mese arról, hogyan győzött a kaszkádtörlés a Realmban egy hosszú indítás felett

Ez a szabványos nyomkövetés méri az időt az alkalmazás felhasználó általi megnyitása és az első tevékenység onResume() végrehajtása között. A Firebase-konzolban ennek a mutatónak a neve _app_start. Kiderült, hogy:

  • A 95. percentilis feletti felhasználók indítási ideje közel 20 másodperc (néhányan még ennél is hosszabb), annak ellenére, hogy a hidegindítási idő mediánja kevesebb, mint 5 másodperc.
  • Az indítási idő nem állandó érték, hanem idővel növekszik. De néha vannak cseppek. Ezt a mintát akkor találtuk meg, amikor az elemzés skáláját 90 napra növeltük.

A mese arról, hogyan győzött a kaszkádtörlés a Realmban egy hosszú indítás felett

Két gondolat jutott eszembe:

  1. Valami szivárog.
  2. Ez a „valami” visszaállításra kerül a kioldás után, majd ismét szivárog.

„Valószínűleg valami az adatbázissal van” – gondoltuk, és igazunk volt. Először is az adatbázist gyorsítótárként használjuk, a migráció során töröljük. Másodszor, az adatbázis betöltődik az alkalmazás indításakor. Minden összeillik.

Mi a baj a Realm adatbázissal?

Elkezdtük ellenőrizni, hogyan változik az adatbázis tartalma az alkalmazás élettartama során, az első telepítéstől kezdve, majd az aktív használat során. A Realm adatbázis tartalmát a következőn keresztül tekintheti meg sztetho vagy részletesebben és egyértelműen a fájl megnyitásával Realm Stúdió. Az adatbázis tartalmának ADB-n keresztüli megtekintéséhez másolja ki a Realm adatbázisfájlt:

adb exec-out run-as ${PACKAGE_NAME} cat files/${DB_NAME}

Az adatbázis tartalmát különböző időpontokban megvizsgálva azt találtuk, hogy egy bizonyos típusú objektumok száma folyamatosan növekszik.

A mese arról, hogyan győzött a kaszkádtörlés a Realmban egy hosszú indítás felett
A képen a Realm Studio töredéke látható két fájlhoz: bal oldalon - az alkalmazás alapja valamivel a telepítés után, jobb oldalon - aktív használat után. Látható, hogy az objektumok száma ImageEntity и MoneyType jelentősen megnőtt (a képernyőképen az egyes típusú objektumok száma látható).

Az adatbázis növekedése és az indítási idő közötti kapcsolat

Az adatbázis kontrollálatlan növekedése nagyon rossz. De hogyan befolyásolja ez az alkalmazás indítási idejét? Ezt meglehetősen könnyű mérni az ActivityManager segítségével. Az Android 4.4 óta a logcat megjeleníti a naplót a Megjelenített karakterlánccal és az idővel. Ez az idő megegyezik az alkalmazás elindításának pillanatától a tevékenység megjelenítésének végéig eltelt idővel. Ez idő alatt a következő események történnek:

  • Indítsa el a folyamatot.
  • Objektumok inicializálása.
  • Tevékenységek létrehozása és inicializálása.
  • Elrendezés készítése.
  • Alkalmazás renderelés.

Nekünk megfelel. Ha az ADB-t -S és -W jelzőkkel futtatja, akkor kiterjesztett kimenetet kaphat az indítási idővel:

adb shell am start -S -W ru.dodopizza.app/.MainActivity -c android.intent.category.LAUNCHER -a android.intent.action.MAIN

Ha onnan megragad grep -i WaitTime idővel automatizálhatja ennek a mutatónak a gyűjtését, és vizuálisan megtekintheti az eredményeket. Az alábbi grafikon az alkalmazás indítási idejének függését mutatja az alkalmazás hidegindításainak számától.

A mese arról, hogyan győzött a kaszkádtörlés a Realmban egy hosszú indítás felett

Ugyanakkor hasonló volt a kapcsolat az adatbázis mérete és növekedése között, amely 4 MB-ról 15 MB-ra nőtt. Összességében kiderült, hogy idővel (a hidegindítások növekedésével) mind az alkalmazásindítási idő, mind az adatbázis mérete nőtt. Van egy hipotézis a kezünkben. Most már csak a függőség megerősítése maradt hátra. Ezért úgy döntöttünk, hogy megszüntetjük a „szivárgást”, és megnézzük, hogy ez felgyorsítja-e az indítást.

Az adatbázisok végtelen növekedésének okai

A „szivárgások” eltávolítása előtt érdemes megérteni, miért jelentek meg először. Ehhez emlékezzünk meg, mi is az a Realm.

A Realm egy nem relációs adatbázis. Lehetővé teszi az objektumok közötti kapcsolatok leírását az Android rendszeren található ORM relációs adatbázisokhoz hasonló módon. Ugyanakkor a Realm közvetlenül a memóriában tárolja az objektumokat a legkevesebb transzformációval és leképezéssel. Ez lehetővé teszi az adatok gyors kiolvasását a lemezről, ami a Realm erőssége, és ezért is szeretik.

(A cikk céljaira nekünk ez a leírás elég lesz. A Realmról bővebben a menőben olvashat dokumentáció vagy az övéikben akadémia).

Sok fejlesztő hozzászokott ahhoz, hogy többet dolgozzon relációs adatbázisokkal (például ORM-adatbázisokkal, amelyek SQL-t tartalmaznak). És az olyan dolgok, mint például a lépcsőzetes adattörlés, gyakran adottnak tűnnek. De nem a Realmban.

A kaszkádtörlés funkciót egyébként már régóta kérik. Ez felülvizsgálat и egy másik, amelyhez kapcsolódik, aktívan megvitatásra került. Érezte, hogy hamarosan elkészül. De aztán minden erős és gyenge láncszemek bevezetésébe torkollott, ami szintén automatikusan megoldaná ezt a problémát. Elég élénk és aktív volt ebben a feladatban pull kérés, amely belső nehézségek miatt egyelőre szünetel.

Adatszivárgás lépcsőzetes törlés nélkül

Pontosan hogyan szivárognak ki adatok, ha egy nem létező lépcsőzetes törlésre támaszkodik? Ha beágyazott Realm objektumai vannak, akkor azokat törölni kell.
Nézzünk egy (majdnem) valós példát. Van egy tárgyunk CartItemEntity:

@RealmClass
class CartItemEntity(
 @PrimaryKey
 override var id: String? = null,
 ...
 var name: String = "",
 var description: String = "",
 var image: ImageEntity? = null,
 var category: String = MENU_CATEGORY_UNKNOWN_ID,
 var customizationEntity: CustomizationEntity? = null,
 var cartComboProducts: RealmList<CartProductEntity> = RealmList(),
 ...
) : RealmObject()

A kosárban lévő termék különböző mezőket tartalmaz, beleértve a képet ImageEntity, testreszabott összetevők CustomizationEntity. Ezenkívül a kosárban lévő termék lehet kombinált saját termékkészlettel RealmList (CartProductEntity). Minden felsorolt ​​mező tartomány objektum. Ha beszúrunk egy új objektumot (copyToRealm() / copyToRealmOrUpdate()) ugyanazzal az azonosítóval, akkor ez az objektum teljesen felül lesz írva. De minden belső objektum (image, customizationEntity és cartComboProducts) elveszíti a kapcsolatot a szülővel, és az adatbázisban marad.

Mivel a velük való kapcsolat megszakadt, többé nem olvassuk el és nem töröljük őket (hacsak nem férünk hozzá kifejezetten, vagy nem töröljük a teljes „táblázatot”). Ezt „memóriaszivárgásnak” neveztük.

Amikor a Realmmal dolgozunk, kifejezetten végig kell mennünk az összes elemen, és explicit módon mindent törölnünk kell az ilyen műveletek előtt. Ez megtehető például a következőképpen:

val entity = realm.where(CartItemEntity::class.java).equalTo("id", id).findFirst()
if (first != null) {
 deleteFromRealm(first.image)
 deleteFromRealm(first.customizationEntity)
 for(cartProductEntity in first.cartComboProducts) {
   deleteFromRealm(cartProductEntity)
 }
 first.deleteFromRealm()
}
// и потом уже сохраняем

Ha ezt megteszi, akkor minden úgy fog működni, ahogy kell. Ebben a példában feltételezzük, hogy a képen, a customizationEntity-n és a cartComboProducts-on belül nincsenek más beágyazott Realm objektumok, így nincsenek más beágyazott hurkok és törlések.

"Gyors" megoldás

Az első dolgunk volt, hogy megtisztítjuk a leggyorsabban növekvő objektumokat, és ellenőrizzük az eredményeket, hátha ez megoldja eredeti problémánkat. Először a legegyszerűbb és legintuitívabb megoldás született, nevezetesen: minden tárgynak felelősnek kell lennie gyermekei eltávolításáért. Ehhez bevezettünk egy felületet, amely visszaadta a beágyazott Realm objektumok listáját:

interface NestedEntityAware {
 fun getNestedEntities(): Collection<RealmObject?>
}

És megvalósítottuk a Realm objektumainkban:

@RealmClass
class DataPizzeriaEntity(
 @PrimaryKey
 var id: String? = null,
 var name: String? = null,
 var coordinates: CoordinatesEntity? = null,
 var deliverySchedule: ScheduleEntity? = null,
 var restaurantSchedule: ScheduleEntity? = null,
 ...
) : RealmObject(), NestedEntityAware {

 override fun getNestedEntities(): Collection<RealmObject?> {
   return listOf(
       coordinates,
       deliverySchedule,
       restaurantSchedule
   )
 }
}

В getNestedEntities minden gyereket lapos listaként adunk vissza. És minden gyermekobjektum megvalósíthatja a NestedEntityAware felületet is, jelezve, hogy vannak belső Realm objektumai, amelyeket törölni kell, pl. ScheduleEntity:

@RealmClass
class ScheduleEntity(
 var monday: DayOfWeekEntity? = null,
 var tuesday: DayOfWeekEntity? = null,
 var wednesday: DayOfWeekEntity? = null,
 var thursday: DayOfWeekEntity? = null,
 var friday: DayOfWeekEntity? = null,
 var saturday: DayOfWeekEntity? = null,
 var sunday: DayOfWeekEntity? = null
) : RealmObject(), NestedEntityAware {

 override fun getNestedEntities(): Collection<RealmObject?> {
   return listOf(
       monday, tuesday, wednesday, thursday, friday, saturday, sunday
   )
 }
}

És így tovább, az objektumok egymásba ágyazása megismételhető.

Ezután írunk egy metódust, amely rekurzív módon törli az összes beágyazott objektumot. Módszer (kiterjesztésként készült) deleteAllNestedEntities megkapja az összes legfelső szintű objektumot és metódust deleteNestedRecursively Rekurzív módon eltávolítja az összes beágyazott objektumot a NestedEntityAware felület segítségével:

fun <T> Realm.deleteAllNestedEntities(entities: Collection<T>,
 entityClass: Class<out RealmObject>,
 idMapper: (T) -> String,
 idFieldName : String = "id"
 ) {

 val existedObjects = where(entityClass)
     .`in`(idFieldName, entities.map(idMapper).toTypedArray())
     .findAll()

 deleteNestedRecursively(existedObjects)
}

private fun Realm.deleteNestedRecursively(entities: Collection<RealmObject?>) {
 for(entity in entities) {
   entity?.let { realmObject ->
     if (realmObject is NestedEntityAware) {
       deleteNestedRecursively((realmObject as NestedEntityAware).getNestedEntities())
     }
     realmObject.deleteFromRealm()
   }
 }
}

Ezt a leggyorsabban növekvő tárgyakkal tettük, és ellenőriztük, mi történt.

A mese arról, hogyan győzött a kaszkádtörlés a Realmban egy hosszú indítás felett

Ennek eredményeként azok a tárgyak, amelyeket ezzel a megoldással borítottunk, leálltak a növekedésben. És a bázis általános növekedése lelassult, de nem állt meg.

A "normális" megoldás

Bár a bázis lassabban kezdett növekedni, mégis növekedett. Így hát elkezdtünk tovább keresni. Projektünk nagyon aktívan használja az adatgyorsítótárat a Realmban. Emiatt minden objektumhoz minden beágyazott objektum írása munkaigényes, ráadásul megnő a hibaveszély is, mert a kód megváltoztatásakor elfelejthetjük megadni az objektumokat.

Meg akartam győződni arról, hogy nem interfészeket használok, hanem minden működjön magától.

Amikor azt akarjuk, hogy valami magától működjön, reflexiót kell használnunk. Ehhez végigmegyünk minden osztálymezőn, és ellenőrizzük, hogy Realm objektumról vagy objektumlistáról van-e szó:

RealmModel::class.java.isAssignableFrom(field.type)

RealmList::class.java.isAssignableFrom(field.type)

Ha a mező RealmModel vagy RealmList, akkor adja hozzá a mező objektumát a beágyazott objektumok listájához. Minden pontosan ugyanaz, mint fentebb, csak itt ez magától megtörténik. Maga a kaszkádtörlési módszer nagyon egyszerű, és így néz ki:

fun <T : Any> Realm.cascadeDelete(entities: Collection<T?>) {
 if(entities.isEmpty()) {
   return
 }

 entities.filterNotNull().let { notNullEntities ->
   notNullEntities
       .filterRealmObject()
       .flatMap { realmObject -> getNestedRealmObjects(realmObject) }
       .also { realmObjects -> cascadeDelete(realmObjects) }

   notNullEntities
       .forEach { entity ->
         if((entity is RealmObject) && entity.isValid) {
           entity.deleteFromRealm()
         }
       }
 }
}

Kiterjesztés filterRealmObject csak a Realm objektumokat szűri ki és továbbítja. Módszer getNestedRealmObjects A tükrözés révén megtalálja az összes beágyazott tartományobjektumot, és lineáris listába helyezi őket. Ezután ugyanezt rekurzívan csináljuk. Törléskor ellenőrizni kell az objektum érvényességét isValid, mert előfordulhat, hogy a különböző szülőobjektumokban azonosak lehetnek egymásba ágyazottak. Jobb ezt elkerülni, és egyszerűen az azonosító automatikus generálását használni új objektumok létrehozásakor.

A mese arról, hogyan győzött a kaszkádtörlés a Realmban egy hosszú indítás felett

A getNestedRealmObjects metódus teljes megvalósítása

private fun getNestedRealmObjects(realmObject: RealmObject) : List<RealmObject> {
 val nestedObjects = mutableListOf<RealmObject>()
 val fields = realmObject.javaClass.superclass.declaredFields

// Проверяем каждое поле, не является ли оно RealmModel или списком RealmList
 fields.forEach { field ->
   when {
     RealmModel::class.java.isAssignableFrom(field.type) -> {
       try {
         val child = getChildObjectByField(realmObject, field)
         child?.let {
           if (isInstanceOfRealmObject(it)) {
             nestedObjects.add(child as RealmObject)
           }
         }
       } catch (e: Exception) { ... }
     }

     RealmList::class.java.isAssignableFrom(field.type) -> {
       try {
         val childList = getChildObjectByField(realmObject, field)
         childList?.let { list ->
           (list as RealmList<*>).forEach {
             if (isInstanceOfRealmObject(it)) {
               nestedObjects.add(it as RealmObject)
             }
           }
         }
       } catch (e: Exception) { ... }
     }
   }
 }

 return nestedObjects
}

private fun getChildObjectByField(realmObject: RealmObject, field: Field): Any? {
 val methodName = "get${field.name.capitalize()}"
 val method = realmObject.javaClass.getMethod(methodName)
 return method.invoke(realmObject)
}

Ennek eredményeként a klienskódunkban minden adatmódosítási műveletnél „lépcsőzetes törlést” alkalmazunk. Például egy beszúrási műveletnél így néz ki:

override fun <T : Entity> insert(
 entityInformation: EntityInformation,
 entities: Collection<T>): Collection<T> = entities.apply {
 realmInstance.cascadeDelete(getManagedEntities(entityInformation, this))
 realmInstance.copyFromRealm(
     realmInstance
         .copyToRealmOrUpdate(this.map { entity -> entity as RealmModel }
 ))
}

Először a módszer getManagedEntities megkapja az összes hozzáadott objektumot, majd a metódust cascadeDelete Rekurzívan törli az összes összegyűjtött objektumot, mielőtt újakat írna. Végül ezt a megközelítést alkalmazzuk az alkalmazás során. A Realm memóriaszivárgása teljesen megszűnt. Miután ugyanazt a mérést elvégeztük az indítási idő függésének az alkalmazás hidegindításainak számától, látjuk az eredményt.

A mese arról, hogyan győzött a kaszkádtörlés a Realmban egy hosszú indítás felett

A zöld vonal az alkalmazás indítási idejének függőségét mutatja a hidegindítások számától a beágyazott objektumok automatikus kaszkádtörlése során.

Eredmények és következtetések

Az egyre növekvő Realm adatbázis miatt az alkalmazás nagyon lassan indult el. Kiadtunk egy frissítést a beágyazott objektumok saját „lépcsőzetes törlésével”. Most pedig az _app_start mutatón keresztül figyeljük és értékeljük, hogy döntésünk hogyan befolyásolta az alkalmazás indítási idejét.

A mese arról, hogyan győzött a kaszkádtörlés a Realmban egy hosszú indítás felett

Az elemzéshez 90 napos időszakot veszünk fel, és látjuk: az alkalmazásindítási idő, mind a medián, mind a felhasználók 95. százalékára eső idő, csökkenni kezdett, és már nem emelkedik.

A mese arról, hogyan győzött a kaszkádtörlés a Realmban egy hosszú indítás felett

Ha megnézi a hét napos diagramot, az _app_start mutató teljesen megfelelőnek tűnik, és kevesebb, mint 1 másodperc.

Azt is érdemes hozzátenni, hogy alapértelmezés szerint a Firebase értesítést küld, ha az _app_start medián értéke meghaladja az 5 másodpercet. Azonban, mint látjuk, nem szabad erre hagyatkozni, inkább menjen be és ellenőrizze kifejezetten.

A Realm adatbázis különlegessége, hogy nem relációs adatbázis. Könnyű kezelhetősége, az ORM-megoldásokhoz való hasonlósága és az objektumok összekapcsolása ellenére nem rendelkezik kaszkádtörléssel.

Ha ezt nem vesszük figyelembe, a beágyazott objektumok felhalmozódnak és „elszivárognak”. Az adatbázis folyamatosan növekszik, ami viszont hatással lesz az alkalmazás lelassulására vagy indulására.

Megosztottam tapasztalatainkat arról, hogyan lehet gyorsan végrehajtani az objektumok lépcsőzetes törlését a Realmban, ami még nem került ki a dobozból, de már régóta beszélnek róla mond и mond. Esetünkben ez nagymértékben felgyorsította az alkalmazás indítási idejét.

A funkció küszöbön álló megjelenéséről szóló vita ellenére a kaszkádtörlés hiánya a Realmban a tervezésből fakad. Ha új alkalmazást tervez, akkor ezt vegye figyelembe. És ha már használja a Realmot, ellenőrizze, hogy vannak-e ilyen problémái.

Forrás: will.com

Hozzászólás