在云端 Mac 上验收 iOS 断点续传:搭建本地 Range 服务

在云端 Mac 上验收 iOS 断点续传:搭建本地 Range 服务

远程开发一款需要下载较大离线资源的 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 就能认定 iOS 断点续传成功吗?

不能。还要核对请求的 Range 起点、最终文件长度与 SHA-256;只有部分响应而文件不完整,仍是失败。

服务器返回 200 时,客户端应该继续追加文件吗?

不应该。200 表示返回的是完整响应,应从头写入或重新下载;把它追加到已有部分会生成损坏文件。

测试用的本地 HTTP 配置可以带进正式应用吗?

不应带入。仅在独立测试配置中允许本地明文请求,验收后检查正式构建的网络安全设置。

XCZone · 云端 Mac

需要一台可远程连接的独享物理 Mac?

核对 M4 Start、M4 Work 与 M4 Pro 的配置、节点和计费周期,再选择适合任务的方案。

查看云端 Mac 方案