如何通过Xposed框架获取点击的文字

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

简介

我们模仿锤子制作的Bigbang应用,通过辅助服务基本上实现了在微信、QQ等聊天应用中快速取词,在其他应用中也能用其他方式补足。虽然由于辅助服务的限制,无法做到在锤子手机中那么方便,但也还算不错了。最遗憾的是辅助服务在一些系统上(小米、华为等)会容易被自动关闭,导致用户经常抱怨,这是因为这些系统中清理后台的时候,会把应用标记为STOPPED,也就是停止使用的,所以导致了一些权限被回收。后来有用户建议我们使用xposed框架来实现取词,于是我就借此机会学习了一下鼎鼎大名的xposed框架。这篇就是关于如何使用xposed框架实现在所以应用中通过点击获取文字的。

Xposed 是什么?

Xposed是一个框架,它可以在不修改APK的情况下影响程序运行或修改系统服务,基于它可以制作出许多功能强大的模块,且在功能不冲突的情况下同时运作。这些模块本身也是以APK的形式提供,可以实现五花八门的功能,比如全自动抢红包、模拟定位、将微信改成材料设计风格等等。
但是Xposed并不是所有手机都能运行的,目前支持7.0以下的手机系统,而且有些厂商由于修改了系统的虚拟机实现,所以也可能造成Xposed框架不兼容。
对于已经安装了Xposed框架的手机,其插件的执行是不需要root权限的,但是,但是,但是普通的手机刷入Xposed框架需要root。为什么需要root权限呢?首先必须了解一下它的工作原理:

Xposed的原理简介

Android 系统在启动时,有一个名字叫做“Zygote”的进程,它是android 运行时环境的核心,从它的名字(中文含义——受精卵)就能看出其重要性,所有的其他app进程都是fork这个 Zygote进程产生的。这个Zygote是如何启动呢?答案是在手机启动时,执行了/init.rc脚本,最后还会执行/system/bin/app_process(加载需要的类以及关联初始方法)。这里就是Xposed框架执行的地方,当你安装了Xposed框架,一个 extended app_process被拷贝到来了 /system/bin,然后这个'extended startup process' 就会把 XposedBridge.jar加载到运行时环境。这样我们就可以在虚拟机启动之前,甚至是在Zygote的main方法被执行之前做一些爱做的事(捂脸,其实就是加载插件)。此时我们的插件被执行,就是Zygote进程的一部分,所以可以直接获取到应用的上下文Context,然后做很多超出想象的事情——对于任何一个app ,我们都可以hook或者替换掉其中的类或方法或对象。其实我一直不太明白应该怎么解释hook,有种只可意会不可言传的感觉,不过你看完这篇估计就懂了。
Xposed很厉害有木有!
知道这些以后,我们便可以开发自己的插件,官方教程点这里Xposed官方教程

创建Xposed模块

首先需要知道,Xposed模块是以APK的格式提供的,本身也是需要安装到手机上的,也像普通应用一样可以启动,只是因为APK中包含了一些声明,被Xposed框架检测到了,所以同时也可以以Xposed模块的方式来进行hook操作。那么这些声明是什么呢?
在AndroidManifest.xml中添加下面的声明,meta-data中的内容分别用于声明是否为插件,插件的描述和兼容的最低Xposed版本。

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="com.shang.xposed">

    <application
     >
        <activity android:name=".setting.XposedAppManagerActivity"
            android:theme="@style/BaseAppTheme">

        </activity>

        <meta-data
            android:name="xposedmodule"
            android:value="true" />
        <meta-data
            android:name="xposeddescription"
            android:value="支持在任意APP中点击文字进行分词,可以对每个应用选择单击、双击或者长按。建议在设置中将【点击悬浮球触发BigBang】打开,以减少误触发。" />
        <meta-data
            android:name="xposedminversion"
            android:value="30" />
    </application>

</manifest>

在工程的assets目录下新建文件xposed_init,内容为:

com.shang.xposed.XposedBigBang

很明显这是一个类的全限定名,这个类就是进行hook操作的类

在build.gradle中添加依赖:

dependencies {
    provided 'de.robv.android.xposed:api:82'
}

Xposed框架是预先安装到你的手机中的,所以我们只需要以provided的方式依赖就行了,82是版本号,是本文写作时的最新版本,该用什么版本可以看这里。一般来说xposedminversion的值应该与这里相等,但是如果你能保证你使用的API并不是新版本加入的,则可以将xposedminversion写低一些。

创建类com.shang.xposed.XposedBigBang,内容如下:

package com.shang.xposed;

public class XposedBigBang implements IXposedHookLoadPackage {
    @Override
    public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable {
    }
}

关闭Instant Run (File -> Settings -> Build, Execution, Deployment -> Instant Run)

完成以上操作以后,安装完程序,你就会在Xposed installer中看到你安装的应用,如下图:

Xposed installer的模块列表

勾选以后重启就可以生效了。当然目前什么功能都没有实现,所以还是先别重启了,继续看。

如何实现点击文字触发分词

既然前面已经说过了,Xposed框架可以hook方法,所以很直觉就会想到:只要将TextView的OnClickListener替换成我们的,不就能拿到点击事件了吗。直接看代码:

package com.shang.xposed;

public class XposedBigBang implements IXposedHookLoadPackage {
    private final TouchEventHandler mTouchHandler = new TouchEventHandler();
    @Override
    public void handleLoadPackage(XC_LoadPackage.LoadPackageParam loadPackageParam) throws Throwable {
        findAndHookMethod(View.class, "setOnClickListener", View.OnClickListener.class, new ViewOnClickListenerHooker(loadPackageParam.packageName,type));                
    }
    
    private class ViewOnClickListenerHooker extends XC_MethodHook {

        private final String packageName;

        public ViewOnClickListenerHooker(String packageName,int type) {
            this.packageName = packageName;
            setClickTypeToTouchHandler(type);
        }

        @Override
        protected void beforeHookedMethod(MethodHookParam param) throws Throwable {
            super.beforeHookedMethod(param);
            View view = (View) param.thisObject;
            final View.OnClickListener listener = (View.OnClickListener) param.args[0];
            if (isKeyBoardOrLauncher(view.getContext(), packageName))
                return;
            View.OnClickListener newListener=new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    mTouchHandler.hookOnClickListener(v,mFilters);
                    if (listener==null){
                        return ;
                    }else {
                        listener.onClick(v);
                    }
                }
            };
            param.args[0]=newListener;
        }
    }
}

代码的方法名就是最好的注释,这里是hook了setOnClickListener,并将传入的OnClickListener替换成我们的,在我们的Listener中再调用原来的Listener。
不过这种方法只能获取设置了OnClickListener的View上的点击,如果没有设置OnClickListener则无法获取,所以我们还需要hook住dispatchTouchEvent方法。将下面代码添加到相应位置:

findAndHookMethod(View.class, "dispatchTouchEvent", MotionEvent.class, new ViewTouchEvent(loadPackageParam.packageName,type));

private class ViewTouchEvent extends XC_MethodHook {

    private final String packageName;
    Class viewRootImplClass;
    public ViewTouchEvent(String packageName,int type) {
        this.packageName = packageName;
        try {
            viewRootImplClass = this.getClass().getClassLoader().loadClass("android.view.ViewRootImpl");
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        setClickTypeToTouchHandler(type);
    }


    @Override
    protected void afterHookedMethod(MethodHookParam param) throws Throwable {
        super.afterHookedMethod(param);
        View view = (View) param.thisObject;
        if (isKeyBoardOrLauncher(view.getContext(), packageName))
            return;
        MotionEvent event = (MotionEvent) param.args[0];
        if ((Boolean) param.getResult() || view.getParent()==null || (viewRootImplClass.isInstance(view.getParent()) )) {
            mTouchHandler.hookTouchEvent(view, event, mFilters, true, appXSP.getInt(SP_DOBLUE_CLICK, 1000));
        }
    }
}

通过上面代码的最后几行能看到,我们只对消费了这个MotionEvent的view调用mTouchHandler.hookTouchEvent(),其内容如下:


public boolean hookTouchEvent(View v, MotionEvent event, final List<Filter> filters, boolean needVerify, int anInt) {
    hasTriggerLongClick=false;
    hasTriggerClick=false;
    hasTriggerDoubleClick=false;
    if (handler==null){
        handler=new Handler(Looper.getMainLooper());
    }
    if (gestureDetector==null){
        gestureDetector=new GestureDetector(v.getContext(),new GestureDetector.SimpleOnGestureListener(){
            ...
            
            @Override
            public boolean onSingleTapConfirmed(MotionEvent e) {
                Log.e(TAG,"gestureDetector onSingleTapConfirmed");
                if (!useClick){
                    return false;
                }
                if (mCurrentView==null){
                    return false;
                }                    
                if (!hasTriggerClick){
                    hasTriggerClick=true;
                    handler.post(new Runnable() {
                        @Override
                        public void run() {
                            String text = getTextFromView(mCurrentView, filters);
                            Log.e(TAG, "onSingleTapConfirmed text=" + text);
                            longPressedRunnable.setText(text);
                            longPressedRunnable.run();
                        }
                    });
                }
                return super.onSingleTapConfirmed(e);
            }

        });
    }
    gestureDetector.onTouchEvent(event);
    BIG_BANG_RESPONSE_TIME = anInt;
    boolean handle = false;
//        Log.e(TAG,"hookTouchEvent event:"+event);
    if (event.getAction() == MotionEvent.ACTION_DOWN){
        View targetTextView = getTargetTextView(v, event,filters);
        mCurrentView=targetTextView;
    }
    float currentX = event.getRawX();
    float currentY = event.getRawY();

    float x =longPressedRunnable.getX();
    float y=longPressedRunnable.getY();
    if (mScaledTouchSlop==0) {
        mScaledTouchSlop = ViewConfiguration.get(v.getContext()).getScaledTouchSlop();
    }        
    return handle;
}


private View getTargetTextView(View view, MotionEvent event, List<Filter> filters) {
    if (isOnTouchRect(view, event)) {
        if (view instanceof ViewGroup) {
            getTopSortedChildren((ViewGroup) view, topmostChildList);
            final int childCount = topmostChildList.size();
            for (int i = 0; i < childCount; i++) {
                View child = topmostChildList.get(i);
                if (isOnTouchRect(child, event)) {
                    if (child instanceof ViewGroup) {
                        return getTargetTextView(child, event, filters);
                    } else if (isValid(filters, child))
                        return child;
                }
            }
        } else {
            if (isOnTouchRect(view, event) && isValid(filters, view)) {
                return view;
            }
        }

    }
    return null;
}


private boolean isOnTouchRect(View view, MotionEvent event) {
    int rawX = (int) event.getRawX();
    int rawY = (int) event.getRawY();
    int[] xy = new int[2];
    view.getLocationOnScreen(xy);
    Rect rect = new Rect();
    rect.set(xy[0], xy[1], xy[0] + view.getWidth(), xy[1] + view.getHeight());
    return rect.contains(rawX, rawY);
}


private void getTopSortedChildren(ViewGroup viewGroup, List<View> out) {
    out.clear();
    //todo 因为系统的限制不能再非ViewGroup 中调用 isChildrenDrawingOrderEnabled 和 isChildrenDrawingOrderEnabled 方法。所以这里暂时注释掉了
//        final boolean useCustomOrder = viewGroup.isChildrenDrawingOrderEnabled();
    final int childCount = viewGroup.getChildCount();
    for (int i = childCount - 1; i >= 0; i--) {
//             int childIndex = useCustomOrder ? viewGroup.isChildrenDrawingOrderEnabled(childCount, i) : i;
        int childIndex = i;
        final View child = viewGroup.getChildAt(childIndex);
        if (child.getVisibility() == View.VISIBLE) {
            out.add(child);
        }
    }

    if (TOP_SORTED_CHILDREN_COMPARATOR != null) {
        Collections.sort(out, TOP_SORTED_CHILDREN_COMPARATOR);
    }
}


private boolean isValid(List<Filter> filters, View view) {
    return (view instanceof TextView )&& !(view instanceof EditText);
}

这块代码稍微有点多,不过逻辑不复杂,就是在MotionEvent.ACTION_DOWN的时候,拿到当前点击位置的View,并判断是不是TextView,然后通过GestureDetector来判断是不是单击操作,最后触发点击后的逻辑。

你可能从代码中看出来了以下几点:

  1. 在setOnClickListener和dispatchTouchEvent的hook中用的是用同一个TouchEventHandler 进行处理的,而且用到了hasTriggerClick变量来标记,这是为了便于控制点击事件的触发,以防一次点击触发两次;
  2. 有hasTriggerLongClick、hasTriggerDoubleClick和longPressedRunnable等命名的变量,这是因为我不但实现了单击操作触发,也实现了长按和双击触发,篇幅原因,这里就不贴长按和双击的实现方式了,详细代码可以看Bigbang工程源码
  3. 传入的List<Filter> filters变量好像没用到?其实这个filters是用于针对一些应用进行定制化的,比如微信的自定义View——“com.tencent.mm.ui.widget.MMTextView”,这需要对特定应用进行反编译和分析。

从代码中看不出来的几点思考:

  1. 为什么不hook住onTouch方法呢?原因很简单,因为dispatchTouchEvent比onTouch执行得早,hook onTouch也是可以的。
  2. 为什么要在一系列判断条件成立的时候才进行操作呢?因为在hookTouchEvent方法中会去定位到当前触摸位置的View,所以其实只需要确保能被调用到hookTouchEvent方法就行了,而这一系列条件就是为了保证hookTouchEvent方法不会被同一个触摸事件反复调用,从而引起误触发。
  3. 在hook setOnClickListener时并不是只对TextView做处理,而是对点击的View进行遍历,将其中所有TextView的内容拼接出来的。而在hook dispatchTouchEvent的时候,是则是拿到点击位置所在的最小的View。原因是,setOnClickListener的View是一个整体,点击的时候会作为一个整体响应点击,而dispatchTouchEvent则不一定是整体响应的,直接取整体会导致严重的误触发现象。

源码

详细代码可以看Bigbang工程源码的XposedBigBang和TouchEventHandler类,XposedBigBang还包含了全局复制的hook,感兴趣的同学可以看这篇——使用Xposed框架实现全局复制

还需要注意的是,Bigbang工程的通过productFlavors来区分Xposed版本和普通版本的,运行代码的时候注意修改。

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,386评论 25 707
  • 嗯哼嗯哼蹦擦擦~~~ 转载自:https://github.com/Tim9Liu9/TimLiu-iOS 目录 ...
    philiha阅读 4,831评论 0 6
  • 你说你很好 为何脸上竟显沧桑 像是风沙腐蚀了你的脸庞 你说你很好 为何笑容却如此迷茫 你可知道 你的笑容出卖了你的...
    源仔阅读 395评论 0 0
  • 我一个人睡一间房,一个床。半夜里听着雨声,滴答滴答……脑中开始困惑着那个人,我爱的那个人过得咋样,他的未来会有我吗...
    主张慢节奏阅读 239评论 0 0
  • 夜,天空已领走太阳,徐徐拉上一层黑幕,让一切慢慢变得昏暗,朦胧。夜色无垠,又是一日匆匆过去了,时间都去了哪里?是否...
    童心杰阅读 884评论 7 10