首页 > 代码库 > 从零封装一个Android大图查看器

从零封装一个Android大图查看器

背景:

大图查看器是许多app的常用功能,主要使用场景是用户点击图片,然后启动一个新界面来展示图片的完整尺寸,并能通过手势移动图片以及放大缩小。当然,上面说的是最基本的功能,实际使用中还要包括:如果是本地图片应该可以移除,如果是网络图片,应提供一个保存到本地的功能等。

本文为什么叫封装一个大图查看器,而不是叫做编写一个大图查看器呢?因为大图查看器的最核心功能,展示图片以及手势操控我们使用了一个开源库来完成,这个开源库叫做subsampling-scale-image-view,这个开源库非常靠谱,使用也非常简单,我记得知乎的Android app中也是使用了这个库。这个库的Github地址是:subsampling-scale-image-view。

我先把我做出来的效果图展示出来:

首先第一张是:

技术分享技术分享


然后来看第二张:

技术分享

两张界面截图展示的是同一张图片,但区别在于,第一张图片右下角有一个下载的图标,对应的是查看网络图片这种方式(大家都需要保存网络图片这个功能);而第二张图片右下角没有了图标,而右上角有一个垃圾桶的图标,用来进行移除操作,想象一下,本来查看的就是本地图片,那用户也就不用再保存它一次了,而移除操作在很多实际使用场景中有效,比如说在微信中,你想发朋友圈,你选择了一几张本地照片以后进行大图浏览,而这时候你发现其中几张不太好,想移除它,于是为了用户方便,应该给用户一个在大图查看器中进行移除的方法。

具体实现:

首先,确定一下大体的实现方式

我决定使用一个全屏的Dialog来实现图片的查看界面,Dialog应该是最简洁的实现方式,因为用户可能打开大图查看后会快速的关掉,如果使用Activity,要在短时间内创建和销毁Activity好像开销比较大,而Fragment貌似是一个可以考虑的方式,但Fragment由于必须是嵌入在Activity中,因此要实现某些效果,例如遮挡Activity中的其它View比较麻烦,所以也不考虑。
确定了展示方式,我们来考虑另一个问题,我们这个库是支持多图查看的,也就是左右滑动可以查看同一组中别的图片,因此,我选择使用ViewPager来实现这种效果。而每个ViewPager中放置一个Subsampling-Image-View来展示图片。
好了,我们整个大图查看器的大体实现方式已经确定好了,下面我们来一步一步实现。

正式开始实现,第一步:实现Dialog的布局

首先,定义一个style,代码如下:
<style name="Dialog_Fullscreen">
        <item name="android:windowFullscreen">true</item>
        <item name="android:windowNoTitle">true</item>
    </style>
这就是全屏Dialog的实现方式。接下来,定义Dialog的布局:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/black">

    <android.support.v4.view.ViewPager
        android:id="@+id/scale_image_view_pager"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <ImageView
        android:id="@+id/scale_image_close"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentTop="true"
        android:layout_alignParentStart="true"
        android:padding="24dp"
        android:src=http://www.mamicode.com/"@drawable/ic_clear_white_24dp">
代码很简单,唯一值得说的地方就是,里面放置了三个ImageView,分别作为:关闭Dialog,移除图片,保存图片到本地三个功能的按钮。

第二步,初始化大图查看器的实现

首先,我们,新建一个类,我起名叫ScaleImageView,然后,我们实现它的构造方法,并实现它的初始化。
public class ScaleImageView {

    private static final byte URLS = 0;
    private static final byte FILES = 1;
    private byte status;

    private Activity activity;

    private List<String> urls;
    private List<File> files;
    private List<File> downloadFiles;

    private int selectedPosition;

    private Dialog dialog;

    private ImageView delete;
    private ImageView download;
    private TextView imageCount;
    private ViewPager viewPager;

    private List<View> views;
    private MyPagerAdapter adapter;

    private OnDeleteItemListener listener;
    private int startPosition;

    public ScaleImageView(Activity activity) {
        this.activity = activity;
        init();
    }

    public void setOnDeleteItemListener(OnDeleteItemListener listener) {
        this.listener = listener;
    }

    private void init() {
        RelativeLayout relativeLayout = (RelativeLayout) activity.getLayoutInflater().inflate(R.layout.dialog_scale_image, null);
        ImageView close = (ImageView) relativeLayout.findViewById(R.id.scale_image_close);
        delete = (ImageView) relativeLayout.findViewById(R.id.scale_image_delete);
        download = (ImageView) relativeLayout.findViewById(R.id.scale_image_save);
        imageCount = (TextView) relativeLayout.findViewById(R.id.scale_image_count);
        viewPager = (ViewPager) relativeLayout.findViewById(R.id.scale_image_view_pager);
        dialog = new Dialog(activity, R.style.Dialog_Fullscreen);
        dialog.setContentView(relativeLayout);
        close.setOnClickListener(v -> dialog.dismiss());
        delete.setOnClickListener(v -> {
            int size = views.size();
            files.remove(selectedPosition);
            if (listener != null) {
                listener.onDelete(selectedPosition);
            }
            viewPager.removeView(views.remove(selectedPosition));
            if (selectedPosition != size) {
                int position = selectedPosition + 1;
                String text = position + "/" + views.size();
                imageCount.setText(text);
            }
            adapter.notifyDataSetChanged();
        });
        download.setOnClickListener(v -> {
            try {
                MediaStore.Images.Media.insertImage(activity.getContentResolver(),
                        downloadFiles.get(selectedPosition).getAbsolutePath(),
                        downloadFiles.get(selectedPosition).getName(), null);
            } catch (FileNotFoundException e) {
                e.printStackTrace();
            }
            Snackbar.make(viewPager, "图片保存成功", Snackbar.LENGTH_SHORT).show();
        });
        viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
            @Override
            public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {

            }

            @Override
            public void onPageSelected(int position) {
                selectedPosition = position;
                String text = ++position + "/" + views.size();
                imageCount.setText(text);
            }

            @Override
            public void onPageScrollStateChanged(int state) {

            }
        });
    }
}


首先,我们关注一下最上面三行定义的几个变量,URLS和FILES两个常量分别表示网络查看模式和本地查看模式,而status则用来标示现在查看器处于哪个模式下。
我们直接跳到下面,看构造方法,构造方法接收一个Activity的参数,用来当作Context使用。然后构造方法会调用Init()方法,我们接着看init方法的实现。
init()方法主要用来加载Dialog的布局,并获取其中每一个View的Id,并给几个可点击项设置了点击事件监听。例如close代表最上面界面截图中最左上角的“叉”,在它的点击事件监听中我们设置了关闭Dialog,即dialog.dissmiss()。delete代表本地查看模式下移除图片,download代表网络查看模式下的保存图片到本地,我们先说download,因为它比较简单,就是调用了一行代码,将下载的文件保存到相册并通知系统。其中值得说的是downFiles的类型是List<File>,它用来储存从网络下载下来的图片。而seletePosition则用来表示用户当前浏览的图片在ViewPager中的下标,说白了它就等于用户当前查看的第n张图片减一。
再来说一下delete,delete对一个接口类型的变量进行了判空,这个接口的定义如下:
public interface OnDeleteItemListener {
        void onDelete(int position);
    }

这个接口的作用在于,当调用者在移除了某张图片后,还需要做的事情就应该写在接口的onDelete方法中。举个例子,假如你用了一个GridView来展示9张图片,当用户在大图查看器中将第3张图片移除的时候,这个时候你的GridView应该和大图查看器保持一致,也把第三张图片移除掉,而GridView的移除逻辑,就应该写在onDelete方法中。
最后我们再看下ViewPager的选择事件监听,这里说一下,imageCount是用来显示当前用户浏览第几张图片的TextView,在上面的界面截图中它就位于图片的下方。我们在这里可以看到,当用户通过左右侧滑改变当前查看的图片的时候,也就是,切换ViewPager的显示页面的时候,selectPosition的值以及imageCount显示的内容都要发生改变。

第三步,实现如何设置网络查看模式和本地查看模式

我首先编写了如下两个方法:
    public void setUrls(List<String> urls, int startPosition) {
        if (this.urls == null) {
            this.urls = new ArrayList<>();
        } else {
            this.urls.clear();
        }
        this.urls.addAll(urls);
        status = URLS;
        delete.setVisibility(View.GONE);
        if (downloadFiles == null) {
            downloadFiles = new ArrayList<>();
        } else {
            downloadFiles.clear();
        }
        this.startPosition = startPosition++;
        String text = startPosition + "/" + urls.size();
        imageCount.setText(text);
    }

    public void setFiles(List<File> files, int startPosition) {
        if (this.files == null) {
            this.files = new LinkedList<>();
        } else {
            this.files.clear();
        }
        this.files.addAll(files);
        status = FILES;
        download.setVisibility(View.GONE);
        this.startPosition = startPosition++;
        String text = startPosition + "/" + files.size();
        imageCount.setText(text);
    }

当调用者想使用网络查看模式的时候,应该使用第一个方法——setUrls。调用者需要传入想要查看的这组图片的url并传入起始查看位置,举例来说,当调用者使用GridView来为用户展示图片的时候,用户可能点击了GridView的第三张图片,他想从第三张开始看起,而这第二个参数就是指的这个。后面的逻辑包括给类设置状态,即给status的值设置为URLS,然后清空或者初始化下载的图片,并给imageCount设置正确的显示内容。而setFiles也是类似的,我就不展开讲了,大致就是,要查看本地图片,调用者应该拿到本地图片的File对象并装入一个List然后传入。

最后一步,加载图片,并启动大图查看器

public void create() {
        dialog.show();
        views = new ArrayList<>();
        adapter = new MyPagerAdapter(views, dialog);
        if (status == URLS) {
            for (String url : urls) {
                FrameLayout frameLayout = (FrameLayout) activity.getLayoutInflater().inflate(R.layout.view_scale_image, null);
                SubsamplingScaleImageView imageView = (SubsamplingScaleImageView) frameLayout.findViewById(R.id.scale_image_view);
                views.add(frameLayout);
                IOThread.getSingleThread().execute(() -> {
                    File downLoadFile;
                    try {
                        downLoadFile = Glide.with(activity).load(url).downloadOnly(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL).get();
                        downloadFiles.add(downLoadFile);
                        activity.runOnUiThread(() -> imageView.setImage(ImageSource.uri(Uri.fromFile(downLoadFile))));
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                });
            }
            viewPager.setAdapter(adapter);
        } else if (status == FILES) {
            for (File file : files) {
                FrameLayout frameLayout = (FrameLayout) activity.getLayoutInflater().inflate(R.layout.view_scale_image, null);
                SubsamplingScaleImageView imageView = (SubsamplingScaleImageView) frameLayout.findViewById(R.id.scale_image_view);
                views.add(frameLayout);
                imageView.setImage(ImageSource.uri(Uri.fromFile(file)));
            }
            viewPager.setAdapter(adapter);
        }
        viewPager.setCurrentItem(startPosition);
    }

首先,这里要将Dialog显示出来,然后初始化ViewPager的Adapter,Adapter的代码如下所示:
private static class MyPagerAdapter extends PagerAdapter {

        private List<View> views;
        private Dialog dialog;

        MyPagerAdapter(List<View> views, Dialog dialog) {
            this.views = views;
            this.dialog = dialog;
        }

        @Override
        public Object instantiateItem(ViewGroup container, int position) {
            container.addView(views.get(position));
            return views.get(position);
        }

        @Override
        public void destroyItem(ViewGroup container, int position, Object object) {
            if (position == 0 && views.size() == 0) {
                dialog.dismiss();
                return;
            }
            if (position == views.size()) {
                container.removeView(views.get(--position));
            } else {
                container.removeView(views.get(position));
            }
        }

        @Override
        public int getCount() {
            return views.size();
        }

        @Override
        public boolean isViewFromObject(View view, Object object) {
            return view == object;
        }

        @Override
        public int getItemPosition(Object object) {
            return POSITION_NONE;
        }

    }

这是一个很简单的ViewPager适配器,唯一要说明的就是复写的destroyItem这个方法,它里面对应了多种情况,例如,当图片只有一张的时候,当移除的图片是一组图片的最后一张的时候,这些都要做相映的判断,否则使用的时候都会发生异常。
现在再回到create方法中,后面的代码就比较简单了,通过判断当前status的值,走不同的分支,如果是网络模式,则使用Glide在子线程(IOThread是一个在我的应用中的缓存了一个线程的线程池,它专门用来做一些耗时的工作,比如这里的下载,这种打杂的任务我都交给它来执行)中将图片都下载下来,然后装入downloadFiles,然后再把每一张分别显示在ViewPager每一页的Subsampling-Scale-Image-View中;如果是本地模式,则直接获取本地图片File的Uri并通过ScaleImageView显示,到此,任务就完成了,说明一下,这个create方法是必须调用者调用的。
最后,再给出一下ViewPager中每个Item的布局代码:
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
        android:id="@+id/scale_image_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>

</FrameLayout>

如何使用这个大图查看器呢?

如果查看网络图片:
ScaleImageView scaleImageView = new ScaleImageView(activity);
scaleImageView.setUrls(urls, position);
scaleImageView.create();

如果查看本地图片:
ScaleImageView scaleImageView = new ScaleImageView(activity);
scaleImageView.setFiles(files, position);
scaleImageView.setOnDeleteItemListener(deletePosition -> adapter.removeItem(deletePosition));
scaleImageView.create();

最后附上完整的java代码,一共也就两百多行,很简单:
public class ScaleImageView {

    private static final byte URLS = 0;
    private static final byte FILES = 1;
    private byte status;

    private Activity activity;

    private List<String> urls;
    private List<File> files;
    private List<File> downloadFiles;

    private int selectedPosition;

    private Dialog dialog;

    private ImageView delete;
    private ImageView download;
    private TextView imageCount;
    private ViewPager viewPager;

    private List<View> views;
    private MyPagerAdapter adapter;

    private OnDeleteItemListener listener;
    private int startPosition;

    public ScaleImageView(Activity activity) {
        this.activity = activity;
        init();
    }

    public void setUrls(List<String> urls, int startPosition) {
        if (this.urls == null) {
            this.urls = new ArrayList<>();
        } else {
            this.urls.clear();
        }
        this.urls.addAll(urls);
        status = URLS;
        delete.setVisibility(View.GONE);
        if (downloadFiles == null) {
            downloadFiles = new ArrayList<>();
        } else {
            downloadFiles.clear();
        }
        this.startPosition = startPosition++;
        String text = startPosition + "/" + urls.size();
        imageCount.setText(text);
    }

    public void setFiles(List<File> files, int startPosition) {
        if (this.files == null) {
            this.files = new LinkedList<>();
        } else {
            this.files.clear();
        }
        this.files.addAll(files);
        status = FILES;
        download.setVisibility(View.GONE);
        this.startPosition = startPosition++;
        String text = startPosition + "/" + files.size();
        imageCount.setText(text);
    }

    public void setOnDeleteItemListener(OnDeleteItemListener listener) {
        this.listener = listener;
    }

    private void init() {
        RelativeLayout relativeLayout = (RelativeLayout) activity.getLayoutInflater().inflate(R.layout.dialog_scale_image, null);
        ImageView close = (ImageView) relativeLayout.findViewById(R.id.scale_image_close);
        delete = (ImageView) relativeLayout.findViewById(R.id.scale_image_delete);
        download = (ImageView) relativeLayout.findViewById(R.id.scale_image_save);
        imageCount = (TextView) relativeLayout.findViewById(R.id.scale_image_count);
        viewPager = (ViewPager) relativeLayout.findViewById(R.id.scale_image_view_pager);
        dialog = new Dialog(activity, R.style.Dialog_Fullscreen);
        dialog.setContentView(relativeLayout);
        close.setOnClickListener(v -> dialog.dismiss());
        delete.setOnClickListener(v -> {
            int size = views.size();
            files.remove(selectedPosition);
            if (listener != null) {
                listener.onDelete(selectedPosition);
            }
            viewPager.removeView(views.remove(selectedPosition));
            if (selectedPosition != size) {
                int position = selectedPosition + 1;
                String text = position + "/" + views.size();
                imageCount.setText(text);
            }
            adapter.notifyDataSetChanged();
        });
        download.setOnClickListener(v -> {
            try {
                MediaStore.Images.Media.insertImage(activity.getContentResolver(),
                        downloadFiles.get(selectedPosition).getAbsolutePath(),
                        downloadFiles.get(selectedPosition).getName(), null);
            } catch (FileNotFoundException e) {
                e.printStackTrace();
            }
            Snackbar.make(viewPager, "图片保存成功", Snackbar.LENGTH_SHORT).show();
        });
        viewPager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
            @Override
            public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {

            }

            @Override
            public void onPageSelected(int position) {
                selectedPosition = position;
                String text = ++position + "/" + views.size();
                imageCount.setText(text);
            }

            @Override
            public void onPageScrollStateChanged(int state) {

            }
        });
    }

    public void create() {
        dialog.show();
        views = new ArrayList<>();
        adapter = new MyPagerAdapter(views, dialog);
        if (status == URLS) {
            for (String url : urls) {
                FrameLayout frameLayout = (FrameLayout) activity.getLayoutInflater().inflate(R.layout.view_scale_image, null);
                SubsamplingScaleImageView imageView = (SubsamplingScaleImageView) frameLayout.findViewById(R.id.scale_image_view);
                views.add(frameLayout);
                IOThread.getSingleThread().execute(() -> {
                    File downLoadFile;
                    try {
                        downLoadFile = Glide.with(activity).load(url).downloadOnly(Target.SIZE_ORIGINAL, Target.SIZE_ORIGINAL).get();
                        downloadFiles.add(downLoadFile);
                        activity.runOnUiThread(() -> imageView.setImage(ImageSource.uri(Uri.fromFile(downLoadFile))));
                    } catch (Exception e) {
                        e.printStackTrace();
                    }
                });
            }
            viewPager.setAdapter(adapter);
        } else if (status == FILES) {
            for (File file : files) {
                FrameLayout frameLayout = (FrameLayout) activity.getLayoutInflater().inflate(R.layout.view_scale_image, null);
                SubsamplingScaleImageView imageView = (SubsamplingScaleImageView) frameLayout.findViewById(R.id.scale_image_view);
                views.add(frameLayout);
                imageView.setImage(ImageSource.uri(Uri.fromFile(file)));
            }
            viewPager.setAdapter(adapter);
        }
        viewPager.setCurrentItem(startPosition);
    }

    private static class MyPagerAdapter extends PagerAdapter {

        private List<View> views;
        private Dialog dialog;

        MyPagerAdapter(List<View> views, Dialog dialog) {
            this.views = views;
            this.dialog = dialog;
        }

        @Override
        public Object instantiateItem(ViewGroup container, int position) {
            container.addView(views.get(position));
            return views.get(position);
        }

        @Override
        public void destroyItem(ViewGroup container, int position, Object object) {
            if (position == 0 && views.size() == 0) {
                dialog.dismiss();
                return;
            }
            if (position == views.size()) {
                container.removeView(views.get(--position));
            } else {
                container.removeView(views.get(position));
            }
        }

        @Override
        public int getCount() {
            return views.size();
        }

        @Override
        public boolean isViewFromObject(View view, Object object) {
            return view == object;
        }

        @Override
        public int getItemPosition(Object object) {
            return POSITION_NONE;
        }

    }

    public interface OnDeleteItemListener {
        void onDelete(int position);
    }

}

反思

这个大图查看器的代码只是比较适合我的项目,实际上封装的并不好,例如,在判断当前加载模式这个地方,更好的做法是使用策略模式,因为很多人的需求可能更多变,例如即使浏览本地图片,也不需要移除图片功能等等。另外如果要解耦的话,图片下载的功能是应该通过一个接口隔离出去的,在文中我用了Glide作为下载工具,但是也许有些人会使用OkHttp下载。我将这个大图查看器略改代码以后写在了一个moudle中,已经上传到了Github,其中图片下载器已经接口分离了,有兴趣的读者可以移步Github一看:CompleteImageView

从零封装一个Android大图查看器