Android后台模拟点击探索(附源码)
工作中我们需要自制一套工具,其中遇到需要模拟点击事件的需求,类似按键精灵的功能,支持后台持续运行,满足触发条件时完成点击。
经过一番探索,一共整理出两种不同的方案:AccessibilityService 和 adb shell命令,读者可自行选择合适的场景。
AccessibilityService
无障碍模式是我首先想到的方案,对于不知道Android无障碍模式的,可自行百度。这里简单说明一下,AccessibilityService是Android为残障人士提供的贴心功能,比如可以报出当前页面有哪些按钮balabala。使用官方提供的一些列API,我们还可以完成一些自动运行的“黑科技”操作,比如早些年的红包插件、微信自动回复插件、自动点赞插件等。
本方案原理比较简单:扫描当前页面的View树,找到目标控件,模拟点击操作,下面详细阐述。
添加配置文件
首先需要在res目录下建立配置文件:accessible_service_config.xml ,名字随意取。
<?xml version="1.0" encoding="utf-8"?> <accessibility-service xmlns:android="http://schemas.android.com/apk/res/android" android:accessibilityEventTypes="typeAllMask" android:accessibilityFeedbackType="feedbackGeneric" android:accessibilityFlags="flagReportViewIds" android:canRetrieveWindowContent="true" android:notificationTimeout="100" android:description="@string/description" android:packageNames="目标包名"/>
accessibilityEventTypes:设置响应事件的类型,这里设置typeAllMask,就是响应全部类型的事件。
accessibilityFeedbackType:设置回馈给用户的方式,有语音播出和振动,这里使用通用类型。
notificationTimeout:设置响应时间。
packageNames:目标包名,比如红包插件就要设置微信包名,关于包名如何获取,下文会提到。
继承AccessibilityService编码
接着我们继承AccessibilityService新建AutoClickAccessibilityService,重写onAccessibilityEvent(AccessibilityEvent event)。
public class AutoClickAccessibilityService extends AccessibilityService { private static final String TAG = "GK"; @Override public void onAccessibilityEvent(AccessibilityEvent event) { ztLog("===start==="); try { //拿到根节点 AccessibilityNodeInfo rootInfo = getRootInActiveWindow(); if (rootInfo == null) { return; //开始遍历,这里拎出来细讲,直接往下看正文 if (rootInfo.getChildCount() != 0) { …… } } catch (Exception e) { ztLog("Exception:" + e.getMessage(), true); } }
拿到根节点以后,我们有两种方式开始寻找目标节点:
- 根据View id:findAccessibilityNodeInfosByViewId
- 根据控件文案:findAccessibilityNodeInfosByText
这里我们拿魅族手机自带的音乐App做例子,假如我们需要自动点击下图的 专栏 :
使用findAccessibilityNodeInfosByViewId寻找目标
我们可以使用findAccessibilityNodeInfosByViewId(),通过id找到目标节点,关于View id,可以使用DDMS中的Dump View Hierarchy for UI Automator,就是点击下图按钮(不知道如何打开eclipse或者AS的DDMS的同学可以自行百度):
稍等片刻,生成屏幕快照,并解析出View树,从右下的属性框就可以找到id,同时仔细看,包名也可以获取到啦~
这里很有可能因为目标apk混淆严重而读不到id,比如是个?,那么可以尝试第二个方法。
使用findAccessibilityNodeInfosByText寻找目标
使用findAccessibilityNodeInfosByText("最热MV"),顾名思义,就是根据文案找控件。
找到控件以后,就可以执行点击操作了,但是且慢,这里有个坑。
因为注意看这里的view树:
无论我们根据id还是文案,找到的可能只是一个TextView或者Button,但是根据我们日常经验,我们肯定是给其父布局设置的点击事件,也就是这里的LinearLayout或者FrameLayout。
所以我的方案是根据View树的结构,自行遍历。比如这里的View树结构如下:
我先做深度优先遍历找到GridView,然后遍历它所有孩子直至找到专栏这个TextView,为什么我不直接DFS找到专栏呢?因为我要记录它的父节点甚至爷爷节点,方便接下来执行点击操作。
如果有同学使用这种方案,建议根据实际View树的结构,自行遍历寻找,我的代码如下:
/** * 深度优先遍历寻找目标节点 */ private void DFS(AccessibilityNodeInfo rootInfo) { if (rootInfo == null || TextUtils.isEmpty(rootInfo.getClassName())) { return; } if (!"android.widget.GridView".equals(rootInfo.getClassName())) { ztLog(rootInfo.getClassName().toString()); for (int i = 0; i < rootInfo.getChildCount(); i++) { DFS(rootInfo.getChild(i)); } } else { ztLog("==find gridView=="); final AccessibilityNodeInfo GridViewInfo = rootInfo; for (int i = 0; i < GridViewInfo.getChildCount(); i++) { final AccessibilityNodeInfo frameLayoutInfo = GridViewInfo.getChild(i); //细心的同学会发现,我代码里的遍历的逻辑跟View树里显示的结构不一样, //快照显示的FrameLayout下明明该是LinearLayout,我这里却是TextView, //这个我也不知道,实际调试出来的就是这样……所以大家实操过程中也要注意了 final AccessibilityNodeInfo childInfo = frameLayoutInfo.getChild(0); String text = childInfo.getText().toString(); if (text.equals("专栏")) { performClick(frameLayoutInfo); } else { ztLog(text); } } } } private void performClick(AccessibilityNodeInfo targetInfo) { targetInfo.performAction(AccessibilityNodeInfo.ACTION_CLICK); }
AndroidManifest文件添加Service配置
AccessibilityService也是一个Servcie,所以要在AndroidManifest配置一下。
<service android:name=".AutoClickService" android:exported="false" <!-- label就是在手机设置中的无障碍里,显示的标签 --> android:label="自动点击Demo" <!-- 注意这里的android:permission是在service结构里面的!! --> android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE" > <intent-filter> <action android:name="android.accessibilityservice.AccessibilityService" /> </intent-filter> <!-- 配置服务服务配置文件路径 --> <meta-data android:name="android.accessibilityservice" android:resource="@xml/accessible_service_config" /> </service>
至此无障碍模式方案就讲完了,运行之后,需要在手机设置中的无障碍里打开对应的开关:
打开以后,自动点击功能可以自动后台运行了,不想用时可以在上图开关那里关闭即可。
以后需要先运行App,再打开开关,开启功能。
无障碍模式虽然用着挺舒服,但是在很多厂商的系统里,已经打开的无障碍模式隔一段时间经常会被自动关闭,比如MIUI系统里就要给App加开机运行的权限。
而厂商自带的无障碍就没事,猜测系统里内置了处理,这也是无障碍模式的一个坑吧。
小结
最后总结一下,AccessibilityService是一个很有趣的功能,发挥想象力可以做很多事,但是要小心踩坑:
- 通过findAccessibilityNodeInfosByViewId或者findAccessibilityNodeInfosByText找到的目标控件不一定是你想要的点击控件
- 各家厂商系统可能对无障碍模式内置了屏蔽处理
adb shell命令
adb可以方便我们直接高效的操作真机,比如安装apk,批量安装apk,复制文件等,而模拟点击事件也是可以通过adb命令完成的。
我是突然想到,前阵子看过网上流传的一个“微信跳一跳”的辅助,使用python + adb完成。
原理就是adb负责截图,python负责图像识别像素计算距离,最后再由adb模拟点击。
如果我们需要点击的目标,坐标相对确定,那我们直接在代码里执行adb命令模拟点击即可。
真机实验
我们先用USB连接真机,在cmd命令行工具里:
adb shell shell@PRO6:/ $ input tap 125 521 shell@PRO6:/ $
这里的意思就是点击屏幕上 (x, y) = (125, 521)的地方。果然手机响应了,缺点就是响应时间略长,感觉有1秒左右。
同理其他手势操作也可以完成,这里不作详解,感兴趣的可以自行搜索。
下面我们需要做的就是在代码里完成上述操作,并且可以持续在后台运行。这里我也是踩坑无数,听我慢慢吐槽。
寻找后台执行adb命令的方案
ProcessBuilder — OUT
没什么好说的,直接看代码:
int x = 0, y = 0; String[] order = { "input", "tap", " ", x + "", y + "" }; try { new ProcessBuilder(order).start(); } catch (IOException e) { Log.i("GK", e.getMessage()); e.printStackTrace(); }
这种版本,在Activity中可行,但是切后台不行……这肯定无法满足需求,再找!
Instrumentation — OUT
try { Instrumentation inst = new Instrumentation(); inst.sendPointerSync(MotionEvent.obtain(SystemClock.uptimeMillis(), SystemClock.uptimeMillis(), MotionEvent.ACTION_DOWN, x, y, 0)); inst.sendPointerSync(MotionEvent.obtain(SystemClock.uptimeMillis(), SystemClock.uptimeMillis(), MotionEvent.ACTION_UP, x, y, 0)); Log.i("GK", "模拟点击" + x + ", " + y); } catch (Exception e) { Log.e("Exception when sendPointerSync", e.toString()); }
这种版本和上一个一模一样,不能后台,差评!!
救世主Runtime登场
private OutputStream os; /** * 执行ADB命令: input tap 125 340 */ private final void exec(String cmd) { try { if (os == null) { os = Runtime.getRuntime().exec("su").getOutputStream(); } os.write(cmd.getBytes()); os.flush(); } catch (Exception e) { e.printStackTrace(); Log.e("GK", e.getMessage()); } }
后台问题迎刃而解!
添加合适的时机
目前我们把核心功能做完了,最后需要做的就是找到合适的时机,执行操作。
首先我们的容器肯定是一个Service,然后后台不断的判断当前app是否是目标app,如果是的话,再执行自动点击操作。
所以我们需要判断当前前台app的包名或者Activity的名字是否是我们的目标。
/** * 如果前台APP是目标apk */ private boolean isCurrentAppIsTarget() { String name = getForegroundAppPackageName(); if (!TextUtils.isEmpty(name) && PACKAGE_NAME.equalsIgnoreCase(name)) { return true; } return false; } /** * 获取前台程序包名 */ public String getForegroundAppPackageName() { ActivityManager am = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE); List<RunningAppProcessInfo> lr = am.getRunningAppProcesses(); if (lr == null) { return null; } for (RunningAppProcessInfo ra : lr) { if (ra.importance == RunningAppProcessInfo.IMPORTANCE_VISIBLE || ra.importance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND) { Log.i("GK", ra.processName); return ra.processName; } } return ""; }
以上就是adb shell方案,这种方案缺陷也比较明显,就是要求 自动点击的位置不能改变。
对于如何获取点击位置的坐标,可以打开开发者选项中的指针位置:
直接查看坐标。
总结
模拟点击这种需求,我们一般都不会用到,也有点歪门邪道的意思。但是无论什么需求,中间的探索过程才最珍贵。技术也是人,不是每次都会有说干就干的决心和勇气,保持一颗好奇心,珍惜每次探索的机会,学有所得,小有收获,也未尝不是一种自我认可。
最后附上源码:AutoClickService
以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。
您可能感兴趣的文章:
- Android模拟开关按钮点击打开动画(属性动画之平移动画)
- Android模拟用户点击的实现方法