开源鸿蒙平台 CMP 三方库「dotsindicator」适配全流程

本文记录 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 / Compose PagerState 联动。本次适配与社区此前的 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 黄金截图验证;经典组件的弹簧手感用 AndroidX SpringForce(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 注入手势)

  1. 首屏渲染:标题、Compose 卡片 Swiper 第 1 页(蓝色页码),Shift 首点加宽、Spring 前景点在首位、Worm 药丸趴在首点、Balloon 首点放大------四种形态与设计一致;
  2. 左滑一页:Swiper 翻到第 2 页,四行指示器高亮全部同步移到第二位 (Shift 第二点加宽、Worm 药丸到第二点、Balloon 第二点放大)------证明 onGestureSwipe 偏移方向常量正确、松手补间落位正确;
  3. 滚动到经典卡片:Default 行首点蓝色加宽、Spring 行空心圆 + 实心选择点、Worm 行空心圆 + 实心药丸,三行与 Swiper 页码同步;
  4. 经典 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 踩坑复盘

  1. animateTo 驱动不了 Canvas:@State 在闭包内一次性赋终值,@Watch 只触发一次,画布直接跳帧------逐帧动画必须走 animator 模块(本次封装为 FrameDriver);
  2. animator 不在 @kit.ArkUI 的导出清单里,要从 @ohos.animator 默认导入 Animator 类;Animator.create 静态方法自 API 18 起废弃,改用 uiContext.createAnimator;
  3. AnimatorOptions 的 fill / direction 是字符串字面量联合类型('none' / 'normal'),不是 FillMode 枚举;AnimatorResult 停止用 pause(),没有 stop();
  4. @Builder 方法不能带闭包参数(does not meet UI component syntax):内容插槽必须做成 @Component + @BuilderParam,调用侧尾随闭包传入;
  5. Area.width 是 Length 联合类型,赋给 number 需要 typeof 收窄(demo 的 widthToVp);
  6. 命令行打包报 spawn java ENOENT:PackageHap 从 PATH 找 java,仅设 JAVA_HOME 不够,必须把 DevEco 自带 JBR 的 bin 加进 PATH;
  7. HAR 模块目录名与 oh-package 包名不一致会告警 Missing module info------模块目录、模块名、包名统一为 dotsindicator 后消除;
  8. hvigor 会在 HAR 模块根生成 BuildProfile.ets(内容随 debug/release 变化),属构建产物,须加入 .gitignore 而不是提交;
  9. Swiper.onGestureSwipe 的 currentOffset 符号存在机型差异,demo 顶部留 SWIPE_OFFSET_SIGN 常量可翻转(真机实测为正);
  10. .gitignore 的 /build/、/*/build/ 是根锚定模式,盖不住 example/harmonyApp/**/build/ 这类嵌套目录,鸿蒙工程的 oh_modules / .hvigor / build / *.hap 要显式追加;
  11. 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 三条经验

  1. UI 库的适配保真锚点是"公式与参数"而不是二进制:没有内核可编译时,把上游的可验证资产(internal 数学函数、黄金截图、物理参数)当作移植合同,逐行照抄、逐张对照、数值同源------"看起来差不多"不叫适配;
  2. ArkUI 动画先分清声明式与保留式 :animateTo 只插值组件属性,Canvas 这类保留式表面必须自驱逐帧------先认清这条分界再选方案,能省掉一整轮返工;
  3. 离散事件拼不出连续手势,要有补间层 :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。

参考文档

相关推荐
resh_people1 小时前
开源鸿蒙平台 CMP 三方库「kmp-wheel-picker」适配全流程
华为·开源·harmonyos
HelloGitHub1 小时前
《HelloGitHub》第 127 期
开源·github
2501_944521592 小时前
开源鸿蒙平台 KMP 三方库「BottomSheetNavigator3KMP」适配全流程
华为·开源·harmonyos
Agent世界2 小时前
百度OCR 2开源:KV缓存从47GB压到1.77GB
百度·缓存·开源·大模型·ocr
2501_944521592 小时前
开源鸿蒙平台 CMP 三方库「scroll-effects」适配全流程
华为·开源·harmonyos
Dovis(誓平步青云)2 小时前
切换语言后按钮被截断,本地化审计怎样发现长度风险
开发语言·前端·javascript·人工智能·机器学习·华为·harmonyos
kiros_wang2 小时前
折叠屏状态监听:横竖屏、折叠态差异化布局开发
华为·harmonyos
街头便衣2 小时前
OpenRouter周榜复算:164万亿token,输出2.55%,DeepSeek账单占1.4%
android·java·开发语言·人工智能·语言模型·开源
2501_944525542 小时前
开源鸿蒙平台 KMP 三方库「FileKit」适配全流程
华为·开源·harmonyos