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



時間は第二部の出版のために来たので、今日は私たちのコードエディタを開発し、それを強調する自動補完やエラーを追加し、またその理由についてお話していきます任意のコードエディタがEditText遅れることはありません。



さらに読む前に、最初の部分を読むことを強くお勧めします



前書き



まず、最後の部分で中断したところを思い出してみましょうバックグラウンドのテキストを解析し、その可視部分のみに色を付ける最適化された構文の強調表示と、追加された行番号(Androidの改行はありませんが、それでも)を書きました。



この部分では、コード補完とエラーの強調表示を追加します。



コード補完



まず、それがどのように機能するかを想像してみましょう:



  1. ユーザーが単語を書く
  2. 最初のN文字を入力すると、ウィンドウにヒントが表示されます
  3. ヒントをクリックすると、単語が自動的に「印刷」されます
  4. ヒントのあるウィンドウが閉じ、カーソルが単語の最後に移動します
  5. ユーザーがツールチップに表示された単語を自分で入力した場合、ヒントのあるウィンドウは自動的に閉じるはずです


何か見えませんか?Androidには、まったく同じロジックを持つコンポーネントが既にあります。MultiAutoCompleteTextViewそのため、松葉杖をPopupWindow私たち一緒に書く必要はありません(これらはすでに作成されています)。



最初のステップは、クラスの親を変更することです。



class TextProcessor @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = R.attr.autoCompleteTextViewStyle
) : MultiAutoCompleteTextView(context, attrs, defStyleAttr)


次にArrayAdapter、見つかった結果を表示するように記述する必要があります。完全なアダプタコードは使用できません。実装の例はインターネットで確認できます。しかし、今はフィルタリングで停止します。



するためにArrayAdapterヒントを表示する必要があるかを理解することができ、我々はメソッドをオーバーライドする必要がありますgetFilter



override fun getFilter(): Filter {
    return object : Filter() {

        private val suggestions = mutableListOf<String>()

        override fun performFiltering(constraint: CharSequence?): FilterResults {
            // ...
        }

        override fun publishResults(constraint: CharSequence?, results: FilterResults) {
            clear() //    
            addAll(suggestions)
            notifyDataSetChanged()
        }
    }
}


そしてメソッドで、ユーザーが入力し始めた単語(変数に含まれている)に基づいて単語performFilteringのリストsuggestionsを入力しますconstraint



フィルタリングする前にどこでデータを取得しますか?



それはすべてあなた次第です。ある種のインタープリターを使用して有効なオプションのみを選択するか、ファイルを開いたときにテキスト全体をスキャンできます。例を簡単にするために、自動補完オプションの既製のリストを使用します。



private val staticSuggestions = mutableListOf(
    "function",
    "return",
    "var",
    "const",
    "let",
    "null"
    ...
)

...

override fun performFiltering(constraint: CharSequence?): FilterResults {
    val filterResults = FilterResults()
    val input = constraint.toString()
    suggestions.clear() //   
    for (suggestion in staticSuggestions) {
        if (suggestion.startsWith(input, ignoreCase = true) && 
            !suggestion.equals(input, ignoreCase = true)) {
            suggestions.add(suggestion)
        }
    }
    filterResults.values = suggestions
    filterResults.count = suggestions.size
    return filterResults
}


ここでのフィルタリングロジックはかなり原始的です。リスト全体を調べ、大文字と小文字を区別せずに文字列の先頭を比較します。



アダプタをインストールし、テキストを書きます-それは動作しません。どうしましたか?Googleの最初のリンクで、インストールを忘れたという答えに出くわしTokenizerます。



Tokenizerとは何ですか?



簡単に言えば、どの入力文字が入力された後、単語入力が完了したと見なすことができるかTokenizerMultiAutoCompleteTextView理解するのに役立ちますまたCommaTokenizer、単語をコンマ区切る形式の既成の実装もあり、この場合は適していません。



さて、CommaTokenizer私たちは満足していないので、私たちは自分で書きます:



カスタムトークナイザー
class SymbolsTokenizer : MultiAutoCompleteTextView.Tokenizer {

    companion object {
        private const val TOKEN = "!@#$%^&*()_+-={}|[]:;'<>/<.? \r\n\t"
    }

    override fun findTokenStart(text: CharSequence, cursor: Int): Int {
        var i = cursor
        while (i > 0 && !TOKEN.contains(text[i - 1])) {
            i--
        }
        while (i < cursor && text[i] == ' ') {
            i++
        }
        return i
    }

    override fun findTokenEnd(text: CharSequence, cursor: Int): Int {
        var i = cursor
        while (i < text.length) {
            if (TOKEN.contains(text[i - 1])) {
                return i
            } else {
                i++
            }
        }
        return text.length
    }

    override fun terminateToken(text: CharSequence): CharSequence = text
}




それを理解しましょう:

TOKEN -ある単語を別の単語から分離する文字列。メソッドfindTokenStartfindTokenEndは、これらの非常に分離した記号を探してテキストを調べます。このメソッドをterminateToken使用すると、変更された結果を返すことができますが、必要ないため、テキストを変更せずに返します。



リストを表示する前に、2文字の入力遅延を追加することも好みます。



textProcessor.threshold = 2


インストール、実行、テキストの書き込み-機能します!しかし、何らかの理由でプロンプトのあるウィンドウが奇妙に動作します-全幅で表示され、高さが小さく、理論的にはカーソルの下に表示されるはずですが、どうすれば修正できますか?



視覚的な欠陥を修正する



ここからがおもしろいところです。APIを使用すると、ウィンドウのサイズだけでなく、その位置も変更できます。



まずはサイズを決めましょう。私の意見では、最も便利なオプションは画面の高さと幅の半分のウィンドウですが、サイズViewはキーボードの状態に応じて変化するため、メソッドでサイズを選択しますonSizeChanged



override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    updateSyntaxHighlighting()
    dropDownWidth = w * 1 / 2
    dropDownHeight = h * 1 / 2
}


それは良く見えますが、それほどではありません。カーソルの下にウィンドウが表示され、編集中にウィンドウが移動するようにします。X



に沿って移動することですべてが非常に単純な場合-文字の先頭の座標を取得しこの値を設定すると、高さの選択がより困難になります。 フォントの特性についてグーグル、あなたはこの投稿に出くわすことができます著者が添付した画像は、垂直座標の計算に使用できるプロパティを明確に示しています。dropDownHorizontalOffset







写真に基づいて、ベースラインは私たちが必要とするものです。オートコンプリートオプションのあるウィンドウが表示されるのはこのレベルです。



次に、テキストが次のように変化したときに呼び出すメソッドを記述しますonTextChanged



private fun onPopupChangePosition() {
    val line = layout.getLineForOffset(selectionStart) //   
    val x = layout.getPrimaryHorizontal(selectionStart) //  
    val y = layout.getLineBaseline(line) //   baseline

    val offsetHorizontal = x + gutterWidth //     
    dropDownHorizontalOffset = offsetHorizontal.toInt()

    val offsetVertical = y - scrollY // -scrollY   ""  
    dropDownVerticalOffset = offsetVertical
}


彼らは何も忘れていないようです-Xオフセットは機能しますが、Yオフセットは正しく計算されません。これはdropDownAnchor、マークアップで指定なかったためです。



android:dropDownAnchor="@id/toolbar"


指定することでToolbar品質を、dropDownAnchor私たちは、ドロップダウンリストが表示されることを知っているウィジェットせ下回ること。



これで、テキストの編集を開始すると、すべてが機能しますが、時間の経過とともに、ウィンドウがカーソルの下に収まらない場合は、醜いように見える大きなインデントで上にドラッグされます。松葉杖を書く時が来ました:



val offset = offsetVertical + dropDownHeight
if (offset < getVisibleHeight()) {
    dropDownVerticalOffset = offsetVertical
} else {
    dropDownVerticalOffset = offsetVertical - dropDownHeight
}

...

private fun getVisibleHeight(): Int {
    val rect = Rect()
    getWindowVisibleDisplayFrame(rect)
    return rect.bottom - rect.top
}


合計がoffsetVertical + dropDownHeight画面の表示可能な高さよりも小さい場合、インデントを変更する必要はありません。この場合、ウィンドウはカーソルの下に配置されているためです。しかし、それ以上の場合は、インデントから差し引きます。そのため、ウィジェット自体が追加する大きなインデントなしでカーソルのdropDownHeightに収まります。PSあなたはgifでキーボードが点滅しているのを見ることができます、そして正直に言うと、私はそれを修正する方法がわからないので、あなたが解決策を持っているなら、書いてください。







エラーの強調表示



エラーの強調表示を使用すると、コード内の構文エラーを直接検出できないため、見た目よりもすべてが単純になります。サードパーティのパーサーライブラリを使用します。私はJavaScriptのエディターを書いているので、長い間テストされ、現在もサポートされている人気のあるJavaScriptエンジンであるRhinoを選びました。



どのように解析しますか?



Rhinoの起動は非常に煩雑な操作であるため、(ハイライトで行ったように)各文字が入力された後にパーサーを実行することはまったく選択肢ではありません。この問題を解決するには、RxBindingライブラリを使用します。RxJavaをプロジェクトにドラッグしたくない場合は、同様のオプションを試すことができます。



オペレーターdebounceは私たちが望むものを達成するのを助けてくれます。もしあなたが彼に慣れていないなら、私はこの記事を読むことを勧めます



textProcessor.textChangeEvents()
    .skipInitialValue()
    .debounce(1500, TimeUnit.MILLISECONDS)
    .filter { it.text.isNotEmpty() }
    .distinctUntilChanged()
    .observeOn(AndroidSchedulers.mainThread())
    .subscribeBy {
        //    
    }
    .disposeOnFragmentDestroyView()


次に、パーサーが返すモデルを作成しましょう。



data class ParseResult(val exception: RhinoException?)


次のロジックを使用することをお勧めしexceptionますnullエラーが見つからない場合、エラーが発生しますそれ以外の場合はRhinoException、必要なすべての情報(行番号、エラーメッセージ、StackTraceなど)を含むオブジェクト取得します



まあ、実際には、解析自体:



//      !
val context = Context.enter() // org.mozilla.javascript.Context
context.optimizationLevel = -1
context.maximumInterpreterStackDepth = 1
try {
    val scope = context.initStandardObjects()

    context.evaluateString(scope, sourceCode, fileName, 1, null)
    return ParseResult(null)
} catch (e: RhinoException) {
    return ParseResult(e)
} finally {
    Context.exit()
}


理解:

ここで最も重要なのはメソッドevaluateStringです。これにより、文字列として渡したコードを実行できますsourceCodefileNameファイル名がで示されている-それはエラーで表示され、ユニットがカウントを開始する行番号で、最後の引数は、セキュリティドメインですが、我々が設定されて、私たちは、それを必要としませんnull



optimizationLevelおよびmaximumInterpreterStackDepth



1から9optimizationLevelまでの値を 持つパラメーター使用すると、特定のコードの「最適化」(データフロー分析、タイプフロー分析など)を有効にできます。これにより、単純な構文エラーチェックが非常に時間のかかる操作になり、私たちはそれを必要としません。 値を0にして使用すると、これらの「最適化」はすべて適用されませんが、私が正しく理解していれば、Rhinoは単純なエラーチェックに不要なリソースの一部を引き続き使用するため、適切ではありません。 負の値のみが残ります。- 1指定することにより、「インタプリタ」モードをアクティブにします。これがまさに私たちが必要とするものです。ドキュメントには、これはRhinoのを実行するための最速かつ最も経済的な方法であると述べています。











パラメータをmaximumInterpreterStackDepth使用すると、再帰呼び出しの数を制限できます。



このパラメーターを指定しないとどうなるかを想像してみましょう。



  1. ユーザーは次のコードを記述します。



    function recurse() {
        recurse();
    }
    recurse();
    
  2. Rhinoがコードを実行し、すぐにアプリケーションがでクラッシュしOutOfMemoryErrorます。終わり。


エラーの表示



私が先に言ったように、できるだけ早く我々が得るとしてParseResult含むものをRhinoException、私たちは、行番号を含む表示に必要なすべてのデータセットを、持っています-私たちはメソッドを呼び出す必要がありますlineNumber()StackOverflow



にコピーした赤い波線のスパンを書きましょうコードはたくさんありますが、ロジックは単純です-異なる角度で2本の短い赤い線を描きます。



ErrorSpan.kt
class ErrorSpan(
    private val lineWidth: Float = 1 * Resources.getSystem().displayMetrics.density + 0.5f,
    private val waveSize: Float = 3 * Resources.getSystem().displayMetrics.density + 0.5f,
    private val color: Int = Color.RED
) : LineBackgroundSpan {

    override fun drawBackground(
        canvas: Canvas,
        paint: Paint,
        left: Int,
        right: Int,
        top: Int,
        baseline: Int,
        bottom: Int,
        text: CharSequence,
        start: Int,
        end: Int,
        lineNumber: Int
    ) {
        val width = paint.measureText(text, start, end)
        val linePaint = Paint(paint)
        linePaint.color = color
        linePaint.strokeWidth = lineWidth

        val doubleWaveSize = waveSize * 2
        var i = left.toFloat()
        while (i < left + width) {
            canvas.drawLine(i, bottom.toFloat(), i + waveSize, bottom - waveSize, linePaint)
            canvas.drawLine(i + waveSize, bottom - waveSize, i + doubleWaveSize, bottom.toFloat(), linePaint)
            i += doubleWaveSize
        }
    }
}




これで、問題のある行にスパンをインストールする方法を書くことができます:



fun setErrorLine(lineNumber: Int) {
    if (lineNumber in 0 until lineCount) {
        val lineStart = layout.getLineStart(lineNumber)
        val lineEnd = layout.getLineEnd(lineNumber)
        text.setSpan(ErrorSpan(), lineStart, lineEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
    }
}


結果には遅延が伴うため、ユーザーは数行のコードを消去する時間lineNumberがあり、無効になる可能性があることに注意してください。



したがって、それを取得しないようにするために、IndexOutOfBoundsException最初にチェックを追加します。それでは、おなじみのスキームに従って、文字列の最初と最後の文字を計算し、その後スパンを設定します。



主なことは、すでに設定されているスパンからテキストをクリアすることを忘れないことですafterTextChanged



fun clearErrorSpans() {
    val spans = text.getSpans<ErrorSpan>(0, text.length)
    for (span in spans) {
        text.removeSpan(span)
    }
}


コードエディターが遅れるのはなぜですか?



2つの記事で、EditTextおよびを継承する優れたコードエディターをMultiAutoCompleteTextView作成しましたが、大きなファイルを処理するときにパフォーマンスを誇ることはできません。



あなたは同じ開くとTextView.javaため9K +コードの行を、その後、任意のテキストエディタは我々が遅れると同じ原理に基づいて書かれました。



Q:なぜQuickEditは遅れないのですか?

A:内部で使用しているため、もも使用していEditTextませんTextView



最近、のCustomView上のコードエディタは(人気を集めているここそこにも、あるいは、ここそこに、たくさんあります)。歴史的に、TextViewにはコードエディターが必要としない冗長なロジックが多すぎます。最初に頭に浮かぶのは、オートフィル絵文字複合ドローアブルクリック可能なリンクなどです。



私が正しく理解していれば、ライブラリの作成者は、これらすべてを単に取り除くだけでした。その結果、UIスレッドに大きな負荷をかけることなく、100万行のファイルを処理できるテキストエディターを手に入れました。 (私は部分的に間違っているかもしれませんが、私はソースをあまり理解していませんでした)



別のオプションがありますが、私の意見ではあまり魅力的ではありません-WebViewのコードエディターあちこちあります)、それらもたくさんあります)。WebViewのUIはネイティブのUIよりも見た目が悪く、パフォーマンスの点でCustomViewのエディターにも負けているため、私はそれらが好きではありません。



結論



コードエディターを作成してGoogle Playのトップに到達することがタスクの場合は、時間を無駄にせず、CustomViewで既成のライブラリーを使用してください。ユニークな体験をしたい場合は、ネイティブウィジェットを使用して自分ですべてを記述してください。



また、コードエディターのソースコードへのリンクをGitHubに残します。この2つの記事で説明した機能だけでなく、注意せずに残された他の多くの機能も見つかります。



感謝!



All Articles