1. 程式人生 > >Android Notification和權限機制探討

Android Notification和權限機制探討

程序執行效率 except hit nes 數組 sdk default 耐心 lean

近期為了在部門內做一次小型的技術分享。深入了解了一下Notification的實現原理。以及android的權限機制。在此做個記錄。文章可能比較長,沒耐心的話就直接看題綱吧。


先看一下以下兩張圖

圖一:

技術分享技術分享

看到這圖可能大家不太明確,這和我們的notification有什麽關系,我來簡介一下背景。這是發生在15年NBA季後賽期間,火箭隊對陣小牛隊,火箭隊以3:1率先,僅僅要再贏一場就能淘汰對手。這時候火箭隊的官方首席運營官發了這條官方推特。

翻譯一下就是 “一把槍指著小牛的隊標,哼哼,僅僅須要閉上你們額眼睛,立即就要結束了”。這條推特當時引起了非常多人的轉發和評論,而且推送給了全部關註相關比賽的球迷以及媒體。

我們試想一下,你是一個小牛的球迷,輸了比賽以後本來心情就非常差。這時候手機一震,收到這條通知欄推送。你是不是會有一種強烈的被蔑視感覺。當天推特上就掀起了一陣網絡爭議。不僅小牛球迷,其它中立球迷也表示這條推特諷刺意味十足,已經有侮辱對手的嫌疑了。當然了,通知欄表示我不背這個鍋,誰來背?第二天,這位首席運營官就被火箭官方開除了,並宣稱此推特僅代表前運營官個人意見與火箭隊無不論什麽關系。


圖二:

技術分享技術分享


技術分享

技術分享

說完別人,再來說說我們自己吧。3月5日那天,群裏都在討論這條推送。本意是我們的編輯打算推一個分手相關的歌單,可是文案考慮不周全,讓人誤解。

導致非常多用戶感到莫名其妙,我們試想一下。你準備與你近期交往的對象一起吃個晚餐,出門前收到這條祝我們分手的通知。你是不是感到非常不爽呢。是的,在微博上隨手一搜就發現有非常多用戶是這樣的不爽的感覺了。當然了我拿這個對照並沒有說要炒這位編輯的魷魚噢。



好像偏題非常遠了,說這麽多事實上就是想說明一件事,應用程序的通知是非常重要的一環,處理的不好非常可能給用戶帶來不好的印象。輕則吐槽,重則直接卸載。

好了好了。言歸正傳,我先列一下題綱吧

一、Notification的使用

二、Notification跨進程通信的源代碼分析

三、優雅地設計通知(7.0)

四、通知權限問題

五、安卓的權限機制(6.0)

六、總結



一、Notification的使用

眼下咱們酷狗裏的通知使用主要有下面三種場景

1.消息中心的通知

2.下載歌曲的通知

3.通過PlaybackService啟動的通知

以下簡單分析一下這三種場景的通知是怎樣實現的。

第一種是使用系統布局生成的普通通知樣式

技術分享技術分享

NotificationManagerCompat manager = NotificationManagerCompat.from(this);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
builder.setContentTitle()  [1]
.setLargeIcon()       	   [2]
.setContentText()  	   [3]
.setNumber()         	   [4] 
.setSmallIcon()       	   [5]
.setWhen()            	   [6]
.setContentIntent(pendingIntent);
manager.notify(tag, id, builder.build());


另外一種是使用自己定義的布局生成的通知樣式

NotificationManagerCompat manager = NotificationManagerCompat.from(this);
NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
builder.setWhen()
.setSmallIcon()
.setLargeIcon()
.setContentIntent(pendingIntent);
RemoteViews remoteView = new RemoteViews(getPackageName,  R.layout.custom);
remoteView.setTextViewText(R.id.tv_title,  “通知標題”);
remoteView.setImageViewResource(R.id.iv_icon,  R.drawable.icon);
remoteView.setOnClickPendingIntent(R.id.iv_icon, pendingIntent);
builder.setContent(remoteView);
manager.notify(tag, id, builder.build());      RemoteViews不支持自己定義View等復雜View

這兩點的共性就是都是先初始化NotificationManagerCompat。和NotificationCompat.Builder, 再經過一系列builder設值後通過manager.notify去發送通知。不同點是普通通知直接設置界面元素的值,而自己定義通知是構造了一個remoteView的自己定義布局,把它設置給builder的content。

自己定義通知呢有一點須要註意就是。這個自己定義的布局裏的TextView字體的大小和顏色須要合理地配置,不然非常easy在不同系統中和其它app的通知展示方式不一樣,導致用戶通知欄由於這個而顯得不美觀,甚至非常突兀。那麽,官方也是有給我們提供這種解決方式:

Android 5.0之前可用:
android:style/TextAppearance.StatusBar.EventContent.Title    // 通知標題樣式  
android:style/TextAppearance.StatusBar.EventContent             // 通知內容樣式  

Android 5.0及更高版本號:  
android:style/TextAppearance.Material.Notification.Title         // 通知標題樣式  
android:style/TextAppearance.Material.Notification                  // 通知內容樣式

當然了這麽處理的話應該能解決絕大部分手機的通知文字樣式問題,但還是有一些被優化或者說改造過的系統。仍然不兼容這種通知樣式,這時候就須要通過build()一個默認通知,然後再去獲取當前系統通知的文字和顏色的方式了。

這種方式,能夠來看看我們代碼中怎樣實現的。

public SystemNotification getSystemText() {
        mSystemNotification = new SystemNotification();

        try {
            NotificationCompat.Builder builder = new NotificationCompat.Builder(this);
            builder.setContentTitle("SLNOTIFICATION_TITLE")
                   .setContentText("SLNOTIFICATION_TEXT")
                   .setSmallIcon(R.drawable.comm_ic_notification)
                   .build();

            LinearLayout group = new LinearLayout(this);
            RemoteViews tempView = builder.getNotification().contentView;
            ViewGroup event = (ViewGroup) tempView.apply(this, group);
            recurseGroup(event);
            group.removeAllViews();
        } catch (Exception e) {
            mSystemNotification.titleColor = Color.BLACK;
            mSystemNotification.titleSize = 32;
            mSystemNotification.contentColor = Color.BLACK;
            mSystemNotification.contentSize = 24;
        }


        return mSystemNotification;
    }

    private boolean recurseGroup(ViewGroup gp) {
        for (int i = 0; i < gp.getChildCount(); i++) {
            View v = gp.getChildAt(i);
            if (v instanceof TextView) {
                final TextView text = (TextView) v;
                final String szText = text.getText().toString();
                if ("SLNOTIFICATION_TITLE".equals(szText)) {
                    mSystemNotification.titleColor = text.getTextColors().getDefaultColor();
                    mSystemNotification.titleSize = text.getTextSize();
//                    return true;
                }
                if ("SLNOTIFICATION_TEXT".equals(szText)) {
                    mSystemNotification.contentColor = text.getTextColors().getDefaultColor();
                    mSystemNotification.contentSize = text.getTextSize();
//                    return true;
                }
            }
//            if (v instanceof ImageView) {
//                final ImageView image = (ImageView) v;
//                if (image.getBackground().getConstantState().equals(getResources().getDrawable(R.drawable.comm_ic_notification))) {
//                    mSystemNotification.iconWidth = image.getWidth();
//                    mSystemNotification.iconHeight = image.getHeight();
//                }
//            }
            if (v instanceof ViewGroup) {// 假設是ViewGroup 遍歷搜索
                recurseGroup((ViewGroup) gp.getChildAt(i));
            }
        }
        return false;
    }


至於詳細怎樣實現的發送通知。我們待會再繼續分析。


而第三種通知比較特殊。是用service.startForground(notification)的方式生成的通知。

我們酷狗啟動的時候就會在通知欄生成一個能夠控制播放的通知,這個通知就是playbackdservice在啟動的時候生成的。

Notification notification = new Notification();
notification.icon = R.drawable.icon;
notification.flags = mFlag;
notification.contentView = mContentView;
notification.contentIntent = pendingIntent
mService.startForeground(id, notification);

這種方法的註解是這種:Make this service run in the foreground, supplying the ongoing notification to be shown to the user while in this state.By default services are background, meaning that if the system needs to kill them to reclaim more memory (such as to display a large page in a web browser), they can be killed without too much harm. You can set this flag if killing your service would be disruptive to the user, such as if your service is performing background music playback, so the user would notice if their music stopped playing.

二、Notification跨進程通信的源代碼分析


我們的進程是怎樣將通知數據傳遞給系統進程的? 系統進程又是怎樣拿到我們進程的資源去繪制通知欄界面的? 關鍵在於RemoteViews 技術分享技術分享

那我們得好好了解一下這裏的RemoteViews的工作原理。先看一張流程圖
技術分享技術分享

我來解釋一下這張圖。本地進程和系統通知欄所在的系統進程是通過Binder來傳輸的。 Notification內部本身有RemoteViews變量。當notification.Builder運行build()方法的時候。會把通知相關的數據及View操作等都通過一系列的addAction的方法存在RemoteViews裏。在notificationManager真正運行notify()的時候,本地進程通過getService拿到binder對象,再生成NotificationManagerService的實例。service通過調用enqueueNotificationWithTag()方法將notification,pkgName,tag,id等等展示通知須要的數據都傳遞到系統進程。系統進程通過循環調用RemoteViews裏的apply()方法,去獲取到之前的view操作並運行,而系統進程要拿到本地進程的資源。則是通過context.createApplicationContext()先拿到和本地進程基本一樣的context。然後再通過getResource(資源id)去獲取資源。

這樣就非常好地解釋了remoteViews是怎樣跨進程通信的。

這裏我們要再跟進一下RemoteViews的源代碼,來驗證這段流程。
搞清楚了這點呢。我們再來看看之前一直存在於我們崩潰樹其中的這個崩潰,量還不小。

技術分享技術分享

大致意思就是系統無法創建notification,由於通過資源id0x7f021b02找不到須要展示的通知icon,也就展示不了通知。

而獲取資源的方式,剛才我們講到是通過context.createApplicationContext()拿到context,官方給出的解釋是:Return a new Context object for the given application name. This Context is the same as what the named application gets when it is launched, containing the same resources and class loader. Each call to this method returns a new instance of a Context object; Context objects are not shared, however they share common state (Resources, ClassLoader, etc) so the Context instance itself is fairly lightweight. 然後用context去getResource來獲取資源,能夠設想一下,由於這個資源id是在之前的build()操作的時候就已經把它傳遞到系統進程了,這時候假設本地進程覆蓋升級後更換了資源映射表,這時候系統進程再運行getResource的話。用舊的資源id。當然就找不到資源了。 眼下我們酷狗的解決的方法就是固化這一部分資源id,這樣不論發多少新版本號,通知欄須要的這些個資源都是相同的資源id。怎麽拿都不會拿不到了。

了解了remoteViews的跨進程通信這一塊,咱們再繼續跟進一下究竟notification.notify(),經歷了哪些詳細的過程。以下還是有一張圖、 技術分享技術分享
這張圖清晰地描寫敘述了,通知的notify方法是怎樣觸發到系統更新通知欄界面的,源代碼跟進解說。 主要是下面幾個類 NotificationManager NotificationManagerService NotificationListenerService BaseStatusBar PhoneStatusBar StatusBarView HeadsUpManager
三、優雅地設計通知 技術分享技術分享
這裏有一張通知界面的對照圖。上面的是7.0之前的系統通知欄布局,以下的是7.0的最新系統通知欄布局。

詳細變化圖裏已經表現的非常清晰了。 當然了,如今非常多手機廠商也都在嘗試使用自己定制的通知欄樣式。這也就使得我們在做自己定義通知的時候會遇到非常多阻礙。非常顯然。由於廠商會自己來繪制通知樣式,所以我們的程序要自己定義通知的時候,非常可能就和系統的樣式區別非常大,導致非常醜的現象。

僅僅有當我們的通知本身就非常特殊,不須要尾隨系統的其它通知樣式來展示時,才比較適合自己定義布局,眼下酷狗裏的下載通知就有這種問題。

技術分享

技術分享

技術分享技術分享技術分享

說到界面布局我想起來剛開始做通知的時候,遇到的一個小問題,這裏也講一下。為什麽左上角的smallIcon看不到,是一團灰色呢。

事實上是從sdk21開始,Google要求。全部應用程序的通知欄圖標,應該僅僅使用alpha圖層來進行繪制,而不應該包含RGB圖層。通俗地說,就是我們的通知欄圖標不要帶顏色就能夠了。

原來如此,怪不得我之前在酷狗裏看見這種代碼感到非常好奇卻不知道原因。

if (SystemUtils.getSdkInt() >= 21) {
    setSmallIcon(com.kugou.common.R.drawable.stat_notify_musicplayer_for5);
} else {
    setSmallIcon(com.kugou.common.R.drawable.stat_notify_musicplayer);
}

以下我們再來看看我覺得比較優雅的使用通知的方式。

1、進行中的通知
2、監聽清除事件的通知
3、不同優先級的通知
4、系統懸浮窗和鎖屏的通知
5、不同Style樣式的通知
6、Group通知
7、能夠直接回復的通知

尤其是最後兩點是7.0安卓系統獨有的。

https://material.io/guidelines/patterns/notifications.html#notifications-behavior

這個站點是谷歌推出的設計平臺有關通知這一塊的設計吧。

這一部分我們能夠來看看我寫的demo吧。


四、通知權限問題

說到通知欄就不得不提通知欄權限問題。之前我們的歌單推送功能。產品找到我說曝光量比點擊量大非常多,從技術上是什麽原因導致用戶收到以後並不去點擊呢。

由於之前的邏輯是僅僅要運行了notify()方法就覺得通知曝光了,這裏設計是有問題的,由於非常可能用戶已經把我們程序的通知給禁止掉了。

那我們怎麽知道自己的應用程序通知權限被禁止了呢?假設被禁止了又該怎麽辦呢?

帶著這兩個問題。我開始查閱資料了。

1、API24開始系統就提供了現成的方法來獲取通知權限

NotificationManagerCompat.from(this).areNotificationEnable();

2、另一種方式就是通過反射獲取

/**
     * 通過反射獲取通知的開關狀態
     * @param context
     * @return
     */
    public static boolean isNotificationEnabled(Context context){

        AppOpsManager mAppOps = (AppOpsManager) context.getSystemService(Context.APP_OPS_SERVICE);
        ApplicationInfo appInfo = context.getApplicationInfo();
        String pkg = context.getApplicationContext().getPackageName();
        int uid = appInfo.uid;
        Class appOpsClass = null; /* Context.APP_OPS_MANAGER */
        try {
            appOpsClass = Class.forName(AppOpsManager.class.getName());
            Method checkOpNoThrowMethod = appOpsClass.getMethod(CHECK_OP_NO_THROW, Integer.TYPE, Integer.TYPE, String.class);
            Field opPostNotificationValue = appOpsClass.getDeclaredField(OP_POST_NOTIFICATION);
            int value = (int)opPostNotificationValue.get(Integer.class);
            return ((int)checkOpNoThrowMethod.invoke(mAppOps,value, uid, pkg) == AppOpsManager.MODE_ALLOWED);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return true;
    }
這樣的方式呢實質就是通過AppOpsManager和AppOpsService去獲取位於/data/system/文件夾下的文件Appops.xml裏的數據。

這一塊的流程我們待會再細致描寫敘述一下。

好的,如今我們已經知道用戶的權限了,假設確實被用戶禁止了,我們有下面三個處理方式

1、最友好的方式當然是給用戶一個彈窗,對我們為什麽須要通知作一下闡述。然後引導用戶去打開權限。

2、最不友好的方式就是通過AppOpsManager.setMode()方法去改動用戶的權限

3、通過自己設計一個懸浮窗來替代系統的通知

第2種方式呢。在實踐的時候發現運行就會拋出異常,以下是異常信息

SecurityException java.lang.SecurityException: uid 10835 does not have android.permission.UPDATE_APP_OPS_STATS.

非系統應用都沒有改動權限的權限。

怎樣知道是不是系統應用呢。就是這個uid了。

看來谷歌已經把這條路給堵上了。

第3種方式應該是眼下比較普遍的做法了。產品希望一定要展示。那就僅僅能這麽繞彎子了。

這裏直接看項目裏的OverlayUtils吧。

只是懸浮窗又涉及到還有一個懸浮窗的權限,須要用戶打開才行,這麽看來還是傾向於第一種讓用戶自己來決定吧。

這裏記錄一下,我測試了兩款手機

華為:打開了通知,不管有沒有打開懸浮窗權限,都能彈出懸浮窗

關閉了通知,須要打開懸浮窗權限才幹彈出懸浮窗

小米:懸浮窗僅僅受懸浮窗權限控制,和是否打開通知沒有關系


說完通知欄我們再看看,為什麽我把通知欄權限禁止後。程序的Toast提示也都顯示不了了。

查閱源代碼後發現 Toast裏也用到了NotificationManagerService。

在Toast運行show()方法後,走到enqueueToast的時候有這麽一段代碼

if (ENABLE_BLOCKED_TOASTS && !noteNotificationOp(pkg, Binder.getCallingUid())) {
    if (!isSystemToast) {
        Slog.e(TAG, "Suppressing toast from package " + pkg + " by user request.");
        return;
    }
}
原來如此。這裏也用到了檢測通知權限的方法noteNotificationOp()。

Toast也被notification影響了,但是我們的程序裏Toast無處不在。由於通知權限導致toast彈不出影響挺大的。那我們找找看替代方案吧,事實上和通知類似。前面幾種就不說了。

事實上也是用WindowManager 懸浮窗。僅僅只是先通過系統toast拿到布局來用,這樣顯示效果就和系統toast一樣了。


五、安卓的權限機制(6.0)

這裏說到安卓的權限,我就想講講我還在實習的時候遇到的一個相關問題。balabalabala

當targetSdkVersion值為23下面(也就是android 6.0)的時候,權限是在程序安裝的時候便詢問了用戶。並配置好。
可是當targetSdkVersion值為23或23以上的時候,權限是當使用的時候才會詢問用戶,假設代碼不變的情況下,直接使用使用危急權限。程序會直接崩潰
java.lang.SecurityException: Permission Denial
眼下酷狗為21,臨時還不會出現這個問題
官方已經提供了一套流程來配合app與用戶之間的權限交互。

那targetSdkVersion是否該提升?官方說當用戶設備與targerSdkVersion一致的時候,程序執行效率會提高,由於會少處理非常多兼容性問題,有待考證。


我們來看看6.0下的權限流程

左圖是標準流程,右圖是用戶操作了不再提示

技術分享技術分享



鑒權(檢測權限)這一步來說一說。

這個之前提到過的AppOpsManager

技術分享


Setting UI通過AppOpsManager與AppOpsService交互。給用戶提供入口管理各個app的操作。



AppOpsService詳細處理用戶的各項設置,用戶的設置項存儲在 /data/system/appops.xml文件裏。
AppOpsService也會被註入到各個相關的系統服務中,進行權限操作的檢驗。



各個權限操作對應的系統服務(比方定位相關的Location Service,Audio相關的Audio Service等)中註入AppOpsService的推斷。

假設用戶做了對應的設置,那麽這些系統服務就要做出對應的處理。


(比方。LocationManagerSerivce的定位相關接口在實現時。會有推斷調用該接口的app是否被用戶設置成禁止該操作,假設有該設置,就不會繼續進行定位。)


那這個appops.xml文件長啥樣呢。我們來看看

技術分享

op標簽中
n標識權限的opCode,
t表示時間戳。
m標識權限值mode,有三種
1.MODE_ALLOWED = 0;
2.MODE_IGNORED = 1;
3.MODE_ERRORED = 2;

假設沒有m值。則為默認值,每種權限都有一種相應默認值。在AppOpsManager.sOpDefaultMode數組中,這是一個int數組,下標代表opCode,內容代表默認權限值。其它屬性能夠參考writeState方法一一相應


六、總結

一、通知的選擇
1.不依賴系統版本號都要顯示相同UI的能夠使用自己定義通知(比如酷狗播放通知)
2.須要與安卓系統版本號UI保持一致的使用系統通知(比如酷狗消息通知)
3.當你須要保護你的Service不被系統優先Kill掉,能夠用service.startForeground(notification)
二、做通知欄拓展的時候盡可能考慮7.0的通知欄特性(由於這些都是官方針對人性化用戶體驗設計的)
三、當須要跨進程使用View的時候能夠考慮RemoteViews
四、當通知權限受阻,考慮使用替代方案(懸浮窗等)
五、建立完好的權限詢問機制(針對targetSdkVersion,提高效率且提升用戶體驗)



Android Notification和權限機制探討