系統原生的Toast是用了INotificationManager類來顯示的, Android 5.0以上系統使用者隻要關閉了通知權限,在大部分手機上Toast也将不能顯示(有部分國産手機5.0以上的系統禁了通知權限仍能顯示Toast)。
/**
* Show the view for the specified duration.
*/
public void show() {
if (mNextView == null) {
throw new RuntimeException("setView must have been called");
}
INotificationManager service = getService();
String pkg = mContext.getOpPackageName();
TN tn = mTN;
tn.mNextView = mNextView;
try {
service.enqueueToast(pkg, tn, mDuration);
} catch (RemoteException e) {
// Empty
}
}
針對這種情況有以下幾種解決方案:
1、提醒使用者打開通知權限
2、讓系統認定為系統Toast(推薦)
3、使用Dialog或PopupWindow實作Toast(推薦)
1、提醒使用者打開通知權限
優點:有了通知權限其他都不是問題;
缺點:是否打開權限取決于使用者;
針對不同版本系統擷取通知權限是否打開的方法也不一樣,4.4之前的系統沒有通知權限預設true,4.4到7.0系統沒有直接擷取通知權限方法需要通過反射擷取,7.0以上系統可以使用NotificationManager#areNotificationsEnabled()方法直接擷取應用是否有通知權限。
/**
* 檢查通知欄權限有沒有開啟
*/
public static boolean isNotificationEnabled(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
return ((NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)).areNotificationsEnabled();
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
AppOpsManager appOps = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
ApplicationInfo appInfo = context.getApplicationInfo();
String pkg = context.getApplicationContext().getPackageName();
int uid = appInfo.uid;
try {
Class<?> appOpsClass = Class.forName(AppOpsManager.class.getName());
Method checkOpNoThrowMethod = appOpsClass.getMethod("checkOpNoThrow", Integer.TYPE, Integer.TYPE, String.class);
Field opPostNotificationValue = appOpsClass.getDeclaredField("OP_POST_NOTIFICATION");
int value = (Integer) opPostNotificationValue.get(Integer.class);
return (Integer) checkOpNoThrowMethod.invoke(appOps, value, uid, pkg) == 0;
} catch (NoSuchMethodException | NoSuchFieldException | InvocationTargetException | IllegalAccessException | RuntimeException | ClassNotFoundException ignored) {
return true;
}
} else {
return true;
}
}
雖然有些使用者是不小心關閉通知權限的,但某些使用者就是不想接受應用的通知才關閉的權限,讓他們打開很大可能也會選擇拒絕,這種方案隻能pass。
2、讓系統認定為系統Toast(推薦)
優點:對原先使用那套ToastUtil改動最小;
缺點:國内廠商對Android系統定制化嚴重,可能需要做機型相容,不過暫時隻發現華為P20;
檢視系統源碼可以看到NotificationManagerService.java裡有這樣一個判斷是否是系統Toast,判斷條件隻要兩者滿足其一就行,由此我們隻要将enqueueToast的參數pkg改成"android"即可。
通過反射代理INotificationManager,在執行enqueueToast方法時讓系統認定為是原生Toast(但由于國内廠商對系統的定制化可能需要做其他相容性處理,目前隻知道華為P20有問題)。
隻需要在原來封裝的那套ToastUtil方法最終show的地方調用下面的show方法即可。
/**
* 顯示
*
* @param context
* @param toast
*/
public static void show(Context context, Toast toast) {
if (context == null || toast == null) {
throw new RuntimeException("context 與 toast不能為null");
}
if (isNotificationEnabled(context)) {
toast.show();
} else {
try {
Method getServiceMethod = Toast.class.getDeclaredMethod("getService");
getServiceMethod.setAccessible(true);
if (iNotificationManagerObject == null) {
iNotificationManagerObject = getServiceMethod.invoke(null);
Class iNotificationManagerClazz = Class.forName("android.app.INotificationManager");
Object iNotificationManagerProxy = Proxy.newProxyInstance(toast.getClass().getClassLoader(), new Class[]{iNotificationManagerClazz}, new InvocationHandler() {
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
if ("enqueueToast".equals(method.getName())//原生系統用了enqueueToast
|| "enqueueToastEx".equals(method.getName())//華為P20用了enqueueToastEx
) {
//強制變成系統Toast
args[0] = "android";
}
return method.invoke(iNotificationManagerObject, args);
}
});
Field field = Toast.class.getDeclaredField("sService");
field.setAccessible(true);
//替換Toast裡的sService
field.set(null, iNotificationManagerProxy);
}
toast.show();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
}
}
3、使用Dialog或PopupWindow實作Toast(推薦)
缺點:需要傳目前Activity的上下文;
優點:無需權限在頁面上顯示完全沒問題;
根據應用是否有通知權限分别顯示原生Toast和自定義實作的Toast,我這邊用了PopupWindow實作了一個Toast。
public void show() {
initToast();
startTimer();
if (Util.isNotificationEnabled(activity)) {
//有權限使用原生Toast
if (contentView != null) {
//視圖
setCustomView();
toast.setView(contentParentView);
} else {
//text
toast.setGravity(gravity, offsetX, offsetY);
toast.setText(text);
}
toast.show();
} else {
//無權限使用PopupWindow
if (contentView != null) {
//視圖
setCustomView();
popupWindow.setContentView(contentParentView);
} else {
//text
((TextView) defaultView.findViewById(R.id.tv_default_text)).setText(text);
popupWindow.setContentView(defaultView);
}
if (!popupWindow.isShowing()) {
popupWindow.showAtLocation(activity.getWindow().getDecorView(), gravity, offsetX, offsetY);
}
}
}
用計時器來取消顯示PopupWindow
/**
* 開始計時
*/
private void startTimer() {
stopTimer();
timer = new Timer();
timerTask = new TimerTask() {
@Override
public void run() {
handler.post(runnable);
}
};
timer.schedule(timerTask, duration == Toast.LENGTH_SHORT ? LENGTH_SHORT : LENGTH_LONG);
}
/**
* 結束計時
*/
private void stopTimer() {
if (timer != null) {
timer.cancel();
timer = null;
}
if (timerTask != null) {
timerTask.cancel();
timerTask = null;
}
}
連續顯示View視圖土司時,如果直接設定View,
原生Toast會等上一個消失後再顯示;
而看PopupWindow#setContentView()方法可以發現如果PopupWindow已經顯示是不能再次設定contentView的;
這裡先讓Toast或PopupWindow添加一個内容容器,通過内容容器的addView和removeView實作良好的使用者體驗。
/**
* 設定View視圖
*/
private void setCustomView() {
contentParentView.removeAllViews();
if (contentView.getParent() != null) {
((ViewGroup) contentView.getParent()).removeView(contentView);
}
contentParentView.addView(contentView);
}
在依賴包裡面也封裝了一個ToastViewUtil(具體使用),方法與ToastUtil一樣。
以上提供了幾種Toast在無權限下的解決方案,2、3都是不錯的選擇。
源碼