diff --git a/.waylog/history/2026-07-07_12-55Z-__Files_mentioned_by_the_user______AUDIT_REPORT_20.md b/.waylog/history/2026-07-07_12-55Z-__Files_mentioned_by_the_user______AUDIT_REPORT_20.md index d4b5fa0..1da3500 100644 --- a/.waylog/history/2026-07-07_12-55Z-__Files_mentioned_by_the_user______AUDIT_REPORT_20.md +++ b/.waylog/history/2026-07-07_12-55Z-__Files_mentioned_by_the_user______AUDIT_REPORT_20.md @@ -484,3 +484,1321 @@ flutter analyze --no-pub lib/shared/tools/sc_gift_vap_svga_manager.dart \ 如果你要我直接动手,我建议第一刀就从 **SVGA 缓存失控修复** 开始。 --- + + +**OpenAI Codex** + +再看全局性能开关和图片缓存限制,确认已有防护和仍存在的问题。 + +--- + + +**OpenAI Codex** + +再扫启动链路和全局状态管理,确认哪些是“性能债”而不是功能安全问题。 + +--- + + +**OpenAI Codex** + +重点再抽几处“大 Consumer 包巨页”的源码,因为这是重建性能的主因。 + +--- + + +**OpenAI Codex** + +还要确认高频计时器通知是否会触发大面积重建。 + +--- + + +**OpenAI Codex** + +再看文件缓存管理是否有容量上限,网络特效/图片下载是否可能无限增长。 + +--- + + +**OpenAI Codex** + +这轮我只看性能问题,当前能确认的性能风险如下。旧报告里提到的 SVGA 裸缓存问题我复核过,当前代码已有 8 个缓存上限和引用计数,所以不再算当前问题。 + +## 总览指标 + +- Dart 文件:478 个,约 16.9 万行。 +- `setState`:437 处。 +- `notifyListeners`:190 处。 +- `shrinkWrap: true`:21 处。 +- 静态资源:`sc_images` 约 84MB,其中 `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/sc_images/room` 约 55MB。 + +## P0:优先修 + +1. **低端机性能开关失效,所有高成本动画都会跑** + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/app/constants/sc_global_config.dart:190` + - `allowsHighCostAnimations => true`,导致低端机判断、低内存判断、SDK 限制都无效。 + - 影响 VAP/SVGA/MP4/PAG 礼物、进房、飘屏等高成本动画。 + +2. **语音房整页被 `RtcProvider` 任意变更触发重建** + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/voice_room_page.dart:113-120` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/voice_room_page.dart:229-240` + - 页面手动监听整个 `RtcProvider`,每次 `notifyListeners()` 都 `setState` 整个房间页。 + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtc_manager.dart` 约 5997 行,`notifyListeners()` 50 处,还包含麦位、在线用户、红包、火箭、音乐、CP 等多域状态。 + +3. **房间聊天列表每条消息都触发列表重排和滚动跳转** + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/chat/all/all_chat_page.dart:65-104` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/chat/chat/chat_page.dart:65-103` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/chat/gift/gift_chat_page.dart:65-104` + - 问题组合:`setState` + `contains/remove/insert(0)` + `jumpTo(0)` + `ListView.builder(shrinkWrap: true)`。 + - 房间消息高频时很容易卡顿。 + +4. **多个大页面使用宽泛 `Consumer`,无关数据变化会重建大块 UI** + 重点位置: + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/chat/message_chat_page.dart:201`:整个私聊页监听 `RtmProvider`。 + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/gift/gift_page.dart:687`:整个礼物面板监听 `SCAppGeneralManager`。 + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/user/profile/person_detail_page.dart:2099`:整个个人主页监听 `SocialChatUserProfileManager`。 + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/widgets/msg/message_conversation_list_page.dart:62`:整个会话列表监听 `RtmProvider`。 + +5. **资源包过大,且包含 debug MP4 和疑似未引用大图** + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/pubspec.yaml:170-191` + - `assets/debug/manager-packed-alpha.mp4` 被打包,约 2.67MB。 + - 多个 `sc_images/index/sc_icon_gamebroad_lv*_bg.webp` 单个 1.4MB - 2.24MB,当前扫描不到引用但仍因目录打包进入产物。 + - 最大资源包括: + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/sc_images/room/rocket/rocket_rank_bg.png` 约 2.96MB + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/sc_images/room/cp_profile/sc_cp_profile_relation_card_bg.png` 约 2.71MB + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/sc_images/room/sc_room_recharge_modal_coin.png` 约 2.47MB + +## P1:第二批修 + +6. **音乐播放进度每 800ms 全局通知,组件重建范围偏大** + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/music/room_music_manager.dart:436-461` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/widgets/room/music/room_music_room_player.dart:28` + - 进度刷新 `notifyListeners()`,但播放器、底部 bar、音乐页都用了宽泛 `Consumer`。 + +7. **`SCAppGeneralManager` 职责过大,构造时直接触发多路加载** + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/general/sc_app_general_manager.dart:53-57` + - 构造即加载国家、礼物、活动礼物。 + - 同一个 notifier 同时管国家、礼物、背包、banner、emoji、首充、榜单,导致无关 UI 被连带重建。 + +8. **嵌套滚动 + `shrinkWrap` 导致列表懒加载失效或布局成本变高** + 重点位置: + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/widgets/msg/message_conversation_list_page.dart:76` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/widgets/msg/message_conversation_list_page.dart:257-259` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/home/popular/party/sc_home_party_page.dart:142-176` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/home/popular/party/sc_home_party_page.dart:728-730` + +9. **IM/系统消息多处主线程 `jsonDecode`** + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtm_manager.dart:1332` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtm_manager.dart:3418` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtm_manager.dart:3466` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/chat/system/message_system_page.dart` 多处。 + - 高频消息或大 JSON 时会占 UI isolate。 + +10. **生产日志级别和动画日志过重** + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtm_manager.dart:475` 使用 `LogLevelEnum.V2TIM_LOG_ALL`。 + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/shared/tools/sc_gift_vap_svga_manager.dart:80-81` 动画路径大量 `debugPrint`,未按 debug/profile/release 分层。 + +11. **多个 controller / timer 释放不完整,存在性能泄漏风险** + 重点位置: + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/gift/gift_tab_page.dart:64` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/gift/bag/gift_bag_page.dart:24` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/search/sc_search_page.dart:45-55` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/store/store_page.dart:29-50` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/them/room_theme_page.dart:25-41` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/components/sc_float_ichart.dart:100-112`:1ms `Timer.periodic` 做贴边动画,成本很高。 + +## P2:后续优化 + +12. **多处大面积 `BackdropFilter` / blur** + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/gift/gift_page.dart:728-730` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/gift/gift_page.dart:2212-2214` + - blur 在弹层、礼物面板、房间弹窗里较多,低端机容易掉帧。 + +13. **图片缓存策略还需要细化** + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/components/sc_compontent.dart:239-255` + - GIF 不设置 `cacheWidth/cacheHeight`,且 `clearMemoryCacheWhenDispose: false`。头像/礼物/房间图多时内存压力会比较明显。 + +14. **首帧前仍有同步等待链路** + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/main.dart:63` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/main.dart:79-99` + - 首帧前等待 `_initStore()`、`restoreDurableSessionIfNeeded()`、`SCDeviceIdUtils.initDeviceinfo()`,启动耗时需要实测拆分。 + +## 建议修复顺序 + +1. 先修 `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/app/constants/sc_global_config.dart:190`,让低端机真的禁用高成本动画。 +2. 拆掉 `VoiceRoomPage` 对整个 `RtcProvider` 的全量监听,改成局部 `Selector/context.select`。 +3. 修三个房间聊天列表:去掉 `shrinkWrap`、批量合并消息更新、避免每条消息 `jumpTo(0)`。 +4. 把礼物页、私聊页、个人主页、会话列表的宽泛 `Consumer` 改成精确 selector。 +5. 清理资源包:去掉 debug 资源、压缩 1MB+ 图片、移除未引用大图。 +6. 音乐播放进度独立成更小粒度 notifier/value listenable。 +7. 日志按 `kDebugMode` 或环境分层,IM release 不用 `V2TIM_LOG_ALL`。 +8. 补齐 controller/timer dispose。 + +--- + + +**User** + +开始修复 + +拆掉 VoiceRoomPage 对整个 RtcProvider 的全量监听,改成局部 Selector/context.select。 +修三个房间聊天列表:去掉 shrinkWrap、批量合并消息更新、避免每条消息 jumpTo(0)。 + +并编写适当注释, 修复完成后开启子agents进行验收 + +--- + + +**User** + +在修复的时候顺便把修复到的做组件模块化高性能拆分 + +--- + + +**User** + +先判断是否需要拆分再去拆分,不要无脑拆 + +--- + + +**OpenAI Codex** + +判断结果:这次只拆三处重复度很高、且同属本次性能修复范围的房间聊天列表;`VoiceRoomPage` 只做局部 Selector 化,不额外拆大组件,避免扩大改动面。 + +--- + + +**User** + + +{"agent_path":"019f3d3d-dbd0-7993-a9ff-5505fefd5953","status":{"completed":"通过。\n\n## 发现的问题\n\n未发现阻断问题。\n\n## 验收结论\n\n1. **已去掉 `shrinkWrap` 和每条消息 `jumpTo(0)`**\n - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/chat/all/all_chat_page.dart`\n - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/chat/chat/chat_page.dart`\n - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/chat/gift/gift_chat_page.dart`\n\n 三个页面已改为薄封装,不再直接持有 `ListView`、`ScrollController`、`shrinkWrap` 或每条消息 `jumpTo(0)`。\n\n2. **组件化拆分范围克制**\n - 新增 `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/chat/widgets/room_chat_message_pane.dart`\n - 只抽取三类聊天页重复的列表、滚动、listener、消息合并逻辑。\n - 三个页面仅保留 channel 选择和点击用户逻辑,范围合理。\n\n3. **批量合并、最多 250 条、不强制跳底逻辑正确**\n - 使用 16ms `Timer` 合并消息批次,减少每条消息 `setState`。\n - `_msgList.removeRange(250, length)` 保证最多 250 条。\n - 仅当 `_isAtLatestMessage == true` 时批量后自动跳到底部;用户不在底部时不会强制跳。\n\n4. **RtmProvider listener 挂载/卸载安全**\n - `msgAllListener` / `msgChatListener` / `msgGiftListener` 按 channel 挂载。\n - dispose 时通过 `identical` 判断,只清理自己挂上的 listener,避免误删新实例 listener。\n - timer、pending messages、scroll controller 都有清理。\n\n5. **编译检查**\n - 已执行:\n `flutter analyze --no-pub` 针对 4 个验收文件\n - 结果:`No issues found`\n\n## 建议\n\n- 可补充 widget/unit 测试覆盖:\n - 连续多条消息只触发批量刷新;\n - 超过 250 条时裁剪旧消息;\n - 用户滚离底部后新消息不自动跳底;\n - 三个 channel listener dispose 后不残留。\n\n- 工作区中还存在 `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/voice_room_page.dart` 的变更,不属于本次四文件验收范围;如发布要求严格限定变更范围,建议单独验收。"}} + + +--- + + +**User** + + +{"agent_path":"019f3d3d-db66-7e53-9db1-1bc13916f513","status":{"completed":"不通过。\n\n发现的问题:\n\n1. 全量监听已拆除 \n - `VoiceRoomPage` 已不再对整个 `RtcProvider` 调用 `addListener/removeListener`。\n - 当前仅保留 `_tabController.addListener(_handleTabChange)`,`setState` 也只用于 Tab 切换。\n\n2. 局部 Selector 基本符合预期 \n - 聊天 room key:`Selector` 驱动。\n - 背景:`Selector` 驱动。\n - 启动失败层:`Selector` 驱动。\n - 礼物 SVGA / 座位飞行动画层:`Selector` 基于 `shouldShowRoomVisualEffects` 驱动。\n\n3. 主要问题:`_buildRoomVisualEffectActivationListener` 存在“漏开启/漏绑定监听器”的风险 \n 当前逻辑只在:\n\n ```dart\n snapshot.hasRoom && !snapshot.effectsEnabled\n ```\n\n 时才调度 `_ensureRoomVisualEffectsEnabled()`。\n\n 但 `_ensureRoomVisualEffectsEnabled()` 又在 `roomVisualEffectsEnabled == true` 时直接 return:\n\n ```dart\n if (rtcProvider.currenRoom == null ||\n !_roomRouteVisible ||\n rtcProvider.roomVisualEffectsEnabled) {\n return;\n }\n ```\n\n 问题是 `_enableRoomVisualEffects()` 不只是打开 provider flag,还负责绑定:\n\n ```dart\n rtmProvider.msgFloatingGiftListener = _floatingGiftListener;\n rtmProvider.msgLuckyGiftRewardTickerListener =\n _luckyGiftRewardTickerListener;\n ```\n\n 如果进入房间流程在 `VoiceRoomPage` 挂载前已经把 `roomVisualEffectsEnabled` 设为 `true`,那么页面侧不会再调用 `_enableRoomVisualEffects()`,RTM 礼物监听可能没有绑定,导致礼物上飘、幸运礼物 ticker、座位飞行动画等消息驱动特效漏触发。\n\n4. 循环/无谓重建方面 \n - 目前没有看到明显死循环。\n - `Selector` 快照类实现了 `==/hashCode`,不会因 `RtcProvider` 任意 notify 而重建整页。\n - `_roomVisualEffectsEnsureScheduled` 也做了 post-frame 合并,避免重复调度。\n\n5. 明显编译/生命周期问题 \n - 本文件内未发现明显语法或生命周期错误。\n - `dispose()` 中仍有 provider 读取和状态清理,属于既有模式,未看到新增的直接崩溃点。\n\n建议:\n\n- 将“设置 provider flag”和“绑定 RTM 礼物监听器”拆开。\n- `_ensureRoomVisualEffectsEnabled()` 应该在房间存在且路由可见时,无论 `roomVisualEffectsEnabled` 当前是否已为 `true`,都确保 RTM listener 已绑定。\n- 例如逻辑上改成:\n\n```dart\nif (rtcProvider.currenRoom == null || !_roomRouteVisible) return;\n\nif (!rtcProvider.roomVisualEffectsEnabled) {\n rtcProvider.setRoomVisualEffectsEnabled(true);\n}\n\n绑定/恢复 rtmProvider 的两个 listener;\n```\n\n这样可避免 provider flag 已经为 true 但页面监听未挂上的漏开启问题。"}} + + +--- + + +**OpenAI Codex** + +已完成这轮性能修复,并按你的要求先判断再拆分。 + +## 已修复 + +### 1. `VoiceRoomPage` 去掉全量 `RtcProvider` 监听 + +修改文件: + +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/voice_room_page.dart` + +处理内容: + +- 移除 `RtcProvider.addListener(_handleRoomProviderChanged)`。 +- 移除由 `RtcProvider.notifyListeners()` 触发的整页 `setState()`。 +- 房间背景、聊天 room key、启动失败层、礼物特效层继续由局部 `Selector` 驱动。 +- 新增轻量监听 `_buildRoomVisualEffectActivationListener()`,只关注: + - 是否已有房间 + - 房间特效开关 + - RTM 礼物消息 listener 是否已绑定 + +子 agent 初验发现一个 RTM 礼物 listener 漏绑定风险,我已修复并复验通过。 + +### 2. 三个房间聊天列表性能优化 + +修改文件: + +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/chat/all/all_chat_page.dart` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/chat/chat/chat_page.dart` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/chat/gift/gift_chat_page.dart` +- 新增:`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/chat/widgets/room_chat_message_pane.dart` + +处理内容: + +- 去掉三个聊天列表的 `shrinkWrap: true`。 +- 去掉每条消息都 `jumpTo(0)`。 +- 新增 16ms 批量合并消息刷新,降低高频消息下的 `setState` 和布局次数。 +- 只有用户本来就在最新消息位置时,才批量后自动贴底。 +- 用户滚动查看历史时,新消息不会强制把列表拉回底部。 +- 列表最多保留 250 条,避免长时间房间内存持续增长。 + +### 3. 组件拆分判断 + +这次只拆了聊天列表组件,因为: + +- 三个页面逻辑高度重复。 +- 都属于这次性能修复范围。 +- 都是高频消息路径。 +- 抽成 `RoomChatMessagePane` 可以统一批量刷新、listener 清理、滚动策略。 + +`VoiceRoomPage` 没做大拆分,避免扩大改动面。 + +## 验证 + +已执行: + +```bash +flutter analyze --no-pub lib/modules/room/voice_room_page.dart lib/modules/room/chat/all/all_chat_page.dart lib/modules/room/chat/chat/chat_page.dart lib/modules/room/chat/gift/gift_chat_page.dart lib/modules/room/chat/widgets/room_chat_message_pane.dart +``` + +结果: + +```text +No issues found +``` + +也执行了: + +```bash +git diff --check +``` + +无空白/格式问题。 + +## 子 agents 验收 + +- 聊天列表重构验收:通过。 +- `VoiceRoomPage` 验收:初验发现 listener 漏绑定风险,已修复;复验通过。 + +当前变更未提交,工作区只剩本次相关代码变更。 + +--- + + +**User** + +接下来还有哪些性能问题 + +--- + + +**OpenAI Codex** + +当前这两块已经处理掉: + +- `VoiceRoomPage` 对整个 `RtcProvider` 的全量监听。 +- 房间三个聊天列表的 `shrinkWrap`、逐条 `setState/jumpTo`。 + +接下来还剩这些性能问题,按优先级排: + +## P0:下一刀建议先修 + +### 1. 高成本动画开关仍然失效 + +文件: + +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/app/constants/sc_global_config.dart:190` + +问题: + +```dart +static bool get allowsHighCostAnimations => true; +``` + +这会导致低端机、低内存机仍然跑 VAP / SVGA / MP4 / PAG 礼物特效。 + +影响位置包括: + +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/gift/gift_page.dart` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtm_manager.dart` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/shared/tools/sc_gift_vap_svga_manager.dart` + +这个是当前最值得优先修的性能问题。 + +--- + +### 2. `SCAppGeneralManager` 太大,导致礼物页/首页无关重建 + +核心文件: + +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/general/sc_app_general_manager.dart` + +问题: + +- 构造函数里直接触发国家、礼物、活动礼物加载。 +- 同一个 manager 管国家、礼物、背包、banner、emoji、首充、榜单。 +- 多个页面用宽泛 `Consumer`。 + +重点页面: + +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/gift/gift_page.dart:687` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/gift/gift_tab_page.dart:84` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/gift/bag/gift_bag_page.dart:33` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/home/popular/party/sc_home_party_page.dart` + +建议: + +- 先不用大拆 manager。 +- 先把礼物页、首页里的宽泛 `Consumer` 改成 `Selector`。 +- 再考虑拆 `GiftGeneralManager` / `CountryManager` / `BannerManager`。 + +--- + +### 3. 私聊页和会话列表仍有宽泛重建 + 嵌套 shrinkWrap + +重点文件: + +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/chat/message_chat_page.dart:201` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/widgets/msg/message_conversation_list_page.dart:62` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/widgets/msg/message_conversation_list_page.dart:76` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/widgets/msg/message_conversation_list_page.dart:258` + +问题: + +- 私聊页整个页面监听 `RtmProvider`。 +- 会话列表 `Consumer` 包大块 UI。 +- 会话列表还有 `CustomScrollView(shrinkWrap: true)` + 内部 `ListView(shrinkWrap: true)`。 +- 搜索时还有 build 中触发 `_loadSearchUserInfo(...)` 的副作用。 + +这是消息页下一批重点。 + +--- + +## P1:第二批修 + +### 4. 音乐播放进度每 800ms 全局 notify + +文件: + +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/music/room_music_manager.dart:438` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/widgets/room/music/room_music_room_player.dart` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/widgets/room/music/room_music_player_bar.dart` + +问题: + +- 每 800ms `notifyListeners()`。 +- UI 用宽泛 `Consumer`。 +- 进度变化可能带着整块音乐 UI 重建。 + +建议: + +- 把 position/duration 独立成 `ValueNotifier` 或小 selector。 +- 播放器非进度区域不要跟着每 800ms 重建。 + +--- + +### 5. 首页房间流仍有 `SingleChildScrollView + GridView(shrinkWrap)` + +文件: + +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/home/popular/party/sc_home_party_page.dart:142` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/home/popular/party/sc_home_party_page.dart:729` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/home/popular/party/sc_home_party_page.dart:1064` + +问题: + +- 首页房间列表不是纯 sliver 懒加载。 +- 房间卡片多时布局成本高。 +- 还有多个 `Consumer` 嵌在 item / filter 中。 + +建议: + +- 改成 `CustomScrollView + SliverGrid`。 +- 国家 flag 数据提前映射,不要每个 item 里查 provider。 + +--- + +### 6. 大资源包和大图解码 + +当前静态资源压力仍大: + +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/sc_images` 约 84MB。 +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/sc_images/room` 约 55MB。 +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/assets/debug/manager-packed-alpha.mp4` 仍被 `pubspec.yaml` 打包。 + +重点: + +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/pubspec.yaml:170-191` + +建议: + +- 先移除 debug asset 打包。 +- 清理未引用大图。 +- 压缩 1MB+ PNG/WebP。 +- 对房间、火箭、CP profile 大图做尺寸核对。 + +--- + +### 7. 日志过多 + +文件: + +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtm_manager.dart:475` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/shared/tools/sc_gift_vap_svga_manager.dart` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/widgets/room/effect/gift_mp4_effect_view.dart` + +问题: + +- `LogLevelEnum.V2TIM_LOG_ALL`。 +- 礼物/MP4/SVGA 路径里有大量 `debugPrint`。 + +建议: + +- release/profile 下关闭高频日志。 +- IM SDK 日志按环境分级。 + +--- + +## P2:后续补 + +### 8. Controller / Timer 释放不完整 + +重点: + +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/gift/gift_tab_page.dart` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/gift/bag/gift_bag_page.dart` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/search/sc_search_page.dart` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/store/store_page.dart` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/them/room_theme_page.dart` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/components/sc_float_ichart.dart:100` + +尤其是 `sc_float_ichart.dart` 里 1ms `Timer.periodic` 做贴边动画,应该改成动画控制器或至少降低频率。 + +--- + +### 9. 主线程 JSON 解析仍多 + +重点: + +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtm_manager.dart` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/chat/system/message_system_page.dart` +- `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/shared/tools/sc_system_message_utils.dart` + +高频 IM / 系统消息里 `jsonDecode` 多,后面可以把大消息解析挪到 isolate 或提前结构化。 + +--- + +## 我建议下一步顺序 + +1. 修 `allowsHighCostAnimations => true`。 +2. 优化 `SCAppGeneralManager` 相关的礼物页重建。 +3. 优化私聊页 / 会话列表。 +4. 优化音乐 800ms notify。 +5. 清资源包和日志。 + +--- + + +**User** + +语音房页面的卡顿点在哪里,并给出证据 + +--- + + +**OpenAI Codex** + +语音房当前主要卡顿点不再是“整页监听 `RtcProvider` 全量重建”——这个上一轮已经拆掉了。现在的卡顿点集中在 **高成本动画、房间状态高频通知、音乐进度刷新、礼物消息链路、资源解码** 这几块。 + +## 1. 高成本礼物/进房动画仍是最大卡顿点 + +### 证据 1:全局高成本动画开关无效 + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/app/constants/sc_global_config.dart:188-190` + +```dart +static bool get isLowPerformanceDevice => _isLowPerformanceDevice; + +static bool get allowsHighCostAnimations => true; +``` + +虽然有低端机判断,但 `allowsHighCostAnimations` 永远返回 `true`。 + +### 证据 2:语音房始终挂着礼物特效层 + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/voice_room_page.dart:526-530` + +```dart +LuckGiftNomorAnimWidget(), +_buildRoomGiftSvgaLayer(), +_buildRoomGiftSeatFlightLayer(), +const RoomGiftComboFloatingLayer(), +``` + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/voice_room_page.dart:538-551` + +```dart +if (!SCGlobalConfig.allowsHighCostAnimations) { + return const SizedBox.shrink(); +} +... +child: const VapPlusSvgaPlayer(tag: "room_gift"), +``` + +这个判断理论上可以关掉高成本动画,但因为 `allowsHighCostAnimations => true`,所以低端机也会跑。 + +### 证据 3:RTM 收到进房/礼物消息会触发 VAP/SVGA 播放 + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtm_manager.dart:6358-6365` + +```dart +if (SCGlobalConfig.allowsHighCostAnimations && + SCGlobalConfig.isEntryVehicleAnimation && + shouldShowRoomVisualEffects) { + SCGiftVapSvgaManager().play(...) +} +``` + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtm_manager.dart:6408-6415` + +```dart +if (SCGlobalConfig.allowsHighCostAnimations && + SCGlobalConfig.isGiftSpecialEffects && + rtcProvider.shouldShowRoomVisualEffects) { + SCGiftVapSvgaManager().play(giftSourceUrl); +} +``` + +### 结论 + +**礼物大特效、坐骑进房、全屏 VAP/SVGA 是语音房最明显的掉帧来源。** +尤其在低端 Android 上,当前开关没有真正生效。 + +--- + +## 2. 房间状态仍然高频 `notifyListeners`,会驱动多个子组件重建 + +上一轮已经拆掉了 `VoiceRoomPage` 整页 `RtcProvider.addListener`,但房间子组件仍然依赖 `RtcProvider`。 + +### 证据 1:`RtcProvider` 仍是高频通知源 + +静态扫描结果: + +```text +notifyListeners(): 190 + 50 lib/services/audio/rtc_manager.dart +``` + +`RtcManager` 是全项目 `notifyListeners()` 最多的类。 + +### 证据 2:房间定时拉麦位/在线用户 + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtc_manager.dart:802-811` + +```dart +_micListPollingTimer = Timer.periodic(_micListPollingInterval, (_) { + retrieveMicrophoneList(notifyIfUnchanged: false).catchError((_) {}); +}); +_onlineUsersPollingTimer = Timer.periodic(_onlineUsersPollingInterval, (_) { + fetchOnlineUsersList(notifyIfUnchanged: false).catchError((_) {}); +}); +``` + +### 证据 3:麦位更新会通知 UI + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtc_manager.dart:1742-1746` + +```dart +if (changed || + notifyIfUnchanged || + roomMusicChanged || + relationCacheChanged) { + notifyListeners(); +} +``` + +### 证据 4:麦位布局会重新计算所有麦位版本 + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/widgets/room/seat/room_seat_widget.dart:74-94` + +```dart +var version = provider.roomWheatMap.length; +final micIndexes = + provider.roomWheatMap.keys.toList()..sort((a, b) => a.compareTo(b)); +for (final micIndex in micIndexes) { + final seat = provider.micAtIndexForDisplay(micIndex); + ... + version = Object.hash(...) +} +``` + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/widgets/room/seat/room_seat_widget.dart:101-115` + +```dart +return Selector( + selector: (context, provider) => _RoomSeatLayoutSnapshot( + seatCount: _seatCountFromMicIndexes(provider.roomWheatMap.keys), + seatContentVersion: _seatContentVersion(provider), + ... + ), +``` + +### 结论 + +现在不会整页重建,但 **麦位、在线用户、房间头部、底部按钮仍会被 `RtcProvider` 高频通知驱动**。 +其中麦位区域尤其重,因为每次 selector 都要计算 `roomWheatMap` 的内容版本。 + +--- + +## 3. 音乐播放进度每 800ms 刷新,会重建整个音乐播放器 + +### 证据 1:音乐进度定时器 + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/music/room_music_manager.dart:436-461` + +```dart +_positionTimer = Timer.periodic(const Duration(milliseconds: 800), (_) { + unawaited(_refreshPosition()); +}); +... +notifyListeners(); +``` + +### 证据 2:语音房直接挂音乐播放器 + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/voice_room_page.dart:519-524` + +```dart +const PositionedDirectional( + start: 0, + end: 0, + bottom: 0, + child: RoomMusicRoomPlayer(), +), +``` + +### 证据 3:播放器用宽泛 `Consumer` + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/widgets/room/music/room_music_room_player.dart:28-35` + +```dart +return Consumer( + builder: (context, manager, child) { + final current = manager.current; + ... + final position = manager.positionMs.clamp(0, duration).toInt(); +``` + +### 结论 + +只要房间音乐播放,**每 800ms 会 `notifyListeners()`,并重建整个 `RoomMusicRoomPlayer`**。 +这不是最致命,但会稳定制造 UI 压力。 + +--- + +## 4. 礼物飘屏链路在 UI 线程做了很多事 + +### 证据 1:语音房收到礼物消息后预热头像/礼物图并入队动画 + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/voice_room_page.dart:720-749` + +```dart +giftModel.sendUserPic = msg.user?.userAvatar ?? ""; +giftModel.giftPic = msg.gift?.giftPhoto ?? ""; + +unawaited(warmImageResource(...)); +unawaited(warmImageResource(...)); + +Provider.of( + context, + listen: false, +).enqueueGiftAnimation(giftModel); +``` + +### 证据 2:CP 礼物会额外走浮屏 overlay + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/voice_room_page.dart:777-805` + +```dart +OverlayManager().addMessage( + SCFloatingMessage(...) +); +``` + +### 证据 3:幸运礼物目标用户解析还可能 `jsonDecode` + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/voice_room_page.dart:1114-1119` + +```dart +if (rawTargetUserIds.startsWith("[")) { + try { + final decoded = jsonDecode(rawTargetUserIds); +``` + +### 证据 4:礼物 ticker 本身有多个动画控制器和计时器 + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/widgets/room/anim/l_gift_animal_view.dart:71-85` + +```dart +for (int i = 0; i < GiftAnimationManager.maxVisibleSlots; i++) { + ... + var controller = AnimationController(...) + bean.countPulseController = AnimationController(...) +} +``` + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/widgets/room/anim/l_gift_animal_view.dart:557-565` + +```dart +_stepTimer = Timer.periodic(Duration(milliseconds: intervalMs), (timer) { + ... + setState(() { + _displayValue = ... + }); +}); +``` + +### 结论 + +礼物高峰时,卡顿不是单点,而是: + +- RTM 消息解析 +- 图片预热 +- 礼物队列 +- overlay +- SVGA/VAP +- ticker 动画 +- 数字跳动 timer + +一起叠加。 + +--- + +## 5. RTM 消息处理仍有主线程列表移动和高日志 + +### 证据 1:房间消息仍然 `insert(0)` 到多个列表 + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtm_manager.dart:3017-3075` + +```dart +roomAllMsgList.insert(0, msg); +... +roomChatMsgList.insert(0, msg); +... +roomGiftMsgList.insert(0, msg); +... +msgFloatingGiftListener?.call(msg); +``` + +UI 聊天列表已经批量刷新了,但服务层这里仍然是同步 `insert(0)`,250 条以内问题不算巨大,但高频消息下仍有成本。 + +### 证据 2:IM SDK 日志级别是全量 + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtm_manager.dart:472-475` + +```dart +TencentImSDKPlugin.v2TIMManager.initSDK( + sdkAppID: int.parse(SCGlobalConfig.tencentImAppid), + loglevel: LogLevelEnum.V2TIM_LOG_ALL, +``` + +### 证据 3:礼物播放路径有大量 `debugPrint` + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/shared/tools/sc_gift_vap_svga_manager.dart:80-81` + +```dart +void _log(String message) { + debugPrint('[GiftFx][manager] $message'); +} +``` + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/shared/tools/sc_gift_vap_svga_manager.dart:613-620` + +```dart +_log( + 'play request path=$path ext=...' +); +``` + +### 结论 + +高频房间消息 + 礼物消息时,日志和同步列表操作会放大卡顿。 + +--- + +## 6. 大图 / 大资源解码会拖慢房间渲染 + +### 证据 1:语音房背景是全屏图片 + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/voice_room_page.dart:455-467` + +```dart +netImage( + url: roomBackground, + width: ScreenUtil().screenWidth, + height: ScreenUtil().screenHeight, + fit: BoxFit.cover, +) +``` + +### 证据 2:房间相关大资源很多 + +当前资源扫描中,房间相关大资源包括: + +```text +2.96 MB sc_images/room/rocket/rocket_rank_bg.png +2.71 MB sc_images/room/cp_profile/sc_cp_profile_relation_card_bg.png +2.47 MB sc_images/room/sc_room_recharge_modal_coin.png +1.82 MB sc_images/room/cp_invite/sc_cp_relation_formed_effect.mp4 +1.45 MB sc_images/room/rocket/rocket_rank_frame_1.png +1.26 MB sc_images/room/rocket/progress_full.png +1.16 MB sc_images/room/sc_icon_room_vip6_sonic_anim.webp +``` + +### 结论 + +语音房里背景、头像、麦位装扮、VIP 动效、火箭、CP 礼物资源都可能带来解码和 GPU 压力。 + +--- + +## 7. 红包/玩法入口仍有定时器 + +### 证据 + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/ui_kit/widgets/room/room_play_widget.dart:330-333` + +```dart +_entryRefreshTimer = Timer.periodic( + const Duration(seconds: 1), + (_) => _onEntryTimerTick(), +); +``` + +`RoomPlayWidget` 在语音房聊天区域上层: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/voice_room_page.dart:646` + +```dart +RoomPlayWidget(), +``` + +### 结论 + +这个不是最大卡顿点,但它是语音房常驻定时器之一,和其他动画/轮询叠加时会增加压力。 + +--- + +# 当前最可疑的卡顿排序 + +1. **高成本礼物/进房动画没有低端机降级** + 证据:`allowsHighCostAnimations => true`。 + +2. **礼物消息链路过重** + 证据:VAP/SVGA 播放、图片预热、overlay、ticker 动画、jsonDecode 都在语音房链路上。 + +3. **`RtcProvider` 高频 notify 驱动麦位/在线用户/底部组件重建** + 证据:`rtc_manager.dart` 50 个 `notifyListeners()`,还有麦位/在线用户轮询。 + +4. **音乐播放器 800ms notify 重建整块播放器** + 证据:`RoomMusicManager` 800ms timer + `RoomMusicRoomPlayer` 宽泛 `Consumer`。 + +5. **资源解码 / 大图 / 动效资源** + 证据:房间大图和 MP4/WebP 资源较多。 + +如果继续修,我建议下一刀先修: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/app/constants/sc_global_config.dart:190` + +也就是把高成本动画开关真正接入低端机判断。 + +--- + + +**User** + +证据 1:房间消息仍然 insert(0) 到多个列表 +文件: +/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtm_manager.dart:3017-3075 +roomAllMsgList.insert(0, msg); +... +roomChatMsgList.insert(0, msg); +... +roomGiftMsgList.insert(0, msg); +... +msgFloatingGiftListener?.call(msg); +UI 聊天列表已经批量刷新了,但服务层这里仍然是同步 insert(0),250 条以内问题不算巨大,但高频消息下仍有成本。 +这个改了后不会造成用户发礼物消息看不到吧 + +--- + + +**OpenAI Codex** + +会,有风险,但**取决于怎么改**。 + +这段不能简单粗暴改掉: + +```dart +roomAllMsgList.insert(0, msg); +roomGiftMsgList.insert(0, msg); +msgGiftListener?.call(msg); +msgFloatingGiftListener?.call(msg); +``` + +因为礼物消息显示链路大概是: + +1. `RtmProvider` 收到礼物消息。 +2. 写入 `roomAllMsgList` / `roomGiftMsgList`。 +3. 调用 `msgGiftListener?.call(msg)`,礼物聊天 tab 才能实时看到。 +4. 调用 `msgFloatingGiftListener?.call(msg)`,礼物飘屏/动画才会触发。 + +所以如果改错,比如: + +- 去掉 `roomGiftMsgList` 写入; +- 去掉 `msgGiftListener?.call(msg)`; +- 去掉 `msgFloatingGiftListener?.call(msg)`; +- 把顺序从 newest-first 改成 oldest-first,但 UI 没同步改; +- 做批量延迟,导致礼物动画延迟明显; + +那确实可能造成用户发礼物后: + +- 礼物 tab 看不到; +- 全部消息 tab 看不到; +- 飘屏/动画不触发; +- 或顺序错乱。 + +## 安全结论 + +这块**不是不能改**,但要遵守三个原则: + +### 1. 保持新消息实时 listener 不变 + +必须保留: + +```dart +msgAllListener?.call(msg); +msgChatListener?.call(msg); +msgGiftListener?.call(msg); +msgFloatingGiftListener?.call(msg); +``` + +尤其礼物消息必须保留: + +```dart +msgGiftListener?.call(msg); +msgFloatingGiftListener?.call(msg); +``` + +### 2. 保持列表语义不变 + +当前语义是: + +```text +index 0 = 最新消息 +最多 250 条 +``` + +UI 已经按这个语义写了。 +如果服务层改成 `add(msg)`,那 UI 顺序就会错,除非 UI 一起改。 + +### 3. 先别优先改这块 + +这里 `List` 最多 250 条,`insert(0)` 的 O(n) 成本存在,但不是语音房最大卡顿点。 +相比它,更值得先修的是: + +1. `allowsHighCostAnimations => true` +2. 礼物 VAP/SVGA/MP4 动画降级 +3. 音乐 800ms notify +4. 麦位/在线用户高频 notify + +## 如果真要优化,推荐安全做法 + +不要直接改行为,先抽一个 helper,保持语义: + +```dart +void _prependCapped(List list, Msg msg, {int maxLength = 250}) { + list.insert(0, msg); + if (list.length > maxLength) { + list.removeAt(list.length - 1); + } +} +``` + +然后替换重复代码: + +```dart +_prependCapped(roomAllMsgList, msg); +msgAllListener?.call(msg); + +_prependCapped(roomGiftMsgList, msg); +msgGiftListener?.call(msg); +msgFloatingGiftListener?.call(msg); +``` + +这个主要是减少重复、降低出错率,性能提升不大,但不会造成礼物消息看不到。 + +如果要进一步用 `Queue.addFirst/removeLast` 优化,那就需要全局检查所有 `roomAllMsgList / roomChatMsgList / roomGiftMsgList` 的读写点,否则更容易引入回归。 + +## 我的建议 + +这块先不要作为下一刀。 +它有成本,但不是最大头;而且礼物消息链路敏感,容易改出“发礼物看不到/动画不播”的问题。 + +下一刀建议先修: + +```dart +SCGlobalConfig.allowsHighCostAnimations +``` + +这个收益更大,风险更低。 + +--- + + +**User** + +那刚才的 UI 聊天列表已经批量刷新了 这个改动会导致消息发送不及时吗 + +--- + + +**OpenAI Codex** + +一般不会明显不及时,因为我们做的是**一帧级别批量**,不是秒级延迟。 + +当前改法是: + +- 新消息进来后先放进 `_pendingMessages` +- 用 `Timer(Duration(milliseconds: 16))` 合并 +- 统一 `setState` +- 如果用户本来在最新位置,再跳到最新消息 + +也就是说,最坏多等约 **16ms**,相当于一帧。用户肉眼基本感知不到。 + +## 会不会导致“发消息后看不到”? + +正常不会。原因: + +1. `RtmProvider` 里的消息 listener 没删。 +2. `msgAllListener / msgChatListener / msgGiftListener` 仍然会触发 UI。 +3. UI 只是把多个连续消息合并到一次刷新里。 +4. 本地列表仍然保持 `index 0 = 最新消息`。 +5. 最多只延迟一帧刷新。 + +## 什么时候可能感觉“不及时”? + +只有几种边界情况: + +### 1. 发送方本地消息不是通过这个房间 listener 回来的 + +如果发送消息后不是马上走 `msgChatListener`,而是等 IM 服务端回包再回调,那用户会感到延迟。 +但这不是这次批量刷新造成的,是发送链路本身的回调时机。 + +### 2. 用户正在看历史消息 + +现在逻辑是:如果用户不在底部,新消息不会强制 `jumpTo` 最新。 +所以用户正在向上看历史时,新消息不会把列表强拉到底部。 + +这是刻意修复卡顿的行为变化。消息其实已经进列表,只是不会强行滚动过去。 + +### 3. 极高频消息连续进来 + +连续消息会被合并刷新,最多每 16ms 刷一次。 +这反而比之前每条消息都 `setState + jumpTo` 更稳定。 + +## 礼物消息会不会不及时? + +礼物聊天 tab 的文字显示最多延迟一帧。 +礼物动画不是靠这个 UI 列表批量逻辑触发的,而是 `RtmProvider` 里: + +```dart +msgFloatingGiftListener?.call(msg); +``` + +所以礼物动画链路不受聊天列表批量刷新的直接影响。 + +## 结论 + +这个改动不会造成消息明显不及时。 +它只是把“每条消息立刻重建一次列表”改成“同一帧内的消息合并重建一次列表”。 + +实际影响: + +```text +刷新延迟:约 0-16ms +卡顿收益:高频消息时明显减少 setState / 布局 / 滚动跳转 +行为变化:用户看历史时不再被新消息强制拉到底部 +``` + +--- + + +**User** + +把这两个修一下 + +证据 2:IM SDK 日志级别是全量 +文件: +/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtm_manager.dart:472-475 +TencentImSDKPlugin.v2TIMManager.initSDK( + sdkAppID: int.parse(SCGlobalConfig.tencentImAppid), + loglevel: LogLevelEnum.V2TIM_LOG_ALL, +证据 3:礼物播放路径有大量 debugPrint +文件: +/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/shared/tools/sc_gift_vap_svga_manager.dart:80-81 +void _log(String message) { + debugPrint('[GiftFx][manager] $message'); +} +文件: +/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/shared/tools/sc_gift_vap_svga_manager.dart:613-620 +_log( + 'play request path=$path ext=...' +); + +分级 + +--- + + +**OpenAI Codex** + +已修这两个点,并做了分级。 + +## 1. IM SDK 日志分级 + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtm_manager.dart` + +原来: + +```dart +loglevel: LogLevelEnum.V2TIM_LOG_ALL +``` + +现在改为: + +```dart +loglevel: _resolveTimSdkLogLevel() +``` + +分级规则: + +```text +debug -> V2TIM_LOG_DEBUG +profile -> V2TIM_LOG_WARN +release -> V2TIM_LOG_ERROR +``` + +避免语音房消息高峰时输出全量 IM 日志。 + +--- + +## 2. 礼物播放日志分级 + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/shared/tools/sc_gift_vap_svga_manager.dart` + +新增: + +```dart +enum _GiftFxLogLevel { verbose, info, warning, error } +``` + +分级规则: + +```text +debug -> 全部输出 +profile -> 只输出 warning / error +release -> 只输出 error +``` + +原来的高频日志: + +```dart +debugPrint('[GiftFx][manager] $message'); +``` + +现在变成受控输出: + +```dart +debugPrint('[GiftFx][manager][${level.name}] $message'); +``` + +并把关键异常路径标了级别,比如: + +- 不支持的特效文件:`warning` +- path 为空:`warning` +- mp4 播放失败:`error` +- controller 缺失:`warning` +- watchdog timeout:`warning` + +普通播放请求、队列状态、预加载状态默认是 `verbose`,只在 debug 输出。 + +--- + +## 额外处理 + +同文件相关的 RTM 礼物日志也加了分级入口: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtm_manager.dart` + +```dart +enum _RtmGiftFxLogLevel { verbose, warning, error } +``` + +现有 RTM 礼物日志默认按 `verbose`,所以 profile/release 不会刷屏。 + +## 验证 + +已执行: + +```bash +flutter analyze --no-pub lib/shared/tools/sc_gift_vap_svga_manager.dart +``` + +结果: + +```text +No issues found +``` + +同时跑了两个目标文件的 analyze,`rtm_manager.dart` 仍有既有 warning/info,但本次改动无编译错误。 + +--- diff --git a/.waylog/history/2026-07-07_13-00Z-建议你不要从_大重构_开始_这个项目现在要复活_最快路径是__先定原则_先止血_再优化_再重构_先修.md b/.waylog/history/2026-07-07_13-00Z-建议你不要从_大重构_开始_这个项目现在要复活_最快路径是__先定原则_先止血_再优化_再重构_先修.md index 5100e81..703b0b6 100644 --- a/.waylog/history/2026-07-07_13-00Z-建议你不要从_大重构_开始_这个项目现在要复活_最快路径是__先定原则_先止血_再优化_再重构_先修.md +++ b/.waylog/history/2026-07-07_13-00Z-建议你不要从_大重构_开始_这个项目现在要复活_最快路径是__先定原则_先止血_再优化_再重构_先修.md @@ -544,3 +544,291 @@ profile 下 UI 帧率明显比 debug 好,说明 debug 确实放大了卡顿。 **冷启动/房间不可见时,不允许启动 RTC、RoomRocket 轮询、礼物/PAG/SVGA 重资源预加载。** --- + + +**User** + +用 profile 包再跑一次 + +--- + + +**OpenAI Codex** + +我重新跑一轮 profile 包,并采 CPU / 内存 / 帧率数据。 + +--- + + +**User** + + +The user interrupted the previous turn on purpose. Any running unified exec processes may still be running in the background. If any tools/commands were aborted, they may have partially executed. + + +--- + + +**User** + +冷启动为什么会自动恢复/进入房间,并启动 RTC、房间轮询和重资源加载。 + +--- + + +**OpenAI Codex** + +我查了代码和真机复现,结论分两种情况: + +## 1. 真冷启动不会因为 `last_time_room_id` 自动进房 + +我刚 `force-stop` 后重新启动,实际停在首页 + Weekly Sign-in 弹窗,没有进房。 + +本地 prefs 里确实还残留: + +```xml +flutter.last_time_room_id=2071715594788466690 +``` + +这个就是你之前看到的 `DEEWA...` 房间。 + +但代码里 `last_time_room_id` 不是用来恢复进房的,只用于退出脏房间: + +- 设置位置:`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/shared/tools/sc_room_utils.dart:334` +- 读取位置:`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtc_manager.dart:3474` +- 清理位置:`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtc_manager.dart:4878` + +所以它的问题不是“自动进房”,而是: +**如果上次退出房间远端 quitRoom 失败,marker 会残留,服务端也可能认为你还在房间。** + +--- + +## 2. 当前代码里真正会“自动进房”的路径是新用户注册后的自动开房间游戏 + +这条链路很明确: + +### 注册/完善资料后设置一个 pending flag + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/auth/edit/sc_edit_profile_page.dart:696` + +```dart +await DataPersistence.setPendingFirstRegisterRoomGameEvent(true); +``` + +### 首页启动后会自动检查这个 flag + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/index/index_page.dart:459` + +```dart +await _openFirstRegisterRoomGameIfNeeded(); +``` + +然后: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/index/index_page.dart:573` + +```dart +if (_isOpeningFirstRegisterRoomGame || + !DataPersistence.getPendingFirstRegisterRoomGameEvent()) { + return; +} +``` + +如果 flag 为 true,会走: + +```dart +await SCEntryPopupCoordinator.openFirstPartyRoomRandomGame(context); +``` + +### 这里会随机拿一个 Party 房间并进入 + +文件: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/shared/tools/sc_entry_popup_coordinator.dart:423` + +```dart +final rooms = await SCHomeRoomPreloadManager.instance.loadPartyRooms(); +final room = _firstRoomWithId(rooms); +``` + +然后: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/shared/tools/sc_entry_popup_coordinator.dart:467` + +```dart +rtcProvider.joinVoiceRoomSession( + context, + roomId, + previewData: resolvedPreviewData, +); +``` + +这就是“冷启动后自动进房”的主要代码路径。 + +--- + +## 3. 一旦进入房间,就必然启动 RTC、轮询、RoomRocket 和资源预加载 + +进入房间后走: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtc_manager.dart:2800` + +```dart +_setRoomStartupLoading(); +setRoomVisualEffectsEnabled(true); +_applyRoomEntryPreview(...); +_startVoiceRoomForegroundService(); +VoiceRoomRoute.openVoiceRoom(context); +``` + +然后真正进房: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtc_manager.dart:2843` + +```dart +final enteredRoom = await SCAccountRepository().entryRoom(...) +``` + +完成后初始化房间: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtc_manager.dart:2903` + +```dart +await initializeRoomSession(...) +``` + +初始化里启动: + +### IM 房间群 + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtc_manager.dart:3631` + +```dart +final joinGroupFuture = rtmProvider?.joinRoomGroup(groupId, ""); +``` + +### 麦位和在线用户轮询 + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtc_manager.dart:3669` + +```dart +_startRoomStatePolling(); +``` + +轮询间隔: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtc_manager.dart:366` + +```dart +_micListPollingInterval = Duration(seconds: 2); +_onlineUsersPollingInterval = Duration(seconds: 3); +``` + +### TRTC 进房 + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtc_manager.dart:3678` + +```dart +await joinRoomRtcVoiceChannel(...) +``` + +里面调用: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtc_manager.dart:2389` + +```dart +await adapter.enterRoomAsAudience(...) +``` + +最终到 TRTC: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/trtc_room_rtc_engine_adapter.dart:337` + +```dart +cloud.enterRoom(...) +``` + +### RoomRocket 状态和资源预加载 + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtc_manager.dart:3928` + +```dart +SCChatRoomRepository().rocketStatus(roomId) +``` + +如果开启火箭: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtc_manager.dart:3935` + +```dart +_scheduleRoomRocketAssetPreload(roomId, res, afterRoomEntry: true); +``` + +所以你看到的现象是链式触发: + +```text +首页启动 + -> pending_first_register_room_game_event 为 true + -> 自动进 Party 房间 + -> 打开 VoiceRoomPage + -> entryRoom + -> join IM group + -> start mic/online polling + -> TRTC enterRoom + -> rocketStatus + -> 预加载 PAG/图片/奖励资源 +``` + +--- + +## 4. 还有一个容易误判的点:返回房间不是退出房间 + +房间页按返回时不是退出,而是最小化: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/voice_room_page.dart:442` + +```dart +_suspendRoomVisualEffects(); +SCFloatIchart().show(); +SCNavigatorUtils.goBack(context); +``` + +真正退出房间要走: + +`/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/services/audio/rtc_manager.dart:4735` + +```dart +exitCurrentVoiceRoomSession(...) +``` + +所以如果 App 没被杀,只是后台/回到首页,`currenRoom` 和 RTC session 仍然可能活着。 + +--- + +## 最小止血建议 + +PR2 我建议只做一个点: + +**去掉/延后新用户注册后的自动进房间游戏。** + +也就是处理这条链路: + +```dart +DataPersistence.setPendingFirstRegisterRoomGameEvent(true) +``` + +或者保留 flag,但首页不要自动执行: + +```dart +_openFirstRegisterRoomGameIfNeeded() +``` + +改成用户明确点击后再进房。 + +这个改动最小,但能直接阻断“刚进 App 就进入房间、RTC/RoomRocket/资源全启动”的路径。 + +--- diff --git a/.waylog/history/2026-07-07_15-41Z-你是验收子_agent_请只做只读代码审查_不要改文件_工作目录__Users_zuozuo_Doc.md b/.waylog/history/2026-07-07_15-41Z-你是验收子_agent_请只做只读代码审查_不要改文件_工作目录__Users_zuozuo_Doc.md new file mode 100644 index 0000000..cdbb972 --- /dev/null +++ b/.waylog/history/2026-07-07_15-41Z-你是验收子_agent_请只做只读代码审查_不要改文件_工作目录__Users_zuozuo_Doc.md @@ -0,0 +1,323 @@ +# 你是验收子 agent。请只做只读代码审查,不要改文件。工作目录:/Users/zuozuo/Doc +_Exported on 07/08/2026 at 00:48:25 GMT+8 from OpenAI Codex via WayLog_ + + +**OpenAI Codex** + + +Filesystem sandboxing defines which files can be read or written. `sandbox_mode` is `danger-full-access`: No filesystem sandboxing - all commands are permitted. Network access is enabled. +Approval policy is currently never. Do not provide the `sandbox_permissions` for any reason, commands will be rejected. + +# Codex desktop context +- You are running inside the Codex (desktop) app, which allows some additional features not available in the CLI alone: + +### Images/Visuals/Files +- In the app, the model can display images and videos using standard Markdown image syntax: ![alt](url) +- When sending or referencing a local image or video, always use an absolute filesystem path in the Markdown image tag (e.g., ![alt](/absolute/path.png)); relative paths and plain text will not render the media. +- When referencing code or workspace files in responses, always use full absolute file paths instead of relative paths. +- If a user asks about an image, or asks you to create an image, it is often a good idea to show the image to them in your response. +- Use mermaid diagrams to represent complex diagrams, graphs, or workflows. Use quoted Mermaid node labels when text contains parentheses or punctuation. +- Return web URLs as Markdown links (e.g., [label](https://example.com)). + +### Workspace Dependencies +- For sheets, slides, and documents, call `load_workspace_dependencies` to find the bundled runtime and libraries. + +### Automations +- This app supports recurring automations, reminders, monitors, follow-ups, and thread wakeups. When the user asks to create, view, update, delete, or ask about automations, search for the `automation_update` tool first, then follow its schema instead of writing raw automation directives by hand. +- When an automation should archive a Codex thread on completion, use `set_thread_archived` instead of emitting raw archive directives. + +### Thread Coordination +- When the user asks to create, fork, inspect, continue, hand off, pin, archive, rename, or otherwise manage Codex threads, search for the relevant thread tool first: `create_thread`, `fork_thread`, `list_threads`, `read_thread`, `send_message_to_thread`, `handoff_thread`, `set_thread_pinned`, `set_thread_archived`, or `set_thread_title`. +- Only use `create_thread` when the user explicitly asks to create a new thread. Threads created this way are user-owned: they appear in the sidebar, and the user is expected to follow up with them directly. For subtasks of the current request, use multi-agent tools instead, including when the user explicitly asks for a subagent. +- After a successful `create_thread` call, emit `::created-thread{threadId="..."}` for a created thread or `::created-thread{pendingWorktreeId="..."}` for queued worktree setup on its own line in your final response. + +### Inline Code Comments +- Use the ::code-comment{...} directive when you need to attach feedback directly to specific code lines. +- Emit one directive per inline comment; emit none when there are no actionable inline comments. +- Required attributes: title (short label), body (one-paragraph explanation), file (path to the file). +- Optional attributes: start, end (1-based line numbers), priority (0-3). +- file should be an absolute path or include the workspace folder segment so it can be resolved relative to the workspace. +- Keep line ranges tight; end defaults to start. +- Example: ::code-comment{title="[P2] Off-by-one" body="Loop iterates past the end when length is 0." file="/path/to/foo.ts" start=10 end=11 priority=2} + +### Git +- Branch prefix: `codex/`. Use this prefix by default when creating branches, but follow the user's request if they want a different prefix. +- After successfully staging files, emit `::git-stage{cwd="/absolute/path"}` on its own line in your final response. +- After successfully creating a commit, emit `::git-commit{cwd="/absolute/path"}` on its own line in your final response. +- After successfully creating or switching the thread onto a branch, emit `::git-create-branch{cwd="/absolute/path" branch="branch-name"}` on its own line in your final response. +- After successfully pushing the current branch, emit `::git-push{cwd="/absolute/path" branch="branch-name"}` on its own line in your final response. +- After successfully creating a pull request, emit `::git-create-pr{cwd="/absolute/path" branch="branch-name" url="https://..." isDraft=true}` on its own line in your final response. Include `isDraft=false` for ready PRs. +- Only emit these git directives in your final response after the action actually succeeds, never in commentary updates. Keep attributes single-line. + +## Apps (Connectors) +Apps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{connector_id})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps. +An app is equivalent to a set of MCP tools within the `codex_apps` MCP. +An installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool. If `tool_search` is available, the apps that are searchable by `tools_search` will be listed by it. +Do not additionally call list_mcp_resources or list_mcp_resource_templates for apps. + +## Skills +A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and a short path that can be expanded into an absolute path using the skill roots table. +### Skill roots +- `r0` = `/Users/zuozuo/.codex/skills` +- `r1` = `/Users/zuozuo/.agents/skills` +- `r2` = `/Users/zuozuo/.codex/skills/.system` +- `r3` = `/Users/zuozuo/.codex/plugins/cache/openai-bundled` +- `r4` = `/Users/zuozuo/.codex/plugins/cache/openai-curated/github/d6169bef/skills` +- `r5` = `/Users/zuozuo/.codex/plugins/cache/openai-curated-remote/figma/2.0.13/skills` +- `r6` = `/Users/zuozuo/.codex/plugins/cache/openai-curated-remote/product-design/0.1.48/skills` +- `r7` = `/Users/zuozuo/.codex/plugins/cache/openai-primary-runtime` +### Available skills +- imagegen: Generate or edit raster images when the task benefits from AI-created bitmap visuals such as photos, illustrations, textures, sprites, mockups, or transparent-background cutouts. Use when Codex should create a brand-new image, transform an existing i (file: r2/imagegen/SKILL.md) +- openai-docs: Use when the user asks how to build with OpenAI products or APIs, asks about Codex itself or choosing Codex surfaces, needs up-to-date official documentation with citations, help choosing the latest model for a use case, or model upgrade and prompt (file: r2/openai-docs/SKILL.md) +- plugin-creator: Create and scaffold plugin directories for Codex with a required `.codex-plugin/plugin.json`, optional plugin folders/files, valid manifest defaults, and personal-marketplace entries by default. Use when Codex needs to create a new personal plugin, a (file: r2/plugin-creator/SKILL.md) +- skill-creator: Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Codex's capabilities with specialized knowledge, workflows, or tool integrations. (file: r2/skill-creator/SKILL.md) +- skill-installer: Install Codex skills into $CODEX_HOME/skills from a curated list or a GitHub repo path. Use when a user asks to list installable skills, install a curated skill, or install a skill from another repo (including private repos). (file: r2/skill-installer/SKILL.md) +- browser:control-in-app-browser: Control the in-app Browser. Use to open, navigate, inspect, test, click, type, screenshot, or verify local targets such as localhost, 127.0.0.1, ::1, file://, the current in-app browser tab, and websites shown side by side inside Codex. (file: r3/browser/26.623.141536/skills/control-in-app-browser/SKILL.md) +- documents:documents: Create, edit, redline, and comment on `.docx`, Word, and Google Docs-targeted document artifacts inside the container, with a strict render-and-verify workflow. Use `render_docx.py` to generate page PNGs (and optional PDF) for visual QA, then itera (file: r7/documents/26.630.12135/skills/documents/SKILL.md) +- dws: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库时使用。 (file: r1/dws/SKILL.md) +- dws: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库时使用。 (file: r0/dws/SKILL.md) +- figma:figma-code-connect: Creates and maintains Figma Code Connect template files that map Figma components to code snippets. Use when the user mentions Code Connect, Figma component mapping, design-to-code translation, or asks to create/update .figma.ts or .figma.js files. (file: r5/figma-code-connect/SKILL.md) +- figma:figma-create-new-file: **MANDATORY prerequisite** — you MUST invoke this skill BEFORE every `create_new_file` tool call. NEVER call `create_new_file` directly without loading this skill first. Trigger whenever the user wants a new blank Figma file — a new design, FigJam, o (file: r5/figma-create-new-file/SKILL.md) +- figma:figma-generate-design: Use this skill alongside figma-use when the task involves translating an application page, view, or multi-section layout into Figma. Triggers: 'write to Figma', 'create in Figma from code', 'push page to Figma', 'take this app/page and build it in Fi (file: r5/figma-generate-design/SKILL.md) +- figma:figma-generate-diagram: MANDATORY prerequisite — load this skill BEFORE every `generate_diagram` tool call. NEVER call `generate_diagram` directly without loading this skill first. Trigger whenever the user asks to create, generate, draw, render, sketch, or build a diagram (file: r5/figma-generate-diagram/SKILL.md) +- figma:figma-generate-library: Build or update a professional-grade design system in Figma from a codebase. Use when the user wants to create variables/tokens, build component libraries, create individual components with proper variant sets and variable bindings, set up theming (file: r5/figma-generate-library/SKILL.md) +- figma:figma-implement-motion: Translates Figma motion and animations into production-ready application code. Use when implementing animation/motion from a Figma design — user mentions "implement this motion", "add animation from Figma", "animate this component", provides a Figma (file: r5/figma-implement-motion/SKILL.md) +- figma:figma-swiftui: SwiftUI ↔ Figma translation. Use whenever the user mentions Swift, SwiftUI, iOS, iPhone, or iPad — in EITHER direction — translating a Figma design into SwiftUI (design → code), or pushing SwiftUI views / screens / tokens back into a Figma file (code (file: r5/figma-swiftui/SKILL.md) +- figma:figma-use: **MANDATORY prerequisite** — you MUST invoke this skill BEFORE every `use_figma` tool call. NEVER call `use_figma` directly without loading this skill first. Skipping it causes common, hard-to-debug failures. Trigger whenever the user wants to perf (file: r5/figma-use/SKILL.md) +- figma:figma-use-figjam: This skill helps agents use Figma's use_figma MCP tool in the FigJam context. Can be used alongside figma-use which has foundational context for using the use_figma tool. (file: r5/figma-use-figjam/SKILL.md) +- figma:figma-use-motion: Motion / animation context for the `use_figma` MCP tool — animating Figma nodes via manual keyframes, animation styles, easing, and timeline duration. Load alongside figma-use whenever a task involves adding, editing, or inspecting animation on a nod (file: r5/figma-use-motion/SKILL.md) +- figma:figma-use-slides: This skill helps agents use Figma's use_figma MCP tool in the Slides context. Can be used alongside figma-use which has foundational context for using the use_figma tool. (file: r5/figma-use-slides/SKILL.md) +- github:gh-address-comments: Address actionable GitHub pull request review feedback. Use when the user wants to inspect unresolved review threads, requested changes, or inline review comments on a PR, then implement selected fixes. Use the GitHub app for PR metadata and flat co (file: r4/gh-address-comments/SKILL.md) +- github:gh-fix-ci: Use when a user asks to debug or fix failing GitHub PR checks that run in GitHub Actions. Use the GitHub app from this plugin for PR metadata and patch context, and use `gh` for Actions check and log inspection before implementing any approved fix. (file: r4/gh-fix-ci/SKILL.md) +- github:github: Triage and orient GitHub repository, pull request, and issue work through the connected GitHub app. Use when the user asks for general GitHub help, wants PR or issue summaries, or needs repository context before choosing a more specific GitHub workflo (file: r4/github/SKILL.md) +- github:yeet: Publish local changes to GitHub by confirming scope, committing intentionally, pushing the branch, and opening a draft PR through the GitHub app from this plugin, with `gh` used only as a fallback where connector coverage is insufficient. (file: r4/yeet/SKILL.md) +- lark-approval: 飞书审批:当前用户审批的查询与全部处理操作,覆盖待本人审批的任务与本人发起的实例。审批待办不是飞书任务(任务类待办走 lark-task);不负责创建审批定义和发起新审批。 (file: r1/lark-approval/SKILL.md) +- lark-apps: 妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。 (file: r1/lark-apps/SKILL.md) +- lark-attendance: 飞书考勤打卡:查询自己的考勤打卡记录 (file: r1/lark-attendance/SKILL.md) +- lark-base: 飞书多维表格(Base)操作:建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限;遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive,认证/授权转 lark-shared。 (file: r1/lark-base/SKILL.md) +- lark-calendar: 飞书日历:管理日历日程和会议室。查看/搜索日程、创建/更新日程、管理参会人、查询忙闲和推荐时段、预定会议室。当用户需要查看日程安排、创建/修改会议、查询/预定会议室时使用。不负责:查询过去的视频会议记录(走 lark-vc)、待办任务(走 lark-task)。 (file: r1/lark-calendar/SKILL.md) +- lark-contact: 飞书 / Lark 通讯录:按姓名 / 邮箱解析成 open_id,或按 open_id 反查姓名 / 部门 / 邮箱 / 联系方式 / 个人状态 / 签名。当用户提到某人姓名要下一步发消息 / 排日程,或拿到 open_id 想查具体信息时使用。不负责部门树遍历、按部门列员工、组织架构图,这类需求走原生 OpenAPI。 (file: r1/lark-contact/SKILL.md) +- lark-doc: 飞书云文档(Docx / Wiki 文档,v2 API):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Bas (file: r1/lark-doc/SKILL.md) +- lark-drive: 飞书云空间(云盘/云存储):管理云空间(云盘/云存储)中的文件和文件夹。上传和下载文件、创建文件夹、复制/移动/删除文件、查看文件元数据、管理文档评论、管理文档权限、订阅用户评论变更事件、修改文件标题(docx、sheet、bitable、file、folder、wiki);也负责把本地 Word/Markdown/Excel/CSV/PPTX 以及 Base 快照(.base)导入为飞书在线云文档(docx、sheet、bitable、slides)。当用户需要上传或下载文件、整理云空间( (file: r1/lark-drive/SKILL.md) +- lark-event: Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume ` (covers IM messages/reactions/chat changes, VC meeting ended, Minutes generated, Whiteboard updated, etc.). Use for Lark (file: r1/lark-event/SKILL.md) +- lark-im: 飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复、发送应用内/短信/电话加急。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据、管理 Feed 置顶(添加/移除/查询置顶会话)、管理标签数据时使用。 (file: r1/lark-im/SKILL.md) +- lark-mail: 飞书邮箱 — draft, compose, send, reply, forward, read, and search emails; manage drafts, folders, labels, contacts, attachments, and mail rules. Use when user mentions 起草邮件, 写一封邮件, 拟邮件, 草稿, 发通知邮件, 发送邮件, 发邮件, 回复邮件, 转发邮件, 查看邮件, 看邮件, 读邮件, 搜索邮件, 查邮件, 收件箱, (file: r1/lark-mail/SKILL.md) +- lark-markdown: 飞书 Markdown:查看、创建、上传、编辑和比较 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取、修改、局部 patch 或比较差异时使用。不负责将 Markdown 导入为飞书在线文档,也不负责文件搜索、权限、评论、移动、删除等云空间管理操作。 (file: r1/lark-markdown/SKILL.md) +- lark-minutes: 飞书妙记:搜索妙记列表、查看妙记基础信息、下载妙记音视频文件、上传音视频生成妙记、更新妙记标题、替换说话人。当需要获取、操作或者生成妙记时使用。也支持将本地音视频文件转成纪要和逐字稿(优先使用本 skill,不要用 ffmpeg/whisper 本地转写)。不负责:获取会议关联妙记、纪要/逐字稿内容获取走 lark-vc (file: r1/lark-minutes/SKILL.md) +- lark-okr: 飞书 OKR:管理目标与关键结果。查看和编辑 OKR 周期、目标(Objective)、关键结果(Key Result)、对齐关系、量化指标和进展记录。当用户需要查看或创建 OKR、管理目标和关键结果、查看对齐关系时使用。 (file: r1/lark-okr/SKILL.md) +- lark-openapi-explorer: 飞书/Lark 原生 OpenAPI 探索:从官方文档库中挖掘未经 CLI 封装的原生 OpenAPI 接口。当用户的需求无法被现有 lark-* skill 或 lark-cli 已注册命令满足,需要查找并调用原生飞书 OpenAPI 时使用。 (file: r1/lark-openapi-explorer/SKILL.md) +- lark-shared: Use when first setting up lark-cli, running auth login, switching user/bot identity (--as), handling permission denied or scope errors, needing to update lark-cli, or seeing _notice in JSON output. (file: r1/lark-shared/SKILL.md) +- lark-sheets: 飞书电子表格:创建和操作电子表格。支持创建表格、管理工作表与行列结构(增删/合并/调整尺寸/隐藏/冻结)、读写单元格(值/公式/样式/批注/单元格图片)、查找替换、多操作原子批量更新,以及图表、透视表、条件格式、筛选器、迷你图、浮动图片等对象的创建与维护。当用户需要创建电子表格、管理工作表、批量读写或编辑数据、统计汇总与可视化、表格美化、公式计算(含 Excel 公式迁移)等任务时使用。若用户是想按名称或关键词搜索云空间(云盘/云存储)里的表格文件,请改用 lark-drive 的 drive (file: r1/lark-sheets/SKILL.md) +- lark-skill-maker: 创建 lark-cli 的自定义 Skill。当用户需要把飞书 API 操作封装成可复用的 Skill(包装原子 API 或编排多步流程)时使用。 (file: r1/lark-skill-maker/SKILL.md) +- lark-slides: 飞书幻灯片:创建和编辑幻灯片。创建演示文稿、读取幻灯片内容、管理幻灯片页面(创建、删除、读取、局部替换)。当用户需要创建或编辑幻灯片、读取或修改单个页面时使用。当用户给出 doubao.com 的 /slides/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:云文档内容编辑(走 lark-doc)、云文档里的独立画板对象(走 lark-whiteboard,注意 slide (file: r1/lark-slides/SKILL.md) +- lark-task: 飞书任务:管理任务、清单和任务智能体。创建待办任务、查看和更新任务状态、拆分子任务、组织任务清单、分配协作成员、上传任务附件、注册或注销任务智能体、更新任务智能体的主页数据、写入智能体任务记录。当用户需要创建待办事项、查看任务列表、跟踪任务进度、管理项目清单或给他人分配任务、为任务上传附件文件、注册注销任务智能体、更新智能体主页数据、写入任务记录时使用。 (file: r1/lark-task/SKILL.md) +- lark-vc: 飞书视频会议:搜索历史会议记录、查询会议纪要(总结/待办/章节/逐字稿)、查询参会人快照。当用户查询已结束的会议、获取会议产物(纪要/妙记)、查看参会人时使用;查询未来日程走 lark-calendar。不负责:Agent 真实入会/离会、会中实时事件(走 lark-vc-agent)。 (file: r1/lark-vc/SKILL.md) +- lark-vc-agent: 飞书视频会议:让机器人代当前用户加入/离开正在进行的会议,并读取会议期间的实时事件(参会人加入与离开、发言、聊天、屏幕共享等)。1. 用户提供 9 位会议号、要求代为入会或离会时使用 +meeting-join / +meeting-leave——会真实产生入会/离会记录。2. 会议进行中用户想知道“谁加入了”“谁离开了”“谁在发言”“有人共享屏幕吗”等会中动态时,机器人入会后用 +meeting-events 读取事件时间线。3. 典型场景:参会机器人、会中助手、代为旁听、代为参会。前提: (file: r1/lark-vc-agent/SKILL.md) +- lark-whiteboard: 飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用 DSL(转成 OpenAPI 格式)、PlantUML/Mermaid 格式更新画板内容。 当用户需要查看画板内容、导出画板图片、编辑画板,或是需要可视化表达架构、流程、组织关系、时间线、因果、对比等结构化信息时使用此 skill,无论是否提及"画板"。 ⚠️ 原 `lark-whiteboard-cli` skill 已合并至本 skill,若 skill 列表中同时存在 `lark-whiteboard- (file: r1/lark-whiteboard/SKILL.md) +- lark-wiki: 飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。当用户给出 doubao.com 的 /wiki/ URL/token 时,也应直接使用本 skill,不要因为域名不是飞书而回退到 WebFetch;路由依据是 URL 路径模式和 token,而不是域名。不负责:上传文件到知识库节点下(走 lark-drive) (file: r1/lark-wiki/SKILL.md) +- lark-workflow-meeting-summary: 会议纪要整理工作流:汇总指定时间范围内的会议纪要并生成结构化报告。当用户需要整理会议纪要、生成会议周报、回顾一段时间内的会议内容时使用。 (file: r1/lark-workflow-meeting-summary/SKILL.md) +- lark-workflow-standup-report: 日程待办摘要:编排 calendar +agenda 和 task +get-my-tasks,生成指定日期的日程与未完成任务摘要。适用于了解今天/明天/本周的安排。 (file: r1/lark-workflow-standup-report/SKILL.md) +- pdf:pdf: Read, create, inspect, render, and verify PDF files where visual layout matters. Use Poppler rendering plus Python tools such as reportlab, pdfplumber, and pypdf for generation and extraction. (file: r7/pdf/26.630.12135/skills/pdf/SKILL.md) +- presentations:Presentations: Create or edit PowerPoint or Google Slides decks (file: r7/presentations/26.630.12135/skills/presentations/SKILL.md) +- product-design:audit: Audit or critique a product flow, journey, workflow, funnel, onboarding path, checkout path, settings path, screen, or multi-step product experience by capturing screenshots first, placing them in Figma or a local folder, then reporting UX, design, (file: r6/audit/SKILL.md) +- product-design:get-context: Mandatory design-brief gate for Product Design build and design workflows. Use before ideation, prototyping, image-to-code builds, redesigns, or product UI work to clarify missing product, visual, and interactivity context or play back the supplied (file: r6/get-context/SKILL.md) +- product-design:ideate: Generate image-based visual alternatives, remixes, or concept directions after Product Design get-context has confirmed the design brief. Use when the user asks for design variants, visual exploration, remixes, or image-generated approaches from provi (file: r6/ideate/SKILL.md) +- product-design:image-to-code: Implement a selected image, screenshot, mockup, or Image Gen reference as a faithful responsive frontend after Product Design get-context has confirmed the design brief. (file: r6/image-to-code/SKILL.md) +- product-design:index: Use to discover specific skills for the Product Design plugin, when it is at-mentioned directly, or for any mentions of potentially relevant work, including: UX research; product, screen, or flow audits; visual ideation; app or interface design, red (file: r6/index/SKILL.md) +- spreadsheets:Spreadsheets: Use this skill when a user requests to create, modify, analyze, visualize, or work with spreadsheet files (`.xlsx`, `.xls`, `.csv`, `.tsv`) or Google Sheets-targeted spreadsheet artifacts with formulas, formatting, charts, tables, and recalculation (file: r7/spreadsheets/26.630.12135/skills/spreadsheets/SKILL.md) +- template-creator:template-creator: Create or update a reusable personal Codex artifact-template skill. Use when the user invokes $template-creator or asks in natural language to create a template using, from, or based on an attached Word document, PowerPoint presentation, or Excel w (file: r7/template-creator/26.630.12135/skills/template-creator/SKILL.md) +### How to use skills +- Discovery: The list above is the skills available in this session (name + description + short path). Skill bodies live on disk at the listed paths after expanding the matching alias from `### Skill roots`. +- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned. +- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback. +- How to use a skill (progressive disclosure): + 1) After deciding to use a skill, the main agent must expand the listed short `path` with the matching alias from `### Skill roots`, then open and read its `SKILL.md` completely before taking task actions. If a read is truncated or paginated, continue until EOF. + 2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the directory containing that expanded `SKILL.md` first, and only consider other paths if needed. + 3) If `SKILL.md` points to extra folders such as `references/`, use its routing instructions to identify the files required for the task. The main agent must read each required instruction or reference file itself before acting on it. Do not delegate reading, summarizing, or interpreting skill instructions to a subagent. Subagents may still perform task work when the selected skill allows it. + 4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks. + 5) If `assets/` or templates exist, reuse them instead of recreating from scratch. +- Coordination and sequencing: + - If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them. + - Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why. +- Context hygiene: + - Progressive disclosure applies to selecting relevant files, not partially reading a selected instruction file. Do not load unrelated references, scripts, or assets. + - Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked. + - When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice. +- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue. + +## Plugins +A plugin is a local bundle of skills, MCP servers, and apps. +### How to use plugins +- Skill naming: If a plugin contributes skills, those skill entries are prefixed with `plugin_name:` in the Skills list. +- MCP naming: Plugin-provided MCP tools keep standard MCP identifiers such as `mcp__server__tool`; use tool provenance to tell which plugin they come from. +- Trigger rules: If the user explicitly names a plugin, prefer capabilities associated with that plugin for that turn. +- Relationship to capabilities: Plugins are not invoked directly. Use their underlying skills, MCP tools, and app tools to help solve the task. +- Relevance: Determine what a plugin can help with from explicit user mention or from the plugin-associated skills, MCP tools, and apps exposed elsewhere in this turn. +- Missing/blocked: If the user requests a plugin that does not have relevant callable capabilities for the task, say so briefly and continue with the best fallback. + + +--- + + +**User** + +你是验收子 agent。请只做只读代码审查,不要改文件。工作目录:/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter。 + +验收范围 2:房间聊天列表重构: +- lib/modules/room/chat/widgets/room_chat_message_pane.dart +- lib/modules/room/chat/all/all_chat_page.dart +- lib/modules/room/chat/chat/chat_page.dart +- lib/modules/room/chat/gift/gift_chat_page.dart + +重点确认: +1) 三个聊天页确实去掉 shrinkWrap 和每条消息 jumpTo(0); +2) 新组件化拆分是必要且范围克制:只抽重复高频列表逻辑,不扩大无关模块; +3) 批量合并消息、最多 250 条、用户不在底部时不强制跳到底部的逻辑是否正确; +4) RtmProvider 三个 listener 的挂载/卸载是否安全; +5) 是否有明显行为回归或编译问题。 +请输出:通过/不通过、发现的问题、建议。 + +--- + + +**OpenAI Codex** + +我会按验收范围做只读审查:查看相关文件与当前 diff,不修改任何文件。 + +--- + + +**OpenAI Codex** + +通过。 + +## 发现的问题 + +未发现阻断问题。 + +## 验收结论 + +1. **已去掉 `shrinkWrap` 和每条消息 `jumpTo(0)`** + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/chat/all/all_chat_page.dart` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/chat/chat/chat_page.dart` + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/chat/gift/gift_chat_page.dart` + + 三个页面已改为薄封装,不再直接持有 `ListView`、`ScrollController`、`shrinkWrap` 或每条消息 `jumpTo(0)`。 + +2. **组件化拆分范围克制** + - 新增 `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/chat/widgets/room_chat_message_pane.dart` + - 只抽取三类聊天页重复的列表、滚动、listener、消息合并逻辑。 + - 三个页面仅保留 channel 选择和点击用户逻辑,范围合理。 + +3. **批量合并、最多 250 条、不强制跳底逻辑正确** + - 使用 16ms `Timer` 合并消息批次,减少每条消息 `setState`。 + - `_msgList.removeRange(250, length)` 保证最多 250 条。 + - 仅当 `_isAtLatestMessage == true` 时批量后自动跳到底部;用户不在底部时不会强制跳。 + +4. **RtmProvider listener 挂载/卸载安全** + - `msgAllListener` / `msgChatListener` / `msgGiftListener` 按 channel 挂载。 + - dispose 时通过 `identical` 判断,只清理自己挂上的 listener,避免误删新实例 listener。 + - timer、pending messages、scroll controller 都有清理。 + +5. **编译检查** + - 已执行: + `flutter analyze --no-pub` 针对 4 个验收文件 + - 结果:`No issues found` + +## 建议 + +- 可补充 widget/unit 测试覆盖: + - 连续多条消息只触发批量刷新; + - 超过 250 条时裁剪旧消息; + - 用户滚离底部后新消息不自动跳底; + - 三个 channel listener dispose 后不残留。 + +- 工作区中还存在 `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/voice_room_page.dart` 的变更,不属于本次四文件验收范围;如发布要求严格限定变更范围,建议单独验收。 + +--- + + +**OpenAI Codex** + +只读复验完成。 + +结论:**不通过**。 +之前的“`roomVisualEffectsEnabled=true` 时漏绑定 RTM listener”核心问题**已解决**,但新增了一个判断 listener 是否已绑定的逻辑问题。 + +发现的问题: + +1. **漏绑定问题已解决** + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/voice_room_page.dart:261-267` + - `_ensureRoomVisualEffectsEnabled()` 现在只在无房间或路由不可见时 return,不再因为 `roomVisualEffectsEnabled == true` 提前退出。 + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/voice_room_page.dart:224-229` + - `_enableRoomVisualEffects()` 会在特效已开启时继续调用 `_bindRoomVisualEffectMessageListeners()`,之前的漏绑定场景已覆盖。 + +2. **新增问题:`identical` 判断 method tear-off 会导致“已绑定”永远判 false** + - `/Users/zuozuo/Documents/Project/haiyi/chatapp3-flutter/lib/modules/room/voice_room_page.dart:232-258` + - Dart 中同一个实例方法 tear-off 多次取值通常不是 `identical`,但 `==` 可以相等。 + - 因此 `_areRoomVisualEffectMessageListenersBound()` 很可能一直返回 `false`。 + - 影响: + - `_buildRoomVisualEffectActivationListener()` 会误以为 listener 未绑定; + - 产生无谓的 post-frame ensure 调度; + - `_bindRoomVisualEffectMessageListeners()` 也会重复赋值 listener。 + - 当前看不到死循环,因为已开启特效后不会再次 notify,但这是新增的逻辑瑕疵。 + +3. **未发现新的明显编译/生命周期问题** + - 全量 `RtcProvider.addListener/setState` 仍已拆除。 + - 聊天 key、背景、启动失败层、礼物特效层仍由局部 `Selector` 驱动。 + +建议: + +把新增的 `identical(...)` 比较改为 `==`,与 `_suspendRoomVisualEffects()` 中已有判断保持一致: + +```dart +rtmProvider.msgFloatingGiftListener == _floatingGiftListener +``` + +以及: + +```dart +rtmProvider.msgLuckyGiftRewardTickerListener == + _luckyGiftRewardTickerListener +``` + +这样才能正确判断 listener 是否已经绑定,避免无谓调度和重复赋值。 + +--- + + +**User** + +已再次修复:把 _bindRoomVisualEffectMessageListeners / _areRoomVisualEffectMessageListenersBound 中的 identical 比较改为 ==,与原有 _suspendRoomVisualEffects 判断保持一致。请只读最终复验 VoiceRoomPage,重点确认之前两个不通过点是否已解决、是否还有阻断问题。 + +--- + + +**OpenAI Codex** + +通过。 + +复验结论: + +1. **之前漏绑定问题已解决** + - `_ensureRoomVisualEffectsEnabled()` 不再因为 `roomVisualEffectsEnabled == true` 直接 return。 + - `_enableRoomVisualEffects()` 会始终调用 `_bindRoomVisualEffectMessageListeners()`。 + - 因此特效开关已开启但 RTM 礼物 listener 未绑定的场景已覆盖。 + +2. **之前 `identical` 判断问题已解决** + - `_bindRoomVisualEffectMessageListeners()` / `_areRoomVisualEffectMessageListenersBound()` 已改为 `==`。 + - 与 `_suspendRoomVisualEffects()` 中原有判断一致,避免 method tear-off 因 `identical` 导致误判未绑定。 + +3. **全量监听仍已拆除** + - 未发现 `RtcProvider.addListener/removeListener` 全量监听。 + - `setState` 仅保留在 Tab 切换逻辑中。 + +4. **局部 Selector 仍符合预期** + - 聊天 room key、背景、启动失败层、礼物 SVGA 层、座位飞行动画层均由局部 `Selector` 驱动。 + +5. **阻断问题** + - 未发现新的明显编译、循环调度、生命周期阻断问题。 + +建议:无阻断建议,可合入。 + +--- diff --git a/lib/modules/room/chat/all/all_chat_page.dart b/lib/modules/room/chat/all/all_chat_page.dart index 6f3ce3e..7171463 100644 --- a/lib/modules/room/chat/all/all_chat_page.dart +++ b/lib/modules/room/chat/all/all_chat_page.dart @@ -1,154 +1,22 @@ import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:yumi/app_localizations.dart'; -import 'package:yumi/shared/business_logic/models/res/login_res.dart'; import 'package:provider/provider.dart'; -import 'package:yumi/app/constants/sc_screen.dart'; +import 'package:yumi/modules/room/chat/widgets/room_chat_message_pane.dart'; import 'package:yumi/services/audio/rtc_manager.dart'; -import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; -import 'package:yumi/services/audio/rtm_manager.dart'; -class AllChatPage extends StatefulWidget { +class AllChatPage extends StatelessWidget { const AllChatPage({super.key}); - @override - State createState() => _AllChatPageState(); -} - -class _AllChatPageState extends State { - final ScrollController _controller = ScrollController(); - bool showGoBottom = true; - bool _isDisposed = false; - final List _msgList = []; - late RtmProvider provider; - late final RoomNewMsgListener _newMsgListener = _onNewMsg; - - @override - void initState() { - super.initState(); - _controller.addListener(_scrollListener); - provider = Provider.of(context, listen: false); - final msgList = provider.roomAllMsgList; - provider.msgAllListener = _newMsgListener; - _msgList.addAll(msgList); - // 使用安全的方式初始化滚动位置 - _initScrollPosition(); - } - - void _scrollListener() { - final position = _controller.position; - - // 容差范围(5像素)避免精度问题 - const tolerance = 10.0; - - // 判断是否在第一个项目(颠倒列表的底部) - final isAtBottom = position.pixels < position.minScrollExtent + tolerance; - - // 判断是否在最后一个项目(颠倒列表的顶部) - // final isAtTop = position.pixels >= position.maxScrollExtent - tolerance; - if (isAtBottom != showGoBottom) { - showGoBottom = isAtBottom; - if (!_isDisposed) { - setState(() {}); - } - } - } - - // 安全初始化滚动位置 - void _initScrollPosition() { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - if (_isDisposed) return; - _controller.jumpTo(0.0); - }); - } - - _onNewMsg(Msg msg) { - if (_isDisposed) return; // 如果组件已销毁,直接返回 - if (msg.groupId == "-1000") { - ///清屏 - _msgList.clear(); - setState(() {}); - return; - } - setState(() { - if (_msgList.contains(msg)) { - _msgList.remove(msg); - } - _msgList.insert(0, msg); - }); - _controller.jumpTo(0.0); - } - - @override - void dispose() { - _isDisposed = true; - if (identical(provider.msgAllListener, _newMsgListener)) { - provider.msgAllListener = null; - } - _controller.removeListener(_scrollListener); - _controller.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.transparent, - resizeToAvoidBottomInset: false, - body: Stack( - alignment: Alignment.bottomLeft, - children: [ - ListView.builder( - reverse: true, - controller: _controller, - shrinkWrap: true, - itemBuilder: (BuildContext context, int index) { - return MsgItem( - msg: _msgList[index], - onClick: (SocialChatUserProfile? user) { - Provider.of(context, listen: false).clickSite( - Provider.of( - context, - listen: false, - ).userOnMaiInIndex(user?.id ?? ""), - clickUser: user, - ); - }, - ); - }, - itemCount: _msgList.length, - ), - Visibility( - visible: !showGoBottom, - child: GestureDetector( - onTap: () { - if (_controller.hasClients) { - _controller.jumpTo(0.0); - } - }, - child: Container( - margin: EdgeInsets.symmetric(horizontal: width(15)), - padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 4.w), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(52), - color: Colors.white, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - SCAppLocalizations.of(context)!.scrollToTheBottom, - style: TextStyle(fontSize: 10.sp), - ), - SizedBox(width: 4.w), - Icon(Icons.chevron_right, size: 10.w), - ], - ), - ), - ), - ), - ], - ), + return RoomChatMessagePane( + channel: RoomChatMessageChannel.all, + onMessageTap: (user) { + final rtcProvider = context.read(); + rtcProvider.clickSite( + rtcProvider.userOnMaiInIndex(user?.id ?? ""), + clickUser: user, + ); + }, ); } } diff --git a/lib/modules/room/chat/chat/chat_page.dart b/lib/modules/room/chat/chat/chat_page.dart index dcf2179..0c0d5e0 100644 --- a/lib/modules/room/chat/chat/chat_page.dart +++ b/lib/modules/room/chat/chat/chat_page.dart @@ -1,153 +1,22 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:yumi/shared/business_logic/models/res/login_res.dart'; -import 'package:provider/provider.dart'; - -import 'package:yumi/app_localizations.dart'; -import 'package:yumi/app/constants/sc_screen.dart'; -import 'package:yumi/services/audio/rtc_manager.dart'; -import 'package:yumi/services/audio/rtm_manager.dart'; -import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; - -class ChatPage extends StatefulWidget { - @override - _ChatPageState createState() => _ChatPageState(); -} - -class _ChatPageState extends State { - ScrollController _controller = ScrollController(); - bool showGoBottom = true; - bool _isDisposed = false; - List _msgList = []; - late RtmProvider provider; - late final RoomNewMsgListener _newMsgListener = _onNewMsg; - - @override - void initState() { - super.initState(); - _controller.addListener(_scrollListener); - provider = Provider.of(context, listen: false); - var msgList = provider.roomChatMsgList; - provider.msgChatListener = _newMsgListener; - _msgList.addAll(msgList ??= []); - // 使用安全的方式初始化滚动位置 - _initScrollPosition(); - } - - void _scrollListener() { - final position = _controller.position; - - // 容差范围(5像素)避免精度问题 - const tolerance = 10.0; - - // 判断是否在第一个项目(颠倒列表的底部) - final isAtBottom = position.pixels < position.minScrollExtent + tolerance; - - // 判断是否在最后一个项目(颠倒列表的顶部) - // final isAtTop = position.pixels >= position.maxScrollExtent - tolerance; - if (isAtBottom != showGoBottom) { - showGoBottom = isAtBottom; - if (!_isDisposed) { - setState(() {}); - } - } - } - - // 安全初始化滚动位置 - void _initScrollPosition() { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - if (_isDisposed) return; - _controller.jumpTo(0.0); - }); - } - - _onNewMsg(Msg msg) { - if (_isDisposed) return; // 如果组件已销毁,直接返回 - if (msg.groupId == "-1000") { - ///清屏 - _msgList.clear(); - setState(() {}); - return; - } - if (!_msgList.contains(msg)) { - setState(() { - _msgList.insert(0, msg); - }); - _controller.jumpTo(0.0); - } - } - - @override - void dispose() { - _isDisposed = true; - if (identical(provider.msgChatListener, _newMsgListener)) { - provider.msgChatListener = null; - } - _controller.removeListener(_scrollListener); - _controller.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.transparent, - resizeToAvoidBottomInset: false, - body: Stack( - alignment: Alignment.bottomLeft, - children: [ - ListView.builder( - reverse: true, - controller: _controller, - shrinkWrap: true, - itemBuilder: (BuildContext context, int index) { - return MsgItem( - msg: _msgList[index], - onClick: (SocialChatUserProfile? user) { - Provider.of(context, listen: false).clickSite( - Provider.of( - context, - listen: false, - ).userOnMaiInIndex(user?.id ?? ""), - clickUser: user, - ); - }, - ); - }, - itemCount: _msgList.length, - ), - Visibility( - visible: !showGoBottom, - child: GestureDetector( - onTap: () { - if (_controller.hasClients) { - _controller.jumpTo(0.0); - } - }, - child: Container( - margin: EdgeInsets.symmetric(horizontal: width(15)), - padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 4.w), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(52), - color: Colors.white, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - SCAppLocalizations.of(context)!.scrollToTheBottom, - style: TextStyle(fontSize: 10.sp), - ), - SizedBox(width: 4.w), - Icon(Icons.chevron_right, size: 10.w), - ], - ), - ), - ), - ), - ], - ), - ); - } -} +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:yumi/modules/room/chat/widgets/room_chat_message_pane.dart'; +import 'package:yumi/services/audio/rtc_manager.dart'; + +class ChatPage extends StatelessWidget { + const ChatPage({super.key}); + + @override + Widget build(BuildContext context) { + return RoomChatMessagePane( + channel: RoomChatMessageChannel.chat, + onMessageTap: (user) { + final rtcProvider = context.read(); + rtcProvider.clickSite( + rtcProvider.userOnMaiInIndex(user?.id ?? ""), + clickUser: user, + ); + }, + ); + } +} diff --git a/lib/modules/room/chat/gift/gift_chat_page.dart b/lib/modules/room/chat/gift/gift_chat_page.dart index 63cb977..0a63a15 100644 --- a/lib/modules/room/chat/gift/gift_chat_page.dart +++ b/lib/modules/room/chat/gift/gift_chat_page.dart @@ -1,146 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:yumi/shared/business_logic/models/res/login_res.dart'; -import 'package:provider/provider.dart'; +import 'package:yumi/modules/room/chat/widgets/room_chat_message_pane.dart'; -import 'package:yumi/app_localizations.dart'; -import 'package:yumi/app/constants/sc_screen.dart'; -import 'package:yumi/services/audio/rtm_manager.dart'; -import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; - -class GiftChatPage extends StatefulWidget { +class GiftChatPage extends StatelessWidget { const GiftChatPage({super.key}); - @override - State createState() => _GiftChatPageState(); -} - -class _GiftChatPageState extends State { - final ScrollController _controller = ScrollController(); - bool showGoBottom = true; - bool _isDisposed = false; - final List _msgList = []; - late RtmProvider provider; - late final RoomNewMsgListener _newMsgListener = _onNewMsg; - - @override - void initState() { - super.initState(); - _controller.addListener(_scrollListener); - provider = Provider.of(context, listen: false); - final msgList = provider.roomGiftMsgList; - provider.msgGiftListener = _newMsgListener; - _msgList.addAll(msgList); - // 使用安全的方式初始化滚动位置 - _initScrollPosition(); - } - - void _scrollListener() { - final position = _controller.position; - - // 容差范围(5像素)避免精度问题 - const tolerance = 10.0; - - // 判断是否在第一个项目(颠倒列表的底部) - final isAtBottom = position.pixels < position.minScrollExtent + tolerance; - - // 判断是否在最后一个项目(颠倒列表的顶部) - // final isAtTop = position.pixels >= position.maxScrollExtent - tolerance; - if (isAtBottom != showGoBottom) { - showGoBottom = isAtBottom; - if (!_isDisposed) { - setState(() {}); - } - } - } - - // 安全初始化滚动位置 - void _initScrollPosition() { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) async { - if (_isDisposed) return; - _controller.jumpTo(0.0); - }); - } - - _onNewMsg(Msg msg) { - if (_isDisposed) return; // 如果组件已销毁,直接返回 - if (msg.groupId == "-1000") { - ///清屏 - _msgList.clear(); - setState(() {}); - return; - } - setState(() { - if (_msgList.contains(msg)) { - _msgList.remove(msg); - } - _msgList.insert(0, msg); - }); - _controller.jumpTo(0.0); - } - - @override - void dispose() { - _isDisposed = true; - if (identical(provider.msgGiftListener, _newMsgListener)) { - provider.msgGiftListener = null; - } - _controller.removeListener(_scrollListener); - _controller.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.transparent, - resizeToAvoidBottomInset: false, - body: Stack( - alignment: Alignment.bottomLeft, - children: [ - ListView.builder( - reverse: true, - controller: _controller, - shrinkWrap: true, - itemBuilder: (BuildContext context, int index) { - return MsgItem( - msg: _msgList[index], - onClick: (SocialChatUserProfile? user) {}, - ); - }, - itemCount: _msgList.length, - ), - Visibility( - visible: !showGoBottom, - child: GestureDetector( - onTap: () { - if (_controller.hasClients) { - _controller.jumpTo(0.0); - } - }, - child: Container( - margin: EdgeInsets.symmetric(horizontal: width(15)), - padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 4.w), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(52), - color: Colors.white, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - SCAppLocalizations.of(context)!.scrollToTheBottom, - style: TextStyle(fontSize: 10.sp), - ), - SizedBox(width: 4.w), - Icon(Icons.chevron_right, size: 10.w), - ], - ), - ), - ), - ), - ], - ), - ); + return const RoomChatMessagePane(channel: RoomChatMessageChannel.gift); } } diff --git a/lib/modules/room/chat/widgets/room_chat_message_pane.dart b/lib/modules/room/chat/widgets/room_chat_message_pane.dart new file mode 100644 index 0000000..b88f2e8 --- /dev/null +++ b/lib/modules/room/chat/widgets/room_chat_message_pane.dart @@ -0,0 +1,266 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:provider/provider.dart'; +import 'package:yumi/app/constants/sc_screen.dart'; +import 'package:yumi/app_localizations.dart'; +import 'package:yumi/services/audio/rtm_manager.dart'; +import 'package:yumi/shared/business_logic/models/res/login_res.dart'; +import 'package:yumi/ui_kit/widgets/room/room_msg_item.dart'; + +enum RoomChatMessageChannel { all, chat, gift } + +class RoomChatMessagePane extends StatefulWidget { + const RoomChatMessagePane({ + super.key, + required this.channel, + this.onMessageTap, + }); + + final RoomChatMessageChannel channel; + final ValueChanged? onMessageTap; + + @override + State createState() => _RoomChatMessagePaneState(); +} + +class _RoomChatMessagePaneState extends State { + static const int _maxVisibleMessages = 250; + static const Duration _messageBatchWindow = Duration(milliseconds: 16); + + final ScrollController _controller = ScrollController(); + final List _msgList = []; + final List _pendingMessages = []; + Timer? _messageBatchTimer; + bool _isAtLatestMessage = true; + bool _isDisposed = false; + late RtmProvider _provider; + late final RoomNewMsgListener _newMsgListener = _onNewMsg; + + @override + void initState() { + super.initState(); + _controller.addListener(_scrollListener); + _provider = context.read(); + _msgList.addAll(_trimmedInitialMessages(_provider, widget.channel)); + _attachListener(_provider, widget.channel); + _jumpToLatestMessageAfterLayout(); + } + + @override + void didUpdateWidget(covariant RoomChatMessagePane oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.channel == widget.channel) { + return; + } + _detachListener(_provider, oldWidget.channel); + _messageBatchTimer?.cancel(); + _messageBatchTimer = null; + _pendingMessages.clear(); + _isAtLatestMessage = true; + _msgList + ..clear() + ..addAll(_trimmedInitialMessages(_provider, widget.channel)); + _attachListener(_provider, widget.channel); + _jumpToLatestMessageAfterLayout(); + } + + @override + void dispose() { + _isDisposed = true; + _messageBatchTimer?.cancel(); + _pendingMessages.clear(); + _detachListener(_provider, widget.channel); + _controller.removeListener(_scrollListener); + _controller.dispose(); + super.dispose(); + } + + List _trimmedInitialMessages( + RtmProvider provider, + RoomChatMessageChannel channel, + ) { + final messages = switch (channel) { + RoomChatMessageChannel.all => provider.roomAllMsgList, + RoomChatMessageChannel.chat => provider.roomChatMsgList, + RoomChatMessageChannel.gift => provider.roomGiftMsgList, + }; + return messages.take(_maxVisibleMessages).toList(growable: false); + } + + void _attachListener(RtmProvider provider, RoomChatMessageChannel channel) { + switch (channel) { + case RoomChatMessageChannel.all: + provider.msgAllListener = _newMsgListener; + return; + case RoomChatMessageChannel.chat: + provider.msgChatListener = _newMsgListener; + return; + case RoomChatMessageChannel.gift: + provider.msgGiftListener = _newMsgListener; + return; + } + } + + void _detachListener(RtmProvider provider, RoomChatMessageChannel channel) { + switch (channel) { + case RoomChatMessageChannel.all: + if (identical(provider.msgAllListener, _newMsgListener)) { + provider.msgAllListener = null; + } + return; + case RoomChatMessageChannel.chat: + if (identical(provider.msgChatListener, _newMsgListener)) { + provider.msgChatListener = null; + } + return; + case RoomChatMessageChannel.gift: + if (identical(provider.msgGiftListener, _newMsgListener)) { + provider.msgGiftListener = null; + } + return; + } + } + + void _scrollListener() { + final position = _controller.position; + const tolerance = 10.0; + final isAtLatest = position.pixels < position.minScrollExtent + tolerance; + if (isAtLatest != _isAtLatestMessage) { + _isAtLatestMessage = isAtLatest; + if (!_isDisposed) { + setState(() {}); + } + } + } + + void _onNewMsg(Msg msg) { + if (_isDisposed) { + return; + } + if (msg.groupId == "-1000") { + _clearMessages(); + return; + } + _pendingMessages.add(msg); + _scheduleMessageBatchFlush(); + } + + void _clearMessages() { + _messageBatchTimer?.cancel(); + _messageBatchTimer = null; + _pendingMessages.clear(); + _isAtLatestMessage = true; + _msgList.clear(); + setState(() {}); + } + + void _scheduleMessageBatchFlush() { + if (_messageBatchTimer?.isActive ?? false) { + return; + } + // 房间消息会成批到达,按一帧合并 setState,避免每条消息都触发布局和滚动。 + _messageBatchTimer = Timer(_messageBatchWindow, _flushPendingMessages); + } + + void _flushPendingMessages() { + _messageBatchTimer = null; + if (_isDisposed || _pendingMessages.isEmpty) { + return; + } + final messages = List.of(_pendingMessages); + _pendingMessages.clear(); + final shouldStickToLatest = _isAtLatestMessage; + + setState(() { + for (final msg in messages) { + _mergeMessage(msg); + } + if (_msgList.length > _maxVisibleMessages) { + _msgList.removeRange(_maxVisibleMessages, _msgList.length); + } + }); + + if (shouldStickToLatest) { + _jumpToLatestMessageAfterLayout(); + } + } + + void _mergeMessage(Msg msg) { + if (widget.channel == RoomChatMessageChannel.chat && + _msgList.contains(msg)) { + return; + } + final existingIndex = _msgList.indexOf(msg); + if (existingIndex >= 0) { + _msgList.removeAt(existingIndex); + } + _msgList.insert(0, msg); + } + + void _jumpToLatestMessageAfterLayout() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_isDisposed) { + return; + } + _jumpToLatestMessage(); + }); + } + + void _jumpToLatestMessage() { + if (!_controller.hasClients) { + return; + } + _controller.jumpTo(_controller.position.minScrollExtent); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.transparent, + resizeToAvoidBottomInset: false, + body: Stack( + alignment: Alignment.bottomLeft, + children: [ + ListView.builder( + reverse: true, + controller: _controller, + // 父级已经提供确定高度;不使用 shrinkWrap,避免每批消息重算全部 item 高度。 + itemBuilder: + (BuildContext context, int index) => MsgItem( + msg: _msgList[index], + onClick: (user) => widget.onMessageTap?.call(user), + ), + itemCount: _msgList.length, + ), + Visibility( + visible: !_isAtLatestMessage, + child: GestureDetector( + onTap: _jumpToLatestMessage, + child: Container( + margin: EdgeInsets.symmetric(horizontal: width(15)), + padding: EdgeInsets.symmetric(horizontal: 10.w, vertical: 4.w), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(52), + color: Colors.white, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + SCAppLocalizations.of(context)!.scrollToTheBottom, + style: TextStyle(fontSize: 10.sp), + ), + SizedBox(width: 4.w), + Icon(Icons.chevron_right, size: 10.w), + ], + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/modules/room/voice_room_page.dart b/lib/modules/room/voice_room_page.dart index 7c6387b..5a0dd83 100644 --- a/lib/modules/room/voice_room_page.dart +++ b/lib/modules/room/voice_room_page.dart @@ -80,7 +80,6 @@ class _VoiceRoomPageState extends State final Map _giftVisualBatchDedupeTimers = {}; int _shownRoomStartupFailureToken = 0; RtcProvider? _rtcProvider; - bool _roomProviderRebuildScheduled = false; bool _roomRouteVisibleScheduled = false; bool _roomVisualEffectsEnsureScheduled = false; PageRoute? _routeObserverRoute; @@ -114,9 +113,7 @@ class _VoiceRoomPageState extends State super.didChangeDependencies(); final rtcProvider = context.read(); if (!identical(_rtcProvider, rtcProvider)) { - _rtcProvider?.removeListener(_handleRoomProviderChanged); _rtcProvider = rtcProvider; - rtcProvider.addListener(_handleRoomProviderChanged); context.read().attachRtcProvider(rtcProvider); } final route = ModalRoute.of(context); @@ -141,9 +138,7 @@ class _VoiceRoomPageState extends State _routeObserverRoute = null; _releaseCoveredRouteGuard(); _setRoomRouteVisible(false); - _rtcProvider?.removeListener(_handleRoomProviderChanged); _rtcProvider = null; - _roomProviderRebuildScheduled = false; _suspendRoomVisualEffects(); _tabController.removeListener(_handleTabChange); _tabController.dispose(); // 释放资源 @@ -226,36 +221,36 @@ class _VoiceRoomPageState extends State setState(() {}); } - void _handleRoomProviderChanged() { - if (!mounted || _roomProviderRebuildScheduled) { - return; + void _enableRoomVisualEffects() { + final rtcProvider = Provider.of(context, listen: false); + if (!rtcProvider.roomVisualEffectsEnabled) { + rtcProvider.setRoomVisualEffectsEnabled(true); } - _roomProviderRebuildScheduled = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - _roomProviderRebuildScheduled = false; - if (!mounted) { - return; - } - setState(() {}); - }); + _bindRoomVisualEffectMessageListeners(); } - void _enableRoomVisualEffects() { - Provider.of( - context, - listen: false, - ).setRoomVisualEffectsEnabled(true); + void _bindRoomVisualEffectMessageListeners() { final rtmProvider = Provider.of(context, listen: false); - rtmProvider.msgFloatingGiftListener = _floatingGiftListener; - rtmProvider.msgLuckyGiftRewardTickerListener = - _luckyGiftRewardTickerListener; + if (rtmProvider.msgFloatingGiftListener != _floatingGiftListener) { + rtmProvider.msgFloatingGiftListener = _floatingGiftListener; + } + if (rtmProvider.msgLuckyGiftRewardTickerListener != + _luckyGiftRewardTickerListener) { + rtmProvider.msgLuckyGiftRewardTickerListener = + _luckyGiftRewardTickerListener; + } + } + + bool _areRoomVisualEffectMessageListenersBound() { + final rtmProvider = Provider.of(context, listen: false); + return rtmProvider.msgFloatingGiftListener == _floatingGiftListener && + rtmProvider.msgLuckyGiftRewardTickerListener == + _luckyGiftRewardTickerListener; } void _ensureRoomVisualEffectsEnabled() { final rtcProvider = Provider.of(context, listen: false); - if (rtcProvider.currenRoom == null || - !_roomRouteVisible || - rtcProvider.roomVisualEffectsEnabled) { + if (rtcProvider.currenRoom == null || !_roomRouteVisible) { return; } _enableRoomVisualEffects(); @@ -350,6 +345,25 @@ class _VoiceRoomPageState extends State ); } + Widget _buildRoomVisualEffectActivationListener() { + return Selector( + selector: + (context, provider) => _RoomEffectActivationSnapshot( + hasRoom: provider.currenRoom != null, + effectsEnabled: provider.roomVisualEffectsEnabled, + ), + builder: (context, snapshot, child) { + if (snapshot.hasRoom && + (!snapshot.effectsEnabled || + !_areRoomVisualEffectMessageListenersBound())) { + // 只监听房间/特效开关,并补齐消息 listener 绑定,避免 RtcProvider 任意通知都重建整页。 + _scheduleEnsureRoomVisualEffectsEnabled(); + } + return const SizedBox.shrink(); + }, + ); + } + void _showRoomStartupFailureDialog(_RoomStartupSnapshot snapshot) { if (_shownRoomStartupFailureToken == snapshot.failureToken) { return; @@ -416,7 +430,6 @@ class _VoiceRoomPageState extends State @override Widget build(BuildContext context) { - final chatRoomKey = _chatRoomKey(context); return PopScope( canPop: false, onPopInvokedWithResult: (bool didPop, Object? result) { @@ -474,7 +487,14 @@ class _VoiceRoomPageState extends State children: [ Column( children: [ - _buildChatView(chatRoomKey), + Selector( + selector: + (context, provider) => + _chatRoomKeyFromProvider(provider), + builder: + (context, chatRoomKey, child) => + _buildChatView(chatRoomKey), + ), const RoomBottomWidget(showGiftComboButton: false), ], ), @@ -494,6 +514,7 @@ class _VoiceRoomPageState extends State ), ], ), + _buildRoomVisualEffectActivationListener(), _buildRoomStartupLayer(), const PositionedDirectional( start: 0, @@ -628,9 +649,8 @@ class _VoiceRoomPageState extends State ); } - String _chatRoomKey(BuildContext context) { - final roomProfile = - context.read().currenRoom?.roomProfile?.roomProfile; + String _chatRoomKeyFromProvider(RtcProvider provider) { + final roomProfile = provider.currenRoom?.roomProfile?.roomProfile; final roomId = roomProfile?.id?.trim() ?? ""; final groupId = roomProfile?.roomAccount?.trim() ?? ""; return "$roomId|$groupId"; @@ -642,7 +662,7 @@ class _VoiceRoomPageState extends State key: ValueKey("all_chat_$chatRoomKey"), child: const AllChatPage(), ), - KeyedSubtree(key: ValueKey("chat_$chatRoomKey"), child: ChatPage()), + KeyedSubtree(key: ValueKey("chat_$chatRoomKey"), child: const ChatPage()), KeyedSubtree( key: ValueKey("gift_chat_$chatRoomKey"), child: const GiftChatPage(), @@ -1355,6 +1375,29 @@ class _RoomStartupSnapshot { int get hashCode => Object.hash(status, failureType, failureToken); } +class _RoomEffectActivationSnapshot { + const _RoomEffectActivationSnapshot({ + required this.hasRoom, + required this.effectsEnabled, + }); + + final bool hasRoom; + final bool effectsEnabled; + + @override + bool operator ==(Object other) { + if (identical(this, other)) { + return true; + } + return other is _RoomEffectActivationSnapshot && + other.hasRoom == hasRoom && + other.effectsEnabled == effectsEnabled; + } + + @override + int get hashCode => Object.hash(hasRoom, effectsEnabled); +} + class _RoomStartupFailureDialog extends StatelessWidget { const _RoomStartupFailureDialog({ required this.title, diff --git a/lib/services/audio/rtm_manager.dart b/lib/services/audio/rtm_manager.dart index e01ff20..fbcafe0 100644 --- a/lib/services/audio/rtm_manager.dart +++ b/lib/services/audio/rtm_manager.dart @@ -97,6 +97,8 @@ typedef RtmProvider = RealTimeMessagingManager; enum _CpInviteProcessResult { accepted, rejected, expired } +enum _RtmGiftFxLogLevel { verbose, warning, error } + class _LuckGiftPushQueueEntry { _LuckGiftPushQueueEntry({ required this.reward, @@ -248,8 +250,35 @@ class RealTimeMessagingManager extends ChangeNotifier { BuildContext? context; - void _giftFxLog(String message) { - debugPrint('[GiftFx][rtm] $message'); + LogLevelEnum _resolveTimSdkLogLevel() { + // IM SDK 日志按构建模式分级:debug 保留排查信息,profile/release 避免全量日志拖慢房间消息链路。 + if (kDebugMode) { + return LogLevelEnum.V2TIM_LOG_DEBUG; + } + if (kProfileMode) { + return LogLevelEnum.V2TIM_LOG_WARN; + } + return LogLevelEnum.V2TIM_LOG_ERROR; + } + + bool _shouldPrintGiftFxLog(_RtmGiftFxLogLevel level) { + if (kDebugMode) { + return true; + } + if (kProfileMode) { + return level.index >= _RtmGiftFxLogLevel.warning.index; + } + return level.index >= _RtmGiftFxLogLevel.error.index; + } + + void _giftFxLog( + String message, { + _RtmGiftFxLogLevel level = _RtmGiftFxLogLevel.verbose, + }) { + if (!_shouldPrintGiftFxLog(level)) { + return; + } + debugPrint('[GiftFx][rtm][${level.name}] $message'); } void _enqueueGiftFloatingMessage( @@ -472,7 +501,7 @@ class RealTimeMessagingManager extends ChangeNotifier { V2TimValueCallback initSDKRes = await TencentImSDKPlugin.v2TIMManager .initSDK( sdkAppID: int.parse(SCGlobalConfig.tencentImAppid), // SDKAppID - loglevel: LogLevelEnum.V2TIM_LOG_ALL, // 日志登记等级 + loglevel: _resolveTimSdkLogLevel(), // 按构建模式分级,避免语音房消息高峰输出全量日志 listener: sdkListener, // 事件监听器 ); if (initSDKRes.code == 0) {} diff --git a/lib/shared/tools/sc_gift_vap_svga_manager.dart b/lib/shared/tools/sc_gift_vap_svga_manager.dart index 492ce4f..3cac148 100644 --- a/lib/shared/tools/sc_gift_vap_svga_manager.dart +++ b/lib/shared/tools/sc_gift_vap_svga_manager.dart @@ -3,6 +3,7 @@ import 'dart:collection'; import 'dart:io'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter_svga/flutter_svga.dart'; import 'package:yumi/app/constants/sc_global_config.dart'; import 'package:yumi/shared/tools/sc_path_utils.dart'; @@ -13,6 +14,8 @@ import 'package:yumi/ui_kit/widgets/room/effect/gift_mp4_effect_view.dart'; import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart'; +enum _GiftFxLogLevel { verbose, info, warning, error } + class SCGiftVapSvgaManager { static const int giftEffectType = 0; static const int entryEffectType = 1; @@ -77,8 +80,21 @@ class SCGiftVapSvgaManager { return _mute; } - void _log(String message) { - debugPrint('[GiftFx][manager] $message'); + bool _shouldPrintLog(_GiftFxLogLevel level) { + if (kDebugMode) { + return true; + } + if (kProfileMode) { + return level.index >= _GiftFxLogLevel.warning.index; + } + return level.index >= _GiftFxLogLevel.error.index; + } + + void _log(String message, {_GiftFxLogLevel level = _GiftFxLogLevel.verbose}) { + if (!_shouldPrintLog(level)) { + return; + } + debugPrint('[GiftFx][manager][${level.name}] $message'); } bool _needsSvgaController(String path) { @@ -152,6 +168,7 @@ class SCGiftVapSvgaManager { _log( 'preload ignored because effect file extension is unsupported ' 'path=$path ext=${SCPathUtils.getFileExtension(path)}', + level: _GiftFxLogLevel.warning, ); return; } @@ -556,7 +573,10 @@ class SCGiftVapSvgaManager { _hcs(); }, onFailed: (error) { - _log('mp4 onFailed path=${_currentTask?.path} error=$error'); + _log( + 'mp4 onFailed path=${_currentTask?.path} error=$error', + level: _GiftFxLogLevel.error, + ); _hcs(); }, ); @@ -592,13 +612,17 @@ class SCGiftVapSvgaManager { int type = giftEffectType, }) { if (path.isEmpty) { - _log('play ignored because path is empty'); + _log( + 'play ignored because path is empty', + level: _GiftFxLogLevel.warning, + ); return; } if (!_isSupportedEffectPath(path)) { _log( 'play ignored because effect file extension is unsupported ' 'path=$path ext=${SCPathUtils.getFileExtension(path)}', + level: _GiftFxLogLevel.warning, ); return; } @@ -622,6 +646,7 @@ class SCGiftVapSvgaManager { if (_dis) { _log( 'manager disposed/no controller; queue play until bind path=$path', + level: _GiftFxLogLevel.warning, ); } final task = SCVapTask( @@ -643,6 +668,7 @@ class SCGiftVapSvgaManager { 'play ignored because performance profile disabled heavy animations ' 'sdkInt=${SCGlobalConfig.sdkInt} maxSdkNoAnim=${SCGlobalConfig.maxSdkNoAnim} ' 'lowPerformance=${SCGlobalConfig.isLowPerformanceDevice}', + level: _GiftFxLogLevel.info, ); } } @@ -740,12 +766,16 @@ class SCGiftVapSvgaManager { _log( 'skip playFile because effect file extension is unsupported ' 'path=$resolvedPath source=${task.path}', + level: _GiftFxLogLevel.warning, ); _finishCurrentTask(); return; } if (_mp4Controller == null) { - _log('skip playFile because mp4 controller is null path=$resolvedPath'); + _log( + 'skip playFile because mp4 controller is null path=$resolvedPath', + level: _GiftFxLogLevel.warning, + ); _finishCurrentTask(); return; } @@ -811,7 +841,10 @@ class SCGiftVapSvgaManager { if (!_isCurrentTask(task)) { return; } - _log('task watchdog timeout path=${task.path}'); + _log( + 'task watchdog timeout path=${task.path}', + level: _GiftFxLogLevel.warning, + ); _mp4Controller?.stop(); _rsc?.stop(); _rsc?.reset();