Skip to content

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

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

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

Conversation

@nikazhao

@nikazhao nikazhao commented Jul 28, 2026

Copy link
Copy Markdown

做了什么

Modifier.drawBehindPathEffect.dashPathEffect 打通为一套通用的文本虚线能力,覆盖 Android / iOS / OHOS 三端,补齐文本行度量的原生桥接,使绘制语义与官方 Compose 对齐;并在 demo 中提供「局部虚线(头/中/尾多段定位)」的可读示例。

改动范围(9 个 commit,按阶段排列)

核心能力(框架层 compose/,未引入新依赖、未混用 DSL)

  • adad8c87 文本虚线 drawBehind + PathEffect 官方写法对齐:KuiklyCanvas 下发 setLineDash、DrawModifier 用 snapshotObserver 触发重绘、PathEffect/DashPathEffect 值对象、TextLayoutResult/MultiParagraph 行度量 getter 与 KRRichTextView 桥接、新增 PathEffectTest 6 个用例
  • 3be45c0f 移除 drawBehind 固定 4dp 底部补丁,DrawScope.size 严格等于组件自身尺寸,对齐官方语义
  • 6b571eaf iOS/OHOS 补齐文本虚线行度量桥接,三端对齐 drawBehind + PathEffect
  • 296fcab6 二轮打磨与 OHOS 越界钳制:非法 dash 输入降级实线、跨帧残留复位

合入主线

  • 8323a37a Merge origin/main(带入 LazyList prefetch 重构,与 drawBehind 绘制链路无交互)

评审反馈修复

  • e0937f1d 共性问题收敛:OHOS endIndex 改回 exclusive 对齐各端、串色 setLineDash 复位、恢复废弃守卫后的整段下划线、bg view 泄漏回滚、iOS LTR override 恢复、license 头补齐、Android toDpF 精度对齐、黑话清理、已知限制 kdoc
  • 7d8b7ca4 Round 2 P1/P2 修复:
    • P1-1 三个新文件补 Tencent 开源 license 头
    • P1-2 Android 行度量精度对齐:getLineMetrics / getCharBoundingBox 由 toDpI(整数 dp 取整)改 toDpF,消除最多 0.5dp 几何漂移
    • P2-1 TextLayoutResult 行度量惰性化:callMethod("lineMetrics") 移出 measure 热路径、收进 lineMetricsFn,首次访问 getLineTop/getLineStart/lineCount 才触发,每次 Text measure 省 1 次同步桥调用 + 1 次 split + 4 次数组分配
    • P2-2 KuiklyCanvas 引入 dashActive 脏标记,仅上一笔真设过 dash 才清 line dash,去除密集 Canvas 页每图形无条件桥调用开销(保留防串色修复)

demo 示例

  • f9211f59 新增 DrawBehindDemo:覆盖 Box/Row/Column/Image/LazyColumn 五类组件与实线/虚线 8-4/虚线 16-8/点线 2-4 四种 PathEffect
  • a2c69f12 TextDemo 新增 DashedUnderlineDemo:单行与五行文本的头/中/尾多段虚线定位,含 \n 换行处自算字符偏移以规避三端换行边界不一致

验证状态

  • 三端(Android / iOS / OHOS)文本虚线下发与渲染已对齐验证
  • 单测 :compose:testDebugUnitTest 全过:PathEffectTest 6/6 + LazyList/Legacy/Frame prefetch 相关 5/5,共 11 用例、0 失败
  • demo:DrawBehindDemo 覆盖通用组件与多种 pathEffect;TextDemo 新增的 DashedUnderlineDemo 覆盖整段与局部多段虚线、含换行文本场景

已知限制

  • 渐变笔刷(brush)虚线暂未启用(属官方对齐的未来增强)
  • 描边矩形/圆本身套 pathEffect 虚线暂不支持

nikazhao added 5 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

@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:#1572 · feat(compose): 文本虚线 drawBehind + PathEffect 官方写法对齐(三端通用化)
  • 作者:nikazhao main ← feat/kuikly-compose-drawbehind-pathEffect-pr_2

变更概述

Modifier.drawBehindPathEffect.dashPathEffect 打通为通用文本虚线能力,覆盖 Android / iOS / OHOS 三端。Compose 侧新增 PathEffect 值对象、给 PaintpathEffectKuiklyCanvas.drawLine 下沉 setLineDash(含空/全零降级实线 + 显式清空防残留)、MultiParagraph/TextLayoutResultgetLineTop/getLineBottom/getLineStart/getLineEnd/getBoundingBox;native 侧三端 RichTextView 新增 lineMetrics / getBoundingBox 桥接;附 DrawBehindDemoPathEffectTest

方案整体在正确层级(compose DSL + 各 render 层桥接),未混用两种 DSL,模块边界清晰。二进制兼容方面 MultiParagraph 新增构造参数均带默认值,CoreTextField.ktMultiParagraph(lineCount, placeholderRects) 旧调用仍可编译;Paint.pathEffect 用默认 no-op getter/setter 保持对既有实现的源码兼容——这些都 OK。

主要问题(建议处理)

  1. OHOS endIndex 语义与既有代码冲突(最需要确认):新代码按 inclusive 语义对 endIndex+1 并用 safeOffset <= endIndex 定位行,但同族的 KRRichTextView.cpp:714 用的是 index < endIndex:471info.end = endIndex,都把它当 exclusive。若 SDK 实际是 exclusive(与既有用法、Android getLineEnd/iOS NSMaxRange 对齐方向一致),+1 会让 lineEnd 偏 1、textLen 大 1,进而让 safeOffset 上界放宽一格、给 OH_Drawing_TypographyGetRectsForRange 喂越界 offset。请对照 drawing_text_typography.h 头文件里 OH_Drawing_LineMetrics.endIndex 的定义确认到底是 inclusive 还是 exclusive,再决定要不要 +1/<=
  2. brush 重载的 pathEffect 被静默丢弃configureStrokePaint(brush, ...) 收了 pathEffect 参数却没赋给 paint(应用那行仍是注释),和 color 重载不一致。drawLine(brush, …, pathEffect = dash) 不会出虚线。
  3. 跨 shape 的 dash 状态残留drawLinesetLineDash 了,但 drawRect/drawPath/fillOrStroke 的 stroke 路径没复位,同一帧同一 canvas 上先画虚线再画实线 stroke 形状时,后画的矩形/路径会继承上一条的 dash。建议在 stroke 路径里按 paint.pathEffect 同样兜底清空。
  4. 整段 style.textDecoration 在 AnnotatedString 下被丢弃if (annotatedText == null) 的守卫让 Text(annotatedString, textDecoration = Underline) 不再下发整段下划线。保护 span 级 dashed 装饰的出发点合理,但代价是丢了整段装饰继承——若官方语义里未显式设装饰的 span 应继承顶层 TextStyle,这里会偏。建议确认是否需要 merge 而非二选一。

其他

  • ohosApp/build-profile.json5 整文件删除 + 入 .gitignore:该文件里 products/compatibleSdkVersion/modules 是非签名的通用配置,OHOS 构建参考文档里 grep signingConfigs ohosApp/build-profile.json5 是前置步骤,新克隆可能缺文件。建议保留文件(signingConfigs: [] 留空)只忽略签名相关本地改动,或补一个生成说明。
  • DrawModifier.drawIntoBackgroundCanvasView 的 kdoc 仍写“含 4dp 底部 padding”“z-order 由 drawInto 的 4dp padding 视觉处理”,但 bottomPadDp = 0f、padding 已移除,注释与实现不符,建议同步改掉。
  • iOS p_createPlaceholderSpanAttributedStringWithSpan 删掉了 NSWritingDirectionAttributeName(强制 LTR override),这是与虚线无关的行为变更,确认下是有意为之(对 RTL 占位符可能影响)。

文档

docs/ 目前未收录 drawBehind / PathEffect.dashPathEffect / TextLayoutResult 行度量 API。这些是新增的公共 Compose DSL 能力,建议后续补一节绘制/文本度量文档(setLineDashdocs/API/components/canvas.md 已有,native 侧无影响)。

整体方向和分层我都认可,三端桥接也基本对齐,上面几条主要是兼容性语义和跨 shape 一致性,确认后即可。

result += " " + std::to_string(top);
result += " " + std::to_string(bottom);
result += " " + std::to_string(lineMetrics.startIndex);
result += " " + std::to_string(lineMetrics.endIndex + 1);

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.

这里对 endIndex+1、并在 BoundingBox 里用 safeOffset <= endIndex 定位行,前提是 OH_Drawing_LineMetrics.endIndex 是 inclusive。但同族的 KRRichTextView.cpp:714 用的是 index < line_info.line_metrics_.endIndex:471info.end = ... endIndex,都把它当 exclusive。如果 SDK 实际是 exclusive(这和 Android Layout.getLineEnd、iOS NSMaxRange 都是 exclusive 的对齐方向一致),+1 会让 lineEnd 偏 1、textLen 比 true length 大 1,进而让 safeOffset 上界放宽一格,给 OH_Drawing_TypographyGetRectsForRange(safeOffset, safeOffset+1) 喂越界 offset。建议对照 drawing_text_typography.hendIndex 的定义确认语义后再定 +1 / <=,避免和既有 KRRichTextView.cpp 用法打架。

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.

已解决:endIndex 的 +1 已去掉,LineMetrics 直接输出 endIndex,注释同步成 end 为 exclusive,与 KRRichTextView.cpp 的 index < endIndex / info.end = endIndex 对齐。

for (size_t i = 0; i < lineCount; ++i) {
OH_Drawing_LineMetrics lm;
OH_Drawing_TypographyGetLineInfo(typo_raw, i, true, true, &lm);
int end = static_cast<int>(lm.endIndex) + 1; // endIndex 与 LineMetrics 保持 inclusive 语义

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.

和上一条同源:这里 end = endIndex + 1 作为 textLen 上界,注释写“endIndex 与 LineMetrics 保持 inclusive 语义”。若实际是 exclusive,textLen 会比真实长度大 1,safeOffset 能取到真实文本长度那一格(越界),后续 GetRectsForRange(safeOffset, safeOffset+1) 即越界。同样建议先拿 SDK 头文件确认 endIndex 到底 inclusive 还是 exclusive,三端(Android/iOS 都是 exclusive)统一到一边。

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.

已解决:textLen 上界改为 end = endIndex(去掉 +1),行定位改回 safeOffset < endIndex。越界钳制用 textLen-1,和 Android coerceIn / iOS 语义统一,不会再给 GetRectsForRange 喂越界 offset。

cap: StrokeCap,
join: StrokeJoin,
// pathEffect: PathEffect?,
pathEffect: PathEffect?,

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.

brush 重载这里把 pathEffect 参数加回来了,但下面 obtainStrokePaint().apply { ... } 里给 paint 赋值的那行还是注释状态(// if (this.pathEffect != pathEffect) this.pathEffect = pathEffect),参数收了却没用。结果 drawLine(brush, …, pathEffect = dash) 之类走 brush 的入口不会把 dash 下发,和 color 重载行为不一致。要么把这行打开,要么直接去掉这个参数避免误导调用方。

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.

部分处理:color 重载那行已经打开(this.pathEffect = pathEffect 生效了),brush 重载这次按 commit message 声明暂不动(属未来增强,需三端 shader+dash 验证)。brush 重载里 pathEffect 参数仍在签名里但赋值行还是注释,调用方传 dash 不会生效——这点留意下,后续启用时一并打开或先去掉参数避免误导。

setLineDash(intervals.map { it / densityValue }.toList())
}
} else {
setLineDash(emptyList()) // 显式清空,防止上一帧残留

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.

drawLine 这里 setLineDash 了(含实线清空),但 drawRect / drawPath / fillOrStroke 走 stroke 时并没有按 paint.pathEffect 复位 dash。同一帧同一 canvas 上先画一条虚线、再画一个实线 stroke 的矩形/路径时,后者会继承前一条的 dash pattern(OHOS pen_OH_Drawing_PenSetPathEffect 是持久的,Android currentDrawStyle.lineDash 也是持久的,跨帧靠 reset() 清、但帧内不会清)。建议在 stroke 路径里也按 paint.pathEffect 兜底 setLineDash(emptyList()),保证每个 shape 自洽。

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 开头补了 setLineDash(emptyList()),每个 stroke 图形进来先清 dash,不会再继承同一 lambda 内上一笔 drawLine 的虚线 pattern。

attr.applyTextDecoration(style.textDecoration)
// 当使用 AnnotatedString 时,textDecoration 由各 span 自行下发,
// 这里跳过整段下发,避免覆盖 span 级别的 dashed 装饰。
if (annotatedText == null) {

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.

annotatedText == null 守卫后,Text(annotatedString, style = TextStyle(textDecoration = Underline)) 这类整段下划线就不会下发了——只有显式在 spanStyle 里设了 textDecoration 的 span 才有装饰。保护 span 级 dashed 装饰不被整段覆盖的出发点能理解,但代价是丢了顶层 TextStyle.textDecoration 的继承(官方语义里未显式覆盖装饰的 span 应继承顶层)。如果只是怕整段装饰盖掉 span 级 dashed,可以考虑只在有 span 级装饰冲突时跳过,而不是 annotatedText 一非空就整体跳过,确认下预期行为?

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.

已解决:if (annotatedText == null) 守卫去掉了,恢复 applyTextDecoration(style.textDecoration) 整段下发。顶层 TextStyle.textDecoration 的继承回来了,span 级装饰仍由 applyAnnotatedString 在各 span 自行下发覆盖。

}

/**
* 把背景 CanvasView 定位到宿主同帧(含 4dp 底部 padding),并用 KuiklyCanvas +

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 还写着“含 4dp 底部 padding”“z-order 由 drawInto 的 4dp padding 视觉处理”,但下面 bottomPadDp = 0f、padding 已经在二轮打磨里移除了。注释和实现不符,后面接手的人容易被带偏,顺手改一下。

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.

已解决:4dp padding 死注释清掉、bottomPadDp = 0f 死变量删除,类 kdoc 补了 z 序限制说明(bg 作为宿主兄弟节点插到父容器 index 0,兄弟重叠场景与官方 z=-1 语义有偏差)。

Comment thread .gitignore Outdated
.mcp.json

## OHOS local build config (regenerated on every build, contains local signing paths)
ohosApp/build-profile.json5

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.

ohosApp/build-profile.json5 这个文件里除了本地签名,还带了 products / compatibleSdkVersion / modules(entry + render)这些通用配置,整文件删掉入 .gitignore 后,新克隆的仓库没有这个文件。而 OHOS 构建参考文档里 grep signingConfigs ohosApp/build-profile.json5 是构建前置步骤、hvigorw 也依赖它存在。建议保留文件入库(signingConfigs: [] 留空),只把签名相关本地字段忽略掉,或补一行说明从哪生成,避免别人 clone 下来 OHOS 起不来。

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.

已解决:ohosApp/build-profile.json5 重新入库(空 signingConfigs 版本,与 origin/main 一致),.gitignore 里对应忽略项移除,本地含密字段用 skip-worktree 保护。新克隆仓库 OHOS 构建链路不再缺文件。

}

NSAttributedString *attrString = [NSAttributedString attributedStringWithAttachment:attachment];
NSMutableAttributedString *mutableAttrString = [[NSMutableAttributedString alloc] initWithAttributedString:attrString];

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.

这里把 placeholder attachment 上的 NSWritingDirectionAttributeName(强制 LTR override)删掉了。和虚线能力不直接相关,属于行为变更:之前占位符在 RTL 文本里也被强制 LTR,删掉后会跟随段落方向。如果是有意改的(对齐官方/修 RTL),建议在 commit message 里单独说一句;如果只是顺手删的,留意下 RTL 场景的 placeholder 表现。

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.

已解决:占位符 span 的 NSWritingDirectionAttributeName LTR override 加回来了,恢复前次 commit 误删的行为。

@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

增量评估(本轮 = merge commit 8323a37a

本轮最新提交是把 origin/main5cf4bea8,含 macOS 平台支持 + LazyList prefetch 重构)合并进 PR 分支,自身不包含业务逻辑改动。

上轮历史评论盘点:之前的 8 条 inline 全部落在 PR 的业务文件上(KRRichTextShadow.cppendIndex + 1 inclusive/exclusive 语义、CanvasDrawScope.kt brush 重载 pathEffect 未下发、KuiklyCanvas.kt drawRect/drawPath 帧内 dash 残留、TextStringRichNode.kt 整段 textDecoration 跳过、DrawModifier.kt kdoc 与 bottomPadDp = 0f 不符、.gitignoreohosApp/build-profile.json5、iOS NSWritingDirectionAttributeName 删除)。本次 merge diff 与这些文件零交集,所以这 8 条均未在本轮改动中处理,保持原样。

其中 KRRichTextShadow.cpp 那两条(endIndex + 1)我顺着既有代码再核对了一遍:同文件族 KRRichTextView.cpp:714index < endIndex 遍历、:471endIndex 作整段 info.end,都把 endIndex 当 exclusive 用,和 Android Layout.getLineEnd、iOS NSMaxRange 的 exclusive 方向一致。这条仍然建议在合入前对照 OH_Drawing 头文件确认语义后定 +1/<=,否则 textLen 会比真实长度大 1、safeOffset 上界放宽一格,给 GetRectsForRange 喂越界 offset。

本轮新改动风险评估

  • 冲突文件 compose/build.2.1.21.gradle.kts 解析干净,无冲突标记残留;引入的 runtimeLegacy / runtime19 源集分流是叠加在 commonMain 之上(dependsOn(commonMain)),不会把 commonMain 排除。
  • PR 的全部业务文件都在 commonMain、单测在 commonTest,所依赖的 snapshotObserver / observeReads / requireOwner / DrawModifierNode 等 API 定义在 commonMainOwner.kt / RootNodeOwner.kt),未被 runtime19/legacy 分流,drawBehind 重绘链路不受影响。
  • main 对 SubcomposeLayout 的 pausable composition 重构集中在 LazyList prefetch 路径,与 PR 走 DrawModifierNode.invalidateDraw + snapshotObserver.observeReads 的 draw 链路无交互。

本轮 merge 本身没有引入需要 inline 的新问题。一个小提醒:作者在最新 commit message 里没提合并 main 后的编译/测试验证状态,而 main 这次带进来的 LazyList prefetch 改动量不小(新增 ~7000 行、多个源集),建议补一次合并后三端编译 + :compose:testDebugUnitTest(含 PathEffectTest)确认无回归再合入。

@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(增量)

这一笔是 CR 收敛 commit,逐条盘点上轮 8 条 inline 评论的处置情况:

  • OHOS endIndex 语义(2 条):✅ 已改回 exclusive,去掉 +1、<=<,与 KRRichTextView.cppindex < endIndex / info.end = endIndex 及 Android/iOS 对齐,越界钳制回归正确。
  • drawLine dash 残留串色:✅ fillOrStroke 开头补 setLineDash(emptyList()),同 lambda 内先虚线再描边图形不再串色。
  • annotatedText == null 守卫丢失顶层 TextDecoration:✅ 守卫去掉,整段下划线恢复,span 级装饰仍走 applyAnnotatedString
  • DrawModifier 4dp padding 死注释/死变量:✅ 清理完毕,并补了 z 序限制 kdoc。
  • build-profile.json5 误入 .gitignore:✅ 文件重新入库(空 signingConfigs,与 origin/main 一致),本地含密字段用 skip-worktree 保护。
  • iOS placeholder NSWritingDirectionAttributeName 误删:✅ 已恢复。
  • brush 重载 pathEffect 参数收而不用:⚠️ color 重载那行已打开生效,brush 重载按 commit 声明暂缓(未来增强);签名仍带该参数但赋值行仍是注释,调用方传 dash 不会生效,后续启用时一并打开或先去参数避免误导。

这一笔没有新引入风险:三端 native 桥接、Compose API、demo/工程文件均在合理范围内,注释与实现已对齐。整体看 PR 已收敛,可以合入。

nikazhao added 3 commits July 28, 2026 20:56
三份 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).
@nikazhao
nikazhao force-pushed the feat/kuikly-compose-drawbehind-pathEffect-pr_2 branch from c658a5c to 7d8b7ca Compare July 28, 2026 13:05

@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

增量盘点(HEAD 7d8b7ca4

这次 push 把之前几笔提交 rebase 到了新 hash(e0937f1d / a2c69f12 / 7d8b7ca4,commit date 都重写成 07-28 20:56 +0800),最新一笔是 Round 2 CR fixup,内容上是上一轮问题的修复 + 性能/精度优化,没有新增功能面。

上轮问题处理情况

上一轮 8 条 inline 已逐条跟进过:endIndex 改回 exclusive、fillOrStroke 清 dash、annotatedText == null 守卫回滚、4dp 死注释清理、build-profile.json5 重新入库、iOS placeholder LTR override 恢复——这 7 条都已落地。剩 CanvasDrawScope brush 重载的 pathEffect 赋值仍注释着,按作者声明属未来增强(需三端 shader+dash 验证)暂缓,这点保持现状,后续启用 brush 虚线时一并打开或先去掉参数即可。

本笔新改动评估

  • P1-2 精度对齐:Android getLineMetrics / getCharBoundingBoxtoDpI(取整)改 toDpFRectRectF,三端统一到浮点口径,最多 0.5dp 的几何漂移消除;compose 侧 getBoundingBoxFntoFloatOrNull 解析,能正确吃浮点串,端到端一致。
  • P2-1 行度量惰性化callMethod("lineMetrics") 移出 measure 热路径、收进 MultiParagraph.lineMetricsFn,首次访问 getLineTop/getLineStart/lineCount 才触发一次桥调用并 lazy 缓存。getLineEnd 的旧格式兼容 fallback(lineEnds 缺失走下一行 start / 末行退化空区间)语义保留,lineMetrics == null(CoreTextField 无行度量场景)退化为 0,与旧空数组行为一致,没引入回归。CoreTextField 走位置参数 MultiParagraph(lineCount, placeholderRects),第一个参数现在落到 initialLineCount,签名兼容。
  • P2-2 dashActive 脏标记KuiklyCanvas 引入 dashActivedrawLine/fillOrStroke 仅在上一笔真设过 dash 时才下发 setLineDash(emptyList())view 重置时同步复位。所有 stroke 入口(drawRect/drawOval/drawCircle/drawArc/drawPath/drawPoints)都走 fillOrStroke,drawPoints 经 fillOrStroke、drawLines 经 drawLine,防串色修复保留的同时去掉了密集 Canvas 页每图形一次的桥开销。
  • P1-1 license 头PathEffect.kt / PathEffectTest.kt / DrawBehindDemo.kt 三个新文件已补 Tencent 开源 license 头。

没有发现新引入的功能正确性或二进制兼容问题,这一轮的优化改动是干净的。CanvasDrawScope brush 重载的 pathEffect 仍是悬而未决项,留意后续启用时处理。

@luoyibu

luoyibu commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

性能侧完整看了下这次 TextStringRichNodeTextLayoutResult/MultiParagraphdrawBehind 和 iOS Render 的改动。

先说结论:最新 head 已经把 lineMetrics 从无条件 measure 热路径移成 lazy,因此普通 Text 每次 measure 不会固定多一次同步 Native 调用;但在业务实际使用新增 API 和文本虚线时,仍然存在同步查询次数过多、Canvas 指令未批处理、额外 Native View、以及每次 measure 新建缓存导致无法复用等风险。

建议区分下面几种具体场景:

场景 新增同步 Native 调用 其他开销
普通 Text,不读取新增行度量 API,也不用 drawBehind 0 每次 measure 新增几个 lambda/lazy 对象
onTextLayout 中读取 lineCount/getLineTop/getLineStart 可能每次 measure 1 次 lineMetrics 同步调用 iOS 遍历全部行并进行字符串序列化
draw 中调用 getBoundingBox 每调用一次就是 1 次同步 Shadow 调用 iOS 每次做 NSLayoutManager glyph/rect 查询和字符串解析
Text 等非 Canvas 宿主使用 drawBehind Canvas View 方法本身是异步排入 UI Scheduler 每个宿主增加 1 个 Native CanvasView;每次 draw 下发多条未 batch 的 Canvas 命令

1. getBoundingBox 是逐次同步 Native 查询,当前没有缓存

TextStringRichNode 新增的实现中,每次:

result.getBoundingBox(offset)

最终都会走:

tv.shadow?.callMethod("getBoundingBox", offset.toString())

这不是普通本地 getter,而是 BridgeManager.CALL_SHADOW_METHOD。在 iOS 上该方法属于同步 Native 调用:

Compose draw / onTextLayout
  -> TextLayoutResult.getBoundingBox
  -> MultiParagraph.getBoundingBox
  -> Shadow.callMethod
  -> BridgeManager.callShadowMethod
  -> KuiklyRenderCore CALL_SHADOW_METHOD(同步)
  -> KRRichTextView css_boundingBoxWithParams
  -> 返回 NSString
  -> Kotlin split + Float 解析

iOS 这里不会 dispatch_sync(main),而是直接在 Context Thread 上执行,因此没有新增主线程同步等待;但调用期间会阻塞当前 Compose/Context 执行链。

每次查询在 iOS 端还会执行:

  • glyphRangeForCharacterRange
  • enumerateEnclosingRectsForGlyphRange
  • 必要时 boundingRectForCharacterRange
  • lineFragmentRectForGlyphAtIndex
  • stringWithFormat

Kotlin 侧随后还有 split(" ")、List 分配和 4 次浮点解析。

PR demo 中的具体触发量

当前 DashedUnderlineMultiSpandrawBehind 内对每个 range 查询首尾字符:

val startBox = result.getBoundingBox(range.first)
val endBox = result.getBoundingBox(range.last)
  • 单行头/中/尾 3 段:一次 redraw 为 6 次同步 Native 查询
  • 五行示例处理第 1/3/5 行,每行头/中/尾 3 段,共 9 段:一次 redraw 为 18 次同步 Native 查询

只要 snapshot state、动画、列表复用、尺寸变化或其他 invalidateDraw 导致重绘,就会重复这些同步查询。即使文字和 offset 完全没有变化,也不会命中缓存。

建议至少在单个 MultiParagraph/TextLayoutResult 生命周期内按 offset 缓存:

offset -> Rect

更推荐增加批量接口,例如一次传入所有 endpoint/range,由 Native 一次返回所有字符矩形,从 2N 次同步桥调用降到 1 次。

2. lineMetrics lazy 不代表一定离开 measure 热路径

当前改成:

private val lineMetrics by lazy {
    lineMetricsFn?.invoke()
}

确实解决了“业务完全不使用行度量时,每次 measure 固定多一次调用”的问题。但如果业务在常见的 onTextLayout 回调中读取:

onTextLayout = { result ->
    val count = result.lineCount
    val top = result.getLineTop(0)
}

调用链仍然发生在 TextStringRichNode.measure() 内:

measure
  -> calculateRenderViewSize(原本已有的同步测量)
  -> genTextLayoutResult
  -> onTextLayout(result)
  -> result.lineCount / getLineTop
  -> lazy lineMetricsFn
  -> shadow.callMethod("lineMetrics")(新增同步调用)

所以更准确的结论应该是:

普通 measure 不自动新增调用;一旦在 onTextLayout 内读取新增行度量 getter,就会在 measure 调用栈中新增一次同步 Native 查询。

另外,每次 measure 都会重新创建 TextLayoutResultMultiParagraph,lazy 缓存仅属于本次结果,无法跨 measure 复用。当前 TextLayoutResult.equals 又会比较 multiParagraph 引用,新建的 MultiParagraph 默认不相等,因此 onTextLayout 很容易每次 measure 都执行;业务一旦读取 lineCount,就可能退化成“每次 measure 一次 lineMetrics”。

建议:

  • 按原生文本布局 generation/version 缓存行度量
  • 或在已有 calculateRenderViewSize 阶段保存行度量结果,后续 getter 只读取
  • 至少补一个“重复 measure + onTextLayout { it.lineCount }”用例,统计 CALL_SHADOW_METHOD 次数

3. iOS lineMetrics 单次调用也是 O(行数) + 字符串序列化

iOS css_lineMetrics 每次会:

  • glyphRangeForTextContainer
  • enumerateLineFragmentsForGlyphRange 遍历全部行
  • 每行 characterRangeForGlyphRange
  • 每行向 NSMutableString 执行 appendFormat
  • 最后再通过 stringWithFormat 拼接行数和完整结果
  • Kotlin 侧再 split 并创建 4 个数组

单次调用本身没有跨主线程,但长文本、多行文本在 Context Thread 上执行时仍可能拉长 measure/draw 所在帧的执行时间。缓存不能只放在每次新建的 MultiParagraph 中,否则重复 measure 仍会重复全部工作。

4. 非 Canvas 宿主的 drawBehind 会增加 Native View 数量

这次为了让 Text、Image、Row、Column、LazyColumn、TextField 等非 Canvas 宿主支持 drawBehind,会在宿主父容器中额外挂一个 CanvasView

parent.addChild(bg, ...)
parent.insertDomSubView(bg, 0)

这个 View 是 modifier 节点级缓存,因此不会每次 draw 都新建;但每个使用该能力的宿主都会增加一个 Core View 和一个 Native CanvasView。

具体场景:

  • LazyColumn 可见区域有 20 个带虚线分隔线的 Text/Box:会额外存在约 20 个 CanvasView
  • 滚动复用、节点 detach/attach 时还会发生创建、插入、删除
  • TextField/Switch/Slider 等组件使用 drawBehind 时同样会增加 sibling CanvasView

这不一定单独构成问题,但需要评估 Native View 层级、列表滚动创建/回收和内存开销,不能只验证视觉结果。

5. 背景 Canvas 每次 draw 的指令没有走 batch

drawIntoBackgroundCanvasView 每次 draw 当前都会:

  1. 无条件 bgRender.setFrame(...)
  2. 新建 KuiklyCanvas
  3. bgCanvas.view = bg,触发一次 reset
  4. 执行业务 onDraw

这里 KuiklyCanvas 内部创建的 CanvasContext.batchDraw 默认是 false,并且该路径没有调用 flush(),因此每条 Canvas 操作都会单独执行一次 Native View bridge。

一条虚线大致会下发:

lineDash
beginPath
moveTo
lineTo
strokeStyle
lineWidth
stroke

即约 7 条 Native Canvas 命令。

具体数量

  • 单段虚线:约 2 次同步 bounding-box 查询 + setFrame/reset + 7 条 Canvas 命令
  • 单行头/中/尾 3 段:6 次同步查询 + 约 23 条 Native View/Canvas 命令
  • 五行 9 段:18 次同步查询 + 约 65 条 Native View/Canvas 命令

iOS 的 View/Canvas 方法不是同步切主线程,而是由 KuiklyRenderUIScheduler 排入主线程任务集合;但每条指令仍会经历:

  • Kotlin → Native bridge
  • 参数/JSON 对象创建
  • UI task block 创建
  • 主线程方法分发
  • Native Canvas action 创建

UIScheduler 的“批量执行主线程任务”不等于 Canvas 指令已经合并,65 个 task/action 仍然存在。

建议:

  • 背景 Canvas 强制使用 CanvasContext.batchDraw = true
  • draw 完成后只下发一次 batchDraw
  • 复用 KuiklyCanvas/CanvasContext,避免每次 draw 新建
  • 对比 bgRender.currentFrame,尺寸和位置没变化时跳过 setFrame
  • 相同 DashPathEffect 连续绘制时避免重复 setLineDash

目前 dashActive 只避免“从虚线切回实线时无条件清空”的调用,并不能避免 9 条相同虚线各自重复下发 setLineDash

另外:

intervals.map { it / densityValue }.toList()

map 已经返回 List,再调用 toList() 会额外复制一次;该分配发生在每条虚线 draw 中,也建议去掉或缓存转换结果。

6. 普通 Text 虽然零新增通信,但每次 measure 仍增加分配

即使业务完全不访问新 getter,genTextLayoutResult 每次仍会创建:

  • lineMetricsFn
  • getBoundingBoxFn
  • lazy initializer
  • SYNCHRONIZED lazy 状态对象

这部分没有 Native 通信,单个 Text 开销不大,但在 LazyColumn 密集文本、频繁 measure 的场景会增加短生命周期对象和 GC 压力。

measure、getter 和 draw 按当前线程模型都在 Context Thread 上执行,可以考虑手写 nullable cache,避免 LazyThreadSafetyMode.SYNCHRONIZED。如果确实存在跨线程访问,应明确证明并补线程安全说明。

iOS Render 总结

优点:

  • 没有看到新增 dispatch_sync(main) 或主线程锁
  • Shadow 查询保持在 Context Thread
  • NSLayoutManager 数据来源与文本测量一致
  • RTL/ligature 使用 enclosing rect 的方向是合理的

风险:

  • lineMetrics/getBoundingBox 都是同步阻塞 Context Thread
  • lineMetrics 为 O(行数)
  • getBoundingBox 每字符一次 NSLayoutManager 查询
  • 返回值使用字符串协议,Native/Kotlin 两侧都有序列化、split 和解析分配
  • 缓存生命周期只跟随每次新建的 MultiParagraph
  • 多段虚线 draw 会放大同步调用次数

建议的合入前检查

我建议至少先处理下面两个问题:

  1. getBoundingBox 增加缓存或批量查询,避免每段虚线产生 2 次同步桥调用
  2. 非 Canvas drawBehind 使用 batchDraw,避免每条 Canvas 指令独立下发

并补几组性能数据:

  • 100 个普通 Text 重复 measure:确认新增 CALL_SHADOW_METHOD = 0
  • onTextLayout { it.lineCount } 重复 measure:统计 lineMetrics 次数和耗时
  • LazyColumn 可见 20 个带虚线分隔线的 item:统计额外 Native View 数量
  • 五行 9 段虚线持续 invalidateDraw:统计每帧同步 Shadow 调用数、Canvas View 调用数
  • iOS Time Profiler:关注 css_lineMetricscss_boundingBoxWithParams、字符串格式化以及 UIScheduler 主线程任务数量

综合判断:

  • 普通 Text 场景没有新增固定同步通信,风险较低
  • 读取行度量的 onTextLayout 场景可能每次 measure 新增一次同步调用
  • 多段文本虚线场景会在每次 redraw 产生 2N 次同步 bounding-box 查询和大量未 batch 的 Canvas 命令,属于当前最需要优化的路径

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.

3 participants