在 Android 开发中,处理文件系统变化,尤其是媒体文件(图片、视频)的增删改查,是一个常见的需求。但直接操作文件系统或频繁触发 MediaStore 扫描可能会导致性能问题。
本文将介绍一个高效、防抖、支持深度与浅层扫描的 FileScanner Kotlin object,它结合了 协程 (Coroutines) 进行异步操作、Mutex 保证并发安全,以及 ContentObserver 监听媒体库变化,实现了一个健壮的媒体文件扫描机制。
一、核心功能与设计目标
FileScanner 的主要目标是通知 MediaStore 扫描文件或目录,而非执行媒体查询。
-
媒体库变化触发扫描 :通过注册
ContentObserver,在媒体库发生变化时自动触发一次带防抖 (Debounce) 的扫描。 -
支持浅层与深度扫描:
- 浅层扫描 (Shallow Scan) :仅将目录本身路径交给系统,适用于新增整个目录或由系统自行发现变化。
- 深度扫描 (Deep Scan) :递归枚举目录下所有支持的媒体文件,将具体文件路径交给系统扫描,确保新文件被及时索引。
-
并发控制 :利用 Kotlin 协程的
Mutex确保同一时间只有一个扫描任务在执行,避免资源浪费和系统过载。 -
资源管理 :提供明确的
unregister和cleanup方法释放ContentObserver和协程资源。
二、完整源代码
以下是 FileScanner.kt 的完整代码:
Kotlin
kotlin
import android.content.Context
import android.database.ContentObserver
import android.media.MediaScannerConnection
import android.net.Uri
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlin.coroutines.resume
/**
* FileScanner
* 功能:
* 1. 注册媒体库 ContentObserver,媒体库变化时触发扫描(防抖)。
* 2. scanFiles 支持浅层(传目录本身)与深度(递归枚举媒体文件)扫描。
* 3. 使用协程 + Mutex 保证单次串行扫描,防止并发重复消耗。
* 4. 提供 unregister 与 cleanup 释放资源。
*
* 非目标:不负责媒体文件查询(应使用 MediaStore 查询)。
*/
object FileScanner {
private val mutex = Mutex()
// 使用 IO 调度器,确保文件扫描在后台线程执行
private var scope: CoroutineScope = createScope()
private var mediaObserver: MediaContentObserver? = null
// 用于防抖的 Job
private var debounceJob: Job? = null
// 观察者注册状态
private val isRegistered = AtomicBoolean(false)
private const val DEBOUNCE_DELAY = 1000L // 1秒防抖
// 支持的媒体扩展名(统一为小写)
private val mediaExtensions = setOf(
"jpg", "jpeg", "png", "gif", "webp", "bmp", // 图片
"mp4", "avi", "mov", "mkv", "3gp", "webm" // 视频
)
private fun createScope() = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private fun ensureScope(): CoroutineScope {
if (!scope.isActive) {
// 如果 Scope 被取消,则重新创建一个新的
scope = createScope()
}
return scope
}
/** 判断是否是支持的媒体文件 */
private fun isMediaFile(file: java.io.File): Boolean {
if (!file.isFile) return false
val name = file.name
val dot = name.lastIndexOf('.')
// 确保有扩展名且不在文件名开头或结尾
if (dot <= 0 || dot == name.length - 1) return false
val ext = name.substring(dot + 1).lowercase()
return mediaExtensions.contains(ext)
}
/**
* 扫描指定路径列表。paths 为 null 时扫描常用公共目录(DCIM / Pictures / Movies)。
* deepScan=true:递归枚举目录下所有媒体文件;false:仅把目录路径交给系统扫描。
*/
fun scanFiles(context: Context, paths: Array<String>? = null, deepScan: Boolean = true) {
// 1. 实现防抖:取消旧的防抖任务
if (debounceJob?.isActive == true) {
debounceJob?.cancel()
}
// 2. 启动新的防抖任务
debounceJob = ensureScope().launch {
delay(DEBOUNCE_DELAY)
// 3. 进入 Mutex 保护区,串行执行扫描逻辑
mutex.withLock {
performMediaScan(context, paths, deepScan)
}
}
}
@OptIn(ExperimentalCoroutinesApi::class)
private suspend fun performMediaScan(context: Context, paths: Array<String>?, deepScan: Boolean) {
// 默认扫描路径
val inputPaths = paths ?: arrayOf(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DCIM).absolutePath,
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES).absolutePath,
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES).absolutePath
)
val filesToScan = mutableListOf<String>()
inputPaths.forEach { path ->
val f = java.io.File(path)
if (!f.exists()) return@forEach
if (f.isDirectory) {
if (deepScan) {
// 深度扫描:递归收集目录下所有媒体文件
collectMediaFiles(f, filesToScan)
} else {
// 浅层扫描:仅把目录本身路径加入扫描列表
filesToScan.add(path)
}
} else {
// 单文件扫描:如果不是深度扫描,或者文件是支持的媒体文件,则加入
if (!deepScan || isMediaFile(f)) {
filesToScan.add(path)
}
}
}
if (filesToScan.isEmpty()) return
val scanPaths = filesToScan.toTypedArray()
// 将 MediaScannerConnection.scanFile 的异步回调封装成挂起函数
suspendCancellableCoroutine<Unit> { continuation ->
val total = scanPaths.size
val completed = AtomicInteger(0)
val resumed = AtomicBoolean(false) // 避免重复 resume
MediaScannerConnection.scanFile(context, scanPaths, null) { _, _ ->
val done = completed.incrementAndGet()
// 所有文件扫描完成,或协程仍未被取消
if (done == total && resumed.compareAndSet(false, true)) {
continuation.resume(Unit)
}
}
// 协程被取消时(如 debounceJob 被取消)
continuation.invokeOnCancellation {
// 可选:如果 MediaScannerConnection 有取消机制,可以在这里调用
}
}
}
/** 递归收集媒体文件(深度扫描用) */
private fun collectMediaFiles(dir: java.io.File, out: MutableList<String>) {
val children = dir.listFiles() ?: return
for (child in children) {
if (child.isFile) {
if (isMediaFile(child)) out.add(child.absolutePath)
} else if (child.isDirectory && !child.name.startsWith('.')) {
// 忽略隐藏目录
collectMediaFiles(child, out)
}
}
}
/** 注册媒体库变化观察者 */
fun registerMediaObserver(context: Context) {
if (!isRegistered.compareAndSet(false, true)) return
try {
val handler = Handler(Looper.getMainLooper())
// 媒体库变化时,触发一次防抖扫描
mediaObserver = MediaContentObserver(handler) {
scanFiles(context)
}
context.contentResolver.registerContentObserver(
// 监听所有文件变化
MediaStore.Files.getContentUri("external"),
true, // 深度监听子目录
mediaObserver!!
)
} catch (e: Exception) {
isRegistered.set(false)
throw e
}
}
/** 注销媒体库观察者 */
fun unregisterMediaObserver(context: Context) {
if (!isRegistered.compareAndSet(true, false)) return
debounceJob?.cancel()
debounceJob = null
mediaObserver?.let {
try { context.contentResolver.unregisterContentObserver(it) } catch (_: Exception) {}
}
mediaObserver = null
}
/** 清理全部资源(通常在宿主销毁时调用) */
fun cleanup(context: Context? = null) {
try { context?.let { unregisterMediaObserver(it) } } catch (_: Exception) {}
// 取消所有协程任务
scope.cancel()
}
}
/** ContentObserver 简单封装 */
class MediaContentObserver(
handler: Handler,
private val onMediaChange: () -> Unit,
) : ContentObserver(handler) {
override fun onChange(selfChange: Boolean, uri: Uri?) {
onMediaChange()
}
}
三、实现逻辑分析
1. 协程与并发控制 (Mutex & debounce)
这是 FileScanner 健壮性的关键:
-
CoroutineScope:scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)。使用Dispatchers.IO确保文件 I/O 操作不会阻塞主线程。SupervisorJob确保子任务失败不会影响其他任务。 -
scanFiles()中的防抖 (Debounce) :- 每次调用
scanFiles()都会先取消前一个正在等待的debounceJob(debounceJob?.cancel())。 - 然后启动一个新的
Job,等待DEBOUNCE_DELAY(1000ms)。如果在 1000ms 内再次调用scanFiles,前一个任务就会被取消。 - 这解决了
ContentObserver可能在短时间内触发多次变化的问题,避免了不必要的频繁扫描。
- 每次调用
-
Mutex串行执行:mutex.withLock { performMediaScan(...) }确保了即使防抖任务成功启动,它也必须等待前一个performMediaScan任务完全结束后才能执行。- 这保证了 MediaStore 扫描任务的单次串行执行,防止系统因多个并发扫描任务而变慢。
2. 媒体扫描逻辑 (performMediaScan)
该函数是核心执行逻辑,工作在 Dispatchers.IO 线程:
-
路径收集:
- 如果是
paths为null,默认扫描系统公共媒体目录 (DCIM,Pictures,Movies)。 - 深度扫描 (
deepScan = true):调用collectMediaFiles递归遍历目录,只收集 满足isMediaFile的具体文件路径。 - 浅层扫描 (
deepScan = false):只将目录本身的路径加入扫描列表。系统收到目录路径后,会自行查找其中的新文件。
- 如果是
-
MediaScannerConnection封装:MediaScannerConnection.scanFile是一个异步 API,依赖回调 (onScanCompleted) 来通知完成。- 使用
suspendCancellableCoroutine将这个回调机制优雅地封装成一个 Kotlin 挂起函数。 AtomicInteger(completed) 用于追踪已完成的文件数量,当达到total时,调用continuation.resume(Unit)恢复协程。
3. 媒体库监听 (registerMediaObserver)
MediaContentObserver:这是一个标准的 Android API,用于监听ContentProvider(这里是MediaStore)的数据变化。- 监听 URI :注册到
MediaStore.Files.getContentUri("external"),这是监听外部存储上所有文件变化的最广 URI。 - 触发机制 :当
onChange触发时,调用scanFiles(context),触发带防抖和 Mutex 保护的扫描逻辑。
4. 资源清理 (cleanup / unregister)
良好的资源管理至关重要:
unregisterMediaObserver:负责注销ContentObserver并清理debounceJob。cleanup:调用unregisterMediaObserver后,还会调用scope.cancel()。这会取消scope下所有未完成的协程任务(包括正在等待的防抖任务和正在执行的performMediaScan任务),确保在宿主组件(如Activity或Service)销毁时,所有后台工作都被停止,防止内存泄漏。
如何使用?
权限:确保在 AndroidManifest.xml 中声明了存储读写权限(取决于您的 Android 版本和目标 API 级别)。
kotlin
Kotlin
```
private boolean hasRequiredMediaPermissions() {
if (Build.VERSION.SDK_INT >= 33) {
return checkSelfPermission(Manifest.permission.READ_MEDIA_IMAGES) == PackageManager.PERMISSION_GRANTED
&& checkSelfPermission(Manifest.permission.READ_MEDIA_VIDEO) == PackageManager.PERMISSION_GRANTED;
} else {
return ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED;
}
}
```
启动监听 (如在 Application 或 Service 中):
go
Kotlin
```
FileScanner.registerMediaObserver(applicationContext)
```
手动触发扫描(如用户点击按钮):
ini
Kotlin
```
// 深度扫描指定目录
FileScanner.scanFiles(context, arrayOf("/path/to/new/photos"), deepScan = true)
// 仅扫描公共目录,浅层
FileScanner.scanFiles(context, deepScan = false)
```
清理资源 (在 onDestroy 或 onStop 中):
go
Kotlin
```
FileScanner.cleanup(applicationContext)
```