
我使用 Compose 开发 Android 端应用已经有两年,已经算是比较晚入坑的了。在这期间,也一直在关注 Compose 跨平台,平时也会体验一下。
目前我认为 Compose 跨平台有两个无可比拟的优势------针对 Android 开发者尤其是用过 Compose Android 的开发者来讲:
- 使用 Kotlin。
- 一致性的 Compose 体验。
这也使得它的学习门槛非常低,几乎能做到各个平台的无缝衔接。
于是乎,我打算开发一个桌面软件,来体验一下 Compose 的桌面开发。
灵感

我经常会从 Youtube 上下载一些视频,然后放到电视上观看。
从 Youtube 上下载视频,现在已经比较有非常成熟的工具了------yt-dlp。
通过它,可以轻松的下载 Youtube 视频,但是这个工具是个命令行工具,而且支持的命令非常多,如果对于视频有着较多的需求的话,他的命令就会非常复杂。我其实每次都记不住那么多命令,很多时候都是现查询的命令。这对于这个下载工具的使用来讲,非常不方便。
所以萌生了一个想法------给这个工具开发一款GUI,可以在桌面端使用。
这款工具至少支持如下的功能:
- 从 Youtube 上下载视频。
- 能够支持比较简单的视频下载选项,例如分辨率、是否包含音频等。
- 能够支持简单的视频处理,例如转码,压缩等。
好的,需求明确,那么我们,立即开始吧!
准备工作
我的整体想法是,利用 yt-dlp 下载使用,使用 FFmpeg 对视频进行处理,所以,在正式开发软件之前,需要在电脑上安装 yt-dlp 和 FFmpeg。
因为都是命令行工具,所以只需要下载安装之后,在环境变量中设置即可。
检验一下我们的安装成果,打开命令行工具:

ffmpeg -version
的结果比较多,这里只截取了一部分展示。
好的,在正式开发软件之前,基本的准备工作已经完成了,现在,我们正式进入 Compose 桌面软件的开发。
开始
打开 compose-multiplatform 官网,点击右上角 Get Start 按钮。

此时,我们会进入到 kotlin-multiplatform 官网文档,有兴趣可以阅读一下这里的文档,对 kotlin-multiplatform 有一个了解。
下滑到这里

点击 KMP web wizard 连接,此时,我们来到了创建 KMP 项目的向导:

我们只勾选 Desktop 选项:

项目名称和项目 ID ,可以按照个人喜好填写,这里我的项目使用了 Ytor (歪图儿)这个名称,后续将会使用 Ytor 称呼该 App。
然后点击下载。完毕之后,解压,使用 IntelliJ IDEA 打开。那么此时,我们的初始工程已经创建完毕。
第一个窗口
观察目录结构:

这里有一个 composeApp
的主目录,这里就是我们主要编写 Ytor 代码的地方,里面包含一个 desktopMain 的目录。如果你是真的包含多端的项目(移动端,桌面端,甚至包含网页端),那么这里的目录结果会跟这个有所区别。
此时,我们已经可以运行第一个版本的 Ytor 了。
找到这个目录下的 main.kt
文件,运行这个 main
函数。

Ytor 的第一个窗口,启动!!!

优化一下
为了让项目更加顺手(更加符合 Android 的开发习惯),我会添加一下额外的依赖:
libs.version.toml
toml
[versions]
kotlinx-io = "0.7.0" # io
navigation = "2.9.0-beta01" # navigation
[libraries]
kotlinx-io-core = { module = "org.jetbrains.kotlinx:kotlinx-io-core", version.ref = "kotlinx-io" }
compose-navigation = { module = "org.jetbrains.androidx.navigation:navigation-compose", version.ref = "navigation" }
[plugins]
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
project 级别 build.gradle.kts
:
bash
alias(libs.plugins.kotlinSerialization) apply false
app 级别 build.gradle.kts
Kotlin
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(compose.components.uiToolingPreview)
// 可以使用viewmode构建
implementation(libs.androidx.lifecycle.viewmodel)
implementation(libs.androidx.lifecycle.runtimeCompose)
// 添加 io
implementation(libs.kotlinx.io.core)
// 添加 navigation
implementation(libs.compose.navigation)
}
我们会用到一些素材,素材相关地址:
- 图标,在 iconfont 下载。
- 主题,在 material-theme-builder 构建 Material 主题,这里构建的主体会以代码的形式提供,之后导入到我们的工程即可。
在 CMP 中,使用资源和 Android 有一点点区别:
Kotlin
// 文字资源
stringResource(Res.string.app_name)
// 图片资源
painterResource(Res.drawable.ytor)
我添加了一些扩展,可以方便资源的使用:
Kotlin
@Composable
fun StringResource.inString():String {
return stringResource(this)
}
@Composable
fun StringResource.inString(vararg formatArgs: Any):String {
return stringResource(this, *formatArgs)
}
@Composable
fun StringArrayResource.inStringArray():List<String> {
return stringArrayResource(this)
}
@Composable
fun DrawableResource.inPainter(): Painter {
return painterResource(this)
}
val Dp.toPxInt :Int @Composable get() = with(LocalDensity.current) { [email protected]().toInt() }
val Dp.toPx :Float @Composable get() = with(LocalDensity.current) { [email protected]() }
val Dp.toSp :TextUnit @Composable get() = with(LocalDensity.current) { [email protected]() }
val Int.px :Dp @Composable get() = with(LocalDensity.current) { [email protected]() }
我们会用到一个 IconButton 的控件,该控件可以提供

这样的显示效果,同时可以支持点击。
我写了一个几个 Compose 函数,统一了当前的按钮样式:
Kotlin
@Composable
fun AppFilledIconButton(
modifier: Modifier,
icon: DrawableResource,
contentDescription: String?,
onClick: () -> Unit,
) {
FilledIconButton(
onClick = onClick, modifier = modifier.size(40.dp)
) {
Icon(
painter = icon.inPainter(), modifier = Modifier.padding(8.dp).fillMaxSize(), contentDescription = contentDescription
)
}
}
@Composable
fun AppIconButton(
modifier: Modifier,
icon: DrawableResource,
contentDescription: String?,
onClick: () -> Unit,
) {
IconButton(
onClick = onClick, modifier = modifier.size(40.dp)
) {
Icon(
painter = icon.inPainter(), modifier = Modifier.padding(8.dp).fillMaxSize(), contentDescription = contentDescription
)
}
}
// 一个单纯的返回按钮
@Composable
fun BackButton(modifier: Modifier = Modifier, onBack: (() -> Unit)? = null) {
val currentBack by rememberUpdatedState(onBack)
val controller = LocalController.current // 这里是自己写的,不是 Compose 自带的
AppIconButton(modifier, icon = Res.drawable.back, contentDescription = Res.string.back.inString()) {
currentBack?.invoke() ?: controller.navigateUp()
}
}
好的,接下来我们统一一下主题。在之前使用 material-theme-builder 创建的主题文件中,稍微修改一下 Theme.kt
:

Kotlin
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable() () -> Unit
) {
val colorScheme = if (darkTheme) darkScheme else lightScheme
MaterialTheme(
colorScheme = colorScheme,
typography = AppTypography,
content = content
)
}
在 main.kt
中,使用这个主题:
Kotlin
Window(
onCloseRequest = ::exitApplication,
undecorated = false,
icon = Res.drawable.ytor.inPainter(),
title = Res.string.app_name.inString(),
) {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize()
) {
App()
}
}
}
为 Compose-Navigation 创建一个扩展,可以更加方便的使用页面:
Kotlin
inline fun <reified T : Any> NavGraphBuilder.composablePage(
typeMap: Map<KType, @JvmSuppressWildcards NavType<*>> = emptyMap(),
deepLinks: List<NavDeepLink> = emptyList(),
noinline enterTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = null,
noinline exitTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = null,
noinline popEnterTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> EnterTransition?)? = enterTransition,
noinline popExitTransition: (@JvmSuppressWildcards AnimatedContentTransitionScope<NavBackStackEntry>.() -> ExitTransition?)? = exitTransition,
noinline sizeTransform: (AnimatedContentTransitionScope<NavBackStackEntry>.() -> @JvmSuppressWildcards SizeTransform?)? = null,
noinline content: @Composable AnimatedContentScope.(NavBackStackEntry) -> Unit
) {
composable<T>(
typeMap = typeMap,
deepLinks = deepLinks,
enterTransition = enterTransition,
exitTransition = exitTransition,
popEnterTransition = popEnterTransition,
popExitTransition = popExitTransition,
sizeTransform = sizeTransform,
content = content
)
}
两个页面
然后,我们准备编写两个页面(主要是为了展示页面跳转),一个主页,一个设置页面。
我的想法是,主页有一个设置按钮,可以跳转到设置页面,然后设置页面可以点击返回按钮返回到主页。
首先,我们先编写 Navigation 路由,新版本声明路由参数非常简单:
Kotlin
import kotlinx.serialization.Serializable
@Serializable
object HomeNav
@Serializable
object SettingNav
在 App.kt
中,添加这两个页面的节点:
Kotlin
// 声明一个 LocalController 的扩展,可以在任何地方使用 NavController
val LocalController = staticCompositionLocalOf<NavHostController> { error("null controller") }
@Composable
@Preview
fun App() {
// Creates the NavController
val navController = rememberNavController()
CompositionLocalProvider(
LocalController provides navController // provides navController
) {
NavHost(navController = navController, startDestination = HomeNav) {
composablePage<HomeNav> {
HomePage(entry = it) // 主页
}
composablePage<SettingNav> {
SettingPage(entry = it) // 设置页面
}
}
}
}
主页:
Kotlin
@Composable
fun HomePage(
entry: NavBackStackEntry,
) {
val controller = LocalController.current
Box(modifier = Modifier.safeContentPadding().fillMaxSize()) {
AppFilledIconButton(
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
icon = Res.drawable.setting,
contentDescription = Res.string.setting.inString(),
) {
controller.navigate(SettingNav) //
}
Text(text = "Hello ${Res.string.app_name.inString()}", modifier = Modifier.align(Alignment.Center))
}
}
设置页面:
Kotlin
@Composable
fun SettingPage(
entry: NavBackStackEntry,
vm: SettingViewModel = viewModel { SettingViewModel() }
) {
val yt by vm.ytVersion.collectAsState()
val ffmpeg by vm.ffmpegVersion.collectAsState()
Box(
modifier = Modifier.safeContentPadding().fillMaxSize()
) {
BackButton(
modifier = Modifier.padding(12.dp)
)
Column(modifier = Modifier.align(Alignment.Center)) {
Text("Yt-dlp Version: $yt")
Text("FFmpeg Version: $ffmpeg")
}
}
}
SettingViewModel
:
Kotlin
class SettingViewModel : ViewModel() {
private val _ffmpegVersion = MutableStateFlow("")
val ffmpegVersion: StateFlow<String> = _ffmpegVersion.asStateFlow()
private val _ytVersion = MutableStateFlow("")
val ytVersion: StateFlow<String> = _ytVersion.asStateFlow()
init {
viewModelScope.launch(Dispatchers.IO) {
val ffmpegProcess = Runtime.getRuntime().exec(arrayOf("ffmpeg","-version"))
val ffmpegResult = ffmpegProcess.inputStream.bufferedReader().readLine()
_ffmpegVersion.update { ffmpegResult }
val ytProcess = Runtime.getRuntime().exec(arrayOf("yt-dlp","--version"))
val ytResult = ytProcess.inputStream.bufferedReader().readLine()
_ytVersion.update { ytResult }
}
}
}
基本代码已经完成,好的,启动:

是的,Ytor 的第一个带有一定功能的窗口,完成!!!
总结
总结一下到目前的进展:
- 完成基本的环境依赖(FFmpeg 和 yt-dlp)。
- 设定 Ytor 的主题。
- 完成基本的跳转功能。
- 测试了一下命令行参数。