Советы по оптимизации производительности растрового потока

Kate

Administrator
Команда форума
Недавно я сел за работу по настройке производительности FlutterFolio, приложения, которое было создано в качестве демонстрации дизайна для Flutter Engage. С помощью одного изменения я сделал FlutterFolio значительно быстрее.

d83221060a0911c4819397ab3ccea898.png

Но сначала пришлось поискать то, что необходимо было изменить. Эта статья посвящена этому поиску.

FlutterFolio — это полнофункциональное приложение, которое было создано за 6 недель (!) от разработки до реализации, для мобильных, настольных и веб-версий. Команде разработчиков явно пришлось срезать некоторые углы — не обессудьте. Масштаб проекта и очень сжатые сроки вынудили их сделать это.

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

А оптимизация производительности лучше объясняется на реальных приложениях, чем на искусственно созданных проблемах.

Шаг 1: Профилирование производительности​

Что является первым шагом любой оптимизации? Измерение. Одного знания того, что приложение кажется медленным, недостаточно. Нужно быть немного более точным. По двум причинам:

  1. Измерение может указать нам на наихудших из виновников кризиса. Каждую часть любого приложения можно сделать быстрее и эффективнее. Но нужно с чего-то начинать. Профилирование производительности позволяет нам увидеть, какие части работают хорошо, а какие — плохо. Далее мы можем сосредоточиться именно на тех частях, которые работают плохо, и добиться большего прогресса за ограниченное время.
  2. Мы можем сравнить "до" и "после". Иногда изменение кода кажется хорошей идеей, но в реальности оно не дает существенной разницы. Имея исходную точку (до), можно количественно оценить эффект от наших изменений. Можно сравнить "до" с тем, что будет "после".
Профилирование производительности приложений — сложная задача. Я написал об этом длинную статью в 2019 году. Итак, давайте начнем с простого. Запускаем приложение в режиме профиля, включаем наложение производительности и начинаем использование приложения, наблюдая за графиком, который демонстрирует нам процесс данного оверлея.

4cff41f17278001711afd8993b146811.png

Сразу видно, что растровый поток испытывает трудности.

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

Давайте посмотрим, что делает растровый поток.

Отступление: Поток пользовательского интерфейса в сравнении с растровым​

На самом деле, давайте сначала проясним, что делает растровый поток.

Все приложения Flutter работают как минимум в двух параллельных потоках: UI thread (пользовательский поток) и Raster thread (растровый поток). Пользовательский поток — это место, где собираются виджеты и выполняется логика вашего приложения. (Вы также имеете возможность создавать изоляты, то есть можно запускать свою логику в других потоках, но для простоты проигнорируем это). Растровый поток — это то, что Flutter использует для _растеризации_ вашего приложения. Он принимает инструкции от пользовательского потока и преобразует их в то, что может быть отправлено на графическую карту.

Чтобы быть более конкретным, давайте посмотрим на функцию сборки:

Widget build(BuildContext context) {
return Image.asset('dash.png');
}
Приведенный выше код выполняется в потоке пользовательского интерфейса. Фреймворк Flutter решает, где разместить виджет, какой размер ему придать и так далее — все еще в UI потоке.

Затем, после того как Flutter узнает все о фрейме, он переходит к растровому потоку. Растровый поток принимает байты в `dash.png`, изменяет размер изображения (если необходимо), а затем применяет к нему непрозрачность, режимы наложения, размытие и так далее, пока не получит необходимые пиксели. Затем растровый поток отправляет полученную информацию на видеокарту и, следовательно, на экран.

a31b05e17f503561a45234fe60493267.png

Шаг 2: Разбираемся в хронологии событий​

Итак, вернемся к FlutterFolio. Открыв Flutter DevTools, мы можем более детально изучить временную шкалу.

12562496a80824650576f549653b7c66.png

На вкладке Perfomance (Производительность) видно, что пользовательский поток (бледно-голубые полосы) работает достаточно хорошо, в то время как растровый поток (синие и красные полосы) занимает поразительно много времени для каждого фрейма, особенно при прокрутке вниз главной страницы. Таким образом, проблема не в неэффективных методах построения или бизнес-логике. Проблема в том, что растровому потоку приходится делать слишком много.

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

Давайте выберем фрейм и посмотрим на панель Timeline Events (хронология событий)

97222d56181a0b4c6336ea2103529350.png

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

Ниже пользовательского потока отображаются события растрового потока, начиная с GPURasterizer:Draw. К сожалению, именно здесь все становится немного запутанным. Здесь много вызовов экзотически звучащих методов, таких как TransformLayer::preroll, OpacityLayer::preroll, PhysicalShapeLayer::paint и так далее. Нет никаких подробностей о том, что происходит в этих методах, и это не те имена, которые узнают большинство разработчиков Flutter.

Это методы C++ из движка Flutter Engine. Если вам захочется, то можете выполнить поиск по этим именам методов и прочитать код и комментарии, чтобы понять, что происходит под капотом. Иногда это позволяет получить немного больше представлений о том, что делает растровый поток. Но этот тип исследования не является строго обязательным для поиска проблем производительности. (Я не занимался этим до относительно недавнего времени, и, тем не менее, мне удалось оптимизировать производительность довольно многих приложений).

Далее, есть длинное событие под названием SkCanvas::Flush. Оно занимает 18 миллисекунд, что превышает разумные пределы. К сожалению, это событие также не содержит никакой подробной информации, поэтому нам придется немного поработать в роли детектива.

Sk в SkCanvas означает Skia, графический движок, который Flutter использует для рендеринга в самом низу своего стека. SkCanvas — это низкоуровневый класс C++, аналогичный собственному Canvas во Flutter (с которым вы можете быть знакомы, если работаете с CustomPaint). Все пиксели, линии, градиенты — весь пользовательский интерфейс вашего приложения — проходит через SkCanvas. И SkCanvas::Flush — это то место, где данный класс делает большую часть своей работы после того, как соберет всю необходимую информацию. В документации сказано, что метод Flush "выполняет все незавершенные операции с GPU (Graphics Processing Unit. Графический процессор)".

Давайте проанализируем, что мы узнали из графика производительности:

  • Растровый поток является основной проблемой. Поток пользовательского интерфейса работает относительно нормально.
  • При прокрутке растровый поток затрачивает много времени для _каждого фрейма_. Постоянно выполняется какая-то затратная работа по растеризации.
  • `SkCanvas::Flush` занимает много времени, что означает, что Skia делает большое количество дополнительной работы.
Мы не знаем, в чем заключается эта работа. Давайте изучим код.

Шаг 3: Прочитать код​

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

e58661c60b9e40bfdd40c181375a4d2f.png

Главная страница FlutterFolio, по крайней мере на мобильных устройствах, выглядит как вертикальный PageView, заполненный виджетами BookCoverWidgets. Если посмотреть на BookCoverWidget, то можно увидеть, что он представляет собой стек различных виджетов, начиная с большого изображения внизу, продолжая некоторыми анимированными накладками, основным текстовым содержимым и заканчивая накладкой при наведении мыши вверху.

child: Stack(fit: StackFit.expand, children: [
/// /////////////////////////////
/// Background Image
// Animated scale for when we mouse-over
AnimatedScale(
duration: Times.slow,
begin: 1,
end: isClickable ? 1.1 : 1,
child: BookCoverImage(widget.data),
),
/// Black overlay, fades out on mouseOver
AnimatedContainer(duration: Times.slow,
color: Colors.black.withOpacity(overlayOpacity)),
/// When in large mode, show some gradients,
/// should sit under the Text elements
if (widget.largeMode) ...[
FadeInLeft(
duration: Times.slower,
child: _SideGradient(Colors.black),
),
FadeInUp(child: _BottomGradientLg(Colors.black))
] else ...[
FadeInUp(child: _BottomGradientSm(Colors.black)),
],
/// Sit under the text content, and unfocus when tapped.
GestureDetector(behavior: HitTestBehavior.translucent,
onTap: InputUtils.unFocus),
/// BookContent, shows either the Large cover or Small
Align(
alignment: widget.topTitle ? Alignment.topLeft : Alignment.bottomLeft,
// Tween the padding depending on which mode we're in
child: AnimatedContainer(
duration: Times.slow,
padding: EdgeInsets.all(widget.largeMode ? Insets.offset : Insets.sm),
child: (widget.largeMode)
? LargeBookCover(widget.data)
: SmallBookCover(widget.data, topTitle: widget.topTitle),
),
),
/// Mouse-over effect
if (isClickable) ...[
Positioned.fill(child: FadeIn(child: RoundedBorder(color: theme.accent1, ignorePointer: false))),
],
]),
Помните: вы ищете что-то, происходящее в каждом фрейме (то есть, присутствующее всегда), и что потенциально затратно для рендерера Skia чтобы нарисовать (изображения, размытия, смешивания и так далее).

Шаг 4: Углубиться в детали​

Теперь необходимо углубиться в поиск виджетов, которые могут быть проблемными. Один из способов сделать это — временно удалить различные виджеты из приложения и посмотреть, как это повлияет на производительность.

Помните, что первым дочерним элементом Stack является фон, а каждый последующий является слоем поверх предыдущих виджетов. Итак, первый дочерний элемент — это фоновое изображение, представленное BookCoverImage. Вы можете удалить его, но главная страница будет выглядеть следующим образом:

3274feaa6e7feda5d52f8d397a922daf.png

Это лишает смысла всю страницу. Если присмотреться к BookCoverImage, можно увидеть, что это простая обертка вокруг Image. За одним примечательным исключением (о котором будет сказано позже в этой статье), здесь мало того, что можно улучшить.

Идем дальше, вот этот код:

/// Black overlay, fades out on mouseOver
AnimatedContainer(duration: Times.slow,
color: Colors.black.withOpacity(overlayOpacity)),
Это виджет, который покрывает все изображение слоем прозрачного черного цвета. По умолчанию (и большую часть времени) overlayOpacity равен 0, поэтому этот слой полностью прозрачен. Хорошо. Давайте удалим его и снова запустим приложение в режиме профиля.

3b87c71cfbe271763c934f9e0dc5f0e8.png

Интересно! Растровый поток по-прежнему потребляет довольно много ресурсов, но наблюдается значительное улучшение производительности.

Я решил внедрить более надежный инструмент профилирования производительности для FlutterFolio, чтобы доказать, что улучшение реально, а не просто случайность. Это изменение дает мне впечатляющее снижение времени растеризации на 20% от общего процессорного времени и на 50% меньше потенциальных ошибок.

b00ea4f3712422cbe09b8b594ffa0df4.png

В целом, это серьезное изменение, которое служит основанием для удаления одного виджета, который большую часть времени ничего не делает.

Исправление простое:

/// Black overlay, fades out on mouseOver
if (overlayOpacity > 0)
AnimatedContainer(duration: Times.slow,
color: Colors.black.withOpacity(overlayOpacity)),
Теперь вы добавляете прозрачный слой только тогда, когда он имеет ненулевую полупрозрачность (то есть он хотя бы частично виден). Вы избегаете (очень распространенного!) сценария, при котором полностью прозрачный слой создается и растеризуется, но при этом не оказывает никакого эффекта.

И, как результат, работа приложения становится более стабильной и экономичной.

Примечание: Зачем это нужно? Разве Flutter не должен быть достаточно умным, чтобы сделать эту оптимизацию за нас? Прочитайте здесь, чтобы узнать, почему он не может этого сделать. И почему прозрачная непрозрачность изначально медленная? Это выходит за рамки данной статьи, но это связано с виджетом BackdropFilter, расположенным дальше по Stack, который взаимодействует со всеми виджетами, расположенными ниже него.

Суть этой статьи не в том, чтобы преподать вам урок об этом конкретном "подводном камне" для производительности. Возможно, вы больше никогда его не увидите. Моя цель - научить вас, как оптимизировать производительность растрового потока в целом.

Шаг 5: Обобщение​

Прежде чем переходить к рассмотрению новой проблемы, часто бывает полезно поискать похожие недостатки где-то еще в проекте. Есть ли в нашем приложении еще места с наложениями большой площади? Можно ли их избежать?

В рассматриваемом случае следующие несколько строк создают обширные градиенты, которые становятся все отчетливее и ярче по мере прокрутки:

/// When in large mode, show some gradients,
/// should sit under the Text elements
if (widget.largeMode) ...[
FadeInLeft(
duration: Times.slower,
child: _SideGradient(Colors.black),
),
FadeInUp(child: _BottomGradientLg(Colors.black))
] else ...[
FadeInUp(child: _BottomGradientSm(Colors.black)),
],
И, конечно, удаление этих анимированных, почти полноэкранных градиентов значительно улучшает производительность прокрутки. К сожалению, в данном случае решение будет не такое простое, как в предыдущем примере. Эти градиенты не являются невидимыми. Они начинают постепенно нарастать, как только пользователь достигает этой области. Их удаление действительно заметно изменит внешний вид.

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

Но это изменение моушн-дизайна приложения, и поэтому его нужно обсудить с более крупной командой. Многие оптимизации производительности попадают в эту категорию. Оптимизация производительности часто является вынужденным компромиссом.

Повторяйте шаги 2-5, пока не останетесь довольны​

До сих пор мы рассмотрели только один тип проблем. На практике их всегда больше.

Вот одна из идей, что делать дальше: не великоваты ли ресурсы, отведенные под изображения в приложении? Помните, что растровый поток отвечает за прием байтов изображения, их декодирование, изменение размера, применение фильтров и так далее. Если он загружает и изменяет размер изображения высокой четкости размером 20 МБ в крошечное изображение аватара на экране, значит, вы расходуете ресурсы нерационально.

Когда ваше приложение работает в режиме отладки, с помощью Flutter Inspector можно инвертировать слишком большие изображения.

1090179d25f6e50825cc4757eaa0ac55.png

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

9d0893b1157d45e3c22d27dd8825b0c7.png

Режим отладки также сообщает об ошибке каждый раз, когда сталкивается с таким изображением, например:

[ERROR] Изображение assets/images/empty-background.png имеет размер отображения 411×706, но размер декодирования 2560×1600, что использует дополнительные 19818 КБ.
Однако решение здесь не совсем простое. Для мобильных устройств не требуется изображение размером 2560×1600, однако для настольных компьютеров оно может понадобиться. Не забывайте, что FlutterFolio — это приложение, которое работает на всех платформах Flutter, включая настольные компьютеры. Если вы сомневаетесь, читайте документацию по API.

Заключение​

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

В конце концов, постоянная практика приводит к совершенству. Оптимизируйте достаточное количество приложений, и с каждым разом у вас будет это получаться все лучше. Удачного хантинга!

 
Сверху