feat(compose): 文本虚线 drawBehind + PathEffect 官方写法对齐(三端通用化) - #1586
feat(compose): 文本虚线 drawBehind + PathEffect 官方写法对齐(三端通用化)#1586nikazhao wants to merge 10 commits into
Conversation
- KuiklyCanvas: dash 参数下沉 CanvasContext.setLineDash + 非法输入(空/全零区间)降级实线 + 显式复位防上一帧残留 - DrawModifier: drawBehind 用 snapshotObserver.observeReads 包裹, snapshot state 变化触发重绘 - PathEffect/DashPathEffect: dash 值对象(intervals/phase), intervals 走 contentEquals - TextLayoutResult/MultiParagraph: 行度量 getter; KRRichTextView 行度量桥接(lineMetrics/getBoundingBox) - demo: DashedUnderlineDemo 官方 5 场景对齐 + ComposeAllSample 注册入口 - test: 新增 PathEffectTest 6 个 commonTest 回归用例(全过) 验证: :core-render-android:assembleDebug / :compose:testDebugUnitTest(6 passed,0 failed) / :demo:compileDebugKotlinAndroid 均 BUILD SUCCESSFUL
drawBehind 的 DrawScope.size 现在严格等于组件自身布局尺寸,不再对 文本宿主额外加 4dp 底部留白。下划线是否落在文字下方由调用方在 lambda 内自行决定绘制 y 坐标(如 size.height),框架不再替宿主 添加偏移补丁。 经 DashedUnderlineDemo 验证,移除补丁后文本下划线仍不压字, 与各组件背景贴边表现一致。
- 新增 DrawBehindDemo.kt:覆盖 Box/Row/Column/Image/LazyColumn item 五类组件的 drawBehind 用法,
并对比实线、虚线 8-4、虚线 16-8、点线 2-4 四种 PathEffect 效果
- ComposeAllSample.kt 注册 DemoItem("DrawBehind", ...)
- 仅改动 demo 模块,未引入 core/compose 之外的依赖,符合最小改动与模块边界
- iOS KRRichTextView: hrv_callWithMethod 新增 lineMetrics/getBoundingBox, 复用 NSLayoutManager,严格解析参数、越界返回空矩形(pt 口径,与现有 spanRect 一致)。 - OHOS KRRichTextShadow: Call 新增 lineMetrics/getBoundingBox, 复用 context 线程 typography 并先拷强引用避免跨线程竞争,/dpi 转 dp。 - DashedUnderlineDemo: 场景2/4 onTextLayout 几何读取推后一帧(OHOS 首帧稳定), 加越界防御,移除本地路径注释。 三端(Android/iOS/OHOS)场景 1/3/5 + 2/4 跑通,callMethod 返回格式与 Android 对齐。
范围:drawBehind + pathEffect 二轮收敛,不涉其他能力。
## Native / render 桥接对齐(三端)
- core-render-ohos/KRRichTextShadow.cpp
* BoundingBox 补 offset 越界钳制:先扫 lineInfo 得 textLen,
safeOffset = clamp(offset, 0, textLen-1),与 Android(coerceIn)/iOS(>=textLen return)
保持一致,避免越界喂给 OH_Drawing_TypographyGetRectsForRange 引发 native crash / 脏数据。
* lineMetrics 返回格式补 start/end offset:"N top0 bottom0 start0 end0 ...",
与 Android 对齐;BoundingBox 换用 TIGHT/TIGHT 避免行末跨行膨胀。
- core-render-ios/KRRichTextView.m
* css_lineMetrics/css_boundingBoxWithParams 补齐并对齐格式。
- core-render-android/KRRichTextView.kt
* getLineMetrics/getCharBoundingBox 补齐并对齐;跨行时用 primaryHorizontal + 行边界回退,
避免 getSelectionPath 把跨行选区扩展成整行盒。
## Compose 层 API 对齐
- MultiParagraph / TextLayoutResult
* 补齐 getLineTop/getLineBottom/getLineStart/getLineEnd/getBoundingBox;
getBoundingBox 空安全(fn null → Rect(0,0,0,0)),getLineXxx 全部 getOrElse 防越界。
- TextStringRichNode
* pageDensity 空安全(fallback to requireDensity().density),避免 !! 强解包。
* 兼容新旧 lineMetrics 返回格式;getBoundingBoxFn 走 callMethod('getBoundingBox', ...)。
* 使用 AnnotatedString 时跳过整段 textDecoration,避免覆盖 span 级 dashed 装饰。
## Demo
- 新增 DrawBehindDemo(合并原 DashedUnderlineDemo,删除后者):
统一验证 Text / Box / Image / Row / Column / BasicTextField / TextField / Switch / Slider / Checkbox
等宿主组件挂 drawBehind + pathEffect 的稳定性;文案改书面语,去掉过期底色说明与
'本期不继续追' 冗余表述。
- ComposeAllSample 注册 DrawBehind 入口。
## 工程与本地环境
- ohosApp/build-profile.json5 转本地不入库(DevEco 自动签名密文只在本地)。
- .gitignore 同步新增 ohosApp/build-profile.json5 / .hvigor/ / .vscode/ / .workbuddy/ / .logs_pid / /scripts/ 忽略。
- iosApp/project.pbxproj:Xcode 自动删除两处空 outputPaths = ()(无害规范化)。
## CR 与验证
- 用 kuikly_lint 三轮回归,compose 层 3 文件 0 issue;OHOS native hvigorw compileNative BUILD SUCCESSFUL。
- Android/iOS/OHOS 三端 DrawBehindDemo 已跑通并出截图,安装/启动/渲染正常,无崩溃。
- 单测 PathEffectTest.kt 6 个用例覆盖 dashPathEffect 工厂 + DashPathEffect 语义。
- 🔴 High = 0(OHOS 越界钳制已补齐);范围仅含本任务改动;无 'mentor' 字样。
…rawbehind-pathEffect-pr_2 # Conflicts: # compose/build.2.1.21.gradle.kts
三份 CR 报告(hy3 / kimi3 / opus48)共性问题及 opus48 补充意见收敛:
## 逻辑修复
- OHOS KRRichTextShadow: endIndex 改回 exclusive(+1 去掉、<= 改 <),与
KRRichTextView / Android getLineEnd / iOS NSMaxRange / Skia 血统对齐。
- KuiklyCanvas.fillOrStroke 开头补 setLineDash(emptyList()),避免同 lambda
内 "先虚线 drawLine → 再描边图形" 状态残留串色。
- TextStringRichNode 去掉 if (annotatedText == null) 守卫(已废路线 C 遗留),
恢复 Text(AnnotatedString, textDecoration = Underline) 整段下划线。
- DrawModifier.ensureBackgroundCanvasView 引入 addedToParent 标志,catch
分支回滚已挂 bg,防止 addChild 成功但后续步骤抛异常时的 view 泄漏。
## 超范围回归恢复
- iOS KRRichTextView.m 恢复占位符 span 的 NSWritingDirectionAttributeName
LTR override(前次 commit 顺带删除,未在 message 中说明)。
- ohosApp/build-profile.json5 加回 origin/main 空 signingConfigs 版本;
.gitignore 移除对应忽略项;本地含密文件用 skip-worktree 保护。
## 注释 / 规范
- 头文件 KRRichTextShadow.h 的 LineMetrics 注释同步为 .cpp 实际返回格式
("N top0 bottom0 start0 end0 ...")。
- 清排期黑话:D2/D3 / Phase 2 / Phase 2 Step 1 spike / E3 完备性不足
/ scene6 / scene7 / Phase 2: PostProcessor(其中最后一处为老代码顺带
清理,因用户明确要求同步清零遗留黑话)。
- DrawModifier 清 "4dp padding" 死注释与 bottomPadDp = 0f 死变量。
- .gitignore 移除个人噪音 .logs_pid / .workbuddy/ / /scripts/,保留通用
工程配置 .vscode/ / .hvigor/。
## 已知限制注释(防下游误判)
- PathEffect.DashPathEffect 加 phase 当前不生效说明(setLineDash 协议
未开放 phase 位,iOS 硬编码 0)。
- DrawBackgroundModifier 类 kdoc 补 z 序限制:bg 作为宿主兄弟节点插到
父容器 index 0,与官方 "宿主内部 z=-1 层" 语义在兄弟重叠场景有偏差。
- MultiParagraph.getLineEnd fallback 补注释:旧格式兼容路径最后一行
退化为空区间是有意为之。
## 不含改动
- brush 重载 pathEffect(未来增强项,需三端 shader+dash 验证)。
… underline Showcase drawBehind + PathEffect.dashPathEffect on Compose Text: full-line dashed underline, local span dashed underline, and custom dash patterns. Lets readers follow TextDemo to learn how to add local dashed underlines.
Round 2 CR fixup covering hy3 / kimi3 / opus48 review reports plus
the P1/P2 issues (license / precision / hot-path perf) called out by
the reviewer:
- P1-1 add missing Tencent KuiklyUI license header to three new files
(PathEffect.kt / PathEffectTest.kt / DrawBehindDemo.kt) for open
source compliance.
- P1-2 align Android line-metric precision with iOS/OHOS: replace
toDpI (integer dp rounding) with toDpF in getLineMetrics and
getCharBoundingBox, eliminating up to 0.5dp geometry drift.
- P2-1 make TextLayoutResult line metrics lazy: hoist
callMethod("lineMetrics") out of the measure hot path and behind a
MultiParagraph.lineMetricsFn that only fires on first getLineTop /
getLineStart / lineCount access, matching the existing
getBoundingBoxFn design. Removes one sync bridge call + one split
+ four array allocations per Text measure.
- P2-2 avoid unconditional setLineDash bridge calls in KuiklyCanvas:
introduce a dashActive dirty flag so drawLine / fillOrStroke only
clear line dash when the previous stroke actually set one. Preserves
the anti-color-bleed fix while removing per-shape bridge overhead
on dense Canvas pages.
Verified with :compose:testDebugUnitTest (11/11 pass:
PathEffectTest 6, LazyListPrefetchBuildConfigTest 3, LegacyPrefetch-
BuildConfigTest 1, FramePrefetchSchedulerTest 1).
KuiklyAI
left a comment
There was a problem hiding this comment.
🤖 KuiklyAI Code Review
基本信息
- PR:#1586 · feat(compose): 文本虚线 drawBehind + PathEffect 官方写法对齐(三端通用化)
- 作者:nikazhao main ← feat/kuikly-compose-drawbehind-pathEffect-pr_3
变更概述
- 干了什么:为 Compose DSL 补齐
Modifier.drawBehind背景绘制和PathEffect.dashPathEffect虚线能力,并统一 Android/iOS/OHOS 三端文本行度量与字符包围盒桥接;新增DrawBehindDemo与文本虚线下划线 demo。 - 解决什么:对齐官方 Compose 的
drawBehind+dashPathEffect写法,补齐文本(含多行/多段富文本)虚线下划线能力。 - 方案 & 合理性:dash 参数下沉到
CanvasContext.setLineDash、用dashActive脏标记避免未激活场景的桥调用;行度量与字符盒走惰性闭包(measure 热路径零桥开销)+ 实例级缓存;背景画布作为宿主兄弟节点挂到父容器 index 0。整体技术路径合理,三端语义对齐(endIndex exclusive、越界钳制、toDpF 浮点精度)做得比较扎实,CR 多轮收敛痕迹清晰。
主要关注点
drawRect/drawPath/drawCircle等 fillOrStroke 路径会忽略paint.pathEffect,仅drawLine真正支持虚线——DrawScope.drawRect已恢复pathEffect参数但实际不生效,是个 API 契约不一致,建议在 kdoc 标注当前限制或后续补齐(见 inline)。MultiParagraph.boundingBoxCache用HashMap且不加锁,getBoundingBox是 public API,并发访问契约偏弱(见 inline)。- OHOS
LineMetrics/BoundingBox依赖context_thread_typography_,注释假设调用发生在 context 线程的 measure 阶段;但MultiParagraph改为惰性后实际首访在 draw 阶段,建议确认此时该 typography 仍有效(见 inline)。
文档同步
drawBehind、PathEffect.dashPathEffect 属于新增的公共绘制 API,命中 docs/ 维护范围(公共 API / 组件 / 平台行为)。当前 docs/ 未见同步更新,建议补一份 drawBehind + 虚线的用法说明,避免下游按官方 Compose 语义直接套用而踩到 phase 不生效、drawRect 不支持 dash 等已知限制。
整体质量不错,三端对齐和性能优化都到位。以上几条以建议为主,不阻塞合入。
|
|
||
| private fun CanvasContext.fillOrStroke(paint: Paint) { | ||
| // 清空上一笔 drawLine 残留的虚线状态,避免同 lambda 内后续描边图形继承虚线 | ||
| clearLineDashIfActive() |
There was a problem hiding this comment.
这里 fillOrStroke 开头调 clearLineDashIfActive() 清掉上一笔虚线,但整个方法没有再根据 paint.pathEffect 重新设置 dash。这意味着 drawRect/drawPath/drawCircle/drawRoundRect 等走 fillOrStroke 的 API 会忽略传入的 paint.pathEffect——只有 drawLine 真正支持虚线。
问题是 DrawScope.drawRect/drawPath 等已经恢复了 pathEffect 参数(见 DrawScope.kt / CanvasDrawScope.kt),业务传 pathEffect = dashPathEffect(...) 给 drawRect 画虚线框时不会报错,但也不会画虚线,是个静默不生效的契约缺口。demo 里 BoxFrameSample 用 4 条 drawLine 拼框绕过了这个限制,所以没暴露。
当前 PR 范围是文本虚线(用 drawLine 实现),可以接受。建议在 fillOrStroke 或 DrawScope.drawRect 的 kdoc 标一句"当前 pathEffect 仅 drawLine 生效",避免下游按官方 Compose 语义直接用 drawRect 画虚线框而困惑。后续要支持可在 fillOrStroke 的 stroke 分支补一段类似 drawLine 的 paint.pathEffect as? DashPathEffect 下沉逻辑。
There was a problem hiding this comment.
已解决:PathEffect.kt 的 sealed interface kdoc 已补"当前 pathEffect 仅对 drawLine 生效,drawRect/drawPath/drawCircle/drawRoundRect 等走 fillOrStroke 的 API 会忽略 pathEffect",与建议一致。后续若要支持可在 fillOrStroke 的 stroke 分支补 DashPathEffect 下沉逻辑。
| * 字符包围盒缓存:offset → Rect。生命周期与本实例一致,文本或布局变化会重建实例、 | ||
| * 缓存随之失效。仅在 UI 线程 draw 阶段访问(`drawBehind` 内按字符定位画线),单线程读写不加锁。 | ||
| */ | ||
| private val boundingBoxCache = HashMap<Int, Rect>() |
There was a problem hiding this comment.
boundingBoxCache 用 HashMap 且注释声明"仅在 UI 线程 draw 阶段访问,单线程读写不加锁"。但 getBoundingBox(offset) 是 public API(经 TextLayoutResult.getBoundingBox 暴露给业务),业务侧 onTextLayout 拿到 TextLayoutResult 后理论上可能在非 UI 线程或重组期间并发读取同一个实例,此时 HashMap.getOrPut 有 ConcurrentModificationException 风险。
当前 drawBehind 场景确实在 UI 线程单线程访问,契约自洽。但既然已升级为 public 契约,建议要么改用 ConcurrentHashMap(getOrPut 仍非原子但迭代安全),要么在 TextLayoutResult.getBoundingBox 的 kdoc 显式约束"必须在 UI 线程调用",让契约更明确。
There was a problem hiding this comment.
已解决:采用了 kdoc 约束方案。MultiParagraph.boundingBoxCache 注释已标明"仅在 UI 线程 draw 阶段访问,单线程读写不加锁";TextLayoutResult.getBoundingBox 的 kdoc 也补了"必须在 UI 线程调用;请勿在非 UI 线程或与重组并发访问同一 TextLayoutResult"。契约已明确。
| // context 线程自有的 context_thread_typography_(而非 main 线程自有的 | ||
| // main_thread_typography_),否则会与主线程 SetMainThreadTypography 形成跨线程 | ||
| // 数据竞争导致 cppcrash。先拷一份强引用再取裸指针,对齐 SpanRect 的惯用法。 | ||
| std::string KRRichTextShadow::LineMetrics() { |
There was a problem hiding this comment.
LineMetrics()/BoundingBox() 都访问 context_thread_typography_,注释说"经 Call() 由 Kotlin measure 在 context 线程触发"。但这个前提在 MultiParagraph 改为惰性后其实变了:lineMetricsFn/getBoundingBoxFn 是 lazy 闭包,getBoundingBox 的首次调用实际发生在 drawBehind 的 draw 闭包里(draw 阶段),而不是 measure 阶段。
需要确认 draw 阶段触发 callMethod("getBoundingBox")→Call() 时,执行的线程是否仍是 context 线程、且此时 context_thread_typography_ 是否仍是 measure 时建立的那一份(未被 SetContextTypography 重建或 reset)。如果 draw 时 typography 已变化,拿到的是脏数据(虽然 typo_raw == nullptr 兜底了空指针,但非空的脏数据不会被兜底)。真机三端验证已跑通说明常规路径 OK,但布局变化触发重建 typography 的时序建议再确认一下,或在注释里把"调用发生在 draw 阶段、依赖 draw 仍在 context 线程且 typography 未变"这个前提写明确。
There was a problem hiding this comment.
已解决:LineMetrics()/BoundingBox() 的注释已从"由 Kotlin measure 在 context 线程触发"改为"首访可能在 measure 也可能延后到 drawBehind 的 draw 阶段,依赖调用仍在 context 线程、且 context_thread_typography_ 为当前布局周期有效那一份",把 lazy 闭包首访时点的不确定性与线程前提写明确了。
…rame - 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 逐帧显示:带虚线滑动流畅度与不带虚线一致,未拉低。
48d47ab to
bc48b94
Compare
KuiklyAI
left a comment
There was a problem hiding this comment.
🤖 KuiklyAI Code Review
增量复核(commit bc48b94:perf 缓存 + bg frame 去抖)
上轮三条 inline 全部已解决,已在原线程逐条回复:
- KuiklyCanvas fillOrStroke pathEffect 仅 drawLine 生效 → PathEffect.kt kdoc 已补;
- MultiParagraph boundingBoxCache 线程安全 → 采用 kdoc 约束方案,MultiParagraph + TextLayoutResult 均补 UI 线程契约;
- KRRichTextShadow 线程模型前提 → 注释已改为说明 lazy 首访可能延后到 draw 阶段。
本期新改动
- MultiParagraph.getBoundingBox 加 offset→Rect 实例级缓存,fn 为 null 时短路返回零矩形不写缓存;Rect 是 immutable data class,缓存值类型无污染风险,逻辑正确。MultiParagraphTest 三条契约用例覆盖到位。
- DrawModifier 背景 CanvasView 的 setFrame 去抖:跳过重复 frame 不影响绘制——绘制命令每帧都经 bgDrawScope.draw → KuiklyCanvas(reset) → flush 重新下发,setFrame 只管 view 位置/尺寸。NaN 初值保证首帧必走 setFrame,新建 bg 时 resetDebounceState() 也会重置历史 frame。
二进制兼容性无问题:getBoundingBox 等 public API 签名未变,仅内部加缓存,新增字段均为 private。本期改动未引入新的功能风险。
背景
Kuikly Compose 此前缺少与官方 Compose 写法一致的
drawBehind背景绘制和PathEffect.dashPathEffect虚线能力。本次补齐文本虚线下划线能力,并统一 Android、iOS、OHOS 三端文本几何数据。功能改动
Modifier.drawBehind背景绘制能力:CanvasView宿主直接复用自身画布;普通组件宿主创建背景CanvasView并与宿主布局同步。PathEffect.dashPathEffect,支持按官方 Compose 的 dash/gap 间隔声明虚线。DrawBehindDemo与文本虚线下划线案例,覆盖通用组件、单行、多行及富文本场景。性能优化
MultiParagraph实例中已查询的字符包围盒,避免相同 offset 重复跨端调用getBoundingBox。CanvasView的位置和尺寸未变化时,跳过重复setFrame。正确性与兼容
MultiParagraph实例内有效;布局变化创建新实例后自然失效。onDetach解绑,避免持有已销毁的 View。测试与验证
PathEffectTest。MultiParagraphTest,覆盖相同 offset 缓存命中、空查询函数返回零矩形、不同 offset 分别查询。:compose:compileDebugKotlinAndroid:androidApp:assembleDebug:compose:testDebugUnitTest --tests com.tencent.kuikly.compose.ui.text.MultiParagraphTest --tests com.tencent.kuikly.compose.ui.graphics.PathEffectTestPixel_6模拟器安装并冷启动TextDemo成功。范围
本 PR 包含 Compose
drawBehind、PathEffect、文本几何三端桥接、示例,以及字符盒缓存和背景位置同步去抖。