当卫星定位失效时:用 Levenberg-Marquardt 对抗蓝牙 RSSI 混乱
卫星定位进楼就失效,作者用 BLE 信标加 Levenberg-Marquardt 三边定位在 iOS 上把误差压到半米内。
中文
复制

卫星定位能把你带到楼前。进了楼,它就把你丢下了。室外能把你的位置三角定位到几米以内的信号,穿不透一层屋顶。混凝土和钢筋会把信号挡死。在仓库、医院、商场、机场航站楼里,人类发射的所有卫星星座都看不见你。这就是室内定位问题,它确实很难。不是“有趣的工程谜题”那种难,而是“物理定律在跟你作对”那种难。我做了一个叫 BeaconIL 的 iOS 应用,试图用 Bluetooth Low Energy 信标和一套从数值计算移植过来的非线性优化算法解决它。以下是我用数学对抗无线电物理的过程中学到的东西。
先说点背景,讲讲这个项目为什么存在。到 2020 年,我在桌面开发和微控制器嵌入式工作上已经干了十二年,从桌面端的 Qt 和 C++ 一路做到裸机固件。我所在公司的研发部门做基于微控制器的产品。没有两个任务是一样的。地铁有害气体监测的固件。工业基础设施监测。还有一个智能宠物项圈,后来扩展到全国范围上市,这段经历我已经写过了。接着就是与本篇相关的那个任务:面向工厂量产的智能手表,功能清单里有室内定位,因为卫星定位在屋顶下会失效。这里还有安全层面的考量。工厂车间里的机器不会原谅半米的误差,所以位置在近距离内必须可信,而不只是平均意义上准确。
手表是主要交付物。为了给它一个可供测距的对象,我们也自己做了 iBeacon:硬件由一位同事设计,固件是我的部分。不过这个任务的核心是数学:把带噪声的无线电读数变成位置估计,跑在微控制器上,最终用纯 C 实现。但要排查无线电的各种坑,手里拿一部手机最方便。iOS 给了我一套能用的 BLE 协议栈、CoreLocation 的测距回调,还有一块能把估计位置画出来的屏幕。这些都不需要调试,拿来就能用。在微控制器上,这些层都得我自己搭起来,数学才有得测。所以算法先落到 iOS 上,成了 BeaconIL。算法在真实硬件上验证之后,我把它移植成 Go,做成一个干净的独立库。那份 Go 代码是给微控制器上最终的 C 重写当参考的,这一次要避开我已经踩过的 bug。所以在 BeaconIL 起步时,这些数学都还不存在。终点是裸机微控制器,但手机让我能只专注在数学上。当然,它也带来了自己的操作系统抽象层和自己的限制。这个实验回答的问题比它被问到的更多。顺带一提,在 iOS 上这么深入的工作定下了我的方向。这个项目之后,我选择移动端作为要长期投入的职业,我在上面发布的那些应用得到了关注,我的经理后来还让我再做一个企业级的。
主要目标不是做一个打磨完善的产品,而是验证数学是否成立:EMA 滤波、路径损耗距离模型和 Levenberg-Marquardt 三边定位,能否在真实的 iOS 硬件和真实的 BLE 信标上给出稳定可用的位置估计。这个目标达到了。在一个 4×6 米、布置了三个已校准信标的房间里,屏幕上的标记与我实际站立的位置始终相差约半米以内。上架 App Store、通过两轮审核,以及后来开源,都是次要的。如果今天用我后来积累的移动端经验重做一遍,架构会不一样:更干净、更符合 iOS 的习惯,不那么像一个嵌入式工程师的第一个移动项目。但架构粗糙不等于代码不可靠。那几个早期应用在实际使用中经受住了考验,真正要紧的边界情况都处理了。数学——这才是重点——是成立的。
这个应用会扫描附近的 iBeacon 兼容 BLE 信标,测量每个信标的信号强度,换算成距离估计,然后运行三边定位算法算出位置,并把结果实时渲染在平面图上。它经历了两轮 App Store 审核并成功上架。代码在 https://github.com/Vitaliy69/BeaconIL。

图 1 — BeaconIL 主界面。上方:实时信标列表,显示 major/minor 标识符、以 dBm 为单位的 RSSI 以及估算距离。下方:SpriteKit 可视化,信标(蓝色标记)与计算出的设备位置(红色标记)叠加在楼层平面图上。
无线电的麻烦
低功耗蓝牙信标是电池供电的小型发射器,按固定间隔广播广告包,通常每秒 10 次。每个包包含一个 UUID、一个 major 值和一个 minor 值,用于标识信标本身及其所属分组。iPhone 通过 CoreLocation 接收这些包。CoreLocation 会把它们攒起来,大约每秒回调一次 didRange,回调里是一个数组,包含所有可见信标及其当前 RSSI 值。RSSI 的单位是分贝毫瓦(dBm)。
RSSI 是负数。越接近零,信号越强。紧挨着手机的信标可能读到 -40 dBm;走到十米开外,可能就只有 -75 dBm。理论上,这给了你估算距离的办法:信号越弱,距离越远。
理论上。
实际上,RSSI 是你这辈子能碰到的最嘈杂的信号之一。离开信标的无线电波并不会干干净净地沿直线传到你的手机。它会从墙壁、地板、天花板、金属货架和人体上反射;会被水吸收(而人体大部分是水);还会因多径传播而自我干扰——同一个包经由多条路径、以略微不同的时间到达,这些副本相互叠加或相互抵消。有人从你和信标之间走过,RSSI 可以瞬间掉 10 dBm,人一走开又弹回来。
就算你站得纹丝不动、手机拿得稳稳当当,单个信标的 RSSI 在相邻两次读数之间也会抖动 3 到 8 dBm。由于 RSSI 到距离的换算是对数关系,3 米处 6 dBm 的抖动对应大约 −1.5 到 +3 米的距离波动。这种不对称来自对数曲线本身。你的位置估计会像弹珠一样到处乱撞。
这是你撞上的第一堵墙。在它能做任何定位计算之前,你必须先解决这个问题。
EMA:指数移动平均
解决办法是信号滤波。你不能直接用原始的 RSSI 值,它们抖动得太厉害,必须先做平滑。但平滑也不能太狠,否则系统会变得迟钝,位置跟不上实际移动。
我选了指数移动平均,也就是 EMA。思路很简单:每来一个新的 RSSI 读数,就让平滑值朝它挪一小步,而不是整个替换掉。这一步的大小由一个平滑系数控制,而系数由窗口大小推导出来 N:
α = 2 / (N + 1)
ema_new = α × rssi_new + (1 - α) × ema_old
IndoorMath.swift 里的实现为每个 beacon 维护一个 RSSI 历史缓冲区。新读数到达时,先看缓冲区是否已经填满 EMA 窗口。没填满就直接把原始值追加进去,因为数据还不够,谈不上平滑。一旦缓冲区满了,EMA 公式就开始生效:
窗口是滑动的:新值进来,旧值从前面掉出去。默认窗口大小是 10,对应的平滑系数约为 0.18。也就是说,每个新读数对平滑值的贡献大约是 18%,剩下的 82% 来自历史值。
窗口大小可以由用户配置,范围从 5 到 100。窗口越小,系统响应越快(你一移动,位置马上更新),但放进来的噪声也更多。窗口越大,曲线越平滑,但滞后越明显。我在真实房间里拿真实 beacon 反复测试后,把默认值定在了 10。这是个甜点:屏幕上的位置标记移动得干脆利落,而不是乱抖,但也不至于像在糖浆里游泳。
还有一个过期检查。如果某个 beacon 超过 12 秒没有收到信号,就把它整个从缓冲区里踢出去。这是为了处理你走出某个 beacon 覆盖范围的情况。你总不希望一个已经看不见的 beacon 留下的陈旧数据继续污染三边定位。
从 RSSI 到距离:路径损耗模型
拿到平滑后的 RSSI 值后,下一步就是把它换算成距离。所用模型是对数距离路径损耗公式的简化版:
distance = 10 ^ ((txCalibratedPower - rssi) / (10 × n))
其中 txCalibratedPower 是在距信标恰好 1 米处测得的 RSSI。它是参考点,是把抽象的 dBm 数值与物理距离绑定在一起的锚。n 是路径损耗指数:自由空间中信号功率按距离的平方衰减,n = 2 对应 Friis 传输方程里的 20·log₁₀(d) 项。这个自由空间的 2 就是 app 最初发布时采用的值。真实建筑很少符合它,本文后面的校准方法讲的正是这件事。频率被折算进了 1 米参考值里,而不是指数里。
实现只有一个函数:
代码里的那个除数 10.0 * pathLossExponent,就是上面公式中的 10 × n。指数在这次提交中变成了参数,默认值对两个现有调用点都保留了 2021 年的原始行为。当你把下面的校准方法应用到真实楼层时,n 就是你要按区域拟合的那个数。
如果 txCalibratedPower 是 -59 dBm(Estimote 或 Kontakt.io 信标的典型值),而你平滑后的 RSSI 读数是 -70 dBm,那么在自由空间默认值下,距离约为 10^(11/20) ≈ 3.5 米。同一个读数改用现场拟合出的指数 2.8,估计值就缩到 10^(11/28) ≈ 2.5 米。同样的无线电,同样的算术,一米的差距完全由指数带来。够简单了。
但 txCalibratedPower 并不是一个普适常数。每个信标都略有不同。制造公差、天线朝向、电量、外壳材质:这些都会让 1 米参考值偏移几个 dBm。如果一个信标的真实值是 -65,你却按 -59 来算,距离估计就会系统性地偏差 10^(6/20) ≈ 2 倍。你会把 2 米外的信标当成 4 米外。整个系统就废了。
这就是校准存在的理由。
1 米参考值
BeaconIL 内置了校准模式。把 iPhone 放在距信标恰好 1 米、高度相同的位置,按下 “Start calibration”。app 会采集 RSSI 测量值(默认 30 个)并取平均,得到该信标专属的 txCalibratedPower。
校准过程中会实时显示 RSSI 值,方便你观察它何时稳定下来。采样数量足够后,平均值会作为该 beacon 的 onMeterRSSI 存入 Core Data。校准数据量可以配置:从 10 到 150 个样本任选。样本越多,平均值越准,但校准过程也越长。30 是个实用的默认值:CoreLocation 大约每秒回调一次,所以大约需要 30 秒,得到的参考值已经足够稳定。完整的流程——包括应用、通知等等——会在后文出现。

图 2 — 单个 beacon 的设置与校准界面。用户在平面图上设置 X/Y 坐标、1 米处的 RSSI 值和名称。校准按钮会启动一次实时采样。采样过程中 RSSI 字段实时更新。
应用会提醒你不要在校准期间把它切到后台,因为应用不在前台时 iOS 会暂停 BLE 测距。那样读到的数据要么过期要么缺失,校准结果就是垃圾。
还有一点需要如实说明。距离公式里的指数 2 是自由空间的值,而真实建筑并不是自由空间。实测的室内指数,从开阔大厅的约 1.6 到混凝土遮挡下的 4 甚至更高都有。校准把曲线锚定在 1 米处,所以近距离的估计仍然可靠。指数固定为 2 时,曲线远端会系统性偏移。在 4 米乘 6 米的房间里,这种偏移可以接受;放在仓库里就不行了。
把校准扩展到真实楼层是另一套流程,而房间与工厂之间的差距正是它的起点。一个实用的做法:
先走一遍现场,再动手安装任何东西。记下金属货架、冷库通道、移动的机械、人流的走向。这些决定了你的指数分区:已发表的测量结果显示,开阔空间接近 2,办公室为 2.7 到 3.5,杂乱的工业厂房则是 4 或更高。你在 4 米乘 6 米实验室里测出的数字并不能直接搬过去。
在信标实际部署的位置做 1 米校准,而不是在图方便的位置。然后沿一条基线采集参考测量值:站在已知距离处——2、4、8 米——记录 RSSI。每个区域两个点就够拟合出一个局部指数:在对数距离图上对测得的点对做直线拟合,斜率就是你的 n。把这个 n 填回该区域信标的 calculateRealDistance,它正是上面代码清单里的 pathLossExponent 参数。混凝土墙后面的区域该有自己的指数,不该和开阔场地共用一个。
按计划重新校准。货架会挪,库存会周转,布局会变。已发布的部署指南建议高精度应用每季度重新校准一次。而当环境实在不听话时,干脆丢掉物理模型。替代方案是指纹法:在已知参考点记录 RSSI 模式,再把实时读数与这张图匹配。它用实测现实换掉模型,是大而不规则空间的标准答案。

图 3 —— 校准方法一张图看完。左:四组模拟基线读数(1、2、4、8 米;txPower -59 dBm,真实指数 2.8,阴影噪声 2 dB),以及对数距离图上的最小二乘直线;其斜率为指数乘以负十。右:指数取错要付出什么代价。自由空间默认值 2 会让估计值随距离增大而偏离对角线(8 米被读成约 23 米),而拟合出的指数能跟上真实距离。这里拟合值落在 3 附近,真实值是 2.8:四个带噪点略微高估,这正是该方法要求按区域分别拟合、而不是用一个全局数字的原因。
为什么线性三边定位行不通
前面讲的是部署规模的校准。现在回到一个房间里、一部手机上:你手上有到至少三个 beacon 的距离,也知道每个 beacon 在平面图上的位置。接下来就该定位了。
教科书上的做法是三边定位:以每个 beacon 为圆心、以测得的距离为半径画圆,三个圆的交点就是你的位置。干净、优雅,而在现实世界里完全不能用。
问题在于,距离估计从来都不精确。它们是从带噪的无线电信号里算出来的近似值——信号被过滤过,但没有被净化。所以实际上,三个圆几乎不会交于一点。它们要么重叠成一片乱七八糟的区域,要么根本不重叠。两个圆可能有两个交点,第三个圆可能一个都不沾。不存在干净的几何解。
你或许可以试着对交点取平均,或者取重叠区域的质心。这些土办法效果很差。它们忽略了一点:有些距离估计比另一些更可靠。信号强、距离近的 beacon 给出的估计,要好过处在噪声底、距离远的那个。
正确的做法是别再把它当成几何问题,而是当成优化问题:在距离测量不完美的情况下,找出最优的位置。
Levenberg-Marquardt:非线性最小二乘
与其去找一个完美的交点,不如定义一个代价函数,用来衡量任意一个候选位置有多差,然后把它最小化。代价函数是残差平方和,写成距离平方之差的形式:
f(x) = Σ ( ‖x - pᵢ‖² - dᵢ² )²
其中 x 是候选位置,pᵢ 是 beacon i 的已知位置,dᵢ 是到该 beacon 的测量距离。对每个 beacon,计算候选位置到该 beacon 的欧氏距离平方,减去测量距离的平方,把差值平方,再对所有 beacon 求和。使这个和最小的位置就是你的最优估计。这种写法(距离平方之差,而非距离之差)在存在精确解时与直接形式有相同的最小值点;在带噪数据上两者可能略有差异。作为交换,平方形式给出的 Jacobian 更简单。偏导数对坐标是线性的(2x - 2pᵢ),因此每次 LMA 迭代的计算都更便宜。
这是一个非线性最小二乘问题,标准解法是 Levenberg-Marquardt 算法。LMA 融合了梯度下降和高斯-牛顿法,会根据当前离解还有多远来调整自身行为。离最小值较远时,它的表现像梯度下降:慢但可靠,沿着最陡下降方向小步前进。接近最小值时,它切换到高斯-牛顿法:收敛快,步长更大、更精确。这一切换由一个阻尼参数 λ 控制,每次迭代都会根据这一步是否降低了代价来调整它。
完整实现见 LMAMath.swift:771 行 Swift,没有任何外部依赖。它是 Apache Commons Math Levenberg-Marquardt 优化器的移植版,原版用 Java 写成。同一个算法也存在于一个开源 Go 库 https://github.com/Vitaliy69/lmamath 中。它的首次提交日期是 2021 年 6 月 28 日,同一天 Swift 核心收到了加固更新,增加了额外检查和对 3D 空间的支持。同一套数学的两个独立实现,从此互相验证。
写这篇文章时,我在共享测试向量上把这两个移植版互相对照。在存在精确解的输入上,两者返回的坐标精确到小数点后十二位完全一致。在有噪声的输入上,结果相差不到一微米,剩下的一点点是浮点舍入,不是逻辑上的差异。
本文的封面图也是用同样的方式构建的。图上三个信标位于固定坐标。那些圆是测得的距离,由真实的 RSSI 值通过路径损耗公式推导而来。那颗红星不是装饰。它是求解器对那个确切场景给出的答案,在绘制封面时算出来的。我用来生成场景的真实位置就在几厘米之外,因为有噪声的圆把估计值拉离了几何理想值。这种偏移就是本文的全部故事,浓缩在一张图里。把这三个圆输入任意一个移植版,你落到的点几乎分毫不差。
两个移植版本都配有只需一条命令即可运行的测试套件。Swift 包用测试可执行文件构建求解器;Go 模块则有 go test。两者共用同一组测试向量:精确输入、带噪输入、更高维度的锚点,以及必须被拒绝的畸形输入。在拒绝这一侧,两个实现各自挣来自己的防护。Swift 在 2021 年 6 月的提交里加上了维度检查。Go 直到几年后才补上,起因是它自己的测试套件在混合维度的锚点上 panic——正是 Swift 版本两年前修掉的那个 bug。修复在 panic 出现的同一天进了仓库。一个移植版本,只有在以同样的方式失败之后,才算完成。
Go 库的一次运行就能把整个故事压缩进一个场景。喂给它四个锚点和四个带噪距离,它从质心出发,感知代价函数往哪个方向倾斜,然后迈出小心阻尼过的步子,直到估计值不再移动。下面这个场景就是那次运行,从求解器自己的循环里追踪出来的。每个半透明球体是一个带噪距离。红色路径是估计值在下坡行走。星号是它最终停下的位置,离真值大约半米。右侧面板从内部展示同一次运行:代价塌缩,阻尼上升过一次,用来刹住一个坏步子。当阻尼降到零,求解器走的就是纯 Gauss-Newton 步。

图 4——Go 移植版求解一个三维案例,轨迹直接取自求解器自身的循环。四个锚点位于不同高度,这正是 2021 年 6 月那次提交解锁的维度;距离带有噪声(±0.35 m);收敛路径从质心初始猜测出发到达估计值:与真值相差 0.45 m,迭代 7 次。右图:每次迭代的代价与阻尼因子 λ。λ 上升过一次,用来压制一个糟糕的步长,随后随着求解器在最小值附近切换到 Gauss-Newton 步长而降为零。噪声来源正是本文反复遇到的那几个:一个站在 B2 路径上的身体,一个位于同一频段的 Wi-Fi 接入点。(为便于阅读,Z 轴拉伸 1.25 倍。)
Swift 入口点是 solve(positions:distances:):
那段注释的符号写反了。target[i] 恒为零。最优点处的实际方程是 (x0-xi)^2 + (y0-yi)^2 - ri^2 = target[i],与上面的代价函数一致,但注释写成了 +xi 和 +ri^2。这无害。注释不会执行。下面的 value() 和 jacobian() 函数算的是对的。就当它是一个留在已发布代码里的小瑕疵吧。
有几个设计决策值得说明。
初始点。 优化器需要一个起始猜测。我用质心,即所有信标位置的平均值。这是一个合理的起点,因为设备位于信标覆盖区域内的某处,而质心正在该区域中央。更好的初始猜测意味着收敛所需的迭代次数更少。
平方反比加权。 每个信标对代价函数的贡献都乘以权重 1/d²。这意味着更近的信标(其距离估计更可靠)对解的影响更大。距离 1 米的信标权重是距离 5 米的 25 倍。这就是平方反比定律,它对应着一个物理事实:信号强度随距离的平方衰减,因此离信号源更近的测量本质上更可信。
收敛判据。 当代价的相对下降量低于 1e-10,或步长小于参数范数的 1e-10 倍时,优化器停止。这些容差很紧,所以算法不会提前停止。它还有 1000 次迭代和 1000 次函数求值的硬上限作为安全网,不过实际上它会在 5 到 20 次迭代内收敛。
QR 分解。 每次迭代时,用带列主元的 QR 分解对雅可比矩阵(残差对位置参数的偏导数矩阵)做分解。这种做法数值稳定,遇到秩亏的情况也能从容处理。实现里包含完整的 QR 分解例程,用 Householder 反射做变换,用列主元保证数值稳定性,并用 Givens 旋转确定 LM 参数。
这个问题的雅可比矩阵很直接。残差对每个坐标的偏导数就是 2x - 2pᵢ:
残差函数计算的是距离平方之差:
把这些拼起来,就得到一个对噪声距离足够稳健的位置估计。算法不要求三个圆交于一点(实际中它们永远不会),而是找出与现有数据拟合最好的位置,并按每个测量值的可信程度加权。当视野里有第四个、第五个信标时,系统会自动把它们都用上。信标越多,约束越多,拟合越好。算法根本不在乎“三个圆”的几何关系,它只是最小化误差。
线程隔离:让 UI 保持流畅
BLE 扫描会持续产生更新。CoreLocation 每秒回调一次 didRange,每次回调都带一个数组,包含所有可见信标及其最新的 RSSI 值。如果能看到 5 个信标,那就是每秒收到 5 个更新过的 CLBeacon 对象。这个负载不算大,但如果在主线程上随意处理,UI 照样会受影响:表格视图会卡顿,SpriteKit 场景会掉帧,整个 app 用起来就像坏了一样。
解决办法是显式管理线程。BeaconScan 处理测距回调(过滤已知信标、计算距离、构建 diffable data source 快照),然后显式切回主线程来应用快照并触发可视化更新:
通知会在 ScanController 中触发 calculateLocation(),进而运行 IndoorMath.updateVisibleBeacons() 和 IndoorMath.getLocation()。这些都是纯计算(EMA 滤波、距离换算和 LMA 求解器),之所以放在主线程,是因为结果马上要被 SpriteKit 场景消费。对 MVP 来说,这是有意做出的取舍。三到五个信标时,求解器几微秒就能收敛,放到后台队列只会增加上下文切换的开销,看不到任何实际收益。我实测过。在一台当前的 Mac 桌面机上,用优化构建的 LMAMath.swift 对两个固定场景(分别有三个和五个信标)循环求解,三个信标平均每次约 5 微秒,五个信标约 10 微秒。手机会慢一些,但仍在微秒量级。按 Geekbench 单核成绩,iPhone 11 大约比这台 Mac 慢 2.5 倍,五个信标的求解也就是几十微秒。结论不变。
快照在任何东西接触 UI 之前就已完整构建,这才是关键所在。DispatchQueue.main.async 随后保证它会在主线程上应用,无论测距回调是从哪个队列送来的。AppDelegate 还持有一个 tableShouldUpdate 标志,ScanController 会在 viewDidDisappear 中把它设为 false,因此当用户没有盯着扫描界面时,扫描控制器会彻底停止处理。没人在看的时候,在三边测量上烧 CPU 毫无意义。
用 SpriteKit 渲染平面图
可视化层我用了 SpriteKit,苹果的 2D 游戏引擎。对一个工具类应用来说,这选择看着有点怪。用 UIKit 会更常规。但 SpriteKit 给你一个带坐标系、z 轴排序和内置动画的场景图,这些在渲染带移动物体的平面图时都用得上。
VisualizationScene 类管理场景。信标画成蓝色方块 sprite,设备位置画成红色标记。平面图可以从照片图库加载,以 50% 不透明度作为背景层放进去:
zPosition 系统保证背景始终在信标和位置标记之后。场景更新时,所有已有的信标节点和位置节点都会被移除,再以新位置重新添加。这不是最高效的做法(你可以在原地更新已有节点),但对少量对象来说足够快,也省去了节点池的复杂度。
场景的坐标系以 (0, 0) 为中心,根据「View Area Size」设置向外延伸。信标坐标相对于这个中心点存储。如果把某个信标的 X 设为 5.0、Y 设为 -3.0,它就会出现在场景中心右侧 5 个单位、下方 3 个单位处。缩放系数把场景单位映射到像素,并随区域大小变化而调整。
有个不错的细节:SKView 上的 preferredFramesPerSecond 被设为 1。这听起来不对。游戏引擎上为什么要 1 fps?但这不是游戏。场景不需要以 60 fps 持续渲染,只需要在有新位置数据时重绘,也就是测距回调到达的时候。把帧率设为 1,意味着 SpriteKit 每秒 tick 一次场景,足以让背景和节点位置保持新鲜,又不会为不必要的渲染耗电。真正的位置更新通过 updateVisualisation() 按需发生,它直接操作节点树。

图 5 — 全局设置界面。UUID 字段决定扫描哪个 iBeacon 区域。View Area Size 控制场景缩放比例。Calibration Data Size 设定校准时平均多少个 RSSI 采样。EMA Filter Size 控制平滑窗口。iCloud 同步可以开关(需重启应用)。
持久化:Core Data、Codable 与 CloudKit
Beacon 的配置(坐标、校准后的 RSSI、名称)需要在应用重启后依然存在,并在多台设备间同步。这里的架构分三层。
序列化。BeaconCoordinates 是一个普通的 Codable 结构体:
每个 beacon 的配置序列化成 JSON,以字符串形式存进一个名为 Beacons 的 Core Data 实体。该实体只有一个属性 data,用来保存这个 JSON 字符串。计算属性 dataAccessor 透明地处理编解码:
这个做法不太常规。通常你会把每个字段建模成单独的 Core Data 属性,而不是把 JSON 塞进一个字符串。但这么做有个实际理由:CloudKit。Core Data 通过 CloudKit 同步时,每个属性都会变成 CloudKit schema 里的一个独立字段,而 schema 变更需要迁移。把配置存成单个 JSON 字符串,CloudKit schema 就能保持简单稳定。给 BeaconCoordinates 加一个新字段只是 Codable 层面的改动:不用迁移,也不用更新 CloudKit schema。
本地持久化。BeaconKnown 是 CRUD 层。它在初始化时取出所有 beacon,提供 addBeacon 和 deleteBeacon 方法,并通过 NSManagedObjectContext 保存。context 开启了 automaticallyMergesChangesFromParent,所以 CloudKit 同步带来的变更会直接反映出来,不需要手写合并逻辑。
云端同步。AppDelegate 延迟初始化 persistent container。如果启用了 iCloud 同步(默认开启),它会使用 NSPersistentCloudKitContainer 而不是普通的 NSPersistentContainer:
启用 CloudKit 后,Core Data 会自动将变更同步到 iCloud,并在用户的多台设备之间保持同步。在 iPad 上配置好的信标,会出现在 iPhone 上。entitlements 文件声明了 CloudKit 容器标识符和各项服务。关闭同步时,应用会退回到仅本地存储,并启用持久化历史跟踪(在不使用 CloudKit 的情况下,要正确处理变更就必须开启它)。
校准流程全貌
下面完整走一遍校准一个信标时实际发生的事,因为它几乎牵涉到每一个组件。
你在列表中点击一个信标,触发跳转到 BeaconSettingsController。界面上有 X、Y、RSSI 和名称几个字段。你输入信标在平面图上的物理坐标,比如 (5.0, 3.0),再填个名字,比如 "B1"。RSSI 字段默认是 -59,一个常见的出厂值。
你按下 "Start calibration"。弹窗提示你把手机放在距信标 1 米、高度相同的位置。你确认。calibrationInProgress 标志被置为 true,RSSI 字段被禁用,同时发出一个通知:
BeaconScan 收到这个通知,保存正在校准的信标。在下一次测距回调中,它从可见信标里筛选出 major/minor 匹配的那一个,并把它的原始 RSSI 发回去:
BeaconSettingsController 收到每一个 RSSI 值,递增计数器并累加求和。实时 RSSI 会显示出来,让你看到读数不断进来:-61、-58、-63、-60、-59……当计数达到配置的校准数据量(默认 30)时,计算平均值并保存:
calibrationInProgress 上的 didSet 触发,发出停止通知,显示最终值,重新启用该字段,并重置累加器。你按下 Save,BeaconKnown.addBeacon 把校准后的信标持久化到 Core Data。如果 CloudKit 开着,它还会同步到你的其他设备。
此后,当这个信标在扫描中可见时,IndoorMath 会用校准后的 RSSI 作为距离计算中的 txCalibratedPower。1 米参考值不再是一个猜测,而是针对这个具体的物理信标、在这个具体的环境中、用这台具体的手机测出来的值。
App Store 之路
这款 app 如今已不在 App Store 上,但它确实上架过。它以「Beacon Indoor Location」之名发布,App Store ID 为 1561643830,先后推出 1.0 和 1.2 两个版本,均通过了 Apple 审核。1.2 版一路走完 App Store Review,最终状态为「Ready for Distribution」。后来下架,并非 app 本身有问题,而是背后的商业开发者账号被临时冻结。那时 BeaconIL 已经完成了它要做的事:在一个 Apple 批准的、面向真实消费者的 iOS app 里,把从原始 BLE 信号到平面图上一个小点的整条链路跑通,端到端验证了概念。剩下的是一个可以进一步扩展、也能适配美国市场基础设施标准的架构——如果真要走这条路的话。代码还在,以 MIT 许可证开源。

图 6 — BeaconIL 在 App Store 上。该 app 通过了两轮 App Store Review 并发布。目前列表处于非活跃状态,架构则在为更大规模的部署做准备。
数学的演进:一个 bug 和一次泛化
git 历史本身就说明了问题,也展示了这套数学是怎么真正成熟起来的。
第一次提交是在 2021 年 4 月 22 日。整个代码库,71 个文件、3,947 行,作为一次「First public commit」全部提交。对一个私下开发、之后才推到远端的个人项目来说,这并不稀奇。稀奇的是接下来发生的事:两个月里什么都没动。然后 6 月 28 日,第二次提交只碰了两个文件:LMAMath.swift 和 IndoorMath.swift。提交信息把这次改动描述为增加了额外的检查,并支持两个以上的维度。
那次提交修掉了一个隐蔽但严重的问题。在原始代码里,迭代计数器和求值计数器被初始化成了最大值本身(1000 和 1000),而不是 0 和 1:
循环里根本没有对这些上限做任何检查。计数器是有了,却从未真正生效。一旦优化器不收敛(比如信标几何分布极端糟糕,或者距离数据前后矛盾得离谱),while true 循环就会一直转下去,把应用卡死。这些上限看起来像一张安全网,但没有任何东西去执行它们。
六月的更新把两个问题都修了。计数器被正确地初始化为 0 和 1,循环内部也加上了真正的上限检查:
maxIterations 和 maxEvaluations 也从实例变量改成了 static let 常量——一次小小的 Swift 重构,让它们成为类型上的编译期常量,而不再是每个实例各自持有的值。
那次提交里的第二处改动是返回类型。原来的 solve 函数返回一个固定的二维元组:
文件头写的是“Solves a formulation of n-D space trilateration problem (2D in this application)”。更新后的版本删掉了括号里的说明,把返回类型改成了泛型数组:
现在求解器返回的坐标数组维度与输入位置的维度一致。IndoorMath.getLocation() 里的调用方仍然只解包前两个元素,因为应用里的楼层平面图是平的,但求解器本身并不局限于两个轴。雅可比矩阵、残差函数和 QR 分解全都按任意维度运算,所以同一份代码不用改动就能处理多层零售店的布局。
那次提交还加了维度校验:如果输入位置的维度不一致,求解器返回空数组,而不是算出一堆垃圾。测量数据也受到同样的对待:距离为零、为负或非有限值,锚点坐标非有限值,都会在一开始就被拒绝。原因出在加权上。平方反比加权会把零距离变成无穷大的权重,而一个 NaN 会悄无声息地穿过 QR 分解,混进返回的位置里。应用本身不会产生这类值,因为它的距离来自路径损耗公式。但求解器是公开 API,它的输入值得和输出一样被严格审视。输入有问题就返回空数组,这是两个实现共同遵守的约定。
故事在 2026 年还在继续,就在这篇文章成稿期间。距离函数在 2021 年上线时,除数里硬编码了指数 2,因为 4 米 × 6 米的测试房间从未让这个选择暴露出问题。编写大场地标定方案时,才暴露出这种硬编码在部署规模下的代价,于是指数变成了你在清单里看到的 pathLossExponent 参数,默认值为 2,这样原来两处调用点的行为与之前完全一致。五年之后,一个提交,把实验室测量和工厂需求之间的闭环合上了。
iBeacon 实际部署:无线电物理告诉了你什么
如果你真的要用 BLE beacon 做室内定位,物理规律会带出一些实用经验。这些经验与具体应用无关,适用于任何用 RSSI 估算距离的系统。
iBeacon 数据包格式
iBeacon 广播包是一个 BLE 广播,带有 Apple 定义的特定载荷结构。包中包含:
- UUID(16 字节)——一组 beacon 共享的区域标识,例如
07070707-0405-0607-0809-0A0B0C0D0E00。手机会按这个 UUID 过滤 beacon,所以同一次部署中的所有 beacon 通常共用它。 - Major(2 字节,0–65535)——子组标识,例如“2 层”或“B 栋”。
- Minor(2 字节,0–65535)——子组内的单个 beacon 标识,例如“2 层的 14 号 beacon”。
- TX Power(1 字节,有符号)——出厂时测得的 1 米处 RSSI 标定值。这就是距离公式使用的
txCalibratedPower。它只是一个起点。你应该像我上面说的那样在现场重新标定,因为出厂值没有考虑你的具体环境。
beacon 以可配置的间隔广播这个包,通常是 100 毫秒(10 Hz)。间隔越短,数据越多、响应越快,但电池寿命越短。以 100 毫秒间隔运行的纽扣电池 beacon 能撑几个月;间隔改为 1 秒,则能撑一年以上。做定位时,你要选更短的间隔:响应速度比电池寿命更重要,而且在长期部署中 beacon 通常接墙电。
部署几何
三边测量要求在你希望获得位置估计的任意位置,至少能看见三个信标。这话听起来显而易见,但实际做到需要提前规划。
在商业零售场所、办公空间或企业物流仓库部署 iBeacon 时,几何布局决定了定位质量。一个在平面图上看起来合理的部署方案,如果几何关系不对,实际效果可能一塌糊涂。
最糟糕的布局是一条直线。沿走廊摆三个信标,几何条件极差。三边测量问题变得病态,微小的距离误差会沿垂直于这条线的轴放大成巨大的位置误差。求解器照样会收敛,但结果不稳定。
最好的布局是三角形:三个信标放在等边三角形的顶点,预期设备位置大致在重心附近。这样求解器能从多个角度获得均衡的约束。在矩形房间里,把信标放在四个角中的三个效果不错。在走廊里,把它们交错布置在两侧墙面,而不是全放在同一侧。
更大的空间要按重叠三角形来考虑。每一块你想定位的区域,都应该至少能看见三个信标,且大致呈三角形分布。加第四个信标能提供冗余、提升精度,但三个位置得当的信标胜过五个位置糟糕的。
高度很关键
这是室内定位部署中最常被忽略的因素。路径损耗模型假设信标和接收器处于同一高度。当两者高度不同时,实际距离是斜边——比水平距离更长——而 RSSI 反映的正是这个斜边距离,无论你的平面图有没有把它算进去。
如果信标安装在三米高的天花板上,手机握在 1.5 米处,垂直偏移就是 1.5 米。若水平距离为 2 米,真实空间距离是 √(2² + 1.5²) ≈ 2.5 米。RSSI 反映的是 2.5 米,而你的二维三边测量假设的是 2 米。仅仅因为高度不匹配,就引入了 25% 的误差。
对于 BeaconIL 这样的二维定位系统,解决办法是把信标安装在和手机差不多的高度——1.5 到 2 米——固定在墙上而不是天花板上。这未必总是可行,但这是你能做的最大的一项精度改进。如果天花板安装无法避免,你有两个选择。第一,在软件里补偿高度差,用勾股定理从距离估计中减去垂直分量。第二,把高度显式建模:2021 年 6 月的更新让数学部分可以接受任意维度的坐标,因此每个信标可以携带自己的安装高度,垂直偏移就成了模型的一部分,而不再是误差。
环境因素
BLE 使用的 2.4 GHz 频段和 Wi-Fi、微波炉以及大量其他消费电子产品相同。需要留意的干扰源:
- Wi-Fi 接入点——尤其是 2.4 GHz 频段上的 802.11n/ac/ax。这些信道与 BLE 广播频率重叠。不要把信标安装在距离 Wi-Fi AP 1 米以内的地方。
- 金属表面——冰箱、服务器机架、电梯井、金属货架。它们会产生强烈的多径反射。放在金属货架后面的信标实际上等于不存在。
- 人体——人体大约 60% 是水,而水会吸收 2.4 GHz。拥挤环境中的 RSSI 会比空旷环境低 3–5 dBm。这是系统性的,不是随机的,所以它会使距离估计偏高。没有简单的解决办法。EMA 滤波器能平滑过渡,但偏差依然存在。
- 玻璃和隔断——玻璃对 2.4 GHz 基本透明,但镀膜或着色玻璃(low-E 窗)会让信号衰减 5–10 dBm。干墙隔断每面墙衰减 2–4 dBm。混凝土墙衰减 10–20 dBm,实际上会阻断信号。
实际结论:在系统将要运行的环境中校准,而不是在实验室里。空旷走廊里测得的 1 米参考 RSSI,和堆满金属货架的仓库里测得的不会一样。BeaconIL 的逐信标校准正是为此而存在。
不是每个信标都需要大声喊
最省事的直觉,是把每个信标的发射功率都拉满。距离更远、覆盖更广,完事。但在工厂车间里,这种直觉会适得其反。到处都是金属,机器在运转,人在走动:一个大声的信标放大的是反射和干扰,而不是覆盖范围。在真实制造环境中的测量发现,BLE 信号在那里勉强可用,但衰减明显,而且布点和密度比原始功率更重要。
我们的答案是功率阶梯。
普通信标承担粗定位层。但有些设备故意发射极弱的信号。它们一超出短距离就几乎立刻无法被检测到。在近距离,经过同样的逐信标校准后,它们的距离估计接近理想值。一个只有靠近才能听到的信标,能高置信度地告诉你一件事:你离它很近。
阶梯的最底层是行为类似 NFC 的近接触发射器。大约十厘米内可检测到。当手表靠近受管控的机器到这个距离时,警报会在设备上触发。半米的平均精度对导航来说没问题。但作为冲压机附近的最后一道安全边界,它不行。机械安全工程考虑的是人与危险区之间的最小隔离距离,而一个带有半米误差的估计无法充当这条边界。于是这条边界成了独立的一类设备。
这个教训不限于工厂。高发射功率有时是敌人。它换来距离,付出的代价是多径、干扰和虚假的自信。刻意使用弱信号反而是一种特性:把距离塌缩当作接近信号。
我会怎么改
隔了一段时间回头看这份代码,有些地方我会改。
EMA 的实现把原始 RSSI 值存在一个数组里,手动计算指数平均,每次更新都平移数组。这能跑,但比需要的复杂。更干净的做法是只存当前的 EMA 值和窗口大小,直接套标准 EMA 公式:ema = α × new + (1 - α) × ema。基于数组的做法是早期实现时的选择,我一直没重构。
Beacons 这个 Core Data 实体把配置存成 JSON 字符串,而不是拆成一个个属性。CloudKit 那边的理由我在上面解释过,这是有意为之的取舍。但代价是你没法用 Core Data 的 predicate 按 major 值之类的字段查询,只能把所有 beacon 取出来逐个解码。几十个 beacon 的 app 无所谓,上千个就不行了。
LMA 求解器是从 Apache Commons Math 的实现原样移植过来的,也就一并继承了那部分代码的复杂度。determineLMParameter 和 determineLMDirection 两个方法写得很密,实现的是计算 LM 参数的 Moré–Hebden 算法,里面涉及 Givens 旋转、秩判定和一个区间收缩循环。逻辑正确,测试也充分,但读起来和维护起来都不轻松。更现代的做法或许是用更简单的信赖域方法,或者干脆把线性代数交给 Accelerate(iOS 自带的库)来做。
数学才是重点
BeaconIL 有意思的地方不在 UI,也不在 App Store 上架。是数学。室内定位从外面看很简单(“用蓝牙信号判断你在哪儿”),真做进去才发现是个无底洞:用 EMA 滤波对抗无线电噪声,用逐 beacon 标定锚定的路径损耗模型,用 Levenberg-Marquardt 把带噪声的距离变成位置,再用线程隔离把 BLE 回调挡在 UI 之外。每一块单独看都不复杂,难的是让它们在真实硬件上实时协同工作——而硬件的行为往往和教科书上写的不一样。在 4 米乘 6 米、三个 beacon 的测试场地上,整条链路把估计误差控制在半米左右。这才是工程上的挑战,也是这个项目值得做的原因。
代码在 https://github.com/Vitaliy69/BeaconIL。三边定位求解器的独立 Go 移植版在 https://github.com/Vitaliy69/lmamath。两者都是 MIT 许可。
来源: HackerNoon← 返回首页