Android 短视频项目实战:从用户中心页与沉浸式登录,到验证码鉴权、用户信息持久化和 EventBus 登录态同步

Android 短视频项目实战:基于 MVVM、DataBinding、ViewPager2、Fragment、Retrofit、EncryptedSharedPreferences 与 EventBus 打通用户模块全链路,涵盖 UserFragment 页面搭建、沉浸式登录页、验证码鉴权、协议跳转、用户详情获取、Token 加密存储、本地缓存与跨页面登录态同步


前言

用户中心页看上去只是一个静态界面,但一旦把它放进 ViewPager + Fragment 的主框架,再把手机号登录、验证码倒计时、协议跳转、Token 持久化和登录态回推串起来,整条链路就会同时涉及沉浸式状态栏、DataBinding、MVVM、网络请求、本地加密存储和跨页面消息同步。

这篇文章按工程推进顺序展开,先完成用户中心页和沉浸式状态栏适配,再接入手机号验证码登录,随后补齐用户协议、用户信息获取、本地缓存与 EventBus 登录态同步。读完以后,可以直接把这条登录链路落到同类 Android 项目里。

目录

  • [Android 短视频项目实战:从用户中心页与沉浸式登录,到验证码鉴权、用户信息持久化和 EventBus 登录态同步](#Android 短视频项目实战:从用户中心页与沉浸式登录,到验证码鉴权、用户信息持久化和 EventBus 登录态同步)
  • [1. 搭建用户中心页面骨架与菜单布局](#1. 搭建用户中心页面骨架与菜单布局)
  • [2. 让 ViewPager 中的 Fragment 正确适配沉浸式状态栏](#2. 让 ViewPager 中的 Fragment 正确适配沉浸式状态栏)
  • [3. 梳理手机号登录页的接口链路与返回数据](#3. 梳理手机号登录页的接口链路与返回数据)
  • [4. 搭建登录页 UI,并用 DataBinding 连接输入状态](#4. 搭建登录页 UI,并用 DataBinding 连接输入状态)
  • [5. 通过双向绑定实时控制登录按钮状态](#5. 通过双向绑定实时控制登录按钮状态)
  • [6. 修复输入法弹出后沉浸式间距重复累加的问题](#6. 修复输入法弹出后沉浸式间距重复累加的问题)
  • [7. 为验证码按钮补齐倒计时、禁用态与请求反馈](#7. 为验证码按钮补齐倒计时、禁用态与请求反馈)
  • [8. 把 Toast 能力上收到 BaseViewModel 与基类页面](#8. 把 Toast 能力上收到 BaseViewModel 与基类页面)
  • [9. 把通用加载样式下沉到 Activity 与 Fragment 基类](#9. 把通用加载样式下沉到 Activity 与 Fragment 基类)
  • [10. 接入验证码发送、手机号登录与用户信息请求](#10. 接入验证码发送、手机号登录与用户信息请求)
    • [10.1 定义 user 模块接口](#10.1 定义 user 模块接口)
    • [10.2 声明请求体与登录响应体](#10.2 声明请求体与登录响应体)
    • [10.3 在 Model 层封装三个网络调用](#10.3 在 Model 层封装三个网络调用)
    • [10.4 在 ViewModel 层串起验证码、登录与用户信息加载](#10.4 在 ViewModel 层串起验证码、登录与用户信息加载)
  • [11. 用 SpannableString 和 WebView 补齐用户协议与隐私政策入口](#11. 用 SpannableString 和 WebView 补齐用户协议与隐私政策入口)
  • [12. 在登录成功后继续拉取用户详情](#12. 在登录成功后继续拉取用户详情)
    • [12.1 明确用户信息接口的输入与输出](#12.1 明确用户信息接口的输入与输出)
    • [12.2 定义跨模块复用的用户实体](#12.2 定义跨模块复用的用户实体)
    • [12.3 扩展 UserApiService 获取用户信息](#12.3 扩展 UserApiService 获取用户信息)
    • [12.4 在 Model 层处理空数据与异常分支](#12.4 在 Model 层处理空数据与异常分支)
    • [12.5 在 ViewModel 中串联登录成功后的详情拉取](#12.5 在 ViewModel 中串联登录成功后的详情拉取)
  • [13. 设计登录态落地方案:持久化用户信息并回推页面状态](#13. 设计登录态落地方案:持久化用户信息并回推页面状态)
    • [13.1 使用单例封装用户信息管理入口](#13.1 使用单例封装用户信息管理入口)
    • [13.2 使用 EncryptedSharedPreferences 加密保存 Token](#13.2 使用 EncryptedSharedPreferences 加密保存 Token)
  • [14. 补齐用户资料缓存、退出登录与本地读取能力](#14. 补齐用户资料缓存、退出登录与本地读取能力)
  • [15. 用 EventBus 把登录成功事件回推到用户页](#15. 用 EventBus 把登录成功事件回推到用户页)
    • [15.1 先回顾 ActivityResultLauncher 的返回方式](#15.1 先回顾 ActivityResultLauncher 的返回方式)
    • [15.2 引入 EventBus 并整理基本用法](#15.2 引入 EventBus 并整理基本用法)
    • [15.3 定义登录状态消息体](#15.3 定义登录状态消息体)
    • [15.4 在登录成功后发布消息](#15.4 在登录成功后发布消息)
    • [15.5 在 UserFragment 中订阅粘性事件](#15.5 在 UserFragment 中订阅粘性事件)
  • [16. 小结](#16. 小结)
  • [17. 相关代码附录](#17. 相关代码附录)
    • [17.1 状态栏与页面基类支撑](#17.1 状态栏与页面基类支撑)
    • [17.2 登录页 UI 与协议跳转](#17.2 登录页 UI 与协议跳转)
    • [17.3 网络接口与请求模型](#17.3 网络接口与请求模型)
    • [17.4 登录态存储与用户缓存](#17.4 登录态存储与用户缓存)
    • [17.5 登录成功事件回推与用户页刷新](#17.5 登录成功事件回推与用户页刷新)

1. 搭建用户中心页面骨架与菜单布局

用户中心页先解决的是版面骨架问题:顶部需要一块铺满页面宽度的背景图,中间是头像、昵称、签名和统计信息,下半部分再放收藏、观看记录、退出等菜单入口。只有先把这个层级关系固定下来,后面的沉浸式状态栏和登录态渲染才有稳定的承载位置。

布局直接放在 layout_fragment_user.xml 中完成:

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">

    <data>

    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <ImageView
            android:id="@+id/imageView"
            android:layout_width="match_parent"
            android:layout_height="200dp"
            android:scaleType="centerCrop"
            android:src="@mipmap/bg_user_center"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <ImageView
            android:id="@+id/iv_settings"
            android:layout_width="20dp"
            android:layout_height="20dp"
            android:layout_marginTop="44dp"
            android:layout_marginEnd="14dp"
            android:src="@mipmap/icon_settings"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <ImageView
            android:id="@+id/iv_qualifications"
            android:layout_width="20dp"
            android:layout_height="20dp"
            android:layout_marginTop="44dp"
            android:layout_marginEnd="12dp"
            android:src="@mipmap/icon_qualifications"
            app:layout_constraintEnd_toStartOf="@id/iv_settings"
            app:layout_constraintTop_toTopOf="parent" />

        <ImageView
            android:id="@+id/imageView2"
            android:layout_width="80dp"
            android:layout_height="80dp"
            android:layout_marginStart="20dp"
            android:layout_marginTop="171dp"
            android:src="@mipmap/ic_launcher_round"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <TextView
            android:id="@+id/tv_nick_name"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="12dp"
            android:text="小雷"
            android:textColor="@color/black"
            android:textSize="@dimen/font_size_14sp"
            app:layout_constraintStart_toStartOf="@+id/imageView2"
            app:layout_constraintTop_toBottomOf="@+id/imageView2" />

        <ImageView
            android:id="@+id/imageView3"
            android:layout_width="12dp"
            android:layout_height="12dp"
            android:layout_marginStart="12.5dp"
            android:src="@mipmap/icon_user_edit"
            app:layout_constraintBottom_toBottomOf="@+id/tv_nick_name"
            app:layout_constraintStart_toEndOf="@+id/tv_nick_name"
            app:layout_constraintTop_toTopOf="@+id/tv_nick_name" />

        <TextView
            android:id="@+id/tv_sign"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:text="去编辑资料完善个人简介吧"
            android:textColor="#ff9c9c9c"
            android:textSize="@dimen/font_size_12sp"
            app:layout_constraintStart_toStartOf="@+id/tv_nick_name"
            app:layout_constraintTop_toBottomOf="@+id/tv_nick_name" />


        <TextView
            android:id="@+id/tv_fans"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="16dp"
            android:text="0 粉丝"
            android:textColor="@color/black"
            android:textSize="12sp"
            app:layout_constraintStart_toStartOf="@id/tv_sign"
            app:layout_constraintTop_toBottomOf="@id/tv_sign" />


        <TextView
            android:id="@+id/tv_follow"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="17dp"
            android:layout_marginTop="16dp"
            android:text="0 关注"
            android:textColor="@color/black"
            android:textSize="12sp"
            app:layout_constraintStart_toEndOf="@id/tv_fans"
            app:layout_constraintTop_toBottomOf="@id/tv_sign" />

        <TextView
            android:id="@+id/tv_medal"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="17dp"
            android:layout_marginTop="16dp"
            android:text="0 勋章"
            android:textColor="@color/black"
            android:textSize="12sp"
            app:layout_constraintStart_toEndOf="@id/tv_follow"
            app:layout_constraintTop_toBottomOf="@id/tv_sign" />

        <View
            android:id="@+id/view_div"
            android:layout_width="match_parent"
            android:layout_height="5dp"
            android:layout_marginTop="36dp"
            android:background="#fff8f8f8"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/tv_fans" />

        <!--因为每一行都是一个点击区域,所以给每个菜单添加一个父布局用来做点击事件-->
        <LinearLayout
            android:id="@+id/ll_collection"
            android:layout_width="match_parent"
            android:layout_height="46dp"
            android:layout_marginTop="16dp"
            android:gravity="center|start"
            android:orientation="horizontal"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/view_div">

            <ImageView
                android:id="@+id/iv_collection"
                android:layout_width="20dp"
                android:layout_height="20dp"
                android:layout_marginStart="20dp"
                android:src="@mipmap/icon_user_collection" />

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="8dp"
                android:text="我的收藏"
                android:textColor="#ff444444"
                android:textSize="13sp" />

        </LinearLayout>

        <View
            android:id="@+id/view_div1"
            android:layout_width="match_parent"
            android:layout_height="1dp"
            android:layout_marginStart="10dp"
            android:layout_marginEnd="10dp"
            android:background="#33000000"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/ll_collection" />

        <LinearLayout
            android:id="@+id/ll_record"
            android:layout_width="match_parent"
            android:layout_height="46dp"
            android:gravity="center|start"
            android:orientation="horizontal"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/view_div1">

            <ImageView
                android:layout_width="20dp"
                android:layout_height="20dp"
                android:layout_marginStart="20dp"
                android:src="@mipmap/icon_user_record" />

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="8dp"
                android:text="观看记录"
                android:textColor="#ff444444"
                android:textSize="13sp" />

        </LinearLayout>

        <View
            android:id="@+id/view_div2"
            android:layout_width="match_parent"
            android:layout_height="1dp"
            android:layout_marginStart="10dp"
            android:layout_marginEnd="10dp"
            android:background="#33000000"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/ll_record" />

        <LinearLayout
            android:id="@+id/ll_exit"
            android:layout_width="match_parent"
            android:layout_height="46dp"
            android:gravity="center|start"
            android:orientation="horizontal"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/ll_record">

            <ImageView
                android:layout_width="20dp"
                android:layout_height="20dp"
                android:layout_marginStart="20dp"
                android:src="@mipmap/icon_exit_login" />

            <TextView
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="8dp"
                android:text="我的收藏"
                android:textColor="#ff444444"
                android:textSize="13sp" />

        </LinearLayout>

    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

项目路径:LsxbugVideo/feature_user/src/main/res/layout/layout_fragment_user.xml

这套布局的关键不是把控件堆上去,而是先把后续会变化的位置预留出来。顶部大图会和状态栏联动,头像区以后要承载登录前后的不同数据,底部菜单行本身又是整块点击区域,所以从一开始就用父布局包住每一行菜单,后面接点击事件时就不用再回头改结构。

2. 让 ViewPager 中的 Fragment 正确适配沉浸式状态栏

用户中心页不是单独跑在一个 Activity 里,而是作为 MainActivity 中的一个 FragmentViewPager 承载。也正因为有这一层嵌套,单纯把沉浸式状态栏写在基类 Activity 里,最终效果并不会自动适配每一个 Fragment 自己的背景和布局。

当前首页容器的组织方式如下:

java 复制代码
@Route(path = ARouterPath.Main.ACTIVITY_MAIN)
public class MainActivity extends BaseActivity<ActivityMainBinding, MainViewModel> {

    @Override
    protected MainViewModel getViewModel() {
        return new ViewModelProvider(this).get(MainViewModel.class);
    }

    @Override
    protected int getLayoutResId() {
        return R.layout.activity_main;
    }

    @Override
    protected int getBindingVariableId() {
        return BR.viewModel;
    }

    @Override
    protected void initView() {

        Fragment homeFragment = (Fragment) ARouter.getInstance().build("/home/homeFragment").navigation();
        Fragment plazaFragment = (Fragment) ARouter.getInstance().build("/plaza/plazaFragment").navigation();
        Fragment findFragment = (Fragment) ARouter.getInstance().build("/find/findFragment").navigation();
        Fragment userFragment = (Fragment) ARouter.getInstance().build("/user/userFragment").navigation();

        ArrayList<Fragment> fragments = new ArrayList<>();
        fragments.add(homeFragment);
        fragments.add(plazaFragment);
        fragments.add(findFragment);
        fragments.add(userFragment);

        MainFragmentStateAdapter stateAdapter = new MainFragmentStateAdapter(this);
        stateAdapter.setFragments(fragments);
        mDataBinding.viewPager.setAdapter(stateAdapter);

        mDataBinding.viewPager.setUserInputEnabled(false);//不允许用户滑动切换

        mDataBinding.rbBottomNavigation.setOnCheckedChangeListener(new RadioGroup.OnCheckedChangeListener() {
            @Override
            public void onCheckedChanged(RadioGroup group, int checkedId) {
                if (checkedId == R.id.rb_home) {
                    showFragment(0);
                } else if (checkedId == R.id.rb_plaza) {
                    showFragment(1);
                } else if (checkedId == R.id.rb_find) {
                    showFragment(2);
                } else if (checkedId == R.id.rb_mine) {
                    showFragment(3);
                }
            }
        });

    }

    private void showFragment(int position) {
        mDataBinding.viewPager.setCurrentItem(position);
    }

    @Override
    protected void initData() {

    }
}

项目路径:LsxbugVideo/app/src/main/java/com/ls/video/MainActivity.java

基类里最早的沉浸式处理方式是直接对 Activity 根布局监听系统栏内边距:

java 复制代码
public abstract class BaseActivity<V extends ViewDataBinding, VM extends ViewModel> extends AppCompatActivity {

    protected VM mViewModel;
    protected V mDataBinding;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        initViewModel();
        initDatabinding();

        //开启沉浸式状态栏
        EdgeToEdge.enable(this);
        //mDataBinding.getRoot()获取根布局
        ViewCompat.setOnApplyWindowInsetsListener(mDataBinding.getRoot(), (v, insets) -> {
            Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
            return insets;
        });


        ARouter.getInstance().inject(this);

        initView();
        initData();
    }

    private void initDatabinding() {
        //从子类获取的布局id 与databinding关联
        mDataBinding = DataBindingUtil.setContentView(this, getLayoutResId());
        mDataBinding.setLifecycleOwner(this);

        //在父类中使用viewmodel在xml中的变量名 关联具体的mViewModel  效果等同于mDataBinding.setViewModel(mViewModel);
        mDataBinding.setVariable(getBindingVariableId(), mViewModel);
        //关联完mViewModel后,实时更新数据
        mDataBinding.executePendingBindings();
    }

    private void initViewModel() {
        //从子类获取到的viewModel 赋值给mViewModel
        mViewModel = getViewModel();
    }


    protected abstract VM getViewModel();

    protected abstract int getLayoutResId();

    protected abstract int getBindingVariableId();

    protected abstract void initView();//如果子类需要做一些视图上的初始化

    protected abstract void initData();//如果子类需要做一些数据上的初始化
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/BaseActivity.java

这种写法只对 Activity 根布局负责。当 Activity 背景是白色、但内部某个 Fragment 的头图又是灰色或带大图时,状态栏会继续沿用 Activity 这一层的视觉效果,于是顶部区域和页面主体之间就会出现明显的割裂感。

要解决这个问题,沉浸式逻辑就不能只盯着页面根布局,而要直接处理整个窗口。先把状态栏改成透明,再让内容铺进状态栏区域,最后根据背景决定状态栏图标使用深色模式:

java 复制代码
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    initViewModel();
    initDatabinding();

//        //开启沉浸式状态栏
//        EdgeToEdge.enable(this);
//        //mDataBinding.getRoot()获取根布局
//        ViewCompat.setOnApplyWindowInsetsListener(mDataBinding.getRoot(), (v, insets) -> {
//            Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
//            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
//            return insets;
//        });

    // 先把状态栏的颜色设置为透明色
    getWindow().setStatusBarColor(Color.TRANSPARENT);

    View decorView = getWindow().getDecorView();
    int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE      // 稳定布局
            | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN    // 内容扩展到状态栏
            | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;    // 指定状态栏图标深色模式

    decorView.setSystemUiVisibility(flags);

    ARouter.getInstance().inject(this);

    initView();
    initData();
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/BaseActivity.java

这段代码是在做沉浸式状态栏,让页面内容延伸到状态栏区域去显示,同时把状态栏图标改成深色。

逐句看:

java 复制代码
getWindow().setStatusBarColor(Color.TRANSPARENT);

状态栏背景色设为透明

这样顶部那条状态栏就不会再是默认纯色,看起来像是页面内容铺到了状态栏后面。

java 复制代码
View decorView = getWindow().getDecorView();

拿到当前窗口最外层的 decorView

很多系统 UI 相关的显示标记,都是设置在这个 View 上。

java 复制代码
int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE

表示保持布局稳定

即使系统栏显示状态发生变化,布局也尽量不要突然跳动。

java 复制代码
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN

表示让布局扩展到全屏区域,内容可以绘制到状态栏下面

也就是页面顶部会"顶到最上面",不是从状态栏下面才开始布局。

这里要注意:

SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 的重点是 layout ,也就是"布局延伸进去"。

它不是直接把状态栏隐藏掉,而是让页面内容能够占用状态栏那块区域。

所以更准确地说,这句的作用是:

让内容延伸到状态栏区域,而状态栏仍然会显示在页面上方。

java 复制代码
| View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;

表示状态栏里的图标和文字使用深色样式

比如时间、电量、信号这些,会变成深色,适合浅色背景。

java 复制代码
decorView.setSystemUiVisibility(flags);

把上面这些系统 UI 标记真正应用到窗口上。

整体效果就是:

  1. 状态栏背景透明
  2. 页面内容延伸到状态栏区域
  3. 状态栏图标变成深色
  4. 页面布局尽量保持稳定

这段代码一般用于:

  • 顶部大图页面
  • 沉浸式首页
  • 自定义标题栏
  • 需要更贴边、更满屏的页面布局

不过要注意一点:

因为内容已经延伸到状态栏下面了,顶部控件可能会被状态栏挡住

所以通常还要配合下面两种方式之一来处理:

  • 给顶部 View 增加 paddingTop
  • 或者使用 WindowInsets 处理状态栏区域

一句话总结:

这段代码的作用,就是把页面内容铺到状态栏后面,同时让状态栏保持透明,并把状态栏图标设置为深色。

此时的运行效果:

不过,窗口铺到状态栏后还有一个实际问题:如果顶部控件完全不做偏移,就会和系统栏图标直接重叠。所以接下来还要补一层"把状态栏高度加回布局"的工具方法。

java 复制代码
/**
 * 状态栏专用的工具类
 */
public class StatusBarUtils {

    /**
     * 把根布局加上状态栏的高度
     *
     * @param rootView
     */
    public static void addStatusBarHeight2RootView(View rootView) {
        ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> {
            Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
            return insets;
        });
    }

    
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/utils/StatusBarUtils.java

这段逻辑本质上还是利用 WindowInsets 读取系统栏高度,只不过这次不再写死在 BaseActivity 中,而是抽成通用工具方法,后面可以按页面类型自由选择是否调用。

如果在基类中统一调用,它的写法如下:

java 复制代码
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    initViewModel();
    initDatabinding();

//        //开启沉浸式状态栏
//        EdgeToEdge.enable(this);
//        //mDataBinding.getRoot()获取根布局
//        ViewCompat.setOnApplyWindowInsetsListener(mDataBinding.getRoot(), (v, insets) -> {
//            Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
//            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
//            return insets;
//        });

    // 先把状态栏的颜色设置为透明色
    getWindow().setStatusBarColor(Color.TRANSPARENT);

    View decorView = getWindow().getDecorView();
    int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE      // 稳定布局
            | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN    // 内容扩展到状态栏
            | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;    // 指定状态栏图标深色模式

    decorView.setSystemUiVisibility(flags);
		// 对指定根布局设置内边距,内边距为状态栏高度
    StatusBarUtils.addStatusBarHeight2RootView(mDataBinding.getRoot());

    ARouter.getInstance().inject(this);

    initView();
    initData();
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/BaseActivity.java

这样做能让大部分页面避开状态栏,但用户中心页反而会被"补"得不好看,因为它的顶部灰色卡片和背景图本来就需要顶到最上面,真正该偏移的只是右上角那两个图标。

因此更合理的方案是:普通页面自己给根布局加顶部内边距,特殊页面只给局部控件加顶部外边距。

在具体需要添加内边距的 Fragment 手动添加,而不是在基类 Activity 中统一设置:

java 复制代码
@Override
protected void initView() {
    StatusBarUtils.addStatusBarHeight2RootView(mDataBinding.getRoot());
}

而用户中心页要新增的则是第二个工具方法,只把状态栏高度叠加到指定子控件的 topMargin 上:

  • 在对应的 View ,加上状态栏高度,参数传入根布局以及需要设置的子控件,注意使用 View... views 表示传入多个控件视图参数,访问形式类似数组,但是不需要指定数组长度;
  • 遍历 views 元素,对每个元素设置一个状态栏高度的 MarginTop;
  • 从 View 元素中获取布局参数,注意,**要转为 ViewGroup.MarginLayoutParams,这样才可以对布局参数设置 Margin **
  • 通过 layoutParams.setMargins(0, topMargin + systemBars.top, 0, 0) ,对该控件原先的 topMargin 值,加上状态栏的高度,再设置到 Magin 参数中,来实现 MarginTop 的效果;
java 复制代码
/**
 * 把对应的view加上状态栏的高度
 *
 * @param rootView
 */
public static void addStatusBarHeight2Views(View rootView, View... views) {
    ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> {
        Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
        for (int i = 0; i < views.length; i++) {
            View view = views[i];
            ViewGroup.MarginLayoutParams layoutParams =
                    (ViewGroup.MarginLayoutParams) view.getLayoutParams();
            int topMargin = layoutParams.topMargin;
            layoutParams.setMargins(0, topMargin + systemBars.top, 0, 0);
            view.setLayoutParams(layoutParams);
        }
        return insets;
    });
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/utils/StatusBarUtils.java

用户中心页 UserFragment 通过 StatusBarUtils 调用 addStatusBarHeight2Views,将根布局,右上角的两个图标控件,一起作为参数:最终只给右上角两个图标加偏移:

java 复制代码
@Route(path = ARouterPath.User.FRAGMENT_USER)
public class UserFragment extends BaseFragment<LayoutFragmentUserBinding, BaseViewModel> {
    private static final String TAG = "UserFragment";

    @Override
    protected BaseViewModel getViewModel() {
        return null;
    }

    @Override
    protected int getLayoutResId() {
        return R.layout.layout_fragment_user;
    }

    @Override
    protected int getBindingVariableId() {
        return 0;
    }

    @Override
    protected void initView() {
        Log.i(TAG, "initView");
        StatusBarUtils.addStatusBarHeight2Views(mDataBinding.getRoot(), mDataBinding.ivSettings, mDataBinding.ivQualifications);

        mDataBinding.ivEdit.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                ARouter.getInstance().build(ARouterPath.User.ACTIVITY_LOGIN).navigation();
            }
        });
    }

    @Override
    protected void initData() {

    }
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/user/UserFragment.java

这样一来,顶部灰色卡片依旧可以顶到透明状态栏区域,而真正会被系统栏挡住的图标则单独向下腾挪。

回到 BaseActivity,还需要再对下面的,将页面内容顶到状态栏上的代码,进行一份封装:

java 复制代码
// 先把状态栏的颜色设置为透明色
getWindow().setStatusBarColor(Color.TRANSPARENT);

View decorView = getWindow().getDecorView();
int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE      // 稳定布局
        | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN    // 内容扩展到状态栏
        | View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;    // 指定状态栏图标深色模式

decorView.setSystemUiVisibility(flags);

为了让后续页面接入更简单,最后再把窗口级的沉浸式逻辑抽成一个独立方法:

java 复制代码
public static void setImmerseStatusBar(Activity activity) {
    //先把状态栏的颜色设置为透明色
    activity.getWindow().setStatusBarColor(Color.TRANSPARENT);

    View decorView = activity.getWindow().getDecorView();
    int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE |//稳定布局
            View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |//内容扩展到状态栏
            View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;//指定状态栏图标深色模式
    decorView.setSystemUiVisibility(flags);
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/utils/StatusBarUtils.java

基类 Activity 只保留入口调用即可:

java 复制代码
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    initViewModel();
    initDatabinding();

    //开启沉浸式效果
    StatusBarUtils.setImmerseStatusBar(this);

    ARouter.getInstance().inject(this);

    initView();
    initData();
    initProgressBar();
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/BaseActivity.java

3. 梳理手机号登录页的接口链路与返回数据

UI 真正开始写之前,先把登录页会用到的服务端接口摸清楚。这样做的好处是,后面在设计 ViewModel 字段、请求体结构和页面跳转时,不会因为接口字段遗漏而反复返工。

登录页最终长这样:

第一步先确认手机号登录接口的调用方式和字段组织:

接口调试时需要注意区分请求参数位置:

  1. Query 对应 GET 请求参数。
  2. Body 对应 POST 请求体。

发送验证码接口的输入输出如下:

手机号加验证码登录接口如下:

登录成功以后,服务端会先返回用户 idtoken。这两个字段不是终点,而是后续发起登录鉴权请求的起点。也就是说,登录接口先解决"你是谁",而获取用户信息接口再继续解决"页面应该展示什么"。

获取用户详情的接口如下:

4. 搭建登录页 UI,并用 DataBinding 连接输入状态

登录页布局需要同时承载三个目标:一是顶部大图和沉浸式按钮区,二是手机号与验证码输入区,三是协议勾选、验证码按钮和登录按钮这几组会跟 ViewModel 实时联动的控件。所以布局阶段就要把 data、输入框绑定和按钮绑定一起铺好。

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="viewModel"
            type="com.ls.feature_user.ui.login.LoginViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/main"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#E5E5E5"
        tools:context=".ui.login.LoginActivity">

        <ImageView
            android:id="@+id/iv_top_bg"
            android:layout_width="match_parent"
            android:layout_height="340dp"
            android:scaleType="centerCrop"
            android:src="@mipmap/bg_login_top"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <ImageView
            android:id="@+id/iv_back"
            android:layout_width="20dp"
            android:layout_height="20dp"
            android:layout_marginStart="@dimen/margin_start_14dp"
            android:layout_marginTop="2dp"
            android:src="@mipmap/icon_back"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <ImageView
            android:id="@+id/iv_settings"
            android:layout_width="20dp"
            android:layout_height="20dp"
            android:layout_marginTop="2dp"
            android:layout_marginEnd="14dp"
            android:src="@mipmap/icon_settings"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <ImageView
            android:id="@+id/iv_qualifications"
            android:layout_width="20dp"
            android:layout_height="20dp"
            android:layout_marginEnd="12dp"
            android:src="@mipmap/icon_qualifications"
            app:layout_constraintEnd_toStartOf="@id/iv_settings"
            app:layout_constraintTop_toTopOf="@id/iv_settings" />


        <ImageView
            android:id="@+id/iv_logo"
            android:layout_width="136.47dp"
            android:layout_height="15.14dp"
            android:layout_marginTop="127.5dp"
            android:src="@mipmap/icon_write_logo"
            app:layout_constraintEnd_toEndOf="@+id/iv_top_bg"
            app:layout_constraintStart_toStartOf="@+id/iv_top_bg"
            app:layout_constraintTop_toTopOf="@+id/iv_top_bg" />

        <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/constraintLayout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="17.5dp"
            android:layout_marginTop="97.5dp"
            android:layout_marginEnd="17.5dp"
            android:background="@color/white"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/iv_logo">


            <TextView
                android:id="@+id/tv_user_account"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="36.5dp"
                android:layout_marginTop="27.5dp"
                android:text="用户账号"
                android:textColor="#ff000000"
                android:textSize="13sp"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

            <View
                android:layout_width="0dp"
                android:layout_height="1dp"
                android:background="#9C9C9C"
                app:layout_constraintEnd_toEndOf="@id/tv_user_account"
                app:layout_constraintStart_toStartOf="@id/tv_user_account"
                app:layout_constraintTop_toBottomOf="@id/tv_user_account" />

            <EditText
                android:id="@+id/et_user_name"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginTop="51dp"
                android:layout_marginEnd="35.5dp"
                android:background="@null"
                android:text="@={viewModel.userMobile}"
                android:hint="输入手机号登录/注册"
                android:inputType="phone"
                android:maxLength="11"
                android:textColor="#ffbbbbbb"
                android:textSize="11sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="@+id/tv_user_account"
                app:layout_constraintTop_toBottomOf="@id/tv_user_account" />


            <View
                android:id="@+id/view_phone_div"
                android:layout_width="0dp"
                android:layout_height="1dp"
                android:layout_marginTop="8.5dp"
                android:background="#9C9C9C"
                app:layout_constraintEnd_toEndOf="@id/et_user_name"
                app:layout_constraintStart_toStartOf="@id/et_user_name"
                app:layout_constraintTop_toBottomOf="@id/et_user_name" />


            <EditText
                android:id="@+id/et_password"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginTop="52dp"
                android:layout_marginEnd="35.5dp"
                android:background="@null"
                android:hint="输入验证码"
                android:inputType="number"
                android:text="@={viewModel.code}"
                android:maxLength="4"
                android:textColor="#ffbbbbbb"
                android:textSize="11sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="@+id/view_phone_div"
                app:layout_constraintTop_toBottomOf="@id/view_phone_div" />

            <TextView
                android:id="@+id/tv_get_code"
                android:layout_width="64dp"
                android:layout_height="16dp"
                android:enabled="@{viewModel.isEnableSendCode}"
                android:background="@drawable/bg_send_sms"
                android:gravity="center"
                android:text="@{viewModel.getVerticalCodeText}"
                android:textSize="10sp"
                android:onClick="@{()->viewModel.sendCode()}"
                app:layout_constraintBottom_toBottomOf="@id/et_password"
                app:layout_constraintEnd_toEndOf="@id/et_password"
                tools:text="获取验证码" />

            <View
                android:id="@+id/view_code_div"
                android:layout_width="0dp"
                android:layout_height="1dp"
                android:layout_marginTop="8.5dp"
                android:background="#9C9C9C"
                app:layout_constraintEnd_toEndOf="@id/et_password"
                app:layout_constraintStart_toStartOf="@id/et_password"
                app:layout_constraintTop_toBottomOf="@id/et_password" />

            <ImageView
                android:id="@+id/iv_wechat"
                android:layout_width="28dp"
                android:layout_height="28dp"
                android:layout_marginTop="50dp"
                android:layout_marginEnd="44dp"
                android:src="@mipmap/icon_wechat"
                app:layout_constraintEnd_toStartOf="@id/iv_qq"
                app:layout_constraintHorizontal_chainStyle="packed"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/view_code_div" />

            <ImageView
                android:id="@+id/iv_qq"
                android:layout_width="28dp"
                android:layout_height="28dp"
                android:src="@mipmap/icon_qq"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toEndOf="@id/iv_wechat"
                app:layout_constraintTop_toTopOf="@id/iv_wechat" />

            <TextView
                android:id="@+id/tv_auto_regist"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="56.5dp"
                android:text="新手机号将自动注册"
                android:textColor="#ffbbbbbb"
                android:textSize="10sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/iv_wechat" />

            <CheckBox
                android:id="@+id/cb_agreen"
                android:layout_width="wrap_content"
                android:layout_height="20dp"
                android:layout_marginTop="9dp"
                android:button="@null"
                android:drawableStart="@drawable/icon_custom_checkbox"
                android:gravity="center"
                android:checked="@={viewModel.checkAgreement}"
                android:text="请阅读并同意《用户协议》和《隐私政策》"
                android:textColor="#ff000000"
                android:textSize="10sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/tv_auto_regist" />


            <TextView
                android:layout_width="0dp"
                android:layout_height="55dp"
                android:layout_marginTop="17dp"
                android:background="@drawable/bg_login_button"
                android:enabled="@{viewModel.isEnableLogin}"
                android:gravity="center"
                android:text="登录"
                android:onClick="@{()->viewModel.login()}"
                android:textColor="@color/white"
                android:textSize="16sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/cb_agreen" />

        </androidx.constraintlayout.widget.ConstraintLayout>


    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

项目路径:LsxbugVideo/feature_user/src/main/res/layout/activity_login.xml

这一版布局里最重要的不是视觉元素,而是四组绑定关系已经提前就位:

  1. 手机号输入框通过 @={viewModel.userMobile}ViewModel 双向同步。
  2. 验证码输入框通过 @={viewModel.code} 同步输入内容。
  3. 获取验证码按钮通过 isEnableSendCodegetVerticalCodeText 同步可用态与文案。
  4. 登录按钮通过 isEnableLogin 控制点击状态,并把点击动作交给 viewModel.login()

布局效果如下:

登录按钮单独拎出来看更清楚,它的可点击性直接由 android:enabled="@{viewModel.isEnableLogin}" 控制:

xml 复制代码
<TextView
      android:layout_width="0dp"
      android:layout_height="55dp"
      android:layout_marginTop="17dp"
      android:background="@drawable/bg_login_button"
      android:enabled="@{viewModel.isEnableLogin}"
      android:gravity="center"
      android:text="登录"
      android:onClick="@{()->viewModel.login()}"
      android:textColor="@color/white"
      android:textSize="16sp"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toBottomOf="@id/cb_agreen" />

项目路径:LsxbugVideo/feature_user/src/main/res/layout/activity_login.xml

不可点击时的样式如下:

可点击时的样式如下:

之所以能跟 enabled 状态联动,是因为用了状态选择器,按钮背景实现这个效果是通过 android:background="@drawable/bg_login_button" 的实现:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- 状态:不可点击 -->
    <item android:state_enabled="false" android:drawable="@android:color/darker_gray" />
    <!-- 状态:可点击 -->
    <item android:state_enabled="true" android:drawable="@android:color/black" />
</selector>

项目路径:LsxbugVideo/feature_user/src/main/res/drawable/bg_login_button.xml

5. 通过双向绑定实时控制登录按钮状态

登录入口并不只存在于底部导航。用户中心页点击昵称旁边的编辑图标,也需要直接跳到登录页面,所以登录流程首先要在用户中心页接好跳转动作。

java 复制代码
@Route(path = ARouterPath.User.FRAGMENT_USER)
public class UserFragment extends BaseFragment<LayoutFragmentUserBinding, BaseViewModel> {
    private static final String TAG = "UserFragment";

    @Override
    protected BaseViewModel getViewModel() {
        return null;
    }

    @Override
    protected int getLayoutResId() {
        return R.layout.layout_fragment_user;
    }

    @Override
    protected int getBindingVariableId() {
        return 0;
    }

    @Override
    protected void initView() {
        Log.i(TAG, "initView");
        StatusBarUtils.addStatusBarHeight2Views(mDataBinding.getRoot(), mDataBinding.ivSettings, mDataBinding.ivQualifications);

        mDataBinding.ivEdit.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                ARouter.getInstance().build(ARouterPath.User.ACTIVITY_LOGIN).navigation();
            }
        });
    }

    @Override
    protected void initData() {

    }
} 

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/user/UserFragment.java

进入 LoginActivity 后,先把沉浸式顶部按钮区补齐状态栏偏移:

java 复制代码
@Override
protected void initView() {
    //在这里做一些布局、ui的初始化

    StatusBarUtils.addStatusBarHeight2Views(mDataBinding.getRoot(), mDataBinding.ivBack, mDataBinding.ivSettings);
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginActivity.java

这里不需要把 iv_qualifications 也传进去,因为它本身已经约束在 iv_settings 顶部,后者一旦完成偏移,前者会自动跟随:

xml 复制代码
<ImageView
      android:id="@+id/iv_qualifications"
      android:layout_width="20dp"
      android:layout_height="20dp"
      android:layout_marginEnd="12dp"
      android:src="@mipmap/icon_qualifications"
      app:layout_constraintEnd_toStartOf="@id/iv_settings"
      app:layout_constraintTop_toTopOf="@id/iv_settings" />

项目路径:LsxbugVideo/feature_user/src/main/res/layout/activity_login.xml

这样顶部三个按钮的视觉位置就稳定了:

接下来再把登录页的输入和按钮状态完整接到 LoginViewModel 上。布局层已经通过双向绑定把几个关键字段暴露出来:

  1. 手机号输入框:android:text="@={viewModel.userMobile}"
  2. 验证码输入框:android:text="@={viewModel.code}"
  3. 登录按钮是否可点击:android:enabled="@{viewModel.isEnableLogin}"
  4. 协议是否勾选:android:checked="@={viewModel.checkAgreement}"
  5. 登录按钮点击动作:android:onClick="@{()->viewModel.login()}"

完整布局如下:

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="viewModel"
            type="com.ls.feature_user.ui.login.LoginViewModel" />
    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/main"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#E5E5E5"
        tools:context=".ui.login.LoginActivity">

        <ImageView
            android:id="@+id/iv_top_bg"
            android:layout_width="match_parent"
            android:layout_height="340dp"
            android:scaleType="centerCrop"
            android:src="@mipmap/bg_login_top"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <ImageView
            android:id="@+id/iv_back"
            android:layout_width="20dp"
            android:layout_height="20dp"
            android:layout_marginStart="@dimen/margin_start_14dp"
            android:layout_marginTop="2dp"
            android:src="@mipmap/icon_back"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <ImageView
            android:id="@+id/iv_settings"
            android:layout_width="20dp"
            android:layout_height="20dp"
            android:layout_marginTop="2dp"
            android:layout_marginEnd="14dp"
            android:src="@mipmap/icon_settings"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <ImageView
            android:id="@+id/iv_qualifications"
            android:layout_width="20dp"
            android:layout_height="20dp"
            android:layout_marginEnd="12dp"
            android:src="@mipmap/icon_qualifications"
            app:layout_constraintEnd_toStartOf="@id/iv_settings"
            app:layout_constraintTop_toTopOf="@id/iv_settings" />


        <ImageView
            android:id="@+id/iv_logo"
            android:layout_width="136.47dp"
            android:layout_height="15.14dp"
            android:layout_marginTop="127.5dp"
            android:src="@mipmap/icon_write_logo"
            app:layout_constraintEnd_toEndOf="@+id/iv_top_bg"
            app:layout_constraintStart_toStartOf="@+id/iv_top_bg"
            app:layout_constraintTop_toTopOf="@+id/iv_top_bg" />

        <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/constraintLayout"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginStart="17.5dp"
            android:layout_marginTop="97.5dp"
            android:layout_marginEnd="17.5dp"
            android:background="@color/white"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@+id/iv_logo">


            <TextView
                android:id="@+id/tv_user_account"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginStart="36.5dp"
                android:layout_marginTop="27.5dp"
                android:text="用户账号"
                android:textColor="#ff000000"
                android:textSize="13sp"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent" />

            <View
                android:layout_width="0dp"
                android:layout_height="1dp"
                android:background="#9C9C9C"
                app:layout_constraintEnd_toEndOf="@id/tv_user_account"
                app:layout_constraintStart_toStartOf="@id/tv_user_account"
                app:layout_constraintTop_toBottomOf="@id/tv_user_account" />

            <EditText
                android:id="@+id/et_user_name"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginTop="51dp"
                android:layout_marginEnd="35.5dp"
                android:background="@null"
                android:text="@={viewModel.userMobile}"
                android:hint="输入手机号登录/注册"
                android:inputType="phone"
                android:maxLength="11"
                android:textColor="#ffbbbbbb"
                android:textSize="11sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="@+id/tv_user_account"
                app:layout_constraintTop_toBottomOf="@id/tv_user_account" />


            <View
                android:id="@+id/view_phone_div"
                android:layout_width="0dp"
                android:layout_height="1dp"
                android:layout_marginTop="8.5dp"
                android:background="#9C9C9C"
                app:layout_constraintEnd_toEndOf="@id/et_user_name"
                app:layout_constraintStart_toStartOf="@id/et_user_name"
                app:layout_constraintTop_toBottomOf="@id/et_user_name" />


            <EditText
                android:id="@+id/et_password"
                android:layout_width="0dp"
                android:layout_height="wrap_content"
                android:layout_marginTop="52dp"
                android:layout_marginEnd="35.5dp"
                android:background="@null"
                android:hint="输入验证码"
                android:inputType="number"
                android:text="@={viewModel.code}"
                android:maxLength="4"
                android:textColor="#ffbbbbbb"
                android:textSize="11sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="@+id/view_phone_div"
                app:layout_constraintTop_toBottomOf="@id/view_phone_div" />

            <TextView
                android:id="@+id/tv_get_code"
                android:layout_width="64dp"
                android:layout_height="16dp"
                android:enabled="@{viewModel.isEnableSendCode}"
                android:background="@drawable/bg_send_sms"
                android:gravity="center"
                android:text="@{viewModel.getVerticalCodeText}"
                android:textSize="10sp"
                android:onClick="@{()->viewModel.sendCode()}"
                app:layout_constraintBottom_toBottomOf="@id/et_password"
                app:layout_constraintEnd_toEndOf="@id/et_password"
                tools:text="获取验证码" />

            <View
                android:id="@+id/view_code_div"
                android:layout_width="0dp"
                android:layout_height="1dp"
                android:layout_marginTop="8.5dp"
                android:background="#9C9C9C"
                app:layout_constraintEnd_toEndOf="@id/et_password"
                app:layout_constraintStart_toStartOf="@id/et_password"
                app:layout_constraintTop_toBottomOf="@id/et_password" />

            <ImageView
                android:id="@+id/iv_wechat"
                android:layout_width="28dp"
                android:layout_height="28dp"
                android:layout_marginTop="50dp"
                android:layout_marginEnd="44dp"
                android:src="@mipmap/icon_wechat"
                app:layout_constraintEnd_toStartOf="@id/iv_qq"
                app:layout_constraintHorizontal_chainStyle="packed"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/view_code_div" />

            <ImageView
                android:id="@+id/iv_qq"
                android:layout_width="28dp"
                android:layout_height="28dp"
                android:src="@mipmap/icon_qq"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toEndOf="@id/iv_wechat"
                app:layout_constraintTop_toTopOf="@id/iv_wechat" />

            <TextView
                android:id="@+id/tv_auto_regist"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="56.5dp"
                android:text="新手机号将自动注册"
                android:textColor="#ffbbbbbb"
                android:textSize="10sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/iv_wechat" />

            <CheckBox
                android:id="@+id/cb_agreen"
                android:layout_width="wrap_content"
                android:layout_height="20dp"
                android:layout_marginTop="9dp"
                android:button="@null"
                android:drawableStart="@drawable/icon_custom_checkbox"
                android:gravity="center"
                android:checked="@={viewModel.checkAgreement}"
                android:text="请阅读并同意《用户协议》和《隐私政策》"
                android:textColor="#ff000000"
                android:textSize="10sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/tv_auto_regist" />


            <TextView
                android:layout_width="0dp"
                android:layout_height="55dp"
                android:layout_marginTop="17dp"
                android:background="@drawable/bg_login_button"
                android:enabled="@{viewModel.isEnableLogin}"
                android:gravity="center"
                android:text="登录"
                android:onClick="@{()->viewModel.login()}"
                android:textColor="@color/white"
                android:textSize="16sp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toBottomOf="@id/cb_agreen" />

        </androidx.constraintlayout.widget.ConstraintLayout>


    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

项目路径:LsxbugVideo/feature_user/src/main/res/layout/activity_login.xml

页面状态真正落在 ViewModel 里之后,按钮可用态的判断就有了统一入口;

在 LoginViewModel

  • 通过双向绑定获取手机号、验证码中输入框的值,以及登录按钮是否可点击
  • 登录按钮 mIsEnableLogin 传 false,置为默认不可用
  • 是否勾选协议按钮,默认置为 false
  • 实现 updateEnableLoginBtnStatus() 方法,用于更新登录按钮的可用状态,当输入的手机号长度为 11,验证码长度为 4,登录按钮置为可用状态;
java 复制代码
public class LoginViewModel extends ViewModel {

    private static final String TAG = "LoginViewModel";

    private MutableLiveData<String> mUserMobile = new MutableLiveData<>();//用户输入的手机号
    private MutableLiveData<String> mCode = new MutableLiveData<>();//用户输入的验证码
    private MutableLiveData<Boolean> mIsEnableLogin = new MutableLiveData<>(false);//登录按钮是否可用。默认不可用
    private MutableLiveData<Boolean> mCheckAgreement = new MutableLiveData<>(false);//是否勾选协议

    /**
     * 更新登录按钮的可用状态
     */
    public void updateEnableLoginBtnStatus() {
        String mobile = mUserMobile.getValue();
        String code = mCode.getValue();

        if (mobile == null || code == null) {
            return;
        }

        boolean isEnable = mobile.length() == 11 && code.length() == 4;
        mIsEnableLogin.setValue(isEnable);
    }

    /**
     * 登录
     */
    public void login() {
        Boolean checkAgreement = mCheckAgreement.getValue();
        if (!checkAgreement) {
            Log.i(TAG, "请先同意用户协议与隐私政策");
            return;
        }
        Log.i(TAG, "login: ");
    }

    public MutableLiveData<Boolean> getIsEnableLogin() {
        return mIsEnableLogin;
    }

    public MutableLiveData<String> getUserMobile() {
        return mUserMobile;
    }

    public MutableLiveData<String> getCode() {
        return mCode;
    }

    public MutableLiveData<Boolean> getCheckAgreement() {
        return mCheckAgreement;
    }
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginViewModel.java

逻辑上的关键点有两个:

  1. mIsEnableLogin 默认就是 false,页面初始态天然不可点。
  2. updateEnableLoginBtnStatus() 不直接依赖按钮点击,而是依赖输入内容变化。这就意味着按钮可点不可点由状态驱动,而不是由页面手动切换。

为了让这个状态实时刷新,LoginActivity 里分别监听手机号和验证码输入:

java 复制代码
@Override
protected void initView() {
    //在这里做一些布局、ui的初始化

    StatusBarUtils.addStatusBarHeight2Views(mDataBinding.getRoot(), mDataBinding.ivBack, mDataBinding.ivSettings);

    mViewModel.getUserMobile().observe(this, mobile -> {
        mViewModel.updateEnableLoginBtnStatus();
    });
    mViewModel.getCode().observe(this, code -> {
        mViewModel.updateEnableLoginBtnStatus();
    });
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginActivity.java

重新运行之后,手机号长度和验证码长度一旦满足条件,按钮状态就会切换;如果没有勾选协议,即便按钮已经亮起,login() 也会在协议校验处提前返回。

6. 修复输入法弹出后沉浸式间距重复累加的问题

沉浸式状态栏适配完之后,登录页又暴露出一个更隐蔽的问题:弹出输入法时,顶部按钮会继续往下跑。

问题根源在于 setOnApplyWindowInsetsListener 并不只会在页面首次展示时触发。只要窗口尺寸变化,例如键盘弹起,WindowInsets 就会再次回调。如果每次回调都把状态栏高度累加到 topMargin 上,顶部控件自然会一层层往下偏。

解决思路是,只有第一次进入页面,才会触发 addStatusBarHeight2Views,后续这个页面的窗体发生变化,都不会再次触发该方法:

  • 使用 AtomicBoolean 原子布尔类,保证线程安全的同时,设置为 true,作为是否需要添加状态栏高度的标记位,默认为 true;
  • 当执行了添加高度的逻辑后,将该标记位置为 false,后续窗体大小发生变化,触发 setOnApplyWindowInsetsListener 回调,此时先获取标记位,为 false 直接返回,无需执行后面的循环为空间增加高度的逻辑;
java 复制代码
public static void addStatusBarHeight2Views(View rootView, View... views) {
    AtomicBoolean isNeedAddHeight = new AtomicBoolean(true);//默认需要添加高度
    ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> {

        if (!isNeedAddHeight.get()) {
            return insets;
        }

        Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
        for (int i = 0; i < views.length; i++) {
            View view = views[i];
            ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) view.getLayoutParams();
            int topMargin = layoutParams.topMargin;
            layoutParams.setMargins(0, topMargin + systemBars.top, 0, 0);
            view.setLayoutParams(layoutParams);
        }
        isNeedAddHeight.set(false);
        return insets;
    });
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/utils/StatusBarUtils.java

这里之所以使用 AtomicBoolean,是为了把"是否已经执行过状态栏补偿"这个开关独立出来。第一次执行完就把标记改成 false,之后即便输入法、分屏或者其他窗口变化再次触发回调,也只会原样返回 insets,不会重复改 margin

7. 为验证码按钮补齐倒计时、禁用态与请求反馈

登录按钮状态稳定以后,下一步要处理验证码按钮。这个控件看似只是一个小按钮,实际上要同时承担手机号校验、请求发送、倒计时展示、点击禁用和失败提示五件事。

验证码按钮的目标交互如下:

先在 LoginViewModel 中把和验证码按钮有关的状态单独抽出来;

这些成员变量分别解决不同问题:

  1. mGetVerticalCodeText 负责按钮文案,从"获取验证码"切到"59s""58s"这类倒计时文本。
  2. mIsEnableSendCode 决定按钮当前能不能再被点击。
  3. mDownTimer 用来维持 60 秒倒计时。
  4. mToastText 负责把校验失败、请求结果等提示传回页面层。
java 复制代码
private MutableLiveData<String> mGetVerticalCodeText = new MutableLiveData<>("获取验证码");//获取验证码控件的显示文本

private MutableLiveData<Boolean> mIsEnableSendCode = new MutableLiveData<>(true);//获取验证码控件是否可用

private CountDownTimer mDownTimer;//获取验证码的倒计时

//如果toastText发生了变化,表示需要进行toast弹窗显示
private MutableLiveData<String> mToastText = new MutableLiveData<>();

// get 方法

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginViewModel.java

按动作顺序理解:

  1. 先检查手机号长度,不合法就直接 showToast 并终止后续流程。
  2. 倒计时开始前先判断 mDownTimer 是否为空,调用 mDownTimer.cancel(),防止重复点击时旧计时器还在跑。
  3. 一旦进入发送流程,立即把 mIsEnableSendCode 设为 false,从源头上阻止再次点击。
  4. onTick() 每秒更新一次文案,把毫秒转成秒再展示给用户。
  5. onFinish() 恢复按钮文本和可点击状态,形成完整闭环。
  6. 请求发起前打开加载状态,请求回调里无论成功失败都关闭加载状态,并通过 showToast() 反馈结果。

发送验证码的主逻辑如下:

java 复制代码
/**
 * 发送验证码
 */
public void sendCode() {

    String mobile = mUserMobile.getValue();
    if (mobile == null || mobile.length() != 11) {
        Log.i(TAG, "sendCode: 手机号不符合规则!");
        showToast("请输入正确的手机号码!");
        return;
    }


    if (mDownTimer != null) {
        mDownTimer.cancel();//防止重复点击时 未停止之前的计时
    }

    //禁用发送按钮
    mIsEnableSendCode.setValue(false);

    mDownTimer = new CountDownTimer(60000, 1000) {

        @Override
        public void onTick(long millisUntilFinished) {
            //每1秒会被触发 把毫秒转为秒
            int seconds = (int) (millisUntilFinished / 1000);
            mGetVerticalCodeText.setValue(seconds + "s");//更新倒计时的显示
        }

        @Override
        public void onFinish() {
            //倒计时完成后
            mGetVerticalCodeText.setValue("获取验证码");
            //60s后允许发送验证码
            mIsEnableSendCode.setValue(true);
        }
    }.start();

    //发起请求,让服务端发送验证码
    Log.i(TAG, "sendCode: ");
    showLoading(true);
    //发起获取验证码请求
    mModel.sendSmsCode(mobile, new IRequestCallback<ResBase<ResBase>>() {
        @Override
        public void onLoadFinish(ResBase<ResBase> datas) {
            showToast(datas.getMsg());//显示消息弹窗
            showLoading(false);
        }

        @Override
        public void onLoadFailure(int errorCode, String message) {
            showToast(message);//显示消息弹窗
            showLoading(false);
        }
    });
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginViewModel.java

由于 MVVM 中的页面提示不应该散落在 Activity 的点击回调里,后面的 Toast 和加载样式会继续下沉到基类里统一处理。

布局这一侧也要把验证码按钮和 ViewModel 连起来:

这里实际绑定了三项内容:

  1. 按钮是否可点击:android:enabled="@{viewModel.isEnableSendCode}"
  2. 按钮显示文本:android:text="@{viewModel.getVerticalCodeText}"
  3. 按钮点击动作:android:onClick="@{()->viewModel.sendCode()}"
xml 复制代码
<TextView
    android:id="@+id/tv_get_code"
    android:layout_width="64dp"
    android:layout_height="16dp"
    android:enabled="@{viewModel.isEnableSendCode}"
    android:background="@drawable/bg_send_sms"
    android:gravity="center"
    android:text="@{viewModel.getVerticalCodeText}"
    android:textSize="10sp"
    android:onClick="@{()->viewModel.sendCode()}"
    app:layout_constraintBottom_toBottomOf="@id/et_password"
    app:layout_constraintEnd_toEndOf="@id/et_password"
    tools:text="获取验证码" />

项目路径:LsxbugVideo/feature_user/src/main/res/layout/activity_login.xml

按钮底层的可点击样式切换示意如下:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- 状态:不可点击 -->
    <item android:state_enabled="false" android:drawable="@android:color/darker_gray" />
    <!-- 状态:可点击 -->
    <item android:state_enabled="true" android:drawable="@android:color/black" />
</selector>

项目路径:LsxbugVideo/feature_user/src/main/res/drawable/bg_send_sms.xml

8. 把 Toast 能力上收到 BaseViewModel 与基类页面

验证码发送逻辑接好以后,页面层马上会出现一个重复问题:只要有手机号校验、验证码请求、登录请求或用户信息请求,几乎每个页面都会监听一遍提示信息,然后自己手动弹 Toast。这类高频 UI 反馈不应该四处散落,更适合放到基类统一收口。

最直接的观察写法如下:

java 复制代码
@Override
protected void initView() {
  	// ....
    mViewModel.getToastText().observe(this, text -> {
      Toast.makeText(this, text, Toast.LENGTH_SHORT).show();
  	});
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginActivity.java

如果每个页面都这样写,问题会有两个:

  1. 观察者代码完全重复。
  2. ViewModel 一旦变多,页面层会同时承担业务逻辑和通用提示逻辑,边界会变得很乱。

因此先在 BaseViewModel 中定义统一的提示出口:

java 复制代码
public class BaseViewModel extends ViewModel {

    //错误码
    public MutableLiveData<Integer> mErrorCode = new MutableLiveData<>();

    //如果toastText发生了变化,表示需要进行toast弹窗显示
    private MutableLiveData<String> mToastText = new MutableLiveData<>();

    //是否显示加载样式
    private MutableLiveData<Boolean> mShowLoading = new MutableLiveData<>();


    /**
     * 显示吐司弹窗
     *
     * @param text
     */
    public void showToast(String text) {
        if (text == null || text.equals("")) {
            return;
        }
        mToastText.setValue(text);
    }
  
   // get
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/BaseViewModel.java

这样做之后,去掉 LoginActivity 的观察者观察 ToastText 的逻辑,所有子类 ViewModel 只要调用 showToast(),就能把提示文案抛给页面层,而不必再关心页面具体如何展示。

为了让基类真正接住这份能力,ActivityFragment 的泛型约束也要同步收紧,不再直接继承 ViewModel,而是统一继承 BaseViewModel

java 复制代码
public abstract class BaseActivity<V extends ViewDataBinding, VM extends BaseViewModel> extends AppCompatActivity {

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/BaseActivity.java

同时,BaseFragment 也可以封装 Toast 提示:

java 复制代码
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {

    // ....
    initToast();
    initProgressBar();
    return mDataBinding.getRoot();
}

private void initToast() {
    if (mViewModel!=null){
        mViewModel.getToastText().observe(getViewLifecycleOwner(), text -> {
            Toast.makeText(getContext(), text, Toast.LENGTH_SHORT).show();
        });

        //控制加载样式是否显示
        mViewModel.getShowLoading().observe(getViewLifecycleOwner(), show -> {
            mProgressBar.setVisibility(show ? View.VISIBLE : View.GONE);
        });
    }
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/BaseFragment.java

最后把 Activity 端的观察逻辑也收回到 BaseActivity.initViewModel() 中:

java 复制代码
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    initViewModel();
    initDatabinding();


    //开启沉浸式效果
    StatusBarUtils.setImmerseStatusBar(this);

    ARouter.getInstance().inject(this);

    initView();
    initData();
}

private void initViewModel() {
    //从子类获取到的viewModel 赋值给mViewModel
    mViewModel = getViewModel();

    if (mViewModel != null) {
        //控制是否显示弹窗信息
        mViewModel.getToastText().observe(this, text -> {
            Toast.makeText(this, text, Toast.LENGTH_SHORT).show();
        });
    }
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/BaseActivity.java

到这一步,页面只负责发出 showToast() 信号,真正的 Toast 展示则全部由基类接管,后面网络请求和登录态处理就可以继续复用这套能力。

9. 把通用加载样式下沉到 Activity 与 Fragment 基类

有了统一的提示能力之后,加载样式也存在同样的下沉价值。验证码发送、登录、拉取用户信息、加载协议页这几类操作都需要一个轻量的等待反馈,如果每个页面都重复创建 ProgressBar,维护成本会很快失控。

先在 BaseViewModel 里补一份加载状态:

java 复制代码
//是否显示加载样式
private MutableLiveData<Boolean> mShowLoading = new MutableLiveData<>();

/**
 * 是否显示弹窗
 *
 * @param b
 */
public void showLoading(boolean b) {
    mShowLoading.setValue(b);
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/BaseViewModel.java

接下来在 BaseActivity 中动态创建一个居中的 ProgressBar。这里没有额外写一个公共布局,而是直接在基类里按约束规则生成,目的是让所有页面天然共用同一种加载表现。

这里每一行都在为"通用"服务:

  1. ConstraintLayout.LayoutParams 把控件固定在父布局中心。
  2. WRAP_CONTENT 自适应,保证加载控件不会侵入页面布局。
  3. 默认 GONE,只有真正请求时才显示。
  4. 直接把 ProgressBar 挂进根布局,不要求业务页面额外准备占位控件。
java 复制代码
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    initViewModel();
    initDatabinding();

    //开启沉浸式效果
    StatusBarUtils.setImmerseStatusBar(this);

    ARouter.getInstance().inject(this);

    initView();
    initData();
    initProgressBar();
}

private ProgressBar mProgressBar;

/**
 * 初始化加载样式
 */
private void initProgressBar() {
    mProgressBar = new ProgressBar(this);
    ConstraintLayout.LayoutParams layoutParams = new ConstraintLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
    layoutParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
    layoutParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID;
    layoutParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
    layoutParams.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID;
    mProgressBar.setLayoutParams(layoutParams);
    mProgressBar.setVisibility(View.GONE);//默认不可见
    ConstraintLayout constraintLayout = (ConstraintLayout) mDataBinding.getRoot();
    constraintLayout.addView(mProgressBar);
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/BaseActivity.java

在 initViewModel() 方法中,设置加载样式的观察者,当观察到 BaseViewModel 的 ShowLoading 变化,显示加载样式:

java 复制代码
private void initViewModel() {
    //从子类获取到的viewModel 赋值给mViewModel
    mViewModel = getViewModel();

    if (mViewModel != null) {
        //控制是否显示弹窗信息
        mViewModel.getToastText().observe(this, text -> {
            Toast.makeText(this, text, Toast.LENGTH_SHORT).show();
        });
        //控制加载样式是否显示
        mViewModel.getShowLoading().observe(this, show -> {
            mProgressBar.setVisibility(show ? View.VISIBLE : View.GONE);
        });
    }
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/BaseActivity.java

Fragment 侧的处理方式保持一致,在 BaseFragment 中,实现并调用 initToast()、initProgressBar:

java 复制代码
@Nullable
@Override
public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) {

    mDataBinding = DataBindingUtil.inflate(inflater, getLayoutResId(), container, false);
    mDataBinding.setLifecycleOwner(this);

    VM viewModel = getViewModel();
    if (viewModel != null) {
        mViewModel = viewModel;
    }


    //在父类中使用viewmodel在xml中的变量名 关联具体的mViewModel  效果等同于mDataBinding.setViewModel(mViewModel);
    int bindingVariableId = getBindingVariableId();
    if (bindingVariableId != 0) {
        mDataBinding.setVariable(bindingVariableId, mViewModel);
        //关联完mViewModel后,实时更新数据
        mDataBinding.executePendingBindings();
    }
    //初始化arouter
    ARouter.getInstance().inject(this);


    initView();
    initData();
    initStatusView();
    initToast();
    initProgressBar();
    return mDataBinding.getRoot();
}

/**
 * 初始化加载样式
 */
private void initProgressBar() {
    mProgressBar = new ProgressBar(getContext());
    ConstraintLayout.LayoutParams layoutParams = new ConstraintLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
    layoutParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
    layoutParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID;
    layoutParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
    layoutParams.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID;
    mProgressBar.setLayoutParams(layoutParams);
    mProgressBar.setVisibility(View.GONE);//默认不可见
    ConstraintLayout constraintLayout = (ConstraintLayout) mDataBinding.getRoot();
    constraintLayout.addView(mProgressBar);
}

private void initToast() {
    if (mViewModel!=null){
        mViewModel.getToastText().observe(getViewLifecycleOwner(), text -> {
            Toast.makeText(getContext(), text, Toast.LENGTH_SHORT).show();
        });

        //控制加载样式是否显示
        mViewModel.getShowLoading().observe(getViewLifecycleOwner(), show -> {
            mProgressBar.setVisibility(show ? View.VISIBLE : View.GONE);
        });
    }
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/BaseFragment.java

这样一来,只要业务层在发起请求前后调用 showLoading(true/false),页面就能自动接通加载反馈。

登录页在发送验证码时已经开始使用这套能力:

重新运行后,提示弹窗和加载样式就都能按预期出现:

10. 接入验证码发送、手机号登录与用户信息请求

前面的页面和交互逻辑把状态准备好了,接下来真正把接口接进来。这里按 Service -> 实体类 -> Model -> ViewModel 的顺序落地,这样每一层的职责会比较清楚。

10.1 定义 user 模块接口

feature_user.api 中先声明发送验证码和手机号登录两个接口:

java 复制代码
/**
 * 这里存放user模块的api
 */
public interface UserApiService {

    /**
     * 请求获取验证码
     *
     * @param code 请求体
     * @return
     */
    @POST("addons/cms/api.sms/send")
    Call<ResBase<ResBase>> sendSmsCode(@Body ReqSendSmsCode code);

    /**
     * 通过手机号登录
     *
     * @param login 请求体
     * @return
     */
    @POST("addons/cms/api.login/mobilelogin")
    Call<ResBase<ResLogin>> mobileLogin(@Body ReqMobileLogin login);

}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/api/UserApiService.java

这两个方法分别对应验证码下发和手机号登录。返回值之所以写成 Call<ResBase<...>>,是因为项目里统一用了一个 ResBase 包裹服务端响应。

接口实例统一通过 Provider 获取,避免业务层到处直接拼 Retrofit

java 复制代码
public class UserApiServiceProvider {

    private static UserApiService mApiService;

    //单例
    public static UserApiService getApiService() {
        if (mApiService == null) {
            Retrofit retrofit = RetrofitProvider.provide();
            mApiService = retrofit.create(UserApiService.class);
        }
        return mApiService;
    }
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/api/UserApiServiceProvider.java

10.2 声明请求体与登录响应体

发送验证码请求体先定义手机号和事件名:

java 复制代码
/**
 * 发起获取验证码请求的请求体
 */
public class ReqSendSmsCode {

    private String mobile;//手机号
    private String event;//事件名称  一般使用mobilelogin就可以

    public ReqSendSmsCode(String mobile, String event) {
        this.mobile = mobile;
        this.event = event;
    }
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/bean/ReqSendSmsCode.java

手机号登录请求体则对应手机号和验证码:

java 复制代码
public class ReqMobileLogin {
    private String mobile;//手机号
    private String captcha;//验证码

    public ReqMobileLogin(String mobile, String captcha) {
        this.mobile = mobile;
        this.captcha = captcha;
    }
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/bean/ReqMobileLogin.java

登录成功后返回的响应体单独建一个 ResLogin,重点保留 tokenid

java 复制代码
public class ResLogin {


    /**
     * token : 28876270-8f8c-4372-8b28-d7c55c0a4b94
     * id : 11
     */

    private String token;//用户身份标识

    private int id;//当前登录的用户id

    // get、set
  
    @Override
    public String toString() {
        return "ResLogin{" +
                "token='" + token + '\'' +
                ", id=" + id +
                '}';
    }
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/bean/ResLogin.java

10.3 在 Model 层封装三个网络调用

Model 层不关心页面如何展示,只负责把请求发出去,再把结果通过回调抛回给外层:

  • 发送验证码请求,使用 Call<ResBase<ResBase>>,因为这个接口返回的响应 data 为 null
  • 手机号登录接口,使用 Call<ResBase<ResLogin>>
  • 发送请求,在 ApiCall.ApiCallback<ResBase<ResBase>> 回调中分别将成功、失败的结果返回给外界;
java 复制代码
public class LoginModel {

    /**
     * 发送验证码
     *
     * @param mobile   手机号
     * @param callback 回调
     */
    public void sendSmsCode(String mobile, IRequestCallback<ResBase<ResBase>> callback) {

        ReqSendSmsCode smsCode = new ReqSendSmsCode(mobile, "mobilelogin");
        Call<ResBase<ResBase>> call = UserApiServiceProvider.getApiService().sendSmsCode(smsCode);
        ApiCall.enqueue(call, new ApiCall.ApiCallback<ResBase<ResBase>>() {
            @Override
            public void onSuccess(ResBase<ResBase> result) {
                callback.onLoadFinish(result);
            }

            @Override
            public void onError(int errorCode, String meesage) {
                callback.onLoadFailure(errorCode, meesage);
            }
        });

    }

    /**
     * 手机号登录
     *
     * @param mobile   手机号
     * @param code     验证码
     * @param callback 回调
     */
    public void mobileLogin(String mobile, String code, IRequestCallback<ResBase<ResLogin>> callback) {

        ReqMobileLogin login = new ReqMobileLogin(mobile, code);
        Call<ResBase<ResLogin>> call = UserApiServiceProvider.getApiService().mobileLogin(login);
        ApiCall.enqueue(call, new ApiCall.ApiCallback<ResBase<ResLogin>>() {
            @Override
            public void onSuccess(ResBase<ResLogin> result) {
                callback.onLoadFinish(result);
            }

            @Override
            public void onError(int errorCode, String meesage) {
                callback.onLoadFailure(errorCode, meesage);
            }
        });

    }
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginModel.java

这里要注意两件事:

  1. 发送验证码接口返回的是 Call<ResBase<ResBase>>,因为这个接口的 data 本身就是空的。
  2. 登录接口返回的是 Call<ResBase<ResLogin>>,因为后面还要继续拿 tokenid

10.4 在 ViewModel 层串起验证码、登录与用户信息加载

LoginViewModel 构造时先持有 LoginModel

java 复制代码
public LoginViewModel() {

    mModel = new LoginModel();
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginViewModel.java

发送验证码时,ViewModel 负责打开加载状态,并把回调结果转成统一的提示反馈:

  • 在发送请求前,调用 BaseViewModel 的 showLoading(true),显示加载 UI;
  • 在完成发送请求后,在回调方法中设置 showLoading(false) 关闭加载 UI;
  • 在请求回调中,调用 BaseViewModel 的 showToast(datas.getMsg()) 方法,将响应体 Msg 作为提示弹出弹窗
java 复制代码
/**
 * 发送验证码
 */
public void sendCode() {

    // ....

    mDownTimer = new CountDownTimer(60000, 1000) {

        @Override
        public void onTick(long millisUntilFinished) {
            //每1秒会被触发 把毫秒转为秒
            int seconds = (int) (millisUntilFinished / 1000);
            mGetVerticalCodeText.setValue(seconds + "s");//更新倒计时的显示
        }

        @Override
        public void onFinish() {
            //倒计时完成后
            mGetVerticalCodeText.setValue("获取验证码");
            //60s后允许发送验证码
            mIsEnableSendCode.setValue(true);
        }
    }.start();

    //发起请求,让服务端发送验证码
    Log.i(TAG, "sendCode: ");
    showLoading(true);
    //发起获取验证码请求
    mModel.sendSmsCode(mobile, new IRequestCallback<ResBase<ResBase>>() {
        @Override
        public void onLoadFinish(ResBase<ResBase> datas) {
            showToast(datas.getMsg());//显示消息弹窗
            showLoading(false);
        }

        @Override
        public void onLoadFailure(int errorCode, String message) {
            showToast(message);//显示消息弹窗
            showLoading(false);
        }
    });
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginViewModel.java

注意,IRequestCallback<T> 接口的 onLoadFailure 新增了 message 参数,在项目调用该方法的位置加上 message 参数:

java 复制代码
/**
 * model和ViewModel通讯的接口回调
 *
 * @param <T>
 */
public interface IRequestCallback<T> {

    void onLoadFinish(T datas);

    void onLoadFailure(int errorCode, String message);
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/IRequestCallback.java

登录逻辑接入后,协议校验、加载控制和结果提示都会放在同一个闭环里处理:

  • 因为在 LoginViewModel 的 updateEnableLoginBtnStatus() 方法,已经对 mobile、code 的长度进行校验,符合才可以将登录按钮设置为可点击,所以这里不需要再次校验 mobile、code 的长度;
  • ResLogin 重写了 toString 方法,因此可以直接使用 datas.getData() 打印响应体字段信息;
java 复制代码
/**
 * 登录
 */
public void login() {
    Boolean checkAgreement = mCheckAgreement.getValue();
    if (!checkAgreement) {
        showToast("请先同意用户协议与隐私政策");
        Log.i(TAG, "请先同意用户协议与隐私政策");
        return;
    }
    showLoading(true);

    String mobile = mUserMobile.getValue();
    String code = mCode.getValue();

    mModel.mobileLogin(mobile, code, new IRequestCallback<ResBase<ResLogin>>() {
        @Override
        public void onLoadFinish(ResBase<ResLogin> datas) {
            Log.i(TAG, "onLoadFinish token:" + datas.getData());
            showLoading(false);
            showToast(datas.getMsg());
        }

        @Override
        public void onLoadFailure(int errorCode, String message) {
            showToast(message);
            showLoading(false);
        }
    });
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginViewModel.java

11. 用 SpannableString 和 WebView 补齐用户协议与隐私政策入口

登录页里"用户协议"和"隐私政策"不是普通文案,而是要同时具备加粗、可点击和跳转能力。因此这部分既要处理文本样式,也要补一个实际落地的协议页。

最终协议文本效果如下:

文本样式通过 SpannableString + ClickableSpan 完成,在 LoginActivity 中:

  • 通过 SpannableString 对象设置一段文本中,部分内容的样式;
  • 通过 ClickableSpan 对象,重写 onClick () 设置文本点击逻辑,重写 updateDrawState() 设置可点击文本 UI;
  • 将 ClickableSpan、文本的具体区间、区间的开闭策略传入 SpannableString,最终实现 UI 效果;
java 复制代码
@Override
protected void initView() {
    //在这里做一些布局、ui的初始化

    //....

    initAgreementText();
}

private void initAgreementText() {
    String string = "请阅读并同意《用户协议》和《隐私政策》";
    //借助SpannableString包装处理字符串内容
    SpannableString spannableString = new SpannableString(string);
    ClickableSpan clickableSpan1 = new ClickableSpan() {
        @Override
        public void onClick(@NonNull View widget) {
            ARouter.getInstance().build(ARouterPath.User.ACTIVITY_AGREEMENT).navigation();
        }

        @Override
        public void updateDrawState(@NonNull TextPaint ds) {
            super.updateDrawState(ds);

            ds.setColor(Color.BLACK);
//                ds.setUnderlineText(false);
            ds.setTypeface(Typeface.DEFAULT_BOLD);

        }
    };

    ClickableSpan clickableSpan2 = new ClickableSpan() {
        @Override
        public void onClick(@NonNull View widget) {
            ARouter.getInstance().build(ARouterPath.User.ACTIVITY_AGREEMENT).navigation();
        }

        @Override
        public void updateDrawState(@NonNull TextPaint ds) {
            super.updateDrawState(ds);

            ds.setColor(Color.BLACK);
//                ds.setUnderlineText(false);
            ds.setTypeface(Typeface.DEFAULT_BOLD);

        }
    };

    spannableString.setSpan(clickableSpan1, 6, 12, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    spannableString.setSpan(clickableSpan2, 14, 19, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
    mDataBinding.cbAgreen.setText(spannableString);
    mDataBinding.cbAgreen.setMovementMethod(LinkMovementMethod.getInstance());
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginActivity.java

这里的处理顺序是固定的:

  1. 先用 SpannableString 包住整段文本。
  2. 再分别创建两个 ClickableSpan,给协议和隐私政策各自绑定点击逻辑。
  3. updateDrawState() 中设置加粗和颜色,让这两段文本从普通说明文字里被视觉区分出来。
  4. 最后把 LinkMovementMethod 挂到控件上,否则点击事件不会生效。

跳转路径统一放在 ARouterPath 中管理:

java 复制代码
public class ARouterPath {

    /**
     * 对应app主模块
     */
    public static class Main {

        private static final String MAIN = "/main";

        //对应MainActivity的路径
        public static final String ACTIVITY_MAIN = MAIN + "/mainActivity";
    }

    public static class Home {

        private static final String HOME = "/home";
        public static final String FRAGMENT_HOME = HOME + "/homeFragment";
        public static final String FRAGMENT_VIDEO_LIST = HOME + "/videoListFragment";
    }

    public static class Plaza {

        private static final String PLAZA = "/plaza";
        public static final String FRAGMENT_PLAZA = PLAZA + "/plazaFragment";
    }

    public static class Find {

        private static final String FIND = "/find";
        public static final String FRAGMENT_FIND = FIND + "/findFragment";
    }

    public static class User {

        private static final String USER = "/user";
        public static final String FRAGMENT_USER = USER + "/userFragment";

        public static final String ACTIVITY_LOGIN = USER + "/loginActivity";
        public static final String ACTIVITY_AGREEMENT = USER + "/AgreementActivity";
    }


}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/config/ARouterPath.java

协议页本身用 WebView 承载即可:

xml 复制代码
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <data>

    </data>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/main"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/white"
        tools:context=".ui.agreement.AgreementActivity">

        <WebView
            android:id="@+id/web_view"
            android:layout_width="match_parent"
            android:layout_height="match_parent" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</layout>

项目路径:LsxbugVideo/feature_user/src/main/res/layout/activity_agreement.xml

AgreementActivity 则负责沉浸式适配、打开加载状态、监听网页加载完成并关闭加载状态:

  • 对于简单的页面,不需要复杂的 ViewModel,直接使用 BaseViewModel 即可;
  • 在 initData 中,加载一个特定 URL 为网页 mDataBinding.webView.setWebViewClient;
  • 在加载前,使用 BaseViewModel 的 showLoading 显示加载 UI,重写 WebViewClient 的接口回调方法 onPageFinished,在加载完成后会触发,此时将加载 UI 关闭;
  • webView.loadUrl(URL) 加载一段 URL
  • 在 initView 中,将页面修改为沉浸式效果,结合 XML 的 android:background="@color/white" 可以达到一个不错的显示效果;
java 复制代码
@Route(path = ARouterPath.User.ACTIVITY_AGREEMENT)
public class AgreementActivity extends BaseActivity<ActivityAgreementBinding, BaseViewModel> {

    private final String URL = "https://titok.fzqq.fun/agreement.html";


    @Override
    protected BaseViewModel getViewModel() {
        return new ViewModelProvider(this).get(BaseViewModel.class);
    }

    @Override
    protected int getLayoutResId() {
        return R.layout.activity_agreement;
    }

    @Override
    protected int getBindingVariableId() {
        return 0;
    }

    @Override
    protected void initView() {
        StatusBarUtils.addStatusBarHeight2RootView(mDataBinding.getRoot());
    }

    @Override
    protected void initData() {
        mViewModel.showLoading(true);

        mDataBinding.webView.setWebViewClient(new WebViewClient() {

            @Override
            public void onPageFinished(WebView view, String url) {
                super.onPageFinished(view, url);
                //网页加载结束  会触发这个方法
                mViewModel.showLoading(false);
            }
        });
        mDataBinding.webView.loadUrl(URL);

    }
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/agreement/AgreementActivity.java

当前协议页和隐私政策页都先复用同一个地址,后续只需要在跳转时带参数,就能切到不同 URL。

同时,在 LoginActivity 中,右上角的按钮也表示隐私政策,设置跳转;以及左上角的返回按钮,为关闭页面,退出登录:

java 复制代码
@Override
    protected void initView() {
        //在这里做一些布局、ui的初始化

        // ...

        initAgreementText();


        mDataBinding.ivBack.setOnClickListener(v -> {
            finish();
        });
        mDataBinding.ivQualifications.setOnClickListener(v -> {
            ARouter.getInstance().build(ARouterPath.User.ACTIVITY_AGREEMENT).navigation();
        });
    }

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginActivity.java

12. 在登录成功后继续拉取用户详情

登录接口返回的 tokenid 只解决了认证问题,页面要真正显示头像、昵称、签名和统计数据,还得再发一次用户详情请求。

12.1 明确用户信息接口的输入与输出

登录成功以后,控制台里已经能看到 token 和用户 id

接下来要基于这些信息继续请求用户详情:

服务端返回结果如下:

12.2 定义跨模块复用的用户实体

用户详情不是登录页专用数据,所以实体类要放到 libbase.bean 这种公共位置,方便多个模块复用。

java 复制代码
public class ResUser {

    private UserInfo user;//其他用户信息
    private int fans;//粉丝数
    private int follow;//关注数
    private int medal;//奖牌数
  	
  	// get、set
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/bean/ResUser.java

user 字段本身再单独拆成一个 UserInfo

java 复制代码
public class UserInfo {

    private String id; //用户id
    private String nickname;//昵称
    private String bio;//签名
    private String avatar;//头像
    private String status;//状态
    private String username;//用户名
  
  	// get、set
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/bean/UserInfo.java

这样的拆分能把"用户基础信息"和"用户统计信息"分成两层,后面做本地持久化时也更容易逐字段存取。

12.3 扩展 UserApiService 获取用户信息

接口层继续往 UserApiService 里补第三个方法:

java 复制代码
/**
 * 这里存放user模块的api
 */
public interface UserApiService {

    // ..... 

    /**
     * 获取用户信息
     *
     * @return
     */
    @GET("addons/cms/api.user/userInfo")
    Call<ResBase<ResUser>> getUserInfo(@Query("user_id") String userId, @Query("type") String type);

}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/api/UserApiService.java

这里把 user_idtype 都声明成查询参数,后续调用时直接传入当前登录用户 id 即可。

12.4 在 Model 层处理空数据与异常分支

Model 层需要把"请求成功但 data 为空"的情况也当成失败处理,否则页面层只知道请求结束,却不知道用户信息其实没有取回来。

在 LoginModel 中,获取用户信息:

  • 使用 UserApiServiceProvider 封装好的方法,调用 getUserInfo 接口,传入用户 id、type,得到请求对象 Call<ResBase<ResUser>>
  • 通过 ApiCall ,发起异步请求,获取用户信息;
  • 在 onSuccess 中,获取响应结果 data,转为 ResUser,如果 ResUser 对象不为空,则通过回调,将结果返回给外界;
  • 如果 ResUser 对象为空,或者请求失败,则调用失败回调,将错误码和错误信息返回给外界;
java 复制代码
public class LoginModel {

    // .....

    /**
     * 获取用户信息
     *
     * @param userId   userid
     * @param callback
     */
    public void getUserInfo(String userId, IRequestCallback<ResBase<ResUser>> callback) {

        Call<ResBase<ResUser>> call = UserApiServiceProvider.getApiService().getUserInfo(userId, "archives");

        ApiCall.enqueue(call, new ApiCall.ApiCallback<ResBase<ResUser>>() {
            @Override
            public void onSuccess(ResBase<ResUser> result) {

                ResUser resUser = result.getData();
                if (resUser != null) {
                    callback.onLoadFinish(result);
                } else {
                    callback.onLoadFailure(ErrorStatusConfig.ERROR_STATUS_SERVER_ERROR, "用户信息获取失败!");
                }
            }

            @Override
            public void onError(int errorCode, String meesage) {
                callback.onLoadFailure(errorCode, meesage);
            }
        });
    }
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginModel.java

这里的关键分支有两个:

  1. result.getData() 不为空,说明服务端返回了可用用户信息,继续走成功回调。
  2. data 为空或者请求失败,都统一走失败回调,并把错误码和错误文案一起抛出去。

12.5 在 ViewModel 中串联登录成功后的详情拉取

登录成功后,ViewModel 不能只停在 showToast(datas.getMsg()),还要立刻继续发详情请求:

java 复制代码
public void login() {
    Boolean checkAgreement = mCheckAgreement.getValue();
    if (!checkAgreement) {
        showToast("请先同意用户协议与隐私政策");
        Log.i(TAG, "请先同意用户协议与隐私政策");
        return;
    }
    showLoading(true);

    String mobile = mUserMobile.getValue();
    String code = mCode.getValue();

    mModel.mobileLogin(mobile, code, new IRequestCallback<ResBase<ResLogin>>() {
        @Override
        public void onLoadFinish(ResBase<ResLogin> datas) {
            Log.i(TAG, "onLoadFinish token:" + datas.getData());
            showLoading(false);
            showToast(datas.getMsg());
						// 获取用户信息
            int id = datas.getData().getId();
            getUserInfo(id);
        }

        @Override
        public void onLoadFailure(int errorCode, String message) {
            showToast(message);
            showLoading(false);
        }
    });
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginViewModel.java

详情请求本身单独拆成一个方法,继续沿用加载状态和错误提示:

java 复制代码
private void getUserInfo(int id) {
    showLoading(true);
    mModel.getUserInfo(String.valueOf(id), new IRequestCallback<ResBase<ResUser>>() {
        @Override
        public void onLoadFinish(ResBase<ResUser> datas) {
            showLoading(false);
        }

        @Override
        public void onLoadFailure(int errorCode, String message) {
            showLoading(false);
            showToast(message);
        }
    });

}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginViewModel.java

用断点检查这一步最直观,登录成功以后如果能顺着进入 getUserInfo(),说明整条"登录 -> 详情加载"链路已经串通:

13. 设计登录态落地方案:持久化用户信息并回推页面状态

到这里,登录页已经能拿到用户详情,但页面间还没有共享登录状态。用户中心、视频点赞、评论发布这些功能都需要知道当前是否已经登录,所以接下来要补两件事:

  • 用户信息持久化存储;
  • 通知其他页面,用户的登录状态;

13.1 使用单例封装用户信息管理入口

在 libbase.manager 中,创建用户管理类 UserManager

  • 使用懒汉式单例模式管理 UserManager,目的是保证整个应用进程中通常只维护一个 UserManager 实例。
  • 这样在首页、登录页、我的页面等多个地方需要访问用户信息时,都不需要反复 new UserManager(),而是统一通过 getInstance() 获取同一个对象。
  • 并且懒加载方式只有在第一次真正使用时才初始化实例,避免对象过早创建,提高资源利用率。
  • synchronized (UserManager.class) 的作用是加锁 ,保证同一时刻只有一个线程能够进入这段创建实例的代码。这样在多线程环境下,即使多个页面或多个线程同时第一次调用 getInstance(),也不会重复创建多个 UserManager 对象。
  • 这里锁的是 UserManager.class,因为单例的创建逻辑是类级别共享的,所有线程访问的都是同一个类对象锁。
  • 外层的 if (instance == null) 是为了减少不必要的加锁,只有在实例还没创建时才进入同步代码块;内层再判断一次 if (instance == null),是为了防止多个线程排队进入时重复创建对象,这种写法就是双重校验锁。
java 复制代码
/**
 * 用户管理类
 */
public class UserManager {

    private static volatile UserManager instance;

    private UserManager() {

    }

    public static UserManager getInstance() {
        if (instance == null) {
            synchronized (UserManager.class) {
                if (instance == null) {
                    instance = new UserManager();
                }
            }
        }
        return instance;
    }
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/manager/UserManager.java

UserManager 使用懒汉式单例模式进行管理,目的是保证整个应用进程中通常只存在一个 UserManager 实例。

这样在视频点赞评论页、登录页、用户页面等多个地方需要访问用户信息时,都不需要反复创建对象,而是统一通过 getInstance() 获取同一个实例。

懒加载的好处是只有在第一次真正使用时才会初始化对象,避免过早创建,提升资源利用率。

与此同时,代码中的 synchronized (UserManager.class) 用来保证多线程场景下的线程安全,防止多个线程同时进入实例创建逻辑而生成多个对象;配合双重判空,可以在保证安全的同时减少频繁加锁带来的性能

我们还需要完善 UserManager 的功能,需要实现:

  • 保存、获取 token
  • 保存、获取 UserInfo
  • 判断是否登录、退出登录(清除登录态)
java 复制代码
/**
 * 用户管理类
 */
public class UserManager {

    private static volatile UserManager instance;

    private UserManager() {

    }

    public static UserManager getInstance() {
        if (instance == null) {
            synchronized (UserManager.class) {
                if (instance == null) {
                    instance = new UserManager();
                }
            }
        }
        return instance;
    }

    public void saveToken(String token) {
    }

    public void getToken() {
    }

    public void saveUserInfo(ResUser user) {
    }

    public void getUserInfo() {
    }

    /**
     * 退出登录,清除用户信息
     */
    public void logout() {
    }

    /**
     * @return 是否登录
     */
    public boolean isLogin() {
        return false;
    }
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/manager/UserManager.java

13.2 使用 EncryptedSharedPreferences 加密保存 Token

如果把 token 明文扔进普通 SharedPreferences,被反编译或抓取之后很容易被滥用,所以存储前要先引入 Jetpack 的加密能力。

先在版本文件中补依赖版本:

toml 复制代码
[versions]
security = "1.0.0"

[libraries]
#一些android官方库
security = { group = "androidx.security", name = "security-crypto", version.ref = "security" }
#一些第三方库

项目路径:LsxbugVideo/gradle/libs.versions.toml

然后在 UserManager 的构造阶段生成或读取主密钥,并初始化加密版 SharedPreferences

  • 新增上下文参数到构造方法中,同时增加 getInstance(Context context) 的方法参数;
  • 生成或者是获取到一个AES256数字签名算法秘钥,并且对 SharedPreferences 的 key、value 指定加密方式;
java 复制代码
private static final String PREFS_NAME = "user_prefs";
private SharedPreferences mPreferences;

private UserManager(Context context) {
    // 生成或者是获取到一个 AES256 数字签名算法秘钥
    try {
        String masterAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
        mPreferences = EncryptedSharedPreferences.create(
                "user_prefs",
                masterAlias,
                context,
                EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, // key 的加密模式
                EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM // value 的加密模式
        );
    } catch (GeneralSecurityException e) {
        throw new RuntimeException(e);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

public static UserManager getInstance(Context context) {
    if (instance == null) {
        synchronized (UserManager.class) {
            if (instance == null) {
                instance = new UserManager(context);
            }
        }
    }
    return instance;
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/manager/UserManager.java

保存 token 到 SharedPreferences,获取时将对应的 key 传入即可;

同时,判断登录态,可以通过判断 token 是否为空来处理;

java 复制代码
private static final String KEY_TOKEN = "key_token";

/**
 * 保存token
 *
 * @param token
 */

public void saveToken(String token) {
    mPreferences.edit().putString(KEY_TOKEN, token).apply();
}

/**
 * 获取token
 *
 * @return
 */
public String getToken() {
    String token = mPreferences.getString(KEY_TOKEN, null);
    return token;
}

/**
 * @return 是否登录
 */
public boolean isLogin() {
    String token = getToken();
    boolean isLogin = token != null && !token.isEmpty();
    return isLogin;
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/manager/UserManager.java

Model 在拿到登录结果后,就可以顺手把 token 落地:

但是 model 拿不到 Activity、Fragment 的上下文,因此还需要再调整 UserManager 的构造方法;

我们可以以 BaseApplication 作为上下文,并且 BaseApplication 本身也是一个全局上下文,app 一启动,该上下文自动创建,并且上下文生命周期跟随 app 进程;

将 BaseApplication 对应的上下文作为 UserManager 所需的加密上下文参数,就不需要调用方传上下文:

在 BaseApplication 中:

  • 指定 Application 类型的对象 instance,在创建 BaseApplication 时,制定 instance 为 BaseApplication;
  • 实现 getContext() 方法,将 instance 中的 BaseApplication 作为 Context 对象进行返回;
java 复制代码
/**
 * 当前工程中的Application基类
 */
public class BaseApplication extends Application {

    private static Application instance;

    @Override
    public void onCreate() {
        super.onCreate();

        instance = this;

        //在调试模式下
        if (BuildConfig.DEBUG) {
            ARouter.openLog();
            ARouter.openDebug();
        }

        //初始化ARouter
        ARouter.init(this);

        //AndroidAutoSize的参数初始化
        AutoSizeConfig.getInstance().setCustomFragment(true);
    }

    /**
     * 使用Application生成了一个全局可用的context
     * 注意不要滥用,否则会产生下面的问题
     * 1、把Application当成是某个Activity上下文,与ui更新关联在一起,会引发错误
     * 2、更容易获取到context,会增加项目耦合性
     *
     * @return
     */
    public static Context getContext() {
        return instance.getApplicationContext();
    }
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/BaseApplication.java

这样 UserManager 就不必再要求外部传上下文了,将 BaseApplication.getContext() 获取到的上下文,作为 UserManager 构造方法中,SharedPreferences 所需的加密上下文参数,就不需要调用方传上下文:

java 复制代码
private UserManager() {
    //生成或者是获取到一个AES256数字签名算法秘钥
    try {
        String masterAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
        mPreferences = EncryptedSharedPreferences.create(
                PREFS_NAME, masterAlias, BaseApplication.getContext(),
                EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,//key的加密模式
                EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);//value的加密模式

    } catch (GeneralSecurityException e) {
        throw new RuntimeException(e);
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

/**
 * 单例模式
 *
 * @return
 */
public static UserManager getInstance() {

    if (instance == null) {
        synchronized (UserManager.class) {
            if (instance == null) {
                instance = new UserManager();
            }
        }
    }
    return instance;
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/manager/UserManager.java

14. 补齐用户资料缓存、退出登录与本地读取能力

只保存 token 还不够,用户中心页真正显示的数据还包括头像、昵称、签名、粉丝数、关注数和勋章数。因此 UserManager 还要继续承担用户资料缓存和退出登录清理的职责。

首先把所有字段对应的 key 固定下来:

java 复制代码
private static final String PREFS_NAME = "user_prefs";
private static final String KEY_TOKEN = "key_token";
private static final String KEY_USER_ID = "key_user_id";
private static final String KEY_NICK_NAME = "key_nick_name";
private static final String KEY_USER_NAME = "key_user_name";
private static final String KEY_BIO = "key_bio";
private static final String KEY_AVATAR = "key_avatar";
private static final String KEY_STATUS = "key_status";
private static final String KEY_FOLLOW = "key_follow";
private static final String KEY_FANS = "key_fans";
private static final String KEY_MEDAL = "key_medal";

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/manager/UserManager.java

保存用户信息时,直接把公共实体拆字段写入:

  • edit() 表示编辑 SharedPreferences
  • putString() 表示指定存储的键值对
  • apply() 表示保存当前 SharedPreferences 信息
java 复制代码
/**
 * 保存用户信息
 *
 * @param user
 */

public void saveUserInfo(ResUser user) {
    UserInfo userInfo = user.getUser();
    mPreferences.edit()
            .putString(KEY_USER_ID, userInfo.getId())
            .putString(KEY_NICK_NAME, userInfo.getNickname())
            .putString(KEY_USER_NAME, userInfo.getUsername())
            .putString(KEY_AVATAR, userInfo.getAvatar())
            .putString(KEY_BIO, userInfo.getBio())
            .putString(KEY_STATUS, userInfo.getStatus())
            .putInt(KEY_FANS, user.getFans())
            .putInt(KEY_FOLLOW, user.getFollow())
            .putInt(KEY_MEDAL, user.getMedal())
            .apply();
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/manager/UserManager.java

读取时则反向组装成 ResUser,这样页面层依旧可以直接面向实体对象编程:

java 复制代码
/**
 * 获取用户信息
 */
public ResUser getUserInfo() {
    String userId = mPreferences.getString(KEY_USER_ID, null);
    String nickName = mPreferences.getString(KEY_NICK_NAME, null);
    String userName = mPreferences.getString(KEY_USER_NAME, null);
    String bio = mPreferences.getString(KEY_BIO, null);
    String avatar = mPreferences.getString(KEY_AVATAR, null);
    String status = mPreferences.getString(KEY_STATUS, null);
    int fans = mPreferences.getInt(KEY_FANS, 0);
    int follow = mPreferences.getInt(KEY_FOLLOW, 0);
    int medal = mPreferences.getInt(KEY_MEDAL, 0);

    ResUser user = new ResUser();
    user.setFans(fans);
    user.setFollow(follow);
    user.setMedal(medal);

    UserInfo userInfo = new UserInfo();
    userInfo.setId(userId);
    userInfo.setNickname(nickName);
    userInfo.setUsername(userName);
    userInfo.setBio(bio);
    userInfo.setAvatar(avatar);
    userInfo.setStatus(status);
    user.setUser(userInfo);

    return user;
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/manager/UserManager.java

退出登录时再把这些字段一次性清掉:

java 复制代码
/**
 * 退出登录 清除用户信息
 */
public void logout() {
    mPreferences.edit()
            .remove(KEY_TOKEN)
            .remove(KEY_USER_ID)
            .remove(KEY_NICK_NAME)
            .remove(KEY_USER_NAME)
            .remove(KEY_AVATAR)
            .remove(KEY_BIO)
            .remove(KEY_STATUS)
            .remove(KEY_FANS)
            .remove(KEY_FOLLOW)
            .remove(KEY_MEDAL).apply();
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/manager/UserManager.java

模型层最终会在两个时机写入本地数据:

  1. 在 LoginModel 中,使用手机号登录接口,保存用户对应 token;
  2. 获取用户信息接口,将用户信息相关的所有信息,通过实体类对象保存到 SharedPreferences:

对应代码如下:

java 复制代码
/**
 * 手机号登录
 *
 * @param mobile   手机号
 * @param code     验证码
 * @param callback 回调
 */
public void mobileLogin(String mobile, String code, IRequestCallback<ResBase<ResLogin>> callback) {

    ReqMobileLogin login = new ReqMobileLogin(mobile, code);
    Call<ResBase<ResLogin>> call = UserApiServiceProvider.getApiService().mobileLogin(login);
    ApiCall.enqueue(call, new ApiCall.ApiCallback<ResBase<ResLogin>>() {
        @Override
        public void onSuccess(ResBase<ResLogin> result) {
            callback.onLoadFinish(result);

            //token存储放在model中,因为model专门处理数据
            String token = result.getData().getToken();
            UserManager userManager = UserManager.getInstance();
            userManager.saveToken(token);
        }

        @Override
        public void onError(int errorCode, String meesage) {
            callback.onLoadFailure(errorCode, meesage);
        }
    });

}

/**
 * 获取用户信息
 *
 * @param userId   userid
 * @param callback
 */
public void getUserInfo(String userId, IRequestCallback<ResBase<ResUser>> callback) {

    Call<ResBase<ResUser>> call = UserApiServiceProvider.getApiService().getUserInfo(userId, "archives");

    ApiCall.enqueue(call, new ApiCall.ApiCallback<ResBase<ResUser>>() {
        @Override
        public void onSuccess(ResBase<ResUser> result) {

            ResUser resUser = result.getData();
            if (resUser != null) {
                //存储用户信息
                UserManager.getInstance().saveUserInfo(result.getData());
                callback.onLoadFinish(result);
            } else {
                callback.onLoadFailure(ErrorStatusConfig.ERROR_STATUS_SERVER_ERROR, "用户信息获取失败!");
            }
        }

        @Override
        public void onError(int errorCode, String meesage) {
            callback.onLoadFailure(errorCode, meesage);
        }
    });
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginModel.java

用户数据虽然已经能写到本地,但登录成功之后,用户中心页还不知道"现在该刷新自己了"。所以最后还需要一层跨页面消息同步机制。

15. 用 EventBus 把登录成功事件回推到用户页

登录页完成后自动关闭,用户中心页重新露出来时,需要立刻知道当前已经登录,并据此刷新头像、昵称和统计数据。这一类跨页面通知如果还走传统回调,链路会越来越重,所以这里引入 EventBus 做轻量级消息分发。

15.1 先回顾 ActivityResultLauncher 的返回方式

传统页面回传方式通常是 ActivityResultLauncher

java 复制代码
/**
 * 是否拥有安装app的权限
 */
public boolean canInstallPermission() {
    boolean canInstall = true;//默认true表示拥有该权限
    //安卓8.0以上才能使用canRequestPackageInstalls方法判断是否有对应权限
    if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
        canInstall = getPackageManager().canRequestPackageInstalls();
    }

    return canInstall;
}

//注册页面跳转回调函数,用户处理完权限,会触发onActivityResult回调方法
private ActivityResultLauncher<Intent> activityResultLauncher = registerForActivityResult(
        new ActivityResultContracts.StartActivityForResult(),
        new ActivityResultCallback<ActivityResult>() {
            @RequiresApi(api = Build.VERSION_CODES.O)
            @Override
            public void onActivityResult(ActivityResult result) {
                if (result.getResultCode() == Activity.RESULT_CANCELED) {
                    //用户取消操作
                    Toast.makeText(SettingsPremissionActivity.this, "权限授予失败", Toast.LENGTH_SHORT).show();
                } else if (result.getResultCode() == Activity.RESULT_OK) {
                    // 用户已授予安装未知来源应用的权限
                    //再次调用权限判断是否真的已经授权
                    boolean canInstall = canInstallPermission();
                    if (canInstall) {
                        // 处理允许安装未知来源应用的逻辑
                        refreshTextView();
                        Toast.makeText(SettingsPremissionActivity.this, "已获取权限", Toast.LENGTH_SHORT).show();
                    }
                }
            }
        }
);

//跳转到系统设置页面
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES);
intent.setData(Uri.parse("package:" + getPackageName()));
activityResultLauncher.launch(intent);

项目路径:LsxbugVideo/app/src/main/java/com/ls/video/MainActivity.java

这种方式适合单次回传,但对于登录成功后要通知多个页面、并且接收方可能暂时不活跃的场景,就不够灵活了。

15.2 引入 EventBus 并整理基本用法

更适合这类场景的是 Android 里常见的消息总线库 EventBus

先在版本文件中补依赖:

toml 复制代码
[versions]
eventBus = "3.3.1"

[libraries]
eventBus = { group = "org.greenrobot", name = "eventbus", version.ref = "eventBus" }

项目路径:LsxbugVideo/gradle/libs.versions.toml

再把依赖暴露给基础库模块:

javascript 复制代码
api libs.eventBus

项目路径:LsxbugVideo/library_base/build.gradle

EventBus 的基本用法可以概括成四步:

  1. 定义消息事件
java 复制代码
public static class MessageEvent { /* Additional fields if needed */ }
  1. 订阅消息
  • 使用 @Subscribe 注解表示订阅消息
  • 订阅的消息类型,为步骤 1 定义的 MessageEvent ,作为参数
java 复制代码
@Subscribe(threadMode = ThreadMode.MAIN)  
public void onMessageEvent(MessageEvent event) {
    // Do something
}
  1. 注册和取消注册 subscriber,比如在 Android 中,和 Activity、Fragment 的生命周期绑定:
java 复制代码
 @Override
 public void onStart() {
     super.onStart();
     EventBus.getDefault().register(this);
 }

 @Override
 public void onStop() {
     super.onStop();
     EventBus.getDefault().unregister(this);
 }
  1. 发布 events,并且发布的 events 对象类型是 MessageEvent,与步骤 1 中,@Subscribe 注解指定的方法参数类型一致,步骤 1 就能订阅 MessageEvent 类型消息的状态变化,接收到此处发布的 events:
java 复制代码
 EventBus.getDefault().post(new MessageEvent());

15.3 定义登录状态消息体

在当前项目里,最重要的就是"登录状态是否发生变化"这一类消息,所以可以把事件体专门设计成布尔型状态消息;

在 libbase.eventbus 包下,创建 MessageEvent;

定义内部类 LoginStatusEvent,构造方法传入是否登录,并且为外界提供 isLogin() 方法,来判断用户是否登录;

java 复制代码
public class MessageEvent {

    /**
     * 登录状态变更 登录成功、退出
     */
    public static class LoginStatusEvent {
        private boolean isLogin;//是否登录

        public LoginStatusEvent(boolean isLogin) {
            this.isLogin = isLogin;
        }

        public boolean isLogin() {
            return isLogin;
        }

    }
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/eventbus/MessageEvent.java

这样做之后,发布端和订阅端都只需要围绕 isLogin 一件事展开,不会让消息体长成一个职责模糊的大对象。

15.4 在登录成功后发布消息

登录成功以后,事件不是在 mobileLogin() 回调中直接发,而是在用户详情也拉取成功之后再发。原因很简单:只有这一步完成,用户中心页才真正有可展示的数据。

在 LoginViewModel 中:

  • 调用 Model 手机号登录的接口,登录成功,在回调中调用 getUserInfo() 方法,根据用户 id ,获取用户具体信息;
  • getUserInfo() 方法又会去 Model 中,调获取用户信息的接口,在获取成功的回调中,发布一个已登录的状态
  • 定义一个登录状态消息对象 MessageEvent.LoginStatusEvent(true),并且设置为已登录,然后通过 EventBus 发布消息;
java 复制代码
/**
 * 登录
 */
public void login() {
    // ....

    mModel.mobileLogin(mobile, code, new IRequestCallback<ResBase<ResLogin>>() {
        @Override
        public void onLoadFinish(ResBase<ResLogin> datas) {
            // .......

            int id = datas.getData().getId();
            getUserInfo(id);
        }
    });
}

private void getUserInfo(int id) {
    showLoading(true);
    mModel.getUserInfo(String.valueOf(id), new IRequestCallback<ResBase<ResUser>>() {
        @Override
        public void onLoadFinish(ResBase<ResUser> datas) {
            showLoading(false);
            mLoginSuccess.setValue(true);
            //发送一个已登录的状态
            MessageEvent.LoginStatusEvent loginStatusEvent = new MessageEvent.LoginStatusEvent(true);
						EventBus.getDefault().post(loginStatusEvent);
        }

        @Override
        public void onLoadFailure(int errorCode, String message) {
            showLoading(false);
            showToast(message);
        }
    });

}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginViewModel.java

之所以要等到 getUserInfo() 成功后再发,是因为用户中心页刷新时不仅要知道"已经登录",还要能立刻从本地拿到最新缓存的用户信息。

15.5 在 UserFragment 中订阅粘性事件

在 UserFragment 中,页面创建和销毁的同时,注册和反注册 EventBus 对象

java 复制代码
/**
 * 通过eventBus订阅 登录状态变化的消息
 *
 * @param event
 */
@Subscribe
public void onMessageEvent(MessageEvent.LoginStatusEvent event) {
    Log.i(TAG, "onMessageEvent: isLogin = " + event.isLogin());
}

@Override
public void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    EventBus.getDefault().register(this);//注册eventbus
}

@Override
public void onDestroy() {
    super.onDestroy();
    if (EventBus.getDefault().isRegistered(this)) {
        EventBus.getDefault().unregister(this);//注销
    }
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/user/UserFragment.java

但如果把注册和注销放在 onStart()/onStop(),从用户中心跳到登录页时,UserFragment 很快就会因为被覆盖而执行 onStop(),结果就是消息还没发回来,订阅者已经被注销了:

java 复制代码
@Subscribe
public void onMessageEvent(MessageEvent.LoginStatusEvent event) {
    Log.i(TAG, "onMessageEvent: isLogin = " + event.isLogin());
}

@Override
public void onStart() {
    super.onStart();
    EventBus.getDefault().register(this);
}

@Override
public void onStop() {
    super.onStop();
    if (EventBus.getDefault().isRegistered(this)) {
        EventBus.getDefault().unregister(this);//注销
    }
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/user/UserFragment.java

所以这里要么把注销时机延后到 onDestroy(),要么直接使用粘性事件。当前项目最终选择的是第二种方式:发布端改用 postSticky(),订阅端显式声明 sticky = true

java 复制代码
private void getUserInfo(int id) {
    showLoading(true);
    mModel.getUserInfo(String.valueOf(id), new IRequestCallback<ResBase<ResUser>>() {
        @Override
        public void onLoadFinish(ResBase<ResUser> datas) {
            showLoading(false);
            //发送一个已登录的状态
            MessageEvent.LoginStatusEvent loginStatusEvent = new MessageEvent.LoginStatusEvent(true);
            // 发送粘性事件
            EventBus.getDefault().postSticky(loginStatusEvent);
        }

        @Override
        public void onLoadFailure(int errorCode, String message) {
            showLoading(false);
            showToast(message);
        }
    });
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginViewModel.java

订阅端同步改成接收粘性事件:

java 复制代码
/**
 * 通过eventBus订阅 登录状态变化的消息
 *
 * @param event
 */
@Subscribe(sticky = true)//表示接收粘性事件
public void onMessageEvent(MessageEvent.LoginStatusEvent event) {
    Log.i(TAG, "onMessageEvent: isLogin = " + event.isLogin());
    if (event.isLogin()) {

    }
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/user/UserFragment.java

粘性事件的核心价值在于:即便事件发布时 UserFragment 还处于不活跃状态,等它重新回到前台并重新注册时,仍然能把最新一次登录成功消息取回来。

此外,@Subscribe 还可以指定线程模型,决定回调是在主线程还是子线程执行:

改成粘性事件以后,回到用户中心页时就能正确收到登录成功通知:

为了让发布动作更简洁,可以把它再封装回 MessageEvent

java 复制代码
public class MessageEvent {

    /**
     * 登录状态变更 登录成功、退出
     */
    public static class LoginStatusEvent {
        private boolean isLogin;//是否登录

        public LoginStatusEvent(boolean isLogin) {
            this.isLogin = isLogin;
        }

        public boolean isLogin() {
            return isLogin;
        }

        public static void post(boolean isLogin) {
            //使用粘性事件发送消息,确保订阅者在活跃的时候再处理消息
            EventBus.getDefault().postSticky(new LoginStatusEvent(isLogin));
        }

    }
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/eventbus/MessageEvent.java

这样 LoginViewModel 最终只保留一句发布代码即可:

java 复制代码
private void getUserInfo(int id) {
    showLoading(true);
    mModel.getUserInfo(String.valueOf(id), new IRequestCallback<ResBase<ResUser>>() {
        @Override
        public void onLoadFinish(ResBase<ResUser> datas) {
            showLoading(false);
            mLoginSuccess.setValue(true);
            //发送一个已登录的状态
            MessageEvent.LoginStatusEvent.post(true);
        }

        @Override
        public void onLoadFailure(int errorCode, String message) {
            showLoading(false);
            showToast(message);
        }
    });

}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginViewModel.java

16. 小结

这一轮实现真正打通的是一整条用户链路,而不是单个页面。用户中心页先完成静态骨架,再把沉浸式状态栏按页面类型拆成根布局补偿和局部控件补偿;登录页则通过 DataBinding 和 ViewModel 状态联动把按钮可用态、验证码倒计时、协议勾选、请求加载和提示反馈全部接起来。

在网络层之后,真正让这套链路稳定下来的,是 UserManager 的本地加密存储和 EventBus 的登录态同步。前者保证 token 与用户资料可以跨页面、跨生命周期复用,后者保证登录页关闭后,用户中心页能够在重新活跃时立即刷新自己的显示内容。

17. 相关代码附录

17.1 状态栏与页面基类支撑

StatusBarUtils 把沉浸式状态栏、根布局补偿和局部控件补偿收口成一组通用方法:

java 复制代码
public class StatusBarUtils {

    public static void addStatusBarHeight2RootView(View rootView) {
        ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> {
            Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
            return insets;
        });
    }

    public static void addStatusBarHeight2Views(View rootView, View... views) {
        AtomicBoolean isNeedAddHeight = new AtomicBoolean(true);//默认需要添加高度
        ViewCompat.setOnApplyWindowInsetsListener(rootView, (v, insets) -> {

            if (!isNeedAddHeight.get()) {
                return insets;
            }

            Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
            for (int i = 0; i < views.length; i++) {
                View view = views[i];
                ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) view.getLayoutParams();
                int topMargin = layoutParams.topMargin;
                layoutParams.setMargins(0, topMargin + systemBars.top, 0, 0);
                view.setLayoutParams(layoutParams);
            }
            isNeedAddHeight.set(false);
            return insets;
        });
    }

    public static void setImmerseStatusBar(Activity activity) {
        activity.getWindow().setStatusBarColor(Color.TRANSPARENT);

        View decorView = activity.getWindow().getDecorView();
        int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
                View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
                View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
        decorView.setSystemUiVisibility(flags);
    }
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/utils/StatusBarUtils.java

BaseActivity 统一接管沉浸式、Toast 和加载样式:

java 复制代码
public abstract class BaseActivity<V extends ViewDataBinding, VM extends BaseViewModel> extends AppCompatActivity {

    protected VM mViewModel;
    protected V mDataBinding;
    private ProgressBar mProgressBar;

    @Override
    protected void onCreate(@Nullable Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        initViewModel();
        initDatabinding();
        StatusBarUtils.setImmerseStatusBar(this);
        ARouter.getInstance().inject(this);

        initView();
        initData();
        initProgressBar();
    }

    private void initProgressBar() {
        mProgressBar = new ProgressBar(this);
        ConstraintLayout.LayoutParams layoutParams = new ConstraintLayout.LayoutParams(
                ViewGroup.LayoutParams.WRAP_CONTENT,
                ViewGroup.LayoutParams.WRAP_CONTENT
        );
        layoutParams.startToStart = ConstraintLayout.LayoutParams.PARENT_ID;
        layoutParams.topToTop = ConstraintLayout.LayoutParams.PARENT_ID;
        layoutParams.endToEnd = ConstraintLayout.LayoutParams.PARENT_ID;
        layoutParams.bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID;
        mProgressBar.setLayoutParams(layoutParams);
        mProgressBar.setVisibility(View.GONE);
        ConstraintLayout constraintLayout = (ConstraintLayout) mDataBinding.getRoot();
        constraintLayout.addView(mProgressBar);
    }

    private void initViewModel() {
        mViewModel = getViewModel();

        if (mViewModel != null) {
            mViewModel.getToastText().observe(this, text -> {
                Toast.makeText(this, text, Toast.LENGTH_SHORT).show();
            });
            mViewModel.getShowLoading().observe(this, show -> {
                mProgressBar.setVisibility(show ? View.VISIBLE : View.GONE);
            });
        }
    }

    protected abstract VM getViewModel();

    protected abstract int getLayoutResId();

    protected abstract int getBindingVariableId();

    protected abstract void initView();

    protected abstract void initData();
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/base/BaseActivity.java

17.2 登录页 UI 与协议跳转

登录页布局负责把手机号、验证码、协议勾选和按钮状态一次性绑定到 LoginViewModel

xml 复制代码
<EditText
    android:id="@+id/et_user_name"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:background="@null"
    android:text="@={viewModel.userMobile}"
    android:hint="输入手机号登录/注册"
    android:inputType="phone"
    android:maxLength="11" />

<EditText
    android:id="@+id/et_password"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:background="@null"
    android:hint="输入验证码"
    android:inputType="number"
    android:text="@={viewModel.code}"
    android:maxLength="4" />

<TextView
    android:id="@+id/tv_get_code"
    android:layout_width="64dp"
    android:layout_height="16dp"
    android:enabled="@{viewModel.isEnableSendCode}"
    android:background="@drawable/bg_send_sms"
    android:text="@{viewModel.getVerticalCodeText}"
    android:onClick="@{()->viewModel.sendCode()}" />

<CheckBox
    android:id="@+id/cb_agreen"
    android:layout_width="wrap_content"
    android:layout_height="20dp"
    android:checked="@={viewModel.checkAgreement}"
    android:text="请阅读并同意《用户协议》和《隐私政策》" />

<TextView
    android:layout_width="0dp"
    android:layout_height="55dp"
    android:background="@drawable/bg_login_button"
    android:enabled="@{viewModel.isEnableLogin}"
    android:onClick="@{()->viewModel.login()}"
    android:text="登录" />

项目路径:LsxbugVideo/feature_user/src/main/res/layout/activity_login.xml

LoginActivity 负责把按钮偏移、协议文案和登录成功后的关闭动作接好:

java 复制代码
public class LoginActivity extends BaseActivity<ActivityLoginBinding, LoginViewModel> {

    @Override
    protected void initView() {
        StatusBarUtils.addStatusBarHeight2Views(mDataBinding.getRoot(), mDataBinding.ivBack, mDataBinding.ivSettings);

        mViewModel.getUserMobile().observe(this, mobile -> {
            mViewModel.updateEnableLoginBtnStatus();
        });
        mViewModel.getCode().observe(this, code -> {
            mViewModel.updateEnableLoginBtnStatus();
        });

        initAgreementText();

        mDataBinding.ivBack.setOnClickListener(v -> finish());
        mDataBinding.ivQualifications.setOnClickListener(v -> {
            ARouter.getInstance().build(ARouterPath.User.ACTIVITY_AGREEMENT).navigation();
        });

        mViewModel.getLoginSuccess().observe(this, isLoginSuceess -> {
            if (isLoginSuceess) {
                finish();
            }
        });
    }
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginActivity.java

协议页本身只负责加载网页并复用基类的加载反馈:

java 复制代码
public class AgreementActivity extends BaseActivity<ActivityAgreementBinding, BaseViewModel> {

    private final String URL = "https://titok.fzqq.fun/agreement.html";

    @Override
    protected void initView() {
        StatusBarUtils.addStatusBarHeight2RootView(mDataBinding.getRoot());
    }

    @Override
    protected void initData() {
        mViewModel.showLoading(true);

        mDataBinding.webView.setWebViewClient(new WebViewClient() {
            @Override
            public void onPageFinished(WebView view, String url) {
                super.onPageFinished(view, url);
                mViewModel.showLoading(false);
            }
        });
        mDataBinding.webView.loadUrl(URL);
    }
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/agreement/AgreementActivity.java

17.3 网络接口与请求模型

UserApiService 统一声明验证码发送、手机号登录和用户信息获取三个接口:

java 复制代码
public interface UserApiService {

    @POST("addons/cms/api.sms/send")
    Call<ResBase<ResBase>> sendSmsCode(@Body ReqSendSmsCode code);

    @POST("addons/cms/api.login/mobilelogin")
    Call<ResBase<ResLogin>> mobileLogin(@Body ReqMobileLogin login);

    @GET("addons/cms/api.user/userInfo")
    Call<ResBase<ResUser>> getUserInfo(@Query("user_id") String userId, @Query("type") String type);
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/api/UserApiService.java

LoginModel 则把三条网络链路统一封装起来:

java 复制代码
public class LoginModel {

    public void sendSmsCode(String mobile, IRequestCallback<ResBase<ResBase>> callback) {
        ReqSendSmsCode smsCode = new ReqSendSmsCode(mobile, "mobilelogin");
        Call<ResBase<ResBase>> call = UserApiServiceProvider.getApiService().sendSmsCode(smsCode);
        ApiCall.enqueue(call, new ApiCall.ApiCallback<ResBase<ResBase>>() {
            @Override
            public void onSuccess(ResBase<ResBase> result) {
                callback.onLoadFinish(result);
            }

            @Override
            public void onError(int errorCode, String meesage) {
                callback.onLoadFailure(errorCode, meesage);
            }
        });
    }

    public void mobileLogin(String mobile, String code, IRequestCallback<ResBase<ResLogin>> callback) {
        ReqMobileLogin login = new ReqMobileLogin(mobile, code);
        Call<ResBase<ResLogin>> call = UserApiServiceProvider.getApiService().mobileLogin(login);
        ApiCall.enqueue(call, new ApiCall.ApiCallback<ResBase<ResLogin>>() {
            @Override
            public void onSuccess(ResBase<ResLogin> result) {
                callback.onLoadFinish(result);
                UserManager.getInstance().saveToken(result.getData().getToken());
            }

            @Override
            public void onError(int errorCode, String meesage) {
                callback.onLoadFailure(errorCode, meesage);
            }
        });
    }

    public void getUserInfo(String userId, IRequestCallback<ResBase<ResUser>> callback) {
        Call<ResBase<ResUser>> call = UserApiServiceProvider.getApiService().getUserInfo(userId, "archives");

        ApiCall.enqueue(call, new ApiCall.ApiCallback<ResBase<ResUser>>() {
            @Override
            public void onSuccess(ResBase<ResUser> result) {
                ResUser resUser = result.getData();
                if (resUser != null) {
                    UserManager.getInstance().saveUserInfo(result.getData());
                    callback.onLoadFinish(result);
                } else {
                    callback.onLoadFailure(ErrorStatusConfig.ERROR_STATUS_SERVER_ERROR, "用户信息获取失败!");
                }
            }

            @Override
            public void onError(int errorCode, String meesage) {
                callback.onLoadFailure(errorCode, meesage);
            }
        });
    }
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/login/LoginModel.java

17.4 登录态存储与用户缓存

UserManager 统一负责加密存储、登录态判断、用户信息读写和退出登录清理:

java 复制代码
public class UserManager {

    private static final String PREFS_NAME = "user_prefs";
    private static final String KEY_TOKEN = "key_token";
    private static final String KEY_USER_ID = "key_user_id";
    private static final String KEY_NICK_NAME = "key_nick_name";
    private static final String KEY_USER_NAME = "key_user_name";
    private static final String KEY_BIO = "key_bio";
    private static final String KEY_AVATAR = "key_avatar";
    private static final String KEY_STATUS = "key_status";
    private static final String KEY_FOLLOW = "key_follow";
    private static final String KEY_FANS = "key_fans";
    private static final String KEY_MEDAL = "key_medal";
    private static UserManager instance;
    private SharedPreferences mPreferences;

    private UserManager() {
        try {
            String masterAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC);
            mPreferences = EncryptedSharedPreferences.create(
                    PREFS_NAME, masterAlias, BaseApplication.getContext(),
                    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
                    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM);
        } catch (GeneralSecurityException | IOException e) {
            throw new RuntimeException(e);
        }
    }

    public static UserManager getInstance() {
        if (instance == null) {
            synchronized (UserManager.class) {
                if (instance == null) {
                    instance = new UserManager();
                }
            }
        }
        return instance;
    }

    public void saveToken(String token) {
        mPreferences.edit().putString(KEY_TOKEN, token).apply();
    }

    public String getToken() {
        return mPreferences.getString(KEY_TOKEN, null);
    }

    public void saveUserInfo(ResUser user) {
        UserInfo userInfo = user.getUser();
        mPreferences.edit()
                .putString(KEY_USER_ID, userInfo.getId())
                .putString(KEY_NICK_NAME, userInfo.getNickname())
                .putString(KEY_USER_NAME, userInfo.getUsername())
                .putString(KEY_AVATAR, userInfo.getAvatar())
                .putString(KEY_BIO, userInfo.getBio())
                .putString(KEY_STATUS, userInfo.getStatus())
                .putInt(KEY_FANS, user.getFans())
                .putInt(KEY_FOLLOW, user.getFollow())
                .putInt(KEY_MEDAL, user.getMedal())
                .apply();
    }

    public ResUser getUserInfo() {
        String userId = mPreferences.getString(KEY_USER_ID, null);
        String nickName = mPreferences.getString(KEY_NICK_NAME, null);
        String userName = mPreferences.getString(KEY_USER_NAME, null);
        String bio = mPreferences.getString(KEY_BIO, null);
        String avatar = mPreferences.getString(KEY_AVATAR, null);
        String status = mPreferences.getString(KEY_STATUS, null);
        int fans = mPreferences.getInt(KEY_FANS, 0);
        int follow = mPreferences.getInt(KEY_FOLLOW, 0);
        int medal = mPreferences.getInt(KEY_MEDAL, 0);

        ResUser user = new ResUser();
        user.setFans(fans);
        user.setFollow(follow);
        user.setMedal(medal);

        UserInfo userInfo = new UserInfo();
        userInfo.setId(userId);
        userInfo.setNickname(nickName);
        userInfo.setUsername(userName);
        userInfo.setBio(bio);
        userInfo.setAvatar(avatar);
        userInfo.setStatus(status);
        user.setUser(userInfo);

        return user;
    }

    public void logout() {
        mPreferences.edit()
                .remove(KEY_TOKEN)
                .remove(KEY_USER_ID)
                .remove(KEY_NICK_NAME)
                .remove(KEY_USER_NAME)
                .remove(KEY_AVATAR)
                .remove(KEY_BIO)
                .remove(KEY_STATUS)
                .remove(KEY_FANS)
                .remove(KEY_FOLLOW)
                .remove(KEY_MEDAL).apply();
    }

    public boolean isLogin() {
        String token = getToken();
        return token != null && !token.isEmpty();
    }
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/manager/UserManager.java

17.5 登录成功事件回推与用户页刷新

MessageEventUserFragment 一起承担登录态同步职责:

java 复制代码
public class MessageEvent {

    public static class LoginStatusEvent {
        private boolean isLogin;

        public LoginStatusEvent(boolean isLogin) {
            this.isLogin = isLogin;
        }

        public boolean isLogin() {
            return isLogin;
        }

        public static void post(boolean isLogin) {
            EventBus.getDefault().postSticky(new LoginStatusEvent(isLogin));
        }
    }
}

项目路径:LsxbugVideo/library_base/src/main/java/com/ls/libbase/eventbus/MessageEvent.java

用户页则在接收到登录状态变化后重新加载本地用户信息:

java 复制代码
public class UserFragment extends BaseFragment<LayoutFragmentUserBinding, UserViewModel> {

    @Override
    protected void initView() {
        StatusBarUtils.addStatusBarHeight2Views(
                mDataBinding.getRoot(),
                mDataBinding.ivSettings,
                mDataBinding.ivQualifications
        );

        mDataBinding.ivEdit.setOnClickListener(v -> {
            ARouter.getInstance().build(ARouterPath.User.ACTIVITY_LOGIN).navigation();
        });
    }

    @Subscribe(sticky = true, threadMode = ThreadMode.MAIN)
    public void onMessageEvent(MessageEvent.LoginStatusEvent event) {
        boolean login = event.isLogin();
        mViewModel.loadUserInfo(login);
    }

    @Override
    public void onStart() {
        super.onStart();
        EventBus.getDefault().register(this);
    }

    @Override
    public void onStop() {
        super.onStop();
        if (EventBus.getDefault().isRegistered(this)) {
            EventBus.getDefault().unregister(this);
        }
    }
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/user/UserFragment.java

真正把本地缓存渲染到用户中心页的是 UserViewModel

java 复制代码
public class UserViewModel extends BaseViewModel {

    private final UserModel mModel;
    private MutableLiveData<String> mAvatar = new MutableLiveData<>();
    private MutableLiveData<String> mNickName = new MutableLiveData<>();
    private MutableLiveData<String> mBio = new MutableLiveData<>();
    private MutableLiveData<String> mFans = new MutableLiveData<>();
    private MutableLiveData<String> mFollow = new MutableLiveData<>();
    private MutableLiveData<String> mMedal = new MutableLiveData<>();

    public UserViewModel() {
        mModel = new UserModel();
        boolean login = mModel.isLogin();
        loadUserInfo(login);
    }

    public void loadUserInfo(boolean login) {
        if (login) {
            showLoading(true);
            mModel.loadUserInfo(new ILoadUserInfoCallback() {
                @Override
                public void onLoadSuccess(ResUser user) {
                    showLoading(false);
                    updateUserInfo(user);
                }

                @Override
                public void onLoadFailure(int errorCode, String message) {
                    showLoading(false);
                    notLoginUpdateUserInfo();
                }
            });
        } else {
            notLoginUpdateUserInfo();
        }
    }
}

项目路径:LsxbugVideo/feature_user/src/main/java/com/ls/feature_user/ui/user/UserViewModel.java

相关推荐
JJay.5 小时前
Android BLE 扫描连接与收发消息实战
android
fly spider5 小时前
MySQL索引篇
android·数据库·mysql
xinhuanjieyi6 小时前
php setplayersjson实现类型转换和文件锁定机制应对高并发
android·开发语言·php
533_6 小时前
[vxe-table] 表头:点击出现输入框
android·java·javascript
邹阿涛涛涛涛涛涛6 小时前
Jetpack Compose Modifier 深度解析:从链式调用到 Modifier.Node
android
jinanwuhuaguo7 小时前
OpenClaw 2026年4月升级大系深度解读剖析:从“架构重塑”到“信任内建”的范式跃迁
android·开发语言·人工智能·架构·kotlin·openclaw
huhy~7 小时前
基于Ubuntu 24.04 LTS 搭建OpenStack F 版
android·ubuntu·openstack
2401_885885048 小时前
视频短信接口接入麻不麻烦?API调用说明
android·音视频
lI-_-Il8 小时前
喜马拉雅 v9.4.56.3:移动端全站音频资源畅听版
android·音视频