Привет, Хабр! Приложение iFunny создано, чтобы показывать контент, который генерируют пользователи. Это могут быть видео, гифки и картинки. Очень большие картинки. Представьте себе, сколько памяти займёт комикс, высотой в 10К пикселей. Представили? А теперь представьте, что вы не можете его сжимать, потому что в таком случае он потеряет в качестве настолько, что станет абсолютно нечитаемым. Под катом я расскажу, как iFunny работает с подобным контентом.
Из коробки Android довольно плохо справляется с большими картинками. Первая проблема, с которой мы столкнулись, — ограничение на размер Bitmap, которую может отобразить Canvas. На старых устройствах ошибки начинают возникать при стороне картинки больше 2К пикселей.
В рантайме можно получить ограничение Canvas#getMaximumBitmapHeight и Canvas#getMaximumBitmapWidth
Оно нас совершенно не устраивало, поэтому мы обошли его тем, что каждую картинку стали нарезать в памяти на куски и рисовать по отдельности в нужных местах. Таким образом, итоговое изображение собирается как лоскутное одеяло из фрагментов допустимых размеров. Этот приём решает проблему отрисовки больших картинок, но не избавляет от переполнения памяти.
На этот случай мы придумали подгружать картинки подобно тому, как картографические приложения подгружают местность. Суть алгоритма заключается в том, что большая картинка разбивается на меньшие куски, видимые из которых затем грузятся в оперативную память и показываются пользователю. Первая идея была в том, чтобы после загрузки каждой картинки нарезать её и разложить по файлам. Хотя при таком подходе и нужно разок положить в оперативку всё изображение целиком, позднее будут загружаться только видимые тайлы. Эта реализация несёт за собой сложности в менеджменте файлов каждого из тайлов, поскольку кэш ограничен, и мы периодически подчищаем его. После непродолжительного исследования мы обнаружили, что в Android предусмотрен специальный механизм, позволяющий грузить определённый кусок картинки в Bitmap — BitmapRegionDecoder.
Первые версии алгоритма работали не слишком гладко, потому что им не хватало скорости загрузки отдельных кусков. После пары часов, проведённых с профилировщиком, нашлись два места, которые стоит оптимизировать.
Во-первых, декодирование отдельного фрагмента занимает много времени. Эту проблему легко решить, распараллелив загрузку каждого тайла. Для этого мы использовали RxJava, который и так широко используется в нашем приложении. Это помогло нам легко манипулировать степенью многопоточности, передавая разные типы Scheduler.
Во-вторых, долгая инициализация самого декодера — BitmapRegionDecoder#newInstance().
С этим методом связан ещё один прикол — параметр isShareable ни на что не влияет, начиная с Android L.
В переиспользовании BitmapRegionDecoder есть нюанс, который заключается в том, что каждый инстанс сам по себе не предоставляет возможности параллельно выполнять decodeRegion. Поэтому просто держать один инстанс декодера нельзя. Так что наш выход — организовать пул BitmapRegionDecoder, чтобы не нужно было тратить дополнительное время на инициализацию при загрузке каждого тайла.
Ещё такой подход отлично сочетается с использованием BitmapPool. Он позволяет нам не аллоцировать картинки каждый раз, а брать изменяемые инстансы из общего пула. В своё время, эта фича существенно улучшила производительность нашего приложения. Когда пользователь прокручивает нарезанное изображение, мы должны перезагрузить довольно много картинок одинакового размера. Использование пула позволило нам не только существенно сэкономить на оперативной памяти, но и улучшить плавность работы приложения.
Так как в приложении iFunny комиксы — это длинные вертикальные картинки, то нам выгодно нарезать их узкими горизонтальными кусками. Это позволяет выгружать из оперативной памяти максимум ненужных тайлов.
К сожалению, работоспособность BitmapRegionDecoder может разниться на разных версиях Android. На 5-6 версиях ОС мы наблюдали артефакты декодирования. Виноват оказался наш способ кодирования картинок mozjpeg’ом на бэкенде. Дальше решили не исследовать, потому что таких устройств у наших пользователей очень мало.
Под спойлером пример того, как картинка выглядит в приложении
Так картинка выглядит в iFunny. Красные линии — границы тайлов, синие — показывают видимую область, а в углу выводится количество аллоцированных Bitmap. Для сравнения, контент целиком.
Так картинка выглядит в iFunny. Красные линии — границы тайлов, синие — показывают видимую область, а в углу выводится количество аллоцированных Bitmap. Для сравнения, контент целиком.
В итоге, разница в потреблении памяти заметна даже в профилировщике AndroidStudio. Она составляет порядка 10-15% для каждой картинки. Сейчас мы проводим эксперимент в продакшене и уже наблюдаем некоторое снижение OutOfMemory и ANR.
Nihiroz
А почему бы не нарезать на лоскуты картинки уже на бакэнде, если вы его контролируете?
FlashLight13 Автор
Возможно, в будущем так и сделаем. Это пока эксперимент, чтобы проверить, как оно скажется на памяти и стабильности.
Nihiroz
А, я подумал, что это уже результат работы, раз большая часть статьи посвящена частичной декодировке изображения. Основной то функционал отображения лоскутов задача простая. Но за BitmapRegionDecoder спасибо, может вспомнится, когда пригодится