Расскажу вам в этой статье, как я снизил потребление памяти моего 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.
Спасибо, что дочитали!
vhlv
У вас в личном кабинете перед футером косяк вылез.
И в футере какой-то парад иконок. Непонятно, что и куда ведет