From fc4ee14b6f0497a67e5994b4e9c52148da1fe549 Mon Sep 17 00:00:00 2001 From: Zane <1638644816@qq.com> Date: Thu, 20 Aug 2026 01:34:13 +0800 Subject: [PATCH] =?UTF-8?q?InBox:=20=E7=9F=A5=E4=B9=8E=E5=9B=9E=E7=AD=94?= =?UTF-8?q?=20-=20Unity=20Profiler=20Agent=20=E7=9C=9F=E6=9C=BA=E6=80=A7?= =?UTF-8?q?=E8=83=BD=E9=87=87=E9=9B=86=E5=B7=A5=E5=85=B7=E9=80=86=E5=90=91?= =?UTF-8?q?=E5=A4=8D=E7=9B=98=20(=E5=B0=B9=E9=B9=8F=E5=B8=85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...erAgent_真机性能采集工具逆向复盘_尹鹏帅.md | 368 ++++++++++++++++++ 1 file changed, 368 insertions(+) create mode 100644 InBox/zhihu_UnityProfilerAgent_真机性能采集工具逆向复盘_尹鹏帅.md diff --git a/InBox/zhihu_UnityProfilerAgent_真机性能采集工具逆向复盘_尹鹏帅.md b/InBox/zhihu_UnityProfilerAgent_真机性能采集工具逆向复盘_尹鹏帅.md new file mode 100644 index 0000000..111aff4 --- /dev/null +++ b/InBox/zhihu_UnityProfilerAgent_真机性能采集工具逆向复盘_尹鹏帅.md @@ -0,0 +1,368 @@ +--- +source: 知乎回答 +url: https://www.zhihu.com/question/28824284/answer/2073491183658067620 +created: 2026-08-20 +author: 尹鹏帅 +tags: [zhihu, unity, 性能优化, 逆向工程, AI编程] +--- + +# 如何正确高效的查找游戏「卡顿」原因?——Unity Profiler Agent 逆向复盘 + +> 来源:[知乎回答](https://www.zhihu.com/question/28824284/answer/2073491183658067620) · 作者:尹鹏帅 + +一个独立运行于 Unity Editor 之外的真机性能采集工具的诞生记:从 adb forward 到 PD3U 数据流,从逐位爆破调用栈开关到给 LLM Agent 装上 MCP 义肢。全文含大量二进制协议细节与踩坑实录,建议配咖啡阅读。 + +## 一、缘起:一个不算梦想的"梦想" + +先交代一下背景。因为工作内容的原因,我一直没有深入研究过 Unity 的性能优化——即使偶尔涉及到,也是浅尝辄止:打开 Profiler 看一眼,"哦,这里卡了",截个图发到群里,然后就没有然后了。多年下来,技能树这一块始终是灰色的,无所成长。 + +但时代变了。随着常规开发任务逐渐被 AI 承担,我自然也想着让 AI 来帮我分析性能,也算随大潮重新站到起跑线上了——我有 GPT 和 Claude,我怕谁! + +理想很丰满,现实却有点骨感。Unity Editor 的 Profiler 是一个彻头彻尾的 GUI 程序,它和 AI Agent 的配合方式只有一种:**截图**。你截一张 Profiler 的图丢给大模型,它眯着眼睛(好吧它没有眼睛)辨认半天,告诉你"Rendering 那一栏好像有点高"。我们来算一笔账:一张 Profiler 全界面截图,分辨率稍微高一点就是一两千 token;一次像样的性能分析,怎么也得看 CPU 时间轴、Hierarchy 展开、Memory 明细、Rendering 计数器好几张图;再追问两轮,上下文里就堆了上万 token 的图片,模型还只能从像素里"猜"数字。token 吃不消不说,这种"看图说话"的分析精度,实在对不起"性能优化"这四个严谨的字。更要命的是,截图是有损压缩的信息——Hierarchy 里折叠的节点、表格里滚出屏幕的行,截图里根本没有。 + +与此同时,我心里一直有个埋藏已久的"梦想":**Editor Profiler 到底是怎么和手机通信的?那些数据又是如何组织的?** 每次用 USB 连上手机、在 Editor 里点下 Record,看着帧曲线跳动,我都会好奇这根数据线里跑的到底是什么字节。Unity 从不公开这些协议,网上能搜到的只言片语也大多过时或者以讹传讹。 + +于是两件事合流了:我决定开发一个 **Unity Profiler Agent**——一个独立于 Unity Editor 的性能分析工具,直接和手机上的 Unity Player 对话,拿到和 Editor Profiler 同源的原始数据,再把这些结构化数据喂给 AI Agent,让 AI 性能分析的效率最大化。截图是模拟信号,协议数据才是数字信号,我要给 AI 接上数字信号。 + +当然,这些协议都是 Unity 闭源的,没有任何公开文档,目标很模糊,只能一点点"PUA" AI 去搞了。所谓 PUA,就是不断给它压担子、提要求、否定它的错误答案、逼它设计实验验证——后面会看到,这套玩法贯穿了整个项目。 + +这件事,本来做也可,不做也可。中间也几度放弃过,所幸大致完成了目标,也算有所收获。这篇文章就是整个开发过程的复盘,关键技术点一个不落,坑也一个不留地交代。文中所有协议细节——魔数、位语义、字节布局——都来自对 Unity 2022.3 的真机实测,代码注释里保留了完整的"证据链";如果你拿着别的 Unity 版本对不上号,那很正常,逆向结论从来都是有保质期的。 + +## 二、技术选型与逆向方法论 + +| 层 | 选型 | +| --- | --- | +| 后端 | Go 1.25 + Wails v3.0.0-beta.8 | +| 前端 | React 18 + TypeScript + Vite | +| 图标 | lucide-react | +| 虚拟化 | @tanstack/react-virtual(十万级帧数据流畅滚动) | +| LLM 接入 | ACP(Agent Client Protocol)via acp-go-sdk | + +选型的逻辑,一条条说: + +**1. 协议解析是核心,Go 是舒适区。** 二进制流解析、goroutine 读写循环、环形缓冲、channel 事件泵,这些都是 Go 的主场,`encoding/binary` 一把梭。更重要的是测试——`go test` 秒级跑完全部用例,这个反馈速度对 AI 协作至关重要,后面会反复强调。 + +**2. 要桌面应用,不要命令行。** 性能数据需要堆叠图、虚拟化表格、层级树、时间轴,命令行展示不了。那为什么不直接做 Unity Editor 插件?因为初衷就是"脱离 Editor"——Editor 又重又慢,而且我要的恰恰是搞清楚 Editor 底下发生了什么,寄生在它体内还怎么观察它。Electron 太重,Wails 用 Go 做后端、系统 WebView 做前端,产物就是一个几 MB 的 exe,正合适。至于用 v3 的 beta 版有没有风险?有,但 beta.8 已经相当稳,而且 v3 的服务绑定和事件机制比 v2 干净不少,值得冒险。 + +**3. LLM 接入走 ACP,而不是自己调 API。** 这是最容易被质疑的一个决策:直接调 OpenAI/Claude 的 API 不是更简单吗?简单,但我没有 API——我手里只有 Codex 和 Cursor 的订阅,一个 API Key 都没有。这听起来是限制,想清楚之后反而成了最正确的路线:订阅制 Agent 本来就是我每天都在用、已经付过费的算力,自己调 API 不仅要额外掏钱,还得管 Key、管上下文窗口、管工具调用循环——等于自己重写一个 Agent。而 ACP(Agent Client Protocol,Zed 编辑器主导的开放协议)让我可以直接复用这些现成的 Agent:Gemini CLI 加 `--experimental-acp`,Claude Code 有社区适配器 `claude-code-acp`,Codex 也能以 app-server 模式接入。我零配置成本、零维护成本,Agent 能力还随官方升级自动变强。站在协议的肩膀上,比站在 API 的泥地里好——何况我根本没有 API 的泥地可站。 + +**4. 方向反过来行不行?不行,而且是刻意不行。** 也有人问:为什么不把 UnityPerfAgent 做成一个 MCP Server,让 Codex、Cursor 这些现成的 Agent 客户端来接?这样连聊天 UI 都不用写了。我认真考虑过,最后否决了,理由有两个。一是**不想管理繁多的 MCP**:每换一个 Agent 客户端就要配一遍 MCP,配置散落各处,版本升级、路径变更、端口冲突全是维护负担,工具没做成先成了 MCP 管理员。二是**环境隔离**:Codex 和 Cursor 里已经挂着一堆别的 MCP 和 Skill,真把性能数据工具挂进去,Agent 每次分析都要在一大堆工具描述里挑挑拣拣,上下文被污染、工具被误调,分析质量反而下降。所以最终的选择是反过来的——**在本工具中内嵌一个干净的 Agent 环境**:Agent 由工具拉起,能看到的 MCP 只有内置的那一个,系统提示词里只有性能优化这一件事。专一,才能专业。 + +![Image 1](https://picx.zhimg.com/50/v2-32abcfb35c470065e71e518818f80546_720w.jpg?source=2c26e567) + +### 2.1 逆向方法论:没有文档,就制造证据 + +在正式进入技术细节之前,先把这次逆向的工作方法交代清楚,因为后面每一章都在用它。 + +面对闭源协议,我们的标准动作是四步循环:**抓包 → 比对 → 假设 → 实验**。 + +抓包是地基。让 AI 写了一个 TCP 中间人:架在 adb forward 的本机端口和真实连接之间,双向转发的同时把字节流原样 dump 成文件。然后用 Editor 连真机,做一组"标准动作"——打开 Profiler 窗口、勾选/取消 Call Stacks、断开重连——每个动作对应一份抓包文件。动作之间要隔开几秒,方便按时间戳把报文和操作对上号。 + +比对是放大镜。两份抓包做 diff:勾选 Call Stacks 前后,Editor 多发了哪条报文?报文体的哪几个字节变了?变化往往就集中在一个 u32 上,剩下的就是语义猜测。假设要大胆,实验要小心——每个假设都设计成"只改一个变量"的真机实验,改一个位、看一次行为、记一条结论。AI 在这个循环里的角色是"实验仪器制造商":抓包工具、diff 工具、假 Player、测试用例,全是它写的;而"下一步验什么"永远由人拍板。 + +还有一条原则贯穿始终:**所有结论必须落成代码注释里的"实测记录"**。你会在代码里看到大量"实测自 Unity 2022.3 Editor"、"真机逐位实测"、"实测帧率直接减半"这样的注释。逆向项目的代码注释不是解释代码的,是保存证据的——三个月后没人记得某个魔数是哪来的,注释里的实测记录就是唯一的考古层。 + +## 三、第一关:USB 数据线里的秘密 + +### 3.1 adb forward 的正确姿势 + +要让电脑和手机上的 Unity Player 通信,第一步是搞清楚通道。很多人(包括一开始的 AI)以为 Unity Profiler 走的是设备 `tcp:55000` 直连——这是流传最广的以讹传讹。55000 确实是 Unity 早期远程 profiling 用过的端口,但 Editor 对 Android USB 设备的真实做法是: + +`adb forward tcp:<本机端口> localabstract:Unity-{应用包名}` + +`localabstract:` 指向的是手机上一个 Unix Domain Socket,名字是 `Unity-` 加包名。这是 Unity Player 在 Development Build 下启动时创建的监听套接字,USB 调试开启后由 adbd 桥接出来。所以工具的第一块代码 `internal/adbforward` 要做三件事: + +1. 执行 `adb devices -l` 枚举设备,只认状态为 `device` 的行(`unauthorized`、`offline` 统统跳过),机型从 `model:xxx` 字段里抠出来、下划线换成空格; +2. 解析手机的 `/proc/net/unix`,找到 `Unity-{bundleId}` 这个 socket 名——包名不一定等于我以为的那个,所以还要结合前台应用信息兜底; +3. 执行 `adb forward tcp:0 localabstract:Unity-{包名}`(`tcp:0` 让系统自动分配本机端口),再连 `127.0.0.1:<分配到的端口>`。 + +这里容易和另一个知识点打架:Unity Player 在设备上确实有一个固定的 TCP 监听端口范围(55000–55016,Player 启动时在这个闭区间里挑一个空闲端口,默认 55000),Editor 的 Wi-Fi 直连(Direct Connection)扫的就是它。但请注意,那是**设备侧**的端口,而且属于 Wi-Fi 通道;USB 通道走的是上面的 Unix 抽象套接字,Player 那一端根本没有端口的概念。而 `tcp:0` 里的 0 是**本机侧**端口——它只是 adb 在本机开的转发隧道入口,让 adb 自动分配一个空闲端口并打印出来即可,Player 永远看不到它,自然也不受 55000–55016 的约束。两个端口,一个在手机上、一个在电脑上,中间隔着 adb 这条隧道,互不相关。 + +![Image 2](https://picx.zhimg.com/50/v2-806dc67ce89b555841fcdafab636b3f6_720w.jpg?source=2c26e567) + +这种脏活累活 AI 写得又快又好,但**解析规则必须人来定**——AI 不知道你的测试机上 `adb devices` 输出到底长什么样,`/proc/net/unix` 里到底有几个候选 socket,只有实测才知道。这是整个项目的第一个方法论结论:**AI 负责把规则写成代码,人负责用真机把规则逼出来。** + +### 3.2 PlayerConnection:一切协议之上的协议 + +通道建好后,连上本机端口,就进入了 Unity 的 **PlayerConnection** 世界。这是 Editor 和 Player 之间的通用消息通道,Profiler 只是跑在它上面的一种业务(脚本调试、Memory Profiler 等也走这里)。 + +逆向的第一步永远是抓包。让 AI 写个 TCP 中间人脚本,架在 adb forward 和真实连接之间,用 Editor 连一次真机,把双向字节流完整 dump 下来。对着十六进制发呆半小时后,好消息出现了:这个协议的报文头非常规整—— + +`magic(4) = 0x67A54E8F | messageId(16) | length(4) | body(length)` + +4 字节魔数 + 16 字节消息 ID + 4 字节小端长度 + 负载。那个 16 字节的消息 ID 是一组固定的 GUID 常量,Editor 和 Player 用它区分消息种类。从抓包里一条条抠出来,写进代码: + +* Player → Editor:`MsgIDPlayerInfo`(引擎信息)、`MsgIDAnnounce`(宣告文本)、`MsgIDProfilerData`(Profiler 原始数据流); +* Editor → Player:`MsgIDProfilerSetup`(标志位)、`MsgIDProfilerAreas`(采集区域掩码)、`MsgIDProfilerCategories`(分类名单)。 + +这些 GUID 没有任何规律,就是硬编码常量。逆向工程的魅力和枯燥都在这里:**没有文档,只有证据。** 每条 GUID 背后都是一次"抓包—比对—确认"的循环。 + +![Image 3](https://pica.zhimg.com/50/v2-f150f80d6cf408a28ac1ae994cf7d5d6_720w.jpg?source=2c26e567) + +### 3.3 伪装成 Editor + +连上之后,Player 会主动推送两条关键消息。 + +**引擎信息**(`MsgIDPlayerInfo`,31 字节定长):协议版本(实测为 2)、能力位(是否支持 Deep Profile、是否支持托管调用栈)、平台枚举(Android 是 11)、引擎版本号。版本号的编码很有意思,是 5 个 u32:major、minor、patch、releaseType、releaseNumber,其中 releaseType 是枚举,`a/b/f/p/x` 对应 `0/1/2/3/4`——所以 `2022.3.47f1` 就是 `(2022, 3, 47, 2, 1)`。第一次把这个位运算还原成版本字符串的时候,有种破译密码的快感。 + +**宣告文本**(`MsgIDAnnounce`):一段 `[IP] ... [ProjectName] ...` 的人类可读文本,里面有 `Flags` 和 `[Debug]` 标记。这里要纠正一个广泛流传的误解:**`[Debug]` 只表示脚本调试(managed debugger),和能不能 Attach Profiler 无关;`Flags` 的 bit1 才是 SupportsProfile。** 所以 Development Build 即使 `[Debug] 0` 也可以 Attach Profiler。这个结论是真机逐次试出来的,网上很多教程把两者混为一谈。 + +而我们要做的,是以 `UnityEditor` 的身份完成握手。抓包显示,Editor 连上后**立即**下发一串报文,根本不等 Player 打招呼: + +``` +ProfilerSetup(flags=0) // 先清零调用栈开关 +ProfilerAreas(mask=0x7FFD) // 采集区域掩码:除 GPU 位以外全开 +ProfilerCategories(...) // 9 个分类名单:Video, Physics2D, Physics, Render, + // Virtual Texturing, Audio, Memory, Lighting, Gui +Announce("UnityEditor...") // 自报家门 +``` + +`0x7FFD` 这个掩码是实测值——Editor 打开 Profiler 窗口时发的就是它。照着发,Player 就把你当成 Editor,开始按帧推送 Profiler 数据。我们的工具自报身份为 `UnityEditor.Profiler(主机名)`,前缀与 Editor 一致,方便在 Player 侧日志里区分连接来源。第一帧数据解出来的那一刻,成就感不亚于第一次 Hello World。 + +### 3.4 假连接:Player 的独占槽位 + +这里必须记录一个工程大坑:**Player 同时只服务一个 Profiler 客户端。** 如果 Unity Editor 的 Profiler 还连着手机,或者上次进程残留了没断开的连接,你的新连接就会一直排在 Player 的等待队列里——TCP 是通的,开启报文也发出去了,但 Player 一个字节都不回。 + +这种"假连接"最容易让人怀疑人生:代码明明没错,网络明明通的,数据就是没有。排查了一下午才确认是槽位被占。所以代码里专门定义了 `ErrPlayerSilent`:握手 5 秒无响应就报错,并明确提示"Player 同时只服务一个 Profiler 客户端,请确认 Unity Editor 的 Profiler 已断开、且本工具没有残留连接;仍不行就重启游戏进程"。好的错误提示,是逆向工具对使用者最大的温柔——你永远可以相信,下一个踩坑的人就是三个月后的你自己。 + +## 四、PD3U:Profiler 数据流的解剖 + +握手完成后,真正的主菜上桌:`MsgIDProfilerData` 消息里装的 Profiler 原始数据流。这是整个项目最硬核的部分,8 月 14 日一整天的两个提交都耗在这里。 + +### 4.1 流头与分块 + +数据流的第一包是 56 字节流头: + +`magic "PD3U"(4) | 保留(4) | formatVersion(4) | ... | mainThreadID(8) | 引擎版本(5×u32)` + +`PD3U` 四个字符(小端读出 `0x55334450`)大概是 "Profiler Data 3 Unity" 的缩写。`formatVersion` 是个日期戳,Unity 2022 是 `0x20220328`——这个值后面会救我们一命,先记住它。`mainThreadID` 指出哪条线程流承载主线程的逐帧事件,这个字段至关重要,4.4 节细说。 + +流头之后是分块(chunk),结构为: + +``` +头(20):magic 0xB10C7EAD | seq(4) | threadIdLow(4) | threadIdHigh(4) | size(4) +负载(size) +尾(8):seq+1(4) | magic 0xB10CF007 +``` + +注意头尾两个魔数——你品,你细品:`0xB10C7EAD` 是 "BLOCK READ",`0xB10CF007` 是 "BLOCK FOOT"。Unity 的工程师在二进制协议里埋了 leetspeak 彩蛋,逆向出来的一瞬间我笑了半天。这种小发现是逆向工程里为数不多的乐趣,也是确认"我理解对了"的路标——彩蛋能对上,说明字节布局八九不离十。 + +分块设计有两个要点: + +1. **按线程多路复用。** threadId 是 `high<<32|low` 拼出的 64 位值,与线程元数据里的 ID 一致;`threadId = -1` 的是全局元数据流。主线程、渲染线程、Job 工作线程各自一条流,在同一个 TCP 连接里交织到达。 +2. **记录可跨块。** 同一线程的负载要按 seq 顺序拼成连续字节流才能解析,一条记录可能前半截在这个块、后半截在下一个块。所以解析器必须先缓冲、再消费,消费不完的尾部留给下一块。seq 校验是唯一的完整性保障:序号断裂就意味着中间丢了字节,这条流从此不可信。 + +![Image 4](https://picx.zhimg.com/50/v2-7234c8e1a35e09b47adc2feb2ef2b01d_720w.jpg?source=2c26e567) + +### 4.2 解析器的骨架:cursor 与 panic/recover + +逐字节解析这种嵌套变长结构,代码很容易写成"每读一个字段检查一次长度"的防御性屎山。这里用了一个干净的模式:定义一个带边界检查的 `cursor` 小端读取器,`take(n)` 越界时直接 `panic(errShortRecord)`;每条记录的解析函数外层用 `recover` 兜住——数据不足就回滚游标、等下一块,其他错误才视为失步。这样记录解析代码可以写成线性的"读、读、读",一个边界检查都不用写,可读性和正确性双赢。Go 的 panic/recover 用在解析器这种"深度递归 + 统一出口"的场景,是教科书级的合理用法。 + +### 4.3 元数据流与事件流 + +拼接好的字节流里是两种记录,各自独立编号。 + +**元数据流**(threadId = -1)负责建立查询表:marker 定义(ID → 名字、分类、标志位、元数据个数)、分类定义(ID → "Scripts"/"GC" 这类名字)、线程定义(ID → 线程名)、以及——开启 Call Stacks 后才会出现的——**托管方法符号表**。我们项目实测 Unity 2022 手机包的方法表约 33 万条,Player 会一次性全量下发,用来把调用栈里的代码地址还原成方法名。 + +**事件流**是逐帧的采样事件:`0x22` 帧边界、`0x24/0x27/0x2A/0x2B` 四种 Begin 采样(普通/带扩展/带元数据/带对象上下文)、`0x25` End 采样、`0x2C` 计数器、`0x03` 调用栈、`0x38` 线程时钟戳……Begin/End 构成一棵采样树,计数器则是 Draw Calls、内存分项这些数值。 + +然后是这个协议最恶心的设计(或者说,最二进制协议的设计):**记录类型会撞号。** 事件流里 `0x01` 到 `0x0F` 这些类型号,同时也表示"随后跟着 N 个元数据取值"——比如 Begin(GC.Alloc) 之后会跟一条 `0x01` 记录,里面装着这次分配的字节数。于是 `0x03` 既是"3 个取值"又是调用栈记录,`0x04` 既是"4 个取值"又是 LegacyStats 定长统计。怎么消歧?看上下文:调用栈记录的第二个字节固定为 0,而取值类型枚举里 0 是 None、不会出现。代码里就是靠 `peek()` 偷看下一个字节来分流的。逆向这种协议,本质上是和引擎作者的编码习惯博弈——他偷懒复用了类型号空间,你就得替他擦屁股。 + +### 4.4 帧边界:只有主线程说了算 + +每条线程流里都有帧记录(`0x22`),但**帧边界只能由主线程流划定**。为什么?因为渲染统计(Draw Calls、Triangles 这些)是渲染线程上报的,如果每条线程都能切帧,一帧的数据会被切成好几段,UI 上就没法看了。所以解码器的分工是: + +* 主线程流:决定帧边界、根采样时长、各 ProfilerCategory 的自耗时; +* 其他所有线程流:只贡献计数器,按"当前帧"归集。渲染线程滞后主线程不足一帧,这么归集和 Editor 的显示行为一致。 + +![Image 5](https://pic1.zhimg.com/50/v2-7ba1c0ae7ecfc4f8c71473c28ceaccdb_720w.jpg?source=2c26e567) + +对应的容错策略也是分级的:非主线程流 seq 断裂(失步),只放弃那一条流——它无非少报几个计数器,逐帧指标不受影响;但主线程流或元数据流一旦失步,缓冲区已丢弃、字节流无法重新对齐,就是致命错误。此时宁可主动断开并报错,也不能让界面显示"已连接"却永远停在同一帧,还白占着 Player 唯一的 Profiler 槽位。**Fail loudly, not silently**——工具软件的基本素养。 + +### 4.5 跨帧的幽灵采样 + +还有一个只有实测才会发现的坑:Unity 的 `Main Thread`、`PlayerLoop` 这类根采样是**跨帧保持打开**的——Begin 在第 N 帧,End 在第 N+1 帧。如果只在栈弹空时才记录节点,那么卡顿帧(恰恰是你最想分析的帧!)的 Hierarchy 就是空的,只剩分类自耗时,我点进去一脸懵。 + +解法是在帧边界做一次"快照":把仍未闭合的采样链原样写入当前帧的 Hierarchy(每层的时长截断到帧边界,自耗时要扣掉已闭合子节点和未闭合内层链),然后把栈上所有 entry 的起始时间重置为帧末、清空子节点和元数据,让它们在新的一帧里继续累积。另外必须给快照深度加上限(64 层)——卡顿帧上未配对的 Begin 可能堆到上万层,全量展开会撑爆递归和 UI。这种防御性限制在逆向项目里不是过度设计,是保命:你永远不知道黑盒对面会吐出什么。 + +![Image 6](https://pic1.zhimg.com/50/v2-726845fec16d30a803b065d0c5f1dfb1_720w.jpg?source=2c26e567) + +### 4.6 版本校验:数据流版本才是真相 + +项目要求只支持 Unity 2022(AGENTS.md 里写死的规矩)。但握手时怎么判断?看 Player 自报的版本号? + +实测给了我们一记响亮的耳光:测试用的游戏包是内部改装引擎(基于 Unity 2022.3.47f1),它自报的版本号居然是 `2018.4.12f1`!如果按版本号拦截,这个包会被当场误判出局。 + +但数据不会撒谎:它的 Profiler 数据流头里,`formatVersion` 依然是 `0x20220328`。所以最终的判定逻辑是——**优先看数据流格式版本,数据流版本未知时才退回看版本号**。改装引擎可以随手改版本字符串,但只要它没动 Profiler 子系统,数据流格式就还是 2022 的格式,就能正常解析。这个经验值得加粗:**逆向项目里,行为证据永远优先于自我声明。** 后面在错误处理、能力探测上,这条原则反复应验。 + +## 五、逐位爆破:Call Stacks 开关之谜 + +8 月 14 日下午到晚上的两个提交之间,穿插着一场精彩的"位运算探案",值得单独一章。 + +`MsgIDProfilerSetup` 的报文体是一个 u32 标志位。它的语义是什么?直觉(以及 AI 的第一反应)是:bit0 = Deep Profile,bit1 = Call Stacks。多合理的猜测啊,Editor 工具栏上就这两个开关。然后真机实测直接打脸:按这个映射勾选 Call Stacks 后,我想看的 GC.Alloc 调用栈**一条都没有**,倒是冒出来一堆原生分配的栈。 + +没办法,只能逐位爆破:一次只置一个位,连真机,看行为变化。32 个位,挨个试。最终结论写在代码注释里,堪称本项目的"镇库之宝": + +* **bit0**:采集**托管分配**(GC.Alloc)的调用栈——这才是 Editor 工具栏 "Call Stacks" 勾选后我真正要看的东西; +* **bit1**:采集引擎**原生分配**(UnsafeUtility.Malloc 等)的调用栈,额外开销很小,可以顺带打开; +* **bit3**:采集原生**分配器**(Native.Alloc/Dealloc/Realloc)的调用栈——实测**帧率直接减半**,采集开销大到影响被测对象,坚决不随 Call Stacks 一起下发; +* 其余位:无任何可观测效果。 + +![Image 7](https://pica.zhimg.com/50/v2-32b13dc18d7a76f19bde83ad5c3450a2_720w.jpg?source=2c26e567) + +那 Deep Profile 呢?整个 u32 逐位测完,**没有一位**能让采样树变深。结论只能写在注释里:Deep Profile 应是另一条尚未逆向出的报文——它需要 Editor 侧插桩配合,Player 光收一个标志位是变不出完整调用树的。之前把 Deep Profile 塞进 bit0 的做法,反而悄悄改掉了调用栈模式,属于典型的"以为懂了其实没懂"。最后工具栏里的 Deep Profile 按钮直接禁用,并在 UI 上注明"需 Editor 插桩,独立工具中禁用"。诚实面对未知,比假装支持重要。 + +## 六、测试策略:养一只假 Player + +逆向项目最大的痛苦是:没有真机就什么都验证不了,而真机测试又慢又麻烦——插线、开游戏、点连接、等数据,一轮下来五分钟,改一行代码验证一次,心态会崩。 + +解法是 8 月 14 日就建立的,也是整个项目最正确的投资:**在单测里养一只假 Player**(`internal/unityprofiler/player.go`)。 + +这只假 Player 不是简单的 mock,而是一个完整的协议对端实现:它走真实的 PlayerConnection 报文、发真实的 PD3U 流头和分块、按线程多路复用、发 Begin/End/计数器/调用栈记录,甚至故意把 ChunkSize 设得很小,专门覆盖"记录跨块重组"的路径。它还能模拟各种真机幺蛾子: + +* `HeaderBeforeAnnounce`:模拟"连接时 Player 已经在采集"——数据流头先于宣告到达。这种情况握手期间收到的数据流报文必须原样留存,丢掉流头就再也拿不到主线程 ID,整个会话报废; +* `paused`:模拟 Android 弹系统对话框导致的 OnApplicationPause——Player 暂停上报但连接不断,对话框关掉后同一条连接继续吐帧。所以读取循环的超时策略是"继续等"而不是"断开",10 秒一轮,永不放弃; +* 渲染统计单独走渲染线程上报,和真机一致,逼解码器正确处理多线程归集。 + +![Image 8](https://pic1.zhimg.com/50/v2-73d6c593f6858e65eba78581d354c908_720w.jpg?source=2c26e567) + +因为假 Player 和真机走的是**同一套解码路径**,单测置信度非常高。8 月 18 日那次 +1676/-835 的大重构能一次改完不翻车,全靠这张安全网。 + +对 AI 协作来说,这更是生死攸关的基础设施。**有了秒级运行的测试闭环,AI 的每次改动都能立刻验证**,"PUA AI"才有抓手——它给出解析逻辑,跑测试,红了就打回去重写,绿了就进入下一轮审查。没有这个闭环,它给你编一段看似合理的二进制解析,你只能瞪着眼睛猜对错,那才是真正的灾难。前端同理:vitest + testing-library,帧格式化、层级布局、时间轴合并这些纯函数全部有单测。项目从头到尾保持 `go test ./...` 和 `npm test` 全绿,这是 AGENTS.md 里写死的规矩,也是对 AI 的紧箍咒。 + +## 七、性能设计:采集工具自己不能成为性能问题 + +性能分析工具如果自己卡顿,那就是行为艺术了。几个关键的性能决策: + +![Image 9](https://pic1.zhimg.com/50/v2-936c7c77a9c359cbcc6acef6839d6445_720w.jpg?source=2c26e567) + +**1. 定长二进制帧。** 帧数据用固定长度二进制编码,单帧负载 164 字节(v2,兼容 v1 的 68 字节)。字段按 Unity Profiler 模块分组:基础(FPS、CPU/GPU/MainThread/Render 耗时、GC Alloc)、CPU Usage 八分类耗时(Rendering/Scripts/Physics/Animation/GC/VSync/UI/Others)、Memory 九项细分(GC/Gfx/Audio/Video/Profiler/System 的 Used/Reserved)、Rendering/Audio/Physics/UI 计数器。定长编码意味着零反射、零序列化开销、零 GC 压力,`binary.LittleEndian` 按偏移量直写直读。 + +**2. 环形缓冲。** 会话存储是固定容量环形缓冲,默认 10.8 万帧——约 30 分钟 @60fps。追加 O(1),范围读取 O(n),内存有硬上界,长时间采集无所畏惧。原始数据流单独设 1 GiB 上限,导出存档时再把流头补回去。 + +**3. 双重节流。** 后端事件按 250ms 节流推送,前端按 500ms 节流刷新。60fps 的帧数据不需要 60fps 地刷 UI——人眼看不出来,但 React 会被活活累死。 + +**4. 虚拟化 + 懒加载。** 帧表格用 @tanstack/react-virtual 全虚拟化渲染,十万行数据流畅滚动;数据按 200 帧分块从后端懒加载,不一次灌给前端。 + +**5. Canvas 直绘。** Profiler 的堆叠时间轴不用任何图表库,Canvas 直接画堆叠柱。图表库在"每帧一根柱子、十万帧"的场景下全是开销,直绘几十行代码搞定,还能精确控制配色对齐 Unity 的分类色。 + +**6. 并发纪律:锁里绝不回调。** 采集会话的读取循环要把解码出的帧回调给服务层,而服务层会在自己的锁里查询会话状态。如果读取循环持有会话锁时回调,就形成"会话锁 → 服务层锁"和"服务层锁 → 会话锁"两条相反路径,撞上了就是整个界面卡死、连日志都不再动。解法很朴素:解码出的帧先攒在局部切片里,放开会话锁之后再统一回调。这条纪律写在 `readLoop` 的注释里,因为这种死锁在测试里很难复现,只能靠纪律防住。并发代码的坑,三分靠工具,七分靠规矩。 + +## 八、UI:像素级致敬 Unity Profiler + +8 月 15 日到 17 日的几个提交,主题是把 UI 做到"对齐 Unity Profiler 2022"——我希望打开工具就有肌肉记忆般的熟悉感: + +* 工具栏:Record 状态、Deep Profile / Call Stacks(前者因第五章的原因禁用并注明)、Clear、帧前进/后退、Current 跳转、当前帧耗时与 FPS; +* 模块开关:CPU Usage、GPU Usage、Rendering、Memory、Audio、Video、Physics、UI; +* 堆叠时间轴:点击选帧,录制时自动跟随最新帧; +* 详情面板:CPU Hierarchy / Timeline、Memory Simple、各模块计数器表。 + +### 8.1 前后端之间:Wails 服务层与事件泵 + +顺带说说 Wails 这层胶水的设计,它决定了整个应用的"手感"。后端 `services` 包暴露一个 `PerfService`,Wails 自动生成 TypeScript 绑定,前端像调本地函数一样调 Go 方法——连接设备、查询帧范围、取单帧 Hierarchy、启动 Agent 会话,全是类型安全的跨语言调用。反向的数据推送走事件机制:采集循环产出的帧经服务层按 250ms 节流后,以事件广播给前端;前端再按 500ms 节流刷新视图。两层节流之间是帧数据的环形缓冲,UI 任何时候要历史数据都是现查现取,不依赖事件流重放。 + +这套"拉取为主、推送为辅"的分工是刻意为之:推送只负责告诉前端"有新数据了",真正的数据永远通过查询接口拿。这样即使前端卡顿丢了几个事件,视图状态也能从后端完整重建——事件是提醒,不是真相。做实时数据类应用,这条原则能避开一大半"界面和实际对不上"的灵异 bug。 + +## 九、.data 文件:第二条逆向战线 + +8 月 16 日到 18 日,开辟第二战场:支持导入 Unity Editor Profiler 保存的 `.data` 文件。动机有两个:我以前用 Editor 采的数据不能浪费;更重要的是,离线文件让 AI 分析可以脱离真机环境——把文件发给同事,同事的工具和 Agent 就能复盘。 + +`.data` 是另一种二进制格式,和 USB 数据流完全不同:28 字节块头(格式版本 + body 大小 + 引擎版本五元组)、帧结束标记 `0xAFAFAFAF`、文件结束标记 `0xDEADFEED`——又一个彩蛋,"dead feed",文件喂完了,Unity 工程师的冷幽默无处不在。字符串是 NUL 结尾且按 4 字节对齐的;采样是 `diskSample` 定长结构(markerID + 总耗时 float32 + 起始时间戳 + 子节点数 + GC 分配字节),子节点紧跟其后递归排列。解析起来比 USB 流简单——没有多路复用、没有跨块——但边界检查一个不能少:单帧 body 上限 128MB、marker 定义上限 10 万条、每帧线程上限 512、字符串上限 1MB,全是防畸形文件的保险丝。 + +![Image 10](https://pica.zhimg.com/50/v2-dbabb5d879d8347889a49ceb813bbc4e_720w.jpg?source=2c26e567) + +8 月 18 日晚上的 `Add Unity 2022 Profiler data support` 是全项目最大的一次提交:44 个文件,+1676/-835。而它真正的价值在 16 分钟后的下一个提交 `Unify Live and Offline Profile Frames` 里完成——**统一内存帧模型**`profileframe.Frame`:实时采集(PlayerConnection 解码)、存档回放(Replay)、.data 导入,三条数据源全部归一化成同一个内存模型,再进 UI 会话。 + +这个重构的意义怎么强调都不为过。之前三条链路各搞各的投影,UI 层要处理三种数据形态,每加一个展示字段要改三处;统一之后,UI 只认一种帧,采集、回放、导入只是三个不同的"生产者"。架构上这叫防腐层,工程上这叫"终于不用写三遍相似代码了"。大文件导入还配了字节级进度回调和进度弹窗(`ImportProgressEvent` 事件),几百 MB 的 .data 导入时 UI 不假死,我随时能取消。 + +## 十、规则诊断:AI 的"前菜"与"锚点" + +在把数据交给 LLM 之前,先做一层规则化诊断(`internal/analyzer`)。规则不复杂,但每条阈值都是移动端 Unity 2022 的经验值: + +* **帧尖峰**:CPU 耗时超过中位数 2 倍且不低于 33.4ms(约 30fps 一帧),超阈值 2 倍升级为严重,最多报告 20 帧; +* **GC 尖峰**:单帧 GC 分配超 256KB; +* **低帧率**:平均 FPS 低于 30 告警; +* **DrawCall**:500 告警、1000 严重; +* **内存增长**:每分钟涨超 30MB 视为疑似泄漏(少于 30 帧不做趋势分析,避免小样本误报)。 + +为什么有了 LLM 还要规则分析?更主要的原因是**可定制**:不同项目的性能预算天差地别——休闲游戏 30fps 就及格,竞技游戏 60fps 都嫌少;有的项目单帧 GC 分配 256KB 就要拉警报,有的项目 1MB 才算事。这些标准写在规则里,每个项目可以按自己的预算定制阈值和规则集,诊断口径是确定的、可评审的、可进版本库跟着项目演化的;而丢给大模型自由发挥,同样的数据今天说"偏高"明天说"可接受",结论没法复现。在此之上还有两个附带好处:一是**便宜**,规则分析零 token,先把明显的问题筛出来,没问题的会话根本不用惊动大模型;二是**锚定**,结构化的规则报告(JSON)通过 MCP 工具提供给 Agent(下一章细说),能显著减少大模型"看图说话"式的臆测——它再天马行空,也得解释为什么规则报告里第 12345 帧有个 4 倍中位数的尖峰。规则报告是 LLM 诊断的前菜,更是它的锚点。 + +## 十一、系统提示词:给 Agent 立规矩 + +Agent 不是接上就好用的,系统提示词反复打磨过很多轮,最终版本的核心条款: + +> 你是 Unity 移动游戏性能优化专家,目标平台为 Unity 2022。涉及具体性能结论时应先查询数据,不要臆测。这是一个持久会话:后续用户消息可能是对前文的追问,应结合已有上下文,仅查询完成当前请求所需的数据。用户指定帧号时,优先查询该帧及其 CPU Hierarchy;除非用户明确要求总体概览,否则不要重新进行全量性能概览。每次收到实际用户请求后,必须立即调用完成任务所需的 MCP 工具并在同一回合给出结论、数据证据以及按优先级排序的优化建议;不要只回复确认、复述请求或给出执行计划。 + +每一条都是踩坑踩出来的:"不要臆测"是因为 Agent 会对着空气分析内存泄漏,编得有鼻子有眼;"不要重新全量概览"是因为追问时它会傻乎乎地把几万帧再拉一遍,token 原地爆炸;"不要只回复确认"是因为它会礼貌地说"好的,我来帮您分析",然后就没有然后了。**写提示词和写代码一样,都是把模糊意图变成确定行为**——只不过编译器换成了概率模型,报错方式变成了"它就是不照做"。 + +## 十二、MCP:给 Agent 装上数据的义肢 + +8 月 19 日的 `Add MCP performance tools and Agent logging`,是整个项目画龙点睛的一笔:让 Agent **按需查询**数据。通过 ACP 的 `session/new.mcpServers` 机制,创建会话时注入一个内置的 stdio MCP Server,暴露五个工具: + +* `performance_session_summary`:连接状态、聚合指标与规则分析,Agent 的"第一眼看全貌"; +* `performance_frames`:范围帧摘要,单次最多 200 帧,防止它一口吃成胖子; +* `performance_frame`:单帧完整计数器; +* `performance_cpu_hierarchy`:单帧 CPU 层级树,定位热点函数靠它; +* `performance_analysis`:重新运行内置规则分析。 + +架构上有个巧妙之处:MCP Server 进程就是工具自己的 exe(`unityperfagent.exe mcp` 子命令),它只拥有 JSON-RPC stdio 传输这一层皮;真正的数据在桌面主进程的内存里(实时采集会话),所以子进程把 `tools/call` 通过 **loopback HTTP** 转发给主进程处理。桥接只监听 `127.0.0.1`,并用每次运行随机生成的 32 字节令牌做 Bearer 鉴权——本地工具也不能裸奔,谁知道我机器上还跑着什么。链路是:Agent ↔ stdio ↔ MCP 子进程 ↔ 带令牌的 loopback HTTP ↔ 主进程采集会话。每一跳都可观测、可鉴权、可替换。 + +![Image 11](https://picx.zhimg.com/50/v2-37008a928915dbcb8f7ef2a251c6446e_720w.jpg?source=2c26e567) + +同时加了 **Agent Log** 功能:菜单栏一点,打开独立原生窗口,实时显示 Agent 对话和 MCP 请求/响应的完整 payload(上限 1000 条环形存储)。调试 Agent 行为时,这个窗口就是 X 光机——Agent 到底查了哪帧、MCP 到底回了什么、提示词哪条被无视了,一目了然。和 AI 协作开发的经验同样适用于 AI 产品的开发:**可观测性优先,不然你分不清是工具错了、协议错了还是模型错了。** + +### 12.1 一次真实的 AI 诊断长什么样 + +光说架构不过瘾,来看一眼这条链路跑起来之后的实际效果。我连上手机、采了几分钟数据,然后在聊天面板里敲一句:"刚才过场动画掉帧严重,帮我看看。" + +接下来发生的事情,在 Agent Log 窗口里一览无余:Agent 先调 `performance_session_summary` 拿到全貌——平均 FPS、P95 帧耗时、规则报告里躺着三条"帧耗时尖峰";它注意到尖峰集中在某个区间,于是调 `performance_frames` 拉了那 200 帧的摘要,锁定最痛的一帧;再调 `performance_cpu_hierarchy` 展开那一帧的采样树,发现 `AssetBundle.Load` 的子树占了主线程 60% 的时间,下面挂着解压和 IO 两个大头。最后它给出结论:过场动画的卡顿是同步加载资源导致的,建议改为异步加载并预加载,附上了每一帧的数据证据。全程三次工具调用,几千 token,结论精确到函数名。 + +对比一下旧世界:同样的问题,你要截五张图、发两万 token,模型告诉你"加载好像有点慢,建议看看资源管理"。从"看图说话"到"精确制导",差的不是模型聪明程度,而是数据通道。这就是 MCP 义肢的意义。 + +![Image 12](https://picx.zhimg.com/50/v2-5f5c36e11d4b2b0d3ea50251eb334683_720w.jpg?source=2c26e567) + +## 十三、最后一个提交:换掉 NLUX + +8 月 19 日傍晚,收官提交 `Replace NLUX with assistant-ui Agent Chat`,+4056 行,把聊天 UI 从 NLUX 整体换成 assistant-ui,同时把 Codex 后端从简单封装升级为持久的 app-server 模式(一个进程一个线程,Prompt 追加 turn,会话状态由服务端维护)。 + +为什么要换?NLUX 上手快、demo 漂亮,但定制能力有限——聊天气泡里要渲染工具调用过程、要支持右键上下文菜单复制、要导出对话、要细粒度控制主题,这些真实需求叠上去之后,与其在 NLUX 的 API 缝隙里雕花,不如换到组件化程度更高的 assistant-ui。这个决策本身也是个教训:**UI 库选型时,"demo 好看"和"能承载真实交互复杂度"是两回事**。好在替换发生在项目早期、聊天面板还没长太大的时候,成本可控;再晚一周,这就是一次伤筋动骨的重构了。 + +至此,12 个提交,一周时间,工具成型:USB 直连真机、逆向协议采集、Unity Profiler 同款 UI、规则诊断、ACP 接入任意 Agent、MCP 按需查数、Agent Log 全程可观测。 + +## 十四、复盘:和 AI 结对逆向的一手经验 + +最后聊聊方法论,这才是这篇文章真正想留下的东西。 + +**1. AI 是逆向工程的超级杠杆,但方向盘必须在你手里。** 抓包解析、假 Player、测试用例、UI 组件,AI 的产出速度是人的十倍以上——first commit 一上午 130 个文件就是证明。但"bit0 到底是什么语义"这种问题,AI 只能给假设,真机实测必须人来设计和裁决。逆向的本质是"提出假设 → 设计实验 → 验证/证伪"的循环,AI 能把每一步的执行成本压低一个数量级,但实验设计的品味无法外包。它最擅长的是把"我怀疑第二个字节是标志位"变成可运行的验证代码,而"为什么怀疑"永远来自人。 + +**2. 测试闭环是 PUA AI 的基础设施。** 没有假 Player 之前,AI 改协议解析我只能干瞪眼;有了假 Player,每次改动 `go test ./...` 秒级出结果,AI 自己也能跑测试自我修正。和 AI 协作的效率上限,不取决于模型多聪明,取决于你的验证闭环多快。闭环越快,你敢让 AI 动的刀就越大——+1676/-835 的重构敢一把过,靠的就是全绿的安全网。 + +**3. 防御性限制不是过度设计。** 16MB 报文上限、64 层快照深度、1 GiB 原始流上限、128MB 单帧上限……面对闭源黑盒,你永远不知道下一个字节流里有什么妖魔鬼怪。每一个上限都是给未知买的保险丝,烧断了保险丝,总比烧了整个屋子强。 + +**4. 错误信息是产品的一部分。**`ErrPlayerSilent` 那句"请确认 Unity Editor 的 Profiler 已断开",比协议解析本身更救我的命。逆向工具的使用者(首先就是三个月后的我自己)面对的都是未知现场,错误提示里多一句上下文,就少一次怀疑人生。 + +**5. 几度想放弃的时候,是"已完成的部分"救了项目。** 这个项目中途确实几次想撂挑子——PD3U 流解不出帧的时候、Call Stacks 位语义对不上的时候。但每次回头看看:adb 通道是通的、握手是成功的、测试是绿的,"已经完成的部分"构成了继续下去的惯性。把大目标切成能独立验证的小里程碑,不只是工程实践,也是心理建设。 + +**6. 给 AI 的需求要像给编译器的代码一样确定。** "帮我分析性能"这种需求丢给 Agent,它会给你一篇正确的废话;"先查 session summary,发现尖峰帧后展开该帧 Hierarchy,结论必须带帧号和数据证据"才能跑出可用的结果。提示词工程的本质不是咒语,是需求工程——你在群里给实习生派活怎么说,给 Agent 就怎么说,只不过 Agent 永远不会不好意思追问,所以你的模糊会被它忠实地放大。 + +**7. 一周做完 ≠ 一周能做。** 这个项目能一周成型,前提是 Go/React/二进制协议/桌面应用每一块我都有积累,AI 补齐的是速度和体力,不是判断力。如果面对完全陌生的领域,AI 产出的"看似合理"反而会成为最大的风险源——你无法审查你看不懂的东西。所以我的结论是:AI 时代最保值的能力,恰恰是那些能让你"审得动 AI 产出"的老本行。 + +## 十五、已知边界与未来的坑 + +诚实是逆向项目的美德,最后把已知的边界交代清楚: + +**只支持 Unity 2022。** 这是 AGENTS.md 里写死的第一条规矩,不是技术上限而是精力上限——每多支持一个大版本,数据流格式、消息 ID、能力位都可能变,测试矩阵直接翻倍。握手和数据流头两道关卡都会校验版本,不支持的版本会明确报错而不是静默解出错误数据。 + +**Deep Profile 仍是未解之谜。** 前面说过,`MsgIDProfilerSetup` 的 32 个位里没有它。它大概率需要 Editor 侧的配合报文,或者干脆是构建期插桩的开关。这个坑留给未来——也许哪天抓一次 Editor 勾选 Deep Profile 时的完整报文序列,就能水落石出。 + +**GPU 计数器是缺的。** Editor 下发的区域掩码 `0x7FFD` 里 GPU 位是关的——真机上 GPU Profiler 需要额外的驱动层支持,很多 Android 设备上 Editor 自己也拿不到。UI 里 GPU Usage 模块保留了开关,但大多数情况下它是空的,这一点文档里写明了,免得日后的自己以为是 bug。 + +**AI 分析的质量上限取决于数据粒度。** 目前 MCP 暴露的是帧级计数器和 CPU 采样树,对内存泄漏这类问题只能看趋势、不能看对象引用链——那是 Memory Profiler 的领域,走的是另一套快照协议。要不要逆向它?老实说,先让我歇一个月。 + +## 结语 + +回到最初的那个"梦想":Editor Profiler 是怎么和手机通信的?现在我可以回答了——PlayerConnection 报文、GUID 消息 ID、PD3U 分块流、按线程多路复用、元数据/事件双流,以及藏在二进制里的 `B10C7EAD` 彩蛋。数据又是如何组织的?采样树、计数器、方法符号表、跨帧幽灵采样,一清二楚。 + +而比答案更重要的是:这些数据现在以结构化的形式流淌在一个开放工具里,AI Agent 可以通过 MCP 随时查询任意一帧的完整现场——哪一帧卡了、卡在哪个函数、那帧分配了多少 GC、DrawCall 是多少,全是精确的数字,不是从截图里猜的。截图分析性能的时代,在我这里翻篇了。 + +这件事本来做也可,不做也可。所幸做了。