文档: 添加注释

This commit is contained in:
Kevin
2026-03-12 10:55:33 +08:00
parent c573882f3e
commit b09f1e8125
26 changed files with 210 additions and 9 deletions

View File

@@ -1,6 +1,20 @@
package com.huaga.life_echo.app
import android.content.Context
/**
* 应用组合根Composition Root
*
* 采用端口-适配器Hexagonal架构
* - **Port**:业务层依赖的接口(如 [ConversationApiPort]、[MemoirApiPort]
* - **Adapter**:将 Port 委托给具体实现ApiService、WebSocketClient
*
* 所有网络相关依赖在此统一创建与注入ViewModel、Repository 只依赖 Port
* 不直接依赖 Ktor/OkHttp便于测试与替换实现。
*
* 生命周期:由 [LifeEchoApp] 在 [android.app.Application.onCreate] 创建,
* [onTerminate] 时调用 [close] 释放连接onTerminate 在真机上通常不会被调用)。
*/
import com.huaga.life_echo.config.AppConfig
import com.huaga.life_echo.data.auth.TokenManager
import com.huaga.life_echo.data.database.AppDatabase
@@ -62,13 +76,13 @@ class AppContainer(private val context: Context) {
val webSocketClient: WebSocketClient by lazy { WebSocketClient(realtimeTransportProvider) }
// Feature ports
/** 各功能模块的 API Port由 Adapter 委托给 [apiService] */
val conversationApi: ConversationApiPort by lazy { ConversationApiAdapter(apiService) }
val memoirApi: MemoirApiPort by lazy { MemoirApiAdapter(apiService) }
val profileApi: ProfileApiPort by lazy { ProfileApiAdapter(apiService) }
val paymentApi: PaymentApiPort by lazy { PaymentApiAdapter(apiService) }
// Repositories
/** Repository 依赖 Port 而非 ApiService便于单测时注入 Fake */
val conversationRepository by lazy {
ConversationRepository(
conversationDao = database.conversationDao(),
@@ -103,11 +117,16 @@ class AppContainer(private val context: Context) {
)
}
/** 释放所有网络客户端,应在 Application 销毁时调用 */
fun close() {
restClientProvider.close()
realtimeTransportProvider.close()
}
/**
* 按 profile 创建 REST 客户端。
* AUTH 与 API 使用不同插件配置,见 [RestClientProfile]。
*/
private fun createRestClient(profile: RestClientProfile): HttpClient {
return when (profile) {
RestClientProfile.AUTH -> HttpClient(Android) {
@@ -174,6 +193,7 @@ class AppContainer(private val context: Context) {
}
}
/** 创建 WebSocket 用 HttpClient使用 OkHttp 引擎以支持长连接 */
private fun createRealtimeClient(): HttpClient {
return HttpClient(OkHttp) {
install(WebSockets)

View File

@@ -4,6 +4,12 @@ import android.app.Application
import com.huaga.life_echo.data.auth.TokenManager
import com.huaga.life_echo.ui.settings.AppSettings
/**
* 应用入口,负责初始化全局依赖。
*
* [container] 为组合根,在 [onCreate] 中创建;[onTerminate] 时调用 [AppContainer.close] 释放网络连接。
* 注:真机上 [onTerminate] 通常不会被调用,进程多由系统直接回收。
*/
class LifeEchoApp : Application() {
lateinit var container: AppContainer

View File

@@ -4,6 +4,13 @@ import com.huaga.life_echo.data.database.*
import com.huaga.life_echo.feature.conversation.ports.ConversationApiPort
import kotlinx.coroutines.flow.Flow
/**
* 对话数据仓库。
*
* 依赖 [ConversationApiPort] 而非 ApiService便于单测时注入 Fake。
* 本地数据通过 [ConversationDao]/[ConversationSegmentDao] 持久化,
* 远程操作(创建、删除、同步)通过 [conversationApi] 完成。
*/
class ConversationRepository(
private val conversationDao: ConversationDao,
private val segmentDao: ConversationSegmentDao,

View File

@@ -5,6 +5,12 @@ import com.huaga.life_echo.data.database.MessageDao
import com.huaga.life_echo.feature.conversation.ports.ConversationApiPort
import kotlinx.coroutines.flow.Flow
/**
* 消息数据仓库。
*
* 依赖 [ConversationApiPort] 获取远程消息,[syncMessages] 将 API 返回的 DTO 转为本地 [Message] 并入库。
* 本地读写通过 [MessageDao] 完成。
*/
class MessageRepository(
private val messageDao: MessageDao,
private val conversationApi: ConversationApiPort,
@@ -13,6 +19,7 @@ class MessageRepository(
return messageDao.getMessagesByConversationId(conversationId)
}
/** 从 [conversationApi] 拉取消息并写入本地数据库 */
suspend fun syncMessages(conversationId: String) {
val result = conversationApi.getMessages(conversationId)
result.getOrNull()?.let { messages ->

View File

@@ -1,6 +1,13 @@
package com.huaga.life_echo.data.repository
import com.huaga.life_echo.feature.payment.ports.PaymentApiPort
/**
* 支付与订阅数据仓库。
*
* 依赖 [PaymentApiPort] 而非 ApiService便于单测注入 Fake。
* 本仓库无本地持久化,数据直接来自 API。
*/
import com.huaga.life_echo.network.models.CreatePaymentOrderResponse
import com.huaga.life_echo.network.models.CurrentPlanDto
import com.huaga.life_echo.network.models.OrderDto

View File

@@ -1,6 +1,13 @@
package com.huaga.life_echo.data.repository
import com.huaga.life_echo.feature.profile.ports.ProfileApiPort
/**
* 个人资料数据仓库。
*
* 依赖 [ProfileApiPort] 而非 ApiService便于单测注入 Fake。
* 本仓库无本地持久化,数据直接来自 API。
*/
import com.huaga.life_echo.network.models.FAQDto
import com.huaga.life_echo.network.models.UserProfileDto

View File

@@ -8,6 +8,12 @@ import com.huaga.life_echo.network.models.CreateConversationResponse
import com.huaga.life_echo.network.models.MessageDto
import com.huaga.life_echo.network.models.TasksStatusDto
/**
* [ConversationApiPort] 的适配器实现。
*
* 将 Port 接口委托给 [ApiService],不包含业务逻辑。
* 若需切换后端实现,只需在此处修改委托目标。
*/
class ConversationApiAdapter(
private val apiService: ApiService,
) : ConversationApiPort {

View File

@@ -8,6 +8,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* [ConversationRealtimePort] 的适配器实现。
*
* 委托给 [WebSocketClient],并维护 [state] 供 UI 展示连接状态。
* [prepare] 会调用 [WebSocketClient.prepare] 预热 HttpClient再设置 state 为 Preparing。
*/
class ConversationRealtimeAdapter(
private val webSocketClient: WebSocketClient,
) : ConversationRealtimePort {

View File

@@ -6,6 +6,12 @@ import com.huaga.life_echo.network.models.CreateConversationResponse
import com.huaga.life_echo.network.models.MessageDto
import com.huaga.life_echo.network.models.TasksStatusDto
/**
* 对话相关 REST API 端口。
*
* 业务层ViewModel、Repository依赖此接口而非 [com.huaga.life_echo.network.ApiService]
* 便于单测时注入 Fake 实现。具体实现由 [com.huaga.life_echo.feature.conversation.adapters.ConversationApiAdapter] 提供。
*/
interface ConversationApiPort {
suspend fun createConversation(): Result<CreateConversationResponse>
suspend fun getConversationList(): Result<List<ConversationListItemDto>>

View File

@@ -3,6 +3,13 @@ package com.huaga.life_echo.feature.conversation.ports
import com.huaga.life_echo.network.WebSocketMessage
import kotlinx.coroutines.flow.StateFlow
/**
* 对话实时通信WebSocket端口。
*
* 封装连接、发送文本/语音、取消生成等能力,业务层不直接依赖 [com.huaga.life_echo.network.WebSocketClient]。
* [prepare] 应在 [connect] 前调用,用于预热 HttpClient减少首次连接延迟。
* [state] 反映连接状态UI 可据此展示加载或已连接。
*/
interface ConversationRealtimePort {
val state: StateFlow<State>
@@ -27,6 +34,7 @@ interface ConversationRealtimePort {
fun isGenerating(): Boolean
fun setGenerating(generating: Boolean)
/** 实时连接状态,用于 UI 展示 */
sealed class State {
data object NotConnected : State()
data object Preparing : State()
@@ -34,6 +42,12 @@ interface ConversationRealtimePort {
}
}
/**
* 分段语音请求参数。
*
* 长语音边录边传时,每段音频携带会话 ID、段索引、是否最后一段等信息。
* 重写 [equals]/[hashCode] 以支持 [ByteArray] 的内容比较。
*/
data class AudioSegmentRequest(
val audioBytes: ByteArray,
val conversationId: String,

View File

@@ -2,6 +2,8 @@ package com.huaga.life_echo.feature.memoir.adapters
import com.huaga.life_echo.feature.memoir.ports.MemoirApiPort
import com.huaga.life_echo.network.ApiService
/** [MemoirApiPort] 的适配器,委托给 [ApiService] */
import com.huaga.life_echo.network.models.BookDto
import com.huaga.life_echo.network.models.ChapterContentDto
import com.huaga.life_echo.network.models.ChapterDto

View File

@@ -6,6 +6,12 @@ import com.huaga.life_echo.network.models.ChapterDto
import com.huaga.life_echo.network.models.MemoirStateDto
import com.huaga.life_echo.network.models.TasksStatusDto
/**
* 回忆录相关 REST API 端口。
*
* 业务层(如 [MyMemoirViewModel])依赖此接口而非 ApiService便于单测注入 Fake。
* 实现由 [com.huaga.life_echo.feature.memoir.adapters.MemoirApiAdapter] 提供。
*/
interface MemoirApiPort {
suspend fun getBookInfo(): Result<BookDto>
suspend fun getChapters(): Result<List<ChapterDto>>

View File

@@ -2,6 +2,8 @@ package com.huaga.life_echo.feature.payment.adapters
import com.huaga.life_echo.feature.payment.ports.PaymentApiPort
import com.huaga.life_echo.network.ApiService
/** [PaymentApiPort] 的适配器,委托给 [ApiService] */
import com.huaga.life_echo.network.models.CreatePaymentOrderResponse
import com.huaga.life_echo.network.models.CurrentPlanDto
import com.huaga.life_echo.network.models.OrderDto

View File

@@ -8,6 +8,11 @@ import com.huaga.life_echo.network.models.PlanDto
import com.huaga.life_echo.network.models.QuotaCheckDto
import com.huaga.life_echo.network.models.TestSubscriptionResponse
/**
* 支付与订阅相关 REST API 端口。
*
* 业务层依赖此接口而非 ApiService便于单测。实现由 [PaymentApiAdapter] 提供。
*/
interface PaymentApiPort {
suspend fun getPlans(): Result<List<PlanDto>>
suspend fun getCurrentPlan(): Result<CurrentPlanDto>

View File

@@ -2,6 +2,8 @@ package com.huaga.life_echo.feature.profile.adapters
import com.huaga.life_echo.feature.profile.ports.ProfileApiPort
import com.huaga.life_echo.network.ApiService
/** [ProfileApiPort] 的适配器,委托给 [ApiService] */
import com.huaga.life_echo.network.models.FAQDto
import com.huaga.life_echo.network.models.UserProfileDto

View File

@@ -3,6 +3,11 @@ package com.huaga.life_echo.feature.profile.ports
import com.huaga.life_echo.network.models.FAQDto
import com.huaga.life_echo.network.models.UserProfileDto
/**
* 个人资料相关 REST API 端口。
*
* 业务层依赖此接口而非 ApiService便于单测。实现由 [ProfileApiAdapter] 提供。
*/
interface ProfileApiPort {
suspend fun getUserProfile(): Result<UserProfileDto>
suspend fun getFAQs(): Result<List<FAQDto>>

View File

@@ -5,6 +5,13 @@ import com.huaga.life_echo.network.models.*
import com.huaga.life_echo.network.runtime.RestClientProfile
import com.huaga.life_echo.network.runtime.RestClientProvider
import io.ktor.client.*
/**
* 业务 API 服务,使用 [RestClientProfile.API] 的 HttpClient。
*
* 通过 [RestClientProvider] 获取客户端,不自行创建,便于统一生命周期与 Bearer 刷新。
* 主构造由 [com.huaga.life_echo.app.AppContainer] 注入;无参构造已废弃,仅用于遗留调用。
*/
import io.ktor.client.call.*
import io.ktor.client.engine.android.*
import io.ktor.client.plugins.auth.*
@@ -25,6 +32,7 @@ class ApiService(
private val tokenManager: TokenManager? = null,
private val authService: AuthService? = null
) {
/** 遗留构造:内部创建临时 RestClientProvider供尚未迁移到 AppContainer 的调用方使用 */
@Suppress("DEPRECATION")
@Deprecated("Use the provider-based constructor via AppContainer")
constructor(
@@ -36,6 +44,7 @@ class ApiService(
authService = authService,
)
/** 每次请求时从 Provider 获取 API 档位客户端,保证使用带 Bearer 的配置 */
private val client get() = provider.getClient(RestClientProfile.API)
companion object {
@@ -110,7 +119,7 @@ class ApiService(
}
}
// ==================== 对话相关API ====================
// ==================== 对话相关 API ====================
suspend fun createConversation(): Result<CreateConversationResponse> {
return try {
@@ -167,7 +176,7 @@ class ApiService(
}
}
// ==================== 回忆录相关API ====================
// ==================== 回忆录相关 API ====================
suspend fun getBookInfo(): Result<BookDto> {
return try {
@@ -248,7 +257,7 @@ class ApiService(
}
}
// ==================== 付费系统相关API ====================
// ==================== 付费系统相关 API ====================
suspend fun getPlans(): Result<List<PlanDto>> {
return try {
@@ -317,7 +326,7 @@ class ApiService(
}
}
// ==================== 支付订单API ====================
// ==================== 支付订单 API ====================
suspend fun createPaymentOrder(
planId: String,
@@ -380,7 +389,7 @@ class ApiService(
}
}
// ==================== 用户相关API ====================
// ==================== 用户相关 API ====================
suspend fun setTestSubscription(
action: String,
@@ -435,7 +444,7 @@ class ApiService(
}
}
// ==================== 回忆录状态相关API ====================
// ==================== 回忆录状态相关 API ====================
suspend fun getMemoirState(): Result<MemoirStateDto> {
return try {
@@ -448,7 +457,7 @@ class ApiService(
}
}
// ==================== 任务状态相关API ====================
// ==================== 任务状态相关 API ====================
suspend fun getTasksStatus(): Result<TasksStatusDto> {
return try {

View File

@@ -4,6 +4,13 @@ import com.huaga.life_echo.config.AppConfig
import com.huaga.life_echo.network.models.*
import com.huaga.life_echo.network.runtime.RestClientProfile
import com.huaga.life_echo.network.runtime.RestClientProvider
/**
* 认证 API 服务,使用 [RestClientProfile.AUTH] 的 HttpClient。
*
* 登录、注册、刷新 token 等无需 Bearer插件栈更轻量。通过 [RestClientProvider] 获取客户端,
* 不自行创建。无参构造内部创建临时 Provider供单测或遗留调用使用。
*/
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.android.Android
@@ -29,6 +36,7 @@ import java.io.File
class AuthService(
private val provider: RestClientProvider,
) {
/** 无参构造:内部创建独立 Provider供单测或未接入 AppContainer 的场景使用 */
constructor() : this(
RestClientProvider { _ ->
HttpClient(Android) {
@@ -43,6 +51,7 @@ class AuthService(
}
)
/** 使用 AUTH 档位客户端,无 Bearer 插件 */
private val client get() = provider.getClient(RestClientProfile.AUTH)
companion object {

View File

@@ -15,9 +15,18 @@ import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import android.util.Log
/**
* WebSocket 客户端,负责与对话服务的实时通信。
*
* 通过 [RealtimeTransportProvider] 获取 [HttpClient],不自行创建,便于统一生命周期管理。
* 无参构造用于单测中继承并覆盖 [connect]/[disconnect] 等方法的 Fake 实现。
*
* [prepare] 调用 [RealtimeTransportProvider.warmUp] 预创建 HttpClient减少首次连接延迟。
*/
open class WebSocketClient(
private val transportProvider: RealtimeTransportProvider,
) {
/** 无参构造:内部创建独立 Provider供单测中 Fake 子类使用 */
constructor() : this(
RealtimeTransportProvider {
HttpClient(OkHttp) {
@@ -47,6 +56,7 @@ open class WebSocketClient(
private const val MAX_RECONNECT_ATTEMPTS = 5
}
/** 预热 HttpClient应在 [connect] 前调用以减少首次连接延迟 */
open fun prepare() {
transportProvider.warmUp()
}
@@ -121,6 +131,7 @@ open class WebSocketClient(
}
}
/** 持续接收 WebSocket 帧,解析后回调 onMessage连接异常时触发重连 */
private suspend fun receiveMessages(onMessage: (WebSocketMessage) -> Unit) {
try {
while (true) {
@@ -167,6 +178,7 @@ open class WebSocketClient(
}
}
/** 发送 WebSocket 消息connect 类型可在未确认连接时发送(用于握手) */
suspend fun sendMessage(message: WebSocketMessage) {
if (!isConnected && message.type != MessageType.connect) {
Log.w(TAG, "尝试发送消息但未连接: ${message.type}")
@@ -318,6 +330,7 @@ open class WebSocketClient(
Log.d(TAG, "WebSocket连接已断开")
}
/** 指数退避重连,最多 [MAX_RECONNECT_ATTEMPTS] 次 */
private suspend fun reconnectWithBackoff(
onMessage: (WebSocketMessage) -> Unit,
attempt: Int = 1

View File

@@ -1,6 +1,14 @@
package com.huaga.life_echo.network.runtime
/**
* 网络客户端就绪状态。
*
* 用于 [RestClientProvider] 和 [RealtimeTransportProvider] 对外暴露当前是否已创建并可用。
* 业务层可据此决定是否发起请求或展示加载状态。
*/
enum class NetworkReadiness {
/** 尚未创建任何客户端 */
NotInitialized,
/** 至少有一个客户端已创建并可用 */
ClientReady,
}

View File

@@ -5,6 +5,13 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* 实时传输WebSocket客户端提供者。
*
* 单例模式:多次 [getClient] 返回同一 [HttpClient]。
* [warmUp] 在进入对话页面前预创建客户端,避免首次连接时的冷启动延迟。
* 使用 [synchronized] 保证多线程下创建与关闭的线程安全。
*/
class RealtimeTransportProvider(
private val factory: () -> HttpClient,
) {

View File

@@ -5,8 +5,22 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* REST 客户端配置档位。
*
* AUTH 与 API 使用不同的 [HttpClient] 实例,因为:
* - AUTH登录/注册等无需 Bearer token插件栈更轻量
* - API业务接口需要 Auth 插件自动附加 token、401 时刷新,并配置更长超时
*/
enum class RestClientProfile { AUTH, API }
/**
* REST 客户端提供者。
*
* 按 [RestClientProfile] 缓存 [HttpClient],同一 profile 多次 [getClient] 返回同一实例。
* 使用 [synchronized] 保证多线程下单例创建安全。
* [warmUp] 可提前创建所有 profile 的客户端,减少首次请求延迟。
*/
class RestClientProvider(
private val factory: (RestClientProfile) -> HttpClient,
) {

View File

@@ -11,6 +11,12 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* 认证 ViewModel负责登录、注册、登出、token 刷新等。
*
* 依赖 [AuthService] 由 [ViewModelFactory] 从 [com.huaga.life_echo.app.AppContainer] 注入,
* 便于单测时替换为 Fake。认证策略见 [checkAuthStatus] 与 [tryRefreshTokenSilently]。
*/
class AuthViewModel(
context: Context,
private val authService: AuthService,

View File

@@ -47,6 +47,12 @@ import kotlinx.coroutines.sync.withLock
import java.io.File
import java.util.UUID
/**
* 创建回忆 ViewModel负责对话创建、语音录制、消息收发、流式回复处理等。
*
* 依赖 [ConversationApiPort] 与 [ConversationRealtimePort] 而非 ApiService/WebSocketClient
* 由 [ViewModelFactory] 注入。进入对话前需调用 [conversationRealtime.prepare] 预热 WebSocket 客户端。
*/
class CreateMemoryViewModel(
private val conversationRepository: ConversationRepository,
private val chapterRepository: ChapterRepository,
@@ -136,6 +142,10 @@ class CreateMemoryViewModel(
}
}
/**
* 初始化已有对话:先 [prepare] 预热 WebSocket再加载历史消息并连接。
* 若 convId 为 null 或 "new" 则直接返回。
*/
fun initializeConversation(convId: String?) {
if (convId == null || convId == "new") {
return
@@ -173,6 +183,7 @@ class CreateMemoryViewModel(
}
}
/** 从 [conversationApi] 拉取历史消息并同步到 [messageRepository] */
private suspend fun loadHistoryMessages(convId: String) {
val result = conversationApi.getMessages(convId)
result.fold(
@@ -186,6 +197,7 @@ class CreateMemoryViewModel(
)
}
/** 创建新对话:调用 API 创建后连接 WebSocket清空任务状态并准备录制 */
fun startConversation() {
viewModelScope.launch {
connectionStatus.value = "创建对话中..."
@@ -800,6 +812,7 @@ class CreateMemoryViewModel(
}
}
/** 根据 [WebSocketMessage.type] 分发处理agent_response、transcript、connect、error 等 */
private fun handleWebSocketMessage(message: WebSocketMessage) {
lastMessageType.value = message.type.name
lastMessageTime.value = java.text.SimpleDateFormat("HH:mm:ss", java.util.Locale.getDefault()).format(java.util.Date())

View File

@@ -7,6 +7,13 @@ import androidx.lifecycle.viewModelScope
import com.huaga.life_echo.data.database.Chapter
import com.huaga.life_echo.data.repository.ChapterRepository
import com.huaga.life_echo.feature.memoir.ports.MemoirApiPort
/**
* 我的回忆录 ViewModel。
*
* 依赖 [MemoirApiPort] 和 [ChapterRepository],由 [ViewModelFactory] 从 [AppContainer] 注入。
* 章节数据本地存于 Room远程同步通过 [memoirApi] 拉取并写入 [chapterRepository]。
*/
import com.huaga.life_echo.network.models.MemoirStateDto
import com.huaga.life_echo.network.models.TasksStatusDto
import kotlinx.coroutines.flow.MutableStateFlow

View File

@@ -7,6 +7,13 @@ import com.huaga.life_echo.app.AppContainer
import com.huaga.life_echo.app.LifeEchoApp
import com.huaga.life_echo.feature.conversation.adapters.ConversationRealtimeAdapter
/**
* ViewModel 工厂,从 [AppContainer] 获取依赖并注入到各 ViewModel。
*
* 各 ViewModel 依赖 Port 接口(如 [ConversationApiPort])而非 ApiService/WebSocketClient
* 依赖由 container 提供;[ConversationRealtimeAdapter] 在此处用 container.webSocketClient 构造,
* 因 Adapter 内部持有连接 state每次创建新实例以保持 state 与当前屏幕一致。
*/
class ViewModelFactory(private val context: Context) : ViewModelProvider.Factory {
private val container: AppContainer