10 najczęstszych błędów, których programiści iOS nie wiedzą, że popełniają
Opublikowany: 2022-03-11Jaka jest jedyna rzecz gorsza niż posiadanie wadliwej aplikacji odrzuconej przez App Store? Mając to zaakceptowane. Gdy zaczną się pojawiać jednogwiazdkowe recenzje, prawie niemożliwe jest ich odzyskanie. To kosztuje firmy, a deweloperów ich miejsca pracy.
iOS to obecnie drugi co do wielkości mobilny system operacyjny na świecie. Ma również bardzo wysoki wskaźnik przyjęcia, z ponad 85% użytkowników korzystających z najnowszej wersji. Jak można się spodziewać, bardzo zaangażowani użytkownicy mają wysokie oczekiwania — jeśli Twoja aplikacja lub aktualizacja nie są bezbłędne, usłyszysz o tym.
Wraz z rosnącym zapotrzebowaniem na programistów iOS, wielu inżynierów przeszło na rozwój mobilny (codziennie ponad 1000 nowych aplikacji jest przesyłanych do Apple). Ale prawdziwa wiedza na temat iOS wykracza daleko poza podstawowe kodowanie. Poniżej znajduje się 10 typowych błędów, na które padają ofiarą programistów iOS, i jak można ich uniknąć.
Powszechny błąd nr 1: Niezrozumienie procesów asynchronicznych
Bardzo powszechnym rodzajem błędu wśród nowych programistów jest niewłaściwa obsługa kodu asynchronicznego. Rozważmy typowy scenariusz: użytkownik otwiera ekran z widokiem tabeli. Niektóre dane są pobierane z serwera i wyświetlane w widoku tabeli. Możemy to napisać bardziej formalnie:
@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; }
Na pierwszy rzut oka wszystko wygląda dobrze: pobieramy dane z serwera, a następnie aktualizujemy interfejs użytkownika. Problem polega jednak na tym, że pobieranie danych jest procesem asynchronicznym i nie zwróci od razu nowych danych, co oznacza, że przed otrzymaniem nowych danych zostanie reloadData
. Aby naprawić ten błąd, powinniśmy przenieść linię nr 2 zaraz po linii nr 1 wewnątrz bloku.
@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; }
Mogą jednak wystąpić sytuacje, w których ten kod nadal nie zachowuje się zgodnie z oczekiwaniami, co prowadzi nas do…
Powszechny błąd nr 2: Uruchamianie kodu związanego z interfejsem użytkownika w wątku innym niż główna kolejka
Wyobraźmy sobie, że użyliśmy poprawionego przykładu kodu z poprzedniego powszechnego błędu, ale nasz widok tabeli nadal nie jest aktualizowany o nowe dane, nawet po pomyślnym zakończeniu procesu asynchronicznego. Co może być nie tak z tak prostym kodem? Aby to zrozumieć, możemy ustawić punkt przerwania wewnątrz bloku i dowiedzieć się, z której kolejki ten blok jest wywoływany. Istnieje duże prawdopodobieństwo, że opisane zachowanie ma miejsce, ponieważ nasze wywołanie nie znajduje się w głównej kolejce, gdzie powinien zostać wykonany cały kod związany z interfejsem użytkownika.
Większość popularnych bibliotek — takich jak Alamofire, AFNetworking i Haneke — zaprojektowano tak, aby wywoływały completeBlock w głównej kolejce po completionBlock
zadania asynchronicznego. Jednak nie zawsze można na tym polegać i łatwo zapomnieć o wysłaniu kodu do właściwej kolejki.
Aby upewnić się, że cały kod związany z interfejsem użytkownika znajduje się w głównej kolejce, nie zapomnij wysłać go do tej kolejki:
dispatch_async(dispatch_get_main_queue(), ^{ [self.tableView reloadData]; });
Powszechny błąd nr 3: Niezrozumienie współbieżności i wielowątkowości
Współbieżność można porównać do naprawdę ostrego noża: możesz się łatwo skaleczyć, jeśli nie jesteś wystarczająco ostrożny lub wystarczająco doświadczony, ale jest to niezwykle przydatne i wydajne, gdy wiesz, jak używać go prawidłowo i bezpiecznie.
Możesz spróbować uniknąć korzystania ze współbieżności, ale bez względu na to, jakie aplikacje tworzysz, istnieje naprawdę duża szansa, że nie możesz się bez niej obejść. Współbieżność może przynieść znaczne korzyści dla Twojej aplikacji. Szczególnie:
- Prawie każda aplikacja ma wywołania usług internetowych (na przykład w celu wykonania ciężkich obliczeń lub odczytania danych z bazy danych). Jeśli te zadania są wykonywane w głównej kolejce, aplikacja zawiesi się na jakiś czas, przez co przestanie odpowiadać. Co więcej, jeśli zajmie to zbyt dużo czasu, iOS całkowicie wyłączy aplikację. Przeniesienie tych zadań do innej kolejki pozwala użytkownikowi na dalsze korzystanie z aplikacji podczas wykonywania operacji bez zawieszania się aplikacji.
- Współczesne urządzenia z systemem iOS mają więcej niż jeden rdzeń, dlaczego więc użytkownik miałby czekać, aż zadania kończą się sekwencyjnie, skoro można je wykonywać równolegle?
Ale zalety współbieżności nie są pozbawione złożoności i możliwości wprowadzenia poważnych błędów, takich jak warunki wyścigowe, które są naprawdę trudne do odtworzenia.
Rozważmy kilka przykładów ze świata rzeczywistego (zauważ, że dla uproszczenia pominięto część kodu).
Przypadek 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 } } } }
Kod wielowątkowy:
let counter = ThreadSafeVar<Int>(value: 0) // this code might be called from several threads counter.value += 1 if (counter.value == someValue) { // do something }
Na pierwszy rzut oka wszystko jest zsynchronizowane i wygląda na to, że powinno działać zgodnie z oczekiwaniami, ponieważ ThreadSaveVar
zawija counter
i czyni go bezpiecznym dla wątków. Niestety, nie jest to prawdą, ponieważ dwa wątki mogą jednocześnie osiągnąć linię przyrostu, a counter.value == someValue
nigdy nie stanie się prawdą. Jako obejście możemy stworzyć ThreadSafeCounter
, który zwraca swoją wartość po zwiększeniu:
class ThreadSafeCounter { private var value: Int32 = 0 func increment() -> Int { return Int(OSAtomicIncrement32(&value)) } }
Przypadek 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 } } }
W tym przypadku do zsynchronizowania dostępu do tablicy użyto dispatch_barrier_sync
. To ulubiony wzorzec zapewniający synchronizację dostępu. Niestety ten kod nie uwzględnia tego, że struct tworzy kopię za każdym razem, gdy dołączamy do niej element, dzięki czemu za każdym razem powstaje nowa kolejka synchronizacji.
Tutaj, nawet jeśli na pierwszy rzut oka wygląda poprawnie, może nie działać zgodnie z oczekiwaniami. Testowanie i debugowanie wymaga również wiele pracy, ale w końcu możesz poprawić szybkość i responsywność swojej aplikacji.
Powszechny błąd nr 4: Nieznajomość pułapek mutowalnych obiektów
Swift jest bardzo pomocny w unikaniu błędów z typami wartości, ale wciąż jest wielu programistów, którzy używają Objective-C. Obiekty mutowalne są bardzo niebezpieczne i mogą prowadzić do ukrytych problemów. Jest to dobrze znana zasada, że niezmienne obiekty powinny być zwracane z funkcji, ale większość programistów nie wie dlaczego. Rozważmy następujący kod:
// 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
Powyższy kod jest poprawny, ponieważ NSMutableArray
jest podklasą NSArray
. Więc co może się nie udać z tym kodem?
Pierwszą i najbardziej oczywistą rzeczą jest to, że inny programista może przyjść i wykonać następujące czynności:
NSArray<Box *> *childBoxes = [box boxes]; if ([childBoxes isKindOfClass:[NSMutableArray class]]) { // add more boxes to childBoxes }
Ten kod zepsuje twoją klasę. Ale w tym przypadku jest to zapach kodu i to programista musi zebrać kawałki.
Oto jednak przypadek, który jest znacznie gorszy i wykazuje nieoczekiwane zachowanie:
Box *box = [[Box alloc] init]; NSArray<Box *> *childBoxes = [box boxes]; [box addBox:[[Box alloc] init]]; NSArray<Box *> *newChildBoxes = [box boxes];
Oczekiwanie jest takie, że [newChildBoxes count] > [childBoxes count]
, ale co, jeśli tak nie jest? Wtedy klasa nie jest dobrze zaprojektowana, ponieważ mutuje wartość, która została już zwrócona. Jeśli uważasz, że nierówność nie powinna być prawdziwa, spróbuj poeksperymentować z UIView i [view subviews]
.
Na szczęście możemy łatwo naprawić nasz kod, przepisując getter z pierwszego przykładu:
- (NSArray *)boxes { return [self.m_boxes copy]; }
Powszechny błąd nr 5: Nie rozumienie, jak działa wewnętrznie iOS NSDictionary
Jeśli kiedykolwiek pracowałeś z klasą niestandardową i NSDictionary
, możesz zdać sobie sprawę, że nie możesz użyć swojej klasy, jeśli nie jest ona zgodna z NSCopying
jako klucz słownikowy. Większość programistów nigdy nie zadawała sobie pytania, dlaczego Apple dodał to ograniczenie. Dlaczego Apple kopiuje klucz i używa tej kopii zamiast oryginalnego obiektu?
Kluczem do zrozumienia tego jest zrozumienie, jak NSDictionary
działa wewnętrznie. Technicznie rzecz biorąc, to tylko tablica mieszająca. Przypomnijmy szybko, jak to działa na wysokim poziomie podczas dodawania obiektu dla klucza (dla uproszczenia pominięto tutaj zmianę rozmiaru tabeli i optymalizację wydajności):
Krok 1: Oblicza
hash(Key)
. Krok 2: Na podstawie skrótu szuka miejsca na umieszczenie obiektu. Zwykle odbywa się to poprzez pobranie modułu wartości skrótu z długością słownika. Wynikowy indeks jest następnie używany do przechowywania pary klucz/wartość. Krok 3: Jeśli w tej lokalizacji nie ma obiektu, tworzy połączoną listę i przechowuje nasz rekord (obiekt i klucz). W przeciwnym razie dołącza rekord na końcu listy.
Opiszmy teraz, jak rekord jest pobierany ze słownika:
Krok 1: Oblicza
hash(Key)
. Krok 2: Przeszukuje klucz według hash. Jeśli nie ma danych, zwracane jestnil
. Krok 3: Jeśli istnieje połączona lista, przechodzi ona przez obiekt aż do[storedkey isEqual:Key]
.
Dzięki takiemu zrozumieniu tego, co dzieje się pod maską, można wyciągnąć dwa wnioski:
- Jeśli skrót klucza ulegnie zmianie, rekord należy przenieść na inną połączoną listę.
- Klucze powinny być niepowtarzalne.
Zbadajmy to na prostej klasie:
@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
Teraz wyobraź sobie, że NSDictionary
nie kopiuje kluczy:
NSMutableDictionary *gotCharactersRating = [[NSMutableDictionary alloc] init]; Person *p = [[Person alloc] init]; p.name = @"Job Snow"; gotCharactersRating[p] = @10;
Oh! Mamy tam literówkę! Naprawmy to!
p.name = @"Jon Snow";
Co powinno się stać z naszym słownikiem? Ponieważ nazwa została zmutowana, mamy teraz inny hash. Teraz nasz obiekt znajduje się w złym miejscu (nadal ma starą wartość hash, ponieważ słownik nie wie o zmianie danych) i nie jest do końca jasne, jakiego hasha powinniśmy użyć do wyszukania danych w słowniku. Może być jeszcze gorszy przypadek. Wyobraź sobie, że w naszym słowniku mielibyśmy już „Jon Snow” z oceną 5. Słownik miałby dwie różne wartości dla tego samego klucza.
Jak widać, istnieje wiele problemów, które mogą wyniknąć z posiadania kluczy mutowalnych w NSDictionary
. Najlepszym sposobem uniknięcia takich problemów jest skopiowanie obiektu przed jego przechowywaniem i oznaczenie właściwości jako copy
. Ta praktyka pomoże ci również zachować spójność zajęć.
Powszechny błąd nr 6: używanie scenorysów zamiast XIB
Większość nowych programistów iOS postępuje zgodnie z sugestią Apple i domyślnie używa scenorysów w interfejsie użytkownika. Istnieje jednak wiele wad i tylko kilka (dyskusyjnych) zalet korzystania ze scenorysów.
Wady scenorysów obejmują:
- Bardzo trudno jest zmodyfikować storyboard dla kilku członków zespołu. Technicznie rzecz biorąc, można używać wielu scenorysów, ale jedyną zaletą w tym przypadku jest możliwość przechodzenia między kontrolerami w scenorysie.
- Nazwy kontrolerów i przejść z scenorysów są ciągami, więc musisz albo ponownie wprowadzić wszystkie te ciągi w całym kodzie (i pewnego dnia go zepsujesz), albo zachować ogromną listę stałych scenorysów. Możesz użyć SBConstants, ale zmiana nazwy w scenorysie nadal nie jest łatwym zadaniem.
- Storyboardy zmuszają Cię do projektowania niemodułowego. Podczas pracy ze scenorysem nie ma zachęty do ponownego wykorzystania Twoich poglądów. Może to być akceptowalne w przypadku minimalnego opłacalnego produktu (MVP) lub szybkiego prototypowania interfejsu użytkownika, ale w rzeczywistych aplikacjach może być konieczne kilkakrotne użycie tego samego widoku w całej aplikacji.
Zalety scenorysu (dyskusyjne):
- Całą nawigację w aplikacji można zobaczyć na pierwszy rzut oka. Jednak rzeczywiste aplikacje mogą mieć więcej niż dziesięć kontrolerów połączonych w różnych kierunkach. Scenorysy z takimi połączeniami wyglądają jak kłębek przędzy i nie dają żadnego zrozumienia przepływów danych na wysokim poziomie.
- Tabele statyczne. To jedyna prawdziwa zaleta, o jakiej mogę pomyśleć. Problem polega na tym, że 90 procent tabel statycznych ma tendencję do przekształcania się w tabele dynamiczne podczas ewolucji aplikacji, a tabele dynamiczne mogą być łatwiej obsługiwane przez XIB.
Powszechny błąd nr 7: mylące porównywanie obiektów i wskaźników
Porównując dwa obiekty, możemy rozważyć dwie równości: równość wskaźnika i obiektu.
Równość wskaźników to sytuacja, w której oba wskaźniki wskazują ten sam obiekt. W Objective-C używamy operatora ==
do porównywania dwóch wskaźników. Równość obiektów to sytuacja, w której dwa obiekty reprezentują dwa logicznie identyczne obiekty, jak ten sam użytkownik z bazy danych. W Objective-C do porównywania dwóch obiektów używamy isEqual
, a nawet lepiej, isEqualToString
, isEqualToDate
itp. dla poszczególnych typów.
Rozważ następujący kod:
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); }
Co zostanie wydrukowane na konsoli, gdy uruchomimy ten kod? Otrzymamy a is equal to b
, ponieważ oba obiekty a
i b
wskazują na ten sam obiekt w pamięci.
Ale teraz zmieńmy wiersz 2 na:
NSString *b = [[@"a" mutableCopy] copy];
Teraz otrzymujemy, że a is NOT equal to b
ponieważ te wskaźniki wskazują teraz różne obiekty, mimo że te obiekty mają te same wartości.
Tego problemu można uniknąć, opierając się na isEqual
lub funkcjach specyficznych dla typu. W naszym przykładzie kodu powinniśmy zastąpić wiersz 3 następującym kodem, aby zawsze działał poprawnie:
if ([a isEqual:b]) {
Powszechny błąd nr 8: Używanie wartości zakodowanych na stałe
Istnieją dwa podstawowe problemy związane z wartościami zakodowanymi na stałe:
- Często nie jest jasne, co reprezentują.
- Muszą zostać ponownie wprowadzone (lub skopiowane i wklejone), gdy mają być używane w wielu miejscach w kodzie.
Rozważmy następujący przykład:
if ([[NSDate date] timeIntervalSinceDate:self.lastAppLaunch] < 172800) { // do something } or [self.tableView registerNib:nib forCellReuseIdentifier:@"SimpleCell"]; ... [self.tableView dequeueReusableCellWithIdentifier:@"SimpleCell"];
Co oznacza 172800? Dlaczego jest używany? Nie jest chyba oczywiste, że odpowiada to liczbie sekund w ciągu 2 dni (w dobie jest 24 x 60 x 60, czyli 86 400 sekund).
Zamiast używać wartości zakodowanych na stałe, możesz zdefiniować wartość za pomocą instrukcji #define
. Na przykład:
#define SECONDS_PER_DAY 86400 #define SIMPLE_CELL_IDENTIFIER @"SimpleCell"
#define
to makro preprocesora, które zastępuje nazwaną definicję jej wartością w kodzie. Tak więc, jeśli masz #define
w pliku nagłówkowym i zaimportujesz go gdzieś, wszystkie wystąpienia zdefiniowanej wartości w tym pliku również zostaną zastąpione.
Działa to dobrze, z wyjątkiem jednego problemu. Aby zilustrować pozostały problem, rozważmy następujący kod:
#define X = 3 ... CGFloat y = X / 2;
Jaka będzie wartość y
po wykonaniu tego kodu? Jeśli powiedziałeś 1.5, to się mylisz. y
będzie równe 1 ( nie 1,5) po wykonaniu tego kodu. Czemu? Odpowiedź jest taka, że #define
nie zawiera informacji o typie. Tak więc w naszym przypadku mamy dzielenie dwóch wartości Int
(3 i 2), co daje w wyniku Int
(tj. 1), który jest następnie rzutowany na Float
.
Można tego uniknąć, używając zamiast tego stałych, które z definicji są wpisywane:
static const CGFloat X = 3; ... CGFloat y = X / 2; // y will now equal 1.5, as expected
Częsty błąd nr 9: Używanie domyślnego słowa kluczowego w instrukcji Switch
Użycie default
słowa kluczowego w instrukcji switch może prowadzić do błędów i nieoczekiwanego zachowania. Rozważ następujący kod w Objective-C:
typedef NS_ENUM(NSUInteger, UserType) { UserTypeAdmin, UserTypeRegular }; - (BOOL)canEditUserWithType:(UserType)userType { switch (userType) { case UserTypeAdmin: return YES; default: return NO; } }
Ten sam kod napisany w Swift:
enum UserType { case Admin, Regular } func canEditUserWithType(type: UserType) -> Bool { switch(type) { case .Admin: return true default: return false } }
Ten kod działa zgodnie z przeznaczeniem, umożliwiając tylko administratorom zmianę innych rekordów. Co jednak może się stać, gdy dodamy inny typ użytkownika, „menedżer”, który również powinien być w stanie edytować rekordy? Jeśli zapomnimy zaktualizować instrukcję switch
, kod się skompiluje, ale nie będzie działał zgodnie z oczekiwaniami. Jeśli jednak programista od samego początku użył wartości wyliczenia zamiast domyślnego słowa kluczowego, przeoczenie zostanie zidentyfikowane w czasie kompilacji i może zostać naprawione przed przejściem do testowania lub produkcji. Oto dobry sposób na poradzenie sobie z tym w 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; } }
Ten sam kod napisany w 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 } }
Częsty błąd nr 10: Używanie NSLog
do logowania
Wielu programistów iOS używa NSLog
w swoich aplikacjach do logowania, ale przez większość czasu jest to straszny błąd. Jeśli sprawdzimy w dokumentacji Apple opis funkcji NSLog
, zobaczymy, że jest to bardzo proste:
void NSLog(NSString *format, ...);
Co może się z tym nie udać? W rzeczywistości nic. Jeśli jednak połączysz swoje urządzenie z organizatorem Xcode, zobaczysz tam wszystkie wiadomości debugowania. Tylko z tego powodu nigdy nie powinieneś używać NSLog
do logowania: łatwo jest pokazać niechciane dane wewnętrzne, a dodatkowo wygląda to nieprofesjonalnie.
Lepszym podejściem jest zastąpienie NSLogs
konfigurowalnym CocoaLumberjack lub innym frameworkiem do rejestrowania.
Zakończyć
iOS to bardzo potężna i szybko rozwijająca się platforma. Apple dokłada ogromnych starań, aby wprowadzić nowy sprzęt i funkcje dla samego iOS, jednocześnie stale rozwijając język Swift.
Poprawa umiejętności Objective-C i Swift sprawi, że staniesz się świetnym programistą iOS i zaoferujesz możliwości pracy nad ambitnymi projektami przy użyciu najnowocześniejszych technologii.