本文记录把 react-native-countdown-component(倒计时组件)适配到 HarmonyOS 的完整过程。

它是一个单文件的倒计时:显示 天:时:分:秒,每秒递减一次,可以自定义显示哪几段、配色和尺寸。它同时提供了一个"应用回到前台时把后台流失的时间补扣掉"的逻辑。

这轮的价值在于两个都能算出来的量,以及一个真 bug:

① 数字格的尺寸有公式,实测精确吻合

renderDigit = (d) => (
  <View style={[styles.digitCont, {width: size * 2.3, height: size * 2.6}, digitStyle]}>
    <Text style={[styles.digitTxt, {fontSize: size}, digitTxtStyle]}>{d}</Text>
  </View>
);

用 size={30}(density 3)⇒ 预言 69 × 78 vp = 207 × 234 px;实测正好 207 × 234 px ✓

② 递减量可以和真实时间对上

同一实例间隔两次读它的文本:

第 1 次:00 | Days | 01 | Hours | 00 | Minutes | 22 | Seconds
第 2 次:00 | Days | 01 | Hours | 00 | Minutes | 15 | Seconds

两次读取的间隔 = 我的等待 3 秒 + 两次 dump 自身耗时 ≈ 7 秒,而秒数正好 22 → 15(差 7) ✓
⇒ 递减量与真实流逝时间一致,不是"看起来在动"。

③ ★★ 一个真 bug:卸载时会抛错

componentWillUnmount() {
  clearInterval(this.timer);
  AppState.removeEventListener('change', this._handleAppStateChange);   // ⚠️ 这个 API 已经没了
}

实测:typeof AppState.removeEventListener === 'undefined'(而 AppState.addEventListener 仍然存在)⇒ 该 API 在 RN 0.65+ 已被移除 ⇒ 组件被卸载时会抛错。
本轮用"直接查询该方法是否存在"来证实,故意不触发崩溃(触发会污染整页)。

设备侧断言 30 / 30 全部通过。

在这里插入图片描述


一、先说结论

项结果
上游最新版2.7.1;license: MIT;上游 star 277
库类型纯 JS 单文件组件库(index.js,275 行):倒计时
是否需要原生适配不需要(无 harmony/、无 NativeModules)
运行时依赖三个:lodash / sprintf-js / prop-types ⇒ 本机宿主三个都已存在 ⇒ 无需补装(本轮没踩依赖坑)
prepare / postinstall✅ 都没有
main⚠️ 交付包与 npm 包都没有 main 字段 ⇒ 靠 Node 默认的 index.js(交付包里确实有 index.js)
导出形态两个:export default CountDown + export { CountDown }(同一个 class)
需要 HAR / 权限 / ohpm❌ 都不需要
设备侧断言✅ 30 / 30 通过(A 22 + B 5 + C 3)
★★ 数字格尺寸(实测)size*2.3 × size*2.6 ⇒ size=30 预言 207×234px,实测正好 207×234px
★★ 递减量(实测)两次读取间隔 ≈7s,秒数 22 → 15(差 7)⇒ 与真实时间一致
★★ 真 bugcomponentWillUnmount 调用 AppState.removeEventListener(RN 0.65+ 已移除) ⇒ 卸载会抛错
★ 回调实测until=3 的实例:onChange 4 次、onFinish 1 次(到点后触发)✓
★ 结构4 个原型方法 + 8 个箭头函数类字段(后者 0 个在 prototype 上)
★ propTypes 缺口代码里用了 this.props.running,但 running 不在 propTypes 里

二、判定与接入

步做法结果
①package.json 里有没有 harmony.autolinking没有
②仓库里有没有 harmony/没有
③代码里有没有 NativeModules / requireNativeComponent全无
④import 图体检lodash / sprintf-js / prop-types + react / react-native ⇒ 三个真依赖(都已存在)
⑤scripts 扫 prepare 家族✅ 没有
npm install "git+https://atomgit.com/oh-react-native/react-native-countdown-component.git#2.7.1-ohos-1.0.0" --ignore-scripts

# 它依赖 lodash / sprintf-js / prop-types(若宿主缺则补装)
npm install lodash sprintf-js prop-types --ignore-scripts
// metro.config.js
watchFolders: [ path.resolve(__dirname, '../react-native-countdown-component') ],
import CountdownTestApp from './CountdownTestApp';
AppRegistry.registerComponent('CountdownTestApp', () => CountdownTestApp);
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey CountdownTestApp

不需要 HAR、权限、ohpm、link-harmony。


三、读源码取到的真值(index.js,275 行)

3.1 结构与两个导出

class CountDown extends React.Component { … }
export default CountDown;
export { CountDown };      // ★ 具名导出与默认导出是同一个 class

实测:命名空间正好 2 个 键(CountDown 与 default),且 CountDown === default ✓

3.2 ★ 成员分两类(4 + 8)

class CountDown extends React.Component {
  // ① 原型方法(4 个)
  componentDidMount() { … }
  componentDidUpdate(prevProps, prevState) { … }
  componentWillUnmount() { … }
  render() { … }

  // ② 箭头函数类字段(8 个)⇒ 挂在实例上,不在 prototype 上
  _handleAppStateChange = currentAppState => { … }
  getTimeLeft = () => { … }
  updateTimer = () => { … }
  renderDigit = (d) => { … }
  renderLabel = label => { … }
  renderDoubleDigits = (label, digits) => { … }
  renderSeparator = () => { … }
  renderCountDown = () => { … }
}

实测:4 个原型方法全部命中;8 个箭头类字段在 prototype 上一个都没有(0/8),而在实例上8 个都在 ✓

3.3 propTypes(12)与 defaultProps(10)

CountDown.propTypes = { id, digitStyle, digitTxtStyle, timeLabelStyle, separatorStyle,
  timeToShow, showSeparator, size, until, onChange, onPress, onFinish };        // 12 键

CountDown.defaultProps = {
  digitStyle: {backgroundColor: '#FAB913'},     // ★ 数字格默认黄色
  digitTxtStyle: {color: '#000'},
  timeLabelStyle: {color: '#000'},
  timeLabels: {d: 'Days', h: 'Hours', m: 'Minutes', s: 'Seconds'},
  separatorStyle: {color: '#000'},
  timeToShow: ['D', 'H', 'M', 'S'],
  showSeparator: false,
  until: 0,
  size: 15,
  running: true,                                 // ★ 代码在用,但【不在 propTypes 里】
};

⇒ running 是"未声明的 prop"(传错类型不会有任何提示)✓

3.4 ★★ 尺寸与渲染

renderDigit = (d) => (
  <View style={[styles.digitCont, {width: size * 2.3, height: size * 2.6}, digitStyle]}>
    <Text style={[styles.digitTxt, {fontSize: size}, digitTxtStyle]}>{d}</Text>
  </View>
);

renderLabel = label => (  // 标签字号 = size / 1.8
  <Text style={[styles.timeTxt, {fontSize: size / 1.8}, timeLabelStyle]}>{label}</Text>
);

renderSeparator = () => ( // 分隔符字号 = size * 1.2
  <Text style={[styles.separatorTxt, {fontSize: size * 1.2}, separatorStyle]}>:</Text>
);

renderCountDown = () => {
  const {days, hours, minutes, seconds} = this.getTimeLeft();
  const newTime = sprintf('%02d:%02d:%02d:%02d', days, hours, minutes, seconds).split(':');
  const Component = this.props.onPress ? TouchableOpacity : View;   // ★ 给了 onPress 才是可点容器
  return (
    <Component style={styles.timeCont} onPress={this.props.onPress}>
      {timeToShow.includes('D') ? this.renderDoubleDigits(timeLabels.d, newTime[0]) : null}
      {showSeparator && timeToShow.includes('D') && timeToShow.includes('H') ? this.renderSeparator() : null}
      … H / M / S 同理 …
    </Component>
  );
};

⇒ timeToShow 决定显示哪几段;showSeparator 只在相邻两段都存在时才插入 : ✓
⇒ 数字格尺寸 = size*2.3 × size*2.6(可精确预言像素)✓✓

3.5 计时逻辑

constructor(props) { super(props); this.timer = setInterval(this.updateTimer, 1000); }

updateTimer = () => {
  if (this.state.lastUntil === this.state.until || !this.props.running) { return; }   // ★ running=false 直接不动
  if (this.state.until === 1 || (this.state.until === 0 && this.state.lastUntil !== 1)) {
    this.props.onFinish?.();      // 到点
    this.props.onChange?.(this.state.until);
  }
  if (this.state.until === 0) { this.setState({lastUntil: 0, until: 0}); }
  else {
    this.props.onChange?.(this.state.until);
    this.setState({lastUntil: this.state.until, until: Math.max(0, this.state.until - 1)});
  }
};

3.6 ★★ 卸载路径用的是已被移除的 API

componentDidMount() {
  AppState.addEventListener('change', this._handleAppStateChange);
}
componentWillUnmount() {
  clearInterval(this.timer);
  AppState.removeEventListener('change', this._handleAppStateChange);   // ⚠️ RN 0.65+ 已移除
}

实测(不需要真的卸载去触发它):

★ AppState.removeEventListener 的 typeof = undefined
AppState 现有方法 = _emitter, currentState, isAvailable
(而 typeof AppState.addEventListener = 'function' ⇒ 只有【旧 API】被移除)

⇒ 旧 API 没了,新 API 在 ⇒ 这个组件的卸载路径必然抛错 ✓(本轮故意不触发)


四、验证设计

判据内容
① 尺寸(定量)数字格 = size*2.3 × size*2.6 ⇒ size=30 时应为 207×234 px
② 递减(定量计时)间隔一段时间读两次文本 ⇒ 秒数差值应≈ 真实流逝时间
③ 回调onChange 每秒一次;until=3 时 onFinish 一次
④ 老 API 存在性直接查 typeof AppState.removeEventListener(不触发崩溃)
⑤ 结构4 原型方法 + 8 箭头类字段(后者不在 prototype 上)

五、实测结果

5.1 断言 30 / 30

组内容结果
A 契约class + prototype.render、结构(4 原型 + 8 类字段)、2 个导出且同一个 class、propTypes 12 键 / defaultProps 10 键 + 关键默认值、running 不在 propTypes、AppState.removeEventListener 不存在、三个依赖可加载22/22
B 渲染与几何5 个实例渲染未抛错、标题不与状态栏重合、实例上 8 个箭头类字段都存在、读实例 state5/5
C 时序与边界onChange 次数、onFinish 次数、两条边界记录3/3
合计30/30

关键观测值:

命名空间导出 = CountDown, default
propTypes 键数 = 12          defaultProps 键数 = 10
原型方法命中数(源码 4 个) = 4
8 个箭头类字段里【在 prototype 上】的个数 = 0
★ 实例上存在的箭头类字段个数(源码 8 个) = 8
★ 实例上可读到的 state = {"until":3647,"lastUntil":3648}       ← 递减中
★ AppState.removeEventListener 的 typeof = undefined
AppState 现有方法 = _emitter, currentState, isAvailable
lodash 版本 = 4.18.1      sprintf-js 版本 = 1.0.3
★ onChange 次数 = 4        ★ onFinish 次数 = 1
UI 安全区:TITLE measureInWindow y=420 > 117 ✓

5.2 ★★ 数字格尺寸(定量)

size={30}、density 3 ⇒ 预言 30×2.3 = 69vp、30×2.6 = 78vp ⇒ 207 × 234 px。

布局 dump 实测(D1 里连续 4 个数字格中的第一个):

y=1523..1757 (h=234) x=228..435 (w=207)   ← ★ 数字格
y=1587..1692 (h=105) x=277..385 (w=108)   ← 格内数字文本(如 "00")
y=1763..1805 (h=42)  x=291..372 (w=81)    ← 格下方标签(如 "Days")

⇒ 207 × 234 px,与公式精确一致 ✓✓

5.3 ★★ 递减量(定量计时)

D1 区(until=3661,即 01:01:01)两次读取、间隔约 7 秒:

第 1 次:00 | Days | 01 | Hours | 00 | Minutes | 22 | Seconds
第 2 次:00 | Days | 01 | Hours | 00 | Minutes | 15 | Seconds

⇒ 秒数 22 → 15,差 7;而真实间隔 = 等待 3s + 两次 dump 自身耗时(每次约 1–2s)≈ 7s ✓
⇒ 递减节奏与真实时间一致 ✓✓

5.4 ★ 回调

  • onChange 4 次(每秒一次)
  • onFinish 1 次 —— until={3} 的实例在约 3 秒后确实触发了 ✓

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述


六、我第 4 次踩同一个坑(这次改了做法)

我在 A 组里断言了 prototype 上有 updateTimer / getTimeLeft / renderCountDown / renderDigit / renderSeparator —— 结果 5 条全挂,因为它们都是箭头函数类字段:

getTimeLeft = () => { … }        // ← 挂实例,不在 prototype
updateTimer = () => { … }
renderDigit = (d) => { … }

这已经是同一类错误第 4 次出现(前几次分别在 onCurrentPositionChanged、toggleItem、getDatesInWeek 上)。

这次改成了不会错的做法:先从源码把成员按写法分成两组,再断言这个"分组"本身:

原型方法命中数(源码 4 个) = 4
8 个箭头类字段里【在 prototype 上】的个数 = 0
★ 实例上存在的箭头类字段个数(源码 8 个) = 8

⇒ 这样即使某个成员改名,也能立刻看出是"分组变了"还是"我认错了" ✓

规矩(写下来提醒自己):断言任何成员之前,先在源码里确认它是 m() {} 还是 f = () => {};前者在 prototype,后者在实例。 与其猜,不如先列出来。


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

7.1 用这个库要知道的

事项说明
卸载会抛错componentWillUnmount 调用已被移除的 AppState.removeEventListener(RN 0.65+)⇒ 请自行在业务侧避免直接卸载,或打补丁
三个运行时依赖lodash / sprintf-js / prop-types ⇒ 目录接入要确认宿主已有
running 不在 propTypes传错没有提示
until 只取初始值之后由内部 setInterval 每秒递减;通过 until/id 变化可以重置
size 同时控制多处字号数字格 size*2.3 × size*2.6、数字字号 size、标签 size/1.8、分隔符 size*1.2
showSeparator 只在相邻两段都存在时才显示 :默认 false
给了 onPress 才会变成 TouchableOpacity否则容器是普通 View
main 字段缺失交付包与 npm 包都靠默认 index.js

7.2 本次验证的边界

  1. 卸载路径的抛错未运行时复现(故意不触发,改用"API 存在性"证实);
  2. AppState 的前后台补扣逻辑(_handleAppStateChange)未验证(需要真的把应用切后台再回前台);
  3. componentDidUpdate 里"until/id 变化即重置"未验证(没有动态改 until);
  4. onPress 的实际点击未验证(只验证了"给了 onPress ⇒ 容器是 TouchableOpacity"这一层与源码一致);
  5. timeLabels 自定义、digitTxtStyle/timeLabelStyle/separatorStyle 的视觉未逐项像素核对;
  6. timeToShow 只验证了 ['M','S'] 与默认全四段两种组合;
  7. id prop 的行为未验证;
  8. 只在一台模拟器上验证(Pura X View,density 3,1320×2232),没有真机;未做跨平台对照。

未改动库代码。


八、常见问题

Q1:怎么用?

import CountDown from 'react-native-countdown-component';

<CountDown
  until={60 * 60 * 2}          // 秒
  size={24}
  digitStyle={{backgroundColor: '#FAB913'}}
  digitTxtStyle={{color: '#000'}}
  timeLabelStyle={{color: '#000'}}
  timeToShow={['H', 'M', 'S']}
  showSeparator
  onFinish={() => console.log('done')}
  onChange={(u) => console.log(u)}
/>

Q2:数字格多大?

width = size*2.3、height = size*2.6(vp)。size=30 时实测 207×234 px(density 3 ⇒ 69×78vp)。

Q3:为什么我的组件一卸载就报 AppState.removeEventListener is not a function?

因为源码在 componentWillUnmount 里调用了这个已经被移除的 API(RN 0.65+)。实测:

typeof AppState.removeEventListener === 'undefined'      // 已经没了
typeof AppState.addEventListener    === 'function'       // 新 API 还在

规避:在业务侧包一层,卸载前先别让这个组件真的 unmount(例如常驻挂载),或者用补丁方式处理(本库源码未改动)。

Q4:想只显示"分:秒"怎么办?

<CountDown timeToShow={['M', 'S']} showSeparator until={125} />

⇒ showSeparator 只在相邻两段都存在时才插入 :。

Q5:running={false} 有什么用?

暂停。updateTimer 里 if (… || !this.props.running) return; ⇒ 完全不动(但 setInterval 仍在跑)。⚠️ 它不在 propTypes 里。

Q6:倒计时能在后台继续走吗?

组件监听 AppState 变化:切到后台时记录时间,回到前台时按"流失的秒数"补扣。⚠️ 但监听器的移除用的是已废弃 API(见 Q3),所以这条路径本身有风险。

Q7:until 改了会怎样?

componentDidUpdate 里比较 until / id,变化时用新的 until 重置。⚠️ 本轮未验证这条。

Q8:这个库需要鸿蒙侧做什么特殊处理吗?

不需要。 它是纯 JS、无原生模块、不需要 HAR / 权限 / ohpm。要注意的是三个运行时依赖与卸载路径的老 API。


小结

react-native-countdown-component 是纯 JS 的单文件倒计时组件(275 行)。设备侧 30 / 30 断言通过。

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

  1. ★★ 尺寸与时间是"可算的量",都要拿去和实测对。
    尺寸:size*2.3 × size*2.6 ⇒ size=30 预言 207×234px,实测正好 207×234px;
    时间:两次读取间隔 ≈7s,秒数 22 → 15(差 7) ⇒ 递减节奏与真实时间一致。
    ⇒ 这两条都比"看起来在倒数/方块大小差不多"硬得多。

  2. ★★ 老库里可能藏着"已经被移除的 API",而且能用"存在性检查"安全地证实。
    componentWillUnmount 里调用 AppState.removeEventListener(RN 0.65+ 已移除)⇒ 卸载必抛错。
    我没有真的去卸载触发它(会污染整页),而是直接查 typeof AppState.removeEventListener === 'undefined',并顺带确认新 API addEventListener 仍在 ⇒ 结论明确、代价为零。

  3. ★ 结构断言要按"源码写法"来分组,不要凭印象。
    这个库有 4 个原型方法 + 8 个箭头函数类字段。我一开始按"都在 prototype 上"断言 ⇒ 挂了 5 条;改成"分组本身作为断言"(4/4 命中、8 个类字段 0 个在 prototype、且 8 个都在实例上)之后一次通过。
    ⇒ 同一件事已经栽了 4 次,所以把规矩明确写下来:断言成员前先看源码里是 m() {} 还是 f = () => {}。

  4. ★ 三个依赖都在宿主里 ⇒ 预检阶段就能确定"不用补装"。 这条经验的价值在于:"依赖要不要补装"是可以在动手之前就回答的问题 —— 预检第 ④ 步(import 图)+ 宿主依赖预查已经连续多次提前给出答案。

  5. 顺带发现的包装问题:交付包与 npm 包都没有 main 字段,靠 Node/Metro 默认的 index.js 兜底(能跑,但不规范);spec.json 仍缺 upstreamCommit。


本篇用到的库

项内容
三方库react-native-countdown-component(上游 2.7.1 的鸿蒙适配版)
适配仓库https://atomgit.com/oh-react-native/react-native-countdown-component
适配 TAG2.7.1-ohos-1.0.0
需要 HAR / 权限 / ohpm都不需要(纯 JS)
库类型纯 JS 单文件组件库:倒计时(天:时:分:秒)
运行时依赖lodash / sprintf-js / prop-types
prepare / postinstall✅ 都没有
许可证MIT
上游仓库https://github.com/talalmajali/react-native-countdown-component(★277)
宿主工程RNOH084Demo(测试页 rnAppKey = CountdownTestApp)
# 从适配仓库安装
npm install "git+https://atomgit.com/oh-react-native/react-native-countdown-component.git#2.7.1-ohos-1.0.0" --ignore-scripts

# 本地目录接入(三个依赖需已在宿主)
npm install ../react-native-countdown-component --ignore-scripts
npm install lodash sprintf-js prop-types --ignore-scripts
// metro.config.js
watchFolders: [ path.resolve(__dirname, '../react-native-countdown-component') ],
import CountDown from 'react-native-countdown-component';

// ① 基本用法
<CountDown
  until={60 * 60 * 2}                        // 秒
  size={24}                                  // 数字格 = size*2.3 × size*2.6
  digitStyle={{backgroundColor: '#FAB913'}}  // 默认就是黄色
  digitTxtStyle={{color: '#000'}}
  timeLabelStyle={{color: '#000'}}
  timeToShow={['H', 'M', 'S']}               // 只显示要的段
  showSeparator                              // 相邻段之间显示 ":"
  onFinish={() => {}}                        // 到点
  onChange={(until) => {}}                   // 每秒
  onPress={() => {}}                         // 给了它 ⇒ 容器变成 TouchableOpacity
/>

// ② 暂停
<CountDown until={99} running={false} />     // ⚠️ running 不在 propTypes 里

// ⚠️ 该组件的 componentWillUnmount 调用了已被移除的 AppState.removeEventListener
//    ⇒ 卸载时会抛错(本轮用存在性检查证实,未触发)
# 换页启动测试页 —— force-stop 不能省
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey CountdownTestApp

判据的复现方式

# ① 尺寸:size*2.3 × size*2.6(vp)⇒ ×density 得像素
#    在 dump 里找数字格节点(size=30 时应为 207×234 px)
devecocli ui layout | Select-String 'Custom#View'
# ② 递减:间隔一段时间读两次文本,比较秒数差与真实时间差
devecocli ui layout | Select-String 'Text#Paragraph'     # 读 "…|22|Seconds" → "…|15|Seconds"
# ③ 老 API 存在性(不触发崩溃):
#    typeof AppState.removeEventListener === 'undefined'(而 addEventListener 仍在)

验证环境

项版本
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(页面留白取 48vp)
宿主依赖lodash 4.18.1 / sprintf-js 1.0.3 / prop-types 15.8.1(都已存在 ⇒ 无需补装)
宿主 HAP 产物entry-default-signed.hap
本次构建assembleHap 首轮 4 分 22 秒(27/32);修结构断言后 4 分 16 秒 ⇒ 30/30
验证规模设备侧 30 / 30 断言;尺寸定量 1 组(预言 207×234 ⇒ 实测 207×234 px);递减定量 1 组(≈7s 内 22→15);回调 2 项(onChange 4 次 / onFinish 1 次);结构 2 项(4 原型 + 8 类字段);老 API 存在性 2 项;UI 安全区 1 项

相关社区与项目地址

项目地址
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

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐