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


一、引言

前面六篇走完了"从硬件到通知栏"的完整链路。但此时 U 盘虽然已经挂载到 /mnt/media_rw/Udisk,文件系统已经可读,但用户打开一个音乐播放器或相册,仍然可能看不到 U 盘上的文件。

原因很简单:

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

Android 应用需要通过 MediaStore(媒体数据库)来发现媒体文件。本文聚焦应用层的两个核心机制:

  1. MediaScanner:扫描 U 盘上的媒体文件,写入 MediaStore 数据库
  2. 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 盘文件访问的完整流程:

  1. MediaScanner 扫描:收到 MEDIA_MOUNTED 广播后,递归扫描 U 盘目录,提取媒体元数据,批量写入 MediaStore 数据库
  2. .nomedia 机制:在目录中放置 .nomedia 文件可阻止 MediaScanner 扫描该目录
  3. SAF 访问:通过 ExternalStorageProviderDocumentsUI 提供标准的文件选择器访问
  4. Android 7 特点:没有分区存储,应用持有权限后可直接通过文件路径访问 U 盘

MediaScanner 的扫描是异步的,大容量 U 盘可能需要数秒到数十秒才能完成扫描。在此之前,应用通过 MediaStore 查询不到 U 盘上的文件。下一篇是本系列的收官之作,我们将通过实战案例分析如何定位和解决 U 盘相关问题。

Logo

CANN开发者社区旨在汇聚广大开发者,围绕CANN架构重构、算子开发、部署应用优化等核心方向,展开深度交流与思想碰撞,携手共同促进CANN开放生态突破!

更多推荐