feat(sdk): 实现动态服务端点配置和环境切换功能

- 移除硬编码的基础URL常量,改为可配置的服务端点
- 添加ServiceEndpointRegistry用于统一管理所有服务端点地址
- 实现ApiClient支持多基础URL的Retrofit实例缓存机制
- 新增XuqmSDK.configureServiceEndpoints等方法用于运行时切换环境
- 为sample-app添加SampleEnvironmentConfig支持本地联调环境切换
- 创建独立的IM、Push、Update SDK模块并集成服务端点配置
- 更新文档说明如何进行联调环境切换操作
这个提交包含在:
XuqmGroup 2026-04-27 19:30:06 +08:00
父节点 087753075e
当前提交 5a0378d579
共有 12 个文件被更改,包括 146 次插入22 次删除

查看文件

@ -75,6 +75,23 @@ XuqmSDK.login(
// 如果工程里集成了 sdk-im,SDK 会自动完成 IM 登录
```
### 3. 切换联调环境
默认使用外网域名。若要本地联调,可在 `Application.onCreate()` 里切换:
```kotlin
XuqmSDK.useLocalServiceEndpoints("10.0.2.2") // Android Emulator
// 物理设备可改成你的电脑局域网 IP
```
如果是 sample-app,也可以直接调用
```kotlin
SampleEnvironmentConfig.useLocalhost("10.0.2.2")
```
切换后,HTTP API 会立即走新端点;如果 IM 已登录,SDK 会自动重新连接。
---
## sdk-core

查看文件

@ -3,12 +3,14 @@ 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,34 @@
package com.xuqm.sdk.sample.config
import com.xuqm.sdk.XuqmSDK
data class SampleEnvironment(
val demoBaseUrl: String,
val serviceHost: String? = null,
)
object SampleEnvironmentConfig {
@Volatile
var current: SampleEnvironment = external()
private set
fun external(): SampleEnvironment = SampleEnvironment(
demoBaseUrl = "https://dev.xuqinmin.com/",
serviceHost = null,
)
fun localhost(host: String): SampleEnvironment = SampleEnvironment(
demoBaseUrl = "http://$host:8081/",
serviceHost = host,
)
fun useExternal() {
current = external()
XuqmSDK.useExternalServiceEndpoints()
}
fun useLocalhost(host: String) {
current = localhost(host)
XuqmSDK.useLocalServiceEndpoints(host)
}
}

查看文件

@ -11,7 +11,6 @@ import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Query
const val DEMO_BASE_URL = "https://dev.xuqinmin.com/"
const val DEMO_APP_ID = "ak_demo_chat"
data class DemoResponse<T>(
@ -85,7 +84,10 @@ interface DemoApi {
}
object DemoApiFactory {
fun create(tokenProvider: () -> String?): DemoApi {
fun create(
baseUrl: String,
tokenProvider: () -> String?,
): DemoApi {
val authInterceptor = Interceptor { chain ->
val request = chain.request().newBuilder().apply {
tokenProvider()?.takeIf { it.isNotBlank() }?.let { header("Authorization", "Bearer $it") }
@ -98,7 +100,7 @@ object DemoApiFactory {
.build()
return Retrofit.Builder()
.baseUrl(DEMO_BASE_URL)
.baseUrl(baseUrl)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()

查看文件

@ -14,6 +14,7 @@ import com.xuqm.sdk.sample.data.api.RegisterRequest
import com.xuqm.sdk.sample.data.api.ResetPasswordRequest
import com.xuqm.sdk.sample.data.api.UpdateProfileRequest
import com.xuqm.sdk.sample.data.api.UserData
import com.xuqm.sdk.sample.config.SampleEnvironmentConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ -27,7 +28,11 @@ class AuthRepository(context: Context) {
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
private val api: DemoApi = DemoApiFactory.create(::getDemoToken)
private val api: DemoApi
get() = DemoApiFactory.create(
baseUrl = SampleEnvironmentConfig.current.demoBaseUrl,
tokenProvider = ::getDemoToken,
)
fun getDemoToken(): String? = prefs.getString("demo_token", null)
fun getCurrentUserId(): String? = prefs.getString("user_id", null)

查看文件

@ -3,6 +3,8 @@ package com.xuqm.sdk
import android.content.Context
import com.xuqm.sdk.auth.TokenStore
import com.xuqm.sdk.core.LogLevel
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.core.ServiceEndpoints
import com.xuqm.sdk.core.SDKConfig
import com.xuqm.sdk.network.ApiClient
import kotlinx.coroutines.Dispatchers
@ -31,6 +33,20 @@ object XuqmSDK {
initialized = true
}
fun configureServiceEndpoints(endpoints: ServiceEndpoints) {
ServiceEndpointRegistry.configure(endpoints)
loginSession?.let { notifyOptionalModules("onSdkLogin", it) }
}
fun useExternalServiceEndpoints() {
configureServiceEndpoints(ServiceEndpoints())
}
fun useLocalServiceEndpoints(host: String) {
ServiceEndpointRegistry.useLocalhost(host)
loginSession?.let { notifyOptionalModules("onSdkLogin", it) }
}
fun requireInit() {
check(initialized) { "XuqmSDK not initialized. Call XuqmSDK.initialize() first." }
}

查看文件

@ -1,8 +1,5 @@
package com.xuqm.sdk.core
const val BASE_URL = "https://dev.xuqinmin.com/"
const val WS_URL = "wss://dev.xuqinmin.com/ws/im"
data class SDKConfig(
val appId: String,
val logLevel: LogLevel = LogLevel.WARN,

查看文件

@ -0,0 +1,41 @@
package com.xuqm.sdk.core
data class ServiceEndpoints(
val controlBaseUrl: String = "https://dev.xuqinmin.com/",
val imApiBaseUrl: String = "https://im.dev.xuqinmin.com/",
val imWsUrl: String = "wss://im.dev.xuqinmin.com/ws/im",
val pushBaseUrl: String = "https://dev.xuqinmin.com/",
val updateBaseUrl: String = "https://update.dev.xuqinmin.com/",
)
object ServiceEndpointRegistry {
@Volatile
var current: ServiceEndpoints = ServiceEndpoints()
private set
val controlBaseUrl: String get() = current.controlBaseUrl
val imApiBaseUrl: String get() = current.imApiBaseUrl
val imWsUrl: String get() = current.imWsUrl
val pushBaseUrl: String get() = current.pushBaseUrl
val updateBaseUrl: String get() = current.updateBaseUrl
fun configure(endpoints: ServiceEndpoints) {
current = endpoints
}
fun useLocalhost(host: String) {
val normalizedHost = host.removeSuffix("/")
current = ServiceEndpoints(
controlBaseUrl = "http://$normalizedHost:8081/",
imApiBaseUrl = "http://$normalizedHost:8082/",
imWsUrl = "ws://$normalizedHost:8082/ws/im",
pushBaseUrl = "http://$normalizedHost:8081/",
updateBaseUrl = "http://$normalizedHost:8084/",
)
}
fun useExternal() {
current = ServiceEndpoints()
}
}

查看文件

@ -1,7 +1,7 @@
package com.xuqm.sdk.network
import com.xuqm.sdk.auth.TokenStore
import com.xuqm.sdk.core.BASE_URL
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.core.LogLevel
import com.xuqm.sdk.core.SDKConfig
import okhttp3.OkHttpClient
@ -14,8 +14,8 @@ import java.util.concurrent.TimeUnit
object ApiClient {
private var tokenStore: TokenStore? = null
lateinit var retrofit: Retrofit
private set
private var okHttpClient: OkHttpClient? = null
private val retrofitCache = mutableMapOf<String, Retrofit>()
fun init(cfg: SDKConfig, store: TokenStore) {
tokenStore = store
@ -25,7 +25,7 @@ object ApiClient {
else HttpLoggingInterceptor.Level.NONE
}
val okhttp = OkHttpClient.Builder()
okHttpClient = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.addInterceptor(logging)
@ -40,12 +40,20 @@ object ApiClient {
}
.build()
retrofit = Retrofit.Builder()
.baseUrl(BASE_URL)
.client(okhttp)
.addConverterFactory(GsonConverterFactory.create())
.build()
synchronized(this) {
retrofitCache.clear()
}
}
inline fun <reified T> create(): T = retrofit.create(T::class.java)
fun <T : Any> create(service: Class<T>, baseUrl: String = ServiceEndpointRegistry.controlBaseUrl): T {
val retrofit = synchronized(this) {
retrofitCache[baseUrl] ?: Retrofit.Builder()
.baseUrl(baseUrl)
.client(requireNotNull(okHttpClient) { "ApiClient not initialized" })
.addConverterFactory(GsonConverterFactory.create())
.build()
.also { retrofitCache[baseUrl] = it }
}
return retrofit.create(service)
}
}

查看文件

@ -2,7 +2,7 @@ package com.xuqm.sdk.im
import com.xuqm.sdk.XuqmLoginSession
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.core.WS_URL
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.im.api.AddMemberRequest
import com.xuqm.sdk.im.api.CreateGroupRequest
import com.xuqm.sdk.im.api.ImApi
@ -20,7 +20,7 @@ import kotlinx.coroutines.withContext
object ImSDK {
private var client: ImClient? = null
private val api: ImApi by lazy { ApiClient.create() }
private val api: ImApi get() = ApiClient.create(ImApi::class.java, ServiceEndpointRegistry.imApiBaseUrl)
var currentUserId: String = ""
private set
@ -131,7 +131,7 @@ object ImSDK {
private fun connectWithToken(token: String) {
XuqmSDK.tokenStore.saveToken(token)
client?.disconnect()
client = ImClient(WS_URL, token, XuqmSDK.appId)
client = ImClient(ServiceEndpointRegistry.imWsUrl, token, XuqmSDK.appId)
client?.connect()
}

查看文件

@ -2,6 +2,7 @@ package com.xuqm.sdk.push
import android.content.Context
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient
import com.xuqm.sdk.push.api.PushApi
import com.xuqm.sdk.utils.DeviceUtils
@ -11,7 +12,7 @@ import kotlinx.coroutines.launch
object PushSDK {
private val api: PushApi by lazy { ApiClient.create() }
private val api: PushApi get() = ApiClient.create(PushApi::class.java, ServiceEndpointRegistry.pushBaseUrl)
private val scope = CoroutineScope(Dispatchers.IO)
fun registerDevice(context: Context, userId: String) {

查看文件

@ -5,6 +5,7 @@ import android.content.Intent
import android.net.Uri
import androidx.core.content.FileProvider
import com.xuqm.sdk.XuqmSDK
import com.xuqm.sdk.core.ServiceEndpointRegistry
import com.xuqm.sdk.network.ApiClient
import com.xuqm.sdk.update.api.UpdateApi
import com.xuqm.sdk.update.model.RnUpdateInfo
@ -16,7 +17,7 @@ import java.net.URL
object UpdateSDK {
private val api: UpdateApi by lazy { ApiClient.create() }
private val api: UpdateApi get() = ApiClient.create(UpdateApi::class.java, ServiceEndpointRegistry.updateBaseUrl)
private fun normalizeDownloadUrl(rawUrl: String?): String? {
if (rawUrl.isNullOrBlank()) return rawUrl