1. 程式人生 > >SmartShow 2.x 版重磅來襲

SmartShow 2.x 版重磅來襲

各位老鐵,又見面了。SmartShow推出一年來,感謝各位的支援和反饋。2.x版有重大更新,新增了SmartTopbar、SmartDialog功能模組,並對1.x版本使用中陸續反饋的功能訴求和bug進行了實現和修復。

SmartToast

BadTokenException解決方案

Android 7.1系統上,Toast會偶現BadTokenException。理解產生的原因,需要對Toast的基本工作原理以及不同系統版本原始碼的變化有所瞭解,完整講述可參考我的Toast系列博文

限於篇幅,我簡要說一下。Android 7.1開始,Google開始限制TYPE_TOAST視窗的濫用,系統在將Toast顯示請求加入佇列時,為其建立一個Token。

@Override
public void enqueueToast(String pkg, ITransientNotification callback, int duration){
   ...
   Binder token = new Binder();
   //為該Toast視窗新增Token,筆者註釋
   mWindowManagerInternal.addWindowToken(token,WindowManager.LayoutParams.TYPE_TOAST);
   record = new ToastRecord(callingPid, pkg, callback, duration, token);
   //將該Toast顯示請求加入佇列,筆者註釋
   mToastQueue.add(record);
   ...
}

當Toast超時(duration耗盡)或者呼叫了cancel方法需要隱藏時,系統將這個顯示請求從佇列移除,並將Token設為失效。

void cancelToastLocked(int index) {
        ...
        //將該Toast請求從佇列移除,筆者註釋
        ToastRecord lastToast = mToastQueue.remove(index);
        //將該Toast視窗的Token設為無效,筆者註釋
        mWindowManagerInternal.removeWindowToken(lastToast.token, true);
        ...
        //如果佇列不為空,則說明還有Toast要顯示,則繼續顯示下一個Toast,筆者註釋
        if (mToastQueue.size() > 0) {
            showNextToastLocked();
        }
    }

Toast的工作流程其實是一個基於Binder的IPC(程序通訊)過程,除了發起Toast顯示請求、顯示和隱藏Toast視窗外,所有工作都有系統服務完成,並通過Toast的內部類Tn來實現與客戶端的互動。準確地說,就是通過Tn的show和hide方法來實現顯示和隱藏Toast視窗。

我們知道,應用的主執行緒是一個死迴圈,不斷地從佇列裡取出訊息執行。在發起Toast地顯示請求後,系統服務為其建立Token,並將請求加入佇列,隨後呼叫Tn的show方法顯示Toast視窗。而恰在此時,主執行緒因為某個訊息阻塞,導致show方法遲遲沒有執行。直到Toast超時,系統服務將其從佇列移除,並將token失效。直到此時,應用主執行緒才執行到Tn的show方法,而Token依然失效,導致BadTokenException。

        public void handleShow(IBinder windowToken) {
                ...
                //將系統服務建立的Token傳遞進來,筆者註釋
                mParams.token = windowToken;
                ...
                //新增Toast視窗,此時Token已然失效,引發BadTokenExceptin,筆者註釋
                mWM.addView(mView, mParams);
                ...
        }

Google在Android 8.0修復了這個錯誤,從兩個方面:

一是直接在產生BadTokenException的捕獲該異常。

        public void handleShow(IBinder windowToken) {
                ...
                try {
                    mWM.addView(mView, mParams);
                ...
                } catch (WindowManager.BadTokenException e) {
                    /* ignore */
                }
            }
        }

二是在Tn的handler處理顯示Toast視窗訊息前,先判斷handler的訊息佇列裡是否有超時隱藏或者主動cancel的訊息,有則說明Token已然失效了,則什麼都不做。

       public void handleShow(IBinder windowToken) {
            ...

            //判斷是否有超時隱藏或者主動cancel的訊息,有則表示系統服務已經將其從
            //顯示佇列移除,並將Token設為失效,筆者註釋
            if (mHandler.hasMessages(CANCEL) || mHandler.hasMessages(HIDE)) {
                return;
            }

            ...

                try {
                    mWM.addView(mView, mParams);
                    ...
                } catch (WindowManager.BadTokenException e) {
                    /* ignore */
                }
            
        }

可是對於已經發布的Android 7.1怎麼辦呢?我們可以仿照Android8.0的補救方法,直接新增Try-Catch塊。注意,下面這種方法是無效的。

        try {
            toast.show();
        }catch (WindowManager.BadTokenException e){

        }

Toast的show方法僅僅是建立Tn提交給系統服務,請求顯示Toast而已。BadTokenException在系統服務回撥Tn的show方法時產生。SmartToast採用的方法如下,新建一個SafeHandler作為Tn原有Handler的外殼,它的HandleMessage方法直接轉交給原有Handler的HandleMessage方法,只是在處理訊息方法的外層加上了Try-Catch塊。最後將SafeHandler注入Tn,取代原有Handler。

SafeHandler原始碼:

class SafeHandler extends Handler {
    //用來儲存Tn原有handler
    private Handler mNestedHandler;

    public SafeHandler(Handler nestedHandler) {
        //構造方法裡將Tn原有Handler傳入
        mNestedHandler = nestedHandler;
    }

    /**
     * handleMessage是在dispatchMessage裡被呼叫的,所以在這裡捕獲異常就可以
     * @param msg
     */
    @Override
    public void dispatchMessage(Message msg) {
        try {
            super.dispatchMessage(msg);
        } catch (WindowManager.BadTokenException e) {
        }
    }

    @Override
    public void handleMessage(Message msg) {
        //交由原有Handler處理
        mNestedHandler.handleMessage(msg);
    }

通過反射拿到Tn,將SafeHandler注入

            Field tnField = Toast.class.getDeclaredField("mTN");
            tnField.setAccessible(true);
            mTn = tnField.get(mToast);

            if (isSdk25()) {
                Field handlerField = mTn.getClass().getDeclaredField("mHandler");
                handlerField.setAccessible(true);
                Handler handlerOfTn = (Handler) handlerField.get(mTn);
                handlerField.set(mTn, new SafeHandler(handlerOfTn));
            }

型別訊息

github上有不少花樣Toast,五彩繽紛。SmartToast並不打算效仿,主流App很少採用如此花哨的訊息提示。主流App如淘寶、微信、優酷、微博等等的型別訊息提示,與ios非常相似。一是android UI確實醜,二是UI設計人員大多基於ios設計,很自然地把ios的風格帶進android。SmartToast提供了8種常見的訊息提示。

        //普通
        SmartToast.info("已在後臺下載");
        SmartToast.infoLong("已在後臺下載");
        //成功    
        SmartToast.success("重置成功");
        SmartToast.successLong("重置成功");
        //錯誤
        SmartToast.error("儲存失敗");
        SmartToast.errorLong("儲存失敗");
        //警告
        SmartToast.warning("電量過低,請充電");      
        SmartToast.warningLong("電量過低,請充電");
        //完成
        SmartToast.complete("下載完成");
        SmartToast.completeLong("下載完成");
        //失敗
        SmartToast.fail("儲存失敗");
        SmartToast.failLong("儲存失敗");
        //禁止
        SmartToast.forbid("當前賬戶不允許匯款操作");
        SmartToast.forbidLong("當前賬戶不允許匯款操作");
        //等候
        SmartToast.waiting("已在後臺下載,請耐心等待");
        SmartToast.waitingLong("已在後臺下載,請耐心等待");

新的複用策略

在1.x版本中說過,Toast存在兩個問題,一是當彈出一個新的Toast時,需等到前一個Toast的duration耗盡才彈出;二是段時間內彈出同樣訊息內容的Toast會重複彈出。當然,在大多廠商裝置在android 7.0左右往後,都做了不同程度的優化,可能部分或全部避免了上述的問題。常見的優化方式如下:

public final class ToastUtil {

    private static Toast sToast;

    public static void showToast(CharSequence msg, int duration) {

        if (sToast == null) {
            sToast = Toast.makeText(MyApplication.sContext, "", Toast.LENGTH_SHORT);
        }

        sToast.setText(msg);
        sToast.setDuration(duration);
        sToast.show();
    }

}

如此,存在兩個缺陷。一是Toast尚未消失,改變訊息文字,無彈出效果;改變gravity,Toast的位置並不會發生變化。前者尚可接受,後者就不可原諒了。

原因是這樣的:對於同一個Toast例項,多次呼叫show方法發起顯示請求,如果它已在顯示佇列裡,系統服務只會更改其duration,並沒有新增新視窗。

 原始碼來自sdk25

 @Override
 public void enqueueToast(String pkg, ITransientNotification callback, int duration){
        ...
        //ToastRecord 代表依次Toast顯示請求,筆者註釋
        ToastRecord record;
        //根據callback查詢ToastRecord,callback其實就是Tn,每個Toast例項的Tn是固定的,筆者註釋
        int index = indexOfToastLocked(pkg, callback);
        // 如果存在,則只更新duration,筆者註釋
        if (index >= 0) {
           record = mToastQueue.get(index);
           record.update(duration);
           } else {
              ...
                  }

         ...                
  }


 原始碼來自sdk25

private static final class ToastRecord{
   ...
        void update(int duration) {
            this.duration = duration;
        }
    
    ...
}

彈出動畫和gravity都是對視窗起作用的,既然沒有新增新的視窗,那麼沒有效果是正常的。而文字能夠改變只是Toast窗口裡的TextView自身重繪所致。

Android8.0開始,google及各大廠商都對Toast進行了優化。這種單例使用模式在部分裝置上存在嚴重問題。下面以華為麥芒7裝置為例,同一個Toast例項呼叫show方法後,短時間內再次呼叫,Toast會立即消失,而且也也不會彈出新的Toast,持續觸發,Toast將一直不顯示。

而且,在1.x版本中,我們通過反射呼叫Tn的hide方法,解決gravity不生效及訊息文字改變無動畫的問題,在Android8.0及以上裝置上並不理想。

綜合以上原因,為了儘可能相容所有裝置,我改變了Toast的複用策略。

首先,當一個Toast觸發後,從顯示到消失的時間內,再次觸發相同的Toast(內容及位置一致),直接忽略。也就是說不再重複呼叫Toast的show方法。

另外,觸發一個與上次顯示內容或位置不相同的Toast,並且當前Toast尚未消失,不再呼叫Tn隱藏,而是直接cancel掉,並重建Toast例項。這種情況發生的次數較少(這也是很多人沒有注意到這種場合下Gravity不能立即生效的原因),所以並不會引起頻繁建立Toast例項,絕大多數時候都是複用例項。

小的優化

①改變Toast的背景不能通過setBackgroundColor。因為Toast的背景實際上是一張圖片,而且不同廠商裝置使用的圖片大小和圓角不盡相同。如魅族的圓角就比較小,而華為的就比較大。如果直接設定setBackgroundColor,你的Toast就成方方正正的了,哈哈。在1.x版本中,我們採用ShapeDrawable設定,忽略不同廠商裝置差異,設定了固定的圓角。在2.x我們改變了策略,直接獲取Toast的背景,得到Drawable圖片,如果是GradientDrawable例項,則直接設定新的顏色,否則一律採用Tint機制,改變其顏色。

@Override
protected void setupToast() {
   ...
   Drawable bg = mView.getBackground();

   if (bg instanceof GradientDrawable) {
     ((GradientDrawable) bg).setColor(ToastDelegate.get().getToastSetting().getBgColor());
                    } else {
     DrawableCompat.setTint(bg, ToastDelegate.get().getToastSetting().getBgColor());
                    }

   mView.setBackgroundDrawable(bg);
   ...

}

這樣定製的Toast的大小和形狀以及透明度都與目標裝置完全一致了,僅僅顏色不同。

②離開當前Activity,Toast自動消失

Toast是獨立視窗,並不會隨著Activity的銷燬而消失。在1.x版本釋出時,有讀者詢問能夠設定離開當前頁面Toast立即消失。2.x版中,只需如此設定:

        SmartToast.setting()
                .dismissOnLeave(true);

這樣,無論是進入新的activity還是退出當前Activity,當前顯示的Toast都會立即消失。

SmartTopbar

這種Topbar類似QQ、微信頂部彈窗。在程式碼實現上,可以說我是奪天之功。靈感是這麼來的,在1.x版本中,有讀者問是否能夠實現頂部彈出的Snackbar。Snackbar是相當優秀的底部彈窗了,如果通過修改Snackbar實現類似QQ頂部彈窗功能是再好不過了。不過QQ、微信彈窗是獨立視窗,可以懸浮於應用之外,Snackbar是依附於Activity的,只能應用內彈出。大多數應用的使用場景都是應用內彈窗,而且獨立彈窗需要危險許可權,使用者拒絕則無法顯示,且各大廠商裝置關於懸浮窗方面的坑又比比皆是,所以應用內頂部彈窗不要採用獨立懸浮窗比較好。

開始改造過程。

首先,改變顯示位置。1.x版本中說過,Snackbar是通過將View(實現類SnackbarLayout)嵌入當前Activity id為android.R.id.content或者某個CoordinateLayout中,具體會根據你提供的view為根基,沿著整個View Tree上溯,先找個哪個,就將其嵌入其內,且layout_gravity為bottom。

<view xmlns:android="http://schemas.android.com/apk/res/android"
      class="android.support.design.widget.Snackbar$SnackbarLayout"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:layout_gravity="bottom"
      android:theme="@style/ThemeOverlay.AppCompat.Dark"
      style="@style/Widget.Design.Snackbar" />

是不是直接把layout_gravity改為top就完事了?當然還不夠,因為無論SnackbarLayout無論如何嵌入,都是android.R.id.content的子View,而android.R.id.content是位於狀態列下面的,彈窗是無法覆蓋住狀態列的,狀態列的顏色和彈窗不一致的話,這就太不協調了。當然,如果不考慮提供給別人使用的話,可以通過setSystemUiVisibility使其侵入狀態列。為了通用,我們將其嵌入DecorView。

修改findSuitableParent(View view)方法,不再按照舊的邏輯沿View Tree上溯,而是直接返回傳入的View,然後我們建立Topbar的時候直接將DecorView傳入。

    private static ViewGroup findSuitableParent(View view) {
        return (ViewGroup) view;
    }

這樣,Topbar就會蓋住狀態列了。

QQ、微信的頂部彈窗都是可以手滑消失的,Snackbar只有以CoordinateLayout為容器的時候才支援手滑消失。我們需要自己實現手滑消失麼?No,牛頓哥說過,站在巨人的肩膀上,我們才能走的更遠。哈哈。繼續奪天之功。在嵌入Topbar之前,我先嵌入一個CoordinateLayout到DecorView,然後再將Topbar嵌入CoordinateLayout中,Topbar就可以支援手滑了。

獲取Topbar的入口

    /**
     * 獲取Topbar的入口
     * @param activity
     * @return
     */
    public static IBarShow get(Activity activity) {
        return TopbarDelegate.get().nestedDecorView(activity);
    }

通過預定義的id值smart_show_top_bar_container,判斷CoordinateLayout是否已嵌入,否則先嵌入CoordinateLayout

public IBarShow nestedDecorView(Activity activity) {
        //儲存當前頁面的Context
        mPageContext = activity;
        //取出DecorView
        ViewGroup decorView = activity == null ? null : (ViewGroup) activity.getWindow().getDecorView();
        CoordinatorLayout topbarContainer = null;
        if (decorView != null) {
            //判斷CoordinateLayout是否已嵌入,不存在則先建立CoordinateLayout
            topbarContainer = decorView.findViewById(R.id.smart_show_top_bar_container);
            if (topbarContainer == null) {
                topbarContainer = new CoordinatorLayout(activity);
                topbarContainer.setId(R.id.smart_show_top_bar_container);
                ViewGroup.LayoutParams lp = new ViewGroup.LayoutParams(
                        ViewGroup.LayoutParams.MATCH_PARENT,
                        ViewGroup.LayoutParams.WRAP_CONTENT
                );
                //將CoordinateLayout嵌入DecorView
                decorView.addView(topbarContainer, lp);
            }

        }
        return getFromView(topbarContainer);
    }

在上面,CoordinateLayout的佈局引數中,寬度設定為WRAP_CONTENT,在Topbar消失,佈局從CoordinateLayout移除的時,高度為0,不會繪製,不影響原有佈局。CoordinateLayout沒有設定layout_gravity為bottom,所以會頂部顯示,也不必修改SnackbarLayout的ayout_gravity為top了。

以CoordinateLayout為容器,將Topbar嵌入

    protected IBarShow getFromView(View view) {
        if (mBar == null || mBaseTraceView != view || isDismissByGesture()) {
            mBaseTraceView = view;
            rebuildBar(view);
        }
        return this;
    }

最後,就剩下動畫了。顯示時向下彈出,消失時往上彈去。

顯示和消失動畫最終是在animateViewIn()和animateViewOut(final int event)方法裡設定的,實現上區分了sdk12(含)以上和以上的情況。我們的庫最低支援sdk15,所以只考慮sdk >= 12的情況。

先分析Snackbar的顯示動畫,原始碼基於design 27.0.1

   void animateViewIn() {
            ...
            //取出View的高度,通過offset或者translationY的方式使其處於初始顯示位置,
            //即所嵌容器的下面,一般也就是螢幕下面
            final int viewHeight = mView.getHeight();
            if (USE_OFFSET_API) {
                ViewCompat.offsetTopAndBottom(mView, viewHeight);
            } else {
                mView.setTranslationY(viewHeight);
            }

            //定義屬性動畫,變化值從view高度值到0,
            final ValueAnimator animator = new ValueAnimator();
            animator.setIntValues(viewHeight, 0);
            animator.setInterpolator(FAST_OUT_SLOW_IN_INTERPOLATOR);
            animator.setDuration(ANIMATION_DURATION);
            ...
            //註冊動畫值變化監聽器,按照變化值動態設定offset或者translationY形成動畫,筆者註釋
            animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                private int mPreviousAnimatedIntValue = viewHeight;

                @Override
                public void onAnimationUpdate(ValueAnimator animator) {
                    int currentAnimatedIntValue = (int) animator.getAnimatedValue();
                    if (USE_OFFSET_API) {
                        ViewCompat.offsetTopAndBottom(mView,
                                currentAnimatedIntValue - mPreviousAnimatedIntValue);
                    } else {
                        mView.setTranslationY(currentAnimatedIntValue);
                    }
                    mPreviousAnimatedIntValue = currentAnimatedIntValue;
                }
            });
            animator.start();
        }
    }
總結一下,先取出View的高度,通過offset或者translationY的方式使其處於初始顯示位置,即所嵌容器的下面,一般也就是螢幕下面。然後定義屬性動畫,變化值從view高度值到0,最後註冊動畫值變化監聽器,按照變化值動態設定offset或者translationY形成動畫。animateViewOut方法與此類似,只是定義的變化值是0到View高度,這裡不再貼出。

要想達到顯示時向下彈出,消失時向上彈去,只需將變化值分為設為負的view高度到0和0到負的View高度即可。

新定義一個方法,獲取設定動畫時,提供的view height。

    private int getAnimHeight() {
        return -mView.getHeight();
    }

分別在animateViewIn和animateViewOut中替換mView.getHeight(),以animateViewIn為例,

    void animateViewIn() {
        //替換mView.getHeight()
        final int viewHeight = getAnimHeight();
        if (USE_OFFSET_API) {
            ViewCompat.offsetTopAndBottom(mView, viewHeight);
        } else {
            mView.setTranslationY(viewHeight);
        }
        final ValueAnimator animator = new ValueAnimator();
        animator.setIntValues(viewHeight, 0);
        ...

    }

複用策略

Topbar由Snackbar改造而來,所以複用策略是一樣的,詳情參看github文件。並且不同於1.x版中需要你在BaseActivity中呼叫資源回收的方法,2.x版本通過為application註冊activityLifeCallback,自動回收資源及重建例項。

       sApplication.registerActivityLifecycleCallbacks(new ActivityLifecycleCallback() {
            ...

            @Override
            public void onActivityDestroyed(Activity activity) {
                super.onActivityDestroyed(activity);
                if (SnackbarDeligate.hasCreated()) {
                    SnackbarDeligate.get().destroy(activity);
                }

                if (TopbarDelegate.hasCreated()) {
                    TopbarDelegate.get().destroy(activity);
                }
            }

        });

前文說過,主流app的訊息提示越來越具有ios風格,對話方塊也是,SmartShow提供了幾種常見的Dialog,具體用法請參考github文件。

最後,謝謝各位老鐵一直以來的關注、支援和指正。如果及覺得SmartShow用的還可以,記得github上Start喲!