Android公共標(biāo)題欄兼容DataBinding踩坑之路

說(shuō)在前面

GoogleArch框架推行已經(jīng)有一段時(shí)間了,之前一直沒(méi)有勇氣去嘗鮮,因?yàn)榉€(wěn)定上線的app很難換框架重構(gòu)。俗話說(shuō)得好,重構(gòu)不如推倒重做(我說(shuō)的),公司剛好啟動(dòng)一個(gè)新項(xiàng)目,部門(mén)內(nèi)部決定搭建包含 LiveData ,ViewModel和LifeCycle的MVVM框架來(lái)搞。萬(wàn)事開(kāi)頭難,踩坑路漫漫,本篇主要介紹如何結(jié)合DataBinding兼容公共標(biāo)題欄的開(kāi)發(fā)。

簡(jiǎn)單介紹一下

俗話說(shuō),站在巨人肩上開(kāi)發(fā),省心省力。這里的巨人就是我之前老項(xiàng)目寫(xiě)的公共標(biāo)題欄(容許我自戀一下,雖然也簡(jiǎn)單(⊙…⊙))。具體說(shuō)來(lái),就是在基類BaseActivity和業(yè)務(wù)開(kāi)發(fā)的Activity中間新添加一個(gè)TitleBarActivity,在業(yè)務(wù)無(wú)感知的情況盡可能減少在繼承BaseActivity或TitleBarActivity的區(qū)別(就是繼承TitleBarActivity也不需要改業(yè)務(wù)Activity代碼),方便插拔。這里貼一下TitleBarActivity的核心處理邏輯:

@Override
public void setContentView(int layoutResID) {
    ViewGroup contentRoot;
    contentRoot = (ViewGroup) mInflater
            .inflate(R.layout.activity_base_titlebar, null);

    View contentView = mInflater.inflate(layoutResID, contentRoot, false);
    if (contentView != null) {
        replaceView(contentRoot, contentView);
        return;
    }

    super.setContentView(layoutResID);
}

@Override
public void setContentView(View view) {
    View contentView = view;
    //判斷當(dāng)前view是否已經(jīng)添加了通用title bar,避免重復(fù)操作
    if (view.findViewById(R.id.title_bar) == null) {
        ViewGroup contentRoot = (ViewGroup) mInflater
                .inflate(R.layout.activity_base_titlebar, null);
        replaceView(contentRoot, contentView);
        contentView = contentRoot;
    }
    super.setContentView(contentView);
    initTitlebar();
}

/**
 * 直接將 FrameLayout 內(nèi)容布局替換掉, 減少層級(jí)
 */
private void replaceView(View contentView) {
    FrameLayout replaceView = mRootTitleView.findViewById(R.id.content_layout);
    ViewGroup.LayoutParams layoutParams = replaceView.getLayoutParams();
    mRootTitleView.removeView(replaceView);
    mRootTitleView.addView(contentView, 1);
    contentView.setLayoutParams(layoutParams);
}

activity_base_titlebar.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">

<*****.ui.TitleBar
    android:id="@+id/title_bar"
    android:layout_width="match_parent"
    android:layout_height="50dp"
    android:gravity="center_vertical"
    android:paddingLeft="12dp"
    android:paddingRight="12dp" />

<FrameLayout
    android:id="@+id/content_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

</LinearLayout>

TitleBarActivity繼承BaseActivity,TitleBarActivity重寫(xiě)setContentView的兩個(gè)方法,是防止業(yè)務(wù)用不同方式去設(shè)置content view而都做到兼容。簡(jiǎn)單來(lái)說(shuō),activity_base_titlebar.xml就是提供一個(gè)根布局,子viewID R.id.title_bar作為自定義的titleBar布局固定第一個(gè)view,而設(shè)置的content view則直接嵌到titleBar布局下,最后直接把a(bǔ)ctivity_base_titlebar的布局作為參數(shù)調(diào)super.setContentView設(shè)置到view上。對(duì)的,就是這么簡(jiǎn)單粗暴。講道理,并沒(méi)有做過(guò)多的侵入系統(tǒng)處理邏輯,即時(shí)使用DataBinding也是完美適配的,然鵝。。。

踩坑一

貼一下使用DataBinding的布局文件:

#test.xml
<?xml version="1.0" encoding="utf-8"?>
<layout>
    <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
        android:id="@+id/activity_content"
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <Button
            android:id="@+id/test"
            android:layout_width="100dp"
            android:layout_height="100dp"
            android:layout_gravity="center"
            android:text="test"
            android:textSize="10dp" />

    </FrameLayout>
</layout>

很簡(jiǎn)單,只是用了layout標(biāo)簽包裹原本的布局設(shè)置。代碼設(shè)置使用:

public class TestActivity extends TitleBarActivity {
 TestBinding mTestBinding;

 @Override
 protected void onCreate(Bundle savedInstanceState) {
     super.onCreate(savedInstanceState);
     mTestBinding = DataBindingUtil.setContentView(this, R.layout.test);
     mTestBinding.setLifecycleOwner(this);
    ....
}

代碼也很簡(jiǎn)單,主要是DataBindingUtil.setContentView(this, R.layout.test)這一句,區(qū)別于普通的setContentView設(shè)置。ok,跑一次放到模擬器上面看看,果然是沒(méi)有那么順利,直接crash了:

image.png

這是什么鬼?view必須有個(gè)tag?這個(gè)不是設(shè)置了<layout>標(biāo)簽自動(dòng)給我打tag了嗎?難道是編譯的時(shí)候沒(méi)有識(shí)別出來(lái)?(相信大家都會(huì)想到我會(huì)用重啟AS大法,對(duì),我愚蠢地做過(guò)。然鵝事實(shí)告訴你不能有僥幸的心理(-?_-?))RTFS是王道,打個(gè)斷點(diǎn)跟一下是什么原因吧:

image.png

這里拋出來(lái)的錯(cuò)誤,這個(gè)INTERNAL_LAYOUT_ID_LOOKUP是哪里初始化的?(路徑打碼)

image.png

這里明明是有把我的test.xml初始化的呀,為啥還會(huì)報(bào)錯(cuò)?咦,不對(duì),view.getTag()這個(gè)代碼里面的view是我設(shè)置進(jìn)去的titleBar布局view,這個(gè)不應(yīng)該是test的布局view嗎?回溯一下view這么傳進(jìn)來(lái)的:


image.png

在回溯下parent是啥:


image.png

注意,這個(gè)代碼位置是整篇文章的核心,后面基本都會(huì)圍繞這段代碼來(lái)說(shuō)明。
好了,原來(lái)這里是拿到根布局作為parent傳進(jìn)去,遍歷根布局拿到的view再進(jìn)行binding的綁定,一切都真相大白,原來(lái)view.getTag()的view就是我的titleBar,而layoutId是test.xml,因?yàn)閠itleBar的布局并沒(méi)有用<layout>標(biāo)簽包裹,所以就報(bào)錯(cuò)了。so,我在titleBar的布局添加<layout>標(biāo)簽就行了?也不行,因?yàn)閘ayoutId是test.xml,這個(gè)是沒(méi)辦法控制的。
所以,第一個(gè)想法是判斷是否使用DataBinding來(lái)做不同的處理,沒(méi)有使用DataBinding跟之前的處理是一樣的,主要看下有使用DataBinding的情況:
  /**
 * 初始化子view的DataBinding
 * @param layoutResID 設(shè)置的內(nèi)容viewId
 */
private void initDataBinding(int layoutResID) {
        //必須要先調(diào)setContentView把view設(shè)置進(jìn)去
        super.setContentView(mRootView);
        mDataBinding = DataBindingUtil
                .inflate(mInflater, layoutResID, mRootView, true);
}

有使用過(guò)DataBinding的同學(xué)應(yīng)該比較熟悉這種初始化的方法,一般針對(duì)Fragment的設(shè)置,這里的mRootView就是titleBar的view??聪翫ataBindingUtil是怎么處理的:

image.png

可以看到,只要useChildren是true,還是會(huì)走到剛剛的bindToAddedViews的方法去,但是要注意的是,此時(shí)的parent不再是根布局,而是我設(shè)置進(jìn)去的mRootView,這時(shí)候拿到需要綁定的viewId就是業(yè)務(wù)Activity的內(nèi)容view。

仔細(xì)看上述使用DataBinding的方法設(shè)置,使用DataBindingUtil.inflate而不是DataBindingUtil.setContentView,為了統(tǒng)一業(yè)務(wù)使用,業(yè)務(wù)Activity不再直接調(diào)用DataBindingUtil,而是調(diào)setContentView丟到上層(即TitleBarActivity)去做判斷處理。

看下此時(shí)業(yè)務(wù)Activity的調(diào)用代碼:

public class TestActivity extends TitleBarActivity<TestBinding> {

 @Override
 protected void onCreate(Bundle savedInstanceState) {
     super.onCreate(savedInstanceState);
     setContentView(R.layout.test);
     mTestBinding.setLifecycleOwner(this);
    ....
}

  @Override
  protected boolean isUseDataBinding() {
      //設(shè)置是否使用DataBinding
      return super.isUseDataBinding();
  }  

主要有三點(diǎn)區(qū)別:

  • 如上所述,調(diào)用setContentView設(shè)置布局,而不是DataBindingUtil.setContentView
  • 繼承父類傳入了泛型,mTestBinding直接丟到父級(jí)去做初始化
  • 可以重載isUseDataBinding方法,父類判斷是否使用DataBinding去做不同的處理

這樣處理有兩個(gè)問(wèn)題

  • 傳入泛型意味著添加約束,對(duì)于繼承者并不是完全無(wú)感知地使用
  • isUseDataBinding方法同樣丟給了繼承方去控制邏輯,明顯不合理
優(yōu)化一下

雖然目前代碼邏輯可以跑起來(lái),但本著組件代碼盡可能簡(jiǎn)化和通用的原則上,不應(yīng)該侵入業(yè)務(wù)代碼和改變?cè)臼褂玫姆椒ǎ磿r(shí)使用BaseActicity也不需要修改業(yè)務(wù)Activity)所以還是看下DataBinding的綁定方法看能不能從中找到啟示。我們?cè)倏匆幌陆壎ǚ椒ǎ?/p>

// @Nullable don't annotate with Nullable. It is unlikely to be null and makes using it from
// kotlin really ugly. We cannot make it NonNull w/o breaking backward compatibility.
public static <T extends ViewDataBinding> T setContentView(@NonNull Activity activity,
        int layoutId, @Nullable DataBindingComponent bindingComponent) {
    activity.setContentView(layoutId);
    View decorView = activity.getWindow().getDecorView();
    ViewGroup contentView = (ViewGroup) decorView.findViewById(android.R.id.content);
    return bindToAddedViews(bindingComponent, contentView, 0, layoutId);
}

處理邏輯

  • 調(diào)Activity的setContentView方法,先把內(nèi)容view設(shè)置進(jìn)去
  • 通過(guò)android.R.id.content拿到系統(tǒng)的根布局
  • 把根布局和業(yè)務(wù)Activity的content layoutId傳進(jìn)去遍歷綁定

不知道大家有沒(méi)有留意到,我們這里可以hook的點(diǎn)除了在Activity的setContentView方法上,其實(shí)能不能在拿到根布局這個(gè)點(diǎn)上去做文章呢?簡(jiǎn)單的說(shuō),就是讓android.R.id.content拿到的布局就是我想包含contentView的父布局,這樣不就可以跟DataBindingUtil.inflate的處理一樣,直接對(duì)contentView做綁定操作了?

踩坑二

Talk is cheap , show me the code:

@Override
public void setContentView(int layoutResID) {
   //必須先把view設(shè)置進(jìn)去,因?yàn)橹苯舆@個(gè)地方設(shè)置
    super.setContentView(R.layout.activity_base_titlebar);
    View contentView = mInflater.inflate(layoutResID, null, false);
    ViewGroup stub = findViewById(R.id.content_layout);
    if (contentView != null) {
        //跟之前處理不一樣,沒(méi)有replace view減少層級(jí),直接添加
        stub.addView(contentView);
        stub.setId(android.R.id.content);
    }
}

ok,趕緊跑一下看下效果。holy~還是報(bào)view must have a tag的錯(cuò)誤,難道不能這樣做?趕緊打個(gè)斷點(diǎn)看下原因,納尼,怎么拿到的根布局還是之前的一樣?打印下設(shè)置id之后的view層級(jí):

/**
 * 打印view樹(shù)id
 * @param view 一開(kāi)始傳進(jìn)來(lái)的是根布局
 */
private void printViewId(View view) {
    if (view instanceof ViewGroup) {
        int childLength = ((ViewGroup) view).getChildCount();
        for (int i = 0; i < childLength; i++) {
            View child = ((ViewGroup) view).getChildAt(i);
            if(child instanceof ViewGroup) {
                System.out.println("id: " + view + view.getId());
                printViewId(child);
                continue;
            }
            System.out.println("id: " + child);
        }
    } else {
        System.out.println("id: " + view);
    }
}

打印出來(lái)的結(jié)果是:


image.png

可以看到,我用紅線標(biāo)注的有兩個(gè)地方設(shè)置了android.R.id.content,在viewTree里面如果存在兩個(gè)id相同的view,系統(tǒng)是通過(guò)什么規(guī)則去返回view的呢?我們看下一下findViewById是什么邏輯:

image.png
image.png

很簡(jiǎn)單,直接判斷是否和當(dāng)前viewId一致返回,我們知道viewGroup是繼承view的,再看下viewGroup的實(shí)現(xiàn)方式:


image.png

顯而易見(jiàn),viewGroup會(huì)從指定的根view去遍歷所有的子view,直至找到對(duì)應(yīng)的id為止,而我們的本來(lái)的android.R.id.content是比后面設(shè)置的id層級(jí)要高,所以就直接返回本來(lái)的view。So,目標(biāo)很明確了,只要把本來(lái)的android.R.id.content的view id指定成別的就ok,在這里就簡(jiǎn)單強(qiáng)暴設(shè)置成NO_ID,所以修改后是醬紫的:

/**
 * 如果是data binding走到這里,說(shuō)明下執(zhí)行邏輯
 *
 * 1.調(diào)用方是子Activity(其實(shí)是data binding內(nèi)部調(diào)用),先獲取根布局(此時(shí)根布局id是android.R.id.content)
 * 2.把內(nèi)容view塞到title bar布局里面,把title bar布局作為參數(shù),調(diào)super.setContentView方法
 * 3.關(guān)鍵兩步:
 *      1)因?yàn)閐ata binding會(huì)找android.R.id.content布局的子view作為綁定對(duì)象,所以這里需要把內(nèi)容布局的父view id設(shè)置為android.R.id.content
 *      2)同時(shí)把原本的根布局id設(shè)置成 View.NO_ID,防止data binding先找到根布局去找子view(事實(shí)上就是這樣,先遍歷父view層級(jí))
 * 4.注意:這時(shí)候根布局就不能依據(jù)android.R.id.content去找了,所以需要提供 #getRootView() 去獲取
 */
@Override
public void setContentView(int layoutResID) {
    //先拿到decorView
    FrameLayout rootContent = findViewById(android.R.id.content);

    View contentView = mInflater.inflate(layoutResID, null, false);
    ViewGroup stub = mRootTitleView.findViewById(R.id.content_layout);
    stub.addView(contentView);
    super.setContentView(mRootTitleView);

    //這里是解決data binding設(shè)置的關(guān)鍵兩步
    stub.setId(android.R.id.content);
    rootContent.setId(View.NO_ID);

    ....
}

梳理下流程:

  • 調(diào)用方是子Activity(其實(shí)是data binding內(nèi)部調(diào)用),先獲取根布局(此時(shí)根布局id是android.R.id.content)
  • 把內(nèi)容view塞到title bar布局里面
  • 把title bar布局作為參數(shù),調(diào)super.setContentView方法
    (這一步順序很重要,必須在修改id前去做調(diào),因?yàn)閟etContentView其實(shí)也會(huì)找android.R.id.content的布局,這時(shí)候是需要原本的android.R.id.content布局去設(shè)置view的)
  • 關(guān)鍵兩步,偷天換日修改id達(dá)到Data Binding去綁定對(duì)應(yīng)view的目的

這樣的話,就沒(méi)必要再針對(duì)是否使用Data Binding去做不同的邏輯處理,上述邏輯在不使用Data Binding同樣使用。假如業(yè)務(wù)Activity想用DataBinding,還是直接調(diào)用DataBindingUtil.setContentView去設(shè)置;不想用DataBinding,直接調(diào)setContentView,真正地做到無(wú)感知~

  • 氮素,這樣就完美了嗎?

細(xì)心想想,這種做法其實(shí)是修改了原本的android.R.id.content指定的布局,假如繼承了TitleBarActicity,提供了獲取根布局的方法:

/**
 * 獲取根布局,android.R.id.content不再是根布局
 */
public ViewGroup getRootView() {
    //fixme 假如其他地方想獲取呢?
    return (ViewGroup) mRootTitleView.getParent();
}

也是比較簡(jiǎn)單了,根布局就是嵌入的titleBar布局的父view。至于我添加的fixme注釋,大家有遇到的話再靈活處理吧,問(wèn)題不大。

完結(jié)~后面再搞下自定義view的雙向綁定(??д??)

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容