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:
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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user