В прошлой статье мы разбирали ситуации, которые мешают пушам работать в killed state на iOS. Сегодня — чистая практика.
Хочу отметить, что я намеренно написал код более вербозным, чтобы вы - мой читатель, могли внести что-то свое, свои обдуманные улучшение и свой взгляд, а не просто бездумно скопировать или пробежаться глазами. Критический взгляд всегда полезен.
Как это устроено под капотом
Давайте сразу договоримся о главном: push сохраняется на нативном уровне ещё до того, как мост React Native вообще проснётся. JS не участвует в гонке «кто первый». Он просто получает готовый массив — и рисует его. Вот как выглядит путь уведомления:
APNs / FCM │ ▼ Нативный обработчик │ ▼ Локальное хранилище │ ▼ EventEmitter → JS │ ▼ FlatList на экране
Немного деталей. На iOS, когда приложение убито, работает NotificationService Extension. Когда живо — AppDelegate. На Android — FirebaseMessagingService. И каждый из этих ребят пишет данные в локальное хранилище, формируя один и тот же JSON. Ниже покажу это в коде, но сначала — контракт.
Единый формат, или почему мы не пишем if (Platform.OS === ‘ios’)
В своей практике я сталкивался с проектами, где формат push-уведомлений был платформозависимый (не знаю зачем это делать, ну да ладно, у всех свои причины). В данном примере мы уберем все платформенные ветвления. Данные, которые сохраняются на уровне натива, выглядят одинаково что на iOS, что на Android:
{ "uniqueKey": "some-unique-id", "title": "Заголовок уведомления", "description": "Текст уведомления", "imageUrl": "https://example.com/image.jpg", "pushLink": "https://example.com/page", "payload": "{\"key\": \"value\"}", "receivedAt": 1716812345 }
JS-слой вообще не знает, с какой платформы пришли данные. И это прекрасно. Никаких if (Platform.OS === 'ios'), никаких отдельных парсеров под Android. Добавить новое поле? Поменяли формирование JSON в двух местах — и всё, JS просто начинает его читать.
iOS: два входа — одно хранилище
NotificationService Extension
Расширение запускается системой при получении пуша с флагом mutable-content: 1, даже если приложение убито. Именно здесь мы и ловим момент, пока никто не мешает. Метод didReceive дёргает saveNotification, та формирует JSON и кладёт его в общий UserDefaults через App Group.
final class NotificationService: UNNotificationServiceExtension { static let suiteName = "group.cloud.Mindbox.com.example.exampleapp" static let notificationsKey = "notifications" private lazy var mindboxService = MindboxNotificationService() override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { saveNotification(request: request) mindboxService.didReceive(request, withContentHandler: contentHandler) } private func saveNotification(request: UNNotificationRequest) { guard let ud = UserDefaults(suiteName: NotificationService.suiteName) else { return } guard let pushData = mindboxService.getMindboxPushData(userInfo: request.content.userInfo) else { return } let storedJson = createStoredPushJson(from: pushData) var notifications = ud.array(forKey: NotificationService.notificationsKey) as? [String] ?? [] if !notifications.contains(storedJson) { notifications.append(storedJson) ud.set(notifications, forKey: NotificationService.notificationsKey) ud.synchronize() } } private func createStoredPushJson(from pushData: MindboxPushData) -> String { let dict: [String: Any] = [ "uniqueKey": pushData.uniqueKey ?? UUID().uuidString, "title": pushData.aps?.alert?.title ?? "", "description": pushData.aps?.alert?.body ?? "", "imageUrl": pushData.imageUrl ?? "", "pushLink": pushData.clickUrl ?? "", "payload": pushData.payload ?? "", "receivedAt": Date().timeIntervalSince1970 ] guard let data = try? JSONSerialization.data(withJSONObject: dict), let json = String(data: data, encoding: .utf8) else { return "{}" } return json } }
Обратите внимание: проверка if !notifications.contains(storedJson) — это защита от дубликатов. Не раз сталкивался с тем, что один пуш может дёрнуть сохранение дважды, и лучше перестраховаться на этапе записи, чем потом разбираться в UI.
AppDelegate
Когда приложение активно, extension не запускается. Тут эстафету перехватывает willPresent. Логика та же, формат тот же.
private func saveMindboxPush(_ userInfo: [AnyHashable: Any]) { guard Mindbox.shared.isMindboxPush(userInfo: userInfo) else { return } guard let pushData = Mindbox.shared.getMindboxPushData(userInfo: userInfo) else { return } let ud = UserDefaults(suiteName: "group.cloud.Mindbox.com.example.exampleapp") let storedJson = createStoredPushJson(from: pushData) var current = ud?.array(forKey: "notifications") as? [String] ?? [] if !current.contains(storedJson) { current.append(storedJson) ud?.set(current, forKey: "notifications") ud?.synchronize() } }
Важный момент: оба метода пишут в один и тот же ключ notifications в общем UserDefaults. Никакой магии — просто дисциплина. Если вы вдруг решите поменять ключ, меняйте сразу в двух местах, иначе получите две разные истории уведомлений. Проверено на собственной невнимательности.
Android: без App Group, но с душой
FirebaseMessagingService
На Android всё чуть проще. Нет никаких расширений и killed state. FirebaseMessagingService получает пуш, дёргает Mindbox.handleRemoteMessage для отображения, а затем сохраняет данные через MainApplication.
class MindboxFirebaseMessagingService: FirebaseMessagingService() { override fun onMessageReceived(remoteMessage: RemoteMessage) { Mindbox.handleRemoteMessage(applicationContext, remoteMessage, ...) val mindboxMessage = MindboxFirebase.convertToMindboxRemoteMessage(remoteMessage) if (mindboxMessage != null) { (applicationContext as MainApplication).saveNotification(mindboxMessage) } } }
MainApplication.kt
А вот и сам метод saveNotification. Делает ровно то же, что и iOS: формирует JSON по единому контракту и пишет в SharedPreferences.
fun saveNotification(message: MindboxRemoteMessage) { val sharedPreferences = getSharedPreferences("notifications", Context.MODE_PRIVATE) val notificationsJson = sharedPreferences.getString("notifications", "[]") val notifications: MutableList<String> = gson.fromJson(notificationsJson, type) val storedJson = JSONObject().apply { put("uniqueKey", message.uniqueKey) put("title", message.title) put("description", message.description) put("imageUrl", message.imageUrl) put("pushLink", message.pushLink) put("payload", message.payload) put("receivedAt", System.currentTimeMillis() / 1000) }.toString() notifications.add(storedJson) editor.putString("notifications", gson.toJson(notifications)) editor.apply() notifyJS() }
JS-слой: тонкий и элегантный
Помните, я обещал, что JS будет простым? Вот, смотрите:
interface StoredPush { uniqueKey: string; title: string; description: string; imageUrl?: string; pushLink?: string; payload?: string; receivedAt: number; } const NotificationCenterScreen = () => { const [notifications, setNotifications] = useState<StoredPush[]>([]); const loadNotifications = async () => { const result = await NotificationModule.getNotifications(); const pushes: StoredPush[] = JSON.parse(result) .map((str: string) => JSON.parse(str)) .reverse(); setNotifications(pushes); }; useEffect(() => { loadNotifications(); const subscription = DeviceEventEmitter.addListener( 'newNotification', loadNotifications ); return () => subscription.remove(); }, []); return <FlatList data={notifications} ... />; };
Никаких if (Platform.OS === 'ios'). Вообще. Данные приходят в одинаковом формате, интерфейс один, парсинг один. Красота.
Ограничение количества: чтобы телефон не захлебнулся
Тысяча последних уведомлений — потолок. Если хранить всё подряд, однажды приложение скажет «пока» по памяти. Поэтому на обеих платформах держим планку:
// iOS if notifications.count > 1000 { notifications = Array(notifications.suffix(1000)) }
// Android if (notifications.size > 1000) { notifications = notifications.takeLast(1000).toMutableList() }
Никакой магии, просто жёсткое ограничение. Хотите больше — меняйте константу, но я бы сто раз подумал, прежде чем раздувать локальное хранилище.
Итог
Собрали архитектуру, в которой:
Обе платформы сохраняют JSON по единому контракту.
Push пишется на нативном уровне, React Native получает уже готовые данные.
Хранилище общее: App Group на iOS, SharedPreferences на Android.
JS вообще не думает, откуда пришли данные.
Добавить новое поле? Меняете createStoredPushJson в двух файлах — и готово.
Кстати, если вам кажется, что вы всё настроили правильно, а пуши всё равно не сохраняются — вспомните, с чего начиналась эта история. Возможно, вы забыли включить App Groups или не добавили mutable-content: 1 в пейлоад. А возможно, проблема глубже, и виноват тот самый баг из доклада Mad Brains, о котором я рассказывал. Но это уже совсем другая история.
Stay in touch
