Android 特殊使用者通知用法彙總 - Notification 原始碼分析

Shawn_Dut發表於2016-11-22

  一直用的android手機,用過這麼多的app,平時也會遇到有趣的通知提醒,在這裡先總結兩種吧,notification和圖示數字,有的以後看到再研究。還有,推廣一下哈,剛剛建立一個Q群544645972,有興趣的加一下,一起成長。

Notification

  Notification應該算是最常見的app通知方式了,網上資料也很多,各種使用方法官方文件也已經寫的非常詳細了:developer.android.com/intl/zh-cn/…。這裡就介紹一下幾種特殊的用法:

動態改變

  將一個notification的setOngoing屬性設定為true之後,notification就能夠一直停留在系統的通知欄直到cancel或者應用退出。所以有的時候需要實時去根據情景動態改變notification,這裡以一個定時器的功能為例,需要每隔1s去更新一下notification,具體效果:

  這裡寫圖片描述

  非常簡單的功能,程式碼也很簡單:

private Timer timer;
private TimerTask task;
...
if (timer != null)
    return;
timer = new Timer("time");
task = new TimerTask() {
    @Override
    public void run() {
        showDynamicNotification();
    }
};
timer.scheduleAtFixedRate(task, 0, 1000);

private void showDynamicNotification() {
    L.i("show dynamic notification");
    mBuilder = new NotificationCompat.Builder(NotificationActivity.this);
    RemoteViews view = new RemoteViews(getPackageName(), R.layout.layout_notification);
    view.setTextViewText(R.id.tv_number, parseDate());
    view.setImageViewResource(R.id.iv_icon, R.mipmap.ic_launcher);

    Intent intent = new Intent(NOTIFY_ACTION);
    PendingIntent pendingIntent = PendingIntent.getBroadcast(NotificationActivity.this,
            1000, intent, PendingIntent.FLAG_UPDATE_CURRENT);

    mBuilder.setSmallIcon(R.mipmap.ic_launcher)
            .setContentIntent(pendingIntent)
            .setTicker("you got a new message")
            .setOngoing(true)
            .setContent(view);
    notification = mBuilder.build();
    notificationManager.notify(NOTIFY_ID2, notification);
}

private String parseDate() {
    SimpleDateFormat format = new SimpleDateFormat("yyyy hh:mm:ss", Locale.getDefault());
    return format.format(System.currentTimeMillis());
}複製程式碼

  需要注意的是Notification.Builder 是 Android 3.0 (API 11) 引入的,為了相容低版本,我們一般使用 Support V4 包提供的 NotificationCompat.Builder 來構建 Notification。要想動態更新notification,需要利用 NotificationManager.notify() 的 id 引數,該 id 在應用內需要唯一(如果不唯一,在有些4.x的手機上會出現pendingIntent無法響應的問題,在紅米手機上出現過類似情況),要想更新特定 id 的通知,只需要建立新的 notification,並觸發與之前所用 id 相同的 notification,如果之前的通知仍然可見,則系統會根據新notification 物件的內容更新該通知,相反,如果之前的通知已被清除,系統則會建立一個新通知。

  在這個例子中使用的是完全自定義的remoteViews,remoteViews和普通view的更新機制不一樣,網上資料很多,感興趣的可以去仔細瞭解。還有一個就是PendingIntent,這就不詳細介紹了,這裡簡單列一下PendingIntent的4個flag的作用


  • FLAG_CANCEL_CURRENT:如果構建的PendingIntent已經存在,則取消前一個,重新構建一個。

  • FLAG_NO_CREATE:如果前一個PendingIntent已經不存在了,將不再構建它。

  • FLAG_ONE_SHOT:表明這裡構建的PendingIntent只能使用一次。

  • FLAG_UPDATE_CURRENT:如果構建的PendingIntent已經存在,則替換它,常用。

big view

  Notification有兩種視覺風格,一種是標準檢視(Normal view)、一種是大檢視(Big view)。標準檢視在Android中各版本是通用的,但是對於大檢視而言,僅支援Android4.1+的版本,比如郵件,音樂等軟體就會使用到這種大檢視樣式的擴充套件通知欄,系統提供了setStyle()函式用來設定大檢視模式,一般情況下有三種模式提供選擇:


  • NotificationCompat.BigPictureStyle, 在細節部分顯示一個256dp高度的點陣圖

  • NotificationCompat.BigTextStyle,在細節部分顯示一個大的文字塊。

  • NotificationCompat.InboxStyle,在細節部分顯示一段行文字。

      這裡寫圖片描述

    在21版本之後增加了一個Notification.MediaStyle,這個可以達到類似

      這裡寫圖片描述

    的效果,基本和一些主流媒體播放器的介面類似了,在這就不具體介紹了,提供一篇資料:controllingMedia

      如果不使用上面的幾種style,完全自定義佈局也是可以的,例如實現:

      這裡寫圖片描述

    相關原始碼:Android custom notification for music player Example

      在這我就以一個簡單的實現為例,效果如下:

      這裡寫圖片描述

    程式碼:

RemoteViews smallView = new RemoteViews(getPackageName(), R.layout.layout_notification)
smallView.setTextViewText(R.id.tv_number, parseDate())
smallView.setImageViewResource(R.id.iv_icon, R.mipmap.ic_launcher)

mBuilder = new NotificationCompat.Builder(NotificationActivity.this)
mBuilder.setSmallIcon(R.mipmap.ic_launcher)
        .setNumber((int) (Math.random() * 1000))
        //No longer displayed in the status bar as of API 21.
        .setTicker()
        .setDefaults(Notification.DEFAULT_SOUND
                | Notification.DEFAULT_VIBRATE | Notification.DEFAULT_LIGHTS)
//        .setDeleteIntent()
        .setAutoCancel(true)
        .setWhen(0)
        .setPriority(NotificationCompat.PRIORITY_LOW)

intent = new Intent(NOTIFY_ACTION)
pendingIntent = PendingIntent.getBroadcast(NotificationActivity.this,
        1000, intent, PendingIntent.FLAG_UPDATE_CURRENT)
mBuilder.setContentIntent(pendingIntent)

//在5.0版本之後,可以支援在鎖屏介面顯示notification
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP){
    mBuilder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
}
notification = mBuilder.build()
notification.contentView = smallView

//如果系統版本 >= Android 4.1,設定大檢視 RemoteViews
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
    RemoteViews view = new RemoteViews(getPackageName(), R.layout.layout_big_notification)
    view.setTextViewText(R.id.tv_name, )
    view.setOnClickPendingIntent(R.id.btn_click_close,
            PendingIntent.getBroadcast(NotificationActivity.this, 1001,
                    new Intent(CLICK_ACTION), PendingIntent.FLAG_UPDATE_CURRENT))
    //textview marquee property is useless for bigContentView
    notification.bigContentView = view
}

notificationManager.notify(NOTIFY_ID3, notification)複製程式碼

xml佈局:

<linearlayout android:background="#ef222222" android:layout_height="match_parent" android:layout_width="match_parent" android:orientation="horizontal" android:padding="10dp" xmlns:android="http://schemas.android.com/apk/res/android">

    <framelayout android:layout_height="150dp" android:layout_width="match_parent">

        <imageview android:background="@mipmap/ic_launcher" android:id="@+id/iv_icon" android:layout_gravity="center_vertical" android:layout_height="wrap_content" android:layout_width="wrap_content">

        <linearlayout android:layout_height="match_parent" android:layout_weight="1" android:layout_width="match_parent" android:orientation="vertical">


            <textview android:ellipsize="marquee" android:fadingedge="horizontal" android:focusable="true" android:focusableintouchmode="true" android:gravity="center_horizontal|center_vertical" android:id="@+id/tv_name" android:layout_gravity="center" android:layout_height="wrap_content" android:layout_width="fill_parent" android:marqueerepeatlimit="marquee_forever" android:scrollhorizontally="false" android:singleline="true" android:textcolor="#fff" android:textsize="15sp" android:textstyle="bold">
            <requestfocus>
            </requestfocus></textview><button android:background="#ef222222" android:id="@+id/btn_click_close" android:layout_gravity="center_horizontal" android:layout_height="40dp" android:layout_margintop="10dp" android:layout_width="40dp" android:text="X" android:textsize="30sp" type="submit"></button></linearlayout>
    </framelayout>

</imageview></linearlayout>複製程式碼

  這裡有幾點需要著重說明一下


  • setTicker函式在21版本之後已經Deprecated了,沒有效果。

  • 監聽notification刪除函式setDeleteIntent是在11版本後新增的,而且setAutoCancel為true後,該函式會失效。

  • setPriority函式用來給notification設定優先順序,上面給的google文件中有很詳細的介紹。

  • 21版本之後,可以支援在鎖屏介面顯示notification,這個在google文件中也有介紹,這個體驗對於我個人來說感觸很深,對於簡訊等私密性通知可以隱藏,但是對於一般毫無隱私的應用通知,就可以設定其為public,省去使用者解鎖,下拉通知欄的操作。

  • 自定義大圖模式也是將自定義的RemoteViews賦值給notification.bigContentView變數,而且這個功能也只是在api16(4.1)之後生效。

  • 大圖模式高度的設定有些奇怪,在上面的xml檔案中,LinearLayout設定高度是無效的,必須要套一層FrameLayout,設定FrameLayout的高度才行,貌似定義最外層的LinearLayout的layoutParams是無效的。

  • 在bigContentView中是無法實現textview的marquee效果,而且事實也很奇怪,單獨使用contentView,傳入的remoteViews中的textview的marquee屬性是好用的,但是一旦設定了bigContentView,contentView中的textview屬性也失效了,這點使用的時候要注意。

浮動通知

  這種效果大家應該在微信中看的很多,其實實現也很簡單:

  這裡寫圖片描述

程式碼:

RemoteViews headsUpView = new RemoteViews(getPackageName(), R.layout.layout_heads_up_notification)

intent = new Intent(NOTIFY_ACTION)
pendingIntent = PendingIntent.getBroadcast(NotificationActivity.this,
        1000, intent, PendingIntent.FLAG_UPDATE_CURRENT)
mBuilder = new NotificationCompat.Builder(NotificationActivity.this)
mBuilder.setSmallIcon(R.mipmap.ic_launcher)
        .setContentTitle()
        .setContentText()
        .setNumber((int) (Math.random() * 1000))
        .setTicker()
        //must set pendingintent for this notification, or will be crash
        .setContentIntent(pendingIntent)
        .setDefaults(Notification.DEFAULT_SOUND
                | Notification.DEFAULT_VIBRATE | Notification.DEFAULT_LIGHTS)
        .setAutoCancel(true)
        .setWhen(0)
notification = mBuilder.build()
if (Build.VERSION.SDK_INT >= 21) {
    notification.priority = Notification.PRIORITY_MAX
    notification.headsUpContentView = headsUpView
}
notificationManager.notify(NOTIFY_ID1, notification)複製程式碼

  這個效果非常的方便,使用者都不需要直接下拉出通知欄,直接就能夠看見,省去了多餘操作,google官方文件介紹:developer.android.com/intl/zh-cn/…。headsUpContentView屬性也只是在21版本時出現,使用的時候需要注意。

notification常見問題總結

  1.通過notification開啟activity的時候,就要涉及到儲存使用者導航的問題,這個時候就要使用到activity task的相關內容了,我以前寫過一篇部落格中有介紹到activity task的內容:android深入解析Activity的launchMode啟動模式,Intent Flag,taskAffinity,感興趣的可以去看看。那麼要實現點選notification開啟指定activity,就需要設定相關的pendingIntent,有兩種特殊的情況需要說明一下:  

  • 第一種是需要開啟該activity的整個task棧,也就是說父activity也需要同時全部開啟,而且按照次序排列在task棧中。
    但是這裡會有一個問題,它在開啟整個activity棧之前會先清空原先的activity task棧,所以最後在task棧中只剩下相關的幾個activity,舉個例子我要開啟A->B->C的activity棧,但是我原先的activity棧中有D和C這兩個activity,系統會直接按順序關閉D和C這兩個activity,接著按順序開啟A->B->C,這種情況在使用的時候需要注意。

  • 第二種是直接開啟一個activity在一個單獨的task棧中
    這種情況會生成兩個task棧,
    這兩種情況在google官方文件中已經詳細介紹了:developer.android.com/intl/zh-cn/…

  2.我在以前的部落格中曾經介紹過一個notification圖示變成白塊的問題:android5.0狀態列圖示變成白色,這個問題在國產的很多rom中自己解決了,例如小米,錘子等,但是例如HTC和三星等rom仍然是有這樣的問題,要重視起來啊

  3.列表內容在2.3的時候直接使用

RemoteViews rvMain = new RemoteViews(context.getPackageName(), R.layout.notification_layout);
//TODO rvMain...
NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
                .setContent(rvMain);
// TOOD ...複製程式碼

  是無效的,需要換一種方式:

RemoteViews rvMain = new RemoteViews(context.getPackageName(), R.layout.notification_layout);
//TODO rmMain...
NotificationCompat.Builder builder = new NotificationCompat.Builder(context)
                .setContent(rvMain);
// TOOD ...
Notification notification = builder.build();
if(Build.VERSION.SDK_INT <= 10){
    notification.contentView = rvMain;
}複製程式碼

  4.通知欄上的操作事件:

  setContentIntent():使用者點選通知時觸發

  setFullScreenIntent()://TODO 這個在通知顯示的時候會被呼叫

  setDeleteIntent():使用者清除通知時觸發,可以是點選清除按鈕,也可以是左右滑動刪除(當然了,前提是高版本)

  2.3及以下是無法處理自定義佈局中的操作事件的,這樣我們就不要去考慮增加自定義按鈕了。

notification原始碼解析

  分析一下原始碼,以NotificationManager.notify為入口進行分析:

INotificationManager service = getService()
......
service.enqueueNotificationWithTag(pkg, mContext.getOpPackageName(), tag, id,
                    stripped, idOut, UserHandle.myUserId())複製程式碼

getService()函式:

static public INotificationManager getService()
{
    if (sService != null) {
        return sService;
    }
    IBinder b = ServiceManager.getService();
    sService = INotificationManager.Stub.asInterface(b);
    return sService;
}複製程式碼

該函式通過ServiceManager.getService(“notification”)獲取了INotificationManager的Binder物件,用來進行跨程式通訊,Binder不太明白的可以看看我以前寫的一篇部落格:android IPC通訊(下)-AIDL。這裡獲取的Binder物件就是NotificationManagerService,這裡涉及的兩個類要介紹一下,StatusBarManagerService和NotificationManagerService,這兩個service都會在frameworks/base/services/java/com/android/server/SystemServer.java檔案裡面進行啟動的:

class ServerThread extends Thread {    
public void run() {  
......  
     StatusBarManagerService statusBar = null;  
     NotificationManagerService notification = null;  
......  
    statusBar = new StatusBarManagerService(context, wm);  
    ServiceManager.addService(Context.STATUS_BAR_SERVICE, statusBar);  
......  
    notification = new NotificationManagerService(context, statusBar, lights);                     
    ServiceManager.addService(Context.NOTIFICATION_SERVICE, notification);  
......  

   }  

} 複製程式碼

我在早期的部落格中介紹過SystemServer,system_server子程式是zygote通過forkSystemServer函式建立的,感興趣的可以去看看android啟動過程詳細講解。上面的程式碼就呼叫到了NotificationManagerService的enqueueNotificationWithTag方法,enqueueNotificationWithTag方法會呼叫到enqueueNotificationInternal方法,這個方法就是核心了,我們抽取其中比較重要的程式碼分析一下:

void enqueueNotificationInternal(final String pkg, final String opPkg, final int callingUid,
            final int callingPid, final String tag, final int id, final Notification notification,
            int[] idOut, int incomingUserId) {
    ...
    //------這裡會做一個限制,除了系統級別的應用之外,其他應用的notification數量會做限制,
    //------用來放置DOS攻擊導致的洩露
    // Limit the number of notifications that any given package except the android
    // package or a registered listener can enqueue.  Prevents DOS attacks and deals with leaks.
    ...
    //------post到工作handler中進行工作
    mHandler.post(new Runnable() {
        @Override
        public void run() {

            synchronized (mNotificationList) {

                // === Scoring ===

                //------審查引數priority
                // 0. Sanitize inputs
                notification.priority = clamp(notification.priority, Notification.PRIORITY_MIN,
                        Notification.PRIORITY_MAX);
                .....

                //------初始化score
                // 1. initial score: buckets of 10, around the app [-20..20]
                final int score = notification.priority * NOTIFICATION_PRIORITY_MULTIPLIER;

                //------將前面傳遞進來的Notification封裝成一個StatusBarNotification物件,然後
                //------和score封裝成一個NotificationRecord物件,接著會呼叫handleGroupedNotificationLocked
                //------方法,看能否跳過下一步操作,額外的會對downloadManager進行單獨處理
                // 2. extract ranking signals from the notification data
                .....

                //------主要是統計notification的各種行為,另外將該上面封裝好的NotificationRecord物件
                //------加入到mNotificationList中,然後排序,排序外後,如果notification設定了smallIcon,
                //------呼叫所有NotificationListeners的notifyPostedLocked方法,通知有新的notification,
                //------傳入的引數為上面封裝成的StatusBarNotification物件。
                // 3. Apply local rules

                .....
                mRankingHelper.sort(mNotificationList);

                if (notification.getSmallIcon() != null) {
                    StatusBarNotification oldSbn = (old != null) ? old.sbn : null;
                    mListeners.notifyPostedLocked(n, oldSbn);
                } else {
                    ......
                }
                //通知status bar顯示該notification
                buzzBeepBlinkLocked(r);
            }
        }
    });
}複製程式碼

notifyPostedLocked方法中會繼續post到工作handler中,在該工作handler中呼叫notifyPosted方法,notifyPosted方法很簡單,也是通過Binder呼叫到了NotificationListenerService中,這個NotificationListenerService中類很實用,它可以繼承,用來監聽系統notification的各種動作:Android 4.4 KitKat NotificationManagerService使用詳解與原理分析(一)__使用詳解。通知完成,最後非同步操作就是呼叫buzzBeepBlinkLocked()方法去顯示該notification了,這個函式也很長,但是職責很明確,確認是否需要聲音,震動和閃光,如果需要,那麼就發出聲音,震動和閃光:

private void buzzBeepBlinkLocked(NotificationRecord record) {
    .....

    // Should this notification make noise, vibe, or use the LED?
    ......

    // If we're not supposed to beep, vibrate, etc. then don't.
    .....
    if (disableEffects == null
            && (!(record.isUpdate
            && (notification.flags & Notification.FLAG_ONLY_ALERT_ONCE) != 0 ))
            && (record.getUserId() == UserHandle.USER_ALL ||
            record.getUserId() == currentUser ||
            mUserProfiles.isCurrentProfile(record.getUserId()))
            && canInterrupt
            && mSystemReady
            && mAudioManager != null) {
        if (DBG) Slog.v(TAG, "Interrupting!");

        sendAccessibilityEvent(notification, record.sbn.getPackageName());

        // sound

        // should we use the default notification sound? (indicated either by
        // DEFAULT_SOUND or because notification.sound is pointing at
        // Settings.System.NOTIFICATION_SOUND)
        .....

        // vibrate
        // Does the notification want to specify its own vibration?
        ....

    // light
    ....
    if (buzz || beep || blink) {
        EventLogTags.writeNotificationAlert(record.getKey(),
                buzz ? 1 : 0, beep ? 1 : 0, blink ? 1 : 0);
        mHandler.post(mBuzzBeepBlinked);
    }
}複製程式碼

最後將mBuzzBeepBlinked post到工作handler,最後會呼叫到mStatusBar.buzzBeepBlinked(),mStatusBar是StatusBarManagerInternal物件,這個物件是在StatusBarManagerService中初始化,所以最後呼叫到了StatusBarManagerService中StatusBarManagerInternal的buzzBeepBlinked()方法:

public void buzzBeepBlinked() {
    if (mBar != null) {
        try {
            mBar.buzzBeepBlinked();
        } catch (RemoteException ex) {
        }
    }
}複製程式碼

mBar是一個IStatusBar物件,這個mBar在哪裡賦值的呢?看這裡:www.programering.com/a/MTOzITNwA…,英文看不懂沒關係,有中文版:home.bdqn.cn/thread-4215…。所以最終呼叫到了CommandQueue類中,接著sendEmptyMessage給了內部的H類(貌似很喜歡用H這個單詞作為Handler的命名,比如acitivity的啟動:android 不能在子執行緒中更新ui的討論和分析),接著呼叫了mCallbacks.buzzBeepBlinked()方法,這個mCallbacks就是BaseStatusBar,最終會將notification繪製出來,到這裡一個notification就算是完成了。

  注:我分析程式碼的時候看的程式碼是最新版本的api 23程式碼,buzzBeepBlinked()這個函式在BaseStatusBar類中是不存在的,繪製程式碼是在UpdateNotification()函式中,但是BaseStatusBar分明是繼承了CommandQueue.Callbacks介面,卻沒有實現它,所以這個buzzBeepBlinked()函式到最後就莫名其妙失蹤了,求大神指點,非常疑惑。

相關資料


www.tutorialsface.com/2015/08/and…

developer.android.com/intl/zh-cn/…

glgjing.github.io/blog/2015/1…

www.codeceo.com/article/and…

www.itnose.net/detail/6169…

www.cnblogs.com/over140/p/4…

blog.csdn.net/loongggdroi…

www.2cto.com/kf/201408/3…

blog.csdn.net/xxbs2003/ar…

www.jianshu.com/p/4d76b2bc8…

home.bdqn.cn/thread-4215…


圖示數字

  這裡寫圖片描述

  雖然說這是iOS上的風格,但是在某些手機上還是支援的,比如三星和HTC(m8t,6.0)的有些手機都可以,小米手機是個特例,它是根據notification的數量來自動生成的。

  一般情況下,HTC和三星可以使用下面的函式生成

public static void setBadge(Context context, int count) {
    String launcherClassName = getLauncherClassName(context);
    if (launcherClassName == null) {
        return;
    }
    Intent intent = new Intent();
    intent.putExtra(, count);
    intent.putExtra(, context.getPackageName());
    intent.putExtra(, launcherClassName);
    context.sendBroadcast(intent);
}

public static String getLauncherClassName(Context context) {

    PackageManager pm = context.getPackageManager();

    Intent intent = new Intent(Intent.ACTION_MAIN);
    intent.addCategory(Intent.CATEGORY_LAUNCHER);

    List resolveInfos = pm.queryIntentActivities(intent, 0);
    for (ResolveInfo resolveInfo : resolveInfos) {
        String pkgName = resolveInfo.activityInfo.applicationInfo.packageName;
        if (pkgName.equalsIgnoreCase(context.getPackageName())) {
            String className = resolveInfo.activityInfo.name;
            return className;
        }
    }
    return null;
}複製程式碼

  由於android碎片化太嚴重,所以在不同手機上適配起來是非常麻煩,不過還好在github上國人寫了一個庫可以覆蓋挺多機型:ShortcutBadger,也可以參考一下:stackoverflow.com/questions/1…

原始碼

github.com/zhaozepeng/…

相關文章