Android Code Editor: Teil 1



Bevor ich die Arbeit an meinem Code-Editor beendet habe, bin ich viele Male auf einen Rechen getreten, wahrscheinlich Dutzende ähnlicher Anwendungen dekompiliert, und in dieser Artikelserie werde ich darüber sprechen, was ich gelernt habe, welche Fehler vermieden werden können und viele andere interessante Dinge.



Einführung



Hallo an alle! Dem Titel nach zu urteilen, ist ziemlich klar, worum es geht, aber ich muss noch ein paar eigene Wörter einfügen, bevor ich zum Code übergehe.



Ich habe beschlossen, den Artikel in zwei Teile zu unterteilen. Im ersten Teil werden wir schrittweise eine optimierte Syntaxhervorhebung und Zeilennummerierung schreiben, und im zweiten Teil werden wir die Code-Vervollständigung und die Fehlerhervorhebung hinzufügen.



Lassen Sie uns zunächst eine Liste erstellen, wozu unser Editor in der Lage sein sollte:



  • Satzstellung markieren
  • Zeilennummerierung anzeigen
  • Optionen für die automatische Vervollständigung anzeigen (ich werde es Ihnen im zweiten Teil sagen)
  • Markieren Sie Syntaxfehler (ich werde im zweiten Teil erzählen)


Dies ist nicht die ganze Liste der Eigenschaften, die ein moderner Code-Editor haben sollte, aber genau darüber möchte ich in dieser kleinen Artikelserie sprechen.



MVP - Einfacher Texteditor



Zu diesem Zeitpunkt sollte es keine Probleme geben - wir strecken es EditTextauf Vollbild, zeigen gravitytransparent backgroundan, um den Balken von unten zu entfernen, Schriftgröße, Textfarbe usw. Ich beginne gerne mit dem visuellen Teil, damit ich leichter verstehen kann, was in der Anwendung fehlt und an welchen Details noch gearbeitet werden muss.



Zu diesem Zeitpunkt habe ich auch Dateien in den Speicher geladen / gespeichert. Ich werde den Code nicht geben, es gibt eine Fülle von Beispielen für die Arbeit mit Dateien im Internet.



Satzstellung markieren



Sobald wir mit den Anforderungen an den Editor vertraut sind, ist es Zeit, mit dem unterhaltsamen Teil fortzufahren.



Um den gesamten Prozess zu steuern - um auf Eingaben zu reagieren und Zeilennummern zu zeichnen, müssen wir natürlich das CustomViewErben von schreiben EditText. Wir werfen ein TextWatcher, um Änderungen im Text zu hören und überschreiben die Methode, afterTextChangedmit der wir die Methode aufrufen, die für das Hervorheben verantwortlich ist:



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


F: Warum verwenden wir es TextWatcherals Variable, weil Sie die Schnittstelle direkt in der Klasse implementieren können?

A: Es kommt einfach so vor, dass wir TextWatchereine Methode haben, die mit einer vorhandenen Methode in Konflikt steht TextView:



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

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


Beide Methoden haben denselben Namen und dieselben Argumente und scheinen dieselbe Bedeutung zu haben. Das Problem ist jedoch, dass die onTextChangedy- Methode TextViewzusammen mit onTextChangedy aufgerufen wird TextWatcher. Wenn wir Protokolle in den Hauptteil der Methode einfügen, werden wir sehen, dass sie onTextChangedzweimal aufgerufen wird:





Dies ist sehr wichtig, wenn wir die Funktion "Rückgängig / Wiederherstellen" hinzufügen möchten. Außerdem benötigen wir möglicherweise einen Moment, in dem Listener nicht funktionieren, in dem wir den Stapel mit Textänderungen löschen können. Wir möchten nicht in der Lage sein, nach dem Öffnen einer neuen Datei auf Rückgängig zu klicken und einen völlig anderen Text zu erhalten. Obwohl in diesem Artikel nicht über Rückgängig / Wiederherstellen gesprochen wird, ist es wichtig, diesen Punkt zu berücksichtigen.



Um eine solche Situation zu vermeiden, können Sie daher anstelle der Standardmethode Ihre eigene Textinstallationsmethode verwenden setText:



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


Aber zurück zum Highlight.



Viele Programmiersprachen haben so etwas Wunderbares wie RegEx . Mit diesem Tool können Sie nach Textübereinstimmungen in einer Zeichenfolge suchen. Ich empfehle, dass Sie sich zumindest mit den grundlegenden Funktionen vertraut machen, da früher oder später jeder Programmierer möglicherweise einige Informationen aus dem Text "ziehen" muss.



Jetzt ist es wichtig, dass wir nur zwei Dinge wissen:



  1. Das Muster bestimmt, was genau wir im Text finden müssen
  2. Matcher durchläuft den Text, um herauszufinden, was wir im Muster angegeben haben


Vielleicht nicht ganz richtig beschrieben, aber das Funktionsprinzip ist wie folgt.



Da ich einen Editor für JavaScript schreibe, ist hier ein kleines Muster mit Sprachschlüsselwörtern:



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


Natürlich sollte es hier viel mehr Wörter geben, und wir brauchen auch Muster für Kommentare, Zeilen, Zahlen usw. Meine Aufgabe ist es jedoch, das Prinzip zu demonstrieren, nach dem Sie den gewünschten Inhalt im Text finden können.



Als nächstes werden wir mit Hilfe von Matcher den gesamten Text durchgehen und die Bereiche festlegen:



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


Lassen Sie mich erklären: Wir erhalten das Matcher- Objekt von Pattern und geben ihm den Bereich an, in dem nach Zeichen gesucht werden soll (Dementsprechend ist von 0 bis text.lengthdies der gesamte Text). Ferner wird der Anruf matcher.find()wird zurückkehren , truewenn eine Übereinstimmung im Text gefunden wurde, und mit Hilfe von Anrufen matcher.start()und matcher.end()wir werden die Positionen von Anfang und Ende des Spiels im Text erhalten. Wenn wir diese Daten kennen, können wir die Methode setSpanzum Färben bestimmter Textabschnitte verwenden.



Es gibt viele Arten von Bereichen, die jedoch normalerweise zum Neulackieren von Text verwendet werden ForegroundColorSpan.



So lass uns anfangen!



Das Ergebnis entspricht genau den Erwartungen, bis wir mit der Bearbeitung einer großen Datei beginnen (im Screenshot beträgt die Datei ~ 1000 Zeilen).



Tatsache ist, dass die Methode setSpanlangsam arbeitet und den UI-Thread stark lädt. Wenn die Methode afterTextChangednach jedem eingegebenen Zeichen aufgerufen wird, wird sie eine Qual.



Suche nach einer Lösung



Das erste, was mir in den Sinn kommt, ist, eine schwere Operation in einen Hintergrund-Thread zu verschieben. Aber die schwere Operation hier ist setSpanüberall im Text, nicht in der regulären Saison. (Ich denke, ich muss nicht erklären, warum es unmöglich ist, setSpanvon einem Hintergrund-Thread aus aufzurufen ).



Nach einigem Suchen nach Feature-Artikeln stellen wir fest, dass wir nur den sichtbaren Teil des Textes hervorheben müssen , wenn wir Glätte erzielen möchten .



Richtig! Machen wir das! Aber wie?



Optimierung



Obwohl ich erwähnt habe, dass uns nur die Leistung der Methode setSpanam Herzen liegt, empfehle ich dennoch, die RegEx-Arbeit in einen Hintergrund-Thread zu stellen, um maximale Glätte zu erzielen.



Wir brauchen eine Klasse, die den gesamten Text im Hintergrund verarbeitet und eine Liste von Bereichen zurückgibt.

Ich werde keine spezifische Implementierung geben, aber wenn jemand interessiert ist, dann verwende ich eine, die AsyncTaskfunktioniert ThreadPoolExecutor. (Ja, ja, AsyncTask im Jahr 2020) Die



Hauptsache für uns ist, dass die folgende Logik ausgeführt wird:



  1. In beforeTextChanged Stop Task, die den Text analysiert
  2. In afterTextChanged starten wir die Aufgabe, die den Text analysiert
  3. Am Ende seiner Arbeit muss Task die Liste der Bereiche zurückgeben, in TextProcessordenen wiederum nur der sichtbare Teil hervorgehoben wird


Und ja, Spannweiten werden auch ihre eigenen schreiben:



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

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


Der Editorcode wird also folgendermaßen:



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




Da ich die spezifische Implementierung der Verarbeitung nicht im Hintergrund gezeigt habe, stellen wir uns vor, dass wir eine bestimmte geschrieben haben JavaScriptStyler, die alles im Hintergrund tut, was wir zuvor im UI-Thread getan haben - durchlaufen Sie den gesamten Text auf der Suche nach Übereinstimmungen und füllen Sie die Liste der Bereiche aus und am Ende von seiner Arbeit wird das Ergebnis an zurückgeben setSpansCallback. In diesem Moment wird eine Methode gestartet updateSyntaxHighlighting, die die Liste der Bereiche durchläuft und nur diejenigen anzeigt, die derzeit auf dem Bildschirm sichtbar sind.



Wie kann man verstehen, welcher Text in den sichtbaren Bereich fällt?



Ich werde auf diesen Artikel verweisen , in dem der Autor Folgendes vorschlägt:



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


Und es funktioniert! Verschieben wir topVisibleLinees nun bottomVisibleLinein separate Methoden und fügen ein paar zusätzliche Überprüfungen hinzu, falls etwas schief geht:



Neue Methoden
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
}




Als letztes müssen Sie die resultierende Liste der Bereiche durchgehen und den Text einfärben:



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


Hab keine Angst vor dem Schrecklichen if, aber er prüft nur, ob die Spanne von der Liste in den sichtbaren Bereich fällt.



Nun, funktioniert es?



Es funktioniert, aber wenn Sie den Text bearbeiten, werden die Bereiche nicht aktualisiert. Sie können die Situation beheben, indem Sie den Text aus allen Bereichen löschen, bevor Sie neue überlagern:



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


Ein weiterer Pfosten - nach dem Schließen der Tastatur bleibt ein Textstück unbeleuchtet. Korrigieren Sie ihn:



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


Die Hauptsache ist nicht zu vergessen, adjustResizeim Manifest anzuzeigen .



Scrollen



Wenn ich über das Scrollen spreche, werde ich noch einmal auf diesen Artikel verweisen . Der Autor schlägt vor, 500 ms nach dem Ende des Bildlaufs zu warten, was meinem Sinn für Schönheit widerspricht. Ich möchte nicht warten, bis die Hintergrundbeleuchtung geladen ist, sondern das Ergebnis sofort sehen.



Der Autor argumentiert auch, dass das Ausführen des Parsers nach jedem "gescrollten" Pixel kostspielig ist, und ich stimme dem vollkommen zu (im Allgemeinen empfehle ich, dass Sie seinen Artikel vollständig lesen, er ist klein, aber es gibt viele interessante Dinge). Tatsache ist jedoch, dass wir bereits eine vorgefertigte Liste von Bereichen haben und den Parser nicht ausführen müssen.



Es reicht aus, die Methode aufzurufen, die für die Aktualisierung der Hintergrundbeleuchtung verantwortlich ist:



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


Zeilennummerierung



Wenn wir dem Markup ein weiteres hinzufügen, ist TextViewes problematisch, sie miteinander zu verknüpfen (z. B. um die Textgröße synchron zu aktualisieren), und wenn wir eine große Datei haben, müssen wir den Text nach jedem eingegebenen Buchstaben vollständig mit Zahlen aktualisieren, was nicht sehr cool ist. Deshalb werden wir alle Standardmittel verwenden CustomView- Zeichnung auf Canvasin onDraw, es ist schnell und nicht schwer.



Definieren wir zunächst, was wir zeichnen werden:



  • Linien Nummern
  • Die vertikale Linie, die das Eingabefeld von den Zeilennummern trennt


Zuerst müssen Sie paddinglinks vom Editor berechnen und installieren, damit keine Konflikte mit dem gedruckten Text auftreten.



Dazu schreiben wir eine Funktion, die den Einzug vor dem Rendern aktualisiert:



Einrückung aktualisieren
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)
    }
}




Erläuterung:



Zuerst ermitteln wir die Anzahl der Zeilen in EditText(nicht zu verwechseln mit der Anzahl " \n" im Text) und nehmen die Anzahl der Zeichen aus dieser Anzahl. Wenn wir beispielsweise 100 Zeilen haben, ist die Variable gutterDigitCountgleich 3, da 100 genau 3 Zeichen enthält. Angenommen, wir haben nur 1 Zeile, was bedeutet, dass ein Einzug mit 1 Zeichen visuell klein erscheint. Dazu verwenden wir die Zählvariable, um den minimal angezeigten Einzug mit 3 Zeichen festzulegen, selbst wenn wir weniger als 100 Codezeilen haben.



Dieser Teil war der verwirrendste von allen, aber wenn Sie ihn mehrmals nachdenklich gelesen haben (indem Sie sich den Code ansehen), wird alles klar.



Als nächstes setzen Sie den Einzug durch Vorberechnung widestNumberund widestWidth.



Beginnen wir mit dem Zeichnen



Wenn wir den Standard-Android-Textumbruch in eine neue Zeile verwenden möchten, müssen wir leider zaubern, was uns viel Zeit und noch mehr Code kostet, was für einen ganzen Artikel ausreicht, um Ihre Zeit (und die Zeit des habr-Moderators) zu reduzieren. Wir aktivieren horizontal Scrollen, damit alle Zeilen nacheinander verlaufen:



setHorizontallyScrolling(true)


Nun können Sie mit dem Zeichnen beginnen und Variablen mit dem folgenden Typ deklarieren Paint:



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


initStellen Sie die Textfarbe und die Trennfarbe irgendwo im Block ein. Es ist wichtig zu beachten, dass beim Ändern der Schriftart des Textes die Schriftart Paintmanuell angewendet werden muss. Ich empfehle Ihnen daher, die Methode zu überschreiben setTypeface. Ebenso mit der Größe des Textes.



Dann überschreiben wir die Methode 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
    )
}


Wir schauen uns das Ergebnis an



Es sieht cool aus.



Was haben wir gemacht onDraw? Vor dem Aufrufen der super-method haben wir den Einzug aktualisiert, danach die Zahlen nur im sichtbaren Bereich gerendert und am Ende eine vertikale Linie gezeichnet, die die Zeilennummerierung visuell vom Code-Editor trennt.



Aus Gründen der Schönheit können Sie den Einzug auch in einer anderen Farbe neu streichen und die Linie, auf der sich der Cursor befindet, visuell hervorheben. Dies überlasse ich jedoch Ihrem Ermessen.



Fazit



In diesem Artikel haben wir einen reaktionsschnellen Code-Editor mit Syntaxhervorhebung und Zeilennummerierung geschrieben. Im nächsten Teil werden wir direkt während der Bearbeitung eine bequeme Code-Vervollständigung und Syntax-Hervorhebung hinzufügen.



Ich werde auch einen Link zu den Quellen meines Code-Editors auf GitHub hinterlassen. Dort finden Sie nicht nur die Funktionen, die ich in diesem Artikel beschrieben habe, sondern auch viele andere, die nicht beachtet wurden.



UPD: Der zweite Teil ist bereits erschienen.



Stellen Sie Fragen und schlagen Sie Diskussionsthemen vor, da ich etwas hätte verpassen können.



Vielen Dank!



All Articles