<?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>Debugging · 仁才德</title>
    <link>https://jared.lynskey.co.nz/zh-cn/tags/debugging/</link>
    <description>关于软件工程与研发团队领导力的短文——绩效评估、招聘与团队扩张、CI/CD、DevOps 监控、智能体 AI。写于首尔。</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <copyright>© 2026 仁才德</copyright>
    <lastBuildDate>Tue, 27 Oct 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/zh-cn/tags/debugging/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Playwright 报「Test timeout of 30000ms exceeded.」：先找到永远等不完的那一步</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-22-playwright-timeout-30000ms/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-22-playwright-timeout-30000ms/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Playwright</category>
      <category>UI Testing</category>
      <category>Testing Best Practices</category>
      <category>Debugging</category>
      <description>Playwright 的「Test timeout of 30000ms exceeded.」到底在计哪段时间、按我排查的顺序常见原因有哪些、怎么找到卡住的那一步，以及什么时候调大超时反而是错的。附上我自己测试套件里的一次真实失败：五个测试，一个改了名的测试 ID。</description>
      <content:encoded><![CDATA[<p><code>Test timeout of 30000ms exceeded.</code> 的意思是：某个 Playwright 测试用完了它的 30 秒时限。30 秒是 Playwright Test 的默认值，这段时间要同时覆盖测试主体、<code>beforeEach</code> 钩子和 fixture 的准备。报错点名的是测试而不是某一步，是因为点击、输入和页面跳转默认都没有自己的超时。它们会一直等下去，测试超时只是最先响的那个闹钟。</p>
<p>所以这个错误很少真的是「太慢」。多半是某一步在等一件永远不会发生的事。按我排查的顺序，原因和对应的修法如下：</p>
<ol>
<li>定位器（locator）什么都没匹配到，于是 <code>click()</code> 或 <code>fill()</code> 一直等到测试时间用完。常见原因是测试 ID 改了、文字改了，或者有个对话框挡在前面。改定位器。</li>
<li>页面跳转或 <code>waitForURL()</code> 在等一个永远不会出现的页面，因为重定向、登录页或首次启动页把应用带去了别处。改你等待的条件。</li>
<li>准备工作确实慢：第一次请求才开始编译的开发服务器、从缩容到零状态被唤醒的后端、每个 <code>beforeEach</code> 里都走一遍 UI 登录。改环境，或者只准备一次。</li>
<li>测试本来就长。这时，也只有这时，才用 <code>test.slow()</code> 或 <code>test.setTimeout()</code> 给这个测试加时间。</li>
</ol>
<p>不管是哪种，第一件事都是找到时间用完那一刻还在执行的那一步。第二件事是设置 <code>actionTimeout</code>，让下一次失败直接告诉你是哪一步。</p>

<h2 class="relative group">这 30 秒里都有什么
    <div id="这-30-秒里都有什么" 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%99-30-%e7%a7%92%e9%87%8c%e9%83%bd%e6%9c%89%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>测试超时是一份时限，由 fixture 准备、<code>beforeEach</code> 钩子和测试函数共用。测试结束后，<code>afterEach</code> 钩子和 fixture 清理（teardown）另外再拿一份同样长的时限。<code>beforeAll</code> 和 <code>afterAll</code> 也拿同样的值，但不和任何测试共用，失败时也有自己的报错。</p>
<p>在这份时限里，只有断言有自己的计时。<code>await expect(locator).toBeVisible()</code> 默认 5 秒后放弃，并准确告诉你它在找什么。动作和页面跳转则不然：<code>actionTimeout</code> 和 <code>navigationTimeout</code> 默认不设上限。这个错误之所以这么含糊，原因全在这里。去点一个永远不会出现的元素，并不会失败，它只会老老实实地等，直到别的东西把测试结束掉。</p>
<p>下面是所有能让一次运行结束的超时，以及各自打印的内容。这些报错原文取自 Playwright 1.58 和 1.63，也就是我项目里最旧和最新的两个版本。</p>
<table>
	<thead>
			<tr>
					<th>超时</th>
					<th>默认值</th>
					<th>在哪里设置</th>
					<th>打印的内容</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>测试（主体、<code>beforeEach</code>、fixture 准备）</td>
					<td>30,000 ms</td>
					<td>配置里的 <code>timeout</code>、<code>--timeout</code>、<code>test.setTimeout()</code>、<code>test.slow()</code></td>
					<td><code>Test timeout of 30000ms exceeded.</code></td>
			</tr>
			<tr>
					<td><code>beforeAll</code> / <code>afterAll</code></td>
					<td>与测试超时相同</td>
					<td>在钩子里调用 <code>test.setTimeout()</code></td>
					<td><code>&quot;beforeAll&quot; hook timeout of 30000ms exceeded.</code></td>
			</tr>
			<tr>
					<td>断言（<code>expect</code>）</td>
					<td>5,000 ms</td>
					<td>配置里的 <code>expect: { timeout }</code>，或单个断言上的 <code>{ timeout }</code></td>
					<td><code>expect(locator).toBeVisible() failed</code> … <code>Timeout: 5000ms</code></td>
			</tr>
			<tr>
					<td>动作（<code>click</code>、<code>fill</code>）</td>
					<td>无</td>
					<td><code>use: { actionTimeout }</code>，或单个动作上的 <code>{ timeout }</code></td>
					<td><code>TimeoutError: locator.click: Timeout 10000ms exceeded.</code></td>
			</tr>
			<tr>
					<td>页面跳转（<code>goto</code>、<code>waitForURL</code>）</td>
					<td>无</td>
					<td><code>use: { navigationTimeout }</code>，或单次调用上的 <code>{ timeout }</code></td>
					<td><code>TimeoutError: page.waitForURL: Timeout 15000ms exceeded.</code></td>
			</tr>
			<tr>
					<td>整次运行</td>
					<td>无</td>
					<td>配置里的 <code>globalTimeout</code></td>
					<td><code>Timed out waiting 3600s for the test suite to run</code></td>
			</tr>
			<tr>
					<td><code>webServer</code> 启动</td>
					<td>60,000 ms</td>
					<td><code>webServer: { timeout }</code></td>
					<td><code>Timed out waiting 60000ms from config.webServer.</code></td>
			</tr>
	</tbody>
</table>
<p>动作、页面跳转和整次运行这三行没有默认值可打印，所以里面的数字就是你自己设的值。</p>

<h2 class="relative group">五个测试和一个改了名的测试 ID
    <div id="五个测试和一个改了名的测试-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%ba%94%e4%b8%aa%e6%b5%8b%e8%af%95%e5%92%8c%e4%b8%80%e4%b8%aa%e6%94%b9%e4%ba%86%e5%90%8d%e7%9a%84%e6%b5%8b%e8%af%95-id" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>今年 2 月，我做的估价应用的测试套件在一次运行里有五个测试失败，而且失败方式一模一样。这套测试用 Playwright 驱动应用的 Web 版（由开发服务器提供），只开一个 worker，连的是刚灌好种子数据的本地后端。下面是五个里的第一个，稍作删减：</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">  1) [default] › tests/templates.spec.ts:50:7 › Templates › should edit a template ───
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    Test timeout of 30000ms exceeded.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    Error: locator.click: Target page, context or browser has been closed
</span></span><span class="line"><span class="cl">    Call log:
</span></span><span class="line"><span class="cl">      - waiting for locator(&#39;[data-testid=&#34;template-item&#34;]&#39;).filter({ hasText: &#39;E2E Edit Template&#39; }).locator(&#39;[data-testid=&#34;edit-template&#34;]&#39;)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">      57 |       .locator(&#39;[data-testid=&#34;template-item&#34;]&#39;)
</span></span><span class="line"><span class="cl">      58 |       .filter({ hasText: &#34;E2E Edit Template&#34; });
</span></span><span class="line"><span class="cl">    &gt; 59 |     await item.locator(&#39;[data-testid=&#34;edit-template&#34;]&#39;).click();
</span></span><span class="line"><span class="cl">         |                                                         ^
</span></span><span class="line"><span class="cl">      60 |     await page.waitForTimeout(500);</span></span></code></pre></div></div>
<p>单看 <code>Target page, context or browser has been closed</code> 这一行，像是浏览器崩了。其实不是。这是页面在脚下被关掉时，动作给出的报错：30 秒到了，Playwright 开始收拾这个测试，那个还在等待的点击就报告说它的页面没了。如果用的是内置的 <code>page</code> fixture，这一行会再说一遍超时，写成 <code>Error: locator.click: Test timeout of 30000ms exceeded.</code>。这些测试是在 <code>beforeAll</code> 里自己开的浏览器上下文，所以点击拿到的是通用版本的报错。</p>
<p>真正有用的是调用日志（call log）。点击在等 <code>[data-testid=&quot;template-item&quot;]</code>。十个小时前，我把列表行组件的测试 ID 从 <code>template-item</code> 改成了 <code>template-item-${name}</code>，好让原生应用的端到端测试能按名字挑出某一行。用 <code>=</code> 的属性选择器要求整个值完全相等，于是 Web 测试要找的元素在页面上已经不存在了；又没有动作超时，这个点击注定要一直等到测试超时。五个失败各 30 秒，就是两分半钟，而整次运行一共才 3.8 分钟。</p>
<p>我第一时间加的是重试。不到半小时，配置里就多了 <code>retries: process.env.CI ? 2 : 1</code>，旁边的注释至今还写着 &ldquo;for flaky browser crashes&rdquo;，意思是用来应付偶发的浏览器崩溃。下一次运行，同样那五个里有四个连同重试一起又挂了，耗时从 3.8 分钟涨到 6.4 分钟。这几个 describe 块跑在串行（serial）模式下：一个失败，组里剩下的全部跳过；重试则从组里第一个测试重新开始。所以每个坏掉的点击都把 30 秒等了两遍。每次都以同样方式失败的测试，重试救不回来，只会让你付两遍代价。</p>
<p>修法是改成前缀匹配：</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">const</span> <span class="nx">item</span> <span class="o">=</span> <span class="nx">page</span>
</span></span><span class="line"><span class="cl">  <span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;[data-testid^=&#34;template-item-&#34;]&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="p">.</span><span class="nx">filter</span><span class="p">({</span> <span class="nx">hasText</span><span class="o">:</span> <span class="s2">&#34;E2E Edit Template&#34;</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">item</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;[data-testid=&#34;edit-template&#34;]&#39;</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span></span></span></code></pre></div></div>
<p>往大里说，测试 ID 也是一种契约。我有两套测试都依赖这一个 ID，而彼此都不知道对方的存在。Playwright 的最佳实践指南建议优先用 <code>getByRole()</code> 这类基于用户可见属性的定位器，而不是我这种 CSS 选择器，为的就是让测试扛得住那些用户根本察觉不到的 DOM 改动。</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%89%be%e5%88%b0%e5%8d%a1%e4%bd%8f%e7%9a%84%e9%82%a3%e4%b8%80%e6%ad%a5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>先看调用日志。它属于时间用完时还在执行的那一步；如果 <code>waiting for locator(...)</code> 后面什么都没有，说明定位器从头到尾都没匹配上任何东西。</p>
<p>还不够的话，就打开 trace。我的两个应用测试套件，一个是估价应用的，一个是我做的一个集市兼社区应用的，用的都是 <code>trace: &quot;on-first-retry&quot;</code>。它只在第一次重试时录制，所以 <code>retries</code> 至少要设成 1。不开重试的话用 <code>&quot;retain-on-failure&quot;</code>；临时跑一次就加 <code>--trace on</code>。然后直接打开 trace，或者从 HTML 报告里打开：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">npx playwright show-trace test-results/&lt;test-folder&gt;/trace.zip
</span></span><span class="line"><span class="cl">npx playwright show-report</span></span></code></pre></div></div>
<p>没执行完的那个动作，就是列表末尾附近带着报错的那一项。选中它看快照：那就是 Playwright 等待时页面的样子，你可以在里面找你期待的那个元素。换成我这个例子，那一行就好好地在那儿，只是带着一个测试没在找的 ID。</p>
<p>较新的版本还会在失败截图旁边写一个 <code>error-context.md</code>，里面是失败那一刻页面的文本快照。元素找不到的情况，往往光看它就够了。</p>
<p>有个坑：<code>--debug</code> 打开 Inspector 的同时，还会设置 <code>--timeout=0</code>，外加 <code>--max-failures=1</code>、<code>--headed</code> 和 <code>--workers=1</code>。在 <code>--debug</code> 下能通过的测试，对这 30 秒什么也说明不了。</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%bb%99%e6%af%8f%e4%b8%aa%e5%8a%a8%e4%bd%9c%e8%ae%be%e4%b8%aa%e4%b8%8a%e9%99%90" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我的配置文件里没有一个设了动作超时，所以一个找不到的元素才能五次各耗掉 30 秒。在 <code>use</code> 里加两行就能改过来：</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">defineConfig</span> <span class="p">}</span> <span class="kr">from</span> <span class="s2">&#34;@playwright/test&#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">export</span> <span class="k">default</span> <span class="nx">defineConfig</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">timeout</span>: <span class="kt">30_000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">expect</span><span class="o">:</span> <span class="p">{</span> <span class="nx">timeout</span>: <span class="kt">5_000</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nx">use</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">actionTimeout</span>: <span class="kt">10_000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">navigationTimeout</span>: <span class="kt">15_000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">trace</span><span class="o">:</span> <span class="s2">&#34;retain-on-failure&#34;</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></code></pre></div></div>
<p>同样那个坏掉的定位器，就会在测试还剩时间的时候这样失败：</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">    TimeoutError: locator.click: Timeout 10000ms exceeded.
</span></span><span class="line"><span class="cl">    Call log:
</span></span><span class="line"><span class="cl">      - waiting for locator(&#39;[data-testid=&#34;template-item&#34;]&#39;).filter({ hasText: &#39;E2E Edit Template&#39; }).locator(&#39;[data-testid=&#34;edit-template&#34;]&#39;)</span></span></code></pre></div></div>
<p>哪一步、哪个动作、等了多久，全写在上面。我觉得这两个值都应该明显小于测试超时，这样某一步卡住时，不会把整份时限耗光，再躲到那条含糊的报错后面。2 月那次，问题从来不在 30 秒。那个点击身上只有这一个计时器，所以响的就是它。</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%8f%aa%e7%bb%99%e9%95%bf%e6%b5%8b%e8%af%95%e5%8a%a0%e6%97%b6%e9%97%b4%e5%88%ab%e7%bb%99%e5%8d%a1%e4%bd%8f%e7%9a%84%e6%b5%8b%e8%af%95%e5%8a%a0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>测试确实在做耗时的工作时，加超时是对的；某一步在等一件不会发生的事时，加超时就是错的，数字再大也只是让失败来得更晚。到底是哪种，通常看一眼 trace 就清楚了：动作一个接一个都完成了，只是数量多，那是长测试；有一个动作直到最后都停在 <code>waiting for</code>，那是卡住的测试。</p>
<p>对长测试，时间要加在真正长的地方，而不是整个套件。集市应用的套件里有一个冒烟测试，会一边截图一边把聊天界面走一遍，我给了它三分钟：</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="nx">test</span><span class="p">(</span><span class="s2">&#34;chat: open, send a message, go back&#34;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">test</span><span class="p">.</span><span class="nx">setTimeout</span><span class="p">(</span><span class="mi">180</span><span class="nx">_000</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// ...
</span></span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>
<p>在 <code>describe</code> 里调用 <code>test.setTimeout()</code>，会作用于组内所有测试，估价应用套件里的成本计算（job-cost）测试就是这样拿到一分钟的。<code>test.slow()</code> 会把当前的超时乘以三，不管它原来是多少，也可以带条件。在 <code>beforeEach</code> 里用 <code>testInfo.setTimeout()</code>，会延长所有经过这个钩子的测试：</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="nx">test</span><span class="p">(</span><span class="s2">&#34;slow in WebKit&#34;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span><span class="p">,</span> <span class="nx">browserName</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">test</span><span class="p">.</span><span class="nx">slow</span><span class="p">(</span><span class="nx">browserName</span> <span class="o">===</span> <span class="s2">&#34;webkit&#34;</span><span class="p">,</span> <span class="s2">&#34;This page is slow in WebKit&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <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></span><span class="line"><span class="cl"><span class="nx">test</span><span class="p">.</span><span class="nx">beforeEach</span><span class="p">(</span><span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">},</span> <span class="nx">testInfo</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">testInfo</span><span class="p">.</span><span class="nx">setTimeout</span><span class="p">(</span><span class="nx">testInfo</span><span class="p">.</span><span class="nx">timeout</span> <span class="o">+</span> <span class="mi">30</span><span class="nx">_000</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>
<p>还有一样东西每次都在悄悄吃掉时限：固定等待。估价应用的套件里，几乎每个 spec 文件都用到了 <code>page.waitForTimeout()</code>，加起来一百多处。上面那个编辑测试在走到点击之前先睡了 3 秒，可睡多久都救不了它。Playwright 的 API 文档在这一点上说得很直白：&ldquo;Never wait for timeout in production. Tests that wait for time are inherently flaky.&rdquo; 也就是说，正式环境里永远不要按时间等待，靠时间等待的测试天生就不稳定。Web 优先断言（web-first assertion）本来就会等到需要的时候为止，一点不多等：</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="c1">// before
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;[data-testid=&#34;save-template&#34;]&#39;</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForTimeout</span><span class="p">(</span><span class="mi">1500</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">getByText</span><span class="p">(</span><span class="s2">&#34;E2E Edited Template&#34;</span><span class="p">)).</span><span class="nx">toBeVisible</span><span class="p">();</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// after
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;[data-testid=&#34;save-template&#34;]&#39;</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">getByText</span><span class="p">(</span><span class="s2">&#34;E2E Edited Template&#34;</span><span class="p">)).</span><span class="nx">toBeVisible</span><span class="p">();</span></span></span></code></pre></div></div>
<p>断言超时也要同样小心。两个应用套件都把 <code>expect.timeout</code> 调到了 15 秒，而且都是对着应用的开发服务器跑，而不是生产构建。这样是多了余量，但连着两个慢断言就可能把 30 秒用光，于是测试超时先触发，你又回到了那条含糊的报错。</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%bd%93%e8%ae%a1%e6%97%b6%e7%9a%84%e4%b8%8d%e6%98%af%e6%b5%8b%e8%af%95%e6%9c%ac%e8%ba%ab" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>如果报错里出现 <code>config.webServer</code>，说明还一个测试都没跑。Playwright 会先启动你的 <code>webServer</code> 命令，等它的 <code>url</code> 有响应了才开始跑测试。这段等待有单独的超时，默认 60 秒。两个应用套件都给开发服务器留了两分钟：</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="nx">webServer</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">command</span><span class="o">:</span> <span class="s2">&#34;npm run web -- --port 8081&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">url</span><span class="o">:</span> <span class="s2">&#34;http://localhost:8081&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">reuseExistingServer</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">timeout</span>: <span class="kt">120_000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">},</span></span></span></code></pre></div></div>
<p>如果测试对着的是部署好的环境而不是本地服务器，先搞清楚它的第一个请求要多久。我终于在自己用的托管平台上量了一次，一台从缩容到零状态启动的机器，要 25 到 33 秒才能响应任何请求，还没点第一下，测试的时限就用掉了大半甚至全部。那段经历写在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/" >缩容到零，于是每次重启都是一次 30 秒的故障</a>里。</p>

<h2 class="relative group">如果报的是「No tests found」
    <div id="如果报的是no-tests-found" 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%a6%82%e6%9e%9c%e6%8a%a5%e7%9a%84%e6%98%afno-tests-found" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><code>No tests found</code> 听起来相关，但它不是超时：Playwright 找了一圈，没有可跑的东西。默认情况下，它会在 <code>testDir</code> 下收集匹配 <code>**/*.@(spec|test).?(c|m)[jt]s?(x)</code> 的文件，而 <code>testDir</code> 默认就是配置文件所在的目录。我的配置都指向配置文件旁边的 <code>testDir: &quot;./tests&quot;</code>，所以放在别处的 spec 根本看不见。命令行参数是拿来匹配文件路径的正则表达式，所以报错里才会附上一句 &ldquo;Make sure that arguments are regular expressions matching test files.&quot;。想在不运行任何东西的情况下，看看 Playwright 收集到了什么：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">npx playwright <span class="nb">test</span> --list</span></span></code></pre></div></div>
<p>如果你的 spec 不在这个列表里，它压根就没机会超时。另外，如果你是第一次搭 Playwright，我在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2024/2024-06-09-playwright-tests/" >小团队更该做视觉测试</a>里讲过为什么建议从截图对比开始。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-playwright-timeout-30000ms/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>
