文件系统操作
源:Okio 官方文档 | kotlinx-io
跨平台文件操作是 KMP 开发中的常见需求。各平台的文件系统 API 差异巨大,本文将展示如何通过 Okio 或 kotlinx-io 提供统一的文件读写接口。
平台差异对比
| 平台 | 原生 API | 路径分隔符 | 沙盒限制 | 权限要求 |
|---|---|---|---|---|
| Android | java.io.File + Context | / | 严格沙盒(/data/data/包名) | WRITE_EXTERNAL_STORAGE (API < 29) |
| iOS/macOS | FileManager (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(): Stringexpect fun getCacheDir(): Stringexpect fun readTextFile(path: String): Stringexpect fun writeTextFile(path: String, content: String)expect fun deleteFile(path: String): Booleanexpect 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
| 库名 | 维护者 | 平台支持 | 稳定性 | 功能完整性 | 推荐度 |
|---|---|---|---|---|---|
| Okio | Square | 全平台 | 成熟稳定 | 完整 | ⭐⭐⭐⭐⭐ |
| kotlinx-io | JetBrains | 全平台 | 实验性 | 基础功能 | ⭐⭐⭐ |
推荐方案
生产环境强烈推荐使用 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)
}