In-app WebView 是雙主題最容易失控的環境,原因是一句話就能講清楚的:在 WebView 裡,prefers-color-scheme 回報的不是使用者的系統設定,而是宿主 app 的佈景設定。 這不是 bug,是 Android 官方文件寫明的設計;iOS 的機制不同但結論類似。理解這一點之後,大部分「同一個網址在 Facebook 裡是暗的、在 Safari 裡是亮的」的怪現象都能解釋。

Android WebView:prefers-color-scheme 綁定 app 佈景

Android 官方文件的規則明確:WebView 依 app 佈景的 isLightTheme 屬性決定 prefers-color-schemeisLightThemetrue 或未設定 → light;為 falsedark。整個過程自動發生,網頁端無法干預。

推論出的三個實際後果:

  1. 使用者的 OS 是暗色,但 app 佈景寫死亮色 → 你的頁面收到 light 使用者看到一個亮色頁面嵌在暗色 app 裡,會認為是你的網站沒做暗色。
  2. 反過來,app 佈景是暗色而使用者 OS 是亮色 → 你的頁面收到 dark 你的暗色主題會在一個使用者根本沒要求暗色的情境下出現。
  3. 同一個 app 的不同頁面可能給出不同答案,如果它在不同 Activity 用了不同佈景。

這三點都不是你能修的,你能做的是在兩種答案下都好看——這就是為什麼雙主題在 in-app 情境不是加分項而是必需品。

Android 的第二層:algorithmic darkening

除了媒體查詢,Android WebView 還有一個會直接改變畫面的機制。歷史演進值得記清楚,因為網路上的教學大量停留在舊 API:

  • 舊 API setForceDark():對 targetSdkVersion ≥ 33(Android 13)的 app 已是 no-op。官方說明是原本的 force dark 模型過於複雜、且與 prefers-color-scheme / color-scheme 的現行網頁標準互通性不佳。
  • 現行 API setAlgorithmicDarkeningAllowed():app 端呼叫後,允許 WebView 在需要時演算法式地把內容變暗。
if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) {
    WebSettingsCompat.setAlgorithmicDarkeningAllowed(webView.settings, true)
}

演算法反色只在四個條件同時成立時發生(這是網頁端最需要記住的一段):

  1. 網頁內容沒有使用 prefers-color-scheme
  2. 網頁作者沒有明確關閉darkening;
  3. Android 13+ 的 app 呼叫了 setAlgorithmicDarkeningAllowed(true)
  4. Android 12 以下則是 force dark 設為 FORCE_DARK_ONFORCE_DARK_AUTO

換句話說,只要你正確支援雙主題,條件 1 就不成立,演算法反色永遠不會碰你的頁面。 這是整篇最重要的一句話,也是「對抗強制反色」唯一乾淨的解法:不是偵測它、不是用 hack 蓋掉它,而是讓它沒有觸發條件。

Android 官方對網頁作者的建議是加上 meta 宣告,最大相容的寫法是明確列出兩者:

<meta name="color-scheme" content="dark light">

若你的內容只支援亮色,則用 content="light" 或更強的 only light 退出。

Android 12 以下還有 ForceDarkStrategy 可調,三個值分別是「優先用網頁主題」(預設)、「只用網頁主題」、「只用 UA 反色」。官方建議:若 app 顯示的是自己第一方、已用 prefers-color-scheme 客製過的網頁內容,用 DARK_STRATEGY_WEB_THEME_DARKENING_ONLY,確保 WebView 採用你的主題而不是自行反色。這條建議只有在你同時掌握 app 端時才用得上。

還有一個容易誤判的細節:若 WebView 走的是 UA 反色路徑,@media (prefers-color-scheme: dark) 會求值為 false。 也就是說畫面是暗的、但你的暗色 CSS 沒有生效——這正是「頁面被反成暗色,但我的暗色樣式明明沒套用」這個困惑的來源。

iOS WKWebView:跟著宿主 app 的 trait

WebKit 的行為與 Android 相反的地方在於:WKWebView 預設不會自動把網頁內容變暗。沒有 algorithmic darkening 這回事,網頁要暗色就得自己實作。

它的 prefers-color-scheme 來自宿主的 UITraitCollection.userInterfaceStyle。app 端可以用 overrideUserInterfaceStyle 直接鎖定:

webView.overrideUserInterfaceStyle = .light   // 網頁固定收到 prefers-color-scheme: light
webView.overrideUserInterfaceStyle = .dark    // 固定收到 dark
webView.overrideUserInterfaceStyle = .unspecified  // 跟隨系統(預設)

許多 app 為了視覺一致會鎖定這個值,於是同一個網址在該 app 裡永遠是某一套主題。這和 Android 的 isLightTheme 是同一個問題的兩種寫法。

WebKit 端的網頁做法就是標準做法:在 :rootcolor-scheme,配合媒體查詢覆寫顏色變數。如果你只用了 UA 的預設文字與背景色,光是設 color-scheme 可能就已經足夠。

SFSafariViewController:另一種東西

SFSafariViewController 不是 WebView,它是系統提供的完整 Safari 元件,跑在獨立行程、共用 Safari 的 cookie 與資料。對網頁作者的意義是它行為上更接近真正的 Safari:儲存是持久的、擴充功能可用、外觀跟隨系統。app 只能調整 tint 色等外框樣式,不能注入腳本、不能改變網頁的色彩配置。

所以「同一個連結,在 A app 裡正常、在 B app 裡怪怪的」很可能就是 A 用了 SFSafariViewController、B 用了 WKWebView。這也是為什麼問題回報常常互相矛盾——回報者用的根本是兩種不同的引擎環境。

社群 App 的內建瀏覽器

Facebook、Instagram、Messenger、LINE、X、微信這些 app 都有自己的內建瀏覽器。它們在 Android 上是 WebView、在 iOS 上多為 WKWebView(部分流程會改用 SFSafariViewController),因此上面所有的機制都直接適用。可以從機制推導出的共同問題:

  • prefers-color-scheme 跟著 app 走。 LINE、Facebook 等 app 有自己的「淺色/深色/跟隨系統」設定,這個設定會決定你的頁面收到什麼。使用者在 app 裡選了深色,你的頁面就會是深色,即使他的手機是亮色模式。
  • 儲存可能不持久。 部分 in-app browser 使用非持久化的資料存放區,或每次開啟就是新的 session。這代表存在 localStorage 的主題偏好可能在下次開啟時消失,使用者的手動選擇無法保留。設計上要接受「每次都回到系統/app 預設」是常態,不要把重要狀態只放在 localStorage。
  • 注入的腳本與樣式。 部分 app 會在頁面注入自己的 JS(追蹤、分享列、關閉按鈕)。這些注入內容偶爾會帶入自己的樣式,或在頁面上方/下方加一條它自己的工具列——那條工具列的顏色由 app 決定,不吃你的 theme-color
  • UA 字串可辨識,但不建議據此改行為。 Facebook 系的 UA 含 FBAN / FBAV / FB_IAB,Instagram 含 Instagram,LINE 含 Line/。可以用來記錄與除錯,但不要用來分支主題邏輯——UA 嗅探會腐爛,而且解不了根本問題。

需要誠實標註的是:上述各 app 的具體版本行為未逐一實測。它們的實作會隨版本改變,唯一可靠的做法是自己在目標 app 上跑一次驗證頁,見下一節與 強制反色的偵測、對抗與測試矩陣

一頁式驗證頁

把下面這段放到一個可公開存取的 URL,用各個 app 的內建瀏覽器打開它,一眼就能看出環境給了什麼:

<!doctype html>
<html lang="zh-Hant">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="color-scheme" content="light dark">
  <title>色彩配置診斷</title>
  <style>
    :root { color-scheme: light dark; }
    body { font: 16px/1.6 system-ui; margin: 0; padding: 24px;
           background: light-dark(#ffffff, #0e1013);
           color: light-dark(#16191d, #e6e8eb); }
    /* 這兩塊是「UA 有沒有動我的顏色」的探針:
       若畫面明顯變暗但 probe 仍顯示原色,代表沒被反色;
       若 probe 的視覺顏色與標示不符,就是被 UA 改過了。 */
    .probe { padding: 12px; margin: 8px 0; font-weight: 700; }
    .p-white { background: #ffffff; color: #000000; }
    .p-black { background: #000000; color: #ffffff; }
    dt { font-weight: 700; margin-top: 12px; }
    dd { margin: 0; font-family: ui-monospace, monospace; word-break: break-all; }
  </style>
</head>
<body>
  <h1>色彩配置診斷</h1>
  <div class="probe p-white">這塊宣告為 #ffffff 白底黑字</div>
  <div class="probe p-black">這塊宣告為 #000000 黑底白字</div>
  <dl>
    <dt>prefers-color-scheme: dark</dt><dd id="mq"></dd>
    <dt>prefers-contrast: more</dt><dd id="pc"></dd>
    <dt>forced-colors: active</dt><dd id="fc"></dd>
    <dt>computed body background</dt><dd id="bg"></dd>
    <dt>localStorage 可用</dt><dd id="ls"></dd>
    <dt>devicePixelRatio / 視窗</dt><dd id="dp"></dd>
    <dt>User-Agent</dt><dd id="ua"></dd>
  </dl>
  <script>
    const q = s => matchMedia(s).matches;
    mq.textContent = q('(prefers-color-scheme: dark)');
    pc.textContent = q('(prefers-contrast: more)');
    fc.textContent = q('(forced-colors: active)');
    bg.textContent = getComputedStyle(document.body).backgroundColor;
    try { localStorage.setItem('_t','1'); localStorage.removeItem('_t'); ls.textContent = 'yes'; }
    catch (e) { ls.textContent = 'no — ' + e.name; }
    dp.textContent = devicePixelRatio + ' / ' + innerWidth + '×' + innerHeight;
    ua.textContent = navigator.userAgent;
  </script>
</body>
</html>

判讀方式:

  • prefers-color-scheme: darkfalse 但畫面明顯是暗的 → UA 演算法反色正在作用(Android 的 algorithmic darkening 或 Chrome Auto Dark Theme)。
  • 兩塊探針的實際顏色與標示不符(宣告白的那塊看起來是深灰)→ 同上,且可看出反色的強度。
  • computed body background 顯示的是你宣告的值而非畫面上的值——這正是 JS 偵測反色不可靠的原因,見下一篇。
  • localStorage 可用: no → 這個 app 的主題偏好無法保存,切換器要有對應的降級行為。

遠端除錯

實機除錯是唯一能確定的方法:

  • Android WebView:手機開啟 USB 偵錯,桌面 Chrome 開 chrome://inspect/#devices。需要 app 端有呼叫 WebView.setWebContentsDebuggingEnabled(true)——第三方 app 通常沒開,所以社群 app 的內建瀏覽器多半連不進去,只能靠上面的診斷頁。
  • iOS WKWebView:Safari → 開發 → 選裝置。iOS 16.4 起 WebView 必須設 isInspectable = true 才會出現在清單裡,同樣第三方 app 通常不會開
  • 能連上的情況幾乎只有自家 app。這也是為什麼那個純 HTML 的診斷頁不可取代——它是唯一在任何 in-app 環境都能用的工具。

In-app 環境的檢查清單

  1. <meta name="color-scheme" content="light dark"> 有沒有在 CSS 之前?
  2. 兩套主題有完整樣式(不是只有暗色寫得好)?因為你不能決定 app 給你哪一套。
  3. 有沒有假設 localStorage 能保存?切換器在儲存失效時要優雅降級,不能丟例外。
  4. html 有沒有背景色?WebView 的過捲區域與 body 高度不足時會露出畫布色。
  5. 頁面頂/底是否預留 app 自己的工具列空間?固定定位的元素可能被它遮住。
  6. 用診斷頁在至少 Facebook、Instagram、LINE、iOS Safari、Android Chrome 五個環境各跑一次,記錄結果。

下一步:強制反色的偵測、對抗與測試矩陣