Realm 中的級聯刪除如何贏得長期發布的故事

所有用戶都認為行動應用程式中的快速啟動和響應式 UI 是理所當然的。 如果應用程式需要很長時間才能啟動,使用者就會開始感到悲傷和憤怒。 您很容易破壞客戶體驗,甚至在用戶開始使用應用程式之前就完全失去用戶。

我們曾經發現,Dodo Pizza 應用程式平均需要 3 秒才能啟動,而對於某些「幸運者」來說,需要 15-20 秒。

以下是一個結局美好的故事:關於 Realm 資料庫的成長、記憶體洩漏、我們如何累積嵌套對象,然後齊心協力並修復所有問題。

Realm 中的級聯刪除如何贏得長期發布的故事

Realm 中的級聯刪除如何贏得長期發布的故事
文章作者: 馬克西姆·卡欽金 — Dodo Pizza 的 Android 開發人員。

從點擊應用程式圖示到第一個 Activity 的 onResume() 的三秒時間是無限大。 而對於部分用戶來說,啟動時間達到了15-20秒。 這怎麼可能?

非常簡短的摘要,適合沒有時間閱讀的人
我們的 Realm 資料庫不斷成長。 有些嵌套物件並沒有被刪除,而是不斷累積。 應用程式啟動時間逐漸增加。 然後我們修復了它,啟動時間達到了目標——變得不到1秒鐘並且不再增加。 本文包含對情況的分析和兩種解決方案 - 快速解決方案和普通解決方案。

問題的查找與分析

如今,任何行動應用程式都必須快速啟動並具有響應能力。 但這不僅僅與行動應用程式有關。 與服務和公司互動的使用者體驗是一件複雜的事情。 例如,在我們的案例中,送貨速度是披薩服務的關鍵指標之一。 如果送貨快的話,披薩就會是熱的,現在想吃的顧客就不用等太久。 反過來,對於應用程式來說,創造快速服務的感覺也很重要,因為如果應用程式只需要 20 秒即可啟動,那麼你需要等待多長時間才能拿到披薩?

起初,我們自己面臨著這樣一個事實:有時應用程式需要幾秒鐘才能啟動,然後我們開始聽到其他同事抱怨這需要多長時間。 但我們無法始終如一地重複這種情況。

多久了? 根據 Google文檔,如果應用程式的冷啟動時間少於 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,請檢查是否有此類問題。

來源: www.habr.com

添加評論