说在前面
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的双向绑定(ー̀дー́)
网友评论