Les threads et le blocage des écrans

Comme je l'indiquais dans le premier article de cette série sur l'utilisation de processus, il ne faut jamais bloquer l'écran de l'utilisateur. Lorsqu'on a besoin de faire un long calcul suite à un clic et d'afficher le résultat il y a bien heureusement des solutions. Je vous en présente trois dont celle que j'utilise quasiment systématiquement.

Le cas traité ici est simple : l'utilisateur clique sur un bouton et a besoin de consulter le résultat calculé.

En guise de calcul je me contente dans cet exemple d'une attente de 5 secondes et le résultat est une chaine de caractères.

Une barre de progression est affichée en haut de la fenêtre. Un tFloatAnimation permet de l'animer sans fin. Cette animation permet de voir si l'écran est bloqué. Et comme vous pourrez le constater, ça a son utilité.

Comme premier exemple, je vous propose la solution foireuse suivante avec le bouton btnThreadClic :

procedure TForm1.btnThreadClick(Sender: TObject);
var
  Resultat: string;
begin
  Memo1.Lines.Add('btnThreadClick démarrée');
  application.ProcessMessages;
  // lance le calcul en tâche de fond
  tthread.CreateAnonymousThread(
    procedure
    begin
      sleep(5000);
      Resultat := 'Hello World btnThreadClick';
    end).Start;
  // affiche la variable avant la fin du calcul
  Memo1.Lines.Add('btnThreadClick résultat : ' + Resultat);
  application.ProcessMessages;
  Memo1.Lines.Add('btnThreadClick terminée');
  application.ProcessMessages;
end;

Pour la démonstration dont la vidéo se trouve plus bas j'ai ajouté des envois de messages dans un tMemo.

Cet exemple fait partiellement le job : il ne bloque pas l'utilisateur pendant les 5 secondes "du calcul", mais le hic, c'est qu'il ne retourne pas la valeur avant que le calcul se soit achevé. Lorsqu'elle est affichée la variable "Resultat" est vide.

C'est pourtant l'utilisation de base des processus, comme j'en ai déjà parlé, mais le côté asynchrone ne fait pas l'affaire dans le cas qui nous intéresse.

Il faut donc un traitement qui bloque l'utilisateur sans bloquer l'utilisateur. Ca nécessite de ruser un peu.

L'une des solutions disponibles dans Delphi est l'utilisation de l'interface iFuture. Elle fonctionne comme une fonction mais exécute son code en tâche de fond dès son affectation.

Voici le code source que je vous propose pour ce bouton btnFutureClick :

procedure TForm1.btnFutureClick(Sender: TObject);
var
  ChaineCalculee: IFuture<string>;
  Resultat: string;
begin
  Memo1.Lines.Add('btnFutureClick démarrée');
  application.ProcessMessages;
  // lance le thread et effectue le calcul demandé
  ChaineCalculee := ttask.Future<string>(
    function: string
    begin
      sleep(5000);
      Result := 'Hello World btnFutureClick';
    end);
  // traitement normal
  Memo1.Lines.Add('btnFutureClick calcul lancé');
  application.ProcessMessages;
  // bloque le processus en cours jusqu'à avoir la réponse
  Resultat := ChaineCalculee.Value;
  // reprend son traitement normal
  Memo1.Lines.Add('btnFutureClick résultat : ' + Resultat);
  application.ProcessMessages;
  Memo1.Lines.Add('btnFutureClick terminée');
  application.ProcessMessages;
end;

On commence par déclarer une variable qui servira à exécuter le calcul et retournera sa réponse. C'est une variable de type iFuture<T> (dans mon cas T= string).

Vient ensuite le codage de son exécution.

  ChaineCalculee := ttask.Future<string>(
    function: string
    begin
      sleep(5000);
      Result := 'Hello World btnFutureClick';
    end);

C'est maintenant une syntaxe plutôt classique avec la création de la zone mémoire nécessaire mais aussi la spécification du code à exécuter sous forme de fonction anonyme. La fonction renvoi une chaîne qui est retransférée dans ChaineCalculer.Value à la fin du processus de calcul. Donc là, 5 secondes après passage à ce niveau du programme.

On peut ensuite utiliser cette valeur n'importe où dans le code.

L'avantage de cette méthode est que si le calcul n'est pas terminé, l'utilisation de la valeur résultante bloque jusqu'à ce qu'elle soit calculée. Mais c'est aussi l'inconvénient car le blocage du programme interrompt le processus dans lequel on est.

Vous le verrez bien sur la vidéo : la barre de progression arrête de bouger le temps d'exécution de ce calcul.

C'est la raison pour laquelle j'utilise peu les variables de type iFuture. Je n'ai jamais eu de cas dans lequel j'ai besoin d'une valeur calculée sur une seule variable et dont l'utilisation devait se faire tout de suite (donc bloquant) ou plus tard (non bloquant si le calcul s'est bien lancé).

La dernière solution est ma préférée du lot : on bloque l'écran soit en totalité soit uniquement sur la zone que l'on désire modifier, le reste continuant à fonctionner. C'est l'exemple suivant.

J'ai donc un bouton btnAniIndicator sur ma fenêtre. Il sert à déclencher le calcul, se désactive le temps du calcul, affiche une animation et traite le résultat calculé.

procedure TForm1.btnAniIndicatorClick(Sender: TObject);
begin
  Memo1.Lines.Add('btnAniIndicatorClick démarrée');
  application.ProcessMessages;
  btnAniIndicator.Enabled := false;
  CalculLaChaineUserFriendly(btnAniIndicator,
    procedure(Resultat: string)
    begin
      Memo1.Lines.Add('btnAniIndicatorClick résultat : ' + Resultat);
      application.ProcessMessages;
      btnAniIndicator.Enabled := true;
    end);
  Memo1.Lines.Add('btnAniIndicatorClick terminée');
  application.ProcessMessages;
end;

Pourquoi désactiver le bouton ?

Tout simplement pour éviter que l'utilisateur ne clique plusieurs fois dessus. L'animation d'attente intercepte bien les événements de toucher et de souris grâce à son hitTest à True par défaut, mais rien n'empêche d'y aller avec le clavier (tabulation pour se déplacer, espace ou entrée pour appuyer sur le bouton). Donc autant tout bloquer grâce à la propriété Enabled du tButton.

Une fois désactivé, le onclick appelle une autre méthode de la classe à laquelle il passe le bouton (qui recevra une animation d'attente) et le bloc de code à exécuter une fois que le calcul sera terminé. Ne pas oublier dans cette partie de réactiver le bouton si on doit pouvoir s'en servir plusieurs fois.

La méthode ainsi appelée créera l'animation d'attente, le processus pour le calcul et exécutera la procédure de fin de traitement.

procedure TForm1.CalculLaChaineUserFriendly(Parent: TFmxObject;
FinTraitement: tproc<string>);
var
  logoAttente: taniindicator;
begin
  Memo1.Lines.Add('CalculLaChaineUserFriendly démarrée');
  application.ProcessMessages;
  logoAttente := taniindicator.Create(Parent);
  try
    logoAttente.Parent := Parent;
    logoAttente.Align := TAlignLayout.Client;
    logoAttente.Enabled := true;
    tthread.CreateAnonymousThread(
      procedure
      var
        Resultat: string;
      begin
        sleep(5000);
        Resultat := 'Hello World CalculLaChaineUserFriendly';
        tthread.Synchronize(nil,
          procedure
          begin
            logoAttente.Free;
            if assigned(FinTraitement) then
              FinTraitement(Resultat);
          end);
      end).Start;
  except
    logoAttente.Free;
  end;
  Memo1.Lines.Add('CalculLaChaineUserFriendly terminée');
  application.ProcessMessages;
end;

J'utilise le composant tAniIndicator qui s'adapte en fonction de la plateforme sur laquelle le programme s'exécute. Il génère une animation d'attente popularisée par les sites Internet utilisant Ajax pour remplir les pages web. Cette animation est maintenant aussi utilisée par les systèmes d'exploitation pour indiquer que le programme en cours travaille. Autant s'en servir pour la même raison.

L'animation est donc créée sous forme de composant associé au bouton passé en paramètre. Elle en occupe toute la surface grâce au changement d'alignement.

La suppression de l'objet se fait dans deux cas :

  • le premier en cas d'erreur dans la procédure. Il y a ici peu de risque, mais prendre de bonnes habitudes ne peut pas nuire.
  • le second cas est plus standard. On supprime l'animation lorsqu'on n'en a plus besoin, tout simplement.

Une fois l'animation lancée, on crée un processus anonyme (que l'on pense à lancer avec son Start). C'est lui qui fera le calcul demandé.

En fin de calcul (donc attente de 5 secondes puis affectation du bon vieil "Hello World") on resynchronise ce processus avec le processus principal de l'application pour désactiver l'animation et appeler la procédure de fin de traitement si elle a été transmise.

En utilisant cette technique l'utilisateur voit que le programme tourne. Il peut continuer à utiliser les autres fonctionnalités ne dépendant pas du calcul lancé. De plus le système d'exploitation ne voit pas d'application figée et n'envoi donc pas d'alerte à l'utilisateur lui demandant s'il veut laisser tourner le programme.

Le fait de passer des procédures anonymes à des procédures exécutant des threads est très pratique. Ca sert énormément lorsqu'on travaille avec des API web pour ne pas bloquer l'utilisateur mais rafraîchir quand même des zones d'écran, notamment pour des jeux vidéo en réseau, de la messagerie ou des réseaux sociaux.

Je ne vous conseille pas d'en abuser pour des raisons de maintenabilité du code (et de débogage plus complexe à faire), mais allez-y franchement quand c'est nécessaire une fois que votre code est robuste et testé.

Pour finir voici ce que donne le programme réalisé pour cet exemple.


Mug Toucan DX dans la baie de RioMug carte postale Sydney