返回博客
Transport19 min read

Wi-Fi Aware 传输设计——邻居发现与数据路径

本文介绍 PlainApp 如何将 Wi-Fi Aware(NAN——Neighbor Awareness Networking)作为其在对端传输回退链中的中间层,介于 LAN(同子网 HTTPS)与 BLE(兜底 GATT RPC)之间。Wi-Fi Aware 使得两台 PlainApp 设备在处于不同 SSID、访客 VLAN 与 IoT VLAN,甚至完全没有 Wi-Fi 基础设施的情况下也能通信——而无需从 DHCP 服务器获取任何 IP 地址。

本文涵盖仅限 Android 的 Aware 会话生命周期、发布 / 订阅发现模型、在框架约 500 ms 窗口内同步两侧 requestNetwork两阶段角色拆分握手、带空闲清理的每对端连接池、让单一 OkHttp 客户端同时服务 LAN 与 Aware 的 IPv6 + 自定义 DNS 技巧,以及通过 BLE 触发对端 Aware 启动的预热器。

关于更广泛的回退链,参见 Chat Architecture。 关于 Aware 不可用时接管传输的 BLE 传输层,参见 BLE Transport。关于作为 Aware PMK 复用的共享 ChaCha20 密钥如何建立,参见 Pairing Flow

目录

为什么使用 Wi-Fi Aware?

Wi-Fi Aware(IEEE 802.11bc,前称 NAN——Neighbor Awareness Networking)是 Wi-Fi 联盟的一项认证,允许两台设备在没有任何 Wi-Fi 基础设施的情况下发现彼此并交换数据——无需 AP、无需路由器、无需 DHCP。PlainApp 将其用于 LAN 无法覆盖的两种场景:

  • 不同 SSID / VLAN。 一台手机在访客网络、一台笔记本在 IoT VLAN 中,二者都通过 Wi-Fi "在线"但无法访问对方的 IP。Aware 创建一条直接绕过基础设施的设备到设备数据路径。
  • 完全没有基础设施。 两台在野外开启 Wi-Fi 但无 AP 的设备仍可聊天。(BLE 也能覆盖此场景,但 Aware 快得多——约 10 ms 往返对比数秒,MB/s 对比数十 KB/s。)

Diagram 1
1

平台限制

Wi-Fi Aware 在 PlainApp 中仅限 Android

  • Android 13(API 33)是最低版本——PlainApp 依赖的 WifiAwareNetworkSpecifier.BuildersetPort()setPmk() 重载需要 isTPlus()
  • iOS 不向第三方应用开放 Wi-Fi Aware。iOS PlainApp 直接从 LAN 回退到 BLE;WifiAwareTransport 对象甚至不会编译进 iOS 目标(@RequiresApi(Build.VERSION_CODES.S) + androidMain 源集)。

这就是为什么 PeerTransportRouter.buildList 调用 createWifiAwareTransport()——一个在 iOS 上返回 null 的工厂。

Aware 在回退链中的位置

PlainApp 的 PeerTransportRouter 是一个有序列表。对于每次 senddownloadFile 调用,它会遍历列表并依次尝试每种传输直到某一种成功;失败会逐级下落。

Diagram 2
2

为什么 Aware 是"中间层"而非"首选"?

因为 LAN 在可用时几乎总是更快。经 AP 的同子网 Wi-Fi 跳是一帧 802.11 交换;而 Aware 数据路径会增加一次 NDP 建立(首次使用约 5 s)以及设备到设备链路的第二个 Wi-Fi 无线电上下文。若两者都可达,LAN 在延迟与吞吐量上都胜出。

反过来,BLE 总是更慢——但只要两台设备已配对它就能工作。Aware 居中:比 BLE 快、比 LAN 慢,且仅在开启 Wi-Fi 的 Android 13+ 设备上可用。

会话生命周期:Attach → Publish + Subscribe

Wi-Fi Aware 会话是进程范围的。每台设备有且仅有一个 WifiAwareSession;其中 PlainApp 运行一个发布会话(让对端能发现本机)和一个订阅会话(让本机能发现对端)。二者都在 AwareSession.start() 完成 attach 回调的瞬间启动。

Diagram 3
3

为什么在同一台设备上既发布又订阅?

Wi-Fi Aware 的发现模型是非对称的:发布者广播一个服务,订阅者扫描该服务。要让发现对称(双方都发现彼此),PlainApp 同时进行两者。否则,设备 A 必须预先知道对于某个对端它是发布者还是订阅者——但对端角色由 clientId 比较稍后决定(参见 发现与角色分配)。

同时发布与订阅意味着每台设备都能看到对方的 onServiceDiscovered(作为订阅者)并接收对方的 hello 消息(作为发布者)——握手的两个方向始终可用。

终止时自动重启

某些 Android 变体(尤其是 MIUI)会为了省电杀掉长时间运行的 Aware 会话。PlainApp 在 onSessionTerminated 回调中处理此情况:将被终止的会话置空并立即在仍处于 attach 状态的 WifiAwareSession 上再次调用 publishOwnService / subscribeOwnService。attach 会话本身并未丢失——丢失的只是发布/订阅发现会话。终止前的 PeerHandle 会变陈旧,这就是 awaitPeerHandle 检查 discoveredAt 时间戳并丢弃超过 30 s 的句柄的原因。

发现与角色分配

Wi-Fi Aware 数据路径协议要求一方充当发布者(服务器),另一方充当订阅者(客户端)。双方不能同时作为发起方——框架会拒绝没有匹配对端的请求。

PlainApp 通过对 clientId 的简单字典序比较按对端确定性地分配角色:

Diagram 4
4

为什么是确定性而非协商?

协商方式(如"MAC 更小者为服务器")需要额外的消息交换。字典序比较是幂等、对称且无状态的:两台设备无需任何通信即可为同一对计算出相同角色。clientId 是 13 字符的短 UUID,因此平局(clientId == peer.id)只在与自身比较时发生——这永远不会到达传输层。

该角色在下游决定两件事:

  1. 谁驱动重试循环。 仅客户端重试 requestNetwork;服务器对收到的每个 hello 仅做一次尝试。这对 500 ms 窗口(下一节)至关重要。
  2. 谁设置端口。 发布者调用 setPort(httpsPort),因为它是那个在自己 HTTPS 服务器端口上接受入站连接的一方。订阅者不设置端口——它在数据路径建立后从 WifiAwareNetworkInfo 中获知对端端口。

两阶段握手(hello + ready)

Wi-Fi Aware 数据路径建立中最难的部分是时序。框架要求双方在大约 500 ms 内相继调用 connectivityManager.requestNetwork——若一方在另一方注册匹配请求之前调用,框架会立即以 onUnavailable("releaseRequestAsUnfulfillableByAnyFactory")拒绝。

PlainApp 通过在 Aware L2 消息通道(即 onServiceDiscovered 使用的同一个 sendMessage API)之上运行的两消息应用层握手来解决此问题:

Diagram 5
5

为什么是两条消息(hello + ready)而不是一条?

仅 hello 不够,因为存在方向不对称。订阅者可以在发现发布者的瞬间(在 onServiceDiscovered 中)发送 hello,但发布者直到收到 hello 才能获得订阅者的 PeerHandle,因此无法启动 requestNetwork。所以 hello 服务于两个目的:

  1. 将订阅者的 PeerHandle 投递给发布者。 发布者需要它来构建 WifiAwareNetworkSpecifier
  2. 发出连接意图信号。 收到 hello 即告诉发布者"订阅者即将 requestNetwork,所以我也应如此。"

ready 回执存在的目的是相反方向——告诉订阅者"发布者已注册其 requestNetwork"。若没有它,订阅者的 requestNetwork 可能领先于发布者并被框架拒绝。ready 回执是一个非阻塞信号:订阅者不会在调用 requestNetwork 之前等待它(那会多一次往返),但如果它到达时订阅者处于 IDLE 状态(在重试尝试之间),订阅者可立即重试而无需等待 RETRY_DELAY_MS 间隔。

重试循环的不对称

这是设计中最微妙的部分。仅订阅者重试。 发布者对收到的每个 hello 仅做一次 requestNetwork 尝试。原因:

  • 若双方独立重试,其重试周期会因不同的 delay() 时长、不同的 GC 暂停而相互错相,两条 requestNetwork 调用很少能在 500 ms 窗口内重叠。
  • 订阅者的重试循环在每次尝试时发送一个新的 hello,这会通过 publishHelloListeners 重新触发发布者的 buildLink。这保证了发布者的 requestNetwork 总是落后 hello 约 50 ms,远在 500 ms 窗口之内。

详细文档见 AwarePeerLink.build

NDP requestNetwork——500 ms 窗口

requestNetwork 调用是 Aware 传输中对时序最敏感的操作。以下是每侧发生的事情:

Diagram 6
6

onUnavailable 回调的含义

onUnavailable 在框架找到匹配对端请求之前拒绝 requestNetwork 时触发。PeerHandle 本身仍然有效——只是 NDP(Neighbor Discovery Protocol)配对失败,因为对端尚未注册。PlainApp 在这种情况下故意不调用 session.invalidatePeerHandle,因为使句柄失效会丢弃 onServiceDiscovered 曾被调用过的唯一信号(它在每个订阅会话生命周期内每个对端只触发一次)。保留句柄使重试可复用它,而非等待一次全新发现。

发布者侧从 onMessageReceived 获得的句柄同理——发布者在失败的尝试间保留 publishPeerHandles[fromCid] 条目,因此订阅者的下一个 hello 可复用缓存的句柄而非被丢弃。

每对端连接池与空闲清理

每个已配对的对端都有自己的 AwarePeerLink 对象,由进程范围的 AwareLinkPool 持有。连接池处理发现事件、连接复用与空闲驱逐。

Diagram 7
7

为什么发现时不自动建立连接?

连接池在 onServiceDiscovered 触发时明确不建立连接。这是一个关键决策:一家繁忙的咖啡馆可能有 100 台 PlainApp 设备都在发布 "plain-peer" 服务。若每次发现都触发一次 requestNetwork,框架会被 NDP 建立尝试淹没,Wi-Fi 无线电也会饱和。

相反,连接池只记录 PeerHandle,并等待以下之一:

  1. 本地用户发送消息WifiAwareTransport.sendpool.buildLink(peer)(发送方触发)。
  2. 远端对端发送 helloonPublishHelloReceivedbuildLink(peer)(接收方触发)。
  3. 远端对端发送 readyonSubscribeReadyReceivedbuildLink(peer)(接收方触发)。

这样,连接只为用户实际与之交换消息的对端建立——而非无线电范围内的每一台 PlainApp 设备。

空闲清理

每 10 秒,连接池遍历所有连接并关闭 lastActiveAt 超过 60 秒的连接。每次 senddownloadFile 都调用 link.touch() 刷新时间戳。这为用户已停止聊天的对端回收 Wi-Fi 无线电上下文与 OkHttp 连接池——很重要,因为 Android 将同时 Aware 数据路径数量限制在大约 4–10 条(因设备而异)。

IPv6 寻址与 plain-aware-peer DNS 技巧

Wi-Fi Aware 数据路径仅使用链路本地 IPv6。没有 IPv4、没有 DNS 服务器、没有 DHCP。对端的 IPv6 地址通过 onCapabilitiesChanged 中的 WifiAwareNetworkInfo.peerIpv6Addr 字段投递——一个仅在 Aware 网络接口上有意义的 fe80::... 地址。

PlainApp 需要向此地址发送 HTTPS 请求,但 OkHttp 的 https:// URL 解析拒绝主机名中的裸 IPv6 字面量(https://[fe80::abcd]:8443/ 可行,但通过自定义 Dns 解析器路由更干净)。技巧如下:

Diagram 8
8

为什么使用哨兵主机名?

替代方案——直接在 URL 中传递 IPv6 字面量——会要求每个调用点都知道链路本地地址。使用哨兵主机名后,URL 构造对 LAN 与 Aware 完全一致:二者都产生一个 OkHttp 可解析的有效 https://<host>:<port>/peer_graphql URL。唯一区别是绑定到客户端的 Dns 实现——LAN 使用系统 DNS,Aware 使用 awareDns(peerIpv6),它为哨兵主机名返回缓存的链路本地地址,对其他主机名则回落到 Dns.SYSTEM

为什么使用 network.socketFactory

Android 的 Network 对象表示一个特定网络接口(此处为 Aware 数据路径)。通过调用 network.socketFactory 并将其传递给 OkHttp 的 socketFactory 配置,强制所有 TCP 套接字在 Aware 接口上创建——而非默认的 Wi-Fi 或蜂窝接口。否则,操作系统会经默认网络路由请求,而链路本地 IPv6 在那里不可达,请求会以 ENETUNREACH 失败。

密码学:PMK 派生与 ChaCha20 复用

Wi-Fi Aware 支持为数据路径使用可选的 PMK(Pairwise Master Key)。设置后,L2 链路本身即用该 PMK 加密——由 Wi-Fi 无线电处理加密,无需应用层加密。

PlainApp 从 LanTransportBleTransport 用于应用层加密的同一 ChaCha20 共享密钥派生 PMK:

Diagram 9
9

为什么截断到 32 字节?

Wi-Fi Aware PMK 必须正好 32 字节(256 位)。配对得到的 ChaCha20 共享密钥在正常情况下也是 32 字节,因此 raw.size == 32 分支是常见路径。截断/填充回退处理密钥被存储得更短的(理论上的)情况——以零填充到 32 字节是一种防御性措施,在正常配对的对端中不会发生。

签名信封与 LAN 完全一致

由于 createCryptoHttpClientLanTransport 使用的是同一个工厂,Aware 上的 L7 加密与 LAN 逐字节一致。服务器侧的 PeerGraphQLService 并不知道(也不关心)请求由哪种传输投递——它只看到一个已签名、已加密的 GraphQL 载荷,并用对端的共享密钥解密。这就是 Chat Architecture 中记载的"一套代码,多种传输"原则。

消息发送路径(端到端)

把所有内容串起来——当一条聊天消息经 Wi-Fi Aware 发送时发生的事情:

Diagram 10
10

关键设计选择

  • 连接复用。 与每次请求后拆除 GATT 连接的 BleTransport 不同,WifiAwareTransport 在 60 s 空闲窗口内为用户的尽可能多的请求复用 Aware 数据路径。首次请求承担约 400 ms 握手;后续请求是约 10 ms 往返。
  • 与 LAN 相同的加密。 ChaCha20 拦截器与签名信封与 LAN 逐字节一致。对端的 PeerGraphQLService 并不知道请求由哪种传输投递。
  • 链路失败时不抢占。buildLink 失败,传输抛出 TransportUnavailable,路由器下落到 BLE。send 内部不重试——AwarePeerLink.build 已有自己的内部重试循环(客户端上 MAX_BUILD_ATTEMPTS = 1,若预热器已预热双方则更多)。

文件下载路径(端到端)

经 Aware 的文件下载复用与聊天消息相同的数据路径,但使用一个单独的 OkHttp 客户端,专为流式传输大文件配置:

Diagram 11
11

为什么下载要单独的客户端?

聊天客户端(AwareHttpClientFactory.build)有 30 s 的 requestTimeoutMillis——适合 GraphQL mutation,但对 100 MB 文件下载是灾难性的。下载客户端(buildFileDownload)设置:

  • connectTimeoutMillis = 10_000(比聊天的 5 s 更长,对新数据路径上较慢的首包更宽容)
  • readTimeout = 120 s 每次读取(对比隐式默认的 10 s)
  • requestTimeoutMillis = 120_000(2 分钟——足够大多数文件)
  • retryOnConnectionFailure(true)——下载中途读取被丢弃时重试,而非使整个传输失败

它还省略 ChaCha20 拦截器/fs 端点提供原始文件字节(而非签名 GraphQL 信封),且 L2 PMK(若存在)已加密无线链路。用 ChaCha20 在软件中双重加密一个 50 MB 视频会浪费 CPU 并拖慢传输。

流式而非缓冲

与 BLE 路径一样,Aware 下载通过 ByteReadChannel 流式传输文件——文件在字节到达时写入临时文件,而非在内存中缓冲。PeerFileDownloader 读取 8 KB 块并每秒发出进度事件。同样的 DownloadedResponse / PeerFileDownloader / DownloadQueue 管道在所有传输中复用——仅 channel 源是传输特定的。

预热:BLE 触发的 Aware 启动

Aware 路径中最大的用户可感知延迟是首次握手——若双方尚未启动 Aware,用户的首条消息必须等待:

  1. 本地 Aware 会话 attach(约 1 s)
  2. 本地 publish + subscribe 启动(约 1 s)
  3. 远端对端的 Aware 启动(经 BLE 约 2 s)
  4. 互相发现(约 1 s)
  5. NDP 握手(约 400 ms)

也就是说首字节发出前约 5 秒。为隐藏此延迟,PeerTransportPrewarmer 在进入 ChatPage 时运行并通过 BLE 触发远端对端的 Aware 启动

Diagram 12
12

BLE 的双重角色

BLE 在此承担两个目的:

  1. 读取对端当前 Aware 状态(廉价,无需 GATT 连接——扫描响应的 serviceData byte0 携带 Aware 标志)。
  2. 若对端支持但当前未运行 Aware,则触发其启动。 这经由常规 BleTransport.send 路径——一个用共享 ChaCha20 密钥加密、通过 GATT RPC 投递到对端 /peer_graphql 端点的 startAware GraphQL mutation。

这是少数几处传输之间相互协作而非仅回退的地方之一:BLE 被用于在用户察觉之前抢先升级会话到更快的 Aware 传输。

为什么乐观地 setAwareRunning(true)

startAware mutation 在远端对端的解析器调用 WifiAwareTransport.start() 后即返回成功——但 Aware 会话实际尚未 attach(onAttached 异步触发)。PlainApp 乐观地将该对端标记为 awareRunning = true,因为:

  • 若它确实启动了,下一次 send 将使用 Aware(快)。
  • 若没有(如对端 Wi-Fi 关闭),下一次 sendbuildLink 会以 TransportUnavailable 失败并自然回退到 BLE。
  • 假阳性的代价是一次约 5 s 超时,而非永久阻塞——PeerCircuitBreaker 记录该失败但不会打开 BLE 腿(BLE 仅在其自身失败时打开)。

为什么限流到 30 s?

PeerTransportPrewarmer.prewarm(peerId) 按对端记录时间戳,并拒绝在 30 s 内重复运行。这是因为用户在聊天列表与聊天页之间频繁来回导航——若不限流,每次导航都会触发 BLE 扫描 + startAware mutation,耗电并轰炸 BLE 无线电。30 s 窗口短到足以捕捉刚上线的对端(如用户在远端设备上打开应用),又长到足以避免虚假重跑。

失败模式与快速跳过标志

Aware 比任何其他传输都有更多失败模式。isAwareRunning 快速跳过标志是整个模块中最重要的单一优化——没有它,每次 send 都会在 buildLink 上浪费 10 s 超时后才回退到 BLE。

Diagram 13
13

isAwareRunning 标志是关键

没有这个布尔值,每次 Aware send 都会要么:

  • 总是尝试 buildLink → 对每条发往 Aware 未运行对端的消息都 10 s 超时。
  • 总是跳过 Aware → 即便双方都运行它也从不使用。

该标志从两个来源刷新,按权威性排序:

  1. BLE 扫描响应(廉价,无需 GATT 连接)——由 PeerTransportPrewarmer.refreshAwareFlagFromScan 设置。对端在 9 字节 serviceData 载荷(byte0 位域)中广播其 Aware 状态。
  2. GATT DISCOVER 回复(权威)——在完整发现发生时由 PairingTransport.scanAndDiscover 设置。这会覆盖扫描提示。

当为 false 时,WifiAwareTransport.senddownloadFile 立即抛出 TransportUnavailable——不扫描、不握手、不超时。路由器在微秒级下落到 BLE。

关键常量参考

常量位置用途
AwareSession.SERVICE_NAME"plain-peer"发现每台 PlainApp 设备发布与订阅的服务名
AwareSession.PEER_HANDLE_MAX_AGE_MS30 000PeerHandle 缓存丢弃陈旧句柄(对端的发布会话可能已被重启)
AwareSession.READY_TIMEOUT_MS15 000握手订阅者等待发布者 ready 回执的时长
AwareSession.MSG_HELLO0握手订阅者 → 发布者 消息 ID
AwareSession.MSG_READY1握手发布者 → 订阅者 消息 ID
AwarePeerLink.MAX_BUILD_ATTEMPTS1握手(仅客户端)单次尝试——曾为 3,现为 1,因预热器已预热双方
AwarePeerLink.ATTEMPT_TIMEOUT_MS5 000握手每次尝试超时——曾为 10 s,减半以加速回退
AwarePeerLink.RETRY_DELAY_MS500握手重试间隔(仅客户端)
AwarePeerLink.REQUEST_TIMEOUT_MS30 000NDPconnectivityManager.requestNetwork 超时
AwareLinkPool.IDLE_TIMEOUT_MS60 000连接池清理60 s 不活动后关闭空闲连接
AwareLinkPool.IDLE_SWEEP_INTERVAL_MS10 000连接池清理清理间隔
AwareHttpClientFactory.AWARE_HOST"plain-aware-peer"DNS由自定义 Dns 解析到对端 IPv6 的哨兵主机名
build 聊天客户端connectTimeout 5 s,requestTimeout 30 s,含 ChaCha20 拦截器
buildFileDownloadconnectTimeout 10 s,readTimeout 120 s,requestTimeout 120 s,无加密
PeerTransportPrewarmer.PREWARM_TTL_MS30 000预热按对端限流
PeerTransportPrewarmer.BLE_SCAN_TIMEOUT_MS15 000预热refreshAwareFlagFromScan 的 BLE 扫描超时
PeerCircuitBreaker.WINDOW_MS30 000熔断器达到阈值后的打开时长
PeerCircuitBreaker.MAX_FAILURES2熔断器窗口内打开所需的失败次数
TempData.httpsPort8443(默认)服务器通过 WifiAwareNetworkSpecifier.setPort 广播的发布者端口
BleServiceData.AWARE_SUPPORTED0x01BLE 扫描响应指示对端支持 Wi-Fi Aware 的位
BleServiceData.AWARE_RUNNING0x02BLE 扫描响应指示对端 Aware 服务当前正在运行的位

设计权衡回顾

Diagram 14
14

延伸阅读

  • Chat Architecture——WifiAwareTransport 如何融入 LAN → Aware → BLE 回退链以及更广泛的聊天发送/接收管道。
  • BLE Transport——Aware 不可用时接管的兜底传输;也是预热器在远端对端上触发 Aware 启动所使用的通道。
  • Pairing Flow——作为 Aware PMK 复用的共享 ChaCha20 密钥如何建立,以及 BLE 扫描响应标志(AWARE_SUPPORTED / AWARE_RUNNING)如何填充。