本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布
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 的工作流程,接下来详细的分析下相关的类。有一点要先说明一下,我下面贴出的所有类中的源码并不是完整的代码,而是将源码中与性能、兼容性、扩展性有关的代码剔除后的「核心代码」。
- 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 列表。
- 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 中进行资源文件夹的展示。
- 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 的实例。
- 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 将 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」传给预览界面就行了。
虽然,两个 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 的架构设计和代码质量深感佩服。
在阅读比较大型的开源项目的时候,由于这个项目你是完全陌生的,而且代码量通常都比较大,这时如果在阅读源码的时候,深陷代码细节的话,很容易让我们陷入到思维黑洞里面。如果我们从功能点入手,一步一步分析功能点是如何实现的,分析主体的逻辑,这样阅读起来就会更加轻松,也更加有成效。
来源: https://juejin.im/post/5a3760f65188252bca04f662