本文记录把 react-native-emoji-selector(Emoji 选择器)适配到 HarmonyOS 的完整过程。

它做的事很专一:给一个 Emoji 选择面板 —— 顶部分类 Tab、可选的搜索框、下面是按列排布的 Emoji 网格,点一个就把字符回调出去;可选支持"最近使用"历史。

结论先说:这是最省事的一类库,装完直接就能用。 具体地说:

  • 纯 JS、单文件(index.js 10235 B)
  • 只有 1 个运行时依赖:emoji-datasource(纯 JS 数据包)
  • 没有 prepare/postinstall(不会卡住安装)
  • 老写法九项预扫几乎全空(无 createClass、无 React.PropTypes、无字符串 ref、无 mixins、无 componentWillMount/componentWillReceiveProps、无 ViewPropTypes、无 measure())
  • 设备侧 23 / 23 条判据全部通过

★★★★ 一、最值得记的一件事:一个"按源码该崩、实测却不崩"的隐患

库的第 10 行是从 react-native 里解构 AsyncStorage:

import {
  View, Text, StyleSheet, TouchableOpacity, TextInput,
  Platform, ActivityIndicator,
  AsyncStorage,                 // ← L10
  FlatList
} from "react-native";
…
L159: let history = await AsyncStorage.getItem(storage_key);
L176: AsyncStorage.setItem(storage_key, JSON.stringify(value));
L183: let result = await AsyncStorage.getItem(storage_key);

而 AsyncStorage 早在 RN 0.60 就被移出核心(改为社区包 @react-native-async-storage/async-storage)。在当前 RN 的 index.js 里,它被定义成一个"一访问就抛错"的 getter:

// react-native/index.js
Object.defineProperty(module.exports, 'AsyncStorage', {
  get() {
    throw new Error('AsyncStorage has been removed from react-native core. ' + …);
  },
  …
});

⇒ 按这个源码,任何对 RN.AsyncStorage 的读取都应该抛错 ⇒ 这个库"与 react-native 的具名导入"理应崩 ✗

我把这件事做成了页面上的一条判据(用 try/catch 探测,因为 typeof AsyncStorage 本身就会抛):

NOTE | ★ 访问 RN.AsyncStorage 是否抛错 = false
NOTE | ★ 抛错信息 =

⇒ 实测:在这套 RNOH 上,访问 RN.AsyncStorage 【不抛错】 ✓✓

⇒ 这正是这个库能正常加载、23 条判据全通过的原因 ✓
⇒ 也就是说:“上游 RN core 里它抛错” 并不等于 “在这套 RNOH 上它抛错” —— 必须实测 ✓

注意边界:本轮验证的是"读取该属性不抛错",以及默认路径(showHistory: false)下库运行正常。
showHistory 打开后那条真实读写链路(loadHistoryAsync / addToHistoryAsync)本轮没测到(页面上那个取证按钮我没点中),
见"本次验证的边界"一节。

★★★★ 二、几何可以精确预言:colSize = floor(width / columns)

组件用 onLayout 量自己的宽度,然后算每格尺寸:

handleLayout = ({ nativeEvent: { layout } }) => {
  this.setState({ width: layout.width }, () => {
    this.prerenderEmojis(() => {
      this.setState({ isReady: true });
    });
  });
};

prerenderEmojis(callback) {
  …
  this.setState({
    emojiList,
    colSize: Math.floor(this.state.width / this.props.columns)   // ← 公式
  }, callback);
}

⇒ colSize 是个可以拿 width 直接算出来的量 ⇒ 我把两个实例的 state 通过 ref 读出来核对:

实例columns实测 state.width公式预言实测 state.colSize
E16(默认)408floor(408/6) = 68✅ 68
E24408floor(408/4) = 102✅ 102
NOTE | E1.state.width = 408     NOTE | E1.state.colSize = 68     NOTE | E1.state.isReady = true
PASS | ★★ colSize 精确等于 floor(width / 6)          | 期望=68  | 实际=68
PASS | ★★ E2(columns=4)colSize 精确等于 floor(width/4) | 期望=102 | 实际=102
PASS | ★ isReady 已变 true(prerenderEmojis 回调链跑通)

⇒ 顺带确认了一条平台经验:在 RNOH 上 onLayout 是可靠的(isReady 从 false 走到了 true,说明 onLayout → setState → prerenderEmojis → 回调 → setState 整条链都跑通了)。
(相比之下,用 measure() 量布局在 RNOH 上会拿不到高度 —— 这个库没有用 measure(),选对了。)

★★★★ 三、它把"可以直接单测"的东西导出得清清楚楚

const charFromUtf16 = utf16 =>
  String.fromCodePoint(...utf16.split("-").map(u => "0x" + u));
export const charFromEmojiObject = obj => charFromUtf16(obj.unified);
export const Categories = { all: {...}, history: {...}, … };   // 11 个分类

⇒ 两个纯函数(含一个导出)+ 一个常量对象 ⇒ 不需要渲染就能断言 ✓

实测(unicode → 字符):

输入 unified含义实测输出
"1F600"单码位😀 ✓
"1F44D"单码位👍 ✓
"1F1E8-1F1F3"多码位(区域指示符拼国旗)🇨🇳 ✓
PASS | ★★ charFromEmojiObject("1F600") === 😀
PASS | ★★ 多码位(国旗 CN)也能拼出 | 期望=🇨🇳 | 实际=🇨🇳
PASS | ★★ Categories 恰好 11 个键 | 期望=11 | 实际=11

⇒ 多码位那一条特别有价值:它验证了 split("-") + String.fromCodePoint(...) 这条链在两个码位时也正确 ✓

★★★★ 四、判据不必依赖点击:用 ref 调它的方法

组件是个 class,而且它的交互方法全是箭头类字段(挂在实例上)⇒ 可以 ref 直接调用:

handleTabSelect = category => {…};   // 切分类
handleSearch     = q => {…};         // 改搜索词
handleEmojiSelect = emoji => {…};    // 选中一个 emoji(内部会回调 props.onEmojiSelected)

实测(完全不点屏幕):

NOTE | handleTabSelect(Categories.food) 调用 = ok
NOTE | handleSearch("smile") 调用 = ok
NOTE | returnSectionData() 返回条数 = 1766
NOTE | onEmojiSelected = "😀"            ← ★★★ 回调精确拿到了字符
PASS | ★★ returnSectionData() 返回数组且非空
SUMMARY | 合计 23/23 通过,失败 0

⇒ 调用 handleEmojiSelect({unified:"1F600"}) 之后,props.onEmojiSelected 收到了 "😀" ✓✓
⇒ 而且 handleSearch("smile") 之后页面上标题从 All 变成了 Search Results ✓(渲染层的旁证)

同时验证了"两类成员混用"这件事(这个库两种写法都有):

// 原型方法:returnSectionData() / prerenderEmojis(cb) / componentDidMount() / render()
// 箭头类字段:handleTabSelect / handleEmojiSelect / handleSearch /
//             addToHistoryAsync / loadHistoryAsync / renderEmojiCell / handleLayout
NOTE | 原型方法命中数(源码 5 个) = 5
NOTE | 7 个箭头类字段里在 prototype 上的个数(应为 0) = 0
PASS | ★ 5 个原型方法都在 prototype 上            | 期望=5 | 实际=5
PASS | ★★ 7 个箭头类字段都不在 prototype 上       | 期望=0 | 实际=0

⇒ 如果按"handleSearch 一定在原型上"写断言,第一条就会挂 ⇒ 写断言前先看源码写法 ✓

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述


五、接入与预检

步做法结果
①harmony.autolinking没有
②harmony/ / ios/ / android/都没有
③原生痕迹预扫✅ 无原生源文件、无 NativeModules
④★ 老写法九项预扫✅ 几乎全空(见下)
⑤平台分支预扫⚠️ 仅 1 处且无害:L344 color={Platform.OS === "android" ? theme : "#000000"}(ActivityIndicator 的颜色)
⑥measure() 排查✅ 未使用;用的是 onLayout(在 RNOH 上可靠)
⑦scripts 扫 prepare 家族✅ 无(只有 prettify)
⑧依赖✅ 仅 emoji-datasource ^6.0.0(纯 JS 数据包);无 peerDependencies

老写法九项逐项结果:

项结果
React.createClass✅ 空
React.PropTypes✅ 空
从 react 具名导入 PropTypes✅ 空
ViewPropTypes / TextPropTypes✅ 空
字符串 ref="x" / this.refs.x✅ 空
mixins:✅ 空
componentWillReceiveProps✅ 空
componentWillMount✅ 空
measure(✅ 空
# 从适配仓库安装
npm install "git+https://atomgit.com/oh-react-native/react-native-emoji-selector.git#0.2.0-ohos-1.0.0" --ignore-scripts
# ★ 必须补装它自己的运行时依赖(本地目录 / file: 接入不会自动装)
npm install emoji-datasource@^6.0.0 --ignore-scripts

⚠️ 版本要按库声明的范围装:库要的是 ^6.0.0,而 emoji-datasource 的 latest 已经是 16.x。
装 latest 会拿到不兼容的数据结构 ⇒ 务必装 ^6.0.0(本次装到的是 6.1.1)。


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

类别结果
交付包内容index.js(10235 B)+ index.d.ts + 文档;单文件实现 ✓
spec.json⚠️ 缺 upstreamCommit
validation⚠️ "build-pass-device-pending"
⚠️ 声明的基线reactNative: "0.88.0" + rnoh: "0.84.4"(verification: evidence/batch50/…)⇒ 两字段自相矛盾(rnoh 0.84.4 是配 RN 0.84.x 的),且既不是当前宿主(0.84.1/0.84.3)也不是权威 0.88 基线

关于基线:本次验证在 RN 0.84.1 + RNOH 0.84.3 上完成。这个库没有任何已移除的写法,
行为只依赖 onLayout、FlatList、StyleSheet 与 String.fromCodePoint ⇒ 与 RN 小版本无关 ⇒ 本轮结论跨基线成立。


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

7.1 用这个库要知道的

事项说明
★ 依赖只有 emoji-datasource,但版本要卡 ^6.0.0它的 latest 是 16.x,直接装 latest 会拿到不兼容的数据结构
★★ AsyncStorage 是从 react-native 解构的在上游 RN core 里它是抛错 getter;这套 RNOH 上实测读取不抛错 ⇒ 库能正常加载;但换到别的基线时这条要重新验证
★ showHistory 打开后会走 AsyncStorage 的真实读写loadHistoryAsync / addToHistoryAsync;本轮未验证
★ 用 onLayout 量宽度colSize = floor(width / columns);在 RNOH 上 onLayout 可靠
★ 交互方法是箭头类字段handleTabSelect / handleSearch / handleEmojiSelect 在实例上(可用 ref 调);returnSectionData / prerenderEmojis 在原型上
★ 导出的纯函数可直接单测charFromEmojiObject({unified})、Categories
★ 组件内含 FlatList不要把它放进同方向的 ScrollView(会触发 VirtualizedLists should never be nested… 警告并影响虚拟化)
⚠️ 一处平台分支ActivityIndicator 的颜色:Platform.OS === "android" ? theme : "#000000" ⇒ 鸿蒙走 else(黑色)

7.2 本次验证的边界

  1. ★★ showHistory 的真实读写链路未验证(页面上的取证按钮没点中)⇒ loadHistoryAsync / addToHistoryAsync 在 RNOH 上是否能真正存取,未测;
  2. ★ 我自己测试页有个缺陷:把 <EmojiSelector>(内含 FlatList)放进了 <ScrollView> ⇒ 触发了 VirtualizedLists should never be nested… 警告(logbox_notification 3 个)——
    这是我的页面布局问题,不是库的问题;
  3. 未逐格核对 Emoji 网格的实际渲染(只验证了 colSize 与 returnSectionData() 的条数);
  4. 未验证搜索过滤的准确性(只验证了"能调用、标题变成 Search Results、返回 1766 条");
  5. 未验证分类 Tab 的切换效果(只验证了 handleTabSelect 能被调用);
  6. 未验证 shouldInclude 过滤 prop;
  7. 未验证 theme / placeholder / showSectionTitles 等外观 prop 的实际效果;
  8. 未验证历史记录去重逻辑(addToHistoryAsync 里按 unified 去重那段);
  9. 未在 RN 0.88.0 / RNOH 0.84.4 上复验(交付包声明的基线);
  10. 未测真机与其它屏幕宽度(只验证了容器宽 408px 这一种情况);
  11. 未做跨平台对照。

未改动库代码。


八、常见问题

Q1:它在 RN 0.84 上能跑吗?

能,本次实测 23/23 条判据全通过:库正常加载、onLayout 链路跑通、colSize 与公式精确一致、导出的纯函数与 ref 编程调用都正确。

Q2:AsyncStorage 不是早就移出核心了吗?为什么它没崩?

因为它虽然是从 react-native 解构的,但当前这套 RNOH 上访问 RN.AsyncStorage 不抛错(实测 try/catch 探测结果为 false)。
而上游的 react-native/index.js 里它是抛错 getter ⇒ 两边行为不同 ⇒ 这种"按源码该崩、实测不崩"的点,只能实测。
⚠️ 换基线时请重新验证这一条。

Q3:装依赖时要注意什么?

卡版本:

npm install emoji-datasource@^6.0.0 --ignore-scripts

库声明的范围是 ^6.0.0,而它的 latest 已经是 16.x ⇒ 装 latest 会拿到不兼容的数据结构。
另外本地目录 / file: 方式接入时,库自己的依赖不会自动装 ⇒ 必须显式补装。

Q4:为什么我的 Emoji 面板一直转圈(ActivityIndicator)?

isReady 只有在 onLayout 拿到宽度、prerenderEmojis 回调跑完之后才会变 true。
如果它一直转圈,说明容器没有给你宽度(例如父容器高度/宽度为 0)⇒ colSize 也就算不出来。

Q5:怎么在不点屏幕的情况下测试它的交互?

用 ref 直接调它实例上的方法(它们都是箭头类字段):

const ref = useRef(null);
<EmojiSelector ref={ref} onEmojiSelected={c => console.log(c)} … />

// 之后
ref.current.handleTabSelect(Categories.food);
ref.current.handleSearch('smile');
ref.current.handleEmojiSelect({unified: '1F600'});   // ⇒ onEmojiSelected 收到 "😀"
ref.current.returnSectionData();                     // 原型方法,返回当前列表

Q6:可以把 Emoji 选择器放进 ScrollView 里吗?

不建议。 组件内部是 FlatList,放进同方向的 ScrollView 会触发
VirtualizedLists should never be nested inside plain ScrollViews with the same orientation…
(本次测试页就因此产生了 3 条 LogBox 通知)⇒ 用固定高度容器或让它自己滚动。

Q7:想拿 Emoji 字符怎么拼?

用导出的纯函数:

import {charFromEmojiObject} from 'react-native-emoji-selector';
charFromEmojiObject({unified: '1F600'});          // "😀"
charFromEmojiObject({unified: '1F1E8-1F1F3'});    // "🇨🇳"(多码位也支持)

小结

react-native-emoji-selector 是单文件、纯 JS、只有一个纯 JS 依赖的 Emoji 选择器,写法很现代(老写法九项预扫几乎全空)。设备侧 23 / 23 条判据全部通过,装完即可用。

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

  1. ★★★★ “按源码该崩、实测却不崩” —— 又一次用实测钉死了推断。
    库从 react-native 解构 AsyncStorage,而上游 RN core 把它定义成抛错 getter ⇒ 按理该崩;
    实测访问它 不抛错(try/catch 探测为 false)⇒ 这正是库能正常加载的原因 ✓
    ⇒ 跨平台 API 的可用性必须实测,别拿上游源码直接推(另一条同类经验:状态栏高度这类"某平台专有"的量,也可能已经被正确实现)。

  2. ★★★★ 几何公式可以直接预言,onLayout 是可靠那一侧。
    colSize = floor(width / columns) ⇒ 实测 floor(408/6)=68 与 floor(408/4)=102 全部精确命中 ✓
    同时 isReady 从 false 走到 true,证明 onLayout → setState → 回调 → setState 整条链在 RNOH 上跑通 ✓
    (反过来,用 measure() 量布局在 RNOH 上拿不到高度 —— 这个库没用它,选对了。)

  3. ★★★★ 导出的纯函数是最便宜的硬判据。
    charFromEmojiObject 三条断言:1F600 → 😀、1F44D → 👍、多码位 1F1E8-1F1F3 → 🇨🇳(后者验证了 split("-") + fromCodePoint 这条链)✓

  4. ★★★★ 交互方法都是箭头类字段 ⇒ 可以用 ref 编程调用,判据不依赖点击。
    handleSearch("smile")、handleTabSelect(Categories.food)、handleEmojiSelect({unified:"1F600"})
    ⇒ onEmojiSelected 精确收到 "😀" ✓,returnSectionData() 返回 1766 条 ✓

  5. ★★★ 两类成员混用必须先看源码再写断言。
    5 个原型方法(returnSectionData/prerenderEmojis/…)+ 7 个箭头类字段(handleTabSelect/handleSearch/…)
    ⇒ 实测 5/5 在 prototype、0/7 箭头字段在 prototype ✓

  6. ★★ 依赖要按库声明的范围装。 库要 emoji-datasource@^6.0.0,而它 latest 已是 16.x ⇒ 装 latest 会拿到不兼容数据结构;
    且本地目录 / file: 接入不会自动装库自己的依赖 ⇒ 必须显式补装 ✓(本次装到 6.1.1)

  7. ★ 测试页自身的坑也要如实写出来。 我把含 FlatList 的组件放进了 ScrollView ⇒ 触发 VirtualizedLists should never be nested… 警告(LogBox 3 条)——
    那是我的页面问题,不是库的问题 ✓

  8. ★ 判据逐条直写日志(PASS|描述|期望|实际)⇒ 采集端 grep 即得全证据,不把证据留存绑在 UI 上 ✓


本篇用到的库

项内容
三方库react-native-emoji-selector(上游 0.2.0 的鸿蒙适配版)
适配仓库https://atomgit.com/oh-react-native/react-native-emoji-selector
适配 TAG0.2.0-ohos-1.0.0
需要 HAR / 权限 / ohpm都不需要(纯 JS、单文件)
库类型Emoji 选择器(分类 Tab + 搜索 + 网格 + 可选历史)
运行时依赖⚠️ emoji-datasource(必须装 ^6.0.0;latest 是 16.x)
prepare / postinstall✅ 都没有
许可证MIT
上游仓库https://github.com/arronhunt/react-native-emoji-selector(★307,pushed 2025-03)
宿主工程RNOH084Demo(测试页 rnAppKey = EmojiSelectorTestApp)
# 从适配仓库安装(⚠️ 必须补装它自己的依赖,并卡版本)
npm install "git+https://atomgit.com/oh-react-native/react-native-emoji-selector.git#0.2.0-ohos-1.0.0" --ignore-scripts
npm install emoji-datasource@^6.0.0 --ignore-scripts
import EmojiSelector, {Categories, charFromEmojiObject} from 'react-native-emoji-selector';

// ① 基本用法
<EmojiSelector
  onEmojiSelected={emoji => console.log(emoji)}   // ⇒ 例如 "😀"
  showSearchBar
  showTabs
  showHistory={false}                             // 打开后会走 AsyncStorage 的真实读写
  columns={6}
  placeholder="Search..."
  theme="#007AFF"
  style={{height: 320}}                           // ★ 给固定高度,别放进同方向的 ScrollView
/>

// ② 不点屏幕也能测(方法都是【箭头类字段】,挂在实例上)
const ref = useRef(null);
// ref.current.handleTabSelect(Categories.food);
// ref.current.handleSearch('smile');
// ref.current.handleEmojiSelect({unified: '1F600'});   // ⇒ onEmojiSelected 收到 "😀"
// ref.current.returnSectionData();                     // 原型方法:返回当前列表(实测 1766 条)
// ref.current.state.colSize                            // 应等于 floor(state.width / columns)

// ③ 纯函数(可直接单测)
charFromEmojiObject({unified: '1F600'});               // "😀"
charFromEmojiObject({unified: '1F1E8-1F1F3'});         // "🇨🇳" 多码位也支持

// ⚠️ AsyncStorage 是 L10 从 react-native 解构的:上游 core 里是抛错 getter,
//    但这套 RNOH 上【读取不抛错】⇒ 库能正常加载(换基线请重新验证)
// ⚠️ 组件内部是 FlatList,放进同方向 ScrollView 会触发 VirtualizedLists 嵌套警告
# 换页启动测试页 —— force-stop 不能省
hdc shell aa force-stop com.rnoh084.demo
hdc shell aa start -b com.rnoh084.demo -a EntryAbility --ps rnAppKey EmojiSelectorTestApp

判据的复现方式

# ① 契约与纯函数:逐条 PASS/FAIL 日志
hdc shell "hilog -x | grep emoji-test" | Select-String 'PASS|FAIL|SUMMARY|NOTE'
#    ⇒ charFromEmojiObject("1F600")=😀 / ("1F44D")=👍 / (1F1E8-1F1F3)=🇨🇳
# ② 几何:用 ref 读 state,核对 colSize = floor(width / columns)
#    ⇒ E1(columns=6)width=408 ⇒ colSize=68;E2(columns=4)⇒ colSize=102
# ③ 编程调用:handleTabSelect / handleSearch / handleEmojiSelect(⇒ onEmojiSelected)
# ④ AsyncStorage 探测(★ 必须 try/catch —— typeof 本身会抛)
#    ⇒ 这套 RNOH 上:访问 RN.AsyncStorage 不抛错

验证环境

项版本
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
宿主依赖⚠️ 需补装 emoji-datasource@^6.0.0(本次装到 6.1.1)
宿主 HAP 产物entry-default-signed.hap
本次构建assembleHap 3 分 42 秒 ⇒ 一次成功(打包与构建退出码都检查了)
验证规模23 条判据全部通过(契约 9 + 几何 4 + 编程调用 4 + 安全区与记录若干);纯函数 3 条精确断言(含多码位);几何公式 2 组精确命中(68 / 102);ref 编程调用 3 个方法;4 张截图互异

⚠️ 基线说明:交付包 spec.json 声明 RN 0.88.0 + RNOH 0.84.4(两字段自相矛盾),本次验证在 RN 0.84.1 + RNOH 0.84.3 上完成;
本库无任何已移除写法,行为只依赖 onLayout / FlatList / StyleSheet / String.fromCodePoint ⇒ 结论跨基线成立。
但 AsyncStorage 那条(“读取不抛错”)建议在换基线时重新验证一次。


相关社区与项目地址

项目地址
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开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐