还没用过 Okio? 一个 KMP 库帮你统一多平台文件操作

766 阅读4分钟

在使用 Kotlin Compose Multiplatform 开发跨平台应用时,处理文件操作是一个常见但棘手的问题。不同平台(如 Android、iOS、Mac、Windows 和 Linux)的文件系统存在显著差异,如果为每个平台单独编写文件操作代码,不仅会导致代码重复,还容易引入平台特定的 bug。本文将介绍如何使用 Okio 库来统一处理跨平台的文件操作。

平台差异带来的挑战 在不同平台上,文件操作存在以下典型差异:

  1. 文件路径表示方式
  • Windows 使用反斜杠 \
  • Unix/Linux/macOS 使用正斜杠 /
  • iOS 需要考虑沙箱限制
  1. 文件权限管理
  • Android 需要动态申请存储权限
  • iOS 有严格的沙箱限制
  • Desktop 平台需要考虑不同用户权限
  1. 文件操作 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 来处理文件操作,让我们能够写出更简洁、更可靠的跨平台代码。

  1. 统一的 IO 模型 Okio 提供了 Source 和 Sink 两个核型抽象,分别用于读取和写入数据:
fun copyFile(source: Path, target: Path) {
    fileSystem.source(source).use { input ->
        fileSystem.sink(target).use { output ->
            input.buffer().readAll(output)
        }
    }
}
  1. 文件路径操作 Okio 提供了统一的路径 API,自动处理不同平台的路径分隔符:
fun createPath(base: Path, child: String): Path {
    return base / child  // 自动使用正确的路径分隔符
}

fun resolvePath() {
    val path = "documents/reports".toPath()
    println(path.normalized()) // 自动规范化路径
}
  1. 统一的缓冲策略 Okio 内置了智能的缓冲策略,无需手动管理缓冲区:
fun processLargeFile(path: Path) {
    fileSystem.source(path).buffer().use { source ->
        // 处理数据
    }
}
  1. 异常处理 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,如果对你有帮助欢迎点个 star ❤️。

UserDataPathProvider 管理了整个应用的文件操作路径,数据迁移等工作,并且此实现是跨平台的,它可以在 Android、iOS、Mac、Windows 和 Linux 上正常工作。由此可见 Okio 可以大大简化跨平台文件操作的复杂性,减少代码,保持一致逻辑,提高开发效率。

UserDataPathProvider.kt

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)
        }
    }
}