【Redis】移动设备离线通知推送全流程实现:系统推送服务与Redis的协同应用

在移动应用开发中,应用未启动时的通知推送是提升用户体验的核心需求之一。当用户未主动启动 App 时,如何通过手机通知栏触达用户,确保关键信息(如订单提醒、系统警报)不丢失?本文将尝试解析从 系统推送服务集成消息存储与唤醒 的全链路实现方案,涵盖 Android(FCM)、iOS(APNs)、Spring Boot 服务端与 Redis 存储的完整技术栈。


一、核心问题与架构设计

1.1 核心问题定义

需求的核心是:当 App 处于后台或未启动状态时,服务器需将通知推送至手机,通过系统通知栏提示用户。关键挑战包括:

  • App 离线:无法通过传统长连接(如 SSE/WebSocket)直接推送。
  • 用户无感知:需通过系统级通知(通知栏)触发用户注意。
  • 消息完整性:用户打开 App 后需完整查看所有离线通知。

1.2 整体架构设计

方案核心依赖 系统推送服务(触发通知栏)与 Redis 消息存储(持久化未读消息),架构图如下:

服务器(Spring Boot)系统推送(APNs/FCM)手机( App 未打开)Redis(消息存储)存储未读通知(用户ID分组)触发系统推送(设备Token)手机通知栏显示提醒用户点击通知(唤醒App)拉取未读通知/跳转目标页服务器(Spring Boot)系统推送(APNs/FCM)手机( App 未打开)Redis(消息存储)

二、客户端集成:获取设备 Token 与上报

系统推送的前提是获取设备的唯一标识(Token),Android(FCM)与 iOS(APNs)的 Token 获取与上报逻辑不同。

2.1 Android:集成 FCM 获取 Token

FCM(Firebase Cloud Messaging)是 Android 官方推送服务,自动为设备生成唯一 Token。

2.1.1 配置 Firebase 项目
  1. 登录 https://console.firebase.google.com/,创建新项目。
  2. 在项目设置中添加 Android 应用(输入包名,如 com.example.app)。
  3. 下载 google-services.json,放入 Android 项目的 app/ 目录。
2.1.2 集成 FCM SDK 并获取 Token

build.gradle(Module: app)中添加依赖:

dependencies {implementation 'com.google.firebase:firebase-messaging:23.6.0'
}

通过 FirebaseMessagingService 监听 Token 生成:

class MyFirebaseMessagingService : FirebaseMessagingService() {// Token 生成或刷新时回调override fun onNewToken(token: String) {super.onNewToken(token)// 上报 Token 到服务器(关联用户 ID)reportDeviceTokenToServer(token)}private fun reportDeviceTokenToServer(token: String) {val userId = getCurrentUserId() // 用户登录后获取val retrofit = Retrofit.Builder().baseUrl("https://your-server.com/").addConverterFactory(GsonConverterFactory.create()).build()val service = retrofit.create(DeviceTokenApi::class.java)service.registerDeviceToken(userId, token).enqueue(object : Callback<Void> {override fun onResponse(call: Call<Void>, response: Response<Void>) {Log.d("FCM", "Token 上报成功")}override fun onFailure(call: Call<Void>, t: Throwable) {Log.e("FCM", "Token 上报失败: ${t.message}")// 本地缓存 Token,后续重试}})}
}// 设备 Token 上报接口
interface DeviceTokenApi {@POST("device-token/register")fun registerDeviceToken(@Query("userId") userId: String,@Query("token") token: String): Call<Void>
}

2.2 iOS:集成 APNs 获取 Token

APNs(Apple Push Notification service)是 iOS 官方推送服务,需通过证书认证。

2.2.1 生成 APNs 证书
  1. 登录 https://developer.apple.com/account/,创建“推送通知”证书(开发/生产环境)。
  2. 导出 .p12 证书(用于服务器端签名推送请求)。
2.2.2 配置 Xcode 项目
  1. 在 Xcode 中启用“Push Notifications”能力,确保 Bundle ID 与开发者后台一致。
  2. AppDelegate 中监听 Token 生成:
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {UNUserNotificationCenter.current().delegate = selfapplication.registerForRemoteNotifications()return true}// 获取设备 Token 成功func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {let token = deviceToken.map { String(format: "%02.2hhx", $0) }.joined()reportDeviceTokenToServer(token)}private func reportDeviceTokenToServer(_ token: String) {let userId = getCurrentUserId() // 用户登录后获取let url = URL(string: "https://your-server.com/device-token/register")!var request = URLRequest(url: url)request.httpMethod = "POST"request.httpBody = try? JSONEncoder().encode(["userId": userId, "token": token])URLSession.shared.dataTask(with: request) { _, _, error inif let error = error {print("Token 上报失败: \(error.localizedDescription)")// 本地缓存 Token,后续重试} else {print("Token 上报成功")}}.resume()}
}

三、服务端开发:推送触发与消息存储

3.1 系统推送服务集成

服务器需通过 FCM 向 Android 设备推送,通过 APNs 向 iOS 设备推送。

3.1.1 FCM 推送实现(Android)

使用 Firebase Admin SDK 发送推送:

@Component
public class FcmPushClient {private final FirebaseApp firebaseApp;public FcmPushClient() throws IOException {// 加载 google-services.jsonFirebaseOptions options = new FirebaseOptions.Builder().setCredentials(GoogleCredentials.fromStream(getClass().getResourceAsStream("/google-services.json"))).build();FirebaseApp.initializeApp(options);this.firebaseApp = FirebaseApp.getInstance();}public void sendFcmNotification(String deviceToken, NotificationMessage message) {FirebaseMessaging messaging = FirebaseMessaging.getInstance(firebaseApp);Message fcmMessage = Message.builder().setToken(deviceToken).putAllData(buildFcmData(message)).build();try {String response = messaging.send(fcmMessage);log.info("FCM 推送成功,响应: {}", response);} catch (FirebaseMessagingException e) {log.error("FCM 推送失败: {}", e.getMessage());// 记录失败 Token,后续清理}}private Map<String, Object> buildFcmData(NotificationMessage message) {Map<String, Object> data = new HashMap<>();data.put("title", message.getTitle());   // 通知标题data.put("body", message.getContent());  // 通知内容data.put("click_action", "OPEN_ORDER_DETAIL"); // 点击 Actiondata.put("orderId", message.getOrderId());     // 跳转参数return data;}
}
3.1.2 APNs 推送实现(iOS)

使用 pushy 库发送 APNs 推送:

@Component
public class ApnsPushClient {private final ApnsClient apnsClient;public ApnsPushClient(@Value("${apns.cert-path}") String certPath,@Value("${apns.team-id}") String teamId,@Value("${apns.key-id}") String keyId,@Value("${apns.bundle-id}") String bundleId) throws Exception {ApnsSigningKey signingKey = ApnsSigningKey.loadFromPkcs8File(new File(certPath), teamId, keyId);this.apnsClient = new ApnsClientBuilder().setSigningKey(signingKey).setApnsServer(ApnsClientBuilder.PRODUCTION_APNS_HOST).build();}public void sendApnsNotification(String deviceToken, NotificationMessage message) {ApnsNotification apnsNotification = new ApnsNotification(deviceToken,new ApnsPayloadBuilder().setAlertTitle(message.getTitle()).setAlertBody(message.getContent()).setSound("default").setBadge(1).build());apnsNotification.getCustomData().put("orderId", message.getOrderId());try {Future<PushNotificationResponse<ApnsNotification>> future = apnsClient.sendNotification(apnsNotification);PushNotificationResponse<ApnsNotification> response = future.get();if (!response.isAccepted()) {log.error("APNs 推送失败: {}", response.getRejectionReason());}} catch (Exception e) {log.error("APNs 推送异常: {}", e.getMessage());}}
}

3.2 消息存储:Redis 持久化未读通知

使用 Redis 存储未读通知,确保用户打开 App 后能拉取所有未读消息。

3.2.1 消息实体设计
@Data
@AllArgsConstructor
@NoArgsConstructor
public class AppNotification {private String notificationId; // 全局唯一 ID(UUID)private String userId;         // 用户 IDprivate String title;          // 通知标题(通知栏显示)private String content;        // 通知内容(通知栏显示)private String jumpUrl;        // 点击跳转链接(如订单详情页)private long timestamp;        // 时间戳(毫秒级)private int priority;          // 优先级(1-高,2-普通)private boolean isRead;        // 是否已读(默认 false)
}
3.2.2 Redis 存储服务实现
@Service
@RequiredArgsConstructor
public class NotificationStorageService {private final RedisTemplate<String, AppNotification> redisTemplate;private final ObjectMapper objectMapper;// 存储未读通知到 Redis ZSET(按时间排序)public void saveUnreadNotification(AppNotification notification) {String key = "app_notifications:" + notification.getUserId();try {String value = objectMapper.writeValueAsString(notification);redisTemplate.opsForZSet().add(key, value, notification.getTimestamp());redisTemplate.expire(key, 7, TimeUnit.DAYS); // 7 天过期} catch (JsonProcessingException e) {log.error("通知序列化失败: {}", e.getMessage());throw new RuntimeException("通知存储失败");}}// 拉取未读通知(最近 20 条,按时间倒序)public List<AppNotification> fetchUnreadNotifications(String userId) {String key = "app_notifications:" + userId;Set<String> notifications = redisTemplate.opsForZSet().range(key, 0, 19);return notifications.stream().map(this::deserializeNotification).collect(Collectors.toList());}private AppNotification deserializeNotification(String json) {try {return objectMapper.readValue(json, AppNotification.class);} catch (JsonProcessingException e) {log.error("通知反序列化失败: {}", e.getMessage());return null;}}
}

3.3 推送触发逻辑:在线/离线判断

服务器需判断 App 是否在线(通过心跳或 SSE 连接状态),决定是否触发系统推送。

@Service
@RequiredArgsConstructor
public class NotificationService {private final FcmPushClient fcmPushClient;private final ApnsPushClient apnsPushClient;private final NotificationStorageService storageService;private final DeviceTokenService deviceTokenService;private final RedisTemplate<String, String> redisTemplate;public void sendNotification(NotificationMessage message) {String userId = message.getUserId();String deviceToken = deviceTokenService.getDeviceToken(userId);if (deviceToken == null) {log.warn("用户 {} 无有效设备 Token,无法推送", userId);return;}AppNotification notification = AppNotification.builder().notificationId(UUID.randomUUID().toString()).userId(userId).title(message.getTitle()).content(message.getContent()).jumpUrl(message.getJumpUrl()).timestamp(System.currentTimeMillis()).priority(message.getPriority()).isRead(false).build();// 判断 App 是否在线(通过 Redis 心跳记录)boolean isAppOnline = redisTemplate.hasKey("app_heartbeat:" + userId);if (isAppOnline) {// 在线:通过 SSE 实时推送(略)sseService.pushToUser(userId, notification);} else {// 离线:触发系统推送 + 存储if (deviceToken.startsWith("fcm_")) {fcmPushClient.sendFcmNotification(deviceToken, message);} else if (deviceToken.startsWith("apns_")) {apnsPushClient.sendApnsNotification(deviceToken, message);}storageService.saveUnreadNotification(notification);}}
}

四、用户唤醒与跳转实现

用户点击通知后,App 需唤醒并跳转至指定页面(如订单详情页)。

4.1 Android:通知点击跳转

通过 PendingIntent 配置跳转目标:

object NotificationUtils {fun showNotification(context: Context, notification: AppNotification) {createNotificationChannel(context)val intent = Intent(context, OrderDetailActivity::class.java).apply {putExtra("orderId", extractOrderIdFromJumpUrl(notification.jumpUrl))flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK}val pendingIntent = PendingIntent.getActivity(context,0,intent,PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)val notificationBuilder = NotificationCompat.Builder(context, "order_channel").setContentTitle(notification.title).setContentText(notification.content).setSmallIcon(R.drawable.ic_order_notification).setContentIntent(pendingIntent).setAutoCancel(true).build()val manager = context.getSystemService(NotificationManager::class.java)manager.notify(notification.notificationId.hashCode(), notificationBuilder)}private fun extractOrderIdFromJumpUrl(jumpUrl: String): String {val pattern = Pattern.compile("orderId=(\\w+)")val matcher = pattern.matcher(jumpUrl)return if (matcher.find()) matcher.group(1) else ""}
}

4.2 iOS:通知点击跳转

通过 UNUserNotificationCenterDelegate 处理点击事件:

extension AppDelegate: UNUserNotificationCenterDelegate {func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {let userInfo = response.notification.request.content.userInfoif let jumpUrl = userInfo["jumpUrl"] as? String {if let url = URL(string: jumpUrl) {let components = URLComponents(url: url, resolvingAgainstBaseURL: true)if let orderId = components?.queryItems?.first(where: { $0.name == "orderId" })?.value {let orderDetailVC = OrderDetailViewController()orderDetailVC.orderId = orderIdif let rootVC = window?.rootViewController {rootVC.navigationController?.pushViewController(orderDetailVC, animated: true)}}}}completionHandler()}
}

五、关键优化与注意事项

5.1 推送可靠性保障

  • Token 校验:定期清理无效 Token(如用户卸载 App 后,FCM/APNs 会返回 InvalidToken 错误)。
  • 重试机制:推送失败时自动重试 3 次(使用 Spring Retry 注解)。
  • 持久化存储:Redis 开启 RDB+AOF 持久化,防止服务端宕机导致消息丢失。

5.2 用户体验优化

  • 通知优先级:高优先级通知(如支付成功)设置 priority: 1,确保立即显示;普通通知(如系统公告)设置 priority: 2
  • 去重逻辑:为每条通知生成全局唯一 ID(UUID),客户端记录已读 ID,避免重复展示。
  • 过期策略:设置 Redis 过期时间(如 7 天),自动清理长期未读的旧消息。

5.3 多平台适配

平台系统推送服务设备 Token 格式通知点击跳转实现
AndroidFCMfcm_ 开头的字符串配置 PendingIntent 跳转目标 Activity
iOSAPNsapns_ 开头的字符串监听 UNUserNotificationCenter 事件

六、总结

通过 系统推送服务(APNs/FCM) 触发手机通知栏提醒,结合 Redis 消息存储 确保消息持久化,最终实现了“App 未启动时用户仍能感知通知”的目标。核心流程如下:

  1. 客户端集成:Android 集成 FCM,iOS 集成 APNs,获取设备 Token 并上报服务器。
  2. 服务器推送:判断 App 离线时,通过系统推送发送通知,并存储消息到 Redis。
  3. 用户唤醒:用户点击通知后,App 被唤醒并跳转至指定页面,完成通知闭环。

该方案兼顾实时性与可靠性,适用于外卖、网约车、即时通讯等需要离线通知的场景。实际开发中可根据业务需求扩展功能(如多设备支持、短信补发),进一步提升用户体验。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:http://www.pswp.cn/pingmian/92113.shtml
繁体地址,请注明出处:http://hk.pswp.cn/pingmian/92113.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

WebView 中控制光标

在 WebView 中控制光标&#xff08;如移动焦点、获取/设置光标位置、显示/隐藏光标等&#xff09;需要根据具体场景和平台&#xff08;Android/iOS/Web&#xff09;采用不同的方法。以下是常见场景的解决方案&#xff1a;一、Web 页面中的光标控制&#xff08;JavaScript&#…

2025国赛数学建模C题详细思路模型代码获取,备战国赛算法解析——决策树

2025国赛数学建模C题详细思路模型代码获取见文末名片 决策树算法&#xff1a;从原理到实战&#xff08;数模小白友好版&#xff09; 1. 决策树是什么&#xff1f;——用生活例子理解核心概念 想象你周末想决定是否去野餐&#xff0c;可能会这样思考&#xff1a; 根节点&#xf…

从底层架构到多元场景:计算机构成与应用的深度剖析

一、引言1.1 研究背景与意义在当今数字化时代&#xff0c;计算机已成为推动社会进步和经济发展的核心力量&#xff0c;其身影遍布生活、工作、学习的各个角落。从个人日常使用的笔记本电脑、智能手机&#xff0c;到企业运营中不可或缺的服务器、大型机&#xff0c;再到科研领域…

控制建模matlab练习08:根轨迹

此练习主要是&#xff1a;在matlab中绘制根轨迹的方法。 一、在matlab中建立对应系统 1、例如&#xff0c;对于如图的反馈系统。 2、其中开环传递函数G(s)、闭环传递函数Gcl(s)。3、因此&#xff0c;其闭环传递函数的根轨迹&#xff0c;就可以直接在matlab中绘制出来。 4、直接…

【Spring Boot 快速入门】七、阿里云 OSS 文件上传

这里写自定义目录标题准备阿里云 OSS参照官方 SDK 编写入门程序案例数据准备案例集成阿里云 OSS前端测试代码app.jsstyle.cssindex.html效果图准备阿里云 OSS 注册登录阿里云&#xff0c;然后点击控制台&#xff0c;在左上角菜单栏搜索对象存储 OSS&#xff0c;点击并开通点击…

分布式微服务--Nacos作为配置中心(二)

前言&#xff1a;Nacos 是什么&#xff1f; Nacos&#xff08;Naming and Configuration Service&#xff09;是阿里巴巴开源的一个更易于构建云原生应用的动态服务发现、配置管理和服务管理平台。我们可以使用它&#xff1a; ✅作为注册中心&#xff08;服务发现&#xff09; …

家庭/公司内部网络内网穿透:无公网IP怎么设置外网远程访问?

家庭宽带内网穿透如何实现&#xff1f;需公网IP吗&#xff1f;公司内部的网址服务怎么提供互联网访问&#xff1f;相信很多人都有遇到家庭网和公司内部网下&#xff0c;搭建了服务器&#xff0c;或网络硬件设备&#xff0c;需要在异地远程访问使用的情况。家庭和公司内部宽带内…

水库防洪安全雨水情监测预警系统

水库防洪安全雨水情监测预警系统是一种高度集成现代信息技术与水利工程管理的综合性智能化管理平台&#xff0c;该系统主要应用于水库及其周边流域的实时水情监测与预警工作。通过部署先进的传感设备和监测网络&#xff0c;该系统能够全天候不间断地采集水库库区及周边区域的降…

【论文阅读】Editing Large Language Models: Problems, Methods, and Opportunities

Editing Large Language Models: Problems, Methods, and Opportunities原文摘要研究背景与问题提出核心问题&#xff1a;尽管LLM已具备强大的能力&#xff0c;但如何长期维持其时效性并修正错误仍缺乏系统方法论。现状&#xff1a;近年来&#xff0c;针对LLMs的模型编辑技术兴…

金融数据可视化的强力引擎 —— QtitanDataGrid在金融行业的应用实践

QtitanDataGrid是一款适用于Qt的商业化DataGrid 组件&#xff0c;它使得表格数据可以直接面向终端用户。这个组件吸收了用户界面结构显示表格方面所有的现代化技术的精华&#xff0c;是目前Qt市场上唯一一款拥有如此高级功能和出色性能的网格组件。这个Qt数据网格组件使用纯C创…

玩转 InfluxDB 3:用 HTTP API 快速创建高效数据表

前言 说起时间序列数据库,InfluxDB 绝对是业界响当当的明星。数据源源不断涌入,能否高效存储和查询,直接决定你的业务能不能飞速跑起来。可你还在用客户端或者命令行一点一点手动操作?朋友,这操作太老土,分分钟拖慢节奏。 现在是 API 自动化时代,HTTP API 可帮你轻松搞…

stc32g利用硬件I2C配合中断实现高效率异步无阻塞读写方法

I2C读写巨慢, 即使在400kbit/s下, 读写一个字节数据也要花费20多us, 这太慢了, 每读写一次设备的寄存器数据, 还要设备地址和寄存器地址, 又加了两个字节数据, 我就读了个传感器的两个字节数据而已, 动辄还要花费100us的阻塞时间, 这太浪费资源了针对这个问题, 我利用硬件I2C及…

生成式 AI 重塑自动驾驶仿真:4D 场景生成技术的突破与实践

近年来&#xff0c;伴随自动驾驶技术的快速发展&#xff0c;行业对于仿真测试平台的精度、覆盖率和可扩展性提出了更高要求。尤其在数据闭环迭代、长尾场景验证及安全冗余验证等关键环节中&#xff0c;高保真、高复杂度的场景生成能力正在成为测试体系的核心支撑。 传统场景生…

Java 启动命令的完整解析

以下为您提供的 Java 启动命令的完整解析和优化建议: nohup java -server \ -XX:+PrintGCDateStamps \ -XX:+PrintGCTimeStamps \ -Xlogger:/home/logs/gc_`date +%Y%m%d_%H%M`.log \ -jar ytr.jar > /dev/null & 一、命令逐行解析 命令部分 功能说明 技术原理 nohup …

JVM中的垃圾回收暂停是什么,为什么会出现暂停,不同的垃圾回收机制暂停对比

JVM中的垃圾回收暂停是什么&#xff1f; 在Java虚拟机&#xff08;JVM&#xff09;中&#xff0c;垃圾回收暂停&#xff08;Garbage Collection Pause&#xff09;&#xff0c;也称为“Stop-The-World”事件&#xff0c;是指当垃圾收集器执行特定阶段时&#xff0c;所有应用程序…

Spearman 相关系数与 Pearson 相关系数的区别

核心区别对比表特征Pearson 相关系数Spearman 相关系数相关性类型线性相关单调相关计算基础原始数据值数据排名&#xff08;秩&#xff09;公式数据要求连续变量&#xff0c;近似正态分布有序数据或连续变量异常值敏感性高度敏感不敏感取值范围[-1, 1][-1, 1]单调关系检测仅检测…

sqli-labs靶场less36-less40

less361.我们打开靶场之后打开来看一下&#xff0c;输入的内容会被转义&#xff0c;依旧是宽字节注入2.使用以下宽字节注入&#xff0c;使用的是%df?id-1%df%27%20union%20select%201,database(),3--3.剩余内容与前面关卡基本一样&#xff0c;只要使用上面的方法合成宽字节即可…

企业级 TinyMCE Vue 编辑器解决方案 – 配置优化与性能提升指南、自定义插件

## 简介TinyMCE Vue 是官方提供的 TinyMCE 富文本编辑器的 Vue 组件封装&#xff0c;支持 Vue 2 和 Vue 3。它让你可以在 Vue 项目中快速集成强大的富文本编辑能力&#xff0c;支持多种插件、主题和自定义扩展&#xff0c;适用于博客、内容管理、后台系统等多种场景。主要特性&…

【模电笔记】—— 直流稳压电源——稳压电路

Tips&#xff1a;本章节笔记建议读者综合学习&#xff0c;内容较多&#xff0c;可谓是模电相当重要的部分&#xff0c;因此部分知识点没有做到详细解释。 1.稳压电路的性能指标 &#xff08;同上节直流稳压电源的主要技术指标【模电笔记】—— 直流稳压电源——整流、滤波电路…

C++——设计模式

文章目录一、面向对象的优点和缺点1.1 回答重点1.2 扩展知识二、面向对象的三大特点2.1 回答重点2.2 扩展知识三、设计模式的六大原则3.1 回答重点3.1.1 单一职责原则&#xff08;Single Responsibility Principle, SRP&#xff09;3.1.2 开放 - 封闭原则&#xff08;Open-Clos…