Всем привет! На связи Вадим, старший разработчик компании STM Labs. Хотите избавиться от ограничений пуш-сервисов и взять пуш-уведомления под полный контроль?
В этой статье мы глубоко погрузимся в процессы работы пуш-уведомлений, рассмотрим пример создания своего транспорта пушей и создадим Flutter-плагин для поддержки
собственного решения.
Задумывались ли вы о том, какие могут быть риски использования внешних пуш-сервисов в крупных проектах? Что делать, если ваш защищенный контур отрезан от интернета, но пуши всё равно нужны? И можно ли обойтись без внешних API и при этом гарантированно доставлять уведомления?
Чтобы ответить на эти вопросы, давайте заглянем под капот пуш-уведомлений: как они попадают на устройство, какие механизмы задействованы и почему инфраструктура Google и Apple играет решающую роль в обработке и доставке push-уведомлений на подавляющем количестве мобильных устройств.
Как работают push-уведомления
В классическом понимании push-уведомление — это любое сообщение, передаваемое через сервисы доставки уведомлений.
Чаще всего используются следующие сервисы:
Google Firebase Cloud Messaging (FCM);
Служба Push-уведомлений Apple (APNS);
Huawei Push Kit.
Сервис | Описание | Поддерживаемые ОС |
Firebase Cloud Messaging | Кроссплатформенное решение для обмена сообщениями. | Android, iOS, macOS, tvOS, watchOS, Web |
Служба Push- уведомлений Apple (APNS) | Облачная платформа, позволяющая сторонним разработчикам приложений отправлять оповещения на устройства Apple. Является основным и единственным способом доставки push-уведомлений на устройства Apple. | iOS, macOS, tvOS, watchOS |
Huawei Push Kit | Облачная служба рассылки уведомлений. Изначально была создана как альтернатива сервису FCM. | Android, HarmonyOS, iOS, Web |
Существует множество других сервисов: OneSignal, ASNS (Amazon), система push-сообщений «Аврора Центра» и прочие. Однако большинство из них работают по одному и тому ��е транспортному уровню доставки сообщений на целевое устройство.
Пуши в Android
Классическим способом доставки сообщений на целевое устройство Android является использование Google Services, с помощью которого реализуется взаимодействие с транспортным уровнем Android — Android Transport Layer (ATL).
Основным инструментом работы с транспортным уровнем является Firebase Cloud Messaging.
Рассмотрим подробнее, как происходит взаимодействие Android-устройства с транспортным уровнем при получении сообщения от FCM:

Всё, что мы знаем о Android Transport Layer, — что это long-live TCP-соединение между GS и целевым устройством. Когда наше соединение закрывается, маршрутизатор отправляет специальный сигнал FIN (или RST) для подтверждения закрытия соединения. Таким образом GS узнают о потере связи и пытаются восстановить соединение.
Однако стоит учитывать, что транспортные уровни не обслуживаются сервисами FCM, так как регулируются условиями, специфичными для определенной платформы, и подпадают под условия обслуживания Google API.
Исходя из схемы взаимодействия, мы можем сделать пару интересных выводов:
FCM SDK не использует службу уведомлений ОС Android для генерации регистрационного токена. SDK отправляет запрос с деталями нашего Firebase-проекта (Sender ID, App ID) на сервер для получения push-токена.
FCM никак не связан с нашим устройством, он лишь отправляет сообщения, передаваемые по транспортному слою (ATL).
Преимущества этого подхода:
Экономия батарейки и трафика. Поскольку данная технология не использует polling или longpolling, устройство не выполняет периодические задачи в фоне.
Эффективное использование сетевых ресурсов. Сообщения передаются по выделенному пути, что позволяет ускорить доставку сообщения на целевое устройство.
Теперь переходим к самому интересному: можем ли мы отправлять сообщения через ATL в обход FCM, чтобы реализовать свой пуш-сервис? Ответ прост — нет. Так как ATL регулируются условиями обслуживания, этот слой закрыт от разработчиков. Но есть другое решение, позволяющее заменить ATL — о нём мы поговорим позже.
Пуши в iOS
В iOS и других операционных системах Apple для доставки push- уведомлений традиционно используется сервис Apple Push Notification Service (APNS), API которого, как правило, интегрируется с помощью провайдера с использованием соединения HTTP/2 & TLS 1.2 и аутентификацией по SSL-сертификату провайдера.

Стоит учитывать, что на iOS нельзя полноценно заменить APNs, так как Apple строго ограничивает работу приложений в фоне, тем самым сокращая возможность получения фоновых уведомлений без использования APNs. Однако есть обходные пути:
Инициализация VoIP-приложения: данное решение позволяет удерживать приложения в фоне, поскольку VoIP-приложения должны оставаться запущенными, чтобы принимать входящие звонки. Система автоматически перезапускает приложение, если оно завершается с ненулевым кодом выхода. Однако данное решение считается устаревшим, так как Apple запрещает злоупотреблять VoIP-уведомлениями.
Добавление режима «Background Fetch»: фоновая активность позволит извлекать обновленный контент в фоне. Однако данный метод не даст реализовать полноценный пуш-сервис, если приложение будет закрыто.
Выводы таковы: Apple требует, чтобы приложения, использующие push-уведомления, применяли официальные API и соответствовали установленным стандартам. Попытки обхода APNs могут привести к нарушению общих принципов руководства, что может повлечь за собой reject приложения при проверке.
Так как все пуш-уведомления отправляются на устройства через официальные сервисы Google & Apple, возникает явная зависимость работы пушей от этих сервисов. В связи с чем появляются риски:
Пуши работают до тех пор, пока работают сервисы. Если сервисы нас заблокируют (например, мы попадем под региональную блокировк��), мы перестанем получать пуш-уведомления.
В случае, если наш проект работает в изолированной сети (без интернета), мы также не сможем отправлять пуш-уведомления на наши устройства.
Метаданные уведомлений (например, время отправки и/или информация об устройстве) проходят через серверы Google & Apple, что в некоторых случаях может быть опасно для конфиденциальности в проектах с высокими требованиями к безопасности.
Создаём альтернативный модуль для работы с пушами
Рассмотрев, как устроена доставка push-уведомлений в ОС Android и iOS, перейдем к основному вопросу — созданию своего клиентского модуля для работы с пуш- уведомлениями.
Для этого проработаем требования, которые должен выполнять плагин:
Библиотека должна реализовывать механизм доставки push-уведомлений по WebSocket-соединению как основной транспортный канал, полностью или частично заменяя стандартные решения на базе FCM или APNs.
Библиотека должна обеспечивать интеграцию с текущими интерфейсами работы с push-уведомлениями, предоставляя API, схожий с нынешними схемами работы.
Библиотека должна поддерживать интеграцию как с Cross-platform-проектами, так и с Native.
Идентификация клиента при подключении по WebSocket должна осуществляться исключительно по случайно сгенерированному на клиенте токену.
Библиотека должна предоставлять API для получения актуального push-токена, а также поддерживать механизм генерации нового токена с возможностью сброса активного соединения.
Библиотека должна предоставлять API для конфигурации параметров соединения, чтобы можно было работать с разными точками доступа.
WebSocket-соединение должно поддерживаться в фоне.
Шаг 1. Определяем базовый интерфейс работы с библиотекой
Метод | Тип данных | Описание |
getPushToken() | String | Получение push-токена в случае его существования. Если push-токен не был создан ранее, будет создан новый push-токен и возвращен в методе |
deletePushToken() | void | Удаление push-токена в случае его существования и генерация нового push-токена |
connect( String? | void | Метод позволяет установить соединение с конечной точкой (WebSocket-сервисом) для получения пуш-уведомлений. Опциональные аргументы notificationChannelName и channelId позволяют настроить пользовательскую конфигурацию. В случае существования активного соединения активное соединение будет сброшено и заменено |
Таким образом, итоговыми артефактами в разработке будут:
Библиотека .aar;
Flutter-плагин с интегрированной библиотекой.
Шаг 2. Делаем свой .aar-модуль
Библиотека .aar — это обыкновенный архив в формате Android Library Project.

Схема взаимодействия библиотеки состоит из нескольких этапов:
Взаимодействие библиотеки с нативными функциями (подключение по WebSocket, генерация push-токенов).
Взаимодействие Flutter-плагина с нативной библиотекой с помощью методов API.
Взаимодействие Flutter-приложения с Flutter-плагином.
Для реализации базового функционала нам необходимо реализовать следующие классы:
Класс | Методы класса | Описание класса |
TokenManager | public String getPushToken(Context) — позволяет получить текущий push-токен public void deleteAndRegenerateToken(Context) — private String generateNewToken(Context) — приватный метод на генерацию нового пуш-токена | Класс, отвечающий за управление токенами авторизации |
WebSocketManager | public void connect(String webSocketUrl, String pushToken, WebSocketCallback callback) — реализует подключение к WebSocket с помощью аргумента webSocketUrl, в query подставляет аргумент pushToken и вызывает callback при получении сообщения с помощью интерфейса WebSocketCallback. public void disconnect() — В случае private void scheduleReconnect() — Реализует автоматическое переподключение в случае разрыва соединения | Класс, который отвечает за управление WebSocket- соединением и обеспечивает взаимодействие с сервером |
SdkResultCallback | void onSuccess(T value) — Удачное завершение метода с передачей результата. void onError(String error) — Неудачное завершение метода с передачей | Интерфейс описывает колбэк для асинхронного получения результата |
WebSocketCallback | void onConnected() — Успешное подключение к сокетам. void onDisconnected(String error) — Разрыв соединения с сокетами с передачей ошибки. void onMessageReceived(String message) | Интерфейс используется для получения событий от WebSocket- соединения |
NotificationService | Наследует методы класса Service. public void onCreate() — создает экземпляр класса WebSocketManager. public int onStartCommand(Intent intent, int public void onDestroy() — выполняет разрыв соединения веб-сокетов. private void createNotificationChannel() — приватный метод, выполняющий private void handlePushMessage(String message) — выполняет обработку полученного через сокеты сообщения и отображает пуш-уведомление. private void showPushNotification(String title, String text) — выполняет создание и отображение пуш-уведомления | Класс, отвечающий за обработку входящих push- уведомлений через WebSocket и передачу их в систему уведомлений Android |
PushNotificationModule | getInstance() — выполняет получение экземпляра (синглтон). public void getPushToken(Context context, SdkResultCallback callback) — выполняет получение пуш-токена из TokenManager и возвращает результат в callback. public void deletePushToken @NonNulll Context context, @NonNull SdkResultCallback callback) — выполняет удаление пуш-токена. Также останавливает работу сервиса уведомлений public void private void private void stopNotificationService @NonNull Context context) — приватный метод, останавливающий сервис уведомлений | Главный класс библиотеки, который предоставляет публичный API для интеграции push-уведомлений |
Шаг 3. TokenManager
Для реализации менеджера хранения токенов воспользуемся стандартным хранилищем SharedPreferences, которое будет указывать на файл, содержащий пары «ключ–значение». Для наименования файла воспользуемся конкатенацией идентификатора пакета приложения совместно с отдельным ключом, хранимым в STORAGE_KEY. Для генерации токена воспользуемся генератором UUID.
Пример реализации:
public class TokenManager { private static final String STORAGE_KEY = "push_token_storage"; private static final String PUSH_TOKEN_KEY = "push_token"; private static SharedPreferences _spInstance(@NotNull Context context) { return context.getSharedPreferences(context.getPackageName() + STORAGE_KEY Context.MODE_PRIVATE); } public static String getPushToken(Context context) { SharedPreferences sharedPreferences = _spInstance(context); String token = sharedPreferences.getString(PUSH_TOKEN_KEY, null); if (token == null) { token = generateNewToken(context); } return token; } public static void deleteAndRegenerateToken(Context context) { SharedPreferences sharedPreferences = _spInstance(context); sharedPreferences.edit().remove(PUSH_TOKEN_KEY).apply(); generateNewToken(context); } private static String generateNewToken(Context context) { String newToken = UUID.randomUUID().toString(); SharedPreferences sharedPreferences = _spInstance(context); sharedPreferences.edit().putString(PUSH_TOKEN_KEY, newToken).apply(); return newToken; } }
Шаг 4. WebSocketManager
Менеджер управления сокетами должен принимать в себя push-токен (для вставки его в параметры запроса), URL для инициализации, а также интерфейс с callback для реагирования на изменение состояния сокета.
Для подключения создадим простейшую реализацию подключения к сокетам на основе OkHttp3 в методе connect().
В методе disconnect() реализуем проверку существования экземпляра сокета и в случае его существования произведём разрыв соединения.
Разрыв соединения будет выполняться с кодом «1000» в соответствии с RFC 6455 —это индикатор, указывающий на нормальное закрытие соединения, цель которого выполнена.
В приватном методе scheduleReconnect() реализуем отложенное переподключение сокетов.
Пример:
public class WebSocketManager { private WebSocket webSocket; private static final long INITIAL_RECONNECT_DELAY = 5000; // 5 секунд private static final long MAX_RECONNECT_DELAY = 60000; // 60 секунд private long reconnectDelay = INITIAL_RECONNECT_DELAY; private WebSocketCallback callback; private final Handler reconnectHandler = new Handler(); private boolean isConnected = false; private String pushToken; private String webSocketUrl; public void connect( @NonNull String webSocketUrl, @NonNull String pushToken, @NonNull WebSocketCallback callback ) { if (isConnected) return; this.pushToken = pushToken; this.callback = callback; this.webSocketUrl = webSocketUrl; OkHttpClient client = new OkHttpClient(); Request request = new Request.Builder() .url(webSocketUrl + "?token=" + pushToken) .build(); webSocket = client.newWebSocket(request, new WebSocketListener() {@Override public void onOpen( @NonNull WebSocket webSocket, @NonNull okhttp3.Response response ) { isConnected = true; reconnectDelay = INITIAL_RECONNECT_DELAY; callback.onConnected(); } @Override public void onMessage( @NonNull WebSocket webSocket, @NonNull String text ) { callback.onMessageReceived(text); } @Override public void onFailure( @NonNull WebSocket webSocket, @NonNull Throwable t, okhttp3.Response response ){ callback.onDisconnected(t.getMessage()); isConnected = false; scheduleReconnect(); }); } public void disconnect() { if (webSocket != null) { reconnectHandler.removeCallbacksAndMessages(null); webSocket.close(1000, null); webSocket = null; } } private void scheduleReconnect() { if (reconnectDelay > MAX_RECONNECT_DELAY) { reconnectDelay = MAX_RECONNECT_DELAY; } reconnectHandler.postDelayed(() -> { connect(webSocketUrl, pushToken, callback); reconnectDelay *= 2; }, reconnectDelay); } }
Шаг 5. SdkResultCallback
Интерфейс с коллбэками работы метода будет использоваться в PushNotificationModule, а результат будет передаваться в кросс-платформу. Тип аргумента с результатом работы представляет из себя Generic для упрощения типизации итоговых значений.
Пример:
public interface SdkResultCallback<T> { void onSuccess(T result); void onError(String error); }
Шаг 6. WebSocketCallback
Интерфейс с коллбэками для веб-сокета примерно идентичен SdkResultCallback, за исключением того, что мы не используем Generic, так как коллбэк всегда возвращает сырые данные, переданные по сокетам.
Пример:
public interface WebSocketCallback { void onConnected(); void onDisconnected(String error); void onMessageReceived(String message); }
Шаг 7. NotificationService
Данный сервис отвечает за обработку сообщений, полученных от веб-сокета. Сервис запускается в фоновом режиме и не зависит от текущего состояния приложения.
В методе onCreate() создаётся экземпляр менеджера веб-сокетов.
В методе onStartCommand() получаем базовую конфигурацию и выполняем необходимые действия: создаём канал уведомлений с заданным channelId и channelName.
Особенностью работы сервиса является то, что после его запуска в течение 5 секунд необходимо вызвать метод startForeground() в соответствии с документацией Google:
Система позволяет приложениям вызывать Context.startForegroundService(), даже если приложение находится в фоновом режиме. Однако приложение должно вызвать метод startForeground() этой службы в течение пяти секунд после ее создания.
Для этого мы напишем небольшой хак, который рассмотрим далее.
3. После запуска сервиса запрашиваем push-токен из TokenManager и выполняем подключение к веб-сокетам.
Результатом работы onStartCommand будет константа START_STICKY. Она означает, что сервис будет восстановлен после уничтожения.
Также будут использованы следующие вспомогательные методы:
createNotificationChannel() для создания канала уведомлений,
handlePushMessage() для обработки push-сообщений,
showPushNotification() для вывода уведомления на устройство.
Пример:
public class NotificationService extends Service { private String channelId = ""; private WebSocketManager webSocketManager; private String channelName = ""; private String webSocketUrl = ""; @Override public void onCreate() { super.onCreate(); webSocketManager = new WebSocketManager(); } @Override public int onStartCommand(Intent intent, int flags, int startId) { if (intent != null) { channelId = intent.getStringExtra("CHANNEL_ID"); channelName = intent.getStringExtra("CHANNEL_NAME"); webSocketUrl = intent.getStringExtra("WEBSOCKET_URL"); } createNotificationChannel(); /// Хак для метода startForeground() Notification notification = new NotificationCompat.Builder(this, channelId) .setContentTitle("") .setContentText("") .setAutoCancel(true) .build(); startForeground(1, notification); String pushToken = TokenManager.getPushToken(this); webSocketManager.connect(webSocketUrl, pushToken, new WebSocketCallback() { @Override public void onConnected() {} @Override public void onDisconnected(String error) {} @Override public void onMessageReceived(String message) { handlePushMessage(message); } }); return START_STICKY; } @Nullable @Override public IBinder onBind(Intent intent) { return null; } @Override public void onDestroy() { super.onDestroy(); if (webSocketManager != null) { webSocketManager.disconnect(); } } private void createNotificationChannel() { NotificationChannel serviceChannel = new NotificationChannel( channelId, channelName, NotificationManager.IMPORTANCE_HIGH); getSystemService(NotificationManager.class).createNotificationChannel(serviceChanne l); } private void handlePushMessage(String message) { // Пример JSON: {"title": "Заголовок", "text": "Сообщение"} try { org.json.JSONObject json = new org.json.JSONObject(message); String title = json.optString("title", ""); String text = json.optString("text", ""); showPushNotification(title, text); } catch (Exception e) { } } private void showPushNotification(String title, String text) { Notification notification = new NotificationCompat.Builder(this, channelId) .setContentTitle(title) .setContentText(text) .setSmallIcon(android.R.drawable.ic_dialog_alert) .setAutoCancel(false) .build(); NotificationManager manager = getSystemService(NotificationManager.class); manager.notify(1, notification); } }
Шаг 8. PushNotificationModule
Данный модуль объединяет в себе функции, описанные ранее. Класс выступает в качестве публичного API, который в дальнейшем будет интегрирован в наш Flutter-плагин.
Для этого класса будут реализованы базовые методы, описанные ранее. Доступ к методам будет осуществляться с помощью метода Singleton getInstance().
Пример:
public class PushNotificationModule { private static PushNotificationModule instance; public static synchronized PushNotificationModule getInstance() { if (instance == null) { instance = new PushNotificationModule(); } return instance; } public void getPushToken( @NonNull Context context, @NonNull SdkResultCallback<String> callback ) { try { String token = TokenManager.getPushToken(context); callback.onSuccess(token); } catch (Exception e) { callback.onError(e.getMessage()); } } public void deletePushToken( @NonNull Context context, @NonNull SdkResultCallback<Boolean> callback ) { try { stopNotificationService(context); TokenManager.deleteAndRegenerateToken(context); callback.onSuccess(true); } catch (Exception e) { callback.onError(e.getMessage()); } } public void connectToWebSocket( @NonNull Context context, @NonNull String notificationChannelName, @NonNull String webSocketUrl, @NonNull String channelId, @NonNull SdkResultCallback<Boolean> callback ) { try { stopNotificationService(context); startNotificationService( context, notificationChannelName, webSocketUrl, channelId ); callback.onSuccess(true); } catch (Exception e) { callback.onError(e.getMessage()); } } private void startNotificationService( @NonNull Context context, @NonNull String notificationChannelName, @NonNull String webSocketUrl, @NonNull String channelId ) { Intent serviceIntent = new Intent(context, NotificationService.class); serviceIntent.putExtra("CHANNEL_NAME", notificationChannelName); serviceIntent.putExtra("WEBSOCKET_URL", webSocketUrl); serviceIntent.putExtra("CHANNEL_ID", channelId); context.startForegroundService(serviceIntent); } private void stopNotificationService( @NonNull Context context ) { Intent serviceIntent = new Intent(context, NotificationService.class); context.stopService(serviceIntent); } }
Решение для iOS
Так как Apple на iOS не поддерживает long-running-сервисы, осуществлять работу веб-сокетов в фоновом режиме практически невозможно — ОС быстро приостанавливает такие процессы.
Однако для поддержки платформы iOS мы интегрируем возможность получения APNs-токена в плагине — рассмотрим этот способ ниже.
Шаг 1. Интеграция с Flutter
Создаем шаблон Flutter-плагина с помощью команды:
flutter create --org com.example.push.plugin.flutter_push --template=plugin -- platforms=android,ios -a java -i swift flutter_push
с помощью параметра «-org» мы указали идентификатор нашего пакета,
с помощью параметров «-a» и «-i» мы указали предпочтительные языки на нативной стороне.
Перейдем к созданию PlatformChannel с помощью пакета pigeon. Для этого добавляем в dev зависимости пакеты «pigeon» и «build_runner»:
flutter pub add -d pigeon
flutter pub add -d build_runner
В папке lib создадим папку src, чтобы ненужные методы не индексировались во Flutter-проекте.
Перенесём файлы flutter_push.dart, flutter_push_method_channel.dart и flutter_push_platform_interface.dart в папку src.
Далее экспортируем только необходимый для нас интерфейс: в папке lib создадим файл flutter_push_plugin.dart и укажем, что мы экспортируем файл flutter_push.dart:
library flutter_push export 'src/flutter_push.dart';
Файловая структура выглядит следующим образом:

4. Теперь опишем интерфейс платформы и добавим необходимые для нас методы, которые мы будем вызывать во Flutter-проекте.
В файле flutter_push_platform_interface.dart укажем следующие методы:
Future<String> getPushToken() async { throw UnimplementedError('getPushToken() has not been implemented.'); } Future<void> deletePushToken() async { throw UnimplementedError('deletePushToken() has not been implemented.'); } Future<void> connectToWebSocket({ required String notificationChannelName, required String webSocketUrl, required String channelId, }) async { throw UnimplementedError('connectToWebSocket() has not been implemented.'); }
В файле flutter_push_plugin.dart, который мы недавно создали, определим эти методы:
class FlutterPush { Future<String> getPushToken() { return FlutterPushPlatform.instance.getPushToken(); } Future<void> deletePushToken() { return FlutterPushPlatform.instance.deletePushToken(); } Future<void> connectToWebSocket({ required String notificationChannelName, required String webSocketUrl, required String channelId, }) { return FlutterPushPlatform.instance.connectToWebSocket( notificationChannelName: notificationChannelName, webSocketUrl: webSocketUrl, channelId: channelId, ); } }
5. Прежде чем приступить к реализации интерфейса и вызову методов в MethodChannel, рассмотрим процесс вызова типизированных методов на нативной стороне с использованием pigeon:
1) В папке src создадим файл native_api.dart. В этом файле будет интерфейс, который мы в дальнейшем реализуем как на нативной стороне, так и на кроссплатформенной.
2) В файл native_api.dart добавим абстрактный класс native_api.dart и несколько аннотаций:
-@ConfigurePigeon() — конфигуратор генератора нативных интерфейсов,
-@HostApi() — указание абстрактного класса как хоста для работы с платформенным каналом.
3)В абстрактном классе укажем методы, которые будем использовать на нативной стороне. Каждый из методов аннотируем с помощью @async: таким образом данные методы будут возвращать Future T.
Пример:
@ConfigurePigeon( PigeonOptions( dartOut: 'lib/src/native_api.g.dart', swiftOut: 'ios/Classes/NativeApi.g.swift', dartOptions: DartOptions( sourceOutPath: 'lib/src/native_api.g.dart', ), javaOut: 'android/src/main/java/com/example/push/plugin/flutter_push/NativeApi.java', javaOptions: JavaOptions( package: 'com.example.push.plugin.flutter_push', ), dartPackageName: 'com.example.push.plugin.flutter_push', ), ) @HostApi() abstract class NativeHostApi { @async String getPushToken(); @async void deletePushToken(); @async void connectToWebSocket({ required String notificationChannelName, required String webSocketUrl, required String channelId, }); }
6. Теперь сгенерируем наш интерфейс с помощью команды:
dart run pigeon --input lib/src/native_api.dart
После генерации в папках android и iOS будут созданы файлы NativeApi.java и NativeApi.swift соответственно.
Вернёмся к файлу flutter_push_method_channel.dart, в котором мы указываем MethodChannel-методы. Теперь, после генерации интерфейса, нам необязательно вызывать метод класса MethodChannel(), достаточно вызвать метод из NativeHostApi.
Как было раньше:
await methodChannel.invokeMethod<String>('getPushToken')
Как будет теперь:
class MethodChannelFlutterPush extends FlutterPushPlatform { final NativeHostApi _native = NativeHostApi(); @override Future<String> getPushToken() async { final pushToken = await _native.getPushToken(); return pushToken; } ...
Теперь перейдем к самому интересному — интегрируем нашу .aar библиотеку и реализуем NativeHostApi-класс на нативной стороне.
Шаг 2. Интегрируем .aar-библиотеку
1. Для сборки ранее разработанного решения в папке с проектом выполним команду
./gradlew flutterpush:assembleRelease
Команда собирает release-версию .aar-файла и кладет её в папку output.
2. В папке с плагином переходим в папку android и создаем папку libs. Туда мы будем складывать все наши .aar-библиотеки.
3. Теперь нам нужно включить нашу библиотеку в наш плагин. Для этого на уровне build.gradle добавляем в список репозиториев flatDir и указываем, из какой директории будем получать зависимости.
Пример:
rootProject.allprojects { repositories { google() mavenCentral() flatDir { dirs project(':Идентификатор_плагина_flutter').file('libs') } }}
4. Остается только внедрить нашу зависимость в проект. Для этого в том же файле build.gradle объявляем внешнюю зависимость:
implementation(name: 'Название_вашего_aar_файла', ext: 'aar')
Шаг 3. Интегрируем методы библиотеки в плагин
Переходим в файл FlutterPushPlugin.java, видим, что наш класс реализует интерфейсы FlutterPlugin и MethodCallHandler. В данном случае MethodCallHandler нам больше не
нужен, так как у нас уже есть платформенный канал на основе интерфейса NativeHostApi.
1. Вместо MethodCallHandler реализуем интерфейс ActivityAware (для получения контекста) и наш новый интерфейс NativeHostApi.
Пример:
public class FlutterPushPlugin implements FlutterPlugin, ActivityAware, NativeApi.NativeHostApi
2. В методе onAttachedToEngine определяем контекст и инициализируем экземпляр NativeHostApi для обработки сообщений binaryMessenger.
Пример:
public void onAttachedToEngine( @NonNull FlutterPluginBinding flutterPluginBinding ) { channel = new MethodChannel(flutterPluginBinding.getBinaryMessenger(), "flutter_push"); NativeApi.NativeHostApi.setUp(flutterPluginBinding.getBinaryMessenger(), this); context = flutterPluginBinding.getApplicationContext(); }
3. После указания NativeHostApi в качестве интерфейса, методы которого мы будем реализовывать, наш FlutterPushPlugin получил доступ к тем методам, которые мы ранее генерировали с помощью pigeon. Нам лишь остается вызывать методы .aar-библиотеки в переопределенных методах и возвращать ответ в кроссплатформу.
Пример:
@Override public void getPushToken( @NonNull NativeApi.Result<String> result ) { PushNotificationModule.getInstance().getPushToken( context, new SdkResultCallback<String>() { @Override public void onSuccess(String s) { result.success(s); } }); }
Шаг 4. Дополнительные зависимости в Flutter-проекте
После интеграции всех необходимых методов добавим дополнительные зависимости в плагин.
1. В build.gradle проекта добавим новые зависимости.
Пример:
dependencies {
implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("androidx.appcompat:appcompat:1.7.0");
}
Но почему мы интегрируем okhttp3 и appcompact, хотя ранее внедряли эти зависимости внутри .aar библиотеки?
Дело в том, что .aar библиотека в текущей реализации не экспортирует зависимости, указанные в ней при разработке, поэтому все зависимости, которые мы указывали в .aar- библиотеке, указываем и в Flutter-плагине.
2. Теперь переходим в наш example проект, который находится в директории с Flutter- плагином. Открываем папку android/app и в build.gradle добавляем новую зависимость.
Пример:
dependencies { implementation fileTree(dir: 'libs', include: '*.aar') }
3. Однако для корректной работы этого всё еще недостаточно. Для корректной работы фоновых сервисов нам нужно задекларировать наш сервис, а также добавить пермишены для работы уведомлений в AndroidManifest.xml.
Пример:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_REMOTE_MESSAGING"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <application> <service android:name="com.example.<Путь к NotificationService в .aar- библиотеке>" android:foregroundServiceType="remoteMessaging" android:exported="false"/> </application>
Шаг 5. Проверяем результат
После проделанной работы зайдём в файл main.dart в example-проекте. Теперь нам доступны методы, которые мы разрабатывали ранее:
await FlutterPush().getPushToken(); await FlutterPush().deletePushToken(); await FlutterPush().connectToWebSocket( notificationChannelName: 'name', webSocketUrl: 'wss://...', channelId: 'id' );
Пробуем подключить веб-сокеты и отправить уведомление на устройство:

Шаг 6. Подключаем APNs к iOS версии
Для получения APNs-токена (пуш-токена) нам необходимо включить функцию push- уведомлений в XCode (в разделе Signing & Capabilities), а также зарегистрировать приложение в APNs. После регистрации приложения мы сможем получить глобальный уникальный токен устройства, который в дальнейшем сможем использовать для отправки пуш-уведомлений.
Регистрация приложения и получение токена не являются асинхронными методами, которые можно выполнить в одном стеке. В данном случае мы можем хранить наш APNs в локальной переменной и получать его при вызове метода нашего плагина.
Зарегистрировать приложение и получить push-токен можно с помощью следующих функций:
func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions:[UIApplication.LaunchOptionsKey: Any]?) -> Bool { UIApplication.shared.registerForRemoteNotifications() return true } func application( _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { // deviceToken - Наш пуш-токен }
Хотя Apple не рекомендует кэшировать токены устройств из-за их частой сменяемости, push-токен подобным методом хранит даже FCM SDK:
/// FLTFirebaseAuthPlugin.m - (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken { _apnsToken = deviceToken; }
Далее реализуем интерфейс NativeHostApi в нативном файле swift нашего плагина в директории iOS по аналогии с Android. Однако в отличие от Android, в момент обращения за пуш-токеном он может быть ещё не зарегистрирован. В подобном случае мы можем возвращать ошибку в обёртку Flutter.
Пример:
public class FlutterPushPlugin: NSObject, FlutterPlugin, NativeHostApi { public static func register(with registrar: FlutterPluginRegistrar) { let messenger : FlutterBinaryMessenger = registrar.messenger() let api : NativeHostApi & NSObjectProtocol = FlutterPushPlugin.init() NativeHostApiSetup.setUp(binaryMessenger: messenger, api: api) } func getPushToken( completion: @escaping (Result<String, any Error>) -> Void ) { if(deviceToken.isEmpty) { completion( .failure( PigeonError( code: "getPushToken", message: "Ошибка получения токена", details: "" ) ) ) } else { completion(.success(deviceToken)); } } }
Итоги и выводы
Создание примера интеграции кастомных пуш-уведомлений, а также разработка .aar- модуля для push-уведомлений через WebSocket оказалось одновременно сложной и полезной задачей.
Главная цель разработки кастомного сервиса — показать, что пуш-уведомления умеют работать и без прямой интеграции в официальные сервисы поставки пуш-уведомлений.
Основные препятствия в разработке были связаны с ограничениями платформ, не дающими реализовать стабильное WebSocket-соединение в фоновом режиме, с автоматическим переподключением, без лишней нагрузки на батарею и с корректным отображением уведомлений даже при закрытом приложении. Мы справились с этой проблемой с помощью foreground service, экспоненциальной задержки при переподключении и системы управления токенами через SharedPreferences.
Главная особенность решения: работа push-уведомлений не зависит от Firebase, что делает его удобным для приложений, уделяющих большое внимание безопасности.
Стоит особенно отметить разработанную библиотеку: готовая библиотека легко встраивается как в Native, так и в Cross-Platform, что делает её более гибкой и универсальной.
