Pull to refresh

Ты помнишь чудное мгновенье?

Reading time 8 min
Views 11K

[Прошедшему Году литературы посвящается]


Это была очередная пятница в тихом, уютном баре с лучшими друзьями… Разговор шел как обычно: новости, работа, шутки и опять по кругу. В поисках темы для разговора, потягивая из пивных кружек, почему-то вспомнили о стихах :) И тут каждый стал припоминать, что он еще помнит с тех далеких школьных лет. Если спотыкался, остальные подсказывали, ежели кто помнил, было довольно весело и интересно. Возвращаясь домой в тот вечер, я подумал: а что если сделать простое веб-приложение, чтобы каждый мог вспомнить эти прекрасные произведения русской поэтической мысли? Дизайн приложения уже крутился в голове, и я засел за разработку…




Постановка задачи


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


Дополнительные требования к приложению:


  • простота и понятность
  • отзывчивый дизайн и доступность на мобильных устройствах
  • плавная анимация и качество подачи и работы приложения
  • отображение подсказок пользователю, если он не помнит пропущенного слова
  • поддержка мультиязычности и разного набора стихов/авторов для разных стран
  • сделать полностью статическое приложение, чтобы можно было разместить на GitHub Pages и не платить за хостинг :P

Дизайн


Для оформления специально были проштудированы старые издания стихов, чтобы почерпнуть стилистику оформления печатных изданий того времени. Хотелось чего-то печатного, ощущения "бумаги" и при этом простого. Нынешнее увлечение Flat-дизайном делает вещи проще, тем более для программиста, ведь он не дизайнер. Имхо, получилось сносно:




Хорошему проекту нужно хорошее имя. Проверив несколько доменов, я быстро нашел подходящее имя для проекта — literator.io. Это только потом я узнал, сколько стоит домен в зоне .io, но менять что-то уже было поздно, "искусство требует жертв".


Разработка


Что может радовать программиста в работе больше, чем возможность писать хороший код и не быть связанным дедлайнами?


Но порядок все равно нужен, и я замутил небольшой Kanban в Trello. Люблю порядок.



Да, заметил, что и на GitHub’е теперь можно создавать борды. Возможно, перееду туда.


Технологии

Выбор фреймворка для построения приложения пал на AngularJS, т.к. несколько проектов с основного места работы использовали его. Дело было год назад, Angular 2 тогда был еще в глубокой жопе бете, а React был мне незнаком. Так же хотелось отточить свои навыки в AngularJS, чтобы не потерять сноровку, т.к. по работе приходилось больше писать на ванильном JS под Titanium SDK, а это совсем другая область.


Данные и как их хранить

Поразмыслив над возможной архитектурой и прикинув различные варианты, я пришел к следующей структуре:




Как видите, выделяются две сущности: авторы и стихи. Каждая из сущностей описывается метаданными, файлы которых располагаются в тех же директориях. Стихи (verses) дополнительно имеют файл content.txt, где как раз и хранится стих.


Отдельно выделяется файл structure.json. Если помните, одно из условий задачи было сделать приложение, для которого не нужен будет бекенд. А раз нет бекенда, то мы не можем перебирать доступные директории, чтобы узнать структуру наших данных. Как раз для этого и нужен файл structure.json, который хранит всю структуру и метаданные. Чтобы каждый раз не изменять этот файл вручную, была написана Node-утилита, которая проходится по всем доступным директориям и собирает метаданные (не зря же мы их там разложили, к тому же это удобно).


Как говорится: "Разделяй и властвуй". Хороший разработчик не будет хранить данные (хоть и статические) вместе с исходным кодом в том же репозитории, поэтому для стихов был создан отдельный репозиторий. Так же это позволит использовать всю мощь pull-реквестов для того, чтобы желающие могли добавить новые стихи и новых авторов. Данный репозиторий подключается к основному репозиторию через Git Submodules, что дает дополнительный контроль за тем, какая ревизия данных сейчас используется.


Оставалось изменить таск grunt build, чтобы всё собиралось и копировалось по своим местам.


Стихи и автодополнение слов

Отдельно хотел остановиться на том, как было реализовано автодополнение слов и вообще разбитие стиха на фрагменты, которые нужно заполнить пользователю. Стоял выбор между алгоритмическим выбором блоков и явным указанием этих блоков в самом стихе. Еще хотелось сделать возможность выбора сложности в будущем, поэтому блоки должны были выбираться по-разному. Я выбрал второе — явное указание этих блоков в самом стихе. Это требует предподготовки текста стиха, но позволяет получить лучший результат, т.к. позволяет подобрать [субъективно] наиболее удачные фрагменты, которые будут соответствовать рифме/течению стихотворения и смыслу повествования. Так же тот, кто подготавливает стих, может заранее оценить на сколько сложно/просто будет решить конкретный фрагмент.



Шли {годы{}}. Бурь порыв {мятежный}

Рассеял прежние {мечты},

И я забыл твой {голос} нежный,

Твои небесные {черты}.

Как видите, фрагменты заключены в фигурные скобки. Вложенность необходима для указания фрагментов разной сложности. Скобки "раскрываются" от внутренних к наружным, таким образом "легкому" уровню сложности соответствуют самые внутренние скобки в конкретном выделенном фрагменте. Фрагмент вида {годы{}} соответсвует тому, что он будет использован только для "средней" сложности и пропущен на "легкой", т.к. вложенные скобки ничего не содержат. Таким образом можно добавить любую сложность, в принципе, но на текущий момент в разметке используются только две, а в приложении пока доступна только "легкая".


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


Verse.prototype = {
  // …
  /**
   * Returns string, which normalized to passed difficulty (removes other difficulties' markup)
   * @param {String} string
   * @param {String} difficulty
   * @returns {String}
   */
  normalizeStringToDifficulty: function(string, difficulty) {
    var self = this;

    // Convert difficulty string into int of complexity
    var complexity = difficulty === self.DIFFICULTY_EASY ? 1 : 2;

    // Normalize verse content to passed difficulty by removing block separators for other difficulties
    // (if somebody know easier solution, drop me pull request :)
    var contentArray = string.split('');
    var startCharPositions = [];
    var endCharPositions = [];
    contentArray.forEach(function(char, index){
      // Count separators
      switch (char) {
        case self.BLOCK_SEPARATOR_START:
          startCharPositions.push(index);
          break;

        case self.BLOCK_SEPARATOR_END:
          endCharPositions.push(index);
          break;
      }

      // Check if we counted all separators for one block (Note: current algo is not working with mixed groups)
      if (startCharPositions.length && startCharPositions.length === endCharPositions.length) {
        var blockComplexity = Math.min(startCharPositions.length, complexity); // if block has lower complexity, use its maximum

        // Cleanup unnecessary blocks' separators
        startCharPositions.reverse().forEach(cleanup);
        endCharPositions.forEach(cleanup);

        // Reset stored positions
        startCharPositions = [];
        endCharPositions = [];
      }
    });

    return contentArray.join('');

    function cleanup(position, index) {
      if (index + 1 !== blockComplexity) {
        delete contentArray[position];
      }
    };
  }

  // …

  /**
   * Returns content divided into pieces to display it later
   * @param options
   * @returns {Array}
   */
  getPieces: function(options) {
    var self = this;

    options = angular.extend({
      difficulty: 'easy',
    }, options);

    // Get normalized content
    var contentArray = self.normalizeStringToDifficulty(self.content, options.difficulty).split('');

    // Divide into pieces
    var pieces = [];
    var isInBlock = false;
    var blockPiece = null;
    contentArray.forEach(function(char){
      switch (char) {
        case self.BLOCK_SEPARATOR_START:
          isInBlock = true;
          blockPiece = '';
          break;

        case self.BLOCK_SEPARATOR_END:
          isInBlock = false;
          if (blockPiece.length) {
            pieces.push(new VerseBlock(blockPiece));
          }
          break;

        default:
          if (isInBlock) {
            blockPiece += char;
          } else {
            pieces.push(char);
          }
      }
    });

    return pieces;
  }

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


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


Safari, что ты делаешь…

Определенную боль принесла оптимизация под iOS Safari, в основном из-за того, что программно нельзя поставить фокус на поле ввода, если пользователь не совершил никаких touch-действий. Поэтому там пришлось добавить специальную подсказку, чтобы пользователь тапнул в любое место на экране — только тогда мы можем установить фокус на нужном элементе, что немного портит юзабилити. Если кто-то знает, как решить эту проблему — пишите! Моя последняя попытка здесь https://jsfiddle.net/6tfrh7qn/5/ (открывайте в iPhone Simulator). Еще не нашел как отстайлить каретку.




И всё равно там какой-то баг с курсором, который может не отображаться после фокуса на поле.


User Testing

В процессе разработки регулярно проводился User Testing (в основном на родных и близких) чтобы выявить ошибки в UI, UX (пользовательский опыт) и вообще проверить корректность подачи идеи. Тесты дали очень хорошие плоды, позволив существенно улучшить UX.


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


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


В общем, очень полезная практика, не пренебрегайте юзер-тестами!


Unit/E2E testing

Также весь код покрыт Unit-тестами и E2E-тестами, не в каждом проекте удается выкроить на это время. Писать их было одно удовольствие, местами разработка шла по TDD. Да, E2E тесты на Protractor могут сфейлиться, если окно браузера, запускаемого тестом, на заднем плане или невидимо. Если кто знает, как это исправить, просьба сообщить.


Подводя итоги


Веб-приложение: http://literator.io
Репозиторий приложения: https://github.com/bobrosoft/literator.io
Репозиторий стихов: https://github.com/bobrosoft/literator.io-verses


Разработка шла неспешно, и прошло уже более года с момента её старта. Хоть и основная часть была завершена довольно быстро, потребовалось время, чтобы всё отполировать и закончить — принцип Парето в действии :) Иногда пропадало желание продолжать, т.к. в голову приходили совершенно новые идеи, но я сделал усилие, а то этот гештальт не давал бы покоя.


Думаю, что для начала добавил все стихи, которые должны быть известны большинству из нас. Старался пока не брать длинные стихи (хотя есть "Бородино"), чтобы не утомлять пользователя. Если незаслуженно пропустил что-то, напишите в комментариях, добавлю.


Идеи развития проекта (в порядке важности):


  • добавить кнопку "Я не помню", чтобы пропустить незнакомый стих

  • изменить отображение результата, сделать его интереснее, показывать на сколько хорошо помнишь конкретное стихотворение / на сколько хорошая память (пожелание с одного из юзер-тестов)

  • мультиязычность и разный набор стихов/авторов для разных стран (еще до конца не реализовано, но задел есть)

  • добавить список стихов

  • рисунки на полях, появляющиеся по мере повествования (Пушкин любил рисовать, у разных авторов былы бы свои; задел для этого в файловой структуре есть)

  • добавить звуковое сопровождение в виде классической музыки под настроение стихотворения (в метаданных стиха есть поле "mood" как раз для этого). Если кто-то знает хороший источник Creative Commons музыки, где можно найти классику в хорошем качестве, просьба поделиться.


Спасибо за внимание! Надеюсь, кому-то этот проект покажется интересным и принесет положительные эмоции :)


UPD: кому интересно дальнейшее развитие проекта, вступайте в группу https://vk.com/literatorio или https://www.facebook.com/LiteratorioApp/ чтобы не пропустить анонс обновлений.

Tags:
Hubs:
+31
Comments 23
Comments Comments 23

Articles