重构: 添加网络客户端提供器
引入由组合根持有的 RestClientProvider 和 RealtimeTransportProvider,并显式跟踪就绪状态。 AppContainer 负责提供器生命周期;LifeEchoApp 在 Application.onCreate 中完成装配。
This commit is contained in:
@@ -12,6 +12,7 @@
|
|||||||
</queries>
|
</queries>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:name=".app.LifeEchoApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.huaga.life_echo.network.runtime
|
||||||
|
|
||||||
|
enum class NetworkReadiness {
|
||||||
|
NotInitialized,
|
||||||
|
ClientReady,
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user