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


Содержание:



Не выносите виджеты в методы класса


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


В следующем примере представлен виджет, содержащий заголовок, основной контент и "подвал" (footer).


class MyHomePage extends StatelessWidget {
  Widget _buildHeaderWidget() {
    final size = 40.0;
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: CircleAvatar(
        backgroundColor: Colors.grey[700],
        child: FlutterLogo(
          size: size,
        ),
        radius: size,
      ),
    );
  }

  Widget _buildMainWidget(BuildContext context) {
    return Expanded(
      child: Container(
        color: Colors.grey[700],
        child: Center(
          child: Text(
            'Hello Flutter',
            style: Theme.of(context).textTheme.display1,
          ),
        ),
      ),
    );
  }

  Widget _buildFooterWidget() {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Text('This is the footer '),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(15.0),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            _buildHeaderWidget(),
            _buildMainWidget(context),
            _buildFooterWidget(),
          ],
        ),
      ),
    );
  }
}


То, что мы увидели выше, – это антипаттерн. Почему так? Всё потому, что, когда мы вносим изменения и обновляем MyHomePage виджет, то виджеты, которые у нас вынесены методах, также обновляются, даже если в этом нет никакой необходимости.


Чтобы не было лишних вычислений, мы можем переделать эти методы в StatelessWidget следующим образом.


class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Padding(
        padding: const EdgeInsets.all(15.0),
        child: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            HeaderWidget(),
            MainWidget(),
            FooterWidget(),
          ],
        ),
      ),
    );
  }
}

class HeaderWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final size = 40.0;
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: CircleAvatar(
        backgroundColor: Colors.grey[700],
        child: FlutterLogo(
          size: size,
        ),
        radius: size,
      ),
    );
  }
}

class MainWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Expanded(
      child: Container(
        color: Colors.grey[700],
        child: Center(
          child: Text(
            'Hello Flutter',
            style: Theme.of(context).textTheme.display1,
          ),
        ),
      ),
    );
  }
}

class FooterWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Text('This is the footer '),
    );
  }
}

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


И было бы неплохо добавить const в наши виджеты. Позже мы увидим, почему это важно.


Избегайте повторных перестроений всех виджетов


Это обычная ошибка, которую многие совершают, когда начинают использовать Flutter и впервые сталкиваются с необходимостью обновить StatefulWidget с помощью setState.


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


class _MyHomePageState extends State<MyHomePage> {
  Color _currentColor = Colors.grey;

  Random _random = new Random();

  void _onPressed() {
    int randomNumber = _random.nextInt(30);
    setState(() {
      _currentColor = Colors.primaries[randomNumber % Colors.primaries.length];
    });
  }

  @override
  Widget build(BuildContext context) {
    print('building `MyHomePage`');
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onPressed,
        child: Icon(Icons.colorize),
      ),
      body: Stack(
        children: [
          Positioned.fill(
            child: BackgroundWidget(),
          ),
          Center(
            child: Container(
              height: 150,
              width: 150,
              color: _currentColor,
            ),
          ),
        ],
      ),
    );
  }
}

class BackgroundWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    print('building `BackgroundWidget`');
    return Image.network(
      'https://cdn.pixabay.com/photo/2017/08/30/01/05/milky-way-2695569_960_720.jpg',
      fit: BoxFit.cover,
    );
  }
}


После клика по кнопке в консоли мы увидим два вывода


flutter: building `MyHomePage`
flutter: building `BackgroundWidget`

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


Как мы уже выяснили выше, перестраивать виджеты без необходимости – нехорошая практика. Обновляем только то, что нам нужно. Многие знают, что это можно сделать с помощью различных пакетов управления состоянием: flutter_bloc, mobx, provider и т. д. Но мало кто знает, что также это можно сделать с помощью классов, которые Flutter уже предлагает из коробки, без каких-либо сторонных библиотек.


Давайте, рассмотрим тот же пример, но обновленный с помощью ValueNotifier.


class _MyHomePageState extends State<MyHomePage> {
  final _colorNotifier = ValueNotifier<Color>(Colors.grey);
  Random _random = new Random();

  void _onPressed() {
    int randomNumber = _random.nextInt(30);
    _colorNotifier.value =
        Colors.primaries[randomNumber % Colors.primaries.length];
  }

  @override
  void dispose() {
    _colorNotifier.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    print('building `MyHomePage`');
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onPressed,
        child: Icon(Icons.colorize),
      ),
      body: Stack(
        children: [
          Positioned.fill(
            child: BackgroundWidget(),
          ),
          Center(
            child: ValueListenableBuilder(
              valueListenable: _colorNotifier,
              builder: (_, value, __) => Container(
                height: 150,
                width: 150,
                color: value,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Кликнем по кнопке и… ничего не увидим в консоли. Это работает, как и ожидалось, мы просто обновляем только тот виджет, который нам нужен (прим. подробнее про ValueListenableBuilder и ValueNotifier).


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


Снова тот же пример, но уже с ChangeNotifier.


//------ ChangeNotifier class ----//
class MyColorNotifier extends ChangeNotifier {
  Color myColor = Colors.grey;
  Random _random = new Random();

  void changeColor() {
    int randomNumber = _random.nextInt(30);
    myColor = Colors.primaries[randomNumber % Colors.primaries.length];
    notifyListeners();
  }
}
//------ State class ----//

class _MyHomePageState extends State<MyHomePage> {
  final _colorNotifier = MyColorNotifier();

  void _onPressed() {
    _colorNotifier.changeColor();
  }

  @override
  void dispose() {
    _colorNotifier.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    print('building `MyHomePage`');
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onPressed,
        child: Icon(Icons.colorize),
      ),
      body: Stack(
        children: [
          Positioned.fill(
            child: BackgroundWidget(),
          ),
          Center(
            child: AnimatedBuilder(
              animation: _colorNotifier,
              builder: (_, __) => Container(
                height: 150,
                width: 150,
                color: _colorNotifier.myColor,
              ),
            ),
          ),
        ],
      ),
    );
  }
}

Самое прекрасное, что и в этом случае у нас нет ненужных обновлений.


Используйте const


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


Давайте ещё раз используем наш пример с setState, но в этот раз мы добавим счетчик, который будет увеличивать значение на 1 каждый раз при нажатии на кнопку.


class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _onPressed() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    print('building `MyHomePage`');
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onPressed,
        child: Icon(Icons.colorize),
      ),
      body: Stack(
        children: [
          Positioned.fill(
            child: BackgroundWidget(),
          ),
          Center(
              child: Text(
            _counter.toString(),
            style: Theme.of(context).textTheme.display4.apply(
                  color: Colors.white,
                  fontWeightDelta: 2,
                ),
          )),
        ],
      ),
    );
  }
}

class BackgroundWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    print('building `BackgroundWidget`');
    return Image.network(
      'https://cdn.pixabay.com/photo/2017/08/30/01/05/milky-way-2695569_960_720.jpg',
      fit: BoxFit.cover,
    );
  }
}


У нас снова 2 вывода в консоли, один из которых относится к основнову виджету, а другой – к BackgroundWidget. Каждый раз, когда мы нажимаем кнопку, мы видим, что дочерний виджет также перестраивается, хотя его содержимое никак не меняется.


flutter: building `MyHomePage`
flutter: building `BackgroundWidget`

А сейчас добавим const при работе с виджетом BackgroundWidget:


class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _onPressed() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    print('building `MyHomePage`');
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onPressed,
        child: Icon(Icons.colorize),
      ),
      body: Stack(
        children: [
          Positioned.fill(
            child: const BackgroundWidget(),
          ),
          Center(
              child: Text(
            _counter.toString(),
            style: Theme.of(context).textTheme.display4.apply(
                  color: Colors.white,
                  fontWeightDelta: 2,
                ),
          )),
        ],
      ),
    );
  }
}

class BackgroundWidget extends StatelessWidget {
  const BackgroundWidget();

  @override
  Widget build(BuildContext context) {
    print('building `BackgroundWidget`');
    return Image.network(
      'https://cdn.pixabay.com/photo/2017/08/30/01/05/milky-way-2695569_960_720.jpg',
      fit: BoxFit.cover,
    );
  }
}

Теперь после клика по кнопке мы видим вывод только для основного виджета (конечно, если использовать те подходы, что я описал выше, избавиться можно и от этого вывода) и избегаем перестроения виджета, вызванного с const.


Используйте itemExtent в ListView для больших списков


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


Давайте рассмотрим простой пример. У нас есть список из 10 тысяч элементов. При нажатии на кнопку мы перейдем к последнему элементу. В этом примере мы не будем использовать itemExtent и позволим элементам списка самим определить свой размер.


class MyHomePage extends StatelessWidget {
  final widgets = List.generate(
    10000,
    (index) => Container(
      height: 200.0,
      color: Colors.primaries[index % Colors.primaries.length],
      child: ListTile(
        title: Text('Index: $index'),
      ),
    ),
  );

  final _scrollController = ScrollController();

  void _onPressed() async {
    _scrollController.jumpTo(
      _scrollController.position.maxScrollExtent,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onPressed,
        splashColor: Colors.red,
        child: Icon(Icons.slow_motion_video),
      ),
      body: ListView(
        controller: _scrollController,
        children: widgets,
      ),
    );
  }
}


Как можно увидеть на анимации выше, переход происходит очень долго (~10 секунд). Так получается из-за того, что дочерние элементы сами определяют свой размер. Это даже блокирует UI!


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


class MyHomePage extends StatelessWidget {
  final widgets = List.generate(
    10000,
    (index) => Container(
      color: Colors.primaries[index % Colors.primaries.length],
      child: ListTile(
        title: Text('Index: $index'),
      ),
    ),
  );

  final _scrollController = ScrollController();

  void _onPressed() async {
    _scrollController.jumpTo(
      _scrollController.position.maxScrollExtent,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onPressed,
        splashColor: Colors.red,
        child: Icon(Icons.slow_motion_video),
      ),
      body: ListView(
        controller: _scrollController,
        children: widgets,
        itemExtent: 200,
      ),
    );
  }
}


С этим небольшим изменением мы мгновенно переходим в самый низ без каких-либо задержек.


Избегайте ненужных перестроений виджетов внутри AnimatedBuilder


Часто мы хотим, чтобы наши виджеты были анимированы. В таких случаях обычно мы просто добавляем слушателя (addListener) для нашего AnimationController и вызываем setState. Но, как мы видели в самом начале, так работает не лучшим образом. Вместо этого мы будем использовать виджет AnimatedBuilder для обновления только того виджета, который мы хотим анимировать.


Давайте создадим экран, который содержит виджет в центре со значением счетчика и кнопку, при нажатии на которую виджет вращается на 360 градусов. После этого добавим вывод в консоль в виджет счетчика CounterWidget, и нажмём на кнопку, чтобы посмотреть, что произойдет.


class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
  AnimationController _controller;
  int counter = 0;

  void _onPressed() {
    setState(() {
      counter++;
    });
    _controller.forward(from: 0.0);
  }

  @override
  void initState() {
    _controller = AnimationController(
        vsync: this, duration: const Duration(milliseconds: 600));
    super.initState();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onPressed,
        splashColor: Colors.red,
        child: Icon(Icons.slow_motion_video),
      ),
      body: AnimatedBuilder(
        animation: _controller,
        builder: (_, child) => Transform(
          alignment: Alignment.center,
          transform: Matrix4.identity()
            ..setEntry(3, 2, 0.001)
            ..rotateY(360 * _controller.value * (pi / 180.0)),
          child: CounterWidget(
            counter: counter,
          ),
        ),
      ),
    );
  }
}

class CounterWidget extends StatelessWidget {
  final int counter;

  const CounterWidget({Key key, this.counter}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('building `CounterWidget`');
    return Center(
      child: Text(
        counter.toString(),
        style: Theme.of(context).textTheme.display4.apply(fontWeightDelta: 3),
      ),
    );
  }
}


Получили в консоли ряд сообщений.


flutter: building `CounterWidget`
flutter: building `CounterWidget`
flutter: building `CounterWidget`
flutter: building `CounterWidget`
flutter: building `CounterWidget`
...
flutter: building `CounterWidget`

Мы видим, что в начале вращения число в нашем виджете увеличивается на 1, но в консоли слишком много логов, хотя должен был появиться только один, после того, как мы задали значение счетчика с помощью setState. Как избавиться от лишних обновлений CounterWidget?


Всё просто! Надо использовать у AnimatedBuilder свойство child, которое позволяет нам кэшировать виджеты для повторного использования их в анимации. Мы делаем это потому, что этот виджет не будет меняться, единственное, что он будет делать, это вращаться, но для этого у нас есть Transform виджет.


  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onPressed,
        splashColor: Colors.red,
        child: Icon(Icons.slow_motion_video),
      ),
      body: AnimatedBuilder(
        animation: _controller,
        child: CounterWidget(
          counter: counter,
        ),
        builder: (_, child) => Transform(
          alignment: Alignment.center,
          transform: Matrix4.identity()
            ..setEntry(3, 2, 0.001)
            ..rotateY(360 * _controller.value * (pi / 180.0)),
          child: child,
        ),
      ),
    );
  }

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


Не используйте виджет Opacity в анимациях


Давайте еще немного про анимации поговорим.


Если нам что-то надо скрыть или показать, то мы обычно применяем виджет Opacity. Давайте переделаем предыдущий пример, но вместо Transform, мы будем использовать Opacity, чтобы наш виджет исчезал и появлялся снова и снова.


class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
  AnimationController _controller;
  int counter = 0;

  void _onPressed() {
    setState(() {
      counter++;
    });
    _controller.forward(from: 0.0);
  }

  @override
  void initState() {
    _controller = AnimationController(
        vsync: this, duration: const Duration(milliseconds: 600));
    _controller.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        _controller.reverse();
      }
    });
    super.initState();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onPressed,
        splashColor: Colors.red,
        child: Icon(Icons.slow_motion_video),
      ),
      body: AnimatedBuilder(
        animation: _controller,
        child: CounterWidget(
          counter: counter,
        ),
        builder: (_, child) => Opacity(
          opacity: (1 - _controller.value),
          child: child,
        ),
      ),
    );
  }
}


Мы видим, что анимация работает, однако, виджет Opacity (и, возможно, его поддерево) перестраивается каждый кадр, что не очень здорово.


Но у нас для оптимизации есть следующие варианты:


1 – Использовать FadeTransition


class _MyHomePageState extends State<MyHomePage>
    with SingleTickerProviderStateMixin {
  AnimationController _controller;
  int counter = 0;

  void _onPressed() {
    setState(() {
      counter++;
    });
    _controller.forward(from: 0.0);
  }

  @override
  void initState() {
    _controller = AnimationController(
        vsync: this, duration: const Duration(milliseconds: 600));
    _controller.addStatusListener((status) {
      if (status == AnimationStatus.completed) {
        _controller.reverse();
      }
    });
    super.initState();
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onPressed,
        splashColor: Colors.red,
        child: Icon(Icons.slow_motion_video),
      ),
      body: FadeTransition(
        opacity: Tween(begin: 1.0, end: 0.0).animate(_controller),
        child: CounterWidget(
          counter: counter,
        ),
      ),
    );
  }
}

2 – Использовать AnimatedOpacity


const duration = const Duration(milliseconds: 600);

class _MyHomePageState extends State<MyHomePage> {
  int counter = 0;
  double opacity = 1.0;

  void _onPressed() async {
    counter++;
    setState(() {
      opacity = 0.0;
    });
    await Future.delayed(duration);
    setState(() {
      opacity = 1.0;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: _onPressed,
        splashColor: Colors.red,
        child: Icon(Icons.slow_motion_video),
      ),
      body: AnimatedOpacity(
        opacity: opacity,
        duration: duration,
        child: CounterWidget(
          counter: counter,
        ),
      ),
    );
  }
}

Оба эти варианта намного эффективнее, чем простое использование виджета Opacity.


Заключение


Как я уже упоминал ранее, Flutter достаточно мощен, чтобы запускать наши приложения без проблем. Однако, всегда полезно следовать хорошим практикам и максимально оптимизировать наше приложение.


Если хотите ещё больше узнать по данной теме, рекомендую видео-выступление Filip Hracek на Flutter Europe, где он рассказывает об оптимизации и профилировании Flutter приложений.


Дополнительные материалы:



Об авторе:


Diego Velasquez – это Flutter и Dart GDE из Лимы, Перу. Он также входит в комитет по Flutter. Diego увлечен мобильными приложениями и работает в качестве архитектора мобильного программного обеспечения. Вы можете подписаться на него в Twitter.




P.S. Ещё раз ссылка на оригинальную статью How to improve the performance of your Flutter app от Diego Velasquez