Příběh o tom, jak kaskádové mazání v dlouhém startu Realmu zvítězilo

Všichni uživatelé považují rychlé spouštění a responzivní uživatelské rozhraní v mobilních aplikacích za samozřejmost. Pokud se aplikace spouští dlouho, uživatel začne být smutný a naštvaný. Můžete snadno zkazit zákaznickou zkušenost nebo úplně ztratit uživatele ještě dříve, než začne aplikaci používat.

Jednou jsme zjistili, že spuštění aplikace Dodo Pizza trvá v průměru 3 sekundy a některým „šťastlivcům“ to trvá 15–20 sekund.

Pod střihem je příběh se šťastným koncem: o růstu databáze Realm, úniku paměti, o tom, jak jsme hromadili vnořené objekty a pak jsme se dali dohromady a vše opravili.

Příběh o tom, jak kaskádové mazání v dlouhém startu Realmu zvítězilo

Příběh o tom, jak kaskádové mazání v dlouhém startu Realmu zvítězilo
Autor článku: Maxim Kachinkin — Android developer ve společnosti Dodo Pizza.

Tři sekundy od kliknutí na ikonu aplikace do onResume() první aktivity jsou nekonečno. A u některých uživatelů dosáhl čas spuštění 15–20 sekund. Jak je to vůbec možné?

Velmi krátké shrnutí pro ty, kteří nemají čas číst
Naše databáze Realm nekonečně rostla. Některé vnořené objekty nebyly odstraněny, ale byly neustále akumulovány. Doba spouštění aplikace se postupně prodlužovala. Pak jsme to opravili a čas spuštění dosáhl cíle - byl méně než 1 sekunda a již se nezvyšoval. Článek obsahuje rozbor situace a dvě řešení - rychlé a normální.

Hledání a analýza problému

Každá mobilní aplikace se dnes musí rychle spustit a musí reagovat. Nejde ale jen o mobilní aplikaci. Uživatelská zkušenost interakce se službou a společností je komplexní věc. Například v našem případě je rychlost doručení jedním z klíčových ukazatelů pro pizzerii. Pokud je dodávka rychlá, pizza bude horká a zákazník, který se chce najíst hned, nebude muset dlouho čekat. Pro aplikaci je zase důležité navodit pocit rychlé obsluhy, protože když spuštění aplikace trvá jen 20 sekund, tak jak dlouho budete čekat na pizzu?

Zpočátku jsme se sami potýkali s tím, že spuštění aplikace někdy trvalo několik sekund, a pak jsme začali slýchat stížnosti ostatních kolegů, jak dlouho to trvalo. Tuto situaci jsme ale nedokázali důsledně opakovat.

Jak je to dlouhé? Podle Dokumentace Google, pokud studený start aplikace trvá méně než 5 sekund, pak se to považuje za „jakoby normální“. Spuštěna aplikace Dodo Pizza pro Android (podle metrik Firebase _app_start) na studený start v průměru za 3 sekundy - "Ne skvělé, ne hrozné," jak se říká.

Pak se ale začaly objevovat stížnosti, že spuštění aplikace trvalo velmi, velmi, velmi dlouho! Pro začátek jsme se rozhodli změřit, co je „velmi, velmi, velmi dlouhé“. A k tomu jsme použili Firebase trace Trasování spuštění aplikace.

Příběh o tom, jak kaskádové mazání v dlouhém startu Realmu zvítězilo

Toto standardní trasování měří čas mezi okamžikem, kdy uživatel otevře aplikaci, a okamžikem, kdy je spuštěna onResume() první aktivity. V konzole Firebase se tato metrika nazývá _app_start. Ukázalo se že:

  • Časy spouštění pro uživatele nad 95. percentilem jsou téměř 20 sekund (někteří dokonce déle), přestože střední doba studeného spuštění je méně než 5 sekund.
  • Doba spuštění není konstantní hodnotou, ale s časem roste. Ale někdy jsou kapky. Tento vzorec jsme našli, když jsme zvýšili rozsah analýzy na 90 dní.

Příběh o tom, jak kaskádové mazání v dlouhém startu Realmu zvítězilo

Napadly mě dvě myšlenky:

  1. Něco uniká.
  2. Toto „něco“ se po uvolnění resetuje a poté znovu unikne.

"Pravděpodobně něco s databází," pomysleli jsme si a měli jsme pravdu. Nejprve používáme databázi jako cache, při migraci ji vymažeme. Za druhé, databáze se načte při spuštění aplikace. Všechno do sebe zapadá.

Co je špatného na databázi Realm

Začali jsme kontrolovat, jak se obsah databáze mění v průběhu životnosti aplikace, od první instalace a dále při aktivním používání. Obsah databáze Realm si můžete prohlédnout přes stetho nebo podrobněji a přehledněji otevřením souboru přes Realm Studio. Chcete-li zobrazit obsah databáze přes ADB, zkopírujte soubor databáze Realm:

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

Když jsme se podívali na obsah databáze v různých časech, zjistili jsme, že počet objektů určitého typu neustále narůstá.

Příběh o tom, jak kaskádové mazání v dlouhém startu Realmu zvítězilo
Obrázek ukazuje fragment Realm Studio pro dva soubory: vlevo - aplikační základna nějakou dobu po instalaci, vpravo - po aktivním používání. Je vidět, že počet objektů ImageEntity и MoneyType výrazně vzrostl (snímek obrazovky ukazuje počet objektů každého typu).

Vztah mezi růstem databáze a dobou spuštění

Nekontrolovaný růst databáze je velmi špatný. Jak to ale ovlivní dobu spouštění aplikace? Je to docela snadné měřit to pomocí ActivityManageru. Od Androidu 4.4 logcat zobrazuje protokol s řetězcem Displayed a časem. Tato doba se rovná intervalu od okamžiku spuštění aplikace do konce vykreslování aktivity. Během této doby dochází k následujícím událostem:

  • Spusťte proces.
  • Inicializace objektů.
  • Vytváření a inicializace aktivit.
  • Vytvoření rozvržení.
  • Vykreslování aplikací.

Vyhovuje nám. Pokud spustíte ADB s příznaky -S a -W, můžete získat rozšířený výstup s časem spuštění:

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

Pokud to vezmeš odtud grep -i WaitTime můžete automatizovat sběr této metriky a vizuálně se podívat na výsledky. Níže uvedený graf ukazuje závislost doby spouštění aplikace na počtu studených startů aplikace.

Příběh o tom, jak kaskádové mazání v dlouhém startu Realmu zvítězilo

Současně byl stejný charakter vztahu mezi velikostí a růstem databáze, která narostla ze 4 MB na 15 MB. Celkově se ukazuje, že postupem času (s růstem studených startů) se zvyšovala jak doba spuštění aplikace, tak velikost databáze. Máme ve svých rukou hypotézu. Teď už zbývalo jen potvrdit závislost. Proto jsme se rozhodli odstranit „úniky“ a zjistit, zda to urychlí spuštění.

Důvody pro nekonečný růst databáze

Před odstraněním „úniků“ stojí za to pochopit, proč se objevily na prvním místě. K tomu si připomeňme, co je Realm.

Realm je nerelační databáze. Umožňuje popsat vztahy mezi objekty podobným způsobem, jako je popsáno mnoho relačních databází ORM v systému Android. Realm zároveň ukládá objekty přímo do paměti s nejmenším počtem transformací a mapování. To vám umožní číst data z disku velmi rychle, což je síla Realmu a proč je tak oblíbený.

(Pro účely tohoto článku nám bude tento popis stačit. Více o Realm si můžete přečíst v pohodě dokumentace nebo v jejich akademie).

Mnoho vývojářů je zvyklých více pracovat s relačními databázemi (například databáze ORM s SQL pod pokličkou). A věci jako kaskádové mazání dat se často zdají jako samozřejmost. Ale ne v Realm.

Mimochodem, funkce mazání kaskády byla žádána již dlouho. Tento finalizace и další, s tím spojené, se aktivně diskutovalo. Byl tu pocit, že to bude brzy hotovo. Pak se ale vše přetavilo do zavedení silných a slabých článků, které by tento problém také automaticky vyřešily. Při plnění tohoto úkolu byl velmi živý a aktivní vytáhnout žádost, která je prozatím pozastavena z důvodu vnitřních potíží.

Únik dat bez kaskádového mazání

Jak přesně dochází k úniku dat, pokud se spoléháte na neexistující kaskádové mazání? Pokud jste vnořili objekty Realm, musíte je odstranit.
Podívejme se na (téměř) reálný příklad. Máme objekt 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()

Produkt v košíku má různá pole včetně obrázku ImageEntity, přizpůsobené ingredience CustomizationEntity. Produkt v košíku může být také kombinací s vlastní sadou produktů RealmList (CartProductEntity). Všechna uvedená pole jsou objekty sféry. Pokud vložíme nový objekt (copyToRealm() / copyToRealmOrUpdate()) se stejným id, pak bude tento objekt zcela přepsán. Ale všechny interní objekty (image, customizationEntity a cartComboProducts) ztratí spojení s nadřazeným objektem a zůstanou v databázi.

Vzhledem k tomu, že spojení s nimi je ztraceno, již je nečteme ani je nemažeme (pokud k nim výslovně nepřistoupíme nebo nevymažeme celou „tabulku“). Nazvali jsme to „úniky paměti“.

Když pracujeme s Realm, musíme výslovně projít všechny prvky a před takovými operacemi vše výslovně smazat. To lze provést například takto:

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

Pokud to uděláte, pak vše bude fungovat, jak má. V tomto příkladu předpokládáme, že uvnitř image, customizationEntity a cartComboProducts nejsou žádné další vnořené objekty Realm, takže neexistují žádné další vnořené smyčky a odstranění.

"Rychlé" řešení

První věc, kterou jsme se rozhodli udělat, bylo vyčistit nejrychleji rostoucí objekty a zkontrolovat výsledky, abychom zjistili, zda to vyřeší náš původní problém. Nejprve bylo vytvořeno nejjednodušší a nejintuitivnější řešení, konkrétně: každý objekt by měl být zodpovědný za odstranění svých potomků. Za tímto účelem jsme představili rozhraní, které vrátilo seznam svých vnořených objektů Realm:

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

A implementovali jsme to do našich objektů 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 vrátíme všechny děti jako plochý seznam. A každý podřízený objekt může také implementovat rozhraní NestedEntityAware, což naznačuje, že má interní objekty Realm, které je třeba odstranit, například 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
   )
 }
}

A tak dále, vnoření objektů se může opakovat.

Poté napíšeme metodu, která rekurzivně odstraní všechny vnořené objekty. Metoda (vyrobeno jako rozšíření) deleteAllNestedEntities získá všechny objekty a metody nejvyšší úrovně deleteNestedRecursively Rekurzivně odstraní všechny vnořené objekty pomocí rozhraní 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()
   }
 }
}

Udělali jsme to s nejrychleji rostoucími objekty a zkontrolovali, co se stalo.

Příběh o tom, jak kaskádové mazání v dlouhém startu Realmu zvítězilo

V důsledku toho objekty, které jsme zakryli tímto řešením, přestaly růst. A celkový růst základny se zpomalil, ale nezastavil.

"Normální" řešení

Základna sice začala růst pomaleji, ale stále rostla. Začali jsme tedy hledat dál. Náš projekt velmi aktivně využívá ukládání dat do mezipaměti v Realmu. Proto je zápis všech vnořených objektů pro každý objekt pracný a navíc se zvyšuje riziko chyb, protože při změně kódu můžete zapomenout specifikovat objekty.

Chtěl jsem se ujistit, že nepoužívám rozhraní, ale že vše funguje samo.

Když chceme, aby něco fungovalo samo, musíme použít reflexi. Za tímto účelem můžeme projít každé pole třídy a zkontrolovat, zda se jedná o objekt Realm nebo seznam objektů:

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

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

Pokud je pole RealmModel nebo RealmList, přidejte objekt tohoto pole do seznamu vnořených objektů. Všechno je úplně stejné, jako jsme to udělali výše, jen tady se to udělá samo. Samotná metoda mazání kaskády je velmi jednoduchá a vypadá takto:

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

Rozšíření filterRealmObject filtruje a předává pouze objekty Realm. Metoda getNestedRealmObjects prostřednictvím reflexe najde všechny vnořené objekty Realm a vloží je do lineárního seznamu. Potom totéž provedeme rekurzivně. Při mazání je potřeba zkontrolovat platnost objektu isValid, protože se může stát, že různé rodičovské objekty mohou mít vnořené stejné. Tomu je lepší se vyhnout a při vytváření nových objektů jednoduše použít automatické generování id.

Příběh o tom, jak kaskádové mazání v dlouhém startu Realmu zvítězilo

Plná implementace metody 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)
}

Výsledkem je, že v našem klientském kódu používáme pro každou operaci úpravy dat „kaskádové mazání“. Například operace vložení vypadá takto:

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

Nejprve metoda getManagedEntities přijímá všechny přidané objekty a poté metodu cascadeDelete Rekurzivně odstraní všechny shromážděné objekty před zápisem nových. Tento přístup nakonec používáme v celé aplikaci. Úniky paměti v Realmu jsou úplně pryč. Po provedení stejného měření závislosti doby spuštění na počtu studených startů aplikace vidíme výsledek.

Příběh o tom, jak kaskádové mazání v dlouhém startu Realmu zvítězilo

Zelená čára ukazuje závislost doby spouštění aplikace na počtu studených startů při automatickém kaskádovém mazání vnořených objektů.

Výsledky a závěry

Neustále rostoucí databáze Realm způsobovala, že se aplikace spouštěla ​​velmi pomalu. Vydali jsme aktualizaci s vlastním „kaskádovým mazáním“ vnořených objektů. A nyní sledujeme a vyhodnocujeme, jak naše rozhodnutí ovlivnilo dobu spuštění aplikace prostřednictvím metriky _app_start.

Příběh o tom, jak kaskádové mazání v dlouhém startu Realmu zvítězilo

Pro analýzu vezmeme časové období 90 dnů a uvidíme: čas spuštění aplikace, jak medián, tak čas, který připadá na 95. percentil uživatelů, se začal snižovat a již neroste.

Příběh o tom, jak kaskádové mazání v dlouhém startu Realmu zvítězilo

Pokud se podíváte na sedmidenní graf, metrika _app_start vypadá zcela adekvátně a je kratší než 1 sekunda.

Je také vhodné dodat, že ve výchozím nastavení Firebase odesílá oznámení, pokud střední hodnota _app_start překročí 5 sekund. Jak však vidíme, neměli byste na to spoléhat, ale raději jít dovnitř a výslovně to zkontrolovat.

Zvláštní věcí na databázi Realm je to, že jde o nerelační databázi. Navzdory snadnému použití, podobnosti s řešeními ORM a propojování objektů nemá kaskádové mazání.

Pokud to nebude bráno v úvahu, pak se vnořené objekty budou hromadit a „uniknou“. Databáze bude neustále růst, což se následně projeví na zpomalení nebo spouštění aplikace.

Podělil jsem se o naši zkušenost, jak rychle udělat kaskádové mazání objektů v Realmu, které ještě není z krabice, ale mluví se o něm už dlouho говорят и говорят. V našem případě to značně zrychlilo dobu spouštění aplikace.

Navzdory diskuzi o blížícím se vzhledu této funkce je absence kaskádového mazání v Realmu provedena záměrně. Pokud navrhujete novou aplikaci, vezměte to v úvahu. A pokud již používáte Realm, zkontrolujte, zda takové problémy nemáte.

Zdroj: www.habr.com

Přidat komentář