Привет, Хабр! Меня зовут Анна Ахлестова, я лидер Flutter-команды компании Friflex. В этой статье я расскажу об оптимизации скроллящихся списков, уменьшении лишних перестроений в build() и контроле утечек памяти в приложении на Flutter. Разберу, какие решения помогают снизить нагрузку на интерфейс, где обычно возникают проблемы с производительностью и на что стоит обращать внимание в повседневной разработке.

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

Оптимизация скроллящихся списков

Сначала разберем базовые решения для списков — здесь часто появляются первые проблемы с производительностью.

  1. Заменяйте вложенные ListView и Column на CustomScrollView.
    Если у вас сложная вложенная структура, CustomScrollView поможет избежать избыточных перестроений и повысит эффективность.

  2. Выбирайте ListView.builder для длинных списков.
    В отличие от ListView, который создает все элементы сразу, ListView.builder рендерит только видимые элементы — это экономит память и ресурсы.

  3. Избегайте SingleChildScrollView для длинных списков.
    Этот виджет загружает весь контент сразу, что приводит к утечке памяти. Он подходит только для небольших списков.

  4. Оптимизируйте ListView с itemExtent или SliverFixedExtentList.
    Фиксированная высота элементов снижает нагрузку на систему прокрутки и делает ее более плавной.

  5. Избегайте shrinkWrap в длинных списках.
    Он пересчитывает размеры всех элементов, и это может замедлить рендеринг. 

  6. Применяйте NestedScrollView для SliverAppBar и TabBarView.
    Это позволит заголовку сворачиваться плавно и без рывков.

  7. Выбирайте ReorderableListView вместо ListView для перетаскивания элементов.
    Он уже оптимизирован под такие сценарии и не требует сложной ручной обработки состояний.

  8. Добавляйте AutomaticKeepAliveClientMixin для сохранения состояния элементов. Если в списке есть сложные элементы, добавляйте AutomaticKeepAliveClientMixin, чтобы Flutter не пересоздавал виджеты при прокрутке.

  9. Используйте ScrollablePositionedList для быстрой прокрутки к нужному элементу.
    Если нужно быстро прокрутить к определенному элементу, ScrollablePositionedList эффективнее, чем стандартный ListView.

  10. Пробуйте ListView.separated вместо дополнительных Padding и Divider.
    Этот вариант более производительный, потому что Flutter не создает ненужные виджеты для каждого элемента.

  11. Реализуйте пагинацию для длинных списков.
    Если в вашем приложении большой список данных, загружайте их по частям. Это снижает нагрузку на память и повышает производительность.

Важно учитывать: все эти советы зависят от контекста, и нужно тестировать каждый случай.

Оптимизация build()

Следующий слой оптимизации — то, как вообще перестраивается интерфейс.

Наверняка чаще всего Flutter-разработчики используют метод build() в Stateful- и Stateless-виджетах. 

Вот мой топ-5 практик для его оптимизации:

Использовать константные конструкторы везде, где можно.

При перестроении родительского виджета все дочерние виджеты, которые созданы как константные, перестраиваться не будут. С помощью const вы даете программе понять, что именно этот объект изменяться не должен.

class Example extends StatelessWidget {
 const Example({super.key});

 @override
 Widget build(BuildContext context) {
   return Column(
     children: [
       const Text('Example text'), // оптимально
       Text('Example text not const'), // можно оптимизировать, добавив const
     ],
   );
 }
}

Кэшировать обращения к MediaQuery.of(context) и Theme.of(context).

Каждое обращение у этих классов к методам of(context) под капотом подразумевает вызов dependOnInheritedWidgetOfExactType(). Этот метод делает поиск необходимых данных по всему дереву виджетов. Операция занимает некоторое время. Если их было запущено несколько, то время увеличивается.

Если от MediaQuery вам необходим, например, только размер экрана, лучше вместо стандартного of(context) использовать sizeOf(context).

/// Пример неоптимизированного использования
class NotOptimizedExample extends StatelessWidget {
 const NotOptimizedExample({super.key});

 @override
 Widget build(BuildContext context) {
   return Container(
     width: MediaQuery.of(context).size.width * 0.8,
     height: MediaQuery.of(context).size.height * 0.3,
     margin: EdgeInsets.all(MediaQuery.of(context).size.width * 0.05),
     decoration: BoxDecoration(
       color: Theme.of(context).primaryColor,
       border: Border.all(color: Theme.of(context).dividerColor),
     ),
     child: const Text('Hello'),
   );
 }
}
/// Пример оптимизированного использования
class OptimizedExample extends StatelessWidget {
 const OptimizedExample({super.key});

 @override
 Widget build(BuildContext context) {
   final sizeData = MediaQuery.sizeOf(context);
   final themeData = Theme.of(context);

   return Container(
     width: sizeData.width * 0.8,
     height: sizeData.height * 0.3,
     margin: EdgeInsets.all(sizeData.width * 0.05),
     decoration: BoxDecoration(
       color: themeData.primaryColor,
       border: Border.all(color: themeData.dividerColor),
     ),
     child: const Text('Hello'),
   );
 }
}

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

Вызывать перестроение только тех участков, которые действительно должны измениться.

Здесь для примера рассмотрим Stateful-виджет NotOptimizedExample, State которого выглядит так:

class _NotOptimizedExampleState extends State<NotOptimizedExample> {
 int counter = 0;

 @override
 Widget build(BuildContext context) {
   return Column(
     children: [
       PageHeader(), // Пересоздается
       CounterView(),
       CounterButton(
         onTap: () => setState(() {
           counter++;
         }),
       ),
       PageFooter(), // Пересоздается
     ],
   );
 }
}

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

Оптимизировать скроллящиеся объекты на экране.

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

С оптимизацией build() и списков виджетов разобрались, этот вопрос считается не самым сложным. По моему опыту, больше вопросов вызывают утечки памяти. Дальше рассмотрим, что может их вызывать и почему не все объекты очищаются самостоятельно.

Откуда берутся утечки памяти во Flutter

Здесь важно сначала определить, что именно считать утечкой памяти.

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

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

Как работает Garbage Collector

Для снижения утечек памяти в Dart есть механизм Garbage Collector (GC). Его основная задача — выполнять автоматическую очистку неиспользуемых объектов в программе. GC самостоятельно отслеживает объекты, которые создаются в процессе работы. Затем очищает те, которые не имеют активных ссылок в программе.

Представим, что у нас есть класс User. Метод createUser() внутри себя создаст его экземпляр и выполнит с ним какие-то манипуляции. После завершения работы метода createUser() объект user больше не будет иметь активных ссылок, и GC легко его очистит.

void main() {
 createUser(); // Вызываем функцию, которая внутри создает пользователя
 print('Пользователь создан, но больше не используется');
 // После этого GC может удалить объект User
}

void createUser() {
 final user = User(name: 'Anna');
 print('Привет, ${user.name}');
 // Когда функция завершится, переменная user исчезнет из области видимости
}

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

void main() {
 final user = User('Alice');
 // Даже если мы больше не используем user, ссылка на него все еще существует.
 // GC не удалит объект.
}

Прочитать о работе GC подробнее можно здесь:

Что чаще всего приводит к утечкам памяти

Garbage Collector не может очистить все объекты, поэтому многие источники утечки памяти разработчику нужно отслеживать самостоятельно.

Самые частые из них:

  • контроллеры — например, TextEditingController, PageController;

  • подписки — StreamSubscription;

  • таймеры — Timer;

  • слушатели — при добавлении addListener();

  • глобальные и статические объекты.

Дальше перейдем к простым правилам, которые помогают такие ситуации не пропускать.

Что стоит делать на практике

  1. Если создаете контроллер или подписку, обязательно вызывайте методы dispose() или cancel()

  2. Таймеры также нужно отменять с помощью cancel()

  3. При добавлении слушателя через addListener() не забывайте удалять его через removeListener()

  4. Если в коде глобальные или статические объекты больше не нужны, можно обнулить их ссылки, присвоив null

  5. Систематически проверяйте свое приложение на утечки памяти.

Отследить утечки памяти помогут специальные инструменты, например, Dart DevTools (вкладка Memory) и библиотека leak_tracker_flutter_testing.

А какие приемы оптимизации Flutter-приложений чаще всего работают у вас?