From 96068d56b26efac1d6b10500055296307406b186 Mon Sep 17 00:00:00 2001 From: fengge <844143714@qq.com> Date: Wed, 3 Jun 2026 14:48:56 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BC=98=E5=8C=96(=E6=97=A5=E5=BF=97):=20?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=9E=81=E7=AB=AF=E7=9A=84=E6=97=A5=E5=BF=97?= =?UTF-8?q?=20I/O=20=E6=80=A7=E8=83=BD=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 过滤掉高频的 VERBOSE 级别日志写入文件(例如录音帧),但依然让其能够在控制台中输出。 2. 保持日志的正常记录逻辑不变,仅作频率控制拦截,减少低端机因为频繁磁盘读写而引发的阻塞、卡顿与 OOM 问题。 --- app/src/main/java/com/stand/standapp/utils/LogManager.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/com/stand/standapp/utils/LogManager.kt b/app/src/main/java/com/stand/standapp/utils/LogManager.kt index 450092e..8a7945d 100644 --- a/app/src/main/java/com/stand/standapp/utils/LogManager.kt +++ b/app/src/main/java/com/stand/standapp/utils/LogManager.kt @@ -187,6 +187,9 @@ object LogManager { private val fileNameFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + // 过滤掉高频的 VERBOSE 级别日志写入(例如录音帧),保留在控制台输出 + if (priority == android.util.Log.VERBOSE) return + executor.execute { try { val logFile = File(logDir, "${fileNameFormat.format(Date())}.log") From 89f1df9a9bbe131798893cffe0ac642768b3d867 Mon Sep 17 00:00:00 2001 From: fengge <844143714@qq.com> Date: Wed, 3 Jun 2026 14:48:56 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E4=BC=98=E5=8C=96(UI):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20Compose=20=E7=9A=84=E6=82=AC=E6=B5=AE=E7=AA=97?= =?UTF-8?q?=E7=81=BE=E9=9A=BE=E6=80=A7=E9=87=8D=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 利用延迟读取(Deferred State Reading)的特性,将 fabOffsetX 等坐标变量的读取放置在 Modifier.offset 的 lambda 内部。 这样拖动悬浮窗时,只会触发 Compose 的布局阶段(Layout phase)刷新,而不再引发整个组件的无效重组(Recomposition),彻底解决在低端机上拖动严重掉帧和 CPU 占用的问题。 --- .../standapp/ui/chat/FloatingChatWidget.kt | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/stand/standapp/ui/chat/FloatingChatWidget.kt b/app/src/main/java/com/stand/standapp/ui/chat/FloatingChatWidget.kt index 42585f5..84dcd91 100644 --- a/app/src/main/java/com/stand/standapp/ui/chat/FloatingChatWidget.kt +++ b/app/src/main/java/com/stand/standapp/ui/chat/FloatingChatWidget.kt @@ -94,25 +94,19 @@ fun FloatingChatWidget() { visible = true } - // 👈 用 FAB 位置计算对话框位置,但强制钳制在屏幕内 - // FAB右下角为基准:对话框应该出现在 FAB 附近 - val fabRight = screenWidthPx - paddingPx * 2 + fabOffsetX // FAB右边缘 - val fabBottom = screenHeightPx - paddingPx * 2 - navBarPx + fabOffsetY // FAB下边缘 - - // 对话框优先放在 FAB 左上方,如果放不下就贴边 - val rawX = fabRight - cardWidthPx - paddingPx // FAB左边 - val rawY = fabBottom - cardHeightPx - paddingPx // FAB上边 - - // 👈 关键:钳制到屏幕内 - val clampedX = rawX.coerceIn(0f, screenWidthPx - cardWidthPx) - val clampedY = rawY.coerceIn(0f, screenHeightPx - cardHeightPx) - AnimatedVisibility( visible = visible, enter = fadeIn(animationSpec = tween(300)) + scaleIn(initialScale = 0.8f, animationSpec = tween(300)), exit = fadeOut(animationSpec = tween(200)) + scaleOut(targetScale = 0.8f, animationSpec = tween(200)), modifier = Modifier .offset { + val fabRight = screenWidthPx - paddingPx * 2 + fabOffsetX + val fabBottom = screenHeightPx - paddingPx * 2 - navBarPx + fabOffsetY + val rawX = fabRight - cardWidthPx - paddingPx + val rawY = fabBottom - cardHeightPx - paddingPx + val clampedX = rawX.coerceIn(0f, screenWidthPx - cardWidthPx) + val clampedY = rawY.coerceIn(0f, screenHeightPx - cardHeightPx) + IntOffset( (clampedX + cardDragX).roundToInt() .coerceIn(0, (screenWidthPx - cardWidthPx).toInt()), From e266a4e69028010784c5c3a555f9f465465868b5 Mon Sep 17 00:00:00 2001 From: fengge <844143714@qq.com> Date: Wed, 3 Jun 2026 14:48:58 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E4=BC=98=E5=8C=96(UI):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20Markdown=20=E6=B8=B2=E6=9F=93=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E7=9A=84=E5=86=85=E5=AD=98=E6=8A=96=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 Markwon 解析引擎的 builder 创建逻辑提取到 LazyColumn 的外部并记忆化(remember),将其作为参数传入每一条消息的渲染组件中。 这避免了在聊天列表滚动时,每条消息都会高频实例化一个极度消耗资源的全新的 Markwon 引擎,从而极大减少了短命对象的创建和 GC 压力。 --- .../standapp/ui/chat/components/ChatArea.kt | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/stand/standapp/ui/chat/components/ChatArea.kt b/app/src/main/java/com/stand/standapp/ui/chat/components/ChatArea.kt index 3e2a1df..2580c4a 100644 --- a/app/src/main/java/com/stand/standapp/ui/chat/components/ChatArea.kt +++ b/app/src/main/java/com/stand/standapp/ui/chat/components/ChatArea.kt @@ -40,6 +40,18 @@ fun ChatArea( } } + val context = androidx.compose.ui.platform.LocalContext.current + val markwon = remember(context) { + io.noties.markwon.Markwon.builder(context) + .usePlugin(io.noties.markwon.core.CorePlugin.create()) + .usePlugin(io.noties.markwon.ext.tables.TablePlugin.create(context)) + .usePlugin(io.noties.markwon.html.HtmlPlugin.create()) + .usePlugin(io.noties.markwon.linkify.LinkifyPlugin.create()) + .usePlugin(io.noties.markwon.ext.strikethrough.StrikethroughPlugin.create()) + .usePlugin(io.noties.markwon.ext.tasklist.TaskListPlugin.create(context)) + .build() + } + val isAnyStreaming = remember(messages) { messages.any { it.isStreaming } } if (isAnyStreaming) { val totalLength = messages.map { it.content.length }.sum() @@ -111,6 +123,7 @@ fun ChatArea( val contentWithCursor = msg.content + if (msg.isStreaming) " █" else "" MarkwonRenderer( markdown = contentWithCursor, + markwon = markwon, modifier = Modifier.padding(12.dp) ) } @@ -161,19 +174,7 @@ fun ChatArea( } @Composable -fun MarkwonRenderer(markdown: String, modifier: Modifier) { - val context = androidx.compose.ui.platform.LocalContext.current - val markwon = remember(context) { - io.noties.markwon.Markwon.builder(context) - .usePlugin(io.noties.markwon.core.CorePlugin.create()) - .usePlugin(io.noties.markwon.ext.tables.TablePlugin.create(context)) - .usePlugin(io.noties.markwon.html.HtmlPlugin.create()) - .usePlugin(io.noties.markwon.linkify.LinkifyPlugin.create()) - .usePlugin(io.noties.markwon.ext.strikethrough.StrikethroughPlugin.create()) - .usePlugin(io.noties.markwon.ext.tasklist.TaskListPlugin.create(context)) - .build() - } - +fun MarkwonRenderer(markdown: String, markwon: io.noties.markwon.Markwon, modifier: Modifier) { androidx.compose.ui.viewinterop.AndroidView( factory = { ctx -> val textView = SafeSelectableTextView(ctx).apply { From 9ed1e317001a233d07179516b837ef41b0c2795a Mon Sep 17 00:00:00 2001 From: fengge <844143714@qq.com> Date: Wed, 3 Jun 2026 14:48:58 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E4=BC=98=E5=8C=96(=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E4=B8=8E=E6=B8=B2=E6=9F=93):=20=E6=8F=90=E5=8D=87=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E6=9B=B4=E6=96=B0=E6=80=A7=E8=83=BD=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20SP=20=E6=8C=81=E4=B9=85=E5=8C=96=E9=98=BB=E5=A1=9E?= =?UTF-8?q?=E4=B8=BB=E7=BA=BF=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 将 ChatViewModel 中 AI 的流式刷新频率 (STREAM_FLUSH_INTERVAL_MS) 从 50ms 调整为 150ms,以降低低端机中高频触发文本重绘(Text Layout)的沉重 CPU 负担,肉眼仍旧保持平滑。 2. 将 SharedPreferences 持久化全量对话记录(saveCacheToLocal)的操作,利用协程移动至 Dispatchers.IO 线程中,防止读写巨大 JSON 字符串时彻底堵死主线程引发应用 ANR 或冻结。 --- .../stand/standapp/ui/chat/ChatViewModel.kt | 57 ++++++++++--------- 1 file changed, 30 insertions(+), 27 deletions(-) diff --git a/app/src/main/java/com/stand/standapp/ui/chat/ChatViewModel.kt b/app/src/main/java/com/stand/standapp/ui/chat/ChatViewModel.kt index 5a0c6e7..043e423 100644 --- a/app/src/main/java/com/stand/standapp/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/stand/standapp/ui/chat/ChatViewModel.kt @@ -21,7 +21,7 @@ import org.json.JSONArray import org.json.JSONObject import java.util.UUID -private const val STREAM_FLUSH_INTERVAL_MS = 50L +private const val STREAM_FLUSH_INTERVAL_MS = 150L class ChatViewModel(application: Application) : AndroidViewModel(application) { private val repository = AiChatRepository() @@ -139,34 +139,37 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } private fun saveCacheToLocal() { - try { - // 保存会话列表 - val sessionsArray = JSONArray() - _sessions.value.forEach { session -> - val obj = JSONObject() - obj.put("id", session.id) - obj.put("title", session.title) - obj.put("timestamp", session.timestamp) - sessionsArray.put(obj) - } + // 将数据保存放到IO线程中,防止SharedPreferences写大文本阻塞主线程卡顿 + viewModelScope.launch(kotlinx.coroutines.Dispatchers.IO) { + try { + // 保存会话列表 + val sessionsArray = JSONArray() + _sessions.value.forEach { session -> + val obj = JSONObject() + obj.put("id", session.id) + obj.put("title", session.title) + obj.put("timestamp", session.timestamp) + sessionsArray.put(obj) + } - // 保存消息列表 - val messagesArray = JSONArray() - _messages.value.forEach { msg -> - val obj = JSONObject() - obj.put("id", msg.id) - obj.put("sessionId", msg.sessionId) - obj.put("role", msg.role.name) - obj.put("content", msg.content) - messagesArray.put(obj) - } + // 保存消息列表 + val messagesArray = JSONArray() + _messages.value.forEach { msg -> + val obj = JSONObject() + obj.put("id", msg.id) + obj.put("sessionId", msg.sessionId) + obj.put("role", msg.role.name) + obj.put("content", msg.content) + messagesArray.put(obj) + } - prefs.edit() - .putString("cache_sessions", sessionsArray.toString()) - .putString("cache_messages", messagesArray.toString()) - .apply() - } catch (e: Exception) { - e.printStackTrace() + prefs.edit() + .putString("cache_sessions", sessionsArray.toString()) + .putString("cache_messages", messagesArray.toString()) + .apply() + } catch (e: Exception) { + e.printStackTrace() + } } }