KMP Web 开发实战(三):纯 KMP + Compose Multiplatform,如何直接开发一个完整 Web 应用?

发布时间:2026/7/24 18:09:48
KMP Web 开发实战(三):纯 KMP + Compose Multiplatform,如何直接开发一个完整 Web 应用? 在上一篇文章中我们已经讲清楚KMP Web 主要有两条路线路线一 KMP Compose Multiplatform 直接开发完整 Web 应用 路线二 KMP 编译成 JavaScript / TypeScript 库 交给 Vue、React 等前端项目使用第二条路线比较容易理解KMP 负责共享业务逻辑 Vue 负责页面和交互但是第一条路线很多 Android 开发者第一次看到时都会有疑问不使用 Vue也不使用 React只使用 Kotlin 和 Compose真的可以开发一个完整网页吗答案是可以。Compose Multiplatform 可以将 Compose UI 运行在浏览器中。Kotlin 代码通过wasmJs编译为 WebAssembly再由浏览器加载和执行。这一篇我们就完整走一遍创建 Web 模块 ↓ 配置 wasmJs ↓ 在 commonMain 编写 Compose UI ↓ 创建 Web main() 入口 ↓ 运行浏览器开发服务器 ↓ 打包生产环境文件 ↓ 部署完整 Web 应用官方当前也提供了使用 Kotlin/Wasm 和 Compose Multiplatform 创建、运行并生成可部署 Web 产物的完整流程。一、先给出结论纯 KMP Web 和 Vue 没有关系纯 KMP Compose Multiplatform Web 的完整链路是Kotlin 代码 Compose Multiplatform UI ↓ Kotlin/Wasm 编译 ↓ 生成 .wasm、JavaScript 加载文件和资源文件 ↓ 浏览器加载 index.html ↓ 运行完整 Web 应用这里没有 Vue也没有 React。页面按钮、列表、输入框、状态管理和业务逻辑都可以直接使用 Kotlin 和 Compose 编写。例如Composable fun App() { var count by remember { mutableStateOf(0) } Column { Text(当前计数$count) Button( onClick { count } ) { Text(增加) } } }这段代码和 Android 中的 Jetpack Compose 几乎没有区别。它既不是 Vue 组件也不是先转换成 Vue 页面而是由 Compose Multiplatform 直接绘制到浏览器页面中。Compose Multiplatform 的 Web UI 当前主要建立在 Kotlin/Wasm 之上官方将 Compose Multiplatform Web 支持标记为 Beta。二、浏览器最终加载的到底是什么虽然我们使用 Kotlin 和 Compose 写页面但浏览器并不能直接识别 Kotlin 源代码。中间需要经历一次编译App.kt ↓ Kotlin/Wasm 编译器 ↓ WebAssembly 模块 ↓ JavaScript 加载代码 ↓ 浏览器执行最终生成的文件通常包括index.html xxx.wasm xxx.js xxx.mjs skiko.js 图片、字体等资源浏览器首先加载index.html然后由 JavaScript 加载和初始化 WebAssembly 模块。所以纯 KMP Web 并不是说浏览器直接运行 Kotlin而是Kotlin ↓ 编译为 WebAssembly ↓ 浏览器运行 WebAssemblyKotlin/Wasm 是 Kotlin 编译器提供的一个目标平台它可以把 Kotlin 代码编译成 WebAssembly并运行在支持相应 Wasm 特性的浏览器环境中。三、推荐的项目结构如果只是学习可以把所有代码都放在一个模块里。但如果我们的目标是同时支持 Android、iOS 和 Web更推荐采用下面这种结构KmpProject ├── sharedLogic │ └── 共享业务逻辑 │ ├── sharedUI │ └── Compose Multiplatform 共享页面 │ ├── androidApp │ └── Android 应用入口 │ ├── iosApp │ └── iOS 应用入口 │ └── webApp └── Web 应用入口职责分别是sharedLogic 网络、数据模型、业务规则、Repository、UseCase sharedUI 登录页、首页、列表页、表单页等 Compose UI androidApp Activity、Manifest、Android 平台入口 iosApp SwiftUI/UIKit 入口、Xcode 配置 webApp 浏览器入口、index.html、CSS、Wasm 打包需要特别注意sharedUI负责提供可共享的页面webApp负责把这些页面启动为一个完整的浏览器应用。可以把它类比成 AndroidsharedUI 类似可复用的 Compose UI Library webApp 类似最终的 application 模块四、sharedUI 为什么也需要 wasmJs 目标假设sharedUI中已经有一个页面Composable fun App() { Text(Hello KMP Web) }现在我们希望 Android、iOS、Web 都能使用这个页面。那么sharedUI就不能只配置 Android 和 iOS还需要声明 WebAssembly 目标。sharedUI/build.gradle.kts可以写成import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) } kotlin { androidTarget() listOf( iosArm64(), iosSimulatorArm64() ) OptIn(ExperimentalWasmDsl::class) wasmJs { browser() } sourceSets { commonMain.dependencies { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) implementation(compose.components.resources) } } }这里要注意wasmJs { browser() }没有配置binaries.executable()原因是sharedUI是一个共享类库不是最终应用。它只需要声明我的代码可以被编译到浏览器的 Wasm 环境。最终生成完整可执行 Web 应用的任务交给webApp模块完成。因此可以先记住sharedUI wasmJs browser 作为共享类库 webApp wasmJs browser binaries.executable 作为最终应用五、配置最终的 webApp 模块接下来创建最终的 Web 应用模块。webApp/build.gradle.kts的核心配置如下import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) } kotlin { OptIn(ExperimentalWasmDsl::class) wasmJs { browser() binaries.executable() } sourceSets { commonMain.dependencies { implementation(project(:sharedUI)) implementation(compose.runtime) implementation(compose.ui) } } }这段配置中最核心的就是wasmJs { browser() binaries.executable() }分别翻译一下。1. wasmJswasmJs表示将这个模块中的 Kotlin 代码编译成 WebAssembly并运行在 JavaScript 宿主环境中。在这里宿主环境就是浏览器。2. browser()browser()表示当前 Wasm 程序运行在浏览器环境中。配置以后Gradle 会提供浏览器开发服务器、浏览器测试和生产打包等相关任务。3. binaries.executable()binaries.executable()表示当前模块不是普通共享类库而是一个可以独立启动的最终应用。没有它模块更接近一个被其他模块依赖的 Library。有了它Gradle 才会生成完整的浏览器应用产物。所以这三段配置连起来可以理解为wasmJs 把 Kotlin 编译成 WebAssembly browser() 运行环境是浏览器 binaries.executable() 生成可以独立运行的最终应用六、在 commonMain 中编写共享页面接下来在sharedUI中创建 Compose 页面。目录可以是sharedUI └── src └── commonMain └── kotlin └── App.kt先写一个简单的计数页面import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp Composable fun App() { MaterialTheme { var count by remember { mutableStateOf(0) } Column( modifier Modifier .fillMaxSize() .padding(24.dp), verticalArrangement Arrangement.Center, horizontalAlignment Alignment.CenterHorizontally ) { Text( text 纯 KMP Compose Web ) Text( text 当前计数$count, modifier Modifier.padding(top 16.dp) ) Button( modifier Modifier.padding(top 16.dp), onClick { count } ) { Text(点击增加) } } } }这就是一个普通的 Compose 页面。同一个App()理论上可以被多个平台入口调用Android Activity ↓ App() iOS ComposeUIViewController ↓ App() Web ComposeViewport ↓ App()这就是 Compose Multiplatform 共享 UI 的核心价值不是只共享数据模型 而是连 UI、状态和交互逻辑 也可以一起共享七、创建 Web 的 main() 入口Android 应用的入口通常是ActivityiOS 应用的入口通常是SwiftUI App 或者 UIViewControllerWeb 应用同样需要一个入口。在当前较新的 KMP 项目结构中可以放在webApp └── src └── webMain └── kotlin └── main.kt部分旧项目也可能使用webApp/src/wasmJsMain/kotlin在main.kt中编写import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.window.ComposeViewport OptIn(ExperimentalComposeUiApi::class) fun main() { ComposeViewport( viewportContainerId webApp ) { App() } }这里的核心是ComposeViewport( viewportContainerId webApp )它的作用是找到 HTML 中 ID 为webApp的容器然后在这个容器中创建 Compose 绘制区域并显示App()页面。完整关系是index.html 中的 webApp 容器 ↓ ComposeViewport 找到这个容器 ↓ 在容器中创建 Canvas ↓ Compose 绘制 App()当前官方推荐使用ComposeViewport将 Compose UI 渲染到 HTML 页面中的 Canvas。过去常见的CanvasBasedWindow已经被弃用新的方式允许开发者通过 HTML 和 CSS 更灵活地控制承载区域。八、index.html 到底负责什么接下来需要准备一个普通的 HTML 页面。目录可以是webApp └── src └── webMain └── resources ├── index.html └── styles.css旧项目中也可能位于webApp/src/wasmJsMain/resources一个简单的index.html可以写成!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleKMP Web Demo/title link relstylesheet hrefstyles.css script typeapplication/javascript srcskiko.js /script script typeapplication/javascript srcwebApp.js /script /head body div idwebApp/div /body /html这里最重要的是div idwebApp/div因为 Kotlin 入口中写的是ComposeViewport( viewportContainerId webApp )两边的 ID 必须一致。HTML div idwebApp/div Kotlin viewportContainerId webApp如果一个叫webApp另一个叫composeApp页面就无法正确挂载。官方 Compose Multiplatform 示例同样会在 HTML 中声明页面容器并加载 Skiko 和应用的 JavaScript 启动文件。需要注意最终 JavaScript 文件名可能由模块名或者 Webpack 配置决定。例如你的模块叫webApp文件可能是webApp.js如果模块名或者outputFileName被修改了HTML 中的文件名也要对应调整。九、为什么还要写 CSSCompose 页面虽然由 Compose 控制但它仍然运行在 HTML 页面提供的容器中。如果 HTML 的body或容器本身没有高度Compose Canvas 就可能无法正确铺满浏览器窗口。因此可以创建styles.csshtml, body { width: 100%; height: 100%; margin: 0; padding: 0; overflow: hidden; } #webApp { width: 100%; height: 100%; }这段 CSS 的意思是html 铺满整个浏览器 body 铺满整个 html webApp 容器铺满整个 body Compose Canvas 再铺满 webApp最终形成浏览器窗口 ↓ html ↓ body ↓ #webApp ↓ Compose Canvas ↓ App()官方文档也特别指出ComposeViewport不会自动向页面注入全局 CSS。如果没有为页面和宿主容器设置尺寸Canvas 可能不能正确调整大小或铺满窗口。十、如何在浏览器中运行配置完成后可以直接使用 IDE 中的运行配置。通常可以看到类似webApp [wasmJs]点击运行后Gradle 会启动本地开发服务器并自动打开浏览器。也可以在终端中执行./gradlew :webApp:wasmJsBrowserDevelopmentRun某些项目中也可能使用./gradlew :webApp:wasmJsBrowserDevelopmentRun --continuous或者./gradlew :webApp:wasmJsBrowserDevelopmentRun -t-t表示持续构建。修改 Kotlin 代码以后Gradle 会重新编译项目。浏览器地址通常类似http://localhost:8080如果8080端口被占用开发服务器会自动使用其他端口具体端口可以在 Gradle 控制台中查看。官方入门教程同样通过webApp [wasmJs]运行配置启动应用默认示例地址为本地8080端口。十一、如何打包生产环境文件开发完成以后需要生成可以部署到服务器的生产环境文件。执行./gradlew :webApp:wasmJsBrowserDistribution构建完成后文件通常位于webApp └── build └── dist └── wasmJs └── productionExecutable里面会包含类似productionExecutable ├── index.html ├── styles.css ├── webApp.js ├── webApp.mjs ├── webApp.wasm ├── skiko.js └── composeResources不同版本和项目配置生成的文件名可能稍有区别但核心都是HTML JavaScript 启动文件 WebAssembly 文件 图片、字体等静态资源官方当前推荐使用wasmJsBrowserDistribution生成可发布产物并将结果输出到模块的build/dist/wasmJs/productionExecutable目录。部署时不是只上传.wasm文件而是要上传整个目录。也就是错误 只上传 webApp.wasm 正确 上传 productionExecutable 中的全部文件因为浏览器还需要index.html JavaScript 加载代码 Skiko Compose 资源 WebAssembly 模块这些文件共同组成完整应用。十二、它和 Vue 打包出来的 dist 有什么区别Vue 项目执行npm run build通常会生成dist ├── index.html └── assets ├── app.js ├── app.css └── imagesKMP Web 执行./gradlew :webApp:wasmJsBrowserDistribution通常会生成productionExecutable ├── index.html ├── webApp.js ├── webApp.wasm ├── skiko.js └── resources从部署角度看两者非常相似Vue dist ↓ 上传到静态服务器 ↓ 浏览器访问 index.html KMP Web productionExecutable ↓ 上传到静态服务器 ↓ 浏览器访问 index.html真正的区别在于页面的实现方式。VueJavaScript / TypeScript ↓ Vue 组件 ↓ DOM ↓ 浏览器页面KMP Compose MultiplatformKotlin ↓ Compose UI ↓ WebAssembly ↓ Canvas 绘制 ↓ 浏览器页面所以可以这样理解KMP Web 的productionExecutable在部署角色上类似 Vue 的dist。但是它们内部的渲染机制不同。Vue 主要构建和更新 HTML DOM。Compose Multiplatform Web 则主要通过 Compose 和 Skia 在浏览器 Canvas 中绘制界面。十三、哪些代码能够真正共享假设我们有一个登录功能。1. 业务模型data class LoginState( val username: String , val password: String , val loading: Boolean false, val errorMessage: String? null )可以放在commonMain2. ViewModel 或状态管理class LoginViewModel { // 登录状态和业务处理 }只要使用的是跨平台依赖也可以放在commonMain3. Compose 页面Composable fun LoginScreen() { // 登录 UI }同样可以放在sharedUI/commonMain4. 网络请求如果使用支持 Wasm 的 Ktor Client 依赖也可以尽量放在sharedLogic/commonMain5. 平台能力以下能力往往需要平台适配Android 权限 iOS 权限 浏览器剪贴板 文件选择器 CameraX AVFoundation 浏览器 DOM API 本地存储这时可以继续使用expect / actual例如expect fun openExternalUrl(url: String)然后分别实现androidMain iosMain webMain所以纯 KMP Web 并不意味着所有代码都必须放进commonMain。更准确的规则是跨平台一致的逻辑 放 commonMain 平台实现不同的能力 放对应平台 Source Set十四、最容易混淆的几个问题1. wasmJs 是完整 Web 应用吗不是。wasmJs只是声明编译目标。还需要browser()说明运行环境是浏览器。如果是最终应用通常还需要binaries.executable()完整配置才是wasmJs { browser() binaries.executable() }2. 写了 App() 就能在浏览器显示吗不能。App()只是一个 Compose 页面还需要 Web 入口fun main() { ComposeViewport( viewportContainerId webApp ) { App() } }也就是App() 负责页面 main() 负责启动页面3. 有 ComposeViewport 就不需要 index.html 了吗仍然需要。浏览器首先加载的是 HTML 页面。ComposeViewport 只是找到 HTML 中的容器并在容器里创建 Compose 绘制区域。index.html 提供页面和容器 ComposeViewport 找到容器 App() 绘制真正的业务界面4. 生成了 wasm 文件就可以直接打开吗不能只双击.wasm文件。需要通过 Web 服务器加载完整产物。开发阶段使用Gradle Webpack Dev Server生产阶段则可以部署到Nginx Apache GitHub Pages Cloudflare Pages 其他静态文件服务器官方文档列出的发布方式也包括 GitHub Pages、Cloudflare 和 Apache HTTP Server。5. 它是不是完全不需要 JavaScript业务页面可以基本使用 Kotlin 编写但最终产物中仍然会有 JavaScript 文件。这些 JavaScript 代码主要负责加载 WebAssembly 连接浏览器 API 初始化运行环境 启动 Compose 处理 Wasm 与 JavaScript 互操作所以更准确的表达是开发者不需要使用 Vue 或 React 来编写页面但浏览器运行链路仍然需要 JavaScript 作为宿主和加载层。十五、浏览器兼容性需要注意什么Kotlin/Wasm 会使用 WasmGC 等较新的 WebAssembly 能力因此需要较新的浏览器版本。根据 Kotlin 官方当前文档Chrome 119 及以上 默认支持 Firefox 120 及以上 默认支持 Safari 18.2 及以上 默认支持旧浏览器的兼容性可能不足因此面向实际用户发布前需要根据目标用户设备进行浏览器测试。这也是纯 KMP Web 和成熟 Vue 项目相比需要重点评估的一点。十六、纯 KMP CMP Web 适合什么项目比较适合Android、iOS、Web 希望共享大量 UI 团队成员主要是 Kotlin 开发者 内部管理系统 设备控制后台 IoT 控制面板 数据看板 跨平台工具类应用 已有 Compose Multiplatform 项目需要补充 Web 端例如一个设备管理项目登录页 设备列表 设备详情 告警列表 维保记录 表单页面这些页面在 Android、iOS、Web 上的业务结构相似就可以考虑使用 Compose Multiplatform 共享。但是对于下面这些项目需要谨慎评估强 SEO 内容网站 高度依赖 HTML DOM 的页面 传统门户网站 复杂富文本编辑器 强依赖成熟前端组件生态的系统 需要兼容大量旧浏览器的项目因为 Compose Multiplatform Web 的定位更接近运行在浏览器中的跨平台应用而不是传统的内容型网页。十七、完整链路重新整理现在把整篇文章重新串起来。第一步在 sharedUI 中声明 Wasm 能力wasmJs { browser() }表示共享 UI 可以被 Web 平台使用。第二步在 webApp 中声明最终应用wasmJs { browser() binaries.executable() }表示生成完整浏览器应用。第三步在 commonMain 中编写页面Composable fun App() { Text(Hello KMP Web) }第四步创建 Web 入口fun main() { ComposeViewport( viewportContainerId webApp ) { App() } }第五步在 HTML 中创建容器div idwebApp/div第六步运行开发环境./gradlew :webApp:wasmJsBrowserDevelopmentRun第七步生成生产文件./gradlew :webApp:wasmJsBrowserDistribution第八步部署完整目录webApp/build/dist/wasmJs/productionExecutable最终链路就是sharedUI/commonMain 中的 Compose 页面 ↓ webApp 中的 main() 启动页面 ↓ ComposeViewport 挂载到 HTML 容器 ↓ Kotlin 编译为 WebAssembly ↓ 浏览器加载 Wasm 和相关资源 ↓ 形成完整 Web 应用十八、总结纯 KMP Compose Multiplatform Web并不是把 Kotlin 编译成 JS 然后交给 Vue 使用而是使用 Kotlin 编写业务逻辑 使用 Compose Multiplatform 编写 UI 通过 Kotlin/Wasm 编译 直接生成完整浏览器应用它与 Vue 没有依赖关系。最终我们仍然会得到一个可以部署的静态目录index.html JavaScript 加载文件 WebAssembly 文件 资源文件这个目录在部署角色上类似 Vue 打包出来的dist。最核心的区别是Vue 使用 JavaScript / TypeScript 编写 UI 纯 KMP Web 使用 Kotlin Compose 编写 UI对于已经在使用 KMP Compose Multiplatform 的项目来说这条路线意味着Android、iOS 和 Web 不仅可以共享业务逻辑还有机会进一步共享页面、状态和交互代码。这才是纯 KMP Compose Multiplatform Web 真正完整的开发链路。下一篇预告这一篇我们已经真正跑通了纯 KMP Web 的完整链路Kotlin Compose Multiplatform UI ↓ Kotlin/Wasm 编译 ↓ 浏览器加载并运行 ↓ 形成完整 Web 应用在这条路线中KMP 不仅负责业务逻辑还直接负责 Web 页面、状态和交互。整个 Web 应用不依赖 Vue也不依赖 ReactKMP 负责业务逻辑 KMP 负责 Compose UI KMP 负责生成最终 Web 应用但是实际项目中并不一定都适合使用纯 KMP Compose Multiplatform 开发 Web 页面。例如公司已经有成熟的 Vue 项目和前端团队Web 页面仍然希望继续使用 Vue 开发。这时KMP 就可以换一种角色KMP 不负责 Web UI ↓ 只负责共享业务逻辑 ↓ 编译成 JavaScript / TypeScript 类库 ↓ 交给 Vue 项目导入使用也就是说同样是 KMP Web实际上存在两种完全不同的开发模式模式一纯 KMP Compose Multiplatform KMP 负责业务逻辑 KMP 负责页面 UI 最终生成完整 Web 应用模式二KMP Vue KMP 负责共享业务逻辑 Vue 负责页面和交互 KMP 编译成 JS / TS 类库供 Vue 调用下一篇我们就切换到第二种模式《KMP 如何编译成 JavaScript / TypeScript 库供 Vue 项目调用》下一篇将重点讲清楚为什么这条路线通常使用js而不是wasmJs为什么共享逻辑模块不需要binaries.executable()binaries.library()的作用是什么如何将 Kotlin 类和函数暴露给 JavaScriptJsExport到底解决什么问题如何生成 TypeScript 类型声明Vue 项目如何导入 KMP 生成的 JS 包KMP 和 Vue 应该如何划分职责如何把 KMP 生成的类库发布成 npm 包我们最终会跑通下面这条完整链路sharedLogic/commonMain ↓ Kotlin/JS 编译 ↓ 生成 JavaScript 和 TypeScript 类型声明 ↓ Vue 项目导入 ↓ 在 Vue 页面中调用共享业务逻辑看完下一篇就能真正区分wasmJs Compose Multiplatform 用于直接开发完整 Web 应用 js library 用于把 KMP 业务能力提供给 Vue 等前端项目这两条路线没有谁一定更好关键在于项目中的 Web UI 到底由谁负责。