源码解析相关安卓开发博客Android开发经验谈

知乎 Matisse 源码解析,带你探究高效图片选择库的秘密

2017-12-09  本文已影响847人  developerHaoz

本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布

目录

一、基本介绍


Matisse 是「知乎」开源的一款十分精美的本地图像和视频选择库。

Matisse

Matisse 的代码写的相当的简洁、规范,很有学习的价值。

讲一下 Matisse 的一些优点:

可以看到 Matisse 的可拓展性是非常强的,不仅可以自定义我们需要的主题,而且还可以按照需求来过滤出我们想要的文件,除此之外,Matisse 采用了建造者模式,使得我们可以通过链式调用的方式,配置各种各样的属性,使我们的图片选择更加灵活。

二、整体的设计和实现流程


在介绍 Matisse 的工作流程之前,我们先来看看几个比较重要的类,有助于我们后面的理解

类名 功能
Matisse 通过外部传入的 Activity 或 Fragment,以弱引用的形式进行保存,同时通过 from() 方法返回 SelectionCreator 进行各个参数的配置
SelectionCreator 通过建造者模式,链式配置我们需要的各种属性
MatisseActivity Matisse 首页的 Activity,将图片和视频进行展示

我们先从 Matisse 的使用入手,看看 Matisse 的工作流程。

Matisse.from(MainActivity.this)
        .choose(MimeType.allOf()) // 1、获取 SelectionCreator
        .countable(true)
        .maxSelectable(9)
        .addFilter(new GifSizeFilter(320, 320, 5 * Filter.K * Filter.K))
        .gridExpectedSize(getResources().getDimensionPixelSize(R.dimen.grid_expected_size))
        .restrictOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)
        .thumbnailScale(0.85f)
        .imageEngine(new GlideEngine()) // 2、配置各种各样的参数
        .forResult(REQUEST_CODE_CHOOSE); // 3、打开 MatisseActivity

上面的使用代码,我们以 Activity 为例,可以分成三部分来看

具体的流程图如下:


Matisse 流程图

以上便是 Matisse 的工作流程,接下来详细的分析下相关的类。有一点要先说明一下,我下面贴出的所有类中的源码并不是完整的代码,而是将源码中与性能、兼容性、扩展性有关的代码剔除后的「核心代码」。

Matisse

public final class Matisse {

    private final WeakReference<Activity> mContext;
    private final WeakReference<Fragment> mFragment;

    private Matisse(Activity activity, Fragment fragment) {
        mContext = new WeakReference<>(activity);
        mFragment = new WeakReference<>(fragment);
    }

    public static Matisse from(Activity activity) {
        return new Matisse(activity);
    }

    public static Matisse from(Fragment fragment) {
        return new Matisse(fragment);
    }

    /**
     *  在打开 MatisseActivity 的 Activity 或 Fragment 中获取用户选择的媒体 Uri 列表
     */
    public static List<Uri> obtainResult(Intent data) {
        return data.getParcelableArrayListExtra(MatisseActivity.EXTRA_RESULT_SELECTION);
    }

    public SelectionCreator choose(Set<MimeType> mimeTypes, boolean mediaTypeExclusive) {
        return new SelectionCreator(this, mimeTypes, mediaTypeExclusive);
    }

}

这个类的代码还是很简单的,将外部传入的 Activity 或 Fragment,用弱引用的形式保存,防止内存泄露。然后通过 choose() 方法返回 SelectionCreator 用于之后参数的配置。等到图片选择完成后,我们可以在 Fragment 或 Activity 中的 onActivityResult() 中通过 obtainResult() 获取我们所选择媒体的 Uri 列表。

SelectionCreator

public final class SelectionCreator {
    private final Matisse mMatisse;
    private final SelectionSpec mSelectionSpec;

    SelectionCreator(Matisse matisse, @NonNull Set<MimeType> mimeTypes) {
        mMatisse = matisse;
        mSelectionSpec = SelectionSpec.getCleanInstance();
        mSelectionSpec.mimeTypeSet = mimeTypes;
    }

    public SelectionCreator theme(@StyleRes int themeId) {
        mSelectionSpec.themeId = themeId;
        return this;
    }

    public SelectionCreator maxSelectable(int maxSelectable) {
        mSelectionSpec.maxSelectable = maxSelectable;
        return this;
    }
    // 其余方法都类似上面这两个,这里面就不贴出来了

    public void forResult(int requestCode) {
        Activity activity = mMatisse.getActivity();
        Intent intent = new Intent(activity, MatisseActivity.class);
        Fragment fragment = mMatisse.getFragment();
        if (fragment != null) {
            fragment.startActivityForResult(intent, requestCode);
        } else {
            activity.startActivityForResult(intent, requestCode);
        }
    }

}

可以看到 SelectionCreator 内部保存了 Matisse 的实例,用于获取外部调用的 Activity 或 Fragment,以及一个 SelectionSpec 类的实例,这个类封装了图片加载类中常见的参数,使得 SelectionCreator 的代码更加简洁。SelectionCreator 内部使用了建造者模式,让我们能够进行链式调用,配置各种各样的属性。最后 forResult() 里面其实就是跳转到 MatisseActivity,然后通过外部传入的 requestCode 将用户选择的媒体 Uri 列表返回给相应的 Activity 或 Fragment.

三、资源文件夹的加载和展示


Matisse 中所展示的资源都是用 Loader 机制进行加载的,Loader 机制是 Android 3.0 之后官方推荐的加载 ContentProvider 中资源的最佳方式,不仅能极大地提高我们资源加载的速度,而且还能让我们的代码变得更加的简洁。对于 Loader 机制不熟悉的同学,可以先看下这篇文章 Android Loader 机制,让你的数据加载更加高效

先附上此项操作的流程图:


继承了 Cursor 的 AlbumLoader,作为资源的加载器,通过配置与资源相关的一些参数,从而加载资源。AlbumCollection 实现了 LoaderManager.LoaderCallbacks 接口,将 AlbumLoader 作为加载器,其内部定义了 AlbumCallbacks 接口,在加载资源完成后,将包含数据的 Cursor 回调给外部调用的 MatisseActivity,然后在 MatisseActivity 中进行资源文件夹的展示。

AlbumsLoader

public class AlbumLoader extends CursorLoader {

    // content://media/external/file
    private static final Uri QUERY_URI = MediaStore.Files.getContentUri("external");

    private static final String[] COLUMNS = {
            MediaStore.Files.FileColumns._ID,
            "bucket_id",
            "bucket_display_name",
            MediaStore.MediaColumns.DATA,
            COLUMN_COUNT};

    private static final String[] PROJECTION = {
            MediaStore.Files.FileColumns._ID,
            "bucket_id",
            "bucket_display_name",
            MediaStore.MediaColumns.DATA,
            "COUNT(*) AS " + COLUMN_COUNT};

    private static final String SELECTION =
            "(" + MediaStore.Files.FileColumns.MEDIA_TYPE + "=?"
                    + " OR "
                    + MediaStore.Files.FileColumns.MEDIA_TYPE + "=?)"
                    + " AND " + MediaStore.MediaColumns.SIZE + ">0"
                    + ") GROUP BY (bucket_id";

    private static final String[] SELECTION_ARGS = {
            String.valueOf(MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE),
            String.valueOf(MediaStore.Files.FileColumns.MEDIA_TYPE_VIDEO),
    };

    private static final String BUCKET_ORDER_BY = "datetaken DESC";

    private AlbumLoader(Context context, String selection, String[] selectionArgs) {
        super(context, QUERY_URI, PROJECTION, selection, selectionArgs, BUCKET_ORDER_BY);
    }

    public static CursorLoader newInstance(Context context) {
        return new AlbumLoader(context, SELECTION, SELECTION_ARGS);
    }

    @Override
    public Cursor loadInBackground() {
       return super.loadInBackground();
    }
}

因为在 Matisse 只需要获取到手机中的图片和视频资源,所以直接将必要的参数配置在 AlbumLoader 中,然后提供 newInstance() 方法给外部调用,获取 AlbumLoader 的实例。

AlbumCollection

public class AlbumCollection implements LoaderManager.LoaderCallbacks<Cursor> {
    private static final int LOADER_ID = 1;
    private static final String STATE_CURRENT_SELECTION = "state_current_selection";
    private WeakReference<Context> mContext;
    private LoaderManager mLoaderManager;
    private AlbumCallbacks mCallbacks;
    private int mCurrentSelection;

    @Override
    public Loader<Cursor> onCreateLoader(int id, Bundle args) {
        Context context = mContext.get();
        return AlbumLoader.newInstance(context);
    }

    @Override
    public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
        Context context = mContext.get();
        mCallbacks.onAlbumLoad(data);
    }

    @Override
    public void onLoaderReset(Loader<Cursor> loader) {
        Context context = mContext.get();
        mCallbacks.onAlbumReset();
    }

    public void onCreate(FragmentActivity activity, AlbumCallbacks callbacks) {
        mContext = new WeakReference<Context>(activity);
        mLoaderManager = activity.getSupportLoaderManager();
        mCallbacks = callbacks;
    }

    public void loadAlbums() {
        mLoaderManager.initLoader(LOADER_ID, null, this);
    }

    public interface AlbumCallbacks {
        void onAlbumLoad(Cursor cursor);

        void onAlbumReset();
    }
}

Matisse 为了降低代码的耦合度,将一些客户端与 LoaderManager 交互的一些操作封装在 AlbumCollection 中。在 onCreate() 中,传入 Activity 用于获取 LoaderManager,加载资源完成后,在 onLoadFinished() 方法中,通过 AlbumCallbacks 的 onAlbumLoad(Cursor cursor) 方法将「包含数据的 Cursor」返回给外部调用的 MatisseActivity.

AlbumsSpinner

AlbumsSpinner 将 MatisseActivity 左上角的一组控件进行了封装,主要包括显示文件夹名称的 TextView 以及显示文件夹列表的 ListPopupWindow,相当于把一个相对完整的功能抽取出来,把逻辑操作写在里面,在 Activity 中当做一种控件来用,有点类似自定义 View.

public class AlbumsSpinner {

    private static final int MAX_SHOWN_COUNT = 6;
    private CursorAdapter mAdapter;
    private TextView mSelected;
    private ListPopupWindow mListPopupWindow;
    private AdapterView.OnItemSelectedListener mOnItemSelectedListener;

在 AlbumCollection 中返回的 Cursor,作为 AlbumsSpinner 的数据源,然后通过 AlbumsAdapter 将资源文件夹显示出来。当选中文件夹的时候,将所点击的文件夹的 position 回调给 MatisseActivity 中的 onItemSelected() 方法。

    @Override
    public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
        mAlbumCollection.setStateCurrentSelection(position);
        mAlbumsAdapter.getCursor().moveToPosition(position);
        // Album 是文件夹的实体类,封装了文件夹的名字、封面图片等信息
        Album album = Album.valueOf(mAlbumsAdapter.getCursor());
        onAlbumSelected(album);
    }

通过 AlbumsSpinner 回调出来的 position 拿到对应的文件夹的信息,然后将当前的界面进行刷新,使当前界面显示所选择的文件夹的图片。

    private void onAlbumSelected(Album album) {
        if (album.isAll() && album.isEmpty()) {
            mContainer.setVisibility(View.GONE);
            mEmptyView.setVisibility(View.VISIBLE);
        } else {
            mContainer.setVisibility(View.VISIBLE);
            mEmptyView.setVisibility(View.GONE);
            // MediaSelectionFragment 中包含一个 RecyclerView,用于显示文件夹中所有的图片
            Fragment fragment = MediaSelectionFragment.newInstance(album);
            getSupportFragmentManager()
                    .beginTransaction()
                    .replace(R.id.container, fragment, MediaSelectionFragment.class.getSimpleName())
                    .commitAllowingStateLoss();
        }
    }

四、主页图片墙的实现


主页的照片墙可以说是 Matisse 中最有意思的模块了,而且学习价值也是最高的。图片墙的数据源同样是通过 Loader 机制来进行加载的,实现思路也跟上一节讲的「资源文件夹的加载和展示」差不多,这里简单讲一下就好。

主页的照片墙会通过我们选择不同的资源文件夹而展示不同的图片,所以我们在选择资源文件夹的时候,便将资源文件夹的 id,传给对应的 Loader,让它对相应的资源文件进行加载。

Matisse 把图片和音频的信息封装成了实体类,并实现了 Parcelable 接口,让其序列化,通过外部传入的 Cursor,拿到对应的 Uri、媒体类型、文件大小,如果是视频的话,就获取视频播放的时长。

/**
 * 图片或音频的实体类
 */
public class Item implements Parcelable {

    public final long id;
    public final String mimeType;
    public final Uri uri;
    public final long size;
    public final long duration; // only for video, in ms

    private Item(long id, String mimeType, long size, long duration) {
        this.id = id;
        this.mimeType = mimeType;
        Uri contentUri;
        if (isImage()) {
            contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI;
        } else if (isVideo()) {
            contentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI;
        } else {
            // 如果不是图片也不是音频就直接当文件存储
            contentUri = MediaStore.Files.getContentUri("external");
        }
        this.uri = ContentUris.withAppendedId(contentUri, id);
        this.size = size;
        this.duration = duration;
    }

    public static Item valueOf(Cursor cursor) {
        return new Item(cursor.getLong(cursor.getColumnIndex(MediaStore.Files.FileColumns._ID)),
                cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)),
                cursor.getLong(cursor.getColumnIndex(MediaStore.MediaColumns.SIZE)),
                cursor.getLong(cursor.getColumnIndex("duration")));
    }

}

图片墙是直接用一个 RecyclerView 进行展示的,Item 是一个继承了 SquareFrameLayout(正方形的 FrameLayout) 的自定义控件,主要包含三个部分

CheckView 就是右上角那个白色的小圆圈,可以理解为是一个自定义的 CheckBox,或者说是一个比较好看的复选框。我在前文中说 Matisse 的学习价值比较高,一个很重要的原因就是 Matisse 中有很多的自定义 View,能够让我们学习图片选择库的同时,学习自定义 View 的一些好的思路和做法。

那我们就来看看 CheckView 究竟是怎样实现的。

首先,CheckView 重写了 onMeasure() 方法,将宽和高都定为 48,而且为了屏幕适配性,将 48dp 乘以 density,将 dp 单位转换为像素单位。

    private static final int SIZE = 48; // dp

    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int sizeSpec = MeasureSpec.makeMeasureSpec((int) (SIZE * mDensity), MeasureSpec.EXACTLY);
        super.onMeasure(sizeSpec, sizeSpec);
    }

接下来就看重头戏的 onDraw() 方法了

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        // 1、画出外在和内在的阴影
        initShadowPaint();
        canvas.drawCircle((float) SIZE * mDensity / 2, (float) SIZE * mDensity / 2,
                (STROKE_RADIUS + STROKE_WIDTH / 2 + SHADOW_WIDTH) * mDensity, mShadowPaint);

        // 2、画出白色的空心圆
        canvas.drawCircle((float) SIZE * mDensity / 2, (float) SIZE * mDensity / 2,
                STROKE_RADIUS * mDensity, mStrokePaint);

        // 3、画出圆里面的内容
        if (mCountable) {
                initBackgroundPaint();
                canvas.drawCircle((float) SIZE * mDensity / 2, (float) SIZE * mDensity / 2,
                        BG_RADIUS * mDensity, mBackgroundPaint);
                initTextPaint();
                String text = String.valueOf(mCheckedNum);
                int baseX = (int) (canvas.getWidth() - mTextPaint.measureText(text)) / 2;
                int baseY = (int) (canvas.getHeight() - mTextPaint.descent() - mTextPaint.ascent()) / 2;
                canvas.drawText(text, baseX, baseY, mTextPaint);
        } else {
            if (mChecked) {
                initBackgroundPaint();
                canvas.drawCircle((float) SIZE * mDensity / 2, (float) SIZE * mDensity / 2,
                        BG_RADIUS * mDensity, mBackgroundPaint);

                mCheckDrawable.setBounds(getCheckRect());
                mCheckDrawable.draw(canvas);
            }
        }
    }

onDraw() 方法主要分为三个部分

这部分主要是有关 Paint 的知识,以及数学方面的计算,如果对于 Paint 不是很熟悉的读者,可以看看这篇文章 HenCoder Android 开发进阶: 自定义 View 1-2 Paint 详解,顺便安利一波,凯哥的 HenCoder 教程,写得是真的好,强烈建议去好好看看。

看完了 CheckView 的实现逻辑,我们接着来看看图片墙的 Item 布局「MediaGrid」的实现逻辑,MediaGrid 是一个继承了 SquareFrameLayout(正方形的 FrameLayout)的自定义控件,可以理解为是一个拓展了复选功能(CheckView)和显示视频时长(TextView)功能的 ImageView.

我们从 MediaGrid 在 Adapter 中的使用入手,进一步看看 MediaGrid 的代码实现

mediaViewHolder.mMediaGrid.preBindMedia(new MediaGrid.PreBindInfo(
        getImageResize(mediaViewHolder.mMediaGrid.getContext()),
        mPlaceholder,
        mSelectionSpec.countable,
        holder
        ));
       mediaViewHolder.mMediaGrid.bindMedia(item);

可以看到 MediaGrid 的使用主要分两步

PreBindInfo 是 MediaGrid 的一个静态内部类,封装了一些图片的一些公用的属性

    public static class PreBindInfo {
        int mResize; // 图片的大小
        Drawable mPlaceholder; // ImageView 的占位符
        boolean mCheckViewCountable; // √ 的图标
        RecyclerView.ViewHolder mViewHolder; // 对应的 ViewHolder

        public PreBindInfo(int resize, Drawable placeholder, boolean checkViewCountable,
                           RecyclerView.ViewHolder viewHolder) {
            mResize = resize;
            mPlaceholder = placeholder;
            mCheckViewCountable = checkViewCountable;
            mViewHolder = viewHolder;
        }
    }

Item 在上文已经介绍了,是图片或音频的实体类。第二步便是将一个包含图片信息的 Item 传给 MediaGrid,然后进行相应信息的设置。

MediaGrid 中自定义了回调的接口

    public interface OnMediaGridClickListener {

        void onThumbnailClicked(ImageView thumbnail, Item item, RecyclerView.ViewHolder holder);

        void onCheckViewClicked(CheckView checkView, Item item, RecyclerView.ViewHolder holder);
    }

当用户点击图片的时候,将点击事件回调到 Adapter,再回调到 MediaSelectionFragment,再回调到 MatisseActivity,然后打开图片的大图预览界面,你没看错,真的回调了三层,我也是一脸蒙蔽。一遇到这种情况,我就觉得 EventBus 还是挺好用的。

当点击右上角的 CheckView 的时候,便将点击事件回调到 Adapter 中,然后根据 countable 的值,来进行相应的设置(显示数字或者显示 √),然后再将对应的 Item 信息保存在 SelectedItemCollection(Item 的容器) 中。

五、预览界面的实现


打开预览界面有两种方法

这两种方法打开的界面看起来似乎是一样的,但实际上他们两个的实现逻辑很不一样,因此用了两个不同的 Activity.

点击首页的某张图片之后,会跳转到一个包含 ViewPager 的界面,因为对应资源文件夹中可能会有很多的图片,这时候如果将包含该文件夹中所有的图片直接传给预览界面的 Activity,这是非常不实际的。比较好的实现方式便是将「包含对应文件夹的信息的 Album」传给界面,然后再用 Loader 机制进行加载。

选择首页图片后,点击左下角的预览按钮,跳转到预览界面,因为我们选择的图片一般都比较少,所以这时候直接将「包含所有选择图片信息的 List<Item>」传给预览界面就行了。

虽然,两个 Activity 的实现逻辑不太一样,但由于都是预览界面,所以有很多相同的地方。因此,Matisse 便实现了一个 BasePreviewActivity,减少代码的冗余程度。

BasePreviewActivity 的布局主要由三部分组成

主要的代码逻辑也基本上是围绕这三个部分进行展开的。

当点击 CheckView 的时候,根据该图片是否已经被选择以及图片的类型,对 CheckView 进行相应的设置以及更新底部栏。

        mCheckView.setOnClickListener(new View.OnClickListener() {

            @Override
            public void onClick(View v) {
                Item item = mAdapter.getMediaItem(mPager.getCurrentItem());
                // 如果当前的图片已经被选择
                if (mSelectedCollection.isSelected(item)) {
                    mSelectedCollection.remove(item);
                    if (mSpec.countable) {
                        mCheckView.setCheckedNum(CheckView.UNCHECKED);
                    } else {
                        mCheckView.setChecked(false);
                    }
                } else {
                    // 判断能否添加该图片
                    if (assertAddSelection(item)) {
                        mSelectedCollection.add(item);
                        if (mSpec.countable) {
                            mCheckView.setCheckedNum(mSelectedCollection.checkedNumOf(item));
                        } else {
                            mCheckView.setChecked(true);
                        }
                    }
                }
                // 更新底部栏
                updateApplyButton();
            }
        });

当用户对 ViewPager 进行左右滑动的时候,根据当前的 position 拿到对应的 Item 信息,然后对 CheckView 进行相应的设置以及切换图片。

    @Override
    public void onPageSelected(int position) {
        PreviewPagerAdapter adapter = (PreviewPagerAdapter) mPager.getAdapter();
        if (mPreviousPos != -1 && mPreviousPos != position) {
            ((PreviewItemFragment) adapter.instantiateItem(mPager, mPreviousPos)).resetView();
            // 获取对应的 Item 
            Item item = adapter.getMediaItem(position);
            if (mSpec.countable) {
                int checkedNum = mSelectedCollection.checkedNumOf(item);
                mCheckView.setCheckedNum(checkedNum);
                if (checkedNum > 0) {
                    mCheckView.setEnabled(true);
                } else {
                    mCheckView.setEnabled(!mSelectedCollection.maxSelectableReached());
                }
            } else {
                boolean checked = mSelectedCollection.isSelected(item);
                mCheckView.setChecked(checked);
                if (checked) {
                    mCheckView.setEnabled(true);
                } else {
                    mCheckView.setEnabled(!mSelectedCollection.maxSelectableReached());
                }
            }
            updateSize(item);
        }
        mPreviousPos = position;
    }

以上便是 BasePreviewActivity 的实现逻辑,至于它的子类 AlbumPreviewActivity(包含所有图片的预览界面)和 SelectedPreviewActivity(所选择图片的预览界面)就很简单了,大家自己看下源码就能明白了。

总结


Matisse 应该是我第一个完整啃下来的开源项目了,从一开始被 MatisseActivity 实现的一堆接口吓蒙。到后来的一步一步抽丝剥茧,从各个功能点入手,慢慢的理解了其中的代码设计以及实现思路,看完整个项目之后,对于 Matisse 的架构设计和代码质量深感佩服。

在阅读比较大型的开源项目的时候,由于这个项目你是完全陌生的,而且代码量通常都比较大,这时如果在阅读源码的时候,深陷代码细节的话,很容易让我们陷入到思维黑洞里面。如果我们从功能点入手,一步一步分析功能点是如何实现的,分析主体的逻辑,这样阅读起来就会更加轻松,也更加有成效。


猜你喜欢

上一篇下一篇

猜你喜欢

热点阅读