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喲!