美文网首页Android开发经验谈Android开发
Android公共标题栏兼容DataBinding踩坑之路

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

作者: hellokugo | 来源:发表于2020-03-08 14:04 被阅读0次

    说在前面

    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布局文件:

    <?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重写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
    <?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>
    

    很简单,只是用了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?这个不是设置了<layout>标签自动给我打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的布局并没有用<layout>标签包裹,所以就报错了。so,我在titleBar的布局添加<layout>标签就行了?也不行,因为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<TestBinding> {
    
     @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 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);
    }
    

    处理逻辑

    • 调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踩坑之路

        本文链接:https://www.haomeiwen.com/subject/gmykdhtx.html