juin
2014
Passer de AWT/Swing/Java2D vers JavaFX c’est aborder sans s’en rendre compte une toute nouvelle conception de l’UI. On abandonne une UI composée de pixels « morts » ou tout se dessine à grand coup de surcharge de paint() ou de paintComponent() pour une approche entièrement orientée objet : des nœuds vectoriels intégrés à un arbre de rendu, le SceneGraph. En théorie, chaque rectangle ou forme, chaque ligne, chaque zone de texte visible à l’écran peut disposer de ses propres écouteurs pour recevoir la saisie de l’utilisateur via la souris, le clavier ou encore la saisie tactile.
Chaque objet existe individuellement dans l’arbre de rendu et dispose de ses propriétés de positionnement, de transformation, et de configuration d’affichage tant via le code que celles transmisses par les CSS. Seulement voila, conserver autant d’information sur chaque entité visible à l’écran a un coût, non seulement en mémoire mais aussi en performance d’affichage. En théorie également, l’arbre de rendu est sensé être suffisamment intelligent pour savoir comment optimiser les réaffichages en ne dessinant que les zones modifiées par exemples, mais quand cet arbre est trop complexe, et que tout change, les performances ne suivent plus. Et donc du coup, on serait bien heureux de revenir a un gros tas de pixels « morts », certes simplistes, mais ô combien efficace !
Dans mon cas, le problème c’est posé dernièrement en affichant des graphes montrant des résidus (différence) entre des données de pêches observées et prédites. À l’origine, l’affichage n’a rien de complexe : il s’agit d’un simple graphique à bulle (bubble chart), légèrement modifié via les CSS et le code pour changer les apparences des séries et afficher des cercles au lieu d’ellipses… un peu plus de 31500 ellipses…
Générer le graphe, c’est à dire effectuer le calcul à partir des données et peupler les différentes instances de BubbleChart.Data, puis les mettre dans des BubbleChart.Series et enfin créer les deux instances de NumberAxis et le BubbleChart final prend à peu près 6 secondes de traitement. Cela n’est pas très gênant en soit puisqu’on peut se créer un Service qui générera le graphique dans un thread séparé du JavaFX Application Thread et ensuite d’afficher le graphique produit lorsque le callback onSucceeded() est appelé. Cela donne quelque chose comme ça :
@Override
protected Task<Chart> createTask() {
return new Task<Chart>() {
@Override
protected Chart call() throws Exception {
final Chart = ... // Créer le graphique ici
return result;
}
};
}
};
chartLoadingService.setOnSucceeded((final WorkerStateEvent workerStateEvent) -> {
final Chart chart = chartLoadingService.getValue();
root.getChildren().setAll(chart);
});
Pendant ce temps-là on peut même avoir une jolie animation à l’écran comme une barre de progression ou un moniteur d’attente. Jusque là, aucun soucis.
En fait le problème se pose lorsqu’on effectue root.getChildren().setAll(chart);. Soudainement, l’interface graphique ne répond plus du tout et toutes les animations sont stoppées ; pendant 5-15 secondes la fenêtre est figée et ne répond plus à aucune saisie de l’utilisateur. À la longue, Windows ira même jusqu’à afficher un curseur de sablier dessus et à modifier la barre titre pour indiquer que le programme ne répond pas… Bref on se croirait revenu dans Swing lorsqu’on bloque l’EDT par mégarde.
Ce qui se passe ici, c’est que le graphe et ses 31500+ sous-nœuds (sans compter ceux qui composent les axes, les labels, le titre et la grille) sont en train d’être connectés au SceneGraphe. Cela implique entre autres, la gestion du style via les CSS mais aussi la création du skin de chaque noeud (en fait dans l’API telle quelle, chaque cercle dans le graphe est composé d’une région qui est le noeud a proprement parlé et d’une forme qui lui est affectée : une ellipse ; il n’y a donc pas 31500+ nœuds mais le double !) pour chaque Data de la Serie. Cela implique également la mise en place de pas mal d’écouteurs interne pour que le graphe puisse par exemple se mettre à jour lorsque la propriété X, Y ou extra d’une Data change, etc…
Et si l’affichage initial est très lent (et c’est peu dire), les redimensionnements du graphe souffrent également de performances exécrables. Bien évidement l’occupation de la mémoire pour stocker tout ce petit monde explose aussi vite que les performances d’affichage décroissent. Et c’était juste pour un seul graphe… normalement dans cet affichage j’aurai du en avoir environ 25 dans un même ScrollPane !
Passé outre les optimisations évidentes (en s’inspirant de ListView et des cellules, n’afficher que 3 graphiques à l’écran et générer les nouveaux graphiques à afficher lorsqu’on défile vers le haut ou vers le bas), force est de constater que même un seul et unique graphique similaire à celui-là est suffisant pour plomber toute l’utilisation du programme et faire que votre utilisateur revienne vers vous en se plaignant que « c’était mieux avant ! » (puisque pas du tout au courant de toutes les optimisations qu’on a pu être amener à programmer au fil du temps dans la vieille version Swing de cette même appli). C’est typiquement le genre de situation dans lesquelles on pousse un gros « erf.. » et où on se prend soudainement à regretter notre bon vieux tas de pixels « morts » d’antan.
Hé bien c’est possible de le ressusciter figurez-vous !
Bien sûr l’API ne le propose pas de base, mais il est totalement possible de surcharger un graphe et de dessiner son contenu, non plus sous forme de nœuds distincts dans le SceneGraph… mais de pixels « morts » sur une zone de dessin !
Nous allons donc étendre la classe XYChart (classe parente de tous les graphes dans lesquelles les données sont projetées sur deux axes) et faire en sorte que celui-ci contienne un Canvas (une zone de dessin ou encore un canevas) en tant que plotChildren (c’est à dire la liste des nœuds qui sont destinés à afficher les données du graphique). Comme cette surface n’est pas redimensionnable, nous allons devoir en allouer une nouvelle et enlever l’ancienne lorsque le graphique change de taille. De même, de manière à conserver ici une approche simple, nous ne coderons pas d’optimisations au niveau du rendu : si quelques chose change dans les données du graphe, le contenu de l’ancien canevas est entièrement nettoyé et on redessine tout le contenu.
/**
* Creates a new instance.
* @param xAxis The X xAxis.
* @param yAxis The Y xAxis.
*/
public XYCanvasChart(Axis<X> xAxis, Axis<Y> yAxis) {
super(xAxis, yAxis);
setData(FXCollections.observableList(new LinkedList()));
}
@Override
protected void dataItemAdded(Series<X, Y> series, int i, Data<X, Y> data) {
requestChartLayout();
}
@Override
protected void dataItemRemoved(Data<X, Y> data, Series<X, Y> series) {
requestChartLayout();
}
@Override
protected void dataItemChanged(Data<X, Y> data) {
requestChartLayout();
}
@Override
protected void seriesAdded(Series<X, Y> series, int i) {
requestChartLayout();
}
@Override
protected void seriesRemoved(Series<X, Y> series) {
requestChartLayout();
}
/**
* The canvas in which the plot children will be layed out.
*/
private Canvas canvas;
@Override
protected void layoutPlotChildren() {
final NumberAxis xAxis = (NumberAxis) getXAxis();
final NumberAxis yAxis = (NumberAxis) getYAxis();
final double x1 = xAxis.getDisplayPosition(xAxis.getLowerBound());
final double x2 = xAxis.getDisplayPosition(xAxis.getUpperBound());
final double y1 = yAxis.getDisplayPosition(yAxis.getUpperBound());
final double y2 = yAxis.getDisplayPosition(yAxis.getLowerBound());
final double areaWidth = Math.abs(x2 - x1);
final double areaHeight = Math.abs(y2 - y1);
if (areaWidth <= 0 || areaHeight <= 0) {
// Get rid of old canvas (if any).
if (canvas != null) {
getPlotChildren().remove(canvas);
canvas = null;
return;
}
} else if (canvas == null || areaWidth != canvas.getWidth() || areaHeight != canvas.getHeight()) {
// Get rid of old canvas (if any).
if (canvas != null) {
getPlotChildren().remove(canvas);
}
canvas = new Canvas(areaWidth, areaHeight);
canvas.setId("canvas");
getPlotChildren().add(canvas);
}
final GraphicsContext graphics = canvas.getGraphicsContext2D();
graphics.clearRect(0, 0, areaWidth, areaHeight);
impl_drawPlotChildren(graphics);
}
/**
* This method is called whenever the plot elements need to be redrawn on the canvas.
* <br />When this method is called the provided graphics context is blank.
* @param graphics The graphics context in which to draw.
*/
protected abstract void impl_drawPlotChildren(final GraphicsContext graphics);
}
Désormais on a bien un canevas qui couvre toute la surface des données du graphique et il suffit donc d’étendre cette classe et de surcharger la méthode impl_drawPlotChildren() pour pouvoir dessiner dans le canevas en itérant sur les séries et leurs données et en utilisant les methodes de dessin disponibles dans la classe GraphicsContext. C’est à dire en utilisant une manière de faire très semblable, sinon similaire, à ce qu’on avait l’habitude d’utiliser dans AWT/Swing/Java2D ! De plus, nous n’avons pas changé quoi que ce soit au niveau de la manière dont le graphe stocke ses données dans ses instances de Series et Data donc cette classe reste tout à fait compatible avec l’API actuelle de création et de manipulation des graphes.
Une fois codé, le nouveau graphique montre un affichage très proche de celui utilisant la classe BubbleChart de l’API. Désormais nous n’avons plus qu’un seul et unique objet dans le graphique : le canevas au lieu des 2 x 31500+ petits noeuds que nous avions précédemment. Et le meilleur c’est bien sûr que l’affichage est bien plus rapide qu’avec notre manière initiale de faire : le temps de connexion du graphique dans le SceneGraph est instantané et les performances lors de redimensionnent sont grandement améliorées ! L’occupation mémoire n’est pas en reste bien sur.
On notera deux bémols cependant :
- L’affichage n’est pas complètement identique à celui du BubbleChart d’origine. On s’en rend plus facilement compte en mettant les deux graphiques côte à côte :
Même si la différence est ténue, il semble que le rendu effectué par SceneGraph via le pipeline Prism soit plus « fin » que celui de notre rendu dans Canvas en ce qui concerne les cercles de très petites tailles. Donc Prism dispose probablement d’optimisation appliquées lorsqu’il rasterise ses objets vectoriels pour les afficher sur les pixels de l’écran (au final tout est affiché sous forme de pixels sur l’écran et donc il faut convertir les formes vectorielles à un nomment ou à un autre). - En l’état, on a perdu toute possibilité d’utiliser CSS pour changer l’apparence des données de notre graphique. Soit on a les couleurs des séries qui sont codées en dur dans la surcharge de la méthode impl_drawPlotChildren(graphics), soit on a du rajouter des propriétés dans la classe pour permettre de les spécifier via le code de notre appli. Je reviendrai sur cette question plus tard car il est possible de contourner le problème des CSS avec des propriétés stylables…
2 Commentaires + Ajouter un commentaire
Commentaires récents
- Back from the future… dans
- Back from the future… dans
- Static linking = does not Compute dans
- Paquetage x 2 dans
- Why you little… dans
Peupler le graphe est un temps incompressible d’environ 6s. Cela reste le même temps que ce soit avec un BubbleChart normal ou avec ce nouveau graphe, d’ou l’interret de le faire faire par un Service.
Par contre le temps de connexion du graphe au SceneGraph ainsi que le temps d’exécution de la méthode layoutPlotChildren() est désormais négligeable ce qui était quand même le but de la manip.
Tu n’a pas indiqué le temps que prend canevas pour charger les données, en terme de comparaison avec le vectoriel?
Je crois que cet article est une réponse à ce que j’ai posé comme question depuis avant pourquoi laisser le modèle objet vers un modèle de dessins à la Swing/Awt/Java2D