1. 程式人生 > >用Kotlin擼一個自定義字母索引控制元件,效能優化

用Kotlin擼一個自定義字母索引控制元件,效能優化

之前App使用Kotlin重構之後,最大的感觸就是kotlin簡潔的語法以及擴充套件函式等特性極大的提升了我們編寫程式碼的速度。
如果說Java是K、T開頭的普通火車的話,那Kotlin就是D、G開頭的動車高鐵了!
在這裡插入圖片描述

嗯,相信我,去用一用吧,絕對很爽。

好了,開始正文。
今天我們來用kotlin寫一個自定義view,一個很常用的字母索引控制元件。
話不多說,先上圖

在這裡插入圖片描述

我們在聯絡人之類的頁面中經常會見到這中控制元件,網上也有很多輪子,有的是在View中建立的很多TextView實現,有的是用ListView實現等等,各有千秋。個人愚見覺得沒必要弄這麼麻煩,我們直接用畫筆畫就完事兒了,效能上也會好一些。

需求分析

  • 首先我們要有自定義屬性,可以在佈局中設定字型顏色,字型大小等功能
  • 繪製的時候要處理一下padding,雖然正常來說一般不會用到padding
  • 在我們手指按下以及滑動的時候,要把當前字母傳出去,讓呼叫者知道當前手指是在哪個字母上
  • 儘可能的優化效能

實現

需求大概分析清楚後,我們來一步一步實現
首先,肯定要先建立一個類繼承自View,這裡我的類名就叫做LetterIndexView好了
如下程式碼,我們先把三個構造方法給重寫一下

class LetterIndexView(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : View(context, attrs, defStyleAttr) {
    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
    constructor(context: Context) : this(context, null)
   }

自定義屬性和初始化

好了,建立好類之後我們需要宣告自定義view屬性,目前想到的自定義屬性就兩個,一個是字型顏色,一個是字型大小。當然,如果你有其他需求的話,可以自己新增。

如下,
看名字就很清楚了
letterViewTextSize 表示字型大小
letterViewTextColor 表示字型顏色

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="LetterIndexView">
        <attr name="letterViewTextSize" format="dimension"></attr>
        <attr name="letterViewTextColor" format="color"></attr>
    </declare-styleable>
</resources>

好了,自定屬性也完成了

下面就是自定義view的常規套路了
測量,繪製,處理觸控
由於繼承的是View,所以也不需要處理onLayout。

首先,我們來處理自定義屬性以及做一些初始化操作,在init程式碼塊中處理即可
可以看到,我們直接通過'A'..'Z'即可宣告一個從A-Z的區間,如果用java寫的話,那可就沒這麼簡單了

    private var textColor: Int = Color.BLACK
    private var textSize: Float = sp2px(14f)

    private var letters: CharRange
    private var letterHeight: Int = 0


    init {

        var typeArr = context.obtainStyledAttributes(attrs, R.styleable.LetterIndexView)

        /*獲取自定義屬性*/
        textColor = typeArr.getColor(R.styleable.LetterIndexView_letterViewTextColor, textColor)
        textSize = typeArr.getDimension(R.styleable.LetterIndexView_letterViewTextSize, textSize)

        typeArr.recycle()

        /*初始化畫筆*/
        textPaint = Paint(Paint.ANTI_ALIAS_FLAG)
        textPaint.color = textColor
        textPaint.textSize = textSize

        /*字母索引區間*/
        letters = 'A'..'Z'


    }

處理測量

然後,我們來處理測量,注意測量的時候要對padding進行處理
因為後期要處理觸控反饋,我們必須知道當前手指所處於哪個字元上,所以這裡要計算一下字元的高度,後面繪製以及計算位置的時候會用到


    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)

        /*測量寬高度*/
        var width = MeasureSpec.getSize(widthMeasureSpec) + paddingRight + paddingLeft
        var height = MeasureSpec.getSize(heightMeasureSpec)

        setMeasuredDimension(width, height)

    }


    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        /*算出每個字母的佔用的高度*/
        letterHeight = (h - paddingBottom - paddingTop) / letters.count()


    }

處理繪製
接著,開始畫文字
畫文字的時候要注意每個字元本身的寬度時不一樣的,比如A肯定要比 I的寬度大,所以我們要保證繪製的文字處於view的正中間,同時,在計算文字基線時也要注意高度,註釋已經很清楚了。

    override fun onDraw(canvas: Canvas?) {

        for ((index, value) in letters.withIndex()) {

            /*當前的字元*/
            var currentLetter = value.toString()
            /*測量當前字元的寬度*/
            var letterW = textPaint.measureText(currentLetter)
            /*算出起點座標  保證字元畫在水平正中間*/
            var x = width / 2 - letterW / 2

            /*算出基線*/
            var fm = textPaint.fontMetricsInt
            var dy = (fm.bottom - fm.top) / 2 - fm.bottom
            /*注意這裡的基線高度是基於之前所有letter的高度加上paddingtop的值再加上其本身的基線位置*/
            var baseLine = letterHeight * index + letterHeight / 2 + dy + paddingTop
            /*畫文字*/
            canvas!!.drawText(currentLetter, x, baseLine.toFloat(), textPaint)

        }


    }

做完這些,實際上我們已經畫出我們需要的控制元件了,我們先來使用一下看看。

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">


    <com.yzq.widget.LetterIndexView
        android:layout_width="30dp"
        android:layout_height="match_parent"
        android:layout_marginBottom="20dp"
        android:layout_marginTop="20dp"
        android:padding="10dp"
        app:letterViewTextColor="@color/colorPrimaryDark"
        app:letterViewTextSize="18sp"
        android:background="@color/colorAccent"
        app:layout_constraintRight_toRightOf="parent" />

</android.support.constraint.ConstraintLayout>

執行效果圖如下

在這裡插入圖片描述

嗯 ,可以看到基本符合我們的預期,就是有點醜,還是改的正常點吧

    <com.yzq.widget.LetterIndexView
        android:layout_width="30dp"
        android:layout_height="match_parent"
        android:layout_marginBottom="10dp"
        android:layout_marginTop="10dp"
        app:layout_constraintRight_toRightOf="parent"
        app:letterViewTextColor="@color/colorPrimary"
        app:letterViewTextSize="16sp" />

在這裡插入圖片描述

嗯,這樣一來好多了。

觸控反饋
好了,該畫的我們畫好之後,下面我們還要處理一下觸控反饋。
當我們手指按下和移動的時候,我們要將當前手指所在的字母返回給呼叫者。
首先我們需要一個介面,用來將觸控的字元傳給呼叫者

    /*監聽器*/
    interface onTouchLetterListener {
        fun showLetter(letter: String)
        fun hideLetter()
    }
    fun setOnTouchLetterListener(listener: onTouchLetterListener) {
        this.listener = listener
    }


然後處理一下觸控事件,註釋寫的也很清楚了,這裡就不多解釋了。需要注意的是最後return的時候要返回true,否則,表示事件沒有被消費掉,導致無法再觸發onTouchEvent事件,也就沒辦法處理MOVE和UP事件了

    override fun onTouchEvent(event: MotionEvent): Boolean {

        when (event.action) {
            MotionEvent.ACTION_DOWN,
            MotionEvent.ACTION_MOVE -> {
                /*按下和移動事件*/

                /*當前手指所處的y的值除以字元高度即可算出字元的下標*/
                var eventIndex: Int = (event.y / letterHeight).toInt()

                /*由於可能設定了margin或者padding  所以要對值進行校驗  否則可能出現下標越界異常*/
                if (eventIndex >= 0 && eventIndex < letters.count()) {

                    var eventLetter = letters.elementAt(eventIndex)

                    /*將當前字元通過介面傳出去*/
                    if (listener != null) {
                        listener!!.showLetter(eventLetter.toString())
                    }

                }

            }

            MotionEvent.ACTION_UP -> {
                /*手指擡起時可以隱藏*/
                if (listener != null) {
                    listener!!.hideLetter()
                }
            }

        }


        return true
    }

好了,至此,我們的觸控也處理完了,下面我們來用一下。

佈局中一般有個TextView用於顯示觸控的字元

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">


    <TextView
        android:id="@+id/letterTv"
        android:layout_width="60dp"
        android:layout_height="60dp"
        android:background="#6889ff"
        android:gravity="center"
        android:text="A"
          android:visibility="gone"
        android:textColor="#FFFFFF"
        android:textSize="18sp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />


    <com.yzq.widget.LetterIndexView
        android:id="@+id/letterView"
        android:layout_width="30dp"
        android:layout_height="match_parent"
        android:layout_marginBottom="10dp"
        android:layout_marginTop="10dp"
        app:layout_constraintRight_toRightOf="parent"
        app:letterViewTextColor="@color/colorPrimary"
        app:letterViewTextSize="16sp" />

</android.support.constraint.ConstraintLayout>

Activity中使用

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        letterView.setOnTouchLetterListener(object : LetterIndexView.onTouchLetterListener {
            override fun showLetter(letter: String) {
                letterTv.visibility = View.VISIBLE
                letterTv.setText(letter)
            }

            override fun hideLetter() {
                letterTv.visibility = View.GONE

            }

        })
    }
}

下面我們來執行看一下

在這裡插入圖片描述

可以看到,已經達到了我們的需求。使用上已經沒有什麼問題了
但是,這就結束了嗎

效能問題
下面我們來看看可能存在的問題,在下onTouchEvent的程式碼中,如果你加上日誌,你就會發現,在手指移動的過程中會觸發很多次介面回撥,假入你此時有重新繪製的需求,比如你想在觸控的時候把觸控的字元畫成紅色,你還想給View加個背景色之類的。那麼你肯定需要呼叫 invalidate(),如果不做處理的話,會造成很多次沒必要的繪製,浪費效能。

我們來看看日誌,可以看到列印的頻率很頻繁

在這裡插入圖片描述

優化

在上面我們已經說了為什麼會有效能問題,實際上我們不處理也是沒什麼大影響的,日常使用其實也感覺不出來效能問題,但是,像這種問題積少成多話還是有一定影響的。
再說了,作為一名合格的程式設計師,我們需要追求更好的程式碼質量,寫出高效能的程式碼

如何優化呢,其實很簡單,我們在手指移動的時候判斷一下當前所處的區域是不是跟移動之前所處的是否是一個區域,如果是的話,就不做處理了,不是的話再做處理。這樣的話,會大大減少回撥和繪製。

我們來實現一下。
我們新增一個oldIndex用於記錄上次手指所處的位置,然後將當前eventIndex跟oldIndex作比較即可知道是否是同一個字元


  private var oldIndex: Int = -1


    override fun onTouchEvent(event: MotionEvent): Boolean {

        when (event.action) {
            MotionEvent.ACTION_DOWN,
            MotionEvent.ACTION_MOVE -> {

                /*按下和移動事件*/
                /*當前手指所處的y的值除以字元高度即可算出字元的下標*/
                var eventIndex: Int = (event.y / letterHeight).toInt()
                /*由於可能設定了margin或者padding  所以要對值進行校驗  否則可能出現下標越界異常*/
                if (eventIndex >= 0 && eventIndex < letters.count()) {

                    /*如果當前eventIndex不等於oldIndex  再執行*/
                    if (eventIndex != oldIndex) {
                        oldIndex = eventIndex
                        var eventLetter = letters.elementAt(eventIndex)
                        /*將當前字元通過介面傳出去*/
                        if (listener != null) {
                            listener!!.showLetter(eventLetter.toString())
                        }
                    }
                }

            }

            MotionEvent.ACTION_UP -> {
                /*手指擡起時可以隱藏*/
                if (listener != null) {
                    listener!!.hideLetter()
                }
            }

        }


        return true
    }
  

再來看看執行效果和日誌,可以看到,大大減少了觸發的頻率

在這裡插入圖片描述

ok,至此,我們已經完成了一個高效能的自定義字母索引控制元件啦,下面是Demo

Demo