快速开发一个Monkey自动挂测的Python测试工具(PyQt5)

基于之前对于PyQt5内容的学习 + subprocess的一些小摸索。

打算自己写一个挂测Monkey的Python工具,也是工作需要

目标实现的功能有:获取连接的设备MAC地址,选择设备,实时展示挂测动作+日志,日志保存

准备条件

adb 的 安装

MuMu模拟器(由于我并没有空余的安卓机器,所以这里使用模拟器来实现)

MuMu模拟器的端口是:127.0.0.1:5555 记得打开ADB调试开关

获取对应的设备Mac地址

先connect 一下 看看执行 adb devices之后,返回的是什么?

C:\Users\60399>adb devices
List of devices attached
127.0.0.1:5555  device

上面是只开了一个设备的情况,如果多开了设备,则可以使用以下命令,查看目标端口,并对模拟设备进行连接

C:\Users\60399>netstat -ano | findstr LISTENING | findstr 127.0.0.1
  TCP    127.0.0.1:5037         0.0.0.0:0              LISTENING       16964
  TCP    127.0.0.1:5555         0.0.0.0:0              LISTENING       16416
  TCP    127.0.0.1:5557         0.0.0.0:0              LISTENING       18664
  TCP    127.0.0.1:7555         0.0.0.0:0              LISTENING       16416
  TCP    127.0.0.1:7555         0.0.0.0:0              LISTENING       18664
  TCP    127.0.0.1:7890         0.0.0.0:0              LISTENING       14972
  TCP    127.0.0.1:10000        0.0.0.0:0              LISTENING       15972
  TCP    127.0.0.1:16384        0.0.0.0:0              LISTENING       16416
  TCP    127.0.0.1:16416        0.0.0.0:0              LISTENING       18664
  TCP    127.0.0.1:20017        0.0.0.0:0              LISTENING       17792
  TCP    127.0.0.1:59385        0.0.0.0:0              LISTENING       5680
  TCP    127.0.0.1:59386        0.0.0.0:0              LISTENING       14972

C:\Users\60399>adb devices
List of devices attached
127.0.0.1:5555  device


C:\Users\60399>adb connect 127.0.0.1:5557
connected to 127.0.0.1:5557

C:\Users\60399>adb devices
List of devices attached
127.0.0.1:5555  device
127.0.0.1:5557  device

这里其实可以发现MuMu模拟器使用的IP地址都是相同的,不过多开设备只是端口号不同

可以发现执行了adb devcies的返回结果,第一行是 list of devices attached,第二行才是IP地址+端口 以及设备

  • 先获取到设备的ip地址
#获取已连接的设备
def get_devicdes():
    response = subprocess.Popen(['adb','devices'],stdin=subprocess.PIPE,stdout=subprocess.PIPE,stderr=subprocess.PIPE)
    stdout,stderr = response.communicate()

    #假设错误,输出错误结果
    if stderr:
        print(f"error:{stderr}")
        return []

    #获取设备IP地址
    devices = []
    lines = stdout.decode().strip().splitlines()[1:]
    for line in lines:
        if line.strip() and 'device' in line:
            device_id = line.split()[0]
            devices.append(device_id)
    return devices
  • 如何通过已知的IP地址对目标的MAC地址

    adb shell cat /sys/class/net/wlan0/address 是获取 Wi-Fi MAC 地址的常见方式;

    如果是一些国产机或者魔改系统,Wi-Fi 设备接口名可能是 wlan1eth0 等,可以先通过 adb shell ifconfigip link 查看。

    #获得mac地址
    def get_macAdd(ipadds):
        macAdd = []
        for ipadd in ipadds:
            response = subprocess.Popen(['adb','-s',ipadd,'shell','cat','/sys/class/net/wlan0/address'],stderr=subprocess.PIPE,stdin=subprocess.PIPE,stdout=subprocess.PIPE,text=True)
            stdout,stderr = response.communicate()
            if stderr:
                print(f'error:{stderr}')
            macAdd.append(stdout.strip())
        return macAdd
    

    看一下运行效果吧!

    PS D:\Study\to the place\GIT-REPOSITORY-FOR-CURSOR> & D:/Python/python.exe "d:/Study/to the place/GIT-REPOSITORY-FOR-CURSOR/Software-tester-plan/monkey-Learn-day/PyQt5_monkey_tester.py"
    ['127.0.0.1:5555', '127.0.0.1:5557']
    PS D:\Study\to the place\GIT-REPOSITORY-FOR-CURSOR> & D:/Python/python.exe "d:/Study/to the place/GIT-REPOSITORY-FOR-CURSOR/Software-tester-plan/monkey-Learn-day/PyQt5_monkey_tester.py"
    ['08:35:fd:cc:2a:7e', '08:bd:d2:87:f3:94']
    

    成功!

Monkey基本内容

首先最重要的,获取我们要测试的APP的包名。

获取途径很多,推荐直接找开发要一个(APP是要上市的产品的话)。

如果是网上可以直接获取的APP呢,那么也可以直接在网上搜。

我们这个测试的对象呢就用自带的浏览器了,对应的包名:com.android.browser

其他途径:

adb shell pm list packages
adb shell dumpsys window windows | findstr mCurrentFocus
#我不知道是不是模拟器的原因,我使用第二行命令时,始终也获取不到。。如果有人知道可以评论下(感谢)

Monkey挂测命令格式

adb shell monkey [options] <event-count>

  • adb shell monkey:在设备中调用Monkey工具
  • [options]:各种可选参数
  • <event-count>:指定发送多少个随机事件

基本参数:

参数 含义
-p <package> 指定要测试的应用包名(可重复写多个)
--ignore-crashes 崩溃时继续执行测试,不中断
--ignore-timeouts 忽略 ANR 错误继续测试
--ignore-security-exceptions 忽略权限相关异常继续执行
--monitor-native-crashes 监控 native crash(如C层崩溃)
--kill-process-after-error 出错后立即杀死目标 App 进程
--bugreport 生成更详细的 bug 报告日志

控制事件类型百分比:

参数 含义(默认总和为100%,可根据需求调整)
--pct-touch <n> 触摸事件所占比例(默认 15%)
--pct-motion <n> 滑动/拖动事件比例
--pct-trackball <n> 滚轮事件比例(极少用)
--pct-nav <n> 导航事件,如方向键
--pct-majornav <n> 主页、菜单等导航事件
--pct-syskeys <n> 系统按键事件,如 Home/Back
--pct-appswitch <n> 模拟应用间切换
--pct-anyevent <n> 其他未分类事件

间隔与种子:

参数 含义
--throttle <ms> 每次事件间的间隔(毫秒)建议设为 300~500
--seed <n> 设置随机种子,便于复现测试
-v 输出日志级别(可写多次,越多越详细)

bash中的标准挂测命令:

adb shell monkey -p com.example.app \
--throttle 500 \
--ignore-crashes \
--ignore-timeouts \
--monitor-native-crashes \
--ignore-security-exceptions \
--pct-touch 80 \
--pct-motion 10 \
--pct-appswitch 5 \
--pct-syskeys 5 \
--bugreport \
-v -v -v 10000 > monkey_log.txt

Monkey在Python中的部分

传入参数部分:

def __init__(self,ipadd,package_name,throttle=500,event_count=1000):
        super().__init__()
        #可变参数传入
        self.device = ipadd
        self.throttle = throttle
        self.package_name = package_name
        self.event_count = event_count

        self.is_running = True
        self.process = None

这里,最好不要写死包名,还有间隔、事件数这些东西。可以更加方便复用操作

线程run部分:

#线程开始之后要做的事情
    def run(self):
        if not self.is_running:
            return

        # 构建adb monkey命令
        command = [
            'adb', '-s', self.device, 'shell', 'monkey',
            '-p', self.package_name,
            '--throttle', str(self.throttle),
            '-v', str(self.event_count)
        ]

        try:
            self.process = subprocess.Popen(
                command,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                encoding='utf-8',
                errors='ignore'
            )
            self.log_signal.emit(f"Monkey test start!")
            #循环读取输入
            for line in self.process.stdout:
                #检查线程是否被外部请求停止:
                if not self.is_running:
                    break
                self.log_signal.emit(line.strip())
            #等待进程执行完毕,确保资源被释放
            self.process.wait()

        except FileNotFoundError:
            self.log_signal.emit("ERROR:'adb' command not found")
        except Exception as e:
            self.log_signal.emit(f'unexcepted error occurred:{e}')
        finally:
            self.log_signal.emit("Monkey thread finished------------------------------")
            self.stop_signal.emit("stopped")

详细阐述Run与Stop中两次不同的线程停止操作,分别起到了什么作用(一开始我是把Monkey与线程混淆了,这里做一个详细的说明)

run() 方法中的检查:if not self.is_running: break

这个检查是在 for 循环内部,也就是在读取子进程输出的这个环节。

  • 它的作用是什么? 它的作用是让 Python 线程停止等待和处理 adb monkey 命令返回的日志。当 stop() 方法将 self.is_running 设置为 False 后,这个循环在下一次迭代时会发现这个变化,然后执行 break,跳出循环。

  • 它的局限性是什么? break 只是跳出了 for line in self.process.stdout: 这个循环。它并没有终止那个独立的 adb monkey 子进程。那个子进程依然在安卓设备上欢快地运行,继续执行点击、滑动等操作,直到它自己完成指定的事件数。

  • 那后面的 self.process.wait() 呢? wait() 方法会阻塞当前线程,直到子进程完全结束。所以,如果仅仅依靠 break 跳出循环,线程会卡在 wait() 这里,一直等到 monkey 测试自己跑完1000次事件后退出。这显然不是我们想要的“立即停止”的效果。

所以,run() 里的检查只是一个“优雅退出循环”的机制,但它本身无法实现“立即停止测试”。

stop() 方法中的 terminate() 和安全检查

stop() 方法是由外部(比如用户点击“停止”按钮)调用的,它的目标就是立刻终止测试。

  • self.process.terminate() 的作用是什么? 这是最关键的一步。terminate() 会向操作系统发送一个信号,要求强制终止由 subprocess.Popen 创建的那个子进程(也就是 adb monkey 进程)。这才是真正让 monkey 测试停下来的命令。

  • 那为什么需要 if self.process and self.process.poll() is None: 这个判断?

这是一个非常重要的安全检查,避免程序在不恰当的时候执行 terminate() 而崩溃。我们来分解它:

  1. if self.process: 检查 self.process 对象是否存在。有可能 run() 方法在启动子进程之前就出错了(比如 adb 命令没找到),此时 self.process 就是 None。如果没有这个检查,调用 None.poll() 会直接导致程序崩溃。

  2. and self.process.poll() is None: self.process.poll() 是一个无阻塞的检查方法。

  • 如果子进程仍在运行,poll() 会返回 None。

  • 如果子进程已经结束,poll() 会返回它的退出码(一个整数,比如 0 表示正常退出)。

所以,整个 if 语句的含义是:“如果子进程对象存在,并且经过检查发现这个子进程当前确实还在运行,那么就执行终止操作”。

这个检查可以防止两种错误:

  • 尝试去终止一个从未成功启动的进程。

  • 尝试去终止一个已经自己运行结束的进程。


我挂测时,注意到一个问题:

使用如下代码终止挂测进程后,我所打开的模拟器仍然在对目标程序进行monkey挂测。(重点是下面的stop)

def run(self):
        if not self.is_running:
            return

        # 构建adb monkey命令
        command = [
            'adb', '-s', self.device, 'shell', 'monkey',
            '-p', self.package_name,
            '--throttle', str(self.throttle),
            '-v', str(self.event_count)
        ]

        try:
            self.process = subprocess.Popen(
                command,
                stdout=subprocess.PIPE,
                stderr=subprocess.STDOUT,
                text=True,
                encoding='utf-8',
                errors='ignore'
            )
            self.log_signal.emit(f"Monkey test start!",self.device)
            #循环读取输入(报错正常)
            for line in self.process.stdout:
                #检查线程是否被外部请求停止:
                if not self.is_running:
                    break
                self.log_signal.emit(line.strip(),self.device)
            #等待进程执行完毕,确保资源被释放
            self.process.wait()

        except FileNotFoundError:
            self.log_signal.emit("ERROR:'adb' command not found",self.device)
        except Exception as e:
            self.log_signal.emit(f'unexcepted error occurred:{e}',self.device)
        finally:
            self.log_signal.emit("Monkey thread finished------------------------------",self.device)
            self.stop_signal.emit("stopped",self.device)
    
    
    
    
    def stop(self):
        self.is_running = False
        if self.process and self.process.poll() is None:
            try:
                self.process.terminate()
                self.log_signal.emit("Monkey process terminating...",self.device)
            except Exception as e:
                self.log_signal.emit(f"Error while terminating process:{e}",self.device)

对应的包仍然在执行monkey程序。

这说明了我并不清楚monkey到底是如何发生的,于是了解后得知:

Monkey的运行是

  • 主机使用adb发出指令,Monkey程序在设备上运行。Monkey作为一个Android系统级命令行工具在目标设备上运行,并开启一个独立的进程

我在stop函数中所做的,只是终止了主机上的ADB Shell连接

所以,我们若想要真正停止设备上的挂测,应当还有以下步骤:

  • 通过命令行,找到各个挂测设备对应执行Monkey的进程

  • 通过命令行,kill掉设备上的Monkey程序

步骤一:

找到执行Monkey的进程

adb -s 127.0.0.1:5555 shell ps | findstr monkey

​ 注意,我这里使用的是windows系统的电脑,所以使用findstr,一开始找的教程让我用grep,我还傻乎乎地试了。改天再好好复习下linux,忘了老多了。。

返回内容为:

shell 2565 1170 12856376 84280 futex_wait_queue_me 0 S com.android.commands.monkey

​ 其中2565为进程号 1170是父进程号

步骤二:

把这个进程kill掉

​ 修改后的stop函数如下:

def stop(self):
        self.is_running = False
        if self.process and self.process.poll() is None:
            try:
                self.process.terminate()
                self.log_signal.emit("Monkey process terminating...",self.device)
            except Exception as e:
                self.log_signal.emit(f"Error while terminating process:{e}",self.device)
		#kill设备上的Monkey进程
        try:
            result = subprocess.run(
                f'adb -s {self.device} shell ps | findstr monkey',
                shell = True,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                text=True
            )

            #返回命令一般是:shell   2565(对应进程)  1170 12856376 84280 futex_wait_queue_me 0 S com.android.commands.monkey
            for line in result.stdout.splitlines():
                parts = line.split()
                if len(parts) > 1:
                    pid = parts[1]
                    kill_cmd = ['adb','-s',self.device,'shell','kill',pid]
                    subprocess.run(kill_cmd)
                    self.log_signal.emit('设备上Monkey进程已被kill------------------------')
        except Exception as e:
            self.log_signal.emit(f'error killing monkey on device{e}',self.device)

关于subprocess与QThread的关系?

这点我觉得比较容易混淆,自己在这里做个特别的说明。防止未来遗忘

  • subprocess.Popen subprocess.run 都会在操作系统层面启动一个新的子进程---执行操作者所给出的命令

  • QThread是Qt框架提供的线程,和UI的线程同属于一个进程空间,只是多了一个执行流--用来做耗时的、阻塞的任务

两者的结构:

在这个工具中,我写了一个UI界面(主进程),然后对每个设备的monkey执行,又要去分管出一个子线程(QThread),就是每个QThread又会用subprocess.Popen,去管理Monkey命令的执行

我的stop方法中呢,有分出了两个子进程subprocess,一个去找设备中的Monkey进程,一个去kill掉它

总结

主线程(UI)→ 多个 QThread(后台任务)→ 每个 QThread 里有自己的 subprocess(子进程)。

关于保存日志

基本思路:UI界面的保存日志按钮-click事件发出信号-引起savelog的槽函数-槽函数中新建线程-线程中获取当前文件路径+时间戳进行保存-返回信号-通过信号弹出结果,并销毁线程

这个部分因为我们是模拟器,所以设备号是带有 :的

但是我们文件名中是不允许有 :的,所以我们保存时要注意替换!

保存日志的进程代码:

获取当前程序的路径需要用到os.path.abspath()

如果打包为exe文件了,注意参数名替换为sys.executable

#保存日志的线程
class Log_thread(QThread):
    send_signal = pyqtSignal(str,str)

    def __init__(self,device_id,content):
        super().__init__()
        self.content = content
        self.device_id = device_id


    def run(self):
        try:
            #获得当前脚本的路径
            if getattr(sys, 'frozen', False):  # PyInstaller打包后的exe
                base_dir =os.path.dirname(sys.executable)
            else:
                base_dir =os.path.dirname(os.path.abspath(__file__))
            
            log_dir = os.path.join(base_dir,'logs')
            os.makedirs(log_dir,exist_ok=True)
            #防止文件中的:导致无法保存
            safe_device_id = self.device_id.replace(':', '_')

            #增加时间戳
            timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')

            file_path = os.path.join(log_dir,f'log_{safe_device_id}_{timestamp}.txt')
            with open(file_path,'a+',encoding='utf-8') as f:
                f.write(self.content)
            self.send_signal.emit('log has been saved',self.device_id)
        except Exception as e:
            self.send_signal.emit(f'fail :{e}',self.device_id)
            
    

注意,日志保存线程也要放在字典中,不然容易被Python的垃圾回收机制回收了

总结

这篇文章我从上一次发文之后就一直再写,一共写+开发用了4天左右,也算是详细记录了下自己的代码过程吧。

只能说想要写好的文章真的不容易,又要写清思路,又要注意排版。落泪啊,原本想着每天一更,但是发现要维持作品的质量,还是需要不断地打磨!

最后希望大家给出多的建议,我后面也会不断改进此工具,或者尝试开发其他小工具的!

posted @ 2025-07-23 21:43  CalvinMax  阅读(10)  评论(0)    收藏  举报