利用ViewPager实现3D画廊效果及其图片加载优化

MalDesmond 8年前
   <h2>前言</h2>    <p>对于ViewPager,相信大家都已经很熟悉了,在各种切换场景比如Fragment切换、选项卡的切换或者顶部轮播图片等都可以用ViewPager去实现。那么本篇文章带来ViewPager的一种实现效果:3D画廊。直接上图来看:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/c544ff1d0ea32a69698e3418073714ec.gif"></p>    <p style="text-align:center">ic.gif</p>    <p>从上面的图我们可以看出,整个页面分成三个部分,中间的是大图,正中地显示给用户;而两边的是侧图,而这两幅图片又有着角度的旋转,与大图看起来不在同一平面上,这就形成了3D效果。接着拖动页面,侧面的图慢慢移到中间,这个过程也是有着动画的,包括了图片的旋转、缩放和平移。在欣赏了上面的效果后,话不多说,我们来看看是怎样实现的。</p>    <h2>实现原理</h2>    <p>1、利用ViewGroup的clipChildren属性。大家可能对ClipChildren属性比较陌生,我们先来看看官方文档对该属性的描述:</p>    <p>Defines whether a child is limited to draw inside of its bounds or not. This is useful with animations that scale the size of the children to more than 100% for instance. In such a case, this property should be set to false to allow the children to draw outside of their bounds. The default value of this property is true.</p>    <p>上面的大意是说,ViewGroup的子View默认是不会绘制边界意外的部分的,倘若将clipChildren属性设置为false,那么子View会把自身边界之外的部分绘制出来。</p>    <p>那么这个属性跟我们的ViewPager又有什么关联呢?我们可以这样想,ViewPager自身是一个ViewGroup,如果将它的宽度限制为某一个大小比如200dp(我们通常是match_parent),这样ViewPager的绘制区域就被限制在了240dp内(此时绘制的是ViewA),此时我们将它的父容器的clipChildren属性设置为false,那么ViewPager未绘制的部分就会在两旁得到绘制(此时绘制的是ViewA左右两边的Item View)。</p>    <p>那么我们的布局文件可以这样写,activity_main.xml:</p>    <pre>  <code class="language-java"><RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"      xmlns:tools="http://schemas.android.com/tools"      android:id="@+id/activity_main"      android:layout_width="match_parent"      android:layout_height="match_parent"      android:clipChildren="false">        <android.support.v4.view.ViewPager          android:id="@+id/viewpager"          android:layout_width="240dp"          android:layout_height="match_parent"          android:clipChildren="false"          android:layout_centerInParent="true">      </android.support.v4.view.ViewPager>    </RelativeLayout></code></pre>    <p>接着,我们需要为每个Item创建一个布局,这个很简单,就是一个ImageView,新建item_main.xml文件:</p>    <pre>  <code class="language-java"><?xml version="1.0" encoding="utf-8"?>  <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"      android:orientation="vertical"      android:layout_width="match_parent"      android:layout_height="match_parent">      <ImageView          android:id="@+id/iv"          android:layout_width="240dp"          android:layout_height="360dp"          android:layout_centerInParent="true"/>  </RelativeLayout></code></pre>    <p>布局文件写好后,我们接着完成MainActivity.java和MyPagerAdapter.java的内容:</p>    <p>MainActivity.java:</p>    <pre>  <code class="language-java">public class MainActivity extends AppCompatActivity {        //这里的图片从百度图片中下载,图片规格是960*640      private static final int[] drawableIds = new int[]{R.mipmap.ic_01,R.mipmap.ic_02,R.mipmap.ic_03,              R.mipmap.ic_04,R.mipmap.ic_05,R.mipmap.ic_06,R.mipmap.ic_07,R.mipmap.ic_08,R.mipmap.ic_09,              R.mipmap.ic_10,R.mipmap.ic_11,R.mipmap.ic_12};      private ViewPager mViewPager;      private RelativeLayout mRelativeLayout;      private MyPagerAdapter mPagerAdapter;      @Override      protected void onCreate(Bundle savedInstanceState) {          super.onCreate(savedInstanceState);          setContentView(R.layout.activity_main);          initViews();      }        private void initViews() {          mViewPager = (ViewPager) findViewById(R.id.viewpager);          mPagerAdapter = new MyPagerAdapter(drawableIds,this);          mViewPager.setAdapter(mPagerAdapter);      }  }</code></pre>    <p>MyPagerAdapter.java:</p>    <pre>  <code class="language-java">public class MyPagerAdapter extends PagerAdapter {        private int[] mBitmapIds;      private Context mContext;        public MyPagerAdapter(int[] data,Context context){          mBitmapIds = data;          mContext = context;      }        @Override      public int getCount() {          return mBitmapIds.length;      }        @Override      public boolean isViewFromObject(View view, Object object) {          return view == object;      }        @Override      public Object instantiateItem(ViewGroup container, int position) {          View view = LayoutInflater.from(mContext).inflate(R.layout.item_main,container,false);          ImageView imageView = (ImageView) view.findViewById(R.id.iv);          imageView.setImageResource(mBitmapIds[position]);          container.addView(view);          return view;      }        @Override      public void destroyItem(ViewGroup container, int position, Object object) {          container.removeView((View) object);      }  }</code></pre>    <p>ok,到现在为止,我们先运行一下看看结果如何:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/699f15afbc5ba60ed4df2c783f8d7825.png"></p>    <p style="text-align:center">ic_01.png</p>    <p>从上图可以看出,本来ViewPager设置的宽度是240dp,那么原来应该只会显示一个Page的内容,但是由于clipChildren=false属性的生效,使得ViewPager早240dp之外的部分也被绘制了出来。那么到目前为止,就实现了在一屏显示多个Page的效果了,那么接下来的3D效果怎样实现呢?</p>    <p>2、利用ViewPager.PageTransformer实现滑动动画效果</p>    <p>PageTransformer是Android3.0之后加入的一个接口,通过该接口我们可以方便地为ViewPager添加滑动动画,但是该接口只能用于Android3.0之后的版本,3.0之前的版本会被忽略。我们看看这个接口需要重写的唯一一个方法:</p>    <pre>  <code class="language-java">/**       * A PageTransformer is invoked whenever a visible/attached page is scrolled.       * This offers an opportunity for the application to apply a custom transformation       * to the page views using animation properties.       *       * <p>As property animation is only supported as of Android 3.0 and forward,       * setting a PageTransformer on a ViewPager on earlier platform versions will       * be ignored.</p>       */  public interface PageTransformer {          /**           * Apply a property transformation to the given page.           *           * @param page Apply the transformation to this page           * @param position Position of page relative to the current front-and-center           *                 position of the pager. 0 is front and center. 1 is one full           *                 page position to the right, and -1 is one page position to the left.           */          void transformPage(View page, float position);      }</code></pre>    <p>通过官方的注释,我们可以获得如下信息:①PageTransformer在可见Item或者被添加到ViewPager的Item的位置发生改变的时候,就会回调该方法。可见Item很容易理解,就是当前被选中的Page,那么attached page怎样理解呢?我们知道, <strong>ViewPager有着预加载机制</strong> ,默认的预加载数量是1,即中心Item向左的一个Item以及向右的一个Item,由于预加载机制的存在使得ViewPager在滑动的过程中不会感到卡顿,因为需要展示的页面已经提前准备好了。</p>    <p>②关注transformPage(page,position)的方法参数,这里的position是存在一个范围的,0代表当前被选中的Page的位置,位于中心,如果当前Page向左滑动,那么position会从0减到-1,当Page向右滑动,position会从0增加到1。当一个page的position变为-1的时候,这个page便位于中心Item的左边了,相对的,position变成1的时候,这个page便位于中心Item的右边。利用这个position变化的性质,我们可以很轻松地对View的某些属性进行改变了。</p>    <p>接下来,新建RotationPageTransformer.java文件:</p>    <pre>  <code class="language-java">public class RotationPageTransformer implements ViewPager.PageTransformer {        private static final float MIN_SCALE=0.85f;        @Override      public void transformPage(View page, float position) {          float scaleFactor = Math.max(MIN_SCALE,1 - Math.abs(position));          float rotate = 10 * Math.abs(position);          //position小于等于1的时候,代表page已经位于中心item的最左边,          //此时设置为最小的缩放率以及最大的旋转度数          if (position <= -1){              page.setScaleX(MIN_SCALE);              page.setScaleY(MIN_SCALE);              page.setRotationY(rotate);          }//position从0变化到-1,page逐渐向左滑动          else if (position < 0){              page.setScaleX(scaleFactor);              page.setScaleY(scaleFactor);              page.setRotationY(rotate);          }//position从0变化到1,page逐渐向右滑动          else if (position >=0 && position < 1){              page.setScaleX(scaleFactor);              page.setScaleY(scaleFactor);              page.setRotationY(-rotate);          }//position大于等于1的时候,代表page已经位于中心item的最右边          else if (position >= 1){              page.setScaleX(scaleFactor);              page.setScaleY(scaleFactor);              page.setRotationY(-rotate);          }      }  }</code></pre>    <p>接着,我们为ViewPager设置这样一个属性即可:</p>    <pre>  <code class="language-java">mViewPager.setPageTransformer(true,new RotationPageTransformer());  mViewPager.setOffscreenPageLimit(2); //下面会说到</code></pre>    <p>我们运行一下代码,会发现结果跟最上面展示的效果图是一样的,此时滑动ViewPager,各个Item之间的切换也会有动画的出现,呈现出了3D效果。</p>    <p>3、setPageMargin(int)方法,PageMargin属性用于设置两个Page之间的距离,有需要的可以加上该属性,使得两个Page的区分更加明显。</p>    <p>4、setOffscreenPageLimit(int)方法,OffscreenPageLimit属性用于设置预加载的数量,比如说这里设置了2,那么就会预加载中心item左边两个Item和右边两个Item。那么这里这个属性对于我们的3D效果有什么影响呢?我们来试验一下,首先调用mViewPager.setOffscreenPageLimit(1),把预加载数量设置为1,然后运行程序,向左右滑动几次,会发现出现了下面的问题:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/3d0a4edbbfce63e83733c5a56b7077c9.png"></p>    <p style="text-align:center">ic_02.png</p>    <p>即左边或者右边的Item在滑动的过程中有可能出现不正确的显示, <strong>这是为什么呢?</strong> 其实这是预加载的数量的问题,当前如果处于position为0的情况下,此时已经预加载了position为1的Item,那么该Item能正常显示,然而当滑动的时候,由于ViewPager是停止滑动的时候才会加载需要的Item,导致滑动到item1的时候,已经没有需要显示的Item2了(因此此时尚未加载),但是当手指松开的时候,Item2得到加载,但是此时不再调用transformPage()方法来调整自身的显示,所以造成了上面的错误显示。解决的办法是可以把预加载的数量设置为2或者3,这样得到的效果更好。</p>    <h2>优化</h2>    <p>在实现以上效果后,我们需要重新审视一遍我们的代码,看看是否还有优化的空间。</p>    <p>1、我们在Adapter中的instantiateItem()方法内加载一个View,并用了ImageView的setImageResource()方法来加载图片,其实查看该方法的源码可知,这个方法是在UI线程内加载图片的,如果加载的是很大的一张图片,那么就造成了UI线程的拥堵。</p>    <p>2、对于已经加载的图片,没有得到充分的利用,而是每次都加载一次,而旧的图片由于失去了引用又处于待回收的状态,这样不断的加载和回收无疑是加重了系统的负担。</p>    <p>3、如果ImageView的宽高小于图片的规格,那么把完整的一个大图加载到ImageView内,显然也是不合适的。因为图片越大的话,其占用的内存也越大。</p>    <p>针对上述所说的情况,我们可以一一找到对应的解决办法:</p>    <p>1、对于在UI线程加载图片的情况,我们可以考虑在子线程加载图片,等图片加载完毕后在通知主线程把图片设置进ImageView内即可。自然我们会想到使用Handler来进行线程之间的通信。但是这又引发一个问题,如果每一次的instantiateItem()方法内我们都新开一条线程去加载图片,那么最终的结果是创建了很多只用了一次的线程,这样的开销更大了。那有没有可以控制子线程的方法呢?答案是线程池。线程池通过合理调度线程的使用,使得线程达到最大的使用效率。那么我们可以直接使用 <strong>AsyncTask</strong> 来实现以上功能,因为AsyncTask内部也用到了线程池。</p>    <p>我们在MyPagerAdapter.java内新建一个内部类:</p>    <pre>  <code class="language-java">private class LoadBitmapTask extends AsyncTask<Integer,Void,Bitmap>{            private ImageView imageView;            public LoadBitmapTask(ImageView imageView){              this.imageView = imageView;          }            @Override          protected Bitmap doInBackground(Integer... params) {              Bitmap bitmap = BitmapFactory.decodeResource(mContext.getResources(),params[0]);              return bitmap;          }            @Override          protected void onPostExecute(Bitmap bitmap) {              imageView.setImageBitmap(bitmap);          }        }</code></pre>    <p>然后在instantiateItem()方法内添加如下代码: new LoadBitmapTask(imageView).execute(mBitmapIds[position]); 这样便开启了异步任务,在后台线程内加载我们的图片。</p>    <p>2、对于高效利用已经加载好的图片,我们可以这样理解:因为如果一个Item被destroy后,它就会从它的父容器中移除,然后它的drawable(已经设置好的Bitmap)接着会在某个时刻被gc回收。但是,用户可能会来回滑动页面,那么之前的无用Bitmap其实可以再度利用,而不是重新加载一遍。自然,我们可以想到的是利用LruCache来进行内存缓存,对Bitmap保存一个强引用,这样就不会被gc回收,等到需要用的时候再返回这个Bitmap,对不常用的bitmap进行回收即可。这样便提高了Bitmap的利用效率,不会重复加载Bitmap,也能使内存的消耗保存在一个合理的范围之内。使用LruCache也很简单:</p>    <p>①首先我们在MyPagerAdapyer的构造方法内初始化LruCache:</p>    <pre>  <code class="language-java">public MyPagerAdapter(int[] data,Context context){          mBitmapIds = data;          mContext = context;            int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);          int cacheSize = maxMemory * 3 / 8;  //缓存区的大小          mCache = new LruCache<Integer, Bitmap>(cacheSize){              @Override              protected int sizeOf(Integer key, Bitmap value) {                  return value.getRowBytes() * value.getHeight();  //返回Bitmap的大小              }          };      }</code></pre>    <p>②新建一个方法:</p>    <pre>  <code class="language-java">public void loadBitmapIntoTarget(Integer id,ImageView imageView){          //首先尝试从内存缓存中获取是否有对应id的Bitmap          Bitmap bitmap = mCache.get(id);          if (bitmap != null){              imageView.setImageBitmap(bitmap);          }else {              //如果没有则开启异步任务去加载              new LoadBitmapTask(imageView).execute(id);          }      }</code></pre>    <p>③对LoadBitmapTask作微小的修改,主要是在异步加载任务之后,向内存缓存中添加bitmap:</p>    <pre>  <code class="language-java">private class LoadBitmapTask extends AsyncTask<Integer,Void,Bitmap>{            @Override          protected Bitmap doInBackground(Integer... params) {              Bitmap bitmap = BitmapFactory.decodeResource(mContext.getResources(),params[0]);              //把加载好的Bitmap放进LruCache内              mCache.put(params[0],bitmap);              return bitmap;          }      }</code></pre>    <p>④最后,在我们的instantiate()方法内调用我们的loadBitmapIntoTarget方法即可:</p>    <pre>  <code class="language-java">loadBitmapIntoTarget(mBitmapIds[position],imageView);</code></pre>    <p>3、对于最后一种情况,我们可以考虑在加载图片之前,对图片进行缩放,使得图片的规格符合ImageView,那么就不会造成内存的浪费了,那么怎样对一个Bitmap进行缩放呢?</p>    <p>我们知道,一般加载图片都是利用BitmapFactory的几个decode方法来加载,但我们观察这几个方法,会发现它们各自还有一个带options参数的重载方法,即BitmapFactory.Options,那么Bitmap的缩放玄机就在这个Options内。Options有一个成员变量:inSampleSize,采样率,即设置对Bitmap的采样率,比如说inSampleSize默认为1,此时Bitmap的采样宽高等于原始宽高,不做任何改变。如果inSampleSize等于2,那么采样宽高都为原始宽高的1/2,那么大小就变成了原始大小的1/4,因此利用好这个inSampleSize能很好地控制一个Bitmap的大小。具体的使用方法可参考如下:</p>    <pre>  <code class="language-java">private int calculateInSampleSize(BitmapFactory.Options options,int reqWidth,int reqHeight){          int height = options.outHeight;          int width = options.outWidth;          int inSampleSize = 1;            if (height >= reqHeight || width > reqWidth){              while ((height / (2 * inSampleSize)) >= reqHeight                      && (width / (2 * inSampleSize)) >= reqWidth){                  inSampleSize *= 2;              }          }          return inSampleSize;      }      //dp转换成px      public static int dp2px(Context context, float dpValue) {          final float scale = context.getResources().getDisplayMetrics().density;          return (int) (dpValue * scale + 0.5f);      }      private class LoadBitmapTask extends AsyncTask<Integer,Void,Bitmap>{            @Override          protected Bitmap doInBackground(Integer... params) {                BitmapFactory.Options options = new BitmapFactory.Options();              options.inJustDecodeBounds = true;     //1、inJustDecodeBounds置为true,此时只加载图片的宽高信息              BitmapFactory.decodeResource(mContext.getResources(),params[0],options);              options.inSampleSize = calculateInSampleSize(options,                      dp2px(mContext,240),                      dp2px(mContext,360));          //2、根据ImageView的宽高计算所需要的采样率              options.inJustDecodeBounds = false;    //3、inJustDecodeBounds置为false,正常加载图片              Bitmap bitmap = BitmapFactory.decodeResource(mContext.getResources(),params[0],options);              //把加载好的Bitmap放进LruCache内              mCache.put(params[0],bitmap);              return bitmap;          }      }</code></pre>    <p>有一点要说明的是,笔者这里使用的图片是960 * 640的,比ImageView的宽高要小,所以体现不出图片的缩放,读者可以自行改变ImageView的大小,或者加载一张更大规格的图片。</p>    <p>最后,放上修改后MyPagerAdapter.java的完整代码,以供读者参考:</p>    <pre>  <code class="language-java">public class MyPagerAdapter extends PagerAdapter {        private int[] mBitmapIds;      private Context mContext;      private LruCache<Integer,Bitmap> mCache;        public MyPagerAdapter(int[] data,Context context){          mBitmapIds = data;          mContext = context;            int maxMemory = (int) (Runtime.getRuntime().maxMemory() / 1024);          int cacheSize = maxMemory * 3 / 8;  //缓存区的大小          mCache = new LruCache<Integer, Bitmap>(cacheSize){              @Override              protected int sizeOf(Integer key, Bitmap value) {                  return value.getRowBytes() * value.getHeight();              }          };      }        @Override      public int getCount() {          return mBitmapIds.length;      }        @Override      public boolean isViewFromObject(View view, Object object) {          return view == object;      }        @Override      public Object instantiateItem(ViewGroup container, int position) {          View view = LayoutInflater.from(mContext).inflate(R.layout.item_main,container,false);          ImageView imageView = (ImageView) view.findViewById(R.id.iv);          loadBitmapIntoTarget(mBitmapIds[position],imageView);          container.addView(view);          return view;      }        @Override      public void destroyItem(ViewGroup container, int position, Object object) {          container.removeView((View) object);      }        public void loadBitmapIntoTarget(Integer id,ImageView imageView){          //首先尝试从内存缓存中获取是否有对应id的Bitmap          Bitmap bitmap = mCache.get(id);          if (bitmap != null){              imageView.setImageBitmap(bitmap);          }else {              //如果没有则开启异步任务去加载              new LoadBitmapTask(imageView).execute(id);          }        }        private int calculateInSampleSize(BitmapFactory.Options options,int reqWidth,int reqHeight){          int height = options.outHeight;          int width = options.outWidth;          int inSampleSize = 1;            if (height >= reqHeight || width > reqWidth){              while ((height / (2 * inSampleSize)) >= reqHeight                      && (width / (2 * inSampleSize)) >= reqWidth){                  inSampleSize *= 2;              }          }          return inSampleSize;      }        public static int dp2px(Context context, float dpValue) {          final float scale = context.getResources().getDisplayMetrics().density;          return (int) (dpValue * scale + 0.5f);      }        private class LoadBitmapTask extends AsyncTask<Integer,Void,Bitmap>{            private ImageView imageView;            public LoadBitmapTask(ImageView imageView){              this.imageView = imageView;          }            @Override          protected Bitmap doInBackground(Integer... params) {                BitmapFactory.Options options = new BitmapFactory.Options();              options.inJustDecodeBounds = true;     //1、inJustDecodeBounds置为true,此时只加载图片的宽高信息              BitmapFactory.decodeResource(mContext.getResources(),params[0],options);              options.inSampleSize = calculateInSampleSize(options,                      dp2px(mContext,240),                      dp2px(mContext,360));          //2、根据ImageView的宽高计算所需要的采样率              options.inJustDecodeBounds = false;    //3、inJustDecodeBounds置为false,正常加载图片              Bitmap bitmap = BitmapFactory.decodeResource(mContext.getResources(),params[0],options);              //把加载好的Bitmap放进LruCache内              mCache.put(params[0],bitmap);              return bitmap;          }            @Override          protected void onPostExecute(Bitmap bitmap) {              imageView.setImageBitmap(bitmap);          }        }  }</code></pre>    <p>最后,感谢你的阅读,希望这篇文章对你有所帮助~</p>    <p> </p>    <p>来自:http://www.jianshu.com/p/1f6c5764dd72</p>    <p> </p>