Android推送集成封装指南:初始化、厂商通道与通知点击全链路

发布时间:2026/10/31 5:19:59
Android推送集成封装指南:初始化、厂商通道与通知点击全链路

做 Android 推送集成,最令人头疼的环节常常是:反复接入同一家服务商,再经历一遍相同的踩坑过程。三四个 App 下来,初始化时机、进程判断、别名绑定、厂商通道分包、通知点击拉起、8.0 渠道适配、12 的 PendingIntent 限制、13 的通知权限,几乎每次都会重新经历一遍,每次都会漏掉一两个细节,然后等测试同学在真机上把“收不到消息”的 bug 列出来才补。这篇分享博主本人做过好几轮推送接入后,整理出的一套可复用封装思路和完整落地流程,重点不在官方文档已经写清楚的内容,而在封装层的边界怎么划、哪些差异必须在封装里处理掉、出问题时按什么顺序排查。无论你是第一次接入推送,还是正在为第二个 App 复用旧代码,后面这套结构都能直接使用。

1. 推送集成为什么要先做一层封装

1.1 直接在各业务模块接入 SDK 的代价

绝大多数项目的推送接入形态,是直接在 Application 里写几行初始化,然后新建一个继承自 SDK Service 的类,把回调里的日志打一打,通知点击后跳转到首页,就这样了。第一个 App 这样没问题,因为业务简单、需求单一、只接一家。但当产品开始要求“消息中心要能拿到所有历史推送”“点击通知要跳转到具体详情页”“要按用户维度做定向推送”“要区分前台和后台的展示策略”时,这些散落在各处的回调就会变成阻碍。

具体代价集中在三个地方。第一是回调入口分散:消息到达、通知展示、通知点击、透传数据这几类事件分别在不同的 Service 方法里,业务方要用就得自己去 Service 里加代码,Service 一旦变复杂,谁都不敢改动。第二是通道差异外溢:不同厂商的系统通道在消息形态、透传能力、点击行为上并不一致,这些差异如果不在中间层统一处理,会扩散到每一个业务调用点。第三是生命周期不可控:推送 SDK 的初始化、设备标识获取、别名绑定都是异步且可能失败的,业务代码如果直接依赖这些异步结果,很容易出现“用户登录成功了但别名还没绑上,定向消息就推不到”的情况。

我在第二个项目里尝试过不封装,把 SDK 调用直接铺到业务里,结果是一次厂商通道升级,涉及了十几个文件的改动。那次之后我就确定,这层封装不值得省略。

1.2 封装层该覆盖哪些范围,又不该覆盖哪些范围

封装最容易走偏的方向是用它做“万能推送框架”。比如有人会想把这层做成支持任意服务商切换、支持自建长连接、支持 WebSocket 降级,结果越做越重,最后没人能维护。我的判断标准很简单:只封装「易变的」和「重复的」,不封装「稳定的」和「一次性的」。

按这个标准,必须进入封装层的内容包括:

类别是否进封装层理由
初始化时机与合规顺序进每个 App 都要处理,且出问题影响审核
设备标识、别名的获取与重试进异步、可能失败、业务强依赖
消息模型转换进各通道字段不同,业务不该感知
点击路由与参数解析进涉及 Activity 启动、参数去重,重复度极高
通知渠道与权限申请进各系统版本差异大
具体业务的消息处理逻辑不进属于业务方
服务商 OpenAPI 调用不进走服务端,客户端不碰
头像、角标等 UI 定制部分进提供默认实现,允许业务覆盖

这个边界划清楚之后,封装层的代码量其实很小,一个完整的实现大概在六到八个文件、七八百行左右,但能把业务方的接入成本从“一天”压缩到“十几分钟”。

1.3 一套封装的分层与文件职责

我目前使用的结构大致是这样,放在独立的pushmodule 里,业务主 module 只依赖它:

push/ ├── PushManager.kt // 唯一对外入口,单例 ├── PushConfig.kt // 配置项,含 appId 等 ├── internal/ │ ├── PushInitializer.kt // 初始化编排,处理合规时序 │ ├── PushServiceDelegate.kt // 承接 SDK Service 的四个回调 │ ├── ChannelResolver.kt // 判断当前设备走的是哪个通道 │ └── AliasBinder.kt // 别名绑定与重试队列 ├── model/ │ ├── PushMessage.kt // 统一的内部消息模型 │ └── PushPayload.kt // 扩展参数封装 └── ui/ ├── PushRouteActivity.kt // 点击中转,统一处理跳转 └── NotificationPermissionHelper.kt

PushManager对外只暴露大概十来个方法:init、getClientId、bindAlias、unbindAlias、setTags、addListener、removeListener、enableDebug。业务方不需要 import 任何一行 SDK 的类。这一点很重要,因为一旦业务代码直接引用了 SDK 的类型,将来换服务商就是把整个项目的 import 改一遍。

1.4 中转 Activity 的价值,很多人会漏掉

PushRouteActivity这个设计值得单独说明。它是通知点击之后的统一落点,本身是一个透明、不展示界面的 Activity。作用有三个:一是把“从通知栏冷启动”和“App 已在前台”两种路径归一,避免业务页面重复处理;二是承载跳转参数的解析和校验,防止脏数据导致崩溃;三是可以在这里判断登录态,如果用户没登录就跳登录页,登录完成后再回到目标页。

不设这个中转直接让通知指向业务 Activity,最典型的问题出现在多任务栈的场景:用户从通知进来,任务栈里已经有 App,点击后显示的却是上一次残留的页面状态。中转 Activity 加上合适的 launch mode,能把这类问题大幅度减少。

2. 从零到跑通:初始化这一段最容易出错

2.1 依赖引入与通道分包的选择

依赖层面有两类做法:全量引入和按需引入。服务商的 SDK 通常会把主包和各个厂商通道拆成独立的依赖,主包体积不大,厂商通道的包加起来可能到几百 KB。我的建议是主包必引,厂商通道全引。

原因很实际:厂商通道的代码只有在对应设备上才会真正加载,未命中的设备上不会执行,但如果你只引入部分通道,就意味着覆盖不到某些品牌的手机,那些设备只能走服务商自己的长连接通道,而后台被系统回收之后送达率会显著下降。为了省一两百 KB 换来一批设备的送达率下降,不划算。

引入时要注意区分implementation和api。如果 push module 用了implementation,那厂商通道的类在主 module 里是不可见的,这反而是好事,正好防止业务方绕过封装直接用 SDK 的类型。

2.2 初始化时机:合规要求带来的顺序约束

这几年国内应用市场的审核对初始化时机卡得很严,核心要求是:在用户明确同意隐私政策之前,不得进行任何涉及设备标识采集的初始化。推送 SDK 初始化时会采集设备信息,所以它天然属于“必须延后”的那一批。

正确的顺序是:

  1. 进程启动,展示隐私政策弹窗
  2. 用户点击“同意”
  3. 写入本地标记(持久化,下次启动直接跳过弹窗)
  4. 调用PushManager.init(context)
  5. 初始化完成后异步回调clientId
  6. 拿到clientId上报给服务端

第 3 步的持久化标记很关键,它决定了二次启动时能不能在Application.onCreate里直接初始化。如果每次启动都要等 UI 层走完某个流程才初始化,推送的注册会被拖后,影响到消息的及时性。

我在实际项目里是这样处理的:

object PushInitializer { private const val KEY_PRIVACY_AGREED = "privacy_agreed" fun tryInit(context: Context) { if (!isPrivacyAgreed(context)) { // 未同意,挂起,等待外部主动调用 return } PushManager.init(context) } fun isPrivacyAgreed(context: Context): Boolean { return context.getSharedPreferences("app_config", Context.MODE_PRIVATE) .getBoolean(KEY_PRIVACY_AGREED, false) } fun onPrivacyAgreed(context: Context) { context.getSharedPreferences("app_config", Context.MODE_PRIVATE) .edit().putBoolean(KEY_PRIVACY_AGREED, true).apply() PushManager.init(context) } }

提示:部分渠道审核会实际抓包看初始化时机,判断依据是 SDK 是否在用户点击同意前发出了网络请求。所以不只是“调用 init 的位置”要改,接入的其他第三方 SDK 也要一并检查,单独改推送没用。

2.3 多进程环境下的重复初始化

推送 SDK 通常会在自己的独立进程里跑 Service,部分服务商的后台进程名是固定的。如果你的Application在初始化时没有做进程判断,Application.onCreate会在每个进程都执行一遍,结果是初始化被调用两次甚至三次,表现为设备重复上报、通知重复弹出、日志重复打印。

判断当前进程的标准写法:

fun isMainProcess(context: Context): Boolean { val processName = getProcessName(context) return processName == context.packageName } private fun getProcessName(context: Context): String? { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { return Application.getProcessName() } // 低版本兜底:读 /proc/self/cmdline return try { File("/proc/self/cmdline").readText().trim() } catch (e: Exception) { null } }

在主进程里做初始化,其他进程里只做必要的 Service 注册。有些服务商的 SDK 设计得比较友好,内部已经做了进程判断,但不要默认它一定做了,自己在封装层加一道,成本很低。

2.4 设备标识的异步性与上报时机

设备标识(各家叫法不同,有的叫 CID,有的叫 ClientId,有的叫 Token)不是同步能拿到的,它是一个异步结果,而且首次安装后可能延迟几秒甚至需要网络请求完成后才有值。业务上常见的错误做法是在init之后立刻读取,拿不到就当作“推送不可用”。

正确的处理方式是在封装层维护一个状态机,把“尚未就绪”“已就绪”“获取失败”三态暴露出去,并在就绪时通过回调通知订阅者:

sealed class ClientIdState { object Pending : ClientIdState() data class Ready(val id: String) : ClientIdState() data class Failed(val code: Int, val msg: String) : ClientIdState() } class PushManager private constructor() { private val listeners = CopyOnWriteArrayList<(String) -> Unit>() @Volatile private var cachedClientId: String? = null fun getClientId(block: (String?) -> Unit) { cachedClientId?.let { block(it); return } listeners.add(block) } internal fun onClientIdReady(id: String) { if (id.isBlank()) return cachedClientId = id listeners.forEach { it.invoke(id) } listeners.clear() } }

上面这种“先缓存再回调”的写法能同时覆盖“业务调用时标识已就绪”和“业务调用时还没就绪”两种情况。CopyOnWriteArrayList是为了避免回调过程中有人注册或注销导致ConcurrentModificationException,这个异常我在早期版本里踩过一次,崩溃栈看起来和推送毫无关系。

另外一个细节:标识在卸载重装后会变,在清除数据后也可能变。所以服务端不能用它当用户唯一 ID,只能当“某台设备当前安装实例的推送地址”,且需要支持更新覆盖。

3. 统一消息链路:把四类回调收敛成一个模型

3.1 不同消息类型对应的处理路径

推送 SDK 的回调大致有四类:透传消息到达、通知消息到达、通知被点击、通知被撤回。前两类在封装层要做的第一件事是把它们转换成同一个内部模型。因为不同通道下发的消息字段不同,有的带标题有的只带内容,有的把扩展参数塞在 JSON 里有的塞在 intent extra 里,业务方如果自己去判断通道类型然后取字段,代码会脏得没法看。

统一的内部模型我定义成这样:

data class PushMessage( val msgId: String, // 消息唯一标识,用于去重 val title: String?, val content: String?, val payload: Map<String, String>, // 业务扩展参数,统一为键值对 val source: PushSource, // 来源通道,仅用于统计,业务不应依赖 val isPassThrough: Boolean, // 是否透传 val receivedAt: Long = System.currentTimeMillis() ) enum class PushSource { MAIN_CHANNEL, VENDOR_H, VENDOR_M, VENDOR_O, VENDOR_V, UNKNOWN }

这里有个设计取舍要说明:我把source放进模型里,但明确标注“业务不应依赖”。理由是统计和排查时确实需要知道消息走了哪条通道,但如果业务方基于这个字段写分支逻辑,就等于把通道差异又漏回业务层了。曾经有产品需求是“厂商通道的消息不弹通知”,这种需求本质上应该由服务端控制下发策略,而不是客户端按通道判断。

3.2 透传消息在厂商通道下的形态变化

这块是坑最集中的地方。透传消息(服务端下发后由客户端自行决定怎么处理,不自动展示)在主通道和厂商通道下行为并不一致:部分厂商通道不支持真正的透传,会把透传消息转成系统通知直接展示。

这意味着你在测试环境用主通道验证得好好的“收到透传消息后由客户端判断是否弹通知”,切到厂商通道真机上一测,通知直接就弹出来了,客户端逻辑根本没走到。

处理办法有两个层面。服务端层面:对需要客户端自行处理的消息,明确指定不走厂商通道,只走主通道。客户端层面:在封装里对透传消息做幂等保护,用msgId去重,避免“厂商通道已经展示了通知,客户端又展示一遍”的双弹。去重表我一般用一个内存 LRU 加上落库的最近 N 条记录:

internal object MessageDeduper { private const val MAX_KEEP = 50 private val cache = object : LinkedHashMap<String, Long>(MAX_KEEP, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Long>?): Boolean { return size > MAX_KEEP } } @Synchronized fun isDuplicate(msgId: String): Boolean { if (msgId.isBlank()) return false if (cache.containsKey(msgId)) return true cache[msgId] = System.currentTimeMillis() return false } }

注意:去重只对同一通道内的重复消息有效。跨通道的重复(服务端同时下发主通道和厂商通道)需要服务端做互斥,客户端做不了。我在一个项目里遇到过用户收到两条一模一样的通知,查了一周才发现是服务端的下发策略配错了。

3.3 别名绑定的时机与重试设计

别名(Alias)是推送里做用户定向的基础能力:把设备和一个业务账号 ID 绑定,服务端就能按账号推。它的典型坑是绑定的时机和登录流程不同步。

场景是这样:用户登录成功后,客户端调用绑定。但绑定是网络请求,可能因为网络抖动失败。旧代码里失败了就打条日志,结果这个用户在整个登录周期内都收不到定向消息,直到下次登录才可能补上。

封装层的做法是引入一个轻量的重试队列,把“待绑定”的别名持久化,在下一次合适的时机(App 启动、网络恢复、下一次绑定调用)重放:

class AliasBinder(private val store: AliasStore) { fun bind(alias: String) { store.savePending(alias) doBind(alias, retry = 0) } private fun doBind(alias: String, retry: Int) { PushClient.bindAlias(alias, object : BindCallback { override fun onSuccess() { store.clearPending() } override fun onFailed(code: Int, msg: String) { if (retry < MAX_RETRY) { scheduleRetry(alias, retry + 1) } } }) } fun flushPending() { store.pending()?.let { doBind(it, retry = 0) } } }

另外还有一个容易忽略的点:账号切换时必须先解绑再绑定。服务商的实现里,一台设备通常只保留一个有效别名,连续调用两次绑定,第二次会失败或者覆盖行为不可预期。所以登出流程里一定要显式调用解绑,别指望下次登录时的绑定会自动覆盖。

3.4 标签与用户分群的上报

标签能力用于按人群推送,比如“活跃用户”“某地区用户”。它的使用频率比别名低,但坑类似:标签是集合操作,多次调用会累积,清空标签的接口往往是全量清空而不是删一个。封装层最好提供setTags(Collection)这种“全量设置”的语义,内部实现为“先清空再添加”,避免业务方在不同地方各加一个标签,最后设备上挂了一堆历史标签。

如果标签数量会超过几十个,还要考虑服务商对单设备标签数量的上限。超限之后添加会静默失败,日志里可能只有一行不起眼的错误码。我的习惯是在调试模式下把所有标签数量打出来,超过阈值就告警。

4. 厂商通道才是真正的战场

4.1 厂商通道存在的意义与代价

主通道依赖客户端进程存活。但国内 Android 生态下,App 退到后台被系统清理是常态,进程不在就收不到消息。厂商通道的作用是借系统级的推送服务下发,即使 App 进程不在也能收到,这是送达率的关键。

代价是需要分别在各家厂商的开放平台上注册应用,提供包名和签名指纹,拿到各自的密钥,然后在客户端引入对应的 SDK 并完成注册。注册过程是异步的,且各家都有各自的错误码。这也是我前面说“封装层必须吃掉通道差异”的原因:如果不封装,这一堆注册逻辑会散落在各处。

4.2 包名与签名指纹不匹配是头号失败原因

厂商通道注册失败,十次里有七八次是包名或签名指纹对不上。具体表现为:客户端日志里能看到注册回调返回失败,但错误信息含糊;服务端下发时显示“设备无有效通道”,于是回落到主通道,进程不在就收不到。

排查这个问题的关键是拿到当前安装包的真实签名指纹,而不是从 Android Studio 的签名配置里复制。调试包和正式包的签名不同,同一份代码在 debug 和 release 下表现不一样,这个差异坑过很多人。我一般用这个命令导出当前包的证书信息:

# 从已安装的 apk 或 keystore 中提取 SHA256 指纹 keytool -list -v -keystore your_release.jks -alias your_alias -storepass ****** | grep SHA256 # 或者直接从构建产物 apk 里读 apksigner verify --print-certs app-release.apk

拿到之后,去各家开放平台核对,注意大小写和冒号格式,有些平台要求去掉冒号,有些要求保留。这个细节看起来微不足道,但它确实会让人耗掉半天。

还有一个隐蔽情况:使用了某些加固或重签名的分发流程,最终用户安装的包签名可能和你在开放平台登记的不一致,导致厂商通道注册失败,而这种失败在你自己测试时根本复现不了。所以上线后要留一条监控,把厂商通道的注册结果上报,按渠道维度看成功率。

4.3 注册时机与启动顺序

各家厂商通道的 SDK 都需要在Application.onCreate里完成注册调用,且必须在主进程。注册成功之后,SDK 会把拿到的厂商 token 同步给主通道的服务端,之后下发时服务端才知道往哪个厂商通道发。

这里有个时序细节值得注意:首次安装启动时,厂商 token 往往还没有就绪,此时如果立刻让服务端下发消息,服务端还不知道该走哪个通道,只能走主通道。表现出来就是“新装的 App 收不到推送,用一段时间就好了”。

解决方案是在封装层监听厂商 token 的就绪事件,成功后主动上报一次,并触发一次服务端的设备信息同步。同时服务端侧要有补充策略:对于没有通道信息的设备,下发时同时尝试主通道,不要直接丢弃。

4.4 通道判断与降级

ChannelResolver的职责是回答“当前这台设备,消息实际会从哪条路来”。它的实现依赖服务商提供的判断能力,一般能拿到当前注册成功的通道类型。这个信息主要用于两块:调试时的日志输出,以及针对特定通道做展示策略微调(比如某些通道下通知的折叠行为不同)。

需要强调的是降级逻辑:当厂商通道注册失败时,客户端不应当把它当成致命错误,而是继续用主通道工作,同时在后台按退避策略重试注册。我见过有的实现里注册失败就直接把推送功能标记为不可用,用户在整个使用周期内都收不到任何消息,这个损失比通知样式不统一大得多。

5. 点击跳转:通知落地页的那一堆细节

5.1 冷启动与热启动的路径差异

通知点击之后,App 可能处于三种状态:进程完全不在(冷启动)、进程在但 Activity 在后台(热启动)、App 在前台。三种状态下,通知携带的参数传递路径不同。服务商 SDK 一般会通过一个回调把参数给你,但冷启动场景下这个回调的触发时机在Application之后、目标 Activity 之前,所以封装层要在这里把参数暂存,等目标页面准备好再取。

我用的是一个简单的参数中转:

object PayloadRelay { private val pending = ConcurrentHashMap<String, Map<String, String>>() fun put(msgId: String, payload: Map<String, String>) { pending[msgId] = payload } fun take(msgId: String): Map<String, String>? = pending.remove(msgId) }

目标页面在onCreate里根据msgId去取,取到就消费掉,取不到说明是用户从桌面直接进的,正常展示即可。这里要注意msgId的来源必须一致,冷启动时从 intent extra 里取,热启动时从回调里取,两边要能对上。

5.2 PendingIntent 的兼容性问题

从 Android 12 开始,创建PendingIntent时必须显式声明FLAG_IMMUTABLE或FLAG_MUTABLE,否则直接抛异常。推送 SDK 内部会创建PendingIntent,如果你的目标版本已经升到这个版本以上,用的又是老版本 SDK,就会出现“点通知必崩”的问题。

这个问题排查起来有点绕,因为崩溃栈在NotificationManager或者厂商通道的代码里,看起来像是系统问题。判断方法是看崩溃时间点是否都发生在通知展示时,以及检查依赖版本。解决方式有两种:升级到支持新版本 SDK,或者在依赖里强制使用新版本。升级是根本办法。

另外还有一个相关的老问题:多条通知如果使用相同的requestCode和相同的 intent,后一条会覆盖前一条的 extra。表现出来就是“点第二条通知,跳转打开的却是第一条的内容”。解决办法是给每条通知生成唯一的requestCode,并把msgId放进 intent 的 data 或 extra 里区分。

val requestCode = msgId.hashCode() // 每条消息唯一 val intent = Intent(context, PushRouteActivity::class.java).apply { putExtra("msg_id", msgId) flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP } val pi = PendingIntent.getActivity( context, requestCode, intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE )

5.3 透明中转页面的 launch mode

PushRouteActivity我一般声明成singleTop加excludeFromRecents="true"、noHistory="true",主题设为透明。这样它不会在任务栈里留下痕迹,用户从通知进来看到的是目标页面,返回时直接回到桌面或上一级页面,而不是回到一个空白的透明页。

如果目标页面本身是singleTask,还要额外处理onNewIntent,因为点击通知时如果目标页面已经在栈里,不会重建。这个逻辑最好统一放在中转页面里,判断目标是否已存在,存在就finish自己并让目标页去处理新参数。

6. 通知展示与权限适配

6.1 Android 8.0 之后的通知渠道

从 8.0 开始,所有通知必须归属一个渠道(NotificationChannel)。渠道一旦创建,其重要性等级就不能由代码再改,只能由用户手动调整。所以渠道的 ID 和重要性,在第一次创建时就要想清楚,后面改 ID 等于新建一个渠道,老用户的设置会失效。

我的建议是按业务场景划分渠道,而不是按推送类型划分。比如“交易提醒”一个渠道(高重要性、有声),“活动推荐”一个渠道(默认重要性),“系统通知”一个渠道(低重要性、静默)。这样用户可以自己决定关掉营销类而不影响交易类,体验和留存都会更好。

fun ensureChannels(context: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val manager = context.getSystemService(NotificationManager::class.java) listOf( ChannelSpec("trade", "交易提醒", NotificationManager.IMPORTANCE_HIGH), ChannelSpec("activity", "活动推荐", NotificationManager.IMPORTANCE_DEFAULT), ChannelSpec("system", "系统通知", NotificationManager.IMPORTANCE_LOW) ).forEach { spec -> if (manager.getNotificationChannel(spec.id) == null) { manager.createNotificationChannel( NotificationChannel(spec.id, spec.name, spec.importance) ) } } }

在厂商通道下发的场景里,渠道 ID 需要服务端下发或者在客户端映射,这一点要和后端约定清楚。如果后端下发时带了未知的渠道通道 ID,系统会回落到默认渠道,表现是通知和预期的重要性不一致。

6.2 通知权限的请求时机

新版本系统引入了通知的运行时权限,用户可以在系统设置里关掉。请求这个权限的时机很讲究:在 App 首次启动就弹,拒绝率极高,而且一旦被拒绝两次,系统就不会再弹了。

我的做法是延后到有实际价值的时候再请求,比如用户完成首单、订阅了某个提醒、或者进入消息中心页面时。请求前先解释用途,用户的接受度会明显高一些。同时在封装层提供一个查询方法,业务方任何需要发通知的地方都先检查权限状态,没权限就走应用内提示,别静默失败。

fun canPostNotification(context: Context): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { ContextCompat.checkSelfPermission( context, Manifest.permission.POST_NOTIFICATIONS ) == PackageManager.PERMISSION_GRANTED } else { NotificationManagerCompat.from(context).areNotificationsEnabled() } }

提示:用户关闭通知权限后,厂商通道下发的消息同样不会展示。所以“用户收不到推送”的排查第一步,永远是先确认通知权限和应用的通知开关状态,而不是上来就查 SDK 日志。

6.3 角标与常驻通知

角标这件事各家实现不同,有的需要应用自己维护数字,有的由系统根据通知条数自动计算,有的根本不支持。实际项目里我一般只对头部几个品牌的设备做角标适配,其他设备直接跳过,不值得为覆盖率不高的场景写一堆兼容代码。

常驻通知(比如播放器、下载进度)要单独用一个固定的通知 ID,并且必须设置setOngoing(true),否则用户一划就没了。同时这类通知不建议走推送通道下发,走本地通知更可控。

7. 上线前的验证清单与排查顺序

7.1 收不到消息时的分层排查法

“收不到推送”是最常被报上来的问题,也是最容易被无效排查拖时间的。我总结的排查顺序是从上到下、从简到繁,任何一步不通过就停下来:

层级检查项快速验证方式
1通知权限是否开启系统设置里看应用通知开关
2设备是否成功拿到标识调试面板显示当前标识
3设备是否在线(主通道)服务端看设备状态
4厂商通道是否注册成功日志里看通道注册回调
5包名签名是否匹配对比开放平台登记信息
6下发参数是否正确服务端看下发记录与目标
7客户端是否收到但未展示打日志看回调是否触发

这套顺序的价值在于,前四步能覆盖八成的问题,而且不需要任何服务端配合,客户端自己就能查。真正的疑难问题往往在第五步和第六步,那才需要拉上后端一起看。

7.2 上线前必过的检查项

下面这份清单是我每次接入完都会过一遍的,条目不多但每一条都对应过真实事故:

  • 隐私政策同意前,确认没有任何推送相关的初始化调用
  • 多进程环境下确认初始化只执行一次
  • 别名绑定失败后,重启 App 能自动补绑
  • 账号切换时先解绑再绑定
  • 通知点击后冷启动能正确跳到目标页,且参数正确
  • 连续点击三条不同通知,跳转内容不串
  • 目标版本在新系统上,点击通知不崩溃
  • 通知渠道在全新安装的设备上被正确创建
  • 关闭通知权限后,应用内提示正常而不是静默失败
  • 厂商通道注册失败时,主通道仍然可用
  • 透传消息在厂商通道下不会被重复展示

7.3 我在几个项目里踩过的具体坑

第一个坑是在Application的attachBaseContext里做推送初始化。当时想着越早越好,结果那个时机资源还没准备好,SDK 内部读取配置时拿到空值,注册静默失败,日志里一行错误都没有。后来挪到onCreate末尾才正常。

第二个坑是通知的smallIcon用了彩色图标。在部分系统上会显示成一个白色方块,因为通知栏的小图标只支持带 alpha 通道的单色图。这个问题的诡异之处在于部分设备正常部分设备异常,很容易被当成系统 bug。

第三个坑是测试环境把调试开关打开了,上线忘了关。调试模式会打印大量日志,其中包含设备标识和消息内容,既影响性能也有信息暴露风险。现在的做法是把开关和 BuildConfig.DEBUG 绑死,release 包里编译期就失效。

第四个坑是服务端按别名推送时,别名大小写不一致。客户端绑的是用户 ID 原样,服务端下发时做了统一小写处理,导致匹配不上。这类问题在联调阶段发现不了,因为联调时两边用的都是同一个测试账号。后来我们在封装里强制别名统一格式,服务端也做同样的规范化,才算彻底解决。

7.4 关于保活这件事的态度

最后说一句可能有点扫兴的话:不要指望通过保活来提升推送到达率。这几年系统的限制越来越严格,后台进程、自启动、关联唤醒都在被收紧,任何保活手段的效果都在持续衰减,而且容易引发耗电和隐私方面的负面评价。把精力放在厂商通道的注册成功率和消息下发的准确性上,收益比保活高一个数量级。

我在做这套封装的时候,最深的体会是:推送这件事,客户端能控制的部分其实很小,真正决定体验的是“通道注册对不对”和“下发策略配得准不准”。封装层做好这两件事的兜底和观测,比堆一堆花哨功能有用得多。落地的时候先跑通最小闭环——能收到、能点开、能拿到参数——再去补去重、重试、渠道划分这些,顺序别反过来。