11

我正在为 Dropbox 编写 DocumentsProvider。我正在尝试遵循创建自定义提供程序的Google 指南,以及 Ian Lake在 Medium 上的帖子

我试图在存储访问框架中加入这一特性,从而表明有更多的数据要加载。

我的 queryChildDocuments() 方法的相关部分如下所示:

@Override
public Cursor queryChildDocuments(final String parentDocumentId,
                                  final String[] projection,
                                  final String sortOrder)  {

    if (selfPermissionsFailed(getContext())) {
        // Permissions have changed, abort!
        return null;
    }

    // Create a cursor with either the requested fields, or the default projection if "projection" is null.
    final MatrixCursor cursor = new MatrixCursor(projection != null ? projection : getDefaultDocumentProjection()){
        // Indicate we will be batch loading
        @Override
        public Bundle getExtras() {
            Bundle bundle = new Bundle();
            bundle.putBoolean(DocumentsContract.EXTRA_LOADING, true);
            bundle.putString(DocumentsContract.EXTRA_INFO, getContext().getResources().getString(R.string.requesting_data));
            return bundle;
            }

        };

        ListFolderResult result = null;
        DbxClientV2 mDbxClient = DropboxClientFactory.getClient();

        result = mDbxClient.files().listFolderBuilder(parentDocumentId).start();

        if (result.getEntries().size() == 0) {
            // Nothing in the dropbox folder
            Log.d(TAG, "addRowsToQueryChildDocumentsCursor called mDbxClient.files().listFolder() but nothing was there!");
            return;
        }

        // Setup notification so cursor will continue to build
        cursor.setNotificationUri(getContext().getContentResolver(),
                                  getChildDocumentsUri(parentDocumentId));

        while (true) {

            // Load the entries and notify listener
            for (Metadata metadata : result.getEntries()) {

                if (metadata instanceof FolderMetadata) {
                    includeFolder(cursor, (FolderMetadata) metadata);

                } else if (metadata instanceof FileMetadata) {
                    includeFile(cursor, (FileMetadata) metadata);
                }

            }

            // Notify for this batch
getContext().getContentResolver().notifyChange(getChildDocumentsUri(parentDocumentId), null);

            // See if we are ready to exit
            if (!result.getHasMore()) {
                break;
            }
            result = mDbxClient.files().listFolderContinue(result.getCursor());
        }

这一切都很好。我得到了我所期望的加载数据的游标。我得到的“免费”(可能是由于附加捆绑包)是 SAF 自动在屏幕顶部放置一个视觉效果,用于向用户发送文本(“请求数据”)和动画栏(在我的Samsung Galaxy S7 running API 27) 来回移动以指示光标正在加载:

“加载”栏和文本的屏幕截图

我的问题是 - 一旦我退出 fetch 循环并完成加载,我如何以编程方式摆脱屏幕顶部的 EXTRA_INFO 文本和 EXTRA_LOADING 动画?我已经搜索了 API,但没有看到任何看起来像“信号”的东西来告诉 SAF 加载已完成。

android 文档没有太多讨论这个功能,Ian 的 Medium 帖子只是简单地提到发送通知,以便光标知道刷新自己。关于动画也没有什么可说的。

4

1 回答 1

6

我通过查看 com.android.documentsui 中的代码以及 AOSP 的其他区域来了解如何调用和使用自定义 DocumentsProvider 来回答这个问题:

  1. 当目录的内容显示在 Picker 中时,它是通过 DirectoryFragment 实例完成的。
  2. DirectoryFragment 最终管理 DirectoryLoader 的一个实例。
  3. DirectoryLoader 异步调用 DocumentsProvider 来填充 Cursor,该 Cursor 包装在 DirectoryResult 实例中并传递给 Model 实例,该实例是 DirectoryFragment 中 RecyclerView 的底层数据存储。重要的是,加载器在完成时会挂起对此光标的引用——当我们需要通知加载器进行另一次加载时,这将发挥作用。
  4. Model 接收 DirectoryResult,使用附带的 Cursor 填充其数据结构,并通过查询 Cursor 的 getExtras() 中的 EXTRA_LOADING 键来更新“isLoading”的状态。然后它通知同样由 DirectoryFragment 管理的侦听器数据已更新。
  5. DirectoryFragment 通过此侦听器检查模型是否指示 EXTRA_LOADING 设置为 TRUE,如果是则显示进度条,否则将删除它。然后它在与 RecyclerView 关联的适配器上执行 notifyDataSetChanged()。

我们解决方案的关键是,在模型通过加载器的返回更新自身之后,进度条的显示/删除发生了。

此外,当要求 Model 实例更新自身时,它会完全清除先前的数据并迭代当前游标以再次填充自身。这意味着我们的“第二次获取”应该在所有数据都被检索到之后才进行,并且它需要包含完整的数据集,而不仅仅是“第二次获取”。

最后 - DirectoryLoader 实质上仅在从 queryChildDocuments() 返回 Cursor 之后将带有 Cursor 的内部类注册为ContentObserver 。

因此,我们的解决方案变为:

在 DocumentsProvider.queryChildDocuments() 中,确定是否可以一次性满足完整的结果集。

如果可以,那么只需加载并返回光标,我们就完成了。

如果不能,那么:

  1. 确保初始加载的 Cursor 的 getExtras() 将为 EXTRA_LOADING 键返回 TRUE

  2. 收集第一批数据并用它加载游标,并利用内部缓存保存这些数据以供下一次查询(更多原因见下文)。我们将在下一步之后返回这个 Cursor,因为 EXTRA_LOADING 为真,所以会出现进度条。

  3. 现在是棘手的部分。queryChildDocuments() 的 JavaDoc 说:

如果您的提供商是基于云的,并且您在本地缓存或固定了一些数据,您可以立即返回本地数据,在光标上设置 DocumentsContract.EXTRA_LOADING 以指示您仍在获取其他数据。然后,当网络数据可用时,您可以发送更改通知以触发重新查询并返回完整的内容。

  1. 问题是这个通知是从哪里来的,什么时候来的?此时,我们深入到我们的 Provider 代码中,使用初始加载请求填充 Cursor。Provider 对 Loader 一无所知 - 它只是响应 queryChildDocuments() 调用。在这一点上,加载器对光标一无所知——它只是在系统中执行一个查询(),最终调用我们的提供者。一旦我们将 Cursor 返回到 Loader,在没有某种外部事件(例如用户单击文件或目录)的情况下,就不会再调用 Provider 了。从 DirectoryLoader:
 if (mFeatures.isContentPagingEnabled()) {
     Bundle queryArgs = new Bundle();
     mModel.addQuerySortArgs(queryArgs);

     // TODO: At some point we don't want forced flags to override real paging...
     // and that point is when we have real paging.
     DebugFlags.addForcedPagingArgs(queryArgs);

     cursor = client.query(mUri, null, queryArgs, mSignal);
 } else {
     cursor = client.query(
               mUri, null, null, null, mModel.getDocumentSortQuery(), mSignal);
 }

 if (cursor == null) {
     throw new RemoteException("Provider returned null");
 }

 cursor.registerContentObserver(mObserver);
  1. client.query() 在最终调用我们的 Provider 的类上完成。请注意,在上面的代码中,在 Cursor 返回后,Loader 使用“mObserver”将自己注册为 Cursor 作为 ContentObserver。mObserver 是 Loader 中的内部类的一个实例,当通知内容更改时将导致 loader 再次重新查询。

  2. 因此,我们需要采取两个步骤。首先是因为 Loader 不会破坏它从初始 query() 接收到的游标,在对 queryChildDocuments() 的初始调用期间,提供者需要使用 Cursor.setNotificationUri() 方法向 ContentResolver 注册游标并传递一个 Uri表示当前子目录(传入 queryChildDocuments() 的 parentDocumentId):

    cursor.setNotificationUri(getContext().getContentResolver(), DocumentsContract.buildChildDocumentsUri(, parentDocumentId));

  3. 然后再次启动加载程序以收集其余数据,生成一个单独的线程来执行一个循环,该循环a)获取数据,b)将其连接到用于填充第一个查询中游标的缓存结果(这就是为什么我说在步骤 2) 中保存它,并且 c) 通知光标数据已更改。

  4. 从初始查询返回光标。由于 EXTRA_LOADING 设置为 true,因此会出现进度条。

  5. 由于 Loader 注册了自己在内容更改时收到通知,当通过步骤 7 在 Provider 中生成的 Thread 完成获取时,它需要使用与步骤 (6) 中在 Cursor 上注册的相同 Uri 值调用 Resolver 上的 notifyChange() ):

    getContext().getContentResolver().notifyChange(DocumentsContract.buildChildDocumentsUri(, parentDocumentId), null);

  6. Cursor 从 Resolver 接收通知,然后通知 Loader 使其重新查询。这一次,当 Loader 查询我的 Provider 时,Provider 指出这是一个重新查询,并用缓存中的当前集合填充游标。它还必须注意线程在获取缓存的当前快照时是否仍在运行 - 如果是,它会设置 getExtras() 以指示仍在加载。如果不是,它将设置 GetExtras() 以指示未发生加载,以便删除进度条。

  7. Thread获取数据后,数据集会加载到Model中,RecyclerView会刷新。当线程在最后一次批量获取后终止时,进度条将被删除。

我在此过程中学到的一些重要技巧:

  1. 在调用 queryChildDocuments() 时,提供者必须决定是否可以一次获取所有条目,并适当调整 Cursor.getExtras() 的结果。文档建议是这样的:
MatrixCursor result = new MatrixCursor(projection != null ?
  projection : DEFAULT_DOCUMENT_PROJECTION) {
    @Override
    public Bundle getExtras() {
      Bundle bundle = new Bundle();
      bundle.putBoolean(DocumentsContract.EXTRA_LOADING, true);
      return bundle;
    }
  };

如果您在创建光标时知道是否一次性获取所有内容,这很好。

相反,如果您需要创建光标,填充它,然后在需要不同的模式之后进行调整,类似的东西:

private final Bundle b = new Bundle()
MatrixCursor result = new MatrixCursor(projection != null ?
  projection : DEFAULT_DOCUMENT_PROJECTION) {
    @Override
    public Bundle getExtras() {
      return b;
    }
  };

然后稍后你可以这样做:

result.getExtras().putBoolean(DocumentsContract.EXTRA_LOADING, true);

  1. 如果您需要像上面的示例一样修改从 getExtras() 返回的 Bundle,您必须编写 getExtras() 以使其返回可以像上面示例中那样更新的内容。如果不这样做,您将无法修改默认情况下从 getExtras() 返回的 Bundle 实例。这是因为默认情况下,getExtras() 将返回 Bundle.EMPTY 的实例,该实例本身由 ArrayMap.EMPTY 支持,ArrayMap 类以使 ArrayMap 不可变的方式定义该实例,因此如果你尝试改变它。

  2. 我认识到在我启动填充其余内容的线程和我将初始光标返回给加载器之间有一个非常小的时间窗口。理论上,线程有可能在加载器向光标注册自己之前完成。如果发生这种情况,那么即使线程将更改通知解析器,由于游标尚未注册为侦听器,它不会收到消息,加载器也不会再次启动。知道一种确保不会发生这种情况的方法会很好,但除了将线程延迟 250 毫秒或其他东西之外,我还没有研究过。

  3. 另一个问题是当用户导航离开当前目录而获取进度仍在发生时处理这种情况。这可以通过 Provider 来检查,每次都跟踪传递给 queryChildDocuments() 的 parentDocumentId - 当它们相同时,它是一个重新查询。当不同时,它是一个新查询。在新查询中,如果线程处于活动状态,我们将取消该线程并清除缓存,然后处理该查询。

  4. 另一个需要处理的问题是对同一目录的重新查询可能有多个来源。第一种是线程在完成获取目录条目后通过 Uri 通知触发它。其他情况是当请求加载程序刷新时,这可能会以几种方式发生(例如,用户在屏幕上向下滑动)。要检查的关键是是否为同一目录调用了 queryChildDocuments() 并且线程尚未完成,那么我们已经收到从某种刷新中重新加载的请求 - 我们通过对光标执行同步加载来尊重这一点从缓存的当前状态,但期望我们会在线程完成时再次被调用。

  5. 在我的测试中,从来没有同时调用同一个 Provider 的时候——当用户浏览目录时,一次只请求一个目录。因此我们可以用一个线程来满足我们的“批量获取”,当我们检测到一个新的目录被请求时(例如用户从一个加载时间过长的目录移开),我们可以取消线程并启动根据需要在新目录中创建它的新实例。

我正在发布我的代码的相关部分以展示我是如何做到的,并附上一些注释:

  1. 我的应用程序支持多种 Provider 类型,因此我创建了一个抽象类“AbstractStorageProvider”,它扩展了 DocumentsProvider,以封装 Provider 从系统获取的常见调用(如 queryRoots、queryChildDocuments 等)。这些又委托给每个服务的类我想支持(我自己的本地存储、Dropbox、Spotify、Instagram 等)来填充光标。我还在这里放置了一个标准方法来检查并确保用户没有在应用程序之外更改我的 Android 权限设置,这会导致抛出异常。
  2. 同步对内部缓存的访问至关重要,因为线程将在后台工作,在多个调用请求更多数据时填充缓存。
  3. 为了清楚起见,我发布了此代码的相对“基本”版本。生产代码中需要多个处理程序来处理网络故障、配置更改等。

我的抽象 Provider 类中的 queryChildDocuments() 方法调用了 createDocumentMatrixCursor() 方法,该方法可以根据 Provider 子类以不同方式实现:

    @Override
    public Cursor queryChildDocuments(final String parentDocumentId,
                                      final String[] projection,
                                      final String sortOrder)  {

        if (selfPermissionsFailed(getContext())) {
            return null;
        }
        Log.d(TAG, "queryChildDocuments called for: " + parentDocumentId + ", calling createDocumentMatrixCursor");

        // Create a cursor with either the requested fields, or the default projection if "projection" is null.
        final MatrixCursor cursor = createDocumentMatrixCursor(projection != null ? projection : getDefaultDocumentProjection(), parentDocumentId);

        addRowsToQueryChildDocumentsCursor(cursor, parentDocumentId, projection, sortOrder);

        return cursor;
}

还有我的 DropboxProvider 实现的 createDocumentMatrixCursor:

@Override
/**
 * Called to populate a sub-directory of the parent directory. This could be called multiple
 * times for the same directory if (a) the user swipes down on the screen to refresh it, or
 * (b) we previously started a BatchFetcher thread to gather data, and the BatchFetcher 
 * notified our Resolver (which then notifies the Cursor, which then kicks the Loader).
 */
protected MatrixCursor createDocumentMatrixCursor(String[] projection, final String parentDocumentId) {
    MatrixCursor cursor = null;
    final Bundle b = new Bundle();
    cursor = new MatrixCursor(projection != null ? projection : getDefaultDocumentProjection()){
        @Override
        public Bundle getExtras() {
            return b;
        }
    };
    Log.d(TAG, "Creating Document MatrixCursor" );
    if ( !(parentDocumentId.equals(oldParentDocumentId)) ) {
        // Query in new sub-directory requested
        Log.d(TAG, "New query detected for sub-directory with Id: " + parentDocumentId + " old Id was: " + oldParentDocumentId );
        oldParentDocumentId = parentDocumentId;
        // Make sure prior thread is cancelled if it was started
        cancelBatchFetcher();
        // Clear the cache
        metadataCache.clear();

    } else {
        Log.d(TAG, "Requery detected for sub-directory with Id: " + parentDocumentId );
    }
    return cursor;
}

addrowsToQueryChildDocumentsCursor() 方法是我的抽象 Provider 类在调用它的 queryChildDocuments() 方法时调用的方法,它是子类实现的方法,也是批量获取大型目录内容的所有魔法。例如,我的 Dropbox 提供程序子类利用 Dropbox API 来获取它需要的数据,如下所示:

protected void addRowsToQueryChildDocumentsCursor(MatrixCursor cursor,
                                                  final String parentDocumentId,
                                                  String[] projection,
                                                  String sortOrder)  {

    Log.d(TAG, "addRowstoQueryChildDocumentsCursor called for: " + parentDocumentId);

    try {

        if ( DropboxClientFactory.needsInit()) {
            Log.d(TAG, "In addRowsToQueryChildDocumentsCursor, initializing DropboxClientFactory");
            DropboxClientFactory.init(accessToken);
        }
        final ListFolderResult dropBoxQueryResult;
        DbxClientV2 mDbxClient = DropboxClientFactory.getClient();

        if ( isReQuery() ) {
            // We are querying again on the same sub-directory.
            //
            // Call method to populate the cursor with the current status of
            // the pre-loaded data structure. This method will also clear the cache if
            // the thread is done.
            boolean fetcherIsLoading = false;
            synchronized(this) {
                populateResultsToCursor(metadataCache, cursor);
                fetcherIsLoading = fetcherIsLoading();
            }
            if (!fetcherIsLoading) {
                Log.d(TAG, "I believe batchFetcher is no longer loading any data, so clearing the cache");
                // We are here because of the notification from the fetcher, so we are done with
                // this cache.
                metadataCache.clear();
                clearCursorLoadingNotification(cursor);
            } else {
                Log.d(TAG, "I believe batchFetcher is still loading data, so leaving the cache alone.");
                // Indicate we are still loading and bump the loader.
                setCursorForLoadingNotification(cursor, parentDocumentId);
            }

        } else {
            // New query
            if (parentDocumentId.equals(accessToken)) {
                // We are at the Dropbox root
                dropBoxQueryResult = mDbxClient.files().listFolderBuilder("").withLimit(batchSize).start();
            } else {
                dropBoxQueryResult = mDbxClient.files().listFolderBuilder(parentDocumentId).withLimit(batchSize).start();
            }
            Log.d(TAG, "New query fetch got " + dropBoxQueryResult.getEntries().size() + " entries.");

            if (dropBoxQueryResult.getEntries().size() == 0) {
                // Nothing in the dropbox folder
                Log.d(TAG, "I called mDbxClient.files().listFolder() but nothing was there!");
                return;
            }

            // See if we are ready to exit
            if (!dropBoxQueryResult.getHasMore()) {
                // Store our results to the query
                populateResultsToCursor(dropBoxQueryResult.getEntries(), cursor);
                Log.d(TAG, "First fetch got all entries so I'm clearing the cache");
                metadataCache.clear();
                clearCursorLoadingNotification(cursor);
                Log.d(TAG, "Directory retrieval is complete for parentDocumentId: " + parentDocumentId);
            } else {
                // Store our results to both the cache and cursor - cursor for the initial return,
                // cache for when we come back after the Thread finishes
                Log.d(TAG, "Fetched a batch and need to load more for parentDocumentId: " + parentDocumentId);
                populateResultsToCacheAndCursor(dropBoxQueryResult.getEntries(), cursor);

                // Set the getExtras()
                setCursorForLoadingNotification(cursor, parentDocumentId);

                // Register this cursor with the Resolver to get notified by Thread so Cursor will then notify loader to re-load
                Log.d(TAG, "registering cursor for notificationUri on: " + getChildDocumentsUri(parentDocumentId).toString() + " and starting BatchFetcher");
                cursor.setNotificationUri(getContext().getContentResolver(),getChildDocumentsUri(parentDocumentId));
                // Start new thread
                batchFetcher = new BatchFetcher(parentDocumentId, dropBoxQueryResult);
                batchFetcher.start();
            }
        }

    } catch (Exception e) {
        Log.d(TAG, "In addRowsToQueryChildDocumentsCursor got exception, message was: " + e.getMessage());
    }

线程(“BatchFetcher”)处理填充缓存,并在每次提取后通知解析器:

private class BatchFetcher extends Thread {
    String mParentDocumentId;
    ListFolderResult mListFolderResult;
    boolean keepFetchin = true;

    BatchFetcher(String parentDocumentId, ListFolderResult listFolderResult) {
        mParentDocumentId = parentDocumentId;
        mListFolderResult = listFolderResult;
    }

    @Override
    public void interrupt() {
        keepFetchin = false;
        super.interrupt();
    }

    public void run() {
        Log.d(TAG, "Starting run() method of BatchFetcher");
        DbxClientV2 mDbxClient = DropboxClientFactory.getClient();
        try {
            mListFolderResult = mDbxClient.files().listFolderContinue(mListFolderResult.getCursor());
            // Double check
            if ( mListFolderResult.getEntries().size() == 0) {
                // Still need to notify so that Loader will cause progress bar to be removed
                getContext().getContentResolver().notifyChange(getChildDocumentsUri(mParentDocumentId), null);
                return;
            }
            while (keepFetchin) {

                populateResultsToCache(mListFolderResult.getEntries());

                if (!mListFolderResult.getHasMore()) {
                    keepFetchin = false;
                } else {
                    mListFolderResult = mDbxClient.files().listFolderContinue(mListFolderResult.getCursor());
                    // Double check
                    if ( mListFolderResult.getEntries().size() == 0) {
                        // Still need to notify so that Loader will cause progress bar to be removed
                        getContext().getContentResolver().notifyChange(getChildDocumentsUri(mParentDocumentId), null);
                        return;
                    }
                }
                // Notify Resolver of change in data, it will contact cursor which will restart loader which will load from cache.
                Log.d(TAG, "BatchFetcher calling contentResolver to notify a change using notificationUri of: " + getChildDocumentsUri(mParentDocumentId).toString());
                getContext().getContentResolver().notifyChange(getChildDocumentsUri(mParentDocumentId), null);
            }
            Log.d(TAG, "Ending run() method of BatchFetcher");
            //TODO - need to have this return "bites" of data so text can be updated.

        } catch (DbxException e) {
            Log.d(TAG, "In BatchFetcher for parentDocumentId: " + mParentDocumentId + " got error, message was; " + e.getMessage());
        }

    }

}
于 2018-08-09T14:47:58.787 回答