项目中会添加各种三方的sdk,大部分都是只有功能,没有 Activity 页面的,但是也有例外,比如说如果项目中接入了某些广告类型的Sdk,打开广告时,此时的Activity 就是Sdk中自带的,我们一般都是需要在自己项目中的配置清单 AndroidManifest 配置一下activity的路径名称。 前几天接到了个需求,要在某三方广告页面增加一个自己的布局,并且手指在广告页面滑动时,要触发自己布局中的某个空间,让它旋转一下。接到需求时才知道,Ios说做不到,所以只有Android实现此功能,只能感叹我司的产品同学,脑路一个比一个大,心中抱怨几句,但还是要开动脑子去实现。
问题一分为二:一、如何把布局view添加到三方SDk的Activity上;二、怎么监听到手指头在页面广告页面滑动了。 先看问题一
如果是自己的Activity页面,布局里增加一个view再简单不过了,直接写在 layout 布局中或者动态的 addView() 都可以,但是这个是别人的页面,并且里面的布局是用初代插件化的方式动态加载的,我想直接替换它的layout布局都不行,既然替换不行,那么我可不可以动态的增加布局呢?我只要能定位到广告的 Activity 即可。说来也巧了,之前我优化过如何判断页面是出于前端还是切入后台的功能,原代码是通过获取 ActivityManager 来判断,如果经常性的调用,对性能不太好,代码如下
public static boolean isAppOnForeground(final Context context) {
ActivityManager activityManager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
String packageName = context.getPackageName();
List<ActivityManager.RunningAppProcessInfo> appProcesses = activityManager.getRunningAppProcesses();
if (appProcesses == null){
return false;
}
for (ActivityManager.RunningAppProcessInfo appProcess : appProcesses) {
if (appProcess.processName.equals(packageName)
&& appProcess.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) {
return true;
}
}
return false;
}
我们可以通过 ActivityManager 类来获取最顶层的 Activity,判断它是不是广告页面,但这种方法比较笨重,因为我们每次打开一个新的页面,都要判断一下,这个比较被动,舍弃。我之前优化判断是否切入后台的思路,是通过 Application 来判断,Application 是全局的,它能管理项目中所有的 Activity,所以我给它注册了一个activity的声明周期回调,代码如下
class BaseApplication extends Application{
public final static String TAG = "BaseApplication";
private int mActivityCount;
@Override
public void onCreate() {
super.onCreate();
}
private void registerActivity() {
registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
}
@Override
public void onActivityStarted(Activity activity) {
mActivityCount++;
//如果mFinalCount ==1,说明是从后台到前台
Log.e(TAG, mActivityCount + " onActivityStarted " + activity);
if (mActivityCount == 1) {
//说明从后台回到了前台
Log.e(TAG, " 从后台到前台 " );
}
}
@Override
public void onActivityResumed(Activity activity) {
}
@Override
public void onActivityPaused(Activity activity) {
}
@Override
public void onActivityStopped(Activity activity) {
mActivityCount--;
//如果mFinalCount ==0,说明是前台到后台
Log.e(TAG, mActivityCount + " onActivityStopped " + activity);
if (mActivityCount == 0) {
//说明从前台回到了后台
Log.e(TAG, " 前台回到了后台 " );
}
}
@Override
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
}
@Override
public void onActivityDestroyed(Activity activity) {
}
});
}
}
Activity的声明周期,比如 onResume()的流程,会从 ActivityThread 中 performResumeActivity() ----- Activity 的 performResume() ---- Instrumentation 的 callActivityOnResume() ----Activity 的 onResume() ---- Application 的 dispatchActivityResumed() ,在这个方法中,会遍历注册的声明周期回调,即ActivityLifecycleCallbacks 的 onActivityResumed() 方法,
既然是这样,那么我就在Application的回调中,判断 Activity 的类型,如果是广告页面,我们就想办法添加布局,对于一个Activity,如果我们知道layout根布局,就可以了,但可惜,我们不知道,并且广告是三方的,它随时可以改变根布局。看过 PhoneWindow 的同学,都知道,不管什么Activity,它默认都有个布局,id 为 com.android.internal.R.id.content,它是layout的父容器,知道这一点,就解决了一大半,还有一小半是添加 view 的时机问题,一般情况,我们如果单纯的添加一个view,可以直接添加;但如果我们想替换某个控件,尤其这个控件还是广告页面通过动态加载添加的,那么我们就得考虑一下时机了。不管是否通过 setContentView(R.layout.activity) 这种方法添加布局,它都会通过 PhoneWindow 来管理布局, ActivityThread 的 handleResumeActivity() 方法中,
ActivityClientRecord r = performResumeActivity(token, clearHide);
...
if (r.window == null && !a.mFinished && willBeVisible) {
r.window = r.activity.getWindow();
View decor = r.window.getDecorView();
decor.setVisibility(View.INVISIBLE);
ViewManager wm = a.getWindowManager();
WindowManager.LayoutParams l = r.window.getAttributes();
a.mDecor = decor;
l.type = WindowManager.LayoutParams.TYPE_BASE_APPLICATION;
l.softInputMode |= forwardBit;
if (a.mVisibleFromClient) {
a.mWindowAdded = true;
wm.addView(decor, l);
}
}
我们重点关注 View decor = r.window.getDecorView() 这行代码,Activity 中如果没有调用 setContentView() 方法,那么在这里 getDecorView() 方法中就会创建 Activity 的根布局 DecorView,同时把 id 为 com.android.internal.R.id.content 的 FrameLayout所在的布局也添加进去,通过上面代码,它是在 performResumeActivity() 之后执行的, 也就是在 onResume() 之后,所以这里有个小细节,如果当前Activity 没有调用过 setContentView() 方法, 我们在 Application 中 onActivityCreated() 中直接 findViewById(Window.ID_ANDROID_CONTENT) 可能会有问题的,除非 Activity 中有调用过 getWindow().getDecorView(),我们为了保险起见,同时也为了规范点,我们等 handleResumeActivity() 逻辑执行完了再去执行自己的逻辑,什么时候执行完呢?我们可以使用 getWindow().getDecorView().post() 方法,这样就巧妙的把上面的两个要求都实现了
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
addViewToContent(activity);
}
public void addViewToContent(final Activity activity){
if (activity instanceof AdActivity) {
activity.getWindow().getDecorView().post(new Runnable() {
@Override
public void run() {
FrameLayout frameLayout = (FrameLayout) activity.findViewById(Window.ID_ANDROID_CONTENT);
View iv = new ImageView(activity);
iv.setImageResource(R.drawable.ad_close_icon);
FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(600,600);
iv.setLayoutParams(layoutParams);
frameLayout.addView(iv);
}
});
}
}
这样,我们就在Activity中成功的添加了自己的view。还有没其他方法,我想起了早期做的一个悬浮窗按钮,通过 WindowManager 来 addView(),所以就把以前的代码找了出了,写了方法二
private static void addViewToContent2(Activity activity){
final WindowManager mWindowManager = (WindowManager) activity.getSystemService(Context.WINDOW_SERVICE);
final Window w = activity.getWindow();
w.setWindowManager(mWindowManager, null, null);
w.setGravity(Gravity.CENTER);
WindowManager.LayoutParams mLayoutParams = new WindowManager.LayoutParams();
mLayoutParams.type = WindowManager.LayoutParams.TYPE_SYSTEM_ALERT;// 系统提示window
mLayoutParams.format = PixelFormat.TRANSLUCENT;// 支持透明
//mParams.format = PixelFormat.RGBA_8888;
mLayoutParams.flags = WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL | //该flags描述的是窗口的模式,是否可以触摸,可以聚焦等
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED;
mLayoutParams.width = 490;//窗口的宽和高
mLayoutParams.height = 160;
mLayoutParams.x = 0;//窗口位置的偏移量
mLayoutParams.y = 0;
final ImageView iv = new ImageView(activity);
iv.setImageResource(R.drawable.ad_close_icon);
FrameLayout.LayoutParams layoutParams = new FrameLayout.LayoutParams(1000,1920);
iv.setLayoutParams(layoutParams);
mWindowManager.addView(iv, mLayoutParams);//添加窗口
}
把 addViewToContent() 方法 替换为 addViewToContent2() 即可。 这两种方法,尤其是第二种,一定要在 Activity 销毁时,在 Application 的 onActivityDestroyed() 方法中调用,移除 iv布局,原因是 方法一是添加到 Activity 的布局里面, Activity 销毁时 iv 也就不显示了;方法二是添加到窗口上了,如果 Activity 销毁时 iv 没有被移除,则它会一直显示在窗口上,容易内存泄漏和UI显示错误。
问题二
如何监听到广告里面一次完整的手指触摸事件,即 按下-滑动-抬起 ,通过使用工具分析得知,该广告页面是用 WebView 加载的,webView 加载url,超出屏幕部分可以上下滑动。WebView 消费了触摸事件,通过 findViewById(Window.ID_ANDROID_CONTENT)可以找到 id 为 content 的 Fragment 容器,我们可以遍历它的子控件,层层找下去,找到 webView,对它设置 setOnTouchListener() 来监听触摸事件,这样就可以知道了。看起来万事大吉,但有几个隐患:1、如果广告页面的 webView 本身已经有设置 setOnTouchListener() 回调,我们在外面再次设置便会把原先覆盖,除非可以获取原先的 OnTouchListener ,我们在自己的setOnTouchListener() 回调中把它给放进去,形成一个代理模式,但可惜, View 中没有 getOnTouchListener() 这个 API,放弃; 2、万一里面的 WebView 不是一个,而是两个甚至三个怎么办?我司项目中,由于有些特殊的需求,需要一个页面添加2个WebView,这种场景罕见,但并非没有可能,如果有多个,我们就需要每个都设置回调,这是后就又回到了隐患1。所以这种方法可以,但不敢保证兼容所有。
换一种思路,对于 WebView 看过它的源码,它的 onTouchEvent() 方法会消费掉触摸事件,发现这一点,脑子里便有了模糊的思路。看过前面几篇关于事件分发机制消息的文章,我们知道,事件分发是从 Activity 传递到 window窗口的 DecorView 根布局,进而传递到 layout布局的 根布局,然后才是对应的子控件,如果没有消费,事件会传给上一层,看看是否消费,如果没有,继续上传上一层,最终传递给 Activity 。这里有个要点,就是事件的分发传递, Activity - ViewGroup - View,这三者之间传递,入口都是 dispatchTouchEvent() 方法,我们是否可以在它上面想些办法呢? Activity 的dispatchTouchEvent()方法我们没法重写,那么 DecorView 呢?我们似乎也没办法。对于WebView 呢,也不行。我们现在可以找到 id 为 content 的 Fragment 容器,如果我们在它和它的子控件之间添加一层自定义的 Fragment,或者用自定义的 Fragment 来替换 content 容器,这样,我们就可以重写dispatchTouchEvent() 方法,在它里面做操作了,为了尽量减轻布局嵌套,我们采用替换的方法,既然是替换,我们需要找到 content 的所有子控件,把它们移除掉,然后把这些子控件都添加到自己创建出来的容器,然后再替换,代码如下
private void replaceView(Activity activity){
FrameLayout frameLayout = (FrameLayout) activity.findViewById(Window.ID_ANDROID_CONTENT);
ViewGroup parent = (ViewGroup) frameLayout.getParent();
FrameLayout fl = new FrameLayout(activity);
for(int i = 0, count = frameLayout.getChildCount(); i < count; i++){
View view = frameLayout.getChildAt(i);
frameLayout.removeView(view);
fl.addView(view);
}
parent.removeView(frameLayout);
parent.addView(fl);
fl.setId(Window.ID_ANDROID_CONTENT);
// 添加自己的控件
ImageView iv = new ImageView(activity);
iv.setImageResource(R.drawable.close_icon);
fl.addView(iv);
}
这样就完成了替换Fragment的功能,此时重点就落在了自定义控件上,它的 dispatchTouchEvent() 会接收到webview返回的事件值,webView的 onTouchEvent() 中的事件,Fragment中都会接收到。一个完整的事件,包括ACTION_DOWN、ACTION_UP、ACTION_MOVE,值分别是 0、1、2, Fragment 中 dispatchTouchEvent() 都会有触发,我们可以用 HashSet 来记录值,只要集合的元素个数为3,则满足一次触摸滑动事件,但集合毕竟消耗内存,尤其是 move 时,不停的add同一个元素,更是消耗;优化方案,既然是要保持唯一性,那我们用位移位运算即可,down up move 分别对应唯一一个二进制值,
class FlContentView extends FrameLayout{
public FlContentView(Context context) {
super(context);
}
public FlContentView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public FlContentView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
ViewHelper.transformFlag(event.getAction());
return super.dispatchTouchEvent(event);
}
}
class ViewAddHelper {
private static final int FLAG_ACTION_DOWN = 1 << 0;
private static final int FLAG_ACTION_UP = 1 << 1;
private static final int FLAG_ACTION_MOVE = 1 << 2;
private static final int FLAG_NUM_ADD = FLAG_ACTION_DOWN | FLAG_ACTION_UP | FLAG_ACTION_MOVE;
private static int mFlag = 0;
private static int mScrollNum;
public static void transformFlag(int flag) {
if (MotionEvent.ACTION_DOWN == flag) {
addFlag(FLAG_ACTION_DOWN);
} else if (MotionEvent.ACTION_UP == flag) {
addFlag(FLAG_ACTION_UP, true);
} else if (MotionEvent.ACTION_MOVE == flag) {
addFlag(FLAG_ACTION_MOVE);
}
}
private static void addFlag(int flag){
addFlag(flag, false);
}
private static void addFlag(int flag, boolean allow){
mFlag |= flag;
if(mFlag == FLAG_NUM_ADD && allow){
checkSide();
restFlag();
}
}
public static void restFlag(){
mFlag = 0;
}
private static void checkSide(){
Log.e("ViewAddHelper", " 滑动次数第 " + (++mScrollNum) + " 次");
}
}
满足滑动条件后,马上清除标识,checkSide() 中我们可以做自己相应的逻辑操作,比如说每次滑动后就需要我们添加的 imageView 动画旋转一下,既可以在 checkSide() 中调用这个控件的动画方法。本来,到此就结束了,往自己项目中的三方页面添加自己的控件,并且三方页面布局滑动时,自己控件有动画旋转,上面例子中也满足了,但项目中又有点不同,因为添加自己的控件不是单纯的一个 ImageView,而是一个可以拖动滑动的 view,如果这个 view 大小铺满全屏,根据手指触摸滑动,https://blog.csdn.net/Deaht_Huimie/article/details/89161963 这篇文章中有介绍几种位移的方法,都是铺满全屏的。那么 WebView 则接受不到触摸事件,所以 view 不能铺满全屏,只能展示自己的部分,然后依附于父容器,手指头按住 view 时,再进行位移,布局结构类似
<FrameLayout
android:id="@+id/fl_move_parent"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/iv_move"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="#222222"
/>
</FrameLayout>
https://blog.csdn.net/Deaht_Huimie/article/details/52817268 这里面也有几种位移的方法,适合这个布局的例子,手指按到 iv_move 上,会拖动它位移,如果 iv_move 没有消费事件。问题就出在这,由于它消费了事件,它也是 FlContentView 控件的子控件,那么 FlContentView 的 dispatchTouchEvent() 方法也会接受到完整的事件触摸机制,这是后就会产生一个状况,不论是滑动 webView 还是可以拖动的 iv, FlContentView 的 dispatchTouchEvent() 中都会打印滑动log,触发旋转动画,怎么办?如果能辨别是触发的控件是哪个就好了,但可惜,这两个页面,一个是三方的,别想了;另外一个是自己的,但它是另外一个小组的,我这边只负责把它给添加到广告页面并触发旋转动画。我一开始想重写一下 iv_move 的 dispatchTouchEvent() 方法,在里面调用 ViewHelper.restFlag() 方法,但是这个控件好多地方都有用到,都是统一控制,也不方便写子类,意思就是这也类似是个黑盒,我无权去改动它里面的内容。怎么办?既然不能改动,那么只能在外面判断了,只要 iv_move 消费了事件,它的父容器都能监听到,那么我就把它添加到 FlContentView 容器之前,再给它增加一层父布局,在父布局中接收消息进行判断,如果子view消费了事件,那么它的 dispatchTouchEvent(MotionEvent event) 中的 super.dispatchTouchEvent(event) 返回值是 true,于是又一个自定义控件诞生了
class FlImageParentView extends FrameLayout{
public FlImageParentView(Context context) {
super(context);
}
public FlImageParentView(Context context, AttributeSet attrs) {
super(context, attrs);
}
public FlImageParentView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
boolean handled = super.dispatchTouchEvent(event);
if(handled){
ViewHelper.restFlag();
}
return handled;
}
}
最终的添加布局的代码就变为了下面的代码
private void addReplaceView(Activity activity){
FrameLayout frameLayout = (FrameLayout) activity.findViewById(Window.ID_ANDROID_CONTENT);
ViewGroup parent = (ViewGroup) frameLayout.getParent();
FrameLayout fl = new FrameLayout(activity);
for(int i = 0, count = frameLayout.getChildCount(); i < count; i++){
View view = frameLayout.getChildAt(i);
frameLayout.removeView(view);
fl.addView(view);
}
parent.removeView(frameLayout);
parent.addView(fl);
fl.setId(Window.ID_ANDROID_CONTENT);
// 添加自己的控件
ImageView iv = new ImageView(activity);
iv.setImageResource(R.drawable.close_icon);
FlImageParentView fILayout = new FlImageParentView(activity);
fILayout.addView(iv);
fl.addView(fILayout);
}
这样,在 Application 中注册的生命周期回调方法,开启了iv的添加流程
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
addViewToContent(activity);
}
public void addViewToContent(final Activity activity){
if (activity instanceof AdActivity) {
activity.getWindow().getDecorView().post(new Runnable() {
@Override
public void run() {
addReplaceView(activity);
}
});
}
}
谨记一点,在 onActivityDestroyed(Activity activity) 方法中,记得要把添加的控件 view 从 content 中移除,防止由于内部逻辑可能造成内存泄漏。