知乎 Matisse 源码解析,带你探究高效图片选择库的秘密
本篇文章已授权微信公众号 guolin_blog (郭霖)独家发布
目录
- 基本介绍
- 整体的设计和实现流程
- 资源文件夹的加载和展示
- 主页图片墙的实现
- 预览界面的实现
- 总结
一、基本介绍
Matisse 是「知乎」开源的一款十分精美的本地图像和视频选择库。
MatisseMatisse 的代码写的相当的简洁、规范,很有学习的价值。
讲一下 Matisse 的一些优点:
-
在 Activity 或 Fragment 都可以轻松的调用
-
支持各种格式的图片和视频加载
-
支持不同的样式,包括两种内置主题和自定义主题
-
可以自定义文件的过滤规则
可以看到 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 为例,可以分成三部分来看
-
将外部传入的 Activity 以弱引用的形式进行保存,然后调用 choose() 获取 SelectionCreator
-
通过链式调用的方式,配置 SelectionCreator 的各种属性,如可选择的数量、缩略图的大小、加载图片的引擎等
-
使用从第一步中传入的 Activity 调用 startActivityForResult(),并从外部传入请求码,以便到时候返回所选择图片的 List<Uri>
具体的流程图如下:
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
-
显示图片的 ImageView
-
显示视频时长的 TextView
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() 方法主要分为三个部分
-
画出空心圆内外的阴影
不得不说,Matisse 的细节处理真的做得特别好,为了图片选择库看起来更加美观,在空心圆的内外增加了一层辐射渐变的阴影 -
画出白色的空心圆
这个真没什么好讲的 -
描绘出里面的内容
通过我们外部配置的 mCountable 参数,来决定 CheckView 的显示方式,如果 mCountable 的值为 true 的话,便在内部描绘一层主题颜色的背景,以及代表所选择图片数量的数字,如果 mCount 的值为 false 的话,那么便描绘背景以及填入一个白色的 ✓
这部分主要是有关 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 的使用主要分两步
-
初始化图片的公有属性(MediaGrid.preBindMedia(new MediaGrid.PreBindInfo()))
-
将图片对应的信息进行绑定(MediaGrid.bindMedia(Item) )
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 的容器) 中。
五、预览界面的实现
打开预览界面有两种方法
-
点击首页的某个图片
-
选择图片之后,点击首页左下角的预览(Preview)按钮
这两种方法打开的界面看起来似乎是一样的,但实际上他们两个的实现逻辑很不一样,因此用了两个不同的 Activity.
点击首页的某张图片之后,会跳转到一个包含 ViewPager 的界面,因为对应资源文件夹中可能会有很多的图片,这时候如果将包含该文件夹中所有的图片直接传给预览界面的 Activity,这是非常不实际的。比较好的实现方式便是将「包含对应文件夹的信息的 Album」传给界面,然后再用 Loader 机制进行加载。
选择首页图片后,点击左下角的预览按钮,跳转到预览界面,因为我们选择的图片一般都比较少,所以这时候直接将「包含所有选择图片信息的 List<Item>」传给预览界面就行了。
虽然,两个 Activity 的实现逻辑不太一样,但由于都是预览界面,所以有很多相同的地方。因此,Matisse 便实现了一个 BasePreviewActivity,减少代码的冗余程度。
BasePreviewActivity 的布局主要由三部分组成
-
右上角的 CheckView
-
自定义的 ViewPager
-
底部栏(包括预览(Preview)和使用按钮(Apply))
主要的代码逻辑也基本上是围绕这三个部分进行展开的。
当点击 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 的架构设计和代码质量深感佩服。
在阅读比较大型的开源项目的时候,由于这个项目你是完全陌生的,而且代码量通常都比较大,这时如果在阅读源码的时候,深陷代码细节的话,很容易让我们陷入到思维黑洞里面。如果我们从功能点入手,一步一步分析功能点是如何实现的,分析主体的逻辑,这样阅读起来就会更加轻松,也更加有成效。