Les 10 erreurs les plus courantes que les développeurs iOS ne savent pas qu'ils font
Publié: 2022-03-11Quelle est la seule chose pire que d'avoir une application boguée rejetée par l'App Store ? Le faire accepter. Une fois que les critiques à une étoile commencent à arriver, il est presque impossible de récupérer. Cela coûte de l'argent aux entreprises et aux développeurs leur travail.
iOS est désormais le deuxième plus grand système d'exploitation mobile au monde. Il a également un taux d'adoption très élevé, avec plus de 85% d'utilisateurs sur la dernière version. Comme vous vous en doutez, les utilisateurs très engagés ont des attentes élevées. Si votre application ou votre mise à jour n'est pas parfaite, vous en entendrez parler.
La demande de développeurs iOS continuant de monter en flèche, de nombreux ingénieurs se sont tournés vers le développement mobile (plus de 1 000 nouvelles applications sont soumises à Apple chaque jour). Mais la véritable expertise iOS s'étend bien au-delà du codage de base. Vous trouverez ci-dessous 10 erreurs courantes dont les développeurs iOS sont la proie et comment vous pouvez les éviter.
Erreur courante n° 1 : ne pas comprendre les processus asynchrones
Un type d'erreur très courant chez les nouveaux programmeurs est la mauvaise gestion du code asynchrone. Considérons un scénario typique : Un utilisateur ouvre un écran avec la vue tableau. Certaines données sont extraites du serveur et affichées dans une vue tableau. On peut l'écrire plus formellement :
@property (nonatomic, strong) NSArray *dataFromServer; - (void)viewDidLoad { __weak __typeof(self) weakSelf = self; [[ApiManager shared] latestDataWithCompletionBlock:^(NSArray *newData, NSError *error){ weakSelf.dataFromServer = newData; // 1 }]; [self.tableView reloadData]; // 2 } // and other data source delegate methods - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return self.dataFromServer.count; }
À première vue, tout semble correct : nous récupérons les données du serveur, puis mettons à jour l'interface utilisateur. Cependant, le problème est que la récupération des données est un processus asynchrone et ne renverra pas de nouvelles données immédiatement, ce qui signifie que reloadData
sera appelé avant de recevoir les nouvelles données. Pour corriger cette erreur, nous devons déplacer la ligne #2 juste après la ligne #1 à l'intérieur du bloc.
@property (nonatomic, strong) NSArray *dataFromServer; - (void)viewDidLoad { __weak __typeof(self) weakSelf = self; [[ApiManager shared] latestDataWithCompletionBlock:^(NSArray *newData, NSError *error){ weakSelf.dataFromServer = newData; // 1 [weakSelf.tableView reloadData]; // 2 }]; } // and other data source delegate methods - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { return self.dataFromServer.count; }
Cependant, il peut y avoir des situations où ce code ne se comporte toujours pas comme prévu, ce qui nous amène à…
Erreur courante n° 2 : exécuter du code lié à l'interface utilisateur sur un thread autre que la file d'attente principale
Imaginons que nous avons utilisé un exemple de code corrigé de l'erreur courante précédente, mais que notre vue de table n'est toujours pas mise à jour avec les nouvelles données même après la réussite du processus asynchrone. Qu'est-ce qui ne va pas avec un code aussi simple ? Pour le comprendre, nous pouvons placer un point d'arrêt à l'intérieur du bloc et savoir sur quelle file d'attente ce bloc est appelé. Il y a de fortes chances que le comportement décrit se produise car notre appel n'est pas dans la file d'attente principale, où tout le code lié à l'interface utilisateur doit être exécuté.
Les bibliothèques les plus populaires, telles qu'Alamofire, AFNetworking et Haneke, sont conçues pour appeler completionBlock
dans la file d'attente principale après avoir effectué une tâche asynchrone. Cependant, vous ne pouvez pas toujours vous y fier et il est facile d'oublier d'envoyer votre code dans la bonne file d'attente.
Pour vous assurer que tout votre code lié à l'interface utilisateur se trouve dans la file d'attente principale, n'oubliez pas de le répartir dans cette file d'attente :
dispatch_async(dispatch_get_main_queue(), ^{ [self.tableView reloadData]; });
Erreur courante n° 3 : incompréhension de la concurrence et du multithreading
La simultanéité pourrait être comparée à un couteau vraiment tranchant : vous pouvez facilement vous couper si vous n'êtes pas assez prudent ou expérimenté, mais c'est extrêmement utile et efficace une fois que vous savez comment l'utiliser correctement et en toute sécurité.
Vous pouvez essayer d'éviter d'utiliser la simultanéité, mais quel que soit le type d'applications que vous construisez, il y a de fortes chances que vous ne puissiez pas vous en passer. La simultanéité peut avoir des avantages significatifs pour votre application. Notamment :
- Presque toutes les applications appellent des services Web (par exemple, pour effectuer des calculs lourds ou lire des données à partir d'une base de données). Si ces tâches sont effectuées sur la file d'attente principale, l'application se fige pendant un certain temps, ce qui la rend non réactive. De plus, si cela prend trop de temps, iOS fermera complètement l'application. Le déplacement de ces tâches vers une autre file d'attente permet à l'utilisateur de continuer à utiliser l'application pendant l'exécution de l'opération sans que l'application ne semble se figer.
- Les appareils iOS modernes ont plus d'un cœur, alors pourquoi l'utilisateur devrait-il attendre que les tâches se terminent séquentiellement alors qu'elles peuvent être exécutées en parallèle ?
Mais les avantages de la simultanéité ne vont pas sans complexité et sans la possibilité d'introduire des bugs épineux, tels que des conditions de concurrence très difficiles à reproduire.
Considérons quelques exemples concrets (notez que certains codes sont omis pour des raisons de simplicité).
Cas 1
final class SpinLock { private var lock = OS_SPINLOCK_INIT func withLock<Return>(@noescape body: () -> Return) -> Return { OSSpinLockLock(&lock) defer { OSSpinLockUnlock(&lock) } return body() } } class ThreadSafeVar<Value> { private let lock: ReadWriteLock private var _value: Value var value: Value { get { return lock.withReadLock { return _value } } set { lock.withWriteLock { _value = newValue } } } }
Le code multithread :
let counter = ThreadSafeVar<Int>(value: 0) // this code might be called from several threads counter.value += 1 if (counter.value == someValue) { // do something }
À première vue, tout est synchronisé et semble fonctionner comme prévu, car ThreadSaveVar
counter
et le rend thread-safe. Malheureusement, ce n'est pas vrai, car deux threads peuvent atteindre simultanément la ligne d'incrémentation et counter.value == someValue
ne deviendra jamais true en conséquence. Pour contourner ce problème, nous pouvons faire en sorte que ThreadSafeCounter
renvoie sa valeur après incrémentation :
class ThreadSafeCounter { private var value: Int32 = 0 func increment() -> Int { return Int(OSAtomicIncrement32(&value)) } }
Cas 2
struct SynchronizedDataArray { private let synchronizationQueue = dispatch_queue_create("queue_name", nil) private var _data = [DataType]() var data: [DataType] { var dataInternal = [DataType]() dispatch_sync(self.synchronizationQueue) { dataInternal = self._data } return dataInternal } mutating func append(item: DataType) { appendItems([item]) } mutating func appendItems(items: [DataType]) { dispatch_barrier_sync(synchronizationQueue) { self._data += items } } }
Dans ce cas, dispatch_barrier_sync
a été utilisé pour synchroniser l'accès à la baie. Il s'agit d'un modèle favori pour assurer la synchronisation des accès. Malheureusement, ce code ne tient pas compte du fait que struct fait une copie chaque fois que nous y ajoutons un élément, ayant ainsi une nouvelle file d'attente de synchronisation à chaque fois.
Ici, même si cela semble correct à première vue, cela pourrait ne pas fonctionner comme prévu. Cela nécessite également beaucoup de travail pour le tester et le déboguer, mais au final, vous pouvez améliorer la vitesse et la réactivité de votre application.
Erreur courante n° 4 : Ne pas connaître les pièges des objets mutables
Swift est très utile pour éviter les erreurs avec les types de valeur, mais il y a encore beaucoup de développeurs qui utilisent Objective-C. Les objets mutables sont très dangereux et peuvent entraîner des problèmes cachés. C'est une règle bien connue que les objets immuables doivent être renvoyés par les fonctions, mais la plupart des développeurs ne savent pas pourquoi. Considérons le code suivant :
// Box.h @interface Box: NSObject @property (nonatomic, readonly, strong) NSArray <Box *> *boxes; @end // Box.m @interface Box() @property (nonatomic, strong) NSMutableArray <Box *> *m_boxes; - (void)addBox:(Box *)box; @end @implementation Box - (instancetype)init { self = [super init]; if (self) { _m_boxes = [NSMutableArray array]; } return self; } - (void)addBox:(Box *)box { [self.m_boxes addObject:box]; } - (NSArray *)boxes { return self.m_boxes; } @end
Le code ci-dessus est correct, car NSMutableArray
est une sous-classe de NSArray
. Alors, qu'est-ce qui peut mal tourner avec ce code ?
La première et la plus évidente est qu'un autre développeur pourrait venir et faire ce qui suit :
NSArray<Box *> *childBoxes = [box boxes]; if ([childBoxes isKindOfClass:[NSMutableArray class]]) { // add more boxes to childBoxes }
Ce code gâchera votre classe. Mais dans ce cas, c'est une odeur de code, et c'est à ce développeur de ramasser les morceaux.
Voici le cas, cependant, qui est bien pire et démontre un comportement inattendu :
Box *box = [[Box alloc] init]; NSArray<Box *> *childBoxes = [box boxes]; [box addBox:[[Box alloc] init]]; NSArray<Box *> *newChildBoxes = [box boxes];
L'attente ici est que [newChildBoxes count] > [childBoxes count]
, mais que se passe-t-il si ce n'est pas le cas ? Ensuite, la classe n'est pas bien conçue car elle mute une valeur qui a déjà été renvoyée. Si vous pensez que l'inégalité ne devrait pas être vraie, essayez d'expérimenter avec UIView et [view subviews]
.
Heureusement, nous pouvons facilement corriger notre code en réécrivant le getter du premier exemple :
- (NSArray *)boxes { return [self.m_boxes copy]; }
Erreur courante n° 5 : ne pas comprendre le fonctionnement interne du NSDictionary
iOS
Si vous avez déjà travaillé avec une classe personnalisée et NSDictionary
, vous réaliserez peut-être que vous ne pouvez pas utiliser votre classe si elle n'est pas conforme à NSCopying
en tant que clé de dictionnaire. La plupart des développeurs ne se sont jamais demandé pourquoi Apple avait ajouté cette restriction. Pourquoi Apple copie-t-il la clé et utilise-t-il cette copie au lieu de l'objet d'origine ?
La clé pour comprendre cela est de comprendre comment NSDictionary
fonctionne en interne. Techniquement, c'est juste une table de hachage. Récapitulons rapidement comment cela fonctionne à un niveau élevé lors de l'ajout d'un objet pour une clé (le redimensionnement de la table et l'optimisation des performances sont omis ici pour plus de simplicité) :
Étape 1 : Il calcule
hash(Key)
. Étape 2 : Sur la base du hachage, il recherche un endroit où placer l'objet. Habituellement, cela se fait en prenant le module de la valeur de hachage avec la longueur du dictionnaire. L'index résultant est ensuite utilisé pour stocker la paire clé/valeur. Étape 3 : S'il n'y a pas d'objet à cet emplacement, il crée une liste chaînée et stocke notre enregistrement (objet et clé). Sinon, il ajoute l'enregistrement à la fin de la liste.
Maintenant, décrivons comment un enregistrement est extrait du dictionnaire :
Étape 1 : Il calcule
hash(Key)
. Étape 2 : Il recherche une clé par hachage. S'il n'y a pas de données,nil
est renvoyé. Étape 3 : S'il existe une liste liée, elle parcourt l'objet jusqu'à ce que[storedkey isEqual:Key]
.
Avec cette compréhension de ce qui se passe sous le capot, deux conclusions peuvent être tirées :
- Si le hachage de la clé change, l'enregistrement doit être déplacé vers une autre liste chaînée.
- Les clés doivent être uniques.
Examinons cela sur une classe simple :
@interface Person @property NSMutableString *name; @end @implementation Person - (BOOL)isEqual:(id)object { if (self == object) { return YES; } if (![object isKindOfClass:[Person class]]) { return NO; } return [self.name isEqualToSting:((Person *)object).name]; } - (NSUInteger)hash { return [self.name hash]; } @end
Imaginez maintenant que NSDictionary
ne copie pas les clés :
NSMutableDictionary *gotCharactersRating = [[NSMutableDictionary alloc] init]; Person *p = [[Person alloc] init]; p.name = @"Job Snow"; gotCharactersRating[p] = @10;
Oh! Nous avons une faute de frappe ici ! Réparons-le !
p.name = @"Jon Snow";
Que doit-il se passer avec notre dictionnaire ? Comme le nom a été muté, nous avons maintenant un hachage différent. Maintenant, notre objet se trouve au mauvais endroit (il a toujours l'ancienne valeur de hachage, car le dictionnaire ne connaît pas le changement de données), et le hachage que nous devons utiliser pour rechercher des données dans le dictionnaire n'est pas vraiment clair. Il pourrait y avoir un cas encore pire. Imaginez si nous avions déjà "Jon Snow" dans notre dictionnaire avec une note de 5. Le dictionnaire se retrouverait avec deux valeurs différentes pour la même clé.
Comme vous pouvez le constater, de nombreux problèmes peuvent survenir en raison de la présence de clés mutables dans NSDictionary
. La meilleure pratique pour éviter de tels problèmes consiste à copier l'objet avant de le stocker et à marquer les propriétés comme copy
. Cette pratique vous aidera également à garder votre classe cohérente.
Erreur courante n° 6 : Utiliser des storyboards au lieu de XIB
La plupart des nouveaux développeurs iOS suivent la suggestion d'Apple et utilisent des storyboards par défaut pour l'interface utilisateur. Cependant, l'utilisation de storyboards présente de nombreux inconvénients et seulement quelques avantages (discutables).
Les inconvénients du storyboard incluent :
- Il est vraiment difficile de modifier un storyboard pour plusieurs membres de l'équipe. Techniquement, vous pouvez utiliser de nombreux storyboards, mais le seul avantage, dans ce cas, est de permettre d'avoir des enchaînements entre les contrôleurs sur le storyboard.
- Les contrôleurs et les noms de séquences des storyboards sont des chaînes, vous devez donc soit ressaisir toutes ces chaînes tout au long de votre code (et un jour vous le casserez), soit conserver une énorme liste de constantes de storyboard. Vous pouvez utiliser SBConstants, mais renommer sur le storyboard n'est toujours pas une tâche facile.
- Les storyboards vous obligent à une conception non modulaire. Lorsque vous travaillez avec un storyboard, il y a très peu d'incitations à rendre vos vues réutilisables. Cela peut être acceptable pour le produit minimum viable (MVP) ou le prototypage rapide de l'interface utilisateur, mais dans les applications réelles, vous devrez peut-être utiliser la même vue plusieurs fois dans votre application.
Avantages du storyboard (discutable):
- Toute la navigation de l'application peut être vue en un coup d'œil. Cependant, les applications réelles peuvent avoir plus de dix contrôleurs, connectés dans des directions différentes. Les storyboards avec de telles connexions ressemblent à une pelote de laine et ne donnent aucune compréhension de haut niveau des flux de données.
- Tableaux statiques. C'est le seul véritable avantage auquel je peux penser. Le problème est que 90 % des tables statiques ont tendance à se transformer en tables dynamiques au cours de l'évolution de l'application et qu'une table dynamique peut être plus facilement gérée par les XIB.
Erreur courante n° 7 : Confondre la comparaison d'objets et de pointeurs
En comparant deux objets, on peut considérer deux égalités : l'égalité pointeur et objet.
L'égalité des pointeurs est une situation où les deux pointeurs pointent vers le même objet. En Objective-C, nous utilisons l'opérateur ==
pour comparer deux pointeurs. L'égalité d'objet est une situation où deux objets représentent deux objets logiquement identiques, comme le même utilisateur d'une base de données. En Objective-C, nous utilisons isEqual
, ou mieux encore, des isEqualToString
, isEqualToDate
, etc. spécifiques au type pour comparer deux objets.
Considérez le code suivant :
NSString *a = @"a"; // 1 NSString *b = @"a"; // 2 if (a == b) { // 3 NSLog(@"%@ is equal to %@", a, b); } else { NSLog(@"%@ is NOT equal to %@", a, b); }
Qu'est-ce qui sera imprimé sur la console lorsque nous exécuterons ce code ? Nous obtiendrons a is equal to b
, car les deux objets a
et b
pointent vers le même objet en mémoire.
Mais maintenant changeons la ligne 2 en :
NSString *b = [[@"a" mutableCopy] copy];
Maintenant, nous obtenons a is NOT equal to b
puisque ces pointeurs pointent maintenant vers des objets différents même si ces objets ont les mêmes valeurs.
Ce problème peut être évité en s'appuyant sur isEqual
ou sur des fonctions spécifiques au type. Dans notre exemple de code, nous devons remplacer la ligne 3 par le code suivant pour qu'il fonctionne toujours correctement :
if ([a isEqual:b]) {
Erreur courante n° 8 : utiliser des valeurs codées en dur
Il existe deux problèmes principaux avec les valeurs codées en dur :
- Ce qu'ils représentent n'est souvent pas clair.
- Ils doivent être saisis à nouveau (ou copiés et collés) lorsqu'ils doivent être utilisés à plusieurs endroits dans le code.
Considérez l'exemple suivant :
if ([[NSDate date] timeIntervalSinceDate:self.lastAppLaunch] < 172800) { // do something } or [self.tableView registerNib:nib forCellReuseIdentifier:@"SimpleCell"]; ... [self.tableView dequeueReusableCellWithIdentifier:@"SimpleCell"];
Que représente 172800 ? Pourquoi est-il utilisé ? Il n'est probablement pas évident que cela corresponde au nombre de secondes dans 2 jours (il y a 24 x 60 x 60, soit 86 400 secondes dans une journée).
Plutôt que d'utiliser des valeurs codées en dur, vous pouvez définir une valeur à l'aide de l'instruction #define
. Par exemple:
#define SECONDS_PER_DAY 86400 #define SIMPLE_CELL_IDENTIFIER @"SimpleCell"
#define
est une macro de préprocesseur qui remplace la définition nommée par sa valeur dans le code. Ainsi, si vous avez #define
dans un fichier d'en-tête et que vous l'importez quelque part, toutes les occurrences de la valeur définie dans ce fichier seront également remplacées.
Cela fonctionne bien, sauf pour un problème. Pour illustrer le problème restant, considérons le code suivant :
#define X = 3 ... CGFloat y = X / 2;
À quoi vous attendriez-vous que la valeur de y
soit après l'exécution de ce code ? Si vous avez dit 1.5, vous vous trompez. y
sera égal à 1 (et non 1,5) après l'exécution de ce code. Pourquoi? La réponse est que #define
n'a aucune information sur le type. Ainsi, dans notre cas, nous avons une division de deux valeurs Int
(3 et 2), ce qui donne un Int
(c'est-à-dire 1) qui est ensuite converti en Float
.
Ceci peut être évité en utilisant à la place des constantes qui sont, par définition, de type :
static const CGFloat X = 3; ... CGFloat y = X / 2; // y will now equal 1.5, as expected
Erreur courante n° 9 : utiliser le mot-clé par défaut dans une instruction Switch
L'utilisation du mot-clé default
dans une instruction switch peut entraîner des bogues et un comportement inattendu. Considérez le code suivant en Objective-C :
typedef NS_ENUM(NSUInteger, UserType) { UserTypeAdmin, UserTypeRegular }; - (BOOL)canEditUserWithType:(UserType)userType { switch (userType) { case UserTypeAdmin: return YES; default: return NO; } }
Le même code écrit en Swift :
enum UserType { case Admin, Regular } func canEditUserWithType(type: UserType) -> Bool { switch(type) { case .Admin: return true default: return false } }
Ce code fonctionne comme prévu, permettant uniquement aux utilisateurs administrateurs de pouvoir modifier d'autres enregistrements. Cependant, que se passerait-il si nous ajoutions un autre type d'utilisateur, "gestionnaire", qui devrait également pouvoir modifier les enregistrements ? Si nous oublions de mettre à jour cette instruction switch
, le code se compilera, mais il ne fonctionnera pas comme prévu. Cependant, si le développeur a utilisé des valeurs enum au lieu du mot-clé par défaut dès le début, l'oubli sera identifié au moment de la compilation et pourrait être corrigé avant de passer au test ou à la production. Voici une bonne façon de gérer cela en Objective-C :
typedef NS_ENUM(NSUInteger, UserType) { UserTypeAdmin, UserTypeRegular, UserTypeManager }; - (BOOL)canEditUserWithType:(UserType)userType { switch (userType) { case UserTypeAdmin: case UserTypeManager: return YES; case UserTypeRegular: return NO; } }
Le même code écrit en Swift :
enum UserType { case Admin, Regular, Manager } func canEditUserWithType(type: UserType) -> Bool { switch(type) { case .Manager: fallthrough case .Admin: return true case .Regular: return false } }
Erreur courante n° 10 : utiliser NSLog
pour la journalisation
De nombreux développeurs iOS utilisent NSLog
dans leurs applications pour la journalisation, mais la plupart du temps, c'est une terrible erreur. Si nous vérifions la documentation Apple pour la description de la fonction NSLog
, nous verrons que c'est très simple :
void NSLog(NSString *format, ...);
Qu'est-ce qui pourrait mal tourner avec ça ? En fait, rien. Cependant, si vous connectez votre appareil à l'organisateur Xcode, vous y verrez tous vos messages de débogage. Pour cette seule raison, vous ne devriez jamais utiliser NSLog
pour la journalisation : il est facile d'afficher des données internes indésirables, et cela ne semble pas professionnel.
Une meilleure approche consiste à remplacer NSLogs
par CocoaLumberjack configurable ou un autre framework de journalisation.
Emballer
iOS est une plate-forme très puissante et en évolution rapide. Apple fait un effort continu massif pour introduire de nouveaux matériels et fonctionnalités pour iOS lui-même, tout en élargissant continuellement le langage Swift.
L'amélioration de vos compétences Objective-C et Swift fera de vous un excellent développeur iOS et vous offrira des opportunités de travailler sur des projets stimulants en utilisant des technologies de pointe.