背景
最近App开发同事发现了个系统Bug, Dialog显示后, 电源键灭屏后再亮屏, 此时Dialog无法点击,是个基本上必现的bug, Android系统版本为Android 7.1.1 .
问题分析
首先发现这个bug后, 可以确定的是这个bug一定是我们自己修改系统相关功能或者代码引进的, 而不是Android系统本身的问题, 毕竟很容易复现并且暴露给用户. 首先就来看下点击失效时的Log, 然后就发现了如下Log:
W ViewRootImpl[MainActivity]: Dropping event due to no window focus
可以看到是由于失去焦点而忽略了点击事件, 因此下面就得从Log打印位置出手跟踪原始流程来定位为什么失去焦点了.
问题定位
在OpenGrok搜索Log中关键字, 找到对应函数, 代码如下:
frameworks/base/core/java/android/view/ViewRootImpl.java
protected boolean shouldDropInputEvent(QueuedInputEvent q) {
if (mView == null || !mAdded) {
Slog.w(mTag, "Dropping event due to root view being removed: " + q.mEvent);
return true;
} else if ((!mAttachInfo.mHasWindowFocus
&& !q.mEvent.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) || mStopped
|| (mIsAmbientMode && !q.mEvent.isFromSource(InputDevice.SOURCE_CLASS_BUTTON))
|| (mPausedForTransition && !isBack(q.mEvent))) {
// This is a focus event and the window doesn't currently have input focus or
// has stopped. This could be an event that came back from the previous stage
// but the window has lost focus or stopped in the meantime.
if (isTerminalInputEvent(q.mEvent)) {
// Don't drop terminal input events, however mark them as canceled.
q.mEvent.cancel();
Slog.w(mTag, "Cancelling event due to no window focus: " + q.mEvent);
return false;
}
// Drop non-terminal input events.
Slog.w(mTag, "Dropping event due to no window focus: " + q.mEvent);
return true;
}
return false;
}
可以看到流程走到else if判断条件里面去了, 因此我们需要将4个判断条件值打印出来, 看看是那个条件导致流程走到了这里, 加入如下Log来打印相关判断条件:
android.util.Log.e("wenzhe1", "1:" + (!mAttachInfo.mHasWindowFocus && !q.mEvent.isFromSource(InputDevice.SOURCE_CLASS_POINTER)));
android.util.Log.e("wenzhe1", "2:" + mStopped);
android.util.Log.e("wenzhe1", "3:" + (mIsAmbientMode && !q.mEvent.isFromSource(InputDevice.SOURCE_CLASS_BUTTON)));
android.util.Log.e("wenzhe1", "4:" + (mPausedForTransition && !isBack(q.mEvent)));
加入Log后, 编译刷机, 重新复现一下bug, 点击Dialog后, Log打印如下:
06-15 13:14:10.795 3621 3621 E wenzhe1 : 1:false
06-15 13:14:10.795 3621 3621 E wenzhe1 : 2:true
06-15 13:14:10.795 3621 3621 E wenzhe1 : 3:false
06-15 13:14:10.795 3621 3621 E wenzhe1 : 4:false
可以看到是第二个条件即mStop
为true导致流程异常了, 看下 mStop
定义位置的注释,:
// Set to true if the owner of this window is in the stopped state,
// so the window should no longer be active.
boolean mStopped = false;
可以看到, 当Window处于Stop状态, ViewRootImpl也要置为Stop状态, 那么看到这里基本对问题出现原因有了大致了解: Dialog的Window状态出现了异常, 亮屏后并没有将 mStop
置为false.
接着定位具体问题点, 在当前文件中搜索一下, 可以发现改变mStop
值只有一个地方, 就是 void setWindowStopped(boolean stopped)
函数, 我们在此函数中加个Log打印一下调用堆栈, 看看是那个地方会调用:
void setWindowStopped(boolean stopped) {
if (mStopped != stopped) {
//打印调用堆栈
android.util.Log.e("wenzhe2", android.util.Log.getStackTraceString(new Throwable()));
mStopped = stopped;
final ThreadedRenderer renderer = mAttachInfo.mHardwareRenderer;
if (renderer != null) {
if (DEBUG_DRAW) Log.d(mTag, "WindowStopped on " + getTitle() + " set to " + mStopped);
renderer.setStopped(mStopped);
}
if (!mStopped) {
scheduleTraversals();
} else {
if (renderer != null) {
renderer.destroyHardwareResources(mView);
}
}
}
}
重新复现bug跑下流程, 定位调用的地方为:
frameworks/base/core/java/android/view/WindowManagerGlobal.java
中的 setStoppedState(IBinder token, boolean stopped)
, 这次我们需要将 mParams
链表中的值和token
的值打印出来, 看看灭屏前后里面值的区别, 来进一步定位问题, 在函数中加入如下Log:
public void setStoppedState(IBinder token, boolean stopped) {
synchronized (mLock) {
int count = mViews.size();
// 加入调试Log
android.util.Log.e("wenzhe3", "view count:" + count + " token:" + token);
for (WindowManager.LayoutParams par : mParams) {
android.util.Log.e("wenzhe3", "params token:" + par.token);
}
for (int i = 0; i < count; i++) {
if (token == null || mParams.get(i).token == token) {
ViewRootImpl root = mRoots.get(i);
root.setWindowStopped(stopped);
}
}
}
}
加入Log后, 又是编译刷机复现bug跑下流程, 打印Log如下:
// 灭屏Log, 此时操作是将两个ViewRoot置为stop状态
06-15 13:02:09.503 3621 3621 E wenzhe3 : view count:2 token:android.os.BinderProxy@5b7e3c
06-15 13:02:09.504 3621 3621 E wenzhe3 : params token:android.os.BinderProxy@5b7e3c
06-15 13:02:09.504 3621 3621 E wenzhe3 : params token:android.os.BinderProxy@5b7e3c
// 亮屏Log 此时操作是将两个ViewRoot置为非stop状态
06-15 13:02:12.859 3621 3621 E wenzhe3 : view count:2 token:android.os.BinderProxy@5b7e3c
06-15 13:02:12.859 3621 3621 E wenzhe3 : params token:android.os.BinderProxy@5b7e3c
06-15 13:02:12.859 3621 3621 E wenzhe3 : params token:null
看到这里, 很容易发现, 灭屏后再亮屏, mParams
链表中有一个token变为null, 导致亮屏时, 两个ViewRoot(一个Activity的, 一个Dialog的)只有第一个执行了 setWindowStopped()
, 所以 Dialog的ViewRoot的mStop
在亮屏时没有被置为 false, 所以后续的点击事件被忽略了. 问题原因找到了, 现在就要定位是谁导致了这个原因.
同样在当前文件中搜索 mParams
, 查找添加和删除元素的地方, 打印Log定位, 步骤和上面类似, 就不贴代码了, 最后定位是在 void updateViewLayout(View view, ViewGroup.LayoutParams params)
中, 添加的LayoutParams的token是null, 所以导致后面的问题, 同样打印调用堆栈, 继续定位具体是哪里调用的, 添加Log如下:
public void updateViewLayout(View view, ViewGroup.LayoutParams params) {
if (view == null) {
throw new IllegalArgumentException("view must not be null");
}
if (!(params instanceof WindowManager.LayoutParams)) {
throw new IllegalArgumentException("Params must be WindowManager.LayoutParams");
}
final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams)params;
view.setLayoutParams(wparams);
// token 为 null就打印调用堆栈, 定位具体调用位置
if (wparams.token == null)
android.util.Log.e("wenzhe2", android.util.Log.getStackTraceString(new Throwable()));
synchronized (mLock) {
int index = findViewLocked(view, true);
ViewRootImpl root = mRoots.get(index);
mParams.remove(index);
mParams.add(index, wparams);
root.setLayoutParams(wparams, false);
}
}
打印结果如下:
E wenzhe2 : java.lang.Throwable
E wenzhe2 : at android.view.WindowManagerGlobal.updateViewLayout(WindowManagerGlobal.java:383)
E wenzhe2 : at android.view.WindowManagerImpl.updateViewLayout(WindowManagerImpl.java:101)
E wenzhe2 : at android.app.Dialog.onWindowAttributesChanged(Dialog.java:723)
E wenzhe2 : at android.view.Window.dispatchWindowAttributesChanged(Window.java:1098)
E wenzhe2 : at com.android.internal.policy.PhoneWindow.dispatchWindowAttributesChanged(PhoneWindow.java:2940)
E wenzhe2 : at android.view.Window.setAttributes(Window.java:1129)
E wenzhe2 : at com.android.internal.policy.PhoneWindow.setAttributes(PhoneWindow.java:3804)
E wenzhe2 : at com.android.internal.policy.PhoneWindow$3.onSwipeCancelled(PhoneWindow.java:3039)
E wenzhe2 : at com.android.internal.widget.SwipeDismissLayout.cancel(SwipeDismissLayout.java:307)
E wenzhe2 : at com.android.internal.widget.SwipeDismissLayout$2$1.run(SwipeDismissLayout.java:101)
E wenzhe2 : at android.os.Handler.handleCallback(Handler.java:751)
E wenzhe2 : at android.os.Handler.dispatchMessage(Handler.java:95)
E wenzhe2 : at android.os.Looper.loop(Looper.java:154)
E wenzhe2 : at android.app.ActivityThread.main(ActivityThread.java:6120)
E wenzhe2 : at java.lang.reflect.Method.invoke(Native Method)
E wenzhe2 : at com.android.internal.os.ZygoteInit$MethodAndArgsCaller.run(ZygoteInit.java:886)
E wenzhe2 : at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:776)
看到这个Log, 就确定了问题所在, 引起这个问题的就是我们系统中加入的SwipeDismissLayout(右滑退出)功能引起的, 这个功能之前我有写过一片文章:点击传送, 看下SwipeDismissLayout源码就知道, 在灭屏的时候会根据当前滑动状态, 来更新一下window的位置(是cancel还是dismiss), 而更新的时候, 要获取Window的属性 WindowManager.LayoutParams newParams = getAttributes();
而此时获取的属性中, newParams.token
值为 null, 所以导致后面一系列问题.
此部分代码位置: frameworks/base/core/java/com/android/internal/policy/PhoneWindow.java
函数为: private void registerSwipeCallbacks()
, 有兴趣可以看下.
解决问题
问题点定位后, bug就好解决了, 先验证下是不是SwipeDismissLayout引起的, 由于我们系统默认是启用右滑退出功能的, 默认Dialog也能右滑退出, 所以需要给测试的Dialog加个主题,
主题中加入:<item name="android:windowSwipeToDismiss">false</item>
然后运行测试, bug不复现, 证实就是SwipeDismissLayout引起.所以解决问题基本就两种方式:
- 给Dialog默认都加上
<item name="android:windowSwipeToDismiss">false</item>
样式 - 在Dialog源码中,将PhoneWindow的FEATURE_SWIPE_TO_DISMISS这个feature去掉即可, 这个需要通过添加函数和修改一点PhoneWindow.java中的逻辑来实现.
一些细节
上面只是大概说明了引起问题的原因, 有些细节还没说明白:为什么Dialog内部的PhoneWindow中, 通过getAttributes()得到的LayoutParams.token为null, 而Activity是正常的, 并且在灭屏之前, mParams
链表里面的token为什么是正常的?
LayoutParams.token
是在Window.java中 void adjustLayoutParamsForSubWindow(WindowManager.LayoutParams wp)
进行赋值的, 而调用此函数是在WindowManagerGlobal.java的addView()
中,代码如下:
public void addView(View view, ViewGroup.LayoutParams params,
Display display, Window parentWindow) {
//部分代码省略...
final WindowManager.LayoutParams wparams = (WindowManager.LayoutParams) params;
if (parentWindow != null) {
//调用此函数后, wparams.token就不为null了
parentWindow.adjustLayoutParamsForSubWindow(wparams);
} else {
// If there's no parent, then hardware acceleration for this view is
// set from the application's hardware acceleration setting.
final Context context = view.getContext();
if (context != null
&& (context.getApplicationInfo().flags
& ApplicationInfo.FLAG_HARDWARE_ACCELERATED) != 0) {
wparams.flags |= WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED;
}
}
//部分代码省略...
}
所以只要parentWindow
不为空, 则会进行调整, 调整后Token就不为空了, 所以addView时候添加的LayoutParams的Token不为空, 所以灭屏前mParams
里面Token也不为空.
- addView()阶段LayoutParams.token会被重新赋值, 即token已经被存储到LayoutParams中了, 但灭屏时调用的updateViewLayout()传递的LayoutParams.token为空, 只能说明这两个不是同一对象, 从前面分析的流程可知, updateViewLayout()函数中的参数LayoutParams是通过调用Window的getAttributes()来得到的, 是当前Window对象的LayoutParams. 而addView()中的的参数LayoutParams则是如下代码获得的:
frameworks/base/core/java/android/app/Dialog.java
public void show() {
//部分代码省略...
WindowManager.LayoutParams l = mWindow.getAttributes();
// 如果满足条件, 会重新new一个对象, 所以后后续调用adjustLayoutParamsForSubWindow()
//生成的Token并没有存储到当前mWindow对象中,后续getAttributes()中的token就为空
if ((l.softInputMode
& WindowManager.LayoutParams.SOFT_INPUT_IS_FORWARD_NAVIGATION) == 0) {
WindowManager.LayoutParams nl = new WindowManager.LayoutParams();
nl.copyFrom(l);
nl.softInputMode |=
WindowManager.LayoutParams.SOFT_INPUT_IS_FORWARD_NAVIGATION;
l = nl;
}
mWindowManager.addView(mDecor, l);
//部分代码省略...
}
上面代码中, 满足if判断条件后, 重新创建了一个对象, 所以后续token并没有存储到本身的window中, 通过打印Log, 证实默认情况下if判断条件为true, 所以updateViewLayout()时LayoutParams并不是当前Window对象的, 所以后续getAttributes()获得的LayoutParams.token依然为null.
我们将if条件内容先直接注释调, 看看此种方式是否能解决我们遇到的Bug, 实测证明Bug完美解决,
可以看到, 在找到更深层次原因后, 又多了一种解决bug的方法.
总结
- Bug产生的原因是Dialog所在的Window灭屏后, ViewRoot没有正常执行setWindowStopped()函数导致的.
- 造成setWindowStopped()流程异常原因是使用了SwipeDismissLayout后, 灭屏后会更新当前Window的LayoutParams, 此时通过getAttributes()获取的LayoutParams中的token为null, 所以造成后面流程出错, 禁用SwipeDismissLayout即可解决bug.
- Dialog由于其特殊性, 在调用show()函数时, 会重新创建WindowManager.LayoutParams对象, 导致后面调用addView()的时候, 生成的Token没有被存储到当前的Window对象的LayoutParams中, 而是存储到了new出来的LayoutParams对象中, 所以后续调用getAttributes()获取的LayoutParams中的token为null.
总的来说, 分析过程要有耐心, 对问题分析的越深入, 会找到更多解决Bug的方法.
更新(2019/01/12)
最近刚好有位小伙伴也遇到了这个问题, 还找到了Google官方解决Bug的提交, 有兴趣的可以看看:
Don't use a copy of window params when showing a dialog
提交信息如下:
Don't use a copy of window params when showing a dialog.
When a Dialog's show() method is called, it makes a copy (l) of its window param
and change the copy's softInputMode before calling wm.addView(). This call ends
up calling WindowManagerGlobal.addView(view, l, display, parentWindow),
which in turn sets the application token from the parentWindow into l and stores
l on its mParams map.
Later, when the dialog layout is changed (for example, if it's resized), the
original params ends up passed to WindowManagerGlobal.updateViewLayout(),
which in turn updates it's internal mParams with it, hence losing the
application token (as the token was set in the copy).
Then, when Autofill (and possibly Assist) is triggered to that activity, the
Dialog's view hierarchy is ignored because WindowManagerGlobal.getRootViews()
ignores views whose params do not have an application token.
This CL fixes this issue by passing the original dialog's param to the wm
method and resetting the softInputMode that was changed, rather than making a
copy.
可以看到和我分析的结论是一样的.