Android 實(shí)時(shí)預(yù)覽 XML 中的自定義控件

前言


項(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é)果還是比較滿意的穆律。

cover

創(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 可以看到我們想要的九宮格效果了:

APP 預(yù)覽 1

然而在編輯器里的預(yù)覽效果卻是這樣的:

編輯器預(yù)覽 1

添加 工具屬性


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:xxxapp: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:itemCounttools: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ù)覽 2

添加自定義屬性


添加 工具屬性 后預(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ù)覽窗口生效笔喉。

APP 預(yù)覽 2

想要解決這個(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" })
編輯器預(yù)覽 3

于是我們就可以不斷調(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ù)覽終于生效了。

編輯器預(yù)覽 4

現(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)題锚国,要有一定耐心。


轉(zhuǎn)載請(qǐng)注明出處: https://github.com/anyesu/blog/issues/40

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末玄坦,一起剝皮案震驚了整個(gè)濱河市血筑,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌煎楣,老刑警劉巖豺总,帶你破解...
    沈念sama閱讀 206,968評(píng)論 6 482
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異择懂,居然都是意外死亡喻喳,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,601評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門(mén)困曙,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)表伦,“玉大人,你說(shuō)我怎么就攤上這事慷丽”暮撸” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 153,220評(píng)論 0 344
  • 文/不壞的土叔 我叫張陵要糊,是天一觀的道長(zhǎng)纲熏。 經(jīng)常有香客問(wèn)我,道長(zhǎng),這世上最難降的妖魔是什么赤套? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 55,416評(píng)論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮珊膜,結(jié)果婚禮上容握,老公的妹妹穿的比我還像新娘梗掰。我一直安慰自己祭钉,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,425評(píng)論 5 374
  • 文/花漫 我一把揭開(kāi)白布遣妥。 她就那樣靜靜地躺著竹祷,像睡著了一般谈跛。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上塑陵,一...
    開(kāi)封第一講書(shū)人閱讀 49,144評(píng)論 1 285
  • 那天感憾,我揣著相機(jī)與錄音,去河邊找鬼令花。 笑死阻桅,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的兼都。 我是一名探鬼主播嫂沉,決...
    沈念sama閱讀 38,432評(píng)論 3 401
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼扮碧!你這毒婦竟也來(lái)了趟章?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 37,088評(píng)論 0 261
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤慎王,失蹤者是張志新(化名)和其女友劉穎蚓土,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體柬祠,經(jīng)...
    沈念sama閱讀 43,586評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡北戏,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,028評(píng)論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了漫蛔。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片嗜愈。...
    茶點(diǎn)故事閱讀 38,137評(píng)論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖莽龟,靈堂內(nèi)的尸體忽然破棺而出蠕嫁,到底是詐尸還是另有隱情,我是刑警寧澤毯盈,帶...
    沈念sama閱讀 33,783評(píng)論 4 324
  • 正文 年R本政府宣布剃毒,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏赘阀。R本人自食惡果不足惜益缠,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,343評(píng)論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望基公。 院中可真熱鬧幅慌,春花似錦、人聲如沸轰豆。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 30,333評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)酸休。三九已至骂租,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間斑司,已是汗流浹背渗饮。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 31,559評(píng)論 1 262
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留宿刮,地道東北人抽米。 一個(gè)月前我還...
    沈念sama閱讀 45,595評(píng)論 2 355
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像糙置,于是被迫代替她去往敵國(guó)和親云茸。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,901評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容