Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView

Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView

前言

  MVVM框架的模式在这几篇文章中相比你已经熟悉很多了,具体的架构模式如下图所示:
Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView
上层的Activity/Fragment表示为View层,通过ViewModel去操作数据,然后由Repository去控制数据的来源,可以是本地数据库也可以是网络数据。这个模式在文章和代码中都有体现,算是比较的完整了。

本文效果图如下:

Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView

正文

  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的,如下图所示:
Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView
  这里创建两个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包下。目录结构如下图所示:
Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView
下面依次修改一下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,我这里贴一下,不过你最好到我的源码去找,这样不会有水印,而且图片格式也是对的。
Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView
针对于启动页我特别弄了一个主题样式,在themes.xml下增加如下代码样式:

	<style name="SplashTheme" parent="Theme.MaterialComponents.DayNight.NoActionBar">
        <item name="android:statusBarColor" tools:targetApi="lollipop">#00FFFFFF</item><!--设置状态栏的颜色-->
    </style>

然后我们修改AndroidManifest.xml中的代码,因为之前的启动Activity是LoginActivity,需要改一下。如下图所示:
Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView
下面我们增加一个动画的帮助工具类,在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隐藏了
Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView

同时我们修改一下图片显示之前的占位图或者说是默认背景图。两个图片如下:

Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView
Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView
然后一个加载图片出错时显示的图片:
Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView

首先是MainActivity中,显示必应图片的位置,修改一下activity_main.xml
Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView
然后打开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);//图片加载失败后,显示的图片

将这个值配置进去,如下图所示:
Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView
下面我们运行一下看是什么效果。
Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView
效果还可以的,下面进入主页面的代码编写。

五、主页面

  当到了每日壁纸页面时,我们需要再提供一个入口可以进入下一个页面,现在的每日壁纸页面不能算是真正意义上的主页面,因此我们写一个入口,可以在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="主页" />

添加的位置如下,这里的图标到我的源码里拿就好,白色的放出来也看不见。
Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView
下面回到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);
    }

代码添加位置如下图所示:
Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView
下面在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>

这里有两个图标,同样是白色的。
Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView
下面我们回到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 MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView
详细的使用说明可以看看这篇文章:Android Navigation + Fragment 制作APP主页面导航(步骤 + 源码),看完后你了解的也许会更多。

七、聚合数据请求

  这里我们使用聚合的API数据,聚合API,点击进入完成注册登录,然后可以申请数据API。
Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView

Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView
申请免费的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();

下面就是对数据库进行升级了,如下图所示:
Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView
注意我标注的地方,少一个都会出现升级不成功,或者你直接都编译不成功或者程序运行闪退的情况。

现在我们的数据库有了,接下来要做的就是数据的或者和保存了。

④ 数据存储库

  下面就是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);
    }

下面我们运行一下:
Android MVVM框架搭建(五)Navigation + Fragment + BottomNavigationView

九、源码

欢迎Star和Fork

GitHub:MVVM-Demo
CSDN:MVVMDemo_5.rar

上一篇:Android事件体系全面总结+实践分析,实践出真知


下一篇:Glide源码分析——Request管理