Setelah kami menemukan bahwa aplikasi Dodo Pizza dimulai dalam rata-rata 3 detik, 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 menyimpan objek bersarang, dan kemudian menyatukan diri dan memperbaiki semuanya.
![]()
Penulis artikel: Maxim Kachinkin adalah pengembang Android di Dodo Pizza.
Tiga detik dari klik pada ikon aplikasi ke onResume () aktivitas pertama adalah tak terhingga. Dan untuk sebagian pengguna, waktu peluncurannya mencapai 15-20 detik. Bagaimana ini mungkin?
Ringkasan yang sangat singkat untuk mereka yang tidak punya waktu untuk membaca
Realm. , . . , β 1 . β - -.
Pencarian dan analisis masalah
Saat ini, aplikasi seluler apa pun harus diluncurkan dengan cepat dan responsif. Tapi ini bukan hanya aplikasi seluler. Pengalaman pengguna dalam berinteraksi dengan layanan dan perusahaan adalah hal yang kompleks. Misalnya, dalam kasus kami, kecepatan pengiriman adalah salah satu indikator utama untuk layanan pizza. Jika pengiriman cepat, maka pizzanya akan panas dan pelanggan yang ingin makan sekarang tidak perlu menunggu lama. Untuk aplikasinya, pada gilirannya, penting untuk menciptakan kesan layanan yang cepat, karena jika aplikasi hanya dimulai 20 detik, lalu berapa lama waktu yang dibutuhkan untuk membuat pizza?
Pada awalnya, kami sendiri dihadapkan pada kenyataan bahwa terkadang aplikasi diluncurkan selama beberapa detik, dan kemudian keluhan dari kolega lain mulai sampai kepada kami bahwa itu βpanjangβ. Tetapi kami tidak berhasil mengulangi situasi ini secara stabil.
Berapa lamakah? BerdasarkanDokumentasi Google , jika start dingin aplikasi membutuhkan waktu kurang dari 5 detik, maka itu dianggap "lumayan normal". Aplikasi Android Dodo Pizza diluncurkan (menurut metrik _app_start Firebase ) dengan awal yang dingin dalam rata-rata 3 detik - "Tidak hebat, tidak buruk", seperti yang mereka katakan.
Tetapi kemudian mulai muncul keluhan bahwa aplikasi diluncurkan untuk waktu yang sangat, sangat, sangat lama! Untuk memulainya, kami memutuskan untuk mengukur apa yang "sangat, sangat, sangat panjang". Dan kami menggunakan jejak Firebase jejak App start untuk ini .
Pelacakan standar ini mengukur waktu antara saat pengguna membuka aplikasi dan saat onResume () aktivasi pertama dijalankan. Di Firebase Console, metrik ini disebut _app_start. Ternyata:
- Pengguna di atas persentil ke-95 memiliki waktu mulai hampir 20 detik (beberapa memiliki lebih banyak), meskipun median waktu mulai dingin kurang dari 5 detik.
- Waktu mulai tidak konstan, tetapi bertambah seiring waktu. Tapi terkadang jatuh diamati. Kami menemukan pola ini ketika kami meningkatkan skala analisis menjadi 90 hari.
Dua pikiran muncul di benak:
- Ada yang bocor.
- "Sesuatu" ini dibuang setelah dilepaskan dan kemudian bocor lagi.
βMungkin sesuatu dengan database,β kami berpikir, dan kami benar. Pertama, kami menggunakan database sebagai cache, kami menghapusnya selama migrasi. Kedua, database dimuat saat aplikasi dimulai. Semuanya cocok satu sama lain.
Apa yang salah dengan database Realm
Kami mulai memeriksa bagaimana konten database berubah selama masa pakai aplikasi, dari penginstalan pertama dan selanjutnya dalam proses penggunaan aktif. Anda dapat melihat konten database Realm melalui Stetho atau lebih detail dan visual dengan membuka file melalui Realm Studio . Untuk melihat konten database melalui ADB, salin file database Realm:
adb exec-out run-as ${PACKAGE_NAME} cat files/${DB_NAME}
Setelah melihat konten database pada waktu yang berbeda, kami menemukan bahwa jumlah objek dari tipe tertentu terus meningkat.

Gambar menunjukkan fragmen Realm Studio untuk dua file: di sebelah kiri - basis aplikasi setelah beberapa saat setelah instalasi, di sebelah kanan - setelah penggunaan aktif. Dapat dilihat bahwa jumlah objek
ImageEntitydan MoneyTypetelah berkembang secara signifikan (screenshot menunjukkan jumlah objek dari masing-masing jenis).
Hubungan Pertumbuhan Database dengan Waktu Startup
Pertumbuhan database yang tidak terkontrol sangat buruk. Tetapi bagaimana hal ini memengaruhi waktu peluncuran aplikasi? Cukup mudah untuk mengukurnya melalui ActivityManager. Mulai dari Android 4.4, logcat menampilkan log dengan string dan waktu yang ditampilkan. Waktu ini sama dengan interval dari saat aplikasi diluncurkan hingga akhir rendering aktivitas. Selama waktu ini, peristiwa terjadi:
- Memulai prosesnya.
- Inisialisasi objek.
- Penciptaan dan inisialisasi aktivitas.
- Pembuatan tata letak.
- Rendering aplikasi.
Cocok untuk kita. Jika Anda menjalankan ADB dengan flag -S dan -W, Anda bisa mendapatkan output yang diperpanjang dengan waktu mulai:
adb shell am start -S -W ru.dodopizza.app/.MainActivity -c android.intent.category.LAUNCHER -a android.intent.action.MAIN
Jika Anda mengumpulkan
grep -i WaitTimewaktu dari sana , Anda dapat mengotomatiskan pengumpulan metrik ini dan melihat hasilnya secara grafis. Grafik di bawah ini menunjukkan ketergantungan waktu peluncuran aplikasi pada jumlah cold start aplikasi.
Pada saat yang sama, ketergantungan ukuran dan pertumbuhan basis juga sama, yaitu tumbuh dari 4 MB menjadi 15 MB. Secara total, ternyata dari waktu ke waktu (dengan pertumbuhan cold start), waktu peluncuran aplikasi dan ukuran database bertambah. Kami memiliki hipotesis di tangan kami. Sekarang tinggal mengkonfirmasi ketergantungan. Oleh karena itu, kami memutuskan untuk menghapus "kebocoran" dan melihat apakah itu akan mempercepat peluncuran.
Alasan Pertumbuhan Tak Terbatas Database
Sebelum menghilangkan "kebocoran", perlu dipahami mengapa mereka muncul. Untuk melakukan ini, mari kita ingat apa itu Realm.
Realm adalah database non-relasional. Ini memungkinkan Anda untuk mendeskripsikan hubungan antar objek dengan cara yang mirip seperti yang dijelaskan oleh banyak database relasional ORM di Android. Pada saat yang sama, Realm secara langsung menyimpan objek dalam memori dengan jumlah transformasi dan pemetaan paling sedikit. Hal ini memungkinkan Anda untuk membaca data dari disk dengan sangat cepat, yang merupakan kekuatan Realm dan disukai.
(Untuk keperluan artikel ini, penjelasan ini sudah cukup bagi kami. Anda dapat membaca lebih lanjut tentang Realm di dokumentasi keren atau di akademi mereka ).
Banyak pengembang terbiasa bekerja lebih banyak dengan database relasional (misalnya, database ORM dengan SQL di bawah tenda). Dan hal-hal seperti penghapusan data bertingkat sering kali tampak seperti masalah biasa. Tapi tidak di Realm.
Ngomong-ngomong, fitur penghapusan kaskade telah diminta untuk dilakukan sejak lama. Ini revisi dan lain yang berhubungan dengan itu secara aktif dibahas. Ada perasaan bahwa itu akan segera dilakukan. Tapi kemudian semuanya berubah menjadi pengenalan tautan kuat dan lemah, yang juga akan secara otomatis menyelesaikan masalah ini. Untuk tugas ini, ada permintaan tarik yang agak aktif dan aktif , yang dijeda untuk saat ini karena masalah internal.
Kebocoran data tanpa penghapusan berjenjang
Bagaimana tepatnya kebocoran data jika Anda mengharapkan penghapusan berjenjang yang tidak ada? Jika Anda memiliki objek Realm bersarang, objek tersebut harus dihapus.
Mari kita lihat contoh (hampir) dunia 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 gerobak memiliki bidang yang berbeda, termasuk gambar
ImageEntity, bahan yang disesuaikan CustomizationEntity. Selain itu, produk di dalam keranjang bisa menjadi kombinasi dengan 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 sepenuhnya ditimpa. Tetapi 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 merujuknya 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 semacam itu. 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 berfungsi sebagaimana mestinya. Dalam contoh ini, kami berasumsi bahwa tidak ada Realme bertingkat lain di dalam image, customizationEntity, dan cartComboProducts, jadi tidak ada loop dan penghapusan bersarang lainnya.
Solusi cepat
Pertama-tama, kami memutuskan untuk membersihkan objek yang tumbuh paling cepat dan memeriksa hasilnya - apakah ini akan menyelesaikan masalah asli kami. Pertama, solusi yang paling sederhana dan intuitif dibuat, yaitu: setiap objek harus bertanggung jawab untuk mengeluarkan anaknya setelah dirinya sendiri. Untuk melakukan ini, kami memperkenalkan antarmuka berikut, yang mengembalikan daftar objek Realm bersarangnya:
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
)
}
}
Saat
getNestedEntitieskami mengembalikan semua anak daftar datar. Dan setiap objek anak juga dapat mengimplementasikan antarmuka NestedEntityAware, menginformasikan bahwa objek Realm internal harus 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
)
}
}
Dan seterusnya, penyarangan objek bisa diulangi.
Kemudian kami menulis metode yang secara rekursif menghapus semua objek bersarang. Metode (dibuat dalam bentuk ekstensi)
deleteAllNestedEntitiesmendapatkan semua objek level teratas dan deleteNestedRecursivelysecara rekursif menghapus semua objek bersarang 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 tumbuh paling cepat dan memeriksa apa yang terjadi.
Akibatnya, objek yang kami tutupi dengan solusi ini berhenti berkembang. Dan pertumbuhan keseluruhan pangkalan itu melambat, tetapi tidak berhenti.
Solusi "normal"
Basisnya, meskipun mulai tumbuh lebih lambat, masih terus tumbuh. Jadi kami mulai mencari lebih jauh. Dalam proyek kami, data caching di Realm sangat aktif digunakan. Oleh karena itu, menulis semua objek bersarang untuk setiap objek melelahkan, ditambah risiko kesalahan meningkat, karena Anda bisa lupa menentukan objek saat mengubah kode.
Saya ingin memastikan untuk tidak menggunakan antarmuka, tetapi membuat semuanya bekerja 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 di sini akan dilakukan dengan sendirinya. Metode penghapusan berjenjang itu sendiri sangat sederhana dan terlihat 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()
}
}
}
}
Ekstensi
filterRealmObjectmemfilter dan hanya meneruskan objek Realm. Metode ini getNestedRealmObjectsmenemukan semua objek Realm bersarang melalui refleksi dan menambahkannya ke dalam daftar linier. Kemudian kami melakukan hal yang sama secara rekursif. Saat menghapus, Anda perlu memeriksa validitas objek isValid, karena mungkin objek induk yang berbeda mungkin memiliki objek bertingkat yang sama. Lebih baik menghindari ini dan cukup gunakan pembuatan otomatis id saat membuat objek baru.

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 perubahan data. Misalnya, untuk operasi penyisipan, akan terlihat 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 }
))
}
Pertama, metode
getManagedEntitiesmendapatkan semua objek yang ditambahkan, lalu metode cascadeDeletesecara rekursif menghapus semua objek yang dikumpulkan sebelum menulis yang baru. Kami akhirnya menggunakan pendekatan ini di seluruh aplikasi. Kebocoran memori di Realm benar-benar hilang. Setelah melakukan pengukuran yang sama dari ketergantungan waktu peluncuran pada jumlah mulai dingin aplikasi, kami melihat hasilnya.
Garis hijau menunjukkan ketergantungan waktu peluncuran aplikasi pada jumlah cold start selama penghapusan kaskade otomatis pada objek bertingkat.
Hasil dan kesimpulan
Basis data Realm yang terus berkembang sangat memperlambat peluncuran aplikasi. Kami telah merilis pembaruan dengan "penghapusan bertingkat" kami sendiri dari objek bersarang. Dan sekarang kami melacak dan mengevaluasi bagaimana keputusan kami memengaruhi waktu peluncuran aplikasi melalui metrik _app_start.
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 naik lagi.
Jika Anda melihat grafik tujuh hari, metrik _app_start terlihat cukup memadai dan kurang dari 1 detik.
Kita juga harus menambahkan 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 periksa secara eksplisit.
Keunikan database Realm adalah database non-relasional. Meskipun penggunaannya sederhana, kesamaan bekerja dengan solusi ORM dan menghubungkan objek, tidak ada penghapusan bertingkat.
Jika ini tidak diperhitungkan, maka objek bersarang akan menumpuk, "bocor". Basis data akan terus berkembang, yang pada gilirannya akan mempengaruhi perlambatan atau peluncuran aplikasi.
Saya berbagi pengalaman kami, seberapa cepat melakukan cascading menghapus objek di Realm, yang tidak di luar kotak, tetapi telah lama berbicara dan berbicara . Dalam kasus kami, ini sangat mempercepat waktu peluncuran aplikasi.
Terlepas dari diskusi tentang kemunculan fitur ini dalam waktu dekat, kurangnya penghapusan berjenjang di Realm dilakukan dengan desain. Pertimbangkan ini jika Anda mendesain aplikasi baru. Dan jika Anda sudah menggunakan Realm - periksa apakah Anda mengalami masalah seperti itu.