猪小花1号

个人签名

282篇博客

Android通知栏介绍与适配总结(上篇)

猪小花1号2018-08-28 09:32

由于历史原因,Android在发布之初对通知栏Notification的设计相当简单,而如今面对各式各样的通知栏玩法,谷歌也不得不对其进行更新迭代调整,增加新功能的同时,也在不断地改变样式,试图迎合更多人的口味。本文总结了Android通知栏的版本迭代过程,在通知栏开发过程中所遇到的各种各样的坑,以及一些解决技巧,特别的,对于大众期盼的Android 7.0的到来,通知栏又会发生怎样的改变呢?接下来一一进行介绍。

Android通知栏发展历史

首先来看一张各个Android版本通知栏消息的全家福。

点击查看大图

Android通知栏从最初的Android1.1系统一直到如今的7.X版本,发生了翻天覆地的变化。从图中可以看出,1.X-2.2版本的通知栏采用了白色背景和黑色字体;2.3-4.X版本,默认背景变成了黑色,而主标题采用白色字体,内容为灰色字体。从Android5.0开始,又更改为白色背景和黑色字体。当然,这只是原生的Android系统通知栏默认颜色,许多厂商对每个Android的版本都尝试了各式各样的修改,在此不一一介绍。

下面分别介绍每个版本的更新和修改记录。

Android 1.X 修改记录^1

Android 1.X版本也就是第一个Android诞生的版本。从Android1.1版本开始,提供基本的通知栏消息功能,包含小图标、主标题、副标题和时间这四个元素。右上角有一个清除通知栏消息的按钮。需要说明的是,Android从一开始就提供了清除通知栏消息的功能并且保留至今,而iOS到现在都没有提供清除按钮。

Android 2.X 修改记录^2

Android 2.X版本的通知栏消息功能上并未发生变化,右上角的“clear notifications”缩减为了“clear”。2.2版本以前沿用了1.5的通知栏样式,从2.3版本开始重新设计,改成了暗色背景。

Android 3.X 修改记录^3

Android 3.X版本是专为Pad而设计的系统。通知栏消息带来了一些新的功能。

  • 非永久的通知栏消息的右边增加了“X”按钮,点击后该条通知可以立即清除。
  • 增加了RemoteControlClient,即远程控制媒体应用的功能。
  • 增加了LargeIcon,可以使用大图展示通知栏消息。

Android 4.1 修改记录^4

Android 4.1版本的通知栏在3.X版本的基础上进行了大量修改。增加了不少新功能。

  • 增加了Style
  • 增加了通知栏按钮
  • 支持通知栏展示的优先级配置
  • 通知栏背景改为黑色透明

通知栏样式

Android 4.1通知栏最大的变化就是增加了丰富多样的Style样式。通过设置样式,可以展示更大区域的通知消息,如展示大图和多行文字,也可以展示类似邮箱收发信的样式,同时支持自定义按钮并增加点击事件。但需要注意的是,只有最顶部的那条通知栏消息可以默认展示Style样式,其他消息默认是以普通样式展示。Style可以通过Notification.Builder.setStyle(Style)进行设置。具体支持的样式有:

Notification.BigPictureStyle

大图样式,即除了普通的通知栏消息内容外,可以在通知栏消息下方展示一张大图,最大高度支持256dp。

Notification.BigTextStyle

多行文字样式,可以支持多行文字的展示。经测试,在不同手机上能够支持的行数不一样,测试过的机子,最大支持12行。

Notification.InboxStyle

收件箱样式。支持展示具有一串消息内容的会话样式,适用于短信、邮件、IM等。

通知栏按钮

通知栏消息不管是普通样式还是Style样式,都支持两个按钮同时出现在一条通知栏消息的底部,通过这两个按钮,可以自定义一系列动作,包括回复信息和邮件,点赞等。通过Notification.Builder.addAction(Action)添加按钮。

通知栏优先级

Android 4.1通知栏增加了优先级的配置,优先级高的消息可以展示在最上方。谷歌设计优先级的初衷是根据不同的优先级来防止用户整天被各种莫名其妙的通知栏消息骚扰,重要的通知则应该适当提高优先级,使得用户可以快速地看到并回应,不重要的通知则降低优先级,防止用户被打扰。优先级一共有5个级别,分别是:

// 默认优先级
public static final int PRIORITY_DEFAULT = 0;
// 低优先级
public static final int PRIORITY_LOW = -1;
// 最低优先级
public static final int PRIORITY_MIN = -2;
// 高优先级
public static final int PRIORITY_HIGH = 1;
// 最高优先级
public static final int PRIORITY_MAX = 2;

Android 4.3 修改记录^6

Android 4.3通知栏没有发生大的变化。主要增加了两个小功能。

  • 增加了Notification AccessApi,允许可穿戴设备远程控制通知栏消息。
  • 增加了NotificationListenerService,允许接收到系统通知栏列表的变化

Android 5.X 修改记录^7

Android 5.X系统相较于以前的版本,可以说是一个真正可以和iOS抗衡的系统。材料设计给Android系统注入了新的活力,相应的通知栏消息也相较于上一个版本进行了改版。所发生的变化有:

  • 通知栏修改为白色背景,暗色字体,以适应材料设计风格。
  • 系统会忽略所有non-alpha通道的图标,包括按钮图标和主图标
  • 可以通过setColor()方法在图标后设置一个背景色。
  • 通知消息的声音将通过STREAM_RING或者STREAM_NOTIFICATION控制,以前是通过STREAM_MUSIC控制。
  • 锁屏状态下,可以控制通知栏消息的隐私程度。
  • 移除了RemoteControlClient,更改为NotificationCompat.MediaStyle实现。
  • 增加了Heads-up通知,即通过状态栏浮动窗口展示通知消息。

Android 6.X 修改记录^8

  • 移除了Notification.setLatestEventInfo()方法,通过持有Notification.Builder,然后使用build()方法可以更新同一个通知栏实例。
  • 允许用户控制应用通知的优先级。
  • 加入了免打扰模式(Do Not Disturb)。
  • 增加了getActiveNotifications()方法获取当前展示的通知消息。

Android 7.X 修改记录

  • 通知栏样式全面改版,小图标在左上角,大图标在右边,小图标、App应用名、副标题、数量和时间在第一行,第二行是主标题,第三行是内容。
  • 增加了Notification.DecoratedCustomViewStyle()Notification.DecoratedMediaCustomViewStyle(),帮助更好的装饰带有RemoteViews的通知栏消息。
  • 需要动态设置Builder.setShowWhen(true)才会显示时间。
  • 支持Action的直接回复,通过RemoteInput实现,且回复的消息内容支持立即添加到通知栏。
  • 支持通知消息组,相似的消息在达到一定数量后会按照消息组来显示。
  • 增加了NotificationManager.areNotificationsEnabled告知应用是否开启了通知权限。

Android通知栏踩坑与填坑指南

魅族5.X手机,大图显示问题

问题详情

Flyme系统对原生Android源码做了修改,采用BigPictureStyle方式显示大图通知栏的时候,消息与大图重合了,如下图。

解决方案

首先说一下为什么会有解决方案。展示大图这个功能开发完成后,拿去给产品演示。碰巧产品的机型就是一魅族手机T_T,结果当然是不能接受的,然后又一个巧合的事情出现了,那就是产品的手机里,京东App推了一条带大图的广告,他们居然能够解决这个问题!于是,我开始研究解决方案。

首先,通过BigPictureStyle来实现大图功能肯定是走不通的,因为事实就摆着行不通的嘛。京东的App肯定是通过RemoteViews来实现的。于是,开始走弯路,尝试通过RemoteViews来展示大图。但是谷歌规定,自定义布局展示的通知栏消息最大高度是64dp。那么,京东的App是怎么实现的?在尝试了各种方法以后,最后又是通过投机取巧的方式解决了问题:

private void showBigPictureNotificationWithMZ(Context context) {
    NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
    Notification.Builder builder = new Notification.Builder(context);
    Notification notification = generateNotification(builder);
    notification.bigContentView = mRemoteViews;
    notificationManager.notify(notifyId, notification);
}

需要先生成Notification的实例,然后手动给notification.bigContentView赋值,再notify,就可以了

顶部状态栏(StatusBar)小图标显示异常

问题详情

当通知来的时候,如果不在通知栏浏览,会在顶部状态栏出现一个向上翻滚动画的通知消息,这条通知消息左边是一个小图标。部分系统这个小图标显示异常,是一个纯灰色的正方形,如下图。

解决方案

首先产生灰色图标的原因就是5.0系统引入了材料设计,谷歌强制使用带有alpha通道的图标,并且RGB的alpha值必须是0(实测不为0也是可以的,但系统会忽略所有RGB值)。因此,使用JPG的图片是不行的,最好的代替方案就是一张背景透明的PNG图片。

Android 7.X机型,通知栏小图标显示成灰色

问题详情

这个问题跟第二个有点类似,在7.0系统及以上,有部分应用的小图标是灰色的,大图可以正常显示。碰巧的是,显示异常的小图标,颜色都是灰色的。

解决方案

小图标显示异常解决方案类似,将小图标替换为透明背景的PNG图片。

RemoteViews显示异常

问题详情

由于系统提供的通知栏消息类型有时候不能满足要求,部分通知栏消息采用自定义RemoteViews来实现。采用RemoteViews,特别是手动生成Bitmap然后直接传给一个自定义Layout,再通过setContentView方式设置通知栏消息时,会存在各种各样的坑。

Android通知栏的背景色有几种情况,白色、暗色、暗色透明和黑色。如果生成的Bitmap带背景色,这个背景色就很难选择。如果选择黑色背景,那么在白色通知栏的机型上就很难看。因此不能完全在各个系统上面完美展示出来。如果不带背景色,那么字体颜色也面临同样的困惑。试想,如果在白色的背景上显示白色的文字,用户看到白茫茫一片,是什么感受?

另一方面,大部分厂商对原生的Android系统都会有各种各样的改造,通知栏的样式也不例外。如果按照原生的样式来设计,那么在大部分国内厂商的机子上显示都和正常的普通通知栏消息不一样。例如华为6.0系统的机子,原生系统的时间线在右上角,华为的在左边,这样会给用户带来错觉。

解决方案

详见RemoteViews适配一节。

大尺寸小图标在部分机型上显示不正确

问题详情

这个问题主要在部分机型的4.X系统上遇见,小图标大小没有按照24dp裁剪,而是采用了桌面图标一样的大小96dp。具体适配不正常的机型有HTC Desire 820、Lenovo A320T。

解决方案

按照标准来,小图标大小为24dp,大图标为桌面icon图标大小96dp。具体可参考这里^14

部分机型不支持Style

具体机型见下图以及后面统计的表格。顺便提下,小米是其中之一,不知道他们为什么不支持额外的这些Style。

点击查看大图

通知栏更新频率

问题详情

每个应用基本都有自更新的逻辑,App开机的时候提示用户升级,点击升级按钮后在Notification出现一个下载带进度条的通知。应用一般是在开启一个工作线程在后台下载,然后在下载的过程中通过回调更新通知栏中的进度条。我们知道,下载进度的快慢是不可控的,如果每次下载中的回调都去更新通知栏,那么可能几百毫秒、几十毫秒、甚至几毫秒就更新一次通知栏,应用可能就会ANR,甚至崩溃。

解决方案

控制通知栏更新频率,一般控制在0.5s或者1s就可以了。在某一个更新时间间隔内下载的进度回调直接丢弃,需要注意的是下载完成的回调,需要实时回调通知栏消息显示下载完成。

恶心的后台通知和“守护”通知

问题详情

这个坑我不愿多介绍,只说结果。但凡存在后台通知或者“守护”通知的应用,在7.0系统以后都会原形毕露。还没有适配7.0的应用,可长点心儿吧~

解决方案

请弃坑。

小米推送SDK接入问题

问题详情

为了提升推送到达,考拉接入了小米推送的SDK。小米推送分为通知栏消息和透传消息,通知栏消息属于系统级推送,在MIUI的机子上可以在进程被杀死的情况下也能收到应用推送。然而有个问题,小米认为应用在前台时,不会回调任何方法;小米认为应用在后台的时候,收到通知栏消息的同时,会回调onNotificationMessageArrived方法。这时候就要小心翼翼地处理这条消息了。因为如果你的应用前后台判断逻辑和小米的不一样,那么就有可能小米帮你发了一条通知栏消息,你自己又发了一遍,造成通知栏消息的重复发送(这个坑考拉踩过T_T)。另一方面,在7.0系统的机子上,主标题和小图标的颜色是可以改变的,目前小米推送SDK没有开放这个接口供调用方定制。

解决方案

目前只能解决第一个问题——前后台判断的问题。应用是否在后台可以根据以下代码进行判断。在Android 5.0以上,可以通过ActivityManager.RunningAppProcessInfo判断,Android 5.0及以下版本通过ActivityManager.RunningTaskInfo判断。经测试,这个方案在Android 4.4以上结果是可以完全匹配的。

public static boolean isAppInBackgroundInternal(Context context) {
    ActivityManager manager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE);
    if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) {
        List<ActivityManager.RunningAppProcessInfo> runningProcesses = manager.getRunningAppProcesses();
        if (!ListUtils.isEmpty(runningProcesses)) {
            for (ActivityManager.RunningAppProcessInfo runningProcess : runningProcesses) {
                if (runningProcess.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) {
                    return false;
                }
            }
        }
    } else {
        List<ActivityManager.RunningTaskInfo> task = manager.getRunningTasks(1);
        if (!ListUtils.isEmpty(task)) {
            ComponentName info = task.get(0).topActivity;
            if (null != info) {
                return !isKaolaProcess(info.getPackageName());
            }
        }
    }
    return true;
}

Android通知栏适配

RemoteViews适配

由于系统自带的通知栏消息样式不能完全满足产品们脑洞大开的需求,有时候我们需要自定义布局样式展示通知栏消息。Android系统可以将自定义布局通过setContent(7.X系统推荐使用setCustomContentView)设置到Notification.Builder中,来实现样式的更变。setContent方法需要传入一个RemoteViews对象,它是一个普通的数据类型,不是View,作用是供其他进程展示视图。RemoteViews只支持4种基本的布局^9:

  • FrameLayout
  • LinearLayout
  • RelativeLayout
  • GridLayout

这些布局下面只支持几种视图控件:

  • AnalogClock
  • Button
  • Chronometer
  • ImageButton
  • ImageView
  • ProgressBar
  • TextView
  • ViewFlipper
  • ListView
  • GridView
  • StackView
  • AdapterViewFlipper

只能通过上述组合生成一个RemoteViews。

自定义布局与视图

除了上面提到的布局与控件,有没有办法自定义布局与视图呢?我们知道,任何一个View,都可以生成一个Bitmap对象,支持的视图控件里有ImageView,可以通过ImageView.setBitmapResource()将自定义视图设置到一个ImageView中,然后再随便放到一个布局上,就可以实现通知栏消息的任意布局。理想是美好的,但现实是残酷的。使用这种方式自定义的布局,会存在与原生的通知栏消息样式不一致的可能,包括小图标/大图标的大小,字体的大小与颜色,时间的显示方式(不同版本的时间显示位置和样式都不一样)。下面解决一个最关键,也最致命的问题——字体颜色。如果字体颜色和背景颜色一样,那这条通知栏消息就没法看了,如RemoteViews显示异常一节介绍的一样。

解决字体颜色和背景颜色一样的问题有三种解决方案,分别是:

  • 背景色固定不透明,字体颜色与背景色形成反差。(360和京东的做法)
  • 背景色透明,字体颜色采用系统原生的notification_style
  • 背景色透明,通过特殊方式拿到通知栏字体颜色和字体大小。

其中,第一种方案简单,能够兼容所有厂商机型。例如京东固定背景色为黑色,字体为红色。这种方式的唯一缺陷是样式上不能与普通通知栏消息重合,在白色背景的通知栏上极为显眼。第二种方式,通过阅读源码可知,系统的通知栏标题和内容采用的颜色分别是@android:color/primary_text_dark@android:color/secondary_text_dark,但踩过坑之后发现并非所有的机型默认都是这两个颜色,有可能获取不到值。因此这种方案只能作为参考,不能用于实际环境中。最后详细介绍一下第三种方式。

Android默认字体颜色获取

这种方案有一点投机取巧,是网上寻找代替方案时在简书上找到的,作者是hackware。思路就是通过Notification.Builder生成一条空的Notification,但不调用notify()方法,然后通过这条Notification想办法获取里面的布局元素,通过遍历,就能拿到对应的字体和颜色了。具体看代码:

private static final String NOTIFICATION_TITLE = "notification_title";
public static final int INVALID_COLOR = -1; // 无效颜色
private static int notificationTitleColor = INVALID_COLOR; // 获取到的颜色缓存

/**
 * 获取系统通知栏主标题颜色,根据Activity继承自AppCompatActivity或FragmentActivity采取不同策略。
 *
 * @param context 上下文环境
 * @return 系统主标题颜色
 */
public static int getNotificationColor(Context context) {
    try {
        if (notificationTitleColor == INVALID_COLOR) {
            if (context instanceof AppCompatActivity) {
                notificationTitleColor = getNotificationColorCompat(context);
            } else {
                notificationTitleColor = getNotificationColorInternal(context);
            }
        }
    } catch (Exception ignored) {
    }
    return notificationTitleColor;
}


/**
 * 通过一个空的Notification拿到Notification.contentView,通过{@link RemoteViews#apply(Context, ViewGroup)}方法返回通知栏消息根布局实例。
 *
 * @param context 上下文
 * @return 系统主标题颜色
 */
private static int getNotificationColorInternal(Context context) {
    Notification.Builder builder = new Notification.Builder(context);
    builder.setContentTitle(NOTIFICATION_TITLE);
    Notification notification = builder.build();
    try {
        ViewGroup root = (ViewGroup) notification.contentView.apply(context, new FrameLayout(context));
        TextView titleView = (TextView) root.findViewById(android.R.id.title);
        if (null == titleView) {
            iteratorView(root, new Filter() {
                @Override
                public void filter(View view) {
                    if (view instanceof TextView) {
                        TextView textView = (TextView) view;
                        if (NOTIFICATION_TITLE.equals(textView.getText().toString())) {
                            notificationTitleColor = textView.getCurrentTextColor();
                        }
                    }
                }
            });
            return notificationTitleColor;
        } else {
            return titleView.getCurrentTextColor();
        }
    } catch (Exception e) {
        DebugLog.e(e.getMessage());
        return getNotificationColorCompat(context);
    }
}

/**
 * 使用getNotificationColorInternal()方法,Activity不能继承自AppCompatActivity(实测5.0以下机型可以,5.0及以上机型不行),
 * 大致的原因是默认通知布局文件中的ImageView(largeIcon和smallIcon)被替换成了AppCompatImageView,
 * 而在5.0及以上系统中,AppCompatImageView的setBackgroundResource(int)未被标记为RemotableViewMethod,导致apply时抛异常。
 *
 * @param context 上下文
 * @return 系统主标题颜色
 */
private static int getNotificationColorCompat(Context context) {
    try {
        Notification.Builder builder = new Notification.Builder(context);
        Notification notification = builder.build();
        int layoutId = notification.contentView.getLayoutId();
        ViewGroup root = (ViewGroup) LayoutInflater.from(context).inflate(layoutId, null);
        TextView titleView = (TextView) root.findViewById(android.R.id.title);
        if (null == titleView) {
            return getTitleColorIteratorCompat(root);
        } else {
            return titleView.getCurrentTextColor();
        }
    } catch (Exception e) {
    }
    return INVALID_COLOR;
}

private static void iteratorView(View view, Filter filter) {
    if (view == null || filter == null) {
        return;
    }
    filter.filter(view);
    if (view instanceof ViewGroup) {
        ViewGroup viewGroup = (ViewGroup) view;
        for (int i = 0; i < viewGroup.getChildCount(); i++) {
            View child = viewGroup.getChildAt(i);
            iteratorView(child, filter);
        }
    }
}

private static int getTitleColorIteratorCompat(View view) {
    if (view == null) {
        return INVALID_COLOR;
    }
    List<TextView> textViews = getAllTextViews(view);
    int maxTextSizeIndex = findMaxTextSizeIndex(textViews);
    if (maxTextSizeIndex != Integer.MIN_VALUE) {
        return textViews.get(maxTextSizeIndex).getCurrentTextColor();
    }
    return INVALID_COLOR;
}

private static int findMaxTextSizeIndex(List<TextView> textViews) {
    float max = Integer.MIN_VALUE;
    int maxIndex = Integer.MIN_VALUE;
    int index = 0;
    for (TextView textView : textViews) {
        if (max < textView.getTextSize()) {
            // 找到字号最大的字体,默认把它设置为主标题字号大小
            max = textView.getTextSize();
            maxIndex = index;
        }
        index++;
    }
    return maxIndex;
}

/**
 * 实现遍历View树中的TextView,返回包含TextView的集合。
 *
 * @param root 根节点
 * @return 包含TextView的集合
 */
private static List<TextView> getAllTextViews(View root) {
    final List<TextView> textViews = new ArrayList<>();
    iteratorView(root, new Filter() {
        @Override
        public void filter(View view) {
            if (view instanceof TextView) {
                textViews.add((TextView) view);
            }
        }
    });
    return textViews;
}

private interface Filter {
    void filter(View view);
}



网易云新用户大礼包:https://www.163yun.com/gift

本文来自网易实践者社区,经作者黎星授权发布。