☰
Android App Links 跳转不一致:从 assetlinks.json 到系统差异的完整排查
2026/10/27 5:14:51 网站建设 项目流程

同样一个链接,用户今天一点直接进了App,明天再点却弹出了系统选择器,甚至干脆落在浏览器里。这种"链接跳转表现不一致"的问题,我在App Links的排查里碰到过不止一次,标题里的"Inconsistent presentation of links"说的就是它。核心绕不开三个环节:app links 配置、assetlinks.json 验证、以及未安装应用时的下载兜底。搞了几年深链开发后我越来越清楚,链接能不能稳定跳转,根本不是 manifest 里写个 autoVerify="true" 就能解决的事,而是从静态文件托管、证书指纹、签名一致性到系统版本差异,一环扣一环。这篇文章我就把实际踩过的坑、排查过程和处理方案完整拆开讲,适合正在做 Android 深链接入、或者被线上链接跳转搞得头大的移动端研发参考。

1. 链接跳转表现不一致,问题到底出在哪

1.1 三种"看起来一样、底层完全不一样"的链接

先说个容易混淆的点。市面上叫"深链"的东西其实分好几种,表现层都是"点一个链接打开App",但系统处理链路完全不同,这也是"表现不一致"的第一个根源。

第一种是自定义 Scheme 的 Deep Link,比如myapp://detail?id=123。这种链接只要 App 注册了对应的 intent-filter,系统就会尝试匹配。但问题在于自定义 scheme 的"命名空间"是全球共享的,任何 App 都可以声明同一个 scheme,所以 Android 默认会弹一个"打开方式"的选择器让用户选,而且一旦用户选了某个应用并勾选了"始终",以后就再也不会问你了。这种表现就是典型的"时灵时不灵"。

第二种是 Intent Scheme,形如intent://detail?id=123#Intent;scheme=myapp;package=com.example.app;end。它可以在网页里通过 JS 或 a 标签触发,能指定包名甚至 fallback 地址。但 Chrome 从 25 版本之后就限制非浏览器环境直接拉起 intent,移动端 WebView 里经常被拦截,兼容性极差。我接手过一个老项目就用这种方式,线上反馈"一半用户能打开,一半打开失败",最后全部迁移到了 App Links。

第三种是 Android App Links,使用的是标准的https://链接,配合autoVerify="true"和服务器上的assetlinks.json文件做数字资产校验。只有校验通过,系统才认为是"已验证的链接",点击后直接拉起 App 且不弹确认框。这是目前 Google 推荐的方案,也是绝大多数"链接跳转表现不一致"问题集中爆发的区域——因为它引入了服务端配置、签名校验、系统版本差异等多个变量,任何一个环节出错,表现就会退化。

1.2 不一致的典型表现清单

我在实际项目里把用户反馈和测试结果归了一下类,基本逃不出下面这几种:

用户看到的现象底层实际原因问题归属
第一次点直接进App,第二次弹确认框assetlinks.json 验证失败,系统回退到普通深链验证环节
同一链接,部分机型进App,部分机型开浏览器系统版本差异,低版本不自动重试验证版本差异
链接能打开网页,但 App 已安装就是不响应intent-filter 的 path 或 host 配置不匹配配置环节
未安装 App 时直接打开网页首页,没有下载引导缺少网页端 fallback 或 smart banner未安装兜底
用户之前选过"始终用浏览器",之后再也进不了App用户偏好被系统持久化用户偏好
开发环境正常,线上包跳不了debug 签名和 release 签名指纹不一致签名环节

看到这里应该有个直觉:这个问题的本质不是"单一原因崩了",而是"多个环节里有一个悄悄失效"。下面我从最常见的 assetlinks.json 开始逐个拆。

2. assetlinks.json 验证是App Links的地基

2.1 assetlinks.json 到底怎么放才正确

App Links 的验证机制说起来很简单:系统安装或升级 App 时,会根据 manifest 里声明的 host 去请求https://你的域名/.well-known/assetlinks.json,拿到文件后用里面的包名和证书指纹和本地安装包比对,一致就标记为 verified。

但"正确"这两个字坑很多。文件必须放在域名根目录下的.well-known目录里,路径不能自定义。这个目录很多人会拼错——注意是.well-known,点号开头,中间一个连字符,不是.wellknown也不是well-known。服务器必须返回 200,不能重定向到别的地方,不能要求登录,不能带鉴权头。我之前遇到过一个项目,前端做了 SPA 路由,所有未匹配的路径都返回index.html,assetlinks.json 看似能访问,但返回的内容是 HTML 而不是 JSON,系统解析失败,验证直接挂掉。

文件内容格式长这样:

[ { "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "com.example.app", "sha256_cert_fingerprints": [ "14:6D:E9:83:C5:73:06:50:D8:EE:B9:95:2F:34:FC:64:AD:A3:3A:6E:9C:79:8A:31:3D:1A:90:FD:9C:E7:81:9D" ] } } ]

这里有个容易忽视的细节:relation字段默认是delegate_permission/common.handle_all_urls,如果你的网页只想让 App 处理特定路径,可以写多个 statement,但不能把handle_all_urls和handle_as_http混在一个 statement 里。我在一个项目里为了同时支持"整站跳 App"和"某些页面留在浏览器",在这里反复改过几次,最后是把两条 statement 分别写清楚才稳定。

另外一个实战提醒:assetlinks.json 里可以包含多个 App 的条目,也可以包含同一 App 的多个指纹。文件是 JSON 数组,每一项算一条 statement。一定要保证输出内容能被标准 JSON 解析器解析,不带 BOM,末尾没有多余逗号。很多编辑器保存时自动加 BOM,或者 JSON 里混了注释,解析器直接报错——这种问题在浏览器里访问看着正常,但系统验证时用的是严格解析,一票否决。

2.2 SHA-256 指纹怎么取才不出错

证书指纹这个环节,是我见过翻车频率最高的地方。很多人直接用 Android Studio 面板里显示的 SHA-1 或者把 debug 签名的指纹传到生产环境的 assetlinks.json 里,结果就是线上用户全部被打回浏览器。

正确的取法是拿你线上正式签名的 keystore 来算。命令行方式:

keytool -list -v -keystore your-release.jks -alias your-alias -storepass your-pass

输出里找到SHA256:那一行,然后把冒号和小写字母处理成大写。assetlinks.json 里要求的是带冒号且大写的形式,比如14:6D:E9:...。

这里有个坑值得单独说:某些 JDK 版本下 keytool 输出的 SHA256 只有一行,而 Android Studio 的 签名面板或者 Gradle 的 signingReport 任务输出的指纹格式可能不带冒号或者全是小写,两种格式混着用就会校验失败。我建议统一用 keytool 的输出,手动转大写,复制过去之后再用 Google 官方的 Digital Asset Links 测试工具验证一遍。测试工具地址是:

https://developers.google.com/digital-asset-links/v1/tester

输入你的域名和 AppID,它会直接调用官方 API 查 assetlinks.json 的解析结果和指纹匹配情况。这个工具在我的排查流程里排第一位,比在真机上反复安装测试快得多。

最容易被忽视的是 debug 和 release 的签名不一致。开发阶段你用的是 debug.keystore 签名,生成的指纹和线上发布包的 release 指纹完全不一样。如果你在 assetlinks.json 里只写了 release 指纹,本地 debug 包永远验证不通过;反过来如果只写了 debug 指纹,线上包又验证不通过。规范做法是把两条指纹都写进去,或者至少搞清楚当前测试的是哪种签名。实际项目里我更推荐用 Gradle 的 signingConfig 区分 flavors,让 debug 和 release 用不同的 keystore,assetlinks.json 里两条都维护,这样开发和线上互不干扰。

2.3 为什么验证失败却"看起来正常"

这是"Inconsistent presentation of links"最迷惑人的地方:很多链接验证失败了,但表现上并没有完全失效。原因在于 App Links 校验失败之后,系统会静默降级为普通深链处理——链接依然能打开浏览器里的对应网页,如果该网页上又有拉起 App 的脚本或者 smart banner,用户点一下还是能进 App,整体体验"看起来能用"。

但降级后的行为和你预期完全不同。普通深链会弹系统选择器,或者被浏览器接管,用户每次点击都可能得到不同结果,今天直接进、明天弹选框、后天开浏览器,全看当时系统状态和用户之前做过什么选择。所以"看起来正常"恰恰是最大的信号:如果你发现链接没有一个确定性的跳转结果,那就是验证没通过,先查 assetlinks.json,别急着改业务代码。

还有一个容易误导的场景:你在浏览器地址栏直接访问https://example.com/.well-known/assetlinks.json,看到内容显示正常,就以为服务器没问题。但系统验证时用的是独立的网络请求,可能被 CDN 缓存、被 WAF 拦截、被运营商劫持。我遇到过 CDN 把 assetlinks.json 缓存了旧版本,App 升级换了新包之后指纹对不上,整整一周线上验证全挂,CDN 刷新之后瞬间恢复。所以排查时一定要看 CDN 和反代层的缓存策略,assetlinks.json 这类低频变更文件建议设置较短的缓存时间,甚至通过版本号参数绕过缓存。

3. 未安装场景:为什么有的链接能跳下载页,有的直接404

3.1 App Links 对未安装应用的处理机制

"用户没装 App 时点击链接应该怎么办"是另一个和 "未安装"、"下载" 强相关的重灾区。先说系统层面的机制:App Links 的验证目标是"本机已安装的应用",如果系统发现没有任何已安装且验证通过的 App 能处理这个链接,那么链接就交给浏览器处理——注意,这里没有"自动打开应用商店"的机制,系统不会管你 App 是否在商店上架,更不会自动跳下载页。

所以你看到的"未安装时能跳下载页",一定是网页端做了兜底逻辑,而不是系统行为。常见的兜底有三类:

第一类是微信/微博这类社交平台内实现的智能跳转,它们通过白名单来控制;第二类是普通浏览器里的网页应用了 Google Smart Lock 的 App Indexing 或者 Manifest 里的related_applications;第三类就是最通用的网页脚本,在页面加载时检测客户端是否能处理自定义 scheme,不能就跳商店。第三类实际最可控,但需要和后端网页团队配合。

如果使用 Intent Scheme 方式的网页,会有标准的 fallback 写法:

<a href="intent://example.com/path#Intent;scheme=https;package=com.example.app;S.browser_fallback_url=https%3A%2F%2Fplay.google.com%2Fstore%2Fapps%2Fdetails%3Fid%3Dcom.example.app;end"> 打开 App </a>

这种写法在 App 未安装时会把用户带到browser_fallback_url指向的下载地址,在 App 已安装且验证通过时会直接拉起。它的问题在于对 Chrome 之外的浏览器兼容性参差不齐,而且在 iOS 上没有对应实现。所以如果做跨端深链,我建议网页端用https://链接 + JS 检测的通用方案,Android 端走 App Links,iOS 端走 Universal Links,服务端统一维护一份"下载页 + 智能识别"逻辑,而不是依赖某一种网页写法。

3.2 方案对比:网页回退 vs Play商店直达 vs 下载页落地

兜底方案实现成本体验一致性适用场景
仅打开普通网页最低差,用户找不到下载入口临时处理
网页内置 smart banner中,需接 Google Play Services较好,但仅官方市场单一商店分发
网页脚本检测后跳转下载页中高最好,可自定义埋点多市场分发、运营需要
HTTP 302 服务端判断 UA 后重定向中依赖 UA 识别,有误判风险大流量落地页

我自己的经验是,工业级方案应该是"服务端重定向 + 网页脚本 + 下载页参数透传"三层配合。比如分享出去的链接里带?channel=xxx&from=yyy,App 已经安装时,打开 App 时读取这些参数完成归因;App 未安装时,下载页拿到同样的参数,用户安装后第一次启动时通过install referrer接回去。这种方案每一层都有明确分工,出问题也容易定位。

这里有个常被忽略的技术细节:即使 App 未安装,assetlinks.json 仍然必须保持可访问且内容正确。因为 Android 的校验动作在安装阶段就会发生,而且网页浏览时 Google Play Services 也会做一次"基于网页的验证",用于决定浏览器的提示形态。如果 assetlinks.json 挂了,哪怕你下载页做得再完善,已安装的用户也会被降级,未安装的用户也会失去"打开 App"的智能提示,等于两头都吃亏。

4. Android系统版本差异与验证状态的真实反馈

4.1 不同Android版本的验证行为差异

"Inconsistent presentation"还有一个隐蔽推手:Android 不同版本对 App Links 的验证和重试策略不一样,导致同一个链接在不同机型上表现不同。

Android 6 到 Android 10 时代,App Links 的验证基本只在应用安装或更新时触发一次。如果此时 assetlinks.json 因为网络抖动、服务端没配置好等原因导致验证失败,之后系统不会主动重试,除非你卸载重装或者清除应用数据。很多开发者在本地测试时因为刚装完、文件也正确,验证通过;但线上用户可能是在我们配置错误的窗口期装的包,之后永远等不到重试,于是长期表现为"别人能跳我不能跳"。

从 Android 11 开始,系统增加了手动"验证链接"入口,用户可以在系统设置 -> 应用 -> 打开支持的链接里主动触发验证,且系统在尝试匹配时会更宽容地重试。Android 12 和 13 则进一步收紧了未验证状态的行为,如果链接未通过验证,系统甚至不会把它列为可处理的 App,用户点击根本不会看到你的 App 选项。这个收紧方向本身是好事,但如果你还在用旧时代的"深链 + 选择器"思维,升级系统后用户表现会断层式变差。

另外一个版本相关的小坑:Android 11 及以上,adb shell pm get-app-links的输出格式更丰富,可以直观看到每个域名的验证状态是verified、failed还是none。低版本则要用dumpsys package domain-preferred-apps去翻,信息量少且难读。我已经把这两条命令放进所有项目的 README 里了,每次排查链接问题第一件事就是跑一遍。

4.2 用命令确认系统当前的验证状态

这是排查 App Links 问题最高效的一步,两条命令吃遍所有场景。

# Android 11+ adb shell pm get-app-links com.example.app

输出示例:

com.example.app: ID: 6f1b2c3d-xxxx-xxxx Signatures: [14:6D:E9:...] Domain verification state: example.com: verified www.example.com: failed

看到verified说明这一项没问题;看到failed就说明配置确实有问题,直接去查 assetlinks.json;看到none,常见原因是响应头不对或者网络层不可达,但也可能是应用根本没声明这个域名的 intent-filter。

# Android 6~10 或者想看用户偏好时 adb shell dumpsys package domain-preferred-apps

这段输出里重点看Package: com.example.app下面的Domain verification status和User precedence。User precedence代表了用户有没有手动设置过"始终用某 App 打开",如果这里显示了你没意料到的条目,说明用户的"记住选择"在起作用,代码层面再改也改不回用户的自主选择。

4.3 用户"记住选择"带来的假性不一致

这一点其实是很多"不稳定"反馈的真正来源。当多个应用声明了同一个链接,或者 App Links 验证失败导致系统降级时,系统会弹出一个选择器。用户如果在这时选择了"始终"用浏览器打开,那么这个选择会被持久化保存,系统以后会绕过你的 App,直接按用户选择处理。用户自己可能完全不记得自己点过"始终",于是反馈就是"链接时好时坏",但实际是系统偏好设置的问题。

测试团队也常踩这个坑:一台设备上之前测试别的应用时选了"始终用某应用打开",换了一个应用测试时发现链接根本不弹选项,误以为是新应用的配置问题。所以我建议测试流程里固定一条清理命令:

adb shell pm clear-package-preferred-activities

这条命令清掉所有应用的偏好设置,恢复系统默认行为。每个测试用例执行前跑一次,可以大幅减少"环境污染"导致的假性 bug。同时要在测试用例文档里明确写:不能用同一台设备连续测两个不同 App 的链接行为,必须清偏好或换设备。

5. 常见问题与排查技巧实录

5.1 高频问题速查表

结合这些年的接待和线上问题,我把高频问题整理成了一张速查表,方便排查时对照:

现象优先检查项常见根因
链接从未直接打开 App,总在浏览器adb shell pm get-app-links状态assetlinks.json 不可访问或内容错误
打开时弹选择器域名的验证状态验证失败降级为深链
部分域名能进 App,部分不能manifest 各域名的验证状态某个 host 没配置或指纹不匹配
debug 包能进,release 包不能指纹列表assetlinks.json 只配了 debug 指纹
线上链接今天正常明天不正常CDN 缓存 / 文件被误删服务端变更未同步
未安装应用时打开空白页网页兜底逻辑页面脚本未处理未安装分支
用户反馈"刚开始能用后来不行"设备用户偏好用户选了"始终"用浏览器打开

5.2 一套标准排障流程

我自己在项目里沉淀了一套固定排查顺序,遇到链接问题不慌,按这个顺序走一遍基本能定位。第一步,先用官方 Digital Asset Links 测试工具验证域名和 AppID,确认服务器端配置是否通过——这一步能把服务端问题在 30 秒内排除。第二步,本地装一个开发包,执行adb shell pm get-app-links看验证状态,如果状态是failed,重点看工具返回的具体错误,是不是 JSON 解析失败。第三步,检查 intent-filter 里的路径匹配规则。很多项目在链接上加pathPrefix或pathPattern限制跳转路径,但实际线上分享出去的 URL 路径格式和规则不一致,结果某些链接能打开、某些不能。

第四步,确认用户实际点击的 URL 是不是标准形式。我碰到过一个有意思的案例:运营在后台手工拼接链接,URL 里带了中文字符和多余空格,跳转时部分编码部分没编码,导致同一个链接在不同客户端表现不同。第五步才是查代码里的跳转逻辑和统计埋点。很多链接打开后 App 内又做了一层二次跳转,如果这层逻辑有判断分支,就会让用户看到"点链接后进了个奇奇怪怪的中间页"。

这里的关键心得是:一定要先确认系统层面的验证状态,再去查业务代码。系统状态是"原点",业务逻辑是"分支",原点没对,后面所有排查都是浪费时间。而且每排查一步都要记录当时的验证状态和复现路径,避免下次再从头查一遍。

5.3 几个让链接稳定运行的经验

最后分享几个能长期减少"Inconsistent presentation of links"问题的工程经验。第一,assetlinks.json 的变更流程要和发版流程绑定。换个签名证书、加个新域名,都应该在发版清单里有一项"更新并验证 assetlinks.json",并且要有 CDN 刷新和官方工具验证这两个动作,凭记忆真的别信。

第二,增加监控。在你的核心链路上埋点,记录用户点击来源、落地 App 后的原始 URI、以及打开过程中的错误,用实时告警覆盖"验证失败率异常升高"的情况。我在项目里是把 Verification 状态上报到了日志平台,pm get-app-links的结果在测试阶段自动跑,一旦发现verified变成failed就会触发告警,这个习惯帮我们提前发现过两次证书到期导致的隐患。

第三,内测阶段就要引入真机云测矩阵,覆盖 Android 11 以下的旧版本和主流厂商 ROM。很多国产 ROM 对 App Links 的处理并不完全遵守 AOSP 行为,有些会默认禁止后台拉起、有些会额外弹权限询问,这些表现差异在线下用户手里会放大成"链接坏了"的投诉。提前在测试矩阵里把厂商差异摸清楚,至少能做到心里有数,而不是被用户牵着走。

第四,给运营和客服部门准备一份 FAQ,说明"链接跳转受系统和用户选择影响"以及最简单的处理办法(比如在系统设置里清除默认打开方式)。很多客服反馈的"链接失效"其实根本不是技术故障,而是用户设备端偏好或系统版本的问题,一份 FAQ 能省掉大量无效排查时间。

我在实际项目里被这个问题折腾最久的一次,是线上版本发布后一周内,用户反馈"点击分享链接进入 App 的成功率从 90% 掉到 30%"。当时代码没有任何改动,assetlinks.json 也没动过,最后查出来是 CDN 某条线路把旧文件缓存到了边缘节点,部分地区的用户请求到了旧 JSON,指纹和新包的签名对不上。从那之后我做了一个强制规定:assetlinks.json 的响应里加Cache-Control: no-cache,并且每次发版前用官方工具跑一遍,再让 QA 用一台全新设备做首装验证。链接跳转这种东西,用户是感受不到你中间做过多少努力的,但只要它有一次表现不一致,用户对产品的信任就会掉一截。把验证状态、缓存策略、版本差异这些变量管住,你才能真正做到"同一个链接,永远打开同一个 App"。

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

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

立即咨询