當我們在APP中執行非同步作業時,常常需要一個可以監控下載進度的View,如下所示:
其中,List View 裏面的每個item view代表一個非同步作業,
每個item view都有一個progress bar顯示出其非同步作業的即時進度。
我們使用JAVA Thread來執行每一個非同步作業,而每個thread必須適時地去更新progress bar的進度值。
問題是,要如何才能讓每個thread 去更新各自item view中的progress bar?
再進一步思考,我們只需解決以下兩點就可以達到目的,
- 在thread作業進度改變時,能通知 progress bar變更其進度值
- 在其它thread中能變更 UI Thread(Main Thread)中的UI元件的狀態值
第二點容易多了,我們可以利用Handler的機制做到。
所以本文重點放在第一點,以及list view的 adapter機制。
目標:
我們的目標是開發如前圖的List View,其中每個list item view 都是自訂的,包含一個圖像、一個文字描述、一個progress bar和一個按鈕。
圖像無特別意義,只是為了排版美觀。文字描述用來區分每個作業。progress bar即時更新作業的進度。按鈕可以讓使用者針對作業的結果做進一步的操作,此例中我們只是顯示出作業的名稱。
為不把範例弄得太複雜,在這裏每個非同步作業不會做什麼有用的事,只會更新進度值、睡1秒鐘後再醒來更新進度,總共睡10秒鐘,之後結束作業。
@Override
public void run() {
Random random = new Random();
try {
Thread.sleep(random.nextInt(9)*1000);
progress = 0;
for (int i = 0; i < 10; i++) {
Thread.sleep(1000);
incrementProgress(10);
}
setProgress(100);
} catch (InterruptedException e) {
setProgress(0);
} catch (Exception generalEcc) {
setProgress(0);
} finally {
Thread.interrupted();
}
}
再來,我們自訂list item view的格式,
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="fill_parent"
android:layout_height="?android:attr/listPreferredItemHeight"
android:padding="6dip" >
<ImageView
android:id="@+id/icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_alignParentLeft="true"
android:layout_centerVertical="true"
android:layout_marginRight="6dip"
android:contentDescription="photo"
android:src="@android:drawable/ic_btn_speak_now" />
<Button
android:id="@+id/btnActive"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentRight="true"
android:layout_marginRight="6dip"
android:layout_centerVertical="true"
android:layout_marginLeft="6dip"
android:paddingLeft="10dp"
android:paddingRight="3dp"
android:gravity="center_vertical"
android:textSize="16sp"
android:text="SHOW" />
<ProgressBar
android:id="@+id/pbTask"
style="?android:attr/progressBarStyleHorizontal"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_toLeftOf="@id/btnActive"
android:layout_toRightOf="@id/icon"
android:layout_marginTop="6dp"
android:max="100"
android:progress="0"
android:progressDrawable="@drawable/custom_progressbar" />
<TextView
android:id="@+id/tvTaskDesc"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
android:layout_above="@id/pbTask"
android:layout_alignParentTop="true"
android:layout_alignWithParentIfMissing="true"
android:layout_toLeftOf="@id/btnActive"
android:layout_toRightOf="@id/icon"
android:layout_marginBottom="3dp"
android:text="My Task" />
</RelativeLayout>
主畫面的設計,
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="fill_parent"
android:layout_height="fill_parent"
android:layout_gravity="center_horizontal"
>
<LinearLayout
android:orientation="horizontal"
android:layout_width="fill_parent"
android:layout_height="wrap_content"
>
<Button android:id="@+id/btnGo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingLeft="20dp"
android:paddingRight="20dp"
android:onClick="btnGoClicked"
android:text="Go"
/>
</LinearLayout>
<ListView
android:id="@+id/android:list"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical" />
<TextView
android:id="@+id/empty"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="24sp"
android:text="NO DATA YET"
android:layout_gravity="left|center_vertical"
android:layout_centerInParent="true"
/>
</LinearLayout>
重點來了,List View 需要一個Adapter餵它資料,在這裏,每一個list item view 代表一個Task,
所以我們自訂一個Adapter,餵入的資料是一個Task array,
public MyAdapter(Context context,Task[] tasks){
this.context=context;
this.tasks=tasks;
inflater = (LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
}
每一個list item view 會與一個Task關聯起來,我們來看看item view 與 Task的那些部份有關聯,
List item | Task |
---|---|
@+id/tvTaskDesc | Task.getDesc() |
@+id/pbTask | Task.getProgress() |
item view 中的@+id/tvTaskDesc(TextView) 將會顯示Task的名稱(desc)。
@+id/pbTask(ProgressBar) 會依據Task的progress值來變更進度的顯示。
那progress bar什麼時候更新進度,又如何得知Task目前的進度呢?
更新progress bar的時機考量:
progress bar 在 Task的作業執行到某個時間點時需更新進度。
我們需要一個機制,可以在Task 的進度變更時通知progress bar 來更新其畫面。
在這裏我們採用listener的機制。
要能讓Task可以在適當的時機做通知,list item view 需向Task註冊一個listener,
當Task的進度變更時,Task會藉由這個listener通知 progress bar並執行畫面更新作業。
private void setProgress(int progress) {
this.progress = progress > 100 ? 100 : progress;
if (!listenerList.isEmpty()) {
for (Task.taskListener listener : listenerList)
listener.onProgressChanged(this.progress);
}
}
此外,因為listener的這個呼叫不是在UI Thread上的,
所以要更新progress bar就必須藉由 Handler機制,使之在UI Thread上進行progress值的變更。
this.l = new taskListener() {
@Override
public void onProgressChanged(final int progress) {
mHandler.post(new Runnable() {
@Override
public void run() {
pbTask.setProgress(progress);
}
});
}
};
到此我們似乎已經解決了之前所提的兩個問題了,
- 在thread作業進度改變時,能通知progress bar變更其進度值 (藉由listener機制)
- 在thread中能變更UI Thread(Main Thread)中的UI元件 (藉由Handler機制)
在繼續完成MyAdapter之前,還有一件事要考慮的,
list item view 的回收再利用:
在list view 中,list item view 可能會多到超出一個手機螢幕所可以顯示的,所以我們捲動list view,以看到未顯示的部份。
但在每次捲動中,list item view 一直在生成並與Task建立關聯,當item一多時,這會耗用許多的記憶體,而手機的記憶體並不是多到可以這樣地濫用。
所以,Android系統提供了一個機制來減少item view 的記憶體的使用,它會回收並再使用已生成的item view,一種pool的機制。
當某個item view 被移出螢幕的可視區時,這個item view就被系統回收並視需要再分配出去使用。
所以,假如一個list item view 在螢幕上可以看到十個item view 時(item 1 ~ item 10),
當我們往上捲動一個item view 時,item 1會上移出螢幕以顯示item 11,
這時item 1會被系統回收,並再分配出去以顯示item 11。
此時使用者看到的item 11 其實是原來的item 1,只是其中的資料被換成item 11的資料罷了。
當然這個例子簡化了許多,系統實際運作時並不是這樣單純,但其概念是相同的。
有了這個觀念,我們再來看Adapter中的getView()就會清楚許多,
@Override
public View getView(int position, View convertView, final ViewGroup parent) {
final ViewHolder viewHolder;
if (convertView == null) {
convertView = inflater.inflate(R.layout.custom_list_item, parent,false);
convertView.setTag(viewHolder);
} else {
viewHolder=(ViewHolder) convertView.getTag();
}
// ......
return convertView;
}// getView()
我們可以看到,當list view需要一個item view時,它會呼叫getView(),
在這裏面我們需要建構一個View 並回傳給list view使用。
承上所提,若系統傳入的convertView是空的,表示沒有任何的item view 可再利用,
那這時我們就要建構一個新的item view回傳回去;
否則我們只要將這個回收再利用的item view 中的資料清空並使之與新的資料關聯起來,再回傳回去就可以了。
在程式碼中,我們看到一個ViewHolder ,它是什麼?為什麼要使用它呢?
ViewHolder的使用
當每次item view 在與新的資料關聯時,都需去抓取其內的UI元件,以便使用新的資料更新UI元件。
比如說,item view 在與新的Task關聯時,Task的名稱需設到item view 的 @+id/tvTaskDesc(TextView),
一般作法是
this.tvTaskDesc = (TextView) convertView.findViewById(R.id.tvTaskDesc);
this.tvTaskDesc.setText(task.getDesc());
可以看到,它需先 findViewById(),找到後才能設task的名稱。
想想看,若每次回收再利用的item view 都要做一次的findViewById(),而每次找到的都是相同的UI元件,那是多麼浪費CPU時間的。
所以我們用View Holder機制,在新生成item view時,找到UI元件並記下來,
下次回收使用時,直接從這抓取,就不用再使用findViewById()找一次了,
每次的回收再利用都可以省下一個method call的時間(和空間)。
一般我們都把這個View Holder 放進item view的tag中,下次要用就從tag拿就行了。
convertView.setTag(viewHolder);
viewHolder=(ViewHolder) convertView.getTag();
final static class ViewHolder {
public ImageView imageType;
public TextView tvTaskDesc;
public ProgressBar pbTask;
public Button btnAction;
public ViewHolder(View convertView){
this.imageType = (ImageView) convertView
.findViewById(R.id.icon);
this.tvTaskDesc = (TextView) convertView
.findViewById(R.id.tvTaskDesc);
this.pbTask = (ProgressBar) convertView
.findViewById(R.id.pbTask);
this.btnAction = (Button) convertView
.findViewById(R.id.btnActive);
}
最後一點我們要談是,listener的註冊關係的存亡。
listener的註冊關係:
在list item view 與Task發生關連時,item view生成listener並註冊到Task;
當item view 被回收再利用時,其與前一個Task的 listener註冊關係也得切斷,否則一個Task會通知多個item view,會造成進度更新錯亂的現象。
切斷註冊關係的時機點就在於 item view與另一個Task發生關連時,此時必須先移掉先前的listener,再註冊新的listener。
就好像要結婚也得要先和 前夫/妻 先離婚(如果曾結過婚的話),否則就會發生問題。
public void setNewTask(Task t) {
removeListener();
this.linkTask=t;
this.tvTaskDesc.setText(t.getDesc());
this.pbTask.setProgress(t.getProgress());
addListener();
}
到此,我們用了簡單和直覺的方式來實作這個常用的功能,
最後,完整的程式碼放在 這裏
--ismsor