如何向Storage Access Framework指示不再需要加载动画?

时间:2018-08-02 23:00:11

标签: android frameworks storage storage-access-framework dropbox-js

我正在为Dropbox编写DocumentsProvider。我试图遵循Google guidelines来创建自定义提供程序,以及Ian Lake的post on Medium来创建自定义提供程序。

我正在尝试将该功能合并到Storage Access Framework中,从而表明有更多数据要加载。

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会自动在屏幕顶部放置一个视觉效果,以显示给用户的文本(​​“请求数据”)和动画条(位于我的运行API 27的Samsung Galaxy S7来回移动以指示光标正在加载:

screenshot of 'loading' bar and text

我的问题是-退出提取循环并完成加载后,如何以编程方式消除屏幕顶部的EXTRA_INFO文本和EXTRA_LOADING动画?我已经仔细检查过API,并且看不到任何看起来像“信号”的东西来告诉SAF加载已完成。

android文档对此功能的讨论不多,Ian的Medium帖子只是简短提及发送通知,因此光标知道自己会刷新。两者都无话可说。

1 个答案:

答案 0 :(得分:2)

基于对com.android.documentsui以及AOSP其他区域中的代码的审查,我对这个问题有一个答案,以了解如何调用和使用自定义DocumentsProvider:

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

我们解决方案的关键是,进度条的显示/删除是在之后进行的,是根据加载程序的返回来更新模型本身。

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

最后-仅在从queryChildDocuments()返回了游标之后,DirectoryLoader才将游标内部类注册为ContentObserver

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

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

如果可以的话,只需加载并返回Cursor就可以了。

如果不能,则:

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

  2. 收集第一批数据并为其加载游标,并利用内部缓存为下次查询保存该数据(下面会详细说明)。下一步后,我们将返回此Cursor,并且由于EXTRA_LOADING为true,因此将显示进度条。

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

  

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

  1. 问题是此通知何时何地发出?至此,我们已经深入到了提供者代码中,并使用初始加载请求填充了Cursor。提供程序对加载程序一无所知-它只是响应queryChildDocuments()调用。至此,Loader对Cursor一无所知-它只是对系统执行query(),最终调用了我们的Provider。一旦将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()在最终调用我们的提供程序的类上完成。请注意,在上面的代码中,在返回游标之后,加载程序立即使用“ mObserver”将游标注册为ContentObserver。 mObserver是Loader中一个内部类的实例,当收到内容更改通知时,它将导致该Loader再次重新查询。

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

      

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

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

  4. 从初始查询返回游标。由于EXTRA_LOADING设置为true,因此将显示进度条。

  5. 由于加载程序已注册自己,以便在内容更改时收到通知,因此当通过步骤7在提供程序中生成的线程完成获取操作时,它需要使用与注册时相同的Uri值在解析器上调用notifyChange()。步骤(6)中的游标:

      

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

  6. Cursor接收来自解析程序的通知,然后通知加载程序,使其重新查询。这次,当加载程序查询我的提供程序时,提供程序会指出它是重新查询,并使用缓存中的当前内容填充游标。它还必须注意线程在获取缓存的当前快照时是否仍在运行-如果是这样,它将设置getExtras()来指示加载仍在进行。如果没有,它将设置GetExtras()来指示没有加载,以便删除进度条。

  7. 线程读取数据后,数据集将加载到模型中,并且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.EMPTY定义了ArrayMap类以使ArrayMap不可变的方式,因此,如果您遇到了运行时异常尝试更改它。

  2. 我认识到,从启动填充其余内容的线程到将初始Cursor返回给Loader的时间之间只有很小的时间窗口。从理论上讲,线程有可能在装载程序向游标注册之前完成。如果发生这种情况,那么即使线程将更改通知了Resolver,由于Cursor尚未注册为侦听器,它也不会收到消息,并且Loader也不会再次启动。 最好知道一种方法,以确保不会发生这种情况,但是除了诸如将线程延迟250ms之类的东西之外,我没有对此进行研究。

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

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

  5. 在我的测试中,从来没有一次并行调用同一提供程序的-当用户浏览目录时,一次只请求一个目录。因此,我们可以用一个线程来满足“批量获取”的要求,并且当我们检测到请求了新目录(例如,用户离开了加载时间太长的目录)时,我们可以取消线程并开始根据需要在新目录中添加它的新实例。

我正在发布代码的相关部分以显示我是如何做到的,并有一些注意事项:

  1. 我的应用程序支持多种提供程序类型,因此我创建了一个抽象类“ AbstractStorageProvider”,该类扩展了DocumentsProvider,以便封装提供程序从系统获取的常见调用(例如queryRoots,queryChildDocuments等),这些依次又委托给类对于我要支持的每项服务(我自己用于本地存储,Dropbox,Spotify,Instagram等)填充光标。我还在这里放置了一种标准方法来检查并确保用户未在应用程序外部更改我对我的Android权限设置,这将引发Exception。
  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;
}

以及我的createDocumentMatrixCursor的DropboxProvider实现:

@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()方法是我的抽象提供程序类的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());
        }

    }

}