優雅地結合 Kotlin 特性深度解耦標題欄

語言: CN / TW / HK

theme: devui-blue

持續創作,加速成長!這是我參與「掘金日新計劃 · 6 月更文挑戰」的第1天,點選檢視活動詳情

前言

標題欄是每個 App 都會有的控制元件,基本每個專案都會對標題欄進行封裝。常見的方式是寫一個標題欄的佈局,用 <include/> 標籤新增到每一個頁面中,然後在基類裡提供初始化標題欄的方法。或者是實現一個標題欄的自定義控制元件,使用自定義屬性配置標題欄。

這兩種常見的標題欄封裝有個比較繁瑣的地方是每次都要在佈局內加標題欄控制元件,那麼有沒什麼辦法在不改動佈局程式碼動態新增標題欄呢?當然有啦,ActionBar 不就是麼,宣告個主題都加了個標題欄。原理其實很簡單,就是一波巧妙的偷天換日操作。

個人很久之前就研究過 ActionBar 原始碼,實現了不改動佈局新增標題欄,感覺挺爽的。不過那只是簡單地封裝,樣式也類似 ActionBar 是固定的,換個專案用可能就不合適了。當我想更進一步解耦時,發現遠比想象中的難,因為樣式的可能性實在太多了,還可能有些奇怪的需求。如何深度解耦個人思考了非常非常久,終於在熟悉 Kotlin 後摸索了一套比較理想的方案。

下面和大家分享一下個人深度解耦標題欄的思路和方案。

解耦思路

整理一下解耦標題欄需要實現的效果:

  • 能在不改動佈局程式碼的情況下動態新增標題欄;
  • 能擴充套件所需的標題欄的配置引數,比如有標題跑馬燈等小眾需求也能支援;
  • 能新增非線性的標題欄,比如可滑動隱藏的標題欄;
  • 支援更新標題欄,可以不用,但不能沒有;
  • 解耦標題欄樣式,能配置全域性樣式或單個頁面的樣式,不需要改動新增和更新標題欄的程式碼;

不修改佈局程式碼新增標題欄

先來看看 ActionBar 為什麼宣告主題就能加標題欄的,很容易猜到是在 setContentView(id) 做了處理,那麼我們看下 AppCompatActivity 的程式碼:

java @Override public void setContentView(@LayoutRes int layoutResID) { initViewTreeOwners(); getDelegate().setContentView(layoutResID); }

這裡是用了代理模式,那麼我們找到代理類 AppCompatDelegateImplsetContentView(id) 函式,看下做了什麼。

java @Override public void setContentView(int resId) { ensureSubDecor(); ViewGroup contentParent = mSubDecor.findViewById(android.R.id.content); contentParent.removeAllViews(); LayoutInflater.from(mContext).inflate(resId, contentParent); mAppCompatWindowCallback.getWrapped().onContentChanged(); }

可以看到傳進來的佈局是填充到了 mSubDecor 控制元件裡的 id 為 android.R.id.contentViewGroup 中,那麼這個 mSubDecor 是怎麼來的呢?第一行執行了個 ensureSubDecor() 函式,應該能找到相關線索,我們跟過去看看。

java private void ensureSubDecor() { if (!mSubDecorInstalled) { mSubDecor = createSubDecor(); // 省略部分程式碼 } }

終於找到了關鍵的程式碼,mSubDecor 是通過 createSubDecor() 函式建立的,mSubDecor 是個怎麼樣的控制元件應該能在該函式中找到答案。函式程式碼有點多,下面是主要的邏輯。

```java private ViewGroup createSubDecor() { TypedArray a = mContext.obtainStyledAttributes(R.styleable.AppCompatTheme); // ...

// 判斷是不是需要 ActionBar 的主題
// requestWindowFeature(featureId) 函式會修改 mHasActionBar 的值
if (a.getBoolean(R.styleable.AppCompatTheme_windowNoTitle, false)) {
    requestWindowFeature(Window.FEATURE_NO_TITLE);
} else if (a.getBoolean(R.styleable.AppCompatTheme_windowActionBar, false)) {
    requestWindowFeature(FEATURE_SUPPORT_ACTION_BAR);
}

// ...

    } else if (mHasActionBar) {
        // 如果需要 ActionBar,subDecor 會填充 abc_screen_toolbar.xml 的佈局
        // abc_screen_toolbar.xml 有一個 ActionBar 和 id 為 R.id.action_bar_activity_content 的容器
        subDecor = (ViewGroup) LayoutInflater.from(themedContext)
                .inflate(R.layout.abc_screen_toolbar, null);
    }

// ...

final ContentFrameLayout contentView = (ContentFrameLayout) subDecor.findViewById(
        R.id.action_bar_activity_content);

final ViewGroup windowContentView = (ViewGroup) mWindow.findViewById(android.R.id.content);
if (windowContentView != null) {
    while (windowContentView.getChildCount() > 0) {
        final View child = windowContentView.getChildAt(0);
        windowContentView.removeViewAt(0); // 把原 android.R.id.content 容器的內容移除
        contentView.addView(child); // 新增到 R.id.action_bar_activity_content 的容器中
    }

    // 把 R.id.action_bar_activity_content 改為 android.R.id.content
    windowContentView.setId(View.NO_ID);
    contentView.setId(android.R.id.content); 
}

// ...

return subDecor;

} ```

先根據主題判斷需不需要 ActionBar,如果需要 ActionBar,則 subDecor 會填充 abc_screen_toolbar.xml 佈局的控制元件。該佈局有一個 ActionBar 和一個 id 為 R.id.action_bar_activity_content 的容器,之後就是把原 android.R.id.content 容器的內容取出放到 subDecor 的容器中,用 subDecor 替代原內容。

簡而言之,就是將 android.R.id.content 的內容取出來,用 R.layout.abc_screen_toolbar 佈局進行裝飾再放回去。用了一波偷天換日的操作給佈局添加了 ActionBar。

現在一些預設頁庫也基於了這個原理,因為顯示預設頁通常需要給內容加個父容器,然後在父容器裡切換預設頁。而通過上述方式能動態新增一個父容器,就能在不改動佈局程式碼的情況下顯示預設頁。既然新增標題欄會套一層容器,那麼可以順便支援在容器切換預設頁。

解耦標題欄樣式

ActionBar 主題會用 abc_screen_toolbar.xml 佈局去裝飾,所以樣式是固定的。我們當然不可能像這樣寫死一個預設的佈局,應該要能根據不同專案去配置。其實可以不用寫個佈局,直接建立個 LinearLayout,往裡面新增標題欄控制元件和原有的內容。

但是標題欄控制元件怎麼得到呢,個人做過非常多的嘗試,最終發現還是用介面卡模式更合適。所以我們封裝一個建立標題欄的介面卡基類:

```kotlin abstract class BaseToolbarAdapter { abstract fun onCreateView(inflater: LayoutInflater, parent: ViewGroup): View

abstract fun onBindView() } ```

使用介面卡不僅能複用已有的標題欄佈局,還能複用自定義的標題欄控制元件,對第三方的標題欄庫進行相容。

然後在 Application 初始化,之後新增標題欄就通過該介面卡建立標題欄。

```kotlin class ToolbarAdapter: BaseToolbarAapter { // ... }

ToolbarManager.init(ToolbarAdapter()) ```

這樣解耦已經比 ActionBar 更靈活了,但是個人認為還不夠。這只是簡單地在頂部新增控制元件,那麼更復雜的情況就不適用了,比如支援滑動隱藏的標題欄。所以寫死 LinearLayout 並不好,可以進一步對裝飾的控制元件進行解耦。

從前面 ActionBar 的相關原始碼可以看出裝飾控制元件有兩個關鍵資訊,第一個是用什麼進行裝飾,原始碼裡是使用 abc_screen_toolbar.xml 佈局進行裝飾。第二個是原本的內容新增到哪裡,原始碼是新增到佈局裡的 R.id.action_bar_activity_content 容器。所以這兩個不應該寫死了,那麼我們再封裝一個裝飾控制元件的基類進行配置。

```kotlin abstract class DecorViewDelegate { abstract fun onCreateDecorView(context: Context, inflater: LayoutInflater): View

abstract fun getContentParent(decorView: View): ViewGroup } ```

這樣就進一步地解耦,想新增一個能滑動隱藏的標題欄都沒問題,只需實現佈局,指明把內容新增到哪。

當然絕大多數情況還是簡單地在頂部新增標題欄,我們用 LinearLayout 實現一個預設的裝飾控制元件介面卡。

```kotlin class LinearDecorViewDelegate(private val views: List) : DecorViewDelegate() { private lateinit var contentParent: FrameLayout

override fun onCreateDecorView(context: Context, inflater: LayoutInflater) = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL contentParent = FrameLayout(context) contentParent.layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) views.forEach { addView(it) } addView(contentParent) }

override fun getContentParent(decorView: View) = contentParent } ```

新增標題欄

樣式已經解耦了,我們可以定義一個 setToolbar() 函式給原本的佈局新增標題欄。不過問題來了,引數怎麼定義才合適了?

雖然通常只有標題欄、返回鍵、右側按鈕或文字,但是寫死這麼幾個引數並不好,不然複雜點的情況就不適用了。比如想在圖示下方加個文字,想讓標題文字能滾動,還可能會有更小眾的需求,把全部可能的引數都列出來很不現實。所以最好是給出常用的配置引數,並且支援讓開發者根據自己的需求去增加引數。

我們先定義一個 ToolbarConfig 類來持有標題欄常見的配置引數。

```kotlin class ToolbarConfig( var title: String? = null, var navBtnType: NavBtnType = NavBtnType.ICON, @DrawableRes var rightIcon: Int? = null, var rightText: String? = null, var onRightClickListener = View.OnClickListener? = null )

enum class NavBtnType { ICON, NONE } ```

封裝一個 Activity.setToolbar() 擴充套件函式,引數是一個 ToolbarConfig 的高階函式。

kotlin fun Activity.setToolbar(block: ToolbarConfig.() -> Unit) { // 根據 ActionBar 原理動態新增標題欄 }

然後就能在 Activity 用 lambda 表示式設定標題欄了。

kotlin setToolbar { title = "title" navBtnType = NavBtnType.NONE }

目前 ToolbarConfig 的屬性是寫死的,如何讓開發者能方便地增加屬性呢?比較容易想到的是繼承 ToolbarConfig 去增加屬性,這要給介面卡和設定標題欄的函式增加泛型,用起來麻煩了很多,個人不太推薦這麼用。

經過了大量時間的思考和嘗試後,終於摸索出了一個滿意的封裝方案,使用擴充套件屬性 + 屬性委託。首先給 ToolbarConfig 增加一個 HashMap 屬性,用來儲存後續增加的變數。

kotlin class ToolbarConfig( // ... val extras: HashMap<String, Any?> = HashMap(), )

為什麼要這麼做呢?因為想使用 Kotlin 的擴充套件屬性,而直接用擴充套件屬性是不行的,由於擴充套件是靜態的,不知道該把變數存哪。所以 ToolbarConfig 需要具備快取任意變數的功能,這樣才能擴充套件非靜態的屬性,比如:

kotlin var ToolbarConfig.isTitleRolled: boolean? get() = extras["isTitleRolled"] as? boolean set(value) { extras["isTitleRolled"] = value }

這樣能增加擴充套件屬性啦,但是程式碼還是有點繁瑣,可以再結合屬性委託來簡化程式碼。我們封裝一個委託函式,返回一個 ToolbarConfig 的屬性委託類。

```kotlin fun toolbarExtras() = object : ReadWriteProperty { @Suppress("UNCHECKED_CAST") override fun getValue(thisRef: ToolbarConfig, property: KProperty<*>): T? = thisRef.extras[property.name] as? T

override fun setValue(thisRef: ToolbarConfig, property: KProperty<*>, value: T?) { thisRef.extras[property.name] = value } } ```

通過屬性委託就能進一步簡化擴充套件屬性程式碼了。

kotlin var ToolbarConfig.isTitleRolled: boolean? by toolbarExtras()

之後設定標題欄就能使用該擴充套件屬性了,當然要先在標題欄的介面卡裡增加相應的 UI 處理。

kotlin setToolbar { title = "title" isTitleRolled = true // 擴充套件屬性 }

更新標題欄

前面封裝了一個 Activity.setToolbar() 擴充套件函式,但擴充套件是靜態的,如果想再封裝一個 Activity.updateToolbar() 擴充套件函式,是很難知道新增過怎麼樣的標題欄,就不知道如何去更新了。

那麼用擴充套件實現的話,需要在設定標題欄時有個返回值去支援更新標題欄的操作,用法就改成:

```kotlin private lateinit var toolbarManager: ToolbarManager

toolbarManager = setToolbar { title = "title" navBtnType = NavBtnType.NONE } ```

kotlin // 更新標題欄 toolbarManager.updateToolbar { title = "other title" }

能用,但是不太好用,設定個標題欄還會返回個管理類太奇怪了。雖然擴充套件的侵入性更低,但是這種使用方式降低了程式碼可讀性,增加些維護成本。

個人思考了很久後還是決定再結合 Kotlin 委託進行封裝,把返回值細節給隱藏了。

定義標題欄的介面和委託類。

kotlin interface IToolbar { fun Activity.setToolbar(block: ToolbarConfig.() -> Unit) fun Activity.updateToolbar(block: ToolbarConfig.() -> Unit) }

```kotlin class ToolbarDelegate : IToolbar { private lateinit var toolbarManager: ToolbarManager

fun Activity.setToolbar(block: ToolbarConfig.() -> Unit) { toolbarManager = // 設定標題欄 }

fun Activity.updateToolbar(block: ToolbarConfig.() -> Unit) { // 更新標題欄 } } ```

修改一下基類,實現標題欄介面,並且使用 Kotlin 委託的特性把介面委託給代理物件。簡單地來說就是增加 IToolbar by ToolbarDelegate() 程式碼。

kotlin abstract class BaseActivity : AppCompatActivity(), IToolbar by ToolbarDelegate() { // ... }

之後就能愉快地設定和更新標題欄啦~

```kotlin class MainActivity : BaseActivity() {

override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // ... setToolbar { title = "title" navBtnType = NavBtnType.NONE } }

private fun onEdit() { updateTitle { title = "Select 0" } // ... } } ```

這就是整體的解耦思路了,下面會介紹具體的實現方案,有興趣的可以結合解耦思路去閱讀原始碼。

實現方案

個人基於以上思路封裝了一個能幫助大家解耦標題欄的庫—— LoadingStateView(早期叫 LoadingHelper,後來改名了)。可以深度解耦標題欄和載入中、載入失敗、無資料等預設頁。

核心功能的實現類僅有 200 多行程式碼,最新版增加 Kotlin 委託用法後總共 500 多行程式碼。雖然程式碼量增加了一些,但是易用非常多,只需小改基類,註冊預設樣式,即可快速新增標題欄和預設頁。

為什麼不分成標題欄庫和預設頁庫?不少人會有這個疑惑,這是個人深思熟慮很久才做的決定,有以下考慮: - 支援給內容和預設頁新增頭部,所以具有管理標題欄的應用場景,感覺沒什麼不妥。 - 大多數情況下標題欄和預設頁關聯性很強,因為預設頁往往是要在標題欄下方顯示,如果分成兩個庫就經常需要呼叫兩個工具類,使用起來更加麻煩。 - 分成兩個庫可能會多一層無意義的佈局巢狀。 - 即使寫在一起,核心功能的實現類才 200 多行程式碼,還要啥腳踏車。由於介面卡和 View 的快取程式碼能複用,在解耦預設頁後,僅加多幾十行程式碼就能把標題欄給一起解耦了,何樂而不為。

準備工作

需要修改基類,只需簡單的兩步就可以把本庫的所有功能整合到基類。不會影響到已有的程式碼,只是給基類擴充套件了新的方法。

新增依賴:

groovy allprojects { repositories { // ... maven { url 'https://www.jitpack.io' } } }

groovy dependencies { implementation 'com.github.DylanCaiCoding.LoadingStateView:loadingstateview-ktx:4.0.1' }

修改基類步驟:

  1. 實現 LoadingState by LoadingStateDelegate(), OnReloadListener, Decorative 介面,其中 LoadingState 介面委託給了 LoadingStateDelegate 代理類。
  2. 在 Activity 的 setContentView() 方法後執行 decorateContentView(this, this)。在 Fragment 的 onCreateView() 返回 view.decorate(this, this)

base_activity_code.png

檢視程式碼 ```kotlin abstract class BaseActivity(private val layoutRes: Int) : AppCompatActivity(), LoadingState by LoadingStateDelegate(), OnReloadListener, Decorative { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(layoutRes) decorateContentView(this, this) } } ```

base_fragment_code.png

檢視程式碼 ```kotlin abstract class BaseFragment(private val layoutRes: Int) : Fragment(), LoadingState by LoadingStateDelegate(), OnReloadListener, Decorative { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { val root = inflater.inflate(layoutRes, container, false) return root.decorate(this, this) } } ```

這樣改造基類後會得到以下的增強:

  • 在不影響已有程式碼的情況下,增加了 LoadingState 介面提供的常用方法,該介面包含了 LoadingStateView 所有功能。
  • 如果擔心對基類有什麼影響,在頁面重寫 override val isDecorated = false 可以把一切還原,即使呼叫了新增的介面方法也不會生效,請放心使用。

設定標題欄

先註冊標題欄樣式,之後才能呼叫 setToolbar(...) 方法。

建立一個類繼承 BaseToolbarViewDelegate,通常專案都有各自的標題欄封裝,我們能基於已有的標題欄佈局或者自定義的標題欄控制元件實現 ToolbarViewDelegate。比如:

```kotlin class ToolbarViewDelegate : BaseToolbarViewDelegate() { private lateinit var tvTitle: TextView private lateinit var ivLeft: ImageView private lateinit var ivRight: ImageView

override fun onCreateToolbar(inflater: LayoutInflater, parent: ViewGroup): View { val view = inflater.inflate(R.layout.layout_toolbar, parent, false) tvTitle = view.findViewById(R.id.tv_title) ivLeft = view.findViewById(R.id.iv_left) ivRight = view.findViewById(R.id.iv_right) return view }

override fun onBindToolbar(config: ToolbarConfig) { tvTitle.text = config.title

if (config.navBtnType == NavBtnType.NONE) {
  ivLeft.visibility = View.GONE
} else {
  ivLeft.setOnClickListener(config.onNavClickListener)
  ivLeft.visibility = View.VISIBLE
}

if (config.rightIcon != null) {
  ivRight.setImageResource(config.rightIcon!!)
  ivRight.setOnClickListener(config.onRightClickListener)
  ivRight.visibility = View.VISIBLE
}

} } ```

ToolbarConfig 提供了幾個常用的屬性,可以根據需要選擇處理,比如上述例子只實現了有無返回鍵和右側按鈕的邏輯。

| 屬性 | 含義 | | -------------------- | -------------------- | | title | 標題 | | navBtnType | 導航 (左側) 按鈕型別 | | navIcon | 導航 (左側) 圖示 | | navText | 導航 (左側) 文字 | | onNavClickListener | 導航 (左側) 按鈕點選事件 | | rightIcon | 右側圖示 | | rightText | 右側文字 | | onRightClickListener | 右側按鈕點選事件 |

onNavClickListener 預設執行 finish() 操作,可直接設定為返回鍵的點選事件。navBtnType 預設型別是 NavBtnType.ICON,還有 NavBtnType.NONENavBtnType.TEXTNavBtnType.ICON_TEXT型別。其它的屬性預設為空,為空的時候不用處理使用預設樣式即可。

當然這點屬性肯定不能滿足所有的需求,所以本庫支援給 ToolbarConfig 增加擴充套件屬性。比如需要動態修改右側文字顏色:

```kotlin var ToolbarConfig.rightTextColor: Int? by toolbarExtras() // 增加 rightTextColor 擴充套件屬性

class ToolbarViewDelegate : BaseToolbarViewDelegate() { // ...

override fun onBindToolbar(config: ToolbarConfig) { // ... config.rightTextColor?.let { tvRight.setTextColor(it) } // 處理擴充套件屬性 } } ```

在 Application 註冊全域性的標題欄 ViewDelegate

kotlin LoadingStateView.setViewDelegatePool { register(ToolbarViewDelegate(), // ... ) }

之後就能在實現了基類的 ActivityFragment 設定標題欄了。

```kotlin setToolbar() // 預設有返回鍵

setToolbar("title") // 有標題和返回鍵

setToolbar("title", NavBtnType.NONE) // 只有標題,無返回鍵

setToolbar("title") { navIcon = R.drawable.account // 只修改返回鍵圖示 navIcon { ... } // 只修改返回鍵的點選事件 navIcon(R.drawable.message) { ... } // 修改返回鍵的圖示和點選事件 rightIcon(R.drawable.add) { ... } // 新增右側圖示 rightText("Delete") { ... } // 新增右側文字 rightTextColor = Color.RED // 新增的擴充套件屬性,修改右側文字顏色 } ```

這樣就多了一種新增標題欄的方式,新寫的程式碼可以用上述的方式新增標題欄,老的程式碼保留已有的 <include/> 佈局或者自定義標題欄控制元件的用法。樣式都是一樣的,因為是基於已有標題欄實現的。

如果某個頁面的標題欄樣式變動很大,不建議寫太多擴充套件屬性來配置,這樣程式碼閱讀性也差。推薦用新佈局再寫一個 BaseToolbarViewDelegate 的實現類,在設定標題欄之前註冊,這會覆蓋掉預設的樣式。比如:

kotlin registerView(SpecialToolbarViewDelegate()) setToolbar("title")

如果需要動態更新標題欄樣式:

kotlin updateToolbar { title = "Loading..." }

新增多個頭部

比如新增標題欄和搜尋欄,搜尋欄需要另寫一個類繼承 LoadingStateView.ViewDelegate

kotlin setHeaders( ToolbarViewDelegate("Search") { rightIcon(R.drawable.more) { ... } }, SearchViewDelegate(onSearchListener) )

設定裝飾控制元件

可以給內容佈局再套上一層裝飾,實現更復雜的樣式,非簡單地在頂部增加控制元件,比如帶聯動效果的標題欄、DrawerLayout、底部輸入框等佈局。

接下來解耦一個能滑動隱藏標題欄,先寫一個 CoordinatorLayout + AppBarLayout 的標題欄佈局,其中有個 FragmentLayout 是用於填充內容和顯示預設頁。

```xml

<androidx.appcompat.widget.Toolbar
  android:id="@+id/toolbar"
  android:layout_width="match_parent"
  android:layout_height="?attr/actionBarSize"
  app:layout_collapseMode="pin"
  app:layout_scrollFlags="scroll|enterAlways"
  app:navigationIcon="@drawable/ic_arrow_back_ios"
  android:background="@color/white"
  app:titleTextAppearance="@style/ToolbarTextAppearance" />

```

然後寫一個類繼承 LoadingStateView.DecorViewDelegate

```kotlin class ScrollingDecorViewDelegate( private val activity: Activity, private val title: String ) : LoadingStateView.DecorViewDelegate() {

override fun onCreateDecorView(context: Context, inflater: LayoutInflater): View { val view = inflater.inflate(R.layout.layout_scrolling_toolbar, null) val toolbar: Toolbar = view.findViewById(R.id.toolbar) toolbar.title = title toolbar.setNavigationOnClickListener { activity.finish() } return view }

override fun getContentParent(decorView: View): ViewGroup { return decorView.findViewById(R.id.content_parent) } } ```

getContentParent(decorView) 函式是指定新增內容的容器,這裡我們返回前面的 FrameLayout。

之後就可以給內容進行裝飾了。

kotlin setDecorView(ScrollingDecorViewDelegate(this, "title"))

顯示預設頁

順便介紹一下預設頁功能,同樣是先註冊各型別預設頁的樣式,之後才能呼叫對應的 showView() 方法。

建立類繼承 LoadingStateView.ViewDelegate,建構函式傳個檢視型別引數,預設提供了 ViewType.LOADINGViewType.ERRORViewType.EMPTY。比如:

```kotlin class LoadingViewDelegate : LoadingStateView.ViewDelegate(ViewType.LOADING) {

override fun onCreateView(inflater: LayoutInflater, parent: ViewGroup): View = inflater.inflate(R.layout.layout_loading, parent, false) } ```

```kotlin class ErrorViewDelegate : LoadingStateView.ViewDelegate(ViewType.ERROR) {

override fun onCreateView(inflater: LayoutInflater, parent: ViewGroup): View = inflater.inflate(R.layout.layout_error, parent, false).apply { findViewById(R.id.btn_reload).setOnClickListener { onReloadListener?.onReload() } } } ```

在 Application 註冊全域性的 ViewDelegate

kotlin LoadingStateView.setViewDelegatePool { register(LoadingViewDelegate(), ErrorViewDelegate(), EmptyViewDelegate()) }

在實現了基類的 ActivityFragment 可以呼叫對應的 showView() 方法。

kotlin showLoadingView() // 顯示 ViewType.LOADING 型別的檢視 showErrorView() // 顯示 ViewType.ERROR 型別的檢視 showEmptyView() // 顯示 ViewType.EMPTY 型別的檢視 showContentView() // 顯示原來的內容檢視 showView(viewType) // 顯示自定義型別的檢視

如果需要實現點選重新載入,就在重寫基類的 onReload() 方法。

如果某個頁面需要顯示不同的預設頁,可以在顯示前呼叫一下 registerView(viewDelegate) 方法覆蓋預設的樣式。比如:

kotlin registerView(CoolLoadingViewDelegate()) showLoadingView()

如果需要動態更新某個樣式,在 ViewDelegate 自行增加更新的方法,比如在 ErrorViewDelegate 增加了 updateMsg(msg) 方法修改請求失敗的文字,然後就能更新了。

kotlin updateView<ErrorViewDelegate>(ViewType.ERROR) { updateMsg("伺服器繁忙,請稍後重試") }

結合 ViewBinding

個人還寫過一個 ViewBinding 庫,也能封裝到基類,兩者結合到一起使用才是個人理想中的用法。

新增依賴:

groovy implementation 'com.github.DylanCaiCoding.ViewBindingKTX:viewbinding-base:2.1.0'

最新的 2.1.0 版本也增加了 Kotlin 委託用法,能更簡單地把 ViewBinding 整合到基類。以下是相關的程式碼:

base_binding_activity_code.png

檢視程式碼 ```kotlin abstract class BaseBindingActivity : AppCompatActivity(), LoadingState by LoadingStateDelegate(), OnReloadListener, Decorative, ActivityBinding by ActivityBindingDelegate() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentViewWithBinding() binding.root.decorate(this, this) } } ```

base_binding_fragment_code.png

檢視程式碼 ```kotlin abstract class BaseBindingFragment : Fragment(), LoadingState by LoadingStateDelegate(), OnReloadListener, Decorative, FragmentBinding by FragmentBindingDelegate() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View? { return createViewWithBinding(inflater, container).decorate(this, this) } } ```

這樣封裝後不僅能在 Activity 或 Fragment 獲取 binding 屬性,還能很方便地指定顯示預設頁的區域。

比如我們在已有的專案迭代開發,一些頁面的佈局已經寫了標題欄。如果直接呼叫 showLoadingView() 函式,預設頁會把標題欄給覆蓋了,通常要在標題欄下方顯示預設頁。此時就可以重寫 contentView 屬性,宣告在哪個控制元件顯示預設頁,比如:

```kotlin class MainActivity : BaseBindingActivity() {

override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) showLoadingView() // ... }

override val contentView get() = binding.container } ```

由於要給基類增加 ViewBinding 泛型,肯定不可能直接修改基類,這會影響到已有的程式碼。建議繼承原基類再擴展出一個支援 ViewBinding 的基類,具體如何繼承修改請檢視文件

優缺點

本庫唯一的缺點是不能在 xml 佈局預覽標題欄,因為這是動態新增的。但是解耦標題欄的收益遠大於在佈局上預覽標題欄的收益,當各種 ViewDelegate 實現好後,可以隨心所欲配置標題欄,可以直接更換一整套標題欄樣式,可以動態新增支援滑動隱藏的標題欄,需求怎麼變都不怕。

總結

本文講解了 ActionBar 的實現原理,分享個人深度解耦標題欄的思路,通過介面卡對樣式進行解耦,結合 Kotlin 擴充套件和委託實現新增標題欄和更新標題欄。

然後分享了個人封裝好的開源庫 LoadingStateView,只需小改基類,配下專案的預設樣式,即可快速新增標題欄和預設頁。我自己用得超級爽,推薦大家試用一下。如果您覺得有幫助的話,希望能點個 star 支援一下 ~ 個人會分享更多封裝相關的文章和好用的開源庫給大家。

關於我

一個興趣使然的程式“工匠”。有些完美主義,喜歡封裝,對封裝有一定個人見解。GitHub 有分享一些幫助搭建開發框架的開源庫,有任何使用上的問題或者需求都可以提 issues 或者加我微信直接反饋,有其它封裝相關的問題也可以找我探討一下。

  • 掘金:https://juejin.cn/user/4195392100243000/posts
  • GitHub:https://github.com/DylanCaiCoding
  • 微訊號:DylanCaiCoding

往期講解封裝思路的文章