開源庫地址:https://github.com/the-pig-of-jungle/smart-show
Toast工作原理依賴於通知,關閉應用通知權限後,Toast無法顯示。在發佈SmartShow1.0.0版的時候,我注意到了這個問題,立即用自己的手機(魅族pro 6 plus)對淘寶、優酷等知名app進行測試,發現關閉通知權限後,它們的“再按一次退出程序”的Toast無法顯示。因爲Toast的工作機制如此,我並沒有把它當做一個問題看待。但是在前兩篇文章發佈時,關閉通知權限依然能夠顯示Toast的呼聲之高,讓我不得不着手解決這個問題。
有不少Toast開源庫解決了該問題,採用獨立懸浮窗,也就是另起爐竈,廢棄了原生Toast,自行實現一套彈窗提示。我們知道,不同手機品牌設備的Toast外觀及彈出動畫不盡相同,直接強行統一成一種風格,並不一定符合開發者的意願,我們應該把是否定製Toast的權利交給開發者。另外,獨立懸浮窗本身也需要申請權限,用戶會關閉通知權限,難道就沒有可能關閉懸浮窗權限麼?
完美的解決方案是什麼?當通知權限關閉時,我們需要製造一個VirtualToast來代替,對開發者來說,在使用上和原生Toast毫無差別。對app用戶來說,在體驗上(彈窗的外觀、動畫)與原生一致。
乍一看,這個問題好像很棘手,換一個角度思考問題,則柳暗花明,哈哈。
先進行技術選型,基於上面提到的原因,我們採用Dialog而不是懸浮窗。
先分析一下,關閉通知權限後,Toast的工作流程卡在了哪裏。Toast的工作流程是一個基於Binder的IPC(進程通信)過程,應用程序作爲客戶端僅僅發起Toast請求和被動接受回調。系統服務負責管理請求隊列、Token等,並通過Toast的內部類Tn來實現與應用程序的交互,即通過回調Tn的show/hide方法來顯示/隱藏Toast窗口。關閉了通知權限,導致無法與系統服務“通信”,最終無法添加Toast窗口。
不過當調用Toast的show方法時,此時添加窗口所需的View及各種窗口參數已全部準備就緒。
public class Toast{
public void show() {
...
//創建tn,筆者註釋
TN tn = mTN;
//窗口View,筆者註釋
tn.mNextView = mNextView;
...
}
}
private static class TN extends ITransientNotification.Stub {
...
TN(String packageName, @Nullable Looper looper) {
...
final WindowManager.LayoutParams params = mParams;
//窗口寬度,筆者註釋
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
//窗口高度,筆者註釋
params.width = WindowManager.LayoutParams.WRAP_CONTENT;
params.format = PixelFormat.TRANSLUCENT;
//窗口動畫,筆者註釋
params.windowAnimations = com.android.internal.R.style.Animation_Toast;
params.type = WindowManager.LayoutParams.TYPE_TOAST;
params.setTitle("Toast");
//不可獲取焦點,不接收觸碰事件,筆者註釋
params.flags = WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
| WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE;
...
}
...
}
如此,當通知權限關閉,我們只需在調用Toast的show方法前用Dialog“截胡”,將其View作爲Dialog的ContentView。
public void showToast() {
applySetting();
//如果具有通知權限,正常顯示Toast
if (Utils.isNotificationPermitted()) {
mToast.show();
} else {
//無通知權限,用Dialog代替
VirtualToastManager.get().show(getToastType(), mToast, mWindowParams);
}
}
將Toast窗口的參數設置給Dialog
public void show(int toastType, Toast toast, WindowManager.LayoutParams windowParams) {
//獲取棧頂activity
Activity activity = ActivityStack.getTop();
//若activity生命週期不符合條件,則什麼都不做
if (!Utils.isUpdateActivityUIPermitted(activity)) {
EasyLogger.d("activity is can not show virtual toast dialog ,so do nothing but return.");
return;
}
...
//取出Dialog窗口布局參數,原樣複製Toast窗口的佈局參數
WindowManager.LayoutParams lp = virtualToastDialog.getWindow().getAttributes();
//窗口寬度
lp.width = windowParams.width;
//窗口高度
lp.height = windowParams.height;
//窗口動畫
lp.windowAnimations = windowParams.windowAnimations;
//窗口gravity
lp.gravity = toast.getGravity();
//窗口x,y座標
lp.x = toast.getXOffset();
lp.y = toast.getYOffset();
ViewGroup content = virtualToastDialog.findViewById(android.R.id.content);
if (toast.getView().getParent() != content) {
if (toast.getView().getParent() != null) {
ViewGroup parent = (ViewGroup) toast.getView().getParent();
parent.removeView(toast.getView());
}
content.removeAllViews();
virtualToastDialog.setContentView(toast.getView());
}
try {
virtualToastDialog.show();
} catch (WindowManager.BadTokenException e) {
EasyLogger.e("bad token has happened when show virtual toast!");
mHostActivity = null;
}
...
}
去除Dialog顯示時周圍變暗的特性
virtualToastDialog.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);
Dialog不響應back鍵
virtualToastDialog.getWindow().setFlags(
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE);
如此這般VirtualToastDialog的外觀及動畫與目標設備的原生Toast完全一致了。就剩下間隔一段時間自動消失的控制了。
定義一個Handler,延時特定時間後,執行隱藏VirtualToastDialog的代碼。
Runnable dismissRunnable = toastType == BaseToastManager.PLAIN_TOAST ? mDismissPlainToastRunnable
: mDismissTypeToastRunnable;
mDismissHandler.removeCallbacks(dismissRunnable);
mDismissHandler.postDelayed(dismissRunnable, toast.getDuration() == Toast.LENGTH_SHORT ?
DURATION_SHORT : DURATION_LONG);
這裏,始終複用同一個runnable任務即可,並且在發起一個新的延時隱藏時,我們要清除所有已存在的延時隱藏任務。假如,在上一個隱藏任務還差200毫秒執行的時候,主動調用了隱藏方法使Toast消失,那麼再顯示一個‘’新的‘’Toast時,200毫秒後就被自動隱藏了。所以mDismissHandler.removeCallbacks(dismissRunnable)起到歸位的作用。