Пошаговые инструкции по созданию второго проекта Riverpod

В этом руководстве вы узнаете, как создать приложение таймера с использованием пакета Riverpod для управления состоянием. Если вы не завершили мое первое руководство Riverpod по созданию приложения Counter, вам следует сначала закончить его, поскольку оно закладывает основу и объясняет многие вещи, которые я не буду повторять в этом руководстве. Цель здесь - помочь вам укрепить этот фундамент и построить на нем.

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

Когда вы закончите, у вас будет приложение-таймер, которое ведет себя следующим образом:

Идея этого приложения была вдохновлена ​​Учебником по таймеру библиотеки Flutter Bloc.

Давайте начнем!

This tutorial is up to date for:
Flutter 2.0
  Dart 2.12
  hooks_riverpod 0.14.0

Настраивать

Запустите новый проект Flutter и назовите его my_timer_app.

Чтобы воспользоваться нулевой безопасностью, перейдите на pubspec.yaml и убедитесь, что ваша минимальная версия Dart не ниже 2.12:

environment:
  sdk: ">=2.12.0 <3.0.0"

Затем добавьте следующие две зависимости в pubspec.yaml:

dependencies:
  flutter_hooks: ^0.16.0
  hooks_riverpod: ^0.14.0

Создание макета

Замените main.dart следующим кодом:

Примечания:

  • Это все виджеты, которые вам понадобятся для приложения. Есть виджет Text для отображения оставшегося времени, три разных FloatingActionButton виджета для запуска, приостановки и сброса таймера и виджет ButtonsContainer для удержания кнопок.
  • Основная причина того, что виджеты текста и кнопок были извлечены в свои собственные виджеты, заключается в том, что, когда вам нужно обновить макет, вам не нужно все перестраивать. В Руководстве по счетчику вы извлекали виджеты в последнюю очередь на этапе рефакторинга, но на этот раз мы делаем это в первую очередь.

Запустите приложение сейчас, и вы увидите следующий результат:

Примечания:

  • Я использую версию Flutter для macOS, чтобы протестировать приложение, потому что оно быстрее эмулятора. Но смело используйте эмулятор Android или симулятор iOS.
  • Нажатие кнопок ничего не даст, потому что вы еще не добавили никакой логики. Это будет дальше.

Анализируя состояние

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

Во-первых, у вас есть оставшееся время, которое будет отображаться в виджете Text вверху. После запуска таймера он будет уменьшаться каждую секунду, пока не достигнет 00:00.

Тогда у вас также есть состояние таймера, которое может находиться в любом из четырех возможных состояний:

  • начальное состояние до запуска таймера.
  • запущенное состояние, в котором работает таймер.
  • состояние паузы, при котором таймер временно остановился.
  • Состояние завершения, при котором таймер дошел до 00:00, но еще не был сброшен.

Для каждого из этих четырех состояний таймера кнопки будут иметь разную конфигурацию:

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

Добавление класса модели

Создайте новый файл с именем timer.dart и добавьте следующий код:

Примечания:

  • Класс TimerModel неизменен. Создав состояние для определенного момента времени, вы не можете изменять ни одно из полей по отдельности. Вы должны создать совершенно новый экземпляр класса при изменении состояния.
  • Вы можете быть удивлены, что timeLeft - это строка, а не целое число. Позже, когда вы будете управлять оставшимся временем, вы действительно будете внутренне использовать целое число. Однако цель TimerModel - подготовить все для прямого отображения пользовательского интерфейса. Причина в том, что вы хотите убрать как можно больше логики из пользовательского интерфейса. Если вы задали пользовательскому интерфейсу целое число, то ему пришлось бы использовать некоторую логику для форматирования целого числа в отображаемую строку. Вместо этого вы сами предварительно отформатируете целое число и просто передадите пользовательскому интерфейсу все, что он должен показать.
  • Вы также создали ButtonState перечисление с четырьмя состояниями, которые вы определили на шаге анализа выше.

Добавление класса управления состоянием

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

Добавьте следующий код в timer.dart:

Примечания:

  • Ваш класс управления состоянием TimerNotifier расширяет StateNotifier. В этом классе есть переменная state, и при изменении этой переменной StateNotifier уведомит всех слушателей.
  • Когда вы создаете StateNotifier, вы должны указать ему начальное состояние. В этом случае вы устанавливаете продолжительность 10 секунд, которую вы конвертируете в строку, которую может использовать пользовательский интерфейс. Вы также установите ButtonState на initial.
  • Вы используете Stream.periodic для создания таймера обратного отсчета. Он заключен в класс Ticker. (Благодарим вас за Учебник по таймеру из библиотеки блоков за этот код и за код для преобразования продолжительности в строку.) Поскольку вы используете поток, вы будете управлять им с помощью экземпляра StreamSubscription.
  • Методы start, pause и reset еще не реализованы. Вы сделаете это дальше.

Запуск таймера

Когда пользователь нажимает кнопку «Пуск», вам понадобится способ указать тикеру, чтобы он начал тикать. Для этого добавьте в класс TimerNotifier следующие методы:

Примечания:

  • Если таймер paused, то все, что вам нужно сделать, это возобновить поток и обновить переменную state.
  • В противном случае таймер будет либо initial, либо finished. В любом из этих случаев вы отменяете любой предыдущий поток, а затем начинаете слушать новый. Вы также можете использовать обратный вызов onDone для обновления state по завершении потока.

Приостановка таймера

Если таймер уже запущен, вам нужен способ его приостановить. Для этого добавьте в класс TimerNotifier следующий метод:

void pause() {
  _tickerSubscription?.pause();
  state = TimerModel(state.timeLeft, ButtonState.paused);
}

Это довольно просто. Вы приостанавливаете поток и обновляете state.

Сброс таймера

Чтобы позволить пользователю сбрасывать таймер, добавьте метод сброса в класс TimerNotifier:

void reset() {
  _tickerSubscription?.cancel();
  state = _initialState;
}

Здесь вы просто восстанавливаете state до начальных условий.

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

Включение Riverpod

Чтобы использовать Riverpod в своем приложении, вам нужно обернуть все приложение виджетом ProviderScope.

В main.dart добавьте импорт Riverpod:

import 'package:hooks_riverpod/hooks_riverpod.dart';

Затем замените метод main следующим кодом:

void main() {
  runApp(
    const ProviderScope(child: MyApp()),
  );
}

Вы можете сделать это const, потому что MyApp имеет const конструктор. Если вы получаете сообщение об ошибке, потому что MyApp не const, удалите const сверху или добавьте конструктор const в MyApp:

const MyApp({Key? key}) : super(key: key);

Создание глобального провайдера

В main.dart добавьте импорт:

import 'timer.dart';

А затем добавьте следующую переменную верхнего уровня:

final timerProvider = StateNotifierProvider<TimerNotifier, TimerModel>(
  (ref) => TimerNotifier(),
);

Вы используете StateNotifierProvider, потому что TimerNotifier является подклассом StateNotifier. TimerModel в качестве второго типа в угловых скобках указывает тип состояния в уведомителе.

Прислушиваясь к изменениям состояния

Каждый раз, когда значение состояния TimerModel изменяется, вы хотите перестроить пользовательский интерфейс, чтобы отразить это изменение. Два виджета, которые необходимо обновить, - это TimerTextWidget и ButtonsContainer.

TimerTextWidget

Замените TimerTextWidget следующим кодом:

Примечания:

  • Поскольку этот класс расширяет HookWidget вместо StatelessWidget, вам необходимо добавить следующий импорт:
import 'package:flutter_hooks/flutter_hooks.dart';
  • Метод useProvider даст вам ссылку на TimerModel, а также будет отслеживать изменения в модели. Каждый раз, когда происходят изменения, метод build перестраивает виджет с новым состоянием.

КнопкиКонтейнер

Также замените ButtonsContainer следующим кодом:

Примечания:

  • Как и раньше, useProvider отслеживает изменения в TimerModel.
  • Вы используете buttonState для создания четырех различных конфигураций кнопок, которые мы рассмотрели ранее.

Обновление состояния

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

Замените параметр onPressed кнопки StartButton следующим кодом:

onPressed: () {
  context.read(timerProvider.notifier).start();
},

Замените параметр onPressed кнопки PauseButton следующим кодом:

onPressed: () {
  context.read(timerProvider.notifier).pause();
},

Замените параметр onPressed кнопки ResetButton следующим кодом:

onPressed: () {
  context.read(timerProvider.notifier).reset();
},

Примечание.

  • В отличие от useProvider, использование context.read позволяет получить ссылку на ваш TimerNotifier класс без прослушивания изменений состояния. (Однако вам нужно использовать notifier, иначе вы получите ссылку на TimerModel.) Это позволяет вам просто вызывать методы этого класса.

Тестируем это

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

Уменьшение ненужных сборок виджетов

Возможно, вы заметили кое-что интересное из операторов print при запуске приложения:

flutter: building MyHomePage
flutter: building TimerTextWidget 00:10
flutter: building ButtonsContainer
flutter: building TimerTextWidget 00:10
flutter: building ButtonsContainer
flutter: building TimerTextWidget 00:09
flutter: building ButtonsContainer
flutter: building TimerTextWidget 00:08
flutter: building ButtonsContainer
flutter: building TimerTextWidget 00:07
flutter: building ButtonsContainer
...

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

Причина этой дополнительной перестройки в том, что ButtonsContainer и TimerTextWidget оба ожидают любых изменений, которые происходят в TimerModel.

Что вам действительно нужно, так это чтобы TimerTextWidget слушал только изменения в TimerModel.timeLeft, а ButtonsContainer слушал только изменения в TimerModel.buttonState.

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

Создание поставщика ButtonState

Добавьте следующий код в main.dart:

final _buttonState = Provider<ButtonState>((ref) {
  return ref.watch(timerProvider).buttonState;
});
final buttonProvider = Provider<ButtonState>((ref) {
  return ref.watch(_buttonState);
});

Примечания:

  • Это Provider вместо StateNotifierProvider. Причина в том, что вы слушаете другого провайдера, а не StateNotifier.
  • Вы раньше не использовали аргумент ref. Он имеет тип ProviderReference и дает вам доступ к другим провайдерам. Что наиболее важно для вас, он дает вам ссылку на timerProvider, который вы сделали ранее.
  • Ключевое слово watch отслеживает изменения в timerProvider. (Не спрашивайте меня, почему watch слушает. Думаю, вы можете думать об этом, как watch, который следит за изменениями.)
  • watch выдает обновления только для уникальных изменений. Поскольку _buttonState будет одинаковым для большинства timerProvider изменений, просмотр _buttonState во втором провайдере вызовет перестройку только тогда, когда _buttonState действительно изменится. Таким образом, buttonProvider будет обновляться только при изменении состояния кнопки. Вы эффективно отфильтровали все timeLeft изменения.

Теперь в ButtonsContainer замените эту строку:

final state = useProvider(timerProvider).buttonState;

с этой строкой:

final state = useProvider(buttonProvider);

Создание провайдера timeLeft

Вы можете сделать то же самое, чтобы отфильтровать изменения состояния кнопок для состояния timeLeft.

Добавьте следующий код в main.dart:

final _timeLeftProvider = Provider<String>((ref) {
  return ref.watch(timerProvider).timeLeft;
});
final timeLeftProvider = Provider<String>((ref) {
  return ref.watch(_timeLeftProvider);
});

Теперь в TimerTextWidget замените эту строку:

final timeLeft = useProvider(timerProvider).timeLeft;

с этой строкой:

final timeLeft = useProvider(timeLeftProvider);

Тестирование снова

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

flutter: building MyHomePage
flutter: building TimerTextWidget 00:10
flutter: building ButtonsContainer
flutter: building ButtonsContainer
flutter: building TimerTextWidget 00:09
flutter: building TimerTextWidget 00:08
flutter: building TimerTextWidget 00:07
flutter: building TimerTextWidget 00:06
flutter: building TimerTextWidget 00:05
flutter: building TimerTextWidget 00:04
flutter: building TimerTextWidget 00:03
flutter: building ButtonsContainer
flutter: building ButtonsContainer
flutter: building ButtonsContainer
flutter: building ButtonsContainer
flutter: building TimerTextWidget 00:02
flutter: building TimerTextWidget 00:01
flutter: building TimerTextWidget 00:00
flutter: building ButtonsContainer

Больше нет лишних перестроек! Это то, что мы хотим видеть.

Грядущие изменения

То, как вы отфильтровали ненужные изменения, показалось вам немного взломанным. Создатель Riverpod говорит, что он собирается добавить select синтаксис, чтобы упростить эту задачу. Фактически это будет работать так:

final buttonProvider = Provider<ButtonState>((ref) {
  return ref.watch(timerProvider.state.select(
    (state) => state.buttonState),
  );
});

Это кажется намного чище. Следите за этой проблемой на предмет обновлений (и дайте ей положительный голос).

Исходный код

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

Альтернативные решения

Вы управляли состоянием, создав TimerNotifier класс, который обновлял слушателей каждый раз, когда TimerModel изменялся. Это заставило вас отфильтровать нежелательные изменения, чтобы избежать ненужных перестроек.

Альтернативным решением было бы разделение государства на более мелкие части. Вы можете создать один StateNotifier для оставшегося времени и другой StateNotifier для состояния кнопки. Это означает, что вам понадобится TimeLeftNotifier в качестве зависимости в ButtonStateNotifier. Тогда у вас будет два отдельных StateNotifierProvider класса, и не нужно будет фильтровать нежелательные изменения состояния.

Другое альтернативное решение - сделать ButtonsContainer настраиваемым StatefulWidget, который обрабатывает свое собственное состояние и имеет параметры обратного вызова для onStarted, onPaused и onReset. Тогда вам нужно будет беспокоиться только о состоянии оставшегося времени.

Обновления

Я обновил эту статью для Flutter 2.0 и Hooks Riverpod 0.14. Спасибо chunghha за обновление репо.

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



Дальнейшее изучение