一、功能概述
- 属于Android UI 自动化测试的核心技术之一,允许应用程序在不需要人工干预的情况下,自动操作界面元素进行测试。
- 实现思路:执行指定命令,系统会将当前界面的 UI 控件信息(包括控件类型、文本内容、坐标位置等)以 XML 格式保存到指定文件中。之后,程序会解析这个 XML 文件,找到特定的控件(如按钮、文本框、输入框等),获取它们的坐标位置,进而实现自动点击、输入等操作。
- 涉及命令:/system/bin/uiautomator dump --compressed [文件路径] 。作用是使用 Android 系统内置的 uiautomator 工具来获取当前屏幕界面的布局信息,并以压缩格式保存为 XML 文件。
二、实现流程
1、核心工具类
1.1、UiAutomatorUtil
import android.text.TextUtils;
import android.util.Log;
import 你的包名.util.ShellUtil;
import java.io.File;/*** Description:调用系统内置的UiAutomator工具操作类*/
public class UiAutomatorUtil {/** 根据文本内容点击控件 */public static boolean clickByText(String classTag, String text, String uiXmlPath) throws Exception {// 1、调用系统内置的uiautomator工具,以压缩格式输出XML(减少文件体积),保存结果到指定路径的文件String execCmdResult = executeUiAutomatorDump(uiXmlPath);if (TextUtils.isEmpty(execCmdResult)) {Log.e(classTag + "_UiAutomatorUtil", "clickByText(),执行系统内置的uiautomator工具时发生异常");return false;}Thread.sleep(1000); // 确保文件写入完成// 2、解析Xml文本内容以获取坐标UiXmlParser.Rect bounds = UiXmlParser.findBoundsByText(new File(uiXmlPath), text);if (bounds == null) {Log.e(classTag + "_UiAutomatorUtil", "clickByText(),未找到文本控件: " + text);return false;} else {Log.i(classTag + "_UiAutomatorUtil", "clickByText(),找到文本控件,文本内容是: " + text);}// 3、计算中心点并点击UiXmlParser.Point center = bounds.getCenter();ShellUtil.getExecCmdResult("input tap " + center.x + " " + center.y);Log.i(classTag + "_UiAutomatorUtil", "clickByText(),点击的文本内容:" + text + ",坐标: (" + center.x + "," + center.y + ")");return true;}/*** 执行 uiautomator dump 命令,增加重试机制以提高成功率* @param uiXmlPath XML文件保存路径* @return 执行结果*/private static String executeUiAutomatorDump(String uiXmlPath) throws Exception {String command = "/system/bin/uiautomator dump --compressed " + uiXmlPath;String result = null;int maxRetries = 3;for (int i = 0; i < maxRetries; i++) {try {result = ShellUtil.getExecCmdResult(command);// 检查是否包含错误信息if (result != null && !result.contains("ERROR: could not get idle state")) {return result;}} catch (Exception e) {Log.e("UiAutomatorUtil", "executeUiAutomatorDump() 第" + (i+1) + "次尝试失败: " + e.getMessage());}// 等待一段时间再重试if (i < maxRetries - 1) {Thread.sleep(2000);}}// 如果所有重试都失败,返回最后一次的结果return result;}/** 在顶部输入框输入文本 */private static boolean inputText(String text) throws Exception {try {// 1、调用系统内置的uiautomator工具,以压缩格式输出XML(减少文件体积),保存结果到指定路径的文件ShellUtil.getExecCmdResult("/system/bin/uiautomator dump --compressed " + fullSensorTestUiXmlPath);Thread.sleep(1000); // 确保文件写入完成// 2、解析Xml文本内容以获取输入框坐标(输入框的提示文本(尾部带空格)为 "Only input integer number ") TODO 此处可修改为你的指定文本UiXmlParser.Rect bounds = UiXmlParser.findBoundsByText(new File(fullSensorTestUiXmlPath), "Only input integer number ");if (bounds == null) {Log.e(TAG, "inputText(),未找到输入框控件");// 按照输入框的文本单位值是"Hz"进行查找bounds = UiXmlParser.findBoundsByText(new File(fullSensorTestUiXmlPath), "Hz");if (bounds == null) {Log.e(TAG, "inputText(),仍然未找到输入框控件");return false;}} else {Log.i(TAG, "inputText(),已找到输入框控件");}// 3、计算中心点并点击,激活输入框UiXmlParser.Point center = bounds.getCenter();if (center == null) {Log.e(TAG, "inputText(),无法计算输入框中心点");return false;}String tapResult = ShellUtil.getExecCmdResult("input tap " + center.x + " " + center.y);if (tapResult == null) {Log.e(TAG, "inputText(),点击输入框失败");return false;}Thread.sleep(500); // 等待输入框激活// 4、输入文本String inputResult = ShellUtil.getExecCmdResult("input text " + text);if (inputResult == null) {Log.e(TAG, "inputText(),输入文本失败");return false;}Log.i(TAG, "inputText(),在输入框中输入文本: " + text);return true;} catch (Exception e) {Log.e(TAG, "inputText(),输入文本时发生异常: " + e.getMessage());return false;}}
}
1.2、UiXmlParser
import android.util.Log;
import org.w3c.dom.Document;
import java.io.File;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import org.w3c.dom.NodeList;/*** Description:XML文件解析工具类*/
public class UiXmlParser {private static final String TAG = UiXmlParser.class.getSimpleName();/** 根据文本内容查找元素 */public static Rect findBoundsByText(File xmlFile, String targetText) {try {DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();DocumentBuilder builder = factory.newDocumentBuilder();Document doc = builder.parse(xmlFile);NodeList nodes = doc.getElementsByTagName("node");for (int i = 0; i < nodes.getLength(); i++) {org.w3c.dom.Node node = nodes.item(i);String text = node.getAttributes().getNamedItem("text").getNodeValue();if (text != null && text.equals(targetText)) {String bounds = node.getAttributes().getNamedItem("bounds").getNodeValue();return parseBounds(bounds);}}Log.i(TAG, "findBoundsByText(),根据文本内容查找元素: " + targetText);} catch (Exception e) {Log.e(TAG, "findBoundsByText(),Failed to parse xml: " + e.getMessage());}return null;}/** 解析bounds字符串 */private static Rect parseBounds(String boundsStr) {// 示例格式:[100,200][300,400]String[] parts = boundsStr.replace("[", "").split("]")[0].split(",");int left = Integer.parseInt(parts[0]);int top = Integer.parseInt(parts[1]);parts = boundsStr.split("\\[")[2].replace("]", "").split(",");int right = Integer.parseInt(parts[0]);int bottom = Integer.parseInt(parts[1]);Log.i(TAG, "parseBounds(),解析bounds字符串: " + boundsStr);return new Rect(left, top, right, bottom);}public static class Rect {public int left, top, right, bottom;public Rect(int l, int t, int r, int b) {left = l; top = t; right = r; bottom = b;}public Point getCenter() {return new Point((left + right) / 2, (top + bottom) / 2);}}public static class Point {public int x, y;public Point(int x, int y) {this.x = x;this.y = y;}}
}
2、执行指令工具类
import android.os.Build;
import android.util.Log;
import java.io.BufferedReader;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.util.Arrays;
import java.util.List;/*** 创建时间:2024/2/19* 功能描述:多种方法执行 adb shell 命令并返回结果* 注意:使用该类的方法,手机须拥有Root权限*/
public class ShellUtil {public static String TAG = ShellUtil.class.getSimpleName();public static final String COMMAND_SU = "su";public static final String COMMAND_SH = "sh";/*** TODO 方法一:* 根据系统版本的类型执行命令行,返回执行结果* @param cmd 命令行字符串* @return 如果命令行有输出,则返回第一样输出,如果没有输出,则返回空字符串*/public static String getExecuteCmdResult(String cmd) {if (Build.VERSION.INCREMENTAL.toLowerCase().contains("factory") || isUserDebugROM()) {String userDebugOSResult = executeCmdOnUserDebugOS(cmd);LOGI("getExecuteCmdResult(),当前系统版本为 factory 或 userdebug 类型,执行结果:" + userDebugOSResult);return userDebugOSResult;} else {String userRootOSResult = executeCmdOnUserRootOS(cmd);LOGI("getExecuteCmdResult(),当前系统版本为 userroot 类型,执行结果:" + userRootOSResult);return userRootOSResult;}}/*** 在 factory 或 userdebug 类型的版本中执行命令行,返回命令行结果* @param shellCommand* @return*/public static String executeCmdOnUserDebugOS(String shellCommand) {LOGI("executeCmdOnUserDebugOS(),执行cmd:" + shellCommand);String line = null;String stringBuffer = "->";try {String[] cmd = new String[]{"/system/bin/sh", "-c", shellCommand};Process proc = Runtime.getRuntime().exec(cmd);InputStream inputSt = proc.getInputStream();BufferedReader reader1 = new BufferedReader(new InputStreamReader(inputSt));stringBuffer = reader1.readLine();LOGI("executeCmdOnUserDebugOS(),执行结果:" + stringBuffer);inputSt.close();reader1.close();proc.destroy();} catch (IOException e) {LOGE("executeCmdOnUserDebugOS(),关闭流数据时发生IO异常:" + e);}return stringBuffer;}/*** 在 userroot 的类型版本中执行命令行,返回命令行结果* @param cmd 命令行字符串* @return 如果命令行有输出,则返回第一样输出,如果没有输出,则返回空字符串*/public static String executeCmdOnUserRootOS(String cmd) {LOGI("executeCmdOnUserRootOS(),执行cmd:" + cmd);Process p = null;String line = null;InputStream is = null;BufferedReader reader = null;ProcessBuilder pb = null;String[] cmdStringArray = {"/system/xbin/su", "-c", cmd};try {List<String> cmdList = Arrays.asList(cmdStringArray);pb = new ProcessBuilder().redirectErrorStream(true).command(cmdList);p = pb.start();is = p.getInputStream();reader = new BufferedReader(new InputStreamReader(is));line = reader.readLine();LOGI("executeCmdOnUserRootOS(),执行结果:" + line);} catch (Exception e) {LOGE("executeCmdOnUserRootOS(),发生异常:" + e);} finally {if (reader != null) {try {reader.close();} catch (IOException e) {LOGE("executeCmdOnUserRootOS(),关闭缓冲流时发生IO异常:" + e);}}if (is != null) {try {is.close();} catch (IOException e) {LOGE("executeCmdOnUserRootOS(),关闭输入流时发生IO异常:" + e);}}if (p != null) {p.destroy();}}return ((line == null) ? "" : line);}/*** TODO 方法二:* 异步执行命令并返回进程引用,支持su/sh权限自动切换* @param command 要执行的命令* @return Process 进程引用,可用于控制进程生命周期* @throws IOException 当无法执行命令时抛出异常*/public static Process executeCommandAsRootAsync(String command) throws IOException {Process process = null;try {// 首先尝试使用su权限执行process = Runtime.getRuntime().exec(new String[]{COMMAND_SU, "-c", command});LOGI("executeCommandAsRootAsync(),使用su权限执行指令:" + command);return process;} catch (IOException e) {LOGE("executeCommandAsRootAsync(),使用su权限执行失败:" + e.getMessage());// 如果su权限失败,尝试使用sh权限执行try {if (process != null) {process.destroy();}process = Runtime.getRuntime().exec(new String[]{COMMAND_SH, "-c", command});LOGI("executeCommandAsRootAsync(),使用sh权限执行指令:" + command);return process;} catch (IOException e2) {LOGE("executeCommandAsRootAsync(),使用sh权限执行也失败了:" + e2.getMessage());if (process != null) {process.destroy();}throw e2; // 抛出sh权限执行失败的异常}}}/*** 异步执行命令并返回进程引用和输出结果,支持su/sh权限自动切换* @param command 要执行的命令* @param inProcess 用于接收进程引用和输出结果的回调接口* @throws IOException 当无法执行命令时抛出异常* @throws InterruptedException 当线程被中断时抛出异常*/public static void executeCommandAsRootAsyncWithOutput(String command, InProcess inProcess) throws IOException, InterruptedException {Process process = null;try {// 首先尝试使用su权限执行process = Runtime.getRuntime().exec(new String[]{COMMAND_SU, "-c", command});LOGI("executeCommandAsRootAsyncWithOutput(),使用su权限执行指令:" + command);} catch (IOException e) {LOGE("executeCommandAsRootAsyncWithOutput(),使用su权限执行失败:" + e.getMessage());// 如果su权限失败,尝试使用sh权限执行try {if (process != null) {process.destroy();}process = Runtime.getRuntime().exec(new String[]{COMMAND_SH, "-c", command});LOGI("executeCommandAsRootAsyncWithOutput(),使用sh权限执行指令:" + command);} catch (IOException e2) {LOGE("executeCommandAsRootAsyncWithOutput(),使用sh权限执行也失败了:" + e2.getMessage());if (process != null) {process.destroy();}throw e2; // 抛出sh权限执行失败的异常}}// 将进程引用传递给回调if (inProcess != null) {inProcess.getProcess(process);}// 读取输出流if (process != null) {BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));String line;while ((line = reader.readLine()) != null && !Thread.currentThread().isInterrupted()) {if (inProcess != null) {inProcess.parseMsg(line);}LOGI("executeCommandAsRootAsyncWithOutput(),执行结果:" + line);}reader.close();}}/*** TODO 方法三:* 使用不同权限执行adb命令,并返回命令执行结果* @param command* @return*/public static String executeCommandAsRoot(String command) {StringBuilder output = new StringBuilder();// 尝试以 su 权限执行命令CommandResult suResult = executeCommandWithPermissions(command, COMMAND_SU);if (suResult.getResultCode() == 0) {LOGI("executeCommandAsRoot(),su 权限执行成功");return suResult.getOutput();} else {LOGE("executeCommandAsRoot(),su 权限执行失败,error code: " + suResult.getResultCode());}// 如果 su 权限执行失败,尝试以 sh 权限执行命令CommandResult shResult = executeCommandWithPermissions(command, COMMAND_SH);if (shResult.getResultCode() == 0) {LOGI("executeCommandAsRoot(),sh 权限执行成功");return shResult.getOutput();} else {LOGE("executeCommandAsRoot(),sh 权限执行失败,error code: " + shResult.getResultCode());output.append("Error: ").append(shResult.getError());}return output.toString();}private static CommandResult executeCommandWithPermissions(String command, String permission) {StringBuilder output = new StringBuilder();StringBuilder error = new StringBuilder();Process process = null;BufferedReader reader = null;try {process = Runtime.getRuntime().exec(new String[]{permission, "-c", command});processBuilderRedirectErrorStream(process);LOGI(permission + " --- executeCommandWithPermissions(),执行指令:" + command);// 读取输出流reader = new BufferedReader(new InputStreamReader(process.getInputStream()));String line;while ((line = reader.readLine()) != null) {output.append(line).append("\n");LOGI("使用 " + permission + "权限 --- executeCommandWithPermissions(),正确结果:" + line);}// 读取错误流BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()));while ((line = errorReader.readLine()) != null) {error.append(line).append("\n");LOGE("使用 " + permission + "权限 --- executeCommandWithPermissions(),错误结果:" + line);}int responseCode = process.waitFor();return new CommandResult(responseCode, output.toString(), error.toString());} catch (IOException | InterruptedException e) {LOGE(permission + " --- executeCommandWithPermissions(),执行失败:" + e.getMessage());error.append("Error: ").append(e.getMessage());return new CommandResult(-1, output.toString(), error.toString());} finally {closeResources(process, reader);}}private static void processBuilderRedirectErrorStream(Process process) {try {process.getErrorStream().close(); // 关闭错误流process.getOutputStream().close(); // 关闭输出流} catch (IOException e) {LOGE("processBuilderRedirectErrorStream(),发生异常:" + e.getMessage());}}private static void closeResources(Process process, BufferedReader reader) {if (reader != null) {try {reader.close();} catch (IOException e) {LOGE("Close BufferedReader Error: " + e.getMessage());}}if (process != null) {process.destroy();}}/*** TODO 方法四:* 执行adb命令,并返回执行结果* @param cmd* @return* @throws IOException* @throws InterruptedException*/public static String getExecCmdResult(String cmd) throws IOException, InterruptedException {StringBuilder result = new StringBuilder();DataOutputStream lDataOutputStream = null;DataInputStream lDataInputStream = null;Process lProcess = null;try {lProcess = Runtime.getRuntime().exec(COMMAND_SU);} catch (IOException pE) {lProcess = Runtime.getRuntime().exec(COMMAND_SH);}lDataOutputStream = new DataOutputStream(lProcess.getOutputStream());lDataInputStream = new DataInputStream(lProcess.getInputStream());BufferedReader lBufferedReader = new BufferedReader(new InputStreamReader(lDataInputStream));lDataOutputStream.writeBytes(cmd + "\n");LOGI("getExecCmdResult(),execute cmd: " + cmd);lDataOutputStream.writeBytes("exit\n");lDataOutputStream.flush();String line = null;while ((line = lBufferedReader.readLine()) != null && !Thread.currentThread().isInterrupted()) {result.append(line);LOGI("getExecCmdResult(),execute result:" + result);}lProcess.waitFor();if (lDataInputStream != null) {lDataOutputStream.close();}if (lDataInputStream != null) {lDataInputStream.close();}LOGI(result.toString());return result.toString();}/*** TODO 方法五:* 解析命令执行结果*/public static void parseCmdResult(String cmd, InCmdMsg pInCmdMsg) throws IOException, InterruptedException {DataOutputStream lDataOutputStream = null;DataInputStream lDataInputStream = null;Process lProcess = null;try {lProcess = Runtime.getRuntime().exec(COMMAND_SU);} catch (IOException pE) {lProcess = Runtime.getRuntime().exec(COMMAND_SH);}lDataOutputStream = new DataOutputStream(lProcess.getOutputStream());lDataInputStream = new DataInputStream(lProcess.getInputStream());BufferedReader lBufferedReader = new BufferedReader(new InputStreamReader(lDataInputStream));lDataOutputStream.writeBytes(cmd + "\n");LOGI("parseCmdResult(),execute cmd: " + cmd);lDataOutputStream.writeBytes("exit\n");lDataOutputStream.flush();String line = null;while ((line = lBufferedReader.readLine()) != null && !Thread.currentThread().isInterrupted()) {if (pInCmdMsg!= null){pInCmdMsg.parseMsg(line);}}lProcess.waitFor();if (lDataInputStream != null) {lDataOutputStream.close();}if (lDataInputStream != null) {lDataInputStream.close();}}/*** TODO 方法六:* 解析命令执行结果*/public static void parseCmdProcess(String cmd, InProcess pInProcess) throws IOException, InterruptedException {DataOutputStream lDataOutputStream = null;DataInputStream lDataInputStream = null;Process lProcess = null;try {lProcess = Runtime.getRuntime().exec(COMMAND_SU);} catch (IOException pE) {lProcess = Runtime.getRuntime().exec(COMMAND_SH);}if (pInProcess!= null){pInProcess.getProcess(lProcess);}lDataOutputStream = new DataOutputStream(lProcess.getOutputStream());lDataInputStream = new DataInputStream(lProcess.getInputStream());BufferedReader lBufferedReader = new BufferedReader(new InputStreamReader(lDataInputStream));lDataOutputStream.writeBytes(cmd + "\n");LOGI("parseCmdResult(),execute cmd: " + cmd);lDataOutputStream.writeBytes("exit\n");lDataOutputStream.flush();String line = null;while ((line = lBufferedReader.readLine()) != null && !Thread.currentThread().isInterrupted()) {if (pInProcess!= null){pInProcess.parseMsg(line);}}lProcess.waitFor();if (lDataInputStream != null) {lDataOutputStream.close();}if (lDataInputStream != null) {lDataInputStream.close();}}/*** TODO 方法七:* 执行命令,并把结果写入文件*/public static void getExecCmdResultToFile(String cmd, String fileName, boolean append) throws IOException, InterruptedException {DataOutputStream lDataOutputStream = null;DataInputStream lDataInputStream = null;Process lProcess = null;try {lProcess = Runtime.getRuntime().exec(COMMAND_SU);} catch (IOException pE) {lProcess = Runtime.getRuntime().exec(COMMAND_SH);}File lFile = new File(fileName);if (!lFile.exists()) {lFile.createNewFile();}OutputStream lOutputStream = null;try {lOutputStream = new FileOutputStream(lFile, append);} catch (FileNotFoundException pE) {}lDataOutputStream = new DataOutputStream(lProcess.getOutputStream());lDataInputStream = new DataInputStream(lProcess.getInputStream());BufferedReader lBufferedReader = new BufferedReader(new InputStreamReader(lDataInputStream));lDataOutputStream.writeBytes(cmd + "\n");LOGI("exec: " + cmd);lDataOutputStream.writeBytes("exit\n");lDataOutputStream.flush();String line = null;while ((line = lBufferedReader.readLine()) != null && !Thread.currentThread().isInterrupted()) {lOutputStream.write(line.getBytes());lOutputStream.write("\r\n".getBytes());}lProcess.waitFor();if (lDataInputStream != null) {lDataOutputStream.close();}if (lDataInputStream != null) {lDataInputStream.close();}}/** 是否为 userdebug 类型版本 */public static boolean isUserDebugROM() {String fingerPrint = executeCmdOnUserDebugOS("getprop ro.product.build.fingerprint");if (fingerPrint.toLowerCase().contains("userdebug")) {LOGI("isUserDebugROM():true");return true;} else {LOGI("isUserDebugROM():false");return false;}}public static void LOGI(String msg) {Log.i(TAG, msg);}public static void LOGE(String msg) {Log.e(TAG, msg);}public static interface InCmdMsg{void parseMsg(String msg);}public static interface InProcess{void getProcess(Process pProcess);void parseMsg(String msg);}// 定义 CommandResult 类public static class CommandResult {private final int resultCode;private final String output;private final String error;public CommandResult(int resultCode, String output, String error) {this.resultCode = resultCode;this.output = output;this.error = error;}public int getResultCode() {return resultCode;}public String getOutput() {return output;}public String getError() {return error;}}
}
3、调用示例
import android.util.Log;
import 你的包名.UiAutomatorUtil;
import 你的包名.UiXmlParser;
import 你的包名.util.ShellUtil;
import java.io.File;/*** Description:自动化测试操作类*/
public class TestAutomator {private static final String TAG = TestAutomator.class.getSimpleName();private static final String testUiXmlPath = "/sdcard/UiDump.xml";private static final long TEST_DURATION = 5 * 1000; // 运行时长/** APP 自动化测试操作 */public static boolean runStressTest() {Log.i(TAG, "runStressTest(), 自动化测试操作,已开始");try {// 1、启动APP ShellUtil.getExecCmdResult("am start -n 应用包名/启动Activity");Thread.sleep(2000); // 等待启动// 2、点击"Consumer IR"boolean consumerIRResult = UiAutomatorUtil.clickByText(TAG, "Consumer IR", testUiXmlPath);if (!consumerIRResult) {Log.e(TAG, "runStressTest(),未能找到或点击'Consumer IR'按钮");return false;}Thread.sleep(1000); // 等待跳转// 3、在输入框输入 "100"boolean inputResult = UiAutomatorUtil.inputText("100");if (!inputResult) {Log.e(TAG, "runStressTest(),未能在输入框中输入文本");return false;}Thread.sleep(500); // 等待输入完成// 4、点击"Power"boolean powerResult = UiAutomatorUtil.clickByText(TAG,"Power", testUiXmlPath);if (!powerResult) {Log.e(TAG, "runStressTest(),未能找到或点击'Power'按钮");return false;}// 5、等待测试完成Thread.sleep(TEST_DURATION);// 6、退出应用ShellUtil.getExecCmdResult("am force-stop 应用包名");Log.i(TAG, "runStressTest(),APP 自动化测试操作,已完成");return true;} catch (Exception e) {Log.e(TAG, "runStressTest(),APP 自动化测试操作时发生异常: " + e.getMessage());return false;}}}