Loupe

Android : Optimiser l'affichage de vos listes en utilisant un RecyclerView

Le SDK propose le composant RecyclerView afin d'afficher de la manière la plus performante possible vos listes de données. Dans cet article, nous verrons comment l'utiliser au mieux d'abord de façon théorique, puis je vous partagerai quelques unes de mes optimisations préférées.

Pour faire plaisir à Clarisse, cet article fait partie d'une série sur l'utilisation du composant RecyclerView dans une application Android.

  1. Cet article.
  2. Android : ajouter des sticky headers sur vos RecyclerView
  3. Ajouter des en-têtes repliables (collapsible headers) sur vos RecyclerView
  4. Suspens

Fonctionnement théorique

Le fonctionnement basique de ce composant est simple : il vous impose les bonnes pratiques concernant la virtualisation d'UI lors de l'affichage d'une liste d'éléments. Partant du principe que le coût en termes de performance se situe au niveau de la création des éléments d'UI (les vues représentant vos éléments et les en-têtes), le RecyclerView va réutiliser un "lot de vues" plutôt que d'en créer pour chaque élément. Le RecyclerView ne manipule pas directement des vues, mais des "ViewHolder" que vous aurez à créer.

Le RecyclerView vous demandera la mise en place d'un adapter de type particulier : un RecyclerView.Adapter (oui les noms sont trèèèssss originaux !). 

En son sein, plusieurs méthodes sont en jeux :

  • ItemCount : retourne le nombre d'éléments à afficher.
  • GetItemViewType : retourne un entier correspondant au type de vue à afficher. La position de l'élément ciblé est passée en paramètre et vous êtes libre d'utiliser ce que vous souhaitez et vous retournez une valeur pour les vues de type "élément" et une une autre valeur pour les vues de type "en-tête" par exemple.
  • OnCreateViewHolder : pour une position donnée, vous devez retourner un objet dérivant de la classe ViewHolder. Le type de vue précédemment calculé est aussi passé en paramètre.
  • OnBindViewHolder : pour une position donnée, vous devez mettre à jour un ViewHolder passé en paramètre pour qu'il reflète l'affichage d'un élément dont l'index est aussi passé en paramètre.

Vous le voyez, il est attendu que vous stockiez vos éléments de façon linéaire à plat et pas forcément dans une arborescence pour représenter vos groupes. Il vous sera peut être nécessaire d'aplatir vos données lorsque vous les donnerez au RecyclerView afin de faciliter les calculs ultérieurs. Dans mon cas, je vais prendre pour exemple l'affichage d'une liste de séries groupées en fonction de leur "état". Je vais donc créer une liste de tuples et jouer sur la nullité des éléments un et/ou deux pour différencier les en-têtes des éléments :

private Tuple<HomeShowGroup, ShowInListViewModel>[] _data;
private void Flatten(
     IList<IGrouping<HomeShowGroup, ShowInListViewModel>> casted)
{
    if (casted == null)
    {
      _data = new Tuple<HomeShowGroup, ShowInListViewModel>[] { };
      return;
    }

    var howMuch = casted.Sum(g => 1 + g.Count);
    var flat = new Tuple<HomeShowGroup, ShowInListViewModel>[howMuch];

    var iterator = 0;
    for (int i = 0; i < casted.Count; i++)
    {
     var group = casted[i];
     flat[iterator] 
       = new Tuple<HomeShowGroup, ShowInListViewModel>(group.Key, null);
     iterator++;

     foreach (var item in group)
     {
      flat[iterator] 
        = new Tuple<HomeShowGroup, ShowInListViewModel>(null, item);
      iterator++;
     }
    }
    _data = flat;
}

Dans tous les cas, il s'agit des seules méthodes à implémenter, le RecyclerView va s'occuper pour vous automatiquement de ne créer et de n'afficher qu'un nombre de vues limité qu'il réutilisera à bon escient. 

Type de vue à afficher - GetItemViewType

Il s'agit ici de la méthode la plus simple : on retourne un entier en fonction du type d'élément. On en a deux, des éléments "classiques" et des en-têtes :

 internal const int ItemAsListViewType = 0;
 internal const int GroupHeaderViewType = 1;
 public override int GetItemViewType(int position)
 {
    if (_data == null || position >= _data.Length || position < 0)
    {
        return ItemAsListViewType;
    }

    return _data[position].Item1 == null 
              ? ItemAsListViewType:GroupHeaderViewType;
}

Création du ViewHolder - OnCreateViewHolder

On va devoir créer ici une classe dérivant de ViewHolder : celle-ci sera manipulée par le RecyclerView et s'occupera de manipuler une vue interne qu'elle générera à sa guise. J'ai pour habitude de créer une seule classe de type ViewHolder par RecyclerView et de ne pas en créer de différents par type de vue dans un recycler, mais c'est un choix personnel. Son initialisation est faite dans le constructeur auquel je passe une vue inflatée.

Voici le code de la méthode OnCreateViewHolder qui n'est rien d'autre qu'un switch :

public override RecyclerView.ViewHolder 
    OnCreateViewHolder(ViewGroup parent, int viewType)
{
    switch (viewType)
    {
    //Episode
    case ItemAsListViewType:
        var convertView = LayoutInflater.From(parent.Context)
        .Inflate(
            Resource.Layout.HomeView_Listitem_MyShowItem, 
            parent, 
            false);
            
        return new HomeListShowViewHolder(convertView);
    
    //Header
    case GroupHeaderViewType:
    default:
        var headerView = LayoutInflater.From(parent.Context)
        .Inflate(
            Resource.Layout.HomeView_Listitem_MyShowHeader, 
            parent, 
            false);
        return new HomeListShowViewHolder(headerView, isHeader: true);
    }
}

Le ViewHolder, l'objet HomeListShowViewHolder que vous pouvez voir dans le code ci-dessus, s'occupe alors de chercher et stocker les différents éléments graphiques à l'intérieur de la vue nouvellement créée. C'est aussi ici que j'en profite pour m'abonner aux différents événements "Click" ou "LongClick".

public class HomeListShowViewHolder 
    : RecyclerView.ViewHolder
{
    public ImageView Image { get; set; }
    public TextView Title { get; set; }

    public HomeListShowViewHolder(View view, bool isHeader = false) 
   : base(view)
    {
     if (isHeader)
     {
         Title = view.FindViewById<TextView>(Resource.Id.HeaderTitle);
         Image = view.FindViewById<ImageView>(Resource.Id.ArrowImage);
         view.Click += (_, __) => HeaderClick();
     }
     else
     {
         Image = view.FindViewById<ImageView>(Resource.Id.PosterImage);
         Title = view.FindViewById<TextView>(Resource.Id.Title); 
         view.Clickable = true;
         view.Click += (_, __) => Click();
         view.LongClickable = true;
         view.LongClick += (_, __) => LongClick();
     }
    }
}

La méthode OnCreateViewHolder sera alors appelée autant de fois que nécessaire par le RecyclerView afin de créer les vues nécessaires pour remplir une hauteur d'écran et un peu plus (afin d'avoir un peu de marge au dessus et en dessous de la zone affichée à un moment t à l'utilisateur).

Remplissage du ViewHolder - OnBindViewHolder

Pour le moment, rien n'est encore affiché à l'écran ! C'est la méthode OnBindViewHolder qui sera appelé par le RecyclerView afin de "personnaliser" chaque ViewHolder pour une position donnée dans la liste. Il faut donc toujours s'attendre à ce qu'il ait déjà été utilisé et bien penser à le nettoyer avant de l'utiliser. Cependant, le ViewHolder réutilisé sera toujours du même type (ViewType) que celui de l'élément à afficher.

Pour ma part, j'aime bien créer une méthode "Fill" sur mes ViewHolders afin de les remplir correctement. Cette méthode prend en paramètre l'élément de donnée à afficher et remplit le ViewHolder correctement. La méthode OnBindViewHolder est alors des plus simples, puisque de surcroit, n'ayant qu'un seul type de ViewHolder, je peux faire un cast direct sur celui qui m'est donné par le RecyclerView :

public override void OnBindViewHolder(
    RecyclerView.ViewHolder holder,
    int position)
{
 if (_data == null || position >= _data.Length || position < 0)
 {
   return ;
 }

  var realHolder = (HomeListShowViewHolder)holder;
  var item = _data[position];
  realHolder.Fill(item);
}

La méthode Fill est alors elle aussi des plus simples car il ne s'agit que de renseigner la vue avec les données passées en paramètre. J'utilise la très bonne librairie Picasso de la société Square (aucun lien de parenté :D ) afin de charger les images. Vous remarquerez que je prends soin d'annuler un potentiel chargement d'image déjà existant et d'assigner un Tag sur le chargement d'image dont nous verrons l'utilité plus tard.

internal void Fill(Tuple<HomeShowGroup, ShowInListViewModel> tuple)
{
    if (tuple == null)
    {
        return;
    }
    var tvShow = tuple.Item2;
    if (tvShow != null)
    {
        tvShow = tvShow;
        Title.Text = tvShow.Title;

        Picasso.With(Image.Context).CancelRequest(Image);
        var pc = Picasso.With(Image.Context)
                 .Load(tvShow.FinalImageUrl)
                 .Tag(nameof(HomeView_ShowsFragment))
                 .Fit().CenterInside()
                .Into(Image);
    }
    // header
    else if (tuple.Item1 != null)
    {
        HeaderItem = tuple.Item1;
        Title.Text = tuple.Item1.Label;
    }
}

Nous avons maintenant branché tout ce qu'il faut pour afficher notre liste d'éléments. Voyons maintenant quelques optimisations possibles.

Optimisation 1 - pré-calculer au maximum les informations

Cela parait peut être évident à dire comme cela mais, s'agissant du parcours d'une liste d'éléments, il est vraiment intéressant d'optimiser au maximum cette partie. Le code précédent parcourt une liste et effectue des calculs pour chaque détermination de type de vue et pour chaque remplissage d'un élément. L'axe d'optimisation consiste donc à calculer cela une (bonne) fois pour toutes dans un ou plusieurs tableaux qui nous permettront d'accéder aux informations nécessaires par accès direct à un élément d'un tableau, opération des plus optimisées. Dans mon cas, il s'agit donc de modifier massivement la méthode "Flatten" déjà décrite pour produire ces tableaux. Le code n'a rien de spécifique au RecyclerView et il serait donc inintéressant de le produire ici mais il est assez simple à imaginer !

Optimisation 2 - Limiter au maximum les chargements lourds pendant le scroll

Lorsque l'utilisateur scrolle à toute vitesse sur votre liste, il n'a pas forcément besoin que toutes les informations lui soient présentées mais préfère généralement avoir un affichage fluide lui permettant d'identifier un élément qu'il rechercherait. Il est possible de mettre en place un tel mécanisme en codant une classe dérivant de la classe du SDK "OnScrollListener". Celle-ci expose une méthode OnScrollStateChanged qu'il faut surcharger et qui est appelée à chaque changement d'état du scroll du RecyclerView. Il est alors simple de savoir si l'utilisateur est en train de scroller ou non afin d'avoir le comportement adéquat.

Dans l'exemple suivi jusqu'ici, par exemple, l'affichage des images est un traitement coûteux (Picasso doit vérifier son cache, décoder l'image si nécessaire et l'afficher) pas forcément des plus pertinents. Picasso permet de geler l'affichage d'images en se basant sur des tags et cela tombe bien, on en a ajouté sur tous nos chargements d'images ! Il est alors possible de créer un ScrollListener tout bête exploitant cette fonctionnalité :

internal class BaseScrollListener : OnScrollListener
{
  private readonly Java.Lang.Object _tagToPause;
  public BaseScrollListener(Java.Lang.Object tagToPause)
  {
    _tagToPause = tagToPause;
  }
  
  public override void OnScrollStateChanged(
     RecyclerView recyclerView, 
     int newState)
  {
    base.OnScrollStateChanged(recyclerView, newState);
    if (newState == ScrollStateIdle)
    {
      // On relance le chargement
      Picasso.With(recyclerView.Context).ResumeTag(_tagToPause);
    }
    else
    {
      // On mets en pause le chargement
      Picasso.With(recyclerView.Context).PauseTag(_tagToPause);
    }
  }
}

Il restera quand même à configurer le RecyclerView afin qu'il exploite cette gestion du scroll. Cela se fait très simplement à l'aide de la méthode AddOnScrollListener :

_recyclerView.AddOnScrollListener(
 new BaseScrollListener(nameof(HomeView_ShowsFragment)));

En scrollant votre RecyclerView vous ne verrez alors les images se charger que lorsque vous resterez sur une position fixe. 

Il serait tout à fait possible d'imaginer un mécanisme similaire pour n'afficher que les titres et n'afficher les contenus complets que sur une pause du défilé de l'utilisateur.

Optimisation 3 - Ne rechargez pas toutes les données à chaque modification

Encore une fois, une pratique qui parait évidente : plutôt que de recréer un objet RecyclerView.Adapter à chaque rechargement de données, il est préférable d'appeler la méthode NotifyDataSetChanged de celui déjà en place car cela évite un rechargement complet. Ainsi, mes RecyclerView.Adapter exposent en général une méthode ReloadData qui effectue ce travail :

internal void UpdateData(
    IList<IGrouping<HomeShowGroup, ShowInListViewModel>> shows)
{
    Flatten(shows);
    NotifyDataSetChanged();
}

Optimisation 4 - ne mettez à jour que les éléments modifiés lors d'un changement

Une autre optimisation consiste à n'indiquer au RecyclerView que les éléments dont des identifiants sont stables mêmes après un rechargement de données. Cela n'est pas toujours le cas et si vous ne respectez pas cette règle, une exception sera levée (et votre appli plantée !). Cela permet au RecyclerView de recharger de manière chirurgicale les différentes vues affichées et changées.

Pour mettre en place ce mécanisme, il faut :

  • Renseigner à true la propriété HasStableId. Pas besoin d'exemple de code, cela est assez parlant et je le fais en général dans le constructeur de mon adapteur.
  • Surcharger la méthode GetItemId pour retourner un identifiant unique pour chaque élément affiché (même les en-têtes !).

Dans notre exemple, le code est alors assez simple à mettre en place :

public override long GetItemId(int position)
{
  var closure = _data;
  if (closure == null || position >= closure.Length || position < 0)
  {
    return base.GetItemId(position);
  }

  Tuple<HomeShowGroup, ShowInListViewModel> tuple = closure[position];
  var infoOnHome = tuple?.Item2;
  if (infoOnHome == null)
  {
    if (tuple?.Item1 != null)
    {
      // identifiant calculé d'après un algorithme savant
      return long.MaxValue - (long)tuple.Item1.ShowGroupType;
    }
    return long.MaxValue - 30;
  }

  // identifiant fonctionnel réél.
  return infoOnHome.IdAsLong;
}

Optimisation 5 - nettoyez vos ViewHolders

Une autre optimisation consiste à bien nettoyer le ViewHolder utilisé lors de leur destruction. Pour cela, l'adapteur du RecyclerView expose une méthode OnViewRecycler que l'on peut surcharger pour effectuer le nettoyage. Dans mon cas, je décharge les images chargées via Picasso :

public override void OnViewRecycled(Java.Lang.Object holder)
{
  if (holder is HomeListShowViewHolder casted 
        && casted.Image != null)
  {
    Picasso.With(casted.Image.Context)
       .CancelRequest(casted.Image);
    casted.Image.SetImageResource(0);
  }
  base.OnViewRecycled(holder);
}

Optimisation 6 - indiquez si vos éléments ne changent pas la taille de votre RecyclerView

Si tous vos éléments n'ont aucun impact sur la taille du RecyclerView : indiquez-le lui ! Cela lui évitera de recalculer la taille de chaque élément et cela aura un effet très positif sur les performances car on sait que le calcul de la taille d'un élément est une opération coûteuse. Au final, il s'agit d'un cas très fréquent car le RecyclerView est souvent la seule vue affichée et prend toute la hauteur disponible sur l'écran. Cela se fait donc en une ligne de code :

_recyclerView.HasFixedSize = true;

 

Happy coding !

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus