转载注明出处:简书-十个雨点
简介
我们模仿锤子制作的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版本和普通版本的,运行代码的时候注意修改。