服务粉丝

我们一直在努力
当前位置:首页 > 财经 >

Android自定义通知方方面面全适配

日期: 来源:郭霖收集编辑:码上开炼


/   今日科技快讯   /

近日,社交媒体推特老板埃隆·马斯克在回复用户推文时表示,推特将于下周开源算法。

在此之前,马斯克一直说要将推特算法开源。周二一名推特用户表示,如果“现在开源推特算法,那才让我们真佩服你。”马斯克回应称,“我们的算法下周就开源,一开始(公开的算法)可能会让你失望,但会迅速改进的。”

/   作者简介   /

本篇文章来自码上开炼的投稿,文章主要是作者对自定义通知相关内容的总结,相信会对大家有所帮助!同时也感谢作者贡献的精彩文章。

码上开炼的博客地址:
https://juejin.cn/user/2551282372718599

/   前言   /

本文主角:

  • NotificationChannel
  • NotificationManager(notify方法)
  • RemoteViews
  • NotificationManagerCompat.cancel(notifyId)
  • UI适配(展开&折叠)

/   创建一条自定义通知   /

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只支持以下几种布局:

  • AdapterViewFlipper
  • FrameLayout
  • GridLayout
  • GridView
  • LinearLayout
  • ListView
  • RelativeLayout
  • StackView
  • ViewFlipper

支持的控件有:

  • AnalogClock
  • Button
  • Chronometer
  • ImageButton
  • ImageView
  • ProgressBar
  • TextClock
  • TextView

API 31之后还支持以下的控件:

  • CheckBox
  • RadioButton
  • RadioGroup
  • Switch

没错,不支持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

IDE也温馨提示了~


总结: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
}

/   通知的UI适配(背景颜色)   /

问题:未居中。


改为自适应后如下:


经过测试,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上,默认就是显示折叠的样式,用户点打开的箭头,就会显示展开的样式。

/   通知的UI适配(显示区域)   /

产品说:为什么在安卓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


调用notify方法报错,就一行代码。

(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_MAX
2. 使用window方式显示在屏幕中间,更具有干扰性


3. 尝试在展示通知之前开个delay 10秒,log确实是有延迟了10秒,但是在通知栏里面,那条通知并没有置顶显示,此方案不可行。



推荐阅读:
我的新书,《第一行代码 第3版》已出版!
Kotlin Flow响应式编程,StateFlow和SharedFlow
Kotlin协程开发的基础入门知识

欢迎关注我的公众号
学习技术或投稿


长按上图,识别图中二维码即可关注

相关阅读

  • 推倒重来,如何快速从 0 到 1 重构一个 APP 项目?

  • 说到「重构」这个话题,或许有些朋友遇到过这样的经历,公司早年间上线的项目系统,因一直没专人在演进过程中为代码质量负责,导致现在代码越来越混乱,逐渐堆积成“屎山”,目前的维护
  • 分享两种方式:如何开启JNI的“大门”?

  • ‍‍1要介绍本篇博客的原因前段时间学习OpenGL ES相关技术,下载了一个Github项目学习,项目地址在:https://github.com/githubhaohao/NDK_OpenGLES_3_0项目的关键代码都是C++实
  • 免费开源Windows系统错误代码查询工具!

  • “设为星标”第一时间获取各类实用干货!前言大家在使用Windows系统过程中,或多或少都遇到过一些错误,有些错误系统会给出解释,有些错误则只给出对应的错误代码,而这些错误代码代
  • 老板:你来弄一个团队代码规范!?

  • 点击上方 三分钟学前端,关注公众号面试官也在看的前端面试资料本篇文章讲怎么在前端团队快速制定并落地代码规范!!!干货,拿走这个仓库[1]image.png一、背景9月份换了一个新部
  • 微信 JS-SDK 出发,一起了解JSBridge的神奇功能

  • 前言前段时间由于要实现 H5 移动端拉取微信卡包并同步卡包数据的功能,于是在项目中引入了 **`微信 JS-SDK(jweixin)`**[1] 相关包实现功能,但也由此让我对其产生了好奇心,于是打
  • Are you OK?

  • “Are you OK?” “Fine, thank you and you?”嗯,是不是感受到了浓浓的international味?从出生起就带着国际化基因的小米,如今已经进入全球100多个国家和地区市场。只要你在小

热门文章

  • “复活”半年后 京东拍拍二手杀入公益事业

  • 京东拍拍二手“复活”半年后,杀入公益事业,试图让企业捐的赠品、家庭闲置品变成实实在在的“爱心”。 把“闲置品”变爱心 6月12日,“益心一益·守护梦想每一步”2018年四

最新文章

  • 平塘公安“天、地”联动 禁种铲毒不留死角

  • 今年以来,平塘县公安局坚持问题导向、精准施策,强化科技支撑、综合治理,从“早部署+广宣传+深踏查”三个维度发力,高效铲除毒品隐患根源,扎实开展禁种铲毒专项行动,确保实现“零种
  • Android自定义通知方方面面全适配

  • / 今日科技快讯 /近日,社交媒体推特老板埃隆·马斯克在回复用户推文时表示,推特将于下周开源算法。在此之前,马斯克一直说要将推特算法开源。周二一名推特用户表示,如果“现
  • App Bundle?了解一下!

  • / 今日科技快讯 /近日,美国国家航空航天局表示,波音“星际线”飞船将于今年春季进行首次载人飞行,将首批宇航员送往国际空间站。 据悉,“星际线”的最近一次任务是轨道飞行
  • 推倒重来,如何快速从 0 到 1 重构一个 APP 项目?

  • 说到「重构」这个话题,或许有些朋友遇到过这样的经历,公司早年间上线的项目系统,因一直没专人在演进过程中为代码质量负责,导致现在代码越来越混乱,逐渐堆积成“屎山”,目前的维护