:URL Scheme與Universal Links參數(shù)透傳C#)
1. 為什么手游團隊繞不開 Deep Link 這件事做過手游運營的兄弟都清楚買量投放最怕的不是點擊率低而是用戶點了廣告、裝完 App、打開之后落到了首頁完全找不到剛才廣告里那個活動入口。這個轉(zhuǎn)化漏斗每斷一層買量成本就往上翻一截。Unity 手游 iOS Deep Link 喚醒要解決的就是這條鏈路讓用戶從瀏覽器、短信、社交 App、廣告落地頁點一個鏈接直接跳到游戲內(nèi)指定頁面并且把鏈接上帶的參數(shù)區(qū)服、邀請碼、活動 ID、渠道標識原封不動投遞到 C# 業(yè)務(wù)層。標題里提到的兩個關(guān)鍵詞——URL Scheme和Universal Links——是 iOS 上實現(xiàn)這件事的兩條腿。前者是老牌方案配置簡單但體驗有瑕疵后者是蘋果主推的方案體驗好但配置鏈路長、坑也多。而真正讓 Unity 開發(fā)者頭疼的往往不是原生層怎么配而是參數(shù)怎么從 Objective-C / Swift 層安全、準確地傳到 C# 層還要處理冷啟動、熱啟動、App 未安裝跳 App Store 再回流的各種邊界情況。這篇內(nèi)容適合三類人看一是正在做手游買量歸因、活動喚起的 Unity 客戶端開發(fā)二是需要和原生 iOS 同學(xué)對接聯(lián)調(diào)的技術(shù)負責人三是想搞清楚 Deep Link 全鏈路到底有哪些坑的獨立開發(fā)者。我會按“方案選型 → 原生配置 → Unity 橋接 → 參數(shù)投遞 → 問題排查”的順序把整條鏈路拆開講透代碼和配置都能直接抄。2. 方案選型URL Scheme 和 Universal Links 到底怎么選2.1 兩種方案的本質(zhì)區(qū)別很多人把這兩個東西混著用結(jié)果線上出現(xiàn)“點了沒反應(yīng)”或者“跳瀏覽器再跳回來”的詭異體驗。先把本質(zhì)講清楚。URL Scheme是 App 自己注冊的一個自定義協(xié)議頭比如mygame://。系統(tǒng)收到這個協(xié)議的鏈接時會去查哪個 App 注冊了它然后拉起。它的特點是配置極簡只要在Info.plist里加一段就行但缺點也很明顯——任何 App 都能注冊同一個 Scheme存在被劫持的風(fēng)險而且從 Safari 打開時如果 App 沒裝會直接彈一個“打不開”的錯誤體驗很差。Universal Links是蘋果在 iOS 9 之后推的方案本質(zhì)是把你自己的域名和 App 綁定。用戶點https://game.example.com/activity?id123這樣的普通網(wǎng)頁鏈接如果設(shè)備上裝了你的 App系統(tǒng)會直接拉起 App 并把鏈接傳進來如果沒裝就正常打開網(wǎng)頁你可以在網(wǎng)頁上引導(dǎo)下載。體驗上無縫安全性也高因為域名歸屬是驗證過的。2.2 選型決策表維度URL SchemeUniversal Links配置復(fù)雜度低改 Info.plist 即可高需要服務(wù)端配置 AASA 文件未安裝 App 時體驗報錯體驗差正常打開網(wǎng)頁可引導(dǎo)下載安全性低可被其他 App 搶注高域名驗證從 Safari 直接點擊需二次確認彈窗直接喚起無彈窗從其他 App 內(nèi) WebView通??捎貌糠謭鼍笆芟尬⑿?QQ 內(nèi)打開基本被攔截基本被攔截冷啟動參數(shù)獲取通過 launchOptions通過 continueUserActivity熱啟動參數(shù)獲取openURL 回調(diào)continueUserActivity 回調(diào)我的實際建議是兩個都配主用 Universal LinksURL Scheme 作為兜底。原因很現(xiàn)實——Universal Links 在某些場景下會失效比如用戶從某些 App 的內(nèi)置瀏覽器點擊、或者 AASA 文件被 CDN 緩存了舊版本這時候 URL Scheme 能救場。反過來如果只配 URL Scheme買量落地頁的轉(zhuǎn)化率會明顯吃虧。2.3 一個容易被忽略的坑AASA 文件的緩存Universal Links 依賴服務(wù)端根目錄下的apple-app-site-association文件簡稱 AASA這個文件必須滿足幾個硬性條件HTTPS、無重定向、Content-Type 為 application/json、不能超過 128KB。更坑的是iOS 會緩存這個文件緩存策略由蘋果控制你更新了文件之后設(shè)備可能幾天都不生效。實測下來開發(fā)階段可以用一個技巧強制刷新把設(shè)備上的 App 卸載重裝或者在設(shè)置里關(guān)閉再打開“開發(fā)者模式”相關(guān)的網(wǎng)絡(luò)緩存。生產(chǎn)環(huán)境更新 AASA 時建議同時保留舊路徑的兼容避免老版本 App 突然失效。3. iOS 原生層配置把兩條鏈路都打通3.1 URL Scheme 的配置在 Unity 導(dǎo)出的 Xcode 工程里找到Info.plist添加URL TypeskeyCFBundleURLTypes/key array dict keyCFBundleURLName/key stringcom.example.mygame/string keyCFBundleURLSchemes/key array stringmygame/string /array /dict /array這樣mygame://activity?id123就能拉起你的 App。注意CFBundleURLName建議用反域名格式避免和其他 App 沖突。3.2 Universal Links 的配置這一步分服務(wù)端和客戶端兩部分。服務(wù)端在https://game.example.com/.well-known/apple-app-site-association放一個 JSON 文件注意沒有后綴名{ applinks: { apps: [], details: [ { appID: TEAMID.com.example.mygame, paths: [/activity/*, /invite/*, /share/*] } ] } }appID是TeamID.BundleID的拼接TeamID 在蘋果開發(fā)者后臺能看到。paths支持通配符但注意 iOS 13 之后推薦用components語法做更精細的控制??蛻舳嗽?Xcode 工程的Signing Capabilities里添加Associated Domains填入applinks:game.example.com這一步會寫進 entitlements 文件。很多人配完發(fā)現(xiàn)不生效八成是 entitlements 沒同步到 Unity 導(dǎo)出的工程里或者 Provisioning Profile 沒重新生成。3.3 Unity 導(dǎo)出工程的自動化處理每次 Unity 重新導(dǎo)出 Xcode 工程Info.plist和 entitlements 都可能被覆蓋。手動改一次兩次還行天天改會瘋。我的做法是寫一個 PostProcessBuild 腳本在導(dǎo)出后自動注入配置#if UNITY_IOS using UnityEditor; using UnityEditor.Callbacks; using UnityEditor.iOS.Xcode; using System.IO; public class iOSDeepLinkPostProcess { [PostProcessBuild(999)] public static void OnPostProcessBuild(BuildTarget target, string path) { if (target ! BuildTarget.iOS) return; string projPath PBXProject.GetPBXProjectPath(path); PBXProject proj new PBXProject(); proj.ReadFromFile(projPath); string mainTarget proj.GetUnityMainTargetGuid(); // 注入 URL Scheme string plistPath Path.Combine(path, Info.plist); PlistDocument plist new PlistDocument(); plist.ReadFromFile(plistPath); PlistElementArray urlTypes plist.root.CreateArray(CFBundleURLTypes); PlistElementDict urlDict urlTypes.AddDict(); urlDict.SetString(CFBundleURLName, com.example.mygame); PlistElementArray schemes urlDict.CreateArray(CFBundleURLSchemes); schemes.AddString(mygame); plist.WriteToFile(plistPath); // 注入 Associated Domains string entPath proj.GetEntitlementsFilePath(mainTarget); if (!string.IsNullOrEmpty(entPath)) { PlistDocument ent new PlistDocument(); ent.ReadFromFile(entPath); PlistElementArray domains ent.root.CreateArray(com.apple.developer.associated-domains); domains.AddString(applinks:game.example.com); ent.WriteToFile(entPath); } proj.WriteToFile(projPath); } } #endif這個腳本放在Editor目錄下每次 Build 自動執(zhí)行。注意PostProcessBuild的優(yōu)先級參數(shù)設(shè)成 999確保在其他處理之后執(zhí)行。4. Unity 與原生橋接參數(shù)怎么從 OC 傳到 C#4.1 冷啟動和熱啟動的區(qū)別這是整個鏈路里最容易出錯的地方。冷啟動指 App 沒在后臺運行用戶點鏈接把它拉起來熱啟動指 App 已經(jīng)在后臺用戶點鏈接把它切到前臺。兩種情況回調(diào)的入口完全不同。冷啟動時參數(shù)在application:didFinishLaunchingWithOptions:的launchOptions里熱啟動時參數(shù)在application:openURL:options:URL Scheme或application:continueUserActivity:restorationHandler:Universal Links里。問題在于Unity 的AppDelegate是自動生成的你直接改會被覆蓋。正確做法是寫一個自定義的AppDelegate繼承類或者用 Unity 提供的UnityAppController子類機制。4.2 自定義 AppDelegate 的寫法在 Unity 導(dǎo)出的 Xcode 工程里創(chuàng)建一個DeepLinkAppController.mm#import UnityAppController.h #import Foundation/Foundation.h extern C { void UnitySendMessage(const char* obj, const char* method, const char* msg); } interface DeepLinkAppController : UnityAppController end implementation DeepLinkAppController - (BOOL)application:(UIApplication*)application didFinishLaunchingWithOptions:(NSDictionary*)launchOptions { NSURL *url launchOptions[UIApplicationLaunchOptionsURLKey]; if (url) { [self dispatchDeepLink:url.absoluteString]; } NSUserActivity *activity launchOptions[UIApplicationLaunchOptionsUserActivityDictionaryKey][UIApplicationLaunchOptionsUserActivityTypeIdentifier]; if (activity [activity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { [self dispatchDeepLink:activity.webpageURL.absoluteString]; } return [super application:application didFinishLaunchingWithOptions:launchOptions]; } - (BOOL)application:(UIApplication*)app openURL:(NSURL*)url options:(NSDictionaryUIApplicationOpenURLOptionsKey,id*)options { [self dispatchDeepLink:url.absoluteString]; return [super application:app openURL:url options:options]; } - (BOOL)application:(UIApplication*)application continueUserActivity:(NSUserActivity*)userActivity restorationHandler:(void (^)(NSArrayidUIUserActivityRestoring*))restorationHandler { if ([userActivity.activityType isEqualToString:NSUserActivityTypeBrowsingWeb]) { [self dispatchDeepLink:userActivity.webpageURL.absoluteString]; } return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler]; } - (void)dispatchDeepLink:(NSString*)urlString { if (!urlString) return; const char *msg [urlString UTF8String]; UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, msg); } end IMPL_APP_CONTROLLER_SUBCLASS(DeepLinkAppController)關(guān)鍵點IMPL_APP_CONTROLLER_SUBCLASS這個宏會告訴 Unity 用你的類替換默認的UnityAppController。UnitySendMessage是 Unity 提供的原生到 C# 的通信接口第一個參數(shù)是場景里掛載的 GameObject 名字第二個是方法名第三個是字符串參數(shù)。4.3 C# 層的接收與解析在 Unity 場景里創(chuàng)建一個空 GameObject命名為DeepLinkManager掛上腳本using System; using UnityEngine; public class DeepLinkManager : MonoBehaviour { public static event ActionDeepLinkData OnDeepLinkParsed; private static DeepLinkManager _instance; void Awake() { if (_instance ! null) { Destroy(gameObject); return; } _instance this; DontDestroyOnLoad(gameObject); } // 由原生層通過 UnitySendMessage 調(diào)用 public void OnDeepLinkReceived(string url) { Debug.Log($[DeepLink] 收到鏈接: {url}); var data DeepLinkParser.Parse(url); if (data ! null) { OnDeepLinkParsed?.Invoke(data); } } }注意UnitySendMessage調(diào)用的是實例方法不是靜態(tài)方法而且 GameObject 必須處于激活狀態(tài)否則消息會丟。這是新手最常踩的坑之一。4.4 參數(shù)解析的健壯性設(shè)計鏈接格式可能是mygame://activity?id123channelwechat也可能是https://game.example.com/activity?id123channelwechat。解析器要同時兼容兩種using System; using System.Collections.Generic; [Serializable] public class DeepLinkData { public string scheme; public string host; public string path; public Dictionarystring, string query; } public static class DeepLinkParser { public static DeepLinkData Parse(string url) { if (string.IsNullOrEmpty(url)) return null; try { var uri new Uri(url); var data new DeepLinkData { scheme uri.Scheme, host uri.Host, path uri.AbsolutePath, query new Dictionarystring, string() }; if (!string.IsNullOrEmpty(uri.Query)) { string query uri.Query.TrimStart(?); foreach (var pair in query.Split()) { var kv pair.Split(); if (kv.Length 2) { data.query[Uri.UnescapeDataString(kv[0])] Uri.UnescapeDataString(kv[1]); } } } return data; } catch (Exception e) { Debug.LogError($[DeepLink] 解析失敗: {url}, {e.Message}); return null; } } }這里用Uri類而不是手動字符串切割是因為Uri會自動處理轉(zhuǎn)義、端口、大小寫等問題。但要注意mygame://activity?id123這種格式里activity會被解析成 Host 而不是 Path所以業(yè)務(wù)層判斷時要同時看 host 和 path。5. 完整實操流程從點擊鏈接到游戲內(nèi)跳轉(zhuǎn)5.1 端到端鏈路梳理把整條鏈路串起來看一次成功的 Deep Link 喚起經(jīng)歷這些環(huán)節(jié)用戶在瀏覽器/短信/社交 App 點擊鏈接iOS 系統(tǒng)判斷是 Universal Link 還是 URL Scheme系統(tǒng)拉起 App冷啟動或切到前臺熱啟動原生層AppDelegate收到回調(diào)拿到完整 URL通過UnitySendMessage把 URL 傳給 C# 層C# 層解析 URL提取業(yè)務(wù)參數(shù)業(yè)務(wù)層根據(jù)參數(shù)決定跳轉(zhuǎn)到哪個界面如果 App 未安裝走 App Store 下載首次啟動時補投參數(shù)第 8 步是最容易被忽略的。用戶點了廣告App 沒裝跳到 App Store下載完打開這時候鏈接參數(shù)已經(jīng)丟了。解決方案是延遲深度鏈接Deferred Deep Link通常需要借助第三方歸因服務(wù)或者自己用剪貼板、IDFA 等做匹配。這部分超出本文范圍但你要知道這個環(huán)節(jié)存在。5.2 冷啟動場景的實測記錄我在測試機上實測冷啟動流程日志輸出如下[DeepLink] 收到鏈接: mygame://activity?id8888channelad_001 [DeepLink] 解析結(jié)果: schememygame, hostactivity, path, id8888, channelad_001 [DeepLink] 業(yè)務(wù)層跳轉(zhuǎn)到活動頁 8888渠道 ad_001注意冷啟動時UnitySendMessage可能在 Unity 引擎還沒完全初始化時就被調(diào)用導(dǎo)致消息丟失。解決辦法是在原生層做一個緩沖如果 Unity 還沒準備好先把 URL 存起來等UnityReady之后再發(fā)。static NSString *pendingUrl nil; - (void)dispatchDeepLink:(NSString*)urlString { if (!urlString) return; if ([self isUnityReady]) { UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, [urlString UTF8String]); } else { pendingUrl urlString; } } - (void)unityReady { if (pendingUrl) { UnitySendMessage(DeepLinkManager, OnDeepLinkReceived, [pendingUrl UTF8String]); pendingUrl nil; } }isUnityReady可以通過監(jiān)聽 Unity 的UnityDidFinishLaunching通知來判斷。5.3 熱啟動場景的處理熱啟動相對簡單因為 Unity 已經(jīng)在運行UnitySendMessage能直接送達。但要注意如果用戶連續(xù)點多個鏈接可能會觸發(fā)多次回調(diào)業(yè)務(wù)層需要做去重或隊列處理避免界面跳轉(zhuǎn)錯亂。我的做法是在 C# 層加一個簡單的節(jié)流private float _lastHandleTime; private const float ThrottleInterval 0.5f; public void OnDeepLinkReceived(string url) { if (Time.realtimeSinceStartup - _lastHandleTime ThrottleInterval) { Debug.LogWarning([DeepLink] 觸發(fā)過于頻繁忽略); return; } _lastHandleTime Time.realtimeSinceStartup; // ... 正常處理 }5.4 參數(shù)投遞到業(yè)務(wù)層的時機參數(shù)解析出來之后什么時候投遞給業(yè)務(wù)層如果游戲還在加載 Logo 頁業(yè)務(wù)層的 UI 可能還沒初始化。我的經(jīng)驗是解析完先緩存等主界面加載完成后再消費。public class DeepLinkManager : MonoBehaviour { private DeepLinkData _pendingData; private bool _mainSceneReady; public void OnDeepLinkReceived(string url) { var data DeepLinkParser.Parse(url); if (data null) return; if (_mainSceneReady) { Dispatch(data); } else { _pendingData data; } } public void MarkMainSceneReady() { _mainSceneReady true; if (_pendingData ! null) { Dispatch(_pendingData); _pendingData null; } } private void Dispatch(DeepLinkData data) { OnDeepLinkParsed?.Invoke(data); } }主界面加載完成后調(diào)用MarkMainSceneReady()這樣就不會出現(xiàn)“參數(shù)來了但界面還沒準備好”的尷尬。6. 常見問題與排查技巧實錄6.1 問題速查表現(xiàn)象可能原因排查方法點鏈接完全沒反應(yīng)Scheme 拼寫錯誤 / AASA 未生效用 Safari 直接輸入 scheme 測試Universal Link 跳瀏覽器AASA 文件格式錯誤或緩存檢查 Content-Type 和路徑冷啟動參數(shù)丟失UnitySendMessage 時機太早加 pendingUrl 緩沖熱啟動參數(shù)重復(fù)多次回調(diào)未去重加節(jié)流或狀態(tài)判斷參數(shù)中文亂碼未做 URL 解碼用 Uri.UnescapeDataString微信內(nèi)點擊無效微信攔截了 scheme引導(dǎo)用系統(tǒng)瀏覽器打開部分機型不生效系統(tǒng)版本差異檢查 iOS 版本和 AASA 兼容6.2 三個獨家避坑技巧技巧一用 Safari 的開發(fā)者工具抓 AASA 請求。把 iPhone 連到 MacSafari 開發(fā)菜單里能看到設(shè)備上的網(wǎng)絡(luò)請求直接看 AASA 文件有沒有被正確請求、返回內(nèi)容對不對。這比盲猜快十倍。技巧二Universal Links 測試時從備忘錄里點鏈接。備忘錄里的鏈接是系統(tǒng)級處理的能真實反映 Universal Links 是否生效。從 Safari 地址欄輸入反而不準因為那是用戶主動輸入系統(tǒng)行為不同。技巧三參數(shù)里不要放特殊字符。、、#、空格這些字符在 URL 里有特殊含義業(yè)務(wù)參數(shù)一定要做 URL 編碼。我見過有團隊把用戶昵稱直接拼進鏈接結(jié)果昵稱里有導(dǎo)致參數(shù)解析全亂。6.3 關(guān)于 Unity 版本差異的提醒Unity 2019 和 2021 在 iOS 導(dǎo)出結(jié)構(gòu)上有差異UnityAppController的路徑和IMPL_APP_CONTROLLER_SUBCLASS宏的行為略有不同。2021 之后 Unity 引入了新的UnityFramework結(jié)構(gòu)UnitySendMessage的符號可能不在主 Target 里需要在UnityFramework的 Build Settings 里確認符號可見性。如果你遇到“編譯通過但運行時找不到符號”八成是這個原因。7. 參數(shù)安全與業(yè)務(wù)層設(shè)計的一點經(jīng)驗7.1 不要信任鏈接里的任何參數(shù)Deep Link 的參數(shù)是用戶可控的任何人都能構(gòu)造一個mygame://activity?id999999來嘗試越權(quán)。業(yè)務(wù)層拿到參數(shù)后必須做服務(wù)端校驗。比如活動 ID 是否真實存在、邀請碼是否有效、渠道標識是否合法這些都不能只靠客戶端判斷。我的做法是客戶端解析出參數(shù)后先做格式校驗長度、字符集、白名單然后把關(guān)鍵參數(shù)發(fā)給服務(wù)端做二次驗證驗證通過才執(zhí)行跳轉(zhuǎn)。這樣即使有人偽造鏈接也拿不到實際利益。7.2 參數(shù)投遞的日志埋點線上出問題時最怕的是“用戶說點了沒反應(yīng)但你復(fù)現(xiàn)不了”。所以在原生層和 C# 層都要打日志并且把日志上報到監(jiān)控系統(tǒng)。關(guān)鍵節(jié)點包括原生收到 URL、UnitySendMessage 調(diào)用、C# 收到消息、解析成功/失敗、業(yè)務(wù)層消費。這樣一旦出問題能快速定位是哪個環(huán)節(jié)斷了。7.3 一個真實案例之前有個項目買量落地頁的 Universal Link 在 iOS 15 上正常iOS 16 上有一半用戶點了沒反應(yīng)。排查了兩天才發(fā)現(xiàn)是 AASA 文件里用了舊的paths語法iOS 16 對components語法的支持更嚴格舊語法在某些路徑匹配上行為變了。改成components語法后問題解決。這個坑當時沒有任何報錯只能靠對比測試發(fā)現(xiàn)。8. 關(guān)于擴展方向的一點個人看法這套鏈路跑通之后其實可以復(fù)用到很多場景。比如推送通知的點擊跳轉(zhuǎn)原理和 Deep Link 幾乎一樣只是入口從openURL變成了didReceiveRemoteNotification。再比如 App 內(nèi)的分享回流用戶分享出去的鏈接帶上分享者 ID被分享者點開安裝后自動綁定邀請關(guān)系這就是社交裂變的基礎(chǔ)設(shè)施。我在實際項目里踩過的最大教訓(xùn)是不要等到上線前才測 Deep Link。這個鏈路涉及原生、Unity、服務(wù)端三方任何一方配置有問題都會導(dǎo)致整條鏈路斷掉而且很多問題在開發(fā)機上復(fù)現(xiàn)不了必須用真機、用真實網(wǎng)絡(luò)環(huán)境測。建議在項目中期就把這條鏈路搭起來留足聯(lián)調(diào)時間。最后分享一個小技巧測試 Universal Links 時如果怎么都不生效先把設(shè)備上的 App 刪掉重啟手機重新安裝。iOS 對 Associated Domains 的緩存非常頑固重啟能清掉大部分緩存狀態(tài)。這個土辦法救過我很多次。