Android公共标题栏兼容DataBinding踩坑之路

说在前面

GoogleArch框架推行已经有一段时间了,之前一直没有勇气去尝鲜,因为稳定上线的app很难换框架重构。俗话说得好,重构不如推倒重做(我说的),公司刚好启动一个新项目,部门内部决定搭建包含 LiveData ,ViewModel和LifeCycle的MVVM框架来搞。万事开头难,踩坑路漫漫,本篇主要介绍如何结合DataBinding兼容公共标题栏的开发。

简单介绍一下

俗话说,站在巨人肩上开发,省心省力。这里的巨人就是我之前老项目写的公共标题栏(容许我自恋一下,虽然也简单(⊙…⊙))。具体说来,就是在基类BaseActivity和业务开发的Activity中间新添加一个TitleBarActivity,在业务无感知的情况尽可能减少在继承BaseActivity或TitleBarActivity的区别(就是继承TitleBarActivity也不需要改业务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;
    //判断当前view是否已经添加了通用title bar,避免重复操作
    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 内容布局替换掉, 减少层级
 */
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布局文件:




<*****.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" />




TitleBarActivity继承BaseActivity,TitleBarActivity重写setContentView的两个方法,是防止业务用不同方式去设置content view而都做到兼容。简单来说,activity_base_titlebar.xml就是提供一个根布局,子viewID R.id.title_bar作为自定义的titleBar布局固定第一个view,而设置的content view则直接嵌到titleBar布局下,最后直接把activity_base_titlebar的布局作为参数调super.setContentView设置到view上。对的,就是这么简单粗暴。讲道理,并没有做过多的侵入系统处理逻辑,即时使用DataBinding也是完美适配的,然鹅。。。

踩坑一

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

#test.xml


    

        

很简单,只是用了layout标签包裹原本的布局设置。代码设置使用:

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);
    ....
}

代码也很简单,主要是DataBindingUtil.setContentView(this, R.layout.test)这一句,区别于普通的setContentView设置。ok,跑一次放到模拟器上面看看,果然是没有那么顺利,直接crash了:

image.png

这是什么鬼?view必须有个tag?这个不是设置了标签自动给我打tag了吗?难道是编译的时候没有识别出来?(相信大家都会想到我会用重启AS大法,对,我愚蠢地做过。然鹅事实告诉你不能有侥幸的心理(-᷅_-᷄))RTFS是王道,打个断点跟一下是什么原因吧:

image.png

这里抛出来的错误,这个INTERNAL_LAYOUT_ID_LOOKUP是哪里初始化的?(路径打码)

image.png

这里明明是有把我的test.xml初始化的呀,为啥还会报错?咦,不对,view.getTag()这个代码里面的view是我设置进去的titleBar布局view,这个不应该是test的布局view吗?回溯一下view这么传进来的:


image.png

在回溯下parent是啥:


image.png

注意,这个代码位置是整篇文章的核心,后面基本都会围绕这段代码来说明。
好了,原来这里是拿到根布局作为parent传进去,遍历根布局拿到的view再进行binding的绑定,一切都真相大白,原来view.getTag()的view就是我的titleBar,而layoutId是test.xml,因为titleBar的布局并没有用标签包裹,所以就报错了。so,我在titleBar的布局添加标签就行了?也不行,因为layoutId是test.xml,这个是没办法控制的。
所以,第一个想法是判断是否使用DataBinding来做不同的处理,没有使用DataBinding跟之前的处理是一样的,主要看下有使用DataBinding的情况:
  /**
 * 初始化子view的DataBinding
 * @param layoutResID 设置的内容viewId
 */
private void initDataBinding(int layoutResID) {
        //必须要先调setContentView把view设置进去
        super.setContentView(mRootView);
        mDataBinding = DataBindingUtil
                .inflate(mInflater, layoutResID, mRootView, true);
}

有使用过DataBinding的同学应该比较熟悉这种初始化的方法,一般针对Fragment的设置,这里的mRootView就是titleBar的view。看下DataBindingUtil是怎么处理的:

image.png

可以看到,只要useChildren是true,还是会走到刚刚的bindToAddedViews的方法去,但是要注意的是,此时的parent不再是根布局,而是我设置进去的mRootView,这时候拿到需要绑定的viewId就是业务Activity的内容view。

仔细看上述使用DataBinding的方法设置,使用DataBindingUtil.inflate而不是DataBindingUtil.setContentView,为了统一业务使用,业务Activity不再直接调用DataBindingUtil,而是调setContentView丢到上层(即TitleBarActivity)去做判断处理。

看下此时业务Activity的调用代码:

public class TestActivity extends TitleBarActivity {

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

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

主要有三点区别:

  • 如上所述,调用setContentView设置布局,而不是DataBindingUtil.setContentView
  • 继承父类传入了泛型,mTestBinding直接丢到父级去做初始化
  • 可以重载isUseDataBinding方法,父类判断是否使用DataBinding去做不同的处理

这样处理有两个问题

  • 传入泛型意味着添加约束,对于继承者并不是完全无感知地使用
  • isUseDataBinding方法同样丢给了继承方去控制逻辑,明显不合理
优化一下

虽然目前代码逻辑可以跑起来,但本着组件代码尽可能简化和通用的原则上,不应该侵入业务代码和改变原本使用的方法(即时使用BaseActicity也不需要修改业务Activity)所以还是看下DataBinding的绑定方法看能不能从中找到启示。我们再看一下绑定方法:

// @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 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);
}

处理逻辑

  • 调Activity的setContentView方法,先把内容view设置进去
  • 通过android.R.id.content拿到系统的根布局
  • 把根布局和业务Activity的content layoutId传进去遍历绑定

不知道大家有没有留意到,我们这里可以hook的点除了在Activity的setContentView方法上,其实能不能在拿到根布局这个点上去做文章呢?简单的说,就是让android.R.id.content拿到的布局就是我想包含contentView的父布局,这样不就可以跟DataBindingUtil.inflate的处理一样,直接对contentView做绑定操作了?

踩坑二

Talk is cheap , show me the code:

@Override
public void setContentView(int layoutResID) {
   //必须先把view设置进去,因为直接这个地方设置
    super.setContentView(R.layout.activity_base_titlebar);
    View contentView = mInflater.inflate(layoutResID, null, false);
    ViewGroup stub = findViewById(R.id.content_layout);
    if (contentView != null) {
        //跟之前处理不一样,没有replace view减少层级,直接添加
        stub.addView(contentView);
        stub.setId(android.R.id.content);
    }
}

ok,赶紧跑一下看下效果。holy~还是报view must have a tag的错误,难道不能这样做?赶紧打个断点看下原因,纳尼,怎么拿到的根布局还是之前的一样?打印下设置id之后的view层级:

/**
 * 打印view树id
 * @param view 一开始传进来的是根布局
 */
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);
    }
}

打印出来的结果是:


image.png

可以看到,我用红线标注的有两个地方设置了android.R.id.content,在viewTree里面如果存在两个id相同的view,系统是通过什么规则去返回view的呢?我们看下一下findViewById是什么逻辑:

image.png
image.png

很简单,直接判断是否和当前viewId一致返回,我们知道viewGroup是继承view的,再看下viewGroup的实现方式:


image.png

显而易见,viewGroup会从指定的根view去遍历所有的子view,直至找到对应的id为止,而我们的本来的android.R.id.content是比后面设置的id层级要高,所以就直接返回本来的view。So,目标很明确了,只要把本来的android.R.id.content的view id指定成别的就ok,在这里就简单强暴设置成NO_ID,所以修改后是酱紫的:

/**
 * 如果是data binding走到这里,说明下执行逻辑
 *
 * 1.调用方是子Activity(其实是data binding内部调用),先获取根布局(此时根布局id是android.R.id.content)
 * 2.把内容view塞到title bar布局里面,把title bar布局作为参数,调super.setContentView方法
 * 3.关键两步:
 *      1)因为data binding会找android.R.id.content布局的子view作为绑定对象,所以这里需要把内容布局的父view id设置为android.R.id.content
 *      2)同时把原本的根布局id设置成 View.NO_ID,防止data binding先找到根布局去找子view(事实上就是这样,先遍历父view层级)
 * 4.注意:这时候根布局就不能依据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设置的关键两步
    stub.setId(android.R.id.content);
    rootContent.setId(View.NO_ID);

    ....
}

梳理下流程:

  • 调用方是子Activity(其实是data binding内部调用),先获取根布局(此时根布局id是android.R.id.content)
  • 把内容view塞到title bar布局里面
  • 把title bar布局作为参数,调super.setContentView方法
    (这一步顺序很重要,必须在修改id前去做调,因为setContentView其实也会找android.R.id.content的布局,这时候是需要原本的android.R.id.content布局去设置view的)
  • 关键两步,偷天换日修改id达到Data Binding去绑定对应view的目的

这样的话,就没必要再针对是否使用Data Binding去做不同的逻辑处理,上述逻辑在不使用Data Binding同样使用。假如业务Activity想用DataBinding,还是直接调用DataBindingUtil.setContentView去设置;不想用DataBinding,直接调setContentView,真正地做到无感知~

  • 氮素,这样就完美了吗?

细心想想,这种做法其实是修改了原本的android.R.id.content指定的布局,假如继承了TitleBarActicity,提供了获取根布局的方法:

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

也是比较简单了,根布局就是嵌入的titleBar布局的父view。至于我添加的fixme注释,大家有遇到的话再灵活处理吧,问题不大。

完结~后面再搞下自定义view的双向绑定(ー̀дー́)

你可能感兴趣的:(Android公共标题栏兼容DataBinding踩坑之路)