如何动态创建 block – JPBlock 扩展原理详解

2017-1-23

简介

JSPatch 最近新增了扩展 JPBlock,解决之前 block 使用上的一些限制:

  1. block 参数个数最多支持6个。
  2. block 参数类型不能是 double / struct / union 类型。
  3. 不支持 JS 封装的 block 传到 OC 再传回 JS 去调用。

接入 JPBlock 后,就可以:

  1. block 参数支持任意多个。
  2. block 参数类型可以是除 struct / union 以外的任意类型,包括 double。
  3. 支持 JS 封装的 block 传到 OC 再传回 JS 去调用。

这篇文章说说这里的实现原理。

原实现

先看看要让 JSPatch 支持 block,我们需要做什么,我们在 JS 用 Function 表示 OC 上的 block,这里涉及两个语言两种类型的转换问题,JS Function 和 OC Block 的互相转换:

  1. Block -> JSFunction,从 OC 传 block 给 JS 可以当成 JS Function 调用。
  2. JSFunction -> Block,从 JS 传 Function 给 OC 可以当成 block 调用。

第一点 JavaScriptCore 已经做了处理,通过 JavaScriptCore 的接口把一个 block 返回给 JS,JavaScriptCore 会自动把它转为一个 JS Function,不过这里有个坑,待会再说。

对于第二点 JavaScriptCore 是没有处理的,一个 JS Function 通过 JavaScriptCore 传入 OC 后仍是一个 JSValue 类型,需要我们自己做处理。具体使用场景:

@implementation JPDemoClass
+ (void)callBlock:(void(^)(NSString *str))blk {
   blk(@"string from oc");
}
@end
require('JPDemoClass').callBlock(block("NSString *", function(str){
  console.log(str);
});

在 JS 用一个 Function 传进去给 OC,OC 经过 JSPatch 引擎处理后,可以当 block 调用它。这里 JSPatch 做了什么处理呢?JSPatch 创建了一个 block 作为转接:

static id genCallbackBlock(JSValue *jsVal) {
  id cb = ^id(void *p0, void *p1, void *p2, void *p3, void *p4, void *p5) {
    //强制转换参数
    //调用 jsVal 里的 JS 函数  
  };
  return cb;
}

上述例子中 blk(@”string from oc”) 调用的是这里 JSPatch 动态创建的一个 block,这个 block 里保存着 JS 传进来的 Function,提取参数去调用 JS Function,再返回 JS Function 执行的结果,整个转接就完成了。

这里一个比较麻烦的问题是,怎样动态创建不同参数类型的 block?在原来的实现中,JSPatch 使用了一种比较取巧简单的方式,固定创建一个返回类型是id,有六个参数并且类型都是 void * 的 block 去表示所有 block。

void* 是无类型指针,可以表示任何数据类型,NSObject 本来就是一个指针,void * 可以强制转换成 NSObject 类型,也可以强制转为 int / BOOL 等类型,另外你强制把一个参数个数多的 block 当成参数个数少的 block 去调用也是没问题的,因为它们内存结构是一样的,只要在 block 里不去取没有传的参数就没事。

于是这里一个 block 就可以表示所有返回值类型是 id,参数个数是 0-6 个的 block。

问题

到这里也明白为什么原来的实现方式会有上述第1/2点的限制,第一点因为这里只声明了6个参数,参数个数再多的就处理不了了,当然这里也可以继续往上加到十几个。第二点因为 void* 无法表示 double 类型,无法强制转换,struct / union 类型也不行。

那第三个问题(不支持 JS 封装的 block 传到 OC 再传回 JS 去调用)是为什么呢?首先这里 JS 封装的 block 传到 OC 后就被包装成上述 6 个 void* 参数的 block 了,这个 block 再返回给 JS,JavaScriptCore 并没有自动把它转成 JS Function,这也是我们刚才说到的一个坑,为什么呢,幸好 JavaScriptCore 是有源码可以看的,并不是一个黑箱,我们可以看到确切的原因,JavaScriptCore 对 block 的转换,可以从 objCCallbackFunctionForBlock 这个函数看起,追溯到最后,可以在 parseObjCType() 这里发现如果 block 参数里有指针/Class/union 等类型,是不会自动转换的。我们这里生成的 block 参数类型全是 void * 指针,自然不会被转换。

block 是什么

终于说完了原来的实现方式以及三条限制的原因,接下来就是怎样改进解决,很简单,只要可以动态创建各种不同参数类型的 block,就可以一举解决上述三个问题。so 怎样创建呢,首先需要了解下 block 究竟是什么,网上已经有很多 block 原理解析的文章,可以看看,就不详细说明了,这里简单说下:

  1. block = 函数 + 数据。block 是持有着一些数据的函数。
  2. block 的定义和调用在编译后都会生成相应的结构体和函数指针,结构体保存着数据。

block 的复杂在于对变量的持有处理,如果抛开这部分处理,block 的结构体是很简单的,可以直接通过结构体创建一个 block:

struct JPSimulateBlock {
    void *isa;
    int flags;
    int reserved;
    void *invoke;
    struct JPSimulateBlockDescriptor *descriptor;
};

struct JPSimulateBlockDescriptor {
    unsigned long int reserved;
    unsigned long int size;
};

void blockImp(){
    NSLog(@"call block succ");
}

void genBlock() {
    struct JPSimulateBlockDescriptor descriptor = {0, sizeof(struct JPSimulateBlock)};

    struct JPSimulateBlock2 simulateBlock = {
        &_NSConcreteStackBlock,
        0, 0, blockImp, &descriptor
    };

    void *blockPtr = &simulateBlock;
    void (^blk)() = ((__bridge id)blockPtr);
    blk();  //output "call block succ"
}

一个存有函数指针的特定结构体就是一个 block,调用这个 block 就是调用里面函数指针指向的函数。block 的参数类型和个数是跟这个结构体没多大关系的,无论 block 的参数类型和个数是怎样,都可以用这个结构体表示这个 block,不同的就是函数指针需要指向对应参数类型的函数。

所以如果我们要动态创建任意参数类型的 block,问题就变成了如何创建支持任意参数类型的 C 函数,怎样创建呢,两种方法:

  1. 对所有参数类型和个数进行排列组合,静态声明N个函数,在运行时根据参数类型个数分配对应的函数。
  2. 根据参数类型和个数动态定义对应的函数。

显然第二种才是正道,问题就变成了如何动态定义一个函数。

动态定义函数

如果你看过《如何动态调用 C 函数》,对这问题可能会有种熟悉的感觉,这正是 libffi 擅长做的事情,原理跟动态调用 C 函数是一样的,只是这里把调用变成定义,这里就不再复述原理了。libffi 同样支持在运行时动态定义 C 函数,在调用时 libffi 会模拟函数参数的入栈出栈去完成调用。来看看是怎样使用的:

svoid JPBlockInterpreter(ffi_cif *cif, void *ret, void **args, void *userdata)
{
    //①
    //函数实体
    //通过 userdata / args 提取参数
    //返回值赋给 ret
}

void main() {
    //②
    ffi_type *returnType = &ffi_type_void;
    NSUInteger argumentCount = 2;
    ffi_type **_args = malloc(sizeof(ffi_type *)*argumentCount) ;
    _args[0] = &ffi_type_sint;
    _args[1] = &ffi_type_pointer;
    ffi_cif *_cifPtr = malloc(sizeof(ffi_cif));
    ffi_prep_cif(_cifPtr, FFI_DEFAULT_ABI, (unsigned int)argumentCount, returnType, _args);

    //③
    void *blockImp = NULL;

    //④
    ffi_closure *_closure = ffi_closure_alloc(sizeof(ffi_closure), (void **)&blockImp);
    ffi_prep_closure_loc(_closure, _cifPtr, JPBlockInterpreter, (__bridge void *)self, blockImp);

}
  1. 准备一个函数实体 JPBlockInterpreter
  2. 根据函数参数个数/参数类型/返回值类型组装成 cif 对象,表示这个函数原型。
  3. 准备一个函数指针 blockImp ,用于调用。
  4. 通过 ffi_closure 把 函数原型_cifPtr / 函数实体JPBlockInterpreter / 上下文对象self / 函数指针blockImp 关联起来。

上述例子中,这一系列处理后 blockImp 就可以当成一个指向返回值类型是void,参数类型是 (int a, NSString *b) 的函数去调用,调用后会去到 JPBlockInterpreter 这个函数实体,在这个函数里面可以通过 args 提取传进来的参数,通过userdata 取上下文进行处理。这里可以根据参数类型的不同动态创建不同的 cif 对象,生成不同的 blockImp 函数指针。

到这里问题都解决了,我们可以用结构体动态创建 block,又可以通过上述流程用 libffi 提供的接口动态定义任意参数类型函数的,也就可以动态创建任意类型的 block 了,剩下的就是手尾和细节,定义接口,接入 JSPatch 流程等工作了。

signature

本来到这里就结束了,但有个签名的问题还是说一下。在实现过程发现,按上述方式创建的 block 传出去给 JS 时,JavaScriptCore 并不会自动把它转为 JS Function,为什么呢,还是得到 JavaScriptCore 源码去找原因,可以在 objCCallbackFunctionForBlock() 这个方法看到:

if (!_Block_has_signature(target))
        return 0;

如果传进来的 block 没有 signature,这里就会不会去走转换的逻辑,那怎样能让 block 有 signature 呢?我们再去 runtime 源码 看看 _Block_has_signature() 的实现,可以得到答案。我们刚才定义的 block 结构体是这样:

struct JPSimulateBlock {
    void *isa;
    int flags;
    int reserved;
    void *invoke;
    struct JPSimulateBlockDescriptor *descriptor;
};

struct JPSimulateBlockDescriptor {
    unsigned long int reserved;
    unsigned long int size;
};

其中 descriptor 结构体只有 reservedsize 两个数据,实际上 descriptor 会根据需要去追加数据,runtime 里定义的 descriptor 结构体有三组:

struct JPSimulateBlockDescriptor {
    //Block_descriptor_1
    struct {
        unsigned long int reserved;
        unsigned long int size;
    };

    //Block_descriptor_2
    struct {
        // requires BLOCK_HAS_COPY_DISPOSE
        void (*copy)(void *dst, const void *src);
        void (*dispose)(const void *);
    };

    //Block_descriptor_3
    struct {
        // requires BLOCK_HAS_SIGNATURE
        const char *signature;
        const char *layout;
    };
};

block 的 flags 有两个位 BLOCK_HAS_COPY_DISPOSE(1 << 25) / BLOCK_HAS_SIGNATURE(1 << 30) 分别表示这个 block 的 descriptor 有没有 Block_descriptor_2Block_descriptor_3 这两组数据。block 的签名就保存在 Block_descriptor_3 结构体里。所以如果我们要让 block 有 signature 签名,就需要:

  1. block 的 flags 需要有 BLOCK_HAS_SIGNATURE 标记,表示这个 block 有 signature 数据。
  2. 把 signature 数据保存到 descriptor 指向的结构体 JPSimulateBlockDescriptor 里。

signature 数据就是表示这个block 返回类型/参数类型的数据,类似这样的:”i8@?@8″。于是只要按它的规则照做就可以了,可以看到 JPBlock 里最终生成 descriptor 和 block 是这样写的:

    struct JPSimulateBlockDescriptor descriptor = {
    0,
    sizeof(struct JPSimulateBlock),
    [self.signature.types cStringUsingEncoding:NSUTF8StringEncoding],    //signature数据
    NULL
};
....
struct JPSimulateBlock simulateBlock = {
    &_NSConcreteStackBlock,
    BLOCK_HAS_SIGNATURE,
    0,
    blockImp,
    &descriptor
};
....

有了 signature,JavaScriptCore 也可以自动把这里创建的 block 转为 JS Function了,到这里问题就都解决了,其他细节可以看看 JPBlock 源码

后续

有 JPBlock 扩展后,JSPatch 对 block 的限制少了,但还没达到完备的状态,目前不支持的还有:

  1. block 参数类型不支持 struct。
  2. block 参数类型是 指针/Class/struct/union 的话,从 JS 传入 OC 再传入 JS 会无法调用。

第一个问题只是还未处理 struct 类型,C 函数调用也一样,struct 的支持会有点麻烦,后续再找时间实现。第二个问题是我们用 JavaScriptCore 的接口去做 block -> JSFunction 的转换,JavaScriptCore 就是不支持有这几个参数类型的 block 的转换,如果要做就需要抛弃 JavaScriptCore 的自动转换,由 JSPatch 自己做转换,后续再看情况支持。

分类:技术文章
评论

*

*

2017年1月23日 22:50

感觉这种单纯的传block到OC再回传给js的场景挺少吧,在oc里面就是接收一个block,做一些判断然后return这个block,这种实现方式感觉并不多,不够为对细节的孜孜以求点赞

2017年1月24日 11:39

你好. Bang哥. 用JSPatch 全部集成项目,可行吗?

2017年2月9日 20:59

以后会常来逛逛,博客很棒。

2017年2月14日 10:53

我又来咯~~无聊来逛逛,情人节快乐。

2017年2月20日 13:44

你好,使用JSPatch已经1年半以上了,之前Apple从来没有针对这个给单独发过安全提醒,在年前上线的一个版本,收到了一个安全提醒,下面我会贴上内容,想问问bang这个要怎么解决,会不会影响下个版本上线?
Dear Developer,

Your app, extension, and/or linked framework appears to contain code designed explicitly with the capability to change your app’s behavior or functionality after App Review approval, which is not in compliance with section 3.3.2 of the Apple Developer Program License Agreement and App Store Review Guideline 2.5.2. This code, combined with a remote resource, can facilitate significant changes to your app’s behavior compared to when it was initially reviewed for the App Store. While you may not be using this functionality currently, it has the potential to load private frameworks, private methods, and enable future feature changes.

This includes any code which passes arbitrary parameters to dynamic methods such as dlopen(), dlsym(), respondsToSelector:, performSelector:, method_exchangeImplementations(), and running remote scripts in order to change app behavior or call SPI, based on the contents of the downloaded script. Even if the remote resource is not intentionally malicious, it could easily be hijacked via a Man In The Middle (MiTM) attack, which can pose a serious security vulnerability to users of your app.

Please perform an in-depth review of your app and remove any code, frameworks, or SDKs that fall in line with the functionality described above before submitting the next update for your app for review.

Best regards,

App Store Review

2017年2月20日 14:56

改成用SDK的吧,应该是这个问题

2017年3月6日 17:29

@请叫我小权权 目前小部分用户会收到,影响不大,收到的话下个版本先下线试试,或者看看自己是否有把 JSPatch 用于热修复以外的事情。

2017年3月8日 8:47

@bang Your app, extension, and/or linked framework appears to contain code designed explicitly with the capability to change your app’s behavior or functionality after App Review approval, which is not in compliance with section 3.3.2 of the Apple Developer Program License Agreement and App Store Review Guideline 2.5.2. This code, combined with a remote resource, can facilitate significant changes to your app’s behavior compared to when it was initially reviewed for the App Store. While you may not be using this functionality currently, it has the potential to load private frameworks, private methods, and enable future feature changes.

This includes any code which passes arbitrary parameters to dynamic methods such as dlopen(), dlsym(), respondsToSelector:, performSelector:, method_exchangeImplementations(), and running remote scripts in order to change app behavior or call SPI, based on the contents of the downloaded script. Even if the remote resource is not intentionally malicious, it could easily be hijacked via a Man In The Middle (MiTM) attack, which can pose a serious security vulnerability to users of your app.

Please perform an in-depth review of your app and remove any code, frameworks, or SDKs that fall in line with the functionality described above before submitting the next update for your app for review.

Best regards,

App Store Review

我也收到了! 只有重新提交一个下线的版本吗

2017年3月8日 9:52

用了热修复,也被警告了,如果不提交下个版本,这个版本会被下架吗?

2017年3月8日 10:09

@Terry 我也在问这个问题

2017年3月8日 10:22

哈哈,不知道bang神了解不@胡迪

2017年3月8日 10:40

@Terry 以苹果的严厉程度,我认为会被强制下线