加密拦截器让抓包失效,我只能从 4KB 的 logcat 碎片里拼回 JSON

Android 联调的真实链路:网络层拦截器做加解密之后,抓包只能看到密文,logcat 里那行明文成了唯一能拿到接口最终形态的地方。可它有 4KB 上限,还会丢中间段。而通用的在线 JSON 工具,恰好假设你手里是一份完整数据。

和后端对字段,我打开 Charles,抓到的是密文。

回来看 logcat,明文在里面,但被切成五段,每段前面都挂着时间戳、pid、包名。

这是 Android 联调的日常。以前这一轮下来要五分钟,现在十秒。体验地址

一、为什么抓包不够用

网络层挂了一个 Interceptor,请求体加密,响应体解密。这套东西在安全和风控上是必要的,代价是抓包工具直接失效。

Charles 装上证书能看到 HTTPS 明文,但 body 是密文,一串 base64。改包更不行,签名对不上,服务端直接返回鉴权失败。

所以在整个链路里,能看到业务数据最终形态的地方只剩一处:解密之后、序列化之前,那一行日志。

logcat 在这里不是备选方案,是唯一窗口。

这也解释了为什么很多 Android 开发者明明有抓包工具,遇到字段问题时还是第一反应去看日志。不是习惯问题,是数据只有在那里才是可读的。

二、这个窗口很窄

三个约束叠在一起:

单条消息有长度上限,四 KB 左右,超了系统直接切断。切断位置不看语法,可能正好落在一个 key 中间。

缓冲区是环形的,满了会覆盖旧数据。覆盖发生在中间,不是结尾。

每段都带前缀。时间戳、pid-tid、包名、级别、tag,切一次就加一遍。

结果就是:你想要一份 JSON,拿到的是几段碎片,每段前面还有一行元数据。

三、于是联调变成了这样

把手工流程完整走一遍,就知道损耗在哪。

第一步,复制。 要精确,多选一行少选一行都不行。少选的那行里可能就有 }。

第二步,粘到在线工具。 提示格式错误。

第三步,回去把每行前面的时间戳和包名删掉。 这里得小心,包名后面紧跟着 {,多删一个字符 JSON 就坏了。

第四步,再粘。 还是错。因为中间少了一段,删前缀解决不了。

第五步,放弃。 回到 Android Studio,肉眼找字段,一个个敲 data class。

一个接口十几个字段,嵌套三四层。敲到一半发现漏了一个,回去补。改个字段名,再敲一遍。

这个过程联调期一天重复十几次。单次三五分钟,一天就是将近一小时。

把时间拆开看:

环节 手工 用工具
复制 + 删前缀 1 到 2 分钟,还得小心别删多 原样粘贴
格式化 半分钟,遇到丢段还会失败 即时
敲 data class 2 到 3 分钟,十几个字段 直接复制
单次合计 3 到 5 分钟 10 秒左右
一天 15 次 50 分钟以上 不到 3 分钟

四、通用工具的边界,就是垂直工具的机会

这类工具本身没错,问题在它的假设:输入是一份完整的、合法的 JSON。

这个假设对前端成立,对后端也成立,大家手里拿到的都是一份能直接跑的数据。但对 Android 不成立:你手里那份是从日志缓冲区里捞出来的碎片,前面挂着时间戳,中间还可能缺一段。

json.cn 这类站点不止一家,搜出来前几页功能都差不多:格式化、压缩、校验、转 XML、转 YAML、JSONPath。它们面向的是同一个通用需求,所以谁也不会为某一个场景把细节做透。换句话说,换一家也一样。

而我需要的三件事,它们一件都没有:

  • 认得出 1234-5678/com.pkg D/OkHttp: 这种前缀,并且知道 1234-5678 是一体的
  • 看得出 pe":"GLOBAL" 前面丢了东西,还能接回正确的层级
  • 直接把结果生成 Kotlin data class,带注解、带可空性、带命名转换

不是它们做得不好,是服务的对象不是我。这是品类分工,不是能力问题。

广告是同一套逻辑。通用工具靠流量活着,流量要变现就得挂广告;我要的是一个打开就能用、不切浏览器、数据不出本机的东西。两个目标不在一条线上,所以每次切过去用,都是一次妥协。

真正缺的不是"更好的 JSON 格式化",是"给 Android 联调用的那一个"。

五、所以我做了一个

用 AI 辅助写的,单文件 HTML,离线,双击就开。打开就有示例数据,不用先准备日志就知道它能干什么。

不用装 Node,不用配环境,不用注册账号。界面就两个框,左边粘日志,右边出结果,没有要学的配置项。发给测试同学也能直接用,他们手里有线上包的日志,一样能还原出字段,不用每次都来问你。

核心是四件事:

环节 做什么
剥前缀 七种 logcat 复制格式,按信息量从多到少依次匹配
拼碎片 括号和引号平衡扫描,同 tag 且上一条未闭合才拼
宽容解析 尾逗号、单引号、无引号 key、Data(a=1, b=2) 这种 toString
生成类 Int/Long 按 int32 边界分,输出 Gson 或 kotlinx 注解

这里有一个决定值得单独说:没有用 JSON.parse。

js 复制代码
JSON.parse('{"orderId":9007199254740993123}')
// {"orderId":9007199254740993000}

JS 的 Number 是双精度浮点,安全整数上限 2^53-1。十九位的订单号、用户 ID 直接报废,而且不报错。你只会觉得后端返回的 ID 有点怪。

所以 parser 是自己写的递归下降,数字保留原始字符串,Int 还是 Long 也从原文判断。

六、AI 一次就过的部分

上面那张表里的四件事,第一版基本一次成型。

正则、递归下降 parser、模板生成、Kotlin 类型映射,这些都是 AI 的舒适区,因为输入输出明确,没有歧义。整个工具单文件 1900 行,零依赖,写完就能跑。

到这里完成度看起来不错。然后我拿真实日志喂了它一次。

七、被真实日志打回的三次

第一次:拼接时吞掉换行

早期版本拼碎片是一律直接接:

js 复制代码
prev.content += line;

JSON 无所谓,反正空白不影响解析。但多行 protobuf TextFormat 会被压成一行,后续"是否多行结构"的判定直接失效,整段走错分支。

修法是看平衡扫描的状态:断在字符串中间就直接接,断在 token 之间补一个换行。

第二次:宽容 parser 吞掉下一个字段的首字母

parseObject 末尾有一句无条件 i++,本意是容错跳过异常字符。遇到没有逗号分隔的输入(protobuf 风格),它会把下一个字段名的首字母吃掉。

nickname 变成 ickname。

这个 bug 在 JSON 里永远不会触发,因为 JSON 有逗号。它只在另一种格式下暴露。

第三次:日志从中间断掉

这次最难。

我原本假设截断只发生在尾部,处理方式也简单:读不到闭合符就带着已解析的部分返回,能拿多少算多少。

真实日志是这样的:

sql 复制代码
..."accountDisabled":false,
17:45:15.895  D  pe":"GLOBAL","rankType":"honor",...

pe 是什么?它是 rankBadges 这个词的尾巴。中间那一段 "rankBadges":[{"topN":1,... 不在任何一行里。

环形缓冲区溢出时,丢的是中间一整段,不是结尾。丢在哪,跟你复制的时间点有关。

在加密链路里这件事没有兜底:抓包那边是密文,服务端日志你拿不到。丢掉的那段,就是真的没了。

拼起来就变成 "accountDisabled":false,pe":"GLOBAL",pe 被当成新字段名,后面所有 key 全部错位。生成出来的类里会冒出 val 17: Int? 这种不可能编译的东西。

八、修复里踩的两个坑

判定:什么才算"丢了东西"

左端停在 token 边界,右端却不是合法的 token 起始,才可能是丢段。普通的尾部截断左端会停在字符串中间或数字中间,不能混为一谈。

js 复制代码
function isLostSegment(prevText, nextText) {
  var left = prevText.replace(/\s+$/, '');
  var right = nextText.replace(/^\s+/, '');
  if (!left || !right) return false;
  var lastCh = left.charAt(left.length - 1);
  // 左端必须停在 token 边界上,否则就是普通截断
  if (lastCh !== ',' && lastCh !== '{' && lastCh !== '[' && lastCh !== ':') return false;

  var firstCh = right.charAt(0);
  // 合法的 JSON token 起始
  if (firstCh === '"' || firstCh === '{' || firstCh === '[' ||
      firstCh === '}' || firstCh === ']') return false;
  if (firstCh === '-' || (firstCh >= '0' && firstCh <= '9')) return false;

  if (/[A-Za-z_]/.test(firstCh)) {
    var m = /^[A-Za-z_]\w*/.exec(right);
    // xxx" → 残缺的 key 尾巴,说明中间丢了内容;否则可能是 true/false/null
    if (m && right.charAt(m[0].length) === '"') return true;
    return false;
  }
  return true;
}

关键在最后那个 xxx":字母后面直接跟引号。合法 JSON 里不会出现这种形态,它是残缺 key 的证据。

对齐:括号深度扫描为什么会失败

判定出丢段之后,得把后面的内容接回正确的层级。第一版做法是括号深度扫描,从左往右数 { [ 和 } ],第一个让深度变负的位置就是"回到了外层"。

听起来没问题,实际全错。

原因很朴素。残片开头那个 " 也丢了。原本 "rankBadges" 的引号是成对的,现在只剩一个孤零零的收尾引号。整段文本的引号奇偶性翻转了 ,后面每一个字符串的起止判断都错一位。字符串内部的 {、}、, 被当成结构符号,深度计算彻底失效。

这个坑没有通用解法,因为信息本身已经丢了。只能换个不依赖引号配对的思路。

缺失段一定吞掉了数组的 [,所以残片里第一个 ]," 或 ]} 就是数组真正的结束位置,跳过残片接到那里:

js 复制代码
function findRealign(text, start) {
  var rest = text.slice(start);
  var m = /][ \t\r\n]*,[ \t\r\n]*"/.exec(rest);
  if (m) return start + m.index + 1;
  m = /][ \t\r\n]*}/.exec(rest);
  if (m) return start + m.index + 1;
  // 缺失段里没有数组时,退回到对象结束
  m = /}[ \t\r\n]*,[ \t\r\n]*"/.exec(rest);
  if (m) return start + m.index + 1;
  return text.length;
}

效果是 activity 从 74 个字段恢复到 111 个,lang、groupInfo、subs 全部回来,只丢 rankBadges 一个。它确实找不回来,在被丢弃的那一段里。

九、Android 特有的那批坑

这部分跟 AI 无关,跟平台有关。做通用 JSON 工具的人不会遇到,只有天天看 logcat 的人才会。

前缀格式比想象的多。 至少七种,取决于 AS 版本、是否开了 threadtime、是不是从表格视图复制的。最容易写错的是 1234-5678/com.pkg 这一段:/ 不是空格,pid 和包名是连在一起的一个整体,正则按空格切会直接匹配失败。

长文本日志的续行没有 tag。 公司内部的长日志框架打续行时,形态是 17:45:15.895 D 内容,只有时间和级别。识别不了的话,整行连时间戳一起被当内容拼进去,17 和 15.895 就变成了字段名。

类名会撞。 字段叫 list,生成出来是 data class List(...),直接把 Kotlin 的 List 遮蔽掉,整份代码编译报错。字段叫 activity,生成 data class Activity,和 android.app.Activity 撞名。这类错误肉眼很难发现,因为类名本身完全合法,它只是抢了一个标准类型的名字。

现在的做法是维护一份黑名单,List、Map、String、Activity、View、Fragment、Context、Intent 这些统一加 Item 后缀。

可空性是个取舍。 工具里给了三档:全部可空、按样本推断、全部非空,默认全部可空。

按样本推断听起来聪明,实际不可靠。样本只有一次,这个字段这次出现,不代表下次接口还返回它,服务端加个灰度开关就能让它消失。所以"样本里非空"不能推出"实际非空",推错了就是线上崩溃。

全部可空的代价是业务代码里到处 ?:。全部非空的代价是一个字段缺失直接抛异常。默认选前者,因为解析工具不该替你做业务决策。

protobuf 有三种形态。 JSON 映射(JsonFormat.printer())天然支持,但注意 int64 在 proto JSON 里是字符串,会推成 String。TextFormat(proto.toString())也能解,关键是 repeated 字段靠重复 key 表达,不是数组,必须做合并。二进制 hex 没有 schema 也能解,但字段名只能给 field_N。

十、AI 的盲区在哪

这三次返工让我把边界看得比较清楚了。

AI 的盲区不是"写不对",是不知道有这种情况存在。

它写正则、写 parser、写类型映射都很稳,因为这些都是有明确输入输出的确定性任务。但"logcat 缓冲区会丢中间一段"这件事,不在它的先验里,也不在任何一份需求文档里。它只在我把真实日志贴进去之后才浮现。

同理,nickname 变 ickname 那个 bug,逻辑上完全正确,只是前提(有逗号分隔)不成立。

所以我把流程调成了这样:

真实数据先于构造数据。 第一版构造的样例全都是合法 JSON,测不出任何问题。真实日志一进来,三个 bug 同时暴露。测试用例应该从真实数据里长出来,而不是先想好再构造。

让工具说人话。 状态栏会显示"日志行 4 / 条目 1 / 自动拼接 10 次 / 日志缺失 1 处已跳过"。这行信息救了我好几次,因为"拼接 10 次"和"缺失 1 处"本身就是异常信号,比结果对不对更早暴露问题。如果工具静默处理,我永远不会知道它在猜。

测试脱离 DOM 跑。 parser 全部是纯函数,在 IIFE 末尾挂一个导出对象,测试脚本 mock 最小 document 之后直接 eval 页面里的 script。这样测的就是线上那份代码,不是复制品。断言从 42 项长到 86 项,每次改解析逻辑都先跑一遍。

十一、AI 真正改变的是"值不值得写"

前面几节说的都是 AI 做不了什么。但它确实改变了一件事,只是不在代码层面。

以前给自己写工具是不划算的。为了每天省五十分钟,要花两天写代码,将近一个月才回本。中途需求一变还得改,改完再测。所以大部分人宁愿忍着,忍着忍着一年就过去了。

AI 把第一版的门槛压到了几十分钟。当天回本,"给自己写个工具"这件事才重新成立。

这个变化对 Android 开发尤其明显,因为日常要碰的碎工具太多了:logcat 过滤、dp 和 px 换算、颜色值转换、JSON 对比、埋点参数拼装、崩溃堆栈还原。每一个都不值得立项排期,但每一个都在偷时间。

这些工具的共同点是很垂直。通用工具不会做,因为用户盘子太小,做出来收不回成本;但对你来说,它每天都要用。

现在它们可以各花半小时做出来,扔进一个文件夹,双击就能用。不用上架,不用过审,不用管服务器。

判断标准也简单:这件事我一周要做超过五次吗。 是,就值得花半小时;不是,继续忍着。

这也是为什么这类工具用 AI 写特别合适。它们没有复杂架构,没有并发,不需要考虑扩展性,只需要在一个很窄的场景里把边界处理干净。AI 擅长写这种确定性强的代码,剩下的事交给真实数据。

十二、为什么是单文件、零依赖

这个小工具我没有做成 npm 包,也没做构建流程。一个 HTML 文件,双击就开。

理由很实际:这类工具的生命周期取决于还能不能打开。带构建链的东西,两年后 npm install 大概率失败,Node 版本、依赖冲突、锁文件,随便一个就能劝退。单文件没有这个问题,十年后浏览器还在,它就能跑。

离线也是同一个考虑。数据不出本机,不用为"这个页面上的第三方脚本在做什么"操心。

开源的意义在我看来不是"免费给你用",而是这类小工具靠维护活着。我不用了、不维护了,别人还能 fork 走继续改,它就不会死。

如果你只想拿走解析逻辑:parseLog、parseLoose、typeOf、genKotlin 这几个函数没有一行 DOM 依赖,复制出去就能跑,搬到 CLI(adb logcat | logcat2kt)或者 Android Studio 插件里都行。

十三、它的边界

没有 .proto 就没有真实字段名。二进制解码只能给 field_1、field_2,这是信息论层面的上限,不是实现问题。

中间丢段只能部分恢复。落在缺失段里的字段永远拿不回来,状态栏会显示丢了几处,但不会假装完整。

gzip 或加密的 body 无能为力。不过加密的数据在拦截器那层就解完了,能进日志的已经是明文,所以这条其实很少碰到。

混合类型数组会退化成 List<Any>。[1, "a", null] 这种推不出统一类型,也不该硬猜。

枚举也推不出来。code: 0 到底是枚举还是状态码,工具不知道。

最后

如果接口一周只碰两次,手搓比配工具快,那用什么工具都行。

我写它是因为联调期一天要碰十几次,而且每次都要在浏览器和 IDE 之间来回切。省下来的时间是实的,一天五十分钟,一个迭代就是一天。

链条其实很短:拦截器解密之后,数据只有 logcat 这一个出口。这个出口被 4KB 卡着,被环形缓冲区啃掉中间一段,还要手工删前缀。工具只是把这一段修好。

下次再被日志坑到,改的还是 parser。

体验地址 rendonglugit.github.io/logcat2kt/

项目地址 github.com/rendonglugi...

相关推荐
booboosui1 小时前
219 个还是 0 个:读不到微信界面时,我只加不删
android
JMchen1 小时前
【Jetpack 内核实战 08 】:组件整合——DataStore + Room 构建离线优先架构
架构·kotlin·android jetpack
Android打工仔1 小时前
Compose 中何时该用 padding,何时该用 offset
android·android jetpack
二流小码农1 小时前
HarmonyOS开发助手,0代码让元服务从开发到上架
android·ios·harmonyos
limit00752 小时前
三维 WebGIS 工具选型:加载、样式、分析、交付,一套界面能做完什么
android·前端·web3·安卓·webgl
码哥字节2 小时前
大厂程序员写 AI 编程半年来,我的 3 次认知推翻与 1 次生存顿悟
ai编程·claude
CVNot2 小时前
AI让程序员失去了什么?
ai编程
没事写点啥2 小时前
WeKnora 回答溯源
前端·后端·ai编程
骑着蜗牛撵大象3273 小时前
让 Codex 值夜班:把告警排查与巡检接成可回滚的自动化链路
android·java·运维·ai·自动化·codex·智能体