Android基础——从存储介质打开文档

使用存储访问框架(SAF-Storage Access Framework)打开文件

Android 4.4(api level 19)中引入了SAF。SAF使用户能够轻松地浏览和打开所有首选的文档存储提供程序中的文档、图像和其他文件。用户可以使用简单易用的UI浏览文件,及在不同的APP及提供程序之间使用统一的方式查看最近历史。

云存储服务或本地存储服务可以通过实现封装了存储服务功能的 DocumentsProvider 加入到这样的生态系统中。客户端APP只需要几行代码集成SAF就能够访问提供程序的文档。

SAF包含了一下3部分:

  • Document Provider —— 是允许某一存储服务(例如:Google Drive)显示其管理的文件的内容提供程序。继承 DocumentsProvider 类的子类可以认为是一个文档提供程序。虽然物理存储介质中文档存储形式取决于你,但文档提供程序的方案(schema)是基于普通的文件结构。Android平台内部集成有几种文档提供程序,例如:下载(Downloads),图片(Images)和视频(Videos)。
  • Client app —— 自定义客户端调用带有 ACTION_OPEN_DOCUMENTACTION_CREATE_DOCUMENT 的intent并且由文档提供程序返回检索的文件。
  • Picker —— 由系统展示给用户,由所有文档提供程序提供符合用户检索文件标准并可以选择文档的系统UI。

SAF提供的特性包含以下几点:

  • 使用户能够浏览所有文档提供程序中内容,而并非单个APP中文档;
  • 使用户可以长时间,持续访问某个文档提供程序提供的文档。通过这种方式,用户可以在文档提供程序中添加,删除,保存及编辑文档。
  • 支持多用户账户及当驱动(USB存储)插入才会出现的临时root账户。

概览

SAF围绕着 DocumentsProvider 子类的一个文档提供程序为中心。在一个文档提供程序中,数据作为普通文件结构被组织保存。
SAF访问结构

注意几点:

  • 每个文档提供程序显示一个或多个‘root’(根目录下多个文件/文件夹),用以进入展示树形文档架构。每个root有一个唯一 COLUMN_ROOT_ID,并且id也指向包含文档的文件夹。root的设计是动态的,用以支持多账户,临时USB存储设备,或者用户登录/登出的场景。
  • 每个root是一个文档。每个文档指向1到多个文档,而每个包含的文档反过来也指向1到多个文档(1->N,每个文件夹包含多个文档)。
  • 每个存储后端使用唯一的 COLUMN_COCUMENT_ID 展示单个的文件和目录。文档id一经发布必须唯一且不可修改,因此设备重启后也能保证使用URI持续访问。
  • 文档要么是一个可打开的文件(需要MIME类型),要么是包含其他文档的文件夹(有MIME_TYPE_DIR MIME类型)。
  • 每个功能可以有由 COLUMN_FLAGS 描述的不同功能,例如:FLAG_SUPPORTS_WRITEFLAG_SUPPORTS_DELETE 以及 FLAG_SUPPORTS_THUMBNAIL。 相同的 COLUMN_COCUMENT_ID 可以包含在多个目录中。

控制流程

如上所述,文档提供程序数据模型是基于传统文件结构类型。然而,你可以在物理介质中随意存储数据,只要你可以通过 DocumentsProvider 访问。

控制流程

上图显示了Photo APP使用SAF来访问存储的数据

注意如下几点:

  • SAF中,提供程序(providers)与客户端(clients)不直接交互。clients需要请求权限来操作文件(读取,编辑,创建,或删除文件)。
  • APP通过调用 ACTION_OPEN_DOCUMENTACTION_CREATE_DOCUMENT 的intent开始u文件系统进行交互。可以在Intent中添加过滤条件来优化搜索——例如:展示所有image类型可打开文件。
  • 一旦调用了Intent,系统picker会检索所有的检索程序(providers)并且将符合条件的所有内容展示给用户。
  • picker以标准的UI让用户访问文档,即时潜在的文档提供程序可能不同。

创建客户端

在Android 4.3及以下版本,如果想从其他APP中检索一个文件,必须调用 ACTION_PICKACTION_GET_CONTENT 的Intent实现。用户必须在选择一个APP并且在APP提供的文档提供程序的UI中浏览并且选择可获取的文件。

在Android 4.4以上,用户有了可选的 ACTION_OPEN_DOCUMENT 项来显示系统的picker UI让用户进行浏览其他App中可获取的所有文件。从单一的UI,用户可以从支持的App中任一一个选择文件。

ACTION_OPEN_DOCUMENT 并非取代 ACTION_GET_CONTENT。使用哪个取决于App的需要:

  • 若App只是简单的读取数据则使用 ACTION_GET_CONTENT 。使用这种方式,App只是导入数据的拷贝,如打开图片文件。
  • 若App需要长期,持续访问一个文档提供程序中的文档则使用 ACTION_OPEN_DOCUMENT 。例如照片编辑的App需要用户编辑文档提供程序中的图片。

这部分来说下基于 ACTION_OPEN_DOCUMENTACTION_GET_CONTENT 的客户端。

搜索文档

调用Intent ACTION_GET_CONTENT 启动的picker ui。 在修改为 ACTION_OPEN_DOCUMENT* 效果相同。

    Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
    intent.setType("image/*"); // 过滤出需要搜索到的文档类型 如apk:“application/vnd.android.package-archive”
    intent.addCategory(Intent.CATEGORY_OPENABLE); // 过滤出系统认为可打开的文件
    ((Activity) mContext).startActivityForResult(intent, 500);

点击选取某张图片,返回的发起页面后,得到的路径:

content://com.android.providers.media.documents/document/image%3A1586

这个路径的schema部分是“content”,authorities值是“com.android.providers.media.documents”,后边是路径部分,路径的最后一段是图片名。

需要关注几点:

  • 发起 ACTION_OPEN_DOCUMENT 时,picker显示的是系统中所有文档提供程序内符合搜索条件的文档;
  • 添加 CATEGORY_OPENABLE 的category条件后,picker中会过滤出可打开的文档;
  • 语句 intent.setType(“image/*”) 过滤出带有image MIME类型的图片文件;

搜索结果

在上述代码中,启动picker时调用了startActivityForResult()方法,因此在最终选择了一个文档后,发起页面的onActivityResult()方法会被调用,其中包含返回选中文档的Uri信息,在Intent类型的参数data中,可以通过调用getData()来获取Uri。

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (resultCode != RESULT_OK) {
        return;
    }
    if (requestCode == 500) {
        Uri uri = data != null ? data.getData() : null;
        Log.d("SAF", "uri====>" + uri);
    }
}

检查文档元数据

获取到Uri后,可以通过它查询文档的元数据。以上图为例,获取图片的名称,大小。

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    super.onActivityResult(requestCode, resultCode, data);
    if (resultCode != RESULT_OK) {
        return;
    }
    if (requestCode == 500) {
        Uri uri = data != null ? data.getData() : null;
        if (uri == null) {
            return;
        }
        Log.d("SAF", "uri====>" + uri);

        Cursor cursor = getContentResolver().query(uri, null, null, null, null, null);
        if (cursor == null || !cursor.moveToFirst()) {
            return;
        }
        final String displayName = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME));
        final int indexOfSize = cursor.getColumnIndex(OpenableColumns.SIZE);

        final String size = !cursor.isNull(indexOfSize) ? cursor.getString(indexOfSize) : "Unknown";
        Log.d("SAF", "displayName=" + displayName + ", size=" + size);

        cursor.close();
    }
}

其他的数据按需要可以进行查询,此处不再一一列出。

打开文档

在有了文档的URI数据后,就可以使用想要的方式打开它。

Bitmap

看看Bitmap的开发方式。

public void showImage(Uri uri) {
    try {
        final ParcelFileDescriptor parcelFileDescriptor = getActivity().getContentResolver().openFileDescriptor(uri, "r");
        final FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
        final Bitmap bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor);
        parcelFileDescriptor.close();

        imageView.setImageBitmap(bitmap);
    } catch (IOException e) {
        e.printStackTrace();
    }

}

接着上述功能例子,得到运行结果后,设置到ImageView组件中。

注意:读取文件是个耗时操作,因此建议将此操作放入到工作线程中。

InputStream

再看下从Uri获取InputStream。代码片段中,最终将读取的文档数据输入到String。

public void showContentFromUri(Uri uri) {
    try {
        final InputStream is = getActivity().getContentResolver().openInputStream(uri);
        final BufferedReader reader = new BufferedReader(new InputStreamReader(is));
        final StringBuilder sb = new StringBuilder();
        String line;
        while ((line = reader.readLine()) != null) {
            sb.append(line);
        }
        reader.close();
        textView.setText(sb.toString());
    } catch (IOException e) {
        e.printStackTrace();
    }
}

在读取内容后,写入到TextView。

创建文档

App可以调用 ACTION_CREATE_DOCUMENT Intent在一个文档提供程序中创建文档。要创建文件,需要这是Intent的MIME类型及文件名。

Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT);
intent.setType("text/plain");
intent.putExtra(Intent.EXTRA_TITLE, "create_file.txt");
startActivityForResult(intent, 450);

可以看到创建的UI效果。

在创建之后,可以在onActivityResult()方法中获取到URI数据,继而针对文件可以进行持续操作。

得到的Uri与选择后组成方式一致:

content://com.android.externalstorage.documents/document/primary%3Acreate_file.txt

删除文档

如果你有了一个文档的Uri并且在 Document.COLUMN_FLAGS 中包含 SUPPORTS_DELETE ,那么就可以删除该文档了。

编辑文档

同样,使用SAF可以用来编辑文档。先使用 ACTION_OPEN_DOCUMENT 获取到目标文档Uri。

Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("text/plain");
startActivityForResult(intent, 500);

再通过FileOutputStream对象,使用write()方法将需要的内容写入到文档中。

public void modifyDocument(Uri uri) throws IOException {
	final ParcelFileDescriptor parcelFileDescriptor = getActivity().getContentResolver().openFileDescriptor(uri, "w");
	final FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
	final FileOutputStream fos = new FileOutputStream(fileDescriptor);
	fos.write(("Overwritten by snowman at " + System.currentTimeMillis() + "\n").getBytes());
	fos.close();
	parcelFileDescriptor.close();
}

最后内容成功写入到文档中。

保存权限

在App打开文档进行读取操作时,系统给予了App对该文档进行操作的URI权限授权——这个权限会持续到设备重启。但是假设App是一个图片编辑App,并且只想用户可以访问最近编辑过的5张图片。如果用户设备重启了,则需要再次使用户调用picker来找到文件,但很显然这是不理想的方式。

要防止这种情况发生,最好的方式当然是App能记住这个系统提供的URI授权。这样用户就可以通过你的App持续访问文件,即使设备重启了也可以持续访问。

final int persistableFlags = data.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
getContentResolver().takePersistableUriPermission(uri, persistableFlags);

还有最后一步。App访问过得最近的URI很可能不再有效——其他App可能删除或者修改了文档。因此需要调用 getContentResolver().takePersistableUriPermission() 来检查最新数据。

打开虚拟文件

此部分不再此列举。有兴趣的查看视频链接

补充——Uri获取对应本地绝对地址

在一些场景下,选择本地文件后,不仅仅像图片一样直接打开,或者普通文档一样直接读取text内容。像apk文件,需要安装。或者获取绝对路径进行其他操作,此时就需要通过picker获取的Uri来得到对应的绝对路径。

object ApkPickHelper {

    fun getFilePath(context: Context, srcUri: Uri): String? {
        var uri = srcUri
        var selection: String? = null
        var selectionArgs: Array<String>? = null
        if (DocumentsContract.isDocumentUri(context.applicationContext, uri)) {
            when {
                isExternalStorageDocument(uri) -> {
                    val docId = DocumentsContract.getDocumentId(uri)
                    val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
                    return Environment.getExternalStorageDirectory().absolutePath + "/" + split[1]
                }

                isDownloadsDocument(uri) -> {
                    val id = DocumentsContract.getDocumentId(uri)
                    uri = ContentUris.withAppendedId(
                            Uri.parse("content://downloads/public_downloads"), java.lang.Long.valueOf(id))
                }

                isMediaDocument(uri) -> {
                    val docId = DocumentsContract.getDocumentId(uri)
                    val split = docId.split(":".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
                    when (split[0]) {
                        "image" -> uri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
                        "video" -> uri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
                        "audio" -> uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
                    }
                    selection = "_id=?"
                    selectionArgs = arrayOf(split[1])
                }
            }
        }
        if ("content".equals(uri.scheme!!, ignoreCase = true)) {
            if (isGooglePhotosUri(uri)) {
                return uri.lastPathSegment
            }
            val projection = arrayOf(MediaStore.Images.Media.DATA)
            var cursor: Cursor? = null
            try {
                cursor = context.contentResolver
                        .query(uri, projection, selection, selectionArgs, null)
                val columnIndex = cursor!!.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
                if (cursor.moveToFirst()) {
                    return cursor.getString(columnIndex)
                }
            } catch (e: Exception) {
                e.printStackTrace()
            } finally {
                cursor?.close()
            }
        } else if ("file".equals(uri.scheme!!, ignoreCase = true)) {
            return uri.path
        }
        return null
    }

    private fun isExternalStorageDocument(uri: Uri): Boolean {
        return "com.android.externalstorage.documents" == uri.authority
    }

    private fun isDownloadsDocument(uri: Uri): Boolean {
        return "com.android.providers.downloads.documents" == uri.authority
    }

    private fun isMediaDocument(uri: Uri): Boolean {
        return "com.android.providers.media.documents" == uri.authority
    }

    private fun isGooglePhotosUri(uri: Uri): Boolean {
        return "com.google.android.apps.photos.content" == uri.authority
    }
}
  • 8
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
您好!感谢您的提问。 以下是一个简单的 Android 计数器的基础编码示例: 1. 在 activity_main.xml 文件中添加以下代码: ``` <?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:orientation="vertical" android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:id="@+id/counterTextView" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="0" android:textSize="50dp" android:layout_gravity="center_horizontal"/> <Button android:id="@+id/incrementButton" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Increment" android:layout_gravity="center_horizontal" android:onClick="incrementCounter"/> <Button android:id="@+id/resetButton" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Reset" android:layout_gravity="center_horizontal" android:onClick="resetCounter"/> </LinearLayout> ``` 2. 在 MainActivity.java 文件中添加以下代码: ``` public class MainActivity extends AppCompatActivity { private int counter = 0; private TextView counterTextView; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); counterTextView = findViewById(R.id.counterTextView); } public void incrementCounter(View view) { counter++; counterTextView.setText(Integer.toString(counter)); } public void resetCounter(View view) { counter = 0; counterTextView.setText(Integer.toString(counter)); } } ``` 3. 运行应用程序,您将看到一个显示当前计数器值的 TextView 和两个按钮:Increment 和 Reset。单击 Increment 按钮将增加计数器值,单击 Reset 按钮将将计数器值重置为零。 希望这可以帮助您入门 Android 编程!
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

VoidHope

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值