Хабрахабр

[Из песочницы] Как я ускорил обработку изображений на Android в 15 раз

Как оптимизировать обработку изображений в рантайме, когда необходимо создать 6 изображений, каждое из которых состоит из последовательно наложенных 15-16 PNG, не получив OutOfMemoryException по дороге?

image

Гугление хороших юзкейсов предоставить не смогло, поэтому пришлось ходить по своим граблям и изобретать велосипед самостоятельно.
Также во время разработки произошла миграция с Java на Kotlin, поэтому код в определенный момент будет переведен. При разработке своего pet-приложения столкнулся с проблемой обработки изображений.

Задача

Необходимо построение карты работы мышц по результатам тренировок в рантайме приложения.
Два пола: М и Ж. Приложение для занятий в тренажерном зале. к. Рассмотрим вариант М, т. для Ж все аналогично.
Должно строится одновременно 6 изображений: 3 периода (одна тренировка, за неделю, за месяц) х 2 вида (спереди, сзади)

image

Плюс по 1 изображению основы (голова, кисти рук и ступни ног). Каждое такое изображение состоит из 15 изображений групп мышц для вида спереди и 14 для вида сзади. Итого, чтобы собрать вид спереди необходимо наложить 16 изображений, сзади – 15.

Всего 23 группы мышц для обеих сторон (для тех, у кого 15+14 != 23, небольшое пояснение – некоторые мышцы "видны" с обеих сторон).

Алгоритм наложения в первом приближении:

  1. На основе данных завершенных тренировок строится HashMap<String, Float>, String – название группы мышц, Float – степень нагрузки от 0 до 10.
  2. Каждая из 23 мышц перекрашивается в цвет от 0 (не участвовала) до 10 (макс. нагрузка).
  3. Накладываем перекрашенные изображения мыщц в два изображения (спереди, сзади).
  4. Сохраняем все 6 изображений.

image

Примерно при превышении ~30-40 МБ вы получаете OutOfMemoryException. Для хранения 31 (16+15) изображения размером 1500х1500 px при 24-битном режиме требуется 31х1500х1500х24бит = 199 MB оперативной памяти. к. Соотвественно, одновременно загрузить все изображения из ресурсов вы не можете, т. Это означает, что необходимо последовательно выполнять наложение изображений. необходимо освобождать ресурсы для неполучения эксепшена. Алгоритм трансформируется в следующий:

На основе данных завершенных тренировок строится HashMap<String, Float>, String – мышца, Float – степень нагрузки от 0 до 10.

Цикл для каждого из 6 изображений:

  1. Получили ресурс BitmapFactory.decodeResource().
  2. Каждая из 23 мышц перекрашивается в цвет от 0 (не участвовала) до 10 (макс. нагрузка).
  3. Накладываем перекрашенные изображения мыщц на один Canvas.
  4. Bitmap.recycle() освободили ресурс.

В каждом Таске создается последовательно два изображения: вид спереди и сзади. Задачу выполняем в отдельном потоке с помощью AsyncTask.

private class BitmapMusclesTask extends AsyncTask<Void, Void, DoubleMusclesBitmaps> @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;
}

Забегая вперед Java-класс DoubleMusclesBitmaps заменяется на Pair<Bitmap, Bitmap> в Kotlin-е. Вспомогательный класс DoubleMusclesBitmaps нужен только для того, чтобы вернуть две переменные Bitmap-а: вид спереди и сзади.

Рисование

Цвета 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.

Минусы:

  1. Периодически получаю OutOfMemoryException.
  2. Обработка занимает более 9 секунд, и это на эмуляторе(!) В "среднем" (старом моем) телефоне доходило до 20 секунд.
  3. AsyncTask со всеми вытекающими утечками [памяти].

Плюсы:
С вероятностью (1-OutOfMemoryException) изображения рисуются.

AsyncTask в IntentService

После завершения работы сервиса, при наличия запущенного BroadcastReceiver-а получаем Uri всех шести сгенерированных изображений, иначе просто изображения сохранялись, для того, чтобы при следующем открытии пользователем приложения не было необходимости ожидать процесс создания. Для ухода от AsyncTask решено было перейти на IntentServiсe, в котором выполнялось задание по созданию изображений. Время работы при этом никак не изменилось, но с одним минусом – утечками памяти разобрались, осталось еще два минуса.

Нужно оптимизировать. Заставлять пользователей ожидать создание изображений такое количество времени, конечно же, нельзя.

Намечаю пути оптимизации:

  1. Обработка изображений.
  2. Добавление LruCache.

Обработка изображений

Уменьшаем их до 1080х1080.
Как видно на второй фотографии все исходники квадратные, мышцы находятся на своем месте, а реальные полезные пиксели занимают небольшую площадь. Все исходные PNG-ресурсы имеют размер 1500х1500 пх. Кропаем (отрезаем) лишнее во всех исходниках, записывая положение (x, y) каждой группы мышц, чтобы наложить в последствии в нужное место. То, что все группы мышц уже находятся на своем месте — это удобно для программиста, но не рационально для производительности.

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

Теперь все исходники выглядят так:

image

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 мс

Если есть идеи по дополнительной оптимизации – велком в комментарии.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть