在使用 Kotlin Compose Multiplatform 开发跨平台应用时,处理文件操作是一个常见但棘手的问题。不同平台(如 Android
、iOS
、Mac
、Windows
和 Linux
)的文件系统存在显著差异,如果为每个平台单独编写文件操作代码,不仅会导致代码重复,还容易引入平台特定的 bug。本文将介绍如何使用 Okio 库来统一处理跨平台的文件操作。
平台差异带来的挑战
在不同平台上,文件操作存在以下典型差异:
文件路径表示方式
Windows
使用反斜杠\
Unix
/Linux
/macOS
使用正斜杠/
- iOS 需要考虑沙箱限制
文件权限管理
Android
需要动态申请存储权限iOS
有严格的沙箱限制Desktop
平台需要考虑不同用户权限
文件操作 API
- 每个平台都有自己的文件 IO API
- 错误处理机制不同
- 性能特性各异
这些差异导致我们 KMP 多平台代码经常要写这样的代码:
fun readFile(path: String): String {
return when (Platform.current) {
Platform.ANDROID -> {
// Android 特定实现
}
Platform.IOS -> {
// iOS 特定实现
}
Platform.DESKTOP -> {
// Desktop 特定实现
}
}
}
Okio 带来的优势
Okio 是一个现代化的 IO 库,它提供了统一的 API 来处理文件操作,让我们能够写出更简洁、更可靠的跨平台代码。
统一的 IO 模型
Okio 提供了Source
和Sink
两个核型抽象,分别用于读取和写入数据:fun copyFile(source: Path, target: Path) { fileSystem.source(source).use { input -> fileSystem.sink(target).use { output -> input.buffer().readAll(output) } } }
- 文件路径操作
Okio 提供了统一的路径 API,自动处理不同平台的路径分隔符:
fun createPath(base: Path, child: String): Path {
return base / child // 自动使用正确的路径分隔符
}
fun resolvePath() {
val path = "documents/reports".toPath()
println(path.normalized()) // 自动规范化路径
}
统一的缓冲策略
Okio 内置了智能的缓冲策略,无需手动管理缓冲区:fun processLargeFile(path: Path) { fileSystem.source(path).buffer().use { source -> // 处理数据 } }
异常处理
Okio 提供了统一的异常处理机制:import okio.FileNotFoundException import okio.IOException import okio.Path import okio.use fun safeFileOperation(path: Path) { try { fileSystem.source(path).use { source -> // 文件操作 } } catch (e: FileNotFoundException) { // 统一的错误处理 } catch (e: IOException) { // 统一的 IO 错误处理 } }
CrossPaste 项目中的应用示例
此示例来自开源项目 crosspaste-desktop,UserDataPathProvider
管理了整个应用的文件操作路径,数据迁移等工作,并且此实现是跨平台的,它可以在 Android
、iOS
、Mac
、Windows
和 Linux
上正常工作。由此可见 Okio 可以大大简化跨平台文件操作的复杂性,减少代码,保持一致逻辑,提高开发效率。
import com.crosspaste.app.AppFileType
import com.crosspaste.config.ConfigManager
import com.crosspaste.exception.PasteException
import com.crosspaste.exception.StandardErrorCode
import com.crosspaste.paste.item.PasteFiles
import com.crosspaste.presist.DirFileInfoTree
import com.crosspaste.presist.FileInfoTree
import com.crosspaste.presist.FilesIndexBuilder
import com.crosspaste.utils.FileUtils
import com.crosspaste.utils.getFileUtils
import okio.Path
import okio.Path.Companion.toPath
/**
* 用户数据路径提供者,负责根据应用配置和平台特定的设置管理用户数据的存储路径。
*
* @param configManager 配置管理器,用于获取存储设置。
* @param platformUserDataPathProvider 平台特定的默认用户数据路径提供者。
*/
class UserDataPathProvider(
private val configManager: ConfigManager,
private val platformUserDataPathProvider: PlatformUserDataPathProvider,
) : PathProvider {
// 文件操作工具
override val fileUtils: FileUtils = getFileUtils()
// 支持的文件类型列表
private val types: List<AppFileType> =
listOf(
AppFileType.FILE,
AppFileType.IMAGE,
AppFileType.DATA,
AppFileType.HTML,
AppFileType.RTF,
AppFileType.ICON,
AppFileType.FAVICON,
AppFileType.FILE_EXT_ICON,
AppFileType.VIDEO,
AppFileType.TEMP,
)
/**
* 根据文件名和文件类型解析路径。
*
* @param fileName 文件名。
* @param appFileType 文件类型。
* @return 解析后的路径。
*/
override fun resolve(
fileName: String?,
appFileType: AppFileType,
): Path {
return resolve(fileName, appFileType) {
getUserDataPath()
}
}
/**
* 根据文件名、文件类型和提供的基路径解析路径。
*
* @param fileName 文件名。
* @param appFileType 文件类型。
* @param getBasePath 获取基路径的函数。
* @return 解析后的路径。
*/
private fun resolve(
fileName: String?,
appFileType: AppFileType,
getBasePath: () -> Path,
): Path {
val basePath = getBasePath()
val path =
when (appFileType) {
AppFileType.FILE -> basePath.resolve("files")
AppFileType.IMAGE -> basePath.resolve("images")
AppFileType.DATA -> basePath.resolve("data")
AppFileType.HTML -> basePath.resolve("html")
AppFileType.RTF -> basePath.resolve("rtf")
AppFileType.ICON -> basePath.resolve("icons")
AppFileType.FAVICON -> basePath.resolve("favicon")
AppFileType.FILE_EXT_ICON -> basePath.resolve("file_ext_icons")
AppFileType.VIDEO -> basePath.resolve("videos")
AppFileType.TEMP -> basePath.resolve("temp")
else -> basePath
}
autoCreateDir(path)
return fileName?.let {
path.resolve(fileName)
} ?: path
}
/**
* 将用户数据迁移到新路径。
*
* @param migrationPath 迁移的目标路径。
* @param realmMigrationAction 处理 Realm 数据库迁移的函数。
*/
fun migration(
migrationPath: Path,
realmMigrationAction: (Path) -> Unit,
) {
try {
for (type in types) {
if (type == AppFileType.DATA) {
continue
}
val originTypePath = resolve(appFileType = type)
val migrationTypePath =
resolve(fileName = null, appFileType = type) {
migrationPath
}
fileUtils.copyPath(originTypePath, migrationTypePath)
}
realmMigrationAction(
resolve(fileName = null, appFileType = AppFileType.DATA) {
migrationPath
},
)
try {
for (type in types) {
val originTypePath = resolve(appFileType = type)
fileUtils.fileSystem.deleteRecursively(originTypePath)
}
} catch (_: Exception) {
}
configManager.updateConfig(
listOf("storagePath", "useDefaultStoragePath"),
listOf(migrationPath.toString(), false),
)
} catch (e: Exception) {
try {
val fileSystem = fileUtils.fileSystem
fileSystem.list(migrationPath).forEach { subPath ->
if (fileSystem.metadata(subPath).isDirectory) {
fileSystem.deleteRecursively(subPath)
} else {
fileSystem.delete(subPath)
}
}
} catch (_: Exception) {
}
throw e
}
}
/**
* 清理临时文件。
*/
fun cleanTemp() {
try {
val tempPath = resolve(appFileType = AppFileType.TEMP)
fileUtils.fileSystem.deleteRecursively(tempPath)
} catch (_: Exception) {
}
}
/**
* 根据提供的参数解析粘贴文件的路径。
*
* @param appInstanceId 应用实例 ID。
* @param dateString 用于组织文件的日期字符串。
* @param pasteId 粘贴的 ID。
* @param pasteFiles 要解析路径的粘贴文件。
* @param isPull 是否为拉取操作。
* @param filesIndexBuilder 文件索引构建器。
*/
fun resolve(
appInstanceId: String,
dateString: String,
pasteId: Long,
pasteFiles: PasteFiles,
isPull: Boolean,
filesIndexBuilder: FilesIndexBuilder?,
) {
val basePath =
pasteFiles.basePath?.toPath() ?: run {
resolve(appFileType = pasteFiles.getAppFileType())
.resolve(appInstanceId)
.resolve(dateString)
.resolve(pasteId.toString())
}
if (isPull) {
autoCreateDir(basePath)
}
val fileInfoTreeMap = pasteFiles.getFileInfoTreeMap()
for (filePath in pasteFiles.getFilePaths(this)) {
fileInfoTreeMap[filePath.name]?.let {
resolveFileInfoTree(basePath, filePath.name, it, isPull, filesIndexBuilder)
}
}
}
/**
* 根据文件信息树解析文件或目录的路径。
*
* @param basePath 文件或目录的基路径。
* @param name 文件或目录的名称。
* @param fileInfoTree 描述文件或目录的文件信息树。
* @param isPull 是否为拉取操作。
* @param filesIndexBuilder 文件索引构建器。
*/
private fun resolveFileInfoTree(
basePath: Path,
name: String,
fileInfoTree: FileInfoTree,
isPull: Boolean,
filesIndexBuilder: FilesIndexBuilder?,
) {
if (fileInfoTree.isFile()) {
val filePath = basePath.resolve(name)
if (isPull) {
if (fileUtils.createEmptyPasteFile(filePath, fileInfoTree.size).isFailure) {
throw PasteException(
StandardErrorCode.CANT_CREATE_FILE.toErrorCode(),
"Failed to create file: $filePath",
)
}
}
filesIndexBuilder?.addFile(filePath, fileInfoTree.size)
} else {
val dirPath = basePath.resolve(name)
if (isPull) {
autoCreateDir(dirPath)
}
val dirFileInfoTree = fileInfoTree as DirFileInfoTree
dirFileInfoTree.iterator().forEach { (subName, subFileInfoTree) ->
resolveFileInfoTree(dirPath, subName, subFileInfoTree, isPull, filesIndexBuilder)
}
}
}
/**
* 根据配置获取用户数据路径。
*
* @return 用户数据路径。
*/
fun getUserDataPath(): Path {
return if (configManager.config.useDefaultStoragePath) {
platformUserDataPathProvider.getUserDefaultStoragePath()
} else {
configManager.config.storagePath.toPath(normalize = true)
}
}
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。