10 самых распространенных ошибок iOS-разработчиков, о которых они не подозревают

Опубликовано: 2022-03-11

Что может быть хуже, чем отклонение приложения с ошибками в App Store? Приняв его. Как только отзывы с одной звездой начинают поступать, восстановить их практически невозможно. Это стоит компаниям денег, а разработчикам — рабочих мест.

iOS сейчас вторая по величине мобильная операционная система в мире. Он также имеет очень высокий уровень внедрения: более 85% пользователей используют последнюю версию. Как и следовало ожидать, у активно вовлеченных пользователей большие ожидания: если ваше приложение или обновление не безупречны, вы об этом узнаете.

Поскольку спрос на разработчиков iOS продолжает стремительно расти, многие инженеры переключились на мобильную разработку (ежедневно в Apple отправляется более 1000 новых приложений). Но настоящий опыт работы с iOS выходит далеко за рамки базового кодирования. Ниже приведены 10 распространенных ошибок, которым подвержены разработчики iOS, и способы их избежать.

85% пользователей iOS используют последнюю версию ОС. Это означает, что они ожидают, что ваше приложение или обновление будут безупречными.
Твитнуть

Распространенная ошибка № 1: непонимание асинхронных процессов

Очень распространенной ошибкой среди начинающих программистов является неправильная обработка асинхронного кода. Рассмотрим типичный сценарий: Пользователь открывает экран с табличным представлением. Некоторые данные извлекаются с сервера и отображаются в виде таблицы. Мы можем записать это более формально:

 @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; }

На первый взгляд все выглядит правильно: мы получаем данные с сервера, а затем обновляем пользовательский интерфейс. Однако проблема заключается в том, что выборка данных является асинхронным процессом и не возвращает новые данные немедленно, а это означает, что reloadData будет вызываться до получения новых данных. Чтобы исправить эту ошибку, мы должны переместить строку № 2 сразу после строки № 1 внутри блока.

 @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; }

Однако могут быть ситуации, когда этот код по-прежнему ведет себя не так, как ожидалось, что приводит нас к…

Распространенная ошибка № 2: выполнение кода, связанного с пользовательским интерфейсом, в потоке, отличном от основной очереди

Давайте представим, что мы использовали исправленный пример кода из предыдущей распространенной ошибки, но наше табличное представление все еще не обновляется новыми данными даже после успешного завершения асинхронного процесса. Что может быть не так с таким простым кодом? Чтобы понять это, мы можем установить точку останова внутри блока и узнать, в какой очереди этот блок вызывается. Существует высокая вероятность того, что описанное поведение происходит из-за того, что наш вызов не находится в основной очереди, где должен выполняться весь код, связанный с пользовательским интерфейсом.

Большинство популярных библиотек, таких как Alamofire, AFNetworking и Haneke, предназначены для вызова completeBlock в основной очереди после completionBlock асинхронной задачи. Однако вы не всегда можете полагаться на это, и легко забыть отправить свой код в нужную очередь.

Чтобы убедиться, что весь ваш код, связанный с пользовательским интерфейсом, находится в основной очереди, не забудьте отправить его в эту очередь:

 dispatch_async(dispatch_get_main_queue(), ^{ [self.tableView reloadData]; });

Распространенная ошибка № 3: непонимание параллелизма и многопоточности

Параллелизм можно сравнить с действительно острым ножом: вы можете легко порезаться, если вы недостаточно осторожны или недостаточно опытны, но он чрезвычайно полезен и эффективен, если вы знаете, как правильно и безопасно им пользоваться.

Вы можете попытаться избежать использования параллелизма, но независимо от того, какие приложения вы создаете, очень высока вероятность того, что вы не сможете обойтись без него. Параллелизм может иметь значительные преимущества для вашего приложения. В частности:

  • Почти в каждом приложении есть вызовы веб-сервисов (например, для выполнения сложных вычислений или чтения данных из базы данных). Если эти задачи выполняются в основной очереди, приложение на некоторое время зависнет и перестанет отвечать. Более того, если это займет слишком много времени, iOS полностью закроет приложение. Перемещение этих задач в другую очередь позволяет пользователю продолжать использовать приложение во время выполнения операции без зависания приложения.
  • Современные iOS-устройства имеют более одного ядра, так зачем пользователю ждать последовательного завершения задач, если их можно выполнять параллельно?

Но преимущества параллелизма не обходятся без сложности и возможности внесения коварных ошибок, таких как условия гонки, которые действительно трудно воспроизвести.

Давайте рассмотрим некоторые примеры из реальной жизни (обратите внимание, что для простоты часть кода опущена).

Дело 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 } } } }

Многопоточный код:

 let counter = ThreadSafeVar<Int>(value: 0) // this code might be called from several threads counter.value += 1 if (counter.value == someValue) { // do something }

На первый взгляд все синхронизировано и выглядит так, как будто оно должно работать как положено, поскольку ThreadSaveVar оборачивает counter и делает его потокобезопасным. К сожалению, это неверно, так как два потока могут достичь строки приращения одновременно, и в результате counter.value == someValue никогда не станет истинным. В качестве обходного пути мы можем сделать ThreadSafeCounter , который возвращает свое значение после увеличения:

 class ThreadSafeCounter { private var value: Int32 = 0 func increment() -> Int { return Int(OSAtomicIncrement32(&value)) } }

Случай 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 } } }

В данном случае для синхронизации доступа к массиву использовалась dispatch_barrier_sync . Это любимый шаблон для обеспечения синхронизации доступа. К сожалению, этот код не учитывает, что структура создает копию каждый раз, когда мы добавляем к ней элемент, таким образом каждый раз создавая новую очередь синхронизации.

Здесь, даже если на первый взгляд все выглядит правильно, оно может работать не так, как ожидалось. Также требуется много работы для его тестирования и отладки, но, в конце концов, вы можете улучшить скорость и время отклика вашего приложения.

Распространенная ошибка № 4: незнание подводных камней изменяемых объектов

Swift очень помогает избежать ошибок с типами значений, но многие разработчики все еще используют Objective-C. Изменяемые объекты очень опасны и могут привести к скрытым проблемам. Хорошо известно правило, что неизменяемые объекты должны возвращаться из функций, но большинство разработчиков не знают почему. Рассмотрим следующий код:

 // 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

Приведенный выше код правильный, потому что NSMutableArray является подклассом NSArray . Итак, что может пойти не так с этим кодом?

Первая и наиболее очевидная вещь заключается в том, что другой разработчик может прийти и сделать следующее:

 NSArray<Box *> *childBoxes = [box boxes]; if ([childBoxes isKindOfClass:[NSMutableArray class]]) { // add more boxes to childBoxes }

Этот код испортит ваш класс. Но в этом случае это запах кода, и разработчику остается собрать его по кусочкам.

Однако вот случай, который намного хуже и демонстрирует неожиданное поведение:

 Box *box = [[Box alloc] init]; NSArray<Box *> *childBoxes = [box boxes]; [box addBox:[[Box alloc] init]]; NSArray<Box *> *newChildBoxes = [box boxes];

Здесь ожидается, что [newChildBoxes count] > [childBoxes count] , но что, если это не так? Тогда класс плохо спроектирован, потому что он изменяет значение, которое уже было возвращено. Если вы считаете, что неравенство не должно быть правдой, попробуйте поэкспериментировать с UIView и [view subviews] .

К счастью, мы можем легко исправить наш код, переписав геттер из первого примера:

 - (NSArray *)boxes { return [self.m_boxes copy]; }

Распространенная ошибка № 5: непонимание того, как iOS NSDictionary работает внутри

Если вы когда-либо работали с пользовательским классом и NSDictionary , вы могли понять, что не можете использовать свой класс, если он не соответствует NSCopying в качестве ключа словаря. Большинство разработчиков никогда не задавались вопросом, почему Apple добавила это ограничение. Почему Apple копирует ключ и использует эту копию вместо исходного объекта?

Ключом к пониманию этого является выяснение того, как NSDictionary работает внутри. Технически это просто хеш-таблица. Давайте быстро вспомним, как это работает на высоком уровне при добавлении объекта для ключа (изменение размера таблицы и оптимизация производительности здесь для простоты опущены):

Шаг 1: Он вычисляет hash(Key) . Шаг 2: На основе хэша ищет место для размещения объекта. Обычно это делается путем взятия модуля хеш-значения с длиной словаря. Полученный индекс затем используется для хранения пары ключ/значение. Шаг 3: Если в этом месте нет объекта, он создает связанный список и сохраняет нашу запись (объект и ключ). В противном случае он добавляет запись в конец списка.

Теперь давайте опишем, как запись извлекается из словаря:

Шаг 1: Он вычисляет hash(Key) . Шаг 2: Он ищет ключ по хешу. Если данных нет, возвращается nil . Шаг 3: Если есть связанный список, он перебирает объект до [storedkey isEqual:Key] .

При таком понимании происходящего под капотом можно сделать два вывода:

  1. Если хэш ключа изменится, запись должна быть перемещена в другой связанный список.
  2. Ключи должны быть уникальными.

Давайте рассмотрим это на простом классе:

 @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

Теперь представьте, что NSDictionary не копирует ключи:

 NSMutableDictionary *gotCharactersRating = [[NSMutableDictionary alloc] init]; Person *p = [[Person alloc] init]; p.name = @"Job Snow"; gotCharactersRating[p] = @10;

Ой! У нас там опечатка! Давайте исправим это!

 p.name = @"Jon Snow";

Что должно произойти с нашим словарем? Поскольку имя было изменено, теперь у нас другой хеш. Теперь наш объект лежит не в том месте (он по-прежнему имеет старое значение хеш-функции, так как словарь не знает об изменении данных), и не очень понятно, по какому хэшу искать данные в словаре. Мог быть и похуже случай. Представьте, если бы в нашем словаре уже был «Джон Сноу» с рейтингом 5. В итоге словарь получил бы два разных значения для одного и того же ключа.

Как видите, есть много проблем, которые могут возникнуть из-за наличия изменяемых ключей в NSDictionary . Чтобы избежать таких проблем, лучше всего копировать объект перед его сохранением и помечать свойства как copy . Эта практика также поможет вам сохранить постоянство вашего класса.

Распространенная ошибка № 6: использование раскадровок вместо XIB

Большинство новых разработчиков iOS следуют предложению Apple и по умолчанию используют раскадровки для пользовательского интерфейса. Однако в использовании раскадровки есть много недостатков и всего несколько (спорных) преимуществ.

К недостаткам раскадровки относятся:

  1. Очень сложно изменить раскадровку для нескольких членов команды. Технически вы можете использовать множество раскадровок, но в этом случае единственное преимущество — возможность иметь переходы между контроллерами на раскадровке.
  2. Имена контроллеров и переходов из раскадровки являются строками, поэтому вам придется либо повторно вводить все эти строки в свой код (и однажды вы его сломаете), либо поддерживать огромный список констант раскадровки. Вы можете использовать SBConstants, но переименование на раскадровке все еще непростая задача.
  3. Раскадровки заставляют вас использовать немодульный дизайн. При работе с раскадровкой очень мало стимулов для повторного использования ваших представлений. Это может быть приемлемо для минимально жизнеспособного продукта (MVP) или быстрого прототипирования пользовательского интерфейса, но в реальных приложениях вам может потребоваться использовать одно и то же представление несколько раз в приложении.

Достоинства раскадровки (спорные):

  1. Всю навигацию по приложению можно увидеть с первого взгляда. Однако в реальных приложениях может быть более десяти контроллеров, подключенных в разных направлениях. Раскадровки с такими соединениями выглядят как клубок пряжи и не дают никакого понимания потоков данных на высоком уровне.
  2. Статические таблицы. Это единственное реальное преимущество, о котором я могу думать. Проблема в том, что 90 процентов статических таблиц имеют тенденцию превращаться в динамические таблицы в ходе развития приложения, а динамические таблицы могут легче обрабатываться XIB.

Распространенная ошибка № 7: запутанное сравнение объекта и указателя

При сравнении двух объектов мы можем рассматривать два равенства: равенство указателя и равенства объекта.

Равенство указателей — это ситуация, когда оба указателя указывают на один и тот же объект. В Objective-C мы используем оператор == для сравнения двух указателей. Равенство объектов — это ситуация, когда два объекта представляют собой два логически идентичных объекта, например одного и того же пользователя из базы данных. В Objective-C мы используем isEqual или, что еще лучше, специфичные для типа isEqualToString , isEqualToDate и т. д. для сравнения двух объектов.

Рассмотрим следующий код:

 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); }

Что будет выведено на консоль, когда мы запустим этот код? Мы получим a is equal to b , так как оба объекта a и b указывают на один и тот же объект в памяти.

Но теперь давайте изменим строку 2 на:

 NSString *b = [[@"a" mutableCopy] copy];

Теперь мы получаем, что a is NOT equal to b так как эти указатели теперь указывают на разные объекты, даже если эти объекты имеют одинаковые значения.

Этой проблемы можно избежать, полагаясь на isEqual или функции, специфичные для типа. В нашем примере кода мы должны заменить строку 3 следующим кодом, чтобы он всегда работал правильно:

 if ([a isEqual:b]) {

Распространенная ошибка № 8: использование жестко закодированных значений

Есть две основные проблемы с жестко запрограммированными значениями:

  1. Часто неясно, что они представляют.
  2. Их нужно вводить повторно (или копировать и вставлять), когда их нужно использовать в нескольких местах кода.

Рассмотрим следующий пример:

 if ([[NSDate date] timeIntervalSinceDate:self.lastAppLaunch] < 172800) { // do something } or [self.tableView registerNib:nib forCellReuseIdentifier:@"SimpleCell"]; ... [self.tableView dequeueReusableCellWithIdentifier:@"SimpleCell"];

Что означает 172800? Почему это используется? Вероятно, не очевидно, что это соответствует количеству секунд в 2 днях (в сутках 24 х 60 х 60, или 86 400 секунд).

Вместо использования жестко закодированных значений вы можете определить значение с помощью инструкции #define . Например:

 #define SECONDS_PER_DAY 86400 #define SIMPLE_CELL_IDENTIFIER @"SimpleCell"

#define — это макрос препроцессора, который заменяет именованное определение его значением в коде. Таким образом, если у вас есть #define в заголовочном файле и вы импортируете его куда-нибудь, все вхождения определенного значения в этом файле также будут заменены.

Это работает хорошо, за исключением одной проблемы. Чтобы проиллюстрировать оставшуюся проблему, рассмотрим следующий код:

 #define X = 3 ... CGFloat y = X / 2;

Каким, по вашему мнению, будет значение y после выполнения этого кода? Если вы сказали 1,5, вы не правы. y будет равен 1 ( не 1,5) после выполнения этого кода. Почему? Ответ заключается в том, что #define не имеет информации о типе. Итак, в нашем случае у нас есть деление двух значений Int (3 и 2), в результате чего получается Int (т.е. 1), которое затем преобразуется в Float .

Этого можно избежать, используя вместо этого константы, которые по определению являются типизированными:

 static const CGFloat X = 3; ... CGFloat y = X / 2; // y will now equal 1.5, as expected

Распространенная ошибка № 9: использование ключевого слова по умолчанию в операторе Switch

Использование ключевого слова по default в операторе switch может привести к ошибкам и неожиданному поведению. Рассмотрим следующий код в Objective-C:

 typedef NS_ENUM(NSUInteger, UserType) { UserTypeAdmin, UserTypeRegular }; - (BOOL)canEditUserWithType:(UserType)userType { switch (userType) { case UserTypeAdmin: return YES; default: return NO; } }

Тот же код, написанный на Swift:

 enum UserType { case Admin, Regular } func canEditUserWithType(type: UserType) -> Bool { switch(type) { case .Admin: return true default: return false } }

Этот код работает по назначению, позволяя только пользователям с правами администратора изменять другие записи. Однако что произойдет, если мы добавим еще один тип пользователя, «менеджер», который также сможет редактировать записи? Если мы забудем обновить этот оператор switch , код скомпилируется, но не будет работать должным образом. Однако, если разработчик с самого начала использовал значения перечисления вместо ключевого слова по умолчанию, упущение будет выявлено во время компиляции и может быть исправлено до перехода к тестированию или производству. Вот хороший способ справиться с этим в 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; } }

Тот же код, написанный на 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 } }

Распространенная ошибка № 10: использование NSLog для ведения журнала

Многие разработчики iOS используют NSLog в своих приложениях для ведения журнала, но в большинстве случаев это ужасная ошибка. Если мы проверим документацию Apple для описания функции NSLog , мы увидим, что это очень просто:

 void NSLog(NSString *format, ...);

Что может пойти не так с ним? На самом деле ничего. Однако, если вы подключите свое устройство к организатору Xcode, вы увидите там все свои отладочные сообщения. Только по этой причине вы никогда не должны использовать NSLog для ведения журнала: легко показать некоторые нежелательные внутренние данные, к тому же это выглядит непрофессионально.

Лучшим подходом является замена NSLogs на настраиваемым CocoaLumberjack или какой-либо другой структурой ведения журналов.

Заворачивать

iOS — очень мощная и быстро развивающаяся платформа. Apple постоянно прилагает огромные усилия для внедрения нового оборудования и функций для самой iOS, а также постоянно расширяет язык Swift.

Улучшение навыков работы с Objective-C и Swift сделает вас отличным разработчиком iOS и даст возможность работать над сложными проектами с использованием передовых технологий.

Связанный: Руководство разработчика iOS: от Objective-C до изучения Swift