近日,社交媒体推特老板埃隆·马斯克在回复用户推文时表示,推特将于下周开源算法。在此之前,马斯克一直说要将推特算法开源。周二一名推特用户表示,如果“现在开源推特算法,那才让我们真佩服你。”马斯克回应称,“我们的算法下周就开源,一开始(公开的算法)可能会让你失望,但会迅速改进的。”本篇文章来自码上开炼的投稿,文章主要是作者对自定义通知相关内容的总结,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。https://juejin.cn/user/2551282372718599
- NotificationManager(notify方法)
- NotificationManagerCompat.cancel(notifyId)
NotificationChannel + RemoteView + Notification 三个主要的类。安卓8之后引入了通知渠道(NotificationChannel),可将多条(notifyId不同)通知绑定到同一个通知渠道下,用户可在设置中手动关闭。/**
*id:渠道ID,必须唯一,且不超过40个字符
*name:渠道名,用户在设置中看到的,如上图
*importance:重要程度
*/
public NotificationChannel(String id, CharSequence name, @Importance int importance) {
}
---importance参数的可选值---
//在通知栏可见,不会有提示音,不会横幅弹出
public static final int IMPORTANCE_LOW = 2;
//在通知栏可见,有提示音,不会横幅弹出
public static final int IMPORTANCE_DEFAULT = 3;
//在通知栏可见,有提示音,会横幅弹出
public static final int IMPORTANCE_HIGH = 4;
//建议单独写在一个常量类中,防止别人在不了解的情况下随意修改
private const val CHANNEL_ID_DAILY = \"channel_id_daily\"
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) {
val dailyChannel: NotificationChannel = NotificationChannel(
CHANNEL_ID_DAILY,
getString(R.string.notify_channel_name_daily),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
//设置一些参数,可以参考API文档自行设置
setShowBadge(false)
enableLights(true)
enableVibration(true)
lightColor = Color.RED
}
notificationManager.createNotificationChannel(dailyChannel)
}
private const val REQUEST_CODE_DAILY = 0x1000
private const val NOTIFY_ID_DAILY = 0x2000
//通过传入channelId将这条通知和上面定义的通知渠道绑定
val notification = NotificationCompat.Builder(this, CHANNEL_ID_DAILY)
.setSmallIcon(R.mipmap.ic_launcher)//必须设置,否则会奔溃
.setLargeIcon(BitmapFactory.decodeResource(this.resources, R.mipmap.ic_launcher))
.setCustomContentView(remoteViews) //折叠后通知显示的布局
.setCustomHeadsUpContentView(remoteViews)//横幅样式显示的布局
.setCustomBigContentView(remoteViews) //展开后通知显示的布局
.setContent(remoteViews) //兼容低版本
.setColor(ContextCompat.getColor(this, R.color.color_367AF6))//小图标的颜色
.setAutoCancel(true) // 允许点击后清除通知
.setPriority(NotificationCompat.PRIORITY_MAX)
.setDefaults(NotificationCompat.DEFAULT_ALL) // 默认配置,包括通知的提示音,震动效果等
.setContentIntent(pendingIntent) //一定要设置,点击整个remoteView就可跳转
notificationManager.notify(NOTIFY_ID_DAILY, notification.build())
标准通知是直接调用setContentTitle & setContentText来创建通知的标题和内容, 这里并没有用到,而是多了一个remoteViews,看看怎么创建RemoteViews。val remoteViews = RemoteViews(this.packageName, R.layout.layout_cus_notify)
val funIntent = Intent(this, ArticleActivity::class.java)//演示代码:点击后跳转到ArticleActivity
funIntent.putExtra(\"key_article_nun\", \"第一篇\")
val pendingIntent: PendingIntent =
PendingIntent.getActivity(
this, REQUEST_CODE_DAILY, funIntent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_MUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
)
remoteViews.setTextViewText(R.id.text_info, \"欢迎您查看的我的文章\")
remoteViews.setOnClickPendingIntent(R.id.btn_fun, pendingIntent) //为按钮设置点击事件
可以看到,RemoteView设置点击的方法并不是setOnClickListener,看看RemoteViews的真面目。public class RemoteViews implements Parcelable, Filter
并没有继承View,是一个序列化的类,那它和View到底有什么关系?这里暂时不展开讨论,会在第三篇中遇到的bug中,详细对RemoteViews进行说明~要注意,RemoteView只支持以下几种布局:没错,不支持ConstraintLayout,IDE也会提醒你的~那支持自定义View吗?试试EditText(继承自TextView),报错了。Caused by: android.view.InflateException:
Binary XML file line #26 in com.test.notification:layout/layout_cus_notify:
Class not allowed to be inflated android.widget.EditText
总结:RemoteViews只支持以上的控件,不支持它们的子类和其他View,当然也不支持自定义View。标准通知的做法是,为整个通知设置点击的PendingIntent,通知点击后自动关闭通知。setContentIntent(PendingIntent)
setAutoCancel(true)
在自定义通知里这样是行不通的,这个想法也在Stack Overflow里面得到了证实,那如何关闭呢?实现思路:给关闭按钮设置单独的PendingIntent,PendingIntent接收的intent是Broadcast,点击关闭按钮后发送特定的action,广播收到特定的action后cancel掉对应的notifyId。NotificationUtils.cancel(notifyId)
算是另辟蹊径了,但也确实没有找到更好的方法,线上跑了一段时间之后,发现ANR增加了,报错是在Broadcast的onReceive方法中,于是把cancel方法放到子线程去执行,发现还是会报ANR,因为我们项目比较特殊,有一个一直在后台运行的Service,于是就把PendingIntent的接收者改为Service,PendingIntent携带的Extra就是notifyId。代码如下,最终,没有再上报相关的ANR,ANR占比下降。//点击关闭按钮的相关代码
val closeIntent = Intent(context, CpuUseService::class.java)
closeIntent.putExtra(UpdateConstant.KEY_CLOSE_NOTIFY_ID, notifyId)
val closePendingIntent: PendingIntent =
PendingIntent.getService(
context,
notifyId,
closeIntent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else PendingIntent.FLAG_UPDATE_CURRENT
)
remoteViews.setOnClickPendingIntent(R.id.btn_close, closePendingIntent)
//Service接收的相关代码
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
val bundle = intent?.extras
val notifyId = bundle?.getInt(UpdateConstant.KEY_CLOSE_NOTIFY_ID)
lifecycleScope.launch(Dispatchers.IO) {
repeatOnLifecycle(Lifecycle.State.CREATED) {
NotificationUtils.cancel(notifyId)
}
}
return START_STICKY
}
经过测试,android:background在部分机型上并没有生效,还是显示系统默认的颜色,如上图,使用如下API设置背景颜色有效。RemoteView?.setInt(R.id.ll_root, \"setBackgroundColor\", ContextCompat.getColor(context, R.color.color_367AF6))
建议:通知的背景应适配系统背景的颜色,这样看上去协调统一,因为不同的手机通知的背景色是不一样的。像横幅一样显示在手机的顶部,就叫悬浮通知,部分机型有悬浮通知权限。setCustomHeadsUpContentView(mRemoteViews) //设置悬浮通知的布局
setCustomContentView(cusSmallRemoteViews) //设置收起后通知的布局
setCustomBigContentView(cusBigRemoteViews) //设置展开后通知的布局
setContent(cusBigRemoteViews) //兼容低版本,可根据需求设置
项目需求是:默认以展开样式显示横幅通知,不过,安卓12以上,通知默认都是折叠的,如果直接设置悬浮通知的布局为展开后的ui,会出现只显示部分通知的问题,适配伪代码如下。//创建通知需要传入NotificationChannel对应的channelId
val notification = NotificationCompat.Builder(context, channelId)
.setCustomContentView(cusSmallRemoteViews) //折叠后通知显示的布局
.setCustomBigContentView(cusBigRemoteViews)//展开后通知显示的布局
.setContent(cusSmallRemoteViews) //兼容低版本,可根据需求设置
...忽略其他需要设置的api
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
notification.setCustomHeadsUpContentView(cusBigRemoteViews)//设置悬浮通知的样式
}else{
//安卓12以上不用设置setCustomHeadsUpContentView
}
notificationManager.notify(notifyId, notification.build())
就是安卓12不用单独设置setCustomHeadsUpContentView,用setCustomContentView和setCustomBigContentView单独设置折叠和展开就可以了,这样的话,在安卓12上,默认就是显示折叠的样式,用户点打开的箭头,就会显示展开的样式。产品说:为什么在安卓12上,我们app显示区域的高度比其他app的要少,看下图,蓝色通知的高度比白色通知的高度少。原来,安卓12(targetSdk >= 32)自定义通知的显示区域在收紧(折叠时高度上限为64dp,展开时高度上限为 256dp),系统强制显示通知的小图标,包括常驻通知,显示区域为图中阴影部分。真相了,我们的app是适配到安卓12的,所以显示区域少,解包看看别人的app,发现适配到安卓11,所以,解决方案是,安卓12和11以下分别用两套布局,这样效果是最完美的,但是通知有展开和折叠两种样式,也就是说,每改动一次通知的UI,要同步修改4个布局,难受.../ TransactionTooLargeException /Fatal Exception: java.lang.RuntimeException
android.os.TransactionTooLargeException: data parcel size 518960 bytes
(ContextCompat.getSystemService(context, NotificationManager::class.java) as NotificationManager).notify(
NotifyConstant.NOTIFY_ID_RESIDENT,
getResidentNotification(context)
)
看getResidentNotification方法中有没有大图传输或者intent传输值,唯一和图片相关的就是setLargeIcon和setSmallIcon,尝试将图片压缩到200K以下,线上仍然报错,推测和图片无关。问题非必现的,刚上线没有这个问题,时间越往后问题占比越高,结合项目的特点:通知一直运行在后台且每隔5分钟刷新一次界面,考虑和时间的积累有关,尝试修改刷新频率为5ms(之前是5min),进行极限测试,问题复现,初步判断和更新UI有关。刷新UI调用的代码如下,考虑和RemoteViews有关,且是跨进程通信出现的传输数据过大,具体查看更新UI的代码。remoteView?.setTextViewText(R.id.tv_cpu_tem_tip_yellow,\"\")
remoteView.setViewVisibility(R.id.small_fl_cpu, View.GONE)
系统并没有通过Binder去直接支持View的跨进程访问,而是提供了一个Action的概念,Action代表一个View操作,Action同样实现了Parcelable接口。系统首先将View操作封装到Action对象,界面上的控件每更新一次,mActions的长度就会加1,调用NotificationManager.notify()就会遍历所有的Action,执行Action的apply方法,通过反射调用TextView的相关方法。//调用setTextViewText后,mActions会+1
private ArrayList<Action> mActions;
private void addAction(Action a) {
if (mActions == null) {
mActions = new ArrayList<>();
}
mActions.add(a);
}
//BaseReflectionAction.java
@Override
public final void apply(View root, ViewGroup rootParent, InteractionHandler handler,
ColorResources colorResources) {
final View view = root.findViewById(viewId);
if (view == null) return;
Class<?> param = getParameterType(this.type);
if (param == null) {
throw new ActionException(\"bad type: \" + this.type);
}
Object value = getParameterValue(view);
//调用setText方法
try {
getMethod(view, this.methodName, param, false /* async */).invoke(view, value);
} catch (Throwable ex) {
throw new ActionException(ex);
}
}
考虑跨进程通信时,mActions对象过大,导致抛出TTL,解决:mActions的大小超过一定限制就重新初始化RemoteView,伪代码如下。private var mActionsSize = 0
private var mRefreshTime = 0
mRefreshTime++
runCatching {
val remoteViewsClass = Class.forName(\"android.widget.RemoteViews\")
val mActionsField: Field = remoteViewsClass.getDeclaredField(\"mActions\")
mActionsField.isAccessible = true
//反射拿到mActions的大小
val d = mActionsField.get(residentRemoteView) as MutableList<*>
mActionsSize = d.size
}
//这里有一个兜底逻辑,如果反射获取mActionsSize失败,就走mRefreshTime的逻辑
//mRefreshTime是指调用RemoteViews API的次数
// 100 和 15是一个粗略值,大佬有更好的建议请在文末留言,谢谢啦~
if (mActionsSize >= 100 || mRefreshTime >= 15) {
mActionsSize = 0
mRefreshTime = 0
residentRemoteView = null //手动将RemoteView置null
residentSmallRemoteView = null
}
if (residentRemoteView == null || residentSmallRemoteView == null) {
initResidentRemoteView(context)
}
当用户卸载应用时,更晚的弹出问题,这样用户最后看到的就是我们的app,下拉通知栏,我们的应用就会显示在第一个。1. 提高通知的优先级,目前项目代码里已经是PRIORITY_MAX2. 使用window方式显示在屏幕中间,更具有干扰性3. 尝试在展示通知之前开个delay 10秒,log确实是有延迟了10秒,但是在通知栏里面,那条通知并没有置顶显示,此方案不可行。