本文轉載自:http://qiangbo.space/2017-07-07/AndroidAnatomy_Notification
瞭解一下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上,然後發送給對應的應用。
創建一個包含直接回覆按鈕的通知分爲下面幾個步驟:
- 創建一個PendingIntent,這個PendingIntent將在用戶輸入完成點擊發送按鈕之後觸發。因此我們需要爲這個PendingIntent設置一個接受者,我們可以使用一個BroadcastReceiver來進行接收
- 創建一個RemoteInput.Builder對象實例,這個類的構造函數接收一個字符串作爲Key來讓系統放入用戶輸入的文字。在接收方通過這個key來獲取輸入
- 通過
Notification.Action.Builder.addRemoteInput()
方法將第1步創建的RemoteInput對象添加到Notification.Action上 - 創建一個通知包含前面創建的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();
}
}
這裏還有兩點需要開發者注意的:
- 用戶點擊完發送按鈕之後,該按鈕會變成一個旋轉的樣式表示這個動作還在進行中。開發者需要重新發送一條新的通知來更新這個狀態
- 通過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();
}
}
這段代碼說明如下:
- 通過getService方法獲取NotificationManagerService的遠程服務接口,getService方法的實現其實就是通過ServiceManager拿到NotificationManagerService的Binder對象
- 通過mContext爲Notification添加一些附加屬性,這裏的mContext代表了調用發送通知接口的Context,系統服務中會通過這個Context來確定是誰在使用服務
- 在LOLLIPOP_MR1之上的版本(API Level 22)上,發送通知必須設置Small Icon,否則直接拋出異常
- 調用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 */); ④
}
}
});
}
}
這段代碼的說明如下:
- 每個StatusBarNotification對象都有一個Key值,這個值根據調用者的身份以及調用者設置的通知id生成。當應用程序通過同一個通知id發送了多次通知,這些通知的Key值是一樣的,由此可以對通知進行更新
- mNotificationData(類型爲NotificationData)中記錄了系統所有的通知列表
- 如果是一個已經存在的通知需要更新,則先將存在的通知刪除
- 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從發送到顯示出來的流程: