领域级联删除如何赢得长期成功的故事

所有用户都将快速启动和响应式UI视为理所当然的移动应用程序。如果应用程序需要很长时间才能启动,则用户会感到难过和生气。您甚至可以在用户开始使用该应用程序之前轻松破坏其客户体验,甚至迷失用户。



一旦我们发现Dodo Pizza应用程序平均启动3秒钟,对于一些“幸运的”应用程序,则需要15到20秒钟。



切入下的是一个以幸福的结局为主题的故事:关于Realm数据库的增长,内存泄漏,我们如何保存嵌套对象,然后将自己拉到一起并修复所有问题。










本文作者:Maxim Kachinkin是Dodo Pizza的一名Android开发人员。






从单击应用程序图标到第一个活动的onResume()三秒钟是无限。对于某些用户,启动时间达到了15-20秒。这怎么可能?



对于那些没有时间阅读的人来说,这是一个非常简短的摘要
Realm. , . . , — 1 . — - -.



搜索和分析问题



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



最初,我们自己面临这样一个事实,有时启动该应用程序会持续几秒钟,然后其他同事的投诉开始向我们表明该应用程序“很长”。但是我们未能稳定地重复这种情况。



多久了?根据Google文档,如果应用程序的冷启动时间少于5秒,则被视为“正常”。他们说,Dodo Pizza Android应用程序是在平均3秒钟冷启动时间(根据Firebase _app_start指标启动的-“不好,不可怕”。



但是随后开始出现抱怨,称该应用程序启动时间非常非常长!首先,我们决定测量“非常非常长”。我们为此使用了Firebase跟踪应用程序启动跟踪







此标准跟踪测量从用户打开应用程序到执行首次激活的onResume()的时间之间的时间。在Firebase控制台中,此指标称为_app_start。结果表明:



  • 尽管中间启动时间少于5秒,但高于第95个百分点的用户的启动时间却接近20秒(有些人更长)。
  • 启动时间不是恒定的,而是随着时间增长的。但是有时会观察到跌倒。当我们将分析范围扩大到90天时,我们发现了这种模式。






我想到了两个想法:



  1. 东西泄漏了。
  2. 该“东西”在释放后将被丢弃,然后再次泄漏出去。


我们认为,“数据库中可能存在某些内容”,这是正确的。首先,我们将数据库用作缓存,我们在迁移过程中将其清除。其次,在应用程序启动时加载数据库。都适合。



Realm数据库出了什么问题



我们开始检查数据库的内容在应用程序的整个生命周期中如何变化,从首次安装到活动使用过程中一直如此。您可以通过Stetho或通过Realm Studio打开文件来更直观地查看Realm数据库的内容要通过ADB查看数据库的内容,请复制Realm数据库文件:



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


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





该图显示了Realm Studio的两个文件片段:左侧-安装一段时间后的应用程序数据库,右侧-活动使用后的数据库。可以看出,对象的数量ImageEntityMoneyType已显著生长(在截图显示每种类型的对象的数目)。



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



无法控制的数据库增长非常糟糕。但是,这如何影响应用程序的启动时间?通过ActivityManager进行测量非常容易。从Android 4.4开始,logcat将显示带有显示的字符串和时间的日志。此时间等于从应用程序启动到活动呈现结束的时间间隔。在此期间,发生事件:



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


适合我们。如果使用-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时间,则可以自动收集该指标并以图形方式查看结果。下图显示了应用程序启动时间对应用程序冷启动次数的依赖性。







同时,对基础大小和增长的依赖性是相同的,从4 MB增加到15 MB。总的来说,事实证明,随着时间的流逝(随着冷启动的增长),应用程序启动时间和数据库大小都会增长。我们手中有一个假设。现在仍然需要确认依赖性。因此,我们决定删除“泄漏”,看看它是否会加快启动速度。



数据库无限增长的原因



在删除“泄漏”之前,值得理解它们为什么会出现。为此,请记住什么是Realm。



领域是一个非关系数据库。它允许您以Android上许多ORM关系数据库描述的相似方式描述对象之间的关系。同时,Realm以最少数量的转换和映射将对象直接保存在内存中。这使您可以非常快速地从磁盘读取数据,这是Realm的强项并且受到人们的喜爱。



(出于本文的目的,此描述对我们来说足够了。您可以在很酷的文档或他们的学院中阅读有关Realm的更多信息)。



许多开发人员习惯于更多地使用关系数据库(例如,带有SQL的ORM数据库)。级联数据删除之类的事情通常看起来是理所当然的。但不是在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对象。如果我们插入具有相同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()
}
//    


如果执行此操作,那么一切都会正常进行。在此示例中,我们假定图像,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::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自动生成。





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数据库极大地减慢了应用程序的启动速度。我们发布了带有嵌套对象“级联删除”的更新。现在,我们通过_app_start指标跟踪并评估我们的决定如何影响应用程序启动时间。







为了进行分析,我们采用了90天的时间间隔,并看到:应用程序启动时间(中位数和落在用户第95个百分位数上的时间)开始减少且不再增加。







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



我们还应该添加默认情况下,如果_app_start中值超过5秒,Firebase会发送通知。但是,正如我们所看到的,您不应该依赖于此,而应该明确地检查它。



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



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



我分享我们的经验,如何迅速做的境界,这是没有现成的级联删除的对象,但它早已谈话谈话。在我们的案例中,这大大加快了应用程序的启动时间。



尽管已经讨论了该功能的即将出现,但是Realm中缺少级联删除是通过设计完成的。如果要设计新的应用程序,请考虑这一点。如果您已经在使用Realm,请检查是否存在任何此类问题。



All Articles