前言
本章我们基于重构的方式进行一个 MVVM 的实战,我们将一个新闻列表的普通实现,一步一步的改造成 MVVM 的架构模式,一共分为上中下三个章节;
传统方式实现
首先咱们来看具体实现的最终效果,就是一个新闻列表页面,拉取新闻标题和新闻列表;
整体页面的构建也很简单,就是 TabLayout + ViewPager + Fragment + RecyclerView + DataBinding 来搭建页面,然后通过 RxJava + Retrofit 拉取数据,并通过 adapter 展示到页面;
页面的构建并不复杂,整体的一个布局排列如上图所示;外层 Fragment 作为根 View 来包裹了一个 TabLayout 和 ViewPager 用来显示新闻标题和标题内容列表;列表以 RecyclerView 进行数据的渲染,整体实现如下:
新闻列表页面的构建也比较简单,就是 Adapter 适配两种不同类型(一种是纯文本、一种是带图片带文本)的 item,通过 ViewHolder 创建不同的布局 View;
scala
public class NewsListRecyclerViewAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private final int VIEW_TYPE_PICTURE_TITLE = 1;
private final int VIEW_TYPE_TITLE = 2;
private List<NewsListBean.Contentlist> mItems;
private Context mContext;
NewsListRecyclerViewAdapter(Context context) {
mContext = context;
}
void setData(List<NewsListBean.Contentlist> items) {
mItems = items;
notifyDataSetChanged();
}
@Override
public int getItemCount() {
if (mItems != null) {
return mItems.size();
}
return 0;
}
@Override
public int getItemViewType(int position) {
if (mItems != null && mItems.get(position).imageurls != null && mItems.get(position).imageurls.size() > 1) {
return VIEW_TYPE_PICTURE_TITLE;
}
return VIEW_TYPE_TITLE;
}
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view;
if (viewType == VIEW_TYPE_PICTURE_TITLE) {
view = LayoutInflater.from(mContext).inflate(R.layout.picture_title_view, parent, false);
return new PictureTitleViewHolder(view);
} else if (viewType == VIEW_TYPE_TITLE) {
view = LayoutInflater.from(mContext).inflate(R.layout.title_view, parent, false);
return new TitleViewHolder(view);
}
return null;
}
private class PictureTitleViewHolder extends RecyclerView.ViewHolder {
public TextView titleTextView;
public AppCompatImageView picutureImageView;
public PictureTitleViewHolder(@NonNull View itemView) {
super(itemView);
titleTextView = itemView.findViewById(R.id.item_title);
picutureImageView = itemView.findViewById(R.id.item_image);
itemView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
WebviewActivity.startCommonWeb(mContext, "News", v.getTag()+"");
}
});
}
}
private class TitleViewHolder extends RecyclerView.ViewHolder {
public TextView titleTextView;
public TitleViewHolder(@NonNull View itemView) {
super(itemView);
titleTextView = itemView.findViewById(R.id.item_title);
itemView.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
WebviewActivity.startCommonWeb(mContext, "News", v.getTag()+"");
}
});
}
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
holder.itemView.setTag(mItems.get(position).link);
if(holder instanceof PictureTitleViewHolder){
((PictureTitleViewHolder) holder).titleTextView.setText(mItems.get(position).title);
Glide.with(holder.itemView.getContext())
.load(mItems.get(position).imageurls.get(0).url)
.transition(withCrossFade())
.into(((PictureTitleViewHolder) holder).picutureImageView);
} else if(holder instanceof TitleViewHolder) {
((TitleViewHolder) holder).titleTextView.setText(mItems.get(position).title);
}
}
}
传统方式,我们会在 Adapter 中写如静态 ViewHolder,但这其实是违背六大原则中的单一职责的,我们来一步一步的拆解成 MVVM 的模式;
我们在搭建一个 App 的架构的时候,追求模块化、层次化、控件化;
控件化的意思就是自定义 View,自定义 View 有什么好处呢?可以将 ViewHolder 的创建放到这个自定义 View 中,满足了单一职责,所有 View 相关的操作都在这个自定义 View,剥离了这个 adapter,
我们先来改造 ViewHolder 的剥离;
ViewHolder 重构
我们要将 ViewHolder 从 adapter 中剥离出来,这里采用控件化的方式,将两个 ViewHolder 剥离到自定义 View 中,我们在目录下新建 view 层级;
然后创建两个自定义 View,一个是 TitleView,一个是 PictureTitleView;
scala
public class TitleView extends LinearLayout {
public TitleView(Context context) {
super(context);
}
}
PictureTitleView
scala
public class PictureTitleView extends LinearLayout {
public PictureTitleView(Context context) {
super(context);
}
}
然后创建对应的 xml 布局 title_view.xml 和 picture_title_view.xml
ini
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<import type="android.view.View" />
<import type="android.text.TextUtils" />
<import type="androidx.databinding.ObservableField" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:animateLayoutChanges="true"
android:orientation="vertical">
<TextView
android:layout_margin="16dp"
android:id="@+id/item_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:gravity="center_vertical"
android:textColor="#303030"
android:textSize="16sp"
android:textStyle="bold"/>
<View
android:layout_width="match_parent"
android:layout_height="1px"
android:background="#303030" />
</LinearLayout>
</layout>
picture_title_view.xml
ini
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<data>
<import type="android.view.View" />
<import type="android.text.TextUtils" />
<import type="androidx.databinding.ObservableField" />
</data>
<LinearLayout xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:animateLayoutChanges="true"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp">
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:card_view="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
card_view:cardBackgroundColor="@android:color/transparent"
card_view:cardCornerRadius="5dp"
android:layout_marginRight="10dp"
card_view:cardElevation="0dp"
card_view:contentPadding="0dp">
<ImageView
android:id="@+id/item_image"
android:layout_width="100dp"
android:layout_height="70dp"
android:scaleType="fitXY" />
</androidx.cardview.widget.CardView>
<TextView
android:id="@+id/item_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="2"
android:textColor="#303030"
android:textSize="16sp"
android:textStyle="bold"
tools:text="myFileName.java" />
</LinearLayout>
<View
android:layout_width="match_parent"
android:layout_height="1px"
android:background="#303030" />
</LinearLayout>
</layout>
我们现在将布局加载到这两个自定义 view 中,通过 inflater 的方式
scss
public class TitleView extends LinearLayout {
TitleViewBinding mBinding;
public TitleView(Context context) {
super(context);
init();
}
private void init() {
LayoutInflater inflater = (LayoutInflater) getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
mBinding = DataBindingUtil.inflate(inflater, R.layout.title_view, this, false);
mBinding.getRoot().setOnClickListener(view -> {
// 处理点击跳转逻辑
});
addView(mBinding.getRoot());
}
}
PictureTitleView 同一样的实现方式;View 创建好了,我们接下来需要绑定数据,分别创建对应的 Model
arduino
public class TitleViewModel {
/**
* 文章标题
*/
String title;
/**
* 文章跳转url
*/
String jumpUrl;
}
model 创建之后,我们需要通过 DataBinding 进行与 view 的绑定;
ini
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<import type="android.view.View" />
<import type="android.text.TextUtils" />
<import type="androidx.databinding.ObservableField" />
<variable
name="titleViewModel"
type="com.xiangxue.news.homefragment.newslist.views.titleview.TitleViewModel" />
</data>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:animateLayoutChanges="true"
android:orientation="vertical">
<TextView
android:layout_margin="16dp"
android:id="@+id/item_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:text="@{titleViewModel.title}"
android:gravity="center_vertical"
android:textColor="#303030"
android:textSize="16sp"
android:textStyle="bold"/>
<View
android:layout_width="match_parent"
android:layout_height="1px"
android:background="#303030" />
</LinearLayout>
</layout>
在 xml 中通过 DataBinding 给 TextView 的 android:text="@{titleViewModel.title}" 就完成了view 获取数据的操作,然后我们需要将给 TitleViewModel 赋值,在 TitleView 中从提供一个 setData 的接口来完成 TitleViewModel 的赋值操作
scss
public void setData(TitleViewModel model) {
mBinding.setTitleViewModel(model);
// 数据更新,让其更新到 View(xml) 上
mBinding.executePendingBindings();
}
到这里,我们就可以替换 Adapter 中 onCreateViewHolder 的逻辑;但是我们在替换的时候发现:
RecyclerView.ViewHolder 不能被实例化,那么怎么办呢?我们来搞一个 BaseViewHolder
scala
public class BaseViewHolder extends RecyclerView.ViewHolder {
private View itemView;
public BaseViewHolder(@NonNull View itemView) {
super(itemView);
this.itemView = itemView;
}
}
然后,我们在 adapter 中进行替换;
csharp
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view;
if (viewType == VIEW_TYPE_PICTURE_TITLE) {
return new BaseViewHolder(new PictureTitleView(parent.getContext()));
} else if (viewType == VIEW_TYPE_TITLE) {
return new BaseViewHolder(new TitleView(parent.getContext()));
}
return null;
}
这样我们就可以把 adapter 中 ViewHolder 创建的逻辑也去掉了,但是删除之后,我们发现 onBindViewHolder 报错了,我们来处理下
我们将 ViewHolder 替换成 holder.itemView,并构造 TitleViewModel,然后 set 给 TitleView;
ini
if(holder.itemView instanceof TitleView) {
TitleViewModel titleViewModel = new TitleViewModel();
titleViewModel.title = mItems.get(position).title;
titleViewModel.jumpUrl = mItems.get(position).link;
((TitleView) holder.itemView).setData(titleViewModel);
}
同理,PictureTitleView 也是这样处理,但是 PictureTitleView 的图片这块要特殊处理下:
xml 中通过自定义命名空间引入一个自定义方法
ini
<ImageView
android:id="@+id/item_image"
android:layout_width="100dp"
android:layout_height="70dp"
app:loadImageUrl="@{pictureTitleViewModel.imgUrl}"
android:scaleType="fitXY" />
然后,通过 @BindingAdapter 注解来和这个方法进行绑定
typescript
@BindingAdapter("loadImageUrl")
public static void loadImageUrl(ImageView imageView, String imgUrl) {
Glide.with(imageView.getContext())
.load(imgUrl)
.transition(withCrossFade())
.into(imageView);
}
onBindViewHolder 中的 PictureViewHolder 改造如下:
ini
if(holder.itemView instanceof PictureTitleView) {
PictureTitleViewModel pictureTitleViewModel = new PictureTitleViewModel();
pictureTitleViewModel.title = mItems.get(position).title;
pictureTitleViewModel.jumpUrl = mItems.get(position).link;
pictureTitleViewModel.imgUrl = mItems.get(position).imageurls.get(0).url;
((PictureTitleView) holder.itemView).setData(pictureTitleViewModel);
}
改造后的 onCreateViewHolder 和 onBindViewHolder 如下:
ini
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view;
if (viewType == VIEW_TYPE_PICTURE_TITLE) {
return new BaseViewHolder(new PictureTitleView(parent.getContext()));
} else if (viewType == VIEW_TYPE_TITLE) {
return new BaseViewHolder(new TitleView(parent.getContext()));
}
return null;
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
holder.itemView.setTag(mItems.get(position).link);
if(holder.itemView instanceof PictureTitleView) {
PictureTitleViewModel pictureTitleViewModel = new PictureTitleViewModel();
pictureTitleViewModel.title = mItems.get(position).title;
pictureTitleViewModel.jumpUrl = mItems.get(position).link;
pictureTitleViewModel.imgUrl = mItems.get(position).imageurls.get(0).url;
((PictureTitleView) holder.itemView).setData(pictureTitleViewModel);
} else if(holder.itemView instanceof TitleView) {
TitleViewModel titleViewModel = new TitleViewModel();
titleViewModel.title = mItems.get(position).title;
titleViewModel.jumpUrl = mItems.get(position).link;
((TitleView) holder.itemView).setData(titleViewModel);
}
}
可以看到,清爽了很多;
但是,估计好多人就有疑问了,数据的处理为什么放在了这个 adapter 层,感觉不符合设计规则,是的,我们来进行数据处理的重构;
Model 重构
数据的加载逻辑,我们放在了 NewListFragment 中,并且 TitleViewModel 和 PictureTitleViewModel 有重复的地方,我们可以抽取到一个 BaseViewModel 中;
arduino
public class BaseViewModel {
/**
* 文章标题
*/
public String title;
public String jumpUrl;
}
然后 TitleViewModel 和 PictureTitleViewModel 都继承 BaseViewModel
scala
public class TitleViewModel extends BaseViewModel {
}
scala
public class PictureTitleViewModel extends BaseViewModel {
public String imgUrl;
}
而 NewListFragment 中的 load 方法改造如下:
ini
protected void load() {
TecentNetworkApi.getService(NewsApiInterface.class)
.getNewsList(getArguments().getString(BUNDLE_KEY_PARAM_CHANNEL_ID),
getArguments().getString(BUNDLE_KEY_PARAM_CHANNEL_NAME), String.valueOf(mPage))
.subscribeOn(Schedulers.io())
.flatMap(new Function<NewsListBean, ObservableSource<ArrayList<BaseViewModel>>>() {
@Override
public ObservableSource<ArrayList<BaseViewModel>> apply(NewsListBean newsChannelsBean) throws Exception {
if (mPage == 0) {
contentlist.clear();
}
ArrayList<BaseViewModel> viewModels = new ArrayList<>();
for (NewsListBean.Contentlist contentlist : newsChannelsBean.showapiResBody.pagebean.contentlist) {
if (contentlist.imageurls != null && contentlist.imageurls.size() > 0) {
PictureTitleViewModel pictureTitleViewModel = new PictureTitleViewModel();
pictureTitleViewModel.imgUrl = contentlist.imageurls.get(0).url;
pictureTitleViewModel.title = contentlist.title;
pictureTitleViewModel.jumpUrl = contentlist.link;
viewModels.add(pictureTitleViewModel);
} else {
TitleViewModel titleViewModel = new TitleViewModel();
titleViewModel.title = contentlist.title;
titleViewModel.jumpUrl = contentlist.link;
viewModels.add(titleViewModel);
}
}
return Observable.just(viewModels);
}
})
.observeOn(AndroidSchedulers.mainThread())
.subscribe(new Consumer<ArrayList<BaseViewModel>>() {
@Override
public void accept(ArrayList<BaseViewModel> baseViewModels) throws Exception {
contentlist = baseViewModels;
mAdapter.setData(contentlist);
mPage ++;
viewDataBinding.refreshLayout.finishRefresh();
viewDataBinding.refreshLayout.finishLoadMore();
}
});
}
adatper 中,将 data 格式改造成 ArrayList BaseViewModel>
整体改造如下:
java
public class NewsListRecyclerViewAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private final int VIEW_TYPE_PICTURE_TITLE = 1;
private final int VIEW_TYPE_TITLE = 2;
private List<BaseViewModel> mItems;
private Context mContext;
NewsListRecyclerViewAdapter(Context context) {
mContext = context;
}
void setData(List<BaseViewModel> items) {
mItems = items;
notifyDataSetChanged();
}
@Override
public int getItemCount() {
if (mItems != null) {
return mItems.size();
}
return 0;
}
@Override
public int getItemViewType(int position) {
if (mItems != null && mItems.get(position) instanceof PictureTitleViewModel) {
return VIEW_TYPE_PICTURE_TITLE;
}
return VIEW_TYPE_TITLE;
}
@Override
public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
View view;
if (viewType == VIEW_TYPE_PICTURE_TITLE) {
return new BaseViewHolder(new PictureTitleView(parent.getContext()));
} else if (viewType == VIEW_TYPE_TITLE) {
return new BaseViewHolder(new TitleView(parent.getContext()));
}
return null;
}
@Override
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
if(holder.itemView instanceof PictureTitleView) {
((PictureTitleView) holder.itemView).setData((PictureTitleViewModel)mItems.get(position));
} else if(holder.itemView instanceof TitleView) {
TitleViewModel titleViewModel = new TitleViewModel();
((TitleView) holder.itemView).setData((TitleViewModel)mItems.get(position));
}
}
}
看到这的时候,好多人可能会继续发出疑问了,都是 setData 那是不是也可以合并成一行代码呢?答案是可以的,我们需要一个 BaseView
csharp
public interface BaseView<DATA extends BaseViewModel> {
void setData(DATA data);
}
我们所有的 View 中都有 setData 方法,所以这个方法我们抽取到 base 层, TitleView 和 PictureTitleView 都实现这个接口;
scala
public class TitleView extends LinearLayout implements BaseView<TitleViewModel> {}
scala
public class PictureTitleView extends LinearLayout implements BaseView<PictureTitleViewModel> {}
然后,我们的 Adapter 改造如下:
scss
public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
if(holder.itemView instanceof PictureTitleView) {
((BaseView<PictureTitleViewModel>) holder.itemView).setData((PictureTitleViewModel)mItems.get(position));
} else if(holder.itemView instanceof TitleView) {
TitleViewModel titleViewModel = new TitleViewModel();
((BaseView<TitleViewModel>) holder.itemView).setData((TitleViewModel)mItems.get(position));
}
}
到这的时候,可以看到我们其实可以在我们的 BaseViewHolder 中进行改造了,
scala
public class BaseViewHolder extends RecyclerView.ViewHolder {
private BaseView<BaseViewModel> itemView;
public BaseViewHolder(@NonNull BaseView<BaseViewModel> itemView) {
super((View) itemView);
this.itemView = itemView;
}
public void bind(BaseViewModel data) {
itemView.setData(data);
}
}
然后,我们的 onBindViewHolder 改造如下:
less
public class NewsListRecyclerViewAdapter extends RecyclerView.Adapter<BaseViewHolder> {
@Override
public BaseViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view;
if (viewType == VIEW_TYPE_PICTURE_TITLE) {
return new BaseViewHolder(new PictureTitleView(parent.getContext()));
} else if (viewType == VIEW_TYPE_TITLE) {
return new BaseViewHolder(new TitleView(parent.getContext()));
}
return null;
}
@Override
public void onBindViewHolder(@NonNull BaseViewHolder holder, int position) {
holder.bind(mItems.get(position));
}
}
我们的 adapter 变得更清爽了一步~
好了,上篇文章就介绍到这里吧~
下一章预告
MVVM 实战新闻客户端(中)
欢迎三连
来都来了,点个关注点个赞吧,你的支持是我最大的动力~~