[Из песочницы] Редактор кода на Android: часть 1

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 вызовется дважды:

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, это инструмент позволяющий искать совпадения текста в строке. Рекомендую как минимум ознакомится с его базовыми возможностями, потому что рано или поздно любому программисту может понадобится «вытащить» какой либо кусочек информации из текста.

Сейчас нам важно знать только две вещи:

  1. Pattern определяет что конкретно нам нужно найти в тексте
  2. 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.

Итак, запускаем!


ok8lahswjxkfp1co-lmh4_gr1xi.jpegРезультат соответствует ожиданиям ровно до того момента, пока мы не начнём редактировать большой файл (на скриншоте файл в ~1000 строк)

Дело в том что метод setSpan работает медленно, сильно нагружая UI Thread, а учитывая что метод afterTextChanged вызывается после каждого введенного символа, писать код становится одним мучением.

Поиск решения


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

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

Точно! Так и сделаем! Вот только… как?

Оптимизация


Хоть я и упомянул что нас заботит только производительность метода setSpan, всё же рекомендую выносить работу RegEx в фоновой поток чтобы добиться максимальной плавности.

Нам нужен класс, который будет в фоне обрабатывать весь текст и возвращать список спанов.
Конкретной реализации приводить не буду, но если кому интересно то я использую AsyncTask работающий на ThreadPoolExecutor. (Да-да, AsyncTask в 2020)

Нам главное, чтобы выполнялась такая логика:

  1. В beforeTextChanged останавливаем Task который парсит текст
  2. В afterTextChanged запускаем Task который парсит текст
  3. По окончанию своей работы, 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 = 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(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
    )
}


Смотрим на результат


k7jb80b1ecbhvyvfzihzwa3ixkq.jpeg

Выглядит круто.

Что же мы сделали в методе onDraw? Перед вызовом super-метода мы обновили отступ, после чего отрисовали номера только в видимой области, ну и под конец провели вертикальную линию, визуально отделяющую нумерацию строк от редактора кода.

Для красоты можно ещё перекрасить отступ в другой цвет, визуально выделить строку на которой находится курсор, но это я уже оставлю на ваше усмотрение.

Заключение


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

Также оставлю ссылку на исходники моего редактора кода на GitHub, там вы найдёте не только те фичи о которых я рассказал в этой статье, но и много других которые остались без внимания.

Задавайте вопросы и предлагайте темы для обсуждения, ведь я вполне мог что-то упустить.

Спасибо!

© Habrahabr.ru