Android7 U盘插拔链路源码全解析(七)应用层MediaScanner与SAF
系列目录:第一篇:全景图与调用链路概览 | 第二篇:内核层—USB驱动与uevent | 第三篇:Native层—vold与NetlinkManager | 第四篇:Framework层(上)—UsbHostManager | 第五篇:Framework层(下)—MountService | 第六篇:广播分发与SystemUI响应 | 第七篇:应用层—MediaScanner与SAF | 第八篇:实战调试与案例分析
一、引言
前面六篇走完了"从硬件到通知栏"的完整链路。但此时 U 盘虽然已经挂载到 /mnt/media_rw/Udisk,文件系统已经可读,但用户打开一个音乐播放器或相册,仍然可能看不到 U 盘上的文件。
原因很简单:
文件系统挂载成功 ≠ 应用能访问到文件。
Android 应用需要通过 MediaStore(媒体数据库)来发现媒体文件。本文聚焦应用层的两个核心机制:
- MediaScanner:扫描 U 盘上的媒体文件,写入 MediaStore 数据库
- SAF(存储访问框架):通过 DocumentsProvider 暴露 U 盘文件系统给文件管理器
Android 7 与后续版本的重要区别:Android 7(Nougat)没有分区存储(Scoped Storage),应用只要持有
READ_EXTERNAL_STORAGE权限,就可以直接通过文件路径访问 U 盘上的文件。但 MediaStore 仍然是系统推荐的标准方式。
二、U 盘挂载点的权限模型
/mnt/media_rw/Udisk ← root:media_rw (0770) — 普通应用无权直接访问
├── Music/
│ ├── song1.mp3
│ └── song2.flac
├── DCIM/
│ └── photo.jpg
└── Documents/
└── manual.pdf
/mnt/runtime/default/Udisk ← FUSE 挂载(sdcard 守护进程)
/mnt/runtime/read/Udisk ← 所有应用可读
/mnt/runtime/write/Udisk ← 有 WRITE_EXTERNAL_STORAGE 权限的应用可写
Android 7 使用 FUSE(Filesystem in Userspace) 进行权限管理。sdcard 守护进程(/system/bin/sdcard)将 /mnt/media_rw/Udisk 重新挂载为 /storage/Udisk,在此过程中实施权限控制。
三、MediaScanner 全流程拆解
3.1 架构概览
ACTION_MEDIA_MOUNTED 广播
│
▼
MediaScannerReceiver.onReceive()
│
▼
MediaScannerService (Service)
│
▼
MediaScanner.scanDirectory() ← 递归遍历所有文件
│
▼
MediaProvider.insert() ← 写入 MediaStore 数据库
3.2 MediaScannerReceiver —— 接收广播
源码路径:packages/providers/MediaProvider/src/com/android/providers/media/MediaScannerReceiver.java
public class MediaScannerReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
final String action = intent.getAction();
final Uri uri = intent.getData();
if (Intent.ACTION_BOOT_COMPLETED.equals(action)) {
// ★ 开机时扫描内部和外部存储
scan(context, MediaProvider.INTERNAL_VOLUME);
scan(context, MediaProvider.EXTERNAL_VOLUME);
} else if (uri.getScheme().equals("file")) {
String path = uri.getPath();
if (Intent.ACTION_MEDIA_MOUNTED.equals(action)) {
// ★ U盘挂载完成 → 启动扫描
scan(context, MediaProvider.EXTERNAL_VOLUME);
} else if (Intent.ACTION_MEDIA_SCANNER_SCAN_FILE.equals(action)) {
// 应用请求扫描单个文件
scanFile(context, path);
}
}
}
private void scan(Context context, String volume) {
Bundle args = new Bundle();
args.putString("volume", volume);
context.startService(
new Intent(context, MediaScannerService.class).putExtras(args));
}
}
3.3 MediaScannerService
public class MediaScannerService extends Service implements Runnable {
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();
try {
String volume = mArgs.getString("volume");
// ★ 创建 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() —— 递归扫描核心
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() —— 单文件处理
private void processFile(File file) {
String path = file.getAbsolutePath();
// 1. ★ 根据扩展名判断 MIME 类型
String mimeType = MediaFile.getMimeTypeForFile(path);
if (mimeType == null) return; // 非媒体文件,跳过
// 2. ★ 读取元数据
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;
}
// 3. ★ 写入 MediaStore
mClient.doScanFile(path, mimeType, file.lastModified(),
file.length(), title, artist, album, duration, width, height);
}
3.6 .nomedia 机制
.nomedia 是一个零字节文件,放在目录中即可让 MediaScanner 跳过该目录:
/mnt/media_rw/Udisk/
├── Music/
│ └── song1.mp3 ← 会被扫描
├── Documents/
│ ├── .nomedia ← ★ 存在此文件
│ └── confidential.pdf ← 跳过,不扫描
└── Photos/
└── vacation.jpg ← 会被扫描
3.7 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 |
四、拔出时的清理
// U 盘拔出后,删除该卷在 MediaStore 中的所有记录
private void deleteFromMediaStore(String path) {
mResolver.delete(mFilesUri,
MediaStore.MediaColumns.DATA + " LIKE ? || '%'",
new String[] { path });
}
五、SAF(存储访问框架)
5.1 SAF 架构
SAF 提供统一的文件访问接口,核心是 DocumentsProvider:
┌──────────────────────────────────────────────┐
│ App(文件管理器) │
│ ACTION_OPEN_DOCUMENT_TREE │
│ DocumentsContract API │
├──────────────────────────────────────────────┤
│ DocumentsUI(系统文件选择器) │
├──────────────────────────────────────────────┤
│ ExternalStorageProvider │
│ (U盘/SD卡 的 DocumentsProvider) │
├──────────────────────────────────────────────┤
│ 实际文件系统 │
│ /mnt/media_rw/Udisk │
└──────────────────────────────────────────────┘
5.2 ExternalStorageProvider 核心代码
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 ParcelFileDescriptor openDocument(String docId,
String mode, CancellationSignal signal) {
File file = getFileForDocId(docId);
int accessMode = ParcelFileDescriptor.parseMode(mode);
return ParcelFileDescriptor.open(file, accessMode);
}
}
六、两条路径的对比
| 维度 | MediaStore 路径 | SAF 路径 |
|---|---|---|
| 适用文件 | 仅媒体文件(音视频/图片) | 所有文件类型 |
| 访问方式 | ContentResolver.query() |
DocumentsContract API |
| 用户交互 | 不需要 | 需要文件选择器授权 |
| 实时性 | 依赖扫描(有延迟) | 直接访问(实时) |
| 元数据 | 自动提取(ID3/EXIF) | 无自动提取 |
| 典型应用 | 相册、音乐播放器 | 文件管理器、Office 应用 |
七、关键源码文件索引
packages/providers/MediaProvider/
├── MediaScannerReceiver.java ★ 广播接收,触发扫描
├── MediaScannerService.java ★ 扫描服务
├── MediaProvider.java ★ ContentProvider
└── DatabaseHelper.java ★ 数据库
frameworks/base/media/java/android/media/
├── MediaScanner.java ★ 核心扫描逻辑
└── MediaFile.java ★ MIME 判断
packages/providers/ExternalStorageProvider/
└── ExternalStorageProvider.java ★ SAF Provider
packages/apps/DocumentsUI/
└── RootsCache.java ★ 根目录缓存
frameworks/base/core/java/android/provider/
├── MediaStore.java ★ Content URI 常量
└── DocumentsContract.java ★ SAF Contract
八、小结
本文拆解了 Android 7 应用层 U 盘文件访问的完整流程:
- MediaScanner 扫描:收到
MEDIA_MOUNTED广播后,递归扫描 U 盘目录,提取媒体元数据,批量写入 MediaStore 数据库 - .nomedia 机制:在目录中放置
.nomedia文件可阻止 MediaScanner 扫描该目录 - SAF 访问:通过
ExternalStorageProvider和DocumentsUI提供标准的文件选择器访问 - Android 7 特点:没有分区存储,应用持有权限后可直接通过文件路径访问 U 盘
MediaScanner 的扫描是异步的,大容量 U 盘可能需要数秒到数十秒才能完成扫描。在此之前,应用通过 MediaStore 查询不到 U 盘上的文件。下一篇是本系列的收官之作,我们将通过实战案例分析如何定位和解决 U 盘相关问题。
更多推荐
所有评论(0)