在使用 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)
}
}
}