使用 Xcode 运行时工具帮助排查 Bug

作者:PMST,博客: http://www.jianshu.com/u/596f2ba91ce9

1. 介绍

iOS 应用程序主流开发 IDE 包括:苹果自家的 Xcode,JetBrains 的 AppCode 以及刚刚登陆 MacOS 的宇宙第一 IDE Visual Studio。其中 Xcode 被吐槽较多,功能少,时不时地转菊花,甚至给你 crash 一下,不过我们也可以看到这几年苹果开发团队在工具这块的努力,比如去年加入的 Memory Graph 功能——觉得非常好用,这里给出卓同学的博客文章Xcode8调试黑科技:Memory Graph实战解决闭包引用循环问题。而今年同样又带来了更多运行时工具帮助我们排查和解决问题,闲话少说,开始我们的正题:

日常开发中,我们所写的程序到可执行文件需要经过四个步骤:预处理,编译,汇编和链接,其中编译过程又可分为:1. 词法分析,将代码的字符序列分割成一系列的词法单元(token);2. 语法分析,生成抽象语法树(AST);3. 语义分析,检查表达式是否合法有效;4. 源代码优化器,用于生成与机器无关的中间代码表示(IR);5. 最后是代码生成器和目标代码优化器,用于生成汇编代码以及做一些优化处理。

其中编译过程中所能分析的语义是静态语义,是在编译期间就已经可以确定的语义,比如除数不能为 0;而与之相对的动态语义则是在运行时才能确定的语义。前者 Xcode 会将发现的问题集中显示在 Issue navigatorBuildtime 一栏中:

我们会经常遇到错误、警告、静态分析问题以及单元测试失败信息:

那么程序运行时的动态语义分析问题我们又该如何定位和排查呢? 这正是 Session 406 所要呈现的!而运行时问题符号标识想必你也不陌生了。

2. Runtime Checking ——运行时检查工具

测试环境要求:Xcode 9.0 beta 及以上

首先通过下面两种方式打开 Edit Scheme 面板:

  • 点击项目 Target 选中 Edit Scheme
  • Xcode 顶部菜单栏中按照 Product->Scheme->Edit Scheme 路径进入面板

此时你应该看到如下界面:

图形中红色矩形框标识了 4 种运行时检测工具,分别是:

  • Main Thread Checker 默认开启,新功能
  • Address Sanitizer
  • Thread Sanitizer
  • Undefined Behavior Sanitizer — 新功能

2.1 Main Thread Checker

2.1.1 功能概述

众所周知,某些 API 当且仅在主线程中被调用使用,例如 AppKit 和 UIKit 两个框架操作;而其他诸如文件下载、图片处理等操作则可以放置到后台线程中执行,如下:

Image Processing 图片处理在后台线程中执行完毕,会更新 ImageView 中的图片内容,若更新操作发生在 Background Thread 中,Main Thread Checker 会检测并给出警告,如下:

正确做法是将 UI 更新操作放置到主线程中执行。

2.1.2 Demo 演示

Demo 下载地址:MainThreadCheckerDemo
演示功能:检测 UI 更新操作是否发生在主线程。

打开 Demo 工程,首先确保 Edit Scheme 面板中开启了 Runtime API Checking;工程相当简单,视图中放置了一个Button 和一个 Label,点击按钮将繁重的任务加入到全局队列中(并发队列)去执行,由 GCD 来负责派发任务到某个线程(非主线程),延迟5秒后更新 Label 的显示文字,但是此时 UI 更新操作并未处于主线程中,这显然是不正确的!是时候运行工程来看看Main Thread Checker 是否能够帮助我们排查出这个问题:

点击按钮等待 5 秒,Xcode 聪明地定位到问题代码,并给出了运行时问题:

2.1.3 易发生错误的地方

  • 网络回调
  • 创建和销毁 NSViewUIView 等对象
  • 设计异步接口时

重点讨论如何设计异步接口,思考下面这个异步接口有什么问题?

DeepThought.asyncComputeAnswer(to: theQuestion) { reply in ...
    // 任务执行完毕 回调允许你做一些事情
}

该接口对传入的 theQuestion 问题进行异步计算,为了不阻塞主线程,将计算放置到某个线程中异步计算,等到计算完毕则回调让你处理一些事情,而此时处于什么线程并未指定!倘若你放置一些 UI 更新行为显然是不正确的。因此我们需要对接口做出些许改动,由调用方来指定回调闭包到哪个队列执行:

DeepThought.asyncComputeAnswer(to: theQuestion, completionQueue: queue) { reply in 
    // 这里指定了回调闭包的队列 如果是UI更新行为,指定 main queue 即可
}

2.1.4 小结

  • 检测违反 API 线程规则的行为
  • AppKit 和 UIKit 接口必须在主线程中调用
  • 适用于 Swift 、C 语言
  • 不需要重新编译
  • 默认开启

更多有关 GCD 知识,可阅读 Grand Central Dispatch(GCD) 深入浅出 一文。

2.2 Address Sanitizer —— 检测内存问题

我们将通过几个示例来介绍 Address Sanitizer 工具的强大之处,当然你若是想深入了解这个工具,不妨看看 WWDC 2015 Advanced Debugging and the address Sanitizer 这个 Session,希望对你有帮助。

2.2.1 检测 use-after-free 问题

Demo 下载地址: AddressSanitizerDemo

演示功能:使用已经释放内存的对象或指针,Xcode 会给出错误信息。

打开 Demo 工程,首先确保 Edit Scheme 面板中勾选了Address SanitizerDetect use of stack after return以及内存管理中的 Malloc Scribble,如下:

查看工程 AppDelegate.m 代码:

@interface AppDelegate ()

@end

@implementation AppDelegate
/// 1
char * buffer;

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    /// 2
    buffer = malloc(32);
    snprintf(buffer, 32, "Hello, World!");
    NSLog(@"%s",buffer);
    free(buffer);
    return YES;
}

- (void)applicationWillTerminate:(UIApplication *)application {
    /// 3
    NSLog(@"%s",buffer);
}
@end
  1. 声明一个 char * 类型指针 buffer,此时并未指向任何有效内存块;
  2. 使用 malloc 方法为 buffer 分配一块大小为 32 字节的内存块,并初始化内容为Hello,World!;接着使用 NSLog 打印字符串;最后释放掉 buffer 指针指向的内存;
  3. 在应用程序即将终止前打印buffer 内存 —— 此时 buffer 早已经释放。

点击运行程序,看到终端输出 Hello, World!;杀掉程序(同时按下shift+command+H两次),观察 Xcode 给出的错误信息:

观察左侧 Debug navigator 面板,居然输出 buffer 在何处创建以及在何处被释放!这个功能实在强大,帮助我们排查和定位内存问题。

2.2.2 检测 use-after-scope 问题

首先思考下面这段代码存在哪些问题:

int *integer_pointer = NULL; // 1
if (is_some_condition_true()) {
    int value = calculate_value(); //2
    integer_pointer = &value; // 3
}
*integer_pointer = 42; // 4
  1. 首先定义一个 int 类型的指针 integer_pointer
  2. if 作用域中我们定义了 value 接收 calculate_value函数返回的计算结果;
  3. 将指针 integer_pointer 指向 value 的内存地址;
  4. 修改 integer_pointer 指向内存块的值。

乍看之下没有任何问题,点击运行 2.2.1 中的示例:

Xcode 抛出了 Use of out-of-scope stack memory 错误,问题原因主要在于变量 value 的作用域是 if 语句括号内,一旦出了大括号,value声明周期宣告结束,栈上分配的内存会被收回,此时 integer_pointer指针指向的内存不复存在!

2.2.3 检测 use-after-return 问题

C 语言面试题中经常涉及,请看如下代码:

/// 定义一个指针函数 
int *returns_address_of_stack() {
    // 1
    int a = 42;
    return &a;
}
// 2
int *integer_pointer = returns_address_of_stack();
*integer_pointer = 43;
  1. 函数中定义变量 a,然后返回 a 的地址
  2. 使用 int * 类型的 integer_pointer 指针接收函数返回值,并设置指针指向内存块的值。

注释掉 2.2.2 代码,运行detect use-after-return issues 代码片段:


注意编译之前,Xcode 就已经抛出警告了,若一意孤行运行程序我们将得到 Use of stack memory after return 错误信息。问题原因一目了然,变量a的作用域局限于函数体,一旦跳出函数则生命周期结束,栈上分配的内存块就会被收回,即使返回了之前变量 a 的内存地址,其实也是无意义的。

2.2.4 Swift 中的 Address Sanitizer

Swift 作为一门安全语言,相对来说问题会少很多,但同样存在上述问题,如下:

let string = "Hello, World!"
var firstBytePointer:UnsafePointer<Char>  // 1
...
string.withCString { pointerToCString in 
    firstBytePointer = pointerToCString // 2
}
...

let firstByte = firstBytePointer.pointee // 3
print(firstByte) 
  1. 定义一个 UnsafePointer<Char> 类型的变量,等同于 const Char *,即指针可变,指针指向的内存值不可修改
  2. Swift 提供给调用者操作对象指针的接口,其中 pointerToCString 指向 string 字符串内存首地址,注意它的作用域局限于大括号里,一旦跳出,变量生命周期结束
  3. 使用 firstBytePointer.pointee 获取 firstBytePointer 指针指向内存区域的值,等同于 * 解引操作。

有了之前的学习经验,这里代码问题一目了然,是 Use of deallocated memory 的问题。

正确的使用姿势为:

let string = "Hello, World!"

string.withCString { pointerToCString in 
    let firstByte = pointerToCString.pointee 
    print(firstByte) 
}

2.2.5 更多调试小技巧

接下来我们将通过另外一个小 Demo 来学习查看 allocationdeallocation 的调用栈,点击下载 AddressSanitizerDemo2 ,现在打开工程查看代码:

在第 24 行设置断点,点击运行等待程序执行到 NSLog(@"Done.");

按照上图查看 integer_pointer 指针指向内存区域内容。当然你也可以使用shift+command+m 快捷键呼出查看内存面板,然后手动输入内存地址。现在你看到的内存面板如下所示:

top Created with Sketch.