创建二维码时如何链接自定义参数?为 App 路由链接自定义参数需要将结构化的 URL 查询键附加到托管的落地页链接上,通过网页集成层捕获这些键,并在应用直接唤醒或安装后启动时将验证后的有效载荷分派给原生导航控制器。
通过二维码进行应用内路由是一种客户端-服务器导航架构,它将结构化的目标参数嵌入二维条形码 URL 中。当应用程序已安装时,通过操作系统的深度链接直接解析这些参数;或者当应用程序从应用商店新安装时,通过平台支持的延迟恢复机制进行解析。工程团队借此可以直接将用户引导至特定的应用内页面,而不是默认的主页视图。
| 术语 | 定义 | 相关实体 | 搜索意图角色 |
|---|---|---|---|
| Create QR Code for App | 将可执行的路由 URL 编码到用于移动端导航的二维条码中的工程工作流。 | 场景还原 | 信息型 / 技术型 |
| app qr code | 配置为触发原生应用启动或应用商店引导的条码资产。 | Universal Links / App Links | 技术型 / 商业型 |
| dynamic qr code | 指向中间路由端点的条码,其目标有效载荷可在服务端进行更新。 | URI 间接寻址 | 商业型 |
| qr code tracking | 对路由入口请求以及下游应用内页面激活情况的度量。 | 渠道统计与追踪 | 商业型 |
架构概览:如何为 App 场景还原创建二维码

主页流失问题:为什么通用的应用商店重定向会中断高意图用户旅程
实体二维码经常部署在高意图的上下文环境中:零售购物者扫描货架标签以查看产品规格,食客扫描桌贴以从特定菜单分类中点餐,或者玩家扫描邀请条码加入多人游戏大厅。当条码编码的是通用的应用商店链接或未参数化的域名时,用户的上下文意图在从实体扫描过渡到原生应用运行时的过程中就会丢失。
在启动应用程序时,用户会被直接丢到默认的顶层主页。强迫用户手动浏览复杂的分类树、重新输入搜索查询或键入房间标识符,会带来巨大的认知和操作摩擦。App 场景还原通过将目标状态编码到二维码 URL 中,并在原生客户端内以编程方式重建该目标视图,从而有效解决这一流失问题。
双状态路由机制:直接唤醒与延迟场景还原

当用户扫描 App 路由二维码时,移动设备处于两种安装状态之一。一个完整的路由架构通过两条不同的技术路径处理这两种生命周期:
- 直接唤醒路由(应用程序已安装):当目标应用程序已存在于设备上时,扫描二维码会触发操作系统深度链接——具体为 iOS 上的 Apple Universal Links 或 Android 上的 Android App Links。这两种机制都要求托管经过密码学验证的域名关联文件:iOS 需要
apple-app-site-association(AASA) 文件并配对 XcodeAssociated Domains权限,而 Android 则需要与应用程序签名证书 SHA-256 指纹匹配并经过验证的清单意图过滤器的/.well-known/assetlinks.json数字资产链接文件。验证通过后,操作系统会唤醒应用程序并将 URI 直接传递给客户端的生命周期委托。 - 延迟场景还原(应用程序未安装):当应用程序不存在时,用户将通过中间网页落地页被引导至平台应用商店。由于尚未安装原生二进制文件来接收操作系统意图,因此必须在重定向到应用商店之前暂存路由参数,并在安装完成后在应用程序的首次启动(第 0 天冷启动)期间进行恢复。
平台特定的恢复边界:Google Play Install Referrer、iOS Universal Links 与 SDK 适配器
在用户从应用商店安装应用程序后,引荐与路由参数的恢复取决于平台以及应用程序或归因 SDK 所实现的机制。通过网页 URL 传递 route 或 id 参数本身并不能保证该参数能够在应用商店安装过程中存活下来:
- Android 与 Google Play:Google Play Install Referrer API 可以提供与 Play 商店安装相关的支持引荐元数据。此机制专门适用于通过官方 Google Play 商店路径完成的安装,不适用于任意第三方 APK 侧载或替代应用商店。应用程序必须配置并测试实际的引荐方有效载荷,并验证预期参数是否能在重定向和安装流程中存活。
- iOS 与 App Store:Apple App Store 不会自动将任意网页落地页查询参数传输到新安装的应用二进制文件中。当应用程序已安装且关联的域名配置有效时,Universal Links 支持直接路由;它们本身并不能保证新安装后的延迟参数恢复。
- 归因 SDK:仅使用为所部署的准确 SDK 版本和平台记录的恢复机制。请验证实现、数据收集和隐私影响,而不要假设某个 SDK 能够跨所有浏览器和安装路径恢复每个路由参数。
iOS 归因与隐私边界:遵循 Apple 反指纹识别规则
iOS 路由和延迟场景还原实现绝不能依赖禁止的设备指纹识别来跨应用程序或网站识别用户或设备。根据 Apple 用户隐私与数据使用指导及 Apple 开发者计划许可协议,开发者严格禁止为此目的从设备、浏览器、配置、位置或网络信号中推导唯一标识符。App 追踪透明度 (ATT) 授权并不会使原本被禁用的指纹识别变为合规。
当应用程序已安装时,请使用 Universal Links 进行经过验证的直接 App 路由。对于 iOS 上的安装后场景还原,请使用其行为已经过所部署 SDK 版本和适用的 Apple 要求审查的记录在案的机制。请查阅 SDK 的隐私披露和适用时的必需原因 API 声明,但不要将隐私清单(PrivacyInfo.xcprivacy)的存在视为归因实现合规的证据。
如果所选 SDK 依赖于无法验证为被允许的匹配方法,则不要建议将该方法用于 iOS 部署。如果没有允许的机制能够可靠地建立延迟路由上下文,请保留正常的引导流程,将用户引导至安全的安全默认主页视图,并将安装标记为未归因,而不是制造无法验证的匹配。
可视化双状态 QR 场景还原管道
下方的架构图将参数化二维码分支为直接操作系统唤醒或延迟应用商店恢复,随后在客户端路由验证器和身份验证关卡汇聚的各个阶段进行了说明:
[用户扫描二维码] ──> URL (?route=product_detail&id=sku_8842&channelCode=pos_01)
│
┌────┴────────────────────────────────────────┐
▼ (App 已安装) ▼ (App 未安装)
[OS Universal Link / App Link] [中间 H5 落地页]
│ │
▼ ▼
[热/冷启动回调] [应用商店下载与首次启动]
(onNewIntent / continueUserActivity) (平台支持的延迟恢复)
│ │
└────────────────────┬────────────────────────┘
▼
[客户端路由验证器与身份验证关卡]
│
┌────────────────────┴────────────────────────┐
▼ (已加入白名单并通过身份验证) ▼ (无效路由 / 不匹配)
[导航至产品详情页] [回退至默认主页]
步骤 1:设计自定义路由参数架构
构建可执行路由有效载荷与被动归因标签的对比
在为 App 路由创建二维码时,工程团队必须建立一个清晰的双层数据契约,将外部紧凑的 URL 查询键与内部标准化的 JSON 有效载荷分离开来:
| 契约层级 | 字段命名规范 | 示例表示 | 架构目的 |
|---|---|---|---|
| 外部 URL 查询层 | route, id, channelCode |
?route=product_detail&id=sku_8842&channelCode=pos_01 |
最小化 URI 字符长度,保持打印的二维码矩阵紧凑 |
| 内部标准化层 | route_key, target_id, channel_code, fallback_route |
{"route_key": "product_detail", "target_id": "sku_8842", ...} |
由 H5 和原生客户端路由器验证的标准规范对象 |
| 客户端安全注册表 | PROTECTED_ROUTES(内部常量) |
setOf("room_lobby", "promo_campaign") |
在内部确定身份验证要求;绝不信任来自外部 URL 的要求 |

在此双层模型中:
- 被动归因标签(
channelCodechannel_code)被分析管道消耗,用于衡量营销转化量,而不改变客户端 UI 状态。 - 可执行路由参数(
routeroute_key和idtarget_id)指示原生客户端的导航控制器要加载哪个屏幕以及要获取哪个实体。 - 身份验证策略隔离:路由是否需要身份验证(
requires_auth)必须严格从客户端应用程序内部受信的路由注册表中推导得出,而不是接受来自外部二维码查询参数的规定。
强制执行 IETF RFC 3986 百分号编码与有效载荷大小限制
附加到二维码目标 URL 的所有路由参数必须遵守 IETF RFC 3986 中指定的 URI 通用语法:
- 避免嵌套分隔符:切勿将原始 JSON 字符串(
?data={"route":"product","id":123})直接嵌入 URL 查询字符串中。未转义的花括号、引号和冒号经常会破坏应用内浏览器 URL 解析器并增加条码模块密度。 - 扁平化键值对:将外部路由上下文表示为扁平的、经过百分号编码的查询参数(
https://promo.example.com/open?route=product_detail&id=sku_8842&channelCode=pos_01)。 - 强制执行严格的字符边界:将
route和id值限制为 URL 安全的字母数字字符、连字符和下划线(^[A-Za-z0-9_-]{1,64}$)。
定义客户端路由白名单契约
一旦外部 URL 参数被摄取并标准化为内部有效载荷结构,它们就必须符合严格的架构契约。如果 route_key 无法识别,或者实体特定路由(product_detail、room_lobby、promo_campaign)缺少所需的 target_id,标准化器必须将有效载荷降级为带有 target_id: null 的 home_default。
下面的 JSON Schema 定义了标准化场景还原有效载荷的规范结构:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "QrSceneRestorationPayload",
"description": "规范的、标准化的路由有效载荷。身份验证策略是从应用程序的受信路由注册表中独立推导出来的。",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"route_key",
"target_id",
"fallback_route"
],
"properties": {
"schema_version": {
"const": "1.0.0"
},
"route_key": {
"type": "string",
"enum": [
"product_detail",
"room_lobby",
"promo_campaign",
"home_default"
]
},
"target_id": {
"type": ["string", "null"],
"pattern": "^[A-Za-z0-9_-]{1,64}$"
},
"channel_code": {
"type": "string",
"pattern": "^[A-Za-z0-9_.-]{1,64}$"
},
"fallback_route": {
"const": "home_default"
}
},
"allOf": [
{
"if": {
"properties": {
"route_key": {
"enum": [
"product_detail",
"room_lobby",
"promo_campaign"
]
}
},
"required": ["route_key"]
},
"then": {
"properties": {
"target_id": {
"type": "string"
}
}
}
},
{
"if": {
"properties": {
"route_key": {
"const": "home_default"
}
},
"required": ["route_key"]
},
"then": {
"properties": {
"target_id": {
"type": "null"
}
}
}
}
],
"examples": [
{
"schema_version": "1.0.0",
"route_key": "product_detail",
"target_id": "sku_8842",
"channel_code": "pos_01",
"fallback_route": "home_default"
}
]
}
步骤 2:在落地页上捕获和转发路由上下文
在 H5 落地页上解析白名单路由键
当未安装应用程序的用户扫描二维码时,移动端浏览器将打开托管的中间 H5 落地页。落地页脚本提取外部查询键(route、id、channelCode),拒绝含糊不清的重复参数,并将其标准化为步骤 1 中定义的规范结构。
下方的 JavaScript 标准化器强制执行单键基数、正则边界和条件性的 home_default 降级,同时将重复的保留参数视为无效:
const query = new URLSearchParams(window.location.search);
const routeValues = query.getAll("route");
const idValues = query.getAll("id");
const channelValues = query.getAll("channelCode");
const allowedRoutes = new Set([
"product_detail",
"room_lobby",
"promo_campaign",
"home_default"
]);
const idPattern = /^[A-Za-z0-9_-]{1,64}$/;
const channelPattern = /^[A-Za-z0-9_.-]{1,64}$/;
function normalizeQrRoutingPayload() {
// 拒绝重复键以防御参数污染
if (routeValues.length > 1 || idValues.length > 1 || channelValues.length > 1) {
return {
schema_version: "1.0.0",
route_key: "home_default",
target_id: null,
fallback_route: "home_default"
};
}
const rawRoute = routeValues.length === 1 ? routeValues[0] : null;
const rawId = idValues.length === 1 ? idValues[0] : null;
const rawChannel = channelValues.length === 1 ? channelValues[0] : null;
let routeKey = allowedRoutes.has(rawRoute)
? rawRoute
: "home_default";
let targetId =
rawId && idPattern.test(rawId)
? rawId
: null;
if (routeKey === "home_default" || targetId === null) {
routeKey = "home_default";
targetId = null;
}
const payload = {
schema_version: "1.0.0",
route_key: routeKey,
target_id: targetId,
fallback_route: "home_default"
};
if (rawChannel && channelPattern.test(rawChannel)) {
payload.channel_code = rawChannel;
}
return payload;
}
const routingPayload = normalizeQrRoutingPayload();
通过版本验证的适配器集成 Web SDK
落地页既协调即时 App 唤醒尝试,也协调延迟参数暂存。在部署归因 Web SDK(例如 opoinstall.js)之前,请对照所部署 SDK 版本的官方文档,确认准确的 JavaScript 捆绑包 URL、初始化签名、就绪回调、唤醒方法和参数架构。
将特定于 SDK 的行为保留在接受标准化 routingPayload 对象的微型应用适配器后面。有关 Web 集成和渠道参数配置的详细规范,请查阅 OpoInstall 开发者文档。
在直接应用商店回退链接中保留路由参数
如果 Web JS SDK 不可用或被浏览器安全控制阻止,落地页将回退到直接应用商店重定向链接。在使用 Google Play 的 Android 部署中,回退链接可以将标准化的路由键编码到 Play 商店 referrer 参数中,以便原生客户端在通过官方 Play 商店流程安装时,能够通过 Google Play Install Referrer API 恢复路由上下文:
// 保留标准化路由上下文的 Android Play Store 回退 URL 示例
const referrerString = `route_key=${routingPayload.route_key}&target_id=${routingPayload.target_id || ""}&channel_code=${routingPayload.channel_code || ""}`;
const playStoreFallbackUrl = `https://play.google.com/store/apps/details?id=com.example.app&referrer=${encodeURIComponent(referrerString)}`;
步骤 3:实现原生客户端路由分发与场景还原
处理 Android Activity 启动模式:为什么 singleTask 要求同时实现 onCreate 和 onNewIntent
在 Android 深度链接架构中,Activity 接收路由意图的方式取决于其配置的启动模式和导航图设计。开发者可以根据任务栈的需求,将 Jetpack Navigation 与标准启动模式、singleTop 或 singleTask 配合使用。
当 Activity 配置为 singleTop 或 singleTask 以防止在深度链接唤醒期间重复实例堆叠时,必须处理两种不同的生命周期路径:
- 冷启动(
onCreate):当应用进程未运行时,扫描二维码会从头启动 Activity。传入的Intent将被传递到onCreate(savedInstanceState: Bundle?)。 - 热启动(
onNewIntent):当操作系统在配置的启动模式和任务栈条件下复用现有的 Activity 实例时,传入的深度链接 Intent 将被传递到onNewIntent()。在singleTop下,仅当目标 Activity 已位于相关任务栈顶部时才会发生复用;否则,可能会创建新的 Activity 实例。请实现onCreate()和onNewIntent()的处理逻辑,并验证应用程序导航架构的实际行为。
为了可靠地处理这两种状态,而不会永久阻止在会话后期合法再次扫描同一二维码的用户,Activity 应当使用消费标志(EXTRA_ROUTE_CONSUMED)标记每个处理过的 Intent 实例,并忽略仅由历史应用切换器启动(Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY)重新传递的意图。
处理 iOS 生命周期入口点:AppDelegate 与 UISceneDelegate 延续活动
在 iOS 上,直接 Universal Link 路由和延迟参数检索必须与应用程序配置的 UI 生命周期保持一致:
- 基于场景的生命周期(
UISceneDelegate):在采用UIScene的应用程序中(自 iOS 13 起为标准),冷启动 Universal Links 通过connectionOptions.userActivities到达scene(_:willConnectTo:options:),而热恢复 Universal Links 则到达scene(_:continue:)。 - 应用程序委托生命周期(
UIApplicationDelegate): 在不使用场景委托的应用程序中,Universal Links 会被传递给application(_:continue:restorationHandler:)。 - 延迟首次启动恢复:独立于 Universal Link 延续委托,延迟安装参数查询在根视图层级准备就绪后的初始应用程序启动期间执行一次。
开发者可以下载平台 SDK 软件包并通过 OpoInstall SDK 下载中心检查生命周期集成模板。
将直接唤醒参数和首次启动延迟参数统一到单个导航路由器中
为了保持一致的安全性和导航行为,客户端架构在将直接唤醒 URI 和延迟安装 JSON 有效载荷分派给 UI 路由器之前,会将其标准化为单个 NavigationTarget 模型:
- 来源验证(直接意图):验证传入的
Intent.ACTION_VIEWURI 是否使用https方案,是否匹配应用程序经过验证的关联主机(promo.example.com)和路径(/open),并且不包含冲突的重复查询键。 - 架构标准化与白名单检查:将外部
route/id键或内部route_key/target_id字段映射到规范模型中,拒绝未列入白名单的路由或格式错误的 ID。请注意,客户端 Kotlin 解析逻辑作为说明性的防御性解析骨架运行;在生产系统中,请在分派前对照完整的规范架构规则解析有效载荷。 - 单次飞行重入保护:使用原子的每次分派保护,以便并发回调不会触发重叠的 fragment 事务。
管理身份验证关卡:将待处理的深度链接路由排队,直至用户登录完成

当新用户通过二维码从应用商店安装应用程序并首次启动时,该用户通常处于未认证状态。如果二维码指向受保护的屏幕(例如 room_lobby 或 promo_campaign)且路由器尝试立即导航,身份验证关卡将把用户重定向到登录屏幕并丢失深度链接上下文。
为了在身份验证边界之间保持场景还原:
- 内部路由策略查找:通过检查客户端内部的
PROTECTED_ROUTES注册表来确定路由是否需要身份验证,而不是盲目信任外部有效载荷标志。 - 原子暂存:如果用户未认证,则将经过验证的
NavigationTarget存储在原子的、会话范围的PendingRouteStore(AtomicReference)中,并将用户引导至登录流程。请注意,内存中的PendingRouteStore适用于活跃的应用程序进程;如果身份验证流程可能跨越进程终止,请使用加密的、有时间限制的本地存储,以便在账号切换时清除状态。 - 认证后重新验证与分派:登录完成后,原子化消费暂存的目标(
getAndSet(null)),验证会话现在是否已认证,重新验证路由参数,并执行屏幕过渡。
导航宿主就绪度与状态还原
onNavigationGraphReady() 回调必须连接到应用程序实际的导航宿主初始化。当导航图在基于 XML 托管的 NavHostFragment 中定义时,Activity 可以在宿主及其目标准备就绪后建立此连接。如果导航图是异步安装的,请仅在导航图初始化完成后调用回调。
仅存储在 Activity 字段中的待处理导航请求可能会在配置更改期间丢失。对于生产实现,请在生命周期感知的 ViewModel 中保留未消费的路由状态,并在需要跨系统驱动的 Activity 重建或进程终止进行还原时使用 SavedStateHandle。此处显示的代码是一个架构示例,需要应用特定的导航和会话集成。
下方的 Kotlin 代码提供了说明性的 Android 路由骨架,其中包含展示这些控制的应用自有 SDK 和导航适配器:
<!-- 用于 App 链接验证的 Android Manifest 配置 -->
<!-- 文件: app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.app">
<application
android:allowBackup="true"
android:label="@string/app_name"
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
<activity
android:name=".SceneRestorationActivity"
android:exported="true"
android:launchMode="singleTask">
<!-- 经过验证的 Android App 链接意图过滤器 -->
<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" />
<!-- 来源必须与 /.well-known/assetlinks.json SHA-256 指纹匹配 -->
<data
android:scheme="https"
android:host="promo.example.com"
android:pathPrefix="/open" />
</intent-filter>
</activity>
</application>
</manifest>
// Android 路由 Activity 实现
// 文件: app/src/main/java/com/example/app/SceneRestorationActivity.kt
//
// 集成说明:
// 本示例是一个架构路由骨架,而非开箱即用的生产实现。
// 请在部署前将 AttributionRoutingAdapter 和 NavController 初始化绑定到您已验证的 SDK 版本、
// 会话管理器以及 NavHostFragment。
package com.example.app
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.fragment.NavHostFragment
import org.json.JSONObject
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicReference
/**
* 表示加入白名单的应用内目的地的已验证导航目标模型。
* 身份验证要求严格从内部路由注册表中推导得出。
*/
data class NavigationTarget(
val eventKey: String,
val routeKey: String,
val targetId: String?,
val requiresAuth: Boolean
)
/**
* 当未认证用户在冷启动或首次启动引导期间触发受保护的深度链接时,
* 持有待处理路由的进程范围原子存储。
*/
object PendingRouteStore {
private val pending = AtomicReference<NavigationTarget?>(null)
fun stage(target: NavigationTarget) {
pending.set(target)
}
fun consume(): NavigationTarget? {
return pending.getAndSet(null)
}
}
class SceneRestorationActivity : AppCompatActivity() {
companion object {
private const val TAG = "SceneRestorationRouter"
private const val TRUSTED_HOST = "promo.example.com"
private const val TRUSTED_PATH = "/open"
private const val EXTRA_ROUTE_CONSUMED = "com.example.app.EXTRA_ROUTE_CONSUMED"
private const val ROUTE_HOME = "home_default"
private const val ROUTE_PRODUCT = "product_detail"
private const val ROUTE_LOBBY = "room_lobby"
private const val ROUTE_PROMO = "promo_campaign"
private val ALLOWED_ROUTES = setOf(ROUTE_HOME, ROUTE_PRODUCT, ROUTE_LOBBY, ROUTE_PROMO)
private val PROTECTED_ROUTES = setOf(ROUTE_LOBBY, ROUTE_PROMO)
private val SAFE_ID_REGEX = Regex("^[A-Za-z0-9_-]{1,64}$")
// 确保每次冷启动仅查询一次延迟安装恢复的进程级守卫
private val hasQueriedDeferredInstall = AtomicBoolean(false)
}
// 每次分派的重入守卫;允许合法后续扫描同一二维码
private val processingIntent = AtomicBoolean(false)
// 在 UI/NavController 挂载之前到达的路由的暂存容器
private val pendingUiTarget = AtomicReference<NavigationTarget?>(null)
private var navigationReady = false
// 隔离第三方 SDK 和会话调用的应用自有适配器
private val attributionAdapter = AttributionRoutingAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_scene_restoration)
// 在布局中定义时连接 NavHost 初始化
connectNavigationHost()
// Activity 重建(例如屏幕旋转)不是新安装或新二维码扫描
if (savedInstanceState != null) {
return
}
val launchIntent = intent
val isDirectLinkLaunch =
launchIntent?.action == Intent.ACTION_VIEW &&
launchIntent.data != null
if (isDirectLinkLaunch) {
processIncomingIntentOnce(launchIntent) { candidate ->
handleIncomingWakeupIntent(candidate)
}
// 直接链接启动后不要启动延迟恢复,
// 即使直接路由被拒绝亦是如此。
return
}
// 仅初始的常规应用启动可以请求延迟恢复
if (hasQueriedDeferredInstall.compareAndSet(false, true)) {
queryDeferredSceneParameters()
}
}
/**
* 将此方法连接到实际的 NavHost 初始化。
* 使用 currentDestination 检查和目的地监听器安全验证图表就绪度,
* 而不会在未设置的 controller.graph 上抛出 IllegalStateException。
*/
private fun connectNavigationHost() {
val navHost = supportFragmentManager
.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment
if (navHost == null) {
Log.w(TAG, "NavHost 未附加;路由保持排队状态。")
// 对于异步附加的 NavHost,请在宿主
// 附加后再次调用此方法。
return
}
val controller = navHost.navController
// 在建立目的地之前,currentDestination 是可为空的。
// 切勿仅为了检查是否已调用 setGraph() 而访问 controller.graph。
if (controller.currentDestination != null) {
onNavigationGraphReady()
return
}
// 如果图表/目的地稍后可用,
// 请在建立目的地时标记导航就绪。
val listener = object : androidx.navigation.NavController.OnDestinationChangedListener {
override fun onDestinationChanged(
navController: androidx.navigation.NavController,
destination: androidx.navigation.NavDestination,
arguments: Bundle?
) {
navController.removeOnDestinationChangedListener(this)
onNavigationGraphReady()
}
}
controller.addOnDestinationChangedListener(listener)
}
/**
* 当 Activity 在 singleTop 或 singleTask 下被复用时,捕获热启动二维码扫描。
*/
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
processIncomingIntentOnce(intent) { candidate ->
handleIncomingWakeupIntent(candidate)
}
}
private fun processIncomingIntentOnce(incomingIntent: Intent?, process: (Intent) -> Unit) {
val candidate = incomingIntent ?: return
// 忽略从最近应用切换器或配置更改重新传递的历史意图
if ((candidate.flags and Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) != 0) return
if (candidate.getBooleanExtra(EXTRA_ROUTE_CONSUMED, false)) return
if (!processingIntent.compareAndSet(false, true)) return
try {
process(candidate)
candidate.putExtra(EXTRA_ROUTE_CONSUMED, true)
} finally {
processingIntent.set(false)
}
}
private fun handleIncomingWakeupIntent(incomingIntent: Intent?): Boolean {
if (incomingIntent?.action != Intent.ACTION_VIEW) return false
val uri: Uri = incomingIntent.data ?: return false
// 1. 验证受信任的 HTTPS 来源和路径
val isTrusted =
uri.scheme.equals("https", ignoreCase = true) &&
uri.host.equals(TRUSTED_HOST, ignoreCase = true) &&
uri.path == TRUSTED_PATH
if (!isTrusted) {
Log.w(TAG, "拒绝不受信任的深度链接来源: $uri")
return false
}
// 2. 拒绝缺少或冲突的重复查询参数
if (uri.getQueryParameters("route").size != 1 ||
uri.getQueryParameters("id").size != 1) {
Log.w(TAG, "拒绝 URI 中缺少或重复的路由键")
return false
}
val rawRoute = uri.getQueryParameter("route")
val rawId = uri.getQueryParameter("id")
val target = validateAndBuildTarget("wakeup", rawRoute, rawId)
if (target.routeKey != ROUTE_HOME) {
dispatchNavigationTarget(target)
return true
}
return false
}
private fun queryDeferredSceneParameters() {
// 应用定义的超时配置;根据部署的服务行为进行调整
attributionAdapter.fetchDeferredInstallParams(timeoutMs = 5000L) { jsonPayload ->
if (jsonPayload == null) {
Log.d(TAG, "未恢复延迟路由参数;停留在默认视图上。")
return@fetchDeferredInstallParams
}
val target = parseJsonRoutingPayload("deferred_install", jsonPayload)
runOnUiThread {
dispatchNavigationTarget(target)
}
}
}
private fun parseJsonRoutingPayload(sourcePrefix: String, rawJson: String): NavigationTarget {
return try {
val obj = JSONObject(rawJson)
// 防御性标准化:使用外部 'route'/'id' 回退映射规范的 'route_key'/'target_id'
val rawRoute = obj.optString("route_key", "").ifEmpty { obj.optString("route", "") }
val rawId = if (obj.has("target_id") && !obj.isNull("target_id")) {
obj.optString("target_id", "")
} else {
obj.optString("id", "")
}
validateAndBuildTarget(sourcePrefix, rawRoute, rawId)
} catch (e: Exception) {
Log.w(TAG, "畸形的路由 JSON 有效载荷: ${e.message}")
NavigationTarget(sourcePrefix, ROUTE_HOME, null, false)
}
}
/**
* 对不受信任的路由参数强制执行严格的白名单和正则边界。
*/
private fun validateAndBuildTarget(
sourcePrefix: String,
rawRoute: String?,
rawId: String?
): NavigationTarget {
val cleanRoute = if (rawRoute != null && ALLOWED_ROUTES.contains(rawRoute)) {
rawRoute
} else {
ROUTE_HOME
}
if (cleanRoute == ROUTE_HOME) {
return NavigationTarget(sourcePrefix, ROUTE_HOME, null, false)
}
// 实体特定路由严格要求有效且经过净化的 targetId
val cleanId = if (rawId != null && SAFE_ID_REGEX.matches(rawId)) {
rawId
} else {
null
}
if (cleanId == null) {
Log.w(TAG, "由于缺少或无效的 target_id,路由 $cleanRoute 被拒绝。")
return NavigationTarget(sourcePrefix, ROUTE_HOME, null, false)
}
val needsAuth = PROTECTED_ROUTES.contains(cleanRoute)
return NavigationTarget("${sourcePrefix}_${cleanRoute}_${cleanId}", cleanRoute, cleanId, needsAuth)
}
/**
* 当应用程序的 NavHost / NavController 图表附加且就绪时调用。
*/
fun onNavigationGraphReady() {
navigationReady = true
flushPendingNavigation()
}
private fun flushPendingNavigation() {
if (!navigationReady || isFinishing || isDestroyed) return
val target = pendingUiTarget.getAndSet(null) ?: return
dispatchReadyNavigationTarget(target)
}
private fun dispatchNavigationTarget(target: NavigationTarget) {
if (isFinishing || isDestroyed) return
// 如果 UI 导航图尚未完成扩充,则暂存路由
if (!navigationReady) {
pendingUiTarget.set(target)
return
}
dispatchReadyNavigationTarget(target)
}
private fun dispatchReadyNavigationTarget(target: NavigationTarget) {
if (target.routeKey == ROUTE_HOME) {
navigateToHomeScreen()
return
}
// 检查受保护目的地的身份验证关卡
if (target.requiresAuth && !attributionAdapter.isUserAuthenticated()) {
Log.d(TAG, "受保护的路由 ${target.routeKey} 已暂存,等待用户身份验证。")
PendingRouteStore.stage(target)
navigateToLoginScreen()
return
}
executeScreenTransition(target.routeKey, target.targetId)
}
/**
* 在登录或注册完成后由身份验证控制器调用。
*/
fun onUserAuthenticationCompleted() {
val pending = PendingRouteStore.consume()
if (pending == null) {
navigateToHomeScreen()
return
}
if (isFinishing || isDestroyed) return
if (!attributionAdapter.isUserAuthenticated()) {
PendingRouteStore.stage(pending)
navigateToLoginScreen()
return
}
val validated = validateAndBuildTarget(
pending.eventKey,
pending.routeKey,
pending.targetId
)
if (validated.routeKey == ROUTE_HOME) {
navigateToHomeScreen()
return
}
executeScreenTransition(validated.routeKey, validated.targetId)
}
private fun executeScreenTransition(routeKey: String, targetId: String?) {
// 实现:执行后端授权的资源获取并调用 NavController.navigate()
Log.d(TAG, "分派已验证的屏幕过渡: route=$routeKey, id=$targetId")
}
private fun navigateToLoginScreen() {
// 实现:过渡到 LoginFragment / LoginActivity
}
private fun navigateToHomeScreen() {
// 实现:显示默认主页
}
}
/**
* 应用自有的适配器骨架。请将存根方法替换为已验证的 OpoInstall SDK 回调、
* Google Play Install Referrer 查询以及您的生产会话管理器。
*/
class AttributionRoutingAdapter {
fun fetchDeferredInstallParams(timeoutMs: Long, callback: (String?) -> Unit) {
// 在生产环境中绑定到已验证的 SDK 安装参数 API 或 Play Install Referrer
callback(null)
}
fun isUserAuthenticated(): Boolean {
// 绑定到生产身份验证会话状态
return false
}
}
安全与诊断:防御路由注入与导航丢弃
根据 OWASP 指南防御深度链接路由注入和开放重定向漏洞
从二维码或延迟归因回调中提取的参数代表不受信任的外部输入。根据 OWASP 移动应用安全验证标准和 OWASP 移动端十大安全风险与指南,客户端路由器必须强制执行三个防御边界:
- 禁止任意 WebView URL 加载:切勿允许路由参数(例如
?route=webview&url=https://...)在应用内的 WebView 中加载未经验证的外部 URL。攻击者可以将网络钓鱼 URL 编码到二维码中,利用应用程序的原生 UI 框架来搜集凭据。 - 拒绝重复参数污染:如果被操纵的 URL 包含多个冲突的键(例如
?route=home_default&route=room_lobby),应当拒绝该 URI,而不是让解析器挑选任意的第一个或最后一个元素。 - 强制执行后端资源授权:传递
target_id=sku_8842仅指示客户端请求哪个视图。当屏幕查询后端 API 时,服务器必须独立验证当前用户会话是否有权访问该实体,从而防止不安全的直接对象引用 (IDOR) 漏洞。
诊断冷启动与热启动导航竞态条件
在冷启动期间,深度链接意图或缓存的 SDK 回调可能会在主 NavHostFragment 或根导航图完成扩充之前解析。在视图层级准备就绪之前尝试调用 navController.navigate() 会导致运行时异常或丢弃过渡。务必将导航执行延迟到宿主视图层级附加且处于活动状态(!isFinishing && !isDestroyed)时进行。
优雅处理过期促销、已删除的 SKU 和缺失的参数
数字促销结束后或产品 SKU 退役后,实体二维码通常仍部署在零售店或印刷媒体中。当目标屏幕获取 target_id 的数据并从后端收到 HTTP 404 Not Found 或 410 Gone 响应时,客户端必须捕获该错误,向用户显示清晰的信息提示,并干净利落地导航至 home_default。
双状态 QR 路由的部署前验证清单
在发布实体二维码物料之前,工程团队应在物理设备上执行以下七点验证协议:
- 步骤 1:已安装的冷启动唤醒:强制关闭已安装的应用并扫描二维码。确认操作系统直接将应用打开到目标屏幕(
product_detail)。 - 步骤 2:已安装的热启动唤醒与重复扫描:在应用打开到第二个标签页的情况下,将应用置于后台并扫描二维码。确认
onNewIntent能够路由到目标屏幕。导航离开,第二次扫描完全相同的二维码,并确认它会再次导航,而不是被过于宽泛的去重逻辑阻止。 - 步骤 3:未安装的延迟恢复(公共路由):卸载应用,扫描二维码,完成应用商店安装并启动。确认延迟参数有效载荷已被标准化并路由至
product_detail。 - Step 4:带有身份验证关卡的未安装延迟恢复(受保护路由):卸载应用,扫描目标为
room_lobby的二维码,在登出状态下安装并启动。确认应用首先路由到登录页面,并在完成登录后消费PendingRouteStore并导航至room_lobby。 - 步骤 5:不受信任的来源与重复键拒绝:从不受信任的主机或带有重复查询参数(
?route=product_detail&route=room_lobby)触发ACTION_VIEW意图。确认handleIncomingWakeupIntent会拒绝该意图并记录警告。 - 步骤 6:屏幕旋转幂等性:在到达深度链接屏幕后,旋转设备以触发 Activity 重建。确认
EXTRA_ROUTE_CONSUMED标志可防止原始意图重新执行。 - 步骤 7:离线 / 不匹配优雅回退:在飞行模式下启动全新安装。确认检索超时能够在不阻塞 UI 线程的情况下解决,并将用户引导至
home_default。
应用内路由机制的比较分析
评估 URL 方案、Universal Links 与 App Links 以及延迟场景还原
移动平台提供了不同的机制,用于在不同的安装状态下将用户引导至应用程序视图中:
| 路由机制 | 目标 App 状态 | OS 验证要求 | 应用商店安装过程中的参数存活情况 | 主要的工程约束 |
|---|---|---|---|---|
| 自定义 URL 方案 | 仅限已安装 | 无(仅清单声明) | 否(如果未安装应用则失败) | 如果应用不存在会触发浏览器错误弹窗;容易遭受方案劫持 |
| Universal Links / App Links | 已安装优先 | iOS:AASA 文件 + Associated Domains 权限;Android:assetlinks.json + 匹配的应用签名证书和经过验证的意图过滤器配置 |
否(新安装需要延迟层) | 可能会被某些受限制的应用内 webview 容器抑制 |
| Google Play Install Referrer | 新的 Android 安装 | 官方 Google Play 商店分发路径 | 是(支持的引荐方字符串) | 特定于 Android Play 商店;不覆盖 iOS 或非 Play 商店 |
| SDK 辅助的场景还原 | 已安装和新安装 | 域名关联 + 经过验证的 SDK 适配器 | 取决于支持的平台机制 | 需要路由白名单验证和平台隐私审查 |
跨安装状态和操作系统的架构权衡
自定义 URL 方案(myapp://product/123)缺乏密码学域名所有权验证,这意味着任何应用程序都可以注册相同的方案并拦截链接。此外,在未安装应用时扫描原始自定义方案二维码会导致无法恢复的浏览器错误。
Universal Links 和 Android App Links 通过 HTTPS 验证域名所有权,并在应用不存在时回退到网页落地页,这使它们成为二维码路由的必需基础。将用于已安装唤醒的 Universal Links 与 App Links 同平台支持的延迟恢复(例如 Android 上的 Google Play Install Referrer 和经过隐私审查的 SDK 适配器)结合使用,能够从单个二维码资产实现双状态场景还原。
常见问题解答 (FAQ)
如果尚未安装 App,自定义二维码参数如何打开特定的 App 内页面?
为什么扫描我的二维码会将 App 打开到主页而不是目标页面?
如何防止恶意二维码将用户引导至未授权的内部屏幕?
总结与决策框架
为了通过自定义参数创建用于 App 路由的二维码,工程团队必须将紧凑的外部 URL 查询字符串与具备状态感知的原生导航控制器连接起来。一个完整的implementation能够处理现有用户的直接操作系统唤醒以及新应用商店安装的条件性延迟参数恢复。
由于应用商店之间的参数连续性取决于平台特定的机制(例如 Android 上的 Google Play Install Referrer 以及 iOS 上经过验证且合规的工作流),因此应用程序必须将恢复的路由有效载荷视为不受信任的候选对象。通过结合使用白名单路由分派器、身份验证状态排队和优雅的主页回退,团队能够在不妥协应用程序稳定性或平台隐私规则的前提下提供可靠的场景还原。
Openinstall 的 SDK 和开发者文档提供了用于实现 web-to-app 路由和参数传递的组件。在生产环境推出之前,请确认您部署的 SDK 版本所支持的具体 API 签名、回调架构和平台行为。
有关集成详情,请查阅 Openinstall 开发者文档、访问 Openinstall SDK 下载中心,或在 Openinstall 开发者控制台中配置您的应用路由域名。
相关材料
-
概念:场景还原、双状态深度链接、路由白名单、延迟参数恢复
-
技术:Universal Links、Android App Links、Google Play Install Referrer、原生导航控制器
-
标准:IETF RFC 3986 统一资源标识符 (URI) 通用语法、OWASP 移动应用安全验证标准、OWASP 移动端十大安全风险与指南
-
集成接口:应用自有的深度链接路由验证器、针对所部署 SDK 版本的记录在案的唤醒和安装参数回调,以及平台原生的导航控制器。
-
官方文档与参考资料:
Share this article
Keep Discovering

如何通过保护自定义二维码来防范线下重定向欺诈
了解如何通过 HTTPS、HMAC 签名、物理防篡改检查以及基于风险的欺诈检测来保护自定义二维码,防止扫描劫持。

GitHub 发布 Copilot Fusion?为什么多模型代码协同至关重要
GitHub 推出了 Copilot Fusion 运行时模型编排器。探索动态模型路由和执行模式如何降低 Token 成本并减少编码延迟。

Google 发布 Connected Apps?应用链接将如何演变
Google 针对 Gemini 和 Search 推出 Connected Apps 平台。了解 MCP 协议与 AppFunctions 将如何重塑应用链接、路由及归因逻辑。
