在雲端 Mac 驗證 iOS 下載續傳,而非只看進度條

在雲端 Mac 驗證 iOS 下載續傳,而非只看進度條

遠端開發一款需要下載大型離線資源的 iOS App 時,網路中斷後進度條從原處繼續,並不能證明用戶端真的重用了已下載的位元組。它也可能重新下載,只是介面沿用了先前的進度。在雲端 Mac 上,可以讓模擬器存取同一台機器上的本機服務,透過伺服器記錄與檔案校驗,進行可重複的驗收;整個測試不依賴公網檔案服務。

先定義成功條件

這次只測試單一檔案、單段位元組範圍,不涵蓋平行分片。準備一個不含敏感資料、大小足以讓人員在下載途中取消的檔案。首次請求應收到 200;如果用戶端保留了有效的續傳資訊,後續請求可能帶有 Range: bytes=N-,伺服器則回傳 206 與正確的 Content-Range。最終檔案的長度與 SHA-256 都必須和來源檔案一致。

resumeData 是用戶端嘗試恢復下載所需的資料,不能證明伺服器已提供部分回應。取消下載也不保證每次都能取得可用的 resumeData。

先在專用測試目錄產生檔案並記錄基準值。檔案大小只是為了留出手動取消的時間,不代表產品規格或效能指標:

mkdir -p ~/range-lab && cd ~/range-lab
python3 - <<'PY'
with open("fixture.bin", "wb") as out:
    for _ in range(64):
        out.write(b"XCZone-range-check" * 65536)
PY
shasum -a 256 fixture.bin
stat -f '%z bytes' fixture.bin

架設僅供本機存取的 Range 服務

以下最小化服務只提供目前目錄中的 fixture.bin。它會為檔案產生 ETag,支援從指定偏移量讀取至檔案結尾;不支援的 Range 請求會收到 416。每寫入一個區塊後,服務會短暫暫停,方便在模擬器中中斷下載。這不是可對外公開部署的檔案服務。

from hashlib import sha256
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from time import sleep
import re

path = Path("fixture.bin")
size = path.stat().st_size
etag = '"' + sha256(path.read_bytes()).hexdigest() + '"'

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path != "/fixture.bin":
            self.send_error(404)
            return
        requested = self.headers.get("Range")
        if self.headers.get("If-Range") not in (None, etag):
            requested = None
        start = 0
        if requested:
            match = re.fullmatch(r"bytes=(\d+)-", requested)
            if not match or int(match.group(1)) >= size:
                self.send_response(416)
                self.send_header("Content-Range", f"bytes */{size}")
                self.end_headers()
                return
            start = int(match.group(1))
        self.send_response(206 if requested else 200)
        self.send_header("Content-Type", "application/octet-stream")
        self.send_header("Accept-Ranges", "bytes")
        self.send_header("ETag", etag)
        self.send_header("Content-Length", str(size - start))
        if requested:
            self.send_header("Content-Range", f"bytes {start}-{size - 1}/{size}")
        self.end_headers()
        with path.open("rb") as source:
            source.seek(start)
            while chunk := source.read(65536):
                try:
                    self.wfile.write(chunk)
                    self.wfile.flush()
                except (BrokenPipeError, ConnectionResetError):
                    break
                sleep(0.01)

HTTPServer(("127.0.0.1", 8765), Handler).serve_forever()

儲存為 range_server.py,並在該目錄執行 python3 range_server.py。另開一個終端機,先以 curl -i -r 1024-2047 http://127.0.0.1:8765/fixture.bin -o /dev/null 檢查狀態碼與回應標頭;這裡的輸出可能顯示傳輸進度,不必據此判斷內容。也可以改用 curl -sD - -r 1024-2047 -o /dev/null http://127.0.0.1:8765/fixture.bin,明確核對 206、Content-Range: bytes 1024-2047/… 和 Content-Length。測試結束後,以鍵盤中斷服務;不要把監聽位址改成對外的網路介面。

在模擬器中中斷並恢復

讓測試版 App 使用 URLSessionDownloadTask 下載 http://127.0.0.1:8765/fixture.bin。模擬器與執行它的 Mac 共用這條本機測試路徑;如果改用實機,127.0.0.1 指向的就是手機,不能直接照搬。如需為明文 HTTP 設定例外,僅在獨立的測試組態中設定網路安全權限,並確認正式建置不包含該例外。

下載開始後呼叫 cancel(byProducingResumeData:),儲存回呼提供的資料,再以 downloadTask(withResumeData:) 建立新任務。保留伺服器終端機輸出,逐次記錄請求中的 Range、回應狀態與最終檔案大小。先確認一般下載可以完成,再進行中斷測試;否則連線問題會和續傳問題混在一起。

區分續傳與重新下載

如果第二次請求沒有 Range,且伺服器回傳 200,用戶端可能選擇了完整重試;這不算部分續傳成功。如果請求帶有 Range,卻因檔案 ETag 改變而收到完整回應,也應從頭寫入檔案。無論是哪種情況,都不能把完整回應附加到舊片段末尾。測試程式碼還應涵蓋取消後取不到 resumeData 的情況,允許明確地重新開始,而不是讓任務停在「等待恢復」。

以檔案校驗完成驗收

下載任務完成後,將暫存檔案複製到測試目錄,再執行 shasum -a 256 下载后的文件路径,與來源檔案的雜湊值逐字比較,同時核對長度。206 只能說明伺服器回傳了部分資料;若雜湊值不一致,仍須檢查偏移量、寫入方式,以及檔案是否在兩次請求之間發生變更。

最後再做一次反例測試:先中斷下載,再修改來源檔案並重新啟動測試服務,讓 ETag 改變。預期用戶端不能把舊片段當成新檔案的前半段。將請求記錄、狀態碼、長度與雜湊值放在同一份測試記錄中,比單獨截取一張進度條畫面更容易定位回歸問題。

常見問題

看到 206 回應就代表續傳成功嗎?

不代表。還須核對 Range 起點、最終檔案長度和 SHA-256。

續傳時伺服器回傳 200,該如何處理?

應視為完整回應,從頭儲存或重新下載,不可接在既有片段後面。

XCZone · 雲端 Mac

需要一台可遠端連線的獨享實體 Mac?

比較 M4 Start、M4 Work 與 M4 Pro 的規格、節點和計費週期,選擇最符合需求的方案。

查看雲端 Mac 方案