fabernovel loader

iOS : Traquer une phase de layout

Le lien entre run loop, layout et rendu

FRONT
MOBILE
QUALITÉ
fabernovel loader
Sur iOS, la position des vues d’un écran est exprimée de façon dynamique à l’aide du système de contraintes d’AutoLayout. L'intérêt est qu’une hiérarchie de vues peut s’adapter à toutes les tailles possibles d’écrans des appareils vendus par Apple. La conséquence est que le rendu d’un écran nécessite un calcul avant chaque apparition à l’écran. Autolayout prend soin de traduire l’ensemble des contraintes en équations dont les positions et la taille des vues en sont les variables. Tentons de découvrir ensemble quand ce calcul de résolution a lieu.

Dans le cycle de vie d’une application, Apple qualifie ces instants de “layout pass” sans jamais formellement les décrire. Ils ont été mentionnées lors de la WWDC 2018, durant laquelle seuls l’ordre et les objectifs des différents calculs ont été précisés.

Pour éviter des calculs répétitifs et inutiles, les phases de layout apparaissent toujours après l’exécution de notre code. Que ce soit suite à un touch, un appel réseau ou à l’apparition d’un view controller, la modification d’une contrainte affecte la position ou de la taille de la vue associée que de façon ultérieure.

myView.frame.size.height = 30
myView.translatesAutoresizingMaskIntoConstraints = false
myView.frame.size.height == 30 // true
myView.heightAnchor.constraint(equalToConstant: 50).isActive = true
// myView conserve pour le moment sa hauteur définie au dessus.
myView.frame.size.height == 30 // true

S’interroger sur le moment du calcul oblige donc à se poser des questions plus larges sur le fil suivi par l’exécution de notre code dans une application iOS : comment Auto Layout parvient-il à intercaler ses calculs au bon moment et de façon si efficace ?

Pour répondre à ces questions, commençons par le début, surprenons une phase de layout !

1. Surprendre une phase de layout

La méthode layoutSubviews

Apple nous interdit d’appeler la méthode layoutSubviews directement. La bibliothèque UIKit l’exécute pour nous pendant une phase de layout.

Son rôle est de donner à chaque vue de l’écran la bonne taille et position, conformément aux contraintes que nous lui avons attribuées. C’est dans cette méthode que la résolution des équations définies par les contraintes sont résolues.

Lors d’une phase de layout, UIKit appelle successivement, et de haut en bas, layoutSubviews sur chacune des vues qui compose l’arbre de vue courant. A la fin de l’exécution de la chaîne, chaque vue dispose de ses position et taille finale.

Sous-classer layoutSubviews permet d’intervenir pendant les calculs de position des vues et de modifier l’implémentation par défaut. C’est également le moment opportun pour modifier les éléments qui dépendent de la taille de la vue.

override func layoutSubviews() {
    super.layoutSubviews()
    layer.cornerRadius = bounds.height / 2
}
On attend que la vue ait sa taille finale avant de la rendre circulaire

Déclencher explicitement une phase de layout

UIKit n’est pas le seul à pouvoir déclencher le calcul de la position des vues portées à l’écran. La méthode layoutIfNeeded permet de déclencher explicitement une phase de layout à n’importe quel moment. On obtient ainsi la position des vues que l’on manipule pendant l’exécution de notre code :

myView.heightAnchor.constraint(equalToConstant: 50).isActive = true

myView.window?.layoutIfNeeded() // Phase de layout explicite

myView.frame.size.height == 30 // false

Par ailleurs, placée dans un bloc d’animation, layoutIfNeeded permet également d’animer un changement effectué par une contrainte :

sampleViewHeightConstraint.constant = 50

UIView.animate(withDuration: 1) {

    self.view.layoutIfNeeded()

}
Placée dans un bloc d’animation, layoutIfNeeded permet d’animer un changement de layout

En effet, comme une phase de layout déclenche des appels successifs aux méthodes layoutSubviews de chacune des vues de l’écran, la taille et la position des vues de l’écran se retrouvent modifiées dans le bloc d’animation. Les mécanismes d’animation qui s’en suivent sont les mêmes que ceux qui auraient été provoqués si l’on avait modifié la vue de façon explicite :

UIView.animate(withDuration: 1) {
    self.view.frame.size.height = 50
}

Interrompre une phase de layout implicite

Notre premier objectif va être de parvenir à intercepter un appel à layoutSubviews déclenché implicitement par UIKit. On n’a pas en effet besoin de faire un appel à layoutIfNeeded à chaque fois que l’on modifie une contrainte, quelqu’un le faisant pour nous. Si l’on y parvient, il ne restera plus qu’à déterminer le moment auquel se fait cet appel pour répondre à notre question initiale : quand une phase de layout a-t-elle lieu ?

Partons du bout de code suivant relié à deux points d’arrêt A et B :

class SampleView: UIView {


    override func layoutSubviews() {

        super.layoutSubviews() // breakpoint B

        layer.cornerRadius = bounds.height / 2

    }

}



class ViewController: UIViewController {


    @IBOutlet var sampleView: SampleView!
    
@IBOutlet var sampleViewHeightConstraint: NSLayoutConstraint!


    @IBAction func buttonAction(_ sender: Any) {

        sampleViewHeightConstraint.constant = 20 // breakpoint A

    }

}

On a placé un bouton dans un ViewController et on les a reliés par IBAction depuis un xib. On a également ajouté une référence à une contrainte définie par ce même xib.

Un appui sur le bouton plus tard, on déclenche notre premier point d’arrêt au moment où l’on modifie notre contrainte pour invalider la taille courante de notre vue.

Modifier une contrainte permet de programmer une phase de layout

La stack de notre premier point d’arrêt A ressemble à :

#0: Layout ViewController.buttonAction(sender=Any @ 0x00007ffeee5e00b8, self=0x00007ff0a1f03e00) at ViewController.swift:30

#1: Layout @objc ViewController.buttonAction(_:) at ViewController.swift:0

#2: UIKit -[UIApplication sendAction:to:from:forEvent:] + 83

#3: UIKit -[UIControl sendAction:to:forEvent:] + 67

#4: UIKit -[UIControl _sendActionsForEvents:withEvent:] + 450

#5: UIKit -[UIControl touchesEnded:withEvent:] + 580

#6: UIKit -[UIWindow _sendTouchesForEvent:] + 2729

#7: UIKit -[UIWindow sendEvent:] + 4086

#8: UIKit -[UIApplication sendEvent:] + 352

#9: UIKit __dispatchPreprocessedEventFromEventQueue + 2796

#10: UIKit __handleEventQueueInternal + 5949

#11: CoreFoundation __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 17

#12: CoreFoundation __CFRunLoopDoSources0 + 271

#13: CoreFoundation __CFRunLoopRun + 1263

#14: CoreFoundation CFRunLoopRunSpecific + 635

#15: GraphicsServices GSEventRunModal + 62

#16: UIKit UIApplicationMain + 159

#17: Layout main at AppDelegate.swift:12

#18: libdyld.dylib start + 1

#19: libdyld.dylib start + 1

En continuant l’exécution, le point d’arrêt B placé dans le layoutSubviews d’une de nos vues est déclenché. En modifiant la contrainte, UIKit a été obligé de déclencher une phase de layout pour qu’elle s’affiche à la bonne place. Nous sommes en présence d’une phase de layout implicite !

#0: Layout View.layoutSubviews(self=0x00007ff0a1d0ca20) at ViewController.swift:15
#1: Layout @objc View.layoutSubviews() at ViewController.swift:0
#2: UIKit -[UIView(CALayerDelegate) layoutSublayersOfLayer:] + 1515
#3: QuartzCore -[CALayer layoutSublayers] + 177
#4: QuartzCore CA::Layer::layout_if_needed(CA::Transaction*) + 395
#5: QuartzCore CA::Context::commit_transaction(CA::Transaction*) + 343
#6: QuartzCore CA::Transaction::commit() + 568
#7: QuartzCore CA::Transaction::observer_callback(__CFRunLoopObserver*, unsigned long, void*) + 76
#8: CoreFoundation __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 23
#9: CoreFoundation __CFRunLoopDoObservers + 430
#10: CoreFoundation __CFRunLoopRun + 1537
#11: CoreFoundation CFRunLoopRunSpecific + 635
#12: GraphicsServices GSEventRunModal + 62
#13: UIKit UIApplicationMain + 159
#14: Layout main at AppDelegate.swift:12
#15: libdyld.dylib start + 1
#16: libdyld.dylib start + 1

Notre méthode layoutSubviews mise à part, on ne trouve aucune trace de notre code. Cette stack est totalement distincte de celle exécutée au moment de notre appui sur le bouton.

Le but est clair : éviter des calculs inutiles. En s’exécutant sur une stack postérieure et distincte de la nôtre, UIKit s’assure que la résolution des équations a lieu après que toutes les modifications susceptibles d’affecter la position des vues ont été faites.

Mais d’où provient cette stack ? Comment UIKit parvient il à la placer si justement par rapport à la nôtre ?

On pourrait imaginer un scénario plus compliqué, où l’on viendrait modifier une contrainte dans un bloc issu de la méthode dispatchAsync sur la queue principale.

Les causes d’une phase de layout

Les éléments de la stack doivent être analysés pour comprendre son fonctionnement.

Le haut de la stack nous est familière. CALayer.layoutSublayers, par exemple, est une méthode publique depuis iOS 2. Elle est visiblement équivalent à layoutSubviews pour les CALayer : CALayerDelegate est le lien qui unit une UIView à un CALayer. On comprend que le calcul la position de l’un passe donc par le calcul de la position de l’autre.

Pour autant, la partie basse de la stack est celle qui nous intéresse réellement. Elle contient les premiers instants de la phase de layout.

Les premiers instants d’une phase de layout

A l’origine du déclenchement du calcul de la position de notre vue, on trouve la méthode de classe commit d’une mystérieuse CATransaction. En dessous, on aperçoit des méthodes liées à un énigmatique run loop : UIApplicationMain, CFRunLoopRun et CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_PERFORM_FUNCTION.

La run loop est une composante essentielle de toutes les applications iOS. Il est fort à parier que la réussite de notre exploration passe par la compréhension de ces deux éléments. Les étapes de notre exploration sont donc définies :

  • Qu’est ce qu’une CATransaction ?
  • Qu’est ce qu’une run loop ?
  • Comment sont-elles liées ?

2. CATransaction

La CATransaction est peu utilisée explicitement. On en trouve très peu dans nos projets chez FABERNOVEL.

Par exemple, dans une application de vidéo sur demande à destination du grand public :

CATransaction.begin()

collectionView.reloadData()
CATransaction.setCompletionBlock { ... }

CATransaction.commit()

Ou encore, dans application de messagerie :

CATransaction.begin()

CATransaction.setDisableActions(true)

backgroundLayer.fillColor = viewModel.backgroundColor.cgColor

CATransaction.commit()

Quel est le lien entre ces lignes de codes et la méthode commit également présente dans notre phase de layout interrompue ?

Expérimentation

La documentation d’Apple nous apprend que la classe CATransaction a deux méthodes principales : begin et commit. Pour comprendre leur fonctionnement, il faut exploiter une méthode capable de bloquer le main thread pendant un temps donné :

func blockThreadDuringThreeSeconds() {

    let date = Date()

    while -date.timeIntervalSinceNow < 3 {}

}

Les transactions explicites

En temps normal, les modifications faites à l’arbre de vues ne sont pas effectives tout de suite. Elles sont simplement programmées. En effet, si l’on bloque l’exécution de notre code, juste après avoir appliqué une modification à notre arbre, celle-ci n’est pas visible immédiatement :

sampleView.backgroundColor = .red

blockThreadDuringThreeSeconds()
La vue met trois secondes à devenir rouge

En revanche, si l’on entoure notre modification par les méthodes begin et commit de CATransaction, l’écran se rafraîchit immédiatement :

CATransaction.begin()

sampleView.backgroundColor = .red

CATransaction.commit()

blockThreadDuringThreeSeconds()
La vue devient rouge immédiatement

Les transaction implicites

Pour être affichée, toute modification de notre arbre doit être encapsulée par une transaction. D’ailleurs, Apple le déclare dans sa documentation : “Toute modification apportée à l’arbre de vues doit faire partie d’une transaction”.

Dans notre premier exemple, qui se contentait de modifier la couleur d’une vue, où était la transaction ?

Et bien, CoreAnimation en a commencé une pour nous. Il observe chacune des modifications nécessitant un rafraîchissement de l’écran (sans doute avec de la KVO bien placée) et commence une transaction si aucune n’est présente. On qualifie cette transaction d’implicite.

Pour la révéler, on peut utiliser une des caractéristiques des transactions : elles sont emboîtables. Lorsqu’une transaction est imbriquée par une autre, le commit de la transaction “enfant” n’est effectif qu’au commit de la parente.

CATransaction.begin()

sampleView.backgroundColor = .red

CATransaction.begin()

sampleView.backgroundColor = .green

CATransaction.commit() // L'écran ne se rafraîchit pas

blockThreadDuringThreeSeconds()

CATransaction.commit()// L'écran se rafraîchit
La vue ne devient pas rouge immédiatement. Le transfert n’est effectif qu’au commit de la transaction parente.

Par conséquent, si l’on fait deux modifications : l’une, isolée, et l’autre, encadrée par une transaction explicite, on se retrouve dans le même cas que notre premier exemple : l’écran affiche notre modification uniquement au bout de trois secondes :

sampleView.backgroundColor = .red
CATransaction.begin()
sampleView.backgroundColor = .green
CATransaction.commit() // L'écran ne se rafraîchit pas
blockThreadDuringThreeSeconds() // L'écran se rafraîchit dans trois secondes
On obtient le même résultat en créant une transaction parente implicite

Le coupable ? La transaction implicite. Notre transaction se retrouve encapsulée par la transaction implicite créée au sampleView.backgroundColor = .red par CoreAnimation. Les modifications attendent le commit de la transaction implicite appelée après blockThreadDuringThreeSeconds(). Notre vue devient verte qu’après trois secondes.

CATransaction et layout

On peut facilement vérifier que la transaction déclenche une phase de layout au moment de son commit en modifiant quelque peu notre méthode buttonAction :

func buttonAction() {
    CATransaction.begin()
    sampleViewHeightConstraint.constant = 20
    CATransaction.commit()
    sampleView.frame.height == 20 // true
}

Cette méthode action provoque immédiatement un calcul de la position des vues. Elle est, d’une certaine façon, similaire à un layoutIfNeeded :

#0:  0x000000010c584758 Layout View.layoutSubviews(self=0x00007faf8b70e980) at ViewController.swift:15
#1:  0x000000010c584a84 Layout @objc View.layoutSubviews() at ViewController.swift:0
...
#6:  0x000000010d7be79c QuartzCore CA::Transaction::commit() + 568
#7:  0x000000010c585147 Layout ViewController.buttonAction(sender=Any @ 0x00007ffee36790b8, self=0x00007faf8b709d80) at ViewController.swift:3
#8:  0x000000010c5851ac Layout @objc ViewController.buttonAction(_:) at ViewController.swift:0
...
#18: 0x0000000110cccbb1 CoreFoundation __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 17

A bien y réfléchir, c’est assez logique que le commit d’une transaction soit suivi d’une phase de layout. Le rôle d’une transaction est d’envoyer l’état actuel de l’arbre au serveur de rendu. Il paraît naturel qu’elle déclenche le calcul de la position de chacune des vues avant de l’envoyer.

On arrive donc à une premier élément de réponse : s’intéresser au moment de l’exécution de la phase de layout revient à s’interroger sur le moment où le commit de la transaction implicite courante a lieu.

En effet, dans la stack de notre code initial, on trouve bien un appel à un commit de transaction :

CA::Transaction::commit()

Et c’est une transaction implicite ! La modification de la contrainte associée à notre vue circulaire en est l’origine.

CATransaction et completionBlock

Il est possible d’être notifié une fois la transaction terminée. C’est particulièrement intéressant lorsque celle-ci encapsule des animations (cas non traité ici). Le bloc n’est exécuté qu’une fois toutes les animations programmées terminées.

Voici un exemple :

@IBAction func buttonAction(_ sender: Any) {
    sampleView.backgroundColor = .red
    CATransaction.setCompletionBlock {
        // TRANSACTION ENDED
    }
}

Si l’on met un point d’arrêt dans le bloc de fin :

#0: 0x0000000108f918e4 Layout closure #1 in ViewController.buttonAction(_:) at ViewController.swift:30
#1: 0x0000000108f919bd Layout thunk for @escaping @callee_guaranteed () -> () at ViewController.swift:0
...
#4: 0x000000010e8a78cf libdispatch.dylib _dispatch_main_queue_callback_4CF + 628
#5: 0x000000010d6fac99 CoreFoundation __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 9

Ici, en absence d’animation, notre transaction programme immédiatement notre bloc sur la queue principale juste après l’appel à la méthode commit.

Run loop

On a découvert le responsable de la phase de layout : le commit de la transaction implicite courante. Il nous reste donc qu’une dernière question : quand a-t-il lieu ?

La réponse est derrière un concept important sur iOS : la run loop. Trois publications de qualité traitant du sujet peuvent être consultées :

Rôle d’une run loop

La run loop est le mécanisme qui différencie un programme en ligne de commande d’une application interactive.

On peut voir une run loop comme une boucle infinie en attente perpétuelle d’une action. Une fois une action reçue, elle exécute le code programmé associé et se replonge dans un état de veille.

Sur iOS, une run loop est attachée à chaque NSThread. Son rôle est de faire en sorte que celui ci soit occupé quand nécessaire et au repos une fois tout le travail en attente effectué. Le thread principal lance automatiquement sa run loop. Chaque événement, que ce soit un appui sur l’écran, le lancement de l’application ou le retour d’un appel réseau, est traité par le réveil de la run loop principale.

Implémenter une run loop

func postMessage(runloop, message) {
    runloop.queue.pushBack(message)
    runloop.signal()
}

func run(runloop) {
    do {
        runloop.wait()
        message = runloop.queue.popFront()
        dispatch(message)
    } while(true)
}

Ce pseudo d’implémentation code est tiré du lien de Nicolas. Il permet de se représenter un peu mieux ce qu’est une run loop en mettant en valeur ses deux caractéristiques principales :

  • Une run loop attend un message
  • Une run loop dépile les messages reçus (elle peut en recevoir un alors qu’elle est déjà en train d’en propager) et les propage à qui veut bien les recevoir

La run loop sur iOS

Sur iOS, les “messages” sont de deux types :

  • les timers
  • les sources

On trouve des sources de type 1 (Port-Based Sources) et de type 0 (Custom Input Sources). Pour simplifier, on les considérera cependant identiques dans la suite de cet article.

Ces sources correspondent aux événements que peut traiter notre application : un touch, un appel réseau qui se termine etc. Ils sont fournis par le système. Au final, on trouve un nombre fini d’événements susceptibles de réveiller le thread principal. Chacun a été intelligemment attaché à un appel de fonction au prototype bien distinctif :

static void __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__();
static void __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__();
static void __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__();

Les étapes de la run loop

Dans la liste des prototypes ci-dessus on retrouve nos sources, les blocs programmés sur une queue et nos timers. Le seul que nous avons pas encore abordé est l’observateur.

static void __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__();

Il est en effet possible d’observer la run loop et d’être notifié au moment souhaité.

Rob Mayoff, sur son post stackoverflow décrit chacune des étapes effectuées par la run loop :

while (true) {

    Call kCFRunLoopBeforeTimers observer callbacks;

    Call kCFRunLoopBeforeSources observer callbacks;

    Perform blocks queued by CFRunLoopPerformBlock;

    Call the callback of each version 0 CFRunLoopSource that has been signalled;

    if (any version 0 source callbacks were called) {

        Perform blocks newly queued by CFRunLoopPerformBlock;

    }
    if (I didn't drain the main queue on the last iteration

        AND the main queue has any blocks waiting)

    {

        while (main queue has blocks) {

            perform the next block on the main queue

        }

    } else {

        Call kCFRunLoopBeforeWaiting observer callbacks;

        Wait for a CFRunLoopSource to be signalled

          OR for a timer to fire

          OR for a block to be added to the main queue;

        Call kCFRunLoopAfterWaiting observer callbacks;

        if (the event was a timer) {

            call CFRunLoopTimer callbacks for timers that should have fired by now

        } else if (event was a block arriving on the main queue) {

            while (main queue has blocks) {

                perform the next block on the main queue

            }

        } else {

            look up the version 1 CFRunLoopSource for the event

            if (I found a version 1 source) {

                call the source's callback

            }

        }

    }

    Perform blocks queued by CFRunLoopPerformBlock;

}

Ainsi, on retrouve notre représentation d’une run loop comme une simple boucle infinie et effectuant chacun des appels cités plus haut. En particulier, on trouve les différents appels aux observateurs à des moments différents et l’événement kRunLoopBeforeWaiting qui signe la mise en veille de la run loop.

Notre application, au lancement, met en place cette run loop. Elle attend son réveil puis va suivre méthodiquement chacune des étapes décrites au dessus.

CoreAnimation et Run Loop

Quel est le lien avec la CATransaction ?

Comme annoncé par Rob Mayoff, CoreAnimation fait principalement partie des observateurs de la run loop. Il attend l’événement kCFRunLoopBeforeWaiting de la run loop. En procédant ainsi, il s’assure que toutes les modifications faites sur l’arbre ont été programmées. Pour rendre ces modifications visibles à l’écran, il n’a plus qu’à commit la transaction implicite qu’il a commencé au moment où la première modification a été effectuée.

On peut le vérifier en observant à nouveau le bas de nos stacks de nos points d’arrêts A et B de la première partie de l’article.

En A, un touch (une source) a réveillé la run loop et a été propagé jusqu’à déclencher l’action de notre bouton.

#0: Layout ViewController.buttonAction(sender=Any @ 0x00007ffeee5e00b8, self=0x00007ff0a1f03e00) at ViewController.swift:30
#1: Layout @objc ViewController.buttonAction(_:) at ViewController.swift:0
...
#11: CoreFoundation __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 17
…

A cet instant, la modification de la contrainte d’une des vues a obligé CoreAnimation à commencer une transaction implicite.

La boucle a continué son chemin en déclenchant au fur et à mesure ses différents observateurs. CoreAnimation, en observateur habile, a commit la transaction tout justement juste avant que la run loop se mette en veille.

#0: Layout View.layoutSubviews(self=0x00007ff0a1d0ca20) at ViewController.swift:15
#1: Layout @objc View.layoutSubviews() at ViewController.swift:0
...
#6: QuartzCore CA::Transaction::commit() + 568
...
#8: CoreFoundation __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 23
...

On peut généraliser ce comportement en observant le breakpoint symbolique C++:

CA::Context::commit_transaction(CA::Transaction*)
Lors d’un scroll une transaction est commit à chaque frame

Récapitulatif

CoreAnimation transmet les vues manipulées par nos applications à chaque tour de run loop et non à chaque modification. Pour cela, il observe chaque modification faite à chaque vue et initie une CATransaction s’il n’en existe pas déjà une. En tant qu’observateur de la run loop, il commit finalement cette transaction juste avant la mise en veille du thread. L’intérêt est de rassembler toutes les modifications faites depuis le réveil et ne pas rafraîchir l’écran excessivement. Juste avant d’effectuer le transfert, CoreAnimate s’assure que chaque vue est à sa bonne place : il déclenche une phase de layout pour nous !

Les événements d’un UIViewController

Un intérêt de découvrir la run loop et son interaction avec CoreAnimation est d’avoir un nouveau point de vue sur les événements déclencheurs du code de nos applications. Apple a pris soin de nos exposer une API d’un haut niveau et accessible : viewWillAppear, viewDidAppear, viewWillLayoutSubviews etc. On peut dorénavant chercher à leur donner plus de sens.

En mettant des points d’arrêt sur ces méthodes, on découvre que leur déclenchement est étroitement lié aux événements issus des transactions et donc des phases de layout. Bien qu’exposées au niveau des UIViewController, elles traduisent en réalité l’état des vues qui leur sont associées.

viewWillAppear

#0: Layout PresentedViewController.viewWillAppear(animated=true, self=0x00007fafb6602be0) at ViewController.swift:51

#1: Layout @objc PresentedViewController.viewWillAppear(_:) at ViewController.swift:0

...

#6: UIKit _cleanUpAfterCAFlushAndRunDeferredBlocks + 388

#7: UIKit _afterCACommitHandler + 137

#8: CoreFoundation __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 23

Lors de la présentation d’un UIViewController, viewWillAppear est la première méthode appelée par UIKit. Si l’on se réfère au point d’arrêt, elle est appelée dans la même stack que le commit de la transaction implicite et donc qu’une phase de layout. La vue du UIViewController est sur le point d’être transférée. Pour autant, le calcul de sa position n’a pas encore eu lieu. Les méthodes layoutSubviews de nos vues n’ont pas encore été appelées.

viewWillLayoutSubviews & viewDidLayoutSubviews :

#0: Layout PresentedViewController.viewWillLayoutSubviews(self=0x00007fafb6602be0) at ViewController.swift:59

#1: Layout @objc PresentedViewController.viewWillLayoutSubviews() at ViewController.swift:0

...

#6: QuartzCore CA::Transaction::commit() + 568

#7: UIKit _afterCACommitHandler + 272

#8: CoreFoundation __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 23
...

Lors de la présentation d’un UIViewController, viewWillAppear est la première méthode appelée par UIKit. Si l’on se réfère au point d’arrêt, elle est appelée dans la même stack que le commit de la transaction implicite. La vue du UIViewController est sur le point d’être transférée. Pour autant, le calcul de sa position n’a pas encore eu lieu. Les méthodes layoutSubviews de nos vues n’ont pas encore été appelées.

viewWillLayoutSubviews & viewDidLayoutSubviews

#0: Layout PresentedViewController.viewWillLayoutSubviews(self=0x00007fafb6602be0) at ViewController.swift:59

#1: Layout @objc PresentedViewController.viewWillLayoutSubviews() at ViewController.swift:0

...

#6: QuartzCore CA::Transaction::commit() + 568

#7: UIKit _afterCACommitHandler + 272

#8: CoreFoundation __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 23
...

viewWillLayoutSubviews annonce le départ des calculs. Elle est appelée dans la même stack viewWillAppear mais cette fois-ci, le commit a eu lieu (cf #6). Les appels successifs aux méthodes layoutSubviews des vues du UIViewController l’écran sont sur le point de commencer. viewDidLayoutSubviews est appelée juste après.

viewDidAppear

#0: Layout PresentedViewController.viewDidAppear(animated=true, self=0x00007fafb6602be0) at ViewController.swift:55

#1: Layout @objc PresentedViewController.viewDidAppear(_:) at ViewController.swift:0

...

#17: CoreFoundation __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ + 9
...

viewDidAppear est appelée quelques secondes plus tard – le temps que l’animation de présentation se termine. Elle apparaît dans la stack d’un bloc de code envoyé sur le thread principal. Ce bloc a probablement été défini par la méthode setCompletionBlock au début de la présentation du UIViewController. viewDidAppear est donc appelée une fois l’ensemble des animations de présentation transmise par la transaction sont terminées.

Conclusion

On a vu les engrenages principaux du mécanisme responsable du rafraîchissement de l’écran, une transaction, une run loop, CoreAnimation et un modèle d’observateurs.

On ne peut cependant pas prétendre avoir répondu entièrement à la question. On s’élève du monde sensible présenté dans la documentation d’Apple mais la réalité nous échappe. D’une version d’iOS à l’autre, les éléments décrits ci-dessus peuvent changer et beaucoup d’affirmations faites ici ne peuvent être considérées comme complètement valables ; ce sont de simples observations.

On s’est également permis quelques raccourcis. Si vous tentez de mettre le breakpoint C++ donné plus haut, vous risquez de rencontrer des commits effectués par CoreAnimation dans d’autres blocs que ceux déclenchés par un observateur. En particulier, si vous restez bloqué sur un point d’arrêt trop longtemps, CoreAnimation semble déclencher une phase de layout dès la fin de la stack courante.

Il reste beaucoup de questions en suspens :

  • On pourrait creuser plus profondément dans CoreFondation et tenter de comprendre de façon explicite le lien entre un thread et une run loop
  • On aimerait comprendre davantage la différence entre une transaction implicite et explicite. Une transaction implicite est uniquement une transaction explicite pour CoreAnimation ou est-ce qu’elle présente également des caractéristiques intrinsèques différentes ?
  • On aimerait avoir plus de détails sur l’implémentation de CATransaction. Derrière ces quelques méthodes statiques, il semble exister une gestion de l’asynchrone complexe et difficilement assimilable.
  • On s’est contenté d’une description très succincte du lien entre une transaction et des animations. Comment CoreAnimation les gère-t-elles ?

Notre exploration serait-elle vaine ?

On a tout de même réussi à percer la couche, représentée par UIKit, qui cache le coeur d’une application iOS. Les événements envoyés par UIKit, responsables de l’exécution de notre code, nous servent à nous intercaler habilement dans les boucles infinies de la run loop qui régit le rafraîchissement de notre application à l’écran. Comprendre ce mécanisme nous a permis de visualiser les temps forts du cycle de vie d’une application et de saisir l’importance de les respecter scrupuleusement.

Et vous, quel est votre point de vue ?

Echangeons
à lire
API
BACK
DEVOPS
EVENTS
DevFest Nantes 2018 : retour sur la première édition pour FABERNOVEL TECHNOLOGIES

Découvrez ici nos retours sur l’événement incontournable de la région nantaise, DevFest, qui a eu lieu en octobre cette année.

VR
La Réalité Augmentée au service de l’expérience artistique

L'article suivant explique comment le framework Apple pour iOS, ARKit, est utilisé pour co-créer l'œuvre "In Memory of Me", produite grâce à la collaboration entre FABERNOVEL et l' ...