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
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

Logo

旨在为数千万中国开发者提供一个无缝且高效的云端环境,以支持学习、使用和贡献开源项目。

更多推荐