iOS 开发者不知道的 10 个最常见错误

已发表: 2022-03-11

有什么比 App Store 拒绝一个有问题的应用程序更糟糕的事情? 让它接受。 一旦一星评论开始滚动,几乎不可能恢复。 这会花费公司的资金和开发人员的工作。

iOS现在是世界上第二大移动操作系统。 它的采用率也非常高,超过 85% 的用户使用最新版本。 正如您所料,高度参与的用户有很高的期望——如果您的应用或更新并非完美无缺,您就会听到。

随着对 iOS 开发人员的需求持续飙升,许多工程师转向了移动开发(每天有超过 1,000 个新应用程序提交给 Apple)。 但真正的 iOS 专业知识远远超出了基本的编码范围。 以下是 iOS 开发人员容易犯的 10 个常见错误,以及如何避免这些错误。

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

乍一看,一切正常:我们从服务器获取数据,然后更新 UI。 但是,问题是获取数据是一个异步过程,不会立即返回新数据,这意味着在接收新数据之前会调用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:在主队列以外的线程上运行 UI 相关代码

假设我们使用了前面常见错误的更正代码示例,但即使在异步过程成功完成后,我们的表视图仍然没有更新新数据。 这么简单的代码可能有什么问题? 为了理解它,我们可以在块内设置一个断点,并找出这个块在哪个队列中被调用。 由于我们的调用不在主队列中,所有与 UI 相关的代码都应该在主队列中执行,因此很有可能发生所描述的行为。

大多数流行的库——例如 Alamofire、AFNetworking 和 Haneke——被设计为在执行异步任务后在主队列上调用completionBlock 。 但是,您不能总是依赖它,并且很容易忘记将代码分派到正确的队列。

为确保所有与 UI 相关的代码都在主队列中,不要忘记将其分派到该队列:

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

常见错误 3:对并发和多线程的误解

并发可以比作一把非常锋利的刀:如果您不小心或经验不足,您很容易割伤自己,但是一旦您知道如何正确安全地使用它,它就会非常有用和高效。

您可以尝试避免使用并发,但无论您正在构建什么样的应用程序,您都离不开它。 并发可以为您的应用程序带来显着的好处。 尤其:

  • 几乎每个应用程序都会调用 Web 服务(例如,执行一些繁重的计算或从数据库中读取数据)。 如果这些任务在主队列上执行,应用程序将冻结一段时间,使其无响应。 此外,如果这需要太长时间,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用于同步对数组的访问。 这是确保访问同步的常用模式。 不幸的是,这段代码没有考虑到 struct 每次我们向它追加一个项目时都会创建一个副本,因此每次都有一个新的同步队列。

在这里,即使第一眼看起来正确,它也可能无法按预期工作。 它还需要大量的工作来测试和调试它,但最终,您可以提高应用程序的速度和响应能力。

常见错误 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

上面的代码是正确的,因为NSMutableArrayNSArray的子类。 那么这段代码有什么问题呢?

首先也是最明显的事情是另一个开发人员可能会出现并执行以下操作:

 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]进行试验。

幸运的是,我们可以通过重写第一个示例中的 getter 轻松修复我们的代码:

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

常见错误 5:不了解 iOS NSDictionary如何在内部工作

如果您曾经使用过自定义类和NSDictionary ,您可能会意识到如果您的类不符合NSCopying作为字典键,您将无法使用它。 大多数开发者从来没有问过自己为什么苹果会增加这个限制。 为什么 Apple 复制密钥并使用该副本而不是原始对象?

理解这一点的关键是弄清楚NSDictionary如何在内部工作。 从技术上讲,它只是一个哈希表。 让我们快速回顾一下它在为键添加对象时如何在较高级别上工作(为简单起见,此处省略了表调整大小和性能优化):

第 1 步:计算hash(Key) 。 第 2 步:根据哈希值,寻找放置对象的位置。 通常,这是通过将哈希值与字典长度取模来完成的。 然后使用生成的索引来存储键/值对。 第 3 步:如果该位置没有对象,它会创建一个链表并存储我们的记录(对象和键)。 否则,它将记录附加到列表的末尾。

现在,让我们描述如何从字典中获取记录:

第 1 步:计算hash(Key) 。 第 2 步:它通过哈希搜索密钥。 如果没有数据,则返回nil 。 第 3 步:如果存在链表,则遍历 Object 直到[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 的“Jon Snow”。字典最终会为同一个 Key 提供两个不同的值。

如您所见,在NSDictionary中使用可变键可能会导致许多问题。 避免此类问题的最佳做法是在存储之前复制对象,并将属性标记为copy 。 这种做法也将帮助您保持课堂的一致性。

常见错误 6:使用故事板而不是 XIB

大多数新的 iOS 开发人员都遵循 Apple 的建议,并默认为 UI 使用故事板。 然而,使用故事板有很多缺点,只有几个(有争议的)优点。

故事板的缺点包括:

  1. 为几个团队成员修改故事板真的很难。 从技术上讲,您可以使用许多故事板,但在这种情况下,唯一的优势是可以在故事板上的控制器之间进行转场。
  2. 情节提要中的控制器和转场名称是字符串,因此您必须在整个代码中重新输入所有这些字符串(总有一天您破坏它),或者维护大量情节提要常量。 您可以使用 SBConstants,但在情节提要上重命名仍然不是一件容易的事。
  3. 故事板迫使您进行非模块化设计。 在使用故事板时,几乎没有动力让您的视图可重用。 这对于最小可行产品 (MVP) 或快速 UI 原型设计可能是可以接受的,但在实际应用程序中,您可能需要在整个应用程序中多次使用相同的视图。

故事板(有争议的)优点:

  1. 整个应用导航一目了然。 但是,实际应用程序可以有十多个控制器,以不同的方向连接。 具有这种连接的情节提要看起来像一团毛线,并不能提供对数据流的任何高级理解。
  2. 静态表。 这是我能想到的唯一真正的优势。 问题是 90% 的静态表在应用程序演变过程中往往会变成动态表,而动态表可以更容易地由 XIB 处理。

常见错误 7:混淆对象和指针的比较

在比较两个对象时,我们可以考虑两个相等:指针相等和对象相等。

指针相等是两个指针都指向同一个对象的情况。 在 Objective-C 中,我们使用==运算符来比较两个指针。 对象相等是两个对象代表两个逻辑上相同的对象的情况,例如数据库中的同一个用户。 在 Objective-C 中,我们使用isEqual甚至更好的类型特定isEqualToStringisEqualToDate等运算符来比较两个对象。

考虑以下代码:

 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 ,因为对象ab都指向内存中的同一个对象。

但现在让我们将第 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 x 60 x 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 语句中使用默认关键字

在 switch 语句中使用default关键字可能会导致错误和意外行为。 考虑一下 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进行日志记录:它很容易显示一些不需要的内部数据,而且它看起来不专业。

更好的方法是用可配置的 CocoaLumberjack 或其他一些日志框架替换NSLogs

包起来

iOS 是一个非常强大且快速发展的平台。 Apple 不断努力为 iOS 本身引入新的硬件和功能,同时也在不断扩展 Swift 语言。

提高您的 Objective-C 和 Swift 技能将使您成为一名出色的 iOS 开发人员,并提供机会使用尖端技术从事具有挑战性的项目。

相关: iOS 开发者指南:从 Objective-C 到学习 Swift