Skip to content

文件系统操作

源:Okio 官方文档 | kotlinx-io

跨平台文件操作是 KMP 开发中的常见需求。各平台的文件系统 API 差异巨大,本文将展示如何通过 Okio 或 kotlinx-io 提供统一的文件读写接口。

平台差异对比

平台原生 API路径分隔符沙盒限制权限要求
Androidjava.io.File + Context/严格沙盒(/data/data/包名)WRITE_EXTERNAL_STORAGE (API < 29)
iOS/macOSFileManager (Cocoa)/严格沙盒(Documents/Library/Tmp)无需权限
Desktop (JVM)java.io.File / java.nio.file/ (Linux/Mac) \ (Win)无限制依赖操作系统
Desktop (Native)POSIX / Win32 API平台相关无限制依赖操作系统

expect/actual 实现方案

API 核心签名说明

  • expect fun getAppDataDir(): String
  • expect fun getCacheDir(): String
  • expect fun readTextFile(path: String): String
  • expect fun writeTextFile(path: String, content: String)
  • expect fun deleteFile(path: String): Boolean
  • expect fun listFiles(directory: String): List<String>

标准代码块(自实现方案)

kotlin
expect class FileSystemProvider {
    fun getAppDataDir(): String
    fun getCacheDir(): String
    fun readTextFile(path: String): String
    fun writeTextFile(path: String, content: String)
    fun deleteFile(path: String): Boolean
    fun listFiles(directory: String): List<String>
    fun fileExists(path: String): Boolean
}

// 业务层使用
class ConfigManager(private val fs: FileSystemProvider) {
    
    private val configFile: String
        get() = "${fs.getAppDataDir()}/config.json"
    
    fun loadConfig(): String {
        return if (fs.fileExists(configFile)) {
            fs.readTextFile(configFile)
        } else {
            "{}" // 默认配置
        }
    }
    
    fun saveConfig(json: String) {
        fs.writeTextFile(configFile, json)
    }
}
kotlin
import android.content.Context
import java.io.File

actual class FileSystemProvider(private val context: Context) {
    
    actual fun getAppDataDir(): String {
        return context.filesDir.absolutePath
    }
    
    actual fun getCacheDir(): String {
        return context.cacheDir.absolutePath
    }
    
    actual fun readTextFile(path: String): String {
        return File(path).readText(Charsets.UTF_8)
    }
    
    actual fun writeTextFile(path: String, content: String) {
        val file = File(path)
        file.parentFile?.mkdirs()
        file.writeText(content, Charsets.UTF_8)
    }
    
    actual fun deleteFile(path: String): Boolean {
        return File(path).delete()
    }
    
    actual fun listFiles(directory: String): List<String> {
        return File(directory).listFiles()?.map { it.name } ?: emptyList()
    }
    
    actual fun fileExists(path: String): Boolean {
        return File(path).exists()
    }
}
kotlin
import platform.Foundation.*

actual class FileSystemProvider {
    
    private val fileManager = NSFileManager.defaultManager
    
    actual fun getAppDataDir(): String {
        val paths = NSSearchPathForDirectoriesInDomains(
            NSDocumentDirectory,
            NSUserDomainMask,
            true
        )
        return paths.first() as String
    }
    
    actual fun getCacheDir(): String {
        val paths = NSSearchPathForDirectoriesInDomains(
            NSCachesDirectory,
            NSUserDomainMask,
            true
        )
        return paths.first() as String
    }
    
    actual fun readTextFile(path: String): String {
        val data = fileManager.contentsAtPath(path)
            ?: throw Exception("File not found: $path")
        return NSString.create(data = data, encoding = NSUTF8StringEncoding) as String
    }
    
    actual fun writeTextFile(path: String, content: String) {
        val data = content.encodeToByteArray().toNSData()
        
        // 创建父目录
        val pathObj = path as NSString
        val parentDir = pathObj.stringByDeletingLastPathComponent
        if (!fileManager.fileExistsAtPath(parentDir)) {
            fileManager.createDirectoryAtPath(
                parentDir,
                withIntermediateDirectories = true,
                attributes = null,
                error = null
            )
        }
        
        fileManager.createFileAtPath(path, contents = data, attributes = null)
    }
    
    actual fun deleteFile(path: String): Boolean {
        return fileManager.removeItemAtPath(path, error = null)
    }
    
    actual fun listFiles(directory: String): List<String> {
        val contents = fileManager.contentsOfDirectoryAtPath(directory, error = null)
            ?: return emptyList()
        return (contents as List<*>).map { it as String }
    }
    
    actual fun fileExists(path: String): Boolean {
        return fileManager.fileExistsAtPath(path)
    }
}

// 扩展函数
private fun ByteArray.toNSData(): NSData {
    return NSData.create(bytes = this.refTo(0), length = this.size.toULong())
}
kotlin
import java.io.File

actual class FileSystemProvider {
    
    actual fun getAppDataDir(): String {
        val userHome = System.getProperty("user.home")
        val appDir = File(userHome, ".myapp")
        if (!appDir.exists()) {
            appDir.mkdirs()
        }
        return appDir.absolutePath
    }
    
    actual fun getCacheDir(): String {
        val tmpDir = System.getProperty("java.io.tmpdir")
        val cacheDir = File(tmpDir, "myapp_cache")
        if (!cacheDir.exists()) {
            cacheDir.mkdirs()
        }
        return cacheDir.absolutePath
    }
    
    actual fun readTextFile(path: String): String {
        return File(path).readText(Charsets.UTF_8)
    }
    
    actual fun writeTextFile(path: String, content: String) {
        val file = File(path)
        file.parentFile?.mkdirs()
        file.writeText(content, Charsets.UTF_8)
    }
    
    actual fun deleteFile(path: String): Boolean {
        return File(path).delete()
    }
    
    actual fun listFiles(directory: String): List<String> {
        return File(directory).listFiles()?.map { it.name } ?: emptyList()
    }
    
    actual fun fileExists(path: String): Boolean {
        return File(path).exists()
    }
}

标准代码块(Okio 方案) 推荐

kotlin
import okio.FileSystem
import okio.Path
import okio.Path.Companion.toPath

class OkioFileManager {
    
    private val fs = FileSystem.SYSTEM
    
    fun readTextFile(path: String): String {
        return fs.read(path.toPath()) {
            readUtf8()
        }
    }
    
    fun writeTextFile(path: String, content: String) {
        val pathObj = path.toPath()
        
        // 创建父目录
        pathObj.parent?.let { parent ->
            if (!fs.exists(parent)) {
                fs.createDirectories(parent)
            }
        }
        
        fs.write(pathObj) {
            writeUtf8(content)
        }
    }
    
    fun deleteFile(path: String) {
        fs.delete(path.toPath())
    }
    
    fun listFiles(directory: String): List<String> {
        val dirPath = directory.toPath()
        return fs.list(dirPath).map { it.name }
    }
    
    fun fileExists(path: String): Boolean {
        return fs.exists(path.toPath())
    }
    
    fun copyFile(source: String, destination: String) {
        fs.copy(source.toPath(), destination.toPath())
    }
    
    fun moveFile(source: String, destination: String) {
        fs.atomicMove(source.toPath(), destination.toPath())
    }
}

代码封装示例

以下是带错误处理和日志的完整封装:

kotlin
// commonMain
sealed class FileResult<out T> {
    data class Success<T>(val data: T) : FileResult<T>()
    data class Error(val exception: Throwable) : FileResult<Nothing>()
}

interface FileRepository {
    suspend fun readConfig(): FileResult<String>
    suspend fun writeConfig(content: String): FileResult<Unit>
    suspend fun clearCache(): FileResult<Unit>
}

class FileRepositoryImpl(
    private val fileSystem: FileSystem = FileSystem.SYSTEM,
    private val basePath: String
) : FileRepository {
    
    private val configPath = "$basePath/config.json".toPath()
    
    override suspend fun readConfig(): FileResult<String> = withContext(Dispatchers.IO) {
        runCatching {
            if (!fileSystem.exists(configPath)) {
                return@runCatching "{}" // 默认配置
            }
            fileSystem.read(configPath) { readUtf8() }
        }.fold(
            onSuccess = { FileResult.Success(it) },
            onFailure = { FileResult.Error(it) }
        )
    }
    
    override suspend fun writeConfig(content: String): FileResult<Unit> = withContext(Dispatchers.IO) {
        runCatching {
            configPath.parent?.let { parent ->
                if (!fileSystem.exists(parent)) {
                    fileSystem.createDirectories(parent)
                }
            }
            fileSystem.write(configPath) { writeUtf8(content) }
        }.fold(
            onSuccess = { FileResult.Success(Unit) },
            onFailure = { FileResult.Error(it) }
        )
    }
    
    override suspend fun clearCache(): FileResult<Unit> = withContext(Dispatchers.IO) {
        runCatching {
            val cachePath = "$basePath/cache".toPath()
            if (fileSystem.exists(cachePath)) {
                fileSystem.deleteRecursively(cachePath)
            }
        }.fold(
            onSuccess = { FileResult.Success(Unit) },
            onFailure = { FileResult.Error(it) }
        )
    }
}

第三方库推荐

Okio vs kotlinx-io

库名维护者平台支持稳定性功能完整性推荐度
OkioSquare全平台成熟稳定完整⭐⭐⭐⭐⭐
kotlinx-ioJetBrains全平台实验性基础功能⭐⭐⭐

推荐方案

生产环境强烈推荐使用 Okio,它已经过 Square 多年的生产验证,API 设计优雅,性能优秀。

依赖补充

Okio 推荐

kotlin
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("com.squareup.okio:okio:3.9.0")
        }
    }
}
groovy
kotlin {
    sourceSets {
        commonMain {
            dependencies {
                implementation "com.squareup.okio:okio:3.9.0"
            }
        }
    }
}
toml
[versions]
okio = "3.9.0"

[libraries]
okio = { module = "com.squareup.okio:okio", version.ref = "okio" }

最新稳定版本查看链接:https://square.github.io/okio/changelog/

kotlinx-io(实验性)

kotlin
kotlin {
    sourceSets {
        commonMain.dependencies {
            implementation("org.jetbrains.kotlinx:kotlinx-io-core:0.3.1")
        }
    }
}

最新版本查看链接:https://github.com/Kotlin/kotlinx-io/releases

实战坑点

Android 外部存储权限

Scoped Storage

Android 10+ 强制执行分区存储,应用只能访问自己的外部存储目录(getExternalFilesDir())。

解决方案:

kotlin
// androidMain
fun getPublicDownloadDir(context: Context): String {
    // Android 10+ 使用 MediaStore
    return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
            ?: context.filesDir.absolutePath
    } else {
        Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath
    }
}

iOS 文件路径转义

特殊字符处理

iOS 文件路径中的特殊字符(如中文)可能导致 NSString 转换失败。

解决方案:

kotlin
// iosMain
fun sanitizePath(path: String): String {
    return (path as NSString).stringByAddingPercentEncodingWithAllowedCharacters(
        NSCharacterSet.URLPathAllowedCharacterSet
    ) ?: path
}

Desktop 跨平台路径

::: caution 路径分隔符 硬编码路径分隔符会在跨平台时出错。 :::

错误示例:

kotlin
val path = "$baseDir\\config\\app.json" // ❌ Windows 专用

正确示例:

kotlin
val path = listOf(baseDir, "config", "app.json").joinToString(File.separator)
// 或使用 Okio
val path = Path(baseDir) / "config" / "app.json"

文件锁竞争

并发写入

多线程同时写入同一文件会导致数据损坏。

解决方案:

kotlin
class ThreadSafeFileWriter(private val filePath: String) {
    private val mutex = Mutex()
    
    suspend fun write(content: String) = mutex.withLock {
        FileSystem.SYSTEM.write(filePath.toPath()) {
            writeUtf8(content)
        }
    }
}

磁盘空间不足

IOException

写入文件时未检查磁盘空间,可能抛出 IOException。

解决方案:

kotlin
// androidMain
fun getAvailableSpace(context: Context): Long {
    val stat = StatFs(context.filesDir.absolutePath)
    return stat.availableBlocksLong * stat.blockSizeLong
}

suspend fun writeWithSpaceCheck(path: String, content: String): FileResult<Unit> {
    val requiredBytes = content.toByteArray().size
    if (getAvailableSpace() < requiredBytes * 2) { // 保留 2 倍空间
        return FileResult.Error(Exception("Insufficient disk space"))
    }
    return writeTextFile(path, content)
}