[廚餘回收] iOS 與 Android 對於已快取的 Request 處理方式不一樣

以前聽過有個笑話是這樣說的:

某A:聽說 iOS 在瀏覽網頁的時候很省電
某B:對,因為它什麼事都沒做。

原來這件事是真的,根據 RFC 7234 5.2.1.4 的定義,如果我們在發送 Request 的時候,加上 cache-control: no-cache,在沒有從伺服器成功取得內容之前,不得使用已儲存的快取來滿足目前的 Request,但是 iOS 它連 Request 都沒送,自然就不需要理會這個定義。

某天,我家老闆拿著他的 iPhone 跑來問我「為什麼我一直看到舊的資訊?而且重新整理都沒有用,必須清除瀏覽資料才可以。」,我當時心裡想「誰叫你要拿 iPhone!?」,但是身為工程師自然得找出原因是啥?

是這樣的,我們網站的頁面資料大都有使用瀏覽器的快取,避免後端伺服器一直受到騷擾,有一部分的頁面資料會因為時間到了,它必須得強制清除快取,然後從後端伺服器取得最新的資料,方法也很簡單,就是程式判斷需要強制更新的時候,在 Request Headers 中加入 cache-control: no-cache 就好了。

這個做法在 Desktop 平台、Android 平台的幾個主流瀏覽器上,都運作正常,只有 iOS 跟人家不一樣,在 iOS 上不管是 Safari、Chrome、Firefox、Edge,都失效了,我們就來看看怎麼個失效法?

開始實驗

我的實驗環境是這樣,我用 ASP.NET Core MVC 寫了一個 Action,這個 Action 有兩個路由 /test/test/test/test/test,並且掛上 [ResponseCache(Duration = 600)],而回傳的內容也很簡單,就是一個 Timestamp 的值。

接著,我弄了一個簡單的測試頁,用 jQuery.ajax() 分別去 GET /test/test 及 /test/test/test,把內容個別顯示在兩個區塊上,有一點不同的是,在 GET /test/test/test 時,Headers 加上 cache-control: no-cache,頁面上還有一個 Refresh 按鈕用來手動更新內容。

<!DOCTYPE html>

<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Test</title>
    <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
</head>
<body>
    <div id="cached">Cached: <span></span></div>
    <div id="no-cached">NoCached: <span></span></div>
    <div>
        <button onclick="refresh()">Refresh</button>
    </div>
    <script>
        $(() => {
            refresh();
        });

        function refresh() {
            $.ajax("/test/test").then(o => {
                $("#cached span").text(o.value);
            });

            $.ajax("/test/test/test", { headers: { "cache-control": "no-cache" } }).then(o => {
                $("#no-cached span").text(o.value);
            });
        }
    </script>
</body>
</html>

正常來講,應該像下面的影片一樣,Cached 的內容不會動,NoCached 的內容每按一次就更新一次。

但是換到 iOS 的時候,NoCached 的內容就不會動了。

使用 DevTools

我分別進入 Android 及 iOS 的瀏覽器 DevTools,在 Android 上點擊 Refresh 按鈕,於 Network 頁籤會出現兩個 Request,一個顯示資料來源是 (disk cache),一個則顯示成功從伺服器取得資料,但是在 iOS 初始載入之後,不管怎麼按 Refresh 按鈕,都看不到這兩個 Request,NoCached 的內容也都不會更新。

這確定一件事情,在 iOS 上,只要我們的 Response 有指定使用瀏覽器快取,在 Headers 加入 cache-control: no-cache 來更新快取是行不通的,我們得想辦法在 iOS 上處理這個問題。

QueryString + localStorage

要從後端伺服器取得最新資料還有一招大絕,就是在 URL 後面隨便帶個 QueryString,這樣就會被視為是不同網址,重新發送 Request,通常的做法是用 Timestamp,像這樣 ?t=1616468166396,但是這樣瀏覽器快取在 iOS 上就無用武之地了,所以我想了個方式,把 Timestamp 存放在 localStorage,當頁面內容需要強制更新的時候,就更新 localStorage 內的 Timestamp,實際做法請看下面的程式碼。

<script>
    if (!window.isiOS) {
        window.isiOS = function () {
            return /iPad|iPhone|iPod/.test(navigator.userAgent);
        }
    }

    if (!window.mayTheForceBeWithiOS) {
        window.mayTheForceBeWithiOS = function (prefix, refresh) {
            if (!window.isiOS()) return "";

            let force = window.localStorage.getItem("ios-force");

            if (!force || refresh === true) {
                force = Date.now();

                window.localStorage.setItem("ios-force", force);
            }

            return `${prefix}_ios=${force}`;
        }
    }

    $(() => {
        refresh();
    });

    function refresh() {
        $.ajax(`/test/test${window.mayTheForceBeWithiOS("?")}`).then(o => {
            $("#cached span").text(o.value);
        });

        $.ajax(`/test/test/test${window.mayTheForceBeWithiOS("?", true)}`, { headers: { "cache-control": "no-cache" } }).then(o => {
            $("#no-cached span").text(o.value);
        });
    }
</script>

以前聽過一句話「程式不是照你想的跑,是照你寫的跑。」,現在這句話要改一下了「程式不是照你想的跑,也不是照你寫的跑,是照 iOS 的規則跑。」

相關資源

C# 指南
ASP.NET 教學
ASP.NET MVC 指引
Azure SQL Database 教學
SQL Server 教學
Xamarin.Forms 教學