前言
不知道大家是否遇到了当你们的App在5.0以上系统中被用户关闭消息通知后(其实用户本身只是想关闭Notification的,猜测),系统的Toast也神奇的无法显示。当然这个问题并不复杂,有很多种解决方案,我们逐一探讨一下,然后来看看到底哪种方式会好一点。
问题分析
直接跟踪Toast的源码,其实我们可以发现,果真Toast其实是通过NotificationManagerService 维护一个toast队列,然后通知给Toast中的客户端 TN 调用 WindowManager 添加view。那么当用户关闭通知权限后自然也无法显示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
}
}
....
static private INotificationManager getService() {
if (sService != null) {
return sService;
}
sService = INotificationManager.Stub.asInterface(ServiceManager.getService("notification"));
return sService;
}
解决思路
这边就来说说我这边的几种解决方案,就是大致我能想到的,哈哈。
自己仿照系统的Toast然后用自己的消息队列来维护,让其不受NotificationManagerService影响。
通过WindowManager自己来写一个通知。
通过Dialog、PopupWindow来编写一个自定义通知。
通过直接去当前页面最外层content布局来添加View。
仿照系统Toast自己来维护Toast消息队列
通过WindowManager自己来写一个通知
说起WindowManager,其实我对这个东西的第一印象就是强大,悬浮窗什么的其实都是通过WindowManager来实现的,那么我们来看看怎么实现,我就直接上代码了
public class Toast {
private Context mContext;
private WindowManager wm;
private int mDuration;
private View mNextView;
public static final int LENGTH_SHORT = 1500;
public static final int LENGTH_LONG = 3000;
public Toast(Context context) {
mContext = context.getApplicationContext();
wm = (WindowManager) mContext.getSystemService(Context.WINDOW_SERVICE);
}
public static Toast makeText(Context context, CharSequence text,
int duration) {
Toast result = new Toast(context);
View view = android.widget.Toast.makeText(context, text, android.widget.Toast.LENGTH_SHORT).getView();
if (view != null){
TextView tv = (TextView) view.findViewById(android.R.id.message);
tv.setText(text);
}
result.mNextView = view;
result.mDuration = duration;
return result;
}
public static Toast makeText(Context context, int resId, int duration)
throws Resources.NotFoundException {
return makeText(context, context.getResources().getText(resId),duration);
}
public void show() {
if (mNextView != null) {
WindowManager.LayoutParams params = new WindowManager.LayoutParams();
params.gravity = Gravity.CENTER | Gravity.CENTER_HORIZONTAL;
params.height = WindowManager.LayoutParams.WRAP_CONTENT;
params.width = WindowManager.LayoutParams.WRAP_CONTENT;
params.flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
| WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON;
params.format = PixelFormat.TRANSLUCENT;
params.windowAnimations = android.R.style.Animation_Toast;
params.y = dip2px(mContext, 64);
params.type = WindowManager.LayoutParams.TYPE_TOAST;
wm.addView(mNextView, params);
new Handler().postDelayed(new Runnable() {
@Override
public void run() {
if (mNextView != null) {
wm.removeView(mNextView);
mNextView = null;
wm = null;
}
}
}, mDuration);
}
}
/**
* dip与px的转换
*
* @参数 @param context
* @参数 @param dipValue
* @返回值 int
*
*/
private int dip2px(Context context, float dipValue) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (dipValue * scale + 0.5f);
}
}
嗯,这样写应该是没问题的,然后为啥没有效果呢??好吧,其实写了这么多,就是给自己挖坑,很明显,这个东西在现在的5.0以上机器中有一个悬浮窗权限,而且系统默认是关闭该权限的,只有用户手动打开才能显示,而且代码中也要添加如下一条权限。
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
那么问题又回来了,用户一般不会打开,这不是又白搞么。
通过Dialog、PopupWindow来编写一个自定义通知。
这个方案貌似也是可行的,代码就不写了,提醒一点就是一般来说Dialog和PopupWindow显示时有一个隔板,用户是无法点击其余部分控件的,所以记得加上以上属性。
public static void setPopupWindowTouchModal(PopupWindow popupWindow,
boolean touchModal) {
if (null == popupWindow) {
return;
}
Method method;
try {
method = PopupWindow.class.getDeclaredMethod("setTouchModal",
boolean.class);
method.setAccessible(true);
method.invoke(popupWindow, touchModal);
}
catch (Exception e) {
e.printStackTrace();
}
}
通过直接去当前页面最外层content布局来添加View。
说说这种方式吧,其实刚开始我也是没有想到的,因为一般很少回去直接拿Activity最外层的content布局去创建一个View并且显示在上面的。
(ViewGroup) ((Activity) context).findViewById(android.R.id.content);
其实我们是可以直接通过findViewById去直接拿到最外层布局的哦,当然context记得一定是Activity。
然后通过以下代码就可以直接把布局显示在当前content布局之上。
ViewGroup container = (ViewGroup) ((Activity) context).findViewById(android.R.id.content);
View v = ((Activity) context).getLayoutInflater().inflate(R.layout.etoast,container);
这种方式是不是有点奇怪,好吧,我也是这么想的,不过感觉还是非常的实在的,也不复杂,东西也不多,直接上代码。
public class EToast {
public static final int LENGTH_SHORT = 0;
public static final int LENGTH_LONG = 1;
private static EToast result;
//动画时间
private final int ANIMATION_DURATION = 600;
private static TextView mTextView;
private ViewGroup container;
private View v;
//默认展示时间
private int HIDE_DELAY = 2000;
private LinearLayout mContainer;
private AlphaAnimation mFadeOutAnimation;
private AlphaAnimation mFadeInAnimation;
private boolean isShow = false;
private static Context mContext;
private Handler mHandler = new Handler();
private EToast(Context context) {
mContext = context;
container = (ViewGroup) ((Activity) context)
.findViewById(android.R.id.content);
v = ((Activity) context).getLayoutInflater().inflate(
R.layout.etoast, container);
mContainer = (LinearLayout) v.findViewById(R.id.mbContainer);
mContainer.setVisibility(View.GONE);
mTextView = (TextView) v.findViewById(R.id.mbMessage);
}
public static EToast makeText(Context context, String message, int HIDE_DELAY) {
if(result == null){
result = new EToast(context);
}else{
//这边主要是当切换Activity后我们应该更新当前持有的context,不然无法显示的
if(!mContext.getClass().getName().equals(context.getClass().getName())){
result = new EToast(context);
}
}
if(HIDE_DELAY == LENGTH_LONG){
result.HIDE_DELAY = 2500;
}else{
result.HIDE_DELAY = 1500;
}
mTextView.setText(message);
return result;
};
public static EToast makeText(Context context, int resId, int HIDE_DELAY) {
String mes = "";
try{
mes = context.getResources().getString(resId);
} catch (Resources.NotFoundException e) {
e.printStackTrace();
}
return makeText(context,mes,HIDE_DELAY);
}
public void show() {
if(isShow){
//如果已经显示,则再次显示不生效
return;
}
isShow = true;
//显示动画
mFadeInAnimation = new AlphaAnimation(0.0f, 1.0f);
//消失动画
mFadeOutAnimation = new AlphaAnimation(1.0f, 0.0f);
mFadeOutAnimation.setDuration(ANIMATION_DURATION);
mFadeOutAnimation
.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
//消失动画消失后记得刷新状态
isShow = false;
}
@Override
public void onAnimationEnd(Animation animation) {
//隐藏布局,没有remove主要是为了防止一个页面创建多次布局
mContainer.setVisibility(View.GONE);
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});
mContainer.setVisibility(View.VISIBLE);
mFadeInAnimation.setDuration(ANIMATION_DURATION);
mContainer.startAnimation(mFadeInAnimation);
mHandler.postDelayed(mHideRunnable, HIDE_DELAY);
}
private final Runnable mHideRunnable = new Runnable() {
@Override
public void run() {
mContainer.startAnimation(mFadeOutAnimation);
}
};
public void cancel(){
if(isShow) {
isShow = false;
mContainer.setVisibility(View.GONE);
mHandler.removeCallbacks(mHideRunnable);
}
}
//这个方法主要是为了解决用户在重启页面后单例还会持有上一个context,
//并且上面的mContext.getClass().getName()其实是一样的
//所以使用上还需在你们的BaseActivity的onDestroy()方法中调用该方法
public static void reset(){
result = null;
}
public void setText(CharSequence s){
if(result == null) return;
TextView mTextView = (TextView) v.findViewById(R.id.mbMessage);
if(mTextView == null) throw new RuntimeException("This Toast was not created with Toast.makeText()");
mTextView.setText(s);
}
public void setText(int resId) {
setText(mContext.getText(resId));
}
}
简单说下吧,代码应该是很简单的,然后简单封装了和Toast相同的几个方法。嗯,其实大家也应该能发现我这边的布局其实是一直都在的,只是直接GONE掉了。所以呢,还是有待优化的地方,当然可以去想想是不是可以直接remove()掉什么的。我这边也没有用队列,我觉得在一个Toast显示的期间如果再需要显示另一个Toast,直接把当前的文本改过来就好了,没有必要搞个队列的,而且系统Toast我最厌恶的就是这个了,用户如果不停的点击,那Toast一个接一个的显示,这个我觉得是不合理的。上面的布局文件我也贴一下吧。有一点大家还是要注意下,因为我在完善的过程中其实遇到了很多种情况的BUG,所以最终需要大家再BaseActivity中的onDestory()方法中去手动调用一下EToast.reset();具体可以看源码中的解释。
etoast.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:id="@+id/mbContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingLeft="50dp"
android:paddingRight="50dp"
android:layout_marginBottom="50dp"
android:gravity="bottom|center">
<LinearLayout
android:id="@+id/toast_linear"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/shape_eroast_bg"
android:gravity="bottom|center"
android:padding="5dp"
android:orientation="vertical" >
<TextView
android:id="@+id/mbMessage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_weight="1"
android:gravity="center"
android:layout_margin="5dp"
android:layout_gravity="center"
android:textColor="#ffffffff"
android:shadowColor="#BB000000"
android:shadowRadius="2.75"/>
</LinearLayout>
</LinearLayout>
shape_eroast_bg.xml
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 实心 -->
<solid
android:color="@color/BlackTransparent" />
<corners
android:radius="45dp"
/>
</shape>
优化
上面的几种方式我大致也都走了一遍,其实我觉得都没啥区别,看你喜欢用哪种吧。我其实是采用了第四种,因为第一种的话我是不喜欢队列的,比如5个Toast排队还要一个一个等待显示,这样的体验我是不喜欢的。第二种就不推荐了,因为又涉及到了其他的权限。第三种我没试,实现应该是不难的,效果的话也是随你喜欢。最后我采用的是第四种,因为这种方式之前是没有用到过的,也尝试一下。
那么来说说优化,如果直接替换掉系统的Toast,那相当的暴力,肯定妥妥的。那么我们能不能智能的去判断一下呢,如果用户没有关闭通知权限,那么久跟随系统的Toast去吧,这样好让App采用系统风格,对吧。
方法是有的,如下:
/**
* 用来判断是否开启通知权限
* */
private static boolean isNotificationEnabled(Context context){
AppOpsManager mAppOps = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
ApplicationInfo appInfo = context.getApplicationInfo();
String pkg = context.getApplicationContext().getPackageName();
int uid = appInfo.uid;
Class appOpsClass = null; /* Context.APP_OPS_MANAGER */
try {
appOpsClass = Class.forName(AppOpsManager.class.getName());
Method checkOpNoThrowMethod = appOpsClass.getMethod(CHECK_OP_NO_THROW, Integer.TYPE, Integer.TYPE, String.class);
Field opPostNotificationValue = appOpsClass.getDeclaredField(OP_POST_NOTIFICATION);
int value = (int)opPostNotificationValue.get(Integer.class);
return ((int)checkOpNoThrowMethod.invoke(mAppOps,value, uid, pkg) == AppOpsManager.MODE_ALLOWED);
} catch (Exception e) {
e.printStackTrace();
}
return true;
}
据说android24 可以使用NotificationManagerCompat.areNotificationsEnabled()来判断,具体大家可以尝试。那么如何来替换老项目中的Toast呢?
我这边的话自定义Toast就是EToast了。为什么要E开头呢,因为公……你懂的。然后写一个Toast的工具类,如下:
public class Toast {
private static final String CHECK_OP_NO_THROW = "checkOpNoThrow";
private static final String OP_POST_NOTIFICATION = "OP_POST_NOTIFICATION";
private static int checkNotification = -1;
private Object mToast;
public static final int LENGTH_SHORT = 0;
public static final int LENGTH_LONG = 1;
private Toast(Context context, String message, int duration) {
try{
if (checkNotification == -1){
checkNotification = isNotificationEnabled(context) ? 0 : 1;
}
if (checkNotification == 1) {
mToast = EToast.makeText(context, message, duration);
} else {
mToast = android.widget.Toast.makeText(context, message, duration);
}
}catch (Exception e){
e.printStackTrace();
}
}
private Toast(Context context, int resId, int duration) {
if (checkNotification == -1){
checkNotification = isNotificationEnabled(context) ? 0 : 1;
}
if (checkNotification == 1) {
mToast = EToast.makeText(context, resId, duration);
} else {
mToast = android.widget.Toast.makeText(context, resId, duration);
}
}
public static Toast makeText(Context context, String message, int duration) {
return new Toast(context,message,duration);
}
public static Toast makeText(Context context, int resId, int duration) {
return new Toast(context,resId,duration);
}
public void show() {
if(mToast instanceof EToast){
((EToast) mToast).show();
}else if(mToast instanceof android.widget.Toast){
((android.widget.Toast) mToast).show();
}
}
public void cancel(){
if(mToast instanceof EToast){
((EToast) mToast).cancel();
}else if(mToast instanceof android.widget.Toast){
((android.widget.Toast) mToast).cancel();
}
}
public void setText(int resId){
if(mToast instanceof EToast){
((EToast) mToast).setText(resId);
}else if(mToast instanceof android.widget.Toast){
((android.widget.Toast) mToast).setText(resId);
}
}
public void setText(CharSequence s){
if(mToast instanceof EToast){
((EToast) mToast).setText(s);
}else if(mToast instanceof android.widget.Toast){
((android.widget.Toast) mToast).setText(s);
}
}
/**
* 用来判断是否开启通知权限
* */
private static boolean isNotificationEnabled(Context context){
AppOpsManager mAppOps = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
ApplicationInfo appInfo = context.getApplicationInfo();
String pkg = context.getApplicationContext().getPackageName();
int uid = appInfo.uid;
Class appOpsClass = null; /* Context.APP_OPS_MANAGER */
try {
appOpsClass = Class.forName(AppOpsManager.class.getName());
Method checkOpNoThrowMethod = appOpsClass.getMethod(CHECK_OP_NO_THROW, Integer.TYPE, Integer.TYPE, String.class);
Field opPostNotificationValue = appOpsClass.getDeclaredField(OP_POST_NOTIFICATION);
int value = (int)opPostNotificationValue.get(Integer.class);
return ((int)checkOpNoThrowMethod.invoke(mAppOps,value, uid, pkg) == AppOpsManager.MODE_ALLOWED);
} catch (Exception e) {
e.printStackTrace();
}
return true;
}
}
然后直接把你项目的import Android.widget.Toast 全局替换成import 你Toast的包名 即可。