详解ios crash分析

原始文档及Demo可以在 git@gitlab.pri.ibanyu.com:cailei5072/d_palfish_ios.git crash-analyze文件夹下找到

一、各工程说明

lib/MyCrashLib

这个是库工程,其中Target Universal 用来生成对外的Fat Framework, 支持 x86_64 (simulator), arm64 (device)

lib/TestMyCrashLib

直接依赖了MyCrashLib工程,库提供方自己测试用

app/MyCrashApp

依赖Fat Framework,每次Framework发版都需要手动更新,模拟了业务方使用三方Framework的场景

二、crash分析

工程设置的一些定义

<

h3>选项 |

<

h3>值
:————————– | :————-
Build Active Architecture Only | NO
Debug Information Format | DWARF with dSYM File
Strip Debug Symbols During Copy | NO

准备工作

  • lipo -info 确认支持x86_64和arm64
  • 找到xcode自带的symbolicatecrash

可以在path中的路径下生成软链接

sudo ln -s /Applications/Xcode.app/Contents/SharedFrameworks/DVTFoundation.framework/Versions/A/Resources/symbolicatecrash /usr/local/bin/symbolicatecrash
  • export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"

可以添加到启动文件

# ~/.zshrc

# 在最后一行添加
export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"
  • app.dSYM 和 lib.dSYM 放到同一个路径下

开始生成

  • 在手机上运行App,分别点击 AppLib,让其crash在不同的地方
  • ./symbolicatecrash xxx.crash的文件路径 xxx.app.dSYM的文件路径 > log.crash
  • mdfind "com_apple_xcode_dsym_uuids == *"检查下列出的.dSYM全不全

验证

dwarfdump -u dwarfdump -u 可以查看 UUID

➜  快速Dump-Crash实验 git:(master) ✗ dwarfdump -u MyCrashApp.app.dSYM
UUID: D2FD226E-7F12-362F-809D-DC2EBB02A67E (arm64) MyCrashApp.app.dSYM/Contents/Resources/DWARF/MyCrashApp

➜  快速Dump-Crash实验 git:(master) ✗ dwarfdump -u MyCrashLib.framework.dSYM
UUID: 3D50094C-0B91-3D5E-8E1E-110F4F7474C6 (arm64) MyCrashLib.framework.dSYM/Contents/Resources/DWARF/MyCrashLib

.crash文件里载入image时应有

Binary Images:
0x102b1c000 - 0x102b23fff MyCrashApp arm64  <d2fd226e7f12362f809ddc2ebb02a67e> /var/containers/Bundle/Application/D26B9146-4F17-4F5A-AE8F-2FF11D6F2B3B/MyCrashApp.app/MyCrashApp
0x102b4c000 - 0x102b53fff MyCrashLib arm64  <3d50094c0b913d5e8e1e110f4f7474c6> /var/containers/Bundle/Application/D26B9146-4F17-4F5A-AE8F-2FF11D6F2B3B/MyCrashApp.app/Frameworks/MyCrashLib.framework/MyCrashLib


Binary Images:
0x100450000 - 0x100457fff MyCrashApp arm64  <d2fd226e7f12362f809ddc2ebb02a67e> /var/containers/Bundle/Application/D26B9146-4F17-4F5A-AE8F-2FF11D6F2B3B/MyCrashApp.app/MyCrashApp
0x100474000 - 0x10047bfff MyCrashLib arm64  <3d50094c0b913d5e8e1e110f4f7474c6> /var/containers/Bundle/Application/D26B9146-4F17-4F5A-AE8F-2FF11D6F2B3B/MyCrashApp.app/Frameworks/MyCrashLib.framework/MyCrashLib

App UUID :

  • D2FD226E-7F12-362F-809D-DC2EBB02A67E

Lib UUID :

  • 3D50094C-0B91-3D5E-8E1E-110F4F7474C6

两次crash虽然载入的内存段不一样,但是UUID和dSYM中的保持一致

<

h3>

注意:每次代码改动后,生成的UUID会变化,所以一定保证release版本的ipa,dSYM,和git的tag保持一致

三、没有耐心?

已经按照上述做了个可以快速验证结果的,放到了 快速Dump-Crash实验

运行命令

symbolicatecrash app.crash . > app.log
symbolicatecrash lib.crash . > lib.log

并查看结果

iOS 从AFNetworking中获取请求度量信息

事情起因是想更多的获取到iOS客户端访问网络情况的一些信息。
于是翻看iOS网络库文档, 主要是NSURLSession相关的,找到一个NSURLSessionTaskMetrics,里边记录了每次Http请求中的可能多个重定向中每一次的具体情况的时间度量信息(记录在NSURLSessionTaskTransactionMetrics), 包括请求开始时间、DNS解析开始时间、DNS解析结束时间、开始连接时间、响应开始时间等等具体的信息, 虽然还是没有例如连接的服务器ip等信息,但总归是多了不少信息。

查看头文件,系统是通过是通过NSURLSessionTaskDelegate 的如下方法给的NSURLSessionTaskMetrics的回调信息。

/*
 * Sent when complete statistics information has been collected for the task.
 */
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics API_AVAILABLE(macosx(10.12), ios(10.0), watchos(3.0), tvos(10.0));

绝大部分iOS客户端不会直接用NSURLSession,而是会借助第三方的网络库(AFNetworking)的封装,来更简单的使用,伴鱼也不例外,所以能不能把这个统计信息拿出来就看AFNetworking给不给力了…

查看了项目中现在用的AFNetworking源码,AFURLSessionManager 实现了NSURLSessionTaskDelegate, 但是搜一下“didFinishCollectingMetrics”,并没有实现这个方法,也就是,没办法直接用AFNetworking的api来做这件事情了。

第一时间想到的是不是升级下AFNetworking就好了, 于是来到AFNetworking的Github主页,还是这个类AFURLSessionManager, 找最新的代码搜了下,搜到了下边这个方法:

- (void)URLSession:(NSURLSession *)session
              task:(NSURLSessionTask *)task
didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics
{
    AFURLSessionManagerTaskDelegate *delegate = [self delegateForTask:task];
    // Metrics may fire after URLSession:task:didCompleteWithError: is called, delegate may be nil
    if (delegate) {
        [delegate URLSession:session task:task didFinishCollectingMetrics:metrics];
    }

    if (self.taskDidFinishCollectingMetrics) {
        self.taskDidFinishCollectingMetrics(session, task, metrics);
    }
}

一阵欣喜,看来生个级就好了,pod update一下,开心的等了不少时间执行完了之后发现,本地的AFNetworking代码里边还是没有这个方法… 看了下pod执行的提示,原来AFNetworking已经是最新的3.2.1版了,再仔细看Github上是看的master代码,切换到tag 3.2.1, 果然没有了这个方法…3.2.1是好久之前发布的了,AFNetworking不知道啥时候才能再发布把现在master的代码放上去,肯定是等不及它发布的。

那就想办法把这个信息从AFNetworking了里拿出来吧,OC办法会比较多的,想了下AFURLSessionManager既然没有实现这个方法,我们帮它实现好了,利用OC Runtime方式,把实现加到这个类上应该就可以了,动手实现了下:

@implementation XCHttpClient

+ (void)load
{
    Class afHttpSessionManagerClass = AFHTTPSessionManager.class;
    Class clientClass = self;
    SEL selector = @selector(URLSession:task:didFinishCollectingMetrics:);
    Method method = class_getInstanceMethod(clientClass, selector);
    IMP imp = class_getMethodImplementation(clientClass, selector);
    BOOL success =  class_addMethod(afHttpSessionManagerClass, selector, imp, method_getTypeEncoding(method));
    NSLog(@"af ---> class success: %@", @(success));
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics
{
    NSLog(@"didFinishCollectingMetrics.... %@", metrics);
}

这里只是为了试验没有考虑代码结构合理性,正常应该单独搞一个类来处理这个逻辑。断点、运行, 成功了…拿到了时间信息。
(此处后来想到有更简单的方式,不用runtime,读者可以简单思考下…)

还有一个问题是,之前是在请求成功或失败,做了一些处理,然后记录的网络请求信息,如何把这个详细的时间信息交给到请求成功或者失败的block里边,从而跟之前的逻辑串起来?

很自然的想到,是不是在回调中把信息记录到NSURLSessionTask这个对象上,这样成功或者失败都从这个对象把信息取出来?

写代码尝试了一下(标准的利用关联对象生成新属性):

static char XCHttpClientMetrics;
@interface NSURLSessionTask (XCHttpClientMetrics)
@property (nonatomic, strong) NSURLSessionTaskMetrics *metrics;
@end
@implementation NSURLSessionTask
- (void)setMetrics:(NSURLSessionTaskMetrics *)metrics
{
    objc_setAssociatedObject(self, &XCHttpClientMetrics, metrics, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (NSURLSessionTaskMetrics *)metrics
{
   return objc_getAssociatedObject(self, &XCHttpClientMetrics);
}
@end

相应的回调方法修改下实现如下:

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics
{
    task.metrics = metrics;
}

运行,Crash !!!, 未识别的selector,仔细检查了代码,没有问题,打断点调试下:

(lldb) po [task respondsToSelector:@selector(setMetrics:)]
NO

(lldb) po task.class
__NSCFLocalDataTask

(lldb) po task.superclass
__NSCFLocalSessionTask

(lldb) po task.superclass.superclass
__NSCFURLSessionTask

很熟悉的苹果的做法,用一个私有类替换了NSURLSessionTask,所以确实不响应setMetrics这个selector。
怎么办?给这个私有类加一个属性吗? 理论上是可以做的,不过想想好累…
参考下AFNetworking的做法吧,AFNetworking应该也给task记录了一些信息,不然没办法从回调的方式转成success/fail回调的方式,翻看代码:

- (void)setDelegate:(AFURLSessionManagerTaskDelegate *)delegate
            forTask:(NSURLSessionTask *)task
{
    NSParameterAssert(task);
    NSParameterAssert(delegate);

    [self.lock lock];
    self.mutableTaskDelegatesKeyedByTaskIdentifier[@(task.taskIdentifier)] = delegate;//关键代码
    [self addNotificationObserverForTask:task];
    [self.lock unlock];
}

关键是每个task有一个整数标识(taskIdentifier)! 于是解决方案就很简单了,找个地方存一个map,用taskIdentifier作为key,具体的metrics信息作为value,然后在成功或者失败回调里根据key把信息取出来就可以了。

由于代码太简单,就不贴了…测试通过。能看到metrics信息里边对于ip直连的请求dns解析开始时间等信息都是null了,对于复用了连接的请求,connect时间也是null了。仔细分析metrics信息又是另外一个话题了,需要模拟下各种网络环境分析成功或失败请求的数据,就不在这里分析了。

注意: 根据文档Task对象并不是从一开始请求,到请求结束都是用的同一个Task对象,而是可能中间会换成另外一个对象,只是identifier应该是相同的,可以参考方法- (void)URLSession:(NSURLSession *)session
dataTask:(NSURLSessionDataTask *)dataTask
didBecomeDownloadTask:(NSURLSessionDownloadTask *)downloadTask
的文档,也可以试验一下。

如何实现一个简单的WebViewJavascriptBridge

在移动应用的开发的过程中,经常会有应用内部的活动,以及部分对灵活性要求很高的功能会采用基于WebView的方式实现,用来免去应用提交到应用市场的审核时间,以及用户的升级成本。这种最基础的Hybrid应用场景中,WebView页面中的javascript环境经常需要共享native环境(iOSAndroid)中的部分数据,例如: 用户登录授权信息、应用的版本信息等等,以及需要借助native的能力实现某些功能,例如: 录音、拍照、GPS位置信息、蓝牙等等,还有另外的场景是重用部分native已经实现的功能,例如:跳转到App内其他页面,调起native的某个功能弹窗等等,因此提供一个js native交互的通道是非常基础的一个需求。

原理

如果对AndroidiOS 的相应的WebViewapi比较熟悉的话,有这么几个API可以实现javascriptnative之间的互相调用。

Android下边的android.webkit.WebView, javascript调用native的实现方式:

首先在native端调用addJavascriptInterface (Object object, String name),将Java对象object注册到Javascript环境的主Frame上,这样就可以在javascript中调用到nativeJava对象的方法。为了安全方面的考虑在JELLY_BEAN_MR1 版本以上的Android系统中,只有Java对象的公开的并且有JavascriptInterface注解的方法才能被Javascript调用到。

native调用Javascript的方式:

KitKat版本之后,WebView提供了一个evaluateJavascript方法,执行传入的字符串表示的js代码并能返回结果。在这个版本之前只能通过loadUrl(“javascript:”)这种方式执行js代码。

iOS8新加入了WebView的实现WKWebView, 现在基本上应用支持到iOS8以上就够了,因此这里只考虑WKWebView。在WKWebViewjs调用native端可以通过WKUserContentControlleraddScriptMessageHandler:name实现,在native中调用这个方法将相应的handler添加到WKWebViewWKUserContentController上,在Javascript中就可以通过调用window.webkit.messageHandlers.handlerName.postMessagejs消息发送到nativeWKWebView同样实现了一个跟AndroidWebView类似的evaluateJavaScript的方法,用来在native端直接在WKWebViewjs环境执行字符串表示的Javascript代码。

业内的开源实现

当然业内也有比较出色的开源实现,最出名的是marcuswestin/WebViewJavascriptBridge, 这是一个iOS下边的WebViewJavascriptBridge库实现,实现了一个iOSjs-native双向调用的bridge,可以支持UIWebViewWKWebView。这个项目的解析在网上很多,在这里不再赘述。只讲几个很关键的设计:

1) 执行环境的保持

jsnative的互相调用过程中,如果能保持执行环境,以js调用native举例,如果可以封装给上层一个调用接口,同时支持方法回调,这样上层的代码写起来就会非常顺畅。这个的实现其实也比较简单,给回调生成一个唯一id,在js端保存这个id和回调方法的映射,将id传给native, native端处理完,回调的时候同时将这个id传回给js端,这样js端就能找到相应的回调函数并执行。

2) 消息排队机制

native端和js端,这个库都实现了一个消息排队机制,用来缓存bridge准备好之前的消息,这样上层就可以不再关心bridge是否准备好了的逻辑,更方便调用。

3) iFrame的方式

js端新插入了一个不可见的iFrame,通过修改这个iFramesrc为自定义的schema来通知native端更新,这样可以保证完全不影响任何主Frame的逻辑。当然这种方式主要受限于UIWebView的接口,只能通过修改src的方式来让native端监听到变化,如果是WKWebView则完全没有必要用这种方式处理。

简化的设计

我们希望做一个iOS/Android下边通用的WebViewJavascriptBridge,在js端、iOS端、Android端统一接口,因此并不能直接使用这个开源实现。理论上我们可以根据这个库的实现,在js端稍作修改,判断Android环境上走javascript调用java的方法,而不是修改iFramesrc,同时在Java端解析发过来的消息,跟iOS端一样的解析和处理逻辑,就能实现三端统一的接口。

但是,这是最简单的方式吗?结合我们自己的需求,因为不需要兼容UIWebView, iOS端和Android端都可以在一开始就将相应handler注册进去,所以js一开始加载就可以调用到相应的handler,在js端不需要做消息排队机制,完全可以直接调用。同样因为不用兼容UIWebViewjs也就没必要创建iFrame来实现通知native端消息。那么native端消息排队的机制是必须的吗?如果native主动调用javascript,而javascript加载是异步的,native端除非限制只能在页面加载完之后再调用,否则必须增加消息排队机制,来缓存bridge准备好之前的调用消息。

我们考虑软件分层设计,把客户端作为更为基础的不易变的下层,把h5做为更灵活易变的上层,通常下层是不允许主动调用上层的,而是提供好基础的接口,等待上层调用,如果有状态变化或者其他需要通知上层的,通常是通过上层注册通知回调、消息监听等机制来实现。因此我们应该只需要实现js调用native的方法,并支持方法回调的异步处理机制,以及支持消息监听机制即可满足所有需求。

js调用native的方法可以很自然的联想到RPC(远程过程调用)js等价于RPC中的客户机,native等价于RPC中的服务端,中间的传输不是经过网络而是经过webview提供的js环境和native环境之间的字符串传输。因此可以参考RPC的设计,js需要实现的是每次调用生成唯一id,组装方法名和参数,保存这次调用的回调方法,将这些信息:调用id,调用方法名、方法参数转成字符串,传递给nativenative需要实现的就是接收方法名、方法参数,然后找到对应的方法并执行,并将执行结果返回给jsjs再根据客户端传回的调用id和结果信息找到相应的回调方法并执行。实现js监听native的事件也非常简单,对比RPC调用的每次调用一次回调,监听的方式是一次调用(或者说注册监听),可以允许多次回调,这样native需要将js端注册的回调记录下,当事件发生时,多次调用这个回调,js端在收到native的回调成功后不把回调方法移除,这样就能够收到native的多次回调。

模块化

如果native提供方法很多,则需要根据不同的业务来提供不同的方法实现。可用的模块化方式很多,比如最基本的每个模块自己注册js方法的处理函数的方式,在iOS下也可以利用OCruntime机制将js调用的方法名映射到OC中的方法,在Android下类似,也可以利用Java的反射机制将js调用的方法名映射成Java的类和方法名。

安全性

如果我们nativewebview打开了一个第三方的网站,如果这些方法暴露给了这些第三方网站的js,假设native实现的方法返回了一些敏感信息,那么第三方网站就能获取这些敏感信息。因此native需要判断调用的合法性,我们自己网站可以全部支持httpsnative需要禁止任何http站点的调用,强校验站点的https证书,只有自己站点才允许调用native提供的方法。当然native提供的方法最好不要返回任何敏感信息。