Kisah tentang bagaimana penghapusan berjenjang di Realm memenangkan peluncuran yang panjang

Semua pengguna menganggap remeh peluncuran cepat dan UI responsif dalam aplikasi seluler. Jika aplikasi membutuhkan waktu lama untuk diluncurkan, pengguna mulai merasa sedih dan marah. Anda dapat dengan mudah merusak pengalaman pelanggan atau kehilangan pengguna sepenuhnya bahkan sebelum dia mulai menggunakan aplikasi.

Kami pernah mengetahui bahwa aplikasi Dodo Pizza rata-rata membutuhkan waktu 3 detik untuk diluncurkan, dan untuk beberapa “yang beruntung” dibutuhkan waktu 15-20 detik.

Di bawah potongan adalah cerita dengan akhir yang bahagia: tentang pertumbuhan database Realm, kebocoran memori, bagaimana kami mengumpulkan objek yang bersarang, dan kemudian menyatukan diri dan memperbaiki semuanya.

Kisah tentang bagaimana penghapusan berjenjang di Realm memenangkan peluncuran yang panjang

Kisah tentang bagaimana penghapusan berjenjang di Realm memenangkan peluncuran yang panjang
Penulis artikel: Maxim Kachinkin — Pengembang Android di Dodo Pizza.

Tiga detik dari mengklik ikon aplikasi hingga onResume() aktivitas pertama adalah tak terhingga. Dan untuk beberapa pengguna, waktu startup mencapai 15-20 detik. Bagaimana ini mungkin?

Ringkasan yang sangat singkat bagi mereka yang tidak punya waktu untuk membaca
Basis data Realm kami berkembang tanpa henti. Beberapa objek yang disarangkan tidak dihapus, tetapi terus diakumulasikan. Waktu startup aplikasi meningkat secara bertahap. Kemudian kami memperbaikinya, dan waktu startup mencapai target - menjadi kurang dari 1 detik dan tidak bertambah lagi. Artikel ini berisi analisis situasi dan dua solusi - solusi cepat dan normal.

Pencarian dan analisis masalah

Saat ini, aplikasi seluler apa pun harus diluncurkan dengan cepat dan responsif. Tapi ini bukan hanya tentang aplikasi seluler. Pengalaman pengguna dalam berinteraksi dengan suatu layanan dan perusahaan merupakan suatu hal yang kompleks. Misalnya, dalam kasus kami, kecepatan pengiriman adalah salah satu indikator utama layanan pizza. Jika pengirimannya cepat, pizzanya akan panas, dan pelanggan yang ingin makan sekarang tidak perlu menunggu lama. Untuk aplikasi, pada gilirannya, penting untuk menciptakan rasa layanan yang cepat, karena jika aplikasi hanya membutuhkan waktu 20 detik untuk diluncurkan, berapa lama Anda harus menunggu pizzanya?

Pada awalnya, kami sendiri dihadapkan pada kenyataan bahwa terkadang aplikasi memerlukan waktu beberapa detik untuk diluncurkan, kemudian kami mulai mendengar keluhan dari rekan lain tentang berapa lama waktu yang dibutuhkan. Namun kami tidak dapat mengulangi situasi ini secara konsisten.

Berapa lamakah? Berdasarkan dokumentasi Google, jika permulaan aplikasi yang dingin membutuhkan waktu kurang dari 5 detik, maka ini dianggap "seolah-olah normal". Aplikasi Android Dodo Pizza diluncurkan (menurut metrik Firebase _aplikasi_mulai) pada awal yang dingin rata-rata dalam 3 detik - “Tidak bagus, tidak buruk,” seperti kata mereka.

Namun kemudian mulai muncul keluhan bahwa aplikasi tersebut membutuhkan waktu yang sangat, sangat, sangat lama untuk diluncurkan! Untuk memulainya, kami memutuskan untuk mengukur apa yang dimaksud dengan “sangat, sangat, sangat panjang”. Dan kami menggunakan jejak Firebase untuk ini Jejak awal aplikasi.

Kisah tentang bagaimana penghapusan berjenjang di Realm memenangkan peluncuran yang panjang

Pelacakan standar ini mengukur waktu antara saat pengguna membuka aplikasi dan saat onResume() aktivitas pertama dijalankan. Di Firebase Console, metrik ini disebut _app_start. Ternyata:

  • Waktu pengaktifan untuk pengguna di atas persentil ke-95 hampir 20 detik (bahkan ada yang lebih lama), meskipun median waktu pengaktifan dingin kurang dari 5 detik.
  • Waktu startup bukanlah nilai yang konstan, tetapi bertambah seiring berjalannya waktu. Namun terkadang ada yang turun. Kami menemukan pola ini ketika kami meningkatkan skala analisis menjadi 90 hari.

Kisah tentang bagaimana penghapusan berjenjang di Realm memenangkan peluncuran yang panjang

Dua pemikiran muncul di benak:

  1. Ada yang bocor.
  2. “Sesuatu” ini disetel ulang setelah dirilis dan kemudian bocor lagi.

“Mungkin ada sesuatu dengan databasenya,” pikir kami, dan kami benar. Pertama, kami menggunakan database sebagai cache; selama migrasi kami menghapusnya. Kedua, database dimuat saat aplikasi dimulai. Semuanya cocok satu sama lain.

Apa yang salah dengan database Realm

Kami mulai memeriksa bagaimana isi database berubah selama masa pakai aplikasi, dari instalasi pertama dan selanjutnya selama penggunaan aktif. Anda dapat melihat isi database Realm melalui stetho atau lebih detail dan jelas dengan membuka filenya melalui Studio Alam. Untuk melihat isi database melalui ADB, salin file database Realm:

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

Setelah melihat isi database pada waktu yang berbeda, kami menemukan bahwa jumlah objek dengan tipe tertentu terus meningkat.

Kisah tentang bagaimana penghapusan berjenjang di Realm memenangkan peluncuran yang panjang
Gambar menunjukkan fragmen Realm Studio untuk dua file: di sebelah kiri - basis aplikasi beberapa saat setelah instalasi, di sebelah kanan - setelah penggunaan aktif. Terlihat jumlah bendanya ImageEntity и MoneyType telah berkembang secara signifikan (tangkapan layar menunjukkan jumlah objek dari setiap jenis).

Hubungan antara pertumbuhan database dan waktu startup

Pertumbuhan database yang tidak terkendali sangatlah buruk. Namun bagaimana pengaruhnya terhadap waktu startup aplikasi? Cukup mudah untuk mengukurnya melalui ActivityManager. Sejak Android 4.4, logcat menampilkan log dengan string Ditampilkan dan waktu. Waktu ini sama dengan interval dari saat aplikasi diluncurkan hingga akhir rendering aktivitas. Pada masa ini terjadi peristiwa-peristiwa berikut:

  • Mulai prosesnya.
  • Inisialisasi objek.
  • Pembuatan dan inisialisasi aktivitas.
  • Membuat tata letak.
  • Rendering aplikasi.

Cocok untuk kita. Jika Anda menjalankan ADB dengan flag -S dan -W, Anda bisa mendapatkan output yang diperpanjang dengan waktu startup:

adb shell am start -S -W ru.dodopizza.app/.MainActivity -c android.intent.category.LAUNCHER -a android.intent.action.MAIN

Jika Anda mengambilnya dari sana grep -i WaitTime waktu, Anda dapat mengotomatiskan pengumpulan metrik ini dan melihat hasilnya secara visual. Grafik di bawah ini menunjukkan ketergantungan waktu startup aplikasi pada jumlah cold start aplikasi.

Kisah tentang bagaimana penghapusan berjenjang di Realm memenangkan peluncuran yang panjang

Pada saat yang sama, terdapat sifat hubungan yang sama antara ukuran dan pertumbuhan database, yang tumbuh dari 4 MB menjadi 15 MB. Secara total, ternyata seiring waktu (dengan meningkatnya cold start), waktu peluncuran aplikasi dan ukuran database meningkat. Kami memiliki hipotesis di tangan kami. Sekarang yang tersisa hanyalah memastikan ketergantungannya. Oleh karena itu, kami memutuskan untuk menghilangkan “kebocoran” tersebut dan melihat apakah ini akan mempercepat peluncuran.

Alasan pertumbuhan database tanpa akhir

Sebelum menghilangkan “kebocoran”, ada baiknya memahami mengapa kebocoran itu muncul. Untuk melakukan ini, mari kita ingat apa itu Realm.

Realm adalah database non-relasional. Ini memungkinkan Anda mendeskripsikan hubungan antar objek dengan cara yang mirip dengan deskripsi banyak database relasional ORM di Android. Pada saat yang sama, Realm menyimpan objek secara langsung di memori dengan jumlah transformasi dan pemetaan paling sedikit. Hal ini memungkinkan Anda membaca data dari disk dengan sangat cepat, yang merupakan kekuatan Realm dan mengapa ia disukai.

(Untuk keperluan artikel ini, uraian ini sudah cukup bagi kami. Anda dapat membaca lebih lanjut tentang Realm in the cool dokumentasi atau di mereka akademi).

Banyak pengembang yang terbiasa bekerja lebih banyak dengan database relasional (misalnya, database ORM dengan SQL). Dan hal-hal seperti penghapusan data berjenjang sering kali tampak biasa saja. Tapi tidak di Alam.

Omong-omong, fitur penghapusan berjenjang sudah lama diminta. Ini revisi и lain, terkait dengannya, dibahas secara aktif. Ada perasaan bahwa hal itu akan segera terlaksana. Namun kemudian semuanya diterjemahkan ke dalam pengenalan hubungan kuat dan lemah, yang secara otomatis juga akan menyelesaikan masalah ini. Cukup lincah dan aktif dalam tugas ini permintaan tarik, yang telah dihentikan sementara karena masalah internal.

Kebocoran data tanpa penghapusan berjenjang

Bagaimana sebenarnya kebocoran data jika Anda mengandalkan penghapusan berjenjang yang tidak ada? Jika Anda memiliki objek Realm bertumpuk, maka objek tersebut harus dihapus.
Mari kita lihat contoh yang (hampir) nyata. Kami memiliki sebuah objek 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()

Produk di keranjang memiliki bidang yang berbeda-beda, termasuk gambar ImageEntity, bahan-bahan yang disesuaikan CustomizationEntity. Selain itu, produk dalam keranjang dapat berupa kombinasi dengan rangkaian produknya sendiri RealmList (CartProductEntity). Semua bidang yang terdaftar adalah objek Realm. Jika kita memasukkan objek baru (copyToRealm() / copyToRealmOrUpdate()) dengan id yang sama, maka objek ini akan tertimpa seluruhnya. Namun semua objek internal (image,customizationEntity, dan cartComboProducts) akan kehilangan koneksi dengan induknya dan tetap berada di database.

Karena koneksi dengan mereka terputus, kami tidak lagi membacanya atau menghapusnya (kecuali kami secara eksplisit mengaksesnya atau menghapus seluruh “tabel”). Kami menyebutnya “kebocoran memori”.

Saat kita bekerja dengan Realm, kita harus secara eksplisit memeriksa semua elemen dan secara eksplisit menghapus semuanya sebelum operasi tersebut. Ini bisa dilakukan, misalnya seperti ini:

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()
}
// и потом уже сохраняем

Jika Anda melakukan ini, maka semuanya akan berjalan sebagaimana mestinya. Dalam contoh ini, kami berasumsi bahwa tidak ada objek Realm bersarang lainnya di dalam image,customizationEntity, dan cartComboProducts, sehingga tidak ada loop dan penghapusan bersarang lainnya.

Solusi "cepat".

Hal pertama yang kami putuskan untuk lakukan adalah membersihkan objek yang tumbuh paling cepat dan memeriksa hasilnya untuk melihat apakah ini akan menyelesaikan masalah awal kami. Pertama, solusi paling sederhana dan intuitif dibuat, yaitu: setiap objek harus bertanggung jawab untuk mengeluarkan anaknya. Untuk melakukan ini, kami memperkenalkan antarmuka yang mengembalikan daftar objek Realm yang disarangkan:

interface NestedEntityAware {
 fun getNestedEntities(): Collection<RealmObject?>
}

Dan kami menerapkannya di objek Realm kami:

@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 kami mengembalikan semua anak sebagai daftar datar. Dan setiap objek anak juga dapat mengimplementasikan antarmuka NestedEntityAware, yang menunjukkan bahwa objek tersebut memiliki objek Realm internal untuk dihapus, misalnya 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
   )
 }
}

Begitu seterusnya, penumpukan objek bisa diulangi.

Kemudian kita menulis sebuah metode yang secara rekursif menghapus semua objek yang disarangkan. Metode (dibuat sebagai ekstensi) deleteAllNestedEntities mendapatkan semua objek dan metode tingkat atas deleteNestedRecursively Menghapus semua objek bersarang secara rekursif menggunakan antarmuka 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()
   }
 }
}

Kami melakukan ini dengan objek yang paling cepat berkembang dan memeriksa apa yang terjadi.

Kisah tentang bagaimana penghapusan berjenjang di Realm memenangkan peluncuran yang panjang

Akibatnya, objek yang kami tutupi dengan solusi ini berhenti tumbuh. Dan pertumbuhan basis secara keseluruhan melambat, namun tidak berhenti.

Solusi "normal".

Meskipun basisnya mulai tumbuh lebih lambat, namun tetap saja tumbuh. Jadi kami mulai mencari lebih jauh. Proyek kami sangat aktif menggunakan cache data di Realm. Oleh karena itu, menulis semua objek yang disarangkan untuk setiap objek membutuhkan banyak tenaga, ditambah lagi risiko kesalahan meningkat, karena Anda bisa lupa menentukan objek saat mengubah kode.

Saya ingin memastikan bahwa saya tidak menggunakan antarmuka, tetapi semuanya berfungsi dengan sendirinya.

Ketika kita ingin sesuatu bekerja dengan sendirinya, kita harus menggunakan refleksi. Untuk melakukan ini, kita dapat menelusuri setiap bidang kelas dan memeriksa apakah itu objek Realm atau daftar objek:

RealmModel::class.java.isAssignableFrom(field.type)

RealmList::class.java.isAssignableFrom(field.type)

Jika bidangnya adalah RealmModel atau RealmList, tambahkan objek bidang ini ke daftar objek bertingkat. Semuanya sama persis seperti yang kami lakukan di atas, hanya saja di sini akan dilakukan dengan sendirinya. Metode penghapusan kaskade sendiri sangat sederhana dan tampilannya seperti ini:

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()
         }
       }
 }
}

Perpanjangan filterRealmObject memfilter dan hanya meneruskan objek Realm. metode getNestedRealmObjects melalui refleksi, ia menemukan semua objek Realm yang disarangkan dan menempatkannya ke dalam daftar linier. Kemudian kita melakukan hal yang sama secara rekursif. Saat menghapus, Anda perlu memeriksa validitas objek isValid, karena mungkin saja objek induk yang berbeda dapat mempunyai objek induk yang sama. Lebih baik menghindari ini dan cukup gunakan pembuatan id otomatis saat membuat objek baru.

Kisah tentang bagaimana penghapusan berjenjang di Realm memenangkan peluncuran yang panjang

Implementasi penuh dari metode 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)
}

Akibatnya, dalam kode klien kami, kami menggunakan "penghapusan berjenjang" untuk setiap operasi modifikasi data. Misalnya, untuk operasi penyisipan, tampilannya seperti ini:

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 }
 ))
}

Metode pertama getManagedEntities menerima semua objek yang ditambahkan, dan kemudian metodenya cascadeDelete Menghapus semua objek yang dikumpulkan secara rekursif sebelum menulis objek baru. Kami akhirnya menggunakan pendekatan ini di seluruh aplikasi. Kebocoran memori di Realm benar-benar hilang. Setelah melakukan pengukuran yang sama tentang ketergantungan waktu startup pada jumlah cold start aplikasi, kita melihat hasilnya.

Kisah tentang bagaimana penghapusan berjenjang di Realm memenangkan peluncuran yang panjang

Garis hijau menunjukkan ketergantungan waktu startup aplikasi pada jumlah cold start selama penghapusan kaskade otomatis objek bersarang.

Hasil dan kesimpulan

Basis data Realm yang terus berkembang menyebabkan aplikasi diluncurkan dengan sangat lambat. Kami merilis pembaruan dengan "penghapusan berjenjang" objek bersarang kami sendiri. Dan sekarang kami memantau dan mengevaluasi bagaimana keputusan kami memengaruhi waktu startup aplikasi melalui metrik _app_start.

Kisah tentang bagaimana penghapusan berjenjang di Realm memenangkan peluncuran yang panjang

Untuk analisis, kami mengambil jangka waktu 90 hari dan melihat: waktu peluncuran aplikasi, baik median maupun yang berada pada persentil ke-95 pengguna, mulai berkurang dan tidak lagi meningkat.

Kisah tentang bagaimana penghapusan berjenjang di Realm memenangkan peluncuran yang panjang

Jika Anda melihat grafik tujuh hari, metrik _app_start terlihat cukup memadai dan berdurasi kurang dari 1 detik.

Perlu juga ditambahkan bahwa secara default, Firebase mengirimkan notifikasi jika nilai median _app_start melebihi 5 detik. Namun, seperti yang bisa kita lihat, Anda tidak boleh mengandalkan ini, melainkan masuk dan memeriksanya secara eksplisit.

Hal khusus tentang database Realm adalah database non-relasional. Meskipun mudah digunakan, mirip dengan solusi ORM dan penautan objek, ia tidak memiliki penghapusan berjenjang.

Jika hal ini tidak diperhitungkan, maka objek yang bersarang akan menumpuk dan “bocor”. Basis data akan terus berkembang, yang pada gilirannya akan mempengaruhi lambatnya atau dimulainya aplikasi.

Saya berbagi pengalaman tentang cara cepat melakukan penghapusan kaskade objek di Realm, yang belum keluar dari kotak, tetapi sudah dibicarakan sejak lama. kata orang и kata orang. Dalam kasus kami, ini sangat mempercepat waktu startup aplikasi.

Meskipun ada diskusi tentang kemunculan fitur ini dalam waktu dekat, tidak adanya penghapusan berjenjang di Realm memang disengaja. Jika Anda merancang aplikasi baru, pertimbangkan hal ini. Dan jika Anda sudah menggunakan Realm, periksa apakah Anda mengalami masalah seperti itu.

Sumber: www.habr.com

Tambah komentar