ViewHolder - good practice

Gorets picture Gorets · Oct 31, 2012 · Viewed 13.7k times · Source

A little newbie question. Why should we initialize the ViewHolder in getView()? Why can't we initialize it in the constructor?

Answer

mindvirus picture mindvirus · Oct 31, 2012

You will have multiple ViewHolder objects in existence.

A ListView by its nature doesn't create new View instances for each of its rows. This is so that if you have a ListView of a million things, you don't need to store layout information for a million things. So what do you need to store? Just the things that are on the screen. You can then reuse those views over and over again. This way, your ListView of a million objects can just have maybe 10 child views.

In your custom array adapter, you will have a function called getView() that looks something like this:

public View getView(int position, View convertView, ViewGroup parent) {
    //Here, position is the index in the list, the convertView is the view to be
    //recycled (or created), and parent is the ListView itself.

    //Grab the convertView as our row of the ListView
    View row = convertView;

    //If the row is null, it means that we aren't recycling anything - so we have
    //to inflate the layout ourselves.
    if(row == null) {
          LayoutInflater inflater = (LayoutInflater)getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
          row = inflater.inflate(R.layout.list_item, parent, false);
    }

    //Now either row is the recycled view, or one that we've inflated. All that's left
    //to do is set the data of the row. In this case, assume that the row is just a
    //simple TextView
    TextView textView = (TextView) row.findViewById(R.id.listItemTextView);

    //Grab the item to be rendered. In this case, I'm just using a string, but
    //you will use your underlying object type.
    final String item = getItem(position);

    textView.setText(item);

    //and return the row
    return row;
}

This will work, but take a moment and see if you can spot the inefficiency here. Think about which of the above code will be called redundantly.

The problem is that we are calling row.findViewById over and over again, even though after the first time we look it up, it will never change. While if you only have a simple TextView in your list, it's probably not that bad, if you have a complex layout, or you have multiple views that you want to set data for, you could lose a bit of time finding your view over and over again.

So how do we fix this? Well, it would make sense to store that TextView somewhere after we look it up. So we introduce a class called a ViewHolder, which "holds" the views. So inside of the adaptor, introduce an inner class like so:

private static class ViewHolder {
    TextView textView;
}

This class is private, since it's just a caching mechanism for the adapter, and it is static so that we don't need a reference to the adapter to use it.

This will store our view so that we don't have to call row.findViewById multiple times. Where should we set it? When we inflate the view for the first time. Where do we store it? Views have a custom "tag" field, which can be used to store meta-information about the view - exactly what we want! Then, if we've already seen this view, we just have to look up the tag instead of looking up each of the views within the row..

So the if statement inside of getView() becomes:

//If the row is null, it means that we aren't recycling anything - so we have
//to inflate the layout ourselves.
ViewHolder holder = null;
if(row == null) {
    LayoutInflater inflater = (LayoutInflater)getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
    row = inflater.inflate(R.layout.list_item, parent, false);
    //Now create the ViewHolder
    holder = new ViewHolder();
    //and set its textView field to the proper value
    holder.textView =  (TextView) row.findViewById(R.id.listItemTextView);
    //and store it as the 'tag' of our view
    row.setTag(holder);
} else {
    //We've already seen this one before!
    holder = (ViewHolder) row.getTag();
}

Now, we just have to update the holder.textView's text value, since it's already a reference to the recycled view! So our final adapter's code becomes:

public View getView(int position, View convertView, ViewGroup parent) {
    //Here, position is the index in the list, the convertView is the view to be
    //recycled (or created), and parent is the ListView itself.

    //Grab the convertView as our row of the ListView
    View row = convertView;

    //If the row is null, it means that we aren't recycling anything - so we have
    //to inflate the layout ourselves.
    ViewHolder holder = null;
    if(row == null) {
        LayoutInflater inflater = (LayoutInflater)getContext().getSystemService(Context.LAYOUT_INFLATER_SERVICE);
        row = inflater.inflate(R.layout.list_item, parent, false);
        //Now create the ViewHolder
        holder = new ViewHolder();
        //and set its textView field to the proper value
        holder.textView =  (TextView) row.findViewById(R.id.listItemTextView);
        //and store it as the 'tag' of our view
        row.setTag(holder);
    } else {
        //We've already seen this one before!
        holder = (ViewHolder) row.getTag();
    }

    //Grab the item to be rendered. In this case, I'm just using a string, but
    //you will use your underlying object type.
    final String item = getItem(position);

    //And update the ViewHolder for this View's text to the correct text.
    holder.textView.setText(item);

    //and return the row
    return row;
}

And we're done!

Some things to think about:

  1. How does this change if you have multiple views in a row that you want to change? As a challenge, make a ListView where each row has two TextView objects and an ImageView
  2. When debugging your ListView, check a few things so that you can really see what's going on:
    1. How many times ViewHolder's constructor is called.
    2. What the value of holder.textView.getText() is before you update it at the end of getView()