Flutter производительность: 12 ошибок, которые убивают FPS вашего приложения

1 апреля 2026 · ? просмотров · ? мин
логотип flutter в стиле спидометра на темно фиолетовом фоне
Содержание
Производительность Flutter-приложения напрямую зависит от качества написанного кода: лишние перестроения UI, тяжёлые операции в основном потоке, неправильная работа со списками и изображениями — всё это ведёт к фризам, падению FPS и ухудшению пользовательского опыта.

В данной статье мы собрали наиболее распространённые ошибки, которые снижают производительность Flutter-приложений, и показали, как их избежать на практике.

Введение

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

Важно понимать, что Flutter работает по принципу частых перерисовок UI, и сам по себе этот механизм эффективен только при условии, что разработчик использует его правильно. Любой лишний build, тяжёлая операция в UI-потоке или неправильная работа со списками напрямую влияет на пользовательский опыт. В отличие от некоторых других платформ, здесь нет "магической оптимизации" — производительность во многом зависит от качества написанного кода.

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

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

Такой подход позволяет не только избежать проблем в будущем, но и делает код более читаемым, поддерживаемым и масштабируемым.
Ниже собраны конкретные рекомендации, как делать не следует, и как будет более оптимально использовать ресурсы во flutter-приложении.

Лишние rebuild’ы

Плохо:
class MyScreen extends StatefulWidget {
  @override
  State<MyScreen> createState() => _MyScreenState();
}
class _MyScreenState extends State<MyScreen> {
  int counter = 0;
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Counter: $counter'),
        ElevatedButton(
          onPressed: () {
            setState(() {
              counter++; // пересобирает ВСЁ дерево
            });
          },
          child: Text('Increment'),
        ),
      ],
    );
  }
}
Главная проблема setState — он пересобирает весь subtree текущего StatefulWidget. Если внутри есть тяжёлые виджеты (списки, картинки, сложные layout’ы), это приводит к лишним вычислениям и падению FPS. Даже если изменился один Text, Flutter заново вызовет build() для всех дочерних элементов. Это особенно критично на слабых устройствах. Решение — локализовать обновления. ValueListenableBuilder, StreamBuilder, Selector или BlocBuilder позволяют обновлять только конкретную часть UI. Чем меньше область перерисовки — тем выше производительность. Это ключевая практика для scalable UI.
Хорошо:
class MyScreen extends StatelessWidget {
  final ValueNotifier<int> counter = ValueNotifier(0);
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        ValueListenableBuilder<int>(
          valueListenable: counter,
          builder: (_, value, __) {
            return Text('Counter: $value');
          },
        ),
        ElevatedButton(
          onPressed: () => counter.value++,
          child: const Text('Increment'),
        ),
      ],
    );
  }
}

Нет const

Плохо:
Widget build(BuildContext context) {
  return Column(
    children: [
      Text('Hello'),
      Icon(Icons.home),
      Padding(
        padding: EdgeInsets.all(8),
        child: Text('World'),
      ),
    ],
  );
}
Ключевое слово, модификатор const сообщает Flutter, что виджет неизменяемый и может быть создан на этапе компиляции. Без const каждый rebuild создаёт новый объект, даже если он полностью идентичен предыдущему. Это увеличивает нагрузку на GC (сборщик мусора) и CPU. При большом количестве мелких виджетов (иконки, тексты, padding) это начинает заметно влиять на производительность. Использование const позволяет Flutter переиспользовать уже созданные объекты, уменьшая количество аллокаций. В больших списках или сложных UI это даёт ощутимый прирост производительности и стабильности кадров.
Хорошо:
Widget build(BuildContext context) {
  return const Column(
    children: [
      Text('Hello'),
      Icon(Icons.home),
      Padding(
        padding: EdgeInsets.all(8),
        child: Text('World'),
      ),
    ],
  );
}

Логика в build()

Плохо:
Widget build(BuildContext context) {
  final sortedList = items..sort((a, b) => a.compareTo(b));
  final filtered = sortedList.where((e) => e > 10).toList();
  return ListView(
    children: filtered.map((e) => Text('$e')).toList(),
  );
}
Метод build() может вызываться десятки раз в секунду (например,
при анимациях или скролле). Если внутри него выполняются тяжёлые операции — сортировка, фильтрация, парсинг — это напрямую влияет
на производительность UI.

Даже операция sort() на среднем списке может занимать миллисекунды,
что критично для 60 FPS (16 ms на кадр). Логика должна выполняться один раз (например, в initState) или в бизнес-слое. build() должен быть максимально лёгким и декларативным — только описание UI. Это фундаментальный принцип Flutter: UI должен разделён с бизнес-логикой.
Хорошо:
late List<int> filtered;
@override
void initState() {
  super.initState();
  final sorted = [...items]..sort((a, b) => a.compareTo(b));
  filtered = sorted.where((e) => e > 10).toList();
}
@override
Widget build(BuildContext context) {
  return ListView.builder(
    itemCount: filtered.length,
    itemBuilder: (_, i) => Text('${filtered[i]}'),
  );
}

ListView без builder

Плохо:
Widget build(BuildContext context) {
  return ListView(
    children: items.map((item) {
      return ListTile(
        title: Text(item.title),
        subtitle: Text(item.subtitle),
      );
    }).toList(),
  );
}
При использовании children: [...] Flutter создаёт ВСЕ элементы списка сразу, даже если пользователь видит только первые 5–10. Это приводит к лишним аллокациям, загрузке памяти и долгому времени первого рендера. Особенно критично при списках 100+ элементов.

ListView.builder создаёт элементы лениво — только те, которые видны на экране. Это drastically снижает нагрузку на CPU и память. Также builder лучше работает с переработкой элементов при скролле. Это стандарт де-факто для любых динамических списков во Flutter.
Хорошо:
Widget build(BuildContext context) {
  return ListView.builder(
    itemCount: items.length,
    itemBuilder: (context, index) {
      final item = items[index];
      return ListTile(
        title: Text(item.title),
        subtitle: Text(item.subtitle),
      );
    },
  );
}

Нет keys

Плохо:
ListView.builder(
  itemCount: items.length,
  itemBuilder: (_, index) {
    final item = items[index];
    return ListTile(
      title: Text(item.title),
    );
  },
);
Без key Flutter не может корректно сопоставить старые и новые элементы при обновлении списка. В результате он может пересоздавать виджеты вместо их обновления, что приводит к лишним rebuild’ам и визуальным багам (например, “прыгающие” элементы). Key позволяет Flutter понимать, какой элемент соответствует какому состоянию. Это особенно важно при:

  • reorder списка
  • удалении элементов
  • анимациях
Использование ValueKey или ObjectKey значительно снижает количество ненужных операций и делает UI стабильным.
Хорошо:
ListView.builder(
  itemCount: items.length,
  itemBuilder: (_, index) {
    final item = items[index];
    return ListTile(
      key: ValueKey(item.id),
      title: Text(item.title),
    );
  },
);

Частый setState (таймер)

Плохо:
Timer.periodic(Duration(milliseconds: 100), (_) {
  setState(() {
    progress += 0.01;
  });
});
Widget build(BuildContext context) {
  return LinearProgressIndicator(value: progress);
}
Частые вызовы setState (например, каждые 16–100 мс) перегружают UI поток. Flutter вынужден постоянно пересчитывать layout и перерисовывать виджеты. Это приводит к пропуску кадров (jank). Для анимаций есть специализированные инструменты: AnimationController, AnimatedBuilder, TweenAnimationBuilder.

Они оптимизированы и обновляют только нужные части UI. Также они работают синхронно с кадровой частотой устройства. Использование правильных инструментов для анимаций — ключ к плавному интерфейсу.
Хорошо:
late AnimationController controller;
@override
void initState() {
  super.initState();
  controller = AnimationController(
    vsync: this,
    duration: Duration(seconds: 2),
  )..repeat();
}
Widget build(BuildContext context) {
  return AnimatedBuilder(
    animation: controller,
    builder: (_, __) {
      return LinearProgressIndicator(value: controller.value);
    },
  );
}

Огромный StatefulWidget

Плохо:
Widget build(BuildContext context) {
  return Column(
    children: [
      Text('Header'),
      ListView(...),
      Text('Footer'),
      ElevatedButton(...),
      Image.network(url),
    ],
  );
}
Когда весь экран — один StatefulWidget, любое изменение состояния приводит к пересборке всей структуры. Это дорого и плохо масштабируется. Разделение UI на маленькие независимые виджеты позволяет:

  • локализовать rebuild’ы
  • переиспользовать код
  • улучшить читаемость
Flutter оптимизирован под композицию — множество маленьких виджетов лучше, чем один большой. Это снижает нагрузку на build и layout фазу. Также это облегчает тестирование и поддержку.
Хорошо:
Widget build(BuildContext context) {
  return Column(
    children: const [
      Header(),
      Expanded(child: ItemsList()),
      Footer(),
    ],
  );
}
class Header extends StatelessWidget {
  const Header();
  @override
  Widget build(BuildContext context) {
    return Text('Header');
  }
}

Future в build()

Плохо:
Widget build(BuildContext context) {
  return FutureBuilder(
    future: fetchData(), // каждый раз новый запрос
    builder: (_, snapshot) {
      if (!snapshot.hasData) return CircularProgressIndicator();
      return Text(snapshot.data.toString());
    },
  );
}
Если вы вызываете fetchData() прямо в build(), каждый rebuild создаёт новый Future и новый запрос. Это может привести к:

  • множественным HTTP-запросам
  • миганию UI
  • лишней нагрузке на сеть
Правильный подход — создать Future один раз (в initState) и переиспользовать его. FutureBuilder должен работать с уже существующим Future, а не создавать новый. Это гарантирует, что данные загружаются один раз, а UI просто реагирует на результат.
Хорошо:
late Future dataFuture;
@override
void initState() {
  super.initState();
  dataFuture = fetchData();
}
Widget build(BuildContext context) {
  return FutureBuilder(
    future: dataFuture,
    builder: (_, snapshot) {
      if (!snapshot.hasData) return CircularProgressIndicator();
      return Text(snapshot.data.toString());
    },
  );
}

Картинки без кэша

Плохо:
Column(
  children: [
    Image.network(url1),
    Image.network(url2),
    Image.network(url3),
  ],
);
Image.network по умолчанию не обеспечивает полноценное кэширование. При каждом rebuild изображение может заново загружаться или декодироваться, что нагружает сеть и CPU. Особенно это заметно в списках. Использование библиотек вроде cached_network_image добавляет:

  • дисковый кэш
  • memory cache
  • placeholder
Это уменьшает количество запросов и ускоряет отображение. Картинки
— одна из самых тяжёлых частей UI, поэтому их оптимизация даёт большой прирост производительности.
Хорошо:
Column(
  children: [
    CachedNetworkImage(imageUrl: url1),
    CachedNetworkImage(imageUrl: url2),
    CachedNetworkImage(imageUrl: url3),
  ],
);

Opacity

Плохо:
Opacity(
  opacity: 0.5,
  child: Container(
    color: Colors.red,
    width: 100,
    height: 100,
  ),
);
Opacity создаёт отдельный compositing layer и требует дополнительного прохода рендеринга. Это дорогая операция, особенно если используется часто или внутри списков. В простых случаях лучше использовать прозрачные цвета (withOpacity), которые не требуют отдельного слоя. Если нужна анимация — используйте FadeTransition, он более оптимизирован. Избыточное использование Opacity может сильно нагрузить GPU и привести к падению FPS.
Хорошо:
Container(
  width: 100,
  height: 100,
  color: Colors.red.withOpacity(0.5),
);

Глубокая вложенность

Плохо:
Container(
  child: Padding(
    padding: EdgeInsets.all(8),
    child: Align(
      alignment: Alignment.center,
      child: Column(
        children: [
          Text('Hello'),
        ],
      ),
    ),
  ),
);
Каждый виджет добавляет стоимость на build и layout этапах. Глубокие деревья (10+ уровней) увеличивают время рендеринга и усложняют перерасчёт layout. Часто разработчики используют лишние обёртки (Container, Align, SizedBox), хотя их можно заменить более простыми конструкциями. Упрощение дерева:

  • ускоряет UI
  • снижает количество вычислений
  • делает код читаемее
Flutter быстрый, но не магический — глубина дерева всё равно имеет значение.
Хорошо:
Padding(
  padding: const EdgeInsets.all(8),
  child: Center(
    child: Text('Hello'),
  ),
);

Тяжёлый JSON в UI

Плохо:
Widget build(BuildContext context) {
  final data = jsonDecode(bigJsonString);
  final items = data['items'];
  return ListView(
    children: items.map<Widget>((e) => Text(e.toString())).toList(),
  );
}
Парсинг большого JSON — CPU-bound операция. Если выполнять её в UI потоке, приложение “зависает” на время обработки. Пользователь видит фриз. В Flutter есть isolates — отдельные потоки для тяжёлых задач. compute() позволяет вынести парсинг в background. Это освобождает UI поток и сохраняет плавность интерфейса. Особенно важно при:

  • больших API ответах
  • локальных файлах
  • сложных вычислениях
Разделение UI и вычислений — обязательная практика для production-приложений.
Хорошо:
Future<List<dynamic>> parseData() async {
  return compute(parseJson, bigJsonString);
}
List<dynamic> parseJson(String json) {
  final data = jsonDecode(json);
  return data['items'];
}

Вывод
Большинство проблем с производительностью возникают
не из-за сложности задачи, а из-за мелких решений, которые легко упустить
в процессе разработки. Лишний rebuild, тяжёлая операция в основном потоке, список без builder — каждое из этих решений кажется безобидным, пока приложение не начинает тормозить.

Хорошая новость в том, что большинство из описанных проблем решаются относительно просто, если знать, на что обращать внимание.
Главное — не откладывать это на потом: чем раньше закладываются правильные практики, тем меньше времени уходит на их исправление
в будущем.
Оценить материал
Остальные статьи по react