发布于 2026-01-06 1 阅读
0

如何在 Android 上计算目录大小?DEV 全球展示挑战赛,由 Mux 呈现:展示你的项目!

如何在安卓系统中计算目录大小?

由 Mux 赞助的 DEV 全球展示挑战赛:展示你的项目!

这是介绍如何计算未压缩应用程序在磁盘上的大小并生成报告的系列文章的第二部分。本部分将介绍第一部分中提到的各种组件的实现。

报告和运输的架构

我将采用简单的结构:报告将是一个映射表,其中路径是键,路径的大小是值。该映射表是扁平的,这意味着父目录的路径和所有子目录的路径都只是同一个映射表的不同键。

这意味着传输功能将以路径和尺寸映射作为输入,并将其记录到我们需要的地方。因此,传输功能的接口将如下所示:

interface AppSizeTransport {
   suspend fun sendAppSizeCollection(appSizeCollection: Map<String, Long>): Result<Unit>
}
Enter fullscreen mode Exit fullscreen mode

注意:我使用了暂停函数,因为传输层很可能需要将指标报告给后端,因此需要异步执行此操作。也正因如此,它最终会返回结果。

如果报告失败,系统应该重试,但是在这种情况下,我们可能需要AppSizeCollector再次运行,因为文件系统的状态可能会发生变化。

AppSizeCollector 的配置

文件系统不受我们控制,这意味着可能会创建成千上万个文件。如果我们要从数十万台设备向后端发送这些信息,可能需要支付高昂的存储费用。因此,系统需要一种方法来控制其发送的数据量。

有几个指标是我们绝对需要的:

  • 应用总大小
  • 计算每个顶级目录(例如缓存、文件等)的大小,以了解哪些因素对目录大小影响最大。

顶级目录的数量是有限且固定的(数据目录的内容除外,但我们希望您的应用程序不会频繁使用它),因此可以安全地报告这些目录。文件树更深层的文件数量几乎没有限制,因此必须限制报告的深度。

有时需要调查特定路径,或者需要监控某个深层的重要目录。为此,需要一份限制例外列表。

基于以上所有要求,系统配置应如下所示:

data class AppSizeCollectorConfig(
   val importantDirsLimit: Map<String, Int>, // List of paths that must be reported, and how deep
   val globalLimit:Int = 3, // General limit on how deep we will report
)
Enter fullscreen mode Exit fullscreen mode

计算应用程序大小

该系统的主要组件是磁盘扫描器AppSizeCollector,它将扫描磁盘,计算应用程序的大小,然后将结果传递给传输器。

鉴于此,该类的依赖关系非常明确:我们需要一个上下文实例来解析顶级路径和传输对象。我还会修改CoroutineDispatcher构造函数,以支持并发 I/O 操作,并使整个类在 Main 环境下安全运行。

class AppSizeCollector(
   private val context: Context,
   private val transport: AppSizeTransport,
   private val dispatcher: CoroutineDispatcher = Dispatchers.IO
)
Enter fullscreen mode Exit fullscreen mode

该类的主方法应接收配置作为输入,并返回操作结果。目前,我不会添加具体的错误处理,只会告知操作是否成功。不过,改进泛型类型将提升系统的错误处理能力和调试效率。

suspend fun traverseAndCollect(config: AppSizeCollectorConfig): Result<Unit>
Enter fullscreen mode Exit fullscreen mode

如前所述,我们感兴趣的是 6 个顶级目录,但实际情况要复杂一些。应用程序可能关联多个不同的外部目录。此外,除了其他目录之外,内部数据目录还包含内部缓存目录和内部文件目录。我们需要遍历的完整顶级目录集如下所示:

val dirsToTraverse = (context.externalCacheDirs.filterNotNull() +
       context.getExternalFilesDirs(null).filterNotNull() +
       context.externalMediaDirs.filterNotNull() +
       context.filesDir +
       context.cacheDir +
       (context.filesDir.parentFile?.listFiles() ?: emptyArray<File>())
       ).toSet()
Enter fullscreen mode Exit fullscreen mode

一旦我们有了目录列表,我们就需要遍历这些目录并计算其大小,但是,可能会有一些注意事项。

计算文件和目录的大小

如果你的应用支持 API 级别 26 及更高版本,这项任务就容易得多,你可以使用Files.walk。但如果你需要支持旧版本,则需要考虑一些特殊情况。

主要难点在于,对目录调用 length() 方法不会返回其内容的总大小。这迫使我们递归遍历文件树并计算其大小。

在递归迭代过程中,我们需要避免扫描符号链接,因为这可能会导致陷入循环或重复计算同一个目录。因此,我们会检测当前文件是否为符号链接,并计算链接本身的大小,而不是其内容的大小。

为了检测文件是否为符号链接,我将使用以下实用函数:

private fun File.isSymLink(): Boolean {
   val canon: File = if (parent == null) {
       this
   } else {
       val canonDir: File = parentFile.canonicalFile
       File(canonDir, name)
   }
   return canon.canonicalFile != canon.absoluteFile
}
Enter fullscreen mode Exit fullscreen mode

它会根据父文件的规范路径(即所有符号链接都已解析后的路径)重新计算文件路径,并将其与当前路径进行比较。

如果当前扫描的文件既不是符号链接也不是目录,则递归结束,我们需要返回其大小并将其添加到哈希映射中,然后将其传递给传输层。

var fileSize = dir.getSizeOnDisk()
if (dir.isFile) {
   // Check if current level of the recursion is within the limit defined in the config
   if (currentLevel < limit) {
       dirSizes[dir.path] = fileSize
   }
   return fileSize
}
Enter fullscreen mode Exit fullscreen mode

currentLevellimit 将作为参数传递给递归函数,我们稍后会详细讨论它的函数签名。您可能已经注意到一个奇怪的函数 getSizeOnDisk,它并非 Java 文件 API 的一部分。它是做什么用的呢?

该文件占用多少磁盘空间?

文件系统通常不直接以字节为单位进行操作,而是以称为“块”的小信息块进行读写操作。块的大小是固定的,文件系统中的任何文件都不能小于块的大小。因此,如果文件系统的块大小为 4KB,即使一个 1B 的文件也会占用 4KB 的实际空间。请注意,file.length()在这种情况下,调用 `get_size()` 方法会返回 1,因为它表示文件的实际长度。这也是为什么空目录也可能占用 4KB 空间的原因,因为这是存储目录项所需的最小字节数。由于我们关注的是磁盘空间,因此不能使用 `get_size()` 方法file.length()来计算文件大小。

Android 提供了多种获取块大小的方法:例如,你可以查看StatFs类。我将使用Os.lstat,它相当于 Linux 的lstat命令。调用 lstat 会返回块大小,我就可以由此推算出特定文件将占用多少磁盘空间。

private fun File.getSizeOnDisk(): Long {
   val lstatData = Os.lstat(canonicalPath)
   return (ceil(lstatData.st_size.toDouble() / lstatData.st_blksize) * lstatData.st_blksize).roundToLong()
}
Enter fullscreen mode Exit fullscreen mode

目录的递归

现在我们可以获取文件大小,并在遇到符号链接时停止递归。最后一部分是计算目录大小,这应该很简单。但是,我们还没有定义递归函数的签名,所以现在就来定义一下!

private suspend fun calculateDirSize(
   dir: File,
   currentLevel: Int,
   limit: Int,
   dirSizes: HashMap<String, Long>,
   config: AppSizeCollectorConfig
): Long
Enter fullscreen mode Exit fullscreen mode

dir当前要查看的文件在哪里?currentLevel当前递归深度是多少?limit 应该验证我们是否需要报告大小,dir还是仅用于计算顶级目录的大小?dirSizes我们将要传递给传输的映射是什么?访问特殊路径需要配置。

以下是负责目录遍历的代码块:

dir.listFiles()?.forEach { file ->
   val nextLimit = config.importantDirsLimit[dir.path] ?: config.globalLimit
   fileSize += calculateDirSize(file, currentLevel + 1, nextLimit, dirSizes, config)
}
if (currentLevel < limit) {
   dirSizes[dir.path] = fileSize
}
return fileSize
Enter fullscreen mode Exit fullscreen mode

由于这引入了阻塞式 I/O,并且我将该函数定义为挂起函数,因此我必须使其在主函数中安全,所以最终的函数将如下所示:

private suspend fun calculateDirSize(
   dir: File,
   currentLevel: Int,
   limit: Int,
   dirSizes: HashMap<String, Long>,
   config: AppSizeCollectorConfig
): Long = withContext(dispatcher) {
   if (dir.isSymLink()) {
       return@withContext dir.getSizeOnDisk()
   }
   var fileSize = dir.getSizeOnDisk()
   if (dir.isFile) {
       // Check if current level of the recursion is within the limit defined in the config
       if (currentLevel < limit) {
           dirSizes[dir.path] = fileSize
       }
       return@withContext fileSize
   }
   dir.listFiles()?.forEach { file ->
       val nextLimit = config.importantDirsLimit[dir.path] ?: config.globalLimit
       fileSize += calculateDirSize(file, currentLevel + 1, nextLimit, dirSizes, config)
   }

   if (currentLevel < limit) {
       dirSizes[dir.path] = fileSize
   }
   fileSize
}
Enter fullscreen mode Exit fullscreen mode

我们的主要功能将变为:

suspend fun traverseAndCollect(config: AppSizeCollectorConfig): Result<Unit> =
   withContext(dispatcher) {
       val dirsToTraverse = (context.externalCacheDirs.filterNotNull() +
               context.getExternalFilesDirs(null).filterNotNull() +
               context.externalMediaDirs.filterNotNull() +
               context.filesDir +
               context.cacheDir +
               (context.filesDir.parentFile?.listFiles() ?: emptyArray<File>())
               ).toSet()
       val dirSizes = hashMapOf<String, Long>()
       var totalAppSize: AtomicLong = AtomicLong(0L)

       dirsToTraverse.map { dir ->
           launch {
               val limit = config.importantDirsLimit[dir.path] ?: config.globalLimit
               totalAppSize.addAndGet(calculateDirSize(dir, 0, limit, dirSizes, config))
           }
       }.joinAll()
       dirSizes["/"] = totalAppSize.get()
       transport.sendAppSizeCollection(dirSizes)
   }
Enter fullscreen mode Exit fullscreen mode

请注意,我在这里添加了一些并发机制,这可能会加快整体处理速度。由于协程非常轻量级,因此也可以将其添加到其他组件中calculateDirSize,但这超出了本文的讨论范围。

我还使用“/”来表示应用程序的整体大小。
在实现 LogcatTrasport 时,
我不会介绍后端报告部分,但会保留调试传输部分,它会将结果打印到日志中,供您分析和调试系统。

const val TRANSPORT_TAG = "APP_SIZE"

class LogCatAppsizeTransport : AppSizeTransport {
   override suspend fun sendAppSizeCollection(appSizeCollection: Map<String, Long>): Result<Unit> {
       Log.d(TRANSPORT_TAG, "App size collection is ${appSizeCollection.size}")
       appSizeCollection.entries.forEach { entry ->
           Log.d(TRANSPORT_TAG, "Path ${entry.key} is ${entry.value} bytes")
       }
       return Result.success(Unit)
   }
}
Enter fullscreen mode Exit fullscreen mode

本文第二部分“计算应用大小”到此结束。最后一部分,我将展示如何将所有内容整合起来。

文章来源:https://dev.to/ilyavorobiev/how-to-calculate-directory-size-on-android-3l71