← 返回首页

用 Kotlin 手搓蜂窝信号路测 App:思路与踩坑实录 🛰️

做网优的朋友应该都用过 Cellular-Z:一个页面把服务小区、邻小区、频段、RSRP/SINR 全摊开。方便是方便,但有两件事它给不了——工参匹配(这个小区属于哪个区县、叫什么名字)和地图打点(边走边把信号强度画在轨迹上)。

于是决定自己写。前后做了两个 App:一个纯信号监测(2.5MB),一个加百度地图打点(40MB)。两个的包名、签名、数据目录都不同,可以同时装在一台手机上,老的那个不用卸。

下面把开发过程中的思路和踩坑的办法一并写出来。凡是"我以为"和"实际是"不一致的地方,都标出来。

一、先定三条设计原则,比写代码重要

1. 数据可以来两次,画面只能画一次

这是最贵的坑。第一版信号页每秒闪一下,我以为是刷新频率太高。其实是每一拍渲染了两次:先同步查一次通话栈画一遍,紧接着主动请求的回调又画一遍。两帧内容不同,叠加状态栏文案在两份字符串之间来回跳,就是肉眼可见的闪烁。

改法很简单:异步回调只负责把数据存进缓存,渲染统一交给定时拍,每拍只画一次。

2. 不缓存通话栈数据

小区身份(频点、PCI、CI、频段)和信号值必须来自同一份快照。我第一版把推送来的数据缓存起来"省点开销",结果出现了一个自相矛盾的对象:身份来自陈旧缓存,所以 B3 切到 B40 之后还显示 B3;信号值来自实时覆盖,所以 RSRP 一直在变。混合数据的表现就是"参数都不变,只有信号值在动"。

3. 能不加依赖就不加

纯信号那个 App 是零第三方依赖的(只有 framework 和 Kotlin 标准库),构建从来没出过依赖问题。为了地图不得不引 SDK 之后,体积一下从 2.5MB 涨到 40MB——其中 29.5MB 是地图的原生库,我自己的代码加两万条工参不到 0.5MB。

配图

二、通话栈到底给不给这个值

这部分我认为最值得写下来。Android 的蜂窝 API 有个特点:文档写了有这个字段,不代表你的手机会填。

反直觉事实一:getAllCellInfo() 经常返回缓存

很多机型上它是"上一次系统刷新时的快照",不是当前值。requestCellInfoUpdate()(API 29+)能拿到更新的数据,但受系统限频,回调里会给你错误码 1(被限频)、2(定位权限不足)——注意这两个常量是隐藏的,代码里只能硬编码。

我的做法是两条通道并用,但渲染只用一份:主动请求的结果先落缓存,等下一拍取用。

反直觉事实二:频段可以直接问通话栈要

CellIdentity#getBands()(API 30)直接返回频段号,不用再拿 EARFCN 去查表反推。这解决了一个大问题——之前 B3 切 B40 时因为 EARFCN 是缓存值,反查出来还是 B3。

反直觉事实三:载波聚合下 isRegistered 分不出主辅载波

CA 的时候 PCC 和 SCC 的 isRegistered 都是 true,所以"聚合了 B40 却只显示 B3"是必然的。正解是 CellInfo#getCellConnectionStatus(),它能区分主服务小区、辅服务小区和邻区。

SINR 必须三级取值(这条最实用)

AOSP 文档说 CellSignalStrengthLte.getRssnr() 单位是 dB、范围 -20 到 +30,取不到返回 UNAVAILABLE。但很多机型在 CellInfo 里根本不填这个字段,于是 4G 下 SINR 永远是「—」,而 Cellular-Z 却显示得好好的。

三级兜底:

KOTLIN
一级 本条 CellInfo: info.cellSignalStrength.rssnr
二级 系统级兜底:   tm.signalStrength
                        .getCellSignalStrengths(CellSignalStrengthLte::class.java)
                        .firstOrNull()?.rssnr
三级 合理性兜底:   正常区间原样用;-300..400 视为 0.1 dB 除以 10;其余判未知

二级之所以更可靠,是因为它是调制解调器主动上报的当前信号强度,不是 CellInfo 那份快照。但有个必须注意的点:二级只能给服务小区用,因为系统级的那个值描述的就是当前服务小区,套到邻区头上就是张冠李戴。

NR 侧同理:ssSinr 退到 csiSinr(部分机型只填后者),再退到系统兜底。

时间戳的基准不是 epoch

KOTLIN
// 基准是 SystemClock.elapsedRealtime(),也就是开机以来的毫秒数,不是 epoch
CellInfo#getTimestampMillis()

我拿它和 currentTimeMillis() 相减算"数据延迟",得到 1790739539424 毫秒——整整一个 epoch。算延迟必须用同一个基准。

三、坑一:打点两秒后闪退到桌面

现象很有说明性:不按"开始打点"不会崩;一按,两秒后崩,有时候能撑到七八秒。

先按时间反推:刷新间隔是 2 秒,移动中第二个采样点正好 2 秒到(需要位移超过 3 米);如果起点静止等 GPS,就会延到七八秒。和观察到的两个时间点完全对上。

嫌疑锁定在轨迹渲染。注意这是原生崩溃,Java 层一行堆栈都拿不到,只能靠推理和实验。

于是把 SDK 的 AAR 下下来解包,用 javap -c 看 Polyline.setColorList(int[]) 的字节码:

JAVA
public void setColorList(int[] colors) {
    this.k = colors;        // 只是存了个引用
    ...
}

长度语义在 native 层。 我按"点数减一"传数组,只要语义不一致就是段错误——JVM 捕不到,表现就是闪退到桌面。

改法三处一起改,不留侥幸:

TEXT
一、弃用 setColorList,改成"按连续同信号等级切段、每段一条单色折线",
    只用 points() 加 color() 两个方法
二、关掉抽稀开关 isThined(true) —— 它会在 SDK 内部丢点,和颜色点数对应关系失配
三、交给 SDK 的点列表一律传副本 —— SDK 内部存引用,而我的裁剪函数会改那个可变列表

顺带加了个崩溃取证机制,这也是值得推广的做法:原生崩溃拿不到堆栈,那就每次调 native 之前,把"正在做什么"覆盖写进一个定长文件。崩溃后回来读它,等价于手动打的断点。定位效率比反复猜测高一个数量级。

四、坑二:坐标系不转换,点位偏了 926 米

系统定位给的是 WGS-84(GPS 原始坐标)。国内地图吃的是 GCJ-02(腾讯、高德)或 BD-09(百度)。不做转换直接往上画,轨迹整体偏移——形状是对的,只是平移了,特别容易被误判成"信号覆盖有问题"。

实测偏移量(四川资阳):

TEXT
WGS-84 到 GCJ-02:    偏约 392 米
GCJ-02 到 BD-09:     再叠一层
合计完全不转换:       偏 926 米

两个决定:

  • 不调地图 SDK 的 setCoordType(),自己实现三级转换。转换公式是公开的纯数学,约 30 行,不联网、不依赖 SDK,行为确定可控。
  • 只用系统定位,不引入地图 SDK 自带的定位功能。因为它返回的是已偏移坐标,两套坐标系混用是这类项目最大的 bug 源。

五、几个让界面"像那么回事"的细节

邻区 PCI 的模 3 干扰标记

邻区 PCI 右上角出现上标 3(比如 495³),表示它和服务小区 PCI 的模 3 相同。

原理:LTE 的小区特定参考信号在频域的梳状位置由 PCI 模 3 决定;模 3 相同的两个小区,参考信号落在同一组子载波上互相干扰——就是网优说的"模 3 干扰",做 PCI 规划时要优先避开。

判定和显示都很轻:

KOTLIN
val conflict = pci % 3 == servingPci % 3      // 只在同一制式内比较
// 命中就拼一个 Unicode 上标 ³,不用 Spannable

邻小区排序要用稳定键

原来按 RSRP 降序,每一拍顺序都可能变,行与行互相换位——这也是闪动的一部分。改成按"频段加频点"这种稳定键排序之后就安定了。

工参匹配的主键必须是小区标识

工参表里"频点加 PCI"的组合重复率极高(我这份 4G 的 14426 行里只有 3210 个不同组合),拿它当主键必然张冠李戴。可靠的主键是手机上报的小区标识:4G 对应表里的 ECI,5G 对应 NCI(等于 gNB 左移 12 位再或上 CI,验算过)。频点加 PCI 只能兜底。

六、排查方法论(比结论更重要)

一、先读真实签名,别信搜索引擎。 搜索给的隐私合规接口是单参版本,而地图 SDK 是两参版本,照抄编译不过。把产物解包、反编译出来看,成本几分钟,能省掉好几轮试错。

二、可观测性要主动设计。 诊断面板上的"数据来源、单次读取耗时、小区数、请求成功失败计数、错误码、数据延迟"这几项,帮我省下的时间远超写它的时间。

三、别用错误的方式做压测,会得出错误结论。 我用假令牌做并发压测,得到"一半请求 429",就断言服务端限流很紧,还为此在客户端加了退避逻辑。后来读到服务端源码才发现:限流只统计失败,正常校验永远不被限——我那是在拿头撞防爆破闸门。有效令牌 120 路并发 100% 通过,之前那套"防限流"机制纯属多余。

四、"改完没生效"先怀疑落盘。 我有一次改代码,工具报成功、文件里还是旧内容,于是编译报了一个莫名其妙的参数缺失错误。现在改完关键文件一律复核一遍。

七、本机从零构建 APK 的几条硬约束

最后记几条环境层面的坑,都是会直接卡住构建的:

TEXT
一、构建工具拒绝非 ASCII 项目路径 → 中文目录直接报错,必须同步到纯英文路径再编译
二、含中文路径的脚本文件会被按 GBK 解码 → 路径乱码,一律改用内联命令
三、报"文件拒绝访问"往往是缓存目录被锁 → 换全新的输出目录加重开,不必去删旧目录
四、长构建必须显式给足超时 → 否则被腰斩在编译中途,日志戛然而止(不是编译错误)
五、升级版本必须用同一个签名文件 → 否则用户得先卸载再装

交付前用 aapt2 dump badging 核对包名、版本、最低 SDK;用签名工具核对指纹是否与上一版一致,一致才能直接覆盖安装。

写在最后

这套东西的价值不在于"又一个信号 App",而在于它把两件平时拿不到的信息合到了一起:测到的信号和这个小区的档案。记录里自动带上"这个点属于哪个区县、哪个小区",事后导出 CSV 或 KML 就能直接做覆盖分析。

代价也很实在:为了那张地图,安装包从 2.5MB 变成 40MB。如果你也想做类似的事,我的建议是先做"只采集加导出"跑一轮——不用地图 SDK、不用申请密钥、不涨体积,导出的 KML 丢进 Google Earth 一样能看轨迹。确认数据真有用,再决定要不要为一张地图付 40MB 的代价。

💬 评论 (1)

今日还可评论 3 条
杰哥加油,好好做!
IP 117.173.111.4 · 📍 四川省 · 资阳市 · 🏠 中国移动 · 🕑 22 天前