Editor de código do Android: Parte 1



Antes de terminar o trabalho no meu editor de código, entrei em uma varredura muitas vezes, provavelmente descompilei dezenas de aplicativos semelhantes e nesta série de artigos falarei sobre o que aprendi, que erros podem ser evitados e muitas outras coisas interessantes.



Introdução



Olá a todos! A julgar pelo título, é bastante claro o que será, mas ainda assim devo inserir algumas palavras minhas antes de passar para o código.



Decidi dividir o artigo em duas partes; na primeira, escreveremos o destaque otimizado da sintaxe e a numeração das linhas passo a passo; na segunda, adicionaremos a conclusão do código e o destaque dos erros.



Primeiro, vamos fazer uma lista do que nosso editor deve ser capaz de:



  • Realce de sintaxe
  • Mostrar numeração de linha
  • Mostrar opções de preenchimento automático (mostrarei na segunda parte)
  • Realçar erros de sintaxe (vou contar na segunda parte)


Esta não é a lista completa de quais propriedades um editor de código moderno deve ter, mas é exatamente sobre isso que quero falar nesta pequena série de artigos.



MVP - Editor de Texto Simples



Nesse estágio, não deve haver problemas - estenda EditTextpara a tela inteira, indique gravitytransparente backgroundpara remover a faixa da parte inferior, tamanho da fonte, cor do texto etc. Eu gosto de começar com a parte visual, para que fique mais fácil entender o que está faltando no aplicativo e quais detalhes ainda precisam ser trabalhados.



Nesta fase, também carreguei / salvei arquivos na memória. Não vou dar o código, há uma superabundância de exemplos de trabalho com arquivos na Internet.



Realce de sintaxe



Assim que lemos os requisitos para o editor, é hora de passar para o mais interessante.



Obviamente, para controlar todo o processo - para responder à entrada, desenhar números de linha, teremos que escrever CustomViewherdados EditText. Jogamos TextWatcherpara ouvir as alterações no texto e substituímos o método afterTextChangedpelo qual chamaremos o método responsável por destacar:



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 que a usamos TextWatchercomo uma variável, porque você pode implementar a interface diretamente na classe?

R: Acontece que temos TextWatcherum método que entra em conflito com um método existente em TextView:



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

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


Ambos os métodos têm o mesmo nome e os mesmos argumentos, e parecem ter o mesmo significado, mas o problema é que o método onTextChangedy será TextViewchamado junto com onTextChangedy TextWatcher. Se colocarmos os logs no corpo do método, veremos o que é onTextChangedchamado duas vezes:





Isso é muito crítico se planejarmos adicionar a funcionalidade Desfazer / Refazer. Além disso, podemos precisar de um momento em que os ouvintes não funcionem, em que possamos limpar a pilha com alterações de texto. Não queremos que, depois de abrir um novo arquivo, você possa clicar em Desfazer e obter um texto completamente diferente. Embora este artigo não fale sobre Desfazer / Refazer, é importante considerar esse ponto.



Assim, para evitar tal situação, você pode usar seu próprio método de instalação de texto em vez do padrão setText:



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


Mas voltando à luz de fundo.



Muitas linguagens de programação têm uma coisa maravilhosa como RegEx , esta é uma ferramenta que permite pesquisar correspondências de texto em uma string. Eu recomendo que você pelo menos se familiarize com seus recursos básicos, porque mais cedo ou mais tarde qualquer programador pode precisar "extrair" algumas informações do texto.



Agora é importante sabermos apenas duas coisas:



  1. Padrão determina o que exatamente precisamos encontrar no texto
  2. O Matcher percorrerá o texto tentando encontrar o que especificamos em Padrão


Talvez ele não tenha descrito corretamente, mas é assim que funciona.



Como estou escrevendo um editor para JavaScript, eis um pequeno padrão com palavras-chave de idioma:



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


Obviamente, deve haver muito mais palavras aqui, e também precisamos de padrões para comentários, linhas, números etc. mas minha tarefa é demonstrar o princípio pelo qual você pode encontrar o conteúdo desejado no texto.



Em seguida, usando o Matcher, percorreremos todo o texto e definiremos os intervalos:



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


Deixe-me explicar: nós temos a Matcher objeto do padrão , e indicar a ele a área para procurar em símbolos (Assim, de 0 a text.lengtheste é o texto inteiro). Além disso, a chamada matcher.find()voltará truese uma correspondência foi encontrada no texto, e com a ajuda de chamadas matcher.start()e matcher.end()vamos obter as posições de início e no final da partida no texto. Conhecendo esses dados, podemos usar o método setSpanpara colorir certas seções do texto.



Existem muitos tipos de extensões, mas geralmente é usado para repintar texto ForegroundColorSpan.



Então vamos começar!



O resultado atende às expectativas exatamente até começarmos a editar um arquivo grande (na captura de tela, o arquivo tem ~ 1000 linhas).



O fato é que o método setSpanfunciona lentamente, carregando muito o Thread da interface do usuário e, como o método afterTextChangedé chamado após cada caractere inserido, ele se torna um tormento.



Encontrando uma solução



A primeira coisa que vem à mente é mover uma operação pesada para um encadeamento em segundo plano. Mas a operação pesada aqui está em setSpantodo o texto, não na temporada regular. (Acho que não preciso explicar por que é impossível chamar setSpande um thread em segundo plano).



Depois de pesquisar um pouco por artigos temáticos, descobrimos que, se queremos obter suavidade, teremos que destacar apenas a parte visível do texto.



Certo! Vamos fazer isso! Quão?



Otimização



Embora eu tenha mencionado que estamos preocupados apenas com o desempenho do método setSpan, ainda recomendo colocar o RegEx no segmento de segundo plano para obter a máxima suavidade.



Precisamos de uma classe que processe todo o texto em segundo plano e retorne uma lista de extensões.

Não darei uma implementação específica, mas se alguém estiver interessado, então uso a que AsyncTaskfunciona ThreadPoolExecutor. (Sim, sim, AsyncTask em 2020) O



principal para nós é que a seguinte lógica seja executada:



  1. A tarefa de beforeTextChanged parada que analisa o texto
  2. Na tarefa afterTextChanged Executar, que analisa o texto
  3. No final de seu trabalho, a Tarefa deve retornar a lista de vãos TextProcessor, que, por sua vez, destacará apenas a parte visível


E sim, também escreveremos nossos próprios períodos:



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

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


Assim, o código do editor se transforma em algo assim:



Muito 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 eu não mostrei a implementação específica do processamento em segundo plano, vamos imaginar que escrevemos uma certa JavaScriptStylerque fará tudo em segundo plano que fizemos antes no thread da interface do usuário - percorra todo o texto em busca de correspondências e preencha a lista de extensões, e no final seu trabalho retornará o resultado para setSpansCallback. Nesse momento, será lançado um método updateSyntaxHighlightingque percorrerá a lista de extensões e exibirá apenas as que estão atualmente visíveis na tela.



Como você sabe qual texto cai na área visível?



Vou me referir a este artigo , onde o autor sugere usar algo como isto:



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


E funciona! Agora, vamos colocá- topVisibleLinelo bottomVisibleLineem métodos separados e adicionar algumas verificações adicionais, caso algo dê errado:



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




A última coisa a fazer é percorrer a lista de extensões e colorir o 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
        )
    }
}


Não tenha medo do terrível if, mas ele apenas verifica se o espaço da lista cai na área visível.



Bem, isso funciona?



Funciona, mas ao editar o texto, os períodos não são atualizados, você pode corrigir a situação limpando o texto de todos os períodos antes de sobrepor novos:



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


Outro batente - depois de fechar o teclado, um pedaço de texto permanece apagado, corrija-o:



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


O principal é não esquecer de indicar adjustResizeno manifesto.



Rolagem



Falando sobre rolagem, vou me referir novamente a este artigo . O autor sugere esperar 500ms após o final da rolagem, o que contradiz meu senso de beleza. Não quero esperar a luz de fundo carregar, quero ver o resultado instantaneamente.



O autor também argumenta que executar o analisador após cada pixel "rolado" é caro, e eu concordo completamente com isso (em geral, recomendo que você leia o artigo na íntegra, é pequeno, mas há muitas coisas interessantes). Mas o fato é que temos uma lista pronta de vãos e não precisamos iniciar o analisador.



Basta chamar o método responsável pela atualização do destaque:



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


Numeração de linha



Se adicionarmos mais uma marcação, TextViewserá problemático vinculá-los (por exemplo, atualizar o tamanho do texto de forma síncrona) e, mesmo se tivermos um arquivo grande, teremos que atualizar completamente o texto com números após cada letra inserida, o que não é muito legal. Portanto, vamos usar todos os meios normais CustomView- desenho em Canvasem onDraw, é rápido e não é difícil.



Primeiro, vamos definir o que desenharemos:



  • Números de linha
  • A linha vertical que separa o campo de entrada dos números de linha


Você deve primeiro calcular e definir à paddingesquerda do editor para que não haja conflitos com o texto impresso.



Para fazer isso, escreveremos uma função que atualizará o recuo antes de desenhar:



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




Explicação:



Primeiro, descobrimos o número de linhas inseridas EditText(não confundir com o número " \n" no texto) e obtemos o número de caracteres desse número. Por exemplo, se tivermos 100 linhas, a variável gutterDigitCountserá igual a 3, porque existem exatamente 3 caracteres em 100. Mas suponha que tenhamos apenas 1 linha - o que significa que um recuo de 1 caractere aparecerá visualmente pequeno e, para isso, usamos a contagem de variáveis ​​para definir o recuo mínimo exibido de 3 caracteres, mesmo se tivermos menos de 100 linhas de código.



Essa parte foi a mais confusa de todas, mas se você a ler cuidadosamente várias vezes (observando o código), tudo ficará claro.



Em seguida, definimos o recuo após calcular widestNumbere widestWidth.



Vamos começar a desenhar



Infelizmente, se quisermos usar o empacotamento de texto padrão do Android em uma nova linha, teremos que invocar, o que levará muito tempo e ainda mais código, o que será suficiente para um artigo inteiro, portanto, para reduzir seu tempo (e o tempo do moderador habr), habilitaremos a horizontal rolagem para que todas as linhas sejam uma após a outra:



setHorizontallyScrolling(true)


Bem, agora você pode começar a desenhar, vamos declarar variáveis ​​com o tipo Paint:



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


initDefina a cor do texto e a cor do separador em algum lugar do bloco. É importante lembrar que, se você alterar a fonte do texto, ela Paintdeverá ser aplicada manualmente, por isso aconselho que você substitua o método setTypeface. Da mesma forma com o tamanho do texto.



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


Nós olhamos para o resultado



Isso parece legal.



No que fizemos onDraw? Antes de chamar o supermétodo, atualizamos o recuo, após o qual renderizamos os números apenas na área visível e, no final, desenhamos uma linha vertical que separa visualmente a numeração de linhas do editor de código.



Para beleza, você também pode repintar o recuo em uma cor diferente, destacar visualmente a linha na qual o cursor está localizado, mas deixarei isso a seu critério.



Conclusão



Neste artigo, escrevemos um editor de código responsivo com realce de sintaxe e numeração de linha e, na próxima parte, adicionaremos o conveniente preenchimento de código e o erro de sintaxe destacando durante a edição.



Também deixarei um link para as fontes do meu editor de código no GitHub , onde você encontrará não apenas os recursos que descrevi neste artigo, mas também muitos outros que foram deixados sem atenção.



UPD: A segunda parte já está disponível:



faça perguntas e sugira tópicos para discussão, porque eu poderia ter perdido alguma coisa.



Obrigado!



All Articles