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
publicclassMediaScannerReceiverextendsBroadcastReceiver{@OverridepublicvoidonReceive(Contextcontext,Intentintent){finalStringaction=intent.getAction();finalUriuri=intent.getData();if(Intent.ACTION_BOOT_COMPLETED.equals(action)){// ★ 开机时扫描内部和外部存储scan(context,MediaProvider.INTERNAL_VOLUME);scan(context,MediaProvider.EXTERNAL_VOLUME);}elseif(uri.getScheme().equals("file")){Stringpath=uri.getPath();if(Intent.ACTION_MEDIA_MOUNTED.equals(action)){// ★ U盘挂载完成 → 启动扫描scan(context,MediaProvider.EXTERNAL_VOLUME);}elseif(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE.equals(action)){// 应用请求扫描单个文件scanFile(context,path);}}}privatevoidscan(Contextcontext,Stringvolume){Bundleargs=newBundle();args.putString("volume",volume);context.startService(newIntent(context,MediaScannerService.class).putExtras(args));}}3.3 MediaScannerService
publicclassMediaScannerServiceextendsServiceimplementsRunnable{privatevolatileMediaScannermScanner;@OverridepublicintonStartCommand(Intentintent,intflags,intstartId){// ★ 用独立线程执行扫描(避免阻塞主线程)newThread(null,this,"MediaScannerService").start();returnService.START_REDELIVER_INTENT;}@Overridepublicvoidrun(){Looper.prepare();try{Stringvolume=mArgs.getString("volume");// ★ 创建 MediaScanner 实例mScanner=newMediaScanner(this,volume);// ★ 核心:递归扫描目录mScanner.scanDirectory(newFile(path));}catch(Exceptione){Log.e(TAG,"exception in MediaScanner.scan()",e);}stopSelf(mStartId);Looper.loop();}}3.4 MediaScanner.scanDirectory() —— 递归扫描核心
publicvoidscanDirectory(Filedir){// 1. ★ 检查 .nomedia 文件if(hasNoMediaFile(dir)){mNoMediaPaths.put(dir.getAbsolutePath(),"");return;// 跳过整个目录}// 2. 列出所有文件和子目录File[]files=dir.listFiles();if(files==null)return;// 3. ★ 逐个处理for(Filefile:files){if(file.isDirectory()){scanDirectory(file);// 递归}else{processFile(file);// 处理单个文件}}// 4. ★ 批量提交到 MediaProvidermClient.flush();}3.5 processFile() —— 单文件处理
privatevoidprocessFile(Filefile){Stringpath=file.getAbsolutePath();// 1. ★ 根据扩展名判断 MIME 类型StringmimeType=MediaFile.getMimeTypeForFile(path);if(mimeType==null)return;// 非媒体文件,跳过// 2. ★ 读取元数据if(mimeType.startsWith("audio/")){// 读取 ID3 标签MediaMetadataRetrieverretriever=newMediaMetadataRetriever();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();}elseif(mimeType.startsWith("image/")){// 读取图片尺寸BitmapFactory.Optionsopts=newBitmapFactory.Options();opts.inJustDecodeBounds=true;BitmapFactory.decodeFile(path,opts);width=opts.outWidth;height=opts.outHeight;}// 3. ★ 写入 MediaStoremClient.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 中的所有记录privatevoiddeleteFromMediaStore(Stringpath){mResolver.delete(mFilesUri,MediaStore.MediaColumns.DATA+" LIKE ? || '%'",newString[]{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 核心代码
publicclassExternalStorageProviderextendsDocumentsProvider{@OverridepublicCursorqueryRoots(String[]projection){MatrixCursorresult=newMatrixCursor(projection);StorageManagersm=getContext().getSystemService(StorageManager.class);for(VolumeInfovol:sm.getVolumes()){if(vol.isVisible()&&vol.isMountedReadable()){MatrixCursor.RowBuilderrow=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);}}returnresult;}@OverridepublicParcelFileDescriptoropenDocument(StringdocId,Stringmode,CancellationSignalsignal){Filefile=getFileForDocId(docId);intaccessMode=ParcelFileDescriptor.parseMode(mode);returnParcelFileDescriptor.open(file,accessMode);}}六、两条路径的对比
| 维度 | MediaStore 路径 | SAF 路径 |
|---|---|---|
| 适用文件 | 仅媒体文件(音视频/图片) | 所有文件类型 |
| 访问方式 | ContentResolver.query() | DocumentsContractAPI |
| 用户交互 | 不需要 | 需要文件选择器授权 |
| 实时性 | 依赖扫描(有延迟) | 直接访问(实时) |
| 元数据 | 自动提取(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 盘相关问题。
