Расскажу вам в этой статье, как я снизил потребление памяти моего 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.

Спасибо, что дочитали!

Комментарии (1)


  1. vhlv
    12.12.2025 10:13

    У вас в личном кабинете перед футером косяк вылез.

    И в футере какой-то парад иконок. Непонятно, что и куда ведет