做Flutter安卓App的人,基本都会在一个节点被“推送”卡住:App上线了,用户要收到订单提醒、审批通知、活动公告,服务器总不能一直挂在后台轮询;业务方还动不动来一句“怎么我手机收不到消息?”。这篇文章要解决的,就是把极光推送和本地通知在Flutter安卓端一起接好,让“服务端下发的消息”和“本手机该弹的提醒”各司其职,也把我在真实项目里踩过的坑一次性倒出来。
Flutter接入极光推送这件事,网上的教程大多是抄官方readme,能跑到回调就已经算成功,但真正在实际项目里跑通“远程推送触发本地通知、点击通知跳转指定页面、系统权限掉链子”这些组合场景,还是有门槛的。这篇文章不只讲插件怎么初始化,会把控制台配置、AndroidManifest、权限适配、通知渠道、路由跳转、常见坑位都拆开讲。适合准备上推送、或者已经在接推送但被线上用户反馈折磨的同学。
1. 方案先想清楚:极光推送和本地通知是什么分工
1.1 远程推送解决“触达”,本地通知解决“呈现”
先分清两件事。极光推送解决的是“网络触达”,它把服务端的消息通过极光的通道推到手机上;本地通知解决的是“系统呈现”,它让App在本地直接创建一条系统通知,不经过任何网络。很多人接到一个需求“帮我接入推送”,默认只有极光推送,其实工程里往往还需要本地通知做配合,否则你会在三个场景里寸步难行。
第一个场景是App在前台,想让通知样式统一或者不出系统通知,只在应用内做提示;第二个场景是定时提醒类需求,比如吃药、还款、打卡,这类任务根本不应该依赖服务端下发,客户端本地排期就够了;第三个场景是进程被系统杀掉后,你想让App在下一次启动时根据本地缓存重新补发提醒,远程推送这时候根本帮不上忙。这三个场景,极光推送都替代不了本地通知,它们是互补关系,不是二选一。
从技术链路看,我的做法是:服务端只负责决定“发什么内容、发给谁”,极光SDK负责把消息送到设备,客户端拿到消息后统一走一套“消息处理中心”,该落库的落库、该弹通知的调用本地通知、该跳页面的带上payload去路由。如果收到的是极光自定义消息,那就更简单了,服务端不触发系统通知,我完全用本地通知来控制展示时间和样式。这套思路的好处是把“消息传输”和“消息展示”彻底解耦,后面不管是换极光还是换厂商通道,客户端只需改接入层,展示层完全不用动。
1.2 为什么不自建长连接,非要选极光
自建推送通道这件事我劝你慎重,除非团队有专门的IM基础设施。Android上自建长连接需要自己维护心跳保活、网络切换重连、各厂商的后台限制适配,哪个都是大工程。极光这类专业推送服务已经把国内厂商的兼容性做好了,虽然也不能保证进程被杀后100%送达,但省下的运维成本是实打实的。
选极光还有一个理由:它跟厂商通道是打通的。你可以在极光控制台同时配置华为、小米、OPPO、vivo等厂商的密钥,SDK在对应品牌的手机上会自动走厂商系统通道,这在国产ROM上尤其关键——纯极光长连接在后台被杀后基本收不到,但厂商通道作为系统级服务能活下来。如果你团队的业务主要面向国内安卓用户,这个适配能力是绕不开的。
1.3 先画一条推送数据流,再动手写代码
我习惯在接任何推送SDK之前,先画清楚数据流,避免后面改接口改到哭。标准的一条流向是这样的:服务端调用极光REST API,把通知或自定义消息推送给指定registrationId、别名或标签;极光服务器把消息下发给设备上的JPush SDK;SDK以系统通知形式展示,或通过事件回调把原始数据交给Flutter层;Flutter层拿到后决定是否用flutter_local_notifications重写通知样式,同时把消息体转发给路由去跳转页面。
点击事件又分两种路径:用户点击的是极光SDK弹出的通知,走的是极光的onOpenNotification回调;用户点击的是我用本地通知弹出的通知,走的是本地插件的onDidReceiveNotificationResponse回调。两条路径在Flutter端看起来像是两个入口,但最终都要汇聚到同一个“根据通知类型跳转对应页面”的公共方法上。先把这个流程定下来,后面写代码会非常顺。
2. 环境准备与依赖选型:接入前的硬性条件
2.1 Flutter环境和Android项目基础配置
接推送之前,先把Android工程底子打好。我这里使用的Flutter版本是3.x稳定版,compileSdk和targetSdk建议直接拉到Android 13或以上,因为极光SDK新版本和flutter_local_notifications都开始强制要求较高的SDK版本,太低会导致编译报错或者运行时权限异常。
build.gradle里常见的坑是ndkVersion和abiFilters。极光SDK的so库包含多个ABI,如果工程里为了减包只保留了arm64-v8a,需要确保极光的aar也支持,否则会在个别设备上崩。我用的是默认不限制ABI的方式,然后用AGP的abiFilters统一裁剪,实测对极光没有影响。
工程结构上,建议把推送相关代码单独抽成一个pkg,至少不要全写在MainActivity里。原因很简单,推送回调会贯穿整个App生命周期,你得在冷启动、页面恢复、权限变化等各个状态都处理消息,零散写在页面里后面维护成本极高。
2.2 插件选型:jpush_flutter和flutter_local_notifications
极光官方维护的Flutter插件在pub.dev上是jpush_flutter,这是接入远程推送的最直接选择。它的Android底层就是极光JPush SDK,通过MethodChannel和EventChannel把原生事件抛给Dart层,所以版本更新对我Flutter端来说是透明的,只要关注它适配的JPush SDK版本即可。本地通知这边,pub.dev上社区最活跃的是flutter_local_notifications,它不只是Android能用,iOS、macOS都覆盖,而且支持定时通知和通知渠道管理,算是本地通知的瑞士军刀。
插件版本别乱选,先去pub.dev看jpush_flutter最新版本号跟你的Flutter版本是否兼容。早期有些版本要求Flutter 2.x,直接用在新项目上会出现找不到符号的编译错误。选定版本后,记住两个插件的主版本锁定,不要轻易升降级,因为这两个插件都涉及原生层和Flutter层的通道协议,升级一个往往要连带升级另一个,否则事件回调会丢。
2.3 AndroidManifest配置:权限不是越多越好
极光推送的完整权限列表很长,但理解每个权限干什么比直接抄列表重要。基础的必须要INTERNET、ACCESS_NETWORK_STATE、WAKE_LOCK,这是长连接和网络状态监听的基础;VIBRATE和RECEIVE_USER_PRESENT用于通知震动和亮屏提醒;如果你要处理开机后补发通知,还需要RECEIVE_BOOT_COMPLETED。
Android 13开始多了两个容易漏的权限:本地通知必须申请POST_NOTIFICATIONS,定时通知需要SCHEDULE_EXACT_ALARM。这些光写在AndroidManifest里还不够,运行时也要申请,后面第四章我会专门讲权限适配。极光SDK还要求在Manifest里声明一个自定义权限,并用你的包名做后缀,作用是防止其他应用伪造极光广播,这个字段写错了会导致收不到推送。别为了权限精简把极光的receiver或service移除,SDK的组件都需要在Manifest里显式声明。
Manifest配置完成后,务必检查你的applicationId和极光控制台的包名是否一致,一个字母都不能差。包名不一致是收不到推送的第一大原因,极光服务器按包名匹配应用,你本地改了applicationId但控制台没改,注册出来的registrationId在服务端下发时会直接失败。
3. 极光推送接入实操:从注册到消息回调
3.1 极光控制台配置:先搞定AppKey和包名
代码层面动手前,先去极光控制台注册应用。创建应用的时候会要求填写Android包名,这一步填的包名必须和工程里applicationId完全一致。我当时在这个环节吃过亏,控制台填了applicationId,后面为了上架改了包名,结果推送直接断掉,所以在这里多提醒一句:包名变更后,极光控制台要同步改,且AppKey是不变的。
控制台里每个应用会分配一个AppKey,这是初始化SDK的唯一凭证。同一套代码如果要在多个渠道包之间切换AppKey,最好把AppKey放到启动配置里按渠道区分,不要写死在代码里。Debug和Release如果使用不同AppKey,注意检查混淆和BuildConfig的引用,我见过有人把Debug的AppKey带上线,推送一天就触发限流。
3.2 Flutter端初始化:避开重复初始化的坑
初始化代码看起来简单,但有个细节:必须在runApp之前或第一个页面initState里完成,而且整个进程生命周期只能调一次。我见过有人把初始化放在入口页的initState里,页面被重建时又执行一遍,导致极光SDK重复注册、事件回调被多次监听,点击一条通知进来三个回调同时触发。
推荐的写法是把初始化封装在一个独立的推送服务类里,通过单例模式调用。初始化时可以开启debug模式,这样可以打印极光SDK的注册日志,排查问题会方便很多,正式包再关掉。初始化成功后会通过回调拿到registrationId,这个ID要上报给服务端,服务端才能给你这个设备单独发推送,它相当于设备在推送系统中的身份证。
初始化完成后调用getRegistrationID获取设备标识,但这个接口在冷启动时有概率返回空,稳妥做法是把registrationId缓存到本地,每次进入App时异步去极光获取并对比,发现变化就重新上报服务端。别小看这个细节,极光在某些场景下会自动换registrationId,你不主动更新,服务端发到你旧ID上就永远送达不了。
3.3 事件回调:Dart层的三个入口
极光插件在Dart层暴露了三个核心回调,分别对应通知到达、通知点击和自定义消息到达。onReceiveNotification对应SDK收到推送通知,此时系统通知已经展示;onOpenNotification对应用户点击通知栏,是跳转页面的主要入口;onReceiveMessage对应极光自定义消息,这条消息不会由SDK自动展示,完全由你决定怎么处理。
这三个回调通过EventChannel源源不断传给Flutter,所以如果你用了Provider、Bloc这类状态管理,注意回调触发时组件树可能还没挂载完整,直接用context要非常谨慎,我在项目里是把消息写成全局状态,等页面刷新时再读取。自定义消息的典型场景是“静默推送”,比如App内聊天消息、图书更新的角标提示,服务端用自定义消息下发,客户端视情况决定是否通知用户,这种模式最能体现极光+本地通知的组合优势。
代码骨架大概是这样的:
JPush.addEventHandler( onReceiveNotification: (Map<String, dynamic> msg) { // 收到通知,msg里包含title、content、extras }, onOpenNotification: (Map<String, dynamic> msg) { // 用户点击了通知,这里跳页面 }, onReceiveMessage: (Map<String, dynamic> msg) { // 收到自定义消息,自己决定是否alert }, );我处理onOpenNotification时,会先从extras里取业务类型type和目标id,再查一遍本地缓存,确认这条通知对应的业务数据是否已经存在,如果不存在,先跳详情骨架页并显示loading,等接口返回再渲染。这么做是防止用户点击通知时网络还没恢复,页面白屏。
3.4 别名和标签:让推送更精准的三板斧
极光的别名和标签机制,本质上是给设备和用户之间建映射。别名适合一对一场景,比如用用户ID作为别名,每个用户只能绑定一台设备;标签适合一对多场景,比如按版本、按用户层次、按活动分组推送。我实际的用法是:用户登录成功后setAlias为用户ID,退出登录时clearAlias,避免下一个登录用户收到上一个用户的推送;标签则按“登录用户”、“测试员”、“vip用户”这些粗粒度维度打,方便运营圈选人群。
设置别名和标签也有时序问题。用户登录成功、进入主页面这两个时机都可能有网络延迟,千万别在setAlias后立刻调服务端“推送测试消息”,极光的别名绑定是需要几秒生效的。我踩过这个坑,测试时点了推送没反应,以为代码错了,其实是别名还没生效。出于稳妥,设置别名成功后不要立即依赖它推送,而是用registrationId先做联调,等整个链路通了一次再切换到别名模式。
4. 本地通知实现细节:权限、渠道、定时与路由
4.1 flutter_local_notifications初始化与通知渠道
本地通知插件初始化的核心是InitializationSettings。Android端需要传一个AndroidInitializationSettings,里面指定应用图标,我使用的是@mipmap/ic_launcher,如果你的应用有专门的推送图标,可以单独做一个小图标,通知栏显示效果会好很多。
初始化还有一个关键参数onDidReceiveNotificationResponse,用户点击本地通知时触发。这个回调在冷启动和热启动的表现不同,冷启动时App还没跑起来,插件会把通知的payload存起来,等初始化完成后回调,热启动时直接回调。因此初始化代码必须在入口最早的地方执行,并且要把收到的payload转发给统一路由处理,否则会出现冷启动点击通知无法跳转的经典bug。
通知渠道是Android 8.0引入的概念,你可以把它理解成给不同类型的通知办“分类标签”。极光SDK默认会创建自己的渠道,而flutter_local_notifications可以创建多个渠道,比如订单消息、活动消息、系统消息各一个渠道。用户可以在系统设置里单独关闭某一类通知,但如果不设置渠道,所有通知混在一起,用户只能全量关闭,这对产品来说很伤。
4.2 Android 13权限适配:POST_NOTIFICATIONS绕不过去
Android 13开始,通知权限从安装时自动授权变成运行时权限,这是本地通知最容易踩坑的点。很多用户手机升级到Android 13后突然收不到本地提醒,原因就是没有主动请求POST_NOTIFICATIONS权限。对应到代码,你需要使用flutter_local_notifications的resolvePlatformSpecificImplementation方法去拿Android实现,然后调用requestNotificationsPermission。
请求时机也讲究,不要在App启动时就立刻弹权限,用户会反感;建议在第一次需要弹通知的时候再请求,并且先通过isNotificationsEnabled判断当前是否有权限。在Android 13以下的设备上,这个权限接口不会生效,但也不会报错,所以可以放心调用。极光SDK那边如果没适配Android 13,它自己弹出的通知也会被系统静默丢弃,升级极光SDK版本能解决一大半问题。
4.3 定时通知:时区与精确闹钟权限
定时通知我重点说两个坑。第一个是时区,flutter_local_notifications的zonedSchedule使用timezone包,必须先在main函数里初始化时区:tz.initializeTimeZones(),并设置本地时区tz.local。如果你忘记设置时区,Android默认用UTC,你的“每天晚上9点提醒”可能变成“UTC时间上午9点提醒”,下午才会弹,用户直接懵。
第二个坑是Android 12+的精确闹钟权限。定时任务如果要求准点触发,需要SCHEDULE_EXACT_ALARM权限,但这个权限在Android 12上默认不授予,用户需要手动去系统设置里开启“闹钟和提醒”。如果App只申请权限而不引导用户,定时通知可能会被延迟,不是不弹,是弹得不准。如果你的业务对“准点”不敏感,可以退一步用AndroidScheduleMode.inexactAllowWhileIdle,避开高版本权限限制,系统会在合适时间发出通知,省电也省心。
4.4 点击本地通知跳转页面:payload与路由
本地通知点击跳转的关键是把业务数据编码进payload。flutter_local_notifications的show和zonedSchedule都有payload参数,我一般传一个JSON字符串,包含type和id两个字段,路由收到payload后解析类型再调对应页面。这样处理的优势是即使通知栏已经被系统清空,只要用户点了未清除的那条,依然能回到正确的页面。
跳转还要区分业务深度。如果App进程冷启动,Flutter的Navigator还没有任何页面,直接push会崩;我的做法是先等第一个页面(通常是启动页或主页)挂载完成,然后再push目标页。判断冷启动可以用插件的getNotificationAppLaunchDetails方法,它返回didNotificationLaunchApp字段,App是被通知点击拉起的就为true。用法虽然多一层判断,但能避免线上冷启动崩溃。
5. 远程推送和本地通知的搭配实战
5.1 前台通知:服务端用自定义消息,客户端接管展示
最推荐的搭配模式是:服务端对需要App前台处理的业务,使用极光自定义消息而不是普通通知下发。自定义消息到了客户端不弹任何系统通知,完全由我在onReceiveMessage里处理,想弹本地通知就弹,想走应用内横幅就走横幅,发不发、怎么发都控制在自己手里。这样做的好处是前台逻辑完全可测,不受系统通知展示时机影响。
后台消息则仍然用极光普通通知,依赖极光和厂商通道在进程存活的情况下弹出系统通知。这两种类型在极光控制台是分开发送的,代码层面也只在一个回调里判断消息类型即可。这套模式跑起来后,前台体验和后台送达率都照顾到了,不会顾此失彼。
5.2 离线兜底:本地缓存加定时自查
离线兜底针对的是“服务器想推但用户手机没网”的场景。极光在设备离线时会把通知存一段时间,但用户恢复网络后是否还能收到,取决于厂商通道和极光的策略,没有100%保障。我自己的做法是:对时效性要求高的业务,在客户端本地维护一份“待提醒队列”,每次App进入前台或网络恢复时,拉取服务端未读消息接口,然后根据业务规则重新生成本地通知。
这样即使极光通道因厂商限制丢了消息,用户打开App时也能通过接口补偿收到通知,体验不会断。要注意的是,兜底通知要加去重逻辑,避免用户已经读过消息又被通知提醒一次,我是在本地用消息ID做去重,收到通知前先查一下是否已处理过。
5.3 多渠道分组与用户偏好设置
如果项目里有多种业务通知,强烈建议在设置页里提供通知偏好开关,做这件事情并不复杂,核心就是借助Android通知渠道。用户设置页面里展示“订单通知”“活动通知”“系统通知”三个开关,每个开关对应一个channelId,关闭开关时调用flutter_local_notifications或原生方法去停用对应渠道。
极光自带的系统通知渠道默认可能开着,用户关掉我的自定义渠道并不影响极光渠道的通知,所以如果需要统一开关,最好所有远程通知都通过自定义消息下发,然后用本地通知统一走flutter_local_notifications的渠道管理。这种模式下,通知的最终展示权完全从极光手里收到了自己手里,产品和运营也能更灵活地调整通知策略。
6. 常见问题速查与排障实录
6.1 收不到推送,先按链路排查而不是问文档
收不到推送是最高频问题,我排障时按顺序查五步:第一步看极光控制台推送记录,确认消息是否成功下发;第二步看客户端Logcat里JPush的关键日志,确认SDK是否注册成功;第三步核对AppKey和包名;第四步看手机是否在厂商白名单限制下;第五步看通知权限是否关闭。五步走完,90%的问题都能定位。
其中注册成功与否最重要,Logcat里会打印registrationId相关信息。如果完全没看到极光日志,多半是Manifest里receiver被裁剪了,或者插件初始化没执行。注意使用混淆时,极光SDK要添加keep规则,proguard-rules.pro里保留cn.jpush包,否则SDK的反射调用全军覆没。
还有个小细节:安卓虚拟机怎么联网,这是很多人在模拟器上测试收不到通知的原因。模拟器的网络模式要选桥接或共享模式,并确认SDK能访问公网接口,测试机的推送网络环境和生产环境不一致,优先用真机验证推送时序,模拟器只用来验证UI。
6.2 点击通知没有跳转页面
点击通知没反应,一般先区分是哪条通知。极光的通知点击走onOpenNotification,本地通知点击走onDidReceiveNotificationResponse,两边要分别加日志确认回调有没有触发。如果回调触发了但没跳转,检查冷启动时Navigator状态;如果回调都没触发,多半是回调注册太晚,Post了初始化时机。
本地通知冷启动点击还有一个特殊场景:如果App在通知被点击时才被拉起,插件的getNotificationAppLaunchDetails会在初始化时才返回详情,如果你在main函数顶层就急着做路由跳转,页面还没就绪就会失败。处理办法是把点击事件的业务数据先放入一个全局单例,等首个页面build完成后统一调度跳转。
总结成一张速查表:
| 问题现象 | 常见原因 | 处理办法 |
|---|---|---|
| 收不到远程推送 | AppKey或包名不一致 | 核对控制台配置 |
| 收不到远程推送 | 厂商通道未配置 | 按机型接厂商推送 |
| 通知不弹 | Android 13未授权通知 | 请求POST_NOTIFICATIONS |
| 定时通知不准时 | 未设置时区 | 初始化tz本地时区 |
| 定时通知不弹 | Android 12精确闹钟被限制 | 降级非精确模式或引导授权 |
| 点击通知无反应 | 回调注册时序不对 | 确保init期间已注册回调 |
6.3 本地通知在国产ROM上不弹
国产ROM对后台限制比原生Android激进得多,华为、小米、OPPO、vivo都有自己的省电策略和自启动管理。App不在白名单里,本地通知的定时任务可能被系统冻结,极光通知也可能延迟,这是当前国内安卓推送环节的最大难题。
解决思路分两层:基础层是申请厂商推送通道,让通知走系统级通道,这是官方推荐方案;兜底层是在App进程被杀后,下一次启动时通过自查补偿。别指望改客户端的保活代码来对抗厂商限制,现在的高版本系统上,任何保活手段都不可持续,还是老老实实按厂商规则来。
6.4 抓包失败和日志调试技巧
调试极光推送时,很多人想抓包看请求内容,但极光SDK做了TLS通信加密,抓包工具经常只能看到握手失败,这个现象是正常的,不代表推送链路有问题。与其纠结抓包,不如直接看Logcat:过滤JPush关键字,SDK会把注册、连接状态、消息到达都打出来,信息量足够。
另一个技巧是用极光控制台给指定registrationId发测试推送,这样确认了服务端到设备的链路没问题,再回头查自己Flutter端的逻辑。如果连推送记录都显示成功但设备没反应,那基本可以锁定是Manifest或权限问题,回到6.1的五步法逐项排查即可。
实际项目里踩过几次坑之后,我最大的体会是:推送这个功能表面上是接SDK,本质上是在做Android系统机制的适配。极光负责把消息送到门口,能不能进用户的眼睛,取决于权限、渠道、厂商限制这些底层细节。如果文章里只选一条建议带走,那就是把“消息接收”和“通知展示”分开设计,远程推送只管传输,本地通知负责打磨展示逻辑,这个分层能让你在后续换厂商、换推送服务商时,少挖好几个坑。最后再分享一个小技巧:推送相关的所有关键日志,上线时不要全清空,保留一个可动态开启的debug日志入口,线上用户反馈收不到消息时,让他发一份日志,排查效率比你远程盲猜高一倍不止。