feat: 集成服务端驱动 UI 演示
新增 Remote Compose 播放端与服务端生成逻辑,支持按页面拉取并渲染二进制 UI 文档。
This commit is contained in:
97
AGENTS.md
Normal file
97
AGENTS.md
Normal file
@@ -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` 消费,保持二进制协议一致。
|
||||||
@@ -13,7 +13,7 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.ttstd.composeremote"
|
applicationId "com.ttstd.composeremote"
|
||||||
minSdk 24
|
minSdk 29
|
||||||
targetSdk 36
|
targetSdk 36
|
||||||
versionCode 1
|
versionCode 1
|
||||||
versionName "1.0"
|
versionName "1.0"
|
||||||
@@ -52,4 +52,12 @@ dependencies {
|
|||||||
androidTestImplementation libs.androidx.junit
|
androidTestImplementation libs.androidx.junit
|
||||||
debugImplementation libs.androidx.compose.ui.test.manifest
|
debugImplementation libs.androidx.compose.ui.test.manifest
|
||||||
debugImplementation libs.androidx.compose.ui.tooling
|
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")
|
||||||
}
|
}
|
||||||
@@ -2,12 +2,15 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network"
|
||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/Theme.ComposeRemoteAndroid">
|
android:theme="@style/Theme.ComposeRemoteAndroid">
|
||||||
|
|||||||
@@ -4,13 +4,23 @@ import android.os.Bundle
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.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.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
|
import com.ttstd.composeremote.ui.theme.ComposeRemoteAndroidTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -19,29 +29,31 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
ComposeRemoteAndroidTheme {
|
ComposeRemoteAndroidTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
// 页面 id 列表,每个 id 对应后端一个独立接口 /api/remote-ui/{page}
|
||||||
Greeting(
|
var page by remember { mutableStateOf("home") }
|
||||||
name = "Android",
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
modifier = Modifier.padding(innerPadding)
|
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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>(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("重试") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
ComposeRemoteAndroid/app/src/main/res/xml/network.xml
Normal file
4
ComposeRemoteAndroid/app/src/main/res/xml/network.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<base-config cleartextTrafficPermitted="true" />
|
||||||
|
</network-security-config>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "9.2.1"
|
agp = "9.2.1"
|
||||||
coreKtx = "1.18.0"
|
coreKtx = "1.18.0"
|
||||||
junit = "5.0-SNAPSHOT"
|
junit = "4.13.2"
|
||||||
junitVersion = "1.3.0"
|
junitVersion = "1.3.0"
|
||||||
espressoCore = "3.7.0"
|
espressoCore = "3.7.0"
|
||||||
lifecycleRuntimeKtx = "2.10.0"
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
activityCompose = "1.13.0"
|
activityCompose = "1.13.0"
|
||||||
kotlin = "2.2.10"
|
kotlin = "2.4.0"
|
||||||
composeBom = "2026.02.01"
|
composeBom = "2026.02.01"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
</scm>
|
</scm>
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>17</java.version>
|
<java.version>17</java.version>
|
||||||
|
<kotlin.version>2.1.0</kotlin.version>
|
||||||
</properties>
|
</properties>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -40,6 +41,11 @@
|
|||||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
@@ -49,17 +55,78 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
<scope>annotationProcessor</scope>
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Remote Compose:服务端生成二进制文档 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>androidx.compose.remote</groupId>
|
||||||
|
<artifactId>remote-creation-jvm</artifactId>
|
||||||
|
<version>1.0.0-alpha17</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jetbrains.kotlin</groupId>
|
||||||
|
<artifactId>kotlin-stdlib</artifactId>
|
||||||
|
<version>${kotlin.version}</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>google</id>
|
||||||
|
<url>https://maven.google.com</url>
|
||||||
|
</repository>
|
||||||
|
<repository>
|
||||||
|
<id>central</id>
|
||||||
|
<url>https://repo.maven.apache.org/maven2</url>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
<plugins>
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.jetbrains.kotlin</groupId>
|
||||||
|
<artifactId>kotlin-maven-plugin</artifactId>
|
||||||
|
<version>${kotlin.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<jvmTarget>17</jvmTarget>
|
||||||
|
<args>
|
||||||
|
<arg>-Xjsr305=strict</arg>
|
||||||
|
</args>
|
||||||
|
</configuration>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>compile</id>
|
||||||
|
<phase>process-sources</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>compile</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<sourceDirs>
|
||||||
|
<sourceDir>${project.basedir}/src/main/kotlin</sourceDir>
|
||||||
|
<sourceDir>${project.basedir}/src/main/java</sourceDir>
|
||||||
|
</sourceDirs>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
<execution>
|
||||||
|
<id>test-compile</id>
|
||||||
|
<phase>process-test-sources</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>test-compile</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<sourceDirs>
|
||||||
|
<sourceDir>${project.basedir}/src/test/kotlin</sourceDir>
|
||||||
|
<sourceDir>${project.basedir}/src/test/java</sourceDir>
|
||||||
|
</sourceDirs>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
</build>
|
</build>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -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 接口。
|
||||||
|
*
|
||||||
|
* <p>一个页面一个接口:每个页面通过独立的路径暴露,例如
|
||||||
|
* <ul>
|
||||||
|
* <li>GET /api/remote-ui/home → 首页</li>
|
||||||
|
* <li>GET /api/remote-ui/detail → 详情页</li>
|
||||||
|
* </ul>
|
||||||
|
* 返回 {@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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user