注意系统库的坑之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)
    }
}

一步步学R(2)

系列连载

一步步学R(1)
一步步学R(2)

If-Else

R 种的If-Else结构并没有比较特殊的地方,仍然支持两种结构:

if (condition) {
    // Do Something
} else {
   // Do Otherthing
}

或者如下:

if (condition) {
    // Do Something
} else if (condition2) {
   // Do Otherthing
} else {
   // Do Else
}

但是在R中,对于If-else有一个可以简化的地方,如:

if (x > 100) {
    y <- 10
} else if (x < 100) {
   y <- 11
} else {
   y <- 5
}

可以简化成:

y <- if (x > 100) {
    10
} else if (x < 100) {
   11
} else {
   5
}

For

For语句的语法也非常简单:

for (i in 1:10) {
    print(i)
}

seq_along 函数是for循环中可以注意的一个点。它的参数是一个vector,如x <- c('a', 'b'),调用seq_along(x)会得到一个序列,长度为2,值为1,2。因此,print(x[1]) 就等于 a

While

同样简单:

while (condition) {
   // Do studyy
}

Repeat

repeat是R中特有的一种逻辑结构,简单来理解就是死循环,想要退出的唯一方式是显式使用break

repeat {
    x <- something()

    if (A) {
        break
    } else {
        x <- x + 1
    }
}

Next

next就是其他语言中的continue

for (i in 1:100) {
     if (i < 20) {
         next
     }

     // Do other
}

函数

R中的函数可以没有显式的return,默认返回最后一句语句。

add2 <- function(x, y) {
     x + y
}

和其他语言一样,你可以给参数设置默认值,如

add2 <- function(x, y = 10) {
     x + y
}

R中的函数参数优点类似于JavaScript,可以不用赋值完全,前提是你用不到。而且,对于R中的参数,你可以打乱参数传递,只要你前面加上了行参的名称,

比如

add2 <- function(x, y) {
     x + y
}

你可以通过add2(y = 5, x = 7)来进行调用。

可变参数

在R中也是有可变参数的,即...

myplot <- function(x, y, type = 1, ...) {
    plot(x, y, type, ...)
}

同样,...也可以用在泛型函数中,后续学习中我们会说到。

**不过,与其他编程语言所不同的是,R中的可变参数可以放在函数列表的前面,如

function(..., sep = " ", collsape = NULL)

调用如上的这种函数,必须显式的通过函数参数名称来调用后续参数,如

function("haha", "heihei", sep = ",")

变量作用域

使用变量

当你使用一个R语言中的变量时,比如x,你有没有想过x究竟是存在于哪里呢?

有些人会说,我定义的呀,比如x <- 5,那么对于那些默认函数,比如vector()呢?

所以,这就涉及到R中的Symbol binding(其他语言的变量查找)了。

在R中,查找顺序是这样的。

[1] ".GlobalEnv"        "tools:rstudio"     "package:stats"     "package:graphics"  "package:grDevices"
[6] "package:utils"     "package:datasets"  "package:methods"   "Autoloads"         "package:base" 

默认查找的是.GlobalEnv,依次类推。

如果你还通过library()函数加载了其他package,如ggplot2,那么查找顺序是

 [1] ".GlobalEnv"        "package:ggplot2"   "tools:rstudio"     "package:stats"     "package:graphics" 
 [6] "package:grDevices" "package:utils"     "package:datasets"  "package:methods"   "Autoloads"        
[11] "package:base"     

也就是用户加载的package会自动加到除了.GlobalEnv之外的任意搜索顺序前。

如果要查看最新的搜索顺序,可以通过search()

作用域

R中的作用域,是Lexical Scoping,也就是静态作用域,也就是JavaScript的作用域

好了,我不多说了,如果想学习更多冠以静态作用域的话,看我的JavaScript博客部分。

当然,如果你不懂,你可以通过如下函数帮助你理解。

ls(environment(functionName))
get(variableName, environment(functionName))

一言以蔽之,lexcial scoping可以简单理解为你函数中需要的变量,是通过其定义时环境进行查找。

Data and Times

R中的时间表示,采用了一种特殊的数据结构。

Date是通过Date这一数据结构表示,而Time是通过POSIXct或者POSIXlt表示。

  • Date是不包含Time的,只显示年、月、日。
  • Date的内部储存是计算1970-01-01到当前时间之间的天数。
  • Time的内部储存是计算1979-01-01到当前时间之间的秒数。

可以采用as.Date构建Date,如as.Date("1970-01-01")

而Time相对来说比较复杂,我们首先来看看Posixlt的表现形式。

我们输入p<- Sys.time()获取当前时间,结果是"2015-12-27 00:59:18 CST",然后我们调用unclass(p)来看看其构成,结果如下:

[1] "sec"    "min"    "hour"   "mday"   "mon"    "year"   "wday"   "yday"   "isdst"  "zone"   "gmtoff"

这表明,通过Posixlt表征的Time,其内部是由一系列成分组成的集合。我们可以通过
p$wday 来查看今天是周几。

Posixct就是就算1970-01-01到当前时间的描述,是个非常大的Integer

你可以对Date或者Time进行大小比较操作,但是注意,不能讲Date和Time混合操作