Казка пра тое, як каскаднае выдаленне ў Realm доўгі запуск перамагло
Усе карыстачы лічаць хуткі запуск і спагадны UI у мабільных прыкладаннях само сабой якія разумеюцца. Калі праграма запускаецца доўга, карыстальнік пачынае сумаваць і злавацца. Проста можна сапсаваць кліенцкі досвед або зусім страціць карыстальніка яшчэ да таго, як ён пачаў карыстацца дадаткам.
Аднойчы мы выявілі, што прыкладанне Дадо Піца запускаецца ў сярэднім 3 секунды, а ў некаторых "шчасліўчыкаў" 15-20 секунд.
Пад катам гісторыя з хэпі эндам: пра рост базы дадзеных Realm, уцечку памяці, тое, як мы назапашвалі ўкладзеныя аб'екты, а пасля ўзялі сябе ў рукі і ўсё паправілі.
Аўтар артыкула: Максім Качынкін — Android-распрацоўшчык у Дадо Піцы.
Тры секунды ад кліку на абразок дадатку да onResume() першага актывіці — бясконцасць. А ў некаторых карыстальнікаў час запуску даходзіла да 15-20 секунд. Як такое ўвогуле магчыма?
Вельмі кароткі змест для тых, каму некалі чытаць
У нас бясконца расла база дадзеных Realm. Некаторыя ўкладзеныя аб'екты не выдаляліся, а ўвесь час назапашваліся. Час запуску прыкладання паступова павялічвалася. Потым мы гэта паправілі, і час запуску прыйшоў да мэтавага стала менш за 1 секунды і больш не расце. У артыкуле аналіз сітуацыі і два варыянты рашэння - па-хуткаму і па-нармальнаму.
Пошук і аналіз праблемы
Сёння любое мабільнае прыкладанне павінна запускацца хутка і быць спагадным. Але справа не толькі ў мабільным дадатку. Карыстацкі вопыт узаемадзеяння з сэрвісам і кампаніяй - гэта комплексная рэч. Напрыклад, у нашым выпадку хуткасць дастаўкі - адзін з ключавых паказчыкаў для сэрвісу піцы. Калі дастаўка хуткая, то піца будзе гарачай, і кліент, які хоча есці зараз, не будзе доўга чакаць. Для прыкладання, у сваю чаргу, важна ствараць адчуванне хуткага сэрвісу, бо калі дадатак толькі 20 секунд запускаецца, то колькі давядзецца чакаць піцу?
Спачатку мы самі сутыкаліся з тым, што часам прыкладанне запускаецца пару-тройку секунд, а потым да нас сталі далятаць скаргі іншых калег аб тым, што "доўга". Але стабільна паўтарыць гэтую сітуацыю нам не ўдавалася.
Доўга - гэта колькі? Згодна Google-дакументацыі, Калі халодны старт прыкладання займае менш за 5 секунд, то гэта лічыцца "як бы нармальна". Android-дадатак Дадо Піцы запускалася (згодна з Firebase метрыцы _app_start) пры халодным старце у сярэднім за 3 секунды - "Not great, not terrible", як гаворыцца.
Але потым сталі з'яўляцца скаргі, што дадатак запускаецца вельмі-вельмі-вельмі доўга! Для пачатку мы вырашылі вымераць, што ж такое "вельмі-вельмі-вельмі доўга". І скарысталіся для гэтага Firebase trace App start trace.
Гэты стандартны трэйс вымярае час паміж момантам, калі карыстач адчыняе прыкладанне, і момантам, калі выканаецца onResume() першага актывіці. У Firebase Console гэтая метрыка завецца _app_start. Высветлілася што:
Час запуску ў карыстальнікаў вышэй за 95-ы працэнты складае амаль 20 секунд (у некаторых і больш), нягледзячы на тое, што медыяны час халоднага запуску менш за 5 секунд.
Час запуску - велічыня не сталая, а якая расце з часам. Але часам назіраюцца падзенні. Гэтую заканамернасць мы знайшлі, калі павялічылі маштаб аналізу да 90 дзён.
На розум прыйшло дзве думкі:
Нешта выцякае.
Гэтае «нешта» пасля рэлізу скідаецца і потым выцякае ізноў.
«Напэўна, нешта з базай дадзеных», - падумалі мы і мелі рацыю. Па-першае, база дадзеных выкарыстоўваецца ў нас як кэш, пры міграцыі мы яе чысцім. Па-другое, база дадзеных загружаецца пры старце дадатку. Усё сыходзіцца.
Што не так з базай дадзеных Realm
Мы сталі правяраць, як мяняецца змесціва базы з часам жыцця прыкладання, ад першай ўстаноўкі і далей у працэсе актыўнага выкарыстання. Паглядзець змесціва базы дадзеных Realm можна праз Стэто ці больш падрабязна і наглядна, адкрыўшы файл праз Realm Studio. Каб паглядзець змесціва базы праз ADB, які капіюецца файл базы Realm:
Паглядзеўшы на змесціва базы ў розны час, мы высветлілі, што колькасць аб'ектаў вызначанага тыпу ўвесь час павялічваецца.
На малюнку паказаны фрагмент Realm Studio для двух файлаў: злева - база прыкладання праз некаторы час пасля ўстаноўкі, справа - пасля актыўнага выкарыстання. Відаць, што колькасць аб'ектаў ImageEntity и MoneyType моцна вырасла (на скрыншоце паказана колькасць аб'ектаў кожнага тыпу).
Сувязь росту базы дадзеных з часам запуску
Некантралюемы рост базы дадзеных - гэта вельмі дрэнна. Але як гэта ўплывае на час запуску дадатку? Памераць гэта дастаткова проста праз ActivityManager. Пачынальна з Android 4.4, logcat адлюстроўвае лог са радком Displayed і часам. Гэты час роўна прамежку з моманту запуску прыкладання да канца адмалёўкі актывiт. За гэты час адбываюцца падзеі:
Запуск працэсу.
Ініцыялізацыя аб'ектаў.
Стварэнне і ініцыялізацыя актывісты.
Стварэнне лейаўта.
Адмалёўка прыкладання.
Нам падыходзіць. Калі запусціць 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 Час, можна аўтаматызаваць збор гэтай метрыкі і паглядзець навочна на вынікі. На графіцы ніжэй прыведзена залежнасць часу запуску дадатку ад колькасці халодных запускаў прыкладання.
Пры гэтым быў такі ж характар ??залежнасці памеру і росту базы, якая вырасла з 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()
}
}
}
Мы прарабілі гэта з самымі хуткарослымі аб'ектамі і праверылі, што атрымалася.
У выніку тыя аб'екты, якія мы пакрылі гэтым рашэньнем, перасталі расці. А агульны рост базы замарудзіўся, але не спыніўся.
Рашэнне "па-нармальнаму"
База хоць і стала расці павольней, але ўсё роўна расла. Таму мы пачалі шукаць далей. У нашым праекце вельмі актыўна выкарыстоўваецца кэшаванне дадзеных у Realm. Таму пісаць для кожнага аб'екта ўсе ўкладзеныя аб'екты працаёмка, плюс павялічваецца рызыка памылкі, бо можна забыцца паказаць аб'екты пры змене кода.
Жадалася зрабіць так, каб не выкарыстоўваць інтэрфейсы, а каб усё працавала само.
Калі мы хочам, каб нешта працавала само, даводзіцца выкарыстоўваць рэфлексію. Для гэтага мы можам прайсціся па кожным полі класа і праверыць, ці з'яўляецца ён Realm-аб'ектам або спісам аб'ектаў:
Калі поле з'яўляецца RealmModel ці RealmList, то складзем аб'ект гэтага поля ў спіс укладзеных аб'ектаў. Усё гэтак жа, як мы рабілі вышэй, толькі тут яно будзе рабіцца само. Сам метад каскаднага выдалення атрымліваецца вельмі простым і выглядае так:
Экстэншн filterRealmObject адфільтроўвае і прапускае толькі Realm-аб'екты. Метад getNestedRealmObjects праз рэфлексію знаходзіць усе укладзеныя Realm-аб'екты і складае іх у лінейны спіс. Далей рэкурсіўна які робіцца ўсё тое ж самае. Пры выдаленні трэба праверыць аб'ект на валіднасць isValid, таму што можа быць такое, што розныя бацькоўскія аб'екты могуць мець укладзеныя аднолькавыя. Гэтага лепш не дапушчаць і проста выкарыстоўваць автогенерацию id пры стварэнні новых аб'ектаў.
Поўная рэалізацыя метаду 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)
}
У выніку ў нашым кліенцкім кодзе мы выкарыстоўваем "каскаднае выдаленне" пры кожнай аперацыі змены дадзеных. Напрыклад, для аперацыі ўстаўкі гэта выглядае вось так:
Спачатку метад getManagedEntities атрымлівае ўсе аб'екты, якія дадаюцца, а потым метад cascadeDelete рэкурсіўна выдаляе ўсе сабраныя аб'екты перад запісам новых. У выніку мы выкарыстоўваем гэты падыход па ўсім дадатку. Уцечкі памяці ў Realm цалкам зніклі. Правёўшы той жа замер залежнасці часу запуску ад колькасці лядоўняў запускаў прыкладання, мы бачым вынік.
Зялёная лінія паказвае залежнасць часу запуску прыкладання ад колькасці лядоўняў стартаў пры аўтаматычным каскадным выдаленні ўкладзеных аб'ектаў.
Вынікі і высновы
Пастаянна расце база дадзеных Realm моцна запавольвала запуск прыкладання. Мы выпусцілі абнаўленне з уласным "каскадным выдаленнем" укладзеных аб'ектаў. І зараз адсочваем і ацэньваем, як наша рашэнне паўплывала на час запуску прыкладання праз метрыку _app_start.
Для аналізу бярэм прамежак часу 90 дзён і бачым: час запуску прыкладання, як медыяннае, так і тое, што прыпадае на 95 працэнтаў карыстальнікаў, пачало памяншацца і больш не падымаецца.
Калі паглядзець на сямідзённы графік, то метрыка _app_start цалкам выглядае адэкватнай і складае менш за 1 секунду.
Асобна варта дадаць, што па змаўчанні Firebase шле апавяшчэнні, калі медыянае значэнне _app_start перавышае 5 секунд. Аднак, як мы бачым, на гэта не варта спадзявацца, а лепей зайсці і праверыць яго відавочна.
Асаблівасць базы даных Realm заключаецца ў тым, што гэта нерэляцыйная база даных. Нягледзячы на простае выкарыстанне, падабенства працы з ORM-рашэннямі і звязванне аб'ектаў, у яе няма каскаднага выдалення.
Калі гэта не ўлічваць, то ўкладзеныя аб'екты будуць назапашвацца, "выцякаць". База дадзеных будзе расці ўвесь час, што ў сваю чаргу адаб'ецца на запаволенні працы або запуску прыкладання.
Я падзяліўся нашым досведам, як хутка зрабіць каскаднае выдаленне аб'ектаў у Realm, якога пакуль няма са скрынкі, але пра які даўно кажуць и кажуць. У нашым выпадку гэта моцна паскорыла час запуску дадатку.
Нягледзячы на абмеркаванне хуткага з'яўлення гэтай фічы, адсутнасць каскаднага выдалення ў Realm зроблена by design. Калі вы праектуеце новае прыкладанне, то улічвайце гэта. А калі ўжо выкарыстоўваеце Realm – праверце, ці няма ў вас такіх праблем.