今天是19年1月30日,又要过年了,也是各个微信、qq群红包狂轰滥炸开启之际。在应用市场搜索微信红包助手,相关功能的app层出不穷。实际上红包助手实现原理就只是一个android官方自带的AccessibilityService(无障碍服务)。感谢安卓的开源精神,百度一下,会不费吹灰之力搜到实现自动抢红包功能的源码,当然,这篇文章不会又再次Crtl+C,crtl+V的介绍一遍。
项目的需求是用户打开手机应用时,必须处于USB调试开启状态。而这通过文字去引导用户如何开启显然是不现实的。用无障碍服务去实现这个需求是当时团队想到的方案之一,不过从结果而言,即使排除软件bug、手机适配的因素,实际的交互体验也是不好的,最后也没采用这个方案。写下这篇文章算是对那段时间工作的一个总结和代码的回顾,不过大半年时间过去了,当时走过的很多坑很多已经忘了。所以呀,今后但凡掌握新的知识,得第一时间记录下来。
一个辅助功能被声明为AndroidManifest.xml中的任何其他服务,但它必须做两件事情:1、指定它处理“android.accessibilitiy.AccessibilityService”意图。2、请求BIND_ACCESSIBILITY_SERVICE权限,以确保只有系统可以绑定到它。如果这些项目中的任一项丢失,系统将忽略辅助功能服务。以下是一个示例声明:
其中,引用的xml配置文件,在res/xml/autodebug.xml中创建的:代码示例如下:
需要在无障碍服务列表找到你自己的app,手动打开该项功能。通过下面代码可以打开系统的无障碍功能列表
Intent intent = new Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS);
startActivity(intent);
public class AutoDebugService extends AccessibilityService {
private static String TAG = "AutoDebugService";
private IAutoDebug mAutoDebug;
public static final SpHelper SP_HELPER = new SpHelper(App.getContext(), CommonSpConsts.COMMON_SP, MODE_PRIVATE);
/**
* 当窗口发生的事件是我们配置监听的事件时,会回调此方法.会被调用多次
*
* 其中参数accessibilityEvent封装来自界面相关事件的信息,比如我们可以获得该事件的事件类型,进而根据起类型选择不同的处理方式:
*
* @param accessibilityEvent
*/
@Override
public void onAccessibilityEvent (final AccessibilityEvent accessibilityEvent) {
if (mAutoDebug==null){
return;
}
mAutoDebug.autoDebug(accessibilityEvent, AutoDebugService.this);
}
/**
* 系统成功绑定该服务时被触发,也就是当你在设置中开启相应的服务,
* 系统成功的绑定了该服务时会触发,通常我们可以在这里做一些初始化操作
*/
@Override
public void onServiceConnected () {
ToastUtil.toast("无障碍服务开启");
SP_HELPER.initEditor().putBoolean(CommonSpConsts.ACCESSIBILITY_SERVICE_STATE, true).commit();//无障碍服务已打开
mAutoDebug = AutoDebugFactory.createDebug();
SP_HELPER.initEditor().putBoolean(CommonSpConsts.AUTO_USB_COMPLETE, false).commit();
PackageManager packageManager = getPackageManager();
Intent intent = packageManager.getLaunchIntentForPackage("com.android.settings");
if (intent != null) {
startActivity(intent); //打开设置
return;
}
}
/**
* 当服务要被中断时调用.会被调用多次
*/
@Override
public void onInterrupt () {
// SP_HELPER.initEditor().putBoolean(CommonSpConsts.ACCESSIBILITY_SERVICE_STATE, false).commit();//无障碍服务已关闭
}
@Override
public boolean onUnbind (Intent intent) {
ToastUtil.toast("无障碍服务关闭");
SP_HELPER.initEditor().putBoolean(CommonSpConsts.ACCESSIBILITY_SERVICE_STATE, false).commit();//无障碍服务已关闭
return super.onUnbind(intent);
}
/**
* 判断无障碍服务是否开启
*
* @param mContext
* @return
*/
public static boolean isAccessibilitySettingsOn (String packageName,Context mContext) {
int accessibilityEnabled = 0;
final String service = packageName + "/" + AutoDebugService.class.getCanonicalName();
try {
accessibilityEnabled = Settings.Secure.getInt(
mContext.getApplicationContext().getContentResolver(),
android.provider.Settings.Secure.ACCESSIBILITY_ENABLED);
} catch (Settings.SettingNotFoundException e) {
}
TextUtils.SimpleStringSplitter mStringColonSplitter = new TextUtils.SimpleStringSplitter(':');
if (accessibilityEnabled == 1) {
String settingValue = Settings.Secure.getString(
mContext.getApplicationContext().getContentResolver(),
Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES);
if (settingValue != null) {
mStringColonSplitter.setString(settingValue);
while (mStringColonSplitter.hasNext()) {
String accessibilityService = mStringColonSplitter.next();
if (accessibilityService.equalsIgnoreCase(service)) {
return true;
}
}
}
} else {
}
return false;
}
}
打开usb调试模式,涉及到很多界面的跳转,为了要适配不同品牌手机的不同android版本,所以采取简单工厂模式来设计适配工作的这部分代码。1、首先,在assets目录创建一个json文件放置不同品牌手机的不同android版本手机跳转界面的步骤对应的关键信息;2、再用一个抽象类,加载配置文件,根据具体机型来生成操作步骤;3、产品具体实现类继承这个抽象类并实现产品接口里的方法,在这个方法里完成具体的操作。4、工厂类里,创建不同品牌不同android版本的产品实例。
手动打开无障碍服务开关后,进入onServiceConnected回调,完成具体产品实例创建。mAutoDebug = AutoDebugFactory.createDebug();
public class AutoDebugFactory {
public static IAutoDebug createDebug() {
String name = Build.MANUFACTURER;//手机品牌
String release = android.os.Build.VERSION.RELEASE;//android 版本
switch (name) {
case "samsung":
switch (release){
case "6.0.1":
return new SamSung();
}
case "HUAWEI":
switch (release){
case "7.0":
return new HuaWei_P10();
case "8.0.0":
return new HuaWei_8_0_0();
}
case "OPPO":
switch (release){
case "7.1.1":
return new OPPO_7_1_1();
}
case "vivo":
switch (release){
case "7.0":
return new Vivo_7_0();
}
case "Xiaomi":
switch (release){
case "7.1.2":
return new XiaoMi_7_1_2();
}
case "Meizu":
switch (release){
case "7.0":
return new MeiZu_7_0();
}
default:
return null;
}
}
下面以一款具体手机为例,华为8.0.0android版本。代码如下:
public class HuaWei_8_0_0 extends AbstractAutoDebug implements IAutoDebug {
private static final String TAG = "HuaWei";
public static final SpHelper SP_HELPER = new SpHelper(App.getContext(), CommonSpConsts.COMMON_SP, MODE_PRIVATE);
public HuaWei_8_0_0() {
super("huawei_8.0.0");
}
@Override
public void autoDebug(AccessibilityEvent accessibilityEvent,AccessibilityService service) {
boolean autoUSBComplete=SP_HELPER.getBoolean(CommonSpConsts.AUTO_USB_COMPLETE,false);
if (autoUSBComplete){
return;
}
int eventType = accessibilityEvent.getEventType();
Log.d(TAG, "onAccessibilityEvent " + eventType);
if (!Common.StepFlags.stepOneDone) { //第一步,打开 原生设置 界面
Log.d(TAG, "第一步,打开 原生设置 界面");
String keyName = mConfig.getSteps().get(0).getKeyName();
String containerResId = mConfig.getSteps().get(0).getContainerResId();
int clickNums = mConfig.getSteps().get(0).getClickNums();
Common.scroll2PositionClick(service,keyName, containerResId, clickNums, Common.StepFlags.STEP_ONE);
}
//第二步,打开 关于手机 界面
if (Common.StepFlags.stepOneDone && !Common.StepFlags.stepTwoDone) {
Log.d(TAG, "第二步,打开 关于手机 界面");
String keyName = mConfig.getSteps().get(1).getKeyName();
String containerResId = mConfig.getSteps().get(1).getContainerResId();
int clickNums = mConfig.getSteps().get(1).getClickNums();
if (eventType == AccessibilityEvent.TYPE_VIEW_CLICKED || eventType == AccessibilityEvent.TYPE_VIEW_SCROLLED)
{
Common.scroll2PositionClick(service,keyName, containerResId, clickNums, Common.StepFlags.STEP_TWO);
}
}
//第三步,点击7次版本号
if (Common.StepFlags.stepTwoDone && !Common.StepFlags.stepThreeDone) {
Log.d(TAG, "第三步,点击7次版本号 Common.StepFlags.stepThreeDone: " + Common.StepFlags.stepThreeDone);
String keyName = mConfig.getSteps().get(2).getKeyName();
String containerResId = mConfig.getSteps().get(2).getContainerResId();
int clickNums = mConfig.getSteps().get(2).getClickNums();
if (eventType == AccessibilityEvent.TYPE_VIEW_CLICKED || eventType == AccessibilityEvent.TYPE_VIEW_SCROLLED) {
Common.scroll2PositionClick(service,keyName, containerResId, clickNums, Common.StepFlags.STEP_THREE);
}
}
//第四步:先判断已经返回到关于手机界面 android 8.0 会弹出输入密码界面
if (Common.StepFlags.stepThreeDone && !Common.StepFlags.stepFourDone && Common.isShowToast("处于开发者模式", accessibilityEvent)) {
//第四步,返回到 原生设置 界面
Log.d(TAG, "第四步,返回到 原生设置 界面");
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
service.performGlobalAction(AccessibilityService.GLOBAL_ACTION_BACK);
Common.setStepsFlag(Common.StepFlags.STEP_FOUR);
}
}
//第五步,打开 开发人员选项界面
if (Common.StepFlags.stepFourDone && !Common.StepFlags.stepFiveDone) {
Log.d(TAG, "第五步,打开 开发人员选项界面");
String keyName = mConfig.getSteps().get(4).getKeyName();
String containerResId = mConfig.getSteps().get(4).getContainerResId();
int clickNums = mConfig.getSteps().get(4).getClickNums();
if (eventType == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
Common.scroll2PositionClick(service,keyName, containerResId, clickNums, Common.StepFlags.STEP_FIVE);
}
}
//第六步,打开 debug模式
if (Common.StepFlags.stepFiveDone && !Common.StepFlags.stepSixDone) {
Log.d(TAG, "第六步,打开 debug模式");
String keyName = mConfig.getSteps().get(5).getKeyName();
String containerResId = mConfig.getSteps().get(5).getContainerResId();
int clickNums = mConfig.getSteps().get(5).getClickNums();
if (eventType == AccessibilityEvent.TYPE_VIEW_CLICKED || eventType == AccessibilityEvent.TYPE_VIEW_SCROLLED) {
Common.scroll2PositionClick(service,keyName, containerResId, clickNums, Common.StepFlags.STEP_SIX);
}
}
//第七步,点击确定
if (Common.StepFlags.stepSixDone && !Common.StepFlags.stepSevenDone) {
Log.d(TAG, "第七步,点击确定");
String keyName = mConfig.getSteps().get(6).getKeyName();
String containerResId = mConfig.getSteps().get(6).getContainerResId();
int clickNums = mConfig.getSteps().get(6).getClickNums();
if (eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) {
Common.scroll2PositionClick(service,keyName, containerResId, clickNums, Common.StepFlags.STEP_SEVEN);
}
if (Common.StepFlags.stepSevenDone) {
Common.StepFlags.stepOneDone = false;
Common.StepFlags.stepTwoDone = false;
Common.StepFlags.stepThreeDone = false;
Common.StepFlags.stepFourDone = false;
Common.StepFlags.stepFiveDone = false;
Common.StepFlags.stepSixDone = false;
Common.StepFlags.stepSevenDone = false;
SP_HELPER.initEditor().putBoolean(CommonSpConsts.AUTO_USB_COMPLETE, true).commit();
}
}
}
}
public abstract class AbstractAutoDebug {
AutoDebugConfig.Config mConfig;
public AbstractAutoDebug(String string) {
Gson gson = new Gson();
//将json数据变成字符串
StringBuilder stringBuilder = new StringBuilder();
try {
//获取assets资源管理器
AssetManager assetManager = App.getContext().getAssets();
//通过管理器打开文件并读取
BufferedReader bf = new BufferedReader(new InputStreamReader(
assetManager.open("auto_debug_config.json")));
String line;
while ((line = bf.readLine()) != null) {
stringBuilder.append(line);
}
AutoDebugConfig config = gson.fromJson(stringBuilder.toString(), AutoDebugConfig.class);
HashMap map = config.getMap();
mConfig = map.get(string);
} catch (IOException e) {
e.printStackTrace();
}
}
{
"map": {
"huawei_8.0.0": {
"openServiceClassName": "",
"openSettingsClassName": "",
"stepNums": 0,
"steps": [
{
"keyName": "系统导航、系统更新",
"containerResId": "com.android.settings:id/dashboard_container",
"clickNums": 1
},
{
"keyName": "关于手机",
"containerResId": "com.android.settings:id/list",
"clickNums": 1
},
{
"keyName": "版本号",
"containerResId": "com.android.settings:id/list",
"clickNums": 7
},
{
"keyName": "",
"containerResId": "",
"clickNums": 0
},
{
"keyName": "开发人员选项",
"containerResId": "com.android.settings:id/list",
"clickNums": 1
},
{
"keyName": "USB调试",
"containerResId": "com.android.settings:id/list",
"clickNums": 1
},
{
"keyName": "确定",
"containerResId": "",
"clickNums": 1
}
]
}
}
}
由于在xml我们设置了监听界面的变化: android:accessibilityEventTypes= “typeAllMask|typeWindowStateChanged|typeNotificationStateChanged”,在打开设置界面后,由于手机界面的变化,所以会进入onAccessibilityEven的回调函数,在回调函数里,我们调用IAutoDebug接口的方法autoDebug()。
获取了界面窗口变化后,这个时候就要获取控件的节点。整个窗口的节点本质是个树结构,通过以下操作节点信息
1.4.1 获取窗口节点(根节点)
AccessibilityNodeInfo nodeInfo = getRootInActiveWindow();
1.4.2 获取指定子节点(控件节点)
List item = rootInActiveWindow.findAccessibilityNodeInfosByText(text); //根据关键字查找某元素
List list = rootInActiveWindow.findAccessibilityNodeInfosByViewId(listId); //根据resource id 查找容器元素;根据关键字查找出的元素在该容器元素中;
获取了节点信息之后,对空间节点进行模拟点击、长按等操作,AccessibilityNodeInfo类提供了performAction()方法让我们执行模拟操作,具体操作可看官方文档介绍,这里列举常用的操作:
accessibilityNodeInfo.performAction(AccessibilityNodeInfo.ACTION_CLICK);//模拟点击事件
accessibilityNodeInfo.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD);//触发容器元素的滚动事件
大致过程就是,1、首先通过Android sudio里Android Device Monitor中的选择设备并开启Dump View Hierarchy for UI Automator工具获取当前ui布局树状结构,能获取到每一个控件的id,是否可点击等信息。2、在获取到当前展示窗口后,根据关键字查找需要点击跳转到下一个界面的控件元素,通常是一个textviw。然后根据resource Id 查找滚动容器控件,通常是一个recycleview,再判断需要点击的textview控件是否在当前窗口的recycleview里。若存在,判断此textview控件是否可点击,若可以,触发模拟点击事件;若不可以,获取他的父元素,触发模拟点击事件。如果需要点击的textview控件是不在当前窗口的recycleview里,测触发recycleview的滚动事件,此时再重复步骤2
比如,我们在打开设置界面后,需要点击“系统导航、系统更细、关于手机、语言和输入法”这一textview控件进行下一个界面的跳转,此时我们需要判断这个item是否在手机屏幕当前窗口里,此时是不在的,所以我们要模拟滚动事件,并找到它。再模拟点击事件操作。此时就可以完成一个界面的跳转。
public class Common {
private static final String TAG ="Conmon" ;
public static class StepFlags {
public static boolean stepOneDone;
public static final int STEP_ONE = 0;
public static boolean stepTwoDone;
public static final int STEP_TWO = 1;
public static boolean stepThreeDone;
public static final int STEP_THREE = 2;
public static boolean stepFourDone;
public static final int STEP_FOUR = 3;
public static boolean stepFiveDone;
public static final int STEP_FIVE = 4;
public static boolean stepSixDone;
public static final int STEP_SIX = 5;
public static boolean stepSevenDone;
public static final int STEP_SEVEN = 6;
public static boolean stepEightDone;
public static final int STEP_EIGHT = 7;
public static boolean stepNineDone;
public static final int STEP_NINE = 8;
}
/**
* 滑动直到控件显示后,触发点击事件
*
* @param text 查找的控件显示的内容
* @param listId 滚动的容器id
* @param num 触发控件的点击次数
*/
public static void scroll2PositionClick(AccessibilityService service,String text, String listId, int num, int stepFlagIndex) {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR2) { //必须android4.3以上的版本
AccessibilityNodeInfo rootInActiveWindow = service.getRootInActiveWindow(); //获取当前展示的窗口
if (rootInActiveWindow != null) {
List item = rootInActiveWindow.findAccessibilityNodeInfosByText(text); //根据关键字查找某控件元素
List list = rootInActiveWindow.findAccessibilityNodeInfosByViewId(listId); //根据resource id 查找容器元素;判断关键字查找出的元素是否在该容器元素中;
if (item == null || item.size() == 0) { // 关键字元素不存在,则滚动容器元素
Log.d(TAG, "不存在 " + text);
if (list != null && list.size() > 0) {
try {
Thread.sleep(200); //隔200 ms 滚动一次
} catch (InterruptedException e) {
e.printStackTrace();
}
list.get(0).performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD); //触发容器元素的滚动事件
Log.d(TAG, "---- [ " + text + " ] 滚动查找中 ----");
}
} else {
Log.d(TAG, "存在 " + text);
AccessibilityNodeInfo clickableItem = item.get(0);
if (clickableItem.isEnabled() && clickableItem.isClickable()) { //关键字元素存在,则判断它是否可用,是否可点击
for (int i = 0; i < num; i++) {
clickableItem.performAction(AccessibilityNodeInfo.ACTION_CLICK); //触发点击 num 次
Log.d(TAG, "点击: " + text);
setStepsFlag(stepFlagIndex); //更新状态
}
} else {
AccessibilityNodeInfo parent = clickableItem.getParent(); //关键字元素不可用或者不可点击,则直接获取它的父元素
if (parent.isEnabled() && parent.isClickable()) {
for (int i = 0; i < num; i++) {
parent.performAction(AccessibilityNodeInfo.ACTION_CLICK); //触发点击 num 次
Log.d(TAG, "点击parent: " + text);
setStepsFlag(stepFlagIndex);
}
}
}
}
}
}
}
/**
* 更新每一步执行状态
* @param stepsFlagIndex
*/
public static void setStepsFlag(int stepsFlagIndex) {
switch (stepsFlagIndex) {
case StepFlags.STEP_ONE:
StepFlags.stepOneDone = true;
break;
case StepFlags.STEP_TWO:
StepFlags.stepTwoDone = true;
break;
case StepFlags.STEP_THREE:
StepFlags.stepThreeDone = true;
break;
case StepFlags.STEP_FOUR:
StepFlags.stepFourDone = true;
break;
case StepFlags.STEP_FIVE:
StepFlags.stepFiveDone = true;
break;
case StepFlags.STEP_SIX:
StepFlags.stepSixDone = true;
break;
case StepFlags.STEP_SEVEN:
StepFlags.stepSevenDone = true;
break;
case StepFlags.STEP_EIGHT:
StepFlags.stepEightDone=true;
default:
break;
}
}
/**
* 判断当前界面是否弹出toast
* @param msg
* @param event
* @return
*/
public static boolean isShowToast(String msg, AccessibilityEvent event) {
boolean rel = false;
//判断是否是通知事件
if (event.getEventType() != AccessibilityEvent.TYPE_NOTIFICATION_STATE_CHANGED) {
rel = false;
}
//获取事件具体信息
Parcelable parcelable = event.getParcelableData();
//如果是下拉通知栏消息
if (parcelable instanceof Notification) {
rel = false;
} else {
//其它通知信息,包括Toast
List texts = event.getText();
if (texts != null && texts.size() > 0) {
String toastMsg = (String) texts.get(0);
if (!TextUtils.isEmpty(toastMsg)) { // toast内容不为空
Log.d(TAG, "弹出toast: " + toastMsg);
if (toastMsg.contains(msg)) {
rel = true;
}
}
}
}
return rel;
}
}
后面界面的跳转逻辑大致相同,后面的步骤分别是第二步:点击“关于手机”控件;第三步,点击7次版本号;第四步:先判断已经返回到关于手机界面 (android 8.0 会弹出输入密码界面);第五步,打开 开发人员选项界面 ;第六步,打开 debug模式;第七步,点击确定 。 根据具体点击操作方法去一一实现就可以了。
相关的完整demo git地址和gif图演示后续会后续上传。