動態

詳情 返回 返回

還沒用過 Okio? 一個 KMP 庫幫你統一多平台文件操作 - 動態 詳情

在使用 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)
        }
    }
}
user avatar koogua 頭像 renzhendezicai 頭像
點贊 2 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.