通过辅助模式获取点击的文字

转载注明出处:简书-十个雨点

在准备实现Bigbang的功能的时候,第一个需要解决的重大问题就是——如何像在锤子手机上一样方便的取词。好在有个同事做过辅助服务相关的功能,给我们提供了一个解决方案:通过辅助服务能够获取对View的点击和长按事件,并取得View的内容。

以此为起点,我们先实现了基于辅助服务的取词(适用于QQ、微信、支付宝等),然后加入了基于复制的取词(适用于浏览器、阅读器等),再又加入了全局复制功能(适用于系统设置等无法复制的页面),最后则是加上了截图OCR(适用于其他场景)。至此,基本上涵盖了所有取词的需要。

这些取词方式我都会一一介绍,这篇先介绍如何通过辅助模式取词,效果如下图所示:

通过辅助服务实现双击触发
通过辅助服务实现双击触发

也可以下载全能分词体验

1. 如何使用辅助服务

首先要在AndroidManifest.xml中声明:

<service
    android:name=".component.service.BigBangMonitorService"
    android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE"
    android:process=":monitor">
    <intent-filter>
        <action android:name="android.accessibilityservice.AccessibilityService" />
    </intent-filter>

    <meta-data
        android:name="android.accessibilityservice"
        android:resource="@xml/accessibility" />
</service>

然后在res/xml/文件夹下新建文件accessibility.xml,内容如下:

<?xml version="1.0" encoding="utf-8"?>
<accessibility-service
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:accessibilityEventTypes="typeViewClicked|typeViewLongClicked|typeWindowStateChanged"
    android:accessibilityFeedbackType="feedbackGeneric"
    android:accessibilityFlags="flagRetrieveInteractiveWindows"
    android:canRetrieveWindowContent="true"
    android:canRequestFilterKeyEvents ="true"
    android:notificationTimeout="10"
    android:packageNames="@null"
    android:description="@string/accessibility_des"
    android:settingsActivity="com.forfun.bigbang.SettingActivity"
/>

其中accessibilityEventTypes代表希望接收的事件类型,看名字就知道我们需要的是单击和长按,至于typeWindowStateChanged,则是在用于在切换activity时接收事件用的。
canRequestFilterKeyEvents是代表希望接收按键的事件类型,比如按音量键等,这里设置成true跟当前介绍的功能无关,而是为了用按键触发悬浮窗菜单,以后另开一篇介绍。
其他flag的含义可以参考API文档,这里就不展开说了。
最后创建BigBangMonitorService,本文用到的最重要的方法如下:

public class BigBangMonitorServiceextends AccessibilityService {   
    @Override
    public void onAccessibilityEvent(AccessibilityEvent event) {
        int type=event.getEventType();
        switch (type){
            case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED:               
            case TYPE_VIEW_CLICKED:
            case TYPE_VIEW_LONG_CLICKED:
                break;
        }
    }
    @Override
    protected void onServiceConnected() {
        super.onServiceConnected();
        setServiceInfo(mAccessibilityServiceInfo);
    }
}

其中onAccessibilityEvent很明显就是我们接收事件的回调方法。
而onServiceConnected则是在本service被设置成AccessibilityService 时的回调。什么意思呢?因为AccessibilityService 本身也是一个service,可以被start,bind,而只有当用户在辅助辅助的设置页面中开启了本程序的辅助服务时,才会被作为AccessibilityService使用,此时才会回调onServiceConnected,如下图。

开启辅助服务的界面

2. 如何获取和处理点击事件

从前面的xml中,我们就已经设置好了需要获取的事件:单击和长按。所以在用户进行操作的时候我们就会收到相应的回调,注意这里的回调是异步回调,也就是说,我们没有办法对点击事件进行任何干预,只是收到一份通知而已。
那我们怎样从这个通知中取得我们想要的信息呢?直接看代码吧:

private CharSequence mWindowClassName;
private String mCurrentPackage;
private int mCurrentType;
private Map<String,Integer> selections;//保存每个应用的包名对应的触发方式
private boolean onlyText = true;
public  int double_click_interval = 1000;
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
    int type=event.getEventType();
    switch (type){
        case AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED:
            mWindowClassName = event.getClassName();
            mCurrentPackage = event.getPackageName()==null?"":event.getPackageName().toString();
            Integer selectType=selections.get(mCurrentPackage);
            mCurrentType = selectType==null?TYPE_VIEW_NONE:(selectType+1);                
            break;
        case TYPE_VIEW_CLICKED:
        case TYPE_VIEW_LONG_CLICKED:
            getText(event);
            break;
    
}


private synchronized void getText(AccessibilityEvent event){        
    int type=getClickType(event);
    CharSequence className = event.getClassName();
    if (mWindowClassName==null){
        return;
    }
    if (mWindowClassName.toString().startsWith("com.forfan.bigbang")){
        //自己的应用不监控
        return;
    }
    if (mCurrentPackage.equals(event.getPackageName())){        
        if (type!=mCurrentType){
            //点击方式不匹配,直接返回
            return;
        }
    }else {
        //包名不匹配,直接返回
        return;
    }
    if (className==null || className.equals("android.widget.EditText")){
        //输入框不监控
        return;
    }
    if (onlyText){
        //onlyText方式下,只获取TextView的内容
        if (className==null || !className.equals("android.widget.TextView")){
            if (!hasShowTipToast){
                ToastUtil.show(R.string.toast_tip_content);
                hasShowTipToast=true;
            }
            return;
        }
    }
    AccessibilityNodeInfo info=event.getSource();
    if(info==null){
        return;
    }
    CharSequence txt=info.getText();
    if (TextUtils.isEmpty(txt) && !onlyText){
        //非onlyText方式下获取文字更多,但是可能并不是想要的文字
        //比如系统短信页面需要这样才能获取到内容。
        List<CharSequence> txts=event.getText();
        if (txts!=null) {
            StringBuilder sb=new StringBuilder();
            for (CharSequence t : txts) {
                sb.append(t);
            }
            txt=sb.toString();
        }
    }
    if (!TextUtils.isEmpty(txt)) {
        if (txt.length()<=2 ){
            //对于太短的词进行屏蔽,因为这些词往往是“发送”等功能按钮,其实应该根据不同的activity进行区分
            if (!hasShowTooShortToast) {
                ToastUtil.show(R.string.too_short_to_split);
                hasShowTooShortToast = true;
            }
            return;
        }
        //打开分词功能
        Intent intent=new Intent(this, BigBangActivity.class);
        intent.addFlags(intent.FLAG_ACTIVITY_NEW_TASK);
        intent.putExtra(BigBangActivity.TO_SPLIT_STR,txt.toString());
        startActivity(intent);
    }
}


private Method getSourceNodeIdMethod;
private long mLastSourceNodeId;
private long mLastClickTime;

private long getSourceNodeId(AccessibilityEvent event)  {
    //用于获取点击的View的id,用于检测双击操作
    if (getSourceNodeIdMethod==null) {
        Class<AccessibilityEvent> eventClass = AccessibilityEvent.class;
        try {
            getSourceNodeIdMethod = eventClass.getMethod("getSourceNodeId");
        } catch (NoSuchMethodException e) {
            e.printStackTrace();
        }
    }
    if (getSourceNodeIdMethod!=null) {
        try {
            return (long) getSourceNodeIdMethod.invoke(event);
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (InvocationTargetException e) {
            e.printStackTrace();
        }
    }
    return -1;
}

private int getClickType(AccessibilityEvent event){
    int type = event.getEventType();
    long time = event.getEventTime();
    long id=getSourceNodeId(event);
    if (type!=TYPE_VIEW_CLICKED){
        mLastClickTime=time;
        mLastSourceNodeId=-1;
        return type;
    }
    if (id==-1){
        mLastClickTime=time;
        mLastSourceNodeId=-1;
        return type;
    }
    if (type==TYPE_VIEW_CLICKED && time - mLastClickTime<= double_click_interval && id==mLastSourceNodeId){
        mLastClickTime=-1;
        mLastSourceNodeId=-1;
        return TYPE_VIEW_DOUBLD_CLICKED;
    }else {
        mLastClickTime=time;
        mLastSourceNodeId=id;
        return type;
    }
}

别看代码挺长,其实挺简单的,这么长的原因是实现了双击的检测(通过getClickType和getSourceNodeId实现的),只是对系统提供的API的一些灵活调用而已,没有什么难的地方。

3.不足之处

通过阅读上面的代码,不难看出辅助模式取词的两个局限:

  1. 点击的View必须是支持辅助服务的,也就是实现了sendAccessibilityEvent()、createAccessibilityNodeInfo()等方法的,而如果是我们自己绘制的View,都是无法使用的(除非非常有节操的程序员开发的)。不过好在大部分情况下,我们都是还是使用系统组件,或者是继承自系统组件。
  2. 只能获取到可点击的View的事件,对于不可点击的View则无能为力。特别是长按事件,必须设置了OnLongClickListener才能触发长按事件。这就导致了,在很多页面(比如系统设置页面)中,如果想监听单击或者双击,就会直接触发单击事件发生页面跳转,而长按则根本无法监听。

由于这两点原因,导致辅助模式取词最适合于QQ、微信、短信等以对话形式出现的文字。因此我们才又添加了各种其他的取词方式作为补充。

源码

完整代码可以参考Bigbang项目的BigBangMonitorService类。

ps:BigBangMonitorService中还包含了全局复制的功能和监听系统按键的功能,阅读的时候不要被干扰了,感兴趣的可以看——使用辅助服务实现全局复制使用辅助服务监听系统按键这两篇文章

我们还基于Xposed框架实现了文字点击触发和全局复制:
如何通过Xposed框架获取点击的文字
使用Xposed框架实现全局复制

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,802评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,109评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,683评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,458评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,452评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,505评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,901评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,550评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,763评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,556评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,629评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,330评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,898评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,897评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,140评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,807评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,339评论 2 342

推荐阅读更多精彩内容