Appearance
Security HTTP 移动端节点一键测速对接指南
本文档面向 Android 和 iOS 前端开发团队,说明如何使用移动 SDK 一键测试当前设备到 所有授权节点的延迟和丢包率,并据此辅助用户选择节点。
Android 无 Context 节点测速 API 的迁移、各架构层 Context 获取方式以及 TUN 已连接时的 验收步骤见 FRONTEND_MOBILE_NODE_PROBE_CONTEXT_MIGRATION_GUIDE.md。 本文继续定义节点来源、测速流程、结果字段与 UI 规则。
1. 能力说明
一键测速是移动端本地能力,不需要新增服务端接口。当前需要区分两种接入模式:
- 第三方高层 SDK:Android
SecHttpClient、iOSSecHttpSDKClient已提供testAllNodes,SDK 内部会拉取授权节点后逐个测速。 - 第一方/自有客户端:当前使用 Android
SecHttpVpnController或 iOSSecHttpTunnelController的低层接口。低层 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. 结果字段
| 含义 | Android | iOS | 说明 |
|---|---|---|---|
| 节点 | getNode() | node | 节点完整信息 |
| 平均延迟 | getLatencyMs() | latencyMilliseconds | 成功探测的平均耗时,单位毫秒;-1 表示全部失败 |
| 丢包率 | getPacketLossPercent() | packetLossPercent | 范围 0...100 |
| 成功次数 | getSuccessfulSamples() | successfulSamples | TCP 建连成功次数 |
| 总次数 | getTotalSamples() | totalSamples | 本次节点探测总次数 |
| 是否可达 | isReachable() | isReachable | 至少一次探测成功 |
| 最后错误 | getLastError() | lastError | 全部或部分失败时的诊断信息,可能为空 |
例如测试 3 次:
| 成功次数 | 丢包率 | 延迟 |
|---|---|---|
| 3 | 0% | 3 次成功结果的平均值 |
| 2 | 33.33% | 2 次成功结果的平均值 |
| 1 | 66.67% | 该次成功结果 |
| 0 | 100% | -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 代码位置:
| 平台 | 文件 | 说明 |
|---|---|---|
| Android | sdk/android/src/main/java/com/securityhttp/sdk/SecHttpNodeProbe.java | TUN 已连接或可能已连接时必须调用 SecHttpNodeProbe.testAll(context, ...),接收 /v1/nodes 转换出的节点 |
| iOS | sdk/ios/src/SecHttpNodeProbe.swift | SecHttpNodeProbe().testAll(...),接收 [SecHttpNode] |
5.1 Android Context 必填与迁移
Android 第一方 SDK 已移除以下旧方法,不能再调用:
kotlin
// 已移除:不能保证 TUN 已开启时仍使用底层网络。
SecHttpNodeProbe.testAll(probeNodes, callback)必须改为传入 Context:
kotlin
SecHttpNodeProbe.testAll(context, probeNodes, callback)推荐传 applicationContext,避免测速任务持有 Activity 或 Fragment:
| 调用位置 | 获取并传入的 Context |
|---|---|
Activity | applicationContext(或直接传 this) |
Fragment | requireContext().applicationContext |
ViewModel / Repository | 通过依赖注入传入 Application 或 applicationContext;不要保存 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 == -1 或 isReachable == 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 主线程执行 onSuccess 和 onError,回调内可以直接更新 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%推荐排序规则:
- 可达节点排在不可达节点之前。
- 可达节点优先按丢包率从低到高排序。
- 丢包率相同时按延迟从低到高排序。
- 不可达节点保留在列表末尾,不要直接隐藏。
推荐状态文案:
| 状态 | 展示 |
|---|---|
| 尚未测试 | 测速 或 -- |
| 正在测试 | 测试中... |
| 延迟有效 | 38 ms |
| 部分失败 | 95 ms · 丢包 33% |
| 全部失败 | 不可达 |
| 整体调用失败 | 测速失败,请重试 |
不要仅根据延迟自动连接节点。用户选择时还应考虑丢包率;例如 40ms / 66% 通常不如 80ms / 0% 稳定。
9. 交互流程
第三方高层 SDK 建议前端流程:
- 完成 SDK 认证并取得节点列表。
- 展示节点列表,初始延迟显示
--。 - 用户点击“一键测速”。
- 禁用重复点击并显示整体加载状态。
- 调用
testAllNodes。 - 收到结果后更新每个节点的延迟和丢包率。
- 按可达性、丢包率、延迟重新排序。
- 用户选择节点后调用现有连接或切换接口。
第一方/自有客户端建议前端流程:
- 登录并取得当前用户
access_token。 - 调
/v1/nodes获取当前用户授权节点。 - 调
/v1/config获取策略。 - 展示节点列表,初始延迟显示
--。 - 用户点击“一键测速”。
- 本地测速模块只测试
/v1/nodes返回的节点。 - 收到结果后更新每个节点的延迟和丢包率。
- 按可达性、丢包率、延迟重新排序。
- 用户选择节点后组装
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 引用更新界面。