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
相关推荐
千里马学框架19 小时前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台20 小时前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone20 小时前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc21 小时前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo1 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077001 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼1 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone1 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen1 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone1 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui