Пошаговые инструкции по созданию второго проекта 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 и решения для крайних случаев затрудняют их понимание. В конце концов я решил использовать более упрощенный подход к управлению состоянием, который позволяет избежать использования сложных сторонних пакетов. Если вы хотите узнать об этом, прочтите статью ниже.
Дальнейшее изучение
- Документация Riverpod
- Управление государством как профессионал - Flutter Riverpo d (Видео: Обзор различных типов провайдеров)
- Государственное управление Riverpod (Видео: Хороший обзор)
- Изучение Riverpod и создание приложения Todo (видео: расширенное объяснение с использованием
hooks_riverpod
иStateNotifierProvider
)