☰
React Native跨端跳转:OpenHarmony通用链接实践
2026/10/16 6:55:17 网站建设 项目流程

做 React Native 的跨端跳转,我踩过的坑比写业务代码还多。特别是到了 OpenHarmony 这种新平台上,大家最容易忽视的不是 RN 层的路由,而是系统怎么把一条“普通网址”交到你的 App 手里。UniversalLink 通用链接这套玩法,恰好是解决这个问题的标准答案之一,今天我把一次完整落地过程拆开讲。

1. 先把问题说清:应用和网页之间的跳转为什么这么难

1.1 scheme 链接的老大难问题

早几年做 RN 跳转,大家第一反应就是自定义 scheme。比如你定义一个myapp://product/123,然后在网页里写一个<a href="myapp://product/123">,用户点击后系统会尝试拉起对应的 App。这套逻辑在 iOS 和 Android 上都能跑,但实际用起来非常别扭。

第一,大部分系统会弹一个“是否打开该应用?”的确认框,多一步确认,转化率就掉一截。第二,自定义 scheme 是开放调用,别人也能用你定义的 scheme 去拼参数,甚至可以做恶意跳转,你很难做严格的来源校验。第三,很多内嵌 WebView 会把myapp://直接当成无效地址吞掉,压根不传给系统。第四,如果你要接第三方平台的单点登录、支付回调,很多平台只认 https 链接,不认自定义 scheme。

这些痛点不是 RN 层能解决的,必须从系统层面换一种链接形式。

1.2 UniversalLink 通用链接的解决思路

UniversalLink 长期被叫成“iOS 通用链接”,但它本质并不是某个系统独有的专利。它的核心思路是:仍然使用一条标准的 https 网址,例如https://www.example.com/product/123。当你的 App 已经安装,并且完成域名校验之后,系统在检测到用户点击这条链接时,会直接把链接转交给你的 App;如果 App 没安装,浏览器继续打开这个网址,展示对应的 H5 页面。

这套思路在 Android 上叫 App Links,在 OpenHarmony 上也有域校验、关联链接之类的实现。很多从 iOS、Android 迁移过来的 RN 开发者,会在 OpenHarmony 上继续沿用Linking.openURL('myapp://')的方案,结果发现很多场景不支持。原因很简单:OpenHarmony 对自定义 scheme 的限制和统一管理,比老系统更严格,它更希望你使用标准 https 链接来建立关联关系。

所以这篇文章里的“UniversalLink 通用链接”,我理解为一条能被系统识别、能落到指定页面的标准网页链接,它的价值是打通 H5、短信、邮件等外部入口,让用户点击后能以最小跳步进入 RN 应用里的对应页面。对 RN 开发者来说,真正需要动手的是三块:服务端校验配置、原生 Ability 接收链接、RN 层路由映射。

2. 整体设计:不要只在 JS 层做文章

2.1 一条链接从点击到进入 RN 页面的完整链路

我刚开始搞的时候,以为只要在 RN 里加一个Linking.addEventListener('url', callback)就够了。后来发现完全不是这么回事。系统拉起 App 的过程,根本不会直接通知 JS 层,而是先通知到原生 Ability,再由原生层通过桥接把 url 传进 RN。

完整链路可以这样理解:

  1. 用户在浏览器、短信或扫码工具里点击一条 https 链接。
  2. 系统做域名校验,判断这个域名是否已经声明归属于当前 App。
  3. 校验通过后,系统创建一个新的 Want 信息,里面带uri、action等字段,拉起 EntryAbility。
  4. 如果你的 App 已经是冷启动状态,Ability 的onCreate会先执行;如果是热启动状态,则走到onNewWant。
  5. 原生代码从 Want 里取出 url,放进一个全局变量或者直接通过桥模块发给 JS。
  6. RN 收到链接后,解析路径参数,调用你自己的路由方法完成页面跳转。

这个链路里,第 2 步和第 5 步是最容易断掉的。第 2 步问题在于域名校验配置;第 5 步问题在于很多人忘了“系统 -> 原生 -> JS”中间还隔了一层桥,尤其是冷启动时,RN 引擎还没就绪,链接很容易丢。

2.2 冷启动和热启动必须分开处理

我在实际开发中吃过最大的亏就是只接一个事件监听,结果 App 被冷启动拉起时,监听事件已经错过了。RN 的Linking模块里有两个入口,一个叫getInitialURL(),一个叫addEventListener('url')。前者用于 App 还没有运行的情况,你在启动时主动去拿初始链接;后者用于 App 已经切到后台、再由链接重新唤醒的情况,这时候不需要主动拉,系统会推送一个新事件。

在 OpenHarmony 侧同理,冷启动会走onCreate拿初始 Want,热启动会走onNewWant拿新 Want。很多第三次适配 OpenHarmony 的组件库只封装了热启动事件,所以你必须自己做一层保护:冷启动时把 url 暂存到一个队列,等 RN 的getInitialURL被调用时再消费。这个思路在 iOS、Android、OpenHarmony 上完全一致。

2.3 RN 桥接方案的取舍

官方Linking模块在 OpenHarmony 的 RN 适配里并不保证完整可用,尤其是getInitialURL的时序问题,经常会有延迟。我自己更倾向于在原生侧写一个很小的桥接模块,通过NativeEventEmitter主动把链接抛到 JS 层。这样虽然多了一点原生代码,但可控性高很多,调试起来也直观。

方案选型上,我建议不要为了省事而在原生层写死业务路径。原生层只负责“收到 url 并尽快交给 JS”,至于这条 url 要打开哪个页面,那是路由层的事。原生和 JS 各管一段,后面加新业务页面就不用再改原生。

3. 服务端与客户端配置:UniversalLink 能不能生效,全看这一步

3.1 服务端域名校验文件

通用链接最核心的信任基础是域名校验。系统不可能允许任意 App 拦截任意域名的点击,所以你必须在你自己的域名根目录下放一个校验文件,让系统确认“这个域名授权给这个 App 使用”。

这里先给一个最通用的校验文件内容。不同系统的文件名和字段略有差别,但 OpenHarmony 侧的思路和 Android 的 App Links 非常接近,很多适配版允许你直接挂一个名为assetlinks.json的文件,里面内容大概长这样:

[ { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "web_app", "package_name": "com.example.app", "sha256_cert_fingerprints": [ "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99" ] } } ]

字段里的包名和指纹必须换成你自己应用的,否则校验一定失败。有的系统还需要你额外放一个apple-app-site-association文件,那是另一个格式。所以在配置前,请先查一下你当前使用的 OpenHarmony SDK 版本推荐的是哪种文件格式,不要凭经验硬套。

几个服务端的硬性要求:

  • 域名必须支持 HTTPS,证书不能过期,否则系统校验直接不通过。
  • 校验文件必须能通过公网直接访问,尽量放在https://你的域名/.well-known/目录下。
  • 响应头里的Content-Type必须是application/json,不要返回页面 HTML,也不要带 BOM 头。
  • 文件内容里的路径匹配规则要精确。paths不写或者写["*"]虽然简单,但会把整个域名所有路径都交给 App,运营同学后续想改会非常痛苦。

3.2 客户端声明关联域名

服务端准备好之后,原生工程里要声明“我这个 App 关联了哪些域名”。在 OpenHarmony 工程里,通常是在module.json5或者对应的配置文件里增加关联域名声明。不同版本字段位置不完全一样,但大体思路一致。下面是我整理过的示意写法:

{ "module": { "abilities": [ { "name": "EntryAbility", "skills": [ { "actions": ["action.view"], "entities": ["entity.system.browsable"], "uris": [ { "scheme": "https", "host": "www.example.com" } ] } ] } ], "appLink": { "domains": ["www.example.com"] } } }

这里我特意没有把配置字段名写得太死板,因为 OpenHarmony 不同版本的配置迁移比较频繁。你只需要抓住两个要点:一是 entry ability 的skills里要声明可处理 https 的 host;二是模块配置里要有关联域名的声明。两者缺一不可。

配置完成后,记得重新安装 App。只改配置文件不重装,系统很多情况下不会重新拉取校验结果,你会误以为配置没生效。

3.3 原生 Ability 接收链接并转发给 RN

原生侧的核心代码并不长。你需要拿到 Want 对象,并把其中的uri提取出来。这里用一个通用代码结构示意:

import { UIAbility, Want, AbilityConstant } from '@kit.AbilityKit'; export default class EntryAbility extends UIAbility { private pendingLink: string = ''; onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { this.pendingLink = want?.uri ?? ''; AppStorage.setOrCreate('pendingUniversalLink', this.pendingLink); } onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void { const link = want?.uri ?? ''; if (link) { AppStorage.setOrCreate('pendingUniversalLink', link); } } }

这段代码做的事情很纯粹:冷启动时把链接存到全局;热启动时更新链接。RN 侧通过桥模块去读取这个全局值,或者原生模块在onNewWant里直接调用emitEvent把链接实时抛给 JS。我个人更推荐后一种方式,因为不需要在 JS 里反复轮询。

原生发送事件时,可以用类似这样的桥接封装:

this.context?.eventHub?.emit('universalLinkReceived', link);

然后 RN 侧再去订阅这个事件。为了让桥接更通用,你最好自己封装一个原生模块,比如叫RNOpenHarmonyLinking,对外暴露getInitialLink()方法,并持有一个NativeEventEmitter。这样 RN 代码可以统一处理冷启动和热启动。

4. RN 层的统一监听与路由映射

4.1 封装一个 useUniversalLink Hook

RN 层不要直接散落地写Linking.getInitialURL()。我建议把整套逻辑封装成一个 Hook,所有页面都能复用。核心代码如下:

import { useEffect, useRef } from 'react'; import { NativeModules, NativeEventEmitter, Linking } from 'react-native'; const { RNOpenHarmonyLinking } = NativeModules; const linkingEmitter = new NativeEventEmitter(RNOpenHarmonyLinking); export function useUniversalLink(onLink: (url: string) => void): void { const callbackRef = useRef(onLink); callbackRef.current = onLink; useEffect(() => { let mounted = true; Linking.getInitialURL().then((url) => { if (mounted && url) { callbackRef.current(url); } }); const subscription = linkingEmitter.addListener('onUniversalLink', (url: string) => { if (url) { callbackRef.current(url); } }); return () => { mounted = false; subscription.remove(); }; }, []); }

为什么要把onLink放到ref里?因为用户可能在整个应用生命周期内多次点击链接,而路由回调函数引用会变。如果直接在 Effect 依赖里写onLink,会导致订阅被频繁重建,反而容易漏事件。放在 ref 里,订阅只需要建立一次,回调永远是最新的。

如果你不想依赖私有的 NativeEventEmitter,也可以退一步直接用 RN 官方Linking.addEventListener('url', ...)。但我在 OpenHarmony 上测试下来,事件名和触发时机偶尔不稳定,所以自建桥接更稳。

4.2 链接和路由之间一定要做映射层

拿到 url 之后,千万不要直接在业务代码里到处if (url.includes(...))。链接是有公共语义的东西,今天可能长这样:

https://www.example.com/product/123?from=share

明天运营想改成:

https://www.example.com/goods/123?from=share

如果逻辑散落各处,你就要满项目找字符串。建议统一维护一个解析函数,把 link 转换成路由对象:

export function parseUniversalLink(url: string): { routeName: string; params: Record<string, string>; } { try { const parsed = new URL(url); const pathname = parsed.pathname; const searchParams = parsed.searchParams; const pageMatch = pathname.match(/^\/(product|goods)\/(\d+)$/); if (pageMatch) { return { routeName: 'ProductDetail', params: { id: pageMatch[2], from: searchParams.get('from') ?? 'universal', }, }; } if (pathname === '/profile') { return { routeName: 'Profile', params: Object.fromEntries(searchParams.entries()), }; } } catch (e) { // url 不是合法链接时,回首页 } return { routeName: 'Home', params: {} }; }

然后在导航容器里统一消费:

useUniversalLink((url) => { const route = parseUniversalLink(url); navigationRef.navigate(route.routeName, route.params); });

这么做的好处是,以后运营要调整路径,只需要改parseUniversalLink这一个文件,路由层和原生层都不用动。我经历过的项目里,这个映射层是最值得提前做好的,越早做越省事。

4.3 参数编码和处理细节

链接里的中文、空格、#符号很容易出错。服务端生成的短链通常已经编码过,但你在URL解析时要注意是否已经被自动解码。有的解析库会把%20变成空格,导致你去做 query 匹配时对不上。

安全做法是:在原生层拿到原始 uri 后,不要提前做decodeURIComponent。把原始字符串交给 JS,在 JS 侧解析时统一处理。因为系统传给原生层的链接可能是半编码的,提前解码一次,到 JS 层再解码一次,会叠加出脏数据。

比如商品 ID 本身可能带#字符,如果服务端没有正确 encode,URL 的 hash 部分会被浏览器吃掉,导致你永远拿不到完整参数。遇到这种问题,第一时间不是改 App,而是让服务端修正链接生成逻辑,所有 query value 都必须做encodeURIComponent。

5. 实测踩坑与排查清单

5.1 冷启动时拿不到链接,只拿到空值

这个问题我遇到过太多次。原因通常是 RN 引擎初始化需要时间,getInitialURL()调用时,原生侧还没把链接准备好。解决办法就是我在 4.1 里写的:原生侧先把链接存到一个全局变量,RN 调用getInitialLink()时返回这个存量值。不要等 event 推过来,冷启动下 event 经常不可靠。

如果用了AppStorage,还要注意它的作用域。跨 Ability 的时候,同一个AppStorage不一定能直接共享,必要时用全局类或者持久化存储兜底。

5.2 域名校验文件一直校验失败

我在真机调试时,最常见的错误是把校验文件放在了某个后端子路径下,比如https://www.example.com/api/.well-known/...,或者干脆放在了一个需要登录态的 CDN 后面。系统去校验时没有带你的登录 cookie,当然会 404。

排查命令推荐直接看响应头:

curl -I https://www.example.com/.well-known/assetlinks.json

不要只看有没有 200。还要看Content-Type是不是application/json,看响应的字节数对不对,看有没有被压缩成非标准格式。有些服务器会把 JSON 文件当成下载附件,Content-Disposition: attachment也会导致校验失败。

另外,域名校验文件在系统侧有缓存。我改完文件后,经常需要把 App 从后台杀掉、清掉缓存再重装。如果你没有重装,即使服务端已经修好,本地校验结果可能还是旧的。

5.3 路径匹配写得太宽,导致所有网页都拉起 App

有些同学图省事,会把配置写成paths: ["*"]或者uris里的 path 留空。结果运营在短信里随便发一条公司官网链接,都会被 App 拦截,用户只要装了 App,就永远看不了 H5 页面。

我不建议这样做。通用链接的正确用法是只拦截 App 能承接的业务路径,比如/product/、/order/,其余路径全部放行给浏览器。路径匹配尽可能精确,宁可少接,不要误伤。

5.4 H5 页面和 App 页面参数不一致

这里有一个很容易被忽略的坑:H5 页面里的商品 ID 可能带的是字符串,比如sku=SKU001,而 App 路由里用的 ID 是数字。直接透传会导致页面白屏或接口 404。建议在parseUniversalLink里做一层类型转换和兜底校验,不要直接透传原始字符串。

我一般会在映射层加一个白名单字段校验:params里凡是进入路由的,都必须经过sanitizeParam过滤一遍,去掉javascript:、data:这类危险前缀,防止外部链接注入。

5.5 hdc 命令行调试链接

OpenHarmony 上调试通用链接,除了肉眼点击,还可以用命令行直接模拟拉起。类似下面的命令格式:

hdc shell aa start -d -b com.example.app -a EntryAbility --uri "https://www.example.com/product/123"

不过这串命令里的参数在不同版本会有变化,建议你先用hdc shell aa start --help看一下当前支持的选项。这种调试方式特别适合验证“原生层能否正确拿到 uri”,能把服务端校验和客户端跳转分开测试。如果命令行没问题,但点击链接没反应,问题大概率出在域名校验;如果命令行也拿不到,那就是原生 Ability 接收逻辑有 bug。

5.6 真机上热启动时事件重复触发

当 App 已经在栈里,用户再点一条新的通用链接,onNewWant可能会被调用多次。如果你在 RN 层注册了多个订阅者,同一链接会被处理两遍,导致页面被二次压栈、或者弹两个相同页面。

解决办法是在路由层加一个dedupe判断:解析出来的routeName和关键params如果与当前页面栈顶完全一致,就忽略本次跳转。这个防护在 iOS 和 Android 上也同样需要。

6. 再分享一点我的个人经验

如果你所在的团队同时维护 iOS、Android 和 OpenHarmony 三个端,我强烈建议把通用链接的路径规划权收回到一个人手里,不要每个端各配一套。我在实际项目里见过最混乱的情况:iOS 用/product/:id,Android 用/productDetail?id=,OpenHarmony 又用/goods/,最后运营同学根本不知道该往短信里放哪条链接。真正的多端通用,应该是一条链接在不同系统上都能打开同一个页面,路径规则完全一致。

还有一点,虽然 UniversalLink 很强大,但它并不是万能的。一些只能在 App 内完成的特殊协议,比如调用蓝牙配对、打开某个系统能力,还是需要保留内部 scheme 兜底。你可以把 UniversalLink 作为“用户入口”,把内部 scheme 作为“功能通道”,两者并存,但不能互相替代。

如果你现在正处于把现有 RN SDK 适配到 OpenHarmony 的阶段,我建议先把通用链接这件事放到前面做。因为很多页面跳转、分享回流、推送跳转都会依赖它,越晚改,牵涉到的历史逻辑越多。把它做成一个独立的桥接模块和 Hook,未来新系统适配时只需要换原生层实现,JS 层一行都不用动。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询