做 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.ktPushManager对外只暴露大概十来个方法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 初始化时会采集设备信息所以它天然属于“必须延后”的那一批。正确的顺序是进程启动展示隐私政策弹窗用户点击“同意”写入本地标记持久化下次启动直接跳过弹窗调用PushManager.init(context)初始化完成后异步回调clientId拿到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: MapString, 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 : LinkedHashMapString, Long(MAX_KEEP, 0.75f, true) { override fun removeEldestEntry(eldest: MutableMap.MutableEntryString, 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 ConcurrentHashMapString, MapString, String() fun put(msgId: String, payload: MapString, String) { pending[msgId] payload } fun take(msgId: String): MapString, 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 modePushRouteActivity我一般声明成singleTop加excludeFromRecentstrue、noHistorytrue主题设为透明。这样它不会在任务栈里留下痕迹用户从通知进来看到的是目标页面返回时直接回到桌面或上一级页面而不是回到一个空白的透明页。如果目标页面本身是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 关于保活这件事的态度最后说一句可能有点扫兴的话不要指望通过保活来提升推送到达率。这几年系统的限制越来越严格后台进程、自启动、关联唤醒都在被收紧任何保活手段的效果都在持续衰减而且容易引发耗电和隐私方面的负面评价。把精力放在厂商通道的注册成功率和消息下发的准确性上收益比保活高一个数量级。我在做这套封装的时候最深的体会是推送这件事客户端能控制的部分其实很小真正决定体验的是“通道注册对不对”和“下发策略配得准不准”。封装层做好这两件事的兜底和观测比堆一堆花哨功能有用得多。落地的时候先跑通最小闭环——能收到、能点开、能拿到参数——再去补去重、重试、渠道划分这些顺序别反过来。
企业数字化 ERP 产品动态
相关推荐
智能代理系统Hermes Agent的核心架构与应用实践 1. Hermes Agent 的本质与定义Hermes Agent 是一种新型的智能代理系统,其核心在于通过高度模块化的架构实现多任务协同处理。不同于传统的单一功能代理,Hermes Agent 的设计哲学强调"能力组合"而非"功能堆砌"。在实际开发中… · 2026/11/14 3:42:53
多智能体AI协作系统:架构设计与工程实践 1. 个人AI助手的崛起与团队协作变革程序员群体正在经历一场工作方式的革命。过去三年里,从GitHub Copilot到ChatGPT,AI工具已经渗透到我们日常开发的每个环节。但真正具有颠覆性的变化,是AI从单纯的"代码补全工具"进化为能够自主执… · 2026/11/14 3:42:52
宽带测向中的TOPS算法:投影子空间正交性检验原理与实战 简介:在无线通信与雷达系统中,信号到达方向(DOA)估计是检测、定位和干扰抑制的关键任务。TOPS(TDOA-Optimized Pulse Summation)正是针对宽带信号源测向的一种新方法,它通过优化脉冲求和并利用到… · 2026/11/12 21:46:25
找工作必须的美凯龙电工证怎么考?官方报名入口全攻略 找工作必须的美凯龙电工证怎么考?官方报名入口全攻略 你缺的不是能力,而是美凯龙电工证,没有它,连面试机会都没有。别再等了,现在就告诉你怎么在官方报名入口上报名,顺利拿到证书,稳稳拿下电工岗位。 美凯龙电工证到底是什么?为什么这么重要?… · 2026/11/14 3:42:32
内江办电工证在哪办全流程办理指南,含金量到底有多高? 内江办电工证在哪办全流程办理指南,含金量到底有多高? 急需特种作业证才能上岗找工作?你不是一个人。现在越来越多的岗位要求持证上岗,特别是像电工证这种门槛高的证书,几乎是行业的“通行证”。但很多人对“内江办电工证在哪办”一脸迷茫,不知道从哪开… · 2026/11/14 3:42:32
铜仁应急管理局电工证真实经历:工地太忙根本没时间复习考试怎么办? 铜仁应急管理局电工证真实经历:工地太忙根本没时间复习考试怎么办? 工地太忙根本没时间复习考试,我之前也和你一样,每天从早忙到晚,手头还有十几个项目要盯,连吃饭的时间都成了奢望。那天,我在工地上接到电话,说我的电工证快到期了,需要去铜仁应急管… · 2026/11/14 3:42:32
南京电工证怎么补办?全国通用证书怎么考怎么用 南京电工证怎么补办?全国通用证书怎么考怎么用 你还在为找不到电工证发愁吗?急需特种作业证才能上岗找工作,别再卡在这道门槛上了!尤其是像郑州金水区从事公路工程的朋友,电工证不仅关乎安全,更直接影响薪资与晋升。本文将帮你搞清楚【南京电工证怎么补… · 2026/11/14 3:42:32
考电工证在哪个app找?电子证书时代怎么考更靠谱 考电工证在哪个app找?电子证书时代怎么考更靠谱 你是不是正被“急需特种作业证才能上岗找工作”这个问题折磨?特别是像新乡市政公用工程这些岗位,电工证几乎是入职的硬门槛。别急,现在考电工证,不一定要去线下,手机上也能搞定,关键是得找到靠谱的a… · 2026/11/14 3:42:32
赤峰电工证办理流程全攻略+郑州报考避坑指南 赤峰电工证办理流程全攻略+郑州报考避坑指南 怕考不过白交培训费?别急,这篇讲透赤峰电工证办理流程、郑州报考避坑指南、薪资前景全解析,看完再决定要不要报!… · 2026/11/14 3:41:32
电工证丢失还能补吗?千万别踩坑,丢了证等于失业 电工证丢失还能补吗?千万别踩坑,丢了证等于失业 你正在找工作,但手里的电工证突然丢了,这下怎么办?别急,先别慌, 电工证丢失还能补吗 ?答案是肯定的,但 千万别踩坑 ,补办流程如果不了解,可能会耽误你上岗、甚至影响到你的收入。… · 2026/11/13 22:30:50
电力电缆作业证直播报名入口与办理流程良心建议 电力电缆作业证直播报名入口与办理流程良心建议 你是不是也担心学历不高报不了名?别急,今天就给你讲讲 电力电缆作业证直播 的报名流程和那些你必须知道的干货, 良心建议 在这里,别错过。 电力电缆作业证直播报名需要什么条件?… · 2026/11/13 5:40:45
英德电工证报考费多少钱,怕考不过白交培训费?看完这篇别再踩坑 英德电工证报考费多少钱,怕考不过白交培训费?看完这篇别再踩坑 怕考不过白交培训费?英德电工证报考费多少钱,你真的了解吗?现在市面上培训机构五花八门,价格参差不齐,很多人一不小心就花了冤枉钱,结果还考不过。别急,今天就来给你说清楚英德电工证的… · 2026/11/12 23:27:43
有初级电工证怎么工作 2026河南最新政策怎么应对 有初级电工证怎么工作 2026河南最新政策怎么应对 之前考的证在外省能不能转过来?这个问题我每天被问上百次。2026年河南最新政策对证书跨省转移有了明确说明,特别是南阳市的从业者,需要特别关注。… · 2026/11/14 0:00:31
电工证过期了不知道怎么复审?考电工证自学书+官方报名入口全攻略 电工证过期了不知道怎么复审?考电工证自学书+官方报名入口全攻略 证书过期了不知道怎么复审延期?很多人遇到这种情况,要么是工作太忙没时间处理,要么是不知道具体流程,导致证书失效,影响工作和项目进展。其实复审并不难,关键是要找准官方报名入口,掌… · 2026/11/14 0:00:31
为什么要低压电工证实操考试考什么 为什么低压电工证值不值得考?别再被中介骗了 你是不是也经常看到网上有人在问“低压电工证到底值不值得考”?或者在找报名点时,担心被中间人骗钱?这些问题,我懂,我也经历过。作为一名在应急管理行业摸爬滚打多年的从业者,我深知低压电工证不仅是一个从… · 2026/11/14 0:00:31
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/13 6:12:21
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/13 6:11:51
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/13 22:31:30