1. 项目整体设计思路与拆解
最近我用 Kotlin 重写了一遍移动端的推送通知模块,从接收服务端消息、解析 payload、弹出系统通知,到用户点击后的跳转与埋点,整个链路三天左右就收工了。这里说的“Kotlin 助力”不是一句空话,而是语言特性真的在替我们解决推送场景里最棘手的问题:异步回调、状态流转、数据模型的可维护性。
推送通知是移动应用里最基础、也最容易被低估的功能之一。用户可能几天才打开一次 App,但你的推送消息能不能到达状态栏,决定了这个人会不会重新回来。早期我用 Java 写推送逻辑,最大的痛点是回调嵌套。一个推送消息从 FCM 或厂商通道进来,要先解析,再判断渠道,再准备 PendingIntent,中间还有网络请求、用户偏好读取、去重判断,Java 写下来全是嵌套回调,看两遍就想重构。换成 Kotlin 之后,协程把回调扁平化了,数据类把 payload 变成了强类型结构,密封类把通知点击行为收拢成了一组有限状态,整个模块的代码量砍掉了将近三分之一。
这篇内容我当时是边做边记录的笔记,现在整理成文章,主要写给三类人看:刚学 Kotlin 想找个落地场景的移动开发初学者、正在备战移动应用开发技能大赛或类似考试的选手,以及已经在用 Kotlin 做业务、但推送模块长期靠复读老代码的同行。读完你可以直接照着实现一套较完整的客户端推送能力,并且理解每一步为什么要这么做,而不只是会调一个 notify()。
设计思路上,我把它分成了四层:第一层是 Kotlin 基础特性的消化,确认协程、数据类、密封类、扩展函数这些语言能力在推送场景里分别扮演什么角色;第二层是通道与服务选型,包括系统通知渠道、FCM、国内厂商推送的取舍;第三层是完整链路编码,从权限适配到消息展示,从 token 回传到点击路由;第四层是稳定性细节,包括 Android 13 的权限变化、渠道删除陷阱、后台限制、消息幂等这些常规文档很少展开的东西。接下来按这个顺序逐一拆开说。
2. 动手前的技术地基:选型、依赖与 Kotlin 特性
2.1 Kotlin 的哪些特性正好长在推送需求的痛点上
先说协程。推送模块里到处是异步回调:FirebaseMessaging 获取 token 是异步的,厂商 SDK 注册回调是异步的,点击通知之后的跳转往往还要先从本地数据库取参数。Java 的做法是监听器嵌套,或者 EventBus 满天飞。而 Kotlin 的 suspendCoroutine 可以把一次回调封装成挂起函数,让代码看起来像同步逻辑,真正执行时又不阻塞主线程。这个价值在推送场景里会被放得很大,因为推送消息进入客户端的那一刻,往往正好是 App 处于后台或者刚被系统回收的时候,线程和时序都不能出错。
再说数据类和密封类。推送消息 payload 从服务端下来,是一个四级到五级的 JSON 结构,拿 Java 写要手写一堆 getter/setter 和类型判断。Kotlin 一行 data class 就搞定,默认实现了 equals、hashCode、toString,调试打印异常方便。密封类则是处理“点击通知后做什么”的利器,它把跳转页面、打开链接、无操作这几种行为收束成有限集合,when 分支穷尽,编译器帮你兜底。
还有扩展函数和顶层函数。通知渠道的创建、通知样式的构建,非常适合用扩展函数封装在独立文件里,Activity 和 Service 里只留一两行调用。这些都是语言层面的“助力”,不是框架逼你写的模板。
2.2 推送方案选型:FCM、厂商推送与本地通知别混为一谈
动手前先想清楚消息走哪条路。现在市面上主流三套方案:FCM、各大手机厂商推送通道、以及纯本地通知。
FCM 是 Google 官方方案,集成简单,服务端有 HTTP v1 API,客户端只有一个 FirebaseMessagingService。项目里如果面向海外用户,或者做工具类、独立开发者应用,选它成本最低。缺点是国内部分设备对 GMS 依赖很重,用户没装 Google Play 服务就收不到消息,所以在国内上架的 App 一般还要接厂商推送兜底。
厂商推送指的是小米、华为、OPPO、vivo 这些 ROM 自带的系统推送服务。优势是 App 进程被杀死后消息仍然能到达桌面通知栏,这是 FCM 和本地通知做不到的。缺点是 SDK 私自货太多,每家一套配置,服务端要同时维护多个 token 和多种消息格式,工作量明显上升。国内大型应用基本都是 FCM + 多个厂商通道统一封装,服务端做路由分发。
本地通知其实不属于“推送”,它是 App 在前台或者被用户主动拉活时,由客户端自己触发的通知,比如闹钟提醒、限时活动倒数。很多入门文章把本地通知和远程推送混在一起讲,导致初学者以为只调 NotificationManagerCompat.notify() 就是推送,这是最大的误解。我在这条路上走过弯路,所以单独拎出来提醒一句。
技术选型没有绝对最优,取决于目标和预算。个人练手项目完全可以用 FCM 打通链路,再在本地路由层预留厂商推送接口。国内公司项目则要先确认产品重点机型,通常小米和华为优先,因为市场份额摆在那。
2.3 工程依赖与权限配置:先让项目能跑起来
无论选哪种推送服务,Android 客户端的依赖基础是一致的。我用的是 Firebase Messaging KTX 加协程库,Gradle 依赖如下:
dependencies { implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.activity:activity-ktx:1.8.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") // Firebase 推送 implementation(platform("com.google.firebase:firebase-bom:32.7.0")) implementation("com.google.firebase:firebase-messaging-ktx:23.4.0") }Manifest 中需要补两个关键声明:一个是监听远程消息的 FirebaseMessagingService,一个是 Android 13 及以上必须声明的运行时通知权限。如果 App 目标是 Android 14,同时还涉及精确闹钟能力,还要视场景加上 SCHEDULE_EXACT_ALARM。核心声明长这样:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.INTERNET" /> <application> <service android:name=".push.PushService" android:exported="false"> <intent-filter> <action android:name="com.google.firebase.MESSAGING_EVENT" /> </intent-filter> </service> </application>这里有个我踩过的坑:如果 service 的 exported 没写 false,上架 Google Play 会被拒,理由是导出的 service 存在被外部应用启动的风险。Android 12 之后要求显式声明 android:exported,务必把不对外暴露的组件都标成 false。
3. 用 Kotlin 实现一个完整推送链路
3.1 第一步:消息从哪来——封装远程消息入口
推送链路的第一环是收到消息。继承 FirebaseMessagingService 后有两个重点方法:onMessageReceived 处理前台和系统正常投递的消息,onNewToken 处理 token 刷新。
class PushService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { super.onMessageReceived(message) if (message.data.isNotEmpty()) { val payload = runCatching { Json.decodeFromString<PushPayload>(message.data["payload"].orEmpty()) }.getOrNull() if (payload != null) { PushNotifier.notify(this, payload) } } } override fun onNewToken(token: String) { super.onNewToken(token) // 实际项目里这里应该把 token 上报到自己的服务端,并且服务端要返回幂等标记 viewModelScope.launch { pushRepository.reportToken(token) } } }写到这里顺便把协程的推荐用法说透。onNewToken 本身是回调执行在主线程,如果你想在里面做网络请求,别直接用 Thread,而是启动一个协程切到 Dispatchers.IO。上面示例里用了 viewModelScope,但 Service 不适合直接用 ViewModelScope,更适合的方式是自建一个 CoroutineScope,或者用 GlobalScope 并自行管理生命周期。
我实际写的时候是定义了一个应用级的 CoroutineScope,看代码更清楚:
object AppScope { val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) }然后在 PushService 中使用:
AppScope.scope.launch { pushRepository.reportToken(token) }这里有个细节经验:网络请求确实应该在 IO 线程做,但 token 上报这种操作优先级极高,最好加一个重试机制,比如使用 kotlinx.coroutines 的 retry 模式。系统在安装 App 后频繁刷新 token,如果第一次上报失败就丢掉,后面通知全收不到,排查起来会很被动。
3.2 第二步:把异步回调改成挂起函数,代码瞬间清爽
继续实现在推送场景里感受最明显的一个点——把 FirebaseMessaging 的 token 回调转成可挂起的函数。
Firebase 在专门获取 token 时的典型写法是 addOnCompleteListener 回调:
private suspend fun fetchFcmToken(): String = suspendCoroutine { continuation -> FirebaseMessaging.getInstance().token .addOnCompleteListener { task -> if (task.isSuccessful) { continuation.resume(task.result) } else { continuation.resumeWithException( task.exception ?: RuntimeException("获取 token 失败") ) } } }原理其实不复杂:suspendCoroutine 会暂停当前协程,等待你手动调用 resume 或 resumeWithException 才恢复执行。加了这层封装之后,你可以在更上层用同步语义写逻辑:
suspend fun refreshTokenIfNeeded() { val token = pushTokenRepository.getLocalToken() if (token.isNullOrEmpty()) { val newToken = fetchFcmToken() pushTokenRepository.saveLocal(newToken) serverApi.reportToken(newToken) } }看起来是顺序代码,实际上是异步执行,不会阻塞主线程。这比一长串回调好读太多。新学 Kotlin 的朋友对 suspendCoroutine 有点畏惧,我用一个生活化的类比解释:你点完外卖,手里拿的不是手机而是号码牌,骑手到了给你打电话,你才去取餐。suspendCoroutine 就是那个号码牌,resume 就是骑手的电话,号码牌不响,协程就停在那里不往下走。
3.3 第三步:消息解析与数据模型——服务端字段别裸奔
推送 payload 是最容易放飞的 JSON,因为服务端和客户端经常是两个团队维护。我在这个项目里坚持用 Kotlin serialization 定义一份强类型模型,服务端下发前先约定好 schema。
@Serializable data class PushPayload( val notifyId: Int = 0, // 服务端生成的通知唯一 ID,用于去重 val title: String, val body: String, val type: PushType = PushType.GENERAL, // 消息类型 val targetUrl: String? = null, // 点击要打开的页面地址 val imageUrl: String? = null, // 大图样式用 val extra: Map<String, String> = emptyMap() ) @Serializable enum class PushType { ORDER, CHAT, SYSTEM, GENERAL }enum class 配合 data class 的好处是,后续如果要加新消息类型,编译器会强制要求所有对 PushType 做 when 的地方重新审视一遍。我在项目里把类型判断集中在一个地方,避免两个页面各写一套自定义解析。
此外,notifyId 是个通常会漏掉的字段。服务端同一活动可能会推两三次同内容消息,客户端如果没有幂等控制,用户会看到状态栏堆了一排一模一样的内容。拿 notifyId 做去重,配合 PendingIntent 的 requestCode 逻辑,能把这类体验问题一次性解决。
3.4 第四步:状态栏通知的完整样子——渠道、样式与点击意图
到这里才轮到 NotificationManager 上场。先把最基础的发送封装好:
object PushNotifier { private const val NOTIFICATION_TAG = "push_notification" fun notify(context: Context, payload: PushPayload) { // Android 13 及以上需要运行时权限 if (Build.VERSION.SDK_INT >= 33 && ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED ) { return } ensureNotificationChannels(context) val contentIntent = buildContentIntent(context, payload) val notification = NotificationCompat.Builder(context, channelIdFor(payload.type)) .setSmallIcon(R.drawable.ic_stat_push) .setContentTitle(payload.title) .setContentText(payload.body) .setStyle(buildStyle(payload)) .setPriority(priorityFor(payload.type)) .setContentIntent(contentIntent) .setAutoCancel(true) .setShowWhen(true) .build() NotificationManagerCompat.from(context) .notify(NOTIFICATION_TAG, payload.notifyId, notification) } }这段代码清晰但是缺关键逻辑,主要有三处要展开。
第一处是 notificationChannel 的创建。Android 8.0 之后,通知必须挂在某个渠道(channel)上,否则不显示。渠道创建后重要性等级不可改,想把“重要消息”和“营销消息”用不同声音和打扰强度区分,必须在第一次创建时设计好。下面是渠道创建函数:
private fun channelIdFor(type: PushType): String = when (type) { PushType.CHAT -> CHAT_CHANNEL_ID PushType.ORDER -> IMPORTANT_CHANNEL_ID else -> DEFAULT_CHANNEL_ID } fun ensureNotificationChannels(context: Context) { val manager = context.getSystemService(NotificationManager::class.java) manager.createNotificationChannel( NotificationChannel(DEFAULT_CHANNEL_ID, "普通通知", NotificationManager.IMPORTANCE_DEFAULT) ) manager.createNotificationChannel( NotificationChannel(IMPORTANT_CHANNEL_ID, "重要通知", NotificationManager.IMPORTANCE_HIGH) ) manager.createNotificationChannel( NotificationChannel(CHAT_CHANNEL_ID, "聊天消息", NotificationManager.IMPORTANCE_HIGH) .apply { description = "收到聊天消息时提醒" } ) }第二处是 buildContentIntent,这里决定用户点击通知后去哪里。常见目标有两个:打开 MainActivity 然后带参数,或者通过深链跳转指定页面。实现方式用的是 PendingIntent:
private fun buildContentIntent(context: Context, payload: PushPayload): PendingIntent { val type = if (payload.type == PushType.CHAT) { Intent(context, ChatActivity::class.java) } else if (payload.targetUrl != null) { Intent(context, WebViewActivity::class.java).apply { putExtra("url", payload.targetUrl) } } else { Intent(context, MainActivity::class.java) } type.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP type.putExtra("notifyId", payload.notifyId) return PendingIntent.getActivity( context, payload.notifyId, // requestCode 取唯一值,避免覆盖 type, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) }这里必须强调 FLAG_IMMUTABLE。Android 12 开始,不设置这个 flag 的系统应用如果意外获取了你的 PendingIntent 就能修改参数,Google Play 也会直接拒审。FLAG_UPDATE_CURRENT 的作用是让你重复传入相同 intent 时保留原请求,这个组合在通知场景里已经是标配。
第三处是通知点击后的分发。很多人直接把跳转 Activity 写死在 Intent 里,但复杂项目的跳转逻辑通常要统一管理。建议加一层点击路由:
sealed interface PushAction { data class OpenPage(val page: String) : PushAction data class OpenUrl(val url: String) : PushAction data object OpenChatDetail : PushAction data object DoNothing : PushAction }在接收端做一次 where 分发,后续要加行为只要改这里一处。这样思路与真正的入口解耦:通知栏只负责提醒,不负责业务判断。
3.5 第五步:回调式监听也能用 Flow 重新包装
推送模块除了接收系统消息,还有大量来自厂商 SDK 的监听器注册,比如收到厂商回执、收到透传内容。这类回调型 API 和协程之间有一个很好用的中间件:callbackFlow。
举个例子。假设厂商推送 SDK 提供了一个普通的监听器接口:
interface VendorMessageListener { fun onMessage(message: String) fun onError(code: Int, message: String) }我可以用一个扩展函数把它转成 Flow:
fun observeVendorMessages(source: VendorPushManager): Flow<String> = callbackFlow { val listener = object : VendorMessageListener { override fun onMessage(message: String) { trySend(message) } override fun onError(code: Int, message: String) { close(CancellationException("vendor push error: $code $message")) } } source.register(listener) awaitClose { source.unregister(listener) } }看起来有点绕,其实 callbackFlow 的语义就是“把监听器变成数据流源”。等协程取消时,awaitClose 块负责清理监听器,避免内存泄漏。之后你在 ViewModel 里就可以用 Flow 的 map、filter 做链式处理,还能用 debounce 做消息合并,这对消息风暴场景非常管用。
4. 从“弹出来”到“好用”:一点一点打磨
4.1 Android 13 通知权限,别再只适配到 API 33 之前
Android 13(API 33)上线后,通知权限从安装时自动授权改成了运行时权限:Manifest 里声明 POST_NOTIFICATIONS 之外,还必须在代码中主动请求。问题在于很多老项目只把权限加到 Manifest,忘了运行时请求,导致 Android 13 及以上设备完全接收不到通知。
请求时机也很关键。不要在 App 启动时就弹权限,用户会反感。最好在第一次真正需要推送的时候弹,比如用户登录成功、或者进入消息页面时。示例:
fun requestNotificationPermission(activity: Activity) { if (Build.VERSION.SDK_INT >= 33) { activity.registerForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> // 拒绝的话可以后续引导用户去系统设置里开 }.launch(Manifest.permission.POST_NOTIFICATIONS) } }如果用户第一次拒绝,系统会限制再次弹窗的次数,所以要珍惜首次请求机会,配合业务说明。
4.2 通知渠道一旦创建就改不了,设计的时候要慎重
这是一个看起来不起眼、实际上很影响后续运营的坑。渠道的问候语、声音、重要性只能在创建时设置,创建之后如果用户在系统设置里改过,你的代码再怎么 update 都不会覆盖用户选择。
我在项目里花了 15 分钟规划了三个渠道:默认通知、重要通知、聊天消息。聊天的打扰级别最高,有铃声;营销类的优先级最低,只震动不响。上线一周后运营想再加一个“活动通知”渠道,结果发现只能新增,已有的改不了。所以设计渠道不妨一步到位,宁可多规划一个备用渠道,也不要事后补。
渠道还要配合 Android 的“通知冷落”机制,8.0 之后用户能在长按通知时快速关闭指定渠道,再也不会被打扰。这对消息触达率是明显利好,客户端不需要做复杂的设置界面,系统帮你搞定了。
4.3 通知样式不是越花越好,但要考虑折叠和可读性
纯文本通知是最常见的,但运营场景很难只用一行小字。大段活动说明、订单详情、图片推广,分别对应 BigTextStyle、BigPictureStyle、InboxStyle。我用一个 buildStyle 函数统一处理:
private fun buildStyle(payload: PushPayload): NotificationCompat.Style? { return when { payload.imageUrl != null -> { NotificationCompat.BigPictureStyle() .bigPicture(BitmapFactory.decodeStream(URL(payload.imageUrl).openStream())) } payload.body.length > 50 -> { NotificationCompat.BigTextStyle() .bigText(payload.body) } else -> null } }这里有个隐蔽的坑:如果图片下载放在主线程,Doze 模式下可能直接报 NetworkOnMainThreadException 或卡顿。实际工程要用协程先下载图片到临时文件,同时在图片加载失败时优雅降级为普通文本,不要因为一张图让整条通知都崩掉。
4.4 深链跳转:让通知不只打开首页
App 已经进入“用户点进来就是精确页面”的时代。如果一条订单提醒点开还是首页,用户和产品经理都会失望。Kotlin 配合深链的标准做法是 AppLinks 或者自定义 scheme:
<intent-filter> <action android:name="android.intent.action.VIEW" /> <category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.BROWSABLE" /> <data android:scheme="myapp" android:host="order" /> </intent-filter>收到 push 后,构造这种 Uri:
val deepLinkUri = "myapp://order?orderId=${orderId}&from=push" val openPageIntent = Intent(Intent.ACTION_VIEW, Uri.parse(deepLinkUri))然后包进 PendingIntent。这样即使通知模块完全不知道 MainActivity 的内部结构,也能精准地把某个 Activity 拉起。衔接上面提到的 PushAction 密封类,解析过程就是一次分支流转。
4.5 前台服务与通知要同时管:别让服务被系统干掉
如果你的 App 在做后台下载、语音通话类任务,会用到前台服务和持久通知。这个场景“通知”本身的含义从消息提醒变成了服务存活标识,用户要么看见一个常驻通知,要么服务被系统杀掉。Kotlin 写前台服务的核心管理逻辑比 Java 简洁不少:
private fun startRecordService(context: Context) { val notification = NotificationCompat.Builder(context, FOREGROUND_CHANNEL_ID) .setContentTitle("正在记录轨迹") .setContentText("为了保障实时定位,请勿清理该应用") .setSmallIcon(R.drawable.ic_location) .setOngoing(true) .build() ContextCompat.startForegroundService(context, Intent(context, TrackService::class.java)) }注意 Android 14 对前台服务类型有强管控,使用时要区分 foregroundServiceType 并声明对应权限。这个话题和推送相关性弱一些,但做服务类 App 一定会遇到,提前了解能少走弯路。
5. 常见问题与排查技巧实录
5.1 通知“完全没有反应”的常见可能性速查
推送通知弹不出来,是我在技术交流群里被问得最多的问题。原因不外乎下面几类,我做了张速查表方便对照:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 前台能收到,后台收不到 | 厂商推送未接入或 FCM 在国内不可用 | 确认设备是否有 GMS,检查厂商通道 |
| 通知栏有消息,但无声音/无震动 | 渠道重要性设置成了 IMPORTANCE_LOW | 查看系统设置里的渠道详情 |
| Android 13 点授权后仍然不显示 | 用户可能在系统设置里关掉了渠道 | 引导打开设置页手动开启 |
| 点了通知没跳转 | PendingIntent 的 requestCode 冲突 | 换用 notifyId 做 requestCode |
| 消息时有时无,重启后消失 | 服务被系统回收 | 检查是否是前台服务场景 |
| 通知到达但瞬间消失 | autoCancel 和 onDelete 逻辑互相干扰 | 检查是否手动 clear 了同 ID 通知 |
这类问题大部分不是 Kotlin 代码的问题,而是 Android 系统版本差异和 ROM 行为差异。遇到问题第一步先确认:你测试的设备是什么版本、什么 ROM,权限开了没有,渠道是不是被系统静默降级了。
5.2 协程里调用 Push API 的几个细节坑
Kotlin 协程虽然好用,但在调用系统推送相关 API 时也有反直觉的地方。
第一个坑是 Main 线程受限。FirebaseMessaging.getInstance().token 是完全异步的,但如果你拿到的 token 是 suspendCoroutine 手动恢复,恢复时默认协程上下文仍是外层。如果外层是 Dispatchers.Main,结果没问题,但如果外层是 Dispatchers.IO,而你在 resume 之后直接操作 SharedPreferences,也不会崩,因为 resume 的恢复点不会改变线程——只有在 resume 之后继续用 withContext(Dispatchers.Main) 切回主线程才能安全操作 UI。千万别图省事在 suspend 函数里偷偷开 Thread。
推荐做法是:业务协同层统一用 withContext 包裹 IO 操作,UI 观察一律 main 线程。
第二个坑是 cancel 与 resume 的冲突。用 suspendCoroutine 封装回调时,如果协程在回调返回前被取消,你的 continuation 可能已经失效,这时候盲目 resume 会抛 IllegalStateException。稳妥的写法是用 CancellableContinuation:
private suspend fun fetchTokenCancellable(): String = suspendCancellableCoroutine { continuation -> continuation.invokeOnCancellation { // 在这里注销回调,清理资源 } FirebaseMessaging.getInstance().token.addOnCompleteListener { if (it.isSuccessful) continuation.resume(it.result) else continuation.resumeWithException(...) } }这么处理之后,协同取消时不会埋雷。这个细节在课堂或比赛代码里未必考到,但它能预防线上偶发崩溃。
第三个坑是通知里加载网络图片。在 onMessageReceived 里直接 decodeStream 网络图片,很容易触发 StrictMode 崩溃。正确做法是把图片下载放到协程中,然后 retry 一次失败情况,再降级为文字展示。下载图片时不建议用原生 HttpURLConnection,项目里已经有 OkHttp 就直接用 OkHttp + coroutine。
5.3 后台限制与厂商 ROM:推送可靠性要提前说清楚的事
我最常提醒团队的一句话是:就算 Android 系统标准很统一,厂商 ROM 依然能改变推送的存活率。小米、华为、OPPO、vivo 都有自己的“应用冻结”策略,如果用户没有在系统设置里手动把 App 设为“允许后台运行”,系统可能会在息屏后杀掉进程,厂商的推送通道虽然能收到系统级推送,但客户端如果想要在后台做进一步处理(比如更新角标),发现进程已经没了。
应对思路有几个方向:接入厂商 SDK 时严格按对方文档注册,尽量用厂商提供的“服务+推送”组合;不要试图绕过系统限制在后台长驻进程,既不符合合规要求也容易导致用户卸载;重要消息走厂商高优先级通道,保证通知栏可达。
这个问题上技术能做的有限,产品预期也要合理。很多 App 把 PUSH 当作全时段强触达工具,这本身就是误解。Android 的省电机制越来越大,用户对通知的容忍度也越来越低,设计消息策略时要有取舍。
5.4 微小的体验改进:幂等、统计与手动触发
最后补几个我自己项目里做了之后很值的细节。
通知幂等。上面提到过 notifyId,Service 端最好保证同一活动下发的 notifyId 一致。客户端收到新消息后,如果状态栏里已有相同 notifyId,可以选择 update 而不是新增一条。代码里唯一的动作是 notify(tag, id, notification) 时传入相同 id,系统自动替换。
通知点击统计。Push 消息点击率是运营的命根子,客户端在用户点击 PendingIntent 触发入口时埋点。可以用 BroadcastReceiver 替换 Activity 作为点击中转,也可以简单地在目标 Activity onCreate 里读取 extra,把参数带上报。
手动测试链路。开发环境很难频繁依赖真实服务端发消息,我在 Debug 模式加了一个测试入口:点击按钮后本地构造一个 PushPayload,走同一个 PushNotifier 展示,同时模拟点击路由。这样不用启动 Firebase 也能覆盖 80% 的 UI 流程。这个测试入口在技能比赛和入门练手项目中更是必要,毕竟评审往往不会给你搭好完整服务端。
6. 写在最后:这次项目沉淀下来的几句话
坦白讲,推送通知功能并不难,难的是在系统版本碎片化、厂商 ROM 各自为战的环境下做到稳定可控。Kotlin 给我的帮助不是某一行魔法代码,而是它的语言设计天然适合处理“异步加状态”这种业务场景。协程让消息链路的代码变平了,数据类让 JSON 不再裸奔,密封类让点击行为变得可以被编译器检查。这些能力叠加在一起,才有了标题里那四个字:助力移动开发。
最后分享两个实操心得。第一,推送模块尽量保持独立,不要和业务逻辑强耦合,一个 PushService、一个 PushNotifier、一个 PushRouter,收敛成三个类,后续接入厂商 SDK 或调整消息样式都方便。第二,学 Kotlin 时不要只刷语法题,找一个推送通知这样自带异步回调、网络请求、系统 API、UI 跳转的完整场景练手,进步速度会快很多。我自己也是这么过来的,这个项目做完,协程和数据类的理解才算真正落地。