当前位置: 首页 > news >正文

【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.dtcms.com/a/314318.html

相关文章:

  • 模型学习系列之考试
  • 机器学习(8):线性回归
  • 基于落霞归雁思维框架的自动化测试实践与探索
  • OpenLayers 入门指南【五】:Map 容器
  • Unity发布Android平台实现网页打开应用并传参
  • 如何查看 iOS 电池与电耗:入门指南与实战工具推荐
  • 期权投资盈利之道书籍推荐
  • Codeforces Round 1008 (Div. 2)
  • Chrontel【CH7214C-BF】CH7214C USB Type C Logic Controller
  • 【Java线程池深入解析:从入门到精通】
  • Memcached 缓存详解及常见问题解决方案
  • 【深度学习新浪潮】近三年城市级数字孪生的研究进展一览
  • 【音视频】WebRTC 一对一通话-实现概述
  • 使用vue缓存机制 缓存整个项目的时候 静态的一些操作也变的很卡,解决办法~超快超简单~
  • 深入剖析RT-Thread串口驱动:基于STM32H750的FinSH Shell全链路Trace分析与实战解密(上)
  • Back to the Features:附录C Unconditional world model evaluations
  • 第四十一节 MATLAB GNU Octave教程
  • 第四十五章:AI模型的“灵魂契约”:GGUF权重到PyTorch结构极致适配
  • Nginx vs Spring Cloud Gateway:限流功能深度对比与实践指南
  • 政策合规性网页设计:工业数据可视化的信息安全技术规范解析
  • 基于机器学习的二手房信息可视化及价格预测系统设计与实现
  • 车载通信架构 ---车内通信的汽车网络安全
  • [spring-cloud: @LoadBalanced @LoadBalancerClient]-源码分析
  • bypass
  • Azure DevOps - 使用 Ansible 轻松配置 Azure DevOps 代理 - 第6部分
  • vim 组件 使用pysocket进行sock连接
  • ArcGIS的字段计算器生成随机数
  • Deepoc 赋能送餐机器人:从机械执行到具身智能的革命性跨越
  • 登录验证码功能实现:Spring Boot + Vue 全流程解析
  • 《P1462 通往奥格瑞玛的道路》