重构: 添加网络客户端提供器

引入由组合根持有的 RestClientProvider 和 RealtimeTransportProvider,并显式跟踪就绪状态。
AppContainer 负责提供器生命周期;LifeEchoApp 在 Application.onCreate 中完成装配。
This commit is contained in:
Kevin
2026-03-12 10:35:45 +08:00
parent c4eb481c5c
commit 0f53d7ce34
8 changed files with 384 additions and 0 deletions

View File

@@ -12,6 +12,7 @@
</queries>
<application
android:name=".app.LifeEchoApp"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"

View File

@@ -0,0 +1,180 @@
package com.huaga.life_echo.app
import android.content.Context
import com.huaga.life_echo.config.AppConfig
import com.huaga.life_echo.data.auth.TokenManager
import com.huaga.life_echo.data.database.AppDatabase
import com.huaga.life_echo.data.repository.ChapterRepository
import com.huaga.life_echo.data.repository.ConversationRepository
import com.huaga.life_echo.data.repository.MessageRepository
import com.huaga.life_echo.data.repository.PaymentRepository
import com.huaga.life_echo.data.repository.ProfileRepository
import com.huaga.life_echo.feature.memoir.adapters.MemoirApiAdapter
import com.huaga.life_echo.feature.memoir.ports.MemoirApiPort
import com.huaga.life_echo.feature.payment.adapters.PaymentApiAdapter
import com.huaga.life_echo.feature.payment.ports.PaymentApiPort
import com.huaga.life_echo.feature.profile.adapters.ProfileApiAdapter
import com.huaga.life_echo.feature.profile.ports.ProfileApiPort
import com.huaga.life_echo.network.ApiService
import com.huaga.life_echo.network.AuthService
import com.huaga.life_echo.network.WebSocketClient
import com.huaga.life_echo.network.runtime.RealtimeTransportProvider
import com.huaga.life_echo.network.runtime.RestClientProfile
import com.huaga.life_echo.network.runtime.RestClientProvider
import com.huaga.life_echo.payment.PaymentManager
import io.ktor.client.HttpClient
import io.ktor.client.engine.android.Android
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.auth.Auth
import io.ktor.client.plugins.auth.providers.BearerTokens
import io.ktor.client.plugins.auth.providers.bearer
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.logging.LogLevel
import io.ktor.client.plugins.logging.Logging
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.json.Json
class AppContainer(private val context: Context) {
val restClientProvider: RestClientProvider by lazy {
RestClientProvider(factory = ::createRestClient)
}
val realtimeTransportProvider: RealtimeTransportProvider by lazy {
RealtimeTransportProvider(factory = ::createRealtimeClient)
}
private val database by lazy { AppDatabase.getDatabase(context) }
val authService: AuthService by lazy { AuthService(restClientProvider) }
val apiService: ApiService by lazy {
ApiService(
provider = restClientProvider,
tokenManager = TokenManager,
authService = authService
)
}
val webSocketClient: WebSocketClient by lazy { WebSocketClient(realtimeTransportProvider) }
// Feature ports
val memoirApi: MemoirApiPort by lazy { MemoirApiAdapter(apiService) }
val profileApi: ProfileApiPort by lazy { ProfileApiAdapter(apiService) }
val paymentApi: PaymentApiPort by lazy { PaymentApiAdapter(apiService) }
// Repositories
val conversationRepository by lazy {
ConversationRepository(
conversationDao = database.conversationDao(),
segmentDao = database.conversationSegmentDao(),
apiService = apiService
)
}
val chapterRepository by lazy {
ChapterRepository(chapterDao = database.chapterDao())
}
val messageRepository by lazy {
MessageRepository(
messageDao = database.messageDao(),
apiService = apiService
)
}
val paymentRepository by lazy {
PaymentRepository(paymentApi = paymentApi)
}
val profileRepository by lazy {
ProfileRepository(profileApi = profileApi)
}
val paymentManager by lazy {
PaymentManager(
context = context.applicationContext,
wechatAppId = AppConfig.WECHAT_APP_ID
)
}
fun close() {
restClientProvider.close()
realtimeTransportProvider.close()
}
private fun createRestClient(profile: RestClientProfile): HttpClient {
return when (profile) {
RestClientProfile.AUTH -> HttpClient(Android) {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
encodeDefaults = false
})
}
install(Logging) { level = LogLevel.INFO }
}
RestClientProfile.API -> HttpClient(Android) {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
install(Logging) { level = LogLevel.INFO }
install(HttpTimeout) {
requestTimeoutMillis = 45_000
connectTimeoutMillis = 15_000
socketTimeoutMillis = 45_000
}
install(Auth) {
bearer {
loadTokens {
val access = TokenManager.getAccessToken()
val refresh = TokenManager.getRefreshToken()
if (!access.isNullOrBlank() && !refresh.isNullOrBlank()) {
BearerTokens(access, refresh)
} else null
}
refreshTokens {
val refresh = oldTokens?.refreshToken
?: TokenManager.getRefreshToken()
if (!refresh.isNullOrBlank()) {
val result = authService.refreshToken(refresh)
result.fold(
onSuccess = { tokenResponse ->
TokenManager.saveTokens(
tokenResponse.access_token,
tokenResponse.refresh_token,
TokenManager.getUserId() ?: ""
)
BearerTokens(
tokenResponse.access_token,
tokenResponse.refresh_token
)
},
onFailure = {
TokenManager.clearTokens()
TokenManager.notifyTokenRefreshFailed()
null
}
)
} else {
TokenManager.clearTokens()
TokenManager.notifyTokenRefreshFailed()
null
}
}
sendWithoutRequest { true }
}
}
}
}
}
private fun createRealtimeClient(): HttpClient {
return HttpClient(OkHttp) {
install(WebSockets)
install(Logging) { level = LogLevel.INFO }
}
}
}

View File

@@ -0,0 +1,23 @@
package com.huaga.life_echo.app
import android.app.Application
import com.huaga.life_echo.data.auth.TokenManager
import com.huaga.life_echo.ui.settings.AppSettings
class LifeEchoApp : Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
TokenManager.initialize(this)
AppSettings.initialize(this)
container = AppContainer(this)
}
override fun onTerminate() {
super.onTerminate()
container.close()
}
}

View File

@@ -0,0 +1,6 @@
package com.huaga.life_echo.network.runtime
enum class NetworkReadiness {
NotInitialized,
ClientReady,
}

View File

@@ -0,0 +1,31 @@
package com.huaga.life_echo.network.runtime
import io.ktor.client.HttpClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class RealtimeTransportProvider(
private val factory: () -> HttpClient,
) {
private var client: HttpClient? = null
private val _readiness = MutableStateFlow(NetworkReadiness.NotInitialized)
val readiness: StateFlow<NetworkReadiness> = _readiness.asStateFlow()
fun warmUp() {
getClient()
}
fun getClient(): HttpClient {
return client ?: factory().also {
client = it
_readiness.value = NetworkReadiness.ClientReady
}
}
fun close() {
client?.close()
client = null
_readiness.value = NetworkReadiness.NotInitialized
}
}

View File

@@ -0,0 +1,34 @@
package com.huaga.life_echo.network.runtime
import io.ktor.client.HttpClient
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
enum class RestClientProfile { AUTH, API }
class RestClientProvider(
private val factory: (RestClientProfile) -> HttpClient,
) {
private val clients = mutableMapOf<RestClientProfile, HttpClient>()
private val _readiness = MutableStateFlow(NetworkReadiness.NotInitialized)
val readiness: StateFlow<NetworkReadiness> = _readiness.asStateFlow()
fun getClient(profile: RestClientProfile): HttpClient {
return clients.getOrPut(profile) {
factory(profile).also {
_readiness.value = NetworkReadiness.ClientReady
}
}
}
fun warmUp() {
RestClientProfile.entries.forEach { getClient(it) }
}
fun close() {
clients.values.forEach { it.close() }
clients.clear()
_readiness.value = NetworkReadiness.NotInitialized
}
}

View File

@@ -0,0 +1,43 @@
package com.huaga.life_echo.network.runtime
import io.ktor.client.HttpClient
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
import org.mockito.Mockito
class RealtimeTransportProviderTest {
private val fakeFactory: () -> HttpClient = {
Mockito.mock(HttpClient::class.java)
}
@Test
fun realtime_provider_only_reports_client_ready_after_warmup_or_getClient() {
val provider = RealtimeTransportProvider(factory = fakeFactory)
assertEquals(NetworkReadiness.NotInitialized, provider.readiness.value)
provider.warmUp()
assertEquals(NetworkReadiness.ClientReady, provider.readiness.value)
}
@Test
fun get_client_returns_same_instance() {
val provider = RealtimeTransportProvider(factory = fakeFactory)
val client1 = provider.getClient()
val client2 = provider.getClient()
assertSame(client1, client2)
}
@Test
fun close_resets_readiness_to_not_initialized() {
val provider = RealtimeTransportProvider(factory = fakeFactory)
provider.warmUp()
provider.close()
assertEquals(NetworkReadiness.NotInitialized, provider.readiness.value)
}
}

View File

@@ -0,0 +1,66 @@
package com.huaga.life_echo.network.runtime
import io.ktor.client.HttpClient
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotSame
import org.junit.Assert.assertSame
import org.junit.Test
import org.mockito.Mockito
class RestClientProviderTest {
private val fakeFactory: (RestClientProfile) -> HttpClient = {
Mockito.mock(HttpClient::class.java)
}
@Test
fun auth_and_api_clients_are_stable_per_profile_until_close() {
val provider = RestClientProvider(factory = fakeFactory)
val auth1 = provider.getClient(RestClientProfile.AUTH)
val auth2 = provider.getClient(RestClientProfile.AUTH)
val api1 = provider.getClient(RestClientProfile.API)
assertSame(auth1, auth2)
assertNotSame(auth1, api1)
}
@Test
fun readiness_is_not_initialized_before_any_client_is_requested() {
val provider = RestClientProvider(factory = fakeFactory)
assertEquals(NetworkReadiness.NotInitialized, provider.readiness.value)
}
@Test
fun readiness_is_client_ready_after_get_client() {
val provider = RestClientProvider(factory = fakeFactory)
provider.getClient(RestClientProfile.AUTH)
assertEquals(NetworkReadiness.ClientReady, provider.readiness.value)
}
@Test
fun warm_up_creates_all_profiles_and_reports_ready() {
val created = mutableListOf<RestClientProfile>()
val provider = RestClientProvider { profile ->
created += profile
Mockito.mock(HttpClient::class.java)
}
provider.warmUp()
assertEquals(RestClientProfile.entries.toList(), created)
assertEquals(NetworkReadiness.ClientReady, provider.readiness.value)
}
@Test
fun close_resets_readiness_to_not_initialized() {
val provider = RestClientProvider(factory = fakeFactory)
provider.warmUp()
provider.close()
assertEquals(NetworkReadiness.NotInitialized, provider.readiness.value)
}
}