Animation iOS et optimisation de l'efficacité
Publié: 2022-03-11La création d'une excellente application n'est pas qu'une question d'apparence ou de fonctionnalité, c'est aussi une question de performances. Bien que les spécifications matérielles des appareils mobiles s'améliorent à un rythme rapide, les applications qui fonctionnent mal, bégaient à chaque transition d'écran ou défilent comme un diaporama peuvent ruiner l'expérience de son utilisateur et devenir une cause de frustration. Dans cet article, nous verrons comment mesurer les performances d'une application iOS et la régler pour plus d'efficacité. Pour les besoins de cet article, nous allons créer une application simple avec une longue liste d'images et de textes.
Aux fins de tester les performances, je recommanderais l'utilisation d'appareils réels. Si vous êtes sérieux au sujet de la création d'applications et de leur optimisation pour une animation iOS fluide, les simulateurs ne suffisent tout simplement pas. Les simulations peuvent parfois être en décalage avec la réalité. Par exemple, le simulateur peut être exécuté sur votre Mac, ce qui signifie probablement que le CPU (Central Processing Unit) est beaucoup plus puissant que le CPU de votre iPhone. Inversement, le GPU (Graphics Processing Unit) est si différent entre votre appareil et votre Mac que votre Mac émule en fait le GPU de l'appareil. Par conséquent, les opérations liées au CPU ont tendance à être plus rapides sur votre simulateur, tandis que les opérations liées au GPU ont tendance à être plus lentes.
Animation à 60 FPS
Un aspect clé des performances perçues est de s'assurer que vos animations fonctionnent à 60 FPS (images par seconde), qui est le taux de rafraîchissement de votre écran. Il existe des animations basées sur une minuterie, dont nous ne discuterons pas ici. De manière générale, si vous utilisez une vitesse supérieure à 50 FPS, votre application aura l'air fluide et performante. Si vos animations sont bloquées entre 20 et 40 FPS, il y aura un bégaiement notable et l'utilisateur détectera une "rugosité" dans les transitions. Tout ce qui est inférieur à 20 FPS affectera gravement la convivialité de votre application.
Avant de commencer, il vaut probablement la peine de discuter de la différence entre les opérations liées au CPU et liées au GPU. Le GPU est une puce spécialisée optimisée pour dessiner des graphiques. Alors que le CPU le peut aussi, il est beaucoup plus lent. C'est pourquoi nous souhaitons décharger une grande partie de notre rendu graphique, le processus de génération d'une image à partir d'un modèle 2D ou 3D, vers le GPU. Mais nous devons être prudents, car lorsque le GPU manque de puissance de traitement, les performances liées aux graphiques se dégradent même si le CPU est relativement libre.
Core Animation est un cadre puissant qui gère l'animation à la fois à l'intérieur et à l'extérieur de votre application. Il décompose le processus en 6 étapes clés :
Disposition : où vous organisez vos calques et définissez leurs propriétés, telles que la couleur et leur position relative
Affichage : c'est là que les images de fond sont dessinées sur un contexte. Toute routine que vous avez écrite dans
drawRect:
oudrawLayer:inContext:
est accessible ici.Préparation : à ce stade, Core Animation, alors qu'il est sur le point d'envoyer le contexte au moteur de rendu sur lequel dessiner, effectue certaines tâches nécessaires telles que la décompression des images.
Commit : Ici, Core Animation envoie toutes ces données au serveur de rendu.
Désérialisation : Les 4 étapes précédentes étaient toutes dans votre application, maintenant l'animation est traitée en dehors de votre application, les couches empaquetées sont désérialisées dans un arbre que le serveur de rendu peut comprendre. Tout est converti en géométrie OpenGL.
Dessiner : Rend les formes (en fait des triangles).
Vous avez peut-être deviné que les processus 1 à 4 sont des opérations CPU et 5 à 6 sont des opérations GPU. En réalité, vous n'avez le contrôle que sur les 2 premières étapes. Le plus grand tueur du GPU est les couches semi-transparentes où le GPU doit remplir le même pixel plusieurs fois par image. De plus, tout dessin hors écran (plusieurs effets de calque tels que les ombres, les masques, les coins arrondis ou la pixellisation des calques obligeront Core Animation à dessiner hors écran) affectera également les performances. Les images trop volumineuses pour être traitées par le GPU seront traitées par le processeur beaucoup plus lent à la place. Bien que les ombres puissent être facilement obtenues en définissant deux propriétés directement sur le calque, elles peuvent facilement réduire les performances si vous avez de nombreux objets à l'écran avec des ombres. Parfois, il vaut la peine d'envisager d'ajouter ces ombres en tant qu'images.
Mesurer les performances d'animation iOS
Nous allons commencer avec une application simple avec 5 images PNG et une vue tableau. Dans cette application, nous chargerons essentiellement 5 images, mais nous la répéterons sur 10 000 lignes. Nous ajouterons des ombres à la fois aux images et aux étiquettes à côté des images :
-(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; }
Les images sont simplement recyclées alors que les étiquettes sont toujours différentes. Le résultat est:
En glissant verticalement, vous remarquerez très probablement un bégaiement lorsque la vue défile. À ce stade, vous pensez peut-être que le fait que nous chargeons des images dans le fil principal est le problème. Peut-être que si nous déplacions cela dans le fil d'arrière-plan, tous nos problèmes seraient résolus.
Au lieu de faire des suppositions à l'aveugle, essayons et mesurons les performances. C'est l'heure des Instruments.
Pour utiliser Instruments, vous devez passer de "Exécuter" à "Profil". Et vous devez également être connecté à votre appareil réel, tous les instruments ne sont pas disponibles sur le simulateur (une autre raison pour laquelle vous ne devriez pas optimiser les performances sur le simulateur !). Nous utiliserons principalement les modèles "GPU Driver", "Core Animation" et "Time Profiler". Un fait peu connu est qu'au lieu de s'arrêter et de fonctionner sur un instrument différent, vous pouvez faire glisser et déposer plusieurs instruments et en exécuter plusieurs en même temps.
Maintenant que nos instruments sont configurés, mesurons. Voyons d'abord si nous avons vraiment un problème avec notre FPS.
Yikes, je pense que nous obtenons 18 FPS ici. Le chargement d'images à partir du bundle sur le thread principal est-il vraiment si cher et coûteux ? Notez que l'utilisation de notre moteur de rendu est presque au maximum. Il en va de même pour notre utilisation de carreleur. Les deux sont supérieurs à 95 %. Et cela n'a rien à voir avec le chargement d'une image à partir du bundle sur le thread principal, alors ne cherchons pas de solutions ici.
Réglage pour l'efficacité
Il existe une propriété appelée shouldRasterize, et les gens vous recommanderont probablement de l'utiliser ici. Que fait shouldRasterize exactement ? Il met en cache votre calque sous forme d'image aplatie. Tous ces dessins de couches coûteux doivent se produire une fois. Dans le cas où votre cadre change fréquemment, il n'y a aucune utilité pour un cache, car il devra être régénéré à chaque fois de toute façon.
En faisant une modification rapide de notre code, nous obtenons :
-(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; }
Et on mesure à nouveau :

Avec seulement deux lignes, nous avons amélioré notre FPS de 2x. Nous sommes maintenant en moyenne au-dessus de 40 FPS. Mais cela aiderait-il si nous avions déplacé le chargement de l'image vers un fil d'arrière-plan ?
-(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; }
Lors de la mesure, nous voyons que la performance est en moyenne d'environ 18 FPS :
Rien à fêter. En d'autres termes, cela n'a apporté aucune amélioration à notre fréquence d'images. C'est parce que même si obstruer le thread principal est une erreur, ce n'était pas notre goulot d'étranglement, c'était le rendu.
Pour en revenir au meilleur exemple, où nous étions en moyenne au-dessus de 40 images par seconde, les performances sont nettement plus fluides. Mais on peut vraiment faire mieux.
En vérifiant "Calques de couleurs mélangées" sur l'outil d'animation de base, nous voyons :
"Color Blended Layers" montre à l'écran où votre GPU fait beaucoup de rendu. Le vert indique le moins d'activité de rendu tandis que le rouge en indique le plus. Mais nous avons défini shouldRasterize sur YES
. Il convient de souligner que "Color Blended Layers" n'est pas la même chose que "Color Hits Green and Misses Red". Ce dernier met essentiellement en évidence les couches pixellisées en rouge lorsque le cache est régénéré (un bon outil pour voir si vous n'utilisez pas correctement le cache). Définir shouldRasterize sur YES
n'a aucun effet sur le rendu initial des calques non opaques.
C'est un point important, et nous devons nous arrêter un instant pour réfléchir. Que shouldRasterize soit défini sur YES
ou non, pour rendre le framework, il faut vérifier toutes les vues et mélanger (ou non) selon que les sous-vues sont transparentes ou opaques. Bien qu'il puisse être logique que votre UILabel soit non opaque, cela peut être sans valeur et tuer vos performances. Par exemple, un UILabel transparent sur un fond blanc est probablement sans valeur. Rendons opaque :
Cela donne de meilleures performances, mais notre apparence de l'application a changé. Maintenant, parce que notre étiquette et nos images sont opaques, l'ombre s'est déplacée autour de notre image. Personne ne va probablement aimer ce changement, et si nous voulons préserver l'aspect et la convivialité d'origine avec des performances de premier ordre, nous ne perdons pas espoir.
Pour extraire quelques FPS supplémentaires tout en préservant l'aspect original, il est important de revoir deux de nos phases d'animation de base que nous avons négligées jusqu'à présent.
- Préparer
- S'engager
Ceux-ci peuvent sembler être complètement hors de nos mains, mais ce n'est pas tout à fait vrai. Nous savons que pour qu'une image soit chargée, elle doit être décompressée. Le temps de décompression change en fonction du format d'image. Pour les PNG, la décompression est beaucoup plus rapide que les JPEG (bien que le chargement soit plus long, et cela dépend aussi de la taille de l'image), nous étions donc en quelque sorte sur la bonne voie pour utiliser les PNG, mais nous ne faisons rien sur le processus de décompression, et cette décompression se passe au "point du dessin" ! C'est le pire endroit possible où nous pouvons tuer le temps - sur le fil principal.
Il existe un moyen de forcer la décompression. Nous pourrions le définir immédiatement sur la propriété image d'un UIImageView. Mais cela décompresse toujours l'image sur le fil principal. Y a-t-il une meilleure façon?
Il existe une. Dessinez-le dans un CGContext, où l'image doit être décompressée avant de pouvoir être dessinée. Nous pouvons le faire (en utilisant le processeur) dans un thread d'arrière-plan et lui donner des limites si nécessaire en fonction de la taille de notre vue d'image. Cela optimisera notre processus de dessin d'image en le faisant hors du fil principal et nous évitera des calculs de "préparation" inutiles sur le fil principal.
Pendant que nous y sommes, pourquoi ne pas ajouter les ombres pendant que nous dessinons l'image ? Nous pouvons alors capturer l'image (et la mettre en cache) comme une image statique et opaque. Le code est comme suit:
- (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; }
Et enfin:
-(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; }
Et les résultats sont :
Nous sommes maintenant en moyenne au-dessus de 55 FPS, et notre utilisation du rendu et celle du carreleur sont presque la moitié de ce qu'elles étaient à l'origine.
Emballer
Juste au cas où vous vous demanderiez ce que nous pouvons faire d'autre pour générer quelques images de plus par seconde, ne cherchez pas plus loin. UILabel utilise WebKit HTML pour restituer le texte. Nous pouvons aller directement à CATextLayer et peut-être aussi jouer avec les ombres.
Vous avez peut-être remarqué dans notre implémentation ci-dessus, nous ne faisions pas le chargement de l'image dans un fil d'arrière-plan, mais à la place nous le mettions en cache. Comme il n'y avait que 5 images, cela a fonctionné très rapidement et n'a pas semblé affecter les performances globales (d'autant plus que les 5 images ont été chargées à l'écran avant le défilement). Mais vous pouvez essayer de déplacer cette logique vers un thread d'arrière-plan pour des performances supplémentaires.
Le réglage de l'efficacité est la différence entre une application de classe mondiale et une application amateur. L'optimisation des performances, en particulier en ce qui concerne l'animation iOS, peut être une tâche ardue. Mais avec l'aide d'Instruments, on peut facilement diagnostiquer les goulots d'étranglement des performances d'animation sur iOS.