【Redis】移动设备离线通知推送全流程实现:系统推送服务与Redis的协同应用
在移动应用开发中,应用未启动时的通知推送是提升用户体验的核心需求之一。当用户未主动启动 App 时,如何通过手机通知栏触达用户,确保关键信息(如订单提醒、系统警报)不丢失?本文将尝试解析从 系统推送服务集成 到 消息存储与唤醒 的全链路实现方案,涵盖 Android(FCM)、iOS(APNs)、Spring Boot 服务端与 Redis 存储的完整技术栈。
一、核心问题与架构设计
1.1 核心问题定义
需求的核心是:当 App 处于后台或未启动状态时,服务器需将通知推送至手机,通过系统通知栏提示用户。关键挑战包括:
- App 离线:无法通过传统长连接(如 SSE/WebSocket)直接推送。
- 用户无感知:需通过系统级通知(通知栏)触发用户注意。
- 消息完整性:用户打开 App 后需完整查看所有离线通知。
1.2 整体架构设计
方案核心依赖 系统推送服务(触发通知栏)与 Redis 消息存储(持久化未读消息),架构图如下:
二、客户端集成:获取设备 Token 与上报
系统推送的前提是获取设备的唯一标识(Token),Android(FCM)与 iOS(APNs)的 Token 获取与上报逻辑不同。
2.1 Android:集成 FCM 获取 Token
FCM(Firebase Cloud Messaging)是 Android 官方推送服务,自动为设备生成唯一 Token。
2.1.1 配置 Firebase 项目
- 登录 https://console.firebase.google.com/,创建新项目。
- 在项目设置中添加 Android 应用(输入包名,如
com.example.app
)。 - 下载
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 证书
- 登录 https://developer.apple.com/account/,创建“推送通知”证书(开发/生产环境)。
- 导出
.p12
证书(用于服务器端签名推送请求)。
2.2.2 配置 Xcode 项目
- 在 Xcode 中启用“Push Notifications”能力,确保 Bundle ID 与开发者后台一致。
- 在
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 格式 | 通知点击跳转实现 |
---|---|---|---|
Android | FCM | 以 fcm_ 开头的字符串 | 配置 PendingIntent 跳转目标 Activity |
iOS | APNs | 以 apns_ 开头的字符串 | 监听 UNUserNotificationCenter 事件 |
六、总结
通过 系统推送服务(APNs/FCM) 触发手机通知栏提醒,结合 Redis 消息存储 确保消息持久化,最终实现了“App 未启动时用户仍能感知通知”的目标。核心流程如下:
- 客户端集成:Android 集成 FCM,iOS 集成 APNs,获取设备 Token 并上报服务器。
- 服务器推送:判断 App 离线时,通过系统推送发送通知,并存储消息到 Redis。
- 用户唤醒:用户点击通知后,App 被唤醒并跳转至指定页面,完成通知闭环。
该方案兼顾实时性与可靠性,适用于外卖、网约车、即时通讯等需要离线通知的场景。实际开发中可根据业务需求扩展功能(如多设备支持、短信补发),进一步提升用户体验。