简介这份PDF面向有一定Android基础的中初级开发者讲解如何在应用图标上添加数字角标直观提示未读消息数量。内容先从角标概念与应用场景切入说明Android原生并不直接支持该功能随后详细拆解实现原理在清单文件声明三星等设备所需的读写权限通过发送广播携带包名、Activity类名和计数来更新角标并给出完整的BadgeUtils工具类代码覆盖设置角标和清除角标逻辑。针对国内不同厂商Launcher的兼容性问题文档还推荐了ShortcutBadger开源库帮助开发者屏蔽差异快速适配主流机型。资源为单个PDF文件整体压缩包仅82KB内容紧凑适合快速阅读与代码套用。该资料目前已有2128人学习是一份解决实际需求、可操作性较强的参考文档。1. Android加角标不复杂但要先分清你要的是哪一种“为应用添加数字角标”这个需求乍看是个很小的事网上教程一搜一大堆但只要你真拿一台国产手机试一遍就会发现一个扎心的事实应用内角标一分钟搞定桌面角标却可能折腾一整天还不生效。这个落差不是代码问题而是Android的角标分成了两条完全不同的技术路线一条是自己在应用界面里画的“红点数字”另一条是桌面图标上由厂商Launcher渲染的“系统角标”。前者你用TextView就能做后者得看各家ROM的脸色。这篇文会把两条路都拆开讲重点落在“简单实现”四个字上——哪些地方能简单哪些地方从一开始就躲不掉复杂度适合刚接到角标需求、又不想在真机上反复翻车的开发者。2. 先弄清两种角标桌面角标和应用内角标选错方向必然翻车很多教程把角标讲成一个API的事但实际上Android官方从来没有统一过桌面图标角标的标准。Google原生桌面从Android 8.0引入的Notification Dot只是一个小圆点不带数字而国内主流厂商的桌面则各自实现了带数字的角标调用方式、权限限制、是否需要在桌面上“显示通知数”开关全都不同。所以你第一步不是写代码而是确认产品要的角标出现在哪里是App内部的Tab栏、消息列表上的数字还是手机桌面上图标右上角的数字。2.1 桌面角标依赖厂商Launcher你控制不了全部参数桌面角标的本质是应用通过某种方式告诉Launcher“我的未读数是N。”Launcher收到后在自己的进程里绘制数字叠加层。这就意味着Android系统本身不参与这件事你在代码里能做的只是“请求”最终显示成什么样由厂商的桌面决定。比如有的桌面支持显示99有的只显示到99有的数字上限是6个字符超出就变成省略号有的需要用户在设置里主动打开“应用角标”开关否则权限默认关闭。更麻烦的是部分厂商要求应用必须在自己的应用市场上架后才能调用角标接口开发阶段完全没法验证这是做这个需求时最让人头疼的一个信息差。一些常见做法是按厂商SDK接入但每个厂商都要单独下载一份jar或aar还要在AndroidManifest里声明对应权限。我一般会先拉一张表把“哪些厂商必须接SDK、哪些只需要发广播、哪些要权限”再根据目标用户的机型分布决定先适配哪一家。不要把精力均匀摊给所有厂商那是给自己挖坑。2.2 应用内角标才是“简单实现”的主场如果产品说“角标只是App内部的红点提示”那恭喜你下面这些厂商适配问题全都不用管。应用内角标就是在一个控件通常是FrameLayout或Drawable上绘制一个带数字的圆位置在图标或文本的右上角由你自己的代码完全控制。颜色、大小、动画、最大数字、消失时机全都可以按照UI稿来做不依赖任何系统服务。应用内角标不建议直接用两个View硬拼。常见做法是封装一个独立的BadgeView内部封装绘制逻辑和可见性切换。如果只是想快速出效果直接在TextView里画也行但后续加边框、加动画、加“99”截断逻辑时就会越改越乱。我做这个需求时默认会选自定义View方案理由很简单数字角标是一个会持续加需求的组件比如“红点不加数字”“超过99显示99”“从数字到红点切换”这些如果写成散落的Drawable代码改一处就要动多文件不如一开始就收拢在一个类里。核心选型结论需求只存在应用内部就用自绘方案需求明确指向桌面图标那就去评估厂商适配成本评估完再动手。很多翻车场景都是这两件事没分清导致的。3. 应用内数字角标的实现自绘BadgeView与三段式参数应用内数字角标的落地方式有很多种比如开源库、第三方TabLayout组件自带角标、或者自己画。这里不做“上述方案框架推荐”只讲一个我实际常用的自绘路径继承TextView重写onDraw或者直接在FrameLayout上叠加一个View。前一种适合嵌在已有图标之上后一种适合把角标做成独立View挂载到任意父布局。3.1 最小可运行的BadgeView实现代码常见做法是让BadgeView继承TextView利用TextView自带的文本绘制能力配合一个画圆的Paint。代码如下可直接贴在项目里跑通public class BadgeView extends TextView { private Paint badgePaint; private String badgeText ; private int maxNum 99; private int bgColor 0xFFE53935; private Context context; public BadgeView(Context context, AttributeSet attrs) { super(context, attrs); this.context context; badgePaint new Paint(Paint.ANTI_ALIAS_FLAG); badgePaint.setColor(bgColor); badgePaint.setStyle(Paint.Style.FILL); setGravity(Gravity.CENTER); setTextColor(Color.WHITE); setTextSize(10 * context.getResources().getDisplayMetrics().density / getResources().getDisplayMetrics().scaledDensity); setIncludeFontPadding(false); } public void setBadgeCount(int count) { if (count 0) { badgeText ; setVisibility(View.GONE); } else if (count maxNum) { badgeText maxNum ; setVisibility(View.VISIBLE); } else { badgeText String.valueOf(count); setVisibility(View.VISIBLE); } setText(badgeText); } Override protected void onDraw(Canvas canvas) { if (getVisibility() ! View.VISIBLE || badgeText.isEmpty()) { super.onDraw(canvas); return; } int width getWidth(); int height getHeight(); int radius Math.min(width, height) / 2; canvas.drawCircle(width / 2f, height / 2f, radius, badgePaint); super.onDraw(canvas); } }逻辑说明核心只有两块setBadgeCount负责数字的输入、截断和显隐切换onDraw负责在文字下方画一个圆形背景。TextView本身的文字绘制能力承担了数字渲染这样不需要自己处理文字的垂直居中。参数说明maxNum是截断阈值超过它显示成“99”而不是“100”这是产品侧最常见的需求提前内置比每次在外面format要好处理得多。setIncludeFontPadding(false)非常关键TextView默认的top/bottom padding会让数字在小尺寸下看起来偏离圆心加上后文字基线计算会收紧数字视觉上更居中。batchPaint的颜色、文字大小要按UI稿对应调整如果设计稿是红色底白字直接改bgColor和setTextColor即可。3.2 三个必调参数位置偏移、最大数字和可见性切换把BadgeView集成进页面时最常调的是三个参数这里单独说明。第一个是位置。最常见的做法是把BadgeView放到一个FrameLayout中通过layout_gravity控制它出现在右上角还是右上角偏左一点。我习惯用layout_gravity来定位而不是手动设Margin原因是这样dp换算只做一次代码里不需要维护Screen密度。但要加一个横向偏移否则数字位数多时比如“99”三个字符会超出圆形背景范围此时通过rightMargin或translationX修正而不是改圆半径。第二个是最大数字。上一节的maxNum参数建议统一放在资源文件或常量类里不要在每个页面写死。因为产品后续可能会从99改成999到时候全局替换才是后悔药。另外“历史未读数上限”这类逻辑服务端有可能直接返回9999客户端不截断的话圆里放四个字符会显得很突兀。第三个是可见性切换。很多开发者直接把setVisibility写在收到消息的回调里但忘了处理列表滚动、页面关闭等场景。常见做法是定义统一接口UiBadgeManageable里面有refreshBadge(int count)和clearBadge()两个方法页面在onResume时调用一次刷新在消息已读时调用clear。这样不会出现一个角标显示残留到另一个页面的情况。public interface UiBadgeManageable { void refreshBadge(int count); void clearBadge(); }逻辑说明这个接口的价值在于把“未读数从哪来”和“角标怎么显示”解耦。页面只调refreshBadge网络层和本地存储层不需要知道UI细节后续接推送、接消息中心都会省事。参数说明refreshBadge里的count应当传入“未读总数”而不是“增量”。比如新来了1条要先读本地总未读数再传入否则页面刷新时数字会越加越大。这一条看着简单实际是应用内角标最常见的计数错误来源。3.3 配合RecyclerView的角标刷新避免重复计数角标不止出现在Tab栏上也经常出现在列表项里比如“对话列表里未读会话红点”。这个场景的坑在于列表复用会导致角标状态串位。RecyclerView滚动时ViewHolder被复用上一次设置的badge值会带到下一个条目上。常见做法是在Adapter的onBindViewHolder里统一强制刷新角标而不是只对“有未读”的那一条做设置。因为复用机制下上一次position对应的badge不会自动清掉必须显式地把旧状态清空。Override public void onBindViewHolder(ViewHolder holder, int position) { ChatItem item dataList.get(position); holder.badgeView.setBadgeCount(item.getUnreadCount()); // 这里不需要add、不需要invalidatesetBadgeCount内部已经处理了显隐 }逻辑说明setBadgeCount内部做了count早于等于0时隐藏角标的逻辑所以在onBindViewHolder里无条件调用永远是正确的不会出现复用后角标残留。参数说明item.getUnreadCount()应该来自数据实体的字段。如果这个字段没被持久化那列表刷新时比如下拉刷新之后计数会丢。我一般会把未读数存到本地数据库的会话表里而不是放在内存集合中否则App一重启桌面角标还在应用内角标却清零了两边对不上产品体验反而更差。4. 桌面角标的厂商适配能生效的路径、权限和那些“玄学”限制桌面角标不像应用内角标那样自给自足你发一个广播或调一个AIDL接口系统不一定理会。它生效的前提是设置里打开了“应用角标”开关、厂商的桌面支持你的Action字符串、你的应用满足厂商在权限或上架方面的要求。这三点哪一环断了代码写得再对也没有用。所以写代码前先查机型分布和产品预期决定适配深度。4.1 各家ROM的角标接口差异与权限门槛我整理了一下主流的适配路径不同思路的真实工程经验用表对比重点看接口类型和权限要求基本能确定一个App最少需要多少工作量ROM类型常见接口方式是否要额外权限说明某米系发送广播Intent需声明“桌面角标”相关权限广播Action固定需要在manifest里声明对应权限部分机型还要打开“通知显示图标”开关才能在桌面上看到某为系申请权限调用厂商API需要权限且可能只对应用市上架的包生效非上架包在部分系统版本上调用无效开发阶段较常见某绿厂系发送广播设置默认角标数需要权限角标数字上限默认为零很多机型需要先用一个“默认角标显示位数”的Setter把最大值设为99否则数字不显示某蓝厂系通过反射调system service权限限制严格需要targetSdk兼容反射调用时注意SystemService异常需要捕获NoSuchMethodError三星系发送广播原生支持较完整不需要额外权限但需要设置“角标风格相关”开关数字角标在三星上表现较稳定是少数几乎开箱即用的类型原生AndroidNotification Dot不支持数字不需要权限原生系统只显示小圆点数字角标需要自行实现替代方案这张表不是官方支持矩阵是经验值作用是让你心里有个底同样一段代码在不同机型上表现差异可能很大。某个上了架的应用在A系手机上正常在B系手机上则完全没有角标这种情况在真机测试时经常碰到不是你的代码写错而是该系列ROM只给上架应用开放了接口。4.2 一套能跑通主流机的调用模板这里提供一套我实际在项目里用过的调用代码它能覆盖主流几类ROM但不保证覆盖所有历史版本。用的时候逐行看注释别一行不落全塞进去就完事。public class BadgeUtil { public static void setDesktopBadge(Context context, int count) { if (context null || count 0) return; String packageName context.getPackageName(); Intent intent new Intent(); // 某米系发送桌面角标广播 intent.setAction(android.intent.action.APPLICATION_MESSAGE_UPDATE); intent.putExtra(android.intent.extra.update_application_component_name, packageName / getLauncherClassName(context)); intent.putExtra(android.intent.extra.update_application_message_text, count 0 ? : String.valueOf(count)); sendSafeBroadcast(context, intent); // 绿厂系触发角标然后设置支持的最大位数 try { Intent oppoIntent new Intent(com.android.launcher.action.SET_APP_LOCK_SCREEN_RED_NUM); oppoIntent.setComponent(new ComponentName(com.android.launcher, com.android.launcher.Launcher)); oppoIntent.putExtra(appPkg, packageName); oppoIntent.putExtra(appNum, count); sendSafeBroadcast(context, oppoIntent); } catch (Exception ignored) { } } private static void sendSafeBroadcast(Context context, Intent intent) { try { context.sendBroadcast(intent); } catch (SecurityException e) { // 某些ROM没有声明权限直接发广播会崩 Log.w(BadgeUtil, desktop badge broadcast rejected, e); } } private static String getLauncherClassName(Context context) { Intent launch new Intent(Intent.ACTION_MAIN); launch.addCategory(Intent.CATEGORY_LAUNCHER); launch.setPackage(context.getPackageName()); ResolveInfo info context.getPackageManager().resolveActivity(launch, PackageManager.MATCH_DEFAULT_ONLY); if (info ! null) return info.activityInfo.name; return ; } }逻辑说明这段代码按“发广播”的思路去适配。每个厂商Action让它做自己定义的事找不到入口时走getLauncherClassName不传这个类名时某米系收不到广播。sendSafeBroadcast包了一层try catch目的是在个别系统版本上避免因为权限问题直接崩溃。参数说明count传0时清空角标。但“清空”在很多ROM上不是真的删掉角标只是把数字变成隐身所以建议清空后额外调一次“显示空字符串”或“角标样式为透明”的方式补一刀。另外要注意这里用了int如果未读数超过int范围约21亿时不需要考虑但超过厂商上限如99时仍建议传99不要在厂商侧触发截断静默失败如图标上显示一个超长的数字串会很丑。4.3 厂商角标数量限制从“显示99”到“显示省略号”各家ROM对角标数字的上限差别很大有的上限是99有的是6个字符有的超出上限后静默显示为“99”而不是“99”。这行字看着不起眼真机上表现却很明显。常见做法是统一在进入桌面角标接口前做一次截断客户端主动把99以上的值截成“99”。为什么这样处理因为厂商侧显示“99”的样式是否支持你控制不了而客户端自己截断至少确保视觉表现一致不会出现“这个手机上是100那个手机上是99”的局面。if (count 99) { count 99; }参数说明这行代码不是多余。你传100给A厂商它显示100传100给B厂商它显示99同一套业务在两端数据不一致产品验收时会连续提bug。主动截断后全平台行为一致只是固定上限过后不再增长这个逻辑问题去跟产品沟通一次即可。但这里还有一个隐藏的坑清空时传0有时厂商会把它当成“不改变当前状态”所以如果之前显示过99并截断后来未读数降为0时厂商角标可能在界面上仍然是红点。部分ROM提供了另一个接口用于“清除角标”见上一节提到的“额外调一次透明角标样式”。在工程上清理动作不能省最好单独封装一个clearDesktopBadge方法在logout或已读所有时调用。5. 角标避坑真机上的5个血泪经验角标的坑和普通UI开发不一样它多数情况下“开发机上看不出来换台真机就原形毕露”。这里收集了我自己遇到过的典型问题每条都按现象、原因、解决三个步骤写覆盖从桌面角标到应用内角标最常见的问题。5.1 清除角标后残留多半是Intent不一致现象一次设置角标数3成功清空角标传0后桌面角标仍显示“3”或只消失一角重启手机才消失。原因桌面角标通过Action、packageName和className定位应用。你在设置和清除时用了两个不同形式的packageName或className比如设置时用applicationIdactivity全名清除时没带classNameLauncher匹配不到目标广播被丢弃。解决把“设置”和“清除”放在同一个Intent构建逻辑里className固定从getLauncherClassName获取不要手动拼接。清除时不要把count传int以外的类型统一走setBadgeCount(count)的内部清空逻辑避免产生“清空但显示占位背景”的状态。5.2 桌面角标必须串行调用“写太快”会被丢弃现象连续收到两个推送1秒内调两次setDesktopBadge第二次调用没生效角标停留在第一个值上。原因部分ROM在Launcher进程内对角标的更新做了同步锁或去重处理在极短时间内收到多条广播后续广播没有被及时处理。解决角标更新不要“无脑每次都发”改成“合并通知延迟刷新”模式。常见做法是维护一个pendingCount在收到新推送时只更新内存值通过Handler延时300ms再调setDesktopBadge。例如在Handler的run里取出pendingCount并发送保证连续推送时执行一次最终的桌面角标跳变。private Handler badgeHandler new Handler(Looper.getMainLooper()); private int pendingBadgeCount 0; private Runnable flushBadgeRunnable () - { BadgeUtil.setDesktopBadge(context, pendingBadgeCount); }; public void notifyBadgeChanged(int count) { pendingBadgeCount count; badgeHandler.removeCallbacks(flushBadgeRunnable); badgeHandler.postDelayed(flushBadgeRunnable, 300); }逻辑说明核心是removeCallbacks postDelayed多个更新在同一个时间窗口内被合并成一次。逻辑说明这能显著减少“数字跳变次数比推送次数少”这个问题同时减轻Launcher负担。参数说明300ms是经验值不能太长否则用户会看到角标数字先跳到某个值再跳到另一个值容易觉得动画不流畅也不能太短太短起不到合并作用。5.3 应用内角标在TextView上显示成省略号因为没预留空间现象角标数字到三位数时文字被截断成“1…”不是“99”圆形背景里数字显示不完整。原因应用内BadgeView默认宽度按wrap_content计算但TextView容纳不下“100”这类较长文本时系统在onDraw之前就把文字截断了。解决不要用wrap_content作为角标宽度而是用固定直径或动态计算最大文本宽度后设置。一般做法是定义最小宽度为24dp当数字达到三位数时宽度取textBounds两侧padding。Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int diameter dp2px(24); int specSize diameter getPaddingLeft() getPaddingRight(); setMeasuredDimension(resolveSize(specSize, widthMeasureSpec), resolveSize(specSize, heightMeasureSpec)); }逻辑说明重写onMeasure固定最小直径避免TextView按字符宽度自动扩展成椭圆形保证圆形背景不随数字长度变形。resolveSize只在该View有约束时才拉伸否则固定为预设直径。参数说明dp2px(24)里的24是在图标右上角看起来舒适的尺寸具体值以UI稿为准。窄角标圆内出现三到四个字符时需要调大到26或28dp同时setTextSize用sp而不是dp避免系统字体缩放时数字挤出圆形。5.4 数字角标超过厂商上限静默失败不报错现象角标设置为108某款机型上桌面角标显示“108”另一款显示“9”还有一款直接不显示。原因厂商在上限、字符长度和“是否支持三位数”上都没有统一标准超出能力范围的输入被上层丢弃代码无异常打点极难定位。解决客户端主动截断并固定加白名单见第4.3节。还要做一步防御调用后立刻读一次该ROM角标开关的状态多数机型的“显示通知数”开关可以通过Settings.Secure读到如果关闭在桌面上显示角标但实际不显示。结合开关状态判断是“代码没生效”还是“用户在设置里关掉了”。public static boolean isBadgeEnabled(Context context) { // 部分ROM的角标开关名不一定通用 return Settings.Secure.getInt(context.getContentResolver(), badge_show_count, 1) 1; }参数说明这行代码不保证在所有ROM上都有这个字段所以只作为统计/排查手段不作为业务依赖。主要目的是在测试时快速缩小范围开关是开的但角标不显示就要怀疑接口调用开关是关的就要引导用户去设置页打开而不是不断改代码。5.5 角标闪烁一下才出现是可见性时机问题现象应用内角标在启动Activity的瞬间能正常显示但页面加载完成几毫秒后角标闪一下消失。原因在onCreate里设了setBadgeCount后来某个异步回调回来时调了Visibility.GONE或没有刷新数据导致角标被隐藏另一种情况是RecyclerView在第一次布局时item中的角标宽度未测量完成onDraw里画的圆在layout之后才绘制。解决把角标的刷新时机放在onWindowFocusChanged或postDelay的runnable里等视图树稳定后再调用尤其不要把“设置可见”和“设置数据”分两步调统一走setBadgeCount一个入口。Override public void onWindowFocusChanged(boolean hasFocus) { super.onWindowFocusChanged(hasFocus); if (hasFocus) { badgeView.post(() - badgeView.setBadgeCount(count)); } }逻辑说明post一个runnable到主线程队列确保在布局完成后执行避免“宽度未确定状态下画出错位圆”。参数说明count来自内存缓存避免从网络重新拉防止“有角标但用户已经读过刷新后又出现”的浪费请求。大多数角标闪烁问题都是时序问题这个方案能覆盖到九成以上案例。6. 用桌面断连法快速验证角标是否真的生效桌面角标是否生效在开发机上经常看走眼。比如“设置了角标数3桌面上确实有3”就以为大功告成可代码里什么都没调桌面上也有一个红点——那是系统把Notification转成了桌面角标根本不是你的代码生效。要在测试阶段准确判断自己的调用是否生效有一个简单的验证方法我叫它“断连法”不需要额外工具纯靠手机操作。操作步骤先把手机断开数据网络和Wi-Fi接着在设置里把应用角标开关关掉再打开然后通过推送或本地模拟未读数量设置一个角标数比如3最后锁屏再解锁观察桌面图标。如果角标数字保留为3说明是应用“设置”行为推送给了Launcher且被持久化如果角标消失或变成1说明刚才的3是来自Notification你的调用并没有真正让Launcher记住状态。再进阶一步验证“清空是否彻底”断网状态下把已读所有消息在代码里调clearDesktopBadge锁屏再解锁。如果角标消失说明清空路径也是稳定的如果数字还在说明Launcher有缓存或你的className拼接有差异。这个方法的价值在于隔离了SystemUI、通知中心和网络推送的影响让你直接看到桌面角标接口本身的表现适合在项目发布前统一做一轮验证。验证时最好搭配一台“厂商系统版本较旧”的备用机。新版系统往往修复了角标的一些时序问题旧系统才是线上用户最多的区间。验证时不用把每个厂商都测一遍优先覆盖用户量最大的前三个机型即可。如果测试资源实在有限至少测一个“大数字如99”和一个“清空为0”的场景这两类最容易踩到5.1和5.4的坑。我对角标这类需求的态度是别把它当成一个单纯的UI小功能。应用内角标是纯技术活桌面角标更像一个渠道对接的外部接口。每次接到这个需求我都会先把“显示在桌面还是应用内”确认清楚然后在真机矩阵上跑一遍断连法再交付。即使改了三轮也要把已知失效的视频或现象记录留档方便下个开发者在接手时少走重复路。希望这篇落到真实场景的笔记能帮你在角标需求上少折腾几个晚上一次交付就让测试点头。本文还有配套的精品资源点击获取