首页 / 资讯中心 / 文章详情

Flutter深度链接与Firebase身份验证:从Dynamic Links迁移到Universal Links/App Links

Flutter深度链接与Firebase身份验证:从Dynamic Links迁移到Universal Links/App Links ★ FEATURED ARTICLE
最近把一个Flutter项目的深度链接和Firebase身份验证整套流程重新捋了一遍发现网上很多资料还停留在Firebase Dynamic Links的旧思路上而那条路在2025年已经被官方正式停用。这次把我在Android和iOS两侧的落地配置、Flutter端的代码路径、还有几个踩了好久的坑一起整理出来希望能让做Flutter迁移或者从零接入的朋友少走点弯路。要搞清楚这套组合到底解决了什么问题得先想明白一个场景你收到一封验证邮件点里面的链接系统应该直接把你的App拉起来并且App能自动识别出“这是验证邮箱的操作”帮你完成邮箱认证而不是让你复制一串编码去App里手动粘贴。深度链接负责把用户从App外部准确送进应用内页面Firebase身份验证则负责把链接里的操作码通过网络校验转化为真实的账号状态变更。两者结合才能把一个看似普通的URL变成一整套完整、安全、可追踪的身份流程。适合读这篇文章的人主要是两类一类是Flutter项目刚起步、需要接入注册登录验证流程的开发者另一类是已经上了Firebase认证但邮箱验证和密码重置一直没做得顺的老手。前者能照着做完后者能补上不少认知盲区。1. 为什么要折腾深度链接与身份验证1.1 深度链接到底解决什么问题移动应用和网页最大的差异在于入口。网页里的用户可以被搜索引擎、社交媒体任意链接“瞬移”过来而App是一个封闭的岛用户只能从图标点进来。深度链接就是给App开一条外部可直达的路。具体到业务上最常见的痛点是注册后的邮箱验证用户填完邮箱和密码后向邮箱发出了一封验证邮件邮件里的链接指向你的运营域名。正常情况下用户点击这个链接手机系统会判断这个域名跟你某个App存在关联关系于是直接拉起App。如果没有深度链接用户点开链接只能看到一个网页上面可能写着“请在浏览器打开”或者“已通过验证”之类的提示随后用户不得不手动切回App、重新登录一次。这个割裂感极其致命尤其对产品经理来说每一个多余步骤都在蒸发转化率。深度链接还有另一层价值参数传递。链接背后可以携带操作码、用户ID、邀请码、渠道标记等一长串参数而这些参数能在App被拉起后第一时间被读取并用于业务处理。这也是为什么它不能仅仅是一个“打开App”的开关必须配合可靠的参数解析机制。1.2 为什么偏偏要配Firebase身份验证深度链接本身并不关心消除“你是谁”它只是一条路。但身份验证决定了这条路的地基和目的地。举个例子用户在小程序里收到一条群组邀请链接点开后App只被拉起来了如果没有身份验证状态那App根本不知道应该把这个人拉进哪个群组——是直接进群老用户还是先走注册流程新用户更极端的场景是邮箱验证这种操作App获得到的链接里带着一个oobCode这个代码是Firebase身份验证系统生成的临时一次性操作码必须通过Firebase的接口才能把它兑换成“邮箱已验证”这个事实。选择Firebase还有一个很实际的理由服务端逻辑已经替你处理好了大量边缘情况——操作码过期、重复使用、域名白名单、单次消费、跨平台一致等。你自己写一套邮件验证逻辑当然可以但要在安全性和数据一致性上与现成服务对齐成本高得多。Flutter侧接入Firebase Auth也异常简单几乎是一行初始化代码的事情这让它成为Flutter生态里身份验证方案的默认选项。1.3 典型场景邮件验证与邀请链接我这次实际处理的场景是两种很典型值得先摆出来。第一种是邮箱验证。新用户注册后用Firebase的sendEmailVerification下发验证邮件用户点击邮件里的链接App拉起后调用applyActionCode去消费操作码邮箱从unverified变成verified。这个流程看似简单但要注意邮件链接的域名配置由Firebase后台控制App端则在系统层面做了域名绑定。第二种是邀请链接。带邀请码的URL被用户点击后App根据当前登录状态分两条路走已登录则直接用邀请码调业务接口完成加入群组的动作未登录则先弹登录页成功后再继续调邀请接口。这里需要考虑的就是链接中的参数在冷启动时会不会被系统交给App以及参数在多次事件回调中会不会出现重复处理。这两种场景基本包含了深度链接身份验证的常见组合形态后面所有代码和配置都围绕这两条链路展开。2. 方案选型从Dynamic Links到Universal Links/App Links2.1 曾经的标配Firebase Dynamic Links过去几年里Firebase动态链接一直是Flutter社区里做深度链接的首选方案。它包装了短链接、域名自动配置、跨平台跳转逻辑甚至能处理App未安装时自动跳转到应用商店的情况。开发者只需要引入firebase_dynamic_links插件配置一个域名就能拿到一套开箱即用的标准流程。但这里有个非常关键的时间节点这个服务已经被官方停止了支持。2025年之后动态链接的控制台配置、短链接解析、旧链接重定向全部不再可用。如果新项目还在用这套方案上线后会发现用户点历史邮件里的验证链接直接就打不开了。我已经把项目中所有Dynamic Links相关代码全部移除否则存量的验证邮件会全部变成死链。这个事在业界引起的涟漪很大但好在官方给出了清晰的迁移方向放弃动态链接的域名分发改用系统原生支持的深度链接能力。这可能让一批老项目非常不情愿但换个角度看它实际上让Flutter的深度链接方案回归了平台原生的本真——不再依赖一家中间服务做跳板而是直接跟iOS和Android的系统握手。2.2 现阶段的推荐路线Universal Links App Links现在推荐的技术组合是iOS的Universal Links加Android的App Links这两者本质上是同一套思路你拥有一个HTTPS域名并在域名上放置约定的校验文件系统验证通过后所有指向这个域名的链接都会默认尝试拉起已安装的App。Android侧的App Links原理可以概括为三步在AndroidManifest里给Activity配置Intent过滤器并声明autoVerify把一个包含包名和签名SHA-256证书指纹的assetlinks.json文件放到HTTPS域名的常规路径下系统在安装或首次点击时自动到域名上抓取该文件完成校验。校验通过后点击同时匹配Scheme和Host的链接就不会再弹浏览器而是直接发给你的App。iOS侧则是把apple-app-site-association文件放到域名下并在App的Entitlements里开启Associated Domains填入applinks:你的域名。系统会在App安装后抓取关联文件确认允许的路径范围。这套方案完全依赖HTTPS和域名所有权没有中间商稳定性远高于动态链接并且每个环节都可以自己调试和验证。唯一的代价是配置步骤更原始Android要处理证书指纹iOS要处理Entitlements但这也意味着出错时可排查的环节更清晰。2.3 参数编码与通用约定选定系统级方案后剩下的关键就是链接参数的约定。我建议所有身份验证相关的链接统一使用一个专用域名不要跟业务推广链接混在一起。这样既方便在域名校验文件里统一控制路径范围也方便在Flutter端做来源域名白名单校验。参数格式上我对链接结构做了规范大致是这样https://auth.example.com/action?modeverifyEmailoobCodeabc123langzh其中mode表示操作类型oobCode是Firebase身份验证系统签发的操作码lang用于下发后展示多语言。这里特别要注意参数的编码问题URL里如果包含特殊符号比如在query string中已经承担了分割作用如果某个参数值本身带或就需要使用URL编码。但Firebase的oobCode本身通常是Base64串直接放在query里基本不会冲突。麻烦的是某些邮件客户端会在展示链接时对做编码或者把转成amp这时候URI解析出来的queryParameters往往会帮你自动解码反而比手动字符串截取靠谱得多。还有一个通用约定值得强调深度链接要处理的操作类型尽量用枚举明确定义不要在参数里自由发挥。我在Flutter端统一用一个链接解析类去处理只放行已知的操作类型遇到未知模式一律丢弃并打印警告日志避免被异常链接捣乱。3. 接入配置Android与iOS的深度链接设置3.1 Android App Links配置Android侧配置的第一步是在AndroidManifest.xml里给接收链接的Activity加上Intent过滤器。需要注意宿主Activity要选对通常就是你MainActivity对应的Flutter容器Activity。代码块如下activity android:name.MainActivity intent-filter android:autoVerifytrue action android:nameandroid.intent.action.VIEW / category android:nameandroid.intent.category.DEFAULT / category android:nameandroid.intent.category.BROWSABLE / data android:schemehttps android:hostauth.example.com android:pathPrefix/action / /intent-filter /activityautoVerify是开启自动校验的开关告诉系统去域名下验证assetlinks.json。pathPrefix可以限制只有这个路径下的链接才会拉起App避免整个域名的其他普通网页也触发App跳转。然后需要把assetlinks.json文件放到域名根路径下文件内容大致如下[ { relation: [delegate_permission/common.handle_all_urls], target: { namespace: android_app, package_name: com.example.myapp, sha256_cert_fingerprints: [ AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99 ] } } ]这里的SHA-256指纹是发布证书的指纹不是调试证书。很多开发者在这里栽跟头测试时用的debug签名上线后换成release签名指纹不匹配上架后链接全部失效。建议把debug和release两个指纹都写进assetlinks.json里本地调试和线上运营互不影响。配置完成后可以用一个在线校验工具或直接通过浏览器打开链接测试。我用的是adb命令来模拟adb shell am start -W -a android.intent.action.VIEW -d https://auth.example.com/action?modeverifyEmailoobCodetest com.example.myapp如果配置正确这个命令会拉起来你的App而且日志里能看到Intent的详细信息。3.2 iOS Universal Links配置iOS侧的配置稍微绕一点因为证书指纹变成了Apple开发后台的关联配置。首先要在开发者后台给App ID开启Associated Domains能力然后在Xcode的Signing Capabilities里添加applinks:auth.example.com。接着需要把apple-app-site-association文件放到HTTPS域名的根目录或者.well-known路径下。iOS的要求是域名必须支持HTTPS文件内容要严格符合JSON格式并且不能有文本编辑器偷偷带上的换行符或BOM头。内容大致如下{ applinks: { apps: [], details: [ { appID: TEAMID.com.example.myapp, paths: [/action/*] } ] } }这里的appID是开发者Team ID加Bundle ID的组合可以在开发者后台的Membership Details里查到。paths用来限定哪些路径会触发App拉起如果不写具体路径整个域名下的链接都会尝试打开App可能导致某个普通网页内容无法在浏览器里展示的尴尬局面。iOS的校验文件更新后不是即时生效的有些情况需要等待一段时间或者用户必须在Safari中打开一次链接触发预取后才能让系统完成缓存更新。这个细节经常让开发者误以为配置失败。3.3 Firebase后台与自定义域名的联动深度链接的域名配好了接下来要让Firebase身份验证发出的邮件链接指向这个域名。在Firebase控制台的Authentication设置页里有一个操作链接配置区域可以为邮箱验证、密码重置等操作设置自定义的链接域名和路径。这里的关键是设置action URL为你已经做过App Links/Universal Links校验的域名。如果这里没配好Firebase默认会使用它自身的域名生成邮件链接而那个域名没有跟你的App绑定用户点击后只能跳到浏览器页面永远不会拉起App。把域名换成自己的业务域名后Firebase会在邮件中生成类似这样的链接https://auth.example.com/action?modeverifyEmailoobCodeabcapiKeyxyzlangzh随后系统通过已经配好的App Links自动拉起Flutter应用整个链路就通了。Firebase默认的邮件模板可能会把链接显示成按钮或者纯文本为了保持一致性建议在模板设置里把链接样式改成按钮并填好对应的应用名不过新版控制台对模板的编辑自由度已经比较高了直接设置即可。另外不要忘记检查发件人域名配置。虽然这不影响深度链接本身的连通性但如果在测试邮箱验证时始终收不到邮件多半是域名验证和SPF/DKIM配置没完成这属于邮件送达率问题和App侧无关但同样会阻断整条链路。4. 代码实战Flutter端的完整实现4.1 环境准备与依赖Flutter端的代码部分其实不算繁重主要依赖三个包firebase_core、firebase_auth以及负责接收系统深度链接的app_links。我会用典型命令添加依赖flutter pub add firebase_core flutter pub add firebase_auth flutter pub add app_linksapp_links现在维护比较活跃兼容Android和iOS也支持桌面端部分场景。如果你项目中已经有uni_links的旧代码迁移成本也不大核心API很相似。平台配置文件部分Android需要在build.gradle里添加google-services插件并在app目录放google-services.json。iOS需要在Runner工程里放GoogleService-Info.plist。这一步属于Firebase基础接入踩坑的人不多但要提醒一点不同环境开发/生产的配置文件不同千万别把测试环境的plist带上线。在main()里初始化的顺序很关键。推荐先初始化Firebase再设置链接监听否则有可能在Firebase尚未就绪时链接回调就到了导致数据丢失。实际代码结构如下Futurevoid main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(); final handler DeepLinkHandler(auth: FirebaseAuth.instance); await handler.initialize(); runApp(const MyApp()); }4.2 链接监听与冷启动处理深度链接回调有一个分叉点冷启动和热启动。热启动指的是App已经在后台运行系统直接把链接丢给现有进程冷启动则是App已经被系统杀了链接作为启动Intent的一部分传进来。app_links分别用两个API应对这两种情况uriLinkStream是流式监听负责热启动getInitialLink是主动获取负责冷启动。如果只监听流而忽略初始链接冷启动场景下用户会看到App正常打开了但后续没有任何反应因为那条链接已经被“丢”在了启动参数里而监听器注册得太晚。我的处理方式是统一封装一个类class DeepLinkHandler { final FirebaseAuth _auth; AppLinks? _appLinks; DeepLinkHandler({required FirebaseAuth auth}) : _auth auth; Futurevoid initialize() async { _appLinks AppLinks(); // 热启动场景 _appLinks!.uriLinkStream.listen((Uri uri) { _handleUri(uri); }); // 冷启动场景 final initialUri await _appLinks!.getInitialLink(); if (initialUri ! null) { _handleUri(initialUri); } } Futurevoid _handleUri(Uri uri) async { if (uri.host ! auth.example.com || uri.path ! /action) { debugPrint(Ignore unknown deep link: $uri); return; } // 解析参数并交给对应的处理器 await _processActionCode(uri); } }这里有一个隐私细节宿主域名校验不能少否则任何人都可以用一个相似域名伪造链接把非法参数塞给你的App。4.3 身份验证动作解析拿到链接和参数后下一步就是把参数送给Firebase身份验证系统。利用checkActionCode来判断操作码类型是最稳妥的方式Futurevoid _processActionCode(Uri uri) async { try { final ActionCodeInfo info await _auth.checkActionCode(uri.toString()); switch (info.operation) { case ActionCodeOperation.verifyEmail: await _auth.applyActionCode(uri.toString()); await _auth.currentUser?.reload(); break; case ActionCodeOperation.passwordReset: final newPassword await _promptNewPassword(); if (newPassword ! null) { await _auth.confirmPasswordReset( link: uri.toString(), newPassword: newPassword, ); } break; case ActionCodeOperation.recoverEmail: // 处理改绑邮箱后的旧邮箱恢复根据业务决定是否支持 break; default: debugPrint(Unsupported operation: ${info.operation}); } } on FirebaseAuthException catch (e) { if (e.code invalid-action-code || e.code expired-action-code) { // 提示用户链接失效或已使用 _showLinkExpiredDialog(); } } }邮箱验证的路径里先checkActionCode拿到操作类型再applyActionCode消费操作码最后reload刷新当前用户状态。这里有一个很容易忽略的问题用户在邮箱验证链接打开前App里可能还没有登录任何账号这时调用applyActionCode是没问题的因为Firebase是server-side处理这个操作码的之后再让用户登录用户资料里的emailVerified字段就会是true。密码重置的流程则分两步先用verifyPasswordResetCode验证操作码有没有过期再调用confirmPasswordReset设置新密码。上述代码里我把这两步合并了checkActionCode已经消耗了一部分校验逻辑但为了稳妥更合理的做法是在弹出输入新密码页面前先调用verifyPasswordResetCode做个前置检查避免用户填完新密码后才被告知链接失效。另外用户若在邀请链接场景里未登录这部分代码要分开处理。链接里携带的邀请参数和认证操作码是独立的认证操作完成之后再读取query里的业务参数用于跳转。4.4 业务跳转与用户状态刷新身份验证处理完成后App需要回到业务逻辑层。这里我建议在状态管理层面维护一个路由目标对象用户在App里当前处于哪个页面是否等待某个认证动作完成认证完成后应该去哪个页面。以邀请链接为例处理逻辑可以这么设计class AuthFlowState extends ChangeNotifier { AuthStatus _status AuthStatus.unknown; AppRoutingTarget? _pendingTarget; Futurevoid handleInviteLink(String inviteCode) async { if (_status AuthStatus.authenticated) { // 已登录直接加入 await _joinGroupByInvite(inviteCode); } else { // 未登录把待跳转目标保存下来引导用户登录 _pendingTarget AppRoutingTarget.groupInvite(inviteCode); notifyListeners(); } } Futurevoid signIn(String email, String password) async { await FirebaseAuth.instance.signInWithEmailAndPassword( email: email, password: password, ); _status AuthStatus.authenticated; notifyListeners(); // 登录成功后消费待跳转目标 if (_pendingTarget ! null) { await _joinGroupByInvite(_pendingTarget!.inviteCode!); _pendingTarget null; notifyListeners(); } } }这个模式的好处是把系统的深度链接处理与业务状态解绑。链接处理层只负责解析和认证业务层只关心当前有没有待消费的目标。即使链接在冷启动时早于UI渲染完成只要状态管理对象保存了这个目标UI恢复后依然能可靠地执行跳转。总之这套机制对用户而言体验是“点链接进App验证完自动到达目标页面”而代码侧最重要的保障则是状态解耦。5. 常见问题与排查技巧实录5.1 冷启动丢链接与单例顺序这是一个发生率极高的问题现象是App被链接拉起后界面正常但完全没有任何处理动作。排查时十个里有八个是冷启动那条initial link没有被读取。我之前遇到过在App进入主页后才调用getInitialLink结果返回null但返回null不代表没有链接而是这个链接已经被系统消费掉但你的处理时机没有跟上。正确的做法是在入口处就获取initial link也就是在splash页面停留时提前调用越早越好。如果业务复杂甚至可以先把链接解析结果放进一个临时变量等Firebase初始化完成后立刻处理。日志是排查这个问题最好的工具。在initialize里分别给流监听和冷启动获取加上日志确认两个路径是否都收到了同一份链接。如果冷启动没收到多半是时序问题如果两个路径都收到了同一份链接则要注意后面加一个幂等保护避免重复处理同一操作码。5.2 Android的autoVerify失败与跳到浏览器问题第二种常见问题是点击链接后App没被拉起反而打开了浏览器页面。当你自己反复确认Intent过滤器和域名文件都没问题时可以从这个角度切入排查。先在adb里跑一次上文的启动命令看App能不能被强制拉起。如果能说明Intent过滤器是对的问题大概率出在系统的自动验证环节它还没确认这个域名可以安全地关联你的App。此时需要检查assetlinks.json文件是否真的能通过HTTPS公网访问文件Content-Type是否是application/json以及域名有没有跳转。另外确认一下你的包名和指纹是不是debug/release签名对应的值。Android系统对首次关联的确认可能有一段时间延迟需要耐心等待一段时间后再试。经验上我刚布置完前端资源后手机往往需要重新安装App或等待一段时间才能正常识别域名映射急着点击测试不一定能拿到最新的验证结果。另外Android 11之后系统对包可见性做了限制某些场景下也会影响系统判定关联的可靠性但日常开发中影响不大通常配置无误后就能正常跳转。5.3 参数编码、双重转义与invalid-action-code第三种问题是checkActionCode抛invalid-action-code。多数情况不是操作码本身失效而是URL中的参数在传输过程中被转义破坏了。典型的例子是邮件服务商或者中间跳转页把链接里的变成了或者把写成了%3D。这时如果你直接用uri.toString()传给Firebase它会解析出错误的oobCode自然验证失败。我遇到这个问题的诀窍是先从Uri.queryParameters里取出mode和oobCode再手动拼接出标准链接final params uri.queryParameters; final mode params[mode]; final oobCode params[oobCode]; if (mode verifyEmail oobCode ! null) { final normalized https://auth.example.com/action?mode$modeoobCode$oobCode apiKey$apiKeylang${params[lang] ?? zh}; await _auth.applyActionCode(normalized); }这样做能规避掉绝大多数转义问题因为queryParameters已经帮你做了解码你可以用这些解码后的参数重建一个标准链接再交给Firebase。不过要留意上下文如果你从某个短链服务跳转过来的链路里可能还隐藏着额外的302跳转但app_links拿到的最终URI通常是最后一次跳转后的地址问题不大。还有一点如果操作码本身已经过期或使用过报错也会是invalid-action-code。Firebase的操作码是一次性的验证成功后再点击第二次必然报错需要在前端把按钮状态和错误提示处理好避免用户疑惑。5.4 Dynamic Links迁移与老用户邮件兜底如果你和我一样是从Firebase Dynamic Links迁移过来的还要处理一类存量用户问题那些已经发出的验证邮件、密码重置邮件里链接指向的老域名已经停用点击后可能直接打不开。这事没有完美的技术方案只能在产品层面做兜底。我是这么处理的把邮件中旧域名的链接仍然保留一个静态跳转页页面提示用户“旧验证链接已失效”并提供两个替代方案一是从App内重新触发验证邮件二是使用刷新后的安全验证码在登录页完成操作。同时在App启动后检查当前用户的emailVerified状态如果未验证则弹窗引导用户重新发送验证邮件。这样即使旧链接全废用户的完成率也不会降到零。迁移本身并不复杂把域名文件从旧服务切到自己的HTTPS域名更新Firebase后台的操作链接模板客户端替换包依赖、删除旧的动态链接监听逻辑回归一遍验证和重置流程即可。真正的复杂度全在兼容性和用户引导上。5.5 iOS相关的不稳定因素iOS的Universal Links偶尔也有自己的“脏脾气”常见的有几种Safari里第一次打开链接时弹了个确认框或者完全没反应。遇到这种情况先检查关联文件是否可访问其次要确认Entitlements里applinks域的配置没有写错。另外有个老坑在模拟器上测试Universal Links基本不可靠经常验证失败建议直接使用真机。还有一个容易忽略的点关联文件里写的paths和App内实际监听路径必须完全一致。我试过在文件里写/action/*但App里只匹配了/action把带后续参数的路径全部过滤掉了链接能拉起App但永远走不到处理逻辑排查了好半天。写在最后这套配置下来说实话写代码的时间只占一小半大部分精力都花在域名校验、证书指纹、系统级路由分发这类“看不见”的环节上。但也正因为这些环节是系统层的一旦配好稳定性和安全性都远胜于用短链跳板的方式。最后分享一个我自己的调试习惯不要每次都通过邮箱服务去点邮件里的链接太慢也太被动。我准备了一个测试域名和一条模拟命令任何一次链接参数变更都能在几秒内直接投给App验证配合日志输出可以很快定位问题。先用命令把链路跑通再回头去收真实邮件效率会高很多。还有一个小建议无论你是新项目还是老项目在链接处理层一定要留足日志深度链接的坑往往不在IDE报错里而在系统的静默行为中。
阅读完成 · 觉得有帮助?
咨询建站