Android代码编辑器:第1部分



在完成代码编辑器的工作之前,我多次踩了耙,可能已经反编译了许多类似的应用程序,在本系列文章中,我将谈论我学到的东西,可以避免的错误以及许多其他有趣的事情。



介绍



大家好!从标题看,它的含义很清楚,但是在继续进行代码之前,我仍然必须插入自己的几句话。



我决定将文章分为两部分,第一部分将逐步编写优化的语法突出显示和行编号,第二部分将添加代码完成和错误突出显示。



首先,让我们列出编辑器应具备的功能:



  • 高亮语法
  • 显示行号
  • 显示自动完成选项(第二部分将告诉您)
  • 突出显示语法错误(我将在第二部分告诉您)


这并不是现代代码编辑器应具有的所有属性的完整列表,但这恰恰是我希望在此系列文章中讨论的内容。



MVP-一个简单的文本编辑器



在此阶段,应该没有任何问题-伸展EditText到全屏,显示gravity透明background以从底部去除条带,字体大小,文本颜色等。我喜欢从视觉部分入手,因此对我来说,了解应用程序中缺少的内容以及需要处理的细节变得更加容易。



在此阶段,我也确实将文件加载/保存到内存中。我不会提供代码;在Internet上使用文件的例子很多。



语法高亮



一旦我们熟悉了编辑器的需求,就该着手进行有趣的部分了。



显然,为了控制整个过程-响应输入,绘制行号,我们将不得不编写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有一种方法与位于的现有方法冲突TextView



//  TextWatcher
fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int)

//  TextView
fun onTextChanged(text: CharSequence?, start: Int, lengthBefore: Int, lengthAfter: Int)


这两种方法都具有相同的名称和参数,其含义似乎相同,但是问题在于onTextChangedy 方法将TextViewonTextChangedy 一起调用TextWatcher如果将日志放在方法的主体中,我们将看到它将被onTextChanged调用两次:





如果我们计划添加撤消/重做功能,这是非常关键的。另外,我们可能需要一会儿听众无法使用,这时我们可以通过更改文本来清除堆栈。我们不希望在打开新文件后单击“撤消”来获得完全不同的文本。尽管本文将不讨论撤消/重做,但重要的是要考虑这一点。



因此,为避免这种情况,您可以使用自己的设置文本的方法来代替标准方法setText



fun processText(newText: String) {
    removeTextChangedListener(textWatcher)
    // undoStack.clear()
    // redoStack.clear()
    setText(newText)
    addTextChangedListener(textWatcher)
}


但是回到重点。



许多编程语言都具有RegEx这样的奇妙功能,这是一个允许您搜索字符串中的文本匹配项的工具。我建议您至少至少熟悉其基本功能,因为任何程序员迟早都可能需要从文本中“提取”一些信息。



现在,对我们来说,只有两件事很重要:



  1. 模式决定了我们到底需要在文本中找到什么
  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
        )
    }
}


让我解释一下:我们Pattern中获取Matcher对象,并向其指示要搜索字符的区域(因此,从0 到此均为文本)。此外,如果在文本中找到匹配项,则该调用将返回,并在调用的帮助下我们将获得文本中该匹配项的开始和结束的位置。知道了这些数据,我们就可以使用该方法为文本的某些区域着色。 跨度有很多类型,但通常用于重新绘制文本text.lengthmatcher.find()truematcher.start()matcher.end()setSpan



ForegroundColorSpan



所以,让我们开始吧!



结果完全符合期望,直到我们开始编辑一个大文件(在屏幕快照中是一个约1000行的文件)



。事实是该方法setSpan运行缓慢,大量加载了UI线程,并且假定afterTextChanged在输入每个字符之后调用该方法,一种折磨。



寻找解决方案



首先想到的是将繁重的操作移至后台线程。但这在setSpan整个文本中都是困难的操作,而不是规则的行。(我认为您无法解释为什么无法setSpan从后台线程调用)。



在搜索了一些主题文章之后,我们发现如果要实现平滑性,我们将突出显示文本的可见部分



究竟!我们开始做吧!只是...怎么了?



优化



尽管我提到我们只关心方法的性能,但setSpan我仍然建议将RegEx工作放在后台线程中以实现最大的平滑度。



我们需要一个将在后台处理所有文本并返回跨度列表的类。

我不会给出具体的实现方式,但是如果有人感兴趣,那么我会使用AsyncTask适合的实现方式ThreadPoolExecutor(是的,是的,2020年的AsyncTask)



对我们来说,主要是执行以下逻辑:



  1. 解析文本beforeTextChanged 停止任务
  2. afterTextChanged 运行任务中解析文本
  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<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线程中完成后台处理的特定操作-遍历整个文本以查找匹配项并填写跨度列表,最后工作的结果将返回到setSpansCallback此时,将启动一种方法,该方法将updateSyntaxHighlighting遍历跨度列表,并仅显示屏幕上当前可见的那些。



您如何知道哪些文本属于可见区域?



我将参考本文,作者建议使用如下所示的内容:



val topVisibleLine = scrollY / lineHeight
val bottomVisibleLine = topVisibleLine + height / lineHeight + 1 // height -  View
val lineStart = layout.getLineStart(topVisibleLine)
val lineEnd = layout.getLineEnd(bottomVisibleLine)


而且有效!现在,将topVisibleLinebottomVisibleLine移至单独的方法,并添加一些其他检查,以防出现问题:



新方法
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-利用Canvasin 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个字符的缩进在视觉上会显得很小,为此,即使没有少于100行代码,我们也可以使用count变量设置3个字符的最小显示缩进。



这部分是所有部分中最令人困惑的部分,但是如果您仔细地阅读了几次(看代码),那么一切都会变得清晰起来。



接下来,我们在计算widestNumber之后设置缩进量widestWidth



让我们开始绘画



不幸的是,如果我们想在新行上使用标准的Androyd文本换行,我们将不得不对其进行构想,这将花费我们很多时间,甚至更多的代码,这对于整篇文章来说已经足够了,因此,为了减少您的时间(以及集线器主持人的时间),我们将启用水平滚动,以便所有行都接连显示:



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


看结果



看起来很酷。



我们做了onDraw什么?在调用该super方法之前,我们更新了缩进,之后我们仅在可见区域中绘制了数字,最后绘制了一条垂直线,在视觉上将行号与代码编辑器分开。



为了美观,您也可以用不同的颜色重新绘制压痕,在视觉上突出显示光标所在的线,但是我会留给您。



结论



在本文中,我们编写了一个具有语法突出显示和行编号的响应式代码编辑器,在下一部分中,我们将在编辑过程中添加方便的代码完成和语法突出显示功能。



我还将在GitHub上保留我的代码编辑器资源的链接,您不仅可以找到我在本文中描述的那些功能,还可以找到许多其他未被关注的功能。



UPD: 第二部分已经讲完



,提出问题并提出讨论话题,因为我很可能会漏掉一些东西。



谢谢!



All Articles