前言
項(xiàng)目中用到了類(lèi)似于微信朋友圈的九宮格控件,沒(méi)有找到合適的開(kāi)箱即用的成品,只好自己去開(kāi)發(fā)一個(gè)。開(kāi)發(fā)過(guò)程中买猖,每次修改了代碼都要重新編譯運(yùn)行 APP 才能看到實(shí)際效果( 因?yàn)榫庉嬈鞯念A(yù)覽窗口是空白的或者說(shuō)十分簡(jiǎn)陋 ),開(kāi)發(fā)體驗(yàn)十分不友好:
- 硬編碼造數(shù)據(jù)去測(cè)試滋尉,測(cè)試完了還不能留下臟數(shù)據(jù)玉控。
- 控件所在頁(yè)面層次比較深的話,就會(huì)造成一堆不必要的重復(fù)操作狮惜,浪費(fèi)時(shí)間高诺。
- 改一個(gè)屬性值又要重新走一遍。
- ...
其實(shí)市面上大部分控件都是如此碾篡,不跑一遍根本不曉得是什么鬼效果虱而。
為了提高開(kāi)發(fā)效率,也為了 所見(jiàn)即所得 开泽,于是就萌生了去適配 布局編輯器 預(yù)覽窗口 的想法牡拇。過(guò)程不易,但結(jié)果還是比較滿意的穆律。
創(chuàng)建控件
首先我們新建一個(gè)簡(jiǎn)化的九宮格控件惠呼,看下默認(rèn)的預(yù)覽效果。
-
MainActivity.kt
package com.example.myapplication import android.os.Bundle import androidx.appcompat.app.AppCompatActivity class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) findViewById<NineGridView<Int>>(R.id.nineGridView).apply { setList((0 until 7).toList()) } } }
-
res/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" tools:context=".MainActivity"> <com.example.myapplication.NineGridView android:id="@+id/nineGridView" android:layout_width="wrap_content" android:layout_height="wrap_content" /> </FrameLayout>
-
NineGridView.kt
package com.example.myapplication import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.annotation.LayoutRes import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView class NineGridView<T> @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int? = null) : RecyclerView(context, attrs, defStyleAttr ?: androidx.recyclerview.R.attr.recyclerViewStyle) { private val mAdapter: NineGridViewAdapter<T> fun setList(mData: List<T>) = mAdapter.setList(mData) init { layoutManager = GridLayoutManager(context, 3) mAdapter = NineGridViewAdapter() adapter = mAdapter } } class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) class NineGridViewAdapter<T>( @LayoutRes private val layoutResId: Int = R.layout.sample_grid_item, data: MutableList<T>? = null ) : RecyclerView.Adapter<ViewHolder>() { private var data = data ?: arrayListOf() fun setList(mData: List<T>) = data.run { if (this != mData) { clear() addAll(mData) } notifyDataSetChanged() } private fun ViewGroup.getItemView(@LayoutRes layoutResId: Int): View = LayoutInflater.from(context).inflate(layoutResId, this, false) override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(parent.getItemView(layoutResId)) @SuppressLint("SetTextI18n") override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.itemView.apply { findViewById<TextView>(R.id.text).text = "item-${data[position]?.toString()}" } } override fun getItemCount() = data.size }
-
res/layout/sample_grid_item.xml
<?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" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:padding="5dp"> <TextView android:id="@+id/text" android:layout_width="match_parent" android:layout_height="0dp" android:background="#4D000000" android:gravity="center" android:textColor="#000000" android:textSize="36sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintDimensionRatio="H,1:1" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" tools:text="@tools:sample/lorem" /> </androidx.constraintlayout.widget.ConstraintLayout>
打開(kāi) APP 可以看到我們想要的九宮格效果了:
然而在編輯器里的預(yù)覽效果卻是這樣的:
添加 工具屬性
Android Studio 支持
tools
命名空間中的多種 XML 屬性峦耘,這些屬性支持設(shè)計(jì)時(shí)功能(例如要在 Fragment 中顯示哪種布局)或編譯時(shí)行為(例如要對(duì) XML 資源應(yīng)用哪種壓縮模式)剔蹋。在您構(gòu)建應(yīng)用時(shí),構(gòu)建工具會(huì)移除這些屬性辅髓,因此它們不會(huì)對(duì) APK 大小或運(yùn)行時(shí)行為產(chǎn)生影響泣崩。
添加 工具屬性 可以使編輯器的預(yù)覽效果變得豐富起來(lái),相關(guān)用法和屬性值官方文檔中介紹的也比較詳細(xì)了利朵,我就提幾個(gè)注意點(diǎn):
- 可以使用
tools:
前綴的屬性替換大部分的普通屬性(android:xxx
、app:xxx
)猎莲,可以和普通屬性同時(shí)存在绍弟,但在預(yù)覽時(shí)具有更高的優(yōu)先級(jí)。 -
tools:
前綴的屬性只在預(yù)覽時(shí)生效著洼,不會(huì)影響 APP 實(shí)際的運(yùn)行效果樟遣,也就是說(shuō)可以在豐富預(yù)覽效果的同時(shí)不會(huì)產(chǎn)生臟數(shù)據(jù)而叼。 -
tools:
前綴的屬性不支持代碼提示或者說(shuō)只支持官方文檔中提到的幾個(gè)特定屬性,可以先寫(xiě)成普通屬性然后替換為tools:
前綴豹悬。 - 可以使用 "@tools:sample/*" 資源 將占位符數(shù)據(jù)或圖片注入到視圖中葵陵,除了自帶的數(shù)據(jù)外也可以 自定義 sample data 。
我們的 NineGridView
繼承自 RecyclerView
瞻佛,所以我們可以通過(guò) tools:itemCount
和 tools:listitem
屬性來(lái)修改預(yù)覽效果脱篙。
<com.example.myapplication.NineGridView
android:id="@+id/nineGridView"
android:layout_width="match_parent"
android:layout_height="match_parent"
+ tools:itemCount="9"
+ tools:listitem="@layout/sample_grid_item" />
添加自定義屬性
添加 工具屬性 后預(yù)覽效果和 APP 中的實(shí)際顯示效果比較接近了,但這樣就結(jié)束了嗎伤柄?
既然是自定義的控件绊困,那自然少不了自定義的屬性,我們添加一個(gè)自定義屬性再看下效果如何适刀。
-
res/values/attrs.xml
<?xml version="1.0" encoding="utf-8"?> <resources> <declare-styleable name="NineGridView"> <!-- 讀取 tools:itemCount 屬性 --> <attr name="itemCount" format="integer" /> <!-- item 的大小 - 單位: px --> <attr name="size" format="dimension" /> </declare-styleable> </resources>
-
NineGridView.kt
class NineGridView<T> @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int? = null) : ... + private var size: Int? = null + private var itemCount = 0 ... init { + attrs?.also { + context.withStyledAttributes(it, R.styleable.NineGridView) { + itemCount = getInt(R.styleable.NineGridView_itemCount, itemCount) + R.styleable.NineGridView_size.also { index -> + if (hasValue(index)) size = getDimensionPixelSize(index, LayoutParams.MATCH_PARENT) + } + } + } ... - mAdapter = NineGridViewAdapter() + mAdapter = NineGridViewAdapter(size) ... } } ... class NineGridViewAdapter<T>( + private val size: Int?, @LayoutRes private val layoutResId: Int = R.layout.sample_grid_item, data: MutableList<T>? = null ) : RecyclerView.Adapter<ViewHolder>() { ... override fun onBindViewHolder(holder: ViewHolder, position: Int) { holder.itemView.apply { findViewById<TextView>(R.id.text).text = "item-${data[position]?.toString()}" + layoutParams?.run { + width = size ?: ViewGroup.LayoutParams.MATCH_PARENT + layoutParams = this + } } } ... }
-
res/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?> <FrameLayout 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.example.myapplication.NineGridView android:id="@+id/nineGridView" android:layout_width="wrap_content" android:layout_height="wrap_content" app:size="50dp" tools:itemCount="9" tools:listitem="@layout/sample_grid_item" /> </FrameLayout>
重新運(yùn)行 APP 可以看到 item 的大小已經(jīng)產(chǎn)生了變化( app:size
所設(shè)定的大小 )秤朗,而預(yù)覽窗口沒(méi)有發(fā)生變化。
注意:
Java / Kotlin 代碼改動(dòng)之后要重新編譯才能使預(yù)覽窗口生效笔喉。
想要解決這個(gè)問(wèn)題取视,我們需要了解預(yù)覽窗口是如何完成渲染的,但是既看不到源碼常挚,也沒(méi)辦法調(diào)試作谭,又陷入了另一個(gè)坑。
預(yù)覽原理分析
經(jīng)過(guò)不斷嘗試之后待侵,我發(fā)現(xiàn)了一個(gè)方法就是 拋異常 丢早,在預(yù)覽窗口進(jìn)行渲染的過(guò)程中,如果遇到了異常就會(huì)中止并提示錯(cuò)誤( 如下圖所示 )秧倾。( 參考 )
fun debugInEditMode(vararg contents: Any?): Unit = throw RuntimeException(contents.joinToString { it?.toString() ?: "null" })
于是我們就可以不斷調(diào)整拋異常的位置來(lái)推斷究竟執(zhí)行了哪些方法怨酝,最終推斷出大概的執(zhí)行過(guò)程用下面的偽代碼來(lái)表示:
val context: com.android.layoutlib.bridge.android.BridgeContext
LayoutInflater.from(context).inflate()
val view = BridgeInflater.createViewFromTag(..., context, attrs, ...)
val view = NineGridView(context, attrs)
setupViewInContext(view, attrs)
ReflectionUtils.isInstanceOf(view, RecyclerViewUtil.CN_RECYCLER_VIEW) {
RecyclerViewUtil.setAdapter(view, context, layoutlibCallback, adapterLayout, itemCount)
setLayoutManager(view, layoutMgrClassName, context, layoutlibCallback)
val adapter = RecyclerViewUtil.createAdapter(layoutlibCallback, adapterClassName)
view.setAdapter(adapter)
}
parent.addView(view, params)
所以,無(wú)法預(yù)覽 size
屬性的原因就找到了:
預(yù)覽時(shí)另外設(shè)置了一個(gè)內(nèi)置的 Adapter
把我們自定義的 NineGridViewAdapter
給覆蓋了那先。
既然我們自己的 adapter
用不上农猬,那么可以擴(kuò)展 layoutManager
來(lái)實(shí)現(xiàn)同樣的效果。但是不推薦這樣做售淡,因?yàn)檫@樣做和實(shí)際運(yùn)行的 APP 不是一種實(shí)現(xiàn)方式很容易埋雷斤葱。
(layoutManager as? GridLayoutManager)?.run {
spanSizeLookup = object : SpanSizeLookup() {
override fun getSpanSize(position: Int) = 1.also {
getChildAt(position)?.apply {
layoutParams?.run {
width = size ?: LayoutParams.MATCH_PARENT
layoutParams = this
}
}
}
}
}
更徹底的解決辦法:通過(guò)覆蓋 setAdapter
方法來(lái)屏蔽掉這個(gè)行為。
class NineGridView<T> @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int? = null) :
...
+ override fun setAdapter(adapter: Adapter<*>?) {
+ if (!isInEditMode) super.setAdapter(adapter)
+ }
...
init {
...
- adapter = mAdapter
+ // 由于禁用了 setAdapter 揖闸, 需要自己填充數(shù)據(jù)
+ super.setAdapter(mAdapter)
+ // 這里寫(xiě)死了數(shù)據(jù)揍堕,也可以借助 sample data 自定義數(shù)據(jù)
+ @Suppress("UNCHECKED_CAST")
+ if (isInEditMode) setList((0 until itemCount).toList() as List<T>)
}
}
可以看到預(yù)覽終于生效了。
現(xiàn)在我們?cè)?XML 中不斷修改屬性值即可實(shí)時(shí)預(yù)覽效果汤纸,無(wú)需再打開(kāi) APP 進(jìn)行調(diào)試衩茸。
需要添加其他自定義屬性( 比如:縮放,間距贮泞,布局等 )依葫蘆畫(huà)瓢即可楞慈。
isInEditMode
眼尖的朋友應(yīng)該看到了上面的代碼中使用了 isInEditMode()
方法進(jìn)行判斷幔烛,使用這個(gè)方法可以幫助我們輕松識(shí)別控件的運(yùn)行環(huán)境( APP 還是編輯器 )。
分享一個(gè) 擴(kuò)展函數(shù) 來(lái)避免不斷傳遞
isInEditMode
變量為參數(shù):private var IS_IN_EDIT_MODE: Boolean? = null val Context.isInEditMode get() = IS_IN_EDIT_MODE ?: View(this).isInEditMode.also { IS_IN_EDIT_MODE = it }
在 android.view.View
類(lèi)中它的定義是這樣的:
public boolean isInEditMode() {
return false;
}
既然在預(yù)覽時(shí)能返回 true
囊蓝,那肯定是有地方對(duì)其進(jìn)行了修改饿悬,于是定位到下面的代碼:
// android-23/com.android.tools.layoutlib.create.CreateInfo
public final static String[] DELEGATE_METHODS = new String[] {
...
"android.view.View#isInEditMode",
...
};
根據(jù)調(diào)用鏈一層一層定位到:
// android-23/com.android.tools.layoutlib.create
Main.main()
AsmGenerator.generate()
transform()
new DelegateClassAdapter()
大概意思就是利用 ASM 重寫(xiě)相關(guān)的類(lèi)和方法,然后打包字節(jié)碼到一個(gè) jar 包中聚霜,于是順藤摸瓜找到了 layoutlib.jar :
// ${ANDROID_SDK_ROOT}/platforms/android-xx/data/layoutlib.jar
@LayoutlibDelegate
public boolean isInEditMode() {
return View_Delegate.isInEditMode(this);
}
public class View_Delegate {
public View_Delegate() {
}
@LayoutlibDelegate
static boolean isInEditMode(View thisView) {
return true;
}
@LayoutlibDelegate
static IBinder getWindowToken(View thisView) {
Context baseContext = BridgeContext.getBaseContext(thisView.getContext());
return baseContext instanceof BridgeContext ? ((BridgeContext)baseContext).getBinder() : null;
}
}
可以推斷出預(yù)覽窗口利用 layoutlib.jar 代替了 android.jar 來(lái)構(gòu)建渲染環(huán)境狡恬。
總結(jié)
經(jīng)過(guò)前面的分析,編輯器預(yù)覽的原理已經(jīng)一目了然了:
調(diào)用控件兩個(gè)參數(shù)的構(gòu)造方法進(jìn)行初始化俯萎,然后加入到父視圖中傲宜。
因此,我們要做的適配工作也很簡(jiǎn)單:
- 在構(gòu)造方法中利用
isInEditMode
和屬性值填充一些用于預(yù)覽的數(shù)據(jù)夫啊。 - 如果 layoutlib 做了額外的初始化配置( 如上文的
setAdapter()
)影響到了控件的正常渲染函卒,需要想辦法去禁用。 - 利用
isInEditMode
過(guò)濾掉一些不必要的初始化操作以加速渲染撇眯。
按照上面的步驟就能輕松為我們自定義的控件提供比較完美的預(yù)覽效果了( 和實(shí)際運(yùn)行效果基本保持一致 )报嵌。
但是由于無(wú)法調(diào)試,遇到代碼沒(méi)有生效的問(wèn)題還是會(huì)比較難受熊榛,需要不斷黑盒測(cè)試定位問(wèn)題锚国,要有一定耐心。