Skip to content

文件/媒体与互操作

CMP 开发中最常见的痛点就是:如何选择文件?如何处理外部拖入的文件?如何播放视频?这些通常涉及平台特定 API。

1. 跨平台文件选择 (File Picker)

由于各个平台的文件选择器实现完全不同(Android 是 Activity Result API,Desktop 是 JFileChooser/Swing),我们需要使用 expect/actual 模式封装。

定义接口 (commonMain)

kotlin
// FilePicker.kt (commonMain)
expect class FilePickerLauncher(
    onFileSelected: (File?) -> Unit
) {
    fun launch()
}

@Composable
expect fun rememberFilePickerLauncher(
    onFileSelected: (File?) -> Unit
): FilePickerLauncher

Android 实现 (androidMain)

Android 需要使用 ActivityResultLauncher

kotlin
// FilePicker.android.kt
actual class FilePickerLauncher(
    private val launcher: ActivityResultLauncher<String>
) {
    actual fun launch() {
        launcher.launch("*/*") // 选择所有类型
    }
}

@Composable
actual fun rememberFilePickerLauncher(
    onFileSelected: (File?) -> Unit
): FilePickerLauncher {
    val context = LocalContext.current
    val launcher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.GetContent()
    ) { uri: Uri? ->
        // 这里需要将 Uri 转换为通用 File 对象或直接读取流
        // 建议仅传递 Uri String 或 InputStream
        onFileSelected(uri?.let { File(it.toString()) }) 
    }
    return remember { FilePickerLauncher(launcher) }
}

Desktop 实现 (desktopMain)

Desktop 可以直接调用 Java AWT/Swing 的组件。

kotlin
// FilePicker.desktop.kt
import java.awt.FileDialog
import java.awt.Frame

actual class FilePickerLauncher(
    private val onFileSelected: (File?) -> Unit
) {
    actual fun launch() {
        val dialog = FileDialog(null as Frame?, "Select File", FileDialog.LOAD)
        dialog.isVisible = true
        val file = if (dialog.file != null) {
            File(dialog.directory, dialog.file)
        } else null
        onFileSelected(file)
    }
}

@Composable
actual fun rememberFilePickerLauncher(
    onFileSelected: (File?) -> Unit
): FilePickerLauncher {
    return remember { FilePickerLauncher(onFileSelected) }
}

2. 外部文件拖拽 (Drag & Drop)

在桌面端,用户经常从资源管理器直接拖拽文件到 App 中。

kotlin
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun DropTargetBox() {
    var dropText by remember { mutableStateOf("Drop files here") }

    Box(
        modifier = Modifier
            .size(300.dp)
            .background(Color.LightGray)
            // 监听外部拖拽事件
            .onExternalDrag(
                onDragStart = { externalDragValue ->
                    // 可以检查拖进来的内容类型
                    dropText = "Dragging..."
                },
                onDragExit = {
                    dropText = "Drop files here"
                },
                onDrop = { externalDragValue ->
                    val dragData = externalDragValue.dragData
                    if (dragData is androidx.compose.ui.draganddrop.DragData.FilesList) {
                        // 获取文件路径列表
                        val files = dragData.readFiles()
                        dropText = "Dropped: ${files.joinToString { it.substringAfterLast("/") }}"
                    }
                }
            )
    ) {
        Text(dropText, Modifier.align(Alignment.Center))
    }
}

3. 资源访问 (Resources)

从 Compose Multiplatform 1.6.0 开始,官方提供了全新的资源库 compose.components.resources

目录结构

所有资源都放在 composeApp/src/commonMain/composeResources 目录下:

text
commonMain/
  composeResources/
    drawable/
      logo.xml
      image.png
    font/
      myfont.ttf
    values/
      strings.xml

代码调用

会自动生成 Res 对象。

kotlin
import compose.components.resources.*

Image(
    painter = painterResource(Res.drawable.image),
    contentDescription = stringResource(Res.string.app_name)
)

异步加载

CMP 的资源加载本质上是异步的(尤其在 Web 端)。虽然 painterResource 看起来是同步的,但它内部处理了挂起。

4. 平台判断

有时你需要在 commonMain 中根据平台渲染不同的 UI。

kotlin
// Platform.kt
expect val currentPlatform: Platform

enum class Platform { Android, IOS, Desktop, Web }

// AndroidMain
actual val currentPlatform = Platform.Android

// DesktopMain
actual val currentPlatform = Platform.Desktop
kotlin
// UI 代码
if (currentPlatform == Platform.Desktop) {
    // 显示鼠标特有的悬停提示
    TooltipArea(...)
}