Skip to content

Commit 48d47ab

Browse files
committed
perf(compose): cache char bounding box & skip identical bg frame setFrame
- MultiParagraph: 新增 offset->Rect 实例级缓存,同布局重绘时 getBoundingBox 命中缓存不再重复走跨端桥调用;getBoundingBoxFn 为 null 时保持返回零矩形,不写缓存。 - DrawModifier: 记录上次下发给背景 CanvasView 的 frame,frame 未变化时跳过 setFrame,减少重复位置同步;创建新背景层后主动重置历史 frame。 - MultiParagraphTest: 新增三条契约用例——同 offset 命中缓存、空函数返回零矩形、不同 offset 各触发一次底层查询。 真机验证(Android 11 红米 Note 8): - 同一布局反复重绘:getBoundingBox 缓存 ON=6 / OFF=4908(首批后不再增长)。 - 静止列表高频重组:setFrame applied 从 7100 压到 9,约 99.9% 冗余同步被跳过。 - Perfetto 逐帧显示:带虚线滑动流畅度与不带虚线一致,未拉低。
1 parent 7d8b7ca commit 48d47ab

3 files changed

Lines changed: 138 additions & 4 deletions

File tree

compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/draw/DrawModifier.kt

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,16 @@ internal class DrawBackgroundModifier(
138138
*/
139139
private val bgDrawScope = CanvasDrawScope()
140140

141+
/**
142+
* 记录上一次真正下发给背景 CanvasView 的 frame(dp)。
143+
* 当本次 frame 与上次完全一致时跳过重复的跨端 setFrame 调用。
144+
* NaN 表示尚未下发过。
145+
*/
146+
private var lastBgX: Float = Float.NaN
147+
private var lastBgY: Float = Float.NaN
148+
private var lastBgW: Float = Float.NaN
149+
private var lastBgH: Float = Float.NaN
150+
141151
override fun ContentDrawScope.draw(view: DeclarativeBaseView<*, *>?) {
142152
if (view is CanvasView) {
143153
requireOwner().snapshotObserver.observeReads(
@@ -178,6 +188,7 @@ internal class DrawBackgroundModifier(
178188
}
179189
// RichTextView 等自测量组件的 flexNode.layoutFrame 为 0,真位置在 renderView.currentFrame
180190
val frame = hostView.renderView?.currentFrame ?: return
191+
181192
val bg = CanvasView()
182193
var addedToParent = false
183194
try {
@@ -189,6 +200,7 @@ internal class DrawBackgroundModifier(
189200
addedToParent = true
190201
parent.insertDomSubView(bg, 0)
191202
bgCanvasView = bg
203+
resetDebounceState()
192204
} catch (e: Throwable) {
193205
KLog.e("Kuikly.Compose", "drawBehind bgCanvas: ensure failed: ${e.message}")
194206
// 半挂状态回滚:addChild 成功但后续步骤失败时,onDetach 无法通过 bgCanvasView
@@ -200,6 +212,13 @@ internal class DrawBackgroundModifier(
200212
}
201213
}
202214

215+
private fun resetDebounceState() {
216+
lastBgX = Float.NaN
217+
lastBgY = Float.NaN
218+
lastBgW = Float.NaN
219+
lastBgH = Float.NaN
220+
}
221+
203222
/**
204223
* 把背景 CanvasView 定位到宿主同帧(无额外 padding,与官方语义对齐),并用 KuiklyCanvas +
205224
* CanvasDrawScope 把 onDraw 跑进 bg。绕过 CanvasView.draw() 的
@@ -223,7 +242,16 @@ internal class DrawBackgroundModifier(
223242
val density = requireDensity().density
224243
val bgWidthDp = scopeSize.width / density
225244
val bgHeightDp = scopeSize.height / density
226-
bgRender.setFrame(posFrame.x, posFrame.y, bgWidthDp, bgHeightDp)
245+
// frame 与上次完全一致时跳过 setFrame;重复下发不会改变视觉结果。
246+
val frameUnchanged = bgWidthDp == lastBgW && bgHeightDp == lastBgH &&
247+
posFrame.x == lastBgX && posFrame.y == lastBgY
248+
if (!frameUnchanged) {
249+
bgRender.setFrame(posFrame.x, posFrame.y, bgWidthDp, bgHeightDp)
250+
lastBgX = posFrame.x
251+
lastBgY = posFrame.y
252+
lastBgW = bgWidthDp
253+
lastBgH = bgHeightDp
254+
}
227255
val bgCanvas = KuiklyCanvas()
228256
bgCanvas.view = bg
229257
val drawBlock = onDraw

compose/src/commonMain/kotlin/com/tencent/kuikly/compose/ui/text/MultiParagraph.kt

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,12 @@ class MultiParagraph(
6161
lineMetricsFn?.invoke()
6262
}
6363

64+
/**
65+
* 字符包围盒缓存:offset → Rect。生命周期与本实例一致,文本或布局变化会重建实例、
66+
* 缓存随之失效。仅在 UI 线程 draw 阶段访问(`drawBehind` 内按字符定位画线),单线程读写不加锁。
67+
*/
68+
private val boundingBoxCache = HashMap<Int, Rect>()
69+
6470
/**
6571
* 文本行数。提供 [lineMetricsFn] 时取自 native 回填值,否则取构造传入值
6672
*(如 CoreTextField 等无行度量场景)。
@@ -98,8 +104,12 @@ class MultiParagraph(
98104

99105
/**
100106
* Returns the bounding box of the character for given character offset.
101-
* 通过 [getBoundingBoxFn] 向 native 端查询(Android / iOS / OHOS 原生文本布局)。
107+
* 通过 [getBoundingBoxFn] 向 native 端查询(Android / iOS / OHOS 原生文本布局),
108+
* 同 offset 首次查询后写入 [boundingBoxCache],之后命中缓存。
109+
* [getBoundingBoxFn] 为 null(如 CoreTextField)时返回零矩形,不写缓存。
102110
*/
103-
fun getBoundingBox(offset: Int): Rect =
104-
getBoundingBoxFn?.invoke(offset) ?: Rect(0f, 0f, 0f, 0f)
111+
fun getBoundingBox(offset: Int): Rect {
112+
val fn = getBoundingBoxFn ?: return Rect(0f, 0f, 0f, 0f)
113+
return boundingBoxCache.getOrPut(offset) { fn(offset) }
114+
}
105115
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* Tencent is pleased to support the open source community by making KuiklyUI
3+
* available.
4+
* Copyright (C) 2025 Tencent. All rights reserved.
5+
* Licensed under the License of KuiklyUI;
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
* https://github.com/Tencent-TDS/KuiklyUI/blob/main/LICENSE
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS,
11+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
* See the License for the specific language governing permissions and
13+
* limitations under the License.
14+
*/
15+
16+
package com.tencent.kuikly.compose.ui.text
17+
18+
import com.tencent.kuikly.compose.ui.geometry.Rect
19+
import kotlin.test.Test
20+
import kotlin.test.assertEquals
21+
22+
/**
23+
* [MultiParagraph.getBoundingBox] 的纯 Kotlin 回归用例。
24+
*
25+
* 核心契约:
26+
* 1. 同一 [MultiParagraph] 实例里,同 offset 二次读取命中缓存,只触发一次查询。
27+
* 2. 空路径([getBoundingBoxFn] 为 null)保持返回零矩形语义,不写缓存、不抛异常。
28+
*/
29+
class MultiParagraphTest {
30+
31+
@Test
32+
fun getBoundingBox_sameOffsetTwice_invokesFunctionOnlyOnce() {
33+
var invokeCount = 0
34+
val expected = Rect(1f, 2f, 3f, 4f)
35+
val multiParagraph = MultiParagraph(
36+
placeholderRects = emptyList(),
37+
getBoundingBoxFn = { offset ->
38+
invokeCount++
39+
Rect(
40+
expected.left + offset,
41+
expected.top + offset,
42+
expected.right + offset,
43+
expected.bottom + offset,
44+
)
45+
},
46+
)
47+
48+
val first = multiParagraph.getBoundingBox(7)
49+
val second = multiParagraph.getBoundingBox(7)
50+
51+
assertEquals(Rect(8f, 9f, 10f, 11f), first)
52+
assertEquals(first, second)
53+
assertEquals(1, invokeCount)
54+
}
55+
56+
@Test
57+
fun getBoundingBox_nullFunction_returnsZeroRect() {
58+
val multiParagraph = MultiParagraph(
59+
initialLineCount = 2,
60+
placeholderRects = emptyList(),
61+
)
62+
63+
val first = multiParagraph.getBoundingBox(3)
64+
val second = multiParagraph.getBoundingBox(3)
65+
66+
assertEquals(Rect(0f, 0f, 0f, 0f), first)
67+
assertEquals(first, second)
68+
}
69+
70+
@Test
71+
fun getBoundingBox_differentOffsets_invokeFunctionPerOffset() {
72+
var invokeCount = 0
73+
val multiParagraph = MultiParagraph(
74+
placeholderRects = emptyList(),
75+
getBoundingBoxFn = { offset ->
76+
invokeCount++
77+
Rect(
78+
offset.toFloat(),
79+
offset.toFloat(),
80+
offset.toFloat() + 1f,
81+
offset.toFloat() + 1f,
82+
)
83+
},
84+
)
85+
86+
val a = multiParagraph.getBoundingBox(3)
87+
val b = multiParagraph.getBoundingBox(7)
88+
val aAgain = multiParagraph.getBoundingBox(3)
89+
90+
assertEquals(Rect(3f, 3f, 4f, 4f), a)
91+
assertEquals(Rect(7f, 7f, 8f, 8f), b)
92+
assertEquals(a, aAgain)
93+
// 两个不同 offset 各触发一次底层查询,同 offset 复读命中缓存不再触发。
94+
assertEquals(2, invokeCount)
95+
}
96+
}

0 commit comments

Comments
 (0)