1. 从一个真实需求说起:为什么要在鸿蒙上折腾深层链接
去年底接手一个跨平台项目,核心诉求很明确:App 要在鸿蒙设备上跑起来,而且必须支持从外部浏览器、短信、社交应用里点击一个链接,直接唤起 App 并跳到指定页面。听起来是个很常规的需求,做过移动端的人都知道,这在 Android 和 iOS 上早就有一套成熟的方案,无非就是 intent-filter 或者 Universal Links 那一套。但问题在于,鸿蒙平台的生态和 Android 虽然同源,却在路由分发、包管理、权限声明上走出了一条自己的路,Flutter 作为跨平台框架,官方并没有为鸿蒙提供一等公民级别的深层链接支持。
这就导致一个很尴尬的局面:你在 Flutter 层写的路由逻辑再漂亮,如果鸿蒙系统层面不把外部链接派发给你的应用,一切都是白搭。我见过不少团队在这个环节卡住,要么是链接点开没反应,要么是唤起了 App 却停在首页,要么干脆跳到了应用市场。这些问题的根子,都不在 Flutter 代码本身,而在于对鸿蒙平台应用间通信机制的理解深度不够。
这篇文章想做的事情很具体:把“Flutter 应用在鸿蒙平台上实现外部跳转”这件事,从原理到落地,从配置到排错,完整地拆一遍。适合两类人看:一类是正在做鸿蒙适配的 Flutter 开发者,另一类是对跨平台深层链接机制感兴趣、想搞清楚底层逻辑的技术人。我不会只给你一堆配置文件让你抄,而是会解释清楚每一步为什么这么做,以及我在实际项目里踩过的那些坑。
2. 深层链接在鸿蒙上的底层逻辑:不只是改个 Manifest
2.1 鸿蒙的应用间通信模型和 Android 的差异
要理解深层链接怎么实现,得先搞清楚鸿蒙是怎么处理“一个链接该由谁来打开”这个问题的。在 Android 上,你声明一个 intent-filter,系统在收到 VIEW 类型的 intent 时,会去匹配所有声明了对应 scheme 和 host 的应用,然后弹选择框或者直接打开默认应用。这套机制运行了十几年,大家都熟。
鸿蒙的 Want 机制在设计理念上和 intent 很像,但细节上有几个关键差异。第一,鸿蒙对隐式 Want 的匹配规则更严格,它要求你在 module.json5 里显式声明 skills 数组,里面包含 entities 和 actions,而且 actions 必须是系统预定义的字符串,不能随便写。第二,鸿蒙对 uri 的匹配是精确匹配加通配符匹配的组合,scheme、host、path 三段都可以用通配符,但优先级规则和 Android 不同。第三,也是最容易忽略的一点:鸿蒙在分发外部链接时,会先经过一个系统级的链接处理服务,这个服务会根据应用的安装状态、版本号、签名信息做一轮筛选,如果你的应用签名和声明不匹配,链接根本不会派发给你。
我一开始就是栽在第三点上。当时应用在调试机上跑得好好的,链接点开就能唤起,但一打到测试包发给同事,就死活没反应。排查了半天才发现,调试签名和正式签名的证书指纹不一样,而鸿蒙的链接服务在匹配时会把签名作为筛选条件之一。这个细节在官方文档里藏得很深,不踩一次坑根本注意不到。
2.2 Flutter 引擎在鸿蒙上的启动流程与链接接收时机
另一个需要搞清楚的问题是:当鸿蒙系统决定把你的应用拉起来处理一个链接时,这个链接信息是怎么传到 Flutter 层的?这涉及到 Flutter 引擎在鸿蒙上的启动流程。
简单来说,鸿蒙的原生入口是一个 Ability(可以类比成 Android 的 Activity),当系统因为链接唤起应用时,会在 onCreate 或者 onNewWant 回调里把 Want 对象传进来。这个 Want 里就包含了完整的 uri 信息。但此时 Flutter 引擎可能还没初始化完成,你的 Dart 代码还没跑起来。所以你不能直接在 Dart 层去读这个 Want,而是需要在原生层先把 uri 解析出来,缓存到一个地方,等 Flutter 引擎就绪后再通过 MethodChannel 传给 Dart 层。
这里有个时序问题特别关键:如果应用是冷启动(进程不存在),链接信息会在 Ability 的 onCreate 里到达;如果应用是热启动(进程还在后台),链接信息会在 onNewWant 里到达。这两种情况需要分别处理,而且热启动时 Flutter 引擎已经跑着,你可以直接通过 EventChannel 或者 MethodChannel 把新链接推给 Dart 层。我见过有团队只处理了冷启动的情况,结果用户从后台切回来点链接,App 是唤起了但页面没跳,体验很割裂。
2.3 为什么不能只靠 Flutter 层的路由方案
有些同学可能会想:我能不能在 Flutter 层用 go_router 或者 auto_route 这类路由库,配合一个统一的链接解析器,把深层链接的事情全在 Dart 层搞定?答案是:不行,至少不完整。
原因很简单:外部链接要唤起 App,这个动作是操作系统层面的,Flutter 层根本没有机会介入。系统在决定是否唤起你的应用时,看的是原生配置文件里的声明,不会去读你的 Dart 代码。所以原生层的配置是绕不过去的。Flutter 层的路由库能做的是:当链接信息已经传到 Dart 层之后,帮你解析路径、管理导航栈、处理参数。它解决的是“进来之后怎么跳”的问题,而不是“怎么进来”的问题。
我的建议是分工明确:原生层负责链接的接收、初步解析和缓存,Flutter 层负责路由分发和页面跳转。两层之间通过一个定义良好的 Channel 协议通信,协议里至少包含 uri、来源标识、时间戳这几个字段。这样职责清晰,排查问题也容易定位。
3. 原生侧配置:鸿蒙的 skills 声明与 Want 解析
3.1 module.json5 里 skills 字段的完整写法
鸿蒙应用要在 module.json5 的 abilities 数组里,为需要处理链接的 Ability 声明 skills。一个典型的配置长这样:
{ "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ts", "skills": [ { "actions": ["ohos.want.action.viewData"], "entities": ["entity.system.browsable"], "uris": [ { "scheme": "myapp", "host": "open.example.com", "path": "detail" } ] } ] }这里有几个点需要展开说。actions 里写ohos.want.action.viewData是鸿蒙系统预定义的查看数据动作,等价于 Android 的android.intent.action.VIEW。entities 里写entity.system.browsable表示这个 Ability 可以被浏览器类应用唤起。uris 数组里每个对象定义一组匹配规则,scheme 是必须的,host 和 path 可选但强烈建议写上,否则匹配范围太宽,容易被系统判定为低优先级。
关于 path 的写法,鸿蒙支持两种模式:精确路径和通配符路径。精确路径就是写死的字符串,比如detail;通配符路径可以用*匹配任意字符,比如detail/*可以匹配detail/123和detail/abc。但要注意,通配符只能出现在路径的末尾或者中间某一段,不能跨段匹配。我试过写detail/*/comment,结果发现匹配不上,后来查文档才知道鸿蒙的路径匹配是按段进行的,每段要么精确要么全通配。
3.2 Want 对象的解析与参数提取
当链接唤起应用后,原生层拿到的 Want 对象里,uri 信息在want.uri字段里。但这个 uri 是完整字符串,你需要自己解析出 scheme、host、path 和 query 参数。鸿蒙提供了@ohos.uri模块,可以用uri.URI类来解析:
import uri from '@ohos.uri'; function parseWantUri(want: Want): RouteInfo | null { if (!want.uri) return null; const parsed = new uri.URI(want.uri); const scheme = parsed.scheme; const host = parsed.host; const path = parsed.path; const params = new Map<string, string>(); parsed.queryParams.forEach((value, key) => { params.set(key, value); }); return { scheme, host, path, params }; }这段代码看起来简单,但有个坑:鸿蒙的queryParams返回的是一个Map或者类似结构,不同 API 版本行为略有差异。在 API 9 上,它返回的是一个可迭代对象,你需要用forEach或者for...of来遍历;在 API 10 上,它直接返回一个Map。如果你的应用要兼容多个 API 版本,最好写一个适配层,先判断类型再处理。
另一个坑是中文参数。如果链接里带了中文查询参数,比如?name=张三,鸿蒙在传递过程中可能会做一次 URL 编码,你拿到的可能是%E5%BC%A0%E4%B8%89。这时候需要用decodeURIComponent解码。我建议在解析完参数后统一做一次解码,避免后续业务层拿到乱码。
3.3 冷启动与热启动的分流处理
前面提到冷启动和热启动的链接到达时机不同,这里给出具体的处理框架。在 EntryAbility 的onCreate里,你需要检查want是否包含 uri,如果有就缓存起来,等 Flutter 引擎初始化完成后再发送。在onNewWant里,同样检查 uri,但此时可以直接通过已经建立好的 Channel 发送给 Dart 层。
export default class EntryAbility extends UIAbility { private pendingUri: string | null = null; private channel: MethodChannel | null = null; onCreate(want: Want) { if (want.uri) { this.pendingUri = want.uri; } } onNewWant(want: Want) { if (want.uri && this.channel) { this.channel.invokeMethod('onDeepLink', { uri: want.uri }); } } setChannel(channel: MethodChannel) { this.channel = channel; if (this.pendingUri) { channel.invokeMethod('onDeepLink', { uri: this.pendingUri }); this.pendingUri = null; } } }这个模式的关键在于:pendingUri作为冷启动时的暂存区,channel作为热启动时的直通道。setChannel方法在 Flutter 引擎初始化完成后由原生侧调用,把 Channel 注入进来,同时把暂存的 uri 发出去。这样就保证了无论冷启动还是热启动,链接信息都不会丢。
4. Flutter 侧的接收与路由分发
4.1 MethodChannel 的建立与消息协议设计
Flutter 侧需要在引擎初始化后尽快建立 MethodChannel,并注册好处理方法。Channel 的名字要和原生侧约定一致,我一般用app/deeplink这种带命名空间的格式,避免和其他 Channel 冲突。
class DeepLinkHandler { static const _channel = MethodChannel('app/deeplink'); final _controller = StreamController<Uri>.broadcast(); Stream<Uri> get stream => _controller.stream; void init() { _channel.setMethodCallHandler((call) async { if (call.method == 'onDeepLink') { final uriString = call.arguments['uri'] as String; final uri = Uri.parse(uriString); _controller.add(uri); } }); } }这里用 Stream 而不是回调,是因为深层链接可能在应用生命周期内多次到达,Stream 可以方便地做多订阅和流式处理。消息协议我建议至少包含 uri 字符串,如果原生侧能提供来源标识(比如是从浏览器还是从短信来的),也一并带上,方便做数据统计和差异化处理。
4.2 路由解析:从 Uri 到页面栈的映射策略
拿到 Uri 之后,下一步是把它映射到具体的页面。这里有两种主流做法:一种是维护一张路由表,把 path 和页面构造器对应起来;另一种是用代码生成的路由方案,比如 go_router 的GoRouter配置。
我倾向于用显式的路由表,因为深层链接的路径往往和内部路由路径不完全一致,需要做一层转换。比如外部链接可能是myapp://open.example.com/detail/123,而内部路由可能是/product/detail?id=123。这中间的映射逻辑,用路由表写最清楚:
class DeepLinkRouter { final _routes = <String, Widget Function(Uri)>{ '/detail': (uri) => DetailPage(id: uri.pathSegments.last), '/user': (uri) => UserPage(uid: uri.queryParameters['uid'] ?? ''), '/promo': (uri) => PromoPage(code: uri.queryParameters['code'] ?? ''), }; Widget? resolve(Uri uri) { final path = '/' + uri.pathSegments.first; final builder = _routes[path]; return builder?.call(uri); } }这个路由表的设计要点是:key 用路径的第一段,value 是一个接收完整 Uri 的构造器函数。这样每个页面可以自己决定从 Uri 的哪部分取参数,灵活性最高。如果路径匹配不上,返回 null,上层可以做兜底处理,比如跳到首页或者显示一个错误页。
4.3 导航栈的处理:什么时候 push,什么时候 replace
深层链接的导航行为需要仔细设计。如果用户是从外部链接进来的,通常期望的是直接看到目标页面,而不是在首页上面叠一层。这时候应该用pushReplacement或者先popUntil到根再push。但如果应用已经在运行,用户从后台切回来点了一个链接,可能期望的是在当前栈上面 push 一个新页面,保留返回能力。
我的做法是根据应用状态来分流:如果应用是冷启动(Flutter 引擎刚初始化),用pushReplacement直接替换根页面;如果是热启动(引擎已在运行),用push叠加。判断应用状态可以通过一个全局标志位,在 Flutter 的initState里设为冷启动,之后设为热启动。
void handleDeepLink(Uri uri, {required bool isColdStart}) { final page = _router.resolve(uri); if (page == null) { _navigatorKey.currentState?.pushReplacementNamed('/home'); return; } if (isColdStart) { _navigatorKey.currentState?.pushReplacement( MaterialPageRoute(builder: (_) => page), ); } else { _navigatorKey.currentState?.push( MaterialPageRoute(builder: (_) => page), ); } }这里用了一个全局的navigatorKey,这是必须的,因为深层链接的处理往往发生在 Widget 树之外,你需要一个不依赖 BuildContext 的方式来操作导航栈。
5. 实测中遇到的五个典型问题与排查路径
5.1 链接点开无反应:从系统日志开始查
这是最常见的问题,表现是点击链接后什么都没发生。排查路径应该从系统日志开始。在鸿蒙设备上,你可以用hdc shell hilog查看系统日志,过滤关键字Want或者你的应用包名。如果日志里能看到系统收到了链接,但没有派发给你的应用,说明是 skills 配置的问题;如果连日志都没有,说明链接本身格式不对,或者系统没有识别为可处理的链接。
我遇到过一次,链接在浏览器里点开没反应,但复制到备忘录里再点就能唤起。后来发现是浏览器的链接处理策略问题:某些浏览器对非 http/https 协议的链接会做拦截,需要用户手动确认。这种情况你没法从应用侧解决,只能建议用户换浏览器或者用系统自带浏览器测试。
5.2 唤起了 App 但停在首页:时序问题的三种可能
这个问题比第一个更隐蔽,因为应用确实被唤起了,只是没跳到目标页。可能的原因有三种:一是原生层解析了 uri 但没有传给 Flutter 层;二是 Flutter 层收到了但路由解析失败;三是路由解析成功了但导航操作被其他逻辑覆盖了。
排查时我建议在三个关键节点打日志:原生层解析完 uri 后打一条,Channel 发送后打一条,Dart 层收到后打一条。如果第一条有第二条没有,说明 Channel 没建立好;如果第二条有第三条没有,说明 Dart 层的 handler 没注册或者被覆盖了;如果三条都有但页面没跳,那就是导航逻辑的问题,需要检查navigatorKey是否绑定了正确的 Navigator。
5.3 参数丢失或乱码:编码问题的完整链路
参数丢失通常发生在两个环节:一是原生层解析 uri 时没有正确处理 query 参数,二是 Channel 传输时对特殊字符做了转义。鸿蒙的 Want 在传递 uri 时,会对某些字符做编码,比如&可能变成%26,=可能变成%3D。如果你在原生层直接字符串截取,很容易截错。
我的做法是:原生层解析出参数后,统一做一次decodeURIComponent,然后再通过 Channel 传输。Channel 传输时用 JSON 格式,把参数放在一个对象里,避免字符串拼接带来的转义问题。Dart 层收到后再做一次校验,如果发现参数值里还有%开头的编码序列,再解码一次。虽然多了一次解码,但能覆盖绝大多数编码场景。
5.4 应用在后台时链接失效:onNewWant 的触发条件
前面提到热启动时链接信息在onNewWant里到达,但这里有个前提:Ability 的启动模式必须是singleton或者standard且没有被销毁。如果 Ability 被系统回收了,onNewWant不会触发,而是走onCreate。所以你的代码必须同时处理这两种情况,不能只写一个。
另外,鸿蒙对后台应用的链接唤起有一些限制策略,具体行为取决于系统版本和电源管理状态。我实测下来,如果应用被用户手动划掉,链接唤起会走冷启动;如果应用在后台但被系统冻结,可能会延迟唤起或者不唤起。这个行为在不同设备上表现不一致,建议在测试时覆盖多种场景。
5.5 签名与包名不匹配导致的静默失败
这是最坑的一个问题,因为没有任何错误提示,链接就是静默失败。前面提过,鸿蒙的链接服务会校验应用签名。如果你在调试时用的是自动生成的调试签名,而 module.json5 里声明的 skills 没有和签名绑定,系统可能不会把链接派发给你。
解决方案是:在正式打包时,确保签名证书和你在开发者后台注册的证书一致。如果需要在调试阶段测试深层链接,可以在开发者选项里关闭签名校验(如果有这个选项的话),或者直接用正式签名打包测试。我个人的习惯是,深层链接的测试一律用正式签名包,避免调试签名带来的干扰。
6. 进阶:让深层链接更可控的几个实践
6.1 链接的降级策略:当 App 未安装时怎么办
深层链接的一个常见场景是:用户点了链接,但没装 App。这时候理想的体验是引导用户去应用市场下载,下载完后还能恢复到之前的链接目标。鸿蒙支持一种叫“延迟深层链接”的机制,原理是在链接里带一个参数,应用首次启动时读取这个参数并跳转。
实现方式是在你的链接服务端做一个重定向:用户点开链接后,先到一个中间页,中间页尝试唤起 App,如果唤起失败(通过超时判断),就跳转到应用市场。同时把原始链接的目标信息存在服务端,用设备标识关联。App 首次启动时,带着设备标识去服务端查询,拿到目标信息后跳转。这套方案需要服务端配合,但体验最完整。
6.2 多环境下的链接配置管理
开发、测试、生产环境往往需要不同的链接 scheme 或者 host。比如开发环境用myapp-dev://,生产环境用myapp://。如果每次打包都手动改 module.json5,很容易出错。我的做法是用构建脚本在打包前动态生成 module.json5,把环境相关的配置抽成变量。
具体来说,在项目的构建配置里定义deepLinkScheme和deepLinkHost两个变量,然后在 module.json5 的模板里用占位符。构建时根据目标环境替换占位符,生成最终的配置文件。这样一套代码可以打出多个环境的包,互不干扰。
6.3 链接安全:校验来源与参数合法性
深层链接是一个开放入口,任何人都可以构造一个链接来唤起你的应用。如果不做校验,可能会被恶意利用,比如构造一个跳转到敏感页面的链接。我的做法是在原生层和 Dart 层都做校验:原生层校验 scheme 和 host 是否在白名单里,Dart 层校验路径是否在路由表里、参数是否符合预期格式。
对于涉及敏感操作的页面(比如支付、账号设置),还应该要求用户已登录,并且在跳转前做一次身份确认。这些校验逻辑不要只放在一处,原生层和 Dart 层各做一遍,形成纵深防御。
7. 我个人在实际项目中的几点体会
这套方案我在两个鸿蒙项目里落地过,整体跑下来稳定性没问题,但有几个细节值得反复提醒。第一,Channel 的建立时机越早越好,最好在 Flutter 引擎的onCreate之后就立即建立,不要等到第一个页面渲染完,否则冷启动的链接可能会延迟到达。第二,日志一定要打全,原生层和 Dart 层的日志用同一个 tag 前缀,方便在 hilog 里过滤。第三,测试用例要覆盖冷启动、热启动、后台唤起、未安装降级这四种场景,缺一个都可能在线上出问题。
还有一个容易被忽略的点:鸿蒙的系统版本迭代很快,不同版本对 Want 的处理行为可能有细微差异。建议在项目里维护一个兼容性矩阵,记录每个版本上实测的行为,升级系统或者 API 版本时对照检查。这个习惯帮我省了很多排查时间。
最后说一个我踩过的坑:鸿蒙的onNewWant在某些情况下会被连续调用两次,导致同一个链接被处理两遍,页面被 push 两次。解决方案是在 Dart 层做一个去重,记录最近处理的 uri 和时间戳,如果同一个 uri 在 500 毫秒内重复到达,就忽略第二次。这个阈值可以根据实际情况调整,但去重逻辑一定要有。