Equatable + LinkedHashMap suppression de la réorganisation LRU
Problème
Une classe d'état Flutter bloc/cubit utilise une LinkedHashMap<K, V> ordonnée par insertion pour implémenter la sémantique LRU (accès-touche, éviction du plus ancien passé une limite). Le champ est inclus dans props pour l'égalité des valeurs. Quand une clé existante est réinsérée pour la déplacer vers la plus récente, Cubit.emit abandonne le nouvel état comme égal au précédent, laissant l'ordre LRU obsolète. À l'insertion suivante passant la limite, l'entrée « touchée » est évincée au lieu de la vraiment la plus ancienne.
C'est silencieux : aucune erreur, aucun log. Les tests qui ne vérifient que l'état final de chaque clé passent. Le bug ne fait surface que quand un test affirme spécifiquement qu'une clé rafraîchie survit à une éviction ultérieure de débordement de limite.
Contexte / Conditions de déclenchement
- La classe d'état
extends Equatableavec un champLinkedHashMap<K, V> - Comportement LRU implémenté via
remove(key)+ insertion pour déplacer-vers-plus-récent propsinclut la map directement :List<Object?> get props => [_map, ...]- Symptômes :
blocTest(..., expect: () => hasLength(N))signale moins d'émissions que prévu- Test d'éviction LRU comme « rafraîchir la clé A, puis déborder — s'attendre à l'éviction de B » échoue avec A évincée à la place
- Les consommateurs utilisant
BlocListeneroucontext.selectne réagissent pas aux mises à jour de toucher seul
Solution
Ajoutez la liste des clés à props aux côtés de la map pour que les changements d'ordre d'insertion produisent un état distinct :
@override
List<Object?> get props {
// Les deux entrées sont requises.
//
// La comparaison map par défaut d'Equatable est structurelle (sans ordre), donc
// `_statuses` seul détecte les changements de valeur mais PAS les pur réordonnancements LRU
// où l'ensemble clé/valeur est inchangé.
// `_statuses.keys.toList()` détecte ces changements d'ordre d'insertion.
// Supprimer l'un ou l'autre supprimerait silencieusement toute une classe de mises à jour d'état
// — ne « simplifiez » pas ceci.
return [_statuses, _statuses.keys.toList(), maxEntries];
}
Gardez la map dans props aussi — elle détecte les changements de valeur seule (même clé, valeur différente, pas de réordonnancement). La liste des clés détecte les changements d'ordre seul. Ensemble, ils couvrent les deux dimensions. Supprimer l'un ou l'autre rouvre le bug.
Renforcement supplémentaire (recommandé)
Pendant que vous êtes ici, faites aussi :
-
Copie défensive dans le constructeur s'il accepte une
LinkedHashMapconstruite en externe. Un appelant peut sinon conserver une référence et muter l'état « immuable » :VideoPlaybackStatusState({ this.maxEntries = _defaultMaxEntries, LinkedHashMap<K, V>? statuses, }) : _statuses = statuses == null ? LinkedHashMap<K, V>() : LinkedHashMap<K, V>.from(statuses); -
Court-circuitez les écritures redondantes dans le cubit pour éviter d'allouer une nouvelle map à chaque rapport sans-op (par exemple errorBuilder qui tire chaque frame lors d'une relance) :
void report(K key, V value) { if (state.valueFor(key) == value) return; emit(state.withValue(key, value)); }
Vérification
Écrivez un test explicite d'inégalité des props qui épingle l'invariant directement, plutôt que de compter sur des tests LRU de plus haut niveau pour le détecter transitivement :
test('states with same entries but different LRU order are not equal', () {
final a = MyState()
.withStatus(idA, Status.foo)
.withStatus(idB, Status.bar);
final b = MyState()
.withStatus(idB, Status.bar)
.withStatus(idA, Status.foo);
expect(a, isNot(equals(b)));
});
Test de mutation : revenez temporairement à props: [_map, maxEntries] (sans liste de clés) et exécutez le test. Il DOIT échouer. Restaurez et confirmez le passage. Cela prouve que l'assertion est porteuse de charge et défend l'invariant.
Exemple
Forme complète de la classe d'état (Dart) :
import 'dart:collection';
import 'package:equatable/equatable.dart';
class VideoPlaybackStatusState extends Equatable {
VideoPlaybackStatusState({
this.maxEntries = _defaultMaxEntries,
LinkedHashMap<String, PlaybackStatus>? statuses,
}) : _statuses = statuses == null
? LinkedHashMap<String, PlaybackStatus>()
: LinkedHashMap<String, PlaybackStatus>.from(statuses);
static const int _defaultMaxEntries = 100;
final int maxEntries;
final LinkedHashMap<String, PlaybackStatus> _statuses;
PlaybackStatus statusFor(String id) =>
_statuses[id] ?? PlaybackStatus.ready;
VideoPlaybackStatusState withStatus(String id, PlaybackStatus status) {
final next = LinkedHashMap<String, PlaybackStatus>.from(_statuses)
..remove(id)
..[id] = status;
while (next.length > maxEntries) {
next.remove(next.keys.first);
}
return VideoPlaybackStatusState(maxEntries: maxEntries, statuses: next);
}
@override
List<Object?> get props => [_statuses, _statuses.keys.toList(), maxEntries];
// ^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^
// détecte détecte les réordonnancements
// les changements (touches LRU)
// de valeur
}
Le test LRU qui détecte le bug :
test('reporting same id twice moves it to most-recent', () {
final cubit = VideoPlaybackStatusCubit(maxEntries: 2);
cubit.report(id1, PlaybackStatus.forbidden);
cubit.report(id2, PlaybackStatus.ageRestricted);
cubit.report(id1, PlaybackStatus.forbidden); // refresh id1
cubit.report(id3, PlaybackStatus.notFound); // overflow
// Sans le correctif : id1 est évincée (faux — elle vient d'être touchée)
// Avec le correctif : id2 est évincée (correct — c'est la plus ancienne)
expect(cubit.state.statusFor(id2), PlaybackStatus.ready);
expect(cubit.state.statusFor(id1), PlaybackStatus.forbidden);
});
Notes
- Interaction avec lint Flutter : utiliser un littéral explicite
LinkedHashMap<K, V>()déclencheprefer_collection_literals(le lint veut{}). Mais{}type asMap<K, V>, pasLinkedHashMap, ce qui perd la garantie de compile-time que l'ordre d'insertion est préservé à travers les refactors. Gardez le type explicite et supprimez le lint localement (// ignore: prefer_collection_literals) si le chemin du constructeur le déclenche. - S'applique à toute collection ordonnée dans Equatable :
Queue,SplayTreeMap, ou toute structure où l'ordre est sémantique. Partout où Equatable compare les contenus de la structure sans ordre mais votre code dépend de l'ordre, vous avez besoin d'une entrée props secondaire qui capture l'ordre. - Non spécifique à bloc : le même bug apparaît avec les vérifications
==du state notifier Riverpod et tout diffing basé sur l'égalité. Le même correctif s'applique. - Discipline TDD : ce bug n'a été attrapé que parce qu'un test affirmait spécifiquement « la clé rafraîchie survit au débordement ». Une suite de tests qui ne vérifie que « le statut X correspond à la clé Y » après des écritures individuelles passerait avec le bug intact. Quand vous écrivez des tests pour un état de collection ordonnée, incluez toujours au moins un test qui exerce l'ordonnancement lui-même (pas seulement l'appartenance).
Références
- Package Equatable — sémantique des props
- Dart
LinkedHashMap— garantie d'ordre d'insertion - flutter_bloc — comportement d'égalité de Cubit.emit
- Divine mobile fix :
fix(video_playback_status): add cubit for per-video playback status tracking(commitfb828df33sur la branchefix/moderated-content-filter), où le bug a été attrapé et corrigé.