Как я делал канбан, и что из этого вышло

“Я написал полностью оффлайновое приложение Brisqi. Это канбан-доска: популярное сейчас, и весьма наглядное отображение персональной эффективности. Запустил Brisqi на пяти платформах. Начинал я этот проект, имея в виду две задачи: хорошо выучить React, и сделать удобную канбан-доску. Я потратил год на этот проект.

Вот такой был стек:

  • ReactJS + BlueprintJS + кастомные стили в десктопном приложении.
  • React Native + кастомные стили для Android и iOS.
  • NextJS + BulmaCSS на сайте.
  • Firebase-аутентификация + базы данных Firestore.
  • Google Cloud для бекэнда.

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

Вот к каким выводам я пришел. Многое касается React, надеюсь, будет полезно.

  • Для управления состояниями: применяй Context API, если приложение небольшое. Хорошо изучи Context API. Изучи паттерн Reducer в React для управления состояниями. Не беспокойся о скорости поначалу. React достаточно умная штука, хорошо оптимизирует рендеринг. Оптимизируй код и обращай внимание на рендеринг только когда приложение реально начнет тормозить. Переходи на Redux только если видишь, что приложению нужно что-то более серьезное для управления состояниями.
  • Изучи Immer; да, хорошо изучи как он работает. Это превосходная библиотека для управления состояниями – “in immutable way”, то есть как бы “неизменяемым” путем. Библиотека здорово упрощает жизнь – она изменяет лишь “копию” состояния, причем не применяя новомодные spread-операторы, это важно, если много вложенных объектов. Это удобно также, если применяются “редукторы” (reducers) и Context API.
  • Если все же переходишь на Redux, то делай все в Redux Toolkit. Отличный тулкит, его создали и поддерживают те же люди, кто создал Redux.

Они пишут у себя на сайте:

“Redux Toolkit – наш официальный, полный, эффективный набор инструментов для эффективной работы с Redux. Изначально предназначается для написания логики в Redux, настоятельно рекомендуем работать в Redux Toolkit.”

  • Еще одно замечание. Надо хорошо понять, как использовать с React Функциональные Компоненты и Хуки. Все мои приложения написаны с применением функциональных компонентов и хуков. Я считаю, что такой код лучше для чтения и понимания, он определенно помогает избегать НОС (компонентов высшего порядка, higher-order component) или “костылей” для рендеринга, и это очень понравилось. Начинай свой проект уже с функциональными компонентами и хуками.
  • Оптимизация производительности в React Native бывает сложной задачей, когда надо выводить на отображение много данных одновременно. Если в программе очень много списков, то применяй FlatList вместо итерации значений в карте-map (ну, или подобные способы). Redux здесь полезен, потому как избегает перерисовки лишний раз. Следует помнить, что в Contect API вызов useContext компонента – принудительно перерисует этот компонент и все его вложенные компоненты, если к этим вложенным компонентам не применен React.memo. Если здесь момент непонятный, посмотри на Гитхабе. В Redux, запрос компонента хуком useSelector – предотвращает перерисовку не изменявшегося компонента.
  • Если заострился вопрос ненужности перерисовки не изменявшихся компонентов, изучи как работает React.memo(), useCallback() и useMemo(). Изучи их, понимай их, и применяй их для избежания перерисовки, как только видишь что производительность падает. Если работаешь с Redux, ознакомься с библиотекой Reselect, это о том как создавать мемоизированные функции-селекторы. Все вышеприведенное очень помогло мне, когда понадобилось улучшить производительность в моем мобильном приложении Brisqi. Если сначала пишешь под iOS, то возможно проблемы с производительностью сразу не будут видны, поэтому логику программы надо протестировать на Android сначала. По опыту, приложение написанное на React Native, работает на iOS быстрее, чем на Android. Таким образом, если приложение не тормозит на Android, то точно не будет тормозить на iOS. Но в любом случае надо протестировать на обеих платформах.
  • Если возможно, разбей свои компоненты на малые компоненты. Малые компоненты – обычно “реюзабельные”, простые в работе если применяется React.memo(), легче управлять их состояниями. Легче читать код, и обслуживать его, когда через пару месяцев это понадобится.
  • Включай в проект сторонние библиотеки только при необходимости. Включай их в проект только если видишь, что не можешь воспроизвести эту функциональность самостоятельно. Например, я с нуля написал собственный модуль; это был модуль ожидания ввода с клавиатуры (keyboard avoiding/aware view). Почему писал сам: 1) это было проще, чем подтягивать сторонний модуль. 2) проще настраивать под свое приложение. 3) проще адаптировать под обе платформы – iOS и Android. 4) избежал “подтягивающихся” внешних зависимостей-dependencies, вовсе ненужных. Я не говорю, что сторонние решения нехороши, они может и хороши, и такие есть, но меньше зависимостей = более стабильно все работает. Самописный модуль ты всегда знаешь как свои пять пальцев.
  • Что касается стилей/конвенций, выбери один стиль и соблюдай его. Гайд Airbnb может быть хорош для простого приложения, но я не соблюдал все что там написано. Например, я не согласен с ними по применению двойных и одинарных кавычек. Обычно ставлю двойные кавычки везде, а в гайде требуют ставить и те, и те.
  • Сохраняй простоту. Не усложняй архитектуру приложения. Добавляй компоненты лишь по мере необходимости. Не спеши оптимизировать код на производительность, делай это только когда необходимо.
  • NextJS предназначен для веб-сайтов и многостраничных приложений, а React (CRA – Create-react-app, или вручную) – для одностраничных. Лично я не пробовал “смешивать”, и не советую. Давайте не усложнять.
  • Делай рефакторинг, если видишь, что есть способ что-то сделать получше, чем сделал ты. Будешь расти как специалист.
  • Продолжай учиться, не останавливайся, и делись знаниями.

Мой Твиттер
Собственно, приложение Brisqi

Здесь мы сделаем передышку

А отдохнув, приступим. Выше я описал, как делал приложение редкого сейчас типа “offline-first”. Мой путь не единственный, и выше я описал, наверное, один из “легких путей”.

Я работал с NoSQL, и слово “документ” далее везде – означает таблицу в базе данных SQL.

Что такое Offline-first

Мне пришлось понять, что это значит. В интернете есть несколько распространенных описаний: “частичная оффлайн-функциональность”, “часть данных хранится в оффлайне” и тому подобное. Но эти описания меня не удовлетворили, и я даю собственное описание:

Оффлайн-приложение (offline-first) – это приложение, которое работает (функционирует) полностью в оффлайн-режиме, то есть вообще не требует интернета для своей работы, причем неограниченное время. Для таких приложений главный принцип работы “вся функциональность в оффлайне”. Приложение может иметь, например, подключение к облаку для синхронизации, однако эта функциональность является вторичной.

Можно, следовательно, описать и другую категорию подобных приложений, чуть отличающуюся – oflline-tolerant, то есть толерантную к отсутствию интернета. Такое приложение работает в оффлайне некое ограниченное время, или же дает ограниченную функциональность, но рано или поздно все же потребует синхронизации с облаком. Количество этого оффлайн-времени зависит от функциональности приложения, и места хранения данных. Толерантные к оффлайну приложения обычно хранят часть данных во временном кэше, а вот полностью оффлайновые приложения (offline-first) хранят все свои данные в локальной базе данных.

Все.Должно.Быть.Простым.

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

В конце концов, мне понравилось писать это приложение, и я понял что надо сосредоточиться всего на двух вещах – “онлайн и оффлайн“. Когда приложение было в оффлайне, я отслеживал действия пользователя. Когда появлялось покрытие и приложение переходило в состояние “онлайн” – я “воспроизводил” (replay) эти действия.

Это может показаться немного странным, потому что обычный, принятый подход-“отслеживать изменения” вместо “отслеживать действия”. Оказалось, что отслеживать действия – намного проще, чем отслеживать изменения. Например даже в том, что не надо хранить сотни изменений, внесенных пользователем в базу данных. Я просто фиксирую действия, и потом воспроизвожу их, вот и все.

Как это работает

Когда приложение в онлайне

  • Пользователь выполняет действие (что-то добавляет, изменяет, удаляет и т.п.).
  • Сохраняются изменения в локальную базу данных.
  • Изменения передаются на сервер.

Очень просто. Когда приложение в онлайне, просто передаю (“push”) эти изменения, как в локальную базу, так и на сервер.

Когда приложение в оффлайне

  • Пользователь выполняет действие.
  • Изменение сохраняется в локальной базе данных.
  • Из действий формируется очередь, и также сохраняется в локальную базу данных.

Когда приложение возвращается в онлайн

  • Получить список отслеженных действий.
  • Воспроизвести эти действия, по одному, и не запрашивая локальную базу передать их на сервер.
  • Получить данные из сервера; объединение данных.

Я получаю нужные действия или из локальной базы, или из очереди (если она еще в памяти), и вызываю функции, соответствующие этим действиям, одну за другой. Каждая из этих функций теперь “обходит мимо” локальной базы данных, и напрямую обращается к серверному API. И затем я получаю данные из сервера и объединяю их с данными в локальной базе (позже объясню, как).

Выглядит более-менее осуществимо? Потому что я старался удержать принцип простоты.

Как я определял, что есть какое-то изменение

Чтобы узнать, что в документ (базу данных) внесено изменение, я пробовал следующие подходы:

  • Сохранял временнЫе метки (timestamps) при изменении документа, и потом сравнивал эти метки.

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

  • Выпускал версии документов (так называемый versioning-подход)

При каждом изменении в документе, создается его новая версия, а старый документ сохраняется, плюс хранится история версий. Такое я тоже не продолжал, потому что уж слишком сложно все, а ведь мы – помнишь – стараемся все делать попроще. Подход с опорой на версии документов/файлов – хорошо работает на Git и в PouchDB/CouchDB, и у них получается все делать эффективно, но у меня в приложении – Firebasе (а не CouchDB), по своим причинам.

  • Генерирование нового Changeset ID – идентификатора изменений, при каждом изменении документа.

Changeset ID изменяется при каждом внесении изменения в документ. Если Changeset ID отличается, значит что-то изменилось, и значит документ надо обновить. Это способ оказался удобным для экспериментирования и внедрения, и именно его я и выбрал.

Стратегия решения конфликтов

Далее мне понадобилась стратегия разрешения конфликтов. Сначала мне виделись два типа разрешения будущих конфликтов. Или я объединяю (“ссыпаю в одну кучу”) все приходящие изменения, или же применяю подход “исходя из записи, последней по времени”/”last write wins” (LRW). Я выбрал второй вариант. Этот подход зависит от типа и важности объединяемых данных. Если пишешь приложение, состоящее из примечаний/обновлений от пользователей, слияние данных – это то что надо применять.

В моем случае я делал персональное канбан-приложение, в котором только один пользователь (то есть я) будет синхронизировать данные с другими моими устройствами. LRW-стратегия применима в данном случае. Если что-то перезаписано, то считается, что пользователь сознательно внес это изменение; а если что-то не так, то он исправит, при необходимости. Это намного проще, чем LRW-стратегия синхронизации данных в обе стороны. Помним, что надо сохранять простоту.

Синхронизация и слияние документов из облака

Итак, я подготовил все нужное – уникальный ссылочный ID (reference ID) для каждого документа, Changeset ID для уведомления об изменениях в документе, и LRW-стратегию, и синхронизация облака с локальной базой стала казаться проще простого. Я решил работать с Firestore, поэтому “слушатели запросов” (query listeners) у меня вызываются когда что-то изменяется в облаке. Можно считать слушатели неким подобием “слушателей событий” из Android (event listeners), вызываемых когда Firestore SDK видит что есть изменение. Если бы не было Firestore, пришлось бы делать некий самописный механизм “опрашивания” об изменениях на сервере.

Синхронизация происходит по принципу “Push first, then pull”, то есть сначала отправка, потом прием. Сначала отправляем цепочку ожидающих действий в облако (если действия есть), затем принимаем данные с облака. Этот принцип упрощает работу и данные всегда правильно синхронизируются. Последние изменения, внесенные пользователем, не перезаписываются сверху пришедшими с сервера изменениями. Это соответствует выбранной стратегии LRW.

Отправка данных на сервер

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

Прием данных с сервера

Для приема применял два метода:

  • Передача всех документов пользователя из облака; далее их сравнение с локальной базой данных; далее поиск данных, которые были добавлены/изменены/удалены; далее обновление локальной базы данных.

Это очень “общий” подход, я сделал его более эффективным, ограничив количество получаемых документов. Для этого оценивался некий “ограничиваемый” набор данных. Понадобилось подумать, как сформировать этот набор. В моем случае, я включил в проект “слушатели запросов” Firestore; каждый набор (назовем его коллекцией) может иметь разные слушатели. Количество слушателей я старался ограничить разумеется, поэтому моя опора на Firebase сработала. Я применил этот метод в десктопном приложении для синхронизации.

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

Этот подход работает, когда передача всех пользовательских данных не обязательна. Особенно это важно для смартфонов; мобильное приложение передает только реально нужные данные, вместо “забивания” данными неустойчивого подключения.

Слияние документов

Слияние (объединение) документов из облака с локальной базой данных предусматривает: добавление новых документов; обновление измененных документов; или удаление как бы “удаленных” документов. Напомню, для каждого документа есть уникальный референсный ID, а также changeset ID. Нам надо пройтись по локальным данным и полученным из облака, и сравнить changeset ID (идентификатор изменений); затем обновить соответствующий документ в локальной базе данных (если нужно). Написать такую логику заняло много времени, но оно того стоило.

Что я сделал:

  • Обнаружение новых документов: если в облаке обнаружен новый документ, надо пройтись по локальной коллекции, проверить есть ли в ней соответствующий идентификатор (reference id) и если его нет, то возможно в облаке – новый документ; добавляем его в локальную базу данных.
  • Обнаружение измененного документа: сравниваем идентификаторы changeset ID; если они отличаются, обновляем документ в базе данных.
  • Удаление “удаленных” документов. Здесь и выше, под “удаленными” имеются в виду документы, уже не существующие в облаке. Чтобы удалить эти документы для каждого локального документа – проходим по всем данным в облаке и ищем такой же документ в облаке; если не находим, то удаляем его из локальной базы.

Вот и все

Это было, в принципе, довольно краткое описание, но надеюсь достаточное для понимания. Важный момент – changeset ID, они очень упростили мне жизнь. Я также применил их в мобильном приложении, для сравнения и обновления данных, что ускорило мое приложение. Осталось много не упомянутых вещей, но пост и так слишком большой. И да, если не будешь думать самостоятельно – никогда ничему не научишься.

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

Инди-девелопер и путешественник Ash G

Leave a Comment

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Scroll to Top