Die Geschichte, wie die Kaskadenlöschung in Realm einen langen Start überstanden hat

Für alle Benutzer ist ein schneller Start und eine reaktionsfähige Benutzeroberfläche in mobilen Anwendungen eine Selbstverständlichkeit. Wenn der Start der Anwendung lange dauert, wird der Benutzer traurig und wütend. Sie können leicht das Kundenerlebnis verderben oder den Benutzer vollständig verlieren, noch bevor er mit der Nutzung der Anwendung beginnt.

Wir haben einmal herausgefunden, dass der Start der Dodo Pizza-App durchschnittlich 3 Sekunden dauert, bei manchen „Glückspilzen“ sogar 15 bis 20 Sekunden.

Unter dem Schnitt ist eine Geschichte mit Happy End: über das Wachstum der Realm-Datenbank, einen Speicherverlust, wie wir verschachtelte Objekte angesammelt haben und uns dann zusammengerafft und alles repariert haben.

Die Geschichte, wie die Kaskadenlöschung in Realm einen langen Start überstanden hat

Die Geschichte, wie die Kaskadenlöschung in Realm einen langen Start überstanden hat
Autor des Artikels: Maxim Kachinkin — Android-Entwickler bei Dodo Pizza.

Drei Sekunden vom Klicken auf das Anwendungssymbol bis zu onResume() der ersten Aktivität sind unendlich. Bei einigen Benutzern betrug die Startzeit 15 bis 20 Sekunden. Wie ist das überhaupt möglich?

Eine sehr kurze Zusammenfassung für diejenigen, die keine Zeit zum Lesen haben
Unsere Realm-Datenbank wuchs endlos. Einige verschachtelte Objekte wurden nicht gelöscht, sondern ständig angesammelt. Die Startzeit der Anwendung verlängerte sich allmählich. Dann haben wir das Problem behoben und die Startzeit erreichte das Ziel – sie betrug weniger als 1 Sekunde und erhöhte sich nicht mehr. Der Artikel enthält eine Analyse der Situation und zwei Lösungen – eine schnelle und eine normale.

Suche und Analyse des Problems

Heutzutage muss jede mobile Anwendung schnell gestartet werden und reagieren. Aber es geht nicht nur um die mobile App. Die Benutzererfahrung bei der Interaktion mit einem Dienst und einem Unternehmen ist eine komplexe Sache. In unserem Fall ist beispielsweise die Liefergeschwindigkeit einer der Schlüsselindikatoren für den Pizzaservice. Wenn die Lieferung schnell erfolgt, ist die Pizza heiß und der Kunde, der jetzt essen möchte, muss nicht lange warten. Für die Anwendung wiederum ist es wichtig, das Gefühl eines schnellen Service zu erzeugen, denn wenn der Start der Anwendung nur 20 Sekunden dauert, wie lange müssen Sie dann auf die Pizza warten?

Zuerst waren wir selbst mit der Tatsache konfrontiert, dass der Start der Anwendung manchmal ein paar Sekunden dauerte, und dann hörten wir Beschwerden von anderen Kollegen darüber, wie lange es dauerte. Aber es gelang uns nicht, diese Situation konsequent zu wiederholen.

Wie lange ist es? Entsprechend Google-DokumentationWenn ein Kaltstart einer Anwendung weniger als 5 Sekunden dauert, gilt dies als „als ob normal“. Dodo Pizza Android-App gestartet (laut Firebase-Metriken). _app_start) bei Kaltstart im Durchschnitt in 3 Sekunden – „Nicht großartig, nicht schrecklich“, wie sie sagen.

Doch dann tauchten Beschwerden auf, dass der Start der Anwendung sehr, sehr, sehr lange gedauert habe! Zunächst haben wir beschlossen, zu messen, was „sehr, sehr, sehr lang“ ist. Und wir haben dafür Firebase Trace verwendet App-Start-Trace.

Die Geschichte, wie die Kaskadenlöschung in Realm einen langen Start überstanden hat

Dieser Standard-Trace misst die Zeit zwischen dem Öffnen der Anwendung durch den Benutzer und dem Zeitpunkt, an dem onResume() der ersten Aktivität ausgeführt wird. In der Firebase Console heißt diese Metrik _app_start. Es stellte sich heraus, dass:

  • Die Startzeiten für Benutzer über dem 95. Perzentil betragen fast 20 Sekunden (einige sogar länger), obwohl die mittlere Kaltstartzeit weniger als 5 Sekunden beträgt.
  • Die Startzeit ist kein konstanter Wert, sondern wächst mit der Zeit. Aber manchmal gibt es Tropfen. Dieses Muster fanden wir, als wir den Analyseumfang auf 90 Tage erhöhten.

Die Geschichte, wie die Kaskadenlöschung in Realm einen langen Start überstanden hat

Zwei Gedanken kamen mir in den Sinn:

  1. Etwas ist undicht.
  2. Dieses „Etwas“ wird nach der Veröffentlichung zurückgesetzt und tritt dann erneut aus.

„Wahrscheinlich irgendetwas mit der Datenbank“, dachten wir und hatten Recht. Zunächst nutzen wir die Datenbank als Cache; bei der Migration leeren wir sie. Zweitens wird die Datenbank geladen, wenn die Anwendung gestartet wird. Alles passt zusammen.

Was stimmt mit der Realm-Datenbank nicht?

Wir begannen zu prüfen, wie sich der Inhalt der Datenbank im Laufe der Lebensdauer der Anwendung verändert, von der ersten Installation bis hin zur aktiven Nutzung. Den Inhalt der Realm-Datenbank können Sie über einsehen Stethos oder detaillierter und übersichtlicher, indem Sie die Datei über öffnen Realm Studio. Um den Inhalt der Datenbank über ADB anzuzeigen, kopieren Sie die Realm-Datenbankdatei:

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

Nachdem wir uns die Inhalte der Datenbank zu verschiedenen Zeitpunkten angesehen hatten, stellten wir fest, dass die Anzahl der Objekte eines bestimmten Typs ständig zunimmt.

Die Geschichte, wie die Kaskadenlöschung in Realm einen langen Start überstanden hat
Das Bild zeigt ein Fragment von Realm Studio für zwei Dateien: links - die Anwendungsbasis einige Zeit nach der Installation, rechts - nach aktiver Nutzung. Es ist ersichtlich, dass die Anzahl der Objekte ImageEntity и MoneyType ist erheblich gewachsen (der Screenshot zeigt die Anzahl der Objekte jedes Typs).

Zusammenhang zwischen Datenbankwachstum und Startzeit

Unkontrolliertes Datenbankwachstum ist sehr schlecht. Aber wie wirkt sich das auf die Startzeit der Anwendung aus? Dies lässt sich ganz einfach über den ActivityManager messen. Seit Android 4.4 zeigt Logcat das Protokoll mit der Zeichenfolge Displayed und der Uhrzeit an. Diese Zeit entspricht dem Intervall vom Start der Anwendung bis zum Ende der Aktivitätswiedergabe. In dieser Zeit treten folgende Ereignisse auf:

  • Starten Sie den Vorgang.
  • Initialisierung von Objekten.
  • Erstellung und Initialisierung von Aktivitäten.
  • Erstellen eines Layouts.
  • Anwendungsrendering.

Passt uns. Wenn Sie ADB mit den Flags -S und -W ausführen, können Sie eine erweiterte Ausgabe mit der Startzeit erhalten:

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

Wenn Sie es von dort greifen grep -i WaitTime Gleichzeitig können Sie die Erfassung dieser Metrik automatisieren und die Ergebnisse visuell betrachten. Die folgende Grafik zeigt die Abhängigkeit der Anwendungsstartzeit von der Anzahl der Kaltstarts der Anwendung.

Die Geschichte, wie die Kaskadenlöschung in Realm einen langen Start überstanden hat

Gleichzeitig bestand ein ähnlicher Zusammenhang zwischen der Größe und dem Wachstum der Datenbank, die von 4 MB auf 15 MB anwuchs. Insgesamt stellt sich heraus, dass im Laufe der Zeit (mit der Zunahme von Kaltstarts) sowohl die Startzeit der Anwendung als auch die Größe der Datenbank zunahmen. Wir haben eine Hypothese in unseren Händen. Jetzt musste nur noch die Abhängigkeit bestätigt werden. Daher haben wir beschlossen, die „Lecks“ zu beseitigen und zu prüfen, ob dies den Start beschleunigen würde.

Gründe für endloses Datenbankwachstum

Bevor man „Lecks“ beseitigt, lohnt es sich zu verstehen, warum sie überhaupt aufgetreten sind. Erinnern wir uns dazu daran, was Realm ist.

Realm ist eine nicht relationale Datenbank. Damit können Sie Beziehungen zwischen Objekten auf ähnliche Weise beschreiben, wie viele relationale ORM-Datenbanken auf Android beschrieben werden. Gleichzeitig speichert Realm Objekte direkt im Speicher mit dem geringsten Aufwand an Transformationen und Zuordnungen. Dadurch können Sie Daten sehr schnell von der Festplatte lesen, was die Stärke von Realm ist und warum es so beliebt ist.

(Für die Zwecke dieses Artikels wird uns diese Beschreibung ausreichen. Mehr über Realm können Sie im coolen lesen Dokumentation oder in ihrem die Akademie).

Viele Entwickler sind es gewohnt, mehr mit relationalen Datenbanken zu arbeiten (z. B. ORM-Datenbanken mit SQL unter der Haube). Und Dinge wie die kaskadierende Datenlöschung scheinen oft eine Selbstverständlichkeit zu sein. Aber nicht im Reich.

Übrigens wurde die Kaskadenlöschfunktion schon lange nachgefragt. Das Revision и eine andere, damit verbunden, wurde aktiv diskutiert. Man hatte das Gefühl, dass es bald geschafft sein würde. Doch dann mündete alles in der Einführung von starken und schwachen Gliedern, die auch dieses Problem automatisch lösen würden. War bei dieser Aufgabe recht lebhaft und aktiv Pull-Anfrage, die aufgrund interner Schwierigkeiten vorerst unterbrochen wurde.

Datenleck ohne kaskadierende Löschung

Wie genau kommt es zu einem Datenverlust, wenn Sie sich auf einen nicht vorhandenen kaskadierenden Löschvorgang verlassen? Wenn Sie verschachtelte Realm-Objekte haben, müssen diese gelöscht werden.
Schauen wir uns ein (fast) reales Beispiel an. Wir haben ein 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()

Das Produkt im Warenkorb verfügt über verschiedene Felder, darunter ein Bild ImageEntity, individuelle Zutaten CustomizationEntity. Außerdem kann das Produkt im Warenkorb eine Kombination mit einer eigenen Produktgruppe sein RealmList (CartProductEntity). Alle aufgelisteten Felder sind Realm-Objekte. Wenn wir ein neues Objekt (copyToRealm() / copyToRealmOrUpdate()) mit derselben ID einfügen, wird dieses Objekt vollständig überschrieben. Aber alle internen Objekte (Bild, CustomizationEntity und CartComboProducts) verlieren die Verbindung zum übergeordneten Objekt und verbleiben in der Datenbank.

Da die Verbindung zu ihnen verloren geht, lesen wir sie nicht mehr und löschen sie nicht mehr (es sei denn, wir greifen explizit darauf zu oder löschen die gesamte „Tabelle“). Wir haben dies „Memory Leaks“ genannt.

Wenn wir mit Realm arbeiten, müssen wir vor solchen Vorgängen explizit alle Elemente durchgehen und alles explizit löschen. Dies kann beispielsweise so erfolgen:

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

Wenn Sie dies tun, wird alles so funktionieren, wie es sollte. In diesem Beispiel gehen wir davon aus, dass es keine anderen verschachtelten Realm-Objekte innerhalb von „image“, „customizationEntity“ und „cartComboProducts“ gibt, sodass es keine anderen verschachtelten Schleifen und Löschvorgänge gibt.

„Schnelle“ Lösung

Als erstes beschlossen wir, die am schnellsten wachsenden Objekte zu bereinigen und die Ergebnisse zu überprüfen, um zu sehen, ob dies unser ursprüngliches Problem lösen würde. Zunächst wurde die einfachste und intuitivste Lösung gefunden, nämlich: Jedes Objekt sollte für das Entfernen seiner untergeordneten Objekte verantwortlich sein. Zu diesem Zweck haben wir eine Schnittstelle eingeführt, die eine Liste ihrer verschachtelten Realm-Objekte zurückgibt:

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

Und wir haben es in unseren Realm-Objekten implementiert:

@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 Wir geben alle Kinder als flache Liste zurück. Und jedes untergeordnete Objekt kann auch die NestedEntityAware-Schnittstelle implementieren, um beispielsweise anzuzeigen, dass es interne Realm-Objekte zum Löschen hat 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
   )
 }
}

Und so kann die Verschachtelung von Objekten wiederholt werden.

Dann schreiben wir eine Methode, die alle verschachtelten Objekte rekursiv löscht. Methode (als Erweiterung erstellt) deleteAllNestedEntities Ruft alle Objekte und Methoden der obersten Ebene ab deleteNestedRecursively Entfernt rekursiv alle verschachtelten Objekte mithilfe der NestedEntityAware-Schnittstelle:

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

Wir haben dies mit den am schnellsten wachsenden Objekten gemacht und überprüft, was passiert ist.

Die Geschichte, wie die Kaskadenlöschung in Realm einen langen Start überstanden hat

Infolgedessen hörten die Objekte, die wir mit dieser Lösung bedeckten, auf zu wachsen. Und das Gesamtwachstum der Basis verlangsamte sich, hörte aber nicht auf.

Die „normale“ Lösung

Obwohl die Basis langsamer zu wachsen begann, wuchs sie dennoch. Also begannen wir weiter zu suchen. Unser Projekt nutzt das Daten-Caching in Realm sehr aktiv. Daher ist das Schreiben aller verschachtelten Objekte für jedes Objekt arbeitsintensiv und das Fehlerrisiko steigt, da beim Ändern des Codes möglicherweise vergessen wird, Objekte anzugeben.

Ich wollte sicherstellen, dass ich keine Schnittstellen verwende, sondern dass alles von alleine funktioniert.

Wenn wir wollen, dass etwas von selbst funktioniert, müssen wir Reflexion einsetzen. Dazu können wir jedes Klassenfeld durchgehen und prüfen, ob es sich um ein Realm-Objekt oder eine Liste von Objekten handelt:

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

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

Wenn es sich bei dem Feld um ein RealmModel oder eine RealmList handelt, fügen Sie das Objekt dieses Felds einer Liste verschachtelter Objekte hinzu. Alles ist genau das gleiche wie oben, nur dass es hier von selbst erledigt wird. Die Kaskadenlöschmethode selbst ist sehr einfach und sieht folgendermaßen aus:

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

Verlängerung filterRealmObject filtert nur Realm-Objekte heraus und übergibt sie. Methode getNestedRealmObjects Durch Reflexion findet es alle verschachtelten Realm-Objekte und fügt sie in eine lineare Liste ein. Dann machen wir dasselbe rekursiv. Beim Löschen müssen Sie das Objekt auf Gültigkeit prüfen isValid, da es sein kann, dass verschiedene übergeordnete Objekte verschachtelte identische Objekte haben können. Es ist besser, dies zu vermeiden und beim Erstellen neuer Objekte einfach die automatische Generierung von IDs zu verwenden.

Die Geschichte, wie die Kaskadenlöschung in Realm einen langen Start überstanden hat

Vollständige Implementierung der getNestedRealmObjects-Methode

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

Aus diesem Grund verwenden wir in unserem Client-Code „kaskadierendes Löschen“ für jeden Datenänderungsvorgang. Für eine Einfügeoperation sieht es beispielsweise so aus:

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

Methode zuerst getManagedEntities empfängt alle hinzugefügten Objekte und dann die Methode cascadeDelete Löscht alle gesammelten Objekte rekursiv, bevor neue geschrieben werden. Letztendlich verwenden wir diesen Ansatz in der gesamten Anwendung. Speicherlecks in Realm sind vollständig verschwunden. Nachdem wir die gleiche Messung der Abhängigkeit der Startzeit von der Anzahl der Kaltstarts der Anwendung durchgeführt haben, sehen wir das Ergebnis.

Die Geschichte, wie die Kaskadenlöschung in Realm einen langen Start überstanden hat

Die grüne Linie zeigt die Abhängigkeit der Anwendungsstartzeit von der Anzahl der Kaltstarts beim automatischen Kaskadenlöschen verschachtelter Objekte.

Ergebnisse und Schlussfolgerungen

Die ständig wachsende Realm-Datenbank führte dazu, dass die Anwendung sehr langsam startete. Wir haben ein Update mit unserem eigenen „kaskadierenden Löschen“ verschachtelter Objekte veröffentlicht. Und jetzt überwachen und bewerten wir anhand der _app_start-Metrik, wie sich unsere Entscheidung auf die Startzeit der Anwendung ausgewirkt hat.

Die Geschichte, wie die Kaskadenlöschung in Realm einen langen Start überstanden hat

Zur Analyse nehmen wir einen Zeitraum von 90 Tagen und sehen: Die Anwendungsstartzeit, sowohl der Median als auch die, die auf das 95. Perzentil der Benutzer fällt, begann zu sinken und steigt nicht mehr an.

Die Geschichte, wie die Kaskadenlöschung in Realm einen langen Start überstanden hat

Wenn Sie sich das Sieben-Tage-Diagramm ansehen, sieht die Metrik „_app_start“ völlig ausreichend aus und beträgt weniger als 1 Sekunde.

Es lohnt sich auch hinzuzufügen, dass Firebase standardmäßig Benachrichtigungen sendet, wenn der Medianwert von _app_start 5 Sekunden überschreitet. Darauf sollte man sich aber, wie wir sehen, nicht verlassen, sondern explizit nachschauen.

Das Besondere an der Realm-Datenbank ist, dass es sich um eine nicht relationale Datenbank handelt. Trotz seiner Benutzerfreundlichkeit, Ähnlichkeit mit ORM-Lösungen und der Objektverknüpfung verfügt es nicht über eine Kaskadenlöschung.

Wird dies nicht berücksichtigt, kommt es zu einer Anhäufung und „Versickerung“ verschachtelter Objekte. Die Datenbank wächst ständig, was sich wiederum auf die Verlangsamung oder den Start der Anwendung auswirkt.

Ich habe unsere Erfahrungen darüber geteilt, wie man in Realm schnell eine Kaskadenlöschung von Objekten durchführt, was noch nicht out-of-the-box ist, aber schon seit langem diskutiert wird sagen sie и sagen sie. In unserem Fall hat dies die Startzeit der Anwendung erheblich beschleunigt.

Trotz der Diskussion über das bevorstehende Erscheinen dieser Funktion ist das Fehlen einer Kaskadenlöschung in Realm beabsichtigt. Wenn Sie eine neue Anwendung entwerfen, berücksichtigen Sie dies. Und wenn Sie Realm bereits verwenden, prüfen Sie, ob solche Probleme auftreten.

Source: habr.com

Kommentar hinzufügen