[Android] 0.1.70 灰度:开启「对话归为一组」时切换「按项目分组」卡顿/ANR 闪退(#3500 原生栈主嫌疑,#3590 rerun 环路独立缺陷)
- Dominant language
- TypeScript
- Stars
- 2.7k
- Forks
- 395
- Avg merge
- 21h 48m
- Merged PRs (30d)
- 776
Description
## 现象
Android 端首页,在**「对话归为一组」开启**的前提下,无论打开还是关闭「按项目分组」,都会出现明显卡顿;有时卡死并闪退(ANR 后系统杀进程)。
- v0.1.69 同操作**正常**;v0.1.70 灰度**必现卡顿、间歇闪退** → 回归窗口 `2310a13..5a595a25`(v0.1.69..v0.1.70-beta)。
- 「对话归为一组」关闭时切换不卡。
- 复现设备:小米10青春版(M2007J1SC,骁龙 765G)。
## 三层裁决(当前结论)
| 层次 | 结论 | 确定性 |
|---|---|---|
| 触发场景 | 分组切换导致 grouped/mixed 行 key 全变,SectionList 大量重挂载;项目/对话 folder 子行内联渲染、不吃虚拟化 | 源码证实 |
| 结构放大器 | #3060 引入的高成本「分组 + Swipeable 行」结构(0.1.69 已包含,当时可承受) | 源码证实 |
| **版本回归主嫌疑** | #3500 的 Expo/RN/Reanimated/RNGH 原生栈替换;分组渲染 JS 路径同期零改动 | 时间窗 + diff 支持,待 E1 验证 |
| **独立缺陷** | #3590 的 mutation epoch 失效后立即无限 rerun(无退避、无上限) | 代码证实缺陷存在;对本症状贡献未测量 |
因果模型:
```
#3060 高成本重挂载结构(既有)
+ #3500 原生运行时栈变化(同期唯一渲染路径变更)
+ #3590 可能的后台全量重拉压力(同期新增)
↓
分组切换时 JS/UI 线程压力超过中低端 Android 承受能力 → 卡顿 / ANR / 闪退
```
## 故障链细节
### 触发与结构放大(#3060)
- `buildHomeSections` 切换 grouped/mixed 时行 key 全变(`apps/mobile/src/session/homeSections.ts:108` vs `:164`),`keyExtractor` 用 `item.key`(`apps/mobile/app/devices/index.tsx:2446`)→ 整列表全量重挂载,memo 无法拦截。
- folder 子行内联 map 渲染、不走虚拟化(`index.tsx:3336`);`collapsedProjectKeys` 默认空 = folder 全部默认展开(`:424`);`PROJECT_PREVIEW_LIMIT = 5`(`:224`)且有 24h 活跃豁免,实际每组可远超 5 条。
- 每行 `SwipeableSessionRow` = RNGH `ReanimatedSwipeable`(约 9 个 sharedValue + 2 层 GestureDetector);代码注释自述单行挂载 ~13ms、105 cell 一轮 ~500ms(`index.tsx:3515-3516`)。
- 「对话归为一组」开启时所有项目 folder 挤进首屏,内联手势子行全部落在可见区;关闭时对话展平为顶层行把 folder 挤出屏外,虚拟化保护了挂载量——这解释了为什么只有开启归组才卡。
### 回归主嫌疑:#3500 原生栈替换(待验证)
`feat(mobile): iOS 外壳改用系统导航栏、菜单与选项 Sheet (#3500)`(`f46baf515`)为接入 `@expo/ui` 将 mobile 升到 Expo SDK 57,lockfile 证实为窗口内唯一依赖变更:
| 依赖 | v0.1.69 | v0.1.70 |
|---|---|---|
| expo | ~56.0.12 | ~57.0.17 |
| react-native | 0.85.3 | 0.86.3 |
| react-native-gesture-handler | 3.0.2 | 2.32.0 |
| react-native-reanimated | 4.3.1 | 4.5.1 |
| react-native-screens | 4.25.2 | 4.26.2 |
窗口内 `app/devices/index.tsx` 仅 #3500(菜单接线,Android 走自绘分支)与 #3590(同步逻辑)触碰;**分组渲染 JS 路径两版本逐字相同**。证据边界:静态源码无法把责任单独钉死在 RNGH 2.32.0,RN 0.86.3 / Reanimated 4.5.1 或三者组合同在嫌疑名单。
### 独立缺陷:#3590 无退避 rerun 环路
`perf(mobile): 隔离 Device Link Peer 恢复与首页同步 (#3590)`(`d933455d6`):
1. 拉取前记录 mutation epoch(`index.tsx:647`);
2. 拉取完成后校验(`:678`),期间任何推送落进来即判过期,返回 `needsRerun: true`(`:687`);
3. **立即、无退避、无上限地重新全量拉取**(`:795`)。
epoch bump 点:`sessions:created` / `sessions:patched` / `usage:session-tokens-changed` / `usage:session-spend-changed`(`apps/mobile/src/session/remoteSessionStore.ts:3714/3728/3887/3892`)。其中 `sessions:patched` 来自桌面侧每条可见消息落库时的 preview 即时广播(`apps/desktop/src/main/localDb/ipc/messages.ts:155-183`),流式回合期间持续发生 → 桌面有活跃回合时,手机端可能进入「拉全量列表 → 过期 → 立即重拉」的持续循环,与分组切换的重挂载叠加压满 JS 线程。
证据边界:活锁烈度取决于推送频率与 RPC 往返时长的比值,对本症状的实际贡献未测量;但该缺陷独立成立,无论本 issue 最终归因如何都应修复。
## 取证限制
- 手机端没有落盘日志(`apps/mobile/src/debug/jsStallWatchdog.ts:6`),JS 停摆探测器为 dev-only;
- 桌面侧不落 per-invoke 日志,`local-db:sessions:list` 调用频率无法从现有日志还原。
## 验证实验(定案路径)
- **E2 变体(验 #3590,建议优先)**:临时禁用 `needsRerun` 自动重调度(只保留一次过期丢弃),重复同样操作,直接回答「没有 rerun 活锁时分组切换是否恢复」;之后补 `local-db:sessions:list` 调用计数确认频率。
- **E1(验 #3500)**:0.1.70 分支把 Android 的 `SwipeableSessionRow` 临时换成无手势 plain View 打诊断包(比回退 RNGH 干净,RNGH 3.0.2 未必兼容 Expo 57;诊断包非最终修复)。症状消失 = 行挂载成本是瓶颈。
## 修复建议
1. **先行(修复即实验)**:修 #3590 的 rerun——指数退避 + 次数上限,或把 preview-only 的 `sessions:patched` 与 `usage:*` 推送豁免出 epoch bump(它们本就走增量 patch 收敛,不该作废全量快照)。发灰度后症状消失则坐实;仍在则转 E1。
2. **若 E1 坐实 #3500**:评估 RNGH 3.0.2 与 Expo 57 兼容性并升回,或针对 RN 0.86 排查行挂载回归。
3. **治本(无论归因)**:消除分组切换的全量重挂载——行 key 跨模式稳定;folder 子行虚拟化或默认折叠;切换分组先关菜单再用 `InteractionManager` 延迟重挂载。
## 相关 PR
- #3060 分组结构引入者(0.1.69 已含,非回归源):https://github.com/makecindy/cindy/pull/3060
- #3500 回归主嫌疑(原生栈替换):https://github.com/makecindy/cindy/pull/3500
- #3590 独立缺陷(rerun 环路):https://github.com/makecindy/cindy/pull/3590
Contributor guide
Research direction
Start with the E2 experiment around needsRerun in apps/mobile/app/devices/index.tsx, then inspect mutation epoch updates in apps/mobile/src/session/remoteSessionStore.ts. Reproduce the grouped-project toggle and measure local-db:sessions:list calls; use E1 with a plain View for SwipeableSessionRow if needed. Done means the cause is separated and the Android toggle no longer stalls, triggers ANR, or repeatedly reruns full loads.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react-native, typescript
- Domain
- mobile, performance
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100