使用存储访问框架打开文件

Android 4.4(API 级别 19)引入了存储访问框架 (SAF)。借助 SAF,用户可轻松在其所有首选文档存储提供程序中浏览并打开文档、图像及其他文件。用户可通过易用的标准界面,以统一方式在所有应用和提供程序中浏览文件,以及访问最近使用的文件。

云存储服务或本地存储服务可实现封装其服务的 DocumentsProvider,进而参与此生态系统。只需几行代码,便可将需要访问提供程序文档的客户端应用与 SAF 进行集成。

SAF 包含以下内容:

  • 文档提供程序 — 一种内容提供程序,可让存储服务(如 Google Drive)显示其管理的文件。文档提供程序以 DocumentsProvider 类的子类形式实现。文档提供程序的架构基于传统的文件层次结构,但其实际的数据存储方式由您决定。Android 平台包含若干内置文档提供程序,如 Downloads、Images 和 Videos。
  • 客户端应用 — 一种自定义应用,它会调用 ACTION_OPEN_DOCUMENT 和/或 ACTION_CREATE_DOCUMENT Intent 并接收文档提供程序返回的文件。
  • 选择器 — 一种系统界面,可让用户访问所有满足客户端应用搜索条件的文档提供程序内的文档。

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

  • 让用户浏览所有文档提供程序的内容,而不仅仅是单个应用的内容。
  • 让您的应用获得对文档提供程序所拥有文档的长期、持续性访问权限。用户可通过此访问权限添加、编辑、保存和删除提供程序上的文件。
  • 支持多个用户帐户和临时根目录,如只有在插入驱动器后才会出现的 USB 存储提供程序。

概览

SAF 所围绕的内容提供程序是 DocumentsProvider 类的一个子类。在文档提供程序内,数据结构采用传统的文件层次结构:

数据模型

图 1. 文档提供程序数据模型。根目录指向单个文档,然后该文档启动整个结构树的扇出。

请注意以下事项:

  • 每个文档提供程序都会报告一个或多个“根目录”(探索文档树的起点)。每个根目录都有唯一的 COLUMN_ROOT_ID,并且指向表示该根目录下内容的文档(目录)。根目录采用动态设计,以支持多个帐户、临时 USB 存储设备或用户登录/注销等用例。
  • 每个根目录下都有一个文档。该文档指向 1 至 N 个文档,其中每个文档又可指向 1 至 N 个文档。
  • 每个存储后端都会使用唯一的 COLUMN_DOCUMENT_ID 引用各个文件和目录,从而将其显示出来。文档 ID 必须具有唯一性,且一经发出便不得更改,因为它们用于所有设备重启过程中的 URI 持久授权。
  • 文档可以是可打开的文件(具有特定的 MIME类型)或包含附加文档的目录(具有 MIME_TYPE_DIR MIME 类型)。
  • COLUMN_FLAGS 所述,每个文档可拥有不同功能。例如,FLAG_SUPPORTS_WRITEFLAG_SUPPORTS_DELETEFLAG_SUPPORTS_THUMBNAIL。多个目录中可包含相同的 COLUMN_DOCUMENT_ID

控制流

如前文所述,文档提供程序数据模型基于传统的文件层次结构。不过,只要能通过 DocumentsProvider API 访问数据,您实际上可以采用自己喜欢的任何方式来存储数据。例如,您可以使用基于标记的云存储来存储数据。

图 2 展示照片应用可能会如何利用 SAF 来访问存储数据:

应用

图 2. 存储访问框架流

请注意以下事项:

  • 在 SAF 中,提供程序和客户端并不直接交互。客户端会请求与文件进行交互(即读取、编辑、创建或删除文件)的权限。
  • 当应用(在本示例中为照片应用)触发 Intent ACTION_OPEN_DOCUMENTACTION_CREATE_DOCUMENT 后,交互便会开始。Intent 可包含过滤器,以进一步细化条件 — 例如,“为我提供所有 MIME 类型为‘图像’的可打开文件”。
  • 当 Intent 触发后,系统选择器会前往每个已注册的提供程序,并向用户显示匹配的内容根目录。
  • 选取器会为用户提供标准的文档访问界面,即使底层文档提供程序可能与其相差较大。例如,图 2 显示了 Google 云端硬盘提供程序、USB 提供程序和云提供程序。

图 3 显示了一个选取器,某位搜索图像的用户在其中选择了一个 Google 云端硬盘帐户。该图还显示可供客户端应用使用的所有根目录。

选择器

图 3. 选择器

当用户选择 Google 云端硬盘时,系统会显示图像(如图 4 所示)。从这时起,用户便可通过提供程序和客户端应用所支持的方式与这些图像交互。

选择器

图 4. Google 图片

编写客户端应用

在 Android 4.3 及更低版本中,如果您想让应用从其他应用中检索文件,则该应用必须调用 ACTION_PICKACTION_GET_CONTENT 等 Intent。然后,用户必须选择一个要从中选取文件的应用,并且所选应用必须提供用户界面,以便用户浏览和选取可用文件。

在 Android 4.4 及更高版本中,您还可选择使用 ACTION_OPEN_DOCUMENT Intent,此 Intent 会显示由系统控制的选择器界面,以便用户浏览其他应用提供的所有文件。借助此界面,用户便可从任何受支持的应用中选取文件。

ACTION_OPEN_DOCUMENT 并非用于代替 ACTION_GET_CONTENT。您应根据应用需求选择所使用的 Intent:

  • 如果您只想让应用读取/导入数据,请使用 ACTION_GET_CONTENT。使用此方法时,应用会导入数据(如图片文件)的副本。
  • 如果您想让应用获得对文档提供程序所拥有文档的长期、持续性访问权限,请使用 ACTION_OPEN_DOCUMENT。例如,照片编辑应用可让用户编辑存储在文档提供程序中的图像。

本部分描述了如何编写基于 ACTION_OPEN_DOCUMENTACTION_CREATE_DOCUMENT Intent 的客户端应用。

以下代码段使用 ACTION_OPEN_DOCUMENT 来搜索包含图片文件的文档提供程序:

Kotlin

private const val READ_REQUEST_CODE: Int = 42
...
/**
 * Fires an intent to spin up the "file chooser" UI and select an image.
 */
fun performFileSearch() {

    // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's file
    // browser.
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
        // Filter to only show results that can be "opened", such as a
        // file (as opposed to a list of contacts or timezones)
        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 "*/*".
        type = "image/*"
    }

    startActivityForResult(intent, READ_REQUEST_CODE)
}

Java

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 Intent 时,该 Intent 会启动选择器,以显示所有匹配的文档提供程序。
  • 在 Intent 中添加 CATEGORY_OPENABLE 类别可对结果进行过滤,从而只显示可打开的文档(如图片文件)。
  • intent.setType("image/*") 语句可做进一步过滤,从而只显示 MIME 数据类型为图像的文档。

处理结果

当用户在选择器中选择文档后,系统会调用 onActivityResult()resultData 参数包含指向所选文档的 URI。您可以使用 getData() 提取该 URI。获得 URI 后,您可以用它来检索用户所需文档。例如:

Kotlin

override fun onActivityResult(requestCode: Int, resultCode: Int, resultData: Intent?) {

    // 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().
        resultData?.data?.also { uri ->
            Log.i(TAG, "Uri: $uri")
            showImage(uri)
        }
    }
}

Java

@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 所指定文档的元数据,并将其记入日志:

Kotlin

fun 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.
    val cursor: Cursor? = contentResolver.query( uri, null, null, null, null, null)

    cursor?.use {
        // moveToFirst() returns false if the cursor has 0 rows.  Very handy for
        // "if there's anything to look at, look at it" conditionals.
        if (it.moveToFirst()) {

            // Note it's called "Display Name".  This is
            // provider-specific, and might not necessarily be the file name.
            val displayName: String =
                    it.getString(it.getColumnIndex(OpenableColumns.DISPLAY_NAME))
            Log.i(TAG, "Display Name: $displayName")

            val sizeIndex: Int = it.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.
            val size: String = if (!it.isNull(sizeIndex)) {
                // Technically the column stores an int, but cursor.getString()
                // will do the conversion automatically.
                it.getString(sizeIndex)
            } else {
                "Unknown"
            }
            Log.i(TAG, "Size: $size")
        }
    }
}

Java

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

Kotlin

@Throws(IOException::class)
private fun getBitmapFromUri(uri: Uri): Bitmap {
    val parcelFileDescriptor: ParcelFileDescriptor = contentResolver.openFileDescriptor(uri, "r")
    val fileDescriptor: FileDescriptor = parcelFileDescriptor.fileDescriptor
    val image: Bitmap = BitmapFactory.decodeFileDescriptor(fileDescriptor)
    parcelFileDescriptor.close()
    return image
}

Java

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;
}

请注意,您不应在界面线程上执行此操作。请使用 AsyncTask 在后台执行此操作。打开位图后,您可以在 ImageView 中显示该位图。

获取 InputStream

以下示例展示了如何从 URI 中获取 InputStream。在此代码段中,系统会将文件行读取到字符串中:

Kotlin

@Throws(IOException::class)
private fun readTextFromUri(uri: Uri): String {
    val stringBuilder = StringBuilder()
    contentResolver.openInputStream(uri)?.use { inputStream ->
        BufferedReader(InputStreamReader(inputStream)).use { reader ->
            var line: String? = reader.readLine()
            while (line != null) {
                stringBuilder.append(line)
                line = reader.readLine()
            }
        }
    }
    return stringBuilder.toString()
}

Java

private String readTextFromUri(Uri uri) throws IOException {
    StringBuilder stringBuilder = new StringBuilder();
    try (InputStream inputStream =
            getContentResolver().openInputStream(uri);
            BufferedReader reader = new BufferedReader(
            new InputStreamReader(Objects.requireNonNull(inputStream)))) {
        String line;
        while ((line = reader.readLine()) != null) {
            stringBuilder.append(line);
        }
    }
    return stringBuilder.toString();
}

创建文档

您的应用可通过使用 ACTION_CREATE_DOCUMENT Intent,在文档提供程序中创建新文档。如要创建文件,请为您的 Intent 提供 MIME 类型和文件名,然后使用唯一的请求代码启动该 Intent。系统会为您执行其余操作:

Kotlin

// 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 const val WRITE_REQUEST_CODE: Int = 43
...
private fun createFile(mimeType: String, fileName: String) {
    val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
        // Filter to only show results that can be "opened", such as
        // a file (as opposed to a list of contacts or timezones).
        addCategory(Intent.CATEGORY_OPENABLE)

        // Create a file with the requested MIME type.
        type = mimeType
        putExtra(Intent.EXTRA_TITLE, fileName)
    }

    startActivityForResult(intent, WRITE_REQUEST_CODE)
}

Java

// 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,则便可删除该文档。例如:

Kotlin

DocumentsContract.deleteDocument(contentResolver, uri)

Java

DocumentsContract.deleteDocument(getContentResolver(), uri);

编辑文档

您可以随时使用 SAF 编辑文本文档。以下代码段会触发 ACTION_OPEN_DOCUMENT Intent 并使用 CATEGORY_OPENABLE 类别,从而只显示可打开的文档。它会进一步过滤,从而只显示文本文件:

Kotlin

private const val EDIT_REQUEST_CODE: Int = 44
/**
 * Open a file for writing and append some text to it.
 */
private fun editDocument() {
    // ACTION_OPEN_DOCUMENT is the intent to choose a file via the system's
    // file browser.
    val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
        // Filter to only show results that can be "opened", such as a
        // file (as opposed to a list of contacts or timezones).
        addCategory(Intent.CATEGORY_OPENABLE)

        // Filter to show only text files.
        type = "text/plain"
    }

    startActivityForResult(intent, EDIT_REQUEST_CODE)
}

Java

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。其默认使用写入模式。最佳做法是请求获得最少的所需访问权限,因此如果您只需要写入权限,请勿请求获得读取/写入权限:

Kotlin

private fun alterDocument(uri: Uri) {
    try {
        contentResolver.openFileDescriptor(uri, "w")?.use {
            // use{} lets the document provider know you're done by automatically closing the stream
            FileOutputStream(it.fileDescriptor).use {
                it.write(
                    ("Overwritten by MyCloud at ${System.currentTimeMillis()}\n").toByteArray()
                )
            }
        }
    } catch (e: FileNotFoundException) {
        e.printStackTrace()
    } catch (e: IOException) {
        e.printStackTrace()
    }
}

Java

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 持久授权。如此一来,用户便可通过您的应用持续访问文件,即使设备已重启也不受影响:

Kotlin

val takeFlags: Int = intent.flags and
        (Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
// Check for the freshest data.
contentResolver.takePersistableUriPermission(uri, takeFlags)

Java

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(),以检查有无最新数据。

打开虚拟文件

Android 7.0 在存储访问框架中加入了虚拟文件的概念。即使虚拟文件没有二进制表示形式,客户端应用也可将其强制转换为其他文件类型,或使用 ACTION_VIEW Intent 查看这些文件,从而打开文件中的内容。

如要打开虚拟文件,您的客户端应用需包含可处理此类文件的特殊逻辑。若想获取文件的字节表示形式(例如为了预览文件),则需从文档提供程序请求另一种 MIME 类型。

为获得应用中虚拟文件的 URI,您首先需创建 Intent 来打开文件选择器界面(如先前搜索文档中的代码所示)。

当用户做出选择后,系统会调用 onActivityResult() 方法(如先前处理结果中所示)。您的应用可以检索文件的 URI,然后使用与以下代码段类似的方法,确定文件是否为虚拟文件。

Kotlin

private fun isVirtualFile(uri: Uri): Boolean {
    if (!DocumentsContract.isDocumentUri(this, uri)) {
        return false
    }

    val cursor: Cursor? = contentResolver.query(
            uri,
            arrayOf(DocumentsContract.Document.COLUMN_FLAGS),
            null,
            null,
            null
    )

    val flags: Int = cursor?.use {
        if (cursor.moveToFirst()) {
            cursor.getInt(0)
        } else {
            0
        }
    } ?: 0

    return flags and DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT != 0
}

Java

private boolean isVirtualFile(Uri uri) {
    if (!DocumentsContract.isDocumentUri(this, uri)) {
        return false;
    }

    Cursor cursor = getContentResolver().query(
        uri,
        new String[] { DocumentsContract.Document.COLUMN_FLAGS },
        null, null, null);

    int flags = 0;
    if (cursor.moveToFirst()) {
        flags = cursor.getInt(0);
    }
    cursor.close();

    return (flags & DocumentsContract.Document.FLAG_VIRTUAL_DOCUMENT) != 0;
}

在验证文件为虚拟文件后,您可以将其强制转换为另一种 MIME 类型,如图片文件。以下代码段展示如何检查虚拟文件能否表示为图像,如果是,则从虚拟文件获取输入流。

Kotlin

@Throws(IOException::class)
private fun getInputStreamForVirtualFile(uri: Uri, mimeTypeFilter: String): InputStream {

    val openableMimeTypes: Array<String>? = contentResolver.getStreamTypes(uri, mimeTypeFilter)

    return if (openableMimeTypes?.isNotEmpty() == true) {
        contentResolver
                .openTypedAssetFileDescriptor(uri, openableMimeTypes[0], null)
                .createInputStream()
    } else {
        throw FileNotFoundException()
    }
}

Java


private InputStream getInputStreamForVirtualFile(Uri uri, String mimeTypeFilter)
    throws IOException {

    ContentResolver resolver = getContentResolver();

    String[] openableMimeTypes = resolver.getStreamTypes(uri, mimeTypeFilter);

    if (openableMimeTypes == null ||
        openableMimeTypes.length &lt; 1) {
        throw new FileNotFoundException();
    }

    return resolver
        .openTypedAssetFileDescriptor(uri, openableMimeTypes[0], null)
        .createInputStream();
}

如需详细了解虚拟文件以及如何在您的存储访问框架客户端应用中处理此类文件,请观看视频存储访问框架中的虚拟文件

如需查看与此页面相关的示例代码,请参阅:

如需观看与此页面相关的视频,请参阅:

如需了解更多相关信息,请参阅: