iOS 开发技巧及常见误区

本文列举了 iOS 开发中一些容易被误解或忽视的点,高德客户端研发的同学结合实际经验,做了一份小结,供大家查缺补漏。本文分为 Foundation,UIKit 和 GCD 三部分,一起来看下。

0x00 Foundation

0x01 Must call -[NSNotificationCenter removeObserver:] in dealloc?

-[NSObject dealloc] 方法中移除 self 对 NSNotificationCenter 的监听早已深入人心。它很容易被人遗忘,也让 NSNotificationCenter 的使用变得麻烦,这种机械式重复劳动实际上是设计的问题,完全可以通过框架来简化。

当然,Apple 也发现了这个问题。从 iOS 9 和 macOS 10.11 开始,开发者已经无需再在 -[NSObject dealloc] 中移除监听了。

参见 Apple 官方文档

If your app targets iOS 9.0 and later or macOS 10.11 and later, you don't need to unregister an observer in its dealloc method. 
Otherwise, you should call removeObserver:name:object: before observer or any object passed to this method is deallocated.

由于调用 -[NSNotificationCenter addObserverForName:object:queue:usingBlock:] 返回的对象会被系统 retain,所以还是需要被手动 remove.

0x02 -[NSUserDefaults synchronize]

-[NSUserDefaults synchronize] 是一个 Apple 一直恋恋不舍的 API。Apple 从 iOS 7 开始就在文档中说它已经废弃了:

-synchronize is deprecated and will be marked with the API_DEPRECATED macro in a future release.

但直到今天(iOS 13.2),也没给它打上 API_DEPRECATED。它是一个耗性能的 API,被调用时会阻塞当前线程直到所有数据被同步。

实际上,Apple 已在底层实现了一套内存缓存,使得 NSUserDefaults 不再需要频繁调用 synchronize,从而简化了 NSUserDefaults 的使用,同时也提高了性能。

对于 iOS App,移除 -[NSUserDefaults synchronize] 唯一需要适配的情况:

  • 写入数据后希望通知其它程序读取。在这种情况下,Apple 建议在其它程序中使用 KVO 去监听 NSUserDefaults

对于 non-app 进程,如 CLI,agent,daemon,需要适配的情况:

  • 在即将 exit 进程时,调用 CFPreferencesAppSynchronize(kCFPreferencesCurrentApplication)

0x03 Block

以下代码是不是很熟悉:

__weak typeof(self) weakSelf = self;
^{
    __strong typeof(self) strongSelf = weakSelf;
    // do something with `strongSelf`
};

这种做法虽然能够避免 block 循环引用,但它有诸多问题:

1)block 中后续的代码如果存在 self,则还会有循环引用

有小伙伴可能会说,我谨慎点写 self,不就行了?实际上,我们可能会遇到以下一些隐晦的 self:

  • 直接使用 _ivar 会被编译器编译为 self->_ivar

  • 使用一些系统的宏,展开后带有 self,如 NSAssert

所以,最佳实践是__strong typeof(self) self = weakSelf;

使用一个名为 self 的局部变量,抑制该作用域中本来的 self 关键字,来确保不会出现 self 的循环引用。ReactiveObjC 中的 @strongify() 便是这样实现的

2)使用 strongSelf 时,它可能已经为 nil

上述写法只能保证在 block 执行阶段,self 不被释放,但 self 可能在 block 执行之前就已经释放了。

所以,最佳实践是这样

__weak typeof(self) weakSelf = self;
^{
    __strong typeof(self) self = weakSelf;
    if (!self) {
        // error handling
        return;
    }
    // do something with `self`
};

延伸

  • 有小伙伴可能会有疑问,在 block 中使用 typeof(self) 不会导致循环引用吗?

答案当然是不会。typeof() 是一个编译期指令,在编译期,编译器就会将 typeof(self) 转换为 self 真正的类型,比如 UIViewController *

  • __strong 是必须的吗?

在 ARC 中,如果不指定变量的内存管理语义,则默认是 __strong,所以此处不是必须的。但这里牵扯 weak-strong 转换,为了遵守 Explicit is better than implicit. 的原则,还是建议写上。

  • Heap-Stack Dance

上面避免循环引用的方式也被人称作 "Weak-Strong Dance"。另一种解决循环引用的 pattern,称作 "Heap-Stack Dance",也常被使用,尤其在 API 设计时:

^(SomeType self) {
    // do something with `self`
};

这种方式在调用 block 时,将 self 作为参数传递,使得 self 成为一个在 stack 上的 auto variable,从而避免循环引用,也节省了代码。

0x10 UIKit

0x11 Responder Chain

有些人谈到响应者链就会说 UIEvent 沿着链条向上传递到 first responder 再分发下去。然而,这实际上是概念的杂糅。

响应者链是什么:

响应者链就是一个单链表,由一些 UIResponders 通过 nextResponder (next 指针)串联。

仅此而已。

而从 UIWindow 向上的过程叫 Hit-Testing,它在 App 的 View Tree 中进行递归调用 -[UIView hitTest:withEvent:],来确定 type 是 Touch 的 events 的 first responder 是谁,与响应者链无关。

所以

  • 响应者链使用的是单链表,每个节点是 UIResponder;而 Hit-Testing 使用的是,每个节点是 UIView.

  • 响应者链所依赖的 -[UIResponder nextResponder] 是 UIResponder 的方法,而 Hit-Testing 所依赖的 -[UIView hitTest:withEvent:] 是 UIView 的方法,两者也完全不在一个层次上。

延伸

不同 type 的 events 确定 first responder 的方式也不相同。
Event type First responder
Touch events The view in which the touch occurred.
Press events The object that has focus.
Shake-motion events The object that you (or UIKit) designate.
Remote-control events The object that you (or UIKit) designate.
Editing menu messages The object that you (or UIKit) designate.

链条中有哪些 UIResponders,它们的顺序又是什么

-[UIResponder nextResponder] 默认返回 nil,而它不同的子类,分别 override 了 nextResponder.

UIView

如果是 UIViewController 的 view,则返回 UIViewController

否则,返回 superview

UIViewController

如果 self.presentingViewController 不为空,返回 self.presentingViewController.

否则,返回 self.view.superview(如,是 UIWindow 的 rootViewController 或有 parentViewController)

UIWindow

iOS 13 以前,返回 UIApplication.sharedApplication

iOS 13 开始,返回 self.windowScene

UIWindowScene

返回它通过 UISceneSession 所连接的 UIApplication.sharedApplication

UIApplication

如果 [self.delegate isKindOfClass:UIResponder.class], 返回 self.delegate

否则返回 nil

所以,一个响应者链的例子:

subview -> superview -> superview's view controller -> parent view controller's view -> parent view controller -> presenting view controller -> some private views -> window -> window scene -> UIApplication.sharedApplication -> AppDelegate -> nil

决定谁是 first responder 可以有很多方式,使用 Hit-Testing 最符合 GUI App 用户交互的直觉。

event 的分发也可以有很多方式,例如 broadcast.

0x12 -[UIViewController init]

UIViewController 子类的 Initializers 是经常容易犯错的地方。不仅针对 UIViewController,其它类的子类也一样。

以下代码是不是在项目中经常见?如果你明确知道它们的错误是什么,可以跳过本小节。

Case 1

@implementation MYViewController


- (instancetype)init {
    self = [super init];
    if (self) {
        // do some initializations
    }
    return self;
}


@end

Case 2

@interface MYViewController : UIViewController


- (instancetype)initWithID:(NSString *)aID;


@end


@implementation MYViewController


- (instancetype)initWithID:(NSString *)aID {
    self = [super init];
    if (self) {
        // do some initializations
    }
    return self;
}


@end

对于 Case 1,正确的做法

@implementation MYViewController


- (instancetype)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil {
    self = [super initWithNibName:nibNameOrNil bundle:nibBundleOrNil];
    if (self) {
        // do some initializations
    }
    return self;
}


- (instancetype)initWithCoder:(NSCoder *)coder {
    self = [super initWithCoder:coder];
    if (self) {
        // do some initializations
    }
    return self;
}


@end

因为 MYViewController 的父类 UIViewController 的 designated initializers 是 -[UIViewController initWithNibName:bundle:] 和 -[UIViewController initWithCoder:],所以被 override 的应该是它们俩。

对于 Case 2,正确的做法

@implementation MYViewController


- (instancetype)initWithID:(NSString *)aID {
    self = [self initWithNibName:nil bundle:nil];
    if (self) {
        // do some initializations
    }
    return self;
}


@end

因为 -[MYViewController initWithID:] 并不是 MYViewController 的 designated initializer,所以它需要调用 self 的 designated initializer.

Case 2,也可以这样做

@interface MYViewController : UIViewController


- (instancetype)initWithID:(NSString *)aID NS_DESIGNATED_INITIALIZER;
- (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE;


@end


@implementation MYViewController


- (instancetype)initWithID:(NSString *)aID {
    self = [super initWithNibName:nil bundle:nil];
    if (self) {
        // do some initializations
    }
    return self;
}


- (instancetype)initWithNibName:(NSString *)nibNameOrNil bundle:(NSBundle *)nibBundleOrNil {
    return [self initWithID:nil];
}


@end

-[MYViewController initWithID:] 标记为 NS_DESIGNATED_INITIALIZER,所以它需要调用 super 的 designated initializer,即 -[UIViewController initWithNibName:bundle:]。而它继承来的 -[MYViewController initWithNibName:bundle:] 此时不再是 NS_DESIGNATED_INITIALIZER,所以需要调用 self 的 designated initializer,即 -[MYViewController initWithID:].

Case 2 的正确方案还有多种,这里不再列举。关于 NS_DESIGNATED_INITIALIZER,它是为了保持初始化代码同一个类只有一份,和在类继承时,initializers 能被正确 override.

0x20 GCD

0x21 在 dispatch_queue_global_t 中的 block 一定在子线程执行?

大家可以在主线程尝试执行下列代码:

dispatch_sync(dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0), ^{
    NSLog(@"%@", NSThread.currentThread);
});

可以得到它的输出:

{number = 1, name = main}

这实际上是 GCD 的一种线程优化策略,当前线程被阻塞时,它闲着也是闲着,GCD 就调度它帮别的 queue 做事儿了,无需再创建线程。

不过,对于 main queue 和 target queue 是 main queue 的 queue,不会采用这种优化。

参见 GCD 文档。

 * As an optimization, dispatch_sync() invokes the workitem on the thread which
 * submitted the workitem, except when the passed queue is the main queue or
 * a queue targetting it (See dispatch_queue_main_t,
 * dispatch_set_target_queue()).

从刚才的例子我们也可以看到:对于 GCD 而言,线程不会被阻塞,被阻塞的是 serial queue

concurrent queue 也不会被阻塞,可以用如下代码验证:

dispatch_queue_global_t globalQueue = dispatch_get_global_queue(QOS_CLASS_DEFAULT, 0);


dispatch_sync(globalQueue, ^{
    dispatch_sync(globalQueue, ^{
         NSLog(@"会不会被执行呢?"); // 会被执行
    });
});

0x22 在 main queue 中执行的代码一定在 main queue 中吗?

是的,你没看错标题,不是 main thread.

首先先来回顾一下 main thread 和 main queue 的关系:

在 main queue 中的任务一定会在 main thread 上执行
在 main thread 上执行的任务不一定在 main queue 中

但是,在 main queue 中执行的任务不一定在 main queue 中,可能让人乍一听有点难以接受。甚至有相当一批 iOS 同学认为,自己平常写的大部分代码都会被系统框架扔进 GCD 的 main queue 中执行。这是真的吗?且听我慢慢分解。

相信大部分同学都见过或写过如下的代码:

if ([NSThread isMainThread]) {
    // do something
} else {
    dispatch_sync(dispatch_get_main_queue(), ^{
        // do something        
    });
}

从上一小节的结论,大家应该已经能发现这样写的问题了。那就是判断当前是否在主线程根本无法保证当前在 main queue 中。那是否能判断当前 queue 呢?由于 dispatch_get_current_queue 已经废弃了,所以,不少同学知道了如下的改进版写法:

// 使用 dispatch_queue_set_specific 预先给 main queue 设置 specific


static const void *const kDispatchMainQueueSpecificKey = &kDispatchMainQueueSpecificKey;
dispatch_queue_set_specific(dispatch_get_main_queue(), kDispatchMainQueueSpecificKey, kDispatchMainQueueSpecificKey, NULL);


// 然后在需要的地方做如下判断


void *spec = dispatch_get_specific(kDispatchMainQueueSpecificKey)
if (spec == kDispatchMainQueueSpecificKey) {
    // do something
} else {
    dispatch_sync(dispatch_get_main_queue(), ^{
        // do something        
    });
}

(PS: 以上两种写法均可能导致死锁,因为无法保证 main queue 此时没有在等待当前 queue)

因此,绝大多数同学认为,只要通过 dispatch_queue_set_specificdispatch_get_specific 就能判断当前代码所在的 queue,当然也就能判断当前是否在 main queue 中。

所以你会去验证,在我们平常写的诸如 viewDidAppear,UIButton 点击的 action,UITableView 的 delegate 方法等代码中,调用 dispatch_get_specific。并且会得到 “当前确实运行在 main queue 中” 的结论。

然而,通过这样的方式就能说明当前执行的代码在 main queue 中吗?让我们来从源码一探究竟吧!

源码采用笔者写文章时最新的,libdispatch(1008.250.7)

首先看下 dispatch_get_specific 到底是如何实现的

void *
dispatch_get_specific(const void *key)
{
  dispatch_queue_t dq = _dispatch_queue_get_current();
  void *ctxt = NULL;


  if (likely(key && dq)) {
    do {
      ctxt = _dispatch_queue_get_specific_inline(dq, key);
      dq = dq->do_targetq;
    } while (unlikely(ctxt == NULL && dq));
  }
  return ctxt;
}

可见,它先调用了 _dispatch_queue_get_current 来获取当前所在的 queue。所以,获取当前所在的 queue 成了关键,那我们再来看看 _dispatch_queue_get_current 的实现。

static inline dispatch_queue_t
_dispatch_queue_get_current(void)
{
  return (dispatch_queue_t)_dispatch_thread_getspecific(dispatch_queue_key);
}

继续看 _dispatch_thread_getspecific,会发现它存在两种实现,取决于是否定义了 DISPATCH_USE_THREAD_LOCAL_STORAGE 宏:

如果没有定义 DISPATCH_USE_THREAD_LOCAL_STORAGE

static inline void *
_dispatch_thread_getspecific(pthread_key_t k)
{
#if DISPATCH_USE_DIRECT_TSD
  if (_pthread_has_direct_tsd()) {
    return _pthread_getspecific_direct(k);
  }
#endif
  return pthread_getspecific(k);
}

可以看到它最终调用了 pthread 的 pthread_getspecific* 方法。

而如果定义了 _dispatch_thread_getspecific

__thread struct dispatch_tsd __dispatch_tsd;


#define _dispatch_thread_getspecific(key) \
  (_dispatch_get_tsd_base()->key)


static inline struct dispatch_tsd *
_dispatch_get_tsd_base(void)
{
  if (unlikely(__dispatch_tsd.tid == 0)) {
    libdispatch_tsd_init();
  }
  OS_COMPILER_CAN_ASSUME(__dispatch_tsd.tid != 0);
  return &__dispatch_tsd;
}


void libdispatch_tsd_init(void)
{
  pthread_setspecific(__dispatch_tsd_key, &__dispatch_tsd);
  __dispatch_tsd.tid = gettid();
}

它使用了线程级(__thread)的变量,并把它塞进了 pthread_setspecific

由此可见,它们最终都是通过 TSD (Thread-Specific Data) 将 queue 绑定在了 thread 上。TSD 是每个线程私有的数据,以 key-value pairs 的形式存在。

获取当前队列实际上就是获取当前线程 TSD 中绑定的 queue.

当 thread 在为 queue 执行其中的 block 时,GCD 就会将 thread 的 TSD 中 dispatch_queue_key 对应的 value 设置成该 queue,完成所谓的“绑定”(bind)。

当 thread 切换所服务的 queue 时,会涉及 TSD 的切换(GCD 内部实现了一个链表,来实现 queue 切换时上下文的入栈和出栈)。

我们可以从这里看到 main thread 当前绑定的不同 queues.

iOS 开发技巧及常见误区_第1张图片

main queue 在 main 函数之前被自动创建并绑定在了 main thread 上。

所以,当 main thread 没有为其它 queues 执行任务时,通过 _dispatch_queue_get_current,你就能得到 main queue。但这并不能说明当前的代码被 dispatch 到了 main queue 中

在 main 函数中,我们调用了 UIApplicationMain,UIApplicationMain 中创建并启动了 main runloop。从此,main thread 就被 main runloop 接管了,后续需要在 main thread 执行的代码就需要通过 Runloop,GCD 也不例外。

GCD 提供了一些列以 _4CF (For CoreFoundation) 结尾的函数来与 Runloop 配合。具体来说,

GCD 提供了 _dispatch_get_main_queue_port_4CF 函数。该函数会创建一个 mach port,将这个 mach port 在 UIApplicationMain() 中会被添加进 main runloop 的 source 中,供 GCD 向 main runloop 发消息。

当有 block 被 dispatch 到 main queue 中,GCD 就会向这个 mach port 发消息,main runloop 就会被唤醒并调用 __CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ 来处理。

__CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ 中会调用 GCD 提供的另一个函数 _dispatch_main_queue_callback_4CF 来真正处理 mian queue 中的 block。

所以 Runloop 不关心 main queue 中的 block 究竟要如何处理,而是把控制权交还给了 GCD。_dispatch_main_queue_callback_4CF 又调用了 _dispatch_main_queue_drain 将 main queue 中的所有任务一次性执行完(drain)。你可以在这里看到 Runloop 最新的(CF-1153.18)源码。

通过上述方式执行的任务,才是真正在 main queue 中的。而诸如上文所提到的 viewDidAppear,事件响应等,并非采用这种方式执行。它们要么也通过添加 Runloop source 来触发,要么通过注册 Runloop observer handler 来触发。

我们可以通过以下几个例子的 call stack 来验证:

  • viewDidAppear

iOS 开发技巧及常见误区_第2张图片

  • 事件响应

iOS 开发技巧及常见误区_第3张图片

  • 手势识别

iOS 开发技巧及常见误区_第4张图片

可以看到,它们的 stack 中都有诸如 __CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__, __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ 函数,它们直接调起了真正要做的事情,而没有调用 GCD。只是当前 main queue 恰好被绑定在了 main thread 上而已

综上

Most iOS code "running in main queue" is not really in main queue!

GCD (iOS) 为什么会给开发者这样的错觉呢?

实际上这是 GCD 希望的。从 GCD 的设计角度,它希望对开发者屏蔽 Threads 概念,忘掉 Threads,专注 Queues 和 Blocks(看起来它没完全做到)。

Threads 在底层被称作 worker,为 queue 服务。使用 GCD 后就不应再去直接使用 Threads。

我们也不应该假定 Threads 和 Queues 之间的关系。main thread 可以为任何 queue 服务,main queue 也应该允许任何 thread 为它服务,只要保证串行即可(当然,GCD 没让这么干,main queue 和 main thread 还是有些特殊,没法实现设计上的完美)。

0x23 dispatch 不能 cancel?

从 iOS 8 开始,GCD 就已经有 cancel API 了:

void dispatch_block_cancel(dispatch_block_t block)

示例如下:

dispatch_block_t block = dispatch_block_create(0, ^{
    NSLog(@"到底会不会被 canel 呢?");
});


dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), block);


dispatch_block_cancel(block);

需要注意的是:

1. 只有通过 dispatch_block_create* 创建的 block 才能被取消。

尽管 dispatch_block_t 被定义为 typedef void (^dispatch_block_t)(void);,下列代码依旧会 crash.

dispatch_block_t block = ^{
    NSLog(@"到底能不能被 canel 呢?");
};


dispatch_async(dispatch_get_main_queue(), block);


dispatch_block_cancel(block); // crash,直接创建的 block 不能被 cancel

注:在 GCD 内部,dispatch_block_t 也被定义为 typedef struct Block_layout *dispatch_block_t

2. 只有还没被执行的 block 才能被取消。

正在执行或者已经被执行的 block 不能被 cancel.

3. 确保 block 不引用任何需要该 block 的执行才能被释放的资源。

参见 Apple 文档

 * NOTE: care needs to be taken to ensure that a block object that may be
 *       canceled does not capture any resources that require execution of the
 *       block body in order to be released (e.g. memory allocated with
 *       malloc(3) that the block body calls free(3) on). Such resources will
 *       be leaked if the block body is never executed due to cancellation.

也许你还想看

Java编码技巧之高效代码50例

高德APP全链路源码依赖分析工程

高德JS依赖分析工程及关键原理


你可能感兴趣的:(iOS 开发技巧及常见误区)