<?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/ja/tags/react-native/</link>
    <description>ソフトウェアエンジニアリングと開発組織のリーダーシップについて。評価、採用とチームの拡大、CI/CD、DevOps モニタリング、エージェンティック AI。ソウルより。</description>
    <generator>Hugo</generator>
    <language>ja</language>
    <copyright>© 2026 仁才徳</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ja/tags/react-native/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Sentryをセルフホストしない選択：SDKはそのまま、React Nativeのクラッシュレポートは自前サーバーで</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-10-09-sentry-sdk-own-server/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/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の費用もかけずに、React Nativeアプリのクラッシュレポートを受ける方法です。オープンソースのSDKはそのままに、DSNを自前のDjango APIに向け、グルーピング、アラート、データの除去まで自分で行います。</description>
      <content:encoded><![CDATA[<p>React Nativeアプリのクラッシュレポートを自分で受けたいなら、Sentryのサーバーをセルフホストする必要も、sentry.ioに費用を払う必要もありません。SentryのSDKはオープンソースで、DSNはどのホストにも向けられます。私は自前のDjango APIのエンドポイントに向け、そこでレポートを受け取り、グルーピングし、アラートを出し、保存しています。費用は、もともと動いていたAPIの分だけです。まだ置き換えられていないのはシンボリケーションで、スタックトレースからはソースの行まではたどれません。</p>
<p>今週まで、うちのアプリがクラッシュしたことを知る手段は、誰かが教えてくれることだけでした。ほとんどの人は教えてくれません。アプリを開き直し、また落ちたら、もう開かなくなるだけです。API側は把握できていました（ERRORログはすべてチームのチャットチャンネルに流れます）。でも、スマートフォン上でのクラッシュは、私に見える場所には何の痕跡も残していませんでした。</p>
<p>実は、アプリにはすでに報告の仕組みがひとつありました。アプリが誰かの前にエラーメッセージを表示すると、そのメッセージをAPIの小さなエンドポイントに送り、サーバーがERRORでログに残すので、これもチャットチャンネルに届きます。きっかけは、削除が <em>成功していた</em> のに行がリストに残り、もう一度タップすると「No Item matches the given query」と表示された件でした。サーバーはごく普通の404を2回記録しただけで、何も言いませんでした。この仕組みでカバーできるのは、アプリが捕まえて表示したエラーです。クラッシュは事情が違います。報告を送るはずのJavaScriptそのものが、たった今死んだのですから。</p>
<p>素直な解決策はSentryです。SentryのSDKはとても気に入っていて、React Nativeのクラッシュレポートが「解決済みの問題」になっているのはこのSDKのおかげです。ただ、ユーザーデータを預ける業者がまたひとつ増え、プライバシーポリシーの行がまたひとつ増え、うちの規模なら多くても1日数件のレポートのためにまた請求書が増えるのは避けたいところでした。そこで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%e3%81%ae%e3%81%8b%e3%82%89%e3%81%8f%e3%82%8a" 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に変換し、キーは <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>キーはすべてのバイナリに含まれるので、秘密ではありません。キーがある理由は二つで、エンドポイントにゴミが集まり始めたら変更できるようにするため、そしてサーバー側でキーが未設定なら、何でも受け入れるのではなくすべて拒否するためです。</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が送ってくるもの：エンベロープ
    <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="#sdk%e3%81%8c%e9%80%81%e3%81%a3%e3%81%a6%e3%81%8f%e3%82%8b%e3%82%82%e3%81%ae%e3%82%a8%e3%83%b3%e3%83%99%e3%83%ad%e3%83%bc%e3%83%97" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Sentryの通信フォーマットは<a href="https://develop.sentry.dev/sdk/data-model/envelopes/"  target="_blank" rel="noreferrer">エンベロープ</a>です。JSONのヘッダー行が1行あり、その後に任意の数のアイテムが続きます。各アイテムはJSONのヘッダー行とペイロードの組です。アイテムヘッダーに <code>length</code> があればペイロードはちょうどそのバイト数、なければ次の改行までです。</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のSDKはしません。</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>長さ指定のペイロードは改行を含むことがあります。</strong> <code>\n</code> で分割すると、ネイティブSDKが送るイベントがすべて壊れます。<code>length</code> があるときは必ずそれに従う必要があります。</li>
</ul>
<p>パーサーは短いものです。残すのは <code>event</code> アイテムだけで、セッション、トランザクション、添付ファイル、クライアントレポートは受け取ったうえで捨てます。</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="#sdk%e3%81%8c%e7%90%86%e8%a7%a3%e3%81%a7%e3%81%8d%e3%82%8b%e5%bd%a2%e3%81%a7%e5%bf%9c%e7%ad%94%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>これは思っていたより大事でした。SDKはディスク上にキューを持ち、届いたか確信が持てないものはすべて再送します。なので、サーバーの応答次第で、SDKが送り続けるかどうかが決まります。</p>
<ul>
<li><strong>200</strong>：保存できたとき、 <em>または意図的に捨てたとき</em> 。セッションアイテムに4xxを返すと、SDKはそれを保持したまま永遠に再送します。</li>
<li><strong>429と <code>Retry-After</code></strong> ：スロットリングしたとき。エンドポイントにはIPごとの制限（1時間に120件）があります。クラッシュループに入ったスマートフォンは起動のたびに1件送ってくるからです。SDKは429を受けると、期待どおりに間隔を空けてくれます。</li>
<li><strong>400</strong>：壊れたエンベロープ。ログはエラーではなく警告にしています。壊れたレポートが教えてくれるのは「どこかのクライアントがおかしい」ということで、次のまともなレポートが何がクラッシュしたかを教えてくれます。</li>
</ul>
<p>SDKは最初の送信が届いたか確信が持てないと同じイベントを再送するので、<code>event_id</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="#%e3%82%bd%e3%83%bc%e3%82%b9%e3%83%9e%e3%83%83%e3%83%97%e3%81%aa%e3%81%97%e3%81%a7%e3%81%ae%e3%82%b0%e3%83%ab%e3%83%bc%e3%83%94%e3%83%b3%e3%82%b0" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ここは、自作の受信側がSentryなら代わりにやってくれる判断を自分でしなければならない部分です。Sentryはスタックトレースでクラッシュをグルーピングします。でも、リリース版のHermesバンドルは難読化されているので、フレーム名は1文字になり、行や列の位置はビルドのたびに変わります。フレームでグルーピングすると、同じバグがリリースのたびに新しいissueになってしまいます。</p>
<p>ソースマップがない状態（最後に詳しく書きます）では、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>{{ default }}</code> を含まない独自の <code>fingerprint</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="#%e3%83%81%e3%83%a3%e3%83%b3%e3%83%8d%e3%83%ab%e3%82%92%e5%9f%8b%e3%82%81%e5%b0%bd%e3%81%8f%e3%81%95%e3%81%aa%e3%81%84%e3%82%a2%e3%83%a9%e3%83%bc%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>フィンガープリントごとにissueが1つでき、各レポートはその下にイベントとして保存されます。issueがERRORでログを出す（チャットのアラートが待ち受けているのはこれです）のは、次の場合だけです。</p>
<ul>
<li><strong>新しい</strong> issue</li>
<li><strong>解決済み</strong> のissueが <strong>再発</strong> したとき（issueは再オープンされます）</li>
<li><strong>10回目、100回目、1,000回目、10,000回目</strong> の発生</li>
</ul>
<p>通常の繰り返しはカウンターを増やすだけです。 <em>無視</em> にしたissueはカウントは続けますが、アラートは一切出しません。その上にチャットのハンドラー自体のレート制限があるので、ダメなリリースでクラッシュループが起きても、届くのは壁のような大量のメッセージではなく、数件で済みます。</p>
<p>保存量も同じように上限を設けています。各issueは <strong>直近50件</strong> のイベントだけを残し、新しいものが来るたびに古いものを削除します。これで最初のグラフが壊れました。50件のイベントでは、先週どれくらいの頻度で起きていたかは分からないからです。そこで各issueに90日分の日ごとの件数も持たせ、カウンターを増やすのと同じトランザクションの中で更新するようにしました。グラフはこの件数から描き、イベントは読むためだけのものです。</p>
<p>自分で作るなら、小さな競合状態が二つ気にかける価値があります。まったく新しいクラッシュが同時に何台ものスマートフォンで起きると、二つのレポートが両方ともissueを作ろうとすることがあるので、フィンガープリントをユニークにして、負けた方が既存の行を読み直すようにします。また、90日の範囲は一番新しいレポートからではなく、サーバーの今日から数えます。時計がずれたスマートフォンは、来年の日付も含めてどんな日付のレポートでも送ってくる可能性があるからです。</p>
<p>ペイロードは128KBが上限です。超えたイベントはまずパンくずリストとデバッグ用のメタデータを削り、それでも大きければ例外、メッセージ、リリース、プラットフォームだけを残して、削ったことが分かる印を付けます。</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="#%e3%83%87%e3%83%bc%e3%82%bf%e3%81%ae%e9%99%a4%e5%8e%bb" 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>パンくずリストはクエリ文字列も含めたURL全体を記録します。</strong> アプリの検索リクエストは検索語をクエリ文字列に載せて送るので、検索直後にクラッシュすると、その人が何を検索したかまで保存されていたことになります。今はサーバーがパンくずリストのURLをすべて <code>?</code> で切っています。</li>
<li><strong>コンソールのパンくずリストには <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="#%e3%82%af%e3%83%a9%e3%82%a4%e3%82%a2%e3%83%b3%e3%83%88%e5%81%b4" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>アプリでは、すべてが小さなモジュール1つを通ります。OTAアップデートのSDKをラップしているのと同じ理由で、OTAバンドルはネイティブモジュールが存在する前にビルドされたバイナリの上でも動くからです。<code>RNSentry</code> を持たないバイナリでトップレベルに <code>import * as Sentry from &quot;@sentry/react-native&quot;</code> と書くとimportの時点で例外が投げられ、画面が真っ白になります。なので、SDKは遅延して <code>require</code> し、それもネイティブモジュールが本当にあることを確認してからにしています。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">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>扱うのはエラーだけです。サーバーはトレース、セッション、プロファイル、リプレイを保存しないので、どれも有効にしていません。DSNがなければ（開発時やE2Eのビルド）SDKは起動すらしません。ビルドプロファイルがDSNと環境（<code>staging</code> か <code>production</code>）を設定するので、どちらのビルドも同じエンドポイントに報告しつつ、区別できます。ログイン中のユーザーはアカウントIDで設定し、名前やメールアドレスは一切使いません。</p>
<p>エラーバウンダリには明示的な呼び出しが必要でした。Expo Routerのルートの <code>ErrorBoundary</code> はレンダリングエラーを捕まえて独自の画面を表示するので、そのエラーはSDKがフックしているグローバルハンドラーまで届きません。解決策は小さなラッパーで、<code>useEffect</code> の中で <code>captureException</code> を呼んでから、これまでどおりルーターのバウンダリを描画します。ルートのバウンダリにもルートごとのバウンダリにも同じものを使います。レンダリング中ではなくエフェクトの中で呼んでいるのは、再レンダリングのたびにではなく1回だけ報告するためです。ルートコンポーネントは <code>Sentry.wrap</code> でもラップしていて、それが例外を投げたら素のコンポーネントに戻ります。</p>
<p>Web向けには、全関数を無効にした <code>.web.ts</code> 版のモジュールを置いています。Metroがプラットフォーム別のファイルを選ぶので、SDKがWebのバンドルに入ることはありません。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="#%e3%83%8d%e3%82%a4%e3%83%86%e3%82%a3%e3%83%96%e3%82%af%e3%83%a9%e3%83%83%e3%82%b7%e3%83%a5%e3%81%8c%e5%b1%8a%e3%81%8f%e3%81%93%e3%81%a8%e3%82%92%e8%a8%bc%e6%98%8e%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ユニットテストは手書きのエンベロープをPOSTするもので、証明できるのは、SDKが送ってくると <em>私が思っている</em> ものをパーサーが処理できる、ということだけです。リリースから2日後、本番のクラッシュレポートはゼロでした。アップデートしたのはまだ3人だけだったので、ゼロでもおかしくはありません。でも「誰もクラッシュしていない」と「クラッシュが届いていない」は、サーバーから見るとまったく同じに見えます。本物を見たいと思いました。</p>
<p>そこで、DSNをポート8000のローカルDjangoと使い捨てのキーに向けた、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"><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>（Debugではなく Release です。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がそれを見つけて送信します。再起動すると、1秒後にシミュレーターのネットワークログに <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>クラッシュから保存まで15秒で、そのほとんどは私が再起動していた時間です。そして本物のペイロードを見たことで、テストでは分からなかったことがいくつも分かりました。</p>
<ul>
<li><strong>ユーザーIDがアカウントIDではありませんでした。</strong> テスト用ビルドは実際にはログインしていないので、sentry-cocoaはインストールごとの独自IDを使っていました。知っておく価値があり、プライバシーの説明にも書くべきことです。レポートには、ログインしていればアカウントのID、していなければランダムなインストールIDが入ります。</li>
<li><strong>タイムゾーンがありません。</strong> cultureコンテキストがあるものと思い込んでいました。deviceコンテキストには <code>locale</code>、メモリ、画面サイズ、省電力モード、カーネルのバージョンはありますが、タイムゾーンはありません。</li>
<li><strong>ネイティブクラッシュは <code>beforeSend</code> を通りません。</strong> JSの <code>beforeSend</code> で実行中のOTAバンドルを示す <code>ota</code> タグを付けていますが、ネイティブクラッシュは次の起動時にネイティブSDKが組み立てるので、JSのフックを一度も通りません。ネイティブクラッシュにタグを付けるには、バンドルが分かった時点で <code>setTag</code> を使ってスコープに設定し、ネイティブ層に同期させる必要があります。</li>
<li><strong>パンくずリストからアプリの動きが見えました。</strong> コンソールの警告、ビューコントローラーのライフサイクル、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="#%e3%83%ac%e3%83%9d%e3%83%bc%e3%83%88%e3%82%92%e8%aa%ad%e3%82%80" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>レポートはDjangoの管理画面で読みます。Sentryをゆるく真似たissueページで、上部に件数とResolve / Ignore / Reopenボタン、日ごとの棒グラフ、リリース・OS・端末・OTAバンドル別の内訳があり、その下でイベントを1件ずつ、例外、フレーム（アプリのフレームは強調し、ライブラリのフレームは折りたたみます）、パンくずリスト、端末のコンテキストと一緒に表示します。新しい依存関係は要りませんでした。</p>
<p>作っている途中で一つ修正が出ました。ネイティブクラッシュでは「原因（culprit）」フレームが端末上のバイナリのパスになっていて、そこにインストールごとの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="#%e3%83%97%e3%83%a9%e3%82%a4%e3%83%90%e3%82%b7%e3%83%bc%e3%83%9d%e3%83%aa%e3%82%b7%e3%83%bc%e3%82%92%e3%81%be%e3%82%8b%e3%81%94%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>クラッシュレポートは、ほぼ個人情報です。アカウントID、スマートフォンの機種、そしてアプリが死ぬ直前の1分間にその人が何をしていたか。なので、プライバシーポリシーに1段落必要でした。きちんと書くにはレポートに何が入っているかを正確に書く必要があったので、さっき見たばかりのペイロードから列挙しました。エラー、アプリのバージョンとOTAアップデート、機種、OSと言語、空きメモリと画面サイズ、直前の数十ステップ（開いた画面、 <code>?</code> 以降を取り除いたWebアドレス、アプリ自身のログ）、そしてアカウントIDかランダムなインストールID。ポリシーには、レポートが自社のサーバーに届き、Sentryやその他のクラッシュレポート会社には送られないことも書いています。保存期間は表に専用の行を設けました。問題ごとに直近50件のレポートと、90日分の日ごとの件数です。</p>
<p>一つの段落をコードと突き合わせ始めると、残りを確認しないわけにはいきませんでした。既存のポリシーは耐えられませんでした。アカウント削除ページには、アカウントを閉じるとプッシュ通知のトークンとパスキーは即座に削除され、サーバーログは90日以内に消える、と書かれていました。どれも事実ではありませんでした。アカウントを閉じるとサインイン方法の紐付けが外れてプロフィールは匿名化されますが、プッシュ通知のトークンとパスキーは誰かが手で消すまで残りますし、ログのアーカイブにはまだ有効期限がありません。そこでポリシー全体を、コードが実際に何をしているかの棚卸しから書き直し、それらの行は今は「まだ自動では削除されません。メールでご連絡ください」となっています。安心感は減りますが、正確です。</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="#%e8%b6%b3%e3%82%8a%e3%81%aa%e3%81%84%e3%82%82%e3%81%ae" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><strong>シンボリケーション。</strong> これが本当の穴で、sentry.ioがやっていて私がまだ置き換えていない唯一のものです。スタックはJavaScriptでは難読化され、ネイティブコードではシンボル化されていません。</p>
<ul>
<li><strong>JavaScript：</strong> フレームはHermesバンドルの中を指しています。直すには、ストア向けビルドとOTAバンドルのすべてについて合成済みのHermesソースマップをリリースとバンドルをキーに保存しておき、ページを描画するときにJSのフレームに適用する必要があります。うちのクラッシュの大半はJavaScriptになるはずなので、次に取りかかるのはこれです。</li>
<li><strong>iOSのネイティブ：</strong> 命令アドレスにはビルドのdSYMが必要です。</li>
<li><strong>Android：</strong> 難読化（minification）を有効にするとJavaのフレームはR8で難読化され（<code>mapping.txt</code> が必要）、C++のフレームにはNDKのシンボルが必要です。</li>
</ul>
<p>先ほど見つけた、もう少し小さな穴もあります。OTAタグは <code>beforeSend</code> でしか付けていないので、ネイティブクラッシュにはまだ付きません。実行中のバンドルが確定した時点でスコープに設定するのは数行ですが、まだ書いていません。</p>
<p>今のところ、issueから分かるのは、 <em>何が</em> クラッシュしたか、どのリリース、OTAバンドル、端末でか、そしてその直前に何をしていたかです。ソースの <em>どこで</em> かは分かりません。週に数回リリースする小さなアプリなら、これまでは型とメッセージとパンくずリストで十分に原因を見つけられています。</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>自作の Expo アップデートサーバーを Codemagic Patch に置き換える</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-09-13-moving-to-codemagic-patch/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/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 で直したバグ、そしてリリースが実際にクラッシュしたときのロールバックの様子まで。</description>
      <content:encoded><![CDATA[<blockquote><p><strong>開示。</strong> この移行と記事の執筆には Codemagic から報酬を受けています。Codemagic のレビューは事実関係の確認だけで、論調には及ばず、先方の要望で書き換えた箇所はありません。問題にぶつかって報告した箇所はそう書いていますし、あなたが読む時点で直っているものは、直ったものとして書いてあります。</p>
</blockquote><blockquote><p><strong>2026年9月23日追記。</strong> 私が報告した問題には、Codemagic の開発者が順に対応してくれました。iOS のバージョン検出のバグ、二重に数えられたインストール、無音の初回セットアップ、うるさいデバッグコマンドを含めて大半はすでに修正済みで、ドキュメントにもコミット済みのネイティブプロジェクトの扱いとクラッシュ時のロールバックまでの予算が載りました。本文ではそれぞれ該当する箇所でそう書き添え、まだ残っているいくつかは元のままにしています。</p>
</blockquote><blockquote><p><strong>2026年10月16日追記。</strong> Patch に移ってから初めてのネイティブのアップグレード（Expo SDK 58）で、Patch の設定がまったく入っていない Android のリリースビルドができあがり、しかもどこもエラーを出しませんでした。何が起きたのかと、ファイル1つで済んだ修正は <a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-13-moving-to-codemagic-patch/#%e5%88%9d%e3%82%81%e3%81%a6%e3%81%ae%e3%83%8d%e3%82%a4%e3%83%86%e3%82%a3%e3%83%96%e3%82%a2%e3%83%83%e3%83%97%e3%82%b0%e3%83%ac%e3%83%bc%e3%83%89" >初めてのネイティブアップグレード</a> に書きました。</p>
</blockquote><p>2月に、<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-02-22-self-hosted-ota-updates/" >Expo のアップデートサーバーを自作した話</a> を書きました。あれは今も動いています。私が運営しているコミュニティアプリの maru は、それ以来すべての OTA 修正をあのサーバー経由で、両プラットフォームに、月1ドルほどで配信してきました。</p>
<p>それは同時に、部品を全部自分で抱えているということでもあります。データベースのテーブル、API エンドポイント、管理コマンド、パブリッシュスクリプト、共有シークレット、そして壊れたアップデートを一つずつ経験して覚えたハマりどころの数々。今回の計画を立てようと腰を据えたとき、スクリプトが書いた日からずっと、本番へパブリッシュするたびに &ldquo;Sent 0 push notifications&rdquo; と出力していたことに気づきました。このサーバーが一度も返したことのないフィールドを読んでいたのです。誰も気づいていませんでした。誰よりも私が。自前で運用するというのは、だいたいこういうことになります。</p>
<p>そこで Codemagic から、CodePush を<a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">オープンソース</a>でセルフホストできる形にした <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a> を試してみないかと声をかけられたとき、条件を一つ付けて引き受けました。デモではなく本物のアプリでやること。本物の署名、本物のリリース手順、手で編集した箇所のあるコミット済みのネイティブプロジェクト、その全部です。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-%e3%81%a8%e3%81%af%e4%bd%95%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Patch は、自分で動かすサーバーと、アプリに組み込む SDK でできています。サーバー側は API、バックグラウンドワーカー、Postgres、S3 互換のオブジェクトストレージ、Web ダッシュボードで、Docker Compose のスタックとして配布されています。クライアント側は <code>@codemagic/react-native-patch</code> という iOS と Android 向けの TurboModule で、Expo の config plugin が付いています。それに加えて、JavaScript をバンドルしてパブリッシュする CLI の <code>cmpatch</code> があります。</p>
<p>ソースを読んで、設計上の判断が2つ目に留まりました。</p>
<p>1つ目は、アプリが API に「アップデートはあるか」と一度も尋ねないことです。リリースがパブリッシュされるとワーカーが素の JSON マニフェストをオブジェクトストレージに書き、アプリはそれを取りに行くだけ。アップデートの確認はストレージか CDN に対する静的な GET が2回で、API が受け取るのはメトリクスだけです。私の旧エンドポイントは、アプリを起動するたびにリクエスト経路の上に乗っていました。こちらではそれが起こり得ません。</p>
<p>2つ目は、すべてのリリースが対象とするバイナリのバージョンを厳密に指定することと、CLI がネイティブプロジェクトのフィンガープリントを計算して、ネイティブコードが一致しないバイナリへ JavaScript をパブリッシュするのを拒むことです。私のサーバーにあったのは バージョン文字列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="#%e3%81%be%e3%81%9a%e3%81%af%e6%89%8b%e5%85%83%e3%81%ae%e3%83%8e%e3%83%bc%e3%83%88%e3%83%91%e3%82%bd%e3%82%b3%e3%83%b3%e3%81%a7" 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 のインストールは2秒でした。スタックのほうは初回に3分14秒かかり、大半はサーバーとダッシュボードのイメージをローカルでビルドしている時間です。非対話モードだとその間何も出力しないので、ハングしているようにしか見えません（その後修正され、今はステップごとに進捗を1行出します）。そのあと、healthy なコンテナが4つ、すべて localhost に束縛された状態で現れました。8080 にダッシュボード、3000 に API、Postgres、そして 9100 に MinIO です。私自身のスタックがすでに使っているポートは、きちんと避けてくれました。</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 というアプリがあらかじめ入っています。何かをパブリッシュする前に一度眺めておく価値があります。ダッシュボードが何のためにあるのかが分かるからです。次の2枚のスクリーンショットの数字はそのシードデータで、実際のユーザーではありません。</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% のカナリアリリースと、リリースごとの成功数・失敗数が並んでいる"
      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% のカナリア、対象バージョン、リリースごとの成功数と失敗数を含むリリース履歴。</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>シードされたデモデータ: 端末が実際に動かしているリリースと、インストールの結果。</figcaption>
</figure>

<p>比較のために言うと、私のダッシュボードは、管理画面のアップデート一覧に「有効」のチェックボックスが付いただけのものでした。実際に何台の端末がアップデートを取り込んだのか、まったく分かりませんでした。</p>
<p>CLI のログインは PKCE と localhost へのコールバックを使ったまっとうなブラウザフローで、承認ページにはどのアカウントが要求しているかと、コードが1分ほどで失効することが書いてあります。</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="#%e6%9c%ac%e7%89%a9%e3%81%ae%e3%82%a2%e3%83%97%e3%83%aa%e3%81%ab%e5%90%91%e3%81%91%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Patch はプラットフォームごとに1アプリを求め、それぞれに Staging と Production のデプロイメントが作られます。<code>maru-ios</code> はダッシュボードで、<code>maru-android</code> は <code>cmpatch app create</code> で作りました。主に両方を見ておきたかったからです。アプリを作ったあとに出るダイアログは、移行にあたってこの製品でいちばん役に立つ画面です。両方のデプロイメントキー、SDK が必要とする2つの URL、そしてデプロイメントキーは秘密情報ではないという注記が載っています。</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 のデプロイメントキーと 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 に必要なものが、1画面に。</figcaption>
</figure>

<p>小さなことが2つ。<code>cmpatch init</code> はすでに存在するアプリにプロジェクトを紐付けるコマンドだったので、移行ガイドがどう匂わせていようと、最初のコマンドにはなりませんでした。これはその後修正され、今は対話モードならプラットフォームごとにアプリを1つずつ自分で作ってくれます。それから、ダイアログは API の URL を <code>http://localhost:8080</code> と示すのに、ドキュメントと CLI は 3000 と言います。どちらのポートも両方を配信しているのでどちらでも動きますが、首をかしげることにはなるでしょう。</p>
<p>次に、手を付けていないアプリに対して <code>cmpatch doctor</code> を走らせたところ、3秒で本物の問題を見つけてきました。iOS のバイナリバージョンを 2.0.0 と報告したのです。アプリは 2.5.1 です。</p>
<p>CLI は <code>ios/</code> 以下のすべての <code>Info.plist</code> を探し、パスに &ldquo;test&rdquo; を含むものがなければ、アルファベット順で最初のものを取ります。maru には WidgetKit の extension があり、そのフォルダがアプリ本体のフォルダより前に並ぶので、CLI は extension の <code>Info.plist</code> を拾いました。その plist が 2.0.0 のまま古くなっていたのは私のバグですが、その影響はたちが悪い。自動検出に任せると、iOS のリリースがすべて、どの端末も動かしていないバージョンを対象にしてしまい、しかも何もエラーになりません。アップデートが単に永遠に届かないだけ。まさにセルフホストの記事で警告した失敗です。Android は <code>versionName</code> を正しく読みました。回避策は <code>--target-binary-version</code> か <code>--plist-file</code> を渡すことで、私のパブリッシュスクリプトは今、<code>app.config.js</code> と両方のネイティブプロジェクトが一致していることを確認したうえで、バージョンを明示的に渡しています。あとになって同じバグのせいで、<code>doctor</code> 自身のマニフェスト確認が、正常に動いているデプロイメントに対して失敗しました。バージョン 2.0.0 のマニフェストを探しに行ったからです。古い plist を直したら、<code>doctor</code> は24項目すべてを通過しました。</p>
<p>これは Codemagic がその後修正しました。CLI は今、extension ではなくアプリターゲットの <code>Info.plist</code> からバージョンを読みますし、アプリの候補になるターゲットが複数あれば、どれがそうなのかを <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-%e5%81%b4" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><code>expo-updates</code> を外して SDK を足すのは2行の変更です。アプリがアップデートに対してやっていたことを置き換えるほうは、もう少し考える必要がありました。</p>
<p>maru は起動時と、フォアグラウンドに戻るたびにアップデートを確認します。ただし最短でも15分おきです。見つかればダウンロードして再起動を提案し、断られれば次のコールドスタートで適用します。設定画面には、動作中のバンドルを表示する手動チェックもあり、エラーレポーターはすべてのレポートに、それが来たアップデートのタグを付けます。</p>
<p>SDK のソースから最初に学んだのは、インポート時に <code>TurboModuleRegistry.getEnforcing</code> でネイティブモジュールを探しに行き、モジュールがなければ例外を投げるということです。maru は Web ビルドも出荷しているので、トップレベルでインポートすると Web アプリが落ちます。そこで、SDK を遅延ロードする小さなモジュールを1つ用意し、すべてそこを通すようにしました。何も返さない <code>.web.ts</code> の双子を置いてあるので、SDK は Web バンドルから完全に外れます。</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> です。決して例外を投げず、ステータスに解決します。</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 の必須リリースのデフォルトは <code>IMMEDIATE</code> で、ユーザーがどこにいようと JavaScript をリロードします。私はアプリが次にバックグラウンドから戻ってきたときに適用するよう変えました。書きかけの投稿が消えるのは、たいていの修正の効き目より重いバグだからです。それから当時は、<code>InstallMode</code> がドキュメントの例から想像する enum オブジェクトではなく、文字列の union 型でした。今は定数オブジェクトとしてエクスポートされているので、<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="#%e3%83%8d%e3%82%a4%e3%83%86%e3%82%a3%e3%83%96%e5%81%b4ios-%e3%81%a8-android-%e3%82%92%e3%82%b3%e3%83%9f%e3%83%83%e3%83%88%e3%81%97%e3%81%a6%e3%81%84%e3%82%8b%e5%a0%b4%e5%90%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>移行ガイドは、config plugin を追加したあとで <code>npx expo prebuild --clean</code> を実行するように言っています。別のチェックアウトで実行してみたところ、同梱の画像、Android のネットワークセキュリティ設定、iOS のプライバシーマニフェスト、<code>Podfile.lock</code> が消え、<code>build.gradle</code> のテストランナー向けの修正と <code>Info.plist</code> の数百行が元に戻りました。<code>--clean</code> なしでも、Expo はどのみち両方のディレクトリを空にしました。</p>
<p>これは Patch のせいではありません。maru は managed な Expo アプリとして始まり、ネイティブのカスタマイズが増えていったので、<code>ios/</code> と <code>android/</code> はコミットされていて、EAS はそれをそのままビルドします。ただ、そういうアプリはいくらでもあるのに、ガイドは警告してくれませんでした。今はしてくれます。Expo の移行ガイドには道が2つ用意されていて、生成されたネイティブプロジェクトなら prebuild、コミット済みのプロジェクトなら手で当てる変更をそのまま示してくれます。</p>
<p>plugin が実際に変える内容は、蓋を開けてみれば手作業で済む程度でした。iOS では <code>AppDelegate.swift</code> に import が1つと1行。</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 が1つと引数が1つ（これは 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>加えてプラットフォームごとに設定値が3つと、古い <code>expo-updates</code> の設定の削除。両プロジェクト合わせて13行の追加と20行の削除です。</p>
<p>plugin はこれらの設定値を prebuild 時にリテラルとして書き込みます。コミット済みのネイティブプロジェクトでは、ビルドごとにデプロイメントが1つハードコードされることになります。私は同じプロジェクトから、プレビュービルドは 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>キーは <code>eas.json</code> の各ビルドプロファイルに、<code>EXPO_PUBLIC_</code> の値と並べて置きます。値が空だと 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="#%e6%9c%80%e5%88%9d%e3%81%ae%e3%82%a2%e3%83%83%e3%83%97%e3%83%87%e3%83%bc%e3%83%88" 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">./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秒後にマニフェストをパブリッシュしました。アップデートは 7.3 MB の圧縮 tarball で、以前のエクスポートが生成していた 11 MB の Hermes バンドルとアセットに比べて小さくなっています。</p>
<p>ここで間違えてはいけないことが1つ。JavaScript バンドルはビルド時に <code>EXPO_PUBLIC_</code> の値をインライン展開するので、アップデートは着地先のバイナリと同じ値でビルドしなければなりません。さもないと、間違ったバックエンドに話しかける JavaScript を出荷することになります。私のスクリプトは、バイナリをビルドしたのと同じ <code>eas.json</code> のプロファイルから値を読み込みます。Patch の CI ドキュメントにも今はこの点が明記されていて、<code>release-react</code> 自体は EAS のプロファイルを読まないことも書かれています。</p>
<p>それからアプリを再起動しました。端末のログでは、アップデートの確認全体がオブジェクトストレージへの2リクエスト、<code>meta.json</code> と <code>2.5.1/manifest.json</code> として現れました。SDK は起動の3秒後に <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="同梱バンドルで再起動したアプリがアップデートを提案し、リリース 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 で届いたリリース 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="#%e3%83%90%e3%82%b0%e3%81%af%e7%a7%81%e3%81%ae%e3%82%82%e3%81%ae%e3%81%a0%e3%81%a3%e3%81%9f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>そこでダッシュボードを見ました。シミュレーター1台がリリース v1 をインストールしたのに、成功2件、アクティブユーザー2人と表示されています。</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 デプロイメントが1台の端末に対してアクティブユーザー2人、成功2件を表示している"
      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>シミュレーター1台が、2回数えられています。</figcaption>
</figure>

<p>SDK は状態と未送信のメトリクスイベントをアプリのコンテナ内にファイルとして持っているので、追跡は簡単でした。タイムスタンプが同一で id が異なる <code>Success</code> イベントが2つありました。私のフックはマウント時に <code>notifyAppReady()</code> で動作中のバンドルを確定させ、その直後に <code>sync()</code> を呼んでいて、<code>sync()</code> は自分でも <code>notifyAppReady()</code> を呼びます。2つの呼び出しが重なり、どちらもリリースを pending と見なし、どちらもイベントを記録した。サーバーはイベント id で重複排除しますが、id が違うので両方数えられ、その重複は永久に残ります。</p>
<p>私の側の修正は、起動ごとに確認処理を1つだけ共有し、ほかはすべてそれを待つようにすることでした。SDK 自身が重なった呼び出しを防ぐこともできるはずで、そう提案しました。今はそうなっています。<code>sync()</code> の内部のものも含め、重なった <code>notifyAppReady()</code> の呼び出しは1つの確認処理を共有するので、インストールは1回だけ記録されます。SDK のリファレンスにも、<code>sync()</code> は自分で準備完了を確定させるので、<code>sync()</code> を使うアプリが別に <code>notifyAppReady()</code> を呼ぶ必要はない、と書かれるようになりました。</p>
<p>気持ちよかったのは、この修正の出荷です。JavaScript だけの変更なのでリリース v2 として出せて、v1 の端末にはバイナリ差分を送れるので、v2 は 7.3 MB のバンドルではなく 543 KB のパッチ、サイズにして約7% でダウンロードされました。再起動後: ダウンロード1件、インストール1件、成功1件、アクティブ端末1台。</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="#%e3%82%8f%e3%81%96%e3%81%a8%e5%a3%8a%e3%81%99" 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="壊れたリリースでアプリがクラッシュしたあとのシミュレーターのホーム画面">
      <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> の前にクラッシュしたバンドルはロールバックされると書いてあるからです。ソースが説明してくれました。SDK は未確認の起動を3回まで許してから、クラッシュだと判断します。未確認の起動1回は、JavaScript が動く前に iOS が健全なプロセスを殺しただけかもしれないからです。4回目の起動で 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>4回目の起動: 誰もサーバーに触らずに v2 へロールバック。</figcaption>
</figure>

<p>そして端末は自分から失敗を <code>crash_rollback</code> として報告し、ダッシュボードは v3 に対してそれを表示しました。理にかなった設計ですが、それが何を意味するかははっきり言っておく価値があります。悪いリリースは、ユーザー一人あたり3回のクラッシュ起動を代償にしてから回復するのであって、当時のドキュメントにはその回数が書かれていませんでした。今は書かれていますし、この予算は設定で変えられます。ネイティブなら <code>CodemagicPatchMaxLaunchAttempts</code>、Expo の plugin なら <code>maxLaunchAttempts</code> です。だからこそ、まず Staging、次に小さな Production ロールアウトなのです。</p>
<p>ほかの全員に対して悪いリリースを止めるのはコマンド1つで、1秒かかりませんでした。</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>履歴を書き換えることはしません。前のリリースを新しいリリース（v4、v2 のロールバックとしてマーク）としてもう一度パブリッシュするので、クラッシュしたリリースは失敗数とともに一覧に残ります。これが正直な記録です。ダッシュボードからも、ダイアログで同じことができます。</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 をロールバックする様子。前のリリースが 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 に失敗1件が表示されている"
      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 には驚きが2つあり、どちらも Patch のせいではありません。SDK 向けに足した R8 の keep ルールは <code>app.config.js</code> に置いてあって、これは prebuild を通してしかネイティブプロジェクトに届きません。つまり最初のリリースビルドは、keep ルールなしで SDK が R8 を生き延びるかどうかを、意図せず試すことになりました。生き延びます。R8 はクラスをすべてリネームしましたが、アップデートのダウンロード、インストール、ロールバックは問題なく動きました。もう1つはエミュレーターで、コールドブートしたばかりで Gradle がまだメモリを握っていたせいで極端に遅く、最初のテストは、SDK がすでに始めていたダウンロードを終える45秒前に諦めてしまいました。</p>
<p>リリース v1 は適用され、成功1件、アクティブ端末1台。クラッシュする v2 は、同じ3回の起動予算のあとにロールバックされました。</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="OTA で配信された見出しを表示している Android アプリ"
      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="#%e3%83%8f%e3%83%83%e3%83%94%e3%83%bc%e3%83%91%e3%82%b9%e3%81%ae%e5%85%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>アップデートが届いてロールバックされることが核ではありますが、チームが日々使うのはそれ以外の機能なので、一つずつ試していきました。</p>
<p>段階的ロールアウトは決定論的で、これは気に入りました。<code>md5(deviceId + &quot;-&quot; + releaseLabel)</code> の先頭8桁の16進数を 100 で割った余りがロールアウトのパーセンテージ未満なら対象になるので、同じ端末は同じリリースに対してずっと対象か対象外のままです。私のシミュレーターは次のリリースでバケット 91 に入ると計算できたので、25% でパブリッシュしたところ、シミュレーターには正しく何も来ませんでした。<code>cmpatch release patch</code> で 95% に上げると（1秒かかりません）、次の起動で更新されました。</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="ダッシュボードでリリースのロールアウトを 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>すると次のリリースが 409 で失敗しました。&ldquo;deployment has an active rollout below 100 percent&rdquo;。デプロイメントごとに部分ロールアウトは1つしか許されないので、ほかの何かを出す前にカナリアを完了するか、無効化するか、ロールバックする必要があります。妥当ですし、ドキュメントにも書いてありますが、マージのたびにパブリッシュするパイプラインは、カナリアの途中でここにぶつかります。報告してからは、CLI がここで事情を説明してくれるようになりました。アクティブな部分ロールアウトが邪魔をしているなら、待っても解決しないことと、ロールアウトを完了または無効化する方法を教えてくれますし、別のリリースジョブがまだ動いているなら、<code>release inspect --wait</code> で待つように言ってくれます。ドキュメントにも、リリースの変更を順番に行うためのセクションができました。</p>
<p>必須リリースは SDK の <code>mandatoryInstallMode</code> を使います。私は次のレジューム時に適用する設定にしていたので、アプリはアップデートをダウンロードしてプロンプトを表示し、プロンプトを無視してアプリをバックグラウンドに送ってから戻すと、新しいバージョンがただそこにありました。</p>
<p>リリースの無効化は、提供をやめる以上のことをします。最新のリリースを無効化した瞬間、マニフェストはまだ有効な中でいちばん新しいリリースを指すようになり、無効化したリリースにいる端末には、そこへ戻るパッチが提供されました。あるバイナリバージョンのリリースをすべて無効化すると、マニフェストのターゲットは null になり、SDK はアプリを出荷時のバンドルに戻します。私のアップデート用フックは最初このケースでプロンプトを出していなかったので、今は出すようにしてあります。</p>
<p>Staging から Production への昇格は1秒かからず、テストしたのとまったく同じパッケージを、同じハッシュとノートのまま再利用しました。ビルドし直すのではありません。ロールアウトのパーセンテージも受け付けるので、「Production の 10% に昇格」がコマンド1つで済みます。</p>
<p>ビルドとパブリッシュを別々の手順に分けることもできます。<code>cmpatch bundle</code> が <code>.cmpatch</code> のアーティファクトを生成し、ダッシュボードの「Bundle upload」はアップロード前にそれを読み取ります。プラットフォーム、対象バージョン、フィンガープリント、署名の状態、サイズ、バンドラー。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>バンドルアップロード: ダッシュボードがアーティファクトを読み取り、続いてフィンガープリントのガードが働きます。</figcaption>
</figure>

<p>フィンガープリントのガードに本格的にぶつかったのもここです。古いウィジェットの plist を直したあと、サーバーは次のリリースを 409 で拒否しました。ネイティブプロジェクトのフィンガープリントが、2.5.1 に対して記録されていたものと一致しなくなったからです。Android では <code>app.config.js</code> のコメントを編集しただけで十分でした。Expo はアプリ設定をフィンガープリントに含めるからです。ダッシュボードは同じチェックを、両方のフィンガープリントと「Upload anyway」ボタンの付いた警告として表示します。どちらの場合もインストール済みのバイナリのネイティブ部分は何も変わっていないので、上書きが正解でした。ただ実運用では、正直な答えはほぼ必ずバージョンを上げてストア向けにビルドすることで、私のパブリッシュスクリプトはこれが起きたとき、今はまさにそう伝えます。</p>
<p>コード署名は RS256 です。CLI が RSA の秘密鍵でパッケージのハッシュに署名し、SDK がバイナリにコンパイルされた公開鍵でそれを検証します。鍵は base64 の1行として渡すので、私のビルド設定方式にそのまま収まり、両プラットフォームでテストしました。鍵をアプリに入れた状態では、署名なしのリリースはダウンロード前に拒否されました。アプリで「require code signing」を有効にすると、サーバーは署名なしのパブリッシュを、対処法を示しガイドへリンクするエラーで拒否しました。署名済みのリリースはインストールされ、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="リリース詳細。署名が 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>署名済みのリリース。</figcaption>
</figure>

<p>チームと CI もカバーされています。メンバーにはチーム全体で共通の4つのロール（owner、admin、developer、viewer）のいずれかが付き、招待は相手が最初にサインインするまで保留されます。パーソナルアクセストークンは空のホームディレクトリでも動き、<code>--token</code> でも環境変数でも渡せて、トークン一覧には最後に使われた日時が出ます。Linux ランナーで同じパブリッシュスクリプトを、シークレットから取り出した署名鍵で走らせる手動実行の CI ワークフローも書きましたが、ホスト済みのサーバーが要るので、まだ実行していません。</p>
<p>当時なら使わずにおいたツールが1つ、<code>cmpatch debug ios</code> です。システムログを &ldquo;OTA&rdquo; で大文字小文字を区別せずにフィルタしてストリームしていたので &ldquo;Rotation&rdquo; にもマッチしてしまい、20秒のアプリ起動で SpringBoard のメッセージが画面いっぱいに流れ、アプリからは何も出ませんでした。これは修正済みで、今は SDK のマーカーが付いた行だけを表示します。もっとも SDK はもともとあまりログを出さず、出るのはほとんどエラーと開発ビルドでの警告なので、静かなストリームだと思っておいてください。<code>getpwuid_r</code> の警告が出ることがありますが、これは Patch ではなく <code>xcrun simctl</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="#%e5%ae%9f%e9%9a%9b%e3%81%ae%e3%81%a8%e3%81%93%e3%82%8d%e3%81%a9%e3%82%8c%e3%81%8f%e3%82%89%e3%81%84%e5%a4%a7%e5%a4%89%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Codemagic から意見を求められたので、できるだけ率直に書きます。</p>
<p>試すのは簡単です。コマンド2つと数分で、本物のサーバー、デモデータ入りのダッシュボード、自分のアプリに向けられる CLI が手に入ります。最初の1時間でいちばん良かった部分だと思います。</p>
<p>SDK の組み込みはそこそこの作業で、難しさの大半は Patch ではなく自分のプロジェクトの側にあります。ネイティブフォルダを生成させたまっさらな Expo アプリなら、本当に plugin と、<code>expo-updates</code> の呼び出しを置き換える数行だけです。maru のように、コミット済みのネイティブプロジェクト、ウィジェット extension、Web ビルド、R8、ビルドプロファイルを抱えたアプリでは、作業は端のほうにありました。prebuild を避けるためにネイティブの変更を手で当て、Staging と Production のビルドが別物になるように設定をビルド時に決め、SDK を Web から遠ざける必要がありました。アップデートの JavaScript がバイナリと同じ環境でビルドされることも保証しなければなりません。理解してしまえばどれも難しくはありませんでした。私が作業した時点ではその大半がドキュメントにありませんでしたが、コミット済みのネイティブプロジェクトの手順とバンドル時の環境については、今は載っています。</p>
<p>日常の使い勝手は良いです。パブリッシュはコマンド1つと30秒ほど。ロールアウト、昇格、無効化、ロールバックは CLI からでもダッシュボードからでもすべて1秒未満で、エラーは何が悪くて何をすべきかを伝えてくれます。サーバーのガード（フィンガープリントのチェック、一度に1つのカナリア、署名）は厳格で、私が間違ったことをするのを何度も止めてくれました。</p>
<p>本番で運用するのは、より大きなコミットメントです。Postgres とオブジェクトストレージ付きの Docker Compose スタックで、ホスティング、バックアップ、アップグレードは自分でやらなければならず、ドメイン2つと OAuth アプリが必要です。すでにサーバーを運用しているチームなら静かな午後1回分。サーバーを持ちたくないからこそ EAS にいるチームにとっては、SDK の作業よりもここが乗り換えの本当のコストです。</p>
<p>実時間で言うと、12:29 に CLI をインストールし、13:34 にはシミュレーターで最初のアップデートが適用されていました。そのうち30分は、Patch と無関係な理由で止まった Xcode のビルドです。この記事にあることは両プラットフォームとも、ホスト済みのサーバーを除けば午後の半ばまでに終わっていました。</p>
<p>いちばん時間を取られたものを順に挙げると、<code>expo prebuild --clean</code> がコミット済みのネイティブプロジェクトを消してしまい、そのあと全部を手で配線する方法を考えたこと。iOS のバージョン検出のバグで、これは誰も動かしていないバージョンにアップデートを送るところでした。二重に数えられたインストールで、これは自分のせいですが、<code>sync()</code> が自分で <code>notifyAppReady()</code> を呼ぶ以上、誰でも簡単に再現してしまいます。そしてドキュメントにない3回の起動というクラッシュ予算で、テストの途中で驚かされました。その次に来るのが、正しいけれど計画が要るもの。無害なネイティブ周辺の編集のあとに発火するフィンガープリントのガード、デプロイメントごとに一度に1つのカナリア、重ねて実行できないライフサイクルジョブ。最後のはスクリプトが続けていくつか処理をした途端に効いてきます。残りは小さなものでした。無音の初回セットアップ、うるさいデバッグコマンド、当時コードと合っていなかったドキュメントの例がいくつか。</p>
<p>これらはすべて詳細とともに Codemagic に書いて渡し、9月23日の時点でその大半が直っていました。バージョン検出、重なった確認処理、prebuild についての警告、クラッシュ予算、無音のセットアップ、デバッグコマンド、<code>InstallMode</code> の例、そしてリリースジョブやカナリアが邪魔をしているときのエラーです。まだ残っているのは小さなものです。ほかがすべて <code>:3000</code> と言うなかでダッシュボードだけが API を <code>:8080</code> と表示すること、Web と Jest で SDK が 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="#%e6%9c%ac%e7%95%aa%e3%81%b8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ここまでの内容はすべて評価用スタックに対して動かしたものです。本番は同じアプリと同じスクリプトを本物のサーバーに向けるだけで、これが maru の計画です。</p>
<!-- 編集メモ: このセクションは手順。実際の切り替え後に、実際に起きたこと（ホスト、所要時間、壊れたもの）で置き換えること。 -->
<p>サーバーを動かすサポート対象の方法は Docker Compose でのインストールです。ポート 80 と 443 が開いた Linux マシン、ドメイン2つ（API とダッシュボード用、ダウンロード用）、そしてサインイン用の GitHub OAuth アプリ。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 とリリースワーカーを一緒に）で動くコンテナ1つで、必要なのは 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>注意点が2つ。Compose の外で動かす方法はリポジトリに参考資料として書かれているだけで、この最初のオープンソースリリースではサポート対象の経路ではないので、Fly 上でのアップグレードとバックアップは自己責任です。私もまだその形ではデプロイしていません。これは計画であって、報告ではありません。それから、<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/" >Fly のゼロスケール</a> の記事を読んだ方は、マシンを最低1台は動かしておいてください。マニフェストはストレージから直接来るので、端末がアップデートの確認で API を待つことはありませんが、そうしないとメトリクスのアップロードとリリースジョブがすべてコールドブートから始まることになります。</p>
<p>Tigris はいちばん楽しみにしている部分です。maru のファイルがすでにある場所ですし、アプリの起動のたびに叩かれる URL の前に CDN を置いてくれます。これをやるならマニフェストのキャッシュには気をつけてください。素の base-URL 配信アダプターでは、Patch は CDN をパージできないためマニフェストを <code>no-cache</code> で配信します。マニフェストが独自の TTL でキャッシュされると、ロールバックしたばかりのリリースを配り続けてしまうからです。設定リファレンスには今、この点が <code>MANIFEST_CACHE_CONTROL</code> の項に書かれていて、ドキュメントサイトでも S3 互換ストレージが扱われています。</p>
<p>ホスト済みのサーバーでもう一度アプリを作り、新しいキーを <code>eas.json</code> の <code>preview</code> と <code>production</code> プロファイルに（Staging のキーと Production のキー）入れ、パブリッシュ用のトークンを作ります。</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 のシークレットとして保管し、公開鍵をビルドプロファイルに入れ、両アプリで「require code signing」を有効にします。Patch 入りの最初のバイナリには新しいバージョン番号も必要です。ストアにすでにあるバイナリは <code>expo-updates</code> を動かしているからです。まず preview プロファイルで TestFlight と Play の内部テスト向けにビルドし、誰かに渡す前に、キーが実際にビルド済みの <code>Info.plist</code> と Android のリソースに入ったかを確認してください。環境変数を手でエクスポートしていたので、ここだけはローカルのテストで検証できていない繋ぎ目です。それから Staging にパブリッシュし、テスター1人につきダウンロード1件と成功1件を確認して、そこで初めてストア向けにビルドします。</p>
<p>Production のリリースは小さく始めて広げます。</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="#%e5%88%9d%e3%82%81%e3%81%a6%e3%81%ae%e3%83%8d%e3%82%a4%e3%83%86%e3%82%a3%e3%83%96%e3%82%a2%e3%83%83%e3%83%97%e3%82%b0%e3%83%ac%e3%83%bc%e3%83%89" 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>上の本番のセクションでは、誰かにビルドを渡す前に、キーが実際にビルド済みの <code>Info.plist</code> と Android のリソースに入ったかを確認するよう書きました。今回はちゃんと確認しましたし、このセクションがあるのはその確認のおかげです。iOS のビルドには Patch の設定が4つともありました。Android のビルドには1つもありませんでした。</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 のビルドではキーを4つ出力し、2.6.0 では何も出しませんでした。環境変数のせいではありません。Gradle の <code>generateReleaseResValues</code> は4つとも書き出しています。消しているのはその後に走るリリースのリソースシュリンカーで、Android Gradle Plugin 9 ではずっと攻撃的になりました。同じアプリでリソースが 9,240 個から 7,116 個に減っています。SDK は設定を <code>getIdentifier</code> で名前から読みますが、シュリンカーにはそれが見えないので、4つの文字列を未使用とみなして消しました。存在しない文字列は空として読まれ、空は「アップデートなし」を意味します。何も失敗しなかったはずです。Android の 2.6.0 はインストールされ、普通に動き、アップデートを一度も受け取らず、唯一の兆候はダッシュボードに Android 2.6.0 の端末が1台もないことだけだったでしょう。</p>
<p>修正は、シュリンカーに4つの文字列を残すよう伝えるリソースファイル1つです。</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 のバンドルタスクが JavaScript のアセット用にすでに <code>raw/keep.xml</code> を生成していて（画像やフォントが生き残ったのはそのためです）、2つがぶつかります。ライブラリの keep ファイルはアプリに自動でマージされるので、このルールの置き場所としては SDK 自体のほうがふさわしく、デプロイメントキーなしでリリースビルドが起動したときに警告が1つ出るだけでも、黙って起きていたことが目に見えるようになります。前に出てきた iOS のバージョン検出のバグと同じ種類の失敗です。何もエラーにならず、アップデートがただ届かない。</p>
<p>同じ確認で、Patch とは関係のない2つ目の問題も見つかりました。こちらは別の理由で知っておく価値があります。Android のリリースビルドは起動直後にクラッシュしました。それも2回、Expo 自身のモジュールの中にある別々の2つの原因によってです。まず、Expo のウィジェット用モジュールがネイティブクラスの登録に失敗し、JavaScript が動く前にアプリが落ちていました。それを直すと、アプリは JavaScript の起動までは進んだものの、そこで止まりました。Expo の新しいモジュールシステムに移った3つのモジュールが登録されていなかったからです。どちらにも Patch のクラッシュロールバックは役に立ちません。ロールバックすべきアップデートがないからです。守ってくれるのは悪いアップデートからであって、悪いバイナリからではありません。Patch があってもなくても、ネイティブのアップグレードはストアに出す前に実機かエミュレーターにインストールして開いてみる必要があります。</p>
<p>同じテストから、もう1つ小さなことが出てきました。<code>sync()</code> は失敗すると <code>&quot;error&quot;</code> を返し、アプリが開発モードで動いていない限り理由は教えてくれません。なのでリリースビルドでは、何かがうまくいかなかったことしかログに残せません。今は私のフックが、sync が失敗したあとに一度だけ <code>checkForUpdate()</code> を呼んで、そこで投げられたものをログに出します。そうして初めて、サーバーが壊れていたのではなく、ローカルのシミュレータービルドの Patch の URL が空だった（<code>unsupported URL</code>）とわかりました。最後のエラーがリリースビルドでも読めれば助かります。</p>
<p>ネイティブリリースのチェックリストには、以前はなかった3行が加わりました。ビルド済みの <code>.ipa</code> と <code>.aab</code> から Patch の設定をダンプすること、デプロイメントキーをビルドプロファイルと突き合わせること、そして提出前にデバッグビルドではなくリリースビルドそのものを起動すること。</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="#expo-%e3%81%ae%e8%ab%8b%e6%b1%82%e6%9b%b8%e3%82%92%e6%8a%b1%e3%81%88%e3%81%a6%e3%81%84%e3%82%8b%e4%ba%ba%e3%81%ab%e4%bc%9d%e3%81%88%e3%81%9f%e3%81%84%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>サーバーを作るのが大変そうに見えたから EAS Update にお金を払っているのなら、Patch は本物の答えです。サーバーは動かしますが、それは Compose スタックとインストーラーであって、自分でメンテナンスするコードではありません。私が作ったものと比べると、段階的ロールアウト、リリースごとのインストール数と失敗数、バイナリパッチ、フィンガープリントのガード、コード署名、端末側でのクラッシュロールバック、ロールバックボタンが手に入ります。私はどれも作っていませんでした。</p>
<p>代償は、SDK をアプリに入れる必要があるので、誰かに Patch のアップデートが届く前に新しいバイナリが要ること。アップグレードとバックアップを続けるサーバー。リリースが十分ひどければ、デフォルトでユーザー一人あたり3回のクラッシュ起動。ネイティブプロジェクトをコミットしているなら、いくらかの手作業。それに、まだ若い。今回のバージョンは 0.3.0 で、バージョン検出のバグ、うるさいデバッグコマンド、ドキュメントの穴をいくつか踏み、SDK が防げたはずのバグを自分でも作りました。バージョン検出とデバッグコマンドはその後修正され、ドキュメントの穴も大半が埋まり、私がやった間違いも今は 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/ja/posts/2026/2026-09-09-cloudflare-crawlers-cache/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/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 の SPA を 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 タグ1つと空の 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="#%e3%82%af%e3%83%ad%e3%83%bc%e3%82%ad%e3%83%b3%e3%82%b0%e3%81%a7%e3%81%af%e3%81%aa%e3%81%84%e3%83%80%e3%82%a4%e3%83%8a%e3%83%9f%e3%83%83%e3%82%af%e3%83%ac%e3%83%b3%e3%83%80%e3%83%aa%e3%83%b3%e3%82%b0" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>対処法は古く、Google も認めています。クローラーを判定し、SPA が描いたはずのものと同じ内容を含むサーバーレンダリング HTML を返す。別の内容ではなく、同じ内容を、より早く。</p>
<p>そこで <code>/listings/view?id=...</code> のような出品 URL は分岐します。ブラウザには SPA のシェル。クローラーには本物のドキュメント。見出し、出品の主要な情報、alt 付きの写真ごとの画像、本文の抜粋、そしてこのページが孤立しないよう関連する出品いくつかへのリンク。</p>
<p>最後の項目が思っていたより効きました。内部リンクができる前は、約1,000件の出品ページに何もリンクしておらず、Search Console でクロールが1日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="#%e7%bd%a0-%e3%83%99%e3%83%b3%e3%83%80%e3%83%bc%e5%90%8d%e3%81%af%e3%81%9d%e3%81%ae%e3%83%9c%e3%83%83%e3%83%88%e3%81%ae%e5%90%8d%e5%89%8d%e3%81%a7%e3%81%af%e3%81%aa%e3%81%84" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>クローラー判定は user-agent のマッチで、ここでハマりました。</p>
<p>韓国のプラットフォームは、会社名とまったく似ていない名前でクロールします。Naver のクローラーは <strong>Yeti</strong>。Daum は <strong>Daumoa</strong>。Kakao のリンクスクレイパーは <strong>kakaotalk-scrap</strong>。3つとも、ベンダー名ではありません。</p>
<p>一方で同じベンダーが、埋め込みブラウザを持つアプリを出しています。そのブラウザは本物のブラウザ UA の末尾にアプリ名を足して送ります。KakaoTalk のアプリ内ブラウザは <code>KAKAOTALK/10.5.1</code>。Kakao のスクレイパーは <code>kakaotalk-scrap/1.0</code>。</p>
<p><code>kakao</code> でマッチすれば両方に当たります。私はまさにそれをやり、その結果リンクを開いた KakaoTalk・Naver アプリ・Daum アプリのユーザー全員がクローラー向けページを受け取りました。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="#%e3%81%9d%e3%81%97%e3%81%a6%e5%89%8d%e6%ae%b5%e3%81%ab-cloudflare-%e3%82%92%e7%bd%ae%e3%81%84%e3%81%9f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>オリジンはシドニーの共有 CPU マシン1台で、毎秒20〜30リクエストを支えます。Googlebot が巡回する URL は約995本。その全部がシドニーまで到達していました。すべての URL で <code>cf-cache-status</code> が <code>DYNAMIC</code> だったからです。<strong>Cloudflare はルールが指示しない限り HTML をキャッシュしません。</strong> ゾーンの約16%だけがキャッシュされ、肝心の部分は1つも入っていませんでした。</p>
<p>そこでクローラー向けページにキャッシュルールを。単純な話です。出品 URL の本文が1つではない、という点を除けば。本文は3つあります。</p>
<ul>
<li>HTML を求めるクローラーには、サーバーレンダリングされたページ。</li>
<li>人間には、SPA のシェル。</li>
<li><code>Accept: text/markdown</code> を送るエージェントには、データベースから組み立てた Markdown。空の HTML シェルを変換しても空のドキュメントにしかならないからです。</li>
</ul>
<p>オリジンは <code>Vary: Accept</code> を送り、user-agent に対する <code>Vary</code> はどのみち望み薄です。そしてここに、私が知らなかったことがあります。</p>
<blockquote><p><strong>Cloudflare は HTML では <code>Vary</code> を無視します。</strong> 3つの本文が1つのキャッシュキーを共有します。</p>
</blockquote><p>先に到着した本文がその URL を取り、同じエッジロケーションのその後の訪問者全員に対して、オリジンの <code>s-maxage</code> が許す1時間、さらに <code>stale-while-revalidate</code> の1日ぶん、それが出続けます。</p>
<p>本番で両方向とも観測できました。</p>
<ul>
<li>Naver アプリのユーザーがクローラー向けページで出品 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="#%e7%b5%9e%e3%82%8b%e3%81%93%e3%81%a8%e3%81%a7%e7%9b%b4%e3%81%99%e3%83%ab%e3%83%bc%e3%83%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Cloudflare に HTML で <code>Vary</code> を尊重させることはできません。できるのは、3つの本文のうち<strong>1つだけ</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> を読んでオリジンへ行きます。キャッシュを読み書きするのはクローラーのリクエストだけで、そこに入りうる本文はクローラー向けの1つだけです。</p>
<p>エッジ TTL は <code>respect_origin</code>。寿命はダッシュボードに固定されるのではなく、ページをレンダリングするコードが持ち続けます。</p>
<p>見落としやすい運用上の細部を1つ。<strong>ルールを絞っても、すでにキャッシュにあるものは消えません。</strong> パージが必要です。さもないと、たった今生成を止めたばかりの汚染されたエントリを配り続けることになります。</p>

<h2 class="relative group">2つのリストと、その誤りが対称でない理由
    <div id="2つのリストとその誤りが対称でない理由" 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="#2%e3%81%a4%e3%81%ae%e3%83%aa%e3%82%b9%e3%83%88%e3%81%a8%e3%81%9d%e3%81%ae%e8%aa%a4%e3%82%8a%e3%81%8c%e5%af%be%e7%a7%b0%e3%81%a7%e3%81%aa%e3%81%84%e7%90%86%e7%94%b1" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>このルールにはクローラー UA のリストが必要で、Django にはすでに1つあります。同じ知識が2つのシステムに2部あるのは普通なら臭いますが、ここで面白いのは、両者がずれたときに何が起きるかです。対称ではないのです。</p>
<ul>
<li><strong>Cloudflare 側のボットリストは、Django 側の部分集合であり続ける必要があります。</strong> Cloudflare がボットだと思い Django はそう思わないトークンがあると、クローラー専用のキーの下に SPA シェルがキャッシュされ、それが本物のクローラーに出ていきます。</li>
<li><strong>アプリ内ブラウザの除外リストは、Django 側の上位集合である必要があります。</strong> 除外しすぎても、失うのはキャッシュヒットだけです。</li>
</ul>
<p>どちらも安全な方向に間違えれば、キャッシュが少し減るだけ。1つ目を逆方向に間違えれば、また 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="#%e3%83%9a%e3%83%bc%e3%82%b8%e3%81%a7%e3%81%af%e3%81%aa%e3%81%8f%e3%83%93%e3%83%ab%e3%83%89%e3%82%92%e8%90%bd%e3%81%a8%e3%81%99%e4%b8%8d%e5%a4%89%e6%9d%a1%e4%bb%b6" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>クローラー向けページには、7日間有効な署名付き画像 URL が埋め込まれています。ページ自体はエッジで1時間、加えて stale-while-revalidate の1日。</p>
<p>この2つの数字は関係していて、キャッシュ寿命が画像の寿命を超えた瞬間、エッジは全写真が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>別々のファイルにある2つの定数のあいだの制約は、コードレビューでは見えず、プロセス起動時には明白です。プロセスが起動する場所に置きましょう。</p>

<h2 class="relative group">Cloudflare がやったことのうち、取り消す必要があった2つ
    <div id="cloudflare-がやったことのうち取り消す必要があった2つ" 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-%e3%81%8c%e3%82%84%e3%81%a3%e3%81%9f%e3%81%93%e3%81%a8%e3%81%ae%e3%81%86%e3%81%a1%e5%8f%96%e3%82%8a%e6%b6%88%e3%81%99%e5%bf%85%e8%a6%81%e3%81%8c%e3%81%82%e3%81%a3%e3%81%9f2%e3%81%a4" 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.6MB の 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="#%e3%82%af%e3%83%ad%e3%83%bc%e3%83%ab%e3%82%92%e5%be%85%e3%81%9f%e3%81%9a%e3%81%ab%e6%a4%9c%e7%b4%a2%e3%82%a8%e3%83%b3%e3%82%b8%e3%83%b3%e3%81%b8%e7%9f%a5%e3%82%89%e3%81%9b%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>プロトコルは2つ、そして2つ合わせてもカバー範囲は残念なものです。</p>
<p><strong>IndexNow</strong> は ping を1つ受け取り、Bing、Yandex、Seznam をカバーします。Google と Naver は参加していません。出品が保存されるたびにバックグラウンドで短いタイムアウト付きの ping を送り、売却済み・終了済みの出品はスキップします。ネットワーク障害は警告を残すだけで、それを引き起こした保存を決して失敗させません。ベストエフォートのサイドチャネルに許される唯一の振る舞いです。</p>
<p><strong>Google の Indexing API</strong> はちょうど2つのスキーマ型しか受け付けず、その1つが私の持っている <code>JobPosting</code> です。生きている求人は <code>URL_UPDATED</code>、終了したものは <code>URL_DELETED</code>。その削除通知が誠実でいられるのは、終了した出品が <code>noindex</code> も出しているからで、コードにそう書いてあります。あとで noindex を外す人が、ほかに何を壊すのかを知れるように。</p>
<p>ここでは2つのことに時間を取られました。クォータは1日200 URL で、出品を一括インポートすると一度に数百回呼ばれます。だからインポートされた出品はスキップします。さもないと1回のインポートで1日分の予算を使い切ります。そしてエンドポイントはコロン入りの <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="#%e6%a7%8b%e9%80%a0%e5%8c%96%e3%83%87%e3%83%bc%e3%82%bf%e3%81%8c%e6%95%99%e3%81%88%e3%81%a6%e3%81%8f%e3%82%8c%e3%81%9f%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>3つのルール、どれも 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> を落とします。だから1つ導出し、古い出品が昨日期限切れになったと主張しないよう下限を置きます。</li>
</ol>
<p>そしてサイトマップについて4つ目。<strong>すべてに <code>changefreq: daily</code> を主張しない。</strong> 1,000件の出品を毎日再クロールしてくれという要求は、過負荷のホストとして読まれ、Google はサイト全体のクロールレートを下げることで応じます。実際にどれだけ最近変わったかから導出しましょう。</p>
<p>サイトマップには品質の下限もあります。タイトルと本文を合わせて40文字。Search Console が約1,000件を「クロール済み – インデックス未登録」と報告していて、ある中古品ページの固有コンテンツは、一語のタイトルと電話番号だけでした。40文字は意図的に低くしてあります。484件の出品のうち471件が韓国語で、韓国語は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="#%e3%81%93%e3%81%93%e3%81%8b%e3%82%89%e6%8c%81%e3%81%a1%e5%b8%b0%e3%82%8b%e3%82%82%e3%81%ae" aria-label="アンカー">#</a>
    </span>
    
</h2>
<ul>
<li><strong>1つの URL で異なる本文を返すことは、何よりもまずキャッシュキーの問題です。</strong> 使っている CDN が何をキーにしているかを突き止め、そのコンテンツタイプで実証するまでは <code>Vary</code> を無視すると想定してください。</li>
<li><strong>2つのシステムが1つのリストで一致しなければならないなら、どちら向きの誤りが安全かを見極め</strong>、それを両方の写しの隣に書いてください。「ずれてよい、ただし許されるずれ方はこれだけ」のほうが、ずれない前提より優れています。</li>
<li><strong>ボットはベンダー名ではなくボット名でマッチさせること。</strong> 特に英語圏の外では、そのベンダーがユーザーの手元のブラウザも作っています。</li>
<li><strong>離れた定数のあいだの不変条件は、大きな音で失敗する場所に置くこと。</strong> インポート時はタダで、誰もスキップできません。</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をオートスケール：負荷テストで小さなマシン1台の限界を測る</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-07-22-my-current-stack/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/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で動かしています。1台を速くし、負荷テストでレイテンシが崩れる同時実行数を測り、その手前にオートスケールの閾値を置く方法です。</description>
      <content:encoded><![CDATA[<p>Fly.ioでDjangoを安く動かすために、私はshared-cpu-1xの小さなマシン1台をできるだけ速くし、レイテンシが崩れるまで負荷テストをして、その少し手前でFlyのオートスケーラーが2台目を起動するように設定しています。負荷テストはステップ状に負荷を上げるLocust（もう一方のアプリでは同時実行数を順に変える小さなPythonスクリプト）で行い、p95とエラー率を見ます。トラフィックが落ちればアイドルのマシンは自動で止まるので、普段のコストはアプリごとに小さなVM 1台です。</p>
<p>私は現在、個人開発者として2つのプロダクトを運用しています。ひとつは現場で見積もりを出す職人向けの<strong>現場見積もりアプリ</strong>、もうひとつは出品とリアルタイムメッセージングを備えた<strong>マーケットプレイス兼コミュニティアプリ</strong>です。</p>
<p>プロダクトもユーザーも全く違いますが、技術スタックはほぼ同一です。そしてどちらも、Fly.ioの最安ティアである<strong>shared-cpu-1xマシン</strong>だけで動いています。この制約は意図的なものです。ピーク負荷に合わせてサーバーを大きくするのではなく、小さなマシン1台から絞り出せる性能を最大化し、どこで崩壊するかを正確に測定し、その実測された限界を軸にFlyのオートスケーラーにマシンの追加と削除を任せます。この記事では、それを可能にしている最適化、閾値の根拠となった負荷テストの数値、そしてFly.ioでスケールアップ/ダウンが実際にどう動くのかを紹介します。</p>

<h2 class="relative group">哲学：1台を測定し、それを掛け算する
    <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%93%b2%e5%ad%a61%e5%8f%b0%e3%82%92%e6%b8%ac%e5%ae%9a%e3%81%97%e3%81%9d%e3%82%8c%e3%82%92%e6%8e%9b%e3%81%91%e7%ae%97%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>両アプリともスケーリング戦略は同じ3ステップです。</p>
<ol>
<li>小さなマシン1台を、合理的な範囲で可能な限り速くする。</li>
<li>壊れるまで負荷テストを行い、レイテンシが崩壊する同時実行数を記録する。</li>
<li>その直前で2台目のマシンが起動するようFlyのオートスケーラーを設定し、トラフィックが減ったらアイドルマシンを停止させる。</li>
</ol>
<p>こうすればキャパシティはマシン数にほぼ線形に比例し、ベースラインコストはアプリごとに小さなVM 1台です。以下の内容はすべて、この3ステップのいずれかのためにあります。</p>

<h2 class="relative group">小さなマシン1台を速くする
    <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%b0%8f%e3%81%95%e3%81%aa%e3%83%9e%e3%82%b7%e3%83%b31%e5%8f%b0%e3%82%92%e9%80%9f%e3%81%8f%e3%81%99%e3%82%8b" 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%e3%81%afwsgiwebsocket%e3%81%afuvloop-asgi" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>両バックエンドともDjango 6 + Django REST Frameworkです。APIはRustベースのサーバー<strong>Granian</strong>がサービングしますが、ASGIではなく<strong>ブロッキングスレッドプールを使うWSGI</strong>で動かしています。</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>で—ワーカーあたり一度に1つずつ—実行します。つまりASGIは、ワーカーあたりの同時リクエストを実質1つに絞りながら、asyncio/uvloop/Channelsのスタック一式をただメモリに載せていただけでした。<code>--blocking-threads</code>を使うWSGIは、同じ同期ビューを本物のスレッドプールで走らせます。ワーカー1つ × スレッド16 = I/Oバウンドなリクエスト（DB読み取り、S3のプリサイン）16件を同時に、非同期スタックより低い常駐メモリでさばきます。</p>
<p>なぜワーカーが1つなのか？VMが<code>shared-cpu-1x</code>—1コアだからです。2つ目のワーカーは1コアではPythonを並列に走らせられない（GIL + 1コア）くせに、常駐するDjangoプロセスをまるごと1つ余分に食います。Granianはまさにこの点を警告します。ワーカー1つにスレッド16が、I/Oバウンドな処理における1 vCPUマシンの正直な上限です。<code>--workers-max-rss 1400</code>はワーカーがメモリ上限を超えると再生成するので、暴走したPDFや画像処理がマシンをOOMへ引きずっていく代わりにリサイクルされます。</p>
<p>WebSocketは自前のGranianプロセスに切り出しました。uvloopと<code>--ws</code>を使う<strong>ASGI</strong>アプリで、長寿命コネクションのためにワーカー1つで動かします。同期APIにはASGIの経路はまったく不要です。ビューがソケットへプッシュしたいときはRedis経由で<code>async_to_sync(channel_layer.group_send)</code>を呼ぶので、メッセージは変わらずWebSocketプロセスへ渡ります。2つのサーバーが、それぞれ本当に得意なインターフェースを走らせているわけです。ローカルのDockerも同じWSGIスレッドプールで動かしているため、テスト時は私のラップトップがプロダクションマシン1台の近似になります。</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="#%e3%83%9b%e3%83%83%e3%83%88%e3%83%91%e3%82%b9%e3%81%ab%e3%81%afrust" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>PythonのホットパスにRustベースの代替があれば、必ず採用します。</p>
<ul>
<li><strong>orjson</strong>（<code>drf-orjson-renderer</code>経由）— すべてのAPIレスポンスが標準ライブラリのJSONエンコーダーではなくRustでシリアライズされます。</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>これらはアーキテクチャを変えません。2台目のマシンが必要になる前に、1 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="#%e9%9a%a0%e3%82%8c%e3%81%9f%e3%83%ac%e3%82%a4%e3%83%86%e3%83%b3%e3%82%b7%e7%a8%8e%e3%82%92%e6%bd%b0%e3%81%99" 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>にしてコネクション再利用をプーラーに任せ、古くなったSSLコネクションでワーカーが固まらないよう<code>connect_timeout</code>を5秒にして素早く失敗させます。</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%9c%ac%e5%bd%93%e3%81%ae%e3%83%9c%e3%83%88%e3%83%ab%e3%83%8d%e3%83%83%e3%82%af%e3%81%ae%e5%89%8d%e3%81%ab%e3%82%ad%e3%83%a3%e3%83%83%e3%82%b7%e3%83%a5%e3%82%92%e7%bd%ae%e3%81%8f" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>マーケットプレイスの負荷テストで、覚えておく価値のある事実が判明しました。<strong>天井はCPUではなくPostgresのコネクションだった</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="#%e3%83%96%e3%83%ad%e3%83%bc%e3%82%ab%e3%83%bc%e3%81%aa%e3%81%97%e3%81%ae%e3%83%90%e3%83%83%e3%82%af%e3%82%b0%e3%83%a9%e3%82%a6%e3%83%b3%e3%83%89%e5%87%a6%e7%90%86" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>両アプリともCeleryではなく、Djangoのデータベースバックエンドのタスクフレームワーク<strong>django-tasks-db</strong>を使っています。独立した<code>db_worker</code>プロセスがデータベースをポーリングし、画像処理、PDF生成、機械翻訳、リマインダーといったジョブを処理します。ブローカーはなく、サイズを見積もって監視すべき追加サービスもなく、APIプロセスが重い処理でブロックされることもありません。さらにワーカーは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="#%e3%83%a1%e3%83%a2%e3%83%aajemalloc%e3%81%a8%e3%82%b9%e3%83%af%e3%83%83%e3%83%97%e3%81%ae%e3%82%af%e3%83%83%e3%82%b7%e3%83%a7%e3%83%b3" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>画像とPDFの処理は、定常状態ベースの見積もりでは予測できないメモリスパイクを起こします。安上がりな緩和策が2つ。Dockerイメージに<strong>jemalloc</strong>をプリロードして断片化を抑えること、そして<strong>全マシンにスワップ</strong>を持たせることです（APIマシンに2GB、WebSocketマシンに1GB）。スパイクが来てもプロセスがOOMで殺される代わりにディスクへスワップされます。スワップは性能のための機能ではなく、メモリ割り当てを小さく保つための保険です。</p>

<h2 class="relative group">Djangoの負荷テスト：膝（knee）を見つける
    <div id="djangoの負荷テスト膝kneeを見つける" 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%e3%81%ae%e8%b2%a0%e8%8d%b7%e3%83%86%e3%82%b9%e3%83%88%e8%86%9dknee%e3%82%92%e8%a6%8b%e3%81%a4%e3%81%91%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>閾値の根拠として通用するのは、実測した値だけです。プロジェクトごとに専用のハーネスがあります。</p>
<ul>
<li>見積もりアプリには、同時実行数を掃引してRPS、p50/p95/p99、エラー率を報告する小さなPythonのストレステストスクリプトがあります。生成した数百件のサンプルデータに対して実行するので、リスト系エンドポイントは現実的なデータ量でクエリされます。</li>
<li>マーケットプレイスは<strong>Locust</strong>で30秒刻みのステップ負荷により50 → 500ユーザーまでランプして飽和点を探し、実際の閲覧中心トラフィックに合わせたエンドポイント重み付けを使っています。さらに別建ての小さなテストスイートが、仮想ユーザー5 → 50をp95 &lt; 2秒の閾値付きでプロダクションに対してランプします。</li>
</ul>
<p>見積もりアプリのスイープの代表的な数値です（Granianワーカー1 × ブロッキングスレッド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マシン1台</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%e3%81%a7%e3%81%ae%e3%82%b9%e3%82%b1%e3%83%bc%e3%83%aa%e3%83%b3%e3%82%b0%e3%81%ae%e4%bb%95%e7%b5%84%e3%81%bf" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Flyのモデルはシンプルです。アプリはプロキシの背後にある同一マシンの集合で、プロキシがマシンごとの負荷を数えます。宣言するのは2つの数字だけです。</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><strong>スケールアップ：</strong> あるマシンの処理中（in-flight）リクエストがsoft limitを超えると、プロキシは停止中のマシンを起動し、新しいトラフィックをそちらへ流します。WSGIスレッドプールによってマシンあたり実質16の同時スロットがあるので、soft limitは12—プールの約75%で、ユーザーが数秒のレイテンシを体験した後ではなく、スレッドにまだ余裕があるうちに2台目が起動します。hard limitの24（プールの1.5倍）はサーキットブレーカーです。短いバーストはスレッド上でキューイングされ、それを超えたら、1台のマシンが溶け落ちるのを許すのではなく、プロキシが負荷を遮断します。（旧来のASGIビルドは実質スロットが約3で8/20でした。プールを広げたことで両方の数字を引き上げられたのです。）</p>
<p><strong>スケールダウン：</strong> トラフィックが減ると、Flyはアイドルマシンを自動的に停止し、<code>min_machines_running = 1</code>まで縮小します。停止中のマシンはコンピューティング費用がかからず rootfs のストレージ分だけなので、各アプリのベースラインコストは文字通り小さなVM 1台で、バースト用のキャパシティは常に待機しています。</p>
<p>私はここに、停止中のマシンは「1秒未満でコールドスタートする」と書いていました。それは間違いで、しかも高くつく間違いでした。想定ではなく実際に計測したところ、コールドスタートには<strong>25〜33秒</strong>かかっていて、まだ起動中のマシンに振られたリクエストはその時間をまるごと待たされます。当初は、ヘルスチェックがずっと緑のままCDNのダッシュボードに出ていた22パーセントの失敗率を、このコールドスタートのせいだと考えていました。ところがその数字は、CDN自身のEarly Hintsのプローブがオリジンで失敗していたもので、実際のクライアントが受け取った504はひとつもありませんでした。それでも、コールドスタートそのものは本物でした。</p>
<p>対処は上のスケーリングの数値にはありません。あの数値は問題ないのです。マシンが立ち上がる途中で何をしているか、そして Fly にマシンを停止ではなくサスペンドさせるかどうかにあります。そうすれば復帰は約3秒のメモリ復元になります。別記事にまとめました。<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/" >ゼロスケールと、再起動のたびに起きる30秒の障害</a>で、診断の過程と変更すべき点を扱っています。<code>auto_stop_machines</code> を使って Fly で何かを動かしているなら、この段落を信じる前にそちらを読んでください。</p>
<p>ひとつ微妙なポイント：同時実行のタイプが<code>connections</code>ではなく<code>requests</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%e3%81%af%e5%88%a5%e3%81%ae%e8%bb%b8%e3%81%a7%e3%82%b9%e3%82%b1%e3%83%bc%e3%83%ab%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>マーケットプレイスはWebSocketサーバーを、同じDockerイメージを共有する<strong>2つ目のFlyアプリ</strong>に分離しています。APIアプリはWSGIでワーカー1つ × ブロッキングスレッド16を動かし、<em>リクエスト</em>を数えます（soft limit 12）。WebSocketアプリは<strong>512MB</strong>のマシンでASGIでuvloopとともにワーカー1つを動かし、<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キャパシティのコストは2GBではなく512MBで済みます。Channels側では、Redisレイヤーを<code>capacity: 1500</code>、メッセージ有効期限60秒で動かし、30秒ごとにソケットの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="#%e3%82%b3%e3%83%b3%e3%83%94%e3%83%a5%e3%83%bc%e3%83%86%e3%82%a3%e3%83%b3%e3%82%b0%e3%82%92%e3%82%a8%e3%83%83%e3%82%b8%e3%81%a8%e3%82%af%e3%83%a9%e3%82%a4%e3%82%a2%e3%83%b3%e3%83%88%e3%81%b8%e3%82%aa%e3%83%95%e3%83%ad%e3%83%bc%e3%83%89%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>最も安いサーバー処理は、サーバーが一切やらない処理です。</p>
<ul>
<li><strong>静的ファイルとメディア</strong>はFlyのS3互換オブジェクトストレージに置き、非公開メディアは署名付きURLでCDN経由配信します。画像のバイト列はDjangoマシンを一切通りません。</li>
<li><strong>AI推論はユーザーのスマートフォン上で動きます。</strong> 両アプリとも小さなローカル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="#%e6%ae%8b%e3%82%8a%e3%81%ae%e3%82%b9%e3%82%bf%e3%83%83%e3%82%af%e3%82%92%e6%89%8b%e7%9f%ad%e3%81%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>認証はホスティング型のIDプロバイダーとWebAuthnパスキーで、WebSocketコネクションはアップグレード完了前に認証します。プロダクションはPostgres、開発はSQLite。管理画面と監査履歴には既製のDjangoパッケージをいくつか使っています。モバイルはExpo上のReact NativeとTypeScriptにExpo RouterとTailwindを組み合わせ、OTAアップデートはセルフホストのExpo Updatesサーバーから配信し、アプリ内課金はサブスクリプション管理サービスに任せています。品質ゲートはバックエンドがpytestとmypy、クライアントがユニットテストとE2Eテストです。</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%8c%81%e3%81%a1%e5%b8%b0%e3%81%a3%e3%81%a6%e3%81%bb%e3%81%97%e3%81%84%e3%82%82%e3%81%ae" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>移植できるアイデアは特定のライブラリではなく、ループそのものです。安いマシン1台を本当に速くする（Rustのホットパス、コネクション再利用、本当のボトルネックの前のキャッシュ、CDNとクライアントへのオフロード）。どの同時実行数で壊れるかを特定できるまで負荷テストする。オートスケーラーのsoft limitをその膝に置き、アイドルマシンは停止させ、最小フリートは1に保つ。プロダクションアプリ2つ、リアルタイム機能、AIアシスタント — それでも定常時の請求額は、プラットフォームで最安のVM数台分です。</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/ja/posts/2026/2026-05-05-zustand-vs-react-query/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/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の併用のしかた、そして3つを組み合わせてオフライン対応を実現する方法をまとめました。</description>
      <content:encoded><![CDATA[<p>ZustandとReact Queryは競合しません。なので「Zustand vs React Query」の本当の答えは、たいてい「両方」です。React Query（現在はTanStack Queryという名前で公開されています）はサーバーステートのためのものです。サーバーが所有するデータを取得し、キャッシュし、重複したリクエストをまとめ、古くなったら再取得してくれます。Zustandはクライアントステートのためのもの。どのタブが開いているか、書きかけのフォームに何が入っているかといった、アプリだけが知っている情報です。ほとんどのアプリはこの2つを併用しますし、React NativeではAsyncStorageが3つ目のピースとして、再起動をまたいで残すべきものを受け持ちます。この記事では、その境界線を私がどこに引いているかをコードつきで説明し、3つを合わせてオフラインでも動くアプリにする方法を紹介します。</p>
<p>いま開発している見積もりアプリには、絶対に譲れない要件がひとつあります。電波がなくても動くこと。見積もりは現場を訪ねてその場で作るのですが、現場は新築の建物だったり地下だったりで、電波はあてになりません。この制約のおかげで、多くのReactプロジェクトが曖昧なままにしている「どの種類のステートをどこに置くか」という問題に、正面から向き合わざるを得ませんでした。</p>
<p>よくある失敗は、すべてをひとつのストアに突っ込むことです。APIキャッシュもユーザー設定もフォームの状態も認証トークンも、全部同じReduxストアの中。でもこれらは性質もライフサイクルも違うステートで、それぞれに合った道具があります。Zustand、React Query、AsyncStorageはそれぞれちょうどひとつの問題をうまく解決してくれるので、境界線さえ引けば、アーキテクチャはだいたい勝手に片付きます。</p>

<h2 class="relative group">ステートは3種類ある
    <div id="ステートは3種類ある" 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="#%e3%82%b9%e3%83%86%e3%83%bc%e3%83%88%e3%81%af3%e7%a8%ae%e9%a1%9e%e3%81%82%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ライブラリの話をする前に、自分が何を管理しているのかをはっきりさせておきましょう。</p>
<p>クライアントステートは、アプリのランタイムにしか存在しないデータです。UIのトグル、選択中のタブ、フォーム入力、モーダルの開閉。上流に所有者はおらず、プロセスが終われば消えます。</p>
<p>サーバーステートは事情が違います。所有者はサーバーで、アプリが持っているのはローカルコピーにすぎません。ユーザープロフィール、商品一覧、通知、フィード。手元のコピーは古くなりますし、再取得が必要ですし、今この瞬間に別のクライアントが別のバージョンを見ているかもしれません。</p>
<p>永続ステートは、再起動をまたいで残す必要があるデータです。認証トークン、オンボーディング完了フラグ、キャッシュした設定、オフライン用データ。デバイスそのものに保存されます。</p>
<p>たいていのアプリには3つとも存在します。そして、たいていの混乱は、3つをひとつの道具で扱おうとするところから始まります。</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%e3%82%af%e3%83%a9%e3%82%a4%e3%82%a2%e3%83%b3%e3%83%88%e3%82%b9%e3%83%86%e3%83%bc%e3%83%88%e6%8b%85%e5%bd%93" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Zustandは余計な儀式のない小さなステートライブラリです。プロバイダーなし、ボイラープレートなし、何重にもネストしたコンテキストラッパーもなし。ストアを作ってコンポーネントで使う、覚えることはほぼそれだけです。</p>
<p>出番になるのは、複数のコンポーネントが同じUIステートを必要とするとき（サイドバーの開閉、アクティブなフィルター、選択中のアイテム）、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">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="#zustand%e3%81%ab%e5%85%a5%e3%82%8c%e3%81%aa%e3%81%84%e3%82%82%e3%81%ae" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>まずAPIレスポンス。APIコールのたびにZustandストアへ書き込み、コンポーネントはクエリの代わりにストアを読む、というプロジェクトを見たことがあります。行き着く先は、キャッシュ無効化・ローディング状態・エラーハンドリング・再取得・ページネーションの手作り再実装です。それこそ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%e3%82%b5%e3%83%bc%e3%83%90%e3%83%bc%e3%82%b9%e3%83%86%e3%83%bc%e3%83%88%e6%8b%85%e5%bd%93" 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="#%e6%9b%b8%e3%81%8b%e3%81%9a%e3%81%ab%e6%b8%88%e3%82%80%e3%82%82%e3%81%ae" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>サーバーデータはここに置くべきだと私が言い張る理由は、タダでついてくる仕組みの多さです。5つのコンポーネントが同じユーザーを要求しても、ネットワークリクエストは1回だけ。ウィンドウがフォーカスを取り戻したり回線が復活したりすれば再取得してくれます。古いキャッシュは勝手にガベージコレクションされます。どのクエリも <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%90%91%e3%81%84%e3%81%a6%e3%81%84%e3%81%aa%e3%81%84%e3%82%82%e3%81%ae" 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%e5%86%8d%e8%b5%b7%e5%8b%95%e3%82%92%e7%94%9f%e3%81%8d%e5%bb%b6%e3%81%b3%e3%82%8b%e3%82%82%e3%81%ae%e6%8b%85%e5%bd%93" 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="#%e3%83%97%e3%83%a9%e3%83%83%e3%83%88%e3%83%95%e3%82%a9%e3%83%bc%e3%83%a0%e3%81%94%e3%81%a8%e3%81%ae%e4%b8%ad%e8%ba%ab" 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〜10MB程度。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>ブラウザのオリジンストレージ</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="#%e7%a7%81%e3%81%8c%e5%85%a5%e3%82%8c%e3%81%a6%e3%81%84%e3%82%8b%e3%82%82%e3%81%ae" 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%e3%81%a7%e3%81%ae%e7%9b%b8%e5%bd%93%e5%93%81" 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%90%91%e3%81%84%e3%81%a6%e3%81%84%e3%81%aa%e3%81%84%e3%82%82%e3%81%ae-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">3つの組み合わせ方
    <div id="3つの組み合わせ方" 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="#3%e3%81%a4%e3%81%ae%e7%b5%84%e3%81%bf%e5%90%88%e3%82%8f%e3%81%9b%e6%96%b9" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>実際のアプリでは3つを同時に使います。私がよく使うパターンをいくつか。</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%aa%8d%e8%a8%bc%e3%83%95%e3%83%ad%e3%83%bc" 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="#%e5%86%8d%e8%b5%b7%e5%8b%95%e3%81%97%e3%81%a6%e3%82%82%e6%b6%88%e3%81%88%e3%81%aa%e3%81%84%e3%83%86%e3%83%bc%e3%83%9e%e8%a8%ad%e5%ae%9a" 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="#%e3%82%aa%e3%83%95%e3%83%a9%e3%82%a4%e3%83%b3%e3%83%95%e3%82%a1%e3%83%bc%e3%82%b9%e3%83%88%e3%81%aa%e8%aa%ad%e3%81%bf%e5%8f%96%e3%82%8a" 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="#%e3%82%aa%e3%83%95%e3%83%a9%e3%82%a4%e3%83%b3%e3%83%a2%e3%83%bc%e3%83%89%e3%82%92%e9%a0%86%e7%95%aa%e3%81%ab%e7%b5%84%e3%82%80" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ここが私にとっての本題です。一般論としても、ユーザーは地下鉄やエレベーターや飛行機の中でアプリを開くものですが、私の場合はもっと切実で、見積もりは現場で書くものなのに、現場に電波があるとは限りません。地下でスピナーが回りっぱなしというのは、見積もりを1件落とすということです。</p>
<p>うれしいことに、この3つのライブラリだけで、重量級フレームワークに頼らず、かなりしっかりしたオフライン構成が組めます。</p>

<h3 class="relative group">ステップ1：オフラインかどうかを知る
    <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="#%e3%82%b9%e3%83%86%e3%83%83%e3%83%971%e3%82%aa%e3%83%95%e3%83%a9%e3%82%a4%e3%83%b3%e3%81%8b%e3%81%a9%e3%81%86%e3%81%8b%e3%82%92%e7%9f%a5%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>まず、デバイスがネットワークの状態をアプリに伝え、アプリがそれをどこかに覚えておく必要があります。イベントは <code>@react-native-community/netinfo</code> が発行してくれるので、小さなZustandストアに入れて、どこからでも読めるようにします。</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">ステップ2：React Queryにオフラインを教える
    <div id="ステップ2react-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="#%e3%82%b9%e3%83%86%e3%83%83%e3%83%972react-query%e3%81%ab%e3%82%aa%e3%83%95%e3%83%a9%e3%82%a4%e3%83%b3%e3%82%92%e6%95%99%e3%81%88%e3%82%8b" 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>3つのモードはこうなっています。</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">ステップ3：クエリキャッシュを永続化する
    <div id="ステップ3クエリキャッシュを永続化する" 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="#%e3%82%b9%e3%83%86%e3%83%83%e3%83%973%e3%82%af%e3%82%a8%e3%83%aa%e3%82%ad%e3%83%a3%e3%83%83%e3%82%b7%e3%83%a5%e3%82%92%e6%b0%b8%e7%b6%9a%e5%8c%96%e3%81%99%e3%82%8b" 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時間 - 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">ステップ4：オフライン中の書き込みをキューに積む
    <div id="ステップ4オフライン中の書き込みをキューに積む" 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="#%e3%82%b9%e3%83%86%e3%83%83%e3%83%974%e3%82%aa%e3%83%95%e3%83%a9%e3%82%a4%e3%83%b3%e4%b8%ad%e3%81%ae%e6%9b%b8%e3%81%8d%e8%be%bc%e3%81%bf%e3%82%92%e3%82%ad%e3%83%a5%e3%83%bc%e3%81%ab%e7%a9%8d%e3%82%80" 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に「送信中...」のインジケーターを表示する
</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">ステップ5：回線が戻ったら整合させる
    <div id="ステップ5回線が戻ったら整合させる" 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="#%e3%82%b9%e3%83%86%e3%83%83%e3%83%975%e5%9b%9e%e7%b7%9a%e3%81%8c%e6%88%bb%e3%81%a3%e3%81%9f%e3%82%89%e6%95%b4%e5%90%88%e3%81%95%e3%81%9b%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>再接続まわりの仕事は、React Queryがほとんど自分でやってくれます。一時停止していたミューテーションは再開し、古くなったクエリは再取得されます。こちらの仕事は、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>アプリを1時間バックグラウンドに置いてから戻ってくれば <code>focusManager</code> が再取得を走らせ、トンネルを抜けて電波が戻れば <code>onlineManager</code> が止まっていたものを再開してくれます。</p>

<h3 class="relative group">ステップ6：UIでちゃんと伝える
    <div id="ステップ6uiでちゃんと伝える" 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="#%e3%82%b9%e3%83%86%e3%83%83%e3%83%976ui%e3%81%a7%e3%81%a1%e3%82%83%e3%82%93%e3%81%a8%e4%bc%9d%e3%81%88%e3%82%8b" 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="#%e5%85%a8%e4%bd%93%e5%83%8f" 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のミューテーション</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="#%e6%9c%ac%e7%89%a9%e3%81%ae%e5%90%8c%e6%9c%9f%e3%82%a8%e3%83%b3%e3%82%b8%e3%83%b3%e3%81%8c%e5%bf%85%e8%a6%81%e3%81%ab%e3%81%aa%e3%82%8b%e3%81%a8%e3%81%8d" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>ここまでの構成は、サーバーのデータが正でオフラインは一時的なもの、という前提で成り立っています。競合解決込みの本当のオフラインファースト（2台のデバイスが同じドキュメントをオフラインで編集するノートアプリのような世界）が必要なら、このスタックでは足りず、専用の同期エンジンの出番です。WatermelonDBはReact Native向けに作られていて、SQLiteの上で動き、競合解決のための同期プロトコルを備えています。RealmはAtlas Device Syncと組み合わせれば、MongoDB Atlas経由の自動競合解決付きの完全なオフラインファーストデータベースになります。PowerSyncは既存のPostgresバックエンドと連携できるSQLiteベースの同期レイヤー。完全に自分で制御したければ、Expo SQLiteに自作の同期ロジックという道もあります。</p>
<p>Zustand + React Query + AsyncStorageで、モバイルアプリの8割くらいはカバーできます。残りの2割、つまり共同編集やマルチデバイス同期のようなオフライン中心のワークフローには、専用の同期データベースが必要です。</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="#%e3%81%a9%e3%81%93%e3%81%ab%e7%bd%ae%e3%81%8f%e3%81%8b%e8%bf%b7%e3%81%a3%e3%81%9f%e3%82%89" 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>1つのコンポーネントだけで使う単純なフォーム入力か？</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="#%e3%82%88%e3%81%8f%e3%81%82%e3%82%8b%e3%83%91%e3%82%bf%e3%83%bc%e3%83%b3" 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>1つのコンポーネントで完結し、共有の必要がない</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="#%e3%83%97%e3%83%a9%e3%83%83%e3%83%88%e3%83%95%e3%82%a9%e3%83%bc%e3%83%a0%e5%88%a5%e3%81%ae%e3%82%bb%e3%83%83%e3%83%88%e3%82%a2%e3%83%83%e3%83%97" 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>3つまとめて入れます。</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"># or</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="#web%e5%b0%82%e7%94%a8%e3%81%aereact" 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"># Optional for 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 is the default on web  - no extra config needed
</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="#%e4%bd%95%e5%ba%a6%e3%82%82%e8%a6%8b%e3%81%a6%e3%81%8d%e3%81%9f%e5%a4%b1%e6%95%97" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>APIデータをZustandに入れる。アクションの中に <code>setUsers(apiResponse.users)</code> があったら、一度手を止めてください。それはReact Queryの仕事で、その先に待っているのはキャッシュ無効化の下手な再発明です。</p>
<p>React Queryをクライアントステートに使う。<code>queryFn</code> がネットワークリクエストをしていないなら、道具を間違えています。</p>
<p>AsyncStorageをデータベース扱いする。1万件の配列をキーバリューストアにシリアライズし始めたら、素直にSQLiteなり本物のデータベースなりを使いましょう。</p>
<p>トークンを平文で置く。AsyncStorageはセキュアストレージではありません。認証トークン、APIキー、認証情報は <code>expo-secure-store</code> かプラットフォームのキーチェーンへ。</p>
<p>永続化を手作りする。マウント時にAsyncStorageを読んで、ステートが変わるたびに書き込んで……というコードを書いているなら、Zustandのpersistミドルウェアがハイドレーションもシリアライゼーションも含めて全部やってくれます。コードは減って、バグも減ります。</p>
<hr>
<p>サーバーデータはReact Queryへ。クライアントステートはZustandへ。再起動を生き延びるべきものはAsyncStorageへ、機密データはセキュアストレージへ。</p>
<p>私が関わってきた中で最悪のアーキテクチャは、どれも巨大なストアひとつにすべてが流れ込むものでした。良いものには、サーバーステート・クライアントステート・永続化すべきものの間にはっきりした線が引いてあります。その線を早めに引いておくと、あとの全部が楽になります。</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 Updateを使わずにExpoのOTAアップデートをセルフホスト：DjangoとTigrisでアップデートサーバーを自作する</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-02-22-self-hosted-ota-updates/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/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 Frameworkのサーバーに、バンドルの置き場はTigris S3。運用費は月1ドルほどです。</description>
      <content:encoded><![CDATA[<blockquote><p><strong>2026年9月13日追記。</strong> この記事の公開後、CodemagicがCodePush系の<a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">オープンソース</a>でセルフホストできるOTAサーバー、<a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a>をリリースしました。サーバーを自分で作りたくなくてここにたどり着いた方には、いまや現実的な選択肢です。私自身、その後<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-13-moving-to-codemagic-patch/" >この記事の構成からPatchへアプリを移行</a>してその顛末を書きましたし、<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-02-22-self-hosted-ota-updates/#%e3%81%8a%e3%82%8f%e3%82%8a%e3%81%ab" >おわりに</a>の下に短い補足も足してあります。開示: その移行と執筆にはCodemagicから報酬を受けていて、ここで触れることも先方からの依頼です。本文のコードは、その後短い例に絞りました。</p>
</blockquote><p>私が開発している見積もりアプリでは、バグ修正をOTA（Over-the-Air）で配信しています。JavaScriptの変更が、App Storeの審査を通さずにユーザーの端末へ届くわけです。ExpoのホストサービスであるEAS Updateを使えば同じことができるのですが、私はDjango REST FrameworkとTigris S3で自前のサーバーを作りました。以来、iOS・Androidのアップデートはすべてここから配信しています。</p>
<p>ExpoのOTAアップデートの仕組みはこうです。アプリに入っている<code>expo-updates</code>ライブラリが、プラットフォームとランタイムバージョンをヘッダーに載せて、アップデートサーバーにマニフェストを要求します。サーバーは互換性のある最新のアップデートを、そのJavaScriptバンドルとアセットのURLと一緒に返し、アプリはそれらをダウンロードして、次回の起動時から新しいバンドルで動きます。EAS Updateはこのプロトコルを実装したExpoのホストサーバーですが、同じプロトコルに対応していればどんなサーバーでも構いません。私が作ったのは、まさにそういうサーバーです。</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="#%e3%81%9d%e3%82%82%e3%81%9d%e3%82%82%e3%81%aa%e3%81%9c%e8%87%aa%e5%89%8d%e3%81%a7%e3%83%9b%e3%82%b9%e3%83%86%e3%82%a3%e3%83%b3%e3%82%b0%e3%81%99%e3%82%8b%e3%81%ae%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>私の場合、一番の理由はコストでした。EAS Updateの料金は使用量に応じて増えていくので、ユーザーが増えて更新頻度も上がるとそれなりの金額になります。一方、TigrisのようなS3互換ストレージならほとんどタダみたいなものです。ほかの理由が効いてくる場合もあるでしょう。アプリケーションのアセットをすべて自社インフラ内に置くことが求められる業界もありますし、サーバーを自分で持てばアップデートの流れを丸ごと制御できるので、特定のユーザー層への配信やバンドルの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="#%e5%85%a8%e4%bd%93%e3%81%ae%e6%a7%8b%e6%88%90" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>構成要素は4つです。アップデートのマニフェストを返しメタデータを保存するDjangoバックエンド、バンドルとアセットの実体を置くTigris、エクスポート・アップロード・登録をまとめて行うパブリッシュ用スクリプト、そして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%ae%9f%e8%a3%85" aria-label="アンカー">#</a>
    </span>
    
</h2>

<h3 class="relative group">2つのモデル
    <div id="2つのモデル" 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="#2%e3%81%a4%e3%81%ae%e3%83%a2%e3%83%87%e3%83%ab" 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は別レコードです。<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="#%e3%83%9e%e3%83%8b%e3%83%95%e3%82%a7%e3%82%b9%e3%83%88%e3%82%a8%e3%83%b3%e3%83%89%e3%83%9d%e3%82%a4%e3%83%b3%e3%83%88" 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>ポイントは3つあります。アップデートがないときは204を返し、アプリは組み込みのバンドルをそのまま使い続けます。マニフェスト内のアセットURLは署名付きURL（presigned URL）なので、Djangoを経由せずTigrisのCDNから直接ダウンロードされます。そしてレスポンスの<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="#%e3%82%a2%e3%83%83%e3%83%97%e3%83%87%e3%83%bc%e3%83%88%e3%81%ae%e3%83%91%e3%83%96%e3%83%aa%e3%83%83%e3%82%b7%e3%83%a5" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>パブリッシュはDjangoの管理コマンドと、それを包むシェルスクリプトで行います。管理コマンドは<code>expo export</code>の出力を読み取り、各アセットのSHA-256ハッシュを計算し、バンドルとアセットを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="#%e3%83%97%e3%83%ad%e3%83%80%e3%82%af%e3%82%b7%e3%83%a7%e3%83%b3%e3%81%b8%e3%81%ae%e5%90%8c%e6%9c%9f" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>プロダクションの認証情報を手元のマシンに置きたくなかったので、本番へのデプロイは2段階にしました。まずローカルでパブリッシュし（アセットは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="#%e3%82%a2%e3%83%97%e3%83%aa%e3%82%92%e3%82%b5%e3%83%bc%e3%83%90%e3%83%bc%e3%81%ab%e5%90%91%e3%81%91%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p><code>app.json</code>でアップデートURLとランタイムバージョンを設定します。</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%e3%81%a7%e3%81%ae%e3%82%b9%e3%83%88%e3%83%ac%e3%83%bc%e3%82%b8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>TigrisはS3互換のオブジェクトストレージで、本家AWS S3よりかなり安く、グローバルなエッジキャッシュも込みです。数メガバイト級のバンドルをユーザーがダウンロードすることを考えると、これが効いてきます。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="#%e3%82%bb%e3%82%ad%e3%83%a5%e3%83%aa%e3%83%86%e3%82%a3" 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も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="#%e9%80%9f%e5%ba%a6%e3%81%be%e3%82%8f%e3%82%8a" 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%e3%80%85%e3%81%ae%e9%81%8b%e7%94%a8" 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="#%e9%96%8b%e7%99%ba%e4%b8%ad" 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="#%e3%83%97%e3%83%ad%e3%83%80%e3%82%af%e3%82%b7%e3%83%a7%e3%83%b3%e3%81%b8%e3%81%ae%e3%83%aa%e3%83%aa%e3%83%bc%e3%82%b9" 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="#%e3%83%ad%e3%83%bc%e3%83%ab%e3%83%90%e3%83%83%e3%82%af" 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="#%e3%81%8b%e3%81%8b%e3%81%a3%e3%81%a6%e3%81%84%e3%82%8b%e8%b2%bb%e7%94%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>このアプリのアクティブユーザーは500人ほどです。Tigrisでは、過去のアップデート約200MB分のストレージが月$0.02ほど、アップデートのダウンロードによる月約50GBのエグレスが$1.00ほど。Django側はもともとAPIを動かしているサーバーに相乗りしているので、追加コストはゼロです。全部で月1ドルといったところ。同じ規模で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="#%e3%83%a2%e3%83%8b%e3%82%bf%e3%83%aa%e3%83%b3%e3%82%b0%e3%81%a8%e3%83%87%e3%83%90%e3%83%83%e3%82%b0" 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="#%e3%83%8f%e3%83%9e%e3%82%8a%e3%81%a9%e3%81%93%e3%82%8d" 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="#%e3%83%a9%e3%83%b3%e3%82%bf%e3%82%a4%e3%83%a0%e3%83%90%e3%83%bc%e3%82%b8%e3%83%a7%e3%83%b3%e3%81%ae%e4%b8%8d%e4%b8%80%e8%87%b4" 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%e3%82%bf%e3%82%a4%e3%83%a0%e3%82%b9%e3%82%bf%e3%83%b3%e3%83%97" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p><code>expo-updates</code>クライアントはマニフェストの<code>createdAt</code>を組み込みバンドルの<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="#%e3%83%9e%e3%83%8b%e3%83%95%e3%82%a7%e3%82%b9%e3%83%88%e3%81%ae%e3%82%a2%e3%82%bb%e3%83%83%e3%83%88key" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>マニフェスト内の各アセットの<code>key</code>フィールドは<code>expo-updates</code>がキャッシュに使うもので、ランダムなUUIDや適当な文字列ではなく、決定論的なハッシュ（ファイル名のMD5など）でなければなりません。ここを間違えると、クライアントがアセットを正しくキャッシュ・取得できなくなり、最初の1回だけ成功したあとアップデートが黙って壊れます。この問題を報告してくれた読者のRaphael Mutschlerさんに感謝します。原因を突き止めるまで、彼の環境ではアップデートがちょうど1回だけ動いていたそうです。</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%e3%81%ae%e3%82%a2%e3%83%97%e3%83%aa%e8%a8%ad%e5%ae%9a" 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="#%e3%82%a2%e3%82%bb%e3%83%83%e3%83%88%e3%81%ae%e6%8e%83%e9%99%a4" 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="#%e3%81%93%e3%81%ae%e5%85%88%e3%82%84%e3%82%8a%e3%81%9f%e3%81%84%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><em>(2026年9月: いまではPatchがこの3つも、それ以上もやってくれます。<a href="https://jared.lynskey.co.nz/ja/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="#%e6%ae%b5%e9%9a%8e%e7%9a%84%e3%83%ad%e3%83%bc%e3%83%ab%e3%82%a2%e3%82%a6%e3%83%88" 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="#%e3%82%b9%e3%83%86%e3%83%bc%e3%82%b8%e3%83%b3%e3%82%b0%e7%94%a8%e3%82%a2%e3%83%83%e3%83%97%e3%83%87%e3%83%bc%e3%83%88%e3%81%ae%e5%88%86%e9%9b%a2" 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="#%e3%83%80%e3%82%a6%e3%83%b3%e3%83%ad%e3%83%bc%e3%83%89%e3%81%ae%e8%a8%88%e6%b8%ac" 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="#%e3%81%8a%e3%82%8f%e3%82%8a%e3%81%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>システム全体で、Djangoのモデルが2つとビューがいくつか、パブリッシュ用スクリプトがひとつ、インフラ費用が月1ドル。始める前に想像していたよりずっと小さく収まり、以来両プラットフォームで淡々と動き続けています。</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="#%e3%82%b5%e3%83%bc%e3%83%90%e3%83%bc%e3%82%92%e8%87%aa%e5%88%86%e3%81%a7%e6%9b%b8%e3%81%8d%e3%81%9f%e3%81%8f%e3%81%aa%e3%81%84%e3%81%aa%e3%82%89" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>この記事を書いたあと、Djangoなしで同じ領域をカバーするものが2つ登場しました。1つ目は、上で触れたRaphaelさんの<code>expo-ota-server</code>。2つ目は<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のインストールで自分のマシン上で動き、Expo UpdatesプロトコルではなくCodePushを話すので、<code>expo-updates</code>をそのSDKに、<code>eas update</code>を<code>cmpatch release-react</code>に置き換えることになります。S3互換ストレージを使えるので、この記事に出てくるようなTigrisのバケットもそのまま使えます。</p>
<p>「この先やりたいこと」に挙げて結局作らなかったものが、最初から揃っています。ステージングとプロダクションのデプロイメント、パーセンテージ指定のロールアウト、リリースごとのダウンロード数とインストール数、必須アップデート、バイナリパッチ、コード署名、そして新しいバンドルがアプリの準備完了を報告する前にクラッシュしたときの端末側でのロールバック。引き換えになるのは、SDKをアプリに入れる必要があるため誰かにPatchのアップデートが届く前に新しいネイティブバイナリが要ることと、管理コマンドの代わりに運用するサーバーが要ることです。</p>
<p>私は自分のアプリの1つを、コミット済みのネイティブプロジェクトと本物のリリースパイプラインごと、この記事の構成からPatchへ移行しました。<a href="https://jared.lynskey.co.nz/ja/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アプリにローカルLLMを組み込む：llama.rnとllama.cppでQwenをオフライン実行</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-01-30-on-device-ai/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/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をスマホの中だけで動かす、オンデバイスAIの実装メモです。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トークンほど出ます。この記事の残りは、それを実際に役立つものにするまでの話です。モデル選び、ツール呼び出しを安定して出させる工夫、そしてメモリの抑え方です。</p>
<blockquote><p><strong>📝 更新:</strong> 現在この実装では、当初のQwen3-1.7Bに代えて<a href="https://huggingface.co/unsloth/Qwen3.5-2B-GGUF"  target="_blank" rel="noreferrer">Qwen3.5-2B-GGUF</a>を使っています。また「/no_think」メッセージという小技をやめて、<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="#%e3%82%af%e3%83%a9%e3%82%a6%e3%83%89ai%e3%82%92%e4%bd%bf%e3%81%84%e3%81%9f%e3%81%8f%e3%81%aa%e3%81%8b%e3%81%a3%e3%81%9f%e7%90%86%e7%94%b1" 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="#%e3%81%84%e3%81%be%e5%ae%9f%e7%8f%be%e3%81%a7%e3%81%8d%e3%82%8b%e3%82%88%e3%81%86%e3%81%ab%e3%81%aa%e3%81%a3%e3%81%9f%e7%90%86%e7%94%b1" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>1年前なら手を出さなかったと思いますが、ほぼ同時期に3つの変化がありました。まず、量子化モデルが小さくなったこと。Q4_K_M量子化のQwen 1.7Bは1.1 GBで、大半のゲームより小さく、一度ダウンロードすればアプリのストレージに収まります。次に、モバイルGPUが速くなったこと。llama.cppはiOSではMetal、最近のQualcomm製GPUを積んだAndroidではOpenCLを使い、iPhone 14 Proなら毎秒15トークンほど出ます。リアルタイムのストリーミングには十分です。そして、小型モデル自体が実用的になったこと。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="#%e3%83%a2%e3%83%87%e3%83%ab%e9%81%b8%e3%81%b3" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Qwen3-1.7Bに落ち着くまでに、4つの小型モデルを試しました。</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（medium）の構成では、影響を受けやすい一部のテンソルを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="#llamarn%e3%81%ae%e3%82%bb%e3%83%83%e3%83%88%e3%82%a2%e3%83%83%e3%83%97" 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が大きなモデル用に推奨している2つのiOSエンタイトルメント（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なら2〜3分、LTEだと5〜8分といったところです。ダウンロードが済んだら、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="#%e3%82%b9%e3%83%88%e3%83%aa%e3%83%bc%e3%83%9f%e3%83%b3%e3%82%b0%e6%8e%a8%e8%ab%96" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>10秒間スピナーを眺めたい人はいないので、応答はトークン単位でストリーミングします：</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>最近の端末なら、最初のトークンまで約200ms（これはプロンプト処理の時間です）、以降は1トークンあたり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%e5%9e%8b%e3%83%a2%e3%83%87%e3%83%ab%e3%81%a7%e3%82%82%e6%88%90%e7%ab%8b%e3%81%99%e3%82%8b%e9%96%a2%e6%95%b0%e5%91%bc%e3%81%b3%e5%87%ba%e3%81%97" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>大手クラウドモデルの関数呼び出しはJSONスキーマが頼りで、小型モデルはここで転びます。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>：「Create a job for Smith」</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>：「Smithさんが2人見つかりました。JaneさんとBobさん、どちらですか？」</li>
<li><strong>ユーザー</strong>：「Jane」</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>：「完了しました！Jane Smithさんのジョブ J-0073を作成しました。」</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="#think%e3%82%bf%e3%82%b0%e3%81%a7%e3%83%81%e3%82%a7%e3%83%bc%e3%83%b3%e3%82%aa%e3%83%96%e3%82%bd%e3%83%bc%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>小型モデルは、推論を「声に出させる」と目に見えて安定します。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="#%e9%95%b7%e3%81%84%e4%bc%9a%e8%a9%b1%e3%82%928k%e3%81%ab%e5%8f%8e%e3%82%81%e3%82%8b" 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トークン程度まで縮み、作成済みのジョブや顧客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="#%e3%82%bb%e3%83%83%e3%82%b7%e3%83%a7%e3%83%b3%e3%81%ae%e4%bf%9d%e5%ad%98" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>チャットセッションはアプリのバックエンドにも保存しています。真似する価値があるのは、アシスタントのメッセージを2通り持たせておくことです。表示用に整えたものと、<code>&lt;think&gt;</code>や<code>&lt;action&gt;</code>タグを残した生のものです。こうしておくと、チャット履歴はUI上できれいに再生でき、セッションを再開するときはアクションを含む完全なコンテキストが戻ってきます。ユーザーは一般的なチャットアプリと同じ感覚で、履歴のドロップダウンから会話を切り替えられます。</p>

<h2 class="relative group">本番環境でのベンチマーク
    <div id="本番環境でのベンチマーク" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%9c%ac%e7%95%aa%e7%92%b0%e5%a2%83%e3%81%a7%e3%81%ae%e3%83%99%e3%83%b3%e3%83%81%e3%83%9e%e3%83%bc%e3%82%af" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><strong>iPhone 14 Pro</strong>（A16 Bionic、6GB RAM）：</p>
<ul>
<li>モデルロード：約3秒</li>
<li>最初のトークン：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>最初のトークン：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>最初のトークン：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="#%e3%83%a1%e3%83%a2%e3%83%aa%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="#%e3%83%90%e3%83%83%e3%83%86%e3%83%aa%e3%83%bc%e3%81%b8%e3%81%ae%e5%bd%b1%e9%9f%bf" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>オンデバイス推論は電力的にタダではありません。実測では：</p>
<table>
	<thead>
			<tr>
					<th>使用パターン</th>
					<th>追加バッテリー消費</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>軽い使用（1日5-10クエリ）</td>
					<td>1日 &lt;1%</td>
			</tr>
			<tr>
					<td>中程度（1日20-30クエリ）</td>
					<td>1日約3-4%</td>
			</tr>
			<tr>
					<td>ヘビー（1日50以上のクエリ）</td>
					<td>1日約6-8%</td>
			</tr>
	</tbody>
</table>
<p>GPU推論はCPUより電力を食いますが、そのぶんずっと早く終わります。ユーザーは明らかに即応を好むので、GPUのままにしています。</p>

<h2 class="relative group">運用2ヶ月、ベータユーザー約200人
    <div id="運用2ヶ月ベータユーザー約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="#%e9%81%8b%e7%94%a82%e3%83%b6%e6%9c%88%e3%83%99%e3%83%bc%e3%82%bf%e3%83%a6%e3%83%bc%e3%82%b6%e3%83%bc%e7%b4%84200%e4%ba%ba" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>クエリの内訳は少し意外でした：</p>
<ol>
<li>「Create a job for [customer name]」 - クエリの68%</li>
<li>「Find jobs for [customer]」 - 15%</li>
<li>「Update job [ID] to [status]」 - 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="#%e3%82%b3%e3%82%b9%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>オンデバイス版はインフラ費$0/月。ユーザーあたりのコストも、初回の1.1 GBダウンロードを除けば$0で、ユーザーが何人増えてもそのままです。</p>
<p>クラウド版をざっくり見積もると、平均的な会話が約2,000トークン、200ユーザー×月30会話で6,000会話、つまり月1,200万トークン。安価なクラウドモデルなら月$18、最上位クラスのモデルなら月$180〜$360といったところです。この規模なら破産する額ではありませんが、オンデバイス方式は初日から元が取れていて、ユーザーが10,000人になっても$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="#%e7%9f%a5%e3%81%a3%e3%81%a6%e3%81%8a%e3%81%8f%e3%81%b9%e3%81%8d%e5%88%b6%e9%99%90" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><strong>モデルの能力。</strong> Qwen 1.7Bは構造化タスクが得意な一方、マルチホップの複雑な推論、事実知識（検索エンジンではありません）、創作、ニュアンスの読み取りは苦手です。小型モデルの得意分野に沿って機能を設計すれば問題になりません。</p>
<p><strong>デバイス要件。</strong> 最低ラインはRAM 3 GBと空きストレージ2 GB。現実的にはRAM 6 GB、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%e3%81%a8android%e3%81%ae%e9%81%95%e3%81%84" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>MetalのiOSはAndroidより2割ほど速く、メモリ管理も上手で、モデルはデフォルトでiCloudにバックアップされます（ユーザーのバックアップ容量を食いたくなければ<code>NSURLIsExcludedFromBackupKey</code>で除外できます）。Androidは端末ごとの差がずっと大きいです。llama.rnがGPUを使うのはQualcomm Adreno 700シリーズのGPU上のOpenCLで、それを積んでいない端末はCPUで動きます。ストレージの自動バックアップもありません。両方でテストしてください。良いAndroid端末と悪いAndroid端末の差は、プラットフォーム間の差より大きいくらいです。</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="#%e3%83%87%e3%83%90%e3%83%83%e3%82%b0%e3%81%ae%e3%83%92%e3%83%b3%e3%83%88" 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. トークン数を追跡する</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="#%e3%81%93%e3%81%ae%e5%85%88%e3%82%84%e3%82%8a%e3%81%9f%e3%81%84%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>llama.cppはすでにビジョンモデルに対応していて、「この現場の写真をジョブに追加して」という使い方はこのアプリにぴったりです。過去のジョブ記述や顧客とのやり取りで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="#%e4%bd%9c%e3%82%8b%e3%81%b9%e3%81%8d%e3%81%8b" 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>
