Всем привет! На связи Вадим, старший разработчик компании 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, что делает её более гибкой и универсальной.