摘要:本文从零开始,手把手带你构建一个高可用的 Android 文件管理器。内容涵盖项目环境搭建与权限配置、界面布局设计、目录加载与列表适配器、文件夹创建与重命名、删除与长按监听、复制粘贴机制、本地搜索服务、调用系统默认程序打开文件、内置文本编辑器与网页浏览器,以及常见运行时异常的排查与适配优化。全文以实战代码为主线,跳过理论堆砌,直接落地可运行的实现方案,帮助新手快速掌握文件管理器的核心开发路径。
关键词:Android;文件管理器;Scoped Storage;RecyclerView;FileProvider;协程;权限适配
在开发 Android 文件管理器时,很多开发者容易陷入一个误区:过分关注文件操作的底层 API,却忽视了用户体验的流畅性与系统权限的合规性。实际项目中,我们常遇到应用启动后无法读取存储卡、长按菜单响应迟钝、或者调用系统打开文件时崩溃等问题。这些痛点不仅影响功能完整性,更直接决定了用户是否愿意继续使用你的应用。
尤其随着 Android 版本迭代,存储访问机制发生了巨大变化,从早期的直接路径访问到现在的 Scoped Storage,每一步都需要谨慎处理。如果你正在着手构建一个功能完备的文件管理工具,或者想优化现有项目的交互逻辑,那么本文分享的实战经验将非常有价值。我们将跳过理论堆砌,直接深入代码层面,从环境搭建到异常排查,完整还原一个高可用文件管理器的核心实现路径。
目录
- [① 项目环境搭建与权限配置要点](#① 项目环境搭建与权限配置要点 "#%E2%91%A0-%E9%A1%B9%E7%9B%AE%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BA%E4%B8%8E%E6%9D%83%E9%99%90%E9%85%8D%E7%BD%AE%E8%A6%81%E7%82%B9")
- [② 界面布局设计与菜单初始化流程](#② 界面布局设计与菜单初始化流程 "#%E2%91%A1-%E7%95%8C%E9%9D%A2%E5%B8%83%E5%B1%80%E8%AE%BE%E8%AE%A1%E4%B8%8E%E8%8F%9C%E5%8D%95%E5%88%9D%E5%A7%8B%E5%8C%96%E6%B5%81%E7%A8%8B")
- [③ 目录内容加载与文件列表适配器实现](#③ 目录内容加载与文件列表适配器实现 "#%E2%91%A2-%E7%9B%AE%E5%BD%95%E5%86%85%E5%AE%B9%E5%8A%A0%E8%BD%BD%E4%B8%8E%E6%96%87%E4%BB%B6%E5%88%97%E8%A1%A8%E9%80%82%E9%85%8D%E5%99%A8%E5%AE%9E%E7%8E%B0")
- [④ 文件夹创建与文件重命名功能编码](#④ 文件夹创建与文件重命名功能编码 "#%E2%91%A3-%E6%96%87%E4%BB%B6%E5%A4%B9%E5%88%9B%E5%BB%BA%E4%B8%8E%E6%96%87%E4%BB%B6%E9%87%8D%E5%91%BD%E5%90%8D%E5%8A%9F%E8%83%BD%E7%BC%96%E7%A0%81")
- [⑤ 文件删除操作与长按监听器逻辑](#⑤ 文件删除操作与长按监听器逻辑 "#%E2%91%A4-%E6%96%87%E4%BB%B6%E5%88%A0%E9%99%A4%E6%93%8D%E4%BD%9C%E4%B8%8E%E9%95%BF%E6%8C%89%E7%9B%91%E5%90%AC%E5%99%A8%E9%80%BB%E8%BE%91")
- [⑥ 文件复制粘贴机制与剪贴板管理](#⑥ 文件复制粘贴机制与剪贴板管理 "#%E2%91%A5-%E6%96%87%E4%BB%B6%E5%A4%8D%E5%88%B6%E7%B2%98%E8%B4%B4%E6%9C%BA%E5%88%B6%E4%B8%8E%E5%89%AA%E8%B4%B4%E6%9D%BF%E7%AE%A1%E7%90%86")
- [⑦ 本地文件搜索服务与广播接收器应用](#⑦ 本地文件搜索服务与广播接收器应用 "#%E2%91%A6-%E6%9C%AC%E5%9C%B0%E6%96%87%E4%BB%B6%E6%90%9C%E7%B4%A2%E6%9C%8D%E5%8A%A1%E4%B8%8E%E5%B9%BF%E6%92%AD%E6%8E%A5%E6%94%B6%E5%99%A8%E5%BA%94%E7%94%A8")
- [⑧ 调用系统默认程序打开各类文件](#⑧ 调用系统默认程序打开各类文件 "#%E2%91%A7-%E8%B0%83%E7%94%A8%E7%B3%BB%E7%BB%9F%E9%BB%98%E8%AE%A4%E7%A8%8B%E5%BA%8F%E6%89%93%E5%BC%80%E5%90%84%E7%B1%BB%E6%96%87%E4%BB%B6")
- [⑨ 内置文本编辑器与网页浏览器实现](#⑨ 内置文本编辑器与网页浏览器实现 "#%E2%91%A8-%E5%86%85%E7%BD%AE%E6%96%87%E6%9C%AC%E7%BC%96%E8%BE%91%E5%99%A8%E4%B8%8E%E7%BD%91%E9%A1%B5%E6%B5%8F%E8%A7%88%E5%99%A8%E5%AE%9E%E7%8E%B0")
- [⑩ 常见运行时异常排查与适配优化](#⑩ 常见运行时异常排查与适配优化 "#%E2%91%A9-%E5%B8%B8%E8%A7%81%E8%BF%90%E8%A1%8C%E6%97%B6%E5%BC%82%E5%B8%B8%E6%8E%92%E6%9F%A5%E4%B8%8E%E9%80%82%E9%85%8D%E4%BC%98%E5%8C%96")
- [⑪ 完整实战代码:一个可运行的 Android 文件管理器](#⑪ 完整实战代码:一个可运行的 Android 文件管理器 "#%E2%91%AA-%E5%AE%8C%E6%95%B4%E5%AE%9E%E6%88%98%E4%BB%A3%E7%A0%81%E4%B8%80%E4%B8%AA%E5%8F%AF%E8%BF%90%E8%A1%8C%E7%9A%84-android-%E6%96%87%E4%BB%B6%E7%AE%A1%E7%90%86%E5%99%A8")
① 项目环境搭建与权限配置要点
启动一个新项目时,首要任务是明确目标 SDK 版本并配置相应的权限策略。目前主流做法是将 targetSdkVersion 设定为最新稳定版,同时在 AndroidManifest.xml 中声明必要的存储权限。对于 Android 10 及以上设备,必须适配分区存储机制,这意味着传统的 WRITE_EXTERNAL_STORAGE 权限在某些场景下不再生效,转而需要使用 MediaStore 或 Storage Access Framework。
在 build.gradle 中,建议将 compileSdk 与 targetSdk 设为当前最新稳定版(如 34),minSdk 设为 21 以覆盖绝大多数设备。AndroidManifest.xml 中按需声明权限,例如:
xml
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
这里用 maxSdkVersion 限制旧权限的生效范围:Android 10(API 29)及以上已默认启用分区存储,声明旧权限不仅无效,还可能引发审核问题;Android 13(API 33)起则改用细粒度的 READ_MEDIA_IMAGES、READ_MEDIA_VIDEO、READ_MEDIA_AUDIO 权限,需要按需申请。 在代码初始化阶段,建议封装一个权限检查工具类。该类应在应用启动时主动检测当前权限状态,若未授权则引导用户跳转设置页。注意不要简单粗暴地请求所有权限,而是根据用户即将执行的操作动态申请。例如,仅在用户点击"新建文件夹"时才触发写入权限请求,这样能显著降低用户的抵触心理。此外,务必处理权限被永久拒绝的情况,提供友好的提示文案,避免应用陷入死循环。
下面是一个基于协程的权限请求封装示例,按需申请并处理"永久拒绝"场景:
kotlin
class PermissionHelper(private val activity: FragmentActivity) {
private val launcher = activity.registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { result ->
val denied = result.filter { !it.value }.keys
if (denied.isNotEmpty()) {
val permanentlyDenied = denied.any {
!activity.shouldShowRequestPermissionRationale(it)
}
if (permanentlyDenied) {
// 引导用户去系统设置手动开启
showSettingsDialog(activity)
} else {
// 用户可再次点击"重试"触发请求
showRetryDialog(activity, denied)
}
}
}
fun request(vararg permissions: String) {
launcher.launch(permissions.toList())
}
}
调用时按操作动态申请,例如点击"新建文件夹"时只请求写入权限,点击"扫描图片"时才请求媒体读取权限,避免一次性弹出一堆授权框吓退用户。
为帮助你更直观地理解版本差异,下表对比了 Android 10 以下与 Android 10+ 在存储权限、文件访问方式、URI 获取方式三个维度的核心区别:
| 对比维度 | Android 10 以下(API < 29) | Android 10+(API ≥ 29) |
|---|---|---|
| 存储权限 | 声明 READ_EXTERNAL_STORAGE / WRITE_EXTERNAL_STORAGE 即可直接读写外部存储 |
默认启用分区存储,传统读写权限仅对媒体文件生效,需配合 MediaStore 或 Storage Access Framework 申请 |
| 文件访问方式 | 可直接通过 File 路径访问外部存储任意目录 |
无法直接访问应用专属目录之外的路径,需通过 MediaStore 查询媒体文件,或用 SAF 的 DocumentFile 访问用户授权目录 |
| URI 获取方式 | 可直接构造 file:// 路径或 Uri.fromFile() 获取 |
必须通过 MediaStore 的 ContentResolver 查询得到 content:// URI,或经 SAF 的 ACTION_OPEN_DOCUMENT 让用户选择后返回授权 URI |
简要说明 :核心变化在于 Android 10+ 引入了分区存储(Scoped Storage),应用不再拥有对整个外部存储的任意读写权。因此,在适配新版本时,应优先使用 MediaStore 处理媒体文件、用 SAF 处理任意目录访问,并统一改用 content:// URI 传递文件,避免因路径或权限问题导致崩溃。
② 界面布局设计与菜单初始化流程
界面是用户感知应用质量的第一窗口。文件管理器通常采用经典的"左侧导航 + 右侧列表"或"顶部标签 + 内容区域"结构。推荐使用 RecyclerView 作为文件列表容器,配合 ConstraintLayout 实现灵活的自适应布局。在 XML 布局文件中,预定义好空状态视图(Empty View)和加载进度条,确保在网络延迟或目录为空时给用户明确反馈。
下面是一个典型的文件列表布局骨架,使用 SwipeRefreshLayout 包裹 RecyclerView,并预留空状态视图:
xml
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refreshLayout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/fileRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="8dp"
android:clipToPadding="false"
android:scrollbars="vertical" />
<TextView
android:id="@+id/emptyView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="当前目录为空"
android:visibility="gone" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
在 Activity 或 Fragment 中初始化时,设置 LinearLayoutManager 并绑定空状态切换逻辑:
kotlin
val layoutManager = LinearLayoutManager(this)
fileRecyclerView.layoutManager = layoutManager
fileRecyclerView.adapter = fileAdapter
// 数据为空时显示空状态视图
fun updateEmptyState(isEmpty: Boolean) {
emptyView.visibility = if (isEmpty) View.VISIBLE else View.GONE
fileRecyclerView.visibility = if (isEmpty) View.GONE else View.VISIBLE
}
SwipeRefreshLayout 自带下拉刷新能力,配合 setOnRefreshListener 即可实现目录内容的手动刷新,无需额外引入第三方库。 菜单初始化方面,可以利用 PopupMenu 或自定义 Dialog 来实现上下文操作菜单。当用户长按某项文件时,弹出包含"复制"、"移动"、"重命名"、"删除"等选项的菜单。关键在于菜单项的动态可见性:例如,对文件夹不应显示"用文本编辑器打开",对只读文件应隐藏"删除"按钮。这需要在适配器层维护一份状态标记,并在菜单创建回调中实时判断。
下面是一个基于 PopupMenu 的动态菜单实现,根据文件类型决定显示哪些操作项:
kotlin
fun showContextMenu(anchor: View, fileItem: FileItem) {
val popup = PopupMenu(anchor.context, anchor)
popup.menuInflater.inflate(R.menu.context_menu, popup.menu)
// 动态控制菜单项可见性
popup.menu.findItem(R.id.action_open_with).isVisible = fileItem.isFile
popup.menu.findItem(R.id.action_rename).isVisible = true
popup.menu.findItem(R.id.action_delete).isVisible = !fileItem.isReadOnly
popup.menu.findItem(R.id.action_edit_text).isVisible =
fileItem.isFile && fileItem.extension in setOf("txt", "md", "json", "xml")
popup.setOnMenuItemClickListener { item ->
when (item.itemId) {
R.id.action_open_with -> openWith(fileItem)
R.id.action_rename -> showRenameDialog(fileItem)
R.id.action_delete -> confirmDelete(fileItem)
R.id.action_edit_text -> openTextEditor(fileItem)
else -> false
}
}
popup.show()
}
对应的 res/menu/context_menu.xml 定义如下:
xml
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@+id/action_open_with"
android:title="用其他应用打开" />
<item android:id="@+id/action_edit_text"
android:title="用文本编辑器打开" />
<item android:id="@+id/action_rename"
android:title="重命名" />
<item android:id="@+id/action_delete"
android:title="删除" />
</menu>
在适配器的 onBindViewHolder 中为每个 item 设置 setOnLongClickListener,长按时调用 showContextMenu 即可。这样菜单项始终与当前文件类型匹配,避免出现"对文件夹显示文本编辑"这类逻辑错误。
③ 目录内容加载与文件列表适配器实现
数据加载是文件管理器的核心引擎。我们需要编写一个异步任务或使用协程来扫描指定目录下的所有文件和子文件夹,避免阻塞主线程导致界面卡顿。扫描结果应封装成统一的 FileItem 数据模型,包含文件名、路径、大小、修改时间及类型标识。
首先定义一个轻量的数据模型,用于在列表与适配器之间传递文件信息:
kotlin
data class FileItem(
val name: String,
val path: String,
val size: Long,
val lastModified: Long,
val isDirectory: Boolean
) {
val extension: String
get() = if (isDirectory) "" else name.substringAfterLast('.', "").lowercase()
val isFile: Boolean get() = !isDirectory
}
目录扫描建议放在协程的 Dispatchers.IO 中执行,避免阻塞主线程。下面是一个基于协程的扫描封装,支持按需取消并返回排序后的结果:
kotlin
suspend fun scanDirectory(path: String): List<FileItem> = withContext(Dispatchers.IO) {
val dir = File(path)
if (!dir.exists() || !dir.isDirectory) return@withContext emptyList()
dir.listFiles()?.map { file ->
FileItem(
name = file.name,
path = file.absolutePath,
size = if (file.isFile) file.length() else 0L,
lastModified = file.lastModified(),
isDirectory = file.isDirectory
)
}?.sortedWith(
compareByDescending<FileItem> { it.isDirectory }
.thenBy { it.name.lowercase() }
) ?: emptyList()
}
在 ViewModel 或 Fragment 中调用时,通过 viewModelScope.launch 启动扫描,并在完成后更新列表数据。若目录较大,可结合 yield() 分批让出 CPU,保证界面始终流畅。 适配器(Adapter)的实现决定了列表的渲染效率。继承 RecyclerView.Adapter 并重写 onBindViewHolder 方法,根据文件类型动态加载不同的图标资源。为了提升滚动流畅度,务必在 ViewHolder 中缓存视图引用,并利用 Glide 或 Coil 等库异步加载缩略图(针对图片视频文件)。
下面是一个完整的 FileAdapter 实现,包含 ViewHolder 缓存、类型图标切换与点击/长按回调:
kotlin
class FileAdapter(
private val onItemClick: (FileItem) -> Unit,
private val onItemLongClick: (FileItem) -> Unit
) : RecyclerView.Adapter<FileAdapter.FileViewHolder>() {
private val items = mutableListOf<FileItem>()
fun submitList(newItems: List<FileItem>) {
items.clear()
items.addAll(newItems)
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FileViewHolder {
val binding = ItemFileBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return FileViewHolder(binding)
}
override fun onBindViewHolder(holder: FileViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
inner class FileViewHolder(private val binding: ItemFileBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: FileItem) {
binding.tvName.text = item.name
binding.tvInfo.text = if (item.isDirectory) {
"文件夹 · ${formatSize(item.size)}"
} else {
"${item.extension.uppercase()} · ${formatSize(item.size)}"
}
binding.ivIcon.setImageResource(
if (item.isDirectory) R.drawable.ic_folder else R.drawable.ic_file
)
binding.root.setOnClickListener { onItemClick(item) }
binding.root.setOnLongClickListener {
onItemLongClick(item)
true
}
}
}
private fun formatSize(size: Long): String {
val kb = size / 1024.0
return when {
kb < 1 -> "$size B"
kb < 1024 -> String.format("%.1f KB", kb)
else -> String.format("%.1f MB", kb / 1024)
}
}
}
同时,实现一个简单的排序逻辑,允许用户按名称、时间或大小对列表进行升序/降序排列。可以在 submitList 前传入排序方式,例如:
kotlin
fun sortBy(comparator: Comparator<FileItem>) {
items.sortWith(comparator)
notifyDataSetChanged()
}
// 按名称排序
sortBy(compareBy { it.name.lowercase() })
// 按修改时间倒序
sortBy(compareByDescending { it.lastModified })
// 按大小倒序
sortBy(compareByDescending { it.size })
这样数据加载、列表渲染与排序逻辑彼此解耦,后续新增排序维度或切换视图模式时,只需扩展适配器即可,无需改动扫描逻辑。
④ 文件夹创建与文件重命名功能编码
新建文件夹功能看似简单,实则涉及路径合法性校验。在接收到用户输入的文件夹名称后,首先需过滤非法字符(如 / \ : * ? " < > |),然后调用 File.mkdir() 或 Files.createDirectory() 执行创建。若返回失败,应立即捕获异常并向用户展示具体原因,如"名称已存在"或"磁盘空间不足"。
下面是一个完整的创建文件夹封装,包含名称校验、非法字符过滤与结果反馈:
kotlin
fun createFolder(parentPath: String, name: String): Boolean {
// 1. 过滤非法字符
val invalidChars = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|')
val cleanName = name.filterNot { it in invalidChars }.trim()
if (cleanName.isEmpty()) {
Toast.makeText(context, "文件夹名称不能为空或仅含非法字符", Toast.LENGTH_SHORT).show()
return false
}
// 2. 检查是否已存在同名文件夹
val target = File(parentPath, cleanName)
if (target.exists()) {
Toast.makeText(context, "同名文件夹已存在", Toast.LENGTH_SHORT).show()
return false
}
// 3. 执行创建并反馈结果
return try {
val created = target.mkdirs()
if (created) {
Toast.makeText(context, "文件夹创建成功", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "创建失败,请检查磁盘空间", Toast.LENGTH_SHORT).show()
}
created
} catch (e: SecurityException) {
Log.e(TAG, "createFolder: 权限不足", e)
Toast.makeText(context, "没有写入权限", Toast.LENGTH_SHORT).show()
false
}
}
在 UI 层,通过 AlertDialog 弹出输入框,让用户输入新文件夹名称,确认后调用上述方法:
kotlin
fun showCreateFolderDialog(parentPath: String) {
val input = EditText(this).apply {
hint = "请输入文件夹名称"
inputType = InputType.TYPE_CLASS_TEXT
}
AlertDialog.Builder(this)
.setTitle("新建文件夹")
.setView(input)
.setPositiveButton("创建") { _, _ ->
val name = input.text.toString()
if (createFolder(parentPath, name)) {
refreshCurrentDirectory() // 创建成功后刷新列表
}
}
.setNegativeButton("取消", null)
.show()
}
这里使用 mkdirs() 而非 mkdir(),它能一次性创建多级不存在的父目录,避免因父目录缺失导致创建失败。 文件重命名逻辑类似,但多了一步原子性操作的要求。使用 File.renameTo() 方法时,需注意该方法在不同文件系统上的行为差异,部分情况下可能返回 false 但未抛出异常。更稳健的做法是使用 NIO 包的 Files.move(),并指定 StandardCopyOption.ATOMIC_MOVE 以确保操作的原子性。
下面是一个基于 NIO 的重命名封装,兼顾原子性与错误反馈:
kotlin
fun renameFile(file: File, newName: String): Boolean {
// 1. 校验新名称合法性
val invalidChars = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|')
val cleanName = newName.filterNot { it in invalidChars }.trim()
if (cleanName.isEmpty()) {
Toast.makeText(context, "文件名不能为空或仅含非法字符", Toast.LENGTH_SHORT).show()
return false
}
// 2. 目标路径
val target = File(file.parentFile, cleanName)
if (target.exists()) {
Toast.makeText(context, "同名文件已存在", Toast.LENGTH_SHORT).show()
return false
}
// 3. 使用 NIO 原子移动
return try {
Files.move(
file.toPath(),
target.toPath(),
StandardCopyOption.ATOMIC_MOVE
)
Toast.makeText(context, "重命名成功", Toast.LENGTH_SHORT).show()
true
} catch (e: AtomicMoveNotSupportedException) {
// 部分文件系统不支持原子移动,降级为普通移动
try {
Files.move(file.toPath(), target.toPath())
Toast.makeText(context, "重命名成功", Toast.LENGTH_SHORT).show()
true
} catch (e2: IOException) {
Log.e(TAG, "renameFile: 重命名失败", e2)
Toast.makeText(context, "重命名失败:${e2.message}", Toast.LENGTH_SHORT).show()
false
}
} catch (e: IOException) {
Log.e(TAG, "renameFile: 重命名失败", e)
Toast.makeText(context, "重命名失败:${e.message}", Toast.LENGTH_SHORT).show()
false
}
}
在 UI 层面,重命名通常通过 EditText 对话框实现,记得在输入框默认填入原文件名并将光标置于末尾,方便用户快速修改:
kotlin
fun showRenameDialog(file: File) {
val input = EditText(this).apply {
setText(file.name)
setSelection(file.name.length) // 光标置于末尾
}
AlertDialog.Builder(this)
.setTitle("重命名")
.setView(input)
.setPositiveButton("确定") { _, _ ->
val newName = input.text.toString()
if (renameFile(file, newName)) {
refreshCurrentDirectory() // 重命名成功后刷新列表
}
}
.setNegativeButton("取消", null)
.show()
}
需要特别注意的是,ATOMIC_MOVE 并非所有文件系统都支持。代码中已做了降级处理:当抛出 AtomicMoveNotSupportedException 时,自动回退到普通 Files.move(),确保在 FAT32 等旧式文件系统上也能正常工作。此外,重命名前务必确认目标路径不存在同名文件,否则会静默覆盖导致数据丢失。
⑤ 文件删除操作与长按监听器逻辑
删除操作具有不可逆性,因此必须加入二次确认机制。当用户在长按菜单中选择"删除"时,弹出一个模态对话框,列出即将被删除的文件名清单,并要求用户再次点击确认。对于文件夹的删除,需递归遍历其下所有子文件,统计总数后告知用户,防止误删大量数据。
下面是一个带二次确认的删除封装,支持递归统计文件夹内容并给出明确反馈:
kotlin
fun confirmDelete(file: File) {
// 1. 统计待删除内容(文件夹需递归统计)
val fileCount = if (file.isDirectory) {
file.walkTopDown().count { it.isFile }
} else {
1
}
val message = if (file.isDirectory) {
"确定删除文件夹「${file.name}」吗?\n其中包含 $fileCount 个文件,此操作不可恢复!"
} else {
"确定删除文件「${file.name}」吗?此操作不可恢复!"
}
// 2. 二次确认对话框
AlertDialog.Builder(context)
.setTitle("删除确认")
.setMessage(message)
.setPositiveButton("删除") { _, _ ->
deleteRecursively(file)
}
.setNegativeButton("取消", null)
.show()
}
private fun deleteRecursively(file: File): Boolean {
return try {
val deleted = if (file.isDirectory) {
file.deleteRecursively()
} else {
file.delete()
}
if (deleted) {
Toast.makeText(context, "删除成功", Toast.LENGTH_SHORT).show()
refreshCurrentDirectory()
} else {
Toast.makeText(context, "删除失败,请检查权限", Toast.LENGTH_SHORT).show()
}
deleted
} catch (e: SecurityException) {
Log.e(TAG, "deleteRecursively: 权限不足", e)
Toast.makeText(context, "没有删除权限", Toast.LENGTH_SHORT).show()
false
}
}
这里使用 Kotlin 标准库的 File.deleteRecursively() 递归删除文件夹,它会自动遍历并删除所有子文件与子目录,比手写递归更简洁且不易出错。删除前务必确认目标路径存在,且不要在 IO 线程之外执行耗时操作。 长按监听器的实现依赖于 ItemTouchHelper 或原生的 OnLongClickListener。推荐使用 ItemTouchHelper,因为它天然支持滑动删除和拖拽排序,能极大丰富交互维度。在监听回调中,除了触发菜单显示,还应更新视觉状态,如改变背景色或显示选中勾选框,让用户清晰知道当前哪些文件处于操作选中状态。批量操作时,维护一个 Set<String> 集合来存储选中文件的路径,操作完成后及时清空状态。
下面是一个基于 ItemTouchHelper 的滑动删除与拖拽排序实现:
kotlin
class FileItemTouchHelperCallback(
private val onSwiped: (FileItem) -> Unit,
private val onMoved: (Int, Int) -> Unit
) : ItemTouchHelper.Callback() {
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
val swipeFlags = ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
return makeMovementFlags(dragFlags, swipeFlags)
}
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
onMoved(viewHolder.bindingAdapterPosition, target.bindingAdapterPosition)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
val position = viewHolder.bindingAdapterPosition
onSwiped(fileAdapter.getItemAt(position))
}
}
// 在 Activity 或 Fragment 中绑定
val callback = FileItemTouchHelperCallback(
onSwiped = { item -> confirmDelete(File(item.path)) },
onMoved = { from, to -> fileAdapter.moveItem(from, to) }
)
ItemTouchHelper(callback).attachToRecyclerView(fileRecyclerView)
同时,在适配器中补充 moveItem 与 getItemAt 方法,配合拖拽排序与滑动删除:
kotlin
fun moveItem(from: Int, to: Int) {
val item = items.removeAt(from)
items.add(to, item)
notifyItemMoved(from, to)
}
fun getItemAt(position: Int): FileItem = items[position]
对于批量操作场景,可在适配器中维护选中状态集合,长按时切换选中并更新背景色:
kotlin
private val selectedPaths = mutableSetOf<String>()
fun toggleSelection(item: FileItem): Boolean {
return if (!selectedPaths.add(item.path)) {
selectedPaths.remove(item.path)
false
} else {
true
}
}
fun clearSelection() {
selectedPaths.clear()
notifyDataSetChanged()
}
在 onBindViewHolder 中根据选中状态动态改变 item 背景,让用户直观看到当前选中项。操作完成后调用 clearSelection() 清空状态,避免残留导致误操作。
⑥ 文件复制粘贴机制与剪贴板管理
复制粘贴是文件管理器的高频功能。其本质是维护一个临时剪贴板状态,记录操作类型(复制/剪切)和源文件路径列表。我们可以创建一个单例类的 ClipboardManager,内部持有 List<File> 和 OperationType 枚举。
首先定义一个操作类型枚举与剪贴板单例,负责记录复制/剪切状态:
kotlin
enum class OperationType { COPY, CUT }
object ClipboardManager {
private val files = mutableListOf<File>()
var operationType: OperationType? = null
private set
fun setClipboard(type: OperationType, newFiles: List<File>) {
operationType = type
files.clear()
files.addAll(newFiles)
}
fun getFiles(): List<File> = files.toList()
fun clear() {
files.clear()
operationType = null
}
fun isEmpty(): Boolean = files.isEmpty()
}
在长按菜单中,复制与剪切只需调用 setClipboard 记录源文件与操作类型,并给出 Toast 反馈即可。注意剪切操作在真正粘贴成功前不要删除源文件,避免用户中途取消导致数据丢失。 当用户执行"粘贴"时,根据操作类型决定是拷贝还是移动。对于大文件复制,必须在后台线程中进行,并实时更新进度通知栏,允许用户取消操作。IO 流的处理建议使用 FileChannel 的 transferTo 方法,相比传统的字节数组缓冲,它在处理大文件时性能更优且内存占用更低。若遇到同名文件,策略通常是自动追加后缀(如 file(1).txt)或弹窗询问用户是否覆盖,切忌静默覆盖导致数据丢失。
下面是一个基于协程的粘贴封装,支持复制/剪切两种模式,并自动处理同名冲突:
kotlin
suspend fun pasteFiles(targetDir: File): Boolean = withContext(Dispatchers.IO) {
val clipboard = ClipboardManager
val type = clipboard.operationType ?: return@withContext false
val sources = clipboard.getFiles()
if (sources.isEmpty()) return@withContext false
var successCount = 0
for (source in sources) {
val target = resolveConflict(targetDir, source.name) ?: continue
try {
when (type) {
OperationType.COPY -> copyFile(source, target)
OperationType.CUT -> {
// 优先尝试原子移动,失败则降级为复制+删除
try {
Files.move(source.toPath(), target.toPath(), StandardCopyOption.ATOMIC_MOVE)
} catch (e: AtomicMoveNotSupportedException) {
copyFile(source, target)
source.delete()
}
}
}
successCount++
} catch (e: IOException) {
Log.e(TAG, "pasteFiles: 粘贴失败", e)
}
}
// 剪切模式粘贴完成后清空剪贴板
if (type == OperationType.CUT) clipboard.clear()
withContext(Dispatchers.Main) {
Toast.makeText(context, "粘贴完成:$successCount 个文件", Toast.LENGTH_SHORT).show()
refreshCurrentDirectory()
}
successCount > 0
}
// 同名冲突处理:自动追加 (1)、(2) 后缀
private fun resolveConflict(targetDir: File, name: String): File? {
var target = File(targetDir, name)
if (!target.exists()) return target
val dotIndex = name.lastIndexOf('.')
val base = if (dotIndex > 0) name.substring(0, dotIndex) else name
val ext = if (dotIndex > 0) name.substring(dotIndex) else ""
var index = 1
while (index < 1000) {
target = File(targetDir, "$base($index)$ext")
if (!target.exists()) return target
index++
}
return null
}
// 基于 FileChannel 的高效复制
private fun copyFile(source: File, target: File) {
FileChannel.open(source.toPath(), StandardOpenOption.READ).use { input ->
FileChannel.open(target.toPath(), StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE).use { output ->
input.transferTo(0, input.size(), output)
}
}
}
这里 resolveConflict 会在目标目录存在同名文件时自动追加 (1)、(2) 后缀,避免静默覆盖导致数据丢失。copyFile 使用 FileChannel.transferTo 完成零拷贝复制,大文件场景下内存占用极低。剪切模式优先尝试 ATOMIC_MOVE 原子移动,若文件系统不支持则降级为"复制 + 删除",确保在 FAT32 等旧式文件系统上也能正常工作。粘贴完成后记得调用 ClipboardManager.clear() 清空剪贴板状态,避免残留导致误操作。
⑦ 本地文件搜索服务与广播接收器应用
全局搜索功能依赖高效的遍历算法。简单的线性搜索在文件数量庞大时会导致 UI 冻结,因此应采用深度优先搜索(DFS)并结合协程的 yield 机制,每处理一定数量的文件就挂起一次,让出 CPU 时间片给主线程渲染。搜索结果可实时插入列表顶部,或通过专用适配器展示。
下面是一个基于协程的全局搜索封装,采用 DFS 遍历并配合 yield 分批让出 CPU,支持按文件名模糊匹配:
kotlin
suspend fun searchFiles(
rootPath: String,
keyword: String,
onResult: (FileItem) -> Unit
): List<FileItem> = withContext(Dispatchers.IO) {
val results = mutableListOf<FileItem>()
val root = File(rootPath)
if (!root.exists() || !root.isDirectory) return@withContext results
val stack = ArrayDeque<File>()
stack.add(root)
var processed = 0
while (stack.isNotEmpty()) {
val dir = stack.removeLast()
val children = dir.listFiles() ?: continue
for (file in children) {
// 每处理 50 个文件让出一次 CPU,保证界面流畅
if (++processed % 50 == 0) yield()
if (file.isDirectory) {
stack.add(file)
} else if (file.name.contains(keyword, ignoreCase = true)) {
val item = FileItem(
name = file.name,
path = file.absolutePath,
size = file.length(),
lastModified = file.lastModified(),
isDirectory = false
)
results.add(item)
onResult(item) // 实时回调,便于增量刷新 UI
}
}
}
results
}
在 ViewModel 或 Fragment 中调用时,通过 viewModelScope.launch 启动搜索,并实时更新列表。为避免频繁刷新导致卡顿,可结合 debounce 对输入框做防抖处理,例如用户停止输入 300ms 后才真正发起搜索:
kotlin
fun startSearch(keyword: String) {
searchJob?.cancel() // 取消上一次未完成的搜索
searchJob = viewModelScope.launch {
val results = searchFiles(
rootPath = Environment.getExternalStorageDirectory().absolutePath,
keyword = keyword
) { item ->
// 在主线程增量插入结果
withContext(Dispatchers.Main) {
searchAdapter.addResult(item)
}
}
// 全部完成后更新总数
searchAdapter.setTotal(results.size)
}
}
这里使用 ArrayDeque 作为栈实现非递归的深度优先遍历,避免递归过深导致栈溢出。yield() 每处理 50 个文件挂起一次,让主线程有机会渲染新结果,即使文件数量达到数万级也不会出现明显的 UI 卡顿。搜索任务通过 searchJob?.cancel() 支持取消,用户输入新关键词时自动终止旧任务,避免结果错乱。 广播接收器(BroadcastReceiver)在此处主要用于监听系统存储状态变化。例如,当外部 SD 卡被拔出或挂载时,系统会发送 ACTION_MEDIA_MOUNTED 或 ACTION_MEDIA_REMOVED 广播。注册一个静态或动态接收器,捕捉这些事件后刷新当前文件列表,避免出现"文件消失"或"路径无效"的假象。此外,还可以监听电池电量低的事件,暂停耗能的索引任务以节省资源。
下面是一个动态注册的存储状态监听器,在 Activity 的 onStart 中注册、onStop 中注销,避免内存泄漏:
kotlin
class StorageReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
Intent.ACTION_MEDIA_MOUNTED,
Intent.ACTION_MEDIA_UNMOUNTED,
Intent.ACTION_MEDIA_REMOVED,
Intent.ACTION_MEDIA_EJECT -> {
// 存储状态变化,刷新当前目录
refreshCurrentDirectory()
Toast.makeText(context, "存储状态已变化,列表已刷新", Toast.LENGTH_SHORT).show()
}
Intent.ACTION_BATTERY_LOW -> {
// 电量低时暂停耗能的索引任务
pauseIndexingTask()
}
}
}
}
// 在 Activity 中动态注册
private val storageReceiver = StorageReceiver()
override fun onStart() {
super.onStart()
val filter = IntentFilter().apply {
addAction(Intent.ACTION_MEDIA_MOUNTED)
addAction(Intent.ACTION_MEDIA_UNMOUNTED)
addAction(Intent.ACTION_MEDIA_REMOVED)
addAction(Intent.ACTION_MEDIA_EJECT)
addAction(Intent.ACTION_BATTERY_LOW)
addDataScheme("file") // 存储类广播必须声明 file scheme
}
registerReceiver(storageReceiver, filter)
}
override fun onStop() {
super.onStop()
unregisterReceiver(storageReceiver)
}
这里有几个关键细节:存储相关的广播(ACTION_MEDIA_MOUNTED 等)必须通过 addDataScheme("file") 声明数据协议,否则无法收到事件;接收器在 onStart 注册、onStop 注销,避免在 Activity 销毁后仍持有引用导致内存泄漏。对于 Android 8.0(API 26)及以上,静态注册的隐式广播大多已被限制,因此推荐统一使用动态注册方式。收到存储变化事件后刷新列表,能有效避免用户看到"文件消失"或"路径无效"的假象。
⑧ 调用系统默认程序打开各类文件
让文件"活"起来的关键在于正确调用系统 Intent。不同文件类型对应不同的 MIME Type,我们需要建立一个映射表,将文件后缀名(如 .pdf, .mp4, .docx)转换为标准的 MIME 字符串。若无法识别,则统一使用 application/octet-stream。
下面是一个后缀名到 MIME Type 的映射工具类,覆盖常见文档、图片、音视频与压缩包格式:
kotlin
object MimeTypeMap {
private val map = mapOf(
"pdf" to "application/pdf",
"doc" to "application/msword",
"docx" to "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"xls" to "application/vnd.ms-excel",
"xlsx" to "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"ppt" to "application/vnd.ms-powerpoint",
"pptx" to "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"txt" to "text/plain",
"md" to "text/markdown",
"json" to "application/json",
"xml" to "application/xml",
"html" to "text/html",
"htm" to "text/html",
"jpg" to "image/jpeg",
"jpeg" to "image/jpeg",
"png" to "image/png",
"gif" to "image/gif",
"bmp" to "image/bmp",
"webp" to "image/webp",
"svg" to "image/svg+xml",
"mp3" to "audio/mpeg",
"wav" to "audio/x-wav",
"flac" to "audio/flac",
"aac" to "audio/aac",
"mp4" to "video/mp4",
"mkv" to "video/x-matroska",
"avi" to "video/x-msvideo",
"mov" to "video/quicktime",
"3gp" to "video/3gpp",
"zip" to "application/zip",
"rar" to "application/vnd.rar",
"7z" to "application/x-7z-compressed",
"apk" to "application/vnd.android.package-archive"
)
fun fromExtension(extension: String): String {
return map[extension.lowercase()] ?: "application/octet-stream"
}
}
构建 Intent 时,动作设为 ACTION_VIEW,数据和类型分别设置为文件的 Uri 和 MIME Type。特别注意 Android 7.0 以上的 FileProvider 机制,不能直接传递 file:// 路径,必须通过 FileProvider.getUriForFile() 生成 content:// URI,并在 Intent 中添加 FLAG_GRANT_READ_URI_PERMISSION 标志,否则目标应用会因权限拒绝而崩溃。若系统中没有安装能处理该类型的应用,需捕获 ActivityNotFoundException 并提示用户安装相应软件。
下面是一个完整的打开文件封装,集成了 FileProvider、URI 授权与异常兜底:
kotlin
fun openWith(file: File) {
// 1. 通过 FileProvider 生成 content:// URI
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
// 2. 根据后缀名解析 MIME Type
val extension = file.extension.lowercase()
val mimeType = MimeTypeMap.fromExtension(extension)
// 3. 构建 ACTION_VIEW Intent
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
// 4. 尝试启动,捕获无可用应用的情况
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Toast.makeText(
context,
"没有找到能打开 ${file.name} 的应用,请安装相应软件",
Toast.LENGTH_SHORT
).show()
} catch (e: SecurityException) {
Toast.makeText(context, "打开文件失败:权限不足", Toast.LENGTH_SHORT).show()
}
}
对应的 res/xml/file_paths.xml 需要声明可共享的目录,例如:
xml
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="external_files" path="." />
<cache-path name="cache_files" path="." />
</paths>
同时在 AndroidManifest.xml 中注册 FileProvider:
xml
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
这里有几个关键细节:FileProvider 的 authorities 必须与 getUriForFile 中传入的第二个参数保持一致,否则会抛出 IllegalArgumentException;file_paths.xml 中 path="." 表示共享整个外部存储目录,若只想暴露特定子目录,可改为更精确的路径以缩小暴露面。FLAG_GRANT_READ_URI_PERMISSION 是必须的,它把读取权限临时授予目标应用,避免因 URI 权限不足导致崩溃。对于无法识别后缀的文件,统一回退到 application/octet-stream,系统会弹出"选择打开方式"列表,让用户自行挑选合适的应用。 构建 Intent 时,动作设为 ACTION_VIEW,数据和类型分别设置为文件的 Uri 和 MIME Type。特别注意 Android 7.0 以上的 FileProvider 机制,不能直接传递 file:// 路径,必须通过 FileProvider.getUriForFile() 生成 content:// URI,并在 Intent 中添加 FLAG_GRANT_READ_URI_PERMISSION 标志,否则目标应用会因权限拒绝而崩溃。若系统中没有安装能处理该类型的应用,需捕获 ActivityNotFoundException 并提示用户安装相应软件。
⑨ 内置文本编辑器与网页浏览器实现
虽然调用外部应用是首选,但提供轻量级的内置编辑器能显著提升体验。对于文本文件,可使用 EditText 结合 ScrollView 实现基础查看与编辑功能。进阶方案是集成开源的代码编辑组件,支持语法高亮和行号显示。加载大文本文件时,切忌一次性读入内存,应采用分段读取或 MappedByteBuffer 技术,仅渲染可视区域内容。
下面是一个基于 EditText 的轻量级文本编辑器封装,支持打开、编辑与保存文本文件:
kotlin
fun openTextEditor(file: File) {
// 1. 启动编辑器 Activity,传入文件路径
val intent = Intent(context, TextEditorActivity::class.java).apply {
putExtra("file_path", file.absolutePath)
}
context.startActivity(intent)
}
对应的 TextEditorActivity 实现如下,使用 EditText 承载内容,并在后台线程中完成读写,避免大文件阻塞主线程:
kotlin
class TextEditorActivity : AppCompatActivity() {
private lateinit var editText: EditText
private var filePath: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_text_editor)
editText = findViewById(R.id.editorContent)
filePath = intent.getStringExtra("file_path")
// 在 IO 线程读取文件内容
lifecycleScope.launch(Dispatchers.IO) {
val content = readTextFile(File(filePath!!))
withContext(Dispatchers.Main) {
editText.setText(content)
}
}
// 保存按钮
findViewById<Button>(R.id.btnSave).setOnClickListener {
saveFile()
}
}
private fun readTextFile(file: File): String {
return if (file.length() > 5 * 1024 * 1024) {
// 超过 5MB 的大文件,只读取前 5MB 预览
file.readBytes().take(5 * 1024 * 1024).toString(Charsets.UTF_8)
} else {
file.readText(Charsets.UTF_8)
}
}
private fun saveFile() {
val content = editText.text.toString()
lifecycleScope.launch(Dispatchers.IO) {
try {
File(filePath!!).writeText(content, Charsets.UTF_8)
withContext(Dispatchers.Main) {
Toast.makeText(this@TextEditorActivity, "保存成功", Toast.LENGTH_SHORT).show()
finish()
}
} catch (e: IOException) {
Log.e(TAG, "saveFile: 保存失败", e)
withContext(Dispatchers.Main) {
Toast.makeText(this@TextEditorActivity, "保存失败:${e.message}", Toast.LENGTH_SHORT).show()
}
}
}
}
}
对应的布局文件 activity_text_editor.xml 采用 LinearLayout 垂直排列,顶部为操作栏、中间为可滚动的编辑区:
xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<Button
android:id="@+id/btnSave"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="保存" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent">
<EditText
android:id="@+id/editorContent"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top|start"
android:inputType="textMultiLine"
android:scrollbars="vertical"
android:textSize="14sp" />
</ScrollView>
</LinearLayout>
这里有几个关键细节:大文件读取时做了截断保护,超过 5MB 只读取前 5MB 预览,避免一次性加载导致 OOM;保存操作放在 Dispatchers.IO 线程执行,防止大文件写入时阻塞主线程。若需要语法高亮与行号,可集成 io.github.rosemoe.sora:editor 等开源组件,其 CodeEditor 控件天然支持这些能力,只需将上述 EditText 替换为 CodeEditor 即可。
内置浏览器功能相对简单,利用 Android 自带的 WebView 组件即可。配置 WebSettings 开启 JavaScript 支持和缩放控制,并设置 WebViewClient 拦截页面加载过程,确保在当前应用内跳转而不是跳出到系统浏览器。对于 HTML 格式的本地文件,直接 loadUrl("file:///path/to/file.html") 即可预览。记得处理 WebView 的安全策略,禁止加载不可信的远程资源,防止 XSS 攻击。
下面是一个完整的 WebView 封装,支持本地 HTML 预览与远程页面加载:
kotlin
fun openWebView(file: File) {
val intent = Intent(context, WebViewActivity::class.java).apply {
putExtra("file_path", file.absolutePath)
}
context.startActivity(intent)
}
对应的 WebViewActivity 实现如下,配置了 JavaScript、缩放与安全策略:
kotlin
class WebViewActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_webview)
val webView = findViewById<WebView>(R.id.webView)
val filePath = intent.getStringExtra("file_path")
// 1. 配置 WebSettings
webView.settings.apply {
javaScriptEnabled = true // 开启 JavaScript
setSupportZoom(true) // 支持缩放
builtInZoomControls = true
displayZoomControls = false
loadWithOverviewMode = true
useWideViewPort = true
// 禁止加载远程资源,防止 XSS
setSupportMultipleWindows(false)
}
// 2. 设置 WebViewClient,拦截页面跳转
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
// 只允许加载 file:// 或 https:// 协议
val url = request?.url?.toString() ?: return true
return !(url.startsWith("file://") || url.startsWith("https://"))
}
}
// 3. 加载本地 HTML 文件
webView.loadUrl("file://$filePath")
}
// 处理返回键,让 WebView 回退历史而不是退出页面
override fun onBackPressed() {
val webView = findViewById<WebView>(R.id.webView)
if (webView.canGoBack()) {
webView.goBack()
} else {
super.onBackPressed()
}
}
}
对应的布局文件 activity_webview.xml 只需一个 WebView 占满全屏:
xml
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>
这里有几个关键细节:shouldOverrideUrlLoading 中限制了只允许 file:// 与 https:// 协议,拦截 http:// 等非可信来源,防止加载恶意远程页面;onBackPressed 中优先让 WebView 回退历史记录,而不是直接退出页面,符合用户预期。若需进一步加固安全,可在 WebViewClient 的 onPageStarted 中校验域名白名单,或在 WebChromeClient 中拦截 onJsAlert 等弹窗,避免恶意脚本骚扰用户。
⑩ 常见运行时异常排查与适配优化
在实际运行中,最频发的问题是 SecurityException 和 FileNotFoundException。前者多源于权限申请时机不当或 URI 暴露错误,后者则常因文件在操作过程中被其他进程占用或删除。排查此类问题时,日志是关键,务必在关键 IO 操作周围包裹 try-catch 块,并打印详细的堆栈信息。
下面是一个统一的异常处理工具类,集中捕获文件操作中的常见异常,并给出可读的错误提示:
kotlin
object FileExceptionHandler {
fun handle(e: Exception, action: String): String {
return when (e) {
is SecurityException -> {
Log.e(TAG, "handle: $action 权限不足", e)
"权限不足,请在系统设置中开启存储权限"
}
is FileNotFoundException -> {
Log.e(TAG, "handle: $action 文件不存在", e)
"文件不存在或已被移动/删除"
}
is IOException -> {
Log.e(TAG, "handle: $action IO 异常", e)
"文件读写失败:${e.message}"
}
is ActivityNotFoundException -> {
Log.e(TAG, "handle: $action 无可用应用", e)
"没有找到能处理该文件的应用"
}
else -> {
Log.e(TAG, "handle: $action 未知异常", e)
"操作失败:${e.message}"
}
}
}
}
在关键 IO 操作处统一调用,例如删除文件时:
kotlin
fun safeDelete(file: File): Boolean {
return try {
val deleted = if (file.isDirectory) file.deleteRecursively() else file.delete()
if (!deleted) {
Toast.makeText(context, "删除失败,请检查文件是否被占用", Toast.LENGTH_SHORT).show()
}
deleted
} catch (e: Exception) {
Toast.makeText(context, FileExceptionHandler.handle(e, "删除"), Toast.LENGTH_SHORT).show()
false
}
}
这里有几个关键细节:SecurityException 通常发生在权限被撤销或 URI 授权过期时,建议在每次操作前重新校验权限状态;FileNotFoundException 多因文件在操作间隙被其他进程删除,可在读取前先调用 file.exists() 做一次防御性检查。统一异常处理能避免散落的 try-catch 导致代码臃肿,也让错误提示保持一致。
针对 SecurityException,一个更彻底的方案是封装权限校验与自动申请逻辑,在每次文件操作前先确认权限是否仍然有效:
kotlin
fun ensurePermission(permission: String, onGranted: () -> Unit) {
if (ContextCompat.checkSelfPermission(context, permission)
== PackageManager.PERMISSION_GRANTED
) {
onGranted()
} else {
// 权限已失效,重新申请
permissionHelper.request(permission)
}
}
适配优化还涉及深色模式的支持、不同屏幕尺寸的布局调整以及低端机型的性能降级策略。例如,在内存受限设备上,自动关闭缩略图加载功能;在折叠屏设备上,充分利用大屏优势展示双栏布局。
下面是一个基于系统配置的适配策略封装,根据设备能力动态调整功能:
kotlin
object DeviceAdaptation {
// 判断是否为低内存设备
fun isLowMemoryDevice(context: Context): Boolean {
val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val memInfo = ActivityManager.MemoryInfo()
am.getMemoryInfo(memInfo)
return memInfo.lowMemory
}
// 判断是否为折叠屏/大屏设备
fun isLargeScreen(context: Context): Boolean {
val config = context.resources.configuration
return config.screenWidthDp >= 600
}
// 根据设备能力决定是否加载缩略图
fun shouldLoadThumbnail(context: Context): Boolean {
return !isLowMemoryDevice(context)
}
}
在适配器中应用上述策略,低内存设备自动关闭缩略图:
kotlin
// 在 FileAdapter 的 bind 方法中
if (DeviceAdaptation.shouldLoadThumbnail(context)) {
loadThumbnail(item, holder.binding.ivIcon)
} else {
holder.binding.ivIcon.setImageResource(
if (item.isDirectory) R.drawable.ic_folder else R.drawable.ic_file
)
}
最后,定期进行 LeakCanary 检测,确保文件流、Cursor 等资源被及时关闭,避免内存泄漏导致应用随使用时间增长而愈发卡顿。建议在 Application 中初始化 LeakCanary,并在代码审查时重点关注以下资源释放点:
kotlin
class FileManagerApp : Application() {
override fun onCreate() {
super.onCreate()
if (LeakCanary.isInAnalyzerProcess(this)) {
// 仅在分析进程中运行,避免重复初始化
return
}
LeakCanary.install(this)
}
}
资源释放的常见规范:使用 use {} 包裹 FileInputStream、FileOutputStream、Cursor 等可关闭资源,确保异常时也能自动关闭;WebView 在 onDestroy 中调用 destroy() 并移除引用;广播接收器在 onStop 中注销。通过这些细致的打磨,才能打造出一个既稳定又顺滑的文件管理工具。
⑪ 完整实战代码:一个可运行的 Android 文件管理器
前面十节分别讲解了文件管理器的各个核心模块,本节将它们整合成一个可直接运行的完整项目。下面给出关键文件的完整代码,涵盖 MainActivity、FileAdapter、FileItem 数据模型、权限处理、文件操作工具类以及布局资源,方便你对照搭建自己的项目。
11.1 项目结构一览
在动手搭建之前,先整体看一下项目的目录结构。这里采用 Android 官方推荐的单一 app 模块结构,代码按职责拆分为多个文件,每个文件只负责一类功能,便于后续维护与扩展:
text
app/
├── src/main/
│ ├── java/com/example/filemanager/
│ │ ├── MainActivity.kt // 主界面:目录浏览、菜单、搜索
│ │ ├── FileAdapter.kt // 文件列表适配器
│ │ ├── FileItem.kt // 文件数据模型
│ │ ├── PermissionHelper.kt // 权限请求封装
│ │ ├── FileOperations.kt // 创建/重命名/删除/复制粘贴
│ │ ├── FileSearch.kt // 全局搜索
│ │ ├── StorageReceiver.kt // 存储状态广播接收器
│ │ ├── TextEditorActivity.kt // 内置文本编辑器
│ │ └── WebViewActivity.kt // 内置网页浏览器
│ ├── res/
│ │ ├── layout/
│ │ │ ├── activity_main.xml
│ │ │ ├── item_file.xml
│ │ │ ├── activity_text_editor.xml
│ │ │ └── activity_webview.xml
│ │ ├── menu/context_menu.xml
│ │ └── xml/file_paths.xml
│ └── AndroidManifest.xml
└── build.gradle
各文件职责说明如下:
| 文件 | 职责 |
|---|---|
MainActivity.kt |
应用入口,负责目录浏览、顶部菜单、搜索框与存储广播的初始化,是各模块的调度中心 |
FileAdapter.kt |
RecyclerView 适配器,负责文件列表的渲染、排序、选中状态与点击/长按回调 |
FileItem.kt |
文件数据模型,封装文件名、路径、大小、修改时间与类型标识 |
PermissionHelper.kt |
基于协程的权限请求封装,按需申请并处理"永久拒绝"场景 |
FileOperations.kt |
文件操作工具类,集中实现创建、重命名、删除、复制粘贴等核心逻辑 |
FileSearch.kt |
全局搜索服务,基于 DFS 遍历并配合 yield 分批让出 CPU |
StorageReceiver.kt |
动态注册的广播接收器,监听存储挂载/卸载事件并刷新列表 |
TextEditorActivity.kt |
内置文本编辑器,支持打开、编辑与保存文本文件 |
WebViewActivity.kt |
内置网页浏览器,基于 WebView 预览本地 HTML 与远程页面 |
activity_main.xml |
主界面布局,包含搜索框、操作按钮与文件列表 |
item_file.xml |
文件列表项布局,展示图标、文件名与附加信息 |
activity_text_editor.xml |
文本编辑器布局,顶部保存按钮 + 可滚动编辑区 |
activity_webview.xml |
网页浏览器布局,一个 WebView 占满全屏 |
context_menu.xml |
长按菜单定义,包含打开、编辑、重命名、删除等操作项 |
file_paths.xml |
FileProvider 的路径映射配置,声明可共享的目录 |
AndroidManifest.xml |
声明权限、四大组件与 FileProvider 配置 |
说明 :
FileSearch.kt与StorageReceiver.kt在示例中也可直接写在MainActivity内部,这里拆分为独立文件是为了让职责更清晰。实际项目中若文件数量不多,合并到主 Activity 也能正常运行,读者可按需取舍。
11.2 数据模型 FileItem.kt
FileItem 是整个文件管理器的数据基石,它把文件系统中的一个文件或文件夹抽象成统一的数据模型,在扫描、列表渲染、排序、搜索等各个环节之间传递。设计上遵循「轻量 + 只读」原则:所有字段在构造时一次性赋值,后续只通过计算属性派生额外信息,避免在适配器中反复做字符串拼接或类型判断。
kotlin
data class FileItem(
val name: String,
val path: String,
val size: Long,
val lastModified: Long,
val isDirectory: Boolean
) {
val extension: String
get() = if (isDirectory) "" else name.substringAfterLast('.', "").lowercase()
val isFile: Boolean get() = !isDirectory
}
各字段与计算属性的作用如下:
| 成员 | 类型 | 说明 |
|---|---|---|
name |
String |
文件或文件夹的名称,用于列表展示与搜索匹配 |
path |
String |
文件的绝对路径,是后续所有文件操作的定位依据 |
size |
Long |
文件大小(字节)。文件夹统一为 0L,避免递归统计拖慢扫描 |
lastModified |
Long |
最后修改时间戳(毫秒),用于按时间排序 |
isDirectory |
Boolean |
是否为文件夹,决定图标、点击行为与菜单项 |
extension |
String(计算属性) |
文件后缀名(小写)。文件夹返回空字符串,避免误判 |
isFile |
Boolean(计算属性) |
是否为普通文件,等价于 !isDirectory,语义更清晰 |
几个值得注意的设计细节:
extension用substringAfterLast('.', ""):当文件名不含.(如README)时返回空字符串,而不是返回整个文件名,避免把无后缀文件误判成某种类型。- 后缀统一转小写 :
lowercase()保证Photo.JPG与photo.jpg被视为同一类型,在 MIME 映射和图标选择时不会因大小写不一致而漏匹配。 data class自动生成equals/hashCode:在批量选中、去重等场景可直接用FileItem作为集合元素,无需手写比较逻辑。- 文件夹的
size固定为0L:扫描时若对文件夹递归统计大小会显著拖慢首屏加载,这里刻意留空,需要时再按需计算。
说明 :若后续要支持「隐藏文件过滤」或「按类型分组」,可在
FileItem上追加计算属性(如isHidden、category),无需改动构造参数,保持对既有调用方完全兼容。
11.3 主界面 MainActivity.kt
MainActivity 是整个文件管理器的调度中心,负责把前面各节实现的模块串联起来:初始化列表适配器、绑定顶部菜单、接入搜索框、注册存储广播,并在启动时加载根目录。它的核心设计思路是「职责分离」------每个 setupXxx() 方法只负责一类初始化,onCreate 里按顺序调用,逻辑清晰、便于排查问题。
kotlin
class MainActivity : AppCompatActivity() {
private lateinit var fileAdapter: FileAdapter
private lateinit var permissionHelper: PermissionHelper
private lateinit var storageReceiver: StorageReceiver
private var currentPath: String = Environment.getExternalStorageDirectory().absolutePath
private var searchJob: Job? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
permissionHelper = PermissionHelper(this)
setupRecyclerView()
setupMenu()
setupSearch()
setupStorageReceiver()
refreshCurrentDirectory()
}
private fun setupRecyclerView() {
fileAdapter = FileAdapter(
onItemClick = { item ->
if (item.isDirectory) {
currentPath = item.path
refreshCurrentDirectory()
} else {
openWith(File(item.path))
}
},
onItemLongClick = { item ->
showContextMenu(findViewById(R.id.fileRecyclerView), item)
}
)
findViewById<RecyclerView>(R.id.fileRecyclerView).apply {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = fileAdapter
}
}
private fun setupMenu() {
// 顶部工具栏菜单:新建文件夹、搜索、刷新
findViewById<Button>(R.id.btnNewFolder).setOnClickListener {
ensurePermission(Manifest.permission.WRITE_EXTERNAL_STORAGE) {
showCreateFolderDialog(currentPath)
}
}
findViewById<Button>(R.id.btnRefresh).setOnClickListener {
refreshCurrentDirectory()
}
}
private fun setupSearch() {
val searchInput = findViewById<EditText>(R.id.etSearch)
searchInput.doAfterTextChanged { text ->
val keyword = text?.toString()?.trim().orEmpty()
if (keyword.length >= 2) {
startSearch(keyword)
} else {
searchJob?.cancel()
refreshCurrentDirectory()
}
}
}
private fun setupStorageReceiver() {
storageReceiver = StorageReceiver()
}
override fun onStart() {
super.onStart()
val filter = IntentFilter().apply {
addAction(Intent.ACTION_MEDIA_MOUNTED)
addAction(Intent.ACTION_MEDIA_UNMOUNTED)
addAction(Intent.ACTION_MEDIA_REMOVED)
addAction(Intent.ACTION_MEDIA_EJECT)
addDataScheme("file")
}
registerReceiver(storageReceiver, filter)
}
override fun onStop() {
super.onStop()
unregisterReceiver(storageReceiver)
}
private fun refreshCurrentDirectory() {
lifecycleScope.launch {
val items = scanDirectory(currentPath)
fileAdapter.submitList(items)
updateEmptyState(items.isEmpty())
}
}
private fun updateEmptyState(isEmpty: Boolean) {
findViewById<TextView>(R.id.emptyView).visibility =
if (isEmpty) View.VISIBLE else View.GONE
findViewById<RecyclerView>(R.id.fileRecyclerView).visibility =
if (isEmpty) View.GONE else View.VISIBLE
}
private fun startSearch(keyword: String) {
searchJob?.cancel()
searchJob = lifecycleScope.launch {
val results = searchFiles(
rootPath = Environment.getExternalStorageDirectory().absolutePath,
keyword = keyword
) { item ->
withContext(Dispatchers.Main) {
fileAdapter.submitList(listOf(item))
}
}
fileAdapter.submitList(results)
}
}
private fun ensurePermission(permission: String, onGranted: () -> Unit) {
if (ContextCompat.checkSelfPermission(this, permission)
== PackageManager.PERMISSION_GRANTED
) {
onGranted()
} else {
permissionHelper.request(permission)
}
}
}
各初始化方法与关键字段的作用如下:
| 成员 | 类型 | 说明 |
|---|---|---|
fileAdapter |
FileAdapter |
文件列表适配器,负责渲染、排序、选中与点击/长按回调 |
permissionHelper |
PermissionHelper |
权限请求封装,按需申请并处理"永久拒绝"场景 |
storageReceiver |
StorageReceiver |
存储状态广播接收器,监听挂载/卸载事件 |
currentPath |
String |
当前浏览目录的绝对路径,初始为外部存储根目录 |
searchJob |
Job? |
当前搜索协程的引用,用于输入新关键词时取消旧任务 |
setupRecyclerView() |
方法 | 创建适配器并绑定 LinearLayoutManager,定义点击/长按行为 |
setupMenu() |
方法 | 绑定顶部"新建""刷新"按钮,新建前先校验写入权限 |
setupSearch() |
方法 | 监听搜索框输入,关键词 ≥ 2 个字符时触发搜索,否则恢复目录浏览 |
setupStorageReceiver() |
方法 | 实例化存储广播接收器,在 onStart/onStop 中动态注册与注销 |
refreshCurrentDirectory() |
方法 | 在协程中扫描当前目录并刷新列表,同时更新空状态视图 |
startSearch(keyword) |
方法 | 启动全局搜索,先取消旧任务,结果实时增量插入列表 |
ensurePermission() |
方法 | 权限校验 + 自动申请的统一入口,授权后执行回调 |
几个值得注意的设计细节:
- 点击文件夹进入、点击文件打开 :
onItemClick中通过item.isDirectory分流------文件夹更新currentPath后重新扫描,普通文件则调用openWith()交给系统默认程序。这样目录浏览与文件打开两条路径互不干扰。 - 搜索与浏览的切换 :
setupSearch里以「关键词长度 ≥ 2」为阈值。输入时进入搜索模式并取消旧任务,清空输入框则自动回到目录浏览,避免搜索结果与目录列表互相污染。 - 广播接收器动态注册 :在
onStart注册、onStop注销,防止 Activity 销毁后仍持有引用导致内存泄漏。存储类广播必须addDataScheme("file")才能收到事件。 ensurePermission统一入口 :所有需要权限的操作(如新建文件夹)都先经过它校验,已授权直接执行回调,未授权则走PermissionHelper申请,避免散落的权限判断逻辑。lifecycleScope自动取消 :扫描与搜索都挂在lifecycleScope上,Activity 销毁时协程自动取消,不会出现后台任务更新已销毁界面的崩溃。
说明 :
openWith、showContextMenu、showCreateFolderDialog等方法在本文前面各节已给出实现,这里不再重复贴出。若你的项目把FileSearch与StorageReceiver合并进MainActivity,只需把对应方法体搬入即可,调度逻辑保持不变。
11.4 文件列表适配器 FileAdapter.kt
FileAdapter 是 RecyclerView 与数据之间的桥梁,负责把 FileItem 列表渲染成可交互的界面。它同时承担了三类职责:列表渲染 (ViewHolder 缓存与绑定)、选中状态管理 (批量操作时的背景高亮)、排序与移动(配合拖拽排序与滑动删除)。设计上遵循「数据与视图分离」------适配器只关心如何展示,不关心数据从哪来,因此扫描、搜索等逻辑可以独立演进。
kotlin
class FileAdapter(
private val onItemClick: (FileItem) -> Unit,
private val onItemLongClick: (FileItem) -> Unit
) : RecyclerView.Adapter<FileAdapter.FileViewHolder>() {
private val items = mutableListOf<FileItem>()
private val selectedPaths = mutableSetOf<String>()
fun submitList(newItems: List<FileItem>) {
items.clear()
items.addAll(newItems)
notifyDataSetChanged()
}
fun sortBy(comparator: Comparator<FileItem>) {
items.sortWith(comparator)
notifyDataSetChanged()
}
fun moveItem(from: Int, to: Int) {
val item = items.removeAt(from)
items.add(to, item)
notifyItemMoved(from, to)
}
fun getItemAt(position: Int): FileItem = items[position]
fun toggleSelection(item: FileItem): Boolean {
return if (!selectedPaths.add(item.path)) {
selectedPaths.remove(item.path)
false
} else {
true
}
}
fun clearSelection() {
selectedPaths.clear()
notifyDataSetChanged()
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FileViewHolder {
val binding = ItemFileBinding.inflate(
LayoutInflater.from(parent.context), parent, false
)
return FileViewHolder(binding)
}
override fun onBindViewHolder(holder: FileViewHolder, position: Int) {
holder.bind(items[position])
}
override fun getItemCount(): Int = items.size
inner class FileViewHolder(private val binding: ItemFileBinding) :
RecyclerView.ViewHolder(binding.root) {
fun bind(item: FileItem) {
binding.tvName.text = item.name
binding.tvInfo.text = if (item.isDirectory) {
"文件夹 · ${formatSize(item.size)}"
} else {
"${item.extension.uppercase()} · ${formatSize(item.size)}"
}
binding.ivIcon.setImageResource(
if (item.isDirectory) R.drawable.ic_folder else R.drawable.ic_file
)
binding.root.setBackgroundColor(
if (selectedPaths.contains(item.path)) {
Color.parseColor("#33009688")
} else {
Color.TRANSPARENT
}
)
binding.root.setOnClickListener { onItemClick(item) }
binding.root.setOnLongClickListener {
onItemLongClick(item)
true
}
}
}
private fun formatSize(size: Long): String {
val kb = size / 1024.0
return when {
kb < 1 -> "$size B"
kb < 1024 -> String.format("%.1f KB", kb)
else -> String.format("%.1f MB", kb / 1024)
}
}
}
各成员与方法的职责如下:
| 成员 | 类型 | 说明 |
|---|---|---|
items |
MutableList<FileItem> |
当前展示的文件列表,是适配器的数据源 |
selectedPaths |
MutableSet<String> |
选中文件的路径集合,用于批量操作与背景高亮 |
submitList(newItems) |
方法 | 整体替换列表数据并刷新,扫描/搜索完成后调用 |
sortBy(comparator) |
方法 | 按传入的比较器排序并刷新,支持名称/时间/大小等维度 |
moveItem(from, to) |
方法 | 拖拽排序时移动元素位置,配合 ItemTouchHelper 使用 |
getItemAt(position) |
方法 | 按位置取回 FileItem,滑动删除时定位目标文件 |
toggleSelection(item) |
方法 | 切换选中状态,返回是否处于选中态,供长按菜单判断 |
clearSelection() |
方法 | 清空所有选中状态并刷新,操作完成后调用避免残留 |
onCreateViewHolder |
重写方法 | 通过 ViewBinding 创建 ViewHolder,复用视图引用 |
onBindViewHolder |
重写方法 | 绑定数据到视图,设置文本、图标、背景与点击/长按回调 |
getItemCount |
重写方法 | 返回列表长度,RecyclerView 据此决定渲染多少项 |
FileViewHolder.bind(item) |
方法 | 核心绑定逻辑,把单个 FileItem 渲染到一行 item 上 |
formatSize(size) |
方法 | 把字节数格式化为 B / KB / MB 的可读字符串 |
几个值得注意的设计细节:
- ViewHolder 缓存视图引用 :
FileViewHolder持有ItemFileBinding,onBindViewHolder只做数据绑定,不做findViewById,避免每次滚动都重新查找视图,这是RecyclerView流畅滚动的关键。 - 选中状态用
Set<String>而非Set<FileItem>:以path作为唯一标识,即使列表刷新后FileItem对象被重建,选中状态依然能通过路径正确匹配,不会因对象引用变化而丢失。 toggleSelection返回布尔值:长按菜单可以根据返回值判断「本次是选中还是取消选中」,从而决定是继续弹出操作菜单还是仅取消高亮,交互更自然。moveItem用notifyItemMoved而非notifyDataSetChanged:拖拽排序时只通知位置变化,RecyclerView会做局部动画,比整体刷新更流畅、开销更小。formatSize的边界处理 :小于 1KB 直接显示字节数,1KB~1MB 显示 KB,更大则显示 MB,避免出现0.0 KB这类不友好的文案。- 背景色用半透明色值 :
#33009688是带透明度的青色,选中项高亮但不遮挡图标与文字,视觉上更清爽。
说明 :
ItemFileBinding由item_file.xml自动生成,对应布局在 11.8 节给出。若你的项目不使用 ViewBinding,也可在onCreateViewHolder中手动inflate布局并findViewById,逻辑完全一致。moveItem与getItemAt配合第⑤节的ItemTouchHelper使用,实现拖拽排序与滑动删除。
11.5 文件操作工具类 FileOperations.kt
FileOperations 是一个纯静态工具类,把文件管理器中所有「写操作」收敛到一处,统一处理名称校验、异常降级、同名冲突 等边界问题。它不持有任何界面状态,也不依赖 Context,因此可以在协程、适配器、长按菜单等任意位置直接调用,职责单一、便于复用。
kotlin
object FileOperations {
private val invalidChars = setOf('/', '\\', ':', '*', '?', '"', '<', '>', '|')
fun createFolder(parentPath: String, name: String): Boolean {
val cleanName = name.filterNot { it in invalidChars }.trim()
if (cleanName.isEmpty()) return false
val target = File(parentPath, cleanName)
if (target.exists()) return false
return try {
target.mkdirs()
} catch (e: SecurityException) {
Log.e("FileOps", "createFolder: 权限不足", e)
false
}
}
fun renameFile(file: File, newName: String): Boolean {
val cleanName = newName.filterNot { it in invalidChars }.trim()
if (cleanName.isEmpty()) return false
val target = File(file.parentFile, cleanName)
if (target.exists()) return false
return try {
Files.move(
file.toPath(),
target.toPath(),
StandardCopyOption.ATOMIC_MOVE
)
true
} catch (e: AtomicMoveNotSupportedException) {
try {
Files.move(file.toPath(), target.toPath())
true
} catch (e2: IOException) {
Log.e("FileOps", "renameFile: 降级移动失败", e2)
false
}
} catch (e: IOException) {
Log.e("FileOps", "renameFile: 重命名失败", e)
false
}
}
fun deleteRecursively(file: File): Boolean {
return try {
if (file.isDirectory) file.deleteRecursively() else file.delete()
} catch (e: SecurityException) {
Log.e("FileOps", "deleteRecursively: 权限不足", e)
false
}
}
suspend fun pasteFiles(targetDir: File): Boolean = withContext(Dispatchers.IO) {
val clipboard = ClipboardManager
val type = clipboard.operationType ?: return@withContext false
val sources = clipboard.getFiles()
if (sources.isEmpty()) return@withContext false
var successCount = 0
for (source in sources) {
val target = resolveConflict(targetDir, source.name) ?: continue
try {
when (type) {
OperationType.COPY -> copyFile(source, target)
OperationType.CUT -> {
try {
Files.move(
source.toPath(),
target.toPath(),
StandardCopyOption.ATOMIC_MOVE
)
} catch (e: AtomicMoveNotSupportedException) {
copyFile(source, target)
source.delete()
}
}
}
successCount++
} catch (e: IOException) {
Log.e("FileOps", "pasteFiles: 粘贴失败", e)
}
}
if (type == OperationType.CUT) clipboard.clear()
successCount > 0
}
private fun resolveConflict(targetDir: File, name: String): File? {
var target = File(targetDir, name)
if (!target.exists()) return target
val dotIndex = name.lastIndexOf('.')
val base = if (dotIndex > 0) name.substring(0, dotIndex) else name
val ext = if (dotIndex > 0) name.substring(dotIndex) else ""
var index = 1
while (index < 1000) {
target = File(targetDir, "$base($index)$ext")
if (!target.exists()) return target
index++
}
return null
}
private fun copyFile(source: File, target: File) {
FileChannel.open(source.toPath(), StandardOpenOption.READ).use { input ->
FileChannel.open(
target.toPath(),
StandardOpenOption.CREATE_NEW,
StandardOpenOption.WRITE
).use { output ->
input.transferTo(0, input.size(), output)
}
}
}
}
各方法与字段的职责如下:
| 成员 | 类型 | 说明 |
|---|---|---|
invalidChars |
Set<Char> |
非法文件名字符集合,创建/重命名前统一过滤 |
createFolder(parentPath, name) |
方法 | 校验名称合法性后创建文件夹,返回是否成功 |
renameFile(file, newName) |
方法 | 基于 NIO 原子移动重命名,不支持时降级为普通移动 |
deleteRecursively(file) |
方法 | 递归删除文件夹或删除单个文件,捕获权限异常 |
pasteFiles(targetDir) |
挂起方法 | 在 IO 线程执行粘贴,支持复制/剪切两种模式并自动处理同名冲突 |
resolveConflict(targetDir, name) |
私有方法 | 目标存在同名文件时自动追加 (1)、(2) 后缀,返回去重后的目标文件 |
copyFile(source, target) |
私有方法 | 用 FileChannel.transferTo 零拷贝复制文件,性能优于逐字节读写 |
几个值得注意的设计细节:
- 纯静态、无状态 :
FileOperations用object声明,所有方法都是静态的,不持有Context或界面引用。这样既能在协程里安全调用,也方便单元测试------传入File即可验证逻辑,无需启动模拟器。 - 名称校验前置 :创建与重命名都先过滤非法字符并
trim(),空名称直接返回false。把校验收敛到工具类,UI 层就不必重复写过滤逻辑,只需根据返回值决定是否刷新列表。 ATOMIC_MOVE降级链 :重命名优先尝试原子移动,捕获AtomicMoveNotSupportedException后降级为普通Files.move();粘贴的剪切模式同样如此,降级时先复制再删除源文件,保证数据不丢失。resolveConflict自动去重 :粘贴时若目标已存在同名文件,自动追加(1)、(2)后缀,而不是静默覆盖。base(1)ext的命名规则对带扩展名的文件(如report.pdf→report(1).pdf)和无扩展名文件(如README→README(1))都适用。FileChannel.transferTo零拷贝 :复制文件用FileChannel的transferTo,由内核直接完成数据搬运,比InputStream/OutputStream逐字节拷贝快得多,适合大文件场景。- 剪切成功才清空剪贴板 :
pasteFiles只在OperationType.CUT且全部粘贴完成后才调用clipboard.clear(),避免用户中途取消或部分失败时源文件被误删。
说明 :
pasteFiles依赖第⑥节的ClipboardManager单例与OperationType枚举,二者在 11.6 节给出。deleteRecursively使用 Kotlin 标准库的File.deleteRecursively(),会自动遍历并删除所有子文件与子目录,比手写递归更简洁。若你的项目需要进度反馈,可在pasteFiles中通过回调把「已复制 x / 共 y」实时抛给 UI 层。
11.6 剪贴板管理 ClipboardManager.kt
ClipboardManager 是一个单例对象,负责维护文件管理器的「临时剪贴板」状态。它不直接操作文件,只记录两件事:操作类型 (复制还是剪切)与源文件路径列表。之所以用单例,是因为复制/剪切与粘贴往往发生在不同的界面时机------用户可能在某个目录长按选择「复制」,再切换到另一个目录执行「粘贴」,中间隔着多次列表刷新,只有全局单例才能让状态跨界面稳定传递。
kotlin
enum class OperationType { COPY, CUT }
object ClipboardManager {
private val files = mutableListOf<File>()
var operationType: OperationType? = null
private set
fun setClipboard(type: OperationType, newFiles: List<File>) {
operationType = type
files.clear()
files.addAll(newFiles)
}
fun getFiles(): List<File> = files.toList()
fun clear() {
files.clear()
operationType = null
}
fun isEmpty(): Boolean = files.isEmpty()
}
各成员与方法的职责如下:
| 成员 | 类型 | 说明 |
|---|---|---|
OperationType |
枚举 | 定义操作类型:COPY(复制)与 CUT(剪切) |
files |
MutableList<File> |
剪贴板中记录的源文件路径列表,私有不可直接访问 |
operationType |
OperationType? |
当前操作类型,private set 保证只能通过 setClipboard 修改 |
setClipboard(type, newFiles) |
方法 | 设置操作类型并整体替换源文件列表,长按菜单调用 |
getFiles() |
方法 | 返回源文件列表的只读副本,粘贴时遍历使用 |
clear() |
方法 | 清空剪贴板,剪切粘贴成功后调用 |
isEmpty() |
方法 | 判断剪贴板是否为空,用于菜单项可见性控制 |
几个值得注意的设计细节:
operationType用private set保护 :外部只能通过setClipboard一次性设置类型与文件列表,避免出现「类型是剪切、文件却是复制残留」的不一致状态。这是剪贴板正确性的关键约束。getFiles()返回副本而非原引用 :files.toList()生成新列表返回,外部拿到后无论怎么增删都不会污染剪贴板内部数据,防止粘贴逻辑误改源状态。- 复制与剪切共用同一份存储 :两者都只是「记录源文件」,真正的差异体现在粘贴时------
COPY直接复制文件,CUT移动文件并在成功后清空剪贴板。因此剪贴板本身不需要区分存储结构,只需一个OperationType标记。 - 剪切成功才
clear():clear()由FileOperations.pasteFiles在剪切模式全部粘贴完成后调用,而不是在用户点击「剪切」时就清空。这样即使粘贴中途失败或用户取消,源文件也不会被误删。 isEmpty()服务菜单可见性:长按菜单可以根据它动态隐藏「粘贴」项------剪贴板为空时粘贴没有意义,避免用户点了没反应。
说明 :
ClipboardManager与OperationType被 11.5 节的FileOperations.pasteFiles直接依赖,二者共同构成第⑥节「文件复制粘贴机制」的完整实现。若你的项目需要支持「多选批量复制」,只需在setClipboard传入文件列表即可,单例结构无需改动。
11.7 主界面布局 activity_main.xml
activity_main.xml 是文件管理器的「门面」,承载了搜索、新建、刷新与文件列表四大核心区域。整体采用垂直 LinearLayout 作为根容器,自上而下分为「顶部操作栏」与「文件列表区」两部分,结构清晰、职责单一,便于后续扩展。
xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<!-- 顶部操作栏 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="8dp">
<EditText
android:id="@+id/etSearch"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="搜索文件..."
android:inputType="text" />
<Button
android:id="@+id/btnNewFolder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="新建" />
<Button
android:id="@+id/btnRefresh"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="刷新" />
</LinearLayout>
<!-- 文件列表 -->
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/fileRecyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="8dp"
android:clipToPadding="false"
android:scrollbars="vertical" />
<TextView
android:id="@+id/emptyView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="当前目录为空"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>
各控件的作用如下:
| 控件 ID | 类型 | 职责 |
|---|---|---|
etSearch |
EditText |
顶部搜索框,输入关键字后触发第⑦节的全局搜索,配合 debounce 防抖 |
btnNewFolder |
Button |
新建文件夹入口,点击后弹出第④节的 showCreateFolderDialog |
btnRefresh |
Button |
手动刷新当前目录,调用 refreshCurrentDirectory() 重新扫描 |
fileRecyclerView |
RecyclerView |
文件列表容器,绑定第③节的 FileAdapter,展示扫描结果 |
emptyView |
TextView |
空状态提示,目录为空时显示「当前目录为空」,配合 updateEmptyState 切换 |
几个值得注意的设计细节:
- 搜索框用
layout_weight="1"撑满剩余空间 :顶部操作栏是水平LinearLayout,搜索框宽度设为0dp并赋予权重 1,两个按钮用wrap_content固定宽度。这样无论屏幕多宽,搜索框都会自动占满按钮之外的剩余区域,适配不同尺寸设备。 clipToPadding="false"让滚动内容不被内边距裁剪 :RecyclerView设置了8dp内边距,配合clipToPadding="false",列表滚动到边缘时内容会平滑滑入内边距区域,而不是被生硬裁切,视觉更自然。- 空状态视图用
FrameLayout叠加 :RecyclerView与emptyView放在同一个FrameLayout中,空状态视图通过layout_gravity="center"居中显示,并用visibility="gone"默认隐藏。这样列表与空提示互斥显示,逻辑清晰。 inputType="text"明确输入类型 :搜索框显式声明inputType="text",避免系统弹出数字键盘或带自动纠错的键盘,提升中文与文件名的输入体验。- 按钮文案简短直接:「新建」「刷新」两个按钮用两个字概括核心动作,配合顶部搜索框,用户一眼就能看懂主界面的三大操作入口,符合移动端「少即是多」的交互原则。
说明 :
fileRecyclerView与emptyView的切换逻辑在 11.3 节的MainActivity.kt中通过updateEmptyState(isEmpty)实现;etSearch的搜索回调同样在MainActivity中绑定,配合第⑦节的searchFiles协程封装完成全局搜索。若你的项目需要「多选批量操作」,可在顶部操作栏追加「全选」「删除选中」等按钮,布局结构无需大改。
11.8 列表项布局 item_file.xml
item_file.xml 是 RecyclerView 中每一行文件条目的布局,对应 11.4 节 FileAdapter 里通过 ItemFileBinding 绑定的视图。它承载了「图标 + 名称 + 信息」三要素,是文件列表最基础、也最常被复用的单元。整体采用水平 LinearLayout,左侧固定图标、右侧垂直排列名称与信息,结构简单、渲染高效。
xml
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:background="?attr/selectableItemBackground">
<!-- 文件类型图标 -->
<ImageView
android:id="@+id/ivIcon"
android:layout_width="40dp"
android:layout_height="40dp"
android:contentDescription="文件图标"
android:src="@drawable/ic_file" />
<!-- 名称与信息 -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp"
android:orientation="vertical">
<TextView
android:id="@+id/tvName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="1"
android:ellipsize="middle"
android:textSize="16sp"
android:textColor="@android:color/black"
android:textStyle="bold" />
<TextView
android:id="@+id/tvInfo"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:maxLines="1"
android:ellipsize="end"
android:textSize="12sp"
android:textColor="#888888" />
</LinearLayout>
</LinearLayout>
各控件的作用如下:
| 控件 ID | 类型 | 职责 |
|---|---|---|
ivIcon |
ImageView |
文件类型图标,文件夹显示 ic_folder、文件显示 ic_file,由 FileAdapter 按类型切换 |
tvName |
TextView |
文件名或文件夹名,加粗显示,超长时中间省略 |
tvInfo |
TextView |
辅助信息,显示「文件夹 · 大小」或「扩展名 · 大小」,灰色小字 |
几个值得注意的设计细节:
selectableItemBackground提供点击反馈 :根布局设置?attr/selectableItemBackground,点击或长按时系统自动播放水波纹效果,无需手写按压态背景,交互更原生。ellipsize="middle"保护文件名可读性 :文件名通常较长,tvName用maxLines="1"加ellipsize="middle",超长时省略中间而非末尾,保留扩展名,用户一眼能看出文件类型。layout_weight="1"让信息区自适应 :右侧信息区宽度设为0dp并赋予权重 1,图标固定 40dp,无论屏幕多宽,名称与信息都能占满剩余空间,避免长文件名挤压图标。contentDescription提升无障碍体验 :ivIcon声明contentDescription="文件图标",屏幕阅读器可正确朗读,符合无障碍规范。- 内边距统一、行高适中 :上下
8dp、左右12dp的内边距让每行有足够的触控热区,配合 40dp 图标,整体行高约 56dp,符合 Material Design 的列表项推荐高度。
说明 :
ItemFileBinding由本布局自动生成,字段名ivIcon、tvName、tvInfo与 11.4 节FileAdapter中的绑定代码一一对应。若你的项目不使用 ViewBinding,可在onCreateViewHolder中手动inflate(R.layout.item_file, parent, false)并findViewById获取这三个控件,逻辑完全一致。图标资源ic_folder与ic_file需自行准备,放在res/drawable目录即可。
11.9 AndroidManifest.xml 关键配置
一个可运行的 Android 项目,除了代码与布局,还离不开资源文件与清单配置。11.8 节的 item_file.xml 引用了 ic_folder、ic_file 两个图标,11.3 节的 MainActivity 需要声明权限与入口 Activity,这些都要在 res/drawable 与 AndroidManifest.xml 中补齐。本节把项目运行所需的资源与清单一次性讲清楚,避免你照着代码敲完却因缺资源而编译失败。
11.9.1 图标资源 ic_folder.xml 与 ic_file.xml
图标可以用矢量图(VectorDrawable)实现,体积小、适配任意分辨率,无需为每个密度单独切图。下面给出两个简洁的矢量图标,放在 res/drawable/ 目录即可:
xml
<!-- res/drawable/ic_folder.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFB300">
<path
android:fillColor="#FFB300"
android:pathData="M10,4H4C2.9,4 2,4.9 2,6V18C2,19.1 2.9,20 4,20H20C21.1,20 22,19.1 22,18V8C22,6.9 21.1,6 20,6H12L10,4Z" />
</vector>
xml
<!-- res/drawable/ic_file.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#607D8B">
<path
android:fillColor="#607D8B"
android:pathData="M14,2H6C4.9,2 4,2.9 4,4V20C4,21.1 4.9,22 6,22H18C19.1,22 20,21.1 20,20V8L14,2ZM16,18H8V16H16V18ZM16,14H8V12H16V14ZM13,9V3.5L18.5,9H13Z" />
</vector>
两个图标的要点:
android:tint统一着色 :矢量图用tint指定颜色,ic_folder用琥珀色#FFB300区分文件夹,ic_file用蓝灰色#607D8B表示普通文件,视觉层次清晰。viewportWidth/Height与width/height分离:前者定义绘制坐标系(24×24),后者定义实际显示尺寸(24dp)。这样图标在不同密度下自动缩放,无需多套切图。pathData直接复用 Material 图标路径:文件夹与文件图标均取自 Material Design 标准路径,语义明确、无需自行设计,直接可用。
说明 :若你希望图标随选中状态变色,可在
FileAdapter的bind中通过binding.ivIcon.setColorFilter(...)动态调整,或在res/color中定义选择器。这里保持静态着色,逻辑最简单。
11.9.2 AndroidManifest.xml 清单配置
AndroidManifest.xml 是应用的「身份证」,声明了入口 Activity、存储权限与 FileProvider。结合第①节的权限策略,完整清单如下:
xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.filemanager">
<!-- 存储权限:Android 10 以下生效,10+ 由分区存储接管 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="文件管理器"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<!-- FileProvider:供第⑧节调用系统打开文件时共享 URI -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
清单配置的要点:
maxSdkVersion限制旧权限范围 :READ_EXTERNAL_STORAGE只在 API ≤ 32 生效,WRITE_EXTERNAL_STORAGE只在 API ≤ 28 生效,与第①节的权限策略完全一致,避免在分区存储设备上声明无效权限引发审核问题。android:exported="true"声明启动入口 :MainActivity作为 LAUNCHER 必须显式声明exported="true",这是 Android 12(API 31)起对带 intent-filter 组件的强制要求,漏写会导致安装后无法启动。- FileProvider 用于跨应用共享 URI :第⑧节调用系统默认程序打开文件时,需要把
file://转成content://授权 URI,FileProvider正是为此服务。authorities用${applicationId}.fileprovider动态拼接,避免硬编码包名。
11.9.3 FileProvider 路径配置 res/xml/file_paths.xml
FileProvider 需要一份路径映射文件,告诉系统哪些目录可以被授权共享。这里把应用专属外部目录与缓存目录都暴露出来:
xml
<!-- res/xml/file_paths.xml -->
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path
name="external_files"
path="." />
<cache-path
name="cache_files"
path="." />
</paths>
路径配置的要点:
external-path覆盖外部存储根目录 :path="."表示授权整个外部存储,配合grantUriPermissions="true",第⑧节打开任意文件时都能生成可用的content://URI。cache-path覆盖缓存目录 :若你的应用把临时文件放在cacheDir,也需要在此声明,否则 FileProvider 无法共享缓存中的文件。name只是别名 :name用于生成 URI 路径段,不影响实际授权范围,可自定义,但建议语义化便于排查。
说明 :
file_paths.xml必须放在res/xml/目录,且文件名与清单中android:resource="@xml/file_paths"保持一致。若你的项目需要共享getExternalFilesDir()下的文件,可追加<external-files-path name="app_files" path="." />。
11.9.4 资源清单小结
至此,一个可运行的 Android 文件管理器所需的资源已全部就绪。把 11.1 至 11.9 的代码、布局、资源与清单组合起来,即可编译运行。下表汇总了本节涉及的文件与用途:
| 文件 | 位置 | 用途 |
|---|---|---|
ic_folder.xml |
res/drawable/ |
文件夹图标,FileAdapter 按 isDirectory 切换 |
ic_file.xml |
res/drawable/ |
普通文件图标,FileAdapter 默认使用 |
AndroidManifest.xml |
项目根目录 | 声明入口 Activity、存储权限与 FileProvider |
file_paths.xml |
res/xml/ |
FileProvider 的路径映射,授权共享目录 |
几个值得注意的收尾细节:
- 图标与布局解耦 :图标资源独立于
item_file.xml,FileAdapter通过R.drawable.ic_folder/R.drawable.ic_file引用,后续想换图标只需替换 drawable,无需改动代码。 - 权限与清单一致 :清单中的
maxSdkVersion与第①节的权限策略一一对应,运行时再配合PermissionHelper动态申请,形成「清单声明 + 运行时申请」的双层保障。 - FileProvider 是第⑧节的前提 :没有
file_paths.xml,调用系统打开文件时会抛出FileUriExposedException崩溃。务必保证清单、file_paths.xml、authorities三者一致。
说明 :
ic_launcher应用图标未在本节给出,它是 Android 项目的默认启动图标,可由 Android Studio 的 Image Asset 工具自动生成,不影响文件管理器核心逻辑。若你希望自定义,在res/mipmap中替换同名资源即可。