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

Задача простая, но это не точно
Начиналось все так. Нам предстояло написать «Заметки» с нуля, задав при этом некий архитектурный шаблон для других приложений 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. Он рисует разбитый текст на холсте, применяя нужные стили по заданным диапазонам. В итоге мы получаем красивую разноцветную надпись:

Работа кипела, таски закрывались с первой космической скоостью, кофе тек рекой, а начальство нами гордилось. А потом… потом случился первый затык.
Первое испытание — изображения
Когда мы приступили к вставке изображений в заметку, жизнь перестала казаться такой уж красочной. BasicTextField упорно отказывался принимать объекты, у которых можно было менять размер. Поэтому мы приняли ответственное решение взять за основу LazyColumn.
Каждый раз, когда пользователь хотел вставить картинку, мы добавляли Image ниже нашего поля ввода и следом — еще одно поле. Визуально все выглядело симпатично. В базе данных заметка была представлена в виде набора полей типов Picture и Text.
Второе испытание — стили
Когда настало время добавлять поддержку стилей, мне приснился сон: я пришел в лес за грибами, а вокруг одни поганки. Вроде бы ничего страшного, но осадочек остался.
Первые неприятности возникли, когда я попытался использовать AnnotatedString в Compose для реализации стилей. Оказалось, что Google по какой-то неведомой причине решил, что стили в BasicTextField — лишние. То есть на момент создания этого текста он просто их не поддерживал — ну, или почти не поддерживал.
Если выставить красиво отформатированный текст через TextFieldValue, он прекрасно отобразится. Но стоит пользователю ввести хотя бы один символ, все стили волшебным образом испаряются.

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

Как и в случае со стилями, поле ввода отказывалось запоминать, где и какие параграфы должны отображаться. Немного исследовав эту тему, я пришел к выводу, что нам придется серьезно потрудиться. Чтобы отобразить текст элемента списка, недостаточно просто добавить ParagraphStyle. Нужно было вставлять дополнительные символы в виде табов, точек и цифр и в зависимости от этого смещать, расширять или удалять уже добавленные стили. Ну и нельзя забывать, что пользователь может изменить нумерацию списка.
Утром я посмотрел гороскоп и обнаружил, что Марс в пятом доме. Решил, что это знак, чтобы наведаться с интересным предложением к начальству. Накануне, бороздя просторы интернета, мы с коллегами наткнулись на парочку библиотек с нужной функциональностью. Лицензия у них была подходящая, и их можно было использовать бесплатно. То есть интегрировать одну из них было гораздо проще и быстрее, чем писать свою.
Менеджмент справедливо приравнял слово «быстрее» к слову «дешевле», да еще и библиотека бесплатная. Поэтому мой вариант был незамедлительно принят, и я приступил к интеграции.
Все хорошо, но надо переделать
К несчастью, бесплатная и по всем параметрам подходящая нам библиотека отказывалась отдавать наружу TextFieldValue с AnnotatedString. Мы не могли получить диапазоны стилей и параграфов и как-то влиять на них. Зато она умела превращать текст со стилями в HTML-строку и обратно.
К тому моменту мы уже знали, что такое же приложение планируется для веба, и нам нужно будет синхронизироваться с бэкендом. Поэтому мы сохраняли текстовое поле заметки в виде HTML в базе данных, и все было прекрасно… пока не пришел срок реализовать To-Do-списки. Но библиотека делать этого не хотела и не умела. Пришлось обратиться к шаману. Он постучал в бубен, вызвал дождь и уехал, а мы остались думать, как быть дальше.
Повздыхав, решили, что пришло время возвращаться к старым наработкам со стилями. Внимательно посмотрели на AnnotatedString и обнаружили там есть inlineContent, в который можно поместить иконку. Эти иконки должны были играть роль чекбоксов.
Дальше у меня состоялся разговор с собой и нет, я не шизофреник, у меня даже справка есть:
— А где есть AnnotatedString?
— Правильно, в TextFieldValue!
— А кто у нас работает с TextFieldValue?
— BasicTextField!
Все-таки не зря мы с него начинали. Кот сидел рядом и довольно жмурился.
Обработка стилей в нашей старой либе уже была. Осталось добавить параграфы, они же списки. Решили начать с маркированных и нумерованных листов, а потом вплотную взяться за To-Do.
Помня, что утро вечера мудренее, я всю ночь не спал и ждал рассвета. Воспаленный мозг яростно рисовал в голове диаграммы зависимостей, data-классы и котиков, которые все это запутывали. Очнувшись утром, я открыл ноутбук, и работа закипела с новой силой.
Внедрение параграфов в целом было похоже на то, что мы делали со стилями. В зависимости от положения курсора и изменений текста обновляли диапазоны AnnotatedString.Range — смещали или расширяли их. Различие было только в том, что при добавлении нового параграфа мы добавляли к существующему тексту префиксы наших списков — цифры, табы и точки.
Наш любимый Compose и тут умудрился устроить подлянку. Оказалось, что при добавлении ParagraphStyle в AnnotatedString съедался последний символ этого параграфа. Видимо, он использовался как идентификатор переноса строки где-то под капотом. Или это просто баг, который никто не находил, потому что эта функциональность официально не поддерживалась. Мы послали письмо на Альфу Центавра с вопросом, но нам никто не ответил, а самим разбираться в причинах было некогда.

В качестве воркэраунда мы добавляли к концу параграфа специальный символ в виде дефиса — это так называемый hyphen.
Дополнительно пришлось повозиться с префиксами для нумерованных списков. Изменение номера в середине такого списка вело к нарушению последовательности в строках. Из-за этого затронутые параграфы и стили начинали «плыть» и применялись не к тому тексту, к которому нужно. Или вообще выходили за рамки существующего текста. Приложению это не нравилось: когда такое случалось, оно с готовностью падало.
И вот, наконец, нумерованные и маркированные списки были реализованы, протестированы и выпущены в релиз. Остались To-Do.
Четвертое испытание — снова списки, на этот раз To-Do
По традиции тут тоже сначала казалось, что все максимально просто. Нужно было добавить InlineContent для иконки чекбокса, поместить этот контент в TextField, повесить на иконку клик-листенер и менять иконку и цвет текста параграфа. Не так много, если бы не новый сюрприз от Compose.
Оказалось, что поля ввода не умеют работать с InlineContent. Я купил билеты в Гималаи, взял капельницу и улетел очищаться. По крайней мере, так я рассказывал друзьям, а на самом деле просто рыдал в подушку ночи напролет.
На этот раз без символичного сна тоже не обошлось: мне приснился Менделеев и другие внушительные люди, они молчали и строго смотрели на меня. Что ж, пришлось снова взять себя в руки.
Итак, что у нас было. Текущий вариант уперся в границы своих возможностей, нужно было искать другое решение. А чтобы непреодолимых сложностей в будущем не возникало, мы должны были четко понимать, что нам еще предстоит разработать, и подобрать подходящие технологии.
Я собрал команду на встречу, и мы принялись расспрашивать продактов, какие функции еще планируются. Кто же знал, что мы открыли ящик Пандоры… Все-таки приятно, когда у тебя во главе стоят люди с прекрасным воображением и далеко идущими планами. От восторга я радостно и увлеченно моргал. Но вместе с новым знанием росло понимание, что и как нам нужно делать.
Чтобы реализовать To-Do-списки и запланированные фичи, нужно писать свой редактор с нуля и рисовать все на Canvas. Тут было два варианта:
Наследоваться от
Viewи переопределитьonDraw. Потом встроить этот класс черезAndroidViewBindingв наш Composable-экран.Использовать
Canvas.
Я накидал простенькие прототипы для обоих вариантов. Изучив их с командой, мы выбрали первый. Преимущества у него были такие: изменения во встроенной «вьюшке» никак не влияли на рекомпозицию всего экрана. Работа с традиционным View не вызывала вопросов и не сулила неприятных сюрпризов. Главное — не забывать про один важный нюанс: метод onDraw вызывается по малейшей необходимости, будь то скроллинг или мигание курсора.
Коллаб редактора и клавиатуры
Прежде чем расписывать наш успешный успех, кратко поясню, как устроен процесс взаимодействия клавиатуры и редактора.
Можно провести такую аналогию. Представьте, что редактор (EditText) — это большой и сложный робот, а его задача — аккуратно составлять текст из полученных букв. Источники ввода (IME) — это толпа увлеченных детей (клавиатура, голосовой ввод и другие), которые хотят управлять роботом и диктовать ему, что писать. Проблема в том, что все дети кричат одновременно, команды у всех разные, и, если позволить им напрямую дергать робота за рычаги, он может сломаться или сделать что-то не то.
Чтобы этого не случилось, работает система безопасности:
InputManager— это главный диспетчер. Он стоит между детьми и роботом и наводит порядок: принимает сигналы от детей, выстраивает их в очередь и решает, когда какую команду передать дальше. Он не дает детям напрямую лезть к роботу.InputConnection— это официальная инструкция и пульт управления. Не просто шаблон, а единственный способ общаться с роботом. Диспетчер (InputManager) передает детям специальный пульт, на котором всего несколько кнопок: «добавить букву», «стереть последнее слово», «предложить исправление».Робот понимает только команды с этого пульта. Даже если ребенок сто раз крикнет «Нарисуй котика!», робот не отреагирует, пока не будет нажата правильная кнопка
commitText(«котик»). В результате дети (источники ввода) активно генерируют идеи, но до робота (редактора) они доходят только через диспетчера (InputManager) и только в виде строгих команд с официального пульта (InputConnection). Это защищает робота от хаоса и позволяет ему работать четко и предсказуемо.
Примерно так выглядит общий принцип работы с входными данными на Android. В нашем случае нам нужно было реализовать именно вывод. Как мы это сделали, расскажу дальше.
Google рекомендует использовать свою реализацию InputConnection (BaseInputConnection). Признаюсь честно: я очень хотел сделать ребятам приятно и следовать их рекомендациям, но не стал. Для реализации списков мы должны были модифицировать текст на лету, самостоятельно вставляя цифры и другие символы при работе со списками. BaseInputConnection такого не терпит, потому что ему самому нравится управлять текстом. У BaseInputConnection есть свой замечательный Editable-объект, работу с которым он прячет у себя в недрах. Но, как сказал классик, «ты не ты, когда не ты». Поэтому мы сделали свою реализацию InputConnection.
Вот максимально упрощенная схема того, как устроена обработка входных данных:

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

Приведу список основных методов интерфейса 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%. А вредные советы о том, как испортить ПО еще до начала разработки, можно почитать у моей коллеги тут.