Расскажу вам в этой статье, как я снизил потребление памяти моего macOS-приложения на Flutter более чем на 90%. Это потребовало неожиданно много усилий и включало создание собственного хоста для Flutter, разработку пользовательского плагина для перетаскивания и отладку кучи кода на Rust.
Некоторое время назад я создал приложение со строкой меню для macOS под названием Quickgif. Оно удовлетворило мою давнюю потребность — иметь инструмент для выборки GIF-картинок, который можно использовать в любом приложении, не загружая GIF-ки вручную и не имея дела с разными реализациями, используемыми в других программах.
Я решил написать такое приложение на Flutter, чтобы проверить, можно ли добиться нативного восприятия без написания полноценного нативного macOS-приложения. После некоторых экспериментов приложение стало ощущаться довольно нативным и плавным (по крайней мере, для меня). Я выпустил приложение, и оно набрало немного пользователей, которые, казалось бы, оставались им довольны
Пока не начали появляться некоторые отзывы

Хм, давайте проверим.

Это нехорошо. И чем дольше вы скроллите, тем хуже. Quickgif справляется с прокруткой невероятно длинного списка GIF-ок, загружаемого напрямую через API Tenor. Tenor возвращает до 50 GIF за один запрос, при заданной начальной позиции. Это удобно, потому что позволяет подгружать n-ное количество GIF по мере прокрутки пользователем. И именно так работает Quickgif: он держит в памяти до n GIF и запрашивает ещё по мере того, как пользователь скроллит список. Однако если мы не выгружаем из памяти GIF-ки, которые находятся выше по списку, в итоге получаем огромное потребление памяти, которое резко увеличивается по мере прокрутки. Давайте подробно проверим, действительно ли это происходит.
Высвобождение кэшированных изображений из памяти
Сначала я запустил режим профилирования Flutter, который, на мой взгляд, довольно удобен. Это быстро подтвердило моё подозрение, что у нас есть проблемы с выгрузкой картинок из памяти после того, как мы их пролистали
Давайте посмотрим, как реализован список (упрощённая версия ниже)
child: StreamBuilder<List<GifMetadata>>( stream: widget.gifProvider.stream, builder: (context, snapshot) { [...] return MasonryGridView.count( cacheExtent: 10, controller: _scrollController, crossAxisCount: 5, // 5 GIFs per row crossAxisSpacing: 4.0, // Space between columns mainAxisSpacing: 4.0, // Space between rows itemCount: widget.gifProvider.currentGifs.length, itemBuilder: (context, index) { final gif = safeGet(widget.gifProvider.currentGifs, index); return GifContainer( [...] setFavorite: setFavorite, copyEvent: copyEvent, key: ValueKey(gif.id), gif: gif, ), }, ); }, ), ),
StreamBuilder обрабатывает поток входящих GIF-ок, которые динамически загружаются, как только пользователь прокручивает список дальше определённого порога. Затем мы загружаем в память новую пачку GIF-ок и добавляем их в список. MasonryGridView — это часть библиотеки для отображения сеток под названием flutter_staggered_grid_view, которая под капотом использует BoxScrollView и делегат с простым названием SliverSimpleGridDelegateWithMaxCrossAxisExtent. На мой взгляд, это похоже на обычные реализации списков во Flutter.
Кроме того, я уже ограничил cacheExtend всего лишь 10 изображениями. Поэтому чрезмерное кэширование, похоже, не является проблемой.
GifContainer был намеренно реализован как виджет, не сохраняющий состояния и использует CachedNetworkImage под капотом. CachedNetworkImage — отличная штука: как следует из названия, он кэширует загруженные изображения на диске, чтобы потом можно было быстро их доставать. Этот инструмент сэкономил мне много времени и во многом благодаря нему прокрутка в приложении работает плавно. Однако после некоторых исследований оказалось, что я не единственный, кто сталкивается с проблемами потребления памяти при использовании этого виджета. Я попробовал некоторые рекомендации из обсуждения, но не увидел значительных результатов. Более того, другие сообщают о ещё более серьёзных проблемах с памятью при использовании стандартных Flutter-виджетов ListView и Image.
Давайте опробуем некоторые рекомендации из того обсуждения и используем ExtendedImage.network, который поддерживает кэширование и включает флаг clearMemoryCacheWhenDispose.
В результате потребление памяти стабильно держится на уровне 500+ МБ, и оно, похоже, не растёт при активной прокрутке. Это уже более чем вдвое меньше, чем раньше. Но 500+ МБ всё ещё слишком много, особенно учитывая, что приложение работает в фоне. Я сам пользуюсь несколькими приложениями в строке меню, и похоже, что по крайней мере JetBrains Toolbox и ещё некоторые сталкиваются с похожими проблемами с памятью. Тем не менее, я не хотел мириться с таким большим постоянным потреблением памяти.
Принудительное завершение движка Flutter
Режим профилирования Flutter ранее показал, что сам движок и некоторые платформенные плагины занимают довольно много памяти. Для примера: запуск базового пример-приложения Flutter в режиме release занимает около ~170 МБ, пока оно активно на моей машине.
А что если мы будем полностью завершать движок Flutter и его плагины, когда приложение находится в фоне? Это освободило бы (почти) всё. Тогда давайте посмотрим, как Flutter обычно отображает приложения на macOS. Вот как запускается стандартное flutter-приложение на macOS.
class MainFlutterWindow: NSWindow { override func awakeFromNib() { let flutterViewController = FlutterViewController() let windowFrame = self.frame self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) RegisterGeneratedPlugins(registry: flutterViewController) super.awakeFromNib() } }
Мы создаём новый NSWindow, ждём, пока приложение «проснётся», добавляем FlutterViewController, регистрируем плагины и на этом заканчиваем. Это работает для большинства macOS-приложений, но так как мы говорим о приложении для строки меню, мы не хотим сразу запускать NSWindow — мы хотим показать окно только после того, как пользователь нажмёт на маленькую иконку в строке меню, показанную ниже.

На GitHub есть отличный пример проекта, показывающий, как сделать приложение для строки меню с Flutter, так как на данный момент это не поддерживается напрямую. Моя реализация в основном основывалась на этом примере. Ниже приведены некоторые из соответствующих частей кода:
@NSApplicationMain class AppDelegate: FlutterAppDelegate { var statusBar: StatusBarController? var popover = NSPopover.init() override init() { popover.behavior = NSPopover.Behavior.transient } override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return false } override func applicationDidFinishLaunching(_ aNotification: Notification) { let controller: FlutterViewController = mainFlutterWindow?.contentViewController as! FlutterViewController popover.contentSize = NSSize(width: 360, height: 360) popover.contentViewController = controller statusBar = StatusBarController.init(popover) guard let window = mainFlutterWindow else { print("mainFlutterWindow is nil") return } window.close() super.applicationDidFinishLaunching(aNotification) } }
Этот код делает несколько вещей:
Инициализирует NSPopover
Обеспечивает, чтобы приложение не завершалось после закрытия
Присоединяет FlutterViewController Flutter к нашему Popover
Создает StatusBarController, который в контексте этого поста не очень важен
Но такая реализация никогда не освобождает FlutterViewController и его движок, так как App Kit сам по себе, похоже, поддерживает его работу. Давайте изменим это, расширив FlutterViewController и явно завершая работу движка после закрытия панели.
После долгих манипуляций и полунедельного простоя в попытках заставить NSPopover вести себя так, как я хочу, я в итоге перешёл на NSPanel, что более-менее упростило задачу. Реализация, к которой я пришёл, выглядит примерно так:
class Panel: NSPanel { override var canBecomeKey: Bool { true } override var canBecomeMain: Bool { true } [...] } class PanelFlutterViewController: FlutterViewController { var launchChannel: FlutterMethodChannel? var openCloseChannel: FlutterMethodChannel? weak var delegate: PluginDelegate? init() { let engine = FlutterEngine( name: "engine_\(UUID().uuidString)", project: FlutterDartProject(), ) super.init(engine: engine, nibName: nil, bundle: nil) self.view.translatesAutoresizingMaskIntoConstraints = false } [...] } class MenuBarController: NSObject, PluginDelegate, PanelDelegate { func panelClosed() { // 1. Удалите представление Flutter из иерархии. panel?.contentViewController = nil // 2. Отключите делегатов, чтобы предотвратить дальнейшие сообщения. viewController?.delegate = nil panel?.delegate = nil // 3. Отмените регистрацию обработчиков каналов. viewController?.launchChannel?.setMethodCallHandler(nil) viewController?.openCloseChannel?.setMethodCallHandler(nil) // 4. Остановите движок. viewController?.engine.shutDownEngine() // 5. Очистить ссылки, чтобы вся выделенная под объекты память освободилась. viewController?.launchChannel = nil viewController?.openCloseChannel = nil viewController = nil panel = nil } [...]
Как выглядит использование оперативной памяти после наших изменений, когда приложение находится в фоне?

Уже лучше! Мы снизили потребление памяти в фоне более чем на 90%. Тем не менее, ~80 МБ всё ещё много для крошечного приложения, но пока я вполне доволен, учитывая, что это не нативное приложение.
Думаете, на этом всё? Ошибаетесь.
Проблемы плагина
Поскольку Quickgif требует множества функций, тесно связанных с платформой, на которой оно работает, я довольно рано добавил пакет super_native_extensions во время начальной разработки. Этот пакет просто отличный: он позволяет пользователям перетаскивать GIF прямо в любое приложение, добавляет поддержку горячих клавиш и управляет состоянием буфера обмена за меня. Пакет также официально поддерживается самим Flutter.
Однако, так как мы теперь завершаем работу движка Flutter, события клавиатуры не будут доходить до Dart, и приложение не сможет запускаться. Поэтому я в итоге использовал отличный пакет Hotkey от Swift, удалив любую работу с горячими клавишами из super_native_extensions. Но, судя по всему, пакет поглощает все macOS-события горячих клавиш сразу после запуска, ломая часть функционала приложения. К счастью, это не критично — я форкнул плагин и отключил часть с горячими клавишами.
Далее я столкнулся со случайными сбоями, связанными с тем, что я завершал и запускал новые движки каждый раз, когда пользователь открывал или закрывал приложение. Пользователи могут делать это довольно быстро, потому что Quickgif можно запускать и скрывать через глобальное сочетание клавиш. Сбои, похоже, были связаны с тем же пакетом. Я начал разбираться и в итоге немного изучил, как работает super_native_extensions. Удивительно, но большая часть пакета написана на Rust, что позволяет автору создавать платформонезависимый код, например, связанный с перетаскиванием, на одном языке. О его подходе можно почитать в этой статье. Немного разобравшись в проблеме, я пришёл к следующему:
+ (void)registerWithRegistrar:(NSObject<FlutterPluginRegistrar> *)registrar { int64_t engineHandle = nextHandle++; IrondashEngineContextPlugin *instance = [[IrondashEngineContextPlugin alloc] init]; instance->engineHandle = engineHandle; // На macOS нет уведомления об уничтожении, поэтому отслеживаем жизненный цикл // BinaryMessenger. _IrondashAssociatedObject *object = [[_IrondashAssociatedObject alloc] initWithEngineHandle:engineHandle]; objc_setAssociatedObject(registrar.messenger, &associatedObjectKey, object, OBJC_ASSOCIATION_RETAIN); // View становится доступным только после завершения registerWithRegistrar:. И // мы не хотим держать сильную ссылку на регистратор в экземпляре, потому что // он ссылается на движок, а, к сожалению, сам экземпляр будет подвержен утечкам // с текущей архитектурой Flutter-плагинов на macOS; dispatch_async(dispatch_get_main_queue(), ^{ _IrondashEngineContext *context = [_IrondashEngineContext new]; context->flutterView = registrar.view; context->binaryMessenger = registrar.messenger; context->textureRegistry = registrar.textures; // На macOS нет обратного вызова для отписки, что означает, что мы будем // создавать утечку экземпляра _IrondashEngineContext для каждого движка. // К счастью, экземпляр маленький и использует только слабые указатели для ссылки // на артефакты движка. [registry setObject:context forKey:@(instance->engineHandle)]; }); FlutterMethodChannel *channel = [FlutterMethodChannel methodChannelWithName:@"dev.irondash.engine_context" binaryMessenger:[registrar messenger]]; [registrar addMethodCallDelegate:instance channel:channel]; }
Для поддержки перетаскивния и других функций super_native_extensions внутренне использует IrondashEngineContext, чтобы получить FlutterView, сам движок и прочее. Фрагмент выше показывает процесс регистрации плагина и то, как он отслеживает жизненный цикл движка. Похоже, текущая архитектура Flutter-плагинов не особенно подходит для моего случая, который требует частого создания и уничтожения экземпляров движка.
Я пытался исправить некот��рые состояния гонки, которые, вероятно, возникают из-за того, как плагин должен отслеживать экземпляр движка, но в итоге пришлось полностью отказаться от библиотеки. К счастью, мне были нужны всего две функции: выброс файлов за пределы приложения и управление буфером обмена. Для управления буфером обмена есть множество Flutter-пакетов для разных платформ, проблем с этим нет. Но я не смог найти жизнеспособную альтернативу для перетаскивания произвольных файлов за пределы главного окна приложения.
В итоге я написал собственную реализацию, названную flutter_drop. Поскольку Quickgif доступен только для macOS на данный момент, реализация оказалась удивительно простой, и большую часть работы я закончил за день-два. Я не планирую расширять или публиковать плагин в ближайшее время, так как super_drag_and_drop уже покрывает большинство сценариев, поддерживает все основные платформы и имеет больше функций. Но, возможно, кому-то он окажется полезен.
Клавиатурный конечный автомат Flutter
Далее я заметил, что иногда в приложение не поступает ввод с клавиатуры. Мне удавалось воспроизвести проблему с переменным успехом, когда я открывал приложение через его горячую клавишу.
Вот что появлялось в логах
A KeyUpEvent is dispatched, but the state shows that the physical key is not pressed.
Покопавшись в исходниках Flutter, можно увидеть множество условий, которые проверяют, что клавиатурный конечный автомат Flutter не находится в некорректном состоянии.
void _assertEventIsRegular(KeyEvent event) { assert(() { [...] if (event is KeyDownEvent) { assert( !_pressedKeys.containsKey(event.physicalKey), 'A ${event.runtimeType} is dispatched, but the state shows that the physical ' 'key is already pressed. $common$event', ); [...] } }
Также можно найти тикет на GitHub, где люди сталкиваются с похожими проблемами. К счастью, по умолчанию assert’ы в Dart не выполняются в релизных сборках. И приложение всё равно работает нормально. Но это доставило мне немало головной боли.
Заключение
Когда я впервые прочитал отзывы, я ожидал, что мне придётся исправить простую проблему с ленивой загрузкой списка, на что ушло бы полдня. На деле же мне пришлось отлаживать большое количество кода, написанного как минимум на четырёх разных языках программирования, создать собственный flutter-host и написать свой плагин для перетаскивания. Это было очень интересно, и я многому научился! Но также это доставило массу головной боли в поиске надёжных решений. Надеюсь, кто-то другой, наткнувшись на это, сможет сэкономить время.
Возникли бы у меня эти проблемы, если бы я просто написал приложение напрямую на SwiftUI?
Скорее всего, нет.
Стал бы я снова делать что-то подобное на Flutter?
Вероятно, да — если бы целился в другие платформы.
Понравилось ли мне использовать Flutter в процессе разработки?
Определённо. Я смог очень быстро создать первоначальный прототип благодаря простоте Dart/Flutter, экосистеме и широкой распространённости фреймворка. В сравнении с документацией Apple, это было проще простого.
Рекомендовал бы я Flutter для создания macOS-приложений?
Возможно. Если у вас уже есть опыт с ним на мобильных платформах — дерзайте! В противном случае изучите другие многочисленные варианты или просто используйте SwiftUI, если вам нужна поддержка только для macOS.
Спасибо, что дочитали!
