fix(chat): 分享确认面板一打开就崩 —— 正文那一层绝不能再拿到 Dp.Infinity

真机崩溃栈:heightIn(max = Dp.Infinity) 等于不施加约束,verticalScroll 在无界高度下被测量时
checkScrollableContainerConstraints 直接抛 IllegalStateException(ScrollNode.measure ←
SizeNode.measure ← DialogLayout.internalOnMeasure),分享进来、面板刚打开就闪退。

改法:这一层的高度上限只经纯函数 shareConfirmWindowCap() 一个出口 —— 首帧用
SHARE_PREVIEW_MAX_HEIGHT(150dp) 兜底(有限值),展开态这一层不设上限(外层 240dp 仍是有限约束),
并且不再用 Dp.Infinity 当「还没量到」的哨兵值(改用 null)。新增 4 条单测钉住
「任何输入组合都不会产出非有限上限」。
This commit is contained in:
AskaEth committed 2026-10-07 09:45:34 +08:00
1 parent af9c63d267
commit 6d4f2cdbae
2 files changed
+91 -17

No files matched your search

@@ -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<Dp?>(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
@@ -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(),
)
}
}
}
}