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

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

image

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


Задача

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

image

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

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

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


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

image

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

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

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


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

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

private class BitmapMusclesTask extends AsyncTask {

        private final WeakReference> musclesMap;

        BitmapMusclesTask(HashMap 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 в Kotlin-е.


Рисование

Цвета colors.xml в ресурсах values.



    #BBBBBB
    #ffb5cf
    #fda9c6
    #fa9cbe
    #f890b5
    #f583ac
    #f377a4
    #f06a9b
    #ee5e92
    #eb518a
    #e94581

Создание одного вида

public Bitmap createBitmapMuscles(HashMap 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

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

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

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


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


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

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

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

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

image


LruCache

После дополнительной обработке исходных изображений, некоторые стали занимать немного памяти, что привело к мысли о переиспользовании (не освобождать их после каждого наложения методом .recycle ()) с помощью LruCache. Создаем класс для хранения исходных изображений, который одновременно берет на себя функцию чтения из ресурсов:

class LruCacheBitmap(val context: Context) {

    private val lruCache: LruCache

    init {
        val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
        val cacheSize = maxMemory / 4
        lruCache = object : LruCache(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 = 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 {
        return suspendCoroutine { continuation ->
                val resultUries = HashMap()
                ... //создаем и сохраняем изображения 
                continuation.resume(resultUries)
        }
    }
}

Замеряем время обработки.

>start   1549400719844
>finish  1549400720440 diff=596 ms


С 9313 мс обработка уменьшилась до 596 мс

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

© Habrahabr.ru