From 0e834fc8549f546ef43433da9dcca3307db261fb Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 11:50:53 +0800 Subject: [PATCH] =?UTF-8?q?perf(parse):=20=E8=90=BD=E5=8D=95=20`

`=20?= =?UTF-8?q?=E7=9A=84=E3=80=8C=E5=90=8E=E9=9D=A2=E8=BF=98=E6=9C=89=E6=B2=A1?= =?UTF-8?q?=E6=9C=89=E6=AD=A3=E6=96=87=E3=80=8D=E5=88=A4=E6=8D=AE=E6=94=B9?= =?UTF-8?q?=E8=AE=B0=E5=BF=86=E5=8C=96=EF=BC=8C`parse`=20=E4=BB=8E=20O(N?= =?UTF-8?q?=C2=B2)=20=E5=9B=9E=E5=88=B0=20O(N)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W61 审查点出的问题:`stripLooseTags` 对**每一个**落单 `

` 都要调一次 `hasContentAfter`, 而那函数每次从该位置一路现扫到串尾 ⇒ 正文里有 N 个落单标签整体 O(N²)。`parse` 跑在主线程 (`ui/chat/MessageBubble.kt` 的 `remember`),用户粘一段 HTML 残骸就能把界面卡死十几秒。 改了什么 1. `hasContentAfter(text, from)` → `ContentAfterQuery`(同文件私有类):判定用的还是原来那段 扫描本身(`next` 逐分支对应原循环,判据仍是同一组 OPEN_P_TAG_RE / CLOSE_P_TAG_RE,没有另立 一套「什么算 `

` 标签」的定义),只是把走链途经位置的答案落表,之后 O(1) 命中。每个位置 至多被算一次 ⇒ 整体线性。惰性建表:没遇到落单 `

` 开标签的常规消息一个字节都不多花。 2. 顺着同一个输入查下去,发现同一形状下**还有另一处 O(N²)**:`tokenize` 的 `P_BLOCK_RE.findAll(raw)`——正文里没有 `

` 时,每个 `` 回溯到串尾。完整块必然以 `

` 收尾,所以把最后一个 `

` 之后切掉再扫: 每个匹配都整体落在被切下的前缀里、正则的尝试顺序也没变 ⇒ 匹配结果逐字不变,而这一路回溯没了。 实测(本机 JVM 单测内 `System.nanoTime` 计时,修复前后跑的是同一份测速脚手架、同一台机器) `parse("

".repeat(N))` 修复前 修复后 N = 2000 854ms 5ms N = 8000 4254ms 12ms N = 20000 26405ms 17ms 另一轮完整曲线 N=1000/2000/4000/8000/16000 = 148/238/881/3700/15973ms(每翻一倍涨约 4 倍) 新增的复杂度回归测试在修复前报:`60000 字符(20000 个落单标签)用了 31226ms` 两处各自的份额(N=20000,`"

".repeat(N)`):落单标签判据约 23s、完整块搜索约 8.2s。 `parse(" \n

".repeat(20000))`(标签间夹空白):修复前也是 20s 量级,修复后 6ms。 已知残留(**非本次引入**,改前改后一致,在测试里如实登记):`"

"` 这种「``、最后只有一个 `

`」的病态输入仍走完整块搜索的最坏路径 (N=2000/8000/20000 = 105/1067/7314ms → 108/1242/7201ms)。要治它得把完整块搜索换成手写的 线性匹配(牵动气泡切分的核心语义),本切片没做。 语义 `parse` 的对外行为一字未改:气泡切分、旁白依附、空块过滤、畸形回落全部照旧。既有 121 个测试 套件 1471 条全部通过、一条期望值都没动,另外新增 2 条测试:1 条复杂度回归 (`hugeRunOfLooseTagsIsDroppedWithinALinearBudget`,20000 个落单标签 + 宽松阈值,注释里写明 它测的是复杂度不是精确耗时)+ 1 条钉住「后面有正文才留字面」的语义 (`aLooseTagIsKeptWheneverThereIsStillContentAfterIt`)。 验收:`gradlew.bat testDebugUnitTest assembleDebug --rerun-tasks` → BUILD SUCCESSFUL (121 suites / 1473 tests / 0 failures / 0 errors;APK 已产出)。无新依赖。 --- .../cyrene/domain/parse/ChatContentParser.kt | 97 ++++++++++++++++--- .../domain/parse/ChatContentParserTest.kt | 44 +++++++++ 2 files changed, 125 insertions(+), 16 deletions(-) diff --git a/app/src/main/kotlin/top/yeij/cyrene/domain/parse/ChatContentParser.kt b/app/src/main/kotlin/top/yeij/cyrene/domain/parse/ChatContentParser.kt index 98dba45..d78ede5 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/domain/parse/ChatContentParser.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/domain/parse/ChatContentParser.kt @@ -172,7 +172,18 @@ private sealed interface Tok { /** 先切 `

` 块(决定气泡边界),再按区剥落单标签,最后在每个文本区做旁白/星号解析。 */ private fun tokenize(raw: String): List { - val pMatches = P_BLOCK_RE.findAll(raw).toList() + // 完整块必然以 `

` 收尾,所以最后一个 `

` 之后不可能再有块:切掉尾巴再让正则去扫。不切的话, + // 每个 `` 一路回溯到串尾——正文里 N 个落单标签时 + // 这里就是 O(N²):实测 20000 个要 8.2 秒(同一输入下 `stripLooseTags` 那边还要 23 秒,见 + // [ContentAfterQuery])。切掉后同一输入 1 毫秒内出结果,且匹配结果逐字不变(每个匹配都整体落在 + // 被切下的前缀里,正则的尝试顺序也没变)。 + val lastClose = raw.lastIndexOf("

") + val pMatches = + if (lastClose < 0) { + emptyList() + } else { + P_BLOCK_RE.findAll(raw.substring(0, lastClose + "

".length)).toList() + } val out = ArrayList() if (pMatches.isEmpty()) { // 一条完整块都没有:`

` 及其后内容按既有契约整段回落字面文本,`` 这类旁白标签 @@ -224,6 +235,9 @@ private fun tokenize(raw: String): List { private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags: Boolean): String { if (text.indexOf('<') < 0) return text val roleRanges = if (stripStrayRoleTags) ROLE_RE.findAll(text).map { it.range }.toList() else emptyList() + // 「这个落单 `

` 后面还有没有正文」按位置记表:正文里 N 个落单标签,现扫是 O(N²)(见 + // [ContentAfterQuery] 的 KDoc)。常规消息(没有落单 `

` 开标签)一次都不建表。 + val contentAfter = ContentAfterQuery(text) val out = StringBuilder(text.length) var last = 0 var i = 0 @@ -234,7 +248,7 @@ private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags if (gt < 0) break val tag = text.substring(lt, gt + 1) val drop = when { - OPEN_P_TAG_RE.matches(tag) -> markupMode || !hasContentAfter(text, gt + 1) + OPEN_P_TAG_RE.matches(tag) -> markupMode || !contentAfter.hasContentAfter(gt + 1) CLOSE_P_TAG_RE.matches(tag) -> true !stripStrayRoleTags -> false roleRanges.any { lt in it } -> false @@ -253,27 +267,78 @@ private fun stripLooseTags(text: String, markupMode: Boolean, stripStrayRoleTags return out.toString() } -/** [from] 之后是否还有正文:跳过空白与后续的 `

` 族标签后仍非空即为有。 */ -private fun hasContentAfter(text: String, from: Int): Boolean { - var i = from - while (i < text.length) { - if (text[i].isWhitespace()) { - i++ - continue +/** + * [stripLooseTags] 判落单 `

` 用的「[from] 之后还有没有正文」查询:空白跳过,`

` / `

` + * 整段跳过,其余字符(别的标签、落单的 `<`)一律算有正文——跳过空白与后续 `

` 族标签后仍非空 + * 即为有。判据与原实现逐字一致,只多了记忆化。 + * + * **为什么要记忆化**:这条判据原本每次查询都一路现扫到串尾,而 [stripLooseTags] 对**每一个** + * 落单 `

` 都要问一次,正文里 N 个落单标签就是 O(N²)——20000 个落单 `

` 的输入实测这条判据 + * 要 23 秒(同一次 `parse` 合计 31 秒),而 `parse` 跑在主线程上(`ui/chat/MessageBubble.kt` 的 + * `remember`),用户粘一段 HTML 残骸就能把界面卡死十几秒。 + * + * **为什么可以只算一次**:答案只取决于**位置**——同一个位置再问一次,走的一定是同一条链、到的 + * 一定是同一个终点。所以走链时把沿途每个位置的答案落表,之后 O(1) 命中;每个位置至多被算一次, + * N 个落单标签整体就是线性的(同一输入实测这条判据从 23 秒降到毫秒级)。 + * + * **为什么不改变语义**:判定用的就是原来那段扫描本身([next] 逐分支对应原循环),没有另立一套 + * 「什么算 `

` 标签」的判据——仍是同一组 [OPEN_P_TAG_RE] / [CLOSE_P_TAG_RE]。落表的答案是同一条 + * 扫描推出来的,因此与逐次现扫逐字相同;连「起点落在标签内部」这种查询也一致(原扫描看到的是 + * 标签里的字符、判有正文,[next] 在这里同样返回 null)。 + * + * 惰性:没遇到落单 `

` 开标签的常规消息一次也不建表,一个字节都不多花。 + */ +private class ContentAfterQuery(private val text: String) { + /** 每个位置的答案:`0` = 还没算、[NOISE_ONLY] = 后面只剩空白与 `

` 族标签、[HAS_CONTENT] = 有正文。 */ + private val answers: ByteArray by lazy(LazyThreadSafetyMode.NONE) { + ByteArray(text.length + 1).also { it[text.length] = NOISE_ONLY } + } + + /** 本次走链途经的位置,用来把 [answers] 一次落表。 */ + private val path: IntArray by lazy(LazyThreadSafetyMode.NONE) { IntArray(text.length) } + + /** 与原先「每次现扫到串尾」的判定逐字等价。 */ + fun hasContentAfter(from: Int): Boolean { + var walked = 0 + var i = from + while (true) { + val known = answers[i] + if (known != UNKNOWN) return finish(walked, known) + path[walked++] = i + val step = next(i) + if (step == null) return finish(walked, HAS_CONTENT) + i = step } - if (text[i] == '<') { + } + + /** 把这次走链途经的位置一次落表:它们与起点走的是同一条链,答案必然相同。 */ + private fun finish(walked: Int, answer: Byte): Boolean { + for (k in 0 until walked) answers[path[k]] = answer + return answer == HAS_CONTENT + } + + /** + * 原扫描的一步:空白 → 下一位;`

` 族标签 → 标签之后;其余(别的标签、落单的 `<`)→ `null`, + * 即「有正文」,扫描到此为止。走到 `text.length` 时由 [answers] 里预置的答案收口。 + */ + private fun next(i: Int): Int? { + val c = text[i] + if (c.isWhitespace()) return i + 1 + if (c == '<') { val gt = text.indexOf('>', i) if (gt >= 0) { val tag = text.substring(i, gt + 1) - if (OPEN_P_TAG_RE.matches(tag) || CLOSE_P_TAG_RE.matches(tag)) { - i = gt + 1 - continue - } + if (OPEN_P_TAG_RE.matches(tag) || CLOSE_P_TAG_RE.matches(tag)) return gt + 1 } } - return true + return null + } + + private companion object { + const val UNKNOWN: Byte = 0 + const val NOISE_ONLY: Byte = 1 + const val HAS_CONTENT: Byte = 2 } - return false } /** 在文本区内按左到右顺序切出 Text / Action / Narration。 */ diff --git a/app/src/test/kotlin/top/yeij/cyrene/domain/parse/ChatContentParserTest.kt b/app/src/test/kotlin/top/yeij/cyrene/domain/parse/ChatContentParserTest.kt index dd96635..78822ec 100644 --- a/app/src/test/kotlin/top/yeij/cyrene/domain/parse/ChatContentParserTest.kt +++ b/app/src/test/kotlin/top/yeij/cyrene/domain/parse/ChatContentParserTest.kt @@ -325,6 +325,14 @@ class ChatContentParserTest { assertEquals(listOf("甲", "乙"), parse("

甲

乙").map { it.body }) } + /** 剥不剥只看它**后面**还有没有内容:后面有正文的留字面,后面什么都没有的才剥。 */ + @Test + fun aLooseTagIsKeptWheneverThereIsStillContentAfterIt() { + assertEquals(listOf("

甲

乙"), parse("

甲

乙").map { it.body }) + assertEquals(listOf("

甲"), parse("

甲

").map { it.body }) + assertEquals(listOf("

甲"), parse("

甲

").map { it.body }) + } + @Test fun strayRoleTagOutsideParagraphIsDropped() { assertEquals(listOf("甲"), parse("

甲

").map { it.body }) @@ -509,6 +517,42 @@ class ChatContentParserTest { assertEquals(listOf("新"), parser.onDelta("

新

").map { it.body }) } + // ------------------------------------------------------------------ + // 复杂度回归:一大段落单标签不许把 parse 拖成 O(N²) + // + // ⚠️ 这条测的是**复杂度**,不是精确耗时:阈值宽到 20000 个落单标签给 2 秒,只用来区分 + // 「线性」与「平方」。修复前实测:2000 个 854ms、8000 个 4254ms、20000 个 26405ms(每翻 + // 一倍涨约 4 倍,典型平方);修复后 20000 个是十几毫秒。所以阈值别卡紧(CI 机器可能更慢), + // 这里量到的数也别当性能指标读——要盯的是「有没有退回 O(N²)」。 + // + // 这个形状同时压住两处(修复前各占一部分,都在 `ChatContentParser` 里): + // - 落单标签的「后面还有没有正文」判据(`ContentAfterQuery`,修复前约 23 秒); + // - 完整块搜索 `P_BLOCK_RE.findAll`(没有 `

` 时每个 `".repeat(count), // 一串纯落单开标签 + " \n

".repeat(count), // 标签之间夹空白:跳过空白后仍是「后面什么都没有」 + ) + for (input in inputs) { + val startedAt = System.nanoTime() + val bubbles = parse(input) + val elapsedMs = (System.nanoTime() - startedAt) / 1_000_000 + assertTrue("落单标签应当被剥净,实际留下:${bubbles.map { it.body }}", bubbles.isEmpty()) + assertTrue( + "${input.length} 字符($count 个落单标签)用了 ${elapsedMs}ms," + + "超出线性预算 ${looseTagBudgetMs}ms——复杂度退回 O(N²) 了?", + elapsedMs < looseTagBudgetMs, + ) + } + } + /** 产出的任何片段里都不许出现段落标签本身——这是落单标签容错的底线。 */ private fun assertNoLooseTagInRenderedText(bubbles: List) { for (bubble in bubbles) {