feat(sample): 添加环境设置功能支持本地联调

- 新增 EnvironmentRepository 处理环境状态持久化
- 添加 EnvironmentScreen 提供环境切换界面
- 集成环境设置到主界面和登录界面导航
- 实现外网和本地联调模式切换逻辑
- 添加环境配置文档说明
这个提交包含在:
XuqmGroup 2026-04-27 19:33:08 +08:00
父节点 5a0378d579
当前提交 c318a318a3
共有 8 个文件被更改,包括 316 次插入4 次删除

查看文件

@ -92,6 +92,8 @@ SampleEnvironmentConfig.useLocalhost("10.0.2.2")
切换后,HTTP API 会立即走新端点;如果 IM 已登录,SDK 会自动重新连接。
sample-app 里也提供了一个“环境设置”页面,可以直接在外网和本地联调之间切换。
---
## sdk-core

查看文件

@ -3,14 +3,12 @@ package com.xuqm.sdk.sample
import android.app.Application
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.core.LogLevel
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
import com.xuqm.sdk.sample.di.AppDependencies
class XuqmSampleApp : Application() {
override fun onCreate() {
super.onCreate()
SampleEnvironmentConfig.useExternal()
AppDependencies.init(this)
XuqmSDK.initialize(
context = this,

查看文件

@ -0,0 +1,59 @@
package com.xuqm.sdk.sample.data.repo
import android.content.Context
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
enum class EnvironmentMode {
EXTERNAL,
LOCALHOST,
}
data class EnvironmentState(
val mode: EnvironmentMode = EnvironmentMode.EXTERNAL,
val host: String = "10.0.2.2",
)
class EnvironmentRepository(context: Context) {
private val prefs = context.getSharedPreferences("xuqm_demo_env", Context.MODE_PRIVATE)
fun current(): EnvironmentState = load().also { apply(it) }
fun setExternal() {
save(EnvironmentState(mode = EnvironmentMode.EXTERNAL, host = load().host))
SampleEnvironmentConfig.useExternal()
}
fun setLocalhost(host: String) {
val normalizedHost = host.trim().ifBlank { "10.0.2.2" }
save(EnvironmentState(mode = EnvironmentMode.LOCALHOST, host = normalizedHost))
SampleEnvironmentConfig.useLocalhost(normalizedHost)
}
private fun load(): EnvironmentState {
val mode = runCatching {
EnvironmentMode.valueOf(prefs.getString(KEY_MODE, EnvironmentMode.EXTERNAL.name)!!)
}.getOrDefault(EnvironmentMode.EXTERNAL)
val host = prefs.getString(KEY_HOST, "10.0.2.2").orEmpty().ifBlank { "10.0.2.2" }
return EnvironmentState(mode = mode, host = host)
}
private fun save(state: EnvironmentState) {
prefs.edit()
.putString(KEY_MODE, state.mode.name)
.putString(KEY_HOST, state.host)
.apply()
}
private fun apply(state: EnvironmentState) {
when (state.mode) {
EnvironmentMode.EXTERNAL -> SampleEnvironmentConfig.useExternal()
EnvironmentMode.LOCALHOST -> SampleEnvironmentConfig.useLocalhost(state.host)
}
}
private companion object {
const val KEY_MODE = "mode"
const val KEY_HOST = "host"
}
}

查看文件

@ -1,15 +1,19 @@
package com.xuqm.sdk.sample.di
import android.content.Context
import com.xuqm.sdk.sample.data.api.DemoApi
import com.xuqm.sdk.sample.data.repo.AuthRepository
import com.xuqm.sdk.sample.data.repo.EnvironmentRepository
object AppDependencies {
lateinit var authRepository: AuthRepository
private set
lateinit var environmentRepository: EnvironmentRepository
private set
fun init(context: Context) {
environmentRepository = EnvironmentRepository(context)
environmentRepository.current()
authRepository = AuthRepository(context)
}
}

查看文件

@ -10,6 +10,7 @@ import com.xuqm.sdk.sample.data.repo.AuthRepository
import com.xuqm.sdk.sample.ui.auth.LoginScreen
import com.xuqm.sdk.sample.ui.auth.RegisterScreen
import com.xuqm.sdk.sample.ui.chat.ChatScreen
import com.xuqm.sdk.sample.ui.environment.EnvironmentScreen
import com.xuqm.sdk.sample.ui.group.GroupSettingsScreen
import com.xuqm.sdk.sample.ui.main.MainScreen
import java.net.URLDecoder
@ -33,6 +34,7 @@ fun AppNavGraph(
}
},
onNavigateToRegister = { navController.navigate("register") },
onOpenEnvironment = { navController.navigate("environment") },
)
}
composable("register") {
@ -56,6 +58,7 @@ fun AppNavGraph(
onGroupSettings = { groupId ->
navController.navigate("group_settings/$groupId")
},
onOpenEnvironment = { navController.navigate("environment") },
onLogout = {
navController.navigate("auth") {
popUpTo("main") { inclusive = true }
@ -88,5 +91,11 @@ fun AppNavGraph(
onNavigateBack = { navController.popBackStack() },
)
}
composable("environment") {
EnvironmentScreen(
onNavigateBack = { navController.popBackStack() },
)
}
}
}

查看文件

@ -36,6 +36,7 @@ import com.xuqm.sdk.sample.di.AppDependencies
fun LoginScreen(
onLoginSuccess: () -> Unit,
onNavigateToRegister: () -> Unit,
onOpenEnvironment: () -> Unit,
viewModel: LoginViewModel = viewModel(
factory = viewModelFactory {
initializer { LoginViewModel(AppDependencies.authRepository) }
@ -111,5 +112,9 @@ fun LoginScreen(
TextButton(onClick = onNavigateToRegister) {
Text("没有账号?注册")
}
TextButton(onClick = onOpenEnvironment) {
Text("环境设置")
}
}
}

查看文件

@ -0,0 +1,225 @@
package com.xuqm.sdk.sample.ui.environment
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.xuqm.sdk.sample.data.repo.EnvironmentMode
import com.xuqm.sdk.sample.data.repo.EnvironmentRepository
import com.xuqm.sdk.sample.data.repo.EnvironmentState
import com.xuqm.sdk.sample.di.AppDependencies
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
data class EnvironmentUiState(
val mode: EnvironmentMode = EnvironmentMode.EXTERNAL,
val host: String = "10.0.2.2",
val message: String? = null,
)
class EnvironmentViewModel(
private val environmentRepository: EnvironmentRepository,
) : ViewModel() {
private val initial = environmentRepository.current()
private val _state = MutableStateFlow(initial.toUiState())
val state: StateFlow<EnvironmentUiState> = _state
fun selectExternal() {
_state.value = _state.value.copy(mode = EnvironmentMode.EXTERNAL, message = null)
}
fun selectLocalhost() {
_state.value = _state.value.copy(mode = EnvironmentMode.LOCALHOST, message = null)
}
fun updateHost(host: String) {
_state.value = _state.value.copy(host = host, message = null)
}
fun apply() {
val current = _state.value
when (current.mode) {
EnvironmentMode.EXTERNAL -> environmentRepository.setExternal()
EnvironmentMode.LOCALHOST -> environmentRepository.setLocalhost(current.host)
}
_state.value = current.copy(message = "已切换并保存")
}
private fun EnvironmentState.toUiState(): EnvironmentUiState =
EnvironmentUiState(mode = mode, host = host)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EnvironmentScreen(
onNavigateBack: () -> Unit,
viewModel: EnvironmentViewModel = viewModel(
factory = viewModelFactory {
initializer { EnvironmentViewModel(AppDependencies.environmentRepository) }
}
),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
var hostInput by remember(state.host) { mutableStateOf(state.host) }
LaunchedEffect(state.host) {
hostInput = state.host
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("环境设置") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = null)
}
},
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp)
.imePadding(),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
Text(
text = "切换 demo 与 SDK 的联调端点",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
Text(
text = if (state.mode == EnvironmentMode.EXTERNAL) {
"当前使用外网服务dev.xuqinmin.com"
} else {
"当前使用本地服务http://${state.host}:8081"
},
style = MaterialTheme.typography.bodyMedium,
)
EnvironmentModeRow(
title = "外网服务",
selected = state.mode == EnvironmentMode.EXTERNAL,
description = "走线上 / 开发服务器,适合正常联调。",
onClick = viewModel::selectExternal,
)
EnvironmentModeRow(
title = "本地联调",
selected = state.mode == EnvironmentMode.LOCALHOST,
description = "走本机或局域网服务,适合本地开发。",
onClick = viewModel::selectLocalhost,
)
OutlinedTextField(
value = hostInput,
onValueChange = {
hostInput = it
viewModel.updateHost(it)
},
modifier = Modifier.fillMaxWidth(),
label = { Text("本地 Host") },
placeholder = { Text("10.0.2.2 或你的电脑局域网 IP") },
singleLine = true,
enabled = state.mode == EnvironmentMode.LOCALHOST,
)
Text(
text = "切换后会立即重建 HTTP 客户端;如果 IM 已登录,会自动重连。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
Button(
onClick = viewModel::apply,
modifier = Modifier.fillMaxWidth(),
) {
Text("保存并应用")
}
state.message?.let {
Text(
text = it,
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall,
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EnvironmentModeRow(
title: String,
selected: Boolean,
description: String,
onClick: () -> Unit,
) {
androidx.compose.material3.Surface(
onClick = onClick,
tonalElevation = if (selected) 2.dp else 0.dp,
shape = MaterialTheme.shapes.medium,
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
androidx.compose.foundation.layout.Row(
verticalAlignment = Alignment.CenterVertically,
) {
RadioButton(selected = selected, onClick = onClick)
Text(title, style = MaterialTheme.typography.titleSmall)
}
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline,
)
}
}
}

查看文件

@ -8,11 +8,13 @@ import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.Group
import androidx.compose.material.icons.filled.People
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@ -44,13 +46,21 @@ private val tabs = listOf(
fun MainScreen(
onOpenChat: (targetId: String, chatType: String, targetName: String) -> Unit,
onGroupSettings: (groupId: String) -> Unit,
onOpenEnvironment: () -> Unit,
onLogout: () -> Unit,
) {
var selectedTab by remember { mutableIntStateOf(0) }
Scaffold(
topBar = {
TopAppBar(title = { Text(tabs[selectedTab].label) })
TopAppBar(
title = { Text(tabs[selectedTab].label) },
actions = {
IconButton(onClick = onOpenEnvironment) {
Icon(Icons.Default.Settings, contentDescription = "环境设置")
}
},
)
},
bottomBar = {
NavigationBar {