本文涵盖仅限 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?
- Aware 在回退链中的位置
- 会话生命周期:Attach → Publish + Subscribe
- 发现与角色分配
- 两阶段握手(hello + ready)
- NDP requestNetwork——500 ms 窗口
- 每对端连接池与空闲清理
- IPv6 寻址与
plain-aware-peerDNS 技巧 - 密码学:PMK 派生与 ChaCha20 复用
- 消息发送路径(端到端)
- 文件下载路径(端到端)
- 预热:BLE 触发的 Aware 启动
- 失败模式与快速跳过标志
- 关键常量参考
- 设计权衡回顾
为什么使用 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。)
平台限制
Wi-Fi Aware 在 PlainApp 中仅限 Android:
- Android 13(API 33)是最低版本——PlainApp 依赖的
WifiAwareNetworkSpecifier.Builder的setPort()与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 是一个有序列表。对于每次 send 或 downloadFile 调用,它会遍历列表并依次尝试每种传输直到某一种成功;失败会逐级下落。
为什么 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 回调的瞬间启动。
为什么在同一台设备上既发布又订阅?
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 的简单字典序比较按对端确定性地分配角色:
为什么是确定性而非协商?
协商方式(如"MAC 更小者为服务器")需要额外的消息交换。字典序比较是幂等、对称且无状态的:两台设备无需任何通信即可为同一对计算出相同角色。clientId 是 13 字符的短 UUID,因此平局(clientId == peer.id)只在与自身比较时发生——这永远不会到达传输层。
该角色在下游决定两件事:
- 谁驱动重试循环。 仅客户端重试
requestNetwork;服务器对收到的每个 hello 仅做一次尝试。这对 500 ms 窗口(下一节)至关重要。 - 谁设置端口。 发布者调用
setPort(httpsPort),因为它是那个在自己 HTTPS 服务器端口上接受入站连接的一方。订阅者不设置端口——它在数据路径建立后从WifiAwareNetworkInfo中获知对端端口。
两阶段握手(hello + ready)
Wi-Fi Aware 数据路径建立中最难的部分是时序。框架要求双方在大约 500 ms 内相继调用 connectivityManager.requestNetwork——若一方在另一方注册匹配请求之前调用,框架会立即以 onUnavailable("releaseRequestAsUnfulfillableByAnyFactory")拒绝。
PlainApp 通过在 Aware L2 消息通道(即 onServiceDiscovered 使用的同一个 sendMessage API)之上运行的两消息应用层握手来解决此问题:
为什么是两条消息(hello + ready)而不是一条?
仅 hello 不够,因为存在方向不对称。订阅者可以在发现发布者的瞬间(在 onServiceDiscovered 中)发送 hello,但发布者直到收到 hello 才能获得订阅者的 PeerHandle,因此无法启动 requestNetwork。所以 hello 服务于两个目的:
- 将订阅者的 PeerHandle 投递给发布者。 发布者需要它来构建
WifiAwareNetworkSpecifier。 - 发出连接意图信号。 收到 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 传输中对时序最敏感的操作。以下是每侧发生的事情:
onUnavailable 回调的含义
onUnavailable 在框架找到匹配对端请求之前拒绝 requestNetwork 时触发。PeerHandle 本身仍然有效——只是 NDP(Neighbor Discovery Protocol)配对失败,因为对端尚未注册。PlainApp 在这种情况下故意不调用 session.invalidatePeerHandle,因为使句柄失效会丢弃 onServiceDiscovered 曾被调用过的唯一信号(它在每个订阅会话生命周期内每个对端只触发一次)。保留句柄使重试可复用它,而非等待一次全新发现。
发布者侧从 onMessageReceived 获得的句柄同理——发布者在失败的尝试间保留 publishPeerHandles[fromCid] 条目,因此订阅者的下一个 hello 可复用缓存的句柄而非被丢弃。
每对端连接池与空闲清理
每个已配对的对端都有自己的 AwarePeerLink 对象,由进程范围的 AwareLinkPool 持有。连接池处理发现事件、连接复用与空闲驱逐。
为什么发现时不自动建立连接?
连接池在 onServiceDiscovered 触发时明确不建立连接。这是一个关键决策:一家繁忙的咖啡馆可能有 100 台 PlainApp 设备都在发布 "plain-peer" 服务。若每次发现都触发一次 requestNetwork,框架会被 NDP 建立尝试淹没,Wi-Fi 无线电也会饱和。
相反,连接池只记录 PeerHandle,并等待以下之一:
- 本地用户发送消息 →
WifiAwareTransport.send→pool.buildLink(peer)(发送方触发)。 - 远端对端发送 hello →
onPublishHelloReceived→buildLink(peer)(接收方触发)。 - 远端对端发送 ready →
onSubscribeReadyReceived→buildLink(peer)(接收方触发)。
这样,连接只为用户实际与之交换消息的对端建立——而非无线电范围内的每一台 PlainApp 设备。
空闲清理
每 10 秒,连接池遍历所有连接并关闭 lastActiveAt 超过 60 秒的连接。每次 send 与 downloadFile 都调用 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 解析器路由更干净)。技巧如下:
为什么使用哨兵主机名?
替代方案——直接在 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 从 LanTransport 与 BleTransport 用于应用层加密的同一 ChaCha20 共享密钥派生 PMK:
为什么截断到 32 字节?
Wi-Fi Aware PMK 必须正好 32 字节(256 位)。配对得到的 ChaCha20 共享密钥在正常情况下也是 32 字节,因此 raw.size == 32 分支是常见路径。截断/填充回退处理密钥被存储得更短的(理论上的)情况——以零填充到 32 字节是一种防御性措施,在正常配对的对端中不会发生。
签名信封与 LAN 完全一致
由于 createCryptoHttpClient 与 LanTransport 使用的是同一个工厂,Aware 上的 L7 加密与 LAN 逐字节一致。服务器侧的 PeerGraphQLService 并不知道(也不关心)请求由哪种传输投递——它只看到一个已签名、已加密的 GraphQL 载荷,并用对端的共享密钥解密。这就是 Chat Architecture 中记载的"一套代码,多种传输"原则。
消息发送路径(端到端)
把所有内容串起来——当一条聊天消息经 Wi-Fi Aware 发送时发生的事情:
关键设计选择
- 连接复用。 与每次请求后拆除 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 客户端,专为流式传输大文件配置:
为什么下载要单独的客户端?
聊天客户端(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,用户的首条消息必须等待:
- 本地 Aware 会话 attach(约 1 s)
- 本地 publish + subscribe 启动(约 1 s)
- 远端对端的 Aware 启动(经 BLE 约 2 s)
- 互相发现(约 1 s)
- NDP 握手(约 400 ms)
也就是说首字节发出前约 5 秒。为隐藏此延迟,PeerTransportPrewarmer 在进入 ChatPage 时运行并通过 BLE 触发远端对端的 Aware 启动:
BLE 的双重角色
BLE 在此承担两个目的:
- 读取对端当前 Aware 状态(廉价,无需 GATT 连接——扫描响应的
serviceDatabyte0 携带 Aware 标志)。 - 若对端支持但当前未运行 Aware,则触发其启动。 这经由常规
BleTransport.send路径——一个用共享 ChaCha20 密钥加密、通过 GATT RPC 投递到对端/peer_graphql端点的startAwareGraphQL mutation。
这是少数几处传输之间相互协作而非仅回退的地方之一:BLE 被用于在用户察觉之前抢先升级会话到更快的 Aware 传输。
为什么乐观地 setAwareRunning(true)?
startAware mutation 在远端对端的解析器调用 WifiAwareTransport.start() 后即返回成功——但 Aware 会话实际尚未 attach(onAttached 异步触发)。PlainApp 乐观地将该对端标记为 awareRunning = true,因为:
- 若它确实启动了,下一次
send将使用 Aware(快)。 - 若没有(如对端 Wi-Fi 关闭),下一次
send的buildLink会以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。
isAwareRunning 标志是关键
没有这个布尔值,每次 Aware send 都会要么:
- 总是尝试
buildLink→ 对每条发往 Aware 未运行对端的消息都 10 s 超时。 - 总是跳过 Aware → 即便双方都运行它也从不使用。
该标志从两个来源刷新,按权威性排序:
- BLE 扫描响应(廉价,无需 GATT 连接)——由
PeerTransportPrewarmer.refreshAwareFlagFromScan设置。对端在 9 字节serviceData载荷(byte0 位域)中广播其 Aware 状态。 - GATT DISCOVER 回复(权威)——在完整发现发生时由
PairingTransport.scanAndDiscover设置。这会覆盖扫描提示。
当为 false 时,WifiAwareTransport.send 与 downloadFile 立即抛出 TransportUnavailable——不扫描、不握手、不超时。路由器在微秒级下落到 BLE。
关键常量参考
| 常量 | 值 | 位置 | 用途 |
|---|---|---|---|
AwareSession.SERVICE_NAME | "plain-peer" | 发现 | 每台 PlainApp 设备发布与订阅的服务名 |
AwareSession.PEER_HANDLE_MAX_AGE_MS | 30 000 | PeerHandle 缓存 | 丢弃陈旧句柄(对端的发布会话可能已被重启) |
AwareSession.READY_TIMEOUT_MS | 15 000 | 握手 | 订阅者等待发布者 ready 回执的时长 |
AwareSession.MSG_HELLO | 0 | 握手 | 订阅者 → 发布者 消息 ID |
AwareSession.MSG_READY | 1 | 握手 | 发布者 → 订阅者 消息 ID |
AwarePeerLink.MAX_BUILD_ATTEMPTS | 1 | 握手(仅客户端) | 单次尝试——曾为 3,现为 1,因预热器已预热双方 |
AwarePeerLink.ATTEMPT_TIMEOUT_MS | 5 000 | 握手 | 每次尝试超时——曾为 10 s,减半以加速回退 |
AwarePeerLink.RETRY_DELAY_MS | 500 | 握手 | 重试间隔(仅客户端) |
AwarePeerLink.REQUEST_TIMEOUT_MS | 30 000 | NDP | connectivityManager.requestNetwork 超时 |
AwareLinkPool.IDLE_TIMEOUT_MS | 60 000 | 连接池清理 | 60 s 不活动后关闭空闲连接 |
AwareLinkPool.IDLE_SWEEP_INTERVAL_MS | 10 000 | 连接池清理 | 清理间隔 |
AwareHttpClientFactory.AWARE_HOST | "plain-aware-peer" | DNS | 由自定义 Dns 解析到对端 IPv6 的哨兵主机名 |
build 聊天客户端 | connectTimeout 5 s,requestTimeout 30 s,含 ChaCha20 拦截器 | ||
buildFileDownload | connectTimeout 10 s,readTimeout 120 s,requestTimeout 120 s,无加密 | ||
PeerTransportPrewarmer.PREWARM_TTL_MS | 30 000 | 预热 | 按对端限流 |
PeerTransportPrewarmer.BLE_SCAN_TIMEOUT_MS | 15 000 | 预热 | refreshAwareFlagFromScan 的 BLE 扫描超时 |
PeerCircuitBreaker.WINDOW_MS | 30 000 | 熔断器 | 达到阈值后的打开时长 |
PeerCircuitBreaker.MAX_FAILURES | 2 | 熔断器 | 窗口内打开所需的失败次数 |
TempData.httpsPort | 8443(默认) | 服务器 | 通过 WifiAwareNetworkSpecifier.setPort 广播的发布者端口 |
BleServiceData.AWARE_SUPPORTED | 0x01 | BLE 扫描响应 | 指示对端支持 Wi-Fi Aware 的位 |
BleServiceData.AWARE_RUNNING | 0x02 | BLE 扫描响应 | 指示对端 Aware 服务当前正在运行的位 |
设计权衡回顾
延伸阅读
- Chat Architecture——
WifiAwareTransport如何融入LAN → Aware → BLE回退链以及更广泛的聊天发送/接收管道。 - BLE Transport——Aware 不可用时接管的兜底传输;也是预热器在远端对端上触发 Aware 启动所使用的通道。
- Pairing Flow——作为 Aware PMK 复用的共享 ChaCha20 密钥如何建立,以及 BLE 扫描响应标志(
AWARE_SUPPORTED/AWARE_RUNNING)如何填充。