从零开始实现一个兼容版本的Android文件选择器

Android文件选择器的实现

前言

此项目和之前发布的项目有些不同,之前都是基本的功能,不是基于 Activity 页面实现的,而类似文件选择,图片选择,除了功能的实现还需要处理 UI 相关的配置。

在前面的【如何操作文件的选择】 一文中我就想把逻辑做一下封装,做成开箱即用的文件选择器,本来这功能是项目中自用的,UI 等都是自有的,如果要做开源出去,那么就要抽取功能与 UI 逻辑,设置可配置选项。

分解一下实现步骤,如何自定义一个文件下载器呢?

  1. 我们需要配置 Activity 基本的 Theme,动画,状态栏,导航栏等处理。
  2. 我们需要配置展示的文本大小,返回图标,列表与导航栏的文本大小等等。
  3. 然后我们对XML的布局并构建导航列表与文件列表的数据适配器等。
  4. 然后我们就可以处理权限以及对文件的操作了。
  5. 可以使用策略模式不同的版本不同的实现方式。
  6. 过滤操作是比不可少的,我们获取文件之后使用过滤操作展示我们想要的文件。

这样差不多就能完成一个基本的操作文件选择框架了。

这里先放实现之后各版本手机的截图,具体效果如下:

Android 7.0 效果(华为):

image.png

Android 9.0 效果(谷歌):

image.png

Android 12 效果(三星):

image.png

Android 13 效果(Vivo):

image.png

框架实现基于 target 31,不配置兼容模式 requestLegacyExternalStorage ,支持 4.4 及以上系统,可保持UI的一致性...

话不多说,赶紧开始吧!

300.png

一、文件选择的页面的配置

我们使用我们自定义的theme与动画即可。由于我们要自己实现可控的标题栏,所以我们的样式不需要toolbar:

 
<resources>
    <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
        <item name="colorPrimary">@color/choose_file_app_blue</item>
        <item name="colorPrimaryDark">@color/choose_file_app_blue</item>
        <item name="colorAccent">@color/choose_file_app_blue</item>
        <item name="android:windowAnimationStyle">@style/My_AnimationActivity</item>
        <item name="android:windowIsTranslucent">false</item>
    </style>

    <style name="My_AnimationActivity" mce_bogus="1" parent="@android:style/Animation.Activity">
        <item name="android:activityOpenEnterAnimation">@anim/open_enter</item>
        <item name="android:activityCloseExitAnimation">@anim/close_exit</item>
    </style>
</resources>

为了可配置的状态栏与导航栏,这里我用到之前的项目中的 StatusBarHost 框架,具体的实现与细节可以查看之前的文章,【传送门】。

https://juejin.cn/post/7138312796474703909

那么我们创建选择文件的Activity大致如下:

 
class ChooseFileActivity : AppCompatActivity(), View.OnClickListener {

    private val mViewModel: ChooseFileViewModel by lazy {
        ViewModelProvider(this, ChooseFileViewModelFactory()).get(ChooseFileViewModel::class.java)
    }

    private var mainHandler = Handler(Looper.getMainLooper())

    //展示当前页面的UI风格
    private val uiConfig = ChooseFile.config?.mUIConfig ?: ChooseFileUIConfig.Builder().build()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContentView(R.layout.activity_choose_file)

        StatusBarHost.inject(this)
            .setStatusBarBackground(uiConfig.statusBarColor)
            .setStatusBarWhiteText()
            .setNavigationBarBackground(uiConfig.navigationBarColor)
            .setNavigatiopnBarIconBlack()

    }

而为了横竖屏切换的效果,或者说为了适配折叠屏设备,我们可以使用ViewModel保存一些页面状态:

 
class ChooseFileViewModel : ViewModel() {

    val mNavPathList = arrayListOf<ChooseFileInfo>()
    var mNavAdapter: FileNavAdapter? = null

    val mFileList = arrayListOf<ChooseFileInfo>()
    var mFileListAdapter: FileListAdapter? = null

    //根目录
    val rootPath = Environment.getExternalStorageDirectory().absolutePath

    var rootChoosePos = 0  //根目录文档选中的索引

    //当前选择的路径
    var mCurPath = Environment.getExternalStorageDirectory().absolutePath
}

这里已经用到了一些UI的配置选项,我们赶紧接下来往下走。

二、页面的UI配置与其他配置

一般我们都会根据不同的UI效果,设置不同的文本颜色和背景,所以我们需要把页面上的文本与背景和图标等选项抽取出来,配置成可选的属性:

 
public class ChooseFileUIConfig {

    private int statusBarColor;   //状态栏颜色

    private int titleBarBgColor;  //标题栏的背景颜色
    private int titleBarBackRes;  //标题栏的返回按钮资源
    private int titleBarTitleColor; //标题栏的标题文字颜色
    private int titleBarTitleSize; //标题栏的标题文字大小(sp)

    private int navigationBarColor; //底部导航栏颜色

    private int fileNavBarTextColor; //文件导航栏的文本颜色
    private int fileNavBarTextSize; //文件导航栏的文本大小
    private int fileNavBarArrowIconRes; //文件导航栏的箭头图标资源

    private int fileNameTextColor;  //文件(夹)名称字体颜色
    private int fileNameTextSize;  //文件(夹)名称字体大小(sp)
    private int fileInfoTextColor;  //文件(夹)提示信息字体大小
    private int fileInfoTextSize;  //文件(夹)提示信息字体大小(sp)

    private ChooseFileUIConfig() {
    }

    ...
}

然后我们使用构建者模式创建可选的配置,如果不选择那么就可以使用默认的配置,就特别适合此场景:

  public static class Builder {

        private int statusBarColor = Color.parseColor("#0689FB");   //状态栏颜色

        private int titleBarBgColor = Color.parseColor("#0689FB");  //标题栏的背景颜色
        private int titleBarBackRes = R.drawable.cf_back;  //标题栏的返回按钮资源
        private int titleBarTitleColor = Color.parseColor("#FFFFFF"); //标题栏的标题文字颜色
        private int titleBarTitleSize = 20; //标题栏的标题文字大小(sp)

        private int navigationBarColor = Color.parseColor("#F7F7FB"); //底部导航栏颜色

        private int fileNavBarTextColor = Color.parseColor("#333333"); //文件导航栏的文本颜色
        private int fileNavBarTextSize = 15; //文件导航栏的文本大小
        private int fileNavBarArrowIconRes = R.drawable.cf_next; //文件导航栏的箭头图标资源

        private int fileNameTextColor = Color.BLACK;  //文件(夹)名称字体颜色
        private int fileNameTextSize = 16;  //文件(夹)名称字体大小(sp)
        private int fileInfoTextColor = Color.parseColor("#A9A9A9");  //文件(夹)提示信息字体大小
        private int fileInfoTextSize = 14;  //文件(夹)提示信息字体大小(sp)

        public Builder() {
        }

        public Builder statusBarColor(int statusBarColor) {
            this.statusBarColor = statusBarColor;
            return this;
        }
     ...   

UI的配置完成之后,我们还需要对一些常规的配置做一些可选操作,例如线程池的自定义,过滤文件的选择等等。

 
class ChooseFileConfig(private val chooseFile: ChooseFile) {

    internal var mUIConfig: ChooseFileUIConfig? = null
    internal var mIFileTypeFilter: IFileTypeFilter? = null
    internal var mExecutor: ExecutorService? = ThreadPoolExecutor(
        1, 1, 10L, TimeUnit.MINUTES, LinkedBlockingDeque()
    )

    fun setUIConfig(uiConfig: ChooseFileUIConfig?): ChooseFileConfig {
        mUIConfig = uiConfig
        return this
    }

    fun setExecutor(executor: ExecutorService): ChooseFileConfig {
        mExecutor = executor
        return this
    }

    fun getExecutor(): ExecutorService? {
        return mExecutor
    }

    fun setTypeFilter(filter: IFileTypeFilter): ChooseFileConfig {
        mIFileTypeFilter = filter
        return this
    }

    fun forResult(listener: IFileChooseListener) {
        val activity = chooseFile.activityRef?.get()
        activity?.gotoActivityForResult<ChooseFileActivity> {
            it?.run {
                val info = getSerializableExtra("chooseFile") as ChooseFileInfo
                listener.doChoose(info)
            }
        }
    }

    //销毁资源
    fun clear() {
        mUIConfig = null
        mIFileTypeFilter = null
        if (mExecutor != null && !mExecutor!!.isShutdown) {
            mExecutor!!.shutdown()

        }

    }

}

由于操作文件是耗时的操作,我们最好是在线程中进行,我们统一使用默认的线程池处理,如果用户想自定义使用可以他自己的线程池。

而 forResult 的实现我们是对 startActivityForResult 的封装,为了兼容低版本内部是 Ghost 实现。

而内部使用到的 ChooseFile 则是我们的单例使用入口,内部实现如下:

 
object ChooseFile {

    @JvmField
    internal var activityRef: WeakReference<FragmentActivity>? = null
    @JvmField
    internal var config: ChooseFileConfig? = null

    @JvmStatic
    fun create(activity: FragmentActivity): ChooseFileConfig {
        activityRef?.clear()
        this.activityRef = WeakReference(activity)
        config = ChooseFileConfig(this)
        return config!!
    }

    @JvmStatic
    fun create(fragment: Fragment): ChooseFileConfig {
        activityRef?.clear()
        val activity = fragment.requireActivity()
        this.activityRef = WeakReference(activity)
        config = ChooseFileConfig(this)
        return config!!
    }

    @JvmStatic
    fun release() {
        activityRef?.clear()
        config?.clear()
        config = null
    }
}

到处我们就可以正常的使用框架了:

 
findViewById<Button>(R.id.btn_get_file).setOnClickListener {

    ChooseFile.create(this)
        .setUIConfig(ChooseFileUIConfig.Builder().build())
        .setTypeFilter { listData ->
            return@setTypeFilter ArrayList(listData.filter { item ->
                //只要文件夹
                  item.isDir

                //只要文档文件
//                        item.fileType == ChooseFile.FILE_TYPE_FOLDER ||
//                                item.fileType == ChooseFile.FILE_TYPE_TEXT ||
//                                item.fileType == ChooseFile.FILE_TYPE_PDF
            })
        }
        .forResult {
            Toast.makeText(this, "选中的文件:" + it?.fileName, Toast.LENGTH_SHORT).show()
            val uri = Uri.parse(it?.filePathUri)
            val fis = contentResolver.openInputStream(uri)

            Log.w("TAG", "文件的Uri:" + it?.filePathUri + " uri:" + uri + " fis:" + fis)

            fis?.close()
        }
}

这样拉到列表的底部之后就只会显示文件夹类型:

图片

三、导航列表与文件列表的展示

对应文件列表的展示以及文件导航的展示,我们需要先定义对应的xml:

代码大家都会,效果如下图:

图片

那么RV的处理如下:

private fun initRV() {

    mViewModel.mNavAdapter = FileNavAdapter(mViewModel.mNavPathList, uiConfig)
    rvNav.layoutManager = LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
    rvNav.adapter = mViewModel.mNavAdapter
    mViewModel.mNavAdapter?.setOnNavClickListener { position ->
        val item = mViewModel.mNavPathList[position]
        mViewModel.mCurPath = item.filePath
        startRefreshAnim()
        obtainByPath(mViewModel.mCurPath)
    }

    mViewModel.mFileListAdapter = FileListAdapter(mViewModel.mFileList, uiConfig)
    rvFiles.layoutManager = LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false)
    rvFiles.adapter = mViewModel.mFileListAdapter
    mViewModel.mFileListAdapter?.setOnFileListClickListener() { position ->
        val item = mViewModel.mFileList[position]
        if (item.isDir) {
            //设置当前Root的选中
            if (mViewModel.mNavPathList.isEmpty()) {
                mViewModel.rootChoosePos = (rvFiles.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
            }

            //文件夹-直接刷新页面
            mViewModel.mCurPath = item.filePath
            startRefreshAnim()
            obtainByPath(mViewModel.mCurPath)
        } else {
            //选中文件-回调出去
            setResult(-1, Intent().putExtra("chooseFile", item))
            finish()
        }

    }

}

Adapter的处理也很简单,我们把UI的配置选择传进来,然后做赋值操作即可,我们最好是只做赋值操作,处理的逻辑都在文件的处理那一边处理,那边是有子线程一并处理的。

 
class FileNavAdapter(private val navPathList: MutableList<ChooseFileInfo>, private val uiConfig: ChooseFileUIConfig) :
    RecyclerView.Adapter<FileNavAdapter.FileNavViewHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FileNavViewHolder {
        val itemView = View.inflate(parent.context, R.layout.item_choose_file_nav, null)
        return FileNavViewHolder(itemView)
    }

    override fun onBindViewHolder(holder: FileNavViewHolder, position: Int) {
        holder.curPosition = position
        holder.tvPath.text = navPathList[position].fileName
        holder.tvPath.setTextColor(uiConfig.fileNavBarTextColor)
        holder.tvPath.setTextSize(TypedValue.COMPLEX_UNIT_SP, uiConfig.fileNameTextSize.toFloat())
        holder.ivPathSegment.setImageResource(uiConfig.fileNavBarArrowIconRes)

        if (position == (itemCount - 1)) holder.ivPathSegment.visibility = View.INVISIBLE
        else holder.ivPathSegment.visibility = View.VISIBLE
    }

    override fun getItemCount(): Int = navPathList.size


    inner class FileNavViewHolder(private val itemView: View) : ViewHolder(itemView) {

        val tvPath: TextView = itemView.findViewById(R.id.tv_root)
        val ivPathSegment: ImageView = itemView.findViewById(R.id.iv_path_segment)
        var curPosition: Int = 0

        init {
            itemView.setOnClickListener {
                mListener?.onClick(curPosition)
            }
        }
    }

    private var mListener: OnNavClickListener? = null
    fun setOnNavClickListener(listener: OnNavClickListener) {
        mListener = listener
    }

    fun interface OnNavClickListener {
        fun onClick(position: Int)
    }

}

两个 Adapter 的实现效果是类似的,就不多贴代码,有兴趣可以去文章末尾找源码。

关于展示的Item的Bean对象,我们需要使用自定义的 File 封装,作为展示的选项。我们需要对文件进行读取之后直接封装到这个 Bean 对象中,方便直接展示。

 
public class ChooseFileInfo implements Serializable {

    public String fileName;
    public boolean isDir;  //是否是文件夹
    public String fileSize; //如果是文件夹则表示子目录项数,如果不是文件夹则表示文件大小,当值为-1的时候不显示
    public String fileLastUpdateTime;   //最后操作事件
    public String filePath;             //文件的路径
    public String filePathUri;          //文件的路径,URI形式
    public String fileType;           //文件类型
    public int fileTypeIconRes;         //文件类型对应的图标展示

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        ChooseFileInfo that = (ChooseFileInfo) o;
        return Objects.equals(filePath, that.filePath);
    }

    @Override
    public int hashCode() {
        return Objects.hash(filePath);
    }

}

需要注意的是我们需要处理文件夹的选中与顶部文件导航的交互,两个RV选中之后需要有数据的逻辑处理。

底部的 RV 选中文件夹之后需要给顶部的文件导航添加数据,而顶部的文件导航选中之后需要刷新底部的 RV 选中:

底部 RV 的选中:

 
 mViewModel.mFileListAdapter?.setOnFileListClickListener() { position ->
        val item = mViewModel.mFileList[position]
        if (item.isDir) {
            //设置当前Root的选中
            if (mViewModel.mNavPathList.isEmpty()) {
                mViewModel.rootChoosePos = (rvFiles.layoutManager as LinearLayoutManager).findFirstVisibleItemPosition()
            }

            //文件夹-直接刷新页面
            mViewModel.mCurPath = item.filePath
            startRefreshAnim()
            obtainByPath(mViewModel.mCurPath)
        } else {
            //选中文件-回调出去
            setResult(-1, Intent().putExtra("chooseFile", item))
            finish()
        }

    }

顶部 RV 的选中:

 
 mViewModel.mNavAdapter?.setOnNavClickListener { position ->
            val item = mViewModel.mNavPathList[position]
            mViewModel.mCurPath = item.filePath
            startRefreshAnim()
            obtainByPath(mViewModel.mCurPath)
        }

加载数据完成之后的顶部导航展示逻辑:

//顶部文件导航的设置
private fun setTopNavSelect(topInfo: ChooseFileInfo?) {

    if (topInfo != null) {
        if (mViewModel.mNavPathList.isEmpty()) {
            mViewModel.mNavPathList.add(topInfo)
        } else {
            val index = mViewModel.mNavPathList.indexOf(topInfo)

            if (index >= 0) {
                mViewModel.mNavPathList.subList(index + 1, mViewModel.mNavPathList.size).clear()
            } else {
                mViewModel.mNavPathList.add(topInfo)
            }
        }

    } else {
        mViewModel.mNavPathList.clear()
    }

    mViewModel.mNavAdapter?.notifyDataSetChanged()
}

四、权限处理与文件的操作

到此,UI的部分就大致完成了,我们需要对数据与权限的逻辑做处理,我们为了演示之前文章中 FilrProvider 与 DocumentsProvider 的使用,这里用做高版本的作为展示。

首先我们需要处理动态权限问题,分为不同的版本的权限申请实现:

 
public class PermissionUtil {

    //统一处理权限
    public static boolean isStoragePermissionGranted(Activity activity) {
        Context context = activity.getApplicationContext();


        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {

            if (!Environment.isExternalStorageManager()) {
                Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION);
                Uri uri = Uri.fromParts("package", activity.getPackageName(), null);
                intent.setData(uri);
                activity.startActivityForResult(intent, 1);

                return false;
            } else {

                // 有外部存储的权限
                return true;
            }

        } else {

            int readPermissionCheck = ContextCompat.checkSelfPermission(context,
                    Manifest.permission.READ_EXTERNAL_STORAGE);

            int writePermissionCheck = ContextCompat.checkSelfPermission(context,
                    Manifest.permission.WRITE_EXTERNAL_STORAGE);

            if (readPermissionCheck == PackageManager.PERMISSION_GRANTED
                    && writePermissionCheck == PackageManager.PERMISSION_GRANTED) {
                Log.v("permission", "Permission is granted");
                return true;
            } else {
                Log.v("permission", "Permission is revoked");
                ActivityCompat.requestPermissions(activity, new String[]{
                        Manifest.permission.READ_EXTERNAL_STORAGE,
                        Manifest.permission.WRITE_EXTERNAL_STORAGE}, 1);
                return false;
            }

        }

    }

}

那么在 Activity 的权限回调中我们需要处理成功的回调:

//动态权限授权的回调
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)

    if (grantResults[0] == PackageManager.PERMISSION_GRANTED) {
        obtainByPath(mViewModel.rootPath)
    }
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
    super.onActivityResult(requestCode, resultCode, data)
    if (requestCode == 1) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && Environment.isExternalStorageManager()) {
            // 用户已经授权,执行需要访问外部存储的操作
            obtainByPath(mViewModel.rootPath)
        } else {
            // 用户未授权,无法访问外部存储
            Toast.makeText(this, "未授权,无法访问外部存储", Toast.LENGTH_SHORT).show()
        }
    }
}

对于文件的类型处理,我们使用工具类封装一下,大致的逻辑是根据文件的后缀名匹配。并且对文件的 mimeType 做了匹配,大致代码如下:

 
// Audio
public static final int FILE_TYPE_MP3 = 1;
public static final int FILE_TYPE_M4A = 2;
public static final int FILE_TYPE_WAV = 3;
public static final int FILE_TYPE_AMR = 4;
public static final int FILE_TYPE_AWB = 5;
public static final int FILE_TYPE_WMA = 6;
public static final int FILE_TYPE_OGG = 7;
private static final int FIRST_AUDIO_FILE_TYPE = 0;
private static final int LAST_AUDIO_FILE_TYPE = 10;

// MIDI
public static final int FILE_TYPE_MID = 11;
public static final int FILE_TYPE_SMF = 12;
public static final int FILE_TYPE_IMY = 13;
private static final int FIRST_MIDI_FILE_TYPE = 10;
private static final int LAST_MIDI_FILE_TYPE = 20;

// Video
public static final int FILE_TYPE_MP4 = 21;
public static final int FILE_TYPE_M4V = 22;
public static final int FILE_TYPE_3GPP = 23;
public static final int FILE_TYPE_3GPP2 = 24;
public static final int FILE_TYPE_WMV = 25;
private static final int FIRST_VIDEO_FILE_TYPE = 20;
private static final int LAST_VIDEO_FILE_TYPE = 30;

// Image
public static final int FILE_TYPE_JPEG = 31;
public static final int FILE_TYPE_GIF = 32;
public static final int FILE_TYPE_PNG = 33;
public static final int FILE_TYPE_BMP = 34;
public static final int FILE_TYPE_WBMP = 35;
private static final int FIRST_IMAGE_FILE_TYPE = 30;
private static final int LAST_IMAGE_FILE_TYPE = 40;

// Playlist
public static final int FILE_TYPE_M3U = 41;
public static final int FILE_TYPE_PLS = 42;
public static final int FILE_TYPE_WPL = 43;
private static final int FIRST_PLAYLIST_FILE_TYPE = 40;
private static final int LAST_PLAYLIST_FILE_TYPE = 50;

//TEXT
public static final int FILE_TYPE_TXT = 51;
public static final int FILE_TYPE_DOC = 52;
public static final int FILE_TYPE_RTF = 53;
public static final int FILE_TYPE_LOG = 54;
public static final int FILE_TYPE_CONF = 55;
public static final int FILE_TYPE_SH = 56;
public static final int FILE_TYPE_XML = 57;
public static final int FILE_TYPE_DOCX = 58;
private static final int FIRST_TEXT_FILE_TYPE = 50;
private static final int LAST_TEXT_FILE_TYPE = 60;

//XLS
public static final int FILE_TYPE_XLS = 61;
public static final int FILE_TYPE_XLSX = 62;
private static final int FIRST_XLS_FILE_TYPE = 60;
private static final int LAST_XLS_FILE_TYPE = 70;

//PPT
public static final int FILE_TYPE_PPT = 71;
public static final int FILE_TYPE_PPTX = 72;
private static final int FIRST_PPT_FILE_TYPE = 70;
private static final int LAST_PPT_FILE_TYPE = 80;

//PDF
public static final int FILE_TYPE_PDF = 81;
private static final int FIRST_PDF_FILE_TYPE = 80;
private static final int LAST_PDF_FILE_TYPE = 90;

//静态内部类
static class MediaFileType {

    int fileType;
    String mimeType;

    MediaFileType(int fileType, String mimeType) {
        this.fileType = fileType;
        this.mimeType = mimeType;
    }
}

private static HashMap<String, MediaFileType> sFileTypeMap
        = new HashMap<>();
private static HashMap<String, Integer> sMimeTypeMap
        = new HashMap<>();

static void addFileType(String extension, int fileType, String mimeType) {
    sFileTypeMap.put(extension, new MediaFileType(fileType, mimeType));
    sMimeTypeMap.put(mimeType, fileType);
}

static {
    //根据文件后缀名匹配
    addFileType("MP3", FILE_TYPE_MP3, "audio/mpeg");
    addFileType("M4A", FILE_TYPE_M4A, "audio/mp4");
    addFileType("WAV", FILE_TYPE_WAV, "audio/x-wav");
    addFileType("AMR", FILE_TYPE_AMR, "audio/amr");
    addFileType("AWB", FILE_TYPE_AWB, "audio/amr-wb");
    addFileType("WMA", FILE_TYPE_WMA, "audio/x-ms-wma");
    addFileType("OGG", FILE_TYPE_OGG, "application/ogg");

    addFileType("MID", FILE_TYPE_MID, "audio/midi");
    addFileType("XMF", FILE_TYPE_MID, "audio/midi");
    addFileType("RTTTL", FILE_TYPE_MID, "audio/midi");
    addFileType("SMF", FILE_TYPE_SMF, "audio/sp-midi");
    addFileType("IMY", FILE_TYPE_IMY, "audio/imelody");

    addFileType("MP4", FILE_TYPE_MP4, "video/mp4");
    addFileType("M4V", FILE_TYPE_M4V, "video/mp4");
    addFileType("3GP", FILE_TYPE_3GPP, "video/3gpp");
    addFileType("3GPP", FILE_TYPE_3GPP, "video/3gpp");
    addFileType("3G2", FILE_TYPE_3GPP2, "video/3gpp2");
    addFileType("3GPP2", FILE_TYPE_3GPP2, "video/3gpp2");
    addFileType("WMV", FILE_TYPE_WMV, "video/x-ms-wmv");

    addFileType("JPG", FILE_TYPE_JPEG, "image/jpeg");
    addFileType("JPEG", FILE_TYPE_JPEG, "image/jpeg");
    addFileType("GIF", FILE_TYPE_GIF, "image/gif");
    addFileType("PNG", FILE_TYPE_PNG, "image/png");
    addFileType("BMP", FILE_TYPE_BMP, "image/x-ms-bmp");
    addFileType("WBMP", FILE_TYPE_WBMP, "image/vnd.wap.wbmp");

    addFileType("M3U", FILE_TYPE_M3U, "audio/x-mpegurl");
    addFileType("PLS", FILE_TYPE_PLS, "audio/x-scpls");
    addFileType("WPL", FILE_TYPE_WPL, "application/vnd.ms-wpl");

    addFileType("TXT", FILE_TYPE_TXT, "text/plain");
    addFileType("RTF", FILE_TYPE_RTF, "application/rtf");
    addFileType("LOG", FILE_TYPE_LOG, "text/plain");
    addFileType("CONF", FILE_TYPE_CONF, "text/plain");
    addFileType("SH", FILE_TYPE_SH, "text/plain");
    addFileType("XML", FILE_TYPE_XML, "text/plain");
    addFileType("DOC", FILE_TYPE_DOC, "application/msword");
    addFileType("DOCX", FILE_TYPE_DOCX, "application/vnd.openxmlformats-officedocument.wordprocessingml.document");

    addFileType("XLS", FILE_TYPE_XLS, "application/vnd.ms-excel application/x-excel");
    addFileType("XLSX", FILE_TYPE_XLSX, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");

    addFileType("PPT", FILE_TYPE_PPT, "application/vnd.ms-powerpoint");
    addFileType("PPTX", FILE_TYPE_PPTX, "application/vnd.openxmlformats-officedocument.presentationml.presentation");

    addFileType("PDF", FILE_TYPE_PDF, "application/pdf");

    StringBuilder builder = new StringBuilder();

    for (String s : sFileTypeMap.keySet()) {
        if (builder.length() > 0) {
            builder.append(',');
        }
        builder.append(s);
    }
    sFileExtensions = builder.toString();
}

public static final String UNKNOWN_STRING = "<unknown>";

public static boolean isAudioFileType(int fileType) {
    return ((fileType >= FIRST_AUDIO_FILE_TYPE &&
            fileType <= LAST_AUDIO_FILE_TYPE) ||
            (fileType >= FIRST_MIDI_FILE_TYPE &&
                    fileType <= LAST_MIDI_FILE_TYPE));
}

public static boolean isVideoFileType(int fileType) {
    return (fileType >= FIRST_VIDEO_FILE_TYPE &&
            fileType <= LAST_VIDEO_FILE_TYPE);
}

public static boolean isImageFileType(int fileType) {
    return (fileType >= FIRST_IMAGE_FILE_TYPE &&
            fileType <= LAST_IMAGE_FILE_TYPE);
}

public static boolean isPlayListFileType(int fileType) {
    return (fileType >= FIRST_PLAYLIST_FILE_TYPE &&
            fileType <= LAST_PLAYLIST_FILE_TYPE);
}

public static boolean isTextFileType(int fileType) {
    return (fileType >= FIRST_TEXT_FILE_TYPE &&
            fileType <= LAST_TEXT_FILE_TYPE);
}

public static boolean isXLSFileType(int fileType) {
    return (fileType >= FIRST_XLS_FILE_TYPE &&
            fileType <= LAST_XLS_FILE_TYPE);
}

public static boolean isPPTFileType(int fileType) {
    return (fileType >= FIRST_PPT_FILE_TYPE &&
            fileType <= LAST_PPT_FILE_TYPE);
}

public static boolean isPDFFileType(int fileType) {
    return (fileType >= FIRST_PDF_FILE_TYPE &&
            fileType <= LAST_PDF_FILE_TYPE);
}

public static MediaFileType getFileType(String path) {
    int lastDot = path.lastIndexOf(".");
    if (lastDot < 0)
        return null;
    return sFileTypeMap.get(path.substring(lastDot + 1).toUpperCase());
}

//根据视频文件路径判断文件类型
public static boolean isVideoFileType(String path) {
    MediaFileType type = getFileType(path);
    if (null != type) {
        return isVideoFileType(type.fileType);
    }
    return false;
}

//根据音频文件路径判断文件类型
public static boolean isAudioFileType(String path) {
    MediaFileType type = getFileType(path);
    if (null != type) {
        return isAudioFileType(type.fileType);
    }
    return false;
}

//根据图片文件路径判断文件类型
public static boolean isImageFileType(String path) {
    MediaFileType type = getFileType(path);
    if (null != type) {
        return isImageFileType(type.fileType);
    }
    return false;
}

//根据文本文件路径判断文件类型
public static boolean isTextFileType(String path) {
    MediaFileType type = getFileType(path);
    if (null != type) {
        return isTextFileType(type.fileType);
    }
    return false;
}

public static boolean isXLSFileType(String path) {
    MediaFileType type = getFileType(path);
    if (null != type) {
        return isXLSFileType(type.fileType);
    }
    return false;
}

public static boolean isPPTFileType(String path) {
    MediaFileType type = getFileType(path);
    if (null != type) {
        return isPPTFileType(type.fileType);
    }
    return false;
}

public static boolean isPDFFileType(String path) {
    MediaFileType type = getFileType(path);
    if (null != type) {
        return isPDFFileType(type.fileType);
    }
    return false;
}

接下来我们就能在获取文件的时候,处理好队友的格式,赋值对应展示的Icon,就可以在数据适配器上面展示了。

五、不同版本的文件获取

其实获取到对应版本权限之后,都使用 File 就可以获取到对应版本的文件信息了,这里便于演示,所以把 Android10 以上与 Android10 以下区别开来,高版本的使用 DocumentProvider的方式实现:

使用接口+策略的方式,我们定义不同的实现方案:

 
internal interface IChooseFilePolicy {

    fun getFileList(rootPath: String, callback: (fileList: List<ChooseFileInfo>, topInfo: ChooseFileInfo?) -> Unit)

}

低版本的直接获取 FileList ,注意我们处理文件,赋值操作等都是耗时操作,所以我们最好是在线程池中处理,大致的逻辑如下:

 
internal class ChooseFileLowPolicy : IChooseFilePolicy {

    override fun getFileList(rootPath: String, callback: (fileList: List<ChooseFileInfo>, topInfo: ChooseFileInfo?) -> Unit) {

        ChooseFile.config?.mExecutor?.execute {

            val listData: ArrayList<ChooseFileInfo> = ArrayList()
            val rootFile = File(rootPath)
            var topInfo: ChooseFileInfo? = null
            val rootExternalPath = Environment.getExternalStorageDirectory().absolutePath
            if (rootExternalPath != rootPath) {
                //添加一个顶部的导航对象
                topInfo = ChooseFileInfo().apply {
                    fileName = rootFile.name
                    filePath = rootFile.absolutePath
                    isDir = true
                }
            }

            val listFiles = rootFile.listFiles()
            if (listFiles.isNullOrEmpty()) {
                //空数据回调
                callback(listData, topInfo)
                return@execute
            }

            for (file in listFiles) {
                if (file.isDirectory) {
                    //如果是文件夹
                    listData.add(
                        ChooseFileInfo().apply {
                            isDir = true
                            fileName = file.name
                            filePath = file.absolutePath
                            fileLastUpdateTime = TimeUtil.getDateInString(Date(file.lastModified()))
                            fileSize = "共" + FileUtil.getSubfolderNum(file.absolutePath) + "项"
                            fileType = ChooseFile.FILE_TYPE_FOLDER
                            fileTypeIconRes = R.drawable.file_folder
                        }
                    )
                } else {

                    //根据后缀类型封装自定义文件Bean
                    val fileInfo = ChooseFileInfo()

                    if (FileUtil.isAudioFileType(file.absolutePath)) {
                        fileInfo.fileType = ChooseFile.FILE_TYPE_AUDIO
                        fileInfo.fileTypeIconRes = R.drawable.file_audio

                    } else if (FileUtil.isImageFileType(file.absolutePath)) {
                        fileInfo.fileType = ChooseFile.FILE_TYPE_IMAGE
                        fileInfo.fileTypeIconRes = R.drawable.file_image

                    } else if (FileUtil.isVideoFileType(file.absolutePath)) {
                        fileInfo.fileType = ChooseFile.FILE_TYPE_VIDEO
                        fileInfo.fileTypeIconRes = R.drawable.file_video

                    } else if (FileUtil.isTextFileType(file.absolutePath)) {
                        fileInfo.fileType = ChooseFile.FILE_TYPE_TEXT
                        fileInfo.fileTypeIconRes = R.drawable.file_text

                    } else if (FileUtil.isXLSFileType(file.absolutePath)) {
                        fileInfo.fileType = ChooseFile.FILE_TYPE_XLS
                        fileInfo.fileTypeIconRes = R.drawable.file_excel

                    } else if (FileUtil.isPPTFileType(file.absolutePath)) {
                        fileInfo.fileType = ChooseFile.FILE_TYPE_PPT
                        fileInfo.fileTypeIconRes = R.drawable.file_ppt

                    } else if (FileUtil.isPDFFileType(file.absolutePath)) {
                        fileInfo.fileType = ChooseFile.FILE_TYPE_PDF
                        fileInfo.fileTypeIconRes = R.drawable.file_pdf

                    } else {
                        fileInfo.fileType = ChooseFile.FILE_TYPE_Unknown
                        fileInfo.fileTypeIconRes = R.drawable.file_unknown
                    }

                    fileInfo.apply {
                        isDir = false
                        fileName = file.name
                        filePath = file.absolutePath
                        filePathUri = getFileUri(ChooseFile.activityRef?.get(), file).toString()
                        fileLastUpdateTime = TimeUtil.getDateInString(Date(file.lastModified()))
                        fileSize = FileUtil.getFileSize(file.length())
                    }

                    listData.add(fileInfo)
                }
            }

            //满数据回调
            callback(filterData, topInfo)
        }

    }

}

Android 10以上的高版本我们启动 DocumentProvider 的查询方式:

 
internal class ChooseFileHighPolicy : IChooseFilePolicy {

    @SuppressLint("Range")
    override fun getFileList(rootPath: String, callback: (fileList: List<ChooseFileInfo>, topInfo: ChooseFileInfo?) -> Unit) {

        val uri = DocumentsContract.buildChildDocumentsUri(
            "com.newki.choosefile.authorities",
            rootPath
        )

        ChooseFile.config?.mExecutor?.execute {

            val cursor = ChooseFile.activityRef?.get()?.contentResolver?.query(uri, null, null, null, null)

            val listData: ArrayList<ChooseFileInfo> = ArrayList()
            var topInfo: ChooseFileInfo? = null

            if (cursor != null) {

                while (cursor.moveToNext()) {

                    val isTop = cursor.getInt(cursor.getColumnIndex("isTop"))
                    val isRoot = cursor.getInt(cursor.getColumnIndex("isRoot"))

                    val fileName = cursor.getString(cursor.getColumnIndex("fileName"))
                    val isDir = cursor.getInt(cursor.getColumnIndex("isDir"))
                    val fileSize = cursor.getString(cursor.getColumnIndex("fileSize"))
                    val fileLastUpdateTime = cursor.getString(cursor.getColumnIndex("fileLastUpdateTime"))
                    val filePath = cursor.getString(cursor.getColumnIndex("filePath"))
                    val filePathUri = cursor.getString(cursor.getColumnIndex("filePathUri"))
                    val fileTypeIconRes = cursor.getInt(cursor.getColumnIndex("fileTypeIconRes"))

                    if (isTop == 1) {

                        if (isRoot == 0) {
                            topInfo = ChooseFileInfo().apply {
                                this.fileName = fileName
                                this.isDir = isDir != 0
                                this.fileSize = fileSize
                                this.fileLastUpdateTime = fileLastUpdateTime
                                this.filePath = filePath
                                this.filePathUri = filePathUri
                                this.fileTypeIconRes = fileTypeIconRes
                            }
                        }

                    } else {

                        listData.add(ChooseFileInfo().apply {
                            this.fileName = fileName
                            this.isDir = isDir != 0
                            this.fileSize = fileSize
                            this.fileLastUpdateTime = fileLastUpdateTime
                            this.filePath = filePath
                            this.filePathUri = filePathUri
                            this.fileTypeIconRes = fileTypeIconRes
                        })
                    }

                }

                cursor.close()

                //满数据回调
                callback(filterData, topInfo)

            } else {

                callback(emptyList(), null)

            }

        }

    }
}

而 DocumentProvider 的具体实现如下,我们只需要重点关注 queryChildDocuments 方法的实现即可:

 
public class ChooseFileDocumentProvider extends DocumentsProvider {

    private final static String[] DEFAULT_DOCUMENT_PROJECTION = new String[]{"isTop", "isRoot", "fileName", "isDir", "fileSize", "fileLastUpdateTime",
            "filePath", "filePathUri", "fileType", "fileTypeIconRes"};

    @Override
    public Cursor queryRoots(String[] projection) throws FileNotFoundException {
        return null;
    }

    @Override
    public boolean isChildDocument(String parentDocumentId, String documentId) {
        return documentId.startsWith(parentDocumentId);
    }

    @Override
    public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException {

        // 创建一个查询cursor, 来设置需要查询的项, 如果"projection"为空, 那么使用默认项
        final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);
        includeFile(result, new File(documentId), false, false);
        return result;
    }

    @Override
    public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) throws FileNotFoundException {

        // 创建一个查询cursor, 来设置需要查询的项, 如果"projection"为空, 那么使用默认项。
        final MatrixCursor result = new MatrixCursor(projection != null ? projection : DEFAULT_DOCUMENT_PROJECTION);

        final File parent = new File(parentDocumentId);
        boolean isDirectory = parent.isDirectory();
        boolean canRead = parent.canRead();
        File[] files = parent.listFiles();
        boolean isRoot = parent.getAbsolutePath().equals(Environment.getExternalStorageDirectory().getAbsolutePath());
        includeFile(result, parent, isRoot, true);

        //遍历添加处理文件列表
        if (isDirectory && canRead && files != null && files.length > 0) {
            for (File file : files) {
                // 添加文件的名字, 类型, 大小等属性
                includeFile(result, file, isRoot, false);
            }
        }

        return result;
    }

    private void includeFile(final MatrixCursor result, final File file, boolean isRoot, boolean isTop) {
        final MatrixCursor.RowBuilder row = result.newRow();

        row.add("isTop", isTop ? "1" : "0");
        row.add("isRoot", isRoot ? "1" : "0");

        if (file.isDirectory()) {
            row.add("fileName", file.getName());
            row.add("isDir", 1);
            row.add("fileSize", "共" + FileUtil.getSubfolderNum(file.getAbsolutePath()) + "项");
            row.add("fileLastUpdateTime", TimeUtil.getDateInString(new Date(file.lastModified())));
            row.add("filePath", file.getAbsolutePath());
            row.add("filePathUri", file.getAbsolutePath());
            row.add("fileType", ChooseFile.FILE_TYPE_FOLDER);
            row.add("fileTypeIconRes", R.drawable.file_folder);

        } else {

            row.add("fileName", file.getName());
            row.add("isDir", 0);
            row.add("fileSize", FileUtil.getFileSize(file.length()));
            row.add("fileLastUpdateTime", TimeUtil.getDateInString(new Date(file.lastModified())));
            row.add("filePath", file.getAbsolutePath());
            row.add("filePathUri", getFileUri(ChooseFile.activityRef.get(), file).toString());

            setFileType(row, file.getAbsolutePath());
        }

    }


    private void setFileType(MatrixCursor.RowBuilder row, String absolutePath) {
        if (FileUtil.isAudioFileType(absolutePath)) {
            row.add("fileType", ChooseFile.FILE_TYPE_AUDIO);
            row.add("fileTypeIconRes", R.drawable.file_audio);

        } else if (FileUtil.isImageFileType(absolutePath)) {
            row.add("fileType", ChooseFile.FILE_TYPE_IMAGE);
            row.add("fileTypeIconRes", R.drawable.file_image);

        } else if (FileUtil.isVideoFileType(absolutePath)) {
            row.add("fileType", ChooseFile.FILE_TYPE_VIDEO);
            row.add("fileTypeIconRes", R.drawable.file_video);

        } else if (FileUtil.isTextFileType(absolutePath)) {
            row.add("fileType", ChooseFile.FILE_TYPE_TEXT);
            row.add("fileTypeIconRes", R.drawable.file_text);

        } else if (FileUtil.isXLSFileType(absolutePath)) {
            row.add("fileType", ChooseFile.FILE_TYPE_XLS);
            row.add("fileTypeIconRes", R.drawable.file_excel);

        } else if (FileUtil.isPPTFileType(absolutePath)) {
            row.add("fileType", ChooseFile.FILE_TYPE_PPT);
            row.add("fileTypeIconRes", R.drawable.file_ppt);

        } else if (FileUtil.isPDFFileType(absolutePath)) {
            row.add("fileType", ChooseFile.FILE_TYPE_PDF);
            row.add("fileTypeIconRes", R.drawable.file_pdf);

        } else {
            row.add("fileType", ChooseFile.FILE_TYPE_Unknown);
            row.add("fileTypeIconRes", R.drawable.file_unknown);
        }
    }

    @Override
    public String getDocumentType(String documentId) throws FileNotFoundException {
        return null;
    }


    @Override
    public ParcelFileDescriptor openDocument(String documentId, String mode, @Nullable CancellationSignal signal) throws FileNotFoundException {
        return null;
    }

    @Override
    public boolean onCreate() {
        return true;
    }

}

记得要在清单文件中注册哦:

<provider
    android:name=".provider.ChooseFileDocumentProvider"
    android:authorities="com.newki.choosefile.authorities"
    android:exported="true"
    android:grantUriPermissions="true"
    android:permission="android.permission.MANAGE_DOCUMENTS">
    <intent-filter>
        <action android:name="android.content.action.DOCUMENTS_PROVIDER" />
    </intent-filter>
</provider>

为了地址的可达性,对应 7.0以上的版本我们最好是提供到 Uri 的资源,所以我们定义到自己的 FileProvider ,而我们只用到了外置 SD 卡的资源,所以我们直接这么配置即可:

<provider
    android:name=".provider.ChooseFileProvider"
    android:authorities="com.newki.choosefile.file.path.share"
    android:exported="false"
    android:grantUriPermissions="true">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/choose_file_paths" />
</provider>

关于 FileProvider 的细节使用可以看我的这一篇文章【别滥用FileProvider了,Android中FileProvider的各种场景应用】。

https://juejin.cn/post/7140166121595863076

使用起来的话,就都是这么固定的写法:

private Uri getFileUri(Context context, File file) {

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
        return ChooseFileProvider.getUriForFile(context, "com.newki.choosefile.file.path.share", file);
    } else {
        return Uri.fromFile(file);
    }
}

六、过滤的操作

对于我们的应用来说,我们只需要选中SD卡中的文档文件,Txt,word,pdf等文件,那么我们就一定是需要过滤的操作的。

由于在上文我们获取File,封装自定义的 Bean 对象 ChooseFileInfo 中我们已经把文件的自定义格式定义好了,所以我们在回调之前先进行过滤操作,然后在再排序之后返回最终的数据源即可。

而为了对过滤的信息进行更灵活的过滤,我们可以直接暴露 ChooseFileInfo 对象,这样我们甚至能根据文件类型,文件名称,文件最后操作时间等等的方式进行过滤了。

先定义一个过滤的抽象接口如下:

 
public interface IFileTypeFilter {

    List<ChooseFileInfo> doFilter(List<ChooseFileInfo> list);
}

在 FileConfig 的配置中,我们可以加上过滤的接口处理逻辑。

 
class ChooseFileConfig(private val chooseFile: ChooseFile) {

    internal var mIFileTypeFilter: IFileTypeFilter? = null

        fun setTypeFilter(filter: IFileTypeFilter): ChooseFileConfig {
        mIFileTypeFilter = filter
        return this
    }

    ...
}

我们在最后返回的时候就可以这样:

 
override fun getFileList(rootPath: String, callback: (fileList: List<ChooseFileInfo>, topInfo: ChooseFileInfo?) -> Unit) {

    ChooseFile.config?.mExecutor?.execute {

        // ... 获取文件操作

        //根据Filter过滤数据并排序
        val filterData = ChooseFile.config?.mIFileTypeFilter?.doFilter(listData) ?: listData
        FileUtil.SortFilesByInfo(filterData)

        //满数据回调
        callback(filterData, topInfo)
    }

}

而排序的逻辑就是先展示文件夹,然后根据文件名排序:

 
public static void SortFilesByInfo(List<ChooseFileInfo> fileList) {
    Collections.sort(fileList, (o1, o2) -> {
        if (o1.isDir && (!o2.isDir))
            return -1;
        if ((!o1.isDir) && o2.isDir)
            return 1;
        return Collator.getInstance(java.util.Locale.CHINA).compare(o1.fileName, o2.fileName);
    });
}

到此我们的整体的基本框架就完成了。

七、使用与上传

先看看具体的使用方式:

 
findViewById<Button>(R.id.btn_get_file).setOnClickListener {

    ChooseFile.create(this)
        .setUIConfig(ChooseFileUIConfig.Builder().build())
        .setTypeFilter { listData ->
            return@setTypeFilter ArrayList(listData.filter { item ->
                //只要文件夹
//                          item.isDir

                //只要文档文件
                item.fileType == ChooseFile.FILE_TYPE_FOLDER ||
                        item.fileType == ChooseFile.FILE_TYPE_TEXT ||
                        item.fileType == ChooseFile.FILE_TYPE_PDF
            })
        }
        .forResult {
            Toast.makeText(this, "选中的文件:" + it?.fileName, Toast.LENGTH_SHORT).show()
            val uri = Uri.parse(it?.filePathUri)
            val fis = contentResolver.openInputStream(uri)

            Log.w("TAG", "文件的Uri:" + it?.filePathUri + " uri:" + uri + " fis:" + fis)

            fis?.close()
        }
}

这里我们可以拿到path或uri,拿到 uri 之后我们可以直接获取输入流,上传到后端服务器,例如:

 
public interface ApiService {
    @Multipart
    @POST("upload")
    Call<ResponseBody> upload(@Part("text") String text, @Part("file") RequestBody requestBody);
}

// 创建OkHttpClient实例
OkHttpClient client = new OkHttpClient();

// 构建请求体
RequestBody fileRequestBody = new RequestBody() {
    @Override
    public MediaType contentType() {
        return MediaType.parse("application/octet-stream");
    }

    @Override
    public long contentLength() {
        try {
            // 返回输入流的长度,如果无法确定长度,返回-1
            return inputStream.available();
        } catch (IOException e) {
            return -1;
        }
    }

    @Override
    public void writeTo(BufferedSink sink) throws IOException {
        // 将输入流中的数据写入到请求体中
        Source source = Okio.source(inputStream);
        sink.writeAll(source);
    }
};

// 创建Retrofit实例
Retrofit retrofit = new Retrofit.Builder()
        .baseUrl("http://www.example.com/")
        .client(client)
        .build();

// 创建ApiService实例
ApiService apiService = retrofit.create(ApiService.class);

// 构造请求参数
String text = "Hello World!";

// 发送请求并获取响应
Call<ResponseBody> call = apiService.upload(text, fileRequestBody);
Response<ResponseBody> response = call.execute();

下面看看视频的演示:

图片

后记

我们定义的文件选择框架只是一个简单的轻量级框架,甚至都没有加入多选文件的操作,创建文件、修改文件的操作等。为什么?只因我们没这方面的需求而已。

多选文件的操作只需要修改一些UI和一些选中的逻辑而已并不复杂,创建修改文件则需要 DocumentFile 配合 SAF 的操作才能兼容高版本,稍微复杂一些,之前的文章也讲过,如果大家有兴趣也可以自行实现。毕竟我也只需要一个文件选择的功能而已,不想过渡封装。

类似的框架,我们除了做一些文件选择的功能,类似的图片选择也可以采用类似的框架实现,只是获取图片的方式不同而已。

通过本文我们可以了解File的使用,权限的申请,FileProvider的使用,以及重点的DocumentProvider,我们重写并完整的了解了如何的使用。

接下来放出源码供大家参考与指正,【传送门】

本文发布之时也已经传到 MavenCentral 了,如有要求可以直接依赖,地址如下:

implementation "com.gitee.newki123456:android_choose_file:1.0.0"

内部的依赖库版本并不高,appcompat:1.2.0 ,recyclerview:1.1.0 ,swiperefreshlayout:1.1.0 。最低支持到 4.4 版本,默认 target 为 31 。aar总大小为 100K 。如果有需求可以远程依赖去使用,如果有自定义化的需求,也可以自行拉代码修改。

作者:Newki
链接:https://juejin.cn/post/7218080084142309436
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值