实战项目 7&8 : 从 Web API 获取数据

这篇文章分享我的 Android 开发(入门)课程 的第七个和第八个实战项目:书籍列表应用和新闻应用。这两个项目都托管在我的 GitHub 上,分别是 BookListingNewsApp 这两个 Repository,项目介绍已详细写在 README 上,欢迎大家 star 和 fork。

这两个实战项目的主要目的是练习从 Web API 获取应用数据,不过在实际 coding 的过程中使用了很多其它有意思的 Android 组件,这篇文章就逐个分享给大家。文章内容不会按应用的开发流程进行,各部分内容相对独立,大家可以利用浏览器的查找 (cmd/ctrl+F) 功能按需取用。为了精简篇幅,文中的代码有删减,请以 GitHub 中的代码为准。


SwipeRefreshLayout

Android 提供了 SwipeRefreshLayout 类实现下拉刷新的手势操作,在 BookListing 和 NewsApp 这两个应用中都使用了 SwipeRefreshLayout。例如下面的 XML 代码,应用的主要内容显示在 RecyclerView 中,要想实现它的下拉刷新功能,需要将 SwipeRefreshLayout 作为它的父视图 (Parent View),但是 SwipeRefreshLayout 只能有一个子视图,所以在 RecyclerView 之外还需要用 RelativeLayout 这个 ViewGroup 包括。另外,SwipeRefreshLayout 是由 Android 支持库提供的,所以使用前确保在项目的 Gradle 中添加了正确的依赖库。

<android.support.v4.widget.SwipeRefreshLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/swipe_container"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <android.support.v7.widget.RecyclerView
            android:id="@+id/list"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" />

        <TextView
            android:id="@+id/empty_view"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerInParent="true"
            android:gravity="center" />
    </RelativeLayout>
</android.support.v4.widget.SwipeRefreshLayout>

SwipeRefreshLayout 的 ID 设置为 swipe_container,用于在 Java 中查找这个 Android 组件,并设置监听器实现具体的刷新操作。例如下面的 Java 代码,在 onCreate 中设置 OnRefreshListener 监听器,并在其中 override onRefresh method,它会在用户完成下拉手势后调用,所以这里就是刷新应用内容需要执行的代码。另外,刷新动画的颜色序列可以在 setColorSchemeResources 中设置。

SwipeRefreshLayout swipeContainer = findViewById(R.id.swipe_container);

swipeContainer.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() {
    @Override
    public void onRefresh() {
        // ToDo: Handles the pull to refresh event here.
    }
});
// Configure the refreshing colors.
swipeContainer.setColorSchemeResources(
        android.R.color.holo_blue_light,
        android.R.color.holo_green_light,
        android.R.color.holo_orange_light,
        android.R.color.holo_red_light);

SwipeRefreshLayout 的刷新动画通常由用户的下拉手势触发,应用在完成刷新操作后停止刷新动画,通过设置以下 method 实现:

swipeContainer.setRefreshing(false);

如果设置 setRefreshingtrue 就可以主动开始刷新动画,所以 SwipeRefreshLayout 也可以用作加载指示符 (Loading Indicator),在加载数据的时候开始刷新动画,数据加载完成后停止刷新动画,在 BookListing 和 NewsApp 这两个应用中都是这么做的。

更多 SwipeRefreshLayout 内容可以参考这个 CodePath 教程


Navigation Drawer

Navigation Drawer 是 Android 应用中一种常用的导航模式,在 NewsApp 中用它来切换不同主题的新闻。使用 Android Studio 为应用添加 Navigation Drawer 非常简单,只需要在新建 Activity 时选择 Navigation Drawer Activity 就会自动创建好很多样板代码 (Boilerplate Code),样式符合 Material Design 风格,开发者仅需根据需求修改。以 NewsApp 为例:

In activity_main.xml

<android.support.v4.widget.DrawerLayout 
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/drawer_layout"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    tools:openDrawer="start">

    <include
        layout="@layout/app_bar_main"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <android.support.design.widget.NavigationView
        android:id="@+id/nav_view"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_gravity="start"
        android:fitsSystemWindows="true"
        app:headerLayout="@layout/nav_header_main"
        app:menu="@menu/activity_main_drawer" />
</android.support.v4.widget.DrawerLayout>
  1. 以 DrawerLayout 作为根视图,显示应用内容的视图作为其子视图,与 NavigationView 互为兄弟视图。
  2. 显示应用内容的视图的宽高尺寸要设置为 match_parent,因为 Navigation Drawer 通常是隐藏的,不占用屏幕空间。
  3. NavigationView 必须是 DrawerLayout 的最后一个子视图,保证 Navigation Drawer 显示在屏幕的最顶层,这与 XML 的渲染次序有关。
  4. NavigationView 必须指定 android:layout_gravity 属性,即设置 Navigation Drawer 的呼出方向,通常是从左边滑出。这里设置为 start 而不是 left,是因为支持了从右至左 (RTL) 的设计语言,例如用户设备为 RTL 风格时,Navigation Drawer 是从右边滑出的。
  5. NavigationView 的高度设置为 match_parent,宽度设置为 wrap_content,实现抽屉的画面效果,而且通常宽度不会大于 320dp 以保证在抽屉打开时,部分应用内容仍可见。
  6. NavigationView 一般分为两部分布局:Header(通过 app:headerLayout 属性设置)和 Menu(通过 app:menu 属性设置)。注意两者的文件路径不同。
  7. 通过设置 tools:openDrawer 可以利用 DesignTime Layout Attributes 实时预览 Navigation Drawer 的显示效果。

设置好 Navigation Drawer 的布局后,接下来就在 Java 中初始化:

In MainActivity.java

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    Toolbar toolbar = findViewById(R.id.toolbar);
    setSupportActionBar(toolbar);

    DrawerLayout drawer = findViewById(R.id.drawer_layout);
    ActionBarDrawerToggle toggle = new ActionBarDrawerToggle(
        this, drawer, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close);
    drawer.addDrawerListener(toggle);
    toggle.syncState();

    NavigationView navigationView = findViewById(R.id.nav_view);
    navigationView.setCheckedItem(R.id.nav_overview);
    navigationView.setNavigationItemSelectedListener(this);

    ...
}
  1. 首先操作 ActionBarDrawerToggle 将 DrawerLayout 和 ActionBar 整合以提供 Navigation Drawer 的推荐设计风格,这是 Android Studio 自动生成的代码。
  2. 然后新建 NavigationView 对象并设置一个默认选中的子项 (item),item 的 ID 是在 NavigationView 的 Menu 资源中定义的。
  3. 最后将 NavigationItemSelectedListener 设置为 this 表示 MainActivity 是实现这个监听器接口的类。例如在 NewsApp 中,在 MainActivity 中 override onNavigationItemSelected method 处理 item 的选中事件。

In MainActivity.java

public class MainActivity extends AppCompatActivity
        implements NavigationView.OnNavigationItemSelectedListener {
    ...

    @Override
    public boolean onNavigationItemSelected(MenuItem item) {
        // Handle navigation view item clicks here.
        Toolbar toolbar = findViewById(R.id.toolbar);
        switch (item.getItemId()) {
            case R.id.nav_overview:
                toolbar.setTitle(R.string.app_name);
                section = null;
                break;
            case R.id.nav_news:
                toolbar.setTitle(R.string.menu_news);
                section = "news";
                break;
            case R.id.nav_opinion:
                toolbar.setTitle(R.string.menu_opinion);
                section = "commentisfree";
                break;
            default:
                Log.e(LOG_TAG, "Something wrong with navigation drawer items.");
        }

        // Close navigation drawer after handling item click event.
        DrawerLayout drawer = findViewById(R.id.drawer_layout);
        drawer.closeDrawer(GravityCompat.START);
        return true;
    }
  1. 由于 MainActivity 设置为实现 NavigationItemSelectedListener 接口的类,所以在类名后面需要添加 implements 参数。
  2. 用户通过选中不同的 item 时,通过 switch/case 语句进行相应的操作。
  3. 操作结束后,可以关闭 Navigation Drawer。注意这个操作由 DrawerLayout 完成,而不是 NavigationView。

除此之外,还需要修改 onBackPressed method 来指定当 Navigation Drawer 打开时,用户点击“返回”按钮 (Back buttons) 时的行为。

@Override
public void onBackPressed() {
    DrawerLayout drawer = findViewById(R.id.drawer_layout);
    if (drawer.isDrawerOpen(GravityCompat.START)) {
        drawer.closeDrawer(GravityCompat.START);
    } else {
        super.onBackPressed();
    }
}

当用户在Navigation Drawer 打开时点击“返回”按钮的操作应该是关闭 Navigation Drawer。这部分代码是由 Android Studio 自动生成的


SearchView

SearchView 是一种 Android 组件,相当于在应用栏放入一个 EditText,提供了很多搜索相关的功能,例如显示候选词等。在 BookListing App 中,使用 SearchView 来获取用户输入的搜索关键词,用于向 Web API 发送请求。

一、提供 menu 资源

<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <item
        android:id="@+id/menu_search"
        android:icon="@android:drawable/ic_menu_search"
        android:title="@string/search_title"
        app:actionViewClass="android.widget.SearchView"
        app:showAsAction="ifRoom|collapseActionView"
        android:orderInCategory="1" />
</menu>
  1. 通过 android:icon 属性设置 SearchView 出现在应用栏的图标。
  2. 通过 android:title 属性设置 SearchView 的标题。若未设置 SearchView 的图标时,就会在应用栏显示它的标题;用户长按图标时也会弹出标题文本消息。
  3. 通过 app:showAsAction 属性设置 SearchView 的显示策略,其中 ifRoom 表示SearchView 图标仅在应用栏有空间时才显示,否则会显示在溢出菜单 (Overflow Menu) 中;collapseActionView 表示 SearchView 会包含在一个二级菜单中。
  4. 通过 android:orderInCategory 属性设置 SearchView 的显示优先级,数字越小优先级越高。在应用栏有多个 item 时,如果它们的 app:showAsAction 属性都设置为 ifRoom,那么在应用栏没有空间时会按照这个属性仅显示优先级最高的菜单项。

二、在 Java 实现代码

与其它菜单项类似,SearchView 的操作也是在 onCreateOptionsMenu 中进行。

In MainActivity.java

@Override
public boolean onCreateOptionsMenu(Menu menu) {
    getMenuInflater().inflate(R.menu.options_menu, menu);

    searchMenuItem = menu.findItem(R.id.menu_search);
    searchView = (SearchView) searchMenuItem.getActionView();

    searchView.setQueryHint(getString(R.string.search_hint));
    searchView.setIconifiedByDefault(false);
    searchView.setOnQueryTextListener(new SearchView.OnQueryTextListener() {
        @Override
        public boolean onQueryTextSubmit(String query) {
            // Todo: Get the submitted query text here.
            return false;
        }

        @Override
        public boolean onQueryTextChange(String newText) {
            return false;
        }
    });
    return true;
}
  1. 调用 setQueryHint method 设置 SearchView 的提示文字。
  2. 调用 setIconifiedByDefault 设置 SearchView 是否默认显示图标,若真则仅显示图标,若假则显示带有文本输入框的完整 SearchView。在 BookListing App 中,由于在 menu 资源中设置了 app:showAsAction="collapseActionView" 将 SearchView 放入了二级菜单,所以在这里将 setIconifiedByDefault 设为 false 也仅显示 SearchView 的图标。
  3. 设置 SearchView 的 OnQueryTextListener 来获取用户输入的文本。其中必须 override 两个 method:onQueryTextSubmit 会在用户点击回车键后获取提交的文本;onQueryTextChange 则每当文本发生变化时就获取新的文本。

三、点击 TextView 自动打开 SearchView

在 BookListing App 中,提供了点击 Empty View 直接打开 SearchView,弹出输入法 (IME) 供用户输入搜索关键词的功能。

mEmptyStateView.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        searchMenuItem.expandActionView();
        searchView.setIconified(false);
    }
});
  1. 设置 Empty View 的 OnClickListener 并 override onClick method 添加打开 SearchView 的代码。
  2. 调用 MenuItem 的 expandActionView() 打开 SearchView 所在的应用栏二级菜单;再设置 SearchView 的 setIconifiedfalse 显示完整的 SearchView,系统就自动聚焦到 SearchView 的输入框,弹出输入法供用户输入搜索关键词了。

Endless Scrolling RecyclerView List

在 RecyclerView 列表滑到底部之前,应用提前加载数据添加到列表中,实现无限滚动列表的效果。因此,这里要添加 OnScrollListener 并 override onScrolled method 来监控列表的滚动情况,当应用判断列表快要滑到底时,会加载更多数据。

recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
    @Override
    public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
        if (isLoading) {
            return;
        }

        if (dy > 0) {
            visibleItemCount = layoutManager.getChildCount();
            totalItemCount = layoutManager.getItemCount();
            pastVisibleItems = layoutManager.findFirstVisibleItemPosition();

            if ((visibleItemCount + pastVisibleItems) >= totalItemCount) {
            isLoading = true;
            // Todo: Fetch new data here.
            }
        }
    }
});
  1. onScrolled 中,首先判断 isLoading 是否为真,若是则提前返回。isLoading 是一个全局的布尔类型变量,默认为 false。它表示当前状态下数据是否正在加载中,所以在开始加载数据时需要将它设置为 true,数据加载完成时设为 false。
  2. 利用 onScrolled 的参数 dy 大于零(表示屏幕的滚动方向为向上)时分别获取三个参数。由于这三个变量是在匿名类中使用的,所以要声明为全局变量。
    (1)visibleItemCount:获取 RecyclerView 的 item 数目,但不包括已回收的视图,所以它可以看作是当前屏幕可见的 item 数目。
    (2)totalItemCount:获取 RecyclerView 的所有 item 数目。
    (3)pastVisibleItems:获取 RecyclerView.Adapter 第一个可见的 item 的位置,也就是当前屏幕可见的第一个 item 的位置,所以它可以看作是已滑出屏幕的 item 数目。
  3. 根据上述三个参数判断列表滑到底时,设置 isLoading 为 true,并添加加载更多数据的代码。在 NewsApp 中的做法是设置新的 URL 请求参数后重启 AsyncTaskLoader 加载数据,并在数据加载完成后判断此次加载是否用了新的请求参数,若是则将数据添加到列表中,实现无限滚动列表的效果。

RecyclerView clear & addAll

由于 RecyclerView 没有提供与类似 ListView 的 clear 和 addAll method,所以需要开发者自行实现,通常是在 RecyclerView.Adapter 中添加辅助方法 (Helper Method)。

In NewsAdapter.java

public void clear() {
    mBooksList.clear();
    notifyDataSetChanged();
}

public void addAll(List<News> newsList) {
    mBooksList.addAll(newsList);
    notifyDataSetChanged();
}

上面两个辅助方法都调用了同一个 method,告知适配器列表数据有变化。列表数据变化通常有两种类型:一种是子项变化 (Item Change),指 item 的数据变化,列表没有任何位置上的变化;另一种是结构变化 (Structural Change),指列表中有 item 插入、移除、移动。常见的 notify 类 method 有以下几种:

Method Description
notifyDataSetChanged() 未指定数据变化的类型,适配器认为所有的原先数据已不可用,LayoutManager 会重新捆绑 (rebind) 和重新布局 (relayout) 视图,这种方式效率较低,通常不优先考虑使用。
notifyItemChanged (int position) 列表中指定位置 (position) 的 item 发生数据变化,这属于子项变化,适配器仅更新该位置的 item,其它 item 不受影响。
notifyItemInserted (int position) 列表中在指定位置 (position) 插入 item,原先该位置的 item 往后移一位 (position + 1),其它 item 仅改变位置,不会重新布局。这属于结构变化。
notifyItemMoved (int fromPosition, int toPosition) 列表中一个 item 从原先位置 (fromPosition) 移动到另一位置 (toPosition),其它 item 仅改变位置,不会重新布局。这属于结构变化。
notifyItemRemoved (int position) 列表中指定位置 (position) 的 item 被移除,该位置后面的 item 位置前移一位 (position - 1),其它 item 仅改变位置,不会重新布局。这属于结构变化。
notifyItemRangeChanged (int positionStart, int itemCount) 从指定位置 (positionStart) 开始,共计 itemCount 个数的 item 发生数据变化,这属于子项变化,适配器仅更新相应的 item,其它 item 不受影响。

根据不同的情景使用不同的 notify 类 method 以达到更高效率,更多信息可以到 RecyclerView.Adapter 文档查看。


在 RecyclerView 子项间添加分隔线

DividerItemDecoration 属于 RecyclerView.ItemDecoration 的子类,它可用于为 LinearLayoutManager 下的 item 添加分隔线,支持垂直和水平方向。

LinearLayoutManager layoutManager = new LinearLayoutManager(this);
recyclerView.setLayoutManager(layoutManager);

DividerItemDecoration dividerItemDecoration = new DividerItemDecoration(
        recyclerView.getContext(), layoutManager.getOrientation());
recyclerView.addItemDecoration(dividerItemDecoration);
  1. DividerItemDecoration 提供了很多 method 可以为分隔线提供更多设置,例如 setDrawable 可以为分隔线设置 Drawable 资源。
  2. 如果 RecyclerView 不采用 LinearLayoutManager,那么可以使用 RecyclerView.ItemDecoration 来进行更精细的分隔线设置。

Expandable CardView

在 BookListing App 中,RecyclerView 使用了 CardView 作为其子项的主要布局,并且实现了可扩展的 CardView 效果。实现这一功能有三个要点。

一、OnItemClickListener

RecyclerView 没有类似 ListView 可直接调用的类来处理 item 的点击事件,RecyclerView 只提供了 OnItemClickListener 接口,所以首先需要在 RecyclerView.Adapter 中实现 OnItemClickListener,以 BookListing App 为例,代码如下。

In BookAdapter.java

private OnItemClickListener mOnItemClickListener;

public void setOnItemClickListener(OnItemClickListener OnItemClickListener) {
    mOnItemClickListener = OnItemClickListener;
}

public interface OnItemClickListener {
    void onItemClick(View view, int position);
}

然后在 Mainactivity 中设置监听器,代码如下。

In MainActivity.java

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);
    ...

    mAdapter.setOnItemClickListener(new BookAdapter.OnItemClickListener() {
        @Override
        public void onItemClick(View view, int position) {
        }
    });

    ...
}

针对 BookListing App 的情况,CardView 的点击事件不需要在 MainActivity 中进行任何操作,所以这里留空,但必须在 MainActivity 中设置监听器。所有操作放在监听器内进行,因此又回到 RecyclerView.Adapter 中去。

In BookAdapter.java

@Override
public void onBindViewHolder(final MyViewHolder holder, final int position) {
    ...

    if (mOnItemClickListener != null) {
        holder.cardView.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                cardViewIndex = holder.getAdapterPosition();
                notifyItemChanged(holder.getAdapterPosition());
            }
        });
    }

    ...
}

onBindViewHolder 中设置监听器并通过 override onClick method 添加 CardView 点击事件触发后执行的代码。由于 BookListing App 要实现 CardView 的展开和折叠功能,所以在这里使用了一个全局变量记录当前用户点击的 CardView 的位置,并通过 notifyItemChanged 告知监听器更新该位置的 item 数据。注意 cardViewIndex 是全局变量,默认值为 -1,使其默认情况下无作用 (unreachable),直到发生点击事件时对它赋值。

二、展开和折叠 CardView

接下来适配器会更新发生点击事件的 item 数据,也就是重新执行一次 onBindViewHolder,position 参数为 cardViewIndex。所以,此时就要往 onBindViewHolder 添加扩展 CardView 的代码了。

@Override
public void onBindViewHolder(final MyViewHolder holder, final int position) {
    ...

    if (cardViewIndex == position) {
        ViewGroup.LayoutParams cardViewLayoutParams = holder.cardView.getLayoutParams();
        
        if (isCardExpanded.get(position).equals(false)) {
            cardViewLayoutParams.height = (int) mContext.getResources().
                    getDimension(R.dimen.card_expanded_height);

            int expandedHorizontalMargin = mContext.getResources().
                    getDimensionPixelOffset(R.dimen.card_expanded_horizontal_margin);
            int expandedVerticalMargin = mContext.getResources().
                    getDimensionPixelOffset(R.dimen.card_expanded_vertical_margin);
            setMargins(holder.cardView, expandedHorizontalMargin, expandedVerticalMargin,
                    expandedHorizontalMargin, expandedVerticalMargin);

            isCardExpanded.set(position, true);
        } else {
             cardViewLayoutParams.height = (int) mContext.getResources().
                    getDimension(R.dimen.card_height);

            int originVerticalMargin = mContext.getResources().
                    getDimensionPixelOffset(R.dimen.card_vertical_margin);
            int originHorizontalMargin = mContext.getResources().
                    getDimensionPixelOffset(R.dimen.card_horizontal_margin);
            setMargins(holder.cardView, originHorizontalMargin, originVerticalMargin,
                    originHorizontalMargin, originVerticalMargin);

            isCardExpanded.set(position, false);
        }

        holder.cardView.setLayoutParams(cardViewLayoutParams);

        cardViewIndex = -1;
    }

    ...
}
  1. 首先通过 if/else 语句保证监听器只更新发生点击事件的 item,并在更新完毕后将 cardViewIndex 重新设为 -1,使其默认情况下无作用。
  2. 为了精简篇幅,以上代码仅以 CardView 的操作举例,删去了显示副标题、作者、简介、链接的 TextView 以及显示图片的 ImageView 在 CardView 展开和折叠情况下的操作逻辑。完整代码请参考我的 GitHub BookListing Repository。
  3. 通过设置 ViewGroup.LayoutParams 的 height 参数改变 CardView 的高度,达到展开和折叠的效果。
  4. 通过辅助方法 setMargins 改变 CardView 与屏幕边缘的距离,达到放大和缩小的效果。其中 setMargins 的输入参数为像素值 (px),可利用 mContext.getResources().getDimensionPixelOffset() 实现独立像素 (dp) 对像素 (px) 的转换。
/**
 * Helper method that set margins of views, using {@link ViewGroup.MarginLayoutParams}.
 *
 * @param view         is the view whom set margins to.
 * @param leftMargin   is the left margin of the view.
 * @param topMargin    is the top margin of the view.
 * @param rightMargin  is the right margin of the view.
 * @param bottomMargin is the bottom margin of the view.
 */
private void setMargins(View view, int leftMargin, int topMargin,
                        int rightMargin, int bottomMargin) {
    if (view.getLayoutParams() instanceof ViewGroup.MarginLayoutParams) {
        ViewGroup.MarginLayoutParams params =
                (ViewGroup.MarginLayoutParams) view.getLayoutParams();
        params.setMargins(leftMargin, topMargin, rightMargin, bottomMargin);
        view.requestLayout();
    }
}
  1. CardView 在展开和折叠过程中的动画效果是由 DefaultItemAnimator 提供的,在 MainActivity 中添加以下指令即可。

     recyclerView.setItemAnimator(new DefaultItemAnimator());
    
  2. 设置好需要修改的 LayoutParams 参数后,最后不要忘记执行以下指令,使修改设置生效。

     holder.cardView.setLayoutParams(cardViewLayoutParams);
    
  3. 大家肯定注意到,与 CardView 展开和折叠相关的参数不止有 cardViewIndex,还有一个全局布尔类型变量 isCardExpanded,它实际上是一个 ArrayList<Boolean>,记录了 RecyclerView 列表的每个 item 的展开和折叠情况,CardView 展开时为真,折叠时为假。因此,在展开某个位置的 CardView 后需要将该位置的 isCardExpanded 设为 true,折叠后则设为 false。如何获取一个与 RecyclerView 列表等长的 ArrayList<Boolean> 并将所有项默认为 false(因为 CardView 默认是折叠的)就是第三个要点。

三、isCardExpanded

由于 RecyclerView.Adapter 必须 override getItemCount method,在这个 method 中会得到 RecyclerView 列表的所有 item 数目,因此可以在 getItemCount 内初始化 isCardExpanded,代码如下。

private List<Boolean> isCardExpanded = new ArrayList<>();

@Override
public int getItemCount() {
    int listItemCount = mBooksList.size();
    if (isCardExpanded.size() < listItemCount) {
        isCardExpanded.clear();

        for (int index = 0; index < listItemCount; index++) {
            isCardExpanded.add(false);
        }
    }
    return listItemCount;
}
  1. isCardExpanded 的数据类型定义为 List<Boolean>,仅在定义对象实例时指定为 ArrayList<Boolean>,这是因为 List 是接口,而 ArrayList 是 List 的具象类,当 App 需要重构代码 (refactor) 时,例如由 ArrayList 改为 LinkedList,仅在对象实例的定义处指定一个具象类即可,保持代码的灵活性。
  2. getItemCount 内,首先判断当前 isCardExpanded 是否已有值,若无才对其赋值,并在赋值之前清除列表,最后通过 for 循环语句向 isCardExpanded 添加与 RecyclerView 列表等长的 item 并将所有项默认为 false。
  3. 事实上,对于 BookListing App 来说,RecyclerView 列表的 item 数目一直都是 10,但是这里没有将 isCardExpanded 硬编码为长度为 10 的 ArrayList,保持良好的编程习惯。

先显示文字,后显示图片

在 BookListing App 中,列表中的每一本图书都包含标题、作者、评分等文字,还有一张图片。因为应用的内容是通过 AsyncTaskLoader 从 Web API 获取的,文字与图片的数据大小量级不同,为了尽快为用户提供有意义的内容,所以 BookListing App 采取了“先显示文字,后显示图片”的策略,这就要求图书的文字和图片分开两个线程加载,用到两个 AsyncTaskLoader。
以 BookListing App 为例,在 MainActivity 中引入两个 AsyncTaskLoader,它们的 LoaderCallback 作为一个类定义,在操作 Loader 时传入的参数也需要更改。

In MainActivity.java

public class MainActivity extends AppCompatActivity {
    ...

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...

        getLoaderManager().initLoader(BOOK_LOADER_ID, null, new BookLoaderCallback());
    }

    private class BookLoaderCallback implements LoaderManager.LoaderCallbacks<List<Book>> {
        @Override
        public Loader<List<Book>> onCreateLoader(int i, Bundle bundle) {
            ...
        }

        @Override
        public void onLoadFinished(Loader<List<Book>> loader, List<Book> books) {
            ...

            loaderManager.restartLoader(IMAGE_LOADER_ID, null, new ImageLoaderCallback());
        }
    }

    private class ImageLoaderCallback implements LoaderManager.LoaderCallbacks<List<Drawable>> {
        @Override
        public Loader<List<Drawable>> onCreateLoader(int i, Bundle bundle) {
            return new ImageLoader(getApplicationContext());
        }

        @Override
        public void onLoadFinished(Loader<List<Drawable>> loader, List<Drawable> drawables) {
            mAdapter.setImage(drawables);
        }
    }
}
  1. 在 NewsApp 中,因为只用到了一个 AsyncTaskLoader,所以直接把 MainActivity 作为它的 LoaderCallback 类,在 MainActivity 类名后面添加 implements 参数。而在 BookListing App 中就需要在 MainActivity 内分别定义两个 BookLoaderCallback 和 ImageLoaderCallback 类,并在类名后面添加 implements 参数。在调用 initLoaderrestartLoader 时第三个参数也要由 this 改为各自的 LoaderCallback 类实例,如 new BookLoaderCallback()new ImageLoaderCallback()
  2. BookListing App 采用“先显示文字,后显示图片”的策略,所以在加载完文字后再开始加载图片,也就是说,在 BookLoaderCallback 的 onLoadFinished 执行 restartLoader 指令,开启 ImageLoader。
  3. 在 ImageLoaderCallback 的 onCreateLoader 中,ImageLoader 直接跳到后台线程 loadInBackground 将 Web API 返回的图片 URL (QueryUtils.image) 转换为 Drawable 资源。返回值的数据类型为 List<Drawable>。

In ImageLoader.java

@Override
public List<Drawable> loadInBackground() {
    List<Drawable> drawables = new ArrayList<>();
 
    List<String> image = QueryUtils.image;

    if (image != null && !image.isEmpty()) {
        for (int index = 0; index < image.size(); index++) {
            drawables.add(getImageDrawable(image.get(index)));
        }
    }

    return drawables;
}

这里用到了辅助方法 getImageDrawable,涉及到显示网络图片的内容,主要是应用了 InputStream 缓存并转换为 Drawable 资源,返回值的数据类型为 Drawable。

private static Drawable getImageDrawable(String imageUrlString) {
    Drawable imageResource = null;

    try {
        URL url = new URL(imageUrlString);
        InputStream content = (InputStream) url.getContent();
        imageResource = Drawable.createFromStream(content, "src");
    } catch (MalformedURLException e) {
        Log.e(LOG_TAG, "Problem building the URL ", e);
    } catch (IOException e) {
        Log.e(LOG_TAG, "Problem getting the URL content ", e);
    }

    return imageResource;
}
  1. ImageLoader 完成图片数据加载后,在 ImageLoaderCallback 的 onLoadFinished 中调用 RecyclerView.Adapter 的 setImage 辅助方法,向列表中添加图片。
public void setImage(List<Drawable> drawables) {
    if (drawables != null && !drawables.isEmpty()) {
        for (int index = 0; index < drawables.size(); index++) {
            mBooksList.get(index).setImageResource(drawables.get(index));
            notifyItemChanged(index);
        }
    }
}

通过 for 循环语句为 RecyclerView 列表的每一项添加图片,并通知适配器每一项的数据变化,使其得以更新。


NestedScrollView

在 BookListing App 中,除 RecyclerView 之外还有其它视图需要随着 RecyclerView 的列表一起实现滚动效果,例如图书列表上面的两个分别显示图书总数和页码信息的 TextView,所以这里引入 NestedScrollView

<android.support.v4.widget.NestedScrollView
    android:id="@+id/scroll_view"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:fadeScrollbars="true"
    android:scrollbars="vertical">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:orientation="vertical">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content">

            <TextView
                android:id="@+id/result_count"
                style="@style/resultTextView"
                android:gravity="start|center_vertical" />

            <TextView
                android:id="@+id/result_page"
                style="@style/resultTextView"
                android:gravity="end|center_vertical" />
        </LinearLayout>

        <android.support.v7.widget.RecyclerView
            android:id="@+id/list"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:clipToPadding="false"
            android:paddingBottom="@dimen/recycler_view_bottom_padding" />
    </LinearLayout>
</android.support.v4.widget.NestedScrollView>
  1. NestedScrollView 与 ScrollView 类似,只能有一个子视图。针对 RecyclerView 和 ListView 垂直方向的滚动,NestedScrollView 提供了更灵活的滚动效果,而且无需任何 Java 代码默认开启滚动效果。
  2. 在 NestedScrollView 中 设置 android:scrollbars 属性为 vertical 使其拥有一个垂直方向的滚动条,默认在右侧显示;同时设置 android:fadeScrollbarstrue 使滚动条在列表静止不动时隐藏。这两个属性并不是 NestedScrollView 专有的,事实上它是在 View 类定义的,所以理论上所有视图都可以设置这两个属性。
  3. RecyclerView 设置了 android:paddingBottom 使列表的最后一个 item 距离屏幕底部有一定的距离,但是这会导致内容滚动时在 padding 区域出现一个空白横条,非常影响美观。所以这里还需要设置 android:clipToPaddingfalse 使 padding 的空白区域在内容滚动时消失,仅在列表滚动到底部时出现。

将 RecyclerView 放在 NestedScrollView 内可能会出现 RecyclerView 列表滚动卡顿不流畅的现象,根据 stack overflow 的高票答案来看,在 Java 中添加以下代码即可解决问题。

recyclerView.setNestedScrollingEnabled(false);

不过在 stack overflow 的答案下面也有评论指出,执行这条代码后 RecyclerView 将不会回收视图,导致资源浪费。由于这条指令在 RecyclerView 文档中没有详细介绍,我通过 Android Profiler 也没有观察到异常,所以就没有深究下去,有了解的各位请不吝赐教。


Empty View

BookListing 和 NewsApp 这两个应用的数据都是从 Web API 获取的,所以在设备无网络连接或无数据的情况下,要用 Empty View 显示当前应用的状态,提醒用户进行下一步操作。

在 XML 布局中,通常把 RecyclerView 与 Empty View 放入 RelativeLayout 中,彼此不用设置相对位置关系,因为两者在同一时间只会显示其一。

<RelativeLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/list"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <TextView
        android:id="@+id/empty_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:gravity="center"
        android:padding="@dimen/activity_spacing" />
</RelativeLayout>

设置 Empty View 需要在多处实现,将重复使用的代码封装成一个 Helper method 供其它地方调用是一个好的做法。

private void setEmptyView(boolean visibility, @Nullable Integer textStringId,
                          @Nullable Integer imageDrawableId) {
    TextView emptyView = findViewById(R.id.empty_view);
    if (visibility && textStringId != null && imageDrawableId != null) {
        emptyView.setText(textStringId);
        emptyView.setCompoundDrawablesWithIntrinsicBounds(null,
                ContextCompat.getDrawable(getApplicationContext(), imageDrawableId),
                null, null);
        emptyView.setCompoundDrawablePadding(getResources().
                getDimensionPixelOffset(R.dimen.compound_image_spacing));
        emptyView.setVisibility(View.VISIBLE);
    } else {
        emptyView.setVisibility(View.GONE);
    }
}
  1. setEmptyView 设置了三个输入参数,第一个是设置 Empty View 是否可见的布尔类型参数;第二个是 Empty View 的文本字符串 ID,可以为 null;第三个是 Empty View 的图片资源 ID,可以为 null。注意设置为 @Nullable 的输入参数不能是原始数据类型,所以这里需要将 int 换成其对象类型 Integer。

  2. 如果要设置 Empty View 为不可见,可以调用以下代码。

     setEmptyView(false, null, null);
    
  3. 仅当依次传入 true、字符串 ID、以及图片资源 ID 后,Empty View 才会开始设置相应的属性,最后设置为可见。其中,设置 TextView 的组合图片 (Compound Drawable) 需要调用 setCompoundDrawablesWithIntrinsicBounds 并通过 ContextCompat.getDrawable() 获取 Drawable 资源传入第二个参数,表示在 TextView 上方显示一张图片。

  4. 调用 setCompoundDrawablePadding 设置图片与文本之间的间隔,它传入的参数是像素值 (px),可以通过 getResources().getDimensionPixelOffset() 实现独立像素 (dp) 对像素 (px) 的转换。


onSaveInstanceState

在面对设备旋转等会导致 Activity 重启的情况时,可以将一些变量在 Activity 被杀死 (killed) 之前保存起来,然后 Activity 重启时在 onCreate 或 onRestoreInstanceState 中取回变量。例如在 BookListing App 中,通过 override onSaveInstanceState method 保存了 resultOffset 整数以及 requestKeywords 字符串。

@Override
public void onSaveInstanceState(Bundle savedInstanceState) {
    savedInstanceState.putInt("resultOffset", resultOffset);
    savedInstanceState.putString("requestKeywords", requestKeywords);

    super.onSaveInstanceState(savedInstanceState);
}
  1. 参数是以字符串键/值的形式存在的,在取回变量时也是根据字符串键作为每个变量的 ID 来识别的。
  2. 最后不要忘了调用 onSaveInstanceState 的超级类。

变量可以在 onCreate 中取回,例如在 BookListing App 中,当 savedInstanceState 不为空时,按字符串键取回 resultOffset 整数以及 requestKeywords 字符串。注意在 onCreate 的输入参数就是 savedInstanceState。

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_main);

    if (savedInstanceState != null) {
        resultOffset = savedInstanceState.getInt("resultOffset");
        requestKeywords = savedInstanceState.getString("requestKeywords");
    }

    ...
}

变量也可以在 onRestoreInstanceState 中取回,只不过它是在 onCreate 之后执行的,因此如果变量是需要在 onCreate 中用到的,就不能在 onRestoreInstanceState 中取回变量了。

@Override
public void onRestoreInstanceState(Bundle savedInstanceState) {
    super.onRestoreInstanceState(savedInstanceState);

    resultOffset = savedInstanceState.getInt("resultOffset");
    requestKeywords = savedInstanceState.getString("requestKeywords");
}

注意 onSaveInstanceState 和 onRestoreInstanceState 调用各自的超级类的时机是不一样的。


横滑手势捕捉

在 BookListing App 中,采用了底部横滑切换上下页的导航模式,实现方法主要参考了这个 stack overflow 帖子,主要是应用了 OnTouchListener 中的 SimpleOnGestureListener 来捕捉左滑和右滑手势操作。
不过在 BookListing App 中的应用不够理想,例如局部的横滑通常是面向局部操作的,例如移除屏幕中的一个卡片。另外设置了 OnTouchListener 的视图会让 Android Studio 认为该视图是一个自定义视图,提示无障碍 (Accessibility) 方面的警告。因此,这部分内容仅作为备忘,不作讨论。


检查网络状态

在 BookListing 和 NewsApp 这两个应用中,在进行数据加载之前都需要检查网络状态。面对这种经常用到的功能,封装成一个 Helper method 供其它地方调用是一个好的做法。

private boolean isConnected() {
    // Get a reference to the ConnectivityManager to check state of network connectivity.
    ConnectivityManager connMgr = (ConnectivityManager)
            getSystemService(Context.CONNECTIVITY_SERVICE);
    // Get details on the currently active default data network.
    NetworkInfo networkInfo = connMgr.getActiveNetworkInfo();

    // Return true if the device is connected, vice versa.
    return networkInfo != null && networkInfo.isConnected();
}

该辅助方法返回的数据类型是布尔类型,当检查到设备已连接网络时返回值为真,无连接时为假。这样一来 isConnected() 就可以轻易地放入 if/else 流控语句应用。


格式化 ISO-8601 时间

在 NewsApp 中,使用的 The Guardian API 返回的时间数据是 ISO-8601 格式的,具体来说是 UTC 日期与时间结合 (Combined date and time in UTC) 的形式。这种格式会在时间前面加一个大写字母 T,显示 UTC 时间时在末尾加一个大写字母 Z。这只是复杂的时间问题的冰山一角,大家有兴趣可以观看这个 YouTube 视频。所幸在 Android 中可以使用 SimpleDateFormat 来格式化时间,例如格式化 ISO-8601 时间可以利用如下代码:

try {
    SimpleDateFormat inFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault());
    Date dateIn = inFormat.parse(news.getTime());
   
    SimpleDateFormat outFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault());
    String dateOut = outFormat.format(dateIn);
} catch (ParseException e) {
    Log.e(LOG_TAG, "Problem parsing the news time.", e);
}
  1. 首先通过 SimpleDateFormat 指定输入的时间格式,然后在 try/catch 区块中解析 (parse) 输入的时间,获得一个 Date 对象;
  2. 最后通过 SimpleDateFormat 指定输出的时间格式,并将上面获得的 Date 对象传入 format method,获得预期格式的时间字符串。

触摸反馈

之前的课程中提到,为视图提供触摸反馈,最简单的方法是设置视图的背景:

android:background="?android:attr/selectableItemBackground"

它实际上是应用了 R.attr 类提供的 Drawable 资源,在视图聚焦或点击 (focus/pressed) 状态下显示圆形涟漪的动画触摸反馈。常用的还有另外一个资源。

android:background="?android:attr/selectableItemBackgroundBorderless"

由于它是从 API Level 21 引入的,所以对于 minSdkVersion 在 API Level 21 以下的应用可以在 styles 中分开定义,在 BookListing App 中就是这么做的。它可以忽略视图的边界,在聚焦或点击 (focus/pressed) 时显示完整的圆形涟漪动画。这在一些不想由于显示视图边界而破坏界面完整性的场景很有帮助。


设置字体

字体 属于 Android 应用的一类资源,它可以像图片、音频等资源一样引用。例如在 NewsApp 中,新闻标题的首字母采用了 Hansa Gotisch 字体(来源:Font Meme),实现方法是在 res/font 目录下存放 TTF 文件,然后在 TextView 中设置 android:fontFamily 属性为对应的 TTF 文件名即可。


实战项目 7&8 BookListing 和 NewsApp 这两个应用的分享完毕,欢迎大家到我的 GitHub 交流,文中有遗漏的要点也可以提醒我,我很乐意解答。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,458评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,454评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,171评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,062评论 0 207
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,440评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,661评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,906评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,609评论 0 200
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,379评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,600评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,085评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,409评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,072评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,088评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,860评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,704评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,608评论 2 270

推荐阅读更多精彩内容