Шукаємо причини витоку пам’яті в програмній клавіатурі для Android, реалізованої за допомогою Compose
Написавши статтю «Реалізація програмної клавіатури для Android за допомогою Compose», я вирішив перевірити програмне рішення на витоки пам’яті. Оскільки це була моя перша стаття, для мене було важливо переконатися, що все працює бездоганно. Пошук відбувався за допомогою бібліотеки LeakCanary на фізичному пристрої Android 8.1 (27 Api) та на Android-емуляторі, системний образ якого Google Play Intel x86_64 Atom System Image API 34. Змінюючи орієнтацію/конфігурацію екрана та методи введення тексту (клавіатуру) почергово зі створеною, неочікувано я ніяких витоків не виявив, хоча мав деякі сумніви.
Мені пригадався влучний вислів авторів книги Android Programming: The Big Nerd Ranch Guide про програмування на Android: «Писати програми на Android — це як спілкуватися з лейтенантом Коломбо» (детективний серіал). Наче все вдається реалізувати і все працює як заплановано, але можуть трапитися нюанси, які покажуть, що ти не правий.
Так само як в серіалі, коли підозрювані думають, що здихалися лейтенанта та вже попрощалися з ним після спілкування, на виході Коломбо згадує ще щось, говорячи свою коронну фразу «One more thing» («І ще одне»). Після неї може пролунати інформація, яка не сподобається співрозмовнику.
В цій статті йтиме мова про це «І ще одне» та пошук його причин. У моєму випадку це пошук витоку пам’яті, якого «немає». Насправді, щоб переконатися остаточно, я запустив застосунок на своєму власному пристрої (API 34) і виявив витік пам’яті.
Ну що ж, почнемо досліджувати цю проблему.
Проблемний клас, де трапляються витоки пам’яті:
import android.inputmethodservice.InputMethodService import android.view.View import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.savedstate.SavedStateRegistry import androidx.savedstate.SavedStateRegistryController import androidx.savedstate.SavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import com.example.customsoftkeyboard.view.ComposeHexadecimalKeyBoardView class IMEHexadecimalService : InputMethodService(), LifecycleOwner, SavedStateRegistryOwner { private var lifecycleRegistry: LifecycleRegistry = LifecycleRegistry(this) override val lifecycle: Lifecycle get() = lifecycleRegistry private val savedStateRegistryController = SavedStateRegistryController.create(this) override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry override fun onCreateInputView(): View { window?.window?.decorView?.let { decorView -> decorView.setViewTreeLifecycleOwner(this) decorView.setViewTreeSavedStateRegistryOwner(this) } return ComposeHexadecimalKeyBoardView(this) } override fun onCreate() { super.onCreate() savedStateRegistryController.performRestore(null) handleLifecycleEvent(Lifecycle.Event.ON_CREATE) } override fun onDestroy() { super.onDestroy() handleLifecycleEvent(Lifecycle.Event.ON_DESTROY) } private fun handleLifecycleEvent(event: Lifecycle.Event) = lifecycleRegistry.handleLifecycleEvent(event)
Спробуємо простежити ці витоки через LeakCanary з версіями 2.14 та 3.0-alpha-8 на фізичному пристрої Motorola Edge 40 Pro.
Самі витоки пам’яті отримані за допомогою цієї бібліотеки:
LeakCanary 3.0-alpha-8 зліва та 2.14 справа
Як бачимо, версія 3.0-alpha-8 виявилася більш інформативною. Тому будемо використовувати її в подальшому на інших пристроях.
Результати трасування стека при витоці в текстовому форматі:
==================================== HEAP ANALYSIS RESULT ==================================== 1 APPLICATION LEAKS References underlined with "~~~" are likely causes. Learn more at https://squ.re/leaks. 299455 bytes retained by leaking objects Signature: 680efbf9ebebd6f8946131812c75d1798f996c35 ┬─── │ GC Root: Global variable in native code │ ├─ android.window.WindowOnBackInvokedDispatcher$OnBackInvokedCallbackWrapper instance │ Leaking: UNKNOWN │ Retaining 300.0 kB in 6630 objects │ WindowOnBackInvokedDispatcher$OnBackInvokedCallbackWrapper is a binder stub. Binder stubs will often be retained │ long after the associated activity or service is destroyed, as by design stubs are retained until the other side │ gets GCed. If WindowOnBackInvokedDispatcher$OnBackInvokedCallbackWrapper is not a *static* inner class then that's │ most likely the root cause of this leak. Make it static. If │ WindowOnBackInvokedDispatcher$OnBackInvokedCallbackWrapper is an Android Framework class, file a ticket here: │ https://issuetracker.google.com/issues/new?component=192705 │ ↓ WindowOnBackInvokedDispatcher$OnBackInvokedCallbackWrapper.mCallbackRef │ ~~~~~~~~~~~~ ├─ android.window.WindowOnBackInvokedDispatcher$OnBackInvokedCallbackWrapper$CallbackRef instance │ Leaking: UNKNOWN │ Retaining 299.5 kB in 6629 objects │ ↓ WindowOnBackInvokedDispatcher$OnBackInvokedCallbackWrapper$CallbackRef.mStrongRef │ ~~~~~~~~~~ ├─ android.inputmethodservice.InputMethodService$$ExternalSyntheticLambda3 instance │ Leaking: UNKNOWN │ Retaining 299.5 kB in 6628 objects │ f$0 instance of com.example.customsoftkeyboard.service.IMEHexadecimalService │ ↓ InputMethodService$$ExternalSyntheticLambda3.f$0 │ ~~~ ╰→ com.example.customsoftkeyboard.service.IMEHexadecimalService instance Leaking: YES (ObjectWatcher was watching this because com.example.customsoftkeyboard.service. IMEHexadecimalService received Service#onDestroy() callback and Service not held by ActivityThread) Retaining 299.5 kB in 6627 objects key = 81bcf4b0-11cc-407a-b4e5-66622602a44c watchDurationMillis = 8244 retainedDurationMillis = 3242 mApplication instance of com.example.customsoftkeyboard.CustomSoftKeyboardApplication mBase instance of android.app.ContextImpl ==================================== 0 LIBRARY LEAKS A Library Leak is a leak caused by a known bug in 3rd party code that you do not have control over. See https://square.github.io/leakcanary/fundamentals-how-leakcanary-works/#4-categorizing-leaks ==================================== 0 UNREACHABLE OBJECTS An unreachable object is still in memory but LeakCanary could not find a strong reference path from GC roots. ==================================== METADATA Please include this in bug reports and Stack Overflow questions. Build.VERSION.SDK_INT: 34 Build.MANUFACTURER: unknown LeakCanary version: 3.0-alpha-8 App process name: com.example.customsoftkeyboard Class count: 30515 Instance count: 205029 Primitive array count: 147949 Object array count: 28315 Thread count: 22 Heap total bytes: 29012550 Bitmap count: 6 Bitmap total bytes: 31110 Large bitmap count: 0 Large bitmap total bytes: 0 Stats: LruCache[maxSize=3000,hits=106811,misses=137464,hitRate=43%] RandomAccess[bytes=7127807,reads=137464,travel=54128216187,range=34936098,size=43515825] Analysis duration: 16250 ms Heap dump file path: /storage/emulated/0/Download/leakcanary-com.example.customsoftkeyboard/2025-03-13_16-40-15_225. hprof Heap dump timestamp: 1741884036842 Heap dump duration: Unknown ====================================
Оскільки виток пам’яті присутній не на всіх версіях Android та емуляторах, спробуємо виявити закономірності, де це трапляється. Внизу наведені результати пошуку.
Витоки відсутні на таких фізичних пристроях: Moto G5 Plus Android 8.1 (27 Api), Moto Hyper One Android 11 (30 Api).
Відсутні на системних образах (Android емуляторах): Intel x86_64 Atom System Image API 33, Google Apis Intel x86_64 Atom System Image API 33, Google Play Intel x86_64 Atom System Image API 33, Google Play Intel x86_64 Atom System Image API 34, Google Play Intel x86_64 Atom System Image API 35.
Присутні на фізичних пристроях: Motorola Edge 40 Pro, Motorola Edge 30 Neo and Samsung Galaxy S24 — Android 14 (API 34).
Присутні на системних образах: Intel x86_64 Atom System Image API 34, Google Apis Intel x86_64 Atom System Image API 34.
🕷️ Долучайтесь до збору на Павуки Допхіна!
Також під час пошуку помітив цікаву поведінку на Android-емуляторах Intel x86_64 Atom System Image API 35 та Google Apis Intel x86_64 Atom System Image API 35. LeakCanary знайшла схожі або ті самі витоки пам’яті. Але через декілька секунд вони зникають і повідомлення бібліотеки змінюється на «All retained objects were garbage collected.»
Тобто з цього можна зробити висновок, що проблема, напевно, криється в поведінці методу onDestroy() в IMEHexadecimalService() для Android 14 з сутністю android.window.WindowOnBackInvokedDispatcher$OnBackInvokedCallbackWrapper, яка, вірогідно, тримає наш сервіс/представлення (view).
Поки не будемо заглиблюватися у поведінку onDestroy() в InputMethodService(), але чому б нам не глянути на WindowOnBackInvokedDispatcher в скомпільованому apk-файлі за допомогою JADX Dex до Java-декомпілера.
Відкривши наш файл за допомогою вищезгаданого декомпілера, ми можемо спробувати пошукати android.window.WindowOnBackInvokedDispatcher$OnBackInvokedCallbackWrapper. Неочікувано, але вдалося знайти згадку про нього.
Вікно пошуку JADX:
Вікно пошуку JADX
Переходимо по цьому посиланню і отримуємо цікаву інформацію по нашому витоку WINDOW_ON_BACK_INVOKED_DISPATCHER__STUB, що присутній в класі shark.AndroidReferenceMatchers.
Результати пошуку в JADX
Можливо, вдасться витягнути з класу більше інформації. Для цього переходимо в наш проєкт та дивимося на External Libraries, як вказано на рисунку.
External Libraries проєкту
Пролистуємо до бібліотеки Gradle: com.squareup.leakcanary:shark-android:3.0-alpha-8.
Результат пошуку WINDOW_ON_BACK_INVOKED_DISPATCHER__STUB
Знайшли корисну інформацію та схожу проблему по нашому питанню, але справа в тому, що це тільки для 32 Api, а у нас, як відомо, проблема в 34 Api. І ще цікавий коментар:
Detected in Android 13 DP2, should be fixed in the next release.
Значить, цей витік пам’яті був у Android 13 Developer Preview 2 версії, але, як ми переконалися, його виправили в релізній версії Android 13. Принаймні на емуляторах, які згадані вище.
Після цього дослідження одночасно виникло нерозуміння того, що взагалі відбувається, і відчуття, що розв’язання проблеми зовсім близько. Таке враження, наче бібліотеку не оновили. Або моя «проблема» така специфічна, що нею знехтували, враховуючи те, що розмір зайнятої пам’яті навіть ніколи не перевищував 1 MB при дослідженні.
Ну що ж, поки будемо думати над отриманим даними, а зараз спробуємо перевірити, чи є витоки пам’яті через інші інструменти. Наприклад через Profile Analyze memory Usage (Heap Dump).
Результати аналізу використання пам’яті в Intel x86_64 Atom System Image API 34
Дивно, але через Profile Heap Dump витоків пам’яті не виявлено, хоча LeakCanary інформує, що вони є.
Треба напевно писати на Stack Overflow, GoogleIssueTracker та LeakCanary. Можливо, хтось прояснить цю ситуацію, або ж хтось стикався з чимось схожим. Допитливі користувачі можуть помітити відмінності в класі IMEHexadecimalService в статтях та проблемах, які опубліковані на трьох вищезгаданих ресурсах. Можу запевнити, це не впливає на витік пам’яті від присутніх у двох варіантах реалізації цього класу.
Трохи обдумавши та перечитавши, як Profile Analyze Memory Usage (Heap Dump) показує витоки пам’яті на інформаційній панелі, я згадав, що вона відображає лише ті витоки, які пов’язані з Activity або Fragment. Це логічно — у нас витікає IMEHexadecimalService, тому на панелі він не з’явився.
А поки буду чекати відповіді, протестуємо застосунок на витоки пам’яті через Profile Analyze memory Usage (Heap Dump) і подивимося на кількість виділення пам’яті в кучі на IMEHexadecimalService. Протестуємо на таких системних образах(емуляторах): Intel x86_64 Atom System Image API 33, Intel x86_64 Atom System Image API 34, Intel x86_64 Atom System Image API 35. Протестуємо застосунок, як вказано на початку статті.
Результати для Intel x86_64 Atom System Image API 33:
Дамп кучі Intel x86_64 Atom System Image API 33
Як бачимо, виділення пам’яті в купі для IMEHexadecimalService відбувається тільки один раз — витоків немає.
Результати для Intel x86_64 Atom System Image API 34:
Дамп кучі Intel x86_64 Atom System Image API 34
Виділення пам’яті в купі для IMEHexadecimalService відбувається 2 рази — витік є. Також цікаво, що посилання на об’єкти мають різну кількість для IMEHexadecimalService та також сильні посилання (Strong reference) на нього WindowOnBackInvokedDispatcher$OnBackInvokedCallbackWrapper (наш проблемний елемент, який тримає наш Сервіс).
Результати для Intel x86_64 Atom System Image API 35:
Дамп кучі Intel x86_64 Atom System Image API 35
Звідси робимо висновок, що виділення пам’яті в купі для IMEHexadecimalService відбувається тільки один раз — витоків немає.
Думаю, цієї інформації вистачить, щоб зробити висновки з цим витоком пам’яті.
Висновок
Отже, з вищевказаної інформації за результатами перевірки можна зробити підсумок, що витік пам’яті в сервісі спричинений android.window.WindowOnBackInvokedDispatcher$OnBackInvokedCallbackWrapper, який тримає посилання на IMEHexadecimalService. Він з’являється тільки в Android 14 (34 API). Причина, напевно, криється в неправильному очищуванні onDestroy() WindowOnBackInvokedDispatcher$OnBackInvokedCallbackWrapper в InputMethodService(). Тобто ця неправильна поведінка пов’язана з 14 Android фреймворком. Тому я створив звіт про цю помилку в IssueTracker та в LeakCanary.
На мою думку, цей витік можна проігнорувати через те, що він трапляється тільки в Android 14 та кількість пам’яті, яка витікає, не перевищує декілька Мбайт. Це не повинно впливати на швидкодію застосунку.
По можливості, коли ви пишете застосунок на Android, завжди перевіряйте його на витоки пам’яті за допомогою перевірених інструментів. Наприклад, LeakCanary, Android Studio Profile та інших.
Також варто пам’ятати, що деякі витоки можуть бути пов’язані з Android-фреймворком. Залежно від ситуації, вам слід вирішити, що робити: шукати виправлення, ігнорувати проблему чи чекати на офіційне оновлення.
Було б цікаво дізнатися, чи читачі стикалися зі схожими проблемами і як їх вирішували. Дякую за увагу!
P.S. Хочу висловити подяку людям, які працюють в IssueTracker. Вони відреагували на звіт про витік пам’яті досить швидко і взяли в роботу.
На момент публікації статті звіт був ще в роботі. Це додатково підтверджує наш висновок: витік пам’яті справді є, але він незначний і майже не впливає на працездатність застосунків.
2 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів