Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView
前言
MVVM框架的模式在这几篇文章中相比你已经熟悉很多了,具体的架构模式如下图所示:
上层的Activity/Fragment表示为View层,通过ViewModel去操作数据,然后由Repository去控制数据的来源,可以是本地数据库也可以是网络数据。这个模式在文章和代码中都有体现,算是比较的完整了。
本文效果图如下:
正文
MVVM框架的搭建按理来说就已经完成了,但是我们既然要弄一个实用的框架,就不能只停留于框架搭建的阶段,还要有实用的场景,我喜欢我的框架可以满足绝大部分开发中的使用。现在我们的框架虽然有了Activity,但是还没有使用过Fragment,通常Fragment是在什么时候使用呢?例如主页面五个子模块Fragment,分别表示五个功能,这样是不是会很好呢,这样就完美的将Fragment融入了进去,同时我们还可以与实际的开发模式相结合起来。嗯,不错,开始行动吧。
一、添加依赖
使用Navigation需要添加依赖,在app的build.gradle中的dependencies{}闭包中添加如下依赖:
// navigation依赖 ui 和 fragment
implementation 'androidx.navigation:navigation-fragment:2.3.2'
implementation 'androidx.navigation:navigation-ui:2.3.2'
然后Sync Now同步依赖项目。
二、Fragment创建
创建Fragment可以通过快捷的方式,自带了ViewModel的,如下图所示:
这里创建两个Fragment,NewsFragment和VideoFragment,对应的布局文件是news_fragment.xml和video_fragment.xml,ViewModel是NewsViewModel和VideoViewModel。
下面对项目的包分一下,我把Activity、Fragment、Adapter都看为ui,那么我在com.llw.mvvm包下新建一个ui包,包下新建一个fragment包,然后将NewsFragment和VideoFragment放入fragment包,然后把adapter包也移到ui包下,同时在ui包下新建一个activity包,包下将项目中所有的Activity移入,最后将NewsViewModel和VideoViewModel放到viewmodels包下。目录结构如下图所示:
下面依次修改一下news_fragment.xml和video_fragment.xml中的内容:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rv"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
</layout>
两个xml里面的内容都是上面的这个代码,复制粘贴即可,这两个Fragment中的内容我们待会儿再写。
三、BaseActivity创建
因为我们的Activity比较多,而可能有些Activity中的方法有重合的,或者通用的,这种情况下我们可以将一些方法放入一个基础类里面,例如BaseActivity中,下面进行创建,在activity包下新建一个BaseActivity类,代码如下:
/**
* 基础Activity
*
* @author llw
*/
public class BaseActivity extends AppCompatActivity {
protected AppCompatActivity context;
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
this.context = this;
}
protected void showMsg(CharSequence msg) {
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show();
}
protected void showLongMsg(CharSequence msg) {
Toast.makeText(context, msg, Toast.LENGTH_LONG).show();
}
/**
* 跳转页面
* @param clazz 目标页面
*/
protected void jumpActivity(final Class<?> clazz) {
startActivity(new Intent(context, clazz));
}
/**
* 跳转页面并关闭当前页面
* @param clazz 目标页面
*/
protected void jumpActivityFinish(final Class<?> clazz) {
startActivity(new Intent(context, clazz));
finish();
}
/**
* 状态栏文字图标颜色
* @param dark 深色 false 为浅色
*/
protected void setStatusBar(boolean dark) {
View decor = getWindow().getDecorView();
if (dark) {
decor.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR);
} else {
decor.setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
}
}
}
里面也是一些简单的方法,后面在开发中有新的需要可以一直加进去,根据实际情况来,不要什么都加进去,其实没必要的。
四、启动页
我们的这个MVVM-Demo虽然只是一个Demo,但是我们要给自己一个高一点的标准,所以我打算给一个启动页,一个简单的动画,然后进入我们的登录页,虽然我们是一个假登录,但是意思已经到位了。然后我们在登录页面上记录程序是否登录过,如果登录过下次进入程序就不再进入登录页面,而是直接进入主页面了,这样的逻辑很简单,下面来实现一下吧。
在activity包下新建一个SplashActivity,对应的布局是activity_splash.xml,xml代码如下:
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
tools:context=".ui.activity.SplashActivity">
<RelativeLayout
android:layout_width="160dp"
android:layout_height="160dp">
<ImageView
android:layout_width="160dp"
android:layout_height="160dp"
android:src="@mipmap/ic_splash_logo" />
<TextView
android:visibility="invisible"
android:id="@+id/tv_mvvm"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginBottom="46dp"
android:text="MVVM"
android:textColor="@color/white"
android:textSize="28sp"
android:textStyle="bold" />
</RelativeLayout>
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="20dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Model View ViewModel"
android:textColor="@color/black"
android:textSize="24sp" />
<TextView
android:id="@+id/tv_translate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/white"
android:text="Model View ViewModel"
android:textColor="@color/white"
android:textSize="24sp" />
</RelativeLayout>
</LinearLayout>
</layout>
这里面有一个图标ic_splash_logo.png,我这里贴一下,不过你最好到我的源码去找,这样不会有水印,而且图片格式也是对的。
针对于启动页我特别弄了一个主题样式,在themes.xml下增加如下代码样式:
<style name="SplashTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:statusBarColor" tools:targetApi="lollipop">#00FFFFFF</item><!--设置状态栏的颜色-->
</style>
然后我们修改AndroidManifest.xml中的代码,因为之前的启动Activity是LoginActivity,需要改一下。如下图所示:
下面我们增加一个动画的帮助工具类,在utils包下新建一个EasyAnimation类,里面的代码如下:
public class EasyAnimation {
/**
* 开始眨眼动画
*
* @param view 需要设置动画的View
*/
public static void startBlink(View view) {
AlphaAnimation alphaAnimation = new AlphaAnimation(0.0f, 1.0f);
alphaAnimation.setDuration(500);
alphaAnimation.setStartOffset(20);
alphaAnimation.setRepeatMode(Animation.REVERSE);
alphaAnimation.setRepeatCount(Animation.INFINITE);
view.startAnimation(alphaAnimation);
}
/**
* 开始眨眼动画
*
* @param view 需要设置动画的View
* @param alphaAnimation 透明度动画(自行配置)
*/
public static void startBlink(View view, AlphaAnimation alphaAnimation) {
view.startAnimation(alphaAnimation);
}
/**
* 停止眨眼动画
*
* @param view 需要清除动画的View
*/
public static void stopBlink(View view) {
if (view != null) {
view.clearAnimation();
}
}
/**
* 移动指定View的宽度
*
* @param view
*/
public static void moveViewWidth(View view, TranslateCallback callback) {
view.post(() -> {
//通过post拿到的tvTranslate.getWidth()不会为0。
TranslateAnimation translateAnimation = new TranslateAnimation(0, view.getWidth(), 0, 0);
translateAnimation.setDuration(1000);
translateAnimation.setFillAfter(true);
view.startAnimation(translateAnimation);
//动画监听
translateAnimation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
//检查Android版本
callback.animationEnd();
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});
});
}
/**
* 移动指定View的宽度
*
* @param view 需要位移的View
* @param callback 位移动画回调
* @param translateAnimation 位移动画 (自行配置)
*/
public static void moveViewWidth(View view, TranslateCallback callback, TranslateAnimation translateAnimation) {
view.post(() -> {
//通过post拿到的tvTranslate.getWidth()不会为0。
view.startAnimation(translateAnimation);
//动画监听
translateAnimation.setAnimationListener(new Animation.AnimationListener() {
@Override
public void onAnimationStart(Animation animation) {
}
@Override
public void onAnimationEnd(Animation animation) {
//检查Android版本
callback.animationEnd();
}
@Override
public void onAnimationRepeat(Animation animation) {
}
});
});
}
public interface TranslateCallback {
//动画结束
void animationEnd();
}
}
因为在启动页需要知道程序有没有登录,因此在Constant中增加一个常量,如下所示:
/**
* 是否登录过
*/
public static final String IS_LOGIN = "isLogin";
下面我们修改一下SplashActivity的代码,使用这个常量来判断需要跳转到那个页面,代码如下:
public class SplashActivity extends BaseActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
ActivitySplashBinding binding = DataBindingUtil.setContentView(this, R.layout.activity_splash);
setStatusBar(true);
EasyAnimation.moveViewWidth(binding.tvTranslate, () -> {
binding.tvMvvm.setVisibility(View.VISIBLE);
jumpActivity(MVUtils.getBoolean(Constant.IS_LOGIN) ? MainActivity.class : LoginActivity.class);
});
}
}
这里我继承了BaseActivity,然后设置了状态栏深色模式,因为我们的页面是白色的,如果状态栏也是白色就看不出来了,后面就是在动画结束的时候跳转页面,很简单的代码。这个页面的代码就写完了,下面我们修改LoginActivity中的代码,首先是修改继承的Activity为BaseActivity。里面的代码如下:
public class LoginActivity extends BaseActivity {
private ActivityLoginBinding dataBinding;
private LoginViewModel loginViewModel;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
//数据绑定视图
dataBinding = DataBindingUtil.setContentView(this, R.layout.activity_login);
loginViewModel = new LoginViewModel();
//Model → View
User user = new User("admin", "123456");
loginViewModel.getUser().setValue(user);
//获取观察对象
MutableLiveData<User> user1 = loginViewModel.getUser();
user1.observe(this, user2 -> dataBinding.setViewModel(loginViewModel));
dataBinding.btnLogin.setOnClickListener(v -> {
if (loginViewModel.user.getValue().getAccount().isEmpty()) {
showMsg("请输入账号");
return;
}
if (loginViewModel.user.getValue().getPwd().isEmpty()) {
showMsg("请输入密码");
return;
}
//记录已经登录过
MVUtils.put(Constant.IS_LOGIN,true);
showMsg("登录成功");
jumpActivity(MainActivity.class);
});
}
}
这里就没啥好说的,就是使用了BaseActivity中的方法。同时我修改了一下布局中的代码,我将这两个TextView隐藏了
同时我们修改一下图片显示之前的占位图或者说是默认背景图。两个图片如下:
然后一个加载图片出错时显示的图片:
首先是MainActivity中,显示必应图片的位置,修改一下activity_main.xml
然后打开CustomImageView,增加如下代码:
private static final RequestOptions OPTIONS = new RequestOptions()
.placeholder(R.drawable.wallpaper_bg)//图片加载出来前,显示的图片
.fallback(R.drawable.wallpaper_bg) //url为空的时候,显示的图片
.error(R.mipmap.ic_loading_failed);//图片加载失败后,显示的图片
将这个值配置进去,如下图所示:
下面我们运行一下看是什么效果。
效果还可以的,下面进入主页面的代码编写。
五、主页面
当到了每日壁纸页面时,我们需要再提供一个入口可以进入下一个页面,现在的每日壁纸页面不能算是真正意义上的主页面,因此我们写一个入口,可以在MainActivity中增加一个浮动按钮,页面上下滑动时控制按钮的显示和消失。下面在activity_main.xml中增加如下布局代码:
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_home"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end|bottom"
android:layout_margin="20dp"
android:background="@color/purple_500"
android:onClick="toHome"
android:src="@mipmap/ic_home"
app:backgroundTint="@color/purple_500"
app:fabSize="auto"
tools:ignore="UsingOnClickInXml"
android:contentDescription="主页" />
添加的位置如下,这里的图标到我的源码里拿就好,白色的放出来也看不见。
下面回到MainActivity中,继承BaseActivity,在initView方法中增加如下代码:
//页面上下滑动监听
dataBinding.scrollView.setOnScrollChangeListener((NestedScrollView.OnScrollChangeListener) (v, scrollX, scrollY, oldScrollX, oldScrollY) -> {
if (scrollY > oldScrollY) {
//上滑
dataBinding.fabHome.hide();
} else {
//下滑
dataBinding.fabHome.show();
}
});
然后也增加一个方法,当点击时跳转到HomeActivity,我们将在这个HomeActivity中显示Fragment,现在还没有,下面会创建的。
public void toHome(View view) {
jumpActivity(HomeActivity.class);
}
代码添加位置如下图所示:
下面在activity包下创建一个HomeActivity,对应的布局是activity_home.xml,在改动之前我们先做好准备的工作。
六、Navigation使用
在res包下新建一个navigation包,包下新建一个nav_graph.xml,里面的代码如下:
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/nav_graph"
app:startDestination="@id/news_fragment">
<fragment
android:id="@+id/news_fragment"
android:name="com.llw.mvvm.ui.fragment.NewsFragment"
android:label="news_fragment"
tools:layout="@layout/news_fragment" />
<fragment
android:id="@+id/video_fragment"
android:name="com.llw.mvvm.ui.fragment.VideoFragment"
android:label="video_fragment"
tools:layout="@layout/video_fragment" />
</navigation>
这里就是将Fragment配置到Navigation中,app:startDestination表示显示的第一个Fragment。那么这一步就完成了,下面是另一个操作,就是通过点击底部导航栏菜单去进行Fragment的切换。我们在res下新建一个menu包,包下新建一个navigation_menu.xml,里面的代码如下:
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/news_fragment"
android:icon="@mipmap/ic_hot_news"
android:title="新闻" />
<item
android:id="@+id/video_fragment"
android:icon="@mipmap/ic_hot_video"
android:title="视频" />
</menu>
这里有两个图标,同样是白色的。
下面我们回到activity_home.xml中,里面的代码如下:
<?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"
xmlns:tools="http://schemas.android.com/tools">
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".ui.activity.HomeActivity">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="@color/purple_500">
<TextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="头条新闻"
android:textColor="@color/white"
android:textSize="18sp"
android:textStyle="bold" />
</com.google.android.material.appbar.MaterialToolbar>
<!--NavHost-->
<fragment
android:id="@+id/nav_host_fragment"
android:name="androidx.navigation.fragment.NavHostFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_above="@+id/bottom_navigation"
android:layout_below="@+id/toolbar"
app:navGraph="@navigation/nav_graph" />
<!--底部导航-->
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_navigation"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:layout_alignParentBottom="true"
android:background="#FFF"
app:menu="@menu/navigation_menu" />
</RelativeLayout>
</layout>
这里分为三个部分,一个是标题栏、一个是装载Fragment的容器,另一个是控制Fragment切换的。
下面我们进入到HomeActivity页面,修改代码如下:
public class HomeActivity extends BaseActivity {
private ActivityHomeBinding binding;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
binding = DataBindingUtil.setContentView(this, R.layout.activity_home);
initView();
}
/**
* 初始化
*/
private void initView() {
//获取navController
NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment);
binding.bottomNavigation.setOnNavigationItemSelectedListener(item -> {
switch (item.getItemId()) {
case R.id.news_fragment:
binding.tvTitle.setText("头条新闻");
navController.navigate(R.id.news_fragment);
break;
case R.id.video_fragment:
binding.tvTitle.setText("热门视频");
navController.navigate(R.id.video_fragment);
break;
default:
}
return true;
});
}
}
下面在fragment包下创建一个BaseFragment,里面的代码如下:
public class BaseFragment extends Fragment {
protected AppCompatActivity context;
@Override
public View onCreateView(@NonNull @NotNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {
return super.onCreateView(inflater, container, savedInstanceState);
}
@Override
public void onViewCreated(@NonNull @NotNull View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
}
@Override
public void onAttach(@NonNull @NotNull Context context) {
super.onAttach(context);
if(context instanceof AppCompatActivity){
this.context = (AppCompatActivity) context;
}
}
@Override
public void onDetach() {
super.onDetach();
context = null;
}
protected void showMsg(String msg) {
Toast.makeText(context, msg, Toast.LENGTH_SHORT).show();
}
}
然后修改NewsFragment中的代码:
public class NewsFragment extends BaseFragment {
private NewsFragmentBinding binding;
public static NewsFragment newInstance() {
return new NewsFragment();
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
binding = DataBindingUtil.inflate(inflater, R.layout.news_fragment, container, false);
return binding.getRoot();
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
NewsViewModel mViewModel = new ViewModelProvider(this).get(NewsViewModel.class);
}
}
再修改VideoFragment的代码:
public class VideoFragment extends BaseFragment {
private VideoFragmentBinding binding;
public static VideoFragment newInstance() {
return new VideoFragment();
}
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container,
@Nullable Bundle savedInstanceState) {
binding = DataBindingUtil.inflate(inflater, R.layout.video_fragment, container, false);
return binding.getRoot();
}
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
VideoViewModel mViewModel = new ViewModelProvider(this).get(VideoViewModel.class);
}
}
这里我们在点击底部导航栏时切换Fragment并且更改一下标题栏的文字,下面运行一下。
详细的使用说明可以看看这篇文章:Android Navigation + Fragment 制作APP主页面导航(步骤 + 源码),看完后你了解的也许会更多。
七、聚合数据请求
这里我们使用聚合的API数据,聚合API,点击进入完成注册登录,然后可以申请数据API。
申请免费的API,每天有一百次请求,因此我们可以把数据请求一次之后保存到本地数据库中。
① NetworkApi
这两个API的接口是不同的地址,修改一下NetworkApi中的setUrlType方法。
private static void setUrlType(int type) {
switch (type) {
case 0:
//必应
BASE_URL = "https://cn.bing.com";
break;
case 1:
//热门壁纸
BASE_URL = "http://service.picasso.adesk.com";
break;
case 2:
//聚合API 1
BASE_URL = "http://v.juhe.cn";
break;
case 3:
//聚合API 2
BASE_URL = "http://apis.juhe.cn";
break;
default:
break;
}
}
这里两个接口分别是用于请求新闻数据和视频数据的。
② ApiService
在ApiService中增加两个接口,代码如下所示:
/**
* 聚合新闻数据
*/
@GET("/toutiao/index?type=&page=&page_size=&is_filter=&key=99d3951ed32af2930afd9b38293a08a2")
Observable<NewsResponse> news();
/**
* 聚合热门视频数据
*/
@GET("/fapig/douyin/billboard?type=hot_video&size=20&key=a9c49939cae34fc7dae570b1a4824be4")
Observable<VideoResponse> video();
针对这个情况我们同样需要对数据库进行一次升级,这一次我们增加两个表。
③ 数据库升级
首先在bean包下新建两个实体,News和Video。里面的内容都是我根据接口返回的数据制作的,News里的代码如下:
@Entity(tableName = "news")
public class News {
@PrimaryKey(autoGenerate = true)
private int uid;
private String uniquekey;
private String title;
private String date;
private String category;
private String author_name;
private String url;
private String thumbnail_pic_s;
private String is_content;
public int getUid() {
return uid;
}
public void setUid(int uid) {
this.uid = uid;
}
public String getUniquekey() {
return uniquekey;
}
public void setUniquekey(String uniquekey) {
this.uniquekey = uniquekey;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDate() {
return date;
}
public void setDate(String date) {
this.date = date;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public String getAuthor_name() {
return author_name;
}
public void setAuthor_name(String author_name) {
this.author_name = author_name;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getThumbnail_pic_s() {
return thumbnail_pic_s;
}
public void setThumbnail_pic_s(String thumbnail_pic_s) {
this.thumbnail_pic_s = thumbnail_pic_s;
}
public String getIs_content() {
return is_content;
}
public void setIs_content(String is_content) {
this.is_content = is_content;
}
public News() {}
@Ignore
public News(String uniquekey, String title, String date, String category, String author_name, String url, String thumbnail_pic_s, String is_content) {
this.uniquekey = uniquekey;
this.title = title;
this.date = date;
this.category = category;
this.author_name = author_name;
this.url = url;
this.thumbnail_pic_s = thumbnail_pic_s;
this.is_content = is_content;
}
}
Video的代码如下:
@Entity(tableName = "video")
public class Video {
@PrimaryKey(autoGenerate = true)
private int uid;
private String title;
private String share_url;
private String author;
private String item_cover;
private String hot_words;
public int getUid() {
return uid;
}
public void setUid(int uid) {
this.uid = uid;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getShare_url() {
return share_url;
}
public void setShare_url(String share_url) {
this.share_url = share_url;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getItem_cover() {
return item_cover;
}
public void setItem_cover(String item_cover) {
this.item_cover = item_cover;
}
public String getHot_words() {
return hot_words;
}
public void setHot_words(String hot_words) {
this.hot_words = hot_words;
}
@Ignore
public Video(String title, String share_url, String author, String item_cover, String hot_words) {
this.title = title;
this.share_url = share_url;
this.author = author;
this.item_cover = item_cover;
this.hot_words = hot_words;
}
public Video() {}
}
然后是Dao类,在dao包下新建一个NewsDao和VideoDao的接口,NewsDao代码如下:
@Dao
public interface NewsDao {
@Query("SELECT * FROM news")
Flowable<List<News>> getAll();
@Insert(onConflict = OnConflictStrategy.REPLACE)
Completable insertAll(List<News> news);
@Query("DELETE FROM news")
Completable deleteAll();
}
VideoDao代码如下:
@Dao
public interface VideoDao {
@Query("SELECT * FROM video")
Flowable<List<Video>> getAll();
@Insert(onConflict = OnConflictStrategy.REPLACE)
Completable insertAll(List<Video> videos);
@Query("DELETE FROM video")
Completable deleteAll();
}
最后我们进入AppDatabase中,对数据库进行升级迁移,在AppDatabase中新增如下代码:
/**
* 版本升级迁移到3 新增新闻表和视频表
*/
static final Migration MIGRATION_2_3 = new Migration(2, 3) {
@Override
public void migrate(SupportSQLiteDatabase database) {
//创建新闻表
database.execSQL("CREATE TABLE `news` " +
"(uid INTEGER NOT NULL, " +
"uniquekey TEXT, " +
"title TEXT, " +
"date TEXT," +
"category TEXT," +
"author_name TEXT," +
"url TEXT," +
"thumbnail_pic_s TEXT," +
"is_content TEXT," +
"PRIMARY KEY(`uid`))");
//创建视频表
database.execSQL("CREATE TABLE `video` " +
"(uid INTEGER NOT NULL, " +
"title TEXT," +
"share_url TEXT," +
"author TEXT," +
"item_cover TEXT," +
"hot_words TEXT," +
"PRIMARY KEY(`uid`))");
}
};
然后再增加两个抽象方法,就是之前的两个数据操作类,我们这样写了之后通过Room的编译时技术会对这两个抽象类中的接口进行一个实现,不需要我们去管它。
public abstract NewsDao newsDao();
public abstract VideoDao videoDao();
下面就是对数据库进行升级了,如下图所示:
注意我标注的地方,少一个都会出现升级不成功,或者你直接都编译不成功或者程序运行闪退的情况。
现在我们的数据库有了,接下来要做的就是数据的或者和保存了。
④ 数据存储库
下面就是Repository了,我们在repository包下新建NewsRspository和VideoRepository两个类,然后为了方便管理数据的请求方式,我们同样需要在Constant中增加几个常量来保存当天是否有请求网络接口数据,在Constant中增加如下代码:
/**
* 今日是否请求了聚合新闻数据
*/
public static final String IS_TODAY_REQUEST_NEWS = "isTodayRequestNews";
/**
* 今日请求聚合新闻数据的时间戳
*/
public static final String REQUEST_TIMESTAMP_NEWS = "newsRequestTimestamp";
/**
* 今日是否请求了聚合视频数据
*/
public static final String IS_TODAY_REQUEST_VIDEO = "isTodayRequestVideo";
/**
* 今日请求聚合视频数据的时间戳
*/
public static final String REQUEST_TIMESTAMP_VIDEO = "videoRequestTimestamp";
然后我们再来编辑NewsRepository的代码:
@SuppressLint("CheckResult")
public class NewsRepository {
private static final String TAG = NewsRepository.class.getSimpleName();
final MutableLiveData<NewsResponse> news = new MutableLiveData<>();
public final MutableLiveData<String> failed = new MutableLiveData<>();
/**
* 获取新闻数据
* @return news
*/
public MutableLiveData<NewsResponse> getNews() {
//今日此接口是否已经请求
if (MVUtils.getBoolean(Constant.IS_TODAY_REQUEST_NEWS)) {
if (DateUtil.getTimestamp() <= MVUtils.getLong(Constant.REQUEST_TIMESTAMP_NEWS)) {
getNewsForLocalDB();
} else {
getNewsForNetwork();
}
} else {
getNewsForNetwork();
}
return news;
}
/**
* 从本地数据库获取新闻
*/
private void getNewsForLocalDB() {
Log.d(TAG, "getNewsForLocalDB: 从本地数据库获取 新闻数据");
NewsResponse newsResponse = new NewsResponse();
NewsResponse.ResultBean resultBean = new NewsResponse.ResultBean();
List<NewsResponse.ResultBean.DataBean> dataBeanList = new ArrayList<>();
Flowable<List<News>> listFlowable = BaseApplication.getDb().newsDao().getAll();
CustomDisposable.addDisposable(listFlowable, newss -> {
for (News news1 : newss) {
NewsResponse.ResultBean.DataBean dataBean = new NewsResponse.ResultBean.DataBean();
dataBean.setUniquekey(news1.getUniquekey());
dataBean.setTitle(news1.getTitle());
dataBean.setDate(news1.getDate());
dataBean.setAuthor_name(news1.getAuthor_name());
dataBean.setCategory(news1.getCategory());
dataBean.setThumbnail_pic_s(news1.getThumbnail_pic_s());
dataBean.setIs_content(news1.getIs_content());
dataBeanList.add(dataBean);
}
resultBean.setData(dataBeanList);
newsResponse.setResult(resultBean);
news.postValue(newsResponse);
});
}
/**
* 从网络获取壁纸数据
*/
private void getNewsForNetwork() {
Log.d(TAG, "getNewsForNetwork: 从网络获取 热门壁纸");
NetworkApi.createService(ApiService.class, 2).
news().compose(NetworkApi.applySchedulers(new BaseObserver<NewsResponse>() {
@Override
public void onSuccess(NewsResponse newsResponse) {
if (newsResponse.getError_code() == 0) {
//保存本地数据
saveNews(newsResponse);
news.setValue(newsResponse);
} else {
failed.postValue(newsResponse.getReason());
}
}
@Override
public void onFailure(Throwable e) {
failed.postValue("News Error: " + e.toString());
}
}));
}
/**
* 保存热门壁纸数据
*/
private void saveNews(NewsResponse newsResponse) {
MVUtils.put(Constant.IS_TODAY_REQUEST_NEWS, true);
MVUtils.put(Constant.REQUEST_TIMESTAMP_NEWS, DateUtil.getMillisNextEarlyMorning());
Completable deleteAll = BaseApplication.getDb().newsDao().deleteAll();
CustomDisposable.addDisposable(deleteAll, () -> {
Log.d(TAG, "saveNews: 删除数据成功");
List<News> newsList = new ArrayList<>();
for (NewsResponse.ResultBean.DataBean dataBean : newsResponse.getResult().getData()) {
newsList.add(new News(dataBean.getUniquekey(),dataBean.getTitle(),dataBean.getDate(),dataBean.getCategory(),
dataBean.getAuthor_name(),dataBean.getUrl(),dataBean.getThumbnail_pic_s(),dataBean.getIs_content()));
}
//保存到数据库
Completable insertAll = BaseApplication.getDb().newsDao().insertAll(newsList);
Log.d(TAG, "saveNews: 插入数据:" + newsList.size() + "条");
//RxJava处理Room数据存储
CustomDisposable.addDisposable(insertAll, () -> Log.d(TAG, "saveNews: 新闻数据保存成功"));
});
}
}
这里的代码和之前WallPaperRepository中的代码神似,逻辑上基本一致,只不过是不同的接口和不同的数据表,同事我在当前的这个Repository中增加了一个异常信息的LiveData,因为请求接口你可能会需要很多情况,最理想的时能获取到数据,但是也有其他情况,例如接口地址错误访问不到、请求返回的数据为空,请求次数达到上限等一些异常。因此我们有必要做一个异常信息的处理,然后传递到ViewModel中,最终在Activity中对这个异常进行观察,及时通知到页面上。不然我光打印日志,用户是看不到的。
VideoRepository的代码如下:
@SuppressLint("CheckResult")
public class VideoRepository {
public static final String TAG = VideoRepository.class.getSimpleName();
final MutableLiveData<VideoResponse> video = new MutableLiveData<>();
public final MutableLiveData<String> failed = new MutableLiveData<>();
/**
* 获取视频数据
* @return video
*/
public MutableLiveData<VideoResponse> getVideo() {
//今日此接口是否已经请求
if (MVUtils.getBoolean(Constant.IS_TODAY_REQUEST_VIDEO)) {
if (DateUtil.getTimestamp() <= MVUtils.getLong(Constant.REQUEST_TIMESTAMP_VIDEO)) {
getVideoForLocalDB();
} else {
getVideoForNetwork();
}
} else {
getVideoForNetwork();
}
return video;
}
/**
* 从本地数据库获取新闻
*/
private void getVideoForLocalDB() {
Log.d(TAG, "getVideoForLocalDB: 从本地数据库获取 视频数据");
VideoResponse videoResponse = new VideoResponse();
List<VideoResponse.ResultBean> dataBeanList = new ArrayList<>();
Flowable<List<Video>> listFlowable = BaseApplication.getDb().videoDao().getAll();
CustomDisposable.addDisposable(listFlowable, videos -> {
for (Video video : videos) {
VideoResponse.ResultBean resultBean = new VideoResponse.ResultBean();
resultBean.setTitle(video.getTitle());
resultBean.setShare_url(video.getShare_url());
resultBean.setAuthor(video.getAuthor());
resultBean.setHot_words(video.getHot_words());
resultBean.setItem_cover(video.getItem_cover());
dataBeanList.add(resultBean);
}
videoResponse.setResult(dataBeanList);
video.postValue(videoResponse);
});
}
/**
* 从网络获取壁纸数据
*/
private void getVideoForNetwork() {
Log.d(TAG, "getVideoForNetwork: 从网络获取 热门壁纸");
NetworkApi.createService(ApiService.class, 3)
.video().compose(NetworkApi.applySchedulers(new BaseObserver<VideoResponse>() {
@Override
public void onSuccess(VideoResponse videoResponse) {
if (videoResponse.getError_code() == 0) {
//保存本地数据
saveVideo(videoResponse);
video.postValue(videoResponse);
} else {
failed.postValue(videoResponse.getReason());
}
}
@Override
public void onFailure(Throwable e) {
failed.postValue("Video Error: " + e.toString());
}
}));
}
/**
* 保存热门壁纸数据
*/
private void saveVideo(VideoResponse videoResponse) {
MVUtils.put(Constant.IS_TODAY_REQUEST_VIDEO, true);
MVUtils.put(Constant.REQUEST_TIMESTAMP_VIDEO, DateUtil.getMillisNextEarlyMorning());
Completable deleteAll = BaseApplication.getDb().videoDao().deleteAll();
CustomDisposable.addDisposable(deleteAll, () -> {
Log.d(TAG, "saveVideo: 删除数据成功");
List<Video> videoList = new ArrayList<>();
for (VideoResponse.ResultBean resultBean : videoResponse.getResult()) {
videoList.add(new Video(resultBean.getTitle(),resultBean.getShare_url(),resultBean.getAuthor(),
resultBean.getItem_cover(), resultBean.getHot_words()));
}
//保存到数据库
Completable insertAll = BaseApplication.getDb().videoDao().insertAll(videoList);
Log.d(TAG, "saveVideo: 插入数据:" + videoList.size() + "条");
//RxJava处理Room数据存储
CustomDisposable.addDisposable(insertAll, () -> Log.d(TAG, "saveVideo: 视频数据保存成功"));
});
}
}
这里面的代码也是类似的。如果我们每一个ViewModel中都要有一个failed,那么我们可以定义一个基础ViewModel,然后所有的ViewModel去继承它,这样就会更好一些。
⑤ BaseViewModel
在viewmodels包下新建一个BaseViewModel,里面的代码如下:
public class BaseViewModel extends ViewModel {
public LiveData<String> failed;
}
哦豁,就这么点代码吗?是的,目前就这些,可以根据实际的需求后面再进行添加,不着急。下面我们修改一下NewsViewModel中的代码,如下所示:
public class NewsViewModel extends BaseViewModel {
public LiveData<NewsResponse> news;
public void getNews() {
NewsRepository newsRepository = new NewsRepository();
failed = newsRepository.failed;
news = newsRepository.getNews();
}
}
然后再修改一下VideoViewModel的代码:
public class VideoViewModel extends BaseViewModel {
public LiveData<VideoResponse> video;
public void getVideo() {
VideoRepository videoRepository = new VideoRepository();
failed = videoRepository.failed;
video = videoRepository.getVideo();
}
}
另外其他的ViewModel也这样修改一下,我就不重复贴代码了,不了解的看源码对着改一下就好。
八、新闻、视频数据显示
前面做了这么多都是做准备工作,最重要的是要显示数据在Fragment上,下面我们写两个适配器,还有两个xml文件。
下面先创建xml文件,在layout下新建item_news.xml文件,里面的代码如下:
① item布局
<?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"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="news"
type="com.llw.mvvm.model.NewsResponse.ResultBean.DataBean" />
</data>
<RelativeLayout
android:background="@color/white"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingTop="12dp"
android:paddingEnd="12dp">
<TextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_toStartOf="@id/image"
android:text="@{news.title}"
android:textColor="@color/black"
android:textSize="14sp" />
<com.llw.mvvm.view.CustomImageView
android:id="@+id/image"
android:layout_marginStart="12dp"
networkUrl="@{news.thumbnail_pic_s}"
android:layout_width="140dp"
android:layout_height="80dp"
android:layout_alignParentEnd="true"
android:scaleType="centerCrop"
app:shapeAppearanceOverlay="@style/roundedImageStyle_6" />
<TextView
android:id="@+id/tv_author"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_above="@+id/tv_date"
android:layout_below="@+id/tv_title"
android:layout_marginTop="4dp"
android:text="@{news.author_name}"
android:textSize="12sp"
tools:ignore="NestedWeights" />
<TextView
android:id="@+id/tv_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBottom="@id/image"
android:text="@{news.date}"
android:textSize="12sp" />
<View
android:layout_width="match_parent"
android:layout_height="0.5dp"
android:layout_below="@id/image"
android:layout_marginTop="12dp"
android:background="@color/line" />
</RelativeLayout>
</layout>
这里用的颜色值line,是#EEEEEE,自行在colors.xml中添加就好了。
然后在layout下创建一个item_video.xml文件,里面的代码如下:
<?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"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="video"
type="com.llw.mvvm.model.VideoResponse.ResultBean" />
</data>
<RelativeLayout
android:background="@color/white"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="12dp"
android:paddingTop="12dp"
android:paddingEnd="12dp">
<TextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_toEndOf="@id/image"
android:ellipsize="end"
android:maxLines="2"
android:text="@{video.title}"
android:textColor="@color/black"
android:textSize="14sp" />
<com.llw.mvvm.view.CustomImageView
android:id="@+id/image"
networkUrl="@{video.item_cover}"
android:layout_width="140dp"
android:layout_height="80dp"
android:layout_marginEnd="12dp"
android:scaleType="centerCrop"
app:shapeAppearanceOverlay="@style/roundedImageStyle_6" />
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignStart="@+id/image"
android:layout_alignTop="@+id/image"
android:layout_alignEnd="@id/image"
android:layout_alignBottom="@+id/image"
android:padding="20dp"
android:src="@mipmap/ic_play" />
<TextView
android:id="@+id/tv_author"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_above="@+id/tv_words"
android:layout_below="@+id/tv_title"
android:layout_alignParentEnd="true"
android:layout_marginTop="4dp"
android:layout_toEndOf="@id/image"
android:text="@{video.author}"
android:textSize="12sp"
tools:ignore="NestedWeights" />
<TextView
android:id="@+id/tv_words"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignBottom="@id/image"
android:layout_alignParentEnd="true"
android:layout_toEndOf="@id/image"
android:ellipsize="end"
android:maxLines="1"
android:text="@{video.hot_words}"
android:textSize="12sp" />
<View
android:layout_width="match_parent"
android:layout_height="0.5dp"
android:layout_below="@id/image"
android:layout_marginTop="12dp"
android:background="@color/line" />
</RelativeLayout>
</layout>
② 适配器
下面写适配器,在adapter包下新建一个NewsAdapter类,里面的代码如下:
public class NewsAdapter extends BaseQuickAdapter<NewsResponse.ResultBean.DataBean, BaseDataBindingHolder<ItemNewsBinding>> {
public NewsAdapter(@Nullable List<NewsResponse.ResultBean.DataBean> data) {
super(R.layout.item_news, data);
}
@Override
protected void convert(@NotNull BaseDataBindingHolder<ItemNewsBinding> bindingHolder, NewsResponse.ResultBean.DataBean dataBean) {
ItemNewsBinding binding = bindingHolder.getDataBinding();
if (binding != null) {
binding.setNews(dataBean);
binding.executePendingBindings();
}
}
}
常规代码了,相信你能看懂的,下面再新建一个VideoAdapter类,里面的代码如下:
public class VideoAdapter extends BaseQuickAdapter<VideoResponse.ResultBean, BaseDataBindingHolder<ItemVideoBinding>> {
public VideoAdapter(@Nullable List<VideoResponse.ResultBean> data) {
super(R.layout.item_video, data);
}
@Override
protected void convert(@NotNull BaseDataBindingHolder<ItemVideoBinding> bindingHolder, VideoResponse.ResultBean dataBean) {
ItemVideoBinding binding = bindingHolder.getDataBinding();
if (binding != null) {
binding.setVideo(dataBean);
binding.executePendingBindings();
}
}
}
适配器写好了,下面进入到Fragment中去显示数据。首先是NewsFragment,修改代码如下:
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
NewsViewModel mViewModel = new ViewModelProvider(this).get(NewsViewModel.class);
//获取新闻数据
mViewModel.getNews();
binding.rv.setLayoutManager(new LinearLayoutManager(context));
//数据刷新
mViewModel.news.observe(context, newsResponse ->
binding.rv.setAdapter(new NewsAdapter(newsResponse.getResult().getData())));
mViewModel.failed.observe(context, this::showMsg);
}
然后是VideoFragment,修改代码如下:
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
VideoViewModel mViewModel = new ViewModelProvider(this).get(VideoViewModel.class);
//获取视频数据
mViewModel.getVideo();
binding.rv.setLayoutManager(new LinearLayoutManager(context));
//数据刷新
mViewModel.video.observe(context, videoResponse ->
binding.rv.setAdapter(new VideoAdapter(videoResponse.getResult())));
mViewModel.failed.observe(context, this::showMsg);
}
下面我们运行一下:
九、源码
欢迎Star和Fork
GitHub:MVVM-Demo
CSDN:MVVMDemo_5.rar