Opowieść o tym, jak wygrało kaskadowe usuwanie w długiej premierze Realm

Wszyscy użytkownicy uważają szybkie uruchamianie i responsywny interfejs użytkownika w aplikacjach mobilnych za coś oczywistego. Jeśli uruchomienie aplikacji zajmuje dużo czasu, użytkownik zaczyna odczuwać smutek i złość. Możesz łatwo zepsuć doświadczenie klienta lub całkowicie stracić użytkownika jeszcze zanim zacznie korzystać z aplikacji.

Kiedyś odkryliśmy, że uruchomienie aplikacji Dodo Pizza zajmuje średnio 3 sekundy, a niektórym „szczęśliwcom” zajmuje to 15–20 sekund.

Poniżej fragmentu historia ze szczęśliwym zakończeniem: o rozroście bazy danych Realm, wycieku pamięci, o tym, jak zgromadziliśmy zagnieżdżone obiekty, a potem zebraliśmy się w sobie i wszystko naprawiliśmy.

Opowieść o tym, jak wygrało kaskadowe usuwanie w długiej premierze Realm

Opowieść o tym, jak wygrało kaskadowe usuwanie w długiej premierze Realm
Autor artykułu: Maksym Kachinkin — Programista Androida w Dodo Pizza.

Trzy sekundy od kliknięcia ikony aplikacji do onResume() pierwszej aktywności to nieskończoność. W przypadku niektórych użytkowników czas uruchamiania osiągnął 15-20 sekund. Jak to w ogóle jest możliwe?

Bardzo krótkie streszczenie dla tych, którzy nie mają czasu na czytanie
Nasza baza danych Realm rosła w nieskończoność. Niektóre zagnieżdżone obiekty nie zostały usunięte, ale stale się gromadziły. Czas uruchamiania aplikacji stopniowo się wydłużał. Następnie naprawiliśmy to i czas uruchamiania osiągnął wartość docelową - stał się mniejszy niż 1 sekunda i już nie wzrastał. Artykuł zawiera analizę sytuacji oraz dwa rozwiązania – szybkie i normalne.

Wyszukiwanie i analiza problemu

Dziś każda aplikacja mobilna musi uruchamiać się szybko i być responsywna. Ale nie chodzi tylko o aplikację mobilną. Doświadczenie użytkownika w interakcji z usługą i firmą jest rzeczą złożoną. Na przykład w naszym przypadku prędkość dostawy jest jednym z kluczowych wskaźników usługi pizzy. Jeśli dostawa będzie szybka, pizza będzie gorąca, a klient chcący zjeść już teraz nie będzie musiał długo czekać. Dla aplikacji z kolei ważne jest, aby stworzyć wrażenie szybkiej obsługi, bo jeśli uruchomienie aplikacji zajmie tylko 20 sekund, to jak długo będziesz musiał czekać na pizzę?

Na początku sami mieliśmy do czynienia z faktem, że czasami uruchomienie aplikacji trwało kilka sekund, a potem zaczęliśmy słyszeć skargi od innych kolegów na temat tego, jak długo to trwało. Nie udało nam się jednak konsekwentnie powtórzyć tej sytuacji.

Jak długie to jest? Według Dokumentacja Google, jeśli zimny start aplikacji trwa krócej niż 5 sekund, uznaje się to za „jak normalne”. Uruchomiono aplikację Dodo Pizza na Androida (według wskaźników Firebase _aplikacja_start) Na chłodny początek średnio w 3 sekundy - „Nie świetnie, nie strasznie”, jak mówią.

Ale potem zaczęły pojawiać się skargi, że uruchomienie aplikacji trwało bardzo, bardzo, bardzo długo! Na początek postanowiliśmy zmierzyć, co to jest „bardzo, bardzo, bardzo długi”. Użyliśmy do tego śledzenia Firebase Ślad uruchomienia aplikacji.

Opowieść o tym, jak wygrało kaskadowe usuwanie w długiej premierze Realm

Ten standardowy ślad mierzy czas pomiędzy momentem otwarcia aplikacji przez użytkownika a momentem wykonania onResume() pierwszego działania. W konsoli Firebase ta metryka nosi nazwę _app_start. Okazało się że:

  • Czas uruchamiania dla użytkowników powyżej 95. percentyla wynosi prawie 20 sekund (niektórzy nawet dłużej), mimo że średni czas zimnego uruchamiania wynosi mniej niż 5 sekund.
  • Czas uruchomienia nie jest wartością stałą, ale rośnie w czasie. Ale czasami zdarzają się krople. Znaleźliśmy ten wzór, gdy zwiększyliśmy skalę analizy do 90 dni.

Opowieść o tym, jak wygrało kaskadowe usuwanie w długiej premierze Realm

Przyszły mi do głowy dwie myśli:

  1. Coś przecieka.
  2. To „coś” zostaje zresetowane po zwolnieniu, a następnie ponownie wycieka.

„Prawdopodobnie coś z bazą danych” – pomyśleliśmy i mieliśmy rację. Po pierwsze, wykorzystujemy bazę danych jako pamięć podręczną, podczas migracji ją czyścimy. Po drugie, baza danych jest ładowana podczas uruchamiania aplikacji. Wszystko do siebie pasuje.

Co jest nie tak z bazą danych Realm

Zaczęliśmy sprawdzać jak zawartość bazy danych zmienia się w trakcie życia aplikacji, od pierwszej instalacji i dalej w trakcie aktywnego użytkowania. Możesz przeglądać zawartość bazy danych Realm poprzez steto lub bardziej szczegółowo i wyraźnie, otwierając plik za pomocą Studio Realm. Aby wyświetlić zawartość bazy danych poprzez ADB, skopiuj plik bazy danych Realm:

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

Przeglądając zawartość bazy danych w różnych momentach, odkryliśmy, że liczba obiektów określonego typu stale rośnie.

Opowieść o tym, jak wygrało kaskadowe usuwanie w długiej premierze Realm
Na zdjęciu fragment Realm Studio dla dwóch plików: po lewej - baza aplikacji jakiś czas po instalacji, po prawej - po aktywnym użytkowaniu. Widać, że liczba obiektów ImageEntity и MoneyType znacznie wzrosła (zrzut ekranu pokazuje liczbę obiektów każdego typu).

Zależność pomiędzy wzrostem bazy danych a czasem uruchamiania

Niekontrolowany wzrost bazy danych jest bardzo zły. Ale jaki to ma wpływ na czas uruchamiania aplikacji? Można to dość łatwo zmierzyć za pomocą menedżera aktywności. Od wersji Androida 4.4 logcat wyświetla dziennik z ciągiem Wyświetlany i godziną. Czas ten równy jest odstępowi od momentu uruchomienia aplikacji do zakończenia renderowania aktywności. W tym czasie mają miejsce następujące zdarzenia:

  • Rozpocznij proces.
  • Inicjalizacja obiektów.
  • Tworzenie i inicjalizacja działań.
  • Tworzenie układu.
  • Renderowanie aplikacji.

Nam pasuje. Jeśli uruchomisz ADB z flagami -S i -W, możesz uzyskać rozszerzone dane wyjściowe z czasem uruchamiania:

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

Jeśli go stamtąd złapiesz grep -i WaitTime czasu, możesz zautomatyzować zbieranie tych danych i wizualnie przeglądać wyniki. Poniższy wykres przedstawia zależność czasu uruchomienia aplikacji od ilości zimnych startów aplikacji.

Opowieść o tym, jak wygrało kaskadowe usuwanie w długiej premierze Realm

Jednocześnie ten sam charakter miał związek pomiędzy wielkością i przyrostem bazy danych, która wzrosła z 4 MB do 15 MB. W sumie okazuje się, że z biegiem czasu (wraz ze wzrostem zimnych startów) zwiększał się zarówno czas uruchamiania aplikacji, jak i wielkość bazy danych. Mamy hipotezę w rękach. Teraz pozostało tylko potwierdzić zależność. Dlatego postanowiliśmy usunąć „przecieki” i sprawdzić, czy przyspieszy to uruchomienie.

Powody niekończącego się wzrostu bazy danych

Przed usunięciem „wycieków” warto najpierw zrozumieć, dlaczego się pojawiły. Aby to zrobić, pamiętajmy, czym jest Kraina.

Dziedzina jest nierelacyjną bazą danych. Pozwala opisywać relacje pomiędzy obiektami w sposób podobny do tego, jak wiele relacyjnych baz danych ORM opisano na Androidzie. Jednocześnie Realm przechowuje obiekty bezpośrednio w pamięci przy najmniejszej liczbie transformacji i mapowań. Dzięki temu można bardzo szybko odczytać dane z dysku, co jest mocną stroną Realma i za co jest uwielbiany.

(Na potrzeby tego artykułu ten opis będzie dla nas wystarczający. Więcej o Realm przeczytacie w chłodnym miejscu dokumentacja lub w ich akademie).

Wielu programistów jest przyzwyczajonych do większej pracy z relacyjnymi bazami danych (na przykład bazami danych ORM z ukrytym SQL). A rzeczy takie jak kaskadowe usuwanie danych często wydają się oczywiste. Ale nie w Królestwie.

Nawiasem mówiąc, funkcja usuwania kaskadowego była proszona od dłuższego czasu. Ten rewizja и inne, z tym związany, był aktywnie omawiany. Było poczucie, że wkrótce to się stanie. Ale potem wszystko przełożyło się na wprowadzenie mocnych i słabych linków, co również automatycznie rozwiązałoby ten problem. Był dość żywy i aktywny w tym zadaniu prośba o pociągnięcie, który został na razie wstrzymany ze względu na trudności wewnętrzne.

Wyciek danych bez kaskadowego usuwania

Jak dokładnie dochodzi do wycieku danych, jeśli polegasz na nieistniejącym usuwaniu kaskadowym? Jeśli masz zagnieżdżone obiekty Realm, należy je usunąć.
Spójrzmy na (prawie) prawdziwy przykład. Mamy obiekt 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 w koszyku ma różne pola, w tym zdjęcie ImageEntity, spersonalizowane składniki CustomizationEntity. Również produkt w koszyku może stanowić combo z własnym zestawem produktów RealmList (CartProductEntity). Wszystkie wymienione pola są obiektami typu Realm. Jeśli wstawimy nowy obiekt (copyToRealm() / copyToRealmOrUpdate()) o tym samym identyfikatorze, to obiekt ten zostanie całkowicie nadpisany. Jednak wszystkie obiekty wewnętrzne (image, CustomizationEntity i CartComboProducts) stracą połączenie z obiektem nadrzędnym i pozostaną w bazie danych.

Ponieważ połączenie z nimi zostanie utracone, nie będziemy już ich czytać ani usuwać (chyba że uzyskamy do nich wyraźny dostęp lub wyczyścimy całą „tabelę”). Nazwaliśmy to „wyciekami pamięci”.

Kiedy współpracujemy z Realm, musimy jawnie przejrzeć wszystkie elementy i wyraźnie wszystko usunąć przed takimi operacjami. Można to zrobić na przykład w następujący sposób:

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

Jeśli to zrobisz, wszystko będzie działać tak, jak powinno. W tym przykładzie zakładamy, że w obrazach, CustomizationEntity i CartComboProducts nie ma innych zagnieżdżonych obiektów Realm, więc nie ma innych zagnieżdżonych pętli ani usunięć.

„Szybkie” rozwiązanie

Pierwszą rzeczą, którą postanowiliśmy zrobić, było oczyszczenie najszybciej rosnących obiektów i sprawdzenie wyników, aby sprawdzić, czy to rozwiąże nasz pierwotny problem. Najpierw zastosowano najprostsze i najbardziej intuicyjne rozwiązanie, a mianowicie: każdy obiekt powinien odpowiadać za usuwanie swoich dzieci. Aby to zrobić, wprowadziliśmy interfejs, który zwraca listę zagnieżdżonych obiektów Realm:

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

I zaimplementowaliśmy to w naszych obiektach 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 zwracamy wszystkie elementy podrzędne jako płaską listę. Każdy obiekt podrzędny może również implementować interfejs NestedEntityAware, wskazując na przykład, że ma wewnętrzne obiekty Realm do usunięcia 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 tak dalej, zagnieżdżanie obiektów można powtarzać.

Następnie piszemy metodę, która rekurencyjnie usuwa wszystkie zagnieżdżone obiekty. Metoda (wykonana jako rozszerzenie) deleteAllNestedEntities pobiera wszystkie obiekty i metody najwyższego poziomu deleteNestedRecursively Rekursywnie usuwa wszystkie zagnieżdżone obiekty za pomocą interfejsu 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()
   }
 }
}

Zrobiliśmy to z najszybciej rosnącymi obiektami i sprawdziliśmy, co się stało.

Opowieść o tym, jak wygrało kaskadowe usuwanie w długiej premierze Realm

W rezultacie obiekty, które pokryliśmy tym rozwiązaniem, przestały rosnąć. Ogólny wzrost bazy spowolnił, ale się nie zatrzymał.

„Normalne” rozwiązanie

Choć baza zaczęła rosnąć wolniej, to jednak rosła. Zaczęliśmy więc szukać dalej. Nasz projekt bardzo aktywnie wykorzystuje buforowanie danych w Realm. Dlatego zapisanie wszystkich zagnieżdżonych obiektów dla każdego obiektu jest pracochłonne, a ponadto zwiększa się ryzyko błędów, ponieważ można zapomnieć o określeniu obiektów podczas zmiany kodu.

Chciałem mieć pewność, że nie korzystam z interfejsów, ale że wszystko działa samodzielnie.

Kiedy chcemy, żeby coś działało samodzielnie, musimy skorzystać z refleksji. W tym celu możemy przejść przez każde pole klasy i sprawdzić, czy jest to obiekt Dziedziny, czy lista obiektów:

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

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

Jeśli pole to RealmModel lub RealmList, dodaj obiekt tego pola do listy zagnieżdżonych obiektów. Wszystko jest dokładnie tak samo, jak zrobiliśmy powyżej, tylko tutaj zrobi się to samo. Sama metoda usuwania kaskadowego jest bardzo prosta i wygląda następująco:

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

Rozszerzenie filterRealmObject filtruje i przekazuje tylko obiekty Realm. metoda getNestedRealmObjects poprzez odbicie znajduje wszystkie zagnieżdżone obiekty Realm i umieszcza je na liniowej liście. Następnie robimy to samo rekurencyjnie. Podczas usuwania należy sprawdzić ważność obiektu isValid, ponieważ może się zdarzyć, że różne obiekty nadrzędne mogą mieć zagnieżdżone identyczne obiekty. Lepiej tego unikać i po prostu używać automatycznego generowania identyfikatora podczas tworzenia nowych obiektów.

Opowieść o tym, jak wygrało kaskadowe usuwanie w długiej premierze Realm

Pełna implementacja 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)
}

W rezultacie w naszym kodzie klienta stosujemy „kaskadowe usuwanie” dla każdej operacji modyfikacji danych. Na przykład dla operacji wstawiania wygląda to tak:

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

Najpierw metoda getManagedEntities odbiera wszystkie dodane obiekty, a następnie metodę cascadeDelete Rekursywnie usuwa wszystkie zebrane obiekty przed zapisaniem nowych. W końcu używamy tego podejścia w całej aplikacji. Całkowicie zniknęły wycieki pamięci w Realm. Po przeprowadzeniu tego samego pomiaru zależności czasu rozruchu od liczby zimnych startów aplikacji widzimy wynik.

Opowieść o tym, jak wygrało kaskadowe usuwanie w długiej premierze Realm

Zielona linia pokazuje zależność czasu uruchomienia aplikacji od liczby zimnych startów podczas automatycznego kaskadowego usuwania zagnieżdżonych obiektów.

Wyniki i wnioski

Stale rosnąca baza danych Realm powodowała, że ​​aplikacja uruchamiała się bardzo wolno. Wydaliśmy aktualizację z naszym własnym „kaskadowym usuwaniem” zagnieżdżonych obiektów. A teraz monitorujemy i oceniamy, jak nasza decyzja wpłynęła na czas uruchamiania aplikacji poprzez metrykę _app_start.

Opowieść o tym, jak wygrało kaskadowe usuwanie w długiej premierze Realm

Do analizy bierzemy okres 90 dni i widzimy: czas uruchomienia aplikacji, zarówno mediany, jak i tego, który przypada na 95 percentyl użytkowników, zaczął się zmniejszać i już nie rośnie.

Opowieść o tym, jak wygrało kaskadowe usuwanie w długiej premierze Realm

Jeśli spojrzysz na wykres siedmiodniowy, wskaźnik _app_start wygląda całkowicie adekwatnie i trwa mniej niż 1 sekundę.

Warto też dodać, że Firebase domyślnie wysyła powiadomienia, jeśli mediana wartości _app_start przekroczy 5 sekund. Jak jednak widzimy, nie należy na tym polegać, a raczej wejść i dokładnie to sprawdzić.

Cechą szczególną bazy danych Realm jest to, że jest to baza danych nierelacyjna. Pomimo łatwości obsługi, podobieństwa do rozwiązań ORM i łączenia obiektów, nie posiada możliwości kasowania kaskadowego.

Jeśli nie zostanie to wzięte pod uwagę, zagnieżdżone obiekty będą się gromadzić i „wyciekać”. Baza danych będzie stale rosła, co z kolei wpłynie na spowolnienie lub uruchomienie aplikacji.

Podzieliłem się naszym doświadczeniem, jak szybko wykonać kaskadowe usuwanie obiektów w Realm, co nie jest jeszcze gotowe do użycia, ale mówi się o nim od dłuższego czasu mówią и mówią. W naszym przypadku znacznie przyspieszyło to czas uruchamiania aplikacji.

Pomimo dyskusji na temat rychłego pojawienia się tej funkcji, brak usuwania kaskadowego w Realm jest zgodny z projektem. Jeśli projektujesz nową aplikację, weź to pod uwagę. A jeśli korzystasz już z Realm, sprawdź, czy nie masz takich problemów.

Źródło: www.habr.com

Dodaj komentarz