page.title=存储访问框架 @jd:body
Android 4.4(API 19 级)引入了存储访问框架 (SAF)。SAF 让用户能够在其所有首选文档存储提供程序中方便地浏览并打开文档、图像以及其他文件。 用户可以通过易用的标准 UI,以统一方式在所有应用和提供程序中浏览文件和访问最近使用的文件。
云存储服务或本地存储服务可以通过实现封装其服务的 {@link android.provider.DocumentsProvider} 参与此生态系统。只需几行代码,便可将需要访问提供程序文档的客户端应用与 SAF 集成。
SAF 包括以下内容:
SAF 提供的部分功能如下:
SAF 围绕的内容提供程序是 {@link android.provider.DocumentsProvider} 类的一个子类。在文档提供程序内,数据结构采用传统的文件层次结构:
请注意以下事项:
如前文所述,文档提供程序数据模型基于传统文件层次结构。 不过,只要可以通过 {@link android.provider.DocumentsProvider} API 访问数据,您实际上可以按照自己喜好的方式存储数据。例如,您可以使用基于标记的云存储来存储数据。
图 2 中的示例展示的是照片应用如何利用 SAF 访问存储的数据:
请注意以下事项:
图 3 显示了一个选取器,一位搜索图像的用户在其中选择了一个 Google 云端硬盘帐户:
当用户选择 Google 云端硬盘时,系统会显示图像,如图 4 所示。从这时起,用户就可以通过提供程序和客户端应用支持的任何方式与它们进行交互。
对于 Android 4.3 及更低版本,如果您想让应用从其他应用中检索文件,它必须调用 {@link android.content.Intent#ACTION_PICK} 或 {@link android.content.Intent#ACTION_GET_CONTENT} 等 Intent。然后,用户必须选择一个要从中选取文件的应用,并且所选应用必须提供一个用户界面,以便用户浏览和选取可用文件。
对于 Android 4.4 及更高版本,您还可以选择使用 {@link android.content.Intent#ACTION_OPEN_DOCUMENT} Intent,后者会显示一个由系统控制的选取器 UI,用户可以通过它浏览其他应用提供的所有文件。用户只需通过这一个 UI 便可从任何受支持的应用中选取文件。
{@link android.content.Intent#ACTION_OPEN_DOCUMENT} 并非设计用于替代 {@link android.content.Intent#ACTION_GET_CONTENT}。应使用的 Intent 取决于应用的需要:
本节描述如何编写基于 {@link android.content.Intent#ACTION_OPEN_DOCUMENT} 和 {@link android.content.Intent#ACTION_CREATE_DOCUMENT} Intent 的客户端应用。
以下代码段使用 {@link android.content.Intent#ACTION_OPEN_DOCUMENT} 来搜索包含图像文件的文档提供程序:
private static final int READ_REQUEST_CODE = 42; ... /** * Fires an intent to spin up the "file chooser" UI and select an image. */ public void performFileSearch() { // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's file // browser. Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); // Filter to only show results that can be "opened", such as a // file (as opposed to a list of contacts or timezones) intent.addCategory(Intent.CATEGORY_OPENABLE); // Filter to show only images, using the image MIME data type. // If one wanted to search for ogg vorbis files, the type would be "audio/ogg". // To search for all documents available via installed storage providers, // it would be "*/*". intent.setType("image/*"); startActivityForResult(intent, READ_REQUEST_CODE); }
请注意以下事项:
用户在选取器中选择文档后,系统就会调用 {@link android.app.Activity#onActivityResult onActivityResult()}。指向所选文档的 URI 包含在 {@code resultData} 参数中。使用 {@link android.content.Intent#getData getData()} 提取 URI。获得 URI 后,即可使用它来检索用户想要的文档。例如:
@Override public void onActivityResult(int requestCode, int resultCode, Intent resultData) { // The ACTION_OPEN_DOCUMENT intent was sent with the request code // READ_REQUEST_CODE. If the request code seen here doesn't match, it's the // response to some other intent, and the code below shouldn't run at all. if (requestCode == READ_REQUEST_CODE && resultCode == Activity.RESULT_OK) { // The document selected by the user won't be returned in the intent. // Instead, a URI to that document will be contained in the return intent // provided to this method as a parameter. // Pull that URI using resultData.getData(). Uri uri = null; if (resultData != null) { uri = resultData.getData(); Log.i(TAG, "Uri: " + uri.toString()); showImage(uri); } } }
获得文档的 URI 后,即可获得对其元数据的访问权限。以下代码段用于获取 URI 所指定文档的元数据并将其记入日志:
public void dumpImageMetaData(Uri uri) { // The query, since it only applies to a single document, will only return // one row. There's no need to filter, sort, or select fields, since we want // all fields for one document. Cursor cursor = getActivity().getContentResolver() .query(uri, null, null, null, null, null); try { // moveToFirst() returns false if the cursor has 0 rows. Very handy for // "if there's anything to look at, look at it" conditionals. if (cursor != null && cursor.moveToFirst()) { // Note it's called "Display Name". This is // provider-specific, and might not necessarily be the file name. String displayName = cursor.getString( cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)); Log.i(TAG, "Display Name: " + displayName); int sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE); // If the size is unknown, the value stored is null. But since an // int can't be null in Java, the behavior is implementation-specific, // which is just a fancy term for "unpredictable". So as // a rule, check if it's null before assigning to an int. This will // happen often: The storage API allows for remote files, whose // size might not be locally known. String size = null; if (!cursor.isNull(sizeIndex)) { // Technically the column stores an int, but cursor.getString() // will do the conversion automatically. size = cursor.getString(sizeIndex); } else { size = "Unknown"; } Log.i(TAG, "Size: " + size); } } finally { cursor.close(); } }
获得文档的 URI 后,即可打开文档或对其执行任何其他您想要执行的操作。
以下示例展示了如何打开 {@link android.graphics.Bitmap}:
private Bitmap getBitmapFromUri(Uri uri) throws IOException { ParcelFileDescriptor parcelFileDescriptor = getContentResolver().openFileDescriptor(uri, "r"); FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor(); Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor); parcelFileDescriptor.close(); return image; }
请注意,您不应在 UI 线程上执行此操作。请使用 {@link android.os.AsyncTask} 在后台执行此操作。打开位图后,即可在 {@link android.widget.ImageView} 中显示它。
以下示例展示了如何从 URI 中获取 {@link java.io.InputStream}。在此代码段中,系统将文件行读取到一个字符串中:
private String readTextFromUri(Uri uri) throws IOException { InputStream inputStream = getContentResolver().openInputStream(uri); BufferedReader reader = new BufferedReader(new InputStreamReader( inputStream)); StringBuilder stringBuilder = new StringBuilder(); String line; while ((line = reader.readLine()) != null) { stringBuilder.append(line); } fileInputStream.close(); parcelFileDescriptor.close(); return stringBuilder.toString(); }
您的应用可以使用 {@link android.content.Intent#ACTION_CREATE_DOCUMENT} Intent 在文档提供程序中创建新文档。要想创建文件,请为您的 Intent 提供一个 MIME 类型和文件名,然后通过唯一的请求代码启动它。系统会为您执行其余操作:
// Here are some examples of how you might call this method. // The first parameter is the MIME type, and the second parameter is the name // of the file you are creating: // // createFile("text/plain", "foobar.txt"); // createFile("image/png", "mypicture.png"); // Unique request code. private static final int WRITE_REQUEST_CODE = 43; ... private void createFile(String mimeType, String fileName) { Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); // Filter to only show results that can be "opened", such as // a file (as opposed to a list of contacts or timezones). intent.addCategory(Intent.CATEGORY_OPENABLE); // Create a file with the requested MIME type. intent.setType(mimeType); intent.putExtra(Intent.EXTRA_TITLE, fileName); startActivityForResult(intent, WRITE_REQUEST_CODE); }
创建新文档后,即可在 {@link android.app.Activity#onActivityResult onActivityResult()} 中获取其 URI,以便继续向其写入内容。
如果您获得了文档的 URI,并且文档的 {@link android.provider.DocumentsContract.Document#COLUMN_FLAGS Document.COLUMN_FLAGS} 包含 {@link android.provider.DocumentsContract.Document#FLAG_SUPPORTS_DELETE SUPPORTS_DELETE},便可以删除该文档。例如:
DocumentsContract.deleteDocument(getContentResolver(), uri);
您可以使用 SAF 就地编辑文本文档。以下代码段会触发 {@link android.content.Intent#ACTION_OPEN_DOCUMENT} Intent 并使用类别 {@link android.content.Intent#CATEGORY_OPENABLE} 以仅显示可以打开的文档。它会进一步过滤以仅显示文本文件:
private static final int EDIT_REQUEST_CODE = 44; /** * Open a file for writing and append some text to it. */ private void editDocument() { // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's // file browser. Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT); // Filter to only show results that can be "opened", such as a // file (as opposed to a list of contacts or timezones). intent.addCategory(Intent.CATEGORY_OPENABLE); // Filter to show only text files. intent.setType("text/plain"); startActivityForResult(intent, EDIT_REQUEST_CODE); }
接下来,您可以从 {@link android.app.Activity#onActivityResult onActivityResult()}(请参阅处理结果)调用代码以执行编辑。以下代码段可从 {@link android.content.ContentResolver} 获取 {@link java.io.FileOutputStream}。默认情况下,它使用“写入”模式。最佳做法是请求获得所需的最低限度访问权限,因此如果您只需要写入权限,就不要请求获得读取/写入权限。
private void alterDocument(Uri uri) { try { ParcelFileDescriptor pfd = getActivity().getContentResolver(). openFileDescriptor(uri, "w"); FileOutputStream fileOutputStream = new FileOutputStream(pfd.getFileDescriptor()); fileOutputStream.write(("Overwritten by MyCloud at " + System.currentTimeMillis() + "\n").getBytes()); // Let the document provider know you're done by closing the stream. fileOutputStream.close(); pfd.close(); } catch (FileNotFoundException e) { e.printStackTrace(); } catch (IOException e) { e.printStackTrace(); } }
当您的应用打开文件进行读取或写入时,系统会为您的应用提供针对该文件的 URI 授权。该授权将一直持续到用户设备重启时。但假定您的应用是图像编辑应用,而且您希望用户能够直接从应用中访问他们编辑的最后 5 张图像。如果用户的设备已经重启,您就需要将用户转回系统选取器以查找这些文件,这显然不是理想的做法。
为防止出现这种情况,您可以保留系统为您的应用授予的权限。您的应用实际上是“获取”了系统提供的持久 URI 授权。这使用户能够通过您的应用持续访问文件,即使设备已重启也不受影响:
final int takeFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); // Check for the freshest data. getContentResolver().takePersistableUriPermission(uri, takeFlags);
还有最后一个步骤。您可能已经保存了应用最近访问的 URI,但它们可能不再有效—另一个应用可能已删除或修改了文档。 因此,您应该始终调用 {@code getContentResolver().takePersistableUriPermission()} 以检查有无最新数据。
如果您要开发为文件提供存储服务(如云保存服务)的应用,可以通过编写自定义文档提供程序,通过 SAF 提供您的文件。本节描述如何执行此操作。
要想实现自定义文档提供程序,请将以下内容添加到您的应用的清单文件:
<provider>
元素; com.example.android.storageprovider.MyCloudProvider
;
com.example.android.storageprovider
)加内容提供程序的类型
(documents
)。例如,{@code com.example.android.storageprovider.documents};android:exported
设置为
"true"
。您必须导出提供程序,以便其他应用可以看到;android:grantUriPermissions
设置为
"true"
。此设置允许系统向其他应用授予对提供程序中内容的访问权限。
如需查看有关保留对特定文档授权的阐述,请参阅保留权限;
<bool name="atLeastKitKat">false</bool>
<bool name="atLeastKitKat">true</bool>
以下是从一个包括提供程序的示例清单文件中摘录的内容:
<manifest... > ... <uses-sdk android:minSdkVersion="19" android:targetSdkVersion="19" /> .... <provider android:name="com.example.android.storageprovider.MyCloudProvider" android:authorities="com.example.android.storageprovider.documents" android:grantUriPermissions="true" android:exported="true" android:permission="android.permission.MANAGE_DOCUMENTS" android:enabled="@bool/atLeastKitKat"> <intent-filter> <action android:name="android.content.action.DOCUMENTS_PROVIDER" /> </intent-filter> </provider> </application> </manifest>
{@link android.content.Intent#ACTION_OPEN_DOCUMENT} Intent 仅可用于运行 Android 4.4 及更高版本的设备。如果您想让应用支持 {@link android.content.Intent#ACTION_GET_CONTENT} 以适应运行 Android 4.3 及更低版本的设备,则应在您的清单文件中为运行 Android 4.4 或更高版本的设备禁用 {@link android.content.Intent#ACTION_GET_CONTENT} Intent 过滤器。应将文档提供程序和 {@link android.content.Intent#ACTION_GET_CONTENT} 视为具有互斥性。如果您同时支持这两者,您的应用将在系统选取器 UI 中出现两次,提供两种不同的方式来访问您存储的数据。这会给用户造成困惑。
建议按照以下步骤为运行 Android 4.4 版或更高版本的设备禁用 {@link android.content.Intent#ACTION_GET_CONTENT} Intent 过滤器:
<bool name="atMostJellyBeanMR2">true</bool>
<bool name="atMostJellyBeanMR2">false</bool>
<!-- This activity alias is added so that GET_CONTENT intent-filter can be disabled for builds on API level 19 and higher. --> <activity-alias android:name="com.android.example.app.MyPicker" android:targetActivity="com.android.example.app.MyActivity" ... android:enabled="@bool/atMostJellyBeanMR2"> <intent-filter> <action android:name="android.intent.action.GET_CONTENT" /> <category android:name="android.intent.category.OPENABLE" /> <category android:name="android.intent.category.DEFAULT" /> <data android:mimeType="image/*" /> <data android:mimeType="video/*" /> </intent-filter> </activity-alias>
通常,当您编写自定义内容提供程序时,其中一项任务是实现协定类,如内容提供程序开发者指南中所述。 协定类是一种 {@code public final} 类,它包含对 URI、列名称、MIME 类型以及其他与提供程序有关的元数据的常量定义。SAF 会为您提供这些协定类,因此您无需自行编写:
例如,当系统在您的文档提供程序中查询文档或根目录时,您可能会在游标中返回以下列:
private static final String[] DEFAULT_ROOT_PROJECTION = new String[]{Root.COLUMN_ROOT_ID, Root.COLUMN_MIME_TYPES, Root.COLUMN_FLAGS, Root.COLUMN_ICON, Root.COLUMN_TITLE, Root.COLUMN_SUMMARY, Root.COLUMN_DOCUMENT_ID, Root.COLUMN_AVAILABLE_BYTES,}; private static final String[] DEFAULT_DOCUMENT_PROJECTION = new String[]{Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE, Document.COLUMN_DISPLAY_NAME, Document.COLUMN_LAST_MODIFIED, Document.COLUMN_FLAGS, Document.COLUMN_SIZE,};
编写自定义文档提供程序的下一步是为抽象类 {@link android.provider.DocumentsProvider} 创建子类。您至少需要实现以下方法:
这些只是您需要严格实现的方法,但您可能还想实现许多其他方法。 详情请参阅 {@link android.provider.DocumentsProvider}。
您实现的 {@link android.provider.DocumentsProvider#queryRoots queryRoots()} 必须使用在 {@link android.provider.DocumentsContract.Root} 中定义的列返回一个指向文档提供程序所有根目录的 {@link android.database.Cursor}。
在以下代码段中,{@code projection} 参数表示调用方想要返回的特定字段。代码段会创建一个新游标,并为其添加一行—一个根目录,如 Downloads 或 Images 等顶层目录。大多数提供程序只有一个根目录。有时您可能有多个根目录,例如,当您具有多个用户帐户时。 在这种情况下,只需再为游标添加一行。
@Override public Cursor queryRoots(String[] projection) throws FileNotFoundException { // Create a cursor with either the requested fields, or the default // projection if "projection" is null. final MatrixCursor result = new MatrixCursor(resolveRootProjection(projection)); // If user is not logged in, return an empty root cursor. This removes our // provider from the list entirely. if (!isUserLoggedIn()) { return result; } // It's possible to have multiple roots (e.g. for multiple accounts in the // same app) -- just add multiple cursor rows. // Construct one row for a root called "MyCloud". final MatrixCursor.RowBuilder row = result.newRow(); row.add(Root.COLUMN_ROOT_ID, ROOT); row.add(Root.COLUMN_SUMMARY, getContext().getString(R.string.root_summary)); // FLAG_SUPPORTS_CREATE means at least one directory under the root supports // creating documents. FLAG_SUPPORTS_RECENTS means your application's most // recently used documents will show up in the "Recents" category. // FLAG_SUPPORTS_SEARCH allows users to search all documents the application // shares. row.add(Root.COLUMN_FLAGS, Root.FLAG_SUPPORTS_CREATE | Root.FLAG_SUPPORTS_RECENTS | Root.FLAG_SUPPORTS_SEARCH); // COLUMN_TITLE is the root title (e.g. Gallery, Drive). row.add(Root.COLUMN_TITLE, getContext().getString(R.string.title)); // This document id cannot change once it's shared. row.add(Root.COLUMN_DOCUMENT_ID, getDocIdForFile(mBaseDir)); // The child MIME types are used to filter the roots and only present to the // user roots that contain the desired type somewhere in their file hierarchy. row.add(Root.COLUMN_MIME_TYPES, getChildMimeTypes(mBaseDir)); row.add(Root.COLUMN_AVAILABLE_BYTES, mBaseDir.getFreeSpace()); row.add(Root.COLUMN_ICON, R.drawable.ic_launcher); return result; }
您实现的 {@link android.provider.DocumentsProvider#queryChildDocuments queryChildDocuments()} 必须使用在 {@link android.provider.DocumentsContract.Document} 中定义的列返回一个指向指定目录中所有文件的 {@link android.database.Cursor}。
当您在选取器 UI 中选择应用时,系统会调用此方法。它会获取根目录下某个目录内的子文档。可以在文件层次结构的任何级别调用此方法,并非只能从根目录调用。 以下代码段可创建一个包含所请求列的新游标,然后向游标添加父目录中每个直接子目录的相关信息。子目录可以是图像、另一个目录乃至任何文件:
@Override public Cursor queryChildDocuments(String parentDocumentId, String[] projection, String sortOrder) throws FileNotFoundException { final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); final File parent = getFileForDocId(parentDocumentId); for (File file : parent.listFiles()) { // Adds the file's display name, MIME type, size, and so on. includeFile(result, null, file); } return result; }
您实现的 {@link android.provider.DocumentsProvider#queryDocument queryDocument()} 必须使用在 {@link android.provider.DocumentsContract.Document} 中定义的列返回一个指向指定文件的 {@link android.database.Cursor}。
除了特定文件的信息外,{@link android.provider.DocumentsProvider#queryDocument queryDocument()} 方法返回的信息与 {@link android.provider.DocumentsProvider#queryChildDocuments queryChildDocuments()} 中传递的信息相同:
@Override public Cursor queryDocument(String documentId, String[] projection) throws FileNotFoundException { // Create a cursor with the requested projection, or the default projection. final MatrixCursor result = new MatrixCursor(resolveDocumentProjection(projection)); includeFile(result, documentId, null); return result; }
您必须实现 {@link android.provider.DocumentsProvider#openDocument openDocument()} 以返回表示指定文件的 {@link android.os.ParcelFileDescriptor}。其他应用可以使用返回的 {@link android.os.ParcelFileDescriptor} 来流式传输数据。用户选择了文件,并且客户端应用通过调用 {@link android.content.ContentResolver#openFileDescriptor openFileDescriptor()} 来请求对文件的访问权限,系统便会调用此方法。例如:
@Override public ParcelFileDescriptor openDocument(final String documentId, final String mode, CancellationSignal signal) throws FileNotFoundException { Log.v(TAG, "openDocument, mode: " + mode); // It's OK to do network operations in this method to download the document, // as long as you periodically check the CancellationSignal. If you have an // extremely large file to transfer from the network, a better solution may // be pipes or sockets (see ParcelFileDescriptor for helper methods). final File file = getFileForDocId(documentId); final boolean isWrite = (mode.indexOf('w') != -1); if(isWrite) { // Attach a close listener if the document is opened in write mode. try { Handler handler = new Handler(getContext().getMainLooper()); return ParcelFileDescriptor.open(file, accessMode, handler, new ParcelFileDescriptor.OnCloseListener() { @Override public void onClose(IOException e) { // Update the file with the cloud server. The client is done // writing. Log.i(TAG, "A file with id " + documentId + " has been closed! Time to " + "update the server."); } }); } catch (IOException e) { throw new FileNotFoundException("Failed to open document with id " + documentId + " and mode " + mode); } } else { return ParcelFileDescriptor.open(file, accessMode); } }
假设您的文档提供程序是受密码保护的云存储服务,并且您想在开始共享用户的文件之前确保其已登录。如果用户未登录,您的应用应该执行哪些操作呢? 解决方案是在您实现的 {@link android.provider.DocumentsProvider#queryRoots queryRoots()} 中返回零个根目录。也就是空的根目录游标:
public Cursor queryRoots(String[] projection) throws FileNotFoundException { ... // If user is not logged in, return an empty root cursor. This removes our // provider from the list entirely. if (!isUserLoggedIn()) { return result; }
另一个步骤是调用 {@code getContentResolver().notifyChange()}。还记得 {@link android.provider.DocumentsContract} 吗?我们将使用它来创建此 URI。以下代码段会在每次用户的登录状态发生变化时指示系统查询文档提供程序的根目录。 如果用户未登录,则调用 {@link android.provider.DocumentsProvider#queryRoots queryRoots()} 会返回一个空游标,如上文所示。这可以确保只有在用户登录提供程序后其中的文档才可用。
private void onLoginButtonClick() { loginOrLogout(); getContentResolver().notifyChange(DocumentsContract .buildRootsUri(AUTHORITY), null); }