Editor de código de Android: parte 1



Antes de terminar el trabajo en mi editor de código, pisé un rastrillo muchas veces, probablemente descompuse docenas de aplicaciones similares, y en esta serie de artículos hablaré sobre lo que aprendí, qué errores se pueden evitar y muchas otras cosas interesantes.



Introducción



¡Hola a todos! A juzgar por el nombre, está bastante claro lo que se discutirá, pero aún así tengo que insertar algunas palabras propias antes de pasar al código.



Decidí dividir el artículo en 2 partes, en la primera escribiremos paso a paso el resaltado de sintaxis optimizado y la numeración de líneas, y en la segunda agregaremos la finalización del código y el resaltado de errores.



Primero, hagamos una lista de lo que nuestro editor debería poder:



  • Resaltar sintaxis
  • Mostrar numeración de línea
  • Mostrar opciones de autocompletado (te lo diré en la segunda parte)
  • Resaltar errores de sintaxis (lo diré en la segunda parte)


Esta no es la lista completa de las propiedades que debe tener un editor de código moderno, pero esto es exactamente de lo que quiero hablar en esta pequeña serie de artículos.



MVP - Editor de texto simple



En esta etapa, no debería haber ningún problema: estírese EditTexta la pantalla completa, indique gravitytransparente backgroundpara eliminar la tira de la parte inferior, tamaño de fuente, color de texto, etc. Me gusta comenzar con la parte visual, por lo que me resulta más fácil entender lo que falta en la aplicación y en qué detalles todavía tengo que trabajar.



En esta etapa, también cargué / guardé archivos en la memoria. No daré el código, hay una gran cantidad de ejemplos de trabajo con archivos en Internet.



Resaltado de sintaxis



Tan pronto como leamos los requisitos para el editor, es hora de pasar a lo más interesante.



Obviamente, para controlar todo el proceso: para responder a la entrada, dibujar números de línea, tendremos que escribir CustomViewheredando de EditText. Nos lanzamos TextWatcherpara escuchar los cambios en el texto y redefinimos el método afterTextChangeden el que llamaremos al método responsable de resaltar:



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() {
        //    
    }
}


P: ¿Por qué lo usamos TextWatchercomo variable, porque puede implementar la interfaz directamente en la clase?

R: Dio la casualidad de que TextWatcherhay un método que está en conflicto con un método ya existente en TextView:



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

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


Ambos métodos tienen el mismo nombre y los mismos argumentos, y parecen tener el mismo significado, pero el problema es que el método onTextChangedy se TextViewllamará junto con onTextChangedy TextWatcher. Si colocamos los registros en el cuerpo del método, veremos lo que se onTextChangedllama dos veces:





Esto es muy crítico si planeamos agregar la funcionalidad Deshacer / Rehacer. Además, es posible que necesitemos un momento en el que los oyentes no funcionen, en el que podamos borrar la pila con cambios de texto. No queremos poder presionar Deshacer después de abrir un nuevo archivo y obtener un texto completamente diferente. Aunque Deshacer / Rehacer no se discutirá en este artículo, es importante tener en cuenta este punto.



En consecuencia, para evitar tal situación, puede usar su propio método para configurar el texto en lugar del estándar setText:



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


Pero volvamos a lo más destacado.



Muchos lenguajes de programación tienen algo tan maravilloso como RegEx , una herramienta que le permite buscar coincidencias de texto en una cadena. Le recomiendo que al menos se familiarice con sus capacidades básicas, porque tarde o temprano cualquier programador puede necesitar "extraer" alguna información del texto.



Ahora es importante que sepamos solo dos cosas:



  1. El patrón determina exactamente qué necesitamos encontrar en el texto
  2. Matcher recorrerá el texto tratando de encontrar lo que especificamos en Pattern


Tal vez no lo describió correctamente, pero así es como funciona.



Como estoy escribiendo un editor para JavaScript, aquí hay un pequeño patrón con palabras clave de lenguaje:



private val KEYWORDS = Pattern.compile(
    "\\b(function|var|this|if|else|break|case|try|catch|while|return|switch)\\b"
)


Por supuesto, debería haber muchas más palabras aquí, y también necesitamos patrones para comentarios, líneas, números, etc. pero mi tarea es demostrar el principio por el cual puedes encontrar el contenido deseado en el texto.



A continuación, con la ayuda de Matcher, revisaremos todo el texto y estableceremos los tramos:



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


Me explico: tenemos la Matcher objeto de la Patrón , e indicar a que el área a buscar en símbolos (De acuerdo con ello, de 0 a text.lengthesto es el texto completo). A continuación, la llamada matcher.find()volverá truesi un partido se encuentra en el texto, y con la ayuda de la llamada matcher.start()y matcher.end()vamos a obtener la posición de inicio y al final del partido en el texto. Conociendo estos datos, podemos usar el método setSpanpara colorear ciertas áreas del texto.



Hay muchos tipos de tramos, pero generalmente se usa para volver a pintar el texto ForegroundColorSpan.



¡Entonces comencemos!



El resultado corresponde exactamente a las expectativas hasta que comenzamos a editar un archivo grande (en la captura de pantalla, un archivo de ~ 1000 líneas).



El hecho es que el método setSpanfunciona lentamente, cargando el hilo de la interfaz de usuario, y considerando que el método afterTextChangedse llama después de cada carácter ingresado, se convierte en Un tormento.



Encontrar una solución



Lo primero que viene a la mente es mover una operación pesada a un hilo de fondo. Pero la operación pesada aquí está en setSpantodo el texto, no en la temporada regular. (Creo que no necesito explicar por qué es imposible llamar setSpandesde un hilo de fondo).



Después de un poco de búsqueda de artículos, descubrimos que si queremos lograr suavidad, solo tenemos que resaltar la parte visible del texto.



¡Correcto! ¡Vamos a hacerlo! ¿Así cómo?



Mejoramiento



Aunque mencioné que solo nos importa el rendimiento del método setSpan, todavía recomiendo poner el trabajo de RegEx en un hilo de fondo para lograr la máxima suavidad.



Necesitamos una clase que procese todo el texto en segundo plano y devuelva una lista de tramos.

No daré una implementación específica, pero si alguien está interesado, entonces uso una que AsyncTaskfuncione ThreadPoolExecutor. (Sí, AsyncTask en 2020) Lo



principal para nosotros es que se ejecuta esta lógica:



  1. La tarea beforeTextChanged Stop que analiza el texto
  2. En comenzamos afterTextChanged la Tarea que analiza el texto
  3. Al final de su trabajo, la tarea debe devolver la lista de tramos TextProcessor, que, a su vez, resaltará solo la parte visible


Y sí, los tramos también escribirán los suyos:



data class SyntaxHighlightSpan(
    private val color: Int,
    val start: Int,
    val end: Int
) : CharacterStyle() {

    //     italic, ,   
    override fun updateDrawState(textPaint: TextPaint?) {
        textPaint?.color = color
    }
}


Por lo tanto, el código del editor se convierte en algo como esto:



Mucho código
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() {
        //     
    }
}




Como no mostré una implementación específica de procesamiento en segundo plano, imagine que escribimos una cierta JavaScriptStylerque en el fondo hará lo mismo que hicimos antes en el hilo de la interfaz de usuario: ejecute todo el texto en busca de coincidencias y complete la lista de tramos, y al final de su trabajo devolverá el resultado a setSpansCallback. En este momento, se lanzará un método updateSyntaxHighlightingque recorrerá la lista de tramos y mostrará solo los que están actualmente visibles en la pantalla.



¿Cómo entender qué texto cae en el área visible?



Me referiré a este artículo , donde el autor sugiere usar algo como esto:



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


¡Y funciona! Ahora vamos a poner topVisibleLineque bottomVisibleLineen métodos separados y añadir un par de comprobaciones adicionales en caso de que algo va mal:



Nuevos métodos
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
}




Lo último que debe hacerse es revisar la lista resultante de tramos y colorear el texto:



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


No se alarme por el miedo if'pero, solo verifica si el lapso de la lista cae en el área visible.



Bueno, ¿funciona?



Funciona, pero al editar el texto, los tramos no se actualizan, puede solucionar la situación borrando el texto de todos los tramos antes de superponer otros nuevos:



// :  getSpans   core-ktx
val textSpans = text.getSpans<SyntaxHighlightSpan>(0, text.length)
for (span in textSpans) {
    text.removeSpan(span)
}


Otra jamba: después de cerrar el teclado, un fragmento de texto permanece apagado, corríjalo:



override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    updateSyntaxHighlighting()
}


Lo principal es no olvidar indicar adjustResizeen el manifiesto.



Desplazamiento



Hablando de desplazamiento, me referiré a este artículo nuevamente . El autor sugiere esperar 500 ms después del final del desplazamiento, lo que contradice mi sentido de la belleza. No quiero esperar a que se cargue la luz de fondo, quiero ver el resultado al instante.



El autor también argumenta que iniciar el analizador después de cada píxel "desplazado" es costoso, y estoy completamente de acuerdo con esto (generalmente recomiendo que lea su artículo por completo, es pequeño, pero hay muchas cosas interesantes). Pero el hecho es que ya tenemos una lista de tramos listos y no necesitamos lanzar el analizador.



Es suficiente llamar al método responsable de actualizar el resaltado:



override fun onScrollChanged(horiz: Int, vert: Int, oldHoriz: Int, oldVert: Int) {
    super.onScrollChanged(horiz, vert, oldHoriz, oldVert)
    updateSyntaxHighlighting()
}


Numeración de linea



Si agregamos otro al marcado, TextViewserá problemático vincularlos (por ejemplo, para actualizar sincrónicamente el tamaño del texto), y si tenemos un archivo grande, tendremos que actualizar completamente el texto con números después de cada letra ingresada, lo cual no es muy bueno. Por lo tanto, vamos a utilizar cualquier medio estándar CustomView- sobre la base Canvasde onDraw, es rápido y no es difícil.



Primero, determinemos qué dibujaremos:



  • Línea de números
  • La línea vertical que separa el campo de entrada de los números de línea.


Primero debe calcular y establecer a la paddingizquierda del editor para que no haya conflictos con el texto impreso.



Para hacer esto, escribimos una función que actualizará la sangría antes de renderizar:



Actualización de sangría
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)
    }
}




Explicación:



Primero, descubrimos el número de líneas en EditText(que no debe confundirse con el número de " \n" en el texto) y tomamos el número de caracteres de este número. Por ejemplo, si tenemos 100 líneas, entonces la variable gutterDigitCountserá igual a 3, porque hay exactamente 3 caracteres en 100. Pero digamos que solo tenemos 1 línea, lo que significa que una sangría de 1 carácter aparecerá visualmente pequeña, y para esto usamos la variable de conteo para establecer la sangría mínima visualizada de 3 caracteres, incluso si tenemos menos de 100 líneas de código.



Esta parte fue la más confusa de todas, pero si lo leyó cuidadosamente varias veces (mirando el código), entonces todo quedará claro.



A continuación, establezca la sangría precomputando widestNumbery widestWidth.



Empecemos a dibujar



Desafortunadamente, si queremos usar el ajuste de texto estándar de Androyd en una nueva línea, tendremos que conjurarlo, lo que nos llevará mucho tiempo e incluso más código, que será suficiente para todo el artículo, por lo que para reducir su tiempo (y el tiempo del moderador del centro), habilitaremos horizontal desplazamiento para que todas las líneas vayan una tras otra:



setHorizontallyScrolling(true)


Bueno, ahora puedes comenzar a dibujar, declarar variables con tipo Paint:



private val gutterTextPaint = Paint() //  
private val gutterDividerPaint = Paint() //  


initEstablezca el color del texto y el color del separador en algún lugar del bloque. Es importante recordar que si cambia la fuente del texto, entonces la fuente Paintdeberá aplicarse manualmente, para esto le aconsejo que anule el método setTypeface. Del mismo modo con el tamaño del texto.



Luego redefina el método 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
    )
}


Nos fijamos en el resultado



Se ve genial.



¿En qué hemos hecho onDraw? Antes de llamar al supermétodo, actualizamos la sangría, después de lo cual dibujamos los números solo en el área visible, y al final dibujamos una línea vertical que separa visualmente la numeración de la línea del editor de código.



Por belleza, también puede volver a pintar la sangría en un color diferente, resaltar visualmente la línea en la que se encuentra el cursor, pero lo dejaré a su discreción.



Conclusión



En este artículo, escribimos un editor de código receptivo con resaltado de sintaxis y numeración de líneas, y en la siguiente parte agregaremos la finalización de código conveniente y el resaltado de sintaxis justo durante la edición.



También dejaré un enlace al código fuente de mi editor de código GitHub , allí encontrará no solo las características de las que hablé en este artículo, sino también muchas otras que han sido ignoradas.



UPD: La segunda parte ya está fuera.



Haga preguntas y sugiera temas para el debate, porque bien podría haber pasado algo por alto.



¡Gracias!



All Articles