<?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 · Nhân Tài Đức</title>
    <link>https://jared.lynskey.co.nz/vi/tags/react-native/</link>
    <description>Những câu chuyện ngắn về kỹ thuật phần mềm và lãnh đạo đội ngũ kỹ sư — performance review, tuyển dụng và mở rộng team, CI/CD, giám sát DevOps và AI agent. Viết từ Seoul.</description>
    <generator>Hugo</generator>
    <language>vi</language>
    <copyright>© 2026 Nhân Tài Đức</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/vi/tags/react-native/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Không cần Sentry tự host (self-hosted): nhận báo cáo crash React Native trên máy chủ của mình</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-10-09-sentry-sdk-own-server/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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>Crash Reporting</category>
      <category>Self-Hosted</category>
      <category>iOS</category>
      <category>Android</category>
      <category>Privacy</category>
      <description>Không cần chạy Sentry tự host hay trả phí sentry.io: giữ SDK mã nguồn mở của Sentry, trỏ DSN về API Django của mình để nhận báo cáo crash React Native, rồi tự gom nhóm và cảnh báo.</description>
      <content:encoded><![CDATA[<p>Nếu bạn muốn tự nhận báo cáo crash (crash reporting) cho ứng dụng React Native, bạn không cần chạy máy chủ Sentry tự host (self-hosted), cũng không cần trả tiền cho sentry.io. SDK của Sentry là mã nguồn mở, và DSN của nó có thể trỏ tới bất kỳ máy chủ nào. Tôi trỏ nó về một endpoint trong API Django của mình; ở đó báo cáo được nhận, gom nhóm, gửi cảnh báo và lưu lại, không tốn thêm gì ngoài API vốn đã chạy sẵn. Thứ tôi chưa thay được là khôi phục ký hiệu (symbolication), nên stack trace vẫn chưa chỉ ra được dòng nào trong mã nguồn.</p>
<p>Cho tới tuần này, cách duy nhất để tôi biết ứng dụng của tôi bị sập là có ai đó nhắn cho tôi. Mà phần lớn mọi người không nhắn. Họ mở lại ứng dụng, và nếu nó sập thêm lần nữa thì họ thôi không mở nữa. Phía API thì đã có giám sát: mọi log mức ERROR đều được đẩy vào một kênh chat của đội. Nhưng một lần sập trên điện thoại thì không để lại dấu vết gì ở bất cứ chỗ nào tôi nhìn thấy được.</p>
<p>Thật ra ứng dụng đã có sẵn một cơ chế báo lỗi. Mỗi khi ứng dụng hiện một thông báo lỗi cho người dùng, nó gửi nội dung thông báo đó lên một endpoint nhỏ của API. Máy chủ ghi lại ở mức ERROR, nên lỗi đó cũng tới được kênh chat. Tôi thêm cơ chế này sau một lần thao tác xoá thực ra đã <em>thành công</em>, nhưng dòng đó vẫn nằm trong danh sách; người dùng bấm vào lần nữa và nhận được thông báo &ldquo;No Item matches the given query&rdquo;. Phía máy chủ chỉ ghi hai lỗi 404 bình thường và không báo gì cả. Cơ chế đó bao quát được những lỗi mà ứng dụng bắt được và hiện ra cho người dùng. Còn sập ứng dụng (crash) thì khác hẳn: chính đoạn JavaScript lẽ ra sẽ gửi báo cáo lại là thứ vừa chết.</p>
<p>Cách sửa hiển nhiên là dùng Sentry. Tôi rất thích SDK của Sentry; nhờ nó mà báo cáo sự cố trên React Native gần như là bài toán đã có lời giải. Điều tôi không muốn là thêm một nhà cung cấp nữa giữ dữ liệu người dùng, thêm một dòng trong chính sách quyền riêng tư, và thêm một hoá đơn cho một thứ mà với quy mô của chúng tôi thì nhiều lắm cũng chỉ vài báo cáo mỗi ngày. Vì vậy tôi giữ SDK và thay phần dịch vụ: DSN của ứng dụng trỏ về API của chính chúng tôi, và một phần nhỏ trong ứng dụng Django ở đó nhận những gì SDK gửi lên, gom nhóm, gửi cảnh báo và lưu lại.</p>
<p>Bài này kể cách hệ thống đó hoạt động, và cách tôi kiểm tra để chắc rằng một lần sập gốc (native crash) thật sự đi được tới máy chủ.</p>

<h2 class="relative group">Mẹo với DSN
    <div id="mẹo-với-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="#m%e1%ba%b9o-v%e1%bb%9bi-dsn" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Một DSN của Sentry có dạng <code>https://&lt;public-key&gt;@&lt;host&gt;/&lt;project-id&gt;</code>. SDK biến nó thành một yêu cầu POST tới <code>https://&lt;host&gt;/api/&lt;project-id&gt;/envelope/</code>, với khoá nằm trong header <code>X-Sentry-Auth</code>. Không có chi tiết nào trong đó gắn riêng với sentry.io. Vì vậy DSN được đóng gói sẵn trong ứng dụng là:</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>và phía Django có một route khớp đúng với địa chỉ mà SDK sẽ gọi:</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>Khoá này nằm trong mọi bản build của ứng dụng, nên nó không phải bí mật. Nó tồn tại vì hai lý do: nếu một ngày endpoint bắt đầu hứng rác thì tôi có thể đổi khoá, và nếu máy chủ chưa cấu hình khoá thì endpoint sẽ từ chối tất cả thay vì nhận bừa mọi thứ:</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>Hàm <code>_key</code> đọc giá trị <code>sentry_key=</code> trong header <code>X-Sentry-Auth</code>; nếu không có thì lấy từ query string, vì một số SDK đặt khoá ở đó.</p>

<h2 class="relative group">SDK gửi gì lên: envelope
    <div id="sdk-gửi-gì-lên-envelope" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#sdk-g%e1%bb%adi-g%c3%ac-l%c3%aan-envelope" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Định dạng truyền dữ liệu của Sentry là <a href="https://develop.sentry.dev/sdk/data-model/envelopes/"  target="_blank" rel="noreferrer">envelope</a>: một dòng JSON làm header, sau đó là bao nhiêu mục (item) cũng được, mỗi mục gồm một dòng JSON header rồi tới phần nội dung (payload). Nếu header của mục có trường <code>length</code> thì payload dài đúng bằng số byte đó; nếu không thì payload kéo dài tới ký tự xuống dòng kế tiế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">{&#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>Có hai chi tiết khiến tôi vấp:</p>
<ul>
<li><strong>SDK gốc (native) nén gzip phần thân yêu cầu, còn SDK JavaScript thì không.</strong> Một lỗi JS mà <code>@sentry/react-native</code> bắt được sẽ tới dưới dạng văn bản thường, trong khi một lần sập gốc từ sentry-cocoa hoặc sentry-android tới kèm <code>Content-Encoding: gzip</code>. Bộ giải mã xử lý được gzip, deflate có bọc zlib và deflate thô, và dừng giải nén ở mức 1MB, vì một sự kiện sập chỉ nặng vài kilobyte, thứ gì lớn hơn thế thì không phải báo cáo sự cố.</li>
<li><strong>Payload có tiền tố độ dài có thể chứa ký tự xuống dòng.</strong> Nếu bạn tách theo <code>\n</code> thì sẽ làm hỏng mọi sự kiện mà SDK gốc gửi lên. Khi có <code>length</code> thì bắt buộc phải tôn trọng nó.</li>
</ul>
<p>Bộ phân tích cú pháp (parser) khá ngắn. Nó chỉ giữ các mục <code>event</code>; session, transaction, tệp đính kèm và client report đều được nhận rồi bỏ đi.</p>

<h2 class="relative group">Trả lời theo cách SDK hiểu được
    <div id="trả-lời-theo-cách-sdk-hiểu-được" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#tr%e1%ba%a3-l%e1%bb%9di-theo-c%c3%a1ch-sdk-hi%e1%bb%83u-%c4%91%c6%b0%e1%bb%a3c" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phần này quan trọng hơn tôi tưởng. SDK giữ một hàng đợi trên đĩa và gửi lại bất cứ thứ gì nó không chắc là đã tới nơi, nên câu trả lời của máy chủ quyết định SDK có tiếp tục thử lại hay không:</p>
<ul>
<li><strong>200</strong> khi đã lưu, <em>hoặc khi cố ý bỏ đi</em>. Nếu tôi trả 4xx cho một mục session, SDK sẽ giữ nó lại và thử gửi mãi mãi.</li>
<li><strong>429 kèm <code>Retry-After</code></strong> khi bị giới hạn tần suất. Endpoint có giới hạn theo từng IP (120 lần mỗi giờ), vì một chiếc điện thoại bị sập liên tục sẽ gửi một báo cáo mỗi lần khởi động, và SDK lùi lại khi gặp 429 đúng như mình mong.</li>
<li><strong>400</strong> cho envelope sai định dạng, ghi log ở mức cảnh báo (warning) chứ không phải lỗi: một báo cáo hỏng chỉ cho biết có một client đang hỏng, và báo cáo tốt tiếp theo sẽ nói cái gì đã sập.</li>
</ul>
<p>SDK cũng gửi lại cùng một sự kiện nếu nó không chắc lần gửi đầu đã tới nơi, nên <code>event_id</code> có ràng buộc duy nhất (unique constraint) và một bản trùng lặp sẽ được bỏ qua lặng lẽ.</p>

<h2 class="relative group">Gom nhóm khi không có source map
    <div id="gom-nhóm-khi-không-có-source-map" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#gom-nh%c3%b3m-khi-kh%c3%b4ng-c%c3%b3-source-map" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là chỗ một bộ nhận tự viết phải đưa ra lựa chọn mà Sentry vốn làm sẵn cho bạn. Sentry gom nhóm các lần sập theo stack trace. Nhưng bundle Hermes ở bản phát hành đã bị rút gọn (minify), nên tên các frame chỉ còn một chữ cái và vị trí dòng/cột thay đổi sau mỗi lần build. Nếu gom theo frame, cùng một lỗi sẽ mở ra một issue mới ở mỗi bản phát hành.</p>
<p>Khi chưa có source map (phần cuối bài sẽ nói thêm), thứ ổn định nhất trong một lỗi JavaScript là kiểu lỗi và thông điệp lỗi. Vì vậy dấu vân tay (fingerprint) là kiểu exception cộng với thông điệp, trong đó những phần thay đổi giữa các lần xuất hiện được che đi:</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> và <code>(reading index 17)</code> sẽ thành cùng một issue. Nếu SDK tự gửi kèm <code>fingerprint</code> mà không có <code>{{ default }}</code> bên trong thì giá trị đó được ưu tiên, nhờ vậy tôi có thể ép cách gom nhóm từ phía client khi cách mặc định chưa đúng. Sự kiện nào không có exception thì dùng thông điệp log đã che làm dấu vân tay, còn nếu không có cả hai thì dùng frame đầu tiên thuộc mã của ứng dụng (in-app). Với một lần sập do tín hiệu (signal) ở tầng gốc, thông điệp thường kiểu <code>Signal 6, Code 0</code>, nên các lần sập này được gom theo tín hiệu. Cách đó hơi thô, nhưng trung thực cho tới khi có giải mã ký hiệu (symbolication).</p>

<h2 class="relative group">Cảnh báo mà không làm ngập kênh
    <div id="cảnh-báo-mà-không-làm-ngập-kênh" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%e1%ba%a3nh-b%c3%a1o-m%c3%a0-kh%c3%b4ng-l%c3%a0m-ng%e1%ba%adp-k%c3%aanh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Mỗi dấu vân tay ứng với một issue, và mỗi báo cáo được lưu thành một sự kiện nằm dưới issue đó. Issue chỉ ghi log ở mức ERROR (đó là thứ cảnh báo trên kênh chat lắng nghe) đúng trong những trường hợp sau:</p>
<ul>
<li>một issue <strong>mới</strong></li>
<li>một issue <strong>đã đánh dấu xử lý xong (resolved) nhưng quay lại</strong>, khi đó issue được mở lại</li>
<li>lần xuất hiện thứ <strong>10, 100, 1.000 và 10.000</strong></li>
</ul>
<p>Một lần lặp lại bình thường chỉ tăng bộ đếm. Issue bị đánh dấu <em>ignored</em> vẫn tiếp tục được đếm nhưng không bao giờ cảnh báo. Cộng thêm giới hạn tần suất riêng của handler gửi lên kênh chat, một vòng lặp sập trên một bản phát hành lỗi chỉ hiện ra thành vài tin nhắn chứ không thành một bức tường chữ.</p>
<p>Dung lượng lưu trữ cũng được giới hạn theo cùng cách: mỗi issue chỉ giữ <strong>50 sự kiện gần nhất</strong> và xoá các sự kiện cũ hơn khi có sự kiện mới. Điều này làm hỏng biểu đồ đầu tiên của tôi, vì 50 sự kiện không thể cho bạn biết tuần trước lỗi đó xảy ra bao nhiêu lần. Vì vậy mỗi issue giữ thêm số đếm theo từng ngày trong 90 ngày, được cập nhật trong cùng giao dịch (transaction) với bộ đếm. Biểu đồ vẽ từ các số đếm này; các sự kiện chỉ để đọc chi tiết.</p>
<p>Nếu bạn tự làm một hệ thống như vậy, có hai tình huống tranh chấp (race) nhỏ đáng xử lý. Khi một lần sập hoàn toàn mới xảy ra trên nhiều điện thoại cùng lúc, hai báo cáo có thể cùng cố tạo issue, nên hãy đặt ràng buộc duy nhất cho dấu vân tay và để báo cáo chậm chân hơn đọc lại dòng đã có. Ngoài ra, khung 90 ngày nên tính từ ngày hôm nay theo đồng hồ của máy chủ, chứ không tính từ báo cáo mới nhất, vì một chiếc điện thoại cài sai đồng hồ có thể ghi ngày cho báo cáo là bất kỳ ngày nào, kể cả năm sau.</p>
<p>Payload bị giới hạn ở 128KB. Sự kiện nào vượt quá thì bị bỏ breadcrumb và siêu dữ liệu gỡ lỗi (debug metadata) trước; nếu vẫn còn quá lớn thì chỉ giữ exception, thông điệp, phiên bản phát hành và nền tảng, kèm một dấu đánh dấu để tôi biết nó đã bị cắt.</p>

<h2 class="relative group">Lọc bỏ dữ liệu cá nhân
    <div id="lọc-bỏ-dữ-liệu-cá-nhân" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#l%e1%bb%8dc-b%e1%bb%8f-d%e1%bb%af-li%e1%bb%87u-c%c3%a1-nh%c3%a2n" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>SDK chạy với <code>sendDefaultPii: false</code>, nên về nguyên tắc không có địa chỉ IP, cookie hay nội dung yêu cầu nào được gửi lên. Máy chủ không dựa vào điều đó: trước khi lưu bất cứ thứ gì, nó chỉ giữ <code>id</code> và <code>username</code> trong <code>user</code> và bỏ hẳn <code>request</code>, phòng khi một phiên bản SDK hoặc một thiết lập sau này bắt đầu gửi chúng.</p>
<p>Kiểm thử trên thiết bị thật làm lộ ra thêm hai thứ cần loại bỏ:</p>
<ul>
<li><strong>Breadcrumb ghi lại URL đầy đủ, kể cả query string.</strong> Các yêu cầu tìm kiếm của ứng dụng đặt từ khoá ngay trong query string, nên một lần sập ngay sau khi tìm kiếm sẽ lưu luôn từ khoá người dùng đã gõ. Giờ máy chủ cắt mọi URL trong breadcrumb tại dấu <code>?</code>.</li>
<li><strong>Breadcrumb từ console mang theo nguyên các đối số</strong> đã truyền vào <code>console.log</code>, mà đó có thể là bất kỳ object nào ứng dụng tình cờ ghi log. Phần văn bản thông điệp được giữ (vì nó hữu ích), còn các đối số thì bị bỏ.</li>
</ul>
<p>Tôi chỉ phát hiện ra hai điều đó khi nhìn vào một báo cáo thật, và đó cũng là lý do chính cho phần tiếp theo.</p>

<h2 class="relative group">Phía client
    <div id="phía-client" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ph%c3%ada-client" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Trong ứng dụng, mọi thứ đều đi qua một module nhỏ duy nhất, cùng lý do với việc tôi bọc SDK cập nhật qua mạng (OTA) lại: một bundle OTA cũng chạy trên các bản build được tạo ra trước khi module gốc tồn tại. Một câu <code>import * as Sentry from &quot;@sentry/react-native&quot;</code> ở cấp cao nhất trên bản build không có <code>RNSentry</code> sẽ ném lỗi ngay khi import, và kết quả là màn hình trắng. Vì vậy SDK được nạp lười (lazy require), và chỉ sau khi đã kiểm tra module gốc thực sự có mặt:</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>Chỉ báo lỗi. Máy chủ không lưu trace, session, profile hay bản ghi phát lại (replay), nên không tính năng nào trong số đó được bật. Không có DSN (khi phát triển, khi chạy kiểm thử E2E) thì SDK không bao giờ khởi động. Các cấu hình build (build profile) đặt sẵn DSN và một môi trường (<code>staging</code> hoặc <code>production</code>), nên cả hai loại bản build đều báo về cùng một endpoint mà vẫn phân biệt được với nhau. Người dùng đã đăng nhập được gắn bằng ID tài khoản, không bao giờ bằng tên hay email.</p>
<p>Các error boundary cần được gọi báo lỗi một cách tường minh. <code>ErrorBoundary</code> gốc của Expo Router bắt lỗi khi render và hiện màn hình của riêng nó, nên lỗi đó không bao giờ tới được handler toàn cục mà SDK móc vào. Cách sửa là một lớp bọc nhỏ: gọi <code>captureException</code> trong một <code>useEffect</code> rồi render boundary của router như cũ, dùng chung cho cả boundary gốc lẫn boundary của từng route. Lời gọi được đặt trong effect chứ không nằm trong hàm render, để nó chỉ báo một lần thay vì báo lại sau mỗi lần render lại. Component gốc cũng được bọc bằng <code>Sentry.wrap</code>, và nếu việc bọc ném lỗi thì quay về dùng component trần.</p>
<p>Trên web có một phiên bản <code>.web.ts</code> của module, trong đó mọi hàm đều bị tắt. Metro chọn tệp theo nền tảng, nên SDK hoàn toàn không lọt vào bundle web. Ứng dụng web vốn không có module gốc nào để SDK chạy trên đó.</p>

<h2 class="relative group">Chứng minh một lần sập gốc thật sự về tới nơi
    <div id="chứng-minh-một-lần-sập-gốc-thật-sự-về-tới-nơi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ch%e1%bb%a9ng-minh-m%e1%bb%99t-l%e1%ba%a7n-s%e1%ba%adp-g%e1%bb%91c-th%e1%ba%adt-s%e1%bb%b1-v%e1%bb%81-t%e1%bb%9bi-n%c6%a1i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Các unit test gửi những envelope viết tay, tức là chỉ chứng minh parser xử lý được thứ mà tôi <em>nghĩ</em> SDK sẽ gửi. Hai ngày sau khi phát hành, trên production chưa có báo cáo sự cố nào. Lúc đó mới có ba người cập nhật, nên con số 0 là hợp lý, nhưng nhìn từ phía máy chủ thì &ldquo;chưa ai bị sập&rdquo; và &ldquo;báo cáo sập không về tới&rdquo; trông giống hệt nhau. Tôi muốn thấy một báo cáo thật.</p>
<p>Vì vậy tôi build một bản Release cho simulator, với DSN trỏ về một Django chạy cục bộ ở cổng 8000 cùng một khoá dùng một lần:</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>(Dùng Release chứ không phải Debug: bản Debug có liên kết dev launcher và hoạt động khác đi, còn Release mới là thứ người dùng thật sự chạy.) Sau đó tôi cài, mở ứng dụng, chờ vài giây, rồi giết tiến trình theo đúng cách một lỗi thật sẽ làm:</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>Ứng dụng trong simulator là các tiến trình macOS bình thường, nên tín hiệu tới được handler của SentryCrash y như trên thiết bị thật. Handler ghi báo cáo xuống đĩa; trong lúc tiến trình đang chết thì không có gì được gửi đi. Ở <strong>lần khởi động tiếp theo</strong>, SDK tìm thấy báo cáo đó và gửi lên. Tôi mở lại ứng dụng, và một giây sau nhật ký mạng của simulator cho thấy có kết nối tới <code>:8000</code>.</p>
<p>Rồi trong log của Django chẳng có gì, khiến tôi bối rối mất một phút: ở mức log tôi đang đặt, máy chủ phát triển chỉ ghi lại các phản hồi không phải 2xx. Nhưng trong cơ sở dữ liệu thì có:</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>Mười lăm giây từ lúc sập tới lúc được lưu, mà phần lớn thời gian đó là tôi mở lại ứng dụng. Và chính việc nhìn vào payload thật đã dạy tôi những điều mà kiểm thử không thể:</p>
<ul>
<li><strong>ID người dùng không phải ID tài khoản.</strong> Bản build thử nghiệm không thật sự đăng nhập, nên sentry-cocoa dùng ID riêng cho từng lượt cài đặt của nó. Điều này đáng biết, và đáng ghi vào phần lưu ý quyền riêng tư: một báo cáo mang ID tài khoản nếu bạn đã đăng nhập, và một ID cài đặt ngẫu nhiên nếu chưa.</li>
<li><strong>Không có múi giờ.</strong> Tôi đã cho rằng sẽ có context về ngôn ngữ và vùng (culture). Context thiết bị có <code>locale</code>, bộ nhớ, kích thước màn hình, chế độ tiết kiệm pin và phiên bản kernel, nhưng không có múi giờ.</li>
<li><strong>Sập gốc bỏ qua <code>beforeSend</code>.</strong> Hàm <code>beforeSend</code> phía JS của tôi gắn thêm tag <code>ota</code> ghi bundle OTA đang chạy, nhưng một lần sập gốc được SDK gốc lắp ráp ở lần khởi động sau và không bao giờ đi qua hook JS này. Muốn gắn tag cho sập gốc thì phải đặt tag trên scope bằng <code>setTag</code> ngay khi biết bundle, để nó được đồng bộ xuống tầng gốc.</li>
<li><strong>Breadcrumb cho thấy ứng dụng đang làm gì.</strong> Cảnh báo trên console, vòng đời của view controller, và các lệnh gọi HTTP, kể cả các yêu cầu của chính SDK phân tích (analytics) tới endpoint thu thập dữ liệu của nó. Đó là lời nhắc tốt về việc ứng dụng còn nói chuyện với những ai khác. Và các URL có kèm query string, đó là cách tôi phát hiện ra vấn đề từ khoá tìm kiếm ở trên.</li>
</ul>

<h2 class="relative group">Đọc báo cáo
    <div id="đọc-báo-cáo" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%e1%bb%8dc-b%c3%a1o-c%c3%a1o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Báo cáo được đọc trong trang quản trị Django, trên một trang issue mô phỏng đại khái theo Sentry: phần đầu có các con số đếm và các nút Resolve / Ignore / Reopen, tiếp theo là biểu đồ cột theo ngày, tỷ lệ theo phiên bản phát hành, hệ điều hành, thiết bị và bundle OTA, rồi tới từng sự kiện một với exception, các frame (frame của ứng dụng được tô nổi, frame của thư viện được thu gọn), breadcrumb và context thiết bị. Không cần thêm thư viện phụ thuộc nào.</p>
<p>Trong lúc làm trang này có một chỗ phải sửa: với một lần sập gốc, frame &ldquo;thủ phạm&rdquo; (culprit) là đường dẫn của tệp thực thi trên thiết bị, trong đó có một UUID riêng cho mỗi lượt cài đặt, nên cùng một lần sập lại trông khác nhau trên mỗi điện thoại. Giờ nó chỉ hiện địa chỉ và tên tệp thực thi, kiểu <code>0x1000a1b2c (MyApp)</code>.</p>

<h2 class="relative group">Chính sách quyền riêng tư, toàn bộ
    <div id="chính-sách-quyền-riêng-tư-toàn-bộ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ch%c3%adnh-s%c3%a1ch-quy%e1%bb%81n-ri%c3%aang-t%c6%b0-to%c3%a0n-b%e1%bb%99" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Báo cáo sự cố gần như chắc chắn là thông tin cá nhân: một ID tài khoản, một mẫu điện thoại, và những gì một người đã làm trong phút cuối trước khi ứng dụng sập. Vì vậy chính sách quyền riêng tư cần thêm một đoạn. Viết cho đàng hoàng nghĩa là phải nói chính xác một báo cáo mang theo những gì, nên tôi liệt kê dựa trên payload vừa xem: lỗi, phiên bản ứng dụng và bản cập nhật OTA, mẫu điện thoại, hệ điều hành và ngôn ngữ, bộ nhớ trống và kích thước màn hình, vài chục bước gần nhất (các màn hình, địa chỉ web đã bỏ mọi thứ sau dấu <code>?</code>, các dòng log của chính ứng dụng), và ID tài khoản hoặc một ID cài đặt ngẫu nhiên. Chính sách cũng nói rõ báo cáo được gửi về máy chủ của chúng tôi, không gửi tới Sentry hay bất kỳ công ty báo cáo sự cố nào khác. Thời hạn lưu giữ có một dòng riêng trong bảng: 50 báo cáo gần nhất cho mỗi vấn đề, cộng với số đếm theo ngày trong 90 ngày.</p>
<p>Một khi đã đối chiếu một đoạn với mã nguồn, thật khó mà không đối chiếu phần còn lại. Và chính sách hiện có không đứng vững. Trang xoá tài khoản ghi rằng token thông báo đẩy (push notification) và passkey bị xoá ngay khi bạn đóng tài khoản, và log máy chủ sẽ biến mất trong vòng 90 ngày. Không điều nào trong số đó đúng. Đóng tài khoản sẽ gỡ liên kết các phương thức đăng nhập và ẩn danh hoá hồ sơ, nhưng token thông báo đẩy và passkey vẫn còn đó cho tới khi có người xoá bằng tay, và kho lưu log hiện chưa có hạn xoá. Vì vậy toàn bộ chính sách được viết lại dựa trên bản kiểm kê những gì mã nguồn thực sự làm, và những dòng đó giờ ghi &ldquo;chưa được xoá tự động; hãy email cho chúng tôi&rdquo;. Nghe kém yên tâm hơn, nhưng đúng sự thật.</p>
<p>Giờ repo có hẳn một quy tắc cho việc này: chính sách mô tả những gì mã nguồn làm, chứ không phải những gì chúng tôi dự định, và mọi thay đổi khiến dữ liệu cá nhân được thu thập, gửi đi hay lưu giữ ở một chỗ mới đều phải cập nhật chính sách trong cùng commit. Việc cập nhật đó bao gồm cả màn hình tóm tắt trong ứng dụng, trang xoá tài khoản, và các biểu mẫu quyền riêng tư trên cửa hàng ứng dụng, vốn chỉ nằm trong bảng điều khiển của cửa hàng. Báo cáo sự cố chính là thay đổi khiến tôi phải viết quy tắc đó ra.</p>

<h2 class="relative group">Còn thiếu gì
    <div id="còn-thiếu-gì" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%b2n-thi%e1%ba%bfu-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><strong>Giải mã ký hiệu (symbolication).</strong> Đây là khoảng trống thật sự, và là thứ duy nhất sentry.io làm mà tôi chưa thay thế được. Stack của JavaScript thì đã bị rút gọn, còn của mã gốc thì chưa được giải mã ký hiệu:</p>
<ul>
<li><strong>JavaScript:</strong> các frame trỏ vào bundle Hermes. Muốn sửa thì phải giữ source map Hermes đã ghép (composed) cho mọi bản build lên cửa hàng và mọi bundle OTA, đánh khoá theo phiên bản phát hành và bundle, rồi áp vào các frame JS khi render trang. Đó là phần làm tiếp theo, vì phần lớn các lần sập của chúng tôi sẽ là JavaScript.</li>
<li><strong>iOS gốc:</strong> địa chỉ lệnh cần dSYM của bản build.</li>
<li><strong>Android:</strong> frame Java bị R8 làm rối (obfuscate) khi bật rút gọn mã (cần <code>mapping.txt</code>), còn frame C++ cần ký hiệu NDK.</li>
</ul>
<p>Còn một khoảng trống nhỏ hơn mà tôi đã nhắc ở trên: các lần sập gốc chưa mang tag OTA, vì tag này mới chỉ được đặt trong <code>beforeSend</code>. Đặt nó trên scope ngay khi bundle đang chạy được xác nhận chỉ mất vài dòng, nhưng tôi chưa viết.</p>
<p>Hiện tại, một issue cho tôi biết <em>cái gì</em> đã sập, trên phiên bản phát hành, bundle OTA và thiết bị nào, và người dùng đang làm gì ngay trước đó. Nó chưa cho tôi biết lỗi nằm <em>ở đâu</em> trong mã nguồn. Với một ứng dụng nhỏ phát hành vài lần mỗi tuần, cho tới giờ kiểu lỗi, thông điệp và breadcrumb vẫn đủ để tìm ra vấn đề.</p>
<p>Bộ nhận, trang quản trị và phần kiểm thử cộng lại cũng không nhiều mã, và nó không tốn thêm đồng nào ngoài chi phí của API vốn đã chạy sẵn. SDK làm phần khó nhất: bắt được lần sập trên điện thoại và gửi báo cáo đi ở lần khởi động kế tiếp.</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>Thay máy chủ cập nhật Expo tự dựng bằng Codemagic Patch</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-09-13-moving-to-codemagic-patch/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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>Self-Hosted</category>
      <category>Mobile Development</category>
      <category>CI/CD</category>
      <description>Tôi chuyển một ứng dụng Expo thật, kèm cả dự án native đã commit, từ expo-updates sang Codemagic Patch: bộ công nghệ chạy cục bộ, quá trình di chuyển, một lỗi do chính tôi gây ra rồi sửa qua mạng (over the air), và chuyện khôi phục (rollback) trông ra sao khi một bản phát hành thật sự bị sập.</description>
      <content:encoded><![CDATA[<blockquote><p><strong>Minh bạch.</strong> Codemagic đã trả tiền để tôi thực hiện đợt di chuyển này và viết về nó. Họ được đọc duyệt một lượt để kiểm tra tính chính xác về mặt sự kiện, chứ không phải về giọng văn, và không có chi tiết nào trong bài bị sửa theo yêu cầu của họ. Chỗ nào tôi gặp vấn đề và đã báo cáo, tôi nói rõ; nếu tới lúc bạn đọc bài này mà vấn đề đó đã được sửa, thì bài cũng ghi là đã sửa.</p>
</blockquote><blockquote><p><strong>Cập nhật, ngày 23 tháng 9 năm 2026.</strong> Đội ngũ phát triển của Codemagic đã lần lượt xử lý những vấn đề tôi báo cáo. Phần lớn giờ đã được sửa, trong đó có lỗi phát hiện phiên bản trên iOS, các lượt cài bị đếm trùng, lần chạy đầu tiên im lặng và lệnh gỡ lỗi ồn ào; tài liệu giờ cũng đã đề cập tới dự án native đã commit và ngân sách số lần mở trước khi khôi phục khi sập. Ở từng chỗ trong bài, tôi đều ghi chú lại vấn đề nào đã được sửa, còn vài vấn đề vẫn chưa xử lý xong thì tôi giữ nguyên như cũ.</p>
</blockquote><blockquote><p><strong>Cập nhật, ngày 16 tháng 10 năm 2026.</strong> Lần nâng cấp native đầu tiên kể từ khi chuyển sang Patch (Expo SDK 58) đã dựng ra một bản release Android không hề có cấu hình Patch nào bên trong, và không có gì báo lỗi cả. Chuyện gì đã xảy ra và cách sửa chỉ bằng một tệp nằm ở phần <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-13-moving-to-codemagic-patch/#l%e1%ba%a7n-n%c3%a2ng-c%e1%ba%a5p-native-%c4%91%e1%ba%a7u-ti%c3%aan" >Lần nâng cấp native đầu tiên</a>.</p>
</blockquote><p>Hồi tháng Hai, tôi có viết về chuyện <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-02-22-self-hosted-ota-updates/" >tự dựng máy chủ cập nhật cho Expo</a>. Hệ thống đó vẫn chạy tốt. maru, ứng dụng cộng đồng tôi đang vận hành, từ đó tới nay nhận mọi bản sửa lỗi qua mạng (over the air) thông qua nó, trên cả hai nền tảng, với chi phí khoảng một đô mỗi tháng.</p>
<p>Nhưng điều đó cũng có nghĩa là tôi phải tự gánh từng mảnh của nó: các bảng cơ sở dữ liệu, một endpoint API, một lệnh quản trị, một script phát hành, một khóa bí mật dùng chung, và một loạt cái bẫy mà tôi học được qua từng bản cập nhật hỏng. Khi ngồi xuống lên kế hoạch cho đợt di chuyển này, tôi mới phát hiện là script kia đã in ra dòng &ldquo;Sent 0 push notifications&rdquo; sau mỗi lần phát hành lên production, ngay từ ngày tôi viết nó, vì nó đọc một trường mà máy chủ này chưa bao giờ trả về. Chẳng ai để ý, và người ít để ý nhất chính là tôi. Tự vận hành một thứ gì đó thì đại khái là sẽ thành ra như vậy.</p>
<p>Nên khi Codemagic hỏi tôi có muốn thử <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a>, phiên bản <a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">mã nguồn mở</a>, tự vận hành được (self-hosted) của họ theo mô hình CodePush, tôi đồng ý với một điều kiện: phải là một ứng dụng thật chứ không phải bản demo. Ký mã (signing) thật, quy trình phát hành thật, dự án native đã commit sẵn với những chỗ sửa tay bên trong, đủ cả. Phần lớn những người rời EAS Update đều đang mang theo một ứng dụng như thế, chứ không phải một template mới tinh.</p>
<p>Dưới đây là toàn bộ quá trình, giữ nguyên cả những chỗ còn xù xì.</p>

<h2 class="relative group">Patch là gì
    <div id="patch-là-gì" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-l%c3%a0-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Patch gồm một máy chủ do bạn tự vận hành và một SDK bạn nhúng vào ứng dụng. Phía máy chủ có một API, một tiến trình chạy nền (worker), Postgres, kho lưu trữ đối tượng (object storage) tương thích S3, và một bảng điều khiển (dashboard) trên web, tất cả được đóng gói thành một bộ Docker Compose. Phía máy khách là <code>@codemagic/react-native-patch</code>, một TurboModule cho iOS và Android kèm plugin cấu hình cho Expo, cộng thêm một công cụ dòng lệnh (CLI) tên <code>cmpatch</code> để đóng gói (bundle) JavaScript của bạn và phát hành nó.</p>
<p>Sau khi đọc mã nguồn, có hai quyết định thiết kế khiến tôi chú ý.</p>
<p>Thứ nhất, ứng dụng không bao giờ hỏi API xem có bản cập nhật hay không. Khi một bản phát hành được công bố, worker ghi các manifest dạng JSON thuần vào kho lưu trữ đối tượng, và ứng dụng chỉ việc tải chúng về. Một lần kiểm tra cập nhật chỉ là hai yêu cầu GET tĩnh tới kho lưu trữ hoặc CDN; API chỉ nhận số liệu (metrics). Endpoint cũ của tôi trước đây nằm ngay trên đường đi của mọi lần mở ứng dụng. Với Patch thì chuyện đó không thể xảy ra.</p>
<p>Thứ hai, mỗi bản phát hành đều ghi rõ phiên bản nhị phân (binary) chính xác mà nó dành cho, và CLI tính ra một dấu vân tay (fingerprint) của dự án native rồi từ chối phát hành JavaScript lên một bản nhị phân có mã native không khớp. Máy chủ của tôi thì chỉ có một chuỗi phiên bản và rất nhiều hy vọng.</p>

<h2 class="relative group">Chạy thử trên laptop trước đã
    <div id="chạy-thử-trên-laptop-trước-đã" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ch%e1%ba%a1y-th%e1%bb%ad-tr%c3%aan-laptop-tr%c6%b0%e1%bb%9bc-%c4%91%c3%a3" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Tài liệu hướng dẫn bắt đầu bằng một bộ đánh giá chạy cục bộ (local evaluation stack), và đó đúng là chỗ nên bắt đầu.</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 cài xong trong hai giây. Còn cả bộ thì lần đầu mất 3 phút 14 giây, phần lớn là để dựng image của máy chủ và bảng điều khiển ngay trên máy, và ở chế độ không tương tác thì nó không in ra bất cứ thứ gì trong suốt quãng đó, trông y hệt như bị treo. (Chỗ này nay đã được sửa: mỗi bước giờ in ra một dòng tiến độ.) Rồi bốn container khỏe mạnh hiện lên, tất cả đều gắn vào localhost: bảng điều khiển ở cổng 8080, API ở 3000, Postgres, và MinIO ở 9100. Nó khéo léo tránh những cổng mà bộ công nghệ của tôi đang dùng sẵn.</p>
<p>Ở chế độ này, đăng nhập bị tắt, và trang nào cũng có một cảnh báo là đừng đưa nó ra ngoài Internet.</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="Trang đăng nhập của chế độ đánh giá cục bộ, với email điền sẵn và cảnh báo rằng xác thực đã bị tắt"
      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>Chế độ đánh giá cục bộ thay đăng nhập bằng GitHub bằng một email điền sẵn.</figcaption>
</figure>

<p>Bộ này có sẵn dữ liệu mẫu cho một ứng dụng tên Example Data, rất đáng xem trước khi bạn phát hành bất cứ thứ gì, vì nó cho thấy bảng điều khiển dùng để làm gì. Các con số trong hai ảnh chụp màn hình tiếp theo là dữ liệu mẫu đó, không phải người dùng thật.</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="Lịch sử phát hành của ứng dụng mẫu Example Data, với một bản canary 10% và số lượt thành công, thất bại của từng bản"
      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>Dữ liệu demo có sẵn: lịch sử phát hành với một bản canary 10%, phiên bản đích, và số lượt thành công, thất bại của từng bản.</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="Số liệu triển khai của ứng dụng mẫu: phân bố phiên bản, mức độ tiếp nhận theo thời gian, kết quả cập nhật"
      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>Dữ liệu demo có sẵn: các thiết bị thật sự đang chạy bản nào, và việc cài đặt diễn ra ra sao.</figcaption>
</figure>

<p>Để so sánh, bảng điều khiển của tôi trước đây là một danh sách các bản cập nhật trong trang quản trị, kèm một ô đánh dấu &ldquo;đang bật&rdquo;. Tôi hoàn toàn không biết có bao nhiêu điện thoại đã thật sự nhận được bản cập nhật.</p>
<p>Đăng nhập cho CLI là một luồng trên trình duyệt đàng hoàng, dùng PKCE và một callback về localhost, và trang phê duyệt cho bạn biết tài khoản nào đang yêu cầu, cùng với việc mã sẽ hết hạn sau chừng một phút.</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="Bảng điều khiển hỏi có phê duyệt một lượt đăng nhập từ CLI hay không"
      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 yêu cầu bảng điều khiển phê duyệt cho terminal.</figcaption>
</figure>


<h2 class="relative group">Trỏ nó vào một ứng dụng thật
    <div id="trỏ-nó-vào-một-ứng-dụng-thật" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#tr%e1%bb%8f-n%c3%b3-v%c3%a0o-m%e1%bb%99t-%e1%bb%a9ng-d%e1%bb%a5ng-th%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Patch muốn mỗi nền tảng là một ứng dụng riêng, mỗi ứng dụng được tạo sẵn hai môi trường triển khai (deployment) Staging và Production. Tôi tạo <code>maru-ios</code> trên bảng điều khiển và <code>maru-android</code> bằng <code>cmpatch app create</code>, chủ yếu để thử cả hai cách. Hộp thoại hiện ra sau khi tạo ứng dụng là màn hình hữu ích nhất của cả sản phẩm cho một đợt di chuyển: cả hai khóa triển khai (deployment key), hai URL mà SDK cần, và một ghi chú rằng khóa triển khai không phải là thông tin bí mật.</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="Hộp thoại báo đã tạo ứng dụng, hiển thị khóa triển khai Staging, Production và các URL cho SDK"
      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>Mọi thứ SDK cần, gói gọn trên một màn hình.</figcaption>
</figure>

<p>Hai điểm nhỏ. Hồi đó <code>cmpatch init</code> chỉ liên kết dự án với những ứng dụng đã tồn tại, nên nó không thể là lệnh đầu tiên, dù hướng dẫn di chuyển có ngụ ý thế nào đi nữa. Điểm này nay đã được sửa: ở chế độ tương tác, lệnh này giờ tự tạo sẵn một ứng dụng cho mỗi nền tảng. Và hộp thoại đưa URL của API là <code>http://localhost:8080</code>, trong khi tài liệu và CLI thì nói 3000. Cả hai đều chạy được, vì mỗi cổng đều phục vụ cả hai thứ, nhưng bạn sẽ thắc mắc đấy.</p>
<p>Sau đó tôi chạy <code>cmpatch doctor</code> trên ứng dụng còn nguyên chưa sửa gì, và nó tìm ra một vấn đề thật sự trong ba giây. Nó báo phiên bản nhị phân iOS là 2.0.0. Trong khi ứng dụng đang ở 2.5.1.</p>
<p>CLI tìm mọi tệp <code>Info.plist</code> bên dưới <code>ios/</code>, và nếu không tệp nào có chữ &ldquo;test&rdquo; trong đường dẫn, nó lấy tệp đầu tiên theo thứ tự bảng chữ cái. maru có một tiện ích mở rộng WidgetKit mà thư mục của nó lại xếp trước thư mục của chính ứng dụng, nên CLI lấy nhầm <code>Info.plist</code> của tiện ích mở rộng. Tệp plist đó đã cũ, vẫn còn ghi 2.0.0, đây là lỗi của tôi, nhưng hậu quả thì rất khó chịu: cơ chế tự phát hiện sẽ nhắm mọi bản phát hành iOS vào một phiên bản mà không điện thoại nào đang chạy, và sẽ chẳng có lỗi nào bắn ra. Bản cập nhật đơn giản là không bao giờ tới nơi, đúng kiểu thất bại mà tôi đã cảnh báo trong bài về chuyện tự dựng máy chủ. Trên Android thì <code>versionName</code> được đọc đúng. Cách khắc phục là truyền <code>--target-binary-version</code> hoặc <code>--plist-file</code>, và script phát hành của tôi giờ truyền phiên bản một cách tường minh, sau khi kiểm tra rằng <code>app.config.js</code> và cả hai dự án native đều thống nhất với nhau. Về sau, cũng chính lỗi này khiến bước kiểm tra manifest của <code>doctor</code> thất bại trên một môi trường triển khai đang chạy hoàn toàn bình thường, vì nó đi tìm manifest của phiên bản 2.0.0. Sau khi tôi sửa tệp plist cũ kia, <code>doctor</code> vượt qua cả 24 bước kiểm tra.</p>
<p>Codemagic đã sửa lỗi này. Giờ CLI đọc phiên bản từ tệp <code>Info.plist</code> của target ứng dụng chứ không phải của tiện ích mở rộng, và nếu có nhiều hơn một target có thể là ứng dụng chính, <code>cmpatch</code> sẽ hỏi bạn chọn cái nào.</p>

<h2 class="relative group">Phía JavaScript
    <div id="phía-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="#ph%c3%ada-javascript" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Gỡ <code>expo-updates</code> ra và thêm SDK vào chỉ là thay đổi hai dòng. Còn thay thế những gì ứng dụng vốn làm với các bản cập nhật thì phải suy nghĩ nhiều hơn.</p>
<p>maru kiểm tra cập nhật khi mở ứng dụng và mỗi khi quay lại từ chế độ nền (foreground), tối đa mười lăm phút một lần. Nếu có bản mới, nó tải về rồi đề nghị khởi động lại; bạn từ chối thì bản đó sẽ được áp dụng ở lần khởi động nguội (cold start) tiếp theo. Trong phần Cài đặt còn có nút kiểm tra thủ công, cho biết bạn đang chạy gói mã (bundle) nào, và trình báo cáo lỗi gắn nhãn mọi báo cáo bằng bản cập nhật mà nó xuất phát.</p>
<p>Điều đầu tiên tôi học được từ mã nguồn SDK là nó tra cứu mô-đun native ngay lúc import bằng <code>TurboModuleRegistry.getEnforcing</code>, hàm này sẽ ném lỗi nếu mô-đun không tồn tại. maru còn có cả bản web, và một lệnh import ở cấp cao nhất sẽ làm sập luôn ứng dụng web. Nên mọi thứ đi qua một mô-đun nhỏ nạp SDK theo kiểu lười (lazy), kèm một bản song sinh <code>.web.ts</code> trả về rỗng, cách này cũng giữ SDK hoàn toàn nằm ngoài gói mã 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>Bản thân bước kiểm tra tự động thì chủ yếu là <code>sync()</code>, hàm này không bao giờ ném lỗi mà trả về một trạng thái:</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>Mặc định của SDK cho các bản phát hành bắt buộc (mandatory) là <code>IMMEDIATE</code>, tức là nạp lại JavaScript ngay bất kể người dùng đang ở đâu. Tôi đổi thành áp dụng vào lần ứng dụng quay lại từ chế độ nền tiếp theo, vì mất một bài viết đang gõ dở là một lỗi còn tệ hơn lợi ích của hầu hết các bản sửa. Vào thời điểm đó, <code>InstallMode</code> là một kiểu hợp chuỗi (string union), chứ không phải đối tượng enum như ví dụ trong tài liệu gợi ý; giờ nó đã được export dưới dạng một đối tượng hằng, nên <code>InstallMode.ON_NEXT_RESTART</code> chạy đúng như tài liệu viết.</p>

<h2 class="relative group">Phía native, khi bạn đã commit ios/ và android/
    <div id="phía-native-khi-bạn-đã-commit-ios-và-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="#ph%c3%ada-native-khi-b%e1%ba%a1n-%c4%91%c3%a3-commit-ios-v%c3%a0-android" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Hướng dẫn di chuyển bảo chạy <code>npx expo prebuild --clean</code> sau khi thêm plugin cấu hình. Tôi chạy nó trong một bản checkout riêng, và nó xóa các ảnh đi kèm, tệp cấu hình bảo mật mạng của Android, tệp kê khai quyền riêng tư (privacy manifest) của iOS và <code>Podfile.lock</code>, đồng thời hoàn tác một bản sửa cho công cụ chạy kiểm thử trong <code>build.gradle</code> cùng vài trăm dòng của <code>Info.plist</code>. Bỏ <code>--clean</code> đi thì Expo vẫn dọn sạch cả hai thư mục.</p>
<p>Đây không phải lỗi của Patch. maru khởi đầu là một ứng dụng Expo dạng quản lý (managed) rồi dần có thêm những tùy biến native, nên <code>ios/</code> và <code>android/</code> được commit vào kho và EAS dựng chúng nguyên trạng. Nhưng có rất nhiều ứng dụng trông giống như vậy, và hồi đó hướng dẫn không hề cảnh báo bạn. Giờ thì có rồi: hướng dẫn di chuyển cho Expo tách thành hai hướng, chạy prebuild cho dự án có thư mục native được sinh tự động, và liệt kê chính xác những thay đổi cần áp dụng bằng tay cho dự án đã commit thư mục native.</p>
<p>Hóa ra những gì plugin thật sự thay đổi lại đủ nhỏ để làm bằng tay. Trên iOS, một dòng import và một dòng trong <code>AppDelegate.swift</code>:</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>Trên Android, một dòng import và một tham số trong <code>MainApplication.kt</code> (đây là dạng của React Native 0.82 trở lên; các phiên bản cũ hơn thì ghi đè <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>Cộng thêm ba giá trị cấu hình cho mỗi nền tảng và việc gỡ bỏ các thiết lập <code>expo-updates</code> cũ: 13 dòng thêm vào và 20 dòng xóa đi trên cả hai dự án.</p>
<p>Plugin ghi những giá trị cấu hình đó dưới dạng hằng (literal) vào lúc prebuild, mà với dự án native đã commit thì điều đó có nghĩa là mọi bản dựng (build) đều bị gắn cứng vào một môi trường triển khai duy nhất. Tôi muốn bản dựng xem trước (preview) đi vào Staging và bản dựng cho cửa hàng đi vào Production từ cùng một dự án, nên các giá trị này được lấy từ môi trường dựng thay vì ghi cứng. Trong <code>Info.plist</code>, Xcode tự mở rộng các thiết lập dựng:</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>Và trong <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>Các khóa sau đó nằm trong từng hồ sơ dựng (build profile) của <code>eas.json</code>, ngay cạnh các giá trị <code>EXPO_PUBLIC_</code>. Giá trị rỗng nghĩa là SDK lặng lẽ không làm gì cả, với bản dựng cục bộ thì không sao, nhưng với bản dựng cho cửa hàng thì là thảm họa, nên <code>app.config.js</code> giờ sẽ ném lỗi nếu một bản dựng EAS preview hoặc production thiếu chúng, hoặc vẫn còn trỏ về localhost.</p>

<h2 class="relative group">Bản cập nhật đầu tiên
    <div id="bản-cập-nhật-đầu-tiên" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%e1%ba%a3n-c%e1%ba%adp-nh%e1%ba%adt-%c4%91%e1%ba%a7u-ti%c3%aan" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Cho bản phát hành đầu tiên, tôi đổi dòng tiêu đề của màn hình onboarding, thứ bạn thấy được mà không cần đăng nhập, rồi phát hành:</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>Mất 30 giây, phần lớn là Metro và Hermes. Worker của máy chủ công bố manifest 0.6 giây sau khi tải lên xong. Bản cập nhật là một tệp tarball nén 7.3 MB, so với 11 MB gồm gói mã Hermes cộng tài nguyên mà cách kết xuất (export) cũ của tôi tạo ra.</p>
<p>Có một chỗ phải làm cho đúng ở đây: gói mã JavaScript nhúng thẳng các giá trị <code>EXPO_PUBLIC_</code> vào lúc dựng, nên bản cập nhật phải được dựng với đúng những giá trị của bản nhị phân mà nó sẽ đáp xuống, nếu không bạn sẽ đẩy đi một đoạn JavaScript nói chuyện với sai backend. Script của tôi nạp chúng từ đúng hồ sơ <code>eas.json</code> đã dùng để dựng bản nhị phân đó. Tài liệu về CI của Patch giờ cũng đã nói rõ điểm này, kèm theo lưu ý rằng bản thân <code>release-react</code> không đọc hồ sơ EAS.</p>
<p>Rồi tôi mở lại ứng dụng. Nhật ký (log) của thiết bị cho thấy toàn bộ lần kiểm tra cập nhật chỉ là hai yêu cầu tới kho lưu trữ đối tượng, <code>meta.json</code> và <code>2.5.1/manifest.json</code>. SDK ghi một sự kiện <code>Downloaded</code> ba giây sau khi mở, xếp bản cập nhật vào hàng chờ, và ứng dụng đề nghị khởi động lại.</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="Ứng dụng mở lại trên gói mã đi kèm, đề nghị cập nhật, rồi khởi động lại vào bản phát hành 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>Mở lại, hộp thoại của chính ứng dụng khi sync() trả về update-installed, rồi bản phát hành v1 giao qua mạng.</figcaption>
</figure>


<h2 class="relative group">Lỗi là của tôi
    <div id="lỗi-là-của-tôi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#l%e1%bb%97i-l%c3%a0-c%e1%bb%a7a-t%c3%b4i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Sau đó tôi nhìn vào bảng điều khiển. Một trình giả lập (simulator) đã cài bản phát hành v1, mà nó lại hiện hai lượt cài thành công và hai người dùng đang hoạt động.</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="Môi trường Staging hiển thị hai người dùng hoạt động và hai lượt thành công cho một thiết bị"
      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>Một trình giả lập, bị đếm hai lần.</figcaption>
</figure>

<p>SDK lưu trạng thái và các sự kiện số liệu đang chờ gửi dưới dạng tệp trong container của ứng dụng, nên việc truy vết khá dễ. Có hai sự kiện <code>Success</code> với dấu thời gian (timestamp) giống hệt nhau nhưng id khác nhau. Hook của tôi xác nhận gói mã đang chạy bằng <code>notifyAppReady()</code> ngay khi được gắn (mount), rồi gọi <code>sync()</code> ngay sau đó, mà <code>sync()</code> thì tự gọi <code>notifyAppReady()</code> bên trong. Hai lời gọi chồng lên nhau, mỗi lời gọi đều thấy bản phát hành đang ở trạng thái chờ, và mỗi lời gọi đều ghi lại sự kiện. Máy chủ có khử trùng lặp theo id sự kiện, nhưng hai sự kiện này có id khác nhau, nên cả hai đều được đếm, và những bản ghi trùng đó nằm lại vĩnh viễn.</p>
<p>Cách sửa ở phía tôi là chỉ có một lần xác nhận dùng chung cho mỗi lần mở ứng dụng, mọi thứ khác đều phải chờ nó. SDK cũng có thể tự phòng ngừa các lời gọi chồng chéo, và tôi đã đề xuất điều đó. Giờ SDK đã làm vậy: các lời gọi <code>notifyAppReady()</code> chồng lên nhau, kể cả lời gọi nằm bên trong <code>sync()</code>, sẽ dùng chung một lần xác nhận, nên mỗi lượt cài chỉ được ghi nhận một lần. Tài liệu tham khảo của SDK giờ cũng ghi rõ rằng <code>sync()</code> tự xác nhận trạng thái sẵn sàng, nên ứng dụng nào đã dùng nó thì không cần gọi <code>notifyAppReady()</code> riêng nữa.</p>
<p>Phần thú vị là chuyện đẩy bản sửa đi. Đây là thay đổi thuần JavaScript, nên nó được phát hành thành bản v2, và vì thiết bị đang ở v1 có thể nhận một bản vá nhị phân (binary diff), v2 tải về dưới dạng bản vá 543 KB thay vì gói 7.3 MB, tức khoảng 7% kích thước. Sau khi khởi động lại: một lượt tải, một lượt cài, một lượt thành công, một thiết bị hoạt động.</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="Lịch sử phát hành cho thấy v1 với số liệu bị thổi phồng và v2 với số liệu đúng"
      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>Số liệu trùng của v1 vẫn còn đó; v2 đếm đúng.</figcaption>
</figure>


<h2 class="relative group">Cố tình làm hỏng
    <div id="cố-tình-làm-hỏng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%e1%bb%91-t%c3%acnh-l%c3%a0m-h%e1%bb%8fng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Khôi phục (rollback) là tính năng tôi quan tâm nhất, vì hệ thống cũ của tôi không có cơ chế nào trên thiết bị cả. Một bản cập nhật hỏng sẽ làm sập mọi lần mở ứng dụng cho tới khi tôi để ý và bỏ đánh dấu một ô.</p>
<p>Nên tôi phát hành bản v3 ném lỗi trước lần kết xuất giao diện (render) đầu tiên, nghĩa là nó không bao giờ gọi được <code>notifyAppReady()</code>. Ứng dụng tải về, đề nghị khởi động lại, rồi chết ngay về màn hình chính.</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="Màn hình chính của trình giả lập sau khi ứng dụng sập vì bản phát hành lỗi">
      <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 sập ngay lúc mở.</figcaption>
</figure>

<p>Lần mở tiếp theo lại khởi động v3 và lại sập, điều này làm tôi bất ngờ, vì tài liệu nói rằng gói mã nào sập trước khi gọi <code>notifyAppReady()</code> sẽ được khôi phục. Mã nguồn giải thích: SDK cho phép ba lần mở chưa được xác nhận trước khi kết luận là sập, vì một lần mở chưa xác nhận có thể chỉ là iOS đã diệt một tiến trình khỏe mạnh trước khi JavaScript kịp chạy. Lần mở thứ tư thì ứng dụng quay lại 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="Ứng dụng chạy lại v2 sau khi tự động khôi phục">
      <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>Lần mở thứ tư: đã quay về v2 mà không ai phải động vào máy chủ.</figcaption>
</figure>

<p>Thiết bị sau đó tự báo cáo thất bại này dưới dạng <code>crash_rollback</code>, và bảng điều khiển ghi nhận nó cho v3. Đó là một thiết kế hợp lý, nhưng cần nói thẳng ý nghĩa của nó: một bản phát hành hỏng khiến mỗi người dùng phải chịu ba lần mở ứng dụng bị sập trước khi hồi phục, và vào thời điểm đó tài liệu không nhắc tới con số này. Giờ thì tài liệu đã ghi, và ngân sách này cũng cấu hình được, qua <code>CodemagicPatchMaxLaunchAttempts</code> ở phía native hoặc tùy chọn <code>maxLaunchAttempts</code> của plugin Expo. Đó chính là lý do nên đẩy lên Staging trước, rồi mới phát hành theo từng đợt (rollout) nhỏ lên Production.</p>
<p>Chặn một bản phát hành hỏng cho tất cả những người còn lại chỉ là một lệnh, và mất chưa tới một giây:</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>Nó không viết lại lịch sử. Nó công bố lại bản phát hành trước đó thành một bản mới (v4, được đánh dấu là bản khôi phục của v2), nên bản đang gây sập vẫn nằm trong danh sách với số lượt thất bại của nó, đây là cách ghi chép trung thực. Bảng điều khiển làm điều tương tự qua một hộp thoại.</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="Khôi phục Staging từ bảng điều khiển: bản phát hành trước được công bố lại thành 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>Khôi phục từ bảng điều khiển: v2 quay lại thành v4, còn v3 vẫn nằm trong danh sách.</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="Lịch sử phát hành sau khi khôi phục, với v4 được đánh dấu là bản khôi phục và v3 ghi nhận một lượt thất bại"
      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 là bản khôi phục; v3 vẫn giữ lượt thất bại của nó.</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="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Android có hai bất ngờ, đều không phải do Patch. Quy tắc keep cho R8 mà tôi thêm cho SDK nằm trong <code>app.config.js</code>, thứ chỉ tới được dự án native thông qua prebuild, nên bản dựng release đầu tiên của tôi vô tình trở thành bài kiểm thử xem SDK có sống sót qua R8 mà không cần quy tắc đó hay không. Kết quả là có: R8 đổi tên toàn bộ các lớp của nó, mà bản cập nhật vẫn tải, cài và khôi phục bình thường. Còn trình giả lập (emulator), vừa khởi động nguội xong trong khi Gradle vẫn đang chiếm bộ nhớ, chậm đến mức bài kiểm thử đầu tiên của tôi bỏ cuộc 45 giây trước khi SDK tải xong bản cập nhật mà nó đã bắt đầu tải.</p>
<p>Bản v1 được áp dụng, với một lượt thành công và một thiết bị hoạt động, còn bản v2 gây sập thì được khôi phục sau cùng một ngân sách ba lần mở như trên.</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="Ứng dụng Android hiển thị dòng tiêu đề được giao qua mạng"
      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, đã quay về v1 sau lần khôi phục của chính nó.</figcaption>
</figure>


<h2 class="relative group">Vượt ra ngoài kịch bản suôn sẻ
    <div id="vượt-ra-ngoài-kịch-bản-suôn-sẻ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#v%c6%b0%e1%bb%a3t-ra-ngo%c3%a0i-k%e1%bb%8bch-b%e1%ba%a3n-su%c3%b4n-s%e1%ba%bb" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bản cập nhật tới nơi và khôi phục được là phần lõi, nhưng một đội (team) sống với những tính năng còn lại, nên tôi đi qua từng cái một.</p>
<p>Phát hành theo từng đợt là tất định (deterministic), điểm này tôi thích. Một thiết bị được nhận bản mới nếu tám chữ số hex đầu của <code>md5(deviceId + &quot;-&quot; + releaseLabel)</code>, lấy modulo 100, nhỏ hơn tỷ lệ phát hành, nên với một bản phát hành nhất định, cùng một chiếc điện thoại sẽ luôn được nhận hoặc luôn không được nhận. Tôi tính ra trình giả lập của mình rơi vào nhóm 91 cho bản phát hành kế tiếp, công bố nó ở mức 25%, và trình giả lập đúng là không nhận được gì. Nâng lên 95% bằng <code>cmpatch release patch</code>, mất chưa tới một giây, thì nó cập nhật ở lần mở tiếp theo.</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="Nâng mức phát hành của một bản từ 25% lên 95% trong bảng điều khiển">
      <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>Nâng một đợt phát hành một phần từ 25% lên 95%, không cần công bố lại.</figcaption>
</figure>

<p>Rồi bản phát hành tiếp theo của tôi thất bại với mã 409: &ldquo;deployment has an active rollout below 100 percent&rdquo;. Mỗi môi trường triển khai chỉ được phép có một đợt phát hành một phần tại một thời điểm, nên bạn phải hoàn tất, tắt hoặc khôi phục bản canary trước khi đẩy bất cứ thứ gì khác. Điều đó hợp lý, và có ghi trong tài liệu, nhưng một quy trình (pipeline) tự phát hành sau mỗi lần merge sẽ đâm phải nó ngay giữa một đợt canary. Từ sau khi tôi báo cáo, CLI đã giải thích rõ ở chỗ này: nếu bạn đang bị chặn bởi một đợt phát hành một phần còn dang dở, nó cho biết chờ cũng vô ích và hướng dẫn cách hoàn tất hoặc tắt đợt đó; còn nếu có một tác vụ phát hành khác vẫn đang chạy, nó bảo bạn chờ bằng <code>release inspect --wait</code>. Tài liệu giờ cũng có riêng một phần về việc thực hiện các thay đổi phát hành theo tuần tự.</p>
<p>Bản phát hành bắt buộc dùng <code>mandatoryInstallMode</code> của SDK. Với thiết lập của tôi là áp dụng vào lần quay lại tiếp theo, ứng dụng tải bản cập nhật về và hiện hộp thoại, và khi tôi lờ hộp thoại đi, đưa ứng dụng xuống nền rồi mở lại, phiên bản mới đơn giản là đã ở đó.</p>
<p>Tắt một bản phát hành không chỉ là ngừng cung cấp nó. Ngay khi tôi tắt bản mới nhất, các manifest trỏ về bản phát hành mới nhất còn đang bật, và các thiết bị đang chạy bản bị tắt được đề nghị một bản vá để quay về đó. Tắt hết mọi bản cho một phiên bản nhị phân thì đích của manifest trở thành null, và SDK đưa ứng dụng về gói mã đi kèm ban đầu. Hook cập nhật của tôi lúc đầu không hiện hộp thoại cho trường hợp này, nên giờ tôi đã bổ sung.</p>
<p>Chuyển (promote) từ Staging lên Production mất chưa tới một giây và dùng lại đúng gói đã được kiểm thử, cùng mã băm (hash) và ghi chú, thay vì dựng lại. Lệnh này cũng nhận tỷ lệ phát hành, nên &ldquo;chuyển lên 10% của Production&rdquo; chỉ là một lệnh.</p>
<p>Dựng và phát hành cũng có thể tách thành hai bước riêng. <code>cmpatch bundle</code> tạo ra một tệp <code>.cmpatch</code>, và tùy chọn &ldquo;Bundle upload&rdquo; trên bảng điều khiển sẽ đọc nó trước khi tải lên: nền tảng, phiên bản đích, dấu vân tay, trạng thái ký, kích thước và công cụ đóng gói. Cách này hợp với mô hình CI dựng tệp, còn một người khác quyết định khi nào phát hành.</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="Tải một tệp .cmpatch lên trong bảng điều khiển: nó đọc nền tảng, phiên bản, dấu vân tay và kích thước, rồi cảnh báo dấu vân tay không khớp">
      <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>Tải gói lên: bảng điều khiển đọc tệp, rồi cơ chế bảo vệ bằng dấu vân tay lên tiếng.</figcaption>
</figure>

<p>Đó cũng là chỗ tôi thật sự va phải cơ chế bảo vệ bằng dấu vân tay. Sau khi sửa tệp plist cũ của widget, máy chủ từ chối bản phát hành tiếp theo với mã 409, vì dấu vân tay của dự án native không còn khớp với cái nó đã ghi nhận cho 2.5.1. Trên Android, chỉ sửa một dòng chú thích (comment) trong <code>app.config.js</code> cũng đủ, vì Expo đưa cả cấu hình ứng dụng vào dấu vân tay. Bảng điều khiển hiện cùng kiểm tra đó dưới dạng cảnh báo với cả hai dấu vân tay và một nút &ldquo;Upload anyway&rdquo;. Trong cả hai trường hợp, phần native trên bản nhị phân đã cài không hề thay đổi, nên bỏ qua cảnh báo là đúng. Nhưng trong thực tế, câu trả lời trung thực gần như luôn là tăng phiên bản và dựng bản mới cho cửa hàng, và script phát hành của tôi giờ nói đúng như vậy khi chuyện này xảy ra.</p>
<p>Ký mã dùng RS256: CLI ký mã băm của gói bằng một khóa riêng RSA, và SDK đối chiếu với khóa công khai được biên dịch sẵn vào bản nhị phân. Khóa được đưa vào dưới dạng một dòng base64 duy nhất, khớp với cách tiếp cận qua thiết lập dựng của tôi, và tôi đã kiểm thử trên cả hai nền tảng. Khi ứng dụng đã có khóa, một bản phát hành chưa ký bị từ chối trước cả khi tải về. Khi bật &ldquo;require code signing&rdquo; cho ứng dụng, máy chủ từ chối một lần phát hành chưa ký với thông báo lỗi nêu rõ cách sửa và dẫn liên kết tới hướng dẫn. Bản phát hành đã ký thì cài được, và SDK ghi nhận chữ ký là đã xác minh. Nó cũng từ chối một bản phát hành cũ chưa ký mà máy chủ quay về khi tôi tắt bản đã ký. Điểm cần lưu ý là khóa công khai phải có mặt trong bản nhị phân đầu tiên bạn phát hành, vì bản cập nhật không thể thêm nó vào được.</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="Chi tiết bản phát hành hiển thị chữ ký là 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>Một bản phát hành đã ký.</figcaption>
</figure>

<p>Phần đội nhóm và CI cũng được lo. Thành viên nhận một trong bốn vai trò áp dụng toàn đội (owner, admin, developer, viewer), và lời mời sẽ chờ cho tới khi người đó đăng nhập lần đầu. Token truy cập cá nhân (personal access token) chạy được với thư mục home trống, truyền qua <code>--token</code> hoặc qua biến môi trường, và danh sách token cho biết mỗi token được dùng lần cuối khi nào. Tôi cũng đã viết một workflow CI chạy thủ công, chạy cùng script phát hành đó trên một runner Linux với khóa ký lấy từ secret, dù tôi chưa chạy nó, vì nó cần máy chủ thật.</p>
<p>Một công cụ mà hồi đó tôi sẽ bỏ qua là <code>cmpatch debug ios</code>. Nó phát trực tiếp nhật ký hệ thống được lọc theo chữ &ldquo;OTA&rdquo; không phân biệt hoa thường, mà chuỗi này khớp cả với &ldquo;Rotation&rdquo;, nên một lần mở ứng dụng kéo dài hai mươi giây cho ra cả màn hình thông điệp của SpringBoard mà không có dòng nào từ ứng dụng. Lỗi này đã được sửa: giờ nó chỉ hiện những dòng mang dấu hiệu nhận biết của SDK. Bản thân SDK cũng không ghi nhật ký nhiều, chủ yếu là lỗi và cảnh báo trong bản dựng phát triển, nên đừng ngạc nhiên nếu luồng nhật ký khá im ắng. Còn cảnh báo <code>getpwuid_r</code> mà bạn có thể thấy là đến từ <code>xcrun simctl</code>, không phải từ Patch.</p>

<h2 class="relative group">Rốt cuộc thì khó tới mức nào
    <div id="rốt-cuộc-thì-khó-tới-mức-nào" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#r%e1%bb%91t-cu%e1%bb%99c-th%c3%ac-kh%c3%b3-t%e1%bb%9bi-m%e1%bb%a9c-n%c3%a0o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Codemagic hỏi ý kiến của tôi, nên đây là ý kiến đó, nói thẳng nhất có thể.</p>
<p>Chạy thử thì dễ. Hai lệnh, vài phút, và bạn có máy chủ thật, một bảng điều khiển với dữ liệu demo, và một CLI trỏ được vào ứng dụng của chính bạn. Tôi đánh giá đây là phần tốt nhất trong giờ đầu tiên.</p>
<p>Tích hợp SDK là việc ở mức vừa phải, và phần lớn cái khó nằm ở dự án của chính bạn chứ không phải ở Patch. Với một ứng dụng Expo mới tinh có thư mục native được sinh tự động, thì đúng là chỉ cần plugin cộng vài dòng thay thế các lời gọi <code>expo-updates</code>. Với một ứng dụng như maru, có dự án native đã commit, một tiện ích mở rộng widget, một bản web, R8 và các hồ sơ dựng, thì công việc nằm ở những chỗ rìa. Tôi phải áp dụng các thay đổi native bằng tay để tránh prebuild, đưa cấu hình về lúc dựng để bản Staging và Production có thể khác nhau, và giữ SDK tránh xa bản web. Tôi cũng phải đảm bảo JavaScript trong bản cập nhật được dựng với cùng môi trường như bản nhị phân. Không việc nào khó khi đã hiểu. Lúc tôi làm thì phần lớn những điều đó chưa có trong tài liệu, dù giờ hướng dẫn cho dự án native đã commit và chuyện môi trường lúc đóng gói đều đã được bổ sung.</p>
<p>Dùng hằng ngày thì tốt. Phát hành là một lệnh và chừng nửa phút. Phát hành theo từng đợt, chuyển môi trường, tắt và khôi phục đều dưới một giây từ CLI hoặc bảng điều khiển, và thông báo lỗi nói rõ đã sai gì và phải làm gì. Các cơ chế bảo vệ của máy chủ (kiểm tra dấu vân tay, mỗi lần chỉ một canary, ký mã) nghiêm ngặt theo cách đã hơn một lần ngăn tôi làm sai.</p>
<p>Vận hành nó trên production là cam kết lớn hơn. Đó là một bộ Docker Compose với Postgres và kho lưu trữ đối tượng mà bạn phải tự lưu trữ, sao lưu và nâng cấp, cộng thêm hai tên miền và một ứng dụng OAuth. Với một đội đã quen vận hành máy chủ thì đó là một buổi chiều yên ả. Với một đội dùng EAS chính vì không muốn dính tới máy chủ, đây mới là chi phí thật của việc chuyển đổi, hơn cả phần việc với SDK.</p>
<p>Tính theo thời gian thực, tôi cài CLI lúc 12:29 và có bản cập nhật đầu tiên áp dụng trên trình giả lập lúc 13:34, trong đó nửa tiếng là một lần dựng Xcode bị treo vì lý do không liên quan tới Patch. Mọi thứ trong bài này, trên cả hai nền tảng, đều xong trước giữa buổi chiều, chưa tính máy chủ thật.</p>
<p>Những thứ ngốn của tôi nhiều thời gian nhất, theo thứ tự: <code>expo prebuild --clean</code> xóa sạch dự án native đã commit, rồi việc tìm cách nối mọi thứ bằng tay; lỗi phát hiện phiên bản trên iOS, thứ suýt gửi bản cập nhật tới một phiên bản không ai chạy; các lượt cài bị đếm trùng, lỗi do chính tôi gây ra nhưng ai cũng dễ lặp lại, vì <code>sync()</code> tự gọi <code>notifyAppReady()</code>; và ngân sách ba lần mở khi sập chưa được ghi trong tài liệu, thứ làm tôi bất ngờ giữa chừng một bài kiểm thử. Sau đó là những thứ đúng nhưng cần lên kế hoạch: cơ chế bảo vệ dấu vân tay kích hoạt sau những chỉnh sửa vô hại gần với native, mỗi môi trường triển khai chỉ một canary tại một thời điểm, và các tác vụ vòng đời không được chồng lên nhau, điều này quan trọng ngay khi một script làm nhiều việc liên tiếp. Phần còn lại đều nhỏ: lần thiết lập đầu tiên chạy im lặng, một lệnh gỡ lỗi ồn ào, và vài ví dụ trong tài liệu hồi đó không khớp với mã.</p>
<p>Tôi đã viết toàn bộ những điều này, kèm chi tiết, gửi cho Codemagic, và tới ngày 23 tháng 9 thì phần lớn đã được sửa: phát hiện phiên bản, các lần xác nhận chồng lên nhau, cảnh báo về prebuild, ngân sách số lần mở khi sập, lần thiết lập im lặng, lệnh gỡ lỗi, các ví dụ về <code>InstallMode</code>, và thông báo lỗi khi bị một tác vụ phát hành hoặc một bản canary chặn đường. Những gì còn tồn đọng thì nhỏ hơn: bảng điều khiển hiển thị API ở <code>:8080</code> trong khi mọi chỗ khác đều ghi <code>:3000</code>, SDK ném lỗi ngay lúc import trên web và trong Jest, và vài chỗ còn thiếu trong tài liệu.</p>

<h2 class="relative group">Đưa lên production
    <div id="đưa-lên-production" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%c6%b0a-l%c3%aan-production" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Mọi thứ ở trên đều chạy trên bộ đánh giá. Production là cùng ứng dụng và cùng script đó trỏ vào một máy chủ thật, và đây là kế hoạch cho maru.</p>
<!-- EDITOR NOTE: phần này là quy trình dự kiến. Thay bằng những gì thật sự đã xảy ra (nơi lưu trữ, thời gian, thứ gì hỏng) sau khi chuyển đổi thật. -->
<p>Cách vận hành máy chủ được hỗ trợ chính thức là cài bằng Docker Compose: một máy Linux mở cổng 80 và 443, hai tên miền (một cho API và bảng điều khiển, một cho tải xuống), và một ứng dụng GitHub OAuth để đăng nhập, không có nó thì máy chủ không chịu khởi động. Trình cài đặt lo phần còn lại, kể cả 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>Nó ghi ra một tệp <code>.env.selfhost</code> chứa các khóa bí mật của cơ sở dữ liệu và kho lưu trữ, thứ nên nằm trong trình quản lý mật khẩu của bạn.</p>
<p>Nhưng bạn không bắt buộc phải dùng cơ sở dữ liệu và kho lưu trữ đi kèm, và đó chính là điểm khiến nó vừa với một bộ công nghệ như của tôi. API của maru đã chạy trên Fly.io và tệp của nó nằm trên Tigris, mà Patch chạy được trên cả hai. Bên dưới tệp Compose, máy chủ là một container chạy ở <code>MODE=all</code> (API và worker phát hành gộp chung) cần một cơ sở dữ liệu Postgres, kho lưu trữ tương thích S3 và một URL HTTPS công khai cho các tệp. Tức là một ứng dụng Fly dựng từ Dockerfile trong kho mã (chưa có image được công bố, nên <code>fly deploy</code> sẽ tự dựng), một cơ sở dữ liệu Fly Postgres, và một bucket 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>Hai điều cần lưu ý. Chạy ngoài Compose được ghi trong kho mã như tài liệu tham khảo, chứ không phải cách được hỗ trợ trong bản mã nguồn mở đầu tiên này, nên trên Fly bạn phải tự lo nâng cấp và sao lưu. Tôi cũng chưa triển khai theo cách đó; đây là kế hoạch, chưa phải báo cáo. Và nếu bạn đã đọc bài của tôi về <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/" >cơ chế thu về 0 của Fly</a>, hãy giữ ít nhất một máy luôn chạy. Điện thoại không bao giờ phải chờ API khi kiểm tra cập nhật, vì manifest lấy thẳng từ kho lưu trữ, nhưng nếu không thì mọi lần tải số liệu lên và mọi tác vụ phát hành sẽ bắt đầu bằng một lần khởi động nguội.</p>
<p>Tigris là phần tôi hào hứng nhất. Đó là nơi tệp của maru vốn đã nằm, và nó đặt một CDN phía trước cái URL mà mọi lần mở ứng dụng đều gọi tới. Nếu làm theo cách này, hãy để ý việc lưu đệm (cache) manifest: với bộ điều hợp (adapter) phân phối theo URL gốc đơn giản, Patch trả manifest với <code>no-cache</code>, vì nó không thể xóa bộ nhớ đệm trên CDN, và một manifest bị lưu đệm theo TTL riêng sẽ tiếp tục phục vụ một bản phát hành mà bạn vừa khôi phục xong. Tài liệu tham khảo cấu hình giờ đã ghi rõ điều này ở mục <code>MANIFEST_CACHE_CONTROL</code>, và trang tài liệu cũng đã có phần về kho lưu trữ tương thích S3.</p>
<p>Trên máy chủ thật, bạn tạo lại các ứng dụng, đưa khóa mới vào hồ sơ <code>preview</code> và <code>production</code> trong <code>eas.json</code> (khóa Staging và khóa Production), và tạo một token để phát hành:</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>Nếu muốn ký mã, hãy quyết định ngay từ bây giờ: tạo cặp khóa cho production, giữ khóa riêng làm secret trong CI, đưa khóa công khai vào các hồ sơ dựng, và bật &ldquo;require code signing&rdquo; cho cả hai ứng dụng. Bản nhị phân đầu tiên có Patch cũng cần một số phiên bản mới, vì các bản nhị phân đang nằm trên cửa hàng vẫn chạy <code>expo-updates</code>. Dựng nó bằng hồ sơ preview cho TestFlight và kiểm thử nội bộ trên Play trước, rồi kiểm tra xem các khóa có thật sự vào được <code>Info.plist</code> đã dựng và tài nguyên Android hay chưa, trước khi đưa cho bất kỳ ai. Đó là mắt xích duy nhất mà các bài kiểm thử cục bộ của tôi chưa chạm tới, vì tôi đã xuất biến môi trường bằng tay. Sau đó phát hành lên Staging, xác nhận mỗi người thử nghiệm có một lượt tải và một lượt thành công, rồi mới dựng bản cho cửa hàng.</p>
<p>Bản phát hành lên production thì bắt đầu nhỏ rồi tăng dần:</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>Trong lúc đó, máy chủ cũ vẫn giữ nguyên. Mọi bản 2.5.1 mà người dùng chưa cập nhật vẫn kiểm tra ở đó, và bản sửa nóng (hotfix) cho họ vẫn đi qua script cũ. Khi nhật ký cho thấy những yêu cầu đó đã dừng hẳn, thì máy chủ cũ, script phát hành và khóa bí mật của nó đều có thể bỏ đi.</p>

<h2 class="relative group">Lần nâng cấp native đầu tiên
    <div id="lần-nâng-cấp-native-đầu-tiên" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#l%e1%ba%a7n-n%c3%a2ng-c%e1%ba%a5p-native-%c4%91%e1%ba%a7u-ti%c3%aan" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><em>Bổ sung ngày 16 tháng 10 năm 2026.</em></p>
<p>Từ đầu tháng 10, Patch đã đảm nhận việc phân phối các bản cập nhật production của maru. Bản phát hành native đầu tiên kể từ đó là đợt chuyển lên Expo SDK 58: React Native 0.88, và trên Android là một phiên bản chính (major) mới của plugin Gradle. Thay đổi native không thể gửi qua mạng, nên đó là một bản nhị phân mới, 2.6.0, và script phát hành của tôi từ chối gửi bản cập nhật cho 2.6.0 cho tới khi bản nhị phân đó tồn tại. Cả hai nền tảng đều dựng trơn tru ngay lần đầu.</p>
<p>Phần production ở trên dặn hãy kiểm tra xem các khóa có thật sự vào được <code>Info.plist</code> đã dựng và tài nguyên Android hay chưa, trước khi đưa bản dựng cho bất kỳ ai. Lần này tôi đã kiểm tra thật, và chính bước kiểm tra đó là lý do phần này tồn tại. Bản dựng iOS có đủ bốn thiết lập của Patch. Bản dựng Android thì không có cái nào:</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>Lệnh đó in ra bốn khóa với bản dựng 2.5.11 và không in gì với 2.6.0. Vấn đề không nằm ở biến môi trường: tác vụ <code>generateReleaseResValues</code> của Gradle ghi đủ cả bốn. Chúng bị xóa ở bước sau bởi bộ thu gọn tài nguyên (resource shrinker) của bản release, thứ mà trong Android Gradle Plugin 9 đã trở nên mạnh tay hơn nhiều. Cùng một ứng dụng mà số tài nguyên giảm từ 9,240 xuống 7,116. SDK đọc cấu hình theo tên bằng <code>getIdentifier</code>, thứ mà bộ thu gọn không nhìn thấy, nên nó coi bốn chuỗi đó là không dùng tới và xóa đi. Một chuỗi bị thiếu được đọc thành rỗng, và rỗng nghĩa là &ldquo;không có cập nhật&rdquo;. Sẽ không có gì thất bại cả. Android 2.6.0 sẽ cài được, chạy bình thường và không bao giờ nhận được bản cập nhật nào, và dấu hiệu duy nhất là trên bảng điều khiển không có thiết bị Android 2.6.0 nào.</p>
<p>Cách sửa là một tệp tài nguyên bảo bộ thu gọn giữ lại bốn chuỗi đó:</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>Đừng đặt tên nó là <code>keep.xml</code>: tác vụ đóng gói của React Native đã tự sinh một tệp <code>raw/keep.xml</code> cho các tài nguyên JavaScript của bạn (đó là lý do hình ảnh và phông chữ vẫn sống sót), và hai tệp sẽ đụng nhau. Chỗ hợp lý hơn cho quy tắc này là chính SDK, vì tệp keep của một thư viện được gộp vào ứng dụng một cách tự động, và chỉ cần một cảnh báo khi bản dựng release khởi động mà không có khóa triển khai là trường hợp im lặng này sẽ trở nên dễ thấy. Đây là cùng kiểu thất bại với lỗi phát hiện phiên bản trên iOS ở phần trước: không có gì báo lỗi, và bản cập nhật cứ thế không bao giờ tới.</p>
<p>Cũng bước kiểm tra đó còn lộ ra vấn đề thứ hai chẳng liên quan gì tới Patch, và nó đáng biết vì một lý do khác. Bản dựng release Android sập ngay khi mở, tới hai lần, vì hai lý do riêng biệt nằm trong chính các mô-đun của Expo. Đầu tiên, mô-đun widget của Expo không đăng ký được các lớp native của nó, nên ứng dụng chết trước khi bất kỳ dòng JavaScript nào kịp chạy. Sửa xong lỗi đó, ứng dụng chạy được tới lúc khởi động JavaScript rồi dừng lại, vì ba mô-đun đã chuyển sang hệ thống mô-đun mới của Expo chưa bao giờ được đăng ký. Cơ chế khôi phục khi sập của Patch không giúp được gì trong cả hai trường hợp, vì không có bản cập nhật nào để khôi phục. Nó bảo vệ bạn khỏi một bản cập nhật hỏng, chứ không phải khỏi một bản nhị phân hỏng. Có Patch hay không thì một lần nâng cấp native vẫn phải được cài và mở thử trên một thiết bị thật hoặc trình giả lập trước khi lên cửa hàng.</p>
<p>Cũng từ đợt thử nghiệm đó còn một chuyện nhỏ hơn. Khi <code>sync()</code> thất bại, nó trả về <code>&quot;error&quot;</code> và giữ kín lý do, trừ khi ứng dụng đang chạy ở chế độ phát triển, nên một bản dựng release chỉ có thể ghi vào nhật ký rằng đã có gì đó trục trặc. Giờ hook của tôi gọi <code>checkForUpdate()</code> một lần sau mỗi lần sync thất bại và ghi lại lỗi mà nó ném ra, và nhờ vậy tôi mới biết một bản dựng cho trình giả lập cục bộ có các URL Patch rỗng (<code>unsupported URL</code>), chứ không phải máy chủ bị hỏng. Sẽ có ích nếu lỗi gần nhất cũng đọc được trong bản dựng release.</p>
<p>Danh sách kiểm tra cho một bản phát hành native của tôi giờ có thêm ba dòng mà trước đây không có: trích xuất các thiết lập Patch từ tệp <code>.ipa</code> và <code>.aab</code> đã dựng, so sánh khóa triển khai với hồ sơ dựng, và chạy chính bản dựng release, không phải bản dựng debug, trước khi nộp.</p>

<h2 class="relative group">Điều tôi muốn nói với ai đang trả hóa đơn cho Expo
    <div id="điều-tôi-muốn-nói-với-ai-đang-trả-hóa-đơn-cho-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="#%c4%91i%e1%bb%81u-t%c3%b4i-mu%e1%bb%91n-n%c3%b3i-v%e1%bb%9bi-ai-%c4%91ang-tr%e1%ba%a3-h%c3%b3a-%c4%91%c6%a1n-cho-expo" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nếu bạn đang trả tiền cho EAS Update vì thấy tự dựng máy chủ có vẻ quá nhiều việc, thì Patch là một câu trả lời thật sự: bạn vẫn phải vận hành một máy chủ, nhưng đó là một bộ Compose và một trình cài đặt, chứ không phải mã mà bạn phải tự bảo trì. So với thứ tôi đã xây, bạn có thêm phát hành theo từng đợt, số lượt cài và thất bại của từng bản, bản vá nhị phân, cơ chế bảo vệ bằng dấu vân tay, ký mã, khôi phục khi sập ngay trên thiết bị, và một nút khôi phục. Tôi chưa từng xây được cái nào trong số đó.</p>
<p>Cái giá phải trả: một bản nhị phân mới trước khi bất kỳ ai nhận được bản cập nhật qua Patch, vì SDK phải có trong ứng dụng; một máy chủ phải giữ cho luôn được nâng cấp và sao lưu; mặc định là ba lần mở ứng dụng bị sập cho mỗi người dùng khi một bản phát hành hỏng đủ nặng; và một ít việc làm tay nếu dự án native của bạn đã commit. Nó cũng còn non. Đây là phiên bản 0.3.0, và tôi gặp một lỗi phát hiện phiên bản, một lệnh gỡ lỗi ồn ào và vài chỗ thiếu trong tài liệu, cộng thêm một lỗi do chính tôi gây ra mà SDK lẽ ra có thể ngăn được. Từ đó tới nay, lỗi phát hiện phiên bản và lệnh gỡ lỗi đều đã được sửa, phần lớn chỗ thiếu trong tài liệu đã được lấp, và SDK giờ đã tự phòng ngừa đúng cái sai mà tôi mắc phải. Không điều nào trong số đó cản trở đợt di chuyển, và tất cả đều nhìn thấy được, trên CLI, trên bảng điều khiển, hoặc ngay trong các tệp của SDK trên thiết bị, điều mà tôi không thể nói về hệ thống cũ của mình.</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>SEO cho React Native Web: khi Cloudflare trả về cho Googlebot một trang trống</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-cloudflare-crawlers-cache/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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>Cách tôi làm cho ứng dụng React Native Web hiện ra với Google và trình thu thập dữ liệu của Naver bằng kết xuất động, và quy tắc bộ nhớ đệm Cloudflare đã trả cho Googlebot khung SPA rỗng.</description>
      <content:encoded><![CDATA[<p>Nếu trang web React hay React Native Web của bạn là một ứng dụng trang đơn, trình thu thập dữ liệu (crawler) của Naver (Yeti) và Bing sẽ không chạy JavaScript của nó, còn Google thì chỉ thỉnh thoảng mới chạy. Cách sửa là kết xuất động (dynamic rendering): nhận diện crawler qua user-agent rồi trả cho nó HTML kết xuất sẵn ở máy chủ, với đúng nội dung mà ứng dụng sẽ vẽ ra. Sai lầm cần tránh trên Cloudflare là lưu đệm URL đó cho tất cả mọi người. Cloudflare bỏ qua <code>Vary</code> với HTML, nên nội dung nào đến trước sẽ được trả cho mọi lượt truy cập sau, và Googlebot có thể nhận về cái khung rỗng; chỉ lưu đệm request của crawler, và xóa bộ nhớ đệm (purge) sau khi thu hẹp quy tắc.</p>
<p>Ứng dụng chợ (marketplace) mà tôi đang làm là một ứng dụng trang đơn (single-page app) viết bằng React Native Web. Trước khi JavaScript chạy, trang tin đăng (listing) nào cũng chỉ có đúng một thẻ script và một thẻ div rỗng. Với người dùng thì chẳng sao, nhưng với công cụ tìm kiếm thì coi như xong: Google thỉnh thoảng chạy được JS, Bing thì không, còn trình thu thập dữ liệu (crawler) của Naver — thứ quan trọng nhất với một trang web mà gần như toàn bộ tin đăng đều bằng tiếng Hàn — thì chắc chắn là không.</p>
<p>Bài này kể lại quá trình tôi làm cho các trang đó hiện ra với công cụ tìm kiếm, rồi chuyện một quy tắc lưu đệm (cache rule) phá hỏng tất cả theo cách tôi không hề lường trước.</p>

<h2 class="relative group">Kết xuất động, chứ không phải che giấu nội dung
    <div id="kết-xuất-động-chứ-không-phải-che-giấu-nội-dung" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#k%e1%ba%bft-xu%e1%ba%a5t-%c4%91%e1%bb%99ng-ch%e1%bb%a9-kh%c3%b4ng-ph%e1%ba%a3i-che-gi%e1%ba%a5u-n%e1%bb%99i-dung" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Cách sửa thì chẳng có gì mới, và được chính Google chấp nhận dưới cái tên kết xuất động (dynamic rendering), khác hẳn với che giấu nội dung (cloaking): nhận diện trình thu thập dữ liệu, rồi trả cho nó HTML đã kết xuất (render) sẵn ở máy chủ, chứa đúng nội dung mà SPA lẽ ra sẽ vẽ lên màn hình. Không phải nội dung khác. Vẫn nội dung ấy, chỉ là có sớm hơn.</p>
<p>Vì vậy, một URL tin đăng như <code>/listings/view?id=...</code> được tách làm hai nhánh. Trình duyệt nhận phần khung (shell) của SPA. Trình thu thập dữ liệu nhận một tài liệu HTML thật sự: tiêu đề, các thông tin chính của tin đăng, mỗi ảnh một thẻ ảnh kèm văn bản thay thế (alt text), một đoạn trích nội dung, và liên kết sang vài tin đăng liên quan để trang không bị mồ côi.</p>
<p>Phần cuối này hoá ra quan trọng hơn tôi nghĩ. Trước khi có mấy liên kết nội bộ đó, khoảng một nghìn trang tin đăng nằm trơ trọi, không có liên kết nào trỏ tới, và Search Console cho thấy lượng thu thập chững lại ở mức bảy đến mười một yêu cầu mỗi ngày. Trình thu thập dữ liệu mà không tìm thấy trang của bạn thì trang có hay đến mấy cũng vô nghĩa.</p>

<h2 class="relative group">Cái bẫy: tên công ty không phải là tên bot
    <div id="cái-bẫy-tên-công-ty-không-phải-là-tên-bot" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%a1i-b%e1%ba%aby-t%c3%aan-c%c3%b4ng-ty-kh%c3%b4ng-ph%e1%ba%a3i-l%c3%a0-t%c3%aan-bot" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nhận diện trình thu thập dữ liệu thực chất là so khớp chuỗi user-agent, và tôi dính đòn đúng ở bước này.</p>
<p>Các nền tảng Hàn Quốc cho trình thu thập dữ liệu chạy dưới những cái tên chẳng liên quan gì tới tên công ty. Trình thu thập của Naver tên là <strong>Yeti</strong>. Của Daum là <strong>Daumoa</strong>. Còn công cụ cào dữ liệu (scraper) lấy bản xem trước liên kết của Kakao là <strong>kakaotalk-scrap</strong>. Không cái nào mang đúng tên công ty.</p>
<p>Trong khi đó, cũng chính mấy công ty này làm ứng dụng có trình duyệt nhúng bên trong, và trình duyệt đó gửi user-agent y như trình duyệt thật, chỉ gắn thêm tên ứng dụng ở cuối. Trình duyệt trong ứng dụng KakaoTalk ghi <code>KAKAOTALK/10.5.1</code>. Công cụ cào dữ liệu của Kakao thì ghi <code>kakaotalk-scrap/1.0</code>.</p>
<p>Cứ so khớp theo <code>kakao</code> là bạn tóm luôn cả hai. Tôi đã làm đúng như vậy, thế là người dùng KakaoTalk, ứng dụng Naver hay ứng dụng Daum nào mở liên kết cũng nhận về trang dành cho trình thu thập: không JavaScript, không điều hướng, không đăng nhập. Một tài liệu hoàn toàn chuẩn chỉnh, nhưng với con người thì vô dụng.</p>
<p>Cách sửa là một quy tắc về thứ tự kiểm tra, và đáng để viết hẳn ra thành quy tắc:</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"># In-app browsers append the app&#39;s name to an ordinary browser 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"># Name the bot, never the vendor: Naver crawls as Yeti, Daum as Daumoa,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Kakao&#39;s link-preview scraper as 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"># An in-app browser is never a crawler, so that check comes first.</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>Trình duyệt trong ứng dụng được kiểm tra trước, và luôn thắng. Còn token nhận diện trình thu thập nào cũng phải là tên của một <em>bot</em>, tuyệt đối không phải tên công ty.</p>

<h2 class="relative group">Rồi tôi đặt Cloudflare đứng trước
    <div id="rồi-tôi-đặt-cloudflare-đứng-trước" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#r%e1%bb%93i-t%c3%b4i-%c4%91%e1%ba%b7t-cloudflare-%c4%91%e1%bb%a9ng-tr%c6%b0%e1%bb%9bc" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Máy chủ gốc (origin) chỉ là một máy shared-cpu duy nhất đặt ở Sydney, chịu tải ổn định được khoảng hai mươi đến ba mươi yêu cầu mỗi giây. Googlebot có chừng 995 URL cần đi qua. URL nào cũng chạy thẳng về Sydney, vì <code>cf-cache-status</code> ở tất cả đều là <code>DYNAMIC</code>: <strong>Cloudflare không lưu đệm HTML trừ khi có quy tắc bảo nó làm vậy.</strong> Cả vùng (zone) chỉ khoảng 16% được lưu đệm, và phần quan trọng thì không nằm trong số đó.</p>
<p>Vậy thì thêm một quy tắc lưu đệm cho các trang dành cho trình thu thập. Đơn giản. Có điều, một URL tin đăng không chỉ có một phần thân (body). Nó có tới ba.</p>
<ul>
<li>Trình thu thập dữ liệu xin HTML thì nhận trang kết xuất ở máy chủ.</li>
<li>Người dùng thật thì nhận phần khung của SPA.</li>
<li>Tác tử AI (AI agent) gửi <code>Accept: text/markdown</code> thì nhận markdown dựng từ cơ sở dữ liệu, vì đem một khung HTML rỗng đi chuyển đổi thì cũng chỉ ra một tài liệu rỗng.</li>
</ul>
<p>Máy chủ gốc có trả <code>Vary: Accept</code>, còn <code>Vary</code> theo user-agent thì đằng nào cũng vô vọng. Và đây là điều tôi không hề biết:</p>
<blockquote><p><strong>Cloudflare bỏ qua <code>Vary</code> với HTML.</strong> Cả ba phần thân dùng chung một khoá lưu đệm (cache key).</p>
</blockquote><p>Phần thân nào tới trước sẽ chiếm luôn URL đó cho mọi lượt truy cập sau ở điểm biên (edge location) ấy, trong suốt một tiếng mà <code>s-maxage</code> của máy chủ gốc cho phép, cộng thêm một ngày <code>stale-while-revalidate</code>.</p>
<p>Tôi được tận mắt thấy chuyện này trên môi trường production, theo cả hai chiều:</p>
<ul>
<li>Một người dùng ứng dụng Naver mở một URL tin đăng trước, bộ nhớ đệm giữ lại trang dành cho trình thu thập, rồi sau đó Safari bình thường trên iPhone nhận đúng trang ấy dưới dạng HIT. Một con người ngồi nhìn một tài liệu không có tí JavaScript nào.</li>
<li>Một người dùng thật mở một URL trước, bộ nhớ đệm giữ lại phần khung của SPA, và Googlebot nhận về cái khung rỗng. Đúng cái lỗi mà cả nỗ lực SEO sinh ra để ngăn chặn, giờ lại còn được phục vụ nhanh hơn, ngay từ máy chủ biên.</li>
</ul>

<h2 class="relative group">Cái quy tắc sửa lỗi bằng cách thu hẹp phạm vi
    <div id="cái-quy-tắc-sửa-lỗi-bằng-cách-thu-hẹp-phạm-vi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%a1i-quy-t%e1%ba%afc-s%e1%bb%ada-l%e1%bb%97i-b%e1%ba%b1ng-c%c3%a1ch-thu-h%e1%ba%b9p-ph%e1%ba%a1m-vi" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bạn không thể bắt Cloudflare tôn trọng <code>Vary</code> với HTML. Cái bạn làm được là đảm bảo trong ba phần thân đó, chỉ đúng một cái có cửa vào bộ nhớ đệm:</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>Chỉ khớp user-agent của trình thu thập, và loại trừ yêu cầu xin markdown. Giờ yêu cầu của người dùng thật không khớp quy tắc nào cả, nên nó là <code>DYNAMIC</code> và đi về máy chủ gốc như trước. Chỉ yêu cầu của trình thu thập mới được đọc hoặc ghi bộ nhớ đệm, nên phần thân duy nhất có thể nằm trong đó là phần thân dành cho trình thu thập.</p>
<p>Edge TTL để là <code>respect_origin</code>, nên thời gian sống của dữ liệu đệm vẫn do chính phần mã kết xuất trang quyết định, chứ không bị đóng cứng trong một bảng điều khiển nào đó.</p>
<p>Có một chi tiết vận hành rất dễ bỏ sót: <strong>thu hẹp quy tắc không xoá những gì đã nằm sẵn trong bộ nhớ đệm.</strong> Bạn phải xoá bộ nhớ đệm (purge), không thì vẫn tiếp tục trả ra những mục nhiễm độc mà mình vừa ngừng tạo ra.</p>

<h2 class="relative group">Hai danh sách, và vì sao sai lệch giữa chúng không đối xứng
    <div id="hai-danh-sách-và-vì-sao-sai-lệch-giữa-chúng-không-đối-xứng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#hai-danh-s%c3%a1ch-v%c3%a0-v%c3%ac-sao-sai-l%e1%bb%87ch-gi%e1%bb%afa-ch%c3%bang-kh%c3%b4ng-%c4%91%e1%bb%91i-x%e1%bb%a9ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Quy tắc này cần một danh sách user-agent của trình thu thập, mà Django thì đã có sẵn một danh sách như vậy. Cùng một kiến thức mà nằm hai bản ở hai hệ thống thường là dấu hiệu có vấn đề. Nhưng ở đây, điều thú vị là chuyện gì xảy ra khi hai bản lệch nhau, vì hậu quả không đối xứng:</p>
<ul>
<li><strong>Danh sách bot trong Cloudflare phải luôn là tập con</strong> của danh sách trong Django. Nếu có token mà Cloudflare coi là bot còn Django thì không, phần khung SPA sẽ bị lưu đệm dưới khoá chỉ dành cho trình thu thập, rồi bị trả cho một trình thu thập thật.</li>
<li><strong>Danh sách loại trừ trình duyệt trong ứng dụng phải luôn là tập cha</strong> của danh sách trong Django. Loại trừ thừa thì cùng lắm bạn mất một lần trúng bộ nhớ đệm (cache hit).</li>
</ul>
<p>Sai theo chiều an toàn, dù ở danh sách nào, thì bạn chỉ mất một ít dữ liệu đệm. Nhưng sai danh sách đầu theo chiều ngược lại là bạn quay về cảnh trả cho Googlebot một trang trống. Nhân tiện, cũng vì sự bất đối xứng đó mà hai danh sách dùng <code>contains</code> chứ không dùng biểu thức chính quy (regex): <code>matches</code> đòi gói Business, còn <code>contains</code> là đủ cho một danh sách được phép xấp xỉ, miễn là lệch về phía đã biết là an toàn.</p>

<h2 class="relative group">Một bất biến làm hỏng bản dựng thay vì làm hỏng trang
    <div id="một-bất-biến-làm-hỏng-bản-dựng-thay-vì-làm-hỏng-trang" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#m%e1%bb%99t-b%e1%ba%a5t-bi%e1%ba%bfn-l%c3%a0m-h%e1%bb%8fng-b%e1%ba%a3n-d%e1%bb%b1ng-thay-v%c3%ac-l%c3%a0m-h%e1%bb%8fng-trang" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Các trang dành cho trình thu thập nhúng URL ảnh dạng ký sẵn (presigned), có hiệu lực bảy ngày. Còn bản thân các trang thì được lưu đệm ở máy chủ biên một tiếng, cộng thêm một ngày stale-while-revalidate.</p>
<p>Hai con số này dính dáng tới nhau. Nếu có ngày thời gian lưu đệm vượt quá thời hạn của ảnh, máy chủ biên sẽ bắt đầu trả ra những trang mà ảnh nào cũng dính lỗi 403. Sẽ chẳng có cảnh báo nào kêu cả, vì HTML vẫn hoàn toàn bình thường.</p>
<p>Nên đoạn mã định nghĩa mấy con số đó sẽ từ chối nạp (import) nếu quan hệ này bị phá vỡ:</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"># presigned image URLs</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"># Fail at import time if the edge cache could outlive the image URLs.</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>Một ràng buộc giữa hai hằng số nằm ở hai tệp khác nhau thì gần như vô hình khi rà soát mã, nhưng lộ ra ngay khi tiến trình khởi động. Vậy thì cứ đặt phép kiểm tra ở chỗ tiến trình khởi động.</p>

<h2 class="relative group">Hai thứ Cloudflare làm mà tôi phải gỡ lại
    <div id="hai-thứ-cloudflare-làm-mà-tôi-phải-gỡ-lại" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#hai-th%e1%bb%a9-cloudflare-l%c3%a0m-m%c3%a0-t%c3%b4i-ph%e1%ba%a3i-g%e1%bb%a1-l%e1%ba%a1i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><strong><code>Vary: Origin</code> khiến gói JavaScript không vào được bộ nhớ đệm.</strong> <code>django-cors-headers</code> đóng dấu <code>Vary: Origin</code> lên mọi phản hồi mà nó đi qua, còn Cloudflare thì không lưu đệm bất cứ thứ gì vary theo nhiều hơn <code>Accept-Encoding</code>. Thế là gói JavaScript (bundle) 1.6 MB — thứ nặng nhất trên đường tải quan trọng (critical path) — lặng lẽ bị loại hẳn khỏi máy chủ biên. Cách sửa là một middleware đặt đầu tiên trong danh sách, để nó chạy sau cùng lúc phản hồi đi ra, và viết lại <code>Vary</code> cho các đường dẫn tài nguyên tĩnh.</p>
<p><strong>Nén ở máy chủ biên còn tệ hơn nén ở máy chủ gốc.</strong> Để Cloudflare tự nén Brotli tức thời thì ra 2,041,742 byte, trong khi gzip của chính máy chủ gốc chỉ ra 1,658,115. To hơn hai mươi ba phần trăm, chỉ để đổi lấy cái sướng là không phải nghĩ ngợi gì. Nén tức thời tối ưu cho CPU chứ không tối ưu cho tỉ lệ nén; tài nguyên nén sẵn từ máy chủ gốc vẫn thắng.</p>

<h2 class="relative group">Báo cho công cụ tìm kiếm mà không cần chờ được thu thập
    <div id="báo-cho-công-cụ-tìm-kiếm-mà-không-cần-chờ-được-thu-thập" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%c3%a1o-cho-c%c3%b4ng-c%e1%bb%a5-t%c3%acm-ki%e1%ba%bfm-m%c3%a0-kh%c3%b4ng-c%e1%ba%a7n-ch%e1%bb%9d-%c4%91%c6%b0%e1%bb%a3c-thu-th%e1%ba%adp" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Có hai giao thức, nhưng gộp cả hai lại thì phạm vi phủ vẫn ít đến đáng thất vọng.</p>
<p><strong>IndexNow</strong> chỉ cần một cú ping là phủ được Bing, Yandex và Seznam. Google và Naver thì không tham gia. Mỗi khi một tin đăng được lưu, tôi gửi ping ở chế độ nền với thời gian chờ ngắn, và bỏ qua tin đăng đã bán hoặc đã đóng. Gặp lỗi mạng thì nó ghi một dòng cảnh báo vào nhật ký và không bao giờ làm hỏng lần lưu đã kích hoạt nó. Với một kênh phụ kiểu làm được đến đâu hay đến đó (best-effort), đó là cách hành xử duy nhất chấp nhận được.</p>
<p><strong>Indexing API của Google</strong> chỉ nhận đúng hai loại schema, và một trong hai là <code>JobPosting</code>, thứ mà tôi lại có. Tin tuyển dụng còn mở thì gửi <code>URL_UPDATED</code>, tin đã đóng thì gửi <code>URL_DELETED</code>. Lệnh xoá đó chỉ trung thực vì tin đăng đã đóng cũng trả <code>noindex</code>, và mã nguồn có ghi chú rõ chuyện này, để lỡ ai gỡ noindex đi thì cũng biết mình còn làm hỏng thêm thứ gì.</p>
<p>Có hai chỗ làm tôi mất thời gian. Thứ nhất, hạn mức (quota) là 200 URL mỗi ngày, mà một lần nhập hàng loạt tin đăng sẽ gọi nó hàng trăm lần — nên tin đăng nhập vào phải được bỏ qua, không thì một lần nhập đốt sạch hạn mức của cả ngày. Thứ hai, endpoint là <code>urlNotifications:publish</code>, dùng dấu hai chấm. Dùng dấu gạch chéo là Google trả về một trang HTML 404 trơn, trông y hệt như API đang bị tắt.</p>

<h2 class="relative group">Dữ liệu có cấu trúc dạy tôi những gì
    <div id="dữ-liệu-có-cấu-trúc-dạy-tôi-những-gì" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#d%e1%bb%af-li%e1%bb%87u-c%c3%b3-c%e1%ba%a5u-tr%c3%bac-d%e1%ba%a1y-t%c3%b4i-nh%e1%bb%afng-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Ba quy tắc về dữ liệu có cấu trúc (structured data), cái nào cũng học được sau khi bị Search Console phàn nàn:</p>
<ol>
<li><strong>Không phân tích được thì bỏ ra.</strong> Mức lương gửi đến dưới dạng văn bản tự do. Trước đây tôi nhét nguyên chuỗi đó vào <code>baseSalary.value</code>, vốn không hợp lệ, thế là tin tuyển dụng nào cũng bị gắn cờ thiếu đơn vị. Giờ lương nào không phân tích (parse) được thì đơn giản là không có.</li>
<li><strong>Đừng bao giờ bịa ra một trường chỉ để chiều schema.</strong> Tin tuyển dụng hiếm khi ghi địa chỉ cụ thể đến tên đường. Rất dễ bị cám dỗ bịa ra một địa chỉ nghe hợp lý cho <code>PostalAddress</code>. Nhưng một địa chỉ bịa trên tin tuyển dụng còn tệ hơn một địa chỉ thiếu, nên tôi chỉ đưa vào khu vực (locality) và quốc gia.</li>
<li><strong>Ngày hết hạn không phải là tuỳ chọn.</strong> Google bỏ qua <code>JobPosting</code> nào không có <code>validThrough</code>, nên tôi tự suy ra một giá trị, kèm mức sàn để một tin đăng cũ không tự nhận là đã hết hạn từ hôm qua.</li>
</ol>
<p>Còn quy tắc thứ tư, về sơ đồ trang web (sitemap): <strong>đừng khai <code>changefreq: daily</code> cho mọi thứ.</strong> Bảo Googlebot thu thập lại một nghìn tin đăng mỗi ngày thì trong mắt nó, máy chủ của bạn đang quá tải, và nó sẽ đáp lại bằng cách giảm tốc độ thu thập cho cả trang web. Hãy suy ra giá trị này từ lần gần nhất nội dung thực sự thay đổi.</p>
<p>Sơ đồ trang web cũng có một mức sàn về chất lượng: tiêu đề và nội dung cộng lại phải đủ bốn mươi ký tự. Search Console từng báo khoảng một nghìn tin đăng ở trạng thái “Crawled – currently not indexed”, và có một trang đồ cũ mà toàn bộ nội dung riêng của nó chỉ gồm một tiêu đề đúng một chữ và một số điện thoại. Bốn mươi ký tự là tôi cố tình đặt thấp, vì 471 trên 484 tin đăng viết bằng tiếng Hàn, mà tiếng Hàn chứa được nhiều ý hơn trên mỗi ký tự; một ngưỡng canh theo tiếng Anh sẽ loại mất một nửa sàn mua bán. Tin đăng nào nội dung mỏng thì vẫn được thu thập qua các trang danh mục, và sẽ tự động quay lại sơ đồ trang web khi có người sửa chúng.</p>

<h2 class="relative group">Tôi rút ra được gì
    <div id="tôi-rút-ra-được-gì" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#t%c3%b4i-r%c3%bat-ra-%c4%91%c6%b0%e1%bb%a3c-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<ul>
<li><strong>Trả nhiều phần thân khác nhau ở cùng một URL trước hết là bài toán khoá lưu đệm, rồi mới đến chuyện khác.</strong> Hãy tìm hiểu xem CDN của bạn lấy khoá theo những gì, và cứ giả định nó bỏ qua <code>Vary</code> cho đến khi bạn chứng minh được điều ngược lại với đúng kiểu nội dung (content type) đó.</li>
<li><strong>Khi hai hệ thống phải thống nhất về một danh sách, hãy xác định chiều sai nào là an toàn</strong>, rồi ghi điều đó ngay cạnh cả hai bản. Nói thẳng “hai bản này có thể lệch nhau, và đây là cách duy nhất chúng được phép lệch” vẫn hơn là giả vờ chúng không bao giờ lệch.</li>
<li><strong>So khớp bot theo tên bot, đừng bao giờ theo tên công ty.</strong> Nhất là ở ngoài thế giới nói tiếng Anh, nơi chính công ty làm bot cũng làm luôn cái trình duyệt mà người dùng của bạn đang cầm trên tay.</li>
<li><strong>Bất biến giữa những hằng số nằm xa nhau thì hãy đặt ở chỗ nào hỏng thật to.</strong> Kiểm tra lúc nạp mô-đun chẳng tốn gì, và không ai lách qua được.</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>Django trên Fly.io: kiểm thử tải và tự động co giãn với tài nguyên tối thiểu</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-07-22-my-current-stack/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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>Tôi chạy hai ứng dụng Django trên máy shared-cpu-1x của Fly.io: tối ưu một máy cho nhanh, kiểm thử tải để tìm điểm độ trễ tăng vọt, rồi đặt ngưỡng tự động co giãn ngay trước đó.</description>
      <content:encoded><![CDATA[<p>Để chạy Django trên Fly.io với chi phí thấp, tôi tối ưu một máy shared-cpu-1x nhỏ cho thật nhanh, kiểm thử tải (load testing) đến khi độ trễ tăng vọt, rồi cấu hình bộ tự động co giãn (autoscaling) của Fly khởi động máy thứ hai ngay trước điểm đó. Phần kiểm thử tải Django tôi dùng Locust với tải tăng theo từng bậc (còn ứng dụng kia dùng một script Python nhỏ quét qua các mức đồng thời), và theo dõi độ trễ p95 cùng tỉ lệ lỗi. Khi lưu lượng giảm, Fly tự dừng các máy rảnh, nên chi phí nền chỉ là một VM nhỏ cho mỗi ứng dụng.</p>
<p>Hiện tại, với vai trò là một lập trình viên độc lập (solo developer), tôi đang vận hành đồng thời hai sản phẩm:</p>
<ul>
<li>một <strong>ứng dụng ước tính và báo giá tại hiện trường (field estimation)</strong> dành cho các kỹ thuật viên/thợ dịch vụ, giúp họ tính chi phí và báo giá công việc ngay tại chỗ;</li>
<li>và một <strong>ứng dụng chợ mua bán kiêm cộng đồng (marketplace)</strong>, với các tính năng như đăng tin và nhắn tin thời gian thực.</li>
</ul>
<p>Hai sản phẩm khác nhau, nhóm người dùng cũng khác nhau, nhưng bộ công nghệ (tech stack) gần như giống hệt nhau — và cả hai đều đang chạy hoàn toàn trên các <strong>máy shared-cpu-1x</strong>, gói rẻ nhất mà Fly.io cung cấp.</p>
<p>Đây là một giới hạn được tôi chủ động lựa chọn.</p>
<p>Thay vì chọn cấu hình máy chủ theo mức tải đỉnh (peak load), tôi cố gắng khai thác tối đa hiệu năng của một máy nhỏ, đo chính xác ngưỡng mà hệ thống bắt đầu gặp điểm nghẽn (bottleneck) hoặc sụp đổ khi tải cao, rồi để bộ tự động co giãn (autoscaler) của Fly.io tự mở rộng hoặc thu hẹp theo chiều ngang (scale out / scale in), thêm hoặc bớt máy dựa trên chính ngưỡng đã đo đó.</p>
<p>Bài viết này sẽ đi vào các kỹ thuật tối ưu hóa giúp mô hình đó khả thi, các số liệu kiểm thử tải (load testing) dùng để xác định ngưỡng mở rộng (scaling threshold), và cách việc tăng, giảm số máy thực tế diễn ra trên Fly.io.</p>

<h2 class="relative group">Triết lý: đo một máy, rồi nhân lên
    <div id="triết-lý-đo-một-máy-rồi-nhân-lên" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#tri%e1%ba%bft-l%c3%bd-%c4%91o-m%e1%bb%99t-m%c3%a1y-r%e1%bb%93i-nh%c3%a2n-l%c3%aan" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Chiến lược mở rộng quy mô của cả hai ứng dụng đều gồm ba bước giống nhau:</p>
<ol>
<li>Tối ưu một máy nhỏ cho nhanh nhất trong khả năng hợp lý.</li>
<li>Kiểm thử tải cho đến khi máy gãy, rồi ghi lại số yêu cầu đồng thời (concurrency) mà tại đó độ trễ (latency) bắt đầu vọt lên.</li>
<li>Cấu hình bộ tự động co giãn của Fly để bật máy thứ hai ngay trước điểm đó, và dừng các máy đang rảnh khi lưu lượng truy cập (traffic) giảm.</li>
</ol>
<p>Khi đó, năng lực xử lý tăng gần như tuyến tính theo số máy, còn chi phí nền chỉ là một máy ảo (VM) nhỏ cho mỗi ứng dụng. Mọi thứ trong phần còn lại của bài đều phục vụ một trong ba bước này.</p>

<h2 class="relative group">Tối ưu cho một máy nhỏ chạy nhanh
    <div id="tối-ưu-cho-một-máy-nhỏ-chạy-nhanh" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#t%e1%bb%91i-%c6%b0u-cho-m%e1%bb%99t-m%c3%a1y-nh%e1%bb%8f-ch%e1%ba%a1y-nhanh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Granian: WSGI cho API, ASGI với uvloop cho WebSocket
    <div id="granian-wsgi-cho-api-asgi-với-uvloop-cho-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="#granian-wsgi-cho-api-asgi-v%e1%bb%9bi-uvloop-cho-websocket" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Phía máy chủ (backend) của cả hai sản phẩm đều là Django 6 + Django REST Framework. API chạy trên <strong>Granian</strong>, một máy chủ viết bằng Rust, nhưng dùng <strong>WSGI với nhóm luồng chặn (blocking thread pool)</strong> chứ không phải ASGI:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">granian --interface wsgi myapp.wsgi:application <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers <span class="m">1</span> --blocking-threads <span class="m">16</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers-max-rss <span class="m">1400</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers-kill-timeout <span class="m">15</span> --respawn-failed-workers</span></span></code></pre></div></div>
<p>Lúc đầu tôi dùng <code>--interface asgi --loop uvloop</code>, và đó là một quyết định sai. Mọi view HTTP trong hai ứng dụng này đều là đồng bộ (sync), mà trình xử lý ASGI của Django lại chạy view đồng bộ ở chế độ <code>thread_sensitive</code>, tức mỗi tiến trình worker chỉ xử lý được một view tại một thời điểm. Kết quả là với ASGI, mỗi worker chỉ xử lý được khoảng một yêu cầu đồng thời, <em>lại còn</em> phải nạp nguyên bộ asyncio/uvloop/Channels vào RAM mà chẳng được lợi gì. Còn WSGI với <code>--blocking-threads</code> thì chạy chính những view đồng bộ đó trong một nhóm luồng thực thụ: một worker × 16 luồng = 16 yêu cầu nặng về I/O chạy đồng thời (đọc cơ sở dữ liệu, tạo URL ký trước (presigned URL) cho S3), mà bộ nhớ thường trú lại còn thấp hơn bản bất đồng bộ.</p>
<p>Tại sao chỉ dùng một worker? Vì máy ảo là <code>shared-cpu-1x</code>, tức chỉ có một nhân. Thêm worker thứ hai cũng không chạy Python song song được trên một nhân (GIL cộng với chỉ một CPU), nhưng lại tốn thêm nguyên một tiến trình Django thường trú trong bộ nhớ; chính Granian cũng cảnh báo đúng chuyện này. Với các tác vụ nặng về I/O, mười sáu luồng trên một worker là mức trần thực tế của một máy 1 vCPU. Tham số <code>--workers-max-rss 1400</code> sẽ khởi động lại worker nếu nó phình quá giới hạn bộ nhớ, nên nếu một tác vụ tạo PDF hay xử lý ảnh chạy mất kiểm soát thì worker sẽ được thay mới, thay vì kéo cả máy đến chỗ cạn bộ nhớ (OOM).</p>
<p>WebSocket được tách ra chạy trong một tiến trình Granian riêng: một ứng dụng <strong>ASGI</strong> dùng uvloop và <code>--ws</code>, với một worker chuyên giữ các kết nối dài hạn. Phần API đồng bộ hoàn toàn không cần tới ASGI: khi một view cần đẩy dữ liệu xuống socket, nó gọi <code>async_to_sync(channel_layer.group_send)</code> qua Redis, và thông điệp vẫn đến được tiến trình WebSocket. Hai máy chủ, mỗi cái chạy đúng giao diện (interface) mà nó làm tốt nhất. Docker trên máy cục bộ cũng chạy cùng nhóm luồng WSGI đó, nên khi kiểm thử, laptop của tôi mô phỏng khá sát một máy trong môi trường production.</p>

<h3 class="relative group">Dùng Rust ở các đường xử lý nóng
    <div id="dùng-rust-ở-các-đường-xử-lý-nóng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#d%c3%b9ng-rust-%e1%bb%9f-c%c3%a1c-%c4%91%c6%b0%e1%bb%9dng-x%e1%bb%ad-l%c3%bd-n%c3%b3ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Ở đường xử lý nóng (hot path) nào của Python mà có sẵn một thư viện viết bằng Rust thay thế được ngay, tôi đều dùng:</p>
<ul>
<li><strong>orjson</strong> qua <code>drf-orjson-renderer</code>: mọi phản hồi của API đều được tuần tự hóa bằng Rust thay vì bộ mã hóa JSON của thư viện chuẩn.</li>
<li><strong>blurhash-rs</strong>: tạo ảnh giữ chỗ (placeholder) ngay lúc ảnh được tải lên.</li>
<li><strong>uuid-utils</strong>: sinh UUID bằng Rust.</li>
<li><strong>psycopg 3 (binary)</strong>: trình điều khiển (driver) Postgres được tăng tốc bằng C.</li>
</ul>
<p>Không thư viện nào trong số này làm thay đổi kiến trúc; chúng chỉ nâng mức trần của một vCPU lên, để bạn chưa phải thêm máy thứ hai sớm.</p>

<h3 class="relative group">Loại bỏ những độ trễ ẩn
    <div id="loại-bỏ-những-độ-trễ-ẩn" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#lo%e1%ba%a1i-b%e1%bb%8f-nh%e1%bb%afng-%c4%91%e1%bb%99-tr%e1%bb%85-%e1%ba%a9n" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Cải thiện lớn nhất trong mã nguồn của cả hai ứng dụng không đến từ thuật toán nào cả, mà từ việc tái sử dụng kết nối. Trong nhóm luồng của Granian, mỗi luồng mới lại tạo một đối tượng client S3 của boto3 mới toanh, và endpoint nào trả về ảnh hay PDF cũng phải gánh chi phí đó. Tôi viết một lớp lưu trữ (storage) nhỏ tùy chỉnh để lưu đệm (cache) client theo từng tiến trình:</p>
<blockquote><p>Thời gian tạo URL có chữ ký trên các luồng mới giảm từ <strong>~150ms xuống ~1ms</strong>.</p>
</blockquote><p>Với cơ sở dữ liệu cũng vậy: Postgres trên Fly nằm sau PgBouncer, nên ứng dụng chạy với <code>conn_max_age=0</code> và để bộ gộp kết nối (pooler) lo phần tái sử dụng kết nối, kèm <code>connect_timeout</code> 5 giây để khi gặp kết nối SSL đã chết thì báo lỗi ngay, chứ không làm treo cả worker.</p>

<h3 class="relative group">Đặt bộ nhớ đệm đúng chỗ đang thực sự nghẽn
    <div id="đặt-bộ-nhớ-đệm-đúng-chỗ-đang-thực-sự-nghẽn" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%e1%ba%b7t-b%e1%bb%99-nh%e1%bb%9b-%c4%91%e1%bb%87m-%c4%91%c3%bang-ch%e1%bb%97-%c4%91ang-th%e1%bb%b1c-s%e1%bb%b1-ngh%e1%ba%bdn" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Đợt kiểm thử tải phía chợ mua bán cho tôi một bài học đáng nhớ: <strong>mức trần nằm ở số kết nối Postgres, không phải CPU</strong>. Phần lớn lưu lượng duyệt tin đến từ người dùng ẩn danh, và nội dung họ xem giống hệt nhau. Khi tải lên cao, những truy vấn danh sách giống hệt nhau đó làm đầy nhóm kết nối, trong khi CPU vẫn ngồi chơi.</p>
<p>Cách sửa là thêm một lớp bộ nhớ đệm Redis cho các trang danh sách mà người dùng ẩn danh xem, và vô hiệu hóa bộ nhớ đệm bằng bộ đếm phiên bản theo từng không gian tên (namespace): mỗi thao tác ghi chỉ cần tăng một bộ đếm (O(1), không phải theo dõi từng khóa), thế là mọi trang đã lưu đệm của tài nguyên đó lập tức hết hiệu lực. Một thời gian sống (TTL) ngắn làm lưới an toàn cho những trường hợp bị sót. Trả trang duyệt tin thẳng từ Redis gỡ được đúng điểm nghẽn thật, và để cái máy nhỏ dành kết nối cơ sở dữ liệu cho người dùng đã đăng nhập.</p>

<h3 class="relative group">Chạy tác vụ nền mà không cần trình trung gian
    <div id="chạy-tác-vụ-nền-mà-không-cần-trình-trung-gian" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ch%e1%ba%a1y-t%c3%a1c-v%e1%bb%a5-n%e1%bb%81n-m%c3%a0-kh%c3%b4ng-c%e1%ba%a7n-tr%c3%acnh-trung-gian" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Cả hai ứng dụng đều dùng <strong>django-tasks-db</strong>, framework tác vụ của Django lưu hàng đợi trong cơ sở dữ liệu, thay cho Celery. Một tiến trình <code>db_worker</code> riêng liên tục thăm dò cơ sở dữ liệu để lấy việc: xử lý ảnh, tạo PDF, dịch máy, gửi nhắc nhở. Không cần trình trung gian thông điệp (broker), không phải dựng và giám sát thêm dịch vụ nào, và tiến trình API không bao giờ bị những việc nặng chặn lại. Worker còn kết nối thẳng tới Postgres, bỏ qua PgBouncer, vì các tác vụ chạy lâu không hợp với chế độ gộp kết nối theo giao dịch (transaction pooling).</p>

<h3 class="relative group">Bộ nhớ: jemalloc và bộ nhớ hoán đổi để dự phòng
    <div id="bộ-nhớ-jemalloc-và-bộ-nhớ-hoán-đổi-để-dự-phòng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%e1%bb%99-nh%e1%bb%9b-jemalloc-v%c3%a0-b%e1%bb%99-nh%e1%bb%9b-ho%c3%a1n-%c4%91%e1%bb%95i-%c4%91%e1%bb%83-d%e1%bb%b1-ph%c3%b2ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Xử lý ảnh và PDF làm bộ nhớ tăng vọt theo những cách mà khi tính toán cấu hình cho trạng thái ổn định bạn không lường trước được. Tôi dùng hai cách giảm thiểu rẻ tiền: nạp sẵn <strong>jemalloc</strong> trong Docker image để giảm phân mảnh bộ nhớ, và bật <strong>bộ nhớ hoán đổi (swap) trên mọi máy</strong> (2GB trên máy API, 1GB trên máy WebSocket), để khi bộ nhớ tăng vọt thì dữ liệu được đẩy xuống đĩa chứ tiến trình không bị hệ thống buộc dừng vì cạn bộ nhớ. Bộ nhớ hoán đổi không phải tính năng giúp tăng hiệu năng; nó là một khoản bảo hiểm, cho phép tôi giữ mức cấp phát bộ nhớ ở mức nhỏ.</p>

<h2 class="relative group">Kiểm thử tải Django: tìm điểm gãy
    <div id="kiểm-thử-tải-django-tìm-điểm-gãy" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ki%e1%bb%83m-th%e1%bb%ad-t%e1%ba%a3i-django-t%c3%acm-%c4%91i%e1%bb%83m-g%c3%a3y" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Một ngưỡng chỉ đáng tin khi nó đã được đo thực tế. Mỗi dự án có bộ công cụ kiểm thử riêng:</p>
<ul>
<li>Ứng dụng báo giá tại hiện trường có một script kiểm thử sức chịu tải nhỏ viết bằng Python, chạy lần lượt qua các mức yêu cầu đồng thời và báo cáo số yêu cầu mỗi giây (RPS), p50/p95/p99 cùng tỉ lệ lỗi. Script chạy trên vài trăm bản ghi mẫu được sinh sẵn, để các endpoint danh sách được truy vấn trên khối lượng dữ liệu sát thực tế.</li>
<li>Chợ mua bán dùng <strong>Locust</strong> với kiểu tải bậc thang, tăng từ 50 → 500 người dùng theo từng bậc 30 giây để tìm điểm bão hòa, với trọng số các endpoint khớp với lưu lượng thực tế (phần lớn là duyệt tin). Ngoài ra còn một bộ kiểm thử nhỏ hơn, tăng từ 5 → 50 người dùng ảo, chạy thẳng vào môi trường production với ngưỡng p95 &lt; 2s.</li>
</ul>
<p>Vài con số tiêu biểu từ lượt đo của ứng dụng báo giá tại hiện trường (một worker Granian × 16 luồng chặn, chạy Docker trên máy phát triển của tôi):</p>
<table>
	<thead>
			<tr>
					<th>Endpoint</th>
					<th>Số yêu cầu đồng thời</th>
					<th>RPS</th>
					<th>p95</th>
					<th>Lỗi</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>health</td>
					<td>25</td>
					<td>602</td>
					<td>52ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>danh sách nặng</td>
					<td>25</td>
					<td>309</td>
					<td>260ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>danh sách nhẹ</td>
					<td>25</td>
					<td>357</td>
					<td>61ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>danh sách nặng</td>
					<td>50</td>
					<td>309</td>
					<td>347ms</td>
					<td>0%</td>
			</tr>
	</tbody>
</table>
<p>Còn con số quyết định mọi thứ khác đến từ đợt kiểm thử tải chợ mua bán trên <strong>một máy shared-cpu-1x duy nhất</strong>. Với bản ASGI cũ, đây là một bức tường cứng: các view đồng bộ bị xếp hàng tuần tự, chỉ còn khoảng <strong>ba suất xử lý (slot) dùng được</strong>, nên p95 giữ dưới 300ms cho đến khoảng 5 yêu cầu đồng thời, rồi sụp hẳn khi vượt <strong>~8–10</strong>, vì mọi yêu cầu đều phải xếp hàng sau cái khóa thread-sensitive. Chính việc chuyển API sang nhóm luồng WSGI đã đẩy điểm gãy đó ra xa: <strong>16 suất đồng thời thực sự</strong> trên mỗi máy thay vì ~3, và mức trần mới này là nền tảng cho toàn bộ cấu hình tự động co giãn.</p>

<h2 class="relative group">Cơ chế co giãn trên Fly.io
    <div id="cơ-chế-co-giãn-trên-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="#c%c6%a1-ch%e1%ba%bf-co-gi%c3%a3n-tr%c3%aan-flyio" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Mô hình của Fly khá đơn giản: ứng dụng của bạn là một nhóm máy giống hệt nhau đứng sau một proxy, và proxy đếm tải trên từng máy. Bạn chỉ cần khai báo hai con số:</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"># past this, start another machine</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"># past this, shed load instead of queueing</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>Khi tải tăng:</strong> khi số yêu cầu đang xử lý trên một máy vượt giới hạn mềm (soft limit), proxy sẽ bật một máy đang dừng và chuyển lưu lượng mới sang đó. Nhóm luồng WSGI cho mỗi máy 16 suất đồng thời thực sự, nên tôi đặt giới hạn mềm ở 12, tức khoảng 75% nhóm luồng, để máy số 2 khởi động khi các luồng vẫn còn dư sức, thay vì đợi đến lúc người dùng phải chờ tới vài giây. Giới hạn cứng (hard limit) đặt ở 24, gấp 1.5 lần nhóm luồng, đóng vai trò cầu dao: những đợt tăng vọt ngắn thì xếp hàng chờ luồng, còn vượt quá mức đó thì proxy sẽ từ chối bớt tải, thay vì để một máy bị quá tải tới mức sập. (Bản ASGI cũ chạy 8/20, tính theo ~3 suất thực của nó; chính việc nới rộng nhóm luồng mới cho phép nâng cả hai con số lên.)</p>
<p><strong>Khi tải giảm:</strong> khi lưu lượng giảm, Fly tự động dừng các máy đang rảnh, xuống tới <code>min_machines_running = 1</code>. Máy đã dừng không tốn đồng tiền tính toán nào, chỉ tốn phần lưu trữ rootfs, nên chi phí nền của mỗi ứng dụng đúng nghĩa đen là một máy ảo nhỏ, mà lúc nào cũng có sẵn năng lực dự phòng cho những đợt tăng tải.</p>
<p>Ban đầu, ở đoạn này tôi có viết rằng một máy đã dừng &ldquo;khởi động nguội (cold start) mất chưa tới một giây&rdquo;. Câu đó sai, và cái sai ấy khá đắt. Đến khi chịu đo thực tế thay vì đoán, tôi mới thấy một lần khởi động nguội mất <strong>25 đến 33 giây</strong>, và yêu cầu nào rơi vào một máy còn đang khởi động thì phải chờ trọn khoảng thời gian đó. Lúc đầu tôi đổ cho chính những lần khởi động nguội này tỉ lệ lỗi 22% trên bảng điều khiển của CDN, trong khi mọi lần kiểm tra tình trạng (health check) vẫn báo xanh. Hóa ra con số đó là các yêu cầu thăm dò Early Hints của chính CDN bị lỗi ở máy chủ gốc (origin); không có client thật nào nhận lỗi 504 cả. Dù vậy, chuyện khởi động nguội thì vẫn là thật.</p>
<p>Cách sửa không nằm ở mấy con số co giãn phía trên; chúng vẫn ổn. Vấn đề nằm ở những gì máy làm trong lúc khởi động, và ở việc yêu cầu Fly tạm ngưng (suspend) máy thay vì dừng hẳn (stop), để mỗi lần đánh thức máy chỉ còn là một lần khôi phục bộ nhớ mất khoảng 3 giây. Tôi đã viết riêng một bài về chuyện này: <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/" >Thu về 0, và mỗi lần khởi động lại là một lần sập 30 giây</a>, nói về cách tôi chẩn đoán và những gì cần thay đổi. Nếu bạn đang chạy bất cứ thứ gì trên Fly với <code>auto_stop_machines</code>, hãy đọc bài đó trước khi tin vào đoạn này.</p>
<p>Có một điểm tinh tế: kiểu đếm tải là <code>requests</code>, không phải <code>connections</code>. Máy khách hiện đại giữ kết nối duy trì (keep-alive) luôn mở, nên nếu đếm theo kết nối thì con số gần như đứng yên dù tải thực tế tăng, và bộ tự động co giãn sẽ không bao giờ được kích hoạt. Đếm số yêu cầu đang xử lý mới phản ánh đúng khối lượng công việc thực.</p>

<h3 class="relative group">WebSocket co giãn theo một trục khác
    <div id="websocket-co-giãn-theo-một-trục-khác" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-co-gi%c3%a3n-theo-m%e1%bb%99t-tr%e1%bb%a5c-kh%c3%a1c" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Chợ mua bán tách máy chủ WebSocket ra thành một <strong>ứng dụng Fly thứ hai</strong>, dùng chung Docker image. Ứng dụng API chạy một worker × 16 luồng chặn dưới WSGI và đếm theo <em>yêu cầu</em> (giới hạn mềm 12). Ứng dụng WebSocket thì chạy 1 worker dưới ASGI với uvloop, trên một máy <strong>512MB</strong>, và đếm theo <em>kết nối</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>Giữ một socket sống lâu gần như không tốn gì, nhưng nó chiếm một suất hàng giờ liền, trong khi một yêu cầu HTTP chỉ chiếm suất vài mili giây, nên không thể dùng chung một tín hiệu co giãn cho cả hai. Tách riêng ra thì mỗi nhóm máy co giãn theo đúng thước đo phản ánh tải thật của nó, và năng lực WebSocket lúc rảnh chỉ tốn 512MB chứ không phải 2GB. Về phía Channels, lớp Redis chạy với <code>capacity: 1500</code>, thông điệp hết hạn sau 60 giây, cộng thêm cơ chế duy trì kết nối cho socket mỗi 30 giây, vì proxy Redis của Fly sẽ tự đóng các socket BRPOP sống lâu mà Channels cần tới khi chúng không hoạt động.</p>

<h2 class="relative group">Đẩy việc tính toán ra tầng biên và sang phía máy khách
    <div id="đẩy-việc-tính-toán-ra-tầng-biên-và-sang-phía-máy-khách" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%e1%ba%a9y-vi%e1%bb%87c-t%c3%adnh-to%c3%a1n-ra-t%e1%ba%a7ng-bi%c3%aan-v%c3%a0-sang-ph%c3%ada-m%c3%a1y-kh%c3%a1ch" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Việc rẻ nhất cho máy chủ là việc máy chủ không bao giờ phải làm:</p>
<ul>
<li><strong>Tệp tĩnh và tệp đa phương tiện (media)</strong> nằm trên dịch vụ lưu trữ đối tượng tương thích S3 của Fly, được phân phối qua CDN, với URL có chữ ký cho tệp đa phương tiện riêng tư. Không một byte ảnh nào phải đi qua các máy Django.</li>
<li><strong>Suy luận AI chạy trên điện thoại của người dùng.</strong> Cả hai ứng dụng đều nhúng một LLM nhỏ chạy cục bộ ngay trên thiết bị (mô hình 4B đã lượng tử hóa, khoảng 2.7GB). Phía máy chủ không tốn đồng nào cho suy luận, và tính năng này chạy được cả khi ngoại tuyến.</li>
<li><strong>Ứng dụng di động ưu tiên ngoại tuyến (offline-first)</strong> (SQLite trên thiết bị và một hàng đợi đồng bộ có cơ chế thử lại) giúp ứng dụng gom các thao tác ghi lại thành từng đợt và chịu được những vùng mất sóng, thay vì dội vào API hàng loạt yêu cầu lắt nhắt theo từng lần gõ phím.</li>
</ul>

<h2 class="relative group">Phần còn lại của bộ công nghệ, điểm nhanh
    <div id="phần-còn-lại-của-bộ-công-nghệ-điểm-nhanh" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ph%e1%ba%a7n-c%c3%b2n-l%e1%ba%a1i-c%e1%bb%a7a-b%e1%bb%99-c%c3%b4ng-ngh%e1%bb%87-%c4%91i%e1%bb%83m-nhanh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Xác thực dùng một dịch vụ định danh (identity provider) có sẵn cộng với khóa truy cập (passkey) WebAuthn, và kết nối WebSocket được xác thực trước khi nâng cấp giao thức. Postgres cho môi trường production, SQLite khi phát triển, cùng vài gói Django có sẵn cho trang quản trị và lịch sử kiểm toán (audit trail). Phía di động là React Native và TypeScript chạy trên Expo, với Expo Router và Tailwind, cập nhật OTA qua một máy chủ Expo Updates tự vận hành, còn gói thuê bao trong ứng dụng thì giao cho một dịch vụ quản lý thuê bao. Khâu kiểm soát chất lượng: pytest và mypy ở phía máy chủ, kiểm thử đơn vị và kiểm thử đầu-cuối (end-to-end) ở phía máy khách.</p>

<h2 class="relative group">Những gì bạn nên &ldquo;học lỏm&rdquo;
    <div id="những-gì-bạn-nên-học-lỏm" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-g%c3%ac-b%e1%ba%a1n-n%c3%aan-h%e1%bb%8dc-l%e1%bb%8fm" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Điều đáng mang về không nằm ở một thư viện cụ thể nào, mà ở cả vòng lặp. Tối ưu cho một máy rẻ tiền chạy thật nhanh (dùng Rust ở đường xử lý nóng, tái sử dụng kết nối, đặt bộ nhớ đệm chắn trước điểm nghẽn thật, đẩy được gì sang CDN và máy khách thì đẩy). Kiểm thử tải cho đến khi tìm ra chính xác mức yêu cầu đồng thời mà máy gãy. Đặt giới hạn mềm của bộ tự động co giãn ngay tại điểm gãy đó, để các máy rảnh tự dừng, và giữ tối thiểu một máy luôn chạy. Hai ứng dụng đang chạy production, có tính năng thời gian thực, có trợ lý AI, mà hóa đơn hằng tháng lúc bình thường chỉ là vài máy ảo rẻ nhất trên nền tảng.</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: trạng thái nào nên để ở đâu?</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-05-05-zustand-vs-react-query/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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 vs React Query không phải chuyện chọn một bỏ một: React Query (TanStack Query) lưu đệm trạng thái phía máy chủ, Zustand giữ trạng thái phía máy khách, còn AsyncStorage giữ những gì phải còn nguyên sau khi khởi động lại ứng dụng. Cách tôi phân chia trạng thái giữa ba thư viện này trong React và React Native, cách dùng Zustand và React Query cùng nhau, và cách kết hợp cả ba để chế độ ngoại tuyến (offline) chạy được.</description>
      <content:encoded><![CDATA[<p>Zustand và React Query không cạnh tranh với nhau, nên câu trả lời thật cho câu hỏi &ldquo;Zustand vs React Query&rdquo; thường là dùng cả hai. React Query, nay được phát hành dưới tên TanStack Query, dành cho trạng thái phía máy chủ (server state): dữ liệu do máy chủ sở hữu, được React Query tải về, lưu vào bộ nhớ đệm, gộp các yêu cầu trùng lặp, và tải lại khi dữ liệu đã cũ. Zustand dành cho trạng thái phía máy khách (client state): những thứ chỉ ứng dụng của bạn biết, như tab nào đang mở hay một biểu mẫu đang điền dở có gì trong đó. Phần lớn ứng dụng dùng hai thư viện này cùng nhau, và trong React Native thì AsyncStorage là mảnh ghép thứ ba, dành cho những gì phải còn nguyên sau khi khởi động lại ứng dụng. Phần còn lại của bài là chỗ tôi vạch ranh giới giữa ba thứ này, kèm mã ví dụ, và cách cả ba cộng lại thành một ứng dụng vẫn chạy được khi ngoại tuyến.</p>
<p>Ứng dụng lập báo giá tôi đang làm có một yêu cầu không thể thương lượng: mất sóng vẫn phải chạy được. Báo giá được lập ngay trong lúc đi khảo sát tận nơi, mà khảo sát thì toàn ở nhà mới xây hoặc dưới tầng hầm, chỗ sóng điện thoại chỉ có trong lời đồn. Chính ràng buộc này buộc tôi phải trả lời nghiêm túc một câu hỏi mà phần lớn dự án React thường cho qua loa: loại trạng thái (state) nào thì nên để ở đâu?</p>
<p>Sai lầm hay gặp nhất là dồn mọi thứ vào một store. Dữ liệu API lưu đệm, tuỳ chọn của người dùng, trạng thái biểu mẫu, token xác thực, tất cả nhét chung vào một cục Redux. Nhưng đây là những loại trạng thái khác nhau, có vòng đời khác nhau, và mỗi loại hợp với một công cụ riêng. Zustand, React Query và AsyncStorage, mỗi cái giải quyết tốt đúng một trong các bài toán đó. Chỉ cần vạch rõ ranh giới giữa chúng là kiến trúc phần lớn sẽ tự vào khuôn.</p>

<h2 class="relative group">Ba loại trạng thái
    <div id="ba-loại-trạng-thái" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ba-lo%e1%ba%a1i-tr%e1%ba%a1ng-th%c3%a1i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Trước khi tranh luận nên dùng thư viện nào, hãy gọi cho đúng tên thứ mình đang quản lý đã.</p>
<p>Trạng thái phía máy khách (client state) chỉ tồn tại khi ứng dụng đang chạy: các nút bật/tắt trên giao diện, tab đang chọn, dữ liệu người dùng nhập vào biểu mẫu, hộp thoại (modal) đang mở hay đóng. Không có ai ở phía máy chủ sở hữu nó, và tiến trình (process) kết thúc thì nó cũng mất theo.</p>
<p>Trạng thái phía máy chủ (server state) thì khác. Máy chủ mới là chủ sở hữu, ứng dụng của bạn chỉ giữ một bản sao trên thiết bị. Ví dụ như hồ sơ người dùng, danh sách sản phẩm, thông báo, dữ liệu bảng tin (feed). Bản sao của bạn sẽ dần cũ đi, cần được tải lại, và ngay lúc này rất có thể một máy khách khác đang thấy một phiên bản khác của cùng dữ liệu đó.</p>
<p>Trạng thái lưu bền (persistent state) là những gì phải còn nguyên sau khi khởi động lại ứng dụng: token xác thực, cờ đánh dấu người dùng đã xong phần làm quen ban đầu (onboarding), các tuỳ chọn đã lưu, dữ liệu dùng khi ngoại tuyến (offline). Loại trạng thái này nằm ngay trên thiết bị.</p>
<p>Ứng dụng nào cũng có đủ cả ba loại. Và phần lớn mớ bòng bong đều bắt nguồn từ việc dùng một công cụ để xử lý cả ba.</p>

<h2 class="relative group">Zustand: trạng thái phía máy khách
    <div id="zustand-trạng-thái-phía-máy-khách" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-tr%e1%ba%a1ng-th%c3%a1i-ph%c3%ada-m%c3%a1y-kh%c3%a1ch" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Zustand là một thư viện quản lý trạng thái nhỏ gọn cho React, không có mấy thủ tục rườm rà. Không cần provider, không có mã rập khuôn (boilerplate), không phải bọc năm lớp context chồng lên nhau. Bạn tạo một store, dùng nó trong component, vậy là xong bài học.</p>
<p>Nó là lựa chọn đúng khi nhiều component cần dùng chung một trạng thái giao diện (thanh bên đang mở hay đóng, bộ lọc đang bật, các mục đang chọn), khi trạng thái ở cấp ứng dụng không đến từ API (chế độ sáng/tối, ngôn ngữ, các cờ tính năng đọc lúc khởi động), hoặc khi có logic phía máy khách thật sự cần quản lý, như tính tiền giỏ hàng hay một trình hướng dẫn nhiều bước (wizard). Nói chung là những thứ cần cập nhật đồng bộ và dễ đoán.</p>
<p>Một store cơ bản trông như thế này:</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>Còn đây là cách dùng trong component:</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">Những thứ tôi không bỏ vào Zustand
    <div id="những-thứ-tôi-không-bỏ-vào-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="#nh%e1%bb%afng-th%e1%bb%a9-t%c3%b4i-kh%c3%b4ng-b%e1%bb%8f-v%c3%a0o-zustand" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Chủ yếu là dữ liệu phản hồi từ API. Tôi từng gặp nhiều dự án mà lời gọi API nào cũng ghi kết quả vào một store Zustand, còn component thì đọc từ store thay vì dùng truy vấn (query). Rốt cuộc bạn phải tự tay viết lại toàn bộ phần vô hiệu hóa bộ nhớ đệm (cache invalidation), trạng thái đang tải, xử lý lỗi, tải lại và phân trang, trong khi đó lại đúng là những việc React Query sinh ra để làm thay bạn.</p>
<p>Thứ hai là bất cứ thứ gì phải còn lại sau khi khởi động lại. Trạng thái của Zustand nằm trong bộ nhớ, đóng ứng dụng là mất. Middleware persist có thể nối Zustand với AsyncStorage (phần sau sẽ nói kỹ hơn), nhưng dùng nó phải là một quyết định có cân nhắc, đừng để thành thói quen.</p>

<h2 class="relative group">React Query: trạng thái phía máy chủ
    <div id="react-query-trạng-thái-phía-máy-chủ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-tr%e1%ba%a1ng-th%c3%a1i-ph%c3%ada-m%c3%a1y-ch%e1%bb%a7" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>React Query (giờ đã đổi tên thành TanStack Query) quản lý toàn bộ vòng đời của dữ liệu từ xa: tải về (fetch), lưu đệm, đồng bộ, cập nhật và dọn rác. Điểm cần đổi tư duy là nó xem dữ liệu từ máy chủ như một bộ nhớ đệm cần giữ cho mới, chứ không phải trạng thái của riêng bạn. Máy chủ mới là chủ. Bạn chỉ đang cầm một bản sao.</p>
<p>Tôi dùng nó cho mọi thứ lấy từ API. Chẳng hạn dữ liệu mà nhiều component cùng cần từ một endpoint (các yêu cầu trùng nhau được tự động gộp lại), danh sách phân trang hoặc cuộn vô hạn, dữ liệu cần tải lại ngầm khi người dùng quay lại ứng dụng, và cập nhật lạc quan (optimistic update), tức là giao diện đổi ngay lập tức rồi khôi phục lại nếu máy chủ từ chối.</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">// Consider fresh for 5 minutes
</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>Dùng trong component:</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">Những thứ có sẵn mà bạn không phải tự viết
    <div id="những-thứ-có-sẵn-mà-bạn-không-phải-tự-viết" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-th%e1%bb%a9-c%c3%b3-s%e1%ba%b5n-m%c3%a0-b%e1%ba%a1n-kh%c3%b4ng-ph%e1%ba%a3i-t%e1%bb%b1-vi%e1%ba%bft" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Tôi kiên quyết để dữ liệu từ máy chủ ở đây vì số cơ chế được tặng kèm miễn phí nhiều đến choáng ngợp. Năm component cùng lấy một người dùng thì chỉ tạo ra đúng một yêu cầu mạng. Dữ liệu tự tải lại khi cửa sổ được chọn lại (focus) hoặc khi có mạng lại. Bộ nhớ đệm cũ tự được dọn. Truy vấn nào cũng trả sẵn <code>isLoading</code>, <code>isError</code>, <code>data</code> cùng các trường liên quan, nên giao diện cho trạng thái đang tải và lỗi không còn phải làm thủ công từng chỗ. Yêu cầu thất bại được tự động thử lại với thời gian chờ tăng theo cấp số nhân (exponential backoff). Còn cập nhật lạc quan kèm khôi phục khi lỗi là cơ chế có sẵn, chứ không phải mất nguyên buổi chiều tự đi dây.</p>

<h3 class="relative group">Những việc không nên dùng React Query
    <div id="những-việc-không-nên-dùng-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="#nh%e1%bb%afng-vi%e1%bb%87c-kh%c3%b4ng-n%c3%aan-d%c3%b9ng-react-query" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Trạng thái thuần tuý phía máy khách. Nếu dữ liệu không bao giờ đi qua máy chủ thì React Query chỉ thêm rườm rà. Trạng thái mở/đóng của một hộp thoại đâu cần vô hiệu hóa bộ nhớ đệm hay tải lại định kỳ.</p>
<p>React Query cũng không phải tầng lưu trữ lâu dài. Bộ nhớ đệm chỉ nằm trong RAM, nên mỗi lần khởi động lại là trống trơn và phải tải lại một loạt. Bạn có thể lưu bộ nhớ đệm xuống thiết bị (trên React Native thì nên làm, xem phần ngoại tuyến bên dưới), nhưng nguồn dữ liệu gốc (source of truth) lúc nào cũng là máy chủ.</p>

<h2 class="relative group">AsyncStorage: những gì cần giữ lại sau khi khởi động lại
    <div id="asyncstorage-những-gì-cần-giữ-lại-sau-khi-khởi-động-lại" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-nh%e1%bb%afng-g%c3%ac-c%e1%ba%a7n-gi%e1%bb%af-l%e1%ba%a1i-sau-khi-kh%e1%bb%9fi-%c4%91%e1%bb%99ng-l%e1%ba%a1i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>AsyncStorage là kho lưu trữ dạng khóa-giá trị (key-value) của React Native. Trên web, thứ tương đương là <code>localStorage</code> (đồng bộ) hoặc <code>IndexedDB</code> (bất đồng bộ, làm được nhiều việc hơn). Dù dùng cái nào thì ý tưởng cũng như nhau: dữ liệu được ghi xuống thiết bị nên vẫn còn sau khi tiến trình kết thúc.</p>

<h3 class="relative group">Bên dưới mỗi nền tảng thực chất là gì
    <div id="bên-dưới-mỗi-nền-tảng-thực-chất-là-gì" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%c3%aan-d%c6%b0%e1%bb%9bi-m%e1%bb%97i-n%e1%bb%81n-t%e1%ba%a3ng-th%e1%bb%b1c-ch%e1%ba%a5t-l%c3%a0-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Cùng một API, nhưng bên dưới là ba cơ chế lưu trữ khá khác nhau (giả sử bạn dùng <code>@react-native-async-storage/async-storage</code>).</p>
<p>Trên Android, bên dưới là SQLite, thông qua <code>RKStorage</code>, lưu trong một cơ sở dữ liệu nằm ở thư mục lưu trữ nội bộ của ứng dụng. Nhanh, ổn định, lại được cô lập trong môi trường riêng (sandbox) nên ứng dụng khác không đụng vào được. Lưu ý giới hạn dung lượng mặc định trong bảng bên dưới.</p>
<p>Trên iOS, giá trị nhỏ được lưu bằng <code>NSUserDefaults</code>, còn giá trị lớn hơn thì được tuần tự hóa (serialise) rồi ghi ra tệp, cũng nằm trong sandbox của ứng dụng. Apple không đặt giới hạn cứng cho <code>NSUserDefaults</code>, nhưng thông lệ hợp lý là giữ mỗi giá trị dưới vài trăm KB. Nếu dữ liệu của bạn lớn hơn thế thì có lẽ bạn nên dùng hẳn một cơ sở dữ liệu đàng hoàng như WatermelonDB hay Realm.</p>
<p>Trên web (React Native Web hoặc Expo Web), AsyncStorage chuyển sang dùng <code>localStorage</code>, dung lượng tối đa khoảng 5-10 MB tuỳ trình duyệt. Ứng dụng React chỉ chạy trên web thì có thể dùng thẳng <code>localStorage</code>, hoặc dùng <code>IndexedDB</code> qua một thư viện bọc (wrapper) như <code>idb-keyval</code> nếu dữ liệu lớn hơn.</p>
<table>
	<thead>
			<tr>
					<th>Nền tảng</th>
					<th>Cơ chế lưu trữ</th>
					<th>Giới hạn dung lượng</th>
					<th>Vị trí</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Android</td>
					<td>SQLite (RKStorage)</td>
					<td>Mặc định ~6 MB (chỉnh được)</td>
					<td>Bộ nhớ nội bộ của ứng dụng</td>
			</tr>
			<tr>
					<td>iOS</td>
					<td>NSUserDefaults / tệp</td>
					<td>Không có giới hạn cứng (nên giữ giá trị nhỏ)</td>
					<td>Sandbox của ứng dụng</td>
			</tr>
			<tr>
					<td>Web</td>
					<td>localStorage</td>
					<td>~5-10 MB (tuỳ trình duyệt)</td>
					<td>Bộ nhớ theo origin của trình duyệt</td>
			</tr>
	</tbody>
</table>

<h3 class="relative group">Tôi lưu gì trong đó
    <div id="tôi-lưu-gì-trong-đó" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#t%c3%b4i-l%c6%b0u-g%c3%ac-trong-%c4%91%c3%b3" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Token xác thực và dữ liệu phiên đăng nhập, các tuỳ chọn cần giữ lại (ngôn ngữ, chế độ sáng/tối, cài đặt thông báo), cờ đã xong phần làm quen ban đầu, dữ liệu lưu đệm để dùng khi ngoại tuyến. Tóm lại là những mẩu dữ liệu khóa-giá trị nho nhỏ cần giữ lại sau khi khởi động lại, chỉ vậy thôi.</p>
<p>API của nó đơn giản hết mức mà một kho lưu trữ có thể có:</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">// Store a value
</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">// Read a value
</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">// Store an object (must serialize)
</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">// Read an object
</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">// Remove a value
</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">// Clear everything (careful with this)
</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">Phiên bản cho web
    <div id="phiên-bản-cho-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="#phi%c3%aan-b%e1%ba%a3n-cho-web" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Ứng dụng React chỉ chạy trên web thì không cần đến AsyncStorage. Các trường hợp đơn giản dùng <code>localStorage</code> là đủ:</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">// Synchronous  - blocks the main thread, but fine for small data
</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">// For structured data
</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>Còn dữ liệu lớn hơn thì dùng <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">Những việc không nên dùng AsyncStorage
    <div id="những-việc-không-nên-dùng-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="#nh%e1%bb%afng-vi%e1%bb%87c-kh%c3%b4ng-n%c3%aan-d%c3%b9ng-asyncstorage" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>AsyncStorage là kho khóa-giá trị, không phải cơ sở dữ liệu. Dữ liệu quan hệ, mảng hàng nghìn phần tử, bất cứ thứ gì cần đánh chỉ mục hay truy vấn thì hãy để cho SQLite (qua <code>expo-sqlite</code>), WatermelonDB hoặc Realm.</p>
<p>Nó cũng không phải kho lưu trữ bảo mật. Trên thiết bị đã root hoặc jailbreak, ai cũng đọc được nội dung AsyncStorage. Token nhạy cảm phải để trong <code>expo-secure-store</code> hoặc <code>react-native-keychain</code>.</p>

<h2 class="relative group">Kết hợp cả ba với nhau
    <div id="kết-hợp-cả-ba-với-nhau" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#k%e1%ba%bft-h%e1%bb%a3p-c%e1%ba%a3-ba-v%e1%bb%9bi-nhau" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Ứng dụng thực tế thường dùng cả ba cùng lúc. Dưới đây là vài mẫu (pattern) tôi dùng thường xuyên.</p>

<h3 class="relative group">Luồng đăng nhập
    <div id="luồng-đăng-nhập" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#lu%e1%bb%93ng-%c4%91%c4%83ng-nh%e1%ba%adp" aria-label="Cọc dấu">#</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: persist the auth token
</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: track auth state in memory
</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: fetch user profile using the token
</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">// Only fetch when we have a token
</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>Còn lúc ứng dụng khởi động:</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">// App initialization
</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">// Read from 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">// Put in Zustand for quick access
</span></span></span><span class="line"><span class="cl">    <span class="c1">// React Query will automatically fetch the user profile
</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 giữ token qua các lần khởi động lại, Zustand giúp đọc token ở bất cứ đâu mà không tốn kém, còn React Query tải hồ sơ người dùng ngay khi có token. Mỗi công cụ chỉ làm đúng một việc mà nó giỏi nhất.</p>

<h3 class="relative group">Chế độ sáng/tối vẫn giữ nguyên sau khi khởi động lại
    <div id="chế-độ-sángtối-vẫn-giữ-nguyên-sau-khi-khởi-động-lại" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ch%e1%ba%bf-%c4%91%e1%bb%99-s%c3%a1ngt%e1%bb%91i-v%e1%ba%abn-gi%e1%bb%af-nguy%c3%aan-sau-khi-kh%e1%bb%9fi-%c4%91%e1%bb%99ng-l%e1%ba%a1i" aria-label="Cọc dấu">#</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 with persistence middleware bridges the gap
</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 quản lý trạng thái trong lúc ứng dụng chạy, còn middleware persist âm thầm đồng bộ trạng thái đó xuống AsyncStorage (hoặc localStorage trên web). Component của bạn không biết, mà cũng chẳng cần biết, là có một tầng lưu trữ nằm bên dưới.</p>

<h3 class="relative group">Đọc dữ liệu theo hướng ưu tiên ngoại tuyến
    <div id="đọc-dữ-liệu-theo-hướng-ưu-tiên-ngoại-tuyến" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%e1%bb%8dc-d%e1%bb%af-li%e1%bb%87u-theo-h%c6%b0%e1%bb%9bng-%c6%b0u-ti%c3%aan-ngo%e1%ba%a1i-tuy%e1%ba%bfn" aria-label="Cọc dấu">#</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">// Cache in AsyncStorage for offline use
</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">// Network failed  - try cached data
</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 lo việc tải và lưu đệm trong bộ nhớ, còn AsyncStorage là phương án dự phòng khi mất mạng.</p>

<h2 class="relative group">Làm chế độ ngoại tuyến, từng bước một
    <div id="làm-chế-độ-ngoại-tuyến-từng-bước-một" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#l%c3%a0m-ch%e1%ba%bf-%c4%91%e1%bb%99-ngo%e1%ba%a1i-tuy%e1%ba%bfn-t%e1%bb%abng-b%c6%b0%e1%bb%9bc-m%e1%bb%99t" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây mới là phần tôi thật sự quan tâm. Người ta hay nói người dùng mở ứng dụng trên tàu điện ngầm, trong thang máy, trên máy bay, và đúng là vậy. Nhưng trường hợp của tôi thì yêu cầu thẳng thừng hơn nhiều: báo giá được lập ngay tại công trình, và tôi không thể mặc định là công trình có sóng. Một biểu tượng đang tải cứ quay mãi dưới tầng hầm đồng nghĩa với mất một đơn báo giá.</p>
<p>Điều bất ngờ dễ chịu là chỉ với ba thư viện này, bạn đã dựng được một hệ thống chạy ngoại tuyến thật sự vững vàng mà không cần đến framework cồng kềnh nào.</p>

<h3 class="relative group">Bước 1: biết lúc nào đang mất mạng
    <div id="bước-1-biết-lúc-nào-đang-mất-mạng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%c6%b0%e1%bb%9bc-1-bi%e1%ba%bft-l%c3%bac-n%c3%a0o-%c4%91ang-m%e1%ba%a5t-m%e1%ba%a1ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Thiết bị cần báo cho ứng dụng biết tình trạng mạng, và ứng dụng cần một chỗ để lưu thông tin đó. <code>@react-native-community/netinfo</code> cung cấp các sự kiện, còn một store Zustand bé xíu giúp đọc được trạng thái đó từ bất cứ đâu.</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">// Subscribe once at app startup
</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>Từ giờ, component nào cũng có thể gọi <code>useNetworkStore((s) =&gt; s.isOnline)</code> để hiện dải thông báo mất mạng, vô hiệu hoá nút gửi, hoặc báo cho người dùng biết thay đổi sẽ được đồng bộ sau.</p>

<h3 class="relative group">Bước 2: cấu hình React Query cho chế độ ngoại tuyến
    <div id="bước-2-cấu-hình-react-query-cho-chế-độ-ngoại-tuyến" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%c6%b0%e1%bb%9bc-2-c%e1%ba%a5u-h%c3%acnh-react-query-cho-ch%e1%ba%bf-%c4%91%e1%bb%99-ngo%e1%ba%a1i-tuy%e1%ba%bfn" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>React Query có sẵn cơ chế xử lý ngoại tuyến thông qua <code>networkMode</code>, quyết định truy vấn và thao tác ghi (mutation) sẽ làm gì khi mất mạng.</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">// Return cached data immediately, then refetch in background when online
</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">// Keep cache for 24 hours
</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">// Don&#39;t retry if we&#39;re offline  - it&#39;ll just fail again
</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>Có ba chế độ:</p>
<table>
	<thead>
			<tr>
					<th>Chế độ</th>
					<th>Hành vi</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>online</code> (mặc định)</td>
					<td>Truy vấn chỉ chạy khi có mạng. Mất mạng thì tạm dừng.</td>
			</tr>
			<tr>
					<td><code>always</code></td>
					<td>Truy vấn chạy bất kể có mạng hay không. <code>queryFn</code> của bạn phải tự xử lý lỗi.</td>
			</tr>
			<tr>
					<td><code>offlineFirst</code></td>
					<td>Truy vấn chạy một lần (để lấy dữ liệu lưu đệm), rồi tạm dừng, đợi có mạng mới tải lại.</td>
			</tr>
	</tbody>
</table>
<p>Với phần lớn ứng dụng di động, <code>offlineFirst</code> là lựa chọn phù hợp: dữ liệu lưu đệm hiện ra ngay, còn dữ liệu mới sẽ về khi có mạng.</p>

<h3 class="relative group">Bước 3: lưu bộ nhớ đệm truy vấn xuống thiết bị
    <div id="bước-3-lưu-bộ-nhớ-đệm-truy-vấn-xuống-thiết-bị" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%c6%b0%e1%bb%9bc-3-l%c6%b0u-b%e1%bb%99-nh%e1%bb%9b-%c4%91%e1%bb%87m-truy-v%e1%ba%a5n-xu%e1%bb%91ng-thi%e1%ba%bft-b%e1%bb%8b" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Mặc định, bộ nhớ đệm chỉ nằm trong RAM, nên mỗi lần khởi động lại là biểu tượng đang tải quay khắp màn hình. Muốn dùng được khi ngoại tuyến thì bạn lưu nó xuống 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 hours  - must be &gt;= maxAge
</span></span></span><span class="line"><span class="cl">    <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl"><span class="p">})</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">asyncStoragePersister</span> <span class="o">=</span> <span class="nx">createAsyncStoragePersister</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">storage</span>: <span class="kt">AsyncStorage</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">key</span><span class="o">:</span> <span class="s1">&#39;react-query-cache&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">})</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1">// In your App component
</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">// Don&#39;t restore data older than 24 hours
</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">// Only persist successful queries
</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>Giờ thì dù mở ứng dụng ở chỗ không có sóng, người dùng vẫn thấy ngay dữ liệu tải về lần gần nhất thay vì một màn hình trắng, và khi có mạng lại thì React Query lặng lẽ tải dữ liệu mới.</p>

<h3 class="relative group">Bước 4: xếp hàng các thao tác ghi khi mất mạng
    <div id="bước-4-xếp-hàng-các-thao-tác-ghi-khi-mất-mạng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%c6%b0%e1%bb%9bc-4-x%e1%ba%bfp-h%c3%a0ng-c%c3%a1c-thao-t%c3%a1c-ghi-khi-m%e1%ba%a5t-m%e1%ba%a1ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Đọc là phần dễ. Ghi mới là chỗ chế độ ngoại tuyến trở nên thú vị: người dùng thêm bình luận, gửi đơn hàng hay sửa hồ sơ lúc không có mạng, và thay đổi đó phải được xếp vào hàng đợi để gửi lại sau.</p>
<p>React Query giải quyết chuyện này bằng <code>useMutation</code> kèm cập nhật lạc quan trong <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">// Optimistic update  - show the comment immediately
</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">// Show a &#34;sending...&#34; indicator in the 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">// Roll back on failure
</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">// Refetch to get the real data from the server
</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>Khi đặt <code>networkMode: 'offlineFirst'</code> cho thao tác ghi, <code>mutationFn</code> sẽ tạm dừng cho tới khi có mạng trở lại. Cập nhật lạc quan đưa bình luận lên màn hình ngay lập tức, còn lời gọi API thật sẽ chạy khi kết nối lại.</p>
<p>Nếu cần một hàng đợi nghiêm túc hơn, chẳng hạn có vài chục thay đổi đang chờ và phải gửi lại đúng thứ tự, bạn có thể lưu luôn cả hàng đợi thao tác ghi xuống thiết bị:</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">// Save pending mutations to 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">// Log failed mutations for debugging
</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">Bước 5: đồng bộ lại khi có mạng
    <div id="bước-5-đồng-bộ-lại-khi-có-mạng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%c6%b0%e1%bb%9bc-5-%c4%91%e1%bb%93ng-b%e1%bb%99-l%e1%ba%a1i-khi-c%c3%b3-m%e1%ba%a1ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Phần lớn việc xử lý khi kết nối lại thì React Query tự lo: thao tác ghi đang tạm dừng sẽ chạy tiếp, truy vấn đã cũ sẽ được tải lại. Bạn chỉ cần nối các trình quản lý (manager) của nó với sự kiện của 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">// Tell React Query about network state changes
</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">// Refetch when the app comes back to foreground
</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>Khi người dùng để ứng dụng chạy nền một tiếng rồi mở lại, <code>focusManager</code> sẽ kích hoạt việc tải lại để họ thấy dữ liệu mới. Khi họ ra khỏi đường hầm và có sóng trở lại, <code>onlineManager</code> cho những thao tác đang tạm dừng chạy tiếp.</p>

<h3 class="relative group">Bước 6: thông báo rõ trên giao diện
    <div id="bước-6-thông-báo-rõ-trên-giao-diện" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%c6%b0%e1%bb%9bc-6-th%c3%b4ng-b%c3%a1o-r%c3%b5-tr%c3%aan-giao-di%e1%bb%87n" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Dù làm gì thì cũng đừng lẳng lặng nuốt lỗi mạng. Hãy cho người dùng biết họ đang ngoại tuyến và thay đổi nào vẫn đang chờ gửi.</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">Bức tranh toàn cảnh
    <div id="bức-tranh-toàn-cảnh" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%e1%bb%a9c-tranh-to%c3%a0n-c%e1%ba%a3nh" aria-label="Cọc dấu">#</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>Lớp</th>
					<th>Công cụ</th>
					<th>Vai trò</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Phát hiện mạng</td>
					<td>Zustand + NetInfo</td>
					<td>Theo dõi trạng thái có mạng/mất mạng, điều khiển dải thông báo trên giao diện</td>
			</tr>
			<tr>
					<td>Tải dữ liệu</td>
					<td>React Query</td>
					<td>Tải khi có mạng, trả dữ liệu lưu đệm khi mất mạng</td>
			</tr>
			<tr>
					<td>Lưu bộ nhớ đệm</td>
					<td>React Query + AsyncStorage</td>
					<td>Khôi phục bộ nhớ đệm khi khởi động lại ứng dụng</td>
			</tr>
			<tr>
					<td>Ghi khi mất mạng</td>
					<td>Thao tác ghi của React Query</td>
					<td>Xếp hàng thao tác ghi, gửi lại khi có mạng</td>
			</tr>
			<tr>
					<td>Giao diện cập nhật lạc quan</td>
					<td><code>onMutate</code> của React Query</td>
					<td>Hiện thay đổi ngay, khôi phục lại nếu thất bại</td>
			</tr>
			<tr>
					<td>Đồng bộ khi ứng dụng được mở lại</td>
					<td><code>focusManager</code> của React Query</td>
					<td>Tải lại dữ liệu cũ khi ứng dụng quay lại tiền cảnh (foreground)</td>
			</tr>
	</tbody>
</table>

<h3 class="relative group">Khi nào cần một công cụ đồng bộ thực thụ
    <div id="khi-nào-cần-một-công-cụ-đồng-bộ-thực-thụ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#khi-n%c3%a0o-c%e1%ba%a7n-m%e1%bb%99t-c%c3%b4ng-c%e1%bb%a5-%c4%91%e1%bb%93ng-b%e1%bb%99-th%e1%bb%b1c-th%e1%bb%a5" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Tất cả những gì ở trên đều giả định máy chủ là nguồn dữ liệu gốc và việc mất mạng chỉ là tạm thời. Nếu bạn cần ưu tiên ngoại tuyến (offline-first) thật sự, có cả giải quyết xung đột (hình dung một ứng dụng ghi chú mà hai thiết bị cùng sửa một tài liệu lúc ngoại tuyến), thì bộ công nghệ này không kham nổi, và bạn cần một công cụ đồng bộ (sync engine) đúng nghĩa. WatermelonDB được làm riêng cho React Native, chạy trên SQLite và có sẵn giao thức đồng bộ để giải quyết xung đột. Realm kết hợp Atlas Device Sync là một cơ sở dữ liệu ưu tiên ngoại tuyến đầy đủ, tự động giải quyết xung đột qua MongoDB Atlas. PowerSync là một tầng đồng bộ dựa trên SQLite, chạy được với cơ sở dữ liệu Postgres sẵn có ở phía máy chủ của bạn. Còn nếu muốn toàn quyền kiểm soát thì bạn luôn có thể dùng Expo SQLite và tự viết logic đồng bộ.</p>
<p>Bộ ba Zustand + React Query + AsyncStorage đáp ứng được khoảng 80% ứng dụng di động. 20% còn lại, như các trình soạn thảo cộng tác hay những quy trình làm việc phụ thuộc nhiều vào chế độ ngoại tuyến trên nhiều thiết bị, thì cần một cơ sở dữ liệu có đồng bộ chuyên dụng.</p>

<h2 class="relative group">Một mẩu trạng thái nên để ở đâu?
    <div id="một-mẩu-trạng-thái-nên-để-ở-đâu" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#m%e1%bb%99t-m%e1%ba%a9u-tr%e1%ba%a1ng-th%c3%a1i-n%c3%aan-%c4%91%e1%bb%83-%e1%bb%9f-%c4%91%c3%a2u" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Khi phân vân không biết một thứ nên để ở đâu, tôi lần lượt hỏi mấy câu sau:</p>
<table>
	<thead>
			<tr>
					<th>Câu hỏi</th>
					<th>Nếu có → dùng</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Có đến từ máy chủ/API không?</td>
					<td>React Query</td>
			</tr>
			<tr>
					<td>Có phải trạng thái giao diện chỉ ở phía máy khách và được nhiều component dùng chung không?</td>
					<td>Zustand</td>
			</tr>
			<tr>
					<td>Có cần giữ lại sau khi khởi động lại ứng dụng không?</td>
					<td>AsyncStorage (+ Zustand persist nếu cần)</td>
			</tr>
			<tr>
					<td>Có nhạy cảm không (token, mật khẩu)?</td>
					<td>expo-secure-store / react-native-keychain</td>
			</tr>
			<tr>
					<td>Có phải dữ liệu có cấu trúc, dung lượng lớn và cần truy vấn không?</td>
					<td>SQLite / WatermelonDB / Realm</td>
			</tr>
			<tr>
					<td>Có phải một ô nhập liệu đơn giản, chỉ một component dùng không?</td>
					<td>useState</td>
			</tr>
	</tbody>
</table>

<h3 class="relative group">Các trường hợp thường gặp
    <div id="các-trường-hợp-thường-gặp" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%a1c-tr%c6%b0%e1%bb%9dng-h%e1%bb%a3p-th%c6%b0%e1%bb%9dng-g%e1%ba%b7p" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<table>
	<thead>
			<tr>
					<th>Trạng thái</th>
					<th>Công cụ</th>
					<th>Lý do</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Dữ liệu phản hồi từ API</td>
					<td>React Query</td>
					<td>Lưu đệm, gộp yêu cầu trùng, tải lại, trạng thái đang tải</td>
			</tr>
			<tr>
					<td>Tab đang chọn / bộ lọc đang bật</td>
					<td>Zustand</td>
					<td>Chỉ ở máy khách, nhiều component cần dùng</td>
			</tr>
			<tr>
					<td>Token xác thực</td>
					<td>AsyncStorage + Zustand</td>
					<td>Giữ qua các lần khởi động lại, đọc nhanh từ bộ nhớ</td>
			</tr>
			<tr>
					<td>Tuỳ chọn chế độ sáng/tối</td>
					<td>Zustand với middleware persist</td>
					<td>Trạng thái phía máy khách cần giữ lại sau khi khởi động lại</td>
			</tr>
			<tr>
					<td>Giỏ hàng</td>
					<td>Zustand với middleware persist</td>
					<td>Logic phía máy khách phức tạp, cần giữ lại sau khi khởi động lại</td>
			</tr>
			<tr>
					<td>Ô nhập liệu trong biểu mẫu</td>
					<td>useState</td>
					<td>Chỉ một component, không cần chia sẻ</td>
			</tr>
			<tr>
					<td>Hồ sơ người dùng từ API</td>
					<td>React Query</td>
					<td>Trạng thái phía máy chủ, có thể đã cũ</td>
			</tr>
			<tr>
					<td>Cờ đã xong phần làm quen ban đầu</td>
					<td>AsyncStorage</td>
					<td>Chỉ là một giá trị boolean cần lưu lại</td>
			</tr>
			<tr>
					<td>Bảng tin lưu đệm để xem khi mất mạng</td>
					<td>React Query + AsyncStorage</td>
					<td>Tải từ máy chủ, lỗi thì quay về dùng bản lưu đệm</td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">Cài đặt theo từng nền tảng
    <div id="cài-đặt-theo-từng-nền-tảng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%a0i-%c4%91%e1%ba%b7t-theo-t%e1%bb%abng-n%e1%bb%81n-t%e1%ba%a3ng" aria-label="Cọc dấu">#</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="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Cài cả ba một lượt:</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>Thêm kho lưu trữ bảo mật cho những dữ liệu nhạy cảm:</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="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Với Expo, AsyncStorage chạy được ngay, không cần bước liên kết mã native (native linking).</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">React chỉ chạy trên web
    <div id="react-chỉ-chạy-trên-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="#react-ch%e1%bb%89-ch%e1%ba%a1y-tr%c3%aan-web" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Bỏ hẳn AsyncStorage, dùng thẳng <code>localStorage</code> hoặc <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>Middleware persist của Zustand vốn đã dùng <code>localStorage</code> làm mặc định trên web:</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">Những lỗi tôi gặp đi gặp lại
    <div id="những-lỗi-tôi-gặp-đi-gặp-lại" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-l%e1%bb%97i-t%c3%b4i-g%e1%ba%b7p-%c4%91i-g%e1%ba%b7p-l%e1%ba%a1i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bỏ dữ liệu API vào Zustand. Nếu trong action của bạn có dòng <code>setUsers(apiResponse.users)</code> thì dừng lại. Đó là việc của React Query, và chỉ một sprint nữa thôi là bạn sẽ tự chế lại cơ chế vô hiệu hóa bộ nhớ đệm, mà còn chế dở.</p>
<p>Dùng React Query cho trạng thái phía máy khách. Nếu <code>queryFn</code> không hề gửi yêu cầu mạng nào thì bạn đang dùng sai công cụ.</p>
<p>Dùng AsyncStorage như cơ sở dữ liệu. Tuần tự hóa một mảng 10,000 phần tử rồi nhét vào kho khóa-giá trị thì kết cục chỉ có khóc. Hãy dùng SQLite hoặc một cơ sở dữ liệu thật sự.</p>
<p>Lưu token mà không mã hoá. AsyncStorage không phải kho lưu trữ bảo mật. Token xác thực, API key và thông tin đăng nhập phải để trong <code>expo-secure-store</code> hoặc keychain của nền tảng.</p>
<p>Tự viết tay phần lưu trạng thái xuống thiết bị. Nếu bạn đang đọc AsyncStorage lúc component được gắn vào (mount) và ghi xuống mỗi khi trạng thái thay đổi, thì middleware persist của Zustand đã làm sẵn việc đó, lại lo luôn cả khâu nạp lại trạng thái (hydration) và tuần tự hóa, mã ít hơn mà lỗi cũng ít hơn.</p>
<hr>
<p>Tóm lại: dữ liệu từ máy chủ đi qua React Query. Trạng thái phía máy khách nằm trong Zustand. Thứ gì cần giữ lại sau khi khởi động lại thì cho vào AsyncStorage, còn thứ gì nhạy cảm thì cho vào kho lưu trữ bảo mật.</p>
<p>Những kiến trúc tệ nhất tôi từng làm đều có một store khổng lồ mà mọi dữ liệu đều chảy qua. Những kiến trúc tốt nhất thì phân định rõ ràng giữa trạng thái phía máy chủ, trạng thái phía máy khách và những gì cần lưu lại lâu dài. Vạch sẵn các ranh giới đó từ sớm thì mọi thứ về sau sẽ dễ thở hơn nhiều.</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>Tự host cập nhật OTA cho Expo, không cần EAS: dựng máy chủ bằng Django và Tigris</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-02-22-self-hosted-ota-updates/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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>Cập nhật OTA của Expo hoạt động ra sao, và cách tôi tự host nó mà không cần EAS Update: một máy chủ Django REST nói đúng giao thức Expo Updates, gói mã nằm trên Tigris S3, chi phí vận hành chỉ khoảng một đô mỗi tháng.</description>
      <content:encoded><![CDATA[<blockquote><p><strong>Cập nhật, ngày 13 tháng 9 năm 2026.</strong> Sau khi bài này lên, Codemagic đã ra mắt <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a>, một máy chủ OTA <a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">mã nguồn mở</a>, tự vận hành được (self-hosted), theo mô hình CodePush. Nếu bạn tìm tới đây vì không muốn tự viết máy chủ, thì giờ đã có một lựa chọn thật sự: tôi đã <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-13-moving-to-codemagic-patch/" >chuyển một ứng dụng từ hệ thống bên dưới sang Patch</a> và viết lại quá trình đó, ngoài ra còn có một ghi chú ngắn ở phần <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-02-22-self-hosted-ota-updates/#t%e1%bb%95ng-k%e1%ba%bft" >Tổng kết</a>. Minh bạch: Codemagic đã trả tiền để tôi thực hiện đợt di chuyển đó và viết về nó, và họ đề nghị nhắc tới ở đây. Phần mã trong bài sau đó đã được rút gọn thành các ví dụ ngắn.</p>
</blockquote><p>Với ứng dụng báo giá mà tôi đang làm, các bản sửa lỗi được đẩy thẳng qua mạng (over the air): phần mã JavaScript mới về tận điện thoại người dùng mà không phải chờ App Store duyệt. Thật ra EAS Update, dịch vụ do chính Expo vận hành, sẵn sàng lo việc này giúp bạn. Nhưng tôi vẫn tự làm một bản riêng bằng Django REST Framework và Tigris S3, và từ đó tới giờ, mọi bản cập nhật cho cả iOS lẫn Android đều đi qua hệ thống này.</p>
<p>Cơ chế cập nhật OTA của Expo như sau: thư viện <code>expo-updates</code> trong ứng dụng gửi yêu cầu lấy manifest tới một máy chủ cập nhật, kèm theo nền tảng (platform) và phiên bản runtime (runtime version) trong header. Máy chủ trả về bản cập nhật mới nhất còn tương thích, cùng URL của gói mã (bundle) JavaScript và các tài nguyên đi kèm; ứng dụng tải chúng về và chạy gói mã mới ở lần khởi động kế tiếp. EAS Update là máy chủ do Expo vận hành cho giao thức đó, nhưng thực ra máy chủ nào nói đúng giao thức cũng dùng được, và tôi đã tự làm một cái như thế.</p>
<p>Trong bài này, tôi sẽ đi qua toàn bộ hệ thống: các mô hình dữ liệu (model), endpoint trả về manifest, quy trình phát hành (publish), và cả những cái bẫy mà phải tự vận hành thì bạn mới gặp.</p>

<h2 class="relative group">Vì sao lại tự vận hành?
    <div id="vì-sao-lại-tự-vận-hành" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#v%c3%ac-sao-l%e1%ba%a1i-t%e1%bb%b1-v%e1%ba%adn-h%c3%a0nh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Với tôi thì lý do chính là tiền. EAS Update tính phí theo mức sử dụng, nên khi bạn đẩy bản cập nhật thường xuyên cho một lượng người dùng ngày càng đông, chi phí sẽ cộng dồn lên. Trong khi đó, dịch vụ lưu trữ tương thích S3 như Tigris thì gần như miễn phí. Cũng có vài lý do khác, biết đâu còn quan trọng hơn với bạn. Một số ngành bắt buộc mọi tài nguyên (asset) của ứng dụng phải nằm trong hạ tầng của chính công ty. Tự nắm máy chủ cũng có nghĩa là tự nắm luồng cập nhật: nếu sản phẩm cần, bạn có thể phát hành dần cho từng nhóm người dùng, hay thử nghiệm A/B với các gói mã (bundle) khác nhau. Và quy trình cập nhật của bạn không còn phụ thuộc vào chuyện dịch vụ của Expo có ổn định hay không, hay lần tới họ sẽ đổi bảng giá ra sao.</p>

<h2 class="relative group">Các thành phần kết nối với nhau thế nào
    <div id="các-thành-phần-kết-nối-với-nhau-thế-nào" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%a1c-th%c3%a0nh-ph%e1%ba%a7n-k%e1%ba%bft-n%e1%bb%91i-v%e1%bb%9bi-nhau-th%e1%ba%bf-n%c3%a0o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Hệ thống gồm bốn thành phần: một máy chủ Django trả về manifest của bản cập nhật và lưu siêu dữ liệu (metadata); Tigris chứa các tệp gói mã và tài nguyên thật; một script phát hành lo phần kết xuất (export), tải lên và đăng ký bản cập nhật; và cuối cùng là chính ứng dụng di động, được cấu hình trỏ về máy chủ của tôi thay vì máy chủ của 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">Bắt tay vào làm
    <div id="bắt-tay-vào-làm" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%e1%ba%aft-tay-v%c3%a0o-l%c3%a0m" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Hai mô hình dữ liệu
    <div id="hai-mô-hình-dữ-liệu" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#hai-m%c3%b4-h%c3%acnh-d%e1%bb%af-li%e1%bb%87u" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Mọi thứ đều xoay quanh hai mô hình <code>ExpoUpdate</code> và <code>ExpoUpdateAsset</code>. Mô hình thứ nhất lưu siêu dữ liệu của từng bản cập nhật:</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>Trong mô hình này có vài chỗ tôi cố ý thiết kế như vậy. <code>runtime_version</code> tương ứng với <code>runtimeVersion</code> trong <code>app.json</code>, và trường này gánh khá nhiều việc: máy khách chỉ tải những bản cập nhật có cùng phiên bản runtime (runtime version) với mình. iOS và Android được lưu thành hai bản ghi riêng, vì gói mã của hai nền tảng khác nhau. <code>is_active</code> chính là cơ chế khôi phục về phiên bản trước (rollback): chỉ cần tắt bản cập nhật bị lỗi là máy khách quay về bản trước đó. Còn <code>manifest_data</code> lưu nguyên manifest theo giao thức Expo Updates v1 dưới dạng JSON, nên về sau muốn trả manifest ra thì chỉ cần tra một lần là xong.</p>
<p><code>ExpoUpdateAsset</code> thì theo dõi từng tệp riêng lẻ:</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>Tài nguyên được định danh bằng mã băm (hash) SHA-256, nên nội dung của chúng không bao giờ đổi và có thể lưu đệm (cache) thoải mái. Một tài nguyên dùng chung cho bao nhiêu bản cập nhật cũng được.</p>

<h3 class="relative group">Endpoint manifest
    <div id="endpoint-manifest" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#endpoint-manifest" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p><code>/api/expo-updates/manifest/</code> là nơi ứng dụng và máy chủ thật sự trao đổi với nhau. Endpoint này được xây dựng theo <a href="https://docs.expo.dev/technical-specs/expo-updates-1/"  target="_blank" rel="noreferrer">giao thức 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>Ở đây có ba chi tiết cần để ý. Thứ nhất, phản hồi (response) 204 nghĩa là “không có bản cập nhật nào”, và ứng dụng cứ thế chạy tiếp với gói mã nhúng sẵn. Thứ hai, URL của tài nguyên trong manifest là URL ký sẵn (presigned URL), nên ứng dụng tải thẳng từ CDN của Tigris chứ không phải truyền mọi thứ qua Django. Thứ ba, header <code>expo-protocol-version</code> trong phản hồi là bắt buộc, vì máy khách sẽ kiểm tra giá trị này.</p>

<h3 class="relative group">Phát hành bản cập nhật
    <div id="phát-hành-bản-cập-nhật" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ph%c3%a1t-h%c3%a0nh-b%e1%ba%a3n-c%e1%ba%adp-nh%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Phần phát hành là một lệnh quản trị (management command) của Django, bên ngoài bọc thêm một shell script. Lệnh này đọc đầu ra của <code>expo export</code>, tính mã băm SHA-256 cho từng tài nguyên, tải song song gói mã và tài nguyên lên Tigris, ghi bản ghi vào cơ sở dữ liệu, và nếu cần thì đặt thêm vào bucket một tệp JSON dùng để nhập (import) dữ liệu, phục vụ việc đồng bộ lên môi trường production. Rút gọn lại thì phần quan trọng trông như sau:</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>Còn thứ tôi thật sự gõ hằng ngày là script bọc ngoài:</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>Script này nạp các biến môi trường production trước khi kết xuất, chạy được cho một hoặc cả hai nền tảng, và lo luôn phần đồng bộ lên production mà tôi sẽ nói ngay bên dưới.</p>

<h3 class="relative group">Đồng bộ lên production
    <div id="đồng-bộ-lên-production" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%e1%bb%93ng-b%e1%bb%99-l%c3%aan-production" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Tôi không muốn để sẵn thông tin đăng nhập (credential) của production trên laptop, nên việc triển khai lên production được chia làm hai bước. Đầu tiên là phát hành từ môi trường cục bộ (local): tài nguyên được đẩy lên Tigris, kèm một bản chụp siêu dữ liệu dạng JSON. Sau đó gọi một endpoint API trên production, kèm theo đường dẫn S3, để máy chủ tự nhập siêu dữ liệu về:</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">Trỏ ứng dụng về máy chủ của bạn
    <div id="trỏ-ứng-dụng-về-máy-chủ-của-bạn" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#tr%e1%bb%8f-%e1%bb%a9ng-d%e1%bb%a5ng-v%e1%bb%81-m%c3%a1y-ch%e1%bb%a7-c%e1%bb%a7a-b%e1%ba%a1n" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Trong <code>app.json</code>, bạn khai báo URL cập nhật và phiên bản runtime:</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>Chỗ này phải thật chặt chẽ: <code>runtimeVersion</code> ở ứng dụng và ở máy chủ lúc nào cũng phải khớp nhau. Mỗi khi thay đổi phần mã native hoặc nâng cấp Expo SDK, hãy tăng phiên bản runtime rồi phát hành bản cập nhật mới cho phiên bản đó.</p>

<h2 class="relative group">Lưu trữ trên Tigris
    <div id="lưu-trữ-trên-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="#l%c6%b0u-tr%e1%bb%af-tr%c3%aan-tigris" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Tigris là dịch vụ lưu trữ đối tượng (object storage) tương thích S3, rẻ hơn AWS S3 khá nhiều, lại có sẵn bộ nhớ đệm tại biên (edge cache) trên toàn cầu. Điểm này rất đáng giá khi người dùng phải tải về những gói mã nặng vài MB. Cấu hình phía Django như sau:</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>Còn phần khởi tạo client S3 thì chỉ là boto3 như bình thường:</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>Mọi lượt tải đều đi qua URL ký sẵn, nên ứng dụng lấy tệp thẳng từ 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">Bảo mật
    <div id="bảo-mật" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%e1%ba%a3o-m%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Endpoint manifest được cố ý để không cần xác thực, vì ứng dụng phải nhận được bản cập nhật ngay cả khi chưa có ai đăng nhập. Endpoint nhập dữ liệu thì khác hẳn. Endpoint này ghi được vào cơ sở dữ liệu production, nên bắt buộc phải có một khóa bí mật dùng chung (shared secret), và phép so sánh khóa phải chạy trong thời gian không đổi (constant-time) để chặn tấn công định thời (timing attack):</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>Tính toàn vẹn của tài nguyên thì thiết kế đã lo sẵn: tệp nào cũng được đối chiếu với mã băm SHA-256 của nó, nên tài nguyên nào bị chỉnh sửa sẽ bị loại ngay. Ngoài ra, URL ký sẵn hết hạn sau một giờ, nên không ai dùng liên kết trực tiếp (hotlink) tới gói mã của bạn mãi được.</p>

<h2 class="relative group">Giữ cho hệ thống chạy nhanh
    <div id="giữ-cho-hệ-thống-chạy-nhanh" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#gi%e1%bb%af-cho-h%e1%bb%87-th%e1%bb%91ng-ch%e1%ba%a1y-nhanh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nhờ chỉ mục kết hợp (composite index) trên <code>(runtime_version, platform, is_active, -created_at)</code>, truy vấn lấy manifest vẫn nhanh dù bản cập nhật có tích lại nhiều đến đâu:</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>Script phát hành tải tài nguyên lên song song:</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>Với một bản cập nhật điển hình có khoảng 50 tài nguyên, thời gian phát hành giảm từ chừng 2 phút xuống còn 15 giây. Khâu phân phối thì không phải làm gì cả: Tigris tự lưu đệm tài nguyên ở các điểm biên (edge location) gần người dùng của bạn.</p>

<h2 class="relative group">Quy trình làm việc hằng ngày
    <div id="quy-trình-làm-việc-hằng-ngày" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#quy-tr%c3%acnh-l%c3%a0m-vi%e1%bb%87c-h%e1%ba%b1ng-ng%c3%a0y" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Trong lúc phát triển
    <div id="trong-lúc-phát-triển" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#trong-l%c3%bac-ph%c3%a1t-tri%e1%bb%83n" aria-label="Cọc dấu">#</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">Đưa lên production
    <div id="đưa-lên-production" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%c6%b0a-l%c3%aan-production" aria-label="Cọc dấu">#</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">Khôi phục về phiên bản trước
    <div id="khôi-phục-về-phiên-bản-trước" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#kh%c3%b4i-ph%e1%bb%a5c-v%e1%bb%81-phi%c3%aan-b%e1%ba%a3n-tr%c6%b0%e1%bb%9bc" aria-label="Cọc dấu">#</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">Chi phí thực tế
    <div id="chi-phí-thực-tế" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#chi-ph%c3%ad-th%e1%bb%b1c-t%e1%ba%bf" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Ứng dụng này hiện có khoảng 500 người dùng hoạt động. Trên Tigris, ~200 MB các bản cập nhật tích lũy từ trước tới giờ tốn chừng $0.02/tháng, còn ~50 GB lưu lượng tải ra (egress) mỗi tháng từ các lượt tải bản cập nhật thì khoảng $1.00. Phần Django chạy ké trên chính máy chủ đang chạy API của tôi, nên không phát sinh thêm đồng nào. Tính tròn thì tổng cộng là một đô mỗi tháng. Với mức sử dụng tương tự, EAS Update sẽ rơi vào khoảng $300–500/năm, nên có thể nói hệ thống này hoàn vốn gần như ngay lập tức.</p>

<h2 class="relative group">Giám sát và gỡ lỗi
    <div id="giám-sát-và-gỡ-lỗi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#gi%c3%a1m-s%c3%a1t-v%c3%a0-g%e1%bb%a1-l%e1%bb%97i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phần này chẳng có gì cầu kỳ. Viewset ghi nhật ký (log) mọi yêu cầu lấy manifest:</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>Trên thực tế, Django admin chính là bảng điều khiển. Chỉ cần đăng ký các mô hình là bạn xem và lọc được mọi thứ:</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>Còn ở phía máy khách, <code>expo-updates</code> sẽ cho bạn biết nó đang thấy gì:</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">Những cái bẫy
    <div id="những-cái-bẫy" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-c%c3%a1i-b%e1%ba%aby" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Lệch phiên bản runtime
    <div id="lệch-phiên-bản-runtime" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#l%e1%bb%87ch-phi%c3%aan-b%e1%ba%a3n-runtime" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Lỗi hay gặp nhất cũng là lỗi khó phát hiện nhất: máy khách chỉ tải những bản cập nhật khớp với phiên bản runtime của mình. Nếu ứng dụng đã cài đang ở runtime <code>1.0.0</code> mà bạn lại phát hành cho <code>1.0.1</code>, thì sẽ chẳng có bản cập nhật nào về máy, mà cũng chẳng có lỗi nào báo ra. Hãy giữ phiên bản runtime đồng bộ với các bản dựng (build), và chỉ tăng nó khi phần mã native thay đổi.</p>

<h3 class="relative group">Dấu thời gian createdAt
    <div id="dấu-thời-gian-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="#d%e1%ba%a5u-th%e1%bb%9di-gian-createdat" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Thư viện <code>expo-updates</code> phía máy khách so sánh <code>createdAt</code> trong manifest với <code>commitTime</code> của gói mã nhúng sẵn, và chỉ áp dụng bản cập nhật nào có <code>createdAt</code> mới hơn. Trong viewset, tôi ghi đè giá trị này bằng dấu thời gian (timestamp) lấy từ cơ sở dữ liệu:</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>Khi phát triển trên máy cục bộ, nếu bạn dựng lại bản nhị phân của ứng dụng <em>sau khi</em> đã phát hành OTA, hãy phát hành lại bản OTA đó để dấu thời gian của nó mới hơn.</p>

<h3 class="relative group">Giá trị <code>key</code> của tài nguyên trong manifest
    <div id="giá-trị-key-của-tài-nguyên-trong-manifest" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#gi%c3%a1-tr%e1%bb%8b-key-c%e1%bb%a7a-t%c3%a0i-nguy%c3%aan-trong-manifest" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Trường <code>key</code> trên mỗi tài nguyên trong manifest là thứ <code>expo-updates</code> dùng để lưu đệm, và giá trị này phải là một mã băm tất định (MD5 của tên tệp là được), chứ không phải UUID ngẫu nhiên hay một chuỗi tùy ý. Làm sai chỗ này thì máy khách có thể không lưu đệm hoặc không lấy lại được tài nguyên đúng cách, và bản cập nhật sẽ âm thầm hỏng sau lần tải thành công đầu tiên. Cảm ơn bạn đọc Raphael Mutschler đã chỉ ra lỗi này: bản cập nhật bên anh ấy chỉ chạy được đúng một lần, rồi anh mới tìm ra nguyên nhân.</p>

<h3 class="relative group">Cấu hình ứng dụng trong <code>expoClient</code>
    <div id="cấu-hình-ứng-dụng-trong-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="#c%e1%ba%a5u-h%c3%acnh-%e1%bb%a9ng-d%e1%bb%a5ng-trong-expoclient" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Nếu ứng dụng của bạn dùng <code>Linking</code>, <code>Constants</code>, hay bất cứ thứ gì đọc cấu hình ứng dụng (app config) lúc chạy, thì trường <code>extra.expoClient</code> trong manifest phải chứa cấu hình đó. Thiếu trường này, lúc đầu ứng dụng có thể vẫn mở lên bình thường, nhưng sau khi bị đóng thì bị sập (crash) hoặc không chịu mở lại nữa. Lý do là <code>expo-updates</code> thay manifest nhúng sẵn bằng manifest OTA, và nếu thiếu <code>expoClient</code> thì các API kia mất luôn phần cấu hình mà chúng dựa vào. Lỗi này cũng do Raphael Mutschler phát hiện.</p>

<h3 class="relative group">Dọn dẹp tài nguyên
    <div id="dọn-dẹp-tài-nguyên" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#d%e1%bb%8dn-d%e1%ba%b9p-t%c3%a0i-nguy%c3%aan" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Các bản cập nhật cũ cứ thế chất đống trên Tigris. Hiện tại tôi vẫn dọn bằng tay:</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>Rõ ràng đoạn này nên được đưa vào một tác vụ chạy định kỳ (scheduled task). Chỉ là tôi chưa có thời gian làm.</p>

<h2 class="relative group">Những thứ tôi muốn làm tiếp
    <div id="những-thứ-tôi-muốn-làm-tiếp" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-th%e1%bb%a9-t%c3%b4i-mu%e1%bb%91n-l%c3%a0m-ti%e1%ba%bfp" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><em>(Tháng 9 năm 2026: Patch giờ đã có sẵn cả ba thứ này và hơn thế nữa; xem <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-13-moving-to-codemagic-patch/" >bài viết về đợt di chuyển</a>.)</em></p>

<h3 class="relative group">Phát hành theo từng đợt
    <div id="phát-hành-theo-từng-đợt" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ph%c3%a1t-h%c3%a0nh-theo-t%e1%bb%abng-%c4%91%e1%bb%a3t" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Chỉ cần thêm một trường <code>rollout_percentage</code> là có thể phát hành bản cập nhật cho một phần người dùng trước:</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">Tách riêng bản cập nhật cho staging
    <div id="tách-riêng-bản-cập-nhật-cho-staging" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#t%c3%a1ch-ri%c3%aang-b%e1%ba%a3n-c%e1%ba%adp-nh%e1%ba%adt-cho-staging" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Thêm một trường <code>environment</code> thì các bản dựng cho môi trường staging có thể nhận bản cập nhật khác với production:</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">Thống kê lượt tải
    <div id="thống-kê-lượt-tải" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#th%e1%bb%91ng-k%c3%aa-l%c6%b0%e1%bb%a3t-t%e1%ba%a3i" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Và một mô hình nhỏ nữa sẽ trả lời đàng hoàng câu hỏi “rốt cuộc đã có ai nhận được bản này chư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="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">Tổng kết
    <div id="tổng-kết" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#t%e1%bb%95ng-k%e1%ba%bft" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Toàn bộ hệ thống chỉ gồm hai mô hình và vài view của Django, một script phát hành, và một đô mỗi tháng tiền hạ tầng. Ít hơn nhiều so với tôi nghĩ lúc bắt đầu, và từ đó tới nay nó vẫn lặng lẽ làm tốt việc của mình cho ứng dụng trên cả hai nền tảng.</p>
<p>Phần mã ở trên là các ví dụ đã được rút gọn từ chính ứng dụng đang chạy production đó, nên bạn cứ điều chỉnh cho hợp với dự án của mình. Còn nếu muốn bắt đầu từ một thứ đã chạy được sẵn, bạn đọc Raphael Mutschler cũng đã công bố một phiên bản độc lập của riêng anh ấy tại <a href="https://github.com/mutschler/expo-ota-server"  target="_blank" rel="noreferrer">expo-ota-server</a>.</p>

<h3 class="relative group">Nếu bạn không muốn tự viết máy chủ
    <div id="nếu-bạn-không-muốn-tự-viết-máy-chủ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#n%e1%ba%bfu-b%e1%ba%a1n-kh%c3%b4ng-mu%e1%bb%91n-t%e1%bb%b1-vi%e1%ba%bft-m%c3%a1y-ch%e1%bb%a7" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Từ lúc tôi viết bài này, đã có hai thứ xuất hiện làm được cùng việc mà không cần tới Django. Thứ nhất là <code>expo-ota-server</code> của Raphael ở trên. Thứ hai là <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">mã nguồn mở</a>, chạy trên máy của chính bạn qua một bộ cài Docker Compose, và nói giao thức CodePush thay vì Expo Updates, nên bạn thay <code>expo-updates</code> bằng SDK của nó và thay <code>eas update</code> bằng <code>cmpatch release-react</code>. Nó dùng được kho lưu trữ tương thích S3, nên một bucket Tigris như trong bài này cũng chạy được.</p>
<p>Ngay từ đầu, nó đã có sẵn những thứ tôi liệt kê ở mục &ldquo;Những thứ tôi muốn làm tiếp&rdquo; mà chưa bao giờ xây: môi trường triển khai staging và production riêng, phát hành theo tỷ lệ phần trăm, số lượt tải và cài của từng bản, cập nhật bắt buộc, bản vá nhị phân, ký mã (code signing), và khôi phục (rollback) ngay trên thiết bị khi gói mã mới bị sập trước khi ứng dụng kịp báo sẵn sàng. Cái giá phải trả là một bản nhị phân native mới trước khi bất kỳ ai nhận được bản cập nhật qua Patch, vì SDK phải có trong ứng dụng, và một máy chủ phải vận hành thay cho một lệnh quản trị.</p>
<p>Tôi đã chuyển một ứng dụng của chính mình, với dự án native đã commit và quy trình phát hành thật, từ hệ thống trong bài này sang Patch. <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-13-moving-to-codemagic-patch/" >Bài viết đó</a> kể lại những gì chạy tốt, những gì không, và thật sự thì khó tới mức nào. Codemagic tài trợ cho phần việc đó và có một lượt đọc duyệt về tính chính xác của sự kiện, chứ không phải duyệt biên tập.</p>
<hr>
<p><em>Muốn đọc toàn bộ đặc tả, bạn tham khảo <a href="https://docs.expo.dev/technical-specs/expo-updates-1/"  target="_blank" rel="noreferrer">tài liệu đặc tả giao thức Expo Updates</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>Chạy LLM local trên điện thoại với React Native: Qwen offline bằng llama.rn và llama.cpp</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-01-30-on-device-ai/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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>Cách tôi chạy một LLM nhỏ hoàn toàn trên điện thoại trong ứng dụng React Native và Expo: llama.rn, mô hình Qwen định dạng GGUF ở mức Q4_K_M, truyền dần kết quả (streaming), cách gọi công cụ mà mô hình nhỏ làm được, và số đo thật trên iPhone và Galaxy S23.</description>
      <content:encoded><![CDATA[<p>Trợ lý AI trong ứng dụng báo giá mà tôi đang làm chạy hoàn toàn trên điện thoại. Không có dữ liệu nào rời khỏi máy. Người dùng chỉ cần gõ tiếng Anh thông thường là có thể tạo công việc (job), tìm khách hàng và quản lý dự án, và mọi thứ vẫn chạy ngon lành kể cả khi bật chế độ máy bay.</p>
<p>Trong bài này, tôi sẽ kể lại cách mình làm ra nó với <strong>llama.rn</strong> (thư viện kết nối React Native với llama.cpp) và <strong>Qwen 1.7B</strong>, một mô hình nhỏ nhưng hóa ra làm được nhiều hơn tôi tưởng.</p>
<p>Tóm tắt nhanh: cài llama.rn, tải một mô hình GGUF 4-bit (khoảng 1 GB) về ở lần đầu mở ứng dụng, nạp nó bằng <code>initLlama</code>, rồi cho nó sinh câu trả lời theo kiểu truyền dần (streaming). Trên iPhone 14 Pro, tốc độ đạt khoảng 15 token mỗi giây mà không cần chút mạng nào. Phần còn lại của bài là những gì tôi phải làm để biến nó thành thứ thực sự dùng được: chọn mô hình, khiến nó gọi công cụ (tool call) một cách ổn định, và giữ mức dùng bộ nhớ trong tầm kiểm soát.</p>
<blockquote><p><strong>📝 Cập nhật:</strong> Bản hiện tại đã chuyển sang <a href="https://huggingface.co/unsloth/Qwen3.5-2B-GGUF"  target="_blank" rel="noreferrer">Qwen3.5-2B-GGUF</a> thay cho Qwen3-1.7B ban đầu. Tôi cũng bỏ luôn mẹo chèn <code>/no_think</code> vào tin nhắn, giờ thì tắt chế độ suy nghĩ (thinking mode) đúng cách qua tham số <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>Gọn gàng hơn nhiều so với kiểu lén nhét cờ điều khiển vào prompt.</p>
</blockquote>
<h2 class="relative group">Vì sao tôi không muốn dùng AI trên đám mây cho việc này
    <div id="vì-sao-tôi-không-muốn-dùng-ai-trên-đám-mây-cho-việc-này" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#v%c3%ac-sao-t%c3%b4i-kh%c3%b4ng-mu%e1%bb%91n-d%c3%b9ng-ai-tr%c3%aan-%c4%91%c3%a1m-m%c3%a2y-cho-vi%e1%bb%87c-n%c3%a0y" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Cách làm tính năng AI quen thuộc trong ứng dụng di động là: người dùng gõ tin nhắn, ứng dụng gửi nó sang một nhà cung cấp mô hình trên đám mây, nhận câu trả lời về, và hóa đơn lại nhích lên một chút.</p>
<p>Nhưng với một ứng dụng mà thợ thi công dùng để điều hành việc làm ăn của chính họ, cách làm đó có vấn đề. Tên, địa chỉ, số điện thoại của khách hàng bị gửi cho bên thứ ba. Chi tiết dự án, giá cả và ghi chú thì nằm trên máy chủ của người khác. Rồi còn những câu hỏi về GDPR và nơi lưu trữ dữ liệu mà thật lòng tôi không muốn mất thời gian vào. Chưa kể hóa đơn API cứ tăng dần theo số người dùng.</p>
<p>Cách còn lại là chạy mô hình ngay trên điện thoại của người dùng, và tôi đã chọn cách đó.</p>

<h2 class="relative group">Vì sao đến giờ chuyện này mới khả thi
    <div id="vì-sao-đến-giờ-chuyện-này-mới-khả-thi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#v%c3%ac-sao-%c4%91%e1%ba%bfn-gi%e1%bb%9d-chuy%e1%bb%87n-n%c3%a0y-m%e1%bb%9bi-kh%e1%ba%a3-thi" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nếu là một năm trước thì tôi đã chẳng buồn thử. Nhưng có ba thứ thay đổi gần như cùng lúc. Thứ nhất, mô hình lượng tử hóa (quantized) đã đủ nhỏ: Qwen 1.7B ở mức Q4_K_M chỉ nặng 1.1 GB, nhẹ hơn phần lớn các game, tải về một lần là nằm luôn trong bộ lưu trữ của ứng dụng. Thứ hai, GPU trên điện thoại đã đủ nhanh: llama.cpp dùng Metal trên iOS và OpenCL trên GPU Qualcomm của các máy Android đời mới, và trên iPhone 14 Pro tôi đo được khoảng 15 token mỗi giây, thừa sức để truyền dần câu trả lời (streaming) theo thời gian thực. Thứ ba, mô hình nhỏ giờ đã thực sự dùng được: Qwen 1.7B làm theo chỉ dẫn có cấu trúc, phân tích được JSON và suy luận qua nhiều bước. Logic nghiệp vụ cần đúng những khả năng đó. Có ai bắt nó làm thơ đâu.</p>

<h2 class="relative group">Chọn mô hình
    <div id="chọn-mô-hình" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ch%e1%bb%8dn-m%c3%b4-h%c3%acnh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Tôi đã thử bốn mô hình nhỏ trước khi chốt Qwen3-1.7B.</p>
<p><strong>TinyLlama 1.1B</strong> (637 MB) chạy nhanh nhất, nhưng cứ bịa ra ID khách hàng và bỏ sót các trường bắt buộc, nên đầu ra có cấu trúc thì không tin được. <strong>Phi-3-mini</strong> (1.8 GB) suy luận tốt nhưng nói nhiều không chịu nổi; hỏi một câu đơn giản, nó trả về cả bài văn 200 chữ trong khi tôi chỉ cần 20. <strong>Gemma-2B</strong> (1.2 GB) nhanh, phân loại chính xác, nhưng gọi hàm lại yếu: nó không sinh ra đều đặn được các thẻ <code>&lt;action&gt;</code> mà hệ thống công cụ của tôi cần. <strong>Qwen3-1.7B</strong> (1.1 GB) thì vừa khéo: đầu ra có cấu trúc ổn định, làm theo chỉ dẫn chính xác, lại hỗ trợ chuỗi suy luận (chain-of-thought) qua thẻ <code>&lt;think&gt;</code>.</p>
<p>Q4_K_M là một trong các định dạng &ldquo;k-quant&rdquo; của llama.cpp: trọng số được lưu thành giá trị 4-bit theo từng khối, mỗi khối mang hệ số tỉ lệ (scale) riêng, còn biến thể M giữ một vài tensor nhạy cảm ở mức 6-bit. Nhờ vậy tệp mô hình nhỏ hơn khoảng 70% so với trọng số 16-bit, mà chất lượng chỉ giảm chút ít.</p>

<h2 class="relative group">Cài đặt llama.rn
    <div id="cài-đặt-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="#c%c3%a0i-%c4%91%e1%ba%b7t-llamarn" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>llama.rn bọc llama.cpp lại để dùng được trong React Native, và cài đặt là phần dễ nhất:</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>Nếu dùng Expo thì cần lưu ý: llama.rn là một native module, nên không chạy được trong Expo Go. Bạn thêm config plugin của nó vào và dùng bản 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>Với bản build production, plugin này còn thêm hai entitlement iOS mà llama.rn khuyến nghị cho mô hình lớn: mở rộng vùng địa chỉ ảo (extended virtual addressing) và nâng giới hạn bộ nhớ (increased memory limit).</p>
<p>Còn bản thân mô hình thì được tải xuống ở lần dùng đầu tiên:</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>Qua WiFi thì mất 2-3 phút, qua LTE thì khoảng 5-8 phút. Khi tệp đã nằm trên máy, bạn nạp nó lên và bật tăng tốc bằng 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>Đừng bỏ qua <code>n_gpu_layers: 99</code>. Đẩy toàn bộ việc tính toán sang Metal hoặc OpenCL thay vì để CPU gánh sẽ giúp tốc độ tăng khoảng 5 lần.</p>

<h2 class="relative group">Suy luận và truyền dần kết quả
    <div id="suy-luận-và-truyền-dần-kết-quả" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#suy-lu%e1%ba%adn-v%c3%a0-truy%e1%bb%81n-d%e1%ba%a7n-k%e1%ba%bft-qu%e1%ba%a3" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Chẳng ai muốn ngồi nhìn biểu tượng đang tải xoay vòng suốt mười giây, nên quá trình suy luận (inference) trả câu trả lời ra dần từng token một:</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>Trên điện thoại đời mới, token đầu tiên xuất hiện sau khoảng 200ms (đó là thời gian xử lý prompt), sau đó mỗi token mất 50-80ms. So với bất cứ thứ gì phải chờ mạng, cảm giác gần như tức thì.</p>

<h2 class="relative group">Gọi hàm theo cách mô hình nhỏ làm được thật
    <div id="gọi-hàm-theo-cách-mô-hình-nhỏ-làm-được-thật" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#g%e1%bb%8di-h%c3%a0m-theo-c%c3%a1ch-m%c3%b4-h%c3%acnh-nh%e1%bb%8f-l%c3%a0m-%c4%91%c6%b0%e1%bb%a3c-th%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Cơ chế gọi hàm (function calling) của các mô hình lớn trên đám mây dựa vào JSON schema, và mô hình nhỏ gặp nó là &ldquo;ngã&rdquo;: JSON sai định dạng, thiếu trường, đủ cả. Vì vậy tôi dùng một giao thức đơn giản hơn, dựa trên 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>Thẻ XML thắng vì mấy lý do rất bình thường: <code>&lt;action&gt;</code> và <code>&lt;/action&gt;</code> là dấu phân cách rõ ràng, phân tích chỉ cần một biểu thức chính quy (regex), các ví dụ trong prompt kiêm luôn vai trò tài liệu, và cách này vẫn chạy được khi mô hình nói thêm vài câu lan man quanh thẻ.</p>
<p>Phần phân tích thì đơn giản đúng như vẻ ngoài của nó:</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>Khi mô hình trả về một hành động (action), tôi thực thi nó rồi đưa kết quả ngược lại vào cuộc hội thoại:</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>Chừng đó đã đủ để chạy các luồng xử lý nhiều bước đúng nghĩa:</p>
<p><strong>Người dùng</strong>: &ldquo;Create a job for Smith&rdquo;</p>
<ol>
<li><strong>Mô hình</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>Hệ thống</strong>: <code>[TOOL_RESULT] Found: Jane Smith (id:42), Bob Smith (id:89)</code></li>
<li><strong>Mô hình</strong>: &ldquo;I found two Smiths—Jane and Bob. Which one?&rdquo;</li>
<li><strong>Người dùng</strong>: &ldquo;Jane&rdquo;</li>
<li><strong>Mô hình</strong>: <code>&lt;action&gt;{&quot;type&quot;:&quot;create_job&quot;,&quot;customer_id&quot;:42}&lt;/action&gt;</code></li>
<li><strong>Hệ thống</strong>: <code>[TOOL_RESULT] Job J-0073 created</code></li>
<li><strong>Mô hình</strong>: &ldquo;Done! Created job J-0073 for Jane Smith.&rdquo;</li>
</ol>

<h2 class="relative group">Chuỗi suy luận với thẻ &lt;think&gt;
    <div id="chuỗi-suy-luận-với-thẻ-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="#chu%e1%bb%97i-suy-lu%e1%ba%adn-v%e1%bb%9bi-th%e1%ba%bb-think" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Mô hình nhỏ làm tốt hơn thấy rõ khi bạn bắt nó suy nghĩ thành lời. Qwen có sẵn chế độ suy nghĩ:</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>Bên trong, mô hình sẽ sinh ra nội dung trông như thế này:</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>Tôi lọc bỏ các thẻ <code>&lt;think&gt;</code> trước khi hiển thị bất cứ thứ gì lên giao diện, nhưng trong lúc phát triển thì vẫn để chúng hiện ra. Độ ổn định khác nhau một trời một vực: mô hình tự lý luận qua từng bước rồi mới chốt một hành động.</p>

<h2 class="relative group">Giữ các cuộc hội thoại dài nằm gọn trong 8K
    <div id="giữ-các-cuộc-hội-thoại-dài-nằm-gọn-trong-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="#gi%e1%bb%af-c%c3%a1c-cu%e1%bb%99c-h%e1%bb%99i-tho%e1%ba%a1i-d%c3%a0i-n%e1%ba%b1m-g%e1%bb%8dn-trong-8k" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Một khi kết quả từ công cụ bắt đầu dồn vào, cửa sổ ngữ cảnh (context window) 8K đầy nhanh hơn bạn tưởng. Tầm lượt thứ 30 là tôi chạm trần.</p>
<p>Cách tôi xử lý là nén cuộc hội thoại lại, bằng cách nhờ chính mô hình tự tóm tắt:</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>Một cuộc hội thoại 50 tin nhắn được ép xuống còn khoảng 150 token, mà mô hình vẫn tiếp tục bình thường, không quên mình đã tạo những công việc nào, với ID khách hàng nào.</p>

<h2 class="relative group">Lưu phiên trò chuyện
    <div id="lưu-phiên-trò-chuyện" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#l%c6%b0u-phi%c3%aan-tr%c3%b2-chuy%e1%bb%87n" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Các phiên trò chuyện cũng được lưu xuống máy chủ (backend) của ứng dụng. Điều đáng học ở đây là lưu mỗi tin nhắn của trợ lý thành hai bản: một bản đã làm sạch để hiển thị, và một bản thô còn nguyên thẻ <code>&lt;think&gt;</code> và <code>&lt;action&gt;</code>. Nhờ vậy, lịch sử trò chuyện hiển thị lại gọn gàng trên giao diện, còn khi mở lại một phiên cũ thì mô hình được nạp lại đầy đủ ngữ cảnh, kể cả các hành động. Người dùng chuyển qua lại giữa các cuộc hội thoại bằng một danh sách thả xuống chứa lịch sử, giống như phần lớn các ứng dụng trò chuyện.</p>

<h2 class="relative group">Số đo hiệu năng trên môi trường production
    <div id="số-đo-hiệu-năng-trên-môi-trường-production" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#s%e1%bb%91-%c4%91o-hi%e1%bb%87u-n%c4%83ng-tr%c3%aan-m%c3%b4i-tr%c6%b0%e1%bb%9dng-production" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><strong>iPhone 14 Pro</strong> (A16 Bionic, 6GB RAM):</p>
<ul>
<li>Nạp mô hình: ~3 giây</li>
<li>Token đầu tiên: 180-220ms</li>
<li>Tốc độ sinh: 14-16 token/giây</li>
<li>Bộ nhớ: ~1.8 GB</li>
</ul>
<p><strong>Samsung Galaxy S23</strong> (Snapdragon 8 Gen 2, 8GB RAM):</p>
<ul>
<li>Nạp mô hình: ~4 giây</li>
<li>Token đầu tiên: 250-300ms</li>
<li>Tốc độ sinh: 10-12 token/giây</li>
<li>Bộ nhớ: ~2.1 GB</li>
</ul>
<p><strong>iPhone 11</strong> (A13, 4GB RAM):</p>
<ul>
<li>Nạp mô hình: ~6 giây</li>
<li>Token đầu tiên: 400-500ms</li>
<li>Tốc độ sinh: 6-8 token/giây</li>
<li>Bộ nhớ: ~2.2 GB (thỉnh thoảng bị sập vì thiếu bộ nhớ)</li>
</ul>
<p>Kinh nghiệm của tôi là cần từ 6GB RAM trở lên thì mới chạy mượt. Máy 4GB vẫn chạy được, nhưng nhiều khả năng bạn sẽ phải giải phóng mô hình mỗi khi ứng dụng chuyển xuống chạy nền.</p>

<h2 class="relative group">Quản lý bộ nhớ
    <div id="quản-lý-bộ-nhớ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#qu%e1%ba%a3n-l%c3%bd-b%e1%bb%99-nh%e1%bb%9b" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>iOS sẵn sàng buộc đóng ứng dụng của bạn nếu nó ôm quá nhiều bộ nhớ, nên cứ mỗi lần ứng dụng rời khỏi màn hình chính (không còn chạy ở tiền cảnh) là tôi giải phóng mô hình:</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">Ảnh hưởng tới pin
    <div id="ảnh-hưởng-tới-pin" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e1%ba%a3nh-h%c6%b0%e1%bb%9fng-t%e1%bb%9bi-pin" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Chạy suy luận ngay trên máy thì cũng tốn pin chứ không phải miễn phí. Đây là số liệu tôi đo được khi dùng thực tế:</p>
<table>
	<thead>
			<tr>
					<th>Mức độ sử dụng</th>
					<th>Hao pin thêm</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Nhẹ (5-10 truy vấn/ngày)</td>
					<td>&lt;1% mỗi ngày</td>
			</tr>
			<tr>
					<td>Vừa (20-30 truy vấn/ngày)</td>
					<td>~3-4% mỗi ngày</td>
			</tr>
			<tr>
					<td>Nặng (50+ truy vấn/ngày)</td>
					<td>~6-8% mỗi ngày</td>
			</tr>
	</tbody>
</table>
<p>Suy luận bằng GPU tốn điện hơn CPU nhưng xong nhanh hơn nhiều, mà người dùng thì rõ ràng thích được trả lời tức thì, nên tôi vẫn giữ GPU.</p>

<h2 class="relative group">Sau hai tháng, với khoảng 200 người dùng bản beta
    <div id="sau-hai-tháng-với-khoảng-200-người-dùng-bản-beta" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#sau-hai-th%c3%a1ng-v%e1%bb%9bi-kho%e1%ba%a3ng-200-ng%c6%b0%e1%bb%9di-d%c3%b9ng-b%e1%ba%a3n-beta" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Cơ cấu các truy vấn làm tôi hơi bất ngờ:</p>
<ol>
<li>&ldquo;Create a job for [customer name]&rdquo; - 68% số truy vấn</li>
<li>&ldquo;Find jobs for [customer]&rdquo; - 15%</li>
<li>&ldquo;Update job [ID] to [status]&rdquo; - 9%</li>
<li>Câu hỏi chung chung về ứng dụng - 8%</li>
</ol>
<p>91% truy vấn thành công ngay lần đầu. Số còn lại thất bại vì ba lý do: không tìm thấy khách hàng do người dùng gõ sai tên (4%), tràn ngữ cảnh trong những cuộc hội thoại rất dài (3%), và mô hình tự bịa ra ID khách hàng (2%).</p>
<p>Lỗi cuối cùng này trước đây còn tệ hơn nhiều. Chuyện bịa ID gần như biến mất sau khi tôi trả kết quả của công cụ về ở dạng tường minh, kiểu <code>customer_id=42</code>, thay vì một câu văn. Với mô hình nhỏ, bạn phải dựng sẵn &ldquo;giàn giáo&rdquo; đó ra thật rõ ràng.</p>

<h2 class="relative group">Chi phí bao nhiêu
    <div id="chi-phí-bao-nhiêu" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#chi-ph%c3%ad-bao-nhi%c3%aau" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Chạy trên máy: $0/tháng tiền hạ tầng, $0 cho mỗi người dùng ngoài lần tải 1.1 GB duy nhất, và có bao nhiêu người muốn cài ứng dụng thì nó cũng mở rộng được bấy nhiêu.</p>
<p>Còn nếu làm bản chạy trên đám mây, ước tính sơ sơ thế này: một cuộc hội thoại trung bình khoảng 2,000 token, 200 người dùng mỗi người 30 cuộc hội thoại một tháng là 6,000 cuộc hội thoại, tức 12M token/tháng. Tính ra khoảng $18/tháng với một mô hình đám mây giá rẻ, và $180-360/tháng với các mô hình hàng đầu. Ở quy mô này thì chưa đến mức phá sản, nhưng chạy trên máy thì hoàn vốn ngay từ ngày đầu, và kể cả khi lên tới 10,000 người dùng cũng vẫn không tốn đồng nào.</p>

<h2 class="relative group">Những giới hạn nên biết
    <div id="những-giới-hạn-nên-biết" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-gi%e1%bb%9bi-h%e1%ba%a1n-n%c3%aan-bi%e1%ba%bft" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><strong>Năng lực của mô hình.</strong> Qwen 1.7B làm các tác vụ có cấu trúc rất tốt, nhưng lại rất dở ở suy luận phức tạp qua nhiều bước, nhớ kiến thức (nó không phải công cụ tìm kiếm), viết sáng tạo và những việc cần sự tinh tế. Cứ thiết kế tính năng xoay quanh những gì mô hình nhỏ làm giỏi là ổn.</p>
<p><strong>Yêu cầu thiết bị.</strong> Tối thiểu là 3 GB RAM và 2 GB bộ nhớ trống; thực tế thì bạn nên có 6 GB RAM, chip 64-bit và hỗ trợ GPU. Máy đời cũ như iPhone 8 hay Galaxy S9 sẽ chạy rất chật vật, nên hãy kiểm tra khả năng của thiết bị trước và có phương án dự phòng cho êm.</p>
<p><strong>Cập nhật mô hình.</strong> Mỗi lần phát hành mô hình mới là người dùng lại phải tải lại 1.1 GB. Tôi đánh số phiên bản cho mô hình ngay trong đường dẫn lưu trữ:</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>Mỗi phiên bản mô hình mới thì tăng số trong đường dẫn lên. Mô hình cũ sẽ tự được dọn đi khi người dùng gỡ ứng dụng.</p>

<h2 class="relative group">iOS vs Android
    <div id="ios-vs-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-vs-android" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Trên iOS, Metal chạy nhanh hơn Android khoảng 20%, bộ nhớ được quản lý tốt hơn, và mặc định mô hình sẽ được sao lưu lên iCloud (tắt đi bằng <code>NSURLIsExcludedFromBackupKey</code>, trừ khi bạn muốn ngốn dung lượng sao lưu của người dùng). Trên Android, chênh lệch giữa các máy lớn hơn nhiều: llama.rn chỉ tăng tốc bằng GPU qua OpenCL trên các GPU Qualcomm Adreno dòng 700, máy nào không có thì chạy bằng CPU, còn dữ liệu lưu trữ thì không được sao lưu tự động. Hãy kiểm thử trên cả hai nền tảng; khoảng cách giữa một máy Android tốt và một máy Android kém còn lớn hơn cả khoảng cách giữa hai nền tảng.</p>

<h2 class="relative group">Mẹo gỡ lỗi
    <div id="mẹo-gỡ-lỗi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#m%e1%ba%b9o-g%e1%bb%a1-l%e1%bb%97i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><strong>1. Bật ghi nhật ký chi tiết (verbose logging)</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. Theo dõi số token</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">tokens</span> <span class="o">=</span> <span class="nx">fullResponse</span><span class="p">.</span><span class="nx">split</span><span class="p">(</span><span class="sr">/\s+/</span><span class="p">).</span><span class="nx">length</span> <span class="o">*</span> <span class="mf">1.3</span><span class="p">;</span> <span class="c1">// Rough estimate
</span></span></span><span class="line"><span class="cl"><span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="sb">`Generated </span><span class="si">${</span><span class="nx">tokens</span><span class="si">}</span><span class="sb"> tokens in </span><span class="si">${</span><span class="nx">duration</span><span class="si">}</span><span class="sb">ms`</span><span class="p">);</span></span></span></code></pre></div></div>
<p><strong>3. Giám sát bộ nhớ</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. Kiểm thử khi ngoại tuyến (offline)</strong></p>
<p>Bật chế độ máy bay rồi dùng ứng dụng như thật. Mô hình phải nạp được từ bộ nhớ đệm (cache), quá trình suy luận phải chạy xong, và chỉ có phần lưu lên máy chủ là được phép lỗi, mà có lỗi thì cũng phải lỗi êm.</p>

<h2 class="relative group">Tôi muốn đưa nó đi xa hơn thế nào
    <div id="tôi-muốn-đưa-nó-đi-xa-hơn-thế-nào" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#t%c3%b4i-mu%e1%bb%91n-%c4%91%c6%b0a-n%c3%b3-%c4%91i-xa-h%c6%a1n-th%e1%ba%bf-n%c3%a0o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>llama.cpp đã hỗ trợ mô hình thị giác, và một tính năng kiểu &ldquo;đây là ảnh chụp ở công trình, thêm vào công việc này giúp tôi&rdquo; rõ ràng rất hợp với ứng dụng này. Tôi cũng muốn thử tinh chỉnh (fine-tune) một LoRA adapter dựa trên các mô tả công việc cũ; tôi đoán độ chính xác với các truy vấn chuyên ngành sẽ tăng 20-30%. Xa hơn nữa là một mô hình nhận dạng giọng nói chạy ngay trên máy để có giao diện giọng nói chạy hoàn toàn ngoại tuyến, và có thể là học liên kết (federated learning), để mô hình tốt dần lên nhờ nhiều người dùng mà không có chút dữ liệu thô nào rời khỏi điện thoại của họ.</p>

<h2 class="relative group">Bạn có nên làm theo không?
    <div id="bạn-có-nên-làm-theo-không" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%e1%ba%a1n-c%c3%b3-n%c3%aan-l%c3%a0m-theo-kh%c3%b4ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nếu bạn đang làm ứng dụng doanh nghiệp có dữ liệu nhạy cảm, quy trình cần chạy được khi ngoại tuyến, hay các tác vụ có cấu trúc như phân loại và nhập liệu, và nhất là nếu bạn lo chi phí API khi mở rộng quy mô, thì câu trả lời là có, cách này dùng được ngay từ hôm nay.</p>
<p>Còn nếu bạn cần trò chuyện tự do về đủ mọi chủ đề, cứ dùng các mô hình lớn trên đám mây. Tương tự với tác vụ đòi hỏi nhiều kiến thức (mô hình nhỏ đơn giản là không biết nhiều), khi bạn buộc phải hỗ trợ máy cấu hình thấp, hoặc khi kiến thức chuyên ngành của bạn thay đổi nhanh hơn tốc độ bạn phát hành bản cập nhật mô hình.</p>
<p>Toàn bộ hệ thống này tôi làm mất khoảng 12 tiếng. Chi phí vận hành là $0 mỗi tháng, dùng được cả trên máy bay, và dữ liệu khách hàng không bao giờ rời khỏi tay khách hàng. Khó mà chê được.</p>
<hr>
<p><em>Có câu hỏi hay góp ý? Bạn tìm tôi ở <a href="https://twitter.com/jaredlynskey"  target="_blank" rel="noreferrer">@jaredlynskey</a>. Thư viện llama.rn nằm ở <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>
