![](https://habrastorage.org/webt/uy/l0/tz/uyl0tzlpi27fvvp7gkvaetykkww.png)
Перед тем как закончить работу над своим редактором кода я много раз наступал на грабли, наверное декомпилировал десятки похожих приложений, и в данной серии статей я расскажу о том чему научился, каких ошибок можно избежать и много других интересных вещей.
Вступление
Привет всем! Судя из названия вполне понятно о чем будет идти речь, но всё же я должен вставить свои пару слов перед тем как перейти к коду.
Я решил разделить статью на 2 части, в первой мы поэтапно напишем оптимизированную подсветку синтаксиса и нумерацию строк, а во второй добавим автодополнение кода и подсветку ошибок.
Для начала составим список того, что наш редактор должен уметь:
- Подсвечивать синтаксис
- Отображать нумерацию строк
- Показывать варианты автодополнения (расскажу во второй части)
- Подсвечивать синтаксические ошибки (расскажу во второй части)
Это далеко не весь список того, какими свойствами должен обладать современный редактор кода, но именно об этом я хочу рассказать в этой небольшой серии статей.
MVP — простой текстовый редактор
На данном этапе проблем возникнуть не должно — растягиваем
EditText
на весь экран, указываем gravity
, прозрачный background
чтобы убрать полосу снизу, размер шрифта, цвет текста и т.д. Я люблю начинать с визуальной части, так мне становится проще понять чего не хватает в приложении, и над какими деталями ещё стоит поработать.На этом этапе я так же сделал загрузку/сохранение файлов в память. Код приводить не буду, в интернете переизбыток примеров работы с файлами.
Подсветка синтаксиса
Как только мы ознакомились с требованиями к редактору, пора переходить к самому интересному.
Очевидно, чтобы контролировать весь процесс — реагировать на ввод, отрисовывать номера строк, нам придется писать
CustomView
наследуясь от EditText
. Накидываем TextWatcher
чтобы слушать изменения в тексте и переопределяем метод afterTextChanged
, в котором и будем вызывать метод отвечающий за подсветку:class TextProcessor @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.editTextStyle
) : EditText(context, attrs, defStyleAttr) {
private val textWatcher = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
syntaxHighlight()
}
}
private fun syntaxHighlight() {
// Тут будем подсвечивать текст
}
}
Вопрос: Почему мы используем
TextWatcher
как переменную, ведь можно реализовать интерфейс прямо в классе?Ответ: Так уж получилось, что у
TextWatcher
есть метод который конфликтует c уже существующим методом у TextView
:// Метод TextWatcher
fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int)
// Метод TextView
fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int)
Оба этих метода имеют одинаковое название и одинаковые аргументы, да и смысл вроде у них тот же, но проблема в том что метод
onTextChanged
у TextView
вызовется вместе с onTextChanged
у TextWatcher
. Если проставить логи в тело метода, то увидим что onTextChanged
вызовется дважды:![](https://habrastorage.org/webt/tg/px/px/tgpxpxtz_820e_dtyojkly27ukc.png)
Это очень критично если мы планируем добавлять функционал Undo/Redo. Также нам может понадобится момент, в котором не будут работать слушатели, в котором мы сможем очищать стэк с изменениями текста. Мы ведь не хотим, чтобы после открытия нового файла можно было нажать Undo и получить совершенно другой текст. Хоть об Undo/Redo в этой статье говориться не будет, важно учитывать этот момент.
Соответственно, чтобы избежать такой ситуации можно использовать свой метод установки текста вместо стандартного
setText
:fun processText(newText: String) {
removeTextChangedListener(textWatcher)
// undoStack.clear()
// redoStack.clear()
setText(newText)
addTextChangedListener(textWatcher)
}
Но вернёмся к подсветке.
Во многих языках программирования есть такая замечательная штука как RegEx, это инструмент позволяющий искать совпадения текста в строке. Рекомендую как минимум ознакомится с его базовыми возможностями, потому что рано или поздно любому программисту может понадобится «вытащить» какой либо кусочек информации из текста.
Сейчас нам важно знать только две вещи:
- Pattern определяет что конкретно нам нужно найти в тексте
- Matcher будет пробегать по всему тексту в попытках найти то, что мы указали в Pattern
Может не совсем корректно описал, но принцип работы такой.
Т.к я пишу редактор для JavaScript, вот небольшой паттерн с ключевыми словами языка:
private val KEYWORDS = Pattern.compile(
"\\b(function|var|this|if|else|break|case|try|catch|while|return|switch)\\b"
)
Конечно, слов тут должно быть гораздо больше, а ещё нужны паттерны для комментариев, строк, чисел и т.д. но моя задача заключается в демонстрации принципа, по которому можно найти нужный контент в тексте.
Далее с помощью Matcher мы пройдёмся по всему тексту и установим спаны:
private fun syntaxHighlight() {
val matcher = KEYWORDS.matcher(text)
matcher.region(0, text.length)
while (matcher.find()) {
text.setSpan(
ForegroundColorSpan(Color.parseColor("#7F0055")),
matcher.start(),
matcher.end(),
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
Поясню: мы получаем объект Matcher у Pattern, и указываем ему область для поиска в символах (Соответственно с 0 по
text.length
это весь текст). Далее вызов matcher.find()
вернёт true
если в тексте было найдено совпадение, а с помощью вызовов matcher.start()
и matcher.end()
мы получим позиции начала и конца совпадения в тексте. Зная эти данные, мы можем использовать метод setSpan
для раскраски определённых участков текста.Существует много видов спанов, но для перекраски текста обычно используется
ForegroundColorSpan
.Итак, запускаем!
![](https://habrastorage.org/webt/ok/8l/ah/ok8lahswjxkfp1co-lmh4_gr1xi.jpeg)
Дело в том что метод
setSpan
работает медленно, сильно нагружая UI Thread, а учитывая что метод afterTextChanged
вызывается после каждого введенного символа, писать код становится одним мучением.Поиск решения
Первое что приходит в голову — вынести тяжелую операцию в фоновый поток. Но тяжелая операция тут это
setSpan
по всему тексту, а не регулярки. (Думаю, можно не объяснять почему нельзя вызывать setSpan
из фонового потока).Немного поискав тематических статей узнаем, что если мы хотим добиться плавности, придётся подсвечивать только видимую часть текста.
Точно! Так и сделаем! Вот только… как?
Оптимизация
Хоть я и упомянул что нас заботит только производительность метода
setSpan
, всё же рекомендую выносить работу RegEx в фоновой поток чтобы добиться максимальной плавности.Нам нужен класс, который будет в фоне обрабатывать весь текст и возвращать список спанов.
Конкретной реализации приводить не буду, но если кому интересно то я использую
AsyncTask
работающий на ThreadPoolExecutor
. (Да-да, AsyncTask в 2020)Нам главное, чтобы выполнялась такая логика:
- В
beforeTextChanged
останавливаем Task который парсит текст - В
afterTextChanged
запускаем Task который парсит текст - По окончанию своей работы, Task должен вернуть список спанов в
TextProcessor
, который в свою очередь подсветит только видимую часть
И да, спаны тоже будем писать свои собственные:
data class SyntaxHighlightSpan(
private val color: Int,
val start: Int,
val end: Int
) : CharacterStyle() {
// можно заморочиться и добавить italic, например, только для комментариев
override fun updateDrawState(textPaint: TextPaint?) {
textPaint?.color = color
}
}
Таким образом, код редактора превращается в нечто подобное:
class TextProcessor @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.editTextStyle
) : EditText(context, attrs, defStyleAttr) {
private val textWatcher = object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
cancelSyntaxHighlighting()
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
syntaxHighlight()
}
}
private var syntaxHighlightSpans: List<SyntaxHighlightSpan> = emptyList()
private var javaScriptStyler: JavaScriptStyler? = null
fun processText(newText: String) {
removeTextChangedListener(textWatcher)
// undoStack.clear()
// redoStack.clear()
setText(newText)
addTextChangedListener(textWatcher)
// syntaxHighlight()
}
private fun syntaxHighlight() {
javaScriptStyler = JavaScriptStyler()
javaScriptStyler?.setSpansCallback { spans ->
syntaxHighlightSpans = spans
updateSyntaxHighlighting()
}
javaScriptStyler?.runTask(text.toString())
}
private fun cancelSyntaxHighlighting() {
javaScriptStyler?.cancelTask()
}
private fun updateSyntaxHighlighting() {
// подсветка видимой части будет тут
}
}
Т.к конкретной реализации обработки в фоне я не показал, представим что мы написали некий
JavaScriptStyler
, который в фоне будет делать всё тоже самое что мы делали до этого в UI Thread — пробегать по всему тексту в поисках совпадений и заполнять список спанов, а в конце своей работы вернёт результат в setSpansCallback
. В этот момент запустится метод updateSyntaxHighlighting
, который пройдётся по списку спанов и отобразит только те, что видны в данный момент на экране.Как понять, какой текст попадает в видимую область?
Буду ссылаться на эту статью, там автор предлагает использовать примерно такой способ:
val topVisibleLine = scrollY / lineHeight
val bottomVisibleLine = topVisibleLine + height / lineHeight + 1 // height - высота View
val lineStart = layout.getLineStart(topVisibleLine)
val lineEnd = layout.getLineEnd(bottomVisibleLine)
И он работает! Теперь вынесем
topVisibleLine
и bottomVisibleLine
в отдельные методы и добавим пару дополнительных проверок, на случай если что-то пойдёт не так:private fun getTopVisibleLine(): Int {
if (lineHeight == 0) {
return 0
}
val line = scrollY / lineHeight
if (line < 0) {
return 0
}
return if (line >= lineCount) {
lineCount - 1
} else line
}
private fun getBottomVisibleLine(): Int {
if (lineHeight == 0) {
return 0
}
val line = getTopVisibleLine() + height / lineHeight + 1
if (line < 0) {
return 0
}
return if (line >= lineCount) {
lineCount - 1
} else line
}
Последнее что остаётся сделать — пройтись по полученному списку спанов и раскрасить текст:
for (span in syntaxHighlightSpans) {
val isInText = span.start >= 0 && span.end <= text.length
val isValid = span.start <= span.end
val isVisible = span.start in lineStart..lineEnd
|| span.start <= lineEnd && span.end >= lineStart
if (isInText && isValid && isVisible)) {
text.setSpan(
span,
if (span.start < lineStart) lineStart else span.start,
if (span.end > lineEnd) lineEnd else span.end,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
}
}
Не пугайтесь страшного
if
'а, он всего лишь проверяет попадает ли спан из списка в видимую область.Ну что, работает?
Работает, вот только при редактировании текста спаны не обновляются, исправить ситуацию можно очистив текст от всех спанов перед наложением новых:
// Примечание: метод getSpans из библиотеки core-ktx
val textSpans = text.getSpans<SyntaxHighlightSpan>(0, text.length)
for (span in textSpans) {
text.removeSpan(span)
}
Ещё один косяк — после закрытия клавиатуры кусок текста остаётся неподсвеченным, исправляем:
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
updateSyntaxHighlighting()
}
Главное не забыть указать
adjustResize
в манифесте.Скроллинг
Говоря про скроллинг снова буду ссылаться на эту статью. Автор предлагает ждать 500 мс после окончания скроллинга, что противоречит моему чувству прекрасного. Я не хочу дожидаться пока прогрузится подсветка, я хочу видеть результат моментально.
Так же автор приводит аргумент что запускать парсер после каждого «проскроленного» пикселя затратно, и я полностью с этим согласен (вообще рекомендую полностью ознакомится с его статьей, она небольшая, но там много интересного). Но дело в том, что у нас уже есть готовый список спанов, и нам не нужно запускать парсер.
Достаточно вызывать метод отвечающий за обновление подсветки:
override fun onScrollChanged(horiz: Int, vert: Int, oldHoriz: Int, oldVert: Int) {
super.onScrollChanged(horiz, vert, oldHoriz, oldVert)
updateSyntaxHighlighting()
}
Нумерация строк
Если мы добавим в разметку ещё один
TextView
то будет проблематично их между собой связать (например, синхронно обновлять размер текста), да и если у нас большой файл то придется полностью обновлять текст с номерами после каждой введенной буквы, что не очень круто. Поэтому будем использовать стандартные средства любой CustomView
— рисование на Canvas
в onDraw
, это и быстро, и не сложно.Для начала определим что будем рисовать:
- Номера строк
- Вертикальную линию, отделяющую поле ввода от номеров строк
Предварительно необходимо вычислить и установить
padding
слева от редактора, чтобы не было конфликтов с напечатанным текстом.Для этого напишем функцию, которая будет обновлять отступ перед отрисовкой:
private var gutterWidth = 0
private var gutterDigitCount = 0
private var gutterMargin = 4.dpToPx() // отступ от разделителя в пикселях
...
private fun updateGutter() {
var count = 3
var widestNumber = 0
var widestWidth = 0f
gutterDigitCount = lineCount.toString().length
for (i in 0..9) {
val width = paint.measureText(i.toString())
if (width > widestWidth) {
widestNumber = i
widestWidth = width
}
}
if (gutterDigitCount >= count) {
count = gutterDigitCount
}
val builder = StringBuilder()
for (i in 0 until count) {
builder.append(widestNumber.toString())
}
gutterWidth = paint.measureText(builder.toString()).toInt()
gutterWidth += gutterMargin
if (paddingLeft != gutterWidth + gutterMargin) {
setPadding(gutterWidth + gutterMargin, gutterMargin, paddingRight, 0)
}
}
Пояснение:
Для начала мы узнаем кол-во строк в
EditText
(не путать с кол-вом "\n
" в тексте), и берем кол-во символов от этого числа. Например, если у нас 100 строк, то переменная gutterDigitCount
будет равна 3, потому что в числе 100 ровно 3 символа. Но допустим, у нас всего 1 строка — а значит отступ в 1 символ будет визуально казаться маленьким, и для этого мы используем переменную count, чтобы задать минимально отображаемый отступ в 3 символа, даже если у нас меньше 100 строк кода.Эта часть была самая запутанная из всех, но если вдумчиво прочитать несколько раз (поглядывая на код), то всё станет понятно.
Далее устанавливаем отступ предварительно вычислив
widestNumber
и widestWidth
.Приступим к рисованию
К сожалению, если мы хотим использовать стандартный андройдовский перенос текста на новую строку то придется поколдовать, что займет у нас много времени и ещё больше кода, которого хватит на целую статью, поэтому дабы сократить ваше время (и время модератора хабра), мы включим горизонтальный скроллинг, чтобы все строки шли одна за другой:
setHorizontallyScrolling(true)
Ну а теперь можно приступать к рисованию, объявим переменные с типом
Paint
:private val gutterTextPaint = Paint() // Нумерация строк
private val gutterDividerPaint = Paint() // Отделяющая линия
Где-нибудь в
init
блоке установим цвет текста и цвет разделителя. Важно помнить, что если вы поменяйте шрифт текста, то шрифт Paint
'а придется применять вручную, для этого советую переопределить метод setTypeface
. Аналогично и с размером текста.После чего переопределяем метод
onDraw
:override fun onDraw(canvas: Canvas?) {
updateGutter()
super.onDraw(canvas)
var topVisibleLine = getTopVisibleLine()
val bottomVisibleLine = getBottomVisibleLine()
val textRight = (gutterWidth - gutterMargin / 2) + scrollX
while (topVisibleLine <= bottomVisibleLine) {
canvas?.drawText(
(topVisibleLine + 1).toString(),
textRight.toFloat(),
(layout.getLineBaseline(topVisibleLine) + paddingTop).toFloat(),
gutterTextPaint
)
topVisibleLine++
}
canvas?.drawLine(
(gutterWidth + scrollX).toFloat(),
scrollY.toFloat(),
(gutterWidth + scrollX).toFloat(),
(scrollY + height).toFloat(),
gutterDividerPaint
)
}
Смотрим на результат
![](https://habrastorage.org/webt/k7/jb/80/k7jb80b1ecbhvyvfzihzwa3ixkq.jpeg)
Что же мы сделали в
onDraw
? Перед вызовом super
-метода мы обновили отступ, после чего отрисовали номера только в видимой области, ну и под конец провели вертикальную линию, визуально отделяющую нумерацию строк от редактора кода.Для красоты можно ещё перекрасить отступ в другой цвет, визуально выделить строку на которой находится курсор, но это я уже оставлю на ваше усмотрение.
Заключение
В этой статье мы написали отзывчивый редактор кода с подсветкой синтаксиса и нумерацией строк, а в следующей части добавим удобное автодополнение кода и подсветку синтаксических ошибок прямо во время редактирования.
Также оставлю ссылку на исходники моего редактора кода на GitHub, там вы найдёте не только те фичи о которых я рассказал в этой статье, но и много других которые остались без внимания.
Задавайте вопросы и предлагайте темы для обсуждения, ведь я вполне мог что-то упустить.
Спасибо!
Jogger
Ага, конечно. Именно поэтому найти просто текстовый редактор, пользоваться которым не будет мучительно больно — это такая проблема.
Tihon_V
Будучи студентом, мне было достаточно DroidEdit (хороший редактор) и C4droid. Можно там подсмотреть решения :)
VIM — легкий и работает везде, требует клавиатуру (у меня на asus tf101 — она была).