如何通過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閱讀 230,563評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,694評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,672評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,965評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,690評論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,019評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,013評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,188評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,718評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,438評論 3 360
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,667評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,149評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,845評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,252評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,590評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,384評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,635評論 2 380

推薦閱讀更多精彩內容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 172,765評論 25 708
  • 嗯哼嗯哼蹦擦擦~~~ 轉載自:https://github.com/Tim9Liu9/TimLiu-iOS 目錄 ...
    philiha閱讀 4,986評論 0 6
  • 你說你很好 為何臉上竟顯滄桑 像是風沙腐蝕了你的臉龐 你說你很好 為何笑容卻如此迷茫 你可知道 你的笑容出賣了你的...
    源仔閱讀 406評論 0 0
  • 我一個人睡一間房,一個床。半夜里聽著雨聲,滴答滴答……腦中開始困惑著那個人,我愛的那個人過得咋樣,他的未來會有我嗎...
    主張慢節奏閱讀 244評論 0 0
  • 夜,天空已領走太陽,徐徐拉上一層黑幕,讓一切慢慢變得昏暗,朦朧。夜色無垠,又是一日匆匆過去了,時間都去了哪里?是否...
    童心杰閱讀 926評論 7 10