Animación y ajuste de iOS para mayor eficiencia
Publicado: 2022-03-11La creación de una gran aplicación no se trata solo de apariencia o funcionalidad, también se trata de qué tan bien funciona. Aunque las especificaciones de hardware de los dispositivos móviles están mejorando a un ritmo acelerado, las aplicaciones que funcionan mal, tartamudean en cada transición de pantalla o se desplazan como una presentación de diapositivas pueden arruinar la experiencia de su usuario y convertirse en una causa de frustración. En este artículo, veremos cómo medir el rendimiento de una aplicación de iOS y ajustarla para que sea eficiente. A los efectos de este artículo, crearemos una aplicación sencilla con una larga lista de imágenes y textos.
A los efectos de probar el rendimiento, recomendaría el uso de dispositivos reales. Si te tomas en serio la creación de aplicaciones y su optimización para una animación suave de iOS, los simuladores simplemente no son suficientes. Las simulaciones a veces pueden estar fuera de sintonía con la realidad. Por ejemplo, el simulador puede estar ejecutándose en su Mac, lo que probablemente significa que la CPU (Unidad central de procesamiento) es mucho más potente que la CPU de su iPhone. Por el contrario, la GPU (Unidad de procesamiento de gráficos) es tan diferente entre su dispositivo y su Mac que su Mac en realidad emula la GPU del dispositivo. Como resultado, las operaciones vinculadas a la CPU tienden a ser más rápidas en su simulador, mientras que las operaciones vinculadas a la GPU tienden a ser más lentas.
Animación a 60 FPS
Un aspecto clave del rendimiento percibido es asegurarse de que sus animaciones se ejecuten a 60 FPS (fotogramas por segundo), que es la frecuencia de actualización de su pantalla. Hay algunas animaciones basadas en temporizadores, que no discutiremos aquí. En términos generales, si está ejecutando a más de 50 FPS, su aplicación se verá fluida y con buen rendimiento. Si sus animaciones están atascadas entre 20 y 40 FPS, habrá un tartamudeo notable y el usuario detectará una "aspereza" en las transiciones. Cualquier cosa por debajo de 20 FPS afectará gravemente la usabilidad de su aplicación.
Antes de comenzar, probablemente valga la pena analizar la diferencia entre las operaciones vinculadas a la CPU y vinculadas a la GPU. La GPU es un chip especializado que está optimizado para dibujar gráficos. Si bien la CPU también puede hacerlo, es mucho más lenta. Es por eso que queremos descargar la mayor parte de nuestra representación de gráficos, el proceso de generar una imagen a partir de un modelo 2D o 3D, a la GPU. Pero debemos tener cuidado, ya que cuando la GPU se queda sin potencia de procesamiento, el rendimiento relacionado con los gráficos se degradará incluso si la CPU está relativamente libre.
Core Animation es un marco poderoso que maneja la animación tanto dentro como fuera de su aplicación. Desglosa el proceso en 6 pasos clave:
Diseño: donde organiza sus capas y establece sus propiedades, como el color y su posición relativa
Pantalla: aquí es donde las imágenes de respaldo se dibujan en un contexto. Aquí se accede a cualquier rutina que haya escrito en
drawRect:
odrawLayer:inContext:
Preparar: en esta etapa, Core Animation, ya que está a punto de enviar contexto al renderizador para dibujar, realiza algunas tareas necesarias, como descomprimir imágenes.
Confirmar: Aquí Core Animation envía todos estos datos al servidor de renderizado.
Deserialización: los 4 pasos anteriores estaban todos dentro de su aplicación, ahora la animación se procesa fuera de su aplicación, las capas empaquetadas se deserializan en un árbol que el servidor de procesamiento puede entender. Todo se convierte en geometría OpenGL.
Dibujar: Representa las formas (en realidad, triángulos).
Es posible que haya adivinado que los procesos 1-4 son operaciones de CPU y 5-6 son operaciones de GPU. En realidad, solo tienes control sobre los primeros 2 pasos. El mayor asesino de la GPU son las capas semitransparentes en las que la GPU tiene que llenar el mismo píxel varias veces por cuadro. Además, cualquier dibujo fuera de pantalla (varios efectos de capa como sombras, máscaras, esquinas redondeadas o rasterización de capas forzarán a Core Animation a dibujar fuera de pantalla) también afectará el rendimiento. Las imágenes que son demasiado grandes para ser procesadas por la GPU serán procesadas por la CPU mucho más lenta. Si bien las sombras se pueden lograr fácilmente configurando dos propiedades directamente en la capa, pueden matar fácilmente el rendimiento si tiene muchos objetos en pantalla con sombras. A veces vale la pena considerar agregar estas sombras como imágenes.
Medición del rendimiento de la animación de iOS
Comenzaremos con una aplicación simple con 5 imágenes PNG y una vista de tabla. En esta aplicación, esencialmente cargaremos 5 imágenes, pero las repetiremos en 10 000 filas. Agregaremos sombras tanto a las imágenes como a las etiquetas al lado de las imágenes:
-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; cell.customCellImageView.image = image; cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; return cell; }
Las imágenes simplemente se reciclan, mientras que las etiquetas siempre son diferentes. El resultado es:
Al deslizar el dedo verticalmente, es muy probable que note tartamudeos a medida que la vista se desplaza. En este punto, puede estar pensando que el hecho de que estemos cargando imágenes en el hilo principal es el problema. Puede ser que si moviéramos esto al hilo de fondo, todos nuestros problemas se resolverían.
En lugar de hacer conjeturas a ciegas, probemos y midamos el rendimiento. Es hora de Instrumentos.
Para usar Instrumentos, debe cambiar de "Ejecutar" a "Perfil". Y también debe estar conectado a su dispositivo real, no todos los instrumentos están disponibles en el simulador (¡otra razón por la que no debe optimizar el rendimiento en el simulador!). Usaremos principalmente las plantillas "GPU Driver", "Core Animation" y "Time Profiler". Un hecho poco conocido es que en lugar de detener y ejecutar en un instrumento diferente, puede arrastrar y soltar varios instrumentos y ejecutar varios al mismo tiempo.
Ahora que tenemos nuestros instrumentos configurados, vamos a medir. Primero veamos si realmente tenemos un problema con nuestro FPS.
Vaya, creo que estamos obteniendo 18 FPS aquí. ¿Cargar imágenes del paquete en el hilo principal es realmente tan caro y costoso? Observe que la utilización de nuestro renderizador está casi al máximo. También lo es nuestra utilización de soladores. Ambos están por encima del 95%. Y eso no tiene nada que ver con cargar una imagen del paquete en el hilo principal, así que no busquemos soluciones aquí.
Ajuste para la eficiencia
Hay una propiedad llamada shouldRasterize, y la gente probablemente le recomendará que la use aquí. ¿Qué debería hacer exactamente Rasterizar? Almacena en caché su capa como una imagen aplanada. Todos esos costosos dibujos de capas deben suceder una vez. En caso de que su marco cambie con frecuencia, no hay uso para un caché, ya que deberá regenerarse cada vez de todos modos.
Haciendo una enmienda rápida a nuestro código, obtenemos:
-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; cell.customCellImageView.image = image; cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; cell.layer.shouldRasterize = YES; cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }
Y volvemos a medir:

Con solo dos líneas, hemos mejorado nuestro FPS por 2x. Ahora estamos promediando más de 40 FPS. Pero, ¿ayudaría si hubiéramos movido la carga de imágenes a un hilo de fondo?
-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; NSInteger index = (indexPath.row % [self.images count]); NSString *imageName = [self.images objectAtIndex:index]; cell.tag = indexPath.row; dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; dispatch_async(dispatch_get_main_queue(), ^{ if (indexPath.row == cell.tag) { cell.customCellImageView.image = image; } }); }); cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; // cell.layer.shouldRasterize = YES; // cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }
Al medir, vemos que el rendimiento promedia alrededor de 18 FPS:
Nada que valga la pena celebrar. En otras palabras, no mejoró nuestra velocidad de fotogramas. Eso se debe a que aunque obstruir el subproceso principal está mal, no fue nuestro cuello de botella, sino el renderizado.
Volviendo al mejor ejemplo, donde promediamos más de 40FPS, el rendimiento es notablemente más suave. Pero en realidad podemos hacerlo mejor.
Al marcar "Capas combinadas de color" en la herramienta de animación central, vemos:
Se muestra "Capas de mezcla de colores" en la pantalla donde su GPU está renderizando mucho. El verde indica la menor cantidad de actividad de representación, mientras que el rojo indica la mayor parte. Pero establecimos shouldRasterize en YES
. Vale la pena señalar que "Capas mezcladas de colores" no es lo mismo que "Color Hits Green and Misses Red". El último básicamente resalta las capas rasterizadas en rojo a medida que se regenera el caché (una buena herramienta para ver si no está utilizando el caché correctamente). Establecer shouldRasterize en YES
no tiene ningún efecto en la representación inicial de las capas no opacas.
Este es un punto importante, y necesitamos hacer una pausa por un momento para pensar. Independientemente de si shouldRasterize está establecido en YES
o no, para representar el marco debe verificar todas las vistas y combinar (o no) en función de si las subvistas son transparentes u opacas. Si bien podría tener sentido que su UILabel no sea opaca, tal vez no tenga valor y mate su rendimiento. Por ejemplo, una UILabel transparente sobre un fondo blanco probablemente no tenga valor. Hagámoslo opaco:
Esto produce un mejor rendimiento, pero nuestra apariencia de la aplicación ha cambiado. Ahora, debido a que nuestra etiqueta e imágenes son opacas, la sombra se ha movido alrededor de nuestra imagen. Es probable que a nadie le guste este cambio, y si queremos conservar la apariencia original con un rendimiento de primer nivel, no perdemos la esperanza.
Para exprimir algunos FPS adicionales y conservar el aspecto original, es importante revisar dos de nuestras fases principales de animación que hemos descuidado hasta ahora.
- Preparar
- Cometer
Estos pueden parecer completamente fuera de nuestras manos, pero eso no es del todo cierto. Sabemos que para cargar una imagen es necesario descomprimirla. El tiempo de descompresión cambia según el formato de la imagen. Para los PNG, la descompresión es mucho más rápida que los JPEG (aunque la carga es más larga, y esto también depende del tamaño de la imagen), por lo que estábamos en el camino correcto para usar PNG, pero no estamos haciendo nada sobre el proceso de descompresión, y esta descompresión está sucediendo en el "punto de dibujo"! Este es el peor lugar posible donde podemos matar el tiempo: en el hilo principal.
Hay una forma de forzar la descompresión. Podríamos configurarlo en la propiedad de imagen de un UIImageView de inmediato. Pero eso aún descomprime la imagen en el hilo principal. ¿Hay alguna forma mejor?
Hay uno. Dibújelo en un CGContext, donde la imagen debe descomprimirse antes de poder dibujarse. Podemos hacer esto (usando la CPU) en un subproceso de fondo y darle los límites necesarios según el tamaño de nuestra vista de imagen. Esto optimizará nuestro proceso de dibujo de imágenes al hacerlo fuera del hilo principal, y nos ahorrará cálculos de "preparación" innecesarios en el hilo principal.
Mientras estamos en eso, ¿por qué no agregar las sombras mientras dibujamos la imagen? Luego podemos capturar la imagen (y almacenarla en caché) como una imagen estática y opaca. El código es el siguiente:
- (UIImage*)generateImageFromName:(NSString*)imageName { //define a boudns for drawing CGRect imgVwBounds = CGRectMake(0, 0, 48, 48); //get the image NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; UIImage *image = [UIImage imageWithContentsOfFile:filePath]; //draw in the context UIGraphicsBeginImageContextWithOptions(imgVwBounds.size, NO, 0); { //get context CGContextRef context = UIGraphicsGetCurrentContext(); //shadow CGContextSetShadowWithColor(context, CGSizeMake(0, 3.0f), 3.0f, [UIColor blackColor].CGColor); CGContextBeginTransparencyLayer (context, NULL); [image drawInRect:imgVwBounds blendMode:kCGBlendModeNormal alpha:1.0f]; CGContextSetRGBStrokeColor(context, 1.0, 1.0, 1.0, 1.0); CGContextEndTransparencyLayer(context); } image = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); return image; }
Y finalmente:
-(UITableViewCell*)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { CustomTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:[CustomTableViewCell getCustomCellIdentifier] forIndexPath:indexPath]; // NSInteger index = (indexPath.row % [self.images count]); // NSString *imageName = [self.images objectAtIndex:index]; // // cell.tag = indexPath.row; // // dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_HIGH, 0), ^{ // NSString *filePath = [[NSBundle mainBundle] pathForResource:imageName ofType:@"png"]; // UIImage *image = [UIImage imageWithContentsOfFile:filePath]; // // dispatch_async(dispatch_get_main_queue(), ^{ // if (indexPath.row == cell.tag) { // cell.customCellImageView.image = image; // } // }); // }); cell.customCellImageView.image = [self getImageByIndexPath:indexPath]; cell.customCellImageView.clipsToBounds = YES; // cell.customCellImageView.layer.shadowOffset = CGSizeMake(0, 5); // cell.customCellImageView.layer.shadowOpacity = 0.8f; cell.customCellMainLabel.text = [NSString stringWithFormat:@"Row %li", (long)(indexPath.row + 1)]; cell.customCellMainLabel.layer.shadowOffset = CGSizeMake(0, 3); cell.customCellMainLabel.layer.shadowOpacity = 0.5f; cell.layer.shouldRasterize = YES; cell.layer.rasterizationScale = [UIScreen mainScreen].scale; return cell; }
Y los resultados son:
Ahora estamos promediando más de 55 FPS, y nuestra utilización de renderizado y de mosaico es casi la mitad de lo que era originalmente.
Envolver
En caso de que se esté preguntando qué más podemos hacer para producir algunos fotogramas más por segundo, no busque más. UILabel usa WebKit HTML para representar texto. Podemos ir directamente a CATextLayer y tal vez jugar con las sombras allí también.
Es posible que haya notado que en nuestra implementación anterior, no estábamos cargando la imagen en un subproceso de fondo, sino que la estábamos almacenando en caché. Dado que solo había 5 imágenes, esto funcionó muy rápido y no pareció afectar el rendimiento general (especialmente porque las 5 imágenes se cargaron en la pantalla antes de desplazarse). Pero es posible que desee intentar mover esta lógica a un subproceso de fondo para obtener un rendimiento adicional.
La optimización de la eficiencia es la diferencia entre una aplicación de clase mundial y una amateur. La optimización del rendimiento, especialmente cuando se trata de animación de iOS, puede ser una tarea abrumadora. Pero con la ayuda de Instruments, uno puede diagnosticar fácilmente los cuellos de botella en el rendimiento de la animación en iOS.