WWDC Quick Look 💓 By SwiftGGTeam
Detect bugs early with the static analyzer

Detect bugs early with the static analyzer

观看原视频

Highlight

Xcode 13 的静态分析器可以在不运行 App 的情况下扫描 Objective-C、C 和 C++ 代码,提前发现空返回值、断言副作用、死循环、冗余条件和 C++ move/forward 使用错误。

核心内容

一个混合 Swift 和 Objective-C 的 App,常见问题并不都能靠编译器挡住。

演讲里的示例是一个太阳系浏览 App。项目里有一个 Objective-C 方法 positionAtDate,方法声明返回 non-null(非空)值。Swift 代码会调用它。

问题在于,某条 switch 的默认分支没有初始化 position。方法最后返回了 nil。编译可以通过,测试也可能没有覆盖到这条路径。等 Swift 运行时拿到这个 nil,行为就不可预期。

以前要找到这种 bug,开发者通常要等崩溃报告、加日志、复现用户路径,再回到代码里猜哪条分支漏了处理。路径越少见,排查越慢。

Xcode 的 Static Analyzer(静态分析器)把这一步提前到开发阶段。它不运行 App,只分析源代码。它会沿着控制流和数据流推导变量状态,找出少见路径里的逻辑错误。

Xcode 13 继续扩展了检查范围。它可以发现断言里的副作用、死循环、冗余分支条件,以及 C++ 中 moveforward 的使用错误。

从“发现问题”到“解释路径”

静态分析器的价值不只是报一个 warning。

在示例项目里,Xcode 的 issue navigator(问题导航器)会展开一串事件。最后一个事件显示 positionnil。往上看,regularPositionAtDate 没有被调用。继续往上看,接收对象从默认分支开始就是 nil

开发者得到的是一条可读的路径:哪个分支进入、哪个对象保持 nil、哪个调用被跳过、最后为什么返回了 nil

修复也直接:让 default 分支和 spherical(球形)分支使用相同逻辑,然后重新运行 Analyze。

把检查放进日常开发

静态分析可以手动运行,也可以并入构建。

手动方式是 Product > Analyze,快捷键是 Command-Shift-B。Xcode 会分析当前 active scheme(当前激活方案)里所有 target 的源文件。

如果希望每次构建都检查,可以在 Build Settings(构建设置)里打开 Analyze During ‘Build’。Xcode 会像增量构建一样,只分析修改过的文件。

项目很大时,还可以改成 shallow mode(浅分析模式)。它更快,但会避开跨多个函数的问题。安全敏感代码则可以单独打开 security issues(安全问题)相关检查。

详细内容

运行静态分析器(01:21

Apple 提供的入口在 Xcode 菜单里。打开项目后,选择 Product > Analyze,或按 Command-Shift-B。

Xcode
└── Product
    └── Analyze    Command-Shift-B

关键点:

  • Product 是 Xcode 的构建相关菜单。
  • Analyze 会启动静态分析器。
  • Command-Shift-B 是同一个动作的快捷键。
  • 分析范围是当前 active scheme 里所有 target 的源文件。

这一步类似 build(构建),但目标不同。构建检查代码能否编译,静态分析器检查代码路径里是否存在潜在 bug。

读取分析报告的事件链(02:41

分析器发现问题后,可以在 issue navigator 展开报告。报告会列出导致 bug 的事件顺序,并在编辑器里用箭头标出这些事件。

演讲里的核心路径可以整理成下面的最小 Objective-C 示例。它对应逐字稿里的事实:positionAtDate 声明返回 non-null,但默认分支让 position 保持 nil

typedef NS_ENUM(NSInteger, BodyShape) {
    BodyShapeSpherical,
    BodyShapeIrregular,
    BodyShapeUnknown,
};

- (nonnull Position *)positionAtDate:(NSDate *)date {
    Position *position = nil;

    switch (self.shape) {
        case BodyShapeSpherical:
            position = [self regularPositionAtDate:date];
            break;
        case BodyShapeIrregular:
            position = [self irregularPositionAtDate:date];
            break;
        default:
            break;
    }

    return position;
}

关键点:

  • BodyShapeUnknown 表示没有被前两个 case 覆盖的形状。
  • position 初始值是 nil
  • BodyShapeSpherical 分支会调用 regularPositionAtDate: 初始化 position
  • BodyShapeIrregular 分支会调用另一个方法初始化 position
  • default 分支直接 breakposition 仍然是 nil
  • 方法返回类型标注为 nonnull,返回 nil 会破坏这个约定。

修复方式是让默认分支也初始化 position。演讲里采用的修复是让 default case 和 spherical case 一样。

- (nonnull Position *)positionAtDate:(NSDate *)date {
    Position *position = nil;

    switch (self.shape) {
        case BodyShapeSpherical:
        default:
            position = [self regularPositionAtDate:date];
            break;
        case BodyShapeIrregular:
            position = [self irregularPositionAtDate:date];
            break;
    }

    return position;
}

关键点:

  • defaultBodyShapeSpherical 共用同一段逻辑。
  • 进入默认分支时,regularPositionAtDate: 会被调用。
  • position 不再沿着默认分支保持 nil
  • 修复后重新运行 Analyze,用同一个工具确认问题消失。

检查断言里的副作用(04:49

Xcode 13 新增了断言副作用检查。逐字稿里的例子是遍历天体数组,统计有卫星的对象数量,再用 NSAssert 验证数量不超过行星数量。

错误写法是把计数更新放进断言表达式。

NSUInteger objectsWithMoons = 0;

for (AstronomicalObject *object in objects) {
    if (object.moonCount > 0) {
        NSAssert(++objectsWithMoons <= planetCount,
                 @"Objects with moons must not exceed planets");
    }
}

关键点:

  • objectsWithMoons 记录有卫星的对象数量。
  • for 循环遍历天体数组。
  • object.moonCount > 0 表示当前对象有卫星。
  • ++objectsWithMoons 会修改计数器,这就是副作用。
  • 这个副作用发生在 NSAssert 表达式里。
  • release build(发布构建)里断言可能被禁用,计数器更新可能丢失。

修复方式是把副作用移到断言外面。

NSUInteger objectsWithMoons = 0;

for (AstronomicalObject *object in objects) {
    if (object.moonCount > 0) {
        objectsWithMoons += 1;
        NSAssert(objectsWithMoons <= planetCount,
                 @"Objects with moons must not exceed planets");
    }
}

关键点:

  • objectsWithMoons += 1 独立执行,不依赖断言是否启用。
  • NSAssert 只检查条件。
  • debug build(调试构建)和 release build 对计数器的更新保持一致。
  • 同类检查也适用于 C 和 C++ 的 assert

检查死循环(06:02

Xcode 13 还可以发现死循环。逐字稿里的例子是填充二维网格时,内层循环错误地增加了 value,没有增加真正的循环计数器 column

int value = 0;

for (int row = 0; row < rowCount; row++) {
    for (int column = 0; column < columnCount; value++) {
        grid[row][column] = value;
    }
}

关键点:

  • 外层循环用 row 遍历行。
  • 内层循环条件依赖 column < columnCount
  • 内层循环体写入 grid[row][column]
  • 更新表达式却是 value++
  • column 没有变化,循环条件可能一直成立。
  • 分析器会报告这类错误,并解释是哪一个变量没有按预期更新。

修复方式是递增 column

int value = 0;

for (int row = 0; row < rowCount; row++) {
    for (int column = 0; column < columnCount; column++) {
        grid[row][column] = value;
        value++;
    }
}

关键点:

  • column++ 让内层循环向终止条件前进。
  • grid[row][column] 仍然使用当前行列写入。
  • value++ 移到循环体里,用来生成下一个写入值。
  • 控制变量和业务变量分开,错误更容易被人和工具发现。

在构建中自动运行(06:47

Xcode 允许把分析器接入构建流程。进入 Build Settings,用 analysis 搜索相关设置,打开 Analyze During ‘Build’。

Build Settings
└── Search: analysis
    └── Analyze During 'Build' = Yes

关键点:

  • Build Settings 是项目构建设置入口。
  • 搜索 analysis 可以只看静态分析相关选项。
  • Analyze During 'Build' 打开后,每次构建会触发分析。
  • 分析过程类似增量构建,只处理修改过的文件。
  • 对构建时间敏感的项目,可以选择 shallow mode。

如果只想检查一个文件,可以从 Product > Perform Action 菜单选择单文件分析。

Xcode
└── Product
    └── Perform Action
        └── Analyze <Current File>

关键点:

  • 单文件分析适合刚改完某个实现文件时快速检查。
  • 修改 header file(头文件)时尤其有用。
  • 它可以避免重新分析所有 import 这个头文件的文件。

核心启发

  • 做什么:给 Objective-C 与 Swift 混编边界做 non-null 返回值巡检。 为什么值得做:演讲里的 bug 就发生在 Objective-C nonnull 方法返回 nil,Swift 调用后可能出现运行时异常。 怎么开始:从 Swift 会调用的 Objective-C API 入手,运行 Product > Analyze,优先处理返回值为空、接收对象为空的报告。

  • 做什么:把断言清理成“只检查,不修改状态”。 为什么值得做:Xcode 13 可以发现 NSAssert、C assert、C++ assert 中的副作用,release build 里这些副作用可能消失。 怎么开始:搜索 NSAssertassert,把 ++、赋值、写内存等操作移到断言前一行。

  • 做什么:为复杂循环增加一次静态分析检查。 为什么值得做:二维网格、分页遍历、状态机循环常把控制变量和业务变量混在一起,Xcode 13 可以发现循环变量不前进导致的死循环。 怎么开始:改动循环逻辑后,对当前文件运行 Product > Perform Action 的单文件分析,先处理 infinite loop 报告。

  • 做什么:在核心模块构建时自动运行 Analyze。 为什么值得做:静态分析不需要测试覆盖少见路径,适合尽早发现安全问题、逻辑问题和 API 误用。 怎么开始:在 Build Settings 搜索 analysis,打开 Analyze During ‘Build’,再按模块决定使用 deep mode 还是 shallow mode。

  • 做什么:给安全敏感代码打开专门的安全检查。 为什么值得做:演讲明确提到分析器可以检查 security issues,适合处理登录、支付、本地文件和网络输入等代码。 怎么开始:在 Build Settings 里选择性启用安全相关 analyzer checks,对噪音较大的检查单独关闭。

关联 Session

评论

GitHub Issues · utterances