Charles - Android 多媒体选择器

 · 17 mins read

Charles - Android 多媒体选择器

介绍

现在的 App 很多功能都与多媒体有关, 例如图片, 视频, 音频, 文件等等. 在 Android 开发中, 如果需要从本地选择多媒体文件, 我们可以通过调用系统的 DocumentsUI 来实现, 当然通过这种方式往往存在着兼容性和 UI 风格方面的问题. 我们可以自己实现一个多媒体选择器. 首先来看看 Charles 是什么样子:

Charles StyleCharles Dark StyleEmpty View

简单使用

我们来看看 Charles 应该如何使用, 首先启动 Charles :

Charles.from(this@MainActivity)
        .choose()
        .maxSelectable(9)
        .progressRate(true)
        .theme(R.style.Charles)
        .imageEngine(GlideEngine())
        .restrictOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)
        .forResult(REQUEST_CODE_CHOOSE)

接收返回结果:

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (requestCode == REQUEST_CODE_CHOOSE && resultCode == Activity.RESULT_OK) {
        val uris = Charles.obtainResult(data)
        val paths = Charles.obtainPathResult(data)
        mAdapter.setData(uris, paths)
        Log.d("uris", "$uris")
        Log.d("paths", "$paths")
    }
}

是不是很简单呢?

特色

从代码和上面的截图中可以发现, Charles 是支持主题的. Charles 的特性并不仅仅如此, 使用 Charles, 你可以:

  • 直接在 Activity 或者 Fragment 中调用;
  • 选择多种媒体文件, 包括图片, 视频, 音频和文件;
  • 应用不同的主题, 其中 Charles 内置了两套 theme: 日间模式(Charles)和夜间模式(CharlesDark). 如果这两套主题不符合你的需求, 你仍然可以自定义主题;
  • 指定最大可选数量;
  • 支持横竖屏. Charles 内部实现了状态保存, 因此 Configuration 的变化并不会对 Charles 带来不利的影响;
  • 支持多种图片加载库. Charles 内部提供了两种图片加载引擎: Glide 和 Picasso. 当然, 如果以上两种都不是你所需要的, 你还可以自定义图片加载引擎, 只需要实现一个接口就好了. Charles 目前并不支持 Fresco.

在 Charles 的内部, 采用了 Loader 作为多媒体文件的加载器. 如果你对 Loader 感兴趣, 可以参考官方文档(https://developer.android.com/guide/components/loaders.html). Charles 内部并没有使用 MVP 或者 MVVM 等 MV* 实现解耦, 而且也没有采用过多的第三方库(实际上只依赖了 Kotlin, RecyclerView, AppCompat等少量的必须库).

进阶使用

图片引擎

内置图片引擎

Charles 内置了两种图片加载引擎: GlideEnginePicassoEngine. 图片引擎可以通过下面的方式使用:

Charles.from(this@MainActivity)
        ...
        .imageEngine(GlideEngine()) // 或 PicassoEngine()
        ...
        .forResult(REQUEST_CODE_CHOOSE)

自定义图片引擎

通过实现 ImageEngine 接口实现自定义图片加载引擎. 举个例子, 如果你想要使用 Glide v4 的 generated API, 你可以定义如下类:

class GlideLoader : ImageEngine {

    override fun loadImage(context: Context, imageView: ImageView, uri: Uri) {
        GlideApp.with(context)
                .load(uri)
                .centerCrop()
                .into(imageView)
    }

}

主题

内置主题

Charles 内置了两套主题: R.style.Charles (日间模式) 和 R.style.CharlesDark (夜间模式). 可以在启动 Charles 时执行 theme(@StyleRes themeId: Int) 方法指定主题:

Charles.from(this@MainActivity)
        ...
        .theme(R.style.Charles) // 或 R.style.CharlesDark
        ...
        .forResult(REQUEST_CODE_CHOOSE)

自定义主题

通过内置的两套主题甚至是他们的父主题, 你可以自定义 Charles 的外观. 下面是你可以修改的属性(定义在 attrs.xml 文件中):

  • colorPrimary: app bar 的 branding 颜色
  • colorPrimaryDark: 状态栏的颜色
  • toolbar: toolbar 主题
  • toolbar.titleColor: toolbar 上已选类别的标题颜色
  • categories.dropdown.titleColor: 可选类别下拉列表中类别的标题色
  • categories.dropdown.icon.tint: 可选类别下拉列表中类别图标的颜色
  • media.emptyView: 文件列表中空视图的图片
  • media.emptyView.textColor: 文件列表中空视图的文字颜色
  • media.checkView.iconColor: 已选文件的选择对勾的颜色
  • media.checkView.backgroundColor: 已选文件视图的背景色
  • media.checkView.borderColor: 文件的选择视图的边框颜色
  • media.selected.backgroundColor: 已选文件的背景色
  • media.titleColor: 文件的标题颜色
  • media.descColor: 文件的描述文字的颜色
  • page.backgroundColor: ActivityFragment 页面的背景色
  • bottomToolbar.backgroundColor: 底部工具条的背景色
  • bottomToolbar.progress.textColor: 底部工具条的进度比率的文字颜色
  • bottomToolbar.apply.textColor: 底部工具条应用按钮的文字颜色
  • listPopupWindowStyle: 可选类别下拉列表的主题

计数

最大可选数量

使用 maxSelectable(maxSelectable: Int) 限制文件的最大可选数量.

已选进度

使用 progressRate(toShow: Boolean) 决定是否显示当前的进度比率.

屏幕方向

使用 restrictOrientation(orientation: Int) 设置文件选择 Activity 的屏幕方向. 所有的待选值可以在 android.content.pm.ActivityInfo 类中找到.

源码分析

Charles 的整体项目结构:

Architecture

Charles & SelectionCreator

我们先看一下 Charles 类和 SelectionCreator 类:

class Charles {

    // ...

    private constructor(fragment: Fragment) : this(fragment.activity, fragment)

    // other constructor and more...

    companion object {

        @JvmStatic
        fun from(activity: Activity) = Charles(activity)

        @JvmStatic
        fun from(fragment: Fragment) = Charles(fragment)

        @JvmStatic
        fun obtainResult(data: Intent?) = data?.getParcelableArrayListExtra<Uri>(CharlesActivity.EXTRA_RESULT_SELECTION)

        @JvmStatic
        fun obtainPathResult(data: Intent?) = data?.getStringArrayListExtra(CharlesActivity.EXTRA_RESULT_SELECTION_PATH)

    }

    // ...

    fun choose(): SelectionCreator = SelectionCreator(this)

}

Charles 类中, 包含了4个静态方法, 按用途可划分为两种类型: Charles#obtainResult()Charles#obtainPathResult() 用于完成文件选择后获取结果; Charles#from() 方法返回了 Charles 对象, 用于对 Charles 进行进一步的定制. Charles#choose() 方法创建了 SelectionCreator 对象并返回, 我们再来看看 SelectionCreator 类:

class SelectionCreator(private val mCharles: Charles) {

    private val mSelectionSpec = SelectionSpec.cleanInstance

    // ...

    fun maxSelectable(maxSelectable: Int): SelectionCreator {
        // ...
        mSelectionSpec.maxSelectable = maxSelectable
        return this
    }

    fun progressRate(isShow: Boolean): SelectionCreator {
        mSelectionSpec.isShowProgressRate = isShow
        return this
    }

    fun theme(@StyleRes themeId: Int): SelectionCreator {
        mSelectionSpec.themeId = themeId
        return this
    }

    fun imageEngine(imageEngine: ImageEngine): SelectionCreator {
        mSelectionSpec.imageEngine = imageEngine
        return this
    }

    fun restrictOrientation(screenOrientation: Int): SelectionCreator {
        mSelectionSpec.orientation = screenOrientation
        return this
    }

    /**
     * Start to select media and wait for result.
     *
     * @param requestCode Identity of the request Activity or Fragment.
     */
    fun forResult(requestCode: Int) {
        // ...
    }

}

正如你所见到的, SelectionCreator 的主要作用就是对 Charles 进行各种参数的定制, 以符合各种不同的需求. 而可定制的项目均以成员变量的形式存储在 SelectionSpec 类中, 这里就不再列出了.

CharlesActivity & CharlesFragment

CharlesActivity 主要是用于显示 UI 组件, 例如包含了图片, 视频, 音频等选项的一个 ListPopupWindow, 另外, 在极端情况下, 数据的恢复也是在其中完成的. 我们重点来看看 CharlesFragment:

class CharlesFragment : Fragment(), LoaderManager.LoaderCallbacks<Cursor> {

    private var mFilterType: MediaFilterType? = null
    private lateinit var mMediaItemsAdapter: MediaItemsAdapter
    private lateinit var mSelectionProvider: SelectionProvider

    companion object {

        private const val ARG_TYPE = "ARG_TYPE"

        @JvmStatic
        fun newInstance(filterType: MediaFilterType): CharlesFragment {
            val fragment = CharlesFragment()
            val bundle = Bundle()
            bundle.putSerializable(ARG_TYPE, filterType)
            fragment.arguments = bundle
            return fragment
        }

    }

    override fun onAttach(context: Context?) {
        super.onAttach(context)
        if (context is SelectionProvider) {
            mSelectionProvider = context
        } else {
            throw IllegalStateException("Context must implement SelectionProvider.")
        }
    }

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
        return inflater.inflate(R.layout.fragment_charles, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        mFilterType = arguments?.getSerializable(ARG_TYPE) as MediaFilterType?

        initViews()

        mMediaItemsAdapter.registerOnMediaClickListener(object : MediaItemsAdapter.OnMediaItemClickListener {

            override fun onItemClick(item: MediaItem, position: Int) {
                if (activity != null && activity is CharlesActivity) {
                    (activity as CharlesActivity).updateBottomToolbar()
                }
            }

        })

        activity?.supportLoaderManager?.initLoader(0, null, this)

    }

    override fun onDestroyView() {
        super.onDestroyView()
        activity?.supportLoaderManager?.destroyLoader(MediaItemLoader.LOADER_ID)
    }

    override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> = MediaItemLoader.newInstance(context!!, mFilterType!!)

    override fun onLoadFinished(loader: Loader<Cursor>, data: Cursor?) {
        mMediaItemsAdapter.swapCursor(data)
        emptyTextView.visibility = if (mMediaItemsAdapter.itemCount == 0) View.VISIBLE else View.GONE
    }

    override fun onLoaderReset(loader: Loader<Cursor>) {
        mMediaItemsAdapter.swapCursor(null)
    }

    private fun initViews() {
        recyclerView.layoutManager = LinearLayoutManager(context)
        recyclerView.setHasFixedSize(true)
        mMediaItemsAdapter = MediaItemsAdapter(mFilterType, mSelectionProvider.provideSelectedItemCollection())
        recyclerView.adapter = mMediaItemsAdapter
    }

    interface SelectionProvider {
        fun provideSelectedItemCollection(): SelectedItemCollection
    }

}

CharlesFragment 的布局为一个纯列表布局, 使用了 RecyclerView, 当然也就会使用到相应的 Adapter, 这里不贴出 Adapter 的代码. 我们重点看一下 CharlesFragment 实现的 LoaderManager.LoaderCallbacks 接口. 实际上, 这个接口和 Loader 相关. 为什么 Charles 会选用 Loader 呢?

在Android中任何耗时的操作都不能放在UI主线程中, 所以耗时的操作都需要使用异步实现. 同样的, 在ContentProvider中也可能存在耗时操作, 这时也该使用异步操作, 而3.0之后最推荐的异步操作就是Loader. 它可以方便我们在Activity和Fragment中异步加载数据, 而不是用线程或AsyncTask, 他的优点如下:

  • 提供异步加载数据机制;
  • 对数据源变化进行监听, 实时更新数据;
  • 在Activity配置发生变化(如横竖屏切换)时不用重复加载数据;
  • 适用于任何Activity和Fragment.

CharlesFragment 实现了 LoaderManager.LoaderCallbacks 接口之后, 就可以感应到数据的变化, 从而做相应的处理.

MediaItemLoader & SelectedItemCollection & MediaItem

class MediaItemLoader private constructor(

        context: Context,
        uri: Uri,
        projection: Array<String>,
        selection: String,
        selectionArgs: Array<String>,
        sortOrder: String

) : CursorLoader(context, uri, projection, selection, selectionArgs, sortOrder) {

    companion object {

        const val LOADER_ID = 0

        // Images
        private const val IMAGES_SELECTION = "${MediaStore.Files.FileColumns.MEDIA_TYPE}=? AND ${MediaStore.MediaColumns.SIZE}>0"
        private val IMAGE_SELECTION_ARGS = arrayOf(MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString())
        private const val IMAGES_ORDER_BY = "${MediaStore.Images.Media.DATE_TAKEN} DESC"
        private val IMAGE_QUERY_URI = MediaStore.Files.getContentUri("external")
        private val IMAGES_PROJECTION = arrayOf(
                MediaStore.Files.FileColumns._ID,
                MediaStore.MediaColumns.TITLE,
                MediaStore.MediaColumns.MIME_TYPE,
                MediaStore.MediaColumns.SIZE,
                MediaStore.MediaColumns.DATE_ADDED,
                "duration"
        )
        
        // ...

        @JvmStatic
        fun newInstance(context: Context, mediaFilterType: MediaFilterType): MediaItemLoader = when (mediaFilterType) {
            MediaFilterType.IMAGE -> {
                MediaItemLoader(context, IMAGE_QUERY_URI, IMAGES_PROJECTION, IMAGES_SELECTION, IMAGE_SELECTION_ARGS, IMAGES_ORDER_BY)
            }
            // ...
        }

    }

}

MediaItemLoader 中, 我们配置了查询参数和条件, 用于对 ContentProvider 进行具体的查询, SelectedItemCollection 内部使用了一个 LinkedHashSet, 用于顺序地保存用户的选择结果, 并提供了转换方法, 将 Set 转换为对应的 List.

@SuppressLint("ParcelCreator")
@Parcelize
data class MediaItem(

        val id: Long,
        val name: String,
        val mimeType: String,
        val size: Long,
        val time: Long,
        val uri: Uri,
        val duration: Long = 0, // only for video and audio.
        val mediaType: MediaFilterType

) : Parcelable {

    companion object {

        @JvmStatic
        fun valueOf(cursor: Cursor, mediaType: MediaFilterType): MediaItem {
            val id = cursor.getLong(cursor.getColumnIndex(MediaStore.Files.FileColumns._ID))
            return when (mediaType) {
                MediaFilterType.IMAGE -> MediaItem(
                        id,
                        cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.TITLE)),
                        cursor.getString(cursor.getColumnIndex(MediaStore.MediaColumns.MIME_TYPE)),
                        cursor.getLong(cursor.getColumnIndex(MediaStore.MediaColumns.SIZE)),
                        cursor.getLong(cursor.getColumnIndex(MediaStore.MediaColumns.DATE_ADDED)),
                        ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id),
                        0,
                        mediaType
                )
                // ...

}

MediaItem 则是具体的图片, 视频, 音频等的实体类, 得益于 Kotlin 的 Data Class, 我们不用编写冗长的代码即可实现. 其中 MediaItem#valueOf() 方法可以将一个 Cursor 转换为具体的实体类对象.

ImageEngine & GlideEngine & PicassoEngine

interface ImageEngine {

    /**
     * Load a static image resource.
     *
     * @param context   Context
     * @param imageView ImageView widget
     * @param uri       Uri of the loaded image
     */
    fun loadImage(context: Context, imageView: ImageView, uri: Uri)

}

ImageEngine 仅定义了一个 loadImage 方法, 用于加载图片和视频的缩略图. 由于我们对显示图片没有特别高的要求, 所以仅这一个简单方法即可满足要求. Charles 内置的 GlideEnginePicassoEngine 实现也非常的简单:

 class GlideEngine : ImageEngine {

    override fun loadImage(context: Context, imageView: ImageView, uri: Uri) {
        Glide.with(context)
                .load(uri)
                .apply(RequestOptions().centerCrop())
                .into(imageView)
    }

}

class PicassoEngine : ImageEngine {

    override fun loadImage(context: Context, imageView: ImageView, uri: Uri) {
        Picasso.with(context)
                .load(uri)
                .fit()
                .centerCrop()
                .into(imageView)
    }

}

Other

  • CheckView: 自定义的 View, 并实现了 Checkable, 用于显示选中状态;
  • PathUtils: 工具类, 用于获取 Path.

写在最后

Charles 完全使用 Kotlin 开发并在 Github 开源: https://github.com/TonnyL/Charles. 有任何意见或者建议, 欢迎提 issue. 当然, 也欢迎你的 PR.

最后感谢 gejiaheng (https://github.com/gejiaheng)知乎团队(https://github.com/zhihu), Charles 的灵感正是来自于知乎团队开源的 Matisse(https://github.com/zhihu/Matisse).

Charles — An elegant way to select local multi-media files for Android

We often meet the need to choose images, videos or other files from local storage during android development. One solution is call the DocumentsUI or other file manager. But as you know, Android is open sourced, which means that the UI of each system is much varied. But what if we need a unified UI? Then it comes to the second solution: Create our own file selector.

Let’s take a look to Charles:

Charles StyleCharles Dark StyleEmpty View

How to use Charles? The answer is pretty simple:

Charles.from(this@MainActivity)
 .choose()
 .maxSelectable(9)
 .progressRate(true)
 .theme(R.style.Charles)
 .imageEngine(GlideEngine())
 .restrictOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED)
 .forResult(REQUEST_CODE_CHOOSE)

How to receive the results?


override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (requestCode == REQUEST_CODE_CHOOSE && resultCode == Activity.RESULT_OK) {
        val uris = Charles.obtainResult(data)
        val paths = Charles.obtainPathResult(data)

        Log.d("charles", "uris: $uris")
        Log.d("charles", "paths: $paths")
    }
}

As you can see in the screenshots and code, Charles supports a lot of useful functions. Use Charles, you can:

  • Use it in Activity or Fragment;
  • Select multi-media file including images, videos, audio and documents;
  • Set max selectable count;
  • Apply different themes, including two built-in themes and custom themes. If none of the built-in themes that you are satisfied with, you could custom your themes;
  • Apply different image engines, including two built-in engines: Glide Engine & Picasso Engine. Like themes, you could custom your own image engines.
  • Restrict different screen orientations;
  • Find more by yourself.

In Charles internal, we choose Loader to load images and other multi-media files.

Now Charles is open sourced on GitHub. If you have any question or want to get more information about it, check the homepage and wiki. Find a bug? Do not hesitate to file an issue. At the end, thanks zhihu team for their project Matisse.