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

Wszyscy użytkownicy traktują szybkie uruchamianie i responsywny interfejs użytkownika w aplikacjach mobilnych jako coś oczywistego. Jeśli aplikacja uruchamia się długo, użytkownik zaczyna czuć się smutny i zły. Łatwo jest zepsuć doświadczenie klienta lub nawet stracić użytkownika, zanim jeszcze zacznie korzystać z aplikacji.

Kiedyś odkryliśmy, że aplikacja Dodo Pizza uruchamia się średnio w 3 sekundy, a niektórzy „szczęściarze” potrzebują na to 15–20 sekund.

Poniżej znajduje się 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 wzięliśmy się w garść 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 Kaczinkin — Programista Androida w Dodo Pizza.

Trzy sekundy od kliknięcia ikony aplikacji do onResume() pierwszej aktywności to nieskończoność. A dla niektórych użytkowników czas uruchomienia sięgał 15-20 sekund. Jak to w ogóle możliwe?

Bardzo krótkie podsumowanie 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 wzrastał. Następnie naprawiliśmy to, a czas uruchamiania osiągnął cel - stał się mniejszy niż 1 sekunda i już nie rośnie. Artykuł analizuje sytuację i dwa rozwiązania - szybki sposób i normalny sposób.

Znalezienie i analiza problemu

Obecnie 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 złożoną rzeczą. Na przykład w naszym przypadku szybkość dostawy jest jednym z kluczowych wskaźników dla usługi pizzy. Jeśli dostawa jest szybka, pizza będzie gorąca, a klient, który chce ją teraz zjeść, nie będzie długo czekał. Z kolei dla aplikacji ważne jest stworzenie wrażenia szybkiej obsługi, ponieważ jeśli aplikacja uruchamia się w ciągu zaledwie 20 sekund, to jak długo będziesz musiał czekać na pizzę?

Na początku sami spotkaliśmy się z tym, że czasami aplikacja uruchamiała się w ciągu kilku sekund, a potem zaczęliśmy słyszeć skargi od innych współpracowników, że „zajmuje to dużo czasu”. Jednak nie udało nam się konsekwentnie powtórzyć tej sytuacji.

Jak długo jest długo? Według Dokumentacja Google, jeśli zimny start aplikacji trwa mniej niż 5 sekund, to jest to uważane za „w miarę normalne”. Aplikacja Android Dodo Pizza została uruchomiona (według metryk Firebase _uruchomienie_aplikacji) Na zimny start średnio w 3 sekundy - „Ani świetnie, ani strasznie”, jak mawiają.

Ale potem zaczęły pojawiać się skargi, że aplikacja bardzo, bardzo, bardzo długo się uruchamia! Na początek postanowiliśmy zmierzyć, co tak naprawdę oznacza „bardzo, bardzo, bardzo długo”. I w tym celu użyliśmy Firebase trace. Ślad startu aplikacji.

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

Ten standardowy ślad mierzy czas między momentem otwarcia aplikacji przez użytkownika a momentem wykonania onResume() pierwszej aktywności. W konsoli Firebase ta metryka nazywa się _app_start. Okazuje się, że:

  • Czas uruchamiania dla użytkowników powyżej 95. percentyla wynosi prawie 20 sekund (nieco więcej), mimo że mediana czasu zimnego rozruchu wynosi mniej niż 5 sekund.
  • Czas uruchomienia nie jest wartością stałą, ale wzrasta z czasem. Ale czasami zdarzają się spadki. 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 wydaniu, a następnie znów wycieka.

„Prawdopodobnie coś z bazą danych” – pomyśleliśmy i mieliśmy rację. Po pierwsze, baza danych jest używana jako pamięć podręczna i czyścimy ją podczas migracji. Po drugie, baza danych jest ładowana podczas uruchamiania aplikacji. Wszystko się zgadza.

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 podczas aktywnego użytkowania. Zawartość bazy danych Realm można przeglądać za pośrednictwem steto lub bardziej szczegółowo i wizualnie, otwierając plik za pomocą Studio RealmAby wyświetlić zawartość bazy danych za pomocą ADB, skopiuj plik bazy danych Realm:

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

Analizując zawartość bazy danych w różnym czasie, zauważyliśmy, że liczba obiektów pewnego typu stale rośnie.

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

Związek między wzrostem bazy danych a czasem uruchamiania

Niekontrolowany wzrost bazy danych jest bardzo zły. Ale jak wpływa na czas uruchamiania aplikacji? Jest to dość łatwe do zmierzenia za pomocą ActivityManager. Począwszy od Androida 4.4, logcat wyświetla dziennik z linią Displayed i czasem. Czas ten jest równy odstępowi czasu od momentu uruchomienia aplikacji do zakończenia renderowania aktywności. W tym czasie zachodzą następujące zdarzenia:

  • Rozpoczęcie procesu.
  • Inicjalizacja obiektów.
  • Tworzenie i inicjowanie aktywności.
  • Tworzenie układu.
  • Renderowanie aplikacji.

Pasuje nam. Jeśli uruchomisz ADB z flagami -S i -W, możesz uzyskać rozszerzone wyjście 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 to stamtąd zgrabisz grep -i WaitTime time, możesz zautomatyzować zbieranie tej metryki i zobaczyć wyniki wizualnie. Poniższy wykres pokazuje zależność czasu uruchomienia aplikacji od liczby zimnych uruchomień aplikacji.

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

Jednocześnie występowała ta sama natura zależności rozmiaru i wzrostu bazy danych, która wzrosła z 4 MB do 15 MB. W rezultacie okazuje się, że z czasem (wraz ze wzrostem zimnych uruchomień) zarówno czas uruchamiania aplikacji, jak i rozmiar bazy danych rosły. Mieliśmy hipotezę w rękach. Teraz pozostało potwierdzić zależność. Dlatego postanowiliśmy usunąć „przecieki” i sprawdzić, czy przyspieszy to uruchomienie.

Powody niekończącego się wzrostu bazy danych

Zanim zajmiemy się przeciekami, warto dowiedzieć się, dlaczego w ogóle się pojawiły. Aby to zrobić, przypomnijmy sobie, czym jest Realm.

Realm to nierelacyjna baza danych. Umożliwia opisywanie relacji między obiektami w sposób podobny do wielu relacyjnych baz danych ORM na Androidzie. Jednocześnie Realm przechowuje obiekty bezpośrednio w pamięci z najmniejszą liczbą transformacji i mapowań. Pozwala to na bardzo szybkie odczytywanie danych z dysku, co jest zaletą Realm, za którą jest uwielbiany.

(Ten opis wystarczy na potrzeby tego artykułu. Więcej o Realm możesz przeczytać w fajnych dokumentacja lub w ich akademie).

Wielu programistów jest przyzwyczajonych do pracy z bazami danych relacyjnymi (takimi jak bazy danych ORM z SQL pod maską). A rzeczy takie jak kaskadowe usuwanie często wydają się oczywiste. Ale nie w Realm.

Nawiasem mówiąc, funkcja usuwania kaskadowego była proszona od dawna. To rewizja и inne, z tym związane, było aktywnie dyskutowane. Było przeczucie, że wkrótce to się stanie. Ale potem wszystko przełożono na wprowadzenie silnych i słabych ogniw, które automatycznie rozwiązałyby również ten problem. Było dość żywe i aktywne prośba o pociągnięcie, który został tymczasowo wstrzymany ze względu na trudności wewnętrzne.

Wyciek danych bez kaskadowego usuwania

Jak dokładnie dochodzi do wycieku danych, jeśli polegamy na nieistniejącym usuwaniu kaskadowym? Jeśli masz zagnieżdżone obiekty Realm, muszą zostać usunięte.
Przyjrzyjmy się (prawie) prawdziwemu przykładowi. 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 obraz ImageEntity, dostosowane składniki CustomizationEntityPonadto produkt w koszyku może być zestawem z własnym zestawem produktów. RealmList (CartProductEntity). Wszystkie wymienione pola są obiektami Realm. Jeśli wstawimy nowy obiekt (copyToRealm() / copyToRealmOrUpdate()) o tym samym id, ten obiekt zostanie całkowicie nadpisany. Ale wszystkie wewnętrzne obiekty (image, customizationEntity i cartComboProducts) utracą połączenie z obiektem nadrzędnym i pozostaną w bazie danych.

Ponieważ połączenie z nimi zostało utracone, nie czytamy ich już ani nie usuwamy (chyba że wyraźnie uzyskamy do nich dostęp lub wyczyścimy całą „tabelę”). Nazywamy je „wyciekami pamięci”.

Kiedy pracujemy z Realm, musimy jawnie przejść przez wszystkie elementy i jawnie usunąć wszystko przed takimi operacjami. Można to zrobić na przykład tak:

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ć zgodnie z oczekiwaniami. W tym przykładzie zakładamy, że nie ma innych zagnieżdżonych obiektów Realm wewnątrz image, customizationEntity i cartComboProducts, więc nie ma innych zagnieżdżonych pętli i usunięć.

Szybka naprawa

Pierwszą rzeczą, którą zrobiliśmy, było wyczyszczenie najszybciej rosnących obiektów i sprawdzenie wyników - czy rozwiązałoby to nasz początkowy problem. Na początku przyjęliśmy najprostsze i najbardziej intuicyjne rozwiązanie, 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 dzieci jako płaską listę. Każdy obiekt dziecka może również implementować interfejs NestedEntityAware, wskazując, że ma wewnętrzne obiekty Realm do usunięcia, na przykład 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że być powtarzane.

Następnie piszemy metodę, która rekurencyjnie usuwa wszystkie zagnieżdżone obiekty. Metoda (utworzona jako rozszerzenie) deleteAllNestedEntities pobiera wszystkie obiekty i metody najwyższego poziomu deleteNestedRecursively rekurencyjnie usuwa wszystko, co jest zagnieżdżone, korzystając z 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 samo z obiektami, które rozwijały się najszybciej 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ąć. A ogólny wzrost bazy zwolnił, ale się nie zatrzymał.

Rozwiązanie „normalne”

Baza danych, choć rosła wolniej, nadal rosła. Więc zaczęliśmy szukać dalej. Nasz projekt aktywnie korzysta z buforowania danych w Realm. Dlatego pisanie wszystkich zagnieżdżonych obiektów dla każdego obiektu jest pracochłonne, a ryzyko błędu wzrasta, ponieważ można zapomnieć o określeniu obiektów podczas zmiany kodu.

Chciałem zrobić tak, żeby nie używać interfejsów, tylko żeby wszystko działało samo z siebie.

Gdy chcemy, aby coś działało samo, musimy użyć refleksji. Aby to zrobić, możemy przejść przez każde pole klasy i sprawdzić, czy jest to obiekt Realm, czy lista obiektów:

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

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

Jeśli pole jest RealmModel lub RealmList, to umieścimy obiekt tego pola na liście zagnieżdżonych obiektów. Wszystko jest dokładnie takie samo, jak zrobiliśmy powyżej, tylko tutaj zostanie to zrobione automatycznie. Sama metoda usuwania kaskadowego jest bardzo prosta i wygląda tak:

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 przepuszcza tylko obiekty Realm. Metoda getNestedRealmObjects poprzez refleksję znajduje wszystkie zagnieżdżone obiekty Realm i umieszcza je na liście liniowej. Następnie rekurencyjnie robi to samo. Podczas usuwania należy sprawdzić poprawność obiektu isValid, ponieważ może się zdarzyć, że różne obiekty nadrzędne mogą mieć zagnieżdżone identyczne. Lepiej nie pozwalać na to i po prostu używać automatycznego generowania id 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 używamy „cascade delete” dla każdej operacji modyfikacji danych. Na przykład dla operacji insert 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 pobiera wszystkie dodawane obiekty, a następnie metodę cascadeDelete rekurencyjnie usuwa wszystkie zebrane obiekty przed zapisaniem nowych. Skończyło się na tym, że zastosowaliśmy to podejście w całej aplikacji. Wycieki pamięci w Realm całkowicie zniknęły. Uruchamiając ten sam pomiar zależności czasu uruchamiania 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 z automatycznym kaskadowym usuwaniem zagnieżdżonych obiektów.

Wyniki i wnioski

Ciągle rosnąca baza danych Realm znacznie spowalniała uruchamianie aplikacji. Wydaliśmy aktualizację z naszym własnym „kaskadowym usuwaniem” zagnieżdżonych obiektów. Teraz śledzimy i oceniamy, jak nasza decyzja wpłynęła na czas uruchamiania aplikacji za pomocą metryki _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 mediana, jak i ten przypadający na 95. percentyl użytkowników – zaczął się skracać i już nie wydłuża.

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

Jeśli przyjrzymy się wykresowi siedmiodniowemu, wartość _app_start wydaje się całkowicie prawidłowa i wynosi mniej niż 1 sekundę.

Warto dodać osobno, że domyślnie Firebase wysyła powiadomienia, jeśli mediana wartości _app_start przekroczy 5 sekund. Jednak, jak widać, nie należy na tym polegać, ale raczej sprawdzić to jawnie.

Specyfiką bazy danych Realm jest to, że jest to baza danych nierelacyjna. Pomimo łatwości użytkowania, podobieństwa do rozwiązań ORM i łączenia obiektów, nie ma ona kaskadowego usuwania.

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

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

Pomimo rozmów o tej funkcji, brak kaskadowego usuwania w Realm jest celowy. Jeśli projektujesz nową aplikację, weź to pod uwagę. A jeśli już używasz Realm, sprawdź, czy masz takie problemy.

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