RecyclerView à vitesse maximale: analyse des bibliothèques


Ilya Nekrasov, Mahtalitet , développeur Android de KODE
Pendant deux ans et demi dans le développement d'Android, j'ai réussi à travailler sur des projets complètement différents: d'un réseau social pour les automobilistes et une banque lettone à un système de bonus fédéral et à la troisième compagnie aérienne pour le transport. Quoi qu'il en soit, dans chacun de ces projets, je suis tombé sur des tâches qui nécessitaient la recherche de solutions non classiques lors de l'implémentation de listes à l'aide de la classe RecyclerView.
Cet article - fruit de la préparation d'une performance au DevFest Kaliningrad'18, ainsi que de la communication avec des collègues - sera particulièrement utile pour les développeurs débutants et ceux qui n'ont utilisé qu'une seule des bibliothèques existantes.


Pour commencer, nous creusons un peu l'essence du problème et la source de la douleur, à savoir la croissance des fonctionnalités dans le développement de l'application et la complexité des listes utilisées.


Chapitre un, dans lequel le client rêve d'une application, et nous - sur des exigences claires


Imaginons une situation où un client contacte un studio qui souhaite une application mobile pour un magasin de canards en caoutchouc.


Le projet se développe rapidement, de nouvelles idées surgissent régulièrement et ne s'inscrivent pas dans une feuille de route à long terme.


Tout d'abord, le client nous demande d'afficher une liste des marchandises existantes et, lorsque vous cliquez dessus, de remplir une demande de livraison. Vous n'avez pas besoin d'aller loin pour trouver une solution: nous utilisons l'ensemble classique de RecyclerView , un simple adaptateur auto-écrit pour lui et Activity .


Pour l'adaptateur, nous utilisons des données homogènes, un ViewHolder et une logique de liaison simple.


Adaptateur avec canards
class DucksClassicAdapter( private val data: List<Duck>, private val onDuckClickAction: (Duck) -> Unit ) : RecyclerView.Adapter<DucksClassicAdapter.ViewHolder>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder { val rubberDuckView = LayoutInflater.from(parent.context).inflate(R.layout.item_rubber_duck, parent, false) return ViewHolder(rubberDuckView) } override fun onBindViewHolder(holder: ViewHolder, position: Int) { val duck = data[position] holder.divider.isVisible = position != 0 holder.rubberDuckImage.apply { Picasso.get() .load(duck.icon) .config(Bitmap.Config.ARGB_4444) .fit() .centerCrop() .noFade() .placeholder(R.drawable.duck_stub) .into(this) } holder.clicksHolder.setOnClickListener { onDuckClickAction.invoke(duck) } } override fun getItemCount() = data.count() class ViewHolder(view: View) : RecyclerView.ViewHolder(view) { val rubberDuckImage: ImageView = view.findViewById(R.id.rubberDuckImage) val clicksHolder: View = view.findViewById(R.id.clicksHolder) val divider: View = view.findViewById(R.id.divider) } } 



Au fil du temps, le client a l'idée d'ajouter une autre catégorie de marchandises aux canards en caoutchouc, ce qui signifie qu'il devra ajouter un nouveau modèle de données et une nouvelle mise en page. Mais plus important encore, un autre ViewType apparaîtra dans l'adaptateur, avec lequel vous pourrez déterminer le ViewHolder à utiliser pour un élément de liste spécifique.


Après cela, des en-têtes sont ajoutés aux catégories, selon lesquels chaque catégorie peut être réduite et développée pour simplifier l'orientation des utilisateurs dans le magasin. Ceci est plus un autre ViewType et ViewHolder pour les en-têtes. De plus, vous devrez compliquer l'adaptateur, car vous devez conserver une liste des groupes ouverts et l'utiliser pour vérifier la nécessité de masquer et d'afficher tel ou tel élément en cliquant sur l'en-tête.


Adaptateur avec tout
 class DucksClassicAdapter( private val onDuckClickAction: (Pair<Duck, Int>) -> Unit, private val onSlipperClickAction: (Duck) -> Unit, private val onAdvertClickAction: (Advert) -> Unit ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { var data: List<Duck> = emptyList() set(value) { field = value internalData = data.groupBy { it.javaClass.kotlin } .flatMap { groupedDucks -> val titleRes = when (groupedDucks.key) { DuckSlipper::class -> R.string.slippers RubberDuck::class -> R.string.rubber_ducks else -> R.string.mixed_ducks } groupedDucks.value.let { listOf(FakeDuck(titleRes, it)).plus(it) } } .toMutableList() duckCountsAdapters = internalData.map { duck -> val rubberDuck = (duck as? RubberDuck) DucksCountAdapter( data = (1..(rubberDuck?.count ?: 1)).map { count -> duck to count }, onCountClickAction = { onDuckClickAction.invoke(it) } ) } } private val advert = DuckMockData.adverts.orEmpty().shuffled().first() private var internalData: MutableList<Duck> = mutableListOf() private var duckCountsAdapters: List<DucksCountAdapter> = emptyList() private var collapsedHeaders: MutableSet<Duck> = hashSetOf() override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { return when (viewType) { VIEW_TYPE_RUBBER_DUCK -> { val view = parent.context.inflate(R.layout.item_rubber_duck, parent) DuckViewHolder(view) } VIEW_TYPE_SLIPPER_DUCK -> { val view = parent.context.inflate(R.layout.item_duck_slipper, parent) SlipperViewHolder(view) } VIEW_TYPE_HEADER -> { val view = parent.context.inflate(R.layout.item_header, parent) HeaderViewHolder(view) } VIEW_TYPE_ADVERT -> { val view = parent.context.inflate(R.layout.item_advert, parent) AdvertViewHolder(view) } else -> throw UnsupportedOperationException("view type $viewType without ViewHolder") } } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { when (holder) { is HeaderViewHolder -> bindHeaderViewHolder(holder, position) is DuckViewHolder -> bindDuckViewHolder(holder, position) is SlipperViewHolder -> bindSlipperViewHolder(holder, position) is AdvertViewHolder -> bindAdvertViewHolder(holder) } } private fun bindAdvertViewHolder(holder: AdvertViewHolder) { holder.advertImage.showIcon(advert.icon) holder.advertTagline.text = advert.tagline holder.itemView.setOnClickListener { onAdvertClickAction.invoke(advert) } } private fun bindHeaderViewHolder(holder: HeaderViewHolder, position: Int) { val item = getItem(position) as FakeDuck holder.clicksHolder.setOnClickListener { changeCollapseState(item, position) } val arrowRes = if (collapsedHeaders.contains(item)) R.drawable.ic_keyboard_arrow_up_black_24dp else R.drawable.ic_keyboard_arrow_down_black_24dp holder.arrow.setImageResource(arrowRes) holder.title.setText(item.titleRes) } private fun changeCollapseState(item: FakeDuck, position: Int) { val isCollapsed = collapsedHeaders.contains(item) if (isCollapsed) { collapsedHeaders.remove(item) } else { collapsedHeaders.add(item) } // 1 to add items after header val startPosition = position + 1 if (isCollapsed) { internalData.addAll(startPosition - ADVERTS_COUNT, item.items) notifyItemRangeInserted(startPosition, item.items.count()) } else { internalData.removeAll(item.items) notifyItemRangeRemoved(startPosition, item.items.count()) } notifyItemChanged(position) } @SuppressLint("SetTextI18n") private fun bindSlipperViewHolder(holder: SlipperViewHolder, position: Int) { val slipper = getItem(position) as DuckSlipper holder.duckSlipperImage.showIcon(slipper.icon) holder.duckSlipperSize.text = ": ${slipper.size}" holder.clicksHolder.setOnClickListener { onSlipperClickAction.invoke(slipper) } } private fun bindDuckViewHolder(holder: DuckViewHolder, position: Int) { val duck = getItem(position) as RubberDuck holder.rubberDuckImage.showIcon(duck.icon) holder.rubberDuckCounts.adapter = duckCountsAdapters[position - ADVERTS_COUNT] val context = holder.itemView.context holder.rubberDuckCounts.layoutManager = LinearLayoutManager(context, HORIZONTAL, false) } override fun getItemViewType(position: Int): Int { if (position == 0) return VIEW_TYPE_ADVERT return when (getItem(position)) { is FakeDuck -> VIEW_TYPE_HEADER is RubberDuck -> VIEW_TYPE_RUBBER_DUCK is DuckSlipper -> VIEW_TYPE_SLIPPER_DUCK else -> throw UnsupportedOperationException("unknown type for $position position") } } private fun getItem(position: Int) = internalData[position - ADVERTS_COUNT] override fun getItemCount() = internalData.count() + ADVERTS_COUNT class DuckViewHolder(view: View) : RecyclerView.ViewHolder(view) { val rubberDuckImage: ImageView = view.findViewById(R.id.rubberDuckImage) val rubberDuckCounts: RecyclerView = view.findViewById(R.id.rubberDuckCounts) } class SlipperViewHolder(view: View) : RecyclerView.ViewHolder(view) { val duckSlipperImage: ImageView = view.findViewById(R.id.duckSlipperImage) val duckSlipperSize: TextView = view.findViewById(R.id.duckSlipperSize) val clicksHolder: View = view.findViewById(R.id.clicksHolder) } class HeaderViewHolder(view: View) : RecyclerView.ViewHolder(view) { val title: TextView = view.findViewById(R.id.headerTitle) val arrow: ImageView = view.findViewById(R.id.headerArrow) val clicksHolder: View = view.findViewById(R.id.clicksHolder) } class AdvertViewHolder(view: View) : RecyclerView.ViewHolder(view) { val advertTagline: TextView = view.findViewById(R.id.advertTagline) val advertImage: ImageView = view.findViewById(R.id.advertImage) } } private class FakeDuck( val titleRes: Int, val items: List<Duck> ) : Duck private fun ImageView.showIcon(icon: String, placeHolderRes: Int = R.drawable.duck_stub) { Picasso.get() .load(icon) .config(Bitmap.Config.ARGB_4444) .fit() .centerCrop() .noFade() .placeholder(placeHolderRes) .into(this) } private class DucksCountAdapter( private val data: List<Pair<Duck, Int>>, private val onCountClickAction: (Pair<Duck, Int>) -> Unit ) : RecyclerView.Adapter<RecyclerView.ViewHolder>() { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val view = parent.context.inflate(R.layout.item_duck_count, parent) return CountViewHolder(view) } override fun getItemCount() = data.count() override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { (holder as CountViewHolder).count.apply { val item = data[position] text = item.second.toString() setOnClickListener { onCountClickAction.invoke(item) } } } class CountViewHolder(view: View) : RecyclerView.ViewHolder(view) { val count: TextView = view.findViewById(R.id.count) } } 

Je pense que vous comprenez le point - un tel tas de petits ressemble à un développement sain. Et à l'avenir, le client a de plus en plus de nouvelles exigences: fixer une bannière publicitaire en haut de la liste, réaliser la possibilité de choisir le nombre de canards commandés. Seules ces tâches se transformeront finalement en adaptateurs réguliers, qui devront à nouveau être écrits à partir de zéro.


Le processus de développement d'un adaptateur classique dans l'histoire de Github

Résumé


En fait, l'image n'est pas du tout encourageante: les adaptateurs individuels doivent être affinés pour des cas spécifiques. Nous comprenons tous que dans une application réelle, il existe des dizaines voire des centaines de ces écrans de liste. Et ils ne contiennent pas d'informations sur les canards, mais des données plus complexes. Oui, et leur conception est beaucoup plus compliquée.


Quel est le problème avec nos adaptateurs?


  • évidemment, ils sont difficiles à réutiliser;
  • à l'intérieur de la logique métier apparaît et au fil du temps elle devient de plus en plus;
  • difficile à entretenir et à développer;
  • risque élevé d'erreurs lors de la mise à jour des données;
  • conception non évidente.

Chapitre deux, où tout pourrait être différent


Il est irréaliste et insensé d'imaginer le développement de l'application pour les années à venir. Après quelques danses avec un tambourin comme dans le dernier chapitre et avoir écrit des dizaines d'adaptateurs, n'importe qui se posera la question «Peut-être y a-t-il d'autres solutions?».



Après avoir terminé Github, nous constatons que la première bibliothèque AdapterDelegates est apparue en 2015, et un an plus tard, Groupie et Epoxy ont ajouté à l'arsenal des développeurs - ils contribuent tous à rendre la vie plus facile, mais chacun a ses propres spécificités et pièges.


Il existe plusieurs autres bibliothèques similaires (par exemple, FastAdapter), mais ni moi ni mes collègues n'avons travaillé avec elles, nous ne les considérerons donc pas dans l'article.

Avant de comparer les bibliothèques, nous analysons brièvement le cas décrit ci-dessus avec la boutique en ligne à condition d'utiliser AdapterDelegates - des bibliothèques désassemblées, c'est la plus simple du point de vue de la mise en œuvre et de l'utilisation internes (cependant, elle n'est pas avancée dans tout, vous devez donc ajouter beaucoup de choses à la main).


La bibliothèque ne nous sauvera pas complètement de l'adaptateur, mais elle sera formée de blocs (briques), que nous pouvons ajouter ou supprimer en toute sécurité de la liste et les échanger.


Adaptateur de bloc
 class DucksDelegatesAdapter : ListDelegationAdapter<List<DisplayableItem>>() { init { delegatesManager.addDelegate(RubberDuckDelegate()) } fun setData(items: List<DisplayableItem>) { this.items = items notifyDataSetChanged() } } private class RubberDuckDelegate : AbsListItemAdapterDelegate<RubberDuckItem, DisplayableItem, RubberDuckDelegate.ViewHolder>() { override fun isForViewType(item: DisplayableItem, items: List<DisplayableItem>, position: Int): Boolean { return item is RubberDuckItem } override fun onCreateViewHolder(parent: ViewGroup): ViewHolder { val item = parent.context.inflate(R.layout.item_rubber_duck, parent, false) return ViewHolder(item) } override fun onBindViewHolder(item: RubberDuckItem, viewHolder: ViewHolder, payloads: List<Any>) { viewHolder.apply { rubberDuckImage.showIcon(item.icon) } } class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { val rubberDuckImage: ImageView = itemView.findViewById(R.id.rubberDuckImage) } } 

Utilisation d'un adaptateur d'activité
 class DucksDelegatesActivity : AppCompatActivity() { private lateinit var ducksList: RecyclerView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_ducks_delegates) ducksList = findViewById(R.id.duckList) ducksList.apply { layoutManager = LinearLayoutManager(this@DucksDelegatesActivity) adapter = createAdapter().apply { showData() } } } fun createAdapter(): DucksDelegatesAdapter { return DucksDelegatesAdapter() } private fun DucksDelegatesAdapter.showData() { setData(getRubberDucks()) } private fun getRubberDucks(): List<DisplayableItem> { return DuckMockData.ducks.orEmpty().map { RubberDuckItem(it.icon) } } } 

Dès la première tâche, nous voyons la différence: nous avons une classe d'adaptateur qui hérite de la bibliothèque. Et en plus - la même brique, qui s'appelle le délégué et dont nous héritons et mettons également en œuvre une partie de la logique dont nous avons besoin. Ensuite, nous ajoutons un délégué au gestionnaire - c'est également une classe de bibliothèque. Et la dernière chose dont vous avez besoin est de créer un adaptateur et de le remplir de données


Pour implémenter la deuxième catégorie du magasin et les en-têtes, nous écrirons quelques délégués supplémentaires, et l'animation apparaîtra grâce à la classe DiffUtil .


Ici, je vais indiquer une conclusion brève mais définitive: l'utilisation même de cette bibliothèque résout tous les problèmes énumérés qui se sont posés lorsque nous avons compliqué l'application dans un cas avec une boutique en ligne, mais sans les inconvénients, nulle part ailleurs.


Processus de développement de l'adaptateur avec AdapterDelegates dans l'historique Github

Chapitre trois, dans lequel le développeur supprime les lunettes roses en comparant les bibliothèques


Nous approfondirons les fonctionnalités et le fonctionnement de chacune des bibliothèques. D'une manière ou d'une autre, j'ai utilisé les trois bibliothèques sur nos projets, en fonction des tâches et de la complexité de l'application.


Adapterdelegates


Nous utilisons cette bibliothèque dans l'application de l'une des plus grandes compagnies aériennes russes. Nous devions remplacer une simple liste de paiement par une liste avec des groupes et un grand nombre de paramètres différents.


Un schéma de travail de bibliothèque simplifié ressemble à ceci:


La classe principale est le DelegateAdapter , les différentes «briques» sont les «délégués» qui sont chargés d'afficher un type de données particulier et, bien sûr, la liste elle-même.


Avantages:


  • simplicité d'immersion;
  • adaptateurs faciles à réutiliser;
  • peu de méthodes et de classes;
  • aucune réflexion, génération de code et liaison de données.

Inconvénients:


  • vous devez implémenter la logique vous-même, par exemple mise à jour des éléments via DiffUti l (depuis la version 3.1.0, vous pouvez utiliser l'adaptateur AsyncListDifferDeferationAdapter);
  • code redondant.

En général, cette bibliothèque résout toutes les principales difficultés d'élargissement des fonctionnalités de l'application et convient à ceux qui ne l'ont pas utilisée auparavant. Mais pour ne m'arrêter que dessus, je ne conseille pas.

Groupie


Nous utilisons souvent la Groupie , créée il y a plusieurs années par Lisa Wray , notamment pour rédiger la demande d'une banque lettone qui l'utilise complètement.


Pour utiliser cette bibliothèque, vous devez d'abord gérer les dépendances . En plus de la principale, vous pouvez choisir parmi plusieurs options:



Nous nous attardons sur une chose et prescrivons les dépendances nécessaires.


À l'aide d'un exemple de boutique en ligne avec des canards, nous devons créer un élément hérité de la classe de bibliothèque, spécifier la mise en page et implémenter la liaison via la syntaxe Kotlin. Comparé à la quantité de code que j'ai dû écrire avec AdapterDelegates , c'est juste le paradis et la terre.


Il ne reste plus qu'à définir RecyclerView GroupieAdapter en tant qu'adaptateur et à y placer les éléments simulés .



On peut voir que le schéma de travail est plus vaste et plus complexe. Ici, en plus des éléments simples, vous pouvez utiliser des sections entières - des groupes d'éléments et d'autres classes.


Avantages:


  • interface intuitive, bien que l'api vous fasse réfléchir;
  • la présence de solutions en boîte;
  • ventilation en groupes d'éléments;
  • le choix entre l'option habituelle, Kotlin Extensions et DataBinding;
  • incorporant ItemDecoration et animations.

Inconvénients:


  • wiki incomplet;
  • soutien insuffisant du mainteneur et de la communauté;
  • petits bugs qui devaient être contournés dans la première version;
  • diffèrent dans le thread principal (pour l'instant);
  • il n'y a pas de support pour AndroidX (pour le moment, mais vous devez garder une trace du référentiel).

Il est important que Groupie, avec tous ses inconvénients, puisse facilement remplacer AdapterDelegates , surtout si vous prévoyez de créer des listes de pliage de premier niveau et que vous ne souhaitez pas écrire beaucoup de passe-partout.


Implémentation de la liste des canards avec Groupie

Époxy


La dernière bibliothèque que nous avons commencé à utiliser relativement récemment est Epoxy , développée par les gars d' Airbnb . La bibliothèque est complexe, mais vous permet de résoudre toute une gamme de tâches. Les programmeurs Airbnb l'utilisent eux-mêmes pour rendre les écrans directement depuis le serveur. L'époxy a été utile sur l'un de nos derniers projets - une demande pour une banque à Iekaterinbourg.


Pour développer des écrans, nous avons dû travailler avec différents types de données, un grand nombre de listes. Et l'un des écrans était vraiment sans fin. Et l' époxy nous a tous aidés à gérer cela.


Le principe de la bibliothèque dans son ensemble est similaire aux deux précédents, sauf qu'au lieu d'un adaptateur, EpoxyController est utilisé pour construire la liste, ce qui vous permet de déterminer de manière déclarative la structure de l'adaptateur.



Pour ce faire, la bibliothèque est basée sur la génération de code. Comment cela fonctionne - avec toutes les nuances, il est bien décrit dans le wiki et se reflète dans les échantillons .


Avantages:


  • modèles de liste, générés à partir de la vue habituelle, avec possibilité de réutilisation dans des écrans simples;
  • description déclarative des écrans;
  • Liaison de données à vitesse maximale - génère des modèles directement à partir de fichiers de mise en page;
  • afficher simplement non seulement les listes des blocs, mais aussi les écrans complexes;
  • ViewPool partagé sur l'activité;
  • Différence asynchrone prête à l'emploi (AsyncEpoxyController);
  • pas besoin de vapeur avec des listes horizontales.

Inconvénients:


  • tas de classes, processeurs, annotations;
  • plongée difficile à partir de zéro;
  • utilise le plugin ButterKnife pour générer des fichiers R2 dans les modules;
  • il est très difficile de comprendre comment fonctionner correctement avec les rappels (nous ne comprenions pas encore nous-mêmes);
  • Il y a des problèmes à contourner: par exemple, un crash avec le même identifiant.

Implémentation de la liste de canards avec Epoxy

Résumé


La principale chose que je voulais transmettre était: ne supportez pas la complexité qui apparaît lorsque vous devez créer des listes complexes et que vous devez constamment les refaire. Et cela arrive très souvent. Et en principe, lors de leur mise en œuvre, si le projet ne fait que commencer, ou si vous êtes engagé dans sa refactorisation.


La réalité est qu’il n’est pas nécessaire de compliquer la logique une fois de plus, en pensant qu’une sorte d’abstraction suffira. Ils sont assez courts Et travailler avec eux n'est pas seulement un plaisir, mais il y a aussi la tentation de transférer une partie de la logique vers la partie UI, qui ne devrait pas être là. Il existe des outils qui peuvent vous aider à éviter la plupart des problèmes et vous devez les utiliser.


Je comprends que pour de nombreux développeurs expérimentés (et pas seulement), cela est évident ou ils peuvent être en désaccord avec moi. Mais je considère qu'il est important de le souligner à nouveau.

Alors, que choisir?


Conseiller de rester dans une bibliothèque est assez difficile, car le choix dépend de nombreux facteurs: des préférences personnelles à l'idéologie du projet.


Je ferais ce qui suit:


  1. Si vous commencez simplement votre chemin dans le développement, essayez de démarrer un petit projet avec AdapterDelegates - c'est la bibliothèque la plus simple - aucune connaissance particulière n'est requise. Vous comprendrez comment l'utiliser et pourquoi il est plus pratique que d'écrire des adaptateurs vous-même.
  2. Groupie convient à ceux qui ont déjà assez joué avec AdapterDelegates et qui en ont assez d'écrire un tas de passe-partout, ou à tous ceux qui veulent immédiatement commencer par un terrain d'entente. Et n'oubliez pas la présence de groupes pliants hors de la boîte - c'est aussi un bon argument en sa faveur.
  3. Eh bien, et Epoxy - pour ceux qui sont confrontés à un projet vraiment complexe, avec une énorme quantité de données, donc la complexité avec une grosse bibliothèque sera moins un problème. Au début, ce sera difficile, mais la mise en œuvre des listes semblera comme une bagatelle. Un argument important en faveur de l'époxy peut être la présence de DataBinding et MVVM sur le projet - il a été littéralement créé pour cela, étant donné la possibilité de générer des modèles à partir des dispositions correspondantes.

Si vous avez encore des questions, vous pouvez consulter le lien pour voir à nouveau le code de notre application avec les détails.

Source: https://habr.com/ru/post/fr428525/


All Articles