从零设计BadgeActionProvider:用Flow管理徽章状态与动作分发
发布时间:2026/9/2 15:26:11 作者:尧图编辑部 阅读量:1,286

简介这是一套面向Android开发者的BadgeActionProvider自定义实现资源主要解决Toolbar菜单项小红点、角标等消息提醒展示问题。资源基于ActionProvider机制扩展适配Toolbar与Menu场景适合对UI定制有需求、希望深入了解ActionProvider原理的初中级开发者。压缩包共1390个文件大小约10.09MB以png图片资源、xml布局与配置文件、class编译产物、json数据等为主同时包含aidl接口、jar依赖、gradle构建脚本及可直接安装的apk整体覆盖源码、资源、构建与运行产物便于对照学习或集成使用。内容预览中可见MediaSessionCompat、PlaybackStateCompat等系统库文件说明项目中已包含完整依赖环境。适合需要参考完整工程结构、挖掘自定义Menu实现细节的读者已有317人学习下载。整体目录编排清晰源码与编译资源分层存放能帮助开发者快速定位核心ActionProvider逻辑节省从零搭建和排查的时间。1. 从加个红点到管理一串动作BadgeActionProvider的设计动机前阵子在做一款即时通讯类应用的消息中心模块时我遇到一个很典型的业务问题消息tab上的未读徽章既要展示总数又要区分红点模式和数字模式用户点击徽章本身要进入会话列表长按要弹出全部已读的操作面板如果某个会话被免打扰徽章还得变成灰色。一开始我写了一个BadgeManager工具类静态方法满天飞状态用全局变量硬撑后来业务方要加仅显示重要会话未读的筛选时这个工具类已经膨胀到一千多行改一处崩三处。这其实就是很多客户端团队都会经历的阶段徽章从单纯显示一个红点逐渐演变成承载多种状态、多种交互动作的业务实体。你需要的不是又一个广播式的工具类而是一个能把徽章状态和用户动作解耦的Provider层——这就是BadgeActionProvider出现的理由。我可以给它一个比较准确的定义BadgeActionProvider是一个负责提供徽章展示状态、分发徽章相关用户动作、并管理状态与动作之间映射关系的中间组件。它介于业务层和UI层之间让UI只需要关心当前这个Badge长什么样、点了之后去哪而让业务层只关心未读数据怎么算、已读动作怎么做。如果你所在的团队也面临下面任意一种情况这篇内容应该对你有用徽章状态来源多样本地数据库、推送、服务端下发、同一个徽章在不同页面有多种交互动作、急需要一个统一出口来控制徽章样式和点击行为。接下来我会用实际代码把这个Provider的设计思路和落地过程完整拆一遍。2. 接口与数据结构先把Badge是什么这件事想清楚2.1 BadgeModel状态不是一个Int就完事了很多人设计徽章模型时第一反应是搞一个Int未读数大于0就显示。但实际操作后你会发现业务对徽章的需求远不止数字这么简单。我在重构时先画了一张表把所有已经出现和产品已经提上日程的徽章形态列出来徽章形态典型场景需要的数据红点发现页新功能提醒只区分有/无数字消息中心未读数数量、最大值如99文本运营活动标记文案、背景色灰色数字免打扰会话未读数量、灰度标识动画徽章点赞心跳效果动画类型、触发事件依靠这张表我把BadgeModel设计成了一个不可变数据类data class BadgeModel( val badgeId: String, val count: Int, val maxCount: Int 99, val displayMode: BadgeDisplayMode, // DOT, COUNT, TEXT, GRAY_COUNT val customText: String? null, val backgroundColor: Int? null, val active: Boolean true, val extra: MapString, Any emptyMap() )这里有几个设计细节值得展开说说。第一badgeId是必须的它是多业务共用同一徽章时的唯一标识没有这个id后续做动作路由和状态合并几乎寸步难行。第二maxCount不要写死在UI层因为不同tab对封顶值的要求不一样——消息tab是99购物车tab是9写死在UI层会导致业务方各自偷偷传一个超范围数字进来。第三extra字段用来承载灰度、跳转参数等扩展数据避免每次加需求都要改这个数据类本身。2.2 动作接口把点了和怎么处理彻底分离BadgeActionProvider的核心方法只有两个角色参与Provider负责路由动作业务方负责注册具体动作。我在定义接口时刻意避开了在回调里直接写跳转逻辑的写法interface BadgeActionProvider { fun registerAction(badgeId: String, action: BadgeAction) fun unregisterAction(badgeId: String) fun getBadgeState(badgeId: String): BadgeModel fun dispatchAction(badgeId: String, actionType: BadgeActionType, view: View?) fun observeBadge(badgeId: String, observer: (BadgeModel) - Unit): FlowBadgeModel } interface BadgeAction { fun onAction(badgeId: String, actionType: BadgeActionType, source: ActionSource) } enum class BadgeActionType { CLICK, LONG_CLICK, DRAG, SWIPE }为什么我不直接在回调里传一个Intent或跳转对象因为同一个徽章在不同入口的动作经常不一样。举个例子我的-消息中心这个入口徽章点击是进入消息列表但首页-悬浮球上同一个消息徽章点击却是展开最近三条会话预览。动作必须由注册方动态决定而不是由Provider内部写死。dispatchAction方法接收一个actionType实际上是给Provider留了扩展口。比如未来要支持长按显示全部已读这个动作UI层只需要在长按回调里调用dispatchAction(message_badge, BadgeActionType.LONG_CLICK, view)即可不用关心具体逻辑是谁执行的。3. 状态流与生命周期Provider内部的管理机制3.1 用Flow做状态分发而不是回调地狱在Provider内部最核心的数据结构是一个MutableStateFlow的Mapprivate val badgeStates MutableStateFlowMapString, BadgeModel(emptyMap())每次更新徽章状态时Provider会执行一次不可变替换fun updateBadge(badgeId: String, newState: BadgeModel) { badgeStates.value badgeStates.value (badgeId to newState) }这样做的收益是UI层的observeBadge天然支持distinctUntilChanged去重同一个徽章连续两次收到相同状态的更新时UI不会白白重组。我实测下来的效果是在一个底部tab有7个徽章的场景下无脑刷新被去重掉超过60%这个优化在低端机上感知非常明显。3.2 注册表与绑定生命周期这里有一个我踩过坑的设计如果不做生命周期绑定Fragment在onDestroyView之后徽章还在更新轻则内存泄漏重则触发View已销毁但还持有lambda的野指针崩溃。我最终的方案是Provider暴露一个observeBadge方法返回Flow由UI层自己选择合适的收集作用域同时Provider内部做一个WeakHashMap的注册表缓存private val actionRegistry HashMapString, BadgeAction() fun registerAction(badgeId: String, action: BadgeAction) { actionRegistry[badgeId] action }注册的动作在页面销毁时必须主动调用unregisterAction。有的团队觉得反正Provider是单例注册了也跑不了但实际线上问题是一个页面销毁后推送又触发了一次徽章更新Provider尝试把更新分发到已经不存在的行为处理器上日志里全是匿名异常。我建议在dispatchAction时对这层做一次兜底fun dispatchAction(badgeId: String, actionType: BadgeActionType, view: View?) { val action actionRegistry[badgeId] ?: run { Log.w(BadgeActionProvider, No action registered for badge: $badgeId) return } action.onAction(badgeId, actionType, ActionSource.fromView(view)) }4. 多业务方的状态合并与冲突处理4.1 同一个徽章两个业务都要改消息tab的徽章是最典型的冲突场景会话列表业务统计未读数通讯录业务也统计新的联系人申请数它们都往同一个message_badge上写状态。如果直接覆盖就会发生我看完会话列表通讯录的未读也被清了这种连锁bug。我引入了一个BadgeAggregator接口来解决合并问题interface BadgeAggregator { fun aggregate(oldState: BadgeModel, newState: BadgeModel): BadgeModel }Provider持有每个badgeId对应的聚合策略默认使用TotalCountAggregator即新状态与旧状态的count相加maxCount取较大值displayMode如果有一个是DOT则显示DOT。如果你的业务有自己的合并规则比如取最大值、取最小值、哪个优先级高取哪个各自实现BadgeAggregator替换即可。这里需要特别提醒的是已读清零的时序问题。用户点击会话列表后会话未读从5变成0此时通讯录的3条新申请也要能正常显示。如果聚合逻辑是count直接覆盖就会出问题如果是加法也会出问题——因为清零动作不能被当作0去和3相加。我的做法是额外定义了一个BadgeUpdateTypeCLEAR和INCREASE。清零动作走CLEAR分支直接把该业务对应的子计数清掉但不动其他子计数增加动作走INCREASE分支。这也是为什么我强调extra字段里要能存业务方标识就是为了精确路由到该业务方的子计数。4.2 优先级DOT模式与数字模式的博弈另一个冲突场景是运营要红点产品要数字。比如首页的我的入口运营希望新功能上线时用户能看到红点而消息模块希望用户能看到3条未读。两个需求在同一个徽章上打架真正有用的做法是给BadgeModel加一个priority字段data class BadgeModel( ... val priority: Int 0 )聚合时如果新状态的priority大于旧状态则采用新状态的displayMode否则保留旧状态。这样运营的红点可以被赋予priority 10消息数字赋予priority 5合并后显示红点一旦用户看过红点运营手动调用updateBadge把状态降级为active false数字重新接管展示。这套机制在线上运行了一个季度没有出现过一次徽章应该显示但消失了的客诉。5. UI层集成从XML到Compose的适配5.1 传统View体系下的BadgeView如果你的项目还是传统View体系BadgeActionProvider可以配合一个自定义BadgeView使用。BadgeView持有Provider的引用在onAttachedToWindow时注册观察在onDetachedFromWindow时取消注册class BadgeView JvmOverloads constructor( context: Context, attrs: AttributeSet? null ) : FrameLayout(context, attrs) { private lateinit var provider: BadgeActionProvider private var badgeId: String fun bind(provider: BadgeActionProvider, badgeId: String) { this.provider provider this.badgeId badgeId lifecycleScope.launch { provider.observeBadge(badgeId).collect { badge - renderBadge(badge) } } setOnClickListener { provider.dispatchAction(badgeId, BadgeActionType.CLICK, this) } setOnLongClickListener { provider.dispatchAction(badgeId, BadgeActionType.LONG_CLICK, this) true } } private fun renderBadge(badge: BadgeModel) { visibility if (!badge.active) View.GONE else View.VISIBLE // 根据 displayMode 更新文本和背景 } }这里有个坑是lifecycleScope的使用。如果BadgeView所在的页面是ActivitylifecycleScope没问题但如果是RecyclerView里的itemlifecycleScope可能会hold住整个页面生命周期。我后来改成用View自身的addOnAttachStateChangeListener来管理收集协程确保item被回收时观察随之取消。5.2 Compose版本把Flow转为State项目里另一个模块已经切到Compose我按同样的思路写了一个rememberBadgeState方法Composable fun rememberBadgeState(provider: BadgeActionProvider, badgeId: String): StateBadgeModel? { val badgeState remember { mutableStateOf(provider.getBadgeState(badgeId)) } LaunchedEffect(badgeId) { provider.observeBadge(badgeId) .collect { badge - badgeState.value badge } } return badgeState }Compose版本最大的优势是UI层通过collectAsState自动重组不需要手动处理onAttachedToWindow和onDetachedFromWindow。但要注意的是LaunchedEffect在重组时会自动取消并重新启动如果badgeId不变这个协程会一直存活到Composable退出Composition生命周期管理反而更简洁。5.3 统一入口一个Button同时挂多个Badge有个容易忽略的实际场景一个按钮可能要同时展示购物车未读数和优惠券可用数两个徽章但点击只进购物车页面。我实际采取的做法是外层容器作为dispatchAction的入口负责处理点击事件内部放两个BadgeView各自只做展示、不响应点击。这样动作和展示彻底分离一个BadgeActionProvider就能支撑多个徽章的外观组合。这个方案上线后后来产品又说购物车徽章要支持左滑删除我在BadgeActionType里加了SWIPE在BadgeView的touch事件里做一下判断即可完全不用改Provider核心逻辑。6. 实测中的异常场景与排查链路6.1 徽章状态幽灵恢复从现象到根因有一次测试反馈用户清空未读后切到后台再切回来红点又出现了。现象很诡异第一次排查时我怀疑是聚合器的问题但查看BadgeAggregator日志发现聚合逻辑完全正常。后来定位到真正的根因有两层。第一层推送服务在后台每五分钟会轮询一次未读数服务器返回的是未读总数10而不是增量3。客户端如果直接把10当作新状态写入当然会把之前已读清零的0覆盖成10。第二层Provider内部没有对后台轮询和用户主动已读做时序保护——已读清零操作是一个异步动作它update完成之后紧接着到达的轮询更新就把它覆盖了。最终的修复方案是给updateBadge增加一个source参数标识数据来源是PUSH还是LOCAL_ACTION。聚合器对LOCAL_ACTION来源的更新赋予更高优先级并且在本地动作清空计数后10秒内忽略PUSH来源的同badgeId更新。这个时间窗口之所以设成10秒是因为已读请求到服务端返回确认网络正常情况下不会超过这个值如果超过说明已读操作失败了服务端的总数更新反而是正确数据此时应当放行。6.2 内存泄漏排查注册表里的僵尸Action另一个我在提测阶段才发现的坑是一个带徽章的Activity退出后它的BadgeAction被注册表持有导致Activity泄漏。LeakCanary直接弹了红色通知。解决方法是双保险。第一在Provider暴露一个clearAllActionsForOwner(owner: Any)方法BadgeView在onDetachedFromWindow时调用它通过BadgeAction内部持有的owner字段反向查表清除。第二注册表本身改成WeakHashMap避免对象释放后注册表仍强引用。这里要提醒一下WeakHashMap并不像看起来那么美好——如果你的BadgeAction实现类是一个匿名内部类它会隐式持有外层Activity的引用这种情况下WeakHashMap也救不了你。所以我在代码规范里明确要求BadgeAction实现必须是静态内部类或者通过LifecycleBoundObserver的方式显式持有生命周期。否则线上内存泄漏只是时间问题。6.3 生命周期竞态onStop之后还能弹toast吗在集成到具体页面时还遇到过一个问题用户在消息列表页点击了全部已读此时页面马上finish但Provider的log上却显示动作执行完了并且弹了一个toast。虽然不崩溃但体验很怪——toast从下一个页面底部弹出来用户以为是新页面自己的提示。我的解决办法是给ActionSource增加一个可见性检查class ActionSource private constructor( val view: View?, val isViewAttached: Boolean ) { companion object { fun fromView(view: View?): ActionSource { return ActionSource( view view, isViewAttached view ! null view.isAttachedToWindow ) } } }BadgeAction在onAction执行前会检查isViewAttached如果为false则只更新内部状态、不执行任何UI交互。这条规则在Provider的dispatchAction里统一判断避免每个业务方都写一遍。7. Provider的无侵入扩展我后续打算怎么做当前版本稳定跑了两个多月整体收益是新增一个徽章场景从原来的写工具类、改样式、处理点击、测试四步简化为定义BadgeModel、注册BadgeAction、在UI层bind三步。团队的开发效率明显提升尤其两个端并行开发时服务端只需要约定好badgeId和计数规则客户端各自按Provider规范接入即可。接下来我打算做两件事。第一把Provider的聚合能力抽成一个可配置的DSL让业务方不用写BadgeAggregator接口实现类而是直接写3秒内本地优先级更高超过3秒服务端覆盖。第二把徽章的动画动作也纳入Provider分发比如点击徽章时的缩放动画、数字跳动动画它们本质上也属于动作的一种适合和UI动作统一管理。如果你在设计类似的Badge体系我建议从一开始就把状态和动作分开建模不要试图用一个回调容器包打天下。状态流用不可变数据类加Flow驱动动作分发用注册表加类型枚举两件事各管各的演进空间会大很多。也可以直接去GitHub搜项目名BadgeActionProvider看看有没有其他人更新了更好的实现思路。踩过这几个坑之后你会发现一个善解人意的Provider层比一百个能跑但不优雅的工具类都值钱。本文还有配套的精品资源点击获取