轉載注明出處:簡書-十個雨點
簡介
我們模仿錘子制作的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框架可以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來判斷是不是單擊操作,最后觸發點擊后的邏輯。
你可能從代碼中看出來了以下幾點:
- 在setOnClickListener和dispatchTouchEvent的hook中用的是用同一個TouchEventHandler 進行處理的,而且用到了hasTriggerClick變量來標記,這是為了便于控制點擊事件的觸發,以防一次點擊觸發兩次;
- 有hasTriggerLongClick、hasTriggerDoubleClick和longPressedRunnable等命名的變量,這是因為我不但實現了單擊操作觸發,也實現了長按和雙擊觸發,篇幅原因,這里就不貼長按和雙擊的實現方式了,詳細代碼可以看Bigbang工程源碼
- 傳入的List<Filter> filters變量好像沒用到?其實這個filters是用于針對一些應用進行定制化的,比如微信的自定義View——“com.tencent.mm.ui.widget.MMTextView”,這需要對特定應用進行反編譯和分析。
從代碼中看不出來的幾點思考:
- 為什么不hook住onTouch方法呢?原因很簡單,因為dispatchTouchEvent比onTouch執行得早,hook onTouch也是可以的。
- 為什么要在一系列判斷條件成立的時候才進行操作呢?因為在hookTouchEvent方法中會去定位到當前觸摸位置的View,所以其實只需要確保能被調用到hookTouchEvent方法就行了,而這一系列條件就是為了保證hookTouchEvent方法不會被同一個觸摸事件反復調用,從而引起誤觸發。
- 在hook setOnClickListener時并不是只對TextView做處理,而是對點擊的View進行遍歷,將其中所有TextView的內容拼接出來的。而在hook dispatchTouchEvent的時候,是則是拿到點擊位置所在的最小的View。原因是,setOnClickListener的View是一個整體,點擊的時候會作為一個整體響應點擊,而dispatchTouchEvent則不一定是整體響應的,直接取整體會導致嚴重的誤觸發現象。
源碼
詳細代碼可以看Bigbang工程源碼的XposedBigBang和TouchEventHandler類,XposedBigBang還包含了全局復制的hook,感興趣的同學可以看這篇——使用Xposed框架實現全局復制。
還需要注意的是,Bigbang工程的通過productFlavors來區分Xposed版本和普通版本的,運行代碼的時候注意修改。