feat: 集成服务端驱动 UI 演示

新增 Remote Compose 播放端与服务端生成逻辑,支持按页面拉取并渲染二进制 UI 文档。
This commit is contained in:
2026-08-14 10:43:44 +08:00
parent c37b8ff0a4
commit 2bb3255704
11 changed files with 447 additions and 29 deletions

View File

@@ -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 Playerview 版,参考 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")
}

View File

@@ -2,12 +2,15 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ComposeRemoteAndroid">

View File

@@ -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")
}
}

View File

@@ -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"
}

View File

@@ -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("重试") }
}
}
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>