使用辅助服务实现全局复制

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

通过辅助模式获取点击的文字的最后讲到的不足之处,促使我去实现更多的取词方式,复制方式选词显然是最直观最简单的方式。
但是咱们在用手机的时候经常会碰到这么一种情况,就是想复制某个应用内的某段文字却无法使用安卓默认的长按功能进行操作,为此,我参考全局复制这个应用,也实现了全局复制的功能,看似这是一个挺神奇、挺复杂的功能,其实只是对系统API的灵活调用。下面我就介绍一下,如何使用辅助服务实现全局复制。

先看看效果

全局复制触发
全局复制触发

也可以下载全能分词体验

1. 如何使用辅助服务

这部分和通过辅助模式获取点击的文字基本一样,但是需要注意的是xml中canRetrieveWindowContent必须设置成true,否则无法获取窗口内容,自然也无法获得文字数据。

2. 如何获取当前页面中文字以及位置

全局复制使用到了的系统API都是日常开发中不常用到的方法。
先介绍几个相关方法:

AccessibilityService的getRootInActiveWindow方法:
public AccessibilityNodeInfo getRootInActiveWindow()
用于获取当前窗口的根对象,其中AccessibilityNodeInfo是用来在辅助服务中表示的View的对象,包含文字、位置、子View等信息。

AccessibilityNodeInfo的getChild方法:
public AccessibilityNodeInfo getChild(int index) 
用于获取当前对象的子View的对应对象

AccessibilityNodeInfo的getBoundsInScreen方法:
public Rect getBoundsInScreen() 
用于获取当前对象代表的View在屏幕中的位置,返回值是一个Rect对象

AccessibilityNodeInfo的getText()方法:
用于获取当前对象代表的View中的文本

AccessibilityNodeInfo的getContentDescription方法:
用于获取当前对象代表的View中的内容的描述,在有些View中可以作为getText方法的补充

知道了这些方法的功能,要获得当前页面中的文字及其位置就很简单了,直接看代码:
首先,我们设计一种数据结构,用于记录文字和位置

public class CopyNode implements Parcelable {
    public static Creator<CopyNode> CREATOR = new Creator<CopyNode>() {

        @Override
        public CopyNode createFromParcel(Parcel source) {
            return new CopyNode(source);
        }

        @Override
        public CopyNode[] newArray(int size) {
            return new CopyNode[size];
        }
    };

    private Rect bound;
    private String content;

    public CopyNode(Rect var1, String var2) {
        this.bound = var1;
        this.content = var2;
    }

    public CopyNode(Parcel var1) {
        this.bound = new Rect(var1.readInt(), var1.readInt(), var1.readInt(), var1.readInt());
        this.content = var1.readString();
    }

    public long caculateSize() {
        return (long)(this.bound.width() * this.bound.height());
    }

    public Rect getBound() {
        return this.bound;
    }

    public String getContent() {
        return this.content;
    }

    public int describeContents() {
        return 0;
    }

    public void writeToParcel(Parcel var1, int var2) {
        var1.writeInt(this.bound.left);
        var1.writeInt(this.bound.top);
        var1.writeInt(this.bound.right);
        var1.writeInt(this.bound.bottom);
        var1.writeString(this.content);
    }

    @Override
    public String toString() {
        return "CopyNode{" +
                "bound=" + bound +
                ", content='" + content + '\'' +
                '}';
    }
}

然后再看如何获取数据


private int retryTimes = 0;

@RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN)
private void UniversalCopy() {
    boolean isSuccess=false;
    labelOut: {
        AccessibilityNodeInfo rootInActiveWindow = this.getRootInActiveWindow();
        if(retryTimes < 10) {
            String packageName;
            if(rootInActiveWindow != null) {
                packageName = String.valueOf(rootInActiveWindow.getPackageName());
            } else {
                packageName = null;
            }

            if(rootInActiveWindow == null || packageName != null && packageName.contains("com.android.systemui")) {
                //如果通知栏没有收起来,则延迟进行
                ++retryTimes;
                handler.postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        UniversalCopy();
                    }
                }, 100);
                return;
            }

            //获取屏幕高宽,用于遍历数据时确定边界。
            WindowManager windowManager = (WindowManager)this.getSystemService(Context.WINDOW_SERVICE);
            DisplayMetrics displayMetrics = new DisplayMetrics();
            windowManager.getDefaultDisplay().getMetrics(displayMetrics);
            int heightPixels = displayMetrics.heightPixels;
            int widthPixels = displayMetrics.widthPixels;

            ArrayList nodeList = traverseNode(new AccessibilityNodeInfoCompat(rootInActiveWindow), widthPixels, heightPixels);
            if(nodeList.size() > 0) {
                Intent intent = new Intent(this, CopyActivity.class);
                intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                intent.putParcelableArrayListExtra("copy_nodes", nodeList);
                intent.putExtra("source_package", packageName);
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
                    this.startActivity(intent, ActivityOptions.makeCustomAnimation(this.getBaseContext(), android.R.anim.fade_in, android.R.anim.fade_out).toBundle());
                }else {
                    startActivity(intent);
                }
                isSuccess = true;
                break labelOut;
            }
        }

        isSuccess = false;
    }

    if(!isSuccess) {
        if (!BigBangMonitorService.isAccessibilitySettingsOn(this)){
            ToastUtil.show(R.string.error_in_permission);
        }else {
            ToastUtil.show(R.string.error_in_copy);
        }

    }

    retryTimes = 0;
}

private ArrayList<CopyNode> traverseNode(AccessibilityNodeInfoCompat nodeInfo, int width, int height) {
    ArrayList<CopyNode> nodeList = new ArrayList();
    if(nodeInfo != null && nodeInfo.getInfo() != null) {
        nodeInfo.refresh();

        for(int i = 0; i < nodeInfo.getChildCount(); ++i) {
            //递归遍历nodeInfo
            nodeList.addAll(traverseNode(nodeInfo.getChild(i), width, height));
        }

        if(nodeInfo.getClassName() != null && nodeInfo.getClassName().equals("android.webkit.WebView")) {
            return nodeList;
        } else {
            String content = null;
            String description = content;
            if(nodeInfo.getContentDescription() != null) {
                description = content;
                if(!"".equals(nodeInfo.getContentDescription())) {
                    description = nodeInfo.getContentDescription().toString();
                }
            }

            content = description;
            if(nodeInfo.getText() != null) {
                content = description;
                if(!"".equals(nodeInfo.getText())) {
                    content = nodeInfo.getText().toString();
                }
            }

            if(content != null) {
                Rect outBounds = new Rect();
                nodeInfo.getBoundsInScreen(outBounds);
                if(checkBound(outBounds, width, height)) {
                    nodeList.add(new CopyNode(outBounds, content));
                }
            }

            return nodeList;
        }
    } else {
        return nodeList;
    }
}


private boolean checkBound(Rect var1, int var2, int var3) {
    //检测边界是否符合规范
    return var1.bottom >= 0 && var1.right >= 0 && var1.top <= var3 && var1.left <= var2;
}

代码不难,就是通过递归的方式,获取所有在屏幕范围内的文字及其位置。

3. 让用户选择要复制的文字

获取当前窗口中的文字及其位置是在Service中完成的,而让用户进行选择,则必须切换到Activity中进行展示和交互。在UniversalCopy()方法的最后,已经将获得的ArrayList<CopyNode>传递给Activity了,在Activity中取出数据并添加到显示界面中:

protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    ...
    Bundle extras = getIntent().getExtras();
    if (extras==null){
        finish();
        return;
    }
    extras.setClassLoader(CopyNode.class.getClassLoader());

    String packageName = extras.getString("source_package");
    height = statusBarHeight;

    ArrayList nodesList = extras.getParcelableArrayList("copy_nodes");
    if(nodesList != null && nodesList.size() > 0) {
        CopyNode[] nodes = (CopyNode[])nodesList.toArray(new CopyNode[0]);
        Arrays.sort(nodes, new CopyNodeComparator());
        for(int i  = 0; i < nodes.length; ++i) {
            (new CopyNodeView(this, nodes[i])).addToFrameLayout(copyNodeViewContainer, height);
        }
    } else {
        ToastUtil.show(R.string.error_in_copy);
        finish();
    }
    ...
}
public class CopyNodeComparator implements Comparator<CopyNode> {
    //按面积从大到小排序
    public int compare(CopyNode o1, CopyNode o2) {
        long o1Size = o1.caculateSize();
        long o2Size = o2.caculateSize();
        return o1Size < o2Size?-1:(o1Size == o2Size?0:1);
    }
}

为什么CopyNodeComparator 要按照从大到小的顺序进行排列呢,因为如果面积大的View放在下面,就会把小的View遮盖住,小View就无法被点击到了。
其中CopyNodeView是用来展示文本的位置View:


public class CopyNodeView extends View {
    private Rect bound;
    private String content;
    private boolean selected = false;

    ...
    public CopyNodeView(Context context, CopyNode copyNode) {
        super(context);
        this.bound = copyNode.getBound();
        this.content = copyNode.getContent();
    }

    public void addToFrameLayout(FrameLayout frameLayout, int height) {
        LayoutParams var3 = new LayoutParams(this.bound.width(), this.bound.height());
        var3.leftMargin = this.bound.left;
        var3.topMargin = Math.max(0, this.bound.top - height);
        var3.width = this.bound.width();
        var3.height = this.bound.height();
        frameLayout.addView(this, 0, var3);
    }
    ...
}

除了这些核心代码以外,再设置好CopyNodeView的点击事件、菜单项的响应等其他杂七杂八的工作以后,全局复制功能就完成了。

源码

完整代码可以参考Bigbang项目的BigBangMonitorService、CopyActivity、CopyNode、CopyNodeView等类。

ps:BigBangMonitorService中还包含了监听系统按键功能和监听点击的文字的功能,阅读的时候不要被干扰了,感兴趣的可以看——通过辅助模式获取点击的文字使用辅助服务监听系统按键这两篇文章

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

推荐阅读更多精彩内容