[Разработка под Android] Редактор кода на Android: часть 2

Автор Сообщение
news_bot ®

Стаж: 6 лет 9 месяцев
Сообщений: 27286

Создавать темы news_bot ® написал(а)
13-Июл-2020 22:30

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

Для начала давайте вспомним на чем мы остановились в прошлой части. Мы написали оптимизированную подсветку синтаксиса, которая парсит текст в фоне и раскрашивает только его видимую часть, а так же добавили нумерацию строк (хоть и без андройдовских переносов на новую строку, но всё же).
В этой части мы добавим автодополнение кода и подсветку ошибок.
Автодополнение кода
Для начала представим как это должно работать:
  • Пользователь пишет слово
  • После ввода N первых символов появляется окошко с подсказками
  • При нажатии на подсказку слово автоматически «допечатывается»
  • Окошко с подсказками закрывается, и курсор переносится в конец слова
  • Если пользователь ввел слово отображаемое в подсказке сам, то окошко с подсказками должно автоматически закрыться

Ничего не напоминает? В андройде уже есть компонент с точно такой же логикой — MultiAutoCompleteTextView, поэтому писать костыли с PopupWindow нам не придется (их уже написали за нас).
Первым шагом поменяем родителя у нашего класса:
class TextProcessor @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = R.attr.autoCompleteTextViewStyle
) : MultiAutoCompleteTextView(context, attrs, defStyleAttr)

Теперь нам нужно написать ArrayAdapter который будет отображать найденные результаты. Полного кода адаптера не будет, примеры реализации можно найти в интернете. Но на моменте с фильтрацией я всё таки остановлюсь.
Чтобы ArrayAdapter мог понимать какие подсказки нужно отобразить, нам нужно переопределить метод getFilter:
override fun getFilter(): Filter {
    return object : Filter() {
        private val suggestions = mutableListOf<String>()
        override fun performFiltering(constraint: CharSequence?): FilterResults {
            // ...
        }
        override fun publishResults(constraint: CharSequence?, results: FilterResults) {
            clear() // необходимо очистить старый список
            addAll(suggestions)
            notifyDataSetChanged()
        }
    }
}

И в методе performFiltering наполнить список suggestions из слов, основываясь на слове которое начал вводить пользователь (содержится в переменной constraint).
Откуда взять данные перед фильтрацией?
Тут всё зависит от вас — можно использовать какой-нибудь интерпретатор для подбора только валидных вариантов, либо сканировать весь текст при открытии файла. Для простоты примера я буду использовать уже готовый список вариантов автодополнения:
private val staticSuggestions = mutableListOf(
    "function",
    "return",
    "var",
    "const",
    "let",
    "null"
    ...
)
...
override fun performFiltering(constraint: CharSequence?): FilterResults {
    val filterResults = FilterResults()
    val input = constraint.toString()
    suggestions.clear() // очищаем старый список
    for (suggestion in staticSuggestions) {
        if (suggestion.startsWith(input, ignoreCase = true) &&
            !suggestion.equals(input, ignoreCase = true)) {
            suggestions.add(suggestion)
        }
    }
    filterResults.values = suggestions
    filterResults.count = suggestions.size
    return filterResults
}

Логика фильтрации тут довольно примитивная, проходимся по всему списку и игнорируя регистр сравниваем начало строки.
Установили адаптер, пишем текст — не работает. Что не так? По первой ссылке в гугле натыкаемся на ответ, в котором говорится что мы забыли установить Tokenizer.
Для чего нужен Tokenizer?
Говоря простым языком, Tokenizer помогает MultiAutoCompleteTextView понять, после какого введенного символа можно считать ввод слова завершенным. Также у него есть готовая реализация в виде CommaTokenizer с разделением слов на запятые, что в данном случае нам не подходит.
Что ж, раз CommaTokenizer нас не устраивает, тогда напишем свой:

Кастомный Tokenizer

SPL
class SymbolsTokenizer : MultiAutoCompleteTextView.Tokenizer {
    companion object {
        private const val TOKEN = "!@#$%^&*()_+-={}|[]:;'<>/<.? \r\n\t"
    }
    override fun findTokenStart(text: CharSequence, cursor: Int): Int {
        var i = cursor
        while (i > 0 && !TOKEN.contains(text[i - 1])) {
            i--
        }
        while (i < cursor && text[i] == ' ') {
            i++
        }
        return i
    }
    override fun findTokenEnd(text: CharSequence, cursor: Int): Int {
        var i = cursor
        while (i < text.length) {
            if (TOKEN.contains(text[i - 1])) {
                return i
            } else {
                i++
            }
        }
        return text.length
    }
    override fun terminateToken(text: CharSequence): CharSequence = text
}


Разбираемся:
TOKEN — строка с символами которые отделяют одно слово от другого. В методах findTokenStart и findTokenEnd мы проходимся по тексту в поисках этих самых отделяющих символов. Метод terminateToken позволяет вернуть измененный результат, но нам он не нужен, поэтому просто возвращаем текст без изменений.

Ещё я предпочитаю добавлять задержку на ввод в 2 символа перед отображением списка:
textProcessor.threshold = 2

Устанавливаем, запускаем, пишем текст — работает! Вот только почему-то окошко с подсказками странно себя ведет — отображается во всю ширину, высота у него маленькая, да и по идее оно ведь должно появляться под курсором, как будем фиксить?
Исправляем визуальные недостатки
Вот тут и начинается самое интересное, ведь API позволяет нам изменять не только размеры окна, но и его положение.
Для начала определимся с размерами. На мой взгляд, наиболее удобным вариантом будет окошко размером с половину от высоты и ширины экрана, но т.к размер нашей View изменяется в зависимости от состояния клавиатуры, подбирать размеры будем в методе onSizeChanged:
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    updateSyntaxHighlighting()
    dropDownWidth = w * 1 / 2
    dropDownHeight = h * 1 / 2
}


Выглядеть стало лучше, но не сильно. Мы хотим добиться чтобы окошко появлялось под курсором и перемещалось вместе с ним во время редактирования.
Если с перемещением по X всё довольно просто — берем координату начала буквы и устанавливаем это значение в dropDownHorizontalOffset, то с подбором высоты будет сложнее.
Гугля про свойства шрифтов можно наткнуться на вот такой пост. Картинка которую прикрепил автор наглядно показывает, какими свойствами мы можем воспользоваться для вычисления вертикальной координаты.
Судя по картинке, Baseline — это то что нам нужно. Именно на этом уровне и должно появляться окошко с вариантами автодополнения.
Теперь напишем функцию, которую будем вызывать при изменении текста в onTextChanged:
private fun onPopupChangePosition() {
    val line = layout.getLineForOffset(selectionStart) // строка с курсором
    val x = layout.getPrimaryHorizontal(selectionStart) // координата курсора
    val y = layout.getLineBaseline(line) // тот самый baseline
    val offsetHorizontal = x + gutterWidth // нумерация строк тоже часть отступа
    dropDownHorizontalOffset = offsetHorizontal.toInt()
    val offsetVertical = y - scrollY // -scrollY чтобы не "заезжать" за экран
    dropDownVerticalOffset = offsetVertical
}

Вроде ничего не забыли — смещение по X работает, но смещение по Y рассчитывается неправильно. Это потому что мы не указали dropDownAnchor в разметке:
android:dropDownAnchor="@id/toolbar"

Указав Toolbar в качестве dropDownAnchor мы даём виджету понять, что выпадающий список будет отображаться под ним.
Теперь если мы начнем редактировать текст то всё будет работать, но со временем мы заметим — если окошко не помещается под курсором, оно переносится вверх с огромным отступом, что выглядит некрасиво. Самое время написать костыль:
val offset = offsetVertical + dropDownHeight
if (offset < getVisibleHeight()) {
    dropDownVerticalOffset = offsetVertical
} else {
    dropDownVerticalOffset = offsetVertical - dropDownHeight
}
...
private fun getVisibleHeight(): Int {
    val rect = Rect()
    getWindowVisibleDisplayFrame(rect)
    return rect.bottom - rect.top
}

Нам не нужно изменять отступ если сумма offsetVertical + dropDownHeight меньше видимой высоты экрана, ведь в таком случае окошко помещается под курсором. Но если всё таки больше, то вычитаем из отступа dropDownHeight — так оно поместится над курсором без огромного отступа который добавляет сам виджет.
P.S На гифке можно заметить промаргивания клавиатуры, и честно говоря я не знаю как это исправить, поэтому если у вас есть решение — пишите.
Подсветка ошибок
С подсветкой ошибок всё гораздо проще чем кажется, т.к сами мы напрямую не можем определять синтаксические ошибки в коде — будем использовать стороннюю библиотеку-парсер. Т.к я пишу редактор для JavaScript, мой выбор пал на Rhino — популярный JavaScript-движок который проверен временем и всё ещё поддерживается.
Как парсить будем?
Запуск Rhino довольно тяжелая операция, поэтому запускать парсер после каждого введенного символа (как мы делали с подсветкой) вообще не вариант. Для решения этой проблемы я буду использовать библиотеку RxBinding, а для тех кто не хочет тащить в проект RxJava можно попробовать подобные варианты.
Оператор debounce поможет нам добиться желаемого, а если вы с ним не знакомы то советую почитать вот эту статью.
textProcessor.textChangeEvents()
    .skipInitialValue()
    .debounce(1500, TimeUnit.MILLISECONDS)
    .filter { it.text.isNotEmpty() }
    .distinctUntilChanged()
    .observeOn(AndroidSchedulers.mainThread())
    .subscribeBy {
        // Запуск парсера будет тут
    }
    .disposeOnFragmentDestroyView()

Теперь напишем модель которую нам будет возвращать парсер:
data class ParseResult(val exception: RhinoException?)

Предлагаю использовать такую логику: если ошибок не найдено, то exception будет null. В противном случае мы получим объект RhinoException который содержит в себе всю необходимую информацию — номер строки, сообщение об ошибке, StackTrace и т.д.
Ну и собственно, сам парсинг:
// Это должно выполняться в фоне !
val context = Context.enter() // org.mozilla.javascript.Context
context.optimizationLevel = -1
context.maximumInterpreterStackDepth = 1
try {
    val scope = context.initStandardObjects()
    context.evaluateString(scope, sourceCode, fileName, 1, null)
    return ParseResult(null)
} catch (e: RhinoException) {
    return ParseResult(e)
} finally {
    Context.exit()
}

Разбираемся:
Самое главное тут это метод evaluateString — он позволяет запустить код, который мы передали в качестве строки sourceCode. В fileName указывается имя файла — оно будет отображаться в ошибках, единица — номер строки для начала отсчета, последний аргумент это security domain, но он нам не нужен, поэтому ставим null.
optimizationLevel и maximumInterpreterStackDepth
Параметр optimizationLevel со значением от 1 до 9 позволяет включить определенные «оптимизации» кода (data flow analysis, type flow analysis и т.д), что превратит простую проверку синтаксических ошибок в очень длительную операцию, а нам это не к чему.
Если же использовать его со значением 0, то все эти «оптимизации» применяться не будут, однако, если я правильно понял, Rhino по-прежнему будет использовать часть ресурсов не нужных для простой проверки ошибок, а значит нам это не подходит.
Остаётся только отрицательное значение — указав -1 мы активируем режим «интерпретатора», а это именно то что нам нужно. В документации сказано что это самый быстрый и экономичный вариант работы Rhino.
Параметр maximumInterpreterStackDepth позволяет ограничить количество рекурсивных вызовов.
Представим что будет если не указать этот параметр:
  • Пользователь напишет следующий код:
    function recurse() {
        recurse();
    }
    recurse();
  • Rhino запустит код, и через секунду наше приложение вылетит с OutOfMemoryError. Конец.

Отображение ошибок
Как я говорил ранее, как только мы получим ParseResult содержащий RhinoException, у нас появится весь необходимый набор данных для отображения, в том числе и номер строки — нужно лишь вызвать метод lineNumber().
Теперь напишем спан с красной волнистой линией, который я скопировал на StackOverflow. Кода много, но логика простая — рисуем две короткие красные линии под разным углом.

ErrorSpan.kt

SPL
class ErrorSpan(
    private val lineWidth: Float = 1 * Resources.getSystem().displayMetrics.density + 0.5f,
    private val waveSize: Float = 3 * Resources.getSystem().displayMetrics.density + 0.5f,
    private val color: Int = Color.RED
) : LineBackgroundSpan {
    override fun drawBackground(
        canvas: Canvas,
        paint: Paint,
        left: Int,
        right: Int,
        top: Int,
        baseline: Int,
        bottom: Int,
        text: CharSequence,
        start: Int,
        end: Int,
        lineNumber: Int
    ) {
        val width = paint.measureText(text, start, end)
        val linePaint = Paint(paint)
        linePaint.color = color
        linePaint.strokeWidth = lineWidth
        val doubleWaveSize = waveSize * 2
        var i = left.toFloat()
        while (i < left + width) {
            canvas.drawLine(i, bottom.toFloat(), i + waveSize, bottom - waveSize, linePaint)
            canvas.drawLine(i + waveSize, bottom - waveSize, i + doubleWaveSize, bottom.toFloat(), linePaint)
            i += doubleWaveSize
        }
    }
}


Теперь можно написать метод установки спана на проблемную строку:
fun setErrorLine(lineNumber: Int) {
    if (lineNumber in 0 until lineCount) {
        val lineStart = layout.getLineStart(lineNumber)
        val lineEnd = layout.getLineEnd(lineNumber)
        text.setSpan(ErrorSpan(), lineStart, lineEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
    }
}

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

Поэтому чтобы не получить IndexOutOfBoundsException мы добавляем проверку в самом начале. Ну а дальше по знакомой схеме вычисляем первый и последний символ строки, после чего устанавливаем спан.
Главное не забыть очистить текст от уже установленных спанов в afterTextChanged:
fun clearErrorSpans() {
    val spans = text.getSpans<ErrorSpan>(0, text.length)
    for (span in spans) {
        text.removeSpan(span)
    }
}

Почему редакторы кода лагают?
За две статьи мы написали неплохой редактор кода наследуясь от EditText и MultiAutoCompleteTextView, но производительностью при работе с большими файлами похвастаться не можем.
Если открыть тот же TextView.java на 9к+ строк кода то любой текстовый редактор написанный по такому же принципу как наш будет лагать.
Q: А почему QuickEdit тогда не лагает?
A: Потому что под капотом он не использует ни EditText, ни TextView.
В последнее время набирают популярность редакторы кода на CustomView (вот и вот, ну или вот и вот, их очень много). Исторически так сложилось, что TextView имеет слишком много лишней логики, которая не нужна редакторам кода. Первое что приходит на ум — Autofill, Emoji, Compound Drawables, кликабельные ссылки и т.д.
Если я правильно понял, авторы библиотек просто избавились от всего этого, в следствие чего получили текстовый редактор способный работать с файлами в миллион строк без особой нагрузки на UI Thread. (Хотя частично могу ошибаться, в исходниках не сильно разобрался)
Есть ещё один вариант, но на мой взгляд менее привлекательный — редакторы кода на WebView (вот и вот, их тоже очень много). Мне они не нравятся потому что UI на WebView выглядит хуже чем нативный, да и редакторам на CustomView они так же проигрывают по производительности.
Заключение
Если ваша задача написать редактор кода и выйти в топ Google Play — не тратьте время и возьмите готовую библиотеку на CustomView. Если же вы хотите получить уникальный опыт — пишите всё сами, используя нативные виджеты.
Также оставлю ссылку на исходники моего редактора кода на GitHub, там вы найдёте не только те фичи о которых я рассказал за эти две статьи, но и много других которые остались без внимания.
Спасибо!
===========
Источник:
habr.com
===========

Похожие новости: Теги для поиска: #_razrabotka_pod_android (Разработка под Android), #_android, #_razrabotka (разработка), #_redaktor_koda (редактор кода), #_ide, #_edittext, #_multiautocompletetextview, #_razrabotka_pod_android (
Разработка под Android
)
Профиль  ЛС 
Показать сообщения:     

Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы

Текущее время: 22-Ноя 13:43
Часовой пояс: UTC + 5