Как мы сделали Rich Text Editor с поддержкой совместного редактирования под Android

рисунок
«Мобилизация» рабочих процессов в компаниях означает, что на телефон или планшет переносится все больше функций для совместной работы. Для Wrike, как кроссплатформенного сервиса управления проектами, важно, чтобы функционал мобильного приложения был абсолютно полноценным, удобным и не ограничивал пользователей в работе. И когда встала задача создать Rich Text Editor с поддержкой совместного редактирования описания задач, мы, оценив возможности существующих WebView компонентов, решили пойти своим путем и реализовали собственный нативный инструмент.

Для начала немного об истории продукта. Одной из базовых функций Wrike изначально была интеграция с почтой. С самой первой версии задачи можно было создавать и обновлять через e-mail, а затем работать над ними совместно с другими сотрудниками. Тело письма превращалось в описание задачи, а все дальнейшее обсуждение шло в комментариях к ней.

Поскольку в почте можно использовать HTML форматирование, в ранних версиях продукта мы использовали CKEditor для дальнейшей работы с описанием задачи. Но в среде, ориентированной на совместную работу, это очень неудобно — необходимо блокировать весь документ или его часть, чтобы подготовленное описание задачи не затер кто-то другой. В итоге мы решили углубиться в практику Operation Transformation (OT) и сделать инструмент для настоящей совместной работы. В этой статье я не буду подробно рассматривать теорию и реализацию OT для rich text документов, об этом есть уже достаточно материалов. Я рассмотрю лишь сложности, с которыми столкнулась наша команда при разработке мобильного приложения.

Совместное редактирование на смартфоне —, но зачем?


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

  1. Реализация OT требует хранить документ в определенном формате, поддерживающим совместное редактирование. В случае простого текста особого формата тут нет — это может быть просто строка. Но в случае с Rich Text (текста с форматированием), формат хранения становится сложнее.
  2. Нам нужен способ сохранять изменения, сделанные мобильным клиентом, не сломав документ и не создав конфликт с изменениями, которые могли внести в тот же промежуток времени другие пользователи. Это задачи, которые как раз и решаются алгоритмами OT.
  3. Раз нам нужно перенести алгоритм OT на мобильную платформу, чтобы выполнить условия из пункта 2, то сделать полноценное совместное редактирование уже не требует значительных дополнительных усилий.


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

Варианты реализации


С реализацией компонента для совместной работы опыт уже был, а вот с тем, как перенести его на Android, предстояло разобраться. Многое зависело от требований к редактору и их, по большому счету, было два:

  1. Поддержка базового форматирования, списков, вставка картинок и таблиц,
  2. API, позволяющий вносить и отслеживать изменения как в самом тексте, так и в его форматировании.

Способ 1: использовать существующий компонент из Web продукта
Действительно, мы могли бы использовать компонент, который у нас уже есть, и обернуть его в WebView. Из плюсов — простота интеграции, так как фактически весь код редактора находится в скриптах, и Android/iOS разработчику остается только реализовать WebView wrapper.

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

Чтобы обойти проблемы ContentEditable, мы пробовали использовать CodeMirror как фронтенд для редактора, при этом он заметно лучше и стабильнее работает на Android, поскольку обрабатывает все события от клавиатуры и отрисовку самостоятельно. Были, конечно, и минусы, но как быстрый workaround он работал очень неплохо до тех пор, пока не появилось небезызвестное изменение в обработке событий нажатия клавиш в IME — довольно подробно эта проблема обсуждается здесь. Если в двух словах — при использовании LatinIME, он не отправляет событие для KEYCODE_DEL.

Что это значит для пользователя? При нажатии на Delete ничего не происходит, то есть редактор работает корректно, можно вводить текст, применять форматирование… вот только текст нельзя удалить, как бы это абсурдно ни звучало. Единственный вариант решения данной проблемы помимо всего прочего включал в себя следующий код:

@Override
public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
  BaseInputConnection baseInputConnection = new BaseInputConnection(this, false) {
     @Override
     public boolean sendKeyEvent(KeyEvent event) {
        if (needsKeyboardFix() && event.getAction() == KeyEvent.ACTION_MULTIPLE && event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) {
           passUnicodeCharToEditor(event);
           return true;
        }
        return super.sendKeyEvent(event);
     }

     @Override
     public boolean deleteSurroundingText(int beforeLength, int afterLength) {
        if ((Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) && (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) &&
              (beforeLength == 1 && afterLength == 0)) {
           // Send Backspace key down and up events
           return super.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL))
                 && super.sendKeyEvent(new KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_DEL));
        }
        else {
           return super.deleteSurroundingText(beforeLength, afterLength);
        }
     }
  };

  outAttrs.inputType = InputType.TYPE_NULL;

  return baseInputConnection;
}


InputType.TYPE_NULL при этом переводил IME в «упрощенный» вид, сигнализируя, что InputConnection работает в ограниченном режиме, что означает отсутствие copy/paste, autocorrect/autocomplete, а также ввода текста с помощью жестов, но при этом он позволяет обрабатывать все события клавиатуры.

В итоге, в последней реализации редактора, который использовал веб-интерфейс, были следующие недостатки:

  • медленная скорость загрузки;
  • отсутствие доступа к расширенным возможностям IME (copy/paste, autocomplete/autocorrect, gesture input);
  • в некоторых случаях нестабильная работа, в связи с различной реализацией WebView на разных версиях API и модификации этого компонента некоторыми вендорами;
  • обычно WebView долго не держится в памяти, особенно на девайсах с небольшим объемом памяти, и, если свернуть приложение и через некоторое время запустить заново, то в большинстве случаев WebView придется снова инициализировать;
  • многочисленные костыли в коде, число которых со временем только увеличивалось.


Осознав, что поддерживать подобную имплементацию редактора непросто, и учитывая описанные недостатки и ограничения, было решено разработать нативный компонент, который бы давал возможность работать с форматированным текстом.Способ 2: нативная реализация
Для нативной реализации необходимо решить две задачи:

  1. UI редактор, то есть отображение текста с учетом форматирования и его редактирование.
  2. Работа с форматом документа, отслеживание изменений, а также обмен данными с сервером.


Для того, чтобы решить первую задачу, не нужно изобретать колесо — Android предоставляет необходимые инструменты, а именно компонент EditText и интерфейс Spannable, описывающий маркировку текста.

Вторая задача решается переносом алгоритмов OT из JavaScript на Java, и процесс здесь достаточно прозрачен.

Отображение Rich Text в EditText


В Android есть замечательный интерфейс Spannable, который позволяет задать разметку текста. Сам процесс формирования разметки довольно прост — нужно воспользоваться специальным классом SpannableStringBuilder, который позволяет как задавать/изменять текст, так и устанавливать стили для заданных участков текста через метод

setSpan(Object what, int start, int end, int flags). 


Первый параметр как раз задает стиль. Он должен быть экземпляром класса, который реализует один или несколько интерфейсов из пакета android.text.style: CharacterStyle, UpdateAppearance, UpdateLayout, ParagraphStyle и т.д. Набор дефолтных стилей довольно широк — от изменения формата символов (StyleSpan, UnderlineSpan), задания размера текста (RelativeSizeSpan) и изменения его положения (AlignmentSpan) до поддержки изображений (ImageSpan) и кликабельного текста (ClickableSpan).

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

SpannableStringBuilder ssb = new SpannableStringBuilder(text);
ssb.setSpan(new ForegroundColorSpan(Color.BLUE), 0, text.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
textView.setText(ssb, TextView.BufferType.SPANNABLE);


Итак, на входе есть текст в неком формате, а на выходе нужно получить его представление в виде Spannable объекта и передать его в EditText. В нашем случае с сервера документ приходит в особом формате в виде атрибутированной строки — необходимо распарсить эту строку, используя нашу библиотеку для OT, и применить атрибуты к заданным участкам текста. В зависимости от стиля, нужно выставить корректный флаг, чтобы маркировка текста соответствовала ожиданиям пользователя.

Если пометить стиль флагом SPAN_EXCLUSIVE_INCLUSIVE, то он будет применен к введенному в конце интервала тексту, но не будет применяться в начале. Например, есть интервал [10, 20], для которого выставлен стиль UnderlineSpan + SPAN_EXCLUSIVE_INCLUSIVE. В этом случае при вводе текста в позицию 9, к нему стиль UnderlineSpan применяться не будет, но если начать вводить текст в позиции 20, то интервал, который покрывает стиль, расширится и станет [10, 21]. Естественно, это полезно для inline форматирования (bold / italic / underline и т.п.).

При использовании флага SPAN_EXCLUSIVE_EXCLUSIVE, интервал стиля ограничивается с обоих концов. Это подходит, например, для ссылок — если начать вставлять текст сразу после ссылки, то стиль ссылки к нему применяться не должен.

Используя флаги SPAN_EXLUSIVE_INCLUSIVE и SPAN_EXCLUSIVE_EXCLUSIVE можно управлять поведением форматирования при вводе текста в зависимости от ожиданий пользователя. Например, если вы включили режим форматирования Bold, то вводимый текст должен оставаться жирным. А если вы сделали ссылку, то дописывание текста в конце не должно расширять границы ссылки.

Для отображения элементов списка можно воспользоваться BulletSpan, но он подойдет только для ненумерованных списков. Если же необходима нумерация, то можно написать свой класс, реализующий интерфейсы LeadingMarginSpan и UpdateAppearance, отрисовывая индикатор списка на свое усмотрение в методе drawLeadingMargin.

Обработка пользовательских стилей


Понятно, что редактор должен давать пользователю возможность применять форматирование, это включает:

  1. Добавление нового стиля к выбранному тексту,
  2. Вставку нового стиля в позиции курсора,
  3. Применение текущего стиля при редактировании.


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

private void onApplyInlineAttributeToSelection(int selectionStart, int selectionEnd, TextAttribute attribute) {
  int selectionStart = mEditText.getSelectionStart();
  int selectionEnd = mEditText.getSelectionEnd();

  if (!mEditText.hasSelection()) {
     // if there's no selection, insert/delete empty span for the appropriate attribute,
     // but only in case the cursor is present
     if (selectionStart == selectionEnd && selectionStart != -1) {
        if (mTempAttributes == null || mTempAttributes.getPos() != selectionStart) {
           mTempAttributes = new TempAttributes(selectionStart);
        }

        Set attributeSpans = getAttributeSpans(selectionStart, selectionEnd, attribute);
        if (attributeSpans.size() > 0) {
           attribute.nullify();
        }

        mTempAttributes.addAttribute(attribute);
     }
     return;
  }

  if (attribute == null) {
     return;
  }

  boolean changed = applyInlineAttributeToSelection(selectionStart, selectionEnd, attribute);
  // if nothing changed, then there's no need to build any changesets and send updates to server
  if (!changed) {
     return;
  }

   // ...

}


mTempAttributes — экземпляр класса TempAttributes. Он определяет набор атрибутов в данной позиции, выбранных пользователем. Эта переменная обнуляется либо после использования, либо при смене позиции курсора.

static class TempAttributes {
  private final int mPos;
  private final Map mAttributeMap = new HashMap<>();

  public TempAttributes(int pos) {
     mPos = pos;
  }

  public int getPos() {
     return mPos;
  }

  public Collection getAttributes() {
     return mAttributeMap.values();
  }

  public void addAttribute(TextAttribute attribute) {
     AttributeName name = attribute.getAttributeName();
     TextAttribute oldAttribute = mAttributeMap.get(name);
     if (oldAttribute != null && !oldAttribute.isNull()) {
        attribute.nullify();
     }
     mAttributeMap.put(name, attribute);
  }
}


Если пользователь нажимает на кнопку, соответствующую некоторому стилю, на тулбаре, но при этом никакой текст не выбран, в этом случае нужно сохранить этот стиль как «временный» в текущей позиции курсора и применить его при вводе текста в этой позиции. Подробнее об этом чуть ниже.

Когда текст был выбран, нужно определить, есть ли уже этот стиль в выбранном интервале или нет. Если нет или есть частично, то необходимо объединить все существующие span«ы и покрыть интервал этим стилем полностью. Если же есть, то удалить соответствующие span«ы из интервала, при необходимости разбив его.

Пример 1
Есть текст: Quick brown fox.
В нем 2 span-а: bold [0,4] и bold [12,14]. Если пользователь выделяет весь текст и применяет к нему стиль bold, то в итоге он должен покрывать весь интервал. Для этого можно либо удалить оба span«а и добавить новый bold [0, 14], либо удалить второй и продлить первый до конца интервала.

Пример 2
Есть текст: Quick brown fox.
В нем один span: bold [0, 14]. Если пользователь выделяет текст [4, 12] и выбирает стиль bold в тулбаре, то стиль нужно удалить из интервала, так как он полностью присутствует в выделении. Для этого нужно разбить интервал на две части: укоротить весь интервал [0, 14] до начала выделения ([0, 4]) и добавить новый интервал от конца выделения до конца текста ([4, 12]).

Отслеживание изменений в документе


Чтобы корректно отслеживать изменения пользователя и «скармливать» их алгоритму OT, редактор должен уметь их отслеживать. Для этого используется интерфейс TextWatcher — каждый раз, когда в EditText происходят какие-то изменения, последовательно вызываются методы beforeTextChanged, onTextChanged и afterTextChanged этого интерфейса, позволяя определить, что и где изменилось.

private boolean mIgnoreNextTextChange = false;
private int mCurrentPos;
private String mOldStr = null;
private String mNewStr = null;

// ...

public void ignoreNextTextChange(boolean ignore) {
  mIgnoreNextTextChange = ignore;
}

public void beforeTextChanged(CharSequence s, int start, int count, int after){
  if (mIgnoreNextTextChange) {
     return;
  }

  mOldStr = null;
  mCurrentPos = start;
  if (s.length() > 0 && count > 0) {
     mOldStr = s.subSequence(start, start + count).toString();
  }
}

public void onTextChanged(CharSequence s, int start, int before, int count) {
  if (mIgnoreNextTextChange) {
     return;
  }

  mNewStr = null;

  if (s.length() > 0 && count > 0) {
     mNewStr = s.subSequence(start, start + count).toString();
  }
}

public void afterTextChanged(Editable s) {
  // ...
}


Важно учесть, что при первоначальной установке текста в редактор через setText (CharSequence), TextWatcher также получит уведомление об этом, поэтому программная установка текста оборачивается в:

mEditTextWatcher.ignoreNextTextChange(true);
mEditText.setText(builder);
mEditTextWatcher.ignoreNextTextChange(false);


В переменных mOldStr и mNewStr хранятся старая строка и новая строка соответственно, mCurrentPos указывает на позицию, начиная с которой произошли изменения. Например, если пользователь добавил символ «a» в позиции 10, то

mOldStr = null;
mNewStr = "a";
mCurrentPos = 10;


Однако есть небольшой нюанс — при вставке текста из-за автокоррекции эти значения могут включать начало слова. Например, если текст начинается со слова «Text», и пользователь заменяет третий символ на «s», то IME может рапортовать это изменение как:

mOldStr = "Tex";
mNewStr = "Tes";
mCurrentPos = 0;


В этом случае нужно отрезать одинаковые последовательности символов от начала строки.

В конечном итоге, используя TextWatcher, можно однозначно определить, что конкретно произошло — был текст заменен, удален или добавлен. Если пользователь добавляет текст в позиции или заменяет часть имеющегося текста на текст из буфера, необходимо применить к добавленному тексту те атрибуты, которые находятся в позиции курсора. Для этого нужно найти все Spannable объекты в позиции курсора, при этом не забыв исключить те, которые стали пустыми (s.getSpanStart (span) == s.getSpanEnd (span)), удалив при этом сами объекты Spannable и отфильтровав только по inline атрибутам (bold, italic, etc.). Дополнительно добавляются те атрибуты, которым соответствуют стили, выбранные пользователем на тулбаре (mTempAttributes).

public void afterTextChanged(Editable s) {

  // ...

  Object[] spans = s.getSpans(mCurrentPos, mCurrentPos, Object.class);

  Map spanAttrMap = new LinkedHashMap<>();
  for (Object span : spans) {
     TextAttribute attr = AttributeManager.attributeForSpan(span);
     if (attr != null) {
        spanAttrMap.put(span, attr);
     }
  }

  if (!TextUtils.isEmpty(mOldStr)) {
     Iterator> iterator = spanAttrMap.entrySet().iterator();
     while (iterator.hasNext()) {
        Map.Entry entry = iterator.next();
        Object span = entry.getKey();
        TextAttribute attr = entry.getValue();

        // ...

        if (s.getSpanStart(span) == s.getSpanEnd(span)) {
           s.removeSpan(span);
           iterator.remove();
        }
     }
  }

  // ...

  Set attributes = new HashSet<>();
  if (!TextUtils.isEmpty(mNewStr)) {
     // determine all inline attributes at current position
     for (Map.Entry entry : spanAttrMap.entrySet()) {
        TextAttribute attr = entry.getValue();

        if (AttributeManager.isInlineAttribute(attr)) {
           attributes.add(attr);
        }
     }
  }

  if (mCallbacks != null) {
     mCallbacks.onTextChanged(mCurrentPos, mOldStr, mNewStr, attributes);
  }
}


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

Стоит заметить, что при отслеживании изменений в редакторе хорошей практикой будет использование оберток для всех дефолтных стилей. Например, вместо UnderlineSpan использовать класс CustomUnderlineSpan, который наследуется от UnderlineSpan, но при этом никакие методы в нем не переопределены. Такой подход позволит по классу однозначно отделить «свои» стили от тех, которые применяет EditText. Например, если включена поддержка автозамены, то при редактировании слова EditText добавляет ему стиль UnderlineSpan, и визуально слово подчеркивается на момент редактирования.

О совместимости с разными версиями API


На версиях API до Android KitKat существует проблема с наложением Spannable текста при редактировании. Она решается отключением аппаратного ускорения TextView (возможно, есть другие способы это исправить — предложения в комментариях горячо приветствуются):

mEditText.setLayerType(View.LAYER_TYPE_SOFTWARE, null);


Однако в таком виде TextView нельзя поместить в ScrollView, так как вся View будет рендериться в памяти («View too large to fit into drawing cache»), поэтому нужно включать прокрутку в самом TextView.

mEditText.setVerticalScrollBarEnabled(true);
mEditText.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_OVERLAY);

Заключение


Намучавшись с реализацией редактора на webview и осознав тупиковость данного подхода, нам удалось разработать нативный компонент, который решает непростую, но довольно интересную задачу совместного редактирования текста. Это позволило улучшить юзабилити приложения и повысить продуктивность наших пользователей. Получившийся результат можно оценить, скачав наше приложение из Google Play.

9e87fcfc701c4873a018d83b12a5dae1.gif

© Habrahabr.ru