1. 程式人生 > >ListView優化機制及滑動時資料時出現的資料錯亂重複問題

ListView優化機制及滑動時資料時出現的資料錯亂重複問題

該篇內容主要是記錄我在實際開發中遇到的ListView滑動時資料錯亂的幾種情況,以及解決方法。在進行ListView滑動時資料錯亂問題討論之前會對ListView所謂的<優化>進行說明。文章末尾分享了幾篇文章,增強對ListView使用以及Adapter優化的理解,其中有對adapter優化方法的耗時測試的介紹。

Getting Start

遇到過的ListView在滑動時資料錯亂的幾種情況

  • Listview滑動後,圖片(/背景色)重複混亂(非非同步載入時)
  • Listview選取checkbox後,再滑動時,出現checkbox選取錯位問題
  • ListView非同步載入圖片時,圖片顯示重複錯亂

這裡所說的"重複混亂"是指:在滑動list的時候,會看到某行本不該顯示卻重複顯示了其他行的資料(根據情況的不同,資料可以是文字,checkbox的選中狀態,圖片,背景色等等...),而之所以讓人感覺到混亂或者說錯亂是因為這些item的重複現象有時候看似沒有什麼規律可尋。

在進行問題重現之前,先有必要對處理資料與檢視顯示的adapter類以及ViewHolder模式進行深入理解:

Adapters and Holder Pattern

adapter是資料與listview檢視顯示之間的橋樑:

“An adapter manages the data model and adapts it to the individual rows in the list view. An adapter extends theBaseAdapter

class.

The adapter would inflate the layout for each row in itsgetView() method and assign the data to the individual views in the row.

The adapter is assigned to theListView via thesetAdapter method on theListView object.”

adapter中最重要的方法非getView()莫屬,listview每一行的顯示都會呼叫getView()方法,通過getView()方法將每一行要顯示的資料指定給相應的view。

getView()通常的寫法如下:

 private class ViewHolder{
        private TextView brandEnNameTv;
        private TextView brandChNameTv;
        private CheckBox followCheckBox;
    }

    @Override
    public View getView(int i, View convertView, ViewGroup viewGroup) {

        ViewHolder viewHolder = null;

        if(null == convertView){
          
            LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
            convertView = inflater.inflate(R.layout.item_testdisorderitem,null);

            viewHolder = new ViewHolder();
            viewHolder.brandChNameTv = (TextView) convertView.findViewById(R.id.item_chName_txt);
            viewHolder.brandEnNameTv= (TextView) convertView.findViewById(R.id.item_enName_txt);

            convertView.setTag(viewHolder);

        }else {
           
            viewHolder = (ViewHolder) convertView.getTag();
        }

        //set data

        return convertView;
    }
                                                                          程式碼片段1.1

以上這種寫法listview進行了優化,對比與以下這種方式:

     @Override
    public View getView(int i, View view, ViewGroup viewGroup) {

        LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        View rowview = inflater.inflate(R.layout.item_testdisorderitem,null);

        TextView brandChNameTv = (TextView) rowview.findViewById(R.id.item_chName_txt);
        TextView brandEnNameTv= (TextView) rowview.findViewById(R.id.item_enName_txt);

        //set data
        
        return rowview;
    }
                                                                          程式碼片段1.2

具體是怎麼優化的以及優化了什麼呢?

從xml佈局檔案裡inflate的每一個view都會產生一個java物件(eg:View view):

View view = inflater.inflate(R.layout.item_testdisorderitem, null);

inlating 佈局檔案和建立java物件對時間和記憶體的消耗都是昂貴的。

除此之外,使用findViewById()方法也相對地耗時。

為了讓listview減少在時間和記憶體上的消耗,Android提供了convertView引數(getView方法的第二個引數,不一定都叫convertView,個人認為叫rowView更好)來實現這一優化。當用戶滑動列表時,原先可視的item被滾出螢幕變得不可視,而代表該行的java物件可以被新的可視行復用。也就是說如果列表在手機螢幕中一屏可見的行有7行,當第一行滑出螢幕時,底部新滑出來的第8行可以複用第1行的java物件(即通過item佈局inflate出來的view),Android已經把第一行的佈局快取起來,作為可以複用的rowview:

有關ListView優化機制及滑動時資料錯亂有關問題的討論

如果Android已經判定某行不可視,那麼Android允許adapter的getView()方法通過convertView引數來複用相關的view。adapter可以給包含在converView中的各檢視指定新的資料(給row中的各個views assign new data)這樣就避免了顯示一行就要inflate xml file以及建立新的java物件。

如果Android不能複用某行的話,Android System就會返回null給convertView。第一次顯示列表的時候,螢幕中顯示可見的列表項,這時候每行返回的convertView都是null。當向上滑動列表後,有些行被滑出螢幕,convertView(/rowView)不為空,因為有了可以複用的row。所以通過判斷converView是否為空來處理何時複用,當convertView(/rowView)為空時才進行inflate xml file and create new java object,如果不為空,直接通過convertView(/rowView)來findViewById獲取row裡的各個view。這時候的程式碼可能的樣子:

@Override
    public View getView(int i, View convertView, ViewGroup viewGroup) {

        ViewHolder viewHolder = null;

        if(null == convertView){
           
            LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
            convertView = inflater.inflate(R.layout.item_testdisorderitem,null);

        }
        
        TextView brandChNameTv = (TextView) convertView.findViewById(R.id.item_chName_txt);
        TextView brandEnNameTv= (TextView) convertView.findViewById(R.id.item_enName_txt);

        //set data

        return convertView;
    }
                                                                          程式碼片段1.3

之前我們說過除了inlating 佈局檔案和建立java物件對時間和記憶體的消耗都是昂貴的。使用findViewById()方法也相對地耗時。我們知道每個列表項都會呼叫getView方法,如果執行<程式碼片段1.3>那麼每顯示一個列表項rowView就要呼叫findViewById來查詢各個view。多次findViewById不僅增加了時間消耗,也建立了更多的java物件,從而造成了expensive with regards to time and memory consumption。

為了解決這個問題,引入了View Holder Pattern

以下對ViewHolder的描述來自於該文章Using lists in Android(ListView) 8.4.節的翻譯:

View Holder pattern的使用減少了adapter中對findViewById()的呼叫

ViewHoler類是adapter裡自定義的一個(靜態)內部類,他持有佈局檔案中相關view的引用。該ViewHolder的引用通過setTag()方法作為一個tag被指派給row view(convertView)。

如果我們接受了一個convertview物件,我們可以通過getTag()方法獲取到ViewHolder的例項,然後經由該ViewHolder的引用指定新的屬性給Views。

雖然這聽起來很複雜,但是使用這種方式比使用findViewById在速度上提高了大約15%。

view holder pattern的目的即為減少findViewById()的呼叫次數,因為findViewById()這個方法比較耗時,至於兩者之間的比較可以自行測試,測試方法可以參考農民伯伯的

因此有了現在的這種模型:

 private class ViewHolder{
        private TextView brandEnNameTv;
        private TextView brandChNameTv;
        private CheckBox followCheckBox;
    }


    @Override
    public View getView(int i, View viewrow, ViewGroup viewGroup) {

        ViewHolder viewHolder = null;

        if(null == viewrow){
            
            LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
            viewrow = inflater.inflate(R.layout.item_testdisorderitem,null);

            viewHolder = new ViewHolder();
            viewHolder.brandChNameTv = (TextView) viewrow.findViewById(R.id.item_chName_txt);
            viewHolder.brandEnNameTv= (TextView) viewrow.findViewById(R.id.item_enName_txt);

            viewrow.setTag(viewHolder);

        }else {
            
            viewHolder = (ViewHolder) viewrow.getTag();
        }

        BrandItemInfo brandItemInfo = (BrandItemInfo) getItem(i);
        viewHolder.brandChNameTv.setText(brandItemInfo.getBrandChName());
        viewHolder.brandEnNameTv.setText(brandItemInfo.getBrandEnName());

        return viewrow;
    }
                                                                           程式碼片段1.4

當convertView為空時,建立ViewHolder,通過viewHolder.brandChNameTv = (TextView) convertView.findViewById(R.id.item_chName_txt);來持有佈局檔案中view的引用。通過setTag(Object)方法把convertView和viewHolder關聯起來。當接收到一個converView後,通過getTag(Object)方法獲取到viewHolder。這樣不僅使用convertView做到了複用現有的views的目的,同時和convertView“繫結的”viewHolder也做到了被複用。

ListView 滑動時圖片(或背景色)重複混亂

在討論圖片重複問題之前,先來看看背景色重複的問題:

想要達到的效果:品牌列表的前三項的背景色設定為磚紅色,剩餘其他項的背景色為預設色。

實際效果:滑動列表後,後面應該顯示預設背景色的item也會出現磚紅色背景

背景色重複的效果圖如下:

有關ListView優化機制及滑動時資料錯亂有關問題的討論

通過程式碼來進一步分析,下面是處理列表資料顯示的adapter類名為“TestDisorderListAdapter”繼承BaseAdapter。brandInfoList是一個列表,包含了一個個BrandItemInfo物件(data modle):

package com.aliao.myandroiddemo.adapter;

import android.content.Context;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.CheckBox;
import android.widget.TextView;

import com.aliao.myandroiddemo.R;
import com.aliao.myandroiddemo.domain.BrandItemInfo;

import java.util.List;

/**
 * Created by liaolishuang on 14-3-31.
 */
public class TestDisorderListAdapter extends BaseAdapter{

    private Context context;
    private List<BrandItemInfo> brandInfoList;
    private final String TAG = "disorderlist";

    public TestDisorderListAdapter(Context context, List<BrandItemInfo> list){

        this.context = context;
        brandInfoList = list;

    }

    @Override
    public int getCount() {
        return brandInfoList.size();
    }

    @Override
    public Object getItem(int i) {
        return null != brandInfoList?brandInfoList.get(i):null;
    }

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

    private class ViewHolder{
        private TextView brandEnNameTv;
        private TextView brandChNameTv;
        private CheckBox followCheckBox;
    }

    @Override
    public View getView(int i, View view, ViewGroup viewGroup) {

        ViewHolder viewHolder = null;

        if(null == view){
            
            LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
            view = inflater.inflate(R.layout.item_testdisorderitem,null);

            viewHolder = new ViewHolder();
            viewHolder.brandChNameTv = (TextView) view.findViewById(R.id.item_chName_txt);
            viewHolder.brandEnNameTv= (TextView) view.findViewById(R.id.item_enName_txt);

            view.setTag(viewHolder);

        }else {
           
            viewHolder = (ViewHolder) view.getTag();
        }

        BrandItemInfo brandItemInfo = (BrandItemInfo) getItem(i);
        viewHolder.brandChNameTv.setText(brandItemInfo.getBrandChName());
        viewHolder.brandEnNameTv.setText(brandItemInfo.getBrandEnName());

        if(i < 3){
            view.setBackgroundColor(context.getResources().getColor(R.color.coupletwo));
        }
       
        return view;
    }

}

為了設定品牌列表的前三項的背景色,在getView()方法中加入程式碼

if(i < 3){
            view.setBackgroundColor(context.getResources().getColor(R.color.coupletwo));
        }

i是代表每一行的position,由0開始。當向上滑動列表時,i的值隨著滑動行的改變而遞增,沒有任何問題,但是為什麼只有i<3的情況才改變顏色,其他項還會有背景色的改變呢?

在Adapters and Holder Pattern部分我們已經瞭解了listview的快取優化機制,滾出螢幕的檢視會被快取下來並被複用。我們以為只要設定i<3就可以讓前3項變色,其他項自然就是背景色,卻忽略了,除了前三項之外的某些行會去複用前三項的檢視因此也就會有相同的背景色。所以當i>=3對應的某些行由於複用了i<3對應的行,造成了背景色也為磚紅色。

解決方法就是必須對i不小於3的情況進行處理,把它設定為背景色即:

if(i < 3){
            view.setBackgroundColor(context.getResources().getColor(R.color.coupletwo));
        }else{
            view.setBackgroundColor(context.getResources().getColor(R.color.background));
        }
對於圖片重複的問題,其實是一樣的道理,舉這麼個例子:

評論列表中會顯示頭像和評論內容,從手機介面端讀取評論內容,其中的一個欄位是使用者頭像的下載地址,因為有的使用者有上傳頭像有的沒有,如果使用者沒有上傳頭像,那麼返回的使用者頭像的欄位為空字串。在getView()裡面就需要判斷當用戶頭像不為空字串的時候去設定頭像,否則為預設頭像,該預設頭像由應用程式本地儲存的圖片,在xml檔案的ImageView裡設定了。正確的程式碼片段為:

       if(!brandItemInfo.getBrandImage().equal("")){
           //根據url loadImage
       }else{
           viewHolder.brandLogo.setImageResource(R.drawable.ic_default_head);
       }       
如果不進行else處理,那麼原先顯示預設頭像的imageView在list滑動後會重複顯示其他頭像的圖片。

ListView滑動後出現checkbox選取錯位

有關ListView優化機制及滑動時資料錯亂有關問題的討論

我遇到過的checkbox選取錯位是列表上有checkbox,選取某個checkbox為選中狀態,然後滑動列表發現其他未選擇過的checkbox也變成了選中狀態。重現該問題只需要在item的xml file中加入checkbox,然後java程式碼裡不對checkbox做任何處理,測試時直接選中某個checkbox,滑動列表就會看到某些行會重複出現checkbox被選中,這種情況聽起來和上一節所說的listview快取優化問題引起重複錯亂如出一轍(當然這只是我重現問題最簡單的方式)。但是之所以把checkbox單獨拿出來說是因為他與上述的問題並不完全相同,它涉及到列表項上的某個view狀態的改變會影響到與該列表項對應的資料物件的改變。滑動list後checkbox選中狀態出現問題,是由於rowview和物件一一對應,物件裡的狀態值沒有相應改變。

在列表上像checkbox、toglebutton這種控制元件,它的選取狀態會影響到資料的變化。例如在列表項上的checkbox,如果是選中後,該列表項對應的物件裡的資料就會發生改變。這就涉及到資料模型與列表項之間的"通訊",當列表項上的checkbox狀態改變,要相應地修改改行對應物件的資料。

The row can also containviews which interact with the underlying data model via the adapter. For example, you can have aCheckbox in your row layout and if theCheckbox is selected, the underlying data is changed.

Frequently you need to select items in yourListView. As the row of theListView are getting recycled you cannot store the selection on theView level.

To persist the selection you have to update your data model with the selected state.

To update the data model in yourListView you define your ownAdapter class. In this adapter class you attach a listener to theView which is responsible for selecting the model element. If selected you update the state in the model which you can add as a tag to the View to have access to it.”

我們在處理listview帶有像checkbox,toglebutton這類控制元件的時候,需要監聽控制元件的狀態,一旦狀態發生改變,就去改變列表項對應的物件資料。通過setTag()方法把checkbox與物件繫結在一起,一旦狀態改變就在監聽方法裡通過getTag()方法將物件取出,更改物件中選中欄位的狀態值。

程式碼實現:

domain中的實體類BrandItemInfo類:

增加了isSelected變數用來儲存checkbox的選取狀態

package com.aliao.myandroiddemo.domain;

/**
 * Created by liaolishuang on 14-3-31.
 */
public class BrandItemInfo {

    private String brandEnName;
    private String brandChName;
    //    private String brandImage;
    private int brandImage;
    private boolean selected;

    public boolean isSelected() {
        return selected;
    }

    public void setSelected(boolean selected) {
        this.selected = selected;
    }

    public void setBrandImage(int brandImage) {
        this.brandImage = brandImage;
    }

    public void setBrandChName(String brandChName) {
        this.brandChName = brandChName;
    }

    public void setBrandEnName(String brandEnName) {
        this.brandEnName = brandEnName;
    }

    public String getBrandChName() {
        return brandChName;
    }

    public String getBrandEnName() {
        return brandEnName;
    }

    public int getBrandImage() {
        return brandImage;
    }

}

Adapter類:

將每行的checkbox與該行相對應的brandItemInfo物件通過setTag()方法關聯起來。並增加了checkbox的監聽,一旦監聽到選取狀態的改變,就通過getTag()方法取出物件來更新brandItemInfo物件的isSelected欄位的值。

package com.aliao.myandroiddemo.adapter;

import android.content.Context;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.CheckBox;
import android.widget.CompoundButton;
import android.widget.ImageView;
import android.widget.TextView;

import com.aliao.myandroiddemo.R;
import com.aliao.myandroiddemo.domain.BrandItemInfo;

import java.util.List;

/**
 * Created by liaolishuang on 14-3-31.
 */
public class TestDisorderListAdapter extends BaseAdapter{

    private Context context;
    private List<BrandItemInfo> brandInfoList;
    private final String TAG = "disorderlist";

    public TestDisorderListAdapter(Context context, List<BrandItemInfo> list){

        this.context = context;
        brandInfoList = list;

    }

    @Override
    public int getCount() {
        return brandInfoList.size();
    }

    @Override
    public Object getItem(int i) {
        return null != brandInfoList?brandInfoList.get(i):null;
    }

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

    private class ViewHolder{
        private TextView brandEnNameTv;
        private TextView brandChNameTv;
        private CheckBox followCheckBox;
        private ImageView brandLogo;
    }


    @Override
    public View getView(int i, View view, ViewGroup viewGroup) {

        ViewHolder viewHolder = null;
        BrandItemInfo brandItemInfo = (BrandItemInfo) getItem(i);

        if(null == view){
          
            LayoutInflater inflater = (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
            view = inflater.inflate(R.layout.item_testdisorderitem,null);

            viewHolder = new ViewHolder();
            viewHolder.brandChNameTv = (TextView) view.findViewById(R.id.item_chName_txt);
            viewHolder.brandEnNameTv = (TextView) view.findViewById(R.id.item_enName_txt);
            viewHolder.brandLogo = (ImageView) view.findViewById(R.id.item_brandLogo_imagev);
            viewHolder.followCheckBox = (CheckBox) view.findViewById(R.id.item_follow_checkbox);
            final ViewHolder finalViewHolder = viewHolder;
            viewHolder.followCheckBox.setOnCheckedChangeListener(new CompoundButton.OnCheckedChangeListener() {
                @Override
                public void onCheckedChanged(CompoundButton compoundButton, boolean b) {
                    BrandItemInfo info = (BrandItemInfo) finalViewHolder.followCheckBox.getTag();
                    info.setSelected(compoundButton.isChecked());
                }
            });
            view.setTag(viewHolder);
            viewHolder.followCheckBox.setTag(brandItemInfo);
           
        }else {
           
            viewHolder = (ViewHolder) view.getTag();
            viewHolder.followCheckBox.setTag(brandItemInfo);
        }

        viewHolder.brandChNameTv.setText(brandItemInfo.getBrandChName());
        viewHolder.brandEnNameTv.setText(brandItemInfo.getBrandEnName());
        viewHolder.brandLogo.setImageResource(brandItemInfo.getBrandImage());
        viewHolder.followCheckBox.setChecked(brandItemInfo.isSelected());

        return view;
    }

}


好文分享:

Lars Vogel的

Using lists in Android(ListView) 對ListView的使用做了非常系統完整的講解,由淺到深很適合閱讀。閱讀了這篇文章後,對ListView的快取優化機制有了更進一步的理解,從而有了本篇blog的Adapter and Holder Pattern作為閱讀後的總結。

農民伯伯的:

ListView效能優化之檢視快取 介紹了Google I/O提供的優化Adapter方案,並對這些方案進行了測試。

ListView效能優化之檢視快取續 介紹了新浪微博中主介面的做法及測試資料