系列目录 :第一篇:全景图与调用链路概览 | 第二篇:内核层---USB驱动与uevent | 第三篇:Native层---vold与NetlinkManager | 第四篇:Framework层(上)---UsbHostManager | 第五篇:Framework层(下)---StorageManagerService | 第六篇:广播分发与SystemUI响应 | 第七篇:应用层---MediaScanner与SAF | 第八篇:实战调试与案例分析
一、引言
前六篇走完了"从硬件到通知栏"的完整链路。但此时 U 盘虽然已经挂载到 /mnt/media_rw/XXXX,文件系统已经可读,但用户打开一个第三方文件管理器,仍然可能看不到任何文件。
原因很简单:
文件系统挂载成功 ≠ 应用能访问到文件。
Android 有一套严格的文件访问控制体系:普通应用不能直接读取 /mnt/media_rw/ 下的文件,必须通过 MediaStore (媒体数据库)或 SAF(存储访问框架) 来间接访问。
本文聚焦应用层的两个核心机制:
- MediaScanner:扫描 U 盘上的媒体文件,写入 MediaStore 数据库
- SAF / ExternalStorageProvider:通过 DocumentsProvider 暴露 U 盘文件系统给文件管理器
二、为什么应用不能直接访问 U 盘
2.1 Android 10+ 的分区存储(Scoped Storage)
Android 10 引入了分区存储(Scoped Storage),到 Android 12 已经全面强制:
| 访问方式 | 内部存储 | 外部 SD / U 盘 |
|---|---|---|
| 直接文件路径 | ❌ 禁止(沙盒目录除外) | ❌ 禁止 |
| MediaStore API | ✅ 推荐 | ✅ 推荐 |
| SAF(Storage Access Framework) | ✅ | ✅ 推荐 |
MANAGE_EXTERNAL_STORAGE 权限 |
✅(仅文件管理器类应用) | 部分支持 |
2.2 U 盘挂载点的权限模型
/mnt/media_rw/XXXX ← root:media_rw (0770) --- 普通应用无权访问
├── Music/
├── DCIM/
└── Documents/
↑
普通 App 想读?→ 必须通过 MediaStore 或 SAF!
三、MediaScanner 全流程拆解
3.1 架构概览
MediaScanner 并不是一个独立的进程,它由 MediaProvider 管理,通过 MediaScannerReceiver 广播触发扫描:
ACTION_MEDIA_MOUNTED 广播
│
▼
MediaScannerReceiver.onReceive()
│
▼
MediaScannerService (IntentService)
│
▼
MediaScanner.scanDirectory() ← 递归遍历所有文件
│
▼
MediaProvider.insert() ← 写入 MediaStore 数据库
3.2 MediaScannerReceiver ------ 接收广播
源码路径 :packages/providers/MediaProvider/src/com/android/providers/media/MediaScannerReceiver.java
java
public class MediaScannerReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
String action = intent.getAction();
Uri uri = intent.getData(); // 如 "file:///mnt/media_rw/XXXX"
if (Intent.ACTION_MEDIA_MOUNTED.equals(action)) {
// ★ U 盘挂载完成 → 启动扫描
scan(context, MediaProvider.EXTERNAL_VOLUME, uri);
} else if (Intent.ACTION_MEDIA_UNMOUNTED.equals(action)) {
// U 盘卸载 → 清理数据库
delete(context, uri);
} else if (Intent.ACTION_MEDIA_SCANNER_SCAN_FILE.equals(action)) {
// 应用请求扫描单个文件
scanFile(context, uri);
}
}
private void scan(Context context, String volume, Uri uri) {
Bundle args = new Bundle();
args.putString("volume", volume);
args.putParcelable("uri", uri);
context.startService(
new Intent(context, MediaScannerService.class)
.putExtras(args));
}
}
3.3 MediaScannerService
源码路径 :packages/providers/MediaProvider/src/com/android/providers/media/MediaScannerService.java
java
public class MediaScannerService extends Service implements Runnable {
private volatile Looper mServiceLooper;
private volatile MediaScanner mScanner;
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
// ★ 用独立线程执行扫描(避免阻塞主线程)
new Thread(null, this, "MediaScannerService").start();
return Service.START_REDELIVER_INTENT;
}
@Override
public void run() {
Looper.prepare();
mServiceLooper = Looper.myLooper();
try {
String volume = mArgs.getString("volume");
Uri uri = mArgs.getParcelable("uri");
String path = uri.getPath();
// ★ 创建 MediaScanner 实例
mScanner = new MediaScanner(this, volume);
// ★ 核心:递归扫描目录
mScanner.scanDirectory(new File(path));
} catch (Exception e) {
Log.e(TAG, "exception in MediaScanner.scan()", e);
}
stopSelf(mStartId);
Looper.loop();
}
}
3.4 MediaScanner.scanDirectory() ------ 递归扫描核心
java
public void scanDirectory(File dir) {
// 1. ★ 检查 .nomedia 文件
if (hasNoMediaFile(dir)) {
mNoMediaPaths.put(dir.getAbsolutePath(), "");
return; // 跳过整个目录
}
// 2. 列出所有文件和子目录
File[] files = dir.listFiles();
if (files == null) return;
// 3. ★ 逐个处理
for (File file : files) {
if (file.isDirectory()) {
scanDirectory(file); // 递归
} else {
processFile(file); // 处理单个文件
}
}
// 4. ★ 批量提交到 MediaProvider
mClient.flush();
}
3.5 processFile() ------ 单文件处理
java
private void processFile(File file) {
String path = file.getAbsolutePath();
// 1. ★ 根据扩展名判断 MIME 类型
String mimeType = MediaFile.getMimeTypeForFile(path);
if (mimeType == null) return; // 非媒体文件,跳过
// 2. ★ 读取元数据
String title = null, artist = null, album = null;
long duration = 0;
int width = 0, height = 0;
if (mimeType.startsWith("audio/")) {
// 读取 ID3 标签
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
retriever.setDataSource(path);
title = retriever.extractMetadata(METADATA_KEY_TITLE);
artist = retriever.extractMetadata(METADATA_KEY_ARTIST);
duration = Long.parseLong(retriever.extractMetadata(METADATA_KEY_DURATION));
retriever.release();
} else if (mimeType.startsWith("image/")) {
// 读取图片尺寸
BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inJustDecodeBounds = true;
BitmapFactory.decodeFile(path, opts);
width = opts.outWidth;
height = opts.outHeight;
} else if (mimeType.startsWith("video/")) {
// 读取视频信息
MediaMetadataRetriever retriever = new MediaMetadataRetriever();
retriever.setDataSource(path);
duration = Long.parseLong(retriever.extractMetadata(METADATA_KEY_DURATION));
width = Integer.parseInt(retriever.extractMetadata(METADATA_KEY_VIDEO_WIDTH));
height = Integer.parseInt(retriever.extractMetadata(METADATA_KEY_VIDEO_HEIGHT));
retriever.release();
}
// 3. ★ 写入 MediaStore
mClient.doScanFile(path, mimeType, file.lastModified(),
file.length(), title, artist, album, duration, width, height);
}
3.6 .nomedia 机制
.nomedia 是一个零字节文件,放在目录中即可让 MediaScanner 跳过该目录:
/mnt/media_rw/XXXX/
├── Music/
│ └── song1.mp3 ← 会被扫描
├── Documents/
│ ├── .nomedia ← ★ 存在此文件
│ └── confidential.pdf ← 跳过,不扫描
└── Photos/
└── vacation.jpg ← 会被扫描
3.7 批量写入 MediaStore
java
private class MyMediaScannerClient {
private ArrayList<ContentValues> mBuffer = new ArrayList<>();
private static final int BATCH_SIZE = 256;
public void doScanFile(String path, String mimeType, ...) {
ContentValues values = new ContentValues();
values.put(MediaStore.MediaColumns.DATA, path);
values.put(MediaStore.MediaColumns.SIZE, fileSize);
values.put(MediaStore.MediaColumns.DATE_MODIFIED, lastModified);
values.put(MediaStore.MediaColumns.MIME_TYPE, mimeType);
// ... 更多字段
mBuffer.add(values);
if (mBuffer.size() >= BATCH_SIZE) {
flush();
}
}
public void flush() {
// ★ 批量插入,大幅减少 ContentProvider 跨进程调用
mResolver.bulkInsert(mBaseUri,
mBuffer.toArray(new ContentValues[0]));
mBuffer.clear();
}
}
3.8 MediaStore 表结构
| Content URI | 存储内容 | 关键字段 |
|---|---|---|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI |
音频文件 | TITLE, ARTIST, ALBUM, DURATION |
MediaStore.Video.Media.EXTERNAL_CONTENT_URI |
视频文件 | TITLE, DURATION, WIDTH, HEIGHT |
MediaStore.Images.Media.EXTERNAL_CONTENT_URI |
图片文件 | TITLE, WIDTH, HEIGHT |
MediaStore.Files.getContentUri("external") |
所有文件 | MIME_TYPE, SIZE |
四、SAF(Storage Access Framework)
4.1 SAF 架构
SAF 提供统一的文件访问接口,核心是 DocumentsProvider:
┌──────────────────────────────────────────────┐
│ App(文件管理器) │
│ ACTION_OPEN_DOCUMENT_TREE │
│ DocumentsContract API │
├──────────────────────────────────────────────┤
│ DocumentsUI(系统文件选择器) │
├──────────────────────────────────────────────┤
│ ExternalStorageProvider │
│ (U盘/SD卡 的 DocumentsProvider) │
├──────────────────────────────────────────────┤
│ 实际文件系统 │
│ /mnt/media_rw/XXXX │
└──────────────────────────────────────────────┘
4.2 ExternalStorageProvider 核心代码
源码路径 :packages/providers/ExternalStorageProvider/
java
public class ExternalStorageProvider extends DocumentsProvider {
@Override
public Cursor queryRoots(String[] projection) {
// ★ 返回所有可用的存储根目录
MatrixCursor result = new MatrixCursor(projection);
StorageManager sm = getContext().getSystemService(StorageManager.class);
for (VolumeInfo vol : sm.getVolumes()) {
if (vol.isVisible() && vol.isMountedReadable()) {
MatrixCursor.RowBuilder row = result.newRow();
row.add(Root.COLUMN_ROOT_ID, vol.getFsUuid());
row.add(Root.COLUMN_TITLE, vol.getDescription());
row.add(Root.COLUMN_DOCUMENT_ID, getDocIdForFile(vol.getPath()));
row.add(Root.COLUMN_FLAGS,
Root.FLAG_SUPPORTS_CREATE |
Root.FLAG_LOCAL_ONLY);
}
}
return result;
}
@Override
public Cursor queryChildDocuments(String parentId,
String[] projection, String sortOrder) {
File parent = getFileForDocId(parentId);
File[] files = parent.listFiles();
// 构建 Cursor 返回文件列表
// ...
}
@Override
public ParcelFileDescriptor openDocument(String docId,
String mode, CancellationSignal signal) {
File file = getFileForDocId(docId);
int accessMode = ParcelFileDescriptor.parseMode(mode);
return ParcelFileDescriptor.open(file, accessMode);
}
}
4.3 应用通过 SAF 访问 U 盘
java
// 步骤1:请求用户选择 U 盘根目录
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
startActivityForResult(intent, REQUEST_CODE);
// 步骤2:获取持久化权限
@Override
protected void onActivityResult(int req, int res, Intent data) {
Uri treeUri = data.getData();
// ★ 持久化权限:重启后仍然有效
getContentResolver().takePersistableUriPermission(treeUri,
Intent.FLAG_GRANT_READ_URI_PERMISSION |
Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
}
// 步骤3:列出文件
Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(
treeUri, DocumentsContract.getTreeDocumentId(treeUri));
Cursor cursor = getContentResolver().query(childrenUri, projection,
null, null, null);
五、两条路径的对比
| 维度 | MediaStore 路径 | SAF 路径 |
|---|---|---|
| 适用文件 | 仅媒体文件(音视频/图片) | 所有文件类型 |
| 访问方式 | ContentResolver.query() |
DocumentsContract API |
| 用户交互 | 不需要 | 需要文件选择器授权 |
| 实时性 | 依赖扫描(有延迟) | 直接访问(实时) |
| 元数据 | 自动提取(ID3/EXIF) | 无自动提取 |
| 典型应用 | 相册、音乐播放器 | 文件管理器、Office 应用 |
六、拔出时的清理
MediaScanner 清理
java
// U 盘拔出后,删除该卷在 MediaStore 中的所有记录
private void deleteFromMediaStore(String path) {
mResolver.delete(mFilesUri,
MediaStore.MediaColumns.DATA + " LIKE ? || '%'",
new String[] { path });
}
SAF 清理
ExternalStorageProvider 在 queryRoots() 中动态检查 Volume 状态,U 盘拔出后自动从根目录列表消失。
七、关键源码文件索引
packages/providers/MediaProvider/
├── MediaScannerReceiver.java ★ 广播接收,触发扫描
├── MediaScannerService.java ★ 扫描服务
├── MediaProvider.java ★ ContentProvider
└── DatabaseHelper.java ★ 数据库
frameworks/base/media/java/android/media/
├── MediaScanner.java ★ 核心扫描逻辑
├── MediaFile.java ★ MIME 判断
└── MediaMetadataRetriever.java ★ 元数据提取
packages/providers/ExternalStorageProvider/
├── ExternalStorageProvider.java ★ SAF Provider
└── MountReceiver.java ★ 挂载监听
packages/apps/DocumentsUI/
├── RootsCache.java ★ 根目录缓存
└── files/FileListFragment.java ★ 文件列表 UI
frameworks/base/core/java/android/provider/
├── MediaStore.java ★ Content URI 常量
└── DocumentsContract.java ★ SAF Contract