<?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>React Native · 仁才德</title>
    <link>https://jared.lynskey.co.nz/zh-cn/tags/react-native/</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/react-native/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>用 Codemagic Patch 替换我自建的 Expo 更新服务器</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-13-moving-to-codemagic-patch/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-13-moving-to-codemagic-patch/</guid>
      <pubDate>Sun, 13 Sep 2026 00:00:00 +0900</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Codemagic Patch</category>
      <category>CodePush</category>
      <category>自托管</category>
      <category>移动开发</category>
      <category>CI/CD</category>
      <description>我把一个真实的 Expo 应用，连原生工程一起，从 expo-updates 迁到了 Codemagic Patch：本地栈、迁移过程、一个我自己造成又通过 OTA 修掉的 bug，以及一次发布真的崩了之后回滚是什么样子。</description>
      <content:encoded><![CDATA[<blockquote><p><strong>声明。</strong> 这次迁移和这篇文章是 Codemagic 付费委托的。他们只对事实准确性做了一轮审阅，不涉及语气，文中没有任何内容是应他们要求修改的。凡是我遇到问题并反馈了的地方，我都会说明；如果你读到这里时问题已经修复，文中会写成已修复。</p>
</blockquote><blockquote><p><strong>2026 年 9 月 23 日更新。</strong> 我反馈的问题，Codemagic 的开发者都逐一处理了。大部分已经修复，包括 iOS 版本检测的 bug、重复计数的安装、首次运行时的静默，以及那条吵闹的调试命令；文档现在也写到了已提交的原生工程和崩溃回滚的额度。下文中每个问题出现的地方我都标注了，仍未解决的那几个保持原样。</p>
</blockquote><blockquote><p><strong>2026 年 10 月 16 日更新。</strong> 迁到 Patch 之后的第一次原生升级（Expo SDK 58）构建出了一个完全不含 Patch 配置的 Android release，而且没有任何地方报错。来龙去脉和只改一个文件的修复写在 <a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-13-moving-to-codemagic-patch/#%e7%ac%ac%e4%b8%80%e6%ac%a1%e5%8e%9f%e7%94%9f%e5%8d%87%e7%ba%a7" >第一次原生升级</a>。</p>
</blockquote><p>二月我写过一篇<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-02-22-self-hosted-ota-updates/" >自己搭 Expo 更新服务器</a>的文章。它现在还在跑。我运营的社区应用 maru 从那以后每一次 OTA 修复都走它，两个平台都是，每月大约一美元。</p>
<p>这也意味着每一块都归我管：几张数据库表、一个 API 接口、一条管理命令、一个发布脚本、一个共享密钥，还有一堆坑，每个坑都是靠一次坏掉的更新学来的。坐下来做规划时我才发现，那个脚本从我写它的第一天起，每次生产发布之后都会打印 &ldquo;Sent 0 push notifications&rdquo;，读的是一个这台服务器从未返回过的字段。没人注意到，最没注意到的就是我。自己维护一套东西，大概就会变成这样。</p>
<p>所以当 Codemagic 问我要不要试试 <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a>，他们那套<a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">开源</a>、可自托管的 CodePush 替代品，我答应了，只有一个条件：要用真实的应用，而不是 demo。真实的签名、真实的发布流程、已经提交进仓库并且手改过的原生工程，全套。大多数打算离开 EAS Update 的人，要迁的都是这样的应用，不是刚生成的模板。</p>
<p>下面就是整个过程，毛边都留着。</p>

<h2 class="relative group">Patch 是什么
    <div id="patch-是什么" 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="#patch-%e6%98%af%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Patch 是一台你自己跑的服务器，加一个放进应用里的 SDK。服务端包括一个 API、一个后台 worker、Postgres、S3 兼容的对象存储和一个 Web 面板，打包成一套 Docker Compose 栈。客户端是 <code>@codemagic/react-native-patch</code>，一个支持 iOS 和 Android 的 TurboModule，附带 Expo 配置插件，再加一个 CLI，<code>cmpatch</code>，负责打包 JavaScript 并发布。</p>
<p>读完源码之后，有两个设计决定让我印象深刻。</p>
<p>第一，应用从不向 API 询问有没有更新。发布一个 release 时，worker 会把纯 JSON 的清单写进对象存储，应用只是去取那些文件。一次更新检查就是对存储或 CDN 的两个静态 GET，API 只接收指标。我原来的接口站在每一次应用启动的请求路径上；这一套想站也站不上去。</p>
<p>第二，每个 release 都精确地写明它面向哪个二进制版本，CLI 会对你的原生工程算一个指纹，拒绝把 JavaScript 发到原生代码对不上的二进制上。我的服务器只有一个版本字符串，剩下的全靠祈祷。</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%85%88%e5%9c%a8%e8%87%aa%e5%b7%b1%e7%ac%94%e8%ae%b0%e6%9c%ac%e4%b8%8a%e8%af%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>文档一上来让你搭的是本地评估栈，从这里开始是对的。</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">npm install -g @codemagic/patch-cli
</span></span><span class="line"><span class="cl">cmpatch selfhost local-eval up</span></span></code></pre></div></div>
<p>CLI 两秒就装好了。整套栈第一次起来花了 3 分 14 秒，大部分时间在本地构建服务器和面板的镜像，而且在非交互模式下，这期间它什么都不打印，看起来和挂死一模一样。（现已修复：它现在每一步会打印一行进度。）然后四个健康的容器就出现了，全部绑定在 localhost：面板在 8080，API 在 3000，Postgres，还有 MinIO 在 9100。它很懂事地避开了我自己那套栈已经占用的端口。</p>
<p>这个模式下登录是关掉的，每个页面上都有一条警告，叫你别把它暴露出去。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAACQBACdASogABQAPv1ko0eloqGYBgBbD8TgCzgCWNWXs6T53beCcoRI0PP/N6AA/K7n/8I/+iwVHjl71MVWujkm+9TgA0wAr+PZ+x6+cQcgCHdovZ75vexrOBrFPrvj8AA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_6ee9ddaa0c7c695e.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_6ee9ddaa0c7c695e.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_e5915d9b78052f15.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="本地评估模式的登录页，邮箱已预填，并提示认证已禁用"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_8ae8b3cbcb1d7ae7.webp"></span></div>
  <figcaption>本地评估模式用一个预填好的邮箱代替了 GitHub 登录。</figcaption>
</figure>

<p>栈自带一个叫 Example Data 的种子应用，在你发布任何东西之前值得先看一眼，因为它展示了面板是用来干什么的。接下来两张截图里的数字都是种子数据，不是真实用户。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAAAQBACdASogABUAPw1uqkqmIyGYDAQAYxDE9IABUVMfRogwtMiXImNo4AD+1hmAfbeuR0QbIc0lZuw4PBjyhGJq5t/3NdR81x+BG5H496NeiObQODoB8gkWF2AAAA==') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_dfad23ca3450bc38.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_dfad23ca3450bc38.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_453fd7641ffbc69b.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1600" height="1037"
      alt="种子应用 Example Data 的发布历史，有一个 10% 的金丝雀发布，以及每个 release 的成功和失败次数"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_30abe30dd08f2234.webp"></span></div>
  <figcaption>种子演示数据：发布历史，含一个 10% 的金丝雀发布、目标版本，以及每个 release 的成功和失败次数。</figcaption>
</figure>

<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRoQAAABXRUJQVlA4IHgAAABwBACdASogABcAPw1qrEsmIqIYDAQAYxDE9IBnsglwh49zWML+lyXzzjPEgAD+4AZDuD4GlZfO+rTnJJ+yBUjSHGV1a2gPc+tdTO7IIMf86boVjtRW8VJ7KXLfxZsJzumvn68JtLzOqetoXfqt/I/Omlo4DyEAAAA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_2e0ff04f94cd5a88.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_2e0ff04f94cd5a88.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_91146bb0b6c75d52.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1600" height="1155"
      alt="种子应用的部署指标：版本分布、随时间的采用率、更新结果"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_1f6ae6ce53428957.webp"></span></div>
  <figcaption>种子演示数据：设备实际在跑哪些 release，以及安装进行得如何。</figcaption>
</figure>

<p>作为对比，我的面板就是后台里的一张更新列表，带一个“启用”复选框。到底有多少台手机真的装了更新，我完全不知道。</p>
<p>CLI 登录是一套正经的浏览器流程，带 PKCE 和 localhost 回调，批准页面会告诉你是哪个账号在请求，以及授权码大约一分钟后过期。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRn4AAABXRUJQVlA4IHIAAADwBACdASogABQAPv1ioUeloqGYBgBbD8TxAFnAEsa4FDLDrochGk8HT/WQ+e1SUMAA/K7n5mOlQFSkxh0DtgfJ9tHRhVgvwu2jEApXgWL2ekdKJHkUb1NzXgNcjv6IA1JC+dF2JqE73QqxENNNeYvZMAA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_ee30fa96a8ea860e.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_ee30fa96a8ea860e.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_c7af1bedbd1fa8b3.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="面板询问是否批准一次 CLI 登录"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_c267548bca14bf21.webp"></span></div>
  <figcaption>cmpatch login 会请面板来批准这个终端。</figcaption>
</figure>


<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%af%b9%e5%87%86%e4%b8%80%e4%b8%aa%e7%9c%9f%e5%ae%9e%e5%ba%94%e7%94%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Patch 要求每个平台一个应用，每个应用会自动建好 Staging 和 Production 两个 deployment。我在面板里建了 <code>maru-ios</code>，又用 <code>cmpatch app create</code> 建了 <code>maru-android</code>，主要是两种方式都看看。建完应用后弹出的对话框是整个产品里对迁移最有用的一屏：两个 deployment key、SDK 需要的两个 URL，还有一句说明：deployment key 不是机密。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRoIAAABXRUJQVlA4IHYAAAAQBQCdASogABQAPuFWmkekIqEb9ABDDgT0gF8kHTL70/EPugF6lMUZ0A+0pWU0WRlAAPpGgpz7VeOvAMdDs/q/I00JZTDCozGF/p+yUbXDUjfdJacA2ZbVan+1M3EfSih3MXlM1+RWV02wNY/nmH4Pte3keQAA') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_31f66d5c03d282e7.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_31f66d5c03d282e7.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_ec9ad24dac45b7f9.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="应用创建完成的对话框，显示 Staging 和 Production 的 deployment key 以及 SDK 的 URL"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_bd9fb369cb1053b8.webp"></span></div>
  <figcaption>SDK 需要的一切，一屏看全。</figcaption>
</figure>

<p>两件小事。<code>cmpatch init</code> 当时是把项目关联到已经存在的应用上，所以不管迁移指南怎么暗示，它都不可能是第一条命令。这一点后来修好了：在交互模式下，它现在会自己给每个平台建一个应用。另外，对话框给的 API URL 是 <code>http://localhost:8080</code>，而文档和 CLI 说的是 3000。两个都能用，因为两个端口都同时提供这两种服务，但你会犯嘀咕。</p>
<p>接着我对着还没动过的应用跑了 <code>cmpatch doctor</code>，三秒钟它就找出了一个真问题。它报告 iOS 二进制版本是 2.0.0。应用其实是 2.5.1。</p>
<p>CLI 会找出 <code>ios/</code> 下所有的 <code>Info.plist</code>，如果没有一个路径里带 &ldquo;test&rdquo;，就按字母顺序取第一个。maru 有一个 WidgetKit 扩展，它的目录按字母排在应用本身的目录前面，于是 CLI 拿到的是扩展的 <code>Info.plist</code>。这个 plist 还停在 2.0.0，这是我的 bug，但后果很难看：自动检测会把每一个 iOS release 都对准一个没有任何手机在跑的版本，而且不会报任何错。更新只是永远不会到，正是我在自建服务器那篇里警告过的那种故障。Android 读 <code>versionName</code> 是对的。绕过的办法是传 <code>--target-binary-version</code> 或 <code>--plist-file</code>，我的发布脚本现在会先检查 <code>app.config.js</code> 和两个原生工程的版本一致，然后显式传入版本。后来同一个 bug 又让 <code>doctor</code> 自己的清单检查在一个运行正常的 deployment 上失败，因为它跑去找 2.0.0 版本的清单了。修好过期的 plist 之后，<code>doctor</code> 的 24 项检查全部通过。</p>
<p>Codemagic 后来修复了这个问题。CLI 现在读取的是应用 target 的 <code>Info.plist</code>，而不是扩展的；如果有不止一个 target 可能是应用，<code>cmpatch</code> 会问你是哪一个。</p>

<h2 class="relative group">JavaScript 这一侧
    <div id="javascript-这一侧" 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="#javascript-%e8%bf%99%e4%b8%80%e4%be%a7" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>去掉 <code>expo-updates</code>、加上 SDK，是两行的改动。把应用原来围绕更新做的那些事换过来，要多想一想。</p>
<p>maru 在启动时和每次回到前台时检查更新，最多每十五分钟一次。发现更新就下载，然后提议重启；你说不，它就在下一次冷启动时应用。设置页里还有一个手动检查，显示当前跑的是哪个 bundle，错误上报器也会给每份报告打上它来自哪个更新的标签。</p>
<p>从 SDK 源码里学到的第一件事是，它在 import 时就用 <code>TurboModuleRegistry.getEnforcing</code> 查找原生模块，模块不存在就直接抛错。maru 还有一个 Web 构建，顶层 import 会把 Web 应用整个搞挂。所以一切都走一个懒加载 SDK 的小模块，配一个什么都不返回的 <code>.web.ts</code> 孪生文件，这样还能把 SDK 彻底挡在 Web bundle 之外。</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">// ota.ts (ota.web.ts exports a patchSdk() that always returns null)
</span></span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">Platform</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></span><span class="line"><span class="cl"><span class="kr">type</span> <span class="nx">PatchSdk</span> <span class="o">=</span> <span class="k">typeof</span> <span class="kr">import</span><span class="p">(</span><span class="s2">&#34;@codemagic/react-native-patch&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nx">sdk</span>: <span class="kt">PatchSdk</span> <span class="o">|</span> <span class="kc">null</span> <span class="o">|</span> <span class="kc">undefined</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="kd">function</span> <span class="nx">patchSdk</span><span class="p">()</span><span class="o">:</span> <span class="nx">PatchSdk</span> <span class="o">|</span> <span class="kc">null</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">sdk</span> <span class="o">!==</span> <span class="kc">undefined</span><span class="p">)</span> <span class="k">return</span> <span class="nx">sdk</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</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="p">)</span> <span class="k">return</span> <span class="p">(</span><span class="nx">sdk</span> <span class="o">=</span> <span class="kc">null</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="nx">sdk</span> <span class="o">=</span> <span class="kr">require</span><span class="p">(</span><span class="s2">&#34;@codemagic/react-native-patch&#34;</span><span class="p">)</span> <span class="kr">as</span> <span class="nx">PatchSdk</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="nx">sdk</span> <span class="o">=</span> <span class="kc">null</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="nx">sdk</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>sync()</code>，它从不抛错，而是 resolve 成一个状态：</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">status</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">patch</span><span class="p">.</span><span class="nx">sync</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">installMode</span><span class="o">:</span> <span class="s2">&#34;ON_NEXT_RESTART&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">mandatoryInstallMode</span><span class="o">:</span> <span class="s2">&#34;ON_NEXT_RESUME&#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="k">if</span> <span class="p">(</span><span class="nx">status</span> <span class="o">===</span> <span class="s2">&#34;update-installed&#34;</span><span class="p">)</span> <span class="nx">offerRestart</span><span class="p">();</span></span></span></code></pre></div></div>
<p>SDK 对强制 release 的默认值是 <code>IMMEDIATE</code>，不管用户正在干什么都会直接重载 JavaScript。我改成了下次从后台回来时再应用，因为弄丢一篇写了一半的帖子，这个 bug 比大多数修复带来的好处都要糟。当时 <code>InstallMode</code> 是字符串联合类型，不是文档示例里暗示的那种枚举对象；现在它导出为一个常量对象，所以 <code>InstallMode.ON_NEXT_RESTART</code> 可以照文档那样用了。</p>

<h2 class="relative group">原生这一侧，当 ios/ 和 android/ 已经提交进仓库
    <div id="原生这一侧当-ios-和-android-已经提交进仓库" 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%8e%9f%e7%94%9f%e8%bf%99%e4%b8%80%e4%be%a7%e5%bd%93-ios-%e5%92%8c-android-%e5%b7%b2%e7%bb%8f%e6%8f%90%e4%ba%a4%e8%bf%9b%e4%bb%93%e5%ba%93" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>迁移指南说，加完配置插件后跑 <code>npx expo prebuild --clean</code>。我在另一个 checkout 里跑了，它删掉了随包的图片、我的 Android 网络安全配置、iOS 隐私清单和 <code>Podfile.lock</code>，还把 <code>build.gradle</code> 里一个给测试工具做的修复连同 <code>Info.plist</code> 里的几百行一起回退了。不加 <code>--clean</code>，Expo 照样把两个目录清空。</p>
<p>这不怪 Patch。maru 一开始是托管的 Expo 应用，后来长出了原生定制，所以 <code>ios/</code> 和 <code>android/</code> 是提交进仓库的，EAS 拿它们原样构建。但长这样的应用多得很，指南当时没有提醒。现在有了：Expo 迁移指南分成两条路径，生成原生工程的走 prebuild，已提交原生工程的则列出需要手动应用的具体改动。</p>
<p>插件实际改的东西，其实少到可以手动完成。iOS 上，<code>AppDelegate.swift</code> 里一个 import 加一行：</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">return</span> <span class="n">CodemagicPatch</span><span class="p">.</span><span class="n">bundleURL</span><span class="p">()</span> <span class="p">??</span> <span class="n">Bundle</span><span class="p">.</span><span class="n">main</span><span class="p">.</span><span class="n">url</span><span class="p">(</span><span class="n">forResource</span><span class="p">:</span> <span class="s">&#34;main&#34;</span><span class="p">,</span> <span class="n">withExtension</span><span class="p">:</span> <span class="s">&#34;jsbundle&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Android 上，<code>MainApplication.kt</code> 里一个 import 加一个参数（这是 React Native 0.82+ 的写法；更早的版本是改写 <code>getJSBundleFile()</code>）：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="nc">ExpoReactHostFactory</span><span class="p">.</span><span class="n">getDefaultReactHost</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="n">jsBundleFilePath</span> <span class="p">=</span> <span class="nc">CodemagicPatch</span><span class="p">.</span><span class="n">getJSBundleFile</span><span class="p">(</span><span class="n">applicationContext</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// ...</span></span></span></code></pre></div></div>
<p>再加上每个平台三个配置值，以及删掉旧的 <code>expo-updates</code> 设置：两个工程加起来新增 13 行、删除 20 行。</p>
<p>插件会在 prebuild 时把那些配置值写成字面量，对于提交了原生工程的项目，这意味着每次构建都只能有一个写死的 deployment。我想让 preview 构建走 Staging、商店构建走 Production，用的还是同一个工程，所以这些值改成从构建环境来。<code>Info.plist</code> 里，Xcode 会展开构建设置：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-xml" data-lang="xml"><span class="line"><span class="cl"><span class="nt">&lt;key&gt;</span>CodemagicPatchDeploymentKey<span class="nt">&lt;/key&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;string&gt;</span>$(PATCH_IOS_DEPLOYMENT_KEY)<span class="nt">&lt;/string&gt;</span></span></span></code></pre></div></div>
<p><code>android/app/build.gradle</code> 里：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="cl"><span class="kt">def</span> <span class="n">patchEnv</span> <span class="o">=</span> <span class="o">{</span> <span class="n">name</span> <span class="o">-&gt;</span> <span class="n">System</span><span class="o">.</span><span class="na">getenv</span><span class="o">(</span><span class="n">name</span><span class="o">)</span> <span class="o">?:</span> <span class="o">(</span><span class="n">findProperty</span><span class="o">(</span><span class="n">name</span><span class="o">)</span> <span class="o">?:</span> <span class="s2">&#34;&#34;</span><span class="o">)</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="n">resValue</span> <span class="s2">&#34;string&#34;</span><span class="o">,</span> <span class="s2">&#34;CodemagicPatchDeploymentKey&#34;</span><span class="o">,</span> <span class="n">patchEnv</span><span class="o">(</span><span class="s2">&#34;PATCH_ANDROID_DEPLOYMENT_KEY&#34;</span><span class="o">)</span></span></span></code></pre></div></div>
<p>这些 key 于是和 <code>EXPO_PUBLIC_</code> 那些值一起放在 <code>eas.json</code> 的各个构建 profile 里。值为空时 SDK 会安静地什么都不做，本地构建没关系，商店构建就是灾难，所以 <code>app.config.js</code> 现在会在 preview 或 production 的 EAS 构建缺了这些值、或者还指向 localhost 时直接抛错。</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%80%e6%ac%a1%e6%9b%b4%e6%96%b0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>第一个 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">./scripts/publish-update.sh ios --notes <span class="s2">&#34;Onboarding headline over the air&#34;</span></span></span></code></pre></div></div>
<p>耗时 30 秒，大部分是 Metro 和 Hermes。上传完成 0.6 秒后，服务器的 worker 就发布了清单。这个更新是一个 7.3 MB 的压缩 tarball，而我旧的导出流程产出的是 11 MB 的 Hermes bundle 加资源。</p>
<p>这里有一件事要做对：JavaScript bundle 在构建时会把 <code>EXPO_PUBLIC_</code> 那些值内联进去，所以一次更新必须用和它要落上去的那个二进制相同的值来构建，否则你发出去的 JavaScript 会连到错误的后端。我的脚本从构建那个二进制的同一个 <code>eas.json</code> profile 里加载它们。Patch 的 CI 文档现在也把这一点写清楚了，还说明了 <code>release-react</code> 本身不会读取 EAS profile。</p>
<p>然后我重新启动应用。设备日志显示整个更新检查就是对对象存储的两次请求，<code>meta.json</code> 和 <code>2.5.1/manifest.json</code>。启动三秒后 SDK 写下了一条 <code>Downloaded</code> 事件，把更新暂存好，应用弹出了重启提示。</p>
<figure><div class="phone-frame" style="aspect-ratio: 900 / 1840; --screen-radius: 15.7% / 7.2%">
    <video
      class="phone-frame__screen"
      style="left: 5.3333%; top: 2.5%; width: 89.3333%; height: 95%"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-update-poster.jpg" style="background:url('data:image/webp;base64,UklGRoYBAABXRUJQVlA4IHoBAAAwCgCdASogAEYAPw1oq0omIqIWagaoYxDE9IBo3u6bOACEWXWx1HDG/AwrSOYwsJmgoDuewmmxiJfqdlf28aG4OgV3Vy0nk3lgu8Wi25BY3FxW+lY1wMNQ5gAA/uqWQwe/E/6WeZpIf2UpLel/fKpwqlcmjLMeA7F4ReOblq5eH4qIOx1w7ZF3v0mxWMH8e4hZoalFig747vzom14jgdSClOC5vthxNjGbdjdmv0JuF4Bd3uw2FZbc3Dlojmgl1fUvZqyymqEqzOWLtImgLAZjJhWZgigFmKgGd+GQ0SIKjnJHHdXOZrb/cZMTM1wrOB56DkWBsUxEsixjmVu+Vb3yqJNYglAA21u7aH9XnZ18oNyTlPWugRrsPfZ+/F1gGsmkViDzBGO0f1wXrNBJimlGcHkGkCt7LH9QNjPcy1A9c2dBEltQFPx6XWi1nm9wND9YTEPiTwnjvIduwaNn9YfPJS9b+I1LMURaw77It/AhHjT2gmi4ar24AAA=') center/cover no-repeat"
      width="720"
      height="1566"
      aria-label="应用以随包 bundle 重新启动，提示有更新，然后重启进入 release v1">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-update.mp4" type="video/mp4">
    </video>
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/iphone-17-frame_hu_492bde446de4e1d1.webp" width="900" height="1840" alt="" aria-hidden="true" decoding="async">
  </div><figcaption>重新启动、sync() 返回 update-installed 时应用自己的提示，然后是通过 OTA 送达的 release v1。</figcaption>
</figure>


<h2 class="relative group">bug 是我的
    <div id="bug-是我的" 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="#bug-%e6%98%af%e6%88%91%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>然后我看了看面板。一台模拟器装了 release v1，面板却显示两次成功安装、两个活跃用户。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAADwAwCdASogABQAPw1wqUqmIyGYDAQAYxDE9IABUVMd2rf8uKbf68owAP7WGYB/h1ia3xpTYkbuTZlMczaiJLdSTP+17jDH1qfCOJ+8A+IA+QScXzRlnhc+zceAAA==') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_4dd1f5d2ff813ace.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_4dd1f5d2ff813ace.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_13e5d2d984250bf3.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="Staging deployment 显示一台设备对应两个活跃用户和两次成功"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_b455b1f0b69680b5.webp"></span></div>
  <figcaption>一台模拟器，被数了两次。</figcaption>
</figure>

<p>SDK 把自己的状态和待发送的指标事件以文件形式存在应用容器里，追起来很方便。里面有两条 <code>Success</code> 事件，时间戳完全相同，id 不同。我的 hook 在挂载时用 <code>notifyAppReady()</code> 确认当前 bundle，紧接着就调用 <code>sync()</code>，而 <code>sync()</code> 自己也会调用 <code>notifyAppReady()</code>。两次调用重叠了，各自都看到这个 release 处于待确认状态，各自都记了事件。服务器按事件 id 去重，但这两条 id 不同，所以都算数了，而且这些重复是永久的。</p>
<p>我这边的修复是每次启动只做一次共享的确认，其他所有东西都等它。SDK 自己也可以防一下重叠调用，这一点我提了建议。现在它确实这么做了：重叠的 <code>notifyAppReady()</code> 调用，包括 <code>sync()</code> 内部那一次，会共用同一次确认，所以每次安装只记一次。SDK 参考文档现在也写明了 <code>sync()</code> 会自己确认就绪，用它的应用不需要再单独调用 <code>notifyAppReady()</code>。</p>
<p>舒服的地方在于发修复。这是纯 JavaScript 的改动，所以作为 release v2 发了出去，而且因为可以给 v1 上的设备发二进制差分，v2 下载下来是一个 543 KB 的 patch，而不是 7.3 MB 的 bundle，大约是原来的 7%。重启之后：一次下载、一次安装、一次成功、一台活跃设备。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAADwAwCdASogABQAPw1qqksmIqGYDAQAYxDE9IABUVMd2rfRI1O5mmXAAP7WGYB/h1ia3xpTYkbuTZlMczaiJLkoVKAckjyruR4hTL7NmaqeP1qAfIJOLnmwKkXiX44AAAA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_361247d6f35aa5fc.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_361247d6f35aa5fc.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_d07d239ec0e34973.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="发布历史显示 v1 的数字虚高，v2 正确"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_dab633c48fa0be46.webp"></span></div>
  <figcaption>v1 的重复记录留着；v2 数得对。</figcaption>
</figure>


<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%95%85%e6%84%8f%e5%bc%84%e5%9d%8f%e5%ae%83" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>回滚是我最在意的功能，因为旧方案在设备端什么都没有。一个坏更新会让每次启动都崩溃，直到我注意到并把复选框取消勾选。</p>
<p>所以我发了一个 v3，在第一次渲染之前就抛错，也就是它永远没机会调用 <code>notifyAppReady()</code>。应用下载了它，提示重启，然后死回了主屏幕。</p>
<figure><div class="phone-frame" style="aspect-ratio: 900 / 1840; --screen-radius: 15.7% / 7.2%">
    <video
      class="phone-frame__screen"
      style="left: 5.3333%; top: 2.5%; width: 89.3333%; height: 95%"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-crashed-poster.jpg" style="background:url('data:image/webp;base64,UklGRjACAABXRUJQVlA4ICQCAADQDgCdASogAEYAPw1+rU4mJCIhKbqpIMYhieIAuzPq4M7sW3lP/kPxexnET5ASwFv8Htr8DNovWXwo2ZQynTi8ui+9q1qzPLfv97/wPQgMDj/+e/pvE9miOHoCzZC5Gv/U0oN8n33d1NMZxA4+qT/uNT35EBYJZa/w3Ts1AUToAP5vfw4j/pjBP6ZaA8LejegsfSPzEPXc0mvX+J1dMRFcTwhgh8BttzpcI4CRjUDkbGoa6UAnxK5kCSatezNL4gQf4nZeHytilXmRtp2c1xohb5/y8HBhWsukQWCAk8rKnNMQYUQJEVbasq2JgftASZSBFtiUkjtFF9K+EYtO0DOtXBWDPeHJf1TqqEHHwl4CQ1zsv9Cx+l8KPR2tUhRR8Ctso49YsumZzdxVCAKQdb9pNx77EfSYM6ALaYnzRt4qTUeUBf3ENt66RmZKq5nGflJ9lEW0oDnWjx6zoAqeJS44t1nUDDIZm1gayu9+nhUWZE+InoYr7YlMmmXX2kklVMVsT0s5xOQqPPlW4UoSUv0NgFVDavqEtwMzPqNuUIjjfwXToJhzDbgUidZqS0gSOrBna2Xj2XcOmf2KfPjtTFgKpl74NNsPw+SkvLP+XIfGRP7f4DZ3SF5r4kITD9QK1Mb4Z3frLXQeA432gyyslKobZUjhHzgpw6Lsv1z4B+zVfYG/nF6OiGZ+SPJQMFRe2LmOB4/oyDsE7meakIUBZvS1zSAAAA==') center/cover no-repeat"
      width="720"
      height="1566"
      aria-label="应用在坏 release 上崩溃后的模拟器主屏幕">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-crashed.mp4" type="video/mp4">
    </video>
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/iphone-17-frame_hu_492bde446de4e1d1.webp" width="900" height="1840" alt="" aria-hidden="true" decoding="async">
  </div><figcaption>v3 启动即崩。</figcaption>
</figure>

<p>下一次启动又跑了 v3，又崩了，这让我很意外，因为文档说在 <code>notifyAppReady()</code> 之前崩溃的 bundle 会被回滚。源码解释了原因：SDK 允许三次未确认的启动，之后才判定为崩溃，因为一次未确认的启动也可能只是 iOS 在任何 JavaScript 跑起来之前就杀掉了一个健康的进程。第四次启动回到了 v2。</p>
<figure><div class="phone-frame" style="aspect-ratio: 900 / 1840; --screen-radius: 15.7% / 7.2%">
    <video
      class="phone-frame__screen"
      style="left: 5.3333%; top: 2.5%; width: 89.3333%; height: 95%"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-rolled-back-poster.jpg" style="background:url('data:image/webp;base64,UklGRsABAABXRUJQVlA4ILQBAABQCgCdASogAEYAPw1mq0umIqGU2q7YYxDE9IBowO6mweBInrUPWk42PSTT65hMmOGGOXZWA/sd5q9BGdjSK2j84FuUL93nyJVQuphNZ6mwXQhv8NVWhs+Q6BIAAP74ukd3WXEH+9Z7Rrx8W8zZkkK5+g1e2Lc8Yi325om3Qy3bb7uYsUutPzhS99cwA1kptzZhyUOs2MW6lH9eP8+SPePVMJGvfSIxSO6tL43kYr+PfLzJw/oJabDsUcKslfz5t3nFCXsiVKRSWBi80g/4Y8CJZiDrEvRejhn6wF7DlE4cfLc64tC8OpWtwMjLNLzfe0pK5MjRdPbq4v5sKsY7T49CjMzJz033ddGNJ1Vmp+9decCnIDCYl2pLqRBhrM5E+D4Cw6TfkPAN/WpX0LXCs9Wr8ACcRRwajxQnbjxLU1tKX+yJyrshjPphgACyyYJKjidt2xw5zVQZcF6XykRe430a0dWMKdcBvnMqq2qBswoqRDNp4NIHmM7r87FSOG3y5ScC9wPZH2OoU+6Am86Nz+s2Tk/J+sj1gZFVQqfqXFWH4AEGF/cO+yLfluiKfZvqAOcwbgAA') center/cover no-repeat"
      width="720"
      height="1566"
      aria-label="自动回滚后，应用再次运行在 v2 上">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-rolled-back.mp4" type="video/mp4">
    </video>
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/iphone-17-frame_hu_492bde446de4e1d1.webp" width="900" height="1840" alt="" aria-hidden="true" decoding="async">
  </div><figcaption>第四次启动：回滚到 v2，没有任何人碰过服务器。</figcaption>
</figure>

<p>然后设备自己上报了这次失败，类型是 <code>crash_rollback</code>，面板把它记在了 v3 名下。这个设计合理，但有必要把它的含义说白：一个坏 release 会让每个用户崩三次启动才能恢复，而当时文档没有提这个数字。现在提了，而且这个额度可以配置，原生侧用 <code>CodemagicPatchMaxLaunchAttempts</code>，或者用 Expo 插件的 <code>maxLaunchAttempts</code>。这正是先发 Staging、再小比例发 Production 的理由。</p>
<p>替其他所有人叫停一个坏 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">cmpatch release rollback --app maru-ios --deployment Staging</span></span></code></pre></div></div>
<p>它不改写历史。它把上一个 release 重新发布成一个新的（v4，标记为 v2 的回滚），所以崩溃的那个 release 连同它的失败计数一起留在列表里，这是诚实的记录。面板上通过一个对话框做同样的事。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><video
      class="browser-frame__page"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollback-poster.jpg"
      style="background:url('data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAABwBACdASogABQAPw1qp0emIyGYBABjEMT0gGfQEs2nvxX7EOeFbRn3H5Si4AD+1iXJGzIunVk/ewlUVdIUbFiwAGX5QAkFFcZDl2at4PkETvjCXCOVoXFdscz2ut9AAAA=') center/cover no-repeat"
      width="1280"
      height="800"
      aria-label="在面板里回滚 Staging：上一个 release 被重新发布为 v4">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollback.mp4" type="video/mp4">
    </video></div>
  <figcaption>在面板里回滚：v2 以 v4 的身份回来，v3 留在列表里。</figcaption>
</figure>

<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmoAAABXRUJQVlA4IF4AAAAwBACdASogABQAPw1op0emIyGYBABjEMTygAFRZbu1b6Gdq9jYHm/TLAAA/tYZgH+HWJrfGlNiRvwNU+RVn1Uhl5QjE2FNRxQPa3YDnfLX9O0wDNn7xOtAkLPDgAAA') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_6a96ca1588cc6310.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_6a96ca1588cc6310.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_5552e433d6a65a3f.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="回滚后的发布历史，v4 标记为回滚，v3 显示一次失败"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_5066c51b1ba693c2.webp"></span></div>
  <figcaption>v4 是回滚；v3 留着它的失败记录。</figcaption>
</figure>


<h2 class="relative group">Android
    <div id="android" 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="#android" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Android 有两个意外，都不是 Patch 的问题。我给 SDK 加的 R8 keep 规则放在 <code>app.config.js</code> 里，而它只能通过 prebuild 才能到达原生工程，所以我第一个 release 构建等于无意中测试了 SDK 在没有 keep 规则的情况下能不能扛住 R8。能：R8 把它所有的类都重命名了，更新照样下载、安装、回滚，一切正常。另外，刚冷启动的模拟器加上还占着内存的 Gradle，慢到我的第一次测试在 SDK 完成那个早已开始的下载之前 45 秒就放弃了。</p>
<p>Release v1 应用成功，一次成功、一台活跃设备，一个会崩的 v2 在同样的三次启动额度之后回滚了。</p>
<figure>
  <div class="phone-frame" style="aspect-ratio: 900 / 1892; --screen-radius: 9.85% / 4.43%"><span class="phone-frame__screen phone-frame__ph" aria-hidden="true" style="left: 4.889%; top: 2.326%; width: 90.222%; height: 95.349%; background:url('data:image/webp;base64,UklGRsgBAABXRUJQVlA4ILwBAABQCwCdASogAEcAPw1wr06mIqIhJngMAMYhiekAzRAKJZ22yvEtm7/GFRCOt1Z6YnyGNWf+i7i0ewagB55W+rOp9DBjbctVlnDXK6lGDu9FH315DSWJUMBYkPGJY2+xw/B5QDAA/vURG5LVrYKJoO2SXy+UxQiUlovs8pxF48eKxgmVxH4hV0qd23H4fTbOXId4reHVkO/ZtsoHNdCHkv0JWiIqSQvxGntYFegdbOEkel+at+Jvs/b4yghmeIXnBH7M31JdyN1H2WvMPx2TwLZfiUh7Vs/c0xs3ET28MPX4PV32jfA2Qo55LcWZBHZVnPmnDwZLLo2Dv++B0SqBKWqbTWPQKOgzF+6YGKB7vcORmV6iyLh5aW30GEJ6lcPq8nat/61jd9i9CZePrzdLJPqGGJyPCbsjPkfhTd6BEdIQraUcOjRfSxAroG5B9nslpJZA63tGk2R93MKzS48w8pEUJKeCNc9lq7Q91gnOgYXePvVHH49kJkJCf5DnRUSgdBqJS0Dh6nG2R7OcW67l22CzkV09rMSoVJlTLhktX5d3Ximn/zbkXSgGUos1pjcQR73ncJ+50rXoCD05SAA=') center/cover no-repeat"></span>
    <img
      class="phone-frame__screen"
      style="left: 4.889%; top: 2.326%; width: 90.222%; height: 95.349%"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/android-after-v1_hu_a5ee68e09a427bed.webp"
      width="600" height="1333"
      alt="Android 应用显示通过 OTA 送达的大标题"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/android-after-v1_hu_a5ee68e09a427bed.webp">
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/pixel-8-frame_hu_52e6b4e761a590c9.webp" width="900" height="1892" alt="" aria-hidden="true" decoding="async">
  </div>
  <figcaption>Android：回滚后重新回到 v1。</figcaption>
</figure>


<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%a1%ba%e5%88%a9%e8%b7%af%e5%be%84%e4%b9%8b%e5%a4%96" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>更新能到、能回滚是核心，但一个团队天天用的是其他那些功能，所以我一个一个过了一遍。</p>
<p>灰度发布是确定性的，这点我喜欢。<code>md5(deviceId + &quot;-&quot; + releaseLabel)</code> 的前八位十六进制数字对 100 取模，小于灰度百分比的设备就在圈内，所以同一台手机对某个 release 要么一直在、要么一直不在。我算出模拟器在下一个 release 里落在 91 号桶，按 25% 发布，模拟器果然什么都没收到。用 <code>cmpatch release patch</code> 提到 95%，不到一秒，下次启动它就更新了。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><video
      class="browser-frame__page"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollout-poster.jpg"
      style="background:url('data:image/webp;base64,UklGRnIAAABXRUJQVlA4IGYAAAAQBACdASogABQAPw1qqEemIyIYBABjEMT0gGH0Es2nvxX9G/4/1g7tAAD+1iXJGzIunVcHqQBmnStoD1oAHJUjpBT6bKy8GQx+Yo2Ka5cGJlghQl6jWcyOWHRQDYW9x5JJDLafQAA=') center/cover no-repeat"
      width="1280"
      height="800"
      aria-label="在面板里把一个 release 的灰度从 25% 提到 95%">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollout.mp4" type="video/mp4">
    </video></div>
  <figcaption>把部分灰度从 25% 提到 95%，没有重新发布。</figcaption>
</figure>

<p>接着我的下一个 release 失败了，返回 409：&ldquo;deployment has an active rollout below 100 percent&rdquo;。每个 deployment 同时只允许一个部分灰度，所以你得先把金丝雀发完、禁用或者回滚，才能发别的。这合理，也有文档，但一条每次合并都发布的流水线会在金丝雀进行到一半时撞上它。我反馈之后，CLI 在这里会把话说清楚了：如果是一个进行中的部分灰度挡住了你，它会告诉你等也没用，以及怎么把灰度发完或禁用；如果是另一个发布任务还在跑，它会让你用 <code>release inspect --wait</code> 等着。文档现在也有一节专门讲按顺序进行的发布变更。</p>
<p>强制 release 用的是 SDK 的 <code>mandatoryInstallMode</code>。我设的是下次回到前台时应用，应用下载了更新并弹出提示，我无视提示，把应用切到后台再切回来，新版本就直接在那里了。</p>
<p>禁用一个 release 做的不只是停止提供它。我一禁用最新的那个，清单就立刻指向了仍然启用的最新 release，禁用版本上的设备会被推一个回到它的 patch。把某个二进制版本的 release 全部禁用，清单的目标就变成 null，SDK 会把应用退回到它随二进制自带的 bundle。我的更新 hook 起初没有对这种情况弹提示，现在有了。</p>
<p>从 Staging 提升到 Production 不到一秒，而且复用的正是测试过的那个包，同样的哈希和备注，不会重新构建。它还接受一个灰度百分比，所以“提升到 Production 的 10%”就是一条命令。</p>
<p>构建和发布也可以拆成两步。<code>cmpatch bundle</code> 产出一个 <code>.cmpatch</code> 产物，面板的 &ldquo;Bundle upload&rdquo; 选项会在上传前读取它：平台、目标版本、指纹、签名状态、大小和打包器。这适合 CI 构建产物、再由别人决定什么时候发布的场景。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><video
      class="browser-frame__page"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-upload-poster.jpg"
      style="background:url('data:image/webp;base64,UklGRnwAAABXRUJQVlA4IHAAAAAwBACdASogABQAPw14sE+mI6IhKAgAxiGJ6QAAM7aVPPd3tO2d2B9IEAAA/tYlyRsyLp1nLc5Hh5nniWZ+UIxN4AAKrWhTSPPLnDPJV90MvTw1QAmDXcvY3C3ghY+Uo+Wdxkjji3HNEgzIPqpIwAAA') center/cover no-repeat"
      width="1280"
      height="800"
      aria-label="在面板里上传 .cmpatch 产物：先读出平台、版本、指纹和大小，然后警告指纹不一致">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-upload.mp4" type="video/mp4">
    </video></div>
  <figcaption>bundle 上传：面板先读产物，然后指纹保护介入。</figcaption>
</figure>

<p>也是在这里，我真正撞上了指纹保护。修好过期的 widget plist 之后，服务器以 409 拒绝了我的下一个 release，因为原生工程的指纹和它为 2.5.1 记录的那个对不上了。Android 上，改一下 <code>app.config.js</code> 里的注释就够触发了，因为 Expo 把应用配置也算进指纹。面板把同样的检查显示成一条警告，带两个指纹和一个 &ldquo;Upload anyway&rdquo; 按钮。这两次已安装的二进制上都没有任何原生的东西变过，所以覆盖是对的。但在真实使用里，诚实的答案几乎总是升版本号、重新做一次商店构建，我的发布脚本现在遇到这种情况就会这么说。</p>
<p>代码签名用的是 RS256：CLI 用 RSA 私钥给包的哈希签名，SDK 用编译进二进制的公钥校验。公钥以一行 base64 的形式放进去，正好契合我走构建设置的做法，两个平台我都测了。应用里带了公钥之后，未签名的 release 在下载前就被拒了。给应用打开 &ldquo;require code signing&rdquo; 后，服务器拒绝了未签名的发布，错误信息里写明了修法并链接到指南。签名的 release 正常安装，SDK 记录签名为已验证。当我禁用签名的那个 release、服务器退回到一个更早的未签名 release 时，SDK 也把它拒了。要注意的是，公钥必须在你发出的第一个二进制里就带上，因为更新没法把它加进去。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRnAAAABXRUJQVlA4IGQAAABwBACdASogAB0APw14sE+mI6IhKAgAxiGJ6QAAM+3OuAEp3oUrP1GNW2QgAAD+1hmAf4eqLtdnP/9v1g2ZTHRXrrujcZyW6apQhqigB3MdqokDYA7HESmkf+QYJtIlWU+VYAAA') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_23c3bd38e0daa226.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_23c3bd38e0daa226.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_2173e76e5a1b2cec.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="1264"
      alt="Release 详情显示签名为 Signed, sha256"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_f18ca5bb6112ee2a.webp"></span></div>
  <figcaption>一个签过名的 release。</figcaption>
</figure>

<p>团队和 CI 也都覆盖到了。成员会拿到四种团队级角色之一（owner、admin、developer、viewer），邀请会一直等到对方第一次登录。个人访问 token 在空的 home 目录下也能用，通过 <code>--token</code> 或环境变量传入都行，token 列表会显示每一个上次使用的时间。我还写了一个手动触发的 CI workflow，在 Linux runner 上跑同一个发布脚本，签名密钥来自 secret，不过还没跑过，因为它需要托管的服务器。</p>
<p>有一个工具当时我会跳过，<code>cmpatch debug ios</code>。它按 &ldquo;OTA&rdquo; 不区分大小写过滤系统日志流，而这会匹配到 &ldquo;Rotation&rdquo;，于是一次二十秒的应用启动产出了满屏的 SpringBoard 消息，应用自己的一条都没有。这已经修好了：它现在只显示带 SDK 标记的行。SDK 本身日志不多，主要是错误和开发构建下的警告，所以输出会很安静。你可能看到的那条 <code>getpwuid_r</code> 警告来自 <code>xcrun simctl</code>，不是 Patch。</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%88%b0%e5%ba%95%e6%9c%89%e5%a4%9a%e9%9a%be" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Codemagic 想听我的看法，那就尽量说得直白。</p>
<p>试用很容易。两条命令、几分钟，你就有了真正的服务器、一个带演示数据的面板，和一个可以对准自己应用的 CLI。我会说这是头一个小时里最好的部分。</p>
<p>集成 SDK 是中等工作量，而且难点大多在你自己的项目里，不在 Patch。在一个原生目录是生成出来的新 Expo 应用上，确实就是插件加几行替换 <code>expo-updates</code> 调用的代码。在 maru 这样的应用上，原生工程已提交、有 widget 扩展、有 Web 构建、有 R8 和构建 profile，活儿都在边边角角。我得手动应用原生改动来避开 prebuild，把配置改成构建时注入好让 Staging 和 Production 构建能不一样，还要把 SDK 挡在 Web 之外。我还得保证更新里的 JavaScript 和二进制用同样的环境构建。搞明白之后没有一件是难的。我做的时候，这些大多不在文档里，不过已提交原生工程的路径和 bundle 构建时的环境，现在都写进去了。</p>
<p>日常使用很好。发布是一条命令，半分钟左右。灰度、提升、禁用和回滚，从 CLI 或面板操作都不到一秒，错误信息会说出了什么问题、该怎么办。服务器的那些保护（指纹检查、同时只能一个金丝雀、签名）严格得不止一次拦住我做错事。</p>
<p>在生产环境跑它是更大的承诺。它是一套带 Postgres 和对象存储的 Docker Compose 栈，你得托管、备份、升级，还需要两个域名和一个 OAuth 应用。对已经在跑服务器的团队，这是一个安静的下午。对那些正因为不想碰服务器才用 EAS 的团队，这才是切换的真实成本，比 SDK 那部分工作更大。</p>
<p>按挂钟时间算，我 12:29 装好 CLI，13:34 第一个更新在模拟器上应用成功，其中半小时是一次 Xcode 构建卡住了，原因和 Patch 无关。这篇文章里的所有东西，两个平台，都在下午过半之前做完了，托管服务器不算在内。</p>
<p>最耗我时间的几件事，按顺序：<code>expo prebuild --clean</code> 抹掉了我提交的原生工程，然后是研究怎么手动接好一切；iOS 版本检测的 bug，它会把更新发给一个没人在跑的版本；重复计数的安装，虽然是我自己造成的，但任何人都很容易重蹈，因为 <code>sync()</code> 自己会调 <code>notifyAppReady()</code>；还有没写进文档的三次启动崩溃额度，在一次测试进行到一半时把我吓了一跳。再往后是一些本身正确、但需要规划的事：无害的原生相关改动触发指纹保护，每个 deployment 同时只能一个金丝雀，以及不能重叠的生命周期任务，一旦脚本要连着做几件事，这就很要紧。剩下的都是小事：首次运行时静默的搭建过程、一条吵闹的调试命令，还有几个当时和代码对不上的文档示例。</p>
<p>这些我都连同细节一起写给了 Codemagic，到 9 月 23 日，大部分已经修复：版本检测、重叠的确认、prebuild 的提醒、崩溃额度、静默的搭建过程、调试命令、<code>InstallMode</code> 的示例，以及发布任务或金丝雀挡路时给出的报错。还没解决的都是小事：面板显示 API 在 <code>:8080</code>，而其他地方都说 <code>:3000</code>；SDK 在 Web 和 Jest 里一 import 就抛错；以及文档里的几处空白。</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="#%e4%b8%8a%e7%94%9f%e4%ba%a7" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>上面的一切都是在评估栈上跑的。生产就是同一个应用、同一套脚本对准一台真实的服务器，下面是 maru 的计划。</p>
<!-- 编辑备注：本节是操作步骤。真正切换之后，替换成实际发生的情况（主机、耗时、出了什么问题）。 -->
<p>官方支持的服务器部署方式是 Docker Compose 安装：一台开放 80 和 443 端口的 Linux 机器、两个域名（一个给 API 和面板，一个给下载），以及一个用于登录的 GitHub OAuth 应用，没有它服务器不会启动。安装器负责剩下的事，包括 TLS：</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">scripts/selfhost/install.sh <span class="se">\
</span></span></span><span class="line"><span class="cl">  --api-domain updates.example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --storage-domain storage.updates.example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --email you@example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --github-oauth-client-id &lt;id&gt; --github-oauth-client-secret &lt;secret&gt;</span></span></code></pre></div></div>
<p>它会写一个 <code>.env.selfhost</code>，里面是数据库和存储的密钥，应该放进你的密码管理器。</p>
<p>不过你不一定要用自带的数据库和存储，这正是它能嵌进我这种栈的原因。maru 的 API 已经跑在 Fly.io 上，文件放在 Tigris，Patch 在两者上都能工作。Compose 文件底下，服务器就是一个以 <code>MODE=all</code>（API 和 release worker 一起）运行的容器，需要一个 Postgres 数据库、S3 兼容存储，和一个供文件访问的公开 HTTPS URL。也就是一个用仓库的 Dockerfile 构建的 Fly 应用（目前还没有发布的镜像，所以由 <code>fly deploy</code> 来构建）、一个 Fly Postgres 数据库，和一个 Tigris 桶：</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">MODE</span><span class="o">=</span>all
</span></span><span class="line"><span class="cl"><span class="nv">DATABASE_URL</span><span class="o">=</span>postgresql://…            <span class="c1"># Fly Postgres</span>
</span></span><span class="line"><span class="cl"><span class="nv">STORAGE_ADAPTER</span><span class="o">=</span>s3
</span></span><span class="line"><span class="cl"><span class="nv">S3_ENDPOINT</span><span class="o">=</span>https://fly.storage.tigris.dev
</span></span><span class="line"><span class="cl"><span class="nv">S3_REGION</span><span class="o">=</span>auto
</span></span><span class="line"><span class="cl"><span class="nv">S3_BUCKET</span><span class="o">=</span>myapp-patch
</span></span><span class="line"><span class="cl"><span class="nv">S3_FORCE_PATH_STYLE</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="nv">PUBLIC_BASE_URL</span><span class="o">=</span>https://myapp-patch.fly.storage.tigris.dev/codemagic-patch</span></span></code></pre></div></div>
<p>两点提醒。在 Compose 之外运行，仓库里的文档把它定位为参考材料，而不是这个首个开源版本里受支持的路径，所以在 Fly 上，升级和备份得靠你自己。我也还没这样部署过；这是计划，不是报告。另外，如果你读过我写 <a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/" >Fly 缩容到零</a>的那篇，记得至少留一台机器在跑。手机做更新检查时从不等 API，因为清单直接来自存储，但不然的话，每一次指标上传和每一个 release 任务都会从一次冷启动开始。</p>
<p>Tigris 是我最期待的部分。maru 的文件本来就在那儿，而且它在每次应用启动都要访问的那个 URL 前面放了一层 CDN。这么做的话要留意清单的缓存：用普通的 base-URL 分发适配器时，Patch 会把清单以 <code>no-cache</code> 提供，因为它没法清 CDN，而一份按自己 TTL 缓存的清单会一直提供你刚刚回滚掉的那个 release。配置参考现在在 <code>MANIFEST_CACHE_CONTROL</code> 下写明了这一点，文档站也讲到了 S3 兼容存储。</p>
<p>在托管服务器上，重新建一遍应用，把新的 key 放进 <code>eas.json</code> 的 <code>preview</code> 和 <code>production</code> profile（分别是 Staging 的 key 和 Production 的 key），再创建一个用于发布的 token：</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">cmpatch token create --name ci</span></span></code></pre></div></div>
<p>想要代码签名的话，现在就定：生成生产环境的密钥对，私钥作为 CI secret 保管，公钥放进构建 profile，然后给两个应用都打开 &ldquo;require code signing&rdquo;。第一个 Patch 二进制还需要一个新版本号，因为商店里已有的二进制跑的是 <code>expo-updates</code>。先用 preview profile 构建给 TestFlight 和 Play 内部测试，交给任何人之前，先确认 key 真的进了构建出来的 <code>Info.plist</code> 和 Android 资源。这是我本地测试唯一没有覆盖到的一环，因为环境变量是我手动导出的。然后发到 Staging，确认每个测试者一次下载、一次成功，之后才构建商店版本。</p>
<p>生产 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">./scripts/publish-update.sh ios --production --rollout <span class="m">10</span>
</span></span><span class="line"><span class="cl">cmpatch release patch --app maru-ios --deployment Production --label v1 --rollout-percentage <span class="m">100</span></span></span></code></pre></div></div>
<p>旧服务器这期间继续开着。所有还没更新的 2.5.1 副本仍然在向它检查，给它们的热修复也仍然走旧脚本。等日志显示那些请求停了，旧服务器连同它的发布脚本和密钥就可以全部删掉。</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%80%e6%ac%a1%e5%8e%9f%e7%94%9f%e5%8d%87%e7%ba%a7" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><em>2026 年 10 月 16 日补充。</em></p>
<p>从 10 月初开始，maru 的生产更新就交给 Patch 来发了。那之后的第一次原生发布是升级到 Expo SDK 58：React Native 0.88，Android 上还有 Gradle 插件的一个新大版本。原生改动没法 OTA，所以这是一个新的二进制 2.6.0，而我的发布脚本在这个二进制存在之前会拒绝发 2.6.0 的更新。两个平台第一次就都干净地构建成功了。</p>
<p>上面的生产部分说过，交给任何人之前，先确认 key 真的进了构建出来的 <code>Info.plist</code> 和 Android 资源。这次我真的查了，而这一节正是因为这次检查才有的。iOS 构建里四个 Patch 设置都在。Android 构建里一个都没有：</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">bundletool dump resources --bundle<span class="o">=</span>build.aab --values <span class="p">|</span> grep -A1 <span class="s2">&#34;string/Codemagic&#34;</span></span></span></code></pre></div></div>
<p>这条命令对 2.5.11 的构建打印出四个 key，对 2.6.0 什么都没打印。问题不在环境变量：Gradle 的 <code>generateReleaseResValues</code> 四个都写了。它们是之后被 release 构建的资源缩减器（resource shrinker）删掉的，而它在 Android Gradle Plugin 9 里激进了很多。同一个应用的资源从 9,240 个降到了 7,116 个。SDK 用 <code>getIdentifier</code> 按名字读取配置，缩减器看不到这种用法，于是把这四个字符串当成没用的删掉了。缺失的字符串读出来是空的，而空意味着&quot;没有更新&quot;。什么都不会失败。Android 2.6.0 会正常安装、正常运行，却永远收不到更新，唯一的迹象是面板上一台 Android 2.6.0 设备都没有。</p>
<p>修复是一个资源文件，告诉缩减器保留这四个字符串：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-xml" data-lang="xml"><span class="line"><span class="cl"><span class="c">&lt;!-- android/app/src/main/res/raw/codemagic_patch_keep.xml --&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;resources</span> <span class="na">xmlns:tools=</span><span class="s">&#34;http://schemas.android.com/tools&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="na">tools:keep=</span><span class="s">&#34;@string/CodemagicPatch*&#34;</span> <span class="nt">/&gt;</span></span></span></code></pre></div></div>
<p>别把它叫做 <code>keep.xml</code>：React Native 的 bundle 任务已经会为你的 JavaScript 资源生成一个 <code>raw/keep.xml</code>（图片和字体能幸存就是因为它），两个文件会冲突。这条规则更合适的位置是 SDK 本身，因为库的 keep 文件会自动合并进应用；而 release 构建在没有 deployment key 的情况下启动时，只要有一条警告，就能把这种悄无声息的情况变成看得见的。这和前面 iOS 版本检测的 bug 是同一类失败：什么都不报错，更新就是永远不来。</p>
<p>同一次检查还发现了第二个问题，跟 Patch 无关，但出于另一个原因值得知道。Android 的 release 构建一启动就崩溃，而且崩了两次，原因是 Expo 自己模块里的两个不同问题。第一个是 Expo 的 widget 模块没能注册它的原生类，所以应用在任何 JavaScript 运行之前就挂了。修好之后，应用能走到启动 JavaScript 这一步，然后就停住了，因为有三个已经迁到 Expo 新模块系统的模块从来没被注册。这两种情况 Patch 的崩溃回滚都帮不上忙，因为根本没有可以回滚的更新。它保护你不受坏更新的影响，而不是坏二进制。不管用不用 Patch，原生升级在上商店之前都得在真机或模拟器上装好、打开一次。</p>
<p>同一轮测试还带出了一件小事。<code>sync()</code> 失败时会返回 <code>&quot;error&quot;</code>，除非应用跑在开发模式下，否则不会告诉你原因，所以 release 构建只能在日志里记一句出错了。现在我的 hook 在 sync 失败后会调用一次 <code>checkForUpdate()</code>，把它抛出的错误记下来，我就是这样发现问题不是服务器坏了，而是一个本地模拟器构建的 Patch URL 是空的（<code>unsupported URL</code>）。如果最后一次的错误在 release 构建里也能读到，会有帮助。</p>
<p>我的原生发布检查清单现在多了以前没有的三行：从构建出来的 <code>.ipa</code> 和 <code>.aab</code> 里导出 Patch 设置，把 deployment key 和构建 profile 对一遍，以及在提交前启动 release 构建本身，而不是 debug 构建。</p>

<h2 class="relative group">想对正在付 Expo 账单的人说的话
    <div id="想对正在付-expo-账单的人说的话" 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%83%b3%e5%af%b9%e6%ad%a3%e5%9c%a8%e4%bb%98-expo-%e8%b4%a6%e5%8d%95%e7%9a%84%e4%ba%ba%e8%af%b4%e7%9a%84%e8%af%9d" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>如果你付费用 EAS Update 是因为搭服务器看起来太费事，Patch 是一个实在的答案：你确实要跑一台服务器，但那是一套 Compose 栈和一个安装器，不是你要维护的代码。和我自己搭的那套相比，你会得到灰度发布、每个 release 的安装和失败数字、二进制 patch、指纹保护、代码签名、设备端的崩溃回滚，和一个回滚按钮。这些我一个都没做过。</p>
<p>代价是：在任何人收到 Patch 更新之前先要发一个新二进制，因为 SDK 得进到应用里；一台要持续升级和备份的服务器；release 坏得够彻底时每个用户三次崩溃启动（默认值）；以及原生工程已提交的话，一些手工活。它也还年轻。这是 0.3.0 版本，我碰到了一个版本检测 bug、一条吵闹的调试命令和文档里的几处空白，还自己造了一个 SDK 本可以防住的 bug。版本检测和调试命令后来都修好了，文档的空白大多补上了，SDK 现在也能防住我犯的那个错。这些都没有挡住迁移，而且全都看得见，在 CLI 里、在面板里、或者在设备上 SDK 自己的文件里，这一点我的旧方案可说不上。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>React Native Web 的 SEO：Cloudflare 给 Googlebot 发了一个空页面</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-cloudflare-crawlers-cache/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-cloudflare-crawlers-cache/</guid>
      <pubDate>Wed, 09 Sep 2026 04:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>SEO</category>
      <category>Django</category>
      <category>Caching</category>
      <category>Crawlers</category>
      <category>Naver</category>
      <category>React Native</category>
      <description>我怎么用动态渲染让 React Native Web 单页应用被 Google 和 Naver 爬虫看见，以及那条让 Googlebot 拿到空 SPA 外壳的 Cloudflare 缓存规则。</description>
      <content:encoded><![CDATA[<p>用 React 或 React Native Web 做的单页应用，Naver 的爬虫（Yeti）和 Bing 都不会执行它的 JavaScript，Google 也只是有时会。解决办法是动态渲染：按 User-Agent 识别爬虫，给它返回服务端渲染的 HTML，内容和应用本来要画出来的一样。在 Cloudflare 上要避开的坑，是把这个 URL 对所有人一起缓存。Cloudflare 对 HTML 不看 <code>Vary</code>，谁的响应先到，后面所有访客拿到的就是谁的，Googlebot 也可能因此拿到空外壳。只缓存爬虫请求，收窄规则之后记得清一次缓存。</p>
<p>我做的这个集市应用是用 React Native Web 写的单页应用。在 JavaScript 跑起来之前，每一个商品页都只是一个 script 标签加一个空 div。这对人没问题，对搜索却是致命的：Google 有时能执行那段 JS，Bing 不会，而对一个商品几乎全是韩语的站点最要紧的 Naver 爬虫，肯定不会。</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="#%e5%8a%a8%e6%80%81%e6%b8%b2%e6%9f%93%e4%b8%8d%e6%98%af%e4%bc%aa%e8%a3%85" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>办法很老，而且 Google 认可：识别爬虫，给它返回一份服务端渲染的 HTML，内容和 SPA 本该画出来的一样。不是不同的内容，是同样的内容，只是更早。</p>
<p>于是像 <code>/listings/view?id=...</code> 这样的商品 URL 分叉了。浏览器拿到 SPA 外壳。爬虫拿到一份真正的文档：一个标题，商品的关键信息，每张照片一张带 alt 的图，一段正文摘录，以及通往几个相关商品的链接，好让这页不至于成为孤儿。</p>
<p>最后那点比我预想的重要得多。在这些内链存在之前，大约一千个商品页没有任何东西指向它们，Search Console 里能看到抓取一路沉到每天 7 到 11 次请求。找不到你页面的爬虫，不会在乎那些页面有多好。</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%99%b7%e9%98%b1%e5%8e%82%e5%95%86%e7%9a%84%e5%90%8d%e5%ad%97%e4%b8%8d%e6%98%af%e5%ae%83%e5%ae%b6%e7%88%ac%e8%99%ab%e7%9a%84%e5%90%8d%e5%ad%97" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>爬虫识别是 user-agent 匹配，问题就出在这里。</p>
<p>韩国平台用的爬虫名字和公司名一点都不像。Naver 的爬虫叫 <strong>Yeti</strong>，Daum 的叫 <strong>Daumoa</strong>，Kakao 的链接抓取器叫 <strong>kakaotalk-scrap</strong>。三个都不是厂商名。</p>
<p>与此同时，同样这几家还发布带内嵌浏览器的 App，那些浏览器发送的是真实浏览器 UA，后面缀上 App 的名字。KakaoTalk 的内置浏览器说自己是 <code>KAKAOTALK/10.5.1</code>，Kakao 的抓取器说自己是 <code>kakaotalk-scrap/1.0</code>。</p>
<p>用 <code>kakao</code> 去匹配，两个都会中。我当初正是这么干的，结果每一个打开链接的 KakaoTalk、Naver App、Daum App 用户，拿到的都是爬虫页：没有 JavaScript，没有导航，没有登录。一份挑不出毛病的文档，对人却毫无用处。</p>
<p>修复靠的是判断顺序，这一点值得明确写成规则：</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="kn">import</span> <span class="nn">re</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 内嵌浏览器会在普通浏览器 UA 后面附上应用名。</span>
</span></span><span class="line"><span class="cl"><span class="n">INAPP_BROWSER_RE</span> <span class="o">=</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;kakaotalk/|naver\(inapp|daumapps&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 认机器人名，不认厂商名：Naver 用 Yeti，Daum 用 Daumoa，</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Kakao 的链接预览抓取器用 kakaotalk-scrap。</span>
</span></span><span class="line"><span class="cl"><span class="n">CRAWLER_RE</span> <span class="o">=</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;googlebot|bingbot|yeti/|daumoa|kakaotalk-scrap&#34;</span><span class="p">,</span> <span class="n">re</span><span class="o">.</span><span class="n">I</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">is_crawler</span><span class="p">(</span><span class="n">user_agent</span><span class="p">:</span> <span class="nb">str</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">bool</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="k">if</span> <span class="ow">not</span> <span class="n">user_agent</span> <span class="ow">or</span> <span class="n">INAPP_BROWSER_RE</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">user_agent</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="kc">False</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">bool</span><span class="p">(</span><span class="n">CRAWLER_RE</span><span class="o">.</span><span class="n">search</span><span class="p">(</span><span class="n">user_agent</span><span class="p">))</span></span></span></code></pre></div></div>
<p>内嵌浏览器先判断，而且它赢。并且每一个爬虫匹配词都必须指向一个<strong>机器人</strong>，而不是一个厂商。</p>

<h2 class="relative group">然后我在前面放了 Cloudflare
    <div id="然后我在前面放了-cloudflare" 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%84%b6%e5%90%8e%e6%88%91%e5%9c%a8%e5%89%8d%e9%9d%a2%e6%94%be%e4%ba%86-cloudflare" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>源站是悉尼的一台共享 CPU 机器，能撑住每秒二三十个请求。Googlebot 要走大约 995 个 URL，而它们全都打到了悉尼，因为每一个 URL 的 <code>cf-cache-status</code> 都是 <code>DYNAMIC</code>：<strong>除非有规则明说，Cloudflare 不缓存 HTML。</strong> 整个站点大约 16% 的内容被缓存，而真正要紧的那部分一个也不在里面。</p>
<p>那就给爬虫页加一条缓存规则。很简单。除了一点：一个商品 URL 并不只有一个响应体，它有三个。</p>
<ul>
<li>要 HTML 的爬虫拿到服务端渲染页。</li>
<li>人拿到 SPA 外壳。</li>
<li>发送 <code>Accept: text/markdown</code> 的 agent 拿到 Markdown，从数据库拼出来的，因为把一个空的 HTML 外壳转换一遍只会得到一份空文档。</li>
</ul>
<p>源站会发 <code>Vary: Accept</code>，而对 user-agent 做 <code>Vary</code> 本来也没什么指望。然后就是我不知道的这件事：</p>
<blockquote><p><strong>Cloudflare 在 HTML 上忽略 <code>Vary</code>。</strong> 三个响应体共用一个缓存键。</p>
</blockquote><p>哪个先到，哪个就占住这个 URL，对该边缘节点之后的所有访客生效，持续源站 <code>s-maxage</code> 允许的一小时，再加上一天的 <code>stale-while-revalidate</code>。</p>
<p>我在生产环境两个方向都亲眼见到了：</p>
<ul>
<li>一个 Naver App 用户用爬虫页预热了某个商品 URL，随后普通的 iPhone Safari 就在一次 HIT 上拿到了那个页面。一个人盯着一份没有 JavaScript 的文档。</li>
<li>一个人用 SPA 外壳预热了某个 URL，Googlebot 拿到了空外壳。整套 SEO 工作要防的正是这个失败，如今它更快了，还是从边缘发出来的。</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="#%e9%9d%a0%e6%94%b6%e7%aa%84%e6%9d%a5%e4%bf%ae%e5%a4%8d%e7%9a%84%e8%a7%84%e5%88%99" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>你没法让 Cloudflare 在 HTML 上尊重 <code>Vary</code>。你能做的，是确保三个响应体里只有<strong>一个</strong>有资格进缓存：</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">(starts_with(http.request.uri.path, &#34;/listings/&#34;)
</span></span><span class="line"><span class="cl"> and not any(http.request.headers[&#34;accept&#34;][*] contains &#34;text/markdown&#34;)
</span></span><span class="line"><span class="cl"> and (lower(http.user_agent) contains &#34;googlebot&#34; or ...)
</span></span><span class="line"><span class="cl"> and not (lower(http.user_agent) contains &#34;kakaotalk/&#34; or ...))</span></span></code></pre></div></div>
<p>只匹配爬虫 UA，并排除 Markdown 请求。现在人的请求不匹配任何规则，于是读到 <code>DYNAMIC</code> 并像以前一样回源。只有爬虫请求读写缓存，而能进到缓存里的响应体只有爬虫那一个。</p>
<p>边缘 TTL 用 <code>respect_origin</code>，于是有效期仍归渲染这个页面的代码所有，而不是被钉死在控制台里。</p>
<p>一个容易漏掉的运维细节：<strong>收窄规则并不会驱逐已经在缓存里的东西。</strong> 得手动清一次缓存，否则那些刚刚已不再生成的污染条目还会继续被发出去。</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="#%e4%b8%a4%e4%bb%bd%e6%b8%85%e5%8d%95%e4%bb%a5%e5%8f%8a%e5%ae%83%e4%bb%ac%e7%9a%84%e9%94%99%e8%af%af%e4%b8%ba%e4%bd%95%e4%b8%8d%e5%af%b9%e7%a7%b0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这条规则需要一份爬虫 UA 清单，而 Django 里已经有一份。同一份知识在两个系统里存两份通常是坏味道，但这里有意思的是它们漂移时会发生什么，因为并不对称：</p>
<ul>
<li><strong>Cloudflare 里的机器人清单必须始终是 Django 那份的子集。</strong> 一个 Cloudflare 认为是机器人而 Django 不认的匹配词，会把 SPA 外壳缓存到爬虫专用键下，然后发给真正的爬虫。</li>
<li><strong>内嵌浏览器的排除清单必须是 Django 那份的超集。</strong> 排除得太多，代价只是少几次缓存命中。</li>
</ul>
<p>任何一边朝安全方向错，你只是少缓存一点。第一条朝另一个方向错，你就又在给 Googlebot 发空页面了。顺带一提，这种不对称也正是这些清单用 <code>contains</code> 而不是正则的原因：<code>matches</code> 需要 Business 套餐，而对一份允许在已知安全方向上粗糙的清单，<code>contains</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="#%e4%b8%80%e6%9d%a1%e8%ae%a9%e6%9e%84%e5%bb%ba%e5%a4%b1%e8%b4%a5%e8%80%8c%e4%b8%8d%e6%98%af%e8%ae%a9%e9%a1%b5%e9%9d%a2%e5%a4%b1%e8%b4%a5%e7%9a%84%e4%b8%8d%e5%8f%98%e5%bc%8f" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>爬虫页里嵌着有效期七天的预签名图片 URL。页面本身在边缘缓存一小时，外加一天的 stale-while-revalidate。</p>
<p>这两个数字是相关的，一旦缓存寿命超过图片寿命，边缘就会开始发出每张照片都 403 的页面。不会有任何告警，因为 HTML 本身没问题。</p>
<p>所以定义这些数值的代码，在关系被破坏时会拒绝导入：</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">CRAWLER_IMAGE_TTL</span> <span class="o">=</span> <span class="mi">7</span> <span class="o">*</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span>  <span class="c1"># 预签名图片 URL</span>
</span></span><span class="line"><span class="cl"><span class="n">EDGE_S_MAXAGE</span> <span class="o">=</span> <span class="mi">3600</span>
</span></span><span class="line"><span class="cl"><span class="n">EDGE_STALE_WHILE_REVALIDATE</span> <span class="o">=</span> <span class="mi">24</span> <span class="o">*</span> <span class="mi">3600</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 边缘缓存可能比图片 URL 活得更久时，在 import 时就失败。</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">EDGE_S_MAXAGE</span> <span class="o">+</span> <span class="n">EDGE_STALE_WHILE_REVALIDATE</span> <span class="o">&gt;=</span> <span class="n">CRAWLER_IMAGE_TTL</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="k">raise</span> <span class="ne">RuntimeError</span><span class="p">(</span><span class="s2">&#34;edge cache lifetime must stay below the image URL lifetime&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>两个分处不同文件的常量之间的约束，在代码评审里看不见，在进程启动时却一目了然。把它放在进程启动的地方。</p>

<h2 class="relative group">Cloudflare 做的两件、我不得不撤销的事
    <div id="cloudflare-做的两件我不得不撤销的事" 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="#cloudflare-%e5%81%9a%e7%9a%84%e4%b8%a4%e4%bb%b6%e6%88%91%e4%b8%8d%e5%be%97%e4%b8%8d%e6%92%a4%e9%94%80%e7%9a%84%e4%ba%8b" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><strong><code>Vary: Origin</code> 把打包产物挡在了缓存之外。</strong> <code>django-cors-headers</code> 会在它经手的每个响应上打 <code>Vary: Origin</code>，而 Cloudflare 拒绝缓存任何在 <code>Accept-Encoding</code> 之外还有 vary 的响应。这就悄悄把关键路径上最大的那个 1.6 MB JavaScript 包整个排除在边缘之外。修法是把一个中间件放在列表最前面，让它在出站方向最后执行，重写静态资源路径上的 <code>Vary</code>。</p>
<p><strong>边缘压缩比源站压缩更差。</strong> 让 Cloudflare 现场做 Brotli 得到 2,041,742 字节，而源站自己的 gzip 是 1,658,115 字节。为了不用动脑，代价是大了 23%。即时压缩优化的是 CPU，不是压缩率；源站预压缩的资源会赢。</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="#%e4%b8%8d%e7%ad%89%e6%8a%93%e5%8f%96%e5%b0%b1%e5%91%8a%e8%af%89%e6%90%9c%e7%b4%a2%e5%bc%95%e6%93%8e" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>两个协议，加在一起覆盖面也令人失望。</p>
<p><strong>IndexNow</strong> 接受一次 ping，覆盖 Bing、Yandex 和 Seznam。Google 和 Naver 不参与。每次保存商品时，我在后台带着很短的超时发一次 ping，已售出和已关闭的条目直接跳过。网络失败只记一条警告，绝不让触发它的那次保存失败——这是一个尽力而为的旁路通道唯一可以接受的行为。</p>
<p><strong>Google 的 Indexing API</strong> 只接受两种结构化数据类型，其中之一正是我有的 <code>JobPosting</code>。在招的职位发 <code>URL_UPDATED</code>，关闭的发 <code>URL_DELETED</code>。那条删除通知之所以诚实，是因为关闭的条目同时输出了 <code>noindex</code>，代码里也这么写着——好让日后拿掉 noindex 的人知道自己还顺手破坏了什么。</p>
<p>这里有两处让我耗了不少时间。配额是每天 200 个 URL，而批量导入一次商品就会触发几百次，所以导入进来的条目会被跳过，否则一次导入就把当天预算全花光。另外端点是带冒号的 <code>urlNotifications:publish</code>。用斜杠的话，Google 返回一个普通的 HTML 404，看起来和 API 没启用一模一样。</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%93%e6%9e%84%e5%8c%96%e6%95%b0%e6%8d%ae%e6%95%99%e7%bb%99%e6%88%91%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>三条规则，每一条都是被 Search Console 数落出来的：</p>
<ol>
<li><strong>解析不了的就别输出。</strong> 薪资是自由文本。我以前把原始字符串当作 <code>baseSalary.value</code> 输出，那是无效的，还让每一条职位都被标记缺少单位。现在解析不了的薪资干脆不出现。</li>
<li><strong>别为了满足结构化数据的要求而编造字段。</strong> 招聘广告很少带街道地址。人会很想给 <code>PostalAddress</code> 填个看着像样的东西。招聘广告上编造的地址比不完整的地址更糟，所以只放城市和国家。</li>
<li><strong>有效期不是可选项。</strong> Google 会丢掉没有 <code>validThrough</code> 的 <code>JobPosting</code>，所以要推导一个，并加上下限，免得一条旧条目声称自己昨天就过期了。</li>
</ol>
<p>还有关于站点地图的第四条：<strong>别给所有条目都声称 <code>changefreq: daily</code>。</strong> 要求 Googlebot 每天重新抓取一千个条目，会被读成一台过载的主机，而它的回应是降低整站的抓取速率。从这东西实际上多久变一次来推导。</p>
<p>站点地图还有一道质量下限：标题加正文合计四十个字符。Search Console 曾把大约一千条报成&quot;已抓取 – 尚未编入索引&quot;，其中某个二手页面的全部独有内容，是一个单词的标题加一个电话号码。四十这个数字是故意定低的，因为 484 条里有 471 条是韩语，而韩语单位字符承载的信息更多；按英语调的阈值会砍掉这个市场的一半。内容单薄的条目仍然通过索引页保持可抓取，一旦有人编辑就会自动重新进入站点地图。</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%bc%9a%e4%bb%8e%e4%b8%ad%e5%b8%a6%e8%b5%b0%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li><strong>在一个 URL 上返回不同的响应体，首先是一个缓存键问题。</strong> 弄清楚你的 CDN 用什么做键，并且在你于那个确切的内容类型上验证之前，就假定它忽略 <code>Vary</code>。</li>
<li><strong>当两个系统必须就一份清单达成一致时，先弄清哪个方向的错误是安全的</strong>，然后把它写在两份副本旁边。&ldquo;它们可以漂移，而且只允许朝这个方向漂&rdquo;，胜过假装它们不会漂。</li>
<li><strong>按机器人名字匹配机器人，永远别按厂商名字。</strong> 尤其在英语世界之外，那个厂商同时还做着你用户手里的那个浏览器。</li>
<li><strong>把相距遥远的常量之间的不变式，放在会大声失败的地方。</strong> import 时机是免费的，而且没人能跳过。</li>
</ul>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-cloudflare-crawlers-cache/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Fly.io 上的 Django 自动扩缩容：先压测出一台小机器的极限</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-07-22-my-current-stack/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-07-22-my-current-stack/</guid>
      <pubDate>Wed, 22 Jul 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Django</category>
      <category>Python</category>
      <category>React Native</category>
      <category>Expo</category>
      <category>Fly.io</category>
      <category>PostgreSQL</category>
      <category>WebSockets</category>
      <category>Scaling</category>
      <category>Load Testing</category>
      <category>Performance</category>
      <category>Tech Stack</category>
      <description>我的两个 Django 应用都跑在 Fly.io 的 shared-cpu-1x 机器上：先把一台做快，用压测找到延迟崩掉的并发点，再把自动扩缩容的阈值设在它前面。</description>
      <content:encoded><![CDATA[<p>想在 Fly.io 上便宜地跑 Django，我的做法是：把一台 shared-cpu-1x 小机器尽量做快，压测到延迟崩掉为止，然后让 Fly 的自动扩缩容在这个点之前启动第二台机器。压测用的是按阶梯加压的 Locust（另一个应用用一个按并发档位逐级跑的小 Python 脚本），盯着 p95 延迟和错误率看。流量回落后 Fly 会自动停掉空闲机器，所以平时的成本就是每个应用一台小虚拟机。</p>
<p>我目前以独立开发者的身份运营着两个产品：一个是面向上门报价的手艺人的<strong>现场估价应用</strong>，另一个是带有信息发布和实时消息的<strong>集市兼社区应用</strong>。</p>
<p>产品完全不同，用户完全不同，但技术栈几乎一模一样——而且两者都只跑在 Fly.io 最便宜的档位 <strong>shared-cpu-1x 机器</strong>上。这个约束是刻意为之的。与其按峰值负载来配置服务器，我选择把一台小机器的性能榨到极限，精确测出它在哪里崩溃，然后让 Fly 的自动扩缩容器围绕这个实测极限来增减机器。这篇文章讲的就是让这一切成为可能的优化、阈值背后的压测数据，以及 Fly.io 上扩容和缩容到底是怎么运作的。</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%90%86%e5%bf%b5%e5%85%88%e6%b5%8b%e5%87%86%e4%b8%80%e5%8f%b0%e6%9c%ba%e5%99%a8%e5%86%8d%e5%81%9a%e4%b9%98%e6%b3%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>两个应用的扩缩容策略都是同样的三步：</p>
<ol>
<li>把一台小机器在合理范围内做到尽可能快。</li>
<li>压测到它崩溃为止，记下延迟塌陷时的并发水平。</li>
<li>配置 Fly 的自动扩缩容器，在临界点之前启动第二台机器，并在流量回落时停掉空闲机器。</li>
</ol>
<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="#%e8%ae%a9%e4%b8%80%e5%8f%b0%e5%b0%8f%e6%9c%ba%e5%99%a8%e5%8f%98%e5%bf%ab" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">Granian：API 走 WSGI，WebSocket 走 uvloop ASGI
    <div id="granianapi-走-wsgiwebsocket-走-uvloop-asgi" 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="#granianapi-%e8%b5%b0-wsgiwebsocket-%e8%b5%b0-uvloop-asgi" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>两个后端都是 Django 6 + Django REST Framework。API 由基于 Rust 的服务器 <strong>Granian</strong> 提供服务，但跑的是 <strong>带阻塞线程池的 WSGI</strong>，而不是 ASGI：</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">granian --interface wsgi myapp.wsgi:application <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers <span class="m">1</span> --blocking-threads <span class="m">16</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers-max-rss <span class="m">1400</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers-kill-timeout <span class="m">15</span> --respawn-failed-workers</span></span></code></pre></div></div>
<p>我最初用的是 <code>--interface asgi --loop uvloop</code>，事后看是选错了。这些应用里的每个 HTTP 视图都是同步（sync）的，而 Django 的 ASGI 处理器会以 <code>thread_sensitive</code> 方式跑同步视图——每个 worker（工作进程）一次只跑一个。于是 ASGI 把每个 worker 的并发请求实质压到约 1 个，却还把整套 asyncio/uvloop/Channels 栈白白装进内存。带 <code>--blocking-threads</code> 的 WSGI 则把这些同步视图放进一个真正的线程池：1 个 worker × 16 个线程 = 16 个并发的 I/O 密集型请求（数据库读取、S3 预签名），而常驻内存比异步栈更低。</p>
<p>为什么只用一个 worker？因为这台 VM 是 <code>shared-cpu-1x</code>——只有一个核。第二个 worker 在单核上没法并行跑 Python（GIL + 单核），却要白白多占一整个常驻的 Django 进程；Granian 会直接就此发出警告。一个 worker 配 16 个线程，就是单 vCPU 机器在 I/O 密集型工作上诚实的上限。<code>--workers-max-rss 1400</code> 会在 worker 越过内存上限时重启它，于是失控的 PDF 或图片任务会被回收，而不是把机器一路拖向 OOM。</p>
<p>WebSocket 被拆到了它自己的 Granian 进程里——一个用 uvloop 和 <code>--ws</code> 的 <strong>ASGI</strong> 应用，为长连接跑 1 个 worker。同步 API 完全不需要 ASGI 路径：当某个视图想往 socket 推送时，它会经由 Redis 调用 <code>async_to_sync(channel_layer.group_send)</code>，消息照样会跨进程送到 WebSocket 应用。两台服务器，各自跑自己真正擅长的接口。本地 Docker 也跑同样的 WSGI 线程池，因此测试时我的笔记本约等于一台生产机器。</p>

<h3 class="relative group">热路径交给 Rust
    <div id="热路径交给-rust" 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%83%ad%e8%b7%af%e5%be%84%e4%ba%a4%e7%bb%99-rust" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>只要 Python 热路径上存在 Rust 实现的替代品，我就用它：</p>
<ul>
<li><strong>orjson</strong>（通过 <code>drf-orjson-renderer</code>）——所有 API 响应都经由 Rust 序列化，而不是标准库的 JSON 编码器。</li>
<li><strong>blurhash-rs</strong>——在上传时计算图片占位符。</li>
<li><strong>uuid-utils</strong>——Rust 实现的 UUID 生成。</li>
<li><strong>psycopg 3 (binary)</strong>——C 加速的 Postgres 驱动。</li>
</ul>
<p>这些都不改变架构，只是抬高了一颗 vCPU 在需要第二台机器之前所能承受的上限。</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%b6%88%e7%81%ad%e9%9a%90%e8%97%8f%e7%9a%84%e5%bb%b6%e8%bf%9f%e7%a8%8e" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>两个代码库里最大的一次性能提升不是算法，而是连接复用。在 Granian 的线程池下，每个新线程都会重新构建一个 boto3 的 S3 客户端，而每个返回照片或 PDF 的接口都在支付这笔开销。我写了一个小小的自定义存储类，改为按进程缓存客户端之后：</p>
<blockquote><p>新线程上生成签名 URL 的耗时从 <strong>约 150ms 降到约 1ms</strong>。</p>
</blockquote><p>同样的教训也适用于数据库：Fly 的 Postgres 位于 PgBouncer 之后，所以应用设置 <code>conn_max_age=0</code>，把连接复用交给连接池，并用 5 秒的 <code>connect_timeout</code> 快速失败，避免 worker 挂在失效的 SSL 连接上。</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%8a%8a%e7%bc%93%e5%ad%98%e6%94%be%e5%9c%a8%e7%9c%9f%e6%ad%a3%e7%9a%84%e7%93%b6%e9%a2%88%e5%89%8d%e9%9d%a2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>压测社区市集时发现了一个值得记住的事实：<strong>天花板是 Postgres 连接数，而不是 CPU</strong>。浏览流量绝大多数是匿名的、用户之间完全相同，而在高负载下，这些相同的列表查询会在 CPU 闲置的同时把连接池打满。</p>
<p>解决办法是给匿名列表页加一层 Redis 缓存，用按命名空间的版本计数器做失效——一次写操作只需把一个计数器加一（O(1)，无需管理任何键），该资源的所有缓存页面立即失效。再用一个短 TTL 兜底。让浏览页面直接从 Redis 出去，真正的瓶颈就消失了，小机器可以把连接留给已登录的流量。</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%b8%8d%e8%a6%81%e6%b6%88%e6%81%af%e4%bb%a3%e7%90%86%e7%9a%84%e5%90%8e%e5%8f%b0%e4%bb%bb%e5%8a%a1" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>两个应用都用 <strong>django-tasks-db</strong>——Django 的数据库后端任务框架——而不是 Celery。一个独立的 <code>db_worker</code> 进程轮询数据库来处理任务：图片处理、PDF 生成、机器翻译、提醒推送。没有消息代理，没有需要额外估算容量和监控的服务，API 进程也永远不会被重活阻塞。这个 worker 甚至绕过 PgBouncer 直连 Postgres，因为长时间运行的任务和事务级连接池合不来。</p>

<h3 class="relative group">内存：jemalloc 和交换空间缓冲
    <div id="内存jemalloc-和交换空间缓冲" 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%98jemalloc-%e5%92%8c%e4%ba%a4%e6%8d%a2%e7%a9%ba%e9%97%b4%e7%bc%93%e5%86%b2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>图片和 PDF 处理造成的内存尖峰，是按稳态估算内存时无法预测的。两个便宜的缓解手段：在 Docker 镜像里预加载 <strong>jemalloc</strong> 来减少内存碎片，以及<strong>给每台机器都配交换空间</strong>（API 机器 2GB，WebSocket 机器 1GB），这样尖峰来临时进程会把内存换出到磁盘，而不是被 OOM 杀掉。交换空间不是性能特性——它是一份保险，让我能把内存配置一直保持在小规格。</p>

<h2 class="relative group">Django 压力测试：找到拐点
    <div id="django-压力测试找到拐点" 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="#django-%e5%8e%8b%e5%8a%9b%e6%b5%8b%e8%af%95%e6%89%be%e5%88%b0%e6%8b%90%e7%82%b9" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>阈值只有在被实测过之后才站得住脚。每个项目都有自己的压测工具：</p>
<ul>
<li>估价应用有一个小的 Python 压测脚本，扫描各个并发级别并报告 RPS、p50/p95/p99 和错误率；它跑在预先生成的几百条样例数据上，让列表接口在接近真实的数据量下被查询。</li>
<li>社区市集用 <strong>Locust</strong> 以 30 秒为步长从 50 爬升到 500 用户来寻找饱和点，接口权重按真实的浏览为主的流量设定；另有一套更小的独立测试，以 p95 &lt; 2 秒为阈值，对生产环境从 5 爬升到 50 个虚拟用户。</li>
</ul>
<p>估价应用扫描的代表性数据（1 个 Granian worker × 16 个阻塞线程，开发机上的 Docker）：</p>
<table>
	<thead>
			<tr>
					<th>接口</th>
					<th>并发</th>
					<th>RPS</th>
					<th>p95</th>
					<th>错误率</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>health</td>
					<td>25</td>
					<td>602</td>
					<td>52ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>重型列表</td>
					<td>25</td>
					<td>309</td>
					<td>260ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>轻型列表</td>
					<td>25</td>
					<td>357</td>
					<td>61ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>重型列表</td>
					<td>50</td>
					<td>309</td>
					<td>347ms</td>
					<td>0%</td>
			</tr>
	</tbody>
</table>
<p>而决定其他一切的那个数字，来自在<strong>单台 shared-cpu-1x 机器</strong>上对社区市集的压测。在旧的 ASGI 构建里，这曾是一堵硬墙：同步视图被实质串行化到大约<strong>3 个可用槽位</strong>，于是 p95 在约 5 个并发请求以内保持在 300ms 以下，随后当一切都排在 thread_sensitive 锁后面时，超过约 8–10 个并发就塌陷。把拐点往外推的，正是把 API 迁到 WSGI 线程池这件事——每台机器的真实并发槽位从约 3 个变成了 <strong>16 个</strong>，而这个新的上限就是整个自动扩缩容配置的依据。</p>

<h2 class="relative group">Fly.io 上的扩缩容是怎么运作的
    <div id="flyio-上的扩缩容是怎么运作的" 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="#flyio-%e4%b8%8a%e7%9a%84%e6%89%a9%e7%bc%a9%e5%ae%b9%e6%98%af%e6%80%8e%e4%b9%88%e8%bf%90%e4%bd%9c%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Fly 的模型很简单：你的应用是代理后面的一组相同机器，代理统计每台机器的负载。你只需要声明两个数字：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">concurrency</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s1">&#39;requests&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">soft_limit</span> <span class="p">=</span> <span class="mi">12</span>   <span class="c"># 超过就启动另一台机器</span>
</span></span><span class="line"><span class="cl">  <span class="nx">hard_limit</span> <span class="p">=</span> <span class="mi">24</span>   <span class="c"># 超过就直接卸载负载而不是排队</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;stop&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nx">auto_start_machines</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl"><span class="nx">min_machines_running</span> <span class="p">=</span> <span class="mi">1</span></span></span></code></pre></div></div>
<p>**扩容：**当一台机器上的在途（in-flight）请求超过软上限（soft limit），代理会启动一台停止中的机器，并把新流量路由过去。WSGI 线程池让每台机器有 16 个真实并发槽位，所以软上限设为 12——约为池子的 75%，于是第二台机器会在线程还有余量时启动，而不是等用户已经在承受数秒延迟之后。硬上限 24（池子的 1.5 倍）是熔断器：短促的突发会在线程上排队，超过它，代理宁可卸载负载，也不让一台机器被压垮。（旧的 ASGI 构建只有约 3 个真实槽位，用的是 8/20；正是把池子拓宽，才让这两个数字都能往上调。）</p>
<p>**缩容：**流量回落时，Fly 自动停掉空闲机器，最低降到 <code>min_machines_running = 1</code>。停止的机器不产生计算费用，只收 rootfs 存储费，所以每个应用的基线成本就是字面意义上的一台小虚拟机，而突发容量随时待命。</p>
<p>我原本在这里写着，停止的机器&quot;冷启动不到一秒&quot;。这是错的，而且错得很贵。当我不再假设、真正去测量之后，一次冷启动要 <strong>25 到 33 秒</strong>，落到一台还在启动的机器上的请求，就得把这段时间整个等完。我一开始把 CDN 面板上 22% 的失败率归咎于这些冷启动，当时健康检查一直是绿的。后来才发现，那个数字是 CDN 自己的 Early Hints 探测请求在源站失败造成的，真实客户端一个 504 都没收到。不过冷启动本身确实存在。</p>
<p>解法不在上面的伸缩数字里，那些数字没问题。解法在于机器启动过程中做了什么，以及让 Fly 挂起机器而不是停止它们——那样唤醒就变成大约 3 秒的内存恢复。我单独写了一篇：<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/" >缩容到零，于是每次重启都是一次 30 秒的故障</a> 讲了诊断过程和该改什么。如果你在 Fly 上跑着任何用了 <code>auto_stop_machines</code> 的东西，先读那篇，再来相信这一段。</p>
<p>一个微妙之处：并发类型是 <code>requests</code> 而不是 <code>connections</code>。现代客户端会一直保持 keep-alive 连接，所以按连接数统计时数字在真实负载下几乎不动，自动扩缩容永远不会触发。统计在途请求，才能追踪真实的工作量。</p>

<h3 class="relative group">WebSocket 在另一条轴上扩缩
    <div id="websocket-在另一条轴上扩缩" 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="#websocket-%e5%9c%a8%e5%8f%a6%e4%b8%80%e6%9d%a1%e8%bd%b4%e4%b8%8a%e6%89%a9%e7%bc%a9" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>社区市集把 WebSocket 服务器拆成了<strong>第二个 Fly 应用</strong>，与 API 共享同一个 Docker 镜像。API 应用用 WSGI 跑 1 个 worker × 16 个阻塞线程，按<em>请求</em>计数（软上限 12）；WebSocket 应用在一台 <strong>512MB</strong> 的机器上用 ASGI 配 uvloop 跑 1 个 worker，按<em>连接</em>计数：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">concurrency</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s1">&#39;connections&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">soft_limit</span> <span class="p">=</span> <span class="mi">1500</span>
</span></span><span class="line"><span class="cl">  <span class="nx">hard_limit</span> <span class="p">=</span> <span class="mi">2000</span></span></span></code></pre></div></div>
<p>一条长连接维持起来几乎零成本，却会占用一个槽位好几个小时；而一个 HTTP 请求只占用槽位几毫秒——同一个扩缩容信号无法同时服务两者。拆开之后，每个机群都按对自己诚实的指标扩缩，而空闲的 WebSocket 容量只花 512MB，不是 2GB。在 Channels 这一侧，Redis 层以 <code>capacity: 1500</code>、消息 60 秒过期运行，并每 30 秒发送 socket keepalive——因为 Fly 的 Redis 代理会把 Channels 赖以工作的长时间 BRPOP 连接当作空闲连接掐掉。</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%8a%e8%ae%a1%e7%ae%97%e5%8d%b8%e8%bd%bd%e5%88%b0%e8%be%b9%e7%bc%98%e5%92%8c%e5%ae%a2%e6%88%b7%e7%ab%af" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>最便宜的服务器工作，是服务器根本不用做的工作：</p>
<ul>
<li><strong>静态文件和媒体</strong>放在 Fly 的 S3 兼容对象存储上，私有媒体通过签名 URL 走 CDN 分发——图片的每一个字节都不经过 Django 机器。</li>
<li>**AI 推理跑在用户的手机上。**两个应用都内嵌了一个小型本地 LLM（量化的 4B 模型，约 2.7GB），直接在设备上运行。服务端推理成本为零，而且离线也能用。</li>
<li><strong>离线优先的移动端</strong>（设备上的 SQLite 加带重试的同步队列）意味着应用会把写操作攒批、容忍信号盲区，而不是每敲一个键就向 API 发一次琐碎请求。</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="#%e5%85%b6%e4%bd%99%e7%9a%84%e6%8a%80%e6%9c%af%e6%a0%88%e7%ae%80%e5%8d%95%e8%af%b4" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>认证用托管的身份服务加 WebAuthn 通行密钥（passkey），WebSocket 连接在协议升级完成前就完成认证。生产用 Postgres，开发用 SQLite。后台管理和审计记录用了几个现成的 Django 包。移动端是基于 Expo 的 React Native 和 TypeScript，配 Expo Router 和 Tailwind，OTA 更新由自托管的 Expo Updates 服务器下发，应用内订阅交给一个订阅管理服务。质量关卡：后端 pytest 和 mypy，客户端单元测试和端到端测试。</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%80%bc%e5%be%97%e4%bd%a0%e6%8b%bf%e8%b5%b0%e7%9a%84%e4%b8%9c%e8%a5%bf" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>可迁移的想法不是某个具体的库，而是这个循环本身：把一台便宜机器做到真正的快（Rust 热路径、连接复用、在真正的瓶颈前面加缓存、能卸给 CDN 和客户端的都卸掉）；压测到找出它到底在哪个并发数崩溃；把自动扩缩容的软上限设在那个拐点上，让空闲机器停机，把最小机群保持在 1。两个生产应用、实时功能、AI 助手——而平时的账单，只是这个平台上最便宜的几台虚拟机。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-07-22-my-current-stack/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Zustand vs React Query（TanStack Query）vs AsyncStorage：状态该放在哪里</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-05-05-zustand-vs-react-query/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-05-05-zustand-vs-react-query/</guid>
      <pubDate>Tue, 05 May 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React</category>
      <category>React Native</category>
      <category>Zustand</category>
      <category>React Query</category>
      <category>TanStack Query</category>
      <category>AsyncStorage</category>
      <category>State Management</category>
      <category>Mobile Development</category>
      <category>Web Development</category>
      <description>Zustand 和 React Query 不是二选一：React Query（TanStack Query）缓存服务器状态，Zustand 管客户端状态，AsyncStorage 保存必须扛过重启的数据。我在 React 和 React Native 里怎么在三者之间划分状态，Zustand 和 React Query 怎么一起用，以及三者怎么组合出真正能用的离线模式。</description>
      <content:encoded><![CDATA[<p>Zustand 和 React Query 并不互相竞争，所以 &ldquo;Zustand 还是 React Query&rdquo; 这个问题，真正的答案通常是：两个都要。React Query（现在以 TanStack Query 的名字发布）管的是服务器状态，也就是归服务器所有的数据，由它负责拉取、缓存、去重，并在数据过期时重新拉取。Zustand 管的是客户端状态，只有你的应用自己知道的东西，比如当前打开的是哪个标签页、填了一半的表单里写了什么。大多数应用两个一起用；到了 React Native 里，AsyncStorage 是第三块，负责所有必须扛过重启的数据。这篇文章剩下的部分讲我把这几条线画在哪里（附代码），以及三者怎么合起来，做出一个离线也能用的应用。</p>
<p>我正在做的报价 App 有一条硬性要求：没信号也得能用。报价都是上门时在现场做的，而现场经常是毛坯房或者地下室，信号根本靠不住。正是这条约束，逼着我认真回答一个大多数 React 项目都含糊对付的问题：每种状态到底该放在哪里？</p>
<p>最常见的翻车方式，是把所有东西塞进同一个 store。API 缓存、用户偏好、表单状态、认证令牌，全搅在同一个 Redux 大泥球里。可这些状态的性质和生命周期各不相同，各有各的合适工具。Zustand、React Query、AsyncStorage 每个都恰好把一个问题解决得很好。边界画清楚了，架构基本就自己理顺了。</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%8a%b6%e6%80%81%e5%88%86%e4%b8%89%e7%a7%8d" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>聊库之前，先弄清楚自己到底在管理什么。</p>
<p>客户端状态只存在于应用运行时：UI 开关、选中的标签页、表单输入、弹窗开没开。上游没有谁拥有它，进程一结束它就没了。</p>
<p>服务器状态不一样。它的主人是服务器，应用手里只有一份本地副本。用户资料、商品列表、通知、动态流。你的副本会过期，需要重新拉取，而且此刻别的客户端可能正看着另一个版本。</p>
<p>持久化状态是那些必须扛过重启的数据：认证令牌、引导完成标记、缓存的偏好、离线数据。它存在设备本身上。</p>
<p>大多数应用三种都有。大多数烂摊子，都是拿一个工具硬管三种状态造成的。</p>

<h2 class="relative group">Zustand：管客户端状态
    <div id="zustand管客户端状态" 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="#zustand%e7%ae%a1%e5%ae%a2%e6%88%b7%e7%ab%af%e7%8a%b6%e6%80%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Zustand 是个没什么仪式感的小状态库。没有 provider，没有模板代码，没有一层套一层的 context。建个 store，组件里直接用，要学的基本就这些。</p>
<p>它适合的场景：多个组件共享同一份 UI 状态（侧边栏开关、当前筛选条件、选中的项目）；不来自 API 的应用级状态（主题、语言、启动时读的功能开关）；以及真正的客户端逻辑，比如购物车计算或多步表单向导。凡是需要同步、可预测地更新的，基本都归它。</p>
<p>一个基础的 store：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">create</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;zustand&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">interface</span> <span class="nx">AppState</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">theme</span><span class="o">:</span> <span class="s1">&#39;light&#39;</span> <span class="o">|</span> <span class="s1">&#39;dark&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">sidebarOpen</span>: <span class="kt">boolean</span>
</span></span><span class="line"><span class="cl">  <span class="nx">selectedFilters</span>: <span class="kt">string</span><span class="p">[]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">setTheme</span><span class="o">:</span> <span class="p">(</span><span class="nx">theme</span><span class="o">:</span> <span class="s1">&#39;light&#39;</span> <span class="o">|</span> <span class="s1">&#39;dark&#39;</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">void</span>
</span></span><span class="line"><span class="cl">  <span class="nx">toggleSidebar</span><span class="o">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">void</span>
</span></span><span class="line"><span class="cl">  <span class="nx">setFilters</span><span class="o">:</span> <span class="p">(</span><span class="nx">filters</span>: <span class="kt">string</span><span class="p">[])</span> <span class="o">=&gt;</span> <span class="k">void</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">const</span> <span class="nx">useAppStore</span> <span class="o">=</span> <span class="nx">create</span><span class="p">&lt;</span><span class="nt">AppState</span><span class="p">&gt;((</span><span class="kr">set</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">theme</span><span class="o">:</span> <span class="s1">&#39;light&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">sidebarOpen</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">selectedFilters</span><span class="o">:</span> <span class="p">[],</span>
</span></span><span class="line"><span class="cl">  <span class="nx">setTheme</span><span class="o">:</span> <span class="p">(</span><span class="nx">theme</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="kr">set</span><span class="p">({</span> <span class="nx">theme</span> <span class="p">}),</span>
</span></span><span class="line"><span class="cl">  <span class="nx">toggleSidebar</span><span class="o">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="kr">set</span><span class="p">((</span><span class="nx">state</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">({</span> <span class="nx">sidebarOpen</span><span class="o">:</span> <span class="o">!</span><span class="nx">state</span><span class="p">.</span><span class="nx">sidebarOpen</span> <span class="p">})),</span>
</span></span><span class="line"><span class="cl">  <span class="nx">setFilters</span><span class="o">:</span> <span class="p">(</span><span class="nx">filters</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="kr">set</span><span class="p">({</span> <span class="nx">selectedFilters</span>: <span class="kt">filters</span> <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-tsx" data-lang="tsx"><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">Sidebar() {</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="p">{</span> <span class="nx">sidebarOpen</span><span class="p">,</span> <span class="nx">toggleSidebar</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">useAppStore</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">sidebarOpen</span><span class="p">)</span> <span class="k">return</span> <span class="kc">null</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;</span><span class="nt">div</span> <span class="na">className</span><span class="o">=</span><span class="s">&#34;sidebar&#34;</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">&lt;</span><span class="nt">button</span> <span class="na">onClick</span><span class="o">=</span><span class="p">{</span><span class="nx">toggleSidebar</span><span class="p">}&gt;</span><span class="nx">Close</span><span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span><span class="cm">/* sidebar content */</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</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>

<h3 class="relative group">我不往 Zustand 里放的东西
    <div id="我不往-zustand-里放的东西" 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%8d%e5%be%80-zustand-%e9%87%8c%e6%94%be%e7%9a%84%e4%b8%9c%e8%a5%bf" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>首先是 API 响应。我见过这样的项目：每次 API 调用都写进 Zustand store，组件不查询接口，改从 store 里读。结局就是自己手搓缓存失效、加载状态、错误处理、重新拉取和分页，而这些恰恰是 React Query 白送的。</p>
<p>其次是需要扛过重启的数据。Zustand 的状态在内存里，应用一关就没了。persist 中间件可以搭桥到 AsyncStorage（后面会讲），但那应该是想清楚之后的决定，而不是顺手为之。</p>

<h2 class="relative group">React Query：管服务器状态
    <div id="react-query管服务器状态" 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="#react-query%e7%ae%a1%e6%9c%8d%e5%8a%a1%e5%99%a8%e7%8a%b6%e6%80%81" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>React Query（现在叫 TanStack Query）管理远程数据的整个生命周期：拉取、缓存、同步、更新、垃圾回收。关键的观念转变，是把服务器数据当成一份要保持新鲜的缓存，而不是你拥有的状态。数据的主人是服务器，你只是暂存了一份副本。</p>
<p>只要是 API 来的数据，我一律交给它。多个组件用同一个接口的数据（请求会自动去重）、分页和无限滚动、用户回到应用时需要后台刷新的数据、先改 UI 被服务器拒绝再回滚的乐观更新，全在它的射程之内。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">useQuery</span><span class="p">,</span> <span class="nx">useMutation</span><span class="p">,</span> <span class="nx">useQueryClient</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/react-query&#39;</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">useUser</span><span class="p">(</span><span class="nx">userId</span>: <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">useQuery</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">queryKey</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;user&#39;</span><span class="p">,</span> <span class="nx">userId</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="nx">queryFn</span><span class="o">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">fetch</span><span class="p">(</span><span class="sb">`/api/users/</span><span class="si">${</span><span class="nx">userId</span><span class="si">}</span><span class="sb">`</span><span class="p">).</span><span class="nx">then</span><span class="p">(</span><span class="nx">res</span> <span class="o">=&gt;</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">()),</span>
</span></span><span class="line"><span class="cl">    <span class="nx">staleTime</span>: <span class="kt">5</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">,</span> <span class="c1">// 5 分钟内视为新鲜
</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="kd">function</span> <span class="nx">useUpdateUser() {</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">queryClient</span> <span class="o">=</span> <span class="nx">useQueryClient</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">useMutation</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">mutationFn</span><span class="o">:</span> <span class="p">(</span><span class="nx">data</span><span class="o">:</span> <span class="p">{</span> <span class="nx">id</span>: <span class="kt">string</span><span class="p">;</span> <span class="nx">name</span>: <span class="kt">string</span> <span class="p">})</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="nx">fetch</span><span class="p">(</span><span class="sb">`/api/users/</span><span class="si">${</span><span class="nx">data</span><span class="p">.</span><span class="nx">id</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">method</span><span class="o">:</span> <span class="s1">&#39;PATCH&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">body</span>: <span class="kt">JSON.stringify</span><span class="p">(</span><span class="nx">data</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="nx">onSuccess</span><span class="o">:</span> <span class="p">(</span><span class="nx">_</span><span class="p">,</span> <span class="nx">variables</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">queryClient</span><span class="p">.</span><span class="nx">invalidateQueries</span><span class="p">({</span> <span class="nx">queryKey</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;user&#39;</span><span class="p">,</span> <span class="nx">variables</span><span class="p">.</span><span class="nx">id</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="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-tsx" data-lang="tsx"><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">UserProfile</span><span class="p">({</span> <span class="nx">userId</span> <span class="p">}</span><span class="o">:</span> <span class="p">{</span> <span class="nx">userId</span>: <span class="kt">string</span> <span class="p">})</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="p">{</span> <span class="nx">data</span>: <span class="kt">user</span><span class="p">,</span> <span class="nx">isLoading</span><span class="p">,</span> <span class="nx">error</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">useUser</span><span class="p">(</span><span class="nx">userId</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">updateUser</span> <span class="o">=</span> <span class="nx">useUpdateUser</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">isLoading</span><span class="p">)</span> <span class="k">return</span> <span class="p">&lt;</span><span class="nt">Spinner</span> <span class="p">/&gt;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="k">return</span> <span class="p">&lt;</span><span class="nt">ErrorMessage</span> <span class="na">error</span><span class="o">=</span><span class="p">{</span><span class="nx">error</span><span class="p">}</span> <span class="p">/&gt;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;</span><span class="nt">div</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">&lt;</span><span class="nt">h1</span><span class="p">&gt;{</span><span class="nx">user</span><span class="p">.</span><span class="nx">name</span><span class="p">}&lt;/</span><span class="nt">h1</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">&lt;</span><span class="nt">button</span>
</span></span><span class="line"><span class="cl">        <span class="na">onClick</span><span class="o">=</span><span class="p">{()</span> <span class="o">=&gt;</span> <span class="nx">updateUser</span><span class="p">.</span><span class="nx">mutate</span><span class="p">({</span> <span class="nx">id</span>: <span class="kt">userId</span><span class="p">,</span> <span class="nx">name</span><span class="o">:</span> <span class="s1">&#39;New Name&#39;</span> <span class="p">})}</span>
</span></span><span class="line"><span class="cl">        <span class="na">disabled</span><span class="o">=</span><span class="p">{</span><span class="nx">updateUser</span><span class="p">.</span><span class="nx">isPending</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="nx">Update</span> <span class="nx">Name</span>
</span></span><span class="line"><span class="cl">      <span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;/</span><span class="nt">div</span><span class="p">&gt;</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>

<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%8d%e7%94%a8%e8%87%aa%e5%b7%b1%e5%86%99%e7%9a%84%e9%82%a3%e4%ba%9b%e4%b8%9c%e8%a5%bf" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>我坚持把服务器数据放这儿，就是因为白送的机制实在太多。五个组件请求同一个用户，网络请求只发一次。窗口重新聚焦或网络重连时自动重新拉取。过期缓存自动垃圾回收。每个查询都带着 <code>isLoading</code>、<code>isError</code>、<code>data</code>，加载和错误 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="#%e5%ae%83%e4%b8%8d%e9%80%82%e5%90%88%e5%b9%b2%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>纯客户端状态。数据压根不经过服务器的话，React Query 只会平添仪式感。弹窗的开关状态不需要缓存失效，也不需要重新拉取间隔。</p>
<p>它也不是持久化层。缓存在内存里，重启就清空、全部重拉。缓存可以持久化（React Native 上也确实应该做，见下面的离线部分），但数据的真实来源永远是服务器。</p>

<h2 class="relative group">AsyncStorage：管扛过重启的数据
    <div id="asyncstorage管扛过重启的数据" 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="#asyncstorage%e7%ae%a1%e6%89%9b%e8%bf%87%e9%87%8d%e5%90%af%e7%9a%84%e6%95%b0%e6%8d%ae" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>AsyncStorage 是 React Native 的键值存储。Web 上对应的是 <code>localStorage</code>（同步）或 <code>IndexedDB</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%90%84%e5%b9%b3%e5%8f%b0%e5%ba%95%e4%b8%8b%e6%98%af%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>用 <code>@react-native-async-storage/async-storage</code> 的话 API 只有一套，但底下的实现每个平台差别不小。</p>
<p>Android 上是走 <code>RKStorage</code> 的 SQLite，数据库放在应用内部存储目录里。快、可靠、有沙盒隔离，别的应用碰不到。不过要留意下表里的默认容量上限。</p>
<p>iOS 上，小值走 <code>NSUserDefaults</code>，大值走序列化文件，同样被沙盒在应用容器里。Apple 没给 <code>NSUserDefaults</code> 定硬上限，但单个值控制在几百 KB 以内是共识。真有那么大的值，八成该直接上 WatermelonDB 或 Realm 这种正经数据库了。</p>
<p>Web 上（React Native Web / Expo Web）会退回 <code>localStorage</code>，上限看浏览器，大概 5-10 MB。纯 Web 的 React 应用直接用 <code>localStorage</code> 就行，数据大了就通过 <code>idb-keyval</code> 这类封装用 <code>IndexedDB</code>。</p>
<table>
	<thead>
			<tr>
					<th>平台</th>
					<th>底层实现</th>
					<th>容量上限</th>
					<th>存储位置</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Android</td>
					<td>SQLite (RKStorage)</td>
					<td>默认约 6 MB（可配置）</td>
					<td>应用内部存储</td>
			</tr>
			<tr>
					<td>iOS</td>
					<td>NSUserDefaults / 文件</td>
					<td>无硬上限（单个值尽量小）</td>
					<td>应用沙盒容器</td>
			</tr>
			<tr>
					<td>Web</td>
					<td>localStorage</td>
					<td>约 5-10 MB（因浏览器而异）</td>
					<td>浏览器按源（origin）隔离的存储</td>
			</tr>
	</tbody>
</table>

<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%88%91%e6%94%be%e8%bf%9b%e5%8e%bb%e7%9a%84%e4%b8%9c%e8%a5%bf" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>认证令牌和会话数据、需要留下来的用户偏好（语言、主题、通知设置）、引导完成标记、离线用的缓存数据。一句话概括：需要扛过重启的小型键值数据，这个类别就这么大。</p>
<p>API 大概是存储方案里最朴素的样子：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">AsyncStorage</span> <span class="kr">from</span> <span class="s1">&#39;@react-native-async-storage/async-storage&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 存储一个值
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="s1">&#39;auth_token&#39;</span><span class="p">,</span> <span class="nx">token</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 读取一个值
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">token</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="s1">&#39;auth_token&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 存储一个对象（需要序列化）
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="s1">&#39;user_preferences&#39;</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">theme</span><span class="o">:</span> <span class="s1">&#39;dark&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">language</span><span class="o">:</span> <span class="s1">&#39;en&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">notifications</span>: <span class="kt">true</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></span><span class="line"><span class="cl"><span class="c1">// 读取一个对象
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">prefs</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="s1">&#39;user_preferences&#39;</span><span class="p">)</span> <span class="o">??</span> <span class="s1">&#39;{}&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 删除一个值
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">removeItem</span><span class="p">(</span><span class="s1">&#39;auth_token&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 清空所有数据（慎用）
</span></span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">clear</span><span class="p">()</span></span></span></code></pre></div></div>

<h3 class="relative group">Web 上怎么办
    <div id="web-上怎么办" 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="#web-%e4%b8%8a%e6%80%8e%e4%b9%88%e5%8a%9e" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>纯 Web 的 React 应用完全不需要 AsyncStorage。简单键值对用 <code>localStorage</code>：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="c1">// 同步的 - 会阻塞主线程，但对于小数据没问题
</span></span></span><span class="line"><span class="cl"><span class="nx">localStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="s1">&#39;theme&#39;</span><span class="p">,</span> <span class="s1">&#39;dark&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">theme</span> <span class="o">=</span> <span class="nx">localStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="s1">&#39;theme&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 结构化数据
</span></span></span><span class="line"><span class="cl"><span class="nx">localStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="s1">&#39;user&#39;</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">({</span> <span class="nx">name</span><span class="o">:</span> <span class="s1">&#39;Jared&#39;</span><span class="p">,</span> <span class="nx">role</span><span class="o">:</span> <span class="s1">&#39;admin&#39;</span> <span class="p">}))</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">user</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">localStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="s1">&#39;user&#39;</span><span class="p">)</span> <span class="o">??</span> <span class="s1">&#39;{}&#39;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>数据集大了用 <code>IndexedDB</code>：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="kr">get</span><span class="p">,</span> <span class="kr">set</span><span class="p">,</span> <span class="nx">del</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;idb-keyval&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="kr">set</span><span class="p">(</span><span class="s1">&#39;large-dataset&#39;</span><span class="p">,</span> <span class="nx">hugeArray</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="k">await</span> <span class="kr">get</span><span class="p">(</span><span class="s1">&#39;large-dataset&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">del</span><span class="p">(</span><span class="s1">&#39;large-dataset&#39;</span><span class="p">)</span></span></span></code></pre></div></div>

<h3 class="relative group">它不适合干什么
    <div id="它不适合干什么-1" 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%83%e4%b8%8d%e9%80%82%e5%90%88%e5%b9%b2%e4%bb%80%e4%b9%88-1" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>它是键值存储，不是数据库。关系型数据、几千上万条的数组、需要索引和查询的东西，归 SQLite（走 <code>expo-sqlite</code>）、WatermelonDB 或 Realm 管。</p>
<p>它也不是安全存储。设备一旦被 root 或越狱，AsyncStorage 里的内容就是明着放的。敏感令牌请放 <code>expo-secure-store</code> 或 <code>react-native-keychain</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="#%e4%b8%89%e4%b8%aa%e6%80%8e%e4%b9%88%e6%90%ad%e9%85%8d%e7%9d%80%e7%94%a8" 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="#%e8%ae%a4%e8%af%81%e6%b5%81%e7%a8%8b" aria-label="锚点">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="c1">// 1. AsyncStorage: 持久化认证令牌
</span></span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">AsyncStorage</span> <span class="kr">from</span> <span class="s1">&#39;@react-native-async-storage/async-storage&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">async</span> <span class="kd">function</span> <span class="nx">saveToken</span><span class="p">(</span><span class="nx">token</span>: <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="s1">&#39;auth_token&#39;</span><span class="p">,</span> <span class="nx">token</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></span><span class="line"><span class="cl"><span class="kr">async</span> <span class="kd">function</span> <span class="nx">getToken</span><span class="p">()</span><span class="o">:</span> <span class="nx">Promise</span><span class="p">&lt;</span><span class="nt">string</span> <span class="err">|</span> <span class="na">null</span><span class="p">&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="s1">&#39;auth_token&#39;</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></span><span class="line"><span class="cl"><span class="c1">// 2. Zustand: 在内存中跟踪认证状态
</span></span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">create</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;zustand&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">interface</span> <span class="nx">AuthState</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">isAuthenticated</span>: <span class="kt">boolean</span>
</span></span><span class="line"><span class="cl">  <span class="nx">token</span>: <span class="kt">string</span> <span class="o">|</span> <span class="kc">null</span>
</span></span><span class="line"><span class="cl">  <span class="nx">setAuth</span><span class="o">:</span> <span class="p">(</span><span class="nx">token</span>: <span class="kt">string</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">void</span>
</span></span><span class="line"><span class="cl">  <span class="nx">clearAuth</span><span class="o">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="k">void</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">const</span> <span class="nx">useAuthStore</span> <span class="o">=</span> <span class="nx">create</span><span class="p">&lt;</span><span class="nt">AuthState</span><span class="p">&gt;((</span><span class="kr">set</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">isAuthenticated</span>: <span class="kt">false</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">token</span>: <span class="kt">null</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">setAuth</span><span class="o">:</span> <span class="p">(</span><span class="nx">token</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="kr">set</span><span class="p">({</span> <span class="nx">isAuthenticated</span>: <span class="kt">true</span><span class="p">,</span> <span class="nx">token</span> <span class="p">}),</span>
</span></span><span class="line"><span class="cl">  <span class="nx">clearAuth</span><span class="o">:</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="kr">set</span><span class="p">({</span> <span class="nx">isAuthenticated</span>: <span class="kt">false</span><span class="p">,</span> <span class="nx">token</span>: <span class="kt">null</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></span><span class="line"><span class="cl"><span class="c1">// 3. React Query: 使用令牌获取用户资料
</span></span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">useCurrentUser() {</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">token</span> <span class="o">=</span> <span class="nx">useAuthStore</span><span class="p">((</span><span class="nx">s</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">s</span><span class="p">.</span><span class="nx">token</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">useQuery</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">queryKey</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;currentUser&#39;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="nx">queryFn</span><span class="o">:</span> <span class="p">()</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="nx">fetch</span><span class="p">(</span><span class="s1">&#39;/api/me&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">headers</span><span class="o">:</span> <span class="p">{</span> <span class="nx">Authorization</span><span class="o">:</span> <span class="sb">`Bearer </span><span class="si">${</span><span class="nx">token</span><span class="si">}</span><span class="sb">`</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="p">}).</span><span class="nx">then</span><span class="p">(</span><span class="nx">res</span> <span class="o">=&gt;</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">()),</span>
</span></span><span class="line"><span class="cl">    <span class="nx">enabled</span><span class="o">:</span> <span class="o">!!</span><span class="nx">token</span><span class="p">,</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>
<p>启动时：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="c1">// 应用初始化
</span></span></span><span class="line"><span class="cl"><span class="kr">async</span> <span class="kd">function</span> <span class="nx">initializeApp() {</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">token</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">getToken</span><span class="p">()</span> <span class="c1">// 从 AsyncStorage 读取
</span></span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">token</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">useAuthStore</span><span class="p">.</span><span class="nx">getState</span><span class="p">().</span><span class="nx">setAuth</span><span class="p">(</span><span class="nx">token</span><span class="p">)</span> <span class="c1">// 放入 Zustand 以便快速访问
</span></span></span><span class="line"><span class="cl">    <span class="c1">// React Query 会自动拉取用户资料
</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>AsyncStorage 负责让令牌扛过重启，Zustand 让它在任何地方都能低成本读到，React Query 则在令牌一就位时去拉用户资料。每个工具只干自己最擅长的那件事。</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="#%e9%87%8d%e5%90%af%e5%90%8e%e8%bf%98%e5%9c%a8%e7%9a%84%e4%b8%bb%e9%a2%98%e8%ae%be%e7%bd%ae" aria-label="锚点">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">create</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;zustand&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">persist</span><span class="p">,</span> <span class="nx">createJSONStorage</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;zustand/middleware&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">AsyncStorage</span> <span class="kr">from</span> <span class="s1">&#39;@react-native-async-storage/async-storage&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// Zustand 的持久化中间件在两者之间搭建了桥梁
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">useThemeStore</span> <span class="o">=</span> <span class="nx">create</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="nx">persist</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="p">(</span><span class="kr">set</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">theme</span><span class="o">:</span> <span class="s1">&#39;light&#39;</span> <span class="kr">as</span> <span class="s1">&#39;light&#39;</span> <span class="o">|</span> <span class="s1">&#39;dark&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nx">toggleTheme</span><span class="o">:</span> <span class="p">()</span> <span class="o">=&gt;</span>
</span></span><span class="line"><span class="cl">        <span class="kr">set</span><span class="p">((</span><span class="nx">state</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">theme</span>: <span class="kt">state.theme</span> <span class="o">===</span> <span class="s1">&#39;light&#39;</span> <span class="o">?</span> <span class="s1">&#39;dark&#39;</span> <span class="o">:</span> <span class="s1">&#39;light&#39;</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 class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">name</span><span class="o">:</span> <span class="s1">&#39;theme-storage&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nx">storage</span>: <span class="kt">createJSONStorage</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="nx">AsyncStorage</span><span class="p">),</span> <span class="c1">// React Native
</span></span></span><span class="line"><span class="cl">      <span class="c1">// storage: createJSONStorage(() =&gt; localStorage), // Web
</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>运行时状态归 Zustand 管，persist 中间件在背后同步到 AsyncStorage（Web 上是 localStorage）。组件既不知道也不需要知道持久化层的存在。</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%a6%bb%e7%ba%bf%e4%bc%98%e5%85%88%e7%9a%84%e8%af%bb%e5%8f%96" aria-label="锚点">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">useQuery</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/react-query&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">AsyncStorage</span> <span class="kr">from</span> <span class="s1">&#39;@react-native-async-storage/async-storage&#39;</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">useProducts() {</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">useQuery</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">queryKey</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;products&#39;</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">    <span class="nx">queryFn</span>: <span class="kt">async</span> <span class="p">()</span> <span class="o">=&gt;</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="kr">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="s1">&#39;/api/products&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="kr">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">res</span><span class="p">.</span><span class="nx">json</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="c1">// 缓存到 AsyncStorage 以供离线使用
</span></span></span><span class="line"><span class="cl">        <span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="s1">&#39;cached_products&#39;</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">data</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="nx">data</span>
</span></span><span class="line"><span class="cl">      <span class="p">}</span> <span class="k">catch</span> <span class="p">(</span><span class="nx">error</span><span class="p">)</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="kr">const</span> <span class="nx">cached</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="s1">&#39;cached_products&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">if</span> <span class="p">(</span><span class="nx">cached</span><span class="p">)</span> <span class="k">return</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">cached</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="k">throw</span> <span class="nx">error</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="nx">staleTime</span>: <span class="kt">10</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</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>拉取和内存缓存归 React Query，断网时的兜底归 AsyncStorage，分工就这么简单。</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%a6%bb%e7%ba%bf%e6%a8%a1%e5%bc%8f%e4%b8%80%e6%ad%a5%e4%b8%80%e6%ad%a5%e6%9d%a5" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这部分才是我真正关心的。通常的说法是用户会在地铁、电梯、飞机上打开应用，这没错。但我的情况更直接：报价就是在现场写的，而现场有没有信号全看运气。地下室里转圈的加载动画，意味着丢掉一单报价。</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%ac%ac%e4%b8%80%e6%ad%a5%e7%9f%a5%e9%81%93%e8%87%aa%e5%b7%b1%e6%96%ad%e7%bd%91%e4%ba%86" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>设备得把网络状态告诉应用，应用得有个地方存这个答案。事件由 <code>@react-native-community/netinfo</code> 提供，再用一个小小的 Zustand store 让全应用都能读到。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">create</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;zustand&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">NetInfo</span> <span class="kr">from</span> <span class="s1">&#39;@react-native-community/netinfo&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">interface</span> <span class="nx">NetworkState</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">isOnline</span>: <span class="kt">boolean</span>
</span></span><span class="line"><span class="cl">  <span class="nx">setOnline</span><span class="o">:</span> <span class="p">(</span><span class="nx">online</span>: <span class="kt">boolean</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="k">void</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">const</span> <span class="nx">useNetworkStore</span> <span class="o">=</span> <span class="nx">create</span><span class="p">&lt;</span><span class="nt">NetworkState</span><span class="p">&gt;((</span><span class="kr">set</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">isOnline</span>: <span class="kt">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">setOnline</span><span class="o">:</span> <span class="p">(</span><span class="nx">online</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="kr">set</span><span class="p">({</span> <span class="nx">isOnline</span>: <span class="kt">online</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></span><span class="line"><span class="cl"><span class="c1">// 应用启动时订阅一次
</span></span></span><span class="line"><span class="cl"><span class="nx">NetInfo</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">((</span><span class="nx">state</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">useNetworkStore</span><span class="p">.</span><span class="nx">getState</span><span class="p">().</span><span class="nx">setOnline</span><span class="p">(</span><span class="nx">state</span><span class="p">.</span><span class="nx">isConnected</span> <span class="o">??</span> <span class="kc">false</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>useNetworkStore((s) =&gt; s.isOnline)</code> 来显示离线横幅、禁用提交按钮，或者提示“联网后会自动同步”。</p>

<h3 class="relative group">第二步：把离线告诉 React Query
    <div id="第二步把离线告诉-react-query" 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%ba%8c%e6%ad%a5%e6%8a%8a%e7%a6%bb%e7%ba%bf%e5%91%8a%e8%af%89-react-query" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>React Query 内置了 <code>networkMode</code>，控制断网时查询和变更的行为。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">QueryClient</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/react-query&#39;</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">queryClient</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">QueryClient</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">defaultOptions</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">queries</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">networkMode</span><span class="o">:</span> <span class="s1">&#39;offlineFirst&#39;</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="nx">staleTime</span>: <span class="kt">5</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nx">gcTime</span>: <span class="kt">24</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">,</span> <span class="c1">// 缓存保留 24 小时
</span></span></span><span class="line"><span class="cl">      <span class="nx">retry</span><span class="o">:</span> <span class="p">(</span><span class="nx">failureCount</span><span class="p">,</span> <span class="nx">error</span><span class="p">)</span> <span class="o">=&gt;</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="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">useNetworkStore</span><span class="p">.</span><span class="nx">getState</span><span class="p">().</span><span class="nx">isOnline</span><span class="p">)</span> <span class="k">return</span> <span class="kc">false</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="nx">failureCount</span> <span class="o">&lt;</span> <span class="mi">3</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="nx">mutations</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">networkMode</span><span class="o">:</span> <span class="s1">&#39;offlineFirst&#39;</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 class="p">})</span></span></span></code></pre></div></div>
<p>三种模式：</p>
<table>
	<thead>
			<tr>
					<th>模式</th>
					<th>行为</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>online</code>（默认）</td>
					<td>只在联网时发起查询，断网时暂停。</td>
			</tr>
			<tr>
					<td><code>always</code></td>
					<td>不管网络状态都发起查询，失败由你的 <code>queryFn</code> 处理。</td>
			</tr>
			<tr>
					<td><code>offlineFirst</code></td>
					<td>先发起一次查询（取缓存数据），之后暂停，等联网再重新拉取。</td>
			</tr>
	</tbody>
</table>
<p>大多数移动应用选 <code>offlineFirst</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%ac%e4%b8%89%e6%ad%a5%e6%8c%81%e4%b9%85%e5%8c%96%e6%9f%a5%e8%af%a2%e7%bc%93%e5%ad%98" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>默认缓存只在内存里，一重启就满屏转圈。要做离线模式，得把缓存持久化到 AsyncStorage：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">QueryClient</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/react-query&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">PersistQueryClientProvider</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/react-query-persist-client&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">createAsyncStoragePersister</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/query-async-storage-persister&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">AsyncStorage</span> <span class="kr">from</span> <span class="s1">&#39;@react-native-async-storage/async-storage&#39;</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">queryClient</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">QueryClient</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">defaultOptions</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">queries</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">gcTime</span>: <span class="kt">24</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">,</span> <span class="c1">// 24 小时 - 必须 &gt;= maxAge
</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><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">asyncStoragePersister</span> <span class="o">=</span> <span class="nx">createAsyncStoragePersister</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">storage</span>: <span class="kt">AsyncStorage</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">key</span><span class="o">:</span> <span class="s1">&#39;react-query-cache&#39;</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></span><span class="line"><span class="cl"><span class="c1">// 在你的 App 组件中
</span></span></span><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">App() {</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;</span><span class="nt">PersistQueryClientProvider</span>
</span></span><span class="line"><span class="cl">      <span class="na">client</span><span class="o">=</span><span class="p">{</span><span class="nx">queryClient</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">      <span class="na">persistOptions</span><span class="o">=</span><span class="p">{{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">persister</span>: <span class="kt">asyncStoragePersister</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">maxAge</span>: <span class="kt">24</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">60</span> <span class="o">*</span> <span class="mi">1000</span><span class="p">,</span> <span class="c1">// 不恢复超过 24 小时的数据
</span></span></span><span class="line"><span class="cl">        <span class="nx">dehydrateOptions</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">          <span class="nx">shouldDehydrateQuery</span><span class="o">:</span> <span class="p">(</span><span class="nx">query</span><span class="p">)</span> <span class="o">=&gt;</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="k">return</span> <span class="nx">query</span><span class="p">.</span><span class="nx">state</span><span class="p">.</span><span class="nx">status</span> <span class="o">===</span> <span class="s1">&#39;success&#39;</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><span class="line"><span class="cl">    <span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">&lt;</span><span class="nt">YourApp</span> <span class="p">/&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;/</span><span class="nt">PersistQueryClientProvider</span><span class="p">&gt;</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>这样在没信号的地方打开应用，看到的是上次拉取的数据而不是白屏；网络一恢复，React Query 就在后台悄悄重拉。</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%ac%e5%9b%9b%e6%ad%a5%e7%a6%bb%e7%ba%bf%e6%97%b6%e6%8a%8a%e5%86%99%e6%93%8d%e4%bd%9c%e6%8e%92%e8%bf%9b%e9%98%9f%e5%88%97" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>读是容易的那一半。有意思的是写：用户断网时发了评论、提交了订单、改了资料，这些变更得排队存好，等网络回来再重放。</p>
<p>React Query 的答案是 <code>useMutation</code> 加上 <code>onMutate</code> 里的乐观更新：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">useMutation</span><span class="p">,</span> <span class="nx">useQueryClient</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/react-query&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">AsyncStorage</span> <span class="kr">from</span> <span class="s1">&#39;@react-native-async-storage/async-storage&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">interface</span> <span class="nx">Comment</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">id</span>: <span class="kt">string</span>
</span></span><span class="line"><span class="cl">  <span class="nx">text</span>: <span class="kt">string</span>
</span></span><span class="line"><span class="cl">  <span class="nx">postId</span>: <span class="kt">string</span>
</span></span><span class="line"><span class="cl">  <span class="nx">createdAt</span>: <span class="kt">string</span>
</span></span><span class="line"><span class="cl">  <span class="nx">pending?</span>: <span class="kt">boolean</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="kd">function</span> <span class="nx">useAddComment</span><span class="p">(</span><span class="nx">postId</span>: <span class="kt">string</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">queryClient</span> <span class="o">=</span> <span class="nx">useQueryClient</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">useMutation</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">mutationFn</span>: <span class="kt">async</span> <span class="p">(</span><span class="nx">text</span>: <span class="kt">string</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="kr">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="sb">`/api/posts/</span><span class="si">${</span><span class="nx">postId</span><span class="si">}</span><span class="sb">/comments`</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">method</span><span class="o">:</span> <span class="s1">&#39;POST&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">headers</span><span class="o">:</span> <span class="p">{</span> <span class="s1">&#39;Content-Type&#39;</span><span class="o">:</span> <span class="s1">&#39;application/json&#39;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">        <span class="nx">body</span>: <span class="kt">JSON.stringify</span><span class="p">({</span> <span class="nx">text</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="nx">res</span><span class="p">.</span><span class="nx">json</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></span><span class="line"><span class="cl">    <span class="c1">// 乐观更新 - 立即显示评论
</span></span></span><span class="line"><span class="cl">    <span class="nx">onMutate</span>: <span class="kt">async</span> <span class="p">(</span><span class="nx">text</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="k">await</span> <span class="nx">queryClient</span><span class="p">.</span><span class="nx">cancelQueries</span><span class="p">({</span> <span class="nx">queryKey</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;comments&#39;</span><span class="p">,</span> <span class="nx">postId</span><span class="p">]</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">previous</span> <span class="o">=</span> <span class="nx">queryClient</span><span class="p">.</span><span class="nx">getQueryData</span><span class="p">&lt;</span><span class="nt">Comment</span><span class="err">[]</span><span class="p">&gt;([</span><span class="s1">&#39;comments&#39;</span><span class="p">,</span> <span class="nx">postId</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">optimisticComment</span>: <span class="kt">Comment</span> <span class="o">=</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">id</span><span class="o">:</span> <span class="sb">`temp-</span><span class="si">${</span><span class="nb">Date</span><span class="p">.</span><span class="nx">now</span><span class="p">()</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">text</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">postId</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="nx">createdAt</span>: <span class="kt">new</span> <span class="nb">Date</span><span class="p">().</span><span class="nx">toISOString</span><span class="p">(),</span>
</span></span><span class="line"><span class="cl">        <span class="nx">pending</span>: <span class="kt">true</span><span class="p">,</span> <span class="c1">// 在 UI 中显示&#34;发送中...&#34;的标识
</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">queryClient</span><span class="p">.</span><span class="nx">setQueryData</span><span class="p">&lt;</span><span class="nt">Comment</span><span class="err">[]</span><span class="p">&gt;(</span>
</span></span><span class="line"><span class="cl">        <span class="p">[</span><span class="s1">&#39;comments&#39;</span><span class="p">,</span> <span class="nx">postId</span><span class="p">],</span>
</span></span><span class="line"><span class="cl">        <span class="p">(</span><span class="nx">old</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">[...(</span><span class="nx">old</span> <span class="o">??</span> <span class="p">[]),</span> <span class="nx">optimisticComment</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></span><span class="line"><span class="cl">      <span class="k">return</span> <span class="p">{</span> <span class="nx">previous</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></span><span class="line"><span class="cl">    <span class="c1">// 失败时回滚
</span></span></span><span class="line"><span class="cl">    <span class="nx">onError</span><span class="o">:</span> <span class="p">(</span><span class="nx">err</span><span class="p">,</span> <span class="nx">text</span><span class="p">,</span> <span class="nx">context</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="k">if</span> <span class="p">(</span><span class="nx">context</span><span class="o">?</span><span class="p">.</span><span class="nx">previous</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="nx">queryClient</span><span class="p">.</span><span class="nx">setQueryData</span><span class="p">([</span><span class="s1">&#39;comments&#39;</span><span class="p">,</span> <span class="nx">postId</span><span class="p">],</span> <span class="nx">context</span><span class="p">.</span><span class="nx">previous</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="c1">// 重新拉取以获取服务器的真实数据
</span></span></span><span class="line"><span class="cl">    <span class="nx">onSettled</span><span class="o">:</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">queryClient</span><span class="p">.</span><span class="nx">invalidateQueries</span><span class="p">({</span> <span class="nx">queryKey</span><span class="o">:</span> <span class="p">[</span><span class="s1">&#39;comments&#39;</span><span class="p">,</span> <span class="nx">postId</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="p">})</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>给变更设置了 <code>networkMode: 'offlineFirst'</code> 之后，<code>mutationFn</code> 会暂停到网络恢复为止。乐观更新让评论立刻出现在屏幕上，真正的 API 调用在重连时才发出去。</p>
<p>如果需要更正经的队列，比如几十个待处理变更必须按顺序重放，还可以把变更队列本身也持久化：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">MutationCache</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/react-query&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 将待处理的变更保存到 AsyncStorage
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">mutationCache</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">MutationCache</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">onError</span>: <span class="kt">async</span> <span class="p">(</span><span class="nx">error</span><span class="p">,</span> <span class="nx">variables</span><span class="p">,</span> <span class="nx">context</span><span class="p">,</span> <span class="nx">mutation</span><span class="p">)</span> <span class="o">=&gt;</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="kr">const</span> <span class="nx">pending</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">      <span class="k">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">getItem</span><span class="p">(</span><span class="s1">&#39;pending_mutations&#39;</span><span class="p">)</span> <span class="o">??</span> <span class="s1">&#39;[]&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nx">pending</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">      <span class="nx">key</span>: <span class="kt">mutation.options.mutationKey</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nx">variables</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nx">timestamp</span>: <span class="kt">Date.now</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">await</span> <span class="nx">AsyncStorage</span><span class="p">.</span><span class="nx">setItem</span><span class="p">(</span><span class="s1">&#39;pending_mutations&#39;</span><span class="p">,</span> <span class="nx">JSON</span><span class="p">.</span><span class="nx">stringify</span><span class="p">(</span><span class="nx">pending</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>

<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%ac%e4%ba%94%e6%ad%a5%e7%bd%91%e7%bb%9c%e5%9b%9e%e6%9d%a5%e5%90%8e%e5%af%b9%e9%bd%90%e7%8a%b6%e6%80%81" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>重连的活儿 React Query 基本自己包了：暂停的变更恢复执行，过期的查询重新拉取。你要做的只是把它的管理器接到 React Native 的事件上。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="nx">NetInfo</span> <span class="kr">from</span> <span class="s1">&#39;@react-native-community/netinfo&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">onlineManager</span><span class="p">,</span> <span class="nx">focusManager</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@tanstack/react-query&#39;</span>
</span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">AppState</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;react-native&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// 告诉 React Query 网络状态的变化
</span></span></span><span class="line"><span class="cl"><span class="nx">onlineManager</span><span class="p">.</span><span class="nx">setEventListener</span><span class="p">((</span><span class="nx">setOnline</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">NetInfo</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">((</span><span class="nx">state</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">setOnline</span><span class="p">(</span><span class="o">!!</span><span class="nx">state</span><span class="p">.</span><span class="nx">isConnected</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="c1">// 应用回到前台时重新拉取
</span></span></span><span class="line"><span class="cl"><span class="nx">focusManager</span><span class="p">.</span><span class="nx">setEventListener</span><span class="p">((</span><span class="nx">setFocused</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">subscription</span> <span class="o">=</span> <span class="nx">AppState</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s1">&#39;change&#39;</span><span class="p">,</span> <span class="p">(</span><span class="nx">status</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">setFocused</span><span class="p">(</span><span class="nx">status</span> <span class="o">===</span> <span class="s1">&#39;active&#39;</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="p">()</span> <span class="o">=&gt;</span> <span class="nx">subscription</span><span class="p">.</span><span class="nx">remove</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>focusManager</code> 会触发重拉，让他们看到新数据；走出隧道信号恢复时，<code>onlineManager</code> 会把暂停的操作重新跑起来。</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="#%e7%ac%ac%e5%85%ad%e6%ad%a5%e5%9c%a8-ui-%e4%b8%8a%e8%af%b4%e6%b8%85%e6%a5%9a" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>无论如何，别把网络错误默默咽下去。要告诉用户现在离线了、哪些更改还在等待同步。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-tsx" data-lang="tsx"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">useNetworkStore</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;./stores/network&#39;</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">OfflineBanner() {</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">isOnline</span> <span class="o">=</span> <span class="nx">useNetworkStore</span><span class="p">((</span><span class="nx">s</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">s</span><span class="p">.</span><span class="nx">isOnline</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">isOnline</span><span class="p">)</span> <span class="k">return</span> <span class="kc">null</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;</span><span class="nt">View</span> <span class="na">style</span><span class="o">=</span><span class="p">{</span><span class="nx">styles</span><span class="p">.</span><span class="nx">banner</span><span class="p">}&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">&lt;</span><span class="nt">Text</span><span class="p">&gt;</span><span class="nx">You</span><span class="err">&#39;</span><span class="nx">re</span> <span class="nx">offline</span><span class="p">.</span> <span class="nx">Changes</span> <span class="nx">will</span> <span class="nx">sync</span> <span class="nx">when</span> <span class="nx">you</span> <span class="nx">reconnect</span><span class="p">.&lt;/</span><span class="nt">Text</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;/</span><span class="nt">View</span><span class="p">&gt;</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="kd">function</span> <span class="nx">CommentItem</span><span class="p">({</span> <span class="nx">comment</span> <span class="p">}</span><span class="o">:</span> <span class="p">{</span> <span class="nx">comment</span>: <span class="kt">Comment</span> <span class="p">})</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;</span><span class="nt">View</span> <span class="na">style</span><span class="o">=</span><span class="p">{[</span><span class="nx">styles</span><span class="p">.</span><span class="nx">comment</span><span class="p">,</span> <span class="nx">comment</span><span class="p">.</span><span class="nx">pending</span> <span class="o">&amp;&amp;</span> <span class="nx">styles</span><span class="p">.</span><span class="nx">pending</span><span class="p">]}&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">&lt;</span><span class="nt">Text</span><span class="p">&gt;{</span><span class="nx">comment</span><span class="p">.</span><span class="nx">text</span><span class="p">}&lt;/</span><span class="nt">Text</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span><span class="nx">comment</span><span class="p">.</span><span class="nx">pending</span> <span class="o">&amp;&amp;</span> <span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="p">&lt;</span><span class="nt">Text</span> <span class="na">style</span><span class="o">=</span><span class="p">{</span><span class="nx">styles</span><span class="p">.</span><span class="nx">pendingLabel</span><span class="p">}&gt;</span><span class="nx">Sending</span><span class="p">...&lt;/</span><span class="nt">Text</span><span class="p">&gt;</span>
</span></span><span class="line"><span class="cl">      <span class="p">)}</span>
</span></span><span class="line"><span class="cl">    <span class="p">&lt;/</span><span class="nt">View</span><span class="p">&gt;</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>

<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%95%b4%e4%bd%93%e6%9e%b6%e6%9e%84" aria-label="锚点">#</a>
    </span>
    
</h3>
<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">│                   Components                     │
</span></span><span class="line"><span class="cl">│  useQuery() for reads    useMutation() for writes│
</span></span><span class="line"><span class="cl">└──────────┬──────────────────────┬────────────────┘
</span></span><span class="line"><span class="cl">           │                      │
</span></span><span class="line"><span class="cl">     ┌─────▼──────┐        ┌─────▼──────┐
</span></span><span class="line"><span class="cl">     │ React Query │        │ React Query │
</span></span><span class="line"><span class="cl">     │   Cache     │        │  Mutation   │
</span></span><span class="line"><span class="cl">     │ (in-memory) │        │   Queue     │
</span></span><span class="line"><span class="cl">     └─────┬──────┘        └─────┬──────┘
</span></span><span class="line"><span class="cl">           │                      │
</span></span><span class="line"><span class="cl">     ┌─────▼──────────────────────▼──────┐
</span></span><span class="line"><span class="cl">     │     AsyncStorage Persister         │
</span></span><span class="line"><span class="cl">     │  (survives app restart)            │
</span></span><span class="line"><span class="cl">     └─────┬──────────────────────┬──────┘
</span></span><span class="line"><span class="cl">           │                      │
</span></span><span class="line"><span class="cl">     ┌─────▼──────┐        ┌─────▼──────┐
</span></span><span class="line"><span class="cl">     │   Zustand   │        │   Network  │
</span></span><span class="line"><span class="cl">     │ (isOnline,  │◄───────│   NetInfo  │
</span></span><span class="line"><span class="cl">     │  UI state)  │        │            │
</span></span><span class="line"><span class="cl">     └────────────┘        └────────────┘</span></span></code></pre></div></div>
<table>
	<thead>
			<tr>
					<th>层</th>
					<th>工具</th>
					<th>职责</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>网络检测</td>
					<td>Zustand + NetInfo</td>
					<td>跟踪在线/离线状态，驱动 UI 横幅</td>
			</tr>
			<tr>
					<td>数据拉取</td>
					<td>React Query</td>
					<td>在线时拉取，离线时返回缓存</td>
			</tr>
			<tr>
					<td>缓存持久化</td>
					<td>React Query + AsyncStorage</td>
					<td>应用重启时恢复缓存</td>
			</tr>
			<tr>
					<td>离线写入</td>
					<td>React Query mutations</td>
					<td>变更排队，重连后重放</td>
			</tr>
			<tr>
					<td>乐观 UI</td>
					<td>React Query <code>onMutate</code></td>
					<td>立即显示更改，失败时回滚</td>
			</tr>
			<tr>
					<td>前台同步</td>
					<td>React Query <code>focusManager</code></td>
					<td>应用回到前台时重新拉取过期数据</td>
			</tr>
	</tbody>
</table>

<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%bb%80%e4%b9%88%e6%97%b6%e5%80%99%e9%9c%80%e8%a6%81%e7%9c%9f%e6%ad%a3%e7%9a%84%e5%90%8c%e6%ad%a5%e5%bc%95%e6%93%8e" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>上面这套的前提是：服务器是数据的真实来源，离线只是暂时状态。如果你要的是带冲突解决的真·离线优先，比如两台设备离线编辑同一篇笔记，这套就不够了，得上专门的同步引擎。WatermelonDB 专为 React Native 打造，底层是 SQLite，自带解决冲突的同步协议。Realm 配上 Atlas Device Sync，就是一个通过 MongoDB Atlas 自动解决冲突的完整离线优先数据库。PowerSync 是基于 SQLite 的同步层，能直接对接你现有的 Postgres 后端。要完全的控制权，也可以 Expo SQLite 加自己写同步逻辑。</p>
<p>Zustand + React Query + AsyncStorage 能覆盖大约 80% 的移动应用。剩下的 20%，也就是协同编辑、多设备同步、重度离线的工作流，需要专门的同步数据库。</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%8b%bf%e4%b8%8d%e5%87%86%e6%94%be%e5%93%aa%e6%97%b6" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我拿不准的时候，会按这个顺序问自己：</p>
<table>
	<thead>
			<tr>
					<th>问题</th>
					<th>是 → 用</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>数据来自服务器/API 吗？</td>
					<td>React Query</td>
			</tr>
			<tr>
					<td>是多个组件共享的纯客户端 UI 状态吗？</td>
					<td>Zustand</td>
			</tr>
			<tr>
					<td>需要扛过应用重启吗？</td>
					<td>AsyncStorage（可选配合 Zustand persist）</td>
			</tr>
			<tr>
					<td>是敏感数据吗（令牌、密码）？</td>
					<td>expo-secure-store / react-native-keychain</td>
			</tr>
			<tr>
					<td>是需要查询的大型结构化数据吗？</td>
					<td>SQLite / WatermelonDB / Realm</td>
			</tr>
			<tr>
					<td>只是单个组件用的简单表单输入吗？</td>
					<td>useState</td>
			</tr>
	</tbody>
</table>

<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%b8%b8%e8%a7%81%e5%9c%ba%e6%99%af" aria-label="锚点">#</a>
    </span>
    
</h3>
<table>
	<thead>
			<tr>
					<th>状态</th>
					<th>工具</th>
					<th>原因</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>API 响应数据</td>
					<td>React Query</td>
					<td>缓存、去重、重新拉取、加载状态</td>
			</tr>
			<tr>
					<td>选中的标签页 / 当前筛选条件</td>
					<td>Zustand</td>
					<td>纯客户端，多个组件都要用</td>
			</tr>
			<tr>
					<td>认证令牌</td>
					<td>AsyncStorage + Zustand</td>
					<td>重启后仍在，内存中读取快</td>
			</tr>
			<tr>
					<td>主题偏好</td>
					<td>Zustand + persist 中间件</td>
					<td>需要扛过重启的客户端状态</td>
			</tr>
			<tr>
					<td>购物车</td>
					<td>Zustand + persist 中间件</td>
					<td>客户端逻辑复杂，需要扛过重启</td>
			</tr>
			<tr>
					<td>表单输入</td>
					<td>useState</td>
					<td>只在单个组件里用，无需共享</td>
			</tr>
			<tr>
					<td>来自 API 的用户资料</td>
					<td>React Query</td>
					<td>服务器状态，可能过期</td>
			</tr>
			<tr>
					<td>引导完成标记</td>
					<td>AsyncStorage</td>
					<td>只是一个需要持久化的布尔值</td>
			</tr>
			<tr>
					<td>离线缓存的动态流</td>
					<td>React Query + AsyncStorage</td>
					<td>从服务器拉取，失败时退回缓存</td>
			</tr>
	</tbody>
</table>

<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%90%84%e5%b9%b3%e5%8f%b0%e5%ae%89%e8%a3%85" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">React Native (Android + iOS)
    <div id="react-native-android--ios" 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="#react-native-android--ios" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>三个一起装：</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">npm install zustand @tanstack/react-query @react-native-async-storage/async-storage</span></span></code></pre></div></div>
<p>敏感数据再加上安全存储：</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">npx expo install expo-secure-store
</span></span><span class="line"><span class="cl"><span class="c1"># 或者</span>
</span></span><span class="line"><span class="cl">npm install react-native-keychain</span></span></code></pre></div></div>

<h3 class="relative group">Expo
    <div id="expo" 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="#expo" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>AsyncStorage 在 Expo 里开箱即用，不需要原生链接。</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">npx expo install @react-native-async-storage/async-storage</span></span></code></pre></div></div>

<h3 class="relative group">纯 Web React
    <div id="纯-web-react" 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%ba%af-web-react" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>跳过 AsyncStorage，直接用 <code>localStorage</code> 或 <code>IndexedDB</code>。</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">npm install zustand @tanstack/react-query
</span></span><span class="line"><span class="cl"><span class="c1"># 可选，用于 IndexedDB</span>
</span></span><span class="line"><span class="cl">npm install idb-keyval</span></span></code></pre></div></div>
<p>Zustand 的 persist 中间件在 Web 上默认就用 <code>localStorage</code>：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="nx">persist</span><span class="p">(</span><span class="nx">storeConfig</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">name</span><span class="o">:</span> <span class="s1">&#39;my-store&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// localStorage 在 Web 端是默认的 - 不需要额外配置
</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="#%e6%88%91%e5%8f%8d%e5%a4%8d%e8%a7%81%e5%88%b0%e7%9a%84%e9%94%99%e8%af%af" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>把 API 数据塞进 Zustand。如果你的 action 里出现了 <code>setUsers(apiResponse.users)</code>，先停一下。那是 React Query 的活，再往前走一步，就是把缓存失效重新发明一遍，而且发明得不会太好。</p>
<p>用 React Query 管客户端状态。<code>queryFn</code> 里没有网络请求？工具用错了。</p>
<p>把 AsyncStorage 当数据库。往键值存储里序列化一万条记录的数组，结局不会体面。用数据库。</p>
<p>令牌不加密。AsyncStorage 不是安全存储。认证令牌、API 密钥、用户凭证，请放 <code>expo-secure-store</code> 或平台的 keychain。</p>
<p>手搓持久化。如果你在挂载时手动读 AsyncStorage、每次状态一变就手动写回去，Zustand 的 persist 中间件早就把这些连同水合、序列化一起做好了，代码更少，坑也更少。</p>
<hr>
<p>服务器数据走 React Query。客户端状态放 Zustand。要扛过重启的进 AsyncStorage，敏感的进安全存储。</p>
<p>我经手过的最糟的架构，无一例外都是所有东西流经一个巨型 store。好的架构里，服务器状态、客户端状态、需要持久化的东西之间有清清楚楚的线。这些线画得越早，后面的一切就越省事。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-05-05-zustand-vs-react-query/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>不用 EAS，自建 Expo OTA 热更新：用 Django 和 Tigris 搭更新服务器</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-02-22-self-hosted-ota-updates/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-02-22-self-hosted-ota-updates/</guid>
      <pubDate>Sun, 22 Feb 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Django</category>
      <category>Mobile Development</category>
      <category>CI/CD</category>
      <category>Self-Hosted</category>
      <category>Tigris</category>
      <category>S3</category>
      <description>Expo 的 OTA 热更新是怎么工作的，以及我怎么不用 EAS Update 自己托管：一台实现 Expo Updates 协议的 Django REST 服务器，bundle 放在 Tigris S3 上，每月成本大约一美元。</description>
      <content:encoded><![CDATA[<blockquote><p><strong>2026 年 9 月 13 日更新。</strong> 这篇发出之后，Codemagic 发布了 <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a>，一个 CodePush 路线的<a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">开源</a>、可自托管 OTA 服务器。如果你是因为不想自己写服务器才找到这里的，它现在是一个实在的选项：我已经<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-13-moving-to-codemagic-patch/" >把一个应用从下面这套方案迁到了 Patch</a>，并把过程写了下来，<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-02-22-self-hosted-ota-updates/#%e5%86%99%e5%9c%a8%e6%9c%80%e5%90%8e" >写在最后</a>一节里也有一段简短说明。声明：那次迁移和那篇文章是 Codemagic 付费委托的，他们也希望在这里提一句。正文里的代码后来已精简为简短的示例。</p>
</blockquote><p>我做的报价应用，bug 修复都是通过 OTA 推送的：JavaScript 改动不用等 App Store 审核，直接下发到用户手机上。Expo 官方的 EAS Update 本来就能做这件事，但我还是用 Django REST Framework 和 Tigris S3 自己搭了一套。从那以后，iOS 和 Android 的更新全都走这台服务器。</p>
<p>Expo 的 OTA 更新（也就是大家常说的热更新）是这样运作的：应用里的 <code>expo-updates</code> 库向更新服务器请求一份清单，请求头里带着自己的平台和运行时版本。服务器返回最新的兼容更新，以及这次更新的 JavaScript bundle 和资源文件的地址，应用把它们下载下来，下次启动时就跑新的 bundle。EAS Update 是 Expo 官方托管的一种实现，但任何实现了这套协议的服务器都能用，我自己搭的就是这样一台。</p>
<p>这篇文章把整个方案过一遍：数据模型、清单（manifest）接口、发布流水线，还有那些只有真正跑起来才会踩到的坑。</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="#%e4%b8%ba%e4%bb%80%e4%b9%88%e8%a6%81%e8%87%aa%e5%b7%b1%e6%90%ad" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>对我来说主要是钱的问题。EAS Update 按用量计费，用户多了、更新频繁了，账单就跟着涨；而 Tigris 这类 S3 兼容存储几乎不要钱。当然还有别的理由：有些行业要求应用资产必须留在自己的基础设施里；服务器在自己手上，整个更新流程就都可控，想按用户群灰度、想做 bundle 的 A/B 测试都行。另外，更新链路也不再受 Expo 服务可用性或下一次调价的影响。</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%95%b4%e4%bd%93%e6%9e%b6%e6%9e%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>一共四个部分：Django 后端负责下发更新清单、保存元数据；Tigris 存放 bundle 和资源文件本体；一个发布脚本负责导出、上传、登记更新；再加上移动应用本身，把更新地址指向我的服务器而不是 Expo 的。</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">│   Mobile App    │
</span></span><span class="line"><span class="cl">│  (expo-updates) │
</span></span><span class="line"><span class="cl">└────────┬────────┘
</span></span><span class="line"><span class="cl">         │ 1. Request manifest
</span></span><span class="line"><span class="cl">         │    (with headers: platform, runtime-version)
</span></span><span class="line"><span class="cl">         ↓
</span></span><span class="line"><span class="cl">┌─────────────────┐
</span></span><span class="line"><span class="cl">│  Django Server  │
</span></span><span class="line"><span class="cl">│  /api/expo-     │◄─── 2. Query DB for latest update
</span></span><span class="line"><span class="cl">│   updates/      │
</span></span><span class="line"><span class="cl">│   manifest/     │
</span></span><span class="line"><span class="cl">└────────┬────────┘
</span></span><span class="line"><span class="cl">         │ 3. Generate presigned URLs
</span></span><span class="line"><span class="cl">         │
</span></span><span class="line"><span class="cl">         ↓
</span></span><span class="line"><span class="cl">┌─────────────────┐
</span></span><span class="line"><span class="cl">│   Tigris S3     │
</span></span><span class="line"><span class="cl">│  (Asset Files)  │◄─── 4. App downloads bundles directly
</span></span><span class="line"><span class="cl">└─────────────────┘</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="#%e5%85%b7%e4%bd%93%e5%ae%9e%e7%8e%b0" 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%a4%e4%b8%aa%e6%a8%a1%e5%9e%8b" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>一切都建立在 <code>ExpoUpdate</code> 和 <code>ExpoUpdateAsset</code> 之上。前者保存每次更新的元数据：</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">class</span> <span class="nc">ExpoUpdate</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="nb">id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">UUIDField</span><span class="p">(</span><span class="n">primary_key</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">uuid</span><span class="o">.</span><span class="n">uuid4</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">runtime_version</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">50</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">choices</span><span class="o">=</span><span class="p">[(</span><span class="s2">&#34;ios&#34;</span><span class="p">,</span> <span class="s2">&#34;iOS&#34;</span><span class="p">),</span> <span class="p">(</span><span class="s2">&#34;android&#34;</span><span class="p">,</span> <span class="s2">&#34;Android&#34;</span><span class="p">)],</span>
</span></span><span class="line"><span class="cl">        <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">is_active</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">BooleanField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">manifest_data</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">JSONField</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">description</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">TextField</span><span class="p">(</span><span class="n">blank</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">created_at</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">DateTimeField</span><span class="p">(</span><span class="n">auto_now_add</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">class</span> <span class="nc">Meta</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">indexes</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">            <span class="n">models</span><span class="o">.</span><span class="n">Index</span><span class="p">(</span><span class="n">fields</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;-created_at&#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>runtime_version</code> 对应 <code>app.json</code> 里的 <code>runtimeVersion</code>，客户端只会下载和自己运行时版本一致的更新，所以这个字段很关键。iOS 和 Android 的 bundle 不一样，所以分开存。<code>is_active</code> 是回滚开关：把有问题的更新停用，客户端就会退回上一个版本。<code>manifest_data</code> 直接把完整的 Expo Updates v1 协议清单存成 JSON，下发的时候取出来就行。</p>
<p><code>ExpoUpdateAsset</code> 记录每个文件：</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">class</span> <span class="nc">ExpoUpdateAsset</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="nb">id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">UUIDField</span><span class="p">(</span><span class="n">primary_key</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">uuid</span><span class="o">.</span><span class="n">uuid4</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">ForeignKey</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="o">.</span><span class="n">CASCADE</span><span class="p">,</span> <span class="n">related_name</span><span class="o">=</span><span class="s2">&#34;assets&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">hash</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">key</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">content_type</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">100</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_extension</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_path</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">500</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_size</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">IntegerField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span></span></span></code></pre></div></div>
<p>资源用 SHA-256 哈希来引用，天然不可变、好缓存，同一个资源可以被多次更新复用。</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%b8%85%e5%8d%95%e6%8e%a5%e5%8f%a3" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>应用和服务器真正打交道的地方是 <code>/api/expo-updates/manifest/</code>，实现的是 <a href="https://docs.expo.dev/technical-specs/expo-updates-1/"  target="_blank" rel="noreferrer">Expo Updates v1 协议</a>：</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="nd">@action</span><span class="p">(</span><span class="n">detail</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">methods</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;get&#34;</span><span class="p">],</span> <span class="n">url_path</span><span class="o">=</span><span class="s2">&#34;manifest&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">manifest</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Extract required headers</span>
</span></span><span class="line"><span class="cl">    <span class="n">protocol_version</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_PROTOCOL_VERSION&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_PLATFORM&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">runtime_version</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_RUNTIME_VERSION&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Validate protocol version</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">protocol_version</span> <span class="o">!=</span> <span class="s2">&#34;1&#34;</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></span><span class="line"><span class="cl">            <span class="p">{</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;Unsupported protocol version: </span><span class="si">{</span><span class="n">protocol_version</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="n">status</span><span class="o">=</span><span class="mi">400</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="c1"># Find latest active update for this runtime + platform</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">is_active</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span><span class="o">.</span><span class="n">order_by</span><span class="p">(</span><span class="s2">&#34;-created_at&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># No update available - client uses embedded bundle</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">update</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">response</span> <span class="o">=</span> <span class="n">Response</span><span class="p">(</span><span class="n">status</span><span class="o">=</span><span class="mi">204</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">response</span><span class="p">[</span><span class="s2">&#34;expo-protocol-version&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="s2">&#34;1&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">response</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Generate presigned URLs for all assets</span>
</span></span><span class="line"><span class="cl">    <span class="n">manifest_data</span> <span class="o">=</span> <span class="n">build_manifest</span><span class="p">(</span><span class="n">update</span><span class="p">)</span>  <span class="c1"># fills in presigned asset URLs</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">Response</span><span class="p">(</span><span class="n">manifest_data</span><span class="p">,</span> <span class="n">status</span><span class="o">=</span><span class="mi">200</span><span class="p">)</span></span></span></code></pre></div></div>
<p>有三个细节值得注意。没有更新时返回 204，应用会继续用内置的 bundle。清单里的资源地址是预签名 URL，应用直接从 Tigris 的 CDN 下载，不用整个流经 Django。响应头里的 <code>expo-protocol-version</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%8f%91%e5%b8%83%e6%9b%b4%e6%96%b0" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>发布靠一条 Django 管理命令，外面再包一层 shell 脚本。管理命令负责读取 <code>expo export</code> 的输出、给每个资源算 SHA-256 哈希、把 bundle 和资源并行传到 Tigris、写数据库记录，需要的话还会往桶里放一份用于生产同步的导入 JSON。精简到关键部分大致是这样：</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="kn">from</span> <span class="nn">concurrent.futures</span> <span class="kn">import</span> <span class="n">ThreadPoolExecutor</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.db</span> <span class="kn">import</span> <span class="n">transaction</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">publish</span><span class="p">(</span><span class="n">platform</span><span class="p">,</span> <span class="n">runtime_version</span><span class="p">,</span> <span class="n">manifest</span><span class="p">,</span> <span class="n">files</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Upload the bundle and assets in parallel (keys are content hashes)</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">list</span><span class="p">(</span><span class="n">executor</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">upload_asset</span><span class="p">,</span> <span class="n">files</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Swap the active update in one transaction</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">transaction</span><span class="o">.</span><span class="n">atomic</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">is_active</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span><span class="o">.</span><span class="n">update</span><span class="p">(</span><span class="n">is_active</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">create</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">manifest_data</span><span class="o">=</span><span class="n">manifest</span><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-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Publish to local environment</span>
</span></span><span class="line"><span class="cl">./publish.sh ios
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Publish to production</span>
</span></span><span class="line"><span class="cl">./publish.sh ios --production
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Dry run to validate</span>
</span></span><span class="line"><span class="cl">./publish.sh --dry-run</span></span></code></pre></div></div>
<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="#%e5%90%8c%e6%ad%a5%e5%88%b0%e7%94%9f%e4%ba%a7%e7%8e%af%e5%a2%83" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>我不想把生产环境的凭证放在自己笔记本上，所以生产发布分两步：先在本地发布（资源传到 Tigris，同时生成一份元数据的 JSON 快照），再带着 S3 路径调用生产环境的 API，让服务器自己把元数据导进去：</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="nd">@action</span><span class="p">(</span><span class="n">detail</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">methods</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;post&#34;</span><span class="p">],</span> <span class="n">url_path</span><span class="o">=</span><span class="s2">&#34;import-update&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">import_update</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Authenticate via Bearer token</span>
</span></span><span class="line"><span class="cl">    <span class="n">token</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_AUTHORIZATION&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">removeprefix</span><span class="p">(</span><span class="s2">&#34;Bearer &#34;</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">token</span><span class="p">,</span> <span class="n">settings</span><span class="o">.</span><span class="n">IMPORT_TOKEN</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;error&#34;</span><span class="p">:</span> <span class="s2">&#34;Invalid token&#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></span><span class="line"><span class="cl">    <span class="c1"># Download the import JSON from the bucket</span>
</span></span><span class="line"><span class="cl">    <span class="n">s3_key</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;s3_key&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">obj</span> <span class="o">=</span> <span class="n">s3_client</span><span class="o">.</span><span class="n">get_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">s3_key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">data</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">obj</span><span class="p">[</span><span class="s2">&#34;Body&#34;</span><span class="p">]</span><span class="o">.</span><span class="n">read</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Import into the production database</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">transaction</span><span class="o">.</span><span class="n">atomic</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">update</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">update_or_create</span><span class="p">(</span><span class="nb">id</span><span class="o">=</span><span class="n">data</span><span class="p">[</span><span class="s2">&#34;id&#34;</span><span class="p">],</span> <span class="n">defaults</span><span class="o">=</span><span class="p">{</span><span class="o">...</span><span class="p">})</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">asset_data</span> <span class="ow">in</span> <span class="n">data</span><span class="p">[</span><span class="s2">&#34;assets&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">            <span class="n">ExpoUpdateAsset</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">update_or_create</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Clean up the import JSON</span>
</span></span><span class="line"><span class="cl">    <span class="n">s3_client</span><span class="o">.</span><span class="n">delete_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">s3_key</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;update_id&#34;</span><span class="p">:</span> <span class="nb">str</span><span class="p">(</span><span class="n">update</span><span class="o">.</span><span class="n">id</span><span class="p">)})</span></span></span></code></pre></div></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%ae%a9%e5%ba%94%e7%94%a8%e6%8c%87%e5%90%91%e8%87%aa%e5%b7%b1%e7%9a%84%e6%9c%8d%e5%8a%a1%e5%99%a8" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>在 <code>app.json</code> 里配置更新地址和运行时版本：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;expo&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;runtimeVersion&#34;</span><span class="p">:</span> <span class="s2">&#34;1.0.0&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;updates&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;url&#34;</span><span class="p">:</span> <span class="s2">&#34;https://api.example.com/api/expo-updates/manifest/&#34;</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><code>runtimeVersion</code> 必须在应用和服务器之间严格对齐。改了原生代码或者升级了 Expo SDK，就把运行时版本加一，然后针对新版本重新发布更新。</p>

<h2 class="relative group">Tigris 存储
    <div id="tigris-存储" 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="#tigris-%e5%ad%98%e5%82%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Tigris 是 S3 兼容的对象存储，比 AWS S3 便宜得多，还自带全球边缘缓存。考虑到用户要下载好几 MB 的 bundle，这一点很实在。Django 侧的配置：</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="c1"># settings.py</span>
</span></span><span class="line"><span class="cl"><span class="n">BUCKET_NAME</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;BUCKET_NAME&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_ENDPOINT_URL_S3</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;AWS_ENDPOINT_URL_S3&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_ACCESS_KEY_ID</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;AWS_ACCESS_KEY_ID&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_SECRET_ACCESS_KEY</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;AWS_SECRET_ACCESS_KEY&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_REGION</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;AWS_REGION&#34;</span><span class="p">,</span> <span class="s2">&#34;auto&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>客户端就是普通的 boto3：</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="kn">import</span> <span class="nn">boto3</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">create_s3_client</span><span class="p">(</span><span class="n">endpoint_url</span><span class="p">,</span> <span class="n">region</span><span class="p">,</span> <span class="n">access_key</span><span class="p">,</span> <span class="n">secret_key</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">boto3</span><span class="o">.</span><span class="n">client</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;s3&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">endpoint_url</span><span class="o">=</span><span class="n">endpoint_url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">region_name</span><span class="o">=</span><span class="n">region</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">aws_access_key_id</span><span class="o">=</span><span class="n">access_key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">aws_secret_access_key</span><span class="o">=</span><span class="n">secret_key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span></span></span></code></pre></div></div>
<p>下载走预签名 URL，应用直接从 CDN 拉取：</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">presigned_url</span> <span class="o">=</span> <span class="n">s3_client</span><span class="o">.</span><span class="n">generate_presigned_url</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;get_object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">Params</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;Bucket&#34;</span><span class="p">:</span> <span class="n">bucket_name</span><span class="p">,</span> <span class="s2">&#34;Key&#34;</span><span class="p">:</span> <span class="n">asset</span><span class="o">.</span><span class="n">file_path</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="n">ExpiresIn</span><span class="o">=</span><span class="mi">3600</span><span class="p">,</span>  <span class="c1"># 1 hour</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="#%e5%ae%89%e5%85%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>清单接口是故意不做鉴权的，因为应用在用户登录之前就需要拿更新。导入接口则完全不同：它能写生产数据库，所以要求共享密钥，并用常数时间比较来防时序攻击：</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">IMPORT_TOKEN</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;IMPORT_TOKEN&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Constant-time comparison prevents timing attacks</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">token</span><span class="p">,</span> <span class="n">IMPORT_TOKEN</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;error&#34;</span><span class="p">:</span> <span class="s2">&#34;Invalid token&#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></code></pre></div></div>
<p>资源完整性是这套设计白送的：所有资源都按 SHA-256 哈希校验，被篡改的资源直接失败。预签名 URL 一小时过期，bundle 也就不会被长期盗链。</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" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><code>(runtime_version, platform, is_active, -created_at)</code> 这个联合索引保证清单查询不会随着更新数量增长变慢：</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">class</span> <span class="nc">Meta</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">indexes</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="n">models</span><span class="o">.</span><span class="n">Index</span><span class="p">(</span><span class="n">fields</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;-created_at&#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>发布脚本用线程池并行上传资源：</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">with</span> <span class="n">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">futures</span> <span class="o">=</span> <span class="p">{</span><span class="n">executor</span><span class="o">.</span><span class="n">submit</span><span class="p">(</span><span class="n">upload_asset</span><span class="p">,</span> <span class="n">asset</span><span class="p">):</span> <span class="n">asset</span> <span class="k">for</span> <span class="n">asset</span> <span class="ow">in</span> <span class="n">assets</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">future</span> <span class="ow">in</span> <span class="n">as_completed</span><span class="p">(</span><span class="n">futures</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Track progress</span></span></span></code></pre></div></div>
<p>以一次 50 个资源左右的典型更新为例，发布时间从大约 2 分钟缩到了 15 秒。分发这边什么都不用做，Tigris 会自动把资源缓存到离用户近的边缘节点。</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%97%a5%e5%b8%b8%e6%b5%81%e7%a8%8b" 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="#%e5%bc%80%e5%8f%91%e6%97%b6" aria-label="锚点">#</a>
    </span>
    
</h3>
<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="c1"># 1. Make code changes in the app</span>
</span></span><span class="line"><span class="cl">git commit -am <span class="s2">&#34;Fix bug&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Publish OTA update to local environment</span>
</span></span><span class="line"><span class="cl">./publish.sh ios
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. Test on device</span>
</span></span><span class="line"><span class="cl"><span class="c1"># App automatically downloads and applies update</span></span></span></code></pre></div></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%8f%91%e5%b8%83%e5%88%b0%e7%94%9f%e4%ba%a7" aria-label="锚点">#</a>
    </span>
    
</h3>
<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="c1"># 1. Publish to production</span>
</span></span><span class="line"><span class="cl">./publish.sh ios --production
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Monitor</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Check Django admin for update records</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Verify assets in Tigris dashboard</span></span></span></code></pre></div></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%9b%9e%e6%bb%9a" aria-label="锚点">#</a>
    </span>
    
</h3>
<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="c1"># Mark problematic update as inactive in Django admin</span>
</span></span><span class="line"><span class="cl"><span class="c1"># or via management shell:</span>
</span></span><span class="line"><span class="cl">python manage.py shell
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; from updates.models import ExpoUpdate
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; <span class="nv">bad_update</span> <span class="o">=</span> ExpoUpdate.objects.get<span class="o">(</span><span class="nv">id</span><span class="o">=</span><span class="s2">&#34;uuid-here&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; bad_update.is_active <span class="o">=</span> False
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; bad_update.save<span class="o">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Clients will now receive the previous active update</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="#%e6%88%90%e6%9c%ac" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这个应用大约有 500 个活跃用户。Tigris 上，约 200 MB 的历史更新每月大概 0.02 美元，更新下载带来的每月约 50 GB 流出流量大概 1 美元。Django 这边跑在原本就承载 API 的服务器上，没有额外成本。加起来每月一美元左右。同样的用量放到 EAS Update 上，一年大概要 300 到 500 美元，所以这套东西基本上立刻就回本了。</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%9b%91%e6%8e%a7%e4%b8%8e%e8%b0%83%e8%af%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>没什么花哨的。ViewSet 会把每个清单请求记进日志：</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">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Manifest request: platform=</span><span class="si">{</span><span class="n">platform</span><span class="si">}</span><span class="s2">, runtime=</span><span class="si">{</span><span class="n">runtime_version</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Django admin 就是事实上的控制台，把模型注册进去，查看、筛选都很方便：</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="nd">@admin.register</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ExpoUpdateAdmin</span><span class="p">(</span><span class="n">admin</span><span class="o">.</span><span class="n">ModelAdmin</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">list_display</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;created_at&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">list_filter</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;runtime_version&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">search_fields</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;description&#34;</span><span class="p">]</span></span></span></code></pre></div></div>
<p>客户端这边，<code>expo-updates</code> 会告诉你它看到了什么：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="o">*</span> <span class="kr">as</span> <span class="nx">Updates</span> <span class="kr">from</span> <span class="s1">&#39;expo-updates&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">Updates</span><span class="p">.</span><span class="nx">checkForUpdateAsync</span><span class="p">().</span><span class="nx">then</span><span class="p">(</span><span class="nx">update</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;Update available:&#39;</span><span class="p">,</span> <span class="nx">update</span><span class="p">.</span><span class="nx">isAvailable</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;Manifest:&#39;</span><span class="p">,</span> <span class="nx">update</span><span class="p">.</span><span class="nx">manifest</span><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%b8%a9%e8%bf%87%e7%9a%84%e5%9d%91" 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="#%e8%bf%90%e8%a1%8c%e6%97%b6%e7%89%88%e6%9c%ac%e4%b8%8d%e5%8c%b9%e9%85%8d" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>最常见的故障也最安静：客户端只下载和自己运行时版本一致的更新。装机版本还在 <code>1.0.0</code>，你却按 <code>1.0.1</code> 发布，那就什么都收不到，也没有任何报错。运行时版本要和构建保持同步，只在原生代码变化时才递增。</p>

<h3 class="relative group">createdAt 时间戳
    <div id="createdat-时间戳" 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="#createdat-%e6%97%b6%e9%97%b4%e6%88%b3" aria-label="锚点">#</a>
    </span>
    
</h3>
<p><code>expo-updates</code> 客户端会拿清单的 <code>createdAt</code> 和内置 bundle 的 <code>commitTime</code> 比较，只有 <code>createdAt</code> 更新的才会被应用。ViewSet 里用数据库时间戳覆盖它：</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">manifest_data</span><span class="p">[</span><span class="s2">&#34;createdAt&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">update</span><span class="o">.</span><span class="n">created_at</span><span class="o">.</span><span class="n">strftime</span><span class="p">(</span><span class="s2">&#34;%Y-%m-</span><span class="si">%d</span><span class="s2">T%H:%M:%S.</span><span class="si">%f</span><span class="s2">Z&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>本地开发时，如果你在发布 OTA <em>之后</em>又构建了二进制包，就重新发布一次 OTA，保证时间戳更新。</p>

<h3 class="relative group">清单里的资源 <code>key</code>
    <div id="清单里的资源-key" 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%b8%85%e5%8d%95%e9%87%8c%e7%9a%84%e8%b5%84%e6%ba%90-key" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>清单中每个资源的 <code>key</code> 字段是 <code>expo-updates</code> 用来做缓存的，它必须是确定性哈希（比如文件名的 MD5），不能是随机 UUID 或随便一个字符串。搞错了，客户端可能无法正确缓存和读取资源，更新会在第一次成功加载之后悄悄失效。感谢读者 Raphael Mutschler 指出这个问题。在找到原因之前，他的更新恰好只成功过一次。</p>

<h3 class="relative group"><code>expoClient</code> 里的应用配置
    <div id="expoclient-里的应用配置" 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="#expoclient-%e9%87%8c%e7%9a%84%e5%ba%94%e7%94%a8%e9%85%8d%e7%bd%ae" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>如果应用用到 <code>Linking</code>、<code>Constants</code> 或其他在运行时读取应用配置的东西，清单的 <code>extra.expoClient</code> 字段里就必须带上应用配置。少了它，应用第一次可能启动正常，关掉之后再打开就崩溃或干脆打不开。原因是 <code>expo-updates</code> 会用 OTA 的清单替换内置清单，<code>expoClient</code> 一缺，这些 API 依赖的配置就没了。这个坑也是 Raphael Mutschler 发现的。</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%b8%85%e7%90%86%e6%97%a7%e8%b5%84%e6%ba%90" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>旧更新会在 Tigris 里越积越多。目前我还是手动清理：</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="c1"># Delete updates older than 30 days</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">datetime</span> <span class="kn">import</span> <span class="n">timedelta</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.utils</span> <span class="kn">import</span> <span class="n">timezone</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">cutoff</span> <span class="o">=</span> <span class="n">timezone</span><span class="o">.</span><span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">30</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">old_updates</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">created_at__lt</span><span class="o">=</span><span class="n">cutoff</span><span class="p">,</span> <span class="n">is_active</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">update</span> <span class="ow">in</span> <span class="n">old_updates</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Delete assets from S3</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">asset</span> <span class="ow">in</span> <span class="n">update</span><span class="o">.</span><span class="n">assets</span><span class="o">.</span><span class="n">all</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">s3_client</span><span class="o">.</span><span class="n">delete_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">asset</span><span class="o">.</span><span class="n">file_path</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Delete DB records</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span><span class="o">.</span><span class="n">delete</span><span class="p">()</span></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="#%e6%8e%a5%e4%b8%8b%e6%9d%a5%e6%83%b3%e5%8a%a0%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><em>（2026 年 9 月：这三样 Patch 现在都有了，还不止，见<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-13-moving-to-codemagic-patch/" >迁移记录</a>。）</em></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%81%b0%e5%ba%a6%e5%8f%91%e5%b8%83" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>加一个 <code>rollout_percentage</code> 字段，就能先放量给一部分用户：</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">rollout_percentage</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">IntegerField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="mi">100</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In the manifest view:</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">update</span><span class="o">.</span><span class="n">rollout_percentage</span> <span class="o">&lt;</span> <span class="mi">100</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Hash user ID and check if they&#39;re in rollout group</span>
</span></span><span class="line"><span class="cl">    <span class="n">user_hash</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">user_id</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">(),</span> <span class="mi">16</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">user_hash</span> <span class="o">%</span> <span class="mi">100</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="n">update</span><span class="o">.</span><span class="n">rollout_percentage</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="n">status</span><span class="o">=</span><span class="mi">204</span><span class="p">)</span>  <span class="c1"># No update</span></span></span></code></pre></div></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%8c%ba%e5%88%86%e9%a2%84%e5%8f%91%e7%8e%af%e5%a2%83" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>加一个 <code>environment</code> 字段，预发构建和生产就能收到不同的更新：</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">environment</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">20</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="s2">&#34;production&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Client sends environment in custom header</span>
</span></span><span class="line"><span class="cl"><span class="n">environment</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_X_UPDATE_ENVIRONMENT&#34;</span><span class="p">,</span> <span class="s2">&#34;production&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">update</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">environment</span><span class="o">=</span><span class="n">environment</span><span class="p">,</span> <span class="o">...</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">()</span></span></span></code></pre></div></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="#%e4%b8%8b%e8%bd%bd%e7%bb%9f%e8%ae%a1" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>再加一个小模型，就能认真回答“这次更新到底有没有人收到”：</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">class</span> <span class="nc">ExpoUpdateDownload</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">ForeignKey</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="o">.</span><span class="n">CASCADE</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">user_id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span> <span class="n">null</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">downloaded_at</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">DateTimeField</span><span class="p">(</span><span class="n">auto_now_add</span><span class="o">=</span><span class="kc">True</span><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="#%e5%86%99%e5%9c%a8%e6%9c%80%e5%90%8e" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>整套系统就是两个 Django 模型、几个视图、一个发布脚本，外加每月一美元的基础设施。比我动手前预想的要小，而且从那以后一直安安静静地支撑着这个应用的两个平台。</p>
<p>上面的代码是从那个生产应用简化出来的示例，按自己的项目改改就能用。读者 Raphael Mutschler 还发布了一个独立实现 <a href="https://github.com/mutschler/expo-ota-server"  target="_blank" rel="noreferrer">expo-ota-server</a>，想从能直接跑的代码开始的话可以去看看。</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%a6%82%e6%9e%9c%e4%bd%a0%e4%b8%8d%e6%83%b3%e8%87%aa%e5%b7%b1%e5%86%99%e6%9c%8d%e5%8a%a1%e5%99%a8" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>这篇写完之后出现了两样东西，不用 Django 也能覆盖同样的地盘。第一个是上面提到的 Raphael 的 <code>expo-ota-server</code>。第二个是 <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Codemagic Patch</a>：<a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">开源</a>，通过 Docker Compose 安装跑在你自己的机器上，走的是 CodePush 协议而不是 Expo Updates 协议，所以你要把 <code>expo-updates</code> 换成它的 SDK，把 <code>eas update</code> 换成 <code>cmpatch release-react</code>。它能用 S3 兼容存储，所以本文这样的 Tigris 桶也照样能用。</p>
<p>它开箱就带着我在“接下来想加的”里列出来、却一直没做的那些：Staging 和 Production 两套 deployment、按百分比灰度、每个 release 的下载和安装数字、强制更新、二进制 patch、代码签名，以及新 bundle 在应用报告就绪之前崩溃时设备端的回滚。代价是，在任何人收到 Patch 更新之前先要发一个新的原生二进制，因为 SDK 得进到应用里；还有一台要跑的服务器，取代了一条管理命令。</p>
<p>我把自己的一个应用，连同已提交的原生工程和真实的发布流水线，从本文这套方案迁到了 Patch。<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-13-moving-to-codemagic-patch/" >那篇记录</a>写了哪些顺利、哪些不顺，以及到底有多难。Codemagic 赞助了那项工作，并对它做了一轮事实审阅，不是编辑审阅。</p>
<hr>
<p><em>完整规范见 <a href="https://docs.expo.dev/technical-specs/expo-updates-1/"  target="_blank" rel="noreferrer">Expo Updates Protocol Specification</a>。</em></p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-02-22-self-hosted-ota-updates/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>React Native 端侧大模型：用 llama.rn 和 llama.cpp 在手机上离线运行 Qwen</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-01-30-on-device-ai/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-01-30-on-device-ai/</guid>
      <pubDate>Fri, 30 Jan 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>AI</category>
      <category>LLM</category>
      <category>llama.cpp</category>
      <category>Qwen</category>
      <category>On-Device AI</category>
      <category>Privacy</category>
      <category>Mobile Development</category>
      <description>我怎么在 React Native 和 Expo 应用里把一个小型 LLM 完全放在手机上离线运行：llama.rn、Q4_K_M 量化的 Qwen GGUF 模型、流式输出、小模型也能应付的工具调用，以及 iPhone 和 Galaxy S23 上的实测数据。</description>
      <content:encoded><![CDATA[<p>我做的一个报价应用里有个 AI 助手，完全跑在手机上。数据一个字节都不出设备。用户用自然语言就能创建工作单、搜索客户、管理项目，开了飞行模式照样能用。</p>
<p>这篇文章讲讲我是怎么用 <strong>llama.rn</strong>（llama.cpp 的 React Native 绑定）和 <strong>Qwen 1.7B</strong> 把它做出来的。这个尺寸的模型，能干的活比我预想的多得多。</p>
<p>简单来说：装上 llama.rn，应用第一次打开时下载一个 4 位量化的 GGUF 模型（1 GB 左右），用 <code>initLlama</code> 加载，然后流式生成就行。在 iPhone 14 Pro 上，完全不联网也能跑到每秒 15 个 token 左右。这篇剩下的内容，讲的是让它真正派上用场花了哪些功夫：选模型，让它稳定地吐出工具调用，还有把内存控制住。</p>
<blockquote><p><strong>📝 更新：</strong> 现在的实现已经换成 <a href="https://huggingface.co/unsloth/Qwen3.5-2B-GGUF"  target="_blank" rel="noreferrer">Qwen3.5-2B-GGUF</a>，替代了最初的 Qwen3-1.7B。另外我也不再用 <code>/no_think</code> 消息这种土办法，改用 <code>chat_template_kwargs</code> 参数正式关闭思考模式：</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">chat_response</span> <span class="o">=</span> <span class="n">client</span><span class="o">.</span><span class="n">chat</span><span class="o">.</span><span class="n">completions</span><span class="o">.</span><span class="n">create</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="n">model</span><span class="o">=</span><span class="s2">&#34;Qwen/Qwen3.5-27B&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">messages</span><span class="o">=</span><span class="n">messages</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">max_tokens</span><span class="o">=</span><span class="mi">32768</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">temperature</span><span class="o">=</span><span class="mf">0.7</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">top_p</span><span class="o">=</span><span class="mf">0.8</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">presence_penalty</span><span class="o">=</span><span class="mf">1.5</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">extra_body</span><span class="o">=</span><span class="p">{</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;top_k&#34;</span><span class="p">:</span> <span class="mi">20</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;chat_template_kwargs&#34;</span><span class="p">:</span> <span class="p">{</span><span class="s2">&#34;enable_thinking&#34;</span><span class="p">:</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></code></pre></div></div>
<p>比在提示词里塞开关干净多了。</p>
</blockquote>
<h2 class="relative group">我为什么不想用云端 AI
    <div id="我为什么不想用云端-ai" 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%ba%e4%bb%80%e4%b9%88%e4%b8%8d%e6%83%b3%e7%94%a8%e4%ba%91%e7%ab%af-ai" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>移动应用里的 AI 功能，套路基本一样：用户输入消息，应用发给云端的模型服务商，响应回来，账单往上涨。</p>
<p>但这是一个施工商家拿来做生意的应用。照这个套路走，客户的姓名、地址、电话就交给了第三方，项目细节、报价、备注躺在别人的服务器上，还得操心 GDPR 和数据驻留这些我根本不想碰的问题。API 账单也随着用户数一路上涨。</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="#%e4%b8%ba%e4%bb%80%e4%b9%88%e7%8e%b0%e5%9c%a8%e5%81%9a%e5%be%97%e6%88%90%e4%ba%86" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>放在一年前我不会碰这个，但有三件事几乎同时起了变化。一是量化模型变小了：Q4_K_M 量化后的 Qwen 1.7B 只有 1.1 GB，比大多数游戏还小，下载一次就常驻应用存储。二是手机 GPU 够快了：llama.cpp 在 iOS 上走 Metal，在搭载较新高通 GPU 的 Android 手机上走 OpenCL，iPhone 14 Pro 能跑到每秒 15 个 token 左右，实时流式输出绰绰有余。三是小模型真的能用了：Qwen 1.7B 能遵循结构化指令、解析 JSON、串起多步推理。业务逻辑要的正是这些能力，又没人指望它写诗。</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%80%89%e6%a8%a1%e5%9e%8b" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>定下 Qwen3-1.7B 之前，我试了四个小模型。</p>
<p><strong>TinyLlama 1.1B</strong>（637 MB）最快，但结构化输出始终不稳，动不动就编造客户 ID、漏掉必填字段。<strong>Phi-3-mini</strong>（1.8 GB）推理不错，可惜话太多，20 个字能说清的事非要写 200 字的小作文。<strong>Gemma-2B</strong>（1.2 GB）做分类又快又准，但函数调用不行，我的工具系统需要的 <code>&lt;action&gt;</code> 标签它输出不稳定。<strong>Qwen3-1.7B</strong>（1.1 GB）正好卡在甜点位：结构化输出可靠，指令遵循精确，还支持用 <code>&lt;think&gt;</code> 标签做思维链。</p>
<p>Q4_K_M 是 llama.cpp 的 k-quant 量化格式之一：权重按块存成 4 位数值，每一块带着自己的缩放系数，M 这一档还会把少数几个敏感的张量保留在 6 位。这样文件比 16 位权重小约 70%，质量只损失一点点。</p>

<h2 class="relative group">配置 llama.rn
    <div id="配置-llamarn" 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%85%8d%e7%bd%ae-llamarn" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>llama.rn 把 llama.cpp 封装给 React Native 用，安装是整件事里最简单的部分：</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">npm install llama.rn
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> ios <span class="o">&amp;&amp;</span> pod install</span></span></code></pre></div></div>
<p>用 Expo 的话，llama.rn 是原生模块，在 Expo Go 里跑不起来。要加上它的 config plugin，改用 development build：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="cl"><span class="c1">// app.config.js
</span></span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="k">default</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">expo</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">plugins</span><span class="o">:</span> <span class="p">[</span><span class="s2">&#34;llama.rn&#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>
<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">npx expo install llama.rn
</span></span><span class="line"><span class="cl">npx expo run:ios</span></span></code></pre></div></div>
<p>打生产包时，这个插件还会顺手加上 llama.rn 为大模型推荐的两项 iOS entitlement（Extended Virtual Addressing 和 Increased Memory Limit，也就是扩展虚拟寻址和提高内存上限）。</p>
<p>模型本体在首次使用时下载：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">MODEL_URL</span> <span class="o">=</span> <span class="s2">&#34;https://huggingface.co/unsloth/Qwen3-1.7B-GGUF/resolve/main/Qwen3-1.7B-Q4_K_M.gguf&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">MODEL_PATH</span> <span class="o">=</span> <span class="nx">FileSystem</span><span class="p">.</span><span class="nx">documentDirectory</span> <span class="o">+</span> <span class="s2">&#34;llama-models/Qwen3-1.7B-Q4_K_M.gguf&#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">downloadModel</span> <span class="o">=</span> <span class="kr">async</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">downloadResumable</span> <span class="o">=</span> <span class="nx">FileSystem</span><span class="p">.</span><span class="nx">createDownloadResumable</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="nx">MODEL_URL</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">MODEL_PATH</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 class="nx">progress</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="kr">const</span> <span class="nx">pct</span> <span class="o">=</span> <span class="nx">progress</span><span class="p">.</span><span class="nx">totalBytesWritten</span> <span class="o">/</span> <span class="nx">progress</span><span class="p">.</span><span class="nx">totalBytesExpectedToWrite</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">      <span class="nx">setDownloadProgress</span><span class="p">(</span><span class="nx">pct</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="k">await</span> <span class="nx">downloadResumable</span><span class="p">.</span><span class="nx">downloadAsync</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="p">};</span></span></span></code></pre></div></div>
<p>WiFi 下两三分钟，LTE 大概五到八分钟。下载完成后，开着 GPU 加速加载：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">ctx</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">initLlama</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">model</span>: <span class="kt">MODEL_PATH</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">n_ctx</span>: <span class="kt">8192</span><span class="p">,</span>      <span class="c1">// 8K context window
</span></span></span><span class="line"><span class="cl">  <span class="nx">n_gpu_layers</span>: <span class="kt">99</span><span class="p">,</span> <span class="c1">// Use GPU for all layers
</span></span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>
<p><code>n_gpu_layers: 99</code> 千万别省。把计算从 CPU 挪到 Metal 或 OpenCL 上，速度能提 5 倍左右。</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%b5%81%e5%bc%8f%e6%8e%a8%e7%90%86" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>没人愿意盯着加载动画看十秒钟，所以响应按 token 逐个流出：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kd">let</span> <span class="nx">fullResponse</span> <span class="o">=</span> <span class="s2">&#34;&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">await</span> <span class="nx">llamaContext</span><span class="p">.</span><span class="nx">completion</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="nx">messages</span><span class="o">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span> <span class="nx">role</span><span class="o">:</span> <span class="s2">&#34;system&#34;</span><span class="p">,</span> <span class="nx">content</span>: <span class="kt">systemPrompt</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">      <span class="p">{</span> <span class="nx">role</span><span class="o">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="nx">content</span><span class="o">:</span> <span class="s2">&#34;Create a job for John Smith&#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="nx">n_predict</span>: <span class="kt">512</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">temperature</span>: <span class="kt">0.7</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">top_p</span>: <span class="kt">0.8</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 class="nx">data</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="c1">// Called for each token
</span></span></span><span class="line"><span class="cl">    <span class="nx">fullResponse</span> <span class="o">+=</span> <span class="nx">data</span><span class="p">.</span><span class="nx">token</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="nx">setStreamingText</span><span class="p">(</span><span class="nx">fullResponse</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>在近几年的手机上，首个 token 约 200ms（这是提示词处理的时间），之后每个 token 50-80ms。跟走网络的 API 一比，体感就是即时的。</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%b0%8f%e6%a8%a1%e5%9e%8b%e4%b9%9f%e7%8e%a9%e5%be%97%e8%bd%ac%e7%9a%84%e5%87%bd%e6%95%b0%e8%b0%83%e7%94%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>大型云端模型那套函数调用靠 JSON schema，小模型在这里会栽跟头：JSON 格式坏掉、必填字段丢失。所以我用了个更简单的 XML 协议：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">systemPrompt</span> <span class="o">=</span> <span class="sb">`You are an AI assistant. When you need to take an action, output:
</span></span></span><span class="line"><span class="cl"><span class="sb">&lt;action&gt;{&#34;type&#34;:&#34;search_customers&#34;,&#34;query&#34;:&#34;Smith&#34;}&lt;/action&gt;
</span></span></span><span class="line"><span class="cl"><span class="sb">
</span></span></span><span class="line"><span class="cl"><span class="sb">Available actions:
</span></span></span><span class="line"><span class="cl"><span class="sb">- search_customers: {&#34;type&#34;:&#34;search_customers&#34;,&#34;query&#34;:&#34;John&#34;}
</span></span></span><span class="line"><span class="cl"><span class="sb">- create_job: {&#34;type&#34;:&#34;create_job&#34;,&#34;customer_id&#34;:5,&#34;alias&#34;:&#34;Living Room&#34;}
</span></span></span><span class="line"><span class="cl"><span class="sb">- update_job: {&#34;type&#34;:&#34;update_job&#34;,&#34;job_id&#34;:&#34;J-0042&#34;,&#34;status&#34;:&#34;quoting&#34;}
</span></span></span><span class="line"><span class="cl"><span class="sb">
</span></span></span><span class="line"><span class="cl"><span class="sb">Rules:
</span></span></span><span class="line"><span class="cl"><span class="sb">1. ONE action tag per response, at the very end
</span></span></span><span class="line"><span class="cl"><span class="sb">2. When searching/creating → end with &lt;action&gt;
</span></span></span><span class="line"><span class="cl"><span class="sb">3. When just chatting → no action tag
</span></span></span><span class="line"><span class="cl"><span class="sb">`</span><span class="p">;</span></span></span></code></pre></div></div>
<p>选 XML 标签的理由都很朴素：<code>&lt;action&gt;</code> 和 <code>&lt;/action&gt;</code> 的起止毫不含糊，解析只要一条正则，提示词里的示例本身就是格式说明，而且就算模型在标签前后多说几句废话也不影响。</p>
<p>解析代码简单到不好意思展示：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kd">function</span> <span class="nx">parseAction</span><span class="p">(</span><span class="nx">text</span>: <span class="kt">string</span><span class="p">)</span><span class="o">:</span> <span class="nx">Action</span> <span class="o">|</span> <span class="kc">null</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">match</span> <span class="o">=</span> <span class="nx">text</span><span class="p">.</span><span class="nx">match</span><span class="p">(</span><span class="sr">/&lt;action&gt;([\s\S]*?)&lt;\/action&gt;/</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">match</span><span class="p">)</span> <span class="k">return</span> <span class="kc">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</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="nx">JSON</span><span class="p">.</span><span class="nx">parse</span><span class="p">(</span><span class="nx">match</span><span class="p">[</span><span class="mi">1</span><span class="p">].</span><span class="nx">trim</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">null</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>解析出 action 就执行，再把结果塞回对话里：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">action</span> <span class="o">=</span> <span class="nx">parseAction</span><span class="p">(</span><span class="nx">modelResponse</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">action</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">executeAction</span><span class="p">(</span><span class="nx">action</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="c1">// Inject result as a system message
</span></span></span><span class="line"><span class="cl">  <span class="nx">conversationHistory</span><span class="p">.</span><span class="nx">push</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">    <span class="nx">role</span><span class="o">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nx">content</span><span class="o">:</span> <span class="sb">`[TOOL_RESULT] </span><span class="si">${</span><span class="nx">result</span><span class="p">.</span><span class="nx">message</span><span class="si">}</span><span class="err">\</span><span class="sb">n</span><span class="err">\</span><span class="sb">n→ NEXT: Tell the user what happened.`</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="c1">// Continue generation
</span></span></span><span class="line"><span class="cl">  <span class="k">await</span> <span class="nx">generateNextResponse</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>就这么点东西，多步工作流已经能顺畅跑起来了：</p>
<p><strong>用户</strong>：&ldquo;Create a job for Smith&rdquo;</p>
<ol>
<li><strong>模型</strong>：<code>&lt;action&gt;{&quot;type&quot;:&quot;search_customers&quot;,&quot;query&quot;:&quot;Smith&quot;}&lt;/action&gt;</code></li>
<li><strong>系统</strong>：<code>[TOOL_RESULT] Found: Jane Smith (id:42), Bob Smith (id:89)</code></li>
<li><strong>模型</strong>：&ldquo;找到两位 Smith：Jane 和 Bob。您要哪一位？&rdquo;</li>
<li><strong>用户</strong>：&ldquo;Jane&rdquo;</li>
<li><strong>模型</strong>：<code>&lt;action&gt;{&quot;type&quot;:&quot;create_job&quot;,&quot;customer_id&quot;:42}&lt;/action&gt;</code></li>
<li><strong>系统</strong>：<code>[TOOL_RESULT] Job J-0073 created</code></li>
<li><strong>模型</strong>：&ldquo;完成！已为 Jane Smith 创建工作单 J-0073。&rdquo;</li>
</ol>

<h2 class="relative group">用 &lt;think&gt; 标签做思维链
    <div id="用-think-标签做思维链" 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-think-%e6%a0%87%e7%ad%be%e5%81%9a%e6%80%9d%e7%bb%b4%e9%93%be" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>让小模型把推理&quot;说出来&quot;，稳定性会肉眼可见地提高。Qwen 自带思考模式：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">systemPrompt</span> <span class="o">=</span> <span class="sb">`Before each response, wrap your reasoning in &lt;think&gt; tags:
</span></span></span><span class="line"><span class="cl"><span class="sb">
</span></span></span><span class="line"><span class="cl"><span class="sb">&lt;think&gt;
</span></span></span><span class="line"><span class="cl"><span class="sb">INTENT: what does the user want?
</span></span></span><span class="line"><span class="cl"><span class="sb">HAVE: what data do I already have?
</span></span></span><span class="line"><span class="cl"><span class="sb">NEED: what&#39;s still missing?
</span></span></span><span class="line"><span class="cl"><span class="sb">DECISION: call tool | ask user | just respond
</span></span></span><span class="line"><span class="cl"><span class="sb">&lt;/think&gt;
</span></span></span><span class="line"><span class="cl"><span class="sb">
</span></span></span><span class="line"><span class="cl"><span class="sb">Then output your actual response.`</span><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">&lt;think&gt;
</span></span><span class="line"><span class="cl">INTENT: create a job
</span></span><span class="line"><span class="cl">HAVE: customer query &#34;Smith&#34;
</span></span><span class="line"><span class="cl">NEED: exact customer_id
</span></span><span class="line"><span class="cl">DECISION: search first
</span></span><span class="line"><span class="cl">&lt;/think&gt;
</span></span><span class="line"><span class="cl">&lt;action&gt;{&#34;type&#34;:&#34;search_customers&#34;,&#34;query&#34;:&#34;Smith&#34;}&lt;/action&gt;</span></span></code></pre></div></div>
<p><code>&lt;think&gt;</code> 标签在进 UI 之前会被剥掉，开发阶段我会留着它们调试。效果非常明显：模型会先把逻辑捋顺，再决定动手。</p>

<h2 class="relative group">把长对话塞进 8K 上下文
    <div id="把长对话塞进-8k-上下文" 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%8a%e9%95%bf%e5%af%b9%e8%af%9d%e5%a1%9e%e8%bf%9b-8k-%e4%b8%8a%e4%b8%8b%e6%96%87" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>工具结果一多，8K 上下文窗口比想象中满得快。我这边大约 30 轮就顶到上限了。</p>
<p>我的办法是对话压缩，让模型自己总结自己：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">compactPrompt</span> <span class="o">=</span> <span class="sb">`Summarize this conversation in under 200 words.
</span></span></span><span class="line"><span class="cl"><span class="sb">Include: user&#39;s goal, customers/jobs created (with IDs), and any unfinished tasks.
</span></span></span><span class="line"><span class="cl"><span class="sb">
</span></span></span><span class="line"><span class="cl"><span class="si">${</span><span class="nx">conversationHistory</span><span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">m</span> <span class="o">=&gt;</span> <span class="sb">`</span><span class="si">${</span><span class="nx">m</span><span class="p">.</span><span class="nx">role</span><span class="si">}</span><span class="sb">: </span><span class="si">${</span><span class="nx">m</span><span class="p">.</span><span class="nx">content</span><span class="si">}</span><span class="sb">`</span><span class="p">).</span><span class="nx">join</span><span class="p">(</span><span class="s1">&#39;\n\n&#39;</span><span class="p">)</span><span class="si">}</span><span class="sb">`</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">summary</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">chat</span><span class="p">([{</span> <span class="nx">role</span><span class="o">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="nx">content</span>: <span class="kt">compactPrompt</span> <span class="p">}]);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// Replace history with summary
</span></span></span><span class="line"><span class="cl"><span class="nx">conversationHistory</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">  <span class="p">{</span> <span class="nx">role</span><span class="o">:</span> <span class="s2">&#34;user&#34;</span><span class="p">,</span> <span class="nx">content</span><span class="o">:</span> <span class="sb">`[CONTEXT SUMMARY]</span><span class="err">\</span><span class="sb">n</span><span class="si">${</span><span class="nx">summary</span><span class="si">}</span><span class="err">\</span><span class="sb">n[END SUMMARY]`</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">];</span></span></span></code></pre></div></div>
<p>50 条消息的对话能压到 150 个 token 左右，模型接着聊，也不会忘记之前建了哪些工作单、客户 ID 是多少。</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="#%e4%bc%9a%e8%af%9d%e4%bf%9d%e5%ad%98" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>聊天会话也会存到应用的后端。值得借鉴的一点是：每条助手消息存两份，一份清理过、用于显示，一份保留 <code>&lt;think&gt;</code> 和 <code>&lt;action&gt;</code> 标签的原文。这样聊天记录在界面上回放时干干净净，恢复会话时又能拿回包含 action 在内的完整上下文。用户可以像用一般的聊天应用那样，在历史下拉菜单里切换对话。</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%94%9f%e4%ba%a7%e7%8e%af%e5%a2%83%e7%9a%84%e6%80%a7%e8%83%bd%e6%95%b0%e6%8d%ae" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><strong>iPhone 14 Pro</strong>（A16 Bionic，6GB RAM）：</p>
<ul>
<li>模型加载：约 3 秒</li>
<li>首个 token：180-220ms</li>
<li>流式输出：14-16 tokens/秒</li>
<li>内存：约 1.8 GB</li>
</ul>
<p><strong>Samsung Galaxy S23</strong>（Snapdragon 8 Gen 2，8GB RAM）：</p>
<ul>
<li>模型加载：约 4 秒</li>
<li>首个 token：250-300ms</li>
<li>流式输出：10-12 tokens/秒</li>
<li>内存：约 2.1 GB</li>
</ul>
<p><strong>iPhone 11</strong>（A13，4GB RAM）：</p>
<ul>
<li>模型加载：约 6 秒</li>
<li>首个 token：400-500ms</li>
<li>流式输出：6-8 tokens/秒</li>
<li>内存：约 2.2 GB（内存吃紧时偶尔崩溃）</li>
</ul>
<p>我的经验值：想用得顺畅，RAM 至少 6GB。4GB 也能跑，但应用退到后台时多半得把模型卸载掉。</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%e7%ae%a1%e7%90%86" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>应用占着太多内存，iOS 会毫不客气地把它杀掉，所以一离开前台我就释放模型：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="nx">useEffect</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">const</span> <span class="nx">subscription</span> <span class="o">=</span> <span class="nx">AppState</span><span class="p">.</span><span class="nx">addEventListener</span><span class="p">(</span><span class="s2">&#34;change&#34;</span><span class="p">,</span> <span class="p">(</span><span class="nx">nextAppState</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="nx">nextAppState</span> <span class="o">===</span> <span class="s2">&#34;background&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">llamaContext</span><span class="o">?</span><span class="p">.</span><span class="nx">release</span><span class="p">();</span> <span class="c1">// Free ~2 GB
</span></span></span><span class="line"><span class="cl">    <span class="p">}</span> <span class="k">else</span> <span class="k">if</span> <span class="p">(</span><span class="nx">nextAppState</span> <span class="o">===</span> <span class="s2">&#34;active&#34;</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nx">loadModel</span><span class="p">();</span> <span class="c1">// Reload when foregrounded
</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="k">return</span> <span class="p">()</span> <span class="o">=&gt;</span> <span class="nx">subscription</span><span class="p">.</span><span class="nx">remove</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="p">},</span> <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="#%e7%94%b5%e9%87%8f%e6%b6%88%e8%80%97" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>端侧推理在电量上不是免费的。实测数据：</p>
<table>
	<thead>
			<tr>
					<th>使用模式</th>
					<th>额外电池消耗</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>轻度（每天 5-10 次查询）</td>
					<td>每天 &lt;1%</td>
			</tr>
			<tr>
					<td>中度（每天 20-30 次查询）</td>
					<td>每天约 3-4%</td>
			</tr>
			<tr>
					<td>重度（每天 50+ 次查询）</td>
					<td>每天约 6-8%</td>
			</tr>
	</tbody>
</table>
<p>GPU 推理比 CPU 费电，但快得多。用户明显更喜欢秒回，所以我保留了 GPU。</p>

<h2 class="relative group">上线两个月，约 200 名测试用户
    <div id="上线两个月约-200-名测试用户" 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%8a%e7%ba%bf%e4%b8%a4%e4%b8%aa%e6%9c%88%e7%ba%a6-200-%e5%90%8d%e6%b5%8b%e8%af%95%e7%94%a8%e6%88%b7" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>查询的分布有点出乎意料：</p>
<ol>
<li>&ldquo;Create a job for [customer name]&rdquo; - 占查询的 68%</li>
<li>&ldquo;Find jobs for [customer]&rdquo; - 15%</li>
<li>&ldquo;Update job [ID] to [status]&rdquo; - 9%</li>
<li>关于应用本身的问题 - 8%</li>
</ol>
<p>91% 的查询一次就成功。失败的原因分三类：用户把客户名字拼错导致搜不到（4%），超长对话撑爆上下文（3%），模型自己编客户 ID（2%）。</p>
<p>最后这个编 ID 的毛病以前严重得多。后来我把工具结果从叙述句改成 <code>customer_id=42</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%88%90%e6%9c%ac%e8%b4%a6" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>端侧方案：基础设施 $0/月，除了首次那 1.1 GB 下载，每个用户的成本是 $0，用户再多也是这个数。</p>
<p>云端方案粗算一下：平均一次对话约 2,000 个 token，200 个用户每月 30 次对话就是 6,000 次，也就是每月 1200 万 token。便宜的云端模型大约 $18/月，顶级模型则要 $180 到 $360/月。这个规模不至于伤筋动骨，但端侧方案第一天就回了本，哪怕涨到一万用户，还是 $0。</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%be%97%e5%bf%83%e9%87%8c%e6%9c%89%e6%95%b0%e7%9a%84%e5%87%a0%e4%b8%aa%e9%99%90%e5%88%b6" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><strong>模型能力。</strong> Qwen 1.7B 擅长结构化任务，但多跳的复杂推理、事实性知识（它不是搜索引擎）、创意写作、语言里的微妙之处，它都不行。围绕小模型擅长的事来设计功能，就不会失望。</p>
<p><strong>设备要求。</strong> 底线是 3 GB RAM 加 2 GB 可用存储；实际上你需要 6 GB RAM、64 位处理器和 GPU 支持。iPhone 8、Galaxy S9 这类老设备很吃力，记得做特性检测和体面的降级。</p>
<p><strong>模型更新。</strong> 换模型意味着用户要重新下载 1.1 GB。我把版本号写进了存储路径：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">MODEL_PATH</span> <span class="o">=</span> <span class="sb">`</span><span class="si">${</span><span class="nx">FileSystem</span><span class="p">.</span><span class="nx">documentDirectory</span><span class="si">}</span><span class="sb">llama-models/v2/Qwen3-1.7B-Q4_K_M.gguf`</span><span class="p">;</span></span></span></code></pre></div></div>
<p>发新模型时把路径版本号加一就行，旧模型在应用卸载时自动清掉。</p>

<h2 class="relative group">iOS 和 Android 的差异
    <div id="ios-和-android-的差异" 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="#ios-%e5%92%8c-android-%e7%9a%84%e5%b7%ae%e5%bc%82" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>走 Metal 的 iOS 比 Android 快 20% 左右，内存管理也更好，模型默认会备份到 iCloud（不想占用户备份空间的话，用 <code>NSURLIsExcludedFromBackupKey</code> 排除掉）。Android 机型间的差异则大得多：llama.rn 的 GPU 路径是高通 Adreno 700 系列 GPU 上的 OpenCL，没有这类 GPU 的手机就只能跑在 CPU 上；存储也不会自动备份。两个平台都得测。好安卓机和差安卓机之间的差距，比两个平台之间的差距还大。</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%b0%83%e8%af%95%e6%8a%80%e5%b7%a7" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><strong>1. 打开详细日志</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">ctx</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">initLlama</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">model</span>: <span class="kt">MODEL_PATH</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">n_ctx</span>: <span class="kt">8192</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">n_gpu_layers</span>: <span class="kt">99</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">verbose</span>: <span class="kt">true</span><span class="p">,</span> <span class="c1">// Logs every token + timings
</span></span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>
<p><strong>2. 跟踪 token 数量</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">tokens</span> <span class="o">=</span> <span class="nx">fullResponse</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="sr">/\s+/</span><span class="p">).</span><span class="nx">length</span> <span class="o">*</span> <span class="mf">1.3</span><span class="p">;</span> <span class="c1">// Rough estimate
</span></span></span><span class="line"><span class="cl"><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="sb">`Generated </span><span class="si">${</span><span class="nx">tokens</span><span class="si">}</span><span class="sb"> tokens in </span><span class="si">${</span><span class="nx">duration</span><span class="si">}</span><span class="sb">ms`</span><span class="p">);</span></span></span></code></pre></div></div>
<p><strong>3. 监控内存</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">MemoryInfo</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;react-native-device-info&#39;</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">memoryUsage</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">MemoryInfo</span><span class="p">.</span><span class="nx">getUsedMemory</span><span class="p">();</span>
</span></span><span class="line"><span class="cl"><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="sb">`Memory: </span><span class="si">${</span><span class="p">(</span><span class="nx">memoryUsage</span> <span class="o">/</span> <span class="mi">1024</span> <span class="o">/</span> <span class="mi">1024</span><span class="p">).</span><span class="nx">toFixed</span><span class="p">(</span><span class="mi">0</span><span class="p">)</span><span class="si">}</span><span class="sb"> MB`</span><span class="p">);</span></span></span></code></pre></div></div>
<p><strong>4. 离线测试</strong></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%8e%a5%e4%b8%8b%e6%9d%a5%e6%83%b3%e5%81%9a%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>llama.cpp 已经支持视觉模型，&ldquo;这是现场的照片，加到工作单里&quot;这种用法跟这个应用简直是天作之合。我还想拿过去的工作单描述和客户对话微调一个 LoRA 适配器，估计领域内查询的准确率能涨 20-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%80%bc%e4%b8%8d%e5%80%bc%e5%be%97%e5%81%9a" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>如果你在做处理敏感数据的业务应用，走离线优先的工作流，主要任务是分类、录入、搜索这类结构化工作，尤其是担心规模上去之后的 API 账单，那答案是值得，今天就能动手。</p>
<p>如果你要的是开放式聊天，用大型云端模型。知识密集型任务（小模型真的没什么见识）、必须支持低端设备、领域知识变化快过你更新模型的速度，这几种情况也一样。</p>
<p>整个实现花了我 12 个小时左右。每月运行成本 $0，飞机上照常工作，客户数据从头到尾没离开过客户的手机。挑不出毛病。</p>
<hr>
<p><em>有问题或想聊聊？我是 <a href="https://twitter.com/jaredlynskey"  target="_blank" rel="noreferrer">@jaredlynskey</a>。llama.rn 库在 <a href="https://github.com/mybigday/llama.rn"  target="_blank" rel="noreferrer">github.com/mybigday/llama.rn</a>。</em></p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-01-30-on-device-ai/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
