Tarina siitä, kuinka kaskadipoisto Realmissa voitti pitkän käynnistyksen

Kaikki käyttäjät pitävät nopeaa käynnistystä ja reagoivaa käyttöliittymää mobiilisovelluksissa itsestäänselvyytenä. Jos sovelluksen käynnistyminen kestää kauan, käyttäjä alkaa olla surullinen ja vihainen. Voit helposti pilata asiakaskokemuksen tai menettää käyttäjän kokonaan jo ennen kuin hän alkaa käyttää sovellusta.

Havaitsimme kerran, että Dodo Pizza -sovelluksen käynnistyminen kestää keskimäärin 3 sekuntia ja joillakin "onnekkailla" 15-20 sekuntia.

Leikkauksen alla on tarina, jolla on onnellinen loppu: Realm-tietokannan kasvusta, muistivuodosta, siitä, kuinka keräsimme sisäkkäisiä objekteja, sitten vetäydyimme ja korjasimme kaiken.

Tarina siitä, kuinka kaskadipoisto Realmissa voitti pitkän käynnistyksen

Tarina siitä, kuinka kaskadipoisto Realmissa voitti pitkän käynnistyksen
Artikkelin kirjoittaja: Maxim Kachinkin — Android-kehittäjä Dodo Pizzassa.

Kolme sekuntia sovelluskuvakkeen napsauttamisesta ensimmäisen toiminnon onResume()-toimintoon on ääretön. Ja joillekin käyttäjille käynnistysaika oli 15-20 sekuntia. Miten tämä on edes mahdollista?

Erittäin lyhyt yhteenveto niille, joilla ei ole aikaa lukea
Realm-tietokantamme kasvoi loputtomasti. Joitakin sisäkkäisiä objekteja ei poistettu, vaan niitä kertyi jatkuvasti. Sovelluksen käynnistysaika pidentyi vähitellen. Sitten korjasimme sen, ja käynnistysaika saavutti kohteen - siitä tuli alle 1 sekunti eikä enää kasvanut. Artikkeli sisältää tilanneanalyysin ja kaksi ratkaisua - nopean ja normaalin.

Ongelman etsintä ja analysointi

Nykyään kaikkien mobiilisovellusten on käynnistettävä nopeasti ja reagoitava. Mutta se ei koske vain mobiilisovellusta. Käyttäjäkokemus vuorovaikutuksesta palvelun ja yrityksen kanssa on monimutkainen asia. Esimerkiksi meidän tapauksessamme toimitusnopeus on yksi pizzapalvelun avainmittareista. Jos toimitus on nopea, pizza on kuuma, eikä asiakkaan, joka haluaa syödä nyt, tarvitse odottaa kauan. Sovellukselle puolestaan ​​on tärkeää luoda nopean palvelun tunne, sillä jos sovelluksen käynnistyminen kestää vain 20 sekuntia, niin kuinka kauan pizzaa joutuu odottamaan?

Aluksi kohtasimme itse sen tosiasian, että joskus sovelluksen käynnistyminen kesti pari sekuntia, ja sitten aloimme kuulla muilta kollegoilta valituksia siitä, kuinka kauan se kesti. Emme kuitenkaan pystyneet jatkuvasti toistamaan tätä tilannetta.

Kuinka pitkä se on? Mukaan Googlen dokumentaatio, jos sovelluksen kylmäkäynnistys kestää alle 5 sekuntia, sitä pidetään "ikään kuin normaalina". Dodo Pizza Android-sovellus julkaistu (Firebasen mittareiden mukaan _app_start) klo kylmäkäynnistys keskimäärin 3 sekunnissa - "Ei hienoa, ei kauheaa", kuten he sanovat.

Mutta sitten alkoi tulla valituksia siitä, että sovelluksen käynnistäminen kesti hyvin, hyvin, hyvin kauan! Aluksi päätimme mitata, mikä on "erittäin, erittäin, erittäin pitkä". Ja käytimme tähän Firebase-jäljitystä Sovelluksen aloitusjäljitys.

Tarina siitä, kuinka kaskadipoisto Realmissa voitti pitkän käynnistyksen

Tämä vakiojäljitys mittaa aikaa siitä hetkestä, kun käyttäjä avaa sovelluksen, ja hetken, jolloin ensimmäisen toiminnon onResume() suoritetaan. Firebase-konsolissa tätä mittaria kutsutaan nimellä _app_start. Osoittautui että:

  • 95. prosenttipisteen yläpuolella olevien käyttäjien käynnistysajat ovat lähes 20 sekuntia (jotkut jopa pidempään), vaikka kylmäkäynnistysajan mediaani on alle 5 sekuntia.
  • Käynnistysaika ei ole vakioarvo, vaan se kasvaa ajan myötä. Mutta joskus tulee tippoja. Löysimme tämän kaavan, kun lisäsimme analyysin asteikon 90 päivään.

Tarina siitä, kuinka kaskadipoisto Realmissa voitti pitkän käynnistyksen

Kaksi ajatusta tuli mieleen:

  1. Jotain vuotaa.
  2. Tämä "jotain" nollataan vapauttamisen jälkeen ja sitten vuotaa uudelleen.

"Luultavasti jotain tietokannasta", ajattelimme ja olimme oikeassa. Ensinnäkin käytämme tietokantaa välimuistina; siirron aikana tyhjennämme sen. Toiseksi tietokanta ladataan, kun sovellus käynnistyy. Kaikki sopii yhteen.

Mitä vikaa Realmin tietokannassa?

Aloimme tarkistaa, kuinka tietokannan sisältö muuttuu sovelluksen elinkaaren aikana, ensimmäisestä asennuksesta ja edelleen aktiivisen käytön aikana. Voit tarkastella Realm-tietokannan sisältöä kautta steho tai tarkemmin ja selkeämmin avaamalla tiedoston kautta Realm Studio. Jos haluat tarkastella tietokannan sisältöä ADB:n kautta, kopioi Realm-tietokantatiedosto:

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

Tarkastellessamme tietokannan sisältöä eri aikoina, huomasimme, että tietyn tyyppisten objektien määrä kasvaa jatkuvasti.

Tarina siitä, kuinka kaskadipoisto Realmissa voitti pitkän käynnistyksen
Kuvassa fragmentti Realm Studiosta kahdelle tiedostolle: vasemmalla - sovelluskanta jonkin aikaa asennuksen jälkeen, oikealla - aktiivisen käytön jälkeen. Voidaan nähdä, että esineiden määrä ImageEntity и MoneyType on kasvanut merkittävästi (kuvakaappaus näyttää kunkin tyypin kohteiden lukumäärän).

Tietokannan kasvun ja käynnistysajan välinen suhde

Hallitsematon tietokannan kasvu on erittäin huono asia. Mutta miten tämä vaikuttaa sovelluksen käynnistysaikaan? Tämä on melko helppo mitata ActivityManagerin kautta. Android 4.4:stä lähtien logcat näyttää lokin, jossa on Näytetty merkkijono ja kellonaika. Tämä aika on yhtä suuri kuin aikaväli sovelluksen käynnistämisestä toiminnan renderöinnin loppuun. Tänä aikana tapahtuu seuraavia tapahtumia:

  • Aloita prosessi.
  • Objektien alustus.
  • Toimintojen luominen ja alustaminen.
  • Asettelun luominen.
  • Sovelluksen renderöinti.

Sopii meille. Jos suoritat ADB:tä -S- ja -W-lippujen kanssa, voit saada laajennetun lähdön käynnistysajalla:

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

Jos nappaat sen sieltä grep -i WaitTime aikaa, voit automatisoida tämän mittarin keräämisen ja tarkastella tuloksia visuaalisesti. Alla oleva kaavio näyttää sovelluksen käynnistysajan riippuvuuden sovelluksen kylmäkäynnistysten määrästä.

Tarina siitä, kuinka kaskadipoisto Realmissa voitti pitkän käynnistyksen

Samaan aikaan tietokannan koon ja kasvun välinen suhde oli samanlainen, ja tietokannan koko kasvoi 4 megatavusta 15 megatavuun. Kaiken kaikkiaan käy ilmi, että ajan myötä (kylmäkäynnistysten lisääntyessä) sekä sovelluksen käynnistysaika että tietokannan koko kasvoivat. Meillä on käsissämme hypoteesi. Nyt jäi vain vahvistaa riippuvuus. Siksi päätimme poistaa "vuodot" ja katsoa, ​​nopeuttaako tämä käynnistämistä.

Syitä loputtomaan tietokannan kasvuun

Ennen "vuotojen" poistamista on syytä ymmärtää, miksi ne alun perin ilmestyivät. Tätä varten muistetaan, mikä Realm on.

Realm on ei-relaatiotietokanta. Sen avulla voit kuvata objektien välisiä suhteita samalla tavalla kuin kuinka monta Androidin ORM-relaatiotietokantaa on kuvattu. Samaan aikaan Realm tallentaa objektit suoraan muistiin vähiten muunnoksia ja kartoituksia käyttäen. Näin voit lukea tietoja levyltä erittäin nopeasti, mikä on Realmin vahvuus ja miksi sitä rakastetaan.

(Tätä artikkelia varten tämä kuvaus riittää meille. Voit lukea lisää Realmista viileässä dokumentointi tai heidän akatemia).

Monet kehittäjät ovat tottuneet työskentelemään enemmän relaatiotietokantojen kanssa (esimerkiksi ORM-tietokannat, joissa on SQL:n alla). Ja asiat, kuten peräkkäinen tietojen poistaminen, vaikuttavat usein itsestäänselvyyksiltä. Mutta ei Realmissa.

Muuten, kaskadipoistoominaisuutta on pyydetty jo pitkään. Tämä tarkistus и toinen, joka liittyy siihen, keskusteltiin aktiivisesti. Tuli tunne, että se tulee pian tehtyä. Mutta sitten kaikki muuttui vahvojen ja heikkojen linkkien käyttöönotoksi, mikä myös ratkaisisi tämän ongelman automaattisesti. Oli melko vilkas ja aktiivinen tässä tehtävässä vedä pyyntö, joka on toistaiseksi keskeytetty sisäisten ongelmien vuoksi.

Tietovuoto ilman peräkkäistä poistoa

Miten tietoja tarkalleen vuotaa, jos luotat olemattomaan peräkkäiseen poistoon? Jos sinulla on sisäkkäisiä Realm-objekteja, ne on poistettava.
Katsotaanpa (melkein) todellista esimerkkiä. Meillä on esine 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()

Ostoskorissa olevalla tuotteella on eri kentät, mukaan lukien kuva ImageEntity, räätälöityjä ainesosia CustomizationEntity. Myös ostoskorissa oleva tuote voi olla yhdistelmä omalla tuotesarjallaan RealmList (CartProductEntity). Kaikki luetellut kentät ovat Realm-objekteja. Jos lisäämme uuden objektin (copyToRealm() / copyToRealmOrUpdate()) samalla tunnuksella, tämä objekti korvataan kokonaan. Mutta kaikki sisäiset objektit (image, customizationEntity ja cartComboProducts) menettävät yhteyden ylätason kanssa ja jäävät tietokantaan.

Koska yhteys niihin katkeaa, emme enää lue tai poista niitä (ellemme käytä niitä nimenomaisesti tai tyhjennä koko "taulukkoa"). Kutsuimme tätä "muistivuotoiksi".

Kun työskentelemme Realmin kanssa, meidän täytyy nimenomaisesti käydä läpi kaikki elementit ja eksplisiittisesti poistaa kaikki ennen tällaisia ​​toimintoja. Tämä voidaan tehdä esimerkiksi näin:

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

Jos teet tämän, kaikki toimii niin kuin pitää. Tässä esimerkissä oletetaan, että kuvan, customizationEntityn ja cartComboProductsin sisällä ei ole muita sisäkkäisiä Realm-objekteja, joten muita sisäkkäisiä silmukoita ja poistoja ei ole.

Nopea ratkaisu

Ensimmäinen asia, jonka päätimme tehdä, oli puhdistaa nopeimmin kasvavat esineet ja tarkistaa tulokset nähdäksemme, ratkaisiko tämä alkuperäisen ongelmamme. Ensin tehtiin yksinkertaisin ja intuitiivisin ratkaisu, nimittäin: jokaisen esineen tulee olla vastuussa lastensa poistamisesta. Tätä varten otimme käyttöön käyttöliittymän, joka palautti luettelon sisäkkäisistä Realm-objekteistaan:

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

Ja toteutimme sen Realm-objekteissamme:

@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 palautamme kaikki lapset yhtenäisenä luettelona. Ja jokainen lapsiobjekti voi myös toteuttaa NestedEntityAware-rajapinnan, mikä osoittaa, että sillä on sisäisiä Realm-objekteja poistettaviksi, esim. 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
   )
 }
}

Ja niin edelleen, esineiden sisäkkäisyyttä voidaan toistaa.

Sitten kirjoitamme menetelmän, joka poistaa rekursiivisesti kaikki sisäkkäiset objektit. Menetelmä (tehty laajennukseksi) deleteAllNestedEntities saa kaikki huipputason objektit ja menetelmät deleteNestedRecursively Poistaa rekursiivisesti kaikki sisäkkäiset objektit NestedEntityAware-käyttöliittymän avulla:

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

Teimme tämän nopeimmin kasvavien esineiden kanssa ja tarkistimme, mitä tapahtui.

Tarina siitä, kuinka kaskadipoisto Realmissa voitti pitkän käynnistyksen

Tämän seurauksena ne esineet, jotka peitimme tällä ratkaisulla, lakkasivat kasvamasta. Ja pohjan yleinen kasvu hidastui, mutta ei pysähtynyt.

"Normaali" ratkaisu

Vaikka pohja alkoi kasvaa hitaammin, se kasvoi silti. Joten aloimme etsiä pidemmälle. Projektissamme käytetään erittäin aktiivisesti tiedon välimuistia Realmissa. Siksi kaikkien sisäkkäisten objektien kirjoittaminen jokaiselle objektille on työlästä ja virheriski kasvaa, koska voit unohtaa määrittää objektit koodia vaihtaessasi.

Halusin varmistaa, että en käyttänyt rajapintoja, vaan että kaikki toimii itsestään.

Kun haluamme jonkin asian toimivan itsestään, meidän on käytettävä reflektiota. Tätä varten voimme käydä jokaisen luokkakentän läpi ja tarkistaa, onko kyseessä Realm-objekti vai objektiluettelo:

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

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

Jos kenttä on RealmModel tai RealmList, lisää tämän kentän objekti sisäkkäisten objektien luetteloon. Kaikki on täsmälleen samoin kuin teimme yllä, vain täällä se tehdään itsestään. Itse kaskadipoistomenetelmä on hyvin yksinkertainen ja näyttää tältä:

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

Laajennus filterRealmObject suodattaa ja välittää vain Realm-objektit. Menetelmä getNestedRealmObjects heijastuksen avulla se löytää kaikki sisäkkäiset Realm-objektit ja asettaa ne lineaariseen luetteloon. Sitten teemme saman rekursiivisesti. Kun poistat, sinun on tarkistettava kohteen kelvollisuus isValid, koska voi olla, että eri pääobjekteissa voi olla sisäkkäisiä identtisiä objekteja. On parempi välttää tätä ja käyttää yksinkertaisesti id:n automaattista luomista luodessasi uusia objekteja.

Tarina siitä, kuinka kaskadipoisto Realmissa voitti pitkän käynnistyksen

GetNestedRealmObjects-menetelmän täydellinen toteutus

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

Tämän seurauksena asiakaskoodissamme käytämme "peräkkäistä poistoa" jokaisessa tietojen muokkaustoimenpiteessä. Esimerkiksi lisäystoiminnolle se näyttää tältä:

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

Menetelmä ensin getManagedEntities vastaanottaa kaikki lisätyt objektit ja sitten menetelmän cascadeDelete Poistaa rekursiivisesti kaikki kerätyt objektit ennen uusien kirjoittamista. Käytämme tätä lähestymistapaa koko sovelluksen ajan. Realmin muistivuodot ovat täysin poissa. Kun olet suorittanut saman mittauksen käynnistysajan riippuvuudesta sovelluksen kylmäkäynnistysten lukumäärästä, näemme tuloksen.

Tarina siitä, kuinka kaskadipoisto Realmissa voitti pitkän käynnistyksen

Vihreä viiva näyttää sovelluksen käynnistysajan riippuvuuden kylmäkäynnistysten määrästä sisäkkäisten objektien automaattisen kaskadipoiston aikana.

Tulokset ja johtopäätökset

Jatkuvasti kasvava Realm-tietokanta sai sovelluksen käynnistymään hyvin hitaasti. Julkaisimme päivityksen, joka sisältää sisäkkäisten objektien omat "peräkkäiset poistomme". Nyt seuraamme ja arvioimme, kuinka päätöksemme vaikutti sovelluksen käynnistysaikaan _app_start-mittarin avulla.

Tarina siitä, kuinka kaskadipoisto Realmissa voitti pitkän käynnistyksen

Analyysia varten otamme 90 päivän ajanjakson ja näemme: sovelluksen käynnistysaika, sekä mediaani että käyttäjien 95. prosenttipisteen kohdalla oleva aika, alkoi laskea eikä enää nouse.

Tarina siitä, kuinka kaskadipoisto Realmissa voitti pitkän käynnistyksen

Jos katsot seitsemän päivän kaaviota, _app_start-mittari näyttää täysin riittävältä ja on alle 1 sekunti.

On myös syytä lisätä, että Firebase lähettää oletuksena ilmoituksia, jos _app_startin mediaaniarvo ylittää 5 sekuntia. Mutta kuten näemme, sinun ei pitäisi luottaa tähän, vaan mennä sisään ja tarkistaa se nimenomaisesti.

Realm-tietokannan erikoisuus on, että se on ei-relaatiotietokanta. Huolimatta helppokäyttöisyydestään, samankaltaisuudestaan ​​ORM-ratkaisujen kanssa ja objektilinkityksestä, siinä ei ole kaskadipoistoa.

Jos tätä ei oteta huomioon, sisäkkäiset objektit kerääntyvät ja "vuotavat pois". Tietokanta kasvaa jatkuvasti, mikä puolestaan ​​vaikuttaa sovelluksen hidastumiseen tai käynnistymiseen.

Jaoin kokemuksemme kuinka nopeasti tehdä kaskadipoisto Realmissa, joka ei ole vielä valmis, mutta josta on puhuttu pitkään he sanovat и he sanovat. Meidän tapauksessamme tämä nopeuttai huomattavasti sovelluksen käynnistysaikaa.

Huolimatta keskustelusta tämän ominaisuuden välittömästä ilmestymisestä, kaskadipoiston puuttuminen Realmista on tehty suunnittelulla. Jos suunnittelet uutta sovellusta, ota tämä huomioon. Ja jos käytät jo Realmia, tarkista, onko sinulla tällaisia ​​ongelmia.

Lähde: will.com

Lisää kommentti