From 6d4f2cdbaec59c842f85fac3364f4777ba464633 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 09:45:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E5=88=86=E4=BA=AB=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E9=9D=A2=E6=9D=BF=E4=B8=80=E6=89=93=E5=BC=80=E5=B0=B1?= =?UTF-8?q?=E5=B4=A9=20=E2=80=94=E2=80=94=20=E6=AD=A3=E6=96=87=E9=82=A3?= =?UTF-8?q?=E4=B8=80=E5=B1=82=E7=BB=9D=E4=B8=8D=E8=83=BD=E5=86=8D=E6=8B=BF?= =?UTF-8?q?=E5=88=B0=20Dp.Infinity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机崩溃栈:heightIn(max = Dp.Infinity) 等于不施加约束,verticalScroll 在无界高度下被测量时 checkScrollableContainerConstraints 直接抛 IllegalStateException(ScrollNode.measure ← SizeNode.measure ← DialogLayout.internalOnMeasure),分享进来、面板刚打开就闪退。 改法:这一层的高度上限只经纯函数 shareConfirmWindowCap() 一个出口 —— 首帧用 SHARE_PREVIEW_MAX_HEIGHT(150dp) 兜底(有限值),展开态这一层不设上限(外层 240dp 仍是有限约束), 并且不再用 Dp.Infinity 当「还没量到」的哨兵值(改用 null)。新增 4 条单测钉住 「任何输入组合都不会产出非有限上限」。 --- .../yeij/cyrene/ui/chat/ShareConfirmPanel.kt | 58 +++++++++++++------ .../yeij/cyrene/ui/chat/ShareConfirmTest.kt | 50 ++++++++++++++++ 2 files changed, 91 insertions(+), 17 deletions(-) diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt index f831465..7988037 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmPanel.kt @@ -394,11 +394,22 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) { * 这个上限悄悄放宽。而三行有多高没法提前写出来——Miuix 的 `footnote1` 只给了 13sp 的字号, * `lineHeight` 是 `Unspecified`,行高由系统字体的 ascent/descent 决定、随「大字体」设置一起缩放。 * 一个 dp 常量在默认字号档上勉强对得上,到了大字号档就会矮一大截(行数不足三行、末行被切)。 - * 所以这里改为**量**:先按没有上限排一次,从 `TextLayoutResult` 上取第三行的底边当上限。 + * 所以这里改为**量**:从 `TextLayoutResult` 上取第三行的底边当上限。 * - * 上限只写一次(`cap` 还是 [Dp.Infinity] 时才改),所以不会来回跳:拿到上限后重新排版,正文的宽度 + * ## ⚠️ 这一层绝不能拿到 [Dp.Infinity](W58 修的崩溃) + * `heightIn(max = Dp.Infinity)` 等于**不施加约束**,而 `verticalScroll` 一旦在**无界高度**下被测量, + * Compose 的 `checkScrollableContainerConstraints` 会**直接抛 `IllegalStateException` 把 App 打崩** + * (真机复现:分享进来、面板刚打开就闪退,栈是 `ScrollNode.measure` ← `SizeNode.measure` ← `DialogLayout`)。 + * + * 所以这一层的高度上限**只经 [shareConfirmWindowCap] 一个出口**,它保证返回值要么是 `null` + * (这一层不设上限)、要么是**有限值**,永远不是 `Dp.Infinity`: + * - **首帧**(还没量到)用 [SHARE_PREVIEW_MAX_HEIGHT] 兜底 —— 有限,且正好是收起态外层预览区的上限; + * - **展开态**整层不设上限,但**外层**预览区此时换成 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],那仍是一层 + * 有限的约束在管事(不是「没有约束」)。 + * + * 上限只写一次(`cap` 还是 `null` 时才改),所以不会来回跳:拿到上限后重新排版,正文的宽度 * 与样式都没变,行位置与首帧一致,量出来的值也一样。展开 / 收起切换**也不会改写它**——`cap` 记的 - * 恒是收起态那一窗的高度(这一窗的折行只由宽度决定,高度不受限,两种状态下排出来的行位置一样)。 + * 恒是收起态那一窗的高度(这一窗的折行只由宽度决定,两种状态下排出来的行位置一样)。 * * ## 展开态为什么干脆不留这一层上限 * 展开之后正文可能几屏长。若在这一窗上再设一个上限,就成了「窗外滚 + 窗内滚」两层都要滚的手势 @@ -407,16 +418,15 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) { * [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],整段正文摊在里面,一层滚到底,读不读得到尾只取决于那一层。 * 代价是展开后不能「只滚正文、图片与文件钉着不动」——那要把两层滚动接起来,这个面板不值这个复杂度。 * - * ## 首帧的那次回流:留着,且确认它看不见(W56 复核) - * 上限是**首帧之后**才量到的,所以长正文会先按没有上限排一次,下一帧才收到三行。上一批留了个悬念: - * 真机上会不会看见一次跳动。复核的结论是**看不见**,两条理由: + * ## 首帧的那次回流:留着,且确认它看不见 + * 上限是**首帧之后**才量到的,所以长正文会先按 fallback([SHARE_PREVIEW_MAX_HEIGHT])排一次, + * 下一帧才收到三行。真机上**看不见**跳动,两条理由: * 1. 首帧那一下撑不动任何别的东西——正文块是预览区里的**最后一个**元素(图片、文件都在它上面), * 而预览区自己还有一层独立的上限在管可视高度:下面没有东西被它顶下去,上面那两样的位置一分 * 不动,可视的像素与量完之后**一样**(那些像素由 `heightIn` 的裁剪决定,裁剪高度不变)。 - * 2. 加展开态**不会把它变明显**:展开态本来就没有这一层上限(见上),也就没有「量完再回落」这一步; - * 而展开 / 收起是用户主动的一次布局变化,那一帧的尺寸变化是它的**目的**,不是回流。真正多出来的 - * 是展开控件本身——它要到第二帧才出现(行数是排完才知道的),但这一枚也顶不动东西:标签行里那 - * 两句话用的是同一个 `footnote2`,行高不变。 + * 2. 展开态本来就没有这一层上限,不存在「量完再回落」;而展开 / 收起是用户主动的一次布局变化, + * 那一帧的尺寸变化是它的**目的**,不是回流。真正多出来的是展开控件本身——它要到第二帧才出现 + * (行数是排完才知道的),但这一枚也顶不动东西:标签行里那两句话用的是同一个 `footnote2`,行高不变。 * * 另一条路是在组合期用 `rememberTextMeasurer` 把上限量出来,好让控件从首帧就摆上:那要先知道宽度 * (得外包一层 `BoxWithConstraints`),还要自己复现字号、字体缩放与字体回退,等于把排版做两遍—— @@ -425,8 +435,7 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) { * ## 两层滚动怎么分工 * 收起态:这一窗自己滚(正文读完),外层 [ShareConfirmPreviews] 也滚(图片 + 文件芯片 + 这一块 * 合起来的高度上限)。手指停在正文上时先滚正文、滚到底再把余量交给外层——`verticalScroll` 本来 - * 就走嵌套滚动,不用额外接线。展开态下这一窗没有可滚的余量(`heightIn` 给了 [Dp.Infinity]), - * 手势整个交给外层。 + * 就走嵌套滚动,不用额外接线。展开态下这一窗不设上限,手势整个交给外层。 */ @Composable private fun ShareConfirmBody( @@ -435,21 +444,22 @@ private fun ShareConfirmBody( onOverflowChange: (Boolean) -> Unit, ) { val density = LocalDensity.current - var cap by remember(text) { mutableStateOf(Dp.Infinity) } + // `null` = 还没量到。**不要**拿 [Dp.Infinity] 当这个哨兵值(见 KDoc 里那次崩溃)。 + var cap by remember(text) { mutableStateOf(null) } Column( modifier = Modifier .fillMaxWidth() - // 展开态给 [Dp.Infinity] = 这一层没有上限;这正是**首帧那个状态**(`cap` 一开始就是它), - // 所以展开态不多一条代码路径,也不会比首帧更糟。 - .heightIn(max = if (expanded) Dp.Infinity else cap) + .then( + shareConfirmWindowCap(cap, expanded)?.let { Modifier.heightIn(max = it) } ?: Modifier, + ) .verticalScroll(rememberScrollState()), ) { Text( text = text, style = MiuixTheme.textStyles.footnote1, onTextLayout = { layout -> - if (cap == Dp.Infinity && layout.lineCount > 0) { + if (cap == null && layout.lineCount > 0) { // 正文不到三行时上限就取全文的高度(`heightIn` 是个上限,短了本来也不生效)。 val lastVisible = layout.lineCount.coerceAtMost(SHARE_PREVIEW_TEXT_LINES) - 1 cap = with(density) { layout.getLineBottom(lastVisible).toDp() } @@ -462,6 +472,20 @@ private fun ShareConfirmBody( } } +/** + * 正文那一窗该套多大的高度上限(**纯函数**,可单测):`null` = 这一层**不设**上限。 + * + * 三档:展开态不设(外层已有有限上限);量到过就用量的;还没量到就用 [SHARE_PREVIEW_MAX_HEIGHT] 兜底。 + * + * ⚠️ **返回值要么是 `null`、要么是有限值,永远不是 [Dp.Infinity]** —— 后者会让 `verticalScroll` + * 在无界高度下被测量并抛 `IllegalStateException`(W58 真机崩过)。这条不变量有单测钉着。 + */ +internal fun shareConfirmWindowCap(measured: Dp?, expanded: Boolean): Dp? = when { + expanded -> null + measured != null -> measured + else -> SHARE_PREVIEW_MAX_HEIGHT +} + /** 预览区在**收起态**的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */ internal val SHARE_PREVIEW_MAX_HEIGHT = 150.dp diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt index a331c19..746b560 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/ShareConfirmTest.kt @@ -317,3 +317,53 @@ class ShareConfirmTest { assertTrue(SHARE_PREVIEW_COLLAPSE_LABEL.isNotBlank()) } } + +/** + * W58:正文那一窗的高度上限**永远不许是 [androidx.compose.ui.unit.Dp.Infinity]**。 + * + * 背景是真机上的一次崩溃:`heightIn(max = Dp.Infinity)` 等于**不施加约束**,而 `verticalScroll` + * 一旦在无界高度下被测量,Compose 的 `checkScrollableContainerConstraints` 会直接抛 + * `IllegalStateException` 把 App 打崩(分享面板一打开就闪退,栈是 `ScrollNode.measure` ← + * `SizeNode.measure` ← `DialogLayout.internalOnMeasure`)。 + * + * 这条不变量只能靠纯函数钉住:本模块的 `testImplementation` 只有 junit,没有 Compose UI 测试依赖, + * 渲染层的崩溃单测抓不到。 + */ +class ShareConfirmWindowCapTest { + + @Test + fun `the fallback cap is finite and never infinity`() { + val fallback = shareConfirmWindowCap(measured = null, expanded = false) + org.junit.Assert.assertNotNull("首帧必须给出一个上限,不能是 null", fallback) + assertTrue("首帧的兜底上限必须是有限值", fallback!!.value.isFinite()) + assertEquals(SHARE_PREVIEW_MAX_HEIGHT, fallback) + } + + @Test + fun `the expanded state leaves this layer unbounded instead of passing infinity`() { + // 展开态是「这一层不设上限」(null),**不是**「给一个无穷大的上限」——后者正是崩溃的成因。 + org.junit.Assert.assertNull(shareConfirmWindowCap(measured = null, expanded = true)) + org.junit.Assert.assertNull(shareConfirmWindowCap(measured = 42.dp, expanded = true)) + // 而且外层仍然是一层**有限**约束在管事。 + assertTrue(sharePreviewMaxHeight(expanded = true).value.isFinite()) + } + + @Test + fun `a measured cap is passed through`() { + assertEquals(42.dp, shareConfirmWindowCap(measured = 42.dp, expanded = false)) + } + + @Test + fun `no input combination can produce a non finite cap`() { + val measured = listOf(null, 0.dp, 1.dp, 150.dp, 10_000.dp) + for (m in measured) { + for (expanded in listOf(false, true)) { + val cap = shareConfirmWindowCap(m, expanded) + assertTrue( + "m=$m expanded=$expanded 给出了非有限的上限:$cap", + cap == null || cap.value.isFinite(), + ) + } + } + } +}