Flutter Android 项目把链接拉起做通并不难,难的是把“浏览器里能点开”“系统不再弹选择框”“App 里能命中正确页面”“发版后还能持续验证”这四层同时跑稳。很多团队第一次接 App Links 时,问题并不在 Flutter 页面,而是在 AndroidManifest、签名指纹、域名验证和路由入口被拆成了几段,各自都看起来没错,合在一起却失效。下面这篇稿子只解决一个常青问题:Flutter Android 深链为什么时好时坏,以及应该按什么顺序排查。

适用场景和前置边界

这套排查流程适合已经有 Flutter Android 线上包、自己可控域名、并且需要把 https:// 链接直接拉到商品详情、活动页或消息页的项目。本文按 Flutter 文档当前对应的 3.44.0 路径整理,Android 6 及以上可以走自动校验,Android 12 及以上还能手动触发域名验证命令。

如果你的链接来源是第三方营销平台、短链平台或者别人的域名,这篇文章只适合处理 App 内路由,不适合承诺系统级 App Links 验证结果。对于真实项目,我建议先把目录固定下来,至少保证下面三个位置由同一位负责人一起改:

android/app/src/main/AndroidManifest.xml
lib/app/linking/app_link_handler.dart
web/.well-known/assetlinks.json

如果你已经在做环境隔离,可以先把生产域名、预发域名和测试包名按 Flutter Android 多环境配置实战:flavor、dart-define-from-file 与 CI 构建检查清单 里的方式拆开,不要把所有 host 都混进同一个生产配置。

先把故障拆成四层,不要一上来改 Dart 页面

深链不生效时,最容易犯的错误是直接去改页面跳转。实际上它至少有四层:

  1. AndroidManifest.xml 里的 intent filter 有没有声明对。
  2. 域名下的 assetlinks.json 能不能和包名、签名指纹对上。
  3. 系统有没有真的把这个 host 验证成默认处理方。
  4. Flutter 收到 URI 以后,路由解析、日志和页面参数有没有命中。

排查顺序也必须固定。我的建议是:先配声明,再做网站校验,再跑 adb 验证命令,最后才看 Dart 代码。这样做的好处是日志会非常干净,你能明确知道故障是“系统没把链接交给 App”,还是“App 打开了但路由解析错了”。

为了让问题可复现,先准备一个真实链接,例如 https://app.example.com/product/42?from=campaign。后面所有测试、日志和验证都围绕这一条链接,不要一会儿测首页,一会儿测详情页,否则你得到的只是零散现象,不是稳定结论。

App Links 的重点不是“多写几个 <data> 就更稳”,而是让每个 intent filter 只表达一组明确的 scheme、host 和 path 组合。Android 官方文档提醒,同一个 intent filter 里的多个 <data> 会被系统合并;如果你本来只想支持 https + app.example.com + /product,却顺手又塞了别的 host,很容易把组合范围放大。

<activity
    android:name=".MainActivity"
    android:exported="true"
    android:launchMode="singleTask">

    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="https"
            android:host="app.example.com"
            android:pathPrefix="/product" />
    </intent-filter>

    <intent-filter android:autoVerify="true">
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <category android:name="android.intent.category.BROWSABLE" />
        <data
            android:scheme="https"
            android:host="app.example.com"
            android:pathPrefix="/campaign" />
    </intent-filter>
</activity>

这里有三个排查要点。第一,android:autoVerify="true" 必须放在你真正要校验的网站链接过滤器上。第二,如果你有 www.example.comapp.example.com 两个子域名,不要偷懒写成一团,后面网站侧也要分别准备。第三,Android 11 及以下对多 host 更苛刻,只要 manifest 里列出的 host 有一个拿不到匹配结果,默认处理关系就可能建不起来。

Flutter 路由入口只保留一条解析链

系统把链接交给 App 以后,Flutter 侧最怕“双重处理”:一边是 Flutter 默认深链入口,一边又接了第三方插件或自定义监听,最后表现成首页先闪一下、详情页再跳一次,或者 query 被覆盖。比较稳的做法是只保留一个 URI 解析函数,把页面参数、来源和日志都收口到同一个文件。

import 'package:flutter/foundation.dart';

sealed class LinkTarget {
  const LinkTarget();
}

class ProductTarget extends LinkTarget {
  const ProductTarget(this.id, this.source);
  final String id;
  final String? source;
}

class HomeTarget extends LinkTarget {
  const HomeTarget();
}

LinkTarget parseIncomingUri(Uri uri) {
  debugPrint('[deep_link] uri=$uri path=${uri.path} query=${uri.queryParameters}');

  final segments = uri.pathSegments;
  if (segments.length >= 2 && segments.first == 'product') {
    return ProductTarget(segments[1], uri.queryParameters['from']);
  }
  return const HomeTarget();
}

把这段代码放到 lib/app/linking/app_link_handler.dart 后,再让路由层只消费 LinkTarget,不要在页面里重复做字符串切分。这样你在日志里看到 uripathquery 和最终目标页面,就能快速判断问题究竟出在命令、配置还是代码。

如果项目已经用了第三方 deep link 插件,Flutter 官方在 3.27 起明确提醒要关闭默认深链开关,否则会发生重复分发。Android 侧可以加上:

<meta-data
    android:name="flutter_deeplinking_enabled"
    android:value="false" />

这一步不等于必须放弃插件,而是把责任边界划清楚:要么用 Flutter 默认入口,要么用插件统一处理,不要两套链路同时抢事件。

网站端 assetlinks.json 决定系统愿不愿意信任你

很多“本地调试没问题,上线后失效”的根因,其实在 assetlinks.json。Android 只会去 https://你的域名/.well-known/assetlinks.json 拉取声明文件,然后拿里面的包名和 SHA256 指纹对照 App。先用下面的命令拿指纹:

keytool -list -v -keystore upload-keystore.jks

生产包如果启用了 Play App Signing,线上用户设备实际看到的证书通常不是你本地 keystore 那份,所以应该以 Play Console 里的指纹为准。一个最小可用的 assetlinks.json 结构可以写成这样:

[
  {
    "relation": ["delegate_permission/common.handle_all_urls"],
    "target": {
      "namespace": "android_app",
      "package_name": "com.example.app",
      "sha256_cert_fingerprints": [
        "12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF"
      ]
    }
  }
]

这里的排查边界要记住两条。第一,子域名是分开的:www.example.comapp.example.com 需要各自可访问的文件。第二,文件发布完成后先看 HTTP 返回值,再看内容,不要只凭浏览器能打开就认为已经验证成功。你真正需要的是系统可以读取、匹配并建立信任关系。

用 adb、测试和 DevTools 做四层验证

配置写完以后,必须把验证动作变成固定命令,而不是靠手点。下面这组命令是我更推荐的最小测试路径:

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

adb shell pm set-app-links --package com.example.app 0 all
adb shell pm verify-app-links --re-verify com.example.app
adb shell pm get-app-links --user cur com.example.app

adb shell am start -a android.intent.action.VIEW   -c android.intent.category.BROWSABLE   -d "https://app.example.com/product/42?from=campaign"   com.example.app

这组命令对应四个验收对象:网站文件能返回、域名验证状态能刷新、系统能把链接投递给正确包名、Flutter 日志能打印出目标 URI。到了这一步,再补一层回归测试会更稳。你可以把深链入口加入 Flutter Android 测试别只跑 flutter run:单元、Widget、集成测试与设备回归清单 的设备回归里,至少覆盖冷启动打开详情页、前台状态再次拉起、链接参数缺失回退首页三种场景。

真正执行时,我会把每一步结果记到同一张发布记录里:curl -I 看的是网站交付是否稳定,pm get-app-links 看的是系统验证状态,am start 看的是目标页面是否命中,而 Flutter 控制台日志则负责还原参数有没有丢。这样你第二次复盘同一问题时,不需要重新猜“是不是缓存了旧配置”,只要对照上一次记录的 host、包名、证书和验证时间,就能快速知道是哪一层发生了变化。

如果你已经在用 Flutter DevTools,新版 Deep Links validator 也值得跑一次。它更像结构化检查:帮你看 manifest、网站配置和项目声明是否对齐,适合在发版前当最后一道验证,而不是等线上故障后才临时打开。

最常见的失败边界

  • 浏览器点链接仍然弹系统选择框:先看 pm get-app-links,如果 host 不是 verified,继续盯 assetlinks.json 和签名,不要先改 Dart。
  • debug 包能跳,release 包不行:大概率是证书指纹不一致,尤其是接入 Play App Signing 以后。
  • 只有一个子域名能打开:说明你把多个 host 写进 manifest 了,但网站侧没有逐个发布声明文件。
  • App 打开了却总回首页:通常是 URI 路径解析太松、pathSegments 取值错,或者两套 deep link 入口同时处理。
  • Android 11 真机和 Android 14 真机表现不同:前者对多 host 的验证结果更严格,后者则支持手动重新触发验证命令。

这些边界都不需要猜。只要你保留 uri 日志、固定测试链接和 adb 命令,问题一般都能落到某一层,而不是在“看起来像路由问题”和“像服务器问题”之间来回摇摆。

发布前复盘清单

发版前我会把下面这张清单走完一次:

  1. Manifest 里的 host、scheme、pathPrefix 是否只保留需要上线的那组配置。
  2. assetlinks.json 的包名和 SHA256 指纹是否与生产包一致。
  3. adb shell pm get-app-links --user cur com.example.app 是否显示目标 host 已验证。
  4. 真机冷启动、前台拉起和参数缺失回退三种测试是否通过。
  5. Flutter 日志里是否能看到统一的 URI、页面命中结果和失败回退路径。

如果团队里还有 Android、后端和运营一起协作,建议把这五项结果直接贴到发版单里,而不是只留一句“深链已测”。这样后面有人改域名跳转、补营销参数或替换签名证书时,任何人都能拿历史记录对照,知道自己改动的是哪一层,避免把一次简单的配置回归拖成跨团队排障。

做到这一步,深链配置就不再是“能用就先发”,而是有命令、有日志、有测试、有验证结果的稳定能力。下一篇如果继续往后走,更合理的方向不是重复写深链入门,而是把 WebView、原生插件或混合工程里的链接桥接继续拆开。