逆向 Mac 应用 Bartender

前言

本文内容仅作为学习交流,希望大家多多支持正版软件。

Emmmmm… 其实最初是准备写一篇关于 iOS 应用的逆向笔记的,不过一直没找到合适的目标 App 以及难度适宜的功能点来作为写作素材…

破解了 Bartender 之后我觉得对于 Bartender 的破解过程难度适中,非常适合当做素材来写,且不论是 Mac App 还是 iOS App,逆向的思路都是相通的,所以就写了这篇文章~

国庆之前,果果放出了最新操作系统 macOS Mojave 的正式版本,相信很多小伙伴都跟我一样在正式版发布后紧跟着就升级了系统(此前由于工作设备参与项目产出需要确保系统稳定性所以没敢尝鲜的同学应该不只我一个人哈)。

升级到正式版 macOS Mojave 之后,我兴致勃勃的在新系统中各处探索了一番,然后将系统切换到 Dark Mode 后打开 Xcode 心满意足地敲(搬)起了代码(砖)…

嘛~ 又是一个惬意的午后,有时候人就是这么容易满足(笑)~

等等!这是什么鬼!?我的 Bartender 怎么不能正常工作了(其实现在回想起来应该是试用期到期了)…

本文将以 Bartender 为目标 App,讲解如何通过静态分析工具 Hopper 逐步分析 Bartender 的内部实现逻辑并结合动态分析等手段逐步破解 Bartender 的过程与思路~

索引

  • Bartender
  • Hopper
  • 逆向过程 & 思路
  • 总结

Bartender

Bartender 是一款可以帮助我们整理屏幕顶部菜单栏图标的工具。

随着我们安装的 App 不断增多,屏幕顶部菜单栏上面的图标也会对应不断增加。这些 App 的图标并非出自一家之手,风格各异,随着数目增多逐渐显得杂乱不堪。

我们可以通过 Bartender 来隐藏重新排列这些恼人的小图标,可以将没什么用但是运行起来却要显示的 App 图标始终隐藏,将偶尔会用的 App 图标隐藏到 Bartender 功能按钮后面(用到的时候可以通过点击 Bartender 功能按钮切换显隐),只显示常用的或者我们认为好看的应用图标。

除此之外 Bartender 还具备一些其他更加深入的功能(比如支持全部菜单栏条目范围的搜索等等),毫无疑问它是一款非常棒的菜单栏图标管理工具。

Note: 重申,Bartender 仅售 15 刀,还是推荐各位使用正版,本文仅作为学习交流。

Hopper

Hopper 是一款不错的 mac OS 与 Linux 反汇编工具,同时还提供一定的反编译能力,可以利用它来调试我们的程序。此外,Hopper 还支持控制流视图模式,Python 脚本,LLDB & GDB,并且提供了 Hopper SDK 可供扩展,在 Hopper SDK 的基础上你甚至可以扩展自己的文件格式和 CPU 支持。

值得一提的是 Hopper 的作者是一名独立开发者,他的日常工作环境也是在 mac OS 上,所以在 mac OS 上的 Hopper 是完全使用 Cocoa Framework 实现的,而 Linux 版本的 Hopper 则选择使用 Qt 5 来实现。

个人认为 Hopper 在 mac OS 上面的运行表现非常好,很多细节(比如类型颜色区分等)都做的不错,功能简洁的同时快捷键也很好记(Hopper 提供的功能已经覆盖了绝大多数使用场景)。

最关键的一点是收费良心,个人证书只要 99 刀,当之无愧的人人都买得起的逆向工具!当然如果你觉得贵,Hopper 还提供试用,试用形式类似于 Charles,每次开启后可以试用 30 分钟,一般情况下这已经够用了。

Note: Hopper v4.4.0 支持 Mojave Dark Mode。

逆向过程 & 思路

这一章节的内容会详细的讲述我个人在破解 Bartender 过程中的想法以及中间遇到问题时解决问题的思路,之前没有涉足逆向或者逆向经验尚浅的同学可能会觉得比较晦涩,这种情况最好结合自己的实际操作反复阅读没有理解的地方直到真正弄明白为止。

相信自己,每一份努力终会有所回报!当有朝一日自己也可以通过自己的逆向技术破解 & 定制化自己感兴趣的 App 时,你会发现一切的努力都是值得的。

获取目标二进制

Bartender 官网下载最新的 Bartender,截止本文提笔之前 Bartender 的最新版本为 3.0.47。

将下载好的压缩包解压之后得到 Bartender 3.app,将 Bartender 3.app 文件复制到自己的 Application 文件夹下。右键点击 Bartender 3.app 选择“显示包内容”,在 Contents 目录下找到 MacOS 目录,里面有我们要的目标二进制文件 Bartender 3。

从“授权”着手

打开 Hopper,将目标二进制文件拖入 Hopper,在弹出的弹窗中选择 OK 后等待 Hopper 分析完毕。

在左侧的分栏中选择 Proc. ,这可以让我们查看 Hopper 分析出来的方法。分栏下面有搜索框,内部可以通过输入关键词来过滤出我们想要的结果。因为一般的 App 都是通过某些方法判断是否授权的,这里我们先输入 is (注意 is 前面加空格),然后观察过滤出来的结果。

果不其然,发现里面有三个 [xxx isLicensed] 方法,点击方法 Hopper 会跳转至方法处。

Note: 三处 [xxx isLicensed] 的方法内部逻辑几乎一样,这里拿 [Bartender_3.AppDelegate isLicensed] 讲解,其他两处不做赘述。

Emmmmm… 这里的汇编代码还是比较简单的,虽然我不是很了解 x86 的汇编指令,不过 Hopper 已经帮助我们做了一些辅助性工作。其中开始处的 push rbp 以及结束处 pop rbp 可以简单理解为入栈出栈,call sub_100067830 可以理解为调用地址 0x100067830 处的方法,pop 之前的 movsx eax, al 和 ARM64 中的 mov 指令类似,可以理解为将 al 内存储的东西移动到 eax 寄存器中,eax 寄存器用于存储 x86 的方法返回值

我们可以看出这里调用了地址 0x100067830 处的函数,拿到结果之后又调用了 imp___stubs__$S10ObjectiveC22_convertBoolToObjCBoolyAA0eF0VSbF 方法将结果做了转化,最后将结果赋值给 eax 寄存器用于结果返回。其中 imp___stubs__$S10ObjectiveC22_convertBoolToObjCBoolyAA0eF0VSbF 我们可以根据名称推测出该方法的作用应该是将 Bool 转化为 Objective-C 的 BOOL 而已。

那么关键信息应该在 sub_100067830 处,双击 sub_100067830 Hopper 会跳转到 0x100067830 处,这样我们就可以分析其中的具体实现了。不过 0x100067830 内部的实现比较复杂,跳转过去之后发现汇编代码非常多,还有很多跳转… 这时候我们可以通过 Hopper 顶部中间靠右一点的分栏,点击显示为 if(b) f(x); 的按钮查看伪代码。

Hopper 解析出来的伪代码风格类似 Objective-C 代码,可以看到 0x100067830 内部通过 NSUserDefaults 以及其他的逻辑实现,其中还包括其他的形式为 sub_xxxxxx 的方法调用,这种情况下如果我们继续跳转到这些方法的地址查看其内部实现很有可能陷入递归中…

那么这种情况该如何处理呢?

分析问题,我们找到 [xxx isLicensed] 并且觉得这有可能就是 Bartender 中判断授权与否的函数,那么我们只需要将三处 [xxx isLicensed] 的返回值改为 true 即可。所以这里我们没有必要一步步的看其内部实现,先返回 [Bartender_3.AppDelegate isLicensed] 处。前面讲过在 x86 汇编中 eax 寄存器用于存储方法的返回值,我们在 [Bartender_3.AppDelegate isLicensed] 按快捷键 option + A 插入汇编代码 mov eax, 0x1eax 永远赋值为 1true 之后跟 ret 即 return 指令直接让函数返回 true 就可以达到我们的目的了。

用快捷键 shift + command + E 导出二进制文件,覆盖到原 Bartender 目录中,尝试运行。你会发现一开始是成功的,屏幕顶部的菜单栏图标也被正常管理了,但是过了大约 10s 之后一切又变回了原样,并且还会弹出一个试用期到期的弹窗…

重拾思路

那么我们刚才修改的三处 [xxx isLicensed] 为什么没有产生作用呢?其实它已经产生作用了,虽然我们不可以正常使用 Bartender,但是打开 Bartender 的 License 界面我们可以发现这里的界面已经显示我们付过款了,尽管这并没有什么卵用就是了…

到这里我们似乎没有什么头绪了,因为延时方法有很多,光是凭借这一条线索很难定位到阻止我们破解的目标代码位置。

逆向过程中的思路很重要,如果遇到思路断了的情况不要着急也不要气馁,我们可以重新运行程序,尝试不同的操作并观察操作对应的表现 & 结果。

经过反复运行程序,我发现每次重新启动 Bartender 都可以有大约 10s 的可用时间,如果启动之后直接主动点击 Bartender 的功能按钮则会直接弹出试用期到期弹窗且顶部菜单栏图标也会直接回到之前杂乱的样子。

这时候我的思路从延时方法转移到了这个 Trial ended 弹窗以及 Bartender 的功能按钮点击之后的对应方法上。这就是动态分析,它可以帮助我们重新找回思路。

按钮响应方法

有了思路,对应的方法并不难找。我们可以利用 Hopper 的 Tag Scope 先把可能出现的区域找出来,再到对应的区域下的方法列表中寻找我们的目标方法位置。

这里我很快就找到了目标函数 -[_TtC11Bartender_311AppDelegate bartenderStatusItemClickWithSender:], 其内部调用了 sub_100029ac0(arg2); 其中 arg2 就是 sender,也就是这个 Bartender 的功能按钮了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
int sub_100029ac0(int arg0) {
sub_100022840(arg0);
rbx = **_NSApp;
if (rbx == 0x0) goto loc_100029f44;

loc_100029ae7:
[rbx retain];
r14 = [[rbx currentEvent] retain];
rdi = rbx;
if (r14 == 0x0) goto loc_100029bef;

loc_100029b18:
[rdi release];
if (([r14 modifierFlags] & 0x80000) != 0x0) goto loc_100029b6e;

loc_100029b33:
[r14 retain];
if ((([r14 modifierFlags] & 0x40000) != 0x0) || ([r14 type] == 0x4)) goto loc_100029b66;

loc_100029bcc:
rbx = [r14 type];
[r14 release];
if (rbx == 0x3) goto loc_100029b6e;

loc_100029bec:
rdi = r14;
goto loc_100029bef;

loc_100029bef:
[rdi release];
r14 = [[swift_getInitializedObjCClass(@class(NSUserDefaults)) standardUserDefaults] retain];
if (*qword_1000e7e70 != 0xffffffffffffffff) {
swift_once(qword_1000e7e70, sub_100069790);
}
rbx = *qword_1000ee1f8;
r15 = *qword_1000ee200;
swift_bridgeObjectRetain(rbx);
r15 = (extension in Foundation):Swift.String._bridgeToObjectiveC() -> __ObjC.NSString(rbx, r15);
swift_bridgeObjectRelease(rbx);
rbx = [[r14 objectForKey:r15] retain];
[r15 release];
[r14 release];
if (rbx != 0x0) {
swift_getObjectType(rbx);
var_50 = rbx;
}
else {
intrinsic_movaps(var_40, 0x0);
var_50 = intrinsic_movaps(var_50, 0x0);
}
rax = sub_10001c9a0(&var_50, &var_78);
if (var_58 != 0x1) goto loc_100029cd8;

loc_100029ccd:
sub_10001c2f0(&var_78);
goto loc_100029d44;

loc_100029d44:
if (*(int8_t *)(r13 + *objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEnded) == 0x1) {
rax = sub_1000230e0(0x1);
}
else {
*(int8_t *)(r13 + *objc_ivar_offset__TtC11Bartender_311AppDelegate_performDelayedClicks) = 0x1;
rax = sub_1000215f0();
if ((rax & 0x1) == 0x0) {
rbx = *objc_ivar_offset__TtC11Bartender_311AppDelegate_performDelayedClicks;
rax = *(int8_t *)(r13 + rbx);
rax = !rax & 0x1;
*(int8_t *)(r13 + rbx) = rax;
}
}
return rax;

loc_100029cd8:
rcx = *qword_1000e8a98;
if (rcx == 0x0) {
rcx = swift_getObjCClassMetadata(swift_getInitializedObjCClass(@class(NSDictionary)));
*qword_1000e8a98 = rcx;
}
rax = swift_dynamicCast(&var_28, &var_78, *type metadata for Any + 0x8);
if (rax == 0x0) goto loc_100029d44;

loc_100029d24:
r14 = var_28;
if ([r14 count] == 0x0) goto loc_100029d8f;

loc_100029d3c:
[r14 release];
goto loc_100029d44;

loc_100029d8f:
r15 = [objc_allocWithZone(@class(NSAlert)) init];
rbx = sub_1000a7f20("No menu items have been setup", 0x1d, 0x1, rcx, 0x6);
r12 = (extension in Foundation):Swift.String._bridgeToObjectiveC() -> __ObjC.NSString(rbx, 0x1);
swift_bridgeObjectRelease(rbx);
[r15 setMessageText:r12];
[r12 release];
rbx = sub_1000a7f20("No menu items have been setup in Bartender Preferences, so Bartender is not doing anything yet. Would you like to open preferences now.", 0x87, 0x1, rcx, 0x6);
r12 = (extension in Foundation):Swift.String._bridgeToObjectiveC() -> __ObjC.NSString(rbx, 0x1);
swift_bridgeObjectRelease(rbx);
[r15 setInformativeText:r12];
[r12 release];
[r15 setAlertStyle:0x1];
rbx = sub_1000a7f20("Open Preferences", 0x10, 0x1, rcx, 0x6);
r12 = (extension in Foundation):Swift.String._bridgeToObjectiveC() -> __ObjC.NSString(rbx, 0x1);
swift_bridgeObjectRelease(rbx);
rbx = [[r15 addButtonWithTitle:r12] retain];
[r12 release];
[rbx release];
rbx = sub_1000a7f20("Dismiss", 0x7, 0x1, rcx, 0x6);
r12 = (extension in Foundation):Swift.String._bridgeToObjectiveC() -> __ObjC.NSString(rbx, 0x1);
swift_bridgeObjectRelease(rbx);
rbx = [[r15 addButtonWithTitle:r12] retain];
[r12 release];
[rbx release];
if ([r15 runModal] == 0x3e8) {
sub_100029a10();
}
[r15 release];
rax = [r14 release];
return rax;

loc_100029b6e:
*(int8_t *)(r13 + *objc_ivar_offset__TtC11Bartender_311AppDelegate_performDelayedClicks) = 0x0;
rdi = r14;
if (([rdi modifierFlags] & 0x40000) == 0x0) {
sub_100020de0();
}
else {
if (*(int8_t *)(r13 + *objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEnded) == 0x1) {
sub_1000230e0(0x1);
}
else {
sub_100020fe0(rdi);
}
}
rax = [r14 release];
return rax;

loc_100029b66:
[r14 release];
goto loc_100029b6e;

loc_100029f44:
asm { ud2 };
rax = sub_100029f46();
return rax;
}

PS: 为了便于读者结合后面分析部分的内容快速定位(Command + F),上面的伪代码没有使用截图形式展示。

其中很醒目的是 objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEnded 我们按照之前的方法,将伪代码先切回汇编模式,找到对应的汇编代码处。

这是一段明显的 if 语句汇编代码,看下面的 mov edi, 0x1 这一小节就是指 objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEndedtrue 之后执行的代码,表示要是试用期到期就执行 0x1000230e0 处的方法。我们记下这个地址之后把这两处的汇编代码通过上文插入汇编代码的方式修改一下,将这个 objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEnded 直接替换为 0x0false

在逆向工程中,切忌不可以冒进,时值今日几乎所有应用都会采取措施来增加其逆向难度。这时候千万不要想着一步到位,应该在适量修改之后尝试导出二进制,用动态分析的方法验证一下结果。因为我们这时候不是正向开发者,在没有见到上下文的情况下修改代码很可能会把程序改成一个不可用的状态(比如正常功能损坏或者频繁 Crash),所以最好步步为营。

这里我们导出修改之后的二进制文件,按照 Bartender 的原路径覆盖之前的二进制文件验证一下结果。我在这个阶段运行时发现如果正常开启 Bartender 还是会有一个 10s 左右的可用时长,之后依然会弹出试用期到期弹窗,并且程序变为不可用状态;而如果重启 Bartender 在试用期弹窗弹出之前点击功能按钮则可以正常切换,但是再次点击按钮却切换不回来了,并且程序运行 10s 左右仍会弹出试用期到期弹窗,但是菜单栏上面的图标不会变失效,只是切不回去而已。

功能破解

到目前为止如果不在乎功能仅仅想要隐藏菜单栏的图标已经是可以凑合用了,但是这显然不是我们想要的最终结果。

通过上面运行程序后观察到的情况我推测在 -[_TtC11Bartender_311AppDelegate bartenderStatusItemClickWithSender:] 内部切换回来的逻辑中仍然有地方对是否到期做了判断,我们上面只是成功修改了切换过去的逻辑,那么切换回来的逻辑在哪呢?

按逻辑推测,正向切换的时候是使用 objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEnded 做判断,反向切换应该同理才对,我们去追踪 objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEnded 的使用,最终发现 sub_10001f870 中使用了 objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEndedsub_10001f870sub_100029a10 调用,sub_100029a10 又被 sub_100029ac0 调用,sub_100029ac0 就是上文在 -[_TtC11Bartender_311AppDelegate bartenderStatusItemClickWithSender:] 中被调用的函数,这不仅满足了被 Bartender 功能按钮所引用的条件,同时还对 objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEnded 有所引用,所以我用插入汇编的方式将 sub_10001f870 中关于 objc_ivar_offset__TtC11Bartender_311AppDelegate_trialEnded 的使用改为了 0x0,即 false

嘛~ 导出二进制覆盖,发现这次的 Bartender 已经可以正常使用功能了,不过试用期到期的弹窗问题依然存在,尽管它并不影响使用,但我还是无法接受这样一个半成品的状态。

完美破解

还记得上文中得出的 0x1000230e0 吗,如果试用期到期则会执行 0x1000230e0 地址处的方法,我们通过快捷键 G 跳转到 0x1000230e0 地址,看一下里面的实现逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
void sub_1000230e0(int arg0) {
r14 = arg0;
r15 = r13 + *objc_ivar_offset__TtC11Bartender_311AppDelegate_trialOverWindow;
rbx = swift_unknownWeakLoadStrong(r15);
if (rbx != 0x0) {
[rbx center];
[rbx release];
rbx = **_NSApp;
if (rbx != 0x0) {
[rbx retain];
[rbx activateIgnoringOtherApps:sign_extend_64($S10ObjectiveC22_convertBoolToObjCBoolyAA0eF0VSbF(r14 & 0xff))];
[rbx release];
rbx = swift_unknownWeakLoadStrong(r15);
if (rbx != 0x0) {
[rbx makeKeyAndOrderFront:0x0];
[rbx release];
}
else {
asm { ud2 };
sub_100023199();
}
}
else {
asm { ud2 };
loc_100023195();
}
}
else {
asm { ud2 };
loc_100023191();
}
return;
}

通过上面的伪代码,我们可以初步判断这个 0x1000230e0 内部就是弹出试用期到期弹窗的方法。接着我们通过快捷键 X 查看关于 0x1000230e0 的引用,可以发现有三处调用,一个一个看下去发现第一个 sub_100022840 中的调用最像是延时调用,因为其中有 Hopper 反编译出来的 Dispatch 相关的伪代码。

1
2
3
4
5
6
7
8
9
10
11
12
$Ss10SetAlgebraPyxqd__cs8SequenceRd__7ElementQyd__ADRtzlufCTj(&var_A0, r13);
swift_release(*__swiftEmptyArrayStorage);
(extension in Dispatch):__ObjC.OS_dispatch_queueasyncAfterdeadlineqosflags.execute(Dispatch.DispatchTime, Dispatch.DispatchQoS, Dispatch.DispatchWorkItemFlags, @convention(block) () -> ()) -> ()(var_40, var_68, var_B0, var_30);
(*(var_D0 + 0x8))(var_B0, var_C8);
(*(var_C0 + 0x8))(var_68, var_B8);
_Block_release(var_30);
swift_release(var_D8);
(var_38)(var_40, var_70, rdx);
[var_A8 release];
sub_1000230e0(0x0);
rbx = var_48;
goto loc_100022df5;

切到汇编模式,找到对应的汇编代码。

由于 sub_1000230e0(0x0); 是在 Dispatch 中调用的,考虑到修改后程序的稳定性,这里通过 Hopper 的 Modify 菜单中提供的 NOP Region 填平 call sub_1000230e0 汇编代码。

老规矩,导出二进制文件覆盖 Bartender 中的二进制后重启 Bartender 验收成果。

清爽~ 这次运行 Bartender 发现不但可以正常使用功能,之前烦人的试用期到期弹窗也被我们成功干掉了。

总结

  • 文章简单介绍了本次要破解的目标 Mac 应用 Bartender,如果各位同学还没有找到合适的顶部菜单栏图标管理工具不妨试着使用 Bartender。
  • 文章介绍了 maxOS 与 iOS 逆向工程中主流的静态分析工具 Hopper,从文章后面破解 Bartender 的实战过程中就可以看出 Hopper 对于我们逆向过程的帮助有多么大。
  • 文章最后详细讲述了我在破解 Bartender 过程中的经历,从初始常规思路到不起作用思路被截断再到通过动态分析重拾思路…一直到最后的完美破解中间经历了许多关键节点,希望对大家有所帮助。

每一次逆向的过程都是未知的,有的时候可能会很顺利(直接 mov eax, 0x1 + ret 就搞定),有的时候可能会很曲折,有的时候可能还会以失败收尾。我写这篇文章主要是想与大家交流在逆向过程中的常规方法以及遇到困难时的一些解决思路,其实不论是 Bartender 还是其他应用,不论是 Mac 应用还是 iOS 应用,逆向的思路都是相通的,愿各位同学日后可以举一反三。

如果有任何问题欢迎在文章下方留言或在我的微博 @Lision 联系我,真心希望我的文章可以为你带来价值~

WWDC18 What’s New in LLVM

前言

LLVM 作为 Apple 御用的编译基础设施其重要性不言而喻,Apple 从未停止对 LLVM 的维护和更新,并且几乎在每年的 WWDC 中都有专门的 Session 来针对 LLVM 的新特性做介绍和讲解,刚刚过去的 WWDC18 也不例外。

WWDC18 Session 409 What’s New in LLVM 中 Apple 的工程师们又为我们介绍了 LLVM 最新的特性,这篇文章将会结合 WWDC18 Session 409 给出的 官方演示文稿 分享一下 LLVM 的新特性并谈谈笔者自己个人对这些特性的拙见。

Note: 本文不会对官方演示文稿做逐字逐句的翻译工作,亦不会去过多介绍 LLVM 的基本常识。

索引

  • ARC 更新
  • Xcode 10 新增诊断
  • Clang 静态分析
  • 增加安全性
  • 新指令集扩展
  • 总结

ARC 更新

本次 ARC 更新的亮点在于 C struct 中允许使用 ARC Objective-C 对象。

在之前版本的 Xcode 中尝试在 C struct 的定义中使用 Obj—C 对象,编译器会抛出 Error: ARC forbids Objective-C objects in struct,如下图所示:

嘛~ 这是因为之前 LLVM 不支持,如果在 Xcode 10 中书写同样的代码则不会有任何 Warning 与 Error:

那么直接在 C struct 中使用 Objective-C 对象的话难道就没有内存上的问题吗?Objective-C 所占用的内存空间是何时被销毁的呢?

1
2
3
4
5
6
7
// ARC Object Pointers in C Structs!
typedef struct {
NSString *name;
NSNumber *price;
} MenuItem;
void orderFreeFood(NSString *name) { MenuItem item = { name, [NSNumber numberWithInt:0] }; // [item.name retain]; // [item.price retain]; orderMenuItem(item); // [item.name release];
// [item.price release]; }

如上述代码所示,编译器会在 C struct MenuItem 创建后 retain 其中的 ARC Objective-C 对象,并在 orderMenuItem(item); 语句之后,即其他使用 MenuItem item 的函数调用结束之后 release 掉相关 ARC Objective-C 对象。

思考,在动态内存管理时,ARC Objective-C 对象的内存管理会有什么不同呢?

Note: 动态内存管理(Dynamic Memory Management),指非 int a[100];MenuItem item = {name, [NSNumber numberWithInt:0]}; 这种在决定了使用哪一存储结构之后,就自动决定了作用域和存储时期的代码,这种代码必须服从预先制定的内存管理规则。

我们知道 C 语言中如果想要灵活的创建一个动态大小的数组需要自己手动开辟、管理、释放相关的内存,示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void foo() {
int max;
double *ptd;

puts("What is the maximum number of type double entries?");
scanf("%d", &max);
ptd = malloc(max * sizeof(double));
if (ptd == NULL) {
// memory allocation failed
...
}

// some logic
...

free(ptd);
}

那么 C struct 中 ARC Objective-C 的动态内存管理是否应该这么写呢?

1
2
3
4
5
6
// Structs with ARC Fields Need Care for Dynamic Memory Management
typedef struct {
NSString *name;
NSNumber *price;
} MenuItem;
void testMenuItems() { // Allocate an array of 10 menu items MenuItem *items = malloc(10 * sizeof(MenuItem)); orderMenuItems(items, 10); free(items); }

答案是否定的!

可以看到通过 malloc 开辟内存初始化带有 ARC Objective-C 的 C struct 中 ARC Objective-C 指针不会 zero-initialized

嘛~ 这个时候自然而然的会想起使用 calloc ^_^

Note: callocmalloc 均可完成内存分配,不同之处在于 calloc 会将分配过来的内存块中全部位置都置 0(然而要注意,在某些硬件系统中,浮点值 0 不是全部位为 0 来表示的)。

另一个问题就是 free(items); 语句执行之前,ARC Objective-C 并没有被清理。

Emmmmm… 官方推荐的写法是在 free(items); 之前将 items 内的所有 struct 中使用到的 ARC Objective-C 指针手动职位 nil

所以在动态内存管理时,上面的代码应该这么写:

1
2
3
4
5
6
// Structs with ARC Fields Need Care for Dynamic Memory Management
typedef struct {
NSString *name;
NSNumber *price;
} MenuItem;
void testMenuItems() { // Allocate an array of 10 menu items MenuItem *items = calloc(10, sizeof(MenuItem)); orderMenuItems(items, 10); // ARC Object Pointer Fields Must be Cleared Before Deallocation for (size_t i = 0; i < 10; ++i) { items[i].name = nil; items[i].price = nil; } free(items); }

瞬间有种日了狗的感觉有木有?

个人观点

嘛~ 在 C struct 中增加对 ARC Objective-C 对象字段的支持意味着我们今后 Objective-C 可以构建跨语言模式的交互操作

Note: 官方声明为了统一 ARC 与 manual retain/release (MRR) 下部分 function 按值传递、返回 struct 对 Objective-C++ ABI 做出了些许调整。

值得一提的是 Swift 并不支持这一特性(2333~ 谁说 Objective-C 的更新都是为了迎合 Swift 的变化)。

Xcode 10 新增诊断

Swift 与 Objective-C 互通性

我们都知道 Swift 与 Objective-C 具有一定程度的互通性,即 Swift 与 Objective-C 可以混编,在混编时 Xcode 生成一个头文件将 Swift 可以转化为 Objective-C 的部分接口暴露出来。

不过由于 Swift 与 Objective-C 的兼容性导致用 Swift 实现的部分代码无法转换给 Objective-C 使用。

近些年来 LLVM 一致都在尝试让这两种语言可以更好的互通(这也就是上文中提到 Objective-C 的更新都是为了迎合 Swift 说法的由来),本次 LLVM 支持将 Swift 中的闭包(Closures)导入 Objective-C

1
2
3
@objc protocol Executor {
func performOperation(handler: () -> Void)
}
1
2
3
4
#import “Executor-Swift.h”
@interface DispatchExecutor : NSObject<Executor>
- (void)performOperation:(void (^)(void))handler;
@end

Note: 在 Swift 中闭包默认都是非逃逸闭包(non-escaping closures),即闭包不应该在函数返回之后执行。

Objective-C 中与 Swift 闭包对应的就是 Block 了,但是 Objective-C 中的 Block 并没有诸如 Swift 中逃逸与否的限制,那么我们这样将 Swift 的非逃逸闭包转为 Objective-C 中无限制的 Block 岂不是会有问题?

别担心,转换过来的闭包(非逃逸)会有 Warnning 提示,而且我们说过一般这种情况下 Apple 的工程师都会在 LLVM 为 Objective-C 加一个宏来迎合 Swift…

1
2
// Warning for Missing Noescape Annotations for Method Overrides
#import “Executor-Swift.h”
@interface DispatchExecutor : NSObject<Executor>
- (void)performOperation:(NS_NOESCAPE void (^)(void))handler;
@end
@implementation DispatchExecutor
- (void)performOperation:(NS_NOESCAPE void (^)(void))handler {
}
// Programmer must ensure that handler is not called after performOperation returns @end

个人观点

如果 Swift 5 真的可以做到 ABI 稳定,那么 Swift 与 Objective-C 混编的 App 包大小也应该回归正常,相信很多公司的项目都会慢慢从 Objective-C 转向 Swift。在 Swift 中闭包(Closures)作为一等公民的存在奠定了 Swift 作为函数式语言的根基,本次 LLVM 提供了将 Swift 中的 Closures 与 Objective-C 中的 Block 互通转换的支持无疑是很有必要的。

使用 #pragma pack 打包 Struct 成员

Emmmmm… 老实说这一节的内容更底层,所以可能会比较晦涩,希望自己可以表述清楚吧。在 C 语言中 struct 有 内存布局(memory layout) 的概念,C 语言允许编译器为每个基本类型指定一些对齐方式,通常情况下是以类型的大小为标准对齐,但是它是特定于实现的。

嘛~ 还是举个例子吧,就拿 WWDC18 官方演示文稿中的吧:

1
2
3
4
5
struct Struct { 
uint8_t a, b;
// 2 byte padding
uint32_t c;
};

在上述例子中,编译器为了对齐内存布局不得不在 Struct 的第二字段与第三字段之间插入 2 个 byte。

1
2
3
|   1   |   2   |   3   |   4   |
| a | b | pad.......... |
| c(1) | c(2) | c(3) | c(4) |

这样本该占用 6 byte 的 struct 就占用了 8 byte,尽管其中只有 6 byte 的数据。

C 语言允许每个远程现代编译器实现 #pragma pack,它允许程序猿对填充进行控制来依从 ABI。

From C99 §6.7.2.1:

12 Each non-bit-field member of a structure or union object is aligned in an implementation- defined manner appropriate to its type.

13 Within a structure object, the non-bit-field members and the units in which bit-fields reside have addresses that increase in the order in which they are declared. A pointer to a structure object, suitably converted, points to its initial member (or if that member is a bit-field, then to the unit in which it resides), and vice versa. There may be unnamed padding within a structure object, but not at its beginning.

实际上关于 #pragma pack 的相关信息可以在 MSDN page 中找到。

LLVM 本次也加入了对 #pragma pack 的支持,使用方式如下:

1
2
3
4
5
6
#pragma pack (push, 1) 
struct PackedStruct {
uint8_t a, b;
uint32_t c;
};
#pragma pack (pop)

经过 #pragma pack 之后我们的 struct 对齐方式如下:

1
2
3
4
5
6
7
|   1   |
| a |
| b |
| c(1) |
| c(2) |
| c(3) |
| c(4) |

其实 #pragma pack (push, 1) 中的 1 就是对齐字节数,如果设置为 4 那么对齐方式又会变回到最初的状态:

1
2
3
|   1   |   2   |   3   |   4   |
| a | b | pad.......... |
| c(1) | c(2) | c(3) | c(4) |

值得一提的是,如果你使用了 #pragma pack (push, n) 之后忘记写 #pragma pack (pop) 的话,Xcode 10 会抛出 warning:

个人观点

嘛~ 当在网络层面传输 struct 时,通过 #pragma pack 自定义内存布局的对齐方式可以为用户节约更多流量。

Clang 静态分析

Xcode 一直都提供静态分析器(Static Analyzer),使用 Clang Static Analyzer 可以帮助我们找出边界情况以及难以发觉的 Bug。

点击 Product -> Analyze 或者使用快捷键 Shift+Command+B 就可以静态分析当前构建的项目了,当然也可以在项目的 Build Settings 中设置构建项目时自动执行静态分析(个人不推荐):

本地静态分析器有以下提升:

  • GCD 性能反模式
  • 自动释放变量超出自动释放池
  • 性能和可视化报告的提升

GCD 性能反模式

在之前某些迫不得已的情况下,我们可能需要使用 GCD 信号(dispatch_semaphore_t)来阻塞某些异步操作,并将阻塞后得到的最终的结果同步返回:

1
2
3
4
5
6
7
8
__block NSString *taskName = nil;
dispatch_semaphore_t sema = dispatch_semaphore_create(0);
[self.connection.remoteObjectProxy requestCurrentTaskName:^(NSString *task) {
taskName = task;
dispatch_semaphore_signal(sema);
}];
dispatch_semaphore_wait(sema, DISPATCH_TIME_FOREVER);
return taskName;

嘛~ 这样写有什么问题呢?

上述代码存在通过使用异步线程执行任务来阻塞当前线程,而 Task 队列通常优先级较低,所以会导致优先级反转

那么 Xcode 10 之后我们应该怎么写呢?

1
2
3
4
5
6
7
__block NSString *taskName = nil;
id remoteObjectProxy = [self.connection synchronousRemoteObjectProxyWithErrorHandler:
^(NSError *error) { NSLog(@"Error: %@", error); }];
[remoteObjectProxy requestCurrentTaskName:^(NSString *task) {
taskName = task;
}];
return taskName;

如果可能的话,尽量使用 synchronous 版本的 API。或者,使用 asynchronous 方式的 API:

1
2
3
[self.connection.remoteObjectProxy requestCurrentTaskName:^(NSString *task) { 
completionHandler(task);
}];

可以在 build settings 下启用 GCD 性能反模式的静态分析检查:

自动释放变量超出自动释放池

众所周知,使用 __autoreleasing 修饰符修饰的变量会在自动释放池离开时被释放(release):

1
2
3
@autoreleasepool {
__autoreleasing NSError *err = [NSError errorWithDomain:@"domain" code:1 userInfo:nil];
}

这种看似不需要我们注意的点往往就是引起程序 Crash 的隐患:

1
2
3
4
5
6
7
8
9

- (void)findProblems:(NSArray *)arr error:(NSError **)error {
[arr enumerateObjectsUsingBlock:^(id value, NSUInteger idx, BOOL *stop) {
if ([value isEqualToString:@"problem"]) {
if (error) {
*error = [NSError errorWithDomain:@"domain" code:1 userInfo:nil];
}
}
}];
}

嘛~ 上述代码是会引起 Crash 的,你可以指出为什么吗?

Objective-C 在 ARC(Automatic Reference Counting)下会隐式使用 __autoreleasing 修饰 error,即 NSError *__autoreleasing*。而 -enumerateObjectsUsingBlock: 内部会在迭代 block 时使用 @autoreleasepool,在迭代逻辑中这样做有助于减少内存峰值。

于是 *error-enumerateObjectsUsingBlock: 中被提前 release 掉了,这样在随后读取 *error 时会出现 crash。

Xcode 10 中会给出具有针对性的静态分析警告:

正确的书写方式应该是这样的:

1
2
3
4
5
6
7
8
9
10
11
- (void)findProblems:(NSArray *)arr error:(NSError *__autoreleasing*)error { 
__block NSError *localError;
[arr enumerateObjectsUsingBlock:^(id value, NSUInteger idx, BOOL *stop) {
if ([value isEqualToString:@"problem"]) {
localError = [NSError errorWithDomain:@"domain" code:1 userInfo:nil];
}
}];
if (error) {
*error = localError;
}
}

Note: 其实早在去年的 WWDC17 Session 411 What’s New in LLVM 中 Xcode 9 就引入了一个需要显示书写 __autoreleasing 的警告。

性能和可视化报告的提升

Xcode 10 中静态分析器可以以更高效的方式工作,在相同的分析时间内平均可以发现比之前增加 15% 的 Bug 数量。

不仅仅是性能的提升,Xcode 10 在报告的可视化方面也有所进步。在 Xcode 9 的静态分析器报告页面有着非必要且冗长的 Error Path:

Xcode 10 中则对其进行了优化:

个人观点

嘛~ 对于 Xcode 的静态分析,个人认为还是聊胜于无的。不过不建议每次构建项目时都去做静态分析,这样大大增加了构建项目的成本。

个人建议在开发流程中自测完毕提交代码给组内小伙伴们 Code Review 之前做静态分析,可以避免一些 issue 的出现,也可以发现一些代码隐患。有些问题是可以使用静态分析器在提交代码之前就暴露出来的,没必要消耗组内 Code Review 的宝贵人力资源。

还可以在 CI 设置每隔固定是时间间隔去跑一次静态分析,生成报表发到组内小群,根据问题指派责任人去检查是否需要修复(静态分析在比较复杂的代码结构下并不一定准确),这样定期维护从某种角度讲可以保持项目代码的健康状况。

增加安全性

Stack Protector

Apple 工程师在介绍 Stack Protector 之前很贴心的带领着在场的开发者们复习了一遍栈 Stack 相关的基础知识:

如上图,其实就是简单的讲了一下 Stack 的工作方式,如栈帧结构以及函数调用时栈的展开等。每一级的方法调用,都对应了一张相关的活动记录,也被称为活动帧。函数的调用栈是由一张张帧结构组成的,所以也称之为栈帧

我们可以看到,栈帧中包含着 Return Address,也就是当前活动记录执行结束后要返回的地址。

那么会有什么安全性问题呢?Apple 工程师接着介绍了通过不正当手段修改栈帧 Return Address 从而实现的一些权限提升。嘛~ 也就是历史悠久的 缓冲区溢出攻击

当使用 C 语言中一些不太安全的函数时(比如上图的 strcpy()),就有可能造成缓冲区溢出。

Note: strcpy() 函数将源字符串复制到指定缓冲区中。但是丫没有指定要复制字符的具体数目!如果源字符串碰巧来自用户输入,且没有专门限制其大小,则有可能会造成缓冲区溢出

针对缓冲区溢出攻击,LLVM 引入了一块额外的区域(下图绿色区域)来作为栈帧 Return Address 的护城河,叫做 Stack Canary,已默认启用:

Note: Canary 译为 “金丝雀”,Stack Canary 的命名源于早期煤矿工人下矿坑时会携带金丝雀来检测矿坑内一氧化碳是否达到危险值,从而判断是否需要逃生。

根据我们上面对缓冲区溢出攻击的原理分析,大家应该很容易发现 Stack Canary 的防御原理,即缓冲区溢出攻击旨在利用缓冲区溢出来篡改栈帧的 Return Address,加入了 Stack Canary 之后想要篡改 Return Address 就必然会经过 Stack Canary,在当前栈帧执行结束后要使用 Return Address 回溯时先检测 Stack Canary 是否有变动,如果有就调用 abort() 强制退出。

嘛~ 是不是和矿坑中的金丝雀很像呢?

不过 Stack Canary 存在一些局限性:

  • 可以在缓冲区溢出攻击时计算 Canary 的区域并伪装 Canary 区域的值,使得 Return Address 被篡改的同时 Canary 区域内容无变化,绕过检测。
  • 再粗暴一点的话,可以通过双重 strcpy() 覆写任意不受内存保护的数据,通过构建合适的溢出字符串,可以达到修改 ELF(Executable and Linking Format)映射的 GOT(Global Offset Table),只要修改了 GOT 中的 _exit() 入口,即便 Canary 检测到了篡改,函数返回前调用 abort() 退出还是会走已经被篡改了的 _exit()

Stack Checking

Stack Protector 是 Xcode 既有的、且默认开启的特性,而 Stack Checking 是 Xcode 10 引入的新特性,主要针对的是 Stack Clash 问题。

Stack Clash 问题的产生源于 Stack 和 Heap,Stack 是从上向下增长的,Heap 则是自下而上增长的,两者相向扩展而内存又是有限的。

Stack Checking 的工作原理是在 Stack 区域规定合理的分界线(上图红线),在可变长度缓冲区的函数内部对将要分配的缓冲区大小做校验,如果缓冲区超出分界线则调用 abort() 强制退出。

Note: LLVM 团队在本次 WWDC18 加入 Stack Checking,大概率是因为去年年中 Qualys 公布的一份 关于 Stack Clash 的报告

新指令集扩展

Emmmmm… 这一节的内容是针对于 iMac Pro 以及 iPhone X 使用的 指令集架构(ISA - Instruction set architecture) 所做的扩展。坦白说,我对这块并不是很感兴趣,也没有深入的研究,所以就不献丑了…

总结

本文梳理了 WWDC18 Session 409 What’s New in LLVM 中的内容,并分享了我个人对这些内容的拙见,希望能够对各位因为种种原因还没有来得及看 WWDC18 Session 409 的同学有所帮助。

文章写得比较用心(是我个人的原创文章,转载请注明 https://lision.me/),如果发现错误会优先在我的个人博客中更新。如果有任何问题欢迎在我的微博 @Lision 联系我~

希望我的文章可以为你带来价值~

Sony WH-H900N 开箱留念

前言

嘛~ 又是一年高考季,每到这个时候我就知道我又长了一岁…

Emmmmm… 最近似乎陷入了创作低谷,已经很久没有更新过博客了,原因嘛… 有很多:

  • 一方面是入职之后需要适应新的环境,包括新的团队,新的流程… 从这一点来看人类果然是弱鸡动物,会受到外界环境的变化以及心情等内在因素的影响,至少机器人不会被这些东西困扰吧…
  • 另一方面是不知道应该写什么,或者说自己在这段与新团队的磨合期中眼界提升了很多,很多之前计划去写的文章现在突然没有兴趣了,以前也有过类似的经历… 唯有告诫自己戒掉拖延症,不要错过当前这段时期自己想做的事情哈~

另外一个最重要原因就是——真的没时间

自己也想过为什么会这样,除去磨合期种种不熟悉导致的时效问题,还剩下一些客观存在的因素,其中就有办公环境的影响,现在的办公环境比较开放(装修风格和网吧类似 >_<),所以每天都会有 QA 小姐姐们测试订单的声音,PM 小哥哥们撕逼的声音,RD 小伙伴们敲击键盘的声音… 这几种声音在不同的音频攻击着我幼小的心脏和脆弱的神经,影响着我敲代码时的专注程度…

然后可能之前下班回到家后有和我女票抱怨过吧… 结果过生日就收到了这篇文章的主角—— Sony WH-H900N。

索引

  • Sony WH-H900N
  • 入手渠道
  • 开箱照
  • 上头体验
  • 总结

Sony WH-H900N

Sony WH-H900N 全称 Sony | h.ear on 2 Wireless NC | WH-H900N,是大法旗下的 h.ear on 2 系列无线蓝牙降噪耳机。

关键词:

  • 佩戴良好
  • 隔音良好
  • 听诊器效应轻微
  • 做工优秀
  • 降噪良好
  • 杂食

由于我自己平时什么音乐都听一些,偏 ACG 和 Eason Chen 多一些,加上我自己本身是木耳(木头耳朵,指听不出不同耳机的音质区别)所以一直以来对耳机就只有无线降噪两个硬性需求,然后自己没有出街听音乐的习惯(主要是觉得车来车往不太安全),所以想要一款头戴式的耳机(在办公室和家里听听就好),然后因为头戴式的耳机比较显眼,所以对颜值也有了一定的要求…

刚刚好,这些需求 Sony WH-H900N 统统满足了,再次感恩我的女票,想必花了不少心思吧(笑)

配色方面,这款耳机提供了五种配色可供选择:

  • 暮光红(目光红,因为这个颜色真的特别少女,据说出街回头率超高)
  • 月光蓝
  • 薄荷绿
  • 浅金
  • 灰黑

这几种颜色都是偏哑光质感的,低调的同时满足了绝大多数消费者的个性化选择,我这款就是月光蓝配色的~

Note: 鉴于网上各种评测贴和买家秀的晒图中出现了各种颜色的月光蓝,所以我这篇文章的所有配图均无任何滤镜和后期处理,希望能够给想买月光蓝的同学提供一些参考和帮助。

补充,话说我还在大法的官方商城搜到了 WH-H900N FATE 特别版哟~

开箱照

箱内物品如下:

  • Sony WH-H900N 耳机本体
  • 耳机线材(手感一般,随耳机本体颜色)
  • 充电线(黑色
  • 收纳袋(手感不错的透气帆布材质收纳袋,内衬柔软材料,随耳机本体颜色)
  • 收纳盒(随耳机本体颜色)

需要注意的是只有充电线是黑色的,这也就意味着如果你选择的是暮光红这种偏浅色系的配色,充电的时候会…非!常!丑!好在这款耳机有大法的快充黑科技加持(其实不如 Apple 的快充黑科技),在低电量时充电 10 分钟可以听 1 小时左右(说明书有写 & 亲测),另外耳机充满电的续航时间为 28 小时(开启降噪),完全可以在家充好电再带去公司使用。

上头体验

Emmmmm… 自认木耳,又是理工男的渣渣文笔,就简单叙述一下自己的主观听感吧~

音质

音质方面个人认为还不错,这款耳机偏中低音,恰好 Eason Chen 的歌普遍都是中低音男声,所以表现很不错,人声清晰,不会感觉距离耳朵很远,又不会感觉靠的太近,可以说是恰到好处吧~ 另外 ACG 女声的表现也很不错,人声甜美,距离适中,声音有厚度。有的耳机人声表现就很单薄,该怎么形容呢?就是感觉人声是耳机从背景音乐中摘出来突兀的放到耳边的,清楚但是与背景音格格不入,像纸片一样突兀且单薄,一带而过的发声…

降噪

首先,耳机的耳垫做工很不错,足够柔软舒适,扣在耳朵上已经可以降低 10%~20% 的环境噪音了,然后开机并开启降噪模式可以瞬间感觉到 >= 80% 的环境噪音都被屏蔽了,剩下的一些声音基本都是附近的人声,且声音小了很多(开始听音乐时外面减弱过的人声就基本听不到了)~ 低频的杂音类似中央空调出风口的声音则完全被屏蔽,我是中午午休时拆箱第一次试戴的,当时正好没人说话,感觉瞬间沉入海底,整个世界都安静了~

Note: Sony WH-H900N 支持手势操控,用右手遮盖右耳机,则会降低正在播放的音乐音量,同时暂时关闭降噪,这样非常人性化的便捷设计可以让你很方便的与同事短暂沟通。

无线

大法黑科技将蓝牙传输的速率提升很多,在无线状态下依然尽可能的争取音质体验,个人的感觉是比我之前 Beats solo 2 的音质还要稍好一些~

做工

这款耳机的头梁做工很厚实,保证了长时间佩戴的舒适度(不过耳垫处由于密封性等原因会比较容易出汗,好在公司和家里都有空调,问题不大),这一点和我之前的 Beats solo 2 形成强烈对比,Beats solo 2 的头梁就薄薄一层,拆开之后发现里面只有一层超薄海绵(日了狗了)。

耳机的触感和它偏哑光的颜色保持一致,形容起来大概就是介于磨砂和光面之间的感觉,耳垫柔软舒适有弹性,大爱 Sony 这种优秀扎实的做工品质!

总结

嘛~ 总得来说呢… Sony WH-H900N 这款耳机非常适合对无线 & 降噪有硬性需求的同学,难能可贵的是其在无线 & 降噪的基础上还保证了高颜值 & 一定的音质(反正对于我这种木耳来讲足够了)。

补充,降噪耳机真的是提升工效的利器,从某种角度看甚至比键盘来的更加粗暴有效(毕竟不需要磨合期)。

Hackathon 5.0 小记

前言

Emmmmm… 还记得上一次参加 Hackathon 是在 2017 年上海举办的,当时还在 ELSEWHERE 工作的我和公司的小伙伴们一起组团从北京到上海参赛,也是我人生中第一次参加 Hackathon,所以到现在都还印象深刻呢!

这一晃,加入美团·点评也快满三个月了,早就知道美团·点评有公司内部举办 Hackathon 的传统,但没想到这么快就可以参与其中了,想想还有点小激动。其实之前就有看到过同事穿着往届内部 Hackathon 主题 T 恤来公司上班,自己也超想有一件!所以在公司内部发出这次 Hackathon 5.0 的宣传页以及报名表时就毫不犹豫的拉着组里的两位大佬一起报名了哈。

索引

  • Hackathon
  • Hackathon 5.0
  • 不同于往届的赛制
  • 嗨 Kr 森
  • Somewhere
  • 去年的 Hackathon 回忆
  • 总结

Hackathon

Hackathon 译为“黑客马拉松”,旨在用限定的时间做出可用的软件应用。

Hackathon 往往都有一个特定的焦点,其中可能包括使用的编程语言,操作系统,应用程序,API 等等。

不仅仅是程序猿,Hackathon 的参赛人员包含软件开发领域的所有职业,包括平面设计师,界面设计师,产品经理等等,大家在赛前根据计划组队,比赛过程中各司其职,发挥专长,力争为团队拿下好的成绩。

不过随着 Hackathon 的不断发展,现在的 Hackathon 已经不单单是场比赛了,我觉得它更像是让 Hacker 们齐聚一堂盛会和节日。

Hackathon 5.0

Emmmmm… 为什么叫 Hackathon 5.0 呢?其实感觉这个就是公司内部为了区分每一届 Hackathon 的 Tag 而已。貌似在美团网与大众点评合并之前,两家公司就都有内部举办 Hackathon 的历史,合并之后自然而然的将这一传统延续了下来。

不同于往届的赛制

往届 Hackathon 大致流程:

  • 提前一两周给一个特定范围内的主题
  • 大家根据主题找灵感
  • 线下拉人组队
  • 技术调研保证项目可行性以及确定技术方案
  • 报名参赛
  • 现场开始编码
  • 在规定的时间内(24 小时)完成作品
  • 在完成作品期间抽时间准备 PPT 模拟上台宣讲
  • 评委对参赛队伍的作品初筛
  • 入围的队伍上台决赛答辩
  • 评委给出最终评分决出名次
  • 上台领奖 & 拍照留恋

不过这次 Hackathon 5.0 在赛制上为了保证参赛作品的质量,没有明确的限制主题,这就导致有些作品是在赛前做了很久的项目,这些项目不论从体量还是从质量上都跟 24 小时竭尽全力所能产出的作品有着悬殊的差距。

个人认为提前两周公布一个明确的主题范围,大家在两周内做技术调研之后提前在工作之余开始构建项目还是可以接受的,这样可以保证参赛作品的质量同时也可以省去大家通宵熬夜对身体的损耗。主办方出发点是好的,但是任何主题任何项目都可以参赛这点就决定了这场 Hackathon 注定是不太公平的同台竞技,不仅打压了部分第一次参加 Hackathon 的同学以后继续参加的积极性,还掀起了一个不好的势头 —— 以后参赛的作品可能都会是提前很久就开始构建的项目。

嘛~ 上面的个人观点可能有些酸吧… 因为我们战队就是按照老 Hackathon 的赛制准备的(事实上我并不知道美团·点评内部 Hackathon 什么时候举办,也不知道这次的赛制竟然没有限制主题)。

嗨 Kr 森

嘛~ “嗨 Kr 森”是我们本次参加 Hackathon 5.0 的战队名哈!

起这个名字无非就是觉得比较好玩,因为组里连我在内的三人中,有两位都没有参加过公司内部组织的 Hackathon,所以本次参赛的主要目的就是熟悉一下公司内部 Hackathon 的流程,还有混一件 Hackathon 5.0 主题 T 恤衫(笑)。

Somewhere

Somewhere 译为“某处”,是我们战队这次拿来参赛的作品。它是一个将 AR 技术结合到地图的项目,旨在帮助用户发现值得消费时间去用心体验的地方。

Emmmmm… 其实关于 Somewhere 还有一个比较文艺的文案是我们当时在写参赛 PPT 时想到的。

我们一直以来都希望,打开手机,对准想要去的方向。相信总有一处地方,值得我们花些时间在那里。Somewhere,帮助你发现近在咫尺的美好

Somewhere 参考了 ARKit-CoreLocation 的实现,在其基础上做了一些优化,例如对某一方向重叠在一起的 POI AR Annotations 做了收敛以防止多个 POI AR Annotations 重叠在一起的问题。

可惜的是 Somewhere 只拿到了入围奖(42 进 10),确实站在评委的角度看 Somewhere 在完成度上面对比获奖作品来说差的太多了…

去年的 Hackathon 回忆

去年还在 ELSEWHERE 工作时,和公司的小伙伴一起去上海参加中信集团与宝马联合赞助举办的开放式 Hackathon,也是我第一次参加 Hackathon,所以很多细节都记得清清楚楚~

那届 Hackathon 的主题是三选一,其中中信集团的主题之一是我们一直比较感兴趣的区块链相关主题,中信集团想要依靠区块链技术打造其集团下属业务之间的用户信用体系,确实利用区块链超级账本做信用相关的东西是个不错的出发点,从扩展性的角度看区块链技术可以让中信集团以后支持并入其他非集团旗下的外部业务。

最后我们的参赛作品“信信”非常幸运的入围,并且在决赛拿到了名次和奖品~

总结

  • 平日里有自己感兴趣的技术或者想做的事情,不妨抽空动手写一写 Demo,如果可行的话可以持续抽时间完善,说不定可以拿来作为作品参加下一届公司内部的 Hackathon。
  • PPT 和作品的核心代码一样重要,初赛宣讲和决赛答辩给出的时间不同,最好准备详略两份 PPT。
  • 核心流程一定要跑通,尤其是涉及到网络,定位等不确定因素时,一定要在上台前确认好当前的状态是否会影响到作品演示。
  • 最受欢迎奖会在第二日的决赛答辩之前给出一个投票页面,在队长群会周知要填写投票页面中关于自己战队的作品简介,一定要及时填写作品简介以免影响得票率。

嘛~ 目前准备看一下 WWDC 2018 有没有什么感兴趣的技术点,准备拿来结合生活中的场景做些有趣的东西出来,为 Hackathon 6.0 做准备~


Emmmmm… 最后还要感谢战队里面的两位大佬愿意把宣讲和答辩的机会让给我,第一次面对着评委和台下这么多优秀的同事(其中不乏技术大佬)讲话真的是非常紧张的说,果然在回答某些问题上不够巧妙… 不过好在对于项目的宣讲和描述还算比较完整,大佬们也没有责怪我的意思,还给我拍了一张答辩时的照片~

深入理解 iOS Rendering Process

前言

iOS 最早名为 iPhone OS,是 Apple 公司专门为其硬件设备开发的操作系统,最初于 2007 年随第一代 iPhone 推出,后扩展为支持 Apple 公司旗下的其他硬件设备,如 iPod、iPad 等。

作为一名 iOS Developer,相信大多数人都有写出过造成 iOS 设备卡顿的代码经历,相应的也有过想方设法优化卡顿代码的经验。

本文将从 OpenGL 的角度结合 Apple 官方给出的部分资料,介绍 iOS Rendering Process 的概念及其整个底层渲染管道的各个流程。

相信在理解了 iOS Rendering Process 的底层各个阶段之后,我们可以在平日的开发工作之中写出性能更高的代码,在解决帧率不足的显示卡顿问题时也可以多一些思路~

索引

  • iOS Rendering Process 概念
  • iOS Rendering 技术框架
  • OpenGL 主要渲染步骤
  • OpenGL Render Pipeline
  • Core Animation Pipeline
  • Commit Transaction
  • Animation
  • 全文总结
  • 扩展阅读

iOS Rendering Process 概念

iOS Rendering Process 译为 iOS 渲染流程,本文特指 iOS 设备从设置将要显示的图元数据到最终在设备屏幕成像的整个过程。

在开始剖析 iOS Rendering Process 之前,我们需要对 iOS 的渲染概念有一个基本的认知:

基于平铺的渲染

iOS 设备的屏幕分为 N * N 像素的图块,每个图块都适合于 SoC 缓存,几何体在图块内被大量拆分,只有在所有几何体全部提交之后才可以进行光栅化(Rasterization)。

Note: 这里的光栅化指将屏幕上面被大量拆分出来的几何体渲染为像素点的过程。

iOS Rendering 技术框架

事实上 iOS 渲染相关的层级划分大概如下:

UIKit

嘛~ 作为一名 iOS Developer 来说,应该对 UIKit 都不陌生,我们日常开发中使用的用户交互组件都来自于 UIKit Framework,我们通过设置 UIKit 组件的 Layout 以及 BackgroundColor 等属性来完成日常的界面绘画工作。

其实 UIKit Framework 自身并不具备在屏幕成像的能力,它主要负责对用户操作事件的响应,事件响应的传递大体是经过逐层的视图树遍历实现的。

那么我们日常写的 UIKit 组件为什么可以呈现在 iOS 设备的屏幕上呢?

Core Animation

Core Animation 其实是一个令人误解的命名。你可能认为它只是用来做动画的,但实际上它是从一个叫做 Layer Kit 这么一个不怎么和动画有关的名字演变而来的,所以做动画仅仅是 Core Animation 特性的冰山一角。

Core Animation 本质上可以理解为是一个复合引擎,旨在尽可能快的组合屏幕上不同的显示内容。这些显示内容被分解成独立的图层,即 CALayer,CALayer 才是你所能在屏幕上看见的一切的基础。

其实很多同学都应该知道 CALayer,UIKit 中需要在屏幕呈现的组件内部都有一个对应的 CALayer,也就是所谓的 Backing Layer。正是因为一一对应,所以 CALayer 也是树形结构的,我们称之为图层树

视图的职责就是创建并管理这个图层,以确保当子视图在层级关系中添加或者被移除的时候,他们关联的图层同样对应在层级关系树当中有相同的操作

但是为什么 iOS 要基于 UIView 和 CALayer 提供两个平行的层级关系呢?为什么不用一个简单的层级关系来处理所有事情呢?

原因在于要做职责分离,这样也能避免很多重复代码。在 iOS 和 Mac OS X 两个平台上,事件和用户交互有很多地方的不同,基于多点触控的用户界面和基于鼠标键盘的交互有着本质的区别,这就是为什么 iOS 有 UIKit 和 UIView,而 Mac OS X 有 AppKit 和 NSView 的原因。他们功能上很相似,但是在实现上有着显著的区别。

Note: 实际上,这里并不是两个层级关系,而是四个,每一个都扮演不同的角色,除了视图树图层树之外,还存在呈现树渲染树

OpenGL ES & Core Graphics

OpenGL ES

OpenGL ES 简称 GLES,即 OpenGL for Embedded Systems,是 OpenGL 的子集,通常面向图形硬件加速处理单元(GPU)渲染 2D 和 3D 计算机图形,例如视频游戏使用的计算机图形。

OpenGL ES 专为智能手机,平板电脑,视频游戏机和 PDA 等嵌入式系统而设计 。OpenGL ES 是“历史上应用最广泛的 3D 图形 API”。

Core Graphics

Core Graphics Framework 基于 Quartz 高级绘图引擎。它提供了具有无与伦比的输出保真度的低级别轻量级 2D 渲染。您可以使用此框架来处理基于路径的绘图,转换,颜色管理,离屏渲染,图案,渐变和阴影,图像数据管理,图像创建和图像遮罩以及 PDF 文档创建,显示和分析。

Note: 在 Mac OS X 中,Core Graphics 还包括用于处理显示硬件,低级用户输入事件和窗口系统的服务。

Graphics Hardware

Graphics Hardware 译为图形硬件,iOS 设备中也有自己的图形硬件设备,也就是我们经常提及的 GPU。

图形处理单元(GPU)是一种专用电子电路,旨在快速操作和改变存储器,以加速在用于输出到显示设备的帧缓冲器中创建图像。GPU 被用于嵌入式系统,手机,个人电脑,工作站和游戏控制台。现代 GPU 在处理计算机图形和图像方面非常高效,并且 GPU 的高度并行结构使其在大块数据并行处理的算法中比通用 CPU 更有效。

OpenGL 主要渲染步骤

OpenGL 全称 Open Graphics Library,译为开放图形库,是用于渲染 2D 和 3D 矢量图形的跨语言,跨平台应用程序编程接口(API)。OpenGL 可以直接访问 GPU,以实现硬件加速渲染。

一个用来渲染图像的 OpenGL 程序主要可以大致分为以下几个步骤:

  • 设置图元数据
  • 着色器-shader 计算图元数据(位置·颜色·其他)
  • 光栅化-rasterization 渲染为像素
  • fragment shader,决定最终成像
  • 其他操作(显示·隐藏·融合)

Note: 其实还有一些非必要的步骤,与本文主题不相关,这里点到为止。

我们日常开发时使用 UIKit 布局视图控件,设置透明度等等都属于设置图元数据这步,这也是我们日常开发中可以影响 OpenGL 渲染的主要步骤。

OpenGL Render Pipeline

如果有同学看过 WWDC 的一些演讲稿或者接触过一些 OpenGL 知识,应该对 Render Pipeline 这个专业术语并不陌生。

不过 Render Pipeline 实在是一个初次见面不太容易理解的词,它译为渲染管道,也有译为渲染管线的…

其实 Render Pipeline 指的是从应用程序数据转换到最终渲染的图像之间的一系列数据处理过程

好比我们上文中提到的 OpenGL 主要渲染步骤一样,我们开发应用程序时在设置图元数据这步为视图控件的设定布局,背景颜色,透明度以及阴影等等数据。

下面以 OpenGL 4.5 的 Render Pipeline 为例介绍一下:

这些图元数据流入 OpenGL 中,传入顶点着色器(vetex shader),然后顶点着色器对其进行着色器内部的处理后流出。之后可能进入细分着色阶段(tessellation shading stage),其中又有可能分为细分控制着色器和细分赋值着色器两部分处理,还可能会进入几何着色阶段(geometry shading stage),数据从中传递。最后都会走片元着色阶段(fragment shading stage)

Note: 图元数据是以 copy 的形式流入 shader 的,shader 一般会以特殊的类似全局变量的形式接收数据。

OpenGL 在最终成像之前还会经历一个阶段名为计算着色阶段(compute shaing stage),这个阶段 OpenGL 会计算最终要在屏幕中成像的像素位置以及颜色,如果在之前提交代码时用到了 CALayer 会引起 blending 的显示效果(例如 Shadow)或者视图颜色或内容图片的 alpha 通道开启,都将会加大这个阶段 OpenGL 的工作量。

Core Animation Pipeline

上文说到了 iOS 设备之所以可以成像不是因为 UIKit 而是因为 LayerKit,即 Core Animation。

Core Animation 图层,即 CALayer 中包含一个属性 contents,我们可以通过给这个属性赋值来控制 CALayer 成像的内容。这个属性的类型定义为 id,在程序编译时不论我们给 contents 赋予任何类型的值,都是可以编译通过的。但实践中,如果 contents 赋值类型不是 CGImage,那么你将会得到一个空白图层

Note: 造成 contents 属性的奇怪表现的原因是 Mac OS X 的历史包袱,它之所以被定义为 id 类型是因为在 Mac OS X 中这个属性对 CGImage 和 NSImage 类型的值都起作用。但是在 iOS 中,如果你赋予一个 UIImage 属性的值,仅仅会得到一个空白图层。

说完 Core Animation 的 contents 属性,下面介绍一下 iOS 中 Core Animation Pipeline:

  • 在 Application 中布局 UIKit 视图控件间接的关联 Core Animation 图层
  • Core Animation 图层相关的数据提交到 iOS Render Server,即 OpenGL ES & Core Graphics
  • Render Server 将与 GPU 通信把数据经过处理之后传递给 GPU
  • GPU 调用 iOS 当前设备渲染相关的图形设备 Display

Note: 由于 iOS 设备目前的显示屏最大支持 60 FPS 的刷新率,所以每个处理间隔为 16.67 ms。

可以看到从 Commit Transaction 之后我们的图元数据就将会在下一次 RunLoop 时被 Application 发送给底层的 Render Server,底层 Render Server 直接面向 GPU 经过一系列的数据处理将处理完毕的数据传递给 GPU,然后 GPU 负责渲染工作,根据当前 iOS 设备的屏幕计算图像像素位置以及像素 alpha 通道混色计算等等最终在当前 iOS 设备的显示屏中呈现图像。

嘛~ 由于 Core Animation Pipeline 中 Render Server 包含 OpenGL ES & Core Graphics,其中 OpenGL ES 的渲染可以参考上文 OpenGL Render Pipeline 理解。

Commit Transaction

Core Animation Pipeline 的整个管线中 iOS 常规开发一般可以影响到的范围也就仅仅是在 Application 中布局 UIKit 视图控件间接的关联 Core Animation 图层这一级,即 Commit Transaction 之前的一些操作

那么在 Commit Transaction 之前我们一般要做的事情有哪些?

  • Layout,构建视图
  • Display,绘制视图
  • Prepare,额外的 Core Animation 工作
  • Commit,打包图层并将它们发送到 Render Server

Layout

在 Layout 阶段我们能做的是把 constraint 写的尽量高效,iOS 的 Layout Constraint 类似于 Android 的 Relative Layout。

Note: Emmmmm… 据观察 iOS 的 Layout Constraint 在书写时应该尽量少的依赖于视图树中同层级的兄弟视图节点,它会拖慢整个视图树的 Layout 计算过程。

这个阶段的 Layout 计算工作是在 CPU 完成的,包括 layoutSubviews 方法的重载,addSubview: 方法填充子视图等

Display

其实这里的 Display 仅仅是我们设置 iOS 设备要最终成像的图元数据而已,重载视图 drawRect: 方法可以自定义 UIView 的显示,其原理是在 drawRect: 方法内部绘制 bitmap。

Note: 重载 drawRect: 方法绘制 bitmap 过程使用 CPU 和 内存

所以重载 drawRect: 使用不当会造成 CPU 负载过重,App 内存飙升等问题。

Prepare

这个步骤属于附加步骤,一般处理图像的解码 & 转换等操作。

Commit

Commit 步骤指打包图层并将它们发送到 Render Server。

Note: Commit 操作会递归执行,由于图层和视图一样是以树形结构存在的,当图层树过于复杂时 Commit 操作的开销也会非常大。

CATransaction

CATransaction 是 Core Animation 中用于将多个图层树操作分配到渲染树的原子更新中的机制,对图层树的每个修改都必须是事务的一部分。

CATransaction 类没有属性或者实例方法,并且也不能用 +alloc-init 方法创建它,我们只能用类方法 +begin+commit 分别来入栈或者出栈。

事实上任何可动画化的图层属性都会被添加到栈顶的事务,你可以通过 +setAnimationDuration: 方法设置当前事务的动画时间,或者通过 +animationDuration 方法来获取时长值(默认 0.25 秒)。

Core Animation 在每个 RunLoop 周期中自动开始一次新的事务,即使你不显式地使用 [CATransaction begin] 开始一次事务,在一个特定 RunLoop 循环中的任何属性的变化都会被收集起来,然后做一次 0.25 秒的动画(CALayer 隐式动画)。

Note: CATransaction 支持嵌套

Animation

对于 App 用户交互体验提升最明显的工作莫过于使用动画了,那么 iOS 是如何处理动画的渲染过程的呢?

日常开发中如果不是特别复杂的动画我们一般会使用 UIView Animation 实现,iOS 将 UIView Animation 的处理过程分为以下三个阶段:

  • 调用 animateWithDuration:animations: 方法
  • 在 Animation Block 中进行 Layout,Display,Prepare,Commit
  • Render Server 根据 Animation 逐帧渲染

Note: 原理是 animateWithDuration:animations: 内部使用了 CATransaction 来将整个 Animation Block 中的代码作为原子操作 commit 给了 RunLoop。

基于 CATransaction 实现链式动画

事实上大多数的动画交互都是有动画执行顺序的,尽管 UIView Animation 很强大,但是在写一些顺序动画时使用 UIView Animation 只能在 + (void)animateWithDuration:delay:options:animations:completion: 方法的 completion block 中层级嵌套,写成一坨一坨 block 堆砌而成的代码,实在是难以阅读更别提后期维护了。

在得知 UIView Animation 使用了 CATransaction 时,我们不禁会想到这个 completion block 是不是也是基于 CATransaction 实现的呢?

Bingo!CATransaction 中有 +completionBlock 以及 +setCompletionBlock: 方法可以对应于 UIView Animation 的 completion block 的书写。

Note: 我的一个开源库 LSAnimator - 可多链式动画库动画顺序链接时也用到了 CATransaction

全文总结

结合上下文不难梳理出一个 iOS 最基本的完整渲染经过(Rendering pass)

性能检测思路

基于整篇文章的内容归纳一下我们在日常的开发工作中遇到性能问题时检测问题代码的思路:

问题 建议 检测工具
目标帧率 60 FPS Core Animation instrument
CPU or GPU 降低使用率节约能耗 Time Profiler instrument
不必要的 CPU 渲染 GPU 渲染更理想,但要清楚 CPU 渲染在何时有意义 Time Profiler instrument
过多的 offscreen passes 越少越好 Core Animation instrument
过多的 blending 越少越好 Core Animation instrument
奇怪的图片格式或大小 避免实时转换或调整大小 Core Animation instrument
开销昂贵的视图或特效 理解当前方案的开销成本 Xcode View Debugger
想象不到的层次结构 了解实际的视图层次结构 Xcode View Debugger

文章写得比较用心(是我个人的原创文章,转载请注明 https://lision.me/),如果发现错误会优先在我的个人博客中更新。如果有任何问题欢迎在我的微博 @Lision 联系我~

希望我的文章可以为你带来价值~

扩展阅读

HHKB 开箱留念

前言

Emmmmm… 这个清明节本来打算去青海湖骑行的,不过因为北京 4 月飞雪的天气骤变加上自己没注意添衣… 最终还是没能出了帝都 ╮(╯▽╰)╭

不过这也省下了一些开销~ 于是用本来准备出去踏青的 💰 入了一把垂涎已久的键盘,也就是本文的主角 —— HHKB 酱!

嘛~ 知道 HHKB 是在 2015 年,记得还是在某乎的一篇回答中看到的,答主是一个妹子,大意是这个妹子偷偷看到了程序猿男票的购物车中躺着一把 HHKB,然后这个对键盘一窍不通的妹子历尽波折将 HHKB 入手送给自己程序猿男票的暖心小故事。

当时的我刚刚毕业一年左右吧,也是正想买一把键盘来敲代码,提升自己的输出(事实证明,并没有太大帮助 ^_^||),见证自己的成长(这个打油的键帽们可以证明哟)。于是就搜到了这篇回答,看得我心里一暖,无比羡慕答主男票的同时自己默默的继续浏览其他关于键盘的东西…

最后在 Filco 和 HHKB 之间纠结挣扎了许久,选择了 Filco 87 青轴奶绿配色,不过想入一把 HHKB 的种子却埋在了心里…

有了大 F 的陪伴,HHKB 的艹长得依旧很快,每次在使用 ctrl maping command 键时心里的艹都会拔高一截,尤其是最近已经到了不能不拔的地步…

所以趁着这次机会,果断入手,写篇文章记录一下自己的开箱感受,说不定对某些同学有帮助呢~

Note: 前排提示,多图预警!

索引

  • HHKB
  • 入手渠道
  • 开箱照
  • 手感体验
  • 键帽
  • 总结

HHKB

HHKB 全称 Happy Hacking Keyboard,于 1996 年 12 月 20 日诞生于日本,系 富士通 旗下子公司 PFU 生产的紧凑型键盘,以逼格甚高的键盘配列、优秀流畅的敲击手感、高昂的售价为外设发烧友和码农们所熟知,亦以被 Hackers 钟爱而闻名。

关于 HHKB 的评测贴很多,这里只挑一些我认为值得聊得点来写:

  • HHKB 的键盘配列为何如此设计?
  • 什么是静电容轴?
  • 谁捧红了 HHKB?

标志性配列

HHKB 是由日本 Hacker 和田英一 和 PFU 研究所共同设计,从名字就可以看出此系列键盘的用户定位是 Hacker,这块键盘的配列从第一代设计至今未变,可以说是 HHKB 的标志性配列。

键盘非 67 键的普通主键区小键盘传统配列,为了 Hackers 可以更好的在 Emacs 和 Vim 下使用,键盘将高频键位 —— Ctrl 上移至 Cap 处,然后砍掉了 Cap… 并默认把 Esc 键下沉至低频键位 ~ 处,同时还把 Del 键下沉至 | 键处,甚至砍掉了 67 键配列中大多数人认为高频使用的方向键!

静电容轴

HHKB 既不是大多数人用的量产薄膜键盘,也不是近些年来逐渐火热的机械键盘,而是采用了 Topre 无接触式电容开关设计。

  • 普通薄膜键盘的触发开关是在橡胶下黏一块导电薄膜,通过这个导电薄膜触发按键开关。
  • 机械键盘在机械轴体内部加入铜片,按键时通过机械轴体轴心挤压铜片触发按键开关。
  • 静电容键盘通过碗状橡胶触发开关,触发原理是在按键过程中电极间距改变产生电容值变化,进而触发键盘讯号。

Note: 很多同学对机械键盘有误解,其实机械键盘轴体内部也是有弹簧的,轴体回弹也是依靠这个弹簧实现。

钟爱 HHKB 的大神

入手渠道

我入手的是 HHKB Professional BT (Bluetooth) 版本,主要是看中了无线的优势,毕竟对于 Mac 来说,一个键盘占用一个 USB 接口太过奢侈了…

截止至下单时,日亚的售价是 29700 日元,约合人民币 1750 元。

某猫有一家认证过的 HHKB 官方旗舰店,售价 2388 元人民币,店铺限时活动 -200 元,加上满 200 即减 20 的满减活动,最终下单价为 2088 元。

考虑到行货保修问题,以及海淘邮寄可能出现的一些状况果断选择了国行。

开箱照

嘛~ 快递没什么好说的,店家包顺丰次日达,顺丰的服务一如既往的让人满意。

清明小长假第一天就到了,开箱签收之后洗手拍照留念(笑)。

Note: 再次提示,多图预警!

嘛~ 吐槽一下,HHKB Pro BT 的这个电池仓略丑,好在宝宝敲键盘的时候看不到它…

手感体验

买之前体验了同事的 HHKB Pro Type-S 和 HHKB Pro BT,对比发现手感上并无太大偏差,不过虽然 Type-S 缩短键程没有特别明显的手感体验差异,但是静音是真的差了一个梯度。BT 版本的声音并不吵,个人感觉应该比 Filco 红轴要略小,开放式办公应该问题不大。

不过讲真,HHKB 的手感并没有网上传的那么绵软流畅,质感也差了手边 Filco 青轴一段距离,也可能是我青轴用多了吧…

具体描述的话,HHKB 的手感略柔和,没有青轴按下按键触发开关时的清脆声,整体给我的感觉有点神似红轴,但是又不像红轴那样一触到底…

HHKB 和 Filco 都是信仰之物,上面的比较也是在较为苛刻的程度上我个人主观感受而已,毕竟这个价位的东西了,使用体验都会让人感到很舒服~

在蓝牙连接状态下敲代码,完全没有误敲,重复触发以及遗漏触发的情况,个人感觉还是十分满意哈~

键帽

Emmmmm… 一开始并没有入 HHKB 的官方彩色键帽套装,理由是贵… 而且怕自己用不惯的话不方便退货。

不过在连续敲了两天键盘之后,感觉手感真的是玄学啊~ 慢慢的竟然有点喜欢 HHKB 这种静电容轴的手感了,尽管目前仍然认为不及我的大 F 青轴,但是并不排斥~

终于在网上看了很多搭配了 HHKB 官方彩色键帽的毒图之后,下单入手!

嘛~ 头图就是搭配了彩色键帽之后的 HHKB (^U^)ノ~YO

总结

  • HHKB 键盘配列不太适合 Windows 用户,不过非常适合 Mac OS X 下使用,一但养成肌肉记忆将会非常方便。
  • HHKB 手感偏绵软,给我个人的感觉还算比较舒服,但是不及大 F 青轴的手感。
  • HHKB Pro BT 蓝牙连接使用体验极好,没有遗漏以及重复触发按键的情况出现。
  • 彩色键帽真的是可以瞬间提升 HHKB 极具内敛的颜值。

最后放一下已经服役了将近 3 年的大 F 和新伙伴的合影。

巧用 Objective-C Class Properties 解耦

前言

Emmmmm… Objective-C Class Properties 早在 WWDC 2016 中就已经公示,给 Objective-C 加入这个特性主要是为了与 Swift 类型属性相互操作。

官方是这么说明的:

Interoperate with Swift type properties.

嘛~ 虽然是为了配合 Swift 加入的新特性,不过聊胜于无哈!

Note: 值得一提的是 Objective-C Class Properties 语法特性虽然是 WWDC 2016 加入的,不过由于是 Xcode 8 中 LLVM Compiler 的特性,因此也适用于 iOS 10 之前的部署版本哟~

索引

  • LLVM
  • Objective-C Class Properties
  • 解耦
  • 总结

LLVM

LLVM 官网 对于 LLVM 的定义:

Note: The LLVM Project is a collection of modular and reusable compiler and toolchain technologies.

Emmmmm… 有趣的是,有的文章把 LLVM 强行展开为 “low level virtual machine” 译为 “低级别虚拟机”,不过在 LLVM 官网 可以看到官方明示 LLVM 与传统的虚拟机没有一毛钱关系,名称 “LLVM” 本身不是缩写,它仅仅是项目的名称而已~

嘛~ 可能有的同学不能理解为何 LLVM 是一个编译器工具链集合?这就要从 Apple 的编译器历史讲起咯~

很久很久以前… 算了,我感觉要跑题了(囧),这里简单列一下 Apple 采用过的编译方案吧:

  • GCC
  • LLVM & GCC
  • LLVM Compiler

GCC

GCC, the GNU Compiler Collection 是一套由 GNU 开发的编程语言编译器,最初作为 GNU 操作系统 的编译器使用,后面发展成为类 Unix 操作系统以及 Apple Mac OS X 操作系统的标准编译器。

原本 GCC 仅能处理 C 语言的编译,不过 GCC 很快扩展以支持 C++,之后的 GCC 越发全面,支持 Objective-C,Fortran,Ada,以及 Go 语言。

值得一提的是 GCC 是一套以 GPL 以及 LGPL 许可证锁发行的 100% 自由软件,这意味着用户可以自由地运行,拷贝,分发,学习,修改并改进该软件

LLVM & GCC

LLVM 我们前面介绍过了,是模块化 & 可重用性编译器以及工具链技术集合。

LLVM 能够进行程序语言的 编译期优化、链接优化、在线编译优化、代码生成

LLVM Compiler

前面介绍过 GCC 支持很多语言,系统架构庞大而笨重,而 Apple 大量使用的 Objective-C 在 GCC 中顺位(优先级)较低。此外,GCC 作为一个纯粹的编译系统,在与 IDE 配合方面的表现也很差。

So,Apple 决定从零开始写 C,C++,Objective-C 的编译器 Clang。

至此,Apple 彻底与 GCC 了断。

Objective-C Class Properties

Objective-C Class Properties 作为 Objective-C 新语法特性在 WWDC2016 What’s New in LLVM 中公示,表示 Xcode 8 之后可以使用这一新语法特性。

使用方式很简单:

  • Declared with class flag
  • Accessed with dot syntax
  • Never synthesized
  • Use @dynamic to defer to runtime

Declared with class flag

1
2
3
@interface MyType : NSObject
@property (class) NSString *someString;
@end

Accessed with dot syntax

1
NSLog(@"format string: %@", MyType.someString);

Never synthesized

1
2
3
4
5
@implementation MyType
static NSString *_someString = nil;
+ (NSString *)someString { return _someString; }
+ (void)setSomeString:(NSString *)newString { _someString = newString; }
@end

Use @dynamic to defer to runtime

1
2
3
4
5
6
@implementation MyType
@dynamic (class) someString;
+ (BOOL)resolveClassMethod:(SEL) name {
...
}
@end

解耦

笔者在做项目组件下沉时,遇到一个问题,正好适用于 Objective-C Class Properties 发挥:将要下沉的组件库中某系统类 Categroy 引用了业务层某方法。

业务层应该依赖于将要下沉的组件,而组件既然要下沉就不应该再反过来依赖上层业务实现!

按照常规思路,想要把上层业务中被依赖的部分一起随组件下沉,但是发现被依赖的部分虽然也属于一个较为基础的模块,不过此模块现阶段不做下沉…

后来经过组内大佬指点,使用 Objective-C Class Properties 解决了这个问题,即将上层业务被依赖的部分化作将要下沉组件依赖方系统类 Categroy 的 Class Properties。

Note: 在 Categroy 中写 Objective-C Class Properties 需要使用 Runtime 关联方法。

总结

  • 介绍了 LLVM 顺便提到了 Apple 的编译系统发展简史。
  • 使用官方 Demo 简单介绍了 Objective-C Class Properties 语法特性的书写方式。
  • 提供了一种巧妙使用 Objective-C Class Properties 解耦的思路。

文章写得比较用心(是我个人的原创文章,转载请注明 https://lision.me/),如果发现错误会优先在我的 个人博客 中更新。如果有任何问题欢迎在我的微博 @Lision 联系我~

希望我的文章可以为你带来价值~

嘛~ 又一年了啊...

前言

Emmmmm… 不知不觉已经一个多月没有提笔写文章了。

年前的一段时间确实比较忙,一方面想要尽可能的站好最后一班岗,把公司交给自己的事情做好;另一方面也在寻找新的机会,幸运的是在这一过程中得到了很多牛人的赏识和认可,获得了一些帮助以及内推机会,真的很感恩。

这次看机会都是在比较知名的项目或大厂中寻找的,前前后后忙碌了一个多月的时间,分别面试了头条,知乎,美团·点评,新浪,阿里巴巴这 5 家公司。

比较遗憾的是由于面试头条的时间点在 1 月 初,清楚记得元旦时期自己的三天假期基本都投入到了个人开源项目 LSAnimator 的迭代中,准备不充分加上较长时间没有参加过正式面试导致最后没能通过,辜负了彬哥的内推(囧)。

值得庆幸的是后面几家公司都如愿拿到了 offer,尤其在拿到阿里巴巴口头 offer 之后的欣喜若狂仿佛就在昨天(笑),不过由于个人原因没能加入阿里巴巴,最后选择了美团·点评核心部门的 offer。

本来想趁着春招之际把自己年前的面试经历总结一下,写一篇面试攻略。转念一想自己的博客里面还是应该记录一些自己的想法,于是才有这一篇水文出来(笑)。

嘛~ 既然是水文,就不会有什么干货在后面粗线了。想了解上述公司面试流程以及面试题的同学可以就此打住,不用再往下看了,以免浪费时间哟~

索引

  • 迁徙
  • ELSEWHERE
  • 未来

迁徙

回顾 2017 年的大事,应该就是从深圳迁徙到北京了吧… 从 14 年毕业之后就去了深圳,这一待就是 3 年,不知不觉已经对那座年轻的城市有了一些感情,一下子回到北方竟还有些舍不得。

还记得刚毕业的那段日子,住在公司提供的应届生宿舍里,与其他小伙伴吃在一起,住在一起。随后从公司宿舍搬出来,拿着仅够维持在深圳生活的工资,过着紧张而迷茫的日子。拿出自己一年多攒下的钱买了人生中第一台 Mac 和 iPhone,开始自学 iOS 开发并且第一次跳槽。

后面又经历了许许多多的第一次,也包括这次迁徙。

嘛~ 毕竟是北方长大的孩子,初来北京的感觉就是亲切,这里的建筑,植物,小吃… 一切的一切都有着久违的亲切感。

ELSEWHERE

来北京的第一份工作是在皇城根儿旁边的胡同里,一如既往的创业公司(笑),貌似除了校招去的公司是比较大的上市公司之外,自从转了 iOS 开发之后的几次选择都选了创业公司…

当时清楚记得是 CTO 亲自面试,见面的第一印象就是很亲切和善,风度翩翩。聊了聊发现公司虽然是初创团队,但是每个人的素质和背景都很出众,尤其是研发团队的综合素质很高,我一个 211 垫底大学进去的时候不敢大声说话(笑)。

半年多的工作中,兢兢业业,生怕因为自己的失误拖了整个团队的后腿,好在一直没有出什么纰漏,上级对我的工作也比较满意,在里面与各位同事相处的也很愉快…

不过好事多磨,团队内部也发生过一些摩擦,带我一起去胡同里面小学打球的两个大哥先后离职,最后我也由于种种原因离开了…

虽然相处时间算不上长,但是在 ELSEWHERE 的这段时间成长很多,与大家相处的也很愉快,特别是研发部的小伙伴们,现在还一直有来往呢。也许,下次搬家会考虑在一起合租一套大房子呢~

这里也被我命名为 ELSEWHERE,旨在纪念这段来北京后的第一份工作,也顺便祝福 ELSEWHERE 能杀出重围,越走越远吧…

未来

想自己毕业三年有余,却一事无成…

说出来自己都想笑,一直选择创业公司是因为想要改变世界…

自己当初在大学里拿到奖牌后吹过的牛逼还历历在目…

以前一直不懂为什么社会是个大染缸,现在身上已经被沾染上一些社会的颜色之后才明白这个比喻是多么的贴切而又无可奈何。时间也确实是一把利器,这才三年多的光景,就把梦想越磨越小,小到不见…

对自己说,也许现在我还没有创业的资本,等日后多些积累定要卷土重来…

对自己说,也许去大厂并非只是做一颗螺丝钉,毕竟用户量大,自己做的事情即使再小再微不足道,都是可以对用户端有些许影响的,从某种意义上讲也实现了改变人们的生活,改变了世界…

以后的事,谁又能说的准呢?只希望自己可以变得越来越强…

从 Aspects 源码中我学到了什么?

前言

AOP (Aspect-oriented programming) 译为 “面向切面编程”,是通过预编译方式和运行期动态代理实现程序功能统一维护的一种技术。利用 AOP 可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。

Emmmmm…AOP 目前是较为热门的一个话题,尽管你也许没有听说过它,但是你的项目中可能已经渗入了它,例如:用户统计(不添加一行代码即实现对所有 ViewController 的跟踪日志)。

对于 iOS 开发者而言,无外乎 Swift 和 Objective-C 两种主流开发语言:

  • Swift 受限于 ABI 尚未稳定,动态性依赖 dynamic 修饰符,在 Runtime 没有留给我们太多的发挥空间(前几日新增了 swift-5.0-branch 分支,写这篇文章时看了一眼 181 commits behind master 😂)。
  • Objective-C 在动态性上相对 Swift 具有无限大的优势,这几年 Objective-C Runtime 相关文章多如牛毛,相信现在的 iOSer 都具备一定的 Runtime 相关知识。

Aspects 作为 Objective-C 语言编写的 AOP 库,适用于 iOS 和 Mac OS X,使用体验简单愉快,已经在 GitHub 摘得 5k+ Star。Aspects 内部实现比较健全,考虑到了 Hook 安全方面可能发生的种种问题,非常值得我们学习。

Note: 本文内引用 Aspects 源码版本为 v1.4.2,要求读者具备一定的 Runtime 知识。

索引

  • AOP 简介
  • Aspects 简介
  • Aspects 结构剖析
  • Aspects 核心代码剖析
  • 优秀 AOP 库应该具备的特质
  • 总结

AOP 简介

运行时,动态地将代码切入到类的指定方法、指定位置上的编程思想就是面向切面的编程。

AOP (Aspect-oriented programming),即 “面向切面编程” 是一种编程范式,或者说是一种编程思想,它解决了 OOP (Object-oriented programming) 的延伸问题。

什么时候需要使用 AOP

光是给个概念可能初次接触 AOP 的人还是无法 Get 到其中微秒,拿我们前言中举的例子🌰,假设随着我们所在的公司逐步发展,之前第三方的用户页面统计已经不能满足需求了,公司要求实现一个我们自己的用户页面统计。

嘛~ 我们来理一下 OOP 思想下该怎么办?

  • 一个熟悉 OOP 思想的程序猿会理所应当的想到要把用户页面统计这一任务放到 ViewController 中;
  • 考虑到一个个的手动添加统计代码要死人(而且还会漏,以后新增 ViewController 也要手动加),于是想到了 OOP 思想中的继承;
  • 不巧由于项目久远,所有的 ViewController 都是直接继承自系统类 UIViewController(笑),此时选择抽一个项目 RootViewController,替换所有 ViewController 继承 RootViewController;
  • 然后在 RootViewController 的 viewWillAppear:viewWillDisappear: 方法加入时间统计代码,记录 ViewController 以及 Router 传参。

你会想,明明 OOP 也能解决问题是不是?不要急,再假设你们公司有多个 App,你被抽调至基础技术组专门给这些 App 写通用组件,要把之前实现过的用户页面统计重新以通用的形式实现,提供给你们公司所有的 App 使用。

MMP,使用标准 OOP 思想貌似无解啊…这个时候就是 AOP 的用武之地了。

这里简单给个思路:Hook UIViewController 的 viewWillAppear:viewWillDisappear: 方法,在原方法执行之后记录需要统计的信息上报即可。

Note: 简单通过 Method Swizzling 来 Hook 不是不可以,但是有很多安全隐患!

Aspects 简介

Aspects 是一个使用起来简单愉快的 AOP 库,使用 Objective-C 编写,适用于 iOS 与 Mac OS X。

Aspects 内部实现考虑到了很多 Hook 可能引发的问题,笔者在看源码的过程中抠的比较细,真的是受益匪浅。

Aspects 简单易用,作者通过在 NSObject (Aspects) 分类中暴露出的两个接口分别提供了对实例和 Class 的 Hook 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
@interface NSObject (Aspects)

+ (id<AspectToken>)aspect_hookSelector:(SEL)selector
withOptions:(AspectOptions)options
usingBlock:(id)block
error:(NSError **)error;

- (id<AspectToken>)aspect_hookSelector:(SEL)selector
withOptions:(AspectOptions)options
usingBlock:(id)block
error:(NSError **)error;

@end

Aspects 支持实例 Hook,相较其他 Objective-C AOP 库而言可操作粒度更小,适合的场景更加多样化。作为使用者无需进行更多的操作即可 Hook 指定实例或者 Class 的指定 SEL,AspectOptions 参数可以指定 Hook 的点,以及是否执行一次之后就撤销 Hook。

Aspects 结构剖析

Emmmmm…尽管 Aspects 只有不到千行的源码,但是其内部实现考虑到了很多 Hook 相关的安全问题和其他细节,对比其他 Objective-C AOP 开源项目来说 Aspects 更为健全,所以我自己在扒 Aspects 源码时也看的比较仔细。

Aspects 内部结构

Aspects 内部定义了两个协议:

  • AspectToken - 用于注销 Hook
  • AspectInfo - 嵌入 Hook 中的 Block 首位参数

此外 Aspects 内部还定义了 4 个类:

  • AspectInfo - 切面信息,遵循 AspectInfo 协议
  • AspectIdentifier - 切面 ID,应该遵循 AspectToken 协议(作者漏掉了,已提 PR)
  • AspectsContainer - 切面容器
  • AspectTracker - 切面跟踪器

以及一个结构体:

  • AspectBlockRef - 即 _AspectBlock,充当内部 Block

如果你扒一遍源码,还会发现两个内部静态全局变量:

  • static NSMutableDictionary *swizzledClassesDict;
  • static NSMutableSet *swizzledClasses;

现在你也许还不能理解为什么要定义这么多东西,别急~ 我们后面都会分析到。

Aspects 协议

按照上面列出的顺序,先来介绍一些 Aspects 声明的协议。

AspectToken

AspectToken 协议旨在让使用者可以灵活的注销之前添加过的 Hook,内部规定遵守此协议的对象须实现 remove 方法。

1
2
3
4
5
6
7
8
/// 不透明的 Aspect Token,用于注销 Hook
@protocol AspectToken <NSObject>

/// 注销一个 aspect.
/// 返回 YES 表示注销成功,否则返回 NO
- (BOOL)remove;

@end

AspectInfo

AspectInfo 协议旨在规范对一个切面,即 aspect 的 Hook 内部信息的纰漏,我们在 Hook 时添加切面的 Block 第一个参数就遵守此协议。

1
2
3
4
5
6
7
8
9
10
11
12
13
/// AspectInfo 协议是我们块语法的第一个参数。
@protocol AspectInfo <NSObject>

/// 当前被 Hook 的实例
- (id)instance;

/// 被 Hook 方法的原始 invocation
- (NSInvocation *)originalInvocation;

/// 所有方法参数(装箱之后的)惰性执行
- (NSArray *)arguments;

@end

Note: 装箱是一个开销昂贵操作,所以用到再去执行。

Aspects 内部类

接着协议,我们下面详细介绍一下 Aspects 的内部类。

AspectInfo

Note: AspectInfo 在这里是一个 Class,其遵守上文中讲到的 AspectInfo 协议,不要混淆。

AspectInfo 类定义:

1
2
3
4
5
6
7
8
9
@interface AspectInfo : NSObject <AspectInfo>

- (id)initWithInstance:(__unsafe_unretained id)instance invocation:(NSInvocation *)invocation;

@property (nonatomic, unsafe_unretained, readonly) id instance;
@property (nonatomic, strong, readonly) NSArray *arguments;
@property (nonatomic, strong, readonly) NSInvocation *originalInvocation;

@end

Note: 关于装箱,对于提供一个 NSInvocation 就可以拿到其 arguments 这一点上,ReactiveCocoa 团队提供了很大贡献(细节见 Aspects 内部 NSInvocation 分类)。

AspectInfo 比较简单,参考 ReactiveCocoa 团队提供的 NSInvocation 参数通用方法可将参数装箱为 NSValue,简单来说 AspectInfo 扮演了一个提供 Hook 信息的角色。

AspectIdentifier

AspectIdentifier 类定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
@interface AspectIdentifier : NSObject

+ (instancetype)identifierWithSelector:(SEL)selector object:(id)object options:(AspectOptions)options block:(id)block error:(NSError **)error;

- (BOOL)invokeWithInfo:(id<AspectInfo>)info;

@property (nonatomic, assign) SEL selector;
@property (nonatomic, strong) id block;
@property (nonatomic, strong) NSMethodSignature *blockSignature;
@property (nonatomic, weak) id object;
@property (nonatomic, assign) AspectOptions options;

@end

Note: AspectIdentifier 实际上是添加切面的 Block 的第一个参数,其应该遵循 AspectToken 协议,事实上也的确如此,其提供了 remove 方法的实现。

AspectIdentifier 内部需要注意的是由于使用 Block 来写 Hook 中我们加的料,这里生成了 blockSignature,在 AspectIdentifier 初始化的过程中会去判断 blockSignature 与入参 objectselector 得到的 methodSignature 的兼容性,兼容性判断成功才会顺利初始化。

AspectsContainer

AspectsContainer 类定义:

1
2
3
4
5
6
7
8
9
10
11
@interface AspectsContainer : NSObject

- (void)addAspect:(AspectIdentifier *)aspect withOptions:(AspectOptions)injectPosition;
- (BOOL)removeAspect:(id)aspect;
- (BOOL)hasAspects;

@property (atomic, copy) NSArray *beforeAspects;
@property (atomic, copy) NSArray *insteadAspects;
@property (atomic, copy) NSArray *afterAspects;

@end

AspectsContainer 作为切面的容器类,关联指定对象的指定方法,内部有三个切面队列,分别容纳关联指定对象的指定方法中相对应 AspectOption 的 Hook:

  • NSArray *beforeAspects; - AspectPositionBefore
  • NSArray *insteadAspects; - AspectPositionInstead
  • NSArray *afterAspects; - AspectPositionAfter

为什么要说关联呢?因为 AspectsContainer 是在 NSObject 分类中通过 AssociatedObject 方法与当前要 Hook 的目标关联在一起的。

Note: 关联目标是 Hook 之后的 Selector,即 aliasSelector(原始 SEL 名称加 aspects_ 前缀对应的 SEL)。

AspectTracker

AspectTracker 类定义:

1
2
3
4
5
6
7
8
9
@interface AspectTracker : NSObject

- (id)initWithTrackedClass:(Class)trackedClass parent:(AspectTracker *)parent;

@property (nonatomic, strong) Class trackedClass;
@property (nonatomic, strong) NSMutableSet *selectorNames;
@property (nonatomic, weak) AspectTracker *parentEntry;

@end

AspectTracker 作为切面追踪器,原理大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Add the selector as being modified.
currentClass = klass;
AspectTracker *parentTracker = nil;
do {
AspectTracker *tracker = swizzledClassesDict[currentClass];
if (!tracker) {
tracker = [[AspectTracker alloc] initWithTrackedClass:currentClass parent:parentTracker];
swizzledClassesDict[(id<NSCopying>)currentClass] = tracker;
}
[tracker.selectorNames addObject:selectorName];
// All superclasses get marked as having a subclass that is modified.
parentTracker = tracker;
}while ((currentClass = class_getSuperclass(currentClass)));

Note: 聪明的你应该已经注意到了全局变量 swizzledClassesDict 中的 value 对应着 AspectTracker 指针。

嘛~ 就是说 AspectTracker 是从下而上追踪,最底层的 parentEntrynil,父类的 parentEntry 为子类的 tracker

Aspects 内部结构体

AspectBlockRef

AspectBlockRef,即 struct _AspectBlock,其定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct _AspectBlock {
__unused Class isa;
AspectBlockFlags flags;
__unused int reserved;
void (__unused *invoke)(struct _AspectBlock *block, ...);
struct {
unsigned long int reserved;
unsigned long int size;
// requires AspectBlockFlagsHasCopyDisposeHelpers
void (*copy)(void *dst, const void *src);
void (*dispose)(const void *);
// requires AspectBlockFlagsHasSignature
const char *signature;
const char *layout;
} *descriptor;
// imported variables
} *AspectBlockRef;

Emmmmm…没什么特别的,大家应该比较眼熟吧。

Note: __unused 宏定义实际上是 __attribute__((unused)) GCC 定语,旨在告诉编译器“如果我没有在后面使用到这个变量也别警告我”。

嘛~ 想起之前自己挖的坑还没有填,事实上自己也不知道什么时候填(笑):

  • 之前挖坑说要写一篇文章记录一些阅读源码时发现的代码书写技巧
  • 之前挖坑说要封装一个 WKWebView 给群里的兄弟参考

不要急~ 你瞧伦家不是都记得嘛(至于什么时候填坑嘛就…咳咳)

Aspects 静态全局变量

static NSMutableDictionary *swizzledClassesDict;

static NSMutableDictionary *swizzledClassesDict; 在 Aspects 中扮演着已混写类字典的角色,其内部结构应该是这样的:

1
<Class : AspectTracker *>

Aspects 内部提供了专门访问这个全局字典的方法:

1
2
3
4
5
6
7
8
static NSMutableDictionary *aspect_getSwizzledClassesDict() {
static NSMutableDictionary *swizzledClassesDict;
static dispatch_once_t pred;
dispatch_once(&pred, ^{
swizzledClassesDict = [NSMutableDictionary new];
});
return swizzledClassesDict;
}

这个全局变量可以简单理解为记录整个 Hook 影响的 Class 包含其 SuperClass 的追踪记录的全局字典。

static NSMutableSet *swizzledClasses;

static NSMutableSet *swizzledClasses; 在 Aspects 中担当记录已混写类的角色,其内部结构如下:

1
<NSStringFromClass(Class)>

Aspects 内部提供一个用于修改这个全局变量内容的方法:

1
2
3
4
5
6
7
8
9
10
static void _aspect_modifySwizzledClasses(void (^block)(NSMutableSet *swizzledClasses)) {
static NSMutableSet *swizzledClasses;
static dispatch_once_t pred;
dispatch_once(&pred, ^{
swizzledClasses = [NSMutableSet new];
});
@synchronized(swizzledClasses) {
block(swizzledClasses);
}
}

Note: 注意 @synchronized(swizzledClasses)

这个全局变量记录了 forwardInvocation: 被混写的的类名称。

Note: 注意在用途上与 static NSMutableDictionary *swizzledClassesDict; 区分理解。

Aspects 核心代码剖析

嘛~ Aspects 的整体实现代码不超过一千行,而且考虑的情况也比较全面,非常值得大家花时间去读一下,这里我只准备给出自己对其核心代码的理解。

Hook Class && Hook Instance

Aspects 不光支持 Hook Class 还支持 Hook Instance,这提供了更小粒度的控制,配合 Hook 的撤销功能可以更加灵活精准的做我们想做的事~

Aspects 为了能区别 Class 和 Instance 的逻辑,实现了名为 aspect_hookClass 的方法,我认为其中的实现值得我用一部分篇幅来单独讲解,也觉得读者们有必要花点时间理解这里的实现逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
static Class aspect_hookClass(NSObject *self, NSError **error) {
// 断言 self
NSCParameterAssert(self);
// class
Class statedClass = self.class;
// isa
Class baseClass = object_getClass(self);
NSString *className = NSStringFromClass(baseClass);

// 已经子类化过了
if ([className hasSuffix:AspectsSubclassSuffix]) {
return baseClass;
// 我们混写了一个 class 对象,而非一个单独的 object
}else if (class_isMetaClass(baseClass)) {
// baseClass 是元类,则 self 是 Class 或 MetaClass,混写 self
return aspect_swizzleClassInPlace((Class)self);
// 可能是一个 KVO'ed class。混写就位。也要混写 meta classes。
}else if (statedClass != baseClass) {
// 当 .class 和 isa 指向不同的情况,混写 baseClass
return aspect_swizzleClassInPlace(baseClass);
}

// 默认情况下,动态创建子类
// 拼接子类后缀 AspectsSubclassSuffix
const char *subclassName = [className stringByAppendingString:AspectsSubclassSuffix].UTF8String;
// 尝试用拼接后缀的名称获取 isa
Class subclass = objc_getClass(subclassName);

// 找不到 isa,代表还没有动态创建过这个子类
if (subclass == nil) {
// 创建一个 class pair,baseClass 作为新类的 superClass,类名为 subclassName
subclass = objc_allocateClassPair(baseClass, subclassName, 0);
if (subclass == nil) { // 返回 nil,即创建失败
NSString *errrorDesc = [NSString stringWithFormat:@"objc_allocateClassPair failed to allocate class %s.", subclassName];
AspectError(AspectErrorFailedToAllocateClassPair, errrorDesc);
return nil;
}

// 混写 forwardInvocation:
aspect_swizzleForwardInvocation(subclass);
// subClass.class = statedClass
aspect_hookedGetClass(subclass, statedClass);
// subClass.isa.class = statedClass
aspect_hookedGetClass(object_getClass(subclass), statedClass);
// 注册新类
objc_registerClassPair(subclass);
}

// 覆盖 isa
object_setClass(self, subclass);
return subclass;
}

Note: 其实这里的难点就在于对 .classobject_getClass 的区分。

  • .class 当 target 是 Instance 则返回 Class,当 target 是 Class 则返回自身
  • object_getClass 返回 isa 指针的指向

Note: 动态创建一个 Class 的完整步骤也是我们应该注意的。

  • objc_allocateClassPair
  • class_addMethod
  • class_addIvar
  • objc_registerClassPair

嘛~ 难点和重点都讲完了,大家结合注释理解其中的逻辑应该没什么困难了,有什么问题可以找我一起交流~

Hook 的实现

在上面 aspect_hookClass 方法中,不仅仅是返回一个要 Hook 的 Class,期间还做了一些细节操作,不论是 Class 还是 Instance,都会调用 aspect_swizzleForwardInvocation 方法,这个方法没什么难点,简单贴一下代码让大家有个印象:

1
2
3
4
5
6
7
8
9
10
11
12
static void aspect_swizzleForwardInvocation(Class klass) {
// 断言 klass
NSCParameterAssert(klass);
// 如果没有 method,replace 实际上会像是 class_addMethod 一样
IMP originalImplementation = class_replaceMethod(klass, @selector(forwardInvocation:), (IMP)__ASPECTS_ARE_BEING_CALLED__, "v@:@");
// 拿到 originalImplementation 证明是 replace 而不是 add,情况少见
if (originalImplementation) {
// 添加 AspectsForwardInvocationSelectorName 的方法,IMP 为原生 forwardInvocation:
class_addMethod(klass, NSSelectorFromString(AspectsForwardInvocationSelectorName), originalImplementation, "v@:@");
}
AspectLog(@"Aspects: %@ is now aspect aware.", NSStringFromClass(klass));
}

上面的方法就是把要 Hook 的目标 Class 的 forwardInvocation: 混写了,混写之后 forwardInvocation: 的具体实现在 __ASPECTS_ARE_BEING_CALLED__ 中,里面能看到 invoke 标识位的不同是如何实现的,还有一些其他的实现细节:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// 宏定义,以便于我们有一个更明晰的 stack trace
#define aspect_invoke(aspects, info) \
for (AspectIdentifier *aspect in aspects) {\
[aspect invokeWithInfo:info];\
if (aspect.options & AspectOptionAutomaticRemoval) { \
aspectsToRemove = [aspectsToRemove?:@[] arrayByAddingObject:aspect]; \
} \
}

static void __ASPECTS_ARE_BEING_CALLED__(__unsafe_unretained NSObject *self, SEL selector, NSInvocation *invocation) {
// __unsafe_unretained NSObject *self 不解释了
// 断言 self, invocation
NSCParameterAssert(self);
NSCParameterAssert(invocation);
// 从 invocation 可以拿到很多东西,比如 originalSelector
SEL originalSelector = invocation.selector;
// originalSelector 加前缀得到 aliasSelector
SEL aliasSelector = aspect_aliasForSelector(invocation.selector);
// 用 aliasSelector 替换 invocation.selector
invocation.selector = aliasSelector;

// Instance 的容器
AspectsContainer *objectContainer = objc_getAssociatedObject(self, aliasSelector);
// Class 的容器
AspectsContainer *classContainer = aspect_getContainerForClass(object_getClass(self), aliasSelector);
AspectInfo *info = [[AspectInfo alloc] initWithInstance:self invocation:invocation];
NSArray *aspectsToRemove = nil;

// Before hooks.
aspect_invoke(classContainer.beforeAspects, info);
aspect_invoke(objectContainer.beforeAspects, info);

// Instead hooks.
BOOL respondsToAlias = YES;
if (objectContainer.insteadAspects.count || classContainer.insteadAspects.count) {
// 如果有任何 insteadAspects 就直接替换了
aspect_invoke(classContainer.insteadAspects, info);
aspect_invoke(objectContainer.insteadAspects, info);
}else { // 否则正常执行
// 遍历 invocation.target 及其 superClass 找到实例可以响应 aliasSelector 的点 invoke
Class klass = object_getClass(invocation.target);
do {
if ((respondsToAlias = [klass instancesRespondToSelector:aliasSelector])) {
[invocation invoke];
break;
}
}while (!respondsToAlias && (klass = class_getSuperclass(klass)));
}

// After hooks.
aspect_invoke(classContainer.afterAspects, info);
aspect_invoke(objectContainer.afterAspects, info);

// 如果没有 hook,则执行原始实现(通常会抛出异常)
if (!respondsToAlias) {
invocation.selector = originalSelector;
SEL originalForwardInvocationSEL = NSSelectorFromString(AspectsForwardInvocationSelectorName);
// 如果可以响应 originalForwardInvocationSEL,表示之前是 replace method 而非 add method
if ([self respondsToSelector:originalForwardInvocationSEL]) {
((void( *)(id, SEL, NSInvocation *))objc_msgSend)(self, originalForwardInvocationSEL, invocation);
}else {
[self doesNotRecognizeSelector:invocation.selector];
}
}

// 移除 aspectsToRemove 队列中的 AspectIdentifier,执行 remove
[aspectsToRemove makeObjectsPerformSelector:@selector(remove)];
}
#undef aspect_invoke

Note: aspect_invoke 宏定义的作用域。

  • 代码实现对应了 Hook 的 AspectOptions 参数的 Before,Instead 和 After。
  • aspect_invokeaspectsToRemove 是一个 NSArray,里面容纳着需要被销户的 Hook,即 AspectIdentifier(之后会调用 remove 移除)。
  • 遍历 invocation.target 及其 superClass 找到实例可以响应 aliasSelector 的点 invoke 实现代码。

Block Hook

Aspects 让我们在指定 Class 或 Instance 的特定 Selector 执行时,根据 AspectOptions 插入我们自己的 Block 做 Hook,而这个 Block 内部有我们想要的有关于当前 Target 和 Selector 的信息,我们来看一下 Aspects 是怎么办到的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
- (BOOL)invokeWithInfo:(id<AspectInfo>)info {
NSInvocation *blockInvocation = [NSInvocation invocationWithMethodSignature:self.blockSignature];
NSInvocation *originalInvocation = info.originalInvocation;
NSUInteger numberOfArguments = self.blockSignature.numberOfArguments;

// 偏执。我们已经在 hook 注册的时候检查过了,(不过这里我们还要检查)。
if (numberOfArguments > originalInvocation.methodSignature.numberOfArguments) {
AspectLogError(@"Block has too many arguments. Not calling %@", info);
return NO;
}

// block 的 `self` 将会是 AspectInfo。可选的。
if (numberOfArguments > 1) {
[blockInvocation setArgument:&info atIndex:1];
}

// 简历参数分配内存 argBuf 然后从 originalInvocation 取 argument 赋值给 blockInvocation
void *argBuf = NULL;
for (NSUInteger idx = 2; idx < numberOfArguments; idx++) {
const char *type = [originalInvocation.methodSignature getArgumentTypeAtIndex:idx];
NSUInteger argSize;
NSGetSizeAndAlignment(type, &argSize, NULL);

// reallocf 优点,如果创建内存失败会自动释放之前的内存,讲究
if (!(argBuf = reallocf(argBuf, argSize))) {
AspectLogError(@"Failed to allocate memory for block invocation.");
return NO;
}

[originalInvocation getArgument:argBuf atIndex:idx];
[blockInvocation setArgument:argBuf atIndex:idx];
}

// 执行
[blockInvocation invokeWithTarget:self.block];

// 释放 argBuf
if (argBuf != NULL) {
free(argBuf);
}
return YES;
}

考虑两个问题:

  • [blockInvocation setArgument:&info atIndex:1]; 为什么要在索引 1 处插入呢?
  • for (NSUInteger idx = 2; idx < numberOfArguments; idx++) 为什么要从索引 2 开始遍历参数呢?

嘛~ 如果你对 Block 的 Runtime 结构以及执行过程下断点研究一下就全都明白了,感兴趣的同学有疑问可以联系我(与真正勤奋好学的人交流又有谁会不乐意呢?笑~)

优秀 AOP 库应该具备的特质

  • 良好的使用体验
  • 可控粒度小
  • 使用 Block 做 Hook
  • 支持撤销 Hook
  • 安全性

良好的使用体验

Aspects 使用 NSObject + Categroy 的方式提供接口,非常巧妙的涵盖了 Instance 和 Class。

Aspects 提供的接口保持高度一致(本着易用,简单,方便的原则设计接口和整个框架的实现会让你的开源项目更容易被人们接纳和使用):

1
2
3
4
5
6
7
8
9
+ (id<AspectToken>)aspect_hookSelector:(SEL)selector
withOptions:(AspectOptions)options
usingBlock:(id)block
error:(NSError **)error;

- (id<AspectToken>)aspect_hookSelector:(SEL)selector
withOptions:(AspectOptions)options
usingBlock:(id)block
error:(NSError **)error;

Note: 其实接口这里对于 block 的参数自动补全可以更进一步,不过 Aspects 当初是没有办法做到的,单从接口设计这块已经很优秀了。

可控粒度小

Aspects 不仅支持大部分 AOP 框架应该做到的对于 Class 的 Hook,还支持粒度更小的 Instance Hook,而其在内部实现中为了支持 Instance Hook 所做的代码也非常值得我们参考和学习(已在上文 Aspects 核心代码剖析 处单独分析)。

为使用者提供更为自由的 Hook 方式以达到更加精准的控制是每个使用者乐于见到的事。

使用 Block 做 Hook

Aspects 使用 Block 来做 Hook 应该考虑到了很多东西,支持使用者通过在 Block 中获取到相关的信息,书写自己额外的操作就可以实现 Hook 需求。

支持撤销 Hook

Aspects 还支持撤销之前做的 Hook 以及已混写的 Method,为了实现这个功能 Aspects 设计了全局容器,把 Hook 和混写用全局容器做记录,让一切都可以复原,这不正是我们想要的吗?

安全性

嘛~ 我们在学习 Runtime 的时候,就应该看到过不少文章讲解 Method Swizzling 要注意的安全性问题,由于用到了大量 Runtime 方法,加上 AOP 是面向整个切面的,所以一单发现问题就会比较严重,涉及的面会比较广,而且难以调试。

Note: 我们不能因为容易造成问题就可以回避 Method Swizzling,就好比大学老师讲到递归时强调容易引起循环调用,很多人就在内心回避使用递归,甚至于非常适合使用递归来写的算法题(这里指递归来写会易读写、易维护)只会用复杂的方式来思考。

总结

  • 文章简单介绍了 AOP 的概念,希望能给各位读者对 AOP 思想的理解提供微薄的帮助。
  • 文章系统的剖析了 Aspects 开源库的内部结构,希望能让大家在浏览 Aspects 源码时快速定位代码位置,找到核心内容。
  • 文章重点分析了 Aspects 的核心代码,提炼了一些笔者认为值得注意的点,但愿可以在大家扒源码时提供一些指引。
  • 文章结尾总结了 Aspects 作为一个比较优秀的 AOP 所具备的一些特质。

文章写得比较用心(是我个人的原创文章,转载请注明 https://lision.me/),如果发现错误会优先在我的 个人博客 中更新。如果有任何问题欢迎在我的微博 @Lision 联系我~

希望我的文章可以为你带来价值~

深入剖析 WebViewJavascriptBridge

Emmmmm…这篇文章发布出来可能正逢圣诞节🎄,Merry Christmas!

前言

Web 页面中的 JS 与 iOS Native 如何交互是每个 iOS 猿必须掌握的技能。而 JS 和 iOS Native 就好比两块没有交集的大陆,如果想要使它们相互通信就必须要建立一座“桥梁”。

思考一下,如果项目组让你去造这座“桥”,如何才能做到既优雅又实用?

本文将结合 WebViewJavascriptBridge 源码逐步带大家找到答案。

WebViewJavascriptBridge 是盛名已久的 JSBridge 库,早在 2011 年就被作者 Marcus Westin 发布到 GitHub,直到现在作者还在积极维护中,目前该项目已收获近 1w star 咯,其源码非常值得我们学习。

WebViewJavascriptBridge 的代码逻辑清晰,风格良好,加上自身代码量比较小使得其源码阅读非常轻松(可能需要一些 JS 基础)。更加难能可贵的是它仅使用了少量代码就实现了对于 Mac OS X 的 WebView 以及 iOS 平台的 UIWebView 和 WKWebView 三种组件的完美支持。

我对 WebViewJavascriptBridge 的评价是小而美,这类小而美的源码非常利于我们对其实现思想的学习(本文分析 WebViewJavascriptBridge 源码版本为 v6.0.3)。

关于 iOS 与 JS 的原生交互知识,之前我有写过一篇文章《iOS 与 JS 交互开发知识总结》,文章除了介绍 JavaScriptCore 库以及 UIWebView 和 WKWebView 与 JS 原生交互的方法之外还捎带提到了 Hybrid 的发展简史,文末还提供了一个 JS 通过 Native 调用 iOS 设备摄像头的 Demo

所以这篇文章不会再把重点放在 iOS 与 JS 的原生交互了,本文旨在介绍 WebViewJavascriptBridge 的设计思路和实现原理,对 iOS 与 JS 原生交互知识感兴趣的同学推荐去阅读上面提到的文章,应该会有点儿帮助(笑)。

索引

  • WebViewJavascriptBridge 简介
  • WebViewJavascriptBridge && WKWebViewJavascriptBridge 探究
  • WebViewJavascriptBridgeBase - JS 调用 Native 实现原理剖析
  • WebViewJavascriptBridge_JS - Native 调用 JS 实现解读
  • WebViewJavascriptBridge 的“桥梁美学”
  • 文章总结

WebViewJavascriptBridge 简介

WebViewJavascriptBridge 是用于在 WKWebView,UIWebView 和 WebView 中的 Obj-C 和 JavaScript 之间发送消息的 iOS / OSX 桥接器。

有许多不错的项目都有使用 WebViewJavascriptBridge,这里简单列一部分(笑):

关于 WebViewJavascriptBridge 的具体使用方法详见其 GitHub 页面

在读完 WebViewJavascriptBridge 的源码之后我将其划分为三个层级:

层级 源文件
接口层 WebViewJavascriptBridge && WKWebViewJavascriptBridge
实现层 WebViewJavascriptBridgeBase
JS 层 WebViewJavascriptBridge_JS

其中 WebViewJavascriptBridge && WKWebViewJavascriptBridge 作为接口层主要负责提供方便的接口,隐藏实现细节,其实现细节都是通过实现层 WebViewJavascriptBridgeBase 去做的,而 WebViewJavascriptBridge_JS 作为 JS 层其实存储了一段 JS 代码,在需要的时候注入到当前 WebView 组件中,最终实现 Native 与 JS 的交互。

WebViewJavascriptBridge && WKWebViewJavascriptBridge 探究

WebViewJavascriptBridge 和 WKWebViewJavascriptBridge 作为接口层分别对应于 UIWebView 和 WKWebView 组件,我们来简单看一下这两个文件暴露出的信息:

WebViewJavascriptBridge 暴露信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@interface WebViewJavascriptBridge : WVJB_WEBVIEW_DELEGATE_INTERFACE

+ (instancetype)bridgeForWebView:(id)webView; // 初始化
+ (instancetype)bridge:(id)webView; // 初始化

+ (void)enableLogging; // 开启日志
+ (void)setLogMaxLength:(int)length; // 设置日志最大长度

- (void)registerHandler:(NSString*)handlerName handler:(WVJBHandler)handler; // 注册 handler (Native)
- (void)removeHandler:(NSString*)handlerName; // 删除 handler (Native)
- (void)callHandler:(NSString*)handlerName data:(id)data responseCallback:(WVJBResponseCallback)responseCallback; // 调用 handler (JS)
- (void)setWebViewDelegate:(id)webViewDelegate; // 设置 webViewDelegate
- (void)disableJavscriptAlertBoxSafetyTimeout; // 禁用 JS AlertBox 的安全时长来加速消息传递,不推荐使用

@end

WKWebViewJavascriptBridge 暴露信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Emmmmm...这里应该不需要我注释了吧
@interface WKWebViewJavascriptBridge : NSObject<WKNavigationDelegate, WebViewJavascriptBridgeBaseDelegate>

+ (instancetype)bridgeForWebView:(WKWebView*)webView;
+ (void)enableLogging;

- (void)registerHandler:(NSString*)handlerName handler:(WVJBHandler)handler;
- (void)removeHandler:(NSString*)handlerName;
- (void)callHandler:(NSString*)handlerName data:(id)data responseCallback:(WVJBResponseCallback)responseCallback;
- (void)reset;
- (void)setWebViewDelegate:(id)webViewDelegate;
- (void)disableJavscriptAlertBoxSafetyTimeout;

@end

Note: disableJavscriptAlertBoxSafetyTimeout 方法是通过禁用 JS 端 AlertBox 的安全时长来加速网桥消息传递的。如果想使用那么需要和前端约定好,如果禁用之后前端 JS 代码仍有调用 AlertBox 相关代码(alert, confirm, 或 prompt)则程序将被挂起,所以这个方法是不安全的,如无特殊需求笔者不推荐使用。

可以看得出来这两个文件暴露出的接口几乎一致,其中 WebViewJavascriptBridge 中使用了宏定义 WVJB_WEBVIEW_DELEGATE_INTERFACE 来分别适配 iOS 和 Mac OS X 平台的 UIWebView 和 WebView 组件需要实现的代理方法。

WebViewJavascriptBridge 中的宏定义

其实 WebViewJavascriptBridge 中为了适配 iOS 和 Mac OS X 平台的 UIWebView 和 WebView 组件使用了一系列的宏定义,其源码比较简单:

1
2
3
4
5
6
7
8
9
10
11
12
#if defined __MAC_OS_X_VERSION_MAX_ALLOWED
#define WVJB_PLATFORM_OSX
#define WVJB_WEBVIEW_TYPE WebView
#define WVJB_WEBVIEW_DELEGATE_TYPE NSObject<WebViewJavascriptBridgeBaseDelegate>
#define WVJB_WEBVIEW_DELEGATE_INTERFACE NSObject<WebViewJavascriptBridgeBaseDelegate, WebPolicyDelegate>
#elif defined __IPHONE_OS_VERSION_MAX_ALLOWED
#import <UIKit/UIWebView.h>
#define WVJB_PLATFORM_IOS
#define WVJB_WEBVIEW_TYPE UIWebView
#define WVJB_WEBVIEW_DELEGATE_TYPE NSObject<UIWebViewDelegate>
#define WVJB_WEBVIEW_DELEGATE_INTERFACE NSObject<UIWebViewDelegate, WebViewJavascriptBridgeBaseDelegate>
#endif

分别根据所在平台不同定义了 WVJB_WEBVIEW_TYPEWVJB_WEBVIEW_DELEGATE_TYPE 以及刚才提到的 WVJB_WEBVIEW_DELEGATE_INTERFACE 宏定义,并且分别定义了 WVJB_PLATFORM_OSXWVJB_PLATFORM_IOS 便于之后的实现源码区分当前平台时使用,下面的 supportsWKWebView 宏定义也是同样的道理:

1
2
3
#if (__MAC_OS_X_VERSION_MAX_ALLOWED > __MAC_10_9 || __IPHONE_OS_VERSION_MAX_ALLOWED >= __IPHONE_7_1)
#define supportsWKWebView
#endif

在引入头文件的时候可以通过这个 supportsWKWebView 宏灵活引入所需的头文件:

1
2
3
4
5
6
7
8
9
// WebViewJavascriptBridge.h
#if defined supportsWKWebView
#import <WebKit/WebKit.h>
#endif

// WebViewJavascriptBridge.m
#if defined(supportsWKWebView)
#import "WKWebViewJavascriptBridge.h"
#endif

WebViewJavascriptBridge 的实现分析

我们接着看一下 WebViewJavascriptBridge 的实现部分,首先从内部变量信息看起:

1
2
3
4
5
6
7
8
9
10
11
12
#if __has_feature(objc_arc_weak)
#define WVJB_WEAK __weak
#else
#define WVJB_WEAK __unsafe_unretained
#endif

@implementation WebViewJavascriptBridge {
WVJB_WEAK WVJB_WEBVIEW_TYPE* _webView; // bridge 对应的 WebView 组件
WVJB_WEAK id _webViewDelegate; // 给 WebView 组件设置的代理(需要的话)
long _uniqueId; // 唯一标识,Emmmmm...但是我发现没卵用,只有 _base 中的 _uniqueId 才有用
WebViewJavascriptBridgeBase *_base; // 上文说过,底层实现其实都是 WebViewJavascriptBridgeBase 在做
}

上文提到 WebViewJavascriptBridge 和 WKWebViewJavascriptBridge 的 .h 文件暴露接口信息非常相似,那么我们要不要看看 WKWebViewJavascriptBridge 的内部变量信息呢?

1
2
3
4
5
6
7
// 注释参见 WebViewJavascriptBridge 就好
@implementation WKWebViewJavascriptBridge {
__weak WKWebView* _webView;
__weak id<WKNavigationDelegate> _webViewDelegate;
long _uniqueId;
WebViewJavascriptBridgeBase *_base;
}

嘛~ 这俩货简直是一个妈生的。其实这是作者故意为之,因为作者想对外提供一套接口,即 WebViewJavascriptBridge,我们只需要使用 WebViewJavascriptBridge 就可以自动根据绑定的 WebView 组件的不同生成与之对应的 JSBridge 实例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
+ (instancetype)bridge:(id)webView {
// 如果支持 WKWebView
#if defined supportsWKWebView
// 需要先判断当前入参 webView 是否从属于 WKWebView
if ([webView isKindOfClass:[WKWebView class]]) {
// 返回 WKWebViewJavascriptBridge 实例
return (WebViewJavascriptBridge*) [WKWebViewJavascriptBridge bridgeForWebView:webView];
}
#endif
// 判断当前入参 webView 是否从属于 WebView(Mac OS X)或者 UIWebView(iOS)
if ([webView isKindOfClass:[WVJB_WEBVIEW_TYPE class]]) {
// 返回 WebViewJavascriptBridge 实例
WebViewJavascriptBridge* bridge = [[self alloc] init];
[bridge _platformSpecificSetup:webView];
return bridge;
}

// 抛出 BadWebViewType 异常并返回 nil
[NSException raise:@"BadWebViewType" format:@"Unknown web view type."];
return nil;
}

我们可以看到上面的代码,实现并不复杂。如果支持 WKWebView 的话(#if defined supportsWKWebView)则去判断当前绑定的 WebView 组件是否从属于 WKWebView,这样可以返回 WKWebViewJavascriptBridge 实例,否则返回 WebViewJavascriptBridge 实例,最后如果入参 webView 的类型不满足判断条件则抛出 BadWebViewType 异常。

还有一个关于 _webViewDelegate 的小细节,本来不打算讲的,但是还是提一下吧(囧)。其实在 WebViewJavascriptBridge 以及 WKWebViewJavascriptBridge 的初始化实现过程中,会把当前 WebView 组件的代理绑定为自己:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// WebViewJavascriptBridge
- (void) _platformSpecificSetup:(WVJB_WEBVIEW_TYPE*)webView {
_webView = webView;
_webView.delegate = self;
_base = [[WebViewJavascriptBridgeBase alloc] init];
_base.delegate = self;
}

// WKWebViewJavascriptBridge
- (void) _setupInstance:(WKWebView*)webView {
_webView = webView;
_webView.navigationDelegate = self;
_base = [[WebViewJavascriptBridgeBase alloc] init];
_base.delegate = self;
}

Note: 替换组件的代理将其代理绑定为 bridge 自己是因为 WebViewJavascriptBridge 的实现原理上是利用我之前的文章《iOS 与 JS 交互开发知识总结》中讲过的假 Request 方法实现的,所以需要监听 WebView 组件的代理方法获取加载之前的 Request.URL 并做处理。这也是为什么 WebViewJavascriptBridge 提供了一个接口 setWebViewDelegate: 存储了一个逻辑上的 _webViewDelegate,这个 _webViewDelegate 也需要遵循 WebView 组件的代理协议,这样在 WebViewJavascriptBridge 内部不同的代理方法中做完 bridge 要做的事情只有就会再去调用 _webViewDelegate 对应的代理方法,其实可以理解为 WebViewJavascriptBridge 对当前 WebView 组件的代理做了 hook。

对于 WebViewJavascriptBridge 中暴露的初始化以外的所有接口,其内部实现都是通过 WebViewJavascriptBridgeBase 来实现的。这样做的好处就是即使 WebViewJavascriptBridge 因为绑定了 WKWebView 返回了 WKWebViewJavascriptBridge 实例,只要接口一致,对 JSBridge 发送相同的消息,就会有相同的实现(都是由 WebViewJavascriptBridgeBase 类实现的)

WebViewJavascriptBridgeBase - JS 调用 Native 实现原理剖析

作为 WebViewJavascriptBridge 的实现层,WebViewJavascriptBridgeBase 的命名也可以体现出其是作为整座“桥梁”桥墩一般的存在,我们还是按照老规矩先看一下 WebViewJavascriptBridgeBase.h 暴露的信息,好对其有一个整体的印象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
typedef void (^WVJBResponseCallback)(id responseData); // 回调 block
typedef void (^WVJBHandler)(id data, WVJBResponseCallback responseCallback); // 注册的 Handler block
typedef NSDictionary WVJBMessage; // 消息类型 - 字典

@protocol WebViewJavascriptBridgeBaseDelegate <NSObject>
- (NSString*) _evaluateJavascript:(NSString*)javascriptCommand;
@end

@interface WebViewJavascriptBridgeBase : NSObject

@property (weak, nonatomic) id <WebViewJavascriptBridgeBaseDelegate> delegate; // 代理,指向接口层类,用以给对应接口绑定的 WebView 组件发送执行 JS 消息
@property (strong, nonatomic) NSMutableArray* startupMessageQueue; // 启动消息队列,可以理解为存放 WVJBMessage
@property (strong, nonatomic) NSMutableDictionary* responseCallbacks; // 回调 blocks 字典,存放 WVJBResponseCallback 类型的 block
@property (strong, nonatomic) NSMutableDictionary* messageHandlers; // 已注册的 handlers 字典,存放 WVJBHandler 类型的 block
@property (strong, nonatomic) WVJBHandler messageHandler; // 没卵用

+ (void)enableLogging; // 开启日志
+ (void)setLogMaxLength:(int)length; // 设置日志最大长度
- (void)reset; // 对应 WKJSBridge 的 reset 接口
- (void)sendData:(id)data responseCallback:(WVJBResponseCallback)responseCallback handlerName:(NSString*)handlerName; // 发送消息,入参依次是参数,回调 block,对应 JS 端注册的 HandlerName
- (void)flushMessageQueue:(NSString *)messageQueueString; // 刷新消息队列,核心代码
- (void)injectJavascriptFile; // 注入 JS
- (BOOL)isWebViewJavascriptBridgeURL:(NSURL*)url; // 判定是否为 WebViewJavascriptBridgeURL
- (BOOL)isQueueMessageURL:(NSURL*)urll; // 判定是否为队列消息 URL
- (BOOL)isBridgeLoadedURL:(NSURL*)urll; // 判定是否为 bridge 载入 URL
- (void)logUnkownMessage:(NSURL*)url; // 打印收到未知消息信息
- (NSString *)webViewJavascriptCheckCommand; // JS bridge 检测命令
- (NSString *)webViewJavascriptFetchQueyCommand; // JS bridge 获取查询命令
- (void)disableJavscriptAlertBoxSafetyTimeout; // 禁用 JS AlertBox 安全时长以获取发送消息速度提升,不建议使用,理由见上文

@end

嘛~ 从 .h 文件中我们可以看到整个 WebViewJavascriptBridgeBase 所暴露出来的信息,属性层面上需要对以下 4 个属性加深印象,之后分析实现的过程中会带入这些属性:

  • id <WebViewJavascriptBridgeBaseDelegate> delegate 代理,可以通过代理让当前 bridge 绑定的 WebView 组件执行 JS 代码
  • NSMutableArray* startupMessageQueue; 启动消息队列,存放 Obj-C 发送给 JS 的消息(可以理解为存放 WVJBMessage 类型)
  • NSMutableDictionary* responseCallbacks; 回调 blocks 字典,存放 WVJBResponseCallback 类型的 block
  • NSMutableDictionary* messageHandlers; Obj-C 端已注册的 handlers 字典,存放 WVJBHandler 类型的 block

Emmmmm…接口层面看一下注释就好了,后面分析实现的时候会捎带讲解一些接口,剩下一些跟实现无关的接口内容感兴趣的同学推荐自己扒源码哈。

我们在对 WebViewJavascriptBridgeBase 整体有了一个初始印象之后就可以自己写一个页面,简单的嵌入一些 JS 跑一遍流程,在中间下断点扒源码,这样我们对于 Native 与 JS 的交互流程就可以一清二楚了。

下面模拟一遍 JS 通过 WebViewJavascriptBridge 调用 Native 功能的流程分析 WebViewJavascriptBridgeBase 的相关实现(考虑现在的时间点决定以 WKWebView 为例讲解,即针对 WKWebViewJavascriptBridge 源码讲解):

1.监听假 Request 并注入 WebViewJavascriptBridge_JS 内的 JS 代码

上文说到 WebViewJavascriptBridge 的实现其实本质上是利用了我之前的文章《iOS 与 JS 交互开发知识总结》中讲过的假 Request 方法实现的,那么我们就从监听假 Request 开始讲起吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// WKNavigationDelegate 协议方法,用于监听 Request 并决定是否允许导航
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
// webView 校验
if (webView != _webView) { return; }
NSURL *url = navigationAction.request.URL;
__strong typeof(_webViewDelegate) strongDelegate = _webViewDelegate;

// 核心代码
if ([_base isWebViewJavascriptBridgeURL:url]) { // 判定 WebViewJavascriptBridgeURL
if ([_base isBridgeLoadedURL:url]) { // 判定 BridgeLoadedURL
// 注入 JS 代码
[_base injectJavascriptFile];
} else if ([_base isQueueMessageURL:url]) { // 判定 QueueMessageURL
// 刷新消息队列
[self WKFlushMessageQueue];
} else {
// 记录未知 bridge msg 日志
[_base logUnkownMessage:url];
}
decisionHandler(WKNavigationActionPolicyCancel);
return;
}

// 调用 _webViewDelegate 对应的代理方法
if (strongDelegate && [strongDelegate respondsToSelector:@selector(webView:decidePolicyForNavigationAction:decisionHandler:)]) {
[_webViewDelegate webView:webView decidePolicyForNavigationAction:navigationAction decisionHandler:decisionHandler];
} else {
decisionHandler(WKNavigationActionPolicyAllow);
}
}

Note: 之前说过 WebViewJavascriptBridge 会 hook 绑定的 WebView 的代理方法,这一点 WKWebViewJavascriptBridge 也一样,在加入自己的代码之后会判断是否有 _webViewDelegate 响应这个代理方法,如果有则调用。

我们还是把注意力放到注释中核心代码的位置,里面会先判断当前 url 是否为 bridge url:

1
2
3
4
5
// 相关宏定义
#define kOldProtocolScheme @"wvjbscheme"
#define kNewProtocolScheme @"https"
#define kQueueHasMessage @"__wvjb_queue_message__"
#define kBridgeLoaded @"__bridge_loaded__"

WebViewJavascriptBridge GitHub 页面 的使用方法中第 4 步明确指出要复制粘贴 setupWebViewJavascriptBridge 方法到前段 JS 中,我们先来看一下这段 JS 方法源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function setupWebViewJavascriptBridge(callback) {
if (window.WebViewJavascriptBridge) { return callback(WebViewJavascriptBridge); }
if (window.WVJBCallbacks) { return window.WVJBCallbacks.push(callback); }
window.WVJBCallbacks = [callback];
// 创建一个 iframe
var WVJBIframe = document.createElement('iframe');
// 设置 iframe 为不显示
WVJBIframe.style.display = 'none';
// 将 iframe 的 src 置为 'https://__bridge_loaded__'
WVJBIframe.src = 'https://__bridge_loaded__';
// 将 iframe 加入到 document.documentElement
document.documentElement.appendChild(WVJBIframe);
setTimeout(function() { document.documentElement.removeChild(WVJBIframe) }, 0)
}

上面的代码创建了一个不显示的 iframe 并将其 src 置为 https://__bridge_loaded__,与上文中 kBridgeLoaded 宏定义一致,即用于 isBridgeLoadedURL: 方法中判定当前 url 是否为 BridgeLoadedURL。

Note: 假 Request 的发起有两种方式,-1:location.href -2:iframe。通过 location.href 有个问题,就是如果 JS 多次调用原生的方法也就是 location.href 的值多次变化,Native 端只能接受到最后一次请求,前面的请求会被忽略掉,所以这里 WebViewJavascriptBridge 选择使用 iframe,后面不再解释。

因为加入了 src 为 https://__bridge_loaded__ 的 iframe 元素,我们上面截获 url 的代理方法就会拿到一个 https://__bridge_loaded__ 的 url,由于 https 满足判定 WebViewJavascriptBridgeURL,将会进入核心代码区域接着会被判定为 BridgeLoadedURL 执行注入 JS 代码的方法,即 [_base injectJavascriptFile];

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void)injectJavascriptFile {
// 获取到 WebViewJavascriptBridge_JS 的代码
NSString *js = WebViewJavascriptBridge_js();
// 将获取到的 js 通过代理方法注入到当前绑定的 WebView 组件
[self _evaluateJavascript:js];
// 如果当前已有消息队列则遍历并分发消息,之后清空消息队列
if (self.startupMessageQueue) {
NSArray* queue = self.startupMessageQueue;
self.startupMessageQueue = nil;
for (id queuedMessage in queue) {
[self _dispatchMessage:queuedMessage];
}
}
}

至此,第一步交互已完成。关于 WebViewJavascriptBridge_JS 内部的 JS 代码我们放到后面的章节解读,现在可以简单理解为 WebViewJavascriptBridge 在 JS 端的具体实现代码。

2.JS 端调用 callHandler 方法之后 Native 端究竟是如何响应的?

WebViewJavascriptBridge GitHub 页面 中指出 JS 端的操作方式:

1
2
3
4
5
6
7
8
9
10
11
12
setupWebViewJavascriptBridge(function(bridge) {

/* Initialize your app here */

bridge.registerHandler('JS Echo', function(data, responseCallback) {
console.log("JS Echo called with:", data)
responseCallback(data)
})
bridge.callHandler('ObjC Echo', {'key':'value'}, function responseCallback(responseData) {
console.log("JS received response:", responseData)
})
})

我们知道 JS 端调用 setupWebViewJavascriptBridge 方法会走我们刚才分析过的第一步,即监听假 Request 并注入 WebViewJavascriptBridge_JS 内的 JS 代码。那么当 JS 端调用 bridge.callHandler 时,Native 端究竟是如何做出响应的呢?这里我们需要先稍微解读一下之前注入的 WebViewJavascriptBridge_JS 中的 JS 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 调用 iOS handler,参数校验之后调用 _doSend 函数
function callHandler(handlerName, data, responseCallback) {
if (arguments.length == 2 && typeof data == 'function') {
responseCallback = data;
data = null;
}
_doSend({ handlerName:handlerName, data:data }, responseCallback);
}

// 如有回调,则设置 message['callbackId'] 与 responseCallbacks[callbackId]
// 将 msg 加入 sendMessageQueue 数组,设置 messagingIframe.src
function _doSend(message, responseCallback) {
if (responseCallback) {
var callbackId = 'cb_'+(uniqueId++)+'_'+new Date().getTime();
responseCallbacks[callbackId] = responseCallback;
message['callbackId'] = callbackId;
}
sendMessageQueue.push(message);
messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
}

// scheme 使用 https 之后通过 host 做匹配
var CUSTOM_PROTOCOL_SCHEME = 'https';
var QUEUE_HAS_MESSAGE = '__wvjb_queue_message__';

可以看到 JS 端的代码中有 callHandler 函数的实现,其内部将入参 handlerName 以及 data 以字典形式作为参数调用 _doSend 方法,我们看一下 _doSend 方法的实现:

  • _doSend 方法内部会先判断入参中是否有回调
  • 如果有回调则根据规则生成 callbackId 并且将回调 block 保存到 responseCallbacks 字典(囧~ JS 不叫字典的,我是为了 iOS 读者看着方便),之后给消息也加入一个键值对保存刚才生成的 callbackId
  • 之后给 sendMessageQueue 队列加入 message
  • messagingIframe.src 设置为 https://__wvjb_queue_message__

好,点到为止,对于 WebViewJavascriptBridge_JS 内的 JS 端其他源码我们放着后面看。注意这里加入了一个 src 为 https://__wvjb_queue_message__messagingIframe,它也是一个不可见的 iframe。这样 Native 端会收到一个 url 为 https://__wvjb_queue_message__ 的 request,回到第 1 步中获取到假的 request 之后会进行各项判定,这次会满足 [_base isQueueMessageURL:url] 的判定调用 Native 的 WKFlushMessageQueue 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
- (void)WKFlushMessageQueue {
// 执行 WebViewJavascriptBridge._fetchQueue(); 方法
[_webView evaluateJavaScript:[_base webViewJavascriptFetchQueyCommand] completionHandler:^(NSString* result, NSError* error) {
if (error != nil) {
NSLog(@"WebViewJavascriptBridge: WARNING: Error when trying to fetch data from WKWebView: %@", error);
}
// 刷新消息列表
[_base flushMessageQueue:result];
}];
}

- (NSString *)webViewJavascriptFetchQueyCommand {
return @"WebViewJavascriptBridge._fetchQueue();";
}

可见 Native 端会在刷新队列中调用 JS 端的 WebViewJavascriptBridge._fetchQueue(); 方法,我们来看一下 JS 端此方法的具体实现:

1
2
3
4
5
6
7
8
9
// 获取队列,在 iOS 端刷新消息队列时会调用此函数
function _fetchQueue() {
// 将 sendMessageQueue 转为 JSON 格式
var messageQueueString = JSON.stringify(sendMessageQueue);
// 重置 sendMessageQueue
sendMessageQueue = [];
// 返回 JSON 格式的
return messageQueueString;
}

这个方法会把当前 JS 端 sendMessageQueue 消息队列以 JSON 的形式返回,而 Native 端会调用 [_base flushMessageQueue:result]; 将拿到的 JSON 形式消息队列作为参数调用 flushMessageQueue: 方法,这个方法是整个框架 Native 端的精华所在,就是稍微有点长(笑)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
- (void)flushMessageQueue:(NSString *)messageQueueString {
// 校验 messageQueueString
if (messageQueueString == nil || messageQueueString.length == 0) {
NSLog(@"WebViewJavascriptBridge: WARNING: ObjC got nil while fetching the message queue JSON from webview. This can happen if the WebViewJavascriptBridge JS is not currently present in the webview, e.g if the webview just loaded a new page.");
return;
}

// 将 messageQueueString 通过 NSJSONSerialization 解为 messages 并遍历
id messages = [self _deserializeMessageJSON:messageQueueString];
for (WVJBMessage* message in messages) {
// 类型校验
if (![message isKindOfClass:[WVJBMessage class]]) {
NSLog(@"WebViewJavascriptBridge: WARNING: Invalid %@ received: %@", [message class], message);
continue;
}
[self _log:@"RCVD" json:message];

// 尝试取 responseId,如取到则表明是回调,从 _responseCallbacks 取匹配的回调 block 执行
NSString* responseId = message[@"responseId"];
if (responseId) { // 取到 responseId
WVJBResponseCallback responseCallback = _responseCallbacks[responseId];
responseCallback(message[@"responseData"]);
[self.responseCallbacks removeObjectForKey:responseId];
} else { // 未取到 responseId,则表明是正常的 JS callHandler 调用 iOS
WVJBResponseCallback responseCallback = NULL;
// 尝试取 callbackId,示例 cb_1_1512035076293
// 对应 JS 代码 var callbackId = 'cb_'+(uniqueId++)+'_'+new Date().getTime();
NSString* callbackId = message[@"callbackId"];
if (callbackId) { // 取到 callbackId,表示 js 端希望在调用 iOS native 代码后有回调
responseCallback = ^(id responseData) {
if (responseData == nil) {
responseData = [NSNull null];
}

// 将 callbackId 作为 msg 的 responseId 并设置 responseData,执行 _queueMessage
WVJBMessage* msg = @{ @"responseId":callbackId, @"responseData":responseData };
// _queueMessage 函数主要是把 msg 转为 JSON 格式,内含 responseId = callbackId
// JS 端调用 WebViewJavascriptBridge._handleMessageFromObjC('msg_JSON'); 其中 'msg_JSON' 就是 JSON 格式的 msg
[self _queueMessage:msg];
};
} else { // 未取到 callbackId
responseCallback = ^(id ignoreResponseData) {
// Do nothing
};
}

// 尝试以 handlerName 获取 iOS 端之前注册过的 handler
WVJBHandler handler = self.messageHandlers[message[@"handlerName"]];
if (!handler) { // 没注册过,则跳过此 msg
NSLog(@"WVJBNoHandlerException, No handler for message from JS: %@", message);
continue;
}
// 调用对应的 handler,以 message[@"data"] 为入参,以 responseCallback 为回调
handler(message[@"data"], responseCallback);
}
}
}

嘛~ flushMessageQueue: 方法作为整个 Native 端的核心,有点长是可以理解的。我们简单理一下它的实现思路:

  • 入参校验
  • 将 JSON 形式的入参转换为 Native 对象,即消息队列,这里面消息类型是之前定义过的 WVJBMessage,即字典
  • 如果消息中含有 “responseId” 则表明是之前 Native 调用的 JS 方法回调过来的消息(因为 JS 端和 Native 端实现逻辑是对等的,所以这个地方不明白的可以参考下面的分析)
  • 如果消息中不含 “responseId” 则表明是 JS 端通过 callHandler 函数正常调用 Native 端过来的消息
  • 尝试获取消息中的 “callbackId”,如果 JS 本次消息需要 Native 响应之后回调才会有这个键值,具体参见上文中 JS 端 _doSend 部分源码分析。如取到 “callbackId” 则需生成一个回调 block,回调 block 内部将 “callbackId” 作为 msg 的 “responseId” 执行 _queueMessage 将消息发送给 JS 端(JS 端处理消息逻辑与 Native 端一致,所以上面使用 “responseId” 判断当前消息是否为回调方法传递过来的消息是很容易理解的)
  • 尝试以消息中的 “handlerName” 从 messageHandlers(上文提到过,是保存 Native 端注册过的 handler 的字典)取到对应的 handler block,如果取到则执行代码块,否则打印错误日志

Note: 这个消息处理的方法虽然长,但是逻辑清晰,而且有效的解决了 JS 与 Native 相互调用的过程中参数传递的问题(包括回调),此外 JS 端的消息处理逻辑与 Native 端保持一致,实现了逻辑对称,非常值得我们学习。

WebViewJavascriptBridge_JS - Native 调用 JS 实现解读

Emmmmm…这一章节主要讲 JS 端注入的代码,即 WebViewJavascriptBridge_JS 中的 JS 源码。由于我没做过前段,能力不足,水平有限,可能有谬误希望各位读者发现的话及时指正,感激不尽。预警,由于 JS 端和上文分析过的 Native 端逻辑对称且上文已经分析过部分 JS 端的函数,所以下面的 JS 源码没有另做拆分,为避免被大段 JS 代码糊脸不感兴趣的同学可以直接看代码后面的总结。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
;(function() {
// window.WebViewJavascriptBridge 校验,避免重复
if (window.WebViewJavascriptBridge) {
return;
}

// 懒加载 window.onerror,用于打印 error 日志
if (!window.onerror) {
window.onerror = function(msg, url, line) {
console.log("WebViewJavascriptBridge: ERROR:" + msg + "@" + url + ":" + line);
}
}

// window.WebViewJavascriptBridge 声明
window.WebViewJavascriptBridge = {
registerHandler: registerHandler,
callHandler: callHandler,
disableJavscriptAlertBoxSafetyTimeout: disableJavscriptAlertBoxSafetyTimeout,
_fetchQueue: _fetchQueue,
_handleMessageFromObjC: _handleMessageFromObjC
};

// 变量声明
var messagingIframe; // 消息 iframe
var sendMessageQueue = []; // 发送消息队列
var messageHandlers = {}; // JS 端注册的消息处理 handlers 字典(囧,JS 其实叫对象)

// scheme 使用 https 之后通过 host 做匹配
var CUSTOM_PROTOCOL_SCHEME = 'https';
var QUEUE_HAS_MESSAGE = '__wvjb_queue_message__';

var responseCallbacks = {}; // JS 端存放回调的字典
var uniqueId = 1; // 唯一标示,用于回调时生成 callbackId
var dispatchMessagesWithTimeoutSafety = true; // 默认启用安全时长

// 通过禁用 AlertBoxSafetyTimeout 来提速网桥消息传递
function disableJavscriptAlertBoxSafetyTimeout() {
dispatchMessagesWithTimeoutSafety = false;
}

// 同 iOS 逻辑,注册 handler 其实是往 messageHandlers 字典中插入对应 name 的 block
function registerHandler(handlerName, handler) {
messageHandlers[handlerName] = handler;
}

// 调用 iOS handler,参数校验之后调用 _doSend 函数
function callHandler(handlerName, data, responseCallback) {
// 如果参数只有两个且第二个参数类型为 function,则表示没有参数传递,即 data 为空
if (arguments.length == 2 && typeof data == 'function') {
responseCallback = data;
data = null;
}
// 将 handlerName 和 data 作为 msg 对象参数调用 _doSend 函数
_doSend({ handlerName:handlerName, data:data }, responseCallback);
}

// _doSend 向 Native 端发送消息
function _doSend(message, responseCallback) {
// 如有回调,则设置 message['callbackId'] 与 responseCallbacks[callbackId]
if (responseCallback) {
var callbackId = 'cb_'+(uniqueId++)+'_'+new Date().getTime();
responseCallbacks[callbackId] = responseCallback;
message['callbackId'] = callbackId;
}
// 将 msg 加入 sendMessageQueue 数组,设置 messagingIframe.src
sendMessageQueue.push(message);
messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
}

// 获取队列,在 iOS 端刷新消息队列时会调用此函数
function _fetchQueue() {
// 内部将发送消息队列 sendMessageQueue 转为 JSON 格式并返回
var messageQueueString = JSON.stringify(sendMessageQueue);
sendMessageQueue = [];
return messageQueueString;
}

// iOS 端 _dispatchMessage 函数会调用此函数
function _handleMessageFromObjC(messageJSON) {
// 调度从 Native 端获取到的消息
_dispatchMessageFromObjC(messageJSON);
}

// 核心代码,调度从 Native 端获取到的消息,逻辑与 Native 端一致
function _dispatchMessageFromObjC(messageJSON) {
// 判断有没有禁用 AlertBoxSafetyTimeout,最终会调用 _doDispatchMessageFromObjC 函数
if (dispatchMessagesWithTimeoutSafety) {
setTimeout(_doDispatchMessageFromObjC);
} else {
_doDispatchMessageFromObjC();
}

// 解析 msgJSON 得到 msg
function _doDispatchMessageFromObjC() {
var message = JSON.parse(messageJSON);
var messageHandler;
var responseCallback;

// 如果有 responseId,则说明是回调,取对应的 responseCallback 执行,之后释放
if (message.responseId) {
responseCallback = responseCallbacks[message.responseId];
if (!responseCallback) {
return;
}
responseCallback(message.responseData);
delete responseCallbacks[message.responseId];
} else { // 没有 responseId,则表示正常的 iOS call handler 调用 js
// 如 msg 包含 callbackId,说明 iOS 端需要回调,初始化对应的 responseCallback
if (message.callbackId) {
var callbackResponseId = message.callbackId;
responseCallback = function(responseData) {
_doSend({ handlerName:message.handlerName, responseId:callbackResponseId, responseData:responseData });
};
}

// 从 messageHandlers 拿到对应的 handler 执行
var handler = messageHandlers[message.handlerName];
if (!handler) {
// 如未取到对应的 handler 则打印错误日志
console.log("WebViewJavascriptBridge: WARNING: no handler for message from ObjC:", message);
} else {
handler(message.data, responseCallback);
}
}
}
}

// messagingIframe 的声明,类型 iframe,样式不可见,src 设置
messagingIframe = document.createElement('iframe');
messagingIframe.style.display = 'none';
messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
// messagingIframe 加入 document.documentElement 中
document.documentElement.appendChild(messagingIframe);

// 注册 disableJavscriptAlertBoxSafetyTimeout handler,Native 可以通过禁用 AlertBox 的安全时长来加速桥接消息
registerHandler("_disableJavascriptAlertBoxSafetyTimeout", disableJavscriptAlertBoxSafetyTimeout);

setTimeout(_callWVJBCallbacks, 0);
function _callWVJBCallbacks() {
var callbacks = window.WVJBCallbacks;
delete window.WVJBCallbacks;
for (var i=0; i<callbacks.length; i++) {
callbacks[i](WebViewJavascriptBridge);
}
}
}

JS 端和 Native 端逻辑一致,上面的代码已经加入了详细的中文注释,上文在对于“WebViewJavascriptBridgeBase - JS 调用 Native 实现原理剖析”章节的分析过程中为了走通整个调用的逻辑已经对部分 JS 端代码进行了分析,这里我们简单的梳理一下 JS 端核心代码 _doDispatchMessageFromObjC 函数的逻辑:

  • 将 messageJSON 使用 JSON 解析出来
  • 尝试取解析到的消息中的 responseId,如果有取到则说明是 Native 端响应 JS 端之后通过回调向 JS 端发出的消息,用 responseId 取 responseCallbacks 中对应的回调响应 block,找到后执行该 block 之后删除
  • 如果没取到 responseId 则表示这条消息是 Native 端通过 callHandler:data:responseCallback: 正常调用 JS 注册的 handler 发送过来的消息(这里的正常是针对回调而言)
  • 如果当前的消息有 callbackId 则表明 Native 端需要 JS 端响应本次消息之后回调反馈,生成一个 responseCallback 作为回调 block (JS 端是 function) ,其内部使用 _doSend 方法传递一个带有 responseId 的消息给 Native 端,表明此条消息是之前的回调消息
  • 最后按照解析到的消息中 handlerName 从 messageHandlers,即 JS 端注册过的 handlers 中找到与名称对应的处理函数执行,如果没找到则打印附带相关信息的错误日志

嘛~ 对比一下 Native 端的核心代码 flushMessageQueue: 看一下,很容易发现两端的处理实现是逻辑对称的。

WebViewJavascriptBridge 的“桥梁美学”

在总结 WebViewJavascriptBridge 的“桥梁美学”之前请再回顾一下 WebViewJavascriptBridge 的工作流:

  • JS 端加入 src 为 https://__bridge_loaded__ 的 iframe
  • Native 端检测到 Request,检测如果是 __bridge_loaded__ 则通过当前的 WebView 组件注入 WebViewJavascriptBridge_JS 代码
  • 注入代码成功之后会加入一个 messagingIframe,其 src 为 https://__wvjb_queue_message__
  • 之后不论是 Native 端还是 JS 端都可以通过 registerHandler 方法注册一个两端约定好的 HandlerName 的处理,也都可以通过 callHandler 方法通过约定好的 HandlerName 调用另一端的处理(两端处理消息的实现逻辑对称)

嘛~ 所以我们很容易列举出 WebViewJavascriptBridge 所具有的“美学”:

  • 隐性适配
  • 接口对等
  • 逻辑对称

我们结合本文展开来说一下上面的“美学”的具体实现。

隐性适配

WebViewJavascriptBridge 主要是作为 Mac OS X 和 iOS 端(Native 端)与 JS 端相互通信,互相调用的桥梁。对于 Mac OS X 和 iOS 两种平台包含的三种 WebView 功能组件而言,WebViewJavascriptBridge 做了隐性适配,即仅用一套代码即可绑定不同平台的 WebView 组件实现同样功能的 JS 通信功能,这一点非常方便。

接口对等

WebViewJavascriptBridge 对于 JS 端和 Native 端设计了对等的接口,不论是 JS 端还是 Native 端,注册本端的响应处理都是用 registerHandler 接口,调用另一端(给另一端发消息)都是用 callHandler 接口。

这样做是非常合理的,因为不论是 JS 端还是 Native 端,作为通信的双方就通信本身而言是处于对等的地位的。这就好比一座大桥连接两块陆地,两地用大桥相互运输货物并接收资源,两块陆地在大桥的运输使用过程中逻辑上也是地位对等的。

逻辑对称

WebViewJavascriptBridge 在 JS 端和 Native 端对发送过来的消息有着相同逻辑的处理实现,如果考虑到收发双方的身份则可以把逻辑相同看做逻辑对称。

这种实现方式依旧非常合理,被桥连接的两块大陆在装货上桥和下桥卸货这两处逻辑上就应该是对称的。

嘛~ 说到这里就不得不祭出一个词来形容 WebViewJavascriptBridge 了,这个词就是优雅(笑)。当大家结合 WebViewJavascriptBridge 源码阅读本文之后不难发现其整个架构和设计思想跟现实桥梁设计中很多设计思想不谋而合,比如桥一般会分为左右桥幅,而左右幅桥一般只有一条线路中心线,即一个前进方向,用于桥上单一方向的资源传输,左右桥幅在功能上对等。

文章总结

  • 文章系统分析了 WebViewJavascriptBridge 源码,希望各位读者能够在阅读本文之后对 WebViewJavascriptBridge 的架构有一个整体认识。
  • 文章对 WebViewJavascriptBridge 在 JS 端和 Native 端的消息处理实现做了深入剖析,希望可以对各位读者这部分源码的理解提供一些微薄的帮助。
  • 总结了 WebViewJavascriptBridge 作为一个 JSBridge 框架所具有的优势,即文中所指的“桥梁美学”,期望可以对大家以后自己封装一个 JSBridge 提供思路,抛砖引玉。

Emmmmm…不过需要注意的是 WebViewJavascriptBridge 仅仅是作为 JSBridge 层用于提供 JS 和 Native 之间相互传递消息的基础支持的。如果想要封装自己项目中的 WebView 组件还需要另外实现 HTTP cookie 注入,自定义 User-Agent,白名单或者权限校验等功能,更进一步还需要对 WebView 组件进行初始化速度,页面渲染速度以及页面缓存策略的优化。我之后也许可能大概应该会写一篇文章分享一下自己封装 WebView 组件时踩到的一些坑以及经验,因为自己水平有限…所以也可能不会写(笑)。

文章写得比较用心(是我个人的原创文章,转载请注明 https://lision.me/),如果发现错误会优先在我的 个人博客 中更新。如果有任何问题欢迎在我的微博 @Lision 联系我~

希望我的文章可以为你带来价值~