Историята за това как каскадното изтриване в дългото стартиране на Realm спечели

Всички потребители приемат бързото стартиране и отзивчивия потребителски интерфейс в мобилните приложения за даденост. Ако стартирането на приложението отнема много време, потребителят започва да се чувства тъжен и ядосан. Можете лесно да развалите потребителското изживяване или напълно да загубите потребителя, дори преди той да започне да използва приложението.

Веднъж открихме, че стартирането на приложението Dodo Pizza отнема средно 3 секунди, а за някои „късметлии“ отнема 15-20 секунди.

Под изрезката има история с щастлив край: за растежа на базата данни на Realm, изтичане на памет, как натрупахме вложени обекти и след това се събрахме и поправихме всичко.

Историята за това как каскадното изтриване в дългото стартиране на Realm спечели

Историята за това как каскадното изтриване в дългото стартиране на Realm спечели
Автор на статията: Максим Качинкин — Android разработчик в Dodo Pizza.

Три секунди от щракване върху иконата на приложението до onResume() на първата дейност са безкрайност. А за някои потребители времето за стартиране достигна 15-20 секунди. Как изобщо е възможно това?

Много кратко резюме за тези, които нямат време да четат
Нашата база данни Realm нарастваше безкрайно. Някои вложени обекти не бяха изтрити, но постоянно се натрупваха. Времето за стартиране на приложението постепенно се увеличава. След това го поправихме и времето за стартиране достигна целта - стана по-малко от 1 секунда и вече не се увеличава. Статията съдържа анализ на ситуацията и две решения - бързо и нормално.

Търсене и анализ на проблема

Днес всяко мобилно приложение трябва да стартира бързо и да реагира бързо. Но не става въпрос само за мобилното приложение. Потребителското изживяване при взаимодействие с услуга и компания е сложно нещо. Например, в нашия случай скоростта на доставка е един от ключовите показатели за обслужване на пица. Ако доставката е бърза, пицата ще бъде гореща и клиентът, който иска да яде сега, няма да чака дълго. За приложението от своя страна е важно да създаде усещане за бързо обслужване, защото ако стартирането на приложението отнема само 20 секунди, колко време ще трябва да чакате за пицата?

Отначало ние самите се сблъскахме с факта, че понякога стартирането на приложението отнемаше няколко секунди, а след това започнахме да чуваме оплаквания от други колеги колко време отнема. Но не успяхме да повторим тази ситуация постоянно.

Колко е дълго? Според Google документация, ако студеното стартиране на приложение отнема по-малко от 5 секунди, тогава това се счита за „сякаш нормално“. Стартира приложението Dodo Pizza за Android (според показателите на Firebase _app_start) при студен старт средно за 3 секунди - „Не е страхотно, не е ужасно“, както се казва.

Но след това започнаха да се появяват оплаквания, че стартирането на приложението отнема много, много, много време! Като начало решихме да измерим какво е „много, много, много дълго“. И ние използвахме Firebase trace за това Стартиране на приложението.

Историята за това как каскадното изтриване в дългото стартиране на Realm спечели

Това стандартно проследяване измерва времето между момента, в който потребителят отвори приложението, и момента, в който се изпълни onResume() на първата дейност. В конзолата на Firebase този показател се нарича _app_start. Оказа се, че:

  • Времето за стартиране за потребители над 95-ия процентил е почти 20 секунди (някои дори повече), въпреки че средното време за студено стартиране е по-малко от 5 секунди.
  • Времето за стартиране не е постоянна стойност, а нараства с времето. Но понякога има спадове. Открихме този модел, когато увеличихме мащаба на анализа до 90 дни.

Историята за това как каскадното изтриване в дългото стартиране на Realm спечели

Две мисли ми хрумнаха:

  1. Нещо изтича.
  2. Това „нещо“ се нулира след освобождаване и след това изтича отново.

„Вероятно нещо с базата данни“, помислихме си и бяхме прави. Първо, използваме базата данни като кеш; по време на миграцията я изчистваме. Второ, базата данни се зарежда при стартиране на приложението. Всичко си пасва.

Какво не е наред с базата данни Realm

Започнахме да проверяваме как съдържанието на базата данни се променя през живота на приложението, от първата инсталация и по-нататък по време на активна употреба. Можете да видите съдържанието на базата данни на Realm чрез Стето или по-подробно и ясно, като отворите файла през Realm Studio. За да видите съдържанието на базата данни чрез ADB, копирайте файла с база данни на Realm:

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

След като разгледахме съдържанието на базата данни по различно време, установихме, че броят на обектите от определен тип непрекъснато нараства.

Историята за това как каскадното изтриване в дългото стартиране на Realm спечели
Картината показва фрагмент от Realm Studio за два файла: вляво - базата на приложението известно време след инсталирането, вдясно - след активна употреба. Вижда се, че броят на обектите ImageEntity и MoneyType е нараснал значително (екранната снимка показва броя на обектите от всеки тип).

Връзка между растежа на базата данни и времето за стартиране

Неконтролираното нарастване на базата данни е много лошо. Но как това се отразява на времето за стартиране на приложението? Доста лесно е да измерите това чрез ActivityManager. От Android 4.4 насам logcat показва дневника с показания низ и часа. Това време е равно на интервала от момента на стартиране на приложението до края на изобразяването на активността. През това време се случват следните събития:

  • Започнете процеса.
  • Инициализация на обекти.
  • Създаване и инициализация на дейности.
  • Създаване на оформление.
  • Изобразяване на приложения.

Устройва ни. Ако стартирате ADB с флаговете -S и -W, можете да получите разширен изход с времето за стартиране:

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

Ако го вземете от там grep -i WaitTime време, можете да автоматизирате събирането на този показател и визуално да видите резултатите. Графиката по-долу показва зависимостта на времето за стартиране на приложението от броя на студените стартирания на приложението.

Историята за това как каскадното изтриване в дългото стартиране на Realm спечели

В същото време имаше същия характер на връзката между размера и растежа на базата данни, която нарасна от 4 MB на 15 MB. Като цяло се оказва, че с течение на времето (с нарастването на студените стартирания) се увеличава както времето за стартиране на приложението, така и размерът на базата данни. Имаме хипотеза в ръцете си. Сега оставаше само да се потвърди зависимостта. Затова решихме да премахнем „течовете“ и да видим дали това ще ускори стартирането.

Причини за безкраен растеж на базата данни

Преди да премахнете „течовете“, си струва да разберете защо са се появили на първо място. За да направите това, нека си спомним какво е Realm.

Realm е нерелационна база данни. Позволява ви да описвате връзките между обекти по начин, подобен на описания много ORM релационни бази данни на Android. В същото време Realm съхранява обекти директно в паметта с най-малко трансформации и съпоставяния. Това ви позволява да четете данни от диска много бързо, което е силата на Realm и защо е обичан.

(За целите на тази статия това описание ще ни е достатъчно. Можете да прочетете повече за Realm в cool документация или в техните академия).

Много разработчици са свикнали да работят повече с релационни бази данни (например ORM бази данни със SQL под капака). И неща като каскадно изтриване на данни често изглеждат като даденост. Но не и в Realm.

Между другото, функцията за каскадно изтриване е поискана от дълго време. Това ревизия и друг, свързан с него, беше активно обсъждан. Имаше чувството, че скоро ще бъде направено. Но тогава всичко се превърна в въвеждане на силни и слаби връзки, което също автоматично ще реши този проблем. Беше доста жив и активен в тази задача заявка за изтегляне, която засега е на пауза поради вътрешни затруднения.

Изтичане на данни без каскадно изтриване

Как точно изтичат данни, ако разчитате на несъществуващо каскадно изтриване? Ако имате вложени Realm обекти, те трябва да бъдат изтрити.
Нека да разгледаме (почти) реален пример. Имаме обект 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()

Продуктът в количката има различни полета, включително и картинка ImageEntity, персонализирани съставки CustomizationEntity. Също така, продуктът в количката може да бъде комбо със собствен набор от продукти RealmList (CartProductEntity). Всички изброени полета са Realm обекти. Ако вмъкнем нов обект (copyToRealm() / copyToRealmOrUpdate()) със същия идентификатор, тогава този обект ще бъде напълно презаписан. Но всички вътрешни обекти (image, customizationEntity и cartComboProducts) ще загубят връзка с родителя и ще останат в базата данни.

Тъй като връзката с тях е загубена, ние вече не ги четем или изтриваме (освен ако изрично не ги достъпим или изчистим цялата „таблица“). Нарекохме това „изтичане на памет“.

Когато работим с Realm, трябва изрично да преминем през всички елементи и изрично да изтрием всичко преди такива операции. Това може да стане, например, така:

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

Ако направите това, тогава всичко ще работи както трябва. В този пример приемаме, че няма други вложени Realm обекти в image, customizationEntity и cartComboProducts, така че няма други вложени цикли и изтривания.

"Бързо" решение

Първото нещо, което решихме да направим, беше да почистим най-бързо растящите обекти и да проверим резултатите, за да видим дали това ще реши първоначалния ни проблем. Първо, беше направено най-простото и интуитивно решение, а именно: всеки обект трябва да отговаря за премахването на своите деца. За да направим това, въведохме интерфейс, който връща списък с вложени Realm обекти:

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

И ние го внедрихме в нашите 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 връщаме всички деца като плосък списък. И всеки дъщерен обект може също да имплементира интерфейса NestedEntityAware, показвайки, че има вътрешни Realm обекти за изтриване, например 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
   )
 }
}

И така нататък, влагането на обекти може да се повтори.

След това пишем метод, който рекурсивно изтрива всички вложени обекти. Метод (направен като разширение) deleteAllNestedEntities получава всички обекти и метод от най-високо ниво deleteNestedRecursively Рекурсивно премахва всички вложени обекти с помощта на интерфейса 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()
   }
 }
}

Направихме това с най-бързо растящите обекти и проверихме какво се е случило.

Историята за това как каскадното изтриване в дългото стартиране на Realm спечели

В резултат на това тези обекти, които покрихме с този разтвор, спряха да растат. И общият растеж на базата се забави, но не спря.

"Нормалното" решение

Въпреки че основата започна да расте по-бавно, тя все още расте. Така че започнахме да търсим по-нататък. Нашият проект използва много активно кеширането на данни в Realm. Следователно писането на всички вложени обекти за всеки обект е трудоемко, плюс рискът от грешки се увеличава, защото можете да забравите да посочите обекти, когато променяте кода.

Исках да се уверя, че не използвам интерфейси, а че всичко работи само по себе си.

Когато искаме нещо да работи само по себе си, трябва да използваме отражение. За да направим това, можем да преминем през всяко поле на клас и да проверим дали то е Realm обект или списък с обекти:

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

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

Ако полето е RealmModel или RealmList, тогава добавете обекта на това поле към списък с вложени обекти. Всичко е точно същото, както направихме по-горе, само тук ще се направи от само себе си. Самият метод за каскадно изтриване е много прост и изглежда така:

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

Разширение filterRealmObject филтрира и пропуска само Realm обекти. Метод getNestedRealmObjects чрез отражение намира всички вложени Realm обекти и ги поставя в линеен списък. След това правим същото нещо рекурсивно. Когато изтривате, трябва да проверите обекта за валидност isValid, защото може да се окаже, че различни родителски обекти могат да имат вложени идентични. По-добре е да избягвате това и просто да използвате автоматично генериране на идентификатор, когато създавате нови обекти.

Историята за това как каскадното изтриване в дългото стартиране на Realm спечели

Пълна реализация на метода 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)
}

В резултат на това в нашия клиентски код използваме „каскадно изтриване“ за всяка операция за промяна на данни. Например, за операция за вмъкване изглежда така:

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

Метод първи getManagedEntities получава всички добавени обекти и след това метода cascadeDelete Рекурсивно изтрива всички събрани обекти, преди да напише нови. В крайна сметка използваме този подход в цялото приложение. Изтичането на памет в Realm е напълно изчезнало. След като извършихме същото измерване на зависимостта на времето за стартиране от броя на студените стартирания на приложението, виждаме резултата.

Историята за това как каскадното изтриване в дългото стартиране на Realm спечели

Зелената линия показва зависимостта на времето за стартиране на приложението от броя на студените стартирания по време на автоматично каскадно изтриване на вложени обекти.

Резултати и изводи

Постоянно нарастващата база данни на Realm караше приложението да стартира много бавно. Пуснахме актуализация с нашето собствено „каскадно изтриване“ на вложени обекти. И сега ние наблюдаваме и оценяваме как нашето решение се отрази на времето за стартиране на приложението чрез показателя _app_start.

Историята за това как каскадното изтриване в дългото стартиране на Realm спечели

За анализ вземаме времеви период от 90 дни и виждаме: времето за стартиране на приложението, както средното, така и това, което попада на 95-ия процентил от потребителите, започна да намалява и вече не се повишава.

Историята за това как каскадното изтриване в дългото стартиране на Realm спечели

Ако погледнете седемдневната диаграма, показателят _app_start изглежда напълно адекватен и е под 1 секунда.

Също така си струва да добавим, че по подразбиране Firebase изпраща известия, ако средната стойност на _app_start надвишава 5 секунди. Въпреки това, както виждаме, не трябва да разчитате на това, а по-скоро да влезете и да го проверите изрично.

Особеното при Realm базата данни е, че тя е нерелационна база данни. Въпреки лекотата на използване, сходството с ORM решенията и свързването на обекти, той няма каскадно изтриване.

Ако това не се вземе предвид, тогава вложените обекти ще се натрупат и ще „изтекат“. Базата данни ще расте постоянно, което от своя страна ще повлияе на забавянето или стартирането на приложението.

Споделих нашия опит как бързо да направим каскадно изтриване на обекти в Realm, което все още не е готово, но се говори за него от дълго време говорят и говорят. В нашия случай това значително ускори времето за стартиране на приложението.

Въпреки дискусията за предстоящата поява на тази функция, липсата на каскадно изтриване в Realm е направена по проект. Ако проектирате ново приложение, вземете това предвид. И ако вече използвате Realm, проверете дали имате такива проблеми.

Източник: www.habr.com

Добавяне на нов коментар