Éditeur de code Android: Partie 1



Avant de terminer le travail sur mon éditeur de code, je suis monté plusieurs fois sur un râteau, décompilé probablement des dizaines d'applications similaires, et dans cette série d'articles, je parlerai de ce que j'ai appris, des erreurs qui peuvent être évitées et de nombreuses autres choses intéressantes.



introduction



Bonjour à tous! À en juger par le titre, il est assez clair de quoi il s'agira, mais je dois quand même insérer quelques mots avant de passer au code.



J'ai décidé de diviser l'article en 2 parties, dans la première, nous écrivons étape par étape la mise en évidence de la syntaxe optimisée et la numérotation des lignes, et dans la seconde, nous ajoutons la complétion de code et la mise en évidence des erreurs.



Pour commencer, nous allons faire une liste de ce que notre éditeur devrait être capable de:



  • Mise en évidence de la syntaxe
  • Afficher la numérotation des lignes
  • Afficher les options de saisie semi-automatique (je vous le dirai dans la deuxième partie)
  • Souligner les erreurs de syntaxe (je le dirai dans la deuxième partie)


Ce n'est pas la liste complète des propriétés qu'un éditeur de code moderne devrait avoir, mais c'est exactement ce dont je veux parler dans cette petite série d'articles.



MVP - Éditeur de texte simple



À ce stade, il ne devrait y avoir aucun problème - nous l'étirons EditTexten plein écran, indiquons gravitytransparent backgroundpour supprimer la barre du bas, la taille de la police, la couleur du texte, etc. J'aime commencer par la partie visuelle, il est donc plus facile pour moi de comprendre ce qui manque dans l'application et sur quels détails méritent encore d'être travaillés.



À ce stade, j'ai également téléchargé / enregistré des fichiers en mémoire. Je ne donnerai pas le code, il existe une multitude d'exemples de travail avec des fichiers sur Internet.



Mise en évidence de la syntaxe



Dès que nous lisons les exigences de l'éditeur, il est temps de passer au plus intéressant.



De toute évidence, afin de contrôler l'ensemble du processus - pour répondre à l'entrée, tracer des numéros de ligne, nous devrons écrire CustomViewhéritant de EditText. Nous jetons TextWatcherpour écouter les changements dans le texte et remplacer la méthode afterTextChangeddans laquelle nous appellerons la méthode responsable de la mise en évidence:



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


Q: Pourquoi utilisons-nous TextWatchercomme variable, car vous pouvez implémenter l'interface directement dans la classe?

R: Il se trouve que nous TextWatcheravons une méthode qui entre en conflit avec une méthode existante dans TextView:



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

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


Ces deux méthodes ont le même nom et les mêmes arguments, et elles semblent avoir la même signification, mais le problème est que la méthode onTextChangedy sera TextViewappelée avec onTextChangedy TextWatcher. Si nous mettons les journaux dans le corps de la méthode, nous verrons ce qu'on onTextChangedappelle deux fois:





Ceci est très critique si nous prévoyons d'ajouter la fonctionnalité Annuler / Rétablir. De plus, nous pouvons avoir besoin d'un moment pendant lequel les écouteurs ne fonctionneront pas, dans lequel nous pouvons effacer la pile avec des modifications de texte. Nous ne voulons pas, après avoir ouvert un nouveau fichier, vous pouvez cliquer sur Annuler et obtenir un texte complètement différent. Bien que cet article ne parle pas d'Annuler / Rétablir, il est important de considérer ce point.



Par conséquent, afin d'éviter une telle situation, vous pouvez utiliser votre propre méthode d'installation de texte au lieu de la méthode standard setText:



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


Mais revenons au point culminant.



De nombreux langages de programmation ont une chose aussi merveilleuse que RegEx , c'est un outil qui vous permet de rechercher des correspondances de texte dans une chaîne. Je recommande au moins de vous familiariser avec ses fonctionnalités de base, car tôt ou tard, tout programmeur peut avoir besoin de «retirer» une information du texte.



Maintenant, il est important pour nous de ne connaître que deux choses:



  1. Le modèle définit exactement ce que nous devons trouver dans le texte
  2. Matcher parcourra le texte pour essayer de trouver ce que nous avons indiqué dans le modèle


Il ne l’a peut-être pas tout à fait correctement décrit, mais c’est ainsi que cela fonctionne.



Depuis que j'écris un éditeur pour JavaScript, voici un petit modèle avec des mots-clés de langue:



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


Bien sûr, il devrait y avoir beaucoup plus de mots ici, et nous avons également besoin de modèles pour les commentaires, les lignes, les chiffres, etc. mais ma tâche est de démontrer le principe par lequel vous pouvez trouver le contenu souhaité dans le texte.



Ensuite, en utilisant Matcher, nous allons parcourir tout le texte et définir les étendues:



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


Laissez - moi vous expliquer: nous obtenons le matcher objet de la modèle , et lui indiquer la zone à rechercher dans les symboles ( En conséquence, de 0 à text.lengthceci est le texte intégral). Ensuite, l'appel matcher.find()sera renvoyé truesi une correspondance a été trouvée dans le texte, et à l'aide de l'appel matcher.start(), matcher.end()nous obtiendrons la position du début et de la fin de la correspondance dans le texte. Connaissant ces données, nous pouvons utiliser la méthode setSpanpour colorer certaines sections du texte.



Il existe de nombreux types de plages, mais il est généralement utilisé pour repeindre du texte ForegroundColorSpan.



Alors commençons!



Le résultat correspond exactement aux attentes jusqu'au moment où nous commençons à éditer un gros fichier (dans la capture d'écran un fichier de ~ 1000 lignes)



Le fait est que la méthode setSpanfonctionne lentement, en chargeant fortement le thread d'interface utilisateur, et étant donné que la méthode afterTextChangedest appelée après chaque caractère entré, elle devient un tourment.



Trouver une solution



La première chose qui me vient à l'esprit est d'effectuer une opération lourde en arrière-plan. Mais ici, c'est une opération difficile setSpandans tout le texte, pas des lignes régulières. (Je pense que je n'ai pas besoin d'expliquer pourquoi il est impossible d'appeler à setSpanpartir d'un fil d'arrière-plan).



Après un peu de recherche d'articles thématiques, nous découvrons que si nous voulons obtenir de la douceur, nous devrons mettre en évidence uniquement la partie visible du texte.



Exactement! Alors faisons-le! Juste comment?



Optimisation



Bien que j'aie mentionné que nous ne sommes préoccupés que par les performances de la méthode setSpan, je recommande toujours de placer RegEx dans le thread d'arrière-plan pour obtenir une fluidité maximale.



Nous avons besoin d'une classe qui traitera tout le texte en arrière-plan et retournera une liste d'étendues.

Je ne donnerai pas d'implémentation spécifique, mais si quelqu'un est intéressé, j'utilise celui qui AsyncTaskfonctionne ThreadPoolExecutor. (Oui, oui, AsyncTask en 2020) L'



essentiel pour nous est que la logique suivante soit exécutée:



  1. La tâche d' beforeTextChanged arrêt qui analyse le texte
  2. En cours d' afterTextChanged exécution Tâche qui analyse le texte
  3. À la fin de son travail, Task doit renvoyer la liste des travées dans TextProcessor, qui, à son tour, ne mettra en évidence que la partie visible


Et oui, les travées écriront également les leurs:



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

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


Ainsi, le code de l'éditeur se transforme en quelque chose comme ceci:



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




Comme je n'ai pas montré l'implémentation spécifique du traitement en arrière-plan, imaginons que nous en ayons écrit un JavaScriptStylerqui fera tout en arrière-plan que nous faisions auparavant dans le fil UI - parcourez tout le texte à la recherche de correspondances et remplissez la liste des plages, et à la fin son travail rendra le résultat setSpansCallback. A ce moment, une méthode sera lancée updateSyntaxHighlightingqui parcourra la liste des travées et n'affichera que celles qui sont actuellement visibles à l'écran.



Comment comprendre quel texte tombe dans la zone visible?



Je ferai référence à cet article , où l'auteur suggère d'utiliser quelque chose comme ceci:



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


Et il fonctionne! Maintenant, déplaçons- topVisibleLinele bottomVisibleLinevers des méthodes séparées et ajoutons quelques vérifications supplémentaires, au cas où quelque chose ne va pas:



De nouvelles méthodes
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
}




La dernière chose à faire est de parcourir la liste des travées et de colorer le texte:



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


Ne soyez pas alarmé par l'effrayant if'mais, il vérifie simplement si la portée de la liste tombe dans la zone visible.



Eh bien, ça marche?



Cela fonctionne, uniquement lorsque l'édition des plages de texte ne sont pas mises à jour, vous pouvez corriger la situation en effaçant le texte de toutes les plages avant d'en appliquer de nouvelles:



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


Un autre montant - après avoir fermé le clavier, un morceau de texte reste éteint, corrigez-le:



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


L'essentiel est de ne pas oublier d'indiquer adjustResizedans le manifeste.



Défilement



En parlant de défilement, je reviendrai sur cet article . L'auteur propose d'attendre 500ms après la fin du défilement, ce qui contredit mon sens de la beauté. Je ne veux pas attendre que le rétroéclairage se charge, je veux voir le résultat instantanément.



De plus, l'auteur fait valoir que lancer l'analyseur après chaque pixel «défilé» coûte cher, et je suis tout à fait d'accord avec cela (généralement je vous recommande de lire entièrement son article, il est petit, mais il y a beaucoup de choses intéressantes). Mais le fait est que nous avons déjà une liste d’étapes prédéfinies et que nous n’avons pas besoin de lancer l’analyseur.



Il suffit d'appeler la méthode responsable de la mise à jour du rétroéclairage:



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


Numérotation des lignes



Si nous en ajoutons un de plus au balisage, TextViewil sera problématique de les lier (par exemple, mettre à jour de manière synchrone la taille du texte), et même si nous avons un gros fichier, vous devrez mettre à jour complètement le texte avec des chiffres après chaque lettre saisie, ce qui n'est pas très cool. Par conséquent, nous utiliserons tous les moyens standards CustomView- dessin sur Canvasdans onDraw, il est rapide et pas difficile.



Tout d'abord, définissons ce que nous allons dessiner:



  • Numéros de ligne
  • La ligne verticale séparant le champ de saisie des numéros de ligne


Vous devez d'abord calculer et régler à paddinggauche de l'éditeur afin qu'il n'y ait pas de conflits avec le texte imprimé.



Pour ce faire, nous allons écrire une fonction qui mettra à jour le retrait avant de dessiner:



Mise à jour de l'indentation
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)
    }
}




Explication:



Pour commencer, nous trouvons le nombre de lignes dans EditText(à ne pas confondre avec le nombre de " \n" dans le texte), et prenons le nombre de caractères de ce nombre. Par exemple, si nous avons 100 lignes, la variable gutterDigitCountsera égale à 3, car il y a exactement 3 caractères sur 100. Mais disons que nous n'avons qu'une seule ligne - ce qui signifie qu'un retrait de 1 caractère apparaîtra visuellement petit, et pour cela, nous utilisons la variable count pour définir le retrait minimum affiché de 3 caractères, même si nous avons moins de 100 lignes de code.



Cette partie était la plus déroutante de toutes, mais si vous la lisez attentivement plusieurs fois (en regardant le code), tout deviendra clair.



Ensuite, nous définissons l'indentation après avoir calculé widestNumberet widestWidth.



Commençons à dessiner



Malheureusement, si nous voulons utiliser l'habillage de texte Android standard sur une nouvelle ligne, nous devrons évoquer, ce qui nous prendra beaucoup de temps et encore plus de code, ce qui sera suffisant pour un article entier, donc, afin de réduire votre temps (et le temps du modérateur habr), nous activerons l'horizontale défilement pour que toutes les lignes se succèdent:



setHorizontallyScrolling(true)


Eh bien, maintenant vous pouvez commencer à dessiner, déclarer des variables de type Paint:



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


Quelque part dans le initbloc, définissez la couleur du texte et la couleur du séparateur. Il est important de se rappeler que si vous changez la police du texte, alors la police Paintdevra être appliquée manuellement, pour cela je vous conseille de remplacer la méthode setTypeface. De même avec la taille du texte.



Redéfinissez ensuite la méthode 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
    )
}


Nous regardons le résultat



Ça a l'air cool.



Qu'avons-nous fait onDraw? Avant d'appeler la superméthode, nous avons mis à jour l'indentation, après quoi nous avons dessiné les numéros uniquement dans la zone visible, et à la fin nous avons dessiné une ligne verticale séparant visuellement la numérotation des lignes de l'éditeur de code.



Pour la beauté, vous pouvez également repeindre le retrait dans une couleur différente, mettre en évidence visuellement la ligne sur laquelle se trouve le curseur, mais je laisserai cela à votre discrétion.



Conclusion



Dans cet article, nous avons écrit un éditeur de code réactif avec coloration syntaxique et numérotation des lignes, et dans la partie suivante, nous ajouterons une complétion de code pratique et une coloration syntaxique juste pendant l'édition.



Je laisserai également un lien vers les sources de mon éditeur de code sur GitHub , où vous trouverez non seulement les fonctionnalités que j'ai décrites dans cet article, mais aussi bien d'autres qui ont été laissées sans attention.



UPD: La deuxième partie est déjà sortie:



posez des questions et proposez des sujets de discussion, car j'aurais bien pu manquer quelque chose.



Remercier!



All Articles