Frida 是一个动态插桩工具。它可以在程序运行时注入 JavaScript 脚本,观察函数参数、返回值、调用栈、对象字段、内存数据和模块符号。
这篇文档只面向合法授权的软件调试、兼容性排查、学习研究和安全分析。实际使用时建议在测试机、模拟器或隔离环境里操作,不要把脚本直接用于生产账号或第三方系统。
适合做什么
Frida 不适合代替完整调试器。遇到复杂崩溃、线程竞争、寄存器现场和大段汇编逻辑时,还是要配合 lldb、gdb、IDA、Ghidra 或 jadx。
基础安装
PC 端安装 Python 包:
1 2 3
| python3 -m pip install frida-tools frida --version frida-ps --version
|
Android 真机或模拟器需要放入对应架构的 frida-server。常见架构:
查看设备 CPU:
1 2
| adb shell getprop ro.product.cpu.abi adb shell getprop ro.product.cpu.abilist
|
推送并启动 frida-server:
1 2 3
| adb push frida-server /data/local/tmp/frida-server adb shell chmod 755 /data/local/tmp/frida-server adb shell su -c /data/local/tmp/frida-server
|
如果没有 root,Android 上通常只能分析可调试应用或使用 Gadget 方案。日常学习建议准备一台已 root 的测试机或模拟器。
确认连接:
1 2
| frida-ps -U frida-ps -Uai
|
常用参数:
spawn 与 attach
spawn 适合 Hook 启动早期逻辑,例如 Application.attach、类加载、初始化配置:
1
| frida -U -f com.example.app -l hook.js --no-pause
|
attach 适合应用已经启动,目标函数会在后续操作中触发:
1
| frida -U -n com.example.app -l hook.js
|
经验上:
- 找启动流程,用
spawn。
- 找按钮点击、网络请求、业务方法,用
attach。
- 目标类还没加载,用
spawn 或延迟 Hook。
- Hook 后立刻崩,先改成只打印,不改返回值。
脚本基本结构
Android Java 层 Hook 基本模板:
1 2 3 4 5 6 7 8 9 10
| Java.perform(function () { const Log = Java.use('android.util.Log'); const Throwable = Java.use('java.lang.Throwable');
function stack() { return Log.getStackTraceString(Throwable.$new()); }
console.log('[*] script loaded'); });
|
Native Hook 基本模板:
1 2 3 4 5 6 7 8 9 10 11 12 13
| setImmediate(function () { const addr = Module.findExportByName(null, 'open');
Interceptor.attach(addr, { onEnter(args) { this.path = args[0].readCString(); console.log('[open]', this.path); }, onLeave(retval) { console.log('[open ret]', retval.toInt32()); }, }); });
|
建议把脚本写成小函数,而不是所有逻辑塞进一个 Java.perform:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| function logLine(tag, value) { console.log(`[${tag}] ${value}`); }
function hookTarget() { Java.perform(function () { const Target = Java.use('com.example.Target'); Target.foo.implementation = function (arg) { logLine('foo arg', arg); const ret = this.foo(arg); logLine('foo ret', ret); return ret; }; }); }
setImmediate(hookTarget);
|
Java Hook 入门
Hook 普通方法
假设目标 Java 代码类似:
1 2 3 4 5 6 7
| package com.example;
public class UserService { public String sign(String text) { return "sign:" + text; } }
|
Frida 脚本:
1 2 3 4 5 6 7 8 9 10 11 12
| Java.perform(function () { const UserService = Java.use('com.example.UserService');
UserService.sign.implementation = function (text) { console.log('[sign arg]', text);
const ret = this.sign(text); console.log('[sign ret]', ret);
return ret; }; });
|
Hook 静态方法
目标代码:
1 2 3 4 5
| public class DeviceUtil { public static String getDeviceId(Context context) { return "..."; } }
|
Frida 脚本:
1 2 3 4 5 6 7 8 9
| Java.perform(function () { const DeviceUtil = Java.use('com.example.DeviceUtil');
DeviceUtil.getDeviceId.implementation = function (context) { const ret = DeviceUtil.getDeviceId(context); console.log('[device id]', ret); return ret; }; });
|
Hook 重载方法
Java 方法可以重载,Frida 需要明确参数类型:
1 2 3
| public String encode(String text) { ... } public String encode(byte[] data) { ... } public String encode(String text, int mode) { ... }
|
脚本:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| Java.perform(function () { const Codec = Java.use('com.example.Codec');
Codec.encode.overload('java.lang.String').implementation = function (text) { console.log('[encode String]', text); return this.encode(text); };
Codec.encode.overload('[B').implementation = function (data) { console.log('[encode byte[]]', bytesToHex(data)); return this.encode(data); };
Codec.encode.overload('java.lang.String', 'int').implementation = function (text, mode) { console.log('[encode String,int]', text, mode); return this.encode(text, mode); }; });
function bytesToHex(bytes) { const arr = Java.array('byte', bytes); return Array.prototype.map.call(arr, function (b) { return ('0' + (b & 0xff).toString(16)).slice(-2); }).join(''); }
|
常见类型写法:
一次 Hook 所有重载
方法重载太多时,可以统一打印:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| Java.perform(function () { const Target = Java.use('com.example.Target');
Target.foo.overloads.forEach(function (overload) { overload.implementation = function () { const args = [].slice.call(arguments); console.log('[foo overload]', overload.argumentTypes.map(function (t) { return t.name; }).join(', ')); console.log('[foo args]', args.map(String).join(' | '));
const ret = overload.apply(this, arguments); console.log('[foo ret]', ret); return ret; }; }); });
|
这个模板适合第一轮摸索签名。确定目标重载后,最好改成精确 Hook,日志会干净很多。
构造函数与对象字段
Hook 构造函数
Java 构造函数在 Frida 里叫 $init:
1 2 3 4 5 6 7 8
| Java.perform(function () { const User = Java.use('com.example.User');
User.$init.overload('java.lang.String', 'int').implementation = function (name, age) { console.log('[User init]', name, age); return this.$init(name, age); }; });
|
读取字段
字段通过 .value 读取:
1 2 3 4 5 6 7 8 9
| Java.perform(function () { const User = Java.use('com.example.User');
User.getName.implementation = function () { console.log('[field id]', this.id.value); console.log('[field token]', this.token.value); return this.getName(); }; });
|
修改字段
1 2 3 4 5 6 7 8
| Java.perform(function () { const Config = Java.use('com.example.Config');
Config.isDebug.implementation = function () { this.debug.value = true; return this.isDebug(); }; });
|
字段名和方法名重名时,Frida 可能需要用下划线形式访问。遇到字段读不到,先用 jadx 确认字段真实名、访问修饰符和混淆后名称。
打印调用栈
定位谁调用了目标函数时,调用栈很有用:
1 2 3 4 5 6 7 8 9 10
| Java.perform(function () { const Log = Java.use('android.util.Log'); const Throwable = Java.use('java.lang.Throwable'); const Target = Java.use('com.example.Target');
Target.sign.implementation = function (text) { console.log(Log.getStackTraceString(Throwable.$new())); return this.sign(text); }; });
|
日志太多时,只在命中特定参数时打印:
1 2 3 4 5 6 7
| Target.sign.implementation = function (text) { if (String(text).indexOf('keyword') >= 0) { console.log('[hit]', text); console.log(Log.getStackTraceString(Throwable.$new())); } return this.sign(text); };
|
常用数据转换
byte[] 转 hex
1 2 3 4 5 6
| function byteArrayToHex(bytes) { const arr = Java.array('byte', bytes); return Array.prototype.map.call(arr, function (b) { return ('0' + (b & 0xff).toString(16)).slice(-2); }).join(''); }
|
byte[] 转字符串
1 2 3 4 5 6 7
| Java.perform(function () { const StringCls = Java.use('java.lang.String');
function byteArrayToString(bytes) { return StringCls.$new(bytes, 'UTF-8').toString(); } });
|
Java List 遍历
1 2 3 4 5 6 7 8 9 10 11
| function dumpList(list) { if (list === null) { console.log('[list] null'); return; }
const size = list.size(); for (let i = 0; i < size; i++) { console.log(`[list ${i}]`, list.get(i)); } }
|
Java Map 遍历
1 2 3 4 5 6 7 8 9 10 11 12
| function dumpMap(map) { if (map === null) { console.log('[map] null'); return; }
const iterator = map.entrySet().iterator(); while (iterator.hasNext()) { const entry = iterator.next(); console.log(String(entry.getKey()), '=>', String(entry.getValue())); } }
|
枚举类和方法
枚举已加载类
1 2 3 4 5 6 7 8 9 10 11 12
| Java.perform(function () { Java.enumerateLoadedClasses({ onMatch(name) { if (name.indexOf('com.example') >= 0) { console.log(name); } }, onComplete() { console.log('[done]'); }, }); });
|
枚举类里的方法
1 2 3 4 5 6 7 8
| Java.perform(function () { const clazz = Java.use('com.example.Target'); const methods = clazz.class.getDeclaredMethods();
for (let i = 0; i < methods.length; i++) { console.log(methods[i].toString()); } });
|
如果类名不确定,可以先用关键字枚举类,再用 getDeclaredMethods() 打印方法签名。
延迟 Hook 与类加载
有些类不是应用一启动就加载,直接 Java.use() 会报错。可以 Hook ClassLoader.loadClass 找加载时机:
1 2 3 4 5 6 7 8 9 10 11 12 13
| Java.perform(function () { const ClassLoader = Java.use('java.lang.ClassLoader');
ClassLoader.loadClass.overload('java.lang.String').implementation = function (name) { const ret = this.loadClass(name);
if (name.indexOf('com.example') >= 0) { console.log('[loadClass]', name); }
return ret; }; });
|
也可以简单延迟:
1 2 3 4 5 6
| setTimeout(function () { Java.perform(function () { const Target = Java.use('com.example.Target'); console.log('[Target loaded]', Target); }); }, 3000);
|
延迟不是最稳的方案,但调试初期很省事。正式脚本建议从 Application.attach 或 ClassLoader 入手。
Hook Application.attach
多 dex、加固壳或插件化应用里,默认 class loader 可能找不到业务类。可以通过 Application.attach 拿到真实 ClassLoader:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| Java.perform(function () { const Application = Java.use('android.app.Application');
Application.attach.overload('android.content.Context').implementation = function (context) { const ret = this.attach(context); const loader = context.getClassLoader(); Java.classFactory.loader = loader;
console.log('[classloader]', loader); hookBusiness();
return ret; }; });
function hookBusiness() { const Target = Java.use('com.example.Target');
Target.sign.implementation = function (text) { console.log('[sign]', text); return this.sign(text); }; }
|
这类脚本通常配合 spawn:
1
| frida -U -f com.example.app -l hook.js --no-pause
|
修改返回值
只在测试环境中临时改值。建议先打印原返回值,再改:
1 2 3 4 5 6 7 8 9
| Java.perform(function () { const Target = Java.use('com.example.Target');
Target.isEnabled.implementation = function () { const oldRet = this.isEnabled(); console.log('[isEnabled old]', oldRet); return true; }; });
|
修改对象返回值时,要返回正确类型:
1 2 3 4 5 6 7 8 9 10 11
| Java.perform(function () { const Result = Java.use('com.example.Result'); const Api = Java.use('com.example.Api');
Api.getResult.implementation = function () { const result = Result.$new(); result.code.value = 0; result.message.value = 'ok'; return result; }; });
|
如果返回类型不匹配,常见结果是应用崩溃或 Frida 报类型转换错误。
Native Hook 入门
Native 层通常指 .so 里的 C/C++ 函数。常用思路:
- 找模块名。
- 找导出符号或基址。
- 通过
Interceptor.attach 打印参数和返回值。
- 必要时根据偏移 Hook。
枚举模块
1 2 3 4 5
| Process.enumerateModules().forEach(function (m) { if (m.name.indexOf('lib') === 0) { console.log(m.name, m.base, m.size); } });
|
找导出函数
1 2 3 4 5 6 7
| const exports = Module.enumerateExports('libtarget.so');
exports.forEach(function (item) { if (item.name.indexOf('sign') >= 0) { console.log(item.name, item.address); } });
|
Hook 导出函数
1 2 3 4 5 6 7 8 9 10 11 12
| const addr = Module.findExportByName('libtarget.so', 'Java_com_example_Native_sign');
Interceptor.attach(addr, { onEnter(args) { console.log('[native sign enter]'); console.log('arg0', args[0]); console.log('arg1', args[1]); }, onLeave(retval) { console.log('[native sign ret]', retval); }, });
|
按偏移 Hook
如果函数没有导出符号,可以用模块基址加偏移:
1 2 3 4 5 6 7 8
| const base = Module.findBaseAddress('libtarget.so'); const target = base.add(0x1234);
Interceptor.attach(target, { onEnter(args) { console.log('[hit]', target); }, });
|
偏移要和当前版本、架构、加载模块对应。版本更新后,偏移很容易失效。
Native 参数读取
C 字符串
1 2 3 4 5 6 7
| Interceptor.attach(Module.findExportByName(null, 'strcmp'), { onEnter(args) { const a = args[0].readCString(); const b = args[1].readCString(); console.log('[strcmp]', a, b); }, });
|
UTF-16 字符串
1 2 3 4 5
| Interceptor.attach(Module.findExportByName(null, 'wcslen'), { onEnter(args) { console.log('[wcslen]', args[0].readUtf16String()); }, });
|
内存 hexdump
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| Interceptor.attach(Module.findExportByName(null, 'memcpy'), { onEnter(args) { this.dst = args[0]; this.src = args[1]; this.len = args[2].toInt32();
if (this.len > 0 && this.len <= 64) { console.log(hexdump(this.src, { offset: 0, length: this.len, header: true, ansi: false, })); } }, });
|
修改返回值
1 2 3 4 5 6 7
| Interceptor.attach(Module.findExportByName(null, 'strcmp'), { onLeave(retval) { if (retval.toInt32() !== 0) { console.log('[strcmp ret old]', retval.toInt32()); } }, });
|
如果确实需要在测试环境改返回值:
不要一开始就替换返回值。先观察参数和调用频率,否则很容易改到系统函数的正常调用,导致应用崩溃。
Hook JNI RegisterNatives
很多 Native 方法不是导出符号,而是运行时注册。可以 Hook RegisterNatives 观察 Java 方法和 native 函数地址的对应关系:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| function hookRegisterNatives() { const symbols = Module.enumerateSymbols('libart.so'); let addr = null;
for (const s of symbols) { if (s.name.indexOf('RegisterNatives') >= 0 && s.name.indexOf('CheckJNI') < 0) { addr = s.address; console.log('[RegisterNatives]', s.name, addr); break; } }
if (addr === null) { console.log('RegisterNatives not found'); return; }
Interceptor.attach(addr, { onEnter(args) { const env = args[0]; const clazz = args[1]; const methods = args[2]; const count = args[3].toInt32();
console.log('[RegisterNatives count]', count);
for (let i = 0; i < count; i++) { const item = methods.add(i * Process.pointerSize * 3); const name = item.readPointer().readCString(); const sig = item.add(Process.pointerSize).readPointer().readCString(); const fn = item.add(Process.pointerSize * 2).readPointer(); console.log(name, sig, fn); } }, }); }
setImmediate(hookRegisterNatives);
|
这个模板适合定位 JNI 入口。拿到函数地址后,再用 Interceptor.attach(fn, ...) 进一步分析参数。
RPC 调用
Frida 的 rpc.exports 可以把目标进程里的函数暴露给本地 Python 调用。
JavaScript 脚本:
1 2 3 4 5 6 7 8 9 10 11 12
| rpc.exports = { sign(text) { let result = null;
Java.perform(function () { const Signer = Java.use('com.example.Signer'); result = Signer.sign(text); });
return result; }, };
|
Python 调用:
1 2 3 4 5 6 7 8 9 10 11 12 13
| import frida
device = frida.get_usb_device() pid = device.spawn(["com.example.app"]) session = device.attach(pid)
with open("rpc.js", "r", encoding="utf-8") as f: script = session.create_script(f.read())
script.load() device.resume(pid)
print(script.exports_sync.sign("hello"))
|
RPC 适合把已经定位好的函数做成调试入口。不要在 RPC 里写太多枚举和 Hook 逻辑,容易变得难维护。
日志组织
脚本复杂后,日志要有标签和开关。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| const debug = true;
function log(tag, msg) { if (!debug) { return; } console.log(`[${tag}] ${msg}`); }
function json(value) { try { return JSON.stringify(value); } catch (e) { return String(value); } }
|
打印对象时避免直接 JSON.stringify(JavaObject)。很多 Java 对象有循环引用或代理对象,容易报错。优先打印关键字段和 toString():
1 2
| console.log('[obj]', obj); console.log('[obj str]', obj.toString());
|
日志太多时可以只打印命中特征:
1 2 3
| if (String(url).indexOf('/api/') >= 0) { console.log('[url]', url); }
|
常见问题
frida-ps -U 看不到设备
检查顺序:
1 2 3
| adb devices adb shell getprop ro.product.cpu.abi adb shell ps | grep frida
|
常见原因:
- USB 调试未开启。
- 设备未授权当前电脑。
frida-server 没启动。
frida-server 架构不匹配。
- PC 端 Frida 版本和 server 版本差太多。
处理方式:
- 重新插拔 USB,确认
adb devices 是 device。
- 下载与 PC 端版本一致的
frida-server。
- 确认
chmod 755。
- root 环境用
su -c 启动。
unable to connect to remote frida-server
先确认 server 是否运行:
1
| adb shell ps -A | grep frida
|
如果没有运行:
1
| adb shell su -c /data/local/tmp/frida-server
|
如果端口冲突或残留进程异常:
1 2
| adb shell su -c "pkill frida-server" adb shell su -c /data/local/tmp/frida-server
|
Failed to spawn: unable to find process with name
-f 后面要写包名,不是应用显示名称:
1 2
| frida-ps -Uai frida -U -f com.example.app -l hook.js --no-pause
|
如果应用有多进程,确认目标逻辑在哪个进程:
1
| adb shell ps -A | grep example
|
ClassNotFoundException 或 Java.use 找不到类
常见原因:
- 类还没加载。
- 包名或类名写错。
- 应用使用多 dex、插件化或加固。
- 当前
ClassLoader 不对。
处理方式:
- 用
Java.enumerateLoadedClasses() 搜关键字。
- Hook
Application.attach 后设置 Java.classFactory.loader。
- Hook
ClassLoader.loadClass 观察加载时机。
- 用 jadx 确认混淆后的完整类名。
Error: foo(): has more than one overload
说明方法有重载,需要明确签名:
1 2 3
| Target.foo.overload('java.lang.String', 'int').implementation = function (text, mode) { return this.foo(text, mode); };
|
不确定签名时先打印:
1 2 3 4 5
| Target.foo.overloads.forEach(function (o) { console.log(o.argumentTypes.map(function (t) { return t.name; }).join(', ')); });
|
Hook 后应用卡死
常见原因:
- Hook 高频函数,日志太多。
- 在 UI 线程做了耗时操作。
- Hook 里递归调用了自己。
- 修改返回值导致业务等待条件不成立。
处理方式:
- 先加过滤条件。
- 少打印大对象,少
hexdump 大内存。
- 确认调用原函数时用的是当前 overload。
- 先只观察,不修改返回值。
错误示例:
1 2 3
| Target.foo.implementation = function (x) { return Target.foo(x); };
|
更稳的写法:
1 2 3
| Target.foo.implementation = function (x) { return this.foo(x); };
|
对于重载:
1 2 3 4
| const foo = Target.foo.overload('java.lang.String'); foo.implementation = function (x) { return foo.call(this, x); };
|
打印 byte[] 结果全是负数
Java 的 byte 是有符号类型。转 hex 时要 & 0xff:
1 2 3 4 5 6
| function toHex(bytes) { const arr = Java.array('byte', bytes); return Array.prototype.map.call(arr, function (b) { return ('0' + (b & 0xff).toString(16)).slice(-2); }).join(''); }
|
expected a pointer 或读取内存崩溃
Native 参数不一定是指针。读之前先判断:
1 2 3
| if (!args[0].isNull()) { console.log(args[0].readCString()); }
|
读取长度也要控制:
1 2 3 4
| const len = args[2].toInt32(); if (len > 0 && len < 4096) { console.log(hexdump(args[1], { length: len })); }
|
Module.findBaseAddress 返回 null
说明模块还没加载或模块名写错。
可以 Hook android_dlopen_ext 观察动态加载:
1 2 3 4 5 6 7 8 9 10
| Interceptor.attach(Module.findExportByName(null, 'android_dlopen_ext'), { onEnter(args) { this.path = args[0].readCString(); }, onLeave() { if (this.path.indexOf('libtarget.so') >= 0) { console.log('[loaded]', this.path); } }, });
|
模块加载后再 Hook:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| function hookWhenLoaded(name, callback) { const base = Module.findBaseAddress(name); if (base) { callback(base); return; }
Interceptor.attach(Module.findExportByName(null, 'android_dlopen_ext'), { onEnter(args) { this.path = args[0].readCString(); }, onLeave() { if (this.path.indexOf(name) >= 0) { const base = Module.findBaseAddress(name); callback(base); } }, }); }
|
TypeError: cannot read property implementation of undefined
一般是方法名写错,或目标是字段不是方法。先打印方法:
1 2 3 4
| const methods = Target.class.getDeclaredMethods(); for (let i = 0; i < methods.length; i++) { console.log(methods[i].toString()); }
|
再确认是否有重载、是否是静态方法、是否是内部类。
内部类名通常用 $:
1
| Java.use('com.example.Outer$Inner');
|
日志里对象显示 [object Object]
Java 对象优先调用 toString():
1
| console.log(obj.toString());
|
如果对象有 getter:
1 2
| console.log(obj.getId()); console.log(obj.getName());
|
如果是集合:
1 2
| dumpList(obj); dumpMap(obj);
|
常用脚本模板
网络 URL 观察模板
适合初步定位请求从哪里发出:
1 2 3 4 5 6 7 8 9 10
| Java.perform(function () { const URL = Java.use('java.net.URL');
URL.$init.overload('java.lang.String').implementation = function (url) { if (String(url).indexOf('http') === 0) { console.log('[URL]', url); } return this.$init(url); }; });
|
Hash / 加密输入输出观察模板
1 2 3 4 5 6 7 8 9 10 11 12 13
| Java.perform(function () { const MessageDigest = Java.use('java.security.MessageDigest');
MessageDigest.digest.overload('[B').implementation = function (data) { const alg = this.getAlgorithm(); const input = byteArrayToHex(data); const ret = this.digest(data); const output = byteArrayToHex(ret);
console.log(`[digest ${alg}] ${input} => ${output}`); return ret; }; });
|
SharedPreferences 读取模板
1 2 3 4 5 6 7 8 9
| Java.perform(function () { const SharedPreferencesImpl = Java.use('android.app.SharedPreferencesImpl');
SharedPreferencesImpl.getString.implementation = function (key, defValue) { const ret = this.getString(key, defValue); console.log('[sp getString]', key, '=>', ret); return ret; }; });
|
Toast 提示当前脚本已加载
1 2 3 4 5 6 7 8 9
| Java.perform(function () { const ActivityThread = Java.use('android.app.ActivityThread'); const Toast = Java.use('android.widget.Toast'); const context = ActivityThread.currentApplication().getApplicationContext();
Java.scheduleOnMainThread(function () { Toast.makeText(context, Java.use('java.lang.String').$new('Frida loaded'), 0).show(); }); });
|
分析记录模板
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| ## 目标
- 包名: - 版本: - 设备: - Frida 版本: - 目标函数:
## 入口
- 触发动作: - 类名: - 方法签名: - Native 模块: - 偏移:
## 观察
- 参数: - 返回值: - 调用栈: - 关键字段: - 触发频率:
## 结论
-
## 未确认
-
|
使用建议
- 先观察,再修改。
- 先 Hook 业务函数,再 Hook 系统高频函数。
- 每次只验证一个假设。
- 日志加标签,避免后期看不懂。
- 版本、包名、设备、脚本和结论一起记录。
- 脚本能短就短,确定后再抽函数复用。
- 偏移、类名、字段名都可能随版本变化,不要当成长期稳定接口。