Android 瀑布流照片墙实现,体验不规则排列的美感
传统界面的布局方式总是行列分明、坐落有序的,这种布局已是司空见惯,在不知不觉中大家都已经对它产生了审美疲劳。这个时候瀑布流布局的出现,就给人带来了耳目一新的感觉,这种布局虽然看上去貌似毫无规律,但是却有一种说不上来的美感,以至于涌现出了大批的网站和应用纷纷使用这种新颖的布局来设计界面。
记得我在之前已经写过一篇关于如何在 Android 上实现照片墙功能的文章了,但那个时候是使用的 GridView 来进行布局的,这种布局方式只适用于“墙”上的每张图片大小都相同的情况,如果图片的大小参差不齐,在 GridView 中显示就会非常的难看。而使用瀑布流的布局方式就可以很好地解决这个问题,因此今天我们也来赶一下潮流,看看如何在Android 上实现瀑布流照片墙的功能。
首先还是讲一下实现原理,瀑布流的布局方式虽然看起来好像排列的很随意,其实它是很有科学的排列规则的。整个界面会根据屏幕的宽度划分成等宽的若干列,由于手机的屏幕不是很大,这里我们就分成三列。每当需要添加一张图片时,会将这张图片的宽度压缩成和列一样宽,再按照同样的压缩比例对图片的高度进行压缩,然后在这三列中找出当前高度最小的一列,将图片添加到这一列中。之后每当需要添加一张新图片时,都去重复上面的操作,就会形成瀑布流格局的照片墙,示意图如下所示。
听我这么说完后,你可能会觉得瀑布流的布局非常简单嘛,只需要使用三个 LinearLayout 平分整个屏幕宽度,然后动态地 addView() 进去就好了。确实如此,如果只是为了实现功能的话,就是这么简单。可是别忘了,我们是在手机上进行开发,如果不停地往 LinearLayout 里添加图片,程序很快就会 OOM。因此我们还需要一个合理的方案来对图片资源进行释放,这里仍然是准备使用 LruCache 算法,对这个算法不熟悉的朋友可以先参考Android高效加载大图、多图方案,有效避免程序OOM 。 下面我们就来开始实现吧,新建一个 Android 项目,起名叫 PhotoWallFallsDemo,并选择 4.0 的API。 第一个要考虑的问题是,我们到哪儿去收集这些大小参差不齐的图片呢?这里我事先在百度上搜索了很多张风景图片,并且为了保证它们访问的稳定性,我将这些图片都上传到了我的CSDN相册里,因此只要从这里下载图片就可以了。新建一个Images类,将所有相册中图片的网址都配置进去,代码如下所示: public class Images { public final static String[] imageThumbUrls = new String[] { "//img-my.csdn.net/uploads/201309/01/1378037235_3453.jpg", "//img-my.csdn.net/uploads/201309/01/1378037235_7476.jpg", "//img-my.csdn.net/uploads/201309/01/1378037235_9280.jpg", "//img-my.csdn.net/uploads/201309/01/1378037234_3539.jpg", "//img-my.csdn.net/uploads/201309/01/1378037234_6318.jpg", "//img-my.csdn.net/uploads/201309/01/1378037194_2965.jpg", "//img-my.csdn.net/uploads/201309/01/1378037193_1687.jpg", "//img-my.csdn.net/uploads/201309/01/1378037193_1286.jpg", "//img-my.csdn.net/uploads/201309/01/1378037192_8379.jpg", "//img-my.csdn.net/uploads/201309/01/1378037178_9374.jpg", "//img-my.csdn.net/uploads/201309/01/1378037177_1254.jpg", "//img-my.csdn.net/uploads/201309/01/1378037177_6203.jpg", "//img-my.csdn.net/uploads/201309/01/1378037152_6352.jpg", "//img-my.csdn.net/uploads/201309/01/1378037151_9565.jpg", "//img-my.csdn.net/uploads/201309/01/1378037151_7904.jpg", "//img-my.csdn.net/uploads/201309/01/1378037148_7104.jpg", "//img-my.csdn.net/uploads/201309/01/1378037129_8825.jpg", "//img-my.csdn.net/uploads/201309/01/1378037128_5291.jpg", "//img-my.csdn.net/uploads/201309/01/1378037128_3531.jpg", "//img-my.csdn.net/uploads/201309/01/1378037127_1085.jpg", "//img-my.csdn.net/uploads/201309/01/1378037095_7515.jpg", "//img-my.csdn.net/uploads/201309/01/1378037094_8001.jpg", "//img-my.csdn.net/uploads/201309/01/1378037093_7168.jpg", "//img-my.csdn.net/uploads/201309/01/1378037091_4950.jpg", "//img-my.csdn.net/uploads/201308/31/1377949643_6410.jpg", "//img-my.csdn.net/uploads/201308/31/1377949642_6939.jpg", "//img-my.csdn.net/uploads/201308/31/1377949630_4505.jpg", "//img-my.csdn.net/uploads/201308/31/1377949630_4593.jpg", "//img-my.csdn.net/uploads/201308/31/1377949629_7309.jpg", "//img-my.csdn.net/uploads/201308/31/1377949629_8247.jpg", "//img-my.csdn.net/uploads/201308/31/1377949615_1986.jpg", "//img-my.csdn.net/uploads/201308/31/1377949614_8482.jpg", "//img-my.csdn.net/uploads/201308/31/1377949614_3743.jpg", "//img-my.csdn.net/uploads/201308/31/1377949614_4199.jpg", "//img-my.csdn.net/uploads/201308/31/1377949599_3416.jpg", "//img-my.csdn.net/uploads/201308/31/1377949599_5269.jpg", "//img-my.csdn.net/uploads/201308/31/1377949598_7858.jpg", "//img-my.csdn.net/uploads/201308/31/1377949598_9982.jpg", "//img-my.csdn.net/uploads/201308/31/1377949578_2770.jpg", "//img-my.csdn.net/uploads/201308/31/1377949578_8744.jpg", "//img-my.csdn.net/uploads/201308/31/1377949577_5210.jpg", "//img-my.csdn.net/uploads/201308/31/1377949577_1998.jpg", "//img-my.csdn.net/uploads/201308/31/1377949482_8813.jpg", "//img-my.csdn.net/uploads/201308/31/1377949481_6577.jpg", "//img-my.csdn.net/uploads/201308/31/1377949480_4490.jpg", "//img-my.csdn.net/uploads/201308/31/1377949455_6792.jpg", "//img-my.csdn.net/uploads/201308/31/1377949455_6345.jpg", "//img-my.csdn.net/uploads/201308/31/1377949442_4553.jpg", "//img-my.csdn.net/uploads/201308/31/1377949441_8987.jpg", "//img-my.csdn.net/uploads/201308/31/1377949441_5454.jpg", "//img-my.csdn.net/uploads/201308/31/1377949454_6367.jpg", "//img-my.csdn.net/uploads/201308/31/1377949442_4562.jpg" }; } 然后新建一个ImageLoader类,用于方便对图片进行管理,代码如下所示: public class ImageLoader { /** * 图片缓存技术的核心类,用于缓存所有下载好的图片,在程序内存达到设定值时会将最少最近使用的图片移除掉。 */ private static LruCache<String, Bitmap> mMemoryCache; /** * ImageLoader的实例。 */ private static ImageLoader mImageLoader; private ImageLoader() { // 获取应用程序最大可用内存 int tMaxMemory = (int) Runtime.getRuntime().maxMemory(); int tCacheSize = tMaxMemory / 8; // 设置图片缓存大小为程序最大可用内存的1/8 mMemoryCache = new LruCache<String, Bitmap>(tCacheSize) { @Override protected int sizeOf(String key, Bitmap bitmap) { return bitmap.getByteCount(); } }; } /** * 获取ImageLoader的实例。 * * @return ImageLoader的实例。 */ public static ImageLoader getInstance() { if (mImageLoader == null) { mImageLoader = new ImageLoader(); } return mImageLoader; } /** * 将一张图片存储到LruCache中。 * * @param key * LruCache的键,这里传入图片的URL地址。 * @param bitmap * LruCache的键,这里传入从网络上下载的Bitmap对象。 */ public void addBitmapToMemoryCache(String key, Bitmap bitmap) { if (getBitmapFromMemoryCache(key) == null) { mMemoryCache.put(key, bitmap); } } /** * 从 LruCache 中获取一张图片,如果不存在就返回 null。 * * @param key * LruCache 的键,这里传入图片的 URL 地址。 * @return 对应传入键的 Bitmap 对象,或者 null。 */ public Bitmap getBitmapFromMemoryCache(String key) { return mMemoryCache.get(key); } public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth) { // 源图片的宽度 final int tWidth = options.outWidth; int tInSampleSize = 1; if (tWidth > reqWidth) { // 计算出实际宽度和目标宽度的比率 final int tWidthRatio = Math.round((float) tWidth / (float) reqWidth); tInSampleSize = tWidthRatio; } return tInSampleSize; } public static Bitmap decodeSampledBitmapFromResource(String pathName, int reqWidth) { // 第一次解析将inJustDecodeBounds设置为true,来获取图片大小 final BitmapFactory.Options tOptions = new BitmapFactory.Options(); tOptions.inJustDecodeBounds = true; BitmapFactory.decodeFile(pathName, tOptions); // 调用上面定义的方法计算 inSampleSize 值 tOptions.inSampleSize = calculateInSampleSize(tOptions, reqWidth); // 使用获取到的inSampleSize值再次解析图片 tOptions.inJustDecodeBounds = false; return BitmapFactory.decodeFile(pathName, tOptions); } } 这里我们将 ImageLoader 类设成单例,并在构造函数中初始化了LruCache类,把它的最大缓存容量设为最大可用内存的1/8。然后又提供了其它几个方法可以操作LruCache,以及对图片进行压缩和读取。 接下来新建 MyScrollView 继承自 ScrollView,代码如下所示: public class MyScrollView extends ScrollView implements OnTouchListener { /** * 每页要加载的图片数量 */ public static final int PAGE_SIZE = 15; /** * 记录当前已加载到第几页 */ private int mPage; /** * 每一列的宽度 */ private int mColumnWidth; /** * 当前第一列的高度 */ private int mmFirstColumnHeight; /** * 当前第二列的高度 */ private int mmSecondColumnHeight; /** * 当前第三列的高度 */ private int mmThirdColumnHeight; /** * 是否已加载过一次 layout,这里 onLayout 中的初始化只需加载一次 */ private boolean mLoadOnce; /** * 对图片进行管理的工具类 */ private ImageLoader mImageLoader; /** * 第一列的布局 */ private LinearLayout mFirstColumn; /** * 第二列的布局 */ private LinearLayout mSecondColumn; /** * 第三列的布局 */ private LinearLayout mThirdColumn; /** * 记录所有正在下载或等待下载的任务。 */ private static Set<LoadImageTask> mTaskCollection; /** * MyScrollView下的直接子布局。 */ private static View mScrollLayout; /** * MyScrollView布局的高度。 */ private static int mScrollViewHeight; /** * 记录上垂直方向的滚动距离。 */ private static int mLastScrollY = -1; /** * 记录所有界面上的图片,用以可以随时控制对图片的释放。 */ private List<ImageView> mImageViewList = new ArrayList<ImageView>(); /** * 在Handler中进行图片可见性检查的判断,以及加载更多图片的操作。 */ private static Handler mHandler = new Handler() { public void handleMessage(android.os.Message msg) { MyScrollView tMyScrollView = (MyScrollView) msg.obj; int tScrollY = tMyScrollView.getScrollY(); // 如果当前的滚动位置和上次相同,表示已停止滚动 if (tScrollY == mLastScrollY) { // 当滚动的最底部,并且当前没有正在下载的任务时,开始加载下一页的图片 if (mScrollViewHeight + tScrollY >= mScrollLayout.getHeight() && mTaskCollection.isEmpty()) { tMyScrollView.loadMoreImages(); } tMyScrollView.checkVisibility(); } else { mLastScrollY = tScrollY; Message message = new Message(); message.obj = tMyScrollView; // 5毫秒后再次对滚动位置进行判断 mHandler.sendMessageDelayed(message, 5); } }; }; /** * MyScrollView的构造函数。 * * @param context * @param attrs */ public MyScrollView(Context context, AttributeSet attrs) { super(context, attrs); mImageLoader = ImageLoader.getInstance(); mTaskCollection = new HashSet<LoadImageTask>(); setOnTouchListener(this); } /** * 监听用户的触屏事件,如果用户手指离开屏幕则开始进行滚动检测。 */ @Override public boolean onTouch(View v, MotionEvent event) { if (event.getAction() == MotionEvent.ACTION_UP) { Message message = new Message(); message.obj = this; mHandler.sendMessageDelayed(message, 5); } return false; } /** * 进行一些关键性的初始化操作,获取MyScrollView的高度,以及得到第一列的宽度值。并在这里开始加载第一页的图片。 */ @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { super.onLayout(changed, l, t, r, b); if (changed && !mLoadOnce) { mScrollViewHeight = getHeight(); mScrollLayout = getChildAt(0); mFirstColumn = (LinearLayout) findViewById(R.id.first_column); mSecondColumn = (LinearLayout) findViewById(R.id.second_column); mThirdColumn = (LinearLayout) findViewById(R.id.third_column); mColumnWidth = mFirstColumn.getWidth(); mLoadOnce = true; loadMoreImages(); } } /** * 开始加载下一页的图片,每张图片都会开启一个异步线程去下载。 */ public void loadMoreImages() { if (hasSDCard()) { int startIndex = mPage * PAGE_SIZE; int endIndex = mPage * PAGE_SIZE + PAGE_SIZE; if (startIndex < Images.imageUrls.length) { Toast.makeText(getContext(), "正在加载...", Toast.LENGTH_SHORT) .show(); if (endIndex > Images.imageUrls.length) { endIndex = Images.imageUrls.length; } for (int i = startIndex; i < endIndex; i++) { LoadImageTask task = new LoadImageTask(); mTaskCollection.add(task); task.execute(Images.imageUrls[i]); } mPage++; } else { Toast.makeText(getContext(), "已没有更多图片", Toast.LENGTH_SHORT) .show(); } } else { Toast.makeText(getContext(), "未发现SD卡", Toast.LENGTH_SHORT).show(); } } /** * 遍历 mImageViewList 中的每张图片,对图片的可见性进行检查,如果图片已经离开屏幕可见范围,则将图片替换成一张空图。 */ public void checkVisibility() { for (int i = 0; i < mImageViewList.size(); i++) { ImageView imageView = mImageViewList.get(i); int borderTop = (Integer) imageView.getTag(R.string.border_top); int borderBottom = (Integer) imageView .getTag(R.string.border_bottom); if (borderBottom > getScrollY() && borderTop < getScrollY() + mScrollViewHeight) { String imageUrl = (String) imageView.getTag(R.string.image_url); Bitmap bitmap = mImageLoader.getBitmapFromMemoryCache(imageUrl); if (bitmap != null) { imageView.setImageBitmap(bitmap); } else { LoadImageTask task = new LoadImageTask(imageView); task.execute(imageUrl); } } else { imageView.setImageResource(R.drawable.empty_photo); } } } /** * 判断手机是否有SD卡。 * * @return 有SD卡返回true,没有返回false。 */ private boolean hasSDCard() { return Environment.MEDIA_MOUNTED.equals(Environment .getExternalStorageState()); } /** * 异步下载图片的任务。 * * @author guolin */ class LoadImageTask extends AsyncTask<String, Void, Bitmap> { /** * 图片的URL地址 */ private String mImageUrl; /** * 可重复使用的ImageView */ private ImageView mImageView; public LoadImageTask() { } /** * 将可重复使用的ImageView传入 * * @param imageView */ public LoadImageTask(ImageView imageView) { mImageView = imageView; } @Override protected Bitmap doInBackground(String... params) { mImageUrl = params[0]; Bitmap imageBitmap = mImageLoader .getBitmapFromMemoryCache(mImageUrl); if (imageBitmap == null) { imageBitmap = loadImage(mImageUrl); } return imageBitmap; } @Override protected void onPostExecute(Bitmap bitmap) { if (bitmap != null) { double ratio = bitmap.getWidth() / (mColumnWidth * 1.0); int scaledHeight = (int) (bitmap.getHeight() / ratio); addImage(bitmap, mColumnWidth, scaledHeight); } mTaskCollection.remove(this); } /** * 根据传入的URL,对图片进行加载。如果这张图片已经存在于SD卡中,则直接从SD卡里读取,否则就从网络上下载。 * * @param imageUrl * 图片的URL地址 * @return 加载到内存的图片。 */ private Bitmap loadImage(String imageUrl) { File imageFile = new File(getImagePath(imageUrl)); if (!imageFile.exists()) { downloadImage(imageUrl); } if (imageUrl != null) { Bitmap bitmap = ImageLoader.decodeSampledBitmapFromResource( imageFile.getPath(), mColumnWidth); if (bitmap != null) { mImageLoader.addBitmapToMemoryCache(imageUrl, bitmap); return bitmap; } } return null; } /** * 向ImageView中添加一张图片 * * @param bitmap * 待添加的图片 * @param imageWidth * 图片的宽度 * @param imageHeight * 图片的高度 */ private void addImage(Bitmap bitmap, int imageWidth, int imageHeight) { LinearLayout.LayoutParams params = new LinearLayout.LayoutParams( imageWidth, imageHeight); if (mImageView != null) { mImageView.setImageBitmap(bitmap); } else { ImageView imageView = new ImageView(getContext()); imageView.setLayoutParams(params); imageView.setImageBitmap(bitmap); imageView.setScaleType(ScaleType.FIT_XY); imageView.setPadding(5, 5, 5, 5); imageView.setTag(R.string.image_url, mImageUrl); findColumnToAdd(imageView, imageHeight).addView(imageView); mImageViewList.add(imageView); } } /** * 找到此时应该添加图片的一列。原则就是对三列的高度进行判断,当前高度最小的一列就是应该添加的一列。 * * @param imageView * @param imageHeight * @return 应该添加图片的一列 */ private LinearLayout findColumnToAdd(ImageView imageView, int imageHeight) { if (mmFirstColumnHeight <= mmSecondColumnHeight) { if (mmFirstColumnHeight <= mmThirdColumnHeight) { imageView.setTag(R.string.border_top, mmFirstColumnHeight); mmFirstColumnHeight += imageHeight; imageView.setTag(R.string.border_bottom, mmFirstColumnHeight); return mFirstColumn; } imageView.setTag(R.string.border_top, mmThirdColumnHeight); mmThirdColumnHeight += imageHeight; imageView.setTag(R.string.border_bottom, mmThirdColumnHeight); return mThirdColumn; } else { if (mmSecondColumnHeight <= mmThirdColumnHeight) { imageView.setTag(R.string.border_top, mmSecondColumnHeight); mmSecondColumnHeight += imageHeight; imageView.setTag(R.string.border_bottom, mmSecondColumnHeight); return mSecondColumn; } imageView.setTag(R.string.border_top, mmThirdColumnHeight); mmThirdColumnHeight += imageHeight; imageView.setTag(R.string.border_bottom, mmThirdColumnHeight); return mThirdColumn; } } /** * 将图片下载到SD卡缓存起来。 * * @param imageUrl * 图片的URL地址。 */ private void downloadImage(String imageUrl) { HttpURLConnection con = null; FileOutputStream fos = null; BufferedOutputStream bos = null; BufferedInputStream bis = null; File imageFile = null; try { URL url = new URL(imageUrl); con = (HttpURLConnection) url.openConnection(); con.setConnectTimeout(5 * 1000); con.setReadTimeout(15 * 1000); con.setDoInput(true); con.setDoOutput(true); bis = new BufferedInputStream(con.getInputStream()); imageFile = new File(getImagePath(imageUrl)); fos = new FileOutputStream(imageFile); bos = new BufferedOutputStream(fos); byte[] b = new byte[1024]; int length; while ((length = bis.read(b)) != -1) { bos.write(b, 0, length); bos.flush(); } } catch (Exception e) { e.printStackTrace(); } finally { try { if (bis != null) { bis.close(); } if (bos != null) { bos.close(); } if (con != null) { con.disconnect(); } } catch (IOException e) { e.printStackTrace(); } } if (imageFile != null) { Bitmap bitmap = ImageLoader.decodeSampledBitmapFromResource( imageFile.getPath(), mColumnWidth); if (bitmap != null) { mImageLoader.addBitmapToMemoryCache(imageUrl, bitmap); } } } /** * 获取图片的本地存储路径。 * * @param imageUrl * 图片的URL地址。 * @return 图片的本地存储路径。 */ private String getImagePath(String imageUrl) { int lastSlashIndex = imageUrl.lastIndexOf("/"); String imageName = imageUrl.substring(lastSlashIndex + 1); String imageDir = Environment.getExternalStorageDirectory() .getPath() + "/PhotoWallFalls/"; File file = new File(imageDir); if (!file.exists()) { file.mkdirs(); } String imagePath = imageDir + imageName; return imagePath; } } } MyScrollView 是实现瀑布流照片墙的核心类,这里我来重点给大家介绍一下。首先它是继承自 ScrollView 的,这样就允许用户可以通过滚动的方式来浏览更多的图片。这里提供了一个 loadMoreImages() 方法,是专门用于加载下一页的图片的,因此在 onLayout() 方法中我们要先调用一次这个方法,以初始化第一页的图片。然后在 onTouch 方法中每当监听到手指离开屏幕的事件,就会通过一个 handler 来对当前 ScrollView 的滚动状态进行判断,如果发现已经滚动到了最底部,就会再次调用 loadMoreImages() 方法去加载下一页的图片。 那我们就要来看一看 loadMoreImages() 方法的内部细节了。在这个方法中,使用了一个循环来加载这一页中的每一张图片,每次都会开启一个 LoadImageTask,用于对图片进行异步加载。然后在 LoadImageTask 中,首先会先检查一下这张图片是不是已经存在于 SD 卡中了,如果还没存在,就从网络上下载,然后把这张图片存放在 LruCache 中。接着将这张图按照一定的比例进行压缩,并找出当前高度最小的一列,把压缩后的图片添加进去就可以了。 另外,为了保证照片墙上的图片都能够合适地被回收,这里还加入了一个可见性检查的方法,即checkVisibility()方法。这个方法的核心思想就是检查目前照片墙上的所有图片,判断出哪些是可见的,哪些是不可见。然后将那些不可见的图片都替换成一张空图,这样就可以保证程序始终不会占用过高的内存。当这些图片又重新变为可见的时候,只需要再从LruCache中将这些图片重新取出即可。如果某张图片已经从LruCache中被移除了,就会开启一个LoadImageTask,将这张图片重新加载到内存中。 然后打开或新建activity_main.xml,在里面设置好瀑布流的布局方式,如下所示: <?xml version="1.0" encoding="utf-8"?> <com.xjl.imageloaderdemo.photo_wall_falls.MyScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/my_scroll_view" android:layout_width="match_parent" android:layout_height="match_parent" > <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="horizontal" > <LinearLayout android:id="@+id/first_column" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" > </LinearLayout> <LinearLayout android:id="@+id/second_column" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" > </LinearLayout> <LinearLayout android:id="@+id/third_column" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" android:orientation="vertical" > </LinearLayout> </LinearLayout> </com.xjl.imageloaderdemo.photo_wall_falls.MyScrollView> 可以看到,这里我们使用了刚才编写好的 MyScrollView 作为根布局,然后在里面放入了一个直接子布局 LinearLayout 用于统计当前滑动布局的高度,然后在这个布局下又添加了三个等宽的 LinearLayout 分别作为第一列、第二列和第三列的布局,这样在 MyScrollView 中就可以动态地向这三个 LinearLayout 里添加图片了。 最后,由于我们使用到了网络和SD卡存储的功能,因此还需要在 AndroidManifest.xml 中添加以下权限: [html] view plaincopy <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.INTERNET" /> 这样我们所有的编码工作就已经完成了,现在可以尝试运行一下,效果如下图所示: 瀑布流模式的照片墙果真非常美观吧,而且由于我们有非常完善的资源释放机制,不管你在照片墙上添加了多少图片,程序占用内存始终都会保持在一个合理的范围内。在下一篇文章中,我会带着大家对这个程序进行进一步的完善,加入点击查看大图,以及多点触控缩放的功能,感觉兴趣的朋友请继续阅读Android多点触控技术实战,自由地对图片进行缩放和移动 。 好了,今天的讲解到此结束,有疑问的朋友请在下面留言。 源码下载,请点击这里