Monkey源码解析写到第四篇,今天聊异常捕获和页面控制。这两个模块在Monkey源码里算不上最复杂,但却是稳定性测试结果准不准的关键。很多人在使用Monkey时都碰到过这样的困惑:App明明已经崩溃了,为什么Monkey还在继续跑?明明指定了测试包名,Monkey为什么还是跳到了系统桌面?这两类问题,答案其实都藏在Monkey.java和MonkeyActivityEvent相关的源码里。这篇适合两类人看:一类是想真正搞懂Monkey运行机制的Android工程师,另一类是每天跟monkey稳定性测试打交道、想优化测试参数和结果归因的测试同学。我阅读的AOSP版本是Android 13,Monkey源码位置在frameworks/base/cmds/monkey/,整体结构这么多年变化不大,结论可以放心用在绝大多数主流版本上。
1. 异常捕获的底层入口:IActivityController把系统事件“送”到Monkey手里
1.1 setActivityController注册的就是一个“上帝视角”
Monkey能感知应用崩溃和ANR,并不是因为它自己写了个守护进程在轮询,而是因为它在启动时向系统注册了一个监听器。这个动作的代码就在Monkey.java里,核心是:
mAm.setActivityController(mController);mAm是IActivityManager的远程接口,setActivityController是ActivityManagerService提供的一个系统级能力。调用之后,AMS在后续的Activity启动、恢复、进程崩溃、ANR发生时,都会回调Controller里的对应方法。这个注册行为只对system进程开放,普通App拿不到权限,Monkey能这么干是因为它跑在shell/user身份且属于平台测试工具的一部分。
注册成功之后,Monkey就站在了“上帝视角”:系统里所有应用的崩溃、卡死、页面切换,它都能第一时间感知到。代价是:整个测试期间,系统的重要操作都受Monkey的Controller控制,所以Monkey退出时必须把这个Controller清掉,源码里对应的是在runMonkey的finally块里调用mAm.setActivityController(null)。如果这一步没做,系统会一直处于“被监听”状态,后续Activity启动都可能被阻塞。这个清理动作很多初学者读源码时会忽略,但它恰恰是Monkey能够安全退出、不影响后续系统操作的关键。
1.2 五个回调方法的职责与返回值语义
mController的类型是ActivityController extends IActivityController.Stub,实现的核心回调有五个。我用一张表把每个回调的职责、触发时机和返回值的含义列清楚,这样后面讲到具体逻辑时不会绕晕:
| 回调方法 | 触发时机 | 返回true的含义 | 返回false的含义 |
|---|---|---|---|
activityResuming(packageName) | 某个Activity恢复/回到前台 | 允许该Activity继续 | 阻止该Activity恢复 |
activityStarting(intent, pkg) | 某个Activity正要启动 | 允许启动 | 阻止启动 |
appCrashed(...) | 任意进程发生Java崩溃 | 忽略该崩溃,测试继续 | 停止后续事件注入 |
appNotResponding(...) | 任意进程发生ANR | 忽略该ANR,测试继续 | 停止后续事件注入 |
systemNotResponding(message) | 系统级无响应(SystemServer内部卡死) | 忽略继续 | 停止 |
这里有一个贯穿全文的关键概念:回调返回值本质上是给AMS的“放行/阻止”建议。true表示Monkey允许这个行为继续,false表示Monkey表态“不许继续”。对应到崩溃场景,appCrashed返回false时,AMS会认定崩溃被“处理”并终止后续操作,Monkey的消息循环也会被中断,这就是Monkey遇崩溃停下来的底层机制。
1.3 activityStarting和activityResuming:既是页面控制,也是崩溃链路的哨兵
很多源码阅读者第一次看到activityStarting和activityResuming时会觉得奇怪:这两个方法和“异常捕获”有什么关系?其实关系很大。
在Monkey的Controller实现里,activityStarting会在一个新Activity启动前被回调。Monkey拿到Intent后可以做两件事:第一,根据包名判断当前要打开的页面是否属于允许范围;第二,把当前页面的状态记录下来,供后续崩溃归因使用。activityResuming同理,它会在Activity从后台恢复时触发,Monkey借此知道当前用户停留在哪个包、哪个界面。
这两个回调相当于异常捕获链路上的“哨兵”。因为崩溃和ANR并不是孤立发生的,它一定是在某个页面、某次操作之后出现的。有了页面切换的回调信息,Monkey才能在日志里准确记录下来“我在启动哪个包、哪个Activity时挂了”,而不是只给一个孤零零的崩溃栈。源码解析到这一层就会发现,异常捕获和页面控制本来就不是两套完全独立的逻辑,而是共用Controller这套回调机制的两个侧面。
2. appCrashed与appNotResponding:崩溃判定和测试终止的源码逻辑
2.1 appCrashed回调里的三层过滤
Monkey收到appCrashed回调后,并不是马上把测试停下来,而是做了一系列判断。我把核心逻辑抽象出来,大致是这样的结构:
public boolean appCrashed(String processName, int pid, String shortMsg, String longMsg, long timeMillis, String stackTrace) { synchronized (Monkey.this) { // 第一层:包名过滤 if (!isPackageAllowed(processName)) { // 崩溃不在允许列表内,当作无关事件,放行 return true; } // 第二层:崩溃计数与日志记录 mCrashes++; dumpCrashInfo(processName, pid, shortMsg, longMsg, stackTrace); // 第三层:是否忽略崩溃 if (mIgnoreCrashes) { // 用户指定 --ignore-crashes,继续跑 return true; } // 默认情况:通知事件循环退出 setTestFailed(); return false; } }第一层是包名过滤。这也是Monkey源码里非常容易被误读的一点:崩溃进程包名不在允许范围内时,Monkey会直接放行。这意味着如果你跑Monkey时没有指定-p参数,那么系统里任何App崩溃(包括系统UI、输入法、桌面)都会被Monkey判定为“无需停止的事件”,测试会继续跑。很多测试同学发现自己App已经崩了但Monkey还在乱点时,十有八九是栽在这一层过滤上。
第二层是崩溃记录。mCrashes计数器会在Monkey退出时一并汇总打印。dumpCrashInfo这个动作很关键,它会把崩溃的进程名、pid、简短信息、长信息和完整堆栈输出到日志里,这些信息就是后续定位崩溃的直接素材。
第三层判断比较绕:如果用户传了--ignore-crashes参数,那么即使被测App崩溃,Monkey也会选择忽略并继续注入事件。这个参数存在的意义是让Monkey可以在被测App反复崩溃的情况下,继续覆盖其他页面和功能路径。但代价是崩溃一瞬间的页面状态可能已经丢失,后续注入事件可能落在异常的界面上,结果归因时要小心。
2.2 ANR处理逻辑和kill-after-anr参数
ANR的回调和崩溃略有不同。appNotResponding的源码逻辑更简单直接:
public boolean appNotResponding(String processName, int pid, String processStats) { synchronized (Monkey.this) { mAnrs++; // 是否需要杀掉ANR进程 if (mKillAfterAnr) { killProcess(processName, pid); } // ANR默认直接中断测试 setTestFailed(); return false; } }从这段逻辑能看出Monkey对ANR的态度是“零容忍”:默认情况下,一旦目标进程ANR,Monkey就直接中断测试,不会像崩溃那样还提供--ignore-crashes一类轻松放行的开关。唯一的缓和选项是--kill-after-anr参数:如果配置了它,Monkey会在中断前把ANR进程杀掉,避免这个无响应的进程继续占着前台页面,影响测试或后续系统操作。
这里有一个实用的源码阅读提示:源码里ANR中断的判定发生在回调线程里,它通过设置一个内部标记和清空事件队列来通知主循环停下来。所以如果你在日志里看到“All events are queued, wait for test to finish”之类的状态,并不意味着Monkey还在继续注入随机事件,它只是等待事件队列自然清空并退出。
2.3 崩溃后事件循环如何收尾
Monkey的事件主循环在Monkey.java的runMonkey()方法里,代码结构大致是这样的:
while (!mQ.isEmpty()) { MonkeyEvent ev = mQ.getNextEvent(); ... int injectCode = ev.injectEvent(mAm, mCwm, mPm); ... if (checkTestFailed()) { break; } }当Controller的appCrashed返回false并且内部标记了testFailed后,主循环每次取事件前都会检查这个标记,一旦发现测试已经失败,立即跳出循环。随后Monkey会把事件源里还没注入的剩余事件清空,打印崩溃/ANR统计信息,最终以非0退出码结束进程。
这个机制解释了Monkey崩溃后停止速度为什么“看起来有点延迟”:它并不是在崩溃发生的那一瞬间就立刻终止的,而是等当前正在注入的事件执行完、回到主循环检查标记时才真正退出。这个间隔通常只有几十毫秒,但在极端情况下,如果当前事件是长耗时操作,你可能会看到崩溃日志之后还有少量事件被执行。这不是Bug,是事件循环轮询检查的固有延迟。
3. 页面控制的三种实现路径:ActivityEvent、JumpEvent和随机事件池
3.1 MonkeyActivityEvent:直接调AMS的startActivity
Monkey里真正承担“打开指定页面”任务的是MonkeyActivityEvent。它的injectEvent实现并不复杂,核心就是构造一个Intent并调用AMS的startActivity接口:
public int injectEvent(IActivityManager iam, ...) { mIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); try { iam.startActivity(null, mIntent, ...); return MonkeyEvent.INJECT_SUCCESS; } catch (Exception e) { return MonkeyEvent.INJECT_FAIL; } }关键点有三个:第一,FLAG_ACTIVITY_NEW_TASK是必须的,因为Monkey不是从某个Activity的上下文里启动新页面的,它没有现存的任务栈可用,必须用新任务方式拉起页面。第二,它直接调用的是AMS的全局能力,而不是通过Context.startActivity,所以Monkey可以启动任何应用、任何有导出权限的Activity,不受当前前台App限制。第三,注入结果只有INJECT_SUCCESS和INJECT_FAIL两种,失败时主循环会记录一个injectFail计数,但默认不会因为单个页面启动失败就终止整个测试。
这段源码直接解释了为什么Monkey能“跑飞出”被测应用:因为MonkeyActivityEvent根本不在乎当前页面的包名,它拿到什么Intent就启动什么页面,而Intent里携带的包名和Activity名,在随机模式下可能来自系统任意应用。
3.2 MonkeyJumpEvent和MonkeyCommandEvent:脚本模式下的页面跳转
除了直接调AMS,Monkey还有两种间接的页面控制路径。
MonkeyJumpEvent一般用于脚本模式(MonkeyScript)。脚本指令里有一条LaunchActivity,对应的就是JumpEvent。它的实现思想和ActivityEvent类似,但跳转目标通常更“粗粒度”:可能只指定包名,由系统解析出该包的Launcher Activity再打开。和ActivityEvent相比,JumpEvent多了一步通过PackageManager解析目标包默认入口的过程,所以它更接近“打开这个App”的语义,而不是“打开这个App的某个指定页面”。
MonkeyCommandEvent就更直接了:它会把整条命令交给shell执行。脚本里如果写了一条am start -n com.example/.MainActivity,就会落到CommandEvent上。这条路径等于完全绕开Monkey自己的Intent构造逻辑,直接复用adb shell am的能力去做页面跳转。它的好处是灵活,脚本里可以写任意系统命令;坏处是注入成败判断等于没有,Monkey只能知道命令有没有执行,拿不到更细的页面层级信息。
3.3 appswitch事件池:Monkey为什么“自己跳走了”
Monkey源码里最容易被测试同学吐槽的,就是--pct-appswitch这个百分比控制的appswitch事件。它对应的生成逻辑在MonkeySourceRandom里,我简单还原如下:
private MonkeyEvent generateAppSwitchEvent() { int index = mRandom.nextInt(mMainApps.size()); return new MonkeyActivityEvent(mMainApps.get(index)); }mMainApps是Monkey启动时通过PackageManager查询所有带Intent.CATEGORY_LAUNCHER的Activity得到的列表。注意,这个列表的范围是整个系统中可以启动的应用,而不是只包含你指定的被测包。
所以就算你加了-p com.example.app,appswitch事件依然可能随机选到系统桌面、浏览器、设置、商店等其他应用,然后通过MonkeyActivityEvent启动它们。这就是Monkey“自己跳走”的源码级原因:包名白名单影响的是异常判定和Activity事件生成时的过滤逻辑,但appswitch事件池在一开始的构造阶段就取自系统全局应用列表。
理解了这一点,你就知道为什么很多稳定性测试规范里都建议把--pct-appswitch调成0。如果你的目标是“只压测被测App”,这个比例必须关掉,否则Monkey会定期“串门”到别的应用里去。反过来,如果目标就是模拟用户真实使用场景,那保留一定的appswitch比例反而是合理的,因为真实用户本来就可能在App之间来回切换。
4. 白名单的源码逻辑:它到底能不能把Monkey“锁”在被测应用里
4.1 isPackageAllowed就三行,但决定了很多事
Monkey的包名判定逻辑很短,短到很多人会忽略它,但它的影响贯穿整个异常捕获和页面控制流程。核心代码:
private boolean isPackageAllowed(String pkg) { synchronized (this) { if (mRejectPackages.contains(pkg)) { return false; } if (mAllowPackages.isEmpty()) { return true; } return mAllowPackages.contains(pkg); } }三个判断依次是:黑名单命中直接拒绝;白名单为空则全部放行;白名单非空则只放行名单内的包。
默认情况下,mAllowPackages是空集合,所以Monkey对系统里所有包的行为都是一视同仁的。这也是为什么裸跑Monkey时,SystemUI、桌面、键盘这些系统进程一旦崩溃,Monkey一样会停下来(如果没有--ignore-crashes的话)。而在加了-p com.example.app之后,只有com.example.app崩溃才会触发Monkey停止,其他包的崩溃都会被当成“无关干扰”忽略掉。
4.2 白名单管“判定”,但管不了“生成”
这是源码阅读里最容易踩的坑,我在前面已经提到了白名单和appswitch事件池的关系。这里再往深挖一层:MonkeySourceRandom在生成MonkeyActivityEvent时,确实会走一层包名过滤,有一些逻辑会尝试只使用mMainApps里允许的包,但appswitch事件和部分anyevent事件并不走同一套过滤逻辑。
所以结论就是:白名单的本质是“判定层过滤器”,不是“行为层限制器”。它决定了Monkey“如何看待”一个页面的到来、一个进程的崩溃;它不决定Monkey“如何生成”随机事件。如果你希望Monkey不跳出被测应用,唯一可靠的手段是在参数层控制事件比例,关掉--pct-appswitch、--pct-syskeys这类会产生跨应用行为的事件类型。
4.3 实操中的白名单配置建议
基于源码行为,我总结几条拿得出手的白名单配置经验:
- 如果只测一个App,用
-p指定包名就够了,但记得同时把--pct-appswitch设为0,否则照样会跳出去。 - 如果测试场景包含跨App联动(比如登录后跳转支付、分享到社交App),用
--pkg-whitelist-file列出所有相关包名,这样任一环节崩溃都会被Monkey捕获并中断,不会漏报。 - 黑名单优先级高于白名单,所以可以通过黑名单把系统桌面、通知栏、输入法等明确有干扰的进程剔除,即使它们出现在白名单里也不会生效。
--ignore-crashes和白名单是两回事:白名单决定“哪些崩溃归我管”,--ignore-crashes决定“归我管的崩溃是停下还是继续”。两个参数同时使用时,要清楚最终效果是“只记录、不停止”。
5. 读这段源码的三条路线与稳定性测试实战避坑
5.1 这段源码应该怎么读
Monkey源码不算大,但直接按行读很容易绕晕。我建议按三条路线走,效率会高很多。
路线一是跟着入口读流程:从Monkey.java的main()开始,进run()、runMonkey(),把主循环跑通后,再跳去看ActivityController内部类。这条路线能让你建立整体框架,搞清楚“事件从哪来、注入失败后怎么处理、Controller回调在哪里影响了主循环”。
路线二是跟着事件类型读injectEvent:Monkey里每个事件类都有injectEvent方法,把MonkeyActivityEvent、MonkeyKeyEvent、MonkeyMotionEvent、MonkeyCommandEvent这四个核心事件类的injectEvent对比着看,你就会发现它们的共性:要么调AMS、要么注入InputManager、要么执行shell命令。页面控制的核心逻辑百分之八十都在这一环节。
路线三是跟着参数读配置解析:从命令行参数解析函数进入,看每个--pct-*参数怎么映射到MonkeySourceRandom的各类事件百分比上。理解了参数到事件类型的映射关系,你就能精准控制Monkey的行为范围,而不是靠猜参数试错。
5.2 我在实践中踩过的三个坑
第一个坑是没有配白名单导致崩溃漏报。有一次我跑Monkey,日志里明明看到被测App的崩溃栈,但Monkey进程一直没有退出,后续还在继续注入事件。查代码才发现,那次命令用了--pkg-blacklist-file但没加-p,等于mAllowPackages是空的,崩溃虽然发生了,却被判定为“非允许包”而放行。后来我在所有稳定性测试命令里固定加上精确的-p白名单,崩溃识别就再也没有漏过。
第二个坑是appswitch事件导致的“页面飞走”。早期我处理过一次线上问题:Monkey跑着跑着跳到了系统设置,并且在设置里做了一系列随机操作,最后设置应用本身崩了。因为没有白名单限制,崩溃被误算进被测App的指标里。排查后确认就是默认appswitch概率在起作用,解决办法是--pct-appswitch 0,同时把--pct-syskeys也调成0,因为Home、Back、Menu这些系统按键同样会改变页面栈,造成类似影响。
第三个坑是脚本模式下参数“失效”的误判。用MonkeyScript跑脚本时,事件序列完全由脚本内容决定,MonkeySourceRandom的百分比参数基本不生效。我一开始在脚本模式下设置了--pct-appswitch 0,结果脚本里还是跳到了其他页面,一度以为是参数解析问题。后来读了源码才发现脚本模式走的是MonkeySourceScript的解析逻辑,和随机事件源是两条独立的事件生成路径。这两个模式的事件源不同,参数行为也不同,排错前一定先确认自己跑的是哪种模式。
5.3 一套亲测稳定的参数模板
综合源码逻辑和踩坑经验,我目前在一套长期运行的稳定性测试里用的是这样的命令:
adb shell monkey \ -p com.example.app \ --throttle 300 \ --pct-touch 40 \ --pct-motion 25 \ --pct-pinchzoom 5 \ --pct-nav 5 \ --pct-majornav 5 \ --pct-syskeys 0 \ --pct-appswitch 0 \ --pct-anyevent 5 \ --pct-permission 0 \ --kill-after-anr \ --monitor-native-crashes \ --ignore-security-exceptions \ 10000解释一下几个关键参数对应的源码行为:--throttle 300让每个事件之间插入300毫秒延时,给页面加载留出时间,降低ANR误报;--pct-appswitch 0和--pct-syskeys 0是为了把Monkey严格限制在被测App内部;--kill-after-anr让Monkey在ANR后主动处理无响应进程,避免影响下一轮测试;--monitor-native-crashes会开启对native崩溃的监听,配合appCrashed回调一起工作,覆盖Java层和Native层两类崩溃场景。
写到这里,第四篇算是把异常捕获和页面控制这两块源码逻辑讲透了。我自己的体会是,Monkey其实不是一个黑盒,它的一切行为都能在代码里找到解释。遇到Monkey行为不符合预期时,先别急着怀疑工具坏了,花十分钟看看对应事件类型的injectEvent实现,或者查一下参数解析的映射关系,通常比瞎调参数高效得多。下一篇如果继续写这个系列,我打算聊Monkey的事件注入失败重试机制和性能数据统计相关源码,那也是我看源码时觉得水比较深的一块。