Как оптимизировать обработку изображений в рантайме, когда необходимо создать 6 изображений, каждое из которых состоит из последовательно наложенных 15-16 PNG, не получив OutOfMemoryException по дороге?
При разработке своего pet-приложения столкнулся с проблемой обработки изображений. Гугление хороших юзкейсов предоставить не смогло, поэтому пришлось ходить по своим граблям и изобретать велосипед самостоятельно.
Также во время разработки произошла миграция с Java на Kotlin, поэтому код в определенный момент будет переведен.
Задача
Приложение для занятий в тренажерном зале. Необходимо построение карты работы мышц по результатам тренировок в рантайме приложения.
Два пола: М и Ж. Рассмотрим вариант М, т. к. для Ж все аналогично.
Должно строится одновременно 6 изображений: 3 периода (одна тренировка, за неделю, за месяц) х 2 вида (спереди, сзади)
Каждое такое изображение состоит из 15 изображений групп мышц для вида спереди и 14 для вида сзади. Плюс по 1 изображению основы (голова, кисти рук и ступни ног). Итого, чтобы собрать вид спереди необходимо наложить 16 изображений, сзади – 15.
Всего 23 группы мышц для обеих сторон (для тех, у кого 15+14 != 23, небольшое пояснение – некоторые мышцы "видны" с обеих сторон).
Алгоритм наложения в первом приближении:
- На основе данных завершенных тренировок строится HashMap<String, Float>, String – название группы мышц, Float – степень нагрузки от 0 до 10.
- Каждая из 23 мышц перекрашивается в цвет от 0 (не участвовала) до 10 (макс. нагрузка).
- Накладываем перекрашенные изображения мыщц в два изображения (спереди, сзади).
- Сохраняем все 6 изображений.
Для хранения 31 (16+15) изображения размером 1500х1500 px при 24-битном режиме требуется 31х1500х1500х24бит = 199 MB оперативной памяти. Примерно при превышении ~30-40 МБ вы получаете OutOfMemoryException. Соотвественно, одновременно загрузить все изображения из ресурсов вы не можете, т. к. необходимо освобождать ресурсы для неполучения эксепшена. Это означает, что необходимо последовательно выполнять наложение изображений. Алгоритм трансформируется в следующий:
На основе данных завершенных тренировок строится HashMap<String, Float>, String – мышца, Float – степень нагрузки от 0 до 10.
Цикл для каждого из 6 изображений:
- Получили ресурс BitmapFactory.decodeResource().
- Каждая из 23 мышц перекрашивается в цвет от 0 (не участвовала) до 10 (макс. нагрузка).
- Накладываем перекрашенные изображения мыщц на один Canvas.
- Bitmap.recycle() освободили ресурс.
Задачу выполняем в отдельном потоке с помощью AsyncTask. В каждом Таске создается последовательно два изображения: вид спереди и сзади.
private class BitmapMusclesTask extends AsyncTask<Void, Void, DoubleMusclesBitmaps> {
private final WeakReference<HashMap<String, Float>> musclesMap;
BitmapMusclesTask(HashMap<String, Float> musclesMap) {
this.musclesMap = new WeakReference<>(musclesMap);
}
@Override
protected DoubleMusclesBitmaps doInBackground(Void... voids) {
DoubleMusclesBitmaps bitmaps = new DoubleMusclesBitmaps();
bitmaps.bitmapBack = createBitmapMuscles(musclesMap.get(), false);
bitmaps.bitmapFront = createBitmapMuscles(musclesMap.get(), true);
return bitmaps;
}
@Override
protected void onPostExecute(DoubleMusclesBitmaps bitmaps) {
super.onPostExecute(bitmaps);
Uri uriBack = saveBitmap(bitmaps.bitmapBack);
Uri uriFront = saveBitmap(bitmaps.bitmapFront);
bitmaps.bitmapBack.recycle();
bitmaps.bitmapFront.recycle();
if (listener != null)
listener.onUpdate(uriFront, uriBack);
}
}
public class DoubleMusclesBitmaps {
public Bitmap bitmapFront;
public Bitmap bitmapBack;
}
Вспомогательный класс DoubleMusclesBitmaps нужен только для того, чтобы вернуть две переменные Bitmap-а: вид спереди и сзади. Забегая вперед Java-класс DoubleMusclesBitmaps заменяется на Pair<Bitmap, Bitmap> в Kotlin-е.
Рисование
Цвета colors.xml в ресурсах values.
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="muscles_color0">#BBBBBB</color>
<color name="muscles_color1">#ffb5cf</color>
<color name="muscles_color2">#fda9c6</color>
<color name="muscles_color3">#fa9cbe</color>
<color name="muscles_color4">#f890b5</color>
<color name="muscles_color5">#f583ac</color>
<color name="muscles_color6">#f377a4</color>
<color name="muscles_color7">#f06a9b</color>
<color name="muscles_color8">#ee5e92</color>
<color name="muscles_color9">#eb518a</color>
<color name="muscles_color10">#e94581</color>
</resources>
Создание одного вида
public Bitmap createBitmapMuscles(HashMap<String, Float> musclesMap, Boolean isFront) {
Bitmap musclesBitmap = Bitmap.createBitmap(1500, 1500, Bitmap.Config.ARGB_8888);
Canvas resultCanvas = new Canvas(musclesBitmap);
for (HashMap.Entry entry : musclesMap.entrySet()) {
int color = Math.round((float) entry.getValue());
//получение цвета программным способом из ресурсов цвета по названию
color = context.getResources().getColor(context.getResources()
.getIdentifier("muscles_color" + color,
"color", context.getPackageName()));
drawMuscleElement(resultCanvas, entry.getKey(), color);
}
return musclesBitmap;
}
Наложение одной мышцы
private void drawMuscleElement(Canvas resultCanvas, String drawableName, @ColorInt int color) {
PorterDuff.Mode mode = PorterDuff.Mode.SRC_IN;
Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
Bitmap bitmapDst = BitmapFactory.decodeResource(context.getResources(),
context.getResources().getIdentifier(drawableName, "drawable", context.getPackageName()));
bitmapDst = Bitmap.createScaledBitmap(bitmapDst, 1500, 1500, true);
paint.setColorFilter(new PorterDuffColorFilter(color, mode));
resultCanvas.drawBitmap(bitmapDst, 0, 0, paint);
bitmapDst.recycle();//освобождение ресурса
}
Запускаем генерацию 3 пар изображений.
private BitmapMusclesTask taskLast;
private BitmapMusclesTask taskWeek;
private BitmapMusclesTask taskMonth;
private void startImageGenerating(){
taskLast = new BitmapMusclesTask(mapLast);
taskLast.execute();
taskWeek = new BitmapMusclesTask(mapWeek);
taskWeek.execute();
taskMonth = new BitmapMusclesTask(mapMonth);
taskMonth.execute();
}
Запускаем startImageGenerating():
> start 1549350950177
> finish 1549350959490 diff=9313 ms
Необходимо отметить, что очень много времени занимает чтение ресурсов. Для каждой пары изображений декодируется 29 PNG-файлов из ресурсов. В моем случае из общих затрат на создание изображений функция BitmapFactory.decodeResource() тратит ~75% времени: ~6960 ms.
Минусы:
- Периодически получаю OutOfMemoryException.
- Обработка занимает более 9 секунд, и это на эмуляторе(!) В "среднем" (старом моем) телефоне доходило до 20 секунд.
- AsyncTask со всеми вытекающими утечками [памяти].
Плюсы:
С вероятностью (1-OutOfMemoryException) изображения рисуются.
AsyncTask в IntentService
Для ухода от AsyncTask решено было перейти на IntentServiсe, в котором выполнялось задание по созданию изображений. После завершения работы сервиса, при наличия запущенного BroadcastReceiver-а получаем Uri всех шести сгенерированных изображений, иначе просто изображения сохранялись, для того, чтобы при следующем открытии пользователем приложения не было необходимости ожидать процесс создания. Время работы при этом никак не изменилось, но с одним минусом – утечками памяти разобрались, осталось еще два минуса.
Заставлять пользователей ожидать создание изображений такое количество времени, конечно же, нельзя. Нужно оптимизировать.
Намечаю пути оптимизации:
- Обработка изображений.
- Добавление LruCache.
Обработка изображений
Все исходные PNG-ресурсы имеют размер 1500х1500 пх. Уменьшаем их до 1080х1080.
Как видно на второй фотографии все исходники квадратные, мышцы находятся на своем месте, а реальные полезные пиксели занимают небольшую площадь. То, что все группы мышц уже находятся на своем месте — это удобно для программиста, но не рационально для производительности. Кропаем (отрезаем) лишнее во всех исходниках, записывая положение (x, y) каждой группы мышц, чтобы наложить в последствии в нужное место.
В первом подходе перекрашивались и накладывались все 29 изображений групп мышц на основу. Основа же включала в себя только голову, кисти рук и части ног. Изменяем основу: теперь она включает в себя помимо головы, рук и ног, все остальные группы мышц. Всё красим в серый цвет color_muscle0. Это позволит не перекрашивать и не накладывать те группы мышцы, которые не были задействованы.
Теперь все исходники выглядят так:
LruCache
После дополнительной обработке исходных изображений, некоторые стали занимать немного памяти, что привело к мысли о переиспользовании (не освобождать их после каждого наложения методом .recycle() ) с помощью LruCache. Создаем класс для хранения исходных изображений, который одновременно берет на себя функцию чтения из ресурсов:
class LruCacheBitmap(val context: Context) {
private val lruCache: LruCache<String, Bitmap>
init {
val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
val cacheSize = maxMemory / 4
lruCache = object : LruCache<String, Bitmap>(cacheSize) {
override fun sizeOf(key: String, bitmap: Bitmap): Int {
return bitmap.byteCount
}
}
}
fun getBitmap(drawableName: String): Bitmap? {
return if (lruCache.get(drawableName) != null) lruCache.get(drawableName) else decodeMuscleFile(drawableName)
}
fun clearAll() {
lruCache.evictAll()
}
private fun decodeMuscleFile(drawableName: String): Bitmap? {
val bitmap = BitmapFactory.decodeResource(context.resources,
context.resources.getIdentifier(drawableName, "drawable", context.packageName))
if (bitmap != null) {
lruCache.put(drawableName, bitmap)
}
return bitmap
}
}
Изображения подготовлены, декодирование ресурсов оптимизировано.
Плавный переход с Java на Kotlin обсуждать не будем, но он произошел.
Корутины
Код с использованием IntentService работает, но читаемость кода с колбэками не назовешь приятной.
Добавим желание посмотреть на корутины Котлина в работе. Добавим понимание того, что через пару месяцев читать свой синхронный код будет приятнее, чем поиск места возврата Uri файлов сгенерированных изображений.
Также ускорение обработки изображений натолкнуло на мысль использовать фичу в нескольких новых местах приложения, в частности в описании упражнений, а не только после тренировки.
private val errorHandler = CoroutineExceptionHandler { _, e -> e.printStackTrace()}
private val job = SupervisorJob()
private val scope = CoroutineScope(Dispatchers.Main + job + errorHandler)
private var uries: HashMap<String, Uri?> = HashMap()
fun startImageGenerating() = scope.launch {
...
val imgMuscle = ImgMuscle()
uries = withContext(Dispatchers.IO) { imgMuscle.createMuscleImages() }
...
}
Стандартная связка errorHandler, job и scope – скоуп корутин с хендлером ошибок, если корутина сломается.
uries – HashMap, который хранит в себе 6 изображений для последующего вывода в UI:
uries["last_back"]=Uri?
uries["last_front"]=Uri?
uries["week_back"]=Uri?
uries["week_front"]=Uri?
uries["month_back"]=Uri?
uries["month_front"]=Uri?
class ImgMuscle {
val lruBitmap: LruCacheBitmap
suspend fun createMuscleImages(): HashMap<String, Uri?> {
return suspendCoroutine { continuation ->
val resultUries = HashMap<String, Uri?>()
... //создаем и сохраняем изображения
continuation.resume(resultUries)
}
}
}
Замеряем время обработки.
>start 1549400719844
>finish 1549400720440 diff=596 ms
С 9313 мс обработка уменьшилась до 596 мс
Если есть идеи по дополнительной оптимизации – велком в комментарии.
Автор: Klukwist