Боротьба з витоками пам'яті Android. Частина 1

Цією статтею ми відкриваємо цикл статей на Хабре про нашу розробці під Android.
Згідно з доповіддю компанії Crittercism від 2012 року, OutOfMemoryError — друга за поширеністю причина «крашей» мобільних додатків.
Чесно кажучи, і в Badoo ця помилка була в топі всіх крашей (що не дивно при тому обсязі фотографій, які дивляться наші користувачі). Боротьба з OutOfMemory — заняття копітке. Ми взяли в руки Allocation Tracker і почали гратися з додатком. Спостерігаючи за даними зарезервованої пам'яті, ми виявили кілька сценаріїв, при яких виділення пам'яті зростало з підозрілою стрімкістю, забуваючи при цьому зменшуватися. Озброївшись кількома дампами пам'яті після цих сценаріїв, ми проаналізували їх в MAT (http://www.eclipse.org/mat/).
Результат був цікавий і дозволив нам протягом декількох тижнів знизити кількість крашей в рази. Щось було специфічно для нашого коду, але також виявлено типові помилки, властиві більшості Android додатків.
Сьогодні поговоримо про конкретному випадку витоку пам'яті. Про нього багато знають, але часто закривають на це очі (а даремно).

Мова піде про втрату пам'яті, пов'язаних з неправильним використанням android.os.Handler. Не зовсім очевидно, але все, що ви ставите в Handler, знаходиться в пам'яті і не може бути очищено складальником сміття протягом деякого часу. Інколи досить тривалого.
Трохи пізніше ми покажемо на прикладах, що відбувається і чому пам'ять не може бути звільнена. Якщо ви не цікавий, але хочете знати, як боротися з проблемою, то перейдіть до висновків у кінці статті. Або відразу вирушайте на сторінку маленької бібліотеки, яку ми виклали у відкритий доступ: https://github.com/badoo/android-weak-handler.

Отже, що ж там «тече»? Давайте розберемося.

Простий приклад



Це дуже простий клас Activity. Припустимо, що нам потрібно поміняти текст по закінченні 800 секунд. Приклад, звичайно, безглуздий, але зате добре продемонструє нам, як течуть струмки нашій пам'яті.
Зверніть увагу на анонімний Runnable, який ми постимо в Handler. Так само важливо звернути увагу на тривалий тайм-аут.
Для тесту ми запустили цей приклад і повернули телефон 7 разів, тим самим викликавши зміну орієнтації екрану і нарощування Activity. Потім зняли дамп пам'яті і відкрили його в MAT (http://www.eclipse.org/mat/).

З допомогою OQL запускаємо простий запит, який виводить всі инстансы класу Activity:

select * from instanceof android.app.Activity

Ми дуже рекомендуємо почитати про OQL — він відчутно допоможе вам в аналізі пам'яті.
Почитати можна тут visualvm.java.net/oqlhelp.html або help.eclipse.org/luna/index.jsp?topic=%2Forg.eclipse.mat.ui.help%2Freference%2Foqlsyntax.html.



У пам'яті висить 7 инстансов Activity. Це у 7 разів більше, ніж потрібно. Давайте розберемося, чому збирач сміття не зміг видалити відпрацьовані об'єкти з пам'яті. Відкриємо найкоротший граф посилань на один з Activity:



На скріншоті видно, що на Activity посилається this$0. Це неявна посилання з анонімного класу на зовнішній клас. В Java будь анонімний клас завжди має неявную посилання на зовнішній клас, навіть якщо ви ніколи не звертаєтеся до зовнішніх полів або методів. Java не ідеальна, а життя — це біль. Такі справи, котаны.

Далі, посилання на this$0 зберігається у callback, який зберігається у зв'язаному списку повідомлень. В кінці ланцюжка — локальна посилання в стеку головного потоку. По всій видимості, це локальна змінна в головному циклі UI потоку, яка звільниться, коли цикл відпрацює. У нашому випадку це відбудеться після того, як програма завершить свою роботу.

Отже, після того як ми помістили Runnable або у Message Handler, він буде зберігається в списку повідомлень LooperThread до тих пір, поки повідомлення не відпрацює. Цілком очевидно, що якщо ми помістимо відкладене повідомлення, то воно буде лежати в пам'яті до тих пір, поки не настане його час. Разом з повідомленням у пам'яті будуть лежати всі об'єкти, на які посилається повідомлення, явно і неявно.
І з цим потрібно щось робити.

Рішення з використанням статичного класу

Давайте спробуємо вирішити нашу проблему, позбувшись посилання this$0. Для цього переробимо анонімний клас у статичний:



Запускаємо, пару раз повертаємо телефон і збираємо дамп пам'яті.



Знову більше однієї Activity? Давайте подивимося, чому збирач сміття не зміг їх видалити.



Зверніть увагу на самий низ графа посилань: Activity збережений в посиланні mContext з mTextView всередині класу DoneRunnable. Очевидно, що використання статичного класу самого по собі недостатньо, щоб уникнути витоку пам'яті. Нам потрібно зробити дещо ще.

Рішення з використанням статичного класу і WeakReference

Продовжимо послідовний метод позбавлення від посилання на TextView, яку ми знайшли в ході вивчення дампів пам'яті.



Зверніть увагу, що ми зберігаємо посилання на TextView в WeakReference. Використання WeakReference вимагає особливої акуратності: така посилання в будь-який момент може обнулитися. Тому спочатку зберігаємо посилання в локальну змінну та працюємо тільки з останньою, перевіривши її на значення null.

Запускаємо, повертаємо і збираємо дамп пам'яті.



Ми домоглися бажаного! Тільки один Activity в пам'яті. Проблема вирішена.

Для використання даного підходу нам необхідно:

  • використовувати статичний внутрішній або зовнішній клас;
  • використовувати WeakReference для всіх об'єктів, на які ми посилаємося.
Хороший цей метод?
Якщо порівнювати оригінальний код і «безпечний» код, то в очі впадає велика кількість «шуму». Він відволікає від розуміння коду і ускладнює його підтримку. Написання такого коду — те ще задоволення, не кажучи вже про те, що можна щось забути або забити.

Добре, що є рішення краще.

Очищення всіх повідомлень у onDestroy

У класу Handler є цікавий і дуже корисний метод — removeCallbacksAndMessages, який приймає null в якості аргументу. Він видаляє всі повідомлення, перебувають у черзі даного Handler'а. Давайте використаємо його в onDestroy.



Запустимо, повернемо і знімемо дамп пам'яті.



Чудово! Тільки один клас Activity.

Цей метод набагато краще попереднього: кількість супутнього коду мінімально, ризики допустити помилку набагато нижче. Одна біда — не забути б викликати очищення в методах onDestroy або там, де вам потрібно почистити пам'ять.

У нас в запасі є ще один метод, який, можливо, вам сподобається набагато більше.

Рішення з використанням WeakHandler



Команда Badoo написала свій Handler — WeakHandler. Це клас, який веде себе зовсім як Handler, але виключає витоку пам'яті.

Він використовує м'які і жорсткі посилання для уникнення витоків пам'яті. Принцип його роботи ми опишемо трохи пізніше, а поки давайте глянемо на код:



Дуже схоже на оригінальний код, чи не так? Лише одна маленька деталь: замість використання android.os.Handler ми використовували WeakHandler. Давайте запустимо, повернемо телефон кілька разів і знімемо дамп пам'яті.



Нарешті! Код чистий як сльоза і пам'ять не тече.

Якщо вам сподобався цей метод, то ось хороша новина: WeakHandler дуже просто.

Додайте maven-залежність в ваш проект:
repositories {
maven {
repositories {
url 'https://oss.sonatype.org/content/repositories/releases/'
}
}
}

dependencies {
compile 'com.badoo.mobile:android-weak-handler:1.0'
}


Імпортуйте WeakHandler у вашому коді:

import com.badoo.mobile.util.WeakHandler

Вихідний код викладений на github: github.com/badoo/android-weak-handler.

Принцип роботи WeakHandler

Головна ідея — тримати жорстку посилання на повідомлення або Runnable до тих пір, поки існує жорстка посилання на WeakHandler. Як тільки WeakHandler може бути видалений з пам'яті, все інше повинно бути видалено разом з ним.

Для простоти пояснення ми покажемо просту діаграму, що демонструє різницю між приміщенням анонімного Runnable в простій Handler і у WeakHandler:



Зверніть увагу на верхню діаграму: Activity посилається на Handler, який постить Runnable (поміщає його в чергу повідомлень, на які посилається Thread). Все непогано, за винятком неявній зворотного посилання з Runnable на Activity. Поки Message лежить в черзі, яка живе, поки живий Thread, весь граф не може бути зібраний збирачем сміття. В тому числі і товста Activity.

У нижній діаграмі Activity посилається на WeakHandler, який тримає Handler всередині. Коли ми просимо його помістити Runnable, він загортає його в WeakRunnable і постить в чергу. Таким чином, черга повідомлень посилається лише на WeakRunnable. WeakRunnable містить WeakReference на початковий Runnable, тобто збирач сміття може очистити його в будь-який момент. Що б він його не очистив раніше часу, WeakHandler тримає жорстку посилання на Runnable. Але як тільки сам WeakHandler може бути видалений, Runnable так само може бути видалений.

Потрібно бути акуратним і не забувати, що на WeakHandler має бути посилання ззовні, інакше всі повідомлення будуть очищені разом з ним збирачем сміття.

Висновки

Використання postDelayed в Android не так просто, як здається: потрібно здійснювати додаткові дії, щоб пам'ять не текла. Для цього можна застосовувати такі методи:

  • використовувати статичний внутрішній клас Runnable/Handler з WeakReferences на зовнішній клас;
  • чистити всі повідомлення в класі Handler з методу onDestroy;
  • використовувати WeakHandler від Badoo (https://github.com/badoo/android-weak-handler).
Вибір за вами. Перший метод точно не для ледачих. Другий метод є досить простим, але вимагає додаткової роботи. Третій же — наш фаворит, але потрібно бути уважним: WeakHandler повинна бути зовнішня посилання до тих пір, поки він вам потрібен, інакше збирач сміття його видалить разом з усіма повідомленнями з черги.

Вдалої вам боротьби! Залишайтеся з нами — у цієї теми буде продовження.

Стаття в нашому англомовному блозі: bit.ly/AndroidHandlerMemoryLeaks

Дмитро Воронкевич, провідний розробник

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.