Платформенный канал работает как именованная асинхронная труба для сообщений между Dart и платформой: Dart кодирует сообщение кодеком, движок передаёт его нативному обработчику, зарегистрированному на то же имя, а ответ возвращается как Future.
**MethodChannel: запрос и ответ: вызвать метод по имени с аргументами и дождаться одного результата; выбор по умолчанию (уровень заряда, открыть нативный экран)
EventChannel: поток, который нативная сторона пушит в Dart: сенсоры, геолокация, состояние сети
BasicMessageChannel**: сырые сообщения с кодеком на ваш выбор (StringCodec, JSONMessageCodec, BinaryCodec), и отправлять может любая сторона; это «брат» MethodChannel на более низком уровне, оба работают поверх одного BinaryMessenger
Синхронных вызовов нет: даже простой геттер возвращает Future, поэтому обращаться к платформе из build нельзя
Потоки: Dart вызывает из корневого изолята (или из зарегистрированного фонового); нативные обработчики работают на главном потоке платформы, если не дать каналу фоновую очередь задач
Имя и кодек должны совпадать с обеих сторон: опечатка проявится в рантайме как MissingPluginException, а не ошибкой компиляции, поэтому для чего-то крупнее прототипа этот код генерирует Pigeon
FlutterSenior
Платформенные каналы
MethodChannel, EventChannel, разработка плагинов, FFI
Закрыто вопросов
0 / 24
- 0Повторить
- 4Часто спрашивают
Вопросы
EventChannel передаёт непрерывный поток событий из нативного кода в Dart. Он нужен, когда платформа сама присылает значения со временем, а MethodChannel нужен, когда Dart спрашивает один раз и получает один ответ.
Dart вызывает receiveBroadcastStream(). Когда подписывается первый слушатель, нативная сторона получает onListen с event sink и отправляет в него success, error или endOfStream; когда отписывается последний, она получает onCancel.
Для чего: сенсоры, обновления геолокации, смена сети, прогресс загрузки или воспроизведения
**Освобождайте источник в onCancel: иначе сенсор или ресивер продолжит работать после ухода с экрана
Создайте стрим один раз и закешируйте**: каждый вызов receiveBroadcastStream() создаёт новую нативную подписку, а нативный обработчик у канала один, и вторая подписка его перезапускает
Отправляйте события с главного потока: sink шлёт сообщение в Dart, а на Android это должно происходить на главном потоке
Нативная сторона отвечает ошибкой вместо значения (result.error(code, message, details)), и Dart получает PlatformException, по code которого можно ветвиться.
Что приходит в Dart: result.error превращается в PlatformException; result.notImplemented() или отсутствие обработчика превращается в MissingPluginException
Ловите всё на нативной стороне: синхронное исключение на Android приходит как обычный PlatformException с кодом error; исключение в другом потоке или в колбэке роняет приложение или оставляет вызов без ответа, поэтому превращайте их в result.error со своим кодом
Используйте стабильные коды: PERMISSION_DENIED, NO_CAMERA, а текст для пользователя пишите в Dart, где живёт локализация
Встроенного таймаута нет: оберните вызов в .timeout(...), если зависание недопустимо
**Не кладите секреты в message и details**: они попадают в логи и крэш-репорты
Ожидаемые сбои (нет разрешения, нет сети) оформляются как ошибки с известным кодом; всё остальное считается багом, и сообщать о нём нужно как о баге.
Оба падения утверждают тип, которого стандартный кодек не обещал: Dart получает Map<Object?, Object?>, а Kotlin получает Integer или Long в зависимости от значения, поэтому читайте значения как Object? и Number и приводите их сами.
Кодек переносит значения, а не ваши типы. В Dart карта возвращается как Map<Object?, Object?>: она не знает, что все ключи оказались String, поэтому каст падает. В обратную сторону Dart-int приходит в Kotlin как Integer, только пока помещается в 32 бита, а дальше приходит как Long.
В Dart: используйте invokeMapMethod<String, Object?> и invokeListMethod, они сами вызывают .cast(), а вложенные карты приводите по одному уровню
В Kotlin: читайте числа как Number и сужайте сами; call.argument<Int>(...) делает непроверяемый каст, который работает на 42 в разработке и падает на настоящем таймстемпе
**Нет ни DateTime, ни enum: передавайте миллисекунды эпохи и имя значения enum, а единицы измерения фиксируйте в контракте метода
Настоящее решение**: один раз декодировать на границе в нормальные классы; ровно этот код за вас пишет Pigeon
Стандартный кодек умеет null, bool, int, double, String, типизированные данные вроде Uint8List и Float64List, а также List и Map из них. Ваш класс должен превратиться в одно из этого: в карту, в байты или в тип, которому вы научили кодек.
Во что они превращаются нативно: int становится Integer или Long на Android и NSNumber на iOS; Map превращается в HashMap или NSDictionary; Uint8List превращается в ByteArray или FlutterStandardTypedData
**toMap() и fromMap(): быстрый путь: годится для пары типов, но без типизации и написан трижды, на каждом языке
Байты, которые у вас уже есть**: JSON или Protobuf передаются как String или Uint8List без изменений
Свой кодек: наследуйтесь от StandardMessageCodec, в writeValue пишите байт-тег от 128 и выше и поля, в readValueOfType читайте их обратно; тег должен совпадать на всех сторонах, иначе ридер неверно разберёт остаток сообщения
**Делегируйте каждое поле в super.writeValue: так сохраняется выравнивание типизированных данных и стандартная кодировка вложенных значений
Обычно берут Pigeon**: он генерирует ровно такой кодек на всех сторонах; вручную стоит писать только на горячем пути
Pigeon работает как генератор кода: вы один раз описываете интерфейс на Dart, а он пишет согласованный типизированный код на Dart, Kotlin и Swift (или C++, Objective-C), который общается через обычный канал.
Типобезопасность с обеих сторон: никаких нетипизированных карт и строк с именами методов по всему коду
Переименование ломает сборку: сразу в Dart, Kotlin и Swift, а не падает в рантайме на телефоне пользователя
Оба направления: с @HostApi Dart вызывает нативный код, с @FlutterApi нативный код вызывает Dart; свежие версии генерируют и типизированные потоки событий
Классы данных и кодек достаются бесплатно: модель генерируется на каждой стороне вместе с подходящим кодеком, включая nullability
**@async: нативный метод получает колбэк и может ответить позже, с фонового потока
Когда сырые каналы ещё уместны**: прототип на один метод или плагин, который вы не контролируете; начиная с пары методов Pigeon становится лучшим выбором по умолчанию
Объявите разрешение в конфиге каждой платформы, затем запрашивайте его в рантайме в момент, когда оно нужно фиче (обычно через permission_handler), и обрабатывайте «выдано», «отказано» и «отказано навсегда» как разные случаи.
Два уровня: <uses-permission> в AndroidManifest.xml и строка-объяснение вроде NSCameraUsageDescription в Info.plist; без этой строки iOS завершит приложение в момент запроса
**permission_handler на iOS**: каждое используемое разрешение включается макросом PERMISSION_...=1 в Podfile; невключённые вырезаются при компиляции и всегда возвращают отказ
Спрашивайте в контексте: прямо перед фичей, после строки объяснения; стена запросов при запуске получает отказ
Отказано навсегда: iOS показывает системный диалог только один раз, Android перестаёт после второго отказа; дальше помогает только openAppSettings()
Перепроверяйте при возврате: пользователь может отозвать разрешение в настройках, и Android при этом убивает процесс приложения
Многие плагины спрашивают сами: camera, image_picker и geolocator запрашивают своё разрешение; permission_handler нужен, чтобы проверить статус, объяснить и отправить в настройки
Это значит, что сообщение дошло до платформы, но там никто не зарегистрировал обработчик на это имя канала, и платформа ответила «не реализовано».
Сначала проверьте это: после добавления зависимости вы сделали hot restart; плагины регистрируются при старте движка, поэтому новый плагин требует полной остановки и пересборки
Затем нативная сборка: не выполнился pod install, упала синхронизация Gradle или на устройстве стоит старая сборка; flutter clean и собрать заново
Платформа не поддерживается: в вебе, на десктопе или в flutter test нативной стороны может не быть вовсе, поэтому мокайте канал или деградируйте аккуратно
Фоновый изолят: в обработчике пушей или колбэке WorkManager плагинов нет, пока не вызван DartPluginRegistrant.ensureInitialized()
Add-to-app со своим движком: движок, созданный с выключенной регистрацией плагинов, или каналы на messenger-е другого движка дают ту же ошибку
Всё ещё не ясно? Залогируйте имя канала из исключения и сравните эту строку с нативной регистрацией: переименование, устаревший сгенерированный файл или две версии плагина на одно имя выглядят одинаково.
Каждый вызов должен получить ровно один ответ на каждом пути, а этот обработчик нарушает правило четырьмя способами: путь ошибки без ответа, двойной ответ, условный ответ и отсутствие запасной ветки.
**readFile**: отсутствующий файл или аргумент бросает исключение внутри launch: ответ не отправляется, а непойманное исключение в корутине роняет процесс; оберните в try/catch и ответьте result.error
**clearCache**: второй success бросает IllegalStateException: Reply already submitted на главном потоке, то есть краш; к тому же файлы удаляются на главном потоке
**ping**: когда connected равен false, никто не отвечает, и Future в Dart ждёт вечно, потому что у каналов нет таймаута
**Нет ветки else**: неизвестное имя метода, например от более новой Dart-стороны, зависает вместо MissingPluginException; всегда заканчивайте result.notImplemented()
У имени канала ровно один Dart-обработчик: каждый setMethodCallHandler заменяет предыдущий, поэтому слушатель чата молча вытесняет слушатель бейджа, а dispose() чата затем ставит null, и пуши больше никто не получает.
Почему так: обработчик лежит в BinaryMessenger под ключом имени канала, а не в объекте MethodChannel, поэтому любой MethodChannel с этим именем (даже новый экземпляр) перезаписывает ту же ячейку
Что видит нативная сторона: пока обработчик равен null, её invokeMethod получает notImplemented, и пуш теряется; Dart ничего не пишет в лог
Исправление: зарегистрируйте обработчик один раз, в одном владельце, которого касаются при старте, и раздавайте сообщения через broadcast-Stream; экраны подписываются в initState и отписываются в dispose, поэтому появляются и исчезают, не трогая канал
Как поймать в следующий раз: держите setMethodCallHandler для каждого канала ровно в одном месте; юнит-тест, который создаёт два слушателя и доставляет сообщение через TestDefaultBinaryMessengerBinding, показывает, что первый замолкает
build создаёт новый стрим при каждой перестройке. StreamBuilder видит другой объект стрима, отменяет старую подписку и подписывается на новую, поэтому нативная сторона получает onCancel, а следом onListen, и каждый раз перезапускает сенсор.
Хватит любой перестройки родителя: setState выше, Provider или MediaQuery, который читает родитель (клавиатура, тема); сам unit может не меняться
**.cast<int>() не спасает: он тоже возвращает новый объект стрима при каждом вызове
Исправление**: создать стрим один раз: поле static final, поле, заданное в initState, или репозиторий, который им владеет; тогда StreamBuilder получает тот же объект на каждом build и сохраняет подписку
Общее правило: никогда не создавайте Future или Stream для FutureBuilder или StreamBuilder внутри build
Подписка никогда не отменяется: dispose нет, поэтому после закрытия экрана у стрима остаётся слушатель, нативная сторона не получает onCancel, и GPS продолжает работать, а каждая новая точка вызывает setState у уже уничтоженного State.
Почему нативная сторона не останавливается: receiveBroadcastStream отправляет в натив listen при первой подписке, а cancel отправляет только тогда, когда отменена последняя; до этого у нативного StreamHandler нет причин останавливать обновления геолокации
Второй симптом: каждое событие вызывает setState у State в состоянии defunct, и в debug это бросает setState() called after dispose(); замыкание слушателя к тому же удерживает State в памяти, и это утечка
Исправление: отмените сохранённую StreamSubscription в dispose; отмена доходит до натива как onCancel, где обработчик снимает свой слушатель геолокации
Как поймать в следующий раз: анализатор уже предупреждает unused_field для _sub, а линт cancel_subscriptions подсвечивает поле-подписку, которая нигде не отменяется; виджет-тест с setMockStreamHandler может проверить, что onCancel срабатывает, когда экран убирают из дерева
Отставание, которое только растёт, означает, что что-то копится: события встают в очередь быстрее, чем UI-изолят их разбирает, или ваш код делает на каждое событие работу, растущую со временем. Каждое событие и так платит за кодирование, переход между потоками и оборот event loop в Dart.
Back-pressure нет: пауза подписки лишь буферизует, сенсор она замедлиться не просит
Работа, растущая со временем: добавление каждого значения в список и перерисовка графика с каждой секундой становятся медленнее; держите кольцевой буфер фиксированного размера. Вызовы setState внутри кадра сливаются в одну сборку, так что перестроек не больше 60 в секунду, но каждая всё равно чего-то стоит
Наводнение со стороны нативного кода: слушатель сенсора с SENSOR_DELAY_FASTEST, который постит каждое значение в главный looper, забивает тот же поток, на котором работает Dart UI (Flutter 3.29+); снижайте частоту или батчите до отправки
Чините в источнике: задайте период сэмплирования в SensorManager или CMMotionManager; 200 Гц почти никогда реально не нужны
Или батчите нативно: один Float64List за кадр вместо множества карт, либо отправляйте производное состояние (число шагов, встряхивание) вместо сырых значений
Обновляйте маленький виджет, а не весь экран: кладите последнее значение в ValueNotifier, который читает один небольшой ValueListenableBuilder
Для данных в килогерцах канал оказывается неподходящей трубой: используйте FFI с общим буфером.
Запустите flutter create --template=plugin --platforms=android,ios, и получите Dart API, нативный код и приложение example/; делите на federated-плагин, только когда платформы ведут или выпускают отдельно.
Регистрация генерируется: в секции flutter: plugin: platforms: указан pluginClass; инструмент пишет GeneratedPluginRegistrant, который создаёт его при старте движка
Нативный класс: на Android реализует FlutterPlugin (onAttachedToEngine, onDetachedFromEngine) и ActivityAware, если нужна Activity; на iOS принимает протокол FlutterPlugin и настраивает каналы в register(with:)
Контракт описывайте через Pigeon: а не строками с именами методов и картами
Упаковка под iOS: Flutter переходит с CocoaPods на Swift Package Manager, поэтому новый плагин стоит поставлять с Package.swift рядом с podspec
Делайте federated, когда платформы ведут или выпускают отдельно: my_plugin, пакет my_plugin_platform_interface с наследником PlatformInterface и по пакету на платформу
Не делайте federated внутренний плагин на две платформы: один пакет проще, а интерфейс можно выделить позже
Тестируйте по слоям: Dart-тесты с замоканным каналом, нативные юнит-тесты и integration_test в example/ на реальном устройстве
Для Dart вызов канала всегда асинхронный, но нативный обработчик по умолчанию выполняется на главном потоке платформы. Начиная с Flutter 3.29 на Android и iOS это ещё и поток, на котором работает ваш Dart UI-код, так что медленный обработчик напрямую срывает кадры.
Сторона Dart: invokeMethod отправляет сообщение и возвращает Future; вызывать его нужно из корневого изолята или из фонового, настроенного через BackgroundIsolateBinaryMessenger
Нативное поведение по умолчанию: обработчики работают на главном looper-е Android или главной очереди iOS; это нормально для быстрых вызовов и всего, что трогает нативный UI
Фоновая очередь задач: создайте канал с очередью из makeBackgroundTaskQueue(), и движок будет доставлять вызовы на фоновом потоке, сохраняя порядок
Или переключитесь сами: запустите корутину или отправьте работу в фоновую DispatchQueue и ответьте, когда она закончится
Ответы и новые сообщения: ответ через result можно отправить с любого потока, а новое сообщение в Dart (invokeMethod с нативной стороны, событие EventSink) можно отправить только с главного
Отвечайте ровно один раз: второй ответ бросает исключение, а без ответа Future в Dart ждёт вечно
Фильтр идёт через FFI, разрешение идёт через канал. FFI даёт прямой синхронный вызов в C без сериализации, он подходит для горячих чистых вычислений; канал доходит до Kotlin и Swift, а они нужны для системного UI и API, привязанных к Activity.
Цена канала: кодирование, копирование, переход между потоками, декодирование: нормально раз на действие пользователя, неприемлемо для 30 кадров в секунду по мегабайту
Цена FFI: обычный нативный вызов на вызывающем потоке, наносекунды; вы передаёте указатель на кадр, а не копию
FFI синхронен: он блокирует вызывающий изолят, поэтому долгая работа на C идёт в рабочий изолят или в собственный поток библиотеки, которая сообщает о результате через NativeCallable.listener
Память остаётся вашей заботой: calloc и free, Arena или NativeFinalizer; ошибка здесь приводит к крашу, а не к исключению
Упаковка: ffigen генерирует биндинги по C-заголовкам, а build hooks (native assets, стабильны с Dart 3.10) собирают и кладут библиотеку в сборку
Граница сдвигается: jnigen и поддержка Objective-C/Swift в ffigen генерируют прямые биндинги к платформенным API; они ещё молоды, поэтому для платформенных API по умолчанию остаются каналы с Pigeon
Перестаньте гонять пиксели через границу. Каждое сообщение канала копируется с обеих сторон, а нативный обработчик по умолчанию декодирует на главном потоке платформы.
RGBA-буфер на 12 мегапикселей занимает около 48 МБ, и во время передачи он существует в двух-трёх копиях одновременно, на двух кучах. Тем временем декодирование блокирует главный поток, на котором в актуальном Flutter работает и ваш Dart UI.
Передавайте путь или JPEG, а не сырые пиксели: затем декодируйте через ui.ImmutableBuffer и ui.ImageDescriptor, которые работают на рабочих потоках движка и умеют уменьшать размер при декодировании
Уберите обработчик с главного потока: дайте каналу фоновую очередь задач из makeBackgroundTaskQueue()
Если байты действительно должны пересечь границу: BasicMessageChannel с BinaryCodec убирает обёртку вызова метода и одну копию
Правило: каналы для управляющих сообщений; мегабайты в секунду передают через Texture, файл или память FFI
Каналу нужен BinaryMessenger, и он есть только у корневого изолята. Канал в порождённом изоляте не работает, пока вы не одолжите ему messenger корневого через BackgroundIsolateBinaryMessenger.ensureInitialized(token).
Передайте токен: в корневом изоляте прочитайте RootIsolateToken.instance! и отправьте его в изолят
Инициализируйте до любого канала: сначала вызовите BackgroundIsolateBinaryMessenger.ensureInitialized(token); после этого invokeMethod и большинство плагинов работают как обычно
Только Dart → нативный код: вызовы invokeMethod и события EventChannel, инициированные нативной стороной, идут в корневой изолят, а setMethodCallHandler в фоновом изоляте бросает исключение
Нативный обработчик всё равно работает на потоке платформы, если у канала нет фоновой очереди задач; изолят экономит только работу на стороне Dart
Более чистое разделение: часто корневой изолят делает платформенный вызов и отдаёт фоновому сырые данные
Изоляты, запущенные нативным кодом: фоновый обработчик пушей или точка входа WorkManager относятся к другому случаю: у них свой движок, и до первого обращения к плагину нужен DartPluginRegistrant.ensureInitialized()
Platform view встраивает настоящий нативный view (карту, WebView, рекламу) в дерево виджетов через AndroidView или UiKitView. Цена в том, что движку приходится совмещать две системы отрисовки в каждом кадре.
Нативный код регистрирует фабрику view под строкой view type, Dart создаёт view по этой строке, и каждый экземпляр получает id, чтобы до него мог дойти отдельный канал. На практике это уже сделал пакет вроде google_maps_flutter или webview_flutter.
Режимы на Android: AndroidView по умолчанию использует texture layer hybrid composition: нативный view рисуется в текстуру, это дёшево для Flutter, с фолбэком, когда так нарисовать нельзя (SurfaceView). initExpensiveAndroidView даёт полную hybrid composition: настоящий view в иерархии Android, лучшие ввод и доступность, но медленнее кадры
iOS: нативный view живёт в иерархии UIKit; каждый Flutter-виджет поверх него требует дополнительного overlay-слоя
Где больно: много platform views в прокручиваемом списке, виджеты поверх них, трансформации и клипы; профилируйте на слабом Android-устройстве
Тесты: golden- и виджет-тесты не могут его отрисовать, вы увидите пустую область
**Если нужны только пиксели, берите Texture**: нативный код пишет кадры в поверхность, которой владеет движок; так работают превью камеры и видеоплееры, и это намного дешевле
Activity приходит через колбэки ActivityAware, и правило против утечки простое: держать её можно только между attach и detach.
Два binding-а, два времени жизни: attach у FlutterPlugin даёт application context и messenger, и всё, что живёт дольше экрана, должно брать их; ActivityAware даёт Activity, которую вы сохраняете при attach и обнуляете в обоих detach-колбэках
Почему утечка дорога: Activity в поле переживает поворот экрана, и мёртвая Activity вместе со всем деревом view остаётся в памяти; статическое поле даёт тот же баг с ещё более долгой жизнью
Снимайте то, что регистрируете: удаляйте слушатели activity result и permission result при detach, иначе следующий attach добавит второй, и обработчик сработает дважды
Отвечайте один раз: даже запрос, который пользователь закрыл, должен дать ответ, иначе Future в Dart не завершится
Не рассчитывайте, что отложенный result переживёт пересоздание: Activity может пересоздаться, а процесс может умереть, пока открыт системный диалог
На iOS Activity нет: addApplicationDelegate даёт колбэки app delegate, с тем же правилом снятия регистрации.
Собственного фонового планировщика у Flutter нет: ОС будит приложение через свой API (WorkManager на Android, BGTaskScheduler на iOS), а плагин запускает headless-движок Flutter, который выполняет top-level точку входа на Dart.
Как выполняется Dart-сторона
Точка входа: top-level или статическая функция с @pragma('vm:entry-point'), иначе tree shaking в релизе её удалит
Новый изолят: провайдеров, блоков и кешей из UI здесь нет; вызовите DartPluginRegistrant.ensureInitialized() до плагинов, затем заново откройте базу и прочитайте токены из хранилища
Связь с UI идёт через хранилище: база данных или порт, зарегистрированный в IsolateNameServer, но не общие глобальные переменные
Что разрешает каждая ОС
Android: WorkManager (плагин workmanager) выполняет работу с ограничениями вроде наличия сети или зарядки; периодические задачи идут не чаще раза в 15 минут, а Doze их откладывает. Долгой видимой пользователю работе нужен foreground service с уведомлением и объявленным foregroundServiceType
iOS: гарантированного расписания нет: BGAppRefreshTask получает около 30 секунд, когда решит система, BGProcessingTask работает дольше, обычно на зарядке; id задач перечисляются в Info.plist, а принудительное закрытие приложения пользователем их останавливает. В iOS 26 появился BGContinuedProcessingTask для долгой работы, начатой пользователем
Геолокация: постоянное отслеживание означает отдельное разрешение (ACCESS_BACKGROUND_LOCATION, на iOS «Всегда» плюс фоновый режим location), foreground service на Android и вопрос на ревью в сторе
Правила проектирования
Делайте задачи короткими, идемпотентными и возобновляемыми и синхронизируйтесь снова при следующем запуске, потому что фоновый запуск может не случиться вовсе
Когда сервер знает об изменениях, пуш служит лучшим триггером, чем опрос
Flutter-часть собирается как модуль, который нативное приложение подключает как библиотеку, и показывается через FlutterActivity, FlutterFragment или FlutterViewController. За каждым стоит FlutterEngine, и его запуск составляет самую дорогую часть.
Модуль, а не приложение: flutter create -t module; хост подключает его как Gradle-зависимость с исходниками или готовый AAR, а на iOS подключает через CocoaPods или flutter build ios-framework, так что нативным разработчикам Flutter SDK не нужен
Зачем прогревать: холодный движок загружает Dart VM и ваш код и выполняет main, что видно как пустая пауза до первого кадра; запустите его заранее (Application.onCreate, старт приложения), положите в FlutterEngineCache и открывайте экраны через withCachedEngine
Цена: прогретый движок держит память, даже когда ни одного Flutter-экрана не видно; прогревайте, только если экран, скорее всего, откроют
Несколько Flutter-экранов: FlutterEngineGroup порождает движки, которые делят VM и скомпилированный код, поэтому каждый следующий обходится дёшево
Навигацией владеет хост: передайте начальный маршрут до запуска Dart и обменивайтесь данными через каналы или Pigeon на messenger-е этого движка
Плагины: движок регистрирует их автоматически; канал, созданный на messenger-е другого движка, становится классической причиной MissingPluginException
В этот момент Dart-обработчика на канале ещё нет, поэтому сообщение попадает в буфер каналов движка, а он по умолчанию хранит одно сообщение на канал и остальные выбрасывает с предупреждением.
Гонка заложена в архитектуре: обработчик регистрируется в main или initState, а при холодном старте это происходит после того, как Activity или AppDelegate уже обработали стартовый intent
Из пачки остаётся только последнее: тап по уведомлению плюс диплинк, и одно из них пропало
Используйте pull-подход: нативный код хранит payload, Dart один раз запрашивает его при старте и затем подписывается на последующие события; именно так устроены getInitialMessage и getInitialLink
Очищайте только после передачи: чтобы рестарт Dart его не потерял
Если без push никак: вызовите channelBuffers.resize(name, 8) из dart:ui в main до runApp, но сообщения, пришедшие до main, уже обрезаны, так что настоящим решением остаётся pull-подход
Пуш из нативного кода в Dart работает по принципу best effort, пока Dart не слушает; всё, что должно пережить холодный старт, должно быть доступно для чтения.
Используйте NativeCallable.listener. Это единственная форма колбэка, которую можно вызывать с потока, не принадлежащего Dart-изоляту.
**NativeCallable.listener(fn)**: отправляет аргументы в event loop изолята и запускает вашу Dart-функцию на одном из следующих оборотов. Именно это делает его потокобезопасным, и поэтому же он не может вернуть значение: нативная сигнатура обязана возвращать Void
**NativeCallable.isolateLocal(fn, ...): выполняется синхронно и может вернуть значение, но только на потоке самого изолята, то есть на практике во время вашего FFI-вызова. С любого другого потока это неопределённое поведение, обычно краш
Pointer.fromFunction, старая форма: только top-level функции, без замыканий, то же правило одного потока
Всегда вызывайте close(): живой listener держит изолят живым; это обычная причина, по которой порождённый изолят не завершается
Копируются только значения аргументов**: char*, который C освобождает после возврата из колбэка, уже висячий к моменту выполнения вашего Dart-кода, поэтому копируйте данные на нативной стороне
Порядок не гарантирован: колбэк прогресса может обработаться уже после того, как FFI-функция вернула управление