1. 那行 unrecognized arguments: --no-pause 背后是什么第一次接触 frida-il2cpp-bridge是为了给我自己写的一个 Unity IL2CPP 测试工程做崩溃复现。需求很朴素不重新打包、不把 IL2CPP 反编译回 C# 工程直接在跑着的进程里把某个方法的入参和返回值打到终端上。结果第一条命令就吃了个闭门羹终端回给我的是scripts\frida: error: unrecognized arguments: --no-pause。这个报错其实一点都不高深它跟 frida-il2cpp-bridge 本身没什么关系是 frida 命令行客户端frida-tools的参数解析在抱怨你给的这个--no-pause我这个版本不认。但新手最容易在这里被带偏——以为是脚本写错了、是 bridge 装错了、是设备没连上然后开始一通乱改越改越乱。所以这篇东西我打算按我自己的真实路径来写先把命令行和版本这层理顺再讲 IL2CPP 到底把代码变成了什么然后搭 agent 工程、上手核心 API、跑一个能复现的小案例最后把我踩过的坑按排查顺序列出来。适合完全没写过 Frida 脚本、但有一点 JavaScript 或 TypeScript 基础的人也适合写过 Frida 但没用过 il2cpp 桥接层的人。这里先把边界说清楚本文所有操作都只针对你自己开发的、或者你明确获得授权的应用与测试工程。用于排查自己的构建问题、做崩溃定位和性能观察是这套工具最正当的用法。1.1 命令行参数为什么会对不上frida 的命令行客户端是 Python 写的安装在你自己电脑上frida-server 是跑在目标设备上的原生程序。这两者版本必须匹配而命令行参数是在客户端这一侧解析的。当你在网上抄了一条教程命令frida -U -f com.example.demo --no-pause -l _agent.js而你本机的 frida-tools 是比较新的版本时参数集会发生变化。老教程里常用的--no-pause在新版本里可能已经被移除或改名于是 argparse 直接抛出unrecognized arguments连设备都不会去连进程也不会被启动。这里有个很典型的误导点报错前缀是scripts\frida:看起来像是一个叫frida的本地脚本出的问题。实际上这只是 Python 的 argparse 在打印prog名字时用了调用路径。真正的信息量只在最后半句这个参数不认识。处理方式很直接分三步先看本机版本frida --version同时pip show frida-tools看一下 frida-tools 的版本号。再看当前支持哪些参数frida --help翻到 usage 那一段找跟暂停/继续行为相关的开关。如果新版本里确实没有对应开关了直接把它删掉再跑。很多情况下这个参数本来就是可选的删掉之后行为差异只是是否在入口处暂停等你在 REPL 里手动 resume对着脚本跑的场景影响不大。我个人的习惯是网上抄来的命令先把所有--开头的长参数全部删掉只保留-U、-f、-l这三个必需的先跑通再说。跑通之后再逐个加回来。这样出问题时变量只有一个排查成本极低。1.2 先做一次版本体检再谈跑通新手常犯的第二个错误是拿着 A 版本的客户端去连 B 版本的 server。这种组合有时能连上有时连上就断有时表现成注入成功但所有 API 都拿不到数据。所以我在任何一台新机器上第一件事都是做一次版本体检# 本机客户端版本 frida --version # 设备侧以连接安卓设备为例确认 server 是否在跑 frida-ps -U | head -20frida-ps -U能列出进程说明客户端能通过 USB 通道和设备的 server 正常通信这一步是后续一切操作的前提。如果这一步就失败了后面的东西完全不用看先解决连接问题。对齐的原则很简单本机frida的版本号和设备上 frida-server 的版本号保持完全一致。小数点后面的也要一致。差一个小版本有时没事但我见过差一个小版本导致Il2Cpp.perform回调里拿不到任何类的情况排查起来非常耗时间。还有一个容易被忽略的点如果你用的是 TypeScript 工程frida-il2cpp-bridge是通过 npm 装的它内部依赖 Frida 的运行时 API。它的 README 里通常会标注兼容的 Frida 大版本区间。装之前扫一眼package.json里的版本约束比装完之后对着报错猜要省事得多。2. IL2CPP 把 C# 编译成了什么不弄清这层只会瞎试很多人跳过这一节直接去抄 Hook 代码然后就陷入为什么别人能拿到类名我拿不到的循环。花十分钟把 IL2CPP 的输出结构搞清楚后面所有 API 的用法都会变得顺理成章。Unity 有两种脚本后端。一种是 MonoC# 编译成中间语言运行时靠 JIT 编译程序集就是一堆.dll文件用通用工具就能反编译出接近源码的东西。另一种就是 IL2CPPC# 先被转成 C再交给平台的原生编译器编译成机器码最终产物是一个.so安卓、一个可执行文件桌面或者 iOS 上的 Mach-O。你写的方法名、字段名、类名在这一步全部消失变成了内存里的地址和偏移。这也解释了一个现象用常规的反编译工具直接看 IL2CPP 的产物你只能看到一堆没有语义的函数。想知道哪个地址对应哪个方法必须靠另一份东西——元数据。2.1 libil2cpp.so 与 global-metadata.dat 的分工IL2CPP 的构建产物里有两份东西是核心原生代码库安卓上是libil2cpp.so里面是编译后的机器指令、方法实现的入口地址、以及 IL2CPP 运行时自己的那套托管内存管理、GC、类型系统。元数据文件通常是global-metadata.dat里面存着所有程序集、类型、方法、字段、字符串常量的名字和结构信息。它不包含代码逻辑只包含有哪些东西、它们长什么样、彼此是什么关系。关键在于运行起来之后IL2CPP 运行时会在初始化阶段把这份元数据加载进内存在内存里构建出一套完整的类型系统。所以名字并没有真的消失只是被搬到了一个运行时才存在的结构里。frida-il2cpp-bridge 干的事情就是连上这套数据结构把类、方法、字段重新暴露成 JavaScript 能访问的对象。顺手说一个实用的小技巧元数据文件的开头 8 个字节是有规律的前 4 字节是固定的魔数紧接着 4 字节是版本号。想快速判断版本用十六进制看一眼就行xxd -l 8 global-metadata.dat把第二个 4 字节按小端序读出来就是版本号。这个数字直接决定了桥接层能不能正常解析。大致对应关系如下这是常见经验的归纳具体以你的工程为准元数据版本大致对应的 Unity 版本24Unity 2018 到 2019 早期27Unity 2020 到 2021.129Unity 2021.2 到 202231Unity 2023 及以后如果你的元数据版本比桥接层支持的上限还新表现通常是Il2Cpp.perform里直接抛异常或者能连上但所有assembly、class查询都返回空。遇到这种情况别怀疑自己的脚本先确认版本是不是越界了。做研究用的测试工程最简单的方法是把 Unity 版本降到自己确认能支持的那一档重新构建一次比去啃解析逻辑高效得多。2.2 运行时为什么还能按名字找到类和方法理解这一点能帮你判断哪些操作是便宜的哪些是昂贵的。IL2CPP 在初始化时会构建一张类型表。每个类型在里面有一条记录记录里挂着它的方法列表、字段列表、父类关系。桥接层拿到这份表的入口之后就可以按名字做字符串匹配去找你想要的类。比如const image Il2Cpp.domain.assembly(Assembly-CSharp).image; const klass image.class(PlayerController);这两行做的事情是先在程序集列表里找到名为Assembly-CSharp的那一项再从它的镜像里捞出名为PlayerController的类型记录。注意image.class()的参数是包含命名空间的完整名字如果你的类写在命名空间里就得写成MyGame.PlayerController。这是我见过新手最高频的错误没有之一——类名对了但一直返回null八成是漏了命名空间。另一个要注意的是开销。字符串匹配是线性查找偶尔查一次没问题但如果你打算在每帧执行的回调里做这种查找就会明显拖慢目标进程。正确做法是在初始化时查一次把结果存到闭包外面的变量里复用。这个习惯从第一份脚本开始就养成能省掉后面很多性能上的麻烦。2.3 三端宿主环境的差异虽然标题里没写平台但实际差异不小先说清楚省得你走弯路。安卓是最常见的场景。设备侧需要跑 frida-server需要 root 或者在一个允许调试的环境里操作。客户端用-U走 USB 通道。整个链路最成熟文档最多。iOS上通常是通过越狱环境里的包管理器安装对应版本的运行时操作方式和安卓类似但生态不同而且系统限制更多。个人建议新手从这个平台入手的话先找一台专门的测试设备不要用日常主力机。桌面平台Windows、macOS、Linux 上的独立构建是最舒服的调试环境。因为进程就在本机你可以直接附加不需要额外的设备和服务端frida -n YourApp.exe -l _agent.js在桌面环境下调试自己的工程日志直接打在同一个终端里断点、崩溃现场、堆栈都能看到效率比在移动端高一个数量级。我的建议是先在桌面构建上把脚本逻辑全部调通再去移动端跑。这样能把脚本写错了和环境没配好这两类问题彻底分开。3. 环境搭建的完整链路Node、TypeScript 和 agent 工程frida-il2cpp-bridge是发布在 npm 上的一个库它不是那种下载一个 js 文件丢进去就能用的脚本而是一个需要被打包进 agent 的模块。所以你需要一个最小的前端工程环境。别被前端工程这四个字吓到实际要装的东西很少。3.1 初始化一个能被 frida-compile 打包的工程整个流程就是标准的 npm 初始化加装包四步搞定mkdir il2cpp-lab cd il2cpp-lab npm init -y npm i frida-il2cpp-bridge npm i -D frida-compile typescript装完之后在package.json里加两条脚本方便重复使用{ scripts: { build: frida-compile index.ts -o _agent.js, watch: frida-compile index.ts -o _agent.js -w } }frida-compile的作用是把 TypeScript 编译成 JavaScript同时把frida-il2cpp-bridge这个依赖一起打包进一个自包含的文件。因为 Frida 注入进程的时候只能塞进去一个脚本文件没法帮你做模块解析所以打包这一步不能省。我个人强烈建议用watch模式。因为调脚本的过程就是改一行、重跑一次的高频循环手动敲 build 命令会让人烦到放弃。开着 watch保存文件就自动重新生成产物你只需要重跑注入命令。一个实操心得很值得分享产物文件名用下划线开头比如_agent.js。这样在 IDE 里它会自动排到文件列表最上面跟源码文件在视觉上分开不容易误编辑。同时记得把node_modules和产物文件加进忽略列表避免污染版本管理。3.2 写第一个 Il2Cpp.perform 并让它真的打印出东西第一份脚本别贪心就干一件事确认能连上 IL2CPP并列出某个类的所有方法。import frida-il2cpp-bridge; Il2Cpp.perform(() { console.log([*] il2cpp runtime attached); const image Il2Cpp.domain.assembly(Assembly-CSharp).image; const klass image.class(PlayerController); if (klass null) { console.log([!] class not found); return; } console.log([*] ${klass.name} has ${klass.methods.length} methods); for (const method of klass.methods) { console.log( ${method.name} / params${method.parameterCount}); } });几个必须知道的点第一Il2Cpp.perform是等待并初始化的入口。它会一直等到 il2cpp 模块加载完成才执行回调所以你把查询逻辑放在里面是安全的不需要自己写轮询。这也是为什么几乎所有示例代码都把它包在最外层。第二console.log的输出会出现在你运行frida命令的那个终端里。如果终端里什么都看不到先确认脚本是不是真的被注入了后面第 6 节会专门讲这个。第三如果类里方法特别多全打出来会刷屏。改成只打印你关心的那几个或者用klass.method(Name)直接拿单个方法。第四TypeScript 的类型标注在这里基本不起作用因为运行时的对象是桥接层动态构造的。类型标注的价值在于编辑器的补全提示能帮你少查文档。所以别纠结类型写得对不对能跑起来才是第一位的。跑起来的完整命令npm run build frida -U -f com.example.demo -l _agent.js如果你在桌面环境调试把-U -f com.example.demo换成-n YourApp.exe就行。3.3 设备侧的准备与版本对齐设备侧的事情说白了就三件把对应版本的运行时程序放进去、给它执行权限、启动它。# 推送到设备 adb push frida-server-版本-android-arm64 /data/local/tmp/fs # 赋权 adb shell chmod 755 /data/local/tmp/fs # 启动放到后台日志单独存一份 adb shell su -c /data/local/tmp/fs 这里的版本必须和你本机frida --version的输出完全一致。我是这么做的每次装完客户端立刻去确认版本号然后按这个号去找对应的设备端程序绝不凭印象下载。还有一个很实用的排查技巧先在设备上直接跑一次看它有没有报错。如果启动就崩终端里通常会有明确提示比你在客户端那边猜要快得多。另外记住进程名和监听端口如果同机上有多个版本的程序冲突端口会被占用表现就是客户端连不上但设备侧明明在跑。选择注入方式时-fspawn先启动再注入和-nattach附加到已运行进程差别很大。spawn 模式能从进程最早的阶段介入适合那些初始化阶段就完成关键逻辑的工程attach 模式适合你手动启动应用、等它跑起来之后再介入。新手先用 spawn因为时序最干净能避开很多太晚了类已经加载完了的困惑。4. 核心 API 上手从列类到改行为环境通了之后真正的活儿是这几组 API。我把它们分成三类查找、替换、观察。搞清这三类的边界你就不会写出奇怪的脚本了。4.1 定位程序集、类、方法几种写法和适用场景查找链条是固定的一层套一层域 → 程序集 → 镜像 → 类型 → 方法/字段。Il2Cpp.perform(() { // 1. 拿程序集镜像 const image Il2Cpp.domain.assembly(Assembly-CSharp).image; // 2. 拿类型注意命名空间的完整写法 const klass image.class(MyGame.PlayerController); // 3. 拿字段 for (const field of klass.fields) { console.log(${field.name} : ${field.type.name} static${field.isStatic}); } // 4. 拿方法 const method klass.method(TakeDamage); });几点实战体会程序集名不一定是Assembly-CSharp。这是 Unity 默认给用户脚本生成的名字但项目里可能有Assembly-CSharp-firstpass、各种.asmdef拆出来的程序集或者第三方 SDK 自己的程序集。不知道用哪个的时候先把所有程序集名打出来看一眼for (const assembly of Il2Cpp.domain.assemblies) { console.log(assembly.name); }方法的参数类型很关键。klass.method(TakeDamage)这种写法在只有一个同名方法时有效但如果工程里有重载就会拿不准拿到的是哪一个。稳妥的写法是把参数个数或参数类型一起指定具体签名以项目 README 为准因为不同版本的 API 入口有细微差别。判断依据很简单看你想 Hook 的方法有没有重载。有就必须精确指定没有简写可以。拿不到的常见原因只有三类名字写错了大小写、命名空间、程序集选错了、方法被裁剪掉了。第三类值得单独提一句Unity 的代码裁剪managed stripping会在构建时把看起来没用到的代码删掉导致开发时明明存在的方法打包后消失了。这在调试 release 构建时特别容易遇到切到开发构建做对比就能确认。4.2 implementation 与 intercept替换和观察别搞混这两个是最容易用错的。implementation是替换语义。你给它赋一个 JS 函数它就成了那个方法的新实现原来的 C# 逻辑不会被执行。适合我就是要改掉这个行为的场景Il2Cpp.perform(() { const klass Il2Cpp.domain.assembly(Assembly-CSharp).image.class(MyGame.PlayerController); const method klass.method(TakeDamage); method.implementation function (amount: number) { console.log([hook] TakeDamage 被调用原始参数 ${amount}); // 不调用原逻辑直接把这次伤害吞掉 return; }; });intercept是观察语义。它在方法入口和出口挂回调默认不改变原有执行流程适合我只想看看参数和返回值method.intercept({ onEnter(this: Il2Cpp.Object, ...args: any[]) { console.log([enter], args); }, onLeave(this: Il2Cpp.Object, retval: any) { console.log([leave], retval); } });新手最常见的翻车点是在implementation里想先打日志再调原方法然后写成递归调用直接把进程干栈溢出。记住implementation里你没有原方法这个概念你写的就是全部逻辑。如果你需要既观察又保留原行为那从一开始就该用intercept。顺带说一个我踩过的坑implementation会改变方法的行为如果这个方法被频繁调用比如每帧的更新循环你的console.log会瞬间刷爆终端同时严重拖慢进程甚至让人误以为是程序卡死了。给高频方法打日志一定要加计数器限流比如只打前 50 次let count 0; method.implementation function () { if (count 50) { console.log([hook] called); } };这个习惯看着不起眼但它救过我很多次——好几次我以为程序崩了其实只是被日志刷爆了。4.3 gc.choose、dump 和 trace不改逻辑也能拿到大量信息这三个功能是桥接层真正的价值所在因为它们能让你在不改任何行为的前提下把工程结构摸清楚。Il2Cpp.dump()会遍历运行时里的全部类型信息生成一份类似 C# 声明结构的文本。对于完全没有源码的构建产物来说这是最直接的目录。用法上把它放在Il2Cpp.perform里调一次产物会通过send()机制回到客户端侧注意数据量可能很大要留出足够的输出空间。Il2Cpp.gc.choose(klass)是从托管堆上捞出当前活着的实例。这个能力很实用你不需要知道对象是怎么被创建的只要它活着就能拿到它然后读它的字段const instances Il2Cpp.gc.choose(klass); console.log(live instances: ${instances.length}); for (const obj of instances) { console.log(obj.field(hp).value); }这里的心得是实例数量会随时间变化抓取时机不同结果差别很大。比如你在一局游戏的进行中抓可能拿到几十个在加载阶段抓可能一个都没有。所以想稳定复现配合一个定时循环、观察数量变化比单次抓取可靠得多。Il2Cpp.trace()用来对一批方法做执行轨迹记录可以按类或按方法筛选。它的价值在于我不知道方法名但我知道它一定被调用了这时候开一个范围合适的 trace看谁在动比逐个试方法名高效得多。不过要提醒一句trace 的范围千万别开太大全量 trace 基本等于给进程套上枷锁帧率会掉到你怀疑人生。先从一个类开始确认有效果再逐步扩大。字段读取这块有个小细节静态字段和实例字段的取值方式不同前者挂在类上后者挂在对象上。写脚本时先看field.isStatic能省掉很多读出来是 0的困惑。5. 一个可复现的实战给自己的测试工程做方法追踪光看 API 说明记不住我按自己练手的方式把完整链路走一遍。这个场景是我自己写的测试工程一个角色对象有个受伤方法扣血之后如果在 UI 上更新血条。5.1 造一个最小 IL2CPP 测试场景我建议你也在自己的工程里造一个这样的最小场景代码越简单越好using UnityEngine; namespace MyGame { public class PlayerController : MonoBehaviour { public int hp 100; private int shield 20; public void TakeDamage(int amount) { int real Mathf.Max(0, amount - shield); hp - real; if (hp 0) hp 0; Debug.Log($hp {hp}); } } }注意我特意加了几个麻烦点字段有 public 有 private方法有参数计算类有命名空间。这些在真实工程里到处都是提前在小场景里撞一遍比在大工程里撞要省时间得多。构建时记得用开发构建并且关掉代码裁剪把 managed stripping level 设为 Disabled这样你能确保所有东西都在。等你确认脚本没问题了再切回发布构建去验证裁剪带来的差异。5.2 从参数打印到返回值改写的完整链路第一步确认能找类并把方法列表打全这一步别省尤其是有命名空间的情况import frida-il2cpp-bridge; Il2Cpp.perform(() { const image Il2Cpp.domain.assembly(Assembly-CSharp).image; const klass image.class(MyGame.PlayerController); console.log([*] ${klass.name}); for (const m of klass.methods) { console.log( - ${m.name} (${m.parameterCount})); } });第二步观察参数const takeDamage klass.method(TakeDamage); takeDamage.implementation function (amount: number) { console.log([hook] TakeDamage(${amount})); // 这里不调用原实现而是自己把 hp 扣掉方便验证 hook 是否生效 const hpField this.field(hp); hpField.value hpField.value - 1; };跑起来之后你会看到每次受伤都打印一行同时血量的减少量变成了固定 1而不是按原公式算。这就说明替换成功。注意this在回调里指的是被调用对象本身的桥接对象所以能直接this.field(hp)拿到实例字段这是这个桥接层比较舒服的地方。第三步把逻辑切回保留原行为、只观察验证另一种写法takeDamage.intercept({ onEnter(this: Il2Cpp.Object, amount: number) { console.log([enter] amount${amount}, hp${this.field(hp).value}); }, onLeave(this: Il2Cpp.Object) { console.log([leave] hp${this.field(hp).value}); } });对比这两段代码你会立刻明白implementation和intercept的区别前者让原来的扣血公式失效了后者没有。这个对比做完这一节的收获就足够了。5.3 重载、泛型、静态成员这三个高频难点重载。如果同一个名字有两个方法比如TakeDamage(int)和TakeDamage(int, bool)简写会拿不准。解决思路是先遍历klass.methods按parameterCount筛出你要的那个或者按参数类型名筛选再取。这个筛选逻辑写一次后面所有脚本都能复用值得单独封装成一个小函数。泛型。泛型类型在运行时的名字会带上尖括号和类型参数字符串写法跟源码里长得不一样。遇到泛型相关的类或方法最省事的做法是先 dump 一遍类型列表看看运行时里它到底叫什么名字直接复制那个名字来用比猜要快得多。静态成员。静态字段和静态方法的访问入口在类上不在实例上。写的时候别下意识地加this。另外要注意静态成员经常被初始化代码延迟赋值你在进程刚启动时读可能拿到默认值而不是实际值。这个问题很容易误导人以为是 Hook 没生效其实是时机问题。6. 新手最常踩的坑与我的排查顺序最后这节是我自己整理的一张排查清单。顺序很重要从最外层往最内层查不要一上来就怀疑自己的脚本逻辑。6.1 agent 注入成功但没有任何输出这是最常见的一类。终端显示已经连接到进程但console.log一行都没有。按这个顺序查看有没有构建产物。是不是忘了跑npm run build或者改了源码但没重新生成_agent.js这是我个人踩得最多的一条尤其是用 watch 模式时偶尔会漏掉一次失败的构建。每次注入前扫一眼产物文件的修改时间几秒钟的事。看 import 有没有写。import frida-il2cpp-bridge;这行必须有。漏了的话脚本能跑但Il2Cpp这个全局对象不存在报错信息往往在很后面才出现。看是不是卡在等待。Il2Cpp.perform会等模块加载完成。如果目标构建不是 IL2CPP比如有人误用了 Mono 后端它会永远等下去表现就是什么都没发生。确认构建类型是最基本的检查。看是不是被时序问题挡住了。用-nattach时很常见你附加的时候你关心的类还没被加载。换成-fspawn从启动阶段介入问题通常就消失了。6.2 进程启动即退出时先怀疑什么如果注入之后目标进程直接闪退先别急着往对抗的方向想。绝大多数情况下原因很朴素版本不匹配。客户端和设备端运行时版本不一致注入阶段就可能出问题。元数据版本超出支持范围。前面提过表现就是初始化阶段异常。注入得太早或太晚。某些工程在初始化阶段对时序比较敏感。脚本本身有语法或运行时错误。这是最容易被忽略的一条——脚本抛异常也可能连带影响进程状态。排查顺序上我习惯先跑一个最简脚本只有 import 和一行打印确认最基础的注入没问题再逐步加内容。这个方法看着笨但它能把问题范围一刀切干净比对着几千行脚本猜快得多。需要说明的是有些应用确实会做运行时环境检查检测到异常状态会主动结束进程。研究这种情况下的表现前提依然是你操作的是自己负责的、有授权的目标。这是我给自己划的线也建议你划一条。6.3 报错对照表与版本矩阵把常见现象整理成一张表方便对着查现象或报错最可能的原因处理方向unrecognized arguments: --no-pause命令行参数与当前客户端版本不匹配看frida --help删掉或替换该参数客户端连不上设备设备侧运行时未启动或版本不一致先frida-ps -U验证连通性能连上但查不到类程序集名或命名空间写错、类被裁剪先打印程序集列表和类型列表Il2Cpp.perform永远不回调目标不是 IL2CPP 构建或元数据版本不受支持确认构建类型和元数据版本脚本无任何输出忘记构建产物、漏 import检查产物时间戳和文件开头注入后进程退出时序问题、版本问题、脚本异常先用最简脚本二分定位帧率骤降高频方法的日志未限流、trace 范围过大加计数器、缩小 trace 范围最后分享一个我自己养成的习惯每一份脚本都从只打印、不修改开始。先把类的结构、方法的调用频率、字段的取值都摸清楚确认信息无误之后再动手改行为。这么做有两个好处一是排查问题时变量最少二是你对自己在改什么有完全的把握。我早期的脚本基本都是一上来就替换结果就是程序行为莫名其妙地变了而我根本不知道是哪一行导致的只能全部推倒重来。现在这个顺序虽然前面多花十几分钟但整体效率反而高得多。另外一个小技巧给每个脚本的日志加一个统一前缀比如[lab]。当终端里混着运行时自己的输出、系统日志、以及你的打印时一眼就能区分出来省掉大量翻屏找日志的时间。这种小细节用久了就知道有多值。