Android性能優化 | 把構建布局用時縮短 20 倍(下)

上一篇講述了 Activity 構建布局的過程,及測量其耗時的方法。這一篇在此基礎上給出優化構建布局的方案。

這是 Android 性能優化系列文章的第四篇,文章列表如下:

  1. Android性能優化 | 幀動畫OOM?優化幀動畫之 SurfaceView逐幀解析
  2. Android性能優化 | 大圖做幀動畫卡頓?優化幀動畫之 SurfaceView滑動窗口式幀復用
  3. Android性能優化 | 把構建布局用時縮短 20 倍(上)
  4. Android性能優化 | 把構建布局用時縮短 20 倍(下)

靜態布局

測試布局如下圖所示:

[圖片上傳失敗...(image-8fe3e6-1603972156695)]
與之對應的 xml 文件如下(有點長,可以直接跳過):

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="80dp"
        android:paddingStart="20dp"
        android:paddingTop="10dp"
        android:paddingEnd="20dp"
        android:paddingBottom="10dp">

        <ImageView
            android:layout_width="40dp"
            android:layout_height="40dp"
            android:layout_alignParentStart="true"
            android:layout_centerVertical="true"
            android:src="@drawable/ic_back_black" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerInParent="true"
            android:text="commit"
            android:textSize="30sp"
            android:textStyle="bold" />

        <ImageView
            android:layout_width="40dp"
            android:layout_height="40dp"
            android:layout_alignParentEnd="true"
            android:layout_centerVertical="true"
            android:src="@drawable/ic_member_more" />
    </RelativeLayout>

    <View
        android:layout_width="match_parent"
        android:layout_height="1dp"
        android:background="#eeeeee" />


    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        android:paddingStart="5dp"
        android:paddingTop="30sp"
        android:paddingEnd="5dp"
        android:paddingBottom="30dp">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="10dp"
            android:layout_marginEnd="10dp"
            android:background="@drawable/tag_checked_shape"
            android:orientation="vertical">

            <LinearLayout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:orientation="horizontal">

                <ImageView
                    android:layout_width="40dp"
                    android:layout_height="40dp"
                    android:src="@drawable/diamond_tag" />

                <TextView
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginLeft="10dp"
                    android:gravity="center"
                    android:padding="10dp"
                    android:text="gole"
                    android:textColor="#389793"
                    android:textSize="20sp"
                    android:textStyle="bold" />

            </LinearLayout>

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal"
                android:weightSum="8">

                <LinearLayout
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_weight="5"
                    android:orientation="vertical">

                    <TextView
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:text="The changes were merged into release with so many bugs"
                        android:textSize="23sp" />

                    <TextView
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:text="merge it with mercy"
                        android:textColor="#c4747E8B"
                        android:textSize="18sp" />
                </LinearLayout>

                <ImageView
                    android:layout_width="100dp"
                    android:layout_height="100dp"
                    android:layout_weight="3"
                    android:scaleType="fitXY"
                    android:src="@drawable/user_portrait_gender_female" />
            </LinearLayout>

            <RelativeLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginTop="10dp"
                android:paddingEnd="10dp"
                android:paddingBottom="10dp">

                <TextView
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_alignParentEnd="true"
                    android:text="2020.04.30" />
            </RelativeLayout>
        </LinearLayout>

    </LinearLayout>

    <View
        android:layout_width="match_parent"
        android:layout_height="1dp"
        android:background="#eeeeee" />

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="40dp">

        <LinearLayout
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerInParent="true"
            android:orientation="horizontal">

            <Button
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="left"
                android:layout_marginEnd="20dp"
                android:background="@drawable/bg_orange_btn"
                android:text="cancel" />

            <Button
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="right"
                android:layout_marginStart="20dp"
                android:background="@drawable/bg_orange_btn"
                android:text="OK" />
        </LinearLayout>
    </RelativeLayout>
</LinearLayout>

為了驗證“嵌套布局是否會延長解析時間?”,特意用RelativeLayout+LinearLayout寫了上面最深 5 層嵌套的布局。

把它設置為 Activity 的 ContentView,經多次測量構建平均耗時為 24.2 ms 。(布局略簡單,復雜度遠低于真實項目中的界面,遂真實項目中的優化空間更大)

動態構建布局

如果把 xml 中的布局稱為靜態布局的話,那用 Kotlin 代碼構建布局就可以稱為動態布局

正如上一篇分析的那樣,靜態布局避免不了兩個耗時的步驟:

  1. 通過 IO 操作將布局文件讀至內存。
  2. 遍歷布局文件中每一個標簽,通過反射構建控件實例并填入 View 樹。

那棄用靜態布局,直接使用 Kotlin 代碼構建布局,能節約多少時間?

于是我用純 Kotlin 代碼重寫了一遍布局,寫完。。。差點吐了,代碼如下:

 private fun buildLayout(): View {
        return LinearLayout(this).apply {
            orientation = LinearLayout.VERTICAL
            layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)

            RelativeLayout(this@Factory2Activity2).apply {
                layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 80f.dp())
                setPadding(20f.dp(), 10f.dp(), 20.0f.dp(), 10f.dp())

                ImageView(this@Factory2Activity2).apply {
                    layoutParams = RelativeLayout.LayoutParams(40f.dp(), 40f.dp()).apply {
                        addRule(RelativeLayout.ALIGN_PARENT_START, RelativeLayout.TRUE)
                        addRule(RelativeLayout.CENTER_VERTICAL, RelativeLayout.TRUE)
                    }
                    setImageResource(R.drawable.ic_back_black)
                }.also { addView(it) }

                TextView(this@Factory2Activity2).apply {
                    layoutParams =
                        RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT).apply {
                            addRule(RelativeLayout.CENTER_IN_PARENT, RelativeLayout.TRUE)
                        }
                    text = "commit"
                    setTextSize(TypedValue.COMPLEX_UNIT_SP, 30f)
                    setTypeface(null, Typeface.BOLD)
                }.also { addView(it) }

                ImageView(this@Factory2Activity2).apply {
                    layoutParams =
                        RelativeLayout.LayoutParams(40f.dp(), 40f.dp()).apply {
                            addRule(RelativeLayout.ALIGN_PARENT_END, RelativeLayout.TRUE)
                            addRule(RelativeLayout.CENTER_VERTICAL, RelativeLayout.TRUE)
                        }
                    setImageResource(R.drawable.ic_member_more)
                }.also { addView(it) }
            }.also { addView(it) }

            View(this@Factory2Activity2).apply {
                layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 1f.dp())
                setBackgroundColor(Color.parseColor("#eeeeee"))
            }.also { addView(it) }


            NestedScrollView(this@Factory2Activity2).apply {
                layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 500f.dp()).apply {
                    topMargin = 20f.dp()
                }
                isScrollbarFadingEnabled = true

                LinearLayout(this@Factory2Activity2).apply {
                    layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
                    orientation = LinearLayout.VERTICAL
                    setPadding(5f.dp(), 5f.dp(), 30f.dp(), 30f.dp())

                    LinearLayout(this@Factory2Activity2).apply {
                        layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
                            marginStart = 10f.dp()
                            marginEnd = 10f.dp()
                        }
                        orientation = LinearLayout.VERTICAL
                        setBackgroundResource(R.drawable.tag_checked_shape)

                        LinearLayout(this@Factory2Activity2).apply {
                            layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
                            orientation = LinearLayout.HORIZONTAL

                            ImageView(this@Factory2Activity2).apply {
                                layoutParams = LinearLayout.LayoutParams(40f.dp(), 40f.dp())
                                setImageResource(R.drawable.diamond_tag)
                            }.also { addView(it) }

                            TextView(this@Factory2Activity2).apply {
                                layoutParams =
                                    LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
                                        marginStart = 10f.dp()
                                    }
                                gravity = Gravity.CENTER
                                setPadding(10f.dp(), 10f.dp(), 10f.dp(), 10f.dp())
                                text = "gole"
                                setTextColor(Color.parseColor("#389793"))
                                setTextSize(TypedValue.COMPLEX_UNIT_SP, 20F)
                                this.setTypeface(null, Typeface.BOLD)

                            }.also { addView(it) }
                        }.also { addView(it) }

                        LinearLayout(this@Factory2Activity2).apply {
                            layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
                            orientation = LinearLayout.HORIZONTAL
                            weightSum = 8f

                            LinearLayout(this@Factory2Activity2).apply {
                                layoutParams =
                                    LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
                                        weight = 5f
                                    }
                                orientation = LinearLayout.VERTICAL

                                TextView(this@Factory2Activity2).apply {
                                    layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
                                    text = "The changes were merged into release with so many bugs"
                                    setTextSize(TypedValue.COMPLEX_UNIT_SP, 23f)
                                }.also { addView(it) }

                                TextView(this@Factory2Activity2).apply {
                                    layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
                                    text = "merge it with mercy"
                                    setTextColor(Color.parseColor("#c4747E8B"))
                                    setTextSize(TypedValue.COMPLEX_UNIT_SP, 18f)
                                }.also { addView(it) }

                            }.also { addView(it) }
                            ImageView(this@Factory2Activity2).apply {
                                layoutParams = LinearLayout.LayoutParams(100f.dp(), 100f.dp()).apply {
                                    weight = 3f
                                }
                                scaleType = ImageView.ScaleType.FIT_XY
                                setImageResource(R.drawable.user_portrait_gender_female)
                            }.also { addView(it) }
                        }.also { addView(it) }

                        RelativeLayout(this@Factory2Activity2).apply {
                            layoutParams = LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
                                topMargin = 10f.dp()
                            }
                            setPadding(0, 0, 10f.dp(), 10f.dp())

                            TextView(this@Factory2Activity2).apply {
                                layoutParams =
                                    RelativeLayout.LayoutParams(RelativeLayout.LayoutParams.WRAP_CONTENT, RelativeLayout.LayoutParams.WRAP_CONTENT)
                                        .apply {
                                            addRule(RelativeLayout.ALIGN_PARENT_END, RelativeLayout.TRUE)
                                        }
                                text = "2020.04.30"
                            }.also { addView(it) }
                        }.also { addView(it) }
                    }.also { addView(it) }
                }.also { addView(it) }
            }.also { addView(it) }

            View(this@Factory2Activity2).apply {
                layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 1f.dp())
                setBackgroundColor(Color.parseColor("#eeeeee"))

            }.also { addView(it) }

            RelativeLayout(this@Factory2Activity2).apply {
                layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
                    topMargin = 40f.dp()
                }

                LinearLayout(this@Factory2Activity2).apply {
                    layoutParams = RelativeLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT).apply {
                        addRule(RelativeLayout.CENTER_IN_PARENT, RelativeLayout.TRUE)
                    }
                    orientation = LinearLayout.HORIZONTAL

                    Button(this@Factory2Activity2).apply {
                        layoutParams =
                            LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
                                rightMargin = 20f.dp()
                                gravity = Gravity.LEFT
                            }
                        setBackgroundResource(R.drawable.bg_orange_btn)
                        text = "cancel"
                    }.also {
                        addView(it)
                    }
                    Button(this@Factory2Activity2).apply {
                        layoutParams =
                            LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT).apply {
                                leftMargin = 20f.dp()
                                gravity = Gravity.RIGHT
                            }
                        setBackgroundResource(R.drawable.bg_orange_btn)
                        text = "OK"
                    }.also { addView(it) }
                }.also { addView(it) }
            }.also { addView(it) }
        }
    }

用偽代碼描述上述代碼,結構就是這樣的:

容器控件.apply {
    子控件.apply {
        //設置控件屬性
    }.also { addView(it) }
}

代碼又臭又長又冗余,完全沒有可讀性。若要微調其中顯示寶石的控件,你可以試下,反正我是找不到那個控件了。

但跑了一下測試代碼,驚喜地發現構建布局的平均耗時只有 1.32 ms,時間是靜態布局的 1/20

一開始我以為是嵌套布局導致特別耗時,于是用ConstraintLayout將嵌套扁平化,代碼如下:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView
        android:id="@+id/ivBack"
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:layout_marginStart="20dp"
        android:layout_marginTop="20dp"
        android:src="@drawable/ic_back_black"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tvCommit"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="commit"
        android:textSize="30sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toBottomOf="@id/ivBack"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="@id/ivBack" />

    <ImageView
        android:id="@+id/ivMore"
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:layout_marginEnd="20dp"
        android:src="@drawable/ic_member_more"
        app:layout_constraintBottom_toBottomOf="@id/ivBack"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="@id/ivBack" />

    <View
        android:id="@+id/vDivider"
        android:layout_width="match_parent"
        android:layout_height="1dp"
        android:layout_marginTop="10dp"
        android:background="#eeeeee"
        app:layout_constraintTop_toBottomOf="@id/ivBack" />

    <View
        android:id="@+id/bg"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:background="@drawable/tag_checked_shape"
        app:layout_constraintBottom_toBottomOf="@id/tvTime"
        app:layout_constraintEnd_toEndOf="@id/ivDD"
        app:layout_constraintStart_toStartOf="@id/ivD"
        app:layout_constraintTop_toTopOf="@id/ivD" />

    <ImageView
        android:id="@+id/ivD"
        android:layout_width="40dp"
        android:layout_height="40dp"
        android:layout_marginStart="20dp"
        android:layout_marginTop="40dp"
        android:src="@drawable/diamond_tag"
        app:layout_constraintStart_toStartOf="@id/ivBack"
        app:layout_constraintTop_toBottomOf="@id/vDivider" />

    <TextView
        android:id="@+id/tvTitle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="5dp"
        android:gravity="center"
        android:padding="10dp"
        android:text="gole"
        android:textColor="#389793"
        android:textSize="20sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toBottomOf="@id/ivD"
        app:layout_constraintStart_toEndOf="@id/ivD"
        app:layout_constraintTop_toTopOf="@id/ivD" />

    <TextView
        android:id="@+id/tvC"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="5dp"
        android:text="The changes were merged into release with so many bugs"
        android:textSize="23sp"
        app:layout_constraintEnd_toStartOf="@id/ivDD"
        app:layout_constraintStart_toStartOf="@id/ivD"
        app:layout_constraintTop_toBottomOf="@id/ivD" />


    <ImageView
        android:id="@+id/ivDD"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:layout_marginEnd="20dp"
        android:src="@drawable/user_portrait_gender_female"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@id/tvC"
        app:layout_constraintTop_toTopOf="@id/tvC" />

    <TextView
        android:id="@+id/tvSub"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="merge it with mercy"
        android:textColor="#c4747E8B"
        android:textSize="18sp"
        app:layout_constraintStart_toStartOf="@id/ivD"
        app:layout_constraintTop_toBottomOf="@id/tvC" />

    <TextView
        android:id="@+id/tvTime"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="20dp"
        android:text="2020.04.30"
        app:layout_constraintEnd_toEndOf="@id/ivDD"
        app:layout_constraintTop_toBottomOf="@id/ivDD" />

    <TextView
        android:id="@+id/tvCancel"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginEnd="30dp"
        android:background="@drawable/bg_orange_btn"
        android:paddingStart="30dp"
        android:paddingTop="10dp"
        android:paddingEnd="30dp"
        android:paddingBottom="10dp"
        android:text="cancel"
        android:layout_marginBottom="20dp"
        android:textSize="20sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@id/tvOK"
        app:layout_constraintHorizontal_chainStyle="packed"
        app:layout_constraintStart_toStartOf="parent" />

    <TextView
        android:id="@+id/tvOK"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@drawable/bg_orange_btn"
        android:paddingStart="30dp"
        android:paddingTop="10dp"
        android:layout_marginBottom="20dp"
        android:paddingEnd="30dp"
        android:paddingBottom="10dp"
        android:text="OK"
        android:textSize="20sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintHorizontal_chainStyle="packed"
        app:layout_constraintStart_toEndOf="@id/tvCancel" />

    <View
        app:layout_constraintBottom_toTopOf="@id/tvCancel"
        android:layout_marginBottom="20dp"
        android:background="#eeeeee"
        android:layout_width="match_parent"
        android:layout_height="1dp"/>

</androidx.constraintlayout.widget.ConstraintLayout>

這次做到了零嵌套,帶著期望重新運行了一遍代碼。但解析布局耗時絲毫沒有變化。。。好吧

既然靜態布局和動態布局有這么大的性能差距,那就改善一下動態布局代碼的可讀性!!

DSL

DSL 是改善構建代碼可讀性的利器!

DSL = domain specific language,即“特定領域語言”,與它對應的一個概念叫“通用編程語言”,通用編程語言有一系列完善的能力來解決幾乎所有能被計算機解決的問題,像 Java 就屬于這種類型。而特定領域語言只專注于特定的任務,比如 SQL 只專注于操縱數據庫,HTML 只專注于表述超文本。

既然通用編程語言能夠解決所有的問題,那為啥還需要特定領域語言?因為它可以使用比通用編程語言中等價代碼更緊湊的語法來表達特定領域的操作。比如當執行一條 SQL 語句時,不需要從聲明一個類及其方法開始。

更緊湊的語法意味著更簡潔的 API。應用程序中每個類都提供了其他類與之交互的可能性,確保這些交互易于理解并可以簡潔地表達,對于軟件的可維護性至關重要。

DSL 有一個普通API不具備特征:DSL 具有結構。而帶接收者的lambda使得構建結構化的 API 變得容易。

帶接收者的 lambda

它是一種特殊的 lambda,是 kotlin 中特有的。可以把它理解成“為接收者聲明的一個匿名擴展函數”。(擴展函數是一種在類體外為類添加功能的特性)

帶接收者的lambda的函數體除了能訪問其所在類的成員外,還能訪問接收者的所有非私有成員,這個特性是它能夠輕松地構建結構。

當帶接收者的 lambda 配合高階函數時,構建結構化的 API 就變得易如反掌。

高階函數

它是一種特殊的函數,它的參數或者返回值是另一個函數。

比如集合的擴展函數filter()就是一個高階函數:

//filter的參數是一個帶接收的lambda
public inline fun <T> Iterable<T>.filter(predicate: (T) -> Boolean): List<T> {
    return filterTo(ArrayList<T>(), predicate)
}

可以使用它來過濾集合中的元素:

students.filter { age > 18 }

這樣就是一種結構化 API 的調用(在 java 中看不到),雖然這種結構得益于 kotlin 的一個約定(如果函數只有一個參數且它是 lambda,則可以省略函數參數列表的括號)。但更關鍵的是 lambda 的內部,得益于帶接收者的lambdaage > 18運行在一個和其調用方不同的上下文中,在這個上下文中,可以輕松的訪問到Student的成員Student.age( 指向 age 時可以省略 this )

讓我們使用這樣的技巧來改善“動態構建布局”代碼的可讀性。

動態布局DSL

用 DSL 重新構建上面的布局的效果如下:

private val rootView by lazy {
    ConstraintLayout {
        layout_width = match_parent
        layout_height = match_parent

        ImageView {
            layout_id = "ivBack"
            layout_width = 40
            layout_height = 40
            margin_start = 20
            margin_top = 20
            src = R.drawable.ic_back_black
            start_toStartOf = parent_id
            top_toTopOf = parent_id
            onClick = { onBackClick() }
        }

        TextView {
            layout_width = wrap_content
            layout_height = wrap_content
            text = "commit"
            textSize = 30f
            textStyle = bold
            align_vertical_to = "ivBack"
            center_horizontal = true
        }

        ImageView {
            layout_width = 40
            layout_height = 40
            src = R.drawable.ic_member_more
            align_vertical_to = "ivBack"
            end_toEndOf = parent_id
            margin_end = 20
        }

        View {
            layout_id = "vDivider"
            layout_width = match_parent
            layout_height = 1
            margin_top = 10
            background_color = "#eeeeee"
            top_toBottomOf = "ivBack"
        }

        Layer {
            layout_id = "layer"
            layout_width = wrap_content
            layout_height = wrap_content
            referenceIds = "ivDiamond,tvTitle,tvContent,ivAvatar,tvTime,tvSub"
            background_res = R.drawable.tag_checked_shape
            start_toStartOf = "ivDiamond"
            top_toTopOf = "ivDiamond"
            bottom_toBottomOf = "tvTime"
            end_toEndOf = "tvTime"
        }

        ImageView {
            layout_id = "ivDiamond"
            layout_width = 40
            layout_height = 40
            margin_start = 20
            margin_top = 40
            src = R.drawable.diamond_tag
            start_toStartOf = "ivBack"
            top_toBottomOf = "vDivider"
        }

        TextView {
            layout_id = "tvTitle"
            layout_width = wrap_content
            layout_height = wrap_content
            margin_start = 5
            gravity = gravity_center
            text = "gole"
            padding = 10
            textColor = "#389793"
            textSize = 20f
            textStyle = bold
            align_vertical_to = "ivDiamond"
            start_toEndOf = "ivDiamond"
        }

        TextView {
            layout_id = "tvContent"
            layout_width = 0
            layout_height = wrap_content
            margin_top = 5
            text = "The changes were merged into release with so many bugs"
            textSize = 23f
            start_toStartOf = "ivDiamond"
            top_toBottomOf = "ivDiamond"
            end_toStartOf = "ivAvatar"
        }

        ImageView {
            layout_id = "ivAvatar"
            layout_width = 100
            layout_height = 100
            margin_end = 20
            src = R.drawable.user_portrait_gender_female
            end_toEndOf = parent_id
            start_toEndOf = "tvContent"
            top_toTopOf = "tvContent"
        }

        TextView {
            layout_id = "tvSub"
            layout_width = wrap_content
            layout_height = wrap_content
            text = "merge it with mercy"
            textColor = "#c4747E8B"
            textSize = 18f
            start_toStartOf = "ivDiamond"
            top_toBottomOf = "tvContent"
        }

        TextView {
            layout_id = "tvTime"
            layout_width = wrap_content
            layout_height = wrap_content
            margin_top = 20
            text = "2020.04.30"
            end_toEndOf = "ivAvatar"
            top_toBottomOf = "ivAvatar"
        }

        TextView {
            layout_id = "tvCancel"
            layout_width = wrap_content
            layout_height = wrap_content
            margin_end = 30
            background_res = R.drawable.bg_orange_btn
            padding_start = 30
            padding_top = 10
            padding_end = 30
            padding_bottom = 10
            text = "cancel"
            margin_bottom = 20
            textSize = 20f
            textStyle = bold
            bottom_toBottomOf = parent_id
            end_toStartOf = "tvOk"
            start_toStartOf = parent_id
            horizontal_chain_style = packed
        }

        TextView {
            layout_id = "tvOk"
            layout_width = wrap_content
            layout_height = wrap_content
            background_res = R.drawable.bg_orange_btn
            padding_start = 30
            padding_top = 10
            margin_bottom = 20
            padding_end = 30
            padding_bottom = 10
            text = "Ok"
            textSize = 20f
            textStyle = bold
            bottom_toBottomOf = parent_id
            end_toEndOf = parent_id
            horizontal_chain_style = packed
            start_toEndOf = "tvCancel"
        }
    }
}

重構之后的動態布局代碼,有了和靜態布局一樣的可讀性,甚至比靜態布局更簡潔了。

構建控件

代碼中每一個控件的類名都是一個擴展方法,構建容器控件的方法如下:

inline fun Context.ConstraintLayout(init: ConstraintLayout.() -> Unit): ConstraintLayout =
    ConstraintLayout(this).apply(init)

容器控件的構造都通過Context的擴展方法實現,只要有Context的地方就能構建布局。

擴展方法會直接調用構造函數并應用為其初始化屬性的 lambda。該 lambda 是一個帶接收者的labmda,它的接收者是ConstraintLayoutKotlin 獨有的這個特性使得 lambda 函數體中可以額外地多訪問一個對象的非私有成員。本例中 lambda 表達式init的函數體中可以訪問ConstraintLayout的所有非私有成員,這樣就能輕松地在函數體中設置控件屬性。

有了這個擴展函數,就可以這樣構建容器控件(可先忽略屬性賦值邏輯,下一節再介紹):

ConstraintLayout {
    layout_width = match_parent
    layout_height = match_parent
}

上述這段等價于下面的 xml:

<androidx.constraintlayout.widget.ConstraintLayout 
    android:layout_width="match_parent"
    android:layout_height="match_parent">

相較于 xml,省略了一些重復信息,顯得更簡潔。

構建子控件通過ViewGroup的擴展方法實現:

inline fun ViewGroup.TextView(init: TextView.() -> Unit) =
    TextView(context).apply(init).also { addView(it) }

子控件構建完畢后需要填入容器控件,定義成ViewGroup的擴展方法就能方便的調用addView()

控件的構建方法都通過關鍵詞inline進行了內聯,編譯器會將帶有inline函數體中的代碼平鋪到調用處,這樣就避免了一次函數調用,函數調用也有時間和空間上的開銷(在棧中創建棧幀)。默認情況下、每個 Kotlin 中的 lambda 都會被編譯成一個匿名類,除非 lambda 被內聯。被內聯的構建方法使得構建布局時不會發生函數調用,并且也不會創建匿名內部類。

現在就可以像這樣為容器控件添加子控件了:

ConstraintLayout {
    layout_width = match_parent
    layout_height = match_parent
    
    TextView {
        layout_width = wrap_content
        layout_height = wrap_content
    }
}

這樣定義的缺點是:只能在ViewGroup中構建TextView,若有單獨構建的需求,可以模仿容器控件的構建方法:

inline fun Context.TextView(init: TextView.() -> Unit) = 
    TextView(this).apply(init)

設置控件屬性

xml 中每一個屬性都有對應的 Java 方法,直接調用方法使得動態構建代碼可讀性很差。

有什么辦法可以把方法調用轉化成屬性賦值語句?—— 擴展屬性

inline var View.background_color: String
    get() {
        return ""
    }
    set(value) {
        setBackgroundColor(Color.parseColor(value))
    }

View增加了名為background_color的擴展屬性,它是String類型的變量,需為其定義取值和設置方法。當該屬性被賦值時,set()方法會被調用,在其中調用了View.setBackgroundColor()來設置背景色。

現在就可以像這樣設置控件背景色了:

ConstraintLayout {
    layout_width = match_parent
    layout_height = match_parent
    background_color = "#ffff00"
}

特別地,對于下面這種“可或”的屬性:

<TextView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:gravity="center_horizontal|top"/>

改為+

TextView {
    layout_width = wrap_content
    layout_height = wrap_content
    gravity = gravity_center_horizontal + gravity_top
}

增量修改布局屬性

上面的例子中,背景色是一個獨立的屬性,即修改它不會影響到其他屬性。但修改布局屬性都是批量的。當只想修改其中一個屬性值時,就必須增量修改:

inline var View.padding_top: Int
    get() {
        return 0
    }
    set(value) {
        setPadding(paddingLeft, value.dp(), paddingRight, paddingBottom)
    }

padding_top被定義為View的擴展屬性,所以在set()方法中能輕松訪問到View原有的paddingLeftpaddingRightpaddingBottom,以便使這三個屬性保持原樣,而只修改paddingTop

dp()是一個擴展方法,用來將 Int 值根據當前屏幕密度轉換成 dp 值:

fun Int.dp(): Int =
    TypedValue.applyDimension(
        TypedValue.COMPLEX_UNIT_DIP,
        this.toFloat(),
        Resources.getSystem().displayMetrics
    ).toInt()

為控件設置寬高也需要增量修改:

inline var View.layout_width: Int
    get() {
        return 0
    }
    set(value) {
        val w = if (value > 0) value.dp() else value
        val h = layoutParams?.height ?: 0
        layoutParams = ViewGroup.MarginLayoutParams(w, h)
    }

在設置寬時,讀取原有高,并新建ViewGroup.MarginLayoutParams,重新為layoutParams賦值。為了通用性,選擇了ViewGroup.MarginLayoutParams,它是所有其他LayoutParams的父類。

一個更復雜的例子是ContraintLayout中的相對布局屬性:

inline var View.start_toStartOf: String
    get() {
        return ""
    }
    set(value) {
        layoutParams = layoutParams.append {
            //'toLayoutId()是生成控件id的方法,下一節會介紹'
            startToStart = value.toLayoutId()
            startToEnd = -1
        }
    }

在 xml 中每一個相對布局屬性都對應于ContraintLayout.LayoutParams實例中的一個 Int 值(控件 ID 是 Int 類型)。所以必須獲取原LayoutParams實例并為對應的新增屬性賦值,就像這樣:

inline var View.start_toStartOf: String
    get() {
        return ""
    }
    set(value) {
        layoutParams = layoutParams.apply {
            startToStart = 控件ID
            //'-1表示沒有相對約束'
            startToEnd = -1
        }
    }

但設置寬高時,構造的是ViewGroup.MarginLayoutParams實例,它并沒有相對布局的屬性。所以需要將原ViewGroup.MarginLayoutParams中的寬高和邊距值復制出來,重新構建一個ContraintLayout.LayoutParams

fun ViewGroup.LayoutParams.append(set: ConstraintLayout.LayoutParams.() -> Unit) =
    //'如果是限制布局則直接增量賦值'
    (this as? ConstraintLayout.LayoutParams)?.apply(set) ?:
    //'否則將邊距布局參數值拷貝到限制布局參數中,再增量賦值'
    (this as? ViewGroup.MarginLayoutParams)?.toConstraintLayoutParam()?.apply(set)

//'將邊距布局參數轉換成限制布局參數'
fun ViewGroup.MarginLayoutParams.toConstraintLayoutParam() =
    ConstraintLayout.LayoutParams(width, height).also { it ->
        it.topMargin = this.topMargin
        it.bottomMargin = this.bottomMargin
        it.marginStart = this.marginStart
        it.marginEnd = this.marginEnd
    }

這個方案有一個缺點:必須先為控件設置寬高,再設置相對布局屬性。

生成控件ID

View.setId(int id)接收 int 類型的值,但 int 值沒有語義,起不到標記控件的作用,所以擴展屬性layout_id是 String 類型的:

inline var View.layout_id: String
    get() {
        return ""
    }
    set(value) {
        id = value.toLayoutId()
    }

//'將String轉化成對應的Int值'
fun String.toLayoutId():Int{
    var id = java.lang.String(this).bytes.sum()
    if (id == 48) id = 0
    return id
}

String 必須轉化成 Int 才能調用View.setId(),采用的方法是:先將 String 轉化成 byte 數組,然后對數組累加。但 Kotlin 中的 String 沒有getBytes(),所以只能顯示地構造java.lang.String

之所以要硬編碼48是因為:

public class ConstraintLayout extends ViewGroup {
    public static class LayoutParams extends MarginLayoutParams {
        public static final int PARENT_ID = 0;
    }
}

而我把該常量重新定義成 String 類型:

val parent_id = "0"

通過toLayoutId()算法,"0"對應值為 48。

更好的辦法是找出toLayoutId()算法的逆算法,即當該函數輸出為 0 時,輸入應該是多少?可惜并想不出如何實現。望知道的小伙伴點撥~

現在就可以像這樣設置控件 ID 了:

ConstraintLayout {
    layout_id = "cl"
    layout_width = match_parent
    layout_height = match_parent
    background_color = "#ffff00"

    ImageView {
        layout_id = "ivBack"
        layout_width = 40
        layout_height = 40
        src = R.drawable.ic_back_black
        start_toStartOf = parent_id
        top_toTopOf = parent_id
    }
}

重命名控件屬性

為了讓構建語法盡可能的精簡,原先帶有類名的常量都被重新定義了,比如:

val match_parent = ViewGroup.LayoutParams.MATCH_PARENT
val wrap_content = ViewGroup.LayoutParams.WRAP_CONTENT

val constraint_start = ConstraintProperties.START
val constraint_end = ConstraintProperties.END
val constraint_top = ConstraintProperties.TOP
val constraint_bottom = ConstraintProperties.BOTTOM
val constraint_baseline = ConstraintProperties.BASELINE
val constraint_parent = ConstraintProperties.PARENT_ID

新增屬性:組合屬性

利用擴展屬性,還可以任意動態新增一些原先 xml 中沒有的屬性。

ConstraintLayout中如果想縱向對齊一個控件,需要將兩個屬性的值設置為目標控件ID,分別是top_toTopOfbottom_toBottomOf,若通過擴展屬性就能簡化這個步驟:

inline var View.align_vertical_to: String
    get() {
        return ""
    }
    set(value) {
        top_toTopOf = value
        bottom_toBottomOf = value
    }

其中的top_toTopOfbottom_toBottomOf和上面列舉的start_toStartOf類似,不再贅述。

同樣的,還可以定義align_horizontal_to

新增屬性:視圖點擊監聽器

下面的代碼通過擴展屬性來設置點擊事件:

var View.onClick: (View) -> Unit
    get() {
        return {}
    }
    set(value) {
        setOnClickListener { v -> value(v) }
    }

View擴展屬性onClick,它是函數類型
然后就可以像這樣設置點擊事件了:

private fun buildViewByClDsl(): View =
    ConstraintLayout {
        layout_width = match_parent
        layout_height = match_parent

        ImageView {
            layout_id = "ivBack"
            layout_width = 40
            layout_height = 40
            margin_start = 20
            margin_top = 20
            src = R.drawable.ic_back_black
            start_toStartOf = parent_id
            top_toTopOf = parent_id
            onClick = onBackClick
        }
    }

val onBackClick = { v : View ->
    activity?.finish()
}

得益于函數類型,可以把點擊邏輯封裝在一個 lambda 中并賦值給變量onBackClick

新增屬性: 列表表項點擊事件

RecyclerView沒有子控件點擊事件監聽器,同樣可以通過擴展屬性來解決這個問題:

//'為 RecyclerView 擴展表項點擊監聽器屬性'
var RecyclerView.onItemClick: (View, Int) -> Unit
    get() {
        return { _, _ -> }
    }
    set(value) {
        setOnItemClickListener(value)
    }

//'為 RecyclerView 擴展表項點擊監聽器'
fun RecyclerView.setOnItemClickListener(listener: (View, Int) -> Unit) {
    //'為 RecyclerView 子控件設置觸摸監聽器'
    addOnItemTouchListener(object : RecyclerView.OnItemTouchListener {
        //'構造手勢探測器,用于解析單擊事件'
        val gestureDetector = GestureDetector(context, object : GestureDetector.OnGestureListener {
            override fun onShowPress(e: MotionEvent?) {
            }

            override fun onSingleTapUp(e: MotionEvent?): Boolean {
                //'當單擊事件發生時,尋找單擊坐標下的子控件,并回調監聽器'
                e?.let {
                    findChildViewUnder(it.x, it.y)?.let { child ->
                        listener(child, getChildAdapterPosition(child))
                    }
                }
                return false
            }

            override fun onDown(e: MotionEvent?): Boolean {
                return false
            }

            override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean {
                return false
            }

            override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean {
                return false
            }

            override fun onLongPress(e: MotionEvent?) {
            }
        })

        override fun onTouchEvent(rv: RecyclerView, e: MotionEvent) {

        }

        //'在攔截觸摸事件時,解析觸摸事件'
        override fun onInterceptTouchEvent(rv: RecyclerView, e: MotionEvent): Boolean {
            gestureDetector.onTouchEvent(e)
            return false
        }

        override fun onRequestDisallowInterceptTouchEvent(disallowIntercept: Boolean) {
        }
    })
}

然后可以像這樣為RecyclerView設置表項點擊事件:

RecyclerView {
    layout_id = "rvTest"
    layout_width = match_parent
    layout_height = 300
    onItemClick = onListItemClick
}

 val onListItemClick = { v: View, i: Int ->
    Toast.makeText(context, "item $i is clicked", Toast.LENGTH_SHORT).show()
}

新增屬性:文字變化監聽器

上面兩個新增屬性都可以用一個函數類型的變量表示,如果有多個回調,比如監聽EditText中文字的變化,就可以這樣寫:

inline var TextView.onTextChange: TextWatcher
    get() {
        return TextWatcher()
    }
    set(value) {
        // 為控件設置文字變化監聽器
        val textWatcher = object : android.text.TextWatcher {
            override fun afterTextChanged(s: Editable?) {
                // 將回調的實現委托給 TextWatcher.afterTextChanged
                value.afterTextChanged.invoke(s)
            }

            override fun beforeTextChanged(text: CharSequence?,start: Int,count: Int,after:Int) {
                // 將回調的實現委托給 TextWatcher.beforeTextChanged
                value.beforeTextChanged.invoke(text, start, count, after)
            }

            override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) {
                // 將回調的實現委托給 TextWatcher.onTextChanged
                value.onTextChanged.invoke(text, start, before, count)
            }
        }
        addTextChangedListener(textWatcher)
    }

先為控件設置監聽器,然后將回調的實現委托給TextWatcher中的 lambda:

// 類TextWatcher包含三個函數類型的變量,它們分別對應android.text.TextWatcher接口中的三個回調
class TextWatcher(
    var beforeTextChanged: (
        text: CharSequence?,
        start: Int,
        count: Int,
        after: Int
    ) -> Unit = { _, _, _, _ -> },
    var onTextChanged: (
        text: CharSequence?,
        start: Int,
        count: Int,
        after: Int
    ) -> Unit = { _, _, _, _ -> },
    var afterTextChanged: (text: Editable?) -> Unit = {}
)

然后就可以像這樣使用:

EditText {
    layout_width = match_parent
    layout_height = 50
    textSize = 20f
    background_color = "#00ffff"
    top_toBottomOf = "rvTest"
    onTextChange = textWatcher {
        onTextChanged = { text: CharSequence?, start: Int, count: Int, after: Int ->
            Log.v("test","onTextChanged, text=${text}")
        }
    }
}

其中textWatcher是一個頂層函數,他用于構建TextWatcher實例:

fun textWatcher(init: TextWatcher.() -> Unit): TextWatcher = TextWatcher().apply(init)

findViewById

如何獲取控件實例的引用?得益于 DSL 的語法糖,這套動態布局構建有一種新的方法:

class MainActivity : AppCompatActivity() {
    private var ivBack:ImageView? = null
    private var tvTitle:TextView? = null

    private val rootView by lazy {
        ConstraintLayout {
            layout_width = match_parent
            layout_height = match_parent

            ivBack = ImageView {
                layout_id = "ivBack"
                layout_width = 40
                layout_height = 40
                margin_start = 20
                margin_top = 20
                src = R.drawable.ic_back_black
                start_toStartOf = parent_id
                top_toTopOf = parent_id
            }

            tvTitle = TextView {
                layout_width = wrap_content
                layout_height = wrap_content
                text = "commit"
                textSize = 30f
                textStyle = bold
                align_vertical_to = "ivBack"
                center_horizontal = true
            }
        }
    }
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(rootView)
    }
}

除了這種方式,還有一種常規方式:

fun <T : View> View.find(id: String): T = findViewById<T>(id.toLayoutId())

fun <T : View> AppCompatActivity.find(id: String): T = findViewById<T>(id.toLayoutId())

用 DSL 布局的爽點

簡化多狀態界面的控制邏輯

真實項目中經常有這樣的場景:“在不同狀態下,界面的某個位置展示不同類型的控件”。通常的做法是將不同狀態下的控件都聲明在布局文件中,然后通過代碼根據狀態用setVisibility(View.VISIBLE) + setVisibility(View.GONE)控制。

因為 DSL 是 Kotlin 代碼,所以條件判斷邏輯可以無障礙的插入其中:

class AFragment : Fragment() {
    // 界面狀態
    private val type by lazy { arguments?.getInt("layout-type") }
    
    private val rootView: ConstraintLayout? by lazy {
        ConstraintLayout {
            layout_width = match_parent
            layout_height = match_parent
            
            // 根據界面狀態添加不同的視圖
            if (type == 1) {
                TextView {
                    layout_width = wrap_content
                    layout_height = wrap_content
                    textSize = 14f
                    bottom_toBottomOf = parent_id
                    center_horizontal = true
                    onClick = { _ -> startActivityA() }
                }
            } else {
                ImageView {
                    layout_width = match_parent
                    layout_height = 40
                    bottom_toBottomOf = parent_id
                    onClick = { _ -> startActivityB() }
                }
            }
        }
    }
    
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        return rooView
    }  
}

動態構建布局

image

該界面的內容由服務器返回,即事先不能實現確定控件的個數。除了使用RecyclerView之外,也可以用 DSL 根據數據動態地構建布局:

class GameDialogFragment : DialogFragment() {
    // 構建縱向根布局
    private val rootView: LinearLayout? by lazy {
        LinearLayout {
                layout_width = match_parent
                layout_height = 0
                height_percentage = 0.22f
                orientation = vertical
                top_toTopOf = parent_id
            }
        }
    }
    
    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
        return rooView
    }
    
    fun onGameReturn(gameBeans: GameBean){
        buildGameLayout(gameBeans)
    }
    
   private fun buildGameLayout(gameBeans: GameBean) {
        // 遍歷數據并向根布局中添加控件
        rootView.apply {
            // 游戲屬性標題
            gameBeans.forEach { game ->
                TextView {
                    layout_width = wrap_content
                    layout_height = wrap_content
                    textSize = 14f
                    text = game.attrName
                }
                
                // 自動換行的容器控件
                LineFeedLayout {
                    layout_width = match_parent
                    layout_height = wrap_content
                    horizontal_gap = 8
                    vertical_gap = 8
                    
                    // 游戲屬性名
                    game.attrs.forEachIndexed { index, attr ->
                        TextView {
                            layout_width = wrap_content
                            layout_height = wrap_content
                            textSize = 12f
                            text = attr.name
                            bacground_res = if (attr.isDefault) R.drawable.select else R.drawable.unselect
                        }
                    }
                }
            }
        }
    }
}

talk is cheap, show me the code

GitHub 上的代碼把上述所有的擴展方法和屬性都寫在了一個Layout.kt文件中,在業務界面引入該文件中的所有內容后,就能在寫動態布局時帶有補全功能(只列舉了常用的控件及其屬性的擴展,若有需求可自行添加。)

代碼鏈接在

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容