<?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/"><channel><title>國際化 on Tarragon</title><link>https://tarrragon.github.io/blog/tags/%E5%9C%8B%E9%9A%9B%E5%8C%96/</link><description>Recent content in 國際化 on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Wed, 04 Mar 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/%E5%9C%8B%E9%9A%9B%E5%8C%96/index.xml" rel="self" type="application/rss+xml"/><item><title>分層 i18n 管理方法論 - 業務層國際化的正確實踐</title><link>https://tarrragon.github.io/blog/record/%E5%88%86%E5%B1%A4-i18n-%E7%AE%A1%E7%90%86%E6%96%B9%E6%B3%95%E8%AB%96-%E6%A5%AD%E5%8B%99%E5%B1%A4%E5%9C%8B%E9%9A%9B%E5%8C%96%E7%9A%84%E6%AD%A3%E7%A2%BA%E5%AF%A6%E8%B8%90/</link><pubDate>Wed, 04 Mar 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/record/%E5%88%86%E5%B1%A4-i18n-%E7%AE%A1%E7%90%86%E6%96%B9%E6%B3%95%E8%AB%96-%E6%A5%AD%E5%8B%99%E5%B1%A4%E5%9C%8B%E9%9A%9B%E5%8C%96%E7%9A%84%E6%AD%A3%E7%A2%BA%E5%AF%A6%E8%B8%90/</guid><description>&lt;p>在開發書庫管理 Flutter 應用的過程中，我們陷入了一個多語言專案幾乎必經的事故：i18n 訊息出現在不該出現的地方。Domain 層的 repository 開始直接拋出中文錯誤訊息，ViewModel 裡散落著硬編碼字串，UI 層還要自己判斷 errorCode 決定顯示什麼文字。每增加一種語言，就要在三個地方同時動手。&lt;/p></description><content:encoded><![CDATA[<p>在開發書庫管理 Flutter 應用的過程中，我們陷入了一個多語言專案幾乎必經的事故：i18n 訊息出現在不該出現的地方。Domain 層的 repository 開始直接拋出中文錯誤訊息，ViewModel 裡散落著硬編碼字串，UI 層還要自己判斷 errorCode 決定顯示什麼文字。每增加一種語言，就要在三個地方同時動手。</p>
<h2 id="問題的根源">問題的根源</h2>
<p>幾個典型的失控情境：</p>
<p>Domain 層的 Service 直接回傳中文字串作為錯誤訊息，要支援英文時就必須深入業務邏輯層去改。ViewModel 裡出現 <code>errorMessage: '找不到書籍'</code> 這樣的程式碼，看起來無害，但需要國際化時根本不知道有多少個地方要改。UI 層開始承擔它不應該承擔的邏輯，自行根據 errorCode 決定顯示什麼文字。</p>
<p>問題的共同根源：沒有清楚定義每一層對 i18n 的責任邊界。</p>
<h2 id="核心原則">核心原則</h2>
<p>一個簡單的原則統治一切：<strong>Domain 層不知道 UI 呈現方式，UI 層不承擔訊息決策邏輯</strong>。</p>
<p>具體化成三條規則：Domain 層使用技術語言（錯誤碼），ViewModel 層負責轉換為使用者語言，所有使用者可見的字串禁止硬編碼。</p>
<h2 id="domain-層回傳錯誤碼不回傳訊息">Domain 層：回傳錯誤碼，不回傳訊息</h2>
<p>Domain 層用 <code>ErrorCode</code> 枚舉表達失敗狀態，它只知道「找不到書」這個事實，不知道這個事實要用哪種語言告訴使用者：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="n">enum</span> <span class="n">BookErrorCode</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="n">notFound</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="n">invalidIsbn</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="n">networkTimeout</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="n">serverError</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="kd">class</span> <span class="nc">BookRepository</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="n">Future</span><span class="o">&lt;</span><span class="n">Result</span><span class="o">&lt;</span><span class="n">Book</span><span class="p">,</span> <span class="n">BookErrorCode</span><span class="o">&gt;&gt;</span> <span class="n">fetchBook</span><span class="p">(</span><span class="kt">String</span> <span class="n">id</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">      <span class="kd">final</span> <span class="n">response</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">api</span><span class="p">.</span><span class="n">getBook</span><span class="p">(</span><span class="n">id</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">      <span class="k">return</span> <span class="n">Result</span><span class="p">.</span><span class="n">success</span><span class="p">(</span><span class="n">Book</span><span class="p">.</span><span class="n">fromJson</span><span class="p">(</span><span class="n">response</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="p">}</span> <span class="n">on</span> <span class="n">NotFoundException</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">      <span class="k">return</span> <span class="n">Result</span><span class="p">.</span><span class="n">failure</span><span class="p">(</span><span class="n">BookErrorCode</span><span class="p">.</span><span class="n">notFound</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="p">}</span> <span class="n">on</span> <span class="n">TimeoutException</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">      <span class="k">return</span> <span class="n">Result</span><span class="p">.</span><span class="n">failure</span><span class="p">(</span><span class="n">BookErrorCode</span><span class="p">.</span><span class="n">networkTimeout</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>不可違反的規則：不引入任何 i18n 相關的 import，不依賴 <code>BuildContext</code>，不出現任何使用者可見的字串。</p>
<h2 id="viewmodel-層轉換錯誤碼為使用者訊息">ViewModel 層：轉換錯誤碼為使用者訊息</h2>
<p>ViewModel 是整個 i18n 流程的轉換點。三個合法的訊息來源：i18n 系統（靜態訊息）、ErrorHandler 轉換器（集中管理錯誤碼對應）、Domain 層直接回傳的動態訊息。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">class</span> <span class="nc">BookDetailViewModel</span> <span class="kd">extends</span> <span class="n">Notifier</span><span class="o">&lt;</span><span class="n">BookDetailState</span><span class="o">&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="err">@</span><span class="n">override</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="n">BookDetailState</span> <span class="n">build</span><span class="p">()</span> <span class="o">=&gt;</span> <span class="n">BookDetailState</span><span class="p">.</span><span class="n">initial</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="n">Future</span><span class="o">&lt;</span><span class="kt">void</span><span class="o">&gt;</span> <span class="n">loadBook</span><span class="p">(</span><span class="kt">String</span> <span class="n">id</span><span class="p">)</span> <span class="kd">async</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="n">state</span> <span class="o">=</span> <span class="n">state</span><span class="p">.</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">isLoading:</span> <span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="kd">final</span> <span class="n">result</span> <span class="o">=</span> <span class="kd">await</span> <span class="n">ref</span><span class="p">.</span><span class="n">read</span><span class="p">(</span><span class="n">bookRepositoryProvider</span><span class="p">).</span><span class="n">fetchBook</span><span class="p">(</span><span class="n">id</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="n">result</span><span class="p">.</span><span class="n">when</span><span class="p">(</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">      <span class="nl">success:</span> <span class="p">(</span><span class="n">book</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">        <span class="n">state</span> <span class="o">=</span> <span class="n">state</span><span class="p">.</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">isLoading:</span> <span class="kc">false</span><span class="p">,</span> <span class="nl">book:</span> <span class="n">book</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">      <span class="p">},</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">      <span class="nl">failure:</span> <span class="p">(</span><span class="n">errorCode</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl">        <span class="kd">final</span> <span class="n">l10n</span> <span class="o">=</span> <span class="n">ref</span><span class="p">.</span><span class="n">read</span><span class="p">(</span><span class="n">localizationsProvider</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">        <span class="kd">final</span> <span class="n">message</span> <span class="o">=</span> <span class="n">ErrorHandler</span><span class="p">.</span><span class="n">getMessage</span><span class="p">(</span><span class="n">errorCode</span><span class="p">,</span> <span class="nl">l10n:</span> <span class="n">l10n</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">        <span class="n">state</span> <span class="o">=</span> <span class="n">state</span><span class="p">.</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">isLoading:</span> <span class="kc">false</span><span class="p">,</span> <span class="nl">errorMessage:</span> <span class="n">message</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">      <span class="p">},</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">    <span class="p">);</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>ErrorHandler</code> 集中管理所有錯誤碼到 i18n key 的映射，不讓這段邏輯散落在各個 ViewModel：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">class</span> <span class="nc">ErrorHandler</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kd">static</span> <span class="kt">String</span> <span class="n">getMessage</span><span class="p">(</span><span class="n">BookErrorCode</span> <span class="n">code</span><span class="p">,</span> <span class="p">{</span><span class="kd">required</span> <span class="n">AppLocalizations</span> <span class="n">l10n</span><span class="p">})</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">    <span class="k">switch</span> <span class="p">(</span><span class="n">code</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">      <span class="k">case</span> <span class="n">BookErrorCode</span><span class="p">.</span><span class="nl">notFound:</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">        <span class="k">return</span> <span class="n">l10n</span><span class="p">.</span><span class="n">bookNotFound</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">      <span class="k">case</span> <span class="n">BookErrorCode</span><span class="p">.</span><span class="nl">invalidIsbn:</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">        <span class="k">return</span> <span class="n">l10n</span><span class="p">.</span><span class="n">invalidIsbn</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">      <span class="k">case</span> <span class="n">BookErrorCode</span><span class="p">.</span><span class="nl">networkTimeout:</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">        <span class="k">return</span> <span class="n">l10n</span><span class="p">.</span><span class="n">networkTimeout</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">      <span class="k">case</span> <span class="n">BookErrorCode</span><span class="p">.</span><span class="nl">serverError:</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">        <span class="k">return</span> <span class="n">l10n</span><span class="p">.</span><span class="n">serverError</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>動態參數的情況，ARB 檔案定義佔位符，ViewModel 負責組裝：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nt">&#34;bookNotFoundWithId&#34;</span><span class="p">:</span> <span class="s2">&#34;找不到書籍（ID: {bookId}）&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nt">&#34;@bookNotFoundWithId&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nt">&#34;placeholders&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">      <span class="nt">&#34;bookId&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;String&#34;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="kd">final</span> <span class="n">message</span> <span class="o">=</span> <span class="n">l10n</span><span class="p">.</span><span class="n">bookNotFoundWithId</span><span class="p">(</span><span class="n">bookId</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="n">state</span> <span class="o">=</span> <span class="n">state</span><span class="p">.</span><span class="n">copyWith</span><span class="p">(</span><span class="nl">errorMessage:</span> <span class="n">message</span><span class="p">);</span></span></span></code></pre></div><h2 id="ui-層只負責顯示">UI 層：只負責顯示</h2>
<p>UI 層的工作極度單純：顯示 ViewModel 準備好的狀態，不判斷錯誤碼，不組裝字串：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kd">class</span> <span class="nc">BookDetailScreen</span> <span class="kd">extends</span> <span class="n">ConsumerWidget</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="err">@</span><span class="n">override</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="n">Widget</span> <span class="n">build</span><span class="p">(</span><span class="n">BuildContext</span> <span class="n">context</span><span class="p">,</span> <span class="n">WidgetRef</span> <span class="n">ref</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    <span class="kd">final</span> <span class="n">state</span> <span class="o">=</span> <span class="n">ref</span><span class="p">.</span><span class="n">watch</span><span class="p">(</span><span class="n">bookDetailViewModelProvider</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">state</span><span class="p">.</span><span class="n">isLoading</span><span class="p">)</span> <span class="k">return</span> <span class="kd">const</span> <span class="n">CircularProgressIndicator</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">state</span><span class="p">.</span><span class="n">errorMessage</span> <span class="o">!=</span> <span class="kc">null</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">      <span class="k">return</span> <span class="n">ErrorDisplay</span><span class="p">(</span><span class="nl">message:</span> <span class="n">state</span><span class="p">.</span><span class="n">errorMessage</span><span class="o">!</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="k">return</span> <span class="n">BookDetailContent</span><span class="p">(</span><span class="nl">book:</span> <span class="n">state</span><span class="p">.</span><span class="n">book</span><span class="o">!</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p><code>build</code> 方法裡不應該出現任何 <code>switch (errorCode)</code> 或 <code>l10n.xxx</code> 來組裝錯誤訊息。</p>
<h2 id="常見反模式">常見反模式</h2>
<p><strong>Domain 層包含使用者訊息</strong>：repository 直接回傳 <code>'找不到這本書'</code>，要改成英文就得進業務邏輯層動手，Domain 層不應該對 UI 語言有任何感知。</p>
<p><strong>ViewModel 硬編碼訊息</strong>：<code>state.copyWith(errorMessage: '找不到書籍')</code> 讓字串散落各處，修改時不知道有幾個地方要同步改動。</p>
<p><strong>UI 層自行組裝訊息</strong>：Widget 的 <code>build</code> 方法裡出現 <code>switch (state.errorCode)</code>，同一段決策邏輯開始在不同 Widget 裡複製出現。</p>
<p><strong>跨層傳遞 BuildContext</strong>：把 <code>BuildContext</code> 傳入 Domain 層，Domain 層不應該依賴 Flutter 框架的任何概念，這樣的程式碼幾乎無法進行單元測試。</p>
<h2 id="實際效益">實際效益</h2>
<p>新增語言支援時，只需要在 ARB 檔案裡補翻譯，確認 <code>ErrorHandler</code> 映射完整，不需要搜索整個 codebase 找散落的字串。</p>
<p>Domain 層的測試變乾淨，只需驗證正確的 <code>ErrorCode</code> 被回傳，不用比對任何字串內容。每次在 Domain 層看到中文字串，或在 UI 層看到 i18n key 被呼叫，就是一個警示信號——訊息的生成和轉換，只屬於 ViewModel 層。</p>]]></content:encoded></item></channel></rss>