[Из песочницы] Fish Redux — новая Redux библиотека для Flutter

В конце 2018 года Google, не без помощи Open-Source сообщества, сделал большой подарок для мобильных разработчиков, выпустив первую стабильную версию кросс-платформенного фреймворка для мобильной разработки Flutter.

Однако, при разработке крупных приложений, немного больших, чем одностраничные 'Hello World’ы, разработчики могли столкнуться с проблемой неопределенности. Как стоит писать приложение? Фреймворк достаточно молод, ещё не существует достаточной базы хороших примеров с открытым кодом, основываясь на которых можно было бы понять плюсы и минусы применения различных паттернов, понять что стоит использовать в данном конкрентном случае, а что — нет.

Спасает ситуацию то, что Flutter имеет определенную степень схожести с React и React Native, а значит можно перенять некоторый опыт программирования на последних. Возможно, именно из-за этого появились такие библиотеки, как Flutter Flux, Flutter Hooks, MobX, а также сразу несколько реализаций Redux. Долгое время самой популярной была версия Брайана Игана под названием Flutter Redux.

Тем не менее, пару месяцев назад первый коммит увидела библиотека Fish Redux, опубликованная под именем компании Alibaba. Библиотека за короткое время собрала большую популярность, уже на пером дне обогнав реализацию Брайана по колличеству звезд, а на втором опередив её в два раза.

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

Так в чём же существенное отличие Fish’a от версии Брайана? Flutter Redux — фреймворк, отвечающий за управление состоянием. Fish является фреймворком приложения, ставящий в его центр Redux, как основу для управления состояния. Т.е. Fish решает несколько больше задач и не ограничивается лишь state management.

Одной из ключевых особенностей Fish Redux является объединение нескольких редьюсеров в более крупные через прямое выражение зависимости между ними, когда обычный Redux не предоставляет такую возможность вообще, заставляя разработчиков реализовывать всё своими силами. Но давайте вернемся к этому позже, разобравшись с тем, что же из себя представляет этот редьюсер, а также сам Fish Redux.


Связь между Reducer, Effect и View в Component

image

Основой всего в Fish Redux является Component. Это объект, который состоит из трех частей: Effect, Reducer и View. Стоит отметить, что небоходимой является лишь View, т.е. Effect и Reducer описывать необязательно, компонент может работать и без них. Компонент также имеет текущее состояние (State).

State

Для примера возьмем кликер. Пусть в его состоянии будет лишь одно поле — count, которое будет указывать на совершённое кол-во кликов.

class ClickerState implements Cloneable { 
    int count = 0;

    @override
    ClickerState clone() {
        return ClickerState()
            ..count = count;
    }
}

Состояния должны быть неизменяемыми, иммутабельными. Иммутабельность состояния можно легко поддерживать, если реализовать интерфейс Cloneable. В будущем, когда потребуется создать новое состояние, можно будет просто воспользоваться методом clone().


Reducer

Суть редьюсера — отреагировать на какое-то действие, вернув новое состояние. Редьюсер не должен совершать никаких побочных действий.

Напишем простой редьюсер, который будет увеличивать count на какое-то число при получении соответствующего Action’a (про него чуть ниже).

ClickerState clickerReducer(ClickerState state, Action action) {
    // редьюсер получает в параметрах текущее состояниее и Action, который привёл к вызову этого редьюсера.
    if (action.type == Actions.increase) { 
        // т.к. в вашем приложении будет множество различных экшенов, то необходимо убедиться, что целью данного является именно увеличение Count.
        return state.clone()
            ..count = state.count + action.payload;
        // возвращаем копию старого состояния с увеличенным на /payload/ count.
        // payload является параметром экшена.
    }
    // if (action.type == ...) { ... } // редьюсер для другого экшена может быть размещен здесь. 
    return state;
}

Также данный редьюсер можно было записать в таком виде:

Reducer buildClickerReducer() { 
    asReducer({
        Actions.increase: (state, action) => state.clone() ..count = state.count + action.payload,
        //Actions.anotherAction: ...
    });
}


Action

Action — класс в библиотеке FishRedux, который содержит два поля:
Object type — тип экшена, обычно является объектом перечисления (enum)
dynamic payload — параметр экшена, необязательный.

Пример:

enum Actions { increase } // перечисление с типами экшенов
class ActionsCreate {  // вспомогательный класс для их создания
    static Action increase(int value) => Action(Actions.increase, payload: value);
}


View

Логика готова, осталось отобразить результат. View является функцией, принимающей как параметры текущее состояние, функцию dispatch, ViewService и возвращающей Widget.

Функция dispatch нужна для отправки действий: экшена, создание которого мы описали раньше.
ViewService содержит текущий BuildContext (из стандартной библиотеки flutter) и предоставляет методы для создания зависимостей, но про них позже.

Пример:

Widget clickerView(ClickerState state, Dispatch dispatch, ViewService viewService) { 
    return RaisedButton(
        child: Text(state.count.toString()),
        onPressed: () => dispatch(ActionsCreate.increase(1))
        // увеличиваем число на единицу при нажатии на кнопку
    );
}


Component

Соберем из всего этого наш компонент:

class ClickerComponent extends Component {
  ClickerComponent() : super(
    reducer: clickerReducer,
    view: clickerView,
  );
}

Как можно заметить, effect в нашем примере не используется, т.к. в нём нет необходимости. Эффект — функция, которая должна совершать все побочные действия (side effect). Но давайте придумаем случай, в котором нельзя будет обойтись без Effect. Например, таким случаем может стать повышение нашего count на случайное число с сервиса random.org.


Пример реализации effect’а
import 'package:http/http.dart' as http; // нужно добавить http в зависимости

Effect clickerEffect() {
  return combineEffects({
    Actions.increaseRandomly: increaseRandomly,
  });
}

Future increaseRandomly(Action action, Context context) async { 
  final response = await http.read('https://www.random.org/integers/?num=1&min=1&max=10&col=1&base=10&format=plain');
  // запрос к random.org. Возвращает случайное десятичное число от 1 до 10.
  final value = int.parse(response);
  context.dispatch(ActionsCreate.increase(value));
}

// Добавляем экшен increaseRandomly
enum Actions { increase, /* new */ increaseRandomly }
class ActionsCreate { 
    static Action increase(int value) => Action(Actions.increase, payload: value);
    static Action increaseRandomly() => const Action(Actions.increaseRandomly); // new
}

// Добавляем кнопку, при нажатию на которой число будет увеличиваться случайно.

Widget clickerView(ClickerState state, Dispatch dispatch, ViewService viewService) { 
    return Column(
        mainAxisSize: MainAxisSize.min,
        children: [
            RaisedButton( // старая кнопка
                child: Text(state.count.toString()),
                onPressed: () => dispatch(ActionsCreate.increase(1))
            ),
            RaisedButton( // новая
                child: const Text('Increase randomly'),
                onPressed: () => dispatch(ActionsCreate.increaseRandomly())
            ),
        ]
    );
}

// Прописываем эффект в компоненте

class ClickerComponent extends Component {
  ClickerComponent() : super(
    reducer: clickerReducer,
    view: clickerView,
    effect: clickerEffect()
  );
}


Page

Существует расширение для Component под названием Page. Страница включает в себя два дополнительных поля:
T initState(P params) — функция, возвращающая начальное состояние. Будет вызвана при создании страницы.
List> middleware — список Middleware — функций, которые будут вызваны перед редьюсером.
А также один метод:
Widget buildPage(P params) — который собирает страницу в рабочий виджет.

Давайте создадим главную страницу приложения:

class MainPage extends Page {
    MainPage():
        super(
            initState: (dynamic param) {},
            view: (state, dispatch, viewService) => Container(),
        );
}

Страница расширяет компонент, а значит может включать в себя reducer, effect и всё остальное, что имеет обычный компонент.

В примере была создана пустая страница, которая не имеет ни состояния, ни редьюсеров или эффектов. Позднее мы исправим это.

Всё это немного в другом виде есть и в Flutter Redux Брайана Игана, а также других реализациях Redux. Давайте перейдем к главной особенности новой библиотеки — зависимостям.


Dependencies

Fish Redux требует явно определять зависимости между компонентами. Если вы хотите использовать подкомпонент в компоненте, то нужно не только написать эти два компонента, но и создать коннектор, который будет ответственен за преобразование одного состояния в другое. Предположим, что мы хотим встроить ClickerComponent на страницу MainPage.

Для начала нужно добавить нашей странице состояние:

class MainState implements Cloneable { 
    ClickerState clicker;

    @override
    MainState clone() {
        return MainState()
            ..clicker = clicker;
    }

    static MainState initState(dynamic params) { 
        return MainState()
            ..clicker = ClickerState();
    }
}

Теперь мы можем написать Connector:

class ClickerConnector extends ConnOp { 
  @override
  ClickerState get(MainState state) => state.clicker;

  //Этот метод будет вызываться при изменении состояния дочернего компонента.
  @override
  void set(MainState state, ClickerState subState) => state.clicker = subState;
}

Всё. Всё готово для добавления нашего компонента:

class MainPage extends Page {
    MainPage():
        super(
            initState: MainState.initState,
            dependencies: Dependencies(
                slots: {
                  'clicker': ClickerComponent().asDependent(ClickerConnector()),
                  // можно записать как
                  // 'clicker': ClickerComponent() + ClickerConnector(),
                },
              ),
            view: (state, dispatch, viewService) { 
                // получаем наш clicker-виджет.
                final clickerWidget = viewService.buildComponent('clicker');

                return Scaffold(
                  body: Column(
                    mainAxisAlignment: MainAxisAlignment.center,
                    mainAxisSize: MainAxisSize.max,
                    children: [
                      Center(
                        child: clickerWidget, // отображаем его
                      )
                    ],
                  )
                );
            },
        );
}

Таким образом, теперь можно собрать полное рабочее приложение, добавив в main.dart следующий код:


void main() => runApp(MyApp());

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State {
  @override
  Widget build(BuildContext context) =>
      MaterialApp(home: MainPage().buildPage(null));
}

Весь код, разделенный по файлам, доступен здесь. Удачного опыта разработки с Flutter.

© Habrahabr.ru