引子:一个不需要互联网的聊天软件,凭什么在 GitHub 上日增 2300 星
2026 年 7 月的最后一周,GitHub Trending 榜首被一个看起来有点「复古」的项目牢牢占住:bitchat(permissionlesstech/bitchat)。日增 star 从 1166 一路涨到 2346,热度一天翻一倍。
它做的事情说出来甚至有点朴素:一个不需要互联网、不需要服务器、不需要手机号、不需要账号的聊天应用。两台手机靠蓝牙直接说话,中间隔着几百米?没关系,让路过的其他手机帮你「接力」转发。
这就是 BLE(低功耗蓝牙)mesh 组网聊天。听起来像是 2010 年代 FireChat 玩剩下的东西,为什么在 2026 年突然又火了?
我花了几个晚上把 bitchat 的协议设计、源码结构和它背后的 BLE mesh 工程细节完整过了一遍。这篇文章想讲清楚三件事:
- bitchat 的完整技术架构——从 BLE 承载层到 Noise 加密握手,一层层拆开;
- 离线 mesh 组网的真实工程难度——MTU 分片、TTL 泛洪、store-and-forward、电量控制,每一个都是硬骨头;
- 它的能力边界在哪里——哪些宣传是真的,哪些场景下它其实不好使。
按惯例,全文配可运行的代码示例(Swift / Kotlin / 伪代码),5000 字起步,建议先收藏。
一、背景:为什么「无网聊天」在 2026 年重新成为刚需
1.1 从 FireChat 到 bitchat:十年轮回
离线 mesh 聊天不是新概念。2014 年的 FireChat 靠 iOS 的 Multipeer Connectivity 在香港街头一战成名,随后又在多次大规模网络中断事件中被广泛使用。但 FireChat 是闭源商业产品,2018 年悄然死掉。
之后这个赛道沉寂了很多年,原因很现实:
- 平台限制:iOS 对后台蓝牙的管控极严,应用切到后台基本等于失联;
- 没有商业模式:无服务器意味着无法收集数据、无法投广告,VC 不感兴趣;
- 加密工程门槛高:要做到端到端加密 + 无账号身份 + 前向保密,需要相当专业的密码学工程能力。
而 bitchat 的出现把这三个问题重新做了一遍解答:它完全开源(Unlicense/公有领域授权),不需要商业模式(社区驱动),并且直接采用了工业级的 Noise Protocol Framework 做加密——这是 WireGuard 和 WhatsApp 底层用的同一套握手框架。
1.2 需求侧:网络中断正在变成常态化风险
过去两年,全球范围内「局部断网」事件的频率明显上升:自然灾害导致基站瘫痪、大型活动现场基站过载、部分地区的网络管制。在这些场景下,「手机有电但没有网」是最常见的状态。
BLE mesh 聊天的价值主张非常清晰:只要人群密度够,消息就能传。音乐节、地铁、游行集会、灾后安置点——这些场景的共同点是人多、网差,恰好是 mesh 网络的舒适区。
1.3 供给侧:BLE 硬件能力的静默升级
很多人没注意到,蓝牙硬件这几年一直在悄悄进化:
- BLE 5.x 的 Extended Advertising 允许单个广播包携带最多 255 字节负载(Legacy 只有 31 字节),链路层 PDU 上限扩展到 251 字节;
- 2M PHY 把传输速率翻倍,实测 GATT 吞吐可以稳定在 100 KB/s 以上;
- iPhone 和主流 Android 旗舰的 BLE 芯片都已支持同时维持 8 个以上的并发连接。
硬件底子好了,上层应用才有得玩。bitchat 正是踩在这个时间点上。
二、核心概念:bitchat 的四个设计支柱
在拆架构之前,先建立四个核心概念。理解了它们,后面的所有设计决策都顺理成章。
2.1 无账号身份(Ephemeral Identity)
bitchat 没有注册流程。你的「身份」就是本地生成的一对密钥:
- 静态身份密钥(Curve25519):用于 Noise 握手中证明「我还是上次那个我」;
- 临时会话密钥:每次会话协商生成,用完即弃。
昵称只是一个自由文本字段,不具备唯一性,也不做全局注册。这带来一个反直觉但很重要的属性:你无法被「封号」,因为根本没有号。代价是你也无法做全局身份验证——你只能通过「密钥指纹 + 线下确认」来确定对方是谁,这和 PGP 的信任模型一脉相承。
2.2 管理泛洪(Managed Flooding)而非路由
传统网络靠路由表决定包往哪走。但在一个节点随时加入、随时消失的手机 mesh 网络里,维护路由表的开销远大于收益。bitchat 选择了和蓝牙 Mesh 标准(Bluetooth SIG Mesh Profile)相同的思路——泛洪:
每个节点收到消息后,无脑重新广播一遍,直到消息的 TTL(跳数限制)耗尽。
泛洪的好处是极致简单、天然多路径容错;坏处是广播风暴。所以要加「管理」二字:消息去重缓存 + TTL 限制 + 随机延迟转发,三板斧下去,风暴就被摁住了。后文代码实战部分会完整实现这套逻辑。
2.3 Store and Forward(存储转发)
mesh 网络里对方不一定在线。bitchat 的处理方式是:中继节点会把发往「最近见过但当前不在线」节点的消息暂存在内存里(注意:不落盘),等目标节点重新出现在网络中时再投递。
这个机制的关键参数是缓存时长和容量上限。bitchat 对普通节点采用短时缓存(约 12 小时、按 LRU 淘汰),消息一旦投递或超时即清除。没有任何消息会被永久存储——这是它「隐私优先」叙事的重要一环。
2.4 Noise Protocol:不发明加密,只组装加密
bitchat 的端到端加密没有自己造轮子,而是采用 Noise Protocol Framework 的 XX 握手模式,密码套件为 Noise_XX_25519_ChaChaPoly_SHA256。选 XX 模式的原因很简单:
- 双方在握手前互相不知道对方的静态公钥(这正是无账号场景的现实);
- 握手完成后双方各自获得对方静态公钥,可用于后续的身份指纹确认;
- 提供前向保密(Forward Secrecy):即使静态密钥日后泄露,历史会话也无法被解密。
私聊消息走 Noise 加密通道;公共频道消息则是明文广播(这是设计选择,不是疏漏——公共广场本来就是公开的)。
三、架构分析:五层协议栈逐层拆解
把 bitchat 的整个通信栈画出来,是一个清晰的五层结构:
┌─────────────────────────────────────┐
│ 应用层:频道 / 私聊 / IRC 风格命令 │
├─────────────────────────────────────┤
│ 加密层:Noise XX 握手 + ChaChaPoly │
├─────────────────────────────────────┤
│ 消息层:二进制协议 + 分片重组 + 去重 │
├─────────────────────────────────────┤
│ Mesh 层:TTL 泛洪 + 转发决策 + 暂存 │
├─────────────────────────────────────┤
│ 承载层:BLE GATT (Central+Peripheral) │
└─────────────────────────────────────┘
3.1 承载层:每台手机同时是 Central 和 Peripheral
BLE 通信有两个角色:Central(主动扫描、发起连接)和 Peripheral(被动广播、接受连接)。传统 BLE 应用只扮演其中一个角色,而 mesh 网络要求每台手机同时扮演两个角色:
- 作为 Peripheral:持续广播自己的服务 UUID,让别人能发现并连上自己;
- 作为 Central:持续扫描周围的同类节点,主动连接它们。
这在 iOS 上是可行的——CBPeripheralManager 和 CBCentralManager 可以并存。真正的坑在于连接拓扑管理:如果 A 连 B 的同时 B 也连 A,就会产生冗余双向连接,浪费宝贵的连接槽位。bitchat 的解法是用节点 ID 的字典序做仲裁:ID 小的一方作为 Central 发起连接,另一方保持 Peripheral 角色,保证任意两节点间只有一条链路。
3.2 消息层:为 BLE 量身定制的紧凑二进制协议
BLE 的有效载荷极其金贵(协商 MTU 通常在 185~512 字节之间),所以 bitchat 用的是手工设计的紧凑二进制格式,而非 JSON:
Header(固定 13 字节):
+--------+--------+--------+----------------+----------+
| version| type | ttl | timestamp (8B) | flags |
| 1 byte | 1 byte | 1 byte | big-endian | 1 byte |
+--------+--------+--------+----------------+----------+
| payloadLength (2 bytes, big-endian) |
+------------------------------------------------------+
可变部分:
+------------------+------------------+----------------+
| senderID (8B) | recipientID (8B, | payload + |
| | 仅私聊时存在) | 可选签名(64B) |
+------------------+------------------+----------------+
几个值得注意的设计细节:
- senderID 是 8 字节短 ID,不是完整公钥。完整公钥只在 Noise 握手时交换,日常报文里只带短 ID,省流量;
- flags 位图控制可选字段的存在性(是否有 recipientID、是否有签名),典型的 TLV 思路的简化版;
- 消息 ID 用于去重,由
senderID + timestamp + 内容哈希派生,中继节点靠它判断「这条我是不是转发过了」。
3.3 Mesh 层:TTL 泛洪的三道闸门
泛洪转发逻辑是整个系统的心脏。用伪代码描述 bitchat 的转发决策:
onMessageReceived(msg, fromPeer):
# 闸门 1:去重
if msg.id in recentMessageCache: # LRU,容量约 1000 条
return # 见过了,丢弃
recentMessageCache.add(msg.id)
# 闸门 2:本地消费
if msg.recipient == myID or msg.isBroadcast:
deliverToUI(msg)
if msg.recipient == myID:
return # 私聊到达终点,不再转发
# 闸门 3:TTL
if msg.ttl <= 1:
return # 跳数耗尽
msg.ttl -= 1
delay = random(20ms, 200ms) # 随机退避,错开广播风暴
scheduleRelay(msg, delay, excludePeer=fromPeer)
三道闸门各司其职:
- 去重缓存防止消息在环形拓扑里无限打转;
- 本地消费判断保证私聊消息到达终点后立即「沉底」,不泄漏传播范围;
- TTL 递减 + 随机延迟控制传播半径(bitchat 默认初始 TTL 为 7,即最多 7 跳)并避免所有邻居同时转发造成的空口碰撞。
7 跳是什么概念?假设 BLE 实际有效距离 30 米,理论上一条消息能传出 200 米以上;在人流密集的场所(每 10 米一个节点),覆盖一整条街区不成问题。
3.4 加密层:Noise XX 握手全流程
私聊建立时,双方执行 Noise XX 三步握手:
发起方 (Initiator) 响应方 (Responder)
│ │
│ -> e (发送临时公钥) │
│────────────────────────────────────>│
│ │
│ <- e, ee, s, es │
│ (临时公钥 + DH + 加密的静态公钥) │
│<────────────────────────────────────│
│ │
│ -> s, se (加密的静态公钥 + DH) │
│────────────────────────────────────>│
│ │
│ === 双向加密通道建立 === │
握手完成后双方各持有两个单向的 ChaCha20-Poly1305 密钥(发送/接收各一),之后每条消息独立加密、独立认证,并带 nonce 计数防重放。
这里有一个 mesh 场景特有的工程问题:握手消息本身也要经过多跳中继,而中继节点可能丢包、乱序。bitchat 的处理是给握手消息更高的转发优先级 + 独立的重传定时器,实测三步握手在 3 跳网络里通常能在 2 秒内完成。
3.5 应用层:IRC 的幽灵在 2026 年游荡
bitchat 的交互故意做得很像上古时代的 IRC:
/j #channel加入频道;/m @nick message发私聊;/w看谁在线;- 频道可以设置密码(由频道主用 Argon2id 从口令派生密钥)。
这不只是情怀。IRC 式的「频道 = 广播域」模型和 mesh 网络的泛洪广播天然同构——频道消息本来就是要广播给所有人的,正好泛洪就是干这个的。相比之下,如果照搬微信式的「群聊 = 服务器维护的成员列表」,在无服务器架构下反而要付出巨大的状态同步成本。
四、代码实战:从零实现一个最小可用的 BLE Mesh 聊天原型
理解架构最好的方式是写一遍。下面用 Swift(iOS)实现核心链路,Android 端逻辑相同、API 对应即可。
4.1 双角色启动:同时做 Central 和 Peripheral
import CoreBluetooth
// bitchat 风格:固定的服务与特征 UUID
let meshServiceUUID = CBUUID(string: "F47B5E2D-4A9E-4C5A-9B3F-8E1D2C3A4B5C")
let meshCharUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
final class MeshTransport: NSObject {
private var central: CBCentralManager!
private var peripheral: CBPeripheralManager!
private var connectedPeers: [UUID: CBPeripheral] = [:]
private var subscribedCentrals: [CBCentral] = []
private var meshChar: CBMutableCharacteristic!
override init() {
super.init()
central = CBCentralManager(delegate: self, queue: .global(qos: .userInitiated))
peripheral = CBPeripheralManager(delegate: self, queue: .global(qos: .userInitiated))
}
}
extension MeshTransport: CBPeripheralManagerDelegate {
func peripheralManagerDidUpdateState(_ mgr: CBPeripheralManager) {
guard mgr.state == .poweredOn else { return }
// 1. 注册 GATT 服务
meshChar = CBMutableCharacteristic(
type: meshCharUUID,
properties: [.notify, .writeWithoutResponse],
value: nil,
permissions: [.writeable]
)
let service = CBMutableService(type: meshServiceUUID, primary: true)
service.characteristics = [meshChar]
mgr.add(service)
// 2. 开始广播(iOS 后台模式下广播包会被系统降级,见 5.2 节)
mgr.startAdvertising([
CBAdvertisementDataServiceUUIDsKey: [meshServiceUUID]
])
}
// 收到其他节点写入的数据 → 进入 mesh 转发管线
func peripheralManager(_ mgr: CBPeripheralManager,
didReceiveWrite requests: [CBATTRequest]) {
for req in requests {
if let data = req.value {
MeshRouter.shared.onInboundFragment(data, from: req.central.identifier)
}
}
}
}
extension MeshTransport: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ mgr: CBCentralManager) {
guard mgr.state == .poweredOn else { return }
mgr.scanForPeripherals(withServices: [meshServiceUUID],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
}
func centralManager(_ mgr: CBCentralManager,
didDiscover p: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
// 信号太弱的节点不连,链路质量差还占连接槽
guard RSSI.intValue > -80 else { return }
// 字典序仲裁:只有我的 ID 较小时才主动连接,避免双向重复链路
guard myNodeID.uuidString < p.identifier.uuidString else { return }
connectedPeers[p.identifier] = p
mgr.connect(p, options: nil)
}
}
两个容易踩的坑:
RSSI > -80的连接门槛很重要。BLE 在临界距离上的链路极不稳定,连上了也是频繁断连重连,反而拖垮整个 mesh 的吞吐。宁可少连、连稳。- 字典序仲裁(
myNodeID < p.identifier)看似简单,却是避免 N² 冗余连接的关键。没有这一行,10 个节点的房间里会出现 90 条连接,瞬间打爆 iOS 的连接数限制。
4.2 分片与重组:对抗 MTU 的现实
BLE 单包写不下一条完整消息是常态。分片器实现:
struct Fragment {
let messageID: UInt64 // 所属消息
let index: UInt16 // 分片序号
let total: UInt16 // 总分片数
let payload: Data
// 头部 = 8 + 2 + 2 = 12 字节
func encode() -> Data {
var d = Data()
d.append(contentsOf: withUnsafeBytes(of: messageID.bigEndian, Array.init))
d.append(contentsOf: withUnsafeBytes(of: index.bigEndian, Array.init))
d.append(contentsOf: withUnsafeBytes(of: total.bigEndian, Array.init))
d.append(payload)
return d
}
}
final class Fragmenter {
/// 按对端协商到的 MTU 切分
static func split(_ message: Data, messageID: UInt64, mtu: Int) -> [Fragment] {
let chunkSize = mtu - 12 // 扣除分片头
let total = UInt16((message.count + chunkSize - 1) / chunkSize)
return stride(from: 0, to: message.count, by: chunkSize).enumerated().map { (i, offset) in
let end = min(offset + chunkSize, message.count)
return Fragment(messageID: messageID,
index: UInt16(i), total: total,
payload: message.subdata(in: offset..<end))
}
}
}
final class Reassembler {
private var buffers: [UInt64: (total: UInt16, parts: [UInt16: Data], firstSeen: Date)] = [:]
private let timeout: TimeInterval = 10 // 10 秒收不齐就放弃
func onFragment(_ f: Fragment) -> Data? {
var entry = buffers[f.messageID] ?? (f.total, [:], Date())
entry.parts[f.index] = f.payload
buffers[f.messageID] = entry
guard entry.parts.count == Int(entry.total) else {
gcExpired()
return nil
}
buffers.removeValue(forKey: f.messageID)
// 按序拼接
return (0..<entry.total).compactMap { entry.parts[$0] }
.reduce(Data(), +)
}
private func gcExpired() {
let now = Date()
buffers = buffers.filter { now.timeIntervalSince($0.value.firstSeen) < timeout }
}
}
注意 gcExpired():在有丢包的 mesh 环境里,永远收不齐的残缺消息是常态而非异常。不做超时回收,重组缓冲区就是一个稳定增长的内存泄漏点。
4.3 泛洪路由器:去重 + TTL + 随机退避
final class MeshRouter {
static let shared = MeshRouter()
private var seenMessages = LRUCache<UInt64, Bool>(capacity: 1000)
private let relayQueue = DispatchQueue(label: "mesh.relay")
func onInboundMessage(_ msg: MeshMessage, from peer: UUID) {
// 闸门 1:去重
guard seenMessages.get(msg.id) == nil else { return }
seenMessages.put(msg.id, true)
// 闸门 2:本地消费
if msg.isBroadcast || msg.recipientID == LocalIdentity.shortID {
MessageStore.shared.deliver(msg)
if !msg.isBroadcast { return } // 私聊到终点,沉底
}
// 闸门 3:TTL
guard msg.ttl > 1 else { return }
var relayed = msg
relayed.ttl -= 1
// 随机退避 20~200ms:避免所有邻居同时转发
let delay = Double.random(in: 0.02...0.2)
relayQueue.asyncAfter(deadline: .now() + delay) {
MeshTransport.shared.broadcast(relayed, exclude: peer)
}
}
}
随机退避还有一个隐藏收益:先转发的节点会让后转发的节点命中去重缓存。假设一个节点有 5 个邻居同时收到某条消息,随机延迟让它们错峰转发,第一个转发之后,其余 4 个节点大概率会从别处再次收到这条消息并将其标记为已见——实际转发次数远小于理论上限。这是「管理泛洪」中「管理」的精髓。
4.4 Noise 握手:用 swift-noise 建立加密会话
生产环境不要手写 Noise,用成熟库。以下示意关键流程:
import Noise // 任一 Noise Protocol 实现库
final class SecureChannel {
private var handshake: HandshakeState
private var sendCipher: CipherState?
private var recvCipher: CipherState?
init(isInitiator: Bool, localStatic: KeyPair) {
handshake = try! HandshakeState(
pattern: .XX,
initiator: isInitiator,
prologue: Data("bitchat-v1".utf8), // 协议版本绑定,防降级攻击
s: localStatic // 本地静态密钥
)
}
/// 发起方第一步:-> e
func startHandshake() -> Data {
return try! handshake.writeMessage(payload: Data())
}
/// 收到握手消息,产出回复(若有);完成时派生收发密钥
func consume(_ incoming: Data) -> Data? {
_ = try! handshake.readMessage(incoming)
if handshake.isComplete {
(sendCipher, recvCipher) = handshake.split()
// 此刻可取得对方静态公钥 → 计算指纹供用户线下比对
let fingerprint = SHA256.hash(data: handshake.remoteStaticKey!)
TrustStore.shared.record(fingerprint)
return nil
}
return try! handshake.writeMessage(payload: Data())
}
func encrypt(_ plaintext: Data) -> Data {
try! sendCipher!.encrypt(plaintext: plaintext)
}
func decrypt(_ ciphertext: Data) throws -> Data {
try recvCipher!.decrypt(ciphertext: ciphertext) // 认证失败会抛错,必须处理
}
}
两个安全细节值得强调:
- prologue 绑定协议版本。如果日后出现 bitchat-v2,攻击者无法把 v2 客户端骗回 v1 的弱协议——握手时 prologue 不一致会直接失败。
- decrypt 必须处理认证失败。ChaCha20-Poly1305 是 AEAD,任何被篡改的密文都会在解密时抛错。在 mesh 网络里收到无法解密的包很正常(可能是发给别人的),静默丢弃即可,但绝不能把解密失败的内容当明文展示。
4.5 Store and Forward:带 TTL 的内存暂存
final class ForwardCache {
struct Entry {
let message: MeshMessage
let cachedAt: Date
}
private var cache: [ShortID: [Entry]] = [:] // 按目标节点分桶
private let maxAge: TimeInterval = 12 * 3600 // 12 小时
private let maxPerPeer = 100 // 每个目标最多缓存 100 条
/// 目标不在线时暂存
func store(_ msg: MeshMessage, for target: ShortID) {
var entries = cache[target] ?? []
entries.append(Entry(message: msg, cachedAt: Date()))
if entries.count > maxPerPeer {
entries.removeFirst(entries.count - maxPerPeer) // FIFO 淘汰
}
cache[target] = entries
}
/// 目标重新上线时倾倒
func flush(for target: ShortID) -> [MeshMessage] {
defer { cache.removeValue(forKey: target) }
let now = Date()
return (cache[target] ?? [])
.filter { now.timeIntervalSince($0.cachedAt) < maxAge }
.map(\.message)
}
}
配合节点发现回调:每当有新节点完成握手加入网络,路由器调用 flush(for:) 把暂存消息倾倒给它。注意这一切都发生在内存里——进程被杀,缓存即消失。这是隐私特性,不是 bug。
五、性能与工程优化:BLE Mesh 的四大硬仗
原型跑通只是开始。要在真实环境里可用,下面四场硬仗一场都躲不掉。
5.1 吞吐量优化:MTU 协商 + Write Without Response
BLE 默认 MTU 只有 23 字节(有效载荷 20 字节),不协商 MTU 等于自废武功:
// Central 连接成功后立刻协商
func centralManager(_ mgr: CBCentralManager, didConnect p: CBPeripheral) {
p.delegate = self
// iOS 会自动协商到双方支持的最大值(通常 185 或 512)
let mtu = p.maximumWriteValueLength(for: .withoutResponse)
PeerRegistry.shared.updateMTU(p.identifier, mtu: mtu)
p.discoverServices([meshServiceUUID])
}
写模式的选择同样关键:
| 写模式 | 确认机制 | 实测吞吐 | 适用场景 |
|---|---|---|---|
| Write With Response | 每包 ATT 层确认 | ~2 KB/s | 握手消息等关键数据 |
| Write Without Response | 无确认,靠上层重传 | ~20+ KB/s | 普通聊天消息 |
bitchat 的选择是消息层自己做可靠性(去重 ID + 定时重传),承载层全部走 Write Without Response。这是经典的「端到端原则」:可靠性放在最了解业务语义的那一层做。
5.2 电量控制:扫描占空比是最大电老虎
持续 BLE 扫描的功耗大约是待机的 5~8 倍。bitchat 的扫描策略是典型的自适应占空比:
前台活跃: 持续扫描(用户在等消息,体验优先)
前台空闲: 扫 2s → 停 3s(40% 占空比)
后台: 依赖系统级扫描合并(iOS 后台只能靠
bluetooth-central 后台模式 + 服务 UUID 过滤)
低电量: 扫 1s → 停 9s(10% 占空比),并停止中继转发
最后一条很有意思:低电量节点自动退化为「叶子节点」——只收发自己的消息,不再帮别人中继。这相当于蓝牙 Mesh 标准里 Low Power Node 概念的简化版,用一点点网络连通性换取续航,对整体网络是净收益(一个死掉的节点连通性为零)。
5.3 iOS 后台的残酷现实
必须坦率地说:iOS 后台是 BLE mesh 的最大软肋。
- 后台广播时,服务 UUID 会被系统从主广播包移到 overflow area,只有同样在前台扫描的 iOS 设备才能发现你;
- 后台扫描的间隔被系统大幅拉长,节点发现延迟从秒级恶化到分钟级;
- 锁屏一段时间后,进程可能被直接挂起。
bitchat 的缓解手段包括:利用 bluetooth-central/bluetooth-peripheral 后台模式、State Restoration、以及已连接链路在后台仍可收发数据的特性(连接比发现更顽强——所以策略是趁前台多连节点,后台靠存量连接维持)。但物理规律无法绕过:一个所有人都锁屏揣兜里的 mesh 网络,消息传播效率会显著下降。Android 端的自由度大得多,这也是实测中 Android 节点密度高的网络明显更健壮的原因。
5.4 广播风暴的量化控制
泛洪网络的转发量随节点密度平方增长。除了前述的去重和随机退避,还有两个进阶手段:
- RSSI 加权转发概率:信号越强说明离发送者越近,转发的边际覆盖增益越小。可以让 RSSI > -50 dBm(很近)的节点以较低概率转发,把转发机会留给网络边缘的节点——它们才是扩大覆盖的关键。
- 邻居数感知:邻居超过某个阈值(比如 6 个)时主动降低转发概率。密集区域根本不缺中继,稀疏区域才缺。
这两条合起来就是学术界所说的「概率泛洪」(Gossip-based flooding),能把密集场景的冗余转发压掉 60% 以上,而连通率损失可以忽略。
六、能力边界冷思考:它不是什么
写到这里必须泼几盆冷水,避免大家对 mesh 聊天产生不切实际的期待。
它不是「反监控银弹」。 端到端加密保护的是内容,但 BLE 广播本身就是在空口裸奔你的存在性。持设备的观测者可以:记录某个短 ID 在何时何地出现(位置隐私)、通过流量时序分析推断谁在和谁说话(元数据分析)。bitchat 用定期轮换临时 ID 缓解前者,但只要静态身份密钥不变,长期关联仍然可能。威胁模型里如果有「强大的本地物理观测者」,任何无线电方案都要打折扣。
它不适合稀疏场景。 mesh 的一切美好建立在节点密度上。7 跳 × 30 米的理论覆盖,在郊区两个相隔 500 米、中间没有其他节点的用户面前毫无意义。它的主场是人群,不是旷野。旷野场景的正解是 LoRa(Meshtastic 那一套),代价是需要专用硬件。
公共频道没有加密,也没有身份保证。 任何人都可以用任何昵称在公共频道发言。把它当成广场上的喊话,而不是可信通信。
消息可达性是「尽力而为」。 没有服务器兜底,store-and-forward 只是内存级的缓解。对方三天不开机,消息就是会丢。这是架构的诚实代价,用户心智需要匹配。
七、总结与展望:离线优先(Offline-First)的通信栈正在成形
回顾全文,bitchat 值得工程师认真对待的原因,不在于「无网聊天」这个点子本身——点子十年前就有——而在于它展示了 2026 年做这件事的完整工程配方:
- 承载层:BLE 5.x 的硬件红利 + 双角色连接拓扑 + 字典序仲裁;
- 传输层:紧凑二进制协议 + 分片重组 + 端到端可靠性;
- 网络层:管理泛洪(去重 / TTL / 随机退避 / 概率转发);
- 安全层:Noise XX 握手直接复用工业级密码学工程,不造轮子;
- 产品层:IRC 式交互与广播域模型的同构,无账号带来的抗封锁属性。
更宏观地看,bitchat 和它的同类(Meshtastic 的 LoRa mesh、Briar 的 Tor + 本地网络混合、以及各类 Nostr 客户端)正在共同拼出一幅「离线优先通信栈」的图景:互联网不再是通信的必要条件,而只是众多承载层中的一个(且是最快的那个)。当网络在,走网络;网络不在,走蓝牙、走 LoRa、走 WiFi Direct——应用层协议与承载层解耦。
这个方向上还有大量值得做的工程课题:跨承载层的统一路由、mesh 与互联网网关节点的桥接(bitchat 社区已经在讨论通过 Nostr 中继桥接离线与在线世界)、更聪明的转发调度算法、以及低功耗芯片上的轻量 Noise 实现。
对普通开发者的可操作建议:把本文第四节的原型跑起来。两台旧手机 + 一个下午,你就能亲手体会 mesh 网络「消息像涟漪一样扩散」的奇妙手感——那种不依赖任何基础设施的通信自由,代码跑通的那一刻会给你非常直接的冲击。
基础设施会失效,人和人的连接不应该失效。这大概就是 2300 个 star 每天涌向这个项目的原因。