大概是去年底,产品经理把一个需求拍到了我桌上:把现有 App 里的用户信息卡片搬到一块开源鸿蒙设备上,屏幕上跑的还必须是 OpenHarmony 系统。当时我第一反应是“这不得用 ArkUI 重写一遍”,但打开工程一看,逻辑层、接口层、状态管理全在 React Native 的 JS 代码里,真要重写,等于把整个业务再养一遍。
所以最终被推到台前的方案只有一个:React Native for OpenHarmony。这个方向在社区里常被简称为 rn_for_openharmony,也叫 RNOH。它做的不是把 RN 代码翻译成 ArkTS,而是保留 React Native 的 JS 框架和虚拟节点树,在 OpenHarmony 侧提供一个能和 ArkUI 对接的原生适配层。听起来很理想,但实际能不能把一张从草图演化来的用户信息卡片完整跑通,谁也没底。
这篇文章就是我那段时间的真实记录:从产品的手绘线稿开始,拆盒子、画布局、写组件,再到 RK3568 开发板上烧镜像、接真机、调 bug。如果你也在考虑把 RN 业务往 OpenHarmony 设备上迁移,这张卡片可以作为最小验证单元,帮你快速判断这条路的可行性和真正的成本在哪里。
1. 为什么我会把“一张用户信息卡片”当成试刀的第一关
1.1 这个组件到底验证了哪些技术点
选用户信息卡片作为第一个试点,不是因为 UI 简单,恰恰是因为它面积小但覆盖面够宽。一张普通到不能再普通的卡片,通常包含圆形头像、昵称、组织单位、联系方式、操作按钮这五类元素。放到 RN for OpenHarmony 的适配场景里,它背后的技术点大概是这些:
- 文本渲染:中文、数字、邮箱长文本的断行与截断表现。
- 图片能力:网络头像加载、圆角裁切、加载失败时的降级占位。
- 布局还原度:flexDirection、alignItems、flexShrink 这些属性在 OpenHarmony 适配层上是否真的生效。
- 层级表达:圆角、边框、阴影在 ArkUI 容器内的显示语义。
- 点击交互:触摸反馈、点击区域是否和 RN 在 Android/iOS 上一致。
- 列表复用:卡片进入 FlatList 之后,滚动性能和刷新是否正常。
这六点如果能全部通过,那后续页面里的列表页、详情页、表单组件基本上可以照方抓药。如果过不了,那你在代码量庞大之前就该知道问题在哪,而不是等整个业务迁移过去才发现跑不动。
1.2 RNOH 不等于“把代码拿过来就能跑”
先建立一个基本认知:React Native for OpenHarmony 并不是 React Native 官方直接支持的平台,它是开源社区和厂商共同推进的一个适配分支。它保留了 RN 的开发范式,但把原生侧的 iOS/Android 实现替换成了 OpenHarmony 能力。也就是说,你在 JSX 里写的 <View>、<Text>、<Pressable> 会被映射成 OpenHarmony 里的 ArkUI 组件或者自定义封装组件,再由原生渲染管线输出到屏幕。
但要注意,这种映射不是逐字逐句的翻译。RN 里很多样式属性依赖 iOS 的 Core Animation 或 Android 的 Material 规范,比如 elevation、shadowColor、shadowOffset,在 OpenHarmony 上不一定有完全对应的底层实现。你过去在 Android 上调得很愉快的阴影,可能在 RK3568 上怎么看怎么不对劲。
这种差异决定了“拿代码跑一遍”只是起点,真正的工作在于逐个验证 UI 属性是否被正确消费。用一张小卡片做测试,成本最低,收效最快。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从手绘线稿到布局结构:先画盒子再写组件
2.1 把“我觉得好看”翻译成可执行的盒子树
动手敲代码之前,我习惯先在一张白纸上画“盒子树”,把设计稿拆成没有视觉语言的层级关系。产品经理给的那张草图上,信息位置大概是:顶部一行放头像和姓名,中间放手机号、邮箱,底部是两个操作按钮。听上去已经很明确,但直接照着写代码还是会乱,因为你不清楚头部和中间信息区到底是共享一个容器,还是应该各自独立。
我最终拆出来的结构是这样:
code复制UserCard
├── HeaderRow
│ ├── Avatar(56x56,圆角裁成圆形)
│ └── UserMeta
│ ├── UserName
│ └── OrgName
├── Divider(细分隔线)
├── ContactRows
│ ├── ContactRow: Phone
│ └── ContactRow: Email
└── ActionRow
├── PrimaryButton(拨打电话)
└── GhostButton(发送消息)
这样拆分最大的好处是让数据的边界变得清楚。HeaderRow 渲染的是用户基础身份信息,ContactRows 渲染的是联系方式,ActionRow 承载的是业务操作。后续如果要在姓名旁边加一个“VIP 标识”,只需要在 UserMeta 内部扩展,不会影响其他区域。
2.2 为什么优先选 Flexbox 而不是绝对定位
很多新手在设计“右上角放一个图标”“底部按钮固定”这种需求时,第一反应是 position: 'absolute'。但在 OpenHarmony 这一层适配还没完全稳定的时候,我建议你尽量只用 Flexbox 把内容“弹”到目标位置,绝对定位只用于局部细节装饰。
原因是绝对定位依赖父容器坐标系,而在 RNOH 当前的适配实现里,容器尺寸上报和嵌套层级一旦多起来,坐标系偶尔会出现偏差。你用 Flexbox 的话,纵使某个样式属性映射不完美,最坏结果也只是间距偏大或对齐不齐,不会出现整块 UI 漂移的问题。
卡片在这里用的是最朴素的思路:外层 card 容器负责白底、圆角、内边距,内部从上到下排列三个区域;头部用横向 Flex,信息行也用横向 Flex,按钮行用 flexDirection: 'row' + gap 拉开间距。全部压力都给到 Flexbox,结构简单,表现也最稳定。
2.3 关于宽高的单位换算
React Native 里大家习惯直接写 pt(逻辑像素),Android 的 dp、iOS 的 pt、OpenHarmony 的 vp 从理念上说非常接近:它们都是一种和物理像素解耦的逻辑单位。RNOH 适配层通常会把 RN 的数值映射到 OpenHarmony 的 vp 上。
也就是说,你不必在写代码时反复去想“这块 RK3568 的屏幕分辨率是 1920x1080”,只需要关心 UI 稿在标准逻辑宽度下是什么样子。比较常见的坑是:拿设备的 HDMI 输出分辨率去当设计基准,觉得 1080p 很宽,于是把卡片宽度固定成 800,结果换到另一台高分屏上整个布局就歪了。
正确的做法永远是让卡片宽度跟着父容器走。如果你希望它在宽屏上也不至于无限拉长,可以设置一个 maxWidth,配合居中的 alignSelf。这个思路在 Android 平板上成立,到 OpenHarmony 宽屏设备上依然成立。
3. 真机前夜:RK3568、设备树与 USB 调试通道的真实状况
3.1 为什么我选了 RK3568 而不是 RK3588
为了跑这个卡片,我手头同时有 RK3568 和 RK3588 两块板子。它们在性能和定位上的差距非常明显:
| 对比项 | RK3568 | RK3588 |
|---|---|---|
| CPU 架构 | 4 核 Cortex-A55 | 8 核 A76 + A55 |
| 图形/多媒体能力 | 够用,轻量业务流畅 | 强,复杂动画更从容 |
| 板卡价格 | 相对便宜 | 相对贵 |
| OpenHarmony 资料 | 社区案例多,适配较成熟 | 官方和厂商持续跟进中 |
| 适用场景 | 列表页、信息展示、简单交互 | 视频处理、复杂动效、多任务 |
我最终选择 RK3568 作为验证机,理由很简单:用户信息卡片是轻量 UI 任务,用不到 RK3588 的额外算力;而且 RK3568 的开发板价位更友好,遇到问题也更容易在社区找到同类案例。如果只是验证 RNOH 的渲染链路,没必要一上来就上顶配。等业务里出现大量动画、页面跳转或者多窗口,再考虑把验证环境切到 RK3588 也不迟。
3.2 同样都是 RK3568,为什么设备树那么难选
搜索“openharmony 的 rk3568 有许多设备树到底咋选”的人,多半是在烧录镜像时被那个 dtb 列表卡住了。没碰过 dts/dtb 的 RN 开发者第一次看到这东西会非常懵:明明是同一颗 RK3568 芯片,为什么固件包里躺着十几个 .dtb?
因为设备树描述的不只是 CPU,还包括这块板子上的 DDR 型号、LCD 屏幕接口、触摸 IC、以太网 PHY、USB HUB、GPIO 扩展等等。同样用 RK3568,不同厂商做的底板外设完全不同,所以必须用不同的 dtb 来告诉内核该怎么初始化硬件。
我的建议非常直接:如果你用的是正规开发板,不要自己在一个通用 OpenHarmony 镜像里挨个试 dtb。去找板厂提供的专用固件,里面已经绑定了正确的设备树编译产物。烧录后先验证屏幕、触摸、网络是否正常,再开始搭 RN 环境。否则后面 UI 显示异常时,你会分不清是代码问题还是底层显示链路不对。
如果一定要确认当前板子加载的是哪个设备树,可以通过串口看内核启动日志中的相关行,通常会有明确的 kernel: OF: fdt: Machine model: xxx 一类的输出。比起盲猜,这个方式可靠得多。
3.3 hdc 连接不上时先查这三件事
OpenHarmony 设备调试用的工具是 hdc,用法和 adb 很像,但连接不上时排查顺序并不完全一样。我自己的经历里,最容易出的三个问题分别是:
- 设备上没有开启开发者模式和调试授权。和手机一样,第一次连接时屏幕上会弹出授权确认框,如果没点允许,hdc 自然看不到设备。
- USB 线不是数据线。很多 Type-C 线只支持充电,插上后系统能充电但完全没有枚举设备。换一根确认能传数据的线,往往是解决此类问题最快的办法。
- 电脑侧驱动或 udev 权限没配好。Linux 下常见,需要把 OpenHarmony 设备的 USB Vendor ID 加入 udev 规则;Windows 下则要安装对应驱动。
排查完这三件事,再执行 hdc list targets,一般就能看到了。如果还不行,可以试着执行 hdc kill 后重新启动 hdc server,很多时候是服务进程状态卡住了。
我还被问过“hdc 都通了,但 OpenHarmony 应用想直接操作 USB 外设怎么办”。用户信息卡片如果放在自助设备上,可能要接扫码枪或读卡器,这就牵扯到底层的 usbManager,以及社区里常见的使用 libusb 的封装方式。但那个场景属于系统级能力,RN 侧不能直接操作,一般需要通过原生模块桥接或者 ExtensionAbility 再把数据抛给 JS。早期验证 UI 时,先不用碰这块。
4. UserCard 组件的代码落地:头像区、信息区和操作区的实现取舍
4.1 先搭组件的类型和数据模型
终于到了写代码这一步。在 OpenHarmony 上跑 RN,项目目录比普通 RN 工程多了一个 OpenHarmony 原生壳工程,但业务代码的编写方式变化不大。我这个组件放在 src/components/UserCard/ 下,先定义用户数据模型:
tsx复制export type UserProfile = {
id: string;
name: string;
organization: string;
avatarUrl: string;
phone: string;
email: string;
};
不额外引入重型状态管理库,先用 props 把数据传给组件。这样组件是纯展示组件,后面接 Redux、Zustand 还是接口返回数据都方便。
4.2 主结构代码:用 View、Text、Pressable 搭出三个区域
卡片主组件的完整结构:
tsx复制import React from 'react';
import {
Image,
Pressable,
StyleSheet,
Text,
View,
} from 'react-native';
import type { UserProfile } from './types';
type Props = {
user: UserProfile;
onCall: (phone: string) => void;
onMessage: (user: UserProfile) => void;
};
export function UserCard({ user, onCall, onMessage }: Props) {
return (
<View style={styles.card}>
<View style={styles.headerRow}>
<Image
source={{ uri: user.avatarUrl }}
style={styles.avatar}
/>
<View style={styles.userMeta}>
<Text style={styles.userName} numberOfLines={1}>
{user.name}
</Text>
<Text style={styles.orgName} numberOfLines={1}>
{user.organization}
</Text>
</View>
</View>
<View style={styles.contactRow}>
<Text style={styles.contactLabel}>手机</Text>
<Text style={styles.contactValue}>{user.phone}</Text>
</View>
<View style={styles.contactRow}>
<Text style={styles.contactLabel}>邮箱</Text>
<Text style={styles.contactValue} numberOfLines={1}>
{user.email}
</Text>
</View>
<View style={styles.actionRow}>
<Pressable
style={({ pressed }) => [
styles.primaryBtn,
pressed && styles.pressed,
]}
onPress={() => onCall(user.phone)}
>
<Text style={styles.primaryText}>拨打电话</Text>
</Pressable>
<Pressable
style={({ pressed }) => [
styles.ghostBtn,
pressed && styles.grayed,
]}
onPress={() => onMessage(user)}
>
<Text style={styles.ghostText}>发送消息</Text>
</Pressable>
</View>
</View>
);
}
值得强调的是,这里按钮没有用 TouchableOpacity,而是统一用 Pressable。原因是 TouchableOpacity 的透明度反馈在 Android 上天然支持,但到 OpenHarmony 适配层是否稳定,不同版本表现不一致。Pressable 的 style 回调可以精确控制按下态的背景色或透明度,视觉反馈完全由 RN 样式控制,较少依赖原生端实现。
4.3 样式部分:用边框代替阴影,少一点花活
样式是我在 OpenHarmony 真机上调整最多的地方。RNOH 对基础 Flexbox 布局的支持已经比较可靠,但阴影这类风格化属性要谨慎使用。一开始我按 Web 习惯写了 shadowColor、shadowOpacity、elevation,结果在 RK3568 上有的不生效,有的整卡渲染特别糊。
现在的做法是用细边框和底色把层级做出来:
tsx复制const styles = StyleSheet.create({
card: {
backgroundColor: '#FFFFFF',
borderRadius: 16,
borderWidth: StyleSheet.hairlineWidth,
borderColor: 'rgba(0, 0, 0, 0.08)',
paddingVertical: 16,
paddingHorizontal: 16,
},
headerRow: {
flexDirection: 'row',
alignItems: 'center',
},
avatar: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: '#E8ECF4',
},
userMeta: {
marginLeft: 12,
flexShrink: 1,
},
userName: {
fontSize: 18,
fontWeight: '600',
color: '#1A1D26',
},
orgName: {
marginTop: 4,
fontSize: 13,
color: '#6A7080',
},
contactRow: {
flexDirection: 'row',
marginTop: 10,
alignItems: 'center',
},
contactLabel: {
width: 46,
fontSize: 14,
color: '#8A90A0',
},
contactValue: {
flex: 1,
fontSize: 14,
color: '#1A1D26',
},
actionRow: {
flexDirection: 'row',
marginTop: 18,
columnGap: 12,
},
primaryBtn: {
flex: 1,
backgroundColor: '#2563EB',
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
justifyContent: 'center',
},
primaryText: {
color: '#FFFFFF',
fontSize: 15,
fontWeight: '500',
},
ghostBtn: {
flex: 1,
backgroundColor: '#F2F4F7',
borderRadius: 10,
paddingVertical: 12,
alignItems: 'center',
justifyContent: 'center',
},
ghostText: {
color: '#1A1D26',
fontSize: 15,
},
pressed: {
opacity: 0.85,
},
grayed: {
backgroundColor: '#E4E7EC',
},
});
关于头像,圆形的实现仍然是 borderRadius 取宽高的一半;头像加载时的灰色背景作为占位,这样即使网络图还没回来,布局也不会塌掉。头像网络图加载失败要不要放默认图,可以在 Image 的 onError 里通过本地 state 切换成本地资源,例如:
tsx复制const [avatarError, setAvatarError] = React.useState(false);
...
<Image
source={avatarError ? require('./assets/avatar_default.png') : { uri: user.avatarUrl }}
style={styles.avatar}
onError={() => setAvatarError(true)}
/>
这里有个建议:不要因为 RNOH 可能支持 iconfont 就一上来引入整套图标字体。我最初想用 OpenHarmony 官方方向里提到的 lucide 图标库做手机/邮箱小图标,但图标字体在适配层上需要把字符渲染成 glyph,某些版本对动态字体的处理并不完整。更稳的方法是把这两个图标直接导出成 PNG 资源,像处理头像一样放进 Image,等适配稳定了再替换图标库。
4.4 把卡片丢进列表:一张卡不叫页面
业务里几乎不会只有一个用户,所以卡片写完要顺手验证它在 FlatList 里的表现:
tsx复制<FlatList
data={users}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<UserCard
user={item}
onCall={(phone) => handleCall(phone)}
onMessage={(user) => handleMessage(user)}
/>
)}
contentContainerStyle={styles.listContent}
/>
列表外层加个 paddingVertical,卡片之间用 gap 或者卡片自带 marginBottom 隔开。这里用 marginHorizontal: 12 让卡片离屏幕边缘有一点点距离。
5. 第一次跑真机的崩溃与修复:图片、字体、布局与性能隐患
5.1 卡片在宽屏上“撑不满”或“被拉伸”
第一次在 RK3568 上看到页面时,卡片宽度表现和我预期完全不一样。我原本以为它会自动横向撑满父容器,但实际上屏幕很宽,卡片内容只占了一小部分,看起来像一块贴在左侧的便签。
排查后确认,问题不在 RN 代码,而是外层容器没有提供明确的“可用宽度”约束。在 OpenHarmony 上,窗口宽度不一定是手机那种 360vp 或 390vp 的逻辑宽度,宽屏输出时 RN 拿到的窗口基准会大很多。
我的修法是在卡片外面包了一层带 flex: 1 的页面容器,并让卡片容器保持 alignSelf: 'stretch' 或者干脆不写宽度,靠父容器撑开。如果希望宽屏展示时卡片不过分宽,再套一层最多宽 480 的居中容器:
tsx复制const styles = StyleSheet.create({
page: {
flex: 1,
backgroundColor: '#F5F6FA',
},
centerWrapper: {
flex: 1,
alignItems: 'center',
},
cardWrapper: {
width: '100%',
maxWidth: 480,
alignSelf: 'center',
},
});
这段经历提醒我:在做 OpenHarmony 适配时,任何固定 px 的宽度都要再三考虑,宁可让布局“弹”起来,也不要写死。
5.2 中文文本截断和邮箱省略号异常
第二个坑来自文本。头部用户姓名比较短时一切正常,但换成“王小明”这类会多一个测试字符后,发现第二个 Text 会溢出甚至把旁边内容挤下去。虽然我写了 numberOfLines={1},但姓名过长时的表现仍然和手机不同。
后来判断是因为外层没有限制 Text 的父容器宽度。修复方式是在 userMeta 上加 flexShrink: 1,让它在头部区域宽度不够时收缩自己的宽度,而不是把内容往外推。邮箱那行也类似,contactValue 加了 flex: 1,确保长邮箱会在剩余空间里省略,而不是把整行撑破。
如果发现在 OpenHarmony 上默认字体渲染中文有些发虚或字形宽度和预期不同,可以尝试在 Text 样式中显式指定系统字体族。RNOH 底层对接 OpenHarmony 系统字体后,中文显示一般会回退到系统默认字体,不太需要额外处理。但如果产品对字形有明确要求,那你就得在工程资源里引入自定义字体,这就可能需要额外的原生栈配合,不做早期版本的硬性项。
5.3 头像加载慢导致整卡闪烁
第一次用内网 IP 加载远程头像时,卡片在图片返回前会先显示灰色占位,然后图片跳出来,这个过程本来没问题。但在低端 RK3568 上,如果列表里同时有十几张卡片一起发请求,Image 加载线程会被占满,滚动时会出现明显的闪烁和占位色块反复闪。
解决办法有两层。第一层是在服务端把头图裁剪成较小的尺寸,比如 200x200,不要直接拉原图。第二层是给 Image 设置 resizeMode="cover" 并且尽量复用同一尺寸的缓存,减小解码压力。
如果项目有条件,可以引入图片懒加载库,但 RNOH 上第三方图片库未必百分百兼容。我的建议是先跑通自带 Image,再谈优化。
5.4 开发调试:Metro 热更新和离线 Bundle
RN 开发通常靠 Metro 热更新,但在 OpenHarmony 设备上,真机能否加载 Metro 的构建服务,取决于设备能不能访问到开发机的局域网 IP。如果 RK3568 在独立网段、无法回连电脑,你会发现改了代码怎么刷新画面都不变。
这时最直接的方式是打离线 Bundle。在 RNOH 工程中,命令和标准 RN 类似,只是产物输出目录会不同。大致是:
bash复制npx react-native bundle \
--platform openharmony \
--dev false \
--entry-file index.js \
--bundle-output bundles/index.harmony.bundle \
--assets-dest bundles
打完包后,原生工程启动时会优先加载本地 Bundle,就不再依赖 Metro 服务。开发节奏可以这样安排:白天用 Metro 联调样式,跑不顺了再打离线包验证真机表现。注意把 Bundle 生成规则写进文档,不然同事接手时很容易在旧包上反复踩坑。
6. 卡片之后的边界工作:数据列表、拨号能力与原生模块桥接
6.1 把静态数据换成接口返回
演示用的 users 是写死的,真正落地必然要接接口。RNOH 上 fetch 和标准 RN 的差异不大,网络权限则需要在原生工程的 module.json5 里声明,不像 Android 工程那样只看 AndroidManifest.xml。这一步很多 RN 开发者不熟悉,容易漏。
从组件视角看,我推荐的做法是写一个 useUserList 的 Hook 负责拉数据、管理 loading 和 error,然后在组件里把 users 数组交给 FlatList。UserCard 不关心数据从哪来,只负责渲染。这样从单卡验证无缝过渡到列表页。
6.2 “点击拨打电话”比想象中麻烦
用户信息卡片很自然的扩展动作是点击拨打电话。RN 官方生态里通常会调用 Linking.openURL('tel:10086'),但在 RNOH 里这一步不一定稳定,因为它依赖系统对 tel scheme 的处理。搜索“rn 调用电话功能”的人,很多就是在寻找 RN 如何正确唤起系统电话。
通用的兜底方案是自己封装一个原生模块。在 OpenHarmony 原生侧用 ArkTS 实现一个 DialerModule,对外暴露 call(phone: string) 方法,然后在 JS 侧用 NativeModules 调用。电话权限要在应用的权限声明文件里申请用户授权,并且要遵守系统的隐私合规要求。这个工作量比想象中大,但它也说明了卡片“看起来简单,真正做完才是一整套流程”。
6.3 可复用的最小样板建议
如果你打算把一张用户信息卡片作为自己项目的试刀样板,我建议不要直接拿我上面的代码复制完事,而是按这个顺序验证:
- 用一个只有
<Text>的页面跑通从源码到 OpenHarmony 真机的完整链路。 - 引入自定义组件和 props,确认 JS 到原生渲染的数据通道没有断点。
- 加入网络图和 FlatList,观察图片加载和滚动性能。
- 加入交互事件,看
Pressable在 ArkUI 侧是否有正确的触摸反馈。 - 最后再打磨圆角、字体、间距这些视觉细节。
这样每一步出问题时,问题边界都足够小。我第一次就是跳过了第 1 步,直接写整张卡片,结果 UI 不出来时根本分不清是打包问题、容器尺寸问题还是组件语法问题,排查起来很痛苦。
从产品经理那张草图到 RK3568 屏幕上真正渲染出卡片,整个过程用了大概一周。真正写 JSX 的时间不超过半天,剩下大部分时间都花在熟悉 OpenHarmony 的设备树、调试工具和适配差异上。但恰恰是第一张卡片让我把这条技术路线里最陌生的部分挨个摸了一遍。之后再做第二个、第三个组件时,节奏明显快了很多。如果你也在评估 RN 业务接入 OpenHarmony,我建议你先别急着铺大规模页面,找一个像用户信息卡片这样的小组件,完整地走一遍设计、开发、真机验证的闭环。这条路能不能走通、坑有多深,会比任何技术选型文档都回答得更准确。
