fix(chat): 分享面板展开态也会崩 —— 只去掉上限是不够的,必须连 verticalScroll 一起去掉

补上一个修复的漏:上一笔只把展开态的 heightIn 上限去掉,没去掉 verticalScroll。而外层
ShareConfirmPreviews 自己就是一个 verticalScroll,它给内容的是**无界高度** ⇒ 这一层的 scroll
在无界高度下被测量 ⇒ 抛同一个 IllegalStateException。真机上点「展开」的那一下崩了第二次
(pid 2077,01:48:53,reason=4 APP CRASH)。

改:展开态把这一层**整个**去掉(上限 + 滚动一起),只留外层预览区那一层滚 —— 这也正是 W56
原本的设计意图('展开态只留外层那一层')。shareConfirmWindowCap 去掉了 expanded 维度(展开态
不再走它),测试同步改写并补了一条「两种状态在链上都有有限约束」。

真机验收(emulator-5554 + 真后端):收起 153px(3 行)+『展开』→ 点开展开 255px +『收起』→
点回收起 153px,**三次操作同一个 pid 4063,没有任何新崩溃记录**。
This commit is contained in:
AskaEth committed 2026-10-07 09:51:44 +08:00
1 parent 6d4f2cdbae
commit df7b504fa2
2 files changed
+57 -48

No files matched your search

@@ -435,7 +435,13 @@ private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) {
* ## 两层滚动怎么分工
* 收起态:这一窗自己滚(正文读完),外层 [ShareConfirmPreviews] 也滚(图片 + 文件芯片 + 这一块
* 合起来的高度上限)。手指停在正文上时先滚正文、滚到底再把余量交给外层——`verticalScroll` 本来
* 就走嵌套滚动,不用额外接线。展开态下这一窗不设上限,手势整个交给外层。
* 就走嵌套滚动,不用额外接线。
*
* ⚠️ **展开态必须把这一层整个去掉(上限 + 滚动一起),只留外层那一层滚。** 只去掉上限是不够的 ——
* 外层 [ShareConfirmPreviews] 自己就是一个 `verticalScroll`,它给内容的是**无界高度**;这一层若
* 留着 `verticalScroll`,那个 scroll 就会在无界高度下被测量 ⇒ 抛同一个
* `IllegalStateException`(W58 真机在「点展开」的那一下崩了第二次,就是这么来的)。
* 展开后读不读得到尾,只取决于外层那一层。
*/
@Composable
private fun ShareConfirmBody(
@@ -446,15 +452,19 @@ private fun ShareConfirmBody(
val density = LocalDensity.current
// `null` = 还没量到。**不要**拿 [Dp.Infinity] 当这个哨兵值(见 KDoc 里那次崩溃)。
var cap by remember(text) { mutableStateOf<Dp?>(null) }
// 无条件 `remember`,避免在分支里建状态;滚动位置由这一窗自己的生命周期管。
val scroll = rememberScrollState()
Column(
modifier = Modifier
.fillMaxWidth()
.then(
shareConfirmWindowCap(cap, expanded)?.let { Modifier.heightIn(max = it) } ?: Modifier,
)
.verticalScroll(rememberScrollState()),
) {
val window = if (expanded) {
// 展开态:上限与滚动**一起**去掉(见 KDoc)。内容自然撑开,交给外层预览区那一层滚。
Modifier
} else {
Modifier
.heightIn(max = shareConfirmWindowCap(cap))
.verticalScroll(scroll)
}
Column(modifier = Modifier.fillMaxWidth().then(window)) {
Text(
text = text,
style = MiuixTheme.textStyles.footnote1,
@@ -464,7 +474,7 @@ private fun ShareConfirmBody(
val lastVisible = layout.lineCount.coerceAtMost(SHARE_PREVIEW_TEXT_LINES) - 1
cap = with(density) { layout.getLineBottom(lastVisible).toDp() }
}
// 行数与上限无关(这一窗没有 `maxLines`、测量高度不受限,排一次行数就定了),所以
// 行数与上限无关(这一窗没有 `maxLines`、收起态的高度不受限,排一次行数就定了),所以
// 这一句同时管着「收不起时要不要给出展开控件」与「展开态下要不要留着它」。
onOverflowChange(sharePreviewTextOverflowsWindow(layout.lineCount))
},
@@ -473,18 +483,19 @@ private fun ShareConfirmBody(
}
/**
* 正文那一窗该套多大的高度上限(**纯函数**,可单测):`null` = 这一层**不设**上限。
* **收起态**那一窗的高度上限(**纯函数**,可单测)。
*
* 三档:展开态不设(外层已有有限上限);量到过就用量的;还没量到就用 [SHARE_PREVIEW_MAX_HEIGHT] 兜底。
* 量到过就用量的,还没量到就用 [SHARE_PREVIEW_MAX_HEIGHT] 兜底 —— 兜底必须是**有限值**,因为
* `heightIn` 与 `verticalScroll` 就在同一串 modifier 上。
*
* ⚠️ **返回值要么是 `null`、要么是有限值,永远不是 [Dp.Infinity]** —— 后者会让 `verticalScroll`
* 在无界高度下被测量并抛 `IllegalStateException`(W58 真机崩过)。这条不变量有单测钉着。
* 展开态**不走这里**:那一态连 `verticalScroll` 一起去掉(只留外层预览区那一层滚),没有「上限」
* 这个维度可给 —— 见 [ShareConfirmBody] 的 KDoc。
*
* ⚠️ 返回值**永远不是 [Dp.Infinity]** —— `heightIn(max = Dp.Infinity)` 等于不施加约束,而
* `verticalScroll` 在无界高度下被测量时 Compose 会直接抛 `IllegalStateException` 把 App 打崩
* (W58 真机崩过两次:一次是初值用了 `Dp.Infinity`,一次是展开态只去掉了上限)。这条不变量有单测钉着。
*/
internal fun shareConfirmWindowCap(measured: Dp?, expanded: Boolean): Dp? = when {
expanded -> null
measured != null -> measured
else -> SHARE_PREVIEW_MAX_HEIGHT
}
internal fun shareConfirmWindowCap(measured: Dp?): Dp = measured ?: SHARE_PREVIEW_MAX_HEIGHT
/** 预览区在**收起态**的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */
internal val SHARE_PREVIEW_MAX_HEIGHT = 150.dp
@@ -321,49 +321,47 @@ class ShareConfirmTest {
/**
* W58:正文那一窗的高度上限**永远不许是 [androidx.compose.ui.unit.Dp.Infinity]**。
*
* 背景是真机上的一次崩溃:`heightIn(max = Dp.Infinity)` 等于**不施加约束**,而 `verticalScroll`
* 一旦在无界高度下被测量,Compose 的 `checkScrollableContainerConstraints` 会直接抛
* `IllegalStateException` 把 App 打崩(分享面板一打开就闪退,栈是 `ScrollNode.measure` ←
* `SizeNode.measure` ← `DialogLayout.internalOnMeasure`)。
* 背景是真机上**两次**崩溃、同一个异常:`heightIn(max = Dp.Infinity)` 等于**不施加约束**,而
* `verticalScroll` 一旦在无界高度下被测量,Compose 的 `checkScrollableContainerConstraints` 会
* 直接抛 `IllegalStateException` 把 App 打崩。
*
* 这条不变量只能靠纯函数钉住:本模块的 `testImplementation` 只有 junit,没有 Compose UI 测试依赖,
* 渲染层的崩溃单测抓不到。
* 两次踩在不同的地方,都值得钉住:
* 1. **初值**用了 `Dp.Infinity` 当「还没量到」的哨兵值(分享面板一打开就闪退);
* 2. **展开态**只去掉了上限、把 `verticalScroll` 留着 —— 外层预览区自己就是一个 `verticalScroll`,
* 它给内容的是**无界高度**,于是这一层的 scroll 在无界高度下被测量(点「展开」的那一下闪退)。
*
* 结论:这一层的高度上限只经 [shareConfirmWindowCap] 一个出口(永远有限),且展开态**连滚动一起去掉**。
*
* 这两条都只能靠纯函数钉住:本模块的 `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())
val fallback = shareConfirmWindowCap(measured = null)
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))
assertEquals(42.dp, shareConfirmWindowCap(measured = 42.dp))
}
@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(),
)
}
fun `no input can produce a non finite cap`() {
for (m in listOf(null, 0.dp, 1.dp, 150.dp, 10_000.dp)) {
val cap = shareConfirmWindowCap(m)
assertTrue("measured=$m 给出了非有限的上限:$cap", cap.value.isFinite())
}
}
@Test
fun `both preview states keep a finite height bound somewhere in the chain`() {
// 展开态这一层「连滚动一起去掉」了,但**外层预览区**仍是一层有限约束在管事 ——
// 这正是展开后不会掉进同一个「无界高度」崩溃的原因。
assertTrue(sharePreviewMaxHeight(expanded = true).value.isFinite())
assertTrue(sharePreviewMaxHeight(expanded = false).value.isFinite())
}
}