Posts match “ Gallery ” tag:

需求

  • 當我們有大量的圖片,需要呈現圖片列表給使用者,讓使用者可以上下滑動檢視圖片。
  • 每支App都有配置一定的記憶體使用量,如何高效地載入和呈現圖片是重要關鍵。
  • 最後成果類似下圖,此圖為我自己公司內部的App截圖:

思路

  • 這功能的實作思路不困難,用一個GridView元件當作Gallery,加入Adapter當作圖片來源,然後當使用者上下滑動時,可以將圖片一張一張載入顯示,這些圖片可以是從網路下載、手機儲存或是拍照而得來的。
  • 避免OOM:隨著使用者不斷往下滑、載入的圖檔越來越多,就會有一個非常重要的問題(必)會發生,那就是圖片佔用記憶體超過App本身所配置到的記憶體大小,產生OutOfMemoryError的錯誤例外(OOM),使得App最終crash掉。為了避免此現象,我們加入Cache的觀念,在實作時就使用Android所提供的LruCache類別,這個類別是在Android 3.1.x (API 12)之後才提供的,如果是在更早的Android版本開發,則需要import android-support-v4.jar
  • 要呈現的圖片可能非常多,使用者可能不會看完所有圖片,所以我們採用「看得到多少數量的圖片,才載入那樣數量的圖片」來增加效能,因此,我們必須偵測使用者目前滑動到的區域、以及該區域可以呈現多少圖片。 程式碼中讓Adapter實作OnScrollListener介面,然後override onScrollStateChanged() onScroll()兩個方法。

程式碼實作

以下是使用Android 4.0 API開發。

首先先建立Gallery的Layout檔 activity_gallery.xml

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content">
    <GridView
      android:id="@+id/gridPhoto"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:columnWidth="150dp"
      android:stretchMode="columnWidth"
      android:numColumns="auto_fit"
      android:verticalSpacing="3dp"
      android:layout_marginBottom="3dp">
    </GridView>
</LinearLayout>

接下來我們定義GridView內每一張圖片的Layout檔 photo_view.xml,我們就使用ImageView元件來顯示一張圖。

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content" >
    <!-- 放置一張圖片View -->
    <ImageView
      android:id="@+id/photo"
      android:contentDescription="@string/app_name"
      android:layout_width="150dp"
      android:layout_height="150dp"
      android:src="@drawable/ic_launcher"
      android:layout_centerInParent="true"/>
</RelativeLayout>

再來,最重要GridView的Adapter PhotoGalleryAdapter.java

public class PhotoGalleryAdapter extends ArrayAdapter<String> implements OnScrollListener
{
    private static final int PHOTO_WIDTH = 120;
    private static final int PHOTO_HEIGHT = 120;
    
    /**
     * 紀錄所有正在下載圖片或等待的task.
     */
    private Set<BitmapDownloadTask> taskCollection;

    /**
     * 用來做圖片Caching
     */
    private LruCache<String, Bitmap> memoryCache;

    /**
     * Gallery View
     */
    private GridView gridViewPhoto;

    /**
     * 第一章可見圖片的索引.
     */
    private int firstVisiblePhoto;

    /**
     * 圖片位址或路徑列表。
     */
    private List<String> photoURLs;

    /**
     * 螢幕一次可以看到多少張圖片.
     */
    private int visiblePhotoCount;

    /**
     * 紀錄是否是剛進入此程式,用來解決程式不滑動螢幕,不會下載圖片的問題.
     */
    private boolean isFirstEnter = true;

    /**
     * 
     * @param context
     * @param objects 圖片來源列表
     * @param photoWall GridView元件
     */
    public PhotoGalleryAdapter( Context context, List<String> objects, GridView photoWall )
    {
        super( context, 0, objects );
        this.photoURLs = objects;
        this.gridViewPhoto = photoWall;
        this.taskCollection = new HashSet<BitmapDownloadTask>();

        // 取得app可用的最大記憶體

        int maxMemory = (int) Runtime.getRuntime().maxMemory();

        // 圖片暫存大小為app可用最大記憶體的1/8

        int cacheSize = maxMemory / 8;

        this.memoryCache = new LruCache<String, Bitmap>( cacheSize )
        {
            @Override
            protected int sizeOf( String key, Bitmap bitmap )
            {
                return bitmap.getByteCount();
            }
        };
        this.gridViewPhoto.setOnScrollListener( this );
    }

    /**
     * To use something other than TextViews for the array display, for instance, ImageViews,
     * or to have some of data besides toString() results fill the views, override getView(int, View, ViewGroup)
     * to return the type of view you want.
     */
    @Override
    public View getView( int position, View convertView, ViewGroup parent )
    {
        final String url = this.getItem( position );
        View view;
        if ( convertView == null )
            view = LayoutInflater.from( this.getContext() ).inflate( R.layout.photo_view, null );
        else
            // 從cache載入的圖

            view = convertView;

        ImageView photoView = (ImageView) view.findViewById( R.id.photo );

        // 給圖片設置一個tag已保證在做非同步載入圖片時順序不會亂掉

        photoView.setTag( url );
        setImageView( url, photoView );
        return view;
    }

    /**
     * 把圖片做caching
     * 
     * @param key LruCache的鍵,這裡是圖片的URL位址
     * @param bitmap
     */
    public void addBitmapToMemoryCache( String key, Bitmap bitmap )
    {
        if ( this.getBitmapFromMemoryCache( key ) == null )
            this.memoryCache.put( key, bitmap );
    }

    /**
     * 從LruCache中取回圖片
     * 
     * @param key 用圖片URL位址從cache中取回圖片
     * @return 圖片Bitmap或null
     */
    public Bitmap getBitmapFromMemoryCache( String key )
    {
        Bitmap bitmap = this.memoryCache.get( key );
        return bitmap;
    }

    /**
     * 當GridView靜止時才下載圖片,如果正在滑動,則取消下載工作
     */
    @Override
    public void onScrollStateChanged( AbsListView view, int scrollState )
    {
        if ( scrollState == SCROLL_STATE_IDLE )
            loadBitmaps( this.firstVisiblePhoto, this.visiblePhotoCount );
        else
            cancelAllTasks();
    }

    /*
     * (non-Javadoc)
     * 
     * @see android.widget.AbsListView.OnScrollListener#onScroll(android.widget.AbsListView, int, int, int)
     */
    @Override
    public void onScroll( AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount )
    {
        this.firstVisiblePhoto = firstVisibleItem;
        this.visiblePhotoCount = visibleItemCount;

        // 下載圖片的工作是由onScrollStateChanged()裡面啟動

        // 但第一次進入GridView時,onScrollStateChanged()不會被呼叫到所以要進行第一次進入GridView下載圖片的動作。

        if ( isFirstEnter && visibleItemCount > 0 )
        {
            loadBitmaps( firstVisibleItem, visibleItemCount );
            isFirstEnter = false;
        }
    }

    /**
     * 載入Bitmap物件,這方法會在暫存中檢查所有螢幕可見的圖片物件,如果可見但不在暫存中,則啟動非同步載入圖片工作。
     * 
     * @param firstVisiblePhoto 第一個可見圖片的位置索引
     * @param visiblePhotoCount 螢幕可見圖片的數量
     */
    private void loadBitmaps( int firstVisiblePhoto, int visiblePhotoCount )
    {
        for ( int i = firstVisiblePhoto; i < firstVisiblePhoto + visiblePhotoCount; i++ )
        {
            String imageUrl = this.photoURLs.get( i );
            System.out.println( "Load " + imageUrl );
            Bitmap bitmap = this.getBitmapFromMemoryCache( imageUrl );
            ImageView imageView = (ImageView) gridViewPhoto.findViewWithTag( imageUrl );
            if ( bitmap == null )
            {
                // 從遠端伺服器來

                if ( imageUrl.startsWith( "http" ) )
                {
                    BitmapDownloadTask bitmapTask = new BitmapDownloadTask();
                    this.taskCollection.add( bitmapTask );
                    bitmapTask.execute( imageUrl );
                }
                else
                // 從檔案系統來

                {
                    bitmap = loadBitmapFromFile( imageUrl, PHOTO_WIDTH, PHOTO_HEIGHT );
                    if ( bitmap != null )
                        this.addBitmapToMemoryCache( imageUrl, bitmap );
                    
                    if ( imageView != null && bitmap != null )
                        imageView.setImageBitmap( bitmap );
                }
            }
            else
            {
                if ( imageView != null && bitmap != null )
                    imageView.setImageBitmap( bitmap );
            }
        }
    }

    /**
     * 給ImageView設置圖片,這方法會去找暫存,如果沒有暫存圖,則直接先給定一張預設圖。
     * 
     * @param imageUrl
     * @param imageView
     */
    private void setImageView( String imageUrl, ImageView imageView )
    {
        Bitmap bitmap = getBitmapFromMemoryCache( imageUrl );
        if ( bitmap != null )
            imageView.setImageBitmap( bitmap );
        else
            imageView.setImageResource( R.drawable.ic_launcher );
    }

    /**
     * 取消所有下載中和等待中的圖片下載工作。
     */
    public void cancelAllTasks()
    {
        if ( this.taskCollection != null )
            for ( BitmapDownloadTask task : this.taskCollection )
                task.cancel( true );
    }

    /**
     * 圖片非同步下載的工作執行續
     * 
     * @author 白昌永, Engine Bai @ infinitibeat, styletrip
     * @version
     */
    class BitmapDownloadTask extends AsyncTask<String, Void, Bitmap>
    {
        private String bitmapUrl;

        @Override
        protected Bitmap doInBackground( String... params )
        {
            this.bitmapUrl = params[ 0 ];
            Bitmap bitmap = null;
            try
            {
                bitmap = downloadBitmap( this.bitmapUrl, PHOTO_WIDTH, PHOTO_HEIGHT );
            }
            catch ( IOException e )
            {
                e.printStackTrace();
            }

            // 圖片成功下載後,直接先加到暫存中

            if ( bitmap != null )
                addBitmapToMemoryCache( this.bitmapUrl, bitmap );
            return bitmap;
        }

        @Override
        protected void onPostExecute( Bitmap bitmap )
        {
            // 根據Tag找到對應的ImageView元件,把剛剛下載的圖片呈現出來

            ImageView imageView = (ImageView) gridViewPhoto.findViewWithTag( this.bitmapUrl );
            if ( imageView != null && bitmap != null )
                imageView.setImageBitmap( bitmap );
            taskCollection.remove( this );
        }
    }

    /**
     * 下載Bitmap。
     * 
     * @param bitmapUrl
     * @param width Bitmap要呈現的寬度
     * @param height Bitmap要呈現的高度
     * @return
     * @throws IOException
     */
    public static Bitmap downloadBitmap( String bitmapUrl, int width, int height ) throws IOException
    {
        Bitmap bitmap = null;
        
        BitmapFactory.Options options = new BitmapFactory.Options();
        options.inJustDecodeBounds = true;
        InputStream in = getInputStreamFromURL( bitmapUrl );
        try
        {
            bitmap = BitmapFactory.decodeStream( in, null, options );
        }
        finally
        {
            if ( in != null )
                in.close();
        }
        
        options.inSampleSize = calculateImageSampleSize( options, width, height );
//     System.out.printf("%d, %d, %s, %d\n", imageHeight, imageWidth, imageType, options.inSampleSize );

        options.inJustDecodeBounds = false;
        
        in = getInputStreamFromURL( bitmapUrl );
        try
        {
            bitmap = BitmapFactory.decodeStream( in, null, options );
        }
        finally
        {
            if ( in != null )
                in.close();
        }
        return bitmap;
    }

    /**
     * 從手機儲存載入圖片。
     * 
     * @param photoPath
     * @param width  Bitmap要呈現的寬度
     * @param height Bitmap要呈現的高度
     * @return
     */
    public static Bitmap loadBitmapFromFile( String photoPath, int width, int height )
    {
        BitmapFactory.Options options = new BitmapFactory.Options();
        Bitmap bitmap = null;
        options.inJustDecodeBounds = true;
        BitmapFactory.decodeFile( photoPath, options );
        options.inSampleSize = calculateImageSampleSize( options, width, height );
        options.inJustDecodeBounds = false;
        bitmap = BitmapFactory.decodeFile( photoPath, options );
        return bitmap;
    }

    /**
     * 從URL建立連線後取得InputStream
     * 
     * @param URL
     * @return
     * @throws IOException
     */
    public static InputStream getInputStreamFromURL( final String URL ) throws IOException
    {
        HttpURLConnection conn = null;
        URL url = new URL( URL );
        conn = (HttpURLConnection) url.openConnection();
        conn.setConnectTimeout( 5 * 1000 );
        conn.setReadTimeout( 10 * 1000 );
        conn.setDoInput( true );
        conn.setDoOutput( true );
        return conn.getInputStream();
    }

    /**
     * 依照要顯示的長寬來計算Bitmap要sample的比例
     * 
     * @param options
     * @param reqWidth 需要的寬度
     * @param reqHeight 需要的高度
     * @return
     */
    public static int calculateImageSampleSize( BitmapFactory.Options options, int reqWidth, int reqHeight )
    {
        final int rawH = options.outHeight;
        final int rawW = options.outWidth;
        int imageSampleSize = 1;
        
        if ( rawH > reqHeight || rawW > reqWidth )
        {
            final int halfH = rawH / 2;
            final int halfW = rawW / 2;
            
            while ( ( ( halfH / imageSampleSize ) > reqHeight ) && ( ( halfW / imageSampleSize ) > reqWidth ) )
                imageSampleSize *= 2;
        }
        return imageSampleSize;
    }
}

最後實作呈現GridViewGalleryActivity.java

/**
 * @author 白昌永, Engine Bai @ infinitibeat, styletrip
 * @version
 */
public class GalleryActivity extends Activity
{
    private static final String[] IMAGES = { "http://photo1", "http://photo2" };
    
    /**
     * 用來展示圖片的Gallery
     */
    private GridView photoGallery;

    /**
     * GridView所使用的Adapter
     */
    private PhotoGalleryAdapter adapter;

    @Override
    protected void onCreate( Bundle savedInstanceState )
    {
        super.onCreate( savedInstanceState );
        setContentView( R.layout.activity_gallery );
        this.photoGallery = (GridView) findViewById( R.id.gridView );
        
        // TODO 這裡的IMAGES可以換成任何來源,不論是遠端伺服器查詢、或者是手機儲存

        this.adapter = new PhotoGalleryAdapter( this, Arrays.asList( IMAGES ), photoGallery );
        this.photoGallery.setAdapter( adapter );
    }

    @Override
    protected void onDestroy()
    {
        super.onDestroy();
        // 結束Activity時同時也取消所有圖片下載的工作

        this.adapter.cancelAllTasks();
    }

}

因為我們這個範例是從網路上下載圖片,使用到網路功能,所以需要在AndroidManifest.xml加入使用網路的權限。

<manfiest>
    <application>
    ... 略
    </application>
    
  <uses-permission android:name="android.permission.INTERNET" />
  <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
</manifest>

實作說明

  • PhotoGalleryAdapter初始化LruCache類別,設置App配置最大可用記憶體容量的8分之一作為最大暫存容量,因為我們使用了LruCache類別來暫存圖片,所以不需要擔心會發生OOM現象發生,因為LruCache暫存圖片的總容量到達暫存上限時,自動會把最近最少顯示的圖片從暫存中移除。
  • 圖片下載是在實作onScrollStateChanged()方法進行,程式偵測使用者是否進行螢幕滑動,在螢幕靜止時才進行下載圖片,偵測目前可看見幾個ImageView數量,去載入多少圖片(從網路、手機暫存下載或是直接從暫存取回),圖片載入後也同時加入到暫存。當使用者滑動螢幕時,則取消所有正在進行的圖片載入工作,這樣可以提昇GridView滑動的流暢性。
  • Overriden的getView()初始化GridView顯示每一張圖片的ImageView,同時比較重要的是,我們為每一個ImageView設定了一個Tag(這邊我們可以用圖片的遠端伺服器URL或是在手機儲存上的路徑當作Tag),這個Tag可以讓我們正確的找到顯示的ImageView,在進行下載圖片的工作時,可以依照Tag來找到對應的ImageView元件,如此一來不會發生圖片顯示錯亂的問題。

參考連結

Caching Bitmaps http://developer.android.com/training/displaying-bitmaps/cache-bitmap.html
GridView元件
LruCache類別