Editor kode Android: bagian 2



Jadi waktunya telah tiba untuk publikasi bagian kedua, hari ini kita akan terus mengembangkan kode editor dan menambahkan auto-completion and error menyoroti untuk itu, dan juga berbicara tentang mengapa setiap editor kode EditTexttidak akan tertinggal.



Sebelum membaca lebih lanjut, saya sangat menyarankan Anda membaca bagian pertama .



pengantar



Pertama, mari kita ingat di mana kita tinggalkan di bagian terakhir . Kami menulis penyorotan sintaks yang dioptimalkan yang mem-parsing teks di latar belakang dan hanya warna bagian yang terlihat, serta menambahkan penomoran baris (meskipun tanpa jeda baris android, tapi masih).



Pada bagian ini kami akan menambahkan penyempurnaan kode dan penyorotan kesalahan.



Pelengkapan kode



Pertama, mari kita bayangkan bagaimana cara kerjanya:



  1. Pengguna menulis kata
  2. Setelah memasukkan karakter N pertama, sebuah jendela muncul dengan tips
  3. Ketika Anda mengklik petunjuk itu, kata itu secara otomatis "dicetak"
  4. Jendela dengan petunjuk ditutup dan kursor dipindahkan ke akhir kata
  5. Jika pengguna memasukkan kata yang ditampilkan di tooltip sendiri, jendela dengan petunjuk harus ditutup secara otomatis


Bukankah itu terlihat seperti apa? Android sudah memiliki komponen dengan logika yang persis sama - MultiAutoCompleteTextViewjadi PopupWindowkita tidak perlu menulis kruk dengan kita (sudah dituliskan untuk kita).



Langkah pertama adalah mengubah induk dari kelas kami:



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


Sekarang kita perlu menulis ArrayAdapteryang akan menampilkan hasil yang ditemukan. Kode adaptor lengkap tidak akan tersedia, contoh implementasi dapat ditemukan di Internet. Tetapi saya akan berhenti pada saat ini dengan penyaringan.



Untuk ArrayAdapterdapat memahami petunjuk apa yang perlu ditampilkan, kita perlu mengganti metode 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()
        }
    }
}


Dan dalam metodenya, performFilteringisi daftar suggestionskata berdasarkan kata yang mulai dimasukkan pengguna (terkandung dalam variabel constraint).



Di mana mendapatkan data sebelum memfilter?



Semuanya tergantung pada Anda - Anda dapat menggunakan semacam juru bahasa untuk memilih hanya opsi yang valid, atau memindai seluruh teks saat Anda membuka file. Sebagai contoh sederhana, saya akan menggunakan daftar opsi penyelesaian otomatis yang sudah jadi:



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
}


Logika penyaringan di sini agak primitif, kita menelusuri seluruh daftar dan, mengabaikan kasus, membandingkan awal string.



Menginstal adaptor, menulis teks - tidak berfungsi. Apa yang salah? Pada tautan pertama di Google, kami menemukan jawaban yang mengatakan kami lupa menginstal Tokenizer.



Untuk apa Tokenizer?



Secara sederhana, ada Tokenizerbaiknya untuk MultiAutoCompleteTextViewmemahami setelah karakter yang dimasukkan input kata dapat dianggap lengkap. Ini juga memiliki implementasi yang siap pakai dalam bentuk CommaTokenizermemisahkan kata menjadi koma, yang dalam hal ini tidak cocok untuk kita.



Yah, karena CommaTokenizerkita tidak puas, maka kita akan menulis sendiri:



Tokenizer Khusus
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
}




Mari kita cari tahu:

TOKEN - string dengan karakter yang memisahkan satu kata dari yang lain. Dalam metode findTokenStartdan findTokenEndkami pergi melalui teks untuk mencari simbol yang sangat terpisah ini. Metode ini terminateTokenmemungkinkan Anda untuk mengembalikan hasil yang dimodifikasi, tetapi kami tidak membutuhkannya, jadi kami hanya mengembalikan teks yang tidak berubah.



Saya juga lebih suka menambahkan penundaan input 2 karakter sebelum menampilkan daftar:



textProcessor.threshold = 2


Instal, jalankan, tulis teks - itu berfungsi! Tetapi untuk beberapa alasan jendela dengan prompt berperilaku aneh - itu ditampilkan dalam lebar penuh, tingginya kecil, dan secara teori itu akan muncul di bawah kursor, bagaimana kita memperbaikinya?



Memperbaiki kelemahan visual



Di sinilah kesenangan dimulai, karena API memungkinkan kita untuk mengubah tidak hanya ukuran jendela, tetapi juga posisinya.



Pertama, mari kita putuskan ukurannya. Menurut pendapat saya, opsi yang paling mudah adalah jendela setengah tinggi dan lebar layar, tetapi karena ukuran kami Viewberubah tergantung pada kondisi keyboard, kami akan memilih ukuran dalam metode 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
}


Terlihat lebih baik, tetapi tidak banyak. Kami ingin mencapai bahwa jendela muncul di bawah kursor dan bergerak dengannya selama pengeditan.



Jika semuanya cukup sederhana dengan bergerak di sepanjang X - kita mengambil koordinat awal surat dan menetapkan nilai ini dropDownHorizontalOffset, maka memilih ketinggian akan lebih sulit.



Google tentang properti font, Anda dapat menemukan posting ini . Gambar yang dilampirkan penulis dengan jelas menunjukkan properti apa yang bisa kita gunakan untuk menghitung koordinat vertikal.



Berdasarkan gambar, Baseline adalah yang kita butuhkan. Pada level inilah sebuah jendela dengan opsi pelengkapan otomatis akan muncul.



Sekarang mari kita menulis metode yang akan kita panggil ketika teks berubah menjadi 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
}


Tampaknya mereka belum melupakan apa pun - offset X berfungsi, tetapi offset Y dihitung secara salah. Ini karena kami tidak menentukan dropDownAnchordalam markup:



android:dropDownAnchor="@id/toolbar"


Dengan menentukan Toolbarkualitas, dropDownAnchorkami memberi tahu widget bahwa daftar drop-down akan ditampilkan di bawahnya.



Sekarang, jika kita mulai mengedit teks, semuanya akan berfungsi, tetapi seiring waktu kita akan melihat bahwa jika jendela tidak pas di bawah kursor, itu diseret ke atas dengan lekukan besar, yang terlihat jelek. Saatnya menulis kruk:



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
}


Kita tidak perlu mengubah indentasi jika jumlahnya offsetVertical + dropDownHeightkurang dari tinggi layar yang terlihat, karena dalam hal ini jendela ditempatkan di bawah kursor. Tetapi jika masih lebih, maka kita kurangi dari indentasi dropDownHeight- jadi itu akan pas dengan kursor tanpa indentasi besar yang ditambahkan widget itu sendiri.



PS Anda dapat melihat keyboard berkedip pada gif, dan sejujurnya, saya tidak tahu cara memperbaikinya, jadi jika Anda punya solusi, tulis.



Menyoroti kesalahan



Dengan penyorotan kesalahan, semuanya jauh lebih sederhana daripada yang terlihat, karena kita sendiri tidak dapat secara langsung mendeteksi kesalahan sintaksis dalam kode - kita akan menggunakan parser pustaka pihak ketiga. Karena saya sedang menulis editor untuk JavaScript, pilihan saya jatuh pada Rhino , mesin JavaScript yang populer yang telah teruji waktu dan masih didukung.



Bagaimana kita menguraikannya?



Meluncurkan Rhino adalah operasi yang cukup rumit, jadi menjalankan parser setelah setiap karakter dimasukkan (seperti yang kami lakukan dengan menyoroti) bukanlah pilihan sama sekali. Untuk mengatasi masalah ini, saya akan menggunakan pustaka RxBinding , dan bagi mereka yang tidak ingin menyeret RxJava ke dalam proyek, Anda dapat mencoba opsi serupa .



Operator debounceakan membantu kami mencapai apa yang kami inginkan, dan jika Anda tidak terbiasa dengannya, saya menyarankan Anda untuk membaca artikel ini .



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


Sekarang mari kita menulis model yang akan dikembalikan oleh parser kepada kita:



data class ParseResult(val exception: RhinoException?)


Saya sarankan menggunakan logika berikut: jika tidak ada kesalahan ditemukan, maka exceptionakan ada null. Kalau tidak, kita akan mendapatkan objek RhinoExceptionyang berisi semua informasi yang diperlukan - nomor baris, pesan kesalahan, StackTrace, dll.



Sebenarnya, parsing itu sendiri:



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


Memahami:

Yang paling penting di sini adalah metode evaluateString- memungkinkan Anda untuk menjalankan kode yang kami berikan sebagai string sourceCode. Nama fileNamefile ditunjukkan di - itu akan ditampilkan dalam kesalahan, unit adalah nomor baris untuk mulai menghitung, argumen terakhir adalah domain keamanan, tetapi kami tidak membutuhkannya, jadi kami atur null.



optimasi Tingkat dan maksimumInterpreterStackDepth



Parameter optimizationLeveldengan nilai dari 1 hingga 9 memungkinkan Anda untuk mengaktifkan "optimisasi" kode tertentu (analisis aliran data, analisis aliran tipe, dll.), Yang akan mengubah pemeriksaan kesalahan sintaksis sederhana menjadi operasi yang sangat memakan waktu, dan kami tidak membutuhkannya.



Jika Anda menggunakannya dengan nilai 0 , maka semua "optimasi" ini tidak akan diterapkan, namun, jika saya memahaminya dengan benar, Rhino masih akan menggunakan beberapa sumber daya yang tidak diperlukan untuk pemeriksaan kesalahan sederhana, yang artinya tidak cocok untuk kami.



Hanya ada nilai negatif - dengan menentukan -1 kita mengaktifkan mode "juru bahasa", yang persis seperti yang kita butuhkan. The dokumentasi mengatakan bahwa ini adalah cara tercepat dan paling ekonomis untuk menjalankan Rhino.



Parameter ini maximumInterpreterStackDepthmemungkinkan Anda membatasi jumlah panggilan rekursif.



Mari kita bayangkan apa yang terjadi jika Anda tidak menentukan parameter ini:



  1. Pengguna akan menulis kode berikut:



    function recurse() {
        recurse();
    }
    recurse();
    
  2. Badak akan menjalankan kodenya, dan dalam satu detik aplikasi kita akan crash OutOfMemoryError. Tamat.


Menampilkan kesalahan



Seperti yang saya katakan sebelumnya, segera setelah kami mendapatkan yang ParseResultberisi RhinoException, kami akan memiliki semua data yang diperlukan untuk ditampilkan, termasuk nomor baris - kita hanya perlu memanggil metode lineNumber().



Sekarang mari kita tulis rentang garis berlekuk merah yang saya salin ke StackOverflow . Ada banyak kode, tetapi logikanya sederhana - gambar dua garis merah pendek pada sudut yang berbeda.



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




Sekarang Anda dapat menulis metode untuk memasang bentang pada baris masalah:



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


Penting untuk diingat bahwa karena hasilnya disertai dengan penundaan, pengguna mungkin punya waktu untuk menghapus beberapa baris kode, dan kemudian itu lineNumberternyata tidak valid.



Karena itu, agar tidak mendapatkannya, IndexOutOfBoundsExceptionkami menambahkan cek di awal. Nah, kemudian, sesuai dengan skema yang umum, kita menghitung karakter pertama dan terakhir dari string, setelah itu kita mengatur rentang.



Hal utama adalah jangan lupa untuk menghapus teks dari rentang yang sudah diatur di afterTextChanged:



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


Mengapa editor kode ketinggalan?



Dalam dua artikel, kami menulis editor kode yang baik yang diwarisi dari EditTextdan MultiAutoCompleteTextView, tetapi kami tidak dapat membanggakan kinerja ketika bekerja dengan file besar.



Jika Anda membuka TextView.java yang sama untuk 9k + baris kode, maka setiap editor teks yang ditulis sesuai dengan prinsip yang sama dengan kita akan ketinggalan.



T: Mengapa QuickEdit tidak ketinggalan?

A: Karena di bawah tenda, itu tidak menggunakan EditText, juga tidak TextView.



Baru-baru ini, editor kode di CustomView mendapatkan popularitas ( di sana - sini , baik, atau di sana - sini), ada banyak dari mereka). Secara historis, TextView memiliki terlalu banyak logika berlebihan yang tidak perlu editor kode. Hal pertama yang terlintas dalam pikiran adalah IsiOtomatis , Emoji , Drawable Drawables , tautan yang dapat diklik , dll.



Jika saya mengerti dengan benar, para penulis perpustakaan hanya menyingkirkan semua ini, sebagai akibatnya mereka mendapat editor teks yang mampu bekerja dengan file sejuta baris tanpa banyak memuat pada Thread UI. (Meskipun saya mungkin salah sebagian, saya tidak mengerti banyak sumber)



Ada pilihan lain, tetapi menurut saya kurang menarik - editor kode di WebView ( di sana - sini, ada banyak juga). Saya tidak suka mereka karena UI di WebView terlihat lebih buruk daripada yang asli, dan mereka juga kalah dari editor di CustomView dalam hal kinerja.



Kesimpulan



Jika tugas Anda adalah menulis editor kode dan mencapai puncak Google Play, jangan buang waktu dan ambil perpustakaan yang sudah jadi di CustomView. Jika Anda ingin mendapatkan pengalaman unik, tulis semuanya sendiri menggunakan widget asli.



Saya juga akan meninggalkan tautan ke kode sumber editor kode saya di GitHub , di sana Anda tidak hanya akan menemukan fitur-fitur yang saya ceritakan di dua artikel ini, tetapi juga banyak lainnya yang dibiarkan tanpa perhatian.



Terima kasih!



All Articles