如何實作從API抓取資料顯示在列表頁(ListView)上
透過API取得資料顯示在App畫面,資料由後端的資料庫統一管理更新內容,確保使用者每次開啟App能夠看到最新的資料,並且將資料以列表的方式呈現在App上是很常見的設計,所以一直都有在想要寫實作這種功能的文章。
剛好最近很幸運能夠擔任Girls in Tech與CodePath合辦的Android 8週 bootcamp的其中一位講者,CodePath要求學員要繳交的其中一份作業剛好也是必須要實作出這些功能,就借花獻佛的整理一些內容成為一篇教學文章。
實作結果如下圖:
這篇教學將說明要實作下列幾項功能:
但若是客制化的UI元件如果在開發時程內來得及的話則不太建議使用第三方Library,原因是UI視覺的東西很容易在客戶看過之後希望工程師能再修改調整,使用別人的Library不是自己撰寫的程式內容有可能會比從頭刻元件還要花更多的時間。
a.首先在Gradle裡面加上
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相同)
a.首先在Gradle裡面加上
b. 實作AsyncHttpClient並發送API的Request
基本上網路連線的Library通常都會設計如果連線成功做什麼處理、連線失敗要做什麼處理的callback,所以就將上述建立的資料模型取值寫到API回傳結果成功的callback裡:
至於API回傳結果失敗的地方,可以自行設計要告訴使用者的UI,例如將錯誤訊息用Toast顯示出來,才不會讓使用者覺得怎麼App沒有任何反應
所以整理之後的API request的程式區塊可以寫成:
a.首先在Gradle裡面加上
b.顯示圖片的程式碼
使用Android Studio的Implement methods功能,選擇要實作的method,就會自動幫你補上缺少的method
於是就能得到一個自動產生的BaseAdapter基本架構
Context:
由於許多地方都要需要用到Context,像是:透過LayoutInflater產生列表時會需要有Context, 以及讀圖3rd-party library: Picasso也都需要有Context才可以使用。
資料結構:
傳入的資料結構要能夠讓每個ListView的item能夠透過getView()裡的position參數去取得該資料結構中的資料,所以要看每一個項目所設計的內容是只要顯示一列文字,還是要顯示很多項目:像是商品名稱、商品圖片、商品售價...等。如果項目內容只有要一行文字顯示的情形,可以傳入String[]的資料結構;如果是要傳多個資料在同一個項目裡,則可以用List<Map<String,String>的資料結構傳入,所以資料結構的部份要視需求而決定,沒有絕對的標準。
而本例是使用自行定義的Gson資料模型來做為資料結構。於是在建構式你可以寫成:
list_item_type1.xml
list_item_type2.xml
使用Android Studio按下Ctrl + O,選擇getItemViewType(int position)、getViewTypeCount()之後,按下OK就可以新增完成。
為了解決這個問題,後來發展了ViewHolder設計模式,原理是將項目layout中會使用到的UI元件,第一次生成的時候就存成cache,之後如果生成的layout已經存在就可以直接取出使用。
a.建立ViewHolder class, 假設有個item的layout具有一張圖片、文字標題以及說明文字
b.使用ViewHolder
完整程式碼:
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
剛好最近很幸運能夠擔任Girls in Tech與CodePath合辦的Android 8週 bootcamp的其中一位講者,CodePath要求學員要繳交的其中一份作業剛好也是必須要實作出這些功能,就借花獻佛的整理一些內容成為一篇教學文章。
實作結果如下圖:
這篇教學將說明要實作下列幾項功能:
一、使用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 ArrayListresults = 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 Loader、glide、Fresco...等等。而讀圖的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、GridView、Spinner,以及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
谢谢。
回覆刪除你好,
回覆刪除编译运行后,一直有“cz.msebera.android.httpclient.client.HttpResponseException: Not Found”这个错误提示,找不到问题所在,希望能给出点建议。
谢谢
可能還是要看到你的程式碼才會知道發生的原因
刪除不知你是否有在AndroidManifest.xml裡加上網路的權限呢?
刪除AndroidManifest中添加过权限了。
刪除这个错误提示是MainActivity中的network执行到onFailure中打印出来的错误,是个Toast提示。
建議可以先用postman
刪除(https://chrome.google.com/webstore/detail/postman/fhbjgbiflinjbdggehcddcbncdddomop?hl=zh-TW)來測試API是否正常
猜測有可能是傳入的api網址及參數有些格式欄位錯誤造成
谢谢,是接口的原因。
刪除我使用了ngrok做内网穿透,接口是使用Laravel 写的,配置了cors,解决接口跨域过滤问题。