Sobald wir festgestellt haben, dass die Dodo Pizza-Anwendung in durchschnittlich 3 Sekunden startet und für einige "Glückliche" 15 bis 20 Sekunden dauert.
Unter dem Schnitt ist eine Geschichte mit einem Happy End: über das Wachstum der Realm-Datenbank, Speicherlecks, wie wir verschachtelte Objekte gespeichert und uns dann zusammengerissen und alles repariert haben.

![]()
Der Autor des Artikels: Maxim Kachinkin ist ein Android-Entwickler bei Dodo Pizza.
Drei Sekunden von einem Klick auf das Anwendungssymbol bis zum onResume () der ersten Aktivität ist unendlich. Für einige Benutzer erreichte 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
Realm. , . . , — 1 . — - -.
Suche und Analyse des Problems
Heutzutage muss jede mobile Anwendung schnell gestartet und reaktionsschnell sein. Aber es ist nicht nur 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 einen Pizzaservice. Wenn die Lieferung schnell ist, ist die Pizza heiß und der Kunde, der jetzt essen möchte, muss nicht lange warten. Für die Anwendung ist es wiederum wichtig, ein Gefühl des schnellen Service zu schaffen, denn wenn die Anwendung nur 20 Sekunden startet, wie lange dauert es dann für eine Pizza?
Zuerst waren wir selbst mit der Tatsache konfrontiert, dass die Anwendung manchmal für ein paar Sekunden gestartet wird, und dann erreichten uns Beschwerden von anderen Kollegen, dass sie „lang“ sei. Es ist uns jedoch nicht gelungen, diese Situation stabil zu wiederholen.
Wie lange ist es? GemäßWenn ein Kaltstart einer Anwendung in der Google-Dokumentation weniger als 5 Sekunden dauert, wird dies als "normal" angesehen. Die Dodo Pizza Android-Anwendung wurde (laut Firebase _app_start- Metrik ) bei einem Kaltstart in durchschnittlich 3 Sekunden gestartet - "Nicht großartig, nicht schrecklich", wie es heißt.
Aber dann tauchten Beschwerden auf, dass die Bewerbung sehr, sehr, sehr lange dauert! Zunächst haben wir uns entschlossen zu messen, was "sehr, sehr, sehr lang" ist. Und wir haben dafür die Firebase Trace App Start Trace verwendet .

Diese Standardablaufverfolgung misst die Zeit zwischen dem Zeitpunkt, zu dem der Benutzer die Anwendung öffnet, und dem Zeitpunkt, zu dem onResume () der ersten Aktivierung ausgeführt wird. In der Firebase-Konsole heißt diese Metrik _app_start. Es stellte sich heraus, dass:
- Benutzer über dem 95. Perzentil haben eine Startzeit von fast 20 Sekunden (einige haben mehr), trotz einer mittleren Kaltstartzeit von weniger als 5 Sekunden.
- Die Startzeit ist nicht konstant, sondern wächst mit der Zeit. Aber manchmal werden Stürze beobachtet. Wir fanden dieses Muster, als wir die Analyseskala auf 90 Tage erhöhten.

Zwei Gedanken kamen mir in den Sinn:
- Es ist etwas undicht.
- Dieses „Etwas“ wird nach der Freigabe verworfen und tritt dann wieder aus.
"Wahrscheinlich etwas mit der Datenbank", dachten wir und hatten Recht. Erstens verwenden wir die Datenbank als Cache und löschen sie während der Migration. Zweitens wird die Datenbank beim Start der Anwendung geladen. Es passt alles zusammen.
Was ist los mit der Realm-Datenbank?
Wir haben begonnen zu überprüfen, wie sich der Inhalt der Datenbank während der Lebensdauer der Anwendung von der ersten Installation bis zur aktiven Nutzung ändert. Sie können den Inhalt der Realm-Datenbank über Stetho oder detaillierter und visuell anzeigen, indem Sie die Datei über Realm Studio öffnen . Kopieren Sie die Realm-Datenbankdatei, um den Inhalt der Datenbank über ADB anzuzeigen:
adb exec-out run-as ${PACKAGE_NAME} cat files/${DB_NAME}
Nachdem wir uns den Inhalt der Datenbank zu verschiedenen Zeiten angesehen hatten, stellten wir fest, dass die Anzahl der Objekte eines bestimmten Typs ständig zunimmt.

Das Bild zeigt ein Fragment von Realm Studio für zwei Dateien: links - die Anwendungsbasis nach einiger Zeit nach der Installation, rechts - nach aktiver Nutzung. Man erkennt , dass die Anzahl der Objekte zu sehen
ImageEntity
und MoneyType
hat sich deutlich (der Screenshot zeigt die Anzahl der Objekte jeder Art) gezüchtet.
Verhältnis des Datenbankwachstums zu den Startzeiten
Das unkontrollierte Datenbankwachstum ist sehr schlecht. Wie wirkt sich dies jedoch auf die Startzeit der Anwendung aus? Es ist ganz einfach, es mit dem ActivityManager zu messen. Ab Android 4.4 zeigt logcat ein Protokoll mit angezeigter Zeichenfolge und Uhrzeit an. Diese Zeit entspricht dem Intervall vom Beginn der Anwendung bis zum Ende des Renderns der Aktivität. Während dieser Zeit treten Ereignisse auf:
- Den Prozess starten.
- Objektinitialisierung.
- Erstellung und Initialisierung von Aktivitäten.
- Layouterstellung.
- Anwendungs-Rendering.
Geeignet für uns. Wenn Sie ADB mit den Flags -S und -W ausführen, können Sie mit der Startzeit eine erweiterte Ausgabe erhalten:
adb shell am start -S -W ru.dodopizza.app/.MainActivity -c android.intent.category.LAUNCHER -a android.intent.action.MAIN
Wenn Sie von dort aus die
grep -i WaitTime
Zeit kratzen , können Sie die Erfassung dieser Metrik automatisieren und die Ergebnisse grafisch anzeigen. Die folgende Grafik zeigt die Abhängigkeit der Startzeit der Anwendung von der Anzahl der Kaltstarts der Anwendung.

Gleichzeitig war die Abhängigkeit von Größe und Wachstum der Basis gleich und stieg von 4 MB auf 15 MB. Infolgedessen stellt sich heraus, dass im Laufe der Zeit (mit zunehmendem Kaltstart) auch die Startzeit der Anwendung und die Größe der Datenbank zunahmen. Wir haben eine Hypothese in unseren Händen. Nun blieb es, die Abhängigkeit zu bestätigen. Aus diesem Grund haben wir uns entschlossen, die "Lecks" zu entfernen und zu prüfen, ob dies den Start beschleunigen wird.
Gründe für unendliches Datenbankwachstum
Bevor Sie die "Lecks" entfernen, sollten Sie verstehen, warum sie überhaupt aufgetreten sind. Erinnern wir uns dazu daran, was Realm ist.
Realm ist eine nicht relationale Datenbank. Sie können Beziehungen zwischen Objekten auf ähnliche Weise beschreiben, wie es viele relationale ORM-Datenbanken unter Android beschreiben. Gleichzeitig speichert Realm Objekte mit der geringsten Anzahl von Transformationen und Zuordnungen direkt im Speicher. Auf diese Weise können Sie Daten sehr schnell von der Festplatte lesen. Dies ist eine Stärke von Realm und beliebt.
(Für die Zwecke dieses Artikels wird diese Beschreibung für uns ausreichen. Sie können mehr über Realm in der coolen Dokumentation oder in ihrer Akademie lesen .)
Viele Entwickler sind es gewohnt, mehr mit relationalen Datenbanken zu arbeiten (z. B. ORM-Datenbanken mit SQL unter der Haube). Und Dinge wie das kaskadierende Löschen von Daten scheinen oft eine Selbstverständlichkeit zu sein. Aber nicht im Reich.
Übrigens wurde die Kaskadenlöschfunktion schon lange darum gebeten. Diese und eine andere damit verbundene Überarbeitung wurden aktiv diskutiert. Es gab das Gefühl, dass es bald geschehen würde. Aber dann wurde alles zur Einführung starker und schwacher Glieder, die auch dieses Problem automatisch lösen würden. Für diese Aufgabe gab es eine recht lebhafte und aktive Pull-Anfrage , die vorerst aufgrund interner Schwierigkeiten unterbrochen wurde.
Datenleck ohne Kaskadierung löschen
Wie genau lecken Daten, wenn Sie auf eine nicht vorhandene kaskadierende Löschung hoffen? Wenn Sie Realm-Objekte verschachtelt 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 hat verschiedene Felder, einschließlich eines Bildes
ImageEntity
und kundenspezifischer Zutaten CustomizationEntity
. Das Produkt im Warenkorb kann auch eine Kombination mit einem eigenen Produktsatz 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. Alle internen Objekte (image, customizationEntity und cartComboProducts) verlieren jedoch die Verbindung zum übergeordneten Objekt und verbleiben in der Datenbank.
Da die Verbindung zu ihnen unterbrochen wird, lesen oder löschen wir sie nicht mehr (es sei denn, wir verweisen ausdrücklich auf sie oder löschen die gesamte „Tabelle“). Wir haben dies "Speicherlecks" genannt.
Wenn wir mit Realm arbeiten, müssen wir alle Elemente explizit durchgehen und vor solchen Operationen explizit alles löschen. Dies kann zum Beispiel folgendermaßen geschehen:
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 wird davon ausgegangen, dass das Bild, customizationEntity und cartComboProducts keine anderen verschachtelten Bereiche enthalten, sodass keine anderen verschachtelten Schleifen und Löschvorgänge vorhanden sind.
Schnelle Lösung
Zunächst haben wir uns entschlossen, die am schnellsten wachsenden Objekte zu bereinigen und die Ergebnisse zu überprüfen - ob dies unser ursprüngliches Problem lösen wird. Zunächst wurde die einfachste und intuitivste Lösung gefunden: Jedes Objekt sollte dafür verantwortlich sein, seine Kinder nach sich selbst zu entfernen. Zu diesem Zweck haben wir die folgende Schnittstelle eingeführt, die eine Liste der verschachtelten Realm-Objekte zurückgibt:
interface NestedEntityAware {
fun getNestedEntities(): Collection<RealmObject?>
}
Und wir haben es in unsere Realm-Objekte 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
)
}
}
Da
getNestedEntities
wir allen Kindern eine flache Liste zurückgeben. Jedes untergeordnete Objekt kann auch die NestedEntityAware-Schnittstelle implementieren und darüber informieren, dass interne Realm-Objekte gelöscht werden müssen, z. B 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 weiter kann das Verschachteln von Objekten wiederholt werden.
Dann schreiben wir eine Methode, die alle verschachtelten Objekte rekursiv entfernt. Die Methode (in Form einer Erweiterung erstellt) ruft alle
deleteAllNestedEntities
Objekte der obersten Ebene ab und deleteNestedRecursively
entfernt rekursiv alle verschachtelten Objekte über die 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.

Infolgedessen wuchsen die Objekte, die wir mit dieser Lösung abdeckten, nicht mehr. Und das Gesamtwachstum der Basis verlangsamte sich, hörte aber nicht auf.
Die "normale" Lösung
Die Basis wuchs immer noch, obwohl sie langsamer zu wachsen begann. Also begannen wir weiter zu suchen. In unserem Projekt wird das Daten-Caching in Realm sehr aktiv genutzt. Daher ist das Schreiben aller verschachtelten Objekte für jedes Objekt mühsam, und das Risiko eines Fehlers steigt, da Sie beim Ändern des Codes vergessen können, die Objekte anzugeben.
Ich wollte sicherstellen, dass keine Schnittstellen verwendet werden, sondern dass alles von selbst funktioniert.
Wenn wir wollen, dass etwas von selbst funktioniert, müssen wir Reflexion verwenden. Dazu können wir jedes Feld der Klasse 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 das Feld ein RealmModel oder eine RealmList ist, fügen Sie das Objekt dieses Felds zur Liste der verschachtelten Objekte hinzu. Alles ist genau das gleiche wie oben, nur hier wird es von selbst gemacht. Die kaskadierende Lö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()
}
}
}
}
Die Erweiterung
filterRealmObject
filtert und übergibt nur Realm-Objekte. Die Methode getNestedRealmObjects
findet alle verschachtelten Realm-Objekte durch Reflexion und fügt sie einer linearen Liste hinzu. Dann machen wir dasselbe rekursiv. Beim Löschen müssen Sie das Objekt auf Gültigkeit überprüfen isValid
, da möglicherweise verschiedene übergeordnete Objekte dieselben verschachtelten Objekte haben. Es ist besser, dies zu vermeiden und beim Erstellen neuer Objekte nur die automatische Generierung von IDs zu verwenden.

Vollständige Implementierung der Methode 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)
}
Infolgedessen verwenden wir in unserem Client-Code für jede Datenänderungsoperation einen "kaskadierenden Löschvorgang". 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 }
))
}
Zuerst ruft die Methode
getManagedEntities
alle hinzugefügten Objekte ab, und dann entfernt die Methode cascadeDelete
rekursiv alle gesammelten Objekte, bevor neue Objekte geschrieben werden. Wir verwenden diesen Ansatz letztendlich 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 grüne Linie zeigt die Abhängigkeit der Anwendungsstartzeit von der Anzahl der Kaltstarts während des automatischen Kaskadenlöschens verschachtelter Objekte.
Ergebnisse und Schlussfolgerungen
Die ständig wachsende Realm-Datenbank verlangsamte den Start der App erheblich. Wir haben ein Update mit unserer eigenen "kaskadierenden Löschung" verschachtelter Objekte veröffentlicht. Und jetzt verfolgen und bewerten wir anhand der _app_start-Metrik, wie sich unsere Entscheidung auf die Anwendungsstartzeit ausgewirkt hat.

Für die Analyse nehmen wir ein Zeitintervall von 90 Tagen und sehen: Die Startzeit der Anwendung, sowohl der Median als auch die, die auf das 95. Perzentil der Benutzer fällt, begann abzunehmen und steigt nicht mehr an.

Wenn Sie sich das 7-Tage-Diagramm ansehen, sieht die _app_start-Metrik völlig ausreichend aus und beträgt weniger als 1 Sekunde.
Wir sollten auch hinzufügen, dass Firebase standardmäßig Benachrichtigungen sendet, wenn der mittlere _app_start-Wert 5 Sekunden überschreitet. Wie wir jedoch sehen können, sollten Sie sich nicht darauf verlassen, sondern es explizit überprüfen.
Die Besonderheit der Realm-Datenbank besteht darin, dass es sich um eine nicht relationale Datenbank handelt. Trotz seiner einfachen Verwendung, der Ähnlichkeit der Arbeit mit ORM-Lösungen und der Verknüpfung von Objekten wird keine kaskadierende Löschung vorgenommen.
Wenn dies nicht berücksichtigt wird, sammeln sich verschachtelte Objekte an, "Leck". Die Datenbank wächst ständig, was sich wiederum auf die Verlangsamung oder den Start der Anwendung auswirkt.
Ich teilte unsere Erfahrung mit, wie schnell ein kaskadierendes Löschen von Objekten im Reich erfolgt, das nicht sofort einsatzbereit ist, aber lange Gespräche und Gespräche geführt hat . In unserem Fall hat dies die Startzeit der Anwendung erheblich beschleunigt.
Trotz der Diskussion über das bevorstehende Erscheinen dieser Funktion ist das Fehlen einer kaskadierenden Löschung in Realm beabsichtigt. Berücksichtigen Sie dies, wenn Sie eine neue Anwendung entwerfen. Und wenn Sie Realm bereits verwenden, prüfen Sie, ob Sie solche Probleme haben.