Оповідь про те, як каскадне видалення Realm тривалий запуск перемогло

Всі користувачі вважають швидкий запуск і чуйний UI в мобільних додатках само собою зрозумілим. Якщо програма запускається довго, користувач починає сумувати та злитися. Просто можна зіпсувати клієнтський досвід або втратити користувача ще до того, як він почав користуватися додатком.

Одного разу ми виявили, що програма Додо Піца запускається в середньому 3 секунди, а в деяких «щасливчиків» 15-20 секунд.

Під катом історія з хепі ендом: про зростання бази даних Realm, витік пам'яті, те, як ми збирали вкладені об'єкти, а потім взяли себе в руки і все полагодили.

Оповідь про те, як каскадне видалення Realm тривалий запуск перемогло

Оповідь про те, як каскадне видалення Realm тривалий запуск перемогло
Автор статті: Максим Качинкін - Android-розробник у Додо Піцці.

Три секунди від кліка на іконку програми до onResume() першого активіті - нескінченність. А в деяких користувачів час запуску сягав 15-20 секунд. Як таке взагалі можливе?

Дуже короткий зміст для тих, кому ніколи читати
У нас нескінченно росла база даних Realm. Деякі вкладені об'єкти не видалялися, а постійно накопичувалися. Час запуску програми поступово збільшувався. Потім ми це полагодили, і час запуску прийшов до цільового - стало менше 1 секунди і більше не росте. У статті аналіз ситуації та два варіанти вирішення — швидко і нормально.

Пошук та аналіз проблеми

Сьогодні будь-який мобільний додаток має запускатися швидко і бути чуйним. Але справа не лише у мобільному додатку. Користувальницький досвід взаємодії з сервісом та компанією – це комплексна річ. Наприклад, у нашому випадку швидкість доставки – один із ключових показників для сервісу піци. Якщо доставка швидка, то піца буде гарячою, і клієнт, який хоче їсти зараз, не чекатиме довго. Для програми, у свою чергу, важливо створювати відчуття швидкого сервісу, адже якщо програма тільки 20 секунд запускається, то скільки доведеться чекати на піцу?

Спочатку ми самі стикалися з тим, що іноді програма запускається пару-трійку секунд, а потім до нас почали долітати скарги інших колег про те, що «довго». Але стабільно повторити цю ситуацію нам не вдавалося.

Довго це скільки? Згідно Google-документаціїЯкщо холодний старт програми займає менше 5 секунд, то це вважається «як би нормально». Android-додаток Додо Піци запускався (відповідно до Firebase метрики _app_start) при холодному старті в середньому за 3 секунди - "Not great, not terrible", як кажуть.

Але потім стали з'являтися скарги, що програма запускається дуже-дуже-дуже довго! Для початку ми вирішили виміряти, що таке «дуже-дуже-дуже довго». І скористалися для цього Firebase trace App start trace.

Оповідь про те, як каскадне видалення Realm тривалий запуск перемогло

Цей стандартний трейс вимірює час між моментом, коли користувач відкриває програму, і моментом, коли виконається onResume() першого активіті. У Firebase Console ця метрика називається _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 відображає лог зі рядком Displayed та часом. Цей час дорівнює проміжку з моменту запуску програми до кінця відображення активіті. За цей час відбуваються події:

  • Запуск процесу.
  • Ініціалізація об'єктів.
  • Створення та ініціалізація активіті.
  • Створення лейауту.
  • Відображення програми.

Нам підходить. Якщо запустити 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 МБ до 15 МБ. Разом виходить, що з часом (зі зростанням холодних запусків) зростав і час запуску програми та розмір бази. У нас на руках виникла гіпотеза. Наразі залишалося підтвердити залежність. Тому ми вирішили прибрати «відплив» і перевірити, чи прискорить це запуск.

Причини нескінченного зростання бази даних

Перш ніж прибирати «відплив», варто розібратися, чому вони взагалі з'явилися. Для цього пригадаємо, що таке Realm.

Realm – це нереляційна база даних. Вона дозволяє описувати зв'язки між об'єктами схожим способом, яким описують багато ORM реляційних баз даних на Android. Realm зберігає безпосередньо об'єкти в пам'яті з найменшою кількістю перетворень і мапінгів. Це дозволяє читати дані з диска дуже швидко, що є сильною стороною Realm, яку його люблять.

(У рамках цієї статті цього опису нам буде достатньо. Детальніше про Realm можна прочитати в крутій документації або в них академії).

Багато розробників звикли працювати переважно з реляційними базами даних (наприклад, ORM-базами c 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()) з таким самим ID, цей об'єкт повністю перезапишеться. Але всі внутрішні об'єкти (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()
}
// и потом уже сохраняем

Якщо зробити так, то все працюватиме як треба. У цьому прикладі ми припускаємо, що всередині зображення, 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, Тому що може бути таке, що різні батьківські об'єкти можуть мати однакові вкладені. Цього краще не допускати і просто використовувати автогенерацію ID при створенні нових об'єктів.

Оповідь про те, як каскадне видалення 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 зроблено by design. Якщо ви проектуєте нову програму, то враховуйте це. А якщо вже використовуєте Realm — перевірте, чи немає таких проблем.

Джерело: habr.com

Додати коментар або відгук