iOS应用沙盒目录详解与优化
1. Bundle(应用程序包)目录
-
路径:
[NSBundle mainBundle].bundlePath
-
特点:
- 只读目录,包含应用二进制文件、资源文件(如图片、音频、nib文件等)。
- 安装时由系统签名,修改会导致应用无法启动(越狱设备除外)。
- 不可存储用户数据,否则会被拒绝上架。
-
优化建议:
- 动态资源(如配置文件)应放在其他可写目录,通过
NSBundle
仅读取静态资源。
- 动态资源(如配置文件)应放在其他可写目录,通过
2. Documents
-
路径:
NSHomeDirectory()/Documents
-
特点:
- 用于存储用户生成的文件或应用独有的数据(如数据库、绘图作品、游戏存档)。
- iTunes/iCloud自动备份(需注意用户隐私和数据大小)。
- 通过
NSFileManager
的URLForDirectory:inDomain:appropriateForURL:create:error:
获取。
-
注意事项:
-
禁止缓存网络下载内容(否则违反Apple审核指南,需使用
Library/Caches
)。 - 大文件建议标记为
NSURLIsExcludedFromBackupKey
避免占用iCloud空间。
-
禁止缓存网络下载内容(否则违反Apple审核指南,需使用
-
优化建议:
- 敏感数据应加密存储(如用户文档)。
3. Library
3.1 Library/Caches
-
路径:
NSHomeDirectory()/Library/Caches
-
特点:
- 存储临时文件(如缓存图片、离线地图),可重新下载或生成的数据。
- 不备份到iTunes/iCloud,系统磁盘不足时可能被清理(但开发者需主动管理)。
- 通过
NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES)
获取。
-
优化建议:
- 实现定期清理逻辑(如
SDWebImage
的缓存自动清理机制)。 - 大文件建议分目录存储(如
/Caches/Images/
)。
- 实现定期清理逻辑(如
3.2 Library/Preferences
-
路径:
NSHomeDirectory()/Library/Preferences
-
特点:
- 通过
NSUserDefaults
读写,存储用户设置(如主题、登录状态)。 - 自动备份到iTunes/iCloud(需避免存储敏感信息)。
- 文件格式为
.plist
,无需直接操作文件系统。
- 通过
-
优化建议:
- 调用
synchronize
方法强制写入(iOS 12+已优化,通常无需手动调用)。 - 复杂数据建议使用Keychain(如密码)或数据库。
- 调用
3.3 Library/Application Support(需补充)
-
路径:
NSHomeDirectory()/Library/Application Support
-
特点:
- 存储应用运行所需的持久化数据(如模板、语音模型)。
-
默认备份,可通过
NSURLIsExcludedFromBackupKey
排除。 - 需手动创建目录,适合存放不可再生数据。
4. tmp
-
路径:
NSTemporaryDirectory()
-
特点:
- 存储短期临时文件(如解压的ZIP包、拍摄的临时照片)。
- 不备份,系统可能随时清空(如重启、空间不足)。
-
优化建议:
- 文件使用后应立即删除,避免占用空间。
- 高频操作建议使用内存缓存(如
NSCache
)替代。
补充说明
-
iCloud备份规则:
- Documents、Library/Application Support默认备份,可通过
addSkipBackupAttributeToItemAtURL
排除。 - 用户可通过系统设置关闭单个应用的iCloud备份。
- Documents、Library/Application Support默认备份,可通过
-
目录选择原则:
-
用户可见文件 →
Documents
(如PDF导出)。 -
内部缓存 →
Library/Caches
(如视频缓存)。 -
临时处理 →
tmp
(如拍照编辑中间文件)。
-
用户可见文件 →
-
安全建议:
- 敏感数据(如Token)应存于Keychain。
- 使用
FileProtection
API为文件添加加密(如NSFileProtectionComplete
)。
-
调试技巧:
- 通过Xcode的
Device File Explorer
查看沙盒目录。 - 使用
lsof -p <pid>
命令监控文件打开状态。
- 通过Xcode的
extension FileManager
extension FileManager {
// MARK: 获取Home的完整路径名
/// 获取Home的完整路径名
/// - Returns: Home的完整路径名
static func homeDirectory() -> String {
//获取程序的Home目录
let homeDirectory = NSHomeDirectory()
return homeDirectory
}
// MARK: 获取Documnets的完整路径名
/// 获取Documnets的完整路径名
/// - Returns: Documnets的完整路径名
static func DocumnetsDirectory() -> String {
//获取程序的documentPaths目录
//方法1
// let documentPaths = NSSearchPathForDirectoriesInDomains(FileManager.SearchPathDirectory.documentDirectory, FileManager.SearchPathDomainMask.userDomainMask, true)
// let documnetPath = documentPaths[0]
//方法2
let ducumentPath = NSHomeDirectory() + "/Documents"
return ducumentPath
}
// MARK: 获取Library的完整路径名
/**
这个目录下有两个子目录:Caches 和 Preferences
Library/Preferences目录,包含应用程序的偏好设置文件。不应该直接创建偏好设置文件,而是应该使用NSUserDefaults类来取得和设置应用程序的偏好。
Library/Caches目录,主要存放缓存文件,iTunes不会备份此目录,此目录下文件不会再应用退出时删除
*/
/// 获取Library的完整路径名
/// - Returns: Library的完整路径名
static func LibraryDirectory() -> String {
//获取程序的documentPaths目录
//Library目录-方法1
// let libraryPaths = NSSearchPathForDirectoriesInDomains(FileManager.SearchPathDirectory.libraryDirectory, FileManager.SearchPathDomainMask.userDomainMask, true)
// let libraryPath = libraryPaths[0]
//
// Library目录-方法2
let libraryPath = NSHomeDirectory() + "/Library"
return libraryPath
}
// MARK: 获取/Library/Caches的完整路径名
/// 获取/Library/Caches的完整路径名
/// - Returns: /Library/Caches的完整路径名
static func CachesDirectory() -> String {
//获取程序的/Library/Caches目录
let cachesPath = NSHomeDirectory() + "/Library/Caches"
return cachesPath
}
// MARK: 获取Library/Preferences的完整路径名
/// 获取Library/Preferences的完整路径名
/// - Returns: Library/Preferences的完整路径名
static func PreferencesDirectory() -> String {
//Library/Preferences目录-方法2
let preferencesPath = NSHomeDirectory() + "/Library/Preferences"
return preferencesPath
}
// MARK: 获取Tmp的完整路径名
/// 获取Tmp的完整路径名,用于存放临时文件,保存应用程序再次启动过程中不需要的信息,重启后清空
/// - Returns: Tmp的完整路径名
static func TmpDirectory() -> String {
//方法1
//let tmpDir = NSTemporaryDirectory()
//方法2
let tmpDir = NSHomeDirectory() + "/tmp"
return tmpDir
}
}
// MARK: - 二、文件以及文件夹的操作 扩展
public extension FileManager {
// MARK: 文件写入的类型
/// 文件写入的类型
enum FileWriteType {
case TextType
case ImageType
case ArrayType
case DictionaryType
}
// MARK: 移动或者拷贝的类型
/// 移动或者拷贝的类型
enum MoveOrCopyType {
case file
case directory
}
/// 文件管理器
static var fileManager: FileManager {
return FileManager.default
}
// MARK: 创建文件夹(蓝色的,文件夹和文件是不一样的)
/// 创建文件夹(蓝色的,文件夹和文件是不一样的)
/// - Parameter folderName: 文件夹的名字
/// - Returns: 返回创建的 创建文件夹路径
@discardableResult
static func createFolder(folderPath: String) -> (isSuccess: Bool, error: String) {
if judgeFileOrFolderExists(filePath: folderPath) {
return (true, "")
}
// 不存在的路径才会创建
do {
// withIntermediateDirectories为ture表示路径中间如果有不存在的文件夹都会创建
try fileManager.createDirectory(atPath: folderPath, withIntermediateDirectories: true, attributes: nil)
return (true, "")
} catch _ {
return (false, "创建失败")
}
}
// MARK: 删除文件夹
/// 删除文件夹
/// - Parameter folderPath: 文件的路径
@discardableResult
static func removefolder(folderPath: String) -> (isSuccess: Bool, error: String) {
let filePath = "\(folderPath)"
guard judgeFileOrFolderExists(filePath: filePath) else {
// 不存在就不做什么操作了
return (true, "")
}
// 文件存在进行删除
do {
try fileManager.removeItem(atPath: filePath)
return (true, "")
} catch _ {
return (false, "删除失败")
}
}
// MARK: 创建文件
/// 创建文件
/// - Parameter filePath: 文件路径
/// - Parameter contents: 文件内容
/// - Returns: 返回创建的结果 和 路径
@discardableResult
static func createFile(filePath: String, contents: Data? = nil) -> (isSuccess: Bool, error: String) {
guard judgeFileOrFolderExists(filePath: filePath) else {
// 不存在的文件路径才会创建
// 判断目标路径文件夹是否存在,不存在就进行创建
let toFolderPath = directoryAtPath(path: filePath)
if !judgeFileOrFolderExists(filePath: toFolderPath), !createFolder(folderPath: toFolderPath).isSuccess {
return (false, "目标路径文件夹不存在且创建失败")
}
let createSuccess = fileManager.createFile(atPath: filePath, contents: contents, attributes: nil)
return (createSuccess, "创建\(createSuccess ? "成功" : "失败")")
}
let remove = removefile(filePath: filePath)
guard remove.isSuccess else {
return remove
}
let createSuccess = fileManager.createFile(atPath: filePath, contents: contents, attributes: nil)
return (createSuccess, "删除后创建\(createSuccess ? "成功" : "失败")")
}
// MARK: 删除文件
/// 删除文件
/// - Parameter filePath: 文件路径
@discardableResult
static func removefile(filePath: String) -> (isSuccess: Bool, error: String) {
guard judgeFileOrFolderExists(filePath: filePath) else {
// 不存在的文件路径就不需要要移除
return (true, "文件不存在,无需移除")
}
// 移除文件
do {
try fileManager.removeItem(atPath: filePath)
return (true, "移除文件成功")
} catch _ {
return (false, "移除文件失败")
}
}
// MARK: 读取文件内容
/// 读取文件内容
/// - Parameter filePath: 文件路径
/// - Returns: 文件内容
@discardableResult
static func readfile(filePath: String) -> String? {
guard judgeFileOrFolderExists(filePath: filePath) else {
// 不存在的文件路径就不需要要移除
return nil
}
let data = fileManager.contents(atPath: filePath)
return String(data: data!, encoding: String.Encoding.utf8)
}
// MARK: 把文字,图片,数组,字典写入文件
/// 把文字,图片,数组,字典写入文件
/// - Parameters:
/// - writeType: 写入类型
/// - content: 写入内容
/// - writePath: 写入路径
/// - Returns: 写入的结果
@discardableResult
static func writeToFile(writeType: FileWriteType, content: Any, writePath: String) -> (isSuccess: Bool, error: String) {
guard judgeFileOrFolderExists(filePath: writePath) else {
// 不存在的文件路径
return (false, "不存在的文件路径")
}
// 1、文字,2、图片,3、数组,4、字典写入文件
switch writeType {
case .TextType:
let info = "\(content)"
do {
try info.write(toFile: writePath, atomically: true, encoding: String.Encoding.utf8)
return (true, "")
} catch _ {
return (false, "写入失败")
}
case .ImageType:
let data = content as! Data
do {
try data.write(to: URL(fileURLWithPath: writePath))
return (true, "")
} catch _ {
return (false, "写入失败")
}
case .ArrayType:
let array = content as! NSArray
let result = array.write(toFile: writePath, atomically: true)
if result {
return (true, "")
} else {
return (false, "写入失败")
}
case .DictionaryType:
let result = (content as! NSDictionary).write(toFile: writePath, atomically: true)
if result {
return (true, "")
} else {
return (false, "写入失败")
}
}
}
// MARK: 从文件 读取 文字,图片,数组,字典
/// 从文件 读取 文字,图片,数组,字典
/// - Parameters:
/// - readType: 读取的类型
/// - readPath: 读取文件路径
/// - Returns: 返回读取的内容
@discardableResult
static func readFromFile(readType: FileWriteType, readPath: String) -> (isSuccess: Bool, content: Any?, error: String) {
guard judgeFileOrFolderExists(filePath: readPath), let readHandler = FileHandle(forReadingAtPath: readPath) else {
// 不存在的文件路径
return (false, nil, "不存在的文件路径")
}
let data = readHandler.readDataToEndOfFile()
// 1、文字,2、图片,3、数组,4、字典
switch readType {
case .TextType:
let readString = String(data: data, encoding: String.Encoding.utf8)
return (true, readString, "")
case .ImageType:
let image = UIImage(data: data)
return (true, image, "")
case .ArrayType:
guard let readString = String(data: data, encoding: String.Encoding.utf8) else {
return (false, nil, "读取内容失败")
}
return (true, JSON(readString).stringValue, "")
case .DictionaryType:
guard let readString = String(data: data, encoding: String.Encoding.utf8) else {
return (false, nil, "读取内容失败")
}
return (true, JSON(readString).dictionaryObject, "")
}
}
// MARK: 拷贝(文件夹/文件)的内容 到另外一个(文件夹/文件),新的(文件夹/文件)如果存在就先删除再 拷贝
/**
几个小注意点:
1、目标路径,要带上文件夹名称,而不能只写父路径
2、如果是覆盖拷贝,就是说目标路径已存在此文件夹,我们必须先删除,否则提示make directory error(当然这里最好做一个容错处理,比如拷贝前先转移到其他路径,如果失败,再拿回来)
*/
/// 拷贝(文件夹/文件)的内容 到另外一个(文件夹/文件),新的(文件夹/文件)如果存在就先删除再 拷贝
/// - Parameters:
/// - fromeFile: 拷贝的(文件夹/文件)路径
/// - toFile: 拷贝后的文件夹路径
/// - isOverwrite: 当要拷贝到的(文件夹/文件)路径存在,会拷贝失败,这里传入是否覆盖
/// - Returns: 拷贝的结果
@discardableResult
static func copyFile(type: MoveOrCopyType, fromeFilePath: String, toFilePath: String, isOverwrite: Bool = true) -> (isSuccess: Bool, error: String) {
// 1、先判断被拷贝路径是否存在
guard judgeFileOrFolderExists(filePath: fromeFilePath) else {
return (false, "被拷贝的(文件夹/文件)路径不存在")
}
// 2、判断目标路径文件夹是否存在,不存在就进行创建
let toFileFolderPath = directoryAtPath(path: toFilePath)
if !judgeFileOrFolderExists(filePath: toFileFolderPath), !createFolder(folderPath: toFileFolderPath).isSuccess {
return (false, "拷贝后路径文件夹不存在且创建失败")
}
// 3、如果目标(文件夹/文件)已存在,先删除,否则拷贝不了
if isOverwrite, judgeFileOrFolderExists(filePath: toFilePath) {
do {
try fileManager.removeItem(atPath: toFilePath)
} catch _ {
PrintLog(message: "删除目标(文件\\文件夹)失败")
return (false, "拷贝失败")
}
}
// 4、拷贝(文件夹/文件)
do {
try fileManager.copyItem(atPath: fromeFilePath, toPath: toFilePath)
} catch _ {
return (false, "拷贝失败")
}
return (true, "success")
}
// MARK: 移动(文件夹/文件)的内容 到另外一个(文件夹/文件),新的(文件夹/文件)如果存在就先删除再 移动
/// 移动(文件夹/文件)的内容 到另外一个(文件夹/文件),新的(文件夹/文件)如果存在就先删除再 移动
/// - Parameters:
/// - fromeFile: 被移动的文件路径
/// - toFile: 移动后的文件路径
@discardableResult
static func moveFile(type: MoveOrCopyType, fromeFilePath: String, toFilePath: String, isOverwrite: Bool = true) -> (isSuccess: Bool, error: String) {
// 1、先判断被拷贝路径是否存在
guard judgeFileOrFolderExists(filePath: fromeFilePath) else {
return (false, "被移动的(文件夹/文件)路径不存在")
}
// 2、判断拷贝后的文件路径的前一个文件夹路径是否存在,不存在就进行创建
let toFileFolderPath = directoryAtPath(path: toFilePath)
if !judgeFileOrFolderExists(filePath: toFileFolderPath), type == .file ? !createFile(filePath: toFilePath).isSuccess : !createFolder(folderPath: toFileFolderPath).isSuccess {
return (false, "移动后路径前一个文件夹不存在")
}
// 3、如果被移动的(文件夹/文件)已存在,先删除,否则拷贝不了
if isOverwrite, judgeFileOrFolderExists(filePath: toFilePath) {
do {
try fileManager.removeItem(atPath: toFilePath)
} catch _ {
return (false, "移动失败")
}
}
// 4、移动(文件夹/文件)
do {
try fileManager.moveItem(atPath: fromeFilePath, toPath: toFilePath)
} catch _ {
return (false, "移动失败")
}
return (true, "success")
}
// MARK: 判断 (文件夹/文件) 是否存在
/** 判断文件或文件夹是否存在*/
static func judgeFileOrFolderExists(filePath: String) -> Bool {
let exist = fileManager.fileExists(atPath: filePath)
// 查看文件夹是否存在,如果存在就直接读取,不存在就直接反空
guard exist else {
return false
}
return true
}
// MARK: 获取 (文件夹/文件) 的前一个路径
/// 获取 (文件夹/文件) 的前一个路径
/// - Parameter path: (文件夹/文件) 的路径
/// - Returns: (文件夹/文件) 的前一个路径
static func directoryAtPath(path: String) -> String {
return (path as NSString).deletingLastPathComponent
}
// MARK: 判断目录是否可读
static func judegeIsReadableFile(path: String) -> Bool {
return fileManager.isReadableFile(atPath: path)
}
// MARK: 判断目录是否可写
static func judegeIsWritableFile(path: String) -> Bool {
return fileManager.isReadableFile(atPath: path)
}
// MARK: 根据文件路径获取文件扩展类型
/// 根据文件路径获取文件扩展名
/// - Parameter filePath: 文件路径
/// - Returns: 文件扩展名(不带“.”)
static func getFileExtension(filePath: String) -> String {
return (filePath as NSString).pathExtension
}
// MARK: 根据文件路径获取文件名称,是否需要后缀
/// 根据文件路径获取文件名称,是否需要后缀
/// - Parameters:
/// - path: 文件路径
/// - suffix: 是否需要后缀,默认需要
/// - Returns: 文件名称
static func fileName(path: String, suffix: Bool = true) -> String {
let fileName = (path as NSString).lastPathComponent
guard suffix else {
// 删除后缀
return (fileName as NSString).deletingPathExtension
}
return fileName
}
// MARK: 对指定路径执行浅搜索,返回指定目录路径下的文件、子目录及符号链接的列表(只寻找一层)
/// 对指定路径执行浅搜索,返回指定目录路径下的文件、子目录及符号链接的列表(只寻找一层)
/// - Parameter folderPath: 建搜索的lujing
/// - Returns: 指定目录路径下的文件、子目录及符号链接的列表
static func shallowSearchAllFiles(folderPath: String) -> Array<String>? {
do {
let contentsOfDirectoryArray = try fileManager.contentsOfDirectory(atPath: folderPath)
return contentsOfDirectoryArray
} catch _ {
return nil
}
}
// MARK: 深度遍历,会递归遍历子文件夹(包括符号链接,所以要求性能的话用enumeratorAtPath)
/**深度遍历,会递归遍历子文件夹(包括符号链接,所以要求性能的话用enumeratorAtPath)*/
static func getAllFileNames(folderPath: String) -> Array<String>? {
// 查看文件夹是否存在,如果存在就直接读取,不存在就直接反空
if (judgeFileOrFolderExists(filePath: folderPath)) {
guard let subPaths = fileManager.subpaths(atPath: folderPath) else {
return nil
}
return subPaths
} else {
return nil
}
}
// MARK: 深度遍历,会递归遍历子文件夹(但不会递归符号链接)
/** 对指定路径深度遍历,会递归遍历子文件夹(但不会递归符号链接))*/
static func deepSearchAllFiles(folderPath: String) -> Array<Any>? {
// 查看文件夹是否存在,如果存在就直接读取,不存在就直接反空
if (judgeFileOrFolderExists(filePath: folderPath)) {
guard let contentsOfPathArray = fileManager.enumerator(atPath: folderPath) else {
return nil
}
return contentsOfPathArray.allObjects
}else{
return nil
}
}
// MARK: 计算单个 (文件夹/文件) 的大小,单位为字节(bytes) (没有进行转换的)
/// 计算单个 (文件夹/文件) 的大小,单位为字节 (没有进行转换的)
/// - Parameter filePath: (文件夹/文件) 路径
/// - Returns: 单个文件或文件夹的大小
static func fileOrDirectorySingleSize(filePath: String) -> UInt64 {
// 1、先判断文件路径是否存在
guard judgeFileOrFolderExists(filePath: filePath) else {
return 0
}
// 2、读取文件大小
do {
let fileAttributes = try fileManager.attributesOfItem(atPath: filePath)
guard let fileSizeValue = fileAttributes[FileAttributeKey.size] as? UInt64 else {
return 0
}
return fileSizeValue
} catch {
return 0
}
}
// MARK: - 获取文件或文件夹大小(转换为可读字符串)
/// 获取指定路径的文件或文件夹大小(自动转换为 KB/MB/GB 等单位)
/// - Parameter path: 文件或文件夹路径
/// - Returns: 可读性强的字符串(如 "2.45 MB")
static func fileOrDirectorySize(at path: String) -> String {
// 校验路径是否有效
guard !path.isEmpty, fileManager.fileExists(atPath: path) else {
return "0 B"
}
let size = calculateItemSize(at: path)
return convertUsingFormatter(size)
}
// MARK: 获取(文件夹/文件)属性集合
/// 获取(文件夹/文件)属性集合
/// - Parameter path: (文件夹/文件)路径
/// - Returns: (文件夹/文件)属性集合
@discardableResult
static func fileAttributes(path: String) -> ([FileAttributeKey : Any]?) {
do {
let attributes = try fileManager.attributesOfItem(atPath: path)
/*
print("创建时间:\(attributes[FileAttributeKey.creationDate]!)")
print("修改时间:\(attributes[FileAttributeKey.modificationDate]!)")
print("文件大小:\(attributes[FileAttributeKey.size]!)")
*/
return attributes
} catch _ {
return nil
}
/// key的列表如:
/**
public static let type:
public static let size:
public static let modificationDate:
public static let referenceCount:
public static let deviceIdentifier:
public static let ownerAccountName:
public static let groupOwnerAccountName:
public static let posixPermissions:
public static let systemNumber:
public static let systemFileNumber:
public static let extensionHidden:
public static let hfsCreatorCode:
public static let hfsTypeCode:
public static let immutable:
public static let appendOnly:
public static let creationDate:
public static let ownerAccountID:
public static let groupOwnerAccountID:
public static let busy:
@available(iOS 4.0, *)
public static let protectionKey:
public static let systemSize:
public static let systemFreeSize:
public static let systemNodes:
public static let systemFreeNodes:
*/
}
}
// MARK:- fileprivate
extension FileManager {
// MARK: - 递归计算文件/文件夹大小(以字节为单位)
/// 递归计算给定路径下文件/文件夹的实际大小(单位:字节)
/// - Parameter path: 文件或文件夹路径
/// - Returns: 字节数(UInt64)
private static func calculateItemSize(at path: String) -> UInt64 {
var isDirectory: ObjCBool = false
// 判断路径是否存在,是否是文件夹
guard fileManager.fileExists(atPath: path, isDirectory: &isDirectory) else {
return 0
}
// 是文件夹时,递归遍历所有内容
if isDirectory.boolValue {
var totalSize: UInt64 = 0
let url = URL(fileURLWithPath: path)
// 遍历目录下所有文件(包含子目录)
if let enumerator = fileManager.enumerator(at: url,
includingPropertiesForKeys: [.fileSizeKey],
options: [],
errorHandler: nil) {
for case let fileURL as URL in enumerator {
do {
// 获取文件大小属性
let resourceValues = try fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
if resourceValues.isRegularFile ?? false,
let fileSize = resourceValues.fileSize {
totalSize += UInt64(fileSize)
}
} catch {
print("无法读取 \(fileURL.path) 的大小: \(error)")
}
}
}
return totalSize
} else {
// 是单个文件,直接获取大小
do {
let attributes = try fileManager.attributesOfItem(atPath: path)
return (attributes[.size] as? UInt64) ?? 0
} catch {
print("获取文件大小失败: \(error)")
return 0
}
}
}
// MARK: - 可选:使用系统内建 ByteCountFormatter 进行格式化
/// 使用 Apple 提供的 ByteCountFormatter 进行格式化(更国际化)
/// - Parameter size: 字节数
/// - Returns: 格式化后的字符串(如 "2.1 MB")
private static func convertUsingFormatter(_ size: UInt64) -> String {
let formatter = ByteCountFormatter()
formatter.allowedUnits = [.useKB, .useMB, .useGB, .useBytes] // 可自定义
formatter.countStyle = .file
return formatter.string(fromByteCount: Int64(size))
}
}