Skip to content

Most visited

Recently visited



Android 4.4 (API 級別 19) 導入了「儲存空間存取架構」(Storage Access Framework (SAF)),SAF 可方便使用者透過偏好的文件儲存空間供應程式開啟文件、圖片等其他檔案。 提供簡單易用的標準 UI 可讓使用者在各種應用程式和供應程式中,以相同的方式瀏覽檔案及存取近期開啟的檔案。

雲端或本機儲存服務可實作會封裝服務本身的 DocumentsProvider,藉此加入這個生態系統。您只需編寫幾行程式碼,即可將需要存取供應程式文件的用戶端應用程式與 SAF 整合。

SAF 內含下列項目:

以下是 SAF 提供的部分功能:


SAF 是以內容供應程式 (DocumentsProvider 類別的子類別) 為基礎。 「文件供應程式」中的資料結構採用如下所示的傳統檔案階層:

data model

圖 1.文件供應程式資料模型。「根目錄」會指向單一「文件」,接著該文件會展開成樹狀結構的分支。請注意下列事項:


如上所述,文件供應程式資料模型是以傳統檔案階層為基礎。 不過,您可以自己偏好的方式儲存您的資料,只要所儲存資料可透過 DocumentsProvider API 存取即可。例如,您可以將資料存放在標籤式的雲端儲存空間。

圖 2 是相片應用程式如何使用 SAF 存取已儲存資料的說明範例:


圖 2.儲存空間存取架構


圖 3 顯示的是使用者搜尋指定 Google 雲端硬碟帳戶中的圖片時所用的挑選器:


圖 3.挑選器

使用者選取 Google 雲端硬碟後,系統就會顯示相關圖片 (如圖 4 所示)。 此時,使用者即可與這些圖片進行供應程式和用戶端應用程式支援的互動。


圖 4.相關圖片


如果您想讓應用程式在搭載 Android 4.3 以下版本的裝置上從其他應用程式擷取檔案,您的應用程式就必須呼叫 ACTION_PICKACTION_GET_CONTENT 意圖。 接著,使用者必須選取某款應用程式來選取檔案,而且選定的應用程式必須提供使用者介面,讓使用者瀏覽及挑選可用的檔案。

針對搭載 Android 4.4 以上版本的裝置,您的應用程式還可以呼叫 ACTION_OPEN_DOCUMENT 意圖,以顯示系統所控管的挑選器 UI,方便使用者瀏覽其他應用程式提供的所有檔案。 透過這個單一 UI,使用者可以從任何受支援的應用程式挑選檔案。

ACTION_OPEN_DOCUMENT 並不是 ACTION_GET_CONTENT 的替代意圖,實際上應呼叫的意圖取決於您應用程式的需求。


以下程式碼片段採用 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)

    // 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 "*/*".

    startActivityForResult(intent, READ_REQUEST_CODE);



使用者在挑選器中選取某份文件後,便會呼叫 onActivityResult()。指向所選文件的 URI 包含在 resultData 參數中。 請使用 getData() 擷取 URI,然後使用該 URI 擷取使用者所需的文件。 例如:

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());


取得文件的 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(
            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 {


取得文件的 URI 後,您就可以開啟該文件或是對該文件執行任何所需操作。


以下範例可開啟 Bitmap

private Bitmap getBitmapFromUri(Uri uri) throws IOException {
    ParcelFileDescriptor parcelFileDescriptor =
            getContentResolver().openFileDescriptor(uri, "r");
    FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
    Bitmap image = BitmapFactory.decodeFileDescriptor(fileDescriptor);
    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(
    StringBuilder stringBuilder = new StringBuilder();
    String line;
    while ((line = reader.readLine()) != null) {
    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).

    // Create a file with the requested MIME type.
    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).

    // Filter to show only text files.

    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.
    } catch (FileNotFoundException e) {
    } catch (IOException e) {


應用程式開啟要讀取或寫入的檔案後,系統會將該檔案的 URI 權限授予您的應用程式。 除非使用者重新啟動裝置,否則這項權限會持續保持有效狀態。不過,假如您的應用程式為圖片編輯應用程式,而您希望使用者可直接透過您的應用程式存取他們最近編輯的 5 張圖片。如果使用者重新啟動的裝置,就您必須將使用者傳回系統挑選器來搜尋所需檔案,而這並非最佳做法。

為了避免這種情況發生,您可以保留系統授予您應用程式的權限。實際上,您的應用程式會「取得」系統授予的永久性 URI 權限。 這種權限可讓使用者持續透過您的應用程式存取檔案,即使其裝置重新啟動也無妨:

final int takeFlags = intent.getFlags()
// Check for the freshest data.
getContentResolver().takePersistableUriPermission(uri, takeFlags);

除了上述指示外,您還需要完成最後一個步驟。您儲存了您的應用程式最近存取的 URI,但這些 URI 有可能已失效 — 原因在於其他應用程式刪除或修改了文件。 因此,建議您一律呼叫 getContentResolver().takePersistableUriPermission() 檢查最新資料。


如果您想開發可提供檔案儲存服務 (例如雲端儲存服務) 的應用程式,可以編寫自訂文件供應程式透過 SAF 提供您的檔案。 本節說明如何編寫這類程式。




<manifest... >
        android:targetSdkVersion="19" />
                <action android:name="android.content.action.DOCUMENTS_PROVIDER" />


支援搭載 Android 4.3 以下版本的裝置

只有搭載 Android 4.4 以上版本的裝置可使用 ACTION_OPEN_DOCUMENT 意圖。如果您想讓應用程式支援 ACTION_GET_CONTENT 以便與搭載 Android 4.3 以下版本的裝置相容,請針對搭載 Android 4.4 以上版本的裝置停用宣示說明中的 ACTION_GET_CONTENT 意圖篩選器。 文件供應器和 ACTION_GET_CONTENT 是完全不同的項目。 如果您同時支援這兩個項目,您的應用程式就會重複出現在系統挑選器 UI 中,讓使用者可透過兩種不同方式存取您儲存的資料, 而這樣會造成混淆。

以下提供針對搭載 Android 4.4 以上版本的裝置停用 ACTION_GET_CONTENT 意圖篩選器的建議做法:

  1. 在位於 res/values/bool.xml 資源檔案中,新增以下程式碼:
    <bool name="atMostJellyBeanMR2">true</bool>
  2. 在位於 res/values-v19/bool.xml 資源檔案中,新增以下程式碼:
    <bool name="atMostJellyBeanMR2">false</bool>
  3. 新增 Activity 別名來針對搭載 Android 4.4 (API 級別 19) 以上版本的裝置停用 ACTION_GET_CONTENT 意圖篩選器。 例如:
    <!-- 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=""
            <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/*" />


一般來說,當您編寫自訂內容供應程式時,需要完成的其中一項工作為實作合約類別 (詳情請參閱內容供應程式開發人員指南)。 合約類別是 public final 類別,內含以下項目的固定不變定義:URI、欄名稱、MIME 類型以及供應程式擁有的其他中繼資料。 SAF 可為您提供以下合約類別,因此您不必自行編 寫合約:


private static final String[] DEFAULT_ROOT_PROJECTION =
        new String[]{Root.COLUMN_ROOT_ID, Root.COLUMN_MIME_TYPES,
private static final String[] DEFAULT_DOCUMENT_PROJECTION = new
        String[]{Document.COLUMN_DOCUMENT_ID, Document.COLUMN_MIME_TYPE,
        Document.COLUMN_FLAGS, Document.COLUMN_SIZE,};

將 DocumentsProvider 設為子類別

編寫自動文件供應程式的下一個步驟是,將抽象類別 DocumentsProvider 設為子類別。 您至少必須實作下列方法:

以上是您必須實作的方法,不過您可能會視需要實作其他方法。 詳情請參閱 DocumentsProvider

實作 queryRoots

實作 queryRoots() 後系統會使用 DocumentsContract.Root 中定義的資料欄,傳回指向文件供應程式所有根目錄的 Cursor

在以下程式碼片段中,projection 參數代表呼叫者想返回的特定欄位。 這個程式碼片隊會建立新游標並在其中加入一列 — 也就是根目錄或頂層目錄 (例如「下載」或「圖片」)。 大多數供應程式只有一個根目錄。而您可以有多個根目錄,例如擁有多個使用者帳戶的情況下。 在這種情況下,只要在游標中加入第二列即可。

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.
            Root.FLAG_SUPPORTS_RECENTS |

    // 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 中選擇應用程式的根目錄後,就會呼叫這個方法,藉此取得根目錄內某個目錄中的下層文件。 您可以在檔案階層的任何層級中呼叫這個方法,而不單單只能在根目錄中呼叫。 以下程式碼片段會使用要求的資料欄建立新游標,然後加入該游標中上層目錄的任何下層物件相關資訊。下層物件可以是圖片、其他目錄等任何檔案:

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

    final MatrixCursor result = new
    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() 所傳送的相同資訊:

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

    // Create a cursor with the requested projection, or the default projection.
    final MatrixCursor result = new
    includeFile(result, documentId, null);
    return result;

實作 openDocument

您必須實作 openDocument() 來傳回代表特定檔案的 ParcelFileDescriptor。其他應用程式可利用傳回的 ParcelFileDescriptor 傳輸資料。 使用者選取檔案而且用戶端應用程式呼叫 openFileDescriptor() 要求存取該檔案後,系統就會呼叫這個方法。範例:

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, accessMode, handler,
                        new ParcelFileDescriptor.OnCloseListener() {
                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, 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() {
            .buildRootsUri(AUTHORITY), null);
This site uses cookies to store your preferences for site-specific language and display options.

Get the latest Android developer news and tips that will help you find success on Google Play.

* Required Fields


在 WeChat 上追蹤 Google Developers

Browse this site in ?

You requested a page in , but your language preference for this site is .

Would you like to change your language preference and browse this site in ? If you want to change your language preference later, use the language menu at the bottom of each page.

This class requires API level or higher

This doc is hidden because your selected API level for the documentation is . You can change the documentation API level with the selector above the left navigation.

For more information about specifying the API level your app requires, read Supporting Different Platform Versions.

Take a short survey?
Help us improve the Android developer experience. (Dec 2017 Android Platform & Tools Survey)