Танцы с бубном, душевные терзания и комплекс супергероя: как мы новый редактор в «Заметках» разрабатывали

Привет, Хабр! Меня зовут Антон Макарычев, я ведущий инженер-программист в команде мобильной разработки kvadraOS. Сейчас мы с коллегами работаем над приложением «Заметки»: уже реализовали Drag-and-Drop между разными экранами в Compose, рисование на холсте, экспорт заметок в PDF или TXT и другие полезные функции. И сегодня я хочу рассказать, как рождалась наша ключевая функциональность — редактор. 

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

Танцы с бубном, душевные терзания и комплекс супергероя: как мы новый редактор в «Заметках» разрабатывали - 1

Задача простая, но это не точно

Начиналось все так. Нам предстояло написать «Заметки» с нуля, задав при этом некий архитектурный шаблон для других приложений kvadraOs: «Файлов», «Галереи», «Сообщений» и так далее. В качестве редактора решили использовать готовый BaseTextField

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

Практически без раздумий мы с командой определили, какие технологии будем использовать и на что потратим неминуемую премию. Шаблон MVVM прекрасно ложился в концепцию Compose. Для внедрения зависимостей (DI) взяли Hilt. Данные решили хранить в Room.

Продакты вкратце рассказали, какие основные функции нужны в редакторе:

  • поддержка разных стилей текста,

  • вставка изображений из галереи или рисовалки,

  • поддержка списков,

  • выравнивание текста справа, слева и по центру.

Бегло изучив BasicTextField, мы обнаружили у него TextFieldValue, которое работает с AnnotatedString. А тут и ежу понятно: где AnnotatedString, там и поддержка стилей. Но так как мы с вами все-таки не ежи, я немного расскажу, как это устроено.

Коротко о поддержке стилей

AnnotatedString — это основная структура для определения разных стилей в тексте. Каждый представлен в виде AnnotatedString.Range:

    @Immutable
    public final data class Range<T>(
        val item: T,
        val start: Int,
        val end: Int,
        val tag: String,
    )

item — это модель, которая описывает особенности текста: какого он цвета, выделен ли жирным, подчеркнут ли и так далее. Как известно, умные и одаренные люди любят простоту и изящество, и именно для них у AnnotatedString есть вспомогательный класс Builder:

buildAnnotatedString {
    append("Hello")
    // Задаем зеленый цвет текста. Весь новый текст будет зеленым.
    pushStyle(SpanStyle(color = Color.Green))
    // Добавляем новый текст. Он будет отрисован зеленым.
    append(" World")
    // Заканчиваем зеленый текст.
    pop()
    // Добавляем текст без какого-либо стиля.
    append("!")
    // Накладываем стиль красного цвета на весь текст после Hello World.
    // То есть знак восклицания станет красным.
    addStyle(
        SpanStyle(color = Color.Red),
        "Hello World".length, // начало стиля
        This.length, // конец стиля
    )

    toAnnotatedString()
}

Где-то в недрах TextField эта строка измеряется, при необходимости разбивается на несколько, а потом передается в TextPainter. Он рисует разбитый текст на холсте, применяя нужные стили по заданным диапазонам. В итоге мы получаем красивую разноцветную надпись:

Танцы с бубном, душевные терзания и комплекс супергероя: как мы новый редактор в «Заметках» разрабатывали - 2

Работа кипела, таски закрывались с первой космической скоостью, кофе тек рекой, а начальство нами гордилось. А потом… потом случился первый затык.

Первое испытание — изображения

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

Каждый раз, когда пользователь хотел вставить картинку, мы добавляли Image ниже нашего поля ввода и следом — еще одно поле. Визуально все выглядело симпатично. В базе данных заметка была представлена в виде набора полей типов Picture и Text.

Второе испытание — стили

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

Первые неприятности возникли, когда я попытался использовать AnnotatedString в Compose для реализации стилей. Оказалось, что Google по какой-то неведомой причине решил, что стили в BasicTextField — лишние. То есть на момент создания этого текста он просто их не поддерживал — ну, или почти не поддерживал. 

Если выставить красиво отформатированный текст через TextFieldValue, он прекрасно отобразится. Но стоит пользователю ввести хотя бы один символ, все стили волшебным образом испаряются.

Танцы с бубном, душевные терзания и комплекс супергероя: как мы новый редактор в «Заметках» разрабатывали - 3

Это меня сильно раздосадовало, я даже икнул пару раз от расстройства. Но комплекс супергероя не позволил впасть в уныние. Поправив плащ и натянув трико повыше, я взялся реализовывать поддержку стилей. Для этого пришлось хранить последний TextFieldValue с добавленными шрифтами. Когда приходил новый, «чистый» TextFieldValue, я брал стили из сохраненной модели, обновлял их диапазоны в зависимости от изменений текста и переносил их на новый TextFieldValue.

Третье испытание — списки

Успешный успех в поддержке стилей привел нас к следующей задаче — добавить нумерованные и маркированные списки. 

Танцы с бубном, душевные терзания и комплекс супергероя: как мы новый редактор в «Заметках» разрабатывали - 4

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

Утром я посмотрел гороскоп и обнаружил, что Марс в пятом доме. Решил, что это знак, чтобы наведаться с интересным предложением к начальству. Накануне, бороздя просторы интернета, мы с коллегами наткнулись на парочку библиотек с нужной функциональностью. Лицензия у них была подходящая, и их можно было использовать бесплатно. То есть интегрировать одну из них было гораздо проще и быстрее, чем писать свою.

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

Все хорошо, но надо переделать

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

К тому моменту мы уже знали, что такое же приложение планируется для веба, и нам нужно будет синхронизироваться с бэкендом. Поэтому мы сохраняли текстовое поле заметки в виде HTML в базе данных, и все было прекрасно… пока не пришел срок реализовать To-Do-списки. Но библиотека делать этого не хотела и не умела. Пришлось обратиться к шаману. Он постучал в бубен, вызвал дождь и уехал, а мы остались думать, как быть дальше.

Повздыхав, решили, что пришло время возвращаться к старым наработкам со стилями. Внимательно посмотрели на AnnotatedString и обнаружили там есть inlineContent, в который можно поместить иконку. Эти иконки должны были играть роль чекбоксов. 

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

— А где есть AnnotatedString?

— Правильно, в TextFieldValue

— А кто у нас работает с TextFieldValue

BasicTextField!

Все-таки не зря мы с него начинали. Кот сидел рядом и довольно жмурился.

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

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

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

Наш любимый Compose и тут умудрился устроить подлянку. Оказалось, что при добавлении ParagraphStyle в AnnotatedString съедался последний символ этого параграфа. Видимо, он использовался как идентификатор переноса строки где-то под капотом. Или это просто баг, который никто не находил, потому что эта функциональность официально не поддерживалась. Мы послали письмо на Альфу Центавра с вопросом, но нам никто не ответил, а самим разбираться в причинах было некогда. 

Танцы с бубном, душевные терзания и комплекс супергероя: как мы новый редактор в «Заметках» разрабатывали - 5

В качестве воркэраунда мы добавляли к концу параграфа специальный символ в виде дефиса — это так называемый hyphen.

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

И вот, наконец, нумерованные и маркированные списки были реализованы, протестированы и выпущены в релиз. Остались To-Do.

Четвертое испытание — снова списки, на этот раз To-Do

По традиции тут тоже сначала казалось, что все максимально просто. Нужно было добавить InlineContent для иконки чекбокса, поместить этот контент в TextField, повесить на иконку клик-листенер и менять иконку и цвет текста параграфа. Не так много, если бы не новый сюрприз от Compose. 

Оказалось, что поля ввода не умеют работать с InlineContent. Я купил билеты в Гималаи, взял капельницу и улетел очищаться. По крайней мере, так я рассказывал друзьям, а на самом деле просто рыдал в подушку ночи напролет.

На этот раз без символичного сна тоже не обошлось: мне приснился Менделеев и другие внушительные люди, они молчали и строго смотрели на меня. Что ж, пришлось снова взять себя в руки.

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

Я собрал команду на встречу, и мы принялись расспрашивать продактов, какие функции еще планируются. Кто же знал, что мы открыли ящик Пандоры… Все-таки приятно, когда у тебя во главе стоят люди с прекрасным воображением и далеко идущими планами. От восторга я радостно и увлеченно моргал. Но вместе с новым знанием росло понимание, что и как нам нужно делать.

Чтобы реализовать To-Do-списки и запланированные фичи, нужно писать свой редактор с нуля и рисовать все на Canvas. Тут было два варианта:

  1. Наследоваться от View и переопределить onDraw. Потом встроить этот класс через AndroidViewBinding в наш Composable-экран.

  2. Использовать Canvas.

Я накидал простенькие прототипы для обоих вариантов. Изучив их с командой, мы выбрали первый. Преимущества у него были такие: изменения во встроенной «вьюшке» никак не влияли на рекомпозицию всего экрана. Работа с традиционным View не вызывала вопросов и не сулила неприятных сюрпризов. Главное — не забывать про один важный нюанс: метод onDraw вызывается по малейшей необходимости, будь то скроллинг или мигание курсора.

Коллаб редактора и клавиатуры 

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

Можно провести такую аналогию. Представьте, что редактор (EditText) — это большой и сложный робот, а его задача — аккуратно составлять текст из полученных букв. Источники ввода (IME) — это толпа увлеченных детей (клавиатура, голосовой ввод и другие), которые хотят управлять роботом и диктовать ему, что писать. Проблема в том, что все дети кричат одновременно, команды у всех разные, и, если позволить им напрямую дергать робота за рычаги, он может сломаться или сделать что-то не то.

Чтобы этого не случилось, работает система безопасности:

  • InputManager — это главный диспетчер. Он стоит между детьми и роботом и наводит порядок: принимает сигналы от детей, выстраивает их в очередь и решает, когда какую команду передать дальше. Он не дает детям напрямую лезть к роботу.

  • InputConnection — это официальная инструкция и пульт управления. Не просто шаблон, а единственный способ общаться с роботом. Диспетчер (InputManager) передает детям специальный пульт, на котором всего несколько кнопок: «добавить букву», «стереть последнее слово», «предложить исправление».

  • Робот понимает только команды с этого пульта. Даже если ребенок сто раз крикнет «Нарисуй котика!», робот не отреагирует, пока не будет нажата правильная кнопка commitText(«котик»). В результате дети (источники ввода) активно генерируют идеи, но до робота (редактора) они доходят только через диспетчера (InputManager) и только в виде строгих команд с официального пульта (InputConnection). Это защищает робота от хаоса и позволяет ему работать четко и предсказуемо. 

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

Google рекомендует использовать свою реализацию InputConnection (BaseInputConnection). Признаюсь честно: я очень хотел сделать ребятам приятно и следовать их рекомендациям, но не стал. Для реализации списков мы должны были модифицировать текст на лету, самостоятельно вставляя цифры и другие символы при работе со списками. BaseInputConnection такого не терпит, потому что ему самому нравится управлять текстом. У BaseInputConnection есть свой замечательный Editable-объект, работу с которым он прячет у себя в недрах. Но, как сказал классик, «ты не ты, когда не ты». Поэтому мы сделали свою реализацию InputConnection.

Вот максимально упрощенная схема того, как устроена обработка входных данных:

Танцы с бубном, душевные терзания и комплекс супергероя: как мы новый редактор в «Заметках» разрабатывали - 6

Мы выделили несколько ключевых классов:

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

  • InputHandler и TextDataHandler отвечают за бизнес-логику. InputHandler работает с входными данными и обновляет диапазоны стилей и параграфов. Он напрямую связан с InputConnection, так что в его задачи также входит контроль за составным текстом (composing text). Когда работа с текстом завершена, InputHandler извещает редактор и возвращает обновленную модель. Редактор передает эту модель дальше в TextDataHandler, где происходит измерение текста и расчет координат для строк. Эти данные заносятся в поле TextData.linesData и возвращаются обратно редактору.

  • Реализация интерфейса InputConnection. Этот класс — главный поставщик данных для InputHandler. Сюда приходят обновления от InputManager. Если пользователь поменял положение курсора или выделил текст, редактор сообщает об этом тоже через InputConnection.

  • Editor — вьюшка, которая рисует на холсте подготовленное содержимое заметки.

Стоит чуть подробнее рассказать про интерфейс InputConnection и составной текст. Нам пришлось хорошенько попотеть, прежде чем он начал приносить невероятную пользу.

Composing text — это диапазон текста, который в редакторе обычно помечается как подчеркнутый. Если выбрать подсказку в клавиатуре, этот текст будет полностью заменен выбранной подсказкой:

Танцы с бубном, душевные терзания и комплекс супергероя: как мы новый редактор в «Заметках» разрабатывали - 7

Приведу список основных методов интерфейса InputConnection:

  • commitText — заменяет составной текст новым переданным текстом. Длина композиционного текста становится равной 0.

  • setComposingText — заменяет составной текст новым переданным текстом, но длина составного текста при этом становится равной длине переданного текста.

  • setComposingRegion — задает составной текст. Обычно вызывается, когда пользователь поставил курсор в другое место в тексте. Часто перед этим вызываются методы getTextBeforeCursor / getTextAfterCursor или getExtractedText.

  • finishComposingText — выставляет длину составного текста в 0.

  • deleteSurroundingText — удаляет определенное количество символов до и после курсора.

  • beginBatchEdit / endBatchEdit — используются для контроля обработки входных данных. Следует избегать оповещения IME или отображения новых входных данных, пока количество вызовов beginBatchEdit не равно количеству вызовов endBatchEdit.

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

База данных на очереди

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

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

К выводам

Теперь вы знаете, как рождался редактор в «Заметках» и каких душевных терзаний это нам стоило. 

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

В итоге мы потратили почти год на танцы с бубном, прежде чем пришли к правильному решению — написать свой редактор с нуля на базе чистого View и дать ему InputConnection. В процессе этих танцев получили много тайных знаний о том, как устроен ввод текста в Android. Это было полезно для нас как для разработчиков, но грустно для самого продукта, ведь он на несколько месяцев застрял в развитии. А еще мы разработали собственную библиотеку для поддержки стилей в полях ввода Compose, которая теперь нигде не используется. 

Так что мой вам совет: заранее изучите будущий продукт так хорошо, как только возможно (и даже больше). Проясните мельчайшие детали и продумайте возможные направления развития. Тогда вероятность выбрать правильную архитектуру и нужные технологии будет стремиться к 100%. А вредные советы о том, как испортить ПО еще до начала разработки, можно почитать у моей коллеги тут.

Автор: toherishe

Источник

Оставить комментарий