Это четвертая часть моей серии про архитектуру Flutter:



Хотя 2 предыдущие части явно не относились к паттерну RxVMS, они были необходимы для ясного понимания этого подхода. Теперь мы обратимся к самом важным пакетам, которые понадобятся, чтобы использовать RxVMS в вашем приложении.


GetIt: быстрый ServiceLocator


Когда вы вспоминаете диаграмму, отображающую различные элементы RxVMS в приложении...


image


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


При наличии массы различных подходов (таких как Inherited Widgets, контейнеров IoC, DI…), лично я предпочитаю Service Locator. На этот счет у меня есть специальная статья про GetIt — мою реализацию этого подхода, а здесь я слегка коснусь этой темы. В общем, вы регистрируете объекты в этой службе единожды, и потом имеете к ним доступ повсюду в приложении. Это вроде синглтонов… но с большей гибкостью.


Использование


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


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


Немного практики


Я обычно инициализирую мой SeviceLocator в файле с названием service_locator.dart через глобальную переменную. Таким образом получается одна глобальная переменная на весь проект.


// Создаем глобалюную переменную
GetIt sl = new GetIt();

void setUpServiceLocator(ErrorReporter reporter) {

// Сервисы

// [registerSingleton] регистрирует экземпляр-синглтон некоего типа.
// передаваемого параметром шаблона.
// sl.get<ErrorReporter>.get() всегда вернет этот экземпляр.
sl.registerSingleton<ErrorReporter>(reporter);

// [registerLazySingleton] передается фабричная функция, которая возвращает этот или производный тип
// sl.get<ImageService>.get() в первый раз вызовет эту функцию и сохранит результат для последующих вызовов.
sl.registerLazySingleton<ImageService>(() => new ImageServiceImplementation());

sl.registerLazySingleton<MapService>(() => new MapServiceImplementation());

// Менеджеры

sl.registerSingleton<UserManager>(new UserManagerImplementation());

sl.registerLazySingleton<EventManager>(() => new EvenManagerImplementation());

sl.registerLazySingleton<ImageManager>(() => new ImageManagerImplementation());

sl.registerLazySingleton<AppManager>(() => new AppManagerImplementation());

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


RegistrationType object = sl.get<RegistrationType>();

//так как GetIt является `callable`, можно сократить обращение:
RegistrationType object2 = sl<RegistrationType>();

Чрезвычайно важное замечание: При использовании GetIt ВСЕГДА используйте единообразную стилистику при импортировании файлов — либо пакеты (рекомендуется), либо относительные пути, но не оба подхода сразу. Это потому, что Dart трактует такие файлы как разные, несмотря на их идентичность.


Если это сложновато для вас, прошу в мой блог для подробностей.


RxCommand


Теперь, когда мы используем GetIt для повсеместного доступа к нашим объектам (включая интерфейс пользователя), я хочу описать, как нам реализовать функции-обработчики для событий UI. Простейшим способом было бы добавление функций к менеджерам и вызов их в виджетах:


class SearchManager {
  void lookUpZip(String zip);
}

и потом в UI


TextField(onChanged: sl.get<SearchManager>().lookUpZip,)

Это бы вызывало lookUpZip на каждое изменение в TextField. Но как в дальнейшем нам передать результат? Так как мы хотим быть реактивными, мы бы добавили StreamController к нашему SearchManager:


abstract class SearchManager {

  Stream<String> get nameOfCity;

  void lookUpZip(String zip);
}

class SearchManagerImplementation implements SearchManager{ 
  @override
  Stream<String> get nameOfCity => cityController.stream;

  StreamController<String> cityController = new StreamController();

  @override
  Future<void> lookUpZip(String zip) async  {
      var cityName = await  sl.get<ZipApiService>().lookUpZip(zip);
      cityController.add(cityName);
  }
}

и в UI:


StreamBuilder<String>(
    initialData:'',
    stream: sl.get<SearchManager>().nameOfCity,
    builder: (context, snapshot) => Text(snapShot.data);

Хотя этот подход и работает, но он не оптимален. Вот проблемы:


  • избыточный код — нам всегда приходится создавать метод, StreamController, и геттер для его потока, если мы не хотим явно отображать его в публичный доступ
  • состояние "занято" — что если мы бы хотели отображать Spinner пока функция делает свою работу?
  • обработка ошибок — что случится, если функкция выбросит исключение?

Конечно, мы могли бы добавить больше StreamControllers для обработки состояний и ошибок… но скоро это становится утомительным, и вот тут-то пригодится пакет rx_command.


RxCommand решает все вышеперечисленные проблемы и многое другое. RxCommand инкапсулирует функцию (синхронную или асинхронную) и автоматически публикует свои результаты в поток.


С помощью RxCommand мы могли бы переписать наш менеджер так:


abstract class SearchManager {
  RxCommand<String,String> lookUpZipCommand;
}

class SearchManagerImplementation implements SearchManager{  
  @override
  RxCommand<String,String> lookUpZipCommand;

  SearchManagerImplementation()  {
      lookUpZipCommand = RxCommand.createAsync((zip) => 
        sl.get<ZipApiService>().lookUpZip(zip));
  }
}

и в UI:


TextField(onChanged: sl.get<SearchManager>().lookUpZipCommand,)

 ...

StreamBuilder<String>(
    initialData:'',
    stream: sl.get<SearchManager>().lookUpZipCommand,
    builder: (context, snapshot) => Text(snapShot.data);

что гораздо лаконичнее и читабельнее.


RxCommand в деталях



RxCommand имеет один входной и пять выходных Observables:


  • canExecuteInput — это необязательный Observable<bool>, который вы можете передать фабричной функции при создании RxCommand. Он сигнализирует RxCommand, может ли она быть выполнена, в зависимости от последнего значения, которое он получил


  • isExecuting — это Observable<bool>, который сигнализирует, выполняет ли команда в настоящее время свою функцию. Когда команда занята, она не может быть запущена повторно. Если вы хотите отобразить Spinner во время выполнения функции-оболочки, слушайте isExecuting


  • canExecute — это Observable<bool>, который сигнализирует о возможности выполнения команды. Это, например, хорошо сочетается со StreamBuilder для изменения внешнего вида какой-нибудь кнопки между включенным/отключенным состоянием.
    его значение таково:


    Observable<bool> canExecute = Observable.combineLatest2<bool,bool>(canExecuteInput,isExecuting) 
        => canExecuteInput && !isExecuting).distinct.

    что означает


    • будет выдано false если isExecuting выдает true
    • будет выдано true только если isExecuting выдает false И canExecuteInput не выдает false.

  • thrownExceptions это Observable<Exception>. Все исключения, которые может сгенерировать упакованная функция, будут перехвачены и отправлены в этот Observable. Удобно слушать его и отображать диалоговое окно, если возникает ошибка


  • (сама команда) на самом деле тоже Observable. Значения, возвращаемые работающей функцией, будут передаваться по этому каналу, поэтому вы можете напрямую передать RxCommand в StreamBuilder в качестве параметра потока


  • results содержат все состояния команд в одном Observable<CommandResult>, где CommandResult определен как



/// Combined execution state of an `RxCommand`
/// Will be issued for any state change of any of the fields
/// During normal command execution, you will get this item's listening at the command's [.results] observable.
/// 1. If the command was just newly created, you will get `null, false, false` (data, error, isExecuting)
/// 2. When calling execute: `null, false, true`
/// 3. When exceution finishes: `result, false, false`

class CommandResult<T> {
  final T data;
  final Exception error;
  final bool isExecuting;

  const CommandResult(this.data, this.error, this.isExecuting);

  bool get hasData => data != null;
  bool get hasError => error != null;

  @override
  bool operator ==(Object other) =>
      other is CommandResult<T> && other.data == data && other.error == error && other.isExecuting == isExecuting;
  @override
  int get hashCode => hash3(data.hashCode, error.hashCode, isExecuting.hashCode);

  @override
  String toString()  {
    return 'Data: $data - HasError: $hasError - IsExecuting: $isExecuting';
  }
}

.results Observable особенно полезен, если вы хотите передать результат команды непосредственно в StreamBuilder. Это отобразит различное содержимое в зависимости от состояния выполнения команды, и оно очень хорошо работает с RxLoader из пакета rx_widgets. Вот пример виджета RxLoader, который использует .results Observable:


Expanded(
  /// RxLoader выполняет различные билдеры в зависимости
  /// от состояния потока Stream<CommandResult>
  child: RxLoader<List<WeatherEntry>>(
    spinnerKey: AppKeys.loadingSpinner,
    radius: 25.0,
    commandResults: sl.get<AppManager>().updateWeatherCommand.results,
    /// выполняется, если .hasData == true
    dataBuilder: (context, data) =>
        WeatherListView(data, key: AppKeys.weatherList),
    /// выполняется, если .isExceuting == true
    placeHolderBuilder: (context) => Center(
        key: AppKeys.loaderPlaceHolder, child: Text("No Data")),
    /// выполняется, если .hasError == true
    errorBuilder: (context, ex) => Center(
        key: AppKeys.loaderError,
        child: Text("Error: ${ex.toString()}")),
  ),
),

Создание RxCommands


RxCommands могут использовать синхронные и асинхронные функции, которые:


  • Не имеют параметра и не возвращают результат;
  • Имеют параметр и не возвращают результат;
  • Не имеют параметра и возвращают результат;
  • Имеют параметр и возвращают результат;

Для всех вариантов RxCommand предлагает несколько фабричных методов с учетом синхронных и асинхронных обработчиков:


static RxCommand<TParam, TResult> createSync<TParam, TResult>(Func1<TParam, TResult> func,...
static RxCommand<void, TResult> createSyncNoParam<TResult>(Func<TResult> func,...
static RxCommand<TParam, void> createSyncNoResult<TParam>(Action1<TParam> action,...
static RxCommand<void, void> createSyncNoParamNoResult(Action action,...

static RxCommand<TParam, TResult> createAsync<TParam, TResult>(AsyncFunc1<TParam, TResult> func,...
static RxCommand<void, TResult> createAsyncNoParam<TResult>(AsyncFunc<TResult> func,...
static RxCommand<TParam, void> createAsyncNoResult<TParam>(AsyncAction1<TParam> action,...
static RxCommand<void, void> createAsyncNoParamNoResult(AsyncAction action,...

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


Доступ к последнему результату


RxCommand.lastResult предоставляет вам доступ к последнему успешному значению результата выполнения команд, который может быть использован в качестве initialData в StreamBuilder.


Если вы хотите получить последний результат, включенный в события CommandResult во время выполнения или в случае ошибки, вы можете передать emitInitialCommandResult = true при создании команды.


Если вы хотите присвоить начальное значение для .lastResult, например, если вы используете его, как initialData в StreamBuilder, вы можете передать его с параметром initialLastResult при создании команды.


Пример — делаем Flutter реактивным


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


Поскольку это очень простое приложение, нам нужен только один менеджер:


class AppManager {
  RxCommand<String, List<WeatherEntry>> updateWeatherCommand;
  RxCommand<bool, bool> switchChangedCommand;
  RxCommand<String, String> textChangedCommand;

  AppManager() {
    // Эта команда ожидает bool при выполнении и передает его как результат  далее 
    // в своем Observable
    switchChangedCommand = RxCommand.createSync<bool, bool>((b) => b);

    // Мы передаем результат switchChangedCommand как canExecute Observable в
    // updateWeatherCommand
    updateWeatherCommand = RxCommand.createAsync<String, List<WeatherEntry>>(
      sl.get<WeatherService>().getWeatherEntriesForCity,
      canExecute: switchChangedCommand,
    );

    // Будет вызвана при каждом изменении в поле поиска
    textChangedCommand = RxCommand.createSync<String, String>((s) => s);

    // Когда пользователь начнет печатать...
    textChangedCommand
        // Ждем приостановки печати  на 500ms...
        .debounce(new Duration(milliseconds: 500))
        // ... затем вызываем updateWeatherCommand
        .listen(updateWeatherCommand);

    // Инициализация при старте менеджера
    updateWeatherCommand('');
  }
}

Вы можете комбинировать различные RxCommands вместе. Обратите внимание, что switchedChangedCommand на самом деле является Observable canExecute для updateWeatherCommand.


Теперь посмотрим, как Менеджер используется в пользовательском интерфейсе:


 return Scaffold(
      appBar: AppBar(title: Text("WeatherDemo")),
      resizeToAvoidBottomPadding: false,
      body: Column(
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: TextField(
              key: AppKeys.textField,
              autocorrect: false,
              controller: _controller,
              decoration: InputDecoration(
                hintText: "Filter cities",
              ),
              style: TextStyle(
                fontSize: 20.0,
              ),
              // Тут мы используем textChangedCommand!
              onChanged: sl.get<AppManager>().textChangedCommand,
            ),
          ),
          Expanded(
            /// RxLoader выполняет различнце builders в зависимости
            /// от состояния Stream<CommandResult>
            child: RxLoader<List<WeatherEntry>>(
              spinnerKey: AppKeys.loadingSpinner,
              radius: 25.0,
              commandResults: sl.get<AppManager>().updateWeatherCommand.results,
              dataBuilder: (context, data) => WeatherListView(data, key: AppKeys.weatherList),
              placeHolderBuilder: (context) => Center(key: AppKeys.loaderPlaceHolder, child: Text("No Data")),
              errorBuilder: (context, ex) => Center(key: AppKeys.loaderError, child: Text("Error: ${ex.toString()}")),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: Row(
              children: <Widget>[
                /// Строим Updatebutton в зависимости от updateWeatherCommand.canExecute
                Expanded(
                  // Это можно было бы сделать при помощи Streambuilder, 
                  // но надо же показать работу  WidgetSelector
                  child: WidgetSelector(
                    buildEvents: sl
                        .get<AppManager>()
                        .updateWeatherCommand
                        .canExecute,  
                    onTrue: RaisedButton(
                      key: AppKeys.updateButtonEnabled,
                      child: Text("Update"),
                      onPressed: () {
                        _controller.clear();
                        sl.get<AppManager>().updateWeatherCommand();
                      },
                    ),
                    onFalse: RaisedButton(
                      key: AppKeys.updateButtonDisabled,
                      child: Text("Please Wait"),
                      onPressed: null,
                    ),
                  ),
                ),
                // Этот виджет переключает canExecuteInput 
                StateFullSwitch(
                  state: true,
                  onChanged: sl.get<AppManager>().switchChangedCommand,
                ),
              ],
            ),
          ),
        ],
      ),
    );

Типовые шаблоны использования


Мы уже видели один способ реагировать на различные состояния команды с помощью CommandResults. В тех случаях, когда мы хотим отобразить, была ли команда выполнена успешно (но не отображать результат), распространенным шаблоном является прослушивание Observables команды в функции initState StatefulWidget. Вот пример реального проекта.


Определение для createEventCommand:


  RxCommand<Event, void> createEventCommand;

Это создаст объект Event в базе данных и не вернет никакого реального значения. Но, как мы узнали ранее, даже RxCommand с возвращаемым типом void будет выдавать один элемент данных при завершении. Таким образом, мы можем использовать это поведение для запуска действия в нашем приложении, как только команда завершится:


@override
void initState() {
  // эта подписка просто ожидает завершения команды, а затем открывает страницу и показывает сообщение
  _eventCommandSubscription = _createCommand.listen((_) async {
    Navigator.pop(context);
    await showToast('Event saved');
  });

  // реагирует на люьбую ошибку при выполнении команды
  _errorSubscription = _createEventCommand.thrownExceptions.listen((ex) async {
    await sl.get<ErrorReporter>().logException(ex);
    await showMessageDialog(context, 'There was a problem saving event', ex.toString());
  });
}

Важно: не забывайте завершать подписки, когда они нам больше не нужны:


@override
void dispose() {
  _eventCommandSubscription?.cancel();
  _errorSubscription?.cancel();
  super.dispose();
}

Кроме того, если вы хотите использовать отображение занятого счетчика, вы можете:


  • слушать isExecuting Observable команды в функции initState;
  • показывать/скрывать счетчик в подписке; а также
  • использовать собственно Команду в качестве источника данных для StreamBuilder

Облегчение жизни с RxCommandListeners


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


В последней версии rx_command добавлен вспомогательный класс RxCommandListener, который разработан для упрощения этой обработки. Его конструктор принимает команд и обработчики для различных изменений состояния:


class RxCommandListener<TParam, TResult> {
  final RxCommand<TParam, TResult> command;

  // Вызывается на каждое выпущенное значение команды
  final void Function(TResult value) onValue;
  // Вызывается  при изменении isExecuting
  final void Function(bool isBusy) onIsBusyChange;
  // Вызывается  при возбуждении исключения в команде
  final void Function(Exception ex) onError;
  // Вызывается  при изменении canExecute 
  final void Function(bool state) onCanExecuteChange;
  // Вызывается  со значением  .results Observable команды
  final void Function(CommandResult<TResult> result) onResult;

  // для упрощения обработки состояний занято/не занято
  final void Function() onIsBusy;
  final void Function() onNotBusy;

  // можно передато значение задержки
  final Duration debounceDuration;

RxCommandListener(this.command,{    
  this.onValue,
  this.onIsBusyChange,
  this.onIsBusy,
  this.onNotBusy,
  this.onError,
  this.onCanExecuteChange,
  this.onResult,
  this.debounceDuration,}
)

 void dispose(); 

Вам не нужно передавать все функции-обработчики. Все они являются необязательными, поэтому можете просто передать те, которые вам нужны. Вам нужно только вызвать dispose для вашего RxCommandListener в вашей функции dispose, и он отменит все используемые внутри подписки.


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


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

Без RxCommandListener:


_selectImageCommandSubscription = sl
      .get<ImageManager>()
      .selectAndUploadImageCommand
      .listen((imageLocation) async {
    if (imageLocation == null) return;
    // вызов выполнения команды
    sl.get<EventManager>().createChatEntryCommand(new ChatEntry(
            event: widget.event,
            isImage: true,
            content: imageLocation.downloadUrl,
          ));
    });
_selectImageIsExecutingSubscription = sl
      .get<ImageManager>()
      .selectAndUploadImageCommand
      .isExecuting
      .listen((busy) {
    if (busy) {
      MySpinner.show(context);
    } else {
      MySpinner.hide();
    }
  });
_selectImageErrorSubscription = sl
      .get<ImageManager>()
      .selectAndUploadImageCommand
      .thrownExceptions
      .listen((ex) => showMessageDialog(context, 'Upload problem',
          "We cannot upload your selected image at the moment. Please check your internet connection"));

Используя RxCommandListener:


selectImageListener = RxCommandListener(
    command: sl.get<ImageManager>().selectAndUploadImageCommand,
    onValue: (imageLocation) async {
      if (imageLocation == null) return;

      sl.get<EventManager>().createChatEntryCommand(new ChatEntry(
            event: widget.event,
            isImage: true,
            content: imageLocation.downloadUrl,
          ));
    },
    onIsBusy: () => MySpinner.show(context),
    onNotBusy: MySpinner.hide,
    onError: (ex) => showMessageDialog(context, 'Upload problem',
        "We cannot upload your selected image at the moment. Please check your internet connection"));

Как правило, я бы всегда использовал RxCommandListener, если имеется более одного Observable.


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


Для получения дополнительной информации о RxCommand прочитайте readme пакета rx_command.

Комментарии (0)