优雅地管理 loading 页面和标题栏

6,075 阅读8分钟

前言

现在绝大多数 App 都需要请求网络数据,loading 动画必不可少,而网络请求会存在失败的情况,所以通常有 loading 页面也会有加载失败或者别的页面。

这些页面的样式大多时候是统一的,一般会想到的处理方式是把这些界面 include 到一个布局里,然后通过显示隐藏的方式来切换所需的视图。这样会写很多重复代码,可能会把这部分切换的逻辑抽取到 Activity 基类中或者抽取到一个自定义 View 里管理。

不过这些封装都不能解决本身存在的问题。不方便修改样式,样式有变动就需要修改已经封装好的代码。总要在布局上增加 include 代码或添加一个处理切换界面的自定义 View,多多少少有些冗余。

后面有大佬写了工具把这些处理好了,比如目前都有上千个 star 的库 LoadSirGloading 。这两个库都是很不错的,使用起来都很灵活易用。不过也有点小小的不足,因为布局上通常会有标题,我们就只能每次都对标题栏下方的子 View 进行 loading。由于标题栏样式大多时候是统一的,通常会在布局 include 一个标题栏,然后把标题栏的初始化封装在 Activity 基类中……有没发现和前面说的很像?这么写会存在着与前面相同的问题。

既然标题栏会影响到 loading 的区域,而且标题栏的通常写法所存在的问题也和 loading 界面的相似,那么如果一个 loading 库把标题栏一起管理了,会怎么样呢?

解决方案

LoadingHelper 是基于 Adapter 思想 和 ActionBar 原理实现的一个深度解耦 loading 界面和标题栏的工具,只用了一个 Kotlin 文件实现,不算上注释只有 200 多行代码。

基础用法

build.gradle 添加依赖:

dependencies {
    implementation 'com.dylanc:loadinghelper:2.1.0'
}

第一步,创建一个适配器继承 LoadingHelper.Adapter<VH extends ViewHolder>,写法与 RecyclerView.Adapter 类似。如果需要实现点击重新请求数据,可以在点击事件调用 holder.getOnReloadListener.onReload() 方法。

public class LoadingAdapter extends LoadingHelper.Adapter<LoadingHelper.ViewHolder> {
  
  @NonNull
  @Override
  public LoadingHelper.ViewHolder onCreateViewHolder(@NonNull LayoutInflater inflater, @NonNull ViewGroup parent) {
    return new LoadingHelper.ViewHolder(inflater.inflate(R.layout.lce_layout_loading_view, parent, false));
  }

  @Override
  public void onBindViewHolder(@NonNull LoadingHelper.ViewHolder holder) {
	
  }
}

第二步,注册适配器,传一个视图类型。有五个默认类型,也可以传任意类型数据进行注册。

LoadingHelper loadingHelper = new LoadingHelper(this);
loadingHelper.register(ViewType.LOADING, new LoadingAdapter());
// 当需要支持点击重新请求数据时
loadingHelper.setOnReloadListener(() -> {})

如果想注册成全局的适配器,需要配置默认的适配器池。

LoadingHelper.setDefaultAdapterPool(adapterPool -> {
  adapterPool.register(ViewType.LOADING, new LoadingAdapter());
  return Unit.INSTANCE;
});

第三步,显示对应类型的视图。

loadingHelper.showView(viewType);
loadingHelper.showLoadingView(); // 对应视图类型 ViewType.LOADING
loadingHelper.showContentView(); // 对应视图类型 ViewType.CONTENT
loadingHelper.showErrorView(); // 对应视图类型 ViewType.ERROR
loadingHelper.showEmptyView(); // 对应视图类型 ViewType.EMPTY

动态更新已显示视图

在显示了视图之后,可以对视图进行更改刷新。用法和 RecyclerView.Adapter 一样,调用 notifyDataSetChanged() 后,会执行适配器的 onBindViewHolder() 方法。

ErrorAdapter errorAdapter = loadingHelper.getAdapter(ViewType.ERROR);
errorAdapter.errorText = "服务器繁忙,请稍后重试";
errorAdapter.notifyDataSetChanged();

高级用法

管理标题栏

如果是普通的标题栏,就是简单地在内容的上方添加标题栏。

这就要用到添加装饰头部的方法,设置之前需要注册一个继承 LoadingHelper.Adapter<VH extends ViewHolder> 的适配器,设置后就会在内容的上方添加该适配器创建的 View 了,可以添加多个。

loadingHelper.register(ViewType.TITLE, new TitleAdapter("标题名"));
loadingHelper.register(VIEW_TYPE_SEARCH, new SearchHeaderAdapter(onSearchListener));
loadingHelper.setDecorHeader(ViewType.TITLE, VIEW_TYPE_SEARCH);

如果是特殊的标题栏,比如有联动效果,就不能直接使用上面的方式了。

先实现一个不含内容的布局。

<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:fitsSystemWindows="true">

  <com.google.android.material.appbar.AppBarLayout
    android:id="@+id/app_bar"
    android:layout_width="match_parent"
    android:layout_height="@dimen/app_bar_height"
    android:fitsSystemWindows="true"
    android:theme="@style/AppTheme.AppBarOverlay">

    <com.google.android.material.appbar.CollapsingToolbarLayout
      android:id="@+id/toolbar_layout"
      android:layout_width="match_parent"
      android:layout_height="match_parent"
      android:fitsSystemWindows="true"
      app:contentScrim="?attr/colorPrimary"
      app:layout_scrollFlags="scroll|exitUntilCollapsed"
      app:toolbarId="@+id/toolbar">

      <androidx.appcompat.widget.Toolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="?attr/actionBarSize"
        app:layout_collapseMode="pin"
        app:popupTheme="@style/AppTheme.PopupOverlay" />

    </com.google.android.material.appbar.CollapsingToolbarLayout>
  </com.google.android.material.appbar.AppBarLayout>

  <FrameLayout
    android:id="@+id/content_parent"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="@string/appbar_scrolling_view_behavior" />

</androidx.coordinatorlayout.widget.CoordinatorLayout>

创建一个类继承另一个适配器 LoadingHelper.DecorAdapter ,加载实现的布局,并指定一个 contentParent。

public class ScrollDecorAdapter extends LoadingHelper.DecorAdapter {
  @NotNull
  @Override
  public View onCreateDecorView(@NotNull LayoutInflater inflater) {
    return inflater.inflate(R.layout.layout_scrolling, null);
  }

  @NotNull
  @Override
  public ViewGroup getContentParent(@NotNull View decorView) {
    return decorView.findViewById(R.id.content_parent);
  }
}

最后调用一下设置装饰适配器的方法。

loadingHelper.setDecorAdapter(new ScrollDecorAdapter());

上述的两种使用方式都是可以进行多次设置,不过每次设置会把上一次设置的样式给替换掉。

对内容进行解耦

虽然本库主要是用来管理加载界面,但最终目的是为了显示加载好的内容,内容其实也是个视图,那么应该也可以通过 Adapter 进行注册管理。不过配置管理加载界面主要是因为样式通常是统一的,而内容界面基本不一样,会有什么使用场景吗?当然是有的,我们多多少少封装过一些 Activity 基类吧,因为有一些统一的重复操作。

现在可以将基类对内容的操作解耦出来,方便配置管理。而且我们封装了一个 Activity 基类后,通常会再封装一个相同功能的 Fragment 基类。这两个基类会有很多相同的操作,我们解耦后就能很容易复用,后续如果要修改也只是改一份代码。

用法和前面的差不多,不过是创建一个适配器继承 LoadingHelper.ContentAdapter。如果想要使用 Activity 对象,可以在构造方法传入或者通过 contentView 对象获得。

public class CommonContentAdapter extends LoadingHelper.ContentAdapter<LoadingHelper.ViewHolder> {
  @Override
  public LoadingHelper.ViewHolder onCreateViewHolder(@NonNull View contentView) {
    return new LoadingHelper.ViewHolder(contentView);
  }

  @Override
  public void onBindViewHolder(@NonNull LoadingHelper.ViewHolder holder) {
    View contentView = holder.getRootView();
  }
}

在创建 LoadingHelper 对象时传入 ContentAdapter 对象,就会立即对内容视图进行处理。

loadingHelper= new LoadingHelper(this, new CommonContentAdapter());

终极用法

LoadingHelper 可以解耦加载中、加载失败的界面,解耦标题栏,解耦内容的重复操作,因为最初封装的目的就是想将视图层的常见重复代码进行深度解耦。

下面分享一下个人结合了本库的特性所封装的基类用法,展示一下使用 LoadingHelper 对视图层进行封装能达到怎样的效果。

首先配置默认的适配器,不然后续使用可能会因为找不到适配器报错。

public class App extends Application {
    
  @Override
  public void onCreate() {
    super.onCreate();
    LoadingHelper.setDefaultAdapterPool(adapterPool -> {
      adapterPool.register(ViewType.LOADING, new LoadingAdapter());
      adapterPool.register(ViewType.ERROR, new ErrorAdapter());
      adapterPool.register(ViewType.EMPTY, new EmptyAdapter());
      return Unit.INSTANCE;
    });
  }
}

然后就能继承 BaseActivity 进行使用。

public class MainActivity extends BaseActivity {
  @Override
  protected void onCreate(@Nullable Bundle savedInstanceState) {
    setContentView(R.layout.activity_main);
    setToolbar("标题名", TitleConfig.Type.BACK, "完成", v -> {});
    loadData()
  }
  
  // 如果适配器调用 holder.getOnReloadListener.onReload(),会执行此方法重新请求
  @Override
  public void onReload() {
    loadData()
  }

  public void loadData() {
    showLoadingView(); // 展示加载视图
    // (发起请求,回调请求成功或请求失败的方法)
  }

  private void requestDataSuccess(String data){
    showContentView(); // 展示内容视图
    Toast.makeText(this, data, Toast.LENGTH_SHORT).show();
  }

  private void requestDataFailure(String msg){
    showErrorView(); // 展示错误视图
    Toast.makeText(this, msg, Toast.LENGTH_SHORT).show();
  }
}

当需要对该页面的样式进行定制时,只需增加少量代码。

public class MainActivity extends BaseActivity {

  private static final int VIEW_TYPE_SEARCH = 0;

  @Override
  protected void onCreate(@Nullable Bundle savedInstanceState) {
    // 重载的第二个参数是子 View 的 id,配置后会对该 View 进行 loading,可用于如 DrawLayout 等较复杂布局
    // 重载的第三个参数是用于替换默认管理内容的适配器
    setContentView(R.layout.activity_main, R.id.content_view, new CustomContentAdapter());
    
    getLoadingHelper().register(ViewType.TITLE, new CustomTitleAdapter()); // 替换默认标题栏
    getLoadingHelper().register(ViewType.LOADING, new CustomLoadingAdapter()); // 替换默认加载视图
    getLoadingHelper().register(ViewType.ERROR, new CustomErrorAdapter()); // 替换默认错误视图
    getLoadingHelper().register(VIEW_TYPE_SEARCH, new SearchHeaderAdapter()); // 配置搜索视图
    
    getLoadingHelper().setDecorHeader(ViewType.TITLE, VIEW_TYPE_SEARCH); // 添加自定义标题栏和搜索头部
    loadData()
  }
  
  @Override
  public void onReload() {
    loadData()
  }

  @Override
  public void loadData() {
    showLoadingView();
    // (发起请求,回调请求成功或请求失败的方法)
  }

  private void requestDataSuccess(String data) {
    showContentView();
    Toast.makeText(this, data, Toast.LENGTH_SHORT).show();
  }

  private void requestDataFailure(String msg) {
    showErrorView();
    Toast.makeText(this, msg, Toast.LENGTH_SHORT).show();
  }
}

不是什么复杂的封装,主要是结合了些个人觉得比较好的用法。比如没有用 getLayoutId() 而是保留重载了原有的 setContentView() 方法,通过布局和控件 id 来确定 contentView,然后用 showContentView() 方法展示出来。前面 set 了后面 show,代码阅读起来也很合理。

感谢

  • luckbilly/Gloading 站在了巨人肩膀上优化了本库,非常感谢! 该库给了我很多启发,我个人花了很多的时间也摸索出用适配器封装比较好,实现原理和思路都是类似的,但是该库居然用不到 200 行代码来实现。当时我写的代码量是这个的数倍,因为有点贪什么功能都想做,而且为了一些个人使用习惯增加了不少代码。后面明确了定位,做了大量的减法。最终优化成用一个 200 多行 Kotlin 代码的工具实现了原有的核心功能,同时保证了灵活性和易用性。
  • drakeet/MultiType 个人一直在使用的列表库,参考了该库注册配置多适配器的思想和用法。本库原来用法是只实现一个适配器,使用方式更类似 RecyclerView,学习成本更低。不过综合考虑后觉得还是拆分多个适配器注册使用更加灵活方便。

总结

本文讲述了一些管理 loading 界面的方案,分析了一些存在的不足。重点介绍了能同时管理 loading 界面和标题栏的工具 LoadingHelper,相对于同类型的 loading 库,该有的功能都有,还能解耦标题栏和解耦对内容的重复操作,能够进一步解耦视图层。最后展示了结合本库对基类进行封装使用能达到怎么样的效果。

如果你觉得本库还不错的话希望能给个 star 支持一下哦~