實戰專案 7&8 : 從 Web API 獲取資料

HsuJin發表於2018-02-04

這篇文章分享我的 Android 開發(入門)課程 的第七個和第八個實戰專案:書籍列表應用和新聞應用。這兩個專案都託管在我的 GitHub 上,分別是 BookListingNewsApp 這兩個 Repository,專案介紹已詳細寫在 README 上,歡迎大家 star 和 fork。

這兩個實戰專案的主要目的是練習從 Web API 獲取應用資料,不過在實際 coding 的過程中使用了很多其它有意思的 Android 元件,這篇文章就逐個分享給大家。文章內容不會按應用的開發流程進行,各部分內容相對獨立,大家可以利用瀏覽器的查詢 (cmd/ctrl+F) 功能按需取用。為了精簡篇幅,文中的程式碼有刪減,請以 GitHub 中的程式碼為準。


SwipeRefreshLayout

實戰專案 7&8 : 從 Web API 獲取資料

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

實戰專案 7&8 : 從 Web API 獲取資料

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

實戰專案 7&8 : 從 Web API 獲取資料

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 會在使用者點選Enter鍵後獲取提交的文字;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

實戰專案 7&8 : 從 Web API 獲取資料

在 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 子項間新增分隔線

實戰專案 7&8 : 從 Web API 獲取資料

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

實戰專案 7&8 : 從 Web API 獲取資料

在 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,記錄了 RecyclerView 列表的每個 item 的展開和摺疊情況,CardView 展開時為真,摺疊時為假。因此,在展開某個位置的 CardView 後需要將該位置的 isCardExpanded 設為 true,摺疊後則設為 false。如何獲取一個與 RecyclerView 列表等長的 ArrayList 並將所有項預設為 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,僅在定義物件例項時指定為 ArrayList,這是因為 List 是介面,而 ArrayList 是 List 的具象類,當 App 需要重構程式碼 (refactor) 時,例如由 ArrayList 改為 LinkedList,僅在物件例項的定義處指定一個具象類即可,保持程式碼的靈活性。
  2. getItemCount 內,首先判斷當前 isCardExpanded 是否已有值,若無才對其賦值,並在賦值之前清除列表,最後通過 for 迴圈語句向 isCardExpanded 新增與 RecyclerView 列表等長的 item 並將所有項預設為 false。
  3. 事實上,對於 BookListing App 來說,RecyclerView 列表的 item 數目一直都是 10,但是這裡沒有將 isCardExpanded 硬編碼為長度為 10 的 ArrayList,保持良好的程式設計習慣。

先顯示文字,後顯示圖片

實戰專案 7&8 : 從 Web API 獲取資料

在 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。

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

實戰專案 7&8 : 從 Web API 獲取資料

在 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

實戰專案 7&8 : 從 Web API 獲取資料

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 呼叫各自的超級類的時機是不一樣的。


橫滑手勢捕捉

實戰專案 7&8 : 從 Web API 獲取資料

在 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,獲得預期格式的時間字串。

觸控反饋

實戰專案 7&8 : 從 Web API 獲取資料

之前的課程中提到,為檢視提供觸控反饋,最簡單的方法是設定檢視的背景:

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) 時顯示完整的圓形漣漪動畫。這在一些不想由於顯示檢視邊界而破壞介面完整性的場景很有幫助。


設定字型

實戰專案 7&8 : 從 Web API 獲取資料

字型 屬於 Android 應用的一類資源,它可以像圖片、音訊等資源一樣引用。例如在 NewsApp 中,新聞標題的首字母採用了 Hansa Gotisch 字型(來源:Font Meme),實現方法是在 res/font 目錄下存放 TTF 檔案,然後在 TextView 中設定 android:fontFamily 屬性為對應的 TTF 檔名即可。


實戰專案 7&8 BookListing 和 NewsApp 這兩個應用的分享完畢,歡迎大家到我的 GitHub 交流,文中有遺漏的要點也可以提醒我,我很樂意解答。

相關文章