Kotlin移动开发实战:推送通知全链路实现与协程应用
1. 项目整体思路Kotlin 和推送通知的搭配逻辑1.1 推送通知在移动应用里的真实定位推送通知是移动应用里最容易被低估的功能。很多刚接触移动开发的人会以为它只是“服务端发一条消息手机弹一个通知”真正上手之后才发现从注册通道、获取设备令牌、服务端下发、客户端接收到通知渠道、点击跳转、厂商限制任何一环出问题都会直接表现为用户收不到消息。这个项目叫“Kotlin 助力移动开发实现推送通知功能”名字看起来直白但背后其实覆盖了一条完整的推送链路。在我这次做的移动应用里推送承担的任务不止是“提醒用户”业务下单后的状态变更需要实时通知运营活动需要批量触达用户会话超时需要提醒续费。也就是说推送一旦出问题用户流失会非常明显。这也是我为什么选择把推送功能单独拎出来做一次完整重构的原因——它值得被当作一个独立模块来设计而不是顺手写在 Activity 里的一堆代码。这个项目适合两类人看一类是刚接触 Kotlin、准备系统学习移动开发的人可以借推送这个真实业务场景理解 Kotlin 的协程、扩展函数、空安全到底是怎么用的另一类是已经在做移动端开发、但推送模块一直靠复制粘贴别人的代码来维护的人可以参考这里的链路梳理和问题排查思路把自己的项目从“能跑”优化成“稳定”。1.2 Kotlin 在这个场景里比 Java 强在哪如果你的项目是从 Java 时代走过来的推送模块通常是最有“历史感”的一块代码长长的回调接口、到处可空的 token、写不完的 null 判断。Kotlin 解决的问题第一刀就砍在最痛的空安全上。推送链路里最容易崩的地方恰恰是“返回值可能为空”的问题获取设备令牌失败返回 null、消息体里缺字段、Intent 取不到 extra都会引起崩溃。Kotlin 的可空类型和 safe call 语法能把这一类问题在编译期就排除掉一大半。比如获取 token 时如果值为 null直接用 elvis 操作符给一个默认空字符串不需要像 Java 那样写一堆嵌套的 if 判断。再者就是协程。推送场景天然是多异步的注册、刷新 token、处理服务端消息、写日志、更新数据库每一步都有回调。Java 时代用 Handler 或者回调嵌套代码逻辑读起来非常痛苦Kotlin 协程能把回调式代码平铺成顺序结构可维护性明显高一个档次。后面在“用协程把推送回调改造成挂起风格”那一节我会专门展开讲一个实际案例。还有扩展函数。通知栏样式、消息去重、字符串解析这些操作都可以用扩展函数收拢成公共库让业务代码保持干净。当然 Kotlin 也不是完全没有代价协程的调度模型需要学习成本Kotlin 版本更新快依赖冲突的概率比 Java 工程更高。这个项目里我就踩过一个由协程版本不一致引起的构建问题后面在常见问题那一节也会提到。2. 推送链路与整体架构先搞清楚消息是怎么走的2.1 一条推送从服务端到手机屏幕的完整流转要实现推送通知第一件事是弄明白一条消息从服务端到手机要经过哪些环节。这里我用最常见的推送服务来举例业务服务器把消息发送给推送服务商提供的网关网关负责把消息投递到目标设备手机上安装的应用会维持一个与网关的长连接收到消息后由客户端代码决定怎么弹通知、存数据、跳页面。三个环节里“服务端到网关”的发送通常走推送服务商提供的 API“网关到设备”这一段是服务商维护的长连接通道不受应用开发者控制。真正由移动端开发者控制的只有两端服务端发什么格式的消息以及客户端收到之后做什么处理。所以排查“为什么通知没弹出来”的时候第一反应不应该是改代码而是先定位问题到底出现在哪一段。推送消息在格式上分成两类这个必须分清。一类是“通知消息”由推送平台负责展示应用不做任何处理也能在通知栏看到结果另一类是“数据消息”推送平台只负责转发数据不负责展示所有界面上的动作都要由应用自己完成。这两类消息在实际项目里经常混合使用。我个人的建议是需要给用户看的展示型通知走通知消息链路但凡需要在应用内做逻辑处理的消息——比如存数据库、刷新页面、弹自定义样式——一律走数据消息链路。这里有一个非常常见的坑服务端把业务参数塞在通知消息的标题和内容字段里客户端想拿出来做逻辑处理时只能去解析文本一旦服务端改了文案客户端就跟着坏。正确做法是让服务端把业务参数放在 data 字段里客户端在 onMessageReceived 中统一从 data 里取。2.2 用协程把推送回调改造成挂起风格在 Android 推送场景里回调式 API 非常多设备令牌刷新回调、消息到达回调、注册结果回调还有蓝牙、定位这类外围设备回调。如果项目用了很多这类 API代码很容易陷入“回调地狱”。我在这类项目里都会把“回调转挂起”做成一个通用工具类。核心做法是使用 Kotlin 标准库里的 suspendCancellableCoroutine把一次性回调包装成挂起函数让业务代码可以用同步的写法表达异步流程。class CallbackToSuspendT { private var callback: ((T) - Unit)? null fun setCallback(cb: (T) - Unit) { callback cb } fun invoke(value: T) { callback?.invoke(value) } } suspend fun T CallbackToSuspendT.await(): T suspendCancellableCoroutine { continuation - setCallback { value - continuation.resume(value) } }使用时把第三方库的回调值传入这个工具类业务侧直接调用 await() 拿到结果。这里有一个关键点回调可能永远不来如果协程没有超时保护就会一直挂起造成任务泄漏。所以我在实际项目中习惯给所有挂起回调都套一个 withTimeout比如 5 秒没有回调就按失败处理。改造之后最大的好处是在收到推送消息之后可以把一连串操作按顺序写下来先解析数据再存本地数据库然后构建通知最后上报送达统计。中间如果涉及网络请求直接切换调度器到 IO 线程整个流程读起来和同步代码几乎没有区别排查问题的时候也容易多了。3. 项目实操从工程配置到第一条通知送达3.1 工程依赖配置与基础权限实操部分从我推荐的最小工程依赖开始。如果你在 Android Studio 里新建一个项目build.gradle 里至少要包含这几个依赖dependencies { implementation org.jetbrains.kotlin:kotlin-stdlib:1.9.22 implementation androidx.core:core-ktx:1.13.1 implementation androidx.appcompat:appcompat:1.6.1 implementation com.google.firebase:firebase-messaging:23.4.1 implementation org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3 implementation fileTree(dir: libs, include: [*.aar]) }很多初学者会在这一步栽跟头只写了依赖却忘了在项目级 Gradle 文件里配置推送服务的插件也忘了把服务商提供的配置文件放到 app 目录。推送 SDK 初始化时会去读这些配置配置缺失时要么直接崩溃要么静默收不到消息。权限声明也需要提前处理好。虽然通知权限在 Android 13 之后变成了运行时权限但基础权限需要在 AndroidManifest.xml 中声明。uses-permission android:nameandroid.permission.INTERNET / uses-permission android:nameandroid.permission.POST_NOTIFICATIONS /3.2 获取设备令牌并交给服务端推送网关要往目标手机发消息必须先知道这台设备是谁——设备令牌就是设备的唯一标识。在 Kotlin 里获取令牌的常规做法是调用推送服务提供的接口因为是异步操作我会用协程来组织这段代码。private fun fetchToken() { val scope CoroutineScope(SupervisorJob() Dispatchers.Main.immediate) scope.launch { val token withTimeout(5000) { FirebaseMessaging.getInstance().token.await() } if (token.isNotEmpty()) { sendTokenToServer(token) } }.invokeOnCompletion { scope.cancel() } }这里要注意三点。第一token 不是一成不变的应用卸载重装、用户清数据、系统服务更新都可能导致 token 变化所以要在服务里监听令牌刷新事件刷新后把新 token 同步到服务端。第二同步时机要控制好不要在每次 Activity 启动时都重新上传建议把 token 存到本地只有检测到变化时才发送请求。第三token 获取可能失败网络异常、服务未连接都会导致空返回所以空安全处理必须到位不能假设 token 一定有效。3.3 在后台消息服务里处理消息并触发通知当设备和网关的长连接正常时推送消息会投递给应用。应用需要继承一个消息接收服务在消息到达回调里处理业务逻辑。这个回调默认运行在主线程上所以耗时操作不能直接写在里面需要切到协程里执行。class PushMessageService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { val data message.data val title data[title] ?: 默认标题 val content data[content] ?: 默认内容 val orderId data[order_id] ?: CoroutineScope(Dispatchers.IO).launch { saveMessage(data) withContext(Dispatchers.Main) { showLocalNotification(title, content, orderId) } } } }在 onMessageReceived 里做逻辑处理时还应该考虑消息的时效性。如果用户已经很久没打开应用推送消息里的链接、订单信息可能早已失效最好先检查时间戳再做展示。另外对于重复消息要通过消息 ID 做去重处理否则服务端重试投递时用户会收到一连串相同的通知。这个去重逻辑在推送实战里非常重要后面在经验总结部分也会再提。3.4 点击通知跳转到目标页面的实现通知弹出来只是第一步用户点击之后能不能跳转到正确的页面这才是口碑的分水岭。实现方式一句话就能说清楚构建一个 Intent 指明目标 Activity把它包成 PendingIntent 挂到通知上需要通过 extra 传递参数同时还要保证应用进程被杀掉之后从通知点击进入时也能恢复用户预期中的页面栈。val intent Intent(this, OrderDetailActivity::class.java) intent.putExtra(order_id, orderId) val pendingIntent PendingIntent.getActivity( this, 0, intent, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT )这里有三个特别容易踩的坑。第一个Android 12 之后PendingIntent 必须显式声明可变性没有写 FLAG_IMMUTABLE运行时直接抛异常。第二个跳转目标 Activity 时要设置 FLAG_ACTIVITY_NEW_TASK 或者用任务栈构建器否则点击通知启动 Activity 时返回键会直接退出应用而不是回到上一页。第三个目标 Activity 的启动模式如果是 singleTop又要考虑 onNewIntent 里的参数处理很多开发者在跳转后发现页面是老的就是因为忘了在 onNewIntent 里重新读取参数。4. 通知样式与用户交互让通知不再千篇一律4.1 通知渠道的设计原则Android 8.0 之后通知必须绑定到一个通知渠道每个渠道有独立的优先级、声音和震动设置用户还能在系统设置里单独调整某个渠道的开关。这意味着渠道划分不好用户大概率会把应用整个通知关掉。我设计渠道时有两个原则按业务类型划分不按页面划分营销类渠道宁可少发也不能让用户反感。拿这个项目里的渠道举例渠道名称业务用途优先级声音震动订单消息订单状态变更高开开营销活动运营触达默认关关系统公告版本更新、安全提醒低关关这个设计直接影响运营效果营销内容占满通知栏会让用户一键关停所有推送所以营销渠道必须限制频率比如一天最多两条而订单渠道消息要尽可能可靠即使用户在免打扰模式下也应该通过高优渠道在系统层面争取展示机会。Kotlin 里创建渠道的代码很直接if (Build.VERSION.SDK_INT Build.VERSION_CODES.O) { val channel NotificationChannel( order, 订单消息, NotificationManager.IMPORTANCE_HIGH ) channel.description 订单状态变更提醒 val manager getSystemService(NotificationManager::class.java) manager.createNotificationChannel(channel) }这里要特别提醒渠道创建之后名字和优先级不能直接修改。如果后续想改只能用新渠道 ID这会让旧通知失去归属。所以上线前就要想清楚不要上线之后再来调整渠道。创建渠道时注意检查 SDK 版本老设备上调用 NotificationChannel 相关 API 不会崩溃但你最好还是把版本判断写全未来维护的时候会少很多隐患。4.2 自定义通知布局与富媒体样式默认通知显示标题、内容和应用图标但很多时候业务需要更丰富的展示大段落文本、图片预览、进度条、操作按钮。Kotlin 的扩展函数特别适合封装这一类样式逻辑。举个例子构建一个展示长文本的大文本通知val style NotificationCompat.BigTextStyle().bigText(longText) val notification NotificationCompat.Builder(this, order) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(title) .setContentText(content) .setStyle(style) .setAutoCancel(true) .setContentIntent(pendingIntent) .build()建议给通知加上“已读”或“立即处理”这样的操作按钮。操作按钮和通知本体要用不同的 PendingIntent 区分开不要让点击按钮也触发页面跳转。按钮触发的逻辑一般是一个广播接收器在里面更新数据库并把通知取消掉。还有一个细节如果同时弹出多条同一类型的通知默认行为是依次叠加通知栏会越来越长。不优雅也容易引起用户反感。建议对同类型消息做通知合并同一订单的多条状态变更只保留一条最新通知把历史信息折叠在详情里。实现方式非常简单发通知时传一个稳定的通知 ID新通知会直接替换旧通知。4.3 前台服务与通知的联动如果应用有后台下载、导航、音乐播放这类长任务需要通过前台服务来保证任务不被系统快速回收而前台服务必须绑定一条持续显示的通知。放在推送场景里这句话的意思是即使用户限制应用后台运行只要前台服务还在推送通道就能尽量维持。用 Kotlin 写前台服务时启动后需要立刻调用 startForeground 并传入通知。这里有一个很隐蔽的崩溃点如果应用在后台启动前台服务而通知渠道被用户关闭部分机型会抛异常。所以前台服务最好单独使用一个渠道而且这一条通知也要做成不可滑动的形式否则用户把通知滑掉之后系统会再补一条“应用正在运行”的提示体验反而变差。val notification NotificationCompat.Builder(this, foreground) .setSmallIcon(R.drawable.ic_notification) .setContentTitle(正在连接推送服务) .setOngoing(true) .build() startForeground(1001, notification)做前台服务时还要考虑一个现实问题通知内容更新频率。如果在你做的是音乐播放这类持续任务每次播放状态变化都更新通知可以但推送服务类的通知不建议频繁更新因为每次更新都会触发一次系统级的动画和资源占用。保持简单稳定就好。4.4 用 Spinner 做通知偏好设置讲到用户交互这里顺手补充一个 Kotlin 里很容易用到的 UI 场景在设置页里让用户选择通知渠道级别比如“重要通知 / 普通通知 / 全部关闭”。最自然的方式就是用下拉列表 Spinner。许多初学者会在 Spinner 的选择事件上卡住其实 Kotlin 写法非常简单val spinner: Spinner findViewById(R.id.spinner_notify_level) spinner.onItemSelectedListener object : AdapterView.OnItemSelectedListener { override fun onItemSelected(parent: AdapterView*?, view: View?, position: Int, id: Long) { val level parent?.getItemAtPosition(position).toString() saveNotifyLevel(level) } override fun onNothingSelected(parent: AdapterView*?) { // 用户未选择时保留默认值 } }要注意的是 onItemSelected 在 Spinner 初始化时就会回调一次如果你在这里做了“保存设置”操作用户还没动手设置就可能把你写在初始化时的默认值覆写掉了。正确做法是加一个布尔标记第一次回调时只记录默认值不做保存。这个坑很典型稍微不注意就会出现“设置了但没有生效”的假象。5. 常见问题与排查技巧实录5.1 通知完全不显示先从这几步查收不到通知是最常见的求助类型。按我积累的经验90% 的情况出在下面几个地方没有打开应用的通知权限Android 8.0 以上没有创建渠道服务端推送的包名、令牌与设备不匹配应用进程被系统彻底杀死消息体带了不该带的字段导致客户端没有走数据处理逻辑。排查时按顺序来先到系统设置里确认应用的通知开关确实是打开的再看代码里渠道创建是不是真的在发通知之前执行了然后去推送服务商的控制台查发送记录。如果控制台显示消息已到达但手机没有弹通知那基本确定是客户端逻辑的问题如果显示发送失败那多半是令牌失效或者消息格式不符合服务商要求。提示不要一上来就改代码。先利用控制台日志确认消息到底有没有到达能省掉大量无意义时间。5.2 推送延迟与厂商后台限制在国内的移动开发环境里推送延迟是一个绕不开的话题。各家手机厂商都有自己的后台管理策略应用被用户划掉之后系统经常会把推送进程冻结。这个问题的最优解是在依赖推送服务商通道的同时接入各厂商的系统推送通道。接入厂商通道的步骤不算复杂:下载厂商推送 SDK在推送服务商后台申请厂商密钥然后在代码里注册厂商消息接收扩展在扩展回调里做消息透传。有一个容易忽略的坑不同厂商的消息优先级、标题字段和数据格式可能不一样测试的时候必须使用对应机型的真机。模拟器和跨机型安装测试测不出厂商限制的问题。实测时还要注意目标设备上有没有安装厂商自家的推送服务如果没有厂商通道也可能会失效需要做降级处理。5.3 Kotlin 协程在推送场景里的典型错误这个项目里我踩过两个比较典型的 Kotlin 协程坑值得专门拿出来说。第一个是把 CoroutineScope 挂在 Service 或 Activity 上之后忘了取消。比如在 onMessageReceived 里启动了一个协程消息服务被系统回收之后协程还在后台跑轻则浪费资源重则操作一个已经销毁的组件直接崩溃。第二个是把需要在后台执行的长任务硬放在 Dispatchers.Main 上跑结果主线程被卡住通知栏动画、点击响应都跟着变慢用户感知非常明显。另外还有个隐蔽问题FirebaseMessagingService 的 onMessageReceived 回调本身声明在主线程。如果直接在里面调用 withContext(Dispatchers.IO) 还好但如果在这个回调里直接创建一个新的协程且不指定调度器它默认继承的上下文会影响后续线程切换。我的建议是不需要更新界面的任务用全局作用域加超时需要更新界面的任务使用与界面生命周期绑定的作用域并且统一在入口处切换到 IO 调度器。5.4 常见问题速查表现象可能原因排查动作收不到通知通知权限未开启进入系统设置手动开启通知未弹但控制台有送达记录缺少通知渠道检查渠道创建代码是否在发通知前执行点击通知闪退Intent 参数确实或目标页未注册对 extra 做空值保护检查 Manifest 注册推送延迟严重应用被进程冻结接入厂商通道并确认设备厂商推送可用token 频繁变化系统服务更新或数据被清监听 token 刷新事件同步到服务端6. 实测心得与后续扩展建议6.1 我在这个项目里最受益的三个习惯第一把推送相关操作全部收敛到一个模块里。无论获取 token、发送本地通知、记录送达数据都不要散落在各个页面里。Kotlin 的 object 单例类很适合做这件事后续维护时只需要开一个文件就能看到推送全貌。第二一定要记录本地送达日志。我之前排查“订单通知为什么没弹”时查了半天服务端最后发现是消息体里的订单 ID 为空导致客户端走了异常分支。如果客户端在收到消息时把消息 ID、到达时间、解析结果写进本地日志这类问题定位会快得多。第三所有可能为空的字段都用 Kotlin 的可空类型加默认值处理。服务端字段结构变化是常态不要假设服务端永远按文档返回。写的时候多花一分钟做兜底线上就能少一次崩溃告警。6.2 推送功能后续可以扩展的方向基础推送跑通之后还有不少可以深挖的点离线消息补推、按用户活跃度动态调整推送频率、深链接统一处理、消息幂等去重以及把推送接入层抽象成接口方便未来接入不同推送服务商。如果你打算继续深入我建议先做“推送消息的幂等处理”。服务端投递可能重复客户端必须做到重复消息不重复弹窗尤其是营销类消息一旦重复就会明显影响用户体验。常规做法是拿消息 ID 做唯一键每次收到消息先查本地去重表存在就直接丢弃。这个逻辑虽然简单但能避免掉大部分客诉。最后再分享一个扩展建议设置页里用户可以通过之前说的 Spinner 选择通知级别这看起来简单但对用户留存帮助很大。用户主动降低推送频率总比一键关闭所有通知好得多。把选择权交给用户推送功能才能真正为业务创造长期价值而不是变成用户卸载应用的理由。我个人在实际项目里的体会是推送功能表面上看是技术问题实际上每一步都牵涉产品决策和系统限制的理解。Kotlin 能帮你把客户端代码写得更简洁、更可靠但整条链路的稳定还需要你真正理解服务端、客户端和厂商系统三者之间的协作方式。这篇文章里的方法都是我在真实设备上一轮一轮测出来的希望能帮你少踩几个坑。