almost 10 years ago

當我們在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秒鐘,之後結束作業。

Task.java
@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的格式,

custom_list_item.xml
<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>

主畫面的設計,

activity_main.xml
<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,

MyAdapter constructor
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並執行畫面更新作業。

Task.java
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值的變更。

MyAdapter
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()就會清楚許多,

MyAdapter
@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();

ViewHolder
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。
就好像要結婚也得要先和 前夫/妻 先離婚(如果曾結過婚的話),否則就會發生問題。

ViewHolder
public void setNewTask(Task t) {
            removeListener();
            this.linkTask=t;
            this.tvTaskDesc.setText(t.getDesc());
            this.pbTask.setProgress(t.getProgress());
            addListener();
}

到此,我們用了簡單和直覺的方式來實作這個常用的功能,
最後,完整的程式碼放在 這裏

--ismsor