简介:一套面向Android开发者的自定义ActionProvider与Toolbar菜单小红点实现方案,源自博主yanzhenjie1003的实战教程,主要解决Toolbar菜单项需要红点提醒但又缺少原生支持的问题。资源包共含1390个文件,压缩后约10.09MB,其中png为界面切图,xml为布局与资源配置,class、jar、dex等为编译产物与依赖库,整体目录结构清晰,适合直接导入Android Studio学习和二次改造。内容包含完整示例工程、可安装运行的app-debug.apk、以及配套图片和配置文件,能够配合作者博客中的自定义教程逐步实践。已有317人学习下载,适合正在研究Toolbar菜单定制、ActionProvider扩展、自定义Menu小红点效果的中级Android开发者参考,可帮助理解ActionProvider生命周期及菜单项交互原理。
1. 项目概述:BadgeActionProvider 到底解决什么问题
做移动端开发的人,大概率都写过类似这样的代码:某个 Tab 上要显示未读消息数,某个按钮右上角要挂一个小红点,某个列表项要展示“NEW”标识。需求本身不复杂,但真正落到工程里,事情就没那么简单了——角标数据从哪儿来、什么时候更新、谁负责把数据映射到 UI、用户点击角标或按钮时又如何触发后续动作,这些逻辑如果全堆在 View 或者 ViewModel 里,很快就会变成一坨令人头痛的胶水代码。
我第一次意识到需要专门抽象一个组件,是在一个多模块项目里。当时业务线有六七个页面都要展示角标,而且角标数据来源各不相同:有来自消息推送的、有来自接口轮询的、还有本地数据库变化的。最开始每个页面各自拉数据、各自刷新,结果就是同一时间不同页面显示的角标数对不上,用户截图来投诉,我们排查了半天才定位到是某个页面的刷新时机晚了两秒。那之后我就开始考虑,能不能把这些零零散散的角标逻辑统一收到一个专门的管理器里,由它对外提供标准的数据访问方式和动作回调入口。这个组件,就是“BadgeActionProvider”。
简单说,BadgeActionProvider 是一个专门负责角标(Badge)数据状态管理与动作触发的 Provider 组件,它把角标的显示内容、未读数量、红点状态、点击回调、过期策略等逻辑从业务页面中抽离出来,统一由 Provider 维护。无论是 Android 还是 Flutter,或者其他支持 Provider 模式的框架,这个思路都适用。它的核心价值有四个:
- 统一数据源,避免多个页面各自维护导致的状态漂移;
- 隔离 UI 依赖,页面只负责展示,不用关心数据从哪里来;
- 动作与显示解耦,点击角标之后触发什么业务,由 Provider 统一分发;
- 方便测试,角标逻辑可以独立做单元测试,不用启动整个页面。
如果你也是在项目里被微件标注、消息红点这类需求反复纠缠,或者你正在做一个需要支持高频状态变化的中大型应用,这篇文章里的设计思路和踩坑记录会非常实用。下面我把这个组件的完整设计过程、关键实现和一套经过验证的排查技巧都梳理出来。
2. 设计思路与整体架构拆解
2.1 为什么不用一个全局单例来管理角标
一说到“统一管理”,很多人第一反应是写一个全局单例。我一开始也这么干过,但很快就后悔了。全局单例在 Demo 里挺好用,一旦进了真实业务,问题就暴露得很明显:
- 生命周期不可控。单例常驻内存,如果页面销毁时忘记清理数据,下次进入就会看到旧角标闪烁一下再更新,体验很糟糕。
- 状态来源混乱。单例内部如果有 getter 和 setter,调用方可以乱改状态,你根本不知道是哪个页面在什么时候偷偷改了角标数。
- 事件分发容易写出面条代码。点击角标要触发跳转,全局单例就得持有各种各样的回调引用,稍不注意就内存泄漏。
所以我的思路是:不用静态方法直接访问,而是通过 Provider 机制在依赖注入容器里注册一个作用域明确的实例。它不常驻,而是随页面或模块的生命周期创建和销毁。这样既保证了全局唯一状态,又不会出现生命周期泄漏问题。
2.2 整体职责边界
在设计 BadgeActionProvider 时,我明确给这个组件划分了四个核心职责:
| 职责 | 说明 | 典型操作 |
|---|---|---|
| 状态管理 | 维护角标数据(数量、类型、展示状态) | 设置未读数、清零红点、增量&减量 |
| 动作分发 | 把用户的点击行为转换为业务事件 | 点击角标跳转会话列表、红点提醒清空 |
| 数据同步 | 接收多来源数据并合并成统一视图 | 推送、轮询、本地 DB 变更的合并 |
| 配置策略 | 空值策略、数字上限、过期时间 | 超过99显示“99+”、24小时未读自动置灰 |
这样的好处是,页面拿到 BadgeActionProvider 后,只需要做两件事:读取状态来渲染 UI,调用动作方法来响应用户操作。其他一律不需要关心。
2.3 基于“事件驱动”而非“命令驱动”的设计
我在实现时特别把“事件”和“动作”分开。举个例子:用户点击了消息 Tab 上的红点,页面的反应是“去消息列表页”。这个“去消息列表页”是一个动作(Action),而“用户点击了红点”本身是一个事件(Event)。如果直接把它俩绑死,每次需求变更都要改页面代码。
BadgeActionProvider 采用的是事件注册 + 动作响应模型:
- 数据源(比如推送服务)通过
dispatchEvent(...)把“有新消息”这一类事件扔给 Provider。 - Provider 内部做状态更新,把新的未读数计算出来。
- UI 层通过
StateStream/Flow观察状态变化,自动刷新。 - 用户点击时,Provider 上报点击事件,由上层(导航层或路由层)决定执行什么动作。
这套模型的好处是解耦明显。页面不用知道“未读数到底来自哪里”,推送服务也不用关心“当前哪个页面正在展示角标”。
3. 核心实现细节与关键代码
3.1 Provider 对外暴露的接口设计
在设计接口时,我刻意避免了把 Provider 做成一个什么都能干的“上帝类”。对外暴露的方法越少越好,最好只有状态读取和动作触发两类。
以 Kotlin 代码为例,核心接口大致长这样:
interface BadgeActionProvider { /** * 观察某个具体的角标状态 */ fun observeBadge(channel: BadgeChannel): Flow<BadgeState> /** * 获取角标当前快照,用于页面初始化 */ fun currentBadge(channel: BadgeChannel): BadgeState /** * 外部数据源上报角标变化(由推送/接口调用) */ fun notifyBadgeChanged(channel: BadgeChannel, value: Int) /** * 点击角标时调用,触发业务动作 */ fun onBadgeClicked(channel: BadgeChannel, extra: Any?) }这里有一个细节:BadgeChannel。它不是简单的字符串常量,而是一个枚举或者密封类,用来表示不同的角标场景,比如“会话未读”“系统通知”“好友申请”。用密封类比用普通字符串的好处是编译器能帮你检查遗漏,而且在 when 表达式里处理时不用写一堆 debug 时才发现的魔法字符串。
3.2 未读数量的合并与去重策略
实际业务中,同一个角标的数据往往来自多个数据源,很容易出现重复计数。比如:列表页自己拉取过接口拿到未读数 5,同时推送服务又推了一条新消息,累计变成了 6,但后端接口的返回值可能还是 5 或者已经包含了这条,如果不做合并策略,角标的数字就会跳来跳去。
我的做法是在 Provider 内部维护一份channel -> source -> count的映射表:
private val sourceCountMap = mutableMapOf<Pair<BadgeChannel, String>, Int>() private fun recalculate(channel: BadgeChannel) { val total = sourceCountMap.entries .filter { it.key.first == channel } .sumOf { it.value } _badgeStateMap[channel] = BadgeState(count = total, timestamp = System.currentTimeMillis()) }当某个数据源上报数据时,用sourceId + channel作为 key,存入最新数值。这样多个数据源之间的数据是累加还是取最大,完全由 Provider 内部策略决定,外部无感知。数据源需要显式上报clearSource来清理自己的贡献值,防止数据残留。
3.3 页面状态双向同步
页面在onResume时主动拉取最新快照,在onPause时取消数据流监听,避免后台刷新导致无意义的界面渲染。这里我建议用repeatOnLifecycle配合Flow,如下:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) viewLifecycleOwner.lifecycleScope.launch { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { badgeProvider.observeBadge(BadgeChannel.SESSION) .collect { state -> binding.tabSessionBadge.render(state) } } } }在 Provider 内部,因为是共享热流,我采用MutableStateFlow做底层实现,配合StateFlow的特性,新订阅者会立刻收到当前结果,避免页面启动时出现一段空白状态。
3.4 BadgeActionProvider 的依赖注入与生命周期
这个 Provider 不是全局单例,但也不是每个页面都新建。我采用作用域容器的方式:在模块级容器里注册,模块下面的所有页面共享同一个实例;模块销毁时统一释放,避免内存泄漏。
class BadgeModuleContainer { val badgeProvider: BadgeActionProvider by lazy { RealBadgeActionProvider( scope = CoroutineScope(SupervisorJob() + Dispatchers.Default), actionHandler = BadgeActionRouter() ) } }模块内的 Fragment/Activity 通过容器获取同一个实例。模块退出(比如用户退出账号)时调用badgeProvider.reset()清空所有状态。这个 reset 方法一定要做,否则上一个账号的未读角标还会存在于内存中,换账号登录瞬间会出现数据闪跳。
4. 实操过程与核心环节落地
4.1 从零接入 BadgeActionProvider 的六个步骤
假设你现在要在一个已有项目里接入 BadgeActionProvider,不用重构业务代码,只要按这个顺序来即可:
- 定义 BadgeChannel 枚举。先把所有需要用角标的业务场景都列出来,宁可多列几个,也不要后来再加一个然后把 when 表达式改一遍。
- 实现 Provider 类。实现状态管理、事件上报、清空方法。初期不需要做高并发优化,先把主流程跑通。
- 在模块容器里注册 Provider 实例。如果项目里没有容器,先用 Application 级提供默认实现,后续再迁移到 DI 框架。
- 改造页面数据绑定。把原来直接读接口值赋到角标控件的代码,改成通过
observeBadge观察。 - 替换点击逻辑。原来
badgeView.setOnClickListener { startActivity(...) }的地方,改为badgeProvider.onBadgeClicked(channel),由路由层统一处理跳转。 - 数据源接入。把推送、轮询、本地事件等数据入口,统一调用
notifyBadgeChanged(channel, value)。
这六步走完,页面和角标数据源基本就分离开了。
4.2 角标数字上限与展示优化
真实业务中,未读数并不总是“越多越好”。我的经验是设两个阈值:数字上限和展示门槛。
- 数字上限:超过 99 直接显示 “99+”,避免用户盯着一个四位数看。
- 展示门槛:未读数 ≤ 0 时连红点都不展示;未读数在 1~3 之间时,可以只显示一个小红点而不显示具体数字,减少视觉噪音。
在 Provider 内部实现时,根据配置把BadgeState转换成 UI 层可以直接渲染的枚举或类。
data class BadgeState( val count: Int, val isDotOnly: Boolean, val displayText: String ) { companion object { fun fromCount(count: Int, dotThreshold: Int = 4, maxDisplay: Int = 99): BadgeState { return when { count <= 0 -> BadgeState(0, false, "") count <= dotThreshold -> BadgeState(count, true, "") else -> BadgeState(count, false, if (count > maxDisplay) "99+" else count.toString()) } } } }这里有一个很关键的产品细节:dotThreshold 以内的角标只显示红点,不显示数字。很多产品经理会漏掉这个需求,但你提前在 Provider 里留好这个策略,后面要改就是一个配置项的事,不用满项目搜索if (count > 0)之类的逻辑。
4.3 点击动作的拦截与业务跳转解耦
最让我头疼的历史代码是:一个按钮的点击事件里既清除了角标,又跳转了页面,还弹了一个半屏引导浮层。这几个动作强耦合在 View 层,后面想做个 A/B 测试都无从下手。引入 BadgeActionProvider 后,我把所有点击旁路事件交给动作路由处理:
class BadgeActionRouter : BadgeActionHandler { override fun handle(channel: BadgeChannel, extra: Any?) { when (channel) { BadgeChannel.SESSION -> { // 清角标 + 跳会话列表 badgeProvider.resetChannel(BadgeChannel.SESSION) navigator.jumpTo(SessionListPage) } BadgeChannel.SYSTEM_NOTICE -> { navigator.jumpTo(NoticeCenterPage) } BadgeChannel.FRIEND_REQUEST -> { navigator.jumpTo(FriendRequestPage) } } } }这样,每个页面只需要调用badgeProvider.onBadgeClicked(BadgeChannel.SESSION, null),至于跳哪里、要不要清角标、要不要打点埋点,全部由路由层统一处理。业务变了,页面代码纹丝不动,这是我最喜欢这个模块的一点。
4.4 与后端接口数据的协同
需要特别提醒的是,角标数据不能只依赖前端自己算,后端接口最好能提供一个“角标汇总接口”。比如首页/badge/summary一次返回所有 channel 的未读数,前端拿到后统一填充到 Provider 里。这样页面首次加载时可以一次性把所有 Tab 角标刷出来,比每个 Tab 页面各自请求要高效得多。
在实践里,我会在 Provider 里加一个synchronizeFromRemote(summary: Map<BadgeChannel, Int>)方法,专门接收这种汇总数据。它内部做两件事:先比对本地时间戳,如果远端数据比本地的旧则忽略;再重置对应 channel 的数据源,把远端值作为唯一事实来源。这个设计在弱网环境下尤其重要,避免把旧推送和最新接口值叠加在一起重复计数。
5. 常见问题与实测排查
5.1 角标显示不同步:多页面间状态不一致
这大概是最常见的线上问题。页面 A 清除角标后切回首页,首页的角标还挂着原来的数字。排查思路要分三步走:
- 确认 Provider 是否共享实例。如果每个页面都通过
new RealBadgeActionProvider()创建,那肯定不同步。检查容器注册的 lazy 逻辑。 - 确认数据流监听时机。页面在 STARTED 状态下监听,切后台再切回来时,如果
collect没有触发,看一下是否被取消了,或者使用了stateIn但没有whileSubscribed策略。 - 确认清空动作传到哪个层。角标清零应该在 Provider 层做,如果页面直接操作 UI 清掉红点但没有调用 Provider 更新,那么其他页面的观察者不会收到任何变化。
我自己踩过的坑是第二种:用SharedFlow做热流,但配置了extraBufferCapacity = 0和Dropping策略,页面在后台时数据被丢弃,恢复前台后永远看不到最新值。后来改成StateFlow,这个问题就消失了。
5.2 内存泄漏:观察者未释放
如果页面销毁后仍持有 Provider 里的数据流,哪怕 Provider 本身生命周期正确,也会因为 Flow 的收集协程没有被取消而泄漏。排查方法很朴素:在onDestroy或onCleared里打日志,或用 LeakCanary 跑一轮页面来回切换。
我的建议是:Fragment 页面严格使用 viewLifecycleOwner 而不是 fragment 实例。曾经有一次我图方便直接用了this作为 lifecycle owner,导致 Fragment 在 detach 之后还被 View 层的协程吊着,最终分析出来是viewLifecycleOwner和fragment lifecycleOwner混用的锅。
5.3 推送到了但角标没动
这个问题的根因十有八九是线程问题。推送广播回调跑在 Binder 线程或者后台线程,直接调MutableStateFlow.value = ...本身是线程安全的,但如果 Provider 内部的数据源映射表没有加锁或没有使用合适的数据结构,就会出现并发修改异常或状态丢失。
在 Provider 内部我统一用ConcurrentHashMap存储 source 映射,同时把计算逻辑放到单线程上下文中执行:
private val internalScope = CoroutineScope(SupervisorJob() + Dispatchers.Default.limitedParallelism(1)) fun notifyBadgeChanged(channel: BadgeChannel, value: Int) { internalScope.launch { // 更新 map、重新计算、写 StateFlow.value } }所有写操作串行执行,读操作由 StateFlow 天然保证一致性,这样既不阻塞 UI 线程,又避免了并发写导致的脏数据。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 首页角标更新但二级页没有 | Provider 实例不唯一 | 统一从容器获取实例,禁止直接 new |
| 数字与后端查询不一致 | 多来源计数叠加重复 | 引入 sourceId 映射表,按数据源归因 |
| 点击角标跳转后角标还在 | 页面直接改 UI 未调 Provider | 清零动作收敛到 Provider 或路由层 |
| 后台切回出现旧状态 | 冷流被丢弃或 observe 时机过晚 | 改用 StateFlow,在 STARTED 时 collect |
| 换账号后角标残留 | 没有调 reset() | 登出流程统一调用 Provider 的 reset |
6. 实际使用体会与进一步建议
做了这么多期组件设计,我最大的体会是:技术难点不在 Provider 本身,而在外围的边界划分。你拦得越清楚,业务侧就越轻松。BadgeActionProvider 维护的不只是“一个数字”,而是这个数字背后所有使数字发生变化的事件和来源。设计的时候多花些时间想清楚这个“通道”的语义,后面接入多个业务方时才能省力。
几个建议可以给到不同阶段的项目参考:
- 项目只有一两个页面用到角标,不建议过度设计,一个 ViewModel 加一个 StateFlow 就够用。
- 项目有五个以上页面且数据来源多样,可以把 Provider 放进公共库,并把 BadgeChannel、BadgeState、BadgeActionHandler 作为稳定接口冻结,内部实现迭代。
- 如果团队有组件化或模块化诉求,建议把 Provider 做成独立模块,对外不暴露
MutableStateFlow,只暴露只读方法,避免业务方可乘之机。
最后再分享一个调试技巧:我在所有 BadgeChannel 的同步方法入口加了一个 Debug 级别的日志,输出“channel=xxx source=yyy value=zzz”。线上出问题,只要拉日志就能看到哪一秒哪个数据源修正了哪个角标,不用再靠猜了。这个习惯帮我省了很多排查时间。
本文还有配套的精品资源,点击获取