В прошлой статье мы разбирали ситуации, которые мешают пушам работать в 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()
}

Никакой магии, просто жёсткое ограничение. Хотите больше — меняйте константу, но я бы сто раз подумал, прежде чем раздувать локальное хранилище.

Итог

Собрали архитектуру, в которой:

  1. Обе платформы сохраняют JSON по единому контракту.

  2. Push пишется на нативном уровне, React Native получает уже готовые данные.

  3. Хранилище общее: App Group на iOS, SharedPreferences на Android.

  4. JS вообще не думает, откуда пришли данные.

Добавить новое поле? Меняете createStoredPushJson в двух файлах — и готово.

Кстати, если вам кажется, что вы всё настроили правильно, а пуши всё равно не сохраняются — вспомните, с чего начиналась эта история. Возможно, вы забыли включить App Groups или не добавили mutable-content: 1 в пейлоад. А возможно, проблема глубже, и виноват тот самый баг из доклада Mad Brains, о котором я рассказывал. Но это уже совсем другая история.

Stay in touch