Android Dialog滅屏後無法點擊( Dropping event due to no window focus)

背景

最近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也不爲空.

  1. 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的方法.

總結

  1. Bug產生的原因是Dialog所在的Window滅屏後, ViewRoot沒有正常執行setWindowStopped()函數導致的.
  2. 造成setWindowStopped()流程異常原因是使用了SwipeDismissLayout後, 滅屏後會更新當前Window的LayoutParams, 此時通過getAttributes()獲取的LayoutParams中的token爲null, 所以造成後面流程出錯, 禁用SwipeDismissLayout即可解決bug.
  3. Dialog由於其特殊性, 在調用show()函數時, 會重新創建WindowManager.LayoutParams對象, 導致後面調用addView()的時候, 生成的Token沒有被存儲到當前的Window對象的LayoutParams中, 而是存儲到了new出來的LayoutParams對象中, 所以後續調用getAttributes()獲取的LayoutParams中的token爲null.

總的來說, 分析過程要有耐心, 對問題分析的越深入, 會找到更多解決Bug的方法.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章