ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

个推SDK封装与Android推送快速集成工程化实践

个推SDK封装与Android推送快速集成工程化实践

1. 推送SDK为什么必须先封装一层再谈集成

做Android的应该都有这个体感:个推SDK本身接入并不复杂,官方文档照着走,半天就能收到第一条通知。但真正让人头疼的是两周之后——运营要改通知样式、产品要加静默消息、后台要按用户打标签、测试反馈点击通知重复打开页面、老板说冷启动变慢了。这时候你会发现,当初在五个Activity里散着写的PushManager.getInstance().xxx全成了债。所以这篇不讲"如何十分钟跑通Demo",讲的是个推SDK的封装思路和快速集成的工程化落地,把我这几年前后在三四个项目里踩过的坑整理成一套可以照着抄的结构。

先给结论性的判断:封装的目标不是把SDK包一层好看的API,而是把"第三方SDK"变成"你应用内部的一个能力"。这两者的区别在于,前者只是语法糖,后者意味着推送的生命周期、数据流、错误处理、降级策略全部由你自己的代码主导,SDK只是可替换的底座。等你哪天要接第二家推送做AB或者做降级,或者要从免费版切到厂商通道增强版,改动范围应该收敛在一个文件里,而不是全项目搜索替换。

这篇内容适合三类人看:第一类是完全没接过推送、想少走弯路的新人;第二类是接过但集成得比较随意、现在想重构的中级开发;第三类是负责技术方案评审、需要判断"这样封装到底合不合理"的Tech Lead。代码以Kotlin为主,关键处补Java写法,思路对任何移动端推送SDK都通用,你可以把文中的品牌换成任意一家,逻辑不变。

1.1 四个真实翻车场景,说明裸集成的问题在哪

场景一:业务代码直接依赖SDK静态类。典型写法是在登录成功后调PushManager.getInstance().bindAlias(context, uid),在登出时调unBindAlias。一套代码写了十几处,后来要做渠道包区分推送策略,需要切换底层实现时,全项目grep出三十多个调用点,一个个改,漏一个就是线上Bug。问题的本质是业务层对SDK产生了编译期依赖,这在架构上叫"依赖倒置没做好"。

场景二:初始化位置随意。很多人在Application.onCreate()里直接初始化,觉得"越早越好"。实测下来冷启动会多出150到250ms,因为SDK初始化涉及读取设备标识、建立长连接、加载本地缓存。正确的做法是判断当前进程、判断是否首次启动、判断用户是否已同意隐私政策,三者都满足才初始化。这三件事没有一件能在onCreate里拍脑袋写完,必须有封装层来承载这个决策逻辑。

场景三:通知点击后页面不刷新。用户在"消息中心"页面,点了一条通知,本意是刷新列表,结果又压了一个新的"消息中心"实例上去,返回键要按两次。原因通常是PendingIntent的requestCode写死成0,加上Intent缺少FLAG_ACTIVITY_CLEAR_TOP和singleTop配合。这个坑几乎每个团队都会踩,而且测试环境数据量小、不容易复现,往往上线后才被用户投诉。

场景四:多进程重复初始化。为了不让推送的长连接拖累主进程内存,很多人会给推送Service配置独立进程android:process=":push"。但Application.onCreate()会在每个进程各执行一次,结果onReceiveClientId回调了两次、埋点数据统计翻倍、内存里维护了两个状态机。这是最容易忽略的一类"看起来正常但数据全错"的问题。

1.2 封装层的职责边界:管什么、不管什么

封装最容易走偏的地方是"什么都想管",最后变成一个巨型工具类。我一般按下面的边界来切:

职责归属说明
SDK初始化、参数注入封装层统一入口,控制时机
设备标识、用户标识的绑定与解绑封装层屏蔽SDK回调差异
消息透传、通知点击事件分发封装层转成自定义事件模型向上抛
通知栏样式、渠道分类封装层统一配置,避免各页面各写一套
消息内容解析、业务字段含义业务层封装层只负责"送到",不负责"看懂"
跳转目标页面、参数校验业务层(路由)封装层提供跳转入口
用户是否接收推送的开关业务层,封装层执行开关状态存业务侧,封装层读状态决定是否上报

这张表的意义在于:封装层要"薄",但要"完整"。薄是指它不承载业务语义,完整是指SDK对外暴露的能力它都要有对应出口。我见过把消息解析也塞进封装层的,结果后台加一个字段就要发一次SDK层的版本,非常难受。

1.3 三种封装路线的取舍:门面类、接口抽象、反射插件化

路线一:单例门面类。一个object PushHelper里包所有方法,业务直接调。优点是快,一小时写完;缺点是无法单测、无法替换实现、多进程下状态难管理。适合验证阶段或者生命周期只有三个月的小项目。

路线二:接口 + 实现类 + SDK桥接层。定义PushClient接口,实现类PushClientImpl内部持有一个PushSdkBridge(把SDK静态调用全部收拢在这一个文件里)。业务只依赖接口,通过PushProvider.client()拿到实例。这是我推荐的方案,改动成本、可测性、可替换性三者平衡得最好。

路线三:反射 + 插件化。把SDK实现做成独立Module,通过反射或SPI加载。理论上最灵活,能在运行时切换推送厂商。但代价是混淆规则复杂、崩溃堆栈难以定位、增量包体积控制困难。除非你有明确的"一套代码出多个渠道包、每个包用不同推送服务商"的硬需求,否则不建议。我试过一次,调试成本高到项目后期基本没人愿意碰那块代码。

2. 集成前的参数准备与工程配置

很多"集成了但收不到消息"的问题,根子不在代码而在配置。这一节把工程侧的东西一次说清楚,避免后面联调时来回猜。

2.1 应用信息注册与参数落地方式

先从服务方控制台拿到几个关键参数:应用标识(AppID)、应用密钥(AppKey)、应用秘钥(AppSecret)、服务端密钥(MasterSecret)。前三个是客户端用的,最后一个只允许放服务端,绝对不能打进APK,这一点一定要在方案评审时强调,我见过把服务端密钥写进BuildConfig然后被反编译出来的案例。

客户端参数的正确落地方式是用build.gradle的manifestPlaceholders或BuildConfig字段注入,而不是硬编码在Java文件里。这样不同渠道包可以用不同参数,同时避免明文散落在源码中:

// app/build.gradle android { defaultConfig { manifestPlaceholders = [ PUSH_APP_ID : project.PUSH_APP_ID, PUSH_APP_KEY : project.PUSH_APP_KEY, PUSH_APP_SECRET: project.PUSH_APP_SECRET ] buildConfigField "String", "PUSH_APP_ID", "\"${project.PUSH_APP_ID}\"" } }

参数从local.properties或CI环境变量读取,local.properties记得加进.gitignore。这样新人拉代码只需要找你要一份本地配置,不会因为密钥没配导致编译失败。

2.2 Gradle依赖与仓库配置的坑

依赖本身很简单,重点在两个细节:仓库地址和ABI过滤。

// 项目级 build.gradle allprojects { repositories { google() mavenCentral() maven { url 'https://maven.xxx.com/repository/releases/' } // 以官方文档为准 } }

仓库地址一定以官方最新文档为准,不要抄博客里的旧地址,这是最常见的"依赖拉不下来"原因。第二个细节是abiFilters,如果项目里只有armeabi-v7a和arm64-v8a,就把so库过滤掉,能省下2到4MB包体积:

defaultConfig { ndk { abiFilters 'armeabi-v7a', 'arm64-v8a' } }

注意:过滤ABI之前先确认SDK是否依赖x86模拟器调试。有些团队为了在模拟器上跑,保留了x86,结果线上包多了几个用不上的so,白白增加体积。

2.3 Manifest声明、权限与混淆规则

权限这块,不同版本SDK要求不一样,原则是只申请真正需要的。网络权限、网络状态权限是必须的;读取设备标识相关权限在新版本系统上受限,能用就用,拿不到要有降级逻辑,不能因为拿不到就崩溃。

Service和Receiver的声明建议加android:exported="false"(除SDK要求必须为true的),并显式指定进程:

<service android:name=".push.PushService" android:exported="false" android:process=":push" /> <receiver android:name=".push.PushReceiver" android:exported="false"> <intent-filter> <action android:name="com.your.package.PUSH_MESSAGE" /> </intent-filter> </receiver>

混淆规则是另一大坑。推送SDK的消息实体类通常靠反射解析,一旦被混淆,字段名变了就解析不出来,表现是"能收到通知但点进去参数全是null"。规则要覆盖实体类、Service、Receiver:

-keep class com.your.push.entity.** { *; } -keep class com.your.push.core.** { *; } -keep class * extends android.app.Service -keep class * extends android.content.BroadcastReceiver -keepclassmembers class * implements java.io.Serializable { static final long serialVersionUID; private static final java.io.ObjectStreamField[] serialPersistentFields; !static !transient <fields>; }

提示:每次升级SDK版本,先跑一次release包的混淆构建,用真机验证消息透传字段是否完整。debug包不混淆,测不出这类问题,这是最容易漏的一环。

3. 封装层核心实现:从接口定义到消息分发

前面都是铺垫,这节开始上真东西。整个封装层我拆成四个文件:PushClient(接口)、PushClientImpl(实现)、PushSdkBridge(SDK桥接,唯一依赖SDK的地方)、PushDispatcher(事件分发)。职责单一,改哪个都很明确。

3.1 统一门面接口的定义与设计考量

接口设计的第一原则是只暴露业务真正会用的能力,不要把SDK的所有方法原样搬过来。我先给一版:

interface PushClient { fun init(context: Context, config: PushConfig) fun bindAlias(alias: String, callback: PushCallback? = null) fun unbindAlias(alias: String, callback: PushCallback? = null) fun setTags(tags: List<String>, callback: PushCallback? = null) fun clearTags(callback: PushCallback? = null) fun currentToken(): String? fun observeToken(listener: (String) -> Unit) fun removeTokenObserver(listener: (String) -> Unit) fun setNotificationEnabled(enabled: Boolean) fun isNotificationEnabled(): Boolean } data class PushConfig( val appId: String, val appKey: String, val appSecret: String, val debug: Boolean, val silenceTimeRange: List<IntRange> = emptyList() ) fun interface PushCallback { fun onResult(success: Boolean, errorCode: Int, message: String?) }

几个设计点解释一下。PushCallback用fun interface是为了支持lambda,调用方写起来轻。PushConfig用data class而不是散参数,方便后续加字段不破坏方法签名。currentToken()是同步取值,observeToken()是异步监听,两者必须同时存在——因为标识可能在初始化后几秒才回调,也可能早就存在缓存里,业务方不应该关心这个时序。

setNotificationEnabled()这类"用户偏好"能力要不要放进来,我的判断是:放,但它只是执行层,真实状态存储放在业务侧。原因很简单,封装层不应该负责持久化,否则换存储方案又要动它。

3.2 桥接层:把SDK的静态调用全部收拢

这是整个方案里最关键的一个文件。它存在的唯一目的,是让"SDK长什么样"这件事只在一个文件里被知道。

internal object PushSdkBridge { fun register(context: Context) { // 这里调SDK的注册/初始化静态方法 // 例如:PushManager.getInstance().initialize(context, callback) } fun turnOn(context: Context) { // 开启推送服务 } fun bindAlias(context: Context, alias: String) { // 绑定别名,具体方法名以官方文档为准 } fun unbindAlias(context: Context, alias: String) { // 解绑别名 } fun setTags(context: Context, tags: List<String>) { // 打标签 } }

看起来像无意义的转发,但价值在两个月后体现:当你要接第二家推送做降级,只需要新增一个PushSdkBridgeB,PushClientImpl里加一个选择逻辑,业务代码一行不动。这就是依赖倒置的实际收益。

3.3 标识回调的时序处理:为什么回调会"丢"

这是新手最容易遇到也最难查的问题。onReceiveClientId通常是在SDK内部子线程回调的,如果业务方在onCreate里初始化完SDK,然后在Activity.onCreate里注册监听,中间这个几百毫秒的窗口期里回调如果发生了,你的监听器就永远等不到这个值,表现是"拿不到设备标识,绑定别名一直失败"。

解决方案是加一层内存缓存 + 状态标记,把"推送"模型改成"拉取 + 订阅"模型:

object PushTokenStore { @Volatile private var cachedToken: String? = null private val observers = CopyOnWriteArraySet<(String) -> Unit>() fun onTokenReceived(token: String) { cachedToken = token observers.forEach { it.invoke(token) } } fun currentToken(): String? = cachedToken fun observe(listener: (String) -> Unit) { observers.add(listener) cachedToken?.let { listener.invoke(it) } // 已有值立即回调 } fun remove(listener: (String) -> Unit) { observers.remove(listener) } }

CopyOnWriteArrayList/CopyOnWriteArraySet在这里是刚需,因为回调可能在子线程,而注册/反注册发生在主线程,用普通ArrayList会有ConcurrentModificationException风险。这种"写少读多"的场景,COW容器是最省心的选择。

注意:SDK的回调里不要直接做耗时操作或者弹UI,切到主线程再做。有些SDK的回调线程是单线程池,堵住之后后续消息全部延迟,表现是"消息推送越来越慢"。

3.4 消息接收与自定义事件分发

SDK收到消息后,会通过你声明的Service或Receiver抛给你。这里我建议不要在接收器里写任何业务逻辑,只做一件事:把SDK的消息模型转成你自己的事件模型,扔给分发器。

class PushService : IntentService("PushService") { override fun onHandleIntent(intent: Intent?) { intent ?: return val raw = intent.extras ?: return val event = PushEvent( messageId = raw.getString("msgId").orEmpty(), title = raw.getString("title").orEmpty(), content = raw.getString("content").orEmpty(), payload = raw.getString("payload").orEmpty(), receivedAt = System.currentTimeMillis() ) PushDispatcher.dispatch(event) } }

注意这里用了orEmpty(),防止null导致后续解析崩溃。payload是业务自定义字段的载体,通常是一个JSON字符串,封装层原样透传,不解析,解析交给业务方,这是前面说的职责边界。

分发器本身也很简单:

object PushDispatcher { private val listeners = CopyOnWriteArrayList<PushEventListener>() fun register(listener: PushEventListener) { listeners.addIfAbsent(listener) } fun unregister(listener: PushEventListener) { listeners.remove(listener) } fun dispatch(event: PushEvent) { listeners.forEach { listener -> runCatching { listener.onMessageArrived(event) } .onFailure { Log.e("Push", "listener error", it) } } } } interface PushEventListener { fun onMessageArrived(event: PushEvent) fun onNotificationClicked(event: PushEvent) {} }

runCatching包一层是必须的,一个监听器抛异常不能影响其他监听器,更不能让进程崩掉。这类"一个坏苹果不能坏一筐"的处理在事件分发里是标配。

3.5 通知点击跳转与参数透传的完整链路

用户点通知那一刻发生的事情最多:系统拉起你的App(可能是冷启动)、Intent被投递到目标页、页面解析参数决定跳哪。这条链路上有三个坑。

坑一,PendingIntent被复用。如果requestCode固定,两条不同消息的通知点开后会跳到同一个内容,因为系统认为Intent相同就复用了旧的。解决办法是用消息ID的hash作为requestCode:

val intent = Intent(context, PushRouteActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP putExtra("push_payload", payload) } val pendingIntent = PendingIntent.getActivity( context, messageId.hashCode(), // 关键:每条约通知一个独立 requestCode intent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE )

FLAG_IMMUTABLE在Android 12+是强制的,不写会直接抛异常,这个坑在升级targetSdk时集中爆发过一批。

坑二,重复打开页面。用一个透明的中间页PushRouteActivity做统一入口,它不显示UI,onCreate里解析参数、做校验、然后startActivity到真正的目标页,最后finish()自己。同时在AndroidManifest里给目标页配launchMode="singleTop",Intent里带FLAG_ACTIVITY_CLEAR_TOP,这样"已在栈中"的场景会走onNewIntent而不是新建实例。

坑三,冷启动时路由还没准备好。如果App刚起来,路由表还没注册完,PushRouteActivity就跳转会失败。解决方式是加一个"路由就绪"的标志位,未就绪时把payload暂存,等主界面onResume后消费。

3.6 别名、标签与用户体系的绑定时机

别名(Alias)用于按用户维度推,标签(Tag)用于按人群属性推。绑定时机有三个选择,各有取舍:

绑定时机优点风险
App启动时绑定设备ID到达率最高未登录用户也会收到"个人消息",体验差
登录成功后绑定用户ID精准登录前的通知收不到
登录后绑定 + 登出解绑数据最干净解绑失败会导致消息错发,必须校验回调

我倾向第三种,但必须处理两个边界。边界一:解绑失败怎么办。解绑是网络请求,可能失败。如果失败而用户已经登出,下一个登录这个设备的用户会收到前一个人的消息,这是严重的数据问题。我的做法是本地记录一个"待解绑"标记,下次启动时重试,同时服务端在推消息前做一次设备与用户的归属校验。

边界二:切换账号。用户A登出、用户B登入,中间如果A的解绑还没完成就绑了B的别名,可能出现一个设备绑定两个别名。解决方式是加一个简单的串行队列,解绑回调成功后才触发绑定:

fun switchUser(newAlias: String, oldAlias: String?) { if (oldAlias.isNullOrEmpty()) { client.bindAlias(newAlias) { success, code, msg -> if (!success) retryLater(newAlias) } return } client.unbindAlias(oldAlias) { success, _, _ -> if (success) { client.bindAlias(newAlias) { s, c, m -> if (!s) retryLater(newAlias) } } else { retryLater(newAlias) // 解绑失败也要重试,不能直接绑 } } }

这个逻辑看着啰嗦,但它是唯一能保证数据不出错的写法。上线前一定要用两个账号反复切换测十次以上。

4. 生命周期、进程与系统通道的配合策略

封装层写完只是骨架,能不能稳定跑起来取决于对Android生命周期和进程模型的尊重程度。这节讲三个容易被低估的问题。

4.1 初始化时机:为什么"越早越好"是错的

很多人把初始化放在Application.onCreate()第一行。问题是这个时机很多东西还没准备好:隐私政策用户还没同意、设备标识可能拿不到、本地配置还没读出来。

我的做法是分两步走。第一步,在Application.onCreate()里只做"登记",把PushClient实例创建出来、把配置塞进去,不做任何实际网络操作。第二步,在用户同意隐私政策、并且首次进入主界面之后,才真正触发init()。这么做的收益很直接:冷启动时间能降100ms以上,同时满足隐私合规要求(在用户同意前不采集任何设备信息)。

class App : Application() { override fun onCreate() { super.onCreate() PushProvider.install(this) // 只登记,不初始化 } } // 用户同意隐私政策后调用 object PushLauncher { fun launch(context: Context) { if (!PrivacyAgreement.accepted(context)) return if (!isMainProcess(context)) return PushProvider.client().init(context, PushProvider.config(context)) PushProvider.client().observeToken { token -> PushReporter.reportDeviceToken(token) } } }

判断"首次进入主界面"可以用ProcessLifecycleOwner监听第一个Activity的onStart,或者干脆在首页onCreate里触发,加个幂等标记防重复。

4.2 多进程下的状态同步与重复初始化规避

给推送Service配独立进程能显著降低主进程内存占用,但代价是所有进程都会跑一遍Application.onCreate()。所以isMainProcess()这个判断必须加:

fun isMainProcess(context: Context): Boolean { val pid = Process.myPid() val processName = context.applicationContext.packageName val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager val current = am.runningAppProcesses ?.firstOrNull { it.pid == pid } ?.processName return current == processName }

注意Android 9之后runningAppProcesses只返回自己应用的信息,但用来判断"当前是不是主进程"依然有效,因为你自己应用的进程名它还是会返回的。如果某个机型上这个方法返回null,退而求其次用/proc/self/cmdline读取:

fun currentProcessName(): String { return runCatching { File("/proc/self/cmdline").readText().trim('\u0000') }.getOrDefault("") }

跨进程状态同步这块,如果主进程需要知道推送进程收到的消息,可以用Messenger或者AIDL,但更简单的做法是让推送进程只负责收、主进程通过数据库或 SP 读取,避免引入Binder的复杂度。消息落库用Room或轻量KV都行,注意多进程下SP不可靠(MODE_MULTI_PROCESS早就废弃了),要么用ContentProvider包装,要么用支持多进程的存储方案。

4.3 系统通道的接入与降级链路设计

在部分系统上,应用被后台清理后长连接会断,消息收不到。这时候需要借助手机厂商自带的系统级推送通道作为补充。这块的封装要点是把它当成"另一个桥接实现",而不是在主逻辑里写一堆if。

设计上我分成三层:

第一层,统一入口PushClient。业务无感知,调用方式不变。

第二层,通道选择器PushChannelSelector。根据当前设备厂商、系统版本、是否安装了对应服务来决定用哪个通道。这里用一个Map<Int, PushChannel>维护优先级。

第三层,各通道实现PushChannel。每个实现内部自己处理注册、token上报、消息接收。

降级链路要明确写出来,比如:优先系统通道,系统通道注册失败则退回自建长连接,两者都失败则记录状态并定期重试。重试要有退避策略,别做成1秒一次的死循环:

private fun retryWithBackoff(attempt: Int) { val delay = minOf(1000L * (1 shl attempt), 60_000L) handler.postDelayed({ registerAgain(attempt + 1) }, delay) }

第一次1秒、第二次2秒、第三次4秒,最长60秒封顶。这是我在多个项目里试出来比较稳的参数,太激进会耗电,太保守会漏消息。

4.4 通知渠道分类与样式统一

Android 8.0之后通知必须归属渠道(Channel),渠道一旦创建,重要级别就不能再改。这就要求在发版前就把渠道规划好,否则后期想调整优先级只能换新渠道ID。

我的规划通常是三类:channel_message(普通消息,默认优先级)、channel_important(重要通知,高优先级带声音震动)、channel_silent(静默消息,最低优先级不打扰)。渠道创建放在Application.onCreate里,用NotificationManager.getNotificationChannel(id)判断是否已存在,不存在才创建。

样式统一这件事,封装层提供一个PushNotificationBuilder,把标题、内容、图标、小图标、折叠样式、角标数字都收口。业务方只传数据,不碰NotificationCompat.Builder。这样以后要统一改小图标(比如适配不同系统的单色图标规范),只改一处。

5. 联调排查实录与常见问题速查

集成阶段最耗时的不是写代码,是排查"为什么收不到""为什么点开是空白页"。这节把典型问题按现象分类,给出现场可用的排查路径。

5.1 收不到消息的分层排查方法

排查顺序必须是自下而上的,乱猜只会浪费时间。我整理成一张表:

层级检查项判断方法
配置层应用标识、密钥是否正确日志里看初始化返回值,失败码对照文档
网络层设备是否能正常访问服务端抓包看是否有注册请求、返回码是否200
进程层推送进程是否存活`adb shell ps
注册层是否拿到设备标识打日志看onTokenReceived是否有回调
透传层消息是否到达接收器在接收器第一行打日志,看是否被执行
展示层通知是否被系统拦截检查通知权限、渠道是否被用户关闭
点击层跳转是否成功在路由页onCreate打日志看参数

实测下来,八成问题集中在"注册层"和"展示层"。注册层最常见的原因是初始化时机不对(在用户同意隐私政策前就初始化,被SDK内部拦截),展示层最常见的原因是通知权限被用户手动关闭了,代码里没有检测。

检测通知权限是否开启:

fun isNotificationEnabled(context: Context): Boolean { val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { manager.areNotificationsEnabled() && manager.getNotificationChannel("channel_message")?.importance != NotificationManager.IMPORTANCE_NONE } else { manager.areNotificationsEnabled() } }

如果检测到关闭,要在应用内的设置页提示用户去开启,而不是默默失败。

提示:测试阶段一定要用release签名的包做一次完整验证。debug包的签名、包名后缀、混淆状态都和线上不同,有些问题是release才暴露的,比如前面说的实体类被混淆导致字段解析失败。

5.2 点击通知重复打开页面与栈管理的根治方案

前面提过requestCode和FLAG_ACTIVITY_CLEAR_TOP,这里补一个更彻底的思路:所有推送跳转统一走一个中间路由页,并且这个路由页在Manifest里声明android:taskAffinity=""和android:excludeFromRecents="true",让它不参与任务栈的记录,跳完立刻finish()。

同时给目标页统一加launchMode="singleTop",并在onNewIntent里重新解析payload:

override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) setIntent(intent) handlePushPayload(intent?.getStringExtra("push_payload")) }

setIntent(intent)这行很容易漏,漏了之后getIntent()拿到的还是旧Intent,页面参数永远不更新。这个坑我踩过两次,两次都是查了半天才发现。

5.3 混淆、64K与崩溃的连带问题

三个高频连带问题。

第一,混淆后消息字段为null。前面说过,加keep规则。补充一点:如果用了Gson解析payload,要确保payload里的实体类字段名和后台下发的key完全一致,或者用@SerializedName显式指定,别指望混淆后还能对上。

第二,方法数超限。推送SDK加上厂商通道,很容易把方法数推过65536。开multiDexEnabled true是基本操作,但要注意Application的继承关系——如果用了MultiDexApplication,而你的App已经继承了别的基类,就要在attachBaseContext里手动调MultiDex.install(this)。

第三,初始化崩溃。常见的原因是context传了Activity而不是ApplicationContext,导致内存泄漏或者Activity销毁后回调崩溃。封装层内部统一context.applicationContext,这个转换在init()入口做一次就够了。

5.4 常见问题速查表

现象可能原因处理方式
完全收不到通知初始化时机早于隐私同意 / 密钥错误延后初始化,核对参数
能收到但内容为空实体类被混淆 / 字段名不匹配补keep规则,加反序列化注解
点击通知无反应PendingIntent flag 缺失 / 路由页未声明补FLAG_IMMUTABLE,检查Manifest
点击后打开多个页面requestCode 固定 + 无CLEAR_TOP用消息ID做requestCode
拿不到设备标识监听注册晚于回调加缓存层,先取缓存再订阅
绑定别名偶发失败解绑与绑定并发串行队列,回调驱动
冷启动变慢初始化放在onCreate分两步,延后到主界面
后台一段时间后收不到长连接被系统回收接入系统通道,加降级重试
通知不显示但日志有渠道被用户关闭检测权限并引导
多进程回调重复Application多次执行加进程名判断

6. 上线前的验证清单与灰度策略

代码写完不等于能上线。推送这种"依赖外部服务 + 依赖系统行为"的能力,验证成本和普通业务功能完全不在一个量级。

6.1 必须覆盖的验证场景清单

我会在提测前自己先跑一遍这个清单,共十项:新装首次启动(隐私未同意时不该初始化)、同意隐私后首次初始化、设备标识回调是否在3秒内、绑定别名成功与失败、解绑后是否收不到定向消息、前台收到透传、后台收到通知、杀进程后收到通知、点击通知冷启动跳转、点击通知热启动跳转。这十项全覆盖,基本能保证主链路没问题。

补充两个容易漏的:弱网和断网场景(注册失败后重试是否正常)、多账号切换场景(连续切换五次,检查是否串号)。这两个场景手工测一次要花十几分钟,但能挡住最严重的线上事故。

6.2 灰度发布与数据观测的落地方式

推送的灰度不能像普通功能那样按用户比例放量,因为推送是"服务端主动下发",一旦全量发出去就收不回来。我的做法是分三步。

第一步,只对内部测试账号开放,观察三天。重点看两个指标:设备注册成功率、消息到达率。注册成功率低于95%说明初始化链路有问题。

第二步,按设备维度放量1%,观察一天。这一步重点看崩溃率,因为推送SDK涉及so库和系统服务调用,崩溃往往在特定机型上才出现。

第三步,放量到10%、50%、全量,每步之间至少间隔一天。任何一步发现到达率下降超过5个百分点,立即回滚到上一步的配置。

数据观测要埋三个点:初始化成功/失败、标识获取成功/失败、消息到达/点击。这三个点串起来就是一条完整链路,哪一环断了都能立刻定位。

我在实际项目里踩过最深的一个坑,是灰度阶段没盯崩溃率,全量之后才发现某个系统版本上初始化直接闪退,原因是那个版本的SDK依赖了一个已经被移除的系统API。如果当时按设备维度分步放量并盯着崩溃率看,风险面会小很多。这件事之后,我给自己定了个规矩:凡是涉及第三方SDK的改动,发布节奏必须比别人慢一档,宁可晚两天全量,也不要让线上用户帮你做压力测试。

最后分享一个小技巧:在封装层的init()里加一个环境开关,debug包走日志全开的沙箱配置,release包走正式配置,两者用BuildConfig.DEBUG区分。这样联调时不用改代码就能看到完整链路日志,上线时又不会把日志打到线上,省事且安全。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进