При разработке мобильных приложений на Flutter рано или поздно сталкиваешься с необходимостью проверить, как твой интерфейс выглядит на разных устройствах. Держать под рукой десяток телефонов и планшетов — нереально, а постоянно переключаться между эмуляторами — долго и неудобно.

Для решения этой проблемы я написал пакет yx_virtual_device, который позволяет симулировать экраны различных устройств прямо во время разработки, не выходя из запущенного приложения. В этой статье — о том, что умеет этот пакет, как с ним работать, а также почему было сложно подружить его с Flutter и как в итоге это удалось сделать.


Идея и начало использования

Я работаю в команде Pro Design & BDUI. Мы занимаемся разработкой и развитием дизайн‑системы и BDUI‑технологий, которые используются в приложении Яндекс Про. Тема тестирования UI для нас как нельзя близка и актуальна.

Когда мы отлаживали нашу систему адаптивности, а также проверяли экраны при разных настройках пользовательских масштабов (настройку внутри приложения), нам и нашим тестировщикам нужно было визуально проверить множество девайсов в разных состояниях (помимо масштабов): темы, языки, конфиги и так далее. Комбинаторная сложность ужасающая, да и такого количества девайсов под рукой не было. 

Возникла идея: раз Flutter контролирует каждый пиксель на экране, почему бы не рисовать любой виртуальный экран внутри реального? Так я начал работать над проектом, который вскоре был реализован и помог сделать эти глобальные тестирования намного проще. В итоге утилита стала неотъемлемой частью тестирования новых экранов и позволяет сохранять высокую адаптивность интерфейса. 

На рынке уже тогда был ближайший к нам аналог — device_preview, но он требовал серьёзных переработок и ломался в случаях, где была жёсткая завязка на размеры из биндингов или window, и в плагинах. Поэтому мы решили углубиться во фреймворк и разработать решение, которое легко встраивается и рисует без искажений вне зависимости от реализации вёрстки экрана.

Нюансы разработки

Итак, у нас есть фреймворк, который контролирует каждый пиксель на экране. Вроде бы должно быть всё просто? Нужно лишь научить Flutter рисовать в прямоугольнике нужного размера. Но переопределить ключевое поведение фреймворка оказалось не таким простым занятием, особенно поддерживать при миграции на новые версии SDK.

Главной проблемой стало то, что этот слой биндингов фреймворка представлен синглтонами, которые инициализируются при старте приложения, и потом их уже не изменить, и приватными сущностями, которые напрямую создаются нативной стороной / взаимодействуют с ней. Как же удалось выйти из ситуации? Достаточно подглядеть, как работают тесты во Flutter, а именно в TestWidgetsFlutterBinding.

По документации, чтобы переопределить поведение, достаточно у своей реализации биндинга вызвать ensureInitialized раньше, чем это сделает фреймворк. Наследовать функциональность просто не получится, поэтому нужно использовать класс как интерфейс, а основные вызовы делегировать этому классу. Доработка для виртуальных экранов была по большей части в RenderView, FlutterView, WidgetsFlutterBinding (переопределение физических размеров). Потом при переходе на View API дополнительно подменены Display, PlatformDispatcher, ViewConfiguration, устаревшая SingletonFlutterWindow. Патчить движок не потребовалось, поэтому получилось выделить всё необходимое в библиотеку и переиспользовать с разными версиями SDK.

Битва с Flutter SDK при миграции на View API

Всё работало хорошо до тех пор, пока во Flutter не начались серьёзные изменения для поддержки многооконности. Xтобы реализовать эту функциональность, фреймворк довольно сильно изменили, и из‑за этого мои виртуальные экраны перестали работать. 

Изначально казалось, что достаточно мигрировать по гайду. Код собрался, но при переключении на виртуальный экран картинка замирала на текущем кадре и далее не двигалась. 

Первой зацепкой стал коммит во фреймворк:

Источник

Но изменение уже попало в наш форк, и потребовалось копать дальше. Искать по коммитам оказалось не результативно, поэтому я решил сравнить две версии Flutter. Мне было важно только изменение в библиотеке рендеринга. Но ничего очевидного сразу не обнаружилось.

Стал детально анализировать поведение: экран перестаёт реагировать после переключения в режим эмуляции экрана. Дальше ни кадров, ни инструкций. То есть что‑то случалось в момент, когда движок рисует страницу. Предположил, что это нативное зависание или что‑то похожее.

Чтобы разобраться в реальных причинах, потребовалось собирать Flutter из исходников и дебажить нативный код. Это довольно запутанный, но в то же время увлекательный процесс, которому можно посвятить отдельную статью. Главный спойлер: собираЙте под iOS и отлаживаЙте. 

Зависаний в Dart‑части нет

Добавил дебажный флаг:

debugPrintScheduleFrameStacks = true;

Кадры подготавливаются и ставятся в очередь на отрисовку:

Код
flutter: scheduleFrame() called. Current phase is SchedulerPhase.persistentCallbacks.
flutter: #0      debugPrintStack (package:flutter/src/foundation/assertions.dart:1223:29)
flutter: #1      SchedulerBinding.scheduleFrame.<anonymous closure> (package:flutter/src/scheduler/binding.dart:955:9)
flutter: #2      SchedulerBinding.scheduleFrame (package:flutter/src/scheduler/binding.dart:958:6)
flutter: #3      SchedulerBinding.scheduleFrameCallback (package:flutter/src/scheduler/binding.dart:614:7)
flutter: #4      Ticker.scheduleTick (package:flutter/src/scheduler/ticker.dart:285:46)
flutter: #5      Ticker.start (package:flutter/src/scheduler/ticker.dart:192:7)
flutter: #6      AnimationController._startSimulation (package:flutter/src/animation/animation_controller.dart:870:42)
flutter: #7      AnimationController.repeat (package:flutter/src/animation/animation_controller.dart:742:12)
flutter: #8      _CircularProgressIndicatorState.initState (package:flutter/src/material/progress_indicator.dart:896:19)
flutter: #9      StatefulElement._firstBuild (package:flutter/src/widgets/framework.dart:5852:55)
flutter: #10     ComponentElement.mount (package:flutter/src/widgets/framework.dart:5699:5)
flutter: #11     Element.inflateWidget (package:flutter/src/widgets/framework.dart:4548:16)
flutter: #12     MultiChildRenderObjectElement.inflateWidget (package:flutter/src/widgets/framework.dart:7169:36)
flutter: #13     MultiChildRenderObjectElement.mount (package:flutter/src/widgets/framework.dart:7185:32)
flutter: #14     Element.inflateWidget (package:flutter/src/widgets/framework.dart:4548:16)
flutter: #15     MultiChildRenderObjectElement.inflateWidget (package:flutter/src/widgets/framework.dart:7169:36)
flutter: #16     MultiChildRenderObjectElement.mount (package:flutter/src/widgets/framework.dart:7185:32)
flutter: ...     Normal element mounting (16 frames)
flutter: #32     Element.inflateWidget (package:flutter/src/widgets/framework.dart:4548:16)
flutter: #33     Element.updateChild (package:flutter/src/widgets/framework.dart:4004:18)
flutter: #34     _LayoutBuilderElement._rebuildWithConstraints.updateChildCallback (package:flutter/src/widgets/layout_builder.dart:248:18)
flutter: #35     BuildOwner.buildScope (package:flutter/src/widgets/framework.dart:3046:19)
flutter: #36     _LayoutBuilderElement._rebuildWithConstraints (package:flutter/src/widgets/layout_builder.dart:271:12)
flutter: #37     RenderAbstractLayoutBuilderMixin.layoutCallback (package:flutter/src/widgets/layout_builder.dart:334:38)
flutter: #38     RenderObjectWithLayoutCallbackMixin.runLayoutCallback.<anonymous closure> (package:flutter/src/rendering/object.dart:4169:33)
flutter: #39     RenderObject.invokeLayoutCallback.<anonymous closure> (package:flutter/src/rendering/object.dart:2894:17)
flutter: #40     PipelineOwner._enableMutationsToDirtySubtrees (package:flutter/src/rendering/object.dart:1219:15)
flutter: #41     RenderObject.invokeLayoutCallback (package:flutter/src/rendering/object.dart:2893:14)
flutter: #42     RenderObjectWithLayoutCallbackMixin.runLayoutCallback (package:flutter/src/rendering/object.dart:4169:5)
flutter: #43     _RenderLayoutBuilder.performLayout (package:flutter/src/widgets/layout_builder.dart:448:5)
flutter: #44     RenderObject.layout (package:flutter/src/rendering/object.dart:2775:7)
flutter: #45     RenderProxyBoxMixin.performLayout (package:flutter/src/rendering/proxy_box.dart:115:18)
flutter: #46     RenderObject.layout (package:flutter/src/rendering/object.dart:2775:7)
flutter: #47     RenderProxyBoxMixin.performLayout (package:flutter/src/rendering/proxy_box.dart:115:18)
flutter: #48     RenderObject.layout (package:flutter/src/rendering/object.dart:2775:7)
flutter: #49     RenderPositionedBox.performLayout (package:flutter/src/rendering/shifted_box.dart:465:14)
flutter: #50     RenderObject.layout (package:flutter/src/rendering/object.dart:2775:7)
flutter: #51     RenderProxyBoxMixin.performLayout (package:flutter/src/rendering/proxy_box.dart:115:18)
flutter: #52     RenderCustomPaint.performLayout (package:flutter/src/rendering/custom_paint.dart:574:11)
flutter: #53     RenderObject.layout (package:flutter/src/rendering/object.dart:2775:7)
flutter: #54     ChildLayoutHelper.layoutChild (package:flutter/src/rendering/layout_helper.dart:62:11)
flutter: #55     RenderLimitedBox._computeSize (package:flutter/src/rendering/proxy_box.dart:396:41)
flutter: #56     RenderLimitedBox.performLayout (package:flutter/src/rendering/proxy_box.dart:410:12)
flutter: #57     RenderObject.layout (package:flutter/src/rendering/object.dart:2775:7)
flutter: #58     RenderProxyBoxMixin.performLayout (package:flutter/src/rendering/proxy_box.dart:115:18)
flutter: #59     RenderObject.layout (package:flutter/src/rendering/object.dart:2775:7)
flutter: #60     RenderProxyBoxMixin.performLayout (package:flutter/src/rendering/proxy_box.dart:115:18)
flutter: #61     RenderObject.layout (package:flutter/src/rendering/object.dart:2775:7)
flutter: #62     RenderProxyBoxMixin.performLayout (package:flutter/src/rendering/proxy_box.dart:115:18)
flutter: #63     RenderObject.layout (package:flutter/src/rendering/object.dart:2775:7)
flutter: #64     RenderView.performLayout (package:flutter/src/rendering/view.dart:294:12)
flutter: #65     RenderObject._layoutWithoutResize (package:flutter/src/rendering/object.dart:2623:7)
flutter: #66     PipelineOwner.flushLayout (package:flutter/src/rendering/object.dart:1170:18)
flutter: #67     PipelineOwner.flushLayout (package:flutter/src/rendering/object.dart:1183:15)
flutter: #68     RendererBinding.drawFrame (package:flutter/src/rendering/binding.dart:629:23)
flutter: #69     WidgetsBinding.drawFrame (package:flutter/src/widgets/binding.dart:1261:13)
flutter: #70     RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:495:5)
flutter: #71     SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1435:15)
flutter: #72     SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1348:9)
flutter: #73     SchedulerBinding.scheduleWarmUpFrame.<anonymous closure> (package:flutter/src/scheduler/binding.dart:1058:9)
flutter: #74     PlatformDispatcher.scheduleWarmUpFrame.<anonymous closure> (dart:ui/platform_dispatcher.dart:886:16)
flutter: #78     _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:193:12)
flutter: (elided 3 frames from class _Timer and dart:async-patch)

Зависаний в Embedder‑части нет

Прошёл дебагером по нативным вызовам: 

void Engine::Render(int64_t view_id,
                    std::unique_ptr<flutter::LayerTree> layer_tree,
                    float device_pixel_ratio) {...}
void Animator::Render(int64_t view_id,
                      std::unique_ptr<flutter::LayerTree> layer_tree,
                      float device_pixel_ratio) {...}

Всё отрабатывает корректно, дальше передаётся валидный layer_tree.

Зависаний в конвейере растеризации нет

Прошёлся циклично по ключевым вызовам конвейера без проблем:

DrawStatus Rasterizer::Draw(const std::shared_ptr<FramePipeline>& pipeline) {...}
Rasterizer::DoDrawResult Rasterizer::DoDraw(
    std::unique_ptr<FrameTimingsRecorder> frame_timings_recorder,
    std::vector<std::unique_ptr<LayerTreeTask>> tasks) {...}
std::unique_ptr<FrameItem> Rasterizer::DrawToSurfacesUnsafe(
    FrameTimingsRecorder& frame_timings_recorder,
    std::vector<std::unique_ptr<LayerTreeTask>> tasks) {...}

Ошибок и зависаний не обнаружено.

Нет задач на растеризацию (GPU‑задач)

В методе DrawToSurfacesUnsafe реализован механизм планирования (и оптимизации) задач, он работает от статуса задачи, который описывается через DrawSurfaceStatus.

// The result status of drawing to a view.
enum class DrawSurfaceStatus {
  // The layer tree was successfully rasterized.
  kSuccess,
  // The layer tree must be submitted again.
  kRetry,
  // Failed to rasterize the frame.
  kFailed,
  // Layer tree was discarded because its size does not match the view size.
  // This typically occurs during resizing.
  kDiscarded,
};

Меня заинтересовали статусы, которые отличались от kSuccess.

В функцию DrawToSurfacesUnsafe поступает не пустой список задач tasks, которые ставит UI‑поток. В теле функции происходит итерация по задачам с последующим отсеиванием, или перепланированием, или завершением.

// First traverse: Filter out discarded trees
  auto task_iter = tasks.begin();
  while (task_iter != tasks.end()) {
    LayerTreeTask& task = **task_iter;
    if (delegate_.ShouldDiscardLayerTree(task.view_id, *task.layer_tree)) {
      EnsureViewRecord(task.view_id).last_draw_status =
          DrawSurfaceStatus::kDiscarded;
      task_iter = tasks.erase(task_iter);
    } else {
      ++task_iter;
    }
  }

После вызова делегата delegate_.ShouldDiscardLayerTree(task.view_id, *task.layer_tree) список задач пуст. «Вот оно», — подумал я.

Оказалось, что, когда VirtualDevice выключен, вызов ShouldDiscardLayerTree постоянно возвращает false. Во включённом состоянии ShouldDiscardLayerTree постоянно возвращает true, и это приводит к тому, что задач не стоит и мы не обновляем растр у экрана. А это, в свою очередь, выглядит как зависание, но по сути им не является.

Также в один момент установил, что если сразу выставить виртуальный девайс, то произойдёт ошибка и не нарисуется ни одного кадра — появится белый экран:

Код
======== Exception caught by widgets library =======================================================
The following assertion was thrown building RawView:
set a configuration before calling prepareInitialFrame
'package:flutter/src/rendering/view.dart':
Failed assertion: line 261 pos 12: 'hasConfiguration'


Either the assertion indicates an error in the framework itself, or we should provide substantially more information in this error message to help you determine and fix the underlying cause.
In either case, please report this assertion by filing a bug on GitHub:
  https://github.com/flutter/flutter/issues/new?template=02_bug.yml

When the exception was thrown, this was the stack: 
#2      RenderView.prepareInitialFrame (package:flutter/src/rendering/view.dart:261:12)
#3      _RawViewElement.mount (package:flutter/src/widgets/view.dart:505:18)
...     Normal element mounting (15 frames)
#18     Element.inflateWidget (package:flutter/src/widgets/framework.dart:4548:16)
#19     Element.updateChild (package:flutter/src/widgets/framework.dart:4004:18)
#20     RootElement._rebuild (package:flutter/src/widgets/binding.dart:1716:16)
#21     RootElement.mount (package:flutter/src/widgets/binding.dart:1685:5)
#22     RootWidget.attach.<anonymous closure> (package:flutter/src/widgets/binding.dart:1638:18)
#23     BuildOwner.buildScope (package:flutter/src/widgets/framework.dart:3046:19)
#24     RootWidget.attach (package:flutter/src/widgets/binding.dart:1637:13)
#25     WidgetsBinding.attachToBuildOwner (package:flutter/src/widgets/binding.dart:1376:27)
#26     WidgetsBinding.attachRootWidget (package:flutter/src/widgets/binding.dart:1361:5)
#27     WidgetsBinding.scheduleAttachRootWidget.<anonymous closure> (package:flutter/src/widgets/binding.dart:1347:7)
#31     _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:193:12)
(elided 5 frames from class _AssertionError, class _Timer, and dart:async-patch)
====================================================================================================

======== Exception caught by Flutter framework =====================================================
The following assertion was thrown:
The render object for ErrorWidget-[#110e7] cannot find ancestor render object to attach to.

The ownership chain for the RenderObject in question was:
  ErrorWidget-[#110e7] ← RawView ← View ← [root]

Try wrapping your widget in a View widget or any other widget that is backed by a RenderTreeRootElement to serve as the root of the render tree.
====================================================================================================

======== Exception caught by scheduler library =====================================================
The following assertion was thrown during a scheduler callback:
set the RenderView configuration before calling compositeFrame
'package:flutter/src/rendering/view.dart':
Failed assertion: line 354 pos 14: 'hasConfiguration'


Either the assertion indicates an error in the framework itself, or we should provide substantially more information in this error message to help you determine and fix the underlying cause.
In either case, please report this assertion by filing a bug on GitHub:
  https://github.com/flutter/flutter/issues/new?template=02_bug.yml

When the exception was thrown, this was the stack: 
#2      RenderView.compositeFrame (package:flutter/src/rendering/view.dart:354:14)
#3      RendererBinding.drawFrame (package:flutter/src/rendering/binding.dart:634:20)
#4      WidgetsBinding.drawFrame (package:flutter/src/widgets/binding.dart:1261:13)
#5      RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:495:5)
#6      SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1435:15)
#7      SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1348:9)
#8      SchedulerBinding.scheduleWarmUpFrame.<anonymous closure> (package:flutter/src/scheduler/binding.dart:1058:9)
#9      PlatformDispatcher.scheduleWarmUpFrame.<anonymous closure> (dart:ui/platform_dispatcher.dart:886:16)
#13     _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:193:12)
(elided 5 frames from class _AssertionError, class _Timer, and dart:async-patch)
====================================================================================================

======== Exception caught by scheduler library =====================================================
The following assertion was thrown during a scheduler callback:
set the RenderView configuration before calling compositeFrame
'package:flutter/src/rendering/view.dart':
Failed assertion: line 354 pos 14: 'hasConfiguration'


Either the assertion indicates an error in the framework itself, or we should provide substantially more information in this error message to help you determine and fix the underlying cause.
In either case, please report this assertion by filing a bug on GitHub:
  https://github.com/flutter/flutter/issues/new?template=02_bug.yml

When the exception was thrown, this was the stack: 
#2      RenderView.compositeFrame (package:flutter/src/rendering/view.dart:354:14)
#3      RendererBinding.drawFrame (package:flutter/src/rendering/binding.dart:634:20)
#4      WidgetsBinding.drawFrame (package:flutter/src/widgets/binding.dart:1261:13)
#5      RendererBinding._handlePersistentFrameCallback (package:flutter/src/rendering/binding.dart:495:5)
#6      SchedulerBinding._invokeFrameCallback (package:flutter/src/scheduler/binding.dart:1435:15)
#7      SchedulerBinding.handleDrawFrame (package:flutter/src/scheduler/binding.dart:1348:9)
#8      SchedulerBinding._handleDrawFrame (package:flutter/src/scheduler/binding.dart:1201:5)
#9      _invoke (dart:ui/hooks.dart:330:13)
#10     PlatformDispatcher._drawFrame (dart:ui/platform_dispatcher.dart:444:5)
#11     _drawFrame (dart:ui/hooks.dart:302:31)
(elided 2 frames from class _AssertionError)
====================================================================================================

В чём же проблема? Для начала давайте посмотрим на функцию DrawToSurfacesUnsafe:

while (task_iter != tasks.end()) {
  if (delegate_.ShouldDiscardLayerTree(...)) {
    // ...
    task_iter = tasks.erase(task_iter);
  } else {
    ++task_iter;
  }
}

Здесь код пробегается по всем задачам и с помощью delegate_ устанавливает, не нужно ли отбросить эту задачу (LayerTree). Такое может произойти в двух случаях:

  • соответствующий View был уничтожен;

  • для этого View уже пришла более новая версия дерева слоёв и выполнять старую нет смысла.

Если задачу нужно отбросить, она удаляется из списка tasks.

if (tasks.empty()) {
  // ...
  return nullptr;
}

Если после фильтрации не осталось никаких задач, функция просто записывает тайминги и немедленно завершается, возвращая nullptr. Это означает «всё сделано, ничего перерисовывать не нужно». Как раз то, что происходит в нашем случае.

Теперь посмотрим на функцию ShouldDiscardLayerTree:

bool Shell::ShouldDiscardLayerTree(int64_t view_id,
                                   const flutter::LayerTree& tree) {
  // Блокировка мьютекса, чтобы платформа не изменила ожидаемый размер
  // прямо во время нашей проверки.
  std::scoped_lock<std::mutex> lock(resize_mutex_);

  // 1. Получаем АКТУАЛЬНЫЙ размер, который платформа считает правильным.
  auto expected_frame_size = ExpectedFrameSize(view_id);

  // 2. Сравниваем его с размером, с которым был создан LayerTree.
  //    tree.frame_size() — это «прошлый» размер с точки зрения UI-потока.
  return !expected_frame_size.isEmpty() && // Проверка имеет смысл, только если у нас есть ожидаемый размер.
         ToSkISize(tree.frame_size()) != expected_frame_size; // Размеры не совпадают? => Отклонить!
}

Этот механизм обеспечивает корректный и плавный визуальный переход при изменении размера окна. Он отсекает устаревшие кадры, предотвращая мерцание и артефакты, и гарантирует, что GPU тратит время только на отрисовку того, что действительно актуально.

Функция ExpectedFrameSize возвращает размер текущего физического окна по view_id:

SkISize Shell::ExpectedFrameSize(int64_t view_id) {
  auto found = expected_frame_sizes_.find(view_id);
  if (found == expected_frame_sizes_.end()) {
    return SkISize::MakeEmpty();
  }
  return found->second;
}

Прицепимся к процессу дебагером и посмотрим на состояние фреймворка:

Когда экран эмулируется, то обновляется конфигурация tree (задачи рендера) и не обновляется физический размер окна, поэтому последнее условие всегда false.

После исследований я перешёл к поиску решения. 

Итак, виртуальный экран рендерится внутри физического. И зона вне виртуального закрашивается в чёрный. То есть используется весь физический экран. Растровый буфер не меняется, так что дело, скорее всего, в конфигурации.

Распутаем клубок вызовов, посмотрев их в обратном порядке. Нам интересно, откуда пришла конфигурация до финальной команды на отрисовку в Embedder.

void Animator::Render(int64_t view_id,
                      std::unique_ptr<flutter::LayerTree> layer_tree,
                      float device_pixel_ratio) {...}
void Engine::Render(int64_t view_id,
                    std::unique_ptr<flutter::LayerTree> layer_tree,
                    float device_pixel_ratio) {...}

В takeLayerTree просто используются переданные параметры размеров. 

std::unique_ptr<flutter::LayerTree> Scene::takeLayerTree(uint64_t width,
                                                         uint64_t height) {
  return BuildLayerTree(width, height);
}

В итоге всё доходит до RuntimeController::Render.

// |PlatformConfigurationClient|
void RuntimeController::Render(int64_t view_id,
                               Scene* scene,
                               double width,
                               double height) {
  const ViewportMetrics* view_metrics =
      UIDartState::Current()->platform_configuration()->GetMetrics(view_id);
  if (view_metrics == nullptr) {
    return;
  }
  client_.Render(view_id, scene->takeLayerTree(width, height),
                 view_metrics->device_pixel_ratio);
  rendered_views_during_frame_.insert(view_id);
  CheckIfAllViewsRendered();
}

Меня заинтересовал метод RuntimeController::Render, который как раз настраивает область отрисовки. Он связан напрямую с Dart‑частью FlutterView.render через FFI.

Я посмотрел, какие изменения произошли между версиями. И тут удивительно, чутьё в начале меня не подвело, так я вернулся к изменениям из первой зацепки. Конкретно: 

Я стал использовать конфигурацию конкретной RenderView и фолбечиться на реальную конфигурацию экрана (раньше использовали только конфигурацию экрана). Так как в VirtualDevice конфигурация конкретной RenderView переопределяется для эмуляции экрана другого разрешения, оно стало вызывать вот такую ошибку:

Вывод: нужно переопределить метод render, чтобы он не принимал конфигурацию конкретной RenderView (сделать как раньше).

Тут возникают проблемы, так как наследовать FlutterView нельзя (нет публичного конструктора), также класс конфигурации приватный.

Получается, я никак не смогу это обойти со своей стороны? 

Конечно могу!

До обновления была похожая проблема с сущностью window. Можно сделать класс, который не является прямым потомком window, а просто гарантирует наличие тех же методов и свойств. То есть вместо extends использовать implements.

Тут я поступил аналогично. В итоге всё завелось и графически заработало даже лучше, чем было. Но появилась проблема с тачем: некорректно проецировались нажатия. Я локализовал проблему, во фреймворке стали учитывать плотность пикселей на экране для расчёта позиций. После добавления дополнительного преобразования всё заработало как часы.

yx_virtual_device: обзор результата 

Утилита yx_virtual_device позволяет непосредственно во время разработки и тестирования имитировать наиболее часто применяемые разрешения экрана без переустановок и сброса состояния. При использовании утилиты не требуются дополнительные физические устройства или эмуляторы, проверка комбинаций h × w × dpi осуществляется на рабочем устройстве пользователя.

Что умеет пакет:

  • Симулировать экраны приложений на устройствах с iOS, Android и кастомных устройствах.

  • Переключать устройства в реальном времени (hot‑swap).

  • Задавать точные размеры экрана, pixel ratio и безопасные зоны.

  • Интегрироваться в существующее приложение без серьёзных изменений кода.

  • Низкоуровнево переопределять Constraints, MediaQuery, View.

Для чего будет полезен этот инструмент: 

  • Тестирование адаптивности — проверяйте свою вёрстку на разных размерах экрана.

  • Сверка с дизайном — убедитесь, что реализация соответствует макетам.

  • Скриншоты для сторов — генерируйте скриншоты под разные устройства.

  • Воспроизведение багов — повторяйте проблемы, специфичные для конкретных экранов.

В отличие от device_preview, мой yx_virtual_device работает как низкоуровневый виртуальный экран, с которым не требуются доработки кода продукта: всё включается и отключается одной строчкой кода. А ещё в нём предопределены все ходовые размеры экранов, что позволяет рисовать любой блок UI.

Работа с yx_virtual_device

Установка

Установка пакета — простейшая. Нужно просто добавить зависимость в pubspec.yaml:

dependencies:
  yx_virtual_device: ^1.0.0

Быстрый старт

Вызовите VirtualDeviceDevtools.setup() до runApp:

import 'package:flutter/material.dart';
import 'package:yx_virtual_device/yx_virtual_device.dart';

void main() {
  VirtualDeviceDevtools.setup();
  runApp(const MyApp());
}

Используйте VirtualDeviceDevtools.setDevice() для смены симулируемого устройства:

// Установить конкретное устройство
VirtualDeviceDevtools.setDevice(Devices.ios.iPhone13);

// Вернуться к реальному устройству
VirtualDeviceDevtools.setDevice(null);

Доступные устройства

В пакете я собрал список экранов, который покрывает более 90% пользователей мобильных приложений.

Devices.standard.w360p3   // 360×800 @3x
Devices.standard.w375p3   // 375×812 @3x
Devices.standard.w414p3   // 414×896 @3x
// ... и другие

Также устройства можно выбрать по операционной системе. Например, устройства с iOS…

Devices.ios.iPhone13
Devices.ios.iPhone13ProMax
Devices.ios.iPhoneSE
Devices.ios.iPadPro
// ... и другие

…или с Android.

Devices.android.samsungGalaxyS21
Devices.android.pixel5
Devices.android.onePlus9
// ... и другие

Также можно раскатить список всех доступных устройств:

// Все устройства сразу
final allDevices = Devices.all;

// По платформе
final iosDevices     = Devices.ios.all;
final androidDevices = Devices.android.all;
final standardSizes  = Devices.standard.all;

А если нужного устройства нет в списке — создайте своё:

final myDevice = DeviceInfo(
  identifier: const DeviceIdentifier(
    TargetPlatform.android,
    DeviceType.phone,
    'my-custom-device',
  ),
  name: 'Моё устройство',
  pixelRatio: 3.0,
  screenSize: const Size(400, 800),
  safeAreas: const EdgeInsets.only(top: 44, bottom: 34),
  rotatedSafeAreas: const EdgeInsets.only(left: 44, right: 44),
);

VirtualDeviceDevtools.setDevice(myDevice);
Полноценный пример с выбором устройства через bottom sheet
import 'package:flutter/material.dart';
import 'package:yx_virtual_device/yx_virtual_device.dart';

void main() {
  VirtualDeviceDevtools.setup();
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  DeviceInfo? _currentDevice;

  void _setDevice(DeviceInfo? device) {
    setState(() {
      _currentDevice = device;
      VirtualDeviceDevtools.setDevice(device);
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: Text(_currentDevice?.name ?? 'Реальное устройство'),
        ),
        body: LayoutBuilder(
          builder: (context, constraints) {
            final mq = MediaQuery.of(context);
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text('Экран: ${mq.size.width.toInt()}×${mq.size.height.toInt()}'),
                  Text('Pixel Ratio: ${mq.devicePixelRatio}x'),
                  Text('Constraints: '
                      '${constraints.maxWidth.toInt()}×'
                      '${constraints.maxHeight.toInt()}'),
                ],
              ),
            );
          },
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () => _showDevicePicker(context),
          child: const Icon(Icons.devices),
        ),
      ),
    );
  }

  void _showDevicePicker(BuildContext context) {
    showModalBottomSheet(
      context: context,
      builder: (context) {
        final devices = <DeviceInfo?>[
          null,
          ...Devices.standard.all,
          ...Devices.ios.all.take(5),
          ...Devices.android.all.take(5),
        ];

        return ListView.builder(
          itemCount: devices.length,
          itemBuilder: (context, index) {
            final device = devices[index];
            return ListTile(
              leading: Icon(
                device == null ? Icons.phone_android : Icons.devices,
              ),
              title: Text(device?.name ?? 'Реальное устройство'),
              subtitle: device != null
                  ? Text(
                      '${device.screenSize.width.toInt()}×'
                      '${device.screenSize.height.toInt()} '
                      '@${device.pixelRatio}x',
                    )
                  : null,
              selected: device?.name == _currentDevice?.name,
              onTap: () {
                _setDevice(device);
                Navigator.pop(context);
              },
            );
          },
        );
      },
    );
  }
}

Библиотека yx_virtual_device не исправляет ошибки в вашей вёрстке автоматически, а помогает быстро обнаруживать их на самых разных экранах. Если вы открываете экран только на одном устройстве и верстаете чисто по дизайну (который обычно тоже делается под один конкретный экран), то большинство проблем могут остаться незамеченными. 

Вот наглядный пример. Ниже — два экрана корзины с ошибками и искажениями в вёрстке. Их легко пропустить, ведь их можно обнаружить только на маленьком телефоне или планшете.

Всё хорошо помещается на iPhone 14 (393px), но ломается на iPhone SE (320px). А на планшете — кнопка шириной 1000 пикселей и всё мелко
Всё хорошо помещается на iPhone 14 (393px), но ломается на iPhone SE (320px). А на планшете — кнопка шириной 1000 пикселей и всё мелко

Раньше для по‑настоящему качественной проверки адаптивности приходилось либо держать под рукой несколько телефонов и планшетов, либо запускать несколько эмуляторов. Теперь же достаточно буквально трёх переключений виртуального экрана прямо в режиме разработки, чтобы убедиться в корректной работе интерфейса на всех популярных устройствах и сразу внести необходимые правки. Это существенно экономит время — и вам, и тестировщикам — и позволяет быть уверенным, что ваши пользователи получат качественный UI вне зависимости от своих устройств.

В ближайших релизах я планирую добавить больше предустановленных конфигураций устройств, а также повышать удобство использования и со временем интегрироваться во Flutter Dev Tools. 

Пробуйте yx_virtual_device, проверяйте свои приложения на разных экранах, делитесь впечатлениями. Буду рад отзывам, багрепортм, предложениям и любым историям использования. Ваша обратная связь поможет сделать инструмент ещё полезнее для Flutter‑сообщества.