近期为了在部门内做一次小型的技术分享。深入了解了一下Notification的实现原理。以及android的权限机制。在此做个记录。文章可能比較长,没耐心的话就直接看题纲吧。
先看一下以下两张图
图一:
看到这图可能大家不太明确,这和我们的notification有什么关系,我来简介一下背景。这是发生在15年NBA季后赛期间,火箭队对阵小牛队,火箭队以3:1率先,仅仅要再赢一场就能淘汰对手。这时候火箭队的官方首席运营官发了这条官方推特。
翻译一下就是 “一把枪指着小牛的队标,哼哼,仅仅须要闭上你们额眼睛,立即就要结束了”。这条推特当时引起了非常多人的转发和评论,而且推送给了全部关注相关比赛的球迷以及媒体。
我们试想一下,你是一个小牛的球迷,输了比赛以后本来心情就非常差。这时候手机一震,收到这条通知栏推送。你是不是会有一种强烈的被蔑视感觉。当天推特上就掀起了一阵网络争议。不仅小牛球迷,其它中立球迷也表示这条推特讽刺意味十足,已经有侮辱对手的嫌疑了。当然了,通知栏表示我不背这个锅,谁来背?第二天,这位首席运营官就被火箭官方开除了,并宣称此推特仅代表前运营官个人意见与火箭队无不论什么关系。
图二:
说完别人,再来说说我们自己吧。3月5日那天,群里都在讨论这条推送。本意是我们的编辑打算推一个分手相关的歌单,可是文案考虑不周全,让人误解。
导致非常多用户感到莫名其妙,我们试想一下。你准备与你近期交往的对象一起吃个晚餐,出门前收到这条祝我们分手的通知。你是不是感到非常不爽呢。是的,在微博上随手一搜就发现有非常多用户是这样的不爽的感觉了。当然了我拿这个对照并没有说要炒这位编辑的鱿鱼噢。
好像偏题非常远了,说这么多事实上就是想说明一件事,应用程序的通知是非常重要的一环,处理的不好非常可能给用户带来不好的印象。轻则吐槽,重则直接卸载。
好了好了。言归正传,我先列一下题纲吧
一、Notification的使用
二、Notification跨进程通信的源代码分析
三、优雅地设计通知(7.0)
四、通知权限问题
五、安卓的权限机制(6.0)
六、总结
一、Notification的使用
眼下咱们酷狗里的通知使用主要有下面三种场景
1.消息中心的通知
2.下载歌曲的通知
3.通过PlaybackService启动的通知
以下简单分析一下这三种场景的通知是怎样实现的。
第一种是使用系统布局生成的普通通知样式
watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvd3Vob25ncWkwMDEy/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center" >
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下的权限流程
左图是标准流程,右图是用户操作了不再提示
watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvd3Vob25ncWkwMDEy/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center" >
鉴权(检測权限)这一步来说一说。
这个之前提到过的AppOpsManager
watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvd3Vob25ncWkwMDEy/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center" >
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,提高效率且提升用户体验)