深链路由分发复盘:多入口汇入一个中枢、「待执行」单槽,以及分享短链的双层设计
项目 social-app
1. 背景与问题
这是一款面向日本市场的语音社交 App。用户从站外或端内到达指定页的路径很多:自定义 scheme 回跳、通用链接(Universal Link)、点击推送、会话里点一条链接、扫码、运营 Banner 内跳,以及页面里通过网页桥接发起的业务跳转。这些入口最终要落到房间、群会话、动态详情、话题、资料或活动页。
路由要同时解决三件事:
- 入口虽多,分发只能有一套。 每种入口各自解析、各自跳转,协议一改就要改好几处,也难以统一处理「现在还不能跳」的情况。
- 链接到达的时机,往往还不具备跳转条件。 冷启动时用户可能尚未登录,或还没进到首页;也可能正在匹配聊天、网络暂时不可达。解析成功不等于立刻可以
push页面。 - 站外分享既要先给一个可预览的落地页,又要把已安装的用户带回指定页。 动态和话题分享到社交网络时,需要标题、描述和配图;进群、招募卡这类链接,还常常要在进群之后继续打开一个指定页。
2. 整体设计
2.1 多入口汇入一个中枢
系统入口、推送点击、端内点击和扫码,都会把一条地址交给同一个解析与分发流程。Banner 内跳和网页桥接则有一部分直接走业务跳转,不经过短链解析。
推送点击在进入中枢之前会先分流:带会话信息的,记下来,等首页出现后再进对应会话;其余把载荷当作短链来解析。首页首次出现时的优先级是:先处理推送进会话,再执行待执行的短链动作,都没有才尝试恢复异常退出的房间。网页桥接发起的去大厅匹配、创建招募卡、创建群等,不走短链解析。
运营位跳到话题,被抽进路由中枢。 内跳只处理话题这一种路径,和短链那一套类型分发是分开的。
2.2 类型如何组织
链接的「真身」是一条带路径和查询参数的长链,交给动态链接服务缩短。生成时会带上 iOS 与 Android 的应用标识,以及分享用的预览标题、描述和配图。路径用类型枚举的标识来区分;解析时看 URL 最后一段路径是否包含某个标识,不是精确匹配,也不是一张可插拔的路由表。
类型目前有 23 种,按业务可以分成几组:
| 分组 | 典型动作 |
|---|---|
| 关系 / 资料 | 打开用户资料、形象、状态墙、换装邀请 |
| 房间 / 活动 | 进入语音房(可带有效期)、打开活动页 |
| 群 | 加入群聊,并可在进群后再打开指定页 |
| 动态社区 | 动态详情、话题、消息盒子、切换首页 Tab |
| 增长与活动 | 邀请新用户、试用、活动页、通用的页面分享 |
| 其它 | 明确约定不跳转的占位类型 |
同一套枚举要同时服务生成、解析和执行,因此对应着几处大型分支:新增一种类型,至少要改枚举、路径标识、生成、解析和执行。查询参数用短键表示用户、房间、群、话题、动态、目标页地址、内层跳转等;群标识和房间邀请时限在参数里用了相近的键,要靠路径类型来区分。预览用的标题、描述和配图不是业务跳转参数,而是生成短链时写入动态链接服务的社交元数据。
2.3 待执行是单槽,不是队列
解析成功后不会立刻跳转,而是写入一个待执行槽位,然后立即尝试执行。槽位只有一格:后一次解析会覆盖前一次。带内层跳转、需要二次解析的那条路径,在匹配前会先把槽位清空,避免用外层的半成品去执行。
执行前要过一组门闸,任一扇没过就保留槽位,等下一次时机再试:
- 正在匹配聊天 → 不做。
- 未登录 → 只有邀请类(拉新、换装、状态墙等)会提前写入本地,供注册后使用;其它类型不执行,也不清空槽位。
- 已登录但还没进入首页 → 不执行。
- 网络不可达 → 不执行;网络恢复时再试一次。
- 门闸都通过后,先清空槽位再执行,保证同一个动作只进入一次。
重新尝试的时机包括:热启动或已在前台时解析完成后立刻试一次;回到前台时,若已登录且不在「新好友」流程中,再试一次;冷启动进入首页时,按上面的优先级执行;登录成功时,若带着待执行动作,新用户会先走广场、跳过部分自动进房匹配,真正跳转仍等到进入首页之后;网络重连时,若槽位还在,再执行一次。
3. 关键实现
3.1 解析、二次解析,以及降级
解析的输入可能是通用链接、自定义 scheme 回跳,或端内点到的一条短链。端内点击时,如果地址缺少协议头,会先补上网页协议再解析。识别不出类型,或必选参数缺失,就降级用内置网页打开;有一种类型明确约定「保持不动」,解析后不执行跳转。
站外的动态和话题链接是双层的,解析时如果发现地址里带了内层跳转、且不属于「本身也带跳转地址」的招募卡链接,就要取出内层短链做二次解析。招募卡自己也带跳转字段,不能再拆一次,否则会把外层拆错。
3.2 双层短链:先预览,再回 App
动态和话题的站外分享做成双层短链:内层打开 App 并跳到详情,外层是不带打开 App 参数的预览页,内层地址写在跳转字段里;外层链接粘到会话里也能走二次解析。
这样做的原因很直接:如果生成时就带上打开 App 的参数,已安装的用户往往会直接进 App,站外预览页来不及展示标题、描述和配图;把外层的应用参数置空,用户会先落到预览页,再从预览页打开 App,或把同一条外层链接粘到会话里点击。两种路径汇合到同一次二次解析,详情页的分发逻辑只需维护一套。
3.3 门闸与执行(示意,非原始代码)
// 示意,非原始代码
func handleIncomingLink(_ url: Link) {
let resolved = needsSecondParse(url) ? unwrapInnerLink(url) : url
guard let action = parse(resolved) else {
openInWebView(resolved) // 未知类型或缺参
return
}
pending = action // 单槽:后一次覆盖前一次
tryPerformPending()
}
func needsSecondParse(_ url: Link) -> Bool {
url.hasInnerRedirect && !url.carriesOwnRedirect
}
func tryPerformPending() {
guard let action = pending else { return }
if isMatching { return } // 槽位保留
if !isLoggedIn {
if action.isPreLogin { rememberForSignup(action) }
return // 其它类型保留槽位
}
if !hasEnteredHome { return }
if !networkReachable { return }
pending = nil // 先清空,只执行一次
dispatch(action)
}
「先清空再执行」是为了防止执行过程中再次触发尝试,把同一个动作跑两遍。代价是:如果执行到一半失败,槽位里已经没有这份动作,不会自动再来一次,要靠具体跳转自己处理失败。
3.4 进群后再打开指定页
群分享、招募卡可以在进群之后继续打开一个页面。执行顺序是:调用进群接口 → 用服务端返回的群标识打开会话(已在该群则提示「你已在该群中」,但仍视为成功)→ 延迟约 0.3 秒 → 优先打开链接自带的目标页;没有则按类型在本地拼出招募卡编辑页或指定活动页。
进群协议补完整:通用的目标页参数,进群后优先打开该页;预览文案可配置;进群后用服务端返回的群标识打开会话;分享者是自己则不关注;已经在群里也要打开目标页。
进群被拒绝且原因是资料未完成时,会弹出引导,让用户去完善资料。
「已在该群也视为成功」这一点很关键。如果只在「刚刚加入」时打开目标页,已经在群里的用户点开同一条招募卡链接,就只会看到一句「你已在该群中」,活动页出不来。把打开目标页放到进群跳转的成功回调里,并稍微延迟,是为了等会话页先完成转场。
4. 边界与失败处理
4.1 房间与群的冲突
进入语音房时,先消掉正在展示的弹层,再按当前所在位置分流:
房间分享可以带一个时间戳,用配置的有效时长校验,默认是 1 小时,过期就提示「房间邀请已失效」。进入过程中有进行中标记,避免重复进入。换到另一间房需要确认,是因为这会打断当前的收听或发言。
群这一侧:如果当前就已经在目标群会话里,只提示已在该群,仍回调成功,以便继续打开目标页。切换首页 Tab、跳到聊天 Tab,只在导航栈深度为 1 时执行,栈更深则静默放弃,避免把用户从一层层业务页里硬拉出来。多数跳转会先关掉正在展示的弹层;礼物中心如果栈顶已经是内置网页,会先弹出再打开新的。
4.2 未知类型、缺参,以及匹配中
- 无法识别的路径、缺少必选参数:降级用内置网页打开,而不是什么都不做。部分活动页会一边当网页打开,一边执行对应的活动动作。
- 正在匹配聊天:短链跳转和推送进会话都会被拦住,槽位或会话标记保留,避免把匹配流程打断。
- 未登录时的普通跳转:槽位保留,等登录并进入首页;新用户若带着待执行动作,会从广场进入,避免和自动匹配抢。
- 生成侧几乎不设最低版本限制。旧客户端遇到无法识别的路径或新参数,会因为匹配不上或缺参而走到内置网页。这是客户端这一侧能看到的兼容方式。
4.3 解析成功但没有跳转:怎么复现、怎么定位、怎么修
链接已经打开了 App,却停在首页或启动流程、没有到达目标页,可以按下面下手。
- 怎么复现:分别在未登录、尚未进入首页、飞行模式、以及正处于匹配中时点开同一条短链,再补齐那个缺失的条件。如果只有某一种条件下必现、补齐后会补跳,就可以把范围缩到对应的门闸。若在前一条尚未执行时又点开另一条,只到达后一条的目标页,则是单槽被覆盖,而不是门闸卡住。
- 怎么定位:先看待执行槽位有没有被写入、类型和参数对不对。槽位为空时,再看是解析失败走了内置网页,还是执行时已经被清空。槽位还在却不执行,就按门闸逐项核对:是否已登录、是否已进首页、网络是否可达、是否仍在匹配、回到前台时是不是被「新好友」流程拦住。双层短链还要确认做过二次解析:外层预览地址往往对不上业务类型,不拆内层就会被当成普通网页。
- 怎么修:门闸拦截且槽位仍在,应保证登录成功、首页首次出现、回到前台、网络恢复时都会再试一次,并且冷启动时不要被「恢复上次房间」抢在短链前面。单槽覆盖导致前一条丢失,是当前设计的取舍,改队列才能同时保留多条,见第 6 节。若二次解析把招募卡这类「本身也带跳转地址」的链接拆错,就要把它们排除在二次解析之外。解析失败则回到路径标识和必选参数,而不是在执行层补洞。
5. 演进
- 起步时,路由中枢把自定义
scheme、通用链接、推送等入口收成「先解析成动作,再择机执行」,并用动态链接服务生成和解析短链。类型以枚举加分支的方式逐渐增加。 - 运营位跳到话题,抽进路由中枢。
- 动态和话题改为双层短链,先落到预览页再回到详情;外层链接粘到会话里也能打开。
- 进群和招募卡补上目标页参数、可配置的预览信息、以服务端返回的群标识进群、不关注自己,以及已在群里也要打开目标页。
- 进群因资料未完成被拒绝时,引导用户去完善资料。
中枢的组织方式没有换成注册表,一直是枚举加大型分支;变化主要发生在分享回流的协议上:从「生成一条能打开 App 的短链」到「预览与回流分层」,再到进群后打开指定页。单槽待执行和门闸也保持原样,说明「先记下、条件满足再跳」这层抽象是够用的,真正变厚的是链路上的业务协议。
5.1 入口、槽位和门闸
入口很多,解析只有一套。
槽位只有一格,后一次覆盖前一次。
门闸没过就留着,过了只跑一次。
双层短链把预览和回 App 拆开。
进群成功之后才打开目标页,已经在群里也算成功。
6. 复盘与可改进之处
做对了的地方
- 把多种入口收进同一个解析与分发流程,生成、解析、执行共用一套类型,业务方不用在系统入口里各写一套跳转。
- 待执行单槽加上登录、首页、网络、匹配这组门闸,把「链接到达」和「页面可以
push」拆开,冷启动和热启动可以走同一套执行函数,只是触发时机不同。 - 双层短链把站外预览和回 App 详情做成同协议的两层,会话里粘贴外层链接也能汇合到二次解析,详情跳转不必维护两套。
- 进群后再打开目标页时,以服务端返回的标识为准,并把「已在该群」视为成功,避免老成员点开招募卡时走不进活动页。
还可以做得更好
- 用路由表代替巨型分支。 现在新增一种类型要改枚举、标识、生成、解析、执行至少四处,也容易漏掉某一处。更好的做法是让每种类型自己注册:路径标识、必选参数、是否允许登录前记账、以及执行闭包。中枢只负责解析、门闸和调度。解析也不应再对最后一段路径做包含匹配,而是精确匹配或按注册表查找,避免短标识互相包含。
- 单槽与队列的取舍。 单槽的好处是实现简单,不会在启动后连跳多个页面,用户看到的是最后一次意图。代价是前一条被静默丢掉:例如先点了动态详情、启动过程中又点了房间邀请,动态就不会再打开。若要保留多条,可以用队列,但必须规定执行顺序、是否去重、以及最多跳几次,避免冷启动被连续
push。一个折中是:同类型覆盖,不同类型入队,并给队列一个很小的上限。 - 参数协议要有版本。 群标识和房间时限共用相近的键、二次解析靠「取出内层地址再解析一遍」,都对调用方和解析顺序很敏感。可以给协议加上版本号,字段名按类型分开;二次解析应读取明确的内层字段,而不是截取字符串。旧客户端遇到更高版本或不识别的类型时,仍可降级打开网页,但应是一种写明的兼容策略,而不是恰好走到缺参分支。
- 给解析补测试。 路径包含匹配、缺参、内层跳转、招募卡这类不应二次拆分的例外、未登录可记账的类型,都很适合写成纯函数测试,不需要拉起页面。现在这些规则散落在解析和执行里,改一处类型时缺少回归保护。