Androidコードエディター:パート1



コードエディターでの作業を終える前に、レーキを何度も踏んで、おそらく数十の同様のアプリケーションを逆コンパイルしました。このシリーズの記事では、私が学んだこと、どんな間違いを避けることができるか、その他多くの興味深いことについて説明します。



前書き



皆さんこんにちは!タイトルから判断すると、それがどうなるかは非常に明確ですが、コードに移る前に、自分の言葉をいくつか挿入する必要があります。



私は記事を2つの部分に分けることにしました。最初は、最適化された構文の強調表示と行番号付けを段階的に記述し、2番目はコード補完とエラーの強調表示を追加します。



まず、エディターが実行できる内容のリストを作成しましょう。



  • 構文の強調表示
  • 行番号を表示
  • オートコンプリートのオプションを表示します(後半で説明します)
  • 構文エラーを強調表示します(後半で説明します)。


これは、最新のコードエディターに必要な機能の完全なリストではありませんが、この短いシリーズの記事で説明したいものです。



MVP-シンプルテキストエディター



この段階では問題はないはずです。EditText画面全体に拡大しgravity透明background表示してバーを下から削除します。フォントサイズ、テキストの色など。私は視覚的な部分から始めるのが好きなので、アプリケーションに何が欠けているのか、それでも作業する価値がある詳細を理解しやすくなります。



この段階で、メモリへのファイルのロード/保存も行いました。コードは提供しません。インターネット上のファイルを操作する例は豊富にあります。



構文の強調表示



エディターの要件を理解したら、楽しい部分に移りましょう。



明らかに、プロセス全体を制御するには-入力に応答し、行番号を描画するには、から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() {
        //    
    }
}


Q:TextWatcherクラスに直接インターフェイスを実装できるので、なぜ変数として使用するのですか?

A:たまたま、TextWatcher既存のメソッドと競合するメソッドがありTextViewます:



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

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


これらのメソッドの両方が同じ名前と同じ引数を持っている、と彼らは同じ意味を持っているように見えるが、問題があることであるonTextChangedyの方法をされますTextViewと一緒に呼び出されonTextChanged、Y TextWatcherメソッドの本体にログを置くonTextChangedと、2回呼び出されることがわかります





これは、元に戻す/やり直し機能を追加する場合に非常に重要です。また、リスナーが動作しない瞬間が必要になる場合があります。この瞬間に、テキストの変更でスタックをクリアできます。新しいファイルを開いた後に[元に戻す]を押して完全に異なるテキストを取得したくありません。この記事では元に戻す/やり直しについては触れませんが、この点を考慮することが重要です。



したがって、このような状況を回避するには、標準のテキストの代わりに独自のテキスト設定方法を使用できますsetText



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


しかし、ハイライトに戻ります。



多くのプログラミング言語にはRegExのような素晴らしい機能があります。これは、文字列内の一致するテキストを検索できるツールです。遅かれ早かれプログラマはテキストから情報の一部を「引き出す」必要が生じる可能性があるため、少なくとも基本的な機能に慣れることをお勧めします。



ここで、2つのことだけを知ることが重要です。



  1. パターンは、テキスト内で正確に見つける必要があるものを定義します
  2. マッチャーは、パターンで指定したものを見つけようとしてテキストを実行します


正しく記述されていないかもしれませんが、動作原理は以下の通りです。



私は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.lengthmatcher.find()truematcher.start()matcher.end()setSpan



ForegroundColorSpan



それでは始めましょう!



結果は、大きなファイル(スクリーンショットでは〜1000行のファイル)



編集を開始するまでの期待に正確に対応します。実際には、メソッドのsetSpan動作が遅く、UIスレッドの負荷が高く、afterTextChanged入力した各文字の後にメソッドが呼び出されると、 1つの苦痛。



解決策を見つける



最初に頭に浮かぶのは、重い操作をバックグラウンドスレッドに移動することです。しかし、ここでの重い操作setSpanはテキスト全体にあり、レギュラーシーズンではありません。setSpanバックグラウンドスレッドから呼び出すことができない理由を説明する必要はないと思います)。



特集記事を少し検索したところ、滑らかさを実現したい場合は、テキストの表示部分のみを強調表示すればよいことがわかりました



正しい!じゃあやってみよう!ただ...どうやって?



最適化



メソッドのパフォーマンスのみをsetSpan考慮していると述べましたが、RegExの作業をバックグラウンドスレッドに配置して最大のスムーズさを実現することをお勧めします。



バックグラウンドですべてのテキストを処理し、スパンのリストを返すクラスが必要です。

特定の実装は行いませんが、興味がある場合は、で動作する実装を使用AsyncTaskThreadPoolExecutorます。(はい、はい、2020年のAsyncTask)



私たちにとって主なことは、次のロジックが実行されることです。



  1. beforeTextChanged ストップテキストを解析し、タスク
  2. テキストを解析するタスクafterTextChanged 開始します
  3. 作業の最後に、タスクはスパンのリストをに返す必要があります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)


そしてそれはうまくいきます!ここtopVisibleLinebottomVisibleLine、別のメソッドに入れ、何か問題が発生した場合に備えて、いくつかのチェックを追加します。



新しい方法
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、マニフェストで示すことを忘れないことです



スクロール



スクロールについては、この記事をもう一度参照ます。著者は、スクロールの終了後500ms待つことを勧めていますが、これは私の美意識とは矛盾しています。バックライトが読み込まれるのを待ちたくありません。結果を即座に確認したいのです。



著者はまた、各「スクロールされた」ピクセルの後にパーサーを実行することは高価であり、私はこれに完全に同意します(一般的に、私は彼の記事をすべて読むことをお勧めします。小さいですが、興味深いことがたくさんあります)。しかし、実際にはすでにスパンの既製のリストがあり、パーサーを起動する必要はありません



バックライトの更新を担当するメソッドを呼び出すだけで十分です。



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


行番号



マークアップにもう1つ追加する場合、TextViewそれらを一緒にリンクする(たとえば、テキストサイズを同期的に更新する)ことには問題があり、大きなファイルがある場合でも、各文字を入力した後に数字でテキストを完全に更新する必要がありますが、これはあまりクールではありません。したがって、標準的な手段を使用しますCustomView- を利用するCanvasonDraw、高速で難しくありません。



まず、何を描くかを定義しましょう:



  • 行番号
  • 入力フィールドと行番号を区切る垂直線


最初に計算し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行ある場合、gutterDigitCount100という数値には3文字しかないため、変数は3になります。ただし、1行しかないとしましょう。つまり、1文字のインデントは視覚的に小さく表示されます。このために、変数countを使用して、100行未満のコードでも、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: 2番目の部分はすでに出ています。



何かを見逃していた可能性があるので、質問してトピックについて提案してください。



感謝!



All Articles