存储访问框架
Android 4.4(API级别19)引入了存储访问框架(SAF)。 SAF使用户可以轻松浏览和打开所有首选文档存储提供程序中的文档,图像和其他文件。标准易用的用户界面允许用户在应用和提供商之间以一致的方式浏览文件和访问最新信息。
云或本地存储服务可以通过实现封装其服务的DocumentsProvider来参与此生态系统。需要访问提供者文档的客户端应用程序只需几行代码就可以与SAF集成。
SAF包括以下内容:
- 文档提供程序 - 允许存储服务(如Google Drive)显示其管理的文件的内容提供程序。文档提供程序实现为DocumentsProvider类的子类。文档提供程序架构基于传统的文件层次结构,但文档提供程序实际存储数据的方式由您决定。 Android平台包括几个内置文档提供程序,如下载,图像和视频。
- 客户端应用程序 - 一个自定义应用程序,它调用ACTION_OPEN_DOCUMENT和/或ACTION_CREATE_DOCUMENT意图并接收文档提供程序返回的文件。
- Picker-一种系统UI,允许用户访问满足客户端应用搜索条件的所有文档提供者的文档。
SAF提供的一些功能如下:
- 允许用户浏览来自所有文档提供程序的内容,而不仅仅是单个应用程序。
- 使您的应用程序可以长期持久访问文档提供程序拥有的文档。通过此访问,用户可以在提供程序上添加,编辑,保存和删除文件。
- 支持多个用户帐户和瞬态根,例如USB存储提供程序,只有在插入驱动器时才会出现。
概述
SAF围绕一个内容提供者,该提供者是DocumentsProvider类的子类。在文档提供程序中,数据结构为传统文件层次结构:
请注意以下事项:
- 每个文档提供者报告一个或多个“根”,它们是探索文档树的起点。每个根都有一个唯一的COLUMN_ROOT_ID,它指向一个文档(一个目录),表示该根目录下的内容。根设计是动态的,以支持多个帐户,瞬态USB存储设备或用户登录/注销等用例。
- 每个根目录下都是一个文档。该文件指向1至N个文件,每个文件又可指向1至N个文件。
- 每个存储后端通过使用唯一的COLUMN_DOCUMENT_ID引用它们来表示单个文件和目录。文档ID必须是唯一的,并且一旦发布就不会更改,因为它们用于跨设备重新启动的持久URI授权。
- 每个文档都可以具有不同的功能,如COLUMN_FLAGS所述。例如,FLAG_SUPPORTS_WRITE,FLAG_SUPPORTS_DELETE和FLAG_SUPPORTS_THUMBNAIL。相同的COLUMN_DOCUMENT_ID可以包含在多个目录中。
控制流
如上所述,文档提供者数据模型基于传统的文件层次结构。但是,只要可以通过DocumentsProvider API访问,您就可以按照自己的意愿实际存储数据。例如,您可以为数据使用基于标记的云存储。
图2显示了照片应用程序如何使用SAF访问存储数据的示例:
请注意以下事项:
- 在SAF中,提供者和客户端不直接交互。客户端请求与文件交互的权限(即,读取,编辑,创建或删除文件)。
- 当应用程序(在此示例中为照片应用程序)触发意图ACTION_OPEN_DOCUMENT或ACTION_CREATE_DOCUMENT时,交互开始。意图可能包括进一步细化标准的过滤器 - 例如,“给我所有具有'图像'MIME类型的可打开文件。”
- 一旦意图触发,系统选择器将转到每个注册的提供者并向用户显示匹配的内容根。
- 选择器为用户提供了访问文档的标准界面,即使底层文档提供者可能非常不同。例如,图2显示了Google云端硬盘提供商,USB提供商和云提供商。
图3显示了一个选择器,其中搜索图像的用户选择了Google云端硬盘帐户:
当用户选择Google Drive时,将显示图像,如图4所示。从那时起,用户可以以提供者和客户端应用程序支持的任何方式与他们进行交互。
编写客户端应用程序
在Android 4.3及更低版本中,如果您希望应用程序从其他应用程序检索文件,则必须调用ACTION_PICK或ACTION_GET_CONTENT之类的意图。然后,用户必须选择从中挑选文件的单个应用程序,并且所选应用程序必须提供用户界面以供用户浏览和从可用文件中挑选。
在Android 4.4及更高版本中,您还可以选择使用ACTION_OPEN_DOCUMENT意图,该意图显示由系统控制的选择器UI,允许用户浏览其他应用程序可用的所有文件。通过此单一UI,用户可以从任何支持的应用程序中选择文件。
CTION_OPEN_DOCUMENT不能替代ACTION_GET_CONTENT。您应该使用的那个取决于您的应用程序的需求:
- 如果您希望应用程序只是读取/导入数据,请使用ACTION_GET_CONTENT。使用此方法,应用程序将导入数据的副本,例如图像文件
- 如果您希望应用程序对文档提供程序拥有的文档具有长期,持久的访问权限,请使用ACTION_OPEN_DOCUMENT。一个例子是照片编辑应用程序,它允许用户编辑存储在文档提供程序中的图像。
本节介绍如何根据ACTION_OPEN_DOCUMENT和ACTION_CREATE_DOCUMENT意图编写客户端应用程序。
搜索文件
以下代码段使用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);
}
请注意以下事项:
- 当应用程序触发ACTION_OPEN_DOCUMENT意图时,它会启动一个显示所有匹配文档提供程序的选择器。
- 将类别CATEGORY_OPENABLE添加到intent会过滤结果,以仅显示可以打开的文档,例如图像文件。
- 声明intent.setType(“image / *”)进一步过滤以仅显示具有图像MIME数据类型的文档。
处理结果
一旦用户选择了选择器中的文档,就会调用onActivityResult()。指向所选文档的URI包含在resultData参数中。使用getData()提取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后,您可以打开文档或执行其他任何操作。
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线程上执行此操作。使用AsyncTask在后台执行此操作。打开位图后,可以在ImageView中显示它。
获取InputStream
以下是如何从URI获取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();
}
创建一个新文档
您的应用可以使用ACTION_CREATE_DOCUMENT意图在文档提供程序中创建新文档。要创建文件,请为您的意图提供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);
}
创建新文档后,您可以在onActivityResult()中获取其URI,以便您可以继续写入它。
删除文件
如果您有文档的URI并且文档的Document.COLUMN_FLAGS包含SUPPORTS_DELETE,则可以删除该文档。例如:
DocumentsContract.deleteDocument(getContentResolver(), uri);
编辑文档
您可以使用SAF编辑文本文档。此代码段触发ACTION_OPEN_DOCUMENT意图,并使用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);
}
接下来,从onActivityResult()(请参阅处理结果),您可以调用代码来执行编辑。以下代码段从ContentResolver获取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,但它们可能不再有效 - 其他应用可能已删除或修改了文档。因此,您应该始终调用getContentResolver().takePersistableUriPermission()来检查最新的数据。
编写自定义文档提供程序
如果您正在开发一个为文件提供存储服务的应用程序(例如云保存服务),您可以通过编写自定义文档提供程序,通过SAF使您的文件可用。本节介绍如何执行此操作。
Manifest
要实现自定义文档提供程序,请将以下内容添加到应用程序的清单中:
- API级别19或更高的目标。
- 声明自定义存储提供程序的<provider>元素。
- 提供程序的名称,即类名,包括程序包名称。例如:com.example.android.storageprovider.MyCloudProvider。
- 您的权限名称,即您的包名称(在此示例中为com.example.android.storageprovider)以及内容提供程序(文档)的类型。例如,com.example.android.storageprovider.documents。
- android:exports属性设置为“true”。您必须导出您的提供商,以便其他应用可以看到它。
- android:grantUriPermissions属性设置为“true”。此设置允许系统授予其他应用程序访问您的提供商内容的权限。有关如何为特定文档保留授权的讨论,请参阅持久权限。
- MANAGE_DOCUMENTS权限。默认情况下,每个人都可以使用提供商。添加此权限会将您的提供程序限制在系统中。此限制对于安全性很重要。
- android:enabled属性设置为资源文件中定义的布尔值。此属性的目的是在运行Android 4.3或更低版本的设备上禁用提供程序。例如,android:enabled =“@ bool / atLeastKitKat”。除了在清单中包含此属性外,还需要执行以下操作:
在res / values /下的bool.xml资源文件中,添加以下行:
<bool name="atLeastKitKat">false</bool>
在res / values-v19 /下的bool.xml资源文件中,添加以下行:
<bool name="atLeastKitKat">true</bool>
- 包含android.content.action.DOCUMENTS_PROVIDER操作的intent过滤器,以便在系统搜索提供程序时,您的提供程序将出现在选择器中。
以下是包含提供者的示例清单的摘录:
<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>
Contracts
通常,当您编写自定义内容提供程序时,其中一项任务是实现合同类,如内容提供程序开发人员指南中所述。契约类是公共最终类,包含URI,列名,MIME类型以及与提供者相关的其他元数据的常量定义。 SAF为您提供这些合同类,因此您无需编写自己的合同类:
DocumentsContract.Document
DocumentsContract.Root
例如,以下是在查询文档提供程序或文档提供程序时可能返回的列:
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,};
DocumentsProvider子类
编写自定义文档提供程序的下一步是将抽象类DocumentsProvider子类化。至少,您需要实现以下方法:
queryRoots()
queryChildDocuments()
queryDocument()
openDocument()
这些是您严格要求实施的唯一方法,但您可能还需要更多方法。有关详细信息,请参阅DocumentsProvider。
实现queryRoots
您的queryRoots()实现必须使用DocumentsContract.Root中定义的列返回指向文档提供程序的所有根目录的Cursor。
在下面的代码段中,projection参数表示调用者想要返回的特定字段。该代码段创建一个新游标并向其添加一行 - 一个根,一个顶级目录,如下载或图像。大多数提供商只有一个根。例如,在多个用户帐户的情况下,您可能有多个帐户。在这种情况下,只需向光标添加第二行。
@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;
}
实现queryChildDocuments
您的queryChildDocuments()实现必须使用DocumentsContract.Document中定义的列返回指向指定目录中所有文件的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;
}
实现queryDocument
您的queryDocument()实现必须使用DocumentsContract.Document中定义的列返回指向指定文件的Cursor。
queryDocument()方法返回在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;
}
实现openDocument
您必须实现openDocument()以返回表示指定文件的ParcelFileDescriptor。其他应用程序可以使用返回的ParcelFileDescriptor来传输数据。一旦用户选择文件并且客户端应用程序通过调用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);
}
}
安全性
假设您的文档提供程序是受密码保护的云存储服务,并且您希望在开始共享文件之前确保用户已登录。如果用户未登录,您的应用应该怎么做?解决方案是在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;
}
另一步是调用getContentResolver().notifyChange()。还记得DocumentsContract吗?我们用它来制作这个URI。以下代码段告诉系统在用户的登录状态发生变化时查询文档提供程序的根目录。如果用户未登录,则对queryRoots()的调用将返回一个空光标,如上所示。这可确保只有在用户登录到提供程序时,提供程序的文档才可用。
private void onLoginButtonClick() {
loginOrLogout();
getContentResolver().notifyChange(DocumentsContract
.buildRootsUri(AUTHORITY), null);
}