前言

高效排错的关键,是把一个模糊的异常,逐步转化成可以验证的因果判断。

错误信息提供线索,日志记录局部事实,调用栈指出程序经过的位置。要找到可靠的修复,还需要回答三个问题:

  • 问题在什么条件下发生?
  • 哪一种解释符合目前的证据?
  • 做什么实验,能够区分几种可能的原因?

一、先把“出错了”改写成一个可调查的问题

“页面加载失败”“编辑器报错”“偶尔出现旧数据”,都还不够具体。

一次有价值的问题描述,需要包含:

1
2
3
4
5
操作:用户执行了什么
预期:应该发生什么
实际:发生了什么
条件:设备、版本、构建方式、账号或网络状态
频率:必现,还是只在某些时序下出现

例如:

在 VS Code 中打开一个 iOS 工程,部分文件提示无法导入 UIKit;同一工程使用 Xcode 的 iOS 构建目标可以成功编译。

这个描述已经包含一个重要线索:不同工具对同一份代码给出了不同结果。

再看一个运行时问题:

连续刷新两次,第二次请求成功后,页面偶尔又显示第一次请求的失败提示。

这比“错误提示不准确”更接近原因,因为它记录了事件顺序。

排查开始时,还应保留原始错误、相关日志和版本信息。清缓存、重启、升级依赖都可能改变现场;如果这些动作让问题暂时消失,也需要记录具体改变了什么。

二、区分错误的报告位置与原因所在的位置

一个错误往往经过多层传播:

1
2
3
4
5
输入与配置
-> 工具或运行环境
-> 底层操作
-> 业务状态处理
-> 用户看到的结果

用户看到“加载失败”,可能对应网络错误,也可能是解析失败,或者旧请求的错误被写入了新页面。

编辑器在 import UIKit 处画红线,也可能是语言服务使用了错误的 SDK。

因此,阅读报错时,可以先拆出四个要素:

要素 需要确认的内容
报告者 编辑器、编译器、操作系统,还是业务代码
失败操作 导入模块、建立连接、解码资源,还是更新状态
依赖条件 SDK、权限、文件、账号、对象生命周期
时间关系 哪件事先发生,错误属于哪次操作

这一步的目标,是找到最值得检查的边界。

如果报错里只有“失败”,就沿着调用链补信息;如果已经指出具体操作,就优先检查它依赖的条件。

三、案例一:No such module 'UIKit',应该从哪里查起?

UIKit 是 Apple 的 iOS 界面框架。一个 iOS 工程出现下面的提示,很容易让人联想到依赖损坏:

1
No such module 'UIKit'

但“找不到模块”这条信息,本身无法区分几种原因:

假设 可以用什么证据区分
文件被加入了不支持 UIKit 的构建目标 检查文件所属 target
SDK 或工具链配置异常 查看实际编译使用的 SDK、target triple
编辑器没有取得工程构建参数 比较编辑器诊断与真实构建结果
模块搜索路径存在问题 检查编译命令与诊断日志

先做能够缩小范围的检查

在这次排查中,第一项关键证据是:工程使用 iOS 目标实际构建成功。

这说明,在这次构建所覆盖的条件下,源码和依赖可以通过检查。调查范围随之收缩到编辑器的分析环境。

接着检查 Swift 语言服务的日志,发现它使用的是 macOS 目标与 SDK:

1
2
target: arm64-apple-macosx...
SDK: MacOSX.sdk

而工程需要的是 iOS 构建上下文:

1
2
target: arm64-apple-ios...
SDK: iPhoneOS.sdk 或 iPhoneSimulator.sdk

UIKit 不属于 macOS SDK。到这里,报错就有了具体解释:语言服务没有取得正确的 iOS 编译参数。

修复缺失的上下文

这类工程可以通过构建服务,把 Xcode 工程的真实编译参数提供给编辑器。

修复路径是:

  1. 将构建服务绑定到正确的工程和 scheme。
  2. 构建对应目标,生成可读取的构建记录。
  3. 让语言服务重新加载配置。
  4. 检查诊断使用的 SDK 和 target,确认原错误消失。

这里需要分开验证两件事:

构建成功,证明真实构建路径可用;编辑器诊断恢复,证明语言服务的问题也已解决。

两者不能互相替代。

从这个案例抽象出的规律

编译结果取决于源码,也取决于构建上下文:

1
诊断结果 = f(源码、SDK、目标平台、编译选项、依赖搜索路径)

当不同工具给出不同结论时,比较这些输入,通常比反复修改同一行代码更有信息量。

这个思路同样适用于 Python 解释器不一致、TypeScript 配置差异,以及本地成功但 CI 失败的问题。

四、案例二:请求已经成功,为什么页面又出现旧错误?

再看一种更隐蔽的问题。

页面刷新资料时,执行顺序如下:

1
2
3
4
5
6
时间 -------------------------------------------------->

请求 A:发起 ------------------------------- 失败
请求 B: 发起 -------- 成功

页面: 显示新资料 ------ 显示错误

两次请求各自的网络结果都可能正确,但页面最终表达了错误的状态。

如果只看最后一条日志,调查方向很容易变成“为什么接口偶尔失败”。加入请求身份和时间顺序后,会发现真正需要解释的是:

为什么请求 A 的结果,还能够影响请求 B 已经更新过的页面?

单线程也会发生逻辑竞态

即使页面状态全部由 Swift 的 @MainActor 管理,这个问题仍然可能发生。

@MainActor 约束状态访问的执行位置。异步函数执行到 await 时会挂起,其他任务可以在这段时间更新状态。旧任务恢复后,之前的判断可能已经失效。

这里不存在必须同时发生的内存读写。问题来自不同操作的生命周期交错。

因此,需要检查一个更具体的条件:

任务恢复执行时,它是否仍然拥有更新当前状态的资格?

为每次操作分配身份

最简单的办法,是给每次刷新分配一个请求身份。新请求开始时替换当前身份;任何结果提交前,都必须重新核对。

下面省略了类型定义,currentRequestID、isLoading、profile 和 errorMessage 均由同一个主 actor 模型持有:

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
func refresh() async {
let requestID = UUID()
currentRequestID = requestID

isLoading = true
errorMessage = nil

defer {
if currentRequestID == requestID {
isLoading = false
}
}

do {
let result = try await fetchProfile()

try Task.checkCancellation()
guard currentRequestID == requestID else { return }

profile = result
} catch {
guard !Task.isCancelled,
currentRequestID == requestID else { return }

errorMessage = error.localizedDescription
}
}

这里有三个容易遗漏的地方。

首先,成功结果和失败结果都需要检查身份。否则虽然阻止了旧数据覆盖,旧错误仍然可能污染新状态。

其次,收尾操作也需要检查身份。旧任务执行 defer 时,如果直接关闭加载状态,就可能提前结束新任务的加载提示。

最后,页面退出、账号切换或会话重建,也需要让旧身份失效。请求身份的作用范围,应与它能够修改的状态保持一致。

取消任务仍然有价值,可以减少无用工作;身份检查则保护最终的状态提交。对于共享请求,也应区分调用方退出与底层任务取消,避免一个页面离开就中断其他调用方仍然需要的工作。

用可控时序验证修复

这类问题不适合依靠反复点击来验证。

更有效的测试方式,是控制请求何时返回:

1
2
3
4
5
1. 启动 A,让它停在等待状态
2. 启动 B,让 B 成功返回
3. 确认页面已经显示 B 的结果
4. 让 A 返回失败
5. 确认页面仍保留 B 的结果,且没有出现 A 的错误

还可以把最后一步替换为“A 返回旧数据”,验证成功分支同样受到保护。

这种测试复现的是导致问题的执行顺序,因此能够稳定验证修复是否覆盖了关键条件。

五、让每一步排查都产生新的信息

排查容易停滞,往往是因为连续做了很多动作,却无法解释每个动作排除了什么。

可以把一次检查写成:

1
2
3
4
5
假设
-> 如果成立,应观察到什么
-> 执行检查
-> 记录结果
-> 缩小下一步范围

例如:

假设:UIKit 报错来自编辑器使用了错误 SDK。
预测:语言服务日志中的目标平台应与 iOS 工程不一致。
检查:读取语言服务使用的 SDK 和 target。
结果:发现 macOS 构建参数。
下一步:修复编辑器获取构建上下文的路径。

选择检查时,可以考虑三个因素:成本、信息量,以及对现场的影响。

读取编译参数成本很低,却能快速区分环境问题和源码问题。记录请求身份不会改变业务逻辑,却能揭示异步顺序。相比之下,一次性清缓存、更新依赖和重启多个服务,即使恢复,也很难知道哪个动作起了作用。

对于偶发问题,优先增加能够关联事件的观测:

  • 请求或任务身份。
  • 所属账号、页面或会话身份。
  • 状态转换前后的值。
  • 开始、完成、取消和提交结果的时间。
  • 是否来自缓存或重试。

计算耗时时应使用单调时钟;跨服务分析还需要考虑时钟偏差。日志中也应避免记录令牌和敏感业务数据。

六、修复完成,需要同时验证行为与解释

一个可靠的修复,需要通过两类检查。

第一类检查行为:原来的复现步骤是否恢复正常,相邻场景是否出现回归。

第二类检查解释:修复后的观察结果,是否符合此前的因果判断。

例如,构建上下文修复后,应能看到正确的 SDK 与目标平台;请求隔离修复后,旧任务可以结束,但不能再提交状态。

如果只是重启后暂时不报错,仍然缺少解释。可以继续观察、增加日志,或保留一个待验证假设,避免过早把临时恢复写成根因修复。

最终的排查记录可以很短:

1
2
3
4
5
6
现象:什么条件下出现什么异常
原因:哪条依赖或状态约束被破坏
证据:哪些记录和实验支持这个判断
修复:改变了哪个条件
验证:原问题与相关场景的检查结果
边界:还有哪些情况尚未验证

站内搜索

没有找到内容!