知乎的Android开源图片选择框架Matisse源码解析
Matisse中主要的模块有Matisse、SelectionCreator、SelectionSpec、MatisseActivity四个类,它们的工作流程如图:
我们先看到Matisse的使用代码,通过使用的代码来解析源码
Matisse.from(MainActivity.this)
.choose(MimeType.allOf())
.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) // 缩略图的缩放尺寸,默认为0.5
.imageEngine(new GlideEngine()) // 图片加载库
.forResult(REQUEST_CODE_CHOOSE);// 启动选择图片Activity
Matisse类
我们从使用时的入口,Matisse类看起。我们进入Matisse的源码,可以看到下面这一部分:
public final class Matisse {
private final WeakReference<Activity> mContext;
private final WeakReference<Fragment> mFragment;
public static Matisse from(Activity activity) {
return new Matisse(activity);
}
public static Matisse from(Fragment fragment) {
return new Matisse(fragment);
}
...
@Nullable
Activity getActivity() {
return (Activity)this.mContext.get();
}
public SelectionCreator choose(Set<MimeType> mimeTypes) {
return this.choose(mimeTypes, true);
}
public SelectionCreator choose(Set<MimeType> mimeTypes, boolean mediaTypeExclusive) {
return new SelectionCreator(this, mimeTypes, mediaTypeExclusive);
}
@Nullable
Fragment getFragment() {
return this.mFragment != null ? (Fragment)this.mFragment.get() : null;
}
}
我们可以发现,Matisse中用弱引用保存了Activity及Fragment的引用。它的from方法有两个重载,一个是传入Activity,一个是传入Fragment。也就是它同时支持了Activity及Fragment。它的choose方法有两个重载,最后都创建了一个SelectionCreator类。
SelectionCreator类-配置部分
我们看到SelectionCreator类的源码
public final class SelectionCreator {
private final Matisse mMatisse;
private final SelectionSpec mSelectionSpec;
SelectionCreator(Matisse matisse, @NonNull Set<MimeType> mimeTypes,
boolean mediaTypeExclusive){
this.mMatisse = matisse;
this.mSelectionSpec = SelectionSpec.getCleanInstance();
this.mSelectionSpec.mimeTypeSet = mimeTypes;
this.mSelectionSpec.mediaTypeExclusive = mediaTypeExclusive;
this.mSelectionSpec.orientation = -1;
}
public SelectionCreator countable(boolean countable) {
mSelectionSpec.countable = countable;
return this;
}
public SelectionCreator maxSelectable(int maxSelectable) {
if (maxSelectable < 1)
throw new IllegalArgumentException("maxSelectable must be greater than or equal to one");
mSelectionSpec.maxSelectable = maxSelectable;
return this;
}
...
}
可以看到,它内部保存了刚刚创建的Matisse及一个SelectionSpec类。SelectionCreator类采用了一种Builder的设计,比较巧妙的是将它的配置属性都放到了SelectionSpec类中。
SelectionSpec类
我们可以查看SelectionSpec的源码
public final class SelectionSpec {
public Set<MimeType> mimeTypeSet;
public boolean mediaTypeExclusive;
... //一些配置属性
private SelectionSpec() {
}
public static SelectionSpec getInstance() {
return SelectionSpec.InstanceHolder.INSTANCE;
}
public static SelectionSpec getCleanInstance() {
SelectionSpec selectionSpec = getInstance();
selectionSpec.reset();
return selectionSpec;
}
private static final class InstanceHolder {
private static final SelectionSpec INSTANCE = new SelectionSpec();
private InstanceHolder() {
}
}
}
可以看到,SelectionSpec类采用了一种懒汉式单例模式的设计,使用的时候才会被加载。
看到刚刚获取实例的getCleanInstance方法,会发现它仍然是调用了getInstance方法,然后调用了其reset方法对数据进行清空。保证了每次调用时的配置都是初始配置。
SelectionCreator类-跳转部分
我们可以回到SelectionCreator。当我们对其进行了一系列配置之后,就会调用forResult方法来打开选择图片Activity。我们可以看看forResult的源码。
public void forResult(int requestCode) {
Activity activity = this.mMatisse.getActivity();
if (activity != null) {
Intent intent = new Intent(activity, MatisseActivity.class);
Fragment fragment = this.mMatisse.getFragment();
if (fragment != null) {
fragment.startActivityForResult(intent, requestCode);
} else {
activity.startActivityForResult(intent, requestCode);
}
}
}
可以看到,它构建了Intent,然后分别对Activity及Fragment进行不同的跳转处理。最后都是调用了startActivityForResult方法。也就是我们的选择结果会由onActivityResult方法返回。
同时可以看到,它在Intent中,启动了MatisseActivity。
MatisseActivity类
在MatisseActivity类的onCreate方法的开始,我们就可以看到这样一行代码:
this.mSpec = SelectionSpec.getInstance();
由于SelectionSpec是单例模式,所以我们可以通过getInstance方法拿到之前配置过的SelectionSpec。
获取资源及展示
Matisse 中所展示的资源都是用 Loader 机制进行加载的,Loader 机制是 Android 3.0 之后官方推荐的加载 ContentProvider 中资源的最佳方式,不仅能极大地提高我们资源加载的速度,而且还能让我们的代码变得更加的简洁。
下面是它的资源加载的流程图:
public class MatisseActivity extends AppCompatActivity implements
AlbumCollection.AlbumCallbacks, ... {
...
//用于保存资源以及资源的操作
private final AlbumCollection mAlbumCollection = new AlbumCollection();
//用于展示资源的 Adapter
private AlbumsAdapter mAlbumsAdapter;
...
@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
...
//获取资源的主要代码
mAlbumCollection.onCreate(this, this);
mAlbumCollection.onRestoreInstanceState(savedInstanceState);
mAlbumCollection.loadAlbums();
}
//拿到资源后回调方法
@Override
public void onAlbumLoad(final Cursor cursor) {
mAlbumsAdapter.swapCursor(cursor);
...
}
这里的数据加载使用到了Android的Loader API。详细可以看这篇文章:Android Loader 机制,让你的数据加载更加轻松
@Override
public <D> Loader<D> initLoader(int id, Bundle args, LoaderManager.LoaderCallbacks<D> callback) {
...
LoaderInfo info = mLoaders.get(id);
if (info == null) {
info = createAndInstallLoader(id, args, (LoaderManager.LoaderCallbacks<Object>)callback);
}
...
if (info.mHaveData && mStarted) {
// 创建并获取资源完成后调用该方法,执行到AlbumCollection 中重写的 onLoadingFinish() 方法,里面又 callbacks.onAlbumLoad()
info.callOnLoadFinished(info.mLoader, info.mData);
}
}
createAndInstallLoader方法如下:
private LoaderInfo createAndInstallLoader(int id, Bundle args,
LoaderManager.LoaderCallbacks<Object> callback) {
try {
mCreatingLoader = true;
//在 AlbumCollection 中重写了该方法,创建了指定好 query 语句的 AlbumLoader 对象
LoaderInfo info = createLoader(id, args, callback);
//调用 info.start(), 在 CursorLoader 中实现 onStartLoading()
installLoader(info);
return info;
} finally {
mCreatingLoader = false;
}
文件夹的选择
AlbumSpinner是一个自定义View,位于MainActivity左上角。主要包括了显示文件夹名称的TextView、显示文件夹列表的ListPopupWindow。
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()) {
this.mContainer.setVisibility(8);
this.mEmptyView.setVisibility(0);
} else {
this.mContainer.setVisibility(0);
this.mEmptyView.setVisibility(8);
Fragment fragment = MediaSelectionFragment.newInstance(album);
this.getSupportFragmentManager()
.beginTransaction()
.replace(id.container, fragment,
MediaSelectionFragment.class.getSimpleName())
.commitAllowingStateLoss();
}
}
可以看到这里做了一些处理,mContainer是有图片时图片列表的布局。而mEmptyView则是没有图片时的布局。在文件夹中没有图片时显示mEmpty。而显示具体图片列表的布局,则是MediaSelectionFragment这个Fragment。
首页照片墙的实现
首页的图片墙非常值得我们学习。图片墙的数据源是通过 Loader 机制来进行加载的 ,它会通过我们选择不同的资源文件夹而展示不同的图片。
因此我们在选择资源文件夹的时候,便将资源文件夹的 id,传给对应的 Loader,让它对相应的资源文件进行加载。
Item实体类
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")));
}
}
Item布局
图片墙是直接用一个 RecyclerView 通过 GridLayoutManager 进行展示的,Item 是一个继承了 SquareFrameLayout(正方形的 FrameLayout) 的自定义控件,主要包含三个部分
- 右上角的 CheckView
- 显示图片的 ImageView
- 显示视频时长的 TextView
CheckView
CheckView是一个自定义的 CheckBox 。它重写了 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 的话,那么便描绘背景以及填入一个白色的 ✓
MediaGrid
我们接着来看看图片墙的 Item 布局「MediaGrid」的实现逻辑 。MediaGrid 是一个继承了 SquareFrameLayout(正方形的 FrameLayout)的自定义View。是一个拓展了复选功能(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 传给 MediaGrid,然后进行相应信息的设置。
MediaGrid 中自定义了回调的接口
public interface OnMediaGridClickListener {
void onThumbnailClicked(ImageView var1, Item var2, ViewHolder var3);
void onCheckViewClicked(CheckView var1, Item var2, ViewHolder var3);
}
点击图片的时候,将点击事件回调到 Adapter,再回调到 MediaSelectionFragment,再回调到 MatisseActivity。
当点击右上角的 CheckView 的时候,便将点击事件回调到 Adapter 中,然后根据 countable 的值,来进行相应的设置(显示数字或者显示 √),然后再将对应的 Item 信息保存在 SelectedItemCollection(Item 的容器) 中。
预览界面的实现
打开预览界面有两种方法
- 点击首页的某个图片
- 选择图片之后,点击首页左下角的预览(Preview)按钮
这两种方法打开的界面看起来似乎是一样的,但实际上他们两个的实现逻辑很不一样,因此用了两个不同的 Activity。
点击首页的某张图片之后,会跳转到一个包含 ViewPager 的界面,因为对应资源文件夹中可能会有很多的图片,这时候如果将包含该文件夹中所有的图片直接传给预览界面的 Activity,是非常不实际的。
比较好的实现方式便是将「包含对应文件夹的信息的 Album」传给界面,然后再用 Loader 机制进行加载。
而选择首页图片后,点击左下角的预览按钮,实现就不是很一样了。跳转到预览界面,因为我们选择的图片一般都比较少,所以这时候直接将「包含所有选择图片信息的 List」传给预览界面就行了。
虽然两个 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;
}
广告时间
我是N0tExpectErr0r,一名广东工业大学的大二学生
欢迎来到我的个人博客,所有文章均在个人博客中同步更新哦
http://blog.N0tExpectErr0r.cn