Realm 中的级联删除如何赢得长期发布的故事

所有用户都认为移动应用程序中的快速启动和响应式 UI 是理所当然的。 如果应用程序需要很长时间才能启动,用户就会开始感到悲伤和愤怒。 您很容易破坏客户体验,甚至在用户开始使用应用程序之前就完全失去用户。

我们曾经发现,Dodo Pizza 应用程序平均需要 3 秒才能启动,而对于一些“幸运者”来说,需要 15-20 秒。

下面是一个结局美好的故事:关于 Realm 数据库的增长、内存泄漏、我们如何积累嵌套对象,然后齐心协力并修复所有问题。

Realm 中的级联删除如何赢得长期发布的故事

Realm 中的级联删除如何赢得长期发布的故事
作者的文章: 马克西姆·卡钦金 — Dodo Pizza 的 Android 开发人员。

从单击应用程序图标到第一个 Activity 的 onResume() 的三秒时间是无穷大。 而对于部分用户来说,启动时间达到了15-20秒。 这怎么可能?

非常简短的摘要,适合那些没有时间阅读的人
我们的 Realm 数据库不断增长。 有些嵌套对象并没有被删除,而是不断累积。 应用程序启动时间逐渐增加。 然后我们修复了它,启动时间达到了目标——变得不到1秒并且不再增加。 本文包含对情况的分析和两种解决方案 - 快速解决方案和普通解决方案。

问题的查找与分析

如今,任何移动应用程序都必须快速启动并具有响应能力。 但这不仅仅与移动应用程序有关。 与服务和公司交互的用户体验是一件复杂的事情。 例如,在我们的案例中,送货速度是披萨服务的关键指标之一。 如果送货快的话,披萨就会是热的,现在想吃的顾客就不用等太久。 反过来,对于应用程序来说,营造快速服务的感觉很重要,因为如果应用程序只需要 20 秒即可启动,那么您需要等待多长时间才能获得披萨?

起初,我们自己面临着这样一个事实:有时应用程序需要几秒钟才能启动,然后我们开始听到其他同事抱怨这需要多长时间。 但我们无法始终如一地重复这种情况。

多久了? 根据 谷歌文档,如果应用程序的冷启动时间少于 5 秒,则这被视为“正常”。 Dodo Pizza Android 应用程序推出(根据 Firebase 指标 _应用程序_启动) 在 冷启动 平均 3 秒 - 正如他们所说,“不是很好,也不是很糟糕”。

但随后开始有人抱怨该应用程序花了非常非常非常长的时间才启动! 首先,我们决定衡量什么是“非常、非常、非常长”。 我们为此使用了 Firebase 跟踪 应用程序启动跟踪.

Realm 中的级联删除如何赢得长期发布的故事

此标准跟踪测量用户打开应用程序与执行第一个活动的 onResume() 之间的时间。 在 Firebase 控制台中,该指标称为 _app_start。 事实证明:

  • 尽管冷启动时间中位数不到 95 秒,但 20% 以上的用户的启动时间接近 5 秒(有些甚至更长)。
  • 启动时间不是一个恒定值,而是随着时间的推移而增长。 但有时也会有水滴。 当我们将分析规模扩大到 90 天时,我们发现了这种模式。

Realm 中的级联删除如何赢得长期发布的故事

我想到了两个想法:

  1. 有东西正在泄漏。
  2. 这个“东西”在发布后会被重置,然后再次泄漏。

“可能是数据库出了问题,”我们想,我们是对的。 首先,我们使用数据库作为缓存;在迁移过程中我们清除它。 其次,数据库在应用程序启动时加载。 一切都适合在一起。

Realm数据库出了什么问题

我们开始检查数据库内容在应用程序的整个生命周期中(从第一次安装到实际使用期间)如何变化。 您可以通过以下方式查看 Realm 数据库的内容 斯特托 或者通过打开文件来更详细、更清晰地 领域工作室。 要通过 ADB 查看数据库的内容,请复制 Realm 数据库文件:

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

通过查看不同时间的数据库内容,我们发现某种类型的对象数量在不断增加。

Realm 中的级联删除如何赢得长期发布的故事
该图显示了两个文件的 Realm Studio 片段:左侧 - 安装后一段时间的应用程序库,右侧 - 实际使用后。 可以看出,物体的数量 ImageEntity и MoneyType 显着增长(屏幕截图显示了每种类型的对象数量)。

数据库增长与启动时间之间的关系

不受控制的数据库增长非常糟糕。 但这如何影响应用程序的启动时间呢? 通过 ActivityManager 来测量这一点非常容易。 从 Android 4.4 开始,logcat 显示带有 Displayed 字符串和时间的日志。 这个时间等于从应用程序启动到Activity渲染结束的时间间隔。 在此期间,会发生以下事件:

  • 开始该过程。
  • 对象的初始化。
  • 活动的创建和初始化。
  • 创建布局。
  • 应用程序渲染。

适合我们。 如果使用 -S 和 -W 标志运行 ADB,则可以获得启动时间的扩展输出:

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 是一个非关系型数据库。 它允许你以类似于Android上描述多少ORM关系数据库的方式来描述对象之间的关系。 同时,Realm以最少的转换和映射将对象直接存储在内存中。 这使您可以非常快速地从磁盘读取数据,这就是 Realm 的优势,也是它受到喜爱的原因。

(就本文而言,这个描述对我们来说已经足够了。您可以在很酷的页面中阅读有关 Realm 的更多信息 文件资料 或在他们的 学院).

许多开发人员习惯于更多地使用关系数据库(例如,底层带有 SQL 的 ORM 数据库)。 像级联数据删除这样的事情通常看起来是理所当然的。 但不是在领域。

顺便说一句,级联删除功能已经被问了很长时间了。 这 修订 и 另一,与之相关,进行了积极的讨论。 有一种感觉,这件事很快就会完成。 但随后一切都转化为强链接和弱链接的引入,这也会自动解决这个问题。 在这项任务中非常活跃和积极 拉取请求,由于内部困难,目前已暂停。

没有级联删除的情况下数据泄露

如果依赖于不存在的级联删除,数据究竟会如何泄漏? 如果您有嵌套的 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 对象。 如果我们插入一个具有相同id的新对象(copyToRealm() / copyToRealmOrUpdate()),那么这个对象将被完全覆盖。 但所有内部对象(图像、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 中的数据缓存。 因此,为每个对象编写所有嵌套对象是一项劳动密集型工作,而且出错的风险也会增加,因为更改代码时您可能会忘记指定对象。

我想确保我没有使用界面,而是一切都独立运行。

当我们希望某些东西能够独立工作时,我们必须使用反射。 为此,我们可以遍历每个类字段并检查它是 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 中的级联删除如何赢得长期发布的故事

如果您查看 1 天图表,您会发现 _app_start 指标看起来完全足够,并且不到 XNUMX 秒。

还值得补充的是,默认情况下,如果 _app_start 的中值超过 5 秒,Firebase 就会发送通知。 然而,正如我们所看到的,您不应该依赖它,而应该明确地进去检查它。

Realm数据库的特殊之处在于它是一个非关系型数据库。 尽管它易于使用,与 ORM 解决方案和对象链接相似,但它没有级联删除功能。

如果不考虑这一点,则嵌套对象将会累积并“泄漏”。 数据库会不断增长,这反过来会影响应用程序的速度减慢或启动。

我分享了我们关于如何在Realm中快速进行对象级联删除的经验,这还没有开箱即用,但已经讨论了很长时间 他们说 и 他们说。 在我们的例子中,这大大加快了应用程序的启动时间。

尽管讨论了该功能即将出现,但 Realm 中没有级联删除是设计使然。 如果您正在设计新的应用程序,请考虑到这一点。 并且如果您已经在使用 Realm,请检查是否存在此类问题。

来源: habr.com

添加评论