Android12 U盘插拔链路源码全解析(七):应用层 —— MediaScanner与SAF

系列目录第一篇:全景图与调用链路概览 | 第二篇:内核层---USB驱动与uevent | 第三篇:Native层---vold与NetlinkManager | 第四篇:Framework层(上)---UsbHostManager | 第五篇:Framework层(下)---StorageManagerService | 第六篇:广播分发与SystemUI响应 | 第七篇:应用层---MediaScanner与SAF | 第八篇:实战调试与案例分析


一、引言

前六篇走完了"从硬件到通知栏"的完整链路。但此时 U 盘虽然已经挂载到 /mnt/media_rw/XXXX,文件系统已经可读,但用户打开一个第三方文件管理器,仍然可能看不到任何文件。

原因很简单:

文件系统挂载成功 ≠ 应用能访问到文件。

Android 有一套严格的文件访问控制体系:普通应用不能直接读取 /mnt/media_rw/ 下的文件,必须通过 MediaStore (媒体数据库)或 SAF(存储访问框架) 来间接访问。

本文聚焦应用层的两个核心机制:

  1. MediaScanner:扫描 U 盘上的媒体文件,写入 MediaStore 数据库
  2. 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
相关推荐
AI玫瑰助手1 小时前
Python模块:import导入模块与模块的搜索路径
android·开发语言·python
yewq-cn2 小时前
Android Log System
android
问心无愧05133 小时前
ctf show web入门107
android·前端·笔记·android studio
AI科技星3 小时前
第三卷:质数王朝志 第四章:RSA护国玄阵,质数锁天地,一数镇万法
android·人工智能·架构·概率论·学习方法
AFinalStone3 小时前
Android12 U盘插拔链路源码全解析(八)实战调试与案例分析
android·frameworks
我命由我123453 小时前
Android 开发问题:View 的 getWidth、getHeight 方法返回的值都为 0
android·java·java-ee·android studio·android jetpack·android-studio·android runtime
JohnnyDeng9413 小时前
【Android】Hilt 依赖注入:原理与最佳实践
android·kotlin·mvvm·hilt
星间都市山脉16 小时前
Android STS(Security Test Suite)完整介绍与测试流程
android·java·linux·windows·ubuntu·android studio·androidx
Yeyu17 小时前
你真的了解AIDL吗? 附:AIDL 与 Binder 交互全解析
android