documentsUI源码分析

时间:2021-02-22 06:11:06

documentsUI源码分析

本文基于Android 6.0的源码,来分析documentsUI模块。

原本基于7.1源码看了两天,但是Android 7.1与6.0中documentsUI模块差异很大,且更加复杂,因此重新基于6.0的源码分析。

documentsUI是什么?

documentsUI是Android系统提供的一个文件选择器,类似于Windows系统中点击“打开”按钮弹出的文件选择框,有人称documentsUI为文件管理器,这是不准确的。

documentsUI是Android系统中存储访问框架(Storage Access Framework,SAF)的一部分。

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

documentsUI的清单文件中只有一个Activity,且没有带category.LAUNCHER的属性,因此Launcher桌面上并没有图标,但是进入documentsUI的入口很多,如桌面上的下载应用、短信中的添加附件、浏览器中上传图片等。

documentsUI清单文件中的activity如下:

<activity
android:name=".DocumentsActivity"
android:theme="@style/DocumentsTheme"
android:icon="@drawable/ic_doc_text">
<intent-==filter==>
<action android:name="android.intent.action.OPEN_DOCUMENT" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.CREATE_DOCUMENT" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter android:priority="100">
<action android:name="android.intent.action.GET_CONTENT" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.OPENABLE" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.OPEN_DOCUMENT_TREE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.provider.action.MANAGE_ROOT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.document/root" />
</intent-filter>
<intent-filter>
<action android:name="android.provider.action.BROWSE_DOCUMENT_ROOT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.document/root" />
</intent-filter>
</activity>

存储访问框架SAF

在介绍documentUI之前,需要介绍存储访问框架,在Android 4.4(API 级别 19),Google引入了存储访问框架 (SAF),让用户能够在其所有首选文档存储提供程序中方便地浏览并打开文档、图像以及其他文件。 用户可以通过易用的标准 UI,以统一方式在所有应用和提供程序中浏览文件和访问最近使用的文件。

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

SAF 包括以下内容:

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

控制流

文档提供程序数据模型基于传统文件层次结构。 通过DocumentsProvider API访问数据,可以按照自己喜好的任何方式存储数据。例如,可以使用基于标记的云存储来存储数据。

documentsUI源码分析

如上图所示,在 SAF 中,提供程序和客户端并不直接交互。

  • 客户端请求与文件交互(即读取、编辑、创建或删除文件)的权限;
  • 交互在应用(在本示例中为照片应用)触发 Intent ACTION_OPEN_DOCUMENT 或ACTION_CREATE_DOCUMENT 后开始。Intent 可能包括进一步细化条件的过滤器 — 例如,“为我提供所有 MIME 类型为‘图像’的可打开文件”;
  • Intent 触发后,系统选取器将检索每个已注册的提供程序,并向用户显示匹配的内容根目录;
  • 选取器会为用户提供一个标准的文档访问界面,但底层文档提供程序可能与其差异很大。 例如,图 2 显示了一个 Google Drive 提供程序、一个 USB 提供程序和一个云提供程序。

客户端应用

编写一个客户端应用,调用documentsUI选择文件。

    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.
intent.setType("image/*"); startActivityForResult(intent, READ_REQUEST_CODE);

通过Intent.ACTION_OPEN_DOCUMENT,documentsUI将响应该意图,选择文件后,在返回结果中提取URI,解析URI后进行相应操作。

内容提供程序

如需使得的自己的应用程序通过documentsUI向用户展示文件,可编写文档提供程序,通过 SAF 提供自己的文件。

首先要在清单文件中定义相应的provider和activity属性,然后创建继承DocumentsProvider的子类,并实现以下方法:queryRoots()、queryChildDocuments()、queryDocument()、openDocument()

关于存储访问框架的详细介绍可在Android开发者官网获取。

源码分析

documentsUI代码结构较为复杂,本文只分析大致流程。

1. 入口: DocumentsActivity

布局文件是DrawerLayout,左边是侧滑菜单,右边是内容显示

内容显示区域布局:

<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"> <com.android.documentsui.DocumentsToolBar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?android:attr/actionBarSize"
android:background="?android:attr/colorPrimary"
android:elevation="8dp"
android:theme="?android:attr/actionBarTheme"> <Spinner
android:id="@+id/stack"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:overlapAnchor="true" /> </com.android.documentsui.DocumentsToolBar> <com.android.documentsui.DirectoryContainerView
android:id="@+id/container_directory"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" /> <FrameLayout
android:id="@+id/container_save"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@color/material_grey_50"
android:elevation="8dp" /> </LinearLayout>

内容显示区域由一个自定义view DocumentsToolBarDirectoryContainerView组成。

侧滑菜单布局:

<LinearLayout
android:id="@+id/drawer_roots"
android:layout_width="256dp"
android:layout_height="match_parent"
android:layout_gravity="start"
android:orientation="vertical"
android:elevation="16dp"
android:background="@*android:color/white"> <Toolbar
android:id="@+id/roots_toolbar"
android:layout_width="match_parent"
android:layout_height="?android:attr/actionBarSize"
android:background="?android:attr/colorPrimary"
android:elevation="8dp"
android:theme="?android:attr/actionBarTheme" /> <FrameLayout
android:id="@+id/container_roots"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" /> </LinearLayout>

侧滑菜单由一个Toolbar和FrameLayout组成。

在 onCreate 方法中

    if (mState.action == ACTION_CREATE) {
final String mimeType = getIntent().getType();
final String title = getIntent().getStringExtra(Intent.EXTRA_TITLE);
SaveFragment.show(getFragmentManager(), mimeType, title);
} else if (mState.action == ACTION_OPEN_TREE ||
mState.action == ACTION_OPEN_COPY_DESTINATION) {
PickFragment.show(getFragmentManager());
} if (mState.action == ACTION_GET_CONTENT) {
final Intent moreApps = new Intent(getIntent());
moreApps.setComponent(null);
moreApps.setPackage(null);
RootsFragment.show(getFragmentManager(), moreApps);
} else if (mState.action == ACTION_OPEN ||
mState.action == ACTION_CREATE ||
mState.action == ACTION_OPEN_TREE ||
mState.action == ACTION_OPEN_COPY_DESTINATION) {
RootsFragment.show(getFragmentManager(), null);
}

mState保存状态信息,在buildDefaultState初始化,假设启动的action为ACTION_GET_CONTENT,那么将调用RootsFragmentshow方法。

2. RootsFragment

    public static void show(FragmentManager fm, Intent includeApps) {
final Bundle args = new Bundle();
args.putParcelable(EXTRA_INCLUDE_APPS, includeApps); final RootsFragment fragment = new RootsFragment();
fragment.setArguments(args); final FragmentTransaction ft = fm.beginTransaction();
ft.replace(R.id.container_roots, fragment);
ft.commitAllowingStateLoss();
}

show方法显示出RootsFragment自己,RootsFragment就是侧滑菜单部分,在 RootsFragmentonCreateView 方法中,加载出的view就是一个listview,如下图:

documentsUI源码分析

listview中显示的是能响应该打开文件Itent的文档提供者第三方应用,在 onActivityCreated方法中,使用Loard机制加载出listview要显示的数据

    mCallbacks = new LoaderCallbacks<Collection<RootInfo>>() {
@Override
public Loader<Collection<RootInfo>> onCreateLoader(int id, Bundle args) {
return new RootsLoader(context, roots, state);
} @Override
public void onLoadFinished(
Loader<Collection<RootInfo>> loader, Collection<RootInfo> result) {
if (!isAdded()) return; final Intent includeApps = getArguments().getParcelable(EXTRA_INCLUDE_APPS); mAdapter = new RootsAdapter(context, result, includeApps);
mList.setAdapter(mAdapter); onCurrentRootChanged();
} @Override
public void onLoaderReset(Loader<Collection<RootInfo>> loader) {
mAdapter = null;
mList.setAdapter(null);
}
};

onLoadFinished中实例化RootsAdapter

RootsAdapter

private static class RootsAdapter extends ArrayAdapter<Item> {
public RootsAdapter(Context context, Collection<RootInfo> roots, Intent includeApps) {
super(context, 0); RootItem recents = null;
RootItem images = null;
RootItem videos = null;
RootItem audio = null;
RootItem downloads = null; final List<RootInfo> clouds = Lists.newArrayList();
final List<RootInfo> locals = Lists.newArrayList(); for (RootInfo root : roots) {
if (root.isRecents()) {
recents = new RootItem(root);
} else if (root.isExternalStorage()) {
locals.add(root);
} else if (root.isDownloads()) {
downloads = new RootItem(root);
} else if (root.isImages()) {
images = new RootItem(root);
} else if (root.isVideos()) {
videos = new RootItem(root);
} else if (root.isAudio()) {
audio = new RootItem(root);
} else {
clouds.add(root);
}
} final RootComparator comp = new RootComparator();
Collections.sort(clouds, comp);
Collections.sort(locals, comp); if (recents != null) add(recents); for (RootInfo cloud : clouds) {
add(new RootItem(cloud));
} if (images != null) add(images);
if (videos != null) add(videos);
if (audio != null) add(audio);
if (downloads != null) add(downloads); for (RootInfo local : locals) {
add(new RootItem(local));
} if (includeApps != null) {
final PackageManager pm = context.getPackageManager();
final List<ResolveInfo> infos = pm.queryIntentActivities(
includeApps, PackageManager.MATCH_DEFAULT_ONLY); final List<AppItem> apps = Lists.newArrayList(); // Omit ourselves from the list
for (ResolveInfo info : infos) {
if (!context.getPackageName().equals(info.activityInfo.packageName)) {
apps.add(new AppItem(info));
}
} if (apps.size() > 0) {
add(new SpacerItem());
for (Item item : apps) {
add(item);
}
}
}
} @Override
public View getView(int position, View convertView, ViewGroup parent) {
final Item item = getItem(position);
return item.getView(convertView, parent);
} @Override
public boolean areAllItemsEnabled() {
return false;
} @Override
public boolean isEnabled(int position) {
return getItemViewType(position) != 1;
} @Override
public int getItemViewType(int position) {
final Item item = getItem(position);
if (item instanceof RootItem || item instanceof AppItem) {
return 0;
} else {
return 1;
}
} @Override
public int getViewTypeCount() {
return 2;
}
}

RootsAdapter中主要包含以下几点:

  • 实例化RootsAdapter时,解析传入的数据得到recentsimagesvideosaudiodownloadslocalsclouds,这些都可以在内容显示区展示文档
  • includeApps代表可以相应该Intent的第三方APP,获取这些APP的信息(如图标、名称等)显示在listview中
  • 根据getItemViewType判断不同类型item,显示其布局。listview中包含两种item,分别是RootItemAppItem,它们共同继承自Item
  • SpacerItem也是继承自Item类,它是一个分隔线,分隔RootItemAppItem

点击事件

侧滑菜单的listview设置了两个点击事件,普通点击事件和长按点击事件

private OnItemClickListener mItemListener = new OnItemClickListener() {
@Override
public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
Item item = mAdapter.getItem(position);
if (item instanceof RootItem) {
BaseActivity activity = BaseActivity.get(RootsFragment.this);
activity.onRootPicked(((RootItem) item).root);
} else if (item instanceof AppItem) {
DocumentsActivity activity = DocumentsActivity.get(RootsFragment.this);
activity.onAppPicked(((AppItem) item).info);
} else {
throw new IllegalStateException("Unknown root: " + item);
}
}
}; private OnItemLongClickListener mItemLongClickListener = new OnItemLongClickListener() {
@Override
public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {
final Item item = mAdapter.getItem(position);
if (item instanceof AppItem) {
showAppDetails(((AppItem) item).info);
return true;
} else {
return false;
}
}
};

长按点击事件只对AppItem有效,长按AppItem时跳转到对应APP的应用信息界面,点击AppItem时,启动documentsUI的intent交由相应APP处理。

当点击的是RootItem时,调用DocumentsActivityonRootPicked( )方法,该方法继承自BaseActivity

    void onRootPicked(RootInfo root) {
State state = getDisplayState(); // Clear entire backstack and start in new root
state.stack.root = root;
state.stack.clear();
state.stackTouched = true; mSearchManager.update(root); // Recents is always in memory, so we just load it directly.
// Otherwise we delegate loading data from disk to a task
// to ensure a responsive ui.
if (mRoots.isRecentsRoot(root)) {
onCurrentDirectoryChanged(ANIM_SIDE);
} else {
new PickRootTask(root).executeOnExecutor(getCurrentExecutor());
}
}

这里判断是否点击的是“最近”菜单,如果是则直接加载,如果不是则执行new PickRootTask(root).executeOnExecutor(getCurrentExecutor())加载相应item的内容,最后也是进入onCurrentDirectoryChanged

下面看一下onCurrentDirectoryChanged方法

final void onCurrentDirectoryChanged(int anim) {
onDirectoryChanged(anim); //更新文档内容显示 final RootsFragment roots = RootsFragment.get(getFragmentManager());
if (roots != null) {
roots.onCurrentRootChanged();//更新侧滑菜单点击状态
} updateActionBar();
invalidateOptionsMenu();
}

其中重点是onDirectoryChanged(anim)方法,这个方法是在BaseActivity类中定义的一个抽象方法

    abstract void onDirectoryChanged(int anim);

其具体实现在DocumentsActivity中:

@Override
void onDirectoryChanged(int anim) {
final FragmentManager fm = getFragmentManager();
final RootInfo root = getCurrentRoot();
final DocumentInfo cwd = getCurrentDirectory(); mDirectoryContainer.setDrawDisappearingFirst(anim == ANIM_DOWN); if (cwd == null) {
// No directory means recents
if (mState.action == ACTION_CREATE ||
mState.action == ACTION_OPEN_TREE ||
mState.action == ACTION_OPEN_COPY_DESTINATION) {
RecentsCreateFragment.show(fm);
} else {
DirectoryFragment.showRecentsOpen(fm, anim); // Start recents in grid when requesting visual things
final boolean visualMimes = MimePredicate.mimeMatches(
MimePredicate.VISUAL_MIMES, mState.acceptMimes);
mState.userMode = visualMimes ? State.MODE_GRID : State.MODE_LIST;
mState.derivedMode = mState.userMode;
}
} else {
if (mState.currentSearch != null) {
// Ongoing search
DirectoryFragment.showSearch(fm, root, mState.currentSearch, anim);
} else {
// Normal boring directory
DirectoryFragment.showNormal(fm, root, cwd, anim);
}
} // Forget any replacement target
if (mState.action == ACTION_CREATE) {
final SaveFragment save = SaveFragment.get(fm);
if (save != null) {
save.setReplaceTarget(null);
}
} if (mState.action == ACTION_OPEN_TREE ||
mState.action == ACTION_OPEN_COPY_DESTINATION) {
final PickFragment pick = PickFragment.get(fm);
if (pick != null) {
pick.setPickTarget(mState.action, cwd);
}
}
}

其中分支判断当前文档是“最近”、带搜索结果的文档内容还是普通文档内容,这里只看showNormal方法,其他不看,showNormal中调用的是show方法

进入DirectoryFragment

private static void show(FragmentManager fm, int type, RootInfo root, DocumentInfo doc,
String query, int anim) {
final Bundle args = new Bundle();
args.putInt(EXTRA_TYPE, type);
args.putParcelable(EXTRA_ROOT, root);
args.putParcelable(EXTRA_DOC, doc);
args.putString(EXTRA_QUERY, query); final FragmentTransaction ft = fm.beginTransaction(); ...... final DirectoryFragment fragment = new DirectoryFragment();
fragment.setArguments(args); ft.replace(R.id.container_directory, fragment);
ft.commitAllowingStateLoss();
}

show方法显示DirectoryFragment自己

onCreateView中,初始化ListViewGridView,在onActivityCreated方法中:

mCallbacks = new LoaderCallbacks<DirectoryResult>() {
@Override
public Loader<DirectoryResult> onCreateLoader(int id, Bundle args) {
final String query = getArguments().getString(EXTRA_QUERY); Uri contentsUri;
switch (mType) {
case TYPE_NORMAL:
contentsUri = DocumentsContract.buildChildDocumentsUri(
doc.authority, doc.documentId);
if (state.action == ACTION_MANAGE) {
contentsUri = DocumentsContract.setManageMode(contentsUri);
}
return new DirectoryLoader(
context, mType, root, doc, contentsUri, state.userSortOrder);
case TYPE_SEARCH:
contentsUri = DocumentsContract.buildSearchDocumentsUri(
root.authority, root.rootId, query);
if (state.action == ACTION_MANAGE) {
contentsUri = DocumentsContract.setManageMode(contentsUri);
}
return new DirectoryLoader(
context, mType, root, doc, contentsUri, state.userSortOrder);
case TYPE_RECENT_OPEN:
final RootsCache roots = DocumentsApplication.getRootsCache(context);
return new RecentLoader(context, roots, state);
default:
throw new IllegalStateException("Unknown type " + mType);
}
} @Override
public void onLoadFinished(Loader<DirectoryResult> loader, DirectoryResult result) {
if (result == null || result.exception != null) {
// onBackPressed does a fragment transaction, which can't be done inside
// onLoadFinished
mHandler.post(new Runnable() {
@Override
public void run() {
final Activity activity = getActivity();
if (activity != null) {
activity.onBackPressed();
}
}
});
return;
} if (!isAdded()) return; mAdapter.swapResult(result); // Push latest state up to UI
// TODO: if mode change was racing with us, don't overwrite it
if (result.mode != MODE_UNKNOWN) {
state.derivedMode = result.mode;
}
state.derivedSortOrder = result.sortOrder;
((BaseActivity) context).onStateChanged(); updateDisplayState(); // When launched into empty recents, show drawer
if (mType == TYPE_RECENT_OPEN && mAdapter.isEmpty() && !state.stackTouched &&
context instanceof DocumentsActivity) {
((DocumentsActivity) context).setRootsDrawerOpen(true);
} // Restore any previous instance state
final SparseArray<Parcelable> container = state.dirState.remove(mStateKey);
if (container != null && !getArguments().getBoolean(EXTRA_IGNORE_STATE, false)) {
getView().restoreHierarchyState(container);
} else if (mLastSortOrder != state.derivedSortOrder) {
mListView.smoothScrollToPosition(0);
mGridView.smoothScrollToPosition(0);
} mLastSortOrder = state.derivedSortOrder;
} @Override
public void onLoaderReset(Loader<DirectoryResult> loader) {
mAdapter.swapResult(null);
}
};

使用loader机制加载文档内容,在onCreateLoader返回DirectoryLoader加载文档内容内容,加载完成回调onLoadFinished传入加载的结果,最后通过mAdapter.swapResult(result)将数据与Adapter绑定,Adapter有了数据就去更新界面。

那么从启动documentsUI到显示出所选菜单的内容整个过程就结束了,整个过程大致经过以下步骤:

  • 响应Intent启动documentsUI,转到DocumentsActivity
  • 保存Intent和应用显示状态的各种信息
  • 通过RootsLoader加载侧滑菜单数据
  • 点击菜单选项后,通过DirectoryLoader完成异步查询,加载显示文档数据
  • 显示数据

其他

还需进一步了解的

  • Loader机制
  • 自定义View类:DirectoryContainerViewDirectoryViewDocumentsToolBar
  • 缩略图显示