Connect with us

Разработка

День, когда моё Android-приложение чуть не убило телефоны пользователей (и как я это исправил)

В следующий раз, когда будете загружать изображения или работать с большими наборами данных, задумайтесь о памяти. Вы (и ваши пользователи) будете вам благодарны в будущем.

Опубликовано

/

     
     

Было 2 часа ночи, когда мой телефон начал безостановочно вибрировать. Slack, почта и несколько взволнованных звонков от продакт-менеджера. Наше новостное приложение, которое месяцами работало стабильно, внезапно начало крашиться на тысячах устройств по всему миру.

В одном из отзывов пользователи писали: «Приложение съедает всю память телефона. Телефон стал совершенно нерабочим».

За ту ночь я узнал об управлении памятью в Android больше, чем за годы чтения учебников.

Когда всё летит к чертям

Представьте: вы только что выкатили, как казалось, идеально отполированную версию приложения для чтения новостей. Пользователи могут пролистывать статьи, смотреть красивые картинки в высоком разрешении, бесшовно делиться материалами. Всё идеально на тестах.

Но реальность быстро вносит коррективы.

Через несколько часов после релиза начали сыпаться краш-репорты. Картина была пугающе ясна: приложение расходовало 400+ МБ памяти и убивалось Low Memory Killer-ом Android. Еще хуже — оно замедляло работу множества устройств.

Отзывы были беспощадны:

  • «Это приложение крашнуло мой телефон»
  • «После использования пришлось перезагружать»
  • «Совершенно неюзабельно»

Для разработчика нет ничего более гнетущего, чем осознание того, что тысячи людей столкнулись с ужасными проблемами из-за написанного тобой кода.

Начинается расследование

Первый шаг в любой критической ситуации — понять, что происходит. Я запустил Memory Profiler в Android Studio — увиденное заставило сердце уйти в пятки.

Память приложения росла, как хоккейная клюшка: только вверх, без возврата. Виновник быстро нашёлся — выделения Bitmap’ов занимали до 80% кучи.

Вот что делал наш с виду безобидный адаптер:

class NewsAdapter : RecyclerView.Adapter<ViewHolder>() {
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        // This line was our silent killer
        Glide.with(context)
            .load(article.imageUrl)
            .into(holder.imageView)
    }
}

Выглядит безобидно, правда? Неправда.

Мы загружали изображения размером более 2 МБ напрямую с сервера, хотя нам нужны были всего лишь крошечные миниатюры размером 200 пикселей для нашего списка. Каждый раз, когда пользователь прокручивал страницу, мы загружали в память новую партию огромных изображений, не управляя предыдущими должным образом.

Aha! моменты

Используя LeakCanary (лучший друг каждого разработчика Android), я обнаружил несколько проблем.

Проблема №1: Размер имеет значение. Мы загружали изображения, которые были в 10 раз больше, чем отображались на самом деле. Это как купить 60-дюймовый телевизор, чтобы смотреть через телефон.

Проблема №2: Отсутствие уборщиков. Когда RecyclerView перерабатывал наши ViewHolder, старые ссылки на изображения не удалялись. Представьте, что вы устраиваете вечеринку и не убираете после неё — в конечном итоге ваш дом становится непригодным для жизни.

Проблема №3: Проблема повторения. Мы перезагружали одни и те же изображения каждый раз, когда пользователи прокручивали страницу назад. Отсутствие кэширования означало ненужные сетевые вызовы и выделение памяти.

Углубляемся: скрытые монстры в памяти

По мере того, как я свежим взглядом рассматривал нашу кодовую базу, я понимал, что изображения — не единственная  проблема. У нас была целая экосистема проблем с памятью, которые незаметно снижали производительность. Позвольте мне рассказать вам о других найденных мной виновниках:

Ловушка статических ссылок

class MainActivity : AppCompatActivity() {
    companion object {
        // This was holding onto our Activity forever!
        private var currentActivity: MainActivity? = null
    }
     override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        currentActivity = this // Memory leak alert!
    }
}

Эта невинная статическая ссылка мешала сборке мусора в наших Activity. Каждый раз, когда пользователи поворачивали телефоны или переключались между экранами, происходила утечка целого Activity.

Проблема анонимного внутреннего класса

Наши обработчики событий стали ещё одним тихим убийцей:

class NewsFragment : Fragment() {
    private fun setupClickListener() {
        // This anonymous class holds a reference to the Fragment
        button.setOnClickListener(object : View.OnClickListener {
            override fun onClick(v: View?) {
                // Long-running operation that outlives the Fragment
                performNetworkCall()
            }
        })
    }
}

Утечка памяти обработчика

У нас были хенлдеры, отправлявшие отложенные сообщения, которые переживали родительские активити:

class MainActivity : AppCompatActivity() {
    private val handler = Handler(Looper.getMainLooper())
    private fun startPeriodicUpdates() {
        // This Runnable holds an implicit reference to the Activity
        handler.postDelayed(object : Runnable {
            override fun run() {
                updateUI() // Activity might be destroyed by now!
                handler.postDelayed(this, 5000)
            }
        }, 5000)
    }
}

Коллекция, которая никогда не забывается

Наша аналитическая система накапливала данные о событиях бесконечно:

object AnalyticsManager {
   private val eventQueue: MutableList<AnalyticsEvent> = mutableListOf()
   fun trackEvent(event: AnalyticsEvent) {
        eventQueue.add(event) // This list grows forever!
    }
}

Путь к восстановлению

Чтобы решить эти проблемы, нам пришлось полностью переосмыслить не только работу с изображениями, но и управление памятью во всем приложении. Вот что действительно сработало:

1. Умная загрузка изображений

class NewsAdapter : RecyclerView.Adapter<ViewHolder>() {
    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        Glide.with(context)
            .load(article.imageUrl)
            .override(200, 200) // Only load what we need
            .diskCacheStrategy(DiskCacheStrategy.ALL) // Cache everything
            .placeholder(R.drawable.placeholder) // Show something while loading
            .into(holder.imageView)
    }
    override fun onViewRecycled(holder: ViewHolder) {
        // Clean up when views are recycled
        Glide.with(context).clear(holder.imageView)
        super.onViewRecycled(holder)
    }
}

Строка .override(200, 200) оказалась волшебной. Вместо загрузки изображений размером 2 МБ мы теперь загружали миниатюры размером 20 КБ. Визуальный результат тот же, использование памяти на 99% меньше.

2. Быть хорошим гражданином памяти

Android сообщает приложениям, когда системе не хватает памяти. Мы научились слушать:

class MainActivity : AppCompatActivity() {
    override fun onTrimMemory(level: Int) {
        super.onTrimMemory(level)
        when (level) {
            TRIM_MEMORY_UI_HIDDEN -> {
                // User switched apps, free up image cache
                Glide.get(this).clearMemory()
            }
            TRIM_MEMORY_BACKGROUND -> {
                // System is struggling, help it out
                imageCache.evictAll()
            }
        }
    }
}

Это сделало наше приложение командным игроком, а не пожирателем памяти.

3. Устранение утечек статических ссылок

Мы заменили статические ссылки на WeakReferences:

class MainActivity : AppCompatActivity() {
    companion object {
        // WeakReference allows garbage collection
        private var currentActivityRef: WeakReference<MainActivity>? = null
    fun getCurrentActivity(): MainActivity? = currentActivityRef?.get()
    }
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        currentActivityRef = WeakReference(this)
    }
    override fun onDestroy() {
        super.onDestroy()
        currentActivityRef?.clear()
    }
}

4. Предотвращение утечек в хендлере

Мы внедрили надлежащую очистку обработчика:

class MainActivity : AppCompatActivity() {
    private val handler = Handler(Looper.getMainLooper())
    private var updateRunnable: Runnable? = null
    private fun startPeriodicUpdates() {
        updateRunnable = object : Runnable {
            override fun run() {
                if (!isDestroyed && !isFinishing) {
                    updateUI()
                    handler.postDelayed(this, 5000)
                }
            }
        }
        handler.post(updateRunnable!!)
    }
    override fun onDestroy() {
        super.onDestroy()
        updateRunnable?.let { handler.removeCallbacks(it) }
    }
}

5. Более интеллектуальное управление коллекциями

Мы внедрили ограничения по размеру и очистку для нашей аналитики:

object AnalyticsManager {
    private val eventQueue: MutableList<AnalyticsEvent> = mutableListOf()
    private val maxQueueSize = 100
    fun trackEvent(event: AnalyticsEvent) {
        synchronized(eventQueue) {
            eventQueue.add(event)
            // Prevent unbounded growth
            if (eventQueue.size > maxQueueSize) {
                eventQueue.removeAt(0) // Remove oldest events
            }
        }
    }
    fun clearEvents() {
        synchronized(eventQueue) {
            eventQueue.clear()
        }
    }
}

6. Учёт жизненного цикла фрагментов

Мы реализовали поддержку жизненного цикла для сетевых вызовов:

class NewsFragment : Fragment() {
    private var networkJob: Job? = null
    private fun loadData() {
        networkJob = lifecycleScope.launch {
            try {
                val data = repository.getNews()
                if (isAdded && !isDetached) {
                    updateUI(data)
                }
            } catch (e: Exception) {
                if (isAdded) handleError(e)
            }
        }
    }
    override fun onDestroyView() {
        super.onDestroyView()
        networkJob?.cancel() // Cancel ongoing operations
    }
}

7. Безопасность памяти ViewBinding

Мы обеспечиваем корректную очистку ViewBinding:

class NewsFragment : Fragment() {
    private var _binding: FragmentNewsBinding? = null
    private val binding get() = _binding!!
    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        _binding = FragmentNewsBinding.inflate(inflater, container, false)
        return binding.root
    }
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null // Prevent memory leaks
    }
}
### 3. Pagination: The Unsung Hero
Instead of loading all articles at once, we implemented proper pagination:

class NewsRepository {
    fun getNews(page: Int): Flow<PagingData<Article>> {
        return Pager(
            config = PagingConfig(
                pageSize = 20, // Load 20 items at a time
                prefetchDistance = 5 // Prepare the next batch early
            ),
            pagingSourceFactory = { NewsPagingSource(api) }
        ).flow
    }
}

Это означает, что мы хранили в памяти только релевантные данные, а не всю новостную базу данных.

Преобразование

Результаты были мгновенными и впечатляющими:

  • Использование памяти: 400 МБ → 80 МБ (сокращение на 80%)
  • Утечки памяти: устранены все основные источники утечек
  • Сбои: 15% сбоев → <1%
  • Ошибки ANR: сокращены на 95%
  • Пользовательский опыт: восстановлена плавная прокрутка и навигация
  • Рейтинг: поднялся с 2.1 до 4.3 звёзд
  • Время работы батареи: пользователи сообщили об улучшении работы батареи на 30%

Но настоящая победа была в отзывах пользователей:

  • «Наконец-то работает идеально!»
  • «Теперь всё плавно как по маслу»
  • «Обожаю быструю загрузку»

Чему я научился той ночью

Этот опыт научил меня нескольким важным урокам, которые я учитываю в каждом проекте.

1. Память — проблема каждого. Даже если на вашем устройстве 8 ГБ оперативной памяти, ваше приложение не должно использовать больше, чем ему необходимо. Другим приложениям тоже нужна память.

2. Тестируйте на реальных устройствах. Эмуляторы с неограниченными ресурсами не выявят эти проблемы. Всегда тестируйте на реальных телефонах, особенно на старых или бюджетных устройствах с 2–3 ГБ оперативной памяти.

3. Проводите профилирование как можно раньше и как можно чаще. Не ждите сбоев в работе. Профилирование памяти должно стать частью вашей обычной процедуры разработки. Используйте такие инструменты, как:

  • Профилировщик памяти Android Studio
  • LeakCanary для обнаружения утечек
  • Systrace для анализа производительности

4. Обычно виновниками являются изображения. В мобильных приложениях изображения часто являются основными потребителями памяти. Но они не единственные.

5. Управление жизненным циклом критически важно. Жизненный цикл Android сложен. Каждый компонент (Activity, Fragment, Service) нуждается в надлежащей очистке для предотвращения утечек.

6. Статические ссылки опасны. Будьте предельно осторожны со статическими ссылками. Они могут легко привести к утечкам памяти, которые трудно обнаружить.

7. Пользователи помнят негативный опыт. Потребовались месяцы, чтобы восстановить доверие после того провального релиза. Профилактика всегда лучше лечения.

8. Проблемы с памятью усугубляются. Одна небольшая утечка может показаться безобидной, но несколько небольших утечек могут вывести из строя всё приложение.

Чеклист, который я использую сегодня

После этого опыта я составил контрольный список, который проверяю перед каждым релизом:

  • Изображения: Загружаем ли мы правильные размеры? Реализовано ли кэширование?
  • Статические ссылки: Есть ли статические ссылки на Activity или Fragment?
  • Хендлеры: Удалены ли все колбеки Handler в onDestroy()?
  • Сетевые вызовы: Отменяются ли текущие запросы при уничтожении компонентов?
  • Слушатели событий: Все ли слушатели корректно отписываются?
  • ViewBinding: Установлено ли значение привязки null в onDestroyView()?
  • Коллекции: Растут ли какие-либо коллекции бесконечно?
  • Фоновые задачи: Учитывают ли все фоновые операции жизненный цикл?

Ваш ход

Вы когда-нибудь сталкивались с подобным кризисом памяти? Или, может быть, прямо сейчас вы сталкиваетесь с проблемами производительности?

Экосистема Android предоставляет нам потрясающие инструменты для создания потрясающих приложений, но большая мощь влечет за собой большую ответственность. Каждая строка кода, которую мы пишем, влияет на реальных людей, использующих реальные устройства с реальными ограничениями.

В следующий раз, когда будете загружать изображения или работать с большими наборами данных, задумайтесь о памяти. Вы (и ваши пользователи) будете вам благодарны в будущем.

Источник

Если вы нашли опечатку - выделите ее и нажмите Ctrl + Enter! Для связи с нами вы можете использовать info@apptractor.ru.
Telegram

Популярное

Сообщить об опечатке

Текст, который будет отправлен нашим редакторам: