A94e4b15c3524bc4c967fce2664dfcda
知乎开源图片选择库 Matisse 源码解析

Matisse源码解析

Matisse是知乎开源的一个图片选择框架,这篇文章准备对这个图片选择框架进行浅析。

Matisse中主要的模块有Matisse、SelectionCreator、SelectionSpec、MatisseActivity四个类,它们的工作流程如图:

下面是Matisse的基本使用示例代码,我们下面以这段代码为入口开始研究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);
    }
    ...
    
    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);
    }

    
    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,  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;
    ...
    
    protected void onCreate( Bundle savedInstanceState) {
        ...
        //获取资源的主要代码
        mAlbumCollection.onCreate(this, this);
        mAlbumCollection.onRestoreInstanceState(savedInstanceState);
        mAlbumCollection.loadAlbums();
    }
    //拿到资源后回调方法
    
    public void onAlbumLoad(final Cursor cursor) {
        mAlbumsAdapter.swapCursor(cursor);
        ...
}

这里的数据加载使用到了Android的Loader API。详细可以看这篇文章:Android Loader 机制,让你的数据加载更加轻松


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() 方法。


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方法:

    
    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<Item>」传给预览界面就行了。

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

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

  • 右上角的 CheckView
  • 自定义的 ViewPager
  • 底部栏(包括预览(Preview)和使用按钮(Apply))

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

mCheckView.setOnClickListener(new View.OnClickListener() {
    
    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 进行相应的设置以及切换图片。


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;
}

参考文章

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

© 著作权归作者所有
这个作品真棒,我要支持一下!
专栏简介: 本专栏是一个免费专栏,旨在以 Android 开发小白的角度分享技术文章,希望能同本专栏的读者一同成...
0条评论
top Created with Sketch.