本文记录把 react-native-ticker(数字滚动/翻牌效果)适配到 HarmonyOS 的完整过程。

它做的事很专一:把一段文字(通常是数字)拆成单个字符,每个字符位做成一个"竖向滚轮" —— 用 translateY 把目标字符滚到视口里,于是数字变化时看起来像"数字在滚动"。

结论:数字场景(纯数字/含小数点)在鸿蒙上工作正常;但它在"字符集发生变化"时会崩 —— 这是一个可以用源码解释、也用对照实验证实了的真实缺陷。

★★★★★ 一、先说机制:它是怎么"量字"又怎么"滚"的

去掉编译产物后,核心逻辑非常清楚(index.js 137 行):

// ① 把 children 拆成单字符
const measureStrings = Children.map(children, child => splitText(`${child}`)).reduce(...)

// ② 是否含数字 ⇒ 决定"轮子"的内容
const hasNumbers = measureStrings.find(v => isNumber(v)) !== undefined;
const numberRange = range(10).map(p => p + '');
const numAdditional = [',', '.'];
const numberItems = [...numberRange, ...numAdditional];       // ['0'..'9', ',', '.']
const rotateItems = uniq([...(hasNumbers ? numberItems : []), ...measureStrings]);

// ③ 为 rotateItems 里每个元素渲染一个【隐藏 Text】(absolute + opacity:0),靠 onLayout 量宽高
rotateItems.map(v => <Text style={[textStyle, styles.hide]} onLayout={e => handleMeasure(e, v)}>{v}</Text>)

// ④ 全部量完 ⇒ measured = true
if (Object.keys(measureMap.current).length === rotateItems.length) setMeasured(true);

// ⑤ 只有 measured 才渲染 TickItem
{measured === true && Children.map(children, ...)}

// ⑥ TickItem:把 rotateItems 全渲染成一列竖排 Text,用 translateY 选一个露出来
const position = getPosition({text: children, height: measurement.height, items: rotateItems});
const stylePos = useSharedValue(position);
const animatedStyles = useAnimatedStyle(() => ({
  height: measurement.height,
  width: widthAnim.value,
  overflow: 'hidden',
  transform: [{translateY: stylePos.value}],
}));

⇒ ⇒ 这是一个"先测量、再滚动"的两段式状态机 ✓
⇒ measured 这个状态,就是本文最重要的伏笔。

★★★★★ 二、核心发现:字符集一变就崩(Cannot read property 'height' of undefined)

我的测试页用时间轴把值依次改成 123 → 456 → 12.5 → abc,前三个阶段都正常,第四阶段整页崩:

LogBox: "Cannot read property 'height' of undefined"
(此后页面上的单字符 Text 全部消失)

根因(读源码就能推出来):

const measurement = measureMap[children];        // ★ children 变了,但 measureMap 里没有它
const position = getPosition({
  text: children,
  height: measurement.height,                   // ★ 这里崩
  items: rotateItems,
});

把过程走一遍:

阶段值hasNumbersrotateItemsmeasureMap 里有什么
1123true['0'..'9', ',', '.'](12 项)量完这 12 项 ⇒ measured = true
2456true同上(12 项)还是那 12 项
312.5true同上(12 项)还是那 12 项
4abcfalse['a','b','c'](3 项)仍只有那 12 项 —— 没有 a!

⇒ measured 一旦变成 true,就再也不会回到 false(源码里没有任何复位逻辑)
⇒ 于是第四阶段立刻去渲染 TickItem,而 measureMap['a'] 是 undefined
⇒ measurement.height 崩 ✓✓

★★★★★ 用对照实验证实:纯数字路径不崩

为了排除"是别的原因导致崩",我把时间轴改成只走纯数字(123 → 456 → 12.5,不切字符集)再跑一遍:

@5s(值 123):  单字符 Text 数 = 0     LogBox: 无 ✓
@13s(值 456): 单字符 Text 数 = 18    LogBox: 无 ✓
@21s(值 12.5): LogBox: 无 ✓
⇒ ✓✓ 纯数字路径三个阶段都没有 LogBox ⇒ 崩溃确实由【字符集变化】触发

⇒ ⇒ 结论可靠:崩因是"字符集变化导致新字符没有量测结果",不是随机问题 ✓✓

顺带得到的状态机时序证据

上面第一行 @5s 单字符 Text 数 = 0 很有价值 —— 它说明:

  • 页面挂载后,measured 仍然是 false(还在等 12 个 onLayout 回调)
  • 到 @13s 才变成 18 个单字符 Text ⇒ measured 已经 true、TickItem 开始渲染

⇒ onLayout 在 RNOH 上确实是异步但可达的 ✓(这个库正是靠它工作的)

对使用者的直接含义

场景是否可用
纯数字(123 → 456、金额、计数器)✅ 正常
数字 + 小数点/逗号(12.5、1,234)✅ 正常(, 与 . 本来就在 numberItems 里)
数字 ⇄ 字母 混用(123 → abc)❌ 崩(本文发现的缺陷)
纯字母之间变化(abc → abd)⚠️ 字符集不变(a,b,c → a,b,d 里 d 是新的)⇒ 同样会崩

规避办法:让 ticker 的字符集保持稳定(例如始终用等宽数字、需要切字母时给 Ticker 换一个 key 强制重挂载):

<Ticker key={String(value)} …>{value}</Ticker>   {/* 换 key ⇒ 重挂载 ⇒ 重新量测 */}

★★★★ 三、react-native-reanimated 的处理:功能桩(附适配版信息)

它声明的 peer 是 react-native-reanimated: >= 2.4.1(原生库,worklets + C++)。

但它的用法面非常小 —— 只有 5 个 API:

import Animated, {useSharedValue, useAnimatedStyle, withTiming, Easing} from 'react-native-reanimated';

鸿蒙适配版是存在的:

包版本
@react-native-ohos/react-native-reanimated4.0.2
@react-native-oh-tpl/react-native-reanimated3.6.4-rc.1

但我这一轮选择打桩,理由:

  1. reanimated 需要 babel plugin + worklets,接入不只是加 HAR;
  2. HAR 接入此前实测失败过一次;
  3. 更关键:本库的核心逻辑(拆字 → onLayout 量宽高 → measured 状态机 → 渲染 TickItem)全是纯 JS,
    把 reanimated 换成"瞬间到位"的功能桩后,渲染结构与几何仍然真实,只有"滚动过渡"变成瞬移。

桩做成了"功能可用"而不是空壳:

const useSharedValue = (initial) => /* 返回稳定的 {value} 对象 */;
const useAnimatedStyle = (updater) => updater() || {};      // 直接执行 worklet
const withTiming = (toValue) => toValue;                    // 直接到位
const Easing = { linear: t => t, ease: t => t, /* … */ };
const Animated = { View, Text, ScrollView, /* … */ };        // 直接映射 RN 组件

⚠️ 代价必须写清楚:滚动动画的"过程"完全没看到(只有终值正确)。
"接了真 reanimated 之后动画是否顺畅"本轮未验证。

★★★★★ 四、桩统计给出了一条漂亮的"行为计数"证据链

桩会统计三个计数,我在每个相位打一次 ⇒ 数字与组件的内部结构精确对应:

挂载初期          {sharedValueCount: 0,  animatedStyleCount: 0,  withTimingCount: 0}
P0(值 123)      {sharedValueCount: 12, animatedStyleCount: 6,  withTimingCount: 12}
P1(值 456)      {sharedValueCount: 12, animatedStyleCount: 12, withTimingCount: 18}
P2(值 12.5)     {sharedValueCount: 12, animatedStyleCount: 18, withTimingCount: 24}
P3(值 abc)      {sharedValueCount: 14, animatedStyleCount: 25, withTimingCount: 32}

怎么读这些数字:

  • sharedValueCount = 12 ⇒ 页面上一共有 6 个 TickItem(E1 的 3 个字符位 + E2 固定的 abc 3 个字符位),
    每个 TickItem 里有两处 useSharedValue(widthAnim 与 stylePos)⇒ 6 × 2 = 12 ✓✓
  • withTimingCount 每次改值 +6 ⇒ 3 个字符位 × 2 个 withTiming(位置 + 宽度)= 6 ✓✓
  • animatedStyleCount 每次 +6 ⇒ 3 个字符位 × 1 个 useAnimatedStyle = 6 ✓✓

⇒ ⇒ 这些计数不是"跑起来了"的模糊证据,而是能和源码结构一一对上的整数 ✓✓
⇒ 这条经验值得复用:给桩加"按 API 分类的计数",就能反推组件内部渲染了多少个实例、每实例调了几次 hook ✓

★★★★ 五、文本级判据:单字符 Text 的数量与内容

TickItem 会把 rotateItems 全部渲染成一列竖排 Text,靠 translateY 只让目标字符露出来。
所以我直接数"布局 dump 里内容是单个字符的 Text":

单字符 Text 的种类数 = 15
内容 = ,×1 .×1 0×1 1×1 2×1 3×1 4×1 5×1 6×1 7×1 8×1 9×1 a×2 b×2 c×2
总数 = 18

与 rotateItems 的构造完全吻合:

  • , . 0–9 各 1 个(共 12 个)⇒ 来自 E1(数字路径,numberItems 12 项)✓
  • a b c 各 2 个(共 6 个)⇒ 来自 E2 固定渲染的 abc(rotateItems = ['a','b','c'])✓
  • 12 + 6 = 18 ✓✓

⚠️ 为什么每种只有 1–2 个而不是"全部 12 个都可见":
TickItem 用的是 overflow: 'hidden' + translateY,被移出视口的 Text 不会被布局 dump 列出。
这又一次印证了一条反复出现的盲区:“用视觉手段隐藏/位移的节点,dump 看不见” ✓

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

★★★ 六、其余预检结论

预检项结果
老写法九项✅✅ 全部为空
propTypes✅✅ 完全没有 ⇒ 不存在"必填 prop 无默认值"的陷阱(这一项被特意放在预检最前面)
defaultProps只有 {duration: 250}
Platform.OS✅ 空(只有 I18nManager.isRTL 决定 flexDirection,实测 isRTL = false)
measure()✅ 空(量尺寸用 onLayout,2 处)
useNativeDriver✅ 空(reanimated 不用这个参数)
NativeModules✅ 空
dependencies✅ 空(零运行时依赖)
prepublishOnly✅ npm install 不跑

契约(反射实测,全 PASS):

NOTE | 默认导出的 typeof = function
PASS | ★ 默认导出 Ticker 是函数组件
PASS | ★ 具名导出 Tick 是函数组件
NOTE | Ticker.defaultProps 的键 = duration
PASS | ★ defaultProps.duration = 250
NOTE | Ticker.propTypes 的键 = (无 propTypes)
PASS | ★★ 库没有 propTypes ⇒ 不存在"必填 prop 无默认值"陷阱
PASS | ★★ reanimated 可解析(功能桩)

七、接入与预检(要点)

# ① 装库(file: 接入;它没有 dependencies,所以不用补装)
npm install "git+https://atomgit.com/oh-react-native/react-native-ticker.git#6.0.1-ohos-1.0.0" --ignore-scripts
# ② 它的 peer 是 react-native-reanimated —— 二选一:
#    (A) 接真库:@react-native-ohos/react-native-reanimated@4.0.2 + babel plugin + HAR
#    (B) 打桩:metro 的 resolver.extraNodeModules 指向功能桩(本文采用)

八、交付包质量与基线说明

类别结果
交付包内容index.js(5406 B,编译产物)+ index.tsx(5431 B,源码) ⇒ 都完整可读 ✓(12 文件)
spec.json⚠️ 缺 upstreamCommit
validation⚠️ "build-pass-device-pending"
⚠️ 声明的基线reactNative: "0.88.0" + rnoh: "0.84.4" ⇒ 两字段自相矛盾
备注实现零改动(本轮未修改库任何文件)

关于基线:本次验证在 RN 0.84.1 + RNOH 0.84.3 上完成。库无已移除写法 ⇒ 结论跨基线成立 ✓


九、已知限制与本次验证边界

9.1 用这个库要知道的

事项说明
★★★★★ 字符集变化时会崩measureMap 只累积不清理、measured 不复位 ⇒ 从数字切字母(或字母集变化)⇒ Cannot read property 'height' of undefined
★★★★ 规避:保持字符集稳定,或换 key 强制重挂载<Ticker key={String(value)}>
★★★★ reanimated 只用 5 个 APIuseSharedValue / useAnimatedStyle / withTiming / Easing / Animated.View ⇒ 功能桩可行
★★★ 靠 onLayout 量每个字符的宽高量完全部 rotateItems 才 measured = true ⇒ 首帧没有内容(实测 @5s 还是 0 个单字符 Text)
★★★ TickItem 会把 rotateItems 全渲染靠 overflow:hidden + translateY 只露一个 ⇒ dump 里只看得到 1–2 个
★★ 无 propTypes没有"必填 prop"陷阱,但也没有参数校验
★ 零运行时依赖dependencies 为空

9.2 本次验证的边界

  1. ★★★ 滚动动画的"过程"完全没看到(reanimated 用功能桩,withTiming 直接到位);
  2. ★★★ 没有接真的 reanimated(适配版 @react-native-ohos/react-native-reanimated@4.0.2 存在但未接入)⇒ "接了真库后动画是否顺畅"未知;
  3. ★★ Tick 具名导出只做了"可导入/是函数"的断言,没做实际渲染(它需要外部提供 measureMap 与 rotateItems);
  4. ★★ 未验证 duration 取不同值的效果(桩里 withTiming 直接返回目标值,时长无意义);
  5. 未验证 containerStyle / textStyle / textProps 的更多组合;
  6. 未验证 多字符位数(如 6 位金额)时的宽度补偿行为;
  7. 未验证 I18nManager.isRTL = true 时的表现;
  8. 未在 RN 0.88.0 / RNOH 0.84.4 上复验;
  9. 未测其它屏幕宽度;未做跨平台对照。

未改动库代码。


十、常见问题

Q1:它在 RN 0.84 上能跑吗?

数字场景能跑。 值 123 → 456 → 12.5 三个阶段都正常(LogBox 无通知),
单字符 Text 的数量与内容与 rotateItems 的构造精确吻合。
但值里出现字母时会崩(见第二节)。

Q2:报 Cannot read property 'height' of undefined?

你让 ticker 的字符集变了(例如从 123 变成 abc)。
内部 measureMap 只累积不清理、measured 不复位 ⇒ 新字符没被量过 ⇒ measurement.height 崩。
规避:字符集保持稳定,或 <Ticker key={String(value)}>…</Ticker> 强制重挂载。

Q3:为什么首帧看不到内容?

因为它要先用隐藏的 Text + onLayout 把 rotateItems 里每个字符的宽高都量一遍,
全部量完才 setMeasured(true) 才渲染真正的滚动列。
实测挂载 5 秒时单字符 Text 还是 0 个,13 秒时是 18 个 —— 说明这个"量字"过程是异步的。

Q4:一定要装 react-native-reanimated 吗?

理论上要(它是 peer)。但你也可以像我这样打一个功能桩:
withTiming 直接返回目标值、useAnimatedStyle 直接执行——结构能跑、只是没有滚动过程。
如果要真动画,鸿蒙适配版是 @react-native-ohos/react-native-reanimated@4.0.2。

Q5:能不能只显示"数字滚动",不用管字母?

可以,这也是它的设计场景。纯数字 / 带小数点 / 带千分位逗号都正常(, 和 . 本来就在 numberItems 里)。

Q6:为什么布局 dump 里看不到那 12 个竖排字符?

TickItem 用 overflow: 'hidden' + translateY 只让目标字符露出来,被移出视口的节点 dump 不列。
实测每种字符只看到 1–2 个(12 + 6 = 18 个单字符 Text)✓


小结

react-native-ticker 是**"先量字、再滚动"的两段式数字翻牌组件**,
九项老写法全空、无 propTypes、无平台分支、无 measure()、零运行时依赖,
数字场景在鸿蒙上工作正常。

最值得记下来的是这几条:

  1. ★★★★★ 一个可用源码解释、并用对照实验证实了的缺陷:字符集一变就崩。
    measureMap 只累积不清理、measured 一旦 true 就不复位 ⇒ 从 123 切到 abc 时
    measureMap['a'] 是 undefined ⇒ Cannot read property 'height' of undefined。
    对照实验:改成只走纯数字(123→456→12.5)⇒ 三个阶段都没有 LogBox ✓✓
    ⇒ 规避:保持字符集稳定,或换 key 强制重挂载。

  2. ★★★★★ 桩统计可以做"行为计数"证据链,而且能和源码结构一一对上。
    sharedValueCount = 12 ⇒ 6 个 TickItem × 2 个 useSharedValue;
    每次改值 withTiming +6 ⇒ 3 个字符位 × 2 个 withTiming;
    animatedStyleCount 每次 +6 ⇒ 3 个字符位 × 1 个 useAnimatedStyle ✓✓
    ⇒ 给桩加"按 API 分类的计数",就能反推"渲染了多少实例、每实例调了几次 hook" ✓

  3. ★★★★ 抓到了状态机的时序证据。
    @5s 单字符 Text 还是 0 个、@13s 变成 18 个 ⇒ 说明 measured 是"等所有 onLayout 回来"才置位 ✓
    ⇒ 也证明 onLayout 在 RNOH 上异步但可达 ✓

  4. ★★★★ 把"必填 prop 无默认值"作为预检的第一个检查项。
    先确认 propTypes 完全没有 ⇒ 结构上就不可能踩那个陷阱 ⇒ 本轮零崩溃(唯一的崩是本文发现的那个真实缺陷)
    ⇒ 把一个高危检查项固定在预检最前面,收益立刻可见。

  5. ★★★ reanimated 的"用法面小"决定了打桩可行性。
    只用 5 个 API ⇒ 一个 60 行的功能桩就能让结构跑起来(代价:动画过程看不见)。
    这比硬接 HAR 省下大量时间,但一定要把"动画未验证"写清楚。

  6. ★★★ 又一次印证"视觉手段做的隐藏/位移,dump 看不见"。
    TickItem 全渲染 12 个 Text,但 overflow:hidden + translateY ⇒ dump 里只看得到 1–2 个 ⇒
    别把"没看到"当成"没渲染" ✓

  7. ★ 零运行时依赖的库最省事:dependencies 为空 ⇒ 不用补装任何东西(省掉了 file: 接入不装依赖的老坑)✓


本篇用到的库

项内容
三方库react-native-ticker(上游 6.0.1 的鸿蒙适配版)
适配仓库https://atomgit.com/oh-react-native/react-native-ticker
适配 TAG6.0.1-ohos-1.0.0
需要 HAR / 权限 / ohpm⚠️ 其 peer react-native-reanimated 需 HAR(本轮打桩未接)
库类型数字滚动 / 翻牌(拆字 + translateY)
运行时依赖✅ dependencies 为空;⚠️ peer 是 react-native-reanimated
prepublishOnly✅ npm install 不跑
许可证MIT
上游仓库https://github.com/browniefed/react-native-ticker(★456)
宿主工程RNOH084Demo(测试页 rnAppKey = TickerTestApp)
# 装库(无 dependencies,不用补装)
npm install "git+https://atomgit.com/oh-react-native/react-native-ticker.git#6.0.1-ohos-1.0.0" --ignore-scripts
# peer 二选一:接 @react-native-ohos/react-native-reanimated@4.0.2(+babel plugin+HAR),或打功能桩
import Ticker, {Tick} from 'react-native-ticker';

// ① 数字滚动(推荐用法:字符集保持稳定)
<Ticker duration={250} textStyle={{fontSize: 40, height: 56}}>{'123'}</Ticker>

// ② 千分位 / 小数点也支持(',' 与 '.' 本来就在 numberItems 里)
<Ticker>{'1,234.5'}</Ticker>

// ③ ★ 需要切换字符集时,换 key 强制重挂载(否则会崩)
<Ticker key={String(value)} textStyle={{fontSize: 40}}>{value}</Ticker>

// ⚠️ 字符集变化(数字→字母)会导致 Cannot read property 'height' of undefined
// ⚠️ 首帧无内容:要先用隐藏 Text + onLayout 把每个字符量一遍
// ⚠️ 需要 react-native-reanimated(可打功能桩,但那样没有滚动过程)
// ⚠️ TickItem 全渲染 12 个字符,靠 overflow:hidden 只露一个 ⇒ dump 只看得到 1–2 个
# 换页启动测试页 —— force-stop 不能省
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey TickerTestApp

判据的复现方式

# ① 契约 + reanimated 桩统计:逐条 PASS/FAIL 日志
hdc shell "hilog -x | grep rnt-test" | Select-String 'PASS|FAIL|SUMMARY|NOTE'
#    ⇒ Ticker/Tick 是函数、defaultProps.duration=250、(无 propTypes)、桩统计演进
# ② 文本级判据:数"内容是单个字符"的 Text
devecocli ui layout | Select-String 'Text#Paragraph'
#    ⇒ 数字路径下 , . 0..9 各 1 个 + a b c 各 2 个 = 18 个
# ③ 崩溃复现:让 ticker 的值从数字切到字母 ⇒ LogBox 出现
#    "Cannot read property 'height' of undefined"
# ④ 对照:只走纯数字路径 ⇒ 三个阶段都无 LogBox

验证环境

项版本
React Native0.84.1
React19.2.3
RNOH(npm / ohpm)@react-native-oh/react-native-harmony / @rnoh/react-native-openharmony 0.84.3
Node.js / npmv24.14.0 / 11.9.0
DevEco Studio26.0.0.621
HarmonyOS SDKAPI 26(26.0.0.32)
设备HarmonyOS 7.0.0(26.0.0) Beta2 模拟器 Pura X View(ohos-x64),1320×2232,density 3
本机状态栏高度117px = 39vp
宿主依赖⚠️ react-native-reanimated 用功能桩隔离
宿主 HAP 产物entry-default-signed.hap
本次构建assembleHap 3 分 42 秒 ⇒ 一次成功(打包与构建退出码都检查了)
验证规模契约 7 条全 PASS;桩统计行为计数与源码结构精确对应(12 / +6 / +6);单字符 Text 计数与 rotateItems 吻合(18 个);状态机时序证据(@5s 0 个 → @13s 18 个);缺陷复现 + 对照实验(纯数字路径不崩);4 张截图互异,分别对应 4 个相位

⚠️ 基线说明:交付包 spec.json 声明 RN 0.88.0 + RNOH 0.84.4(两字段自相矛盾),本次验证在 RN 0.84.1 + RNOH 0.84.3 上完成。
本库无任何已移除写法、无平台分支 ⇒ 结论跨基线成立。


相关社区与项目地址

项目地址
React Native 鸿蒙官方社区(CPF-RN)https://atomgit.com/CPF-RN
RNOH 框架仓库https://atomgit.com/openharmony-RN/ohos_react_native
RNOH 三方库使用文档https://atomgit.com/OpenHarmony-RN/usage-docs
React Native for OpenHarmony 组织https://atomgit.com/oh-react-native
RN 三方库鸿蒙适配清单https://atomgit.com/oh-react-native/rn-ohos-adaptation-overview

欢迎加入 CPF-RN 鸿蒙社区:https://atomgit.com/CPF-RN

React Native for OpenHarmony 组织:https://atomgit.com/oh-react-native

RN 三方库鸿蒙适配清单:https://atomgit.com/oh-react-native/rn-ohos-adaptation-overview

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐