此文已由作者黎星授權網易雲社區發布。

歡迎訪問網易雲社區,了解更多網易技術產品運營經驗。

由於歷史原因,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);
}

相關閱讀:Android通知欄介紹與適配總結(下篇)

免費領取驗證碼、內容安全、簡訊發送、直播點播體驗包及雲伺服器等套餐

更多網易技術、產品、運營經驗分享請點擊


推薦閱讀:
相关文章