如何實作從API抓取資料顯示在列表頁(ListView)上

透過API取得資料顯示在App畫面,資料由後端的資料庫統一管理更新內容,確保使用者每次開啟App能夠看到最新的資料,並且將資料以列表的方式呈現在App上是很常見的設計,所以一直都有在想要寫實作這種功能的文章。

剛好最近很幸運能夠擔任Girls in TechCodePath合辦的Android 8週 bootcamp的其中一位講者,CodePath要求學員要繳交的其中一份作業剛好也是必須要實作出這些功能,就借花獻佛的整理一些內容成為一篇教學文章。

實作結果如下圖:



這篇教學將說明要實作下列幾項功能:
  1. 使用3rd-Party Library: 網路連線、讀取/顯示圖片、解析JSON
  2. ListView:自訂Adapter、顯示不同類型的item
  3. API回傳的JSON內容顯示到列表裡

一、使用3rd-Party Library

專精於某項功能的第三方Library開發團隊,大部份的情況會比獨自一個工程師在實作上考慮到更多的面向,像是網路連線時會有許多的錯誤判斷處理。加上開發的時程有限,建議如果像是網路連線及讀取顯示圖片這種屬於App基礎建設的部份,可以使用第三方的Library就好。

但若是客制化的UI元件如果在開發時程內來得及的話則不太建議使用第三方Library,原因是UI視覺的東西很容易在客戶看過之後希望工程師能再修改調整,使用別人的Library不是自己撰寫的程式內容有可能會比從頭刻元件還要花更多的時間。

1.解析JSON

這邊使用的是Google推出用來方便建立與解析JSON格式的Java Library: Gson。雖然就算沒有使用Gson也是可以自行使用getJSONObject、getJSONArray如同剝洋蔥一層一層慢慢解析,但如果API的設計非常複雜的時候,一層一層慢慢解析就會變得相當費工,使用Gson則是可以更方便用「點」的方式進入一層一層的解析出JSON裡的值。

a.首先在Gradle裡面加上
dependencies {
  compile 'com.google.code.gson:gson:2.6.2'
}

b.透過API回傳的Json格式定義資料模型的class
假設我們有個api回傳的JSON如下:
我們觀察這個JSON可以看到,是包含了兩個物件的JSON格式:其中一個物件是int型態,可以透過key=page取得另一個是JsonArray,可以透過key=results取得,而這個JsonArray裡面所包了許多個物件正是我們要取得的資料。

假設我們只要取得的資料為:poster_path、overview、title。所以我們建立的Gson資料模型的class可以定義如下:(注意: 命名要與JSON的key相同、資料型態要與JSON的value相同)
public class MovieGson {
    public int page;
    public ArrayList results = new ArrayList<>();

    public class Results {
        public String poster_path; 
        public String overview;
        public String title;
    }
}

2.網路連線

比較知名的大致上是Google官方推薦的Volley,或是也很多人使用的OkHttp,但由於這篇文章是簡介如何使用第三方網路連線的Library而已,所以本例使用CodePath教學文章裡介紹的Android Asynchronous Http Client

a.首先在Gradle裡面加上
dependencies {
  compile 'com.loopj.android:android-async-http:1.4.9'
}

b. 實作AsyncHttpClient並發送API的Request
AsyncHttpClient client = new AsyncHttpClient();
client.get("YOUR_API_ADDRESS", new AsyncHttpResponseHandler() {

    @Override
    public void onSuccess(int statusCode, Header[] headers, byte[] response) {
        // 當取得API回傳結果成功時要做的處理

    }

    @Override
    public void onFailure(int statusCode, Header[] headers, byte[] errorResponse, Throwable e) {
        // 當取得API回傳結果失敗時要做的處理
    }
});

基本上網路連線的Library通常都會設計如果連線成功做什麼處理、連線失敗要做什麼處理的callback,所以就將上述建立的資料模型取值寫到API回傳結果成功的callback裡:
Gson gson = new Gson();
mData = gson.fromJson(responseString, MovieGson.class); //將資料模型存到Global變數mData裡

至於API回傳結果失敗的地方,可以自行設計要告訴使用者的UI,例如將錯誤訊息用Toast顯示出來,才不會讓使用者覺得怎麼App沒有任何反應
Toast.makeText(MainActivity.this, throwable.toString(), Toast.LENGTH_SHORT).show();

所以整理之後的API request的程式區塊可以寫成:
AsyncHttpClient client = new AsyncHttpClient();
client.get("YOUR_API_ADDRESS", new AsyncHttpResponseHandler() {

    @Override
    public void onSuccess(int statusCode, Header[] headers, byte[] response) {
        // 當取得API回傳結果成功時要做的處理
        Gson gson = new Gson();
        mData = gson.fromJson(responseString, MovieGson.class); //將資料模型存到Global變數mData裡
    }

    @Override
    public void onFailure(int statusCode, Header[] headers, byte[] errorResponse, Throwable e) {
        // 當取得API回傳結果失敗時要做的處理
        Toast.makeText(MainActivity.this, throwable.toString(), Toast.LENGTH_SHORT).show();
    }
});

3.讀取/顯示圖片

現在第三方讀圖的Library真的非常的多,像是Universal Image LoaderglideFresco...等等。而讀圖的Library通常會幫你處理記憶體與cache的問題,而且還有一些圖片顯示效果的的部份,像是切圖、圓角、未載入時的預設圖、讀圖錯誤顯示圖,甚至重新讀取圖片的機制...等。本例使用CodePath教學文章裡介紹的Picasso

a.首先在Gradle裡面加上
dependencies {
  compile 'com.squareup.picasso:picasso:2.5.2'
}

b.顯示圖片的程式碼
String imageUri = "圖片位址"; //從API解析出來的圖片位址
ImageView image = (ImageView) findViewById(R.id.image);
Picasso.with(context).load(imageUri).into(image);

二、實作ListView Adapter

雖然現在Android已經推出新的進階版ListView: RecyclerView,但對於初學者而言,RecyclerView的難度比較高一些,所以這邊還是先以ListView來做說明。 無論是ListView、GridViewSpinner,以及RecyclerView,都需要實作一個Adapter,用來將一群資料匯集後,轉換成可以在另一UI元件中重復顯示的功能。

1.新增一個名為MyAdapter的Class

由於這邊我們必須自訂要傳入的資料結構,所以必須繼承BaseAdapter來實作客制化的Adapter,這個時候會發現BaseAdapter還缺少幾個實作的method而出現紅色警告

使用Android Studio的Implement methods功能,選擇要實作的method,就會自動幫你補上缺少的method
於是就能得到一個自動產生的BaseAdapter基本架構

2.新增Adapter建構式

將這個ListView想要顯示的內容或是參數透過Java的建構式傳入,而Adapter在建立的時候,Context及要顯示的資料結構是必須傳入的資料,而日後若有其他的需求也可以再新增以建構式的方式傳入使用。

Context:
由於許多地方都要需要用到Context,像是:透過LayoutInflater產生列表時會需要有Context, 以及讀圖3rd-party library: Picasso也都需要有Context才可以使用。

資料結構:
傳入的資料結構要能夠讓每個ListView的item能夠透過getView()裡的position參數去取得該資料結構中的資料,所以要看每一個項目所設計的內容是只要顯示一列文字,還是要顯示很多項目:像是商品名稱、商品圖片、商品售價...等。如果項目內容只有要一行文字顯示的情形,可以傳入String[]的資料結構;如果是要傳多個資料在同一個項目裡,則可以用List<Map<String,String>的資料結構傳入,所以資料結構的部份要視需求而決定,沒有絕對的標準。

而本例是使用自行定義的Gson資料模型來做為資料結構。於是在建構式你可以寫成:
public class MyAdapter extends BaseAdapter {
    private Context mContext;
    private MovieGson mData;
 
    public MyAdapter(Context context, MovieGson data) {
        this.mContext = context;
        this.mData = data;
    }
}

3.製作列表項目的Layout

在ListView如果不是使用預設只有一行文字的項目Layout,就要自行實作。本例是要實作兩種不同類型的項目Layout,一個是具有圖加文的Layout,另一個是只有全版面的圖片。

list_item_type1.xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal"
    android:paddingBottom="10dp"
    android:paddingTop="10dp">

    <ImageView
        android:id="@+id/image_poster"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_weight="1" />

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:orientation="vertical"
        android:paddingLeft="10dp">

        <TextView
            android:id="@+id/text_title"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:textSize="20sp"
            android:textStyle="bold" />

        <TextView
            android:id="@+id/text_overview"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content" />
    </LinearLayout>

</LinearLayout>

list_item_type2.xml
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingBottom="10dp"
    android:paddingTop="10dp">

    <ImageView
        android:id="@+id/image_poster"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_centerInParent="true" />

</RelativeLayout>

4.列表顯示不同項目Layout

一般的列表項目所顯示的Layout都是同一種樣式,如果要顯示在不同的位置顯示不同的Layout的時候,可以新增兩個callback function: getItemViewType(int position)、getViewTypeCount()來達成判斷不同樣式的功能。

使用Android Studio按下Ctrl + O,選擇getItemViewType(int position)、getViewTypeCount()之後,按下OK就可以新增完成。

5.使用ViewHolder Pattern

由於ListView是將螢幕上可以顯示有幾個項目的Layout重覆使用,只要移出畫面的item就會被回收,進到畫面的item才會繪制,如此達到節省記憶體的功效。但是在畫面捲動的時候,每個進到畫面中的item都不斷的被繪製,而繪製的過程使用findViewById()的方式將項目列表layout的UI元件讀入,就會有效能低落的問題。

為了解決這個問題,後來發展了ViewHolder設計模式,原理是將項目layout中會使用到的UI元件,第一次生成的時候就存成cache,之後如果生成的layout已經存在就可以直接取出使用。

a.建立ViewHolder class, 假設有個item的layout具有一張圖片、文字標題以及說明文字
private static class ViewHolder {
    ImageView imagePoster;
    TextView textTitle;
    TextView textOverview;
}

b.使用ViewHolder
@Override
public View getView(final int position, View convertView, ViewGroup parent) {
    View view = convertView;
    Holder holder;

    if (view == null) {
        view = LayoutInflater.from(mContext).inflate(R.layout.list_item, parent, false);
        holder = new Holder();
        holder.imagePoster = (ImageView) view.findViewById(R.id.image_poster);
        holder.textTitle = (TextView) view.findViewById(R.id.text_title);
        holder.textOverview = (TextView) view.findViewById(R.id.text_overview);

        view.setTag(holder);

    } else {
        holder = (Holder) view.getTag();
    }

    return view;
}

到目前為止,已經可以得到BaseAdapter基本架構:

public class MyAdapter extends BaseAdapter{
    private Context mContext;
    private MovieGson mData;
 
    public MyAdapter(Context context, MovieGson data) {
        this.mContext = context;
        this.mData = data;
    }

    @Override
    public int getCount() {
        return 0;
    }

    @Override
    public Object getItem(int position) {
        return null;
    }

    @Override
    public long getItemId(int position) {
        return 0;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        View view = convertView;
        Holder holder;

        if (view == null) {
            view = LayoutInflater.from(mContext).inflate(R.layout.list_item, parent, false);
            holder = new Holder();
            holder.imagePoster = (ImageView) view.findViewById(R.id.image_poster);
            holder.textTitle = (TextView) view.findViewById(R.id.text_title);
            holder.textOverview = (TextView) view.findViewById(R.id.text_overview);

            view.setTag(holder);

        } else {
            holder = (Holder) view.getTag();
        }

        return view;
    }

    @Override
    public int getItemViewType(int position) {
        return super.getItemViewType(position);
    }

    @Override
    public int getViewTypeCount() {
        return super.getViewTypeCount();
    }

    private static class ViewHolder {
        ImageView imagePoster;
        TextView textTitle;
        TextView textOverview;
    }
}

三、API回傳的JSON內容顯示到列表裡

經過一連串的手續之後得到了上述的BaseAdapter基本架構,接下來要做的就是修改BaseAdapter裡每個callback function的內容,以及如何使用Adapter顯示在ListView。

1.修改getCount()

這個callback所代表的意義是,這個ListView將會產生多少個item,所以你希望要產生多少個項目就寫在回傳的int數字。由於是來自於API取得後的資料,所以可以使用Gson資料模型取得的個數後傳回,如此就能夠達成變動的item數量。並且還要考量如果API取得資料失敗時的處理,就將數量設為0才不會導致App閃退。
@Override
public int getCount() {

    if (mData != null) { //如果有取得api回傳後的資料
        return mData.results.size();
    } else {
        return 0; //設為0則ListView沒有產生item
    }
}

2.修改getViewTypeCount()

這個callback所代表的意義是,這個ListView有多少種不同類型的item layout,所以可以直接填入一個int即可。
@Override
public int getViewTypeCount() {
    return 2; //這個ListView有兩種類型的item layout
}

3.修改getItemViewType(int position)

這個callback所代表的意義是,這個ListView要在哪個條項目的位置,產生哪種類型的Layout的int代號回傳。所以可以設定一些條件判斷,來決定在哪個位置要回傳哪個Layout代號。
@Override
public int getItemViewType(int position) {

    if (mData.results.get(position).vote_average >= 5.0f) {
        return 0; //產生類型0的layout
    } else {
        return 1; //產生類型1的layout
    }
}

4.修改getView(int position, View convertView, ViewGroup parent)

這個callback所代表的意義是,透過自訂的item layout來產生每個item的View。所以當使用者在滑動列表時,這個callback也是不斷的被呼叫使用,於是要將ViewHolder的實作( new出Instance,使用setTag()儲存、使用getTag()取用)、判斷不同類型生成(LayoutInflater)不同的item layout、設定item layout的元件資源,以及設定item layout元件顯示的資料通通都寫在這個callback裡。
@Override
public View getView(int position, View convertView, ViewGroup parent) {
    View view = convertView;
    Holder holder;
    int type = getItemViewType(position);

    /* 使用ViewHolder */
    if (view == null) {

        //產生不同的item layout
        if(type == 0){
          view = LayoutInflater.from(mContext).inflate(R.layout.list_item_type1, parent, false);
        }else{
          view = LayoutInflater.from(mContext).inflate(R.layout.list_item_type2, parent, false);
        }

        holder = new Holder();
        holder.imagePoster = (ImageView) view.findViewById(R.id.image_poster);
        holder.textTitle = (TextView) view.findViewById(R.id.text_title);
        holder.textOverview = (TextView) view.findViewById(R.id.text_overview);

        view.setTag(holder);

    } else {
        holder = (Holder) view.getTag();
    }
    
    /* 設定不同的item layout */
    if (type == 0) {

        //設定item UI元件的顯示資料
        holder.textTitle.setText(mData.results.get(position).title);
        holder.textOverview.setText(mData.results.get(position).overview);

        Picasso.with(mContext).load(getImageUrl(mData.results.get(position).poster_path))
                     .placeholder(R.mipmap.img_placeholder).into(holder.imagePoster);

            //設定item的click listener
            view.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    gotoDetailActivity(position); //前往某個Activity(自行修改成要觸發的行為)
                }
            });
    } else {

        //設定item UI元件的顯示資料
        Picasso.with(mContext).load(getImageUrl(mData.results.get(position).backdrop_path))
                     .placeholder(R.mipmap.img_placeholder).into(holder.imagePoster);

        //設定item的click listener
        view.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
               gotoYoutubeActivity(position); //前往某個Activity(自行修改成要觸發的行為)
            }
        });
    }

    return view;
}

5.使用Adapter顯示在ListView

當Adapter已經都設計好之後,最後的步驟就是傳入自訂建構式的相關參數來建立Adapter的Instance,然後在顯示ListView的Activity或Fragment透過SetAdapter的方式傳入Adapter的Instance。
public class MainActivity extends AppCompatActivity {

    private ListView mListView;
    private MyAdapter mAdapter;
    private MovieGson mData;

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

    private void setList() {
        mListView = (ListView) findViewById(R.id.list); //產生ListView的元件資源
        mAdapter = new MyAdapter(this, mData); //建立Adapter的Instance
        mListView.setAdapter(mListAdapter); //傳入Adapter的Instance
    }
}

完整程式碼:
https://github.com/ukyo99999/CodePathAssignment1

Reference:
https://developer.android.com/reference/android/widget/Adapter.html
http://guides.codepath.com/android/Using-Android-Async-Http-Client
http://guides.codepath.com/android/Displaying-Images-with-the-Picasso-Library
http://guides.codepath.com/android/Using-an-ArrayAdapter-with-ListView#improving-performance-with-the-viewholder-pattern

留言

  1. 你好,
    编译运行后,一直有“cz.msebera.android.httpclient.client.HttpResponseException: Not Found”这个错误提示,找不到问题所在,希望能给出点建议。
    谢谢

    回覆刪除
    回覆
    1. 可能還是要看到你的程式碼才會知道發生的原因

      刪除
    2. 不知你是否有在AndroidManifest.xml裡加上網路的權限呢?

      刪除
    3. AndroidManifest中添加过权限了。
      这个错误提示是MainActivity中的network执行到onFailure中打印出来的错误,是个Toast提示。

      刪除
    4. 建議可以先用postman
      (https://chrome.google.com/webstore/detail/postman/fhbjgbiflinjbdggehcddcbncdddomop?hl=zh-TW)來測試API是否正常
      猜測有可能是傳入的api網址及參數有些格式欄位錯誤造成

      刪除
    5. 谢谢,是接口的原因。
      我使用了ngrok做内网穿透,接口是使用Laravel 写的,配置了cors,解决接口跨域过滤问题。

      刪除

張貼留言

這個網誌中的熱門文章

ISO 27001 LA 主導稽核員 考照心得

Android如何實作強制App版本更新