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-scheme。isLightTheme 為 true 或未設定 → light;為 false → dark。整個過程自動發生,網頁端無法干預。
推論出的三個實際後果:
- 使用者的 OS 是暗色,但 app 佈景寫死亮色 → 你的頁面收到
light。 使用者看到一個亮色頁面嵌在暗色 app 裡,會認為是你的網站沒做暗色。 - 反過來,app 佈景是暗色而使用者 OS 是亮色 → 你的頁面收到
dark。 你的暗色主題會在一個使用者根本沒要求暗色的情境下出現。 - 同一個 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)
}演算法反色只在四個條件同時成立時發生(這是網頁端最需要記住的一段):
- 網頁內容沒有使用
prefers-color-scheme; - 網頁作者沒有明確關閉darkening;
- Android 13+ 的 app 呼叫了
setAlgorithmicDarkeningAllowed(true); - Android 12 以下則是 force dark 設為
FORCE_DARK_ON或FORCE_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 端的網頁做法就是標準做法:在 :root 設 color-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: dark是 false 但畫面明顯是暗的 → 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 環境的檢查清單
<meta name="color-scheme" content="light dark">有沒有在 CSS 之前?- 兩套主題都有完整樣式(不是只有暗色寫得好)?因為你不能決定 app 給你哪一套。
- 有沒有假設 localStorage 能保存?切換器在儲存失效時要優雅降級,不能丟例外。
html有沒有背景色?WebView 的過捲區域與 body 高度不足時會露出畫布色。- 頁面頂/底是否預留 app 自己的工具列空間?固定定位的元素可能被它遮住。
- 用診斷頁在至少 Facebook、Instagram、LINE、iOS Safari、Android Chrome 五個環境各跑一次,記錄結果。
下一步:強制反色的偵測、對抗與測試矩陣。