阅读视图

发现新文章,点击刷新页面。

重识 Objective-C Runtime - 看透 Type 与 Value

这是重识 Objective-C Runtime系列文章的其中一篇:

看透 Type 与 Value

对于 C 语言来说,Type 就个比较虚幻的东西,它唯一的目的便是让编译器知道一段数据的长度,来决定如何存取,举个例子:

1
2
int i = 123;
char c = (char)i;

这段代码声明了一个 int 类型的变量和一个 char 类型的变量,有初始化和类型强转过程,在 x86_64 架构下,这两行代码的汇编如下:

1
2
3
4
movl $123, -4(%rbp)
movl -4(%rbp), %eax
movb %al, %cl
movb %cl, -5(%rbp)

汇编看起来混乱,但却能最真实的反映出程序的运行过程,逐行解释下:

1
movl $123, -4(%rbp)

move 指令就是简单的值拷贝,这条指令中出现的 movl 表示按低 32 位的长度来拷贝(也就是一个 int 的长度),与之相似的还有 8 位的 movb(char)、16 位的 movw (short)、64 位的 movq (long in 64) 等;$123 即字面常量值;-4(%rbp) 代表 base pointer - 栈基地址寄存器,偏移 4 字节的位置。这个指令执行后内存如下所示:

1
movl -4(%rbp), %eax

将刚才 4 字节长度内存赋值给 %eax 寄存器,它是最常用的通用寄存器之一,名为 accumulator,在 64 位架构下,rax 表示这个寄存器的完全体,eax 表示它的低 32 位,ax 表示低 16 位,ah 表示第 8~16 位,al 表示最低的 8 位。这样抠门的设计一部分因为兼容历史的 32 架构,一方面也是为了更充分利用寄存器这个宝贵的资源:

1
movb %al, %cl

按 8 位长度 (char) 将 a 寄存器的最低 8 位移动到 c 寄存器(count register)的低 8 位。这一个指令就在做 int 到 char 的类型转换,把 123 存在寄存器的低 32 位上,再把寄存器的最低 8 位取出来,相当于把 00000000000000000000000001111011 截断成了 01111011。

1
movb %cl, -5(%rbp)

最后,再把刚才的结果按 8 字节的长度拷贝到 %rbp 偏移 5 的位置,完成这个 char 类型栈变量的赋值:

因此,对于 C 这种静态语言,Type 信息只用于编译器解析,除了静态检查外还影响生成:

  1. 相应长度的指令 (是 movq、movl 还是 movb ?)
  2. 寄存器长度的选用(是 rax、eax 还是 al ?)
  3. 栈变量内存大小的确定,也可以说是 sp 的位置( sp 表示 Stack Pointer, 它和 Base Pointer 配合管理栈内存的分配与回收,所谓“分配”栈内存只是用如 subq $32, %rsp 的指令将 sp 向低地址移动)

然而,对于动态语言,Type 不仅在编译期起到上述作用,还需要保留到运行时,让动态调用得以实现,被称作 Type Encodings,对于 Objective-C 所有 Type 的编码,都可以在这个官方文档中查到,里面的编码和用 @encode() 生成的一致,比如:

1
2
3
4
5
@encode(int) => "i"
@encode(float) => "f"
@encode(id) => "@"
@encode(SEL) => ":"
@encode(CGRect) => "{CGRect={CGPoint=dd}{CGSize=dd}}" // 64

Objective-C Class 中每个实例变量的 Type 信息全部被编码,Runtime 也提供了 ivar_getTypeEncoding 来访问。
同时,为支持消息的转发和动态调用,Objective-C Method 的 Type 信息也被以 “返回值 Type + 参数 Types” 的形式组合编码,还需要考虑到 self_cmd 这两个隐含参数:

1
2
- (void)foo; => "v@:"
- (int)barWithBaz:(double)baz; => "iv@:d"

注:上面的方法的 Encoding 使用新的格式,旧的格式中包含调用栈大小和布局信息,如 i24@0:8i16i20,表示调用栈帧共 24 字节大小,后面每个参数跟着的数字表示该参数在调用栈的偏移值,在 x86_64 和 ARM 成为主流后,调用的 Calling Conventions 发生巨大变化,开始借助寄存器传参,所以在“参数压栈”时代的这种编码方式逐渐被废弃。

方法的编码可以使用 method_getTypeEncoding 获取,在 Cocoa 层,被 NSMethodSignature 封装,并提供了一些便捷的解析方法。

多说一句,纯 Swift 声称自己是静态的语言,因为在编译后,任何结构都会被 Name Mangling 压缩成一个符号,比如下面的方法:

1
2
3
4
5
class Sark {
func foo(bar: Int) -> Int {
return bar;
}
}

经过 Name Mangling 的符号是 _TFC12TestSwift4Sark3foofT3barSi_Si,虽然把结构都拍扁了,但该有的信息都在,Module、Class、Method、参数和返回值类型等,按照一定的格式进行了编码,感兴趣可以看这篇文章

来罐可乐并催更

重识 Objective-C Runtime - Smalltalk 与 C 的融合

这是重识 Objective-C Runtime系列文章的其中一篇:

2014 年的时候,线下分享了一次 Runtime,为配合分享还出了几个题目,莫名其妙的被当成了面试题,导致大家各种补 Runtime 的知识和文章,甚至后来招人的时候,面试者都反问我为啥不考点 Runtime 的题 - -
时隔快两年,随着最近对这块的理解的加深,咱们来重新认识一下 Runtime。

Smalltalk 与 C 的融合

三十几年前,Brad Cox 和 Tom Love 在主流且高效的 C 语言基础上,借鉴 Smalltalk 的面向对象与消息机制,想要搞出一个易用且轻量的 C 语言扩展,但 C 和 Smalltalk 的思想和语法格格不入,比如在 Smalltalk 中一切皆对象,一切调用都是发消息:

1
233 log

再比如用一个工厂方法来实例化一个对象:

1
p := Person name: 'sunnyxx' age: 26

在当时来看,一个具有面向对象功能的 C 语言真的是非常有吸引力,但必须得解决消息语法的转换,于是乎他们开发了一个 Preprocessor,去解析 Smalltalk 风格的语法,再转换成 C 语言的代码,进而和其他 C 代码一起编译。这个过程和现在 JavaScript 里的 CoffeeScript、JSX 很相似,构建一个 DSL,用转化器转化成原始语言的代码。

想法很美好,但 Smalltalk 语法里面又是空格、又是冒号的,万一遇到个什么复杂嵌套调用,语法解析多难写呀,于是乎他们想,诶呀别费劲了,把消息两边加个中括号吧,这样 Parser 写起来简单多了呢对吧:

1
[Person name:"sunnyxx" age: 26];

这就造就了 Objective-C 奇怪的中括号、冒号四不像语法,这怎么看都是个临时的方案,但在当时可能是唯一方法,借用已有的 C 的编译器比重造一个成本低多了,而且完全兼容 C 语言。随着这几年 Apple 开发的火热,Objective-C 越来越成为 Apple 不爽的地方,先是恨透了在 GCC 上给 Objective-C 加支持,自己重建了个 Clang,后是干脆重新发明了个 Swift 来彻底代替,用 30 年的时间终于还完了这个技术债。

好的,虽然有了个 Preprocessor,但只能做到把 Smalltalk 风格的代码分析并转译成 C,还需要解决两个问题:

  1. C 语言上实现一个 OOP 对象模型
  2. 将 Smalltalk 风格的 Message 机制转换成 C 函数调用

对象模型的设计倒很省事,直接照搬 Smalltalk 的就好了:如 Class / Meta Class / Instance Method / Class Method 这些概念,还有一些关键字如 self / super / nil 等全都是 Smalltalk 的。这步转换在 Preprocessing 过程中就可以完成,因为重写后的 Class 就是原原本本的 C 语言的 Struct,只需要按 Smalltalk 中“类-元类”的模型设置好即可,无需额外的支持。

消息机制就不一样了,要实现向一个 target ( class / instance ) 发送消息名 ( selector ) 动态寻找到函数实现地址 ( IMP ) 并调用的过程,还要处理消息向父类传递、消息转发( Smalltalk 中叫 “Message-Not-Understood”)等,这些行为无法在 Preprocessing 或 Build Time 实现,需要提供若干运行时的 C 函数进行支持,所有这些函数打个包,便形成了最原始的 Runtime

所以最初的 Objective-C = C + Preprocessor + Runtime

注:GCC 中一开始用预处理器来支持 Objective-C,之后作为一个编译器模块,再后来都交给了 Clang 实现。

作为单纯的 C 语言扩展,Runtime 中只要实现几个最基础的函数(如 objc_msgSend)即可,但为了构建整套 Objective-C 面向对象的基础库(如 Foundation),Runtime 还需要提供像 NSObject 这样的 Root Class 作为面向对象的起点、提供运行时反射机制以及运行时对 Class 结构修改的 API 等。再后来,即便是 Objective-C 语言本身的不断发展,新语言特性的加入,也不外乎是扩展 Clang 和扩展 Runtime,比如:

  • ARC:编译器分析对象引用关系,在合适的位置插入内存管理的函数,并需要把这些函数打包加到 Runtime 中,如 objc_storeStrongobjc_storeWeak等,同时还要处理 dealloc 函数,自动加入对 super 的调用等,具体可以看这篇文章
  • Lightweight Generics:叫做 “轻量泛型” 是因为只增加了编译器检查的支持,而泛型信息并未影响到运行时,所以 Runtime 库无需改动。
  • Syntax Sugars:比如 Boxed Expr(@123)、Array Literal(@[...])、Dictionary Literal(@{...})和轻量泛型一样,只是把如 @123 在编译期 rewrite 成 [NSNumber numberWithInt:123] 而已,无需改动 Runtime。
  • Non Fragile Ivars: 类实例变量的动态调整技术,用于实现 Objective-C Binary 的兼容性,随着 Objective-C 2.0 出现,需要编译器和 Runtime 的共同配合,感兴趣的可以看这篇文章

因此,Runtime 的精髓并非在于平日里很少接触的那些所谓“黑魔法” Runtime API、也并非各种 Swizzle 大法,而是在 Objective-C 语言层面如何处理 Type、处理 Value、如何设计 OOP 数据结构和消息机制、如何设计 ABI 等,去了解这么一个小而美的 C 语言运行时扩展是怎么设计出来的。假如非要让我考一道 Runtime 的题,可能是“给你 C 语言,如何实现一个 Objective-C?”,答到哪儿算哪儿。

接下来的文章就找几个有意思的点挨个聊聊。

References

https://zh.wikipedia.org/wiki/Objective-C
http://web.cecs.pdx.edu/~harry/musings/SmalltalkOverview.html

来罐可乐并催更

Clang Attributes 黑魔法小记

Clang Attributes 是 Clang 提供的一种源码注解,方便开发者向编译器表达某种要求,参与控制如 Static Analyzer、Name Mangling、Code Generation 等过程,一般以 __attribute__(xxx) 的形式出现在代码中;为方便使用,一些常用属性也被 Cocoa 定义成宏,比如在系统头文件中经常出现的 NS_CLASS_AVAILABLE_IOS(9_0) 就是 __attribute__(availability(...)) 这个属性的简单写法。

常见属性的介绍,可以看 NSHipster 的介绍文章 和的 twitter 的介绍文章。本文还会介绍几个有意思的 “黑魔法” Attribute,说不定在某些场景下会起到意想不到的效果哦~

以下测试都以 Xcode 7.3 ( Clang 3.8 ) 为准

objc_subclassing_restricted

使用这个属性可以定义一个 Final Class,也就是说,一个不可被继承的类,假设我们有个名叫 Eunuch(太监) 的类,但并不希望有人可以继承自它:

1
2
3
4
@interface Eunuch : NSObject
@end
@interface Child : Eunuch // 太监不能够有孩砸
@end

只要在 @interface 前面加上 objc_subclassing_restricted 这个属性即可:

1
2
3
4
5
__attribute__((objc_subclassing_restricted))
@interface Eunuch : NSObject
@end
@interface Child : Eunuch // <--- Compile Error
@end

objc_requires_super

aka: NS_REQUIRES_SUPER,标志子类继承这个方法时需要调用 super,否则给出编译警告:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@interface Father : NSObject
- (void)hailHydra __attribute__((objc_requires_super));
@end
@implementation Father
- (void)hailHydra {
NSLog(@"hail hydra!");
}
@end
@interface Son : Father
@end
@implementation Son
- (void)hailHydra {
} // <--- Warning missing [super hailHydra]
@end

objc_boxable

Objective-C 中的 @(...) 语法糖可以将基本数据类型 box 成 NSNumber 对象,假如想 box 一个 struct 类型或是 union 类型成 NSValue 对象,可以使用这个属性:

1
2
3
typedef struct __attribute__((objc_boxable)) {
CGFloat x, y, width, height;
} XXRect;

这样一来,XXRect 就具备被 box 的能力:

1
2
3
4
CGRect rect1 = {1, 2, 3, 4};
NSValue *value1 = @(rect1); // <--- Compile Error
XXRect rect2 = {1, 2, 3, 4};
NSValue *value2 = @(rect2); // √

constructor / destructor

顾名思义,构造器和析构器,加上这两个属性的函数会在分别在可执行文件(或 shared library)loadunload 时被调用,可以理解为在 main() 函数调用前和 return 后执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
__attribute__((constructor))
static void beforeMain(void) {
NSLog(@"beforeMain");
}
__attribute__((destructor))
static void afterMain(void) {
NSLog(@"afterMain");
}
int main(int argc, const char * argv[]) {
NSLog(@"main");
return 0;
}

// Console:
// "beforeMain" -> "main" -> "afterMain"

constructor 和 +load 都是在 main 函数执行前调用,但 +load 比 constructor 更加早一丢丢,因为 dyld(动态链接器,程序的最初起点)在加载 image(可以理解成 Mach-O 文件)时会先通知 objc runtime 去加载其中所有的类,每加载一个类时,它的 +load 随之调用,全部加载完成后,dyld 才会调用这个 image 中所有的 constructor 方法。

所以 constructor 是一个干坏事的绝佳时机:

  1. 所有 Class 都已经加载完成
  2. main 函数还未执行
  3. 无需像 +load 还得挂载在一个 Class 中

FDStackViewFDStackViewPatchEntry 方法便是使用的这个时机来实现偷天换日的伎俩。

PS:若有多个 constructor 且想控制优先级的话,可以写成 __attribute__((constructor(101))),里面的数字越小优先级越高,1 ~ 100 为系统保留。

enable_if

这个属性只能用在 C 函数上,可以用来实现参数的静态检查

1
2
3
4
static void printValidAge(int age)
__attribute__((enable_if(age > 0 && age < 120, "你丫火星人?"))) {
printf("%d", age);
}

它表示调用这个函数时必须满足 age > 0 && age < 120 才被允许,于是乎:

1
2
3
printValidAge(26); // √
printValidAge(150); // <--- Compile Error
printValidAge(-1); // <--- Compile Error

cleanup

声明到一个变量上,当这个变量作用域结束时,调用指定的一个函数,Reactive Cocoa 用这个特性实现了神奇的 @onExit,关于这个 attribute,在之前的文章中有介绍,传送门

overloadable

用于 C 函数,可以定义若干个函数名相同,但参数不同的方法,调用时编译器会自动根据参数选择函数原型:

1
2
3
4
5
6
7
8
9
10
11
12
13
__attribute__((overloadable)) void logAnything(id obj) {
NSLog(@"%@", obj);
}
__attribute__((overloadable)) void logAnything(int number) {
NSLog(@"%@", @(number));
}
__attribute__((overloadable)) void logAnything(CGRect rect) {
NSLog(@"%@", NSStringFromCGRect(rect));
}
// Tests
logAnything(@[@"1", @"2"]);
logAnything(233);
logAnything(CGRectMake(1, 2, 3, 4));

objc_runtime_name

用于 @interface@protocol,将类或协议的名字在编译时指定成另一个:

1
2
3
4
5
__attribute__((objc_runtime_name("SarkGay")))
@interface Sark : NSObject
@end

NSLog(@"%@", NSStringFromClass([Sark class])); // "SarkGay"

所有直接使用这个类名的地方都会被替换(唯一要注意的是这时用反射就不对了),最简单粗暴的用处就是去做个类名混淆:

1
2
3
__attribute__((objc_runtime_name("40ea43d7629d01e4b8d6289a132482d0dd5df4fa")))
@interface SecretClass : NSObject
@end

还能用数字开头,怕不怕 - -,假如写个脚本把每个类前加个随机生成的 objc_runtime_name,岂不是最最精简版的代码混淆就完成了呢…

它是我所了解的唯一一个对 objc 运行时类结构有影响的 attribute,通过编码类名可以在编译时注入一些信息,被带到运行时之后,再反解出来,这就相当于开设了一条秘密通道,打通了写码时和运行时。脑洞一下,假如把这个 attribute 定义成宏,以 annotation 的形式完成某些功能,比如:

1
2
3
4
5
6
// @singleton 包裹了 __attribute__((objc_runtime_name(...)))
// 将类名改名成 "SINGLETON_Sark_sharedInstance"
@singleton(Sark, sharedInstance)
@interface Sark : NSObject
+ (instancetype)sharedInstance;
@end

在运行时用 __attribute__((constructor)) 获取入口时机,用 runtime 找到这个类,反解出 “sharedInstance” 这个 selector 信息,动态将 + alloc- init 等方法替换,返回 + sharedInstance 单例。

References

http://llvm.org/releases/3.8.0/tools/clang/docs/AttributeReference.html
http://clang-analyzer.llvm.org/annotations.html

巧用 Class Extension 分离接口依赖

Class ExtensionCategory 是我们经常使用的 Objective-C 语法:

1
2
3
4
5
6
7
// Class Extension
@interface Sark ()
@end

// Category
@interface Sark (Gay)
@end

还记得最开始学习 Objective-C 时,并没有支持 Class Extension,当时只能凑活的用个 Private 的 Category 充当,需要添加私有成员变量时那叫个痛苦,直到大概四年前的 WWDC 终于宣布添加上了 Class Extension 的语法,当时底下的开发者们含泪报以了热烈掌声,它让类的封装变的更加得心用手。

在类组织结构上,Category 可以用来帮助拆分功能,让一个大型的类分治管理:(类似 NSString.h

1
2
3
4
5
6
7
8
9
10
11
12
// Sark.h
@interface Sark : NSObject
@property (nonatomic, copy) NSSting *name;
@end
@interface Sark (Gay)
- (void)behaviorLikeGay;
@end

// Sark+Work.h <----- 也可拆分成多个文件
@interface Sark (Work)
- (void)writeObjectiveC;
@end

不过有两个设计原则必须要遵守:

  1. Category 的实现可以依赖主类,但主类一定不依赖 Category,也就是说移除任何一个 Category 的代码不会对主类产生任何影响。
  2. Category 可以直接使用主类已有的私有成员变量,但不应该为实现 Category 而往主类中添加成员变量,考虑在 Category 的实现中使用 objc association 来达到相同效果。

所以 Category 一定是简单插拔的,就像买个外接键盘来扩展在 MacBook 上的写码能力,但当拔了键盘,MacBook 的运行不会受到任何影响。

而 Class Extension 和 Category 在语言机制上有着很大差别:Class Extension 在编译期就会将定义的 Ivar、属性、方法等直接合入主类,而 Category 在程序启动 Runtime Loading 时才会将属性(没 Ivar)和方法合入主类。但有意思的是,两者在在语法解析层面却只有细微的差别,可以尝试用 clang 命令查看一个文件的 AST(抽象语法树)

1
$ clang -Xclang -ast-dump -fsyntax-only main.m

生成 AST 是 Clang 其中一个比较重要的职责,像 Xcode 的代码补全、语法检查、代码风格规范都是在这一层做的;如果像我一样无聊,也可以玩玩 libclang,一个 C 语言 Clang API,输入代码,就能将其解析成语法树,通过遍历 AST,可以取得每个 Decl 和 Token 的信息和所处的源码行数和位置,大到类定义,小到一个逗号一个分号都能完全掌控,非常有助于理解编译器如何处理源码;有了 libclang,定义些规则就能实现个简单的 Linter 啦。

上面的命令会在控制台中打印出一堆花花绿绿的语法树结构,挑出我们关注的信息:

1
2
3
4
5
6
// ...
|-ObjCCategoryDecl <line:7:1, line:9:2> line:7:12
| |-ObjCInterface 'Sark'
|-ObjCCategoryDecl <line:15:1, line:17:2> line:15:12 Gay
| |-ObjCInterface 'Sark'
// ...

可以看出,Class Extension 和 Category 在 AST 中的表示都是 ObjCCategoryDecl,只是有无名字的区别,也可以说 Class Extension 是匿名的 Category

既然 Category 可以有 N 个,Class Extension 也可以有,且它不限于写在 .m 中,只要在 @implementation 前定义就可以,我们可以利用这个性质,将 Header 中的声明按功能归类:

1
2
3
4
5
6
7
8
9
10
11
12
// Sark.h
@interface Sark : NSObject
// 这里定义了很多基本属性和方法
@end
@interface Sark () // Gay
@property (nonatomic, copy) NSString *gayFriend; // 属性 √
- (void)behaviorLikeGay;
@end
@interface Sark () // Work
@property (nonatomic, copy) NSString *company; // 属性 √
- (void)writeObjectiveC;
@end

与 Category 不同,Class Extension 的分组形式并没有破坏 “一个主类” 的 基本外交原则 基本结构,还可以把属性( Ivar )也放心丢进来。

— 正题分割线 —

除此之外,Class Extension 还能巧妙的解决一个接口暴露问题,若有下面的声明:

1
2
3
4
5
6
7
8
9
10
// Sark.framework/Sark.h
@interface Sark : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, copy) NSString *creditCardPassword; // secret!
@end

// Sark.framework/PrivateSarkWife.h
@interface PrivateSarkWife : NSObject
- (void)robAllMoneyFromCreditCardOfSark:(Sark *)sark; // needs password!
@end

假设 Sark.hSark.framework 唯一暴露的 Header,而 framework 中的一个私有类需要获取这个公共类的某个属性(或方法)该怎么办?上面的 creditCardPassword 属性需要一个对外不可见而对内可见的地方声明,这时候可以利用 Class Extension:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Sark.h
@interface Sark : NSObject
@property (nonatomic, copy) NSString *name;
@end

// Sark+Internal.h <--- new
@interface Sark ()
@property (nonatomic, copy) NSString *creditCardPassword;
@end

// Sark.m
#import "Sark.h"
#import "Sark+Internal.h" // <--- new

将对公业务和对私业务用 Class Extension 的形式拆到两个 Header 中,这样私有类对私有属性的依赖就被成功隔离开了:

1
2
3
4
5
6
7
8
9
// PrivateSarkWife.m
#import "PrivateSarkWife.h"
#import "Sark+Internal.h" // <--- 私有依赖

@implementation PrivateSarkWife
- (void)robAllMoneyFromCreditCardOfSark:(Sark *)sark {
NSString *password = sark.creditCardPassword; // oh yeah!
}
@end

Done.

iOS 开发中的 Self-Manager 模式

Self-Manager 源于我们团队内部的黑话,“诶?你刚去的创业公司有几个 iOS 开发啊?” “就我一个” “靠,你这是 Self-Manager 啊”

最近,这个思路被我们当做了一种设计模式,即赋予一个 Widget 更大的权利,让其自己负责自己的事件。
举个简单的栗子,这种负责展示头像的视图:

它的职责包括:

  1. 通过传入的 URL,加载并展示头像图片
  2. 显示一些附属信息,比如大V的标志
  3. 将用户点击头像的事件传递给外层的 View Controller 跳转到用户信息页面

于是乎这个 Widget 的 API 可以长这个样子:

1
2
3
4
@interface FDAvatarView : UIView
// 假设 VIPInfo 是某个 Entity
- (void)configureWithAvatarURL:(NSURL *)URL VIPInfo:(id)info tapped:(void (^)(void))block;
@end

使用这个控件的人只需要调用这个 configure 方法就可以配置入参和事件处理。但随之而来的就是一些蛋疼的问题:

  1. configure 的调用者是 superview,上面的例子中也就是一个 UITableViewCell,但 Cell 这层并不知道自己的 ViewController 是谁,于是乎还得向上一级传递这个点击事件,直到能获取到 NavigationController,然后 Push 一个用户信息的页面。
  2. 这个 Avatar View 在 App 的各个地方都可能粗线,而且行为一致,那就意味着事件处理的 block,要散落在各个页面中,同时也带来了很多“只是为向上一层级转发事件”的 “Middle Man”

为解决这个问题,就需要给这个 View 放权,让其自己 Handle 自己的事件,也就是 Self-Managed,为了不破坏 View 的纯洁性,比较好的实践是在 Category 中实现:

1
2
3
@interface FDAvatarView (FDAvatarViewSelfManager)
- (void)selfManagedConfigureWithAvatarURL:(NSURL *)URL VIPInfo:(id)info uid:(NSString *)uid;
@end

实现时最好要调用 View 主类提供的 API:

1
2
3
4
5
6
7
8
9
10
11
12
13
@implementation FDAvatarView (FDAvatarViewSelfManager)
// 为后一个页面的创建增加了个 UID 参数
- (void)selfManagedConfigureWithAvatarURL:(NSURL *)URL VIPInfo:(id)info UID:(NSString *)UID {
[self configureWithAvatarURL:URL VIPInfo:info tapped:^{
// 假设 App 结构是 Root -> TabBar -> Navigation -> ViewController
UITabBarController *tabBarControler = (id)[UIApplication.sharedApplication.delegate.window.rootViewController;
UINavigationController *navigationController = tabBarControler.selectedViewController;
// 创建用户信息 View Controller
FDUserProfileViewController *profileViewController = [FDUserProfileViewController viewControllerWithUID:UID];
[navigationController pushViewController:profileViewController animated:YES];
}];
}
@end

这里用到了类似 AOP 的思路,添加了对 App 层级的耦合,如果觉得这样的耦合方式不妥的话,也可以封装个全局方法去取到当前顶层的 Navigation Controller。
这样,FDAvatarView 的调用者只需要配置入参,其余的它自己全能搞定了,即使 App 内很多处出现头像,逻辑代码也只有一份。

接下来再来个例子:

这个点赞的按钮功能上有几个职责:

  1. 显示已有的点赞数
  2. 点击按钮后执行一个小动画,点赞数 +1,同时发送网络请求。
  3. 若已经点赞,点击执行反向操作
  4. 若网络请求发送失败,则回退成点击前的状态

这个控件的 API 可以设计成这样:

1
2
3
@interface FDLikeButton : UIButton
- (void)configureLikeStatus:(BOOL)likeOrNot count:(NSInteger)count animated:(BOOL)animated;
@end

因为继承自 UIButton,所以外部可以直接设置其 action,就不增加 tappedHandler 的参数了。外部在点击事件中需要调用这个配置方法,播放点赞动画,紧接着发送一个网络请求,若网络请求失败,可以再次调用这个 API 的无动画版本回滚状态。但像上一个例子一样,网络请求和事件处理逻辑相同,但代码却分部在各个页面中,于是给这个 View 增加 Self-Managed 模式的 Category:

1
2
3
@interface FDLikeButton (FDLikeButtonSelfManager)
- (void)selfManagedConfigureWithLikeStatus:(BOOL)likeOrNot count:(NSInteger)count;
@end

伪代码的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@implementation FDLikeButton (FDLikeButtonSelfManager)
- (void)selfManagedConfigureWithLikeStatus:(BOOL)likeOrNot count:(NSInteger)count {
[self configureLikeStatus:likeOrNot count:count animated:NO];
[self addTarget:self action:@selector(likeButtonTapped:) forControlEvents:UIControlEventTouchUpInside];
}
- (void)likeButtonTapped:(id)sender {
// +1 or -1 with animation
// Network request ^(NSError *error) {
// if (error) {
// rollback
// }
// }
}
@end

记得面试题的那篇文章里还调侃说 “面试的时候聊聊设计、架构挺好的,但别整出个往 UIButton 的子类里搞网络请求的奇葩结构就行”,结果就被自己打了个脸。不过从设计上,Self-Manager 模式并没有破坏原有的 MVC 结构,上面两个例子中的 View 依然可以不耦合具体业务逻辑的单拿出来用。使用 Category 的方式把应该写在 ViewController 中的代码移动到 View 的文件中,让功能更加的内聚。

程序的复杂度并不会因哪种酷炫的设计模式所减少,能做到的只是对复杂度的切分和控制,即:

  1. 让一大坨恶心的代码变成几小坨不那么恶心的代码。
  2. 让恶心的代码只在一个地方恶心。

Self-Manager 模式我们实践的时候写起来很开心,抛砖引玉一下,希望也能解决你的苦恼。

Objective-C Class Ivar Layout 探索

这次探索源于一个朋友问的问题,当我们定义一个类的实例变量的时候,可以指定其修饰符:

1
2
3
4
5
6
@interface Sark : NSObject {
__strong id _gayFriend; // 无修饰符的对象默认会加 __strong
__weak id _girlFriend;
__unsafe_unretained id _company;
}
@end

这使得 ivar (instance variable) 可以像属性一样在 ARC 下进行正确的引用计数管理。

那么问题来了,假如这个类是动态生成的:

1
2
3
4
5
Class class = objc_allocateClassPair(NSObject.class, "Sark", 0);
class_addIvar(class, "_gayFriend", sizeof(id), log2(sizeof(id)), @encode(id));
class_addIvar(class, "_girlFriend", sizeof(id), log2(sizeof(id)), @encode(id));
class_addIvar(class, "_company", sizeof(id), log2(sizeof(id)), @encode(id));
objc_registerClassPair(class);

该如何像上面一样来添加 ivar 的属性修饰符呢?

刨根问底了一下,发现 ivar 的修饰信息存放在了 Class 的 Ivar Layout 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct class_ro_t {
uint32_t flags;
uint32_t instanceStart;
uint32_t instanceSize;
#ifdef __LP64__
uint32_t reserved;
#endif
const uint8_t * ivarLayout; // <- 记录了哪些是 strong 的 ivar

const char * name;
const method_list_t * baseMethods;
const protocol_list_t * baseProtocols;
const ivar_list_t * ivars;

const uint8_t * weakIvarLayout; // <- 记录了哪些是 weak 的 ivar
const property_list_t *baseProperties;
};

ivarLayout 和 weakIvarLayout 分别记录了哪些 ivar 是 strong 或是 weak,都未记录的就是基本类型和 __unsafe_unretained 的对象类型。

这两个值可以通过 runtime 提供的几个 API 来访问:

1
2
3
4
const uint8_t *class_getIvarLayout(Class cls)
const uint8_t *class_getWeakIvarLayout(Class cls)
void class_setIvarLayout(Class cls, const uint8_t *layout)
void class_setWeakIvarLayout(Class cls, const uint8_t *layout)

但我们几乎没可能用到这几个 API,IvarLayout 的值由 runtime 确定,没必要关心它的存在,但为了解决上述问题,我们试着破解了 IvarLayout 的编码方式。

举个例子说明,若类定义为:

1
2
3
4
5
6
@interface Foo : NSObject {
__strong id ivar0;
__weak id ivar1;
__weak id ivar2;
}
@end

则储存 strong ivar 的 ivarLayout 的值为 0x012000

储存 weak ivar 的 weakIvarLayout 的值为 0x1200

一个 uint8_t 在 16 进制下是两位,所以编码的值每两位一对儿,以上面的 ivarLayout 为例:

  1. 前两位 01 表示有 0 个非 strong 对象和 1 个 strong 对象
  2. 之后两位 20 表示有 2 个非 strong 对象和 0 个 strong 对象
  3. 最后两位 00 为结束符,就像 cstring 的 \0 一样

同理,上面的 weakIvarLayout:

  1. 前两位 12 表示有 1 个非 weak 对象和接下来连续 2 个 weak 对象
  2. 00 结束符

这样,用两个 layout 编码值就可以排查出一个 ivar 是属于 strong 还是 weak 的,若都没有找到,就说明这个对象是 unsafe_unretained.

做个练习,若类定义为:

1
2
3
4
5
6
7
8
@interface Bar : NSObject {
__weak id ivar0;
__strong id ivar1;
__unsafe_unretained id ivar2;
__weak id ivar3;
__strong id ivar4;
}
@end

则储存 strong ivar 的 ivarLayout 的值为 0x012100

储存 weak ivar 的 weakIvarLayout 的值为 0x01211000

于是乎将 class 的创建代码增加了两个 ivarLayout 值的设置:

1
2
3
4
5
6
7
Class class = objc_allocateClassPair(NSObject.class, "Sark", 0);
class_addIvar(class, "_gayFriend", sizeof(id), log2(sizeof(id)), @encode(id));
class_addIvar(class, "_girlFriend", sizeof(id), log2(sizeof(id)), @encode(id));
class_addIvar(class, "_company", sizeof(id), log2(sizeof(id)), @encode(id));
class_setIvarLayout(class, (const uint8_t *)"\x01\x12"); // <--- new
class_setWeakIvarLayout(class, (const uint8_t *)"\x11\x10"); // <--- new
objc_registerClassPair(class);

本以为解决了这个问题,但是 runtime 继续打脸,strong 和 weak 的内存管理并没有生效,继续研究发现, class 的 flags 中有一个标记位记录这个类是否 ARC,正常编译的类,且标识了 -fobjc-arc flag 时,这个标记位为 1,而动态创建的类并没有设置它。所以只能继续黑魔法,运行时把这个标记位设置上,探索过程不赘述了,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
static void fixup_class_arc(Class class) {
struct {
Class isa;
Class superclass;
struct {
void *_buckets;
#if __LP64__
uint32_t _mask;
uint32_t _occupied;
#else
uint16_t _mask;
uint16_t _occupied;
#endif
} cache;
uintptr_t bits;
} *objcClass = (__bridge typeof(objcClass))class;
#if !__LP64__
#define FAST_DATA_MASK 0xfffffffcUL
#else
#define FAST_DATA_MASK 0x00007ffffffffff8UL
#endif
struct {
uint32_t flags;
uint32_t version;
struct {
uint32_t flags;
} *ro;
} *objcRWClass = (typeof(objcRWClass))(objcClass->bits & FAST_DATA_MASK);
#define RO_IS_ARR 1<<7
objcRWClass->ro->flags |= RO_IS_ARR;

}

把这个 fixup 放在 objc_registerClassPair(class); 之后,这个动态的类终于可以像静态编译的类一样操作 ivar 了,可以测试一下:

1
2
3
4
5
6
7
8
9
10
11
id sark = [class new];
Ivar weakIvar = class_getInstanceVariable(class, "_girlFriend");
Ivar strongIvar = class_getInstanceVariable(class, "_gayFriend");
{
id girl = [NSObject new];
id boy = [NSObject new];
object_setIvar(sark, weakIvar, girl);
object_setIvar(sark, strongIvar, boy);
} // ARC 在这里会释放大括号内的 girl,boy
// 输出:weakIvar 为 nil,strongIvar 有值
NSLog(@"%@, %@", object_getIvar(sark, weakIvar), object_getIvar(sark, strongIvar));

Done.

招聘一个靠谱的 iOS

近一年内陆续面试了不少人了,从面试者到面试官的转变让我对 iOS 招聘有了更多的感受。经过了前段时间的一大波面试,我们终于找到了志同道合的小伙伴,面试也暂时告一段落了。总结下面试人过程中的感受,你也可以读到我们对简历、算法、性格、iOS 基础、底层知识的看法和一些常问的面试题。

一个靠谱的简历

简历非常能反映一个人的性格和水平,相比于你在学校获得多少奖项,工作经历、项目经历、熟悉的技术等更加关键,如果还有博客和一些 Github 上的项目,好感度++,但记得在去面试前收拾下,我们真的会挨个文件 review 你的开源代码的。我们还喜欢关注一些细节,比如简历里关键字的拼写,看似无关紧要但很能反映出对自己的要求,经常见一个简历中 iOS 这三个字母的拼写就出现 IOS、iOS、ios 三种的,非常不能忍,再列举几个常见问题:

iPhone -> IPHONE IPhone
Xcode -> XCode xcode
Objective-C -> Object-C
JSON -> Json
HTTP -> Http

还有,注意中英文间用一个半角空格隔开,排版会漂亮很多,简历承载的不仅是内容,还有细节和态度,上面这些点往往都反映着面试者的代码风格、做事的认真程度。当然,简历写的很漂亮但面聊之后发现啥都不会的也有,甚至见过来面试上来就跟我说简历是假的,就想求个面试机会这种 - -

面试

别迟到,别迟到,别迟到,重要的事说三遍。有变动提前通知 HR,碰到过临时有事没来,和谁都不说一声,打电话过去还要求改个时间的,这种直接拜拜。
面试时最好准备纸、笔、简历,可能用不上,但很能体现认真程度。有条件的话带着 Mac 和源码,手机中装好所有在简历中出现的 App。

关于算法

我们是实用主义,iOS 开发中很少需要自己写复杂的算法,所以不在面试考核标准中。

代码规范

这是一个重点考察项,曾经在微博上发过一个风格纠错题:

也曾在面试时让人当场改过,槽点不少,能够有 10 处以上修改的就基本达到标准了(处女座的人在这方面表现都很优秀

一个区分度很大的面试题

考察一个面试者基础咋样,基本上问一个 @property 就够了:

  • @property 后面可以有哪些修饰符?
  • 什么情况使用 weak 关键字,相比 assign 有什么不同?
  • 怎么用 copy 关键字?
  • 这个写法会出什么问题: @property (copy) NSMutableArray *array;
  • 如何让自己的类用 copy 修饰符?如何重写带 copy 关键字的 setter?

这一套问题区分度比较大,如果上面的问题都能回答正确,可以延伸问更深入点的:

  • @property 的本质是什么?ivar、getter、setter 是如何生成并添加到这个类中的
  • @protocol 和 category 中如何使用 @property
  • runtime 如何实现 weak 属性

每个人擅长的领域不一样,我们一般会从简历上找自己写擅长的技术聊,假如自己并不是很熟,最好别写出来或扯出来,万一面试官刚好非常精通这里就露馅了。


Checklist

总结过些面试题,没坚持下去,后来把这些当 checklist,面试的时候实在没话聊的时候做个提醒,语言、框架、运行机制性质的:

[※]@property中有哪些属性关键字?
[※]weak属性需要在dealloc中置nil么?
[※※]@synthesize和@dynamic分别有什么作用?
[※※※]ARC下,不显式指定任何属性关键字时,默认的关键字都有哪些?
[※※※]用@property声明的NSString(或NSArrayNSDictionary)经常使用copy关键字,为什么?如果改用strong关键字,可能造成什么问题?
[※※※]@synthesize合成实例变量的规则是什么?假如property名为foo,存在一个名为_foo的实例变量,那么还会自动合成新变量么?
[※※※※※]在有了自动合成属性实例变量之后,@synthesize还有哪些使用场景?

[※※]objc中向一个nil对象发送消息将会发生什么?
[※※※]objc中向一个对象发送消息[obj foo]objc_msgSend()函数之间有什么关系?
[※※※]什么时候会报unrecognized selector的异常?
[※※※※]一个objc对象如何进行内存布局?(考虑有父类的情况)
[※※※※]一个objc对象的isa的指针指向什么?有什么作用?
[※※※※]下面的代码输出什么?

1
2
3
4
5
6
7
8
9
10
11
@implementation Son : Father
- (id)init
{
self = [super init];
if (self) {
NSLog(@"%@", NSStringFromClass([self class]));
NSLog(@"%@", NSStringFromClass([super class]));
}
return self;
}
@end

[※※※※]runtime如何通过selector找到对应的IMP地址?(分别考虑类方法和实例方法)
[※※※※]使用runtime Associate方法关联的对象,需要在主对象dealloc的时候释放么?
[※※※※※]objc中的类方法和实例方法有什么本质区别和联系?
[※※※※※]_objc_msgForward函数是做什么的,直接调用它将会发生什么?
[※※※※※]runtime如何实现weak变量的自动置nil?
[※※※※※]能否向编译后得到的类中增加实例变量?能否向运行时创建的类中添加实例变量?为什么?

[※※※]runloop和线程有什么关系?
[※※※]runloop的mode作用是什么?
[※※※※]以+ scheduledTimerWithTimeInterval...的方式触发的timer,在滑动页面上的列表时,timer会暂定回调,为什么?如何解决?
[※※※※※]猜想runloop内部是如何实现的?

[※]objc使用什么机制管理对象内存?
[※※※※]ARC通过什么方式帮助开发者管理内存?
[※※※※]不手动指定autoreleasepool的前提下,一个autorealese对象在什么时刻释放?(比如在一个vc的viewDidLoad中创建)
[※※※※]BAD_ACCESS在什么情况下出现?
[※※※※※]苹果是如何实现autoreleasepool的?

[※※]使用block时什么情况会发生引用循环,如何解决?
[※※]在block内如何修改block外部变量?
[※※※]使用系统的某些block api(如UIView的block版本写动画时),是否也考虑引用循环问题?

[※※]GCD的队列(dispatch_queue_t)分哪两种类型?
[※※※※]如何用GCD同步若干个异步调用?(如根据若干个url异步加载多张图片,然后在都下载完成后合成一张整图)
[※※※※]dispatch_barrier_async的作用是什么?
[※※※※※]苹果为什么要废弃dispatch_get_current_queue?
[※※※※※]以下代码运行结果如何?

1
2
3
4
5
6
7
8
9
- (void)viewDidLoad
{
[super viewDidLoad];
NSLog(@"1");
dispatch_sync(dispatch_get_main_queue(), ^{
NSLog(@"2");
});
NSLog(@"3");
}

[※※]addObserver:forKeyPath:options:context:各个参数的作用分别是什么,observer中需要实现哪个方法才能获得KVO回调?
[※※※]如何手动触发一个value的KVO
[※※※]若一个类有实例变量NSString *_foo,调用setValue:forKey:时,可以以foo还是_foo作为key?
[※※※※]KVC的keyPath中的集合运算符如何使用?
[※※※※]KVC和KVO的keyPath一定是属性么?
[※※※※※]如何关闭默认的KVO的默认实现,并进入自定义的KVO实现?
[※※※※※]apple用什么方式实现对一个对象的KVO?

[※※]IBOutlet连出来的视图属性为什么可以被设置成weak?
[※※※※※]IB中User Defined Runtime Attributes如何使用?

[※※※]如何调试BAD_ACCESS错误
[※※※]lldb(gdb)常用的调试命令?

这些小题可以做为讨论的入口,根据面试者的回答再继续聊下去。其中一些题比较底层,是留给屌屌的面试者或者试探评级用的,一般情况并不是重点的考察内容。

业务能力

毕竟平常的工作内容不是 runtime、runloop,不怎么会用到底层的黑魔法,80% 的时间都是和搭建页面、写业务逻辑、网络请求打交道。
要求面试者能够熟练构建 UI,我会找一个面试者做过的页面让他分析下页面结构、约束或者 frame 布局的连法和计算方法;有时也会让面试者说说 UITableView 常用的几个 delegate 和 data source 代理方法,动态 Cell 高度计算什么的;接下来,在手机里随便找一个 App 的页面,让面试者当场说说如果是他写应该用哪些 UI 组件和布局方式等。问几个问题后就能大概了解业务能力了,我们这边重度使用 IB 和 AutoLayout,假如面试者依然使用代码码 UI 也到没关系,有“从良”意愿就很好~

程序架构和一些设计模式如果面试者自己觉得还不错的话也会聊聊,但跪求别说 Singleton 了,用的越多对水平就越表示怀疑。对设计模式自信的我一般问一个问题,抽象工厂模式在 Cocoa SDK 中哪些类中体现?
架构上 MVC 还是 MVVM 还是 MVP 神马的到是可以聊聊各自的见解,反正也没有正确答案,只要别搞的太离谱就行,比如有的人说网络请求和数据库的操作最好放到 UIView 的子类里面干。

网络请求、数据库等各家都有成熟的封装,基本知道咋用就行。除此之外,我们还会顺带的问下除了 iOS 开发外,还会什么其他编程语言、或者熟悉哪种脚本语言和 Terminal 操作等,甚至还问问是如何翻墙- -,相信这些技能都是很重要的。

性格

大家都是写程序的,没啥必要用奇怪的、很难的问题难为对方,更关键的还是性格,和 Team 的风格是不是和的来。一个心态良好的面试者需要有个平常心,不傲娇也不跪舔,表达要正常,经常遇到问一个问题后一两分钟一直处于沉思状态,一句话不说,交流像挤牙膏一样,很是憋屈;还有非常屌屌的,明明不懂仍然强行据理力争,镇得住面试官也罢,撞枪口上就别怪不客气了- - 。决定要不要一个人基本上聊 5 分钟就可以确定了,喜欢水到渠成的感觉,看对眼了挡都挡不住。

招聘告一段落,后面将会有更精彩的事情发生。最后,再次感谢大家的支持和对我的信任。

2015 Objective-C 新特性

Overview

自 WWDC 2015 推出和开源 Swift 2.0 后,大家对 Swift 的热情又一次高涨起来,在羡慕创业公司的朋友们大谈 Swift 新特性的同时,也有很多像我一样工作上依然需要坚守着 Objective-C 语言的开发者们。今年的 WWDC 中介绍了几个 Objective-C 语言的新特性,还是在“与 Swift 协同工作”这种 Topic 里讲的,越发凸显这门语言的边缘化了,不过有新特性还是极好的,接下来,本文将介绍下面三个主要的新特性:

  • Nullability
  • Lightweight Generics *
  • __kindof

Nullability

然而 Nullability 并不算新特性了,从上一个版本的 llvm 6.1 (Xcode 6.3) 就已经支持。这个简版的 Optional ,没有 Swift 中 ?! 语法糖的支持,在 Objective-C 中就显得非常啰嗦了:

1
2
3
@property (nonatomic, strong, nonnull) Sark *sark;
@property (nonatomic, copy, readonly, nullable) NSArray *friends;
+ (nullable NSString *)friendWithName:(nonnull NSString *)name;

假如用来修饰一个变量,前面还要加双下划线,放到 block 里面就更加诡异,比如一个 Request 的 start 方法可以写成:

1
- (void)startWithCompletionBlock:(nullable void (^)(NSError * __nullable error))block;

除了这俩外,还有个 null_resettable 来表示 setter nullable,但是 getter nonnull,绕死了,最直观例子就是 UIViewController 中的 view 属性:

1
@property (null_resettable, nonatomic, strong) UIView *view;

它可以被设成 nil,但是调用 getter 时会触发 -loadView 从而创建并返回一个非 nil 的 view。
从 iOS9 SDK 中可以发现,头文件中所有 API 都已经增加了 Nullability 相关修饰符,想了解这个特性的用法,翻几个系统头文件就差不离了。接口中 nullable 的是少数,所以为了防止写一大堆 nonnull,Foundation 还提供了一对儿宏,包在里面的对象默认加 nonnull 修饰符,只需要把 nullable 的指出来就行,黑话叫 Audited Regions:

1
2
3
4
5
6
7
NS_ASSUME_NONNULL_BEGIN
@interface Sark : NSObject
@property (nonatomic, copy, nullable) NSString *workingCompany;
@property (nonatomic, copy) NSArray *friends;
- (nullable NSString *)gayFriend;
@end
NS_ASSUME_NONNULL_END

Nullability 在编译器层面提供了空值的类型检查,在类型不符时给出 warning,方便开发者第一时间发现潜在问题。不过我想更大的意义在于能够更加清楚的描述接口,是主调者和被调者间的一个协议,比多少句文档描述都来得清晰,打个比方:

1
+ (nullable instancetype)URLWithString:(NSString *)URLString;

NSURL 的这个 API 前面加了 nullable 后,更加显式的指出了这个接口可能因为 URLString 的格式错误而创建失败,使用时自然而然的就考虑到了判空处理。
不仅是属性和方法中的对象,对于局部的对象、甚至 c 指针都可以用带双下划线的修饰符,可以理解成能用 const 关键字的地方都能用 Nullability。
所以 Nullability 总的来说就是,写着丑B,用着舒服 - -

Lightweight Generics

Lightweight Generics 轻量级泛型,轻量是因为这是个纯编译器的语法支持(llvm 7.0),和 Nullability 一样,没有借助任何 objc runtime 的升级,也就是说,这个新语法在 Xcode 7 上可以使用且完全向下兼容(更低的 iOS 版本)

带泛型的容器

这无疑是本次最重大的改进,有了泛型后终于可以指定容器类中对象的类型了:

1
2
NSArray<NSString *> *strings = @[@"sun", @"yuan"];
NSDictionary<NSString *, NSNumber *> *mapping = @{@"a": @1, @"b": @2};

返回值的 id 被替换成具体的类型后,令人感动的代码提示也出来了:

假如向泛型容器中加入错误的对象,编译器会不开心的:

系统中常用的一系列容器类型都增加了泛型支持,甚至连 NSEnumerator 都支持了,这是非常 Nice 的改进。和 Nullability 一样,我认为最大的意义还是丰富了接口描述信息,对比下面两种写法:

1
2
@property (readonly) NSArray *imageURLs;
@property (readonly) NSArray<NSURL *> *imageURLs;

不用多想就清楚下面的数组中存的是什么,避免了 NSString 和 NSURL 的混乱。

自定义泛型类

比起使用系统的泛型容器,更好玩的是自定义一个泛型类,目前这里还没什么文档,但拦不住我们写测试代码,假设我们要自定义一个 Stack 容器类:

1
2
3
4
5
@interface Stack<ObjectType> : NSObject
- (void)pushObject:(ObjectType)object;
- (ObjectType)popObject;
@property (nonatomic, readonly) NSArray<ObjectType> *allObjects;
@end

这个 ObjectType 是传入类型的 placeholder,它只能在 @interface 上定义(类声明、类扩展、Category),如果你喜欢用 T 表示也 ok,这个类型在 @interface 和 @end 区间的作用域有效,可以把它作为入参、出参、甚至内部 NSArray 属性的泛型类型,应该说一切都是符合预期的。我们还可以给 ObjectType 增加类型限制,比如:

1
2
3
4
// 只接受 NSNumber * 的泛型
@interface Stack<ObjectType: NSNumber *> : NSObject
// 只接受满足 NSCopying 协议的泛型
@interface Stack<ObjectType: id<NSCopying>> : NSObject

若什么都不加,表示接受任意类型 ( id );当类型不满足时编译器将产生 error。
实例化一个 Stack,一切工作正常:

对于多参数的泛型,用逗号隔开,其他都一样,可以参考 NSDictionary 的头文件。

协变性和逆变性

当类支持泛型后,它们的 Type 发生了变化,比如下面三个对象看上去都是 Stack,但实际上属于三个 Type:

1
2
3
Stack *stack; // Stack *
Stack<NSString *> *stringStack; // Stack<NSString *>
Stack<NSMutableString *> *mutableStringStack; // Stack<NSMutableString *>

当其中两种类型做类型转化时,编译器需要知道哪些转化是允许的,哪些是禁止的,比如,默认情况下:

我们可以看到,不指定泛型类型的 Stack 可以和任意泛型类型转化,但指定了泛型类型后,两个不同类型间是不可以强转的,假如你希望主动控制转化关系,就需要使用泛型的协变性逆变性修饰符了:

__covariant - 协变性,子类型可以强转到父类型(里氏替换原则)
__contravariant - 逆变性,父类型可以强转到子类型(WTF?)

协变:

1
@interface Stack<__covariant ObjectType> : NSObject

效果:

逆变:

1
@interface Stack<__contravariant ObjectType> : NSObject

效果:

协变是非常好理解的,像 NSArray 的泛型就用了协变的修饰符,而逆变我还没有想到有什么实际的使用场景。

__kindof

__kindof 这修饰符还是很实用的,解决了一个长期以来的小痛点,拿原来的 UITableView 的这个方法来说:

1
- (id)dequeueReusableCellWithIdentifier:(NSString *)identifier;

使用时前面基本会使用 UITableViewCell 子类型的指针来接收返回值,所以这个 API 为了让开发者不必每次都蛋疼的写显式强转,把返回值定义成了 id 类型,而这个 API 实际上的意思是返回一个 UITableViewCell 或 UITableViewCell 子类的实例,于是新的 __kindof 关键字解决了这个问题:

1
- (__kindof UITableViewCell *)dequeueReusableCellWithIdentifier:(NSString *)identifier;

既明确表明了返回值,又让使用者不必写强转。再举个带泛型的例子,UIView 的 subviews 属性被修改成了:

1
@property (nonatomic, readonly, copy) NSArray<__kindof UIView *> *subviews;

这样,写下面的代码时就没有任何警告了:

1
UIButton *button = view.subviews.lastObject;

Where to go

有了上面介绍的这些新特性以及如 instancetype 这样的历史更新,Objective-C 这门古老语言的类型检测和类型推断终于有所长进,现在不论是接口还是代码中的 id 类型都越来越少,更多潜在的类型错误可以被编译器的静态检查发现。
同时,个人感觉新版的 Xcode 对继承链构造器的检测也加强了,NS_DESIGNATED_INITIALIZER 这个宏并不是新面孔,可以使用它标志出像 Swift 一样的指定构造器和便捷构造器。

最后,附上一段用上了所有新特性的代码,Swift 是发展趋势,如果你暂时依然要写 Objective-C 代码,把所有新特性都用上,或许能让你到新语言的迁移更无痛一点。

References

https://msdn.microsoft.com/zh-cn/library/dd799517.aspx
https://gist.github.com/jtbandes/881f07a955ff2eadd1a0

请我喝瓶可乐?

一个丝滑的全屏滑动返回手势

全屏返回手势

自 iOS7 之后,Apple 增加了屏幕边缘右划返回交互的支持,再配合上 UINavigationController 的交互式动画,pop 到上一级页面的操作变的非常顺畅和丝滑,从此,我很少再使用点击左上角导航栏上的返回按钮的方式返回了,因为这对单手操作十分不友好;如果一个 App 居然胆敢不支持滑动返回,那离被卸载就不远了。

说到全屏返回手势,首先我感觉这件事本身可能就有问题,毕竟有点反苹果官方的交互,让用户从任意的地方都能够滑动返回这个交互在国内的 App 中非常普遍,比如我手机中的手Q、微博、网易新闻、大众点评等,当然还有百度知道- -。这里得对微信的产品经理们得点个赞,从整个 App 来看,不论是交互还是 UI 结构和样式都非常的 iOS,没有什么特别奇葩的页面和交互,以至于使用 UIKit 原生的框架可以非常简单的搭建起来,这也符合我个人对 App 的一个愿景:一个优秀的 App 不论从用户角度看还是从代码角度看都应该是简单且优雅的,呼吁各家产品经理可以多借鉴下像微信这样很本色的 App 设计。(以后可以分享下如何使用 Storyboard 在一小时内快速搭建起微信 UI)

FDFullscreenPopGesture

工作毕竟是工作,于是乎所以就被迫实现了套 pan 手势处理加截图和视差,虽然在运动曲线上、bar 截图处理上下了不少功夫,但距离系统的丝滑效果还是差距挺远。随时间推移,终于能够最低支持 iOS7 后,我们把这个问题再次拿出来讨论和研究,直到在微博上看到了 J_雨同学的这篇文章 后才找到了这个迄今为止最简单的解决方案。于是乎在他的授权下,我们在 forkingdog 上把这个返回手势开源,github地址,并果断应用到了百度知道 App 内,这是 Demo 效果:

利用了系统自己的边缘返回手势处理函数后,一切动画和曲线都和原生效果一毛一样了。
于是乎发布了 FDFullscreenPopGesture 1.0 版本,而且提供了一个 AOP 形式的 API,把它添加到工程里面,什么代码都不用写,所有 UINavigationController 就自带这个全屏返回效果了。

丝滑的处理导航栏的显示和隐藏

接下来我们发现利用系统的 UINavigationBar 时,返回手势中若碰到前一个页面有 bar,后一个页面没 bar,或者反过来时,动画就非常难看,举两个反例:

手Q iOS:

它的个人中心页面上面的 bar 是隐藏状态,然后做了个和其他页面很像的假 bar,但返回手势一开始就露馅了,为了弥补,还做了下后面真 bar 的 alpha 值动画,两个返回按钮还是重叠在了一起。

新浪微博 iOS:

和手Q一样的实现方式,只不过没做 alpha 动画,所以就非常明显了。

为啥会这样呢?这可能就是 UINavigationController 在导航栏控制 API 上设计的缺陷了。 一个 UINavigationController 管理了串行的 N 个 UIViewController 栈式的 push 和 pop,而 UINavigationBar 由 UINavigationController 管理,这就导致了 UIViewController 无法控制自己上面的 bar 单独的隐藏或显示。 这非常像 UIApplication 全局的 status bar,牵一发还得动全身,不过 Apple 在 iOS7 之后为 vc 控制自己的 status bar 提供了下面几个方法:

1
2
3
- (UIStatusBarStyle)preferredStatusBarStyle NS_AVAILABLE_IOS(7_0);
- (BOOL)prefersStatusBarHidden NS_AVAILABLE_IOS(7_0);
- (UIStatusBarAnimation)preferredStatusBarUpdateAnimation NS_AVAILABLE_IOS(7_0);

终于让这个全局变量变成了局部变量,虽然写起来费劲了些。
但是对 UINavigationBar 的控制,依然是全局的,可能 Apple 觉得 App 不应该有这种奇怪的页面结构?

解决这个问题的方法也不难,在滑动返回的后要出现的那个 view controller 中写下面的代码:

1
2
3
4
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
[self.navigationController setNavigationBarHidden:YES animated:animated];
}

系统就会把有 bar 和 无 bar 的 transition 动画衔接起来。但是如上面所说,这是个全局变量,还得在所有由这个没有 bar 的特殊页面能 push 和 pop 的页面都进行反向的处理,代码非常的乱乎。于是乎,我们试着解决了这个问题,先看效果:

我特意挑了个从真 bar 到假 bar,再从假 bar 到 真 bar 的页面,还算蛮丝滑的,transition 动画全是系统自己搞定的。
就事把 FDFullscreenPopGesture 更新到了 1.1 版本,贯彻我们一向的精简 API,你只需要在 bar 要隐藏的 view controller 中写一句话:

1
2
3
4
- (void)viewDidLoad
[super viewDidLoad];
self.navigationController.fd_prefersNavigationBarHidden = YES;
}

或者喜欢重载的写法也行:

1
2
3
- (BOOL)fd_prefersNavigationBarHidden {
return YES;
}

刻意的模仿了下系统的命名风格,就这一句话,剩下的就都不用操心了。

关于私有API

大家会质疑说,这用到了 UIKit 的私有属性和私有 API,要是系统升级变了咋办?要是审核被拒了咋办?
首先,iOS 系统的 SDK 为了向下兼容,一般只会增加方法或者修改方法实现,不太可能直接删除一个共有方法,而私有方法的行为确实可能有变化,但系统 release 频率毕竟很低,每当新版本发布时 check 下原来的功能是否能 work 就好了,大可不必担心这么远,SDK 是死的人是活的。
另一个就是审核问题,FDFullscreenPopGesture 的实现中有主要有两处触碰到了私有 API:

1
2
3
4
// 1. 私有变量标志transition动画是否正在进行
[self.navigationController valueForKey:@"_isTransitioning"];
// 2. 一个内部的selector
NSSelectorFromString(@"handleNavigationTransition:");

不论是 kvc 还是 selector 反射,都是利用 objc runtime 完成的,而到了这一层,真的就没啥公有私有可言了。设想你就是开发 Apple 私有 API 检查工具的工程师,给你一个 ipa 的包,你会如何检查出其中有没有私有 API 呢?

首先,这个检查一定是个静态检查吧,不可能是运行时检查,因为代码逻辑那么复杂,把程序跑起来看所有 objc_msgSend 中包不包括私有调用这件事太不现实了。
对 ipa 文件做静态检查的话肯定是去分析 Mach-O 可执行文件,因为这时很多源代码级别的信息已经丢失,经分析可以采取下面几种手段:

  • 是否 link 了私有 framework 或者公开 framework 中的私有符号,这可以防止开发者把私有 header 都 dump 出来供程序直接调用。
  • 同上,使用@selector(_private_sel)加上-performSelector:的方式直接调用私有 API。
  • 扫描所有符号,查看是否有继承自私有类,重载私有方法,方法名是否有重合。
  • 扫描所有string,看字符串常量段是否出现和私有 API 对应的。

我觉得前三条被 catch 住的可能性最高,也最容易被检查出来。再来看我们用到用字符串的方法 kvc 和 反射 selector,应该属于最后一条,这时候就很难抉择了,拿 handleNavigationTransition: 来说,看上去人畜无害啊,我自己类里面的方法也完全可能命名出这个来,所以单单凭借字符串命中私有 API 判定,苹果很容易误伤一大票开发者。
综上,我觉得使用字符串的方式使用私有 API 是相对安全的,我们的 App 马上要提交审核,如果过了几天你还能读到这段文字,说明我的猜想是木有错的,大家可以放心使用。

0 代码的 Demo

还有一个有意思的事,我们在 github 上的 demo工程 木有写一行代码,就实现了下面的效果:

工程长这个样子,view controller 类也没写,为了体现 FDFullscreenPopGesture 的 AOP 性质:

页面由 Storyboard 构建:

而控制页面隐藏 bar 的属性也能用 Runtime Attributes 模拟调用:

这样就完成了一个非常干净的 Demo

加入到你的工程中

首先要求最低支持 iOS7,我想在 WWDC 2015 结束,iOS9 发布后,主流的 App 就都会 iOS7 起跳了。
依然是熟悉的 cocoapods 安装:

1
pod 'FDFullscreenPopGesture', '~> 1.1'

要是没有搜到就 pod setup 下。

广告时间

我这边正在招聘 iOS,坐标北京,希望找到一个代码规范的、爱用 IB 的、懒得写重复代码、不爱加班的同学,相信这里有很大空间供你学习和提升,还可以参与到 forkingdog 开源小组中做点屌屌的东西,欢迎私聊或把简历丢到 sunyuan01@baidu.com

优化UITableViewCell高度计算的那些事

我是前言

这篇文章是我和我们团队最近对 UITableViewCell 利用 AutoLayout 自动高度计算和 UITableView 滑动优化的一个总结。
我们也在维护一个开源的扩展,UITableView+FDTemplateLayoutCell,让高度计算这个事情变的前所未有的简单,也受到了很多星星的支持,github链接请戳我

这篇总结你可以读到:

  • UITableView高度计算和估算的机制
  • 不同iOS系统在高度计算上的差异
  • iOS8 self-sizing cell
  • UITableView+FDTemplateLayoutCell如何用一句话解决高度问题
  • UITableView+FDTemplateLayoutCell中对RunLoop的使用技巧

UITableViewCell高度计算

rowHeight

UITableView是我们再熟悉不过的视图了,它的 delegatedata source 回调不知写了多少次,也不免遇到 UITableViewCell 高度计算的事。UITableView 询问 cell 高度有两种方式。
一种是针对所有 Cell 具有固定高度的情况,通过:

1
self.tableView.rowHeight = 88;

上面的代码指定了一个所有 cell 都是 88 高度的 UITableView,对于定高需求的表格,强烈建议使用这种(而非下面的)方式保证不必要的高度计算和调用。rowHeight属性的默认值是 44,所以一个空的 UITableView 显示成那个样子。

另一种方式就是实现 UITableViewDelegate 中的:

1
2
3
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
// return xxx
}

需要注意的是,实现了这个方法后,rowHeight 的设置将无效。所以,这个方法适用于具有多种 cell 高度的 UITableView。

estimatedRowHeight

这个属性 iOS7 就出现了, 文档是这么描述它的作用的:

If the table contains variable height rows, it might be expensive to calculate all their heights when the table loads. Using estimation allows you to defer some of the cost of geometry calculation from load time to scrolling time.

恩,听上去蛮靠谱的。我们知道,UITableView 是个 UIScrollView,就像平时使用 UIScrollView 一样,加载时指定 contentSize 后它才能根据自己的 bounds、contentInset、contentOffset 等属性共同决定是否可以滑动以及滚动条的长度。而 UITableView 在一开始并不知道自己会被填充多少内容,于是询问 data source 个数和创建 cell,同时询问 delegate 这些 cell 应该显示的高度,这就造成它在加载的时候浪费了多余的计算在屏幕外边的 cell 上。和上面的 rowHeight 很类似,设置这个估算高度有两种方法:

1
2
3
4
5
self.tableView.estimatedRowHeight = 88;
// or
- (CGFloat)tableView:(UITableView *)tableView estimatedHeightForRowAtIndexPath:(NSIndexPath *)indexPath {
// return xxx
}

有所不同的是,即使面对种类不同的 cell,我们依然可以使用简单的 estimatedRowHeight 属性赋值,只要整体估算值接近就可以,比如大概有一半 cell 高度是 44, 一半 cell 高度是 88, 那就可以估算一个 66,基本符合预期。

说完了估算高度的基本使用,可以开始吐槽了:

  1. 设置估算高度后,contentSize.height 根据“cell估算值 x cell个数”计算,这就导致滚动条的大小处于不稳定的状态,contentSize 会随着滚动从估算高度慢慢替换成真实高度,肉眼可见滚动条突然变化甚至“跳跃”。
  2. 若是有设计不好的下拉刷新或上拉加载控件,或是 KVO 了 contentSize 或 contentOffset 属性,有可能使表格滑动时跳动。
  3. 估算高度设计初衷是好的,让加载速度更快,那凭啥要去侵害滑动的流畅性呢,用户可能对进入页面时多零点几秒加载时间感觉不大,但是滑动时实时计算高度带来的卡顿是明显能体验到的,个人觉得还不如一开始都算好了呢(iOS8更过分,即使都算好了也会边划边计算)

iOS8 self-sizing cell

具有动态高度内容的 cell 一直是个头疼的问题,比如聊天气泡的 cell, frame 布局时代通常是用数据内容反算高度:

1
CGFloat height = textHeightWithFont() + imageHeight + topMargin + bottomMargin + ...;

供 UITableViewDelegate 调用时很可能是个 cell 的类方法:

1
2
3
@interface BubbleCell : UITableViewCell
+ (CGFloat)heightWithEntity:(id)entity;
@end

各种魔法 margin 加上耦合了屏幕宽度。

AutoLayout 时代好了不少,提供了-systemLayoutSizeFittingSize:的 API,在 contentView 中设置约束后,就能计算出准确的值;缺点是计算速度肯定没有手算快,而且这是个实例方法,需要维护专门为计算高度而生的 template layout cell,它还要求使用者对约束设置的比较熟练,要保证 contentView 内部上下左右所有方向都有约束支撑,设置不合理的话计算的高度就成了0。

这里还不得不提到一个 UILabel 的蛋疼问题,当 UILabel 行数大于0时,需要指定 preferredMaxLayoutWidth 后它才知道自己什么时候该折行。这是个“鸡生蛋蛋生鸡”的问题,因为 UILabel 需要知道 superview 的宽度才能折行,而 superview 的宽度还依仗着子 view 宽度的累加才能确定。这个问题好像到 iOS8 才能够自动解决(不过我们找到了解决方案)

回到正题,iOS8 WWDC 中推出了 self-sizing cell 的概念,旨在让 cell 自己负责自己的高度计算,使用 frame layout 和 auto layout 都可以享受到:

这个特性首先要求是 iOS8,要是最低支持的系统版本小于8的话,还得针对老版本单写套老式的算高(囧),不过用的 API 到不是新面孔:

1
2
self.tableView.estimatedRowHeight = 213;
self.tableView.rowHeight = UITableViewAutomaticDimension;

这里又不得不吐槽了,自动计算 rowHeight 跟 estimatedRowHeight 到底是有什么仇,如果不加上估算高度的设置,自动算高就失效了- -
PS:iOS8 系统中 rowHeight 的默认值已经设置成了 UITableViewAutomaticDimension,所以第二行代码可以省略。

问题:

  1. 这个自动算高在 push 到下一个页面或者转屏时会出现高度特别诡异的情况,不过现在的版本修复了。
  2. 求一个能让最低支持 iOS8 的公司- -

iOS8抽风的算高机制

相同的代码在 iOS7 和 iOS8 上滑动顺畅程度完全不同,iOS8 莫名奇妙的卡。很大一部分原因是 iOS8 上的算高机制大不相同,这是我做的小测试:

研究后发现这么多次额外计算有下面的原因:

  1. 不开启高度估算时,UITableView 上来就要对所有 cell 调用算高来确定 contentSize
  2. dequeueReusableCellWithIdentifier:forIndexPath: 相比不带 “forIndexPath” 的版本会多调用一次高度计算
  3. iOS7 计算高度后有”缓存“机制,不会重复计算;而 iOS8 不论何时都会重新计算 cell 高度

iOS8 把高度计算搞成这个样子,从 WWDC 也倒是能找到点解释,cell 被认为随时都可能改变高度(如从设置中调整动态字体大小),所以每次滑动出来后都要重新计算高度。

说了这么多,究竟有没有既能省去算高烦恼,又能保证顺畅的滑动,还能支持 iOS6+ 的一站式解决方案呢?


UITableView+FDTemplateLayoutCell

使用 UITableView+FDTemplateLayoutCell 无疑是解决算高问题的最佳实践之一,既有 iOS8 self-sizing 功能简单的 API,又可以达到 iOS7 流畅的滑动效果,还保持了最低支持 iOS6。
使用起来大概是这样:

1
2
3
4
5
6
7
#import <UITableView+FDTemplateLayoutCell.h>
- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
return [tableView fd_heightForCellWithIdentifier:@"identifer" cacheByIndexPath:indexPath configuration:^(id cell) {
// 配置 cell 的数据源,和 "cellForRow" 干的事一致,比如:
cell.entity = self.feedEntities[indexPath.row];
}];
}

写完上面的代码后,你就已经使用到了:

  • 和每个 UITableViewCell ReuseID 一一对应的 template layout cell
    这个 cell 只为了参加高度计算,不会真的显示到屏幕上;它通过 UITableView 的 -dequeueCellForReuseIdentifier: 方法 lazy 创建并保存,所以要求这个 ReuseID 必须已经被注册到了 UITableView 中,也就是说,要么是 Storyboard 中的原型 cell,要么就是使用了 UITableView 的 -registerClass:forCellReuseIdentifier:-registerNib:forCellReuseIdentifier:其中之一的注册方法。
  • 根据 autolayout 约束自动计算高度
    使用了系统在 iOS6 就提供的 API:-systemLayoutSizeFittingSize:
  • 根据 index path 的一套高度缓存机制
    计算出的高度会自动进行缓存,所以滑动时每个 cell 真正的高度计算只会发生一次,后面的高度询问都会命中缓存,减少了非常可观的多余计算。
  • 自动的缓存失效机制
    无须担心你数据源的变化引起的缓存失效,当调用如-reloadData-deleteRowsAtIndexPaths:withRowAnimation:等任何一个触发 UITableView 刷新机制的方法时,已有的高度缓存将以最小的代价执行失效。如删除一个 indexPath 为 [0:5] 的 cell 时,[0:0] ~ [0:4] 的高度缓存不受影响,而 [0:5] 后面所有的缓存值都向前移动一个位置。自动缓存失效机制对 UITableView 的 9 个公有 API 都进行了分别的处理,以保证没有一次多余的高度计算。
  • 预缓存机制
    预缓存机制将在 UITableView 没有滑动的空闲时刻执行,计算和缓存那些还没有显示到屏幕中的 cell,整个缓存过程完全没有感知,这使得完整列表的高度计算既没有发生在加载时,又没有发生在滑动时,同时保证了加载速度和滑动流畅性,下文会着重讲下这块的实现原理。

我们在设计这个工具的 API 时斟酌了非常长的时间,既要保证功能的强大,也要保证接口的精简,一行调用背后隐藏着很多功能。

这一套缓存机制能对滑动起多大影响呢?除了肉眼能明显的感知到外,我还做了个小测试:
一个有 54 个内容和高度不同 cell 的 table view,从头滑动到尾,再从尾滑动到头,iOS8 系统下,iPhone6,使用 Time Profiler 监测算高函数所花费的时间:

未使用缓存API、未使用估算,共花费 877 ms:

使用缓存API、开启估算,共花费 77 ms:

测试数据的精度先不管,从量级上就差了一个数量级,说实话自己也没想到差距有这么大- -

同时,工具也顺手解决了-preferredMaxLayoutWidth的问题,在计算高度前向 contentView 加了一条和 table view 宽度相同的宽度约束,强行让 contentView 内部的控件知道了自己父 view 的宽度,再反算自己被外界约束的宽度,破除“鸡生蛋蛋生鸡”的问题,这里比较 tricky,就不展开说了。下面说说利用 RunLoop 预缓存的实现。


利用RunLoop空闲时间执行预缓存任务

FDTemplateLayoutCell 的高度预缓存是一个优化功能,它要求页面处于空闲状态时才执行计算,当用户正在滑动列表时显然不应该执行计算任务影响滑动体验。
一般来说,这个功能要耦合 UITableView 的滑动状态才行,但这种实现十分不优雅且可能破坏外部的 delegate 结构,但好在我们还有RunLoop这个工具,了解它的运行机制后,可以用很简单的代码实现上面的功能。

空闲RunLoopMode

在曾经的 RunLoop 线下分享会(视频可戳)中介绍了 RunLoopMode 的概念。
当用户正在滑动 UIScrollView 时,RunLoop 将切换到 UITrackingRunLoopMode 接受滑动手势和处理滑动事件(包括减速和弹簧效果),此时,其他 Mode (除 NSRunLoopCommonModes 这个组合 Mode)下的事件将全部暂停执行,来保证滑动事件的优先处理,这也是 iOS 滑动顺畅的重要原因。
当 UI 没在滑动时,默认的 Mode 是 NSDefaultRunLoopMode(同 CF 中的 kCFRunLoopDefaultMode),同时也是 CF 中定义的 “空闲状态 Mode”。当用户啥也不点,此时也没有什么网络 IO 时,就是在这个 Mode 下。

用RunLoopObserver找准时机

注册 RunLoopObserver 可以观测当前 RunLoop 的运行状态,并在状态机切换时收到通知:

  1. RunLoop开始
  2. RunLoop即将处理Timer
  3. RunLoop即将处理Source
  4. RunLoop即将进入休眠状态
  5. RunLoop即将从休眠状态被事件唤醒
  6. RunLoop退出

因为“预缓存高度”的任务需要在最无感知的时刻进行,所以应该同时满足:

  1. RunLoop 处于“空闲”状态 Mode
  2. 当这一次 RunLoop 迭代处理完成了所有事件,马上要休眠时

使用 CF 的带 block 版本的注册函数可以让代码更简洁:

1
2
3
4
5
6
7
CFRunLoopRef runLoop = CFRunLoopGetCurrent();
CFStringRef runLoopMode = kCFRunLoopDefaultMode;
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler
(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity _) {
// TODO here
});
CFRunLoopAddObserver(runLoop, observer, runLoopMode);

在其中的 TODO 位置,就可以开始任务的收集和分发了,当然,不能忘记适时的移除这个 observer

分解成多个RunLoop Source任务

假设列表有 20 个 cell,加载后展示了前 5 个,那么开启估算后 table view 只计算了这 5 个的高度,此时剩下 15 个就是“预缓存”的任务,而我们并不希望这 15 个计算任务在同一个 RunLoop 迭代中同步执行,这样会卡顿 UI,所以应该把它们分别分解到 15 个 RunLoop 迭代中执行,这时就需要手动向 RunLoop 中添加 Source 任务(由应用发起和处理的是 Source 0 任务)
Foundation 层没对 RunLoopSource 提供直接构建的 API,但是提供了一个间接的、既熟悉又陌生的 API:

1
2
3
4
5
- (void)performSelector:(SEL)aSelector
onThread:(NSThread *)thr
withObject:(id)arg
waitUntilDone:(BOOL)wait
modes:(NSArray *)array;

这个方法将创建一个 Source 0 任务,分发到指定线程的 RunLoop 中,在给定的 Mode 下执行,若指定的 RunLoop 处于休眠状态,则唤醒它处理事件,简单来说就是“睡你xx,起来嗨!”
于是,我们用一个可变数组装载当前所有需要“预缓存”的 index path,每个 RunLoopObserver 回调时都把第一个任务拿出来分发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
NSMutableArray *mutableIndexPathsToBePrecached = self.fd_allIndexPathsToBePrecached.mutableCopy;
CFRunLoopObserverRef observer = CFRunLoopObserverCreateWithHandler(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, true, 0, ^(CFRunLoopObserverRef observer, CFRunLoopActivity _) {
if (mutableIndexPathsToBePrecached.count == 0) {
CFRunLoopRemoveObserver(runLoop, observer, runLoopMode);
CFRelease(observer); // 注意释放,否则会造成内存泄露
return;
}
NSIndexPath *indexPath = mutableIndexPathsToBePrecached.firstObject;
[mutableIndexPathsToBePrecached removeObject:indexPath];
[self performSelector:@selector(fd_precacheIndexPathIfNeeded:)
onThread:[NSThread mainThread]
withObject:indexPath
waitUntilDone:NO
modes:@[NSDefaultRunLoopMode]];
});

这样,每个任务都被分配到下个“空闲” RunLoop 迭代中执行,其间但凡有滑动事件开始,Mode 切换成 UITrackingRunLoopMode,所有的“预缓存”任务的分发和执行都会自动暂定,最大程度保证滑动流畅。

PS: 预缓存功能因为下拉刷新的冲突和不明显的收益已经废弃


开始使用UITableView+FDTemplateLayoutCell

如果你觉得这个工具能帮得到你,整合到工程也十分简单。
使用 cocoapods:

1
pod search UITableView+FDTemplateLayoutCell

写这篇文章时的最新版本为 1.2,去除了前一个版本的黑魔法,增加了预缓存功能。
欢迎使用和支持这个工具,有 bug 请随时反馈哦~
再复习下 github 地址: https://github.com/forkingdog/UITableView-FDTemplateLayoutCell

巧用多字符Char常量

###巧用多字符Char

我们很熟悉char这个数据类型和它的使用方式:

1
char c = 'a';

这个 c 变量在 ASCII 编码下是 97
还有一种不常见的多字符 char 的写法:

1
int i = 'AaBb';

这个 i 变量的值按每个 char 的 ASCII 值转 16 进制拼在一起,也就是说:

1
2
3
'AaBb'
-> '0x41'+'0x61'+'0x42'+'0x62'
-> '0x41614262' // 十进制1096893026

PS:这个组合方式和“大小端”有关系,上面是 i386 下的结果
多字符的长度限度为最多 4 个 char

知道了这个特性,我们就可以做些坏事,比如:

1
2
3
4
5
6
self.someButton.tag = 'SHIT';
...

if (button.tag == 'SHIT') {
NSLog(@"I've got this shit button");
}

当然使用tag是很不推荐的写法,尽量不用。使用这个特性来避免些魔法数字或标记些整型数字还是极好的。

0代码隐藏GroupedTableView上边多余的间隔

实现诸如支付宝的 “探索” 页面时,最简单的方案是在 Storyboard 中来一个静态 Grouped UITableViewController,把各个 Cell 中的元素摆好就行了

不过会有下面的问题,第一个 Section 距离屏幕顶端有间隔

一行代码搞定

研究发现,这里其实是一个被 UITableView 默认填充的 HeaderView。而且,当试图将它的高度设置为 0 时,完全不起效果。但我们用下面的代码创建一个高度特别小的 HeaderView 时,上面的边距就不见了:

CGFLOAT_MIN 这个宏表示 CGFloat 能代表的最接近 0 的浮点数,64 位下大概是 0.00(300左右个)0225 这个样子
这样写单纯的为了避免一个魔法数字,这里用 0.1 效果是一样的,后面再讲。

在 Storyboard 中 0 代码搞定

没用 Storyboard 的同学使用上面的代码就 OK 了; 而在 Storyboard 中可以 0 代码搞定这个事:

首先,在第一个 Section 的上面拖进来一个空 UIView

然后选中这个 UIView 的 Runtime Attributes 栏,添加一个 frame 的 KeyPath

这样头部的间隔就乖乖的不见了:

刨根问底 UITableViewHeader 的猫腻

为什么刚才说 0.1 和 CGFLOAT_MIN 是等效的呢?经过研究,这个高度值的影响大概是这样的:

  1. 若传入的 height == 0,则 height 被设置成默认值
  2. 若 height 小于屏幕半像素对应的高度,这个 header 不在另一个像素渲染

半像素也就是 1.0 / scale / 2.0,如在 @2x 屏上是 0.25
直观的感受下,假如这个 height 被设置成 0.5 的样子:

身患强迫症的我是绝对不能容忍导航栏下面的阴影线看上去宽了 0.5 像素的,Done。

Notification Once

前段时间整理项目中的AppDelegate,发现很多写在- application:didFinishLaunchingWithOptions:中的代码都只是为了在程序启动时获得一次调用机会,多为某些模块的初始化工作,如:

1
2
3
4
5
6
7
8
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// ...
[FooModule setup];
[[BarModule sharedInstance] setup];
// ...
return YES;
}

其实这些代码完全可以利用Notification的方式在自己的模块内部搞定,分享一个巧妙的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
/// FooModule.m
+ (void)load
{
__block id observer =
[[NSNotificationCenter defaultCenter]
addObserverForName:UIApplicationDidFinishLaunchingNotification
object:nil
queue:nil
usingBlock:^(NSNotification *note) {
[self setup]; // Do whatever you want
[[NSNotificationCenter defaultCenter] removeObserver:observer];
}];
}

解释:

  • + load方法在足够早的时间点被调用
  • block 版本的通知注册会产生一个__NSObserver *对象用来给外部 remove 观察者
  • block 对 observer 对象的捕获早于函数的返回,所以若不加__block,会捕获到 nil
  • 在 block 执行结束时移除 observer,无需其他清理工作
  • 这样,在模块内部就完成了在程序启动点代码的挂载

值得注意的是,通知是在- application:didFinishLaunchingWithOptions:调用完成后才发送的。
顺便提下给 AppDelegate 瘦身的建议:AppDelegate 作为程序级状态变化的 delegate,应该只做路由分发的作用,具体逻辑实现代码还是应该在分别的模块中,这个文件应该保持整洁,除了<UIApplicationDelegate>的方法外不应该出现其他方法。

实现一个TODO宏

实现一个能产生warning的TODO宏,用于在代码里做备忘,效果:


下面一步步来实现这个宏。


Let’s do it

手动让编译器报警(报错)可以用以下几个方法:

1
2
3
4
5
#warning sunnyxx
#error sunnyxx
#pragma message "sunnyxx"
#pragma GCC warning "sunnyxx"
#pragma GCC error "sunnyxx"

但我们知道,带#的预处理指令是无法被#define的。好在C99提供了一个_Pragma运算符可以把部分#pragma指令字符串化:

1
2
3
4
5
#pragma message "sunnyxx"
// 等价于
_Pragma("message \"sunnyxx\"") // 需要注意双引号的转义
// 或
_Pragma("message(\"sunnyxx\")") // 需要注意双引号的转义

利用这个特性,我们就可以将warning定义成宏

1
2
3
4
5
#define SOME_WARNING _Pragma("message(\"报告大王!\")")
int main() {
SOME_WARNING // [!]报告大王!
return 1;
}

接下来,我们让这个宏能够接受入参,并显示到warning中去,这里会面临宏的基本用法的考验。

1
2
#define STRINGIFY(S) #S
#define PRAGMA_MESSAGE(MSG) _Pragma(STRINGIFY(message(MSG)))

个人认为不太可能在一个宏定义中完成这件事,需要用到辅助宏:STRINGIFY(S) 将入参转化成字符串,省去了_Pragma中全串加转义字符的困扰。
这时,一个基本功能的TODO宏就完成了,下面向其中加入额外的信息

1
2
3
4
5
6
7
8
// 两个已有的宏
#define STRINGIFY(S) #S
#define PRAGMA_MESSAGE(MSG) _Pragma(STRINGIFY(message(MSG)))
// 延迟1次展开的宏
#define DEFER_STRINGIFY(S) STRINGIFY(S)
// 下面的宏在第一行用`\`折行
#define FORMATTED_MESSAGE(MSG) "[TODO-" DEFER_STRINGIFY(__COUNTER__) "] " MSG " \n" \
DEFER_STRINGIFY(__FILE__) " line " DEFER_STRINGIFY(__LINE__)

其中涉及到的知识:

  • 两个常量字符串可以拼接成一个整串 “123””456” => “123456”
  • 使用到3个预定义宏__COUNTER__宏展开次数的计数器,全局唯一;__FILE__当前文件完整目录字符串;__LINE__在当前文件第几行
  • 在字符串中预定义宏应延时展开,如果将上面的DEFER_STRINGIFY换成STRINGIFY的话,如__LINE__就不能被正确展开成行数,而是成了一个常量字符串"__LINE__"
  • 为了美化,warning message中可以使用\n换行

于是,使用FORMATTED_MESSAGE(MSG)宏就可以将带文件路径、序号、行数等信息加入到最终的warning中。


其实到这步已经OK了,为了让这个宏更加抢眼,还可以借鉴RAC,把宏定义成前面加@的形式:

1
#define KEYWORDIFY try {} @catch (...) {}

将最终的宏定义前面加上上面的宏后,使用时就可以加@前缀了(空的try-catch会被编译器优化,所以没啥性能损耗)


最终版本

1
2
3
4
5
6
7
8
#define STRINGIFY(S) #S
#define DEFER_STRINGIFY(S) STRINGIFY(S)
#define PRAGMA_MESSAGE(MSG) _Pragma(STRINGIFY(message(MSG)))
#define FORMATTED_MESSAGE(MSG) "[TODO-" DEFER_STRINGIFY(__COUNTER__) "] " MSG " \n" \
DEFER_STRINGIFY(__FILE__) " line " DEFER_STRINGIFY(__LINE__)
#define KEYWORDIFY try {} @catch (...) {}
// 最终使用下面的宏
#define TODO(MSG) KEYWORDIFY PRAGMA_MESSAGE(FORMATTED_MESSAGE(MSG))

What’s more

除此之外,还研究了半天如何在宏里面定义一个注释,这样就可以偷偷写// TODO: ...的注释,让Xcode导航栏中也出现这个TODO了:

但很可惜没有找到一个可行的方法,欢迎一起解决。
Xcode插件《XTodo》也是利用这个特性,可以尝试下。

如果需要一个产生error的宏,将这里替换成这样就好了:_Pragma(STRINGIFY(GCC error(MSG)))

同时,上面的代码在《github上》可以找到。也欢迎关注微博@我就叫Sunny怎么了一起交流。

References

http://clang.llvm.org/docs/UsersManual.html
https://gcc.gnu.org/onlinedocs/cpp/Pragmas.html

ARC对self的内存管理

记录下前两天的一次讨论,源于网络库YTKNetwork“YTKRequest.m”- start方法其中的几行代码:

1
2
3
4
5
6
7
8
9
- (void)start {
// ......
YTKRequest *strongSelf = self;
[strongSelf.delegate requestFinished:strongSelf];
if (strongSelf.successCompletionBlock) {
strongSelf.successCompletionBlock(strongSelf);
}
[strongSelf clearCompletionBlock];
}

看起来比较有违常理,所以和猿题库的@晨钰Lancy,@唐巧以及网易的@老汉一起讨论了下这个问题。


具体的问题大概是这样:

  1. 调用方(如view controller)实例化并强引用YTKRequest对象,将自己作为其delegate
  2. 调用方调用YTKRequest- start方法发起网络请求
  3. 调用方在- requestFinished:中执行了self.request = nil;
  4. YTKRequest中,- start方法在回调完- requestFinished:BAD_ACCESS

也就是说,- start方法还未返回时,self就被外部释放了。作者发现了这个潜在的问题,所以在方法局部增设了一个strongSelf的强引用来保证self的生命周期延续到方法结束。问题是解决了,但是更希望知道原因。

简化说明就是:

1
2
3
4
5
- (void)foo {
// self被delegate持有
[self.delegate callout]; // 外部释放了这个对象
// 这里self野指针
}

现在想想还是比较不符合常理,入参的self居然不能保证这个函数执行完成。后来查阅了下文档,发现是ARC的(gao)机(de)制(gui),clang的《这篇ARC文档》中有明确的解释,总结如下:

  • ARC下,self既不是strong也不是weak,而是unsafe_unretained的,也就是说,入参的self被表示为:(init系列方法的self除外)
1
2
3
4
- (void)start {
const __unsafe_unretained YTKRequest *self;
// ...
}
  • 在方法调用时,ARC不会对self做retain或release,生命周期全由它的调用方来保证,如果调用方没有保证,就会出现上面的crash
  • ARC这样做的原因是性能优化,objc中100%的方法(不是函数)调用第一个参数都是self,同时,99%的情况下,调用方都不会在方法执行时把这个对象释放,所以相比于在每个方法中插入对self的引用计数管理:
1
2
3
4
5
- (void)start {
objc_retain(self);
// 其中的代码self一定不会被释放
objc_release(self);
}

优化了的性能还真是比较可观。
而且,ARC也用了挺多方法来避免开发者进行额外的引用计数控制,比如方法的命名约定,通过判断方法是否以如initallocnewcopy等关键字开头来决定其内存管理方式。


One more thing

在写test时发现,下面两种调用方法会导致不同结果:

1
2
3
4
5
6
- (void)viewDidLoad {
// 1
[_request start]; // crash
// 2
[self.request start]; // 正常
}

因为self.request是一次方法调用,返回的结果被objc_retainAutoreleasedReturnValue方法在局部进行了一次强引用,关于这个方法可以看之前写过的关于Autorelease的《这篇文章》

64-bit Tips

终究还是来了。Apple下发了支持64位的最后通牒:

As we announced in October, beginning February 1, 2015 new iOS apps submitted to the App Store must include 64-bit support and be built with the iOS 8 SDK. Beginning June 1, 2015 app updates will also need to follow the same requirements.

早应该做的适配终于要开始动工了,苦了64位的CPU运行了这么久32位的程序。前段时间公司项目完成了64-bit包的适配,本没那么复杂的事被无数不标准的老代码搅和的不轻,总结几个Tip共勉。

Tips

拒绝基本数据类型和隐式转换

首当其冲的就是基本类型,比如下面4个类型在32-bit和64-bit下分别是多长呢?

1
2
3
4
size_t s1 = sizeof(int);
size_t s2 = sizeof(long);
size_t s3 = sizeof(float);
size_t s4 = sizeof(double);

32-bit下:4, 4, 4, 8;64-bit下:4, 8, 4, 8
(PS: 这个结果随编译器,换其他平台可不一定)
它们的长度变化可能并非我们对64-bit长度加倍的预期,所以说,程序中出现sizeof的代码多看两眼。而且,除非你明确知道自己在做什么,应该使用下面的类型代替基本类型:

  • int -> NSInteger
  • unsigned -> NSUInteger
  • float -> CGFloat
  • 动画时间 -> NSTimeInterval

这些都是SDK中定义的类型,而我们大部分时间都在跟SDK的API们打交道,使用它们能将类型转换的影响降低很多。

再比如说下面的代码:

1
2
3
4
NSArray *items = @[@1, @2, @3];
for (int i = -1; i < items.count; i++) {
NSLog(@"%d", i);
}

结果是,for循环一次都没有进。
数组的countNSUInteger类型的,-1与其比较时隐式转换成NSUInteger,变成了一个很大的数字:

1
2
3
4
(lldb) p i
(int) $0 = -1
(lldb) p (NSUInteger)i
(NSUInteger) $1 = 18446744073709551615

这和64-bit到没啥关系,想要说明的是,这种隐式转换也需要小心,一定要注意和这个变量相关的所有操作(赋值、比较、转换)
老式for循环可以考虑写成:

1
for (NSUInteger index = 0; index < items.count; index++) {}

当然,数组遍历还是更推荐用for-inblock版本的,它们之间的比较可以回顾下这篇文章

使用新版枚举

和上面的原因差不多,枚举应该使用新版的写法:

1
2
3
4
5
6
typedef NS_ENUM(NSInteger, UIViewAnimationCurve) {
UIViewAnimationCurveEaseInOut,
UIViewAnimationCurveEaseIn,
UIViewAnimationCurveEaseOut,
UIViewAnimationCurveLinear
};

不仅能为枚举值指定类型,而且当赋值赋错类型时,编译器还会给出警告,没理由不用这种写法。

替代Format字符串

适配64-bit时,你是否遇到了下面的恶心写法:

1
2
NSArray *items = @[@1, @2, @3];
NSLog(@"数组元素个数:%lu", (unsigned long)items.count);

一般情况下,利用NSNumber@语法糖就可以解决:

1
2
NSArray *items = @[@1, @2, @3];
NSLog(@"数组元素个数:%@", @(items.count));

同理,int转string也可以:

1
2
NSInteger i = 10086;
NSString *string = @(i).stringValue;

当然,如需要%.2f这种Format就不适用了。

64-bit下的BOOL

32-bit下,BOOL被定义为signed char,@encode(BOOL)的结果是'c'
64-bit下,BOOL被定义为bool,@encode(BOOL)结果是'B'
更直观的解释是:

1
2
3
4
(lldb) p/t (signed char)7
(BOOL) $0 = 0b00000111 (YES)
(lldb) p/t (bool)7
(bool) $1 = 0b00000001 (YES)

32-bit版本的BOOL包括了256个值的可能性,还会引起一些坑,像这篇文章所说的。而64-bit下只有0(NO),1(YES)两种可能,终于给BOOL正了名。

不直接取isa指针

编译器已经默认禁用了这种使用,isa指针在32位下是Class的地址,但在64位下利用bits mask才能取出来真正的地址,若真需要,使用runtime的object_getClassobject_setClass方法。关于64位下isa的讲解可以看这篇文章

解决第三方lib依赖和lipo命令

以源码形式出现在工程中的第三方lib,只要把target加上arm64编译就好了。
恶心的就是直接拖进工程的那些静态库(.a)或者framework,就需要重新找支持64-bit的包了。这时候就能看出哪些是已无人维护的lib了,是时候找个替代品了(比如我全网找不到工程中用到的一个音频库的64位包,终于在一个哥们的github上找到,哭着给了个star- -)

打印Mach-O文件支持的架构

如何看一个可执行文件是不是支持64-bit呢?

使用lipo -info命令,比如看看UIKit支持的架构:

1
2
3
// 当前在Xcode Frameworks目录
sunnyxx$ lipo -info UIKit.framework/UIKit
Architectures in the fat file: UIKit.framework/UIKit are: arm64 armv7s

想看的更详细的信息可以使用lipo -detailed_info

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sunnyxx$ lipo -detailed_info UIKit.framework/UIKit
Fat header in: UIKit.framework/UIKit
fat_magic 0xcafebabe
nfat_arch 2
architecture arm64
cputype CPU_TYPE_ARM64
cpusubtype CPU_SUBTYPE_ARM64_ALL
offset 4096
size 16822272
align 2^12 (4096)
architecture armv7s
cputype CPU_TYPE_ARM
cpusubtype CPU_SUBTYPE_ARM_V7S
offset 16826368
size 14499840
align 2^12 (4096)

当然,还可以使用file命令:

1
2
3
4
sunnyxx$ file UIKit.framework/UIKit
UIKit.framework/UIKit: Mach-O universal binary with 2 architectures
UIKit.framework/UIKit (for architecture arm64):Mach-O 64-bit dynamically linked shared library
UIKit.framework/UIKit (for architecture armv7s):Mach-O dynamically linked shared library arm

上述命令对Mach-O文件适用,静态库.a文件,framework中的.a文件,自己app的可执行文件都可以打印下看看。

合并多个架构的包

如果,我们有MyLib-32.aMyLib-64.a,可以使用lipo -create命令合并:

1
sunnyxx$ lipo -create MyLib-32.a MyLib-64.a -output MyLib.a

支持64-bit后程序包会变大么?

会,支持64-bit后,多了一个arm64架构,理论上每个架构一套指令,但相比原来会大多少还不好说,我们这里增加了大概50%,还有听说会增加一倍的。

一个lib包含了很多的架构,会打到最后的包里么?

不会,如果lib中有armv7, armv7s, arm64, i386架构,而target architecture选择了armv7s, arm64,那么只会从lib中link指定的这两个架构的二进制代码,其他架构下的代码不会link到最终可执行文件中;反过来,一个lib需要在模拟器环境中正常link,也得包含i386架构的指令。

Checklist

最后列一下官方文档中的注意点:

  • 不要将指针强转成整数
  • 程序各处使用统一的数据类型
  • 对不同类型的整数做运算时一定要注意
  • 需要定长变量时,使用如int32_t, int64_t这种定长类型
  • 使用malloc时,不要写死size
  • 使用能同时适配两个架构的格式化字符串
  • 注意函数和函数指针(类型转换和可变参数)
  • 不要直接访问Objective-C的指针(isa)
  • 使用内建的同步原语(Primitives)
  • 不要硬编码虚存页大小
  • Go Position Independent

References

https://developer.apple.com/library/prerelease/ios/documentation/General/Conceptual/CocoaTouch64BitGuide/Introduction/Introduction.html#//apple_ref/doc/uid/TP40013501-CH1-SW1
http://www.sealiesoftware.com/blog/archive/2013/09/24/objc_explain_Non-pointer_isa.html
http://www.bignerdranch.com/blog/64-bit-smorgasbord/
http://www.bignerdranch.com/blog/bools-sharp-corners/

从NSArray看类簇

Class Clusters

Class Clusters(类簇)是抽象工厂模式在iOS下的一种实现,众多常用类,如NSStringNSArrayNSDictionaryNSNumber都运作在这一模式下,它是接口简单性和扩展性的权衡体现,在我们完全不知情的情况下,偷偷隐藏了很多具体的实现类,只暴露出简单的接口。

NSArray的类簇

虽然官方文档中拿NSNumber说事儿,但Foundation并没有像图中描述的那样为每个number都弄一个子类,于是研究下NSArray类簇的实现方式。

__NSPlacehodlerArray

熟悉这个模式的同学很可能看过下面的测试代码,将原有的alloc+init拆开写:

1
2
3
4
id obj1 = [NSArray alloc]; // __NSPlacehodlerArray *
id obj2 = [NSMutableArray alloc]; // __NSPlacehodlerArray *
id obj3 = [obj1 init]; // __NSArrayI *
id obj4 = [obj2 init]; // __NSArrayM *

发现+ alloc后并非生成了我们期望的类实例,而是一个__NSPlacehodlerArray的中间对象,后面的- init- initWithXXXXX消息都是发送给这个中间对象,再由它做工厂,生成真的对象。这里的__NSArrayI__NSArrayM分别对应Immutable和Mutable(后面的I和M的意思)

于是顺着思路猜实现,__NSPlacehodlerArray必定用某种方式存储了它是由谁alloc出来的这个信息,才能在init的时候知道要创建的是可变数组还是不可变数组

于是乎很开心的去看了下*obj1的内存布局:

下面是32位模拟器中的内存布局(64位太长不好看就临时改32位了- -),第一个箭头是*obj1,第二个是*obj2

我们知道,对象的前4字节(32位下)为isa指针,指向类对象地址,上图所示的0x0051E768就是__NSPlacehodlerArray类对象地址,可以从lldb下po这个地址来验证。

那么问题来了,这个中间对象并没有储存任何信息诶(除了isa外就都是0了),那它init的时候咋知道该创建什么呢?
经过研究发现,Foundation用了一个很贱的比较静态实例地址方式来实现,伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
static __NSPlacehodlerArray *GetPlaceholderForNSArray() {
static __NSPlacehodlerArray *instanceForNSArray;
if (!instanceForNSArray) {
instanceForNSArray = [[__NSPlacehodlerArray alloc] init];
}
return instanceForNSArray;
}

static __NSPlacehodlerArray *GetPlaceholderForNSMutableArray() {
static __NSPlacehodlerArray *instanceForNSMutableArray;
if (!instanceForNSMutableArray) {
instanceForNSMutableArray = [[__NSPlacehodlerArray alloc] init];
}
return instanceForNSMutableArray;
}
// NSArray实现
+ (id)alloc {
if (self == [NSArray class]) {
return GetPlaceholderForNSArray()
}
}
// NSMutableArray实现
+ (id)alloc {
if (self == [NSMutableArray class]) {
return GetPlaceholderForNSMutableArray()
}
}
// __NSPlacehodlerArray实现
- (id)init {
if (self == GetPlaceholderForNSArray()) {
self = [[__NSArrayI alloc] init];
}
else if (self == GetPlaceholderForNSMutableArray()) {
self = [[__NSArrayM alloc] init];
}
return self;
}

Foundation不是开源的,所以上面的代码是猜测的,思路大概就是这样,可以这样验证下:

1
2
3
4
5
id obj1 = [NSArray alloc];
id obj2 = [NSArray alloc];
id obj3 = [NSMutableArray alloc];
id obj4 = [NSMutableArray alloc];
// 1和2地址相同,3和4地址相同,无论多少次都相同,且地址相差16位

静态不可变空对象

除此之外,Foundation对不可变版本的空数组也做了个小优化:

NSArray *arr1 = [[NSArray alloc] init];
NSArray *arr2 = [[NSArray alloc] init];
NSArray *arr3 = @[];
NSArray *arr4 = @[];
NSArray *arr5 = @[@1];

上边1-4号都指向了同一个对象,而arr5指向了另一个对象。
若干个不可变的空数组间没有任何特异性,返回一个静态对象也理所应当。
不仅是NSArray,Foundation中如NSString, NSDictionary, NSSet等区分可变和不可变版本的类,空实例都是静态对象(NSString的空实例对象是常量区的@""

所以也给用这些方法来测试对象内存管理的同学提个醒,很容易意料之外的。

References

https://developer.apple.com/library/ios/documentation/general/conceptual/CocoaEncyclopedia/ClassClusters/ClassClusters.html
http://iphonedevwiki.net/index.php/Foundation.framework/Inheritance_hierarchy

神经病院objc runtime入院考试

《神经病眼中的objc runtime》北京线下分享活动顺利完成,为了配合讲解编造的几个runtime考题发出来分享下:

  1. 为分享内容配合讲解用,可不是为了面试别人的哦(容易被抽)
  2. 这几个题分别对应了runtime中几个隐蔽的知识点,挺非主流的,没必要深究
  3. 答案在本页末尾给出,有同学针对这几道题写了讲解,所以就一笔带过了
  4. 分享的具体内容争取找个时间写个blog总结下

神经病院objc runtime入院考试

(1) 下面的代码输出什么?

1
2
3
4
5
6
7
8
9
10
@implementation Son : Father
- (id)init {
self = [super init];
if (self) {
NSLog(@"%@", NSStringFromClass([self class]));
NSLog(@"%@", NSStringFromClass([super class]));
}
return self;
}
@end

(2) 下面代码的结果?

1
2
3
4
BOOL res1 = [(id)[NSObject class] isKindOfClass:[NSObject class]];
BOOL res2 = [(id)[NSObject class] isMemberOfClass:[NSObject class]];
BOOL res3 = [(id)[Sark class] isKindOfClass:[Sark class]];
BOOL res4 = [(id)[Sark class] isMemberOfClass:[Sark class]];

(3) 下面的代码会?Compile Error / Runtime Crash / NSLog…?

1
2
3
4
5
6
7
8
9
10
11
@interface NSObject (Sark)
+ (void)foo;
@end
@implementation NSObject (Sark)
- (void)foo {
NSLog(@"IMP: -[NSObject (Sark) foo]");
}
@end
// 测试代码
[NSObject foo];
[[NSObject new] foo];

(4) 下面的代码会?Compile Error / Runtime Crash / NSLog…?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@interface Sark : NSObject
@property (nonatomic, copy) NSString *name;
@end
@implementation Sark
- (void)speak {
NSLog(@"my name's %@", self.name);
}
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
id cls = [Sark class];
void *obj = &cls;
[(__bridge id)obj speak];
}
@end

答案

(1) Son / Son 因为super为编译器标示符,向super发送的消息被编译成objc_msgSendSuper,但仍以self作为reveiver
(2) YES / NO / NO / NO <NSObject>协议有一套类方法的隐藏实现,所以编译运行正常;由于NSObject meta class的父类为NSObject class,所以只有第一句为YES
(3) 编译运行正常,两行代码都执行-foo。 [NSObject foo]方法查找路线为 NSObject meta class –super-> NSObject class,和第二题知识点很相似。
(4)编译运行正常,输出ViewController中的self对象。 编译运行正常,调用了-speak方法,由于

1
2
id cls = [Sark class];
void *obj = &cls;

obj已经满足了构成一个objc对象的全部要求(首地址指向ClassObject),遂能够正常走消息机制;
由于这个人造的对象在栈上,而取self.name的操作本质上是self指针在内存向高位地址偏移(32位下一个指针是4字节),按viewDidLoad执行时各个变量入栈顺序从高到底为(self, _cmd, self.class, self, obj)(前两个是方法隐含入参,随后两个为super调用的两个压栈参数),遂栈低地址的obj+4取到了self。

❌