头图

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

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

  1. 文件路径表示方式

    • Windows 使用反斜杠 \
    • Unix/Linux/macOS 使用正斜杠 /
    • iOS 需要考虑沙箱限制
  2. 文件权限管理

    • Android 需要动态申请存储权限
    • iOS 有严格的沙箱限制
    • Desktop 平台需要考虑不同用户权限
  3. 文件操作 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 提供了 SourceSink 两个核型抽象,分别用于读取和写入数据:

    fun copyFile(source: Path, target: Path) {
     fileSystem.source(source).use { input ->
         fileSystem.sink(target).use { output ->
             input.buffer().readAll(output)
         }
     }
    }
  2. 文件路径操作
    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 ->
         // 处理数据
     }
    }
  2. 异常处理
    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 管理了整个应用的文件操作路径,数据迁移等工作,并且此实现是跨平台的,它可以在 AndroidiOSMacWindowsLinux 上正常工作。由此可见 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)
        }
    }
}

GeekCat
819 声望15 粉丝