Povestea cum ștergerea în cascadă în Realm a câștigat o lansare lungă

Toți utilizatorii consideră de la sine lansarea rapidă și interfața de utilizare receptivă în aplicațiile mobile. Dacă lansarea aplicației durează mult, utilizatorul începe să se simtă trist și supărat. Puteți strica cu ușurință experiența clientului sau puteți pierde complet utilizatorul chiar înainte de a începe să folosească aplicația.

Am descoperit odată că aplicația Dodo Pizza durează în medie 3 secunde pentru a se lansa, iar pentru unii „norocoși” durează 15-20 de secunde.

Sub tăietură este o poveste cu un final fericit: despre creșterea bazei de date Realm, o scurgere de memorie, cum am acumulat obiecte imbricate, apoi ne-am strâns și am remediat totul.

Povestea cum ștergerea în cascadă în Realm a câștigat o lansare lungă

Povestea cum ștergerea în cascadă în Realm a câștigat o lansare lungă
Autor articol: Maxim Kachinkin - Android-dezvoltator la Dodo Pizza.

Trei secunde de la clic pe pictograma aplicației până la onResume() a primei activități este infinit. Iar pentru unii utilizatori, timpul de pornire a ajuns la 15-20 de secunde. Cum este posibil acest lucru?

Un rezumat foarte scurt pentru cei care nu au timp să citească
Baza noastră de date despre tărâm a crescut la nesfârșit. Unele obiecte imbricate nu au fost șterse, ci au fost acumulate constant. Timpul de pornire a aplicației a crescut treptat. Apoi l-am reparat și timpul de pornire a ajuns la țintă - a devenit mai puțin de 1 secundă și nu a mai crescut. Articolul conține o analiză a situației și două soluții - una rapidă și una normală.

Căutarea și analiza problemei

Astăzi, orice aplicație mobilă trebuie să se lanseze rapid și să fie receptivă. Dar nu este vorba doar despre aplicația mobilă. Experiența utilizatorului de interacțiune cu un serviciu și o companie este un lucru complex. De exemplu, în cazul nostru, viteza de livrare este unul dintre indicatorii cheie pentru serviciul de pizza. Dacă livrarea este rapidă, pizza va fi fierbinte, iar clientul care vrea să mănânce acum nu va trebui să aștepte mult. Pentru aplicație, la rândul său, este important să se creeze un sentiment de serviciu rapid, deoarece dacă aplicația durează doar 20 de secunde pentru a se lansa, atunci cât timp va trebui să așteptați pizza?

La început, ne-am confruntat noi înșine cu faptul că, uneori, lansarea aplicației a durat câteva secunde, iar apoi am început să auzim plângeri de la alți colegi despre cât de mult a durat. Dar nu am putut repeta constant această situație.

Cât de mult este? Conform documentație GoogleDacă o pornire la rece a unei aplicații durează mai puțin de 5 secunde, este considerată „oarecum normală”. Android- a fost lansată aplicația Dodo Pizza (conform metricilor Firebase) _app_start) la pornire la rece în medie în 3 secunde - „Nu grozav, nu groaznic”, așa cum se spune.

Dar apoi au început să apară plângeri că lansarea aplicației a durat foarte, foarte, foarte mult! Pentru început, am decis să măsurăm ce înseamnă „foarte, foarte, foarte lung”. Și am folosit urmărirea Firebase pentru asta Urmărirea pornirii aplicației.

Povestea cum ștergerea în cascadă în Realm a câștigat o lansare lungă

Această urmărire standard măsoară timpul dintre momentul în care utilizatorul deschide aplicația și momentul în care este executat onResume() al primei activități. În Consola Firebase, această valoare se numește _app_start. S-a dovedit ca:

  • Timpul de pornire pentru utilizatorii de peste percentila 95 este de aproape 20 de secunde (unele chiar mai lungi), în ciuda timpului mediu de pornire la rece este mai mic de 5 secunde.
  • Timpul de pornire nu este o valoare constantă, ci crește în timp. Dar uneori apar picături. Am găsit acest model când am mărit scara de analiză la 90 de zile.

Povestea cum ștergerea în cascadă în Realm a câștigat o lansare lungă

Mi-au venit în minte două gânduri:

  1. Se scurge ceva.
  2. Acest „ceva” este resetat după eliberare și apoi se scurge din nou.

„Probabil ceva cu baza de date”, ne-am gândit și am avut dreptate. În primul rând, folosim baza de date ca cache în timpul migrării; În al doilea rând, baza de date este încărcată la pornirea aplicației. Totul se potrivește.

Ce este în neregulă cu baza de date Realm

Am început să verificăm cum se modifică conținutul bazei de date pe durata de viață a aplicației, de la prima instalare și mai departe în timpul utilizării active. Puteți vizualiza conținutul bazei de date Realm prin Stetho sau mai detaliat și clar prin deschiderea fișierului prin Realm Studio. Pentru a vizualiza conținutul bazei de date prin ADB, copiați fișierul bazei de date Realm:

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

Privind conținutul bazei de date în momente diferite, am aflat că numărul de obiecte de un anumit tip crește constant.

Povestea cum ștergerea în cascadă în Realm a câștigat o lansare lungă
Imaginea arată un fragment din Realm Studio pentru două fișiere: în stânga - baza aplicației la ceva timp după instalare, în dreapta - după utilizarea activă. Se poate observa că numărul de obiecte ImageEntity и MoneyType a crescut semnificativ (captura de ecran arată numărul de obiecte de fiecare tip).

Relația dintre creșterea bazei de date și timpul de pornire

Creșterea necontrolată a bazei de date este un lucru foarte rău. Dar cum afectează timpul de pornire al aplicației? Măsurarea acestui lucru este destul de ușoară folosind ActivityManager. Începând cu Android În versiunea 4.4, logcat afișează un jurnal cu linia „Afișat” și ora. Acest timp este intervalul de la lansarea aplicației până la sfârșitul randării activității. În acest timp, au loc următoarele evenimente:

  • Începeți procesul.
  • Inițializarea obiectelor.
  • Crearea si initializarea activitatilor.
  • Crearea unui aspect.
  • Redarea aplicației.

Ni se potrivește. Dacă rulați ADB cu steagurile -S și -W, puteți obține rezultate extinse cu timpul de pornire:

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

Dacă o apuci de acolo grep -i WaitTime timp, puteți automatiza colectarea acestei valori și puteți privi vizual rezultatele. Graficul de mai jos arată dependența timpului de pornire a aplicației de numărul de porniri la rece ale aplicației.

Povestea cum ștergerea în cascadă în Realm a câștigat o lansare lungă

În același timp, a existat aceeași natură a relației dintre dimensiunea și creșterea bazei de date, care a crescut de la 4 MB la 15 MB. În total, se dovedește că în timp (odată cu creșterea pornirilor la rece), atât timpul de lansare a aplicației, cât și dimensiunea bazei de date au crescut. Avem o ipoteză pe mâini. Acum nu mai rămânea decât să confirmăm dependența. Prin urmare, am decis să eliminăm „scurgerile” și să vedem dacă acest lucru ar grăbi lansarea.

Motive pentru creșterea nesfârșită a bazei de date

Înainte de a elimina „scurgerile”, merită să înțelegeți de ce au apărut în primul rând. Pentru a face acest lucru, să ne amintim ce este Tărâmul.

Realm este o bază de date nerelațională. Vă permite să descrieți relațiile dintre obiecte într-un mod similar cu modul în care multe baze de date relaționale ORM le descriu. AndroidRealm stochează obiectele direct în memorie cu transformări și mapări minime. Acest lucru permite citirea extrem de rapidă a datelor de pe disc, un punct forte cheie al Realm și una dintre cele mai populare caracteristici ale sale.

(În scopul acestui articol, această descriere ne va fi suficientă. Puteți citi mai multe despre Realm în cool documentație sau în lor academii).

Mulți dezvoltatori sunt obișnuiți să lucreze mai mult cu baze de date relaționale (de exemplu, baze de date ORM cu SQL sub capotă). Și lucruri precum ștergerea în cascadă a datelor par adesea a fi date. Dar nu în Regat.

Apropo, funcția de ștergere în cascadă a fost cerută de mult timp. Acest revizuire и o alta, asociat cu acesta, a fost discutat activ. Avea sentimentul că se va termina în curând. Dar apoi totul s-a tradus în introducerea de legături puternice și slabe, care ar rezolva automat această problemă. A fost destul de activ și activ în această sarcină cerere de tragere, care a fost întreruptă deocamdată din cauza dificultăților interne.

Scurgere de date fără ștergere în cascadă

Cum se scurg datele exact dacă vă bazați pe o ștergere în cascadă inexistentă? Dacă aveți obiecte Realm imbricate, atunci acestea trebuie șterse.
Să ne uităm la un exemplu (aproape) real. Avem un obiect 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()

Produsul din coș are diferite câmpuri, inclusiv o poză ImageEntity, ingrediente personalizate CustomizationEntity. De asemenea, produsul din coș poate fi un combo cu propriul set de produse RealmList (CartProductEntity). Toate câmpurile listate sunt obiecte Realm. Dacă introducem un obiect nou (copyToRealm() / copyToRealmOrUpdate()) cu același id, atunci acest obiect va fi complet suprascris. Dar toate obiectele interne (imagine, customizationEntity și cartComboProducts) își vor pierde legătura cu părintele și vor rămâne în baza de date.

Deoarece conexiunea cu acestea s-a pierdut, nu le mai citim și nu le mai ștergem (cu excepția cazului în care le accesăm în mod explicit sau ștergem întregul „tabel”). Am numit acest lucru „scurgeri de memorie”.

Când lucrăm cu Realm, trebuie să parcurgem în mod explicit toate elementele și să ștergem în mod explicit totul înainte de astfel de operațiuni. Acest lucru se poate face, de exemplu, astfel:

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()
}
// и потом уже сохраняем

Dacă faci asta, atunci totul va funcționa așa cum ar trebui. În acest exemplu, presupunem că nu există alte obiecte Realm imbricate în interiorul imaginii, customizationEntity și cartComboProducts, deci nu există alte bucle imbricate și ștergeri.

Soluție rapidă

Primul lucru pe care am decis să-l facem a fost să curățăm obiectele cu cea mai rapidă creștere și să verificăm rezultatele pentru a vedea dacă acest lucru ne va rezolva problema inițială. În primul rând, a fost făcută cea mai simplă și mai intuitivă soluție și anume: fiecare obiect ar trebui să fie responsabil pentru îndepărtarea copiilor săi. Pentru a face acest lucru, am introdus o interfață care a returnat o listă a obiectelor din domeniul său imbricate:

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

Și l-am implementat în obiectele noastre Realm:

@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 returnăm toți copiii ca o listă plată. Și fiecare obiect copil poate implementa, de asemenea, interfața NestedEntityAware, ceea ce indică faptul că are obiecte interne Realm de șters, de exemplu 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
   )
 }
}

Și așa mai departe, cuibărirea obiectelor poate fi repetată.

Apoi scriem o metodă care șterge recursiv toate obiectele imbricate. Metodă (realizată ca extensie) deleteAllNestedEntities primește toate obiectele și metoda de nivel superior deleteNestedRecursively elimină recursiv toate obiectele imbricate folosind interfața NestedEntityAware:

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()
   }
 }
}

Am făcut asta cu obiectele cu cea mai rapidă creștere și am verificat ce s-a întâmplat.

Povestea cum ștergerea în cascadă în Realm a câștigat o lansare lungă

Drept urmare, acele obiecte pe care le-am acoperit cu această soluție au încetat să crească. Și creșterea generală a bazei a încetinit, dar nu s-a oprit.

Soluția „normală”.

Deși baza a început să crească mai încet, tot a crescut. Așa că am început să căutăm mai departe. Proiectul nostru folosește foarte activ stocarea în cache a datelor în Realm. Prin urmare, scrierea tuturor obiectelor imbricate pentru fiecare obiect necesită multă muncă, plus riscul erorilor crește, deoarece puteți uita să specificați obiectele atunci când schimbați codul.

Am vrut să mă asigur că nu folosesc interfețe, ci că totul funcționează singur.

Când vrem ca ceva să funcționeze singur, trebuie să folosim reflecția. Pentru a face acest lucru, putem parcurge fiecare câmp de clasă și putem verifica dacă este un obiect Realm sau o listă de obiecte:

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

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

Dacă câmpul este un RealmModel sau RealmList, atunci adăugați obiectul acestui câmp la o listă de obiecte imbricate. Totul este exact la fel ca noi mai sus, doar că aici se va face de la sine. Metoda de ștergere în cascadă în sine este foarte simplă și arată astfel:

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()
         }
       }
 }
}

Extensie filterRealmObject filtrează și trece numai obiecte din tărâm. Metodă getNestedRealmObjects prin reflecție, găsește toate obiectele Realm imbricate și le pune într-o listă liniară. Apoi facem același lucru recursiv. Când ștergeți, trebuie să verificați validitatea obiectului isValid, deoarece este posibil ca diferite obiecte părinte să aibă imbricate obiecte identice. Este mai bine să evitați acest lucru și să utilizați pur și simplu generarea automată a id-ului atunci când creați obiecte noi.

Povestea cum ștergerea în cascadă în Realm a câștigat o lansare lungă

Implementarea completă a metodei getNestedRealmObjects

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)
}

Drept urmare, în codul nostru client folosim „Ștergerea în cascadă” pentru fiecare operațiune de modificare a datelor. De exemplu, pentru o operație de inserare arată astfel:

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 }
 ))
}

Mai întâi metoda getManagedEntities primește toate obiectele adăugate și apoi metoda cascadeDelete Șterge recursiv toate obiectele colectate înainte de a scrie altele noi. Sfârșim prin a folosi această abordare pe tot parcursul aplicației. Scurgerile de memorie din Realm au dispărut complet. După ce am efectuat aceeași măsurare a dependenței timpului de pornire de numărul de porniri la rece ale aplicației, vedem rezultatul.

Povestea cum ștergerea în cascadă în Realm a câștigat o lansare lungă

Linia verde arată dependența timpului de pornire a aplicației de numărul de porniri la rece în timpul ștergerii automate în cascadă a obiectelor imbricate.

Rezultate și concluzii

Baza de date Realm, în continuă creștere, a făcut ca aplicația să se lanseze foarte lent. Am lansat o actualizare cu propria noastră „ștergere în cascadă” a obiectelor imbricate. Și acum monitorizăm și evaluăm modul în care decizia noastră a afectat timpul de pornire a aplicației prin intermediul valorii _app_start.

Povestea cum ștergerea în cascadă în Realm a câștigat o lansare lungă

Pentru analiză luăm o perioadă de timp de 90 de zile și vedem: timpul de lansare a aplicației, atât mediana cât și cea care se încadrează pe percentila 95 de utilizatori, a început să scadă și nu mai crește.

Povestea cum ștergerea în cascadă în Realm a câștigat o lansare lungă

Dacă te uiți la graficul cu șapte zile, valoarea _app_start pare complet adecvată și durează mai puțin de 1 secundă.

De asemenea, merită adăugat că, în mod implicit, Firebase trimite notificări dacă valoarea mediană a _app_start depășește 5 secunde. Cu toate acestea, după cum putem vedea, nu ar trebui să vă bazați pe acest lucru, ci mai degrabă intrați și verificați-l în mod explicit.

Lucrul special despre baza de date Realm este că este o bază de date non-relațională. În ciuda ușurinței sale de utilizare, asemănării cu soluțiile ORM și legarea obiectelor, nu are ștergere în cascadă.

Dacă acest lucru nu este luat în considerare, atunci obiectele imbricate se vor acumula și se vor „scurge”. Baza de date va crește constant, ceea ce la rândul său va afecta încetinirea sau pornirea aplicației.

Am împărtășit experiența noastră despre cum să faceți rapid o ștergere în cascadă a obiectelor în Realm, care nu este încă din cutie, dar despre care se vorbește de mult timp Spune и Spune. În cazul nostru, acest lucru a accelerat foarte mult timpul de pornire a aplicației.

În ciuda discuției despre apariția iminentă a acestei caracteristici, absența ștergerii în cascadă în Realm este realizată prin proiectare. Dacă proiectați o nouă aplicație, luați în considerare acest lucru. Și dacă utilizați deja Realm, verificați dacă aveți astfel de probleme.

Sursa: www.habr.com

Cumpărați găzduire de încredere pentru site-uri cu protecție DDoS, servere VPS VDS 🔥 Cumpără găzduire web fiabilă cu protecție DDoS, servere VPS VDS | ProHoster