做 App 跳转开发的老哥应该都有同感:Deep Linking 本身不算高深技术,但坑全部埋在细节里。尤其是 UniApp 这类跨端框架,以前在 iOS 配 Universal Link、Android 配 App Links 已经有一套成熟模板,可换成“鸿蒙 NEXT”“非 CLI 项目”“Vue3”这一组合之后,配置入口、包名、签名校验、参数接收链路全变了。我第一次在 HBuilderX 建的 UniApp Vue3 项目里给鸿蒙加 Deep Linking 时,最抓狂的不是不会配,而是不知道“到底该改哪个文件”——文档要么只讲 CLI 工程,要么只给鸿蒙原生开发,中间那一段负责衔接的角色完全缺失。
这篇文章的目标很直接:帮还没接触过鸿蒙 Deep Linking 的 UniApp Vue3 开发者,把自定义 Scheme 和关联链接配通,让外部链接能正确唤起 App 并拿到 query 参数。整个流程围绕 HBuilderX 管理的非 CLI 项目展开,也就是不通过 Vue CLI / Vite 命令行初始化的那种 uni-app 工程。如果你正在准备把 uni-app 项目转到鸿蒙 NEXT 5.0.0(12),或者项目需要支持链接拉新、App 间跳转、短信或者 Web 页面直达应用内某页面,这篇文章可以当作一份能直接照着抄的作业。
1. 先把鸿蒙NEXT的Deep Linking模型讲透
1.1 从“拦截链接”角度看,鸿蒙和安卓本是同源
鸿蒙 NEXT 的 Deep Linking 设计,底层思想跟安卓 Intent 很接近。它不是让系统直接去找某个页面,而是先由应用向系统声明“我能处理哪些链接”,当用户点击链接时,系统再根据链接的 scheme、host、path 去匹配所有声明的应用,最后把完整的跳转意图交给最合适的 App。
这个过程大体分三步:
- 应用在工程的 module.json5 里声明
skills,skills 内部用uris描述自己能识别的链接格式。 - 外部链接被点击后,华为系统的 Want 中心会根据 URI 进行匹配。
- 匹配成功后,系统把整个链接作为 Want 参数交给应用入口 Ability,应用再把链接解析成 uni-app 的页面路由并跳转。
所以 Deep Linking 的完整链路,本质是“链接 → 系统匹配 → 应用入口 → 业务页面”。UniApp 开发者最容易忽略的,恰恰是第四步:鸿蒙系统只是把你的 App 拉起来,并不负责帮你跳到某个 uni-app 页面。页面跳转逻辑还得自己在应用内部用代码实现。
1.2 5.0.0(12) 这个版本号到底该怎么理解
很多人看到“HarmonyOS SDK API 12+ / 5.0.0(12)”会发懵,这里简单做一个对照。鸿蒙 NEXT 对外发布时经常出现两个号码:一个是系统版本号,一个是 API Level。标题里的 5.0.0(12),意思是系统版本 HarmonyOS 5.0.0,SDK 的 API Level 为 12。写配置的时候,我们需要区分这层概念:
"targetSdkVersion"之类只影响 Android 构建,鸿蒙构建基本不看它。- 鸿蒙工程里更多关心
compatibleSdkVersion和 API Level,你要支持 Deep Linking 的正式功能,应保证开发环境使用的 HarmonyOS SDK 不低于 API 12。 - 如果你在 HBuilderX 里发行鸿蒙应用,底层编译器生成鸿蒙工程时,通常已经带上了当前 SDK 的默认配置,一般不需要手动指定版本。
理解版本的意义在于排查问题。很多深链失效的案例,不是配置写错,而是项目用了老版本的鸿蒙 SDK,module.json5 里的某些字段不被识别。API 12 之后的版本,对uris里字段的校验比之前更严格,缺一个 path 属性都有可能导致整条配置静默失败。
1.3 非CLI项目存在的意义和它的限制
UniApp 有多种工程形态。命令行创建的 CLI 项目,内部是 Vue3 + Vite,可以用 vue.config.js、vite.config.ts 去做一些高度自定义;非 CLI 项目则是通过 HBuilderX 可视化界面创建和管理,源代码结构更简单,打开就能跑。
非 CLI 项目的优势是上手快、IDE 集成度高、打包配置直观。对于 Deep Linking 来说,需要特别注意的是:它没有一个独立的原生工程目录让你直接改,真正能被鸿蒙系统识别的 module.json5 也不是写在源码根目录里的,而是通过 HBuilderX 编译后对鸿蒙工程产物生成出来的。所以我们的操作顺序必须是“先改 uni-app 配置 → 再编译 → 找生成的鸿蒙工程 → 改 module.json5 → 重新打包”。如果直接改源码里的某个自定义 json,不参与编译打包,等于白改。
有人可能会问:非 CLI 项目能不能避开 module.json5,直接在 uni-app 层完成 Deep Linking?答案是做不到。自定义 Scheme 和关联链接的声明必须存在于鸿蒙原生工程的配置文件里,跨端框架再强大也不能绕开系统机制。我们要做的,不是抱怨跨界复杂,而是接受这个流程:一部分配置写在 uni-app 层,一部分写在生成的鸿蒙工程里。
2. 准备清单与配置模型梳理
2.1 两种链接形态,先搞清楚你要哪种
鸿蒙 Deep Linking 常见两种形态,它们的使用场景不同。
第一种是自定义 Scheme,格式像uni://app/index?from=share。这种形式最简单,不需要域名,不要求网络环境,适合 App 内部跳转、二维码扫描唤起、短信唤起。缺点是自定义 Scheme 是全局唯一的,两个 App 用同一个 Scheme 会造成冲突;iOS 上部分浏览器还会拦截自定义 Scheme,导致唤起失败。
第二种是应用关联链接,格式像https://example.com/open/index?id=123。它要求你有一个真实可控的 HTTPS 域名,并且要在华为开发者后台完成域名校验。它的好处是够正式、够稳定,适合市场推广、SEO、用户分享、短信推广;坏处是链路更长,要配置的环节更多。对于生产项目,我的建议是两个一起配:对外宣传用 https 连接,内部功能和测试用自定义 Scheme。
表格对比会更直观一些:
| 对比项 | 自定义Scheme | 应用关联链接 |
|---|---|---|
| 格式 | uni://host/path | https://domain/path |
| 是否需要域名 | 否 | 是 |
| 备案/校验 | 无需 | 需要域名所有权校验 |
| 冲突风险 | 高 | 低 |
| 系统支持 | 好 | 好 |
| 使用场景 | 内部跳转、快速测试 | 拉新、推广、短信链接 |
2.2 module.json5 里 skills 和 uris 的关系
鸿蒙工程的链接声明,最终都写在工程模块的 configuration 文件里,通常是module.json5。它区分模块级别,而不是应用级别。理解方式可以参照安卓的AndroidManifest.xml中 intent-filter 的配置,只是字段命名不同。
具体职责:
skills负责定义唤醒入口,相当于一组 Intent-Filter,表示当前模块能够处理哪些类型的 Want。uris是 skills 内部的一个数组,用来描述 URL 规则。- 每个 URI 规则由 scheme、host、path、pathStartWith 等字段组成。
我们配置时通常会给多个 skill 分场景写。一个负责接收自定义 scheme 的跳转,一个负责接收 https 关联链接,这样维护起来比较清晰,也方便单独封禁某个来源。
2.3 非CLI工程里,Deep Linking到底会经过哪些文件
我们从头梳理一个外部链接到达 uni-app 页面所经过的路径。
假设用户点击https://example.com/open/detail?id=10088,系统先检查所有鸿蒙应用是否声明了这个域名规则。你的 App 的 module.json5 中匹配成功,系统就会启动应用入口 Ability。入口 Ability 拿到原始 Uri 后,通常把它交给一个统一处理函数,这个函数会解析 URL 里携带的页面路径和参数,然后调用uni.navigateTo或uni.redirectTo跳转到 uni-app 页面。
如果你是用 HBuilderX 生成的鸿蒙工程,入口 Ability 的代码一般位于编译产物中,默认会把 Want 里的 uri 转化成启动参数。所以我们大部分时候只需要关心两件事:第一是 module.json5 里有没有配对,第二是 App.vue 或特定页面的 onLoad、onLaunch 是否拿到参数并做了路由处理。
需要提前准备的材料包括:
- 一个已经注册好的包名,例如
com.example.app,并记录下它的 bundleName。 - 一个可用的域名,如果走关联链接,需要能上传验证文件。
- 鸿蒙应用的签名信息,调试阶段会自动生成,正式发布阶段要去申请。
- HBuilderX、DevEco Studio 或者 hdc 命令行工具,测试环节会用到。
3. 核心配置实操:把链接规则写进鸿蒙工程
3.1 自定义Scheme的配置示例
假设我们的 uni-app 项目有一个商品详情页,路径是/pages/detail/detail,我们想通过uni://product/detail?id=1这种链接打开它。那么在鸿蒙工程 module.json5 的对应模块配置里,需要增加 skills。
代码看起来是这样:
{ "module": { "name": "entry", "type": "entry", "skills": [ { "actions": [ "ohos.want.action.viewData" ], "uris": [ { "scheme": "uni", "host": "product", "path": "/detail" } ] } ] } }这里简单解释字段含义:
ohos.want.action.viewData表示当前 ability 可以响应查看数据的请求。scheme是"uni",所以完整链接前缀是uni://。host是"product",意味着链接的 host 部分必须匹配 product。path是"/detail",表示路径部分必须匹配,后面接参数不算路径匹配失败,例如uni://product/detail?id=1依然能匹配。
如果你想匹配多个路径,除了写多个 uri 对象,还能用pathStartWith做前缀匹配:
{ "scheme": "uni", "host": "product", "pathStartWith": "/detail" }这种写法更宽松,适合一个模块下挂多个页面,比如/detail/list、/detail/detail都能命中的场景。个人建议优先用精确的path,等确实需要处理更多页面后再扩大匹配范围。过度匹配会引发后续页面路由判断时的混乱。
3.2 支持https关联链接的配置方法
如果走应用关联链接,module.json5 里我认为最关键的并不是配置本身,而是三个环节的联动:后台配置、域名校验文件、module.json5。
module.json5 中增加一个 https 的 uri 规则,核心代码大致如下:
{ "skills": [ { "actions": [ "ohos.want.action.viewData" ], "uris": [ { "scheme": "https", "host": "example.com", "pathStartWith": "/open" } ] } ] }它表示:当用户点击https://example.com/open/xxxxx时,会尝试唤起应用。
但仅改 module.json5 还不够。你需要在华为开发者后台或对应的 App Linking 控制台配置应用的关联域名,并且确保你的网页服务器能提供一个验证文件,通常是一个 JSON。验证文件的作用是告诉系统“这个域名的链接确实属于你”,相当于占据宣示主权。
如果域名校验失败,系统绝不会唤起你的 App。这个问题经常发生在测试环境:开发者只是改了一个 module.json5,然后拼命点击链接,不跳转就怨系统不稳定,其实十有八九是校验文件路径不对。
3.3 在HBuilderX中完成编译并找到鸿蒙工程
非 CLI 项目的编译方式很简单,直接在 HBuilderX 中点击“运行 → 运行到手机或模拟器 → 运行到鸿蒙手机”,或者“发行 → 鸿蒙应用”。编译完成后,HBuilderX 会在项目的unpackage目录下生成鸿蒙工程产物。
编译产物路径大致是这样的:
项目目录/unpackage/dist/dev/app-harmony这个 app-harmony 目录里,通常能看到 AppScope、entry、build-profile.json5 等文件。我们前面说的 module.json5,一般是在entry/src/main/module.json5或类似位置。具体路径可能因为 HBuilderX 版本不同而略有差异,但核心查找方法不变:进入生成目录后,搜索module.json5,找到包含skills的模块配置。
一定要记住:你改的是编译产物里的 module.json5,这个文件是给鸿蒙工程用的。源码里的 uni-app 配置并不会直接生成这些 skills,除非 HBuilderX 增加了可视化配置项。所以这个过程是“改编译产物 → 重新编译或重新运行”。如果 HBuilderX 重新编译覆盖了这个文件,要把自己的链接规则想办法固化,避免每次都要手动重贴。
3.4 在源码层做一个映射表,方便统一维护
module.json5 只是声明了“App 收到链接时可以被唤醒”,但要把链接对到 uni-app 具体页面,还需要在源码里写一个映射逻辑。这个映射表最好抽成一个独立文件,避免散落在页面组件里。
我自己的做法是在 utils 下创建deepLink.js,维护一份链接前缀和页面路径的对应关系:
const deepLinkRouter = [ { prefix: 'uni://product/detail', page: '/pages/detail/detail' }, { prefix: 'https://example.com/open/detail', page: '/pages/detail/detail' } ] export function resolveDeepLink(rawUri) { const match = deepLinkRouter.find(item => rawUri.startsWith(item.prefix)) if (!match) return null const queryStr = rawUri.split('?')[1] || '' const params = {} new URLSearchParams(queryStr).forEach((value, key) => { params[key] = value }) return { page: match.page, params } }这样之后加新页面,只需要往deepLinkRouter数组里加一条,不用到处改代码。如果项目里接入的深链场景很多,这个文件还能配合埋点,统计每个链接来源和转化率。
4. 参数接收与冷热启动处理
4.1 在uni-app层接收深链参数的常见套路
当鸿蒙系统把链接传给应用入口后,最终要落到 uni-app 层。常见方式是在App.vue的 onLaunch 中获取启动参数,或者在目标页面的onLoad中获取页面参数。
如果你的深链规则被鸿蒙系统直接解析成启动参数,那么App.vue可以这样接收:
export default { onLaunch(launchOptions) { console.log('onLaunch options', JSON.stringify(launchOptions)) // 这里拿到的是应用启动参数,不同编译器解析后的字段名可能不同 }, onShow() { // 热启动场景也可能在这里触发 } }页面层的 onLoad 也能接收参数,这是 uni-app 所有平台都支持的:
export default { onLoad(options) { // options 里就是 query 参数 const id = options.id || '' if (id) { this.loadDetail(id) } } }但这里要注意一个常见误导:深链的完整 URI 并不一定等于 uni-app 页面参数对象。某些情况下,鸿蒙系统只是把 uri 字符串交给应用,uri 里的 product/detail 是需要你自己动手拆的。所以不要盲目期望onLoad里直接出现id,一定要先在真机上打印一次,确认实际拿到的字段结构再写业务逻辑。
4.2 冷启动:进程被杀后链接也要能找到家
冷启动指的是 App 完全不在运行状态,用户从短信、浏览器、桌面点击链接,系统拉起应用。这是深链最典型也最重要的场景。
冷启动时,应用进程是全新的,此时页面栈是空的,你不能直接uni.navigateTo去跳转,因为此时 uni-app 根页面都还没完成初始化。我一般会在App.vue的 onLaunch 里先把深链参数存到缓存或者内存对象里,等首页onLoad完成后再做路由分发。
伪代码:
export default { onLaunch(options) { const rawUri = options.uri || options.url || '' if (rawUri) { const link = resolveDeepLink(rawUri) if (link) { uni.setStorageSync('pendingDeepLink', link) } } } }然后再找一个合适的时机,比如首页挂载后:
// 首页 onLoad() { const pending = uni.getStorageSync('pendingDeepLink') if (pending) { uni.removeStorageSync('pendingDeepLink') uni.navigateTo({ url: `${pending.page}?${new URLSearchParams(pending.params).toString()}` }) } }这种“先存再跳”的方式能避免在应用框架没有准备好的时候强行跳转导致的白屏或闪退。
4.3 热启动:App在后台时怎样截获新链接
热启动指的是 App 已经在运行,只是处于后台,用户又点击了一个新链接。这时候 onLaunch 不会再次触发,页面栈中可能已经存在多个页面。如果不做处理,最常见的现象是:页面没有变化,用户都不知道自己点击的链接实际上打开了 App。
热启动处理建议监听uni.onAppShow,App 回到前台时会触发这个回调。在这个回调里重新读取最新的深链参数:
uni.onAppShow((res) => { const rawUri = res.uri || res.url || '' if (!rawUri) return const link = resolveDeepLink(rawUri) if (link) { uni.navigateTo({ url: `${link.page}?${new URLSearchParams(link.params).toString()}` }) } })如果目标页面已经在当前页面栈里,直接navigateTo会产生重复页面。更好的方案是:用uni.getCurrentPages()检查当前页面栈中是否已有目标页,如果有,就用uni.redirectTo替换当前页,或者往页面栈回退到目标页再更新参数。这一块需要配合自己项目的 tabBar 结构来设计,没有绝对统一的答案。
4.4 参数编码与解码,越早处理越省心
深链链接里的参数经常包含中文、空格、特殊字符,例如商品名称、搜索关键词、邀请人昵称。如果上游生成链接时没有做 URL 编码,传递到 App 后非常容易出现乱码或截断。
建议在生成链接时统一用encodeURIComponent对每个参数值做编码。例如:
const url = `https://example.com/open/detail?name=${encodeURIComponent('鸿蒙适配指南')}&id=10088`在resolveDeepLink或者页面 onLoad 中,再对参数值做decodeURIComponent解码。不要依赖系统自动解码,很多场景下系统给的参数是原始编码状态。
还有一个坑是:某些链接生成方会把参数直接拼在 path 后面,导致pathStartWith匹配时错误截断了 query。建议与运营同学约定一个规范,最终落地页面路径永远放在 path 里,参数放在 query 里。
5. 真机验证与问题定位手段
5.1 用浏览器和短信验证最直观
配置完成后,最快的验证方式有两种。
第一种是直接把链接输入到鸿蒙手机的系统浏览器地址栏里,比如uni://product/detail?id=1,回车后看是否弹出选择应用的提示,或者直接拉起 App。如果没反应,说明 module.json5 里的规则没有生效,先检查编译产物里的 module.json5 有没有被正确打包进去。
第二种方式是给手机发一条包含链接的短信,从短信入口点击。短信唤起更贴近真实用户场景,便于验证 scheme 在系统层的可信度。真机上我遇到过浏览器能拉起但短信不能拉起的情况,原因通常还是 scheme 冲突或域名校验失败,这两种方式都要测。
5.2 使用hdc命令行模拟外部跳转
摄影头实机不方便时,可以借助鸿蒙的 hdc 工具模拟系统发起的跳转。这种验证方法最接近真实场景,而且不需要用户手动点击链接。命令行的大致思路是:
hdc shell aa start -b com.example.app -a EntryAbility -U "uni://product/detail?id=1"选项解释:
-b:目标应用包名,即 bundleName。-a:目标 Ability 名称,具体取决于鸿蒙工程里入口 Ability 的名字。-U:要传递的 URI,部分 SDK 版本可能使用--uri。
如果命令行提示参数不正确,可以用hdc shell aa start -h查看当前版本的帮助信息。不同 SDK 版本的 hdc 对参数缩写支持不完全一样,建议实际执行之前先看帮助,避免浪费时间。
执行后观察手机是否拉起应用,同时可以配合日志查看 UniApp 的 onLaunch 是否打印了参数。如果aa start本身报错,优先检查包名和 Ability 名称是否匹配,这是命令行方式最常见的失败点。
5.3 用日志确认整个链路的参数流转
很多深链问题不是“没跳转”,而是“跳转了但参数没拿到”。建议在三个关键位置打日志:
- module.json5 层面的问题没有日志,需要通过
hdc shell查看包安装状态和配置解析结果。 - 鸿蒙入口 Ability 接收到 Want 之后,打印原始 URI。
- uni-app 的 App.vue onLaunch、目标页面 onLoad 里,打印处理后的参数对象。
日志格式不要只打印options或单个字符串,建议整体JSON.stringify输出,保证能看到字段路径。我实测下来,最隐蔽的问题是多个来源的字段混合后,逻辑层误以为参数对象里没有数据,结果实际只是字段名不同。
如果 HBuilderX 控制台不打印日志,通常是因为编译模式和设备连接问题。UniApp 的 Vue3 鸿蒙调试模式的日志输出位置和常规 H5 调试略有不同,优先看鸿蒙侧的 console 输出,必要情况下在 DevEco Studio 中打开生成的鸿蒙工程,直接看系统运行日志,比在 uni-app 层猜要快很多。
6. 高频踩坑与排查速查表
6.1 module.json5 配了 skills 却不生效
先说结论:大多数情况是配置写错位置。检查这个文件是否真的存在于编译后的鸿蒙工程中的entry/src/main,而不是随手新建一个 json 放在别处。其次确认skills是写在正确的模块对象下面,不是写在整个文件的根节点上。
还有一个容易被忽略的细节:如果 HBuilderX 又重新执行了一次发行操作,之前手工修改的 module.json5 很可能会被覆盖。建议改完 module.json5 后立刻做好备份,或者研究 HBuilderX 的 hooks 机制,把配置写入操作做成自动化。
6.2 两个 App 抢同一个 Scheme
自定义 Scheme 是全局唯一的,如果手机里同时安装了多个都声明了uni://的应用,系统会弹出选择框,或者直接不弹。鸿蒙的处理策略比安卓更严格,同一个 scheme 被多个应用声明时,实际唤起成功率会显著下降。
解决方案很简单:给 scheme 加上应用唯一后缀。比如你的应用是做电商的,主包名是com.example.mall,scheme 可以考虑写成mallapp而不是mall。越长的、越像品牌名的 scheme 越安全。发布前可以在多台设备上测试,确认没有跟常见应用冲突。
6.3 关联链接域名校验失败
域名校验失败的原因通常是验证文件放置路径不对。鸿蒙的关联链接要求把验证文件放到域名的特定路径下,并且要求 HTTPS 可访问,不能有跳转。调试时如果使用 http 环境,通常无法通过校验。
另一个原因是校验文件内容里的包名跟你实际打包的 bundleName 不一致。可以先确认工程最终用的 bundleName 是什么,再回头改验证文件里的对应字段。改完以后记得在服务器上清理缓存,部分 CDN 节点会把旧文件缓存住导致校验时读到错误内容。
6.4 中文参数乱码
中文参数乱码的根源几乎都是编码不一致。上游生成链接没有做encodeURIComponent,或者做了两次编码,下游解码时就容易出错。
我建议在入口处统一收口,不让原始 uri 字符串直接进入业务层。先写一个解析函数,内部固定 encode/decode 一次,再输出结构化参数对象。这样即使上游变了格式,也只需要改一个文件。
6.5 鸿蒙应用市场审核时的材料配合
如果你的 App 计划上架鸿蒙应用市场,并且使用了 Deep Linking,审核时通常需要说明链接的使用场景和唤起规则。尤其是自定义 scheme,如果没有任何业务场景支撑,审核人员可能会判定为无意义唤起。
准备材料时建议主动提交:
- 深链触发页面截图。
- 链接格式说明文档。
- 安全合规自查说明,说明没有利用深链做违规跳转或敏感能力调用。
这一块不属于纯技术问题,但一样会影响项目上线时间,提前准备会比较省心。
6.6 速查表形式总结常见问题
| 症状 | 可能原因 | 排查方式 |
|---|---|---|
| 点击链接无反应 | skills 未配置或配置位置错误 | 查看构建产物中的 module.json5 |
| 系统弹选择框 | scheme 冲突 | 换更独特的 scheme 值 |
| 能拉起但参数为空 | 入口 Ability 未传递 uri 参数 | 打印入口 Want 日志 |
| 中文乱码 | 编码不一致 | 统一在解析函数内处理 |
| 只有冷启动能收到 | 热启动监听缺失 | 使用 uni.onAppShow 补充 |
| 发行后失效 | HBuilderX 重构覆盖 module.json5 | 脚本固化配置或打包后手动检查 |
7. 实操体会:配置链路不难,难的是“链路意识”
我个人做了这么多平台适配之后,最大的体会是:Deep Linking 根本不是某一个文件配置完就结束的功能,它是一条完整链路。上游运营生成链接的人,不一定懂技术;下游uni-app页面写业务的人,又可能看不到鸿蒙原生工程的细节。作为工程负责人,需要把“链接规范 → 系统声明 → 入口接收 → 页面路由 → 埋点统计”全程的规则定下来,才不会每次新业务接入后就重新踩一遍老坑。
尤其是非 CLI 项目,它没有现代前端工程那么灵活的插件机制,很多自动化能力也不如 CLI 项目丰富,但胜在胜在简单直接。只要把 module.json5 的配置和 uni-app 内部的解析函数沉淀好,后续维护成本很低。后续如果想做得更完善,可以考虑把深链跟业务二维码结合,用户扫一个码就能直接到达商品页;也可以跟推送服务打通,用户点击推送通知时,不再只是打开首页,而是跳转到对应的业务详情页。
这些功能听着高大上,落到技术层面仍然是同一套 Deep Linking 链路。先把基础配通,再把参数解析收口,后续所有基于链接的玩法,都会变得没那么可怕。希望这份总结能给正在鸿蒙适配路上挣扎的 UniApp 开发者一些实际帮助。