L'objet CADisplayLink

Le SDK iOS permet d'implémenter facilement la plupart des animations qui ont fait le succès de l'iPhone. Mais il arrive parfois, par exemple lorsque l'on développe un élément complètement personnalisé qui doit se comporter à l’écran selon une physique réaliste, de devoir coder une animation entièrement à la main.

Une approche classique consiste à utiliser une boucle sans fin dans une thread pour calculer chaque frame et demander le rafraichissement de l'écran à intervalle régulier. Si cette architecture fonctionne bien sur certains systèmes, elle n'est pas idéale sur l'iPhone. La fréquence à laquelle cette thread va délivrer ses frames sera forcément différente de la fréquence à laquelle le hardware rafraîchira effectivement l'écran. Ce décalage conduira régulièrement l'application à attendre le processeur graphique (ou inversement le processeur graphique à attendre l'application), gaspillant des ressources processeur et donnant un affichage saccadé.

Affichage synchronisé

La classe CADisplayLink propose une solution à ce problème en le prenant par l'autre bout. Plutôt que d'avoir une thread qui essaiera tant bien que mal d'envoyer des frames à l'écran, c'est le processeur graphique qui demandera à l'application une nouvelle frame à chaque fois qu'il en aura besoin. Le calcul étant alors piloté par le hardware, il est synchronisé avec l'affichage et l'animation est fluide, parfaitement régulière, rapide. De plus, le SDK encapsule la chose de telle sorte que tout se passe dans la thread principale, ce qui permet de s'affranchir des problèmes d'accès concurrent aux données que pose habituellement la programmation multithread.

En pratique, il faut commencer par créer un objet CADisplayLink et le configurer. Il n'y a que trois paramètres à lui passer : le destinataire et le sélecteur du message que le système enverra à intervalle régulier pour demander le calcul de la frame suivante, et le taux de rafraichissement. Par exemple :

CADisplayLink * dl = [CADisplayLink displayLinkWithTarget:self selector:@selector(calcNextFrame:)];
[dl setFrameInterval:2];

Le taux de rafraichissement est exprimé en nombre de frames du processeur graphique. Par défaut il vaut 1, ce qui veut dire que la fonction de calcul sera appelée à chaque rafraichissement de l'affichage. S'il est fixé à 2 comme dans l'exemple ci-dessus, elle sera appelée une fois tous les deux rafraichissements. Et ainsi de suite. Autrement dit, plus la valeur est élevée, plus la vitesse de rafraichissement est faible.

Pour démarrer l'animation, il faut ensuite ajouter l'objet comme source à une run loop, typiquement celle de la thread principale de l'application. Ceci s'obtient grâce au code suivant :

[dl addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];

Dès cet instant, la méthode dont le sélecteur a été passé en paramètre lors de la création de l'objet est appelée, depuis la thread principale, aussi souvent que nécessaire. Une implémentation typique de cette méthode, lorsque l'on se trouve dans une classe dérivée de UIView, consiste à préparer la frame suivante et à appeler [self setNeedsDisplay] pour que la vue soit rafraichie. Une stratégie plus efficace, mais légèrement plus complexe si l'on n'est pas un familier de Quartz Core, consiste à rendre la frame directement dans un objet CALayer.

Tempus fugit

Dans beaucoup d'applications, le rendu de l'animation est soumis à une contrainte temporelle. Par exemple, s'il s'agit d'afficher une vidéo, sa vitesse doit correspondre à celle prévue à l'encodage (par exemple 25 images par secondes), quitte à sauter des frames lorsque le taux de rafraichissement chute parce que le processeur est débordé. De même pour un moteur de jeu : un rendu physique réaliste impose de prendre en considération dans le calcul du déplacement des objets le temps qui sépare réellement deux frames.

Pour cela, l'objet CADisplayLink propose la propriété timestamp. Cette dernière retourne avec une très bonne précision l'instant à laquelle la méthode de calcul est appelée. Il est alors possible, en stockant dans une variable membre l'heure de la précédente invocation, de déterminer combien de temps s'est écoulé depuis la frame précédente ; et ainsi de calculer la prochaine frame correctement. Par exemple :

- (void)calcNextFrame:(CADisplayLink *)sender
{
  CFTimeInterval t, diff;

  t = [sender timestamp];
  diff = t - lastStamp;

  // calculer la prochaine frame en tenant compte
  // de l'intervalle de temps diff

  lastStamp = t;
}

Enfin, il est possible d'interrompre momentanément l'animation et de la reprendre en affectant respectivement les valeurs YES et NO à la propriété paused de l'objet CADisplayLink. Pour interrompre définitivement l'animation, il faut retirer l'objet de la run loop courante grâce à la méthode invalidate.

Bien sûr, la méthode qui calcule la frame suivante doit être aussi efficace que possible. Non seulement il en va de la fluidité de l'animation, mais de plus, étant appelée depuis la thread principale, l'exécution de toute l'application est bloquée durant son exécution. Par exemple, imaginons que le processeur graphique rafraichisse l'écran 50 fois par seconde, soit une fois toutes les 20 millisecondes, et que la fonction de calcul demande 15 millisecondes pour construire la frame suivante ; cela veut dire que le système passera 75 % de son temps à ne pas traiter les événements utilisateur, ce qui se traduira par une interface peu réactive : boutons qui réagissent mal, barres de défilement qui « collent », etc.

Un travail d'optimisation est ici indispensable, ce qui ne peut se faire qu'avec des benchmarks sur un device réel. Il ne faut pas hésiter à augmenter la valeur de la propriété frameInterval : abaisser légèrement le taux de rafraichissement donnera souvent une application plus réactive sans altérer sensiblement la fluidité de l’animation. Dans le pire des cas, si l’on essaie d’obtenir un taux de rafraichissement trop élevé, le système sera débordé et n'appellera pas la fonction de calcul à la fréquence demandée. L'animation deviendra alors saccadée et tout le bénéfice apporté par l'objet CADisplayLink sera perdu.

Pour en savoir plus : La documentation de Quartz Core.