之前参考 这篇博客 已经完成了整体的配置,具体思路就是使用 Surge 把公司内网流量转发到 Docker 容器,容器内部开启 OpenConnect 连 VPN。但是最近家里购置了一台 Mac Studio,Apple Silicon 什么都好,就是在一些特殊场景下兼容性堪忧,网络配置花了很久,所以在这里做个记录。
[Proxy]
🚇 VPN = snell, localhost, 8388, psk=123, obfs=http, version=3
[Proxy Group]
💼 公司内网 = select, DIRECT, 🚇 VPN
[Rule]
AND,((DOMAIN-KEYWORD,内网域名), (NOT,((PROCESS-NAME,com.docker.vpnkit)))),💼 公司内网
这里有几个细节点:
参考上面的博客,按照 openconnect-snell 里的说明起一个 Docker 实例就可以了——至少曾经可以这样。但是要想在 M1 上把实例跑起来,必须要重新发布一版 arm64 的镜像才行,具体来说就是要把原来的 Dockerfile 中引用的资源全部替换成 arm64 的,改动点见 diff。
这里有一个坑点,就是 snell-server 依赖了 glibc,但是网络上根本找不到适配 arm64 的 alpine linux 的较新的 glibc 发布包(定语这么多找不到也正常),最终只能借助 docker-glibc-builder 自己在本机编译了一份,然后手动拷贝到镜像中。另外就是要解决各种系统库找不到的问题,alpine 下报错信息少得可怜,解决这个问题花了好几个晚上。
总而言之,镜像发布之后,开启 VPN 就比较方便了,新建一个名字叫 runvpn.sh 的脚本,内容如下:
docker run -d --privileged -p 8388:8388 -p 8388:8388/udp -e PSK="123" -e OC_HOST="..." -e OC_AUTH_GROUP="common-vpn" -e OC_PASSWD="..." -e OC_AUTH_CODE="$1" -e OC_USER="..." --name=openconnect-snell yeatse/openconnect-snell
使用时只需要执行以下命令即可:
sh ./runvpn.sh <动态验证码>

崩溃的方法是
+[RCTFont updateFont:withFamily:size:weight:style:variant:scaleMultiplier:],在这个位置会抛出 mutex lock failed: Invalid argument 的异常。
查了下崩溃栈,大概长这样:
Thread 24 Crashed:
0 libsystem_kernel.dylib __pthread_kill + 8
1 libsystem_c.dylib abort + 140
2 libc++abi.dylib __cxa_bad_cast + 0
3 libc++abi.dylib default_terminate_handler() + 280
4 libobjc.A.dylib _objc_terminate() + 140
5 libc++abi.dylib std::__terminate(void (*)()) + 16
6 libc++abi.dylib __cxxabiv1::exception_cleanup_func(_Unwind_Reason_Code, _Unwind_Exception*) + 0
7 libc++.1.dylib std::__1::__throw_system_error(int, char const*) + 88
8 bee +[RCTFont updateFont:withFamily:size:weight:style:variant:scaleMultiplier:] + 780
9 bee -[RCTShadowText _attributedStringWithFontFamily:fontSize:fontWeight:fontStyle:letterSpacing:useBackgroundColor:foregroundColor:backgroundColor:opacity:] + 580
10 bee -[RCTShadowText attributedString] + 192
11 bee -[RCTShadowText recomputeText] + 28
12 bee -[RCTTextManager uiBlockToAmendWithShadowViewRegistry:] + 612
13 bee -[RCTComponentData uiBlockToAmendWithShadowViewRegistry:] + 96
14 bee -[RCTUIManager _layoutAndMount] + 220
15 bee __36-[RCTBatchedBridge batchDidComplete]_block_invoke + 52
16 libdispatch.dylib _dispatch_call_block_and_release + 24
17 libdispatch.dylib _dispatch_client_callout + 16
18 libdispatch.dylib _dispatch_queue_serial_drain + 928
19 libdispatch.dylib _dispatch_queue_invoke + 884
20 libdispatch.dylib _dispatch_root_queue_drain + 540
21 libdispatch.dylib _dispatch_worker_thread3 + 124
22 libsystem_pthread.dylib _pthread_wqthread + 1096
23 libsystem_pthread.dylib start_wqthread + 4
从崩溃栈上可以看出来是 RN 库 RCTFont 模块出的问题,除此之外再也找不到其他信息,放 google 搜了一圈,只能找到别人提的同样的问题 – RCTFont SIGABRT crash、App crashes for “mutex lock failed: Invalid argument”,却没人提出解决方法,看来只能自己解了。
首先把可执行文件拉到 Hopper 里,定位到崩溃处,看一下对应的汇编指令:

可以得知应用在 RCTFont 内部使用 std::mutex 加锁的时候抛出了异常,对应于 RCTFont.mm 第 103 行:
{
std::lock_guard<std::mutex> lock(fontCacheMutex); ///< 在这里挂掉了
if (!fontCache) {
fontCache = [NSCache new];
}
font = [fontCache objectForKey:cacheKey];
}
对比收集到的各种崩溃样本,可以总结出以下几处共同点:
handleApplicationDeactivationWithScene → +[_UIAlertManager hideAlertsForTermination] → exit 的调用;mutex::lock 的时候抛出了异常。给 handleApplicationDeactivationWithScene 等方法下个断点,发现只有在用户手动 kill 掉 app 时这些方法才会被调用,猜测这个时候系统可能正在做一些清理工作,这时候如果有其他线程调用了 mutex::lock 可能就会导致异常。
假设上面的猜测为真,那么解决问题的关键是让应用进程结束时不调用 mutex::lock 方法。查看 React Native 源码,crash 处的代码只被一个方法调用,即上面提到的 +[RCTFont updateFont:withFamily:size:weight:style:variant:scaleMultiplier:]

这个方法的调用者有多个,但最终都走到了 React Native 模块的各个属性的设置方法里,在 React Native 线程里,由 RCTBatchedBridge → RCTJSCExecutor 驱动

注意到 js 每次调用时都会检查 _valid 属性,所以只需要在进程结束时把 _valid 置为 false,crash 处的代码就不会被执行。RCTBatchedBridge 和它的包装类 RCTBridge 刚好暴露出了 invalidate 方法,可以把 _valid 置为 false:
// -[RCTBridge invalidate]
- (void)invalidate
{
RCTBridge *batchedBridge = self.batchedBridge;
self.batchedBridge = nil;
if (batchedBridge) {
RCTExecuteOnMainQueue(^{
[batchedBridge invalidate];
});
}
}
// -[RCTBatchedBridge invalidate]
- (void)invalidate
{
if (!_valid) {
return;
}
_loading = NO;
_valid = NO;
// Invalidate modules
for (RCTModuleData *moduleData in _moduleDataByID) {
id<RCTBridgeModule> instance = moduleData.instance;
[instance invalidate];
[moduleData invalidate];
}
}
用户杀掉 app 时,系统会调用 App Delegate 的 applicationWillTerminate 方法,所以我们需要在这里调用一下 -[RCTBridge invalidate],使 RCTBridge 失效,这样就不会再触发导致 crash 的代码了。
但问题是,-[RCTBridge invalidate] 方法是异步的,applicationWillTerminate 一返回马上就进入 exit 函数,这时候程序还来不及干掉 RCTBridge,crash 处的代码还是会执行。所以这里还需要借用 runloop 让 applicationWillTerminate 卡一会儿,直到 RCTBridge 完全停止:
- (void)applicationWillTerminate:(UIApplication *)application {
RCTBridge *batchedBridge = [self.bridge valueForKey:@"batchedBridge"];
[self.bridge invalidate];
NSRunLoop *runLoop = [NSRunLoop currentRunLoop];
NSArray<NSRunLoopMode> *allModes = CFBridgingRelease(CFRunLoopCopyAllModes(runLoop.getCFRunLoop));
while (batchedBridge.moduleClasses) {
for (NSRunLoopMode mode in allModes) {
[runLoop runMode:mode beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.1]];
}
}
}
这里用到了几处 trick:
applicationWillTerminate 要用 runloop,而不是简单的 sleep,原因是 invalidate 方法内部向主线程分发了一些事要做(见 RCTBatchedBridge.mm 源码),需要主线程有处理事件的能力;RCTBatchedBridge 的 moduleClasses 属性,所以可以通过它是否为空来确定 RCTBridge 完全停止的时机。 batchedBridge 是私有属性所以需要 kvc 来拿到。加入工程发版之后,这个 crash 就消失了,撒花。

为什么主线程调用了 exit 之后,其他线程调用 mutex::lock 方法时会抛异常?
static NSCache *fontCache;
static std::mutex fontCacheMutex;
NSString *cacheKey = [NSString stringWithFormat:@"%.1f/%.2f", size, weight];
UIFont *font;
{
std::lock_guard<std::mutex> lock(fontCacheMutex);
if (!fontCache) {
fontCache = [NSCache new];
}
font = [fontCache objectForKey:cacheKey];
}
回过来看崩溃位置代码,第 2 行声明了一个局部变量 fontCacheMutex,通过汇编指令可以看出,它在创建的时候通过 __cxa_atexit 方法注册了一个销毁函数:

主线程调用 exit 方法时,会通过 __cxa_finalize 逐个调用之前注册的销毁函数(参考 atexit.c 源码),这个静态变量 fontCacheMutex 随之销毁,之后再调用这个销毁过的 mutex 对象的方法自然会 crash 了。
但是在 WKWebView 已经出现了三年的今天,UIWebView 还没有被标记为 deprecated,我想 Apple 一定也和很多开发者一样,觉得 WKWebView 还没有完善到能完全替代 UIWebView 的程度。比如其中一个痛点——对请求拦截的支持,正常情况下,按照下面的方式注册一个 NSURLProtocol 子类,就可以对 app 内所有的网络请求进行 MitM 了:
[NSURLProtocol registerClass:[AwesomeURLProtocol class]];
但 WKWebView 中的请求却完全不遵从这一规则,除了一开始会调用一下 + [NSURLProtocol canInitWithRequest:] 方法,之后的整个请求流程似乎就与 NSURLProtocol 完全无关了。关于这一点,网络上文章一般都解释说 WKWebView 的请求是在单独的进程里,所以不走 NSURLProtocol。
既然 WKWebView 不走 NSURLProtocol,那为什么还要在一开始调一下 canInitWithRequest: 呢?更令我好奇的是从 WebKit.framework dump 出的头文件能看出,有几个类(WKCustomProtocol、WKCustomProtocolLoader)明显与 NSURLProtocol 有关,说明 WKWebView 很可能是支持 NSURLProtocol 的,只是出于某种原因没开放而已,于是我决定翻 WebKit 的源码一探究竟。
翻 WebKit 源码的过程就不细说了,光从 GitHub 上拉源码到本地就花了我几个 G 的 ss 流量……总之翻到最后,我在一项单元测试 TestProtocol.mm 中看到了 NSURLProtocol 熟悉的身影:
+ (void)registerWithScheme:(NSString *)scheme
{
testScheme = [scheme retain];
[NSURLProtocol registerClass:[self class]];
#if WK_API_ENABLED
[WKBrowsingContextController registerSchemeForCustomProtocol:testScheme];
#endif
}
从 registerSchemeForCustomProtocol: 这个方法名来猜测,它的作用的应该是注册一个自定义的 scheme,这样对于 WebKit 进程的所有网络请求,都会先检查是否有匹配的 scheme,有的话再走主进程的 NSURLProtocol 这一套流程,猜测这么做可能是为了保证效率 (NSURLRequest 的 HTTPBody 属性在 WKWebView 中被忽略了应该也出于这个原因),毕竟 IPC 代价挺高的。后续翻 WebKit::CustomProtocolManager 和 WebKit::WebProcessPool 等相关源码也印证了这个猜想。
看上去没什么问题,于是按照 TestCase 里的例子尝试了一下:
Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([(id)cls respondsToSelector:sel]) {
// 把 http 和 https 请求交给 NSURLProtocol 处理
[(id)cls performSelector:sel withObject:@"http"];
[(id)cls performSelector:sel withObject:@"https"];
}
// 这下 AwesomeURLProtocol 就可以用啦
[NSURLProtocol registerClass:[AwesomeURLProtocol class]];
现在 WKWebView 中的所有请求都可以被 NSURLProtocol 修改了:

按照 @sunnyxx 的总结,Apple 检查私有 API 的使用,大概会采取下面几种手段:
而本文所介绍的方法,一共有两个地方使用了私有 API:
Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
这两个地方都是通过反射的方式拿到了私有的 class/selector,对应上面的第四条。其中第二行那个还好说,因为 registerSchemeForCustomProtocol 这个名词看上去相当普通,如果把这种字符串也禁掉了的话会误伤一大票开发者,所以有风险的主要是 WKBrowsingContextController 这个字符串,要前缀有前缀,要 camel case 有 camel case,再跟私有 class 名撞车的话就跟可能被拒了。
那么怎样绕过这个字符串呢?查询 WKWebView.h 可以看到,有个方法 - browsingContextController 的方法名跟 WKBrowsingContextController 长得很像,通过 KVC 取出来(没错,KVC 不但可以取 property 取 ivar,还可以取无入参 selector 的返回值)发现它就是 WKBrowsingContextController 的一个实例,这样一来这个私有类就可以通过 KVC 的方式来得到了:
Class cls = [[[WKWebView new] valueForKey:@"browsingContextController"] class];
比起粗暴地 NSClassFromString,使用 valueForKey 的方法安全了许多。当然,如果还有什么要担心的话,这些字符串也可以不明着写出来,只要运行时算出来就行,比如用 base64 编码啊,图片资源里藏一段啊,甚至通过服务器下发……既然到了这个程度,苹果的静态扫描就很难再 hold 住了。
使用私有 API 的另一风险是兼容性问题,比如上面的 browsingContextController 就只能在 iOS 8.4 以后才能用,反注册 scheme 的方法 unregisterSchemeForCustomProtocol: 也是在 iOS 8.4 以后才被添加进来的,要支持 iOS 8.0 ~ 8.3 机型的话,只能通过动态生成字符串的方式拿到 WKBrowsingContextController,而且还不能反注册,不过这些问题都不大。至于向后兼容,这个也不用太担心,因为 iOS 发布新版本之前都会有开发者预览版的,那个时候再测一下也不迟。对于本文的例子来说,如果将来哪个 iOS 版本移除了这个 API,那很可能是因为官方提供了完整的解决方案,到那时候自然也不需要本文介绍的方法了。
最后,我写了一个 Demo 放到了 GitHub 上,支持 iOS 8.4+,代码经测试已通过 App Store 审核: https://github.com/yeatse/NSURLProtocol-WebKitSupport
]]>从 iOS 9 开始,UIKit 新增了 3D Touch 相关接口,如果使用苹果推荐的 storyboard 搭建 UI,勾选了 Preview & Commit Segues 选项之后就可以零代码实现系统级的 3D Touch 效果;用代码实现也很简单,只要实现 UIViewControllerPreviewingDelegate 协议,然后调用 - [UIViewController registerForPreviewingWithDelegate:sourceView:] 方法,就可以对任意 UIView 进行 Peek 和 Pop 操作了。
实际上,尽管 - [UIViewController registerForPreviewingWithDelegate:sourceView:] 方法的第二个参数接受的是任意的 UIView,但在 UIWebView 和 WKWebView 上按压的操作却是没有效果的。虽然苹果针对这两个 WebView 提供了 allowsLinkPreview 属性做了特殊处理,但这也仅仅是调用了 Safari 打开链接,实际应用中经常要针对某些特殊的链接进行应用内跳转,这是 allowsLinkPreview 无论如何也完成不了的。
那么为什么在其他 View 上都正常的 3D Touch 在 WebView 上却无效了呢?以 UIWebView 为例,在实际应用中可以发现,如果把 WebView 的 userInteractionEnabled 值设为 NO,或者按压空白位置,UIViewControllerPreviewingDelegate 中的方法还是可以正常回调的,所以原因很可能是 UIWebView 处理链接点击事件的手势与 3D Touch 的手势发生了冲突。要让 UIWebView 和其他 View 一样支持 Peek & Pop,就要完成以下三个步骤:
1 中的每一个手势监听器,调用 - [UIGestureRecognizer requireGestureRecognizerToFail:] 方法,保证 UIKit 优先处理 3D Touch 事件。通过断点等方法可以得出,UIWebView 的内部视图层级结构和继承关系是这样的:
+--- UIWebView → UIView
| |
| +--- _UIWebViewScrollView → UIWebScrollView → UIScrollView → UIView
| |
| +--- UIWebBrowserView → UIWebDocumentView → UIWebTiledView → UIView
| |
| (...)
UIWebBrowserView 是显示 WebView 内部元素的容器,处理链接点击事件也应该由它来做,于是在 Xcode 中打个断点偷窥一下成员变量,果然在它的父类 UIWebDocumentView 里看到了一组与 gesture 有关的私有成员:

看这些单词的意思很明显了,与 3D Touch 冲突的手势可能是 _singleTapGestureRecognizer、highlightLongPressGestureRecognizer、_longPressGestureRecognizer。在 runtime 面前一切私有成员都是纸老虎,通过 KVC 把这三个手势取出来:
UIView* browserView = [self valueForKeyPath:@"internal.browserView"]; // internal.browserView 是一个能获取内部 UIWebBrowserView 的私有 keyPath
UIGestureRecognizer* singleTapGesture = [browserView valueForKey:@"singleTapGestureRecognizer"];
UIGestureRecognizer* longPressGesture = [browserView valueForKey:@"longPressGestureRecognizer"];
UIGestureRecognizer* highlightLongPressGesture = [browserView valueForKey:@"highlightLongPressGestureRecognizer"];
同样地,检查调用 registerForPreviewingWithDelegate:sourceView: 前后 UIWebView 的 gesture 变化,发现注册了 Previewing Delegate 之后,UIWebView 的父类 UIView 多出了三个手势监听器:

那么这三个监听器自然也就与 3D Touch 相关了。接下来调用 requireGestureRecognizerToFail: 为上边取出的手势添加依赖:
for (UIGestureRecognizer* gesture in self.webView.gestureRecognizers) {
[singleTapGesture requireGestureRecognizerToFail:gesture];
[longPressGesture requireGestureRecognizerToFail:gesture];
[highlightLongPressGesture requireGestureRecognizerToFail:gesture];
}
这样一来 UIWebView 就可以和普通的 UIView 一样使用 UIViewControllerPreviewingDelegate 进行 Peek 和 Pop 了:

按上面的方法我们虽然可以正常使用 Peek 和 Pop,但是正常的链接点击却也受到了影响。当长按链接并松手之后,即使没有重按调出 Peek 界面,链接点击事件也依然没有触发,这跟 UIButton 和 UITableViewCell 等行为不一样,所以还需要进一步的处理。
在上面得到的三个 3D Touch 相关手势当中,仅仅根据类名很难猜出它们的具体作用。于是祭出 KVO,进行操作的同时监听它们的 state 变化,得到结果如下:
_UIPreviewInteractionTouchObservingGestureRecognizer state changed to 1
_UIPreviewInteractionTouchObservingGestureRecognizer state changed to 2
_UIPreviewGestureRecognizer state changed to 5
_UIRevealGestureRecognizer state changed to 5
_UIPreviewInteractionTouchObservingGestureRecognizer state changed to 4
_UIPreviewInteractionTouchObservingGestureRecognizer state changed to 1
_UIPreviewInteractionTouchObservingGestureRecognizer state changed to 2
_UIRevealGestureRecognizer state changed to 1
_UIPreviewGestureRecognizer state changed to 1
_UIPreviewInteractionTouchObservingGestureRecognizer state changed to 4
_UIRevealGestureRecognizer state changed to 3
_UIPreviewGestureRecognizer state changed to 3
_UIPreviewInteractionTouchObservingGestureRecognizer state changed to 1
_UIPreviewInteractionTouchObservingGestureRecognizer state changed to 2
_UIRevealGestureRecognizer state changed to 1
_UIPreviewGestureRecognizer state changed to 1
_UIRevealGestureRecognizer state changed to 2
_UIPreviewGestureRecognizer state changed to 2
_UIRevealGestureRecognizer state changed to 3
_UIPreviewGestureRecognizer state changed to 3
_UIPreviewInteractionTouchObservingGestureRecognizer state changed to 4
_UIRevealGestureRecognizer state changed to 4
_UIPreviewInteractionTouchObservingGestureRecognizer state changed to 4
_UIPreviewGestureRecognizer state changed to 4
可以看出,对于 _UIPreviewGestureRecognizer 而言(_UIRevealGestureRecognizer 同样),除了单击的时候 state 值会变成 5 (UIGestureRecognizerStateFailed) 以外,其余情况都成功触发了按压手势,从而导致链接点击的手势启动失败。这也很容易理解,因为长按也算是重按的一种,力度轻了点而已,通过断点也可以看出 _UIPreviewGestureRecognizer 和 _UIRevealGestureRecognizer 本身就是继承自 UILongPressGestureRecognizer 的。
对比长按和重按的状态变化可以发现,重按的时候,_UIPreviewGestureRecognizer的 state 值会变成 2 (UIGestureRecognizerStateChanged),这就是区别长按和重按的突破口。那么接下来要做的就是,在 _UIPreviewGestureRecognizer 的 state 值变成 3 (UIGestureRecognizerStateEnded)的时候,检查之前是否有“重按”过,如果没有重按过(state 从 1 直接变成 3),那么就手动触发 _singleTapGestureRecognizer 手势,模拟对 UIWebView 的点击事件。
那么问题又来了,如何通过代码来触发一个手势事件呢?
一个自然的想法是构造一个 UITouch 丢给系统,不过构造这个相当麻烦,一篇 2008 年的文章详细地说明了整个步骤。实际上我们模拟手势的目的只是为了调用对应的 selector 而已,如果能拿到对应的 selector 的话,直接调用 selector 就好了。
通过断点检查 _singleTapGestureRecognizer 的成员变量,可以看到里面 _targets 那一项很有意思:

很明显这个单击事件最终是要调用 UIWebBrowserView 的 _singleTapRecognized: 方法的,所以我们可以通过 performSelector:withObject: 绕过 gesture recognizer 直接调用它。
最后一个问题是 _singleTapRecognized: 的参数问题,直接传 _singleTapGestureRecognizer 本身会被认为点击了左上角,跟传 nil 效果一样。怀疑 Apple 使用了某个私有方法或变量来确定点击的坐标,于是传一个 NSObject 进去试试,系统弹出这个错误:
-[NSObject location]: unrecognized selector sent to instance 0x6000000162e0
查看 dump 出的 UITapGestureRecognizer.h 文件可以知道,location 是 UITapGestureRecognizer 私有的一个计算型属性,返回类型是 CGPoint。猜想 _singleTapRecognized: 就是通过它来确定到底点了哪里的,那么我们只要构造出一个有 location 属性的对象,把 location 存进去再交给这个方法就可以了:
@interface LocationWrapper : NSObject
@property (nonatomic) CGPoint location;
@end
@implementation LocationWrapper
@end
// ...
LocationWrapper* wrapper = [LocationWrapper new];
wrapper.location = [singleTapGesture locationInView:singleTapGesture.view];
[browserView performSelector:NSSelectorFromString(@"_singleTapRecognized") withObject:wrapper];
按上面的思路,UIWebView 与 Peek & Pop 的冲突就可以比较完美地解决了。我把它封装成了一个 category,通过 method swizzling 的方式尽量简化使用成本,只要把 UIWebView+PeekingSupport.h 和 UIWebView+PeekingSupport.m 拖到工程里,其他什么都不用做,就可以像正常 UIView 一样在使用 UIWebView 上使用 3D Touch 了。项目地址在这里: https://github.com/yeatse/UIWebView-PeekingSupport
]]>- animateTransition: 方法来提供;如果没有实现此协议则此动画由系统提供。以结构如下图的 Navigation Controller 为例:

在页面 B → A 切换的过程中,应用的视图层级结构如下图所示:

最终运行的转场动画,不论是系统默认的平移动画还是通过 UIViewControllerAnimatedTransitioning 来实现的动画,都作用在 View Controller A & B 共同的父视图 UIViewControllerWrapperView 内部,而这个共同的父视图即是 - [UIViewControllerContextTransitioning containerView] 所返回的那个容器 View。
下面再说说可交互式动画的实现机制。
一般情况下,实现可交互式动画需要先实现 UIViewControllerInteractiveTransitioning 协议,通过- startInteractiveTransition: 来启动转场,然后不断调用 - updateInteractiveTransition: 更新转场进度,最后调用 - finishInteractiveTransition 或 - cancelInteractiveTransition 来完成或取消转场。
- updateInteractiveTransition: 方法接受一个表示完成百分比的参数,显然这个百分比是用来更新转场动画 CAAnimation 的进度的,但是 Core Animation 框架并没有提供直接改变动画进度的接口,所以一直以来我都以为苹果利用它的特权调用了某些私有方法来完成这件事。直到有一天,我在一个视图中通过 - [CALayer convertTime:fromLayer:] 来定期检查当前 layer 的相对时间:
CFTimeInterval time = [self.layer convertTime:CACurrentMediaTime() fromLayer:nil];
NSLog(@"Current layer time: %@", @(time));
在通过左滑手势操作当前页滑动返回时,打出的 Log 是这样的:
Current layer time: 261910.875040995
Current layer time: 261911.377889105
Current layer time: 0.02057165431515606
Current layer time: 0.05607890069196765
Current layer time: 0.1110305915132237
Current layer time: 0.2074074031074266
Current layer time: 0.2620772946859903
Current layer time: 0.3263285024154589
Current layer time: 261917.375051694
Current layer time: 261917.879273495
当前 layer 的时间竟然变成了返回动画的相对时间!根据 Apple 文档说明,这个时间会被 CAMediaTiming 协议的属性所影响(如 speed),并且一个 layer 的时间发生改变,则此 layer 层级树中的子孙 layer 的时间全部发生同样的改变。所以我决定找出导致 layer 的 speed 值改变的元凶:
for (UIView* view = self; view; view = view.superview) {
if (view.layer.speed != 1) {
NSLog(@"Speed changed in the layer of view: %@, speed: %@, time offset: %@", view.class, @(view.layer.speed), @(view.layer.timeOffset));
}
}
输出:
Speed changed in the layer of view: UIViewControllerWrapperView, speed: 0, time offset: 0.1144122469252434
Speed changed in the layer of view: UIViewControllerWrapperView, speed: 0, time offset: 0.1479468599033816
Speed changed in the layer of view: UIViewControllerWrapperView, speed: 0, time offset: 0.172463768115942
Speed changed in the layer of view: UIViewControllerWrapperView, speed: 0, time offset: 0.2020531400966183
于是可交互动画的实现机制就变得明朗了:
- startInteractiveTransition: 方法,实际上是将 UIViewControllerWrapperView 的 layer.speed 值改成 0 以暂停动画;- updateInteractiveTransition: 方法,实际上是通过 duration 和百分比换算出一个合适的 timeOffset,更新到 UIViewControllerWrapperView 的 layer 上面,以模拟动画进度更新的效果;- finishInteractiveTransition,则是将 layer.speed 值改回 1,让 Core Animation 继续完成剩下的动画。这个 UIViewControllerWrapperView 就是上面图中那个共同的父 View,在不同的实现中,它的类名可能会有变化,但都是 - [UIViewControllerContextTransitioning containerView] 返回的那个容器 View。由于父 layer 的动画时间改变是会影响到所有子 layer 的,所以有时候即使不用 transitionCoordinator,一些看上去与转场过程毫无关联的动画依然会受滑动返回手势的影响:

最后一个问题,是调用 - cancelInteractiveTransition 之后,系统会自动将动画逆转,以回退到切换之前的状态,这个又是如何实现的呢?
网上一些文章 介绍了通过一帧帧调整 timeOffset 值来模拟逆转动画的效果,还用了 CADisplayLink 来同步屏幕刷新率,讲道理我是不信苹果会用这么蠢的办法的。通过一系列断点和 Log 调试发现,实际上苹果做的事非常巧妙:将所有 CAAnimation 的 autoreverses 属性设定为 YES,然后将 beginTime 设定为一个在 (- 2 * duration, - duration) 区间的一个合适的值,再用 CAAnimationGroup 包裹起来,以保证 CAAnimation 只运行想要的那部分动画。这样一来由于 autoreverses 的作用,Core Animation 层自己就会将动画回放了。
//取当前 run loop CFRunLoopRef runLoop = CFRunLoopGetCurrent(); //取 run loop 所有运行的 mode NSArray *allModes = CFBridgingRelease(CFRunLoopCopyAllModes(runLoop)); while (1) { for (NSString *mode in allModes) { //在每个 mode 中轮流运行至少 0.001 秒 CFRunLoopRunInMode((CFStringRef)mode, 0.001, false); } }对于因为接收到 crash 的 signal 而挂掉的程序,可以在接收到 crash 的信号之后重新起一个 run loop 然后跑起来。但是这个并不能保证 app 能像原来一样能正常运行,只能是利用它来在奄奄一息的状态下弹出一些友好的错误信息。
自己写了个 Demo 测试了一下,首先随便触发一个 unrecognized selector 错误:
UIView* view = (id)[NSObject new];
view.hidden = YES;
捕获到的崩溃栈如下图:

可以看到,RunLoop 在 Source0 中处理点击事件,调用了未定义的 selector 之后经过一系列消息转发,最后调用 objc_exception_throw 抛出了异常。
Foundation 提供的 NSSetUncaughtExceptionHandler 方法可以截获到这个异常。通过它设置一个回调函数,在里面展示一个 UIAlertViewController,再按照上面的方式手动启动一个 RunLoop 来监听手势事件,用 Swift 实现如下:
NSSetUncaughtExceptionHandler { (exception) in
var shouldRun = true
let runLoop = CFRunLoopGetCurrent()
let alertCtrl = UIAlertController(title: "Oops", message: "Your app crashed! OAO", preferredStyle: .Alert)
alertCtrl.addAction(UIAlertAction(title: "OK", style: .Default, handler: { (_) in
shouldRun = false
}))
guard let rootViewController = UIApplication.sharedApplication().keyWindow?.rootViewController else {
return
}
rootViewController.presentViewController(alertCtrl, animated: true, completion: nil)
let allModesAO = CFRunLoopCopyAllModes(runLoop) as [AnyObject]
guard let allModes = allModesAO as? [CFStringRef] else {
return
}
while (shouldRun) {
for mode in allModes {
CFRunLoopRunInMode(mode, 0.001, false)
}
}
}
这样就可以在程序 Crash 之前弹出一个提示框了。
一个需要注意的地方是,如果程序使用了第三方 SDK 做崩溃收集的话,很可能由于第三方 SDK 也注册了 UncaughtExceptionHandler 导致自己注册的函数被覆盖掉。另外由于注册的回调只对 Foundation 对象的异常有效,所以这个方法只对 NSException 起作用,对于 BAD_ACCESS、std::terminate() 等非 Foundation 异常还是没有办法的。至于用 @throw 还是 c++ 的 throw 这个倒是影响不大,亲自尝试了之后发现两者都是抛 NSException 有效,抛其他对象 (包括非 NSException 的 NSObject)无效的。
]]>但是在实际项目中,一些错误的结构设计可能会导致难以发现的泄漏问题,比如像 A -> B -> C -> ... -> A 这种长环的循环引用,或者一个实例被一个 单例 持有,在 review 的时候可能会漏掉这些问题,这时就需要流程化的方式来检测了。
一个很方便的检测方法是重写 dealloc 方法:
- (void)dealloc {
NSLog(@"%s", __func__);
}
只要目标对象有 dealloc 的 log 输出,就表示这里没有出现循环引用问题。
对于拿不到源文件的类,也可以通过类似的方法来实现:
// DeallocationObserver.h
#import <Foundation/Foundation.h>
@interface DeallocationObserver : NSObject
+ (instancetype)attachObserverToObject:(id)object;
@end
// DeallocationObserver.m
#import "DeallocationObserver.h"
#import <objc/runtime.h>
static const char ObserverTag;
@interface DeallocationObserver ()
- (instancetype)initWithParent:(id)parent;
@property (nonatomic, copy) void(^deallocationBlock)();
@end
@implementation DeallocationObserver
+ (instancetype)attachObserverToObject:(id)object {
return [[self alloc] initWithParent:object];
}
- (instancetype)initWithParent:(id)parent {
self = [super init];
if (self) {
NSString* deallocMsg = [NSString stringWithFormat:@"deallocated: %@", parent];
self.deallocationBlock = ^{
NSLog(@"%@", deallocMsg);
};
objc_setAssociatedObject(parent, &ObserverTag, self, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
return self;
}
- (void)dealloc {
if (self.deallocationBlock) {
self.deallocationBlock();
}
}
@end
// Usage:
NSObject* testObj = [NSObject new];
[DeallocationObserver attachObserverToObject:testObj];
testObj = nil; // Output - deallocated: <NSObject: 0x7fce1a412c10>
因为 NSObject 对象在 dealloc 的时候也会把 objc_setAssociatedObject 关联的对象也一并 release 掉,通过监听 DeallocationObserver 的销毁时机,我们就可以检测到目标对象的销毁事件了。
由于 ARC 只对 NSObject 有效,所以对于 Core Foundation、Core Graphics 等非 NSObject 对象,就需要苹果提供的 Instruments 来检测内存泄漏问题了。
按照 Instruments 的官方文档中的步骤,测试一下这段代码:
- (void)testMemoryLeak {
CFMutableDataRef data = CFDataCreateMutable(kCFAllocatorDefault, 0);
CGDataConsumerRef consumer = CGDataConsumerCreateWithCFData(data);
}
打开 Instruments - Leaks,选择目标设备和应用,然后点击🔴按钮,时间线面板就开始记录当前内存的使用情况:

可以看出,图中 28 s 的位置出现了内存泄漏,泄漏点刚好在 testMemoryLeak 方法上。
修改 Details 栏的 Leaks 选项,切换到 Call Tree,⌘ + 2 键切换到 Display Settings,然后勾选右边设置栏中的 Invert Call Tree 和 Hide System Libraries 选项,可以看到泄漏点具体的调用栈:

双击其中一个方法,Instruments 还会把出错的具体代码标识出来:

问题果然出现在 CFDataCreateMutable 和 CGDataConsumerCreateWithCFData 上,根据 Core Foundation 中 关于方法命名的约定,含有 Copy 和 Create 的方法返回的对象需要调用 CFRelease 来释放,Core Graphics / Core Text 也一样,所以需要在 testMemoryLeak 方法中加入这两行,以解决这里的内存泄漏问题:
CGDataConsumerRelease(consumer);
CFRelease(data);
Java、C++ 等 OOP 语言有一个抽象类的概念,即一个类实现了部分方法,另一部分的方法必须由继承它的子类来实现。Objective-C 在设计上没有这个概念,转而提供了用途类似的 协议,除了不能给方法加默认实现以外,与抽象类的用法大体相同。但是在实际项目中,让一个协议实现一些共通的方法还是很有必要的,比如很多类都遵守了某一个协议,而这个协议中某一个方法的实现大体上都一样的时候,在每一个子类内部都 copy 一份同样的代码就不太合适了。
一种规避 copy 的做法是把它的实现抽离到全局方法中,比如下面的协议:
@protocol MyProtocol <NSObject>
- (void)method1;
- (void)method2;
@end
如果所有子类的 method2 的实现都差不多,就可以将它抽到一个全局方法(或者一个单例类的方法)中:
void MyProtocolMethod2(id<MyProtocol> instance) {
// Do with myprotocol...
}
另一种办法是抛弃 @protocol,直接使用 @interface,然后使用文档说明的方式约定它是一个抽象类:
// MyBaseClass.h
@interface MyBaseClass : NSObject
/// 这个方法必须由子类重写
- (void)method1;
/// 这个方法可以被子类重写
- (void)method2;
@end
// MyBaseClass.m
@implementation MyBaseClass
- (void)method1 {
// 如果没有重写就报错...
NSAssert(method_getImplementation(class_getInstanceMethod(self.class, _cmd)) !=
method_getImplementation(class_getInstanceMethod([MyBaseClass class], _cmd)),
@"method1 must be overriden!");
}
- (void)method2 {
// A default implementation...
}
@end
以上两个方法都可以达成目的,但都有一些缺陷:前一种方法把 MyProtocol 相关的代码放到了全局环境中,不优雅;后一种方法在编译阶段没有提示,需要由开发人员仔细阅读文档才能避免误用。StackOverflow 的一篇答案还提供了另一个方案:在每一个子类的 +initialize 方法中通过 class_addMethod 把协议的默认实现加到方法列表当中,但这样也略显繁琐。
一个第三方库 libextobjc 通过 EXTConcreteProtocol 神奇地实现了这个功能,使用方法与原生协议类似:
// MyProtocol.h
@protocol MyProtocol <NSObject>
@required
- (void)method1;
@concrete
- (void)method2;
@end
// MyProtocol.m
@concreteprotocol(MyProtocol)
- (void)method1 {}
- (void)method2 {
// A default implementation
}
@end
这样声明以后,对于任何遵守 MyProtocol 协议的类,如果没有重写 method2 方法,都会有一个在 MyProtocol.m 中声明的默认实现。
这个库为什么这么吊,@concrete 和 @concreteprotocol 到底做了什么。其实 concrete 只是 optional 的别名,为了提示调用者就算不重写这个方法也一定会有的,重点还是在 concreteprotocol 宏上。
查看 EXTConcreteProtocol 源码可以知道,@concreteprotocol(MyProtocol) 这一行通过宏定义的方式生成了这样的一个包装类:
@interface MyProtocol_ProtocolMethodContainer : NSObject <MyProtocol>
@end
@implementation MyProtocol_ProtocolMethodContainer
+ (void)load {
if (!ext_addConcreteProtocol(objc_getProtocol("MyProtocol"), self))
fprintf(stderr, "ERROR: Could not load concrete protocol %s\n", "MyProtocol");
}
__attribute__((constructor))
static void ext_MyProtocol_inject (void) {
ext_loadConcreteProtocol(objc_getProtocol("MyProtocol"));
}
@end
其中 ext_addConcreteProtocol 在 load 方法中被调用,它的作用是把将要对 MyProtocol 进行的注入操作缓存到一个全局列表中,除此之外还有一些边界条件的判断和加锁什么的。
__attribute__((constructor)) 是 GCC 的一个编译器指令(其实是 Clang 的指令,但我翻遍了 Clang 的官方文档并没有找到关于 constructor 的描述- -),被它标记的函数会在整个 Objective-C runtime 初始化完毕之后,在 main() 函数之前被调用。这时 ext_loadConcreteProtocol 函数会遍历 runtime 中所有的 Class,对其中每一个遵从 MyProtocol 协议的 Class 进行缓存过的注入操作:
if (class_getInstanceMethod(metaclass, selector)) {
// it does exist, so don't overwrite it
continue;
}
// add this class method to the metaclass in question
IMP imp = method_getImplementation(method);
const char *types = method_getTypeEncoding(method);
if (!class_addMethod(metaclass, selector, imp, types)) {
fprintf(stderr, "ERROR: Could not implement class method +%s from concrete protocol %s on class %s\n",
sel_getName(selector), protocol_getName(protocol), class_getName(class));
}
虽然调用层级很复杂,但最终还是调用了 class_addMethod 方法给 Class 自动加上了默认的实现,原理跟上面的 StackOverflow 给的答案是一样的。
问题由一个项目需求引起。设计MM给的图大概像这样:

如上图所示,列表的内容由服务器传回,且用户可编辑,显然这样的界面应该用 UICollectionView 来搭建。实现关闭按钮则需要在 UICollectionViewCell 的右上角添加一个 UIButton,并且要将 Cell 的 clipsToBounds 属性设置为 NO 以避免按钮被切掉一部分。
界面搭建好了,但默认状态下 UIButton 的点击响应范围跟它的显示区域一样小,导致这个按钮很难被点到,因此首先要解决的是这个按钮的热区扩展问题。
网络上介绍 UIButton 响应区域扩展的文章有很多,但大多数都是继承 UIButton 然后重写 pointInside:withEvent: 方法。实际上这种子类继承的方法在很多场景下是不合适的,因为这意味着每次用到这个功能都要将系统 UIButton 换成自己的子类。一个更符合 Objective-C Style 的方法是增加一个 UIButton 的 Category,然后在不影响控件默认的行为的情况下在 Category 里做文章。
新建一个 UIButton+ExpandHitArea 分类,像这样在头文件中添加一个 hitTestEdgeInsets 属性,用来配置热区扩展(根据语意实际上是缩小)的范围:
@interface UIButton (ExpandHitArea)
@property (nonatomic) UIEdgeInsets hitTestEdgeInsets;
@end
在 .m 文件中,我们将 pointInside:withEvent: 替换成修改过的方法:先将 hitTestEdgeInsets 的值与 UIEdgeInsetsZero 相比较,如果不等,则调用我们自己的扩展热区的逻辑;如果相等,则调用系统默认的 pointInside:withEvent: 方法,就像什么事也没发生过一样。最终代码如下:
@implementation UIButton (ExpandHitArea)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
YTSwizzleMethod([self class], @selector(pointInside:withEvent:), @selector(yt_pointInside:withEvent:));
});
}
- (UIEdgeInsets)hitTestEdgeInsets {
NSValue* value = objc_getAssociatedObject(self, _cmd);
UIEdgeInsets insets = UIEdgeInsetsZero;
[value getValue:&insets];
return insets;
}
- (void)setHitTestEdgeInsets:(UIEdgeInsets)hitTestEdgeInsets {
NSValue* value = [NSValue value:&hitTestEdgeInsets withObjCType:@encode(UIEdgeInsets)];
objc_setAssociatedObject(self, @selector(hitTestEdgeInsets), value, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (BOOL)yt_pointInside:(CGPoint)point withEvent:(UIEvent*)event {
UIEdgeInsets insets = self.hitTestEdgeInsets;
if (UIEdgeInsetsEqualToEdgeInsets(insets, UIEdgeInsetsZero)) {
return [self yt_pointInside:point withEvent:event];
} else {
CGRect hitBounds = UIEdgeInsetsInsetRect(self.bounds, insets);
return CGRectContainsPoint(hitBounds, point);
}
}
@end
其中在 load 方法里的 YTSwizzleMethod 是 Method Swizzling 的具体实现,因为这段代码经常被用到所以我把它抽了出来,以下是函数的内容:
void YTSwizzleMethod(Class cls, SEL originalSelector, SEL swizzledSelector) {
Method originalMethod = class_getInstanceMethod(cls, originalSelector);
Method swizzledMethod = class_getInstanceMethod(cls, swizzledSelector);
BOOL didAddMethod = class_addMethod(cls, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod));
if (didAddMethod) {
class_replaceMethod(cls, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizzledMethod);
}
}
在工程中引入这个 Category 之后,我们对任意一个 UIButton 设置它的 hitTestEdgeInsets 属性,都可以将它的点击响应区域扩大或缩小。在本文的例子里,扩展 12 pt 差不多是个合适的值:
_closeButton.hitTestEdgeInsets = UIEdgeInsetsMake(-12, -12, -12, -12);

如图所示,按照上一段的步骤配置好以后,按钮的响应区域理应变成图中整块高亮的部分,但实际运行后真正的响应区域只有左下角的 A 部分。这是因为 UIKit 在检测点击响应区域时,首先询问的是父控件的 pointInside:withEvent: 方法,如果返回 NO,那么 UIKit 就认为点击区域在整个控件范围之外,不会继续遍历子控件,因此 Cell 的响应区域也需要跟随按钮一起扩展。除此之外我们还需要屏蔽掉 Cell 本身的事件响应,以防下一个 Cell 覆盖掉了上一个 Button 扩展后的热区 (图中 B 区域)。所以重写 UICollectionViewCell 的两个相关方法,内容如下:
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
CGPoint pointInButton = [self convertPoint:point toView:_closeButton];
return [_closeButton pointInside:pointInButton withEvent:event] ? _closeButton : nil;
}
- (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event {
CGPoint pointInButton = [self convertPoint:point toView:_closeButton];
return [_closeButton pointInside:pointInButton withEvent:event];
}
NSOperation 类有一个属性 name,用以标记一个 NSOperation 对象。苹果提供这个属性的本意是为了调试方便,但实际上通过它我们还可以简便地实现一些业务需求,比如加入 NSOperationQueue 前检查去重和排序什么的。
但很可惜,这个属性是 NS_AVAILABLE(10_10, 8_0) 的,换言之如果在 iOS 7 以下的系统上使用这个属性的话,控制台会打印这样一行错误:
unrecognized selector sent to instance
如果项目需要兼容 iOS 7 系统的话,我们就需要寻找一种方法,在 iOS 7 上也能方便地标记一个 NSOperation 。
扩展一个已有的 Objective-C 类一般有两种方法: Subclass 和 Category 。这里我们选择 Category ,这样可以在扩展 NSOperation 的同时也扩展 NSBlockOperation 等它的子类。
使用 OC Runtime 的两个函数 objc_getAssociatedObject 和 objc_setAssociatedObject ,可以方便地在 Category 中给一个类增加属性。代码大概像这样:
- (NSString*)xxx_name {
return objc_getAssociatedObject(self, _cmd);
}
- (void)xxx_setName:(NSString*)name {
objc_setAssociatedObject(self, @selector(xxx_name), name, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
在头文件里声明 xxx_name 属性以后,在需要调用 name 的地方都改成 xxx_name,就可以完美兼容 iOS 7 以上的所有机型了。
以上方法虽然实现了功能,但实际上我们抛弃了苹果提供的接口,这实在跟标题的优雅沾不上边。所以还需要继续使用 OC Runtime 的黑魔法,来尝试实现『安全地在低版本上调用高版本才有的API,同时完全不影响高版本API的功能』这个目的。
OC Runtime 有三个基础类型IMP,SEL和Method,它们的内容如下表:
| 名词 | 定义 | 说明 |
|---|---|---|
| Selector | typedef struct objc_selector *SEL | 表示一个 OC 对象方法的方法名 |
| Implementation | typedef id (*IMP)(id, SEL, …) | 实际上是一个函数指针,指向了 Selector 对应的方法的具体实现 |
| Method | typedef struct objc_method *Method | 封装了从 Selector 到 Implementation 的映射关系 |
三者之间的联系,可以用NShipster的一段话来总结:
A class (Class) maintains a dispatch table to resolve messages sent at runtime; each entry in the table is a method (Method), which keys a particular name, the selector (SEL), to an implementation (IMP), which is a pointer to an underlying C function.
在 NSOperation 的 Category 中,我们尝试调用与之相关的 OC Runtime 函数来实现以上目的:
+ (void)load
{
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class class = [self class];
SEL nameSEL = @selector(name);
SEL setNameSEL = @selector(setName:);
Method nameMethod = class_getInstanceMethod(class, nameSEL);
Method setNameMethod = class_getInstanceMethod(class, setNameSEL);
if (!nameMethod)
{
SEL xxxNameSEL = @selector(xxx_name);
Method xxxNameMethod = class_getInstanceMethod(class, xxxNameSEL);
class_addMethod(class, nameSEL, method_getImplementation(xxxNameMethod), method_getTypeEncoding(xxxNameMethod));
}
if (!setNameMethod)
{
SEL xxxSetNameSEL = @selector(xxx_setName:);
Method xxxSetNameMethod = class_getInstanceMethod(class, xxxSetNameSEL);
class_addMethod(class, setNameSEL, method_getImplementation(xxxSetNameMethod), method_getTypeEncoding(xxxSetNameMethod));
}
});
}
- (NSString*)xxx_name
{
return objc_getAssociatedObject(self, @selector(xxx_name));
}
- (void)xxx_setName:(NSString*)name
{
objc_setAssociatedObject(self, @selector(xxx_name), name, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
在以上代码中,+ (void)load 函数调用的时候,我们通过运行时的操作,来实现以下两个步骤:
name 和 setName: 方法,使用我们自己的实现。把这个 Category 加入工程以后,我们就可以安全地在 iOS 7 以上使用 NSOperation 的 name 属性了,好像它原生支持了低版本的 iOS 一样。
本文演示了通过 OC Runtime 来优雅地为 NSOperation 的 name 属性增加了 iOS 7 以下的支持。实际上不止是 NSOperation,通过这个方法,很多高版本 iOS 新增的 API(比如 [NSString containsString:] 等)都可以用同样的方法移植到低版本系统上,只需要我们自己模拟实现相应的功能,然后通过 Category 提供给相应的 Selector 就可以了。