diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..53d7bdf --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,97 @@ +# AGENTS.md + +本仓库是多模块 Monorepo,演示 **服务端驱动 UI(Server-Driven UI)**:后端用 AndroidX Remote Compose 把 UI 编译成二进制文档,Android 端下载后由 `RemoteComposePlayer` 原生渲染。 + +## 仓库结构 + +``` +VibeCoding_ComposeRemote/ +├── ComposeRemoteAndroid/ # 【可改】Android 客户端(Kotlin + Jetpack Compose) +├── ComposeRemoteServer/ # 【可改】Spring Boot 4 后端(生成 Remote Compose 二进制文档) +├── remotecompose/ # 【只读】GitHub 开源:Web 编辑器 + JVM 转换器 + .rc 二进制产物 +└── remotecompose-android/ # 【只读】GitHub 开源:纯播放端示例 App +``` + +> ⚠️ `remotecompose/` 与 `remotecompose-android/` 是上游 GitHub 开源项目,**禁止修改**。 +> 仅可在 `ComposeRemoteAndroid/` 与 `ComposeRemoteServer/` 中编写代码。 + +## 架构与数据流 + +``` + CREATION SIDE (JVM) PLAYBACK SIDE (Android) + ┌──────────────────────┐ binary ┌──────────────────────────┐ + │ ComposeRemoteServer │ .rc bytes │ ComposeRemoteAndroid │ + │ RemoteComposeWriter │ ── HTTP ───▶ │ OkHttp GET → RemoteDoc │ + │ → byte[] (octet) │ (GET /card) │ RemoteComposePlayer 渲染 │ + └──────────────────────┘ └──────────────────────────┘ +``` + +- 后端只负责「创建」文档;前端只负责「播放」文档,**两端都不解析 JSON 布局、不共享创建端源码**。 +- 通信协议:Android 向 `GET /api/remote-ui/card` 发请求,后端返回 `application/octet-stream` 的二进制 `byte[]`,前端用 `RemoteDocument(bytes)` 解码。 + +## 1. ComposeRemoteAndroid(可改) + +- 类型:Android Application,Kotlin + Jetpack Compose(声明式 UI)。 +- 构建:Gradle(AGP `9.2.1`,Kotlin `2.4.0`,Compose BOM `2026.02.01`)。 +- SDK:`minSdk 29` / `targetSdk 36` / `compileSdk 36.1`,Java 语言级别 11。 +- 关键依赖: + - `androidx.compose.remote:remote-player-core / -compose / -view:1.0.0-alpha17`(播放端) + - `com.squareup.okhttp3:okhttp:4.12.0`(下载二进制文档) +- 网络:`AndroidManifest.xml` 已声明 `INTERNET` 权限,且 `res/xml/network.xml` 允许明文流量(`cleartextTrafficPermitted=true`)。 +- 关键文件: + - `app/src/main/java/com/ttstd/composeremote/MainActivity.kt` — 入口,`enableEdgeToEdge()`,硬编码服务端地址(`192.168.1.5:8000`)。 + - `app/src/main/java/com/ttstd/composeremote/ui/RemoteUIScreen.kt` — 拉取二进制 → `RemoteDocument` → `RemoteComposePlayer` 渲染;含 loading / error 状态。 + - `ui/theme/` — Material 3 主题(默认未启用 `ComposeRemoteAndroidTheme`)。 +- 构建/运行(在 `ComposeRemoteAndroid/` 内): + - `./gradlew :app:assembleDebug` + - `./gradlew :app:installDebug`(需连接模拟器/设备) +- 约定: + - 服务端地址改为常量或 `BuildConfig`/`local.properties` 注入,勿在 `MainActivity` 中硬编码内网 IP。 + - 播放端代码不要引入 `remote-creation-*`(创建端)依赖。 + +## 2. ComposeRemoteServer(可改) + +- 类型:Spring Boot 4.1.0 Web MVC 应用,Java 17,Maven 构建。 +- 关键依赖: + - `spring-boot-starter-webmvc`、`jackson-databind`、`lombok`、`spring-boot-starter-test` + - `androidx.compose.remote:remote-creation-jvm:1.0.0-alpha17`(服务端生成二进制文档) + - 仓库配置:`maven.google.com`(Remote Compose artifacts 来自 Google Maven)。 +- 关键文件: + - `src/main/java/com/ttstd/composeremote/server/controller/RemoteUIController.java` + - `GET /api/remote-ui/card` → `produces = APPLICATION_OCTET_STREAM`,返回二进制 `byte[]`。 + - 内部用 `RemoteComposeWriter`(Profile: `DOCUMENT_API_LEVEL` / `PROFILE`,1080×1920,density 1.0)写 `header` → `startRoot`/`column`/`textComponent` → `encodeToByteArray()`。 +- 构建/运行(在 `ComposeRemoteServer/` 内): + - `./mvnw spring-boot:run`(默认端口 8080 → 需与客户端地址一致,例如 8000) + - `./mvnw clean package` +- 约定: + - 新增 UI 时复用 `buildRemoteDocument()` 的 writer/Profile 模式;保持 apiLevel/profiles 与 Android 播放端兼容。 + - 注意 **Remote Compose 版本一致性**:服务端 `remote-creation-jvm` 为 `1.0.0-alpha17`,Android `remote-player` 同为 `alpha17`;而只读的 `remotecompose/server` 用的是 `alpha05`,不要混用。 + +## 3. remotecompose(只读,禁止修改) + +- 上游:[armcha/remotecompose](https://github.com/armcha/remotecompose)。 +- 内容:浏览器拖拽编辑器(Vanilla JS)、Kotlin/Wasm 预览、`server/`(JVM 转换器,`remote-creation` `alpha05`)、若干 `.json` 布局配置 + 生成的 `.rc` 二进制文档、`index.html` 实时预览。 +- 用途:参考其 `.rc` 文档格式与 JSON→二进制转换逻辑;**切勿在此目录提交改动**。 + +## 4. remotecompose-android(只读,禁止修改) + +- 上游:[armcha/remotecompose-android](https://github.com/armcha/remotecompose-android)。 +- 内容:纯播放端示例 App,从 GitHub 下载 `.rc` 文件用 `RemoteComposePlayer` 渲染(4 个屏幕 + Compose Navigation)。 +- 用途:播放端架构/组件封装(如 `RemoteDocumentView`、`RemoteConfigFetcher`)的参考实现;**切勿修改**。 + +## 版本与依赖关键约束 + +| 模块 | Remote Compose artifact | 版本 | +|---|---|---| +| ComposeRemoteServer | `remote-creation-jvm` | `1.0.0-alpha17` | +| ComposeRemoteAndroid | `remote-player-*` | `1.0.0-alpha17` | +| remotecompose/server(只读) | `remote-creation*` | `1.0.0-alpha05` | + +- 创建端与播放端必须使用**同一 alpha 版本**(`alpha17`)以保证二进制兼容;升级时两端同步升级。 +- 所有 Remote Compose 依赖均来自 Google Maven(`maven.google.com`)。 + +## 通用协作约定 + +- 提交前在各自子项目目录内构建通过(Android: `:app:assembleDebug`;Server: `./mvnw compile`)。 +- 不要触碰 `remotecompose/`、`remotecompose-android/`。 +- 新增「服务端驱动 UI」能力时:后端在 `RemoteUIController` 生成文档,前端在 `RemoteUIScreen` 消费,保持二进制协议一致。 diff --git a/ComposeRemoteAndroid/app/build.gradle b/ComposeRemoteAndroid/app/build.gradle index 2e30601..3b4e5a8 100644 --- a/ComposeRemoteAndroid/app/build.gradle +++ b/ComposeRemoteAndroid/app/build.gradle @@ -13,7 +13,7 @@ android { defaultConfig { applicationId "com.ttstd.composeremote" - minSdk 24 + minSdk 29 targetSdk 36 versionCode 1 versionName "1.0" @@ -52,4 +52,12 @@ dependencies { androidTestImplementation libs.androidx.junit debugImplementation libs.androidx.compose.ui.test.manifest debugImplementation libs.androidx.compose.ui.tooling + + // Remote Compose Player(view 版,参考 remotecompose-android 的集成方式) + implementation("androidx.compose.remote:remote-core:1.0.0-alpha17") + implementation("androidx.compose.remote:remote-player-core:1.0.0-alpha17") + implementation("androidx.compose.remote:remote-player-view:1.0.0-alpha17") + + // OkHttp 用于请求二进制 payload + implementation("com.squareup.okhttp3:okhttp:4.12.0") } \ No newline at end of file diff --git a/ComposeRemoteAndroid/app/src/main/AndroidManifest.xml b/ComposeRemoteAndroid/app/src/main/AndroidManifest.xml index 384123e..c5d6cb8 100644 --- a/ComposeRemoteAndroid/app/src/main/AndroidManifest.xml +++ b/ComposeRemoteAndroid/app/src/main/AndroidManifest.xml @@ -2,12 +2,15 @@ + + diff --git a/ComposeRemoteAndroid/app/src/main/java/com/ttstd/composeremote/MainActivity.kt b/ComposeRemoteAndroid/app/src/main/java/com/ttstd/composeremote/MainActivity.kt index 3374e37..0b9a22e 100644 --- a/ComposeRemoteAndroid/app/src/main/java/com/ttstd/composeremote/MainActivity.kt +++ b/ComposeRemoteAndroid/app/src/main/java/com/ttstd/composeremote/MainActivity.kt @@ -4,13 +4,23 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Scaffold +import androidx.compose.material3.FilterChip +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.Modifier -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.ttstd.composeremote.ui.RemoteUIScreen import com.ttstd.composeremote.ui.theme.ComposeRemoteAndroidTheme class MainActivity : ComponentActivity() { @@ -19,29 +29,31 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { ComposeRemoteAndroidTheme { - Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> - Greeting( - name = "Android", - modifier = Modifier.padding(innerPadding) - ) + // 页面 id 列表,每个 id 对应后端一个独立接口 /api/remote-ui/{page} + var page by remember { mutableStateOf("home") } + Column(modifier = Modifier.fillMaxSize()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + FilterChip( + selected = page == "home", + onClick = { page = "home" }, + label = { Text("首页") } + ) + FilterChip( + selected = page == "detail", + onClick = { page = "detail" }, + label = { Text("详情") } + ) + } + Box(modifier = Modifier.fillMaxSize().weight(1f)) { + RemoteUIScreen(page = page) + } } } } } } - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true) -@Composable -fun GreetingPreview() { - ComposeRemoteAndroidTheme { - Greeting("Android") - } -} \ No newline at end of file diff --git a/ComposeRemoteAndroid/app/src/main/java/com/ttstd/composeremote/RemoteConfig.kt b/ComposeRemoteAndroid/app/src/main/java/com/ttstd/composeremote/RemoteConfig.kt new file mode 100644 index 0000000..e944007 --- /dev/null +++ b/ComposeRemoteAndroid/app/src/main/java/com/ttstd/composeremote/RemoteConfig.kt @@ -0,0 +1,11 @@ +package com.ttstd.composeremote + +/** + * 全局配置。 + * + * 注意:服务端地址建议通过 BuildConfig / local.properties 注入,避免硬编码内网 IP。 + * 这里集中管理,方便后续替换为可配置项。 + */ +object RemoteConfig { + const val SERVER_BASE_URL = "http://192.168.1.5:8000" +} diff --git a/ComposeRemoteAndroid/app/src/main/java/com/ttstd/composeremote/ui/RemoteUIScreen.kt b/ComposeRemoteAndroid/app/src/main/java/com/ttstd/composeremote/ui/RemoteUIScreen.kt new file mode 100644 index 0000000..28882c7 --- /dev/null +++ b/ComposeRemoteAndroid/app/src/main/java/com/ttstd/composeremote/ui/RemoteUIScreen.kt @@ -0,0 +1,145 @@ +package com.ttstd.composeremote.ui + +import android.annotation.SuppressLint +import android.util.Log +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ElevatedButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +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.viewinterop.AndroidView +import androidx.compose.remote.player.view.RemoteComposePlayer +import androidx.compose.remote.player.core.RemoteDocument +import androidx.compose.ui.unit.dp +import com.ttstd.composeremote.RemoteConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import java.util.concurrent.TimeUnit + +private val client = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() + +private sealed interface RemoteUiState { + data object Loading : RemoteUiState + data class Success(val bytes: ByteArray) : RemoteUiState { + override fun equals(other: Any?): Boolean = + other is Success && bytes.contentEquals(other.bytes) + + override fun hashCode(): Int = bytes.contentHashCode() + } + + data class Error(val message: String) : RemoteUiState +} + +private suspend fun fetchRemoteUi(page: String, baseUrl: String): ByteArray = + withContext(Dispatchers.IO) { + val url = "$baseUrl/api/remote-ui/$page" + val request = Request.Builder().url(url).build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IOException("HTTP ${response.code}") + } + response.body?.bytes() ?: throw IOException("空响应") + } + } + +/** + * 使用 [RemoteComposePlayer] 渲染 Remote Compose 文档。 + * + * 在 alpha17 版本中,建议显式创建 [RemoteDocument] 并使用 [RemoteComposePlayer.setDocument] + * 的 RemoteDocument 重载版本。同时将 setDocument 放在 AndroidView 的 update 块中, + * 以确保在数据变化时能正确触发 View 的更新。 + */ +@SuppressLint("RestrictedApiAndroidX") +@Composable +private fun RemoteDocumentView( + bytes: ByteArray, + modifier: Modifier = Modifier, + contentKey: Any = bytes.contentHashCode(), +) { + key(contentKey) { + AndroidView( + factory = { ctx -> + Log.d("RemoteUIScreen", "Creating RemoteComposePlayer") + RemoteComposePlayer(ctx) + }, + update = { view -> + try { + // 显式解析 ByteArray 为 RemoteDocument 对象 + val document = RemoteDocument(bytes) + view.setDocument(document) + Log.d("RemoteUIScreen", "RemoteDocument set successfully") + } catch (e: Exception) { + Log.e("RemoteUIScreen", "Failed to set RemoteDocument", e) + } + }, + modifier = modifier + ) + } +} + +@Composable +fun RemoteUIScreen(page: String, baseUrl: String = RemoteConfig.SERVER_BASE_URL) { + var state by remember(page) { mutableStateOf(RemoteUiState.Loading) } + var retryToken by remember { mutableIntStateOf(0) } + + LaunchedEffect(page, retryToken) { + state = RemoteUiState.Loading + state = try { + val bytes = fetchRemoteUi(page, baseUrl) + RemoteUiState.Success(bytes) + } catch (e: Exception) { + Log.e("RemoteUIScreen", "Fetch failed", e) + RemoteUiState.Error(e.message ?: "加载失败") + } + } + + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + when (val s = state) { + is RemoteUiState.Loading -> CircularProgressIndicator() + is RemoteUiState.Error -> ErrorView(s.message) { retryToken++ } + is RemoteUiState.Success -> { + RemoteDocumentView( + bytes = s.bytes, + modifier = Modifier.fillMaxSize() + ) + } + } + } +} + +@Composable +private fun ErrorView(message: String, onRetry: () -> Unit) { + Box( + modifier = Modifier.fillMaxSize().padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + Text(text = "加载失败", style = MaterialTheme.typography.titleMedium) + Text(text = message, style = MaterialTheme.typography.bodyMedium) + ElevatedButton(onClick = onRetry) { Text("重试") } + } + } +} diff --git a/ComposeRemoteAndroid/app/src/main/res/xml/network.xml b/ComposeRemoteAndroid/app/src/main/res/xml/network.xml new file mode 100644 index 0000000..dca93c0 --- /dev/null +++ b/ComposeRemoteAndroid/app/src/main/res/xml/network.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/ComposeRemoteAndroid/gradle/libs.versions.toml b/ComposeRemoteAndroid/gradle/libs.versions.toml index 88df221..c364c50 100644 --- a/ComposeRemoteAndroid/gradle/libs.versions.toml +++ b/ComposeRemoteAndroid/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] agp = "9.2.1" coreKtx = "1.18.0" -junit = "5.0-SNAPSHOT" +junit = "4.13.2" junitVersion = "1.3.0" espressoCore = "3.7.0" lifecycleRuntimeKtx = "2.10.0" activityCompose = "1.13.0" -kotlin = "2.2.10" +kotlin = "2.4.0" composeBom = "2026.02.01" [libraries] diff --git a/ComposeRemoteServer/pom.xml b/ComposeRemoteServer/pom.xml index d08d08e..a2de4ec 100644 --- a/ComposeRemoteServer/pom.xml +++ b/ComposeRemoteServer/pom.xml @@ -28,6 +28,7 @@ 17 + 2.1.0 @@ -40,6 +41,11 @@ spring-boot-starter-webmvc + + com.fasterxml.jackson.core + jackson-databind + + org.springframework.boot spring-boot-starter-test @@ -49,17 +55,78 @@ org.projectlombok lombok - annotationProcessor + provided + + + + + androidx.compose.remote + remote-creation-jvm + 1.0.0-alpha17 + + + org.jetbrains.kotlin + kotlin-stdlib + ${kotlin.version} + + + google + https://maven.google.com + + + central + https://repo.maven.apache.org/maven2 + + + + + org.jetbrains.kotlin + kotlin-maven-plugin + ${kotlin.version} + + 17 + + -Xjsr305=strict + + + + + compile + process-sources + + compile + + + + ${project.basedir}/src/main/kotlin + ${project.basedir}/src/main/java + + + + + test-compile + process-test-sources + + test-compile + + + + ${project.basedir}/src/test/kotlin + ${project.basedir}/src/test/java + + + + + org.springframework.boot spring-boot-maven-plugin - diff --git a/ComposeRemoteServer/src/main/java/com/ttstd/composeremote/server/controller/RemoteUIController.java b/ComposeRemoteServer/src/main/java/com/ttstd/composeremote/server/controller/RemoteUIController.java new file mode 100644 index 0000000..9e5cd6c --- /dev/null +++ b/ComposeRemoteServer/src/main/java/com/ttstd/composeremote/server/controller/RemoteUIController.java @@ -0,0 +1,33 @@ +package com.ttstd.composeremote.server.controller; + +import com.ttstd.composeremote.server.RemoteDocumentBuilderKt; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 服务端驱动 UI 接口。 + * + *

一个页面一个接口:每个页面通过独立的路径暴露,例如 + *

    + *
  • GET /api/remote-ui/home → 首页
  • + *
  • GET /api/remote-ui/detail → 详情页
  • + *
+ * 返回 {@code application/octet-stream} 的 Remote Compose 二进制文档, + * 由 Android 端 {@code RemoteComposePlayer} 原生渲染。 + */ +@RestController +@RequestMapping("/api/remote-ui") +public class RemoteUIController { + + @GetMapping( + value = {"/{page}", ""}, + produces = MediaType.APPLICATION_OCTET_STREAM_VALUE + ) + public byte[] getPage(@PathVariable(name = "page", required = false) String page) { + String target = (page == null || page.isBlank()) ? "home" : page; + return RemoteDocumentBuilderKt.buildRemoteDocument(target); + } +} diff --git a/ComposeRemoteServer/src/main/kotlin/com/ttstd/composeremote/server/RemoteDocumentBuilder.kt b/ComposeRemoteServer/src/main/kotlin/com/ttstd/composeremote/server/RemoteDocumentBuilder.kt new file mode 100644 index 0000000..ff0d98c --- /dev/null +++ b/ComposeRemoteServer/src/main/kotlin/com/ttstd/composeremote/server/RemoteDocumentBuilder.kt @@ -0,0 +1,38 @@ +package com.ttstd.composeremote.server + +import androidx.compose.remote.core.CoreDocument +import androidx.compose.remote.creation.JvmRcPlatformServices +import androidx.compose.remote.creation.RemoteComposeWriter +import androidx.compose.remote.creation.dsl.RcColumnScope +import androidx.compose.remote.creation.dsl.RcProfile +import androidx.compose.remote.creation.dsl.RcSp +import androidx.compose.remote.creation.dsl.createRcBuffer +import androidx.compose.remote.creation.dsl.fillMaxSize +import androidx.compose.remote.creation.dsl.fillMaxWidth +import androidx.compose.remote.creation.dsl.padding +import androidx.compose.remote.creation.dsl.background +import androidx.compose.remote.creation.dsl.Modifier +import androidx.compose.remote.creation.profile.Profile +import androidx.compose.remote.creation.profile.RemoteComposeWriterFactory + +fun buildRemoteDocument(page: String): ByteArray { + val writerFactory = RemoteComposeWriterFactory { _, _, _ -> + RemoteComposeWriter(1080, 1920, "remote-ui", JvmRcPlatformServices()) + } + val profile = Profile( + CoreDocument.DOCUMENT_API_LEVEL, + CoreDocument.PROFILE, + JvmRcPlatformServices(), + writerFactory + ) + val rcProfile = RcProfile(profile, false) + return createRcBuffer(rcProfile) { + Column(Modifier.fillMaxSize().background(0xFFFFFFFF.toInt()).padding(16f)) { + Text( + text = if (page == "home") "Home Page" else "Detail Page", + fontSize = RcSp(36f), + color = 0xFF0000FF.toInt() // Blue + ) + } + } +}