Skip to content

Security HTTP 移动端节点一键测速对接指南

本文档面向 Android 和 iOS 前端开发团队,说明如何使用移动 SDK 一键测试当前设备到 所有授权节点的延迟和丢包率,并据此辅助用户选择节点。

Android 无 Context 节点测速 API 的迁移、各架构层 Context 获取方式以及 TUN 已连接时的 验收步骤见 FRONTEND_MOBILE_NODE_PROBE_CONTEXT_MIGRATION_GUIDE.md。 本文继续定义节点来源、测速流程、结果字段与 UI 规则。

1. 能力说明

一键测速是移动端本地能力,不需要新增服务端接口。当前需要区分两种接入模式:

  • 第三方高层 SDK:Android SecHttpClient、iOS SecHttpSDKClient 已提供 testAllNodes,SDK 内部会拉取授权节点后逐个测速。
  • 第一方/自有客户端:当前使用 Android SecHttpVpnController 或 iOS SecHttpTunnelController 的低层接口。低层 Controller 不会自己登录或拉取节点; GUI/APP 持有当前用户 access_token,调用 /v1/nodes 取得授权节点列表后,再按 本文档定义的 TCP 探测规则测试这些节点。

第三方高层 SDK 会先通过已认证会话刷新授权节点列表,然后对每个节点的数据服务端口 执行多次 TCP 连接探测。第一方/自有客户端则由 GUI/APP 调 /v1/nodes 获取节点后, 在本地对这些节点执行同样的 TCP 探测。

  • 默认每个节点测试 3 次。
  • 默认单次连接超时 3000ms(Android/iOS 高层 SDK 一致)。
  • 最多同时测试 4 个节点。
  • 返回平均成功延迟、成功次数、总次数和丢包率。
  • 无法连接的节点仍会返回,延迟值为 -1
  • Android 回调在主线程执行。
  • iOS 使用 async/await 返回结果。

授权节点列表由控制面 /sdk/nodes 按当前 SDK 安装实例等级裁剪。合作方 Backend 创建 Bootstrap Token 时应传 node_level_code

用户等级node_level_code测速范围
普通用户normal 或不传普通节点
VIP 用户vip普通节点 + VIP 节点
SVIP 用户svip普通节点 + VIP 节点 + SVIP 节点

前端 APP 不需要也不应该把自己请求到的节点列表再传给 testAllNodes。测速、刷新节点、 连接和切换都应以 SDK 从服务端拿到的授权节点列表为准。

这条规则仅针对第三方高层 SDK 的 testAllNodes。第一方/自有客户端没有这个高层 testAllNodes API,节点列表本来就由 GUI/APP 调 /v1/nodes 获取;第一方测速应使用 该接口返回的节点列表作为唯一输入,不要使用本地写死节点。

这里的“丢包率”表示 TCP 连接探测失败次数占总探测次数的比例,不是 ICMP Ping 报文丢包率。TCP 探测无需 ICMP 权限,并且更接近 VPN 节点的实际连接可用性。

2. 调用前提

调用测速前,SDK 必须已经完成认证并持有有效的 Access Token:

  • Android 已成功调用 authenticate(...)connect(...)
  • iOS 已成功调用 authenticate(bootstrapToken:)connect(...)

测速本身:

  • 不会启动 VPN。
  • 不会切换当前节点。
  • 不需要申请 Android VPN 权限。
  • 不会消耗隧道业务流量,但会产生少量 TCP 连接。

Android 第一方 SDK 的 SecHttpNodeProbe 会要求传入 Context,并用该 Context 选择当前底层 Wi-Fi/蜂窝网络完成 DNS 和 TCP 建连。因此即使 TUN 已连接,测速也不会 经过当前隧道,无需为测速提示用户断开 VPN。iOS SDK 会通过 bootstrap DNS 在底层网络解析 节点域名,并以解析出的 IP 直连,避免系统 DNS 查询进入 Packet Tunnel。

3. 结果字段

含义AndroidiOS说明
节点getNode()node节点完整信息
平均延迟getLatencyMs()latencyMilliseconds成功探测的平均耗时,单位毫秒;-1 表示全部失败
丢包率getPacketLossPercent()packetLossPercent范围 0...100
成功次数getSuccessfulSamples()successfulSamplesTCP 建连成功次数
总次数getTotalSamples()totalSamples本次节点探测总次数
是否可达isReachable()isReachable至少一次探测成功
最后错误getLastError()lastError全部或部分失败时的诊断信息,可能为空

例如测试 3 次:

成功次数丢包率延迟
30%3 次成功结果的平均值
233.33%2 次成功结果的平均值
166.67%该次成功结果
0100%-1

4. 第三方高层 SDK 流程

第三方高层 SDK 的完整流程如下:

关键点:

  • testAllNodes() 每次会重新调用 /sdk/nodes,避免使用过期节点列表。
  • /sdk/nodes 按 Bootstrap Token 绑定的 node_level_code 过滤节点。
  • 前端只负责调用 testAllNodes() 和展示结果,不传节点列表。
  • connectToNode/switchNode 也会重新解析服务端授权节点,和测速使用同一权限边界。

5. 第一方/自有客户端流程

第一方 Android/iOS 当前走低层 Controller,不是第三方高层 SecHttpClient 流程。 因此第一方不是“SDK 内部拿当前用户 token 再拉节点”,而是:

第一方节点权限由 /v1/nodes 保证:

  • 普通用户:返回普通节点。
  • VIP 用户:返回普通节点 + VIP 节点。
  • SVIP 用户:返回普通节点 + VIP 节点 + SVIP 节点。

第一方测速必须遵守:

  • 只测试 /v1/nodes 返回的节点。
  • 不测试本地写死节点。
  • 不测试管理员接口 /api/v1/admin/nodes 返回的全量节点。
  • 测速结果只影响展示和排序,不改变用户权限。
  • 连接或切换时仍使用同一个用户 token 和用户选择的授权节点。

第一方测速 helper 代码位置:

平台文件说明
Androidsdk/android/src/main/java/com/securityhttp/sdk/SecHttpNodeProbe.javaTUN 已连接或可能已连接时必须调用 SecHttpNodeProbe.testAll(context, ...),接收 /v1/nodes 转换出的节点
iOSsdk/ios/src/SecHttpNodeProbe.swiftSecHttpNodeProbe().testAll(...),接收 [SecHttpNode]

5.1 Android Context 必填与迁移

Android 第一方 SDK 已移除以下旧方法,不能再调用:

kotlin
// 已移除:不能保证 TUN 已开启时仍使用底层网络。
SecHttpNodeProbe.testAll(probeNodes, callback)

必须改为传入 Context

kotlin
SecHttpNodeProbe.testAll(context, probeNodes, callback)

推荐传 applicationContext,避免测速任务持有 ActivityFragment

调用位置获取并传入的 Context
ActivityapplicationContext(或直接传 this
FragmentrequireContext().applicationContext
ViewModel / Repository通过依赖注入传入 ApplicationapplicationContext;不要保存 Activity / View 引用
Compose在 UI 层用 LocalContext.current.applicationContext 取得后传给 ViewModel 方法,或由依赖注入提供

Context 既是 API 必填参数,也是确保测速 DNS 与 TCP 连接绕过已开启 TUN 的条件。传入 null 会立即抛出 IllegalArgumentException,不能回退到普通 Socket

5.2 第一方 Android 对接示例

前端侧建议把 /v1/nodes 返回的“授权节点”作为列表主数据,测速结果只作为该列表的 附加展示字段。这样用户点击连接时,仍然使用服务端返回的原始授权节点,避免因为本地 测速模型字段不全导致连接配置缺失。

下面示例使用 Kotlin 展示完整流程。ControlNode 对应 /v1/nodes 返回的数据结构; 如果现有接口外面还有 data 包装层,只需要在 ControlApi 里按项目实际响应体调整。

kotlin
data class ControlNode(
    val id: String,
    val name: String,
    val host: String,
    val port: Int,
    val region: String? = null
)

data class NodeRow(
    val node: ControlNode,
    val isTesting: Boolean = false,
    val isReachable: Boolean? = null,
    val latencyMs: Long? = null,
    val packetLossPercent: Double? = null,
    val lastError: String? = null
) {
    val latencyText: String
        get() = when {
            isTesting -> "测试中..."
            isReachable == false -> "不可达"
            latencyMs != null -> "${latencyMs} ms"
            else -> "--"
        }

    val packetLossText: String
        get() = when {
            isTesting -> "--"
            packetLossPercent != null -> "丢包 ${packetLossPercent.toInt()}%"
            else -> "--"
        }
}

interface ControlApi {
    @GET("/v1/nodes")
    suspend fun listNodes(
        @Header("Authorization") authorization: String
    ): List<ControlNode>

    @GET("/v1/config")
    suspend fun loadConfig(
        @Header("Authorization") authorization: String
    ): ControlPolicy
}

页面初始化时先拿授权节点并渲染列表。普通/VIP/SVIP 的差异已经由 /v1/nodes 处理, 前端不要再拼接其他来源的节点。

kotlin
class NodeListViewModel(
    private val appContext: Context,
    private val controlApi: ControlApi
) : ViewModel() {
    private var accessToken: String = ""
    private var policy: ControlPolicy? = null

    private val _rows = MutableStateFlow<List<NodeRow>>(emptyList())
    val rows: StateFlow<List<NodeRow>> = _rows

    private val _isTestingAll = MutableStateFlow(false)
    val isTestingAll: StateFlow<Boolean> = _isTestingAll

    suspend fun loadNodes(token: String) {
        accessToken = token
        val authorization = "Bearer $token"
        val nodes = controlApi.listNodes(authorization)
        policy = controlApi.loadConfig(authorization)

        _rows.value = nodes.map { node ->
            NodeRow(node = node)
        }
    }
}

用户点击“一键测速”时,把当前列表里的授权节点转换成 SecHttpNodeProbe.Node 后传入 helper。回调已经在 Android 主线程执行,可以直接更新 StateFlow 或 UI。

kotlin
fun testAllNodes() {
    val currentRows = _rows.value
    if (currentRows.isEmpty() || _isTestingAll.value) {
        return
    }

    _isTestingAll.value = true
    _rows.value = currentRows.map { it.copy(isTesting = true) }

    val probeNodes = currentRows.map { row ->
        val node = row.node
        SecHttpNodeProbe.Node(
            node.id,
            node.name,
            node.host,
            node.port,
            node.region.orEmpty()
        )
    }

    SecHttpNodeProbe.testAll(appContext, probeNodes, object : SecHttpNodeProbe.Callback {
        override fun onSuccess(results: List<SecHttpNodeProbe.Result>) {
            _isTestingAll.value = false
            _rows.value = mergeAndSortProbeResults(
                nodes = currentRows.map { it.node },
                results = results
            )
        }

        override fun onError(error: Exception) {
            _isTestingAll.value = false
            _rows.value = currentRows.map { it.copy(isTesting = false) }
            showToast(error.message ?: "节点测速失败,请重试")
        }
    })
}

结果处理建议统一封装:按 node.id 合并结果,先排可达节点,再按丢包率和延迟排序。 latencyMs == -1isReachable == false 的节点展示为“不可达”,但不要从列表中删除。

kotlin
private fun mergeAndSortProbeResults(
    nodes: List<ControlNode>,
    results: List<SecHttpNodeProbe.Result>
): List<NodeRow> {
    val resultByNodeId = results.associateBy { it.node.id }

    return nodes.map { node ->
        val result = resultByNodeId[node.id]
        if (result == null) {
            NodeRow(node = node, isReachable = false, lastError = "missing probe result")
        } else {
            NodeRow(
                node = node,
                isReachable = result.isReachable,
                latencyMs = if (result.isReachable) result.latencyMs else null,
                packetLossPercent = result.packetLossPercent,
                lastError = result.lastError
            )
        }
    }.sortedWith(
        compareBy<NodeRow> { it.isReachable != true }
            .thenBy { it.packetLossPercent ?: 100.0 }
            .thenBy { it.latencyMs ?: Long.MAX_VALUE }
    )
}

用户选择节点后,连接配置必须使用 NodeRow.node 里的授权节点信息。测速结果只用于 展示和辅助排序,不参与权限判断,也不自动连接。

kotlin
fun connectSelectedNode(row: NodeRow) {
    if (accessToken.isBlank()) {
        showToast("登录已过期,请重新登录")
        return
    }

    if (row.isReachable == false) {
        showToast("该节点当前不可达,仍可手动尝试连接")
    }

    val node = row.node
    val config = SecHttpConfig.builder(
        accessToken,
        node.id,
        node.host,
        node.port
    )
        .nodeName(node.name)
        .nodeRegion(node.region.orEmpty())
        // 如果项目已经把 /v1/config 转成 SDK 支持的配置字段,可在这里继续设置。
        .build()

    SecHttpVpnController.connect(appContext, config)
}

5.3 第一方 iOS 对接示例

iOS 侧同样以 /v1/nodes 返回的 [SecHttpNode] 作为列表主数据。测速 helper 返回 [SecHttpNodeTestResult],前端按 node.id 合并回列表。

swift
struct NodeRow: Identifiable, Equatable {
    let node: SecHttpNode
    var isTesting: Bool = false
    var isReachable: Bool?
    var latencyMilliseconds: Int?
    var packetLossPercent: Double?
    var lastError: String?

    var id: String { node.id }

    var latencyText: String {
        if isTesting { return "测试中..." }
        if isReachable == false { return "不可达" }
        if let latencyMilliseconds { return "\(latencyMilliseconds) ms" }
        return "--"
    }

    var packetLossText: String {
        guard !isTesting, let packetLossPercent else { return "--" }
        return "丢包 \(Int(packetLossPercent.rounded()))%"
    }
}

protocol ControlAPI {
    func listNodes(accessToken: String) async throws -> [SecHttpNode]   // GET /v1/nodes
    func loadConfig(accessToken: String) async throws -> SecHttpConfig.Policy // GET /v1/config
}

页面加载节点和策略:

swift
@MainActor
final class NodeListViewModel: ObservableObject {
    @Published private(set) var rows: [NodeRow] = []
    @Published private(set) var isTestingAll = false
    @Published var errorMessage: String?

    private let controlAPI: ControlAPI
    private let tunnelController: SecHttpTunnelController
    private let nodeProbe = SecHttpNodeProbe()

    private var accessToken = ""
    private var policy = SecHttpConfig.Policy()

    init(
        controlAPI: ControlAPI,
        tunnelController: SecHttpTunnelController
    ) {
        self.controlAPI = controlAPI
        self.tunnelController = tunnelController
    }

    func loadNodes(accessToken token: String) {
        Task {
            do {
                let nodes = try await controlAPI.listNodes(accessToken: token)
                let policy = try await controlAPI.loadConfig(accessToken: token)

                await MainActor.run {
                    self.accessToken = token
                    self.policy = policy
                    self.rows = nodes.map { NodeRow(node: $0) }
                }
            } catch {
                await MainActor.run {
                    self.errorMessage = "节点列表加载失败,请重新登录或稍后重试"
                }
            }
        }
    }
}

一键测速:

swift
func testAllNodes() {
    let currentRows = rows
    guard !currentRows.isEmpty, !isTestingAll else {
        return
    }

    isTestingAll = true
    rows = currentRows.map { row in
        var row = row
        row.isTesting = true
        return row
    }

    Task {
        do {
            let nodes = currentRows.map(\.node)
            let results = try await nodeProbe.testAll(nodes)
            let nextRows = mergeAndSortProbeResults(nodes: nodes, results: results)

            await MainActor.run {
                self.rows = nextRows
                self.isTestingAll = false
            }
        } catch {
            await MainActor.run {
                self.rows = currentRows.map { row in
                    var row = row
                    row.isTesting = false
                    return row
                }
                self.isTestingAll = false
                self.errorMessage = "节点测速失败,请重试"
            }
        }
    }
}

结果合并和排序:

swift
private func mergeAndSortProbeResults(
    nodes: [SecHttpNode],
    results: [SecHttpNodeTestResult]
) -> [NodeRow] {
    let resultByNodeID = Dictionary(uniqueKeysWithValues: results.map {
        ($0.node.id, $0)
    })

    return nodes.map { node in
        guard let result = resultByNodeID[node.id] else {
            return NodeRow(
                node: node,
                isReachable: false,
                lastError: "missing probe result"
            )
        }
        return NodeRow(
            node: node,
            isReachable: result.isReachable,
            latencyMilliseconds: result.isReachable ? result.latencyMilliseconds : nil,
            packetLossPercent: result.packetLossPercent,
            lastError: result.lastError
        )
    }.sorted { lhs, rhs in
        if lhs.isReachable != rhs.isReachable {
            return lhs.isReachable == true
        }
        if (lhs.packetLossPercent ?? 100) != (rhs.packetLossPercent ?? 100) {
            return (lhs.packetLossPercent ?? 100) < (rhs.packetLossPercent ?? 100)
        }
        return (lhs.latencyMilliseconds ?? Int.max) < (rhs.latencyMilliseconds ?? Int.max)
    }
}

用户点击节点连接:

swift
func connectSelectedNode(_ row: NodeRow) {
    guard !accessToken.isEmpty else {
        errorMessage = "登录已过期,请重新登录"
        return
    }

    if row.isReachable == false {
        errorMessage = "该节点当前不可达,仍可手动尝试连接"
    }

    let config = SecHttpConfig(
        accessToken: accessToken,
        node: SecHttpConfig.Node(row.node),
        policy: policy
    )

    tunnelController.start(config: config) { [weak self] result in
        DispatchQueue.main.async {
            if case let .failure(error) = result {
                self?.errorMessage = error.localizedDescription
            }
        }
    }
}

5.4 第一方结果使用规则

  • /v1/nodes 是唯一节点来源,返回什么节点就测试什么节点。
  • SecHttpNodeProbe 不发起 HTTP 请求,不读取用户 token,也不做 VIP/SVIP 判断。
  • 测速结果按 node.id 合并回原节点列表,不要用测速结果替代原节点对象。
  • isReachable == false 或延迟为 -1 时展示“不可达”,并把节点排到末尾。
  • 丢包率比延迟更影响稳定性,推荐排序优先级是:可达性、丢包率、延迟。
  • 测速失败是展示问题,不是权限问题;连接时仍以 /v1/nodes 返回的授权节点为准。

6. Android 第三方 SDK 对接

6.1 默认测速

Java:

java
client.testAllNodes(new SecHttpClient.NodeTestCallback() {
    @Override
    public void onSuccess(List<SecHttpClient.NodeTestResult> results) {
        hideTestingLoading();

        for (SecHttpClient.NodeTestResult result : results) {
            renderNodeQuality(
                    result.getNode().getId(),
                    result.getLatencyMs(),
                    result.getPacketLossPercent(),
                    result.isReachable());
        }
    }

    @Override
    public void onError(SecHttpException error) {
        hideTestingLoading();
        showError(error.getCode(), error.getMessage());
    }
});

Kotlin:

kotlin
client.testAllNodes(object : SecHttpClient.NodeTestCallback {
    override fun onSuccess(results: List<SecHttpClient.NodeTestResult>) {
        hideTestingLoading()

        val sorted = results.sortedWith(
            compareBy<SecHttpClient.NodeTestResult> { !it.isReachable }
                .thenBy { it.packetLossPercent }
                .thenBy { it.latencyMs }
        )
        showNodes(sorted)
    }

    override fun onError(error: SecHttpException) {
        hideTestingLoading()
        showError(error.code, error.message ?: "节点测速失败")
    }
})

SDK 在 Android 主线程执行 onSuccessonError,回调内可以直接更新 UI。

6.2 自定义参数

java
client.testAllNodes(
        5,      // 每个节点测试次数,范围 1...10
        2000,   // 单次超时毫秒数,范围 100...10000
        callback);

一般前端使用默认参数即可。增加测试次数可以提高结果稳定性,但会增加完成时间和连接数。

7. iOS 第三方 SDK 对接

7.1 默认测速

swift
func testNodes() {
    isTestingNodes = true

    Task {
        do {
            let results = try await client.testAllNodes()
            let sorted = results.sorted { lhs, rhs in
                if lhs.isReachable != rhs.isReachable {
                    return lhs.isReachable
                }
                if lhs.packetLossPercent != rhs.packetLossPercent {
                    return lhs.packetLossPercent < rhs.packetLossPercent
                }
                return lhs.latencyMilliseconds < rhs.latencyMilliseconds
            }

            await MainActor.run {
                self.nodeTestResults = sorted
                self.isTestingNodes = false
            }
        } catch let error as SecHttpException {
            await MainActor.run {
                self.isTestingNodes = false
                self.showError(code: error.code, message: error.message)
            }
        } catch {
            await MainActor.run {
                self.isTestingNodes = false
                self.showError(code: "SDK_NODE_TEST_FAILED", message: error.localizedDescription)
            }
        }
    }
}

7.2 自定义参数

swift
let results = try await client.testAllNodes(
    samples: 5,                 // 范围 1...10
    timeoutMilliseconds: 2_000 // 可传范围 100...10000;SDK 有效上限为 3000 ms
)

testAllNodes 不保证在主线程返回。SwiftUI/UIKit 状态更新应放到 MainActor。 单个节点的全部采样共享最多 3 秒的总超时;即使调用方传入大于 3000 的值,SDK 也会自动按 3000 ms 执行,避免不可达节点拖慢一键测速。

8. 前端展示建议

推荐节点列表同时展示延迟和丢包率:

text
香港 01       38 ms     0%
新加坡 01     72 ms     0%
日本 02       95 ms     33%
美国 01       不可达    100%

推荐排序规则:

  1. 可达节点排在不可达节点之前。
  2. 可达节点优先按丢包率从低到高排序。
  3. 丢包率相同时按延迟从低到高排序。
  4. 不可达节点保留在列表末尾,不要直接隐藏。

推荐状态文案:

状态展示
尚未测试测速--
正在测试测试中...
延迟有效38 ms
部分失败95 ms · 丢包 33%
全部失败不可达
整体调用失败测速失败,请重试

不要仅根据延迟自动连接节点。用户选择时还应考虑丢包率;例如 40ms / 66% 通常不如 80ms / 0% 稳定。

9. 交互流程

第三方高层 SDK 建议前端流程:

  1. 完成 SDK 认证并取得节点列表。
  2. 展示节点列表,初始延迟显示 --
  3. 用户点击“一键测速”。
  4. 禁用重复点击并显示整体加载状态。
  5. 调用 testAllNodes
  6. 收到结果后更新每个节点的延迟和丢包率。
  7. 按可达性、丢包率、延迟重新排序。
  8. 用户选择节点后调用现有连接或切换接口。

第一方/自有客户端建议前端流程:

  1. 登录并取得当前用户 access_token
  2. /v1/nodes 获取当前用户授权节点。
  3. /v1/config 获取策略。
  4. 展示节点列表,初始延迟显示 --
  5. 用户点击“一键测速”。
  6. 本地测速模块只测试 /v1/nodes 返回的节点。
  7. 收到结果后更新每个节点的延迟和丢包率。
  8. 按可达性、丢包率、延迟重新排序。
  9. 用户选择节点后组装 SecHttpConfig 并调用低层 Controller 连接或切换。

测速按钮建议设置短时间防抖。SDK 当前不提供单次测速任务的主动取消接口,因此页面 退出后前端可以忽略返回结果,但不应立即连续启动多个测速任务。

10. 错误处理

整体调用进入错误回调或抛出异常的常见原因:

错误处理建议
SDK 尚未认证引导重新认证,不进入节点测速
Access Token 失效触发 SDK 续期或重新认证
拉取节点列表失败保留原节点列表并提示重试
客户端已经关闭重新创建 SDK Client
参数超出范围使用默认参数或修正调用参数

第一方/自有客户端还应处理:

错误处理建议
/v1/nodes 返回 401用户登录过期,刷新 token 或重新登录
/v1/nodes 返回空列表提示暂无可用节点,稍后重试
节点测速全部不可达保留节点列表,提示网络或节点不可达
用户等级变化重新登录或刷新节点列表,不复用旧测速结果

单个节点不可达不属于整体调用失败。SDK 会在成功结果列表中返回该节点:

text
isReachable = false
latency = -1
packetLossPercent = 100

前端应把它显示为“不可达”,而不是弹出全局错误。

11. 验收清单

  • 已认证后可以点击一键测速。
  • 未认证调用时能正确展示错误。
  • 测速期间不能重复触发相同操作。
  • 所有授权节点都有对应结果。
  • latency == -1 的节点显示为“不可达”。
  • 丢包率显示格式统一,建议取整数或保留一位小数。
  • 可达节点排在不可达节点之前。
  • 用户仍可手动选择任意可达节点。
  • 测速不会自动启动、停止或切换 VPN。
  • 页面退出后不会使用已经失效的 UI 引用更新界面。

Security HTTP SDK 官方对接技术文档