Dynamically Loading Recycler View Images in Android

As in iOS, Android applications often display thumbnail images alongside other text-based content such as contact names or product descriptions in recycler views. However, the images are not usually delivered with the initial request, but are instead retrieved separately afterward. They are typically downloaded in the background as needed to avoid blocking the UI thread, which would temporarily render the application unresponsive.

For example, consider this sample REST service, which returns a list of simulated photo data:

[
  {
    "albumId": 1,
    "id": 1,
    "title": "accusamus beatae ad facilis cum similique qui sunt",
    "url": "http://placehold.it/600/92c952",
    "thumbnailUrl": "http://placehold.it/150/92c952"
  },
  {
    "albumId": 1,
    "id": 2,
    "title": "reprehenderit est deserunt velit ipsam",
    "url": "http://placehold.it/600/771796",
    "thumbnailUrl": "http://placehold.it/150/771796"
  },
  {
    "albumId": 1,
    "id": 3,
    "title": "officia porro iure quia iusto qui ipsa ut modi",
    "url": "http://placehold.it/600/24f355",
    "thumbnailUrl": "http://placehold.it/150/24f355"
  },
  ...
]

Each record contains a photo ID, album ID, and title, as well as URLs for both thumbnail and full-size images; for example:

Photo Class

A class representing this data might be defined as follows:

public class Photo {
    private Number id;
    private Number albumId;
    private String title;
    private URL url;
    private URL thumbnailUrl;

    public Photo(Map<String, ?> map) {
        id = valueAt(map, "id");
        albumId = valueAt(map, "albumId");
        title = valueAt(map, "title");

        String url = valueAt(map, "url");
        String thumbnailUrl = valueAt(map, "thumbnailUrl");

        try {
            this.url = new URL(url);
            this.thumbnailUrl = new URL(thumbnailUrl);
        } catch (MalformedURLException exception) {
            throw new RuntimeException(exception);
        }
    }

    public int getId() {
        return id.intValue();
    }

    public int getAlbumId() {
        return albumId.intValue();
    }

    public String getTitle() {
        return title;
    }

    public URL getUrl() {
        return url;
    }

    public URL getThumbnailUrl() {
        return thumbnailUrl;
    }
}

The constructor extracts property data from a map instance using the static valueAt() method of the HTTP-RPC WebServiceProxy class. Instances of this class will be used later to retrieve the photo list as well as the thumbnails themselves.

Main Activity Class

A basic user interface for displaying service results in a recycler view is shown below:

The markup for the main activity simply declares an instance of RecyclerView that will occupy the entire screen:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <android.support.v7.widget.RecyclerView android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:scrollbars="vertical"/>
</LinearLayout>

Item markup is shown below:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_margin="16dp">
    <ImageView android:id="@+id/image_view"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>
    <TextView android:id="@+id/text_view"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:layout_marginLeft="8dp"/>
</LinearLayout>

View holder and adapter classes are used to produce and configure individual photo item views. Item data is stored in a list of Photo instances, and previously loaded thumbnail images are stored in a map that associates Bitmap instances with photo IDs. The executor service will be used later by WebServiceProxy to execute service requests:

public class MainActivity extends AppCompatActivity {
    // Photo view holder
    private class PhotoViewHolder extends RecyclerView.ViewHolder {
        ...
    }

    // Photo adapter
    private class PhotoAdapter extends RecyclerView.Adapter<PhotoViewHolder> {
        ...
    }

    private RecyclerView recyclerView;

    private ArrayList<Photo> photos = null;

    private HashMap<Integer, Bitmap> photoThumbnails = new HashMap<>();

    private ExecutorService executorService = Executors.newSingleThreadExecutor();

    private static String TAG = MainActivity.class.getName();

    ...
}

When the activity is created, the view is loaded and the recycler view configured:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    setContentView(R.layout.activity_main);

    recyclerView = (RecyclerView)findViewById(R.id.recycler_view);

    recyclerView.setLayoutManager(new LinearLayoutManager(this));
    recyclerView.setAdapter(new PhotoAdapter());
}

The photo list is loaded the first time the activity resumes. An instance of WebServiceProxy is used to retrieve the data. If the call succeeds, the response data (a list of map objects) is transformed into a list of Photo objects and the recycler view is refreshed. Otherwise, an error message is logged:

@Override
protected void onResume() {
    super.onResume();

    // Load photo data
    if (photos == null) {
        URL serverURL;
        try {
            serverURL = new URL("https://jsonplaceholder.typicode.com");
        } catch (MalformedURLException exception) {
            throw new RuntimeException(exception);
        }

        WebServiceProxy serviceProxy = new WebServiceProxy(serverURL, executorService) {
            @Override
            protected void dispatchResult(Runnable command) {
                runOnUiThread(command);
            }
        };

        serviceProxy.invoke("GET", "/photos", (List<Map<String, ?>> result, Exception exception) -> {
            if (exception == null) {
                photos = new ArrayList<>(result.size());

                for (Map<String, ?> value : result) {
                    photos.add(new Photo(value));
                }

                recyclerView.getAdapter().notifyDataSetChanged();
            } else {
                Log.e(TAG, exception.getMessage());
            }
        });
    }
}

Note that the service proxy overrides dispatchResult() to ensure that the response handler is executed on the UI thread.

Item content is managed by the view holder and adapter classes. The corresponding Photo instance is retrieved from the photos list and used to configure the item view:

private class PhotoViewHolder extends RecyclerView.ViewHolder {
    private ImageView imageView;
    private TextView textView;

    public PhotoViewHolder(View view) {
        super(view);

        imageView = (ImageView)view.findViewById(R.id.image_view);
        textView = (TextView)view.findViewById(R.id.text_view);
    }
}

private class PhotoAdapter extends RecyclerView.Adapter<PhotoViewHolder> {
    @Override
    public PhotoViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        return new PhotoViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.item_photo, parent, false));
    }

    @Override
    public void onBindViewHolder(PhotoViewHolder holder, int position) {
        Photo photo = photos.get(position);

        // Attempt to load image from cache
        Bitmap thumbnail = photoThumbnails.get(photo.getId());

        if (thumbnail == null) {
            // Image was not found in cache; load it from the server

            ...
        } else {
            holder.imageView.setImageBitmap(thumbnail);
        }

        holder.textView.setText(photo.getTitle());
    }

    @Override
    public int getItemCount() {
        return (photos == null) ? 0 : photos.size();
    }
}

If the thumbnail image is already available in the cache, it is used to populate the item's image view. Otherwise, it is loaded from the server and added to the cache as shown below. If the item is still visible when the image request returns, it is updated immediately:

// Image was not found in cache; load it from the server
URL thumbnailUrl = photo.getThumbnailUrl();

URL serverURL;
try {
    serverURL = new URL(String.format("%s://%s", thumbnailUrl.getProtocol(), thumbnailUrl.getHost()));
} catch (MalformedURLException exception) {
    throw new RuntimeException(exception);
}

WebServiceProxy serviceProxy = new WebServiceProxy(serverURL, executorService) {
    @Override
    protected Object decodeImageResponse(InputStream inputStream, String imageType) {
        return BitmapFactory.decodeStream(inputStream);
    }

    @Override
    protected void dispatchResult(Runnable command) {
        runOnUiThread(command);
    }
};

serviceProxy.invoke("GET", thumbnailUrl.getPath(), (Bitmap result, Exception exception) -> {
    photoThumbnails.put(photo.getId(), result);

    // If view is still visible, update image
    PhotoViewHolder viewHolder = (PhotoViewHolder)recyclerView.findViewHolderForAdapterPosition(position);

    if (viewHolder != null) {
        viewHolder.imageView.setImageBitmap(result);
    }
});

Summary

This article provided an overview of how images can be dynamically loaded as needed to populate recycler views in Android. For more information, please see the HTTP-RPC project on GitHub.

Dynamically Loading Table View Images in iOS

iOS applications often display thumbnail images in table views alongside other text-based content such as contact names or product descriptions. However, these images are not usually delivered with the initial request, but must instead be retrieved separately afterward. They are typically downloaded in the background as needed to avoid blocking the main thread, which would temporarily render the user interface unresponsive.

For example, consider this REST service, which returns a list of simulated photo data:

[
  {
    "albumId": 1,
    "id": 1,
    "title": "accusamus beatae ad facilis cum similique qui sunt",
    "url": "http://placehold.it/600/92c952",
    "thumbnailUrl": "http://placehold.it/150/92c952"
  },
  {
    "albumId": 1,
    "id": 2,
    "title": "reprehenderit est deserunt velit ipsam",
    "url": "http://placehold.it/600/771796",
    "thumbnailUrl": "http://placehold.it/150/771796"
  },
  {
    "albumId": 1,
    "id": 3,
    "title": "officia porro iure quia iusto qui ipsa ut modi",
    "url": "http://placehold.it/600/24f355",
    "thumbnailUrl": "http://placehold.it/150/24f355"
  },
  ...
]

Each record contains a photo ID, album ID, and title, as well as URLs for both thumbnail and full-size images; for example:

Photo Class

A class representing this data might be defined as follows:

class Photo: NSObject {
    var id: Int = 0
    var albumId: Int = 0
    var title: String?
    var url: URL?
    var thumbnailUrl: URL?

    init(dictionary: [String: Any]) {
        super.init()

        setValuesForKeys(dictionary)
    }

    override func setValue(_ value: Any?, forKey key: String) {
        switch key {
        case #keyPath(url):
            url = URL(string: value as! String)

        case #keyPath(thumbnailUrl):
            thumbnailUrl = URL(string: value as! String)

        default:
            super.setValue(value, forKey: key)
        }
    }
}

Since instances of this class will be populated using dictionary values returned by the web service, an initializer that takes a dictionary argument is provided. The setValuesForKeys(_:) method of NSObject is used to map dictionary entries to property values. The ID and title properties are handled automatically by this method; the class overrides setValue(_:forKey:) to convert the URLs from strings into actual URL instances.

View Controller Class

A basic user interface for displaying service results in a table view is shown below:

Row data is stored in an array of Photo instances. Previously loaded thumbnail images are stored in a dictionary that associates UIImage instances with photo IDs:

class ViewController: UITableViewController {
    // Row data
    var photos: [Photo]!

    // Image cache
    var thumbnailImages: [Int: UIImage] = [:]

    ...    
}

The photo list is loaded the first time the view appears. The WSWebServiceProxy class of the open-source HTTP-RPC library is used to retrieve the data. If the call succeeds, the response data (an array of dictionary objects) is transformed into an array of Photo objects and the table view is refreshed. Otherwise, an error message is displayed:

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    // Load photo data
    if (photos == nil) {
        let serviceProxy = WSWebServiceProxy(session: URLSession.shared, serverURL: URL(string: "https://jsonplaceholder.typicode.com")!)

        serviceProxy.invoke("GET", path: "/photos") { result, error in
            if (error == nil) {
                let photos = result as! [[String: Any]]

                self.photos = photos.map({
                    return Photo(dictionary: $0)
                })

                self.tableView.reloadData()
            } else {
                let alertController = UIAlertController(title: "Error", message: error!.localizedDescription, preferredStyle: .alert)

                alertController.addAction(UIAlertAction(title: "OK", style: .default))

                self.present(alertController, animated: true)
            }
        }
    }
}

Cell content is generated as follows. The corresponding Photo instance is retrieved from the photos array and used to configure the cell:

override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return (photos == nil) ? 0 : photos.count
}

override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    var cell = tableView.dequeueReusableCell(withIdentifier: cellIdentifier)

    if (cell == nil) {
        cell = UITableViewCell(style: .default, reuseIdentifier: cellIdentifier)
    }

    let photo = photos[indexPath.row];

    cell!.textLabel!.text = photo.title

    // Attempt to load image from cache
    cell!.imageView!.image = thumbnailImages[photo.id]

    if (cell!.imageView!.image == nil && photo.thumbnailUrl != nil) {
        // Image was not found in cache; load it from the server

        ...
    }

    return cell!
}

If the thumbnail image is already available in the cache, it is used to populate the cell's image view. Otherwise, it is loaded from the server and added to the cache as shown below. If the cell is still visible when the image request returns, it is updated immediately and reloaded:

let serverURL = URL(string: String(format: "%@://%@", photo.thumbnailUrl!.scheme!, photo.thumbnailUrl!.host!))!

let serviceProxy = WSWebServiceProxy(session: URLSession.shared, serverURL: serverURL)

serviceProxy.invoke("GET", path: photo.thumbnailUrl!.path) { result, error in
    if (error == nil) {
        // If cell is still visible, update image and reload row
        let cell = tableView.cellForRow(at: indexPath)

        if (cell != nil) {
            if let thumbnailImage = result as? UIImage {
                self.thumbnailImages[photo.id] = thumbnailImage

                cell!.imageView!.image = thumbnailImage

                tableView.reloadRows(at: [indexPath], with: .none)
            }
        }
    } else {
        print(error!.localizedDescription)
    }
}

Finally, if the system is running low on memory, the image cache is cleared:

override func didReceiveMemoryWarning() {
    super.didReceiveMemoryWarning()

    // Clear image cache
    thumbnailImages.removeAll()
}

Summary

This article provided an overview of how images can be dynamically loaded as needed to populate table view cells in iOS. For more ways to simplify iOS app development, please see my projects on GitHub: