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

<?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的双向绑定(ー̀дー́)

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,835评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,598评论 1 295
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,569评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,159评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,533评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,710评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,923评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,674评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,421评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,622评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,115评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,428评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,114评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,097评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,875评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,753评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,649评论 2 271

推荐阅读更多精彩内容