Recherche via un UITextField
Hello !
Voilà mon problème : j'ai un UITextField qui lance une recherche, en fonction de ce qui est tapé. Voilà mon premier jet de code :
- (void)textFieldDidChange:(NSNotification*)aNotification
{
NSString * text = self.tokenSearchField.inputTextField.text ;
if (text.length == 0)
{
[self collapseTheTableView] ;
return;
}
self.patternSearched = text ;
[self filterWithText:text] ;
[self.tableView reloadData] ;
}
Le problème c'est que c'est trop gourmand en ressources en qu'en conséquence, mon clavier répond mal (si je tape vite, toutes les lettres ne sont pas tapées ; par exemple, si je tape vite "bonjour", il rentrera dans le text field "bnjr").
Je pense que je vais donc devoir faire de l'asynchrone.
Avant de me lancer, avez-vous des conseils/pattern/"librairies à aller voir" à me donner ?
Merci !
Connectez-vous ou Inscrivez-vous pour répondre.
Réponses
Tu sais où c'est long exactement ? Avec les instruments ?
J'ai pas testé mais je pense que c'est
(qui parcourt un NSArray)
Je suis en train de monter une solution avec les idées suivantes :
- si le nouveau texte à tester est un sur text du précédent, on peut utiliser le "bout de recherche" précédent
- les recherches sont lancées en async
- quand on lance une nouvelle recherche avant on stoppe la précédente.
C'est difficile de répondre car j'ai pas le souvenir d'avoir eu des ralentissements lors d'une recherche. Je pense que l'idéal pour toi c'est de voir où exactement tu perds du temps dans ta méthode de filtre.
Tu filtres comment d'ailleurs ?
Mon filtre est fou bête : j'itère le long du NSArray et je garde ce qui matche. Je recherche une sous-string dans ses strings.
Après optimisation, c'est pas vraiment mieux. Je vais voir si c'est pas un des composants que j'utilise qui ralentit tout.
EDIT : non cela vient de ma méthode de recherche.
Même après optimisation, si je tape vite, des lettres passent à la trappe.
@Magiic je n'ai encore jamais réussi à utiliser instruments (trop compliqué pour moi). Aurais-tu des conseils/tutos à me donner ?
EDIT :
il semble que cette fois-ci ça ne lague plus. Je vais donc reporter à plus tard l'optimisation (si nécessaire)
Bon sinon dans les idées si tu y reviens, évidemment le mieux est d'optimiser ta méthode de filtrage/recherche, mais si elle reste longue malgré ça (parce que malgré toutes tes optimisations de toute façon il y a un appel à un WebService ou le jeu de données est trop important, etc), le plus classique :
1) Lancer le filtrage en asynchrone évidemment: Attention commandant, ta méthode filterWithText n'est pas stateless (ce n'est pas très "fonctionnel programming tout ça ^^), c'est à dire que je suppose qu'elle va changer l'état interne de ta classe (modifier une @property). Ca peut poser des problèmes et complexifier le debug, en particulier si tu as plusieurs appels à filterWithText en parallèle qui modifient tous en même temps ton objet, surtout si le résultat n'arrive pas dans le même ordre que l'appel...
Je te conseillerai donc plutôt une méthode "-(NSArray*)filteredResultsWithText" qui elle est stateless (ne modifie pas ton objet self, mais retourne le tableau des objets filtrés), quitte à ce qu'ensuite tu fasses un "self.filteredResults = [self filteredResultsWithText]" juste avant de faire ton reloadData.
Ca n'a l'air de rien, en soi ça ne va pas corriger ton risque de désordonnancement de tes résultats, mais c'est déjà plus propre.
2) ne lancer le filtrage que si l'utilisateur a arrêté de tapé pendant X secondes. Pas la peine de lancer un filtrage à chaque nouvelle lettre s'il est en plein en train de taper rapidement un mot complet, autant attendre qu'il ait fini de taper le mot complet. Ca non plus ce n'est pas très dur à implémenter, par exemple (entre autres solutions) avec des choses comme "-performSelector:withObject:afterDelay:" pour ne lancer la méthode de filtrage qu'au bout d'un délai X, et "+cancelPreviousPerformRequestWithTarget:" pour l'annuler. Ainsi, à chaque nouveau caractère dans ton textField tu "performSelector:withObject:AfterDelay:" pour dire "lance moi le filtrage dans 0.3 secondes" par exemple, mais avant tu "+cancelPreviousPerformRequestWithTarget:" pour annuler toute execution du filtrage qui avait été préalablement planifiée, et ainsi si l'utilisateur tape un nouveau caractère dans les 0.3 secondes, ça ne va finalement pas faire le filtrage et attendre de nouveau 0.3s avant de faire le prochain, etc.
@Ali :
-> oui j'ai appliqué YAGNI!
-> merci pour ces bonnes idées, surtout celle du performWithDelay.
-> pour l'instant effectivement, j'ai une recherche qui n'est pas stateless (elle utilise les recherches précédentes). Cependant, avant de lancer une recherche j'annule toujours la précédente. Mais je retiens ce principe de prendre avec des pincettes les statefull methods
Pour info, voici mon code:
Ces lignes là peuvent faire perdre pas mal de temps en fonction du nombre d'objets déjà triés :
Tu peux travailler directement sur ton tableau filteredObjects et noter les index à retirer dans un NSIndexSet pour ensuite les virer tous d'un coup et bénéficier peut-être d'un code optimisé par Apple dans :
Et en tous cas éviter le removeObject qui va tout reparcourir depuis le début et faire isEqual pour chaque objet.
Après tu pourrais aussi te passer complètement du tableau filteredObjects et utiliser un NSIndexSet à la place pour référencer les objets du tableau principal qui correspondent à la recherche, mais je ne suis pas certain que ça vaille le coup, puisque tu utilises déjà des pointeurs.
Sinon, t'as pas des problèmes d'accès concurrents à ton tableau de résultats, là ? Tu le remplis dans une queue de background et tu y accèdes dans la main queue pendant qu'une autre requête peut y accéder. Je pense que tu dois avoir au moins un @synchronize quelque part, sinon tu as potentiellement un problème.
C'est tout le problème du "code stateful" que j'évoquais plus haut, en effet. Il utilise des méthodes qui modifient en live ses propriétés (sans les protéger par un Lock/Mutex), donc gros problèmes d'accès concurrents en perspective, lecture d'un côté pendant que l'autre modifie.
Alors que s'il faisait des méthodes stateless, donc qui ne modifient pas la propriété filteredObjects directement "sur place" dans la méthode, mais retourne un nouveau NSArray des objets filtrés (ou un NSIndexSet, ou autre), il n'y a plus ce risque, puisque la génération du nouveau NSArray ne contenant que les résultats filtrés pourra se faire en background sans affecter filteredObjects, et que filteredObjects ne sera modifié que par le main thread quand il fera "self.filteredObjects = [self filteredResultsWithText:...]" et pas par des tâches en background. Stateless powa.
Une alternative à la proposition de Ali pour "-performSelector:withObject:afterDelay:", est d'utiliser les dispatch_sources.
C'est plus moderne mais moins facile à comprendre et pas forcément plus efficace (à voir). C'est pas YAGNI mais tu vas apprendre quelque chose.
Ton block de recherche sera appelée dès que possible sur la main queue (ou sur une autre queue si c'est préférable dans ton cas),
dès qu'un caractère a été tapé. Mais si plusieurs caractères sont tapés avant que ton block ne soit exécuté. Il ne sera appelé qu'une seule fois.
Par contre il faut mettre en place un mécanisme pour arrêter les blocks précédent. Ce qui n'est pas fait non plus dans ton code car ton cancellAllOperations ne sert à rien si tu ne testes pas [NSOperation isCancelled], ce qui ne semble pas être fait vu que tu ne gardes pas de pointeur vers ton NSOperation.
Sinon l'idée de repartir de là ou tu étais est bonne, en tous cas, j'ai pas trouvé de failles...
Je ne suis pas sûr, dis-moi ce que tu en penses :
1) je commence à remplir self.filteredObjects
2) si une nouvelle recherche est lancée, la précédente recherche est stoppée (peut-être qu'il faudrait que j'ajoute un waitForEndOfTheOperationQueue...)
3) on récupère éventuellement les anciens résultats et on relance une recherche
4) à la fin de la recherche, on appelle reloadData
Je n'ai pas l'impression d'écrire à plusieurs dans self.filteredObjects...
Je n'ai encore jamais utilisé @synchro. Peut-être le moment de m'y mettre !
Je vais tenir compte de vos remarques et connecter tableView à une copie de self.filteredObjects
Merci de vos remarques très instructives !
2) non car cancelAllOperations n'arrête pas les opérations mais leur signale juste qu'elles sont annulées via leur propriété isCancel. Du coup oui il faudrait checker isCancel et mettre un système pour attendre la fin (ou passer en stateless). Et pas la peine de récréer une queue, tu peux juste recréer une NSOperation.
On ne voit pas le code qui affiche les résultats, mais si ce code travaille directement sur self.filteredObjects, c'est encore un autre accès concurrent.
Il faudrait soit travailler sur une copie, soit protéger tous les accès à filteredObjects par des synchronized.
Si tu travailles sur une copie, il faut protéger le moment de la copie par une propriété atomic ou par un synchronized:
Il reprend la recherche à zéro.
Tu veux dire :
- je garde la fonctionnalité de reprise de la recherche précédente
- mais je découple le datasource de la recherche
OU
- je supprime la fonctionnalité de reprise de la recherche précédente. Pour moi, cette fonctionnalité est par nature statefull.
?
Après, si la recherche est lente :
- Soit il y a moyen de l'optimiser, je ne sais pas comment tu l'as codée mais par exemple si tu as une base CoreData, ça peut être intéressant d'activer le mode "indexé" du champ sur lequel tu recherches, pour accélérer fortement les requêtes, et voir aussi quel prédicat tu utilises.
- Soit améliorer l'algo en soit
- Si tu finis par conclure que tu as besoin du mécanisme de reprise de la recherche précédente, effectivement il est stateful par nature, mais tu peux l'isoler dans une classe si besoin et surtout faire en sorte que les modifications d'état soient le plus minimales possibles, en particulier éviter d'avoir comme propriété représentant l'état un NSMutableArray que tu viens modifier en live au fur et à mesure de ton algo de recherche, mais plutôt que ta property lastFoundResults soit un NSArray, et que quand tu commences ton algo tu fasses une copy (ou mutableCopy si besoin) dessus, le filtre, et seulement à la fin que tu affectes sa copy à la propriété.
En Objective-C, on va donc plutôt utiliser filteredArrayWithPredicate par exemple, qui prend un NSArray et t'en retourne un nouveau, plutôt que d'utiliser un NSMutableArray et appeler filterUsingPredicate dessus.
En programmation fonctionnelle (ou le stateless est roi), on va plutôt utiliser des fonctions comme map ou reduce, qui ont un peu la même idée, à savoir retourner un nouvel objet plutôt que de modifier l'objet d'origine, (et ne pas te donner accès à l'éventuel objet mutable intermédiaire qui aura servi pendant l'alto de construction)
En bref, ne pas faire : Ni même ceci qui est un peu mieux mais pas encore parfait : Mais plutôt : Ce ne sont que des exemples d'implémentation, je ne dis pas que c'est LA solution, mais c'est pout te donner une idée. Préférer les NSArray non-mutable surtout pour ceux que tu mets en propriété, et travailler sur des copies lors des itérations. Surtout qu'avec une signature comme filteredResultsWithText qui fait le traitement sur une copie et *retourne* le résultat, si tu exécutes tout ça sur un thread en background ou une @property, vu que rien dans la méthode ne change l'état de ton dataSource mais te retourne juste le nouveau tableau, c'est pas grave que tout ça se fasse en background il n'y a pas de risque de modifier ton objet dans un thread pendant qu'un autre l'utilise. Si ces méthodes sont faites dans un thread séparé c'est pas grave, elles font une copie de ton NSArray au début, donc partent du tableau pré-filtré à l'instant T, et ensuite elles font tout dans leur coin en indépendant sans se soucier de l'état de l'objet (travaillant sur la copie du NSArray). Et le seul moment où tu changes l'état de l'objet finalement c'est au *retour* de la méthode, c'est l'appelant de "filteredResultsWithText:" qui va récupérer ce nouveau tableau et là il pourra alors faire l'affectation de "self.filteredResults = le résultat obtenu", mais il fera certainement cette affectation dans le main thread, ce qui fait que seul le main thread modifiera l'état de ton objet, pas de risque que plusieurs le changent en même temps du coup. Et les threads secondaires faisant les filtrages en parallèle ne travailleront que sur une copie qu'ils auront fait au moment où ils auront démarré, sans risqué d'être ensuite perturbés si l'objet a changé pendant leur traitement, donc ça élimine tout un tas de soucis aussi.
(Andy Matuschak a fait un talk très intéressant sur le sujet expliquant pourquoi le stateful est dangereux, principalement parce qu'on peut modifier l'état de l'objet de l'extérieur et avoir des effets de bord, ne pas être notifié qu'il a changé, etc. mais bon c'est orienté Swift et comment les "struct" et les "let" de Swift peuvent nous aider à mieux faire du stateless)
Au final, même en gardant ton mécanisme de "reprise de la recherche précédente" tu peux faire le *code* de ce filtrage en stateless. Seul le main thread modifiera le tableau des derniers résultats trouvés et sera stateful, mais si c'est concentré uniquement sur le main thread, c'est 100x plus facile à contrôler.
Toujours intéressant tes posts Ali mais comme dirait Wikipedia : Références nécessaires (sur le talk de Andy Matuschak)
https://realm.io/news/andy-matuschak-controlling-complexity/