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 中的一个 Fragment 由 ViewPager 承载。也正因为有这一层嵌套,单纯把沉浸式状态栏写在基类 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 标记真正应用到窗口上。
整体效果就是:
- 状态栏背景透明
- 页面内容延伸到状态栏区域
- 状态栏图标变成深色
- 页面布局尽量保持稳定
这段代码一般用于:
- 顶部大图页面
- 沉浸式首页
- 自定义标题栏
- 需要更贴边、更满屏的页面布局
不过要注意一点:
因为内容已经延伸到状态栏下面了,顶部控件可能会被状态栏挡住 。
所以通常还要配合下面两种方式之一来处理:
- 给顶部 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 字段、请求体结构和页面跳转时,不会因为接口字段遗漏而反复返工。
登录页最终长这样:

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

接口调试时需要注意区分请求参数位置:
Query对应GET请求参数。Body对应POST请求体。
发送验证码接口的输入输出如下:

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

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

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
这一版布局里最重要的不是视觉元素,而是四组绑定关系已经提前就位:
- 手机号输入框通过
@={viewModel.userMobile}和ViewModel双向同步。 - 验证码输入框通过
@={viewModel.code}同步输入内容。 - 获取验证码按钮通过
isEnableSendCode和getVerticalCodeText同步可用态与文案。 - 登录按钮通过
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 上。布局层已经通过双向绑定把几个关键字段暴露出来:
- 手机号输入框:
android:text="@={viewModel.userMobile}" - 验证码输入框:
android:text="@={viewModel.code}" - 登录按钮是否可点击:
android:enabled="@{viewModel.isEnableLogin}" - 协议是否勾选:
android:checked="@={viewModel.checkAgreement}" - 登录按钮点击动作:
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
逻辑上的关键点有两个:
mIsEnableLogin默认就是false,页面初始态天然不可点。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 中把和验证码按钮有关的状态单独抽出来;
这些成员变量分别解决不同问题:
mGetVerticalCodeText负责按钮文案,从"获取验证码"切到"59s""58s"这类倒计时文本。mIsEnableSendCode决定按钮当前能不能再被点击。mDownTimer用来维持 60 秒倒计时。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
按动作顺序理解:
- 先检查手机号长度,不合法就直接
showToast并终止后续流程。 - 倒计时开始前先判断
mDownTimer是否为空,调用 mDownTimer.cancel(),防止重复点击时旧计时器还在跑。 - 一旦进入发送流程,立即把
mIsEnableSendCode设为false,从源头上阻止再次点击。 onTick()每秒更新一次文案,把毫秒转成秒再展示给用户。onFinish()恢复按钮文本和可点击状态,形成完整闭环。- 请求发起前打开加载状态,请求回调里无论成功失败都关闭加载状态,并通过
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 连起来:

这里实际绑定了三项内容:
- 按钮是否可点击:
android:enabled="@{viewModel.isEnableSendCode}" - 按钮显示文本:
android:text="@{viewModel.getVerticalCodeText}" - 按钮点击动作:
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
如果每个页面都这样写,问题会有两个:
- 观察者代码完全重复。
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(),就能把提示文案抛给页面层,而不必再关心页面具体如何展示。
为了让基类真正接住这份能力,Activity 和 Fragment 的泛型约束也要同步收紧,不再直接继承 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。这里没有额外写一个公共布局,而是直接在基类里按约束规则生成,目的是让所有页面天然共用同一种加载表现。
这里每一行都在为"通用"服务:
ConstraintLayout.LayoutParams把控件固定在父布局中心。WRAP_CONTENT自适应,保证加载控件不会侵入页面布局。- 默认
GONE,只有真正请求时才显示。 - 直接把
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,重点保留 token 和 id:
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
这里要注意两件事:
- 发送验证码接口返回的是
Call<ResBase<ResBase>>,因为这个接口的data本身就是空的。 - 登录接口返回的是
Call<ResBase<ResLogin>>,因为后面还要继续拿token和id。
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
这里的处理顺序是固定的:
- 先用
SpannableString包住整段文本。 - 再分别创建两个
ClickableSpan,给协议和隐私政策各自绑定点击逻辑。 updateDrawState()中设置加粗和颜色,让这两段文本从普通说明文字里被视觉区分出来。- 最后把
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. 在登录成功后继续拉取用户详情
登录接口返回的 token 和 id 只解决了认证问题,页面要真正显示头像、昵称、签名和统计数据,还得再发一次用户详情请求。
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_id 和 type 都声明成查询参数,后续调用时直接传入当前登录用户 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
这里的关键分支有两个:
result.getData()不为空,说明服务端返回了可用用户信息,继续走成功回调。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
模型层最终会在两个时机写入本地数据:
- 在 LoginModel 中,使用手机号登录接口,保存用户对应 token;
- 获取用户信息接口,将用户信息相关的所有信息,通过实体类对象保存到 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 的基本用法可以概括成四步:
- 定义消息事件
java
public static class MessageEvent { /* Additional fields if needed */ }
- 订阅消息
- 使用 @Subscribe 注解表示订阅消息
- 订阅的消息类型,为步骤 1 定义的 MessageEvent ,作为参数
java
@Subscribe(threadMode = ThreadMode.MAIN)
public void onMessageEvent(MessageEvent event) {
// Do something
}
- 注册和取消注册 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);
}
- 发布 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 登录成功事件回推与用户页刷新
MessageEvent 和 UserFragment 一起承担登录态同步职责:
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