Все пользователи считают быстрый запуск и отзывчивый UI в мобильных приложениях само собой разумеющимся. Если приложение запускается долго, пользователь начинает грустить и злиться. Запросто можно подпортить клиентский опыт или вовсе потерять пользователя ещё до того, как он начал пользоваться приложением.
Однажды мы обнаружили, что приложение Додо Пицца запускается в среднем 3 секунды, а у некоторых «счастливчиков» 15-20 секунд.
Под катом история с хеппи эндом: про рост базы данных Realm, утечку памяти, то, как мы копили вложенные объекты, а после взяли себя в руки и всё починили.
![](https://habrastorage.org/r/w1560/webt/_m/ub/s6/_mubs6hvgm_i9y90xpvjvrwzfh8.png)
Три секунды от клика на иконку приложения до onResume() первого активити — бесконечность. А у некоторых пользователей время запуска доходило до 15-20 секунд. Как такое вообще возможно?
Сегодня любое мобильное приложение должно запускаться быстро и быть отзывчивым. Но дело не только в мобильном приложении. Пользовательский опыт взаимодействия с сервисом и компанией — это комплексная вещь. Например, в нашем случае скорость доставки — один из ключевых показателей для сервиса пиццы. Если доставка быстрая, то пицца будет горячей, и клиент, который хочет есть сейчас, не будет долго ждать. Для приложения, в свою очередь, важно создавать ощущение быстрого сервиса, ведь если приложение только 20 секунд запускается, то сколько придётся ждать пиццу?
Сначала мы сами сталкивались с тем, что иногда приложение запускается пару-тройку секунд, а потом до нас стали долетать жалобы других коллег о том, что «долго». Но стабильно повторить эту ситуацию нам не удавалось.
Долго — это сколько? Согласно Google-документации, если холодный старт приложения занимает менее 5 секунд, то это считается «как бы нормально». Android-приложение Додо Пиццы запускалось (согласно Firebase метрике _app_start) при холодном старте в среднем за 3 секунды — «Not great, not terrible», как говорится.
Но потом стали появляться жалобы, что приложение запускается очень-очень-очень долго! Для начала мы решили измерить, что же такое «очень-очень-очень долго». И воспользовались для этого Firebase trace App start trace.
![](https://habrastorage.org/r/w1560/webt/1-/y8/sk/1-y8skg3jwb0rpl8xaljsbkg7iq.png)
Этот стандартный трейс измеряет время между моментом, когда пользователь открывает приложение, и моментом, когда выполнится onResume() первого активити. В Firebase Console эта метрика называется _app_start. Выяснилось что:
![](https://habrastorage.org/r/w1560/webt/49/1p/je/491pjeyzs2ahshweepovgqtyieu.png)
На ум пришло две мысли:
«Наверное, что-то с базой данных», — подумали мы и оказались правы. Во-первых, база данных используется у нас как кэш, при миграции мы её очищаем. Во-вторых, база данных загружается при старте приложения. Всё сходится.
Мы стали проверять, как меняется содержимое базы со временем жизни приложения, от первой установки и далее в процессе активного использования. Посмотреть содержимое базы данных Realm можно через Stetho или более подробно и наглядно, открыв файл через Realm Studio. Чтобы посмотреть содержимое базы через ADB, копируем файл базы Realm:
Посмотрев на содержимое базы в разное время, мы выяснили, что количество объектов определённого типа постоянно увеличивается.
![](https://habrastorage.org/r/w1560/webt/ey/s3/72/eys372ydjgnvgw_0ajh2stt72xw.png)
На картинке показан фрагмент Realm Studio для двух файлов: слева — база приложения спустя некоторое время после установки, справа — после активного использования. Видно, что количество объектов
Неконтролируемый рост базы данных — это очень плохо. Но как это влияет на время запуска приложения? Померить это достаточно просто через ActivityManager. Начиная с Android 4.4, logcat отображает лог со строкой Displayed и временем. Это время равно промежутку с момента запуска приложения до конца отрисовки активити. За это время происходят события:
Нам подходит. Если запустить ADB с флагами -S и -W, то можно получить расширенный вывод с временем запуска:
Если сгрепать оттуда
![](https://habrastorage.org/r/w1560/webt/_e/ad/rp/_eadrpfeeugsla2xpbjjarbbyze.png)
При этом был такой же характер зависимости размера и роста базы, которая выросла с 4 МБ до 15 МБ. Итого получается, что со временем (с ростом холодных запусков) росло и время запуска приложения и размер базы. У нас на руках появилась гипотеза. Теперь оставалось подтвердить зависимость. Поэтому мы решили убрать «утечки» и проверить, ускорит ли это запуск.
Прежде чем убирать «утечки», стоит разобраться, почему они вообще появились. Для этого вспомним, что такое Realm.
Realm — это нереляционная база данных. Она позволяет описывать связи между объектами похожим способом, которым описывают многие ORM реляционные базы данных на Android. При этом Realm сохраняет напрямую объекты в памяти с наименьшим количеством преобразований и маппингов. Это позволяет читать данные с диска очень быстро, что является сильной стороной Realm, за которую его любят.
(В рамках данной статьи этого описания нам будет достаточно. Более подробно о Realm можно прочитать в крутой документации или в их академии).
Многие разработчики привыкли работать в большей степени с реляционными базами данных (например, ORM-базами c SQL под капотом). И такие вещи как каскадное удаление данных часто кажутся само собой разумеющимся делом. Но не в Realm.
Кстати говоря, фичу каскадного удаления просят сделать уже давно. Эту доработку и другую, связанную с ней, активно обсуждали. Было ощущение, что она скоро будет сделана. Но потом всё перевелось в введение сильных и слабых ссылок, которые тоже автоматом решили бы эту проблему. По этой задаче был довольно живой и активный пул-реквест, который пока из-за внутренних сложностей поставили на паузу.
Как именно утекают данные, если надеяться на несуществующее каскадное удаление? Если у вас есть вложенные Realm-объекты, то их нужно обязательно удалять.
Рассмотрим (почти) реальный пример. У нас есть объект
У продукта в корзине есть разные поля, в том числе картинка
Так как связь с ними потеряна, мы их больше не читаем и не удаляем (только если не обращаться к ним явно или не чистить всю «таблицу»). Мы это назвали «утечками памяти».
Когда мы работаем с Realm, то должны явно проходить по всем элементам и явно все удалять перед такими операциями. Это можно сделать, например, вот так:
Если сделать так, то всё будет работать как надо. В данном примере мы предполагаем, что внутри image, customizationEntity и cartComboProducts нет других вложенных Realm-объектов, поэтому нет других вложенных циклов и удалений.
Первым делом мы решили подчистить самые быстрорастущие объекты и проверить результаты — решит ли это нашу изначальную проблему. Сначала было сделано наиболее простое и интуитивно-понятное решение, а именно: каждый объект должен быть ответственным за удаление за собой своих детей. Для этого ввели такой интерфейс, который возвращал список своих вложенных Realm-объектов:
И реализовали его в наших Realm-объектах:
В
И так далее вложенность объектов может повторяться.
Затем пишем метод, который рекурсивно удаляет все вложенные объекты. Метод (сделанный в виде экстеншена)
Мы проделали это с самыми быстрорастущими объектами и проверили, что получилось.
![](https://habrastorage.org/r/w1560/webt/pe/0b/xr/pe0bxrf4t_uiy1qab4stnlpj_ow.png)
В результате те объекты, которые мы покрыли этим решением, перестали расти. А общий рост базы замедлился, но не остановился.
База хоть и стала расти медленнее, но все равно росла. Поэтому мы стали искать дальше. В нашем проекте очень активно используется кеширование данных в Realm. Поэтому писать для каждого объекта все вложенные объекты трудозатратно, плюс повышается риск ошибки, ведь можно забыть указать объекты при изменении кода.
Хотелось сделать так, чтобы не использовать интерфейсы, а чтобы всё работало само.
Когда мы хотим, чтобы что-то работало само, приходится использовать рефлексию. Для этого мы можем пройтись по каждому полю класса и проверить, является ли он Realm-объектом или списком объектов:
Если поле является RealmModel или RealmList, то сложим объект этого поля в список вложенных объектов. Всё точно так же, как мы делали выше, только тут оно будет делаться само. Сам метод каскадного удаления получается очень простым и выглядит так:
Экстеншн
![](https://habrastorage.org/r/w1560/webt/5p/sy/l-/5psyl-ni_uc2muc1p0ukua_4tei.png)
В итоге в нашем клиентском коде мы используем «каскадное удаление» при каждой операции изменения данных. Например, для операции вставки это выглядит вот так:
Сначала метод
![](https://habrastorage.org/r/w1560/webt/op/qi/no/opqinodfcgsbpwxebhjuttijsmk.png)
Зелёная линия показывает зависимость времени запуска приложения от количества холодных стартов при автоматическом каскадном удалении вложенных объектов.
Постоянно растущая база данных Realm сильно замедляла запуск приложения. Мы выпустили обновление с собственным «каскадным удалением» вложенных объектов. И теперь отслеживаем и оцениваем, как наше решение повлияло на время запуска приложения через метрику _app_start.
![](https://habrastorage.org/r/w1560/webt/vp/y1/pr/vpy1pr8woxa0anb2kbvy6xfwtnm.png)
Для анализа берём промежуток времени 90 дней и видим: время запуска приложения, как медианное, так и то, что приходится на 95 процентиль пользователей, начало уменьшаться и больше не поднимается.
![](https://habrastorage.org/r/w1560/webt/pf/v3/v5/pfv3v5_rc57vmivs5rk0ww0n1lk.png)
Если посмотреть на семидневный график, то метрика _app_start полностью выглядит адекватной и составляет меньше 1 секунды.
Отдельно стоит добавить, что по умолчанию Firebase шлёт уведомления, если медианное значение _app_start превышает 5 секунд. Однако, как мы видим, на это не стоит полагаться, а лучше зайти и проверить его явно.
Особенность базы данных Realm заключается в том, что это нереляционная база данных. Несмотря на простое использование, схожесть работы с ORM-решениями и связывание объектов, у неё нет каскадного удаления.
Если это не учитывать, то вложенные объекты будут накапливаться, «утекать». База данных будет расти постоянно, что в свою очередь скажется на замедлении работы или запуске приложения.
Я поделился нашим опытом, как быстро сделать каскадное удаление объектов в Realm, которого пока нет из коробки, но о котором давно говорят и говорят. В нашем случае это сильно ускорило время запуска приложения.
Несмотря на обсуждение скорого появления этой фичи, отсутствие каскадного удаления в Realm сделано by design. Если вы проектируете новое приложение, то учитывайте это. А если уже используете Realm — проверьте, нет ли у вас таких проблем.
Однажды мы обнаружили, что приложение Додо Пицца запускается в среднем 3 секунды, а у некоторых «счастливчиков» 15-20 секунд.
Под катом история с хеппи эндом: про рост базы данных Realm, утечку памяти, то, как мы копили вложенные объекты, а после взяли себя в руки и всё починили.
![](https://habrastorage.org/webt/_m/ub/s6/_mubs6hvgm_i9y90xpvjvrwzfh8.png)
![]()
Автор статьи: Максим Качинкин — Android-разработчик в Додо Пицце.
Три секунды от клика на иконку приложения до onResume() первого активити — бесконечность. А у некоторых пользователей время запуска доходило до 15-20 секунд. Как такое вообще возможно?
Очень краткое содержание для тех, кому некогда читать
У нас бесконечно росла база данных Realm. Некоторые вложенные объекты не удалялись, а постоянно накапливались. Время запуска приложения постепенно увеличивалось. Потом мы это починили, и время запуска пришло к целевому — стало менее 1 секунды и больше не растёт. В статье анализ ситуации и два варианта решения — по-быстрому и по-нормальному.
Поиск и анализ проблемы
Сегодня любое мобильное приложение должно запускаться быстро и быть отзывчивым. Но дело не только в мобильном приложении. Пользовательский опыт взаимодействия с сервисом и компанией — это комплексная вещь. Например, в нашем случае скорость доставки — один из ключевых показателей для сервиса пиццы. Если доставка быстрая, то пицца будет горячей, и клиент, который хочет есть сейчас, не будет долго ждать. Для приложения, в свою очередь, важно создавать ощущение быстрого сервиса, ведь если приложение только 20 секунд запускается, то сколько придётся ждать пиццу?
Сначала мы сами сталкивались с тем, что иногда приложение запускается пару-тройку секунд, а потом до нас стали долетать жалобы других коллег о том, что «долго». Но стабильно повторить эту ситуацию нам не удавалось.
Долго — это сколько? Согласно Google-документации, если холодный старт приложения занимает менее 5 секунд, то это считается «как бы нормально». Android-приложение Додо Пиццы запускалось (согласно Firebase метрике _app_start) при холодном старте в среднем за 3 секунды — «Not great, not terrible», как говорится.
Но потом стали появляться жалобы, что приложение запускается очень-очень-очень долго! Для начала мы решили измерить, что же такое «очень-очень-очень долго». И воспользовались для этого Firebase trace App start trace.
![](https://habrastorage.org/webt/1-/y8/sk/1-y8skg3jwb0rpl8xaljsbkg7iq.png)
Этот стандартный трейс измеряет время между моментом, когда пользователь открывает приложение, и моментом, когда выполнится onResume() первого активити. В Firebase Console эта метрика называется _app_start. Выяснилось что:
- Время запуска у пользователей выше 95-го процентиля составляет почти 20 секунд (у некоторых и больше), несмотря на то, что медианное время холодного запуска менее 5 секунд.
- Время запуска — величина не постоянная, а растущая со временем. Но иногда наблюдаются падения. Эту закономерность мы нашли, когда увеличили масштаб анализа до 90 дней.
![](https://habrastorage.org/webt/49/1p/je/491pjeyzs2ahshweepovgqtyieu.png)
На ум пришло две мысли:
- Что-то утекает.
- Это «что-то» после релиза сбрасывается и потом утекает вновь.
«Наверное, что-то с базой данных», — подумали мы и оказались правы. Во-первых, база данных используется у нас как кэш, при миграции мы её очищаем. Во-вторых, база данных загружается при старте приложения. Всё сходится.
Что не так с базой данных Realm
Мы стали проверять, как меняется содержимое базы со временем жизни приложения, от первой установки и далее в процессе активного использования. Посмотреть содержимое базы данных Realm можно через Stetho или более подробно и наглядно, открыв файл через Realm Studio. Чтобы посмотреть содержимое базы через ADB, копируем файл базы Realm:
adb exec-out run-as ${PACKAGE_NAME} cat files/${DB_NAME}
Посмотрев на содержимое базы в разное время, мы выяснили, что количество объектов определённого типа постоянно увеличивается.
![](https://habrastorage.org/webt/ey/s3/72/eys372ydjgnvgw_0ajh2stt72xw.png)
На картинке показан фрагмент 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
время, можно автоматизировать сбор этой метрики и посмотреть наглядно на результаты. На графике ниже приведена зависимость времени запуска приложения от количества холодных запусков приложения.![](https://habrastorage.org/webt/_e/ad/rp/_eadrpfeeugsla2xpbjjarbbyze.png)
При этом был такой же характер зависимости размера и роста базы, которая выросла с 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()
}
// и потом уже сохраняем
Если сделать так, то всё будет работать как надо. В данном примере мы предполагаем, что внутри image, customizationEntity и cartComboProducts нет других вложенных Realm-объектов, поэтому нет других вложенных циклов и удалений.
Решение «по-быстрому»
Первым делом мы решили подчистить самые быстрорастущие объекты и проверить результаты — решит ли это нашу изначальную проблему. Сначала было сделано наиболее простое и интуитивно-понятное решение, а именно: каждый объект должен быть ответственным за удаление за собой своих детей. Для этого ввели такой интерфейс, который возвращал список своих вложенных 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()
}
}
}
Мы проделали это с самыми быстрорастущими объектами и проверили, что получилось.
![](https://habrastorage.org/webt/pe/0b/xr/pe0bxrf4t_uiy1qab4stnlpj_ow.png)
В результате те объекты, которые мы покрыли этим решением, перестали расти. А общий рост базы замедлился, но не остановился.
Решение «по-нормальному»
База хоть и стала расти медленнее, но все равно росла. Поэтому мы стали искать дальше. В нашем проекте очень активно используется кеширование данных в 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 при создании новых объектов. ![](https://habrastorage.org/webt/5p/sy/l-/5psyl-ni_uc2muc1p0ukua_4tei.png)
Полная реализация метода 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 полностью исчезли. Проведя тот же замер зависимости времени запуска от количества холодных запусков приложения, мы видим результат.![](https://habrastorage.org/webt/op/qi/no/opqinodfcgsbpwxebhjuttijsmk.png)
Зелёная линия показывает зависимость времени запуска приложения от количества холодных стартов при автоматическом каскадном удалении вложенных объектов.
Результаты и выводы
Постоянно растущая база данных Realm сильно замедляла запуск приложения. Мы выпустили обновление с собственным «каскадным удалением» вложенных объектов. И теперь отслеживаем и оцениваем, как наше решение повлияло на время запуска приложения через метрику _app_start.
![](https://habrastorage.org/webt/vp/y1/pr/vpy1pr8woxa0anb2kbvy6xfwtnm.png)
Для анализа берём промежуток времени 90 дней и видим: время запуска приложения, как медианное, так и то, что приходится на 95 процентиль пользователей, начало уменьшаться и больше не поднимается.
![](https://habrastorage.org/webt/pf/v3/v5/pfv3v5_rc57vmivs5rk0ww0n1lk.png)
Если посмотреть на семидневный график, то метрика _app_start полностью выглядит адекватной и составляет меньше 1 секунды.
Отдельно стоит добавить, что по умолчанию Firebase шлёт уведомления, если медианное значение _app_start превышает 5 секунд. Однако, как мы видим, на это не стоит полагаться, а лучше зайти и проверить его явно.
Особенность базы данных Realm заключается в том, что это нереляционная база данных. Несмотря на простое использование, схожесть работы с ORM-решениями и связывание объектов, у неё нет каскадного удаления.
Если это не учитывать, то вложенные объекты будут накапливаться, «утекать». База данных будет расти постоянно, что в свою очередь скажется на замедлении работы или запуске приложения.
Я поделился нашим опытом, как быстро сделать каскадное удаление объектов в Realm, которого пока нет из коробки, но о котором давно говорят и говорят. В нашем случае это сильно ускорило время запуска приложения.
Несмотря на обсуждение скорого появления этой фичи, отсутствие каскадного удаления в Realm сделано by design. Если вы проектируете новое приложение, то учитывайте это. А если уже используете Realm — проверьте, нет ли у вас таких проблем.