Что может быть хуже того момента, когда App Store отвергает ваше приложение из-за багов? Когда приложение с кучей багов размещается в магазине. Оно получает один негативный отзыв, второй… Репутация компании и разработчика катится вниз и восстановить её уже очень сложно.
iOS – вторая по популярности мобильная ОС в мире, причём 65% пользователей использует самую свежую версию. И каждый из них ждёт от любого приложения качества и высокой стабильности. В ситуации, когда к команде разработчиков каждый день присоединяется 1000 новичков, добиться этого не просто. Ниже приведены 10 наиболее популярных ошибок по версии Toptal, которые совершают неопытные iOS-разработчики. Запомните и постарайтесь избегать их.
Отсутствие понимания устройства асинхронных процессов
Одна из наиболее распространённых ошибок – неправильная обработка асинхронного кода. Давайте рассмотрим простой пример: пользователь открывает страницу с таблицей, данные подгружаются с сервера и размещаются в ней. Описать процесс можно так:
@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;
}
На первый взгляд всё в порядке, но давайте проанализируем: мы сначала получаем данные, потом обновляем UI. Загвоздка в том, что получение данных – асинхронный процесс, и новые данные не будут получены до перегрузки интерфейса. Поэтому данный код необходимо переписать, поставив строку «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;
}
Впрочем, и такая запись может не привести к нужному результату, если…
Запуск кода, связанного с UI, не в главном потоке
Итак, вы переписали код, но наши таблицы всё ещё не заполнены обновлёнными данными. Неужели есть ещё ошибка в столь простом коде? Для поиска ответа остановим код внутри блока и посмотрим, в какой очереди он вызывается. Возможно, он не обновился из-за того, что пользовательский интерфейс обслуживается вне главной очереди.
Многие популярные библиотеки (Alamofire, AFNetworking и Haneke) требуют вызова completionBlock в основной очереди. Но иногда разработчики просто забывают об этом. А ведь сделать это так просто:
dispatch_async(dispatch_get_main_queue(), ^{
[self.tableView reloadData];
});
Непонимание многопоточности и параллелизма
Параллельную обработку можно сравнить с очень острым ножом: вы можете легко пораниться, если не будете осторожны, но при правильном использовании она придаст коду невероятную эффективность.
Безусловно, в подавляющем большинстве задач вы можете обойтись без параллелизма, но его использование даст следующие преимущества:
- Почти каждое мобильное приложение использует веб-сервисы (к примеру, для вычислений или работы с БД). Если вы поместите их в главную очередь, то приложение или «подвиснет» на время выполнения, или 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
}
Итак, мы создали ThreadSafeVar для обработки 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 используется для синхронизации доступа к массиву. К сожалению, код не учитывает, что такой алгоритм будет создавать копию каждый раз при добавлении элемента, что означает добавление очереди к синхронизации.
Да, такой код будет работать. Но потребуется провести несколько тестов, чтобы удостовериться в том, что потери производительности незначительны. В противном случае стоит искать другое решение.
Незнание тонкостей работы с переменными объектами
Swift очень полезен для предотвращения ошибок с типами, но iOS-разработчики используют также 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
Код корректен, NSArray является подклассом NSMutableArray. Так что может пойти не так?
Чаще всего проблема возникает, когда другой разработчик решает сделать следующее:
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], но что если не так? В этом случае класс плохо описан, так как он меняет значение, которое уже возвращено.
Исправить это можно, если вы допишете в начальный код:
- (NSArray *)boxes {
return [self.m_boxes copy];
}
Непонимание принципов работы NSDictionary
Если вы когда-нибудь работали с NSDictionary и произвольным классом, то знаете, что не можете использовать класс, если он не соответствует NSCopying в качестве ключа словаря. Многие iOS-разработчики задаются вопросом, зачем Apple добавила это ограничение.
Вам поможет понимание работы NSDictionary. Технически это всего лишь хэш-таблица. Упрощённо рассмотрим, как она работает при добавлении объекта в качестве ключа:
- Шаг 1: рассчитывается hash(Key).
- Шаг 2: основываясь на хэше, ищется место для размещения объекта. Обычно это делается путем вычисления модуля хэш-значения со значением словаря. Затем полученный индекс используется для хранения пары «ключ / значение».
- Шаг 3: если в этом месте отсутствует объект, то создаётся связанный список для записи и хранения нашей пары «ключ/значение». В противном случае пара добавляется в конец списка.
А вот как извлекается:
- Шаг 1: высчитывается hash(Key).
- Шаги2: ищется ключ по хэшу. Если данные отсутствует, возвращается nil.
- Шаг 3: если там связанный список, выполняются итерации объекта, пока [stored_key isEqual:Key].
На основании этого мы можем сделать два вывода:
- Если хэш ключа изменяется, запись должна быть перенесена в другой связанный список.
- Ключи должны быть уникальными.
Давайте рассмотрим это на простом классе:
@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";
Что происходит со словарём? Поскольку имя изменено, изменился и хеш. Теперь наш объект находится в неправильном месте, так как всё ещё имеет старое значение хэша, а словарь не знает об изменении. Таким образом, не ясно, какой хэш мы должны использовать для поиска данных в словаре.
Или ещё хуже. Представьте себе, что мы уже имели «Jon Snow» в нашем словаре с рейтингом 5. Словарь будет иметь два разных значения для одного и того же ключа.
Как видите, невнимательность может создать много проблем при работе с изменяемыми ключами NSDictionary. Лучшим выходом будет копирование объектов перед сохранением с соответствующей пометкой.
Использование StoryBoard вместо XIB
Большинство новых разработчиков iOS следуют рекомендациям Apple и используют сториборды по умолчанию для UI. У такого подхода есть не только [спорные] преимущества, но и явные недостатки. Начнём с плохого:
- Использование Storyboard несколькими членами команды – крайне сложная задача. Технически реально использовать несколько сторибордов, но для этого придётся прописывать переходы.
- Имена переходов и контроллеров в сторибордах – строки, которые вам придётся прописывать в коде (а из-за их количества это его уничтожит). Или создавать огромный список констант. Можно ещё использовать SBConstants, но это тоже не сильно упрощает задачу.
- Использование сторибордов практически исключает модульное программирование из-за малого числа повторов. Для минимального продукта (MVC) или прототипа это не критично, но для настоящего приложения это очень важный недостаток.
Преимущества:
- Навигация интуитивно понятна. Теоретически. Фактически реальное приложение имеет десятки контроллеров, подключённых в разных направлениях. То есть навигация будет выглядеть как большой клубок ниток, который точно не даст вам понимания о взаимодействии данных.
- Статические таблицы. Это неоспоримое преимущество, если не считать того, что 90% всех статических таблиц рано или поздно становится динамическими. А в этом случае лучше работать с XIB.
Путаницы со сравнением указателей и объектов
При сравнении двух объектов мы можем использовать два подхода: сопоставление указателей и самих объектов.
Равенство указателей означает, что оба ссылаются на один и тот же объект. В 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», так как оба указателя ссылаются на один и тот же объект в памяти.
Но теперь давайте изменим строку # 2 на:
NSString *b = [[@"a" mutableCopy] copy];
И теперь мы увидим «a is NOT equal to b» потому что указатели ссылаются на разные объекты, хоть визуально они и идентичны.
Проблема решает использованием isEqual или типизированной функцией. Внесём изменение в строку «3» и запишем код правильно:
if ([a isEqual:b]) {
Использование строго заданных значений
Существуют две основные проблемы со строго заданными значениями:
- Часто неясно, что они представляют.
- Если они используются в нескольких местах в коде, они должны быть повторно введены (или скопированы и вставлены).
Взгляните на пример:
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*2).
Вместо подобной записи вы можете определить значение с помощью инструкции #define. Например:
#define SECONDS_PER_DAY 86400
#define SIMPLE_CELL_IDENTIFIER @"SimpleCell"
#define – это макрос препроцессора, который заменяет значение именем в коде. То есть, если вы пропишите #define в файле заголовка и импортируете его где-нибудь, все вхождения будут заменены на строго заданные значения.
Но это решение не работает, когда возникает путаница с типами. Для его иллюстрации взгляните на код:
#define X = 3
...
CGFloat y = X / 2;
Наверняка вы ждёте, что значение y будет 1.5, но это не так. На самом деле оно примет значение 1. Причина в том, что #define не имеет информации о типе. Так что в нашем случае на основании двух значений типа Int (3 и 2) получается результат также типа Int вместо Float.
Этого можно избежать, используя константы:
static const CGFloat X = 3;
...
CGFloat y = X / 2; // y теперь 1.5
Использование default в конструкции 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. Однако если вместо default использовать значения enum, изменения будут учтены при компиляции и вы сможете это исправить перед тестированием или выпуском приложения. Вот так это должно выглядеть на 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
}
}
Использование NSLog для журнала логов
Многие iOS-разработчики используют NSLog в своих приложениях, чтобы вести служебные записи, однако это может стать большой ошибкой. Взгляните на документацию Apple, а именно на описание функции NSLog. Всё очень просто:
void NSLog(NSString *format, ...);
Проблема в том,что, если вы подключите своё устройство к XCode Organizer, увидите все свои отладочные сообщения. Именно по этой причине не стоит использовать NSLog для журнала: он содержит много нежелательной информации, кроме того, это выглядит непрофессионально.
Поэтому лучше заменить NSLogs на настраиваемый CocoaLumberjack или какой-нибудь фрейморк для протоколирования.
iOS — очень мощная и быстро развивающаяся платформа. Apple прилагает огромные усилия, чтобы внедрить новое оборудование и функции для самой iOS, а также постоянно расширять язык Swift.
Знание Objective-C и Swift сделает вас отличным разработчиком iOS и предоставит возможности для работы над сложными проектами с использованием передовых технологий.
Пройти обучение