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

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

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

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

靜態佈局

測試佈局以下圖所示:git

與之對應的 xml 文件以下(有點長,能夠直接跳過):github

<?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 層嵌套的佈局。web

把它設置爲 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 的概念,引用以下:

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

再好比這篇 是 DSL 在項目中的實戰,介紹瞭如何用 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() } 複製代碼

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 基礎上,加上數據綁定功能。

talk is cheap, show me the code

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

代碼鏈接在

推薦閱讀

  1. Kotlin 實戰 | 幹掉 findViewById 和 Activity 中的業務邏輯
  2. Kotlin實戰:使用DSL構建結構化API去掉冗餘的接口方法
  3. Kotlin進階:動畫代碼太醜,用DSL動畫庫拯救,像說話同樣寫代碼喲!
  4. Kotlin基礎:白話文轉文言文般的Kotlin常識

本文使用 mdnice 排版

相關文章
相關標籤/搜索