feat: 集成服务端驱动 UI 演示
新增 Remote Compose 播放端与服务端生成逻辑,支持按页面拉取并渲染二进制 UI 文档。
This commit is contained in:
@@ -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")
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user