如果你在OpenHarmony上做资讯类App,十个有九个会直接扑向ArkUI,但我当时的情况比较特殊:手上已经有一套在iOS和Android上跑得很顺的React Native版Steam资讯App,重新用ArkUI把每个页面画一遍,成本高得让人肉疼。这篇文章记录的就是我在OpenHarmony上用RN(React Native)移植Steam资讯App,并且把"特惠游戏"模块真正落地跑通的全过程。内容不涉及复杂的系统源码,主要面向有RN基础、想把现有业务快速迁到OpenHarmony上的开发者。你可以把它当成一份实战笔记,里面的工程配置、数据解析、卡片实现和踩坑排查链路,都是我实际跑过、验证过的。
1. 为什么是"RN + OpenHarmony"这套组合来做Steam资讯
1.1 一个我反复被问的问题
"OpenHarmony不是有ArkUI吗,为什么还绕一圈用RN?"这是我每次发相关帖子都会被问到的问题。答案其实分两层:第一层是业务复用。资讯类App重逻辑、轻系统能力,页面无非就是列表、详情、收藏、设置,RN代码里绝大部分可以直接平移过来,不需要重新实现。第二层是团队成本。团队里已经有一批熟练的RN开发,让他们去学ArkUI再写一遍业务,培养周期和排期成本都是实际压力。
但选择RN这套组合也有很实在的代价。OpenHarmony上的RN生态远没有Android和iOS成熟,很多npm包装上去就跑不起来,轻则某个原生模块找不到,重则直接白屏。所以这个项目从一开始就不是"无脑复用",而是"能用纯JS解决的坚决不碰原生依赖"。
1.2 OpenHarmony上RN的现状与选型判断
先说结论:OpenHarmony上跑RN已经不是一个"能不能跑"的问题,而是"跑起来之后你要填多少坑"的问题。社区维护的react-native-openharmony适配方案,核心思路是把RN的JS运行时和渲染链路接到OpenHarmony的组件体系上,RN的生命周期、组件树、事件分发都保留下来,只是底层的Native View换成了OpenHarmony自己的实现。换句话说,RN业务代码基本不变,但原生依赖需要逐个确认兼容性。
选型时我最关注两方面:一是RN版本,二是原生依赖的轻重。RN版本上,我当时用的是社区适配比较成熟的0.72.x,因为那个版本对应的issue和示例最多,遇到问题能搜到答案。后来也有更新的版本被适配,但我个人不推荐项目中途升级RN大版本,尤其是这类跨平台移植项目,升一次版本的排查面太大了。
至于原生依赖,我的判断标准很简单:一个npm包如果底层依赖了Android的View系统或者iOS的UIKit,它在OpenHarmony上大概率不能直接跑。纯TS/JS实现的库(比如axios、dayjs、zustand)基本没问题,UI组件库和系统能力库就需要万分谨慎。不同情况的选择可以参考下面这个表:
| 项目情况 | 我的建议 | 说明 |
|---|---|---|
| 已有RN项目要迁移 | 保持原RN大版本 | 避免连带升级所有原生依赖 |
| 从零开始的新项目 | 选社区适配最活跃的版本 | 示例多、踩坑记录多 |
| 重度依赖原生SDK(相机/地图/蓝牙) | 暂时别迁移 | 等对应SDK的OH适配方案 |
| 依赖很少的资讯/电商类项目 | 可以动手 | 优先用纯JS库替代原生模块 |
1.3 特惠游戏功能在整个资讯App里的定位
为什么要先把"特惠游戏"这个模块单独拎出来做?因为这个模块几乎覆盖了资讯类App的所有典型技术点:远程HTTP请求、列表渲染与滚动性能、远端图片加载、金额计算与格式化、倒计时状态管理、收藏与本地缓存、跨页面跳转。做完特惠模块,其他的资讯列表、评测文章、促销日历基本就是换一套数据源的复制粘贴。
我的实操习惯是先挑最麻烦的模块验证技术路线。特惠模块对数据准确性和性能要求都不低,能在OpenHarmony上把这个功能跑顺,就说明这套RN方案对整个App是可行的。如果一开始只做个静态展示页,后面遇到真正的复杂交互时才发现路线走不通,返工成本就大了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工程初始化:OpenHarmony侧的RN适配要动哪些东西
2.1 不要从示例仓库改起
很多人喜欢先把OpenHarmony官方示例工程拉下来,然后删掉Demo页面往里面填业务代码。这种做法的坑在于:示例工程会捆绑大量为了演示功能而引入的原生依赖,你删代码的时候很难判断某一个依赖到底被谁引用了,删不干净后面全是隐患。我推荐的做法完全反过来,先用RN官方cli初始化一个标准RN工程,再把react-native核心依赖替换成适配OpenHarmony的版本,然后单独引入harmony壳工程。这样工程结构还是标准的RN结构,业务代码和OpenHarmony原生壳完全分离。
我当时的初始化步骤大致如下:
bash复制# 1. 创建RN工程,带上具体版本
npx @react-native-community/cli init SteamSpecial --version 0.72.10
# 2. 进入工程,把react-native替换为OH适配版本
npm install @react-native-oh/react-native --save
完成之后,工程根目录下会有一套标准的RN源码结构,同时多出一个harmony目录。接下来用DevEco Studio打开harmony目录,等首次构建完成,再把应用部署到模拟器或者开发板上。这里我特别提醒一句:不要为了省事去复制别人项目的整个node_modules,依赖版本一旦不对,跑起来的问题会非常难查。
2.2 HarmonyOS工程侧的配置:权限、签名和bundle加载
RN for OpenHarmony的工程配置里,有三个地方最容易忽略,而且每一个都会直接导致运行失败。
第一是网络权限。HarmonyOS的应用默认是没有网络访问能力的,必须在module.json5里显式声明ohos.permission.INTERNET权限。这个权限不配置的话,表现非常诡异:Metro打包服务连不上、远程图片加载不出来、接口请求全部失败,而且报错信息还不一定直接指向权限问题。
json5复制{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
}
]
}
}
第二是签名。DevEco Studio调试时通常会自动生成一个调试签名,连接模拟器没问题,但如果是真机或者RK3568这类开发板,就需要去AppGallery Connect申请并配置自己的调试签名,否则安装阶段就会被系统拦下来。这个问题通常会以"Install Failed"的形式出现,但有些板子甚至只提示"未安装应用",很容易让人误判成代码问题。
第三是bundle加载地址。RN应用在Debug模式下要靠Metro打包服务提供JS Bundle,默认地址是localhost:8081。模拟器上跑没问题,但装到真机或开发板上时,设备里的localhost指向的是设备自己,不是你的电脑。这时候需要把开发机上8081端口的数据映射到设备上,让设备能访问到Metro服务:
bash复制hdc reverse tcp:8081 tcp:8081
这个命令我在调试期间几乎每次重连设备都要执行一次,后面第5章会专门展开讲。
2.3 推荐的最小目录结构和依赖清单
特惠模块跑通之后,我整理了一份最小可运行的项目结构,给准备上手的人参考:
text复制SteamSpecial/
├── index.js // RN入口
├── package.json
├── src/
│ ├── api/ // 接口请求与数据解析
│ │ ├── steamApi.ts
│ │ └── types.ts
│ ├── components/ // 业务组件
│ │ ├── GameCard.tsx
│ │ └── PriceTag.tsx
│ ├── screens/
│ │ ├── HomeScreen.tsx
│ │ └── DetailScreen.tsx
│ ├── store/ // 跨页面状态
│ │ └── useSpecialStore.ts
│ └── utils/ // 格式化、缓存等
│ ├── format.ts
│ └── storage.ts
├── harmony/ // OpenHarmony壳工程
│ ├── entry/
│ └── oh-package.json5
└── android/ // Android壳工程
依赖清单上,我最终跑通特惠模块用的包非常精简:
| 依赖 | 作用 | 备注 |
|---|---|---|
| @react-native-oh/react-native | RN核心运行时 | 替代官方react-native |
| react-native-safe-area-context | 安全区适配 | 需要找适配OH的版本 |
| axios | HTTP请求 | 纯JS实现,通用 |
| dayjs | 时间计算 | 纯JS实现,通用 |
| zustand | 内存状态管理 | 纯JS实现,通用 |
一定要记住这个原则:能少装一个原生依赖,就少装一个。很多页面卡顿、白屏、闪退问题,根源都不是你业务代码写得不好,而是某个库在OH上根本找不到原生实现。
3. 特惠数据从哪来:数据源选型、字段解析和降级方案
3.1 我的数据源选型结论
Steam官方并没有一个专门给你做"特惠游戏列表"的公开批量接口,这是整个功能实现里最大的不确定性。我调研之后把可用的方案归成三类,列了个对比表:
| 方案 | 优点 | 缺点 | 我的结论 |
|---|---|---|---|
| Steam Web API的appdetails接口 | 稳定、字段标准、免费、无需key | 一次只能查单个app,需要自己拼列表 | 作为价格和详情主数据源 |
| 第三方比价服务API | 能直接给特惠榜单和史低判断 | 需要申请key,有调用限额 | 作为发现特惠榜单的来源 |
| 直接抓取Steam商店页面HTML | 数据最全、实时 | 页面结构频繁调整、请求频繁会触发风控 | 坚决不采用 |
我最终的方案是"第三方特惠榜单发现游戏ID + Steam官方appdetails补充价格和封面图"。第三方榜单解决"哪些游戏正在打折"这个问题,官方接口解决"这个游戏现在卖多少钱、原价多少、折扣多少"这个信任度问题。两层数据叠加起来,才是一张完整的特惠卡片。
3.2 Steam官方接口返回的价格字段怎么解析
Steam的appdetails接口调用方式很简单:
text复制https://store.steampowered.com/api/appdetails?appids=730&cc=cn&l=schinese
返回结果里,价格信息在data.price_overview字段下。这个字段不是每个游戏都有,免费游戏通常没有价格信息,所以解析前必须先判空。
核心字段如下:
typescript复制interface PriceOverview {
currency: string; // 货币类型,如 CNY、USD
initial: number; // 原价,单位是"最小货币单位"
final: number; // 现价,单位是"最小货币单位"
discount_percent: number; // 折扣百分比,0~100
initial_formatted: string; // 原价格式化字符串,如 "¥128"
final_formatted: string; // 现价格式化字符串,如 "¥38"
}
这里最大的坑是单位。initial和final的单位不是"元",也不是"美元",而是最小货币单位,按人民币场景理解就是"分"。如果不做换算直接显示,你会看到原价"12800",现价"3800"这样的离谱价格。我封装了一层解析函数:
typescript复制const parsePrice = (p?: PriceOverview) => {
if (!p || typeof p.final !== 'number') {
return null;
}
const original = p.initial / 100;
const current = p.final / 100;
const discount = p.discount_percent || 0;
return {
original,
current,
discount,
currency: p.currency,
originalText: p.initial_formatted,
currentText: p.final_formatted,
};
};
至于final_formatted这种已经格式化好的字符串,我建议只用作展示兜底,实际计算一律用数字,否则排序和筛选时你会被字符串坑得体无完肤。
3.3 批量特惠列表的拼接逻辑
既然appdetails一次只能查一个app,那拿到特惠榜单的ID列表之后,就得并发请求多个appdetails。这里要注意,千万不能直接Promise.all把几十个请求一次性全打出去。在开发板上文件描述符有限,几十个HTTP并发很可能直接把网络栈打挂,而且对接口方也不友好。
我用的分批并发逻辑是每批10个:
typescript复制const BATCH_SIZE = 10;
async function fetchAppDetailsInBatches(appIds: number[]) {
const results: Record<number, AppDetailData> = {};
for (let i = 0; i < appIds.length; i += BATCH_SIZE) {
const batch = appIds.slice(i, i + BATCH_SIZE);
const batchResult = await Promise.allSettled(
batch.map(async (id) => {
const res = await fetch(
`https://store.steampowered.com/api/appdetails?appids=${id}&cc=cn&l=schinese`
);
const json = await res.json();
return { id, json: json[String(id)] };
})
);
batchResult.forEach((item, index) => {
if (item.status === 'fulfilled' && item.value.json?.success) {
results[batch[index]] = item.value.json.data;
}
});
}
return results;
}
Promise.allSettled的作用是让单个失败不拖垮整批请求,失败的appid最多在最后对不上数据时被过滤掉,不至于让整个页面报错。拼接完数据后,我会统一去重,因为第三方榜单里偶尔会出现同一个游戏重复出现的情况,去重用appid作为key最可靠。
3.4 网络异常时的本地兜底与Mock
开发阶段最影响效率的不是代码报错,而是网络一抖页面就白屏,你分不清到底是代码错了还是网络断了。所以我在特惠模块里做了三层兜底:
第一层:本地内置了一批mock特惠数据,启动后先用mock数据渲染页面结构,让UI可以先摆出来。这样你能直观看到卡片排版是否正常,不受外部接口影响。第二层:接口真正返回数据并解析成功后,用真实数据替换mock数据。第三层:把最近一次成功的接口结果缓存到本地文件,下次启动时优先展示缓存,用户即使在没有网络的环境下打开App,也能看到上一次的特惠内容。
这套兜底方案除了提升用户体验,更大的价值是开发调试时可以让接口稳定性和UI开发解耦,不用每次改完样式都去求接口给力。
3.5 为什么我没有直接去抓网页
有些朋友建议我直接抓Steam商店的搜索结果页,把网页上的游戏卡片、价格、折扣全部解析出来。我试过,最后放弃了。原因有三点:
第一,网页结构变动太频繁。今天能用的选择器,下周可能就全失效了,这个维护成本是持续性的。第二,商店页面为了支撑前端渲染,HTML结构里塞了大量非必要数据,解析出来的字段又杂又乱,拿它做数据源很容易出脏数据。第三,频繁请求商店页面明显会触发风控,尤其在开发调试阶段,短时间内请求几十次,很容易被临时限制访问。
这里还要坦白一个细节:Steam官方appdetails接口并不保证返回优惠的截止时间,它主要给的是价格和折扣比例。所以我的倒计时数据来自第三方榜单接口额外提供的促销结束时间字段,如果你自己对接,需要确认数据源里有没有这个字段,没有的话,要么接一个促销日历类型的接口,要么由运营后台手工配置。这个取舍必须在方案设计阶段想清楚,别等UI写完了才发现倒计时无数据可显示。
4. 特惠卡片的核心实现:价格计算、折扣标识和倒计时
4.1 卡片组件拆解
一张特惠游戏卡片,我拆成了四个视觉区块:封面图、标题行、价格行、倒计时行。封面图用RN内置的Image加载Steam CDN图片,标题行放游戏名和一个评分标签,价格行放"当前价 + 原价划线 + 折扣角标",倒计时行放"xx天xx小时xx分"。
基本的卡片结构长这样:
tsx复制import React from 'react';
import { View, Text, Image, Pressable, StyleSheet } from 'react-native';
interface GameCardProps {
game: SpecialGame;
onPress: (appId: number) => void;
}
function GameCard({ game, onPress }: GameCardProps) {
return (
<Pressable style={styles.card} onPress={() => onPress(game.appId)}>
<Image source={{ uri: game.headerImage }} style={styles.cover} />
<View style={styles.info}>
<Text style={styles.title} numberOfLines={1}>
{game.name}
</Text>
<PriceTag price={game.price} />
</View>
<CountdownText endTime={game.offerEndAt} />
</Pressable>
);
}
样式上需要注意的是,OpenHarmony的RN适配对一些复杂阴影、渐变类的样式支持不一定和iOS一致,我在OH上发现圆角、边框这类基础样式没问题,但层级较深的阴影有时会渲染异常,所以卡片阴影我改用了一张半透明底图来模拟,稳妥很多。
4.2 折扣率不是取整那么简单
discount_percent这个字段看起来直接拿来显示就行,实际上有两个必须处理的边界情况。
第一个是折扣为0。很多游戏即使当前没有打折,接口也会返回discount_percent: 0,这种游戏根本不应该出现在"特惠"列表里。我处理时把discount === 0的项直接过滤掉,否则用户会看到一堆标注"0% OFF"的卡片,非常影响信任度。
第二个是脏数据容错。我遇到过discount大于0但initial和final相等的情况,这种数据明显是上游同步出了问题,如果按照折扣去算,会出现原价和现价一样还标着"-50%"的尴尬。所以我判断"是否真的是特惠"时用的是一个综合条件,而不是只看折扣字段:
typescript复制const isRealDiscount = (price: PriceInfo | null) => {
if (!price) return false;
return (
price.discount > 0 &&
price.original > price.current
);
};
4.3 倒计时组件的生命周期管理
特惠卡片上最显眼的动态元素就是倒计时。实现本身不复杂,拿到促销结束时间offerEndAt,每秒算一次剩余时间:
tsx复制function CountdownText({ endTime }: { endTime: number }) {
const [now, setNow] = React.useState(Date.now());
React.useEffect(() => {
const timer = setInterval(() => {
setNow(Date.now());
}, 1000);
return () => clearInterval(timer);
}, []);
const remainMs = Math.max(0, endTime - now);
const days = Math.floor(remainMs / 86400000);
const hours = Math.floor((remainMs % 86400000) / 3600000);
const minutes = Math.floor((remainMs % 3600000) / 60000);
return (
<Text style={styles.countdown}>
{days}天{hours}小时{minutes}分
</Text>
);
}
这里最关键的代码只有一行:return () => clearInterval(timer)。在RN for OpenHarmony上,setInterval不会因为你切走页面就自动暂停,它会一直跑下去,组件如果被反复创建和销毁,定时器就会越积越多,最后整个页面卡得点不动。这个问题我在真机上踩得很惨,后面第5章会展开讲完整的排查过程。
4.4 排序和筛选的交互逻辑
特惠列表光是展示还不够,用户通常需要排序和筛选。我做了三类排序:折扣力度从高到低、现价从低到高、结束时间从近到远。筛选维度则比较简单:只看5折以下,只看历史最低价。
这些计算我统一放在useMemo里,避免每次渲染都对整个列表重新计算:
typescript复制const visibleList = useMemo(() => {
const filtered = specialGames.filter((item) => {
if (!isRealDiscount(item.price)) return false;
if (filter50Off && item.price.discount < 50) return false;
if (onlyLowest && !item.isLowest) return false;
return true;
});
const sorted = [...filtered].sort((a, b) => {
if (sortType === 'discount') {
return b.price.discount - a.price.discount;
}
if (sortType === 'price') {
return a.price.current - b.price.current;
}
return a.offerEndAt - b.offerEndAt;
});
return sorted;
}, [specialGames, sortType, filter50Off, onlyLowest]);
排序和筛选不要在渲染函数里直接写,否则列表一长,每次状态变化都会有肉眼可见的卡顿感。useMemo在RN for OpenHarmony上同样有效,而且非常值得使用。
4.5 收藏和本地记录
收藏特惠游戏是资讯类App的基本操作。这里我要提醒一个OpenHarmony特有的坑:社区里常用的@react-native-async-storage/async-storage在OH上的适配版本不一定和你手上的RN版本匹配,直接装原生版本很可能在构建时报错。
我最开始想着省事,直接装了官方async-storage,结果编译过不了,排查了半天发现是OH的原生模块没配对。后来我没有去折腾原生库,而是自己写了一个简单的文件存储工具,把收藏的appid列表序列化成JSON,写到应用沙箱里的一个文件,读取时再反序列化。对于收藏这种低频小数据量场景,完全够用,而且不引入任何原生依赖。
typescript复制import { getContext } from '@ohos/hypium'; // 示意,实际取沙箱路径
import { writeText, readText } from '@ohos.file.fs';
const FAVORITE_FILE = 'favorites.json';
export async function getFavorites(): Promise<number[]> {
try {
const content = await readText(FAVORITE_FILE);
return content ? JSON.parse(content) : [];
} catch {
return [];
}
}
export async function toggleFavorite(appId: number) {
const list = await getFavorites();
const idx = list.indexOf(appId);
if (idx >= 0) {
list.splice(idx, 1);
} else {
list.push(appId);
}
await writeText(FAVORITE_FILE, JSON.stringify(list));
}
当然,文件读写方案在并发场景下会有限制,但对个人项目来说足够稳定。真要上生产环境,再去关注有没有官方维护的异步存储适配版。
5. 真机排查实录:从白屏到卡顿的几条典型链路
5.1 白屏:bundle加载与端口映射
第一次把应用装到RK3568开发板上时,出现的是最经典的白屏问题。屏幕亮着,应用进程也在,但整个页面一片白,没有报错弹窗。因为模拟器上一切正常,所以问题明显出在设备环境。
排查链路我记录一下,方便大家对照:
- 先打开DevEco Studio的Log窗口,过滤关键词
ReactNative。日志里出现Unable to load script,说明JS Bundle没加载成功。 - 确认Metro打包服务是否在开发机上正常运行,地址是
localhost:8081。 - 但开发板里的App访问
localhost:8081,指向的是开发板自己的端口,当然连不上Metro。解决方式是用hdc命令建立端口映射:
bash复制hdc reverse tcp:8081 tcp:8081
- 映射建立后,在Metro终端按一下
r触发重新加载,页面就出来了。
这里顺便解释一下为什么用hdc而不是adb,因为OpenHarmony的调试工具链是hdc,和Android的adb是两个体系,cmd窗口直接敲adb会提示找不到命令。后续所有设备相关操作,比如安装应用、查看日志、传输文件,都是走hdc。
5.2 接口通了但图挂了:图片加载与缓存
接口数据和列表文字都正常,但所有游戏封面图都是空白。这个问题比白屏温和,但同样让人抓狂。我排查后发现图片URL是https的,而且RN内置Image在OH上对于远端图片默认不做太多处理,如果证书链不完整,或者URL中包含特殊字符,图片加载就可能静默失败。
我的处理方式有三个:
第一,图片地址统一用https,不用http,避免混合内容限制。第二,给Image组件设置背景色和默认占位图,这样即使图片加载失败,卡片也不会白成一块。第三,对榜单列表的图片做了预加载处理,提前调用Image.prefetch把封面图拉取到本地缓存:
typescript复制gameList.forEach((item) => {
Image.prefetch(item.headerImage);
});
需要注意的是,Image.prefetch是否在OH适配版里有完整实现,不同版本不完全一样。我当时测试下来基本能用,但如果你的版本里没有这个方法,可以退化成在FlatList的onViewableItemsChanged回调里对可见区域的图片做手动预加载。
5.3 列表卡顿:FlatList在OH上的表现与优化
几百条特惠数据渲染出来之后,滚动明显掉帧,甚至出现拖动页面时背景卡住、松手才跳过去的情况。我用的是FlatList,Android和iOS上处理这类问题已经有成熟套路,但OH上需要逐个验证。
优化的顺序是这样的:
先给每个列表项加上固定的itemHeight,配置getItemLayout:
typescript复制const ITEM_HEIGHT = 140;
<FlatList
data={visibleList}
renderItem={renderItem}
keyExtractor={(item) => String(item.appId)}
getItemLayout={(_, index) => ({
length: ITEM_HEIGHT,
offset: ITEM_HEIGHT * index,
index,
})}
windowSize={7}
maxToRenderPerBatch={10}
initialNumToRender={10}
/>
removeClippedSubviews这个属性在Android上经常用来提高滚动性能,但我在OH上开启后出现了一个诡异现象:快速滑动时部分卡片区域会变成空白块,过一会才重新渲染出来。我最后直接关掉了这个属性,改为缩小单批渲染数量maxToRenderPerBatch,把每页数据量控制在20条以内,用分页加载onEndReached来补充数据。这个取舍的本质是,OH适配版对组件回收再复用的支持还不稳定,与其开一个高性能但会闪的开关,不如降低单页渲染压力更稳。
5.4 setInterval倒计时导致页面无法返回
这是我整个项目里踩得最深的一个坑,症状很典型:进入特惠详情页再返回列表,返回操作明显变卡;连续进出四五次之后,整个页面几乎无法操作。一开始我怀疑是内存泄漏,但查了一圈找不到具体泄漏点。
后来我在DevEco Studio的Profiler里看到页面退出后仍有定时器在持续触发回调,才把目标锁定在倒计时组件上。原因很简单:详情页的倒计时组件在useEffect里启动了setInterval,但没有在组件卸载时清理。RN for OpenHarmony的定时器行为跟浏览器不完全一样,不会因为页面不可见就自动挂起,它会一直执行,而每一次执行都会触发setNow(Date.now()),导致React重新渲染,页面栈越来越大。
修复代码就是我第4章写的那样,在useEffect的返回值里clearInterval。这里我要特别强调,不要觉得这种低级错误不值一提,在实际项目中,这类定时器清理问题在OpenHarmony上比Android上更容易被忽视,因为它不会立刻报错,而是以"越来越卡"这种慢性症状出现。
5.5 安全区与中文字体的细节
特惠页顶部有一行筛选栏,在模拟器上显示正常,但换到开发板后,状态栏直接压住了筛选栏的一半。这是因为OpenHarmony设备的状态栏高度和模拟器不一样,且RN for OH对安全区的处理没有iOS那么统一。
我的解决办法是获取系统真实的顶部安全高度,然后给页面容器加一个动态paddingTop:
typescript复制import { getSystemInfo } from '@ohos.device.info';
const systemInfo = getSystemInfo();
const STATUS_BAR_HEIGHT = systemInfo.statusBarHeight || 24;
如果你用的是react-native-safe-area-context,一定要确认版本是否适配OH,我当时用的适配版本可以正常工作,但标准版本直接装上去会抛原生模块找不到的异常。
中文字体方面倒是没遇到大问题,系统自带字体对中文支持良好。唯一要注意的是,如果你在项目里引用了自定义字体文件,字体格式最好用ttf,并且确认字体文件被打包进OH的resources目录了,否则界面会看到英文正常、中文变成方块的奇怪效果。
6. 跑通之后的沉淀与扩展方向
6.1 关于"哪些库能用、哪些不能用"的经验
项目跑通后,我把依赖兼容性经验沉淀成了一张速查表,给后续做类似移植的团队参考:
| 库的类型 | OpenHarmony上能不能直接用 | 我的处理 |
|---|---|---|
| 纯逻辑库(axios、dayjs、zustand、query-string) | 基本都能用 | 放心引入 |
| 纯UI组件库(如部分自定义Button库) | 部分能用 | 看是否依赖原生View扩展 |
| 存储类库(AsyncStorage等) | 需要找OH适配版本 | 小数据量可以直接自己写文件 |
| 系统能力库(相机、蓝牙、地图) | 一般不能直接用 | 找官方/社区的OH适配版,或者放弃 |
| 动画库(如React Native Reanimated) | 需要仔细验证 | 复杂动画在OH上极容易出问题 |
判断一个库能不能用的最快方法,不是看它star数多高,而是去仓库里看有没有ohos目录,或者搜issue里有没有OpenHarmony相关的适配讨论。没有适配记录却在README里吹得天花乱坠的库,默认按"不能直接用"处理。
6.2 特惠数据的定时刷新与通知
特惠数据不是一成不变的,我做了两个刷新策略:下拉刷新和进入页面超过30分钟后自动静默刷新。下拉刷新用的是FlatList的refreshing和onRefresh,但这个能力在OH适配版上表现不稳定,我的处理是改用自定义下拉刷新组件,用PanResponder监听手势,配合一个RefreshIndicator,虽然麻烦一点但可控。
通知推送这块我要泼一盆冷水:如果你想让用户在下架游戏降价时收到系统推送,涉及到后台任务、推送服务接入等一堆系统级能力,在OpenHarmony上的适配成本远高于Android/iOS。我的建议是,第一版不要做推送,先做App内消息中心,等用户真正接受这个应用形态后,再评估推送的ROI。
6.3 后续可以扩展的功能
特惠模块稳定运行后,可以考虑在它基础上扩展这些功能:
第一,详情页价格趋势图。把第三方比价数据接进来,用Canvas或者SVG画一个历史价格曲线,这对特惠场景的价值很大,用户能看到现价确实是历史低位。第二,心愿单降价提醒。让用户自己维护一个愿望清单,App每次启动时检查愿望清单里的游戏价格,有变化就高亮提示。第三,按游戏类型筛选特惠。Steam的游戏都有分类标签,结合本地标签缓存,可以让特惠列表进一步细分,提高浏览效率。
这些扩展方向都建立在现有的特惠数据管线上,不需要重新设计数据层,实现成本相对可控。
我在实际跑这个项目时感受最深的一点是:RN for OpenHarmony这条路完全走通,但它对"原生依赖洁癖"的要求比Android和iOS高出一大截。特惠游戏模块从搭工程到真机跑通,过程中踩出来的坑,几乎都集中在"某个库没有OH适配"和"某个系统行为存在差异"这两类问题上。所以如果你也准备动手做类似项目,我建议动手写业务之前先拿出一晚上时间,把现有项目的依赖逐个过一遍,标出哪些是纯JS、哪些需要原生模块、哪些在OpenHarmony上有替代品,这个清单做得越充分,后面白屏、卡顿、闪退的排查时间就越少。
