在完成代码编辑器的工作之前,我多次踩了耙,可能已经反编译了许多类似的应用程序,在本系列文章中,我将谈论我学到的东西,可以避免的错误以及许多其他有趣的事情。
介绍
大家好!从标题看,它的含义很清楚,但是在继续进行代码之前,我仍然必须插入自己的几句话。
我决定将文章分为两部分,第一部分将逐步编写优化的语法突出显示和行编号,第二部分将添加代码完成和错误突出显示。
首先,让我们列出编辑器应具备的功能:
- 高亮语法
- 显示行号
- 显示自动完成选项(第二部分将告诉您)
- 突出显示语法错误(我将在第二部分告诉您)
这并不是现代代码编辑器应具有的所有属性的完整列表,但这恰恰是我希望在此系列文章中讨论的内容。
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)
这两种方法都具有相同的名称和参数,其含义似乎相同,但是问题在于
onTextChanged
y 方法将TextView
与onTextChanged
y 一起调用TextWatcher
。如果将日志放在方法的主体中,我们将看到它将被onTextChanged
调用两次:
如果我们计划添加撤消/重做功能,这是非常关键的。另外,我们可能需要一会儿听众无法使用,这时我们可以通过更改文本来清除堆栈。我们不希望在打开新文件后单击“撤消”来获得完全不同的文本。尽管本文将不讨论撤消/重做,但重要的是要考虑这一点。
因此,为避免这种情况,您可以使用自己的设置文本的方法来代替标准方法
setText
:
fun processText(newText: String) {
removeTextChangedListener(textWatcher)
// undoStack.clear()
// redoStack.clear()
setText(newText)
addTextChangedListener(textWatcher)
}
但是回到重点。
许多编程语言都具有RegEx这样的奇妙功能,这是一个允许您搜索字符串中的文本匹配项的工具。我建议您至少至少熟悉其基本功能,因为任何程序员迟早都可能需要从文本中“提取”一些信息。
现在,对我们来说,只有两件事很重要:
- 模式决定了我们到底需要在文本中找到什么
- 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.length
matcher.find()
true
matcher.start()
matcher.end()
setSpan
ForegroundColorSpan
所以,让我们开始吧!
结果完全符合期望,直到我们开始编辑一个大文件(在屏幕快照中是一个约1000行的文件)
。事实是该方法
setSpan
运行缓慢,大量加载了UI线程,并且假定afterTextChanged
在输入每个字符之后调用该方法,一种折磨。
寻找解决方案
首先想到的是将繁重的操作移至后台线程。但这在
setSpan
整个文本中都是困难的操作,而不是规则的行。(我认为您无法解释为什么无法setSpan
从后台线程调用)。
在搜索了一些主题文章之后,我们发现如果要实现平滑性,我们将仅突出显示文本的可见部分。
究竟!我们开始做吧!只是...怎么了?
优化
尽管我提到我们只关心方法的性能,但
setSpan
我仍然建议将RegEx工作放在后台线程中以实现最大的平滑度。
我们需要一个将在后台处理所有文本并返回跨度列表的类。
我不会给出具体的实现方式,但是如果有人感兴趣,那么我会使用
AsyncTask
适合的实现方式ThreadPoolExecutor
。(是的,是的,2020年的AsyncTask)
对我们来说,主要是执行以下逻辑:
- 解析文本的
beforeTextChanged
停止任务 - 在
afterTextChanged
运行任务中解析文本 - 在工作结束时,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)
而且有效!现在,将
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
in 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: 第二部分已经讲完
,提出问题并提出讨论话题,因为我很可能会漏掉一些东西。
谢谢!