Skip to content

feat(compose): 文本虚线 drawBehind + PathEffect 官方写法对齐(三端通用化) - #1586

Open
nikazhao wants to merge 10 commits into
Tencent-TDS:mainfrom
nikazhao:feat/kuikly-compose-drawbehind-pathEffect-pr_3
Open

feat(compose): 文本虚线 drawBehind + PathEffect 官方写法对齐(三端通用化)#1586
nikazhao wants to merge 10 commits into
Tencent-TDS:mainfrom
nikazhao:feat/kuikly-compose-drawbehind-pathEffect-pr_3

Conversation

@nikazhao

Copy link
Copy Markdown

背景

Kuikly Compose 此前缺少与官方 Compose 写法一致的 drawBehind 背景绘制和 PathEffect.dashPathEffect 虚线能力。本次补齐文本虚线下划线能力,并统一 Android、iOS、OHOS 三端文本几何数据。

功能改动

  • 新增 Modifier.drawBehind 背景绘制能力:CanvasView 宿主直接复用自身画布;普通组件宿主创建背景 CanvasView 并与宿主布局同步。
  • 新增 PathEffect.dashPathEffect,支持按官方 Compose 的 dash/gap 间隔声明虚线。
  • 支持文本多行、多段富文本的虚线下划线绘制。
  • 补齐 Android、iOS、OHOS 的文本行度量与字符包围盒桥接,三端统一逻辑行尾的 exclusive 语义与 dp→px 换算。
  • 新增 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.PathEffectTest
  • Android Pixel_6 模拟器安装并冷启动 TextDemo 成功。

范围

本 PR 包含 Compose drawBehindPathEffect、文本几何三端桥接、示例,以及字符盒缓存和背景位置同步去抖。

nikazhao added 9 commits July 16, 2026 11:42
- 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 KuiklyAI left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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 多轮收敛痕迹清晰。

主要关注点

  1. drawRect/drawPath/drawCircle 等 fillOrStroke 路径会忽略 paint.pathEffect,仅 drawLine 真正支持虚线——DrawScope.drawRect 已恢复 pathEffect 参数但实际不生效,是个 API 契约不一致,建议在 kdoc 标注当前限制或后续补齐(见 inline)。
  2. MultiParagraph.boundingBoxCacheHashMap 且不加锁,getBoundingBox 是 public API,并发访问契约偏弱(见 inline)。
  3. OHOS LineMetrics/BoundingBox 依赖 context_thread_typography_,注释假设调用发生在 context 线程的 measure 阶段;但 MultiParagraph 改为惰性后实际首访在 draw 阶段,建议确认此时该 typography 仍有效(见 inline)。

文档同步

drawBehindPathEffect.dashPathEffect 属于新增的公共绘制 API,命中 docs/ 维护范围(公共 API / 组件 / 平台行为)。当前 docs/ 未见同步更新,建议补一份 drawBehind + 虚线的用法说明,避免下游按官方 Compose 语义直接套用而踩到 phase 不生效、drawRect 不支持 dash 等已知限制。

整体质量不错,三端对齐和性能优化都到位。以上几条以建议为主,不阻塞合入。


private fun CanvasContext.fillOrStroke(paint: Paint) {
// 清空上一笔 drawLine 残留的虚线状态,避免同 lambda 内后续描边图形继承虚线
clearLineDashIfActive()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这里 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 实现),可以接受。建议在 fillOrStrokeDrawScope.drawRect 的 kdoc 标一句"当前 pathEffect 仅 drawLine 生效",避免下游按官方 Compose 语义直接用 drawRect 画虚线框而困惑。后续要支持可在 fillOrStroke 的 stroke 分支补一段类似 drawLine 的 paint.pathEffect as? DashPathEffect 下沉逻辑。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已解决: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>()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

boundingBoxCacheHashMap 且注释声明"仅在 UI 线程 draw 阶段访问,单线程读写不加锁"。但 getBoundingBox(offset) 是 public API(经 TextLayoutResult.getBoundingBox 暴露给业务),业务侧 onTextLayout 拿到 TextLayoutResult 后理论上可能在非 UI 线程或重组期间并发读取同一个实例,此时 HashMap.getOrPutConcurrentModificationException 风险。

当前 drawBehind 场景确实在 UI 线程单线程访问,契约自洽。但既然已升级为 public 契约,建议要么改用 ConcurrentHashMap(getOrPut 仍非原子但迭代安全),要么在 TextLayoutResult.getBoundingBox 的 kdoc 显式约束"必须在 UI 线程调用",让契约更明确。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已解决:采用了 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() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 未变"这个前提写明确。

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

已解决: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 逐帧显示:带虚线滑动流畅度与不带虚线一致,未拉低。
@nikazhao
nikazhao force-pushed the feat/kuikly-compose-drawbehind-pathEffect-pr_3 branch from 48d47ab to bc48b94 Compare July 31, 2026 09:27

@KuiklyAI KuiklyAI left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 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。本期改动未引入新的功能风险。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants