从 Expo Router 到 Detour:用正确的方式实现延迟深度链接
标准深度链接在商店边界就会失效。看看 Software Mansion 的 Detour 如何在 Expo Router 内部弥合这一安装断层。
中文
复制

本文是 Bartek Krasoń 的客座文章——他是 Software Mansion 的软件工程师,目前正在开发 Detour。
…
如果你在用 Expo 开发移动应用,很可能正在用 Expo Router 来让导航逻辑保持可预测、可扩展。
你大概也已经配好了标准深度链接(iOS 用 Universal Links,Android 用 App Links),对老用户来说一切正常。可要是新用户点开一个私密邀请或某个具体商品的链接,而手机上还没装你的应用,会发生什么?
问题出在哪(以及为什么)
标准深度链接(iOS 的 Universal Links、Android 的 App Links)在应用已安装时表现很好。链接打开应用,Expo Router 把用户送到对应页面。就这么简单。
但新用户一旦经过 App Store 或 Google Play,URL 上下文就丢了。iOS 和 Android 都不会让链接数据跨越应用商店这道边界,于是应用启动时对当初促成这次安装的意图一无所知。
这种摩擦就是所谓的「安装断层」(Installation Gap)。怎么解决?延迟深度链接(Deferred deep linking)。 思路是:捕获用户最初的意图——他点的那条 URL、路径和参数,把它在安装过程中保留下来,等应用第一次启动时再重放一遍。
现有的方案
有些平台多年前就解决了这个问题,但它们首先是营销归因工具——深度链接对它们来说只是顺带的附属功能。它们只在回调里给你一个原始 URL 字符串,怎么接到导航上是你自己的事。
在 Expo Router 应用里,这意味着你得写一堆脆弱的胶水代码,去处理生命周期时序、重复的导航事件,以及与登录拦截之间的竞态。SDK 和你的路由活在两个完全不同的世界里,你只能去搭那些本来就不该存在的管道。
Detour 的做法
我们在 Software Mansion 做了 Detour,就是想从导航这一侧解决这个问题。团队从 2017 年起就在为 Expo 生态做贡献(Reanimated、Gesture Handler,以及 EAS 的部分工作),我们想要的是一个能和路由融为一体、而不是绕在它外面的延迟深度链接工具。
核心思路:Detour 之所以能在首屏渲染之前解析链接,靠的是 Expo Router 自己的扩展点。路由器从一开始就知道这个 intent,因此不存在挂载后 useEffect 的竞态,也无需手动解析 URL。
实际用起来是这样的。
在首次渲染前拦截链接
Expo Router 暴露了一个 +native-intent.tsx 文件,让你能在路由启动之前拦截并转换传入的 URL。Detour 直接挂进这里:
// app/+native-intent.tsx
import { createDetourNativeIntentHandler } from "@swmansion/react-native-detour/expo-router";
export const redirectSystemPath = createDetourNativeIntentHandler({
fallbackPath: "/",
hosts: [/\.godetour\.link$/i],
});
这相当于路由前的中间件。应用启动时,Detour 判断这是不是一条延迟深度链接,解析出原始 URL,并在任何屏幕挂载之前把它交给路由器。
处理登录拦截
不过有个常见的坑:登录拦截。用户点开邀请链接,安装应用,然后打开它。Detour 解析出了原始 URL。但用户还没登录,于是你的 auth guard 把他重定向到 /login,解析出来的链接就这么被吞掉了。
DetourProvider 的解决办法是把链接 intent 留在内存里,直到应用发出用户已就绪的信号:
// app/_layout.tsx
import { DetourProvider, useDetourContext } from "@swmansion/react-native-detour";
export default function RootLayout() {
return (
<DetourProvider config={{ appID: "YOUR_APP_ID", apiKey: "YOUR_API_KEY" }}>
<AuthProtectedStack />
</DetourProvider>
);
}
function AuthProtectedStack() {
const { link, clearLink, isLinkProcessed } = useDetourContext();
const { isSignedIn } = useAuth(); // Your logic
const router = useRouter();
useEffect(() => {
if (isLinkProcessed && link && isSignedIn) {
clearLink(); // Ensure the link only fires once
router.replace(link.route);
}
}, [link, isLinkProcessed, isSignedIn]);
return <Stack />;
}
链接会一直留在内存中,直到 isSignedIn 变为 true。用户登录之后(或者本来就已经登录),导航只触发一次并自行清除,因此不会有重复触发,也不会和 auth 重定向抢跑。
匹配机制是怎么运作的
延迟深度链接要回答一个问题:“这是不是 n 秒前在浏览器里点了那条链接的同一个人?”——答案取决于平台。
Android:确定性匹配。 Detour 通过 Install Referrer API 把一个唯一的 click_id 传给 Google Play Store。应用首次启动时,SDK 取回那个一模一样的 click_id。这是 1:1 匹配,准确率 100%,因为 Android 在推荐点击和首次应用启动之间提供了一条直接的数据通道。
iOS:概率性匹配。 Apple 把 App Store 当作隐私边界,没有 Install Referrer API 的对应物。Detour 改为在浏览器点击时采集一组非身份标识信号的快照,再与应用打开时采集的第二组快照进行匹配。
为什么这在路由层很关键
Detour 通过 +native-intent.tsx 处理链接,避免了单个链接触发多次导航、或与初始 URL 冲突这类常见故障。
用典型的第三方 SDK,进来的链接会落在一个独立的监听器里,与导航结构各自为政。浏览器点击和应用内部状态之间的交接只能由你自己处理——要应付鉴权拦截冲突、深层嵌套问题,还有重复触发。SDK 和路由器活在两个世界。
用户旅程从 Web 进入 App 的那一刻,正好发生在路由器这一层,所以 Expo Router 是拼图里重要的一块。我们的目标是随着平台演进让 Detour 保持同步,让这次交接成为 UX 中有意设计的一环,而不是技术上的副作用。
接下来的路
Detour 1.0 打好了核心基础,包括自定义域名,以及面向 React Native、Flutter 和原生平台的 SDK。现在看下一步。接下来我们想扩展这个平台,覆盖用户旅程中更多的环节,同时保持开发者优先、价格可负担。
我们正在积极开发 Detour,也很想听听 Expo 社区的反馈。遇到问题或有功能需求,欢迎到 GitHub 提 issue,或者来我们的 Discord 找我们!