Android代码编辑器:第2部分



因此,发布第二部分的时间到了,今天,我们将继续开发代码编辑器,并向其添加自动完成和错误突出显示功能,并讨论为什么任何代码编辑器EditText都不会落后。



在继续阅读之前,强烈建议您阅读第一部分



介绍



首先,让我们记住最后一部分的内容我们编写了一个优化的语法突出显示,该语法突出显示了背景中的文本并仅对其可见部分着色,并添加了行号(尽管没有android换行符,但仍然)。



在这一部分中,我们将添加代码完成和错误突出显示。



代码完成



首先,让我们想象一下它应该如何工作:



  1. 用户写一个字
  2. 输入N个字符后,将出现一个提示窗口
  3. 当您单击提示时,单词将自动“打印”
  4. 带有提示的窗口将关闭,并且光标将移动到单词的末尾
  5. 如果用户自己输入了工具提示中显示的单词,则带有提示的窗口应自动关闭


看起来不像什么吗?Android已经具有一个具有完全相同逻辑的组件- MultiAutoCompleteTextView因此PopupWindow我们不必我们一起编写拐杖(他们已经为我们编写了)。



第一步是更改类的父级:



class TextProcessor @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = R.attr.autoCompleteTextViewStyle
) : MultiAutoCompleteTextView(context, attrs, defStyleAttr)


现在我们需要编写ArrayAdapter将显示找到的结果的代码。完整的适配器代码将不可用,可以在Internet上找到实现示例。但是我现在将停止过滤。



为了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
}


这里的过滤逻辑相当原始,我们遍历整个列表,并忽略大小写,比较字符串的开头。



安装适配器后,输入文字-不起作用。怎么了?在Google的第一个链接上,我们遇到一个回答,说我们忘记安装了Tokenizer



Tokenizer的作用是什么?



简而言之,它Tokenizer有助于MultiAutoCompleteTextView理解单词输入可以认为是完整的。它也有现成的实现形式,CommaTokenizer将单词分隔成逗号,在这种情况下不适合我们。



好吧,由于CommaTokenizer我们不满意,因此我们将自己编写:



自订分词器
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 -一个字符串,其字符将一个单词与另一个单词分开。在这些方法中findTokenStartfindTokenEnd我们遍历全文以寻找这些非常分离的符号。该方法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,那么选择高度将更加困难。



Google关于字体的属性,您可以偶然发现此帖子作者所附的图片清楚地显示了我们可以用来计算垂直坐标的属性。



根据图片,我们需要基线在此级别上,将出现一个带有自动完成选项的窗口。



现在让我们编写一个方法,当文本更改为时将调用该方法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-所以它适合光标没有一个巨大的凹痕,该控件本身增加。



PS:您可以在gif上看到键盘闪烁,并且说实话,我不知道如何解决它,因此,如果有解决办法,请写。



突出显示错误



突出显示错误,一切都比看起来简单得多,因为我们自己无法直接检测代码中的语法错误-我们将使用第三方解析器库。由于我正在编写JavaScript编辑器,因此我的选择落在Rhino上,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?)


我建议使用以下逻辑:如果未发现错误,那么exceptionnull否则,我们将获得一个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-它允许您运行我们作为字符串传递的代码sourceCodefileName文件的名称表示在-它会在错误显示,单位是行号开始计数,最后一个参数是安全域,但我们并不需要它,所以我们设置null



OptimizationLevel和maximumInterpreterStackDepth



optimizationLevel值从19的 参数允许您启用某些代码“优化”(数据流分析,类型流分析等),这会将简单的语法错误检查变成非常耗时的操作,而我们不需要它。



如果您使用的值为0,那么将不会应用所有这些“优化”,但是,如果我理解正确,Rhino仍会使用一些简单的错误检查不需要的资源,这意味着它不适合我们。



仅存在一个负值-通过指定-1,我们激活了“解释器”模式,这正是我们所需要的。该文档说,这是运行Rhino最快,最经济的方式。



该参数maximumInterpreterStackDepth允许您限制递归调用的数量。



让我们想象一下如果不指定此参数会发生什么:



  1. 用户将编写以下代码:



    function recurse() {
        recurse();
    }
    recurse();
    
  2. Rhino将运行代码,一秒钟后,我们的应用程序将崩溃OutOfMemoryError结束。


显示错误



就像我之前说的,一旦获得ParseResult包含RhinoException的数据,我们将显示所有必要的数据集,包括行号-我们只需要调用method即可lineNumber()



现在,让我们编写复制到StackOverflow的红色波浪线跨距有很多代码,但是逻辑很简单-在不同的角度绘制两条短的红线。



ErrorSpan.kt
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
        }
    }
}




现在,您可以编写一种在问题行上安装span的方法:



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)
    }
}


为什么代码编辑器会滞后?



在两篇文章中,我们编写了一个很好的代码编辑器,该编辑器继承自EditTextMultiAutoCompleteTextView,但是在处理大文件时不能夸耀性能。



如果9k +行代码打开相同的TextView.java,则根据与我们相同的原理编写的任何文本编辑器都会滞后。问:为什么QuickEdit不落后?答:因为在后台,它既不使用,也不使用 最近,CustomView上的代码编辑器越来越流行(在这里那里,或者在这里那里





EditTextTextView



,其中有很多)。从历史上看,TextView具有过多的逻辑,代码编辑器不需要这些逻辑。首先想到的是AutofillEmoji表情Composite Drawablesclickable链接等。



如果我理解正确的话,这些库的作者完全摆脱了所有这些,结果得到了一个文本编辑器,该编辑器能够处理一百万行的文件,而不会给UI线程带来很大的负担。 (虽然我可能是错误的部分,我不明白源多)



还有另一种选择,但在我看来不那么有吸引力-代码编辑器上的WebView这里那里,其中也有很多)。我不喜欢它们,因为WebView上的UI看起来比本机UI差,并且在性能方面,它们也输给了CustomView上的编辑器。



结论



如果您的任务是编写代码编辑器并到达Google Play的顶部,请不要浪费时间并在CustomView上使用现成的库。如果您想获得独特的体验,请使用本机窗口小部件自己编写所有内容。



我还将在GitHub上保留指向我的代码编辑器的源代码的链接,您不仅会发现我在这两篇文章中介绍的那些功能,而且还会发现许多其他未被关注的功能。



谢谢!



All Articles