模拟Android点击事件的趣事
2018-03-21 16:01 阅读(297)

本篇来自投稿老司机  披萨大叔 的投稿,分享了他实践中总结的 Android 后台模拟点击实现,一起来看看!希望大家喜欢。

 披萨大叔  的博客地址:

http://blog.csdn.net/qq_27258799


前言


工作中我们需要自制一套工具,其中遇到需要模拟点击事件的需求,类似按键精灵的功能,支持后台持续运行,满足触发条件时完成点击。

经过一番探索,一共整理出两种不同的方案: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="目标包名"/>


继承 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);
    }
}

拿到根节点以后,我们有两种方式开始寻找目标节点:

这里我们拿魅族手机自带的音乐 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 配置一下。

<?xml version="1.0" encoding="utf-8"?>
<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 是一个很有趣的功能,发挥想象力可以做很多事,但是要小心踩坑:


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());
}

这种版本如果想后台,必须获得系统签名,需要自行编译 Android 系统!成本太高!

救世主 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());
    }
}

后台问题迎刃而解!但是需要 Root 权限!!所以只能自己玩玩。


添加合适的时机


目前我们把核心功能做完了,最后需要做的就是找到合适的时机,执行操作。首先我们的容器肯定是一个 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;
}

/**
 * 获取前台程序包名,该方法仅在android L之前有效
 */
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方案,这种方案缺陷也比较明显,就是要求 自动点击的位置不能改变和Root 权限,而且获取前台程序包名的权限也比较敏感。对于如何获取点击位置的坐标,可以打开开发者选项中的指针位置:

直接查看坐标。


总结


模拟点击这种需求,我们一般都不会用到,也有点歪门邪道的意思。但是无论什么需求,中间的探索过程才最珍贵。技术也是人,不是每次都会有说干就干的决心和勇气,保持一颗好奇心,珍惜每次探索的机会,学有所得,小有收获,也未尝不是一种自我认可。

最后附上源码 AutoClickService 

https://github.com/unclepizza/AutoClickService