系列目录第一篇全景图与调用链路概览 | 第二篇内核层—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 7Nougat没有分区存储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 使用FUSEFilesystem 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.javapublicclassMediaScannerReceiverextendsBroadcastReceiver{OverridepublicvoidonReceive(Contextcontext,Intentintent){finalStringactionintent.getAction();finalUriuriintent.getData();if(Intent.ACTION_BOOT_COMPLETED.equals(action)){// ★ 开机时扫描内部和外部存储scan(context,MediaProvider.INTERNAL_VOLUME);scan(context,MediaProvider.EXTERNAL_VOLUME);}elseif(uri.getScheme().equals(file)){Stringpathuri.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){BundleargsnewBundle();args.putString(volume,volume);context.startService(newIntent(context,MediaScannerService.class).putExtras(args));}}3.3 MediaScannerServicepublicclassMediaScannerServiceextendsServiceimplementsRunnable{privatevolatileMediaScannermScanner;OverridepublicintonStartCommand(Intentintent,intflags,intstartId){// ★ 用独立线程执行扫描避免阻塞主线程newThread(null,this,MediaScannerService).start();returnService.START_REDELIVER_INTENT;}Overridepublicvoidrun(){Looper.prepare();try{StringvolumemArgs.getString(volume);// ★ 创建 MediaScanner 实例mScannernewMediaScanner(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[]filesdir.listFiles();if(filesnull)return;// 3. ★ 逐个处理for(Filefile:files){if(file.isDirectory()){scanDirectory(file);// 递归}else{processFile(file);// 处理单个文件}}// 4. ★ 批量提交到 MediaProvidermClient.flush();}3.5 processFile() —— 单文件处理privatevoidprocessFile(Filefile){Stringpathfile.getAbsolutePath();// 1. ★ 根据扩展名判断 MIME 类型StringmimeTypeMediaFile.getMimeTypeForFile(path);if(mimeTypenull)return;// 非媒体文件跳过// 2. ★ 读取元数据if(mimeType.startsWith(audio/)){// 读取 ID3 标签MediaMetadataRetrieverretrievernewMediaMetadataRetriever();retriever.setDataSource(path);titleretriever.extractMetadata(METADATA_KEY_TITLE);artistretriever.extractMetadata(METADATA_KEY_ARTIST);durationLong.parseLong(retriever.extractMetadata(METADATA_KEY_DURATION));retriever.release();}elseif(mimeType.startsWith(image/)){// 读取图片尺寸BitmapFactory.OptionsoptsnewBitmapFactory.Options();opts.inJustDecodeBoundstrue;BitmapFactory.decodeFile(path,opts);widthopts.outWidth;heightopts.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, DURATIONMediaStore.Video.Media.EXTERNAL_CONTENT_URI视频文件TITLE, DURATION, WIDTH, HEIGHTMediaStore.Images.Media.EXTERNAL_CONTENT_URI图片文件TITLE, WIDTH, HEIGHTMediaStore.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){MatrixCursorresultnewMatrixCursor(projection);StorageManagersmgetContext().getSystemService(StorageManager.class);for(VolumeInfovol:sm.getVolumes()){if(vol.isVisible()vol.isMountedReadable()){MatrixCursor.RowBuilderrowresult.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){FilefilegetFileForDocId(docId);intaccessModeParcelFileDescriptor.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 盘相关问题。