¿Cómo optimizar el procesamiento de imágenes en tiempo de ejecución cuando es necesario crear 6 imágenes, cada una de las cuales consiste en 15-16 PNG superpuestos secuencialmente, sin recibir una excepción OutOfMemoryException en el camino?

Al desarrollar mi aplicación para mascotas, me encontré con el problema del procesamiento de imágenes. No podía proporcionar buenas cajas de Google para Google, así que tuve que caminar con mi rastrillo e inventar una bicicleta por mi cuenta.
También durante el desarrollo, hubo una migración de Java a Kotlin, por lo que el código se traducirá en algún momento.
Desafío
Solicitud de entrenamiento en el gimnasio. Es necesario construir un mapa de trabajo muscular basado en los resultados del entrenamiento en tiempo de ejecución de la aplicación.
Dos sexos: M y G. Considere la opción M, porque para M todo es igual.
Se deben construir 6 imágenes simultáneamente: 3 períodos (una sesión de entrenamiento, por semana, por mes) x 2 vistas (frontal, posterior)

Cada una de esas imágenes consta de 15 imágenes de grupos musculares para una vista frontal y 14 para una vista posterior. Más 1 imagen de la base (cabeza, manos y pies). En total, para recopilar la vista frontal, debe superponer 16 imágenes, desde la parte posterior, 15.
Solo 23 grupos musculares para ambos lados (para aquellos con 15 + 14! = 23, una pequeña explicación: algunos músculos son "visibles" en ambos lados).
Algoritmo en la primera aproximación:
- Según los datos de los entrenamientos completados, se construye HashMap <String, Float>, String es el nombre del grupo muscular, Float es el grado de carga de 0 a 10.
- Cada uno de los 23 músculos se repinta en color de 0 (no involucrado) a 10 (carga máxima).
- Superposición de imágenes musculares repintadas en dos imágenes (frontal, posterior).
- Guardamos las 6 imágenes.

Para almacenar 31 (16 + 15) imágenes de tamaño 1500x1500 px con modo de 24 bits, se requiere 31x1500x1500x24bit = 199 MB de RAM. Cuando excede ~ 30-40 MB, obtiene una excepción OutOfMemoryException. En consecuencia, no puede cargar todas las imágenes de los recursos al mismo tiempo, porque necesita liberar recursos para no recibir la recepción. Esto significa que necesita superponer secuencialmente las imágenes. El algoritmo se transforma en lo siguiente:
Según los datos de los entrenamientos completados, se construyen HashMap <String, Float>, String - muscle, Float - load level de 0 a 10.
El ciclo para cada una de las 6 imágenes:
- Obtuve el recurso BitmapFactory.decodeResource ().
- Cada uno de los 23 músculos se repinta en color de 0 (no involucrado) a 10 (carga máxima).
- Superposición de imágenes musculares repintadas en un lienzo.
- Bitmap.recycle () liberó un recurso.
Realizamos la tarea en un hilo separado usando AsyncTask. En cada tarea, se crean dos imágenes secuencialmente: una vista frontal y una vista posterior.
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; }
La clase auxiliar DoubleMusclesBitmaps solo se necesita para devolver dos variables de mapa de bits: vista frontal y vista posterior. Mirando hacia el futuro, la clase Java DoubleMusclesBitmaps se reemplaza por Pair <Bitmap, Bitmap> en Kotlin.
Dibujo
Colores colors.xml en recursos de valores.
<?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>
Crea una vista
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());
Solapa muscular
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();
Comenzamos la generación de 3 pares de imágenes.
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(); }
Comenzamos startImageGenerating ():
> start 1549350950177 > finish 1549350959490 diff=9313 ms
Cabe señalar que leer recursos requiere mucho tiempo. Para cada par de imágenes, se decodifican 29 archivos PNG de recursos. En mi caso, fuera del costo total de crear imágenes, la función BitmapFactory.decodeResource () gasta ~ 75% del tiempo: ~ 6960 ms.
Contras:
- Recibo una OutOfMemoryException de vez en cuando.
- El procesamiento lleva más de 9 segundos, y esto está en el emulador (!) En el teléfono "promedio" (antiguo mío) alcanzó los 20 segundos.
- AsyncTask con todas las fugas [de memoria] resultantes.
Pros:
Con probabilidad (1-OutOfMemoryException) se dibujan imágenes.
AsyncTask en IntentService
Para abandonar AsyncTask, se decidió cambiar a IntentServie, en el que se realizó la tarea de crear imágenes. Una vez completado el servicio, si BroadcastReceiver se está ejecutando, obtenemos el Uri de las seis imágenes generadas; de lo contrario, las imágenes simplemente se guardaron para que la próxima vez que el usuario abra la aplicación, no haya necesidad de esperar el proceso de creación. Al mismo tiempo, el tiempo de funcionamiento no cambió, pero con una desventaja: se detectaron pérdidas de memoria, hubo dos desventajas más.
Obligar a los usuarios a esperar la creación de imágenes durante tal cantidad de tiempo, por supuesto, es imposible. Necesidad de optimizar.
Esquema de formas de optimización:
- Procesamiento de imagen.
- Añadiendo LruCache.
Procesamiento de imagen
Todos los recursos PNG de origen tienen un tamaño de 1500x1500 px. Reducirlos a 1080x1080.
Como puede ver en la segunda foto, todos los códigos fuente son cuadrados, los músculos están en su lugar y los píxeles útiles reales ocupan un área pequeña. El hecho de que todos los grupos musculares ya estén en su lugar es conveniente para el programador, pero no es racional para el rendimiento. Recortamos (cortamos) el exceso en todos los códigos fuente, registrando la posición (x, y) de cada grupo muscular para luego aplicarlo en el lugar correcto.
En el primer enfoque, las 29 imágenes de grupos musculares fueron repintadas y superpuestas en la base. La base incluía solo la cabeza, las manos y partes de las piernas. Cambiamos la base: ahora incluye, además de la cabeza, brazos y piernas, todos los demás grupos musculares. Pintamos todo en gris color_muscle0. Esto permitirá no volver a pintar y no imponer aquellos grupos musculares que no estuvieron involucrados.
Ahora todas las fuentes se ven así:

Lrucache
Después del procesamiento adicional de las imágenes originales, algunas comenzaron a ocupar un poco de memoria, lo que llevó a la idea de reutilizarlas (no liberarlas después de cada superposición con el método .recycle ()) usando LruCache. Creamos una clase para almacenar imágenes de origen, que asume simultáneamente la función de lectura de recursos:
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 } }
Las imágenes están preparadas, la decodificación de recursos está optimizada.
No discutiremos la transición fluida de Java a Kotlin, pero sucedió.
Corutinas
El código que usa IntentService funciona, pero la legibilidad del código con devoluciones de llamada no se puede llamar agradable.
Agregue un deseo de mirar las corutinas de Kotlin en el trabajo. Agregamos el entendimiento de que después de un par de meses será más agradable leer su código sincrónico que buscar el lugar para devolver los archivos Uri de las imágenes generadas.
Además, la aceleración del procesamiento de imágenes impulsó la idea de utilizar la función en varios lugares nuevos de la aplicación, en particular en la descripción de ejercicios, y no solo después del entrenamiento.
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() } ... }
El paquete estándar de errorHandler, trabajo y alcance es la exploración de la rutina con un controlador de errores si la rutina se rompe.
uries - HashMap, que almacena 6 imágenes en sí mismo para su posterior salida a la interfaz de usuario:
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?>() ...
Medimos el tiempo de procesamiento.
>start 1549400719844 >finish 1549400720440 diff=596 ms
Desde 9313 ms, el procesamiento disminuyó a 596 ms.
Si tiene ideas para una optimización adicional, no dude en comentar.