Pybind11 理解

Pybind11 源码分析

MNN 社区上提供了通过 Python 使用 MNN 的方式,具体可以见:

https://github.com/alibaba/MNN/tree/master/pymnn/src

我们通过 Pybind11 来提供比较优雅的桥接方式。由于 Pybind 11 是一层抽象 C++ 到 Python 桥接的库,上层封装了很多难以理解的细节和流程,本文就带大家抽丝剥茧一下。

关于 Python 桥接,如果你不是很了解,那么在阅读本文之前,请记住如下两句话:

  • 代码必须要以传统的 module 添加类的方式来进行,相关代码是:

    • PyInitModule
    • PyModule_AddObject(xxx)
  • Python 没办法直接编写 C++ Extension,都是通过再包一层 C 方法 (Python 自身的 C 接口)的方式来进行。

换句话说,不管什么,都需要以一个 module 为载体,且需要name

创建模块方法

因此我们的入口就在 PYBIND11_MODULE(name, variable)

全部的宏就是:

#define PYBIND11_MODULE(name, variable) \ 【1】static void PYBIND11_CONCAT(pybind11_init_, name)(pybind11::module &); \ 【2】PYBIND11_PLUGIN_IMPL(name) { \ 【3】PYBIND11_CHECK_PYTHON_VERSION \ auto m = pybind11::module(PYBIND11_TOSTRING(name)); \ try { \ PYBIND11_CONCAT(pybind11_init_, name)(m); \ return m.ptr(); \ } PYBIND11_CATCH_INIT_EXCEPTIONS \ } \ void PYBIND11_CONCAT(pybind11_init_, name)(pybind11::module &variable)

比较晦涩,我们以 mnn 来举例,帮宏全部展开来探索下。

  • 首先声明一个函数 static void pybind11_init_mnn(pybind11::module &);,在宏的最后会有实现。

  • PYBIND11_PLUGIN_IMPL(mnn)这一步是必要的一步,所有 Python 的扩展模块都需要声明对应的初始化方法,用于注册被调用。

    import mnn 就会调用 init_mnn 这样的方法。

    截屏2020-07-27 下午5.33.02.png

  • initmnn 里面定义了 pybind11_init_wrapper 这是真正的初始化实现。(注意 static 声明)

  • pybind11_init_wrapper 里面真正的实现分为几步:

    • 构造一个 module 对象(class module),auto m = pybind11::module(PYBIND11_TOSTRING(name));

    • 调用之前声明的 pybind11_init_mnn 函数,传入module对象,仿造 Python C Extension 的方式来添加方法、定义、变量等。 pybind11_init_mnn(m)

    • 实现 pybind11_init_mnn {xxx}

  • 所以一切核心的关键就是在 class module,抛开繁杂的东西:

    • 第一步,创建一个 CPython 中的 module object,见:

      截屏2020-07-27 下午6.14.57.png

      所以,本质上无论 Pybind11 在干什么,都是利用 CPython 底层的技术在那里操作。

    • 把对应的方法加入到这个 CPython Module 中,如下:

      截屏2020-07-27 下午6.17.23.png

      截屏2020-07-27 下午6.17.30.png

创建类

创建类的方法相对难一点,但是也不难理解,我们还是找到根源 class class_(其实一切只要理解 Pybind11 是用 C++ 去模拟 CPython 的流程就行了)

找到对应的构造函数,首先从函数签名上我们就能窥探一些东西:

截屏2020-07-27 下午6.38.07.png

  • scope 对应类所属的模块。
  • name 就是类名。
  • Extra 就是 C++ 模版机制对应的真正类。

看起来上面和对应的 Python 类型初始化没关系,来看看是不是 generic_type::initialize 造成的。

截屏2020-07-27 下午6.44.13.png

  • make_new_python_type 这名字一看就很符合,哈哈,点进去一看,果然是类型初始化流程。

截屏2020-07-27 下午6.46.15.png

结论:

简要概括下添加方法的实现。

  • 生成模版化的初始化模块方法,即 Wrapper 方法。

  • 提供一个仿造 Python Module 对象(便于 C++ 编写 / 使用智能指针管理引用计数),在对应的自定义函数里面添加对应 Module 的实现。

TrampolineHook - 解决栈污染问题支持变参 Hook

在之前的文章《基于桥的全量方法 Hook 方案(3)- TrampolineHook》 的文末,我说如果对汇编熟悉的同学可能会发现我之前实现的一个错误 - 关于上下文污染

一提到到上下文污染,可能我们绝大多数人想到的都是寄存器污染,但是实际上还有一个不容我们忽视的上下文资源:,过去可能大家常见的 Hook 代码关注比较少,正好这次在借助 TrampolineHook 修复这个方面的问题,我们来一起探讨下。

先看一个例子

假设有这样一个类 TestObject和 不定参函数 method,定义如下:

@interface TestObject : NSObject
@end

@implementation TestObject

- (void)method:(int *)value,...
{
     va_list list;
    va_start(list, value);

    while (value) {
        NSLog(@"orig value is %d", *value);
        value = va_arg(list, int *);
    }
    va_end(list);
}

@end

如果要使用 TrampolineHook 来拦截 method 的调用,也非常简单。如下所示:

THInterceptor *sharedInterceptor = [THInterceptor sharedInterceptorWithFunction:(IMP)wzq_check_variadic];

Method m = class_getInstanceMethod([TestObject class], @selector(method:));
IMP imp = method_getImplementation(m);

THInterceptorResult *result = [sharedInterceptor interceptFunction:(IMP)imp];
if (result.state == THInterceptStateSuccess) {
    method_setImplementation(m, (IMP)result.replacedAddress);
}

// 拦截函数
void wzq_check_variadic(id a, char * methodName, int *v, ...)
{
        NSLog(@"haha checked %@ %s", a, methodName);
}

当我们使用如下方式调用 -[TestObject method:] 的时候,你会发现一切正常,毫无问题。

TestObject *obj = [[TestObject alloc] init];
int a = 0;
int b = 1;
int c = 2;
int d = 3;
int e = 4;
int f = 5;
int g = 6;
int h = 7;
int i = 8;
[obj method:&a, &b, &c, &d, &e, &f, &g, &h, &i, nil];

但是如果你将拦截函数中添加打印参数的语句后,如下所示:

void wzq_check_variadic(id a, char * methodName, int *v, ...)
{
    NSLog(@"haha checked %@ %s", a, methodName);
    va_list args;
    va_start(args, v);
    while (v != NULL) { 
        NSLog(@"v is %d", *v);
        v = va_arg(args, int *); // crash 
    }
    va_end(args);
}

你会发现出现了必现的崩溃情形,而且是必定崩溃在第二次读取变参列表中的参数的时候。
为什么添加了读取参数的代码就导致运行崩溃了?有点意思。

了解变参的传递过程。

为了避免优化的干扰,如下汇编生成的优化选项为 -O0

为了看运行时的栈结构是如何生成的,我们通过汇编结合图的形式来一探究竟没 Hook 的时候的调用情况。

首先先看 Caller 函数,即 [obj method:&a, &b, &c, &d, &e, &f, &g, &h, &i, nil]; 这段代码所处的函数,汇编如下:

 // prologue
0x1009dc428 <+0>:   sub    sp, sp, #0xb0             ; =0xb0 
0x1009dc42c <+4>:   stp    x29, x30, [sp, #0xa0]
0x1009dc430 <+8>:   add    x29, sp, #0xa0            ; =0xa0 

// 构造 int 变量 0 - 8
0x1009dc498 <+112>: stur   wzr, [x29, #-0x2c]
0x1009dc49c <+116>: mov    w11, #0x1
0x1009dc4a0 <+120>: stur   w11, [x29, #-0x30]
0x1009dc4a4 <+124>: mov    w11, #0x2
0x1009dc4a8 <+128>: stur   w11, [x29, #-0x34]
0x1009dc4ac <+132>: mov    w11, #0x3
0x1009dc4b0 <+136>: stur   w11, [x29, #-0x38]
0x1009dc4b4 <+140>: mov    w11, #0x4
0x1009dc4b8 <+144>: stur   w11, [x29, #-0x3c]
0x1009dc4bc <+148>: mov    w11, #0x5
0x1009dc4c0 <+152>: stur   w11, [x29, #-0x40]
0x1009dc4c4 <+156>: mov    w11, #0x6
0x1009dc4c8 <+160>: stur   w11, [x29, #-0x44]
0x1009dc4cc <+164>: mov    w11, #0x7
0x1009dc4d0 <+168>: stur   w11, [x29, #-0x48]
0x1009dc4d4 <+172>: mov    w11, #0x8
0x1009dc4d8 <+176>: stur   w11, [x29, #-0x4c]
0x1009dc4dc <+180>: ldur   x9, [x29, #-0x28]
0x1009dc4e0 <+184>: ldr    x1, [x8]
0x1009dc4e4 <+188>: mov    x8, sp
0x1009dc4e8 <+192>: mov    x10, #0x0

// 把对应 int 变量的地址存入栈中 
0x1009dc4ec <+196>: str    x10, [x8, #0x40]
0x1009dc4f0 <+200>: sub    x10, x29, #0x4c           ; =0x4c 
0x1009dc4f4 <+204>: str    x10, [x8, #0x38]
0x1009dc4f8 <+208>: sub    x10, x29, #0x48           ; =0x48 
0x1009dc4fc <+212>: str    x10, [x8, #0x30]
0x1009dc500 <+216>: sub    x10, x29, #0x44           ; =0x44 
0x1009dc504 <+220>: str    x10, [x8, #0x28]
0x1009dc508 <+224>: sub    x10, x29, #0x40           ; =0x40 
0x1009dc50c <+228>: str    x10, [x8, #0x20]
0x1009dc510 <+232>: sub    x10, x29, #0x3c           ; =0x3c 
0x1009dc514 <+236>: str    x10, [x8, #0x18]
0x1009dc518 <+240>: sub    x10, x29, #0x38           ; =0x38 
0x1009dc51c <+244>: str    x10, [x8, #0x10]
0x1009dc520 <+248>: sub    x10, x29, #0x34           ; =0x34 
0x1009dc524 <+252>: str    x10, [x8, #0x8]
0x1009dc528 <+256>: sub    x10, x29, #0x30           ; =0x30 
0x1009dc52c <+260>: str    x10, [x8]

 // 其余参数 x0, x1, x2
0x1009dc530 <+264>: sub    x2, x29, #0x2c            ; =0x2c 
0x1009dc534 <+268>: mov    x0, x9

// 调用 method 函数
0x1009dc538 <+272>: bl     0x1009e8d9c               ; symbol stub for: objc_msgSend

上述这段函数,简要而言,就是干了四件事:

  • 分配 176 byte 的栈内存

  • 在栈上分配 a = 0, b = 1 等等 9 个变量

  • 把 &b, &c 等 8个 int 变量的地址压栈。

  • x0 (obj), x1 (method), x2(&a)

特别注意,变参列表的第一个参数也是通过寄存器来传递。

  • 调用 method 函数

如果不理解, 可以参考这张图:

而当进入 method: 函数时,汇编如下:

重点看两行蓝色汇编断点的地方,其实是在暗示一种循环,也从底层实现上对应上了我们不断循环获取变参列表的逻辑。

简要来说,就是从变参列表的第一参数(寄存器中的值代表地址),开始读取,循环遍历。这里的循环利用了栈空间在函数调用间的连续性,不断将偏移地址从原来 caller 函数的 sp 回溯,读取处于高地址的 caller 栈空间中的 int 变量地址。

看到这,我想大家也知道了为什么是必定崩溃在第二次读取变参的时候。

  • x0, x1 不用说,是寄存器参数。和变参不变参函数无关,这也能解释为什么只读取 id obj 和 SEL selector 不会崩溃。

  • x2,即变参函数列表的第一个参数,我这里把他称为变参的锚点参数,它也是通过寄存器传递,所以读取的时候没问题。

  • 变参列表的后续参数都是分配在调用函数(caller)中,而 TrampolineHook 在调用 interceptor 之前利用了栈(操作 SP)来保存上下文,如下所示,因此破坏了栈资源上下文,导致循环从栈地址获取参数的时候崩溃

    stp q0,  q1,   [sp, #-32]!
    stp q2,  q3,   [sp, #-32]!
    stp q4,  q5,   [sp, #-32]!
    stp q6,  q7,   [sp, #-32]!
    stp lr,  x10,  [sp, #-16]!
    stp x0,  x1,   [sp, #-16]!
    
  • 而调用原函数的时候,由于栈已经复原了,所以就不会出现崩溃了。

解决方案

了解了问题出现的原因,解决办法就很简单了,我们要让调用 inteceptor 时候的上下文和调用原函数一样。

  • 还是构造一堆的动态 trampoline ,让原函数替换到 trampoline,同时保存原函数的 IMP。

  • 依然保存原先需要的上下文,比如通用寄存器、浮点寄存器,但是不能使用栈了。

  • 调用 interceptor。

  • 恢复上下文,调用到原函数。

其实整个步骤和原先基本一样,唯一需要考虑的就是如何在一点也不用栈的资源的前提下保存寄存器上下文?

堆上。
堆上。
堆上。

简单而言,我们把上下文一股脑都保存到堆上就行。需要保存的上下文大致类似于一个结构体:

typedef struct _THPageVariadicContext {
    int64_t gR[10];              // general registers x0-x8 + x13
    int64_t vR[16];              // float   registers q0-q7
    int64_t linkRegister;        // lr
    int64_t originIMPRegister;   // origin
} THPageVariadicContext;

当然,这里的结构体只是形象化表示内存中的数据顺序和含义,真正使用汇编操作内存的时候,没有结构体。

保存上下文解决了我们不污染栈的诉求,但是同时也引出了一个新的问题,堆分配的地址我们保存在哪?跨函数调用后恢复上下文必须要让我们分配出的堆地址得到“持久化”存储啊。

  • 保存到栈上?这肯定不可能,自己打自己脸嘛。

  • 保存到寄存器上?如果是 caller-saved 寄存器,那不能保证跨函数调用完后,寄存器里面的内容还是我们原先设定的那样;而如果是 callee-saved 寄存器,确实可以解决跨函数调用后数据还原成我们保存的那样。但是同样的,我们自身也是其他 caller 函数的 callee,我们侵占了一个寄存器,怎么在返回到 caller 函数之前复原这个 callee-saved 寄存器呢?

上面这段话有点绕。

所以,我们在分配堆内存的时候,要多分配一个 8 byte 的空间,把侵占的 callee-saved register 的值保存到堆内存中,然后再继续存我们原先要保留的上下文。

关键代码简要概括如下:

  • 第一步,在拦截到函数调用后,先进入我们的 pre 操作,这里是在堆上对应上下文空间大小的地方。需要注意的是,调用分配内存的函数是使用 malloc,我们并不知道 malloc 究竟会破坏哪些寄存器,因为也需要作一次额外的寄存器上下文保存,不过这个保存时短暂的,分配结束后就恢复。然后将这些上下文都保存到堆上。

    attribute((naked))
    void THPageVariadicContextPre(void)
    {

    // 先保存,避免调用 malloc 破坏寄存器
    saveRegs();
    
    // 分配堆上内存 extra 16 byte + sizeof(THPageVariadicContext)
    __asm volatile ("mov x0, #0xF0");
    __asm volatile ("bl _malloc");
    
    // 返回的分配内存地址保存起来 callee-saved
    __asm volatile ("str x19, [x0]");
    __asm volatile ("mov x19, x0");
    
    // 恢复堆栈,避免影响变参所处在的堆栈
    restoreRegs();
    
    // 用堆上空间保存数据
    __asm volatile ("stp x0, x1,  [x19, #(16 + 0 * 16)]");
    __asm volatile ("stp x2, x3,  [x19, #(16 + 1 * 16)]");
    __asm volatile ("stp x4, x5,  [x19, #(16 + 2 * 16)]");
    __asm volatile ("stp x6, x7,  [x19, #(16 + 3 * 16)]");
    __asm volatile ("stp x8, x13, [x19, #(16 + 4 * 16)]");
    
    __asm volatile ("stp q0, q1,  [x19, #(16 + 5 * 16 + 0 * 32)]");
    __asm volatile ("stp q2, q3,  [x19, #(16 + 5 * 16 + 1 * 32)]");
    __asm volatile ("stp q4, q5,  [x19, #(16 + 5 * 16 + 2 * 32)]");
    __asm volatile ("stp q6, q7,  [x19, #(16 + 5 * 16 + 3 * 32)]");
    
    __asm volatile ("stp lr, x10, [x19, #(16 + 5 * 16 + 4 * 32)]");
    
    __asm volatile ("ret");
    

    }

  • 调用完拦截函数,我们需要销毁堆空间,由于我们之前使用的是 callee-saved 的寄存器,我们能确保寄存器的值还是调用之前的。所以我们放心的将其中的值取出来,然后销毁对应的占空间,然后恢复寄存器即可。

    __attribute__((__naked__))
    void THPageVariadicContextPost(void)
    {
        // x19 肯定是正确的地址,使用x19恢复对应的数据
        __asm volatile ("ldp lr, x10, [x19, #(16 + 5 * 16 + 4 * 32)]");
        __asm volatile ("ldp q6, q7,  [x19, #(16 + 5 * 16 + 3 * 32)]");
        __asm volatile ("ldp q4, q5,  [x19, #(16 + 5 * 16 + 2 * 32)]");
        __asm volatile ("ldp q2, q3,  [x19, #(16 + 5 * 16 + 1 * 32)]");
        __asm volatile ("ldp q0, q1,  [x19, #(16 + 5 * 16 + 0 * 32)]");
    
        __asm volatile ("ldp x8, x13, [x19, #(16 + 4 * 16)]");
        __asm volatile ("ldp x6, x7,  [x19, #(16 + 3 * 16)]");
        __asm volatile ("ldp x4, x5,  [x19, #(16 + 2 * 16)]");
        __asm volatile ("ldp x2, x3,  [x19, #(16 + 1 * 16)]");
        __asm volatile ("ldp x0, x1,  [x19, #(16 + 0 * 16)]");
    
        // 保存一下,避免 free 的影响。
        saveRegs();
    
        // 恢复原先的 x19, 调用free
        __asm volatile ("mov x0, x19");
        __asm volatile ("ldr x19, [x19]");
        __asm volatile ("bl _free");
    
        // 恢复堆栈
        restoreRegs();
    
        __asm volatile ("mov lr, x13");
        __asm volatile ("br x10");
    }
    
  • 需要注意的是,我们这里用了__attribute__((__naked__)),这个作用是为了让我们的函数不会额外的生成函数 prologue/epilogue 中的压栈消栈操作。

至此,变参 Hook 就完成了,大家可以前往 Github 查看最新的 THVaradicInterceptor 来使用。

后记

有的朋友会问,为什么很多网上常见的 Hook 方案,都不要这么复杂的上下文保存流程?

其实道理很简单,保存什么上下文取决你的拦截或者 Hook 函数的目的以及使用方式。

举个非常常见的统计函数调用耗时的例子,在这个情形中,一般只用关注 x0x1 两个参数 来记录是什么类什么函数的调用。这种情况下,你的上下文保存可以极简,甚至只要保存 x0, x1 即可。

TrampolineHook 想要提供的拦截器,是一个通用的拦截器,我不能保证其内部的实现,因为我需要保留的上下文就必须很完整。

后续 TrampolineHook 除了完善对 x86_64 的支持外,还有两个比较大的技术目标,也会慢慢完善。如果有什么使用中遇到的问题或者 Bug 也欢迎提交代码。

基于桥的全量方法 Hook 方案(3)- TrampolineHook

本来以为是双休日,结果五一调休本周末只休一天,懵逼。不过还算完成了承诺,赶了出来。

开源地址:https://github.com/SatanWoo/TrampolineHook

TrampolineHook 是什么

之前杨萧玉在看到我《基于桥的全量方法 Hook 方案(2) - 全新升级》 后就问我这个和直接用 method_exchangeImplementation 之类的 runtime 方法交换 IMP 性能对比咋样?

所以这篇文章开头先占用大家宝贵的两分钟,简要说明下。

TrampolineHook 本质上不是用来 Swizzling 的框架,取 Hook 这个名字只是为了读起来顺口。它实际上是一个中心重定向框架。 换句话说,你可以认为它是为了通过一个函数替换/拦截所有你想要函数的框架。

其实这个中心重定向的思想并不新潮,很多人(包括我自己)在内就曾经利用重载 objc_msgForward 干过这样的事。

但是这个方式我在之前的文章里也提到过对应的缺点,比如:

  • 性能慢
  • 不能替换/拦截同一个继承链上的多个类。

所以可以认为 TrampolineHook 是一个让你不用关注底层架构Calling Convention(因为涉及到汇编),不用关心上下文信息保存、恢复,不用担心引入传统 Swizzle 方案在大型项目中有奇奇怪怪 Crash 问题的中心重定向框架。

TrampolineHook 技术原理

整个技术原理其实可以分为三部分:

  • vm_remap 技术。

  • 流程设计。

  • 汇编实现。

vm_remap 的价值

通俗意义上,我们访问的内存都是按照页来组织。而在程序加载后分配的页之中,会对应有不同的权限,比如代码占用的页,就是可读且可执行,但是一般不具备可写的权限;而存放数据的页呢,就对应是可读且可写,但不能拥有可执行权限。

在绝大多数情况下,当我们编写完一个程序运行的时候,动态分配的页都是用来做数据保存、访问的,不太会有涉及执行权限。

而要做到可以将动态分配出来的内存页具备可执行权限,就需要利用 vm_remap。 它的定义是这样的:

On Darwin, vm_remap() provides support for mapping an existing code page at new address, while retaining the existing page protections; using vm_remap(), we can create multiple copies of existing, executable code, placed at arbitrary addresses.

从定义中我们可以知道两点信息:

  • vm_remap 可以让内存页具备被 map 的页的特性,如果是可执行页被 map,那新创建的页自然而然页具备了这个权限。

  • vm_remap 也不是肆无忌惮的创建任何可执行的页,通俗理解,它只是一个 copy 映射。

上述图片引用自Implementing imp_implementationWithBlock()

因此,我们可以通过在编写代码的过程中,精心构造、预留在程序二进制的代码页,在运行时不断“复制映射”,来完成特殊的使命。

在我们的定义中,我们是构造了连续的两个页

流程设计

要构造特殊的程序二进制代码,首先还是要梳理我们的目的,我们的诉求是所有的函数都能先进入我们的一个中心重定向函数,执行自定义的操作,然后返回原函数,同时这个调用栈不能乱。

  • 把一个我们要替换的原方法 IMP A 取出来,保存起来。
  • 给这个原方法塞一个动态分配的可执行地址 B。
  • 当执行这个原方法的时候,会跳转到 可执行地址 B。
  • 这个 B 经过一段简短的运算操作,可以获取到原先保存的 IMP A。
  • 在跳转回 IMP A 之前,统一拦截函数先做些事情,比如检查是不是主线程调用之类的。

【注意】:在整个过程中,我们要保证参数寄存器、返回地址等不能错乱。

汇编实现

既然 vm_remap 是按页的维度来映射,我们要构造的代码自然而然要页对齐在 arm64 中,一页是 0x4000,也就是 16KB,所以首先就是 .align 14 来确保。

然后上一下最关键部分的代码,感兴趣的还是去 Github 上阅读完整的代码吧。

_th_entry:

// 不要小看这五行汇编
nop
nop
nop
nop
nop

sub x12, lr,   #0x8
sub x12, x12,  #0x4000
mov lr,  x13

ldr x10, [x12]

stp q0,  q1,   [sp, #-32]!
stp q2,  q3,   [sp, #-32]!
stp q4,  q5,   [sp, #-32]!
stp q6,  q7,   [sp, #-32]!

stp lr,  x10,  [sp, #-16]!
stp x0,  x1,   [sp, #-16]!
stp x2,  x3,   [sp, #-16]!
stp x4,  x5,   [sp, #-16]!
stp x6,  x7,   [sp, #-16]!
str x8,        [sp, #-16]!

// 加载自定义的拦截器,并跳转过去。
ldr x8,  interceptor
blr x8

ldr x8,        [sp], #16
ldp x6,  x7,   [sp], #16
ldp x4,  x5,   [sp], #16
ldp x2,  x3,   [sp], #16
ldp x0,  x1,   [sp], #16
ldp lr,  x10,  [sp], #16

ldp q6,  q7,   [sp], #32
ldp q4,  q5,   [sp], #32
ldp q2,  q3,   [sp], #32
ldp q0,  q1,   [sp], #32

br  x10

.rept 2032
mov x13, lr
bl _th_entry;
.endr

整段汇编可以分为几个部分:

  • 设计一大堆的动态可执行地址,即:

    .rept 2032
    mov x13, lr
    bl _th_entry;
    .endr
    

    这里最早我的实现是复制粘贴一大堆重复性代码,在 HookZZ 作者的指导下,我优化成了上述这样。

  • 执行统一的运行过程,通过偏移计算等方式获取保留的原始 IMP。

  • 要注意特定的寄存器用处,x8-x18是临时寄存器,里面的值在函数调用后可能被修改,这些寄存器为caller-saved。所以在我们自身函数可以用,但是要在调用别的函数之前保存好。

  • 要特别注意对 LR 寄存器的处理,没处理好,调用栈就回不去了。

  • 保存对应的参数、浮点参数等寄存器,避免上下文被我们自己的处理函数破坏。

  • b / bl 的跳转范围非常有限,由于我们是动态地址分配,不能保证拦截函数的范围偏移,所以要采用 blr 的方式。

TrampolineHook 用处

和传统的 Swizzle 需要提供对应的替换后的函数实现不同,中心化重定向思想可以帮助你实现很多有意思的事情:

  • 比如网上很常见的 hook objc_msgSend,可以帮你查看任意被 Hook 二进制中的函数耗时和调用链路。

  • 比如 Bang / AnyMethodLog 这样的重定向 Log 日志框架等等。

苹果著名的 MainThreadChecker 也用了类似的技术。由于我才疏学浅,只是大致完成了对其实现的逆向,通过 TrampolineHook 进行了重写。 因为效果还不错,所以也开源了出来,地址是:https://github.com/SatanWoo/TrampolineHook/tree/master/Example/MainThreadChecker

这次在重写 MainThreadChecker 的过程中,我也对比了下和 2017 年苹果实现的差异。在整体流程上没有比较大的差异,但是还是有一些细节可以分享分享:

  • iOS 10 的时候对应的二进制是 UIKit,到了 iOS 12/13 成了 UIKitCore,所以原先获取二进制的逻辑失效了,为了避免后续版本的变更干扰,我采用了苹果自身的守候,通过 class_getImageName([UIResponder class]) 来保证获取的就是我们理解上的 UIKit 动态库。

当然 TrampolineHook 的作用不止于此,争取过段时间把我的一些想法做完善再和大家交流。

后续思考

本质上 Trampolinevm_remap 技术不是新的技术,很早就有人应用了,构造 Trampoline 实际上在苹果自身关于 Block 的实现中就有。业界也有 SwiftTrace 也是用了对应的技术。

真正的关键在于你用 Trampoline 做什么?用途的不同也决定了效果的不同,这也是我把之前的代码重写 TrampolineHook 中所收获的,而且随着 TrampolineHook 相对我自身之前实现的优化,我发现眼前豁然开朗,能玩的事情还有很多,哈哈。

对了,如果有朋友对 arm64 的汇编比较熟悉,同时对函数调用也比较了解的话,会很快的发现我上述提供的汇编代码存在一个漏洞(虽然这个漏洞绝大多数人用不到),感兴趣的朋友可以微信交流下。

开源地址:https://github.com/SatanWoo/TrampolineHook 如果大家有什么想法或者遇到了自身项目中的 Bug,欢迎 issue。

基于桥的全量方法 Hook 方案(2) - 全新升级

如果读过我的博客的人可能知道,我在 2017 年曾经研究过当时苹果出的一个新玩意 MainThread Checker,并以此为基础推导了一个基于桥的全量 Hook 方案,基于桥的全量方法Hook方案 - 探究苹果主线程检查实现 。当时简单写了下 ARM64 的方案代码,并放在了 Github 上,不过已废弃。

当时觉得自己研究的还算深入,基于汇编写(其实是复制粘贴)了一大堆的桥,可以针对性 Hook 一个或多个二进制,比如 UIKit 的逻辑,觉得挺屌的。

但是使用中发现了两个巨大的问题:

  • 性能问题。由于我是运行时的方案,没法对二进制产物进行修改(比如编译插桩),因此如果要能达到对二进制所有方法中心重定向的效果,借助了 forwarding 的流程(不是objc_msgForward)。但是这个方案懂的人肯定明白,性能巨慢。

不能上线的方案其实价值都不大。

  • Crash 问题。尽管我通过汇编的方式解决了中心重定向 Hook 方案上对一条继承链重复 Hook 会死循环的 Crash 问题(如果你不理解,可以回到我文章开头所提及的文章了解原因或者查看 Aspects 库中对应的 issue), 但是却出乎意料的引入了系统库新的 Crash,这个我会开一篇新的文章来分析。

因此,当时这个方案我就抛弃了,后续也因为我不怎么搞iOS,就没深入优化。

新的方案

新的方案的起源灵感来自于我隔壁组的同事,手淘架构新生代小天王谢俊逸的启发。他说你用汇编写桥,照理性能不会慢啊,你为啥要走一次 forwarding 的逻辑?

我回顾了下代码,发现原先我为了保留所谓的层级上下文,将类名和方法名构造成了一个唯一标示,然后将这个唯一标示和一个动态生成的函数地址相绑定。然后通过这个不存在的方法名触发 forwarding 流程,然后改写成正确的方法名,从而调用正确的被 HOOK 前的函数。

看不懂的话等我周末整理下代码开源吧。

而整个流程,也是如下两个问题的罪魁祸首。

  • 改了方法名:SEL 的修改之前是为了解决中心重定向相同继承链上的 Hook Crash 问题,但是会导致意想不到的其他 Crash 问题。

  • 性能巨慢:走 fowarding 流程绕了一大圈。

要解决上述这些问题,汇编和桥依然是不可或缺的,但是如何把所有 UIKit 的方法都中心重定向同时又能绕开继承链问题呢还能不修改 SEL 的名称呢?

经过和谢俊逸的讨论,我们发现,我们把原先保存 拼接后 SEL 的逻辑,换成直接保存 HOOK 之前的 函数IMP,然后通过汇编直接跳过去执行 IMP 不就完事了?

思路非常 Nice ! 开工

动手过程

想法有了,因为涉及到汇编,需要非常复杂的操作流程,简单抛砖引玉一下。

_template_page:
sub x12, lr, #0x8
sub x12, x12, #0x4000 // 获取对应数据页的便宜
mov lr, x13   // 获取返回原始调用处

// x8-x18 临时寄存器,里面的值在函数调用后可能被修改,这些寄存器为caller-saved,可以用
ldr x10, [x12] // originIMP

stp lr, x10, [sp, #-16]!
stp x0, x1,  [sp, #-16]!
stp x2, x3,  [sp, #-16]!
stp x4, x5,  [sp, #-16]!
stp x6, x7,  [sp, #-16]!
str x8,      [sp, #-16]!

// 我不用浮点数寄存器,所以我不保存,你们用你们要保存
// 这行是伪代码,意思意思。实际上这个代码是会Crash的。
bl _WZQMainThreadChecker

ldr x8,      [sp], #16
ldp x6, x7,  [sp], #16
ldp x4, x5,  [sp], #16
ldp x2, x3,  [sp], #16
ldp x0, x1,  [sp], #16
ldp lr, x10, [sp], #16

br x10   // 执行原函数

mov x13, lr
bl _template_page;

//// 下面是重复性的一堆代码。

主体上是这么写,但是需要考虑的太多了,今天周二,来不及整完博客了,吹吹逼睡觉。

还有很多东西实现了但是文章中没写,周末再写吧,水一篇博客。

  • 要考虑对齐问题?

  • 为什么可以这么设计桥?

  • 如何保存重要的上下文、寄存器信息等?

结尾

代码写完后我和同事放在手淘里跑了泡,美滋滋,嘻嘻。不崩,还挺顺畅,哈哈,吊打原先的实现。

当然,鉴于本人汇编仅较初级的掌握 ARM64,因此 x86_64 或者 ARM64e(不知道有没有差别) 上的方案近期慢慢等我搞出来吧。

ARM64 上的代码等我周末慢慢整理下开源。

目标检测之 Selective Search


最近因为工作上的事,搞了一点非常基础目标检测相关的东西。正好在学习之余梳理了下之前自己认知错误的一些地方,记录一下。

起因


之前对于目标检测的了解停留于深度学习部分,比如 Fast-RCNN / Faster-RCNN / Yolo 等等,对于候选框域搜索算法主要还是对于 RPN 的认知。


但是这次在工作中了解到了 Selective Search 的概念,没想到在小样本训练的过程中精度也不错,性能还很好,哈哈。因此决定深入研究下。Selective Search 从大类上也可以属于 Region Proposal 的思想,但是主要的思想却是来源于传统的图像处理。


相关的论文发表于 IJCV 2013 《Selective Search for Object Detection》,大家可自行阅读获取更多细节。

主要还是学习目的,业界主流的还是采用 Faster-RCNN 的做法。


目标检测问题相对来说比图像分类复杂点,因为一般情况下要同时检测出多个子物体的位置(及可能需要的分类目的)。最原始的做法就是对于一张图像的每个可能位置都进行搜索,但是这里会产生一个两个互相增加复杂度的问题?

  • 我们要识别的物体在哪?我们要识别的物体大小是多少?长宽比要不要考虑?


简单来说,假设知道一个待识别的物体左上角顶点处于(x, y),那么长和宽分别设置多少呢?设置小了,可能没有办法得到正确要识别的物体;设置大了,可能又把要分开区分的两个或多个物体合在了一起。


因此,这种传统的做法产生的搜索空间基本可以认为是无穷尽的。


那么自然而然地,我们的优化的想法肯定是减少搜索空间的大小!怎么做呢?


答案说难也不难,就是只找哪些可能是物体的区域。从区域这个维度进行搜索,而不是全图像的像素级查询。

全图搜索绝大多数的搜索像素包含区域是不包含物体的,实质上是浪费,可以通过如下两张图进行直观对比。








基于此,作者首先利用图像分割的想法,来获取可能是物体的区域;当然,这种层次的分割肯定不准


进一步地,考虑掉物体之间诸如包含等关系,通过
合并的方式来构建层次化**的潜在物体区域。


所以整篇论文的核心就可以归纳为如下的数学公式:



  • 通过图像分割算法得到初始区域集合 R = {r1, ….. rn},这个很容易理解吧,就是图像分割。
  • 设定一个相似集合 S,初始为
  • 对于初始区域集合相邻中的每一对(ri, rj),计算相似度(下文会说如何计算相似度),得到 s(ri, rj),将其加入之前的相似集合 S 中。
  • 当 S 不为空的时候,从 S 中获取相似度最大的一对 s(ri, rj),将这两个 ri, rj 区域合并,称为 rt。
  • 把所有和 ri, rj 相关的相似度对都从 S 中移除掉。(ri, rj 已经不存在了,变身为 rt)
  • 把新得到的 rt,在分别和其邻区域的 rx 们,计算相似度对,存入 S 中。
  • 把 rt 加入到区域集合 R 中。
  • 重复步骤,知道合并到最后只有一个区域了(即 S 为空)。


这个时候,R 集合中的所有区域,就是通过 Selective Search 得到的候选框区域。


值得注意的是,这种计算方式得到的 R,本身就包含了多层次的关系。

如何合并


前面我们提到了,我们初始的待定区域是基于图像分割得到的一批候选集,但是这些候选集的质量还比较“糙”,粒度也不一定对,需要合并甚至多次合并来处理一下。因此,如何合并也是一个相对值得思考的问题。

截屏2020-04-07上午12.33.29.png

上两张图不难看出,初始化的图像分割对于目标检测来说是不能直接使用的。


其实这篇文章,作者也坦诚道:图片的样式千变万化,某些图片里面可行的方案到了另外一些图片中就不适用了。 因此,作者采用了多种方案混合的合并方法。

  • 比如,背景色大块区域和前景色不同的主体可以很明显区分。
  • 比如,材质 / 纹理等也可以比较明显区分出待检测的物体。
  • 比如,形状和大小也可以做为检测手段区分待检测物体。


有了这些可以参考的思路,作者设计了四合一的合并公式。

  • 颜色相似度
  • 纹理相似度,这里使用了 SIFT 算法。
  • 小区域合并优先级度。这里解释下,作者为了避免出现“大鱼吃小鱼”的现象,即一块区域不断膨胀,吞并周围区域,所以采用了尽量将小区域先分别合并,始终保持大小类似的方式。
  • 距离。如果区域ri包含在rj内,毫无疑问应该立刻合并,另一方面,如果ri很难与rj相接,不应该合并在一块。这里定义区域的合适度距离主要是为了衡量两个区域是否更加“吻合”,其指标是合并后的区域的Bounding Box(能够框住区域的最小矩形BBij)越小,其吻合度越高。

Selective Search 代码理解


读顶尖学术会议论文的好处就是一般对应的代码都会开源,即使论文读的云里雾里,但是只要能大致理解思路,配合源代码深入分析,总是能懂。


这篇论文对应的代码开源在Selective Search,代码总计也就 300+ 行(当然有些非核心代码直接依赖了库),很容易理解。

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
def selective_search(
im_orig, scale=1.0, sigma=0.8, min_size=50):
'''Selective Search

assert im_orig.shape[2] == 3, "3ch image is expected"

# load image and get smallest regions
# region label is stored in the 4th value of each pixel [r,g,b,(region)]


1】图像分割
img = _generate_segments(im_orig, scale, sigma, min_size)

if img is None:
return None, {}


2】获取对象总大小
imsize = img.shape[0] * img.shape[1]

3】获取初始集合
R = _extract_regions(img)

# extract neighbouring information

4】计算相邻的区域
neighbours = _extract_neighbours(R)

5】计算初始化的相邻区域相似度
S = {}
for (ai, ar), (bi, br) in neighbours:
S[(ai, bi)] = _calc_sim(ar, br, imsize)


6】就是之前我们说的搜索过程
# hierarchal search
while S != {}:

# get highest similarity
i, j = sorted(S.items(), key=lambda i: i[1])[-1][0]

# merge corresponding regions
t = max(R.keys()) + 1.0
R[t] = _merge_regions(R[i], R[j])

# mark similarities for regions to be removed
key_to_delete = []
for k, v in list(S.items()):
if (i in k) or (j in k):
key_to_delete.append(k)

# remove old similarities of related regions
for k in key_to_delete:
del S[k]

# calculate similarity set with the new region
for k in [a for a in key_to_delete if a != (i, j)]:
n = k[1] if k[0] in (i, j) else k[0]
S[(t, n)] = _calc_sim(R[t], R[n], imsize)

regions = []
for k, r in list(R.items()):
regions.append({
'rect': (
r['min_x'], r['min_y'],
r['max_x'] - r['min_x'], r['max_y'] - r['min_y']),
'size': r['size'],
'labels': r['labels']
})

return img, regions

  • 第一步,通过经典的图像分割算法获取分割的块。这一步留到后续研究 felzenszwalb 算法再说吧,暂时我也不会。
  • 其实第一步已经得到对应的区域了,但是在算法实现上只是做了一个个标记,所以还需要处理下,变成我们需要的 R 集合。这步里面已经做好了大量的计算处理,后续直接按照论文层级化调用就行。
  • 计算相邻的区域,对应产生初始的 S 集合。
  • 对相邻的区域计算最大相似度,然后合并。
  • 后面就重复我上问的内容了。


大致内容就这样,当然细节还有不少值得研究的,可以继续深入,后续再读读。


最后,作者这 Python 写的真是溜。

实现 MNN 模型的可视化工具

Netron 是一个支持 TensorflowPyTorchMXNetNCNNPaddlePaddle 等深度模型格式的可视化框架。去年国庆前的时候我稍微研究了下相关的代码,重点关注其将其是如何设计出一套兼容不同模型格式表征,用来归一化展现不同的深度学习框架模型。

研究完成后,我利用如下两个 Commit 作为 Pull Request 提交给了作者,用以支持 MNN 的模型可视化。

从中也不难看出我扎实的英语表述能力(我果然是个国际化人才)。

这篇文章会从架构设计、标准定义、巧用JS解析等几个方面来阐述

架构设计

整体上,按照我个人的理解,Netron 的架构可以简要展现如下:

最基础的应用部分及运行环境,是 Electron 这个跨平台框架直接呈现的。
当然,一些诸如基础zip/gzip用于解压等等的库我们也统一归类到支撑里。

然后是一套经典的 MVC 的结构,app.js 作为整体的 controller ,负责整个应用的功能逻辑,如导出图片、菜单管理、保存加载等等。这一层我们需要的做事非常少,只要将 MNN 支持的模型后缀 .mnn 注册进去即可。 然后是是对应的 view.js,这块实际上还是一层 controller,类比我们常说的子控制器,专门用于处理主视图的逻辑,如下图所示:

从这块开始,我们就要注意了,因为这里开始通过工厂方法对应的根据读取文件类型的不同,托管给了不同的自定义 xxx.js 来处理后续步骤。 比如.marmodelprototxt 等格式的模型会首先托管给 mxnet.js来处理。如果存在重名,则按照先后顺序依次尝试。

view.ModelFactoryService = class {
    constructor(host) {
        this._host = host;
        this._extensions = [];
        this.register('./onnx', [ '.onnx', '.pb', '.pbtxt', '.prototxt' ]);
        this.register('./mxnet', [ '.mar', '.model', '.json', '.params' ]);
        this.register('./keras', [ '.h5', '.hd5', '.hdf5', '.keras', '.json', '.model' ]);
        this.register('./coreml', [ '.mlmodel' ]);
        this.register('./caffe', [ '.caffemodel', '.pbtxt', '.prototxt', '.pt' ]);
        this.register('./caffe2', [ '.pb', '.pbtxt', '.prototxt' ]);
        this.register('./pytorch', [ '.pt', '.pth', '.pkl', '.h5', '.t7', '.model', '.dms', '.pth.tar', '.ckpt', '.bin' ]);
        this.register('./torch', [ '.t7' ]);
        this.register('./torchscript', [ '.pt', '.pth' ]);
        this.register('./mnn', ['.mnn', '.tflite']);
        this.register('./tflite', [ '.tflite', '.lite', '.tfl', '.bin' ]);
        this.register('./tf', [ '.pb', '.meta', '.pbtxt', '.prototxt', '.json' ]);
        this.register('./sklearn', [ '.pkl', '.joblib', '.model' ]);
        this.register('./cntk', [ '.model', '.cntk', '.cmf', '.dnn' ]);
        this.register('./openvino', [ '.xml' ]);
        this.register('./darknet', [ '.cfg' ]);
        this.register('./paddle', [ '.paddle', '__model__' ]);
        this.register('./ncnn', [ '.param', '.bin', '.cfg.ncnn', '.weights.ncnn']);
        this.register('./dl4j', [ '.zip' ]);
        this.register('./mlnet', [ '.zip']);

    }

在这上层是一层标准定义层,用于抹平不同模型之间的表达方式,用归一化的逻辑来进行处理,至于怎么把自己的模型表征映射成归一化的逻辑,就需要编写对应 xxx.js 来自行处理,后文会以 MNN 来进行举例。

最上层就是对应各个深度框架自行的逻辑处理了。其中包含了数据格式及对应解析(如 flatbuffer)、内容校验、构图等等,后文也会用 MNN 举例说明。

标准定义

这一环是一个很不起眼但是却非常重要的环节。 每种深度模型框架都有其自定义的模块结构和模块构成,一般都以 Flatbuffer Schema 的形式构成。(当然也有例外)以MNN 为例,其对应的模型结构大致如下图所示:

上图引用自FlatBuffers,MNN模型存储结构基础 —- 无法解读MNN模型文件的秘密

同理, TFLite 的模型也可见 TFLite.schema,不再赘述。

从定义中不难看出,TFLitemodelgraphSubGraph 等;而 MNN 对应的就是Net;再往下一层 TFLiteOperatorOptions;而 MNNOPOPParameter;至于 NCNN 则是 Layer

如果是从整个架构角度去兼容不同的框架,必然会有着大量的 messy code。因此作者定义了一套标准表征,让不同的深度模型自己去解析,然后附着自身的逻辑到这同一套表征上。

  • Model ,表示模型的静态表示。
  • Graph ,表示模型的计算图表示。
  • Node ,一个操作对应一个节点。
  • Tensor ,输入输出数据。
  • Parameter ,对应的属性。
  • Argument ,对应的属性值。

上述 ParameterArgument可以简单认为一一对应吧,都认为是属性值即可。

一图胜千言,下图比较好的展现了术语和对应的表征:

这样不同的框架模型只要在自己对应的 xxx.js 中,把图,OP对应的数据填充至对应的地方即可。

这里依然以 MNN 举例:

  • 我们不存在 subgraph 的概念,直接把 ModelGraph 等价于一个 net即可。
  • net 中取出 oplist ,对应创建成 Node
  • oplist 中每个 op ,取出对应的 tensorIndex,根据 nettensorNametensorIndex 来创建对应的 tensor
  • op 中根据 opparameter 的种类,从 op.main 中取出不同的数据来填入 paramter / argument这块是解析的大头,如果没想好方式,就会非常浪费时间,下文重点说。

数据格式

诸如 MNNTFlite 都选用了 Flatbuffer 来进行数据的保存,而官方的 flatc 程序支持直接根据定义的 schema 文件生成对应的 generated.js,命令如下:

./flatc -s ~/yourPathTo/MNN/schema/default/Type.fbs

这个我看了下很多的同学的在处理多 Schema 定义的时候是对应的一个个生成 generated.js,这样维护成本比较大,既然我们的已经使用了 include 机制,我们直接在生成过程中合并即可,如下所示:

./flatc --js -I ~/yourPathTo/MNN/schema/default/ ~/yourPathTo/MNN/schema/default/MNN.fbs --gen-all

这里有两个参数注意下:

  • -I,表示 include 从哪个路径进行搜索。
  • --gen-all,表示自动对生成的所有文件合并。

生成代码大致如下:

/**
 * @param {number} i
 * @param {flatbuffers.ByteBuffer} bb
 * @returns {MNN.Blob}
 */
MNN.Blob.prototype.__init = function(i, bb) {
  this.bb_pos = i;
  this.bb = bb;
  return this;
};
/**
 * @param {flatbuffers.ByteBuffer} bb
 * @param {MNN.Blob=} obj
 * @returns {MNN.Blob}
 */
MNN.Blob.getRootAsBlob = function(bb, obj) {
  return (obj || new MNN.Blob).__init(bb.readInt32(bb.position()) + bb.position(), bb);
};
/**
 * @param {flatbuffers.ByteBuffer} bb
 * @param {MNN.Blob=} obj
 * @returns {MNN.Blob}
 */
MNN.Blob.getSizePrefixedRootAsBlob = function(bb, obj) {
  return (obj || new MNN.Blob).__init(bb.readInt32(bb.position()) + bb.position(), bb);
};

具体关于 FlatBuffer 的细节,可以阅读我之前的文章,不再赘述。

避免冗余解析流程

上文提到 根据 OpParameter 来获取 main 中的数据,然后依次填入 parameter / argument 是比较耗费精力的步骤。我们所有的 OpParameter 类型有 74种(还在不断更新)

MNN.OpParameter = {
  NONE: 0,
  QuantizedAdd: 1,
  ArgMax: 2,
  AsString: 3,
  Axis: 4,
  BatchNorm: 5,
  BinaryOp: 6,
  Blob: 7,
  CastParam: 8,
  Convolution2D: 9,
  Crop: 10,
  CropAndResize: 11,
  Dequantize: 12,
  DetectionOutput: 13,
  Eltwise: 14,
  ExpandDims: 15,
  Fill: 16,
  Flatten: 17,
  Gather: 18,
  GatherV2: 19,
  InnerProduct: 20,
  Input: 21,
  Interp: 22,
  LRN: 23,
  LSTM: 24,
  MatMul: 25,
  NonMaxSuppressionV2: 26,
  Normalize: 27,
  PackParam: 28,
  Permute: 29,
  Plugin: 30,
  Pool: 31,
  PRelu: 32,
  PriorBox: 33,
  Proposal: 34,
  QuantizedAvgPool: 35,
  QuantizedBiasAdd: 36,
  QuantizedConcat: 37,
  QuantizedLogistic: 38,
  QuantizedMatMul: 39,
  QuantizedMaxPool: 40,
  QuantizedRelu: 41,
  QuantizedRelu6: 42,
  QuantizedReshape: 43,
  QuantizedSoftmax: 44,
  QuantizeMaxMin: 45,
  QuantizeV2: 46,
  Range: 47,
  Rank: 48,
  ReduceJoin: 49,
  ReductionParam: 50,
  Relu: 51,
  Relu6: 52,
  RequantizationRange: 53,
  Requantize: 54,
  Reshape: 55,
  Resize: 56,
  RoiPooling: 57,
  Scale: 58,
  Selu: 59,
  Size: 60,
  Slice: 61,
  SliceTf: 62,
  SpaceBatch: 63,
  SqueezeParam: 64,
  StridedSliceParam: 65,
  TensorConvertInfo: 66,
  TfQuantizedConv2D: 67,
  TopKV2: 68,
  Transpose: 69,
  UnaryOp: 70,
  MomentsParam: 71,
  RNNParam: 72,
  BatchMatMulParam: 73,
  QuantizedFloatParam: 74
};

Convolution2D 举例,它又有几个对应的参数:weightbiasquanParametersymmetricQuanpadXpadYkernelXkernelY 等等,需要解析。

一开始我采用了人肉的解析方式,代码就成了 if else 加上一大堆解析代码:

mnn_private.Convolution2DAttrBuilder = class {
constructor() {}

buildAttributes(metadata, parameter) {
    //var common = parameter.common();
    var attributes = [];
    var common = parameter.common();
    attributes.push(new mnn.Attribute(metadata, "padX", common.padX(), true));
    attributes.push(new mnn.Attribute(metadata, "padY", common.padY(), true));
    attributes.push(new mnn.Attribute(metadata, "kernelX", common.kernelX(), true));
    attributes.push(new mnn.Attribute(metadata, "kernelY", common.kernelY(), true));
    attributes.push(new mnn.Attribute(metadata, "strideX", common.strideX(), true));
    attributes.push(new mnn.Attribute(metadata, "strideY", common.strideY(), true));
    attributes.push(new mnn.Attribute(metadata, "dilateX", common.dilateX(), true));
    attributes.push(new mnn.Attribute(metadata, "dilateY", common.dilateY(), true));
    attributes.push(new mnn.Attribute(metadata, "padMode", mnn.schema.PadModeName[common.dilateY()], true));
    attributes.push(new mnn.Attribute(metadata, "group", common.group(), true));
    attributes.push(new mnn.Attribute(metadata, "outputCount", common.outputCount(), true));
    attributes.push(new mnn.Attribute(metadata, "inputCount", common.inputCount(), true));
    attributes.push(new mnn.Attribute(metadata, "relu", common.relu(), true));
    attributes.push(new mnn.Attribute(metadata, "relu6", common.relu6(), true));
    //var quanParameter = parameter.quanParameter();
    var weights = [];
    for (var w = 0; w < parameter.weightLength(); w++) {
        weights.push(parameter.weight(w));
    }
    attributes.push(new mnn.Attribute(metadata, "weights", weights, true));
    var bias = [];
    for (var b = 0; b < parameter.biasLength(); b++) {
        bias.push(parameter.bias(b));
    }
    attributes.push(new mnn.Attribute(metadata, "bias", bias, true));

    return attributes;
}

get hasMain() {
    return true;
}

这样的代码如果写完74个 OpParameter ,可维护性和后续的扩展也不够。

我们要巧用 JavaScriptReflect 能力以及属性等于与字符串值属性的特性

_buildAttributes(metadata, op, net, args) {
        var opParameter = op.mainType();    
        var opParameterName = mnn.schema.OpParameterName[opParameter];

        // 获取对应的类型
        var mainConstructor = mnn.schema[opParameterName];
        var opParameterObject = null;
        if (typeof mainConstructor === 'function') {
            var mainTemplate = Reflect.construct(mainConstructor, []);
            opParameterObject = op.main(mainTemplate);
        }
        this._recursivelyBuildAttributes(metadata, net, opParameterObject, this._attributes);
    }
    _recursivelyBuildAttributes(metadata, net, opParameterObject, attributeHolders) {
        if (!opParameterObject) return;
        var attributeName;
        var attributeNames = [];
        var attributeNamesMap = {};
        for (attributeName of Object.keys(Object.getPrototypeOf(opParameterObject))) {
            if (attributeName != '__init') {
                attributeNames.push(attributeName);
            }
            attributeNamesMap[attributeName] = true;
        }
        var attributeArrayNamesMap = {}; 
        for (attributeName of Object.keys(attributeNamesMap)) {
            if (attributeNamesMap[attributeName + 'Length']) {                    attributeArrayNamesMap[attributeName] = true;
                attributeNames = attributeNames.filter((item) => item != (attributeName + 'Array') && item != (attributeName + 'Length'));
            }
        }
        for (attributeName of attributeNames) {
            if (opParameterObject[attributeName] && typeof opParameterObject[attributeName] == 'function') {
                var value = null;
                if (attributeArrayNamesMap[attributeName]) {
                    var array = [];
                    var length = opParameterObject[attributeName + 'Length']();
                    //var a = opParameterObject[attributeName + 'Array']();
                    for (var l = 0; l < length; l++) {
                        array.push(opParameterObject[attributeName + 'Length'](l));
                    }
                    value = array;
                }
                else {
                    value = opParameterObject[attributeName]();
                    if (typeof value === 'object') {
                        this._recursivelyBuildAttributes(metadata, net, value, attributeHolders);   
                        value = null;
                    }
                }
                if (value) {
                    var attribute = new mnn.Attribute(metadata, attributeName, value);
                    attributeHolders.push(attribute);
                }                
            }
        }
    }

区区50多行代码就可以完成所有 OpParamater 及其对应的属性解析。

了解 SIMD 指令

了解 SIMD 指令

SIMD 是一种常见的利用单指令完成多数据量处理的计算方式。本文作为 SIMD 文章的引子,先来了解简单的 SIMD 使用和概念。

SIMD 的含义

SIMD 的全称是 Single Instruction Multiple Data。简要来说,就是通过一条指令完成多条数据处理的行为。我们知道,虽然程序是由一条条机器指令组成,但是实际上执行一条机器码包含了多个过程,包含取指令、分析指令到执行等,如下图所示(暂时先忽略流水线并行)

而在这其中,每一个阶段,都会消耗一个或多个机器周期。如果我们认为,取指令和分析指令(译码)可以近似的认为是一个机器周期内完成,那么不同的指令,在执行阶段耗费的机器周期则大不相同。

举个例子,可能加法指令的执行阶段需要两个机器周期;而乘法可能需要5-6个机器周期。那么,当我们无法缩短指令的执行周期缩短的时候,利用 SIMD 技术,则可以在相同的执行周期内完成更多的数据处理,这样也同等的提升了单位时间内的数据吞吐,提高了计算性能。

在 Intel 的手册上,提供了包含 MMX, SSE, AVX 等系列的并行指令,面向不同长度的数据并行,比如:

  • MMX 并行计算 64bit 的数据。
  • SSE 并行计算 128bit 的数据。
  • AVX 并行计算 256bit 的数据。
  • AVX512 并行计算 512bit 的数据。

更多详细的使用可以参考:

Intel 手册

SIMD 的使用方式

由于绝大多数的人对 SIMD 还不甚了解,因此本文基于大家比较熟悉的环境 Xcode + x86/64 架构来完成。

主要是我懒,不想再翻 ARM 的手册了。

这里我们以一个简单的 256bit (32 byte) 加法改写成 SIMD 的形式来验证:

原始版本:

double input1[k] = {1, 2, 3, 4};
double input2[k] = {5, 6, 7, 8};
double result[k] = {0};

for (int i = 0; i < k; i++) {
    result[i] = input1[i] + input2[i];
}

SIMD 版本:

const int k = 4;
double input1[k] = {1, 2, 3, 4};
double input2[k] = {5, 6, 7, 8};
double result[k] = {0};

__m256d a = _mm256_load_pd(input1);
__m256d b = _mm256_load_pd(input2);

__m256d c = _mm256_add_pd(a, b);
_mm256_store_pd(result, c);

原始版本比较好懂,我们主要来深入看下 SIMD 中代码的意思:

  • _mm256_load_pd 就是从内存中读取一个地址,这个地址返回为 __m256d 的向量(256bit)。其中, __mm256d的定义为下:

    typedef double __m256d __attribute__((__vector_size__(32)));
    

    这个含义的意思就是 __m256d 的长度是 32 byte(256bit),而这个 32 byte 是按照 4 个 double 元素构成的。

  • _mm256_add_pd 就是对两个 256bit 的向量元素进行直接相加。

  • _mm256_store_pd 就是 _mm256_load_pd的逆运算,不再赘述。

注意:如果提示需要 AVX 支持的话,请在 Xcode 对应的代码文件处添加 Compiler Flag: -mavx

用 SIMD 实现求和加法

既然说了 SIMD 的本质还是为了提升单位时间内的计算吞吐量,我们还是用一个简单的例子,加法求和来实践一下:

常规的代码如下:

double CommonAdd(double *data, int count)
{
    double result = 0;

    for (int i = 0 ; i < count; i++) {
        result += data[i];
    }

    return result;
}

SIMD 的代码如下:

double AVXAdd(double *data, int count)
{
    int offset = 0;

    __m256d v1;
    __m256d sum = _mm256_setzero_pd();

    double ret = 0;

    for (int i = 0; i < count/4; i++) {
        v1 = _mm256_load_pd(data + offset);
        sum = _mm256_add_pd(sum, v1);
        offset += 4;
    }

    sum = _mm256_hadd_pd(sum, sum); // 水平求和

    ret += sum[0];
    ret += sum[2];

    return ret;
}

测试代码如下:

int main() {

    struct  timeval   start;
    struct  timeval   end;


    const int k = 512 * 512;

    const int loop = 1;

    double input1[k];

    for (int i = 0; i < k; i++) {
        input1[i] = i;
    }

    gettimeofday(&start, nullptr);

    for (int j = 0; j < loop; j++) {
        CommonAdd(input1, k);
    }

    gettimeofday(&end, nullptr);

    printf("tv_sec:%ld\n",end.tv_sec - start.tv_sec);
    printf("tv_usec:%d\n", end.tv_usec - start.tv_usec);

    std::cout << " ======================= " << std::endl;

    gettimeofday(&start, nullptr);

    for (int j = 0; j < loop; j++) {
        AVXAdd(input1, k);
    }

    gettimeofday(&end, nullptr);

    printf("tv_sec:%ld\n",end.tv_sec - start.tv_sec);
    printf("tv_usec:%d\n", end.tv_usec - start.tv_usec);

    return 0;
}

这里,我们选择了图像处理里面比较常见的 512 * 512 大小来做验证,在我的 2015款 MacBookPro 上可以得到大致如下两个性能耗时:

  • 常规方法 【774 us】
  • SIMD 【560 us】

别小看这一点的性能差距,对于大运算量的端侧深度学习可就有很显著的差距了。

后记

本文只是仅仅介绍了最常规的 SIMD 使用方式。但是在实际设计的过程中,不可能像我们这么简单的去应用。随之而来的,你会发现伴随着许多不同的坑,包含不规范的应用导致性能的下降崩溃问题。这些都会留在后面我们去解决。

浅谈移动工程师跨界机器学习之路

题记

相信从事移动开发的朋友们肯定看到过一个表情包:“iOS 开发没人要啦”。

15706761042966.jpg

虽说是搞笑之图,却也反映了移动开发领域的部分焦虑感。网上甚至有文章贴出“难上加难”的数据,称:“相比于 2017 年,2018 年 Android 程序员人均面邀数减少40%,iOS 程序员降幅更高达57%,即平均每个移动端程序员在找工作时收到的面邀数比去年减少一半。”

撇开玩笑之言,移动开发人员的焦虑感来自何处?我从自身角度及与他人沟通,大致归纳出如下几点:

  • 跨平台框架、如 Flutter 对 Native 研发模式的冲击。
  • 业界关注重点从移动时代向人工智能等领域转移。
  • 对自身掌握技术壁垒的担忧。

细细品味这三点,我想开发者在面临业界趋势转移,担忧自身竞争力不足才是焦虑产生的内在根本。我曾和几个国内知名的 iOS 开发者闲聊,他们表示:都 9102 年了,从大量公开的文章来看,大家还是局限于研究 Runtime,Runloop,block 源码分析等一些比较缺少创新的知识点,让人感受行业的停滞不前。

当然,也有不少开发者在积极拥抱新技术。身边的许多朋友也在了解机器学习,自学相关课程等。但是其中大部分都反馈:学完了基础知识,不知道如何应用;也不知道这些东西能对自己日常工作带来怎样的帮助。最终的结果就演变成了学了就忘,无法产生实质价值

那是不是事情就此陷入了僵局呢?抱着怀疑及学习的态度,我在2018年中旬加入了手淘-端智能组,参与了一款名叫 MNN 的深度推理引擎的研发工作。这一年多的开发过程,让我对加深了对机器学习 / 深度学习的理解。但更重要的是,这一年多的亲身经历,让我对过去的观点产生了颠覆式的看法。

在这里,我并不想探讨如何学习机器学习,因为这样的文章数量已经浩瀚如海;相反地,我希望通过这篇文章,阐述在开发推理引擎 MNN 的过程中,我的思考与收获;希望给许多曾和我一样迷茫的移动开发者,一些亲历的感受和信心。

节约篇幅直接贴出 MNN 的 Github 地址:https://github.com/alibaba/MNN

定义清晰的跨界目标

相信有不少同学都曾和我一样,在了解机器学习的初期被诸多的公式推导所吓退,担心这是一个充斥着算法、数学、理论证明的技术领域。

这个观点没错,如果你想要设计出经典的 MobileNet、ResNet 这样的深度神经网络或者是对 Yolo 这样的结构进行复杂度优化,如 Yolo V3 等,你势必要对数学证明、算法优化等方面有较深刻的理解,从这个角度看,说一句很残酷的话:移动工程师跨界的机会不大。

但是机器学习是不是只有算法?这个观点是偏颇的,机器学习本质上是一个工程开发、算法优化与实际应用结合的领域。

用 深度学习领域的知名大牛 贾扬青 的观点来看:AI 是一个系统工程,90%的工作在算法之外

IMG_2784.JPG

换句话说,机器学习还包含系统工程这个范畴。往小了说,模型可视化工具、转换工具;往大了讲,学术界探索机器学习的编译优化系统,比如陈天奇提出的 TVM 等等,这都是机器学习的一部分。

上图是 MNN 官方在 Netron 上维护的可视化框架,我们应该是国内第一个主动支持可视化能力的深度学习推理引擎。

因此,对于我们移动开发者来说,我们更适合从系统工程的角度,通过实际编程解决问题,去探索机器学习

备注:这个观点并不是我自己想象出来。大家可以看看机器学习泰斗级人物 Jeff Dean 和李飞飞等人在2017年发表的机器学习系统白皮书。SysML: The New Frontier of Machine Learning Systems

系统工程角度的机器学习价值

如同大家学习编程时听过的那样,算法和数据结构是核心能力,一通百通。那么从系统工程的角度来看,无论是机器学习抑或是移动开发,存在诸多共通点是可以相互借鉴。限于篇幅,我仅仅列举几点能够切实帮助我自身日常开发的:

数据自描述协议

曾有人戏言“移动开发就是 UITableView + JSON”。虽然是句玩笑话,但也能看出数据传输在移动开发中的重要性。从个人经验来看,绝大多数的移动端数据传输协议基本都采用了 JSON(可能部分公司设计了自己的数据协议)。但是 JSON 存在几个缺点(不考虑优化的前提):

  • 不内存友好,相对会带来性能瓶颈。
  • 需要人为的解析流程。
  • 不具备很好的类型解释性。

为了解决类似的问题,一些新的数据协议,如 FlatBuffer 也渐渐进入大家的视线之中。尽管之前就对其有所耳闻,但是真的深入了解还是要追溯到开发推理引擎的过程中。在设计机器学习模型存储结构中,大名鼎鼎的 TFLite,MNN 等框架都采用了 FlatBuffer,这是一种具备 Access to serialized data without parsing/unpacking 的存储结构。它不仅减少了模型的存储大小、提升了性能,也对模型结构扩展、解析自描述起到了巨大的帮助。

尤其是协议自解析方面,真是令我大开眼界。简单来说,你只要按照 FlatBuffer Schema 要求的方式定义你的数据结构,剩下的编码 / 解析的过程都自动化完成。

这里以 MNN 框架中的 FlatBuffer 的使用举例,比如整个神经网络的拓扑架构定义如下:

1
2
3
4
5
6
7
8
9
10
11
table Net {
bizCode: string;
extraTensorDescribe: [TensorDescribe];
gpulibrary: GpuLibrary;
oplists: [Op];
outputName: [string];
preferForwardType: ForwardType = CPU;
sourceType: NetSource = CAFFE;
tensorName: [string];
tensorNumber: int = 0;
}

整体 MNN 中 Schema 的设计可以参考:https://github.com/alibaba/MNN/tree/master/schema/default

然后我们通过一行简单的命令(这里仅作演示举例)就可以自动生成 JavaScript 的对应代码。

1
./flatc -s -I ~/MNN/schema/default ~/MNN/schema/default/MNN.fbs
1
/**
 * @constructor
 */
MNN.Net = function() {
  /**
   * @type {flatbuffers.ByteBuffer}
   */
  this.bb = null;

  /**
   * @type {number}
   */
  this.bb_pos = 0;
};

/**
 * @param {number} i
 * @param {flatbuffers.ByteBuffer} bb
 * @returns {MNN.Net}
 */
MNN.Net.prototype.__init = function(i, bb) {
  this.bb_pos = i;
  this.bb = bb;
  return this;
};

/**
 * @param {flatbuffers.ByteBuffer} bb
 * @param {MNN.Net=} obj
 * @returns {MNN.Net}
 */
MNN.Net.getRootAsNet = function(bb, obj) {
  return (obj || new MNN.Net).__init(bb.readInt32(bb.position()) + bb.position(), bb);
};

/**
 * @param {flatbuffers.ByteBuffer} bb
 * @param {MNN.Net=} obj
 * @returns {MNN.Net}
 */
MNN.Net.getSizePrefixedRootAsNet = function(bb, obj) {
  return (obj || new MNN.Net).__init(bb.readInt32(bb.position()) + bb.position(), bb);
};

/**
 * @param {number} index
 * @param {MNN.TensorDescribe=} obj
 * @returns {MNN.TensorDescribe}
 */
MNN.Net.prototype.extraTensorDescribe = function(index, obj) {
  var offset = this.bb.__offset(this.bb_pos, 6);
  return offset ? (obj || new MNN.TensorDescribe).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + offset) + index * 4), this.bb) : null;
};

/**
 * @returns {number}
 */
MNN.Net.prototype.extraTensorDescribeLength = function() {
  var offset = this.bb.__offset(this.bb_pos, 6);
  return offset ? this.bb.__vector_len(this.bb_pos + offset) : 0;
};

/**
 * @param {number} index
 * @param {MNN.Op=} obj
 * @returns {MNN.Op}
 */
MNN.Net.prototype.oplists = function(index, obj) {
  var offset = this.bb.__offset(this.bb_pos, 10);
  return offset ? (obj || new MNN.Op).__init(this.bb.__indirect(this.bb.__vector(this.bb_pos + offset) + index * 4), this.bb) : null;
};

而用户在代码中使用这个拓扑结构,只要简单调用入口函数 getRootAsNet ,剩下来的一切都自动化完成。而当你要修改结构定义的时候,仅仅需要修改对应的 Schema 文件,重新生成对应的解析文件,无需人工逐字段手工修改。

限于篇幅有限,这里不过多展开对 FlatBuffer 的介绍,感兴趣的读者可以阅读 MNN 用户自发写的博客《FlatBuffers,MNN模型存储结构基础 —- 无法解读MNN模型文件的秘密》。

那这样的协议能不能应用于移动开发中并起到正向的作用呢?答案是肯定的,有兴趣的朋友可以阅读 Facebook 的相关文章。

汇编知识的深度掌握

部分读者可能知道,我和几位同事在知乎上开了一个专栏《iOS调试进阶》,重点分享 ARM 相关的汇编知识。会有这个想法是因为日常工作中排查许多 Crash 的时候,从源码层面已经无法定位,必须要依赖计算机执行的本质 - 机器码进行分析,而这正是汇编可以产生价值的地方。

但是汇编不仅仅局限于排查 Crash。在开发 MNN 过程中,涉及了大量的密集型计算操作。团队的一些大牛在指令实现层面根据流水线编排、硬件大小核数、缓存大小等等,使用手写汇编来精细化调度数据的读写与执行,使得MNN 的推理性能达到了业界一流的水准(无论是我们自己的 benchmark 抑或是利益无关的友商的评测都证明了这一点)。而阅读这些精心酿造的汇编代码,会让你感到,原来开发还能这么玩!

这里展示一个经典的 Bilinear 插值通过汇编的实现:

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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
text
.align 5

asm_function MNNBilinearProcC1
//void MNNBilinearProcC1(const unsigned char *sample, unsigned char* dst, const int16_t* xFactor, const int16_t* yFactor, size_t w);

//Auto: x0:sample, x1:dst, x2:xFactor, x3:yFactor, x4:w

ld1 {v31.s}[0], [x3]
//Now x3 is no used
dup v30.4h, v31.h[0]
dup v31.4h, v31.h[1]

L8:
cmp x4, #8
blt End

LoopL8:
ld4 {v4.8b, v5.8b, v6.8b, v7.8b}, [x0], #32
ld2 {v0.8h, v1.8h}, [x2], #32//q0, q1
//(x00,x01) -> (y0)
uxtl v2.8h, v4.8b
uxtl v3.8h, v5.8b
umull v16.4s, v2.4h, v0.4h
umull2 v17.4s, v2.8h, v0.8h
umlal v16.4s, v3.4h, v1.4h
umlal2 v17.4s, v3.8h, v1.8h

uqshrn v18.4h, v16.4s, #4
uqshrn v19.4h, v17.4s, #4

//(x10,x11) -> (y1)
uxtl v2.8h, v6.8b
uxtl v3.8h, v7.8b
umull v16.4s, v2.4h, v0.4h
umull2 v17.4s, v2.8h, v0.8h
umlal v16.4s, v3.4h, v1.4h
umlal2 v17.4s, v3.8h, v1.8h

uqshrn v20.4h, v16.4s, #4
uqshrn v21.4h, v17.4s, #4

//(y0,y1) -> dst
umull v16.4s, v18.4h, v30.4h
umull v17.4s, v19.4h, v30.4h
umlal v16.4s, v20.4h, v31.4h
umlal v17.4s, v21.4h, v31.4h

uqshrn v2.4h, v16.4s, #16
uqshrn2 v2.8h, v17.4s, #16

uqrshrn v0.8b, v2.8h, #2

st1 {v0.8b}, [x1], #8


sub x4, x4, #8
cmp x4, #8
bge LoopL8

End:

相信我,当你从不懂汇编 -> 读懂汇编 -> 手写汇编,每前进一步,你会发现更广阔的天地。有一天当你要做性能优化,发现许多网上常见的手段都使用过了但仍然不起作用的时候,也许汇编就是你杀手锏。

GPU 相关知识融合

近些年来随着短视频的崛起,市面上渲染、多媒体相关的岗位也越加变得火热。而这些岗位无一例外都需要对 GPU 有着深度的了解。而操作 GPU,自然而然就少不了与 Shader 打交道。

Shader 其实就是专门用来渲染图形的一种技术。通过 Shader ,我们可以自定义显卡渲染画面的算法,使画面达到我们想要的效果。

但 Shader 的作用不仅仅作用于渲染。在机器学习领域,苹果的 Metal 框架所包含的Metal Performance Shader(MPS)也能用来做 GPU 计算,提升机器学习在移动端的执行性能。就连诞生已久的 OpenGL,也在最新的 OpenGL 3 标准中增加了计算纹理,支持 GPU 计算的能力。由此可见,尽管最初的目的并不相同,但是技术本质是相通的,最后都会产生微妙的化学反应。

上述几点,仅仅是个人抛砖引玉,展示机器学习和日常移动开发相互交织的冰山一角。从工程实现的角度,仍有许多值得探索并实践应用的,欢迎大家一起探讨交流。


与大家携手一起进步

读到这,可能有些读者内心的兴奋之情被熊熊点燃,恨不得立刻能将相关的知识学习起来;但也有部分朋友会觉得,可能只有 BAT 这样的大厂才会有实际的场景需要进行如此深入的研究和开发工作,有沮丧之情。

我对这种体会特别感同身受,因为去年刚转型开发 MNN 之初,我也有过手足无促,连简单的 Metal Performance Shader 都写不好。加上之前有些朋友通过 QCon 和云栖大会听闻了 MNN,也和我或其他同事进行过一些实现上或者应用方面的探讨。

因此,借着这个机会,除了希望通过这篇文章带领大家对【机器学习系统】有一个全新的认知之外,后续也会以连载的方式在以下两个方面给大家继续带来更多有价值的点:

  • 技术介绍,我会把 MNN 里面使用的相关技术点,逐个拆解,带领大家通过理论探索和实际编程相结合的方式来深入了解细节,反哺于大家日常的开发工作。

  • 最佳实践,目前在客户端领域应用机器学习的典型案例还比较缺乏。而我正好在过去一年多的时间里,探索了诸多的实践案例(比如大家耳熟能详的拍立淘、淘宝直播、AR试妆等等中都有 MNN 的身影哦~),我也会将其整理分享出来,和大家一起探索端智能的前行之路

对了,写了这么多文字,还请读者们见谅,允许我打个招人合作的广告吧

MNN 是阿里巴巴开源的一款轻量级、高性能深度学习推理引擎,用于解决深度神经网络模型在端侧推理运行问题。

从今年4月份开源到现在,我们始终在完善和响应社区用户的诉求,并保持着每两个月一次重大 Feature Release 的发布频率。

但我们团队的力量是有限的,而端上智能应用前进的道路仍然充满着广阔未被探索的区域,我们希望和大家一起教学相长,携手进步。

如果大家对移动端机器学习有什么好的想法和建议,也可以前往 Github 上,给我们反馈。

也欢迎对 MNN 感兴趣的朋友,扫描二维码,加入钉钉群,和我们一起交流或者直接加入我们团队哦(悄悄地说,人气有点高,一群都满啦)

用钉钉群比较方便我们在工作闲暇时间及时响应大家的问题和诉求,请大家多包涵啦~

结语

本文记录了过去一年多,个人参与 MNN 框架相关开发过程中的一些收获与心得。如何不分裂的看待机器学习与移动开发的关系,如何从看似不相关的领域寻找共同点,提升自己所处领域的价值和核心能力,是值得我们每位开发同学需要思考的。

在最后,还是要说一句:移动客户端的从业人员并不需要过多的焦虑和担忧,动态化、高性能、内核、渲染等等方向都充满前景。但是,你需要找到你所擅长且愿意为之深入的,这才是你保证在浪潮中不被拍翻的核心竞争力。

Revisit iOS Autorelease之二

Revisit iOS Autorelease(二):为啥生成的优化没有了。

Revisit iOS Autorelease(一)中,按照我的示例我提及,是如下这段代码对基于TLS的优化产生了影响:

// Debug 模式
for (Model *m in self.models) {      
}

这段看似平平无奇的代码为啥会造成优化失效,让我们还是从汇编角度来看看:

0x100011e90 <+568>: ldr    x0, [sp, #0x10]

// 【注意点】:正常情况下应该是b objc_autoreleaseReturnValue
0x100011e94 <+572>: bl     0x100012904               ; symbol stub for: objc_autoreleaseReturnValue
0x100011e98 <+576>: adrp   x8, 3
0x100011e9c <+580>: ldr    x8, [x8, #0x8]
0x100011ea0 <+584>: ldr    x8, [x8]
0x100011ea4 <+588>: ldur   x9, [x29, #-0x18]
0x100011ea8 <+592>: cmp    x8, x9
0x100011eac <+596>: str    x0, [sp, #0x8]
0x100011eb0 <+600>: b.ne   0x100011ec8               ; <+624> at Container.m
0x100011eb4 <+604>: ldr    x0, [sp, #0x8]
0x100011eb8 <+608>: ldp    x29, x30, [sp, #0x170]
0x100011ebc <+612>: ldp    x28, x27, [sp, #0x160]
0x100011ec0 <+616>: add    sp, sp, #0x180            ; =0x180 
0x100011ec4 <+620>: ret    
0x100011ec8 <+624>: bl     0x1000128d4               ; symbol stub for: __stack_chk_fail
0x100011ecc <+628>: brk    #0x1

按照符号优化的场景,LR寄存器的地址需要指向获取Model的外部调用方才能产生正确的优化,因此正常情况下应该直接b objc_autoreleaseReturnValue即可,而这里对应的汇编却是bl,说明执行完objc_autoreleaseReturnValue后还要继续从0x100011e98 <+576>: adrp x8, 3往后执行。

虽然这么一大段汇编很难具体了解做的每一件事的意义,但是从几个关键点上我们可以描绘出一个轮廓:

  • cmp x8, x9肯定在试图检查什么条件。
  • b.ne 0x100011ec8,如果条件满足,继续走(1),否则走(2)
  • (1)最后是栈恢复和ret,说明这是正确的流程。
  • (2)看到了一个比较陌生的符号__stack_chk_fail,暂且不管。但是紧跟着就是brk。而brk简单来讲,就是触发崩溃或者异常。

整体轮廓搞定后,我们再来看看stack_chk_fail到底是啥。从stack中我们不难推断,这肯定是和栈相关的检查工作。那为什么会有这样的检查工作?主要还是害怕栈越界造成的危害。用下图来大致讲解吧。

这里抄了张armv7的图,大致意思没差别。

局部变量和保存函数调用上下文的LR, FP都存在栈上。假设我们的局部变量是个大小为2的数组,但是我如果不小心写出了*(addr + 3) = 5。是不是相当于数组越界,破坏了紧邻着的其他栈内容。如果这个栈内容是重要的上下文信息,那就完蛋了。

那栈越界究竟有什么具体事例呢?嘿嘿,欢迎加入阿里巴巴来内网看我写的关于xxx问题的分析,你就知道了。

所以,在LLVM::CodeGen里面,就帮我们做了这样的栈越界检查(当然对于很多动态的数组也是没法完全防护的),在StackProtector.cpp中:

bool StackProtector::InsertStackProtectors() {
   // Loop through the basic blocks that have return instructions. Convert this:
   //
   //   return:
   //     ...
   //     ret ...
   //
   // into this:
   //
   //   return:
   //     ...
   //     %1 = load __stack_chk_guard
   //     %2 = load <stored stack guard>
   //     %3 = cmp i1 %1, %2
   //     br i1 %3, label %SP_return, label %CallStackCheckFailBlk
   //
   //   SP_return:
   //     ret ...
   //
   //   CallStackCheckFailBlk:
   //     call void @__stack_chk_fail()
   //     unreachable
   //
   BasicBlock *FailBB = 0;       // The basic block to jump to if check fails.
   AllocaInst *AI = 0;           // Place on stack that stores the stack guard.
   Constant *StackGuardVar = 0;  // The stack guard variable.

   for (Function::iterator I = F->begin(), E = F->end(); I != E; ) {
     BasicBlock *BB = I;

     if (ReturnInst *RI = dyn_cast<ReturnInst>(BB->getTerminator())) {
       if (!FailBB) {
         // Insert code into the entry block that stores the __stack_chk_guard
         // variable onto the stack.
         PointerType *PtrTy = PointerType::getUnqual(Type::Int8Ty);
         StackGuardVar = M->getOrInsertGlobal("__stack_chk_guard", PtrTy);

         BasicBlock &Entry = F->getEntryBlock();
         Instruction *InsPt = &Entry.front();

         AI = new AllocaInst(PtrTy, "StackGuardSlot", InsPt);
         LoadInst *LI = new LoadInst(StackGuardVar, "StackGuard", false, InsPt);

         Value *Args[] = { LI, AI };
         CallInst::
           Create(Intrinsic::getDeclaration(M, Intrinsic::stackprotector_create),
                  &Args[0], array_endof(Args), "", InsPt);

         // Create the basic block to jump to when the guard check fails.
         FailBB = CreateFailBB();
       }

-      Function::iterator InsPt = BB; ++InsPt; // Insertion point for new BB.
       ++I; // Skip to the next block so that we don't resplit the return block.

       // Split the basic block before the return instruction.
       BasicBlock *NewBB = BB->splitBasicBlock(RI, "SP_return");

-      // Move the newly created basic block to the point right after the old basic
-      // block so that it's in the "fall through" position.
+      // Move the newly created basic block to the point right after the old
+      // basic block so that it's in the "fall through" position.
       NewBB->removeFromParent();
-      F->getBasicBlockList().insert(InsPt, NewBB);
+      F->getBasicBlockList().insert(I, NewBB);

       // Generate the stack protector instructions in the old basic block.
       LoadInst *LI1 = new LoadInst(StackGuardVar, "", false, BB);
       CallInst *CI = CallInst::
         Create(Intrinsic::getDeclaration(M, Intrinsic::stackprotector_check),
                AI, "", BB);
       ICmpInst *Cmp = new ICmpInst(CmpInst::ICMP_EQ, CI, LI1, "", BB);
       BranchInst::Create(NewBB, FailBB, Cmp, BB);
     } else {
       ++I;
     }
   }

   // Return if we didn't modify any basic blocks. I.e., there are no return
   // statements in the function.
   if (!FailBB) return false;

   return true;
 }

 /// CreateFailBB - Create a basic block to jump to when the stack protector
 /// check fails.
 BasicBlock *StackProtector::CreateFailBB() {
   BasicBlock *FailBB = BasicBlock::Create("CallStackCheckFailBlk", F);
   Constant *StackChkFail =
     M->getOrInsertFunction("__stack_chk_fail", Type::VoidTy, NULL);
   CallInst::Create(StackChkFail, "", FailBB);
   new UnreachableInst(FailBB);
   return FailBB;
 }

还是比较容易看懂的,这里就不过多解释了。

后记

autorelease相关的文章网上不在少数,但是大多数都大同小异,只是在讲libobjc中的代码实现。但是深究我们日常编码过程中的autorelease,其实有不少被我们所忽视的细节值得深挖研究。(不挖还容易踩坑)

最后按照惯例,以一首诗致敬伟大的90后iOS第一人Y帝:

吾辈有Y帝,技术特牛逼。

胸有中国情,一人虐美帝。

Google服务器,Y帝轻松逆。

苹果App,他天天Patch。

微软的程序,总被他蓝屏。

川普各手机,监听so easy。

为躲粉丝迷,转行写程序。

90后第一,当代方世玉!

Revisit iOS Autorelease 之不经意间可能被影响的优化

本文的硬核在第二段

之前在做某项目的时候,自建了基于NSThread的私有线程池,在线程池分配了固定个数的常驻工作线程,在工作线程里面运行相关任务;这个方案取代了原先直接无脑使用GCD的方式,在各方面效果都还不错。

但是在一次偶然的情况下,通过Memory Graph发现很多任务对象却在本该早就销毁的时候仍然存活着。持有其的对象是autorelease content,如下图所示:

屏幕快照 2019-06-20 下午8.22.39

我把数据对象类型隐藏了,公司数据还是要保密。

那这个东西究竟是个啥呢?

由于其是黄色图标,基本上是一个容器类型或其子类。

这个类型@autoreleasepool content先不管,先从右边的堆栈来看:

  • 某个方法调用了autorelease相关的API
  • 由于我是在子线程触发的,没有显示创建的autoreleasepool。因此需要调用autoreleaseNoPage

autoreleaseNoPage其实本质上就是在当前线程没有autoreleasePage的时候,创建一个。然后通过Thread Local Storage存入线程相关上下文中。

static __attribute__((noinline))
    id *autoreleaseNoPage(id obj)
    {
        // "No page" could mean no pool has been pushed
        // or an empty placeholder pool has been pushed and has no contents yet
        assert(!hotPage());

        bool pushExtraBoundary = false;
        if (haveEmptyPoolPlaceholder()) {
            // We are pushing a second pool over the empty placeholder pool
            // or pushing the first object into the empty placeholder pool.
            // Before doing that, push a pool boundary on behalf of the pool 
            // that is currently represented by the empty placeholder.
            pushExtraBoundary = true;
        }
        else if (obj != POOL_BOUNDARY  &&  DebugMissingPools) {
            // We are pushing an object with no pool in place, 
            // and no-pool debugging was requested by environment.
            _objc_inform("MISSING POOLS: (%p) Object %p of class %s "
                         "autoreleased with no pool in place - "
                         "just leaking - break on "
                         "objc_autoreleaseNoPool() to debug", 
                         pthread_self(), (void*)obj, object_getClassName(obj));
            objc_autoreleaseNoPool(obj);
            return nil;
        }
        else if (obj == POOL_BOUNDARY  &&  !DebugPoolAllocation) {
            // We are pushing a pool with no pool in place,
            // and alloc-per-pool debugging was not requested.
            // Install and return the empty pool placeholder.
            return setEmptyPoolPlaceholder();
        }

        // We are pushing an object or a non-placeholder'd pool.

        // Install the first page.
        AutoreleasePoolPage *page = new AutoreleasePoolPage(nil);
        setHotPage(page);

        // Push a boundary on behalf of the previously-placeholder'd pool.
        if (pushExtraBoundary) {
            page->add(POOL_BOUNDARY);
        }

        // Push the requested object or pool.
        return page->add(obj);
    }

同时由于是第一个page,连父子关系都不用串联,非常简单。

但是,其他线程有一点相对主线程比较坑的就是子线程默认没有runloop,导致在释放被autoreleasepool的对象的时候产生着问题。

那么子线程的autoreleasepool在没有runloop的情况下何时释放呢?

  • 线程退出的时候
  • 局部autoreleasepool drain的时候

第二点比较好理解,就是常规的page push以及对应的page pop

那么线程退出释放是如何确定的呢?我们在线程退出的时候下个断点:

static void tls_dealloc(void *p) 
{
    if (p == (void*)EMPTY_POOL_PLACEHOLDER) {
        // No objects or pool pages to clean up here.
        return;
    }

    // reinstate TLS value while we work
    setHotPage((AutoreleasePoolPage *)p);

    if (AutoreleasePoolPage *page = coldPage()) {
        if (!page->empty()) pop(page->begin());  // pop all of the pools
        if (DebugMissingPools || DebugPoolAllocation) {
            // pop() killed the pages already
        } else {
            page->kill();  // free all of the pages
        }
    }

    // clear TLS value so TLS destruction doesn't loop
    setHotPage(nil);
}

而在runtime初始化的过程中,会调用AutoReleasePoolPage::init方法注册tls_dealloc

static void init()
{
    int r __unused = pthread_key_init_np(AutoreleasePoolPage::key, 
                                         AutoreleasePoolPage::tls_dealloc);
    assert(r == 0);
}

结合这两段代码,我们大致可以猜测下phtread_key_init_np是将tls_dealloc注册给某个回调使用。那具体是干嘛的?

实际上phtread_key_init_np时给thread注册了线程销毁时的自定义析构函数,这里我们可以一起来看看darwin-applelibpthread代码,这里我直接简化掉流程,输出大致的过程:

_pthread_exit 在线程销毁时调用 -> _pthread_tsd_cleanup -> _pthread_tsd_cleanup_new -> _pthread_tsd_cleanup_key

在最终的函数里,会遍历所有的自定义销毁函数,逐个触发:

static void
_pthread_tsd_cleanup_key(pthread_t self, pthread_key_t key)
{
    void (*destructor)(void *);
    if (_pthread_key_get_destructor(key, &destructor)) {
        void **ptr = &self->tsd[key];
        void *value = *ptr;
        if (value) {
            *ptr = NULL;
            if (destructor) {
                destructor(value);
            }
        }
    }
}

因此,对于我这样设计了常驻线程的“不死线程”来说,无法指望线程销毁时候的释放,必须自己引入autoreleasepool来修正内存没释放干净!

到底哪些东西会触发autorelease持有?

本文的重点来了。

其实网上关于autoreleasepage相关的文章分析的很多了,我这篇文章的主要目的还是想思考下,看看平常无奇的代码,究竟会在什么情况下触发autorelease及其相关行为。如果说所有的东西都是直接了当的引用计数相加减如objc_storeStrong / objc_storeStrong(nil),何须多此一举引入autorelease呢?

网上许多的文章的结论基本上都是:

编译器为判断方法名是否是以alloc/new/copy/mutableCopy开头,如果不是,就自动将返回的对象注册到池子中。
编译器会在objc_autoreleaseReturnValueobjc_retainAutoreleasedReturnValue进行基于TLS的判断优化,本质上也不会走入autorelease的环节。

为了验证这些结论,我首先重温了下《iOS 内存高级编程》一书,它所阐述的都是内存管理的思想,以alloc/mew/copy/mutableCopy驼峰命名开头的方法,方法的对象由调用者自己持有;而其他方法是取得非自己生成并持有的对象

卧槽,真拗口。

写个Demo验证下,

@interface Model : NSObject
- (Model *)haha;
@end

- (Model *)haha
{
    // 这里是为了避免调用系统库的对象可能在存在某些MRC的情况导致无法优化。
    return [[Model alloc] init];
}

// 调用
Model *model = [[Model alloc] init];
Model *m2 = [model haha];

万变不如汇编,让我们先来看第一条调用的汇编代码。

0x100046768 <+96>:  bl     0x100046b80               ; symbol stub for: objc_msgSend
0x10004676c <+100>: adrp   x8, 2
0x100046770 <+104>: add    x8, x8, #0xd38            ; =0xd38 
0x100046774 <+108>: ldr    x1, [x8]
0x100046778 <+112>: bl     0x100046b80               ; symbol stub for: objc_msgSend
0x10004677c <+116>: mov    x8, #0x0
0x100046780 <+120>: add    x9, sp, #0x8              ; =0x8 
0x100046784 <+124>: str    x0, [sp, #0x8]
0x100046788 <+128>: mov    x0, x9
0x10004678c <+132>: mov    x1, x8
0x100046790 <+136>: bl     0x100046bb0               ; symbol stub for: objc_storeStrong

很明显的,并没有涉及到任何的和retainAutorelease/autorelease相关的调用。

需要注意:在release优化下这里的objc_storeStrong(nil)会直接优化成objc_release

而对于第二条调用,汇编如下:

0x10006275c <+140>: bl     0x100062b80               ; symbol stub for: objc_msgSend
0x100062760 <+144>: mov    x29, x29
0x100062764 <+148>: bl     0x100062ba4               ; symbol stub for: objc_retainAutoreleasedReturnValue

关键字出现了,当然具体会不会进入autorelease的环节,还需要看优化的效果,我们进入haha函数看一看:

 0x100096b28 <+36>: bl     0x100096b80               ; symbol stub for: objc_msgSend
0x100096b2c <+40>: adrp   x1, 2
0x100096b30 <+44>: ldr    x1, [x1, #0xd38]
0x100096b34 <+48>: bl     0x100096b80               ; symbol stub for: objc_msgSend
0x100096b38 <+52>: ldp    x29, x30, [sp, #0x10]
0x100096b3c <+56>: add    sp, sp, #0x20             ; =0x20 
0x100096b40 <+60>: b      0x100096b74               ; symbol stub for: objc_autoreleaseReturnValue

也和我们预测的一样,确实有着objc_autoreleaseReturnValue,那么究竟会不会有基于TLS的优化行为呢?对objc_autoreleaseReturnValue下个符号断点:

libobjc.A.dylib`objc_autoreleaseReturnValue:
->  0x18563e528 <+0>:  ldr    w8, [x30]
    0x18563e52c <+4>:  mov    w9, #-0x55e30000
    0x18563e530 <+8>:  movk   w9, #0x3fd
    0x18563e534 <+12>: cmp    w8, w9
    0x18563e538 <+16>: b.ne   0x18563e550               ; <+40>
    0x18563e53c <+20>: mrs    x8, TPIDRRO_EL0
    0x18563e540 <+24>: and    x8, x8, #0xfffffffffffffff8
    0x18563e544 <+28>: orr    w9, wzr, #0x1
    0x18563e548 <+32>: str    x9, [x8, #0x160]
    0x18563e54c <+36>: ret    
    0x18563e550 <+40>: b      0x18563c130               ; objc_autorelease

这里,偏移 +16的地方的b.ne就是对优化的判断,判断的条件是w8w9的相等与否,不等就走传统的objc_autorelease

这里经过断点我们发现确实走了优化。

那按照这个思路,难道真的在如今的ARC下,没有东西要进autoreleasepool了?那为什么还会在MemoryGraph中出现大量autorelease content呢?

答案可能出乎你的意料,for会影响这个autorelease优化逻辑。

我们构建一个两个线程的场景,Model类型如上述文章段落不变。构建一个符合类型Container,包含一个NSMutableArray的数组:

@interface Container()
@property (nonatomic, strong) NSMutableArray *models;
@end

@implementation Container

- (instancetype)init
{
    self = [super init];
    if (self) {
        _models = @[].mutableCopy;

    }
    return self;
}

- (void)addModel:(Model *)model
{
    if (!model) return;
    [self.models addObject:model];
}

- (Model *)takeModel
{
    //NSMutableArray *toOperateArray = self.models;
    for (Model *model in self.models) {
    }

    Model *model = [self.models firstObject];
    [self.models removeObject:model];

    return model;
}

为了简化场景,我先在ViewController viewDidLoad塞入几个ModelContainer,然后再启动第二个线程从Container中取Model

测试场景如下:

- (void)viewDidLoad {
    [super viewDidLoad];

    self.container = [[Container alloc] init];

    for (int i = 0; i < 10; i++) {
        [self.container addModel:[[Model alloc] initWithCount:i]];
    }

    self.thread = [[NSThread alloc] initWithTarget:self selector:@selector(loop) object:nil];
    [self.thread setName:@"com.walle.test"];
    [self.thread start];
}

- (void)loop
{
    while (true) {
        Model *m = [self.container takeModel];        
        [m increment];
    }
}

如果你执行我这段代码,你会发现的确如文章一开头所示,MemoryGraph中存在大量被@autoreleasepool content持有的Model

那罪魁祸首是什么呢?从表象上看是这段并不起眼的代码:

for (Model *model in self.models) {
}

可具体原因是为啥呢?还是从汇编上来摸索下:

首先先回到没有汇编的场景上,调用的函数是-[ViewController loop],被调用者是-[Container takeModel:]

如果要进行优化,按照objc_autoreleaseReturnValue:的逻辑,在loop调用takeModel:的地方必须有对应的暗示:这个暗示在arm64中如下代码所示:

static ALWAYS_INLINE bool 
callerAcceptsOptimizedReturn(const void *ra)
{
    // fd 03 1d aa    mov fp, fp
    // arm64 instructions are well-aligned
    if (*(uint32_t *)ra == 0xaa1d03fd) {
        return true;
    }
    return false;
}

简单来说,要有mov fp, fp,而fp就是x29寄存器。那我们来看看loop的对应汇编:

屏幕快照 2019-07-01 下午1.15.11

哈哈,0x62b0的地方果然是mov x29, x29

如果你对静态分析的结果不熟悉,可以动态进入汇编。在obc_autoreleaseReturnValue下符号断点,得到

屏幕快照 2019-07-01 下午1.16.57

如果你输出x30寄存器的值(注意不是把寄存器的值当地址再取值)然后再减去所在二进制的基地址,会发现偏移正正好好也是0x62b0

而如果你加上之前提到的for循环代码,再断到obc_autoreleaseReturnValue去查看x30的值,计算偏移量会得到:0x0000000000005e98

而对应到二进制里是:

屏幕快照 2019-07-01 下午1.46.10

看到没,这里调用objc_autoreleaseReturnValue走的是bl,也就是会修改LR寄存器,而LR寄存器的值就是调用后的返回地址5e98。而LR寄存器本身就是x30,导致autorelease的优化失效。

至此,我们终于发现了为什么我们的数据会被所谓的@autoreleasepool content持有。

后记

虽然正如网上很多文章所述,子线程确实会对autoreleasepool进行自动的管理避免内存泄漏。但是,由于诸多场景导致的释放时机变更,会产生诸多的内存不释放(并非是内存泄漏,Leaks是查不出来的),也会对App的稳定性造成巨大的影响。

更重要的是,基于这种TLS的优化很有可能被我们不知情下编写的代码所改变,产生奇怪的问题,因此要特别注意。

下文我会从编译以及代码生成的层面来探讨为什么会产生这种不同的汇编代码。

基于智能指针和RAII的对象内存管理设计

从C++ std::shared_ptr 原理来看看栈溢出的危害,我提及了C++的智能指针指向被管理对象的raw ptr会被栈内存溢出而破坏,而利用智能指针进行对象构造的管理和设计,可以衍生出和RAII的结合,今天就来谈谈这项技术。

什么是RAII?

RAIIResource Acquisition Is Initialization,简而言之就是将对一个资源的申请封装在一个对象的生命周期内的理念。这样做的好处就是,C++的对象势必在创建的时候会经过构造函数,而在销毁的时候会触发析构函数。

听起来有点绕是不是,让我们来简化一下其主要特点。

  • 所有的资源管理内聚在对象内部
  • 利用对象申请/释放的特性对资源同步进行对应的申请/释放
  • 自动管理对象

前两点都比较容易,那么第三点如何达到呢?

合理的利用局部变量。

绝大多数语言,比如C++,都居于块级作用域。当在创建的变量离开其所在的块级时候,就会触发释放。而这就可以达到我们所说的自动管理对象。

这其实就是压栈/出栈的高级语言表现。

而在C++领域,有一个比较经典的利用RAII特性的设计就是ScopeLock

template<class LockType>

class My_scope_lock
{

   public:

   My_scope_lock(LockType& _lock):m_lock(_lock)

   {

         m_lock.occupy();

    }

   ~My_scope_lock()

   {

        m_lock.relase();

   }

   protected:

   LockType    m_lock;
}

在这里,锁被看成是一种资源,他需要lock/unlock的配对操作,不然就会引发问题。

而上述代码,将锁保留在对象的构造函数和西沟函数中。这样,当我们在某个函数中需要操作临界区域的时候,就可以简洁明了的使用局部变量来操作锁:

void Data::Update()
{
     My_scope_lock l_lock(m_mutex_lock);
    // do some operation
}

基于智能指针的RAII

上文我们用锁的例子来举例说明了RAII的设计理念,那什么又是基于智能指针的RAII呢?

我们都知道,在编程过程中,我们必须和内存打交道,而内存分为了两种类型:栈上内存和堆上内存。栈上内存不仅和线程相关,同时空间大小也相对堆内存来说非常小。因此,当我们在处理一些大规模数据(以及对象规模不确定)的时候,比如使用几百个对象的数据等等,一般都采用堆上动态分配内存。

但是堆上内存,在诸多的语言中,都需要手动管理,比如C++。而一般处理不当,比如(new []和delete搭配),或者遗忘了释放,那么就会产生内存泄漏等严重问题。

为此,我们参考上节的设计,准备构建一个可以在对象的构造/析构函数中成对正确释放内存的设计思路。

先假设一个需要在堆上频发操作的对象Data

class Data {
    // 省略
}

如果直接使用,一般情况下是这样的代码:

Data *data = new Data();
delete data;

需要频繁的确认对堆内存的正确使用。现在我们给他加一个包装对象,DataHandle

class DataHandle {
    private:
        Data *m_data;
}

DataHandle::DataHandle():m_data(new Data())
{}

DataHandle::~DataHandle()
{
    delete m_data;
    m_data = NULL;
}

这样,我们后续每次使用,就可以简化成

{
    DataHandle handle;
}

但是,别忘记了,C++中海油拷贝构造和重载赋值等操作,一旦我们写出如下代码,就会引发double free的问题。

{
    DataHandle handle1(handle);
    handle1 = handle;
}

因此,我们需要对拷贝构造函数和重载赋值进行特别处理。这里有两种处理方式:

  • 对于拷贝/赋值,每次把内部指针m_data也拷贝new一次。
  • 对于m_data进行合理的计数记录。

一般情况下,我们期望DataHandle的行为和Data是一致的。 因此我们想使用第二种方式。

这个时候,C++shared_ptr就派上用场了。改写下DataHanle

class DataHandle{
    public:
        DataHandle();
        DataHandle(const DataHandle &handle);
        DataHandle& operator=(const DataHanlde &handle);
    private:
        std::shared_ptr<Data> m_dataS;
}

对于重载后的拷贝/复制函数,我们只要利用智能指针自身重载过的赋值操作赋,即可解决引用计数问题。

最后要特别注意的是,下述两种情况的代码,是完全不相同的含义。

// 第一种情况
Data *data = new Data();

std::shared_ptr<Data> s1 = std::shared_ptr(data);

std::shared_ptr<Data> s2 = s1;

// 第二种情况

Data *data = new Data();

std::shared_ptr<Data> s1 = std::shared_ptr(data);

std::shared_ptr<Data> s2 = std::shared_ptr(data);

LD-2

了解苹果的LD

其实文章关于传统Section Based Linker那块我还没怎么读懂,有兴趣的欢迎互相探讨。

之前研究Xcode 10兼容libstdc++的时候,稍微把玩了下苹果的LD,借这个机会正好通读了下苹果的LD设计,本文做一下总结。

Atom & FixUp

苹果的LD,核心理念就是基于AtomFixUp,拿着两个术语是啥意思呢?

  • Atom就是一块代码(函数)或者数据(全局变量)之类的,每个Atom都有一些属性,比如名称、作用域、内容类型、字节对齐之类的。
  • Fixup可以理解为一个包含种类、便宜、辅助加数以及目标Atom的数据结构。

有点抽象对吧?概要来说,苹果LD通过AtomFixUP构建一张图,图中的节点都是Atom,连接Atom的则是FixUP

通过构建这样一张图,苹果就可以在链接期间进行一系列的优化,比如死代码剔除,怎么做呢?比如一段代码也会被抽象成Atom,如果没有FixUP连接的Atom就可以进行剔除

举一个简单的小例子main.c

#include <stdio.h>

int main()
{
    printf("hello world");
    return 0;
}

会被抽象出如下行为:

单独编译这个.c文件生成的.o会包含两个atom,一个是main函数,另外一个是C字符串"hello world"

printf 本质上也会一个atom,但是在这个编译单元内他还没加入图中。

fixup也存在两个,一个是去调用不知道在哪的函数printf的调用fixup,一个是去加载字符串的fixup

链接过程

  • 链接过程的第一步就是要处理输入文件,构建一张初始图。

    • 如果输入文件是.o,那么所有的atom都会被加入到初始图当中。
    • 如果输入文件是静态库(静态库基本上就是一组.o文件包含一个目录),初始状态下这里面的atom都默认不会加入到里面,当LD不断初始图中有没被决议的fixup,如果fixup对应的目标atom在这个静态库里面的话,就会把找到的atom的加入到图内。
    • 动态库其实在链接期间不会添加任何的atom,同静态库一样,如果有没被决议的fixup对应的atom在动态库内找到(比如tbd声明的那些),就就提供一个代理,这个代理标记了这个符号来自哪个动态库

本质上来说,链接期间动态库的作用就是参与标记一下。

  • 考虑完符号决议,还要考虑符号合并之类的,比如根据字符串表的设计,来自不同文件的相同字符串,比如"haha",不可能保留两份,需要合并。此外还有诸如C中的tentative definitionsC++ Weak Symbol

  • 处理fixup的时候,也需要分几种类型,见下图:

其他

虽然苹果的LD已经抽象成了Atom-FixUP的架构,但是它的可执行文件Mach-O还是传统的基于section的结构,这限制了Atom-FixUP的能力。

从C++ std::shared_ptr 原理来看看栈溢出的危害

C++ std::shared_ptr 实现原理

上周五排查了一个由于XXX模块操作疏忽导致栈越界引发的我的模块的智能指针Crash问题,因此稍微研究了一下,以作参考:

shared_ptr共享被管理对象,同一时刻可以有多个shared_ptr拥有对象的所有权,当最后一个shared_ptr对象销毁时,被管理对象自动销毁

shared_ptr 实现

简单来说,shared_ptr实现包含了两部分,

  • 一个指向堆上创建的对象的裸指针,raw_ptr
  • 一个指向内部隐藏的、共享的管理对象。share_count_object

第一部分没什么好说的,第二部分是需要关注的重点:

  • use_count,当前这个堆上对象被多少对象引用了,简单来说就是引用计数。
  • weak_count,这个管理对象被多少个智能指针共享了,简单来说就是管理对象的引用计数。

不同指针创建的对同一个堆上对象的智能管理,并不共享管理对象,因此存在double free的可能性

_shared_ptr直接包含的裸指针,即raw prt,是为了实现一般指针的->,*等操作,通过__shared_count object间接包含的指针是为了管理对象的生命周期,回收相关资源。

换句话说,__shared_count object内部的use_count主要用来标记被管理对象的生命周期,weak_count主要用来标记管理对象的生命周期。

注意区分管理和被管理。

问题原因

了解了原理,可以看出std::share_ptr本身的raw ptr指向了堆上通过new创建的对象。但是其自身这个raw ptr却会如果在不当操作上,被修改,比如栈越界操作,就会被破坏,导致产生对非法对象的访问:

int i = 5;
NSLog(@"i address is %p", &i);

XXX::XX df = XXX::XX::buildDataFrame();
NSLog(@"sp0 address is %p", &df);

int a[1] = {1};

NSLog(@"k address is %p", a);

for (int i = 0; i < 10; i++) {
    a[i] = i;
}

NSLog(@"haha");

上述这段代码就会引发问题,这里XXX::XX的具体内部设计使用了经典的RAII,下文再表。

抛开性能,谈谈不该用@Synchronized的原因

关于Objective-C中的@Synchronized,想必从事iOS开发相关工作的同学都不陌生,可以说这是一种最简单的加锁的方式了。

网上关于锁对比的文章也不在少数,太多说集中在用法概述以及性能对比。而@Synchronized在不少文章中常常因其性能而被建议不要使用。

本质上来说,在客户端场景下,高密度使用锁的场景是相对较少(比如IM数据库除外);同时,抛开使用场景单独通过比如for循环测试锁的性能,也是比较蛋疼的,不合适的用法、过大的锁范围以及竞态条件,都会导致比较条件的欠考虑性。

因此,今天我想谈谈一个不应该使用@Synchronized的本质原因:它是一个和上下文强相关的锁,会导致锁失效。

一个简单的事例

考虑一个场景:

我们后台静默更新一下数据,一旦有了新数据,就整体替换掉现在呈现的数据,这在列表页配合远程数据的时候非常常见。

为了放大多线程可能出错的场景,我放大到5000个线程,构造如下代码:

@interface ViewController ()
@property (nonatomic, strong) NSMutableArray *testArray;
@end

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];

    self.testArray = @[].mutableCopy;

    for (NSUInteger i = 0; i < 5000; i++) {
        dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
            [self testThreadArray];
        });
    }
}

- (void)testThreadArray
{
    @synchronized (self.testArray) {
        self.testArray = @[].mutableCopy;
    }
}

可以看出,为了避免多个线程同时更新临界资源testArray,我们使用 @synchronized (self.testArray) 进行了资源保护。

备注:为什么需要保护这里的赋值操作,可以阅读我的从Immutable来谈谈对于线程安全的理解误区

看起来一切都很Ok,但是当你实际运行代码,还是会出现野指针Crash。如下图所示:

屏幕快照 2018-12-27 下午2.02.28.png

这里用@Synchronized(self)是可以成功锁住的,但是这会陷入到锁的范围太大的场景中去,不再此文探讨的范围内。

Crash的根因

@Synchronized会变成一对基于try-catchobjc_sync_enterobjc_sync_exit的代码,想必都不陌生了,许多网上文章都有,不再赘述,可以参考clang的代码:

https://clang.llvm.org/doxygen/RewriteObjC_8cpp_source.html

/// RewriteObjCSynchronizedStmt -
 /// This routine rewrites @synchronized(expr) stmt;
 /// into:
 /// objc_sync_enter(expr);
 /// @try stmt @finally { objc_sync_exit(expr); }
 ///
 Stmt *RewriteObjC::RewriteObjCSynchronizedStmt(ObjCAtSynchronizedStmt *S) {
   // Get the start location and compute the semi location.
   SourceLocation startLoc = S->getBeginLoc();
   const char *startBuf = SM->getCharacterData(startLoc);

   assert((*startBuf == '@') && "bogus @synchronized location");

   std::string buf;
   buf = "objc_sync_enter((id)";
   const char *lparenBuf = startBuf;
   while (*lparenBuf != '(') lparenBuf++;
   ReplaceText(startLoc, lparenBuf-startBuf+1, buf);
   // We can't use S->getSynchExpr()->getEndLoc() to find the end location, since
   // the sync expression is typically a message expression that's already
   // been rewritten! (which implies the SourceLocation's are invalid).
   SourceLocation endLoc = S->getSynchBody()->getBeginLoc();
   const char *endBuf = SM->getCharacterData(endLoc);
   while (*endBuf != ')') endBuf--;
   SourceLocation rparenLoc = startLoc.getLocWithOffset(endBuf-startBuf);
   buf = ");\n";
   // declare a new scope with two variables, _stack and _rethrow.
   buf += "/* @try scope begin */ \n{ struct _objc_exception_data {\n";
   buf += "int buf[18/*32-bit i386*/];\n";
   buf += "char *pointers[4];} _stack;\n";
   buf += "id volatile _rethrow = 0;\n";
   buf += "objc_exception_try_enter(&_stack);\n";
   buf += "if (!_setjmp(_stack.buf)) /* @try block continue */\n";
   ReplaceText(rparenLoc, 1, buf);
   startLoc = S->getSynchBody()->getEndLoc();
   startBuf = SM->getCharacterData(startLoc);

   assert((*startBuf == '}') && "bogus @synchronized block");
   SourceLocation lastCurlyLoc = startLoc;
   buf = "}\nelse {\n";
   buf += "  _rethrow = objc_exception_extract(&_stack);\n";
   buf += "}\n";
   buf += "{ /* implicit finally clause */\n";
   buf += "  if (!_rethrow) objc_exception_try_exit(&_stack);\n";

   std::string syncBuf;
   syncBuf += " objc_sync_exit(";

   Expr *syncExpr = S->getSynchExpr();
   CastKind CK = syncExpr->getType()->isObjCObjectPointerType()
                   ? CK_BitCast :
                 syncExpr->getType()->isBlockPointerType()
                   ? CK_BlockPointerToObjCPointerCast
                   : CK_CPointerToObjCPointerCast;
   syncExpr = NoTypeInfoCStyleCastExpr(Context, Context->getObjCIdType(),
                                       CK, syncExpr);
   std::string syncExprBufS;
   llvm::raw_string_ostream syncExprBuf(syncExprBufS);
   assert(syncExpr != nullptr && "Expected non-null Expr");
   syncExpr->printPretty(syncExprBuf, nullptr, PrintingPolicy(LangOpts));
   syncBuf += syncExprBuf.str();
   syncBuf += ");";

   buf += syncBuf;
   buf += "\n  if (_rethrow) objc_exception_throw(_rethrow);\n";
   buf += "}\n";
   buf += "}";

   ReplaceText(lastCurlyLoc, 1, buf);

   bool hasReturns = false;
   HasReturnStmts(S->getSynchBody(), hasReturns);
   if (hasReturns)
     RewriteSyncReturnStmts(S->getSynchBody(), syncBuf);

   return nullptr;
 }

卧槽,原来clang的rewrite部分也写的这么挫逼啊。

我们就从objc_sync_enter来继续挖掘:

if (obj) {
    SyncData* data = id2data(obj, ACQUIRE);
    assert(data);
    data->mutex.lock();
}

关键其实就是在于从obj转换到SyncData,然后通过SyncData中的mutex来进行临界区的锁。

有两个部分需要分析一下,首先SyncData结构体定义如下:

typedef struct alignas(CacheLineSize) SyncData {
    struct SyncData* nextData;
    DisguisedPtr<objc_object> object;
    int32_t threadCount;  // number of THREADS using this block
    recursive_mutex_t mutex;
} SyncData;
  • mutex,一把递归锁,这也是为什么我们可以在@Synchronized里面嵌套@Synchronized的原因。
  • DisguisedPtr,还记得我们以前写安全气垫的时候给一些释放的内存地址填充0x55用于拦截use after free的场景?这里DisguisedPtr其实就是对裸对象指针objc_object的一层包装改写。

继续回到id2data函数往下研究,可以发现一段比较有意思的函数:

static StripedMap<SyncList> sDataLists;

我们具体就关注[]对应的操作即可:

class StripedMap {
#if TARGET_OS_IPHONE && !TARGET_OS_SIMULATOR
    enum { StripeCount = 8 };
#else
    enum { StripeCount = 64 };
#endif

    struct PaddedT {
        T value alignas(CacheLineSize);
    };

    PaddedT array[StripeCount];

    static unsigned int indexForPointer(const void *p) {
        uintptr_t addr = reinterpret_cast<uintptr_t>(p);
        return ((addr >> 4) ^ (addr >> 9)) % StripeCount;
    }

 public:
    T& operator[] (const void *p) { 
        return array[indexForPointer(p)].value; 
    }
    const T& operator[] (const void *p) const { 
        return const_cast<StripedMap<T>>(this)[p]; 
    }

抽丝剥茧,这里其实就是一个简单的Hash算法,然后将传入的对象地址,通过indexForPointer映射到不同的SyncList上。而SyncList是一个维护SyncData的链表,每个SyncList都单独维护操作自己的lock

indexForPointer公式:((addr >> 4) ^ (addr >> 9)) % StripeCount,其中StripeCount是个数。

这样做的好处就是创建了一个所谓的散列锁,可以有效的降低不同的对象操作指尖的相互影响性。当然,从本质上看,iOS上就8个散列锁,这也是影响大规模使用@Synchronized会影响性能的原因之一。

接着往下走,我们直接关注没有命中Thread Local Storage的场景

#define LOCK_FOR_OBJ(obj) sDataLists[obj].lock
spinlock_t *lockp = &LOCK_FOR_OBJ(object);

// 通过对对象地址hash,算法对应SyncList的锁

lockp->lock();

{
    SyncData* p;
    SyncData* firstUnused = NULL;
    for (p = *listp; p != NULL; p = p->nextData) {
        if ( p->object == object ) {
            result = p;
            // atomic because may collide with concurrent RELEASE
            OSAtomicIncrement32Barrier(&result->threadCount);
            goto done;
        }
        if ( (firstUnused == NULL) && (p->threadCount == 0) )
            firstUnused = p;
    }

    // no SyncData currently associated with object
    if ( (why == RELEASE) || (why == CHECK) )
        goto done;

    // an unused one was found, use it
    // 关注点1 !!!!!!!!!!!!
    if ( firstUnused != NULL ) {
        result = firstUnused;
        result->object = (objc_object *)object;
        result->threadCount = 1;
        goto done;
    }
}

// Allocate a new SyncData and add to list.
// XXX allocating memory with a global lock held is bad practice,
// might be worth releasing the lock, allocating, and searching again.
// But since we never free these guys we won't be stuck in allocation very often.

// 关注点2 !!!!!!!!!!!!
posix_memalign((void **)&result, alignof(SyncData), sizeof(SyncData));
result->object = (objc_object *)object;
result->threadCount = 1;
new (&result->mutex) recursive_mutex_t(fork_unsafe_lock);
result->nextData = *listp;
*listp = result;

 done:
lockp->unlock();
if (result) {
    // Only new ACQUIRE should get here.
    // All RELEASE and CHECK and recursive ACQUIRE are 
    // handled by the per-thread caches above.
    if (why == RELEASE) {
        // Probably some thread is incorrectly exiting 
        // while the object is held by another thread.
        return nil;
    }
    if (why != ACQUIRE) _objc_fatal("id2data is buggy");
    if (result->object != object) _objc_fatal("id2data is buggy");

      // 关注点3
#if SUPPORT_DIRECT_THREAD_KEYS
    if (!fastCacheOccupied) {
        // Save in fast thread cache
        tls_set_direct(SYNC_DATA_DIRECT_KEY, result);
        tls_set_direct(SYNC_COUNT_DIRECT_KEY, (void*)1);
    } else 
#endif
    {
        // Save in thread cache
        if (!cache) cache = fetch_cache(YES);
        cache->list[cache->used].data = result;
        cache->list[cache->used].lockCount = 1;
        cache->used++;
    }
}

return result;
    1. 通过散列,计算这个对象应该落入的SyncList,由于需要操作SyncList,用其对应的锁进行加锁。
    1. 关注点1和2,其实本质干的是一件事,就是找出一个可以被使用的SyncData,如果没有就创建一个,设定好对应的成员变量,然后返回。
    1. 关注点3,就是做完了以后,利用一下Thread Local Storage,存一下,这块不关注无伤大雅。

Ok,到现在我们分析完成@Synchronized的实现原理后,我们可以回过头再来看看为什么对象被更改后会产生Crash了。

其实一言以蔽之,就是@Synchronized锁不住对象赋值变化的场景。

回到我们上一小节Crash的问题:

考虑三个线程的场景,分别定义为线程A,线程B,线程C,初始的时候在线程A,self.testArray的初始值为arr0(实质上操作的是arr0地址,下文简述为arr0),我们来理下时间线:

  • 线程A获取self.testArray的值,为arr0
  • 线程B获取self.testArray的值,也为arr0
  • 线程A,B由于对象地址一致,产生竞争,A获取到了对应的锁,我们称之为lock0
  • 线程A在锁的保护下,执行self.testArray = @[].mutableCopyself.testArray指向了arr1
  • 线程Aunlock
  • 此时线程C开始尝试获取self.testArray,获取到了arr1
  • 这个时候线程B由于线程A释放锁了,线程B继续,线程B使用之前获取的arr0进行获取锁的操作。
  • 这个时候线程C也尝试进行锁操作,由于线程C是arr1,所以使用的是arr1对应的锁操作。
  • 由于arr0arr1对应的锁不是一个(当然理论上可能散列计算为同一个),所以这两个线程都进入了临界区
  • 线程B和线程C都执行self.testArray = @[].mutableCopy
  • Setter的赋值并不是atomic的,实质上会转换成如下这样的代码:

    static inline void reallySetProperty(id self, SEL _cmd, id newValue, 
      ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy) 
    {
        id oldValue;
        // 计算结构体中的偏移量
        id *slot = (id*) ((char*)self + offset);
    
        if (copy) {
            newValue = [newValue copyWithZone:NULL];
        } else if (mutableCopy) {
            newValue = [newValue mutableCopyWithZone:NULL];
        } else {
            // 某些程度的优化
            if (*slot == newValue) return;
            newValue = objc_retain(newValue);
        }
    
        // 危险区
        if (!atomic) {
             // 第一步
            oldValue = *slot;
    
            // 第二步
            *slot = newValue;
        } else {
            spin_lock_t *slotlock = &PropertyLocks[GOODHASH(slot)];
            _spin_lock(slotlock);
            oldValue = *slot;
            *slot = newValue;        
            _spin_unlock(slotlock);
        }
    
        objc_release(oldValue);
    }
    
  • 上述危险区的第二步,_testArray在线程B和线程C分别指向了新地址addr2addr3但是获取到的oldValue可能都是arr1

  • 通过objc_releaseoldValue,也就是arr1进行了两次释放,妥妥的double free过度释放场景,导致崩溃。

屏幕快照 2018-12-28 上午11.05.22.png

备注:多线程的场景在于不确定性,可能在其中任何一个指令处挂掉。

结语

所以,从本质上来说,@Synchronized的确是最不应该推荐给用户使用的一种锁机制,但是其根本原因并不一定是性能差距,Hash离散设计的优雅的话,一样能保证性能。但是其内在锁和对象上下文相关的联系会导致锁失效的场景,一旦有对象发生变化(被赋值),导致潜在的锁不住多线程的场景,我们也应该去了解学习。

通过Xcode 10链接libstdc++来深入分析tbd文件

相信玩iOS开发的同学对tbd这个格式的文件已经不再陌生了。最近Xcode 10升级的时候,你会发现很多原先用libstdc++的库在新的Xcode已经没有链接通过。而临时的解决方案也比较简单,网上也很多这样的文章,简而言之就是从Xcode 9中拷贝对应的libstdc++.tbd文件给新的Xcode 10来使用。

Ok,解决方案是有了,我们需要更深入的理解下:为什么拷贝tbd文件,就能够成功解决链接问题?

tbd格式解析

tbd全称是text-based stub libraries,本质上就是一个YAML描述的文本文件。

他的作用是用于记录动态库的一些信息,包括导出的符号、动态库的架构信息、动态库的依赖信息。

为什么需要包含这些信息呢?

  • 动态库的架构信息是了确保运行的程序在运行的平台加载正确的库。比如你不能在运行ARM指令集的iOS设备上加载x86格式的库。

后续我们会举一个手动修改tbdinstall-name字段的小例子来让运行在模拟器的时候加载ARM64架构的动态库

  • 导出的符号。写过程序的人都知道,我们肯定会依赖别人提供的一些函数方法。一般业界都会把这些函数或者方法封装成库的形势。

那库就分为静态库和动态库两种。相信网上关于这两者的讨论和阐述已经很多了,再次不再赘述。唯一需要提及的一点是,动态库是在程序运行(启动依赖或者按需加载)时候加载进程序的地址空间的,那么我们在静态期的时候,是如何得知动态库提供了哪些能力呢?而这就是tbd格式提供的导出符号表的加载,它会指导链接器在链接过程中,将需要决议的符号先做个标记,标记是来自哪个动态库。

这里举个小例子吧。
在程序构建的过程中,比如我们开发一个iOS应用,毋庸置疑的会用到UIKit这个动态库。而为了使我们的程序能够构建成功,这里分为了两个步骤:

  • 通过引入头文件,import <UIKit/UIKit.h>,我们知道了UIKit里面的函数、变量声明。有声明,就能通过编译器的检查。

  • 我们在代码里面使用了UIKit的函数,其本质是一种符号,因此需要链接器来决议这个符号来自哪?要是所有地方都找到,就会报类似undefined symbol之类的错误(想必大家已经很熟悉了)。

为什么要改造成tbd格式

tbd格式实际上是从Xcode 7时代引入的。

用于取代在真机开发过程中直接使用传统的dylib

用于取代在真机开发过程中直接使用传统的dylib

用于取代在真机开发过程中直接使用传统的dylib

我们都知道一个库在没有strip诸如调试信息、非导出符号的情况下是非常大的。但是由于在开发过程中,调试等过程是必不可少的,我们来对比下传统直接包含dylib的时候大小,我们以CoreImage.framework来举例:

  • 首先看下模拟器上的传统架构大小:

  • 再看下对应的真机上的伪framework(包含tbd)的大小

差距很明显了吧,对于真机来说,由于动态库都是在设备上,在Xcode上使用基于tbd格式的伪framework可以大大减少Xcode的大小。

题外话:网上有人说模拟器上还是使用dylib,的确没错。但是模拟器现在也桥了一层tbd格式,真正的dylib是在这个路径下:iPhoneOS.platform/Developer/Library/CoreSimulator/Profiles/Runtimes/iOS.simruntime/Contents

此外,虽然从Xcode 7时代到现在,一直都是tbd的说法,但是它也是经历了一些演变了,目前已经发展了v3格式的版本。

为什么拷贝tbd文件能解决Xcode 10上的问题

网上很多人都研究过dyld的代码,与之对应还有一种ld,就是平时我们在构建程序过程中,链接过程中出错的根因:

既然我们通过拷贝tbd的方式能解决链接不过的问题,那我们就要知道ld是如何运用tbd文件的。

既然报错事library not found,我们扒一下linker的源码即可:

Options::FileInfo Options::findLibrary(const char* rootName, bool dylibsOnly) const
{
    FileInfo result;
    const int rootNameLen = strlen(rootName);
    // if rootName ends in .o there is no .a vs .dylib choice
    if ( (rootNameLen > 3) && (strcmp(&rootName[rootNameLen-2], ".o") == 0) ) {
        for (std::vector<const char*>::const_iterator it = fLibrarySearchPaths.begin();
             it != fLibrarySearchPaths.end();
             it++) {
            const char* dir = *it;
            if ( checkForFile("%s/%s", dir, rootName, result) )
                return result;
        }
    }
    else {
        bool lookForDylibs = false;
        switch ( fOutputKind ) {
            case Options::kDynamicExecutable:
            case Options::kDynamicLibrary:
            case Options::kDynamicBundle:
            case Options::kObjectFile:  // <rdar://problem/15914513> 
                lookForDylibs = true;
                break;
            case Options::kStaticExecutable:
            case Options::kDyld:
            case Options::kPreload:
            case Options::kKextBundle:
                lookForDylibs = false;
                break;
        }
        switch ( fLibrarySearchMode ) {
        case kSearchAllDirsForDylibsThenAllDirsForArchives:
                // first look in all directories for just for dylibs
                if ( lookForDylibs ) {
                    for (std::vector<const char*>::const_iterator it = fLibrarySearchPaths.begin();
                         it != fLibrarySearchPaths.end();
                         it++) {
                        const char* dir = *it;
                        auto path = std::string(dir) + "/lib" + rootName + ".dylib";
                        if ( findFile(path, {".tbd"}, result) )
                            return result;
                    }
                    for (std::vector<const char*>::const_iterator it = fLibrarySearchPaths.begin();
                         it != fLibrarySearchPaths.end();
                         it++) {
                        const char* dir = *it;
                        if ( checkForFile("%s/lib%s.so", dir, rootName, result) )
                            return result;
                    }
                }
                // next look in all directories for just for archives
                if ( !dylibsOnly ) {
                    for (std::vector<const char*>::const_iterator it = fLibrarySearchPaths.begin();
                         it != fLibrarySearchPaths.end();
                         it++) {
                        const char* dir = *it;
                        if ( checkForFile("%s/lib%s.a", dir, rootName, result) )
                            return result;
                    }
                }
                break;

            case kSearchDylibAndArchiveInEachDir:
                // look in each directory for just for a dylib then for an archive
                for (std::vector<const char*>::const_iterator it = fLibrarySearchPaths.begin();
                     it != fLibrarySearchPaths.end();
                     it++) {
                    const char* dir = *it;
                    auto path = std::string(dir) + "/lib" + rootName + ".dylib";
                    if ( lookForDylibs && findFile(path, {".tbd"}, result) )
                        return result;
                    if ( lookForDylibs && checkForFile("%s/lib%s.so", dir, rootName, result) )
                        return result;
                    if ( !dylibsOnly && checkForFile("%s/lib%s.a", dir, rootName, result) )
                        return result;
                }
                break;
        }
    }
    throwf("library not found for -l%s", rootName);
}
  • throwf("library not found for -l%s", rootName); 这里我们就找到了错误发生的原因,我们再往上溯源,找到linker处理编译单元的入口:

  • InputFiles::addOtherLinkerOptions里面存在如下代码:

    CStringSet newLibraries = std::move(state.unprocessedLinkerOptionLibraries);
        state.unprocessedLinkerOptionLibraries.clear();
        for (const char* libName : newLibraries) {
            if ( state.linkerOptionLibraries.count(libName) )
                continue;
            try {
                Options::FileInfo info = _options.findLibrary(libName);
                if ( ! this->libraryAlreadyLoaded(info.path) ) {
                    _linkerOptionOrdinal = _linkerOptionOrdinal.nextLinkerOptionOrdinal();
                    info.ordinal = _linkerOptionOrdinal;
                     //<rdar://problem/17787306> -force_load_swift_libs
                    info.options.fForceLoad = _options.forceLoadSwiftLibs() && (strncmp(libName, "swift", 5) == 0);
                    ld::File* reader = this->makeFile(info, true);
                    ld::dylib::File* dylibReader = dynamic_cast<ld::dylib::File*>(reader);
                    ld::archive::File* archiveReader = dynamic_cast<ld::archive::File*>(reader);
                    if ( dylibReader != NULL ) {
                        dylibReader->forEachAtom(handler);
                        dylibReader->setImplicitlyLinked();
                        dylibReader->setSpeculativelyLoaded();
                        this->addDylib(dylibReader, info);
                    }
                    else if ( archiveReader != NULL ) {
                        _searchLibraries.push_back(LibraryInfo(archiveReader));
                        _options.addDependency(Options::depArchive, archiveReader->path());
                        //<rdar://problem/17787306> -force_load_swift_libs
                        if (info.options.fForceLoad) {
                            archiveReader->forEachAtom(handler);
                        }
                    }
                    else {
                        throwf("linker option dylib at %s is not a dylib", info.path);
                     }
                 }
             }
            catch (const char* msg) {
                // <rdar://problem/40829444> only warn about missing auto-linked library if some missing symbol error happens later
                state.missingLinkerOptionLibraries.insert(libName);
            }
            state.linkerOptionLibraries.insert(libName);
        }
    

而上述这些需要查询的library是从哪里来的呢?

  • 我们以xcconfig举例来看:

    OTHER_LDFLAGS = $(inherited) -ObjC -l"stdc++"
    

在链接过程中就需要处理这样的stdc++ Library,而查询的方式就是在特定目录结构中搜索是否有对应的库文件或者tbd文件。

后记

使用tbd当然不止减少Xcode体积大小这一个好处,嘿嘿,你们自己摸索下吧~

而且,基于这种思路,能玩出许多类似文体两开花,中美合拍美猴王的玩法,加油吧。

谈谈JSDebugger

务虚乱弹

JSDebugger开源地址:https://github.com/SatanWoo/JSDebugger

这是一篇谈谈设计JSDebugger的总体设想,不会过于深究具体实现细节,后续会单独探讨一些涉及实现方面的过程。

读过我之前博客的朋友可能会记得我3-4月份的时候写过一篇动手制作一个简易的iOS动态执行器,效果如下:

虽然这个效果还起来还不错(有许多人问这个东西咋实现的,挺炫酷的),但是其实只是个原型而已,从设想构思到编码实现没有超过一天的时间。当时在博文里面承诺发代码,想想实现的完善度还不够,就准备完善后再继续搞搞。不过后来由于我转到其他组不继续钻研iOS,这事也就不了了之。

那为什么现在又重新开搞呢?,三点原因吧:

  1. 有朋友在群里问我之前提过的Mach-o包瘦身方案怎么也没发文章?
  2. 看到同事查非Crash产生的Bug的过程还是比较累。

好,来说说前两个原因:

  • 第一点,有些文章为什么后续不发,主要原因还是很多产出和收获都是工作中和同事一起探讨研究出来的,发出来一是泄漏了公司的数据(职业操守还是很重要的),二是相当于霸占了一些别人的工作成果。比如说我之前写的内存等等,对外发的一般都是只和开源代码相关的同时删减了大部分略微关键的部分,所以导致有些读者读起来断断续续云里雾里。

完整版的很多文章或者一些没对外的技术研究,我都发在了公司的内网里,欢迎加入阿里巴巴。

  • 第二点,我之前有段时间负责手淘的稳定性,除了有堆栈的Crash问题外,更多是用户反馈的使用异常以及毫无头绪的奇怪现象。针对这种问题,我相信各家(甚至开源方案)一般都有自己的监控体系,比如日志啊、埋点等等。但是怎么说呢,从我的感受来看,都是使用起来偏繁琐,而且还依赖对应的开发把错误信息等现场保留写入到日志内。我想要的就是一个能像我在自己手机上调试应用一样调试用户手机的能力(前提是用户授权同意),因此我就想做了一个这样的工具。结合自己以前玩逆向的些许经历,Cycript就提供了类似的能力,因此就利用业余时间做了一个JSDebugger

说了这么多原因,其实还是我太懒了。

JSDebugger

言归正传,回到JSDebugger本身,基于之前的代码,这次主要做了完整性的代码重构重写以及功能完善上。

  • 类方法调用
  • 实例方法调用
  • Setter & Getter的调用
  • 可变参数的函数调用
  • C Pointer的使用
  • 基础类型的使用
  • 对象和类的使用
  • 结构体的使用(目前支持CGSize, CGRect, CGPoint,正在开发自定义注册接口)
  • 插件化的扩展功能。

同时,为了更好的测试所写的JavaScript代码,开发了玩具级别的Playground功能,每次实时修改文件后保存即可自动触发Reload

很多细节此文不表,但是有些功能上的实现还是比较用心的,比如支持了各种类型、个数的可变参数的函数调用,比如目前支持了chooseintrospect的能力,二者配合可以对任意对象实时查询其当前所有的属性值。

而且,我对JavaScriptCore的使用可能和常规大家所属性的iOS JavaScriptCore有所区别,利用更低层的设计思路,经过我实测:

更低层的设计桥接思路在iOS上同比基于Objective-C的使用方式可以节省50%的时间;同比在Android上使用开源的JavaScriptCore50分之一左右的时间。

当然Android上比较主流的JS引擎室v8咯

具体快原因可以阅读动手制作一个简易的iOS动态执行器中涉及的JavaScriptCore上层源码分析以及阅读我的JSDebugger源码。

目前JSDebugger还在不断完善中,后续会把我更多的想法移植到里面,总体规划有几个关键点:

  • 实现一个交互式的编辑器(或者命令行),能够让大家写Objective-C的代码自动转换成JSDebugger的JS语法。以我目前的技术水准,还做不到Cycript那种牛逼的Objective-CJavaScript的混合语法模式。

  • 实现远程图形化Debug能力。目前JSDebugger可以调试数据,但是如果能像Reveal一样把操作界面和数据结合起来就会更有效的定位问题。

欢迎有想法的朋友一起来参与完善这个项目,开源地址如下:

https://github.com/SatanWoo/JSDebugger

https://github.com/SatanWoo/JSDebugger

https://github.com/SatanWoo/JSDebugger

当然,要是发现了任何的Bug或者使用上的疑惑、抑或是可以改进的点,也可以私聊我或者开issue

最后

实现JSDebugger的过程,还是站在两个杰出的项目肩膀上:

  • Cycript
  • JSPatch

我的思路是来自于Cycript,诸如结构体等许多方面的实现细节是参考了JSPatch。在这里对这几个项目的作者和代码贡献者表示感谢!

此外,很多的技术方案是和HookZZ大神交流(主要是他教我)中学习而来,在这也特别感谢。也感谢头条的谢大佬的代码贡献以及寒神的Code Style整理。

当然,JSDebugger在实现上还是有很多自己思考的部分,感兴趣的读者可以自行前往JSDebugger的Github开源地址

C++实现一个识别MNIST数字的卷积神经网络

新的一个财年加入了新的组,从事机器学习相关的工作。由于之前做的一直是iOS(略微底层)方面的事情,初来乍到,对很多东西不熟悉,在超级大神ZB的建议下,用C++实现一个多层前馈神经网络,来识别MNIST数据中的各种手写图片。

素材寻找

  • 感谢这位不知名的大佬提供的MNIST数据集,可以直接下载纯图片数据集
  • 搜索下载已经调整好的weightbias模型。(下文会介绍)

实现过程

关于实现多层前馈神经网络,网络上的答案数不胜数,但是大多数都是参考Tensorflow或者Caffe(PyTorch)去实现,我觉得这样很不好。因为学习一门新技术,虽然快速完成项目看出效果很重要,但是对个人来说,弄懂黑盒背后的故事非常重要。因此我决定不依赖任何的库来完全裸写。

当然,对于我来说,实现的完整度和正确性是第一位的,我并没有过多的关注性能。

虽然在深度学习高度发展的今天,类似于AlexNet这样的网络模型能够近乎完美的识别手写数据集。但是作为这个领域的入门级选手,我还是想追溯起源,从头开始做起。因此,在一番学习和搜索后,我选定了LeNet-5模型进行编写。选择它的原因主要有如下几点:

  • 它自身是一个多层的前馈网络模型。
  • 麻雀虽小,五脏俱全。包含了卷积层、全联接层、放缩、灰度以及池化层。同时还引入了ReLuSoftmax等激活函数。
  • 实现简单,哈哈哈哈哈

LeNet-5

LeNet-5整体是个非常简单的过程,包含如下步骤:

  1. 接受一个RGBA的图像。这个很简单,直接在RGBA的颜色空间下读取即可。
  2. 放缩到28 * 28(保留所有的feature)的大小,采用的是bilinear插值方法。
  3. 灰度化,公式如下:r / 255.0 * 0.299 + g / 255.0 * 0.587 + b /255.0 * 0.114
  4. 取反,即255.0 - 灰度化的结果
  5. 5 * 5大小的卷积,加Relu,进行第一次卷积操作。(这里为了保证卷积后尺寸一致,添加了Padding)
  6. 最大池化层降采样。
  7. 5 * 5大小的进行第二次卷积,加ReLu,进行第一次卷积操作。(这里为了保证卷积后尺寸一致,添加了Padding)
  8. 最大池化层降采样。
  9. 全链接计算 + ReLu
  10. 全链接输出10个featureMap
  11. Softmax计算并去除最大的值,即为检测的数字结果。

整体实现上没什么需要特别注意的,如果对这里的名次不懂,可以上网自行查询对应的解释。不过这里有一点很不好,浪费了大量的时间在调试我定义的张量的格式和网上找到的weight模型的格式。

什么意思呢?我大致用如下的图解释下我自身设计的张量是如何存储的。

理论上来讲,张量有三个维度,width, height, featureChannels。我在设计我的张量存储上按照的data[height][row][featureChannels]的方式,然后全部拍成了一维。如图所示:

之所以想这么做,主要是瞄了眼TensorFlow也是类似的设计,然后印象中CUDA也是按照这样维度进行存储,貌似可以有效做并行计算拆分。(这个不确定)

然后为什么卡了很久呢?主要是weightsbias的模型文件是个按照自定义协议二进制流的文件(非结构化的数据)。

这模型由于是我网上找的,一开始没注意模型的自定义协议和我设计的张量直接的区别。我直接按照我的张亮顺序进行了相乘,得到了十分错误的结果。

当然,bias模型没什么好说的,就是按照outputFeatureMap定义的纯一维数组,不会出错。

后来发现这个模型是基于苹果的MPS设计的模型,它的模型是这样的数据结构weights[outputChannels][height][width][inputChannel/group]。第一维在计算的时候需要和我做个映射,所以这里没搞清楚模型格式,查了比较久。

当然,我在加载权重和bias这块还是做了点小油画。用了mmap,避免一次性直接搞进来太大的数据,反正看起来weightbias这块并不需要一次性的读取,而且只读的mmap还能合理利用iOS设备上的clean memory回收机制。

框架设计

  • 网络模型拓扑结构,MinstGraph。这里偷懒了,因为LeNet-5也没啥复杂的拓扑结构,不用考虑多个节点连接,直接线性跑下去就好。

  • 支持任意多维度的张量,类似Tensorflow里面的Tensor,这里对应了MinstImage

  • 各种Layer,如MaxPoolingLayerConvolutionLayer, FullConnectionLayer等等。
  • 各种激活函数,如ReluSoftmax等等。
  • 一些辅助函数之类的。

代码下周发吧。

效果

准确度一开始不怎么高,检查了很多遍代码,确实发现了一些问题,比如数据精度问题。

一开始从图像的角度理解,认为用unsigned char存储一个数据点就够了,毕竟图像像素点(RGB空间下)的取值范围就是0-255

后来发现在计算卷积、全链接层的时候会产生很多小数,用unsigned char存储精度全部丢失了。因此修改成了现在的float设计。对效果提升还是比较明显的。

后来专门跑了下苹果基于Metal实现的卷积神经网络,由于上述我自身实现的所有Layer和激活函数在苹果的框架中都有内置,因此把网络模型搭起来跑就完了(除非苹果自己实现有错)。然后对比我的每一层输入输出和对应的MPSImage输入输出。

不过这里有一点要注意,MPSImage的数据格式是NHWC,这里的N是把C按照4对齐后分成的不同batch。如下图所示:

假设是一个2 * 1 * 5(w h c)的数据,会先把前4层排完,再进行第五层的排列,按4对齐后多出来的三个层补0。

我的代码里面MinstImage提供了一个print方法就是专门做输出对比的。嗯,对比了我的实现和用苹果框架的下输入输出,结果是一致的。(除了iOS10上不支持bilinear插值)

最终效果如下图:

备注:

如果直接用我开头提到的MNIST数据集,由于每张图都是28 * 28的灰度图,因此不需要resize + grayscale,直接从取反开始计算就可以了。

后续规划

  1. 做神经网络还是挺有意思,不过目前还是参考简单的模型结构实现,主要做inference。还没真正去研究训练模型这块。这块需要多深入研究算法,多读论文。

  2. 目前并没有真正设计Session的概念。理论上一张图就是一个静态的拓扑结构的组合而已,不应该承担类似执行run的功能。后续业余时间还会继续实现完整这套逻辑,将静态结构和动态执行结构彻底分离。

  3. 后续有时间的话,可以尝试实现别的模型。同时支持从文件中读取已经建立好的模型,类似Caffee模型之类的

  4. 移植到GPU上。

最后

文章的最后,按照惯例还是向我的偶像致敬。机器学习发展到今天,已经成为了不可忽视的研究方向。对于我们这样的后生来说,站在大牛的肩膀上是我们的福气和基石。而像杨萧玉这样,能够一周时间内学完机器学习课程,发表博客造福大众,才是推动机器学习不断发展的中坚力量。相信在他的带领下,我们国家一定能够在2030年达到全球领先的AI技术水准。

动手制作一个简易的iOS动态执行器

之前听说滴滴的DynamicCocoa是基于JavaScriptCore搞得,一直期待看到他们的真正实现,不过可能后来由于公司机密,应该不能再开源了。

借着最近开始研究JavaScriptCore的契机,我决定利用这一两天所学的JavaScript知识,在业余时间做一个简单的iOS动态执行器玩具。

题外话1:听说滴滴基于LLVM backend搞了一套中间语言解释器,不知道最后用了哪个?不过LLVM IR解释器的话,嘿嘿,还是有点意思的。

题外话2:我研究这个并不是想做iOS动态化,因为xxxxxxx。我只是纯粹想看看JavaScriptCore的一些实现而已。

效果

一张Gif图想必能最佳得展示我做的玩具,请各位大佬过目:

前置知识点

在实现我们的执行器前,我们还是要稍微要了解一下一些前置的知识点。

JSWrapper Object

大家都知道,Objective-C中的诸多类型在JavaScript的环境里是不能直接用的,需要通过JSValue进行一层包装,具体的类型转换如下图展示:

基本上图上的转换都很容易理解,唯一需要我们注意的是Wrapper Object。什么是Wrapper Object呢?

举个例子:

self.context[@"a"] = [CustomObject new]

上述代码将我们一个自定义类型CustomObject的实例以变量名a的方式注入到了JavaScript的运行环境里。但是她是怎么知道我们的定义呢,又是如何知道我们是否能调用特定的方法?

从默认的角度看,JS运行环境只会把OC中init初始化方法以及类的继承关系给同步到JS环境中(如果有JSExport我们下文说),然后这个对象会包装给一个JSWrapperValue用于JS环境中使用。而当JS环境调用OC并且涉及到这个对象的时候,JavaScriptCore会自动将其解包还原成原始的OC对象类型。

- (JSValue *)jsWrapperForObject:(id)object
{
    JSC::JSObject* jsWrapper = m_cachedJSWrappers.get(object);
    if (jsWrapper)
        return [JSValue valueWithJSValueRef:toRef(jsWrapper) inContext:m_context];

     // 注意点!!!!!!!!!!!!!!!!!!
    JSValue *wrapper;
    if (class_isMetaClass(object_getClass(object)))
        wrapper = [[self classInfoForClass:(Class)object] constructor];
    else {
        JSObjCClassInfo* classInfo = [self classInfoForClass:[object class]];
        wrapper = [classInfo wrapperForObject:object];
    }

    JSC::ExecState* exec = toJS([m_context JSGlobalContextRef]);
    jsWrapper = toJS(exec, valueInternalValue(wrapper)).toObject(exec);
    m_cachedJSWrappers.set(object, jsWrapper);
    return wrapper;
}
  • 整体分析下,就是基于一个缓存来判断是否对特定的对象或类型已经构建果Wrapper Object,没有的话就进行构建,构建过程如下:
1
2
3
4
5
6
7
JSClassDefinition definition;

definition = kJSClassDefinitionEmpty;
definition.className = className;
m_classRef = JSClassCreate(&definition);

[self allocateConstructorAndPrototypeWithSuperClassInfo:superClassInfo];
  • 没啥特别的,就是OC对象创建对应的JS对象,类型对类型。
  • OC类型的继承关系在JS里面通过设置Constructor和Prototype进行构建,其实就是简单的JavaScript原型链继承。

JSExport协议 & JSExportAs

JSExport协议本质上只是个Protocol标记,用于让JavaScriptCore加载那些打上这个特殊标记的类,用于特定方式的注册及初始化。

上文我们提过,默认情况下,JavaScriptCore会对象创建一个默认的Wrapper Object,但是这个对象除了简单继承关系外,也就一个按照特殊格式命令的Constructor而已:

[NSString stringWithFormat:@"%sConstructor", className]

那如果我们需要将OC环境中的方法注入到JS环境中,就需要用到JSExport协议了,这个协议在运行时会按照如下逻辑进行处理,将方法和属性进行诸如注入:

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
38
39
40
41
42
43
44
45
检查init方法簇的方法,并根据这么合法提供合理的

__block HashMap<String, Protocol *> initTable;
Protocol *exportProtocol = getJSExportProtocol();
for (Class currentClass = cls; currentClass; currentClass = class_getSuperclass(currentClass)) {
forEachProtocolImplementingProtocol(currentClass, exportProtocol, ^(Protocol *protocol) {
forEachMethodInProtocol(protocol, YES, YES, ^(SEL selector, const char*) {
const char* name = sel_getName(selector);
if (!isInitFamilyMethod(@(name)))
return;
initTable.set(name, protocol);
});
});
}

for (Class currentClass = cls; currentClass; currentClass = class_getSuperclass(currentClass)) {
__block unsigned numberOfInitsFound = 0;
__block SEL initMethod = 0;
__block Protocol *initProtocol = 0;
__block const char* types = 0;
forEachMethodInClass(currentClass, ^(Method method) {
SEL selector = method_getName(method);
const char* name = sel_getName(selector);
auto iter = initTable.find(name);

if (iter == initTable.end())
return;

numberOfInitsFound++;
initMethod = selector;
initProtocol = iter->value;
types = method_getTypeEncoding(method);
});

if (!numberOfInitsFound)
continue;

if (numberOfInitsFound > 1) {
NSLog(@"ERROR: Class %@ exported more than one init family method via JSExport. Class %@ will not have a callable JavaScript constructor function.", cls, cls);
break;
}

JSObjectRef method = objCCallbackFunctionForInit(context, cls, initProtocol, initMethod, types);
return [JSValue valueWithJSValueRef:method inContext:context];
}
1
2
3
4
5
6
注入方法和属性
Protocol *exportProtocol = getJSExportProtocol();
forEachProtocolImplementingProtocol(m_class, exportProtocol, ^(Protocol *protocol){
copyPrototypeProperties(m_context, m_class, protocol, prototype);
copyMethodsToObject(m_context, m_class, protocol, NO, constructor);
});

而至于JSExportAs,就是做了个简单的名称映射而已,毕竟JS函数传参和OC有很大的区别:

static NSMutableDictionary *createRenameMap(Protocol *protocol, BOOL isInstanceMethod)
{
    NSMutableDictionary *renameMap = [[NSMutableDictionary alloc] init];

    forEachMethodInProtocol(protocol, NO, isInstanceMethod, ^(SEL sel, const char*){
        NSString *rename = @(sel_getName(sel));
        NSRange range = [rename rangeOfString:@"__JS_EXPORT_AS__"];
        if (range.location == NSNotFound)
            return;
        NSString *selector = [rename substringToIndex:range.location];
        NSUInteger begin = range.location + range.length;
        NSUInteger length = [rename length] - begin - 1;
        NSString *name = [rename substringWithRange:(NSRange){ begin, length }];
        renameMap[selector] = name;
    });

    return renameMap;
}

实现过程

说了那么多基础原理,下面让我们来看看具体实现流程:

类、实例和方法

在我看来,要实现一个动态化的执行环境,有三要素是必不可少的:

类(包括元类)、实例对象以及方法。

基于我们上文对于Wrapper Object的分析,我们可以构建特殊类型的Wrapper Object对这三个元素进行包装,具体就不说了,还是建议大家自行思考,基本上类似我上文分析JSWrapperObject的步骤。

除了上述三要素,我们还需要定义一个全局变量,WZGloablObject(大家可以理解为浏览器的window对象),用于拦截顶层的属性访问。

按照这个设计,大家可以自行思考下,如果是你做,你会如何继续下面的工作,文章下周随着代码一起发布吧。

Choose 调试

搞过逆向用过Cycript的朋友都知道,Cycript在调试时候有个非常方便的调试功能:Choose。该功能可以快速的帮助我们根据类名在堆上的对象全部查询返回。

这么实用的功能必须提供,我基本上直接照搬了Cycript的实现。代码很清晰,基本能够自解释其逻辑。核心基本上就是遍历每个malloc_zone,然后根据获取的vmaddress_range判断获取到的数据其类型是不是我们要的。

// 遍历zone
for (unsigned i = 0; i != size; ++i) {
    const malloc_zone_t * zone = reinterpret_cast<const malloc_zone_t *>(zones[i]);
    if (zone == NULL || zone->introspect == NULL)
        continue;
    zone->introspect->enumerator(mach_task_self(), &choice, MALLOC_PTR_IN_USE_RANGE_TYPE, zones[i], &read_memory, &choose_);
}

// 检查对象
for (unsigned i = 0; i < count; ++i) {
   vm_range_t &range = ranges[i];
   void * data = reinterpret_cast<void *>(range.address);
   size_t size = range.size;

   if (size < sizeof(ObjectStruct))
       continue;

   uintptr_t * pointers = reinterpret_cast<uintptr_t *>(data);
#ifdef __arm64__
   Class isa = (__bridge Class)((void *)(pointers[0] & 0x1fffffff8));
#else
   Class isa = reinterpret_cast<Class>(pointers[0]);
#endif
   std::set<Class>::const_iterator result(choice->query_.find(isa));
   if (result == choice->query_.end())
       continue;

   size_t needed = class_getInstanceSize(*result);
   size_t boundary = 496;
#ifdef __LP64__
   boundary *= 2;
#endif
   if ((needed <= boundary && (needed + 15) / 16 * 16 != size) || (needed > boundary && (needed + 511) / 512 * 512 != size))
       continue;
   choice->result_.insert((__bridge id)(data));
}

不过这里一大堆的511、512的数字构成的公式,实话说我不是很懂,有了解的大佬麻烦告知我一下。

类型转换

首先我们需要记住,JavaScript的基础类型如下:

- 字符串、
- 数字、
- 布尔、
- 数组、
- 对象、
- Null- Undefined

所以我们只要根据对应的进行转换就可以,如下所示:

  • JS字符串 <-> NSString
  • 数字 <-> NSNumber
  • 数组 <-> NSArray
  • Null <-> NSNull
  • Undefined <-> Void (仅当返回值的时候处理,否则直接抛出异常)

题外话,JavaScript里面没有什么整数和浮点数类型区分一说,所以我们可以无脑将其通过double的方式构建NSNumber

最后再来说下对对象类型的处理:

在JavaScript,任何对象都可以简单理解为包含了属性(方法)的一个包装体,如下所示:

var a = {x:10, y:100};

因此,我们在对类型进行转换的时候,要特别注意以下几点:

  • 这个对象是不是我们刚刚上文提过的类、实例、方法,是的话在其进入到Objective-C执行上下文的之前从JSWrapperObject中取出来。
  • 这个对象是不是特定类型的结构体,是的话我们将其转换成结构体,比如CGRect之类的,是的话需要特别转换
  • 是不是可以直接转换成特定类型的对象,比如Date <-> NSDate的转换。
  • 最后,将其可遍历的属性和对应的属性值,转换到NSDictionary之中。
  • 当然,别忘了,需要注意递归处理

Calling Convention

关于Calling Convention,本文就不再赘述,有兴趣的读者可以参考我和同事一起写的知乎专栏iOS调试进阶

简单来重新描述下就是:

一个函数的调用过程中,函数的参数既可以使用栈传递,也可以使用寄存器传递,参数压栈的顺序可以从左到右也可以从右到左,函数调用后参数从栈弹出这个工作可以由函数调用方完成,也可以由被调用方完成。如果函数的调用方和被调用方(函数本身)不遵循统一的约定,有这么多分歧这个函数调用就没法完成。这个双方必须遵守的统一约定就叫做调用惯例(Calling Convention),调用惯例规定了参数的传递的顺序和方式,以及栈的维护方式。

由于业界已经有知名大佬写的libffi,所以我们不需要重复发明轮子,直接使用即可。如果真的要了解具体原理,也可以参考我的文章,具体分析objc_msgSend的实现流程。

其他

为了偷懒,我直接用JavaScript实现了这些的效果。其实理论上,如果我完整的实现编译前端,构建抽象语法树分析执行上下文,将Objective-C的代码转换成JavaScript,那么就能实现动态执行Objective-C代码了。(当然本质上还是障眼法)

其实更快的方式,且不能保证完全正确的方式,就是调用一下JSPatchConvertor就好了,哈哈哈。

谈谈ivar的直接访问

大水文一篇
大水文一篇
大水文一篇

起因

最近对Block的一些实现细节又进行了一次复习,主要涉及的是捕捉变量的部分。有一个点我之前一直没太关注:对ivar变量的直接访问为啥会产生循环引用。

在我原先的理解中,之所以会产生循环引用,绝大多数场景都是由于block里面涉及了self关键字,比如[self doSomething](同理,对于property的访问本质也是一堆方法),但是为啥对ivar的访问也会导致循环引用呢?

不是直接采用 *(void *)address = xxx这样的直接对编译好的静态地址赋值就好了?

当时傻逼了,写完本文后想想就算编译成地址了,基地址从哪算还是要依赖self变量。

谈谈ivar的访问是啥形式

还是回到runtime来看看吧,万变不离其宗,从objc_class结构体看起:

struct objc_class : objc_object {
    // Class ISA; // 8byte
    Class superclass; // 8byte
    cache_t cache;             // formerly cache pointer and vtable // 4 + 4 + 8
    class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags

    class_rw_t *data() { 
        return bits.data();
    }

主要的运行时数据都是class_rw_t表示,继续瞅瞅:

struct class_rw_t {
    // Be warned that Symbolication knows the layout of this structure.
    uint32_t flags;
    uint32_t version;

    const class_ro_t *ro;

其中class_ro_t基本上是从二进制产物中读取的“副本”数据,我们看看:

struct class_ro_t {
    uint32_t flags;
    uint32_t instanceStart;
    uint32_t instanceSize;
#ifdef __LP64__
    uint32_t reserved;
#endif

    const uint8_t * ivarLayout;

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

看起来ivar_list_t就是存放ivar的列表,他的实现是一个模版类,看看具体结构表示:

struct entsize_list_tt {
    uint32_t entsizeAndFlags;
    uint32_t count;
    Element first;

具体对应ivar,替换掉模版就是:

struct ivar_list_t {
    uint32_t entsizeAndFlags;
    uint32_t count;
    ivar_t first;

其中,ivar_t表征的就是我们每个ivar

int32_t *offset;
const char *name;
const char *type;

嗯,从这里开始offset是用一个int32_t *的指针来表示,就开始有意思了。这里我们先暂时忽略

看起来,如果按照这种方式访问ivar,整个流程要经过好多次指针转移:

class -> class.rw_data -> class.rw_data.ro_data -> class.rw_data.ro_data.ivars -> 
-> class.rw_data.ro_data.ivars.first[n]

如果是这样,大量使用ivar肯定很耗时。那么,对于ivar的访问究竟是怎么玩的呢?

全局变量

我们用如下这个非常简单的例子来瞅瞅:

typedef void(^MyBlock)(void);

@interface MyObject : NSObject
@property (nonatomic) NSUInteger haha;
@property (nonatomic, copy) MyBlock block;

- (void)inits;

@end

@implementation MyObject
- (void)inits
{
    self.block = ^{
        _haha = 5;
    };
}
@end

int main(int argc, char * argv[]) {
    MyObject *object = [MyObject new];
    [object inits];
}

重写一把,基本转化成如下的形式:

typedef void(*MyBlock)(void);


#ifndef _REWRITER_typedef_MyObject
#define _REWRITER_typedef_MyObject
typedef struct objc_object MyObject;
typedef struct {} _objc_exc_MyObject;
#endif

// 注意点1!!!!!!!!!!!!!!!!!!!!
extern "C" unsigned long OBJC_IVAR_$_MyObject$_haha;
extern "C" unsigned long OBJC_IVAR_$_MyObject$_block;
struct MyObject_IMPL {
    struct NSObject_IMPL NSObject_IVARS;
    NSUInteger _haha;
    MyBlock _block;
};

// @property (nonatomic) NSUInteger haha;
// @property (nonatomic, copy) MyBlock block;

// - (void)inits;

/* @end */


// @implementation MyObject

struct __MyObject__inits_block_impl_0 {
  struct __block_impl impl;
  struct __MyObject__inits_block_desc_0* Desc;
  MyObject *self;

  // 注意点2!!!!!!!!!!!!!!!
  __MyObject__inits_block_impl_0(void *fp, struct __MyObject__inits_block_desc_0 *desc, MyObject *_self, int flags=0) : self(_self) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};

// 注意点3!!!!!!!!!!!!
static void __MyObject__inits_block_func_0(struct __MyObject__inits_block_impl_0 *__cself) {
  MyObject *self = __cself->self; // bound by copy

        (*(NSUInteger *)((char *)self + OBJC_IVAR_$_MyObject$_haha)) = 5;
    }
static void __MyObject__inits_block_copy_0(struct __MyObject__inits_block_impl_0*dst, struct __MyObject__inits_block_impl_0*src) {_Block_object_assign((void*)&dst->self, (void*)src->self, 3/*BLOCK_FIELD_IS_OBJECT*/);}

static void __MyObject__inits_block_dispose_0(struct __MyObject__inits_block_impl_0*src) {_Block_object_dispose((void*)src->self, 3/*BLOCK_FIELD_IS_OBJECT*/);}

static struct __MyObject__inits_block_desc_0 {
  size_t reserved;
  size_t Block_size;
  void (*copy)(struct __MyObject__inits_block_impl_0*, struct __MyObject__inits_block_impl_0*);
  void (*dispose)(struct __MyObject__inits_block_impl_0*);
} __MyObject__inits_block_desc_0_DATA = { 0, sizeof(struct __MyObject__inits_block_impl_0), __MyObject__inits_block_copy_0, __MyObject__inits_block_dispose_0};

static void _I_MyObject_inits(MyObject * self, SEL _cmd) {
    ((void (*)(id, SEL, MyBlock))(void *)objc_msgSend)((id)self, sel_registerName("setBlock:"), ((void (*)())&__MyObject__inits_block_impl_0((void *)__MyObject__inits_block_func_0, &__MyObject__inits_block_desc_0_DATA, self, 570425344)));
}

static NSUInteger _I_MyObject_haha(MyObject * self, SEL _cmd) { return (*(NSUInteger *)((char *)self + OBJC_IVAR_$_MyObject$_haha)); }
static void _I_MyObject_setHaha_(MyObject * self, SEL _cmd, NSUInteger haha) { (*(NSUInteger *)((char *)self + OBJC_IVAR_$_MyObject$_haha)) = haha; }

static void(* _I_MyObject_block(MyObject * self, SEL _cmd) )(){ return (*(MyBlock *)((char *)self + OBJC_IVAR_$_MyObject$_block)); }
extern "C" __declspec(dllimport) void objc_setProperty (id, SEL, long, id, bool, bool);

static void _I_MyObject_setBlock_(MyObject * self, SEL _cmd, MyBlock block) { objc_setProperty (self, _cmd, __OFFSETOFIVAR__(struct MyObject, _block), (id)block, 0, 1); }
// @end

int main(int argc, char * argv[]) {
    MyObject *object = ((MyObject *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("MyObject"), sel_registerName("new"));
    ((void (*)(id, SEL))(void *)objc_msgSend)((id)object, sel_registerName("inits"));
}

一大堆东西,没啥特别的地方,我们只要关注几个地方:

  • 对于每个ivar,都有对应的全局变量

    extern "C" unsigned long OBJC_IVAR_$_MyObject$_haha;
    extern "C" unsigned long OBJC_IVAR_$_MyObject$_block;
    
  • block_invoke对应的实现是通过对象自身作为基地址,全局变量作为偏移去对haha这个ivar进行赋值。

    static void __MyObject__inits_block_func_0(struct __MyObject__inits_block_impl_0 *__cself) {
      MyObject *self = __cself->self; // bound by copy
    
            (*(NSUInteger *)((char *)self + OBJC_IVAR_$_MyObject$_haha)) = 5;
        }
    
  • block的构造函数,确实捕捉了self

    __MyObject__inits_block_impl_0(void *fp, struct __MyObject__inits_block_desc_0 *desc, MyObject *_self, int flags=0) : self(_self) {
        impl.isa = &_NSConcreteStackBlock;
        impl.Flags = flags;
        impl.FuncPtr = fp;
        Desc = desc;
      }
    

由于全局变量的地址是在编译期就确定了,所以这里也就不难解释ivar_t里面为什么要保存int32_t *,保存的就是对应的全局变量地址。而全局变量的值则是对应的动态偏移。

结语

水完了,其实虽然runtime的结构体设计的比较绕,但是最后对于变量的访问和很多静态语言设计一样,也不会损失很多性能。

从另外一个角度看,如果声明了巨多的ivar,看来也会对包大小产生不可忽视的影响。

XNU之四:iOS虚拟内存限制(一)

XNU之二和之三两篇由于涉及的量过大,就不发了,当然本文我也删除了一定的内容,哈哈哈。

欢迎加入我们手淘/天猫的架构组来内网阅读

提起iOS的内存管理,大多数人第一反应想到的都是引用计数、ARCAutoreleasePool之类的词眼。但是事实上,这只是iOS内存管理的冰山一角,今天就让我们来探究水面之下的内存管理。

虚拟内存的限制

我之所以想研究这个议题,主要还是之前有个UC同事问了我一个问题:

“现在绝大多数都是ARM64的设备,即64位寻址空间,而且iOS上的通过malloc申请的内存只是虚拟内存,还不是真正物理内存,为什么分配了两三G就会失败了。”

按照他的想法,我在我的iPhone上测试了如下代码:

void *buffer = malloc(2000 * 1024 * 1024);

果不其然,报出了如下错误:

malloc: *** mach_vm_map(size=2097152000) failed (error code=3)
*** error: can't allocate region

次奥,我xxxxx分配一个2G虚拟内存就懵逼?

还是赶紧翻翻看源码,由于我分配的是一个超大的内存,所以按照nano_zonescalable_zone的设计理念,

  • 小于256byte的走nano_zone进行分配。
  • 剩下的我记不得了

赶紧翻翻scalable_zone看看源码,如下所示:

void *    szone_malloc_should_clear(szone_t *szone, size_t size, boolean_t cleared_requested)
{
    void *ptr;
    msize_t msize;

    if (size <= SMALL_THRESHOLD) {
        // tiny size: <1024 bytes (64-bit), <512 bytes (32-bit)
        // think tiny
        msize = TINY_MSIZE_FOR_BYTES(size + TINY_QUANTUM - 1);
        if (!msize) {
            msize = 1;
        }
        ptr = tiny_malloc_should_clear(szone, msize, cleared_requested);
    } else if (size <= szone->large_threshold) {
        // small size: <15k (<1GB machines), <127k (>1GB machines)
        // think small
        msize = SMALL_MSIZE_FOR_BYTES(size + SMALL_QUANTUM - 1);
        if (!msize) {
            msize = 1;
        }
        ptr = small_malloc_should_clear(szone, msize, cleared_requested);
    } else {
        // large: all other allocations
        size_t num_kernel_pages = round_page_quanta(size) >> vm_page_quanta_shift;
        if (num_kernel_pages == 0) { /* Overflowed */
            ptr = 0;
        } else {
            ptr = large_malloc(szone, num_kernel_pages, 0, cleared_requested);
        }
    }
#if DEBUG_MALLOC
    if (LOG(szone, ptr)) {
        malloc_printf("szone_malloc returned %p\n", ptr);
    }
#endif
    /*
     * If requested, scribble on allocated memory.
     */
    if ((szone->debug_flags & MALLOC_DO_SCRIBBLE) && ptr && !cleared_requested && size) {
        memset(ptr, SCRIBBLE_BYTE, szone_size(szone, ptr));
    }

    return ptr;
}
  • 小于1k的走tiny_malloc
  • 小于15k或者127k的走small_malloc(视具体不同的设备内存上限不同)
  • 剩下的走large_malloc

OK,由于我们分配的非常大,我们可以确定我们的逻辑是落入large_malloc中。需要特别注意的是:large_malloc分配内存的基本单位是一页大小,而对于其他的几种分配方式,则不是必须按照页大小进行分配。

由于large_malloc这个函数本身并没有特殊需要注意的地方,我们直接关注其真正分配内存的地方,即allocate_pages,如下所示:

vm_addr = vm_page_quanta_size;
kr = mach_vm_map(mach_task_self(), &vm_addr, allocation_size, allocation_mask, alloc_flags, MEMORY_OBJECT_NULL, 0, FALSE,
            VM_PROT_DEFAULT, VM_PROT_ALL, VM_INHERIT_DEFAULT);
if (kr) {
    szone_error(szone, 0, "can't allocate region", NULL, "*** mach_vm_map(size=%lu) failed (error code=%d)\n", size, kr);
    return NULL;
}
addr = (uintptr_t)vm_addr;

从上我们不难看出,如果分配失败,就是提示报错。而mach_vm_map则是整个内存的分配核心。

可能你一开始看到这个mach_vm_map会比较懵逼,可以先看下我下面这张图:

屏幕快照 2018-01-03 上午10.55.41.png

OK,是不是有冒出很多名词。没关系,你其实只要记住两点:vm_map代表就是一个进程运行时候涉及的虚拟内存,pmap代表的就是和具体硬件架构相关的物理内存。(这里我们暂时先不考虑submap这种情况)。

好,vm_map本身是进程(或者从Mach内核的角度看是task的地址分布图)。这个地址分布图维护着一个双向列表,列表的每一项都是vm_entry_t,代表着虚拟地址上连续的一个范围。而pmap这个结构体代表了个硬件相关的内存转换:即利用pmap这个结构体来描述抽象的物理地址访问和使用。

进程(任务)的创建

在继续深入我们的话题之前,我们还需要具备一个额外的知识,就是iOS上的进程创建和加载执行Mach-O过程。

类UNIX系统本质上是没法无缘无故创建出一个全新的进程的,基本上必须要通过fork的形式来创建。(这块不太熟悉,有错请指正)。

在XNU的实现里,不论用户态调用posix相关API还是别的API,落入到内核里面都走的是fork_create_child函数来创建属于Mach内核的任务(task)。其实现如下:

thread_t
fork_create_child(task_t parent_task, coalition_t *parent_coalitions, proc_t child_proc, int inherit_memory, int is64bit, int in_exec)
{
    thread_t    child_thread = NULL;
    task_t        child_task;
    kern_return_t    result;

    /* Create a new task for the child process */
    result = task_create_internal(parent_task,
                    parent_coalitions,
                    inherit_memory,
                    is64bit,
                    TF_LRETURNWAIT | TF_LRETURNWAITER,         /* All created threads will wait in task_wait_to_return */
                    in_exec ? TPF_EXEC_COPY : TPF_NONE,   /* Mark the task exec copy if in execve */
                    &child_task);

    if (result != KERN_SUCCESS) {
        printf("%s: task_create_internal failed.  Code: %d\n",
            __func__, result);
        goto bad;
    }

    if (!in_exec) {
        /*
         * Set the child process task to the new task if not in exec,
         * will set the task for exec case in proc_exec_switch_task after image activation.
         */

         // 注意点:
        child_proc->task = child_task;
    }
  • 这个函数本身实现并没什么出彩的,我们唯一要注意的就是Mach内核里面没有进程的概念,只有任务,进程是属于BSD之上的抽象。它们之间的联系就是通过指针建立,child_proc->task = child_task。

fork出来的进程更像是一个空壳,我们需要利用这个进程壳去执行可执行文件变成我们通常意义上理解的程序进程

从XNU上来看,可执行的文件种类如下:

{ exec_mach_imgact,        "Mach-o Binary" },
{ exec_fat_imgact,        "Fat Binary" },
{ exec_shell_imgact,    "Interpreter Script" }

这里咱们先只看最常用的Mach-O文件:

exec_mach_imgact(struct image_params *imgp)
{
    ... 省略无数

    if ((mach_header->magic == MH_CIGAM) ||
        (mach_header->magic == MH_CIGAM_64)) {
        error = EBADARCH;
        goto bad;
    }

    if ((mach_header->magic != MH_MAGIC) &&
        (mach_header->magic != MH_MAGIC_64)) {
        error = -1;
        goto bad;
    }

    if (mach_header->filetype != MH_EXECUTE) {
        error = -1;
        goto bad;
    }

    if (imgp->ip_origcputype != 0) {
        /* Fat header previously had an idea about this thin file */
        if (imgp->ip_origcputype != mach_header->cputype ||
            imgp->ip_origcpusubtype != mach_header->cpusubtype) {
            error = EBADARCH;
            goto bad;
        }
    } else {
        imgp->ip_origcputype = mach_header->cputype;
        imgp->ip_origcpusubtype = mach_header->cpusubtype;
    }

    task = current_task();
    thread = current_thread();
    uthread = get_bsdthread_info(thread);

    if ((mach_header->cputype & CPU_ARCH_ABI64) == CPU_ARCH_ABI64)
        imgp->ip_flags |= IMGPF_IS_64BIT;

    /* If posix_spawn binprefs exist, respect those prefs. */
    psa = (struct _posix_spawnattr *) imgp->ip_px_sa;
    if (psa != NULL && psa->psa_binprefs[0] != 0) {
        int pr = 0;
        for (pr = 0; pr < NBINPREFS; pr++) {
            cpu_type_t pref = psa->psa_binprefs[pr];
            if (pref == 0) {
                /* No suitable arch in the pref list */
                error = EBADARCH;
                goto bad;
            }

            if (pref == CPU_TYPE_ANY) {
                /* Jump to regular grading */
                goto grade;
            }

            if (pref == imgp->ip_origcputype) {
                /* We have a match! */
                goto grade;
            }
        }
        error = EBADARCH;
        goto bad;
    }
grade:
    if (!grade_binary(imgp->ip_origcputype, imgp->ip_origcpusubtype & ~CPU_SUBTYPE_MASK)) {
        error = EBADARCH;
        goto bad;
    }

    /* Copy in arguments/environment from the old process */
    error = exec_extract_strings(imgp);
    if (error)
        goto bad;

    AUDIT_ARG(argv, imgp->ip_startargv, imgp->ip_argc, 
        imgp->ip_endargv - imgp->ip_startargv);
    AUDIT_ARG(envv, imgp->ip_endargv, imgp->ip_envc,
        imgp->ip_endenvv - imgp->ip_endargv);

    /* reset local idea of thread, uthread, task */
    thread = imgp->ip_new_thread;
    uthread = get_bsdthread_info(thread);
    task = new_task = get_threadtask(thread);

    // 注意点:
    lret = load_machfile(imgp, mach_header, thread, &map, &load_result);

    ... 省略无数

整个代码都没啥用,就是做些检查,分配个进程壳,然后通过load_machfile加载真正的二进制文件。

load_return_t
load_machfile(
    struct image_params    *imgp,
    struct mach_header    *header,
    thread_t         thread,
    vm_map_t         *mapp,
    load_result_t        *result
)
{
    ... 省略一大堆

    if (macho_size > file_size) {
        return(LOAD_BADMACHO);
    }

    result->is64bit = ((imgp->ip_flags & IMGPF_IS_64BIT) == IMGPF_IS_64BIT);

    task_t ledger_task;
    if (imgp->ip_new_thread) {
        ledger_task = get_threadtask(imgp->ip_new_thread);
    } else {
        ledger_task = task;
    }

    // 注意点1
    pmap = pmap_create(get_task_ledger(ledger_task),
               (vm_map_size_t) 0,
               result->is64bit);

    // 注意点2
    map = vm_map_create(pmap,
            0,
            vm_compute_max_offset(result->is64bit),
            TRUE);

#if defined(__arm64__)
    // 注意点三
    if (result->is64bit) {
        /* enforce 16KB alignment of VM map entries */
        vm_map_set_page_shift(map, SIXTEENK_PAGE_SHIFT);
    } else {
        vm_map_set_page_shift(map, page_shift_user32);
    }
  • 利用pmap_create创建硬件相关的物理内存抽象。
  • 利用vmap_create创建虚拟内存的地址图。
  • ARM64下的页是16k一个虚拟页对应一个物理页。

别的没啥关注,我们重点关注vm_map_create0vm_compute_max_offset(result->is64bit)

因为这个代表了这个任务分配的虚拟地址上下限!这个函数的实现如下:

vm_map_offset_t
vm_compute_max_offset(boolean_t is64)
{
#if defined(__arm__) || defined(__arm64__)
    return (pmap_max_offset(is64, ARM_PMAP_MAX_OFFSET_DEVICE));
#else
    return (is64 ? (vm_map_offset_t)MACH_VM_MAX_ADDRESS : (vm_map_offset_t)VM_MAX_ADDRESS);
#endif
}

继续往下看:

vm_map_offset_t pmap_max_offset(
    boolean_t    is64 __unused,
    unsigned int    option)
{
    vm_map_offset_t    max_offset_ret = 0;

#if defined(__arm64__)
    assert (is64);
    vm_map_offset_t min_max_offset = SHARED_REGION_BASE_ARM64 + SHARED_REGION_SIZE_ARM64 + 0x20000000; // end of shared region + 512MB for various purposes
    if (option == ARM_PMAP_MAX_OFFSET_DEFAULT) {
        max_offset_ret = arm64_pmap_max_offset_default;
    } else if (option == ARM_PMAP_MAX_OFFSET_MIN) {
        max_offset_ret = min_max_offset;
    } else if (option == ARM_PMAP_MAX_OFFSET_MAX) {
        max_offset_ret = MACH_VM_MAX_ADDRESS;
    } else if (option == ARM_PMAP_MAX_OFFSET_DEVICE) {
        if (arm64_pmap_max_offset_default) {
            max_offset_ret = arm64_pmap_max_offset_default;
        } else if (max_mem > 0xC0000000) {
            max_offset_ret = 0x0000000318000000ULL;     // Max offset is 12.375GB for devices with > 3GB of memory
        } else if (max_mem > 0x40000000) {
            max_offset_ret = 0x0000000218000000ULL;     // Max offset is 8.375GB for devices with > 1GB and <= 3GB of memory
        } else {
            max_offset_ret = min_max_offset;
        }
    } else if (option == ARM_PMAP_MAX_OFFSET_JUMBO) {
        max_offset_ret = 0x0000000518000000ULL;     // Max offset is 20.375GB for pmaps with special "jumbo" blessing
    } else {
        panic("pmap_max_offset illegal option 0x%x\n", option);
    }

    assert(max_offset_ret >= min_max_offset);
    return max_offset_ret;

其实关键点就是这里的代码:

if (max_mem > 0xC0000000) {
    max_offset_ret = 0x0000000318000000ULL;     // Max offset is 12.375GB for devices with > 3GB of memory
} else if (max_mem > 0x40000000) {
    max_offset_ret = 0x0000000218000000ULL;     // Max offset is 8.375GB for devices with > 1GB and <= 3GB of memory
} else {
    max_offset_ret = min_max_offset;
}

max_offset_ret这个值就代表了我们任务对应的vm_map_t的最大地址范围,比如说这里是8.375GB。

虚拟内存分配的限制

好,在说了那么多前置知识后,我们言归正传,来谈谈为什么虚拟内存有限制。

之前我们提到了large_malloc会走入到最后的vm_map_enter,那么我们来看看vm_map_enter的实现:

vm_map_enter(
    vm_map_t        map,
    vm_map_offset_t        *address,    /* IN/OUT */
    vm_map_size_t        size,
    vm_map_offset_t        mask,
    int            flags,
    vm_map_kernel_flags_t    vmk_flags,
    vm_tag_t        alias,
    vm_object_t        object,
    vm_object_offset_t    offset,
    boolean_t        needs_copy,
    vm_prot_t        cur_protection,
    vm_prot_t        max_protection,
    vm_inherit_t        inheritance)
{

#if CONFIG_EMBEDDED
    // 注意点1:检查页的权限
    if (cur_protection & VM_PROT_WRITE){
        if ((cur_protection & VM_PROT_EXECUTE) && !entry_for_jit){
            printf("EMBEDDED: %s: curprot cannot be write+execute. "
                   "turning off execute\n",
                   __FUNCTION__);
            cur_protection &= ~VM_PROT_EXECUTE;
        }
    }
#endif /* CONFIG_EMBEDDED */

    if (resilient_codesign || resilient_media) {
        if ((cur_protection & (VM_PROT_WRITE | VM_PROT_EXECUTE)) ||
            (max_protection & (VM_PROT_WRITE | VM_PROT_EXECUTE))) {
            return KERN_PROTECTION_FAILURE;
        }
    }

    // 1. 获取任务的可用的地址最小值和最大值
    effective_min_offset = map->min_offset;
    effective_max_offset = map->max_offset;

    if (map->pmap == kernel_pmap) {
        user_alias = VM_KERN_MEMORY_NONE;
    } else {
        user_alias = alias;
    }

#define    RETURN(value)    { result = value; goto BailOut; }

    assert(page_aligned(*address));
    assert(page_aligned(size));

    if (!VM_MAP_PAGE_ALIGNED(size, VM_MAP_PAGE_MASK(map))) {
        clear_map_aligned = TRUE;
    }

StartAgain: ;

    start = *address;

    if (anywhere) {
        vm_map_lock(map);
        map_locked = TRUE;

        if (start < effective_min_offset)
            start = effective_min_offset;
        if (start > effective_max_offset)
            RETURN(KERN_NO_SPACE);


        if( FALSE ) {

        } else {

            if (map->holelistenabled) {
                hole_entry = (vm_map_entry_t)map->holes_list;

                if (hole_entry == NULL) {
                    /*
                     * No more space in the map?
                     */
                    result = KERN_NO_SPACE;
                    goto BailOut;
                } else {

                    boolean_t found_hole = FALSE;

                    do {
                        if (hole_entry->vme_start >= start) {
                            start = hole_entry->vme_start;
                            found_hole = TRUE;
                            break;
                        }

                        if (hole_entry->vme_end > start) {
                            found_hole = TRUE;
                            break;
                        }
                        hole_entry = hole_entry->vme_next;

                    } while (hole_entry != (vm_map_entry_t) map->holes_list);

                    if (found_hole == FALSE) {
                        result = KERN_NO_SPACE;
                        goto BailOut;
                    }

                    entry = hole_entry;

                    if (start == 0)
                        start += PAGE_SIZE_64;
                }
            }
        }

        while (TRUE) {
            vm_map_entry_t    next;

            end = ((start + mask) & ~mask);
            end = vm_map_round_page(end,
                        VM_MAP_PAGE_MASK(map));

            if (end < start)
                RETURN(KERN_NO_SPACE);

            start = end;
            end += size;

            if ((end > effective_max_offset) || (end < start)) {
                RETURN(KERN_NO_SPACE);
            }

            next = entry->vme_next;

            if (map->holelistenabled) {
                if (entry->vme_end >= end)
                    break;
            } else {

                if (next == vm_map_to_entry(map))
                    break;

                if (next->vme_start >= end)
                    break;
            }

            entry = next;

            if (map->holelistenabled) {
                if (entry == (vm_map_entry_t) map->holes_list) {
                    result = KERN_NO_SPACE;
                    goto BailOut;
                }
                start = entry->vme_start;
            } else {
                start = entry->vme_end;
            }

            start = vm_map_round_page(start,
                          VM_MAP_PAGE_MASK(map));
        }

        if (map->holelistenabled) {
            if (vm_map_lookup_entry(map, entry->vme_start, &entry)) {
                panic("Found an existing entry (%p) instead of potential hole at address: 0x%llx.\n", entry, (unsigned long long)entry->vme_start);
            }
        }

        *address = start;
    } 
  • 注意点1:基本上就是检查页的权限啥的,iOS上不允许可写和可执行并存。
  • 剩下的就是作各种前置检查

老实说,我一开始看苹果这最新的XNU代码,我压根没读懂。这一堆hole啥的在干啥,后来我就往之前XNU版本翻了翻,果然好懂了很多:

entry = map->first_free;

if (entry == vm_map_to_entry(map)) {
    entry = NULL;
} else {
       if (entry->vme_next == vm_map_to_entry(map)){
            entry = NULL;
       } else {
            if (start < (entry->vme_next)->vme_start ) {
                start = entry->vme_end;
                start = vm_map_round_page(start,
                              VM_MAP_PAGE_MASK(map));
            } else {
                entry = NULL;
            }
       }
}

if (entry == NULL) {
    vm_map_entry_t    tmp_entry;
    if (vm_map_lookup_entry(map, start, &tmp_entry)) {
        assert(!entry_for_jit);
        start = tmp_entry->vme_end;
        start = vm_map_round_page(start,
                      VM_MAP_PAGE_MASK(map));
    }
    entry = tmp_entry;
}
  • 整个这段代码的意思是,就是要我们要找个一个比我们这个start地址大的vm_entry_t。(这句话比较绕口),我们最终的目的是为了在两个已经存在vm_entry_t之间尝试插入一个能包含从startstart + size的新的vm_entry_t

  • 如果没找到的话,就尝试利用vm_map_lookup_entry找一个preceding我们地址的的vm_entry_t

好,现在我们找到了一个满足start其实地址条件的vm_entry_t了,剩下就是要满足分配大小size的需求了。

while (TRUE) {
    register vm_map_entry_t    next;

    end = ((start + mask) & ~mask);
    end = vm_map_round_page(end,
                VM_MAP_PAGE_MASK(map));
    if (end < start)
        RETURN(KERN_NO_SPACE);

    start = end;
    end += size;

    if ((end > effective_max_offset) || (end < start)) {
        RETURN(KERN_NO_SPACE);
    }

    next = entry->vme_next;

    // 如果是空的头
    if (next == vm_map_to_entry(map))
        break;

    // 如果下一个的start 
    if (next->vme_start >= end)
        break;

    entry = next;
    start = entry->vme_end;
    start = vm_map_round_page(start,
                  VM_MAP_PAGE_MASK(map));
}
*address = start;
assert(VM_MAP_PAGE_ALIGNED(*address,
               VM_MAP_PAGE_MASK(map)));

这段代码相对来说就很简单了,我们判断start + size是不是可以正好插入在vm_entry_t代表的地址范围的空隙内,如果一直遍历到最后的任务地址上限都找不到,那就说明不存在我们需求的连续的虚拟内存空间用于作分配了。

其他

除了本文说明的虚拟内存分配的连续性限制以外,虚拟内存作为堆内存分配的一种,在布局范围上也有限制。此文不表,且听下回分解。

一种基于KVO的页面加载,渲染耗时监控方法

打广告:有兴趣加入阿里巴巴手淘基础架构平台移动高可用团队的请微博联系我@盗版五子棋

和同事zb一起维护了一个ARM64的专栏iOS调试进阶,有兴趣的可以读读

在介绍本文之前,请先允许我提出一个问题,如果你要无痕监控任意一个页面(UIViewController及其子类)的加载或者渲染时间,你会怎么做。

很多人都会想到说用AOP啊,利用Method Swizzling来进行方法替换从而获得方法调用耗时。
比如我们有一个ViewController,如果其实现了一个viewDidLoad方法进行睡眠5秒,如下所示:

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    sleep(5);
}

@end

相信很多人的第一直觉会是如下AOP代码(我们省略Method Swizzling相关的代码):

@implementation UIViewController (TestCase)

+ (void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        wzq_swizzleMethod([UIViewController class], @selector(viewDidLoad), @selector(wzq_viewDidLoad));
    });
}

- (void)wzq_viewDidLoad
{
    NSDate *date = [NSDate date];
    [self wzq_viewDidLoad];

    NSTimeInterval duration = [[NSDate date] timeIntervalSinceDate:date];
    NSLog(@"Page %@ cost %g in viewDidLoad", [self class], duration); 
}

@end

但是,如果你自己尝试了你会发现,你测算的时间压根不是5秒。

为什么呢?其原因在于我们Method Swizzling的时候,因为采用了对基类UIViewController进行替换,获取到的viewDidLoad对应的IMP是属于基类UIViewController的,而并不是ViewController自身覆写的,所以我们监控的其实从子类ViewController调用[super viewDidLoad]的时候调用基类IMP的耗时。

好,看到这,有人就想了对应的方法,把-[ViewController viewDidLoad]的IMP替换掉就行了。方法很多种,比如创建一个ViewControllerCategory进行替换。但是这种方法你好像没办法任意对某个页面进行替换。

有人说你可以runtime遍历所有类判断是不是UIViewController的子类,然后动态替换。理论是可行的,效率嘛,是比较低的。

方案

根据上述我们所知的缺陷,我们需要有一个兼顾动态性和性能的方案,能够直接获取到子类的IMP,这样才能达到我们对于页面加载渲染时间(viewDidLoad, viewDidAppearviewWillAppear)监控的需求。

基于这个需求,我很快想到了基于KVO的方案(如果你对KVO不了解,可以参考我的文章:KVO在不同的二进制中多个符号并存的Crash问题)。我们知道,在对于任意对象进行KVO监控的时候,iOS底层实际上帮你动态创建了一个隐蔽的类,同时帮了做了大量的setter,getter函数的override,并调用原来类对应函数实现,从而让你神不知鬼不觉的以为你还在用原来的类进行操作。

那我们该怎么做呢?

  1. 对我们需要监听的类的实例进行KVO,随便监听一个不存在的KeyPath。我们压根不需要KVO的任何回调,我们只是需要它能帮我们创建子类而已。
  2. 对KVO创建出来的子类添加我们需要Swizzle的方法对应的SEL及其IMP。因为本质上KVO只是对setter和getter方法进行了override如果我们不提供我们自己的实现,还是会调用到原来的类的IMP。
  3. 在实例销毁的时候,将KVO监听移除,不然会导致KVO still registering when deallocated这样的Crash。

总体来说,我们需要做的就是三件事。

1. 对实例进行KVO

KVO方法只能在对象实例上进行操作,我们首先要获取到的就是UIViewController及其子类的实例。

遍历头文件,发现UIViewController的初始化方法比较少,归纳为如下三种:

init
initWithCoder:
initWithNibName:bundle:

我们先Swizzle这几个方法:

 wzq_swizzleMethod([UIViewController class], @selector(initWithNibName:bundle:), @selector(wzq_initWithNibName:bundle:));
wzq_swizzleMethod([UIViewController class], @selector(initWithCoder:), @selector(wzq_initWithCoder:));
wzq_swizzleMethod([UIViewController class], @selector(init), @selector(wzq_init));

这几个方法调用的时候,实例对象对应的内存已经分配出来了,无非就是构造函数还没赋值,但是我们也能进行KVO了。KVO的代码如下所示:

NSString *identifier = [NSString stringWithFormat:@"wzq_%@", [[NSProcessInfo processInfo] globallyUniqueString]];
[vc addObserver:[NSObject new] forKeyPath:identifier options:NSKeyValueObservingOptionNew context:nil];

2. 添加我们想要的方法

我们刚刚已经对页面实例进行了KVO操作,此时对于原先类别为ViewControllervc对象来说,内部其实已经变成NSKVONotifying_ViewController类型了。。如果我们想对其所在的类型添加方法的话,不能直接用[vc class],因为这个方法已经被内部override成了ViewController。我们需要使用object_getClass这个类进行真正的类型获取,如下所示:

 // NSKVONotifying_ViewController
Class kvoCls = object_getClass(vc);
// ViewController
Class originCls = class_getSuperclass(kvoCls);

// 获取原来实现的encoding
const char *originViewDidLoadEncoding = method_getTypeEncoding(class_getInstanceMethod(originCls, @selector(viewDidLoad)));
const char *originViewDidAppearEncoding = method_getTypeEncoding(class_getInstanceMethod(originCls, @selector(viewDidAppear:)));
const char *originViewWillAppearEncoding = method_getTypeEncoding(class_getInstanceMethod(originCls, @selector(viewWillAppear:)));

// 重点,添加方法。
class_addMethod(kvoCls, @selector(viewDidLoad), (IMP)wzq_viewDidLoad, originViewDidLoadEncoding);
class_addMethod(kvoCls, @selector(viewDidAppear:), (IMP)wzq_viewDidAppear, originViewDidAppearEncoding);
class_addMethod(kvoCls, @selector(viewWillAppear:), (IMP)wzq_viewWillAppear, originViewWillAppearEncoding);

上述代码非常通俗易懂,不再赘述,替换完的方法如下,我们以wzq_viewDidLoad举例:

static void wzq_viewDidLoad(UIViewController *kvo_self, SEL _sel)
{
    Class kvo_cls = object_getClass(kvo_self);
    Class origin_cls = class_getSuperclass(kvo_cls);

    // 注意点
    IMP origin_imp = method_getImplementation(class_getInstanceMethod(origin_cls, _sel));
    assert(origin_imp != NULL);

    void(*func)(UIViewController *, SEL) =  (void(*)(UIViewController *, SEL))origin_imp;

    NSDate *date = [NSDate date];

    func(kvo_self, _sel);

    NSTimeInterval duration = [[NSDate date] timeIntervalSinceDate:date];
    NSLog(@"Class %@ cost %g in viewDidLoad", [kvo_self class], duration);
}

重点关注下上述代码中的注意点,之前我们在KVO生成的类中对应添加了原本没有的实现,因此-[ViewController viewDidLoad]会走到我们的wzq_viewDidLoad方法中,但是我们怎么才能调用到原来的viewDidLoad的呢?我们之前并没有保存对应的IMP呀。

这里还是利用了KVO的特殊性:内部生成的NSKVONotifying_ViewController实际上是继承自ViewController的

因此,Class origin_cls = class_getSuperclass(kvo_cls);实际上获取到了ViewController类,我们从中取出对应的IMP,进行直接调用即可。

3. 移除KVO

我们利用Associate Object去移除就好了。一个对象释放的时候会自动去清除其所在的assoicate object

基于这个原理,我们可以实现如下代码:

我们构建一个桩,把所有无用的KVO监听都设置给这个桩,如下所示:

[vc addObserver:[WZQKVOObserverStub stub] forKeyPath:identifier options:NSKeyValueObservingOptionNew context:nil];

然后我们构建一个移除器,这个移除器弱引用保存了vc的实例和对应的keypath,如下:

WZQKVORemover *remover = [WZQKVORemover new];
remover.obj = vc;
remover.keyPath = identifier.copy;

然后我们把这个移除器利用associate object设置给对应的vc。

objc_setAssociatedObject(vc, &wzq_associateRemoveKey, remover, OBJC_ASSOCIATION_RETAIN);

而在对应的移除器的dealloc方法里,我们把kvo监听给移除就可以了。

- (void)dealloc
{
#ifdef DEBUG
    NSLog(@"WZQKVORemover called");
#endif
    if (_obj) {
        [_obj removeObserver:[WZQKVOObserverStub stub] forKeyPath:_keyPath];
    }
}

额外

利用associate object移除KVO的正确性是有保障的,具体见runtime中associate object的源码:

void objc_removeAssociatedObjects(id object) 
{
    if (object && object->hasAssociatedObjects()) {
        _object_remove_assocations(object);
    }
}

void _object_remove_assocations(id object) {
    vector< ObjcAssociation,ObjcAllocator<ObjcAssociation> > elements;
    {
        AssociationsManager manager;
        AssociationsHashMap &associations(manager.associations());
        if (associations.size() == 0) return;
        disguised_ptr_t disguised_object = DISGUISE(object);
        AssociationsHashMap::iterator i = associations.find(disguised_object);
        if (i != associations.end()) {
            // copy all of the associations that need to be removed.
            ObjectAssociationMap *refs = i->second;
            for (ObjectAssociationMap::iterator j = refs->begin(), end = refs->end(); j != end; ++j) {
                elements.push_back(j->second);
            }
            // remove the secondary table.
            delete refs;
            associations.erase(i);
        }
    }
    // the calls to releaseValue() happen outside of the lock.
    for_each(elements.begin(), elements.end(), ReleaseValue());
}

代码

本文的工程代码托管在Github上,包含了测试用例(默认带干扰测试),还没来得及搞成cocoapods,等我有时间了搞一下。但是你直接拖拽源码使用是一点问题都没有。

注意系统库的坑之load函数调用多次

水文一篇

今天在群友逆向企业微信的时候,发现了一个比较有意思的现象了,发现对于NSObject添加的load的方法执行了两次,导致原本意图的Swizzle出现了问题。

之前在个人的理解中,loadinitialize函数有所不同,load是在加载二进制程序的时候,将这些二进制程序中的类中包含的load方法进行一一调用,调用过程中不会有调用父类的情况。而initialize则不同,是在类第一次使用的过程中进行调用,同时也会有过程中调用父类的情况。

所以,今天一开始这个情况有点懵逼啊,来看看究竟是为啥。

准备工作(略)

  1. PP助手上下载一个企业微信
  2. 重签名 -> Build
  3. 写一个诸如下面这么简单的NSObject Category,并实现+(void)Load方法

    @implementation NSObject (injectLocation)
    + (void)load
    {
        NSLog(@"我好弱");
    }
    @end
    

排查过程

按照我们对load函数的理解,程序加载开始的时候,会通过libobjccall_load_methods遍历逐一执行所有的load方法,如下图印证:

一开始当我在使用iOS 10.3.3的设备进行测试的时候,这就是唯一一次调用,没有二次重入的状况。

于是我按照群友的提示换了iOS 11的设备,果不其然,iOS 11的企业微信在登录过程中,会再次调用我这个分类的load方法,让我们一起来看看调用栈:

卧槽,又从WebThread这个类里面进行了调用了load,匪夷所思啊。

lldb调试下,结果如下:

frame #0: 0x0000000107a2558c libZXLQYWechatDylib.dylib`+[NSObject(self=SKUIMetricsAppLaunchEvent, _cmd="load") load] at TestCategory.m:15
frame #1: 0x0000000196767f9c StoreKitUI`+[SKUIMetricsAppLaunchEvent load] + 44
frame #2: 0x00000001807fa91c libobjc.A.dylib`call_load_methods + 184
frame #3: 0x00000001807fba84 libobjc.A.dylib`load_images + 76
frame #4: 0x00000001074e6170 dyld`dyld::notifySingle(dyld_image_states, ImageLoader const*, ImageLoader::InitializerTimingList*) + 488
frame #5: 0x00000001074f6ce8 dyld`ImageLoader::recursiveInitialization(ImageLoader::LinkContext const&, unsigned int, char const*, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&) + 348
frame #6: 0x00000001074f6c90 dyld`ImageLoader::recursiveInitialization(ImageLoader::LinkContext const&, unsigned int, char const*, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&) + 260
frame #7: 0x00000001074f6c90 dyld`ImageLoader::recursiveInitialization(ImageLoader::LinkContext const&, unsigned int, char const*, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&) + 260
frame #8: 0x00000001074f6c90 dyld`ImageLoader::recursiveInitialization(ImageLoader::LinkContext const&, unsigned int, char const*, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&) + 260
frame #9: 0x00000001074f5d40 dyld`ImageLoader::processInitializers(ImageLoader::LinkContext const&, unsigned int, ImageLoader::InitializerTimingList&, ImageLoader::UninitedUpwards&) + 136
frame #10: 0x00000001074f5dfc dyld`ImageLoader::runInitializers(ImageLoader::LinkContext const&, ImageLoader::InitializerTimingList&) + 84
frame #11: 0x00000001074e979c dyld`dyld::runInitializers(ImageLoader*) + 88
frame #12: 0x00000001074f0324 dyld`dlopen + 976
frame #13: 0x0000000180ccf4d4 libdyld.dylib`dlopen + 116
frame #14: 0x0000000189caec58 WebCore`initWebFilterEvaluator() + 36

从上述链路看起来:WebCore通过dlopen加载了/System/Library/PrivateFrameworks/StoreKitUI.framework/StoreKitUI这个动态库,然后动态库加载完成后,执行了和主二进制一样的call_load_methods过程。

逐一执行load的过程中,会调用到这个类SKUIMetricsAppLaunchEvent,然后这个类执行的汇编我们看看:

StoreKitUI`+[SKUIMetricsAppLaunchEvent load]:
->  0x196767f70 <+0>:  sub    sp, sp, #0x20             ; =0x20 
    0x196767f74 <+4>:  stp    x29, x30, [sp, #0x10]
    0x196767f78 <+8>:  add    x29, sp, #0x10            ; =0x10 
    0x196767f7c <+12>: str    x0, [sp]
    0x196767f80 <+16>: adrp   x8, 108130
    0x196767f84 <+20>: ldr    x8, [x8, #0xff8]
    0x196767f88 <+24>: str    x8, [sp, #0x8]
    0x196767f8c <+28>: adrp   x8, 108114
    0x196767f90 <+32>: ldr    x1, [x8, #0xf70]
    0x196767f94 <+36>: mov    x0, sp
    0x196767f98 <+40>: bl     0x1902ccaac
    0x196767f9c <+44>: adrp   x8, 111804
    0x196767fa0 <+48>: ldr    x8, [x8, #0x6e0]
    0x196767fa4 <+52>: cmn    x8, #0x1                  ; =0x1 
    0x196767fa8 <+56>: b.ne   0x196767fb8               ; <+72>
    0x196767fac <+60>: ldp    x29, x30, [sp, #0x10]
    0x196767fb0 <+64>: add    sp, sp, #0x20             ; =0x20 
    0x196767fb4 <+68>: ret    
    0x196767fb8 <+72>: adrp   x0, 111804
    0x196767fbc <+76>: add    x0, x0, #0x6e0            ; =0x6e0 
    0x196767fc0 <+80>: adrp   x1, 93832
    0x196767fc4 <+84>: add    x1, x1, #0xf60            ; =0xf60 
    0x196767fc8 <+88>: bl     0x19684f598               ; symbol stub for: __copy_helper_block_.236
    0x196767fcc <+92>: b      0x196767fac               ; <+60>

看起来没有关键字stub for objc_msgSend之类的关键字,那我们就重点关注几个跳转指令对应的地址。

排除掉 b 0x196767facb.ne 0x196767fb8,因为这两地址就属于本函数。

通过lldb一查询看看剩下的0x1902ccaac是干啥的,卧槽,没结果。那干脆断这个地址试试,然后继续执行,得到如下结果:

0x1902ccaac: b      0x1886362ac
0x1902ccab0: b      0x188637ae8
0x1902ccab4: b      0x1886362e8
0x1902ccab8: b      0x1886365a4
0x1902ccabc: b      0x18863ada8
0x1902ccac0: b      0x1886365b4
0x1902ccac4: b      0x18863889c
0x1902ccac8: b      0x188636b2c

好吧,看起来这是运行时创建的桥(trampoline)。继续断0x1886362ac,然后执行:

0x1886362ac: b      0x18080c620               ; objc_msgSendSuper2
0x1886362b0: b      0x180814250               ; objc_release
0x1886362b4: b      0x180814190               ; objc_retain
0x1886362b8: b      0x1808165f0               ; objc_retainAutorelease
0x1886362bc: b      0x180816558               ; objc_retainAutoreleaseReturnValue
0x1886362c0: b      0x180816588               ; objc_retainAutoreleasedReturnValue
0x1886362c4: b      0x180802fa8               ; class_addMethod
0x1886362c8: b      0x18080157c               ; class_getInstanceMethod

哈哈,看到我们想要的代码了:

0x1886362ac: b 0x18080c620 ; objc_msgSendSuper2

从这段汇编不难看出,在+[SKUIMetricsAppLaunchEvent load]方法里面,会调用[super load]这样的代码。

为啥iOS 10上没有问题

在iOS 10上其实也有同样的问题,但是由于WebCore不会主动把对应的StoreKitUI加载进来,所以也就没出触发这样的问题,但是如果我们主动通过dlopen加载这个系统库,也一样有问题:

__attribute__((constructor)) void load_private()
{
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        void *libHandleIMD = dlopen("/System/Library/PrivateFrameworks/StoreKitUI.framework/StoreKitUI", RTLD_LAZY);
        NSLog(@"libHandleIMD is %p", libHandleIMD);
        if (!libHandleIMD) {
            printf("error is %s\n", dlerror());
        }
    });
}

提醒

对于在系统类上添加的load方法,建议还是做是否是重入的判断或者保护,不然很可能出现与预期不相符的结果。

iOS内存abort(Jetsam) 原理探究

招人

手淘架构组招人 iOS/Android 皆可,地点杭州,有兴趣的请联系我!!

iOS内存abort(Jetsam) 原理探究

苹果最近开源了iOS系统上的XNU内核代码,加上最近又开始负责手淘/猫客的稳定性及性能相关的工作,所以赶紧拜读下苹果的大作。今天主要开始想分析跟abort相关的内存Jetsam原理。

什么是Jetsam

关于Jetsam,可能有些人还不是很理解。我们可以从手机设置->隐私->分析这条路径看看系统的日志,会发现手机上有许多JetsamEvent开头的日志。打开这些日志,一般会显示一些内存大小,CPU时间什么的数据。

之所以会发生这么JetsamEvent,主要还是由于iOS设备不存在交换区导致的内存受限,所以iOS内核不得不把一些优先级不高或者占用内存过大的杀掉。这些JetsamEvent就是系统在杀掉App后记录的一些数据信息。

从某种程度来说,JetsamEvent是一种另类的Crash事件,但是在常规的Crash捕获工具中,由于iOS上能捕获的信号量的限制,所以因为内存导致App被杀掉是无法被捕获的。为此,许多业界的前辈通过设计flag的方式自己记录所谓的abort事件来采集数据。但是这种采集的abort,一般情况下都只能简单的记录次数,而没有详细的堆栈。

源码探究

MacOS/iOS是一个从BSD衍生而来的系统。其内核是Mach,但是对于上层暴露的接口一般都是基于BSD层对于Mach包装后的。虽然说Mach是个微内核的架构,真正的虚拟内存管理是在其中进行,但是BSD对于内存管理提供了相对较为上层的接口,同时,各种常见的JetSam事件也是由BSD产生,所以,我们从bsd_init这个函数作为入口,来探究下原理。

bsd_init中基本都是在初始化各个子系统,比如虚拟内存管理等等。

跟内存相关的包括如下几步可能:

1. 初始化BSD内存Zone,这个Zone是基于Mach内核的zone构建
kmeminit();

2. iOS上独有的特性,内存和进程的休眠的常驻监控线程
#if CONFIG_FREEZE
#ifndef CONFIG_MEMORYSTATUS
    #error "CONFIG_FREEZE defined without matching CONFIG_MEMORYSTATUS"
#endif
    /* Initialise background freezing */
    bsd_init_kprintf("calling memorystatus_freeze_init\n");
    memorystatus_freeze_init();
#endif>

3. iOS独有,JetSAM(即低内存事件的常驻监控线程)
#if CONFIG_MEMORYSTATUS
    /* Initialize kernel memory status notifications */
    bsd_init_kprintf("calling memorystatus_init\n");
    memorystatus_init();
#endif /* CONFIG_MEMORYSTATUS */

这两步代码都是调用kern_memorystatus.c里面暴露的接口,主要的作用就是从内核中开启了两个最高优先级的线程,来监控整个系统的内存情况。

首先先来看看CONFIG_FREEZE涉及的功能。当启用这个效果的时候,内核会对进程进行冷冻而不是Kill。

这个冷冻的功能是通过在内核中启动一个memorystatus_freeze_thread进行。这个线程在收到信号后调用memorystatus_freeze_top_process进行冷冻。

当然,涉及到进程休眠相关的代码,就需要谈谈苹果系统里面其他相关概念了。扯开又是一个比较大的话题,后续单独开文章来进行阐述。

回到iOS Abort问题上的话,我们只需要关注memorystatus_init即可,去除平台无关的代码后如下:

__private_extern__ void
memorystatus_init(void)
{
    thread_t thread = THREAD_NULL;
    kern_return_t result;
    int i;

    /* Init buckets */
    // 注意点1:优先级数组,每个数组都持有了一个同优先级进程的列表
    for (i = 0; i < MEMSTAT_BUCKET_COUNT; i++) {
        TAILQ_INIT(&memstat_bucket[i].list);
        memstat_bucket[i].count = 0;
    }
    memorystatus_idle_demotion_call = thread_call_allocate((thread_call_func_t)memorystatus_perform_idle_demotion, NULL);

#if CONFIG_JETSAM

    nanoseconds_to_absolutetime((uint64_t)DEFERRED_IDLE_EXIT_TIME_SECS * NSEC_PER_SEC, &memorystatus_sysprocs_idle_delay_time);
    nanoseconds_to_absolutetime((uint64_t)DEFERRED_IDLE_EXIT_TIME_SECS * NSEC_PER_SEC, &memorystatus_apps_idle_delay_time);

    /* Apply overrides */
    // 注意点2:获取一系列内核参数
    PE_get_default("kern.jetsam_delta", &delta_percentage, sizeof(delta_percentage));
    if (delta_percentage == 0) {
        delta_percentage = 5;
    }
    assert(delta_percentage < 100);
    PE_get_default("kern.jetsam_critical_threshold", &critical_threshold_percentage, sizeof(critical_threshold_percentage));
    assert(critical_threshold_percentage < 100);
    PE_get_default("kern.jetsam_idle_offset", &idle_offset_percentage, sizeof(idle_offset_percentage));
    assert(idle_offset_percentage < 100);
    PE_get_default("kern.jetsam_pressure_threshold", &pressure_threshold_percentage, sizeof(pressure_threshold_percentage));
    assert(pressure_threshold_percentage < 100);
    PE_get_default("kern.jetsam_freeze_threshold", &freeze_threshold_percentage, sizeof(freeze_threshold_percentage));
    assert(freeze_threshold_percentage < 100);

    if (!PE_parse_boot_argn("jetsam_aging_policy", &jetsam_aging_policy,
            sizeof (jetsam_aging_policy))) {

        if (!PE_get_default("kern.jetsam_aging_policy", &jetsam_aging_policy,
                sizeof(jetsam_aging_policy))) {

            jetsam_aging_policy = kJetsamAgingPolicyLegacy;
        }
    }

    if (jetsam_aging_policy > kJetsamAgingPolicyMax) {
        jetsam_aging_policy = kJetsamAgingPolicyLegacy;
    }

    switch (jetsam_aging_policy) {

        case kJetsamAgingPolicyNone:
            system_procs_aging_band = JETSAM_PRIORITY_IDLE;
            applications_aging_band = JETSAM_PRIORITY_IDLE;
            break;

        case kJetsamAgingPolicyLegacy:
            /*
             * Legacy behavior where some daemons get a 10s protection once
             * AND only before the first clean->dirty->clean transition before
             * going into IDLE band.
             */
            system_procs_aging_band = JETSAM_PRIORITY_AGING_BAND1;
            applications_aging_band = JETSAM_PRIORITY_IDLE;
            break;

        case kJetsamAgingPolicySysProcsReclaimedFirst:
            system_procs_aging_band = JETSAM_PRIORITY_AGING_BAND1;
            applications_aging_band = JETSAM_PRIORITY_AGING_BAND2;
            break;

        case kJetsamAgingPolicyAppsReclaimedFirst:
            system_procs_aging_band = JETSAM_PRIORITY_AGING_BAND2;
            applications_aging_band = JETSAM_PRIORITY_AGING_BAND1;
            break;

        default:
            break;
    }

    /*
     * The aging bands cannot overlap with the JETSAM_PRIORITY_ELEVATED_INACTIVE
     * band and must be below it in priority. This is so that we don't have to make
     * our 'aging' code worry about a mix of processes, some of which need to age
     * and some others that need to stay elevated in the jetsam bands.
     */
    assert(JETSAM_PRIORITY_ELEVATED_INACTIVE > system_procs_aging_band);
    assert(JETSAM_PRIORITY_ELEVATED_INACTIVE > applications_aging_band);

    /* Take snapshots for idle-exit kills by default? First check the boot-arg... */
    if (!PE_parse_boot_argn("jetsam_idle_snapshot", &memorystatus_idle_snapshot, sizeof (memorystatus_idle_snapshot))) {
            /* ...no boot-arg, so check the device tree */
            PE_get_default("kern.jetsam_idle_snapshot", &memorystatus_idle_snapshot, sizeof(memorystatus_idle_snapshot));
    }

    memorystatus_delta = delta_percentage * atop_64(max_mem) / 100;
    memorystatus_available_pages_critical_idle_offset = idle_offset_percentage * atop_64(max_mem) / 100;
    memorystatus_available_pages_critical_base = (critical_threshold_percentage / delta_percentage) * memorystatus_delta;
    memorystatus_policy_more_free_offset_pages = (policy_more_free_offset_percentage / delta_percentage) * memorystatus_delta;

    /* Jetsam Loop Detection */
    if (max_mem <= (512 * 1024 * 1024)) {
        /* 512 MB devices */
        memorystatus_jld_eval_period_msecs = 8000;    /* 8000 msecs == 8 second window */
    } else {
        /* 1GB and larger devices */
        memorystatus_jld_eval_period_msecs = 6000;    /* 6000 msecs == 6 second window */
    }

    memorystatus_jld_enabled = TRUE;

    /* No contention at this point */
    memorystatus_update_levels_locked(FALSE);

#endif /* CONFIG_JETSAM */

    memorystatus_jetsam_snapshot_max = maxproc;
    memorystatus_jetsam_snapshot = 
        (memorystatus_jetsam_snapshot_t*)kalloc(sizeof(memorystatus_jetsam_snapshot_t) +
        sizeof(memorystatus_jetsam_snapshot_entry_t) * memorystatus_jetsam_snapshot_max);
    if (!memorystatus_jetsam_snapshot) {
        panic("Could not allocate memorystatus_jetsam_snapshot");
    }

    nanoseconds_to_absolutetime((uint64_t)JETSAM_SNAPSHOT_TIMEOUT_SECS * NSEC_PER_SEC, &memorystatus_jetsam_snapshot_timeout);

    memset(&memorystatus_at_boot_snapshot, 0, sizeof(memorystatus_jetsam_snapshot_t));

    result = kernel_thread_start_priority(memorystatus_thread, NULL, 95 /* MAXPRI_KERNEL */, &thread);
    if (result == KERN_SUCCESS) {
        thread_deallocate(thread);
    } else {
        panic("Could not create memorystatus_thread");
    }
}

下面先介绍几个知识点

  • 内核里面对于所有的进程都有一个优先级的分布,通过一个数组维护,数组每一项是一个进程的list。这个数组的大小是JETSAM_PRIORITY_MAX + 1。其结构体定义如下:

    typedef struct memstat_bucket {
        TAILQ_HEAD(, proc) list;
        int count;
    } memstat_bucket_t;
    

    这结构体非常通俗易懂。

  • 线程在Mach下采用了不同的优先级,其中MAXPRI_KERNEL代表的是分配给内核可用范围内最高优先级的线程。其他级别还有如下这些:


* // 优先级最高的实时线程 (不太清楚谁用)
 * 127        Reserved (real-time)
 *                A
 *                +
 *            (32 levels)
 *                +
 *                V
 * 96        Reserved (real-time)
 * // 给内核用的线程优先级(MAXPRI_KERNEL)
 * 95        Kernel mode only
 *                A
 *                +
 *            (16 levels)
 *                +
 *                V
 * 80        Kernel mode only
 * // 给操作系统分配的线程优先级
 * 79        System high priority
 *                A
 *                +
 *            (16 levels)
 *                +
 *                V
 * 64        System high priority
 * // 剩下的全是用户态的普通程序可以用的
 * 63        Elevated priorities
 *                A
 *                +
 *            (12 levels)
 *                +
 *                V
 * 52        Elevated priorities
 * 51        Elevated priorities (incl. BSD +nice)
 *                A
 *                +
 *            (20 levels)
 *                +
 *                V
 * 32        Elevated priorities (incl. BSD +nice)
 * 31        Default (default base for threads)
 * 30        Lowered priorities (incl. BSD -nice)
 *                A
 *                +
 *            (20 levels)
 *                +
 *                V
 * 11        Lowered priorities (incl. BSD -nice)
 * 10        Lowered priorities (aged pri's)
 *                A
 *                +
 *            (11 levels)
 *                +
 *                V
 * 0        Lowered priorities (aged pri's / idle)
 *************************************************************************
  • 从上图不难看出,用户态的应用程序的线程可能高于操作系统和内核。而且,在用户态的应用程序间的线程优先级分配也有区别,前台活动的应用程序优先级高于后台的应用程序。iOS上大名鼎鼎的SpringBoard是应用程序中优先级最高的程序。
  • 当然线程的优先级也不是一成不变。Mach会针对每一个线程的利用率和整体系统负载动态调整优先级。如果耗费CPU太多就降低优先级,如果一个线程过度挨饿CPU则会提升其优先级。但是无论怎么变,程序都不能超过其所在的线程优先级区间范围。

好,预备知识说完,那苹果究竟是怎么处理JetSam事件呢?

result = kernel_thread_start_priority(memorystatus_thread, NULL, 95 /* MAXPRI_KERNEL */, &thread);

苹果其实处理的思路非常简单。如上述代码,BSD层起了一个内核优先级最高的线程VM_memorystatus,这个线程会在维护两个列表,一个是我们之前提到的基于进程优先级的进程列表,还有一个是所谓的内存快照列表,即保存了每个进程消耗的内存页memorystatus_jetsam_snapshot

这个常驻线程接受从内核对于内存的守护程序pageout通过内核调用给每个App进程发送的内存压力通知,来处理事件,这个事件转发成上层的UI事件就是平常我们会收到的全局内存警告或者每个ViewController里面的didReceiveMemoryWarning

当然,我们自己开发的App是不会主动注册监听这个内存警告事件的,帮助我们在底层完成这一切的都是libdispatch,如果你感兴趣的话,可以钻研下_dispatch_source_type_memorypressure__dispatch_source_type_memorystatus

那么在哪些情况下会出现内存压力呢?我们来看一看memorystatus_action_needed这段函数:

static boolean_t
memorystatus_action_needed(void)
{
#if CONFIG_EMBEDDED
    return (is_reason_thrashing(kill_under_pressure_cause) ||
            is_reason_zone_map_exhaustion(kill_under_pressure_cause) ||
           memorystatus_available_pages <= memorystatus_available_pages_pressure);
#else /* CONFIG_EMBEDDED */
    return (is_reason_thrashing(kill_under_pressure_cause) ||
            is_reason_zone_map_exhaustion(kill_under_pressure_cause));
#endif /* CONFIG_EMBEDDED */
}

概括来说:

频繁的的页面换进换出is_reason_thrashing,Mach Zone耗尽了is_reason_zone_map_exhaustion(这个涉及Mach内核的虚拟内存管理了,单独写)以及可用的页低于一个门槛了memorystatus_available_pages

在这几种情况下,就会准备去Kill 进程了。但是,在这个处理下面,有一段代码特别有意思,我们看看这个函数memorystatus_act_aggressive

if ( (jld_bucket_count == 0) || 
     (jld_now_msecs > (jld_timestamp_msecs + memorystatus_jld_eval_period_msecs))) {

    /* 
     * Refresh evaluation parameters 
     */
    jld_timestamp_msecs     = jld_now_msecs;
    jld_idle_kill_candidates = jld_bucket_count;
    *jld_idle_kills         = 0;
    jld_eval_aggressive_count = 0;
    jld_priority_band_max    = JETSAM_PRIORITY_UI_SUPPORT;
}

这段代码很明显,是基于某个时间间隔在做条件判断。如果不满足这个判断,后续真正执行的Kill也不会走到。那我们来看看memorystatus_jld_eval_period_msecs这个变量:

/* Jetsam Loop Detection */
if (max_mem <= (512 * 1024 * 1024)) {
    /* 512 MB devices */
    memorystatus_jld_eval_period_msecs = 8000;    /* 8000 msecs == 8 second window */
} else {
    /* 1GB and larger devices */
    memorystatus_jld_eval_period_msecs = 6000;    /* 6000 msecs == 6 second window */
}

这个时间窗口是根据设备的物理内存上限来设定的,但是无论如何,看起来至少有个6秒的时间可以给我们来做点事情。

当然,如果满足了时间窗口的需求,就会根据我们提到的优先级进程列表进行寻找可杀目标:

proc_list_lock();
switch (jetsam_aging_policy) {
case kJetsamAgingPolicyLegacy:
    bucket = &memstat_bucket[JETSAM_PRIORITY_IDLE];
    jld_bucket_count = bucket->count;
    bucket = &memstat_bucket[JETSAM_PRIORITY_AGING_BAND1];
    jld_bucket_count += bucket->count;
    break;
case kJetsamAgingPolicySysProcsReclaimedFirst:
case kJetsamAgingPolicyAppsReclaimedFirst:
    bucket = &memstat_bucket[JETSAM_PRIORITY_IDLE];
    jld_bucket_count = bucket->count;
    bucket = &memstat_bucket[system_procs_aging_band];
    jld_bucket_count += bucket->count;
    bucket = &memstat_bucket[applications_aging_band];
    jld_bucket_count += bucket->count;
    break;
case kJetsamAgingPolicyNone:
default:
    bucket = &memstat_bucket[JETSAM_PRIORITY_IDLE];
    jld_bucket_count = bucket->count;
    break;
}

bucket = &memstat_bucket[JETSAM_PRIORITY_ELEVATED_INACTIVE];
elevated_bucket_count = bucket->count;

需要注意的是,JETSAM不一定只杀一个进程,他可能会大杀特杀,杀掉N多进程。

if (memorystatus_avail_pages_below_pressure()) {
    /*
     * Still under pressure.
     * Find another pinned processes.
     */
    continue;
} else {
    return TRUE;
}

至于杀进程的话,最终都会落到函数memorystatus_do_kill->jetsam_do_kill去执行。

其他

看苹果代码的时候,发现了不少内核的参数,一一进行了尝试后,发现sysctlnamesysctl的系统调用都被苹果禁用了,比如这些:

"kern.jetsam_delta"
"kern.jetsam_critical_threshold"
"kern.jetsam_idle_offset"
"kern.jetsam_pressure_threshold"
"kern.jetsam_freeze_threshold"
"kern.jetsam_aging_policy"

不过,我试了下通过kern.boottime获取机器的开机时间还是可以的,代码示例如下:

size_t size;
sysctlbyname("kern.boottime", NULL, &size, NULL, 0);

char *boot_time = malloc(size);
sysctlbyname("kern.boottime", boot_time, &size, NULL, 0);

uint32_t timestamp = 0;
memcpy(&timestamp, boot_time, sizeof(uint32_t));
free(boot_time);

NSDate* bootTime = [NSDate dateWithTimeIntervalSince1970:timestamp];

最后

嘻嘻,技术原理研究了一些,心里顿时对解决公司的Abort问题有了一定的眉目。嘿嘿,我写了个DEMO验证了我的思路,是可行的。哇咔咔。等我的好消息吧~

基于桥的全量方法Hook方案 - 探究苹果主线程检查实现

最近随着iOS11的正式发布,手淘/天猫也开始逐步用Xcode 9开始编译。在调试过程中,很多同事发现经常报许多API会报线程使用错误的问题。摸索了下,发现是Xcode 9里面带上了一个叫libMainThreadChecker.dylib的动态库,在运行时提供了主线程检查的功能,今天就从探究苹果的实现开始讲起。

0x1 苹果的实现

把苹果的动态库拖入hopper里面看看,基本上扫一眼以后,比较可疑的是__library_initializer__library_deintializer

我看反汇编,第一直觉就是猜,然后都试一把。

我们来看看其伪代码实现,可以分为几个部分来探究:

1.1 环境变量

从图中不难看出,libMainThreadChecker的运行依赖于许多的环境变量,我们可以在Xcode->Scheme->Arguments里面一个个输入这些变量进行测试,我发现比较重要的是MTC_VERBOSE这个参数,使用后,可以输出究竟对于哪些类进行了线程监控。

...
Swizzling class: UIKeyboardEmojiCollectionViewCell
Swizzling class: UIKeyboardEmojiSectionHeader
Swizzling class: UIPrinterSetupPINScrollView
Swizzling class: UIPrinterSetupPINView
Swizzling class: UIPrinterSetupConnectingView
Swizzling class: UICollectionViewTableHeaderFooterView
Swizzling class: UIPrinterSetupDisplayPINView
Swizzling class: UIStatusBarMapsCompassItemView
Swizzling class: UIStatusBarCarPlayTimeItemView
Swizzling class: UIKeyboardCandidateBarCell
Swizzling class: UIKeyboardCandidateBarCell_SecondaryCandidate
Swizzling class: UIActionSheetiOSDismissActionView
Swizzling class: UIKeyboardCandidateFloatingArrowView
Swizzling class: UIKeyboardCandidateGridOverlayBackgroundView
Swizzling class: UIKeyboardCandidateGridHeaderContainerView
Swizzling class: UIStatusBarBreadcrumbItemView
Swizzling class: UIInterfaceActionGroupView
Swizzling class: UIKeyboardFlipTransitionView
Swizzling class: UIKeyboardAssistantBar
Swizzling class: UITextMagnifier
Swizzling class: UIKeyboardSliceTransitionView
Swizzling class: UIWKSelectionView
Swizzled 10717 methods in 384 classes.

可以看出,苹果会在启动前对于这些类进行所谓的线程监控。

1.2 逻辑

看完了输出,我们来看看其中的逻辑实现,如下所示:

CFAbsoluteTimeGetCurrent();
   var_270 = intrinsic_movsd(var_270, xmm0);
   *_indirect__main_thread_checker_on_report = dlsym(0xfffffffffffffffd, "__main_thread_checker_on_report");
   if (objc_getClass("UIView") != 0x0) {
           *_XXKitImage = dyld_image_header_containing_address(objc_getClass("UIView"));
           *_CoreFoundationImage = dyld_image_header_containing_address(_CFArrayGetCount);
           rax = objc_getClass("WKWebView");
           rax = dyld_image_header_containing_address(rax);
           *_WebKitImage = rax;
           *_InlineCallsMachHeaders = *_XXKitImage;
           *0x1ec3e8 = *_CoreFoundationImage;
           *0x1ec3f0 = rax;
           *___CATransaction = objc_getClass("CATransaction");
           *___NSGraphicsContext = objc_getClass("NSGraphicsContext");
           *_SEL_currentState = sel_registerName("currentState");
           *_SEL_currentContext = sel_registerName("currentContext");
           *_MyOwnMachHeader = dyld_image_header_containing_address(___library_initializer);
           *_classesToSwizzle = CFArrayCreateMutable(0x0, 0x200, 0x0);
           var_240 = objc_getClass("UIView");
           _FindClassesToSwizzleInImage(*_XXKitImage, &var_240, 0x2);
           if (*_WebKitImage != 0x0) {
                   var_230 = objc_getClass("WKWebView");
                   *(&var_230 + 0x8) = objc_getClass("WKWebsiteDataStore");
                   *(&var_230 + 0x10) = objc_getClass("WKUserScript");
                   *(&var_230 + 0x18) = objc_getClass("WKUserContentController");
                   *(&var_230 + 0x20) = objc_getClass("WKScriptMessage");
                   *(&var_230 + 0x28) = objc_getClass("WKProcessPool");
                   *(&var_230 + 0x30) = objc_getClass("WKProcessGroup");
                   *(&var_230 + 0x38) = objc_getClass("WKContentExtensionStore");
                   _FindClassesToSwizzleInImage(*_WebKitImage, &var_230, 0x8);
           }
           rcx = CFArrayGetCount(*_classesToSwizzle);
           if (rcx != 0x0) {
                   rax = 0x0;
                   var_278 = rcx;
                   do {
                           var_288 = rax;
                           rax = CFArrayGetValueAtIndex(*_classesToSwizzle, rax);
                           var_258 = rax;
                           rbx = objc_getClass(rax);
                           var_290 = dyld_image_header_containing_address(rbx);
                           var_230 = 0x0;
                           var_280 = rbx;
                           r14 = class_copyMethodList(rbx, &var_230);
                           if (var_230 != 0x0) {
                                   rbx = 0x0;
                                   do {
                                           r13 = *(r14 + rbx * 0x8);
                                           r12 = method_getName(r13);
                                           r15 = sel_getName(r12);
                                           if ((((((((((((((((*(int8_t *)r15 != 0x5f) && (dyld_image_header_containing_address(method_getImplementation(r13)) == var_290)) && (((*(int8_t *)_envIgnoreRetainRelease == 0x0) || (((strcmp(r15, "retain") != 0x0) && (strcmp(r15, "release") != 0x0)) && (strcmp(r15, "autorelease") != 0x0))))) && (((*(int8_t *)_envIgnoreDealloc == 0x0) || ((strcmp(r15, "dealloc") != 0x0) && (strcmp(r15, ".cxx_destruct") != 0x0))))) && (((*(int8_t *)_envIgnoreNSObjectThreadSafeMethods == 0x0) || ((((strcmp(r15, "description") != 0x0) && (strcmp(r15, "debugDescription") != 0x0)) && (strcmp(r15, "self") != 0x0)) && (strcmp(r15, "class") != 0x0))))) && (strcmp(r15, "beginBackgroundTaskWithExpirationHandler:") != 0x0)) && (strcmp(r15, "beginBackgroundTaskWithName:expirationHandler:") != 0x0)) && (strcmp(r15, "endBackgroundTask:") != 0x0)) && (strcmp(r15, "lockFocus") != 0x0)) && (strcmp(r15, "lockFocusIfCanDraw") != 0x0)) && (strcmp(r15, "lockFocusIfCanDrawInContext:") != 0x0)) && (strcmp(r15, "unlockFocus") != 0x0)) && (strcmp(r15, "openGLContext") != 0x0)) && (strncmp(r15, "webThread", 0x9) != 0x0)) && (strncmp(r15, "nsli_", 0x5) != 0x0)) && (strncmp(r15, "nsis_", 0x5) != 0x0)) {
                                                   if (*_userSuppressedClasses != 0x0) {
                                                           rax = CFStringCreateWithCStringNoCopy(0x0, var_258, 0x8000100, *_kCFAllocatorNull);
                                                           var_244 = CFSetContainsValue(*_userSuppressedClasses, rax) != 0x0 ? 0x1 : 0x0;
                                                           CFRelease(rax);
                                                   }
                                                   else {
                                                           var_244 = 0x0;
                                                   }
                                                   if (*_userSuppressedSelectors != 0x0) {
                                                           rax = CFStringCreateWithCStringNoCopy(0x0, r15, 0x8000100, *_kCFAllocatorNull);
                                                           var_250 = rax;
                                                           if (CFSetContainsValue(*_userSuppressedSelectors, rax) != 0x0) {
                                                                   var_244 = 0x1;
                                                           }
                                                           CFRelease(var_250);
                                                   }
                                                   if (*_userSuppressedMethods != 0x0) {
                                                           rax = CFStringCreateWithFormat(0x0, 0x0, @"-[%s %s]");
                                                           var_250 = CFSetContainsValue(*_userSuppressedMethods, rax);
                                                           CFRelease(rax);
                                                           rax = var_250 | var_244;
                                                           if (rax == 0x0) {
                                                                   _addSwizzler(r13, r12, var_258, r15, 0x1);
                                                           }
                                                           else {
                                                                   *_userSuppressionsCount = *_userSuppressionsCount + 0x1;
                                                           }
                                                   }
                                                   else {
                                                           if (var_244 != 0x0) {
                                                                   *_userSuppressionsCount = *_userSuppressionsCount + 0x1;
                                                           }
                                                           else {
                                                                   _addSwizzler(r13, r12, var_258, r15, 0x1);
                                                           }
                                                   }
                                           }
                                           rbx = rbx + 0x1;
                                   } while (rbx < var_230);
                           }
                           _objc_flush_caches(var_280);
                           free(r14);
                           rax = var_288 + 0x1;
                           rcx = var_278;
                   } while (rax != rcx);
           }
           *_totalSwizzledClasses = rcx;
           if (*(int8_t *)_envVerbose != 0x0) {
                   rdx = *_totalSwizzledMethods;
                   fprintf(*___stderrp, "Swizzled %zu methods in %zu classes.\n", rdx, rcx);
           }

代码乍一看很多,其实逻辑非常简单,概述如下:

  • 通过获取UIView的类实体(不理解类实体的去看runtime)所在的地址来反推所在的image(二进制产物,基本是动态库),这里基本能猜测是UIKit
  • UIKit中获取所有继承自UIViewUIApplication的类及其子类(这也是你为什么会在刚刚上文提到的输出中发现UIIBApplication这种不知道啥类的原因),过滤到带_的私有类,然后对剩下的类的所有的方法进行Swizzle。
  • 对于需要Swizzle的方法,要额外判断是不是真正属于UIKit这个动态库的。 比如我们在调试的时候,Xcode会加载libViewDebugging.dylib等不会用于用于线上的动态库,里面会给UIView填上很多奇奇怪怪的方法。
  • 过滤如下的方法,以及以nsli_nsis_开头的方法。

    retain
    release
    autorelease
    .cxx_destruct
    description
    debugDescription
    class
    self
    beginBackgroundTaskWithExpiratonHandler
    beginBackgroundTaskWithName:expirationHandler:
    endBackgroundTask:
    opneGLContext:
    lockFocusIfCanDrawInContext:
    lockFocus
    lockFocusIfCanDraw
    unlockFocus
    
  • 可选,如果还要检查WebKit相关的方法,还可以Hook如下这些类的子类:

    WKWebView
    WKWebsiteDataStore
    WKUserScript
    WKUserContentController
    WKScriptMessage
    WKProcessPool
    WKProcessGroup
    WKContentExtensionStore
    

0x2 自己实现

当时看到这,关于苹果的实现我觉得实在是太简单了,即使不用私有API,结合现在Github上的轮子我自己造一个估计1、2个小时就解决了。现在回想起来,自己还是too simple, sometimes native

大致代码获取UIKitUIViewUIApplication所有子类的代码如下:

NSArray *findAllUIKitClasse()
{
    static NSMutableArray *viewClasses = nil;
    if (!viewClasses) return classes;

    uint32_t image_count = _dyld_image_count();
    for (uint32_t image_index = 0; image_index < image_count; image_index++) {
        const my_macho_header *mach_header = (const my_macho_header *)_dyld_get_image_header(image_index);

        const char *image_name = _dyld_get_image_name(image_index);

        NSString *imageName = [NSString stringWithUTF8String:image_name];
        if ([imageName hasSuffix:@"UIKit"]) {

            unsigned int count;
            const char **classes;
            Dl_info info;

            dladdr(mach_header, &info);
            classes = objc_copyClassNamesForImage(info.dli_fname, &count);

            for (int i = 0; i < count; i++) {
                const char *className = (const char *)classes[i];

                NSString *classname = [NSString stringWithUTF8String:className];
                if ([classname hasPrefix:@"_"]) {
                    continue;
                }

                Class cls = objc_getClass(className);
                Class superCls = cls;

                bool isNeedChild = NO;
                while (superCls != [NSObject class]) {

                    if (superCls == NSClassFromString(@"UIView") || superCls == NSClassFromString(@"UIApplication")) {
                        isNeedChild = YES;
                        break;
                    }
                    superCls = class_getSuperclass(superCls);
                }

                if (isNeedChild) {
                    // 备注:需要在这同时对这个类的方法进行Hook。
                    [viewClasses addObject:cls];
                }
            }

            break;
        }

    return viewClasses;
}

2.1 现有方案Hook的缺陷

到这,我们就只差把这些类的方法都Hook掉就行了。传统的Method Swizzling肯定不行,那样我们需要对每个方法对应实现一个新的方法进行替换,工作量太大。所以我们需要一个思路能够中心重定向整个过程。

之前跟着网易iOS大佬刘培庆学习iOS的时候,了解到了AnyMethodLog,听说能监控所有类所有方法的执行,于是我就直接套用了这个框架,嘿嘿,使用起来真方便,看起来大功告成了,Build & Run

卧槽,怎么运行了就启动崩了,一脸懵逼。

没事,我换个开源库BigBang改改。卧槽,还是崩了。这下必须要开下源码分析下原因了。

AnyMethodLog的实现来看,如下所示:

BOOL qhd_replaceMethod(Class cls, SEL originSelector, char *returnType) {
    Method originMethod = class_getInstanceMethod(cls, originSelector);
    if (originMethod == nil) {
        return NO;
    }
    const char *originTypes = method_getTypeEncoding(originMethod);
    IMP msgForwardIMP = _objc_msgForward;
#if !defined(__arm64__)
    if (qhd_isStructType(returnType)) {
        //Reference JSPatch:
        //In some cases that returns struct, we should use the '_stret' API:
        //http://sealiesoftware.com/blog/archive/2008/10/30/objc_explain_objc_msgSend_stret.html
        //NSMethodSignature knows the detail but has no API to return, we can only get the info from debugDescription.
        NSMethodSignature *methodSignature = [NSMethodSignature signatureWithObjCTypes:originTypes];
        if ([methodSignature.debugDescription rangeOfString:@"is special struct return? YES"].location != NSNotFound) {
            msgForwardIMP = (IMP)_objc_msgForward_stret;
        }
    }
#endif

    IMP originIMP = method_getImplementation(originMethod);

    if (originIMP == nil || originIMP == msgForwardIMP) {
        return NO;
    }

    //把原方法的IMP换成_objc_msgForward,使之触发forwardInvocation方法
    class_replaceMethod(cls, originSelector, msgForwardIMP, originTypes);

    //把方法forwardInvocation的IMP换成qhd_forwardInvocation
    class_replaceMethod(cls, @selector(forwardInvocation:), (IMP)qhd_forwardInvocation, "v@:@");

    //创建一个新方法,IMP就是原方法的原来的IMP,那么只要在qhd_forwardInvocation调用新方法即可
    SEL newSelecotr = qhd_createNewSelector(originSelector);
    BOOL isAdd = class_addMethod(cls, newSelecotr, originIMP, originTypes);
    if (!isAdd) {
        DEV_LOG(@"class_addMethod fail");
    }

    return YES;
}

    // 中心重定向函数
void qhd_forwardInvocation(id target, SEL selector, NSInvocation *invocation) {
    NSArray *argList = qhd_method_arguments(invocation);

    SEL originSelector = invocation.selector;

    NSString *originSelectorString = NSStringFromSelector(originSelector);



    [invocation setSelector:qhd_createNewSelector(originSelector)];
    [invocation setTarget:target];

    [invocation invoke];
}

作者的意图比较简单,主要可以概述为如下几点:

  • 把每个类的forwardInvocation,替换成自己实现的一个C函数。
  • 把需要Hook原来selector获取的method的IMP指向objc_msgForward,通过其触发消息转发,也就是触发forwardInvocation;
  • 对每个需要重定向的selector,生成一个特定的格式的新selector,将其IMP指向原来method的IMP。
  • 对于刚刚重定向的C函数,通过NSInvocation获取要调用的target和selector,再次将这个selector生成特定格式的新selector,反射调用。

为啥能把OC的函数forwardInvocation换成C函数,原因就在于只要补上OC函数隐式的前两个参数self, selector,让其的函数签名一致即可。

读到这,看起来没有啥问题吧?为什么会崩溃呢!!

原因在于这种调用方式,缺少了super上下文。

假设我们现在对UIViewUIButton都Hook了initWithFrame:这个方法,在调用[[UIView alloc] initWithFrame:][[UIButton alloc] initWithFrame:]都会定向到C函数qhd_forwardInvocation中,在UIView调用的时候没问题。但是在UIButton调用的时候,由于其内部实现获取了super initWithFrame:,就产生了循环定向的问题。

问题本质的原因是,由于我们对于父类、子类同名的方法都换成了同一个IMP,那么不论是走objc_msgSend抑或是objc_msgSendSuper2,获取到的IMP都是一致的。而在Hook之前,objc_msgSendSuper2拿到的是super_imp, objc_msgSend拿到是imp,从而不会有问题。

2.2 基于桥的全量Hook方法

好,上面的一个小节我们说,如果我们把所有方法都重定向到一个IMP上的时候,就会丧失关于继承关系之间的父子上下文关系,导致重定向循环。所以,我们需要一个思路,能够正确解决上下文的问题。

首先我们来回顾下runtime的消息转发机制:

1. 调用resolveInstanceMethod:方法 (或 resolveClassMethod:)。允许用户在此时为该 Class 动态添加实现。如果有实现了,则调用并返回YES,那么重新开始objc_msgSend流程。这一次对象会响应这个选择器,一般是因为它已经调用过class_addMethod。如果仍没实现,继续下面的动作。

2. 调用forwardingTargetForSelector:方法,尝试找到一个能响应该消息的对象。如果获取到,则直接把消息转发给它,返回非 nil 对象。否则返回 nil ,继续下面的动作。注意,这里不要返回 self ,否则会形成死循环。

3. 调用methodSignatureForSelector:方法,尝试获得一个方法签名。如果获取不到,则直接调用doesNotRecognizeSelector抛出异常。如果能获取,则返回非nil:创建一个 NSlnvocation 并传给forwardInvocation:。

4. 调用forwardInvocation:方法,将第3步获取到的方法签名包装成 Invocation 传入,如何处理就在这里面了,并返回非ni。

5. 调用doesNotRecognizeSelector: ,默认的实现是抛出异常。如果第3步没能获得一个方法签名,执行该步骤。

对于我们来说,我们至少要在第四步之前(确切的是第三步之前),我们就要保留好super上下文。一旦到了forwardInvocation函数,留给我们的又只有self这样的残缺信息了。

哎,我就是卡在这思考了一天,最终我想出了一个思路。

  • 提供一个桩WZQMessageStub,这个桩保留了class和selector,拼接成不一样的函数名,这样就能区分UIButtonUIView的同名initWithFrame:方法,因为不同的selector找到的IMP肯定不一样。
  • NSObject里面实现forwardingTargetForSelector,在消息转发的时候指定把消息全部转发给WZQMessageStub
  • WZQMessageStub实现methodSignatureForSelectorforwardInvocation:方法,承担真正的方法反射调用的职责。

好,思路确定了,难点还剩一个。对于forwardingTargetForSelector这个函数来说,能拿到的参数也是targetselector。在superself调用场景下,这个参数毫无价值,因此我们需要从selector上着手。如果不做任何改变,我们这里拿到的selector肯定是诸如initWithFrame:old selector,所以我们需要在这之前桥一下,可以按照下述流程理解:

每个方法置换到不同的IMP桥上 -> 从桥上反推出当前的调用关系(class和selector-> 构造一个中间态新名字 -> forwardingTargetForSelector(self, 中间态新名字) 

OK,大功告成。具体桥的实现我待会再单独开篇博客讲一讲。

嘿嘿,看起来很简单的任务也学习到了不少新知识。一会把代码开源到Github上。

0x3 遗留问题

我在开启Main Thread Chekcer后,build了一次产物,但是在通过Mach-O文件中Load Commands部分的时候,却没有发现libMainThreadChecker.dylib的踪影,如下所示:

符号断点dlopen也并没有发现这个动态库调用的踪影,所以非常好奇苹果是怎么加载这个动态库的,有大佬知道请赐教。

KVO在不同的二进制中多个符号并存的Crash问题

现在各大公司的App容纳的功能越来越多,导致应用包大小越来越大。而苹果对于text段的大小现在在60MB,为了避免无法上架的问题,所以很多App都开始用了动态库来避免这个问题。

这两天在帮支付宝开发一个功能的时候,由于支付宝许多模块的代码保密设计,因此只能采用动态库注入的方式进行调试。

一开始都没啥问题,但是当我在调试一个API接口的时候,却出现了一个必现的和MBProgressHUD有关的Crash问题。今天就让我用这个Crash开始,来探讨下KVO在不同的二进制中多个符号并存的Crash问题

不同产物中同名符号的处理问题

我们都知道,在同一个编译->Link的最终产物中,符号(类、MetaClass、甚至是全局的函数符号)定义是不能重复的(当然,我们需要排除weak symbol)。否则在ld期间,就会报duplicate symbol这样的错误。

但是在不同的最终产物里,比如一个主二进制和其相关的动态库,由于这两种MachO类型为产物完全脱离,因此在这两个产物中分别定义相同的符号是完全行得通的。

有人会问了,那我们在主二进制中定义一个类,在动态库中又定义了一个同名的类,当我在主二进制中加载了动态库后,两个同名的类会冲突吗?

答案是不会的,其原因在于苹果使用的是two level namespace的技术。在这种形式下,符号所在的“库”的名称也会作为符号的一部分。链接的时候,staic linker会标记住在这个符号是来自于哪个库的。这样不仅大大减少了dyld搜索符号所需要的时间,也更好对后续库的更新进行了兼容。

类的加载

熟悉runtime的人都知道,iOS中的类和其metaClass都是objc_class对象,这些“类”所代表的结构体,在编译期间都存在于Mach-O文件中了,位于objc_data这个section中。

而这个对象所包含的如方法、协议等等,则是以class_ro_t的形式存在于objc_const节中。

struct class_ro_t {
    uint32_t flags;
    uint32_t instanceStart;
    uint32_t instanceSize;
#ifdef __LP64__
    uint32_t reserved;
#endif

    const uint8_t * ivarLayout;

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

    const uint8_t * weakIvarLayout;
    property_list_t *baseProperties;

    method_list_t *baseMethods() const {
        return baseMethodList;
    }
};

无论Mach-O的产物如何,这都是静态的数据。当我们在程序使用的过程中想调用这些类,都需要将这些类从二进制中读取并进行realize变成一个正确的类。而整个realize的过程,是在主二进制程序和其依赖的动态库加载完成后进行调用的,realize的过程如下:

static Class realizeClass(Class cls)
{
    runtimeLock.assertWriting();

    const class_ro_t *ro;
    class_rw_t *rw;
    Class supercls;
    Class metacls;
    bool isMeta;

    if (!cls) return nil;

    // 1. 如果realize过了,就直接返回了
    if (cls->isRealized()) return cls;
    assert(cls == remapClass(cls));

    // fixme verify class is not in an un-dlopened part of the shared cache?

     // 2. 读取刚刚提到的read only data,将其变成rw的data。
    ro = (const class_ro_t *)cls->data();
    if (ro->flags & RO_FUTURE) {
        // This was a future class. rw data is already allocated.
        rw = cls->data();
        ro = cls->data()->ro;
        cls->changeInfo(RW_REALIZED|RW_REALIZING, RW_FUTURE);
    } else {
        // Normal class. Allocate writeable class data.
        rw = (class_rw_t *)calloc(sizeof(class_rw_t), 1);
        rw->ro = ro;
        rw->flags = RW_REALIZED|RW_REALIZING;
        cls->setData(rw);
    }

    isMeta = ro->flags & RO_META;

    rw->version = isMeta ? 7 : 0;  // old runtime went up to 6


    // Choose an index for this class.
    // Sets cls->instancesRequireRawIsa if indexes no more indexes are available
    cls->chooseClassArrayIndex();

    if (PrintConnecting) {
        _objc_inform("CLASS: realizing class '%s'%s %p %p #%u", 
                     cls->nameForLogging(), isMeta ? " (meta)" : "", 
                     (void*)cls, ro, cls->classArrayIndex());
    }

    // Realize superclass and metaclass, if they aren't already.
    // This needs to be done after RW_REALIZED is set above, for root classes.
    // This needs to be done after class index is chosen, for root metaclasses.

    // 注意点3:对父类和metaClass先进行realize
    supercls = realizeClass(remapClass(cls->superclass));
    metacls = realizeClass(remapClass(cls->ISA()));

#if SUPPORT_NONPOINTER_ISA
    // Disable non-pointer isa for some classes and/or platforms.
    // Set instancesRequireRawIsa.
    bool instancesRequireRawIsa = cls->instancesRequireRawIsa();
    bool rawIsaIsInherited = false;
    static bool hackedDispatch = false;

    if (DisableNonpointerIsa) {
        // Non-pointer isa disabled by environment or app SDK version
        instancesRequireRawIsa = true;
    }
    else if (!hackedDispatch  &&  !(ro->flags & RO_META)  &&  
             0 == strcmp(ro->name, "OS_object")) 
    {
        // hack for libdispatch et al - isa also acts as vtable pointer
        hackedDispatch = true;
        instancesRequireRawIsa = true;
    }
    else if (supercls  &&  supercls->superclass  &&  
             supercls->instancesRequireRawIsa()) 
    {
        // This is also propagated by addSubclass() 
        // but nonpointer isa setup needs it earlier.
        // Special case: instancesRequireRawIsa does not propagate 
        // from root class to root metaclass
        instancesRequireRawIsa = true;
        rawIsaIsInherited = true;
    }

    if (instancesRequireRawIsa) {
        cls->setInstancesRequireRawIsa(rawIsaIsInherited);
    }
// SUPPORT_NONPOINTER_ISA
#endif

    // Update superclass and metaclass in case of remapping
    // 更新当前类的父类和meta类
    cls->superclass = supercls;
    cls->initClassIsa(metacls);

    // Reconcile instance variable offsets / layout.
    // This may reallocate class_ro_t, updating our ro variable.
    // 如果有的话,对ivar进行重新的布局
    if (supercls  &&  !isMeta) reconcileInstanceVariables(cls, supercls, ro);

    // Set fastInstanceSize if it wasn't set already.
    cls->setInstanceSize(ro->instanceSize);

    // Copy some flags from ro to rw
    if (ro->flags & RO_HAS_CXX_STRUCTORS) {
        cls->setHasCxxDtor();
        if (! (ro->flags & RO_HAS_CXX_DTOR_ONLY)) {
            cls->setHasCxxCtor();
        }
    }

    // Connect this class to its superclass's subclass lists
    // 简单理解就是构建层次结构的拓扑关系
    if (supercls) {
        addSubclass(supercls, cls);
    } else {
        addRootClass(cls);
    }

    // Attach categories
    // 把category里面的东西也合并进来进来
    methodizeClass(cls);

    return cls;
}

从上述代码不难看出,整个过程非常简单,分为几个步骤:

  • 把从二进制里面读取的readonly data变成rw data,这也是我们在iOS编程中很多运行时黑魔法的基础。
  • 把父类和metaclass都realize一下,然后建立合理的层次依赖关系。
  • 根据父类的布局,把自己的ivar布局动态更新,这也是大名鼎鼎的non-fragile layout
  • category里面的东西都加载进来。
  • 整个过程结束。

KVO的机制

说了这么多铺垫的知识,我们来开始分析下我们程序在加载动态库后会KVO Crash的原因。处于公司数据保密的原因,我构造了一个最简单的场景,这个主二进制和动态库都包含了MBProgressHUD对应的代码,

我们可以通过nm来查看下符号:

MBProgressHUD里面,有如下一段代码:

- (void)registerForKVO {
    for (NSString *keyPath in [self observableKeypaths]) {
        [self addObserver:self forKeyPath:keyPath options:NSKeyValueObservingOptionNew context:NULL];
    }
}

它会分别对所有的对应属性进行KVO监听,由于KVO本身的机制是通过创建一个“xxxNotify_KVO类”,所以,整体的调用顺序如下图所示:

概括如下:

  • 整个流程会为MBProgressHUD这个类以NSKVONotifying_MBProgressHUD的名称,动态添加一个类。
  • 对这个类构建和原先类的父子关系,注册到全局的类表中。
  • 对KVO中使用到的监听的属性进行setter方法的覆写。

这几个流程的代码分别如下:

  1. 创建类代码非常简单,逻辑上就是这父类-子类的关系构建一个新的类出来:

    Class objc_allocateClassPair(Class superclass, const char *name, 
                                 size_t extraBytes)
    {
        Class cls, meta;
    
        rwlock_writer_t lock(runtimeLock);
    
        // Fail if the class name is in use.
        // Fail if the superclass isn't kosher.
        if (getClass(name)  ||  !verifySuperclass(superclass, true/*rootOK*/)) {
            return nil;
        }
    
        // Allocate new classes.
        cls  = alloc_class_for_subclass(superclass, extraBytes);
        meta = alloc_class_for_subclass(superclass, extraBytes);
    
        // fixme mangle the name if it looks swift-y?
        objc_initializeClassPair_internal(superclass, name, cls, meta);
    
        return cls;
    }
    
  2. 当创建完成后,就会对这个类进行registerClassPair的工作,这一步的目的很简单,就是将类注册到一个全局的map中gdb_objc_realized_classes

  3. 重写setter, class, description之类的

Crash原因

知道了原理,我们来分析Crash的原因就非常简单了,我们先看Crash的堆栈。

从汇编中不难看出,[x19, #0x20]对应的地址是个非法访问地址,导致了Crash。而x19寄存器又是从x0中赋值而来,根据函数objc_registerClassPair的参数,x0Class,那很明显,就是从Class对象的0x20,即32 bytes偏移地方的数据。根据定义,

struct objc_class : objc_object {
    // Class ISA; // 8byte
    Class superclass; // 8byte
    cache_t cache;             // formerly cache pointer and vtable // 4 + 4 + 8
    class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags

我们要获取的数据就是bits。通过输出寄存器,我们发现x0为0,也就是nil。而x0又是从哪来的呢?

倒推堆栈,我们发现,在函数_NSKVONotifyingCreateInfoWithOriginalClass,我们首先调用了objc_allocateClassPair,将其返回值传入objc_registerClassPair(ARM64 Calling Convention)

所以,问题的本质就出现在allocateClassPair返回了nil,而allocateClassPair只有在如下场景下才会返回nil。

if (getClass(name)  ||  !verifySuperclass(superclass, true/*rootOK*/)) {
    return nil;
}

通过LLDB调试,在根据name查询NSKVONotifying_MBProgressHUD时,由于全局的类表已经存在了对应的类,所以在getClass就会返回之前注册的类,从而使得allocate直接返回了nil。

NXMapTable *gdb_objc_realized_classes;  // exported for debuggers in objc-gdb.h

static Class getClass_impl(const char *name)
{
    runtimeLock.assertLocked();

    // allocated in _read_images
    assert(gdb_objc_realized_classes);

    // Try runtime-allocated table
    Class result = (Class)NXMapGet(gdb_objc_realized_classes, name);
    if (result) return result;

    // Try table from dyld shared cache
    return getPreoptimizedClass(name);
}

static Class getClass(const char *name)
{
    runtimeLock.assertLocked();

    // Try name as-is
    Class result = getClass_impl(name);
    if (result) return result;

    // Try Swift-mangled equivalent of the given name.
    if (char *swName = copySwiftV1MangledName(name)) {
        result = getClass_impl(swName);
        free(swName);
        return result;
    }

    return nil;
}

结论

当两个产物都有相同的类名时,这两个类都会被realize,都能够被正常调用。但是由于全局类表的存在,在动态创建KVO的子类时,只能产生一个。所以就导致allocate失败,从而引发register过程的Crash问题。

UIKit解剖(-)逆向UITableViewController分析Bug

之前在做XXXSDK的时候,我hook的UITableViewsetDelegate:方法。整个SDK在接入手淘、天猫以及闲鱼等其他App的时候都没啥问题。

上周,UC的同学突然找到说,给我说了如下图所示的问题:

商业保密,不显示了

卧槽,这下我就懵逼了,看样子是把整个rowHeight给Hook坏了,那这是为什么呢?

从开发UITableView的正向角度来说:我们一般都需要给其提供一个必选的UITableViewDataSource和一个可选的UITableViewDelegate,其中,涉及到高度的是如下这个API:

- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath;

有人说可以直接通过tableview.rowHeight设置高度,但是对于不同cell不同高度的动态需求,但是这里我们暂不提这种分支情况。

通过UC同学的协助,我们发现了如下输出:

通过输出不难发现,是最后的delegate被从对应的UIViewController改成了一个乱七八糟没实现对应heightForRowAtIndexPath方法的对象。

为什么会这样呢?

通过如下图所示的调用栈,

调用栈最下层是UC同学的代码;

self.tableview = [[xxxTableView alloc] init]

调用栈最上层是我们的一层防护性hook,其代码如下:

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Swizzle([UIScrollView class], @selector(init), @selector(swizzled_init));
    });
}

- (instancetype)swizzled_init
{
    id obj = [self swizzled_init];
    UIScrollView *scrollView = (UIScrollView *)obj;
    if (!scrollView.delegate) {
        //scrollView.delegate = [UIScrollViewDelegateDummyStub sharedStub];
    }
    return obj;
}

这段代码是什么作用呢?

我们之前提了UITableViewDelegate不是必需,因此,为了能够抓去所有UITableView的代码,我们会提供一个内置的默认delegate(当时的实现存在bug,没有实现heightForRowAtIndexPath方法)

而且,为了防止我们的delegate覆盖了有delegate的情况,我们还特地做了!scroll.delegate的判断。

按照我们的预期设想,存在两种时间顺序情况:

  1. 我们的init先执行,此时肯定会进入我们设置默认的逻辑;然后当外部代码调用tableview.delegate = xxx的时候,会把我们这个替换掉,不会影响正常的逻辑。
  2. 我们的init后执行(比如某些子类覆盖的情况),那这样的话,当子类已经设置好delegate后,压根不会进入我们的设置逻辑。

然而,就是这一小段看起来无错的代码导致了UC的App出现了文章开头的Bug。

逆向分析UITableViewController

基于10.2的UIKit,我们通过汇编来分析-[UITableViewController setTableView:]的流程:

     // 保存寄存器
->  0x18c84c640 <+0>:   stp    x26, x25, [sp, #-0x50]!
    0x18c84c644 <+4>:   stp    x24, x23, [sp, #0x10]
    0x18c84c648 <+8>:   stp    x22, x21, [sp, #0x20]
    0x18c84c64c <+12>:  stp    x20, x19, [sp, #0x30]
    0x18c84c650 <+16>:  stp    x29, x30, [sp, #0x40]

    // 获取原先UITableViewController的旧tableView
    0x18c84c654 <+20>:  add    x29, sp, #0x40            ; =0x40 
    0x18c84c658 <+24>:  mov    x20, x0
    0x18c84c65c <+28>:  mov    x0, x2
    0x18c84c660 <+32>:  bl     0x1851c8090               ; objc_retain
    0x18c84c664 <+36>:  mov    x19, x0
    0x18c84c668 <+40>:  adrp   x8, 124100
    0x18c84c66c <+44>:  ldr    x1, [x8, #0xd78]
    0x18c84c670 <+48>:  mov    x0, x20
    0x18c84c674 <+52>:  bl     0x1851c2f60               ; objc_msgSend
    0x18c84c678 <+56>:  mov    x29, x29
    0x18c84c67c <+60>:  bl     0x1851ca48c               ; objc_retainAutoreleasedReturnValue

    // 比较旧的tableview和新的tableview
    0x18c84c680 <+64>:  mov    x21, x0
    0x18c84c684 <+68>:  cmp    x21, x19

    // 如果两个tableView一致,直接返回
    0x18c84c688 <+72>:  b.eq   0x18c84c7d4               ; <+404>

    // 获取旧的tableView的datasource
    0x18c84c68c <+76>:  adrp   x8, 124074
    0x18c84c690 <+80>:  ldr    x23, [x8, #0x2e0]
    0x18c84c694 <+84>:  mov    x0, x21
    0x18c84c698 <+88>:  mov    x1, x23
    0x18c84c69c <+92>:  bl     0x1851c2f60               ; objc_msgSend
    0x18c84c6a0 <+96>:  mov    x29, x29
    0x18c84c6a4 <+100>: bl     0x1851ca48c               ; objc_retainAutoreleasedReturnValue


    // 从self的成员对象便宜792处取出UIFilteredDataSource
    0x18c84c6a8 <+104>: mov    x22, x0
    0x18c84c6ac <+108>: adrp   x8, 124145
    0x18c84c6b0 <+112>: ldrsw  x8, [x8, #0x7ac]
    0x18c84c6b4 <+116>: ldr    x8, [x20, x8]
    0x18c84c6b8 <+120>: cmp    x22, x20
    0x18c84c6bc <+124>: ccmp   x22, x8, #0x4, ne

    // 如果不一致,把旧的tableview的datasource 置为nil
    0x18c84c6c0 <+128>: b.ne   0x18c84c6d8               ; <+152>
    0x18c84c6c4 <+132>: adrp   x8, 124073
    0x18c84c6c8 <+136>: ldr    x1, [x8, #0x3c0]
    0x18c84c6cc <+140>: mov    x2, #0x0
    0x18c84c6d0 <+144>: mov    x0, x21
    0x18c84c6d4 <+148>: bl     0x1851c2f60               ; objc_msgSend

    // 获取旧的tableview的delegate
    0x18c84c6d8 <+152>: adrp   x8, 124074
    0x18c84c6dc <+156>: ldr    x24, [x8, #0x7d8]
    0x18c84c6e0 <+160>: mov    x0, x21
    0x18c84c6e4 <+164>: mov    x1, x24
    0x18c84c6e8 <+168>: bl     0x1851c2f60               ; objc_msgSend
    0x18c84c6ec <+172>: mov    x29, x29
    0x18c84c6f0 <+176>: bl     0x1851ca48c               ; objc_retainAutoreleasedReturnValue
        0x18c84c6f4 <+180>: mov    x25, x0
    0x18c84c6f8 <+184>: bl     0x1851c8150               ; objc_release

    // 判断旧的delegate是不是当前的UITableViewController
    0x18c84c6fc <+188>: cmp    x25, x20
    0x18c84c700 <+192>: b.ne   0x18c84c718               ; <+216>
    0x18c84c704 <+196>: adrp   x8, 124073
    0x18c84c708 <+200>: ldr    x1, [x8, #0x3c8]

    // 如果不是,就把旧的tableview的delegate置为nil
    0x18c84c70c <+204>: mov    x2, #0x0
    0x18c84c710 <+208>: mov    x0, x21
    0x18c84c714 <+212>: bl     0x1851c2f60               ; objc_msgSend
    0x18c84c718 <+216>: adrp   x8, 124080
    0x18c84c71c <+220>: ldr    x1, [x8, #0xe80]
    0x18c84c720 <+224>: mov    x0, x21
    0x18c84c724 <+228>: bl     0x1851c2f60               ; objc_msgSend
    0x18c84c728 <+232>: mov    x29, x29
    0x18c84c72c <+236>: bl     0x1851ca48c               ; objc_retainAutoreleasedReturnValue
    0x18c84c730 <+240>: mov    x25, x0

    // 将uitableviewcontroller的tableview通过setView:置为新的
    0x18c84c734 <+244>: adrp   x8, 124076
    0x18c84c738 <+248>: ldr    x1, [x8, #0x4b0]
    0x18c84c73c <+252>: mov    x0, x20
    0x18c84c740 <+256>: mov    x2, x19
    0x18c84c744 <+260>: bl     0x1851c2f60               ; objc_msgSend

    // 新的tableview的datasource判断是不是为空,为空通过_applyDefaultDataSourceToTable将其UIFilteredDataSource
    0x18c84c748 <+264>: adrp   x8, 124080
    0x18c84c74c <+268>: ldr    x1, [x8, #0x810]
    0x18c84c750 <+272>: mov    x0, x19
    0x18c84c754 <+276>: mov    x2, x25
    0x18c84c758 <+280>: bl     0x1851c2f60               ; objc_msgSend
    0x18c84c75c <+284>: mov    x0, x19
    0x18c84c760 <+288>: mov    x1, x23
    0x18c84c764 <+292>: bl     0x1851c2f60               ; objc_msgSend
    0x18c84c768 <+296>: mov    x29, x29
    0x18c84c76c <+300>: bl     0x1851ca48c               ; objc_retainAutoreleasedReturnValue
    0x18c84c770 <+304>: mov    x23, x0
    0x18c84c774 <+308>: bl     0x1851c8150               ; objc_release
    0x18c84c778 <+312>: cbnz   x23, 0x18c84c790          ; <+336>
    0x18c84c77c <+316>: adrp   x8, 124100
    0x18c84c780 <+320>: ldr    x1, [x8, #0xd80]
    0x18c84c784 <+324>: mov    x0, x20
    0x18c84c788 <+328>: mov    x2, x19
    0x18c84c78c <+332>: bl     0x1851c2f60               ; objc_msgSend

    // 新的tableview的delegaate判断是不是为空,为空通过将delegate置为self(即当前的UITableViewController)
    0x18c84c790 <+336>: mov    x0, x19
    0x18c84c794 <+340>: mov    x1, x24
    0x18c84c798 <+344>: bl     0x1851c2f60               ; objc_msgSend
    0x18c84c79c <+348>: mov    x29, x29
    0x18c84c7a0 <+352>: bl     0x1851ca48c               ; objc_retainAutoreleasedReturnValue
    0x18c84c7a4 <+356>: mov    x23, x0
    0x18c84c7a8 <+360>: bl     0x1851c8150               ; objc_release


    0x18c84c7ac <+364>: cbnz   x23, 0x18c84c7c4          ; <+388>
    0x18c84c7b0 <+368>: adrp   x8, 124073
    0x18c84c7b4 <+372>: ldr    x1, [x8, #0x3c8]
    0x18c84c7b8 <+376>: mov    x0, x19
    0x18c84c7bc <+380>: mov    x2, x20
    0x18c84c7c0 <+384>: bl     0x1851c2f60               ; objc_msgSend
    0x18c84c7c4 <+388>: mov    x0, x25
    0x18c84c7c8 <+392>: bl     0x1851c8150               ; objc_release
    0x18c84c7cc <+396>: mov    x0, x22
    0x18c84c7d0 <+400>: bl     0x1851c8150               ; objc_release
    0x18c84c7d4 <+404>: mov    x0, x21
    0x18c84c7d8 <+408>: bl     0x1851c8150               ; objc_release

    // 恢复寄存器

    0x18c84c7dc <+412>: mov    x0, x19
    0x18c84c7e0 <+416>: ldp    x29, x30, [sp, #0x40]
    0x18c84c7e4 <+420>: ldp    x20, x19, [sp, #0x30]
    0x18c84c7e8 <+424>: ldp    x22, x21, [sp, #0x20]
    0x18c84c7ec <+428>: ldp    x24, x23, [sp, #0x10]
    0x18c84c7f0 <+432>: ldp    x26, x25, [sp], #0x50
    0x18c84c7f4 <+436>: b      0x1851c8150               ; objc_release
  • 一看到adrp, ldr的搭配,基本可以确定是取某个方法进行调用。
  • 看到一大堆的bl objc_retainbl objc_release,不用管,反正都是ARC帮我们自动插入的。
  • 可以看出,当传入给UITableViewController的tableView含有dataSourcedelegate,UITableViewController都不会对其进行处理;否则会进行一个默认的设置。

我自己理解后转写的伪代码如下:

UITableView *oldTableView = [self __existingTableView]; 

if (oldTableView == xxxtableView) {
    return 
} else {
    id oldDataSource = [oldTableView dataSource];
    // x21 被赋值成了oldTableView, x22 oldDataSource

    // 取UITableViewController 792偏移的成员变量 filteredDataSource
    id filteredDataSource = [self _filteredDataSource];

    if (oldDataSource != filteredDataSource) 
    {

    } else {
        [oldTableView setDataSource:nil];
    }

    id oldDelegate = [oldTableView delegate];
    // x25 被赋值了oldDelegate

    if (oldeDelegate != self)
    {
        goto //
    } else {
        [oldTableView setDelegate:nil];
    }

    id oldRefreshControl = [oldTableView _refreshControl];
    // x25 被赋值了oldRefreshControl

    [self setView:xxtableView];
    [xxxtableView _setRefreshControl:oldRefreshControl];

    id newDataSource = [xxxtableview dataSource];
    if (!newDataSource) {
        [self _applyDefaultDataSourceToTable:xxxTableView];
    }

    id newDelegate = [xxxtableview delegate];
    if (!newDelegate) {
        [xxxTableView setDelegate:self];
    }
}

结论

通过上面对汇编和伪代码的理解,我们可以很轻易的得出结论:当我们处于第一种情形的实现,我们将tableview.delegate设置成了我们的stub。因为不为空,所以UITableViewController默认不会对其进行处理,而由于我们当时没有提供stub对于heightForRowAtIndexPath的实现,导致出现了UC的bug。

微信高性能线上日志系统xlog剖析

微信高性能线上日志系统xlog剖析

做移动开发的同学经常会遇到一个头疼的问题,就是当用户反馈一些问题,又比较冷僻难以复现的时候(不是Crash),常常就会陷入一筹莫展的境地。因此,很多人就研发了相关的监控系统,比如一些知名的APM来监测帧率、内存、电量等等,将这些数据进行采集、合并再上报至专门的平台供开发测试同学查看。但是这些APM往往都是粗粒度的监控,究其原因就在于如果特别精细的进行监控,线上的性能会吃不消,一些监控反而影响了用户的正常使用。

说了这么多,抛开获取数据方面的难度不提,线上监控的本质还是在于信息(日志)记录,而端上的日志记录存在一个社会主义初级阶段的供需矛盾:

即实时细粒度的日志记录的性能落差和日志的完整不丢失无法兼顾。

如果你要高性能、细粒度的记录日志,那你势必大量使用内存。而大量使用使用内存,万一没电了、程序突然崩了,这些中间态的日志还没持久化,就相当于白费了精力;而如果你想保证可靠性,那你就需要经常实时落盘。我们知道,写磁盘的行为是会设计用户态和内核态的切换,在高流畅性的要求下是绝对会影响性能了,而且这还不是你开多线程能够解决的问题。

写磁盘为什么会非常慢

现如今、几乎所有的操作系统在管理内存的时候,基本采用了页式管理的策略。即将连续的内存空间(注意空间,不是地址)换成了一个个页式大小。这样的好处有几点:

  1. 按页这种大小进行管理、可以有效的减少内存碎片的粒度。
  2. 按页加载,可以充分利用磁盘上的交换空间,使得程序使用的空间能大大超过内存限制。

当然,iOS设备上不存在交换空间,但是也依然按照页式结构进行内存管理。

回到为什么写磁盘会慢的问题上。我们一般会把内存中的数据进行持久化储存到磁盘上。但是写入磁盘并不是你想写就立刻写的,数据是通过flush的方式从内存写回到磁盘,一般有如下几种情况:

  1. 通过页的flag标记为有改动,操作系统定时将这种脏页写回到磁盘上,时机不可控。
  2. 调用用户态的写接口->触发内核态的sys_write->文件系统将数据写回磁盘。

乍一看上述第二种方式非常适合写日志,但是其包含两个非常明显的问题:

  • 文件系统处于效率不会立刻将数据写回到磁盘(比如磁道寻址由于机械操作的原因相对非常耗时),而是以Block块的形式缓存在队列中,经过排序、合并到达一定比例之后再写回磁盘。
  • 这种方式在将数据写回到磁盘时,需要经历两次拷贝。一次是把数据从用户态拷贝到内核态,需要经历上下文切换;还有一次是内核空间到硬盘上真正的数据拷贝。当切换次数过于频繁,整体性能也会下降。

基于上述这些问题,xlog采用了mmap的方案进行日志系统的设计:

mmap 是使用逻辑内存对磁盘文件进行映射,中间只是进行映射没有任何拷贝操作,避免了写文件的数据拷贝。操作内存就相当于在操作文件,避免了内核空间和用户空间的频繁切换。

除了系能耐,使用mmap还能保证日志的完整性,因为如下这些情况下回自动回写磁盘:

  • 内存不足
  • 进程 crash
  • 调用 msync 或者 munmap
  • 不设置 MAP_NOSYNC 情况下 30s-60s(仅限FreeBSD)

xlog源码分析

xlog的代码主要分为两块,面向上层的使用封装xlogger,暴露了一系列的借口。以及核心的appenderlog等。

log_buffer

log_buffer其目的是封装了一个对mmap/传统内存操作的数据结构。其核心思想就是将上层的操作转换对实际开辟出来的日志缓存地址进行读写(也封装了加密压缩操作等等)。我们以写操作为例子进行剖析:

bool LogBuffer::Write(const void* _data, size_t _length) {
    // 一些异常处理,不说了
    if (NULL == _data || 0 == _length) {
        return false;
    }

    if (buff_.Length() == 0) {
        if (!__Reset()) return false;
    }

    size_t before_len = buff_.Length();
    size_t write_len = _length;

    if (is_compress_) {
        // 是否开启压缩
        cstream_.avail_in = (uInt)_length;
        cstream_.next_in = (Bytef*)_data;

        uInt avail_out = (uInt)(buff_.MaxLength() - buff_.Length());
        cstream_.next_out = (Bytef*)buff_.PosPtr();
        cstream_.avail_out = avail_out;

        if (Z_OK != deflate(&cstream_, Z_SYNC_FLUSH)) {
            return false;
        }

        write_len = avail_out - cstream_.avail_out;
    } else {
          // 1. 写入数据到mmap文件或者内存当中
        buff_.Write(_data, _length);
    }

    // 2. 检查之前尝试加密但是还剩的未能成功加密的数据长度
    before_len -= remain_nocrypt_len_;

    AutoBuffer out_buffer;
    size_t last_remain_len = remain_nocrypt_len_;

    // 3. 异步加密,更新未能加密的数据长度
    log_crypt_->CryptAsyncLog((char*)buff_.Ptr() + before_len, write_len + remain_nocrypt_len_, out_buffer, remain_nocrypt_len_);

    // 4. 将加密的文本重新写入到之前最后一次加密的数据结尾位置
    buff_.Write(out_buffer.Ptr(), out_buffer.Length(), before_len);

    // 5. 更新数据
    before_len += out_buffer.Length();
    buff_.Length(before_len, before_len);

    // 6. 添加一下加密的长度之类的辅助信息补充在真实数据之后,主要用于解密时候用
    log_crypt_->UpdateLogLen((char*)buff_.Ptr(), (uint32_t)(out_buffer.Length() - last_remain_len));

    return true;
}

不难看出,整体上就是对写入的数据进行加密,如果有压缩的需求同时进行压缩。并将修改后的数据存入真正的mmap文件/内存缓存中。

如果不能理解的话,可以看下我画的这幅图进行表示:

appender

xlog方案真正的核心实际上只有一个appender文件,本质上的思路都比较清晰,将添加日志分为同步写和异步写。异步写的方式比较常用,下文会基于这个分析。

首先是日志系统的初始化配置

assert(_dir);
assert(_nameprefix);

if (!sg_log_close) {
    __writetips2file("appender has already been opened. _dir:%s _nameprefix:%s", _dir, _nameprefix);
    return;
}

 // 1. 设置真正的添加log信息函数,供上层调用
xlogger_SetAppender(&xlogger_appender);

//mkdir(_dir, S_IRWXU|S_IRWXG|S_IRWXO);
boost::filesystem::create_directories(_dir);
tickcount_t tick;
tick.gettickcount();
__del_timeout_file(_dir);

tickcountdiff_t del_timeout_file_time = tickcount_t().gettickcount() - tick;

tick.gettickcount();

char mmap_file_path[512] = {0};
snprintf(mmap_file_path, sizeof(mmap_file_path), "%s/%s.mmap2", sg_cache_logdir.empty()?_dir:sg_cache_logdir.c_str(), _nameprefix);

bool use_mmap = false;
// 2. 尝试使用mmap
if (OpenMmapFile(mmap_file_path, kBufferBlockLength, sg_mmmap_file))  {
    sg_log_buff = new LogBuffer(sg_mmmap_file.data(), kBufferBlockLength, true, _pub_key);
    use_mmap = true;
} else {
    // 3. 失败了回退到普通内存的方案
    char* buffer = new char[kBufferBlockLength];
    sg_log_buff = new LogBuffer(buffer, kBufferBlockLength, true, _pub_key);
    use_mmap = false;
}

 4. 注意点1!!!!!!!!!!!!!!!!!!!!
if (NULL == sg_log_buff->GetData().Ptr()) {
    if (use_mmap && sg_mmmap_file.is_open())  CloseMmapFile(sg_mmmap_file);
    return;
}


5. 注意点2!!!!!!!!!!!!!!!!
AutoBuffer buffer;
sg_log_buff->Flush(buffer);

ScopedLock lock(sg_mutex_log_file);
sg_logdir = _dir;
sg_logfileprefix = _nameprefix;
sg_log_close = false;
appender_setmode(_mode);
lock.unlock();

char mark_info[512] = {0};
get_mark_info(mark_info, sizeof(mark_info));

if (buffer.Ptr()) {
    __writetips2file("~~~~~ begin of mmap ~~~~~\n");
    __log2file(buffer.Ptr(), buffer.Length());
    __writetips2file("~~~~~ end of mmap ~~~~~%s\n", mark_info);
}

 6. 添加一些关于xlog自身的信息
tickcountdiff_t get_mmap_time = tickcount_t().gettickcount() - tick;


char appender_info[728] = {0};
snprintf(appender_info, sizeof(appender_info), "^^^^^^^^^^" __DATE__ "^^^" __TIME__ "^^^^^^^^^^%s", mark_info);

xlogger_appender(NULL, appender_info);
char logmsg[64] = {0};
snprintf(logmsg, sizeof(logmsg), "del time out files time: %" PRIu64, (int64_t)del_timeout_file_time);
xlogger_appender(NULL, logmsg);

snprintf(logmsg, sizeof(logmsg), "get mmap time: %" PRIu64, (int64_t)get_mmap_time);
xlogger_appender(NULL, logmsg);

xlogger_appender(NULL, "MARS_URL: " MARS_URL);
xlogger_appender(NULL, "MARS_PATH: " MARS_PATH);
xlogger_appender(NULL, "MARS_REVISION: " MARS_REVISION);
xlogger_appender(NULL, "MARS_BUILD_TIME: " MARS_BUILD_TIME);
xlogger_appender(NULL, "MARS_BUILD_JOB: " MARS_TAG);

snprintf(logmsg, sizeof(logmsg), "log appender mode:%d, use mmap:%d", (int)_mode, use_mmap);
xlogger_appender(NULL, logmsg);

BOOT_RUN_EXIT(appender_close);

有几点需要特别注意点:

  • 注意点1: 如果我们尝试打开mmap成功了,但是mmap对应的数据地址是NULL,那我们必须停止映射。因为NULL所代表的地址处于内核态,一旦映射了,势必造成Crash。
  • 注意点2:使用mmap的情况下,如果上次应用断电了、Crash,日志的信息还是存在的,但是并不一定能及时的转换成我们想要的日志文件。因此我们首先检查下mmap文件里面有没有数据,有的话先把这部分转换成日志。

而通过上层添加的日志,都会通过之前的xlogger_appender进行调用,进而往下层的__appender_async 记录日志。

__appender_async

__appender_async 需要和其异步dump线程一起搭配看,是两段非常有意思的代码,它涉及了一个将mmap/内存数据写回到磁盘的策略。

首先是添加日志:

static void __appender_async(const XLoggerInfo* _info, const char* _log) {
    ScopedLock lock(sg_mutex_buffer_async);
    if (NULL == sg_log_buff) return;

    char temp[16*1024] = {0};       //tell perry,ray if you want modify size.
    PtrBuffer log_buff(temp, 0, sizeof(temp));
    log_formater(_info, _log, log_buff);

    if (sg_log_buff->GetData().Length() >= kBufferBlockLength*4/5) {
       int ret = snprintf(temp, sizeof(temp), "[F][ sg_buffer_async.Length() >= BUFFER_BLOCK_LENTH*4/5, len: %d\n", (int)sg_log_buff->GetData().Length());
       log_buff.Length(ret, ret);
    }

    if (!sg_log_buff->Write(log_buff.Ptr(), (unsigned int)log_buff.Length())) return;

     // mmap/内存超出一定限度就写通知异步线程写回到文件中。
    if (sg_log_buff->GetData().Length() >= kBufferBlockLength*1/3 || (NULL!=_info && kLevelFatal == _info->level)) {
       sg_cond_buffer_async.notifyAll();
    }
}

其次是异步线程Dump成日志

static void __async_log_thread() {
    while (true) {

        ScopedLock lock_buffer(sg_mutex_buffer_async);

        if (NULL == sg_log_buff) break;

        AutoBuffer tmp;
        sg_log_buff->Flush(tmp);
        lock_buffer.unlock();

        if (NULL != tmp.Ptr())  __log2file(tmp.Ptr(), tmp.Length());

        if (sg_log_close) break;

        sg_cond_buffer_async.wait(15 * 60 *1000);
    }
}

不难看出,整个日志的主要策略就是利用mmap将日志写入到磁盘映射上,当超过三分之一的时候通知异步线程去写日志。

这样就利用了mmap的实时性、完整性打造了一个逻辑非常清晰易懂的日志,整体架构图如下:

深入理解Macho文件(二)- 消失的__OBJC段与新生的__DATA段

在上文中,我们提到了有个神秘的__OBJC段,Runtime的许多机制就是依赖于它。但是无论我怎么搜索网上相关的资料、苹果的官方文档,都发现找不到这个段了。

一脸懵逼。没事,打开class-dump,看看它怎么处理的。嘿嘿,果不其然,在Class-Dump的代码里,有着如下注释:

@0xced Old ABI has an OBJC segment. New ABI has a DATA,__objc_info section

通俗解释来说,我们先如今使用的都是Objective-C2.0,所以原先的__OBJC段的东西都不存在了,而是存入了__DATA段里。所以,我们就以如下这张图来探究下这些与Runtime加载有关的节。

__objc_imageinfo

这个节可以看作是区别Objective-C 1.0与2.0的区别。从苹果的OBJC源码中能看到这个节的数据结构定义(去除Swift相关)如下:

typedef struct {
    uint32_t version; // currently 0
    uint32_t flags;
} objc_image_info;

其中version这个字段目前永远为0。flags是用于做表示需要支持的特性的,比如是否需要/支持 Garbage Collection

SupportsGC          = 1<<1,  // image supports GC
  RequiresGC          = 1<<2,  // image requires GC

if (ii.flags & (1<<1)) {
    // App wants GC. 
    // Don't return yet because we need to 
    // check the AppleScriptObjC exception.
    wantsGC = YES;
}

__objc _classlist

这个节列出了所有的classmetaclass自身也是一种class)。

以计算器举例:我们先从MachoView找出一段数据,这个数据代表的就是class结构体所在的地址,如下图:

通过hopper查看地址:000000010002A128,得到如下结果:

内存地址(还没rebase过)中包含一个类本身的含义是什么意思呢?这都需要从Runtime里面来说起。

我们假设说我们有个类A,其父类为AA。有两个A类型的实例a1, a2

我们都知道在真正调用[a haha]的方法的时候,实质上是通过objc_msgSend执行一系列的函数查询来找到真正的函数IMP,进而产生函数调用的。

由于objc_msgSend的调用返回值是不确定的,需要根据不同的状态来返回,比如ARM64下的Indirect Result Location。因此其本身的实现需要通过汇编来,我们截取最终要的一段ARM64的汇编如下:

// 1. 定义全局函数符号 _objc_msgSend
ENTRY _objc_msgSend

// 2. 为Exception做准备
UNWIND _objc_msgSend, NoFrame
MESSENGER_START

// 3. 逻辑实现体
cmp    x0, #0            // nil check and tagged pointer check
b.le    LNilOrTagged        //  (MSB tagged pointer looks negative)
ldr    x13, [x0]        // x13 = isa
and    x16, x13, #ISA_MASK    // x16 = class    
LGetIsaDone:
    CacheLookup NORMAL        // calls imp or objc_msgSend_uncached
  • X0是函数调用者,即Self,比较其和nil的关系,如果是nil(或者tagged pointer)就走另外一种分支。通过此,我们也不难理解为什么可以对nil发送消息了
  • 根据self所在的地址,取其成员变量isa
  • x16 = x13 & MASK,也就意味着x16指向了内存里面的对应A class对象(
    注意:不是A class的实例对象)
  • 上述为什么要对ISA进行一个mask的位与操作,主要原因和Tagged Pointer类似,理由就不再赘述。
  • 执行CacheLookUp,具体的代码流程简要如下:

    .macro CacheLookup
    // x1 = SEL, x16 = isa
    ldp    x10, x11, [x16, #CACHE]    // x10 = buckets, x11 = occupied|mask
    and    w12, w1, w11        // x12 = _cmd & mask
    add    x12, x10, x12, LSL #4    // x12 = buckets + ((_cmd & mask)<<4)
    // x9 = key, x17 = _imp
    ldp    x9, x17, [x12]        // {x9, x17} = *bucket
    1:    cmp    x9, x1            // if (bucket->sel != _cmd)
        b.ne    2f            //     scan more
        CacheHit $0            // call or return imp
    
    2:    // not hit: x12 = not-hit bucket
        CheckMiss $0            // miss if bucket->sel == 0
        cmp    x12, x10        // wrap if bucket == buckets
        b.eq    3f
        ldp    x9, x17, [x12, #-16]!    // {x9, x17} = *--bucket
        b    1b            // loop
    
    3:    // wrap: x12 = first bucket, w11 = mask
        add    x12, x12, w11, UXTW #4    // x12 = buckets+(mask<<4)
    
    // Clone scanning loop to miss instead of hang when cache is corrupt.
    // The slow path may detect any corruption and halt later.
    
    ldp    x9, x17, [x12]        // {x9, x17} = *bucket
    1:    cmp    x9, x1            // if (bucket->sel != _cmd)
        b.ne    2f            //     scan more
        CacheHit $0            // call or return imp
    
    2:    // not hit: x12 = not-hit bucket
        CheckMiss $0            // miss if bucket->sel == 0
        cmp    x12, x10        // wrap if bucket == buckets
        b.eq    3f
        ldp    x9, x17, [x12, #-16]!    // {x9, x17} = *--bucket
        b    1b            // loop
    
    3:    // double wrap
        JumpMiss $0
    
    .endmacro
    

我们接着再来读读这段汇编。

  • x16承接上段汇编,是A class的实体,取出其cache成员变量。
  • 按照_cmdmask的位运算,找出其在bucket数组中的偏移量。取出的数据结构是个bucket_t,如下:

    struct bucket_t {
    private:
        cache_key_t _key;
        IMP _imp;
    }
    
  • 从上述数据结构不难理解,cache对象里面存了一个bucket数组,用于进行SEL对应的IMP,缓存。keySEL对应的地址。

  • 如果地址相同,就代表命中,执行CacheHit,其实就是简单的br x17。由于此时x17是IMP,即对应的函数地址,直接跳过去就完事了,这个分支下的objc_msgSend就执行完成了。
  • 那如果不相同,即命中的bucket里面不是我们要的SEL,就检查这个命中的桶是不是没有SEL,如果是空的,执行__objc_msgSend_uncached。这步后续开始就是去查找类方法列表->父类方法列表了。
  • 如果不为空,否则就执行循环,进行查询。

**一些细节知识:

  1. .macro可以在汇编里面定义一段可以被复用的代码段。
  2. .1b 代表的是向回找label定义为1的代码片段起始;1f代表向下找label定义为1的代码片段起始。
  3. 为什么在计算isa的时候先要位与一个mask,其原因在于现在的isa是一个兼具多种含义的指针。
    **

本文重点不在讲述Runtime上,所以objc_msgSend的细节就不去更深入的探究了。

所以,按照上述步骤来理解,我们可以发现,苹果实例对象的objc_msgSend的机制可以简要抽象如下图例子:

__objc _catlist

该节顾名思义,代表的就是程序里面有哪些Category。我们还是通过MachoView和Hopper来看一看:

从Hopper里面看出的内容我们不难得到,catlist也对应着一个Category_t的实体,会在程序运行的过程中存在于内存中。这个结构体的数据定义如下:

struct category_t {
    const char *name;
    classref_t cls;
    struct method_list_t *instanceMethods;
    struct method_list_t *classMethods;
    struct protocol_list_t *protocols;
    struct property_list_t *instanceProperties;
    // Fields below this point are not always present on disk.
    struct property_list_t *_classProperties;
}

__objc_protolist

该节的理解也非常简单,代表的就是程序里面有哪些Protocol。数据结构定义如下:

struct protocol_t : objc_object {
    const char *mangledName;
    struct protocol_list_t *protocols;
    method_list_t *instanceMethods;
    method_list_t *classMethods;
    method_list_t *optionalInstanceMethods;
    method_list_t *optionalClassMethods;
    property_list_t *instanceProperties;
    uint32_t size;   // sizeof(protocol_t)
    uint32_t flags;
    // Fields below this point are not always present on disk.
    const char **_extendedMethodTypes;
    const char *_demangledName;
    property_list_t *_classProperties;
}

__objc_classrefs

一开始这个节的意义我实在是没看懂。实在不理解在已经存在classlist这个数据节的情况下,为啥还是需要用这个类。后来经过一番实验发现,该节的目的是为了标记这个类究竟有没有被引用

那有没有被引用的意义是什么?可以包瘦身。如果在MachoView中都能直观告诉我们没有引用的类甚至是方法,都可以直接剔除了。

但是,作为一名经常奋战在包瘦身一线的同学,我可以直接告诉你,上述的想法是大错特错的。苹果这种可以利用字符串拼接从而调用大量runtime的方法,绝对坑哭了做包瘦身的人。

嘿嘿,不过其实这样也没啥难度,下一篇我会写一个基于Macho的包瘦身方案,绝对轻便简洁,不用基于AST来分析各种调用关系,这里卖个关子。

__objc_selrefs

这节的原理同上,告诉你究竟有哪些SEL对应的字符串被引用了。

__objc_superrefs

这节虽然中字面意义上我们知道,是对超类(即父类)的引用,但是没理解啊,为什么要有这么一个破玩意。
不懂就一点点摸索,从MachoView里面来看,数据对应的地址还是指向一个个在classlist出现的类实体。

通过和classlist里面出现的数据进行diff对比,如下图所示:

可以发现,所有出现的objc_superrefs都是会被继承的类。那么,为什么要单独设计这样一个来存放这样的信息呢?

哈哈哈:我上面的分析都是错的!!!!

哈哈哈:我上面的分析都是错的!!!!

哈哈哈:我上面的分析都是错的!!!!

真正的原因如下:
我们知道,我们在子类调用一个方法的时候,为了调用上层的父类的实现(如果有),常常会写出一个[super message]的代码。而这样的代码,在底层是会转换成调用objc_msgSendSuper2。而其接受的参数,第一个为结构体objc_super2,第二个为SEL。其中objc_super2的定义如下:

struct objc_super2 {
    id receiver;
    Class current_class;
};

为了构造这样的数据结构体,在汇编层面会将[super message]转换成如下的汇编指令:

注意看红框内的汇编代码,我们来分步骤解释下整体的汇编结构:

  • 首先在调用[ViewController viewDidLoad]的时候,x0是self(ViewController的实例),x1是@selector(viewDidLoad)。
  • 0x1000046c0 偏移的地方将sp向下申请了48(0x30)bytes的空间。
  • 0x1000046c4 将SP的地址存到的x8寄存器中。
    这个X8寄存器会很关键
  • 0x1000046d0 通过adrp指令加载内存数据中的一个page,根据这个page的offset找到对应的viewDidLoad方法的ref。存入x9
  • 0x1000046f8 通过x9寄存器中ref指向的地址,以该地址为内存读取真正的SEL,存入x1

至此,调用objc_msgSendSuper2的第二个参数准备完毕,我们再来看看第一个的参数是如何设置的。

  • 0x1000046d8 同样的方式,加载一个page的0x78的偏移位置的数据,点进去会发现是个class地址,存到x10中。

  • 然后,就轮到我们的栈空间出场了。我们先把x0存到sp处,然后再把x10,也就是上面说的class地址存入sp+8 (str x10, [sp, #0x8]

  • 最后,还记得我们之前提到的x8寄存器吗?我们之前可是将sp的值赋予了x8了。所以,在1000046fc x0, x8这个地方,我们将x8的值赋予了x0至此,调用objc_msgSendSuper2的第一个参数也准备完毕

最后附上objc_msgSendSuper2的代码供参考,逻辑非常简单,不再赘述。

ENTRY _objc_msgSendSuper2
    UNWIND _objc_msgSendSuper2, NoFrame
    MESSENGER_START

    ldp    x0, x16, [x0]        // x0 = real receiver, x16 = class
    ldr    x16, [x16, #SUPERCLASS]    // x16 = class->superclass
    CacheLookup NORMAL

    END_ENTRY _objc_msgSendSuper2

等等,心急的读者会问:你说了那么一大堆,你还是没解释到底为什么要存在superrefs?

在Objective-C的设计里面,函数就是函数,它并不知道自己属于哪个类里面。换句通俗的话来说,必须是你(编译器)说去哪个class实体的方法列表里面寻找调用,才会真正的去找对应的方法,函数自身不知道是父类还是子类。同时,由于苹果的设计原因,一个类初始化的实例,是不具备了解superclass的条件的,只有通过isa对应的类实体才能获得。因此,在构建objc_msgSendSuper2的第一个参数的时候,就不如指在编译期定其对应的current_class,以方便后续的superclass方法列表查找。

而且,也必须在编译期间,根据当前的类,去定义current_class这个字段的值,不然当我们有多个层级的继承关系时,在运行时如何从单一的self参数构建正确的向上查找层级,就当前的OC设计里,就做不到了。

C++里面,对于函数来说,是可以明确知道对应的所属类的。究其原因,在于C++的不同类,都是不同的命名空间,调用父类的方法时,需明确指定父类的命名空间,如BASE::method。

__objc_const

这个节的含义是所有初始化的常量的都显示在这。但是很多人都对此节有着巨大的误解,认为const int k = 5对应的数据会存放在__objc_const节中。

但是这是大错特错的,在代码里声明的const类型,实质上都属于__TEXT段,并属于其中的const节。而在__objc_const中存放的,是一些需要在类加载过程中用到的readonly data。具体这个readonly data包含了如下(但不限于)的数据结构:

// 只读数据
struct class_ro_t {
    uint32_t flags;
    uint32_t instanceStart;
    uint32_t instanceSize;
#ifdef __LP64__
    uint32_t reserved;
#endif

    const uint8_t * ivarLayout;

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

    const uint8_t * weakIvarLayout;
    property_list_t *baseProperties;

    method_list_t *baseMethods() const {
        return baseMethodList;
    }
};

// 方法列表
struct method_list_t:entsize_list_tt {
     uint32_t entsizeAndFlags;
     uint32_t count;
     Element first;
}

// 方法实体
struct method_t {
    SEL name;
    const char *types;
    IMP imp;
}

关于readonly data后续会再开一个章节单独讲解。

结尾

基本上MachO 关于Runtime涉及的主要的类就分析到这了,下一次继续剖析其他细枝末节。

深入剖析Macho (1)

起因

最近在公司里和一些同事搞了一些东西,略微底层。于是希望借这个机会好好把Macho相关的知识点梳理下。

虽然网上关于Macho的文章介绍一大堆,但是我希望能够从Macho的构成,加载过程以及需要了解的相关背景角度去进行分析,每一个点都力图深入。也会在这篇文章最后打造一个类似class-dump的小型工具。

程序启动加载的过程

当你点击一个icon启动应用程序的时候,系统在内部大致做了如下几件事:

  • 内核(OS Kernel)创建一个进程,分配虚拟的进程空间等等,加载动态链接器。
  • 通过动态链接器加载主二进制程序引用的库、绑定符号。
  • 启动程序

虽然简要概述很简单,但是有几个需要特别主要的地方:

  1. 二进制程序的格式是怎么样的?内核是如何加载它的?
  2. 内核是如何得知要使用哪种动态链接器的?
  3. 动态链接器和静态链接器的区别是啥?
  4. 程序在运行前究竟要做哪些步骤?顺序是怎么样的?

带着这些问题,我将一步步来剖析整个过程

二进制程序格式

在MacOS或者iOS上可执行的程序格式叫做Macho-O,它的主要成分如下图所示:

  • 一个mach_header标记一些元信息,比如架构、CPU、大小端等等
  • 多个Load Command告诉你究竟如何加载每个段的信息。
  • 多个SegementSection,包含了每个段自身的信息。包括一些数据、代码以及段的执行权限等等。

需要注意的是,不仅仅是可执行文件是Macho-O,目标文件(.o)以及动态库,静态库都是Mach-O格式。

所以,下面我们就用64位的定义从每个部分来介绍一下具体的数据结构:

mach_header_64

这个结构体代表的都是Mach-O文件的一些元信息,它的作用是让内核在读取该文件创建虚拟进程空间的时候,检查文件的合法性以及当前硬件的特性是否能支持程序的运行。

从源码中可以看出,整个结构题定义如下:

struct mach_header_64 {
    uint32_t    magic;        /* mach magic number identifier */
    cpu_type_t    cputype;    /* cpu specifier */
    cpu_subtype_t    cpusubtype;    /* machine specifier */
    uint32_t    filetype;    /* type of file */
    uint32_t    ncmds;        /* number of load commands */
    uint32_t    sizeofcmds;    /* the size of all the load commands */
    uint32_t    flags;        /* flags */
    uint32_t    reserved;    /* reserved */
};
  • magic 用于标识当前设备的是大端序还是小端序。如果是0xfeedfacf(MH_MAGIC_64)就是大端序,而0xcffaedfe(MH_CIGAM_64)是小端序,iOS系统上是小端序。
  • cputype 标识CPU的架构,比如ARM,X86,i386等等,进行了宏观划分。
  • cpusubtype 具体的CPU类型,区分不同版本的处理器。
  • filetype 划分之前我们提到的文件类型,比如是可执行文件还是目标文件。
  • ncmds 有几个LoadCommands,每个LoadCommands代表了一种Segment的加载方式。
  • sizeofcmds LoadCommand的大小,主要用于划分Mach-O文件的‘区域’。
  • flags 标记了一些dyld过程中的参数。
  • reversed 没用。

这里有个比较有意思的问题是,我为了验证大端序小端序的问题的时候,用了MacOS上的计算器进行
验证,本质上这应该是个小端序的应用程序,其二进制如下:

屏幕快照 2017-06-11 下午3.12.33.png

但是在otoolMachoView上看出来都是MH_MAGIC_64,如下所示:

屏幕快照 2017-06-11 下午3.13.47.png

我擦,这下看了懵逼,难道是我理解错了?于是赶紧翻了下class-dump代码,其解析header部分代码如下:

// 解析部分代码
_byteOrder = CDByteOrder_LittleEndian;

CDDataCursor *cursor = [[CDDataCursor alloc] initWithData:data];
_magic = [cursor readBigInt32];
if (_magic == MH_MAGIC || _magic == MH_MAGIC_64) {
    _byteOrder = CDByteOrder_BigEndian;
} else if (_magic == MH_CIGAM || _magic == MH_CIGAM_64) {
    _byteOrder = CDByteOrder_LittleEndian;
} else {
    return nil;
}

// readBigInt32的代码
- (uint32_t)readBigInt32;
{
    uint32_t result;

    if (_offset + sizeof(result) <= [_data length]) {
        result = OSReadBigInt32([_data bytes], _offset);
        _offset += sizeof(result);
    } else {
        [NSException raise:NSRangeException format:@"Trying to read past end in %s", __cmd];
        result = 0;
    }

    return result;
}

我们在用LLDB看下_data里面的内容指向的内存地址:

(lldb) po _data
<OS_dispatch_data: data[0x100600b40] = { leaf, size = 199520, buf = 0x100281000 }>

Xcode Memory看下:

屏幕快照 2017-06-11 下午3.25.06.png

看起来是没错的。然后由于MacOSX本身是小端序的,CFFAEDFE这样的数据会被自动解析成FE ED FA CF。所以这样是有问题的。因此,class-dump采用了OSReadBigInt32的方式去解析:

OS_INLINE
UInt32
OSReadSwapInt32(
    volatile void               * base,
    volatile UInt                 offset
)
{
    union lconv {
    UInt32 ul;
    UInt8  uc[4];
    } *inp, outv;

    // 步骤1
    inp = (union lconv *)((UInt8 *)base + offset);

    // 步骤2
    outv.uc[0] = inp->uc[3];
    outv.uc[1] = inp->uc[2];
    outv.uc[2] = inp->uc[1];
    outv.uc[3] = inp->uc[0];

    // 步骤3
    return (outv.ul);
}

这个方法会利用union的特性,进行数据交换。我们还是用刚刚的例子来验证:

  • 步骤1按照默认方式读出数据:FE ED FA CF
  • 步骤2进行交换,地址从低到高,分别是FE ED FA CF
  • 步骤3利用union的特性,当成一个32的数输出,按照默认小端序解析,会成为CF FA ED FE。也即是MH_CIGAM_64,是小端序。

其实按照MachoView的解析方式,将MH_CIGAM_64MH_MAGIC_64理解成MACHO文件和当前平台的编码顺序是否一致更好,如果解析出来是MH_CIGAM_64则表示不一致;否则一致。

Segment(段)

讲完了Mach-O文件的header部分,我们需要进行Load Commands部分。但是在这之前,我想先大致介绍下Mach-O中的Segment及其下属的Section(节),让大家能更好的理解Load Commands。

从整体上来说,Mach-O里面包含的段有以下这些:

  • __TEXT 代码段/只读数据段
  • __PAGEZERO Catch访问NULL指针的非法操作的段
  • __DATA 数据段
  • __LINKEDIT 包含需要被动态链接器使用的信息,包括符号表、字符串表、重定位项表等。
  • __OBJC 包含会被Objective Runtime使用到的一些数据。

关于__OBJC这个段,我是一脸懵逼的,从Macho文档上看,他包含了一些编译器私有的节。没有任何公开的资料描述,具体让我研究研究再说。

Section(节)

刚刚我们提到的__TEXT__DATA段都分别有下属的节。

之所以按照段->节的方式组织,是因为同一个段下的节,在内存的权限相同,可以不完全按照页大小进行对齐,节省内存空间。而对外整体暴露段,在装载程序的时候完整映射成一个vma,可以更好的做内存对齐。

名称 作用
TEXT.text 只有可执行的机器码
TEXT.cstring 去重后的C字符串
TEXT.const 初始化过的常量
TEXT.stubs 符号桩。本质上是一小段会直接跳入lazybinding的表对应项指针指向的地址的代码。
TEXT.stub_helper 辅助函数。上述提到的lazybinding的表中对应项的指针在没有找到真正的符号地址的时候,都指向这。
TEXT.unwind_info 用于存储处理异常情况信息
TEXT.eh_frame 调试辅助信息
DATA.data 初始化过的可变的数据
DATA.nl_symbol_ptr 非lazy-binding的指针表,每个表项中的指针都指向一个在装载过程中,被动态链机器搜索完成的符号
DATA.la_symbol_ptr lazy-binding的指针表,每个表项中的指针一开始指向stub_helper
DATA.const 没有初始化过的常量
DATA.mod_init_func 初始化函数,在main之前调用
DATA.mod_term_func 终止函数,在main返回之后调用
DATA.bss 没有初始化的静态变量
DATA.common 没有初始化过的符号声明

其中,比较难以理解的可能是__la_symbol_ptr,让我们还是来以计算器的例子来理解:

  • 我们先从MachoView上找一个stub,比如[xxxx -> _CFRelease]。
  • 其数据是FF256A7C0000,结合这个节是在__TEXT段中,我猜测是应该一段汇编代码的16进制表示。

屏幕快照 2017-06-12 上午10.48.21.png

  • 从Hopper中打开,查看对应偏移量的stub含义:

屏幕快照 2017-06-12 上午10.40.50.png

我们可以看到这段代码的16进制表达就是:

屏幕快照 2017-06-13 下午3.47.50.png

从上图不难看出,stub的含义就是跳转到以__la_symbol_ptr对应表项数据所指向地址的代码。

  • 跳入以后,我们可以看到如下代码:

屏幕快照 2017-06-12 上午10.41.02.png

可以看到,在还没加载程序的时候,对应表项的数据还是dq _CFRelease。双击点进去看一下:

屏幕快照 2017-06-13 下午3.51.34.png

这里显示的应该是有点问题,如果全0的话是不可能使用lazy binding的。

我们还是用MachOView来看一下:

屏幕快照 2017-06-13 下午4.04.04.png

跳转到这个地址看看,没错了,处于stub_helper节里了:

屏幕快照 2017-06-13 下午4.04.28.png

屏幕快照 2017-06-13 下午4.04.33.png

__la_symbol_ptr 里面所有表项的数据都会被bind成dyld_stub_helper

从FBTweak 源码剖析一些中阶知识

在开发的过程中,我们进场要做一些类似于参数调整之类的琐碎问题。如果每次都通过CMD + R来编译运行查看效果,浪费的时间真是得不偿失。因此,在看到了FBTweak这个项目以后,就感觉比较有意思,让我们来对这个项目一探究竟。

晕头转向的宏定义

打开项目,整个项目非常简单,抛去UI部分,主要使用的主API就是在FBTweakInline.h中的4个宏定义:

#define FBTweakInline(category_, collection_, name_, ...) _FBTweakInline(category_, collection_, name_, __VA_ARGS__)

#define FBTweakValue(category_, collection_, name_, ...) _FBTweakValue(category_, collection_, name_, __VA_ARGS__)

#define FBTweakBind(object_, property_, category_, collection_, name_, ...) _FBTweakBind(object_, property_, category_, collection_, name_, __VA_ARGS__)

#define FBTweakAction(category_, collection_, name_, ...) _FBTweakAction(category_, collection_, name_, __VA_ARGS__)

看起来很简单,但是随便点一个深入进去看,就会发现如下这些令人头昏脑胀的宏定义。熟悉Facebook开源项目的人可能都知道,它们就好这口。所以在进行整个项目的介绍前,我们先大致分析下各个宏的作用。

#define __FBTweakConcat_(X, Y) X ## Y
#define __FBTweakConcat(X, Y) __FBTweakConcat_(X, Y)

#define __FBTweakIndex(_1, _2, _3, value, ...) value
#define __FBTweakIndexCount(...) __FBTweakIndex(__VA_ARGS__, 3, 2, 1)

#define __FBTweakDispatch1(__withoutRange, __withRange, __withPossible, ...) __withoutRange
#define __FBTweakDispatch2(__withoutRange, __withRange, __withPossible, ...) __withPossible
#define __FBTweakDispatch3(__withoutRange, __withRange, __withPossible, ...) __withRange
#define _FBTweakDispatch(__withoutRange, __withRange, __withPossible, ...) __FBTweakConcat(__FBTweakDispatch, __FBTweakIndexCount(__VA_ARGS__))(__withoutRange, __withRange, __withPossible)

_FBTweakBind

我们以_FBTweakBind作为突破口,来进行深入分析。

#define FBTweakBind(object_, property_, category_, collection_, name_, ...) _FBTweakBind(object_, property_, category_, collection_, name_, __VA_ARGS__)

这个宏包了层皮,就是接受至少5个参数的可变参数,并将参数传递给_FBTweakBind这个宏。在这层定义中,我们看到了...__VA_ARGS__

...对于一个宏/函数来说,意味着接受可变参数。这个参数的形参(换句话说,你要使用或者传递给其他函数的载体)就是__VA_ARGS__

好,接下来我们看下_FBTweakBind,其定义如下:

#define _FBTweakBind(object_, property_, category_, collection_, name_, ...) _FBTweakDispatch(_FBTweakBindWithoutRange, _FBTweakBindWithRange, _FBTweakBindWithPossible, __VA_ARGS__)(object_, property_, category_, collection_, name_, __VA_ARGS__)

卧槽,一开始看的时候,头大了,怎么两个括号呢?仔细一看,_FBTweakBindWithoutRange_FBTweakBindWithRange_FBTweakBindWithPossible 都是不同的宏定义。那么整个_FBTweakBind的作用就是根据可变参数,传递给_FBTweakDispatch,从上述三个宏定义中选择出一个对应的,然后继续调用宏展开。

好,再看__FBTweakDispatch,如下:

#define _FBTweakDispatch(__withoutRange, __withRange, __withPossible, ...) __FBTweakConcat(__FBTweakDispatch, __FBTweakIndexCount(__VA_ARGS__))(__withoutRange, __withRange, __withPossible)

这个也比较绕,我们继续抽丝剥茧,可以发现,核心的本质就变成:

  • __FBTweakIndexCount(args) 返回一个具体数字(1、2、3)
  • __FBTweakConcat(__FBTweakDispatch, 数字) 生成具体的宏定义Token,比如__FBTweakDispatch1__FBTweakDispatch2之类的。
  • 利用刚刚的token继续做函数展开。

到这里,整体思路都没啥特别困难的,但是让我有点诧异的如下两个宏定义:

#define __FBTweakIndex(_1, _2, _3, value, ...) value
#define __FBTweakIndexCount(...) __FBTweakIndex(__VA_ARGS__, 3, 2, 1)

我们刚刚提到,__FBTweakIndexCount会根据参数返回具体的数字,那它本质上是依靠__FBTweakIndex去返回第4个参数。(_1, _2, _3就是普通的宏展开占位符,别被骗了,和x, y, z没区别)。

好,这个__FBTweakIndex按定义来说,至少需要4个参数,但是我在试了__FBTweakIndexCount(),他竟然也能给返回1,这就让我懵逼了。

按照我的理解,当我传递参数为空的时候,__VA_ARGS__就是空,那么宏定义展开的时候,这个应该是不作数的,就导致缺少了一个参数啊???

后来,我发现我思考错了,我从语法分析的角度去分析了这个宏,但是实际上,宏就是简单的“token”展开!!!

我们再来看看__FBTweakIndex的定义,展开后其实就是要了第4个参数,前面的都没啥用。

也就是说,我直接写

__FBTweakIndex(..., 1, 2, 3);
__FBTweakIndex(,, 1, 2, 3);
__FBTweakIndex(, 1, 2, 3);

这种虽然没意义的、甚至是直觉上觉得都不能编译通过的代码,都是合理正确的

是不是有点懵逼,休息一下,消化一下。

好,我们假设我们获取了数字1,因此,新的宏展开为_FBTweakBindWithoutRange,定义如下:

#define _FBTweakBindWithoutRange(object_, property_, category_, collection_, name_, default_) \
((^{ \
  FBTweak *__bind_tweak = _FBTweakInlineWithoutRange(category_, collection_, name_, default_); \
  _FBTweakBindInternal(object_, property_, category_, collection_, name_, default_, __bind_tweak); \
})())

这写法,也是醉了,我以前只是在JavaScript中看到过这样匿名函数自调用的写法,Facebook太强了。

整个过程继续抽丝剥茧,_FBTweakInlineWithoutRange会调用到如下函数:

#define _FBTweakInlineWithPossibleInternal(category_, collection_, name_, default_, possible_) \
((^{ \
  /* store the tweak data in the binary at compile time. */ \
  __attribute__((used)) static FBTweakLiteralString category__ = category_; \
  __attribute__((used)) static FBTweakLiteralString collection__ = collection_; \
  __attribute__((used)) static FBTweakLiteralString name__ = name_; \
  __attribute__((used)) static void *default__ = (__bridge void *) ^{ return default_; }; \
  __attribute__((used)) static void *possible__ = (__bridge void *)  ^{ return possible_; }; \
  __attribute__((used)) static char *encoding__ = (char *)@encode(__typeof__(default_)); \
  __attribute__((used)) __attribute__((section (FBTweakSegmentName "," FBTweakSectionName))) static fb_tweak_entry entry = \
    { &category__, &collection__, &name__, (void *)&default__, (void *)&possible__, &encoding__ }; \
\
  /* find the registered tweak with the given identifier. */ \
  FBTweakStore *store = [FBTweakStore sharedInstance]; \
  FBTweakCategory *category = [store tweakCategoryWithName:category__]; \
  FBTweakCollection *collection = [category tweakCollectionWithName:collection__]; \
\
  NSString *identifier = _FBTweakIdentifier(&entry); \
  FBTweak *__inline_tweak = [collection tweakWithIdentifier:identifier]; \
\
  return __inline_tweak; \
})())

这里相对来说比较复杂,我们逐个知识点进行查看。

__attribute__((used)) static FBTweakLiteralString category__ = category_;

这个前面出来了__attribute__((used)),它的作用是告诉编译器,我声明的这个符号是需要保留的。我们在开发iOS的过程中,常常会遇到有时候会报警告xxx unused,在某些优化的情况下,编译器甚至都不报警告,直接将我们进行了剔除,这样在编译后(预处理、编译、汇编)生成的目标文件里就存在我们这个符号。

继续看这行,又出现了我们新的不熟悉的__attribute__((section (FBTweakSegmentName "," FBTweakSectionName)))

__attribute__((used)) __attribute__((section (FBTweakSegmentName "," FBTweakSectionName))) static fb_tweak_entry entry = \
       { &category__, &collection__, &name__, (void *)&default__, (void *)&possible__, &encoding__ };

我们知道,iOS里面函数代码一般存在于__TEXT段,数据部分一般存在于__DATA段。但是在每个段中,都存在着许多不同作用的节(section)。比如存储常量字符串的__cfstring等等。

因此,编译器提供了我们一种__attribute__((section("xxx段,xxx节")的方式让我们讲一个指定的数据储存到我们需要的节当中。

上述基础知识很容易理解,但是我在实际读代码理解整个运行机制的时候,还是有点懵逼。

令人困惑的指针和数据

我们将上章节中的代码简单具象一下:

static NSString *haha = @"Mingyi";
_attribute__((section ("__DATA, MinyiSpecific"))) static NSString ** entry = &haha;

我们看到,我们将&haha所代表这个数据存入了__DATA Segment的MinyiSpecificSection中。

但是,&haha的类型是一个NSString **,即指向指针的指针。换句话说,这个指针的指针的背后的含义是haha这个变量本身的地址。我们知道,苹果的程序在加载的过程中都会ASLR地址随机化,那我们对一个地址进行存储,有啥用呢?

上述的理解,有个最大的误区,就是从运行时来理解了地址,而不是从编译后的目标文件来理解程序,什么意思呢?

在我们这里,因为声明了常量字符串@”Mingyi”,在编译(而非程序运行)后,它就存在于可执行文件的cfstring这个节中,如下所示:

这个数据格式的起始地址偏移为00000001 00003068

而由于我们使用的是静态变量haha,在编译后,也会生成一个指向刚刚那个常量字符串地址的数据。这个数据存在于__DATA,__data中,如下图:

00000001 00003DC0就是在代码中&haha的含义。

无论是3068抑或是3DC0,它们的含义都是一个地址偏移,在程序加载运行的过程中,都要进行地址REBASE,去获得真正正确地址空间中的数据。但是由于我们的

_attribute__((section ("__DATA, MinyiSpecific"))) static NSString ** entry = &haha;

是一个编译期的行为,因此这行语句的行为表征的还是存储没有rebase之前的相对地址偏移,如下图:

好,至此,我们才将整个数据存入的部分搞懂。

注意,iOS是小端序,即数据的高位在低地址。

镜像数据加载

说完了数据存储,我们再来看看怎么从编译后的执行文件的节中读取出文件,代码如下:

  static uint32_t _tweaksLoaded = 0;
  if (OSAtomicTestAndSetBarrier(1, &_tweaksLoaded)) {
    return;
  }

#ifdef __LP64__
  typedef uint64_t fb_tweak_value;
  typedef struct section_64 fb_tweak_section;
  typedef struct mach_header_64 fb_tweak_header;
#define fb_tweak_getsectbynamefromheader getsectbynamefromheader_64
#else
  typedef uint32_t fb_tweak_value;
  typedef struct section fb_tweak_section;
  typedef struct mach_header fb_tweak_header;
#define fb_tweak_getsectbynamefromheader getsectbynamefromheader
#endif


  FBTweakStore *store = [FBTweakStore sharedInstance];

  // 1. 注意点
  uint32_t image_count = _dyld_image_count();
  for (uint32_t image_index = 0; image_index < image_count; image_index++) {
    const fb_tweak_header *mach_header = (const fb_tweak_header *)_dyld_get_image_header(image_index);

    unsigned long size;

    // 2.注意点
    fb_tweak_entry *data = (fb_tweak_entry *)getsectiondata(mach_header, FBTweakSegmentName, FBTweakSectionName, &size);
    if (data == NULL) {
      continue;
    }
    size_t count = size / sizeof(fb_tweak_entry);
    for (size_t i = 0; i < count; i++) {
      fb_tweak_entry *entry = &data[i];
      FBTweakCategory *category = [store tweakCategoryWithName:*entry->category];
      if (category == nil) {
        category = [[FBTweakCategory alloc] initWithName:*entry->category];
        [store addTweakCategory:category];
      }

      FBTweakCollection *collection = [category tweakCollectionWithName:*entry->collection];
      if (collection == nil) {
        collection = [[FBTweakCollection alloc] initWithName:*entry->collection];
        [category addTweakCollection:collection];
      }

      NSString *identifier = _FBTweakIdentifier(entry);
      if ([collection tweakWithIdentifier:identifier] == nil) {
        FBTweak *tweak = _FBTweakCreateWithEntry(identifier, entry);

        if (tweak != nil) {
          [collection addTweak:tweak];
        }
      }
    }
  }
}
  • 注意点1:通过dyld获取当前程序加载时候的image个数。什么是image个数呢?你的可执行文件就是一个image。那为什么又会存在多个image呢?如果你平时使用的都是静态库,那么在编译连接完成后,静态库这个scope就不存在了,所有的符号都互相匹配完成。但是呢,苹果自身比如UIKIt之类的库又是动态库,因此,你的可执行文件中会存在多个image。

  • 注意点2:读取macho文件中对应的段和节中我们自己储存的数据。

修改同步映射

我们之前看到,在利用FBTweakBind可以将某个对象的属性和操作进行映射,同步修改。这里的机制看起来很复杂,其实非常简单,就是简单的利用了KVO。

FBTweak就是想要修改的属性,其包含了多个Observer。在FBTweakcurrentValue更改后,会利用观察者模式对每个Observer发送属性更新通知。而FBTweak的属性值改变则是和UI界面,利用KVO进行联动修改。

这里就没什么过于复杂的技术含量了,具体看下FBTweak_FBTweakColorViewController代码就行。

其余知识点:

  • objc_precise_lifetime的作用

    __attribute__((objc_precise_lifetime)) id strongObject = _object;
    

这行代码的作用就是确保ARC不会进行特殊的优化,提前将一些本来认为在Scope最后才释放的对象提前释放了。

  • _Generic是一个编译时的泛型选择,他能根据变量的类型兼容来输出不同的结果,比如:

    #define cbrt(X) _Generic((X), \
              long double: cbrtl, \
                  default: cbrt,  \
    /*for clang*/ const float: cbrtf, \
                    float: cbrtf  \
    )(X)
    
    int main(void)
    {
        long double x = 8.0;
        const float y = 3.375;
        printf("cbrt(8.0) = %Lg\n", cbrt(x)); // selects the default cbrt
        printf("cbrtf(3.375) = %f\n", cbrt(y)); // gcc: converts const float to float,
                                                // then selects cbrtf
                                                // clang: selects cbrtf for const float
    }
    

上述代码就根据编译器的类型声明,选择不同的函数进行执行。

ARM64下Indirect Result Location摸索

ARM64下Indirect Result Location摸索

之前学习汇编的时候,大概了解了一些ARM64下寄存器的用途,比如x0 - x7作为函数传递使用。同时,x0也可以作为函数返回值时候的寄存器。

但是,今天在研究一些跟返回结构体相关的时候,发现返回值并不是放在X0寄存器中。上网搜索了一下资料,发现在ARM64下,当一个Callee函数返回的内容大于16bytes的时候,该内容会被存到一个内存地址当中,然后这个内存地址的值会存入寄存器x8。后续Caller函数在使用该返回值的时候,会从X8寄存器中取出内存地址,并从内存地址取出内容的值

是不是有点绕,还是让我们来看个例子吧。

原理

首先我根据大于16 bytes的要求定义了如下结构体:

typedef struct {
    int64_t i;
    int64_t j;
    int64_t k;
} MYStruct;

在ARM64下,该结构体默认按4 bytes对齐,每个int64占用8 bytes,因此结构体大小24 bytes

我们定义如下函数,用于返回一个该结构体:

- (MYStruct)testIndirectResultLocation:(int64_t)i1 second:(int64_t)i2 th:(int64_t)i3
{
    MYStruct s;
    s.i = i1;
    s.j = i2;
    s.k = i3;
    return s;
}

这个函数很简单,传入三个值。然后构造个局部变量MYStruct s,将其对应的成员变量按照刚刚的传入参数赋值,最后返回该结构体。

该函数调用在未优化的前提下的汇编结果如下:

IndirectResultLocation`-[ViewController testIndirectResultLocation:second:th:]:
    // 预留空间
    <+0>:  sub    sp, sp, #0x40             ; =0x40 

    // 存参
    <+4>:  str    x0, [sp, #0x38]
    <+8>:  str    x1, [sp, #0x30]
    <+12>: str    x2, [sp, #0x28]
    <+16>: str    x3, [sp, #0x20]
    <+20>: str    x4, [sp, #0x18]

    // 赋值
->  <+24>: ldr    x0, [sp, #0x28]
    <+28>: str    x0, [sp]
    <+32>: ldr    x0, [sp, #0x20]
    <+36>: str    x0, [sp, #0x8]
    <+40>: ldr    x0, [sp, #0x18]
    <+44>: str    x0, [sp, #0x10]

    // 将结构体存到x8寄存器的值代表的地址去
    <+48>: ldr    x0, [sp]
    <+52>: str    x0, [x8]
    <+56>: ldr    x0, [sp, #0x8]
    <+60>: str    x0, [x8, #0x8]
    <+64>: ldr    x0, [sp, #0x10]
    <+68>: str    x0, [x8, #0x10]

    // 释放空间
    <+72>: add    sp, sp, #0x40             ; =0x40 
    <+76>: ret    

第一行:SP即Stack Pointer,向下减0x40(64 bytes)的大小,预先分配出函数需要用的栈空间。为什么要预留这么多的大小呢?首先按照Objective-C的函数调用规定,前两个参数必须是selfselector,也即会使用到寄存器X0X1。然后该函数有三个形参,使用了X2-X4的寄存器
上述这五个,大小占用了self(8 bytes) + selector(8 bytes) + 三个参数(24 bytes) = 40 bytes。那么还有24 bytes去干嘛了呢?

别忘了,我们在函数中可以声明了一个局部变量MYStruct s,该结构体大小是24 bytes。而在函数调用中使用到的变量,基本上都在栈区中开辟对应的空间进行暂存。

后续第二行到第六行非常简单易懂,就是把上述5个参数存到实际的栈区中去使用。按照这个存法以后,内存布局如下(注意高地址在上,低地址在下,ARM下的栈是向下增长):

将参数都存入到栈以后,我们就要对结构体进行赋值了,这些操作在第七行到第十二行之间。
1赋值给[SP],2赋值给[SP + #0x8],3赋值给[SP + #0x10]。如果不理解啥意思的话,可以看下我自己转化的伪代码:

void *address = &s;
*(int64_t *)(address) = 1;
*(int64_t *)(address + 8) = 2;
*(int64_t *)(address + 16) = 3;

赋值完以后,我们可以通过内存分布看下数据是否正确:

当赋值完成后,就要进行结构体的返回了。这里不是简单的mov x0, sp之类的操作,而是一串和X8寄存器相关操作。

其实原理差不多,转化成伪代码的话,基本上是这样:

void *toSaveAddress = [x8];
void *valueNowAddress = [sp];

*(int64_t *)(toSaveAddress) = *valueNowAddress;
*(int64_t *)(toSaveAddress + 8) = *(valueNowAddress + 8);
*(int64_t *)(toSaveAddress + 16) = *(valueNowAddress + 16);

操作完成后,释放空间即可。

补充

其实ARM64在汇编层面实现的这么复杂, 我们在编程层面只要按照如下方式理解即可:

some_struct foo(int arg1, int arg2);
some_struct s = foo(1, 2);

会被编译成:

some_struct* foo(some_struct* ret_val, int arg1, int arg2);
some_struct s; 
foo(&s, 1, 2);

后续

从本文中我们不难看出,ARM64针对不同大小的返回值都有着对应的Calling Convention。下次我准备来摸索下,处于8 bytes - 16 bytes之间的返回值究竟是怎么处理的。

快速计算两组数据源的变化的方法 - Doppelganger 源码剖析

Doppelganger 源码剖析

性能优化系列一:如何快速的计算UITableView的数据量变换。

今天要介绍的是一个比较精简但是很实用的库:Doppelganger。平时我们经常会和UITableView或者UICollectionView打交道,所以数据源(dataSource)及其变化就非常重要。

如何高效的求解两次数据源之间的删除、增加以及移动(交换位置)就成为了一个可以显著加速的地方。

备注:这里指的是将一定量的数据计算放在客户端来进行,而不是通过多次发送网络请求获取数据然后整体重新刷新。有人会问,什么情况下会有这样的需求呢?比如,你有个用户选项,可以支持按照倒序或者正序的方式进行布局,那这个时候,你直接在本地进行计算并展示差量布局计算,就要比从网络请求多次拉取整体重新刷新的效果赞很多。

本文提到的Doppelganger其实就是一种对于上述需求的封装,提供了及其简化的数据源更新机制。抛开其性能不谈,我们先来看看其实现。

数据结构

从需求不难看出,我们的数据结构需要支持如下潜在数据记录:

  • 改动类型:增加、删除、移动
  • 改动索引:增加的话,是插入到哪行、删除的话是删除哪行、移动的话是从哪行移动到哪行。

基于此,数据结构的定义就很显而易见了:

typedef NS_ENUM(NSInteger, WMLArrayDiffType) {
    WMLArrayDiffTypeMove,
    WMLArrayDiffTypeInsert,
    WMLArrayDiffTypeDelete
};

@interface WMLArrayDiff : NSObject

@property (nonatomic, readonly) WMLArrayDiffType type;

@property (nonatomic, readonly) NSUInteger previousIndex;

@property (nonatomic, readonly) NSUInteger currentIndex;

@end

其中,有些字段在某些类型下可以为空。

计算变动

我们先简化下我们的模型,我们就是两个数组A和B,里面各自一堆不重复的数字,分别代表之前的数据源和现在的数据源。现在我们需要求得这两个数组之前提到的三种变化。

首先是删除的计算,非常简单,只要计算在A中不在B中就可以:

NSSet *deletedObject = ({
    NSMutableSet *set = [previousSet mutableCopy];
    [set minusSet:currentSet];
    [set copy];
});

然后是增加的计算,同样简单,只要计算在B中不在A中的:

NSSet *insertedObjects = ({
    NSMutableSet *set = [currentSet mutableCopy];
    [set minusSet:previousSet];
    [set copy];
});

最后就是计算那些即在A中又在B中的改变,对于这种计算,我们要得到在A中的原索引和现在的新索引。

- (NSArray *)_moveDiffsWithDeletedObjects:(NSSet *)deletedObjects insertedObjects:(NSSet *)insertedObjects {    
    // TODO: Improve on O(n^2)
    __block NSInteger delta = 0;
    NSMutableArray *result = [NSMutableArray array];
    [self.previousArray enumerateObjectsUsingBlock:^(id leftObj, NSUInteger leftIdx, BOOL *stop) {
        if ([deletedObjects containsObject:leftObj]) {
            delta++;
            return; 
        }
        NSUInteger localDelta = delta;
        for (NSUInteger rightIdx = 0; rightIdx < self.currentArray.count; ++rightIdx) {
            id rightObj = self.currentArray[rightIdx];
            if ([insertedObjects containsObject:rightObj]) {
                localDelta--;
                continue;
            }

            if (![rightObj isEqual:leftObj]) {
                continue;
            }

             //  注意点:          
            NSInteger adjustedRightIdx = rightIdx + localDelta;
            // 首先如果前后索引一致,没有变化的区别,没有必要做diff变化
            // 或者如果你前面删除了一条,自身索引是1,然后这边是0,那也没必要做move变化。
            if (leftIdx != rightIdx && adjustedRightIdx != leftIdx) {
                [result addObject:[WMLArrayDiff arrayDiffForMoveFromIndex:leftIdx toIndex:rightIdx]];
            }
            return;
        }
    }];
    return [result copy];
}

上述代码一开始我看了也是懵逼了,我觉得直接二重遍历计算同样数在不同两组数据源中的索引区别不就行了?在读了代码一遍以后确定了,作者的思路是这样的:

  1. 如果在旧数组中和新数组中的数据源一样,那就不更新了,也即leftIdx != rightIdx的判断。

  2. 如果在旧数组中,索引为1,但是之前的0索引位置的数据删除了;然后这个索引为1的数据在新数据中位置为索引0,那么也不需要改了,因为之前计算删除变化的时候已经做了这个相同的效果。

时间复杂度

虽然不知道苹果内部的数据结构代码实现是如何的,但是我们可以进行数据模拟,同时也可以看看苹果WWDC的文章 来进行时间复杂度估算。

而从上面实现的计算变动源代码来看,整个库的实现时间复杂度还是有所欠缺的,到达了O(mn) + O(n) ≈ O(mn)的级别,因此我们可以进行一些优化。

备注:O(mn)就是二重循环遍历的问题。其中m是数据源A的数据个数,n是数据源b的数据个数。简单来看就是O(n^2)级别的运算耗时。

怎么优化呢,答案很简单,就是利用动态规划思想来求解最小编辑距离。

我们举个简单的例子,还是没有重复数组的数组,A = [1, 3, 5, 6, 8]以及B = [1, 5, 6, 9, 2]

怎么样最小变化才能从A变成B呢?

我们列一个二维的矩阵先,如下图:

备注:蓝色为原数据,绿色为新数据,黄色的为最小变化的开销。

不难看出,这个算法的时间复杂度就是填满整张表的O(mn)。

看到这,有人会问,你的Big(O) 复杂度都是O(mn)啊,这你优化在什么地方啊。

从时间复杂度分析上看,最大数值都是O(mn)没错,但是在大数量的情况下,还是会有比较大的区别。

究竟原因在于作者的算法做了很多重复性的劳动,而利用动态规划的特征可以合理的储存状态,避免重复性的劳动。

一些细节

在查看源码的时候,查看过一个代码,

NSSet *deletedObject = ({
    NSMutableSet *set = [previousSet mutableCopy];
    [set minusSet:currentSet];
    [set copy];
});

这里非常有意思,利用了Statements and Declarations in Expressions,具体不多说了,非常巧妙,大开眼界。

The last thing in the compound statement should be an expression followed by a semicolon; the value of this subexpression serves as the value of the entire construct

啥意思呢?就是说这种符合表达式的最后一句必须是一个用分号结尾的表达式,并且这个表达式必须有返回值。而这个返回值就作为整个符号表达式的返回值。

iOS疑难问题排查之深入探究dispatch_group crash

起因

昨天其他部门的同事突然反馈一起相对来说比较严重的Crash问题(占比达到了yyyy左右,并且从Crash堆栈上可以发现很多情况下是一启动就Crash了)。去掉隐私数据大致堆栈如下:

Thread 0 Crashed:
0   libdispatch.dylib               0x000000018953e828 _dispatch_group_leave :76 (in libdispatch.dylib)
1   libdispatch.dylib               0x000000018954b084 __dispatch_barrier_sync_f_slow_invoke :320 (in libdispatch.dylib)
2   libdispatch.dylib               0x000000018953a1bc __dispatch_client_callout :16 (in libdispatch.dylib)
3   libdispatch.dylib               0x000000018953ed68 __dispatch_main_queue_callback_4CF :1000 (in libdispatch.dylib)
4   CoreFoundation                  0x000000018a65e810 ___CFRUNLOOP_IS_SERVICING_THE_MAIN_DISPATCH_QUEUE__ :12 (in CoreFoundation)
5   CoreFoundation                  0x000000018a65c3fc ___CFRunLoopRun :1660 (in CoreFoundation)
6   CoreFoundation                  0x000000018a58a2b8 _CFRunLoopRunSpecific :444 (in CoreFoundation)
7   GraphicsServices                0x000000018c03e198 _GSEventRunModal :180 (in GraphicsServices)
8   UIKit                           0x00000001905d17fc -[UIApplication _run] :684 (in UIKit)
9   UIKit                           0x00000001905cc534 _UIApplicationMain :208 (in UIKit)
10  xxxiPhone                       0x0000000100041a98 main main.m:26 (in xxxiPhone)
11  libdyld.dylib                   0x000000018956d5b8 _start :4 (in libdyld.dylib)

一看到这种堆栈,头就大了,除了Thread 0 的第10行是和程序本身二进制相关的堆栈,其余的调用栈全部是系统库里面的,并且唯一一行程序本身二进制的代码还是一个完全没作用的main函数。

好吧,只能重新找找其余的线索。从堆栈上来反推当时的场景应该是如下场景:

启动 -> main函数 -> main_queue 执行 -> dispatch_group_leave -> Crash

于是,我们的线索就从最后的_dispatch_group_leave来进行。

首先先来最简单的方法:下符号断点:dispatch_group_leave

当然事情没有这么简单,尝试重复多次也没有断到我们想要的符号断点上,于是这条路暂时考虑放弃(结合Crash率也可以发现这并非必现的Crash场景)。

这条路不通,我们先尝试全局搜索dispatch_group_leave,结果发现有如下几条线索:

  • 外部开源库
  • 自身工程代码

结合Crash出现的版本以及以上上述各库最后升级时间来判断,我们基本确定出在问题出现在自身工程中的代码里,如下:

dispatch_group_t serviceGroup = dispatch_group_create();
dispatch_group_notify(serviceGroup, dispatch_get_main_queue(), ^{
    NSLog(@"ttttttt:%@",t);
});

// t 是一个包含一堆字符串的数组 
[t enumerateObjectsUsingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
    dispatch_group_enter(serviceGroup);
    SDWebImageCompletionWithFinishedBlock completion =
    ^(UIImage *image, NSError *error, SDImageCacheType cacheType, BOOL finished, NSURL *imageURL) {
        dispatch_group_leave(serviceGroup);
        NSLog(@"idx:%zd",idx);
    };
    [[SDWebImageManager sharedManager] downloadImageWithURL:[NSURL URLWithString:t[idx]]
                                                    options:SDWebImageLowPriority
                                                   progress:nil
                                                  completed:completion];
}];

这段代码逻辑非常简单吧:给你一个数组,里面是一堆图片地址。你使用多线程进行并发下载,直到所有图片都下载完成(可以失败)进行回调,其中图片下载使用的是SDWebImage

这段代码里面的的确确出现了可疑的dispatch_group_leave,但是这段代码太常见了。和同事认认真真检查了许久,同时也和天猫、手淘中使用dispatch_group_t的地方进行了对比,没发现任何问题。

好吧,问题一下子陷入了僵局,只好上终极调试大法:汇编分析法

通过文章开头的堆栈我们查找libdispatch.dylib中对应的Crash位置,然后通过汇编解析查看相关指令,结果如下:

从上图看出,指令挂掉的原因是因为执行了brk (brk可以理解为跳转指令特殊的一种,一旦执行,就会进入某种Exception模式,导致Crash)。

为什么执行dispatch_group_leave会挂?从上述图中汇编不难发现,dispatch_group_leave具有两条分支:比较x9寄存器和0之间的关系,如果是less equal,就跳转到0x180502808(即会crash的逻辑分支);反之则正确执行ret返回。

那么x9寄存器是什么?我们继续往上看指令ldxr x9, [x10],x9中的值是以x10寄存器中的内容作为地址,取64位放入x9寄存器中。继续,那么x10中的内存是什么?x10中的内容是指令add x10, x0, #0x30。也就是x10 = x0 + 48(0x30的10进制表示)。那么,函数调用的时候x0是self,也即是一个类或者结构体的首地址。所以这两句指令加起来的含义就是取结构体地址偏移48位置的某个成员变量的值。

除此之外,汇编解析还完整保留了Crash的字符串提示: “BUG IN CLIENT OF LIBDISPATCH: Unbalanced call to dispatch_group_leave()”

结合这两点,我们查看libdispatch的源码,代码如下:

void
dispatch_group_leave(dispatch_group_t dg)
{
    dispatch_semaphore_t dsema = (dispatch_semaphore_t)dg;
    dispatch_atomic_release_barrier();
    long value = dispatch_atomic_inc2o(dsema, dsema_value);
    if (slowpath(value == LONG_MIN)) {
        DISPATCH_CLIENT_CRASH("Unbalanced call to dispatch_group_leave()");
    }
    if (slowpath(value == dsema->dsema_orig)) {
        (void)_dispatch_group_wake(dsema);
    }
}

注:苹果开发的libdispatch源码经过了各种变形修改,不是真正运行的代码,仅供参考。

果不其然,这段代码完整复现了我们之前汇编分析的结果:如果dg信号量中的字段dsema_value原子性自加一后等于LONGMIN,就会CRASH。为什么会Crash呢?

我们需要关注下LONG_MIN这个数字,LONG_MIN = -LONG_MAX - 1。理解起来很简单,就是可以表征的(该类型合法范围)最大数和最小数。

搜索下LONGMAX,我们发现在dispatch_group_create里面发现了它的踪影:

dispatch_group_t
dispatch_group_create(void)
{
    dispatch_group_t dg = _dispatch_alloc(DISPATCH_VTABLE(group),
            sizeof(struct dispatch_semaphore_s));
    _dispatch_semaphore_init(LONG_MAX, dg);
    return dg;
}

好了, 这下豁然开朗。这两段代码的结合告诉了我们一个事实:当dq这个信号量加一导致溢出后,dispatch_group_leave就会Crash。

最简单的复现代码如下:

- (void)viewDidLoad 
{
    [super viewDidLoad];
    dispatch_group_t group = dispatch_group_create();
    dispatch_group_leave(group);
    // Do any additional setup after loading the view, typically from a nib.
}

当然,上述代码相当直白简单,我们一般都不会犯这样低级的错误。

代码究竟出错在哪?

了解了dispatch_group_leave的出错原因后,我们再回到我们刚刚认为没问题的代码,一定是哪个地方我们欠考虑了。

上述代码执行流程还是非常简单的,我们用模型简述一遍:

遍历数组,对每个URL进行dispatch_group_enter,然后将其丢入一个下载block交由SDWebImage进行并发下载,下载回调(无论失败或者成功)后执行dispatch_group_leave

我们举个简单的例子,假设我们有一个包含5个URL的数组:

  1. 遍历的时候,对信号量dq enter了5次,简单理解信号量减去5次。
  2. SDWebImage下载回调的时候,对信号量dq leave了5次,于是信号量增加了5次。
  3. 执行完毕,整个group执行完成。

但是,由于SDWebImage的下载是异步且无法保证时间的,如果在整个group没有执行完毕期间,上述函数整体又被执行到了,会怎么样?

我们再用上述的例子来走遍流程。

  1. 第一次遍历,我们创建了信号量dq1,enter了5次,dq1 现在 = -5。
  2. SDWebImage的下载回调捕捉了dq1,准备留待回调后加回来,我们将这次遍历生成的下载回调block统称为b10, b12, b13, b14, b15。
  3. 但是,在第一次SDWebImage下载回调还没执行的时候,第二次函数遍历来了。
  4. 第二次遍历,我们创建了信号量dq2,enter了5次,dq2 现在 = -5。
  5. 创建第二次遍历对应的回调block,称为b20,b21, b22, b23, b24。

通过查阅SDWebImageDownloader.m源码我们发现:

dispatch_barrier_sync(self.barrierQueue, ^{
    SDWebImageDownloaderOperation *operation = self.URLOperations[url];
    if (!operation) {
    operation = createCallback();

    // !!!!!!!特别注意这行!!!!!!!!!
    self.URLOperations[url] = operation;

    __weak SDWebImageDownloaderOperation *woperation = operation;
    operation.completionBlock = ^{
      SDWebImageDownloaderOperation *soperation = woperation;
      if (!soperation) return;
      if (self.URLOperations[url] == soperation) {
          [self.URLOperations removeObjectForKey:url];
      };
    };
}

SDWebImage的下载器会根据URL做下载任务对应NSOperation映射,也即之前创建的下载回调Block。

好,就是这行导致Crash的发生。为什么呢?

我们设想下,假设在第二次遍历中包含了第一次遍历中的图片URL,比如b20对应的图片URL和b10对应的图片URL一样,那么在SDWebImage的处理回调里,b20就会替换掉b10。于是,在第一次遍历创建的5个下载任务回调中,b10回调的时候实际已经执行的是b20,也就是dq2 + 1;而在后续第二次遍历执行下载任务回调的时候,又分别执行了b20-b24的5个任务,导致dq2 + 5。这从导致dq2实际上leave的次数比enter的次数多了1 (6比5),导致了dq2信号量的数值溢出,从而进入了Crash分支。

最后

看起来很简单、清晰易懂的代码,没想到也会造成巨大的问题。所以,写代码一定要谨慎谨慎再谨慎。

JSONRPCKit源码解析

最近公司参与开源项目BeeHive的开发(第一版的代码是由前辈们写的,已经开源在了GitHub上的Alibaba项目里)。在参与开发的过程中,我一直在思考一个问题:基于Protocol的服务调用真的是最合理的方式吗?这种方式从某种方式来说还是一种强依赖(至少需要引入相对应的整个Protocol的头文件),能否有更通用的方式来进行呢?而且,从目前的实现进度来看,也无法做到对方法级的解耦。

C/S架构

在传统的开发框架下,我们一般调用HTTP/HTTPS的请求的方式都是一个API接口,配合一些参数外加GET/POST的调用方式来获取远程服务器的响应返回。如:

[self.manager request:@"api.com" withParams:@{@"name":"satanwoo"} withCompletionBlock:^(id responseObject){
    NSLog(@"return response is %@", responseObject);
)];

JSONRPCKit

JSONRPCKit是一套基于JSON RPC 2.0协议的远程服务调用框架。这套框架基于JSON格式(NULL,Boolean,String,Number,Array,Object)来传递请求以及接受返回的响应,是一套应用层之上的协议。

什么意思呢?

  1. 所有的客户端请求首先都必须构造成JSON格式
  2. 请求中必须带有JSON RPC 2.0协议要求的字段作为标示符。
  3. 服务端在处理客户端请求的时候,就从协议指定的字段去取调用的方法名、参数、版本号等等。
  4. 服务端将请求的结果也封装成复合JSON RPC要求的形式,通过JSON格式传回给客户端。
  5. 客户端根据指定的字段解析返回的结果。

如果还有不懂的,我们可以看看这篇文章

所以,JSONRPCKit就是一套封装了该协议的框架,它主要包含如下几个类:

  • RequestType 代表着当前的请求
  • BatchType 代表着一个批次(即里面可以一次性包含多个请求,减少调用开销)
  • BatchElementType 将业务请求转换成批次请求的数据结构
  • BatchFactory 构造批次请求的地方
  • JSONRPCError JSONRPC出错的原因
  • Id 代表着一次(或者一批次)请求的识别符,网络回调要和客户端请求进行ID匹配,否则谁知道哪个请求需要哪个响应。

RequestType

RequestType就是一个符合JSONRPCKit定义的数据结构,包含里几个主要的字段:

public protocol RequestType {
    /// If `Response == Void`, request is treated as a notification.
    associatedtype Response

    var method: String { get }
    var parameters: AnyObject? { get }
    var extendedFields: [String: AnyObject]? { get }
    var isNotification: Bool { get }

    func responseFromResultObject(resultObject: AnyObject) throws -> Response
}
  1. method 远程调用的方法名
  2. parameters 调用该方法需要传入的参数,顺序需要严格按照方法的入餐,从左至右
  3. extendFields 这个在协议中并没有定义,可以理解为自身业务需要,扩展字段。
  4. isNotification 在JSON RPC协议中规定,当请求或者相应不带有识别ID的时候,意味着这是一个全局通知,可以没有对应的解析结果。

此外,还有一个associatedType Response可以定义响应的类型,用作校验。

BatchElementType

大家都知道,网络调用是有其延迟性和资源消耗的,每次都去建立连接(采用TCP长链接或者HTTP keep alive除外)进行资源传输是非常不划算的话,尤其是当你的数据payload非常小,在整个传输数据占比非常小的情况下就极其的蛋疼。因此,JSON RPC 协议定义了一种可以批量传输的方式:就是一批请求包在一次传输;服务端处理好了以后,同样也在一次性将数据响应返回。

有人会问,那一次性批处理的响应怎么和请求对应呢?
这就是我们之前提到的ID字段的作用了,这是一个全局唯一性的识别符,请求时的id在服务端处理完后,会同样放在数据中进行返回。

好了,我们来看一下这个数据结构的设计:

// 协议定义
public protocol BatchElementType {
    associatedtype Request: RequestType

    var request: Request { get }
    var version: String { get }
    var id: Id? { get }
    var body: AnyObject { get }

    func responseFromObject(object: AnyObject) throws -> Request.Response
    func responseFromBatchObjects(objects: [AnyObject]) throws -> Request.Response

    func resultFromObject(object: AnyObject) -> Result<Request.Response, JSONRPCError>
    func resultFromBatchObjects(objects: [AnyObject]) -> Result<Request.Response, JSONRPCError>
}

// 具体实现
public struct BatchElement<Request: RequestType>: BatchElementType {
    public let request: Request
    public let version: String
    public let id: Id?
    public let body: AnyObject

    public init(request: Request, version: String, id: Id) {
        let id: Id? = request.isNotification ? nil : id
        var body: [String: AnyObject] = [
            "jsonrpc": version,
            "method": request.method,
        ]

        if let id = id {
            body["id"] = id.value
        }

        if let parameters = request.parameters {
            body["params"] = parameters
        }

        request.extendedFields?.forEach { key, value in
            body[key] = value
        }

        self.request = request
        self.version = version
        self.id = id
        self.body = body
    }
}

从代码中不难看出,BatchElement是对之前的Request的进一步封装,将所有Request的字段塞到了一个body中(我们可以理解为HTTP Body),这个body是真正用于传输的,其余字段都是用于校验的,总共需要进行如下校验:

  1. 查看JSON RPC协议是不是2.0。
  2. 响应数据的id和请求的id是不是能匹配。

为了处理这些默认逻辑,BatchElement基于Protocol Extension提供了默认的实现,具体如下:

func responseFromObject(object: AnyObject) throws -> Request.Response
func responseFromBatchObjects(objects: [AnyObject]) throws -> Request.Response

func resultFromObject(object: AnyObject) -> Result<Request.Response, JSONRPCError>
func resultFromBatchObjects(objects: [AnyObject]) -> Result<Request.Response, JSONRPCError>

从命名中不难看出,上述4个API分成两组,分别对应单个请求和批处理的。出于篇幅考虑,我们仅以单个批次进行分析。

public func resultFromObject(object: AnyObject) -> Result<Request.Response, JSONRPCError> {
    let receivedVersion = object["jsonrpc"] as? String
    // 校验协议版本
    guard version == receivedVersion else {
        return .Failure(.UnsupportedVersion(receivedVersion))
    }

     // 校验标识符ID
    guard id == object["id"].flatMap(Id.init) else {
        return .Failure(.ResponseNotFound(requestId: id, object: object))
    }


    let resultObject: AnyObject? = object["result"]
    let errorObject: AnyObject? = object["error"]

      // 根据错误或者结果进行解析
    switch (resultObject, errorObject) {
    case (nil, let errorObject?):
        return .Failure(JSONRPCError(errorObject: errorObject))

    case (let resultObject?, nil):
        do {
            // 请求还要再单独校验一次
            return .Success(try request.responseFromResultObject(resultObject))
        } catch {
            return .Failure(.ResultObjectParseError(error))
        }

    default:
        return .Failure(.MissingBothResultAndError(object))
    }
}

根据JSON RPC的协议规定,数据在成功处理后,必须将响应结果放在result字段里;而如果有出错的时候,就必须放在error字段中。并且必须包含error codeerror message

所以,上述代码利用Swift强大的Pattern Match机制,进行对应的解析。有一点需要注意的是,即使是服务端成功返回了数据,但是该数据可能和我们请求需求的数据类型不一致等等,仍然有可能出错。

BatchType

BatchType顾名思义,就是批次对应的数据结构。简单理解就是包着一堆BatchElement,没啥可以特别讲述的。

public protocol BatchType {
    associatedtype Responses
    associatedtype Results

    var requestObject: AnyObject { get }

    func responsesFromObject(object: AnyObject) throws -> Responses
    func resultsFromObject(object: AnyObject) -> Results

    static func responsesFromResults(results: Results) throws -> Responses
}

BatchFactory

通过上面的讲述不难看出,我们要使用JSON RPC 需要有三步骤:

  1. 构造一个符合JSON RPC 2.0协议的请求
  2. 将其转换成批处理元素
  3. 将批处理元素合并,构造成一个批次。

这样的步骤虽然不困难,但是每次都这么干,估计使用者要吐血。所以BatchFactory的目的是提供简单的工厂方法。我们以构造包含1-2个请求的批处理为例:

public func create<Request: RequestType>(request: Request) -> Batch<Request> {
       dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER)
       let batchElement = BatchElement(request: request, version: version, id: idGenerator.next())
       dispatch_semaphore_signal(semaphore)

       return Batch(batchElement: batchElement)
   }

   public func create<Request1: RequestType, Request2: RequestType>(request1: Request1, _ request2: Request2) -> Batch2<Request1, Request2> {
       dispatch_semaphore_wait(semaphore, DISPATCH_TIME_FOREVER)
       let batchElement1 = BatchElement(request: request1, version: version, id: idGenerator.next())
       let batchElement2 = BatchElement(request: request2, version: version, id: idGenerator.next())
       dispatch_semaphore_signal(semaphore)

       return Batch2(batchElement1: batchElement1, batchElement2: batchElement2)
   }

看了代码,简单吧。什么高深的都没干,就是用信号量构造了互斥区域,为什么要这么做?是为了确保idGenerator生成的标识符是连续的,并且是唯一的。

其他

借这里正好复习下dispatch_semaphore相关知识。

dispatch_semaphore对应的就是信号量,当有多个线程想要访问一个需要并发保护的资源的时候,信号量可以帮助我们协调并发数。

我们用互斥变量(即信号量为1)来举例:
xxx = dispatch_semaphore_create(1);

for (int i = 0; i < 10; i++) {
    dispatch_async(dispatch_get_global_queue(yyy, 0), ^{
        dispatch_semaphore_wait(xxx, DISPATCH_TIME_FOREVER);
        [self doSomething];
        dispatch_semaphore_signal(semaphore);
    });    
};

就可以保证任意时候,只有一个线程中可以访问到资源了。

浅谈一种解决多线程野指针的新思路

无论是xx还是xx,对于整个App的稳定性要求都非常之高。因此,那些前辈大牛们为了解决一些常见的问题,比如空指针、数组越界等等,开发了xxxxxx这样的底层SDK,用于解决问题。

但是随着业务逐渐的复杂化以及愈发严格的性能要求,xxApp绝大多数的Crash开始往野指针方面靠拢。这些野指针的问题,除了一些iOS7上delegate是assign声明导致的历史遗留问题以外,绝大多数都是多线程的赋值导致的野指针问题。

而这些多线程的野指针问题,至今仍未有一个比较好的统一解决方案。因此,今天就想稍微聊下我自身研究的一个方案。

什么是多线程的野指针问题

之前在《浅谈多线程编程误区》一文中,曾经举过如下这样的多线程setter例子:

for (int i = 0; i < 10000; i++) {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        self.data = [[NSMutableData alloc] init];
    });
}

如果这个self.data是个nonatomic的属性的话,就会造成在多次释放导致的野指针问题。(具体可以见《浅谈多线程编程误区》的原理解释)。

从原理解释中不难发现,本质上会产生野指针的场景是由于我们没有对临界区进行保护。导致赋值替换的操作不是原子性的。

有些人会说,例子中你刻意构建了一万个线程才会导致Crash。而我们平时就用用几个线程,不会有问题的。
理论上一万个线程只不过是把两个线程中可能出现问题的概率放大了而已。在一万个线程中会出现的多线程野指针问题在两个线程中一定也会发生。

传统业界方案:赋值加锁

既然原子性是导致野指针的罪魁祸首,那么我们只要在对应可能产生冲突的临界区内加锁就好了,比如:

[lock lock];
self.data = [[NSMutableData alloc] init];
[lock unlock]

按照这样的做法,同一时间不管有多少线程试图对self.data进行赋值,最终都只有一个线程能够抢到锁对其赋值。

但是这样的做法从安全性角度来说是解决了原子赋值的问题。但是这样的做法却对开发要求比较严格,因为任意非基础类型的对象(Int, Bool)都有可能产生多线程赋值的野指针,所以开发需要牢记自身的属性变量究竟有哪些会在多线程场景中被使用到。

而且,这样的方案还有一个非常大的不确定性!

当你开发了一个底层SDK,对外暴露了一些公共的readwrite的Property。别人对你的property赋值的时候,你怎么确定他们一定会做到线程安全?

我的方案:runtime追踪对象初始化的GCD Queue

我们都知道,在Objective-C中,对于一个property的赋值最终都会转化成对于ivar的setter方法。所以,如果我们能确保setter方法的线程安全性,就能确保多线程赋值不会产生野指针。

好,按照这个思路进行操作的话,我们大致需要如下几个步骤:

  1. 获取第一次setter调用的时机及对应的线程。
  2. 将这个线程记录下来。
  3. 后续调用setter的时候,判断当前setter调用的线程是不是我们之前记录的线程,如果是,直接赋值。如果不是,派发到对应的线程进行调用。
  4. 获取所有的setter,重复实现上述步骤。

看起来思路很简单,具体实现起来却有一定的难度,容我由浅入深慢慢道来:

1. 获取第一次赋值的线程并记录

由于我们不能通过成员变量就记录每个ivar对应的setter的初始化线程(这样setter的个数就无限增长了),因此本质上我们只有通过局部静态变量的方式来作为存储。同时由于我们只需要在初次执行时进行记录,所以很理所当然就想到了dispatch_once

具体代码如下:

static dispatch_queue_t initQueue;
static void* initQueueKey;
static void* initQueueContext;

static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{

    // 1. 主队列
    if ([UIApplication isMainQueue]) {
        initQueue = dispatch_get_main_queue();
        initQueueKey = [UIApplication mainQueueKey];
        initQueueContext = [UIApplication mainQueueContext];
    } else {
        // 2. 非主队列
        const char *label = [NSStringFromSelector(_cmd) UTF8String];
        initQueueKey = &initQueueKey;
        initQueueContext = &initQueueContext;
        initQueue = dispatch_queue_create(label, nil);
        dispatch_queue_set_specific(initQueue, initQueueKey, initQueueContext, nil);
    }
});

从代码中不难发现,由于主队列是全局共用的,所以如果这次setter的赋值是在主队列进行的,那么就直接复用主队列即可;而如果当前的队列我们自身都不确定的话,那么就干脆开辟一个串行的队列用语这个setter的后续赋值,并将其记录下来。

细心的读者可能会发现,我们标题里写的是线程,但是在代码中记录的却是GCD的队列(Queue)。而且,我们判断的是主队列而不是主线程。这是为什么呢?

嘿嘿,容我卖个关子,文章最后会有详细的阐述。

2. 判断后续赋值是否是记录的线程

由于我们之前记录的是队列,所以我们是无法直接使用诸如如下代码的方式进行是否是同一个线程的判断

[NSThread currentThread] == xxxThread

在iOS7之前,苹果提供了dispatch_get_current_queue()用于获取当前正在执行的队列,如果有这个方法,我们就可以很容易判断这个队列和我们记录的队列是否是同一个了。但是很不幸的是,该方法已经被从GCD的Public API中移除了,一时间研究陷入了僵局。

不过好在libdispatch是开源的,经过一段时间的摸索,我发现了这个方法dispatch_get_specific,其自身实现如下:

DISPATCH_NOINLINE
void *
dispatch_get_specific(const void *key)
{
    if (slowpath(!key)) {
        return NULL;
    }
    void *ctxt = NULL;
    // 1. 获取当前线程的执行队列
    dispatch_queue_t dq = _dispatch_queue_get_current();

    while (slowpath(dq)) {
        // 2. 如果进行过标记
        if (slowpath(dq->dq_specific_q)) {
            ctxt = (void *)key;
            dispatch_sync_f(dq->dq_specific_q, &ctxt,
                    _dispatch_queue_get_specific);
            if (ctxt) break;
        }
        // 3. 向上传递至target Queue
        dq = dq->do_targetq;
    }
    return ctxt;
}

通过上述代码不难理解,系统会自动获取当前线程正在执行的队列的。如果进行该队列进行过标记,就根据我们传入的key去获取key对应的value(ctxt)。如果查询到了,就返回。否则按照目标队列层层上查,直至root_queue也没找到为止。(关于libdispatch的具体原理,我下周还会专门写篇细细分析的文章)。

通过这个方法,我们可以在直接记录初始化队列的时候对其进行特殊的标定:

dispatch_queue_set_specific(initQueue, initQueueKey, initQueueContext, nil);

随后在后续setter执行的时候通过如下代码进行判断并进行相应的直接赋值或者队列重新派发:

// 如果是当前队列
if (dispatch_get_specific(initQueueKey) == initQueueContext) {
    _threadSafeArray = threadSafeArray;
} else {
     // 不是当前队列
    dispatch_sync(initQueue, ^{
        _threadSafeArray = threadSafeArray;
    });
}

3. 遍历所有的setter,重复上述过程

由于我们的目的是减轻其他开发的负担,所以不得不借助了runtime的Method Swizzling技术。但是传统的Method Swizzling技术是将函数实现两两交换。如果按照这个思路,我们就需要为每一个setter编写一个对应的hook_setter,这工作量无疑太巨大了。

所以,在这里我们需要的一个中心重定向的过程:即,将所有的setter都转移到一个hook_proxy中。代码如下:

- (void)hookAllPropertiesSetter
{
    unsigned int outCount;
    objc_property_t *properties = class_copyPropertyList([self class], &outCount);

    NSMutableArray *readWriteProperties = [[NSMutableArray alloc] initWithCapacity:outCount];
    for (unsigned int i = 0; i < outCount; i++) {
        objc_property_t property = properties[i];
        NSString *propertyName = [[NSString alloc] initWithCString:property_getName(property) encoding:NSUTF8StringEncoding];

        unsigned int attrCount;
        objc_property_attribute_t *attrs = property_copyAttributeList(property, &attrCount);

        // !!!!!!!!!!!!!!!!!!特别注意!!!!!!!!!!!!!!!!!!
        // !!!!!!!!!!!!!!!!!!特别注意!!!!!!!!!!!!!!!!!!
        BOOL isReadOnlyProperty = NO;
        for (unsigned int j = 0; j < attrCount; j++) {
            if (attrs[j].name[0] == 'R') {
                isReadOnlyProperty = YES;
                break;
            }
        }
        free(attrs);

        if (!isReadOnlyProperty) {
            [readWriteProperties addObject:propertyName];
        }
    }
    free(properties);

    for (NSString *propertyName in readWriteProperties) {

        NSString *setterName = [NSString stringWithFormat:@"set%@%@:", [propertyName substringToIndex:1].uppercaseString, [propertyName substringFromIndex:1]];

        // !!!!!!!!!!!!!!!!!!特别注意!!!!!!!!!!!!!!!!!!
        // !!!!!!!!!!!!!!!!!!特别注意!!!!!!!!!!!!!!!!!!
        NSString *hookSetterName = [NSString stringWithFormat:@"hook_set%@:", propertyName];

        SEL originSetter = NSSelectorFromString(setterName);
        SEL newSetter = NSSelectorFromString(hookSetterName);

        swizzleMethod([self class], originSetter, newSetter);
    }
}

在这里有两点需要注意的地方:

  1. readonly的property是不具备setter功能的,所以将其过滤。
  2. 将每个setter,比如setThreadSafeArrayswizzle成了hook__setThreadSafeArray。即为每一个setter都定制了一个对应的hook_setter。

哎,有人会问,你刚刚不才说为每一个setter编写对应的hook_setter是费时费力的吗?怎么自己打自己脸啊?

别急,容我慢慢道来。

在Method Swizzling的时候,我们需要调用class_getInstanceMethod来进行对应方法名的函数查找。整个过程简述如下:

method cache list -> method list -> 动态方法决议 -> 方法转交 (forward Invocation)

其中,在动态方法决议这步,如果我们添加了之前的没找到的方法,那么整个查找过程又会重新开始一遍。

由于那些hook_setter是压根不会存在于method list中的,所以在查找这些函数的时候,一定会走到动态决议这一步。

基于此,我实现了如下的动态决议函数:

+ (BOOL)resolveInstanceMethod:(SEL)sel
{
    NSString *selName = NSStringFromSelector(sel);

    if ([selName hasPrefix:@"hook_"]) {
        Method proxyMethod = class_getInstanceMethod([self class], @selector(hook_proxy:));
        class_addMethod([self class], sel, method_getImplementation(proxyMethod), method_getTypeEncoding(proxyMethod));
        return YES;
    }

    return [super resolveInstanceMethod:sel];
}

从代码中很容易发现,如果是之前那么hook_setter的函数名,我就讲这些方法的函数实现全部重定向到函数hook__proxy上。

4. 寻找上下文

在传统的Method Swizzling技术中,由于我们是两两交换,因此我们不需要上下文这一个步骤,直接调用hook_setter就可以重新返回对应的原setter方法。

可是在本文的实现中,由于我们将所有的setter都重定向到了hook__proxy中,所以我们需要在hook_proxy中寻找究竟是给哪个property赋值。

如果对Method Swizzling的理解只停留在表面,是很难想到后续步骤的。

Method Swizzling的原理是只是交换IMP,即函数实现。而我们在Objective-C的函数调用统统是通过objc_msgSend结合函数的Selector(可以简单理解为函数名)来找到真正的函数实现。

因此,swizzle后的Selector没变,变的是IMP。

有了这个理解,我们就可以在hook_proxy使用__cmd这个隐藏变量,它会指引我们究竟是哪个Setter当前正在被调用,具体代码如下:

- (void)hook_proxy:(NSObject *)proxyObject
{
    // 只是实现被换了,但是selector还是没变
    NSString *originSelector = NSStringFromSelector(_cmd);
    NSString *propertyName = [[originSelector stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@":"]] stringByReplacingOccurrencesOfString:@"set" withString:@""];
    if (propertyName.length <= 0) return;

    NSString *ivarName = [NSString stringWithFormat:@"_%@%@", [propertyName substringToIndex:1].lowercaseString, [propertyName substringFromIndex:1]];

    //NSLog(@"hook_proxy is %@ for property %@", proxyObject, propertyName);

    重复之前步骤即可。
}

5. 其他拓展

本文中只是探索了下没有重载setter的那些ivar,因此只需要简单对ivar进行赋值即可。
如果你碰到了大量自定义setter的ivar,那么也一样很简单,你只需要维护一个ivar 到对应自定义的setter的imp映射,在hook_proxy将setValue:ForKey:替换成直接的IMP调用即可。

一些额外细节

    1. 线程和GCD Queue并不是一一对应的关系。

前面提到了,我们要记录的是队列而不是线程。相信很多人可能一开始都不能理解,那么我用如下这样的代码进行解释:

if ([NSThread isMainThread]) {
    [self doSomeThing];
} else {
    dispatch_sync(dispatch_get_main_queue(), ^{
        [self doSomething];
    });
}

上述代码想必大家非常熟悉,就是全包在主线程执行一些操作,比如UI操作等等。但是事实上,这里有个误区:

主队列一定在主线程执行,而主线程不一定只执行主队列。

换句话说:上述代码的if 和 else是不等价的。

有时候,主线程有可能会被调度到执行其他队列(其他线程亦是如此),比如如下代码:

// 在主线程创建
dispatch\_queue\_t dq = dispatch\_queue\_create('com.mingyi.dashuaibi', NULL);
dispatch_sync(dq, ^{
    NSLog(@"current thread is %@", [NSThread currentThread]);
});

具体效果,大家可以自己尝试下,看看Log输出的结果是不是主线程。

    1. 为什么不能直接将所有的setter直接hook到hook_proxy,非要通过动态决议来进行。

我们举个简单的例子,假设我们有两个property,分别叫A和B。那么在执行下述代码的时候:

for (int i = 0; i < 2; i++) {
     SEL originSetter = NSSelectorFromString(setterName);
     SEL newSetter = NSSelectorFromString(hook_proxy);
     swizzleMethod([self class], originSetter, newSetter);
}

第一次交换的时候,Setter A的 IMP和 hook_proxy的 IMP进行了交换,这一步没问题。
第二次交换的时候,Setter B的 IMP和 hook_proxy的 IMP进行了交换,而此时hook_proxy的IMP已经指向了Setter A的IMP,因此导致的结果就是交换错乱了,调用setter B实质上是调用了setter A。

浅谈iOS的多Window处理

概述

想必做iOS的人都知道,我们的App是通过UIWindow这个载体呈现出来的。默认情况下,iOS App对于开发者来说只有一个UIWindow,也就是AppDelegate在applicationDidFinishLaunching里面创建出来的。

但是即使我们什么都不做,在我们的APP里面也会有其他的UIWindow:

  1. 键盘对应的UITextEffectWindow
  2. 状态栏对应的UIStatusBarWindow

只不过上述两种UIWindow我们一般不太容易去操作罢了,因此很多问题都无形被掩盖住了。所以接下来我们就说说如果在多个UIWindow状态下存在的一些问题吧。

那么在什么情况下会导致我们想要创建多UIWindow的状态呢?我总结了一下,包括但不限于:

  1. 全局性的自定义HUD,Alert效果(SCAlert)等等。
  2. 需要展示的界面需要盖住UIStatusBar。

其中,第一种方案其实不一定需要创建一个新的UIWindow实例,我们也可以将这些自定义的全局性界面添加到AppDelegate的window上。但是这样就会产生一个问题,由于在iOS8之前,UIWindow的bounds是不会随着旋转而改变的,拿到的永远是处于Portrait模式下的坐标系坐标。因此,对于直接添加在UIWindow上的视图,我们需要自己根据 UIApplicationDidChangeStatusBarOrientationNotification来进行转换处理。

苹果这篇Q&A讲述了比较具体的原因:UIWindow并不会处理rotation事件,而是UIWindow的rootViewController去处理。

而对于第二种问题,添加一个盖在UIStatusBar上的界面,就必须依赖我们自己创建一个新的UIWindow,究其原因在于UIStatusBar本身并不属于我们App内可控的一个控件,而是一个系统级创建出来的产物。
因此,我们必须创建一个WindowLevel大于UIWindowStatusBar的新Window盖在上面才行。

有人会问:咦,奇怪了,为什么你在自己App内添加一个WindowLevel大于statusbar的就可以了呢?你只是在你自身应用内添加了一个UIView(UIWindow的子类),竟然能影响系统级的控件?

是的,不知道大家有没有了解过CALayer这层有个属性叫zIndex。通过操纵这个属性,我们可以调整视图渲染的前后关系。即使有的UIView在构建层级树的时候被后加的UIView所遮盖,但是在构建渲染树的时候,zIndex越高的视图就会越处于视觉前方进行渲染。 而渲染树构建完成之后,并不是在我们的App内部进行渲染,而是通过IPC通信,统一交由一个第三方进程Render Server进行渲染。而在我们这里处理盖住StatusBar的多Window的情形也是基于这个原理进行。

横屏及旋转

现在绝大多数的iPhone应用都是竖屏应用,即只支持Portrait模式。但是随着视频、直播的风口到来,在新闻、购物等等APP内都会插入视频播放这一特性,而视频播放需要的全屏播放特性势必要用到横屏,也就意味着会牵扯到旋转。

横屏旋转分为两种,一种是强制性的,一种是随着设备进行旋转的。什么意思呢?
大家还记得手机上有旋转锁这一个开关吧,你将旋转锁开启的时候,手机就保持在锁定对应的模式下,无法自动根据你旋转设备而旋转。在这种模式下,如果你需要更改APP界面对应的UIInterfaceOrientation,就必须要么在对应的viewcontroller里面提供实现如下的方法:

- (NSUInteger)supportedInterfaceOrientations
{
    return UIInterfaceOrientationMaskLandscapeRight; // 表示支持水平右方向
}

- (BOOL)shouldAutorotate
{
    return YES;
}

这样,当你展现到这个页面的时候,就会触发系统检查一下当前页面应该所处的Orientation,从而达到正确的显示效果。

但需要注意的是,如果你的界面是处于一个UINavigationController或者UITabbarController内的话,你就需要从父容器开始,写对应的supportedInterfaceOrientations实现,否则就无法得到正确的效果。

PS: 其实这个道理和hideBottomBarWhenPushed是一个道理。很多人用了这个属性,发现隐藏Tabbar的时机经常错乱了,这个就在于没有仔细阅读文档,需要在整个导航栈里面的topmostViewController提供正确的属性设置才行。

The value of this property on the topmost view controller determines whether the toolbar is visible. If the value of this property is true, the toolbar is hidden. If the value of this property is false, the bar is visible

或者你可以将你需要横屏的ViewController通过present的形势展现出来(有人觉得会狠突兀,那你自己实现专场动画过渡就可以了)。不过呢,这种实现方式会有一个超级大坑,待会我们细细说。

上面这种就是强制性的。

而自动旋转的就是打开旋转锁,让界面随着设备的旋转而进行旋转,这种旋转是物理特性的,非强制性的。

Q: 那么这两种旋转的区别在哪?
A: UIInterfaceOrientation(UIStatusBar的所处方向)和UIDeviceOrientation是否一致。

Q: 那么有什么问题呢?
A: 在iOS8之后,UIScreen的bounds是随着物理设备的旋转而更改的。如果你需要获取iOS8之前的bounds效果,需要使用nativeBounds。但是要记得,nativeBounds是像素级别的,你需要换算到对应的point单位来,所以关系是:

bounds( < iOS8.0) = nativeBounds / nativeScale;

大家可以参考苹果的文档来更确切的掌握一下。

上面的内容我们曾经提及在采用多UIWindow时候的几个大坑,如果你现在有自定义的界面,想要添加到除了delegate window之外的window,可能会遇到如下几个问题。

直接将自定义的视图作为Subview添加到UIWindow上

从理论上来说UIWindow继承于UIView,这种直接用法在认知上没有任何的问题。但是如果涉及的应用牵扯到横屏模式而且又要支持iOS7的话(我相信现在没有哪个产品还需要支持iOS6)吧,那么针对iOS7需要单独处理横屏的坐标系转换。我们摘录一段著名的开源库MBProgressHUD的代码作为示例:

#if __IPHONE_OS_VERSION_MIN_REQUIRED < 80000
    // Only needed pre iOS 8 when added to a window
    BOOL iOS8OrLater = kCFCoreFoundationVersionNumber >= kCFCoreFoundationVersionNumber_iOS_8_0;
    if (iOS8OrLater || ![self.superview isKindOfClass:[UIWindow class]]) return;

    // Make extension friendly. Will not get called on extensions (iOS 8+) due to the above check.
    // This just ensures we don't get a warning about extension-unsafe API.
    Class UIApplicationClass = NSClassFromString(@"UIApplication");
    if (!UIApplicationClass || ![UIApplicationClass respondsToSelector:@selector(sharedApplication)]) return;

    UIApplication *application = [UIApplication performSelector:@selector(sharedApplication)];
    UIInterfaceOrientation orientation = application.statusBarOrientation;
    CGFloat radians = 0;

    if (UIInterfaceOrientationIsLandscape(orientation)) {
        radians = orientation == UIInterfaceOrientationLandscapeLeft ? -(CGFloat)M_PI_2 : (CGFloat)M_PI_2;
        // Window coordinates differ!
        self.bounds = CGRectMake(0, 0, self.bounds.size.height, self.bounds.size.width);
    } else {
        radians = orientation == UIInterfaceOrientationPortraitUpsideDown ? (CGFloat)M_PI : 0.f;
    }

        self.transform = CGAffineTransformMakeRotation(radians);
#endif

通过rootViewController的view添加子视图

这种方式就是通过将window.rootViewController = vc,然后我们所有的子视图都添加到vc.view

这种使用的好处是我们无需去考虑版本兼容的问题,通过vc.view拿到的坐标系对于我们来说都是和UIInterfaceOrientation正确转换过的。

在iOS7之前,坐标系的转换是系统通过设置vc.transform更改;而在iOS8之后,vc和window的旋转会根据UIDeviceOrientation和viewcontroller自身supportedInterfaceOrientations进行交集的操作。

总之,需要支持横屏的自定义界面,全部放在viewcontroller.view上来做,是准没错的。

而且,在iOS9以后,苹果推荐每个UIWindow都必须有一个rootViewController。否则在启动过程使用了不包含rootViewController的UIWindow中会导致必现的crash

presentViewController的大坑

我们前面提过,如果想要让viewcontroller单独横屏有两种方式。

  1. 如果你的界面是处于一个UINavigationController或者UITabbarController内的话,你就需要从父容器开始,写对应的supportedInterfaceOrientations实现,否则就无法得到正确的效果。

  2. 或者你可以将你需要横屏的ViewController通过present的形势展现出来

第二种方案在实现过程中,会产生一个非常隐晦的大坑,容我慢慢道来。
首先我们需要了解下整体响应旋转变化的事件流程,简单来说如下:

UIScreen -> UIWindow -> UIViewController -> ChildViewControllers -> View -> Subviews

其中,UIWindow对应的处理方法是:supportedInterfaceOrientationsForWindow;而UIViewController对应的处理方法是supportedInterfaceOrientations

也就是说,当系统通过这个流程向我们请求界面的UIInterfaceOrientation的时候,我们必须确保我们能够提供正确的返回参数。

而这个流程在使用presentViewController弹出modalViewController会产生一些问题:即当你想从modalViewController 返回(dismiss)原先的界面的时候,你会发现虽然原先界面强制设置了portrait模式,但是如果设备锁关闭且设备仍然处于水平状态,那么此时的UIInterfaceOrientation,仍然是不准确的。

其原因在于:当你想要dismiss的时候,系统的确发起了一次新的请求流程。但是此时,modalViewController正处于dismissing的状态中,请求到的supportedInterfaceOrientations还是针对modalViewController的。所以,如果你的modalViewController是横屏模式,那么返回后的效果就是横屏模式,除非你人为的旋转一下设备,让其回到竖直方向。

Q: 那么这种问题有没有解决办法呢?
A: 你可以在supportedInterfaceOrientations里面判断下当前的viewcontroller是不是处于isBeingDismissed,如果是的话,取其presentingViewControllersupportedInterfaceOrientations作为返回值。

Q: 有些同学会问,我们怎么从来没遇到过这个问题?
A: 那是因为你们使用的UIWindow 99%的可能都是默认的delegate window,对于这个window,所有的旋转事件都自动帮你校准了,因此无需担忧。

参考资料

  1. UIWindow in iOS
  2. After rotation UIView coordinates are swapped but UIWindow’s are not?
  3. 详解UICoordinateSpace和UIScreen在iOS 8上的坐标问题
  4. iOS 7+ Dismiss Modal View Controller and Force Portrait Orientation
  5. iOS Orientations: Landscape orientation for only one View Controller

从Immutable来谈谈对于线程安全的理解误区

毫不夸张的说,80%的程序员对于多线程的理解都是浅陋和错误的。就拿我从事的iOS行业来说,虽然很多程序员可以对异步、GCD等等与线程相关的概念说的天花乱坠。但是实质上深挖本质的话,大多数人并不能很好的区分Race Condition,Atomic,Immutable对象在线程安全中真正起到的作用。

所以今天就以这篇文章来谈谈我所理解的线程安全。

首先就允许我从Immutable来开始整篇话题吧。

Swift中的Immutable

用过Swift的人都知道,Swift相较于Objective-C有一个比较明显的改动就是将结构体(Struct)和类型(Class)进行了分离。从某种方面来说,Swift将值类型和引用类型进行了明显的区分。为什么要这么做?

  1. 避免了引用类型在被作为参数传递后被他人持有后修改,从而引发比较难以排查的问题。
  2. 在某些程度上提供了一定的线程安全(因为多线程本身的问题很大程序上出在写修改的不确定性)。而Immutable 数据的好处在于一旦创建结束就无法修改,因此相当于任一一个线程在使用它的过程中仅仅是使用了读的功能。

看到这,很多人开始欢呼了(嘲讽下WWDC那些“托”一般的粉丝,哈哈),觉得线程安全的问题迎刃而解了。

但事实上,我想说的是使用Immutable不直接等同于线程安全,不然在使用NSArray,NSDictionary等等Immutable对象之后,为啥还会有那么多奇怪的bug出现?

指针与对象

有些朋友会问,Immutable都将一个对象变为不可变的“固态”了,为什么还是不安全呢,在各个线程间传递的只是一份只读文件啊。

是的,对于一个Immutable的对象来说,它自身是不可变了。但是在我们的程序里,我们总是需要有“东西”去指向我们的对象的吧,那这个“东西”是什么?指向对象的指针

指针想必大家都不会陌生。对于指针来说,其实它本质也是一种对象,我们更改指针的指向的时候,实质上就是对于指针的一种赋值。所以想象这样一种场景,当你用一个指针指向一个Immutable对象的时候,在多线程更改的时候,你觉得你的指针修改是线程安全的吗?这也就是为什么有些人碰到一些跟NSArray这种Immutable对象的在多线程出现奇怪bug的时候会显得一脸懵逼。

举例:

// Thread A 其中immutableArrayA count 7
self.xxx = self.immutableArrayA;

// Thread B 其中immutableArrayB count 4
self.xxx = self.immutableArrayB 

// main Thread
[self.xxx objectAtIndex:5]

上述这个代码片段,绝对是存在线程的安全的隐患的。

既然想到了多线程对于指针(或者对象)的修改,我们很理所当然的就会想到用锁。在现如今iOS博客泛滥的年代,大家都知道NSLock, OSSpinLock之类的可以用于短暂的Critical Section竞态的锁保护。

所以对于一些多线程中需要使用共享数据源并支持修改操作的时候,比如NSMutableArray添加一些object的时候,我们可以写出如下代码:

OSSpinLock(&_lock);
[self.array addObject:@"hahah"];
OSSpinUnlock(&_lock);

乍一看,这个没问题了,这个就是最基本的写保护锁。如果有多个代码同时尝试添加进入self.array,是会通过锁抢占的方式一个一个的方式的添加。

但是,这个东西有啥卵用吗?原子锁只能解决Race Condition的问题,但是它并不能解决任何你代码中需要有时序保证的逻辑。

比如如下这段代码:

if (self.xxx) {
    [self.dict setObject:@"ah" forKey:self.xxx];
}

大家第一眼看到这样的代码,是不是会认为是正确的?因为在设置key的时候已经提前进行了self.xxx非nil的判断,只有非nil得情况下才会执行后续的指令。但是,如上代码只有在单线程的前提下才是正确的。

假设我们将上述代码目前执行的线程为Thread A,当我们执行完if (self.xxx)的语句之后,此时CPU将执行权切换给了Thread B,而这个时候Thread B中调用了一句self.xxx = nil

嘿嘿,后果如何,想必我不用多说了吧。

那对于这种问题,我们有没有比较好的解决方案呢?答案是存在的,就是使用局部变量
针对上述代码,我们进行如下修改:

__strong id val = self.xxx;
if (val) {
    [self.dict setObject:@"ah" forKey:val];
}

这样,无论多少线程尝试对self.xxx进行修改,本质上的val都会保持现有的状态,符合非nil的判断。

Objective-C的Property Setter多线程并发bug

最后我们回到经常使用的Objective-C来谈谈现实生活中经常出现的问题。相信各位对于Property的Setter概念都不陌生,self.xxx = @"kks"其实就是调用了xxx的setter方法。而Setter方法本质上就是如下这样一段代码逻辑:

- (void)setXxx:(NSString *)newXXX {
      if (newXXX != _xxx) {
          [newXXX retain];
          [_xxx release];
          _userName = newXXX;
      }
}

比如Thread A 和 B同时对self.xxx进行了赋值,当两者都越过了if (newXXX != _xxx)的判断的时候,就会产生[_xxx release]执行了两次,造成过度释放的crash危险。

有人说,呵呵,你这是MRC时代的写法,我用了ARC,没问题了吧。

ok,那让我们来看看ARC时代是怎么处理的,对于ARC中不复写Setter的属性(我相信是绝大多数情况),Objective-C的底层源码是这么处理的。

static inline void reallySetProperty(id self, SEL _cmd, id newValue, 
  ptrdiff_t offset, bool atomic, bool copy, bool mutableCopy) 
{
    id oldValue;
    // 计算结构体中的偏移量
    id *slot = (id*) ((char*)self + offset);

    if (copy) {
        newValue = [newValue copyWithZone:NULL];
    } else if (mutableCopy) {
        newValue = [newValue mutableCopyWithZone:NULL];
    } else {
        // 某些程度的优化
        if (*slot == newValue) return;
        newValue = objc_retain(newValue);
    }

    // 危险区
    if (!atomic) {
         // 第一步
        oldValue = *slot;

        // 第二步
        *slot = newValue;
    } else {
        spin_lock_t *slotlock = &PropertyLocks[GOODHASH(slot)];
        _spin_lock(slotlock);
        oldValue = *slot;
        *slot = newValue;        
        _spin_unlock(slotlock);
    }

    objc_release(oldValue);
}

由于我们一般声明的对象都是nonatomic,所以逻辑会走到上述注释危险区处。还是设想一下多线程对一个属性同时设置的情况,我们首先在线程A处获取到了执行第一步代码后的oldValue,然后此时线程切换到了B,B也获得了第一步后的oldValue,所以此时就有两处持有oldValue。然后无论是线程A或者线程B执行到最后都会执行objc_release(oldValue);

于是,重复释放的场景就出现了,crash在向你招手哦!

如果不相信的话,可以尝试如下这个小例子:

for (int i = 0; i < 10000; i++) {
    dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
        self.data = [[NSMutableData alloc] init];
    });
}

相信你很容易就能看到如下错误log:error for object: pointer being freed was not allocated

结语

说了这么多,本质上线程安全是个一直存在并且相对来说是个比较困难的问题,没有绝对的银弹。用了Immutable不代表可以完全抛弃锁,用了锁也不代表高枕无忧了。希望这篇文章能够帮助大家更深入的思考下相关的问题,不要见到线程安全相关的问题就直接回答加锁、使用Immutable数据之类的。

当然,其实Stick To GCD (dispatch_barrier)是最好的解决方案。

本文写于头昏脑涨之中,写错之处请大神多多指出。

为什么Spotify的付费用户转化率惊人的高?

本文由本人独自翻译,同步发表在稀土上

在 2015 年的时候,The Fader 报道了一则关于 Spotify 的重磅新闻:在其 7500 万月活跃用户中,有 2000 万左右是付费用户。

26.6% 的转化率对于免费增值产品来说是令人难以置信的。正如 Jason Chen 所说

“如果说免费用户到收费用户的转化率可以达到 4%,那就已经可以说是很不错了,比如 DropBox。但是通常来说,转化率一般都处于 1% 上下浮动,这还是用户十分活跃的情况下才会达到。”

如果说 1% 是普遍的水准,然后 DropBox 4% 的转化率是非常不错的话,那26.6%绝对可以称的上是令人匪夷所思了。

至于用户留存率,80%的用户(包括免费用户和付费用户)每周都会多次使用 Spotify。

我写这篇文章的原因在于我在使用 Spotify 仅仅 11 天后,就成为了它的付费用户(似乎我当时还经历了一个 7 天 A/B 测试的试用阶段)。所以,我想从产品、用户体验和市场运营的视角来真正探究一下其中深层次的原因,究竟是什么导致了 Spotify 有如此大的魔力让用户乐意为其付费。

所以,出于这次研究的目的,我又重新注册了一个账号。

我用了一个新账号并且从一个新用户的视角来使用 Spotify,一个个去剖析那些容易激发用户付费的诱因,并调查这些诱因是如何保证如此高的转化率以及用户留存率。

在我们开始前,我们需要留意一点: Slack 也因为它那令人咋舌的用户转化率而出名,最新的数据显示它们的转化率达到了 30% 左右。但要注意的是,Slack 是一个 B2B 软件,它的用户群体相对来说是付费能力和意愿比较强的高端用户。但是Spotify 有超过 20%的用户是处于 13 到 18 岁年龄段。与企业精英不惜代价寻找一种合适的解决方案相比,这个年龄段的用户一般能成为付费用户的可能性很低的…所以,Spotify 真的很令人难以置信。

步骤 1:减少使用障碍,通过 Facebook 注册来形成病毒式营销

Spotfiy 通过 Facebook 获取信息的注册方法是令人称道的。对于那些已经在手机上登录 Facebook 的用户来说,这种注册方式可以直接从 Facebook 获取你的用户数据,意味着你就不用再笨拙地输入你的邮件地址和密码。这无疑会减少用户注册账号的抵触心理。

只要仅仅一次点击,允许数据导入,你就注册成功了。

除了作为一种注册方式以外,导入 Facebook 的数据还完成了其余两件事:

  • 将用户的喜好展示给他们的朋友
  • 可以让你的朋友了解 Spotify,并吸引他们也来注册使用 Spotify

正如 Helpshift 所说

80% 的手机用户拥有 Facebook 账户。所以,当一个应用的注册只需要轻轻点击蓝色按钮的时候,用户的转化率瞬间就能有 20% 的提升。

所以使用 Facebook 进行注册,对于 Spotify 的营销来说是起了一个非常关键的作用。正如报道中所说的那样,每一个付费用户都带来了3个免费用户

步骤 2:精挑细选的播放列表可以满足特定的需求

Spotify 的目的就是帮助用户发现音乐。它在你初次使用的时候会鼓励你使用它“精心调配”的播放列表。

通过选择一个包含你熟悉歌曲的播放列表,或者一个和你品味相契合的主题,Spotify 会循环播放这些歌,并在其中穿插播放一些你所不了解的歌。

对于一首喜爱却又不了解的歌,人们通常的反应是会去寻找这首歌的歌手、所属专辑或者其他具有相似特征的播放列表。这种寻找的流程在 Spotify 的应用中被设计的极其简单,并且会被推荐到你看到的第一屏当中。

但这里有个需要注意的点:如果你是一个免费用户,那么你就无法在任意时刻切换到你想听的歌。即使你已经制作了你自己的播放列表,歌曲也会是随机出现的。

所以对于我来说,我成为付费用户的一个主要原因就在于:在 Spotify 那不可思议却又十分“对味”的推荐算法指引下,我就很自然而然的养成了一种新的并在不断改进的听歌风格。在这个过程中,许许多多的歌曲都会被加入到你的听歌列表中。但一旦加入,随机播放列表就再也不会将其剔除。因此,其中有部分可能是你不怎么想要再听到的歌曲。比如我就不再想听到任何 Brain Food 里的歌。我想要的是可以自由自在的挑选歌曲、对它们进行排序,并对我自己的歌曲列表有绝对的控制权。如果我不是付费用户的话,即使我特别想听 Stars Wars Headspace 专辑中的几首歌,但是我所能做的仅仅是不断地随机跳过我不喜欢的歌曲,直到从 Spotify 听到我想听的歌。

人们会尝试去“挑战”这个系统来听到他们想要听的歌,但是 Spotify 让这种想法近乎不可能。一般来说,在一个随机播放的列表中,你可能需要跳过8首歌才能听到你想要的歌。

步骤3:Spotify 会强调歌曲和你息息相关的

首页下面是根据心情情况和流派推荐的播放列表。作为一名有音乐文化背景的研究生,我了解到人们听音乐的根本原因在于音乐能够加强情感共鸣。最好的音乐作曲家,如 Lester Bangs,就曾写到这样的乐评:音乐就像一剂猛药,伴随并强化着你的听音乐体验。

Spotify 通过一些描述性的分类,并在其中播放与描述非常贴切的音乐来引发共鸣,让听众产生一种“音乐就是我人生不可分割的轨迹”、或“这就是我现在的感受”的心境。

比如在 Chill 心情分类中可以找到一些让你冷静下来的歌曲,每首歌曲又会与地点、’亚情绪’及个人听歌品味相契合。

歌曲列表包含艺术、排版以及文案。这些东西对于拥有不同审美的用户来说充满诱惑力。因此对用户来说,很容易就会忽略掉那些不重要的。然后立刻识别出那些诉诸于你的音乐。

通过鼓励你多使用播放列表,并将其和你平时的生活习惯紧紧联系到一起,Spotify 就会变得越来越智能:成为一个能够生成适应任何场景的音乐播放器。构建一个能融入用户日常生活习惯的产品是一个非常有效提升用户留存率的方法。而 Spotify 又采用了非常人性化的手段来达成这个目的:通过理解你听音乐时候的场景和心情。比如你聚会时听得音乐;抑或是跑步、学习时听的音乐。一旦你因为这些目的使用过一次播放列表,当失去它的时候你就会非常想念它。

步骤4:你把应用“培养”得迎合你的喜好,就相当于做了一笔投资。

我之前看过一篇关于 Flipboard 的入职流程的分析,让用户将应用“培养”
成迎合他们自身的喜好是一个久经考验能够提升用户留存率的办法。因为在这个过程中,用户相当于在应用内做了一笔“投资”:如果他们不升级成付费用户,就意味着他们之前所耗费的精力和时间都白白浪费了。

Spotify 也采用了这个策略。他们的做法是允许用户将音乐存储到自己的账户中、建立自己的音乐合集、通过 Facebook 以及Spotify 自己的社交网络和朋友进行分享。

当然,这种投资并是金钱投资,因此你不会感到是被强制消费了。(事实上,现在如果还采用收费合同来绑定用户的行为是不能被容忍的)。但是这种投资对于个人来说,却显得更为重要,因为这是一种跟时间相关的投资,每个人都很珍惜时间,不是吗?

将 Spotify 和 Facebook 打通又是另外一种投资。在不同的应用之间建立依赖关系意味着你需要承担更多的责任。比如,你的朋友喜欢你的播放列表、喜欢听你喜欢的歌。这就意味着你在你的朋友圈中成为了一个传播品味的大师,可以给朋友宣传最新最酷的潮流。我想,你肯定不会因为不想成为付费用户就失去这得之不易的品味大师的头衔吧!

Spotify 并不会强求你选择一个你喜欢的音乐类型,也不会给你许多听歌的建议,它所做的只是让你自行探索音乐。因为自行探索出来的音乐会让你更加感同身受,而且和别人分享这些音乐的时候,也会让你更有成就感。

Spotify Personalization

当你在你自己的设备上使用 Spotify 的时候,除了生成个性化的播放列表,Spotify 并不会耗费你大量的精力。事实上,它根本就不需要。Spotify 的推荐算法已经足够强大,能够理解你的需求。而且多半时候,推荐出来的东西都正是你想要的。所以,你只需要在培养属于你自己喜好的 Spotify 的时候耗费一点精力而已。_你的_Spotify其实比你自己更懂你的喜好

这些要求你进行付费的广告并不会让人感到特别烦扰,但是却巧妙的破坏了听音乐时候的代入感

另一个能让 Spotify 的付费策略成功伪装成是不激进的原因的是(实际上是非常激进的)你没有意识到你究竟会被一些负面因素激怒到何种程度。

音乐一个非常关键的作用就是它给人带来的代入感。在 Spotify 上,有一些非常流行的播放列表来帮助用户专注于工作,比如学习、写作或者要求注意力非常集中的情形。

_你听了15分钟的 chill Brian Eno soundscape。突然,一个刺耳的、极不匹配的流行音乐开始播放。紧跟着出现了一个广告,一个人告诉了你一个你现在毫不关心的东西。然后又过了 30 秒,这些乱七八糟的东西终于结束了,你终于可以听你想要的音乐了。如果是你,你是什么感受?。

对我来说,摆脱广告的烦扰并不是一个足够有说服力可以让我进行付费的理由。我并不把它们当成是对听音乐有着巨大负面影响的因素。因为只要等广告结束了,我就能继续听我想听的音乐。

而且和 Spotify 会让你跳过 8 首歌才能听过你想要的歌曲相比,广告是微不足道的,更何况它出现的频率也很低,低到很容易被忽略。但尽管如此,广告对于转化率也有着很大的作用。

允许用户在30天的试用期下载离线音乐是极其明智的

没有什么可以比把你曾经拥有的东西强行夺走更会让你抓狂。

通过允许用户下载歌曲离线使用,又在一段时间后限制他们只能听在线音乐,这 30 天试用期带来的自由绝对你产生巨大的落差感

一个月的试用期对于用户来说完全足够在这段时间内建立起一个音乐合集。更何况 Spotify 大大减少了探索音乐需要耗费的时间:它每天给用户推荐 20 张专辑,而且还会根据你当前的品味和习惯变化。所以 Spotify 通过试用期,给用户画了很大的一个“饼”:如果你们升级成付费用户的话,你们就能享受到多么棒听歌的特权啊。

一旦获得了离线听歌的特权,用户就会囤积尽可能多的歌曲。用他们的话来说,这是属于你的音乐。但囤积的越多,就会让你陷得越深,你再也不会愿意变回免费用户了。

无论用户如何使用 Spotify, 最后都会被引导向升级付费

在用户的使用过程中,有时候 Spotify 会明确的要求用户升级为会员,或者提示这个功能仅仅开放给付费用户。

其中,明确的要求你升级(或者说强迫式的推荐)出现在一些看似可用的功能实质上仅仅开放给付费用户

而在如下几种情况当中,Spotify 会采用暗示的方式提示你如果升级到付费用户,使用体验会更好:

所以即使 Spofity 有着巨大基数的免费用户,也很容易就能说明为什么它的付费转化率如此之高。

只要你是音乐的发烧友、渴望发现那些令你狂热的音乐、存储音乐并想要打造出专属你品味的 Spotify。那么,是时候升级成付费用户了。(当然,你也可以选择不升级)

HTML中的“空白符”,你真的懂了吗?

这几天由于某项任务,暂时转型了成为了前端码农(实际上就是个写初级CSS的屌丝)。在这期间,我有一个需求大致是这样的:

我有一个父类容器,比如div,这个div的宽度是固定的。我现在要在这个div中插入5个img,这5个img等宽。同时伴随这5个img的当然还有四个间隙,这四个间隙也是等宽的。

当然,需要根据百分比宽度进行简单适配。
是不是觉得很简单呢?呵呵,别说专业的前端工程师,我这种半吊子都觉得简单。
根据PSD效果图,我量出了对应的百分比尺寸,于是写出来如下的HTMLCSS代码:

// HTML 文件
<div class="parent">
    <img class="element" src="http://xxxxxxx.com/shshshshs.png" />
    <img class="element" src="http://xxxxxxx.com/shshshshs.png" />
    <img class="element" src="http://xxxxxxx.com/shshshshs.png" />
    <img class="element" src="http://xxxxxxx.com/shshshshs.png" />
    <img class="element" src="http://xxxxxxx.com/shshshshs.png" />
</div>

// css 文件
.parent {
    width:x%;
}

.parent img {
    width:y%;
    height:auto;
    margin-left:k%;
}

.parent img:first-child {
    margin-left:0;
}

这段代码相当简洁明了吧,我通过量好的百分比,对各个图片和之间的间距进行了控制,基本上业界大多数也是这么做的吧。

按理说事情到这基本就结束了,毕竟img是个行内可替换元素,会自动布局在一行之内,当然前提是父容器宽度足够的前提下。不过既然我都身体力行的量过了,那自然不应该出现问题。

但是,卧槽,你越担心的事就越会发生。整个界面出现了非常奇葩的现象:

这是正常情况:
[图<-><-><-><->图] 

这是实际情况:
[图<-><-><-><->]
图]

卧槽,竟然宽度不够,换行了。尝试了很长时间,后来发现,将.parent img中添加float:left就可以完美解决,但这什么原因呢?

原因

经过一番探索研究,我发现,这是由于空白符inline类型的元素造成的影响。

  • 首先,img元素是一种行内可替代元素,效果基本可以理解为inline-block
  • 第二,我们在HTML的时候,为了在编辑器内写的美观,常常会使用回车,而回车在HTML中会被识别为空白符。

    比如
    <img src = "xxxx" />(空白符)
    <img src = "xxxx" />
    
  • 第三,空白符具备宽度(和font-size有关),不具备高度。

所以,表面上我们根据设计好的图片进行了精准的测量,构造了完全匹配父类宽度的元素和间距,但是实际上却由于空白符所具备的宽度而产生了偏差。

用一张图来表示拥有空白符后的效果:

[(空白)<->图(空白)<->图(空白)<->
图<->]

为什么float可以解决这个问题

A floated box is shifted to the left or right until its outer edge touches the containing block edge or the outer edge of another float

看到这个关于float的定义了吗?float要么依赖前一个(或者后一个)float元素的边界,要么就依赖于父元素的边界。而一个空白符,既不是包含块(父容器)的边界,也不是另一个float元素,因此不受影响,也不会对其余float元素有影响。

所以,当你对.parent img启用float:left之后,效果就成了下图所示:

[float<->float<->float<->float<->float]
[空白符]

这次我们测量的宽度正好匹配完全,所以将空白符自然而然的挤到了下一行。还记得我们前面说过空白符不具备高度吗?因此,这个空白符压根没起作用!

文中的 [] 代表父容器,<-> 代表间隔。

其余方案

  1. 将父容器的font-size设置为0
  2. 避免换行,写出 这样的代码。
  3. 启用HTML压缩。

最后:感谢美团大神FTR和淘宝大神YWJ对本菜比的指导。

滥用单例之dispatch_once死锁

现象

上周排查了一个bug,现象很简单,就是个Crash问题。但是读了一下crash Log以后,却发现堆栈报的错误信息却是第一次见到(吹牛的说,我在国内的iOS也能算第十二人了),包含以下还未符号化信息:

Application Specific Information:
com.xxx.yyy failed to scene-create in time

Elapsed total CPU time (seconds): hhh秒 (user hhh, system 0.000), k% CPU
Elapsed application CPU time (seconds): 0.h秒, k% CPU

Thread 0 name:  Dispatch queue: com.apple.main-thread
Thread 0:
0   libsystem_kernel.dylib            0x36cb2540 semaphore_wait_trap + 8
1   libsystem_platform.dylib          0x36d3d430 _os_semaphore_wait + 8
2   libdispatch.dylib                 0x36be04a6 dispatch_once_f + 250
3   xxxx                              偏移量 0x4000 + 947290
...
...

无符号化的crash 堆栈暂时不去管它,我们重点关注com.xxx.yyy failed to scene-create in time。如果理解无误的话,这句话提示我们:我们的应用程序在规定的时间没能加载成功,无法显示。看起来这个原因是启动加载过长直接被干掉。那么问题来了,原因具体是啥?

查看堆栈

首先我们需要符号化一下,这里涉及公司内部信息,所以我们自己构造个demo试试。
demo的代码很简单,如下:

#import "ManageA.h"

@implementation ManageA

+ (ManageA *)sharedInstance
{
    static ManageA *manager = nil;
    static dispatch_once_t token;

    dispatch_once(&token, ^{
        manager = [[ManageA alloc] init];
    });

    return manager;
}

- (instancetype)init
{
    self = [super init];
    if (self) {
        [ManageB sharedInstance];
    }
    return self;
}

@end

@implementation ManageB

+ (ManageB *)sharedInstance
{
    static ManageB *manager = nil;
    static dispatch_once_t token;

    dispatch_once(&token, ^{
        manager = [[ManageB alloc] init];
    });

    return manager;
}

- (instancetype)init
{
    self = [super init];
    if (self) {
        [ManageA sharedInstance];
    }
    return self;
}

运行后的堆栈基本如下:

#0    0x000000011054acd2 in semaphore_wait_trap ()
#1    0x00000001101b1b1a in _dispatch_thread_semaphore_wait ()
#2    0x00000001101b1d48 in dispatch_once_f ()
#3    0x000000010d01c857 in _dispatch_once [inlined] at once.h:68
#4    0x000000010d01c839 in +[ManageA sharedInstance] at ManageA.m:18
#5    0x000000010d01cad8 in -[ManageB init] at ManageA.m:54
#6    0x000000010d01ca42 in __25+[ManageB sharedInstance]_block_invoke at ManageA.m:44
#7    0x00000001101c649b in _dispatch_client_callout ()
#8    0x00000001101b1e28 in dispatch_once_f ()
#9    0x000000010d01c9e7 in _dispatch_once [inlined] at once.h:68
#10    0x000000010d01c9c9 in +[ManageB sharedInstance] at ManageA.m:43
#11    0x000000010d01c948 in -[ManageA init] at ManageA.m:29
#12    0x000000010d01c8b2 in __25+[ManageA sharedInstance]_block_invoke at ManageA.m:19
#13    0x00000001101c649b in _dispatch_client_callout ()
#14    0x00000001101b1e28 in dispatch_once_f ()
#15    0x000000010d01c857 in _dispatch_once [inlined] at once.h:68
#16    0x000000010d01c839 in +[ManageA sharedInstance] at /ManageA.m:18
#17    0x000000010d01c5cc in -[AppDelegate application:didFinishLaunchingWithOptions:]         at /AppDelegate.m:21

从中我们可以发现,的确在这段调用栈中,出现了多次敏感字样sharedInstancedispatch_once_f字样。

在查阅相关资料后,感觉是dispatch_once_f函数造成了信号量的永久等待,从而引发死锁。那么,为什么dispatch_once会死锁呢?以前说的最安全的单例构造方式还正确不正确呢?

所以,我们一起来看看下面关于dispatch_once的源码分析。

dispatch_once源码分析

libdispatch获取最新版本代码,进入对应的文件once.c。去除注释后代码如下,共66行代码,但是真的是有很多奇妙的地方。

#include "internal.h"

#undef dispatch_once
#undef dispatch_once_f

struct _dispatch_once_waiter_s {
    volatile struct _dispatch_once_waiter_s *volatile dow_next;
    _dispatch_thread_semaphore_t dow_sema;
};

#define DISPATCH_ONCE_DONE ((struct _dispatch_once_waiter_s *)~0l)

#ifdef __BLOCKS__
// 1. 我们的应用程序调用的入口
void
dispatch_once(dispatch_once_t *val, dispatch_block_t block)
{
    struct Block_basic *bb = (void *)block;

    // 2. 内部逻辑
    dispatch_once_f(val, block, (void *)bb->Block_invoke);
}
#endif

DISPATCH_NOINLINE
void
dispatch_once_f(dispatch_once_t *val, void *ctxt, dispatch_function_t func)
{
    struct _dispatch_once_waiter_s * volatile *vval =
            (struct _dispatch_once_waiter_s**)val;

    // 3. 地址类似于简单的哨兵位
    struct _dispatch_once_waiter_s dow = { NULL, 0 };

    // 4. 在Dispatch_Once的block执行期进入的dispatch_once_t更改请求的链表
    struct _dispatch_once_waiter_s *tail, *tmp;

    // 5.局部变量,用于在遍历链表过程中获取每一个在链表上的更改请求的信号量
    _dispatch_thread_semaphore_t sema;

    // 6. Compare and Swap(用于首次更改请求)
    if (dispatch_atomic_cmpxchg(vval, NULL, &dow)) {
        dispatch_atomic_acquire_barrier();

        // 7.调用dispatch_once的block
        _dispatch_client_callout(ctxt, func);

        dispatch_atomic_maximally_synchronizing_barrier();
        //dispatch_atomic_release_barrier(); // assumed contained in above

        // 8. 更改请求成为DISPATCH_ONCE_DONE(原子性的操作)
        tmp = dispatch_atomic_xchg(vval, DISPATCH_ONCE_DONE);
        tail = &dow;

        // 9. 发现还有更改请求,继续遍历
        while (tail != tmp) {

            // 10. 如果这个时候tmp的next指针还没更新完毕,等一会
            while (!tmp->dow_next) {
                _dispatch_hardware_pause();
            }

            // 11. 取出当前的信号量,告诉等待者,我这次更改请求完成了,轮到下一个了
            sema = tmp->dow_sema;
            tmp = (struct _dispatch_once_waiter_s*)tmp->dow_next;
            _dispatch_thread_semaphore_signal(sema);
        }
    } else {
        // 12. 非首次请求,进入这块逻辑块
        dow.dow_sema = _dispatch_get_thread_semaphore();
        for (;;) {
            // 13. 遍历每一个后续请求,如果状态已经是Done,直接进行下一个
            // 同时该状态检测还用于避免在后续wait之前,信号量已经发出(signal)造成
            // 的死锁
            tmp = *vval;
            if (tmp == DISPATCH_ONCE_DONE) {
                break;
            }
            dispatch_atomic_store_barrier();
            // 14. 如果当前dispatch_once执行的block没有结束,那么就将这些
            // 后续请求添加到链表当中
            if (dispatch_atomic_cmpxchg(vval, tmp, &dow)) {
                dow.dow_next = tmp;
                _dispatch_thread_semaphore_wait(dow.dow_sema);
            }
        }
        _dispatch_put_thread_semaphore(dow.dow_sema);
    }
}

根据以上注释对源代码的分析,我们可以大致知道如下几点:

  1. dispatch_once并不是简单的只执行一次那么简单
  2. dispatch_once本质上可以接受多次请求,会对此维护一个请求链表
  3. 如果在block执行期间,多次进入调用同类的dispatch_once函数(即单例函数),会导致整体链表无限增长,造成永久性死锁。(其实只要进入两次就完蛋,其原因在于block_invoke的完成依赖于第二次进入的请求的完成,而第二次请求的完成又必须依赖之前信号量的出发。可是第一次block不结束,信号量压根不会触发)

备注

  1. 根据以上分析,相对应地写了一个简易的死锁Demo,就是在两个单例的初始化调用中直接相互调用。A<->B。也许这个Demo过于简单,大家轻易不会犯。但是如果是A->B->C->A,甚至是更多个模块的相互引用,那又该如何轻易避免呢?
  2. 以上的Demo,如果在Xcode模拟器测试环境下,是不会死锁从而导致应用启动被杀。这是因为模拟器不具备守护进程,如果要观察现象,可以输出Log或者直接利用真机进行测试。
  3. 有时候,启动耗时是因为占用了太多的CPU资源。但是从我们的Crash Log中可以发现,我们仅仅占用了Elapsed application CPU time (seconds): 0.h秒, k% CPU。通过这个,我们也可以发现,CPU占用率高并不是导致启动阶段APP Crash的唯一原因。

反思

虽然这次的问题直接原因是dispatch_once引出的死锁问题,但是个人认为,这却是滥用单例造成的后果。各位可以打开自己公司的app源代码查看一下,究竟存在着多少的单例。

实话实说,单例和全局变量几乎没有任何区别,不仅仅占用了全生命周期的内存,还对解耦造成了巨大的负作用。写起来容易,但是对于整个项目的架构梳理却是有着巨大的影响,因为在不读完整个相关代码的前提下,你压根不知道究竟哪里会触发单例的调用。

因此在这里,谈谈个人认为可以不使用单例的几个方面:

  1. 仅仅使用一次的模块,可以不使用单例,可以采用在对应的周期内维护成员实例变量进行替换
  2. 和状态无关的模块,可以采用静态(类)方法直接替换
  3. 可以通过页面跳转进行依赖注入的模块,可以采用依赖注入或者变量传递等方式解决

当然,的确有一些情况我们仍然需要使用单例。那在这种情况,也请将dispatch_once调用的block内减少尽可能多的任务,最好是仅仅负责初始化,剩下的配置、调用等等在后续进行。

Swift 中的静态Dispatch VS 动态Dispatch

C++ VS Swift

虽然我很早就了解了Swift(2014年的WWDC),但是在粗略看了一下Swift的语法后,我认为这不过是许多语言语法的大杂烩,感觉和C++没有啥区别。但是实际上,在我使用Swift的这几个月中,我发现了许多问题值得注意的地方,比如

  • 函数的返回值可以作为推断函数签名的依据。
  • Swift中的函数静态Dispatch VS 动态函数Dispatch

而第二点,也是本文要阐述的重点。

在展开本文的内容前,如果你曾经有C++的开发背景,不妨回忆下C++中RTTI机制,这也是多态发生的先决条件。简单来说,就是C++的多态函数是基于运行时的,我们可以看看下面这个例子:

class A
{
    void print(){cout << "I'm A";}
}

class B: public A
{
    void print(){cout << "I'm B";}
}

A *b = new B();
b->print();

相信大家一眼就能知道这个答案,会输出I'm B。那么,Swift中也存在class,那么对于Swift中的函数调用是否和C++一致呢?

Swift Class

首先我们先来验证下最基本的class中的行为。我们采用和C++中相同例子,定义如下:

class A
{
    func printInfo()
    {
        print("I'm A")
    }
}

class B:A
{
    override func printInfo()
    {
        print("I'm B")
    }
}

根据Swift的Type Inference 我们分别验证了如下几种调用方式:

let b1:B = B()
b1.printInfo() // I'm B

let b2:A = B()
b2.printInfo() // I'm B

let a1:A = A()
a1.printInfo() // I'm A

let a2 = A()
a2.printInfo() // I'm A

let b3 = B()
b3.printInfo() // I'm B

如果你自己的思考结果和这个一模一样,至少你理解了运行期和编译期的概念,恭喜你,你的C++是过关了。以class B举例,无论是b1, b2, b3中的哪一个,尽管其中有部分声明的类型是A,但是在实际运行时还是会走类似virtual function那套确认实际类型为B。但是,事情在Swift中真是这么简单吗?让我们接着往下看。

Protocol Extension

去年,Swift 2.0发布,随之而来,一个概念悄然兴起:面向协议的编程。而这种编程范式不可或缺的必要条件就是Protocol Extension。在Swift < 2.0 时代,Protocol的作用更类似于一种表征特征的约束。而有了Protocol Extension以后,Protocol更类似于一种插件装配的概念(写过Ruby的人相信会有体会),可以在无须编写代码的情况下,更自定义的元素添加行为能力。

哎?你上面说了这么一大段废话,和我们的文章主题有啥关系?

好,首先我们先看如下定义:

protocol Testable
{
    func dynamicInfo()
}

extension Testable
{
    func dynamicInfo()
    {
        print("I'm Testable")
    }
}

class A:Testable
{
    func dynamicInfo()
    {
        print("I'm A")
    }
}

class B:Testable
{
    func dynamicInfo() {
        print("I'm B")
    }
}

然后,我们进行如下调用:

let a1 = A()
a1.dynamicInfo() // I'm A

let b1 = B()     
b1.dynamicInfo() // I'm B

let a2:Testable = A()
a2.dynamicInfo() // I'm A

let b2:Testable = B()
b2.dynamicInfo() // I'm B

到这里,事情还是还是按照C++那套逻辑在走,如果你把class B中的dynamicInfo删除,那么对应B类型的dynamicInfo函数调用就会输出I’m Testable

好,现在问题来了,如果我们将Testable Protocol Extension添加一下东西,同时保持protocol Testable不变,如下所示:

protocol Testable
{
    func dynamicInfo()
}

extension Testable
{
    func dynamicInfo()
    {
        print("I'm Testable")
    }

    // ### 新添加的 ###
    func staticInfo()
    {
        print("I'm Testable Static")
    }
}

如果这个时候,我们进行如下代码的测试:

class A:Testable
{
    func dynamicInfo()
    {
        print("I'm A")
    }

    func staticInfo()
    {
        print("I'm A Static")
    }
}

let a1 = A()     
a1.staticInfo() // I'm A Static

let a2:A = A()
a2.staticInfo() // I'm A Static

let a3:Testable = A()
a3.staticInfo() // I'm Testable Static

看到没?最后一行的输出是不是出乎了大家的意料,竟然输出了I’m Testable Static

这是咋回事?回顾下之前我们改变的地方,发现我们在Protocol Extension中添加了一个func staticInfo(),但是却没在对应的Testable Protocol进行声明。但是这还不够,我们必须将调用staticInfo的地方的类型显式的声明成let a3:Testable

也就是说,Swift方法的静态Dispatch必须严格满足如下条件:

  • 方法在Extension中提供了实现,但是在对应的protocol中没有声明。
  • 调用方法的时候必须显示的声明成protocol的类型。

如果不好理解,我画了张图帮助大家加深印象:

还有一点需要注意的是,Swift中的静态Dispatch不以类的层级和override而转移,也就是说,如下这种定义:

class B:A
{
    override func staticInfo()
    {
        print("I'm B Static")
    }
}

当我们使用

let a4:Testable = B()
a4.staticInfo()

一样会输出I'm Testable Static

RxSwift的第一印象

声明:本文由本人独立翻译完成,同步发表在稀土掘金

去年整整一年,我都在试图理解响应式编程的原理是什么,并且试图验证如果在我的app中使用这种编程范式是否会带来好处。于是,我查询了许多相关的解决方案,从ReactiveCocoa & Objective-C开始,及其Swift版本ReactiveCocoa with Swift,再到我朋友实现的一个轻量级的框架VinceRP。上述这些都是令人赞叹不已的项目,ReactiveCocoa的项目成熟度非常高,但是十分复杂;而VinceRP的实现非常容易,所以理解起来非常简单。

在学习的过程中,我写了一系列关于我学习响应式编程的经历的文章,所以经常会被读者问到一些关于RxSwift的问题。惭愧地说,我还从没有使用RxSwift来编写一个项目。实际上我还从来没用过任何语言的Rx框架,所以我一直认为,对于那些曾在别的开发环境中有使用Rx经历的人来说,理解RxSwift是非常容易的。既然如此,我也是时候来尝试一把了。

Rx

Rx是最常使用的一个响应式编程框架。它与其他RP框架的一大不同是它的跨平台特性,同时,它有着最大的开源社区,无数的文档以及有参考价值的问题讨论,许许多多的人不断地对其进行改进。

Swift

这门语言在去年一年中飞速的成长,并且现在也进行了开源了。一些像RxSwift之类的项目也随着其一起成长。因此,没有什么理由可以再阻止你去使用这些框架。当然,一些重大的改动仍然被列在radar上,但它们很可能在短时间内不会被解决,这就意味着这个项目会不断地被改进,这不是很好吗?

使用RxSwift开发一个app

如果你从未阅读过我的博客,可能你现在会猜我使用RxSwift开发了一个app。没错,你是对的。这是个很耗时的习惯,但是我不喜欢依赖于一个理想的环境,所以通常我都会写一个例子来让我有那么一点感觉。通过这种方式,我可以学会如何让成功得运行这个框架。(意译:这里我想说一点个人感受,对于解决问题来说,你所选用的框架只是万千可用方案中的一种,因此,方案的选择是因人而异的。而这些选择所带来的多样性,正是我如此热爱编程的一大原因。)

我所写的这个应用名叫iCopyPasta,是一个在去年Functional Swift Conf上展示的免费Mac剪贴板应用CopyPasta的iOS姐妹版。显而易见,它们并不是一个完整的产品所以并不可以被用来上架。我现在每天都使用Mac版本的CopyPasta,但是我可能存在某些偏见。我的计划是将来会发布Mac版本和iOS版本的CopyPasta应用,并可能会将这两个版本进行打通。

难道这不是我一直以来的计划吗?

Observables

我首先对UIPasteboard注册了观察者。 这些观察者会对你拷贝东西时出现在UIPasteboard中的字符串图像类型进行观察。

let pasteboard = NSNotificationCenter.defaultCenter().rx_notification("UIPasteboardChangedNotification", object: nil)
_ = pasteboard.map { [weak self] (notification: NSNotification) -> PasteboardItem? in
    if let pb = notification.object as? UIPasteboard {
        if let string = pb.valueForPasteboardType(kUTTypeUTF8PlainText as String) {
            return self?.pasteboardItem(string)
        }
        if let image = pb.valueForPasteboardType(kUTTypeImage as String) {
            return self?.pasteboardItem(image)
        }
    }
    return nil
}

之前我的方法是直接对UIPasteboard中的字符串图像直接进行观察,但是这个方法是不正确的。原因在于UIPasteboard可能不是一个KVO安全的类型(具体请看下方的评论)。参考别人的建议后,我使用RxSwift另一个非常棒的功能rx_notification来监听UIPasteboardChangedNotification

.subscribeNext { [weak self] pasteboardItem in
    if let item = pasteboardItem {
        self?.addPasteboardItem(item)
    }
}

这里的pasteboard是一个Observable<NSNotification>,这也是为什么可以很容易得订阅其.Next事件同时相应地去更新tableView。而map则是从监听到的通知所涉及的对象中获取字符串或者图像,并将获取到的结果转换成PasteboardItem

Dispose bags

订阅信号会产生Disposable。如果不终止订阅,那么这些生成的Disposable将会一直存在,这无疑是非常耗内存的。所以,你要么对这些订阅调用dispose,要么你可以像我一样,使用dispose bags来自动销毁相关的订阅。

.addDisposableTo(disposeBag)

UIKit/Appkit bindings

你可以很容易地通过rx_itemsWithCellIdentifierObservable序列绑定到table view上。element来自于我定义的PasteboardItem枚举类型,这也是为什么我会采用Switch来处理这个对象,这样可以根据其具体的枚举值来显示不同的样式。

pasteViewModel.pasteboardItems()
    .bindTo(tableView.rx_itemsWithCellIdentifier("pasteCell", cellType: UITableViewCell.self)) { (row, element, cell) in
     switch element {
     case .Text(let string):
         cell.textLabel?.text = String(string)
     case .Image(let image):
         cell.imageView?.image = image
}.addDisposableTo(disposeBag)

另外一个很棒的补充是rx_modelSelected。你可以通过它来获取你触发选择事件时对应的element。简单来说,它是一个对tableView:didSelectRowAtIndexPath:的封装,可以将代码变得非常简洁。

tableView
    .rx_modelSelected(PasteboardItem)
    .subscribeNext { [weak self] element in
        self?.pasteViewModel.addItemsToPasteboard(element)
    }.addDisposableTo(disposeBag)

你可以通过如下链接来查看所以关于UIKit/AppKit(RxCocoa)的扩展RxSwift’s GitHub

总体感受

到目前为止,我还只是探索了RxSwift能力的一小部分,但是我已经感受到RxSwift是一个非常棒的框架。如果能够更深入理解它的机制并学会基于它的设计思路进行思考,那肯定会更好。

我非常喜欢一些像Rx.playgroundRxMarbles这样的资料及great community这样的社区。这些资料给了我很多的灵感,所以我也乐于将我的学习经验分享给bitrise.io的用户。还有一些比较重要的内容,比如schedulers还未被涉及,但是绝对值得研究一番。

对我来说,我还需要一段时间来更好地理解Rx。与我尝试ReactiveCocoa只有个把小时不同,我现在可以每天都在工作中使用RxSwift,并且坚持使用超过了一年。这都得感谢在Prezi的伙伴们.

作为一个曾经学习过ReactiveCocoa的人来说,我现在更倾向于使用RxSwift,可能是因为我现在自认为已经对于RxSwift已经足够了解,并且使用它可以很快得完成我的编码任务。当然,在将来我可能会同时使用两者,但是我认为对于两者之间任一框架的熟练使用不代表会在学习另外一个框架的时候给你带来很大的优势。它们在几个方面有着不同。同时,这两个框架(概括来说应该是所有的响应式编程框架)都有着陡峭的学习曲线。对于我来说,我已经度过了学习ReactiveCocoa最难的那段时光,但如果你是一个初学者,我建议你自己动手尝试这两种框架,甚至更多。

深入阅读

如果你还在思考应该使用哪个响应式编程的框架,那么我建议你去读一读Ash Furrow所写的关于如何挑选响应式编程框架的文章

你也可以看看其他一些在iOS中使用响应式编程的视频及文章,这些内容都非常得棒,相信你会受益匪浅。

逆向工程SizeUp

这几天把《汇编语言》好好复习一遍,心里痒痒,就想找个软件来逆向破解一发。破啥好呢?网上逆向工程的教程一大堆,主要都是Sketch啦,Reveal啦,那我照着做一遍也没啥意思啊,体现不出我中国iOS第12人的特点啊。干脆我找个小众一点的软件破解吧。于是,我就盯上了我每天都非常喜欢使用的SizeUp,这是一款非常快速的窗口管理软件,可以通过快捷键将窗口扩展到指定的大小和位置,配合外接显示屏简直酷炫到飞起。

但是这个App有个很大的问题,虽然它是免费的,但是它每次启动的时候,包括你使用的过程中,都会时不时蹦出一个提示你购买的弹框,而且弹框上的取消按钮一定要过5秒才能点击关闭,真是让人蛋疼。

所以,我就讲逆向的目标定在了将这个可恶的弹窗给干掉。

准备工作

首先破解必须要准备的就是逆向工具了,由于这是个Mac app,所以我们无需使用到iPhone。所以,我简单的将SizeUp进行了一次备份就开始了。

逆向一个app,我们当然要去分析其汇编代码,因此必不可少的工具就是IDA或者Hopper。在这里,请允许我个人强烈推荐Hopper,那傻瓜式的操作,非常适合我这种高智商人才,哇哈哈。Hopper也是支持免费的,但是免费版不能重新生成可执行文件,所以我先从网上下载了一个破解版的Hopper。

逆向开始

首先我们将SizeUp拖入Hopper,得到一系列的汇编代码。这么多的代码我们从哪里下手呢? 答案是关键字。在弹窗提示我们购买的界面中,出现了很多关键字,比如license抑或是demo。首先让我们从license开始尝试。我们在Hopper界面左上侧的搜索框中输入license,会得到如下结果:

从结果来看,我们大致猜测SizeUp的逻辑如下:

  1. 初始化程序
  2. 检查存储的license
  3. (如果有)多个,检查最好的一个(可能是有效期最长的)
  4. 和服务器进行验证

上述这段逻辑主要来自于高亮的+[License xxx]函数调用。

从上述这段逻辑,我们可以看出,想要伪造license是不可能的了,这是因为牵涉了服务器验证。所以我们只能把想法转变成,干掉本地相关的逻辑。本地逻辑不外乎判断某种分支条件,根据结果进行某些页面的跳转,比如弹出Demo界面

好,现在我们来试试Demo关键字,搜索结果如下:

从这个关键字的搜索结果来看,我们得到了不少有价值的信息,比如DemoDialogController。哈哈哈哈,苹果经典的MVC设计模式这时候起了很大的作用,搞过开发的人一般都会知道Controller一般对应的就是一个ViewController。如果你不信,我们继续往下看,可以看到一个-[DemoDialogController showDemoDialog],这个提示够明显了吧,这分明就是说:老子就是那个界面,你快来把我干掉吧。

好,大功告成一半了,我们已经找到了我们要干掉的界面,现在我们只要干掉分支判断逻辑就好了。于是,我们继续跟着Demo关键字走,不久,我们发现了+[License isDemo]这个嫌疑犯。卧槽,这时候,我这天赋异禀的大脑中形成了这样一段代码:

if ([License isDemo]) {
    [[[DemoDialogController alloc] init] showDemoDialog]
} else {
    // Follow your heart
}

是不是和我猜测的一样呢?

去掉前面的函数压栈,我们来着重看看这段代码:

mov edx, 0x1
test al, al
je 0x1000008bd9
....
....
mov eax, edx

这段代码不熟悉汇编的人可能不太懂,我将其转换一下。

edx = true(YES)
if al == 0
{
    goto 0x1000008bd9
}

... 0x1000008bd9:
val = edx(true)
return val

理解了吧,就是首先将0x1(即YES)放入edx寄存器,然后判断al代表的某种分支条件是不是0,如果是0,通过je命令跳转到0x1000008bd9地址。这个地址后面的指令就是讲edx的值塞入eax中,而eaxx86指令集中默认存放函数返回值的寄存器

事情到这,是不是基本理清思路了?我们只需要将je跳转的条件极其后面语句干掉就好了。我在这里采用了更暴力的做法,直接在函数一开始就讲false塞入eax寄存器,然后直接调用ret进行返回。

结语

是不是逆向工程看起来也没那么难呢?其实,SizeUp这种利用函数返回至做文章的逆向是最简单的,下次我们来挑战下更难的逆向目标!

DXXcodeConsoleUnicodePlugin源码解析

Xcode插件开发

嘿嘿,今天带大家学习一下基于Xcode的插件开发。可能很多人一听到插件开发,想到的都是Sublime Text,Atom这样轻量级的编辑器的扩展插件,但是实际上,无论是VisualStudio, Eclipse以及Xcode这样重量级的IDE,都是支持自定义的插件开发的。学习好了Xcode的插件开发,不仅可以打造度身定做的神器,也有助于你将来进行Mac OS的应用开发。

DXXcodeConsoleUnicodePlugin

DXXcodeConsoleUnicodePlugin是一个帮助你自动将\u6061这样的unicode码转换成对应的汉字的插件。

这个有什么用呢?想想看,我们在网络传输的时候,服务器如果返回的数据是中文(或者非ASCII码),通过NSLog在console输出的内容是不直观的,基本都是类似\u6061这种,这对于我们开发调试来说是非常困难的。

因此,这款插件可以自动帮助我们将检测到的Unicode字符进行转换,直接输出成我们想要的对应内容。怎么样?让我们赶快来一探究竟吧!

在开始探讨实现之前,我个人首先强调一点,基于Unicode检测对应的字符是一个非常难的问题。不仅仅是中文,韩文、日文、big-5字符等等都属于Unicode,这些字符集之间好常常有交集。现有比较好的开源实现是Mozilla的UcharSet**。

实现

首先打开工程,文件结构如下:

  • DXXcodeConsoleUnicodePlugin.h/.m
  • RegExCategories.h/.m

其中,DXXcodeConsoleUnicodePlugin是入口。同传统的iOS/Mac OS开发不同,插件开发并不存在传统意义上的main函数,更多的是利用所谓的Template Method设计模式将你需要的自定义部分进行复写。

于是,我们可以看到如下三段函数:

+ (void)pluginDidLoad:(NSBundle *)plugin
{
  static dispatch_once_t onceToken;
  NSString *currentApplicationName = [[NSBundle mainBundle] infoDictionary][@"CFBundleName"];
  if ([currentApplicationName isEqual:@"Xcode"]) {
    dispatch_once(&onceToken, ^{
      sharedPlugin = [[self alloc] initWithBundle:plugin];

      [[NSNotificationCenter defaultCenter] addObserver:self
                                               selector:@selector(menuDidChange)
                                                   name:NSMenuDidChangeItemNotification
                                                 object:nil];
    });
  }
}

- (id)initWithBundle:(NSBundle *)plugin
{
  if (self = [super init]) {
    // reference to plugin's bundle, for resource acccess
    self.bundle = plugin;

    // Create menu items, initialize UI, etc.

    // Sample Menu Item:
    [self createMenu];

    IMP_IDEConsoleItem_initWithAdaptorType = ReplaceInstanceMethod(NSClassFromString(@"IDEConsoleItem"), @selector(initWithAdaptorType:content:kind:),
                                                                   [XcodeConsoleUnicode_IDEConsoleItem class], @selector(initWithAdaptorType:content:kind:));
  }

  return self;
}

- (void)createMenu
{
  NSMenuItem *menuItem = [[NSApp mainMenu] itemWithTitle:@"Edit"];
  if (menuItem && !self.convertInConsoleItem) {
    [[menuItem submenu] addItem:[NSMenuItem separatorItem]];

    NSMenuItem *convertItem = [[NSMenuItem alloc] initWithTitle:@"ConvertUnicode" action:@selector(convertAction) keyEquivalent:@"c"];
    [convertItem setKeyEquivalentModifierMask:NSAlternateKeyMask];
    [convertItem setTarget:self];
    [[menuItem submenu] addItem:convertItem];

    self.convertInConsoleItem = [[NSMenuItem alloc] initWithTitle:@"ConvertUnicodeInConsole"
                                                           action:@selector(convertUnicodeInConsoleAction)
                                                    keyEquivalent:@""];
    [self.convertInConsoleItem setTarget:self];
    [[menuItem submenu] addItem:self.convertInConsoleItem];

    sIsConvertInConsoleEnabled = [[NSUserDefaults standardUserDefaults] boolForKey:sConvertInConsoleEnableKey];
    if (sIsConvertInConsoleEnabled) {
      self.convertInConsoleItem.state = NSOnState;
    } else {
      self.convertInConsoleItem.state = NSOffState;
    }
  }
}

上面三段函数我们一一进行解析。

  1. pluginDidLoad,大家可以理解为插件的程序入口,在这个入口中我们通过单例进行我们自己开发的插件加载。**之所以使用单例是因为这个pluginDidLoad可能会由于加载多个插件而被多次触发。

  2. initWithBundle函数是我们自定义插件的构造函数,我们通过它进行自己任务的创建和调用。

  3. createMenu则是对Xcode编辑器上的菜单添加属于我们自己的选项。

在这里,作者在Edit菜单下创建了属于自己的ConvertUnicode以及ConvertUnicodeInConsole,并对这些选项进行了快捷键绑定。

这些东西,除了自定义的菜单项及操作需要我们自己写以外,我们都可以通过Plugin Template这个插件自动生成。

到现在,我们还没有看到任何实质性的转换内容,别急,在initWithBundle中,作者通过Method SwizzlingIDEConsoleItem- (id)initWithAdaptorType:(id)arg1 content:(id)arg2 kind:(int)arg3和自己实现的XcodeConsoleUnicode_IDEConsoleItem进行的调换。

然后在替换后的方法中,实现解析,代码如下:

- (id)initWithAdaptorType:(id)arg1 content:(id)arg2 kind:(int)arg3
{
  id item = IMP_IDEConsoleItem_initWithAdaptorType(self, _cmd, arg1, arg2, arg3);

  if (sIsConvertInConsoleEnabled) {
    NSString *logText = [item valueForKey:@"content"];
    NSString *resultText = [DXXcodeConsoleUnicodePlugin convertUnicode:logText];
    [item setValue:resultText forKey:@"content"];
  }

  return item;
}

这个方法非常简单,通过原方法获取console中的item,并获取对应的content进行解析。而解析也仅仅是采用了UTF8StringEncoding直接进行转换。

补充知识:NSRegularExpression和正则表达式

在本文的实现当中,作者对于中文字符的Unicode的表达方式\u4582这样的格式,采用了正则表达式进行了提取。在传统的Unicode的格式中,单独一个\表示为转义字符,不能直接表达一般字符。所以,在正则表达式中,我们需要采用\\来表示一个\。同时,对于4582这样的字符,我们当然可以认为其模式为四个连续的字符,所以我们可以采用\w{4}。(切记,不能采用\W。大写的\W表征的是非字符。)然后{4}表示前面的模式重复4次,即\w连续出现4次。

好了,综上所述,我们不难写出针对中文Unicode提取的正则表达式:\\u\w{4}

但是,在作者的代码中,作者的正则表达式却是:\\\\[uU]\\w{4},那这个是怎么回事呢?
原因在于, 对于在字符串形式出现的正则表达式,首先解析的是字符串规则,然后才是正则表达式引擎的解析。

所以,\\\\被字符串解析成\\,然后正则解析成\。然后对于[uU],是一个组,表示或者u或者U,因为有些输出的文本里,对于U的大小写并没有规定,所以两种情况都需要考虑。

后面的就不再赘述了,原理一致。大家有兴趣的自己深入学习下吧。

Check Pods Manifest.lock

初次看到这个题目的你,可能还不了解这是个啥。但是,我想下面这个错误提示,你肯定会非常熟悉:

error: The sandbox is not in sync with the  
Podfile.lock. Run 'pod install' or update 
your CocoaPods installation.

没错,当我们使用cocoapods的时候,经常会遇到的一个问题。其原因在于我们本地的manifest.lock和通过git同步的Pod.lock的产生了差异。

注:manifest.lock简单可以理解为我们在本地执行一次pod install后生成的当前Podfile的状态的表征文件。而Pod.lock是同步他人更新过Podfile后的状态。

那么,这个差异报错的原因是什么呢?我们可以打开Xcode项目中对应的TargetBuild Phase,可以发现,其中存在在一项名为Check Pods Manifest.lock,是一个shell script,内容如下:

// 1.
diff "${PODS_ROOT}/../Podfile.lock" "${PODS_ROOT}/Manifest.lock" > /dev/null

// 2.
if [[ $? != 0 ]] ; then

// 3.
    cat << EOM
error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.
EOM
    exit 1
fi

我们来解读下这段代码的意思:

  1. 通过diff命令来检查Podfile.lockManifest.lock的区别。这个命令中的> /dev/null 可以视为一个黑洞,等价于一个只读文件,所有写入它的内容都会永远丢失. 而尝试从它那儿读取内容则什么也读不到。由于在执行diff命令的过程中可能产生大量的标准输出,可能会干扰我们的的工作流程执行,所以我们将它们全部丢弃给黑洞,只关心返回值

  2. if [[ $? != 0 ]] then这个命令指的上一个命令的返回值如果不等于0,就执行xxxx。其中$?也就代表着上一个命令diff的返回值。

  3. 好,如果返回值不为0,说明有差异,因此通过cat << EOMEOM将处于这两者之前的内容输出到标准输出。

改造脚本

好,既然我们已经读懂了上述的shell script,我们不如将这个错误的提示来进行整改,当有差异的情况下,自动去进行pod install

整体改造后的代码如下:

// 1.
diff "${PODS_ROOT}/../Podfile.lock" "${PODS_ROOT}/Manifest.lock" > /dev/null

// 2.
if [[ $? != 0 ]] ; then

// 3.
    pod install --project-directory="${PODS_ROOT}/../"
fi

开发一个简单的Pod Install 插件

前几天刚刚粗略了学习了一下Xcode的插件开发,一时心痒,就准备做个简单的插件练练手。

哎哟我擦,正当我准备大展身手的时候,我突然想到我该做个啥呢? 我这真是有了程序员,只差一个好Idea了。

好吧,正巧这个时候,我发现我和朋友协作的一个iOS项目在两个分支上同步开发,每次要合并后拉下分支,都发现Pod.lock文件都产生了变化,无法编译成功。每当这个时候,我都要进入terminal输入一大堆的cd ..进入对应的文件目录执行pod install命令,甚是繁琐。

当然啦,你可以通过alias 配置快速的执行命令,但是,你仍然得切换出Xcode的窗口,对于我们这种效率控来说不能接受。

所以,我就想到了,在Xcode中利用插件集成一下关于pod的一些功能,同时绑定快捷键提高操作效率。

说干就干。

首先我们利用Xcode的plugin template生成项目的一些基本流程结构。关于插件的具体思路可以参考我之前的一篇文章《DXXcodeConsoleUnicodePlugin源码解析》

在这里,我们着重介绍一下利用 NSTask 去执行诸如pod install这样的命令。

实现思路

在实现真正的Objective-C代码之前,我们首先现在terminal中随便找个安全的目录敲入pod install来试试看结果,如下所说:

pod install
[!] No `Podfile' found in the project directory.

从这个错误提示中我们可以大致了解,pod install的命令依赖于所谓的Podfile。于是,我们输入pod install --help查看其对应的帮助手册:

--project-directory=/project/dir/   The path to the root of the project
                                       directory
--no-clean                          Leave SCM dirs like `.git` and `.svn`
                                       intact after downloading
--no-integrate                      Skip integration of the Pods libraries
                                       in the Xcode project(s)
--no-repo-update                    Skip running `pod repo update` before
                                       install
--silent                            Show nothing
--verbose                           Show more debugging information
--no-ansi                           Show output without ANSI codes
--help                              Show help banner of specified command

从第一条帮助命令张,我们可以看到,我们需要通过–project-directory=来设置pod install的根目录,也即Podfile的所在。

好,事情到这里,我们在编写插件前需要的准备工作就基本完成了,我们现在只需利用NSTask将我们在命令行中输入的命令执行即可。

让我们来看看实现的代码:

 // 1.
 [self searchMainProjectPath];

 // 2.
 NSTask *podInstallAction = [[NSTask alloc] init];
 podInstallAction.currentDirectoryPath = self.mainProjectPath;
 podInstallAction.arguments = @[@"install"];
 podInstallAction.launchPath = @"/usr/bin/pod";

 // 3.
 NSPipe *pipeOut = [NSPipe pipe];
 [podInstallAction setStandardOutput:pipeOut];
 NSFileHandle *output = [pipeOut fileHandleForReading];

 [output setReadabilityHandler:^(NSFileHandle * _Nonnull fileHandler) {
   NSData *data = [fileHandler availableData];
   NSString *text = [[NSString alloc] initWithData:data encoding:NSASCIIStringEncoding];

   NSLog(@"text is %@", text);
 }];

[podInstallAction launch];
[podInstallAction waitUntilExit];
  1. 我们首先要寻找到当前项目的主目录,也就是Podfile的路径
  2. 然后我们构建NSTask,将其的执行目录设置成我们的主目录,然后/usr/bin中调出pod的可执行文件,执行pod install
  3. 我们利用NSPipe将默认的NSTask的输出(stdout)重定向到我们的指定的地方,这样有助于我们查看log或者进行流程工程。

到这里,基本上一个简单的小插件就完成了,但是我在这里想要强调一点关于主工程路径搜索的一些问题,我们首先来看代码:

NSArray *workspaceWindowControllers = [NSClassFromString(@"IDEWorkspaceWindowController") workspaceWindowControllers];
[workspaceWindowControllers enumerateObjectsUsingBlock:^(id controller, NSUInteger idx, BOOL *stop) {
  if ([[controller valueForKey:@"window"] isMainWindow]) {
    id workspace = [controller valueForKey:@"_workspace"];
    NSString *filePath = [[workspace valueForKey:@"representingFilePath"] valueForKey:@"pathString"];
    NSString *projectName = [[filePath lastPathComponent] stringByDeletingPathExtension];
    NSLog(@"CocoaPodUI::ProjectName::%@", projectName);

    NSString *text = [[filePath stringByDeletingLastPathComponent] stringByAppendingPathComponent:@"Podfile"];

    self.mainProjectPath = [filePath stringByDeletingLastPathComponent];

    NSLog(@"pod ifle is %@", text);
  }
}];

基于Xcode的插件开发实际上利用了大量的私有头文件。由于Objective-C著名的runtime特性,因此,很多时候,我们可以利用key-value-coding的方式获取我们普通途径下无法得到的结果。
同时,当有一个类的方法是私有方法的时候,你可以利用一个category声明同样的函数签名,不需要实现,Objective-C的runtime会自动帮你转发对应的message passing

FBKVOController 源码解析

开发过iOS的app已经不计其数了,在不同的项目中采用的架构也各不相同,有传统的MVC,简化的VIPER,以及一些简单的MVVM

这其中,我最不推荐的就是VIPER,谁写谁知道,,绝对是增加了项目的复杂性。MVVM由于自己总是受限于传统的Object-Oriented的思路,总是想不出真正的Functional Programming的代码,因此,绝大多数情况,写着写着都回归到了MVC

其实,相较于网上大家总喜欢提到的Massive View Controller问题,我更想说的是这种传统架构中对于信息流的不友好。

在一个典型的iOS的问题中,我们的代码执行流程,通常都是从View Controller的生命周期开始,如果是一个完全基于顺序执行的应用,那整个app的信息流是单向可跟踪的。但是往往事情并不会那么简单,我们会包含至少如下这些潜在打乱信息流的坏蛋

  • Delegate回调
  • NSNotification
  • UIView控件的Target-Action
  • KVO

在这里,你可能会以为我想谈谈ReactiveCocoaRxSwift,那你错啦,那个开源项目我暂时还没有能力去深究,所以我想从KVO事件入手,读一读Facebook出品的FBKVOController

FBKVOController

简单来说,FBKVOController是对KVO机制的一层封装,同时提供了线程安全的特性和并对如下这个臭名昭著的函数进行了封装,提供了干净的block的回调,避免了处理这个函数的逻辑散落的到处都是。

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context

源码分析

整个项目的结构非常简单,包含如下四个文件:

  • FBKVOController.h/.m
  • NSObject+FBKVOController.h/.m

其中,NSObject+FBKVOController只是通过AssociateObject给NSObject提供了一个retain及一个非retain型的KVOController。

这两种不同类型的KVOController有啥区别,我们稍后再提,我们将重点投向FBKVOController这个文件。

打开这个FBKVOController.m文件,哎呀,600多行文件,有点蛋疼。没事,配合头文件粗略扫一眼以后,可以发现其中很多方法都是convenience method

简单剥离一下数据结构以后,我们可以发现,主要的数据结构有如下三个。

  • FBKVOInfo
  • FBKVOSharedController
  • FBKVOController

FBKVOController

既然我们前面通过NSObject+FBKVOController知道了每个对象都会有其对应的FBKVOController,那我们就先来看看这个类吧。

//1.
@implementation FBKVOController
{
  NSMapTable *_objectInfosMap;
  OSSpinLock _lock;
}

//2.
- (instancetype)initWithObserver:(id)observer retainObserved:(BOOL)retainObserved
{
  self = [super init];
  if (nil != self) {
    // 2.
    _observer = observer;

    // 3.
    NSPointerFunctionsOptions keyOptions = retainObserved ? NSPointerFunctionsStrongMemory|NSPointerFunctionsObjectPointerPersonality : NSPointerFunctionsWeakMemory|NSPointerFunctionsObjectPointerPersonality;
    _objectInfosMap = [[NSMapTable alloc] initWithKeyOptions:keyOptions valueOptions:NSPointerFunctionsStrongMemory|NSPointerFunctionsObjectPersonality capacity:0];

    // 4.
    _lock = OS_SPINLOCK_INIT;
  }
  return self;
}
  1. 首先我们看到,这个对象持有一个OSSpinLock及一个NSMapTable。其中OSSpinLock即为自旋锁,当多个线程竞争相同的critical section时,起到保护作用。NSMapTable可能大家接触不是很多,我们在后文会详细介绍,这里大家可以先理解为一个高级的NSDictionary。

  2. 在构造函数中,首先将传入的observer进行weak持有,这主要为了避免Retain Cycle

  3. 这一段的内容可能大家不太熟悉,NSPointerFunctionsOptions简单来说就是定义NSMapTable中的key和value采用何种内存管理策略,包括strong强引用,weak弱引用以及copy(要支持NSCopying协议)

  4. 初始化自旋锁

接下来,使我们通过FBKVOController来对一个对象的某个或者某些keypath进行观察。

- (void)observe:(id)object keyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options block:(FBKVONotificationBlock)block
{
  NSAssert(0 != keyPath.length && NULL != block, @"missing required parameters observe:%@ keyPath:%@ block:%p", object, keyPath, block);
  if (nil == object || 0 == keyPath.length || NULL == block) {
    return;
  }

  // 1. create info
  _FBKVOInfo *info = [[_FBKVOInfo alloc] initWithController:self keyPath:keyPath options:options block:block];

  // 2. observe object with info
  [self _observe:object info:info];
}
  1. 对于传入的参数,构建一个内部的FBKVOInfo数据结构
  2. 调用[self _observe:object info:info];

接下来,我们来跟踪一下[self _observe:object info:info];,内容如下:

- (void)_observe:(id)object info:(_FBKVOInfo *)info
{
  // lock
  OSSpinLockLock(&_lock);

  // 1.
  NSMutableSet *infos = [_objectInfosMap objectForKey:object];

  // 2. 
  _FBKVOInfo *existingInfo = [infos member:info];
  if (nil != existingInfo) {
    NSLog(@"observation info already exists %@", existingInfo);

    // unlock and return
    OSSpinLockUnlock(&_lock);
    return;
  }

  // lazilly create set of infos
  if (nil == infos) {
    infos = [NSMutableSet set];
    [_objectInfosMap setObject:infos forKey:object];
  }

  // add info and oberve
  [infos addObject:info];

  // unlock prior to callout
  OSSpinLockUnlock(&_lock);

  // 3.
  [[_FBKVOSharedController sharedController] observe:object info:info];
}

抛开Facebook自身标记的注释,有三处比较值得我们注意:

  1. 根据被观察的object获取其对应的infos set。这个主要作用在于避免多次对同一个keyPath添加多次观察,避免crash。因为每调用一次addObserverForKeyPath就要有一个对应的removeObserverForKey

  2. infos set判断是不是已经有了与此次info相同的观察。

  3. 如果以上都顺利通过,将观察的信息及关系注册到_FBKVOSharedController中。

至此,FBKVOController的任务基本都结束,unObserve相关的任务逻辑大同小异,不再赘述。

FBKVOSharedController

初次看到这个类的时候,我的脑海中浮现了两个问题,FBKVOSharedController是干嘛的?为什么FBKVOController还需要将观察的信息转交呢?

其实我个人觉得这一层不是必要的,但是按照Facebook的理念来说就是将所有的观察信息统一交由一个FBKVOSharedController单例进行维护。如果大家读过Facebook出品的Flux架构,也会发现,Facebook经常喜欢维护一个类似于中间件的注册表,在这里,FBKVOSharedController承担的也是类似的职责。

于是,通过如下方法,我们像使用注册表一样将对KVOInfo注册。

- (void)observe:(id)object info:(_FBKVOInfo *)info
{
  if (nil == info) {
    return;
  }

  // register info
  OSSpinLockLock(&_lock);
  [_infos addObject:info];
  OSSpinLockUnlock(&_lock);

  // 1.
  [object addObserver:self forKeyPath:info->_keyPath options:info->_options context:(void *)info];
}
  1. 代表所有的观察信息都首先由FBKVOSharedController进行接受,随后进行转发。

实现observeValueForKeyPath:ofObject:Change:context
来接收通知。

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
  NSAssert(context, @"missing context keyPath:%@ object:%@ change:%@", keyPath, object, change);

  _FBKVOInfo *info;

  {
    // 1. 
    OSSpinLockLock(&_lock);
    info = [_infos member:(__bridge id)context];
    OSSpinLockUnlock(&_lock);
  }

  if (nil != info) {

    // take strong reference to controller
    FBKVOController *controller = info->_controller;
    if (nil != controller) {

      // take strong reference to observer
      id observer = controller.observer;
      if (nil != observer) {

        // dispatch custom block or action, fall back to default action
        if (info->_block) {
          info->_block(observer, object, change);
        } else if (info->_action) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
          [observer performSelector:info->_action withObject:change withObject:object];
#pragma clang diagnostic pop
        } else {
          [observer observeValueForKeyPath:keyPath ofObject:object change:change context:info->_context];
        }
      }
    }
  }
}
  1. 根据context上下文获取对应的KVOInfo
  2. 判断当前infoobservercontroller,是否仍然存在(因为之前我们采用的weak持有)
  3. 根据 infoblock或者selector或者override进行消息转发。

到这里,FBKVOController整体的实现就介绍完了,怎么样,是不是局部看自己都会实现,但是一结合起完整的设计思路,就觉得,不亏是Facebook呢。

NSMapTable

之前我们在前文中提到了NSMapTable,现在我们来详细介绍他一下。
我们在平常的开发中都使用过NSDictionary或者NSMutableDictionary,但是这两种数据结构有其的局限性。

NSDictionary为例,NSDictionarykeyhash值作为索引,存储对应的value。因此,key的要求是不能更改。所以,NSDictionary为了确保安全,对于key采用了copy的策略。

默认情况下,支持NSCopying协议的类型都可以作为key。但是考虑到copy带来的开销,一般情况下我们都使用简单的诸如数字或者字符串作为key。

那么,如果要使用Object作为key,想构建Object to Object的关系怎么办呢?这个时候就用到NSMapTable。我们可以通过NSFunctionsPointer来分别定义对key和value的储存关系,简单可以分类为strong,weak以及copy。而当利用object作为key的时候,可以定义评判相等的标准,如:use shifted pointer hash and direct equality, object description或者size

具体你需要去override如下几种方法:

// pointer personality functions
@property (nullable) NSUInteger (*hashFunction)(const void *item, NSUInteger (* __nullable size)(const void *item));
@property (nullable) BOOL (*isEqualFunction)(const void *item1, const void*item2, NSUInteger (* __nullable size)(const void *item));
@property (nullable) NSUInteger (*sizeFunction)(const void *item);
@property (nullable) NSString * __nullable (*descriptionFunction)(const void *item);

FBKVOController自定义的可以作为key的结构FBKVOInfo,就复写了

- (NSUInteger)hash
{
  return [_keyPath hash];
}

- (BOOL)isEqual:(id)object
{
  if (nil == object) {
    return NO;
  }
  if (self == object) {
    return YES;
  }
  if (![object isKindOfClass:[self class]]) {
    return NO;
  }
  return [_keyPath isEqualToString:((_FBKVOInfo *)object)->_keyPath];
}

DGRunKeeperSwitch 源码解析

DGRunKeeperSwitch是非常有趣的自定义的Segment Control的实现,从其Github上的展现效果来看,可以发现在 同一个 UILabel中的文本竟然可以展现出两种不同的颜色,是不是很奇妙?今天就让我们来看看它是如何实现的。

源码分析

打开项目,发现这个项目真的很简单,就一个文件,DGRunkeeperSwitch.swift,并且实现也只有接近260行左右。

既然这个项目是个UI的开源库,我们主要还是先从界面层级入手。和Glow的开源库(GLCalendar)不同,这个是纯手写的控件,因此无法从.xib文件来快速了解,所以我们把目标首先投向相关的UIKit子属性,包括如下:

// 1. 
private var titleLabelsContentView = UIView()
private var leftTitleLabel = UILabel()
private var rightTitleLabel = UILabel()

// 2.
private var selectedTitleLabelsContentView = UIView()
private var selectedLeftTitleLabel = UILabel()
private var selectedRightTitleLabel = UILabel()

// 3.
private(set) var selectedBackgroundView = UIView() 
private var titleMaskView: UIView = UIView()

其中第一部分我们一看命名就很容易理解了,有一个ContentView作为container,包含了segment control对应的左右两个Label。

然后来看第二部分,第二部分从命名上也很直观,感觉上和第一部分是一致的,但是却可能代表的是选中的状态。不过我们很奇怪,作者为什么要构建一个一模一样的来表征不同的状态呢,直接用一个变量比如 var selected = false 进行样式的控制不可以吗?

好,先别急,这里卖个关子,我们继续往下看。

第三部分,selectedBackgroundViewtitleMaskView,从名字看,也不能一下子了解含义,我们先全局搜索下相关连的代码,与titleMaskView相关的内容如下:

titleMaskView.backgroundColor = .blackColor()
selectedTitleLabelsContentView.layer.mask = titleMaskView.layer

看起来是用titleMaskView给之前可能的选中状态的selectedTitleLabelsContentView加了一层遮罩。

由于遮罩是白色的地方不显示,黑色的地方(准确来说是非白色的区域)显示,因此我们可以理解上述代码是通过titleMaskView来显示selectedTitleLabelsContentView中的内容(也就是两个UILabel),非titleMaskView区域自动隐藏了。

addObserver(self, forKeyPath: "selectedBackgroundView.frame", options: .New, context: nil)

override public func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer<Void>) {
    if keyPath == "selectedBackgroundView.frame" {
        titleMaskView.frame = selectedBackgroundView.frame
    }
}

哦,看完上述这段代码,我开始有点恍然大悟了,通过监听selectedBackgroundView.frame,我们实时改变titleMaskViewframe。而通过实际运行项目,我们可以很容易理解selectedBackgroundView就是用户可拖拽的选项高亮条。

到这,我渐渐有点理解作者为什么要构建两个完全一样的contentView,并都包含左右两个UILabel了。

作者应该是对于titleLabelsContentView设定为普通状态的Label,左右两个Label都是未选中的颜色状态,同时将selectedTitleLabelsContentView设定为选定状态,左右Label都使用了选中时候的颜色状态,然后通过titleMaskView进行遮罩,这样,selectedTitleLabelsContentView其余部分就被隐藏,会显示出下部titleLabelsContentView普通状态的Label颜色。

嘿嘿,读一下剩下的源代码,和我的猜测一致,不得不说,我真是太聪明了,这个思路真是太赞了。

如何真正实现一个好的UI库

看到这个小标题,可能有人会产生疑惑,实现好一个UI库不就是功能正确,效果正常吗?错!

我认为这只是基本的两点,还有如下几点需要包含:

  • 使用正确的类型
  • 在正确的函数中做正确的事
  • 暴露不过多也不过少的属性
  • 抛出、监听相对应的事件
  • 根据不同屏幕大小、屏幕方向进行适配
  • 横竖屏情况都能展示良好
  1. 第一,从DGRunkeeperSwitch来看,首先由于其模仿的是UISegmentControl,所以自然而然的应该继承与UIControl而不是UIView。有人要问有啥区别,简单来说就是UIControl将UIView中能接受的Touch事件,转换成了更高级的UIEvent,比如UITouchUpInside。

  2. 第二,作者通过init函数进行初始化,通过layoutSubview进行页面布局,而不是像很多人自己写代码时将很多东西一窝蜂的堆到了init中。

  3. 提供了颜色、字体、边距以及动画弹性等属性给外部调用,同时将不应该暴露的内部UIKit变量进行私有化,并将selectedIndex通过private(set)对外设置为只读。

  4. 在切换Segment选择后,抛出了相应的sendActionsForControlEvents(.ValueChanged) 用于给外部监听。

效果之外的重点

作者在实现这个项目之中,有几点是比较值得注意的:

利用元组同时赋值多个属性

public var leftTitle: String {
    set { (leftTitleLabel.text, selectedLeftTitleLabel.text) = (newValue, newValue) }
    get { return leftTitleLabel.text! }
}

在Swift中引入了一个元组的新类型,我们可以利用这个数据结构同时给多个属性赋值。

private(set)

private(set) public var selectedIndex: Int = 0

作者在实现过程中保留了一个selectedIndex 变量,但是这个类对外只读,对内可以读写,因此用了private(set)

这相当于在Objective-C时代,我们在.h文件中声明 @property(nonatomic, strong, readonly) Class *A
然后又在.m文件中,声明 @property(nonatomic, strong, readwrite) Class *A

UIView和CALayer

很多人写iOS的时候,分不清UIView和CALayer之间的区别,很多人都理解成了继承的关系。大错特错!

  • 实际上UIView里面有个成员变量是CALayer,而CALayer的delegate是UIView(这会涉及到很多的隐式动画之类的,不展开了)
  • UIView可以接受Touch事件,而Layer不行
  • UIView有个layerClass的类型方法,可以被复写,用于改变这个UIView对应的基础Layer类型,比如你可以将赋值CAGradientLayer给这个View

在本项目中,作者复写了layerClass,如下:

override public class func layerClass() -> AnyClass {
    return DGRunkeeperSwitchRoundedLayer.self
}

好啦,今天就差不多到这啦~下周再见。

PureLayout 源码解析

在开始这篇文章之前,想必大家都应该使用过Autolayout方式的界面布局,相信大家都有过类似于如下这样的API调用:

[NSLayoutConstraint(item: self.viewA, attribute: .CenterY, relatedBy: .Equal, toItem: self.viewB, attribute: .CenterY, multiplier: 1.0, constant: 0.0)]

抑或是Visual Format Language

NSLayoutConstraint.constraintsWithVisualFormat("|-(leftPadding)-[imageView(imageViewWidth)]-(rigntPadding)-[labelA]-(4)-[labelB]-(>=44)-|", options: NSLayoutFormatOptions(rawValue: 0), metrics: methics, views: views)

这种冗长而又晦涩的代码,真是恶心人啊。因此在Github上涌现了一大堆简化布局的开源库,如SnapKit, Mansory以及今天我们要说的PureLayout。

在这之中,PureLayout是最轻量级的,它仅仅是对Autolayout现成的语法进行了一层封装,相较于Mansory引入的一些新概念,Purelayout更直接易懂。

源码解析

Purelayout的源码基本没什么难懂的地方,我们首先来看一下其项目结构:

  • PurelayoutDefines.h
  • ALView + PureLayout.h/.m
  • NSArray + PureLayout.h/.m
  • NSLayoutConstraint + Purelayout.h/.m

PurelayoutDefines

首先从PurelayoutDefines上入手,这个文件主要是进行一些类似Domain Specific Language定义的转化,如:

typedef NS_ENUM(NSInteger, ALEdge) {
    /** The left edge of the view. */
    ALEdgeLeft = NSLayoutAttributeLeft,
    /** The right edge of the view. */
    ALEdgeRight = NSLayoutAttributeRight,
    /** The top edge of the view. */
    ALEdgeTop = NSLayoutAttributeTop,
    /** The bottom edge of the view. */
    ALEdgeBottom = NSLayoutAttributeBottom,
    /** The leading edge of the view (left edge for left-to-right languages like English, right edge for right-to-left languages like Arabic). */
    ALEdgeLeading = NSLayoutAttributeLeading,
    /** The trailing edge of the view (right edge for left-to-right languages like English, left edge for right-to-left languages like Arabic). */
    ALEdgeTrailing = NSLayoutAttributeTrailing
};

上述这段代码,就是将传统的UIKit中的NSLayoutAttribute的枚举类型全部转换成对应的PureLayout中的定义,如ALEdgeRight对应到NSLayoutAttributeRight。

LayoutMargins
在这里补充一点题外知识,在iOS8中,苹果为Autolayout引入了LayoutMargins这一概念。这个概念乍一听可能都不了解,但是大家回忆下,比如在Storyboard中,我们拖拽一个UIView到ViewController的view并设置边距的时候,上边距和下边距对应的限制都是layout guide,如下图所示:


简单来说,在iOS7上就已经存在了LayoutMargin了,当时的作用是用来限制view的真实内容不会被UINavigationBar(上部)以及UIToolbar(下部)所遮盖。而从iOS8中开始,苹果将这一技术引入到了任意一个UIView中。

ALView + Purelayout

ALView实际上是UIView或者NSView的别名,通过添加ALView的分类,可以通过Define在编译期进行替换,避免为NSView和UIView各创建一份重复的代码。这个类中的API过多,因此我们以轴对齐为典型的例子来分解下源码:

  1. 轴对齐
    在PureLayout中,包括Vertical, Horizontal, Baseline等几种轴对齐方式,其中Baseline指的是View中潜在包含文字的Baseline。

好,我们来看看相关的API

/** Aligns an axis of the view to the same axis of another view. */
- (NSLayoutConstraint *)autoAlignAxis:(ALAxis)axis toSameAxisOfView:(ALView *)otherView;

从该API的名称,我们可以直观的感觉出其作用是用于将两个View按照同一个轴对齐。这个API是一个Convenience Init,其层层传递

- (NSLayoutConstraint *)autoAlignAxis:(ALAxis)axis toSameAxisOfView:(ALView *)otherView
{
    return [self autoAlignAxis:axis toSameAxisOfView:otherView withOffset:0.0];
}

- (NSLayoutConstraint *)autoAlignAxis:(ALAxis)axis toSameAxisOfView:(ALView *)otherView withOffset:(CGFloat)offset
{
    return [self autoConstrainAttribute:(ALAttribute)axis toAttribute:(ALAttribute)axis ofView:otherView withOffset:offset];
}

最后调用了

- (NSLayoutConstraint *)autoConstrainAttribute:(ALAttribute)attribute toAttribute:(ALAttribute)toAttribute ofView:(ALView *)otherView withOffset:(CGFloat)offset`

好,那就让我们来看看这个上述这个函数的实现,如下所示:

//1.
self.translatesAutoresizingMaskIntoConstraints = NO;

//2.
NSLayoutAttribute layoutAttribute = [NSLayoutConstraint al_layoutAttributeForAttribute:attribute];
NSLayoutAttribute toLayoutAttribute = [NSLayoutConstraint al_layoutAttributeForAttribute:toAttribute];

//3.
NSLayoutConstraint *constraint = [NSLayoutConstraint constraintWithItem:self attribute:layoutAttribute relatedBy:relation toItem:otherView attribute:toLayoutAttribute multiplier:1.0 constant:offset];

//4.
[constraint autoInstall];
return constraint;
  • 1.首先将translatesAutoresizingMaskIntoConstraints设置为false,对于要使用autolayout的UIView,必须设置为false,也就是不将传统frame布局中的Autoresizing Mask转换成约束。
  • 2.根据传入的PureLayout属性转换成对应的NSLayoutAttribute
  • 3.调用冗长恶心的Autolayout API构建约束
  • 4.添加约束

在这里,我们需要注意一下这个[constraint autoInstall],让我们来探一探实现:

- (void)autoInstall
{
// 1. iOS8+
#if __PureLayout_MinBaseSDK_iOS_8_0 || __PureLayout_MinBaseSDK_OSX_10_10
    if ([self respondsToSelector:@selector(setActive:)]) {
        [NSLayoutConstraint al_applyGlobalStateToConstraint:self];
        // 1.1
        if ([NSLayoutConstraint al_preventAutomaticConstraintInstallation]) {         
            [[NSLayoutConstraint al_currentArrayOfCreatedConstraints] addObject:self];
        } else {
        // 1.2 
            self.active = YES;
        }
        return;
    }
#endif /* __PureLayout_MinBaseSDK_iOS_8_0 || __PureLayout_MinBaseSDK_OSX_10_10 */

// 2. iOS 7
    NSAssert(self.firstItem || self.secondItem, @"Can't install a constraint with nil firstItem and secondItem.");
    if (self.firstItem) {
        if (self.secondItem) {
            NSAssert([self.firstItem isKindOfClass:[ALView class]] && [self.secondItem isKindOfClass:[ALView class]], @"Can only automatically install a constraint if both items are views.");
            ALView *commonSuperview = [self.firstItem al_commonSuperviewWithView:self.secondItem];
            [commonSuperview al_addConstraint:self];
        } else {
            NSAssert([self.firstItem isKindOfClass:[ALView class]], @"Can only automatically install a constraint if the item is a view.");
            [self.firstItem al_addConstraint:self];
        }
    } else {
        NSAssert([self.secondItem isKindOfClass:[ALView class]], @"Can only automatically install a constraint if the item is a view.");
        [self.secondItem al_addConstraint:self];
    }
}

整个实现的部分被一分为二,上半部分专门针对iOS8+的,下半部分针对iOS7(事实上在整个PureLayout的设计中,大部分地方的处理方式都一分为二了

我们暂时也不管al_applyGlobalStateToConstraint:self 以及 al_preventAutomaticConstraintInstallation的作用,我们从1.2看起。

  • 在iOS8上,启用或者禁用一个AutoLayout的Constraint变得更加容易了,仅仅需要设置active即可
  • 在iOS7上,需要手动的addConstraint或者removeConstraint
  • 在处理iOS7的逻辑当中,需要判断当前这个Constraint是否是针对两个Item的,如果是,找到他们的公共父View,在父View在添加约束,比如添加View A和View B之间的间距;而如果是单一一个View,比如是设置高度或者宽度的,直接在当前View添加即可。
  • 通过调用al_addConstraint进行约束实际的添加。

al_addConstraint的实现则如下所示:

[NSLayoutConstraint al_applyGlobalStateToConstraint:constraint];
if ([NSLayoutConstraint al_preventAutomaticConstraintInstallation]) {
    [[NSLayoutConstraint al_currentArrayOfCreatedConstraints] addObject:constraint];
} else {
    [self addConstraint:constraint];
}

这里又出现了al_applyGlobalStateToConstraint:constraint以及al_preventAutomaticConstraintInstallation了,这次我们可不能再躲着它了,赶紧瞧一瞧。

首先是al_applyGlobalStateToConstraint:constraint,这个参数对应的是一个全局静态变量,用于判断:

if ([NSLayoutConstraint al_isExecutingPriorityConstraintsBlock]) {
    constraint.priority = [NSLayoutConstraint al_currentGlobalConstraintPriority];
}

而这个al_isExecutingPriorityConstraintsBlock则是用于如下这个函数:

+ (void)autoSetPriority:(ALLayoutPriority)priority forConstraints:(ALConstraintsBlock)block
{
    NSAssert(block, @"The constraints block cannot be nil.");
    if (block) {
        [[self al_globalConstraintPriorities] addObject:@(priority)];
        block();
        [[self al_globalConstraintPriorities] removeLastObject];
    }
}

这里可能大家有点晦涩,主要在于PureLayout对于给Constraint设置Priority定义了一个Block-based的方法,也就是autoSetPriority。在回调的Block中,可以对多个Constraint设置同一个大小的Priority。(其实我也不是很理解这个集体加Priority设计的目的

不过需要有一点可以肯定的是,设置Constraint的Priority的时机一定要在addConstraint或者active = true之前

而对于al_preventAutomaticConstraintInstallation这个变量,作者在API中描述了如下一段话:

Creates all of the constraints in the block, then installs (activates) them all at once.
All constraints created from calls to the PureLayout API in the block are returned in a single array.
This may be more efficient than installing (activating) each constraint one-by-one.

简而言之,一次性添加所有约束(实际上调用了UIKit的APIactivateConstraints),比一个个添加要有效率。然而,Purelayout的这个特性对于iOS7来说,用不上,只能通过addConstraint一个个装,哈哈,么么哒

NSArray + Purelayout

说完了ALView的layout,我们接下来说说另外的NSArray + Purelayout。顾名思义,该分类的主要目的就是给一个NSArray中的所有UIView添加约束。

比如这个API:

- (__NSArray_of(NSLayoutConstraint *) *)autoDistributeViewsAlongAxis:(ALAxis)axis
                                                           alignedTo:(ALAttribute)alignment
                                                    withFixedSpacing:(CGFloat)spacing
                                                        insetSpacing:(BOOL)shouldSpaceInsets
                                                        matchedSizes:(BOOL)shouldMatchSizes

其实现如下:

NSAssert([self al_containsMinimumNumberOfViews:1], @"This array must contain at least 1 view to distribute.");

//1. 第一部分
    ALDimension matchedDimension;
    ALEdge firstEdge, lastEdge;
    switch (axis) {
        case ALAxisHorizontal:
        case ALAxisBaseline: // same value as ALAxisLastBaseline
#if __PureLayout_MinBaseSDK_iOS_8_0
        case ALAxisFirstBaseline:
#endif /* __PureLayout_MinBaseSDK_iOS_8_0 */
            matchedDimension = ALDimensionWidth;
            firstEdge = ALEdgeLeading;
            lastEdge = ALEdgeTrailing;
            break;
        case ALAxisVertical:
            matchedDimension = ALDimensionHeight;
            firstEdge = ALEdgeTop;
            lastEdge = ALEdgeBottom;
            break;
        default:
            NSAssert(nil, @"Not a valid ALAxis.");
            return nil;
    }
    CGFloat leadingSpacing = shouldSpaceInsets ? spacing : 0.0;
    CGFloat trailingSpacing = shouldSpaceInsets ? spacing : 0.0;

//2. 第二部分  
    __NSMutableArray_of(NSLayoutConstraint *) *constraints = [NSMutableArray new];
    ALView *previousView = nil;
    for (id object in self) {
        if ([object isKindOfClass:[ALView class]]) {
            ALView *view = (ALView *)object;
            view.translatesAutoresizingMaskIntoConstraints = NO;
            if (previousView) {
                // Second, Third, ... View
                [constraints addObject:[view autoPinEdge:firstEdge toEdge:lastEdge ofView:previousView withOffset:spacing]];
                if (shouldMatchSizes) {
                    [constraints addObject:[view autoMatchDimension:matchedDimension toDimension:matchedDimension ofView:previousView]];
                }
                [constraints addObject:[view al_alignAttribute:alignment toView:previousView forAxis:axis]];
            }
            else {
                // First view
                [constraints addObject:[view autoPinEdgeToSuperviewEdge:firstEdge withInset:leadingSpacing]];
            }
            previousView = view;
        }
    }
    if (previousView) {
        // Last View
        [constraints addObject:[previousView autoPinEdgeToSuperviewEdge:lastEdge withInset:trailingSpacing]];
    }
    return constraints;            
  1. 这个API的目的是将一组UIView按照Spacing间距进行均分,同时每个UIView的宽度或者高度保持一致。
  2. 第一部分是根据传入的轴,进行判断,是在竖直方向均分还是水平方向均分,同时影响的还有是宽度一致还是高度一致。
  3. 第二部分是根据传入的轴(比如水平方向),将前一个View的右边距和后一个View的左边距添加间距,循环添加,直至最后一个View的右边距和父View的右边距添加完成约束。

其他方面,这个分类的作用基本和ALView + PureLayout一致,也就不再重复解释了。
至此,PureLayout的源码解析基本上差不多了,其余类似于边对齐的API,如:

- (NSLayoutConstraint *)autoPinEdge:(ALEdge)edge toEdge:(ALEdge)toEdge ofView:(ALView *)otherView;

又或者是约束尺寸的,如:

- (__NSArray_of(NSLayoutConstraint *) *)autoSetDimensionsToSize:(CGSize)size;

都大同小异,在此就不一一赘述了。

最后,强调一点

  1. PureLayout必须在主线程使用,其本身实现非常依赖于静态的全局变量。

重构你的ViewController

这篇文章来自阅读Let’s Play: Refactor the Mega Controller!

在该文中,作者阐述了如何使用Swift重构一个臭名昭著的Massive View Controller。从中,我们可以一窥Swift诸多优秀的特性以及如何利用这些特性将ViewController的职责进行解耦。

但是作者由于时间有限,并没有讲述完全,因此本文是我阅读源码后的理解。

建议大家在阅读本文之前,能够先去看看链接中的视频。

Let’s get started

首先我们下载源码,可以看到如下文件:

- NavigationController.swift
- ViewController.swift
- AddViewController.swift

其中,ViewController.swift是项目的核心,代码行数超过246行。在这里我要强调一下,并不是代码行数多不好,而是要看你这个职责是不是相关。如果246行都是在实现一个数据结构或者算法,当然可行。但是如果246行里面包含了逻辑业务、网络请求、数据持久化,那必然是可以分离一部分职责出去。

在本文的ViewController.swift,这个类在初始状态下包含了UITableViewDataSource, UITableViewDelegate, UIViewControllerTransitioningDelegate, UIViewControllerAnimatedTransitioning, NSFetchedResultsController以及一系类跟UI显示相关的代码。

1. 干掉UINavigationBar相关的内容

作者在app中构建了可变化的NavigationBar,因此bar的样式是根据不同状态进行改变的。原来的逻辑整体写在了ViewController.swift中,如下所示:

 func updateNavigationBar() {
        switch fetchedResultsController!.fetchedObjects!.count {
        case 0...3:
            navigationController!.navigationBar.barTintColor = nil
            navigationController!.navigationBar.titleTextAttributes = nil
            navigationController!.navigationBar.tintColor = nil
        case 4...9:
            navigationController!.navigationBar.barTintColor = UIColor(red: 235/255, green: 156/255, blue: 77/255, alpha: 1.0)
            navigationController!.navigationBar.titleTextAttributes = [NSForegroundColorAttributeName: UIColor.whiteColor()]
            navigationController!.navigationBar.tintColor = UIColor.whiteColor()
        default:
            navigationController!.navigationBar.barTintColor = UIColor(red: 248/255, green: 73/255, blue: 68/255, alpha: 1.0)
            navigationController!.navigationBar.titleTextAttributes = [NSForegroundColorAttributeName: UIColor.whiteColor()]
            navigationController!.navigationBar.tintColor = UIColor.whiteColor()
        }
    }

override func preferredStatusBarStyle() -> UIStatusBarStyle {
    switch fetchedResultsController?.fetchedObjects!.count {
    case .Some(0...3), .None:
        return .Default
    case .Some(_):
        return .LightContent
    }
}

同时,还在几个事件回调的地方,如Core Data的controllerDidChange,调用了setNeedsStatusBarAppearanceUpdate()

这个想法粗略想想并没什么问题,因为我们需要根据一系列的事件变化来改变我们的界面样式,这是很明显的业务逻辑。而我们都很清楚,ViewController就是用来写业务逻辑的地方。

先抛开ViewController是否是应该写业务逻辑的地方这一个有待商榷的论点之外,我们先看看,我们可以如何重构现有代码。

首先updateNavigationBar中多个case中的代码有了重复,因此我们可以将其重构成一个函数,接受三个关于样式的参数,如下:

func applyTheme(barTintColor:newBarTintColor, tintColor:newTintColor, titleTextAttributes:newTextAttributes) {
    barTintColor = barTintColor:newBarTintColor
    tintColor = tintColor:newTintColor
    titleTextAttributes = titleTextAttributes:newTextAttributes
}

重构完函数以后,我们发现在多个样式中用到了switch case进行业务逻辑参数转换样式参数的过程。这说明什么,我们可以将转换逻辑和switch case一起通过Enum进行重构(这里说的东西都是基于你懂Enum)

enum NavigationTheme {
    case Normal
    case Warning
    case Doomed

    var statusBarStyle: UIStatusBarStyle {
        switch self {
        case .Normal: return .Default
        case .Warning, .Doomed: return .LightContent
        }
    }

    var barTintColor: UIColor? {
        switch self {
        case .Normal:
            return nil
        case .Warning:
            return UIColor(red: 235/255, green: 156/255, blue: 77/255, alpha: 1.0)
        case .Doomed:
            return UIColor(red: 248/255, green: 73/255, blue: 68/255, alpha: 1.0)
        }
    }

    var titleTextAttributes: [String: NSObject]? {
        switch self {
        case .Normal:
            return nil
        case .Warning, .Doomed:
            return [NSForegroundColorAttributeName: UIColor.whiteColor()]
        }
    }

    var tintColor: UIColor? {
        switch self {
        case .Normal:
            return nil
        case .Warning, .Doomed:
            return UIColor.whiteColor()
        }
    }
}

extension NavigationTheme {
    init(numberOfImminentTasks: Int) {
        switch numberOfImminentTasks {
        case -Int.max ... 3:
            self = .Normal
        case 4...9:
            self = .Warning
        default:
            self = .Doomed
        }
    }
}    

由于Enum在swift中是一等公民,因此可以可以在其中构建大量的Computed Properties,这些计算变量依赖于当前enum的状态。不仅如此,我们还将之前分散的三种样式,组合成了一个紧凑的结构体,大大简化了变量传输。

重构结束后,我们在Viewcontroller.swift中设置一个计算变量navigationTheme,其的构造函数是之前的fetchedResultsController?.fetchedObjects?.count

最后是在相应的事件后触发更新UINavigationBar即可,在本文的视线中,是采用了closure的形式完成:

navigationThemeDidChangeHandler = { [weak self] theme in
            if let navigationController = self?.navigationController {
                navigationController.navigationBar.applyTheme(theme)
                navigationController.statusBarStyle = theme.statusBarStyle
            }
        }

2. 干掉时间相关的转换逻辑

相信很多人做app的时候遇到过,服务器返回的是一系列标准时间参数,而你需要将其转换成界面需要的生日、星座、年龄等等,这又是一大堆的业务逻辑。为了解决这种逻辑代码和ViewController的耦合,很多人提出了ViewModel,将部分弱业务逻辑代码剥离出来,单独写在一个地方。

但是,我需要强调一点,这种形式的剥离,并不能叫ViewModal,而是一个简单的adapter而已。

在本文中,列表Cell里面需要根据日期距离当前时间的差距显示成昨天、今天、明天等等。因此,其构建了一个单独的的DateFormatter,根据传入的两个Date进行转换,代码如下:

struct RelativeTimeDateFormatter {
    let calendar: NSCalendar

    init(calendar: NSCalendar = NSCalendar.autoupdatingCurrentCalendar()) {
        self.calendar = calendar
    }

    func stringForDate(date: NSDate, relativeToDate baseDate: NSDate) -> String {
        var beginningOfDate: NSDate? = nil
        var beginningOfBaseDate: NSDate? = nil

        calendar.rangeOfUnit(.Day, startDate: &beginningOfDate, interval: nil, forDate: date)
        calendar.rangeOfUnit(.Day, startDate: &beginningOfBaseDate, interval: nil, forDate: baseDate)
        let numberOfCalendarDaysBetweenDates = calendar.components(NSCalendarUnit.Day, fromDate: beginningOfBaseDate!, toDate: beginningOfDate!, options: NSCalendarOptions()).day

        switch numberOfCalendarDaysBetweenDates {
        case -Int.max ... -2:
            return "\(abs(numberOfCalendarDaysBetweenDates)) days ago"
        case -1:
            return "Yesterday"
        case 0:
            return "Today"
        case 1:
            return "Tomorrow"
        default:
            return "In \(numberOfCalendarDaysBetweenDates) days"
        }
    }
}

这里需要注意的是,NSCalendar的初始化非常耗时,过去在Objective-C时代常常使用dispatch_once构建单例传输,在这里通过结构体中的成员变量维护了一份,作用是同样的。

3. 干掉NSPredicate

对于NSPredicate,有些人可能还不熟悉,他就是类似于SQLite中的查询语句,只不过其应用范围是CoreData。咦,查询语句还能重构?

其实在本文中,对于NSPredicate的使用只有原先这一句 fetchRequest.predicate = NSPredicate(format: "dueDate <= %@", argumentArray: [NSCalendar.currentCalendar().dateByAddingUnit(.Day, value: 10, toDate: NSDate(), options: NSCalendarOptions())!])

这段代码从重复性上来说是不需要重构的。但是,我们可以看到,在这里的构造参数里面,我们还是进行了一定的业务逻辑转换。所以,和DateFormatter一样,我们也可以将这部分所谓为的”弱业务逻辑”代码进行剥离:

extension NSPredicate {
    convenience init(forTasksWithinNumberOfDays numberOfDays: Int, ofDate date: NSDate, calendar: NSCalendar = NSCalendar.currentCalendar()) {
        self.init(format: "dueDate <= %@", argumentArray: [calendar.dateByAddingUnit(.Day, value: numberOfDays, toDate: date, options: NSCalendarOptions())!])
    }
}

除了业务逻辑剥离之外,其实我们也可以看到,在这个NSPredicate的新构造参数,可以接受一个calendar,这对于测试用例编写的依赖注入是非常有好处的。

4. Core Data Stack

用过Core Data的人都知道,Core Data的使用非常麻烦,需要配置大量的选项,照着苹果源码写的经历相信大家都有过,那恶心的200-300行配置代码,真是么么哒了。

但是,这几百行代码又是无法省略的,那该怎么办呢?

一个比较好的解决方案就Core Data Stack 。意为将CoreData的初始化以及多个NSManagerObjectContext封装进CoreDataStack 维护。

在本文中,因为只是使用了一个主线程的NSManagerObjectContext,所以可能读者在阅读源码的时候可能觉得这个重构只是将CoreData配置从View剥离了。但是实际上,使用CoreDataStack可以做到更多,建议大家阅读Github上相关项目。

5. 干掉NSFetchedResultsControllerDelegate

NSFetchedResultsController大家可以简单理解为获取CoreData数据的一个中介层。根据传输进入的谓语NSPredicate进行查询,查询结束后通过相应的Delegate事件回调。

在作者的代码中,作者通过构建manager的方式剥离了NSFetchedResultsController的职责,将NSFetchedResultsController的初始化、回调封装进了UpcomingTaskDataManager.swift中。

不过值得注意的一点是,尽管作者封装的NSFetchedResultsControllerDelegate的回调,但是为了让调用者可以自定义处理事件,实际上作者还是需要暴露一些的Delegate,当然,新的回调相对来说进行了一定的简化,同时在数据回调时经过了业务转化。

protocol UpcomingTaskDataManagerDelegate {
    func dataManagerWillChangeContent(dataManager: UpcomingTaskDataManager)
    func dataManagerDidChangeContent(dataManager: UpcomingTaskDataManager)
    func dataManager(dataManager: UpcomingTaskDataManager, didInsertRowAtIndexPath indexPath: NSIndexPath)
    func dataManager(dataManager: UpcomingTaskDataManager, didDeleteRowAtIndexPath indexPath: NSIndexPath)
}

6. CoreDataModel <=> Model

这一步是将从Core Data中获取的NSManagedObject Model 转换成业务中使用的Model。为什么要这么做呢?原因有三个:

  • CoreData中的属性一更改,就会触发NSFetchedResultsController,这会很影响性能。
  • CoreData中的属性存在很多bug
  • NSManagedObject不是一个struct类型,很有可能误伤
import CoreData  
import Foundation

struct Task: Equatable {
    var id: String
    var title: String
    var dueDate: NSDate
}

func ==(lhs: Task, rhs: Task) -> Bool {
    return lhs.id == rhs.id && lhs.title == rhs.title && lhs.dueDate == rhs.dueDate
}

extension Task {
    init(managedTask: NSManagedObject) {
        self.id = managedTask.valueForKey("id") as! String
        self.title = managedTask.valueForKey("title") as! String
        self.dueDate = managedTask.valueForKey("dueDate") as! NSDate
    }
}

作者用以上的Task类型替换了CoreData中的ManagedObject,可以有效的避免以上问题。

7.封装数据结构

在这一步里,我将作者自定义TaskTableViewCell和构建AddCompletionSegue合并到了一块说。

这两步的重构,看似简单,但是其实也蕴含了一个思想:类型越确定,编程越容易,运行越安全

在原文的实现,一开始作者都是通过采用基础的数据结构UITableViewCell和UISegue。这样带来的坏处就是类型不明确导致的职责不明确。对于基础的数据结构,我们常常还要进行类型判断和转换,容易犯错。

8.干掉UITableViewDelegate和UITableViewDataSource

这一步想必大家都很熟悉了,微博上整天热传了用ViewModel重构你的ViewController经常提及的就是干掉UITableViewDelegate和UITableViewDataSource。

那说了那么多,我们来看看究竟如何干掉它。

毫无以为,我们首先要构建一个类型,来实现UITableViewDelegate和DataSource,如下所示:

// 1. 
class UpcomingTaskDataManagerTableViewAdapter<CellType: UITableViewCell>: NSObject, UITableViewDataSource, UpcomingTaskDataManagerDelegate {
    private let tableView: UITableView
    private let upcomingTaskDataManager: UpcomingTaskDataManager
    private let cellReuseIdentifier: String
    private let cellConfigurationHandler: (CellType, Task) -> ()
    private let didChangeHandler: () -> Void

// .2
    init(tableView: UITableView, upcomingTaskDataManager: UpcomingTaskDataManager, cellReuseIdentifier: String, cellConfigurationHandler: (CellType, Task) -> (), didChangeHandler: () -> Void) {
        self.tableView = tableView
        self.upcomingTaskDataManager = upcomingTaskDataManager
        self.cellReuseIdentifier = cellReuseIdentifier
        self.cellConfigurationHandler = cellConfigurationHandler
        self.didChangeHandler = didChangeHandler

        super.init()
    }

// 3.
    func tableView(tableView: UITableView, commitEditingStyle editingStyle: UITableViewCellEditingStyle, forRowAtIndexPath indexPath: NSIndexPath) {
        upcomingTaskDataManager.deleteTask(upcomingTaskDataManager.taskSections[indexPath.section].items[indexPath.row])
    }

    func numberOfSectionsInTableView(tableView: UITableView) -> Int {
        return upcomingTaskDataManager.taskSections.count
    }

    func tableView(tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
        return upcomingTaskDataManager.taskSections[section].title
    }

    func tableView(tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return upcomingTaskDataManager.taskSections[section].items.count
    }

    func tableView(tableView: UITableView, cellForRowAtIndexPath indexPath: NSIndexPath) -> UITableViewCell {
        let task = upcomingTaskDataManager.taskSections[indexPath.section].items[indexPath.row]
        let cell = tableView.dequeueReusableCellWithIdentifier(cellReuseIdentifier, forIndexPath: indexPath) as! CellType
        cellConfigurationHandler(cell, task)
        return cell
    }
  1. 这个UpcomingTaskDataManagerTableViewAdapter通过传入一个CellType支持泛型。
  2. 通过接受几个closure来进行自定义的配置,包括cell的样式配置以及tableview数据更新后的回调。
  3. 实现的UITableViewDataSource

同样,由于职责的重新分配,我们要将跟TaskManager(包括NSFetchedResultsController)相关的划入到这个adapter中。

大结局

最后重构后的ViewController,只有37代码,效果如下:

class ViewController: UITableViewController {
    var navigationThemeDidChangeHandler: ((NavigationTheme) -> Void)?
    var navigationTheme: NavigationTheme {
        return NavigationTheme(numberOfImminentTasks: upcomingTaskDataManager.totalNumberOfTasks)
    }

    private let upcomingTaskDataManager = UpcomingTaskDataManager()
    private var upcomingTaskDataManagerTableViewAdapter: UpcomingTaskDataManagerTableViewAdapter<TaskTableViewCell>!

    override func viewDidLoad() {
        super.viewDidLoad()

        upcomingTaskDataManagerTableViewAdapter = UpcomingTaskDataManagerTableViewAdapter(
            tableView: tableView,
            upcomingTaskDataManager: upcomingTaskDataManager,
            cellReuseIdentifier: "Cell",
            cellConfigurationHandler: { cell, task in
                cell.viewData = TaskTableViewCell.ViewData(task: task, relativeToDate: NSDate())
            },
            didChangeHandler: { [weak self] in self?.updateNavigationBar() }
        )
        upcomingTaskDataManager.delegate = upcomingTaskDataManagerTableViewAdapter
        tableView.dataSource = upcomingTaskDataManagerTableViewAdapter

        updateNavigationBar()
    }

    override func tableView(tableView: UITableView, canEditRowAtIndexPath indexPath: NSIndexPath) -> Bool {
        return true
    }

    func updateNavigationBar() {
        navigationThemeDidChangeHandler?(navigationTheme)
    }

    @IBAction func unwindFromAddController(segue: AddCompletionSegue) {
        upcomingTaskDataManager.createTaskWithTitle(segue.taskTitle, dueDate: segue.taskDueDate)
    }
}