<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>IOS · 仁才德</title>
    <link>https://jared.lynskey.co.nz/zh-cn/tags/ios/</link>
    <description>关于软件工程与研发团队领导力的短文——绩效评估、招聘与团队扩张、CI/CD、DevOps 监控、智能体 AI。写于首尔。</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <copyright>© 2026 仁才德</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/zh-cn/tags/ios/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>不自建 Sentry，也能收 React Native 崩溃报告：保留 SDK，接自己的服务器</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-09-sentry-sdk-own-server/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-09-sentry-sdk-own-server/</guid>
      <pubDate>Fri, 09 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>Sentry</category>
      <category>Django</category>
      <category>崩溃上报</category>
      <category>自托管</category>
      <category>iOS</category>
      <category>Android</category>
      <category>隐私</category>
      <description>不用自建 Sentry，也不用付 sentry.io 的钱：保留 Sentry 的开源 SDK，把 DSN 指向自己 Django API 里的接口，自己接收 React Native 应用的崩溃报告，并做分组、报警和脱敏。</description>
      <content:encoded><![CDATA[<p>如果你想自己接收 React Native 应用的崩溃报告，其实不必自建 Sentry 服务端，也不必给 sentry.io 付费。Sentry 的 SDK 是开源的，DSN 可以指向任何主机。我把它指向自己 Django API 里的一个接口，在那里接收、分组、报警和保存，除了本来就在跑的 API，没有额外成本。目前还没替代掉的是符号化，所以堆栈还对应不到源码的具体行。</p>
<p>直到这周之前，我知道 我们的应用崩了的唯一途径，是有人来告诉我。大多数人不会说。他们重新打开应用，如果又崩了，就不再打开了。API 那边是有覆盖的（所有 ERROR 日志都会发到团队聊天的一个频道），但手机上的一次崩溃，在我能看到的地方不留任何痕迹。</p>
<p>其实应用里已经有一个上报机制。应用每次把错误信息展示给用户时，都会把这条信息 POST 到 API 上的一个小接口，服务端以 ERROR 级别记录，所以同样会进聊天频道。加这个是因为有一次删除操作其实 <em>成功了</em> ，可那一行还留在列表里，再点一下就弹出 &ldquo;No Item matches the given query&rdquo;。服务端只记了两条普通的 404，什么也没说。它覆盖的是应用捕获并展示出来的错误。崩溃不一样：本该发送报告的那段 JavaScript，恰恰就是刚刚死掉的那个。</p>
<p>最直接的办法是用 Sentry。我很喜欢 Sentry 的 SDK，React Native 上的崩溃上报之所以算是已解决的问题，靠的就是它。我不想要的是：又多一家供应商持有用户数据，隐私政策里又多一行，又多一张账单，而以我们的规模，这东西一天顶多几条报告。所以我留下 SDK，换掉服务：应用的 DSN 指向我们自己的 API，由那边 Django 应用里的一小块代码负责接收、分组、报警和保存。</p>
<p>这篇文章讲它是怎么工作的，以及我怎么确认一次真实的原生崩溃确实能送到。</p>

<h2 class="relative group">DSN 的小把戏
    <div id="dsn-的小把戏" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#dsn-%e7%9a%84%e5%b0%8f%e6%8a%8a%e6%88%8f" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Sentry 的 DSN 长这样：<code>https://&lt;public-key&gt;@&lt;host&gt;/&lt;project-id&gt;</code>。SDK 会把它变成一个发往 <code>https://&lt;host&gt;/api/&lt;project-id&gt;/envelope/</code> 的 POST 请求，key 放在 <code>X-Sentry-Auth</code> 头里。这里面没有任何东西是 sentry.io 专属的。所以打包进应用的 DSN 是：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">https://&lt;CRASH_REPORT_KEY&gt;@api.example.com/1</span></span></code></pre></div></div>
<p>Django 里加一条路由，正好匹配 SDK 会调用的地址：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">path</span><span class="p">(</span><span class="s2">&#34;api/&lt;int:project_id&gt;/envelope/&#34;</span><span class="p">,</span> <span class="n">crash_envelope</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="s2">&#34;crash_envelope&#34;</span><span class="p">),</span></span></span></code></pre></div></div>
<p>这个 key 存在于每一个安装包里，所以它不是秘密。它的作用是：万一这个接口开始招来垃圾请求，我可以换掉它；另外服务端没配置 key 时会拒绝一切请求，而不是来者不拒：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">crash_envelope</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">project_id</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">expected</span> <span class="o">=</span> <span class="n">settings</span><span class="o">.</span><span class="n">CRASH_REPORT_KEY</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">expected</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;detail&#34;</span><span class="p">:</span> <span class="s2">&#34;Crash reporting is not configured&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">503</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">_key</span><span class="p">(</span><span class="n">request</span><span class="p">)</span><span class="o">.</span><span class="n">encode</span><span class="p">(),</span> <span class="n">expected</span><span class="o">.</span><span class="n">encode</span><span class="p">()):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;detail&#34;</span><span class="p">:</span> <span class="s2">&#34;Unknown key&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">401</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="o">...</span></span></span></code></pre></div></div>
<p><code>_key</code> 从 <code>X-Sentry-Auth</code> 头里读出 <code>sentry_key=</code>，读不到就退回到查询字符串，有些 SDK 是放在那里的。</p>

<h2 class="relative group">SDK 发的是什么：envelope
    <div id="sdk-发的是什么envelope" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#sdk-%e5%8f%91%e7%9a%84%e6%98%af%e4%bb%80%e4%b9%88envelope" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Sentry 的传输格式叫 <a href="https://develop.sentry.dev/sdk/data-model/envelopes/"  target="_blank" rel="noreferrer">envelope</a>：先是一行 JSON 头，后面跟任意多个 item，每个 item 是一行 JSON 头加一段 payload。如果 item 头里有 <code>length</code>，payload 就恰好是这么多字节；否则一直读到下一个换行符。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">{&#34;event_id&#34;:&#34;9ec79c33...&#34;,&#34;dsn&#34;:&#34;https://...@api.example.com/1&#34;}
</span></span><span class="line"><span class="cl">{&#34;type&#34;:&#34;event&#34;,&#34;length&#34;:4123}
</span></span><span class="line"><span class="cl">{ ...the event JSON, 4123 bytes... }
</span></span><span class="line"><span class="cl">{&#34;type&#34;:&#34;client_report&#34;}
</span></span><span class="line"><span class="cl">{&#34;timestamp&#34;:1791400000.5,&#34;discarded_events&#34;:[]}</span></span></code></pre></div></div>
<p>有两个细节让我栽了跟头：</p>
<ul>
<li><strong>原生 SDK 会 gzip 请求体，JavaScript 的不会。</strong> <code>@sentry/react-native</code> 捕获的 JS 错误是明文发来的，而 sentry-cocoa 或 sentry-android 发来的原生崩溃带着 <code>Content-Encoding: gzip</code>。解码器能处理 gzip、zlib 包装的 deflate 和裸 deflate，解压到 1MB 就停，因为一个崩溃事件只有几 KB，再大就不是崩溃事件了。</li>
<li><strong>带长度前缀的 payload 里可以有换行符。</strong> 如果按 <code>\n</code> 切分，原生 SDK 发来的每个事件都会被切坏。有 <code>length</code> 的时候必须按它来读。</li>
</ul>
<p>解析器很短。它只保留 <code>event</code> 类型的 item；session、transaction、附件和 client report 照单全收，然后丢弃。</p>

<h2 class="relative group">用 SDK 听得懂的方式回应
    <div id="用-sdk-听得懂的方式回应" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%94%a8-sdk-%e5%90%ac%e5%be%97%e6%87%82%e7%9a%84%e6%96%b9%e5%bc%8f%e5%9b%9e%e5%ba%94" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这一点比我预想的重要。SDK 在磁盘上维护一个队列，凡是不确定送达的都会重试，所以服务端怎么回应，决定了它会不会一直重试下去：</p>
<ul>
<li>存好了，<em>或者是故意丢弃的</em>，都回 <strong>200</strong>。如果我对 session item 回一个 4xx，SDK 就会留着它，永远重试。</li>
<li>被限流时回 <strong>429 加 <code>Retry-After</code></strong>。这个接口按 IP 限流（每小时 120 次），因为陷入崩溃循环的手机每次启动都会发一条报告，而 SDK 收到 429 会像你期望的那样退避。</li>
<li>envelope 格式不对回 <strong>400</strong>，记成 warning 而不是 error：一份坏掉的报告只说明某个客户端坏了，下一份正常的报告会告诉我崩在哪。</li>
</ul>
<p>SDK 如果不确定第一次是否送达，还会重发同一个事件，所以 <code>event_id</code> 上有唯一约束，重复的就悄悄跳过。</p>

<h2 class="relative group">没有 source map 时怎么分组
    <div id="没有-source-map-时怎么分组" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%b2%a1%e6%9c%89-source-map-%e6%97%b6%e6%80%8e%e4%b9%88%e5%88%86%e7%bb%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>自己写接收端，就得做一个 Sentry 替你做了的选择。Sentry 按堆栈给崩溃分组。但 release 版的 Hermes bundle 是压缩过的，函数名都是单个字母，行列号每次构建都会变。按堆栈帧分组，同一个 bug 每次发版都会开一个新 issue。</p>
<p>没有 source map（文末再说），JavaScript 错误里最稳定的就是它的类型和信息。所以指纹由异常类型加信息组成，并把每次出现都不一样的部分遮掉：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">_MASKS</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;0x[0-9a-fA-F]+&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;addr&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\b[0-9a-fA-F]</span><span class="si">{8}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{4}</span><span class="s2">-[0-9a-fA-F]</span><span class="si">{12}</span><span class="s2">\b&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;uuid&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\b[0-9a-fA-F]{16,}\b&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;hex&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="n">re</span><span class="o">.</span><span class="n">compile</span><span class="p">(</span><span class="sa">r</span><span class="s2">&#34;\d+&#34;</span><span class="p">),</span> <span class="s2">&#34;&lt;n&gt;&#34;</span><span class="p">),</span>
</span></span><span class="line"><span class="cl"><span class="p">]</span></span></span></code></pre></div></div>
<p><code>Cannot read property 'title' of undefined (reading index 3)</code> 和 <code>(reading index 17)</code> 会归为同一个 issue。如果 SDK 自己带了 <code>fingerprint</code> 且其中没有 <code>{{ default }}</code>，就以它为准，这样默认分组不对时我可以从客户端强制指定。没有异常的事件退而使用遮罩后的日志信息，两者都没有就用最上面的应用内堆栈帧。原生信号崩溃的信息大概是 <code>Signal 6, Code 0</code>，所以这类崩溃按信号分组，粒度很粗，但在有符号化之前，这样是诚实的。</p>

<h2 class="relative group">报警，但不刷屏
    <div id="报警但不刷屏" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%8a%a5%e8%ad%a6%e4%bd%86%e4%b8%8d%e5%88%b7%e5%b1%8f" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>每个指纹对应一个 issue，每份报告作为一个事件存在它下面。issue 只在以下几种情况下以 ERROR 级别记录（聊天报警监听的就是这个级别）：</p>
<ul>
<li><strong>新出现</strong>的 issue</li>
<li><strong>已解决</strong>的 issue <strong>又出现</strong>，此时会重新打开它</li>
<li>第 <strong>10、100、1,000、10,000</strong> 次出现</li>
</ul>
<p>普通的重复只会让计数器加一。标记为 <em>忽略</em> 的 issue 照样计数，但从不报警。再加上聊天 handler 自己的限流，坏版本上的崩溃循环在频道里只是几条消息，而不是一面墙。</p>
<p>存储也一样有上限：每个 issue 保留 <strong>最近 50 条</strong> 事件，新的进来就删掉旧的。这让第一版图表出了问题，因为 50 条事件说不出上周某件事发生了多少次。所以每个 issue 还另外记着 90 天的每日计数，和计数器加一放在同一个事务里更新。图表从这些计数取数；事件只用来阅读。</p>
<p>如果你也自己做一个，有两个小的竞态值得处理。一个全新的崩溃同时打到几台手机上时，两份报告可能都想创建这个 issue，所以给指纹加唯一约束，让输的那个重新读取已有的那一行。另外，90 天的窗口要从服务器的&quot;今天&quot;算起，而不是从最新一份报告算起，因为时钟设错的手机可能把报告的日期标成任何一天，包括明年。</p>
<p>payload 上限是 128KB。超出的事件先丢 breadcrumbs 和调试元数据；还超，就只保留异常、信息、版本和平台，并做个标记，让我知道它被截过。</p>

<h2 class="relative group">脱敏
    <div id="脱敏" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%84%b1%e6%95%8f" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>SDK 开着 <code>sendDefaultPii: false</code>，所以理论上不会发送 IP 地址、cookie 或请求体。服务端并不依赖这一点：存储之前，<code>user</code> 里只留 <code>id</code> 和 <code>username</code>，<code>request</code> 整个删掉，以防以后某个 SDK 版本或设置开始发送这些东西。</p>
<p>用真机测试又发现了两样需要去掉的东西：</p>
<ul>
<li><strong>breadcrumbs 记录的是完整 URL，包括查询字符串。</strong> 应用的搜索请求会把搜索词放在查询字符串里，所以搜索之后紧跟着的一次崩溃，会把用户搜了什么存下来。现在服务端会把每个 breadcrumb 的 URL 在 <code>?</code> 处截断。</li>
<li><strong>console 类 breadcrumb 带着传给 <code>console.log</code> 的原始参数</strong>，可能是应用恰好打印出来的任何对象。消息文本保留（它有用），参数丢掉。</li>
</ul>
<p>这两样都是看了一份真实报告才发现的，这也是下一节存在的主要理由。</p>

<h2 class="relative group">客户端
    <div id="客户端" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%ae%a2%e6%88%b7%e7%ab%af" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>应用里所有相关代码都走一个小模块，原因和我把 OTA 更新 SDK 包一层一样：OTA bundle 也会跑在原生模块还不存在时构建的安装包上。在没有 <code>RNSentry</code> 的安装包上，顶层写 <code>import * as Sentry from &quot;@sentry/react-native&quot;</code> 会在导入时就抛异常，结果就是白屏。所以 SDK 是延迟 require 的，而且要先确认原生模块真的存在：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">NativeModules</span><span class="p">,</span> <span class="nx">Platform</span><span class="p">,</span> <span class="nx">TurboModuleRegistry</span> <span class="p">}</span> <span class="kr">from</span> <span class="s2">&#34;react-native&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">Constants</span> <span class="kr">from</span> <span class="s2">&#34;expo-constants&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">DSN</span> <span class="o">=</span> <span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">EXPO_PUBLIC_CRASH_DSN</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">nativeModulePresent</span><span class="p">()</span><span class="o">:</span> <span class="kr">boolean</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">Boolean</span><span class="p">(</span><span class="nx">TurboModuleRegistry</span><span class="p">.</span><span class="kr">get</span><span class="p">(</span><span class="s2">&#34;RNSentry&#34;</span><span class="p">)</span> <span class="o">??</span> <span class="nx">NativeModules</span><span class="p">.</span><span class="nx">RNSentry</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="kd">function</span> <span class="nx">initCrashReporting</span><span class="p">()</span><span class="o">:</span> <span class="kr">boolean</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">DSN</span> <span class="o">||</span> <span class="nx">Platform</span><span class="p">.</span><span class="nx">OS</span> <span class="o">===</span> <span class="s2">&#34;web&#34;</span> <span class="o">||</span> <span class="o">!</span><span class="nx">nativeModulePresent</span><span class="p">())</span> <span class="k">return</span> <span class="kc">false</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">Sentry</span> <span class="o">=</span> <span class="kr">require</span><span class="p">(</span><span class="s2">&#34;@sentry/react-native&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">Sentry</span><span class="p">.</span><span class="nx">init</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">dsn</span>: <span class="kt">DSN</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">release</span>: <span class="kt">Constants.expoConfig?.version</span> <span class="o">??</span> <span class="s2">&#34;unknown&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">sendDefaultPii</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">tracesSampleRate</span>: <span class="kt">0</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">enableAutoSessionTracking</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// ...turn off the other tracing options the same way
</span></span></span><span class="line"><span class="cl">    <span class="nx">maxBreadcrumbs</span>: <span class="kt">50</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">beforeSend</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span> <span class="cm">/* tag the OTA bundle */</span> <span class="k">return</span> <span class="nx">event</span><span class="p">;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="kc">true</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>只上报错误。服务端不存 trace、session、profile 或 replay，所以这些一个都没开。没有 DSN（开发环境、E2E 构建）时 SDK 根本不会启动。构建 profile 会设置 DSN 和环境（<code>staging</code> 或 <code>production</code>），两类构建上报到同一个接口，又能区分开。登录用户用账号 ID 标识，绝不用名字或邮箱。</p>
<p>错误边界需要显式调用。Expo Router 的根 <code>ErrorBoundary</code> 会捕获渲染错误并显示自己的界面，所以这个错误根本到不了 SDK 挂钩的全局处理器。解决办法是一个小包装组件：在 <code>useEffect</code> 里调用 <code>captureException</code>，然后照旧渲染路由自带的 boundary，根 boundary 和每个路由的 boundary 都用它。放在 effect 里而不是渲染过程中，是为了只上报一次，而不是每次重新渲染都报。根组件还用 <code>Sentry.wrap</code> 包了一层，如果这一步抛异常，就退回到原始组件。</p>
<p>Web 端有一个 <code>.web.ts</code> 版本的模块，所有函数都是空操作。Metro 会选平台专属的文件，所以 SDK 根本不会进入 web bundle。Web 应用也没有原生模块可以运行它。</p>

<h2 class="relative group">证明原生崩溃能送到
    <div id="证明原生崩溃能送到" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%af%81%e6%98%8e%e5%8e%9f%e7%94%9f%e5%b4%a9%e6%ba%83%e8%83%bd%e9%80%81%e5%88%b0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>单元测试 POST 的是手写的 envelope，只能证明解析器能处理我 <em>以为</em> SDK 会发的东西。发布两天后，生产环境一份崩溃报告都没有。当时只有三个人更新了，所以零是说得通的，但从服务端看，&ldquo;没人崩溃&quot;和&quot;崩溃送不到&quot;长得一模一样。我想亲眼看到一份真的。</p>
<p>于是我构建了一个 Release 版的模拟器包，把 DSN 指向本地 8000 端口上的 Django，用一个临时 key：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">EXPO_PUBLIC_CRASH_DSN</span><span class="o">=</span>http://localcrashtestkey0000@localhost:8000/1 <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="nv">EXPO_PUBLIC_CRASH_ENVIRONMENT</span><span class="o">=</span>local-verify <span class="se">\
</span></span></span><span class="line"><span class="cl">xcodebuild -workspace ios/MyApp.xcworkspace -scheme MyApp -configuration Release <span class="se">\
</span></span></span><span class="line"><span class="cl">  -destination <span class="s1">&#39;platform=iOS Simulator,id=&lt;udid&gt;&#39;</span> -derivedDataPath /tmp/iosbuild</span></span></code></pre></div></div>
<p>（用 Release 而不是 Debug：Debug 构建会链接 dev launcher，行为不一样，而用户跑的是 Release。）然后安装、启动，等几秒钟，再像真实故障那样把它杀掉：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">xcrun simctl install booted /tmp/iosbuild/Build/Products/Release-iphonesimulator/MyApp.app
</span></span><span class="line"><span class="cl">xcrun simctl launch booted com.example.myapp      <span class="c1"># prints the pid</span>
</span></span><span class="line"><span class="cl"><span class="nb">kill</span> -ABRT &lt;pid&gt;</span></span></code></pre></div></div>
<p>模拟器里的应用就是普通的 macOS 进程，所以信号到达 SentryCrash 处理器的方式和真机上完全一样。处理器把报告写到磁盘；进程临死时什么都不发送。<strong>下次启动</strong>时 SDK 找到它并发出去。我重新启动应用，一秒后模拟器的网络日志里就出现了一条到 <code>:8000</code> 的连接。</p>
<p>然后 Django 日志里什么都没有，这让我愣了一会儿：在我设的日志级别下，开发服务器只记录非 2xx 的响应。数据库里是有的：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">SIGABRT: Signal 6, Code 0 | cocoa 1.4.0 iOS 27.1 iPhone19,4 handled=False
</span></span><span class="line"><span class="cl">received 09:19:53, crashed 09:19:38
</span></span><span class="line"><span class="cl">contexts: app, device, os, ota_updates, trace · 24 breadcrumbs</span></span></code></pre></div></div>
<p>从崩溃到入库十五秒，大部分时间花在我手动重启上。而看真实的 payload，教会了我测试教不了的东西：</p>
<ul>
<li><strong>用户 ID 不是账号 ID。</strong> 测试包并没有真正登录，所以 sentry-cocoa 退而使用它自己的按安装生成的 ID。这一点值得知道，也值得写进隐私说明：登录时报告带的是账号 ID，未登录时是一个随机的安装 ID。</li>
<li><strong>没有时区。</strong> 我原以为 culture context 会有。device context 里有 <code>locale</code>、内存、屏幕尺寸、低电量模式和内核版本，就是没有时区。</li>
<li><strong>原生崩溃不经过 <code>beforeSend</code>。</strong> 我在 JS 的 <code>beforeSend</code> 里加了一个 <code>ota</code> 标签，标明正在运行的 OTA bundle，但原生崩溃是下次启动时由原生 SDK 组装的，根本不经过 JS 钩子。要给原生崩溃打标签，得在 bundle 确定后用 <code>setTag</code> 设到 scope 上，这样它才会同步到原生层。</li>
<li><strong>breadcrumbs 暴露了应用在做什么。</strong> console 警告、view controller 生命周期，还有 HTTP 请求，其中包括分析 SDK 自己发往它的数据收集接口的请求，这也提醒了我应用还在跟谁通信。而且这些 URL 带着查询字符串，前面说的搜索词问题就是这么发现的。</li>
</ul>

<h2 class="relative group">怎么看这些报告
    <div id="怎么看这些报告" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%80%8e%e4%b9%88%e7%9c%8b%e8%bf%99%e4%ba%9b%e6%8a%a5%e5%91%8a" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>报告在 Django 后台里看，页面大致仿照 Sentry 的 issue 页：顶部是计数和 Resolve / Ignore / Reopen 按钮，然后是每日柱状图、按版本、系统、设备和 OTA bundle 的分布，再往下一次展示一个事件，包括异常、堆栈帧（应用帧高亮，库的帧折叠起来）、breadcrumbs 和设备信息。不需要任何新依赖。</p>
<p>做这个页面时顺手修了一个问题：原生崩溃的&quot;culprit&quot;帧原本是二进制文件在设备上的路径，里面带着每次安装都不同的 UUID，结果同一个崩溃在每台手机上看起来都不一样。现在只显示地址和二进制名，比如 <code>0x1000a1b2c (MyApp)</code>。</p>

<h2 class="relative group">隐私政策，整份重写
    <div id="隐私政策整份重写" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e9%9a%90%e7%a7%81%e6%94%bf%e7%ad%96%e6%95%b4%e4%bb%bd%e9%87%8d%e5%86%99" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>崩溃报告差不多就是个人信息：一个账号 ID、一个手机型号，以及某人在应用崩溃前一分钟做了什么。所以隐私政策需要加一段。要写好，就得准确说明一份报告里有什么，于是我照着刚看过的 payload 列了出来：错误本身、应用版本和 OTA 更新、手机型号、系统和语言、可用内存和屏幕尺寸、最近几十步操作（打开的页面、去掉 <code>?</code> 之后部分的网址、应用自己的日志），以及账号 ID 或随机的安装 ID。政策里也写明了报告直接发到我们自己的服务器，不会发给 Sentry 或任何其他崩溃上报公司。保存期限在表格里单独占一行：每个问题最近 50 份报告，外加 90 天的每日计数。</p>
<p>一旦开始拿代码核对其中一段，就很难不去核对其余部分。原来的政策经不起核对。删除账号页面写着：关闭账号时，推送通知的 token 和 passkey 会立即删除，服务器日志会在 90 天内清除。这些都不是真的。关闭账号会解除登录方式的绑定，并把资料匿名化，但推送 token 和 passkey 会一直留着，直到有人手动删除；日志归档目前也没有过期时间。所以整份政策根据代码实际做了什么的清单重写了一遍，那几行现在写的是&quot;暂未自动删除，请发邮件给我们&rdquo;。听起来没那么让人安心，但它是准确的。</p>
<p>现在代码仓库里有一条规则：政策描述的是代码做了什么，而不是我们打算做什么；任何在新地方收集、发送或保存个人数据的改动，都要在同一个 commit 里更新它。这包括应用里的摘要页面、删除账号页面，以及只存在于商店后台的应用商店隐私表单。正是崩溃上报这个改动，让我把这条规则写了下来。</p>

<h2 class="relative group">还缺什么
    <div id="还缺什么" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%bf%98%e7%bc%ba%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><strong>符号化。</strong> 这是真正的缺口，也是 sentry.io 能做而我还没替代的那件事。JavaScript 的堆栈是压缩过的，原生代码的堆栈没有符号化：</p>
<ul>
<li><strong>JavaScript：</strong> 堆栈帧指向 Hermes bundle 内部。要解决，就得为每个商店构建和每个 OTA bundle 保留合成后的 Hermes source map，按版本和 bundle 索引，在页面渲染时套用到 JS 帧上。这是下一步要做的，因为我们的崩溃大部分会是 JavaScript 的。</li>
<li><strong>iOS 原生：</strong> 指令地址需要该次构建的 dSYM。</li>
<li><strong>Android：</strong> 开启压缩时 Java 帧会被 R8 混淆（需要 <code>mapping.txt</code>），C++ 帧需要 NDK 符号。</li>
</ul>
<p>前面还发现了一个小一点的缺口：原生崩溃目前还不带 OTA 标签，因为它只在 <code>beforeSend</code> 里设置。在运行中的 bundle 确认之后把它设到 scope 上，只要几行代码，我还没写。</p>
<p>目前，一个 issue 能告诉我 <em>什么</em> 崩了、在哪个版本、哪个 OTA bundle、哪台设备上，以及用户崩溃前在做什么。它没法告诉我崩在源码的 <em>哪里</em> 。对一个一周发几次版的小应用来说，到目前为止，异常类型、信息和 breadcrumbs 已经足够找到问题了。</p>
<p>接收端、后台页面和测试加起来代码量不大，在原本就在运行的 API 之外不额外花一分钱。难的部分由 SDK 完成：在手机上捕获崩溃，并在下次启动时把报告送出来。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-09-sentry-sdk-own-server/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>如何用 Xcode 调试 iOS 应用：断点、LLDB、Instruments 和模拟器</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-02-26-debugging-ios-mobile-apps/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-02-26-debugging-ios-mobile-apps/</guid>
      <pubDate>Thu, 26 Feb 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>iOS</category>
      <category>Xcode</category>
      <category>Debugging</category>
      <category>Swift</category>
      <category>iOS Simulator</category>
      <category>Mobile Development</category>
      <description>在 Xcode 里调试 iOS 应用：条件断点和符号断点、值得记住的 LLDB 命令、视图调试器和内存调试器、Instruments、各种 sanitizer、崩溃日志，以及模拟器发现不了、只有真机才能抓到的问题。</description>
      <content:encoded><![CDATA[<p>在 Xcode 里调试 iOS 应用，第一步是加一个异常断点，这样一崩溃就会停在抛异常的那一行；然后重现 bug，在 LLDB 控制台里用 <code>po</code> 查看状态。接下来按症状挑工具：布局问题用视图调试器，内存泄漏和卡顿用 Memory Graph 和 Instruments，时有时无的崩溃用 Address Sanitizer 和 Thread Sanitizer。最后一定要在真机上跑一遍，因为有一整类 bug 在模拟器里根本看不到。</p>
<p>我花在调试上的时间实在太多了。谁不是呢。最近大部分时间都在 Xcode 和模拟器里追自己应用的 bug，越用越有一个感受：Xcode 自带的调试工具其实很强，只是大多数人几乎不碰。这篇就把我多年下来沉淀的调试流程写出来，都是真正省时间的部分。</p>

<h2 class="relative group">断点比你想的更强大
    <div id="断点比你想的更强大" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%96%ad%e7%82%b9%e6%af%94%e4%bd%a0%e6%83%b3%e7%9a%84%e6%9b%b4%e5%bc%ba%e5%a4%a7" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>大多数人点一下行号边栏，设个断点就完事了。其实右键点开，里面有一堆能改变调试方式的选项。</p>

<h3 class="relative group">条件断点
    <div id="条件断点" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%9d%a1%e4%bb%b6%e6%96%ad%e7%82%b9" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>假设有个循环要处理 1000 个项目，只有第 347 个出问题。总不能先命中 346 次断点吧。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="k">for</span> <span class="n">i</span> <span class="k">in</span> <span class="mf">0.</span><span class="p">.&lt;</span><span class="n">users</span><span class="p">.</span><span class="bp">count</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="n">processUser</span><span class="p">(</span><span class="n">users</span><span class="p">[</span><span class="n">i</span><span class="p">])</span>  <span class="c1">// 在这设断点，条件: i == 347</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>右键断点，加上条件 <code>i == 347</code>，Xcode 只在条件为真时停下。<code>userId == &quot;abc123&quot;</code>、<code>error != nil</code> 这类条件同样好使。</p>

<h3 class="relative group">符号断点
    <div id="符号断点" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%ac%a6%e5%8f%b7%e6%96%ad%e7%82%b9" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>不知道方法在哪被调用时，就靠它。打开断点导航器（Cmd+8），点 + 按钮，选“Symbolic Breakpoint”。</p>
<p>想看每个视图控制器什么时候加载？把符号设成 <code>viewDidLoad</code>，Xcode 会在每一处暂停。太吵就缩小范围：<code>MyViewController.viewDidLoad</code>。</p>
<p>我常年留着这三个：</p>
<ul>
<li><code>UIViewAlertForUnsatisfiableConstraints</code>：当场抓住 Auto Layout 冲突</li>
<li><code>objc_exception_throw</code>：在崩溃之前就停在 Objective-C 异常上</li>
<li><code>malloc_error_break</code>：发现内存分配问题</li>
</ul>

<h3 class="relative group">异常断点
    <div id="异常断点" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%bc%82%e5%b8%b8%e6%96%ad%e7%82%b9" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>这大概是新项目里第一件该做的事。加一个异常断点（断点导航器里点 + → Exception Breakpoint），Xcode 会在抛异常的那一刻暂停，而不是等应用死透了才反应。</p>
<p>多少 nil 解包的问题都是靠它抓住的，不然只会在控制台里留下一条莫名其妙的崩溃。</p>

<h3 class="relative group">断点动作
    <div id="断点动作" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%96%ad%e7%82%b9%e5%8a%a8%e4%bd%9c" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>断点其实不必停止执行。编辑断点，点“Add Action”，可以：</p>
<ul>
<li>不暂停地输出变量：<code>&quot;User count: @(users.count)@&quot;</code></li>
<li>自动执行 LLDB 命令</li>
<li>执行 shell 脚本</li>
<li>播放声音（我用它来确认罕见代码路径有没有被走到）</li>
</ul>
<p>勾上“Automatically continue after evaluating actions”，断点就成了一个不打断流程的日志器。</p>

<h2 class="relative group">LLDB：你真正会用到的控制台命令
    <div id="lldb你真正会用到的控制台命令" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#lldb%e4%bd%a0%e7%9c%9f%e6%ad%a3%e4%bc%9a%e7%94%a8%e5%88%b0%e7%9a%84%e6%8e%a7%e5%88%b6%e5%8f%b0%e5%91%bd%e4%bb%a4" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>在断点停下时，底下那个控制台不只是看崩溃日志的地方。那是 LLDB，非常好用。</p>

<h3 class="relative group">基础
    <div id="基础" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9f%ba%e7%a1%80" aria-label="锚点">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) po user
▿ User
  - id: &#34;123&#34;
  - name: &#34;John Doe&#34;
  - email: &#34;john@example.com&#34;</code></pre></div>
<p><code>po</code> 用可读的格式打印对象。我 90% 的 LLDB 操作就是它。</p>
<p>想要更多细节，换 <code>p</code>：</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) p user.name
(String) $R0 = &#34;John Doe&#34;</code></pre></div>
<p>一次看完所有局部变量：</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) frame variable</code></pre></div>

<h3 class="relative group">运行时改值
    <div id="运行时改值" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%bf%90%e8%a1%8c%e6%97%b6%e6%94%b9%e5%80%bc" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>LLDB 真正值钱的地方在这：不用重新编译就能改变量。</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) expr user.name = &#34;Jane Doe&#34;
(lldb) expr index = 0</code></pre></div>
<p>发现 bug，想不重新构建就试一下修复？改掉变量继续跑就行。缩小边界情况范围的时候，我一直这么换值测试。</p>
<p>方法也能调：</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) po self.refreshUI()
(lldb) expr navigationController?.popViewController(animated: true)</code></pre></div>

<h3 class="relative group">导航
    <div id="导航" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%af%bc%e8%88%aa" aria-label="锚点">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) bt              # 显示调用栈
(lldb) frame select 3  # 跳到不同的栈帧
(lldb) continue        # 继续运行（或者直接 &#39;c&#39;）
(lldb) n               # 单步跳过（下一行）
(lldb) s               # 单步进入函数</code></pre></div>

<h3 class="relative group">监视点
    <div id="监视点" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%9b%91%e8%a7%86%e7%82%b9" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>想知道变量什么时候被改了？设个监视点：</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) watchpoint set variable user.isLoggedIn</code></pre></div>
<p>之后不管代码哪里改了这个值，执行都会立刻暂停。追踪莫名其妙的状态变化，没有比这更好用的了。</p>

<h2 class="relative group">Console.app：被埋没的调试工具
    <div id="consoleapp被埋没的调试工具" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#consoleapp%e8%a2%ab%e5%9f%8b%e6%b2%a1%e7%9a%84%e8%b0%83%e8%af%95%e5%b7%a5%e5%85%b7" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>多数时候 Xcode 的控制台够用，但要把系统日志、崩溃报告全都看一遍时，就该打开 Console.app 了。</p>
<p>如果你在用 OSLog（应该用），Console.app 能让这些日志随便搜、随便过滤：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kd">import</span> <span class="nc">os</span><span class="p">.</span><span class="nc">log</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">logger</span> <span class="p">=</span> <span class="n">Logger</span><span class="p">(</span><span class="n">subsystem</span><span class="p">:</span> <span class="s">&#34;com.example.app&#34;</span><span class="p">,</span> <span class="n">category</span><span class="p">:</span> <span class="s">&#34;networking&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="s">&#34;Starting API request&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">debug</span><span class="p">(</span><span class="s">&#34;Request URL: </span><span class="si">\(</span><span class="n">url</span><span class="p">.</span><span class="n">absoluteString</span><span class="si">)</span><span class="s">&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">error</span><span class="p">(</span><span class="s">&#34;Failed to decode: </span><span class="si">\(</span><span class="n">error</span><span class="p">.</span><span class="n">localizedDescription</span><span class="si">)</span><span class="s">&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Console.app 那边的步骤：</p>
<ol>
<li>选中你的模拟器或已连接的设备</li>
<li>按子系统过滤：<code>subsystem:com.example.app</code></li>
<li>按级别过滤：<code>subsystem:com.example.app AND level:error</code></li>
</ol>
<p>常用的过滤谓词可以存起来。我存了三个：看网络请求的、看数据库操作的、只显示错误和警告的。</p>

<h3 class="relative group">为什么用 OSLog 而不是 print()
    <div id="为什么用-oslog-而不是-print" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%ba%e4%bb%80%e4%b9%88%e7%94%a8-oslog-%e8%80%8c%e4%b8%8d%e6%98%af-print" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>OSLog 比到处撒 <code>print()</code> 强太多。它是结构化的，能按级别和分类过滤；日志本身经过优化，不会拖慢应用；生产环境里还会自动遮蔽敏感数据，而且应用崩了日志也还在。</p>
<p>临时看一眼，用 <code>print()</code> 没问题。以后可能要翻的东西，交给 OSLog。</p>

<h3 class="relative group">Console.app 过滤进阶
    <div id="consoleapp-过滤进阶" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#consoleapp-%e8%bf%87%e6%bb%a4%e8%bf%9b%e9%98%b6" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>Console.app 支持相当强的谓词查询。我常用的几条：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># 应用里所有的错误和故障
</span></span><span class="line"><span class="cl">subsystem:com.example.app AND (level:error OR level:fault)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># 失败的网络请求
</span></span><span class="line"><span class="cl">subsystem:com.example.app AND category:networking AND eventMessage CONTAINS &#34;failed&#34;
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># 过去 5 分钟的所有内容
</span></span><span class="line"><span class="cl">subsystem:com.example.app AND timestamp &gt;= now(-5m)</span></span></code></pre></div></div>
<p>日志还能导出来附在 bug 报告里，比从 Xcode 控制台复制粘贴省事多了。</p>

<h2 class="relative group">视图调试
    <div id="视图调试" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%a7%86%e5%9b%be%e8%b0%83%e8%af%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>UI 坏了又看不出为什么，最快的路是视图调试器。</p>
<p>运行应用，走到出问题的页面，点 Xcode 调试栏里的“Debug View Hierarchy”按钮（或按 Cmd+Shift+D），整个视图层级就以 3D 分解图摊在你面前。</p>
<p>转一转，问题自己会冒出来：</p>
<ul>
<li>藏在其他视图后面照常渲染的视图</li>
<li>跑到屏幕外老远的视图</li>
<li>大小为零的视图</li>
<li>选中视图的完整约束链</li>
</ul>
<p>我用它找到过吞掉点击事件的隐形按钮、以 0x0 渲染的标签，还有一张不知怎么变成 10,000 像素宽的图片。</p>

<h3 class="relative group">Auto Layout 调试
    <div id="auto-layout-调试" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#auto-layout-%e8%b0%83%e8%af%95" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>视图层级里的紫色警告图标就是约束冲突。点开，Xcode 会告诉你到底哪些约束在打架。</p>
<p>给约束加上标识符：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="n">heightConstraint</span><span class="p">.</span><span class="n">identifier</span> <span class="p">=</span> <span class="s">&#34;ProfileImageHeight&#34;</span></span></span></code></pre></div></div>
<p>约束出问题时，错误里显示的就是 <code>&quot;ProfileImageHeight&quot;</code> 而不是一串内存地址，日志一下就能读了。</p>
<p>在 LLDB 里也能打印约束树：</p>
<div class="highlight-wrapper"><pre tabindex="0"><code class="language-lldb" data-lang="lldb">(lldb) po view.hasAmbiguousLayout
(lldb) po view._autolayoutTrace()</code></pre></div>

<h2 class="relative group">省时间的模拟器功能
    <div id="省时间的模拟器功能" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%9c%81%e6%97%b6%e9%97%b4%e7%9a%84%e6%a8%a1%e6%8b%9f%e5%99%a8%e5%8a%9f%e8%83%bd" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">慢速动画
    <div id="慢速动画" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%85%a2%e9%80%9f%e5%8a%a8%e7%94%bb" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>Debug → Slow Animations，一切放慢到 1/10 速度。想看清转场里到底发生了什么、动画为什么看着别扭，用它正好。</p>

<h3 class="relative group">模拟内存警告
    <div id="模拟内存警告" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%a8%a1%e6%8b%9f%e5%86%85%e5%ad%98%e8%ad%a6%e5%91%8a" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>Debug → Simulate Memory Warning，测试应用在低内存下的表现。我靠它发现过不少图片缓存 bug：内存警告触发之前一切正常，一触发所有图片全没了。</p>

<h3 class="relative group">Network Link Conditioner
    <div id="network-link-conditioner" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#network-link-conditioner" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>Xcode → Open Developer Tools → Network Link Conditioner</p>
<p>可以模拟 3G、LTE、高丢包率或者完全离线。如果你遇到过 API 请求在 WiFi 上好好的、一到蜂窝网络就超时，用它就能复现。</p>
<p>我常备一个“Bad Network”配置：500ms 延迟加 10% 丢包。那些在办公室高速 WiFi 上永远看不到的超时 bug 和加载状态问题，全在这现形。</p>

<h3 class="relative group">状态栏覆盖
    <div id="状态栏覆盖" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%8a%b6%e6%80%81%e6%a0%8f%e8%a6%86%e7%9b%96" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>在模拟器里右键状态栏，可以改时间、电量、信号强度和运营商。想统一截图，或者想看不同电量下 UI 的样子，都用得上。</p>

<h3 class="relative group">位置模拟
    <div id="位置模拟" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%bd%8d%e7%bd%ae%e6%a8%a1%e6%8b%9f" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>Debug → Location，人不离开桌子就能模拟各种位置：自定义坐标、城市步行、高速公路驾驶，全都有。测位置相关功能，或者查只在某些地区出现的问题，特别方便。</p>

<h2 class="relative group">内存调试
    <div id="内存调试" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%86%85%e5%ad%98%e8%b0%83%e8%af%95" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">Debug Memory Graph
    <div id="debug-memory-graph" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#debug-memory-graph" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>点 Debug Memory Graph 按钮（Cmd+Shift+M），Xcode 会展示内存里的每个对象、它们之间的引用关系，以及你真正关心的：哪些在泄漏。</p>
<p>紫色感叹号就是泄漏。点开就能看到循环引用。</p>
<p>我见得最多的泄漏长这样：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kd">class</span> <span class="nc">ViewController</span><span class="p">:</span> <span class="n">UIViewController</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kd">var</span> <span class="nv">onComplete</span><span class="p">:</span> <span class="p">(()</span> <span class="p">-&gt;</span> <span class="nb">Void</span><span class="p">)?</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kd">func</span> <span class="nf">setupHandler</span><span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="n">onComplete</span> <span class="p">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">            <span class="kc">self</span><span class="p">.</span><span class="n">dismiss</span><span class="p">(</span><span class="n">animated</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>  <span class="c1">// ❌ 强引用了 self</span>
</span></span><span class="line"><span class="cl">        <span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>用 weak self 修：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="n">onComplete</span> <span class="p">=</span> <span class="p">{</span> <span class="p">[</span><span class="kr">weak</span> <span class="kc">self</span><span class="p">]</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">    <span class="kc">self</span><span class="p">?.</span><span class="n">dismiss</span><span class="p">(</span><span class="n">animated</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>  <span class="c1">// ✓ 没有循环引用</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>delegate 也得是 weak：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kr">weak</span> <span class="kd">var</span> <span class="nv">delegate</span><span class="p">:</span> <span class="n">ManagerDelegate</span><span class="p">?</span>  <span class="c1">// 不只是 &#39;var&#39;</span></span></span></code></pre></div></div>

<h3 class="relative group">Instruments
    <div id="instruments" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#instruments" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>要认真查内存，就上 Instruments（Product → Profile，或 Cmd+I）。</p>
<p>Allocations 能看到每一次对象分配、内存随时间的增长、哪些类最占内存，以及每次分配发生在哪的堆栈跟踪。</p>
<p>我通常找三样东西：随时间线性增长的内存（八成是泄漏）、意料之外的大块分配（是不是把 1000 张图一次全加载了？）、该释放却还活着的对象。</p>
<p>在操作前后标记代（那个小旗子按钮），就能看出什么东西不该留却留下了。</p>

<h2 class="relative group">性能分析
    <div id="性能分析" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%80%a7%e8%83%bd%e5%88%86%e6%9e%90" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">Time Profiler
    <div id="time-profiler" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#time-profiler" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>Product → Profile → Time Profiler。一边在应用里做那个慢操作一边录制，然后读调用树。</p>
<p>按“Self Weight”排序找瓶颈。哪个函数占了 40% 的 self weight，时间就是耗在那了。</p>
<p>我有次发现一个 JSON 解析函数每秒被调用 1000 次。挪到后台队列，UI 立刻不卡了。</p>

<h3 class="relative group">Main Thread Checker
    <div id="main-thread-checker" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#main-thread-checker" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>后台线程上的 UI 更新，Xcode 会自动抓。看到这条：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Main Thread Checker: UI API called on a background thread: -[UILabel setText:]</span></span></code></pre></div></div>
<p>说明你干了这种事：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="n">URLSession</span><span class="p">.</span><span class="n">shared</span><span class="p">.</span><span class="n">dataTask</span><span class="p">(</span><span class="n">with</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span> <span class="p">{</span> <span class="n">data</span><span class="p">,</span> <span class="n">response</span><span class="p">,</span> <span class="n">error</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">    <span class="kc">self</span><span class="p">.</span><span class="n">label</span><span class="p">.</span><span class="n">text</span> <span class="p">=</span> <span class="s">&#34;Loaded&#34;</span>  <span class="c1">// ❌ 崩溃！</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>改成这样：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="n">URLSession</span><span class="p">.</span><span class="n">shared</span><span class="p">.</span><span class="n">dataTask</span><span class="p">(</span><span class="n">with</span><span class="p">:</span> <span class="n">url</span><span class="p">)</span> <span class="p">{</span> <span class="n">data</span><span class="p">,</span> <span class="n">response</span><span class="p">,</span> <span class="n">error</span> <span class="k">in</span>
</span></span><span class="line"><span class="cl">    <span class="n">DispatchQueue</span><span class="p">.</span><span class="n">main</span><span class="p">.</span><span class="n">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="kc">self</span><span class="p">.</span><span class="n">label</span><span class="p">.</span><span class="n">text</span> <span class="p">=</span> <span class="s">&#34;Loaded&#34;</span>  <span class="c1">// ✓</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>

<h2 class="relative group">运行时诊断
    <div id="运行时诊断" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%bf%90%e8%a1%8c%e6%97%b6%e8%af%8a%e6%96%ad" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Edit Scheme → Run → Diagnostics。这里一排复选框，专抓那些靠手动永远找不到的 bug。</p>

<h3 class="relative group">Address Sanitizer
    <div id="address-sanitizer" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#address-sanitizer" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>查内存损坏：use-after-free、缓冲区溢出、内存泄漏。应用会慢 2-3 倍，但它抓的 bug 换别的办法几乎无从下手。追那种偶尔才出现的崩溃时，我会把它打开。</p>

<h3 class="relative group">Thread Sanitizer
    <div id="thread-sanitizer" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#thread-sanitizer" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>抓数据竞争和线程问题。开着它跑应用，正常用就行，只要有竞态条件，它自然会找出来。</p>
<p>Address Sanitizer 和 Thread Sanitizer 不能同时开，所以碰到诡异崩溃我就在两者之间轮换。</p>

<h3 class="relative group">Zombie Objects
    <div id="zombie-objects" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#zombie-objects" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>抓发给已释放对象的消息。开着它，你一碰已释放的内存，Xcode 立刻告诉你：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">*** -[MyViewController viewDidLoad]: message sent to deallocated instance 0x600001234000</span></span></code></pre></div></div>
<p>别一直开着。它会阻止对象释放，内存只涨不降。但要钉死某个 use-after-free bug，它是最好的。</p>

<h2 class="relative group">常见调试场景
    <div id="常见调试场景" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%b8%b8%e8%a7%81%e8%b0%83%e8%af%95%e5%9c%ba%e6%99%af" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">一启动就崩溃
    <div id="一启动就崩溃" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%80%e5%90%af%e5%8a%a8%e5%b0%b1%e5%b4%a9%e6%ba%83" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>先加异常断点，通常就能抓到。</p>
<p>抓不到就查这几处：Info.plist 缺键、初始化代码里的强制解包、依赖注入失败。</p>

<h3 class="relative group">UI 不更新
    <div id="ui-不更新" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ui-%e4%b8%8d%e6%9b%b4%e6%96%b0" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>每次。每一次，都是其中之一：</p>
<ol>
<li>更新不在主线程上</li>
<li>outlet 没连上</li>
<li>视图根本不在屏幕上（LLDB 里 <code>po view.window</code> 查一下，返回 nil 就说明不在层级里）</li>
</ol>
<p>表格或集合视图的话，是你忘了调 <code>reloadData()</code>。</p>

<h3 class="relative group">内存警告导致崩溃
    <div id="内存警告导致崩溃" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%86%85%e5%ad%98%e8%ad%a6%e5%91%8a%e5%af%bc%e8%87%b4%e5%b4%a9%e6%ba%83" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>用 Instruments Allocations 看什么在占内存。通常不外乎：图片没从缓存里放掉、视图控制器释放不了（循环引用）、某个数组或字典一直在涨。</p>
<p>在模拟器里模拟内存警告，把清理代码测一遍。</p>

<h3 class="relative group">神秘崩溃
    <div id="神秘崩溃" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%a5%9e%e7%a7%98%e5%b4%a9%e6%ba%83" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>sanitizer 全开。间歇性的多半是线程问题，用 Thread Sanitizer 跑；崩在系统框架里的多半是内存损坏，用 Address Sanitizer 跑。</p>

<h3 class="relative group">复现不了的崩溃
    <div id="复现不了的崩溃" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%a4%8d%e7%8e%b0%e4%b8%8d%e4%ba%86%e7%9a%84%e5%b4%a9%e6%ba%83" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>崩溃要是发生在别人的手机上，崩溃报告一般还找得到。TestFlight 和 App Store 的构建，Xcode 的 Organizer（Window → Organizer → Crashes）会收集那些同意共享分析数据的用户的崩溃报告，只要 dSYM 跟着构建一起上传了，报告就已经符号化好了。测试人员的手机上，报告就存在设备本地：设置 → 隐私与安全性 → 分析与改进 → 分析数据，里面以应用名开头的 <code>.ips</code> 文件就是，让对方用隔空投送（AirDrop）传过来就行。先看崩溃线程的调用栈，再按上面几种场景逐个排查。</p>

<h3 class="relative group">在模拟器里调试 OTA 更新
    <div id="在模拟器里调试-ota-更新" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9c%a8%e6%a8%a1%e6%8b%9f%e5%99%a8%e9%87%8c%e8%b0%83%e8%af%95-ota-%e6%9b%b4%e6%96%b0" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>我的应用会推送 OTA（空中下载）更新，而调试这整条流程，模拟器是最省事的地方。</p>
<p>我先盯网络流量。把 Console.app 过滤到应用的子系统，清单请求和响应就实时滚出来，发了什么头部、服务器回了什么，一清二楚。</p>
<p>然后故意把网络弄坏。用 Network Link Conditioner 看看更新流程在慢连接、丢包下什么表现。应用挂住了吗？加载指示器出来了吗？有没有体面地降级？</p>
<p>更新检查逻辑周围都埋上日志：开始检查时、发现可用更新时、开始下载时、加载新 bundle 时，每个节点都记。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="kd">import</span> <span class="nc">os</span><span class="p">.</span><span class="nc">log</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">logger</span> <span class="p">=</span> <span class="n">Logger</span><span class="p">(</span><span class="n">subsystem</span><span class="p">:</span> <span class="s">&#34;com.example.app&#34;</span><span class="p">,</span> <span class="n">category</span><span class="p">:</span> <span class="s">&#34;updates&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="s">&#34;Checking for updates...&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nv">update</span> <span class="p">=</span> <span class="k">try</span> <span class="n">await</span> <span class="n">Updates</span><span class="p">.</span><span class="n">checkForUpdateAsync</span><span class="p">()</span>
</span></span><span class="line"><span class="cl"><span class="n">logger</span><span class="p">.</span><span class="n">info</span><span class="p">(</span><span class="s">&#34;Update available: </span><span class="si">\(</span><span class="n">update</span><span class="p">.</span><span class="n">isAvailable</span><span class="si">)</span><span class="s">&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>最后，清单端点本身也值得单独测一遍。用应用发的同样头部直接 curl：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">curl -H <span class="s2">&#34;expo-protocol-version: 1&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">     -H <span class="s2">&#34;expo-platform: ios&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">     -H <span class="s2">&#34;expo-runtime-version: 1.0.0&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">     https://updates.example.com/manifest</span></span></code></pre></div></div>
<p>先确认服务器返回的数据没问题，再去挖客户端。</p>

<h2 class="relative group">第三方工具
    <div id="第三方工具" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%ac%ac%e4%b8%89%e6%96%b9%e5%b7%a5%e5%85%b7" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">Charles Proxy / Proxyman
    <div id="charles-proxy--proxyman" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#charles-proxy--proxyman" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>看全部网络流量、改请求和响应、测试错误条件。API 调试离不开。</p>
<p>Proxyman 的 UI 比 Charles 好，在 macOS 上也更像原生应用。我去年换过去之后就没回头。</p>

<h3 class="relative group">Reveal
    <div id="reveal" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#reveal" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>类似 Xcode 的视图调试器，但更强。不连 Xcode 也能在真机上用，查测试人员设备上的布局问题很合适。</p>

<h2 class="relative group">模拟器和真机的区别
    <div id="模拟器和真机的区别" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%a8%a1%e6%8b%9f%e5%99%a8%e5%92%8c%e7%9c%9f%e6%9c%ba%e7%9a%84%e5%8c%ba%e5%88%ab" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我大部分调试都在模拟器里做，因为快：不用插线，不用签名，也不用等安装。但模拟器说到底是一个跑在 Mac 处理器上的 iOS 构建，用的是 Mac 的内存、Mac 的网络，有些 bug 只有在手机上才会出现。</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>模拟器</th>
					<th>真机</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>速度</td>
					<td>用的是 Mac 的 CPU，通常比手机快</td>
					<td>数据是真实的，性能分析要在这上面做</td>
			</tr>
			<tr>
					<td>内存</td>
					<td>没有真正的上限，所以要手动模拟内存警告</td>
					<td>内存占用太多，系统会直接杀掉应用</td>
			</tr>
			<tr>
					<td>网络</td>
					<td>Mac 的网络连接</td>
					<td>蜂窝网络、信号弱、在 Wi-Fi 和移动数据之间来回切换</td>
			</tr>
			<tr>
					<td>相机、蓝牙、NFC</td>
					<td>用不了</td>
					<td>能用</td>
			</tr>
			<tr>
					<td>推送通知</td>
					<td>可以用 <code>xcrun simctl push</code> 模拟</td>
					<td>真实送达，和用户收到的方式一样</td>
			</tr>
			<tr>
					<td>迭代速度</td>
					<td>几秒钟</td>
					<td>慢一些：签名、安装、连线或 Wi-Fi</td>
			</tr>
	</tbody>
</table>
<p>所以我在模拟器里写逻辑、调逻辑，发版前再拿还在支持的最老的那台 iPhone 跑一遍。性能、内存压力，还有一切跟硬件打交道的东西，没在真机上跑过，就不算真正测过。</p>

<h3 class="relative group">在真机上运行
    <div id="在真机上运行" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9c%a8%e7%9c%9f%e6%9c%ba%e4%b8%8a%e8%bf%90%e8%a1%8c" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>把 iPhone 插上，在运行目标里选中它，再到 Signing &amp; Capabilities 里设好 Team（只装到自己的手机上，免费的 Apple ID 就够了）。从 iOS 16 开始，手机还得在 设置 → 隐私与安全性 里打开开发者模式，第一次打开会重启。打开之后，手机自己的 设置 → 开发者 菜单里也有 Network Link Conditioner，弱网配置和 Mac 上的一样。</p>

<h3 class="relative group">无线调试
    <div id="无线调试" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%97%a0%e7%ba%bf%e8%b0%83%e8%af%95" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>用 USB 连一次设备，到 Window → Devices and Simulators 勾上“Connect via network”，然后拔线。只要在同一个 WiFi 下，设备就一直挂在 Xcode 里。</p>

<h3 class="relative group">Device Console
    <div id="device-console" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#device-console" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>Window → Devices and Simulators → Open Console。系统日志、应用日志、崩溃报告，全都在，比 Xcode 自己的控制台详细得多。</p>
<p>测试人员说“应用崩了”，我一定让他们把设备插上来抓控制台日志。往往里面就躺着一条系统级错误，把一切都解释清楚了。</p>

<h2 class="relative group">我调试时的实际顺序
    <div id="我调试时的实际顺序" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%88%91%e8%b0%83%e8%af%95%e6%97%b6%e7%9a%84%e5%ae%9e%e9%99%85%e9%a1%ba%e5%ba%8f" aria-label="锚点">#</a>
    </span>
    
</h2>
<ol>
<li>没有异常断点就先加一个</li>
<li>重现 bug</li>
<li>在怀疑出问题的地方附近设断点</li>
<li>在 LLDB 里用 <code>po</code> 查值</li>
<li>改变量，不重编译就验证修复</li>
<li>UI 相关就查视图层级</li>
<li>性能相关就用 Instruments 分析</li>
<li>内存或线程相关就开 sanitizer</li>
</ol>
<p>诀窍是从症状往回推。崩溃？异常断点。慢？Time Profiler。内存？Instruments。UI 诡异？视图调试器。</p>

<h2 class="relative group">希望早点知道的事
    <div id="希望早点知道的事" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%b8%8c%e6%9c%9b%e6%97%a9%e7%82%b9%e7%9f%a5%e9%81%93%e7%9a%84%e4%ba%8b" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>用断言。逻辑错误还没变成 bug 就能被拦下：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="bp">assert</span><span class="p">(</span><span class="n">users</span><span class="p">.</span><span class="bp">count</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">,</span> <span class="s">&#34;Users array should never be empty here&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>调试前先提交。一旦开始改东西验证猜想，你迟早会想回退。</p>
<p>调完把 print 删掉，至少也用 <code>#if DEBUG</code> 包起来。旧的调试日志会挡住下一个 bug。</p>
<p>把 LLDB 学明白，比把应用重新构建二十遍快。</p>
<p>还有，模拟器不是真手机。只在真机上出现的 bug，几乎全是线程或内存问题。再不然就是性能问题，毕竟模拟器跑的是 Mac 的 CPU，不是真正的 iPhone 芯片。</p>

<h2 class="relative group">我一直开着的工具
    <div id="我一直开着的工具" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%88%91%e4%b8%80%e7%9b%b4%e5%bc%80%e7%9d%80%e7%9a%84%e5%b7%a5%e5%85%b7" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li>Xcode（这不用说）</li>
<li>过滤到应用子系统的 Console.app</li>
<li>网络调试用 Proxyman</li>
<li>事态严重时上 Instruments</li>
</ul>
<hr>
<p>没有哪个工具包打天下。视图调试器找不出内存泄漏，Instruments 也解不开 Auto Layout 冲突，功夫全在把工具对上症状。这事只能靠次数堆：循环引用踩得多了，看一眼堆栈跟踪就能闻出味来。调试大概永远谈不上有趣，但至少不再是受罪。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-02-26-debugging-ios-mobile-apps/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
