个人博客地址:https://blog.yookingh.cn
该文章地址:https://blog.yookingh.cn/dev/200424-accessibilityService.html
前言
公司项目需求:为App提供一键优化功能(一键获取相关所有权限),确保App在优化过后能够常驻手机,为用户提供服务。——商户类App,用户非主动关闭时常驻手机合情合理
阅读源码
-
getWindows()和getRootInActiveWindow()
List<AccessibilityNodeInfo> getWindows()
即:获取当前页面所有的节点——包括顶部状态栏、底部系统按钮等各自的root节点。AccessibilityNodeInfo getRootInActiveWindow()
即:获取当前活动页面(Activity)中的root节点。getRootInActiveWindow()
方法在页面未加载完成时可能为null,而getWindows()
一直有值。 -
AccessibilityNodeInfo
AccessibllityNodeInfo
类是含有链表结构的类(应该是这样称呼吧)。譬如:
class AccessibilityNodeInfo implements Parcelable{ ... public AccessibilityNodeInfo getParent() { throw new RuntimeException("Stub!"); } public AccessibilityNodeInfo getChild(int index) { throw new RuntimeException("Stub!"); } ... }
AccessibllityNodeInfo
页包含了对View的操作(仅包含有用到的部分):class AccessibilityNodeInfo implements Parcelable{ ... //点击事件 public static final int ACTION_CLICK = 16; //朝后滚动 performAction返回false即滑动到顶部 public static final int ACTION_SCROLL_BACKWARD = 8192; //朝前滚动 performAction返回false即滑动到底部 public static final int ACTION_SCROLL_FORWARD = 4096; ... public boolean performAction(int action) { throw new RuntimeException("Stub!"); } public boolean performAction(int action, Bundle arguments) { throw new RuntimeException("Stub!"); } ... }
-
全局事件
public abstract class AccessibilityService extends Service { ... //返回键 public static final int GLOBAL_ACTION_BACK = 1; //Home键 public static final int GLOBAL_ACTION_HOME = 2; //菜单键 public static final int GLOBAL_ACTION_RECENTS = 3; ... public final boolean performGlobalAction(int action) { throw new RuntimeException("Stub!"); } ... }
封装工具
了解完AccessibilityService后,可以对Accessibility进行简单的封装,方便使用。
-
获取root节点
由上可知,在寻找目标的root节点这方面,
getRootInActiveWindow()
会根据有优势,所以/** * @return 获取root节点 */ private AccessibilityNodeInfo findRoot() { return getRootInActiveWindow(); }
-
获取所有当前Activity可见节点
遍历多叉树
/** * 遍历多叉树 全遍历 */ private List<AccessibilityNodeInfo> iteratorTree(AccessibilityNodeInfo parent) { List<AccessibilityNodeInfo> childList = new ArrayList<>(); if (parent == null) return childList; for (int i = 0; i < parent.getChildCount(); i++) { AccessibilityNodeInfo child = parent.getChild(i); childList.add(child); if (child.getChildCount() > 0) { //利用递归方法 childList.addAll(iteratorTree(child)); } } return childList; }
获取全部可见节点
/** * 遍历当前页面所有节点 */ private List<AccessibilityNodeInfo> findAllView() { AccessibilityNodeInfo parent = findRoot(); List<AccessibilityNodeInfo> list = new ArrayList<>(); list.add(parent); list.addAll(iteratorTree(parent)); return list; }
-
查找第一个匹配节点
马后炮:在Activity中,可能存在
ListView
、RecyclerView
等滑动控件使得目标节点出现超出屏幕情况,所以我们应当先在当前可见区域内查找是否有目标节点,如果没有,则向下滚动,再次查找目标节点——直至滑动到屏幕底部。为防止当前界面并非从最顶部开始向下滑动,故应该再向上滑动到顶部一次。查找滑动组件代码如下:
private static final List<String> SCROLL_CLASS_NAME_ARRAY = Arrays.asList( "android.widget.ListView", "android.support.v7.widget.RecyclerView", "androidx.recyclerview.widget.RecyclerView" ); /** * 查找可滑动的view */ private List<AccessibilityNodeInfo> findCanScrollView() { List<AccessibilityNodeInfo> viewList = findAllView(); List<AccessibilityNodeInfo> scrollViewList = new ArrayList<>(); for (AccessibilityNodeInfo info : viewList) { if (info != null) //info.isScrollable判断是否为可滚动控件 if (info.isScrollable()) { //其中滚动控件还包含了spinner、viewpage等,而这些控件目前业务中并没有需要(且会影响业务内容),因此做了个限定 for (String scrollClassName : SCROLL_CLASS_NAME_ARRAY) { if (scrollClassName.equals(info.getClassName().toString())) { scrollViewList.add(info); setLog("------------------------------------------------------------"); setLog("查询到可滑动组件" + info.getViewIdResourceName()); setLog("查询到可滑动组件" + info.getClassName()); setLog("------------------------------------------------------------"); } } } } return scrollViewList; }
遍历多叉树,根据条件查寻当前可见部分的节点
/** * 遍历多叉树 查询方法 * 找到控件会抛出异常,异常中包含目标数据 */ private void findIteratorTree(AccessibilityNodeInfo parent, String text) throws StopIteratorException { if (parent != null) for (int i = 0; i < parent.getChildCount(); i++) { AccessibilityNodeInfo child = parent.getChild(i); if (child != null) if (multiCriteriaMatching(child, text)) { throw new StopIteratorException(child); } else if (child.getChildCount() > 0) { findIteratorTree(child, text); } } } /** * 自定义报错 用于中断递归 */ private static class StopIteratorException extends RuntimeException { private AccessibilityNodeInfo info; private StopIteratorException(AccessibilityNodeInfo info) { this.info = info; } private AccessibilityNodeInfo getInfo() { return info; } } /** * 多条件匹配 multiCriteriaMatching * * @param info 目前包含getViewIdResourceName、getClassName、getText * @param text 匹配词 * @return 返回匹配结果 */ private boolean multiCriteriaMatching(AccessibilityNodeInfo info, String text) { return text.equals(info.getViewIdResourceName()) || text.equals(info.getClassName() == null ? null : info.getClassName().toString()) || text.equals(info.getText() == null ? null : info.getText().toString()); }
查找第一个匹配节点代码如下:
/** * @param text 要查找目标组件的标识 (className/resourceId/text) * @return 返回查找到的目标组件 null则表示查找失败 */ private AccessibilityNodeInfo findFirst(String text) { AccessibilityNodeInfo info = null; try { findIteratorTree(findRoot(), text); int searchNum = 0; boolean scroll = true, isForward = true; List<AccessibilityNodeInfo> scrollViewList = new ArrayList<>(); while (scroll) {//利用关键字scroll和Exception跳出循环 //找不到控件,向下滑动继续找 if (scrollViewList.size() == 0) { if (searchNum < MAX_SEARCH_NUM) { ++searchNum; scrollViewList.addAll(findCanScrollView()); } else { //找不到可滑动组件 setLog("找不到可滑动组件,结束循环体,查找次数:" + MAX_SEARCH_NUM); scroll = false; } } if (scrollViewList.size() != 0) { //如果查找到滑动组件,则滑动滑动组件 //下、上各滑动一次,确保找到控件 for (AccessibilityNodeInfo scrollViewInfo : scrollViewList) { if (isForward) { if (!scrollViewInfo.performAction(AccessibilityNodeInfo.ACTION_SCROLL_FORWARD)) { isForward = false;//滑动到底部,修改滑动状态,再滑一次 } } else { if (!scrollViewInfo.performAction(AccessibilityNodeInfo.ACTION_SCROLL_BACKWARD)) { scroll = false; } } } } //滑动后 睡眠 之后查找组件 SystemClock.sleep(TIME_SLEEP); findIteratorTree(findRoot(), text); } } catch (StopIteratorException e) { info = e.getInfo(); if (info != null) { setLog("------------------------------------------------------------"); setLog("查询到目标组件id:" + info.getViewIdResourceName()); setLog("查询到目标组件text:" + info.getText()); setLog("查询到目标组件clsName:" + info.getClassName()); setLog("------------------------------------------------------------"); } } return info; }
补充说明:(可不看)在查看AccessibilityNodeInfo类中有如下方法
class AccessibilityNodeInfo implements Parcelable{ ... public List<AccessibilityNodeInfo> findAccessibilityNodeInfosByText(String text) { throw new RuntimeException("Stub!"); } public List<AccessibilityNodeInfo> findAccessibilityNodeInfosByViewId(String viewId) { throw new RuntimeException("Stub!"); } ... }
由此联想到
findIteratorTree(AccessibilityNodeInfo parent, String text)
或许可以改成:(以下代码未尝试,只是临时想到的改进方案)private AccessibilityNodeInfo findIteratorTree(AccessibilityNodeInfo parent, String text){ List<AccessibilityNodeInfo> infoList = new ArrayList<>(); infoList.addAll(parent.findAccessibilityNodeInfosByText(text)); if(info.size() == 0){ infoList.addAll(parent.findAccessibilityNodeInfosByViewId(text)); } AccessibilityNodeInfo info = null; if(infoList.size() > 0){ info = infoList.get(0); } return info; }
-
点击事件
找到目标节点后,自然是要对目标节点进行操作——这里仅封装点击事件
/** * 点击事件 * * @param info 要点击的目标View * @return 点击结果(成功/失败) */ private boolean clickView(AccessibilityNodeInfo info) { if (info != null) if (info.isClickable()) { info.performAction(AccessibilityNodeInfo.ACTION_CLICK); return true; } else { AccessibilityNodeInfo parent = info.getParent(); if (parent != null) { boolean b = clickView(parent); parent.recycle(); return b; } } return false; }
将查询节点与点击节点组合起来就是:
/** * 点击事件(点击本身) * * @param text 要点击的目标节点标识 * @return 点击结果(true/false) */ public boolean clickFirstView(String text) { AccessibilityNodeInfo info = findFirst(text); if (info == null) { setLog(text + "找不到匹配的控件"); return false; } return clickView(info); }
那如果是CheckBox之类的点击事件(如下图)应该怎么处理?
首先转化为查询该CheckBox的同级节点,再通过该同级节点反查父节点,之后再查询父节点中的子节点中包含
isCheckable
的节点:private AccessibilityNodeInfo findBrotherCheckBox(AccessibilityNodeInfo child) { AccessibilityNodeInfo checkBoxInfo = null; AccessibilityNodeInfo parent = child.getParent(); if (parent != null) { List<AccessibilityNodeInfo> infoList = iteratorTree(parent); setLog("------------------------------------------------------------"); for (AccessibilityNodeInfo info : infoList) { if (info.isCheckable()) { checkBoxInfo = info; setLog("------------------------------------------------------------"); setLog("查询到目标组件id:" + info.getViewIdResourceName()); setLog("查询到目标组件text:" + info.getText()); setLog("查询到目标组件clsName:" + info.getClassName()); setLog("------------------------------------------------------------"); break; } } setLog("------------------------------------------------------------"); } return checkBoxInfo; }
CheckBox节点中有
isChecked
字段用于判断是( true )否( false )选中,因此有:/** * checkBox的点击事件(带选中状态) * * @param brotherText 要点击目标的兄弟节点标识 * @param setChecked 将checkBox的状态修改为true/false * @return 返回点击事件触发结果(true/false) */ public boolean clickCheckBox(String brotherText, boolean setChecked) { AccessibilityNodeInfo brotherInfo = findFirst(brotherText); if (brotherInfo == null) { setLog(brotherText + "找不到匹配的控件"); return false; } AccessibilityNodeInfo checkBoxInfo = findBrotherCheckBox(brotherInfo); if (checkBoxInfo == null) { setLog(brotherText + "找不到匹配的checkBox控件"); return false; } if (checkBoxInfo.isChecked() != setChecked) { return clickView(checkBoxInfo); } else { setLog("checkBox控件为目标状态,不点击"); } return false; }
-
补充
静态变量、日志与返回
//静态变量 private static final int TIME_SLEEP = 300;//单位毫秒 private static final int MAX_SEARCH_NUM = 20;//查找循环体次数 //日志 protected abstract boolean isDebugger(); private void setLog(String msg) { if (isDebugger()) { String tag = "AManagerLog"; Log.i(tag, msg); } } //系统返回键 public void goBack() { performGlobalAction(GLOBAL_ACTION_BACK); }
以上就是整个工具的封装了
如何使用
-
辅助功能触发点的设置
辅助功能顾名思义,就是辅助用户使用当前App,甚至可以延伸为使用当前手机。比如:
- 监测App接收到的通知消息,根据消息对手机进行辅助操作
- 监测文件下载状态,根据下载状态进行自动安装、打开等操作
- 当用户点击了某个位置时,根据点击的位置做出反馈
- 为用户自动开启相关权限等
- ...
这时候:监测到App接收通知消息和监测到文件下载状态即为触发点;用户点击事件即为出发点;而开启权限,则可以是在辅助功能权限开启的时候。
触发点为辅助功能权限开启:辅助功能权限开启的时候会调用初始化Service方法:
onServiceConnected()
,这里可以作为一个监测点,利用sendBroadcast(Intent intent)
将服务启动的消息广播出去。而广播接收器就是真实的触发点。触发点为某种状态:
if(该状态可回调){ if(辅助功能权限开启){ 直接执行辅助功能 }else{ 弹出无障碍设置页面,引导用户开启辅助功能权限-触发点转为辅助功能权限开启时 } }else{ 监测状态改变引发的界面变化 ... }
触发点为用户点击事件:如果点击的是自己的App,在点击事件触发辅助功能即可。如果点击的不为自己的App,可以在
onAccessibilityEvent(AccessibilityEvent event)
触发。 -
辅助功能执行
辅助功能是一个富有想象力的功能,TA的执行能力取决于脑洞。比如模拟用户点击,让用户的操作更快捷(比如自动抢红包、一键连招等)或者取代繁琐操作(自动下载并安装等)甚至也可以是智能分词翻译(锤子BigBang)等等...
这里以“取代繁琐操作”为例——征得用户同意后,替用户勾选一些无法直接申请的权限:
任务:
AssignmentEntity
:public class AssignmentEntity { private Queue<StepEntity> queue = new LinkedList(); //步骤队列 private String name; //任务名称 public AssignmentEntity() { } public String getName() { return this.name; } public AssignmentEntity setName(String name) { this.name = name; return this; } public Queue<StepEntity> getQueue() { return this.queue; } /** * 添加步骤 **/ public AssignmentEntity addStep(StepEntity entity) { this.queue.add(entity); return this; } }
具体步骤:
StepEntity
:public class StepEntity { public static final int TYPE_INTENT = 0; //页面跳转 public static final int TYPE_CLICK = 1; //点击事件 public static final int TYPE_CHECKED = 2; //选中事件 public static final int TYPE_BACK = 3; //返回事件 private String name; //目标控件名称 private int type; //事件类型 private boolean checked; //选中事件-true:选中-false:反选 private Intent intent; //跳转事件-Intent public StepEntity() { } public String getName() { return this.name; } public void setName(String name) { this.name = name; } public int getType() { return this.type; } public void setType(int type) { this.type = type; } public Intent getIntent() { return this.intent; } public void setIntent(Intent intent) { this.intent = intent; } public boolean isChecked() { return this.checked; } public void setChecked(boolean checked) { this.checked = checked; } }
步骤辅助类
StepHelper
:public class StepHelper { public StepHelper() { } /** * 跳转事件 **/ public static StepEntity intentStep(Intent intent) { return getStep("页面跳转", 0, intent, (Boolean)null); } /** * 点击事件 **/ public static StepEntity clickStep(String text) { return getStep(text, 1, (Intent)null, (Boolean)null); } /** * 选中事件 **/ public static StepEntity checkStep(String text, boolean setChecked) { return getStep(text, 2, (Intent)null, setChecked); } /** * 返回事件 **/ public static StepEntity backStep() { return getStep("返回上一页", 3, (Intent)null, (Boolean)null); } private static StepEntity getStep(String name, int type, @Nullable Intent intent, @Nullable Boolean setChecked) { StepEntity step = new StepEntity(); step.setName(name); step.setType(type); if (0 == type) { if (intent == null) { throw new IllegalArgumentException("代码错误,Type为TYPE_INTENT时Intent参数不能为空"); } step.setIntent(intent); } else if (2 == type) { if (setChecked == null) { throw new IllegalArgumentException("代码错误,Type为TYPE_CHECKED时setChecked参数不能为空"); } boolean b = setChecked; step.setChecked(b); } return step; } }
添加任务示例:
public class AssignmentFactory { ... public static Queue<AssignmentEntity> create() { Queue<AssignmentEntity> queue = new LinkedList<>(); if (RomUtils.isHuawei()) { queue.addAll(HuaweiFactory.create()); } else if (RomUtils.isMiui()) { queue.addAll(XiaomiFactory.create()); } else if (RomUtils.isOppo()) { } else if (RomUtils.isMeizu()) { queue.addAll(MeizuFactory.create()); } return queue; } ... static class HuaweiFactory { private static int getVersion() { int version = -1; try { String systemProperty = RomUtils.getSystemProperty("ro.build.version.emui"); if (systemProperty != null) { String trim = systemProperty.replace("EmotionUI", "").replace("_", "").trim(); if (trim.contains(".")) { trim = trim.substring(0, trim.indexOf(".")); } version = Integer.valueOf(trim); } } catch (Exception ignored) { } return version; } static Queue<AssignmentEntity> create() { L.i("手机版本号:" + getVersion()); //目前适配10 Queue<AssignmentEntity> queue = new LinkedList<>(); queue.add(ignoreBatteryOptimization()); queue.addAll(newMessageNotification()); queue.add(selfStarting()); return queue; } //忽略电池优化 private static AssignmentEntity ignoreBatteryOptimization() { AssignmentEntity assignment = new AssignmentEntity(); assignment.setName("忽略电池优化"); assignment.addStep(StepHelper.intentStep(IntentUtils.hightPowerManger())) .addStep(StepHelper.clickStep("不允许")) .addStep(StepHelper.clickStep("所有应用")) .addStep(StepHelper.clickStep(BaseApplication.getInstance().getAppName())) .addStep(StepHelper.clickStep("不允许")) .addStep(StepHelper.clickStep("确定")) .addStep(StepHelper.backStep()); return assignment; } //新消息通知 private static Queue<AssignmentEntity> newMessageNotification() { AssignmentEntity ae1 = new AssignmentEntity(); ae1.setName("新消息通知"); AssignmentEntity ae2 = new AssignmentEntity(); ae2.setName("通知锁屏显示"); ae1.addStep(StepHelper.intentStep(IntentUtils.huaweiNotification())) .addStep(StepHelper.clickStep(BaseApplication.getInstance().getAppName())) .addStep(StepHelper.checkStep("允许通知", true)) .addStep(StepHelper.backStep()); ae2.addStep(StepHelper.clickStep("锁屏通知")) .addStep(StepHelper.clickStep("显示所有通知")) .addStep(StepHelper.clickStep("更多通知设置")) .addStep(StepHelper.checkStep("通知亮屏提示", true)) .addStep(StepHelper.backStep()) .addStep(StepHelper.backStep()); Queue<AssignmentEntity> queue = new LinkedList<>(); queue.add(ae1); queue.add(ae2); return queue; } //自启动 private static AssignmentEntity selfStarting() { AssignmentEntity assignment = new AssignmentEntity(); assignment.setName("自启动"); assignment.addStep(StepHelper.intentStep(IntentUtils.huaweiStartupNormalApp())) .addStep(StepHelper.checkStep(BaseApplication.getInstance().getAppName(), true)) .addStep(StepHelper.checkStep(BaseApplication.getInstance().getAppName(), false)) .addStep(StepHelper.checkStep("允许自启动", true)) .addStep(StepHelper.checkStep("允许关联启动", true)) .addStep(StepHelper.checkStep("允许后台活动", true)) .addStep(StepHelper.clickStep("确定")) .addStep(StepHelper.backStep()); return assignment; } } ... }
执行任务示例:
public class AssignmentFactory { ... public static void run(Activity activity, Queue<AssignmentEntity> queue) { L.i("队列开始执行"); int assignmentSize = queue.size(); CompositeDisposable co = new CompositeDisposable(); Disposable subscribe = Observable.create( (ObservableOnSubscribe<AssignmentEntity>) emitter -> { int progressSize = 0; for (int i = 0; i < assignmentSize; i++) { AssignmentEntity entity = queue.poll(); if (entity != null) { Queue<StepEntity> stepQueue = entity.getQueue(); if (stepQueue != null) { int stepSize = stepQueue.size(); for (int j = 0; j < stepSize; j++) { progressSize++; emitter.onNext(entity); } } } } //FloatWindowView.getInstance().progressBar.setMax(progressSize); emitter.onComplete(); }) .subscribeOn(Schedulers.io())//执行在io线程 .observeOn(Schedulers.io())//回调在io线程 //主线程阻塞将无法更新ui AndroidSchedulers.mainThread() .subscribe( assignment -> { //FloatWindowView.getInstance().progressAdd(); //L.i("当前进度:" + assignment.getName() + "===" + FloatWindowView.getInstance().getProgress()); poll(activity, assignment.getQueue().poll()); }, throwable -> L.e("", throwable), () -> //FloatWindowView.getInstance().stopFloatWindow() ); co.add(subscribe); } private static void poll(Activity activity, StepEntity poll) { if (poll == null) return; switch (poll.getType()) { case StepEntity.TYPE_INTENT://跳转事件 activity.startActivity(poll.getIntent()); break; case StepEntity.TYPE_CLICK://点击事件 MyAccessibilityService.getInstance().clickFirstView(poll.getName()); break; case StepEntity.TYPE_CHECKED://选中事件 MyAccessibilityService.getInstance().clickCheckBox(poll.getName(), poll.isChecked()); break; case StepEntity.TYPE_BACK://返回事件 MyAccessibilityService.getInstance().goBack(); break; } SystemClock.sleep(POST_DELAY_MILLIS); } ... }
链接
完整demo:Github