Fork me on GitHub

通知管理功能与常驻通知栏的设计与实现

Android 4.3开始系统开放了NotificationListenerService API,让应用管理通知提供了可能,本文系统描述清理大师通知管理模块的设计与实现方式,以及开发过程中遇到问题和解决方案。

[TOC]

功能背景

常驻通知栏

清理大师内部一直存在一个问题就是常驻进程可能在某些条件下被kill掉,导致一些依赖常驻进程的功能失效,典型的如自动清理。由于android系统在管理内存的时候按照Low memory killer 机制来决定需要杀掉哪些进程,当系统内存不足的时候,按照oom_adj的值来判断进程的重要程度,这个值越小,表示进程越重要。而常驻通知栏的使用,可以提升进程的重要程度,因为实现上是一个前台服务,系统会认为此服务对用户来说是重要的,能一定程度上降低被系统kill掉的可能性。基于此背景,加入常驻通知栏功能对清理大师
的功能完善上有促进作用,并且常驻通知栏提供一些小工具入口对于产品的活跃度提升也有一定帮助。

关于oom_adj的规则不在本文的讨论范围,这里不再赘述。

通知管理

基于4.3API开放的接口,我们可以对系统发送的通知进行管理。android上很多应用会发送各种各样的通知,当用户下拉statusbar,会看到很多乱七八糟的通知存在通知栏,有些用户会认为这是一件很烦人的事情,所以管理通知可以帮用户解决这个问题。

需求概述

  • 提供常驻通知栏入口;
  • 通知栏每一项功能分别为:进入大师内部,加速,通知管理/闹钟,手电/wifi,系统设置;
  • 通知栏样式适配;
  • 通知拦截;

    根据系统api对通知取消,使通知不再显示在通知栏。

  • 通知缓存;

    缓存分为内存缓存与本地缓存,其中内存缓存带跳转信息,可以跳转到原通知的目的地,本地缓存不带跳转信息,只打开应用。

  • 通知管理界面;

    对收到的通知界面展示,提供删除和加白功能。

  • 过滤规则;

    运营白名单机制,系统应用,带进度的通知,空通知,已经缓存的通知。

前期调研

如何获取通知?关于获取通知的原理分析参考另一篇文章Android 4.3+ NotificationListenerService 的使用

对于系统回调返回的StatusBarNotification对象,通过源码可以发现里面其实是对Notification对象包装了一下,另外携带了一些基本信息。

先来解决第一问题,怎么取消掉新收到的通知呢?NotificationListenerService提供了两个api,一个是取消所有通知(包括已经显示的),一个是根据参数取消单个通知,看上去第二个比较靠谱。写个demo试一下,果然是靠谱的,发送的新通知会被取消掉。根据经验换个5.0的系统试一下,果然是取消不掉的,又换API了?查官方文档,果然是换了:

This method was deprecated in API level 21.Use cancelNotification(String) instead. Beginning with LOLLIPOP this method will no longer cancel the notification. It will continue to cancel the notification for applications whose targetSdkVersion is earlier than LOLLIPOP.

5.0上不支持,改用cancelNotificatio(String)代替,5.0以下老方法还有效。那就适配一下加个判断解决。

接下来看第二个问题。既然对收到的通知已经取消了,怎么缓存呢?需求是要求通知要带跳转信息,分析下Notification的源码吧,看看我们能获取到什么信息。发现contentIntentcontentRemoteView比较关键。contentIntent是一个PendingIntent对象,光它的内部机制就可以单开一个博文来描述,这里粗暴的理解为四个字“异步激发”。通知我们知道是由NotificationManagerService来管理的,通常我们点击它会启动目标应用,这显然是一个跨进程调用,我们要找的跳转信息就存在pendingIntent中。查看它的源码,发现一个叫send的方法,写个demo验证一下,完美。
那么现在已经知道了,contentIntent这个对象是需要的,它携带了跳转信息。

继续分析notification源码,发现contentRemoteView,这是一个RemoteView对象。熟悉自定义通知栏的同学应该用过这个。这个对象就是你布局的通知栏的样式。查看它的源码,发现有个apply的方法,描述如下:

Inflates the view hierarchy represented by this object and applies all of the actions.

这句话提供了两个信息,第一可以通过这个方法inflate出布局信息,返回一个view;
另外一个是applies all of the actions。通过返回的view,我们可以得到通知栏的布局view,那么我们就可以在界面上还原出这个通知的原样了。

这里的actions很有用,后面解决一个问题就研究了它,这里先不赘述了。

好了,调研到这里,我们数据已经准备好了,接下来该缓存。第一反应,我能把这些数据持久化到本地就好了,这样数据不丢失,状态也能保存,之后需要的时候读取出来。听上去不错,但是一用就犯错,自定义的对象虽然实现了序列化接口,但是pendingIntent这个对象是个Parcelable对象,没法用java的序列化方式往磁盘写,它里面携带binder对象,并且自身没有实现Serializable,直接报错!此路不行。

既然这样,就只能分为两块儿缓存了。内存缓存+磁盘缓存的方式来处理。内存缓存的数据,携带了pendingInetent,可以跳转到目标应用,本地缓存只保存通知基本信息,调研基本上能满足功能需求。

设计思想

缓存系统

由于NotificationListenerService可以帮我们唤醒进程,并且需要有一份内存缓存,所以针对NotificationListenerService选择放在常驻进程中。缓存系统解决需要解决以下问题:

  • 内存限制

    出于占用内存的考虑,设计上缓存系统有上限为最新的200条通知,超过200条会移除最老的通知。

  • 多线程问题

    service在收到通知的时候,是在binder线程中,也就是说对于缓存系统,put操作是在binder线程中完成的。而UI可以对缓存操作,UI线程可以读取,修改缓存,所以同步问题是需要解决的。有以下两种方案:
    1.读写锁,此方式不赘述。
    2.任务队列。既然操作缓存不再一个线程,那么就让这些操作在同一个线程中排队完成。可以使用HandlerThread的任务队列来设计。

  • 跨进程传输问题

    由于数据在常驻进程,如果Activity也在常驻进程的话,每次开启界面退出后activity销毁内存释放得不是很及时,会导致常驻进程内存额外有UI的内存,所以对activity没有声明所属进程。那么当UI进程启动通知管理页面,需要跨进程取数据,需要用到AIDL方式。需要注意的是,binder在数据传输的时候数据大小限制为1M,而且是进程内部所有的binder通信共享的,所以当数据过大是传输出问题的。所以缓存系统需要提供分页传输,或者单个实体传输,避免使用集合传输。
    补充:既然传递大数据有问题,那么为什么bitmap可以传输呢?这是因为binder传输的时候对底层数据的大小做了分类,对于大型数据如bitmap,使用Ashmem匿名共享内存的方式传输的。所以,我们有两种方案:

    • 将数据转成bitmap格式,编码解码传输;
    • 直接使用android提供的MemoryFile传输;
  • 内存本地双缓存

    为了设计上方便,对于本地缓存设计成内存缓存的快照。每次更新内存缓存,同步更新本地缓存一次。这样避免设计成两份缓存,需要合并数据的麻烦。

流程

通知管理的主要流程如下:

当收到一条通知的时候,会先经过一个过滤器,通过过滤器的通知,先取消掉它在通知栏的显示,然后进入缓存系统。

当界面上展示的时候,通过一个UIop的中间层访问缓存系统,并通过UIop对数据删除,加白等操作。

1
st=>start: Start
e=>end: End
in=>operation: onNotificationPosted
filter=>operation: doFilter
cancle=>condition: cancle?
cache=>subroutine: cache
uiop=>operation: OP
UI=>subroutine: UI

st->in->filter->cancle
cancle(yes,right)->in
cancle(no)->cache
cache(right)->uiop(right)->UI

环境与配置

目前清理大师依赖了额外sdk jar包,新增的NotificationListenerService在4.3以上才有,工程编译版本是4.0,所以将SDK目录下platforms/android-19的android.jar解压找出需要的class文件。按照原包名路径重新打包进android-plus.jar中。

踩坑与填坑

  • 如何唯一区分一条通知?

    NotificationListenerService服务启动后,只要有任何通知更新,都会收到一条StatusBarNotification,很多应用发送通知的id都是一个,根据包名与id共同判断无法做到通知唯一性,这样就不能保证进入缓存的通知实体能保证不漏、不重cache住所有收到的通知。如何来区分呢?如何来区分呢?经过一些应用尝试,统计如下这些字段共同判断能解决绝大部分。

    1
    2
    3
    if (entity.id == this.id && entity.pkgName.equals(this.pkgName) && entity.title.equals(this.title) && entity.ticker.equals(this.ticker)) {
    ...
    }
  • 解决带进度的通知

    带进度的通知,每次刷新的时候,NotificationListenerService都会收到一条通知,一般应用会有一个进度条,有些应用没有进度条,只有不断变化的百分比数字(titlle字段),当然每次收到的通知按照上面的逻辑是区分不出来的,还有的进度条跟百分比都有,这就需要来抓进度条了。如何判断通知带进度条?

前面留下一个actions的问题,这个action是我们用来抓取是否包含进度的关键。分析RemoteView源码,发现有一个mActions字段,记录了这个view包含的一些列操作,比如setText,setSize,setMax等。这个字段是隐藏的,需要反射来解析里面的数据,然后看是否包含setProgress方法,通过这个标记来确定是否包含进度条。

热评文章