遠端開發一款需要下載大型離線資源的 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,該如何處理?
應視為完整回應,從頭儲存或重新下載,不可接在既有片段後面。
需要一台可遠端連線的獨享實體 Mac?
比較 M4 Start、M4 Work 與 M4 Pro 的規格、節點和計費週期,選擇最符合需求的方案。