在移動應用開發中,應用未啟動時的通知推送是提升用戶體驗的核心需求之一。當用戶未主動啟動 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 被喚醒并跳轉至指定頁面,完成通知閉環。
該方案兼顧實時性與可靠性,適用于外賣、網約車、即時通訊等需要離線通知的場景。實際開發中可根據業務需求擴展功能(如多設備支持、短信補發),進一步提升用戶體驗。