Android上的Notification

Posted on Jul 7, 2017


在本文中,我们来详细了解一下Android上的Notification实现。

Notification是自Android发布以来就有的API,也是应用程序中最常用的功能的之一,开发者对其应当是相当的熟悉了。

在Android近几年的版本更新中,几乎每个版本都会对系统通知界面,以及相关API做一些的改变。这些改变使得开发者可以更好的控制应用程序的通知样式,同时也使得通知功能更易于用户使用。

本文我们来详细看一下Notification方面的知识。

开发者API

这里不打算对Notification基本的使用方式做过多讲解,这方面内容对于很多开发者来说都已经是非常熟悉的了,并且网络上也很容易搜索到相关内容。

下面只会说明Notification自Android 5.0以来的新增加功能。

Heads-up Notification

Heads-up Notification 是Android 5.0上的新增功能。

当设备处于使用状态下(已经解锁并且屏幕亮着)时,这种通知以一个小的浮动窗口的形式呈现出来,就像下面这样:

这个样式看起来像是对通知的一种压缩,但是Heads-up Notification可以包含Action Button。用户可以点击Action Button进行相应的操作,也可以将这个通知界面移除掉但是不离开当前应用。

这对于用户体验来说是一项非常好的改进,系统的来电通知就是这种形式的通知。在设备处于使用状态下时,这种通知既不会干扰用户当前的行为(可以直接将通知界面移除掉),又方便了用户对于通知的处理(可以直接点击Action Button来处理通知)。

只要Notification满足下面的两种情况下任何一种,就会产生Heads-up Notification:

  • Notification设置了fullScreenIntent
  • Notification是一个High优先级的通知并且使用了铃声或震动

锁屏上的Notification

从Android 5.0开始,通知可以在锁屏上显示。开发者可以利用这个特性来实现媒体播放按钮或者其他常用的操作。但同时,用户也可以通过设置来决定是否在锁屏界面上显示某个应用的通知。

开发者可以通过Notification.Builder.setVisibility(int)方法来控制通知显示的详细级别。这个方法接收三个级别的控制:

  • VISIBILITY_PUBLIC 显示通知的全部内容
  • VISIBILITY_PRIVATE 显示通知的基本信息,例如通知的icon和title,但是不显示详细内容
  • VISIBILITY_SECRET 不显示通知的任何内容

Notification直接回复

从Android 7.0开始,用户可以在通知界面上进行直接回复。直接回复按钮附加在通知的下面。

当用户通过键盘回复时,系统将用户输入的文字附在开发者指定的Intent上,然后发送给对应的应用。

创建一个包含直接回复按钮的通知分为下面几个步骤:

  1. 创建一个PendingIntent,这个PendingIntent将在用户输入完成点击发送按钮之后触发。因此我们需要为这个PendingIntent设置一个接受者,我们可以使用一个BroadcastReceiver来进行接收
  2. 创建一个RemoteInput.Builder对象实例,这个类的构造函数接收一个字符串作为Key来让系统放入用户输入的文字。在接收方通过这个key来获取输入
  3. 通过Notification.Action.Builder.addRemoteInput()方法将第1步创建的RemoteInput对象添加到Notification.Action上
  4. 创建一个通知包含前面创建的Notification.Action,然后发送

相关代码示例如下:


intent = new Intent(context, NotificationBroadcastReceiver.class);
intent.setAction(REPLY_ACTION);
intent.putExtra(KEY_NOTIFICATION_ID, notificationId);
intent.putExtra(KEY_MESSAGE_ID, messageId);
PendingIntent replyPendingIntent = PendingIntent.getBroadcast(
        getApplicationContext(), 100, intent,
        PendingIntent.FLAG_UPDATE_CURRENT);

// Key for the string that's delivered in the action's intent.
private static final String KEY_TEXT_REPLY = "key_text_reply";
String replyLabel = getResources().getString(R.string.reply_label);
RemoteInput remoteInput = new RemoteInput.Builder(KEY_TEXT_REPLY)
        .setLabel(replyLabel)
        .build();

// Create the reply action and add the remote input.
Notification.Action action =
        new Notification.Action.Builder(R.drawable.ic_reply_icon,
                getString(R.string.label), replyPendingIntent)
                .addRemoteInput(remoteInput)
                .build();
                
// Build the notification and add the action.
Notification newMessageNotification =
        new Notification.Builder(mContext)
                .setSmallIcon(R.drawable.ic_message)
                .setContentTitle(getString(R.string.title))
                .setContentText(getString(R.string.content))
                .addAction(action)
                .build();

// Issue the notification.
NotificationManager notificationManager =
        (NotificationManager) this.getSystemService(NOTIFICATION_SERVICE);
notificationManager.notify(notificationId, newMessageNotification);

当用户点击回复按钮时,系统会提示用户输入:

当用户输入完成并点击发送按钮之后,我们设置的replyPendingIntent被会触发。前面我们设置了一个BroadcastReceiver来处理这个Intent,于是在BroadcastReceiver中可以通过下面这样的方式来获取用户输入的文本:

private CharSequence getReplyMessage(Intent intent) {
    Bundle remoteInput = RemoteInput.getResultsFromIntent(intent);
    if (remoteInput != null) {
        return remoteInput.getCharSequence(KEY_REPLY);
    }
    return null;
}

public void onReceive(Context context, Intent intent) {
    if (REPLY_ACTION.equals(intent.getAction())) {
        CharSequence message = getReplyMessage(intent);
        int messageId = intent.getIntExtra(KEY_MESSAGE_ID, 0);

        Toast.makeText(context, "Message ID: " + messageId + "\nMessage: " + message,
                Toast.LENGTH_SHORT).show();
    }
}

这里还有两点需要开发者注意的:

  1. 用户点击完发送按钮之后,该按钮会变成一个旋转的样式表示这个动作还在进行中。开发者需要重新发送一条新的通知来更新这个状态
  2. 通过BroadcastReceiver来处理这个发送事件的同时,请注意将BroadcastReceiver在AndroidManifest.xml中的配置设为:android:exported=”false”。否则任何应用都可以发送一条Intent来触发你的BroadcastReceiver,这可能对你应用造成危害。

Bundling Notifications

从Android 7.0开始,系统提供一个新的方式来展示连续的通知:Bundling notifications。

这种展示方式特别适用于即时通讯类应用,因为这类应用会持续不断的收到新的消息并发送通知。这种展示方式是以一种层次性的结构来组织通知。顶部是显示组内概览信息的消息,当用户进一步展开组的时候,系统显示组内的更多信息。如下图所示:

Notification.Build类中提供了相应的API来进行这种通知样式的管理:

  • Notification.Builder.setGroup(String groupKey) 通过groupKey将通知归为一个组
  • Notification.Builder.setGroupSummary(boolean isGroupSummary) 当isGroupSummary = true时表示将该条通知设为组内的Summary通知
  • Notification.Builder.setSortKey(String sortKey) 系统将根据这里设置的sortKey进行排序

Notification 消息样式

从Android 7.0开始,系统提供了MessagingStyle API来自定义通知的样式。开发者可以自定义通知的各种Label,包括:对话Title,附加消息以及通知的Content view等。下面是一段代码示例:

Notification notification = new Notification.Builder()
             .setSmallIcon(R.drawable.ic_menu_camera)
             .setStyle(new Notification.MessagingStyle("Me")
                 .setConversationTitle("Team lunch")
                 .addMessage("Hi", timestamp1, null) // Pass in null for user.
                 .addMessage("What's up?", timestamp2, "Coworker")
                 .addMessage("Not much", timestamp3, null)
                 .addMessage("How about lunch?", timestamp4, "Coworker"))
             .build();

这条通知显示出来是下面这个样子:

通知栏与通知窗口

外部界面

通知栏位于状态栏中,在状态栏的左侧通过一系列应用的Icon来显示通知:

用户可以通过从屏幕上侧下滑的方法展开通知窗口,通知窗口的上方是Quick Settings区域,下方是通知列表。用户可以展开Quick Settings区域。

内部实现

在了解了通知界面的外观之后,我们就来看一下系统是如何实现这个界面的。

在SystemUI的实现中,通过XML布局文件以及一系列自定义Layout类来管理通知界面。

整个Status Bar通过super_status_bar.xml文件来进行布局,这个布局文件的根元素是一个自定义的FrameLayout,类名是StatusBarWindowView。这个布局文件的结构如下图所示:

在这里,我们重点要关注的就是选中的两行:

  • super_status_bar.xml中include了一个名称为status_bar的布局文件
  • super_status_bar.xml中include了一个名称为status_bar_expanded的布局文件

这里的status_bar便是系统状态栏的布局文件,status_bar_expanded便是下拉的通知窗口的布局文件。

status_bar.xml布局文件结构如下图所示。这个布局文件的根元素是名称为PhoneStatusBarView的自定义FrameLayout类。

对照这个布局文件和手机上的状态栏,我相信读者应该很容易理解了:

  • notification_icon_area 正是系统显示通知icon的区域
  • system_icon_area 是显示系统图标的区域,例如:Wifi,电话信息以及电池等
  • clock 是状态栏上显示时间的区域

下面我们再来看一下status_bar_expanded.xml这个布局文件的结构,这个布局文件的根元素是一个名称为NotificationPanelView的类,这个类同样是一个自定义的FrameLayout。

在这个布局文件中:

  • 顶部是一个名称为keyguard_status_view的元素。这个便是该界面上的状态栏布局。这个状态栏显示的内容和通常的状态栏的内容是有所区别的,读者可以回到上面相应的截图对比一下不同场景下状态栏显示的内容
  • qs_auto_reinflate_container 是显示Quick Settings的区域。这个区域其实是include了一个另外布局文件:qs_panel.xml
  • notification_stack_scroller 便是真正显示通知列表的地方,这是一个NotificationStackScrollLayout类型的元素。从名称上我们就可以看出,这个元素是可以滚动的,因为通知的列表可能是很长的。

上面我只大概讲解了这些界面中最主要的元素,而实际上布局中还有非常多的其他元素。这里我们就不一一讲解了。读者可以借助Android Studio上的Layout Inspector工具选择com.android.systemui进程,然后选择StatusBar来详细分析该界面上的每一个元素,Layout Inspector界面看起来像下面这样:

Notification从发送到显示

Notification的发送

有了上面通知界面布局的知识之后,我们再看一下,应用程序中发送的通知是如何最终显示到系统的通知界面上的。

开发者通过创建Notification对象来发送通知。该对象中记录了一条通知的所有详细信息,Notification类图如下所示:

这里的很多字段相信开发者都很熟悉,因为这些字段都是我们发送通知时要设置的。这里需要说明的是Bundle extras这个字段。Bundle以键值对的形式存储了可以通过IPC传递的一系列数据。当我们通过Notification.buidler构建Notification对象时,有一些自定义样式的值都是存在这个extras字段中的,例如下面这些:

public Builder setShowWhen(boolean show) {
  mN.extras.putBoolean(EXTRA_SHOW_WHEN, show);
  return this;
}

public Builder setSmallIcon(Icon icon) {
  mN.setSmallIcon(icon);
  if (icon != null && icon.getType() == Icon.TYPE_RESOURCE) {
      mN.icon = icon.getResId();
  }
  return this;
}
   
   
public Builder setContentTitle(CharSequence title) {
  mN.extras.putCharSequence(EXTRA_TITLE, safeCharSequence(title));
  return this;
}
   
public Builder setContentText(CharSequence text) {
  mN.extras.putCharSequence(EXTRA_TEXT, safeCharSequence(text));
  return this;
}
   
public Builder setContentInfo(CharSequence info) {
  mN.extras.putCharSequence(EXTRA_INFO_TEXT, safeCharSequence(info));
  return this;
}
   
public Builder setProgress(int max, int progress, boolean indeterminate) {
  mN.extras.putInt(EXTRA_PROGRESS, progress);
  mN.extras.putInt(EXTRA_PROGRESS_MAX, max);
  mN.extras.putBoolean(EXTRA_PROGRESS_INDETERMINATE, indeterminate);
  return this;
}
   
public Builder setStyle(Style style) {
  if (mStyle != style) {
      mStyle = style;
      if (mStyle != null) {
          mStyle.setBuilder(this);
          mN.extras.putString(EXTRA_TEMPLATE, style.getClass().getName());
      }  else {
          mN.extras.remove(EXTRA_TEMPLATE);
      }
  }
  return this;
}

Notification类是一个Parcelable类,这意味着它可以通过Binder被跨进程传递。

我们通常不会手动创建Notification,而是通过Notification.Builder类中的setXXX方法(上面已经列出了一些)来创建Notification。很显然,这个Notification.Builder类使用的是典型的Builder设计模式,通过这个类,简化了我们创建Notification的过程,下图是Notification.Builder类的类图:

这个类提供了非常多的setXXX方法让我们设置Notification的属性,并且这些方法会返回Builder对象本身以便我们可以连续调用。最终,我们通过一个build方法获取到构造好的Notification对象。

NotificationManagerService

在构造好了Notification对象之后,我们通过NotificationManager的public void notify(int id, Notification notification)(及其重载)方法真正将通知发送出去。

我相信读者自然能想到,这个NotificationManager一定也是通过Binder实现的。

确实没错,真正实现通知发送的服务叫做NotificationManagerService,这个Service同样位于system_server进程中。

NotificationManager代表了服务的客户端被应用程序所使用,而NotificationManagerService位于系统进程中接收和处理请求。 Android系统中大量的系统服务都是这样的实现套路。

notify接口最终会调用到NotificationManager中的另一个叫做notifyAsUser的接口来发送通知,其实现如下:

public void notifyAsUser(String tag, int id, Notification notification, UserHandle user)
{
   int[] idOut = new int[1];
   INotificationManager service = getService(); 
   String pkg = mContext.getPackageName();
   // Fix the notification as best we can.
   Notification.addFieldsFromContext(mContext, notification); 
   if (notification.sound != null) {
       notification.sound = notification.sound.getCanonicalUri();
       if (StrictMode.vmFileUriExposureEnabled()) {
           notification.sound.checkFileUriExposed("Notification.sound");
       }
   }
   fixLegacySmallIcon(notification, pkg);
   if (mContext.getApplicationInfo().targetSdkVersion > Build.VERSION_CODES.LOLLIPOP_MR1) {
       if (notification.getSmallIcon() == null) {
           throw new IllegalArgumentException("Invalid notification (no valid small icon): "
                   + notification); 
       }
   }
   if (localLOGV) Log.v(TAG, pkg + ": notify(" + id + ", " + notification + ")");
   final Notification copy = Builder.maybeCloneStrippedForDelivery(notification);
   try {
       service.enqueueNotificationWithTag(pkg, mContext.getOpPackageName(), tag, id,
               copy, idOut, user.getIdentifier()); 
       if (localLOGV && id != idOut[0]) {
           Log.v(TAG, "notify: id corrupted: sent " + id + ", got back " + idOut[0]);
       }
   } catch (RemoteException e) {
       throw e.rethrowFromSystemServer();
   }
}

这段代码说明如下:

  1. 通过getService方法获取NotificationManagerService的远程服务接口,getService方法的实现其实就是通过ServiceManager拿到NotificationManagerService的Binder对象
  2. 通过mContext为Notification添加一些附加属性,这里的mContext代表了调用发送通知接口的Context,系统服务中会通过这个Context来确定是谁在使用服务
  3. 在LOLLIPOP_MR1之上的版本(API Level 22)上,发送通知必须设置Small Icon,否则直接抛出异常
  4. 调用NotificationManagerService的远程接口来真正进行通知的发送

接下来我们要关注的自然是NotificationManagerService.enqueueNotificationWithTag方法的实现。

NotificationManagerService相关代码位于以下路径:/frameworks/base/services/core/java/com/android/server/notification/

在NotificationManagerService.enqueueNotificationWithTag方法中,会将用户发送过来的Notification对象包装在一个StatusBarNotification对象中:

final StatusBarNotification n = new StatusBarNotification(
      pkg, opPkg, id, tag, callingUid, callingPid, 0, notification,
      user);

然后又将StatusBarNotification包装在NotificationRecord对象中:

final NotificationRecord r = new NotificationRecord(getContext(), n);

StatusBarNotification构造函数中的其他参数,描述了发送通知的调用者的身份,包括:包名,调用者的uid,pid等等。这个身份的作用是:系统可以针对调用者身份的不同做不同的处理。例如:用户可能关闭了某些应用的通知显示,系统通过调用者的身份便可以确定这个应用的通知是否需要显示在通知界面上。

而看到NotificationRecord,读者应该很自然能想到ActivityManagerService中的ActivityRecord,ProcessRecord等结构。这些都是系统服务中用来描述应用程序中对象的对应结构。

下图描述了上面三种结构的包含关系:

系统在创建NotificationRecord对象之后,会Post一个Runnable的Task进行通知的发送:

final NotificationRecord r = new NotificationRecord(getContext(), n);
mHandler.post(new EnqueueNotificationRunnable(userId, r));

在EnqueueNotificationRunnable中,需要做下面几件事情:

  • 处理通知的分组
  • 检查该通知是否已经被阻止(通过调用者的身份:包名及uid)
  • 对通知进行排序
  • 判断对已有通知更新,还是发送一条新的通知
  • 调用NotificationListeners.notifyPostedLocked
  • 如果需要:处理声音和震动

这里只有NotificationListeners.notifyPostedLocked需要说明一下。

一条通知发送到系统之后,系统中可能会有很多模块会对其感兴趣(最基本的,会有模块要将这个通知显示在通知界面上)。发送通知是一个事件,处理通知是一个响应,当事件的响应者可能不止一个的时候,为了达到解耦这两者之间的关系,很自然的会使用我们常见的监听器模型(或者叫做:Observer设计模式)。

系统中,对于通知感兴趣的监听器通过NotificationListenerService类来表达。而这里的NotificationListeners.notifyPostedLocked便是对所有的NotificationListenerService进行回调通知。

这其中有一个最重要的NotificationListenerService就是BaseStatusBar。因为它就是负责将通知显示在通知界面上的监听器。

Notification的显示

BaseStatusBar中对于通知发送的回调逻辑如下:

public void onNotificationPosted(final StatusBarNotification sbn,
      final RankingMap rankingMap) {
  if (DEBUG) Log.d(TAG, "onNotificationPosted: " + sbn);
  if (sbn != null) {
      mHandler.post(new Runnable() {
          @Override
          public void run() {
              processForRemoteInput(sbn.getNotification());
              String key = sbn.getKey(); 
              mKeysKeptForRemoteInput.remove(key);
              boolean isUpdate = mNotificationData.get(key) != null; 
              if (!ENABLE_CHILD_NOTIFICATIONS
                  && mGroupManager.isChildInGroupWithSummary(sbn)) {
                  if (DEBUG) {
                      Log.d(TAG, "Ignoring group child due to existing summary: " + sbn);
                  }

                  // Remove existing notification to avoid stale data.
                  if (isUpdate) {
                      removeNotification(key, rankingMap); 
                  } else {
                      mNotificationData.updateRanking(rankingMap);
                  }
                  return;
              }
              if (isUpdate) {
                  updateNotification(sbn, rankingMap);
              } else {
                  addNotification(sbn, rankingMap, null /* oldEntry */); 
              }
          }
      });
  }
}

这段代码的说明如下:

  1. 每个StatusBarNotification对象都有一个Key值,这个值根据调用者的身份以及调用者设置的通知id生成。当应用程序通过同一个通知id发送了多次通知,这些通知的Key值是一样的,由此可以对通知进行更新
  2. mNotificationData(类型为NotificationData)中记录了系统所有的通知列表
  3. 如果是一个已经存在的通知需要更新,则先将存在的通知删除
  4. addNotification是一个抽象方法,由子类实现

在手机设备上,addNotification这个方法自然是由PhoneStatusBar来实现。在addNotification方法中,会调用updateNotifications方法来最终将通知显示在通知界面上,其代码如下所示:

protected void updateNotifications() {
   mNotificationData.filterAndSort();

   updateNotificationShade();
   mIconController.updateNotificationIcons(mNotificationData);
}

这里的updateNotificationShade方法便是将通知的显示内容添加到通知面板的显示区域:NotificationStackScrollLayout中。而mIconController.updateNotificationIcons(mNotificationData)则是在notification_icon_area区域添加通知Icon。

updateNotificationShade代码比较长,但是逻辑是比较好理解的。主体逻辑就是对每一个需要显示的通知创建一个ExpandableNotificationRow,然后设置对应的内容并添加到NotificationStackScrollLayout(mStackScroller对象)中。

浏览一下这段代码便可以看到我们在API部分讲解的一些API在系统服务中的实现:这里了处理通知的分组,visibility等相关信息。

private void updateNotificationShade() {
   if (mStackScroller == null) return;

   // Do not modify the notifications during collapse.
   if (isCollapsing()) {
       addPostCollapseAction(new Runnable() {
           @Override
           public void run() {
               updateNotificationShade();
           }
       });
       return;
   }

   ArrayList<Entry> activeNotifications = mNotificationData.getActiveNotifications();
   ArrayList<ExpandableNotificationRow> toShow = new ArrayList<>(activeNotifications.size());
   final int N = activeNotifications.size();
   for (int i=0; i<N; i++) {
       Entry ent = activeNotifications.get(i);
       int vis = ent.notification.getNotification().visibility;

       // Display public version of the notification if we need to redact.
       final boolean hideSensitive =
               !userAllowsPrivateNotificationsInPublic(ent.notification.getUserId());
       boolean sensitiveNote = vis == Notification.VISIBILITY_PRIVATE;
       boolean sensitivePackage = packageHasVisibilityOverride(ent.notification.getKey());
       boolean sensitive = (sensitiveNote && hideSensitive) || sensitivePackage;
       boolean showingPublic = sensitive && isLockscreenPublicMode();
       if (showingPublic) {
           updatePublicContentView(ent, ent.notification);
       }
       ent.row.setSensitive(sensitive, hideSensitive);
       if (ent.autoRedacted && ent.legacy) {
           // TODO: Also fade this? Or, maybe easier (and better), provide a dark redacted form
           // for legacy auto redacted notifications.
           if (showingPublic) {
               ent.row.setShowingLegacyBackground(false);
           } else {
               ent.row.setShowingLegacyBackground(true);
           }
       }
       if (mGroupManager.isChildInGroupWithSummary(ent.row.getStatusBarNotification())) {
           ExpandableNotificationRow summary = mGroupManager.getGroupSummary(
                   ent.row.getStatusBarNotification());
           List<ExpandableNotificationRow> orderedChildren =
                   mTmpChildOrderMap.get(summary);
           if (orderedChildren == null) {
               orderedChildren = new ArrayList<>();
               mTmpChildOrderMap.put(summary, orderedChildren);
           }
           orderedChildren.add(ent.row);
       } else {
           toShow.add(ent.row);
       }

   }

   ArrayList<ExpandableNotificationRow> toRemove = new ArrayList<>();
   for (int i=0; i< mStackScroller.getChildCount(); i++) {
       View child = mStackScroller.getChildAt(i);
       if (!toShow.contains(child) && child instanceof ExpandableNotificationRow) {
           toRemove.add((ExpandableNotificationRow) child);
       }
   }

   for (ExpandableNotificationRow remove : toRemove) {
       if (mGroupManager.isChildInGroupWithSummary(remove.getStatusBarNotification())) {
           // we are only transfering this notification to its parent, don't generate an animation
           mStackScroller.setChildTransferInProgress(true);
       }
       if (remove.isSummaryWithChildren()) {
           remove.removeAllChildren();
       }
       mStackScroller.removeView(remove);
       mStackScroller.setChildTransferInProgress(false);
   }

   removeNotificationChildren();

   for (int i=0; i<toShow.size(); i++) {
       View v = toShow.get(i);
       if (v.getParent() == null) {
           mStackScroller.addView(v);
       }
   }

   addNotificationChildrenAndSort();

   // So after all this work notifications still aren't sorted correctly.
   // Let's do that now by advancing through toShow and mStackScroller in
   // lock-step, making sure mStackScroller matches what we see in toShow.
   int j = 0;
   for (int i = 0; i < mStackScroller.getChildCount(); i++) {
       View child = mStackScroller.getChildAt(i);
       if (!(child instanceof ExpandableNotificationRow)) {
           // We don't care about non-notification views.
           continue;
       }

       ExpandableNotificationRow targetChild = toShow.get(j);
       if (child != targetChild) {
           // Oops, wrong notification at this position. Put the right one
           // here and advance both lists.
           mStackScroller.changeViewPosition(targetChild, i);
       }
       j++;

   }

   // clear the map again for the next usage
   mTmpChildOrderMap.clear();

   updateRowStates();
   updateSpeedbump();
   updateClearAll();
   updateEmptyShadeView();

   updateQsExpansionEnabled();
   mShadeUpdates.check();
}

至此,一条新发送的通知就真正显示出来了。

下面这幅图描述了一条Notification从发送到显示出来的流程:


 Contents