本文记录
dotsindicator接入 OpenHarmony 的完整过程,覆盖 UI 库 API 面盘点、适配路线选型(纯 ArkTS 重写 vs KMP/Native)、四种 Compose 指示器类型的数学逐行移植、经典 View 组件的 SpringForce 弹簧物理数值积分、ArkUI Canvas 逐帧驱动、Swiper 手势同步模式、HAP 命令行构建和真机验收。
dotsindicator是 Android 上最流行的分页圆点指示器库(上游5.1.1,Apache-2.0):同时提供 Jetpack Compose API (ShiftIndicatorType/SpringIndicatorType/WormIndicatorType/BalloonIndicatorType四种类型)与经典 XML View API (DotsIndicator/SpringDotsIndicator/WormDotsIndicator三个自定义 View),通过attachTo(viewPager)与ViewPager/ViewPager2/ ComposePagerState联动。本次适配与社区此前的 KMP 路线(qrose / KStore 的"Kotlin/Native 真编译 + C ABI/N-API 桥接")有本质区别:dotsindicator 是纯 UI 库 ,全部实现站在 AndroidX View / Compose / dynamicanimation 之上,没有可跨平台复用的算法内核------因此采用 oh-tpc 对 View 类库的标准路径:纯 ArkTS / ArkUI Canvas 重写 ,无 NAPI、无 C++、零原生依赖。"复用"的对象从二进制换成了动画数学 :四种 Compose 类型的公式逐行移植自上游 Kotlin,蠕虫拉伸行为对照上游 Roborazzi 黄金截图验证;经典组件的弹簧手感用 AndroidXSpringForce(stiffness 300 / dampingRatio 0.5)的数值积分器复现。
项目地址: AtomGit/oh-tpc/dotsindicator.git
上游仓库: tommybuonomo/dotsindicator
开发工具: 华为云码道
环境安装: 环境安装指南
一、背景
1.1 为什么做 OpenHarmony 平台适配
dotsindicator 在 Android 上回答"分页圆点指示器"这一件事:XML 里声明一个 View、attachTo(viewPager) 一行完成绑定,选中点加宽、弹簧过冲、蠕虫爬动、气球缩放四种手感开箱即用;Compose 侧则是无状态的 DotsIndicator(dotCount, type, pagerState),由 PagerState.currentPage + currentPageOffsetFraction 逐帧驱动。核心数学都藏在 computeGlobalScrollOffset() 与各类型的形变公式里,上游还配了 JVM 单测与 32 张 Roborazzi 黄金截图钉住行为。
OpenHarmony 的 Swiper 自带圆点指示器,但只有"当前第几个"的状态显示,没有上游这套形变动画语言。鸿蒙应用想要同样的手感,就要把这套随滚动偏移连续形变的数学搬到 ArkUI 上。而上游构建(AGP 9.2.1 / Kotlin 2.4.0 / AndroidX)在鸿蒙工具链上没有可编译的落点:
| 障碍 | 具体问题 |
|---|---|
| 体系不可编译 | 经典组件继承 FrameLayout、内部用 ImageView + GradientDrawable;Compose 版依赖 androidx.compose.foundation------两套 API 都无法编译到 ohos 目标。 |
| 驱动模型不同 | Compose 的 PagerState.currentPageOffsetFraction 在 ArkUI 没有直接等价物,Swiper 只暴露离散事件(onChange / onGestureSwipe)。 |
| 动画机制不同 | 经典组件用 AndroidX SpringAnimation(dynamicanimation);ArkUI 的 animateTo 只对组件属性插值,驱动不了 Canvas。 |
| 颜色插值缺失 | 经典 DotsIndicator 的选中色过渡依赖 ArgbEvaluator,ArkTS 侧需要自行实现。 |
| 绑定模型不同 | attachTo(viewPager) 的 attacher 抽象(ViewPager / ViewPager2 / RecyclerView 桥接)在 ArkUI 无对应物。 |
| 交付链路独立 | HAR 库模块、demo HAP、hvigor 构建、签名安装都要单独验收。 |
因此,本项目把边界放在:四种 Compose 类型数学的逐行移植、经典组件弹簧物理的数值复现、Canvas 逐帧驱动器与 Swiper 同步模式。视觉行为的"正确性"完全由上游公式负责,ArkTS 只负责把公式画出来。
1.2 库提供的能力
适配后的 HAR 库 dotsindicator 对外提供两组组件 + 一层工具:
DotsIndicator(Compose 等价组件,无状态) :入参dotCount/dotSpacing/type/currentPage/pageOffset/onDotClicked,内部 Canvas 绘制,@Prop @Watch逐帧重绘------对应上游 Compose 的 stateless 重载;- 四种
IndicatorType:ShiftIndicatorType(离滚动位置最近的点加宽成药丸,默认 3 倍;吸收了经典DotsIndicator的selectedDotColor颜色插值与progressMode进度模式,factor 2.5 即经典复刻)、SpringIndicatorType(前景点线性跟随全局偏移)、WormIndicatorType(蠕虫药丸前半程拉伸跨两点、后半程收缩落位)、BalloonIndicatorType(最近点按 1.5 倍缩放); - 经典 View 等价组件 :
SpringDotsIndicator(空心圆点 + 弹簧过冲的实心前景点)、WormDotsIndicator(空心圆点 + 两阶段爬动的实心药丸),暴露stiffness/dampingRatio属性,与上游 XML attrs 一一对应; - 工具层全导出 :
computeGlobalScrollOffset/clamp01/ColorUtils(ArgbEvaluator 等效)/DrawingUtils/Spring(SpringForce 积分器)/FrameDriver(animator 逐帧驱动器)。
Demo 页两张卡片复刻上游 sample:卡片一是 Compose 风格四种类型同屏演示(Swiper 手势逐帧驱动 + 添加/删除页面按钮 + 点击圆点跳页),卡片二是经典风格三种指示器(加宽 progressMode / Spring / Worm)。
1.3 实现适配
| 维度 | 要求 |
|---|---|
| 数学复用 | computeGlobalScrollOffset 与四种类型形变公式逐行移植自上游 Kotlin,蠕虫拉伸行为对照上游黄金截图(worm_ltr_p0_o05.png)验证;上游 Android 源码零改动。 |
| 物理复用 | 经典组件弹簧用 AndroidX SpringForce 语义的数值积分(半隐式欧拉、240Hz 子步),参数与上游默认值一致(stiffness 300 / dampingRatio 0.5 与 1.0)。 |
| 工程共存 | 上游 Gradle 工程原样保留照常构建;鸿蒙工程独立放在 example/harmonyApp/(cmp_qrose 审核通过的目录规则),DevEco 只打开该子目录。 |
| 逐帧驱动 | Canvas 重绘不走 animateTo,统一由 animator 模块的 FrameDriver 回调驱动,动画收敛即停帧。 |
| UI 完整 | 单页 demo 覆盖全部七种指示器形态与全部交互(手势、松手补间、点击跳页、增删页面)。 |
| 仓库规范 | 项目说明、文章、效果图和源码链接统一使用 AtomGit。 |
本项目的 ArkUI 页面不是 Compose Multiplatform UI 实现,也没有任何 Kotlin/Native 成分------dotsindicator 没有跨平台内核可保,适配面就是 UI 本身;上游保真度靠"公式照抄 + 黄金截图对照 + 弹簧参数一致"三件事保证。
二、实现路线图
text
第 1 阶段:项目初始化 ── 盘点两套 API 的数学与物理边界、确定纯 ArkTS 重写路线、确定交付物(HAR + demo HAP)
第 2 阶段:工程骨架 ── cmp_qrose 模板落地:AppScope / dotsindicator(HAR) / entry / hvigor 三件套
第 3 阶段:Compose 数学 ── computeGlobalScrollOffset + 四种类型形变公式逐行移植、黄金截图对照
第 4 阶段:经典弹簧物理 ── SpringForce 数值积分器、两阶段蠕虫状态机、animator 逐帧驱动
第 5 阶段:ArkUI 宿主 ── Swiper 手势同步模式(onGestureSwipe / onChange / 补间)、demo 双卡片
第 6 阶段:构建与验证 ── hvigor 命令行构建、编译错误修复、真机安装运行与功能验收、效果图
三、逐步实现过程
第 1 阶段:项目初始化
1.1 工程边界
text
viewpagerdotsindicator/ 上游库模块(Gradle,原样保留,零改动)
viewpagerdotsindicator-sample/ 上游 Android demo(原样保留)
example/harmonyApp/ DevEco Stage 工程(本次新增)
├── AppScope/app.json5 bundle org.terminator.ohos.dotsindicator、versionName 5.1.1-ohos.1
├── dotsindicator/ HAR 库模块(模块名 = oh-package 名 = dotsindicator)
│ └── src/main/ets/ 组件、类型类、模型与工具层
├── entry/ demo HAP
├── build-profile.json5 / hvigor三件套 compatibleSdkVersion 5.1.0(18)、modelVersion 5.1.0
README.md / README.OpenHarmony.md 鸿蒙门户与英文摘要(重写/新增)
README.OpenHarmony_CN.md 鸿蒙适配主文档(665 行 / 22 节)
README.upstream.md 上游原文保存
鸿蒙工程完全照搬 cmp_qrose 已审核通过的目录规则:signingConfigs 留空数组 + product 引用 "default"(自动签名由 DevEco 勾选后回填,仓库不落盘任何签名材料);runtimeOS: "HarmonyOS";stageMode;HAR 模块与 entry 模块的 targets 分开声明。上游 Gradle 工程一根手指都没碰------./gradlew :viewpagerdotsindicator:assembleRelease 照常可用,两套构建互不感知。
1.2 工具链矩阵
| 项目 | 配置 |
|---|---|
| DevEco Studio | Windows 版(自带 node v24.14.1、hvigor、ohpm、JBR 25) |
| SDK | HarmonyOS 26(API 26),目标 compatibleSdkVersion 5.1.0(18) |
| hvigor | modelVersion 5.1.0(@ohos/hvigor-ohos-plugin) |
| 语言 | ArkTS(strict),UI 为 ArkUI 声明式 + Canvas 2D |
| Bundle | org.terminator.ohos.dotsindicator,vendor oh-tpc |
| 版本 | 5.1.1-ohos.1(上游版本 + -ohos.N 后缀,cmp_qrose 版本约定) |
| 许可 | Apache-2.0(随上游,LICENSE 原样保留) |
1.3 源码依赖度盘点
| 上游模块 | 文件 | 依赖形态 | 处置 |
|---|---|---|---|
| 经典 View(5 个类 + attacher 4 个类) | ~1200 行 | FrameLayout / ImageView / GradientDrawable / SpringAnimation / ViewPager(2) |
公式与参数移植,View 体系不复用 |
Compose(DotsIndicator + 4 type + model) |
~450 行 | LazyRow / PagerState / Modifier |
数学逐行移植到 Canvas 绘制 |
attrs.xml |
14 个自定义属性 | XML styleable | 映射为 ArkTS 组件属性 |
两套 API 的"可移植内核"清单在盘点阶段就明确了:computeGlobalScrollOffset()、四个形变公式(shift 宽度、spring 位置、worm padding、balloon 缩放)、ArgbEvaluator 色彩插值、SpringForce 物理参数、progressMode 的着色规则。其余一切(布局、事件、绘制载体)都换 ArkUI 原生。
第 2 阶段:Compose 数学移植(保真核心)
2.1 全局滚动偏移
上游一切形变都是 globalOffset 的函数,先把这个 internal 函数原样搬过来:
typescript
// util/ScrollOffset.ets ------ 逐行移植自 compose/DotsIndicator.kt
export function computeGlobalScrollOffset(position: number, positionOffset: number,
totalCount: number): number {
let offset = position + positionOffset;
const lastPageIndex = totalCount - 1;
if (offset === lastPageIndex) {
offset = lastPageIndex - 0.0001;
}
const leftPosition = Math.floor(offset);
const rightPosition = leftPosition + 1;
if (rightPosition > lastPageIndex || leftPosition < 0) {
return 0;
}
return leftPosition + (offset % 1);
}
pageOffset 语义与 Compose PagerState.currentPageOffsetFraction 对齐:正数朝下一页、负数朝上一页。四个类型的绘制函数全部消费这一个值。
2.2 四种形变公式
| 类型 | 上游公式(Kotlin) | ArkTS 移植 |
|---|---|---|
| Shift | `diffFactor = 1 - min(1, | i - offset |
| Spring | x = firstX + (lastX - firstX) / (count - 1) * offset |
drawSpring() 前景点线性落位,无物理 |
| Worm | startPad = d * clamp01((frac-0.5)*2);endPad = d * clamp01(1-frac*2);boxW = d + size |
drawWorm():pillLeft = anchor - size/2 + startPad,pillW = boxW - startPad - endPad------前半程右缘伸向下一页、后半程左缘追上来 |
| Balloon | scale = (size + max(0, factor-1) * size * diffFactor) / size |
drawBalloon() 以中心缩放画圆 |
Worm 的 padding 数学最容易抄错(药丸在盒内随 padding 进退,Compose 的 width + padding 修饰符链语义并不直观)。本次直接调出上游仓库自带的 Roborazzi 黄金截图 worm_ltr_p0_o05.png(offset 0.5、灰底蓝虫)人工比对:移植版在同一 offset 画出"左缘锚在第一点、右缘伸到两点中间"的拉伸药丸,与黄金图一致------这一步是"数学没抄错"的直接证据。
2.3 颜色插值
经典 DotsIndicator 滚动时选中色与普通色双向过渡,上游用 ArgbEvaluator。ArkTS 侧补一个等效工具(颜色先归一到 0xAARRGGBB 数字再逐通道线性插值):
typescript
static lerpArgb(from: number, to: number, fraction: number): number {
const t = Math.min(1, Math.max(0, fraction));
const a = Math.round(lerp((from >>> 24) & 0xFF, (to >>> 24) & 0xFF, t));
// R/G/B 同理
return (a << 24) | (r << 16) | (g << 8) | b;
}
ColorUtils.toArgb 接受 0xAARRGGBB 数字、Color.* 枚举与 '#RRGGBB' / '#AARRGGBB' 字符串;$r 资源引用能填充但进不了插值通道(已知限制,见 8.2)。
第 3 阶段:经典弹簧物理(技术难点)
3.1 为什么不直接用 curves.springMotion
ArkUI 自带 springMotion(response, dampingFraction),但它的参数是 iOS 风格的响应时间/阻尼分数,与 AndroidX 的 stiffness / dampingRatio 没有解析换算。经典组件的"手感"正是由这两个参数定义的(Spring 过冲来自 dampingRatio 0.5、Worm 的干脆来自临界阻尼 1.0),于是自己写积分器,参数语义与上游完全一致:
typescript
// util/Spring.ets ------ AndroidX SpringForce 语义(mass = 1)
step(dtSeconds: number): void {
const w0 = Math.sqrt(this.stiffnessValue); // ω
const damping = 2 * this.dampingRatioValue * w0; // c = 2ζω
let remaining = Math.min(Math.max(dtSeconds, 0), 0.064);
const maxStep = 1 / 240; // 240Hz 子步保稳定
while (remaining > 1e-6) {
const dt = Math.min(maxStep, remaining);
const x = this.position - this.targetPos;
const acceleration = -this.stiffnessValue * x - damping * this.velocity;
this.velocity += acceleration * dt; // 半隐式欧拉
this.position += this.velocity * dt;
remaining -= dt;
}
}
3.2 两阶段蠕虫状态机
上游 WormDotsIndicator 由滚动事件连续驱动两个弹簧(TRANSLATION_X 与宽度,均为临界阻尼):offset 0.1--0.9 时宽度目标为"跨两点"、>0.9 时锚点切换到目标点并缩回。鸿蒙侧拿到的是离散的页变更事件,用状态机复现同一节奏:
text
页变更 Δ=±1: GROW xTarget=左点 wTarget=跨距+size
wSpring 收敛后 → SHRINK xTarget=右点 wTarget=size
页变更 |Δ|>1: SLIDE 直接滑到目标点,宽度保持 size
SpringDotsIndicator 则简单得多:一个弹簧追目标点横坐标,dampingRatio 0.5 的过冲肉眼可见。
第 4 阶段:Canvas 逐帧驱动(本次适配最大的坑)
4.1 animateTo 驱动不了 Canvas
第一版实现想走捷径:animateTo({ curve: curves.springMotion(...) }, () => { this.selectorX = target }),指望 @Watch('redraw') 每帧触发重绘。实际语义是:animateTo 只对组件属性插值,@State 在闭包里一次性赋成终值,@Watch 只触发一次 ------Canvas 直接跳到终点,没有任何动画。这是 ArkUI 声明式动画与保留式 Canvas 的根本分界,官方 Canvas 动画示例全部用 animator 模块就是这个原因。
4.2 FrameDriver
按官方模式封装一个逐帧驱动器:无限循环的 animator,onFrame 里用墙钟差分出 dt,喂给弹簧积分器并重绘画布;动画收敛即 pause(),空闲时零帧消耗:
typescript
// util/FrameDriver.ets
start(uiContext: UIContext): void {
if (this.animatorResult === null) {
const options: AnimatorOptions = {
duration: 16, easing: 'linear', delay: 0,
fill: 'none', direction: 'normal',
iterations: -1, begin: 0, end: 1
};
const created: AnimatorResult = uiContext.createAnimator(options);
created.onFrame = (_progress: number) => {
const now = Date.now();
const dtMs = this.lastTs === 0 ? 16 : Math.min(64, now - this.lastTs);
this.lastTs = now;
this.onTick(dtMs / 1000); // → spring.step(dt); draw(); settled 则 stop
};
this.animatorResult = created;
}
this.lastTs = 0;
this.animatorResult.play();
}
三个编译期教训(都进了文档踩坑表):animator 不从 @kit.ArkUI 导出,要从 @ohos.animator 默认导入;AnimatorOptions.fill / direction 是字符串字面量联合类型而非枚举;AnimatorResult 没有 stop(),用 pause()。
4.3 绘制与命中
所有形状统一为"药丸"原语:宽 ≥ 高时两段半圆弧 + 两条直线(半径恒为半高),宽 = 高时退化为圆------正是上游 GradientDrawable 圆角矩形 / Compose CircleShape 的画法。描边点(经典空心圆)走 arc + stroke,borderWidth 溢出量计入画布内边距防裁切。点击命中自行实现:最近点距离 ≤ (size + spacing) / 2 + 2 判中,回调 onDotClicked(index)。
第 5 阶段:ArkUI 宿主与 Swiper 同步模式
5.1 手势逐帧 + 松手补间
Compose 的 PagerState 让指示器天然跟手;ArkUI Swiper 只给离散事件,同步模式要自己拼:
typescript
.onAreaChange((_o: Area, n: Area) => { this.swiperWidth = this.widthToVp(n.width); })
.onGestureSwipe((index: number, info: SwiperAnimationEvent) => { // 跟手:逐帧
this.rampActive = false;
this.rampDriver?.stop();
this.composeCurrent = index;
this.composeFraction = SWIPE_OFFSET_SIGN * info.currentOffset / Math.max(1, this.swiperWidth);
})
.onChange((index: number) => { this.settleCompose(index); }) // 松手:补间到目标页
.onAnimationEnd((index: number) => { // 未过阈值:弹回
if (!this.rampActive && index === this.composeCurrent
&& Math.abs(this.composeFraction) > 0.001) {
this.settleCompose(index);
}
})
settleCompose() 用同一个 FrameDriver 跑 easeOut 立方补间:fraction 从手势结束值滑向 index - currentPage(整数差),到位后 currentPage = index; fraction = 0 锚点无缝切换。点击圆点跳页走 swiperController.changeIndex(index, true),复用同一条补间路径。这套模式直接可抄(demo 与 README.OpenHarmony_CN.md 第 9 节均有完整代码)。
5.2 经典组件的"attachTo 等价用法"
上游 attachTo(viewPager) 在 ArkUI 的等价物就是把 currentPage 绑到 Swiper.onChange------经典组件内部自己管弹簧动画,宿主只喂页码:
typescript
SpringDotsIndicator({
dotCount: this.classicColors.length,
currentPage: this.classicCurrent, // ← Swiper onChange 回写
dotColor: '#FF285EA7',
stiffness: 300, dampingRatio: 0.5, // ← 上游 XML attrs 同名参数
onDotClicked: (i: number) => this.swiperB.changeIndex(i, true)
})
5.3 Demo 页结构
text
标题区 Pager Dots Indicator / OpenHarmony 适配 · 5.1.1-ohos.1
卡片一 Compose 风格:Swiper(彩色页 + 页码)+ Shift / Spring / Worm / Balloon 四行指示器
+ 添加 / 删除页面按钮(1--10 页动态增删,指示器实时重排)
卡片二 经典 View 风格:Swiper + Default(加宽 + progressMode 蓝色已读)
/ Spring(空心圆 + 过冲实心点)/ Worm(空心圆 + 两阶段药丸)
页脚 指向 README.OpenHarmony_CN.md 的说明行
第 6 阶段:构建与验证
6.1 命令行构建(本机跑通)
bash
cd example/harmonyApp
ohpm install
export DEVECO_SDK_HOME='<DevEco>/sdk'
export JAVA_HOME='<DevEco>/jbr'
export PATH="<DevEco>/jbr/bin:<DevEco>/tools/node:$PATH"
node "<DevEco>/tools/hvigor/bin/hvigorw.js" \
assembleHap -p product=default -p buildMode=debug --no-daemon
产物 entry/build/default/outputs/default/entry-default-unsigned.hap,约 228KB,内含 ets/modules.abc(纯 ArkTS,无原生库,真机 x86_64 与真机 arm64 通吃)。
6.2 编译错误修复实录
首编 11 个错误、二编 5 个、三编 3 个、四编归零------全部是 ArkTS 严格模式的规范问题,逐一修复并沉淀进踩坑表:
| 轮次 | 错误 | 修复 |
|---|---|---|
| 第 1 轮 | @Builder 方法带闭包参数:content() does not meet UI component syntax |
改为 @Component struct IndicatorRow + @BuilderParam content,调用侧尾随闭包 |
| 第 1 轮 | Area.width 是 Length(string | number 联合)不能赋给 number |
widthToVp() 做typeof 收窄 |
| 第 1 轮 | 'animator' is not exported from Kit '@kit.ArkUI' |
从 @ohos.animator 默认导入 Animator 类 |
| 第 2 轮 | AnimatorOptions 缺 fill / direction |
补 'none' / 'normal' 字符串字面量 |
| 第 2 轮 | ResourceColor 不能传 `string |
number` 形参 |
| 第 2 轮 | AnimatorResult 无 stop 方法 |
用 pause();同时弃用已废弃的 Animator.create 静态方法,改 uiContext.createAnimator |
| 第 3 轮 | spawn java ENOENT(PackageHap) |
打包工具从 PATH 找 java:把 DevEco 自带 JBR 的 bin 加进 PATH(仅设 JAVA_HOME 不够) |
另有三处告警属预期:未显式配置 targetSdkVersion(与 cmp_qrose 一致)、版本号 5.1.1-ohos.1 触发 ohpm SemVer 检查告警(实为合法 semver 预发布段)、未签名提示(签名交给 DevEco 自动生成)。
6.3 真机验收
text
hdc install -r entry-default-unsigned.hap → install bundle successfully
hdc shell aa start -b org.terminator.ohos.dotsindicator -a EntryAbility
→ start ability successfully
hilog | grep DotsIndicatorDemo → onCreate / onForeground /
Succeeded in loading the content
随后用 uinput 注入手势做了四轮功能验收(详见 6.4),全程 hilog 零 crash / fault 记录。
四、完整代码对照
4.1 整体架构
text
上游 compose/DotsIndicator.kt + type/*.kt(数学)
→ computeGlobalScrollOffset + 四个形变公式(逐行移植)
DotsIndicator.ets(@Component,Canvas 绘制)
→ @Prop @Watch(dotCount/currentPage/pageOffset) → draw()
上游 SpringDotsIndicator.kt / WormDotsIndicator.kt(物理)
→ SpringForce 参数(stiffness/dampingRatio)+ 弹簧积分器 + 两阶段状态机
SpringDotsIndicator.ets / WormDotsIndicator.ets
→ @Watch(currentPage) → spring.setTarget → FrameDriver 逐帧
Swiper(宿主)
→ onGestureSwipe 跟手 / onChange+补间 / onAnimationEnd 弹回
▼
ArkUI 真机 / 真机页面(双卡片 demo)
4.2 文件清单
| 文件 | 职责 |
|---|---|
dotsindicator/src/main/ets/DotsIndicator.ets |
Compose 等价组件:四种类型统一 Canvas 绘制、命中测试 |
.../SpringDotsIndicator.ets / WormDotsIndicator.ets |
经典组件:弹簧积分 + 逐帧驱动 + 两阶段蠕虫 |
.../type/*.ets(5 个文件) |
IndicatorType 基类 + 四个类型类(参数与上游构造器一一对应) |
.../model/DotGraphic.ets |
圆点视觉模型(size / color / border,Compose DotGraphic 等价) |
.../util/ScrollOffset.ets |
computeGlobalScrollOffset 逐行移植 + clamp01 |
.../util/Spring.ets |
SpringForce 数值积分器(240Hz 子步) |
.../util/FrameDriver.ets |
animator 逐帧回调驱动器(Canvas 动画基础设施) |
.../util/ColorUtils.ets / DrawingUtils.ets |
ArgbEvaluator 等效插值 / 药丸与描边圆原语 |
dotsindicator/Index.ets |
HAR 导出面(组件 + 类型 + 工具全导出) |
entry/src/main/ets/pages/Index.ets |
双卡片验收页 + Swiper 同步模式参考实现 |
README.OpenHarmony_CN.md |
鸿蒙适配主文档(22 节) |
4.3 关键 API 对照
| Android 上游 | ArkTS 适配 | 说明 |
|---|---|---|
DotsIndicator(type=Shift/Spring/Worm/Balloon, pagerState) |
DotsIndicator({ type, currentPage, pageOffset }) |
pagerState 换成两个数值入参(stateless 语义不变) |
PagerState.currentPageOffsetFraction |
Swiper onGestureSwipe 偏移 / 宽度 |
宿主侧计算 fraction(demo 提供完整模式) |
attachTo(viewPager) |
currentPage 绑 Swiper.onChange |
attacher 抽象的等价用法 |
dotsWidthFactor / progressMode / selectedDotColor |
ShiftIndicatorType 第 2--4 参 |
经典加宽样式并入 Shift 类型 |
stiffness / dampingRatio(XML attrs) |
SpringDotsIndicator 同名属性 |
数值语义一致(AndroidX 单位) |
SpringAnimation |
Spring + FrameDriver |
数值积分复现,参数可直接对抄 |
ArgbEvaluator |
ColorUtils.lerpArgb |
逐通道插值 |
ViewPager 平滑滚动 |
swiperController.changeIndex(i, true) |
点击圆点跳页 |
4.4 ArkTS 与上游的边界
typescript
// Compose 风格:宿主喂状态,组件无内部状态
DotsIndicator({
dotCount: 5,
type: new WormIndicatorType(new DotGraphic(16, '#FFBDBDBD'),
new DotGraphic(16, '#FF1565C0')),
currentPage: this.current, // Swiper onChange 回写
pageOffset: this.fraction, // onGestureSwipe 逐帧回写
onDotClicked: (i) => this.controller.changeIndex(i, true)
})
kotlin
// 上游对照(compose/DotsIndicator.kt)
DotsIndicator(
dotCount = 5,
type = WormIndicatorType(
dotsGraphic = DotGraphic(color = Color(0xFFBDBDBD)),
wormDotGraphic = DotGraphic(color = Color(0xFF1565C0))),
currentPage = pagerState.currentPage,
currentPageOffsetFraction = { pagerState.currentPageOffsetFraction }
)
两边之间只传递 currentPage + pageOffset 两个数值,形变公式在库内封闭。
五、关键决策说明
决策 1:纯 ArkTS 重写,而不是 KMP / Kotlin-Native / NAPI
dotsindicator 的全部实现站在 AndroidX View / Compose / dynamicanimation 之上------上游不是 KMP 工程,没有 commonMain,也没有可脱离 UI 框架的算法内核。qrose / KStore 的"真编译 + C ABI 桥接"路线在这里无从谈起:能桥过去的东西不存在。oh-tpc 对 View 类库的标准路径就是 ArkTS 重写,本次适配把这条路径的保真责任明确为三件事:公式逐行移植、黄金截图对照、弹簧参数一致。
决策 2:保真度锚在"数学与参数",不是像素
UI 库适配最怕"看起来差不多"。本次把上游的可验证资产全部用上:computeGlobalScrollOffset 与四个形变公式逐行照抄(连 -0.0001 的边界修正都保留);蠕虫拉伸对照上游 Roborazzi 黄金截图人工验证;经典弹簧直接用 AndroidX 的 stiffness / dampingRatio 数值与语义。上游 JVM 单测(ComputationsTest)的移植列入路线图(8.3),作为后续自动化保真闸门。
决策 3:弹簧自积分而不是 springMotion
curves.springMotion 的 response/dampingFraction 与 AndroidX 参数无解析换算,套上去"像但不是"。自写 200 行不到的 SpringForce 积分器,让 stiffness = 300 在两边是同一个物理量:Spring 的过冲节奏、Worm 的临界阻尼干脆感都与上游同源,还顺带把参数面(XML attrs 同名属性)原样保留给使用者。
决策 4:animator 逐帧驱动 Canvas
animateTo 驱动不了 Canvas(@Watch 只在终值赋值时触发一次)是声明式动画与保留式画布的分界。统一封装 FrameDriver:无限 animator + 墙钟 dt + 收敛即停帧。它同时服务三处------经典 Spring 的单弹簧、经典 Worm 的双弹簧状态机、demo 的松手补间------一个驱动器收敛全部逐帧场景,空闲零开销。
决策 5:Gradle 工程不动,鸿蒙工程独立成目录
上游 Android 工程零改动(一条 git diff 都没有),鸿蒙侧按 cmp_qrose 审核通过的目录规则放 example/harmonyApp/:AppScope + HAR(模块名 = oh-package 名 = dotsindicator)+ entry + hvigor 三件套;compatibleSdkVersion 5.1.0(18)、signingConfigs 空数组等硬规则全部照搬。DevEco 只打开子目录,两套构建互不感知,CI 互不干扰。
六、测试与验证
6.1 测试环境
本次验证使用 Windows(x64)、DevEco Studio 5.1.0 Release(自带 node v24.14.1 / hvigor / ohpm / JBR 25)、HarmonyOS SDK(API 26,目标兼容 5.1.0(18))与 OpenHarmony 真机(x86_64,hdc 127.0.0.1:5555)。
6.2 构建验证
text
ohpm install → install completed(@ohos/hypium 自官方源)
CompileArkTS(library + entry) → finished(11→5→3→0 错误修复后归零)
PackageHap → finished(JBR 进 PATH 后)
BUILD SUCCESSFUL
entry-default-unsigned.hap → 228KB,含 ets/modules.abc
6.3 安装与启动
text
hdc install -r entry-default-unsigned.hap → install bundle successfully
hdc shell aa start -b org.terminator.ohos.dotsindicator -a EntryAbility
→ start ability successfully
hilog → DotsIndicatorDemo: Ability onCreate / onForeground /
Succeeded in loading the content (零 fault / 零 crash)
6.4 功能用例(真机 + uinput 注入手势)
- 首屏渲染:标题、Compose 卡片 Swiper 第 1 页(蓝色页码),Shift 首点加宽、Spring 前景点在首位、Worm 药丸趴在首点、Balloon 首点放大------四种形态与设计一致;
- 左滑一页:Swiper 翻到第 2 页,四行指示器高亮全部同步移到第二位 (Shift 第二点加宽、Worm 药丸到第二点、Balloon 第二点放大)------证明
onGestureSwipe偏移方向常量正确、松手补间落位正确; - 滚动到经典卡片:Default 行首点蓝色加宽、Spring 行空心圆 + 实心选择点、Worm 行空心圆 + 实心药丸,三行与 Swiper 页码同步;
- 经典 Swiper 再滑一页:翻到第 4 页,三行高亮同步到第 4 位;progressMode 下左侧 1--4 点全蓝(已读页保持选中色)------弹簧动画路径(FrameDriver + 双弹簧状态机)执行正常,全程零崩溃。
6.5 验证结论
四种 Compose 类型的形变数学、经典组件的弹簧物理、Canvas 逐帧驱动器、Swiper 同步模式与 HAR/HAP 交付链路已完成验证。应用在真机上安装、启动、手势交互全链路无异常(真机签名验收按 oh-tpc 流程由持机者执行,验收清单见 README.OpenHarmony_CN.md 第 15 节,共 8 项)。
七、运行效果
7.1 运行效果图

7.2 命令速查
bash
git clone https://atomgit.com/oh-tpc/dotsindicator.git
cd dotsindicator/example/harmonyApp
ohpm install
export DEVECO_SDK_HOME='<DevEco>/sdk'
export JAVA_HOME='<DevEco>/jbr'
export PATH="<DevEco>/jbr/bin:<DevEco>/tools/node:$PATH"
node "<DevEco>/tools/hvigor/bin/hvigorw.js" assembleHap \
-p product=default -p buildMode=debug --no-daemon
hdc install -r entry/build/default/outputs/default/entry-default-unsigned.hap
hdc shell aa start -a EntryAbility -b org.terminator.ohos.dotsindicator
hdc shell hilog | grep DotsIndicatorDemo
DevEco Studio 用户直接打开 example/harmonyApp/(勿开仓库根),Signing Configs 勾选自动生成后运行 entry 即可。
八、遗留问题与改进方向
8.1 踩坑复盘
animateTo驱动不了 Canvas:@State在闭包内一次性赋终值,@Watch只触发一次,画布直接跳帧------逐帧动画必须走animator模块(本次封装为FrameDriver);animator不在@kit.ArkUI的导出清单里,要从@ohos.animator默认导入Animator类;Animator.create静态方法自 API 18 起废弃,改用uiContext.createAnimator;AnimatorOptions的fill/direction是字符串字面量联合类型('none'/'normal'),不是FillMode枚举;AnimatorResult停止用pause(),没有stop();@Builder方法不能带闭包参数(does not meet UI component syntax):内容插槽必须做成@Component+@BuilderParam,调用侧尾随闭包传入;Area.width是Length联合类型,赋给 number 需要 typeof 收窄(demo 的widthToVp);- 命令行打包报
spawn java ENOENT:PackageHap 从 PATH 找 java,仅设JAVA_HOME不够,必须把 DevEco 自带 JBR 的bin加进 PATH; - HAR 模块目录名与 oh-package 包名不一致会告警
Missing module info------模块目录、模块名、包名统一为dotsindicator后消除; - hvigor 会在 HAR 模块根生成
BuildProfile.ets(内容随 debug/release 变化),属构建产物,须加入.gitignore而不是提交; Swiper.onGestureSwipe的currentOffset符号存在机型差异,demo 顶部留SWIPE_OFFSET_SIGN常量可翻转(真机实测为正);.gitignore的/build/、/*/build/是根锚定模式,盖不住example/harmonyApp/**/build/这类嵌套目录,鸿蒙工程的oh_modules/.hvigor/build/*.hap要显式追加;ResourceColor含Resource联合成员,不能直接传给 Canvas 的string | number形参,绘制前经canvasColor()收窄,$r资源只填充不参与插值。
8.2 已知问题
- RTL 未支持:上游经典版用
rotation = 180技巧、Compose 版有专门 RTL 修复,ArkUI 的 RTL 布局模型不同,V1 未做(列为路线图项); $r资源颜色可填充但不参与逐帧颜色插值(toArgb返回 null 走纯色兜底);onGestureSwipe偏移符号的机型差异待真机矩阵验证;- 尚未发布 ohpm 中央仓,当前以
file:../dotsindicator源码依赖接入; - 上游单测(
ComputationsTest等)与 Roborazzi 黄金截图的 ArkTS 等价测试尚未移植; - 上游 5.1.1 的
dotsElevation(阴影)与 per-dot color API(setDotsColors)未随 V1 移植。
8.3 未来优化方向
- 发布
.har/ ohpm 制品,让 ArkTS 工程一行依赖接入; - 移植上游计算单测(
computeGlobalScrollOffset与四个形变公式),配 uitest 自动化截图对照黄金图,把"数学没漂"变成 CI 闸门; - RTL 适配与真机验收矩阵(含
SWIPE_OFFSET_SIGN机型普查); - 补齐
dotsElevation、per-dot colors、dotsCornerRadius等次级样式参数; - 抽取 Swiper 同步模式为官方
SwiperBinding辅助类,降低接入成本。
九、总结
9.1 核心难点回顾
text
上游 dotsindicator 5.1.1(Compose 4 类型 + 经典 View 3 组件)
→ 路线选型:纯 ArkTS 重写(View 类库无 KMP 内核,oh-tpc 标准路径)
→ 工程骨架:cmp_qrose 审核目录规则(example/harmonyApp + HAR + entry)
→ Compose 数学逐行移植(computeGlobalScrollOffset + 四公式)
→ 黄金截图对照(worm_ltr_p0_o05 验证拉伸行为)
→ 经典物理:SpringForce 数值积分(stiffness/dampingRatio 同语义)
→ Canvas 逐帧:FrameDriver(animator + 墙钟 dt + 收敛停帧)
→ Swiper 同步:onGestureSwipe 跟手 + onChange 补间 + onAnimationEnd 弹回
→ hvigor 命令行构建(11→0 编译错误修复实录)
→ 真机验收(安装 / 启动 / 手势 / 弹簧 / progressMode 全绿)
9.2 封装层次
- 数学层 :
computeGlobalScrollOffset与四个形变公式,逐行对应上游 Kotlin; - 物理层 :
Spring积分器(SpringForce 语义)+ 两阶段蠕虫状态机; - 驱动层 :
FrameDriver提供 Canvas 逐帧回调,收敛即停帧; - 组件层 :
DotsIndicator(无状态、状态外部驱动)与SpringDotsIndicator/WormDotsIndicator(页码驱动、内部弹簧); - 宿主层:Swiper 同步模式与双卡片 demo,兼任接入参考实现;
- 交付层:HAR 模块 + demo HAP + hvigor 构建 + 文档四件套。
9.3 三条经验
- UI 库的适配保真锚点是"公式与参数"而不是二进制:没有内核可编译时,把上游的可验证资产(internal 数学函数、黄金截图、物理参数)当作移植合同,逐行照抄、逐张对照、数值同源------"看起来差不多"不叫适配;
- ArkUI 动画先分清声明式与保留式 :
animateTo只插值组件属性,Canvas 这类保留式表面必须自驱逐帧------先认清这条分界再选方案,能省掉一整轮返工; - 离散事件拼不出连续手势,要有补间层 :Swiper 只给
onChange/onGestureSwipe,跟手与落位之间的连续性靠"手势期间直写 fraction + 松手 FrameDriver 补间到整数差 + 到位换锚点"三段式拼出来,这套模式对所有"指示器类"组件通用。
9.4 适配成果
当前适配已完成:
- dotsindicator
5.1.1(Apache-2.0)Compose 版四种指示器类型的 ArkTS 等价实现(数学逐行移植,蠕虫行为经上游黄金截图对照); - 经典
SpringDotsIndicator/WormDotsIndicator的弹簧物理复现(SpringForce 数值积分,参数与上游 XML attrs 同语义)与经典加宽样式(颜色插值 + progressMode 并入ShiftIndicatorType); - HAR 库模块(
example/harmonyApp/dotsindicator,全导出面含工具层)+ demo HAP(双卡片、七种形态、增删页面与点击跳页交互); - hvigor 命令行构建跑通(unsigned HAP 约 228KB)+ 真机安装、启动、手势、弹簧全链路验收(零崩溃);
- AtomGit 项目文档:README 门户重写、README.OpenHarmony 英文摘要、README.OpenHarmony_CN 主文档(665 行 / 22 节)、上游原文备份与本文效果图。
适配过程 FAQ
以下问题来自
ohosArm64目标接入、NAPI 桥接、HAP 构建与真机验收的实际流程,按出现频率排序。
Q1:DevEco Studio Sync 失败或 ohpm 依赖下载超时,怎么处理?
- 确认 DevEco Studio 版本与工程
build-profile.json5的compatibleSdkVersion一致; - 在
File > Settings > Build, Execution, Deployment > Ohpm配置可用的 ohpm 仓库镜像或代理; - 删除
oh_modules、oh-package-lock.json5与.hvigor缓存后重新 Sync; - 公司网络需要代理时,同时给 ohpm 与 hvigor 配置
http.proxy。
Q2:CMake 报 Missing .../libxxx.so 或找不到 Kotlin/Native 动态库?
- 先在
example目录执行./gradlew :nativeApp:linkDebugSharedOhosArm64(模拟器用linkDebugSharedOhosX64); - 确认
libxxx.so与libc++_shared.so已复制到ohos/entry/libs/<abi>/; - 检查
entry/build-profile.json5的abiFilters与设备 ABI 是否一致(真机arm64-v8a,模拟器x86_64); - 清理
entry/.cxx与entry/build后重试。
Q3:ArkTS 报 Cannot find module 'libxxx_napi.so' 或调用返回 undefined?
- 确认 CMake 的
add_library(<target> SHARED ...)名称与import ... from 'libxxx_napi.so'完全一致; - 解包 HAP,确认
libs/<abi>/libxxx_napi.so已打包; - 删除
entry/build、entry/.cxx与oh_modules后重新构建; - 检查
module.json5的srcEntry与页面路径是否正确。
Q4:真机安装报 signature verification failed 或 install failed?
- 在 DevEco Studio 的
Signing Configs配置证书、Profile 与密码; bundleName必须与签名 profile 中的 bundle name 一致;- 设备需加入 profile 的
deviceIds,调试证书有效期需覆盖当前时间; - 用
hdc list targets确认设备在线,再执行hdc install -r xxx-signed.hap。
Q5:应用启动白屏、闪退或 NAPI 调用崩溃?
- 用
hdc hilog | grep -i "napi\|kotlin"查看原生日志; - 确认
libc++_shared.so与 Kotlin/Native 工具链版本匹配,且 ABI 与设备一致; - 检查 C ABI 返回的字符串是否由 Native 侧分配、ArkTS 侧复制后是否调用
XxxFree释放; - 在 DevEco 的 Native 调试配置中附加
lldb,定位空指针与越界访问。
Q6:Gradle 报 ohosArm64 目标不存在或 Kotlin/Native 编译失败?
- 使用鸿蒙发行版 Kotlin(如
2.2.21-1.0.0),并在settings.gradle.kts配置 eazytec 仓库; - Gradle 建议 8.x、JDK 21,版本不匹配时先统一工具链;
- 确认
kotlin { ohosArm64() }已应用,且local.properties的 SDK 路径有效; - 清理
~/.gradle/caches中对应版本的 Kotlin/Native 缓存后重试。
Q7:HAP 构建报 SDK 版本不匹配或 compatibleSdkVersion 错误?
- 对齐 DevEco Studio、HarmonyOS SDK 与
build-profile.json5中的compatibleSdkVersion; - 升级 DevEco Studio 后同步升级 hvigor 与 SDK,避免
modelVersion与 IDE 不匹配; - 命令行构建时设置
DEVECO_SDK_HOME指向 DevEco 的sdk目录。
Q8:命令行 hvigor 构建失败,但 DevEco 里能构建?
- 使用 DevEco 自带的 node 与 hvigor:
"$DEVECO/Contents/tools/node/bin/node" "$DEVECO/Contents/tools/hvigor/bin/hvigorw.js" ...; - 设置
JAVA_HOME、NODE_HOME、DEVECO_SDK_HOME; - 加上
--no-daemon排除守护进程缓存影响,并清理entry/build与.hvigor。