﻿# App WebView 如何支持 Universal Link、App Link、Scheme 唤起 App

部分支付渠道在交易过程中可能会将用户重定向到其他应用，如我们已知的电子钱包：印尼（DANA，GOPAY，SHOPEEPAY），韩国（NAVERPAY，PAYCO，TOSS，KAKAOPAY，SSGPAY），马来西亚（TNG，AXIATABOOST），菲律宾（GCASH），新加坡（SHOPEEPAY），泰国（PAOTANGPAY，SHOPEEPAY，TRUEMONEY，LINEPAY），日本（AUPAY，MERPAY，PAYPAY），中国台湾（JKOPAY），中国香港（ALIPAYHK）都是可以直接拉起钱包APP进行支付的。

但是在WebView里面时，跳转流程可能被阻断（报错：ERR_UNKNOWN_URL_SCHEME），进而会造成用户无法顺利完成支付，或被迫进入支付方式降级后的 H5 流程（需要额外在H5页面上输入信息或在钱包应用内完成授权）。

- 推荐方法： 使用用户移动端系统浏览器打开 PayerMax 收银台

- 优势：

     - 系统浏览器不会限制跳转行为

     - 能确保重定向钱包支付方式顺利跳转至对应 App

::: warning  
⚠️ 如您的产品为了简化用户支付流程，需要支持用户在App 中使用 WebView 时能够正确处理重定向（Redirection）拉起上述提及的钱包进行支付，请确保商户 App WebView 已完成下述优化调整（可让AI读取本文档帮您实现）：
:::

## Android

### 原理

![](https://img-cdn-sg.payermax.com/public/20261110-9b64de7d-b273-4e03-98e8-e9d4375baef3.png)

### 使用 Kotlin

1. 自定义 `WebViewClientDeepLinkWebViewClient.kt `

> 下面代码只依赖 Android SDK，可直接复制到工程（注意更新顶部 package）

```Kotlin
package com.example.deeplink

import android.content.ActivityNotFoundException
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.net.Uri
import android.os.Build
import android.os.SystemClock
import android.view.MotionEvent
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient

/**
 * 在 WebView 内转交 App Link、custom scheme 与 intent://。
 * 不枚举目标 App 的域名、包名或 scheme。
 *
 * 判定一次导航算不算「用户意图」：本次导航带手势、是服务端重定向、或落在用户刚操作过的
 * 激活窗口内。https App Link 还要看 handler：只声明 host、没有 path 的按落地页处理，
 * 先留在 WebView；带 path 的直接 App Link 与 custom scheme / intent:// 一样三者都认。
 *
 * 需要激活窗口是因为：很多支付页在加载完成后由脚本自动跳深链（location.href = "xxx://…"），
 * 这一跳没有用户手势，只凭 hasGesture() 会漏掉。LINE Pay 的 liff.line.me 属于落地页，
 * 不会被窗口直接拉起。
 *
 * 子框架（iframe）导航也会回调：Chromium 只把非 http(s) scheme 的子框架导航交给宿主，
 * 这类导航正是 nidlogin:// 一类的深链，必须和主框架一样处理；子框架的 http(s) 交给 WebView。
 *
 * @param requireUserGesture 是否要求用户意图（手势、重定向，或激活窗口内；落地页除外）
 * @param activation 宿主侧激活状态，默认按 10 秒窗口（Chromium 的 kActivationLifespan 是 5 秒）
 * @param onOpenFailed custom scheme / intent:// 无处理者，或 App Link 无法拉起时的宿主兜底
 */
class DeepLinkWebViewClient(
    private val requireUserGesture: Boolean = true,
    private val activation: UserActivationTracker = UserActivationTracker(),
    private val onOpenFailed: (Uri) -> Unit = {},
) : WebViewClient() {

    private var webView: WebView? = null

    /**
     * 宿主代用户发起一次导航（地址栏回车、原生按钮等）。
     *
     * 这类操作的触摸落在原生控件上，WebView 收不到事件，窗口不会开始计时，
     * 所以这里显式开一次用户意图窗口（与触摸同一个时限）。
     */
    fun loadUrlByUser(url: String) {
        activation.onUserNavigation()
        webView?.loadUrl(url)
    }

    override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
        val uri = request.url ?: return false
        val scheme = uri.scheme?.lowercase()

        // 子框架的 http(s) 导航留给 WebView：Chromium 在 native 侧就不把这类导航交给宿主。
        // 能回调到宿主的子框架导航只有非 http(s) scheme，正是深链，必须和主框架一样处理。
        if (!request.isForMainFrame && (scheme == "http" || scheme == "https")) return false

        val byNavigation = request.hasGesture() || request.isRedirect
        // 激活窗口用于脚本异步发起的跳转：custom scheme / intent://，以及带 path 的直接 App Link。
        // 只声明 host、没有 path 的 https App Link 视为落地页，先留在 WebView，不借用窗口。
        val gestureAllowed = !requireUserGesture || byNavigation || activation.hasTransientActivation()

        val handled = when (scheme) {
            "http", "https" -> {
                if (isLandingPageHandler(view.context, uri)) false
                else openAppLink(view, uri, gestureAllowed)
            }
            "intent" -> openIntentUri(view, uri.toString(), gestureAllowed)
            else -> openCustomScheme(view, uri, gestureAllowed)
        }
        // 本次放行如果靠的是激活窗口，用完即销：一次用户操作只换一次外跳
        if (handled && !byNavigation) activation.consumeTransientActivation()
        return handled
    }

    // 1) https：让系统判断是否存在“非浏览器的默认处理者”
    private fun openAppLink(view: WebView, uri: Uri, allowed: Boolean): Boolean {
        if (!allowed) return false
        val context = view.context

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            // 1) 用户已选“允许”：只认默认处理者，直接拉起
            val strict = viewIntent(uri).apply {
                flags = flags or
                    Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER or
                    Intent.FLAG_ACTIVITY_REQUIRE_DEFAULT
            }
            if (startActivitySafely(context, strict)) return true

            // 普通网页没有非浏览器处理者：交给 WebView，不提示、不询问。
            // 这里是 onOpenFailed 误报的关键：只有确实有 App 可处理的链接才继续降级。
            if (!hasNonBrowserHandler(context, uri)) return false

            // 2) 用户选了“每次都询问”：REQUIRE_DEFAULT 会直接抛异常。
            //    去掉它，让系统按设置弹询问/选择，而不是静默留在 WebView。
            val askSystem = viewIntent(uri).apply {
                flags = flags or Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER
            }
            if (startActivitySafely(context, askSystem)) return true

            // 3) 系统仍未处理：交给宿主提示，避免静默留在 WebView
            onOpenFailed(uri)
            return false
        }

        // API < 30：查询候选，再用浏览器启发式筛选
        val intent = viewIntent(uri)
        val best = findBestNonBrowserActivity(context.packageManager, intent) ?: return false
        intent.component = ComponentName(best.activityInfo.packageName, best.activityInfo.name)
        return startActivitySafely(context, intent)
    }

    /** 这个 URL 是否存在非浏览器的处理者（即是否有 App 能接管）。 */
    private fun hasNonBrowserHandler(context: Context, uri: Uri): Boolean {
        val candidates = context.packageManager.queryIntentActivities(
            viewIntent(uri),
            PackageManager.GET_RESOLVED_FILTER,
        )
        return candidates.any { !isBrowserApp(it) }
    }

    /**
     * https App Link 的 handler 只声明 host、没有 path 时，按“落地页”处理：
     * 先留在 WebView 加载页面，由页面的 Open 生成真正的 custom scheme。
     * LINE Pay 的 LIFF 就是这种；TNG 的 cashier 带 path，仍按直接深链处理。
     */
    private fun isLandingPageHandler(context: Context, uri: Uri): Boolean {
        val candidates = context.packageManager.queryIntentActivities(
            viewIntent(uri),
            PackageManager.GET_RESOLVED_FILTER,
        )
        val nonBrowser = candidates.filterNot { isBrowserApp(it) }
        return nonBrowser.isNotEmpty() && nonBrowser.all {
            (it.filter?.countDataPaths() ?: 0) == 0
        }
    }

    private fun findBestNonBrowserActivity(
        packageManager: PackageManager,
        intent: Intent,
    ): ResolveInfo? {
        val candidates = packageManager.queryIntentActivities(
            intent,
            PackageManager.GET_RESOLVED_FILTER,
        )
        val best = when {
            candidates.size == 1 -> candidates[0]
            candidates.size > 1 -> {
                val first = candidates[0]
                val second = candidates[1]
                if (first.priority > second.priority ||
                    first.preferredOrder > second.preferredOrder ||
                    first.isDefault != second.isDefault
                ) {
                    first
                } else {
                    null
                }
            }

            else -> null
        } ?: return null
        return if (isBrowserApp(best)) null else best
    }

    private fun isBrowserApp(info: ResolveInfo): Boolean {
        val filter = info.filter ?: return false
        return filter.hasCategory(Intent.CATEGORY_APP_BROWSER) ||
            (filter.hasDataScheme("http") &&
                filter.hasDataScheme("https") &&
                filter.countDataAuthorities() == 0)
    }

    // 2) custom scheme：直接启动；失败走宿主兜底
    private fun openCustomScheme(view: WebView, uri: Uri, gestureAllowed: Boolean): Boolean {
        val scheme = uri.scheme?.lowercase()
        if (scheme.isNullOrEmpty() || !uri.isAbsolute) return false
        if (scheme in BLOCKED_SCHEMES) return true
        if (scheme in NON_EXTERNAL_SCHEMES) return false
        if (!gestureAllowed) return true

        val intent = viewIntent(uri)
        if (startActivitySafely(view.context, intent)) return true

        onOpenFailed(uri)
        return true
    }

    // 3) intent://：解析、消毒、启动；失败时读取 fallback
    private fun openIntentUri(view: WebView, url: String, gestureAllowed: Boolean): Boolean {
        if (!gestureAllowed) return true
        val intent = try {
            Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
        } catch (e: Exception) {
            onOpenFailed(Uri.parse(url))
            return true
        }

        val fallbackUrl = intent.getStringExtra(EXTRA_BROWSER_FALLBACK_URL)
        intent.removeExtra(EXTRA_BROWSER_FALLBACK_URL)

        // 只保留安全 flags，只允许 BROWSABLE 组件
        intent.flags = intent.flags and ALLOWED_INTENT_FLAGS
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        intent.addCategory(Intent.CATEGORY_BROWSABLE)
        intent.addCategory(Intent.CATEGORY_DEFAULT)
        intent.component = null
        intent.selector = null

        if (startActivitySafely(view.context, intent)) return true

        if (fallbackUrl != null && isHttpUrl(fallbackUrl)) {
            view.post { view.loadUrl(fallbackUrl) }
            return true
        }
        onOpenFailed(Uri.parse(url))
        return true
    }

    private fun viewIntent(uri: Uri): Intent = Intent(Intent.ACTION_VIEW, uri).apply {
        addCategory(Intent.CATEGORY_BROWSABLE)
        addCategory(Intent.CATEGORY_DEFAULT)
        flags = Intent.FLAG_ACTIVITY_NEW_TASK
    }

    private fun startActivitySafely(context: Context, intent: Intent): Boolean = try {
        context.startActivity(intent)
        true
    } catch (e: ActivityNotFoundException) {
        false
    } catch (e: SecurityException) {
        false
    }

    private fun isHttpUrl(url: String): Boolean =
        url.startsWith("https://", ignoreCase = true) ||
            url.startsWith("http://", ignoreCase = true)

    companion object {
        /**
         * 一行接入：装上客户端，并把触摸/按键事件喂给激活跟踪器。
         *
         * ```
         * DeepLinkWebViewClient.attach(webView) { Toast.makeText(this, "没有可处理该链接的 App", Toast.LENGTH_SHORT).show() }
         * ```
         */
        @JvmStatic
        fun attach(webView: WebView, onOpenFailed: (Uri) -> Unit = {}): DeepLinkWebViewClient {
            val activation = UserActivationTracker()
            val client = DeepLinkWebViewClient(activation = activation, onOpenFailed = onOpenFailed)
            client.webView = webView
            webView.webViewClient = client
            webView.setOnTouchListener { _, event ->
                if (event.actionMasked == MotionEvent.ACTION_DOWN) activation.onUserInput()
                false
            }
            webView.setOnKeyListener { _, _, _ ->
                activation.onUserInput()
                false
            }
            return client
        }

        private const val EXTRA_BROWSER_FALLBACK_URL = "browser_fallback_url"

        /** 直接阻止导航的 scheme，避免 WebView 读取本地文件。 */
        private val BLOCKED_SCHEMES = setOf("file", "content")

        /** 不转成外部 Intent 的内部 scheme。 */
        private val NON_EXTERNAL_SCHEMES = setOf(
            "javascript", "data", "blob", "about", "chrome", "resource", "view-source",
        )

        /** 与 Chromium AwContentsClient.ALLOWED_INTENT_FLAGS 一致。 */
        private val ALLOWED_INTENT_FLAGS: Int =
            Intent.FLAG_EXCLUDE_STOPPED_PACKAGES or
                Intent.FLAG_ACTIVITY_CLEAR_TOP or
                Intent.FLAG_ACTIVITY_SINGLE_TOP or
                Intent.FLAG_ACTIVITY_MATCH_EXTERNAL or
                Intent.FLAG_ACTIVITY_NEW_TASK or
                Intent.FLAG_ACTIVITY_MULTIPLE_TASK or
                Intent.FLAG_ACTIVITY_NEW_DOCUMENT or
                Intent.FLAG_ACTIVITY_RETAIN_IN_RECENTS or
                Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT
    }
}

/**
 * 宿主侧的用户激活状态，近似 Chromium 的 sticky / transient 两层语义。
 *
 * 两层状态与浏览器一致：
 * - sticky：这个 WebView 是否被用户操作过（只置位，不会自己复位）；
 * - transient：最近一次操作后的有效窗口，默认 10 秒，可被消费或过期。
 *
 * WebView 不提供读取浏览器内部激活状态的 API，只给每次导航一个 hasGesture() 布尔，
 * 所以这里用宿主侧的触摸/按键事件自己记时间戳——是近似，不是浏览器内部状态的复刻。
 * 窗口取 10 秒：Chromium 自己的激活窗口是 5 秒，而实测「点支付 → 脚本发出深链」需要
 * 2.75–5.36 秒，5 秒会漏掉最慢的一次。
 */
class UserActivationTracker(
    private val windowMs: Long = USER_INTENT_WINDOW_MS,
    private val clock: () -> Long = SystemClock::elapsedRealtime,
) {

    private var expiryAt: Long = 0L
    private var everActivated: Boolean = false

    /** 用户在 WebView 上触摸或按键。 */
    fun onUserInput() {
        expiryAt = clock() + windowMs
        everActivated = true
    }

    /**
     * 宿主代用户发起导航（地址栏回车、原生按钮等）。
     *
     * 与触摸共用同一个时限：这类操作的触摸落在原生控件上，WebView 收不到事件，
     * 只能由宿主显式开窗口。
     */
    fun onUserNavigation() {
        onUserInput()
    }

    fun hasTransientActivation(): Boolean =
        expiryAt != 0L && clock() < expiryAt

    fun hasStickyActivation(): Boolean = everActivated

    /** 一次外跳后立即作废，避免窗口内被连续拉起多个 App。 */
    fun consumeTransientActivation() {
        expiryAt = 0L
    }

    fun reset() {
        expiryAt = 0L
        everActivated = false
    }

    companion object {
        /**
         * 用户意图窗口时长，触摸与宿主发起的导航共用。
         *
         * 取值依据：收银台从点支付到脚本发出深链实测 2.75–5.36 秒；地址栏打开后经重定向
         * 再跳深链的场景实测约 9 秒。取 10 秒覆盖最慢一档，同时把「用户没操作时页面能自动
         * 拉起 App」的时间窗压到最小。
         */
        const val USER_INTENT_WINDOW_MS = 10_000L
    }
}
```

2. 接入 DeepLinkWebViewClient

```Kotlin
// ...
private lateinit var deepLinkClient: DeepLinkWebViewClient

// ...

// 唤起外部 App 的全部逻辑在 DeepLinkWebViewClient 里，这里只做一行接入
deepLinkClient = DeepLinkWebViewClient.attach(webView) { uri ->
    Toast.makeText(this, "无法打开该链接对应的 App", Toast.LENGTH_SHORT).show()
}

// ...

// 如果需要让 WebView 载入的第一个链接就支持唤起 APP，可以调用【非必须】
deepLinkClient.loadUrlByUser(startURL)
```

3. 在 AndroidManifest.xml 里增加对 http(s) 处理器的可见性

```XML

<queries>
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <data android:scheme="https" android:host="*" />
    </intent>
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <data android:scheme="http" android:host="*" />
    </intent>
</queries>
```

### 使用 Java

1. 自定义 `WebViewClientDeepLinkWebViewClient.java`

```Java
package com.example.deeplink;

import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.net.Uri;
import android.os.Build;
import android.os.SystemClock;
import android.view.MotionEvent;
import android.webkit.WebResourceRequest;
import android.webkit.WebView;
import android.webkit.WebViewClient;

import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

/**
 * 在 WebView 内转交 App Link、custom scheme 与 intent://。
 * 不枚举目标 App 的域名、包名或 scheme。
 *
 * 判定一次导航算不算「用户意图」：本次导航带手势、是服务端重定向、或落在用户刚操作过的
 * 激活窗口内。https App Link 还要看 handler：只声明 host、没有 path 的按落地页处理，
 * 先留在 WebView；带 path 的直接 App Link 与 custom scheme / intent:// 一样三者都认。
 *
 * 需要激活窗口是因为：很多支付页在加载完成后由脚本自动跳深链（location.href = "xxx://…"），
 * 这一跳没有用户手势，只凭 hasGesture() 会漏掉。LINE Pay 的 liff.line.me 属于落地页，
 * 不会被窗口直接拉起。
 *
 * 子框架（iframe）导航也会回调：Chromium 只把非 http(s) scheme 的子框架导航交给宿主，
 * 这类导航正是 nidlogin:// 一类的深链，必须和主框架一样处理；子框架的 http(s) 交给 WebView。
 */
public class DeepLinkWebViewClient extends WebViewClient {

    /** custom scheme / intent:// 无处理者，或 App Link 无法拉起时的宿主兜底。 */
    public interface OnOpenFailedListener {
        void onOpenFailed(Uri uri);
    }

    private final boolean requireUserGesture;
    private final UserActivationTracker activation;
    private final OnOpenFailedListener onOpenFailed;
    private WebView webView;

    public DeepLinkWebViewClient() {
        this(true, new UserActivationTracker(), null);
    }

    public DeepLinkWebViewClient(boolean requireUserGesture, OnOpenFailedListener onOpenFailed) {
        this(requireUserGesture, new UserActivationTracker(), onOpenFailed);
    }

    public DeepLinkWebViewClient(boolean requireUserGesture, UserActivationTracker activation,
            OnOpenFailedListener onOpenFailed) {
        this.requireUserGesture = requireUserGesture;
        this.activation = activation;
        this.onOpenFailed = onOpenFailed;
    }

    /**
     * 宿主代用户发起一次导航（地址栏回车、原生按钮等）。
     *
     * 这类操作的触摸落在原生控件上，WebView 收不到事件，窗口不会开始计时，
     * 所以这里显式开一次用户意图窗口（与触摸同一个时限）。
     */
    public void loadUrlByUser(String url) {
        activation.onUserNavigation();
        if (webView != null) {
            webView.loadUrl(url);
        }
    }

    @Override
    public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
        Uri uri = request.getUrl();
        if (uri == null) return false;

        String scheme = uri.getScheme();
        scheme = scheme == null ? "" : scheme.toLowerCase();

        // 子框架的 http(s) 导航留给 WebView：Chromium 在 native 侧就不把这类导航交给宿主。
        // 能回调到宿主的子框架导航只有非 http(s) scheme，正是深链，必须和主框架一样处理。
        if (!request.isForMainFrame() && ("http".equals(scheme) || "https".equals(scheme))) {
            return false;
        }

        boolean byNavigation = request.hasGesture() || request.isRedirect();
        // 激活窗口用于脚本异步发起的跳转：custom scheme / intent://，以及带 path 的直接 App Link。
        // 只声明 host、没有 path 的 https App Link 视为落地页，先留在 WebView，不借用窗口。
        boolean gestureAllowed =
                !requireUserGesture || byNavigation || activation.hasTransientActivation();

        boolean handled;
        if ("http".equals(scheme) || "https".equals(scheme)) {
            handled = isLandingPageHandler(view.getContext(), uri)
                    ? false
                    : openAppLink(view, uri, gestureAllowed);
        } else if ("intent".equals(scheme)) {
            handled = openIntentUri(view, uri.toString(), gestureAllowed);
        } else {
            handled = openCustomScheme(view, uri, gestureAllowed);
        }
        // 本次放行如果靠的是激活窗口，用完即销：一次用户操作只换一次外跳
        if (handled && !byNavigation) {
            activation.consumeTransientActivation();
        }
        return handled;
    }

    // 1) https：让系统判断是否存在“非浏览器的默认处理者”
    private boolean openAppLink(WebView view, Uri uri, boolean allowed) {
        if (!allowed) return false;
        Context context = view.getContext();

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            // 1) 用户已选“允许”：只认默认处理者，直接拉起
            Intent strict = viewIntent(uri);
            strict.setFlags(strict.getFlags()
                    | Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER
                    | Intent.FLAG_ACTIVITY_REQUIRE_DEFAULT);
            if (startActivitySafely(context, strict)) return true;

            // 普通网页没有非浏览器处理者：交给 WebView，不提示、不询问。
            // 这里是 onOpenFailed 误报的关键：只有确实有 App 可处理的链接才继续降级。
            if (!hasNonBrowserHandler(context, uri)) return false;

            // 2) 用户选了“每次都询问”：REQUIRE_DEFAULT 会直接抛异常。
            //    去掉它，让系统按设置弹询问/选择，而不是静默留在 WebView。
            Intent askSystem = viewIntent(uri);
            askSystem.setFlags(askSystem.getFlags() | Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER);
            if (startActivitySafely(context, askSystem)) return true;

            // 3) 系统仍未处理：交给宿主提示，避免静默留在 WebView
            if (onOpenFailed != null) onOpenFailed.onOpenFailed(uri);
            return false;
        }

        // API < 30：查询候选，再用浏览器启发式筛选
        Intent intent = viewIntent(uri);
        ResolveInfo best = findBestNonBrowserActivity(context.getPackageManager(), intent);
        if (best == null) return false;
        intent.setComponent(
                new ComponentName(best.activityInfo.packageName, best.activityInfo.name));
        return startActivitySafely(context, intent);
    }

    /** 这个 URL 是否存在非浏览器的处理者（即是否有 App 能接管）。 */
    private boolean hasNonBrowserHandler(Context context, Uri uri) {
        List<ResolveInfo> candidates = context.getPackageManager()
                .queryIntentActivities(viewIntent(uri), PackageManager.GET_RESOLVED_FILTER);
        for (ResolveInfo info : candidates) {
            if (!isBrowserApp(info)) return true;
        }
        return false;
    }

    /**
     * https App Link 的 handler 只声明 host、没有 path 时，按“落地页”处理：
     * 先留在 WebView 加载页面，由页面的 Open 生成真正的 custom scheme。
     * LINE Pay 的 LIFF 就是这种；TNG 的 cashier 带 path，仍按直接深链处理。
     */
    private boolean isLandingPageHandler(Context context, Uri uri) {
        List<ResolveInfo> candidates = context.getPackageManager()
                .queryIntentActivities(viewIntent(uri), PackageManager.GET_RESOLVED_FILTER);
        boolean hasNonBrowser = false;
        for (ResolveInfo info : candidates) {
            if (!isBrowserApp(info)) {
                hasNonBrowser = true;
                if (info.filter != null && info.filter.countDataPaths() > 0) {
                    return false;
                }
            }
        }
        return hasNonBrowser;
    }

    private ResolveInfo findBestNonBrowserActivity(PackageManager packageManager, Intent intent) {
        List<ResolveInfo> candidates =
                packageManager.queryIntentActivities(intent, PackageManager.GET_RESOLVED_FILTER);
        ResolveInfo best;
        if (candidates.size() == 1) {
            best = candidates.get(0);
        } else if (candidates.size() > 1) {
            ResolveInfo first = candidates.get(0);
            ResolveInfo second = candidates.get(1);
            if (first.priority > second.priority
                    || first.preferredOrder > second.preferredOrder
                    || first.isDefault != second.isDefault) {
                best = first;
            } else {
                best = null;
            }
        } else {
            best = null;
        }
        if (best == null) return null;
        return isBrowserApp(best) ? null : best;
    }

    private boolean isBrowserApp(ResolveInfo info) {
        if (info.filter == null) return false;
        return info.filter.hasCategory(Intent.CATEGORY_APP_BROWSER)
                || (info.filter.hasDataScheme("http")
                && info.filter.hasDataScheme("https")
                && info.filter.countDataAuthorities() == 0);
    }

    // 2) custom scheme：直接启动；失败走宿主兜底
    private boolean openCustomScheme(WebView view, Uri uri, boolean gestureAllowed) {
        String scheme = uri.getScheme();
        scheme = scheme == null ? "" : scheme.toLowerCase();
        if (scheme.isEmpty() || !uri.isAbsolute()) return false;
        if (BLOCKED_SCHEMES.contains(scheme)) return true;
        if (NON_EXTERNAL_SCHEMES.contains(scheme)) return false;
        if (!gestureAllowed) return true;

        if (startActivitySafely(view.getContext(), viewIntent(uri))) return true;
        if (onOpenFailed != null) onOpenFailed.onOpenFailed(uri);
        return true;
    }

    // 3) intent://：解析、消毒、启动；失败时读取 fallback
    private boolean openIntentUri(WebView view, String url, boolean gestureAllowed) {
        if (!gestureAllowed) return true;

        Intent intent;
        try {
            intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
        } catch (Exception e) {
            if (onOpenFailed != null) onOpenFailed.onOpenFailed(Uri.parse(url));
            return true;
        }

        String fallbackUrl = intent.getStringExtra(EXTRA_BROWSER_FALLBACK_URL);
        intent.removeExtra(EXTRA_BROWSER_FALLBACK_URL);

        // 只保留安全 flags，只允许 BROWSABLE 组件
        intent.setFlags(intent.getFlags() & ALLOWED_INTENT_FLAGS);
        intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        intent.addCategory(Intent.CATEGORY_BROWSABLE);
        intent.addCategory(Intent.CATEGORY_DEFAULT);
        intent.setComponent(null);
        intent.setSelector(null);

        if (startActivitySafely(view.getContext(), intent)) return true;

        if (fallbackUrl != null && isHttpUrl(fallbackUrl)) {
            final String target = fallbackUrl;
            view.post(new Runnable() {
                @Override
                public void run() {
                    view.loadUrl(target);
                }
            });
            return true;
        }
        if (onOpenFailed != null) onOpenFailed.onOpenFailed(Uri.parse(url));
        return true;
    }

    private Intent viewIntent(Uri uri) {
        Intent intent = new Intent(Intent.ACTION_VIEW, uri);
        intent.addCategory(Intent.CATEGORY_BROWSABLE);
        intent.addCategory(Intent.CATEGORY_DEFAULT);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        return intent;
    }

    private boolean startActivitySafely(Context context, Intent intent) {
        try {
            context.startActivity(intent);
            return true;
        } catch (ActivityNotFoundException | SecurityException e) {
            return false;
        }
    }

    private boolean isHttpUrl(String url) {
        return url.regionMatches(true, 0, "https://", 0, 8)
                || url.regionMatches(true, 0, "http://", 0, 7);
    }

    private static final String EXTRA_BROWSER_FALLBACK_URL = "browser_fallback_url";

    /** 直接阻止导航的 scheme，避免 WebView 读取本地文件。 */
    private static final Set<String> BLOCKED_SCHEMES =
            new HashSet<>(Arrays.asList("file", "content"));

    /** 不转成外部 Intent 的内部 scheme。 */
    private static final Set<String> NON_EXTERNAL_SCHEMES =
            new HashSet<>(Arrays.asList(
                    "javascript", "data", "blob", "about", "chrome", "resource", "view-source"));

    /** 与 Chromium AwContentsClient.ALLOWED_INTENT_FLAGS 一致。 */
    private static final int ALLOWED_INTENT_FLAGS =
            Intent.FLAG_EXCLUDE_STOPPED_PACKAGES
                    | Intent.FLAG_ACTIVITY_CLEAR_TOP
                    | Intent.FLAG_ACTIVITY_SINGLE_TOP
                    | Intent.FLAG_ACTIVITY_MATCH_EXTERNAL
                    | Intent.FLAG_ACTIVITY_NEW_TASK
                    | Intent.FLAG_ACTIVITY_MULTIPLE_TASK
                    | Intent.FLAG_ACTIVITY_NEW_DOCUMENT
                    | Intent.FLAG_ACTIVITY_RETAIN_IN_RECENTS
                    | Intent.FLAG_ACTIVITY_LAUNCH_ADJACENT;

    /**
     * 一行接入：装好客户端，并把触摸/按键喂给激活跟踪器。
     */
    public static DeepLinkWebViewClient attach(WebView webView, OnOpenFailedListener onOpenFailed) {
        UserActivationTracker activation = new UserActivationTracker();
        DeepLinkWebViewClient client = new DeepLinkWebViewClient(true, activation, onOpenFailed);
        client.webView = webView;
        webView.setWebViewClient(client);
        webView.setOnTouchListener((view, event) -> {
            if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
                activation.onUserInput();
            }
            return false;
        });
        webView.setOnKeyListener((view, keyCode, event) -> {
            activation.onUserInput();
            return false;
        });
        return client;
    }

    /**
     * 宿主侧的用户激活状态，近似 Chromium 的 sticky / transient 两层语义。
     *
     * 两层状态与浏览器一致：sticky 表示这个 WebView 是否被用户操作过（只置位，不会自己
     * 复位）；transient 表示最近一次操作后的有效窗口，默认 10 秒，可被消费或过期。
     *
     * WebView 不提供读取浏览器内部激活状态的 API，只给每次导航一个 hasGesture() 布尔，
     * 所以这里用宿主侧的触摸/按键事件自己记时间戳——是近似，不是浏览器内部状态的复刻。
     * 窗口取 10 秒：Chromium 自己的激活窗口是 5 秒，而实测「点支付 → 脚本发出深链」需要
     * 2.75–5.36 秒，5 秒会漏掉最慢的一次。
     */
    public static final class UserActivationTracker {

        /**
         * 用户意图窗口时长，触摸与宿主发起的导航共用。
         *
         * 取值依据：收银台从点支付到脚本发出深链实测 2.75–5.36 秒；地址栏打开后经重定向
         * 再跳深链的场景实测约 9 秒。取 10 秒覆盖最慢一档，同时把「用户没操作时页面能自动
         * 拉起 App」的时间窗压到最小。
         */
        public static final long USER_INTENT_WINDOW_MS = 10000L;

        /** 时钟抽象：生产用 SystemClock，单测注入可控时钟。 */
        public interface Clock {
            long now();
        }

        private final long windowMs;
        private final Clock clock;
        private long expiryAt;
        private boolean everActivated;

        public UserActivationTracker() {
            this(USER_INTENT_WINDOW_MS, SystemClock::elapsedRealtime);
        }

        public UserActivationTracker(long windowMs) {
            this(windowMs, SystemClock::elapsedRealtime);
        }

        public UserActivationTracker(long windowMs, Clock clock) {
            this.windowMs = windowMs;
            this.clock = clock;
        }

        /** 用户在 WebView 上触摸或按键。 */
        public void onUserInput() {
            expiryAt = clock.now() + windowMs;
            everActivated = true;
        }

        /**
         * 宿主代用户发起导航（地址栏回车、原生按钮等）。
         *
         * 与触摸共用同一个时限：这类操作的触摸落在原生控件上，WebView 收不到事件，
         * 只能由宿主显式开窗口。
         */
        public void onUserNavigation() {
            onUserInput();
        }

        public boolean hasTransientActivation() {
            return expiryAt != 0L && clock.now() < expiryAt;
        }

        public boolean hasStickyActivation() {
            return everActivated;
        }

        /** 一次外跳后立即作废，避免窗口内被连续拉起多个 App。 */
        public void consumeTransientActivation() {
            expiryAt = 0L;
        }

        public void reset() {
            expiryAt = 0L;
            everActivated = false;
        }
    }
}
```

2. 接入 DeepLinkWebViewClient

```Java
// ...
private DeepLinkWebViewClient deepLinkClient;

// ...

// 唤起外部 App 的全部逻辑在 DeepLinkWebViewClient 里，这里只做一行接入
deepLinkClient = DeepLinkWebViewClient.attach(webView,
        uri -> Toast.makeText(this, "无法打开该链接对应的 App", Toast.LENGTH_SHORT).show());

// ...

// 如果需要让 WebView 载入的第一个链接就支持唤起 APP，可以调用【非必须】
deepLinkClient.loadUrlByUser(startURL)
```

3. 在 AndroidManifest.xml 里增加对 http(s) 处理器的可见性（同 Kotlin）

```XML

<queries>
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <data android:scheme="https" android:host="*" />
    </intent>
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <data android:scheme="http" android:host="*" />
    </intent>
</queries>
```

## IOS

### 原理

![](https://img-cdn-sg.payermax.com/public/20261110-4451dac9-236e-4966-9a45-e7d073450960.png)

### 使用 Swift

1. 实现 `WKNavigationDelegate`

```Swift
//
//  WebViewDeepLink.swift
//  WKWebView 唤起外部 App 的单文件 SDK（iOS 15+）
//
//  普通 http(s) 交给 WebKit；`loadUrlByUser` 的 https 会先尝试 Universal Link。
//  custom scheme 取消导航后调用 UIApplication.open。
//  SDK 不输出日志，只在打开失败时回调 `onOpenFailed`。
//
//  接入：
//  let client = DeepLinkWebViewClient.attach(to: webView)
//  client.loadUrlByUser(url)

import Foundation
import UIKit
import WebKit

// MARK: - 配置

/// 外跳策略。所有字段都有默认值。
struct DeepLinkOptions {
    /// 无用户交互时是否允许外跳。
    var requireUserGesture = true

    /// iframe 没有点击时是否允许自动外跳。
    var allowAutomaticSubframeDeepLinks = false

    /// `loadUrlByUser` 的 https 是否先用 `universalLinksOnly` 预检。
    var openUniversalLinksFromUserLoads = true

    /// 直接取消导航的 scheme；`file` 先走本机页面放行规则。
    var blockedSchemes: Set<String> = ["file", "content", "intent"]

    /// 交还 WebView 的 scheme。
    var webViewOwnedSchemes: Set<String> = [
        "about", "data", "blob", "javascript", "view-source",
    ]

    /// 业务否决外跳；返回 false 取消导航。
    var shouldOpenExternalURL: ((URL) -> Bool)?

    /// 打开失败时是否自动搜索 App Store；系统 scheme 不搜索。
    var autoOpenAppStoreOnFailure = true

    /// 打开失败回调；自动搜索也失败，或关闭自动搜索时调用。
    var onOpenFailed: ((URL) -> Void)?
}

// MARK: - 用户交互状态

/// WebView 级用户交互状态：粘性标记，不自动过期。
final class UserInteractionState {
    private(set) var hasUserInteraction = false

    /// `loadUrlByUser` 是否授权过后续导航。
    private(set) var hasUserInitiatedLoad = false

    func markUserInteraction() {
        hasUserInteraction = true
    }

    func markUserInitiatedLoad() {
        hasUserInitiatedLoad = true
    }

    /// 加载新的内置页面时调用，避免上一页的交互放行新页面。
    func reset() {
        hasUserInteraction = false
        hasUserInitiatedLoad = false
    }
}

// MARK: - 导航上下文

/// 一次导航的输入快照。
struct DeepLinkNavigationContext {
    let url: URL

    /// `targetFrame == nil` 的新窗口按主框架处理。
    let isMainFrame: Bool

    let navigationType: WKNavigationType

    /// 导航前的 `webView.url`；首个宿主加载时为 nil。
    let currentURL: URL?

    var hasNavigationGesture: Bool {
        navigationType == .linkActivated || navigationType == .formSubmitted
    }

    init(url: URL,
         isMainFrame: Bool = true,
         navigationType: WKNavigationType = .other,
         currentURL: URL? = nil) {
        self.url = url
        self.isMainFrame = isMainFrame
        self.navigationType = navigationType
        self.currentURL = currentURL
    }
}

// MARK: - 分类结果

/// 分类结果：交给决策引擎执行的动作。
enum DeepLinkAction: Equatable {
    /// 放行，交还 WebView。
    case allow

    /// 取消导航。
    case block

    /// custom scheme / 系统 scheme 外跳。
    case openExternal(URL)

    /// 用 `universalLinksOnly` 预检一次 https：成功则取消导航交给 App，失败则放行 WebView。
    case checkUniversalLink(URL)
}

// MARK: - 分类器

/// 纯逻辑分类器：只看输入与配置，不做 I/O。
final class DeepLinkClassifier {
    private let options: DeepLinkOptions
    private let interactionState: UserInteractionState
    private let blockedSchemes: Set<String>
    private let webViewOwnedSchemes: Set<String>

    init(options: DeepLinkOptions, interactionState: UserInteractionState) {
        self.options = options
        self.interactionState = interactionState
        // 配置里的 scheme 统一转小写，避免大小写导致漏判。
        self.blockedSchemes = Set(options.blockedSchemes.map { $0.lowercased() })
        self.webViewOwnedSchemes = Set(options.webViewOwnedSchemes.map { $0.lowercased() })
    }

    func classify(_ context: DeepLinkNavigationContext) -> DeepLinkAction {
        guard let scheme = context.url.scheme?.lowercased(), !scheme.isEmpty else {
            return .allow
        }

        // file 先走本机页面放行规则。
        if scheme == "file", isLocalFileNavigation(context) {
            return .allow
        }

        if blockedSchemes.contains(scheme) {
            return .block
        }

        // https 仅在用户加载后、跨 host 的主框架导航上预检 Universal Link。
        if scheme == "http" {
            return .allow
        }
        if scheme == "https" {
            return shouldCheckUniversalLink(context) ? .checkUniversalLink(context.url) : .allow
        }

        if webViewOwnedSchemes.contains(scheme) {
            return .allow
        }

        if !context.isMainFrame,
           !context.hasNavigationGesture,
           !options.allowAutomaticSubframeDeepLinks {
            return .block
        }

        if options.requireUserGesture,
           !context.hasNavigationGesture,
           !interactionState.hasUserInteraction {
            return .block
        }

        if let shouldOpen = options.shouldOpenExternalURL, !shouldOpen(context.url) {
            return .block
        }

        return .openExternal(context.url)
    }

    /// 仅处理用户加载后、主框架、`.other`、跨 host 的 https；
    /// 首次 URL 由 `loadUrlByUser` 自己预检。
    private func shouldCheckUniversalLink(_ context: DeepLinkNavigationContext) -> Bool {
        guard options.openUniversalLinksFromUserLoads else { return false }
        guard context.isMainFrame else { return false }
        guard context.navigationType == .other else { return false }
        guard interactionState.hasUserInitiatedLoad else { return false }
        // currentURL 为 nil 或没有 host 时不做跨 host 判断。
        guard let currentHost = context.currentURL?.host, !currentHost.isEmpty,
              let targetHost = context.url.host, !targetHost.isEmpty else {
            return false
        }
        return currentHost.caseInsensitiveCompare(targetHost) != .orderedSame
    }

    /// 本机页面放行：宿主首屏加载，或当前 file 页面同目录内的导航。
    private func isLocalFileNavigation(_ context: DeepLinkNavigationContext) -> Bool {
        guard context.url.isFileURL else { return false }

        if context.currentURL == nil, context.navigationType == .other {
            return true
        }

        guard let currentURL = context.currentURL, currentURL.isFileURL else { return false }
        let currentPath = currentURL.standardizedFileURL.path
        let targetPath = context.url.standardizedFileURL.path
        // loadFileURL 回调时 webView.url 可能已是目标 URL。
        if targetPath == currentPath {
            return true
        }
        // 目录前缀必须带分隔符，避免 `/a/bc` 误判成 `/a/b` 的子路径。
        let currentDirectory = currentURL.standardizedFileURL.deletingLastPathComponent().path
        let directoryPrefix = currentDirectory.hasSuffix("/") ? currentDirectory : currentDirectory + "/"
        return targetPath.hasPrefix(directoryPrefix)
    }
}

// MARK: - 打开 URL

/// 打开 URL 的能力抽象，便于单测注入。
protocol URLOpening {
    /// `universalLinksOnly` 为 true 时只打开已安装 App 的有效 Universal Link。
    /// `completion` 表示系统是否接受。
    func open(_ url: URL,
              universalLinksOnly: Bool,
              completion: @escaping (Bool) -> Void)
}

/// 生产环境实现。
final class UIApplicationURLOpener: URLOpening {
    func open(_ url: URL,
              universalLinksOnly: Bool,
              completion: @escaping (Bool) -> Void) {
        let work = {
            let options: [UIApplication.OpenExternalURLOptionsKey: Any] =
                universalLinksOnly ? [.universalLinksOnly: true] : [:]
            UIApplication.shared.open(url, options: options, completionHandler: completion)
        }
        if Thread.isMainThread {
            work()
        } else {
            DispatchQueue.main.async(execute: work)
        }
    }
}

/// App Store 搜索工具：itms-apps 主，https 备。
final class AppStoreSearch {
    private let opener: URLOpening

    init(opener: URLOpening = UIApplicationURLOpener()) {
        self.opener = opener
    }

    func search(term: String, completion: ((Bool) -> Void)? = nil) {
        let urls = Self.searchURLs(term: term)
        guard !urls.isEmpty else {
            completion?(false)
            return
        }
        open(urls, index: 0, completion: completion)
    }

    static func searchURLs(term: String) -> [URL] {
        func makeURL(scheme: String, host: String) -> URL? {
            var components = URLComponents()
            components.scheme = scheme
            components.host = host
            components.path = "/search"
            components.queryItems = [URLQueryItem(name: "term", value: term)]
            return components.url
        }
        return [
            makeURL(scheme: "itms-apps", host: "itunes.apple.com"),
            makeURL(scheme: "https", host: "apps.apple.com"),
        ].compactMap { $0 }
    }

    private func open(_ urls: [URL], index: Int, completion: ((Bool) -> Void)?) {
        guard index < urls.count else {
            completion?(false)
            return
        }
        opener.open(urls[index], universalLinksOnly: false) { [weak self] success in
            if success {
                completion?(true)
            } else {
                self?.open(urls, index: index + 1, completion: completion)
            }
        }
    }
}

// MARK: - 决策引擎

/// 执行分类结果：放行、取消，或外跳。
final class DeepLinkDecisionEngine {
    let options: DeepLinkOptions
    let classifier: DeepLinkClassifier
    let opener: URLOpening
    let interactionState: UserInteractionState
    private let appStoreSearch: AppStoreSearch

    init(options: DeepLinkOptions = DeepLinkOptions(),
         interactionState: UserInteractionState? = nil,
         opener: URLOpening = UIApplicationURLOpener(),
         classifier: DeepLinkClassifier? = nil) {
        let state = interactionState ?? UserInteractionState()
        self.options = options
        self.interactionState = state
        self.opener = opener
        self.appStoreSearch = AppStoreSearch(opener: opener)
        self.classifier = classifier ?? DeepLinkClassifier(options: options, interactionState: state)
    }

    /// `loadUrlByUser` 首次 URL 的预检；true 表示 App 已接管，不要再加载。
    func checkUniversalLinkIfNeeded(_ url: URL, completion: @escaping (Bool) -> Void) {
        guard options.openUniversalLinksFromUserLoads,
              url.scheme?.lowercased() == "https" else {
            completion(false)
            return
        }
        opener.open(url, universalLinksOnly: true) { success in
            completion(success)
        }
    }

    /// true 放行，false 取消；外跳时先回调 false，再打开 App。
    func handle(_ context: DeepLinkNavigationContext,
                completion: @escaping (Bool) -> Void) {
        switch classifier.classify(context) {
        case .allow:
            completion(true)

        case .block:
            completion(false)

        case .openExternal(let url):
            // 先取消 WebView 导航，再交给系统打开。
            completion(false)
            opener.open(url, universalLinksOnly: false) { [weak self] success in
                guard let self, !success else { return }
                self.handleOpenFailure(url)
            }

        case .checkUniversalLink(let url):
            opener.open(url, universalLinksOnly: true) { success in
                completion(!success)
            }
        }
    }

    /// 打开失败：默认搜索 App Store；系统 scheme 或关闭搜索时回调宿主。
    private func handleOpenFailure(_ url: URL) {
        let scheme = url.scheme?.lowercased() ?? ""
        let systemSchemes: Set<String> = [
            "mailto", "tel", "sms", "facetime", "maps", "itms", "itms-apps",
        ]
        guard options.autoOpenAppStoreOnFailure,
              !scheme.isEmpty,
              !systemSchemes.contains(scheme) else {
            options.onOpenFailed?(url)
            return
        }

        appStoreSearch.search(term: scheme) { [weak self] success in
            if !success {
                self?.options.onOpenFailed?(url)
            }
        }
    }
}

// MARK: - WKWebView 客户端

/// WKWebView 外跳客户端：接管 delegate，并安装交互手势。
final class DeepLinkWebViewClient: NSObject {
    private weak var webView: WKWebView?
    let interactionState: UserInteractionState
    let engine: DeepLinkDecisionEngine

    /// 宿主显式发起的 file 加载（`loadLocalFile`），一次性放行。
    private var pendingHostFileNavigation: URL?
    private var interactionRecognizers: [UIGestureRecognizer] = []

    /// 返回值需要被宿主强引用。
    @discardableResult
    static func attach(to webView: WKWebView,
                       options: DeepLinkOptions = DeepLinkOptions()) -> DeepLinkWebViewClient {
        let state = UserInteractionState()
        let engine = DeepLinkDecisionEngine(options: options, interactionState: state)
        let client = DeepLinkWebViewClient(webView: webView,
                                           interactionState: state,
                                           engine: engine)
        webView.navigationDelegate = client
        webView.uiDelegate = client
        client.installInteractionRecognizers(on: webView)
        return client
    }

    init(webView: WKWebView,
         interactionState: UserInteractionState,
         engine: DeepLinkDecisionEngine) {
        self.webView = webView
        self.interactionState = interactionState
        self.engine = engine
        super.init()
    }

    deinit {
        // 手势识别器不持有 target，client 释放前要摘掉，避免回调到已释放对象。
        for recognizer in interactionRecognizers {
            webView?.removeGestureRecognizer(recognizer)
        }
    }

    // MARK: 宿主入口

    /// 宿主代用户发起导航。
    ///
    /// https 先尝试 Universal Link，失败才加载；custom scheme 不先加载，直接外跳。
    func loadUrlByUser(_ url: URL) {
        interactionState.markUserInteraction()
        interactionState.markUserInitiatedLoad()

        let scheme = url.scheme?.lowercased() ?? ""
        if scheme == "http" || scheme == "https" {
            engine.checkUniversalLinkIfNeeded(url) { [weak self] opened in
                guard !opened, let self, let webView = self.webView else { return }
                webView.load(URLRequest(url: url))
            }
            return
        }

        // 非 http(s) 不先加载页面，直接给引擎一次决策，避免多一次 WebKit 回调。
        let context = DeepLinkNavigationContext(url: url,
                                                isMainFrame: true,
                                                navigationType: .other,
                                                currentURL: webView?.url)
        engine.handle(context) { [weak self] allow in
            guard allow, let self, let webView = self.webView else { return }
            webView.load(URLRequest(url: url))
        }
    }

    /// 宿主加载本地页面；先重置交互状态，视为新的浏览上下文。
    func loadLocalFile(_ url: URL, allowingReadAccessTo readAccessURL: URL) {
        interactionState.reset()
        pendingHostFileNavigation = url.standardizedFileURL
        webView?.loadFileURL(url, allowingReadAccessTo: readAccessURL)
    }

    // MARK: 用户交互手势

    private func installInteractionRecognizers(on webView: WKWebView) {
        let tap = UITapGestureRecognizer(target: self, action: #selector(handleUserTap))
        tap.cancelsTouchesInView = false
        tap.delegate = self
        webView.addGestureRecognizer(tap)

        // 触摸开始就置位，补齐 JS location.href 等没有 linkActivated 的路径。
        let longPress = UILongPressGestureRecognizer(target: self, action: #selector(handleUserLongPress(_:)))
        longPress.minimumPressDuration = 0.01
        longPress.cancelsTouchesInView = false
        longPress.delegate = self
        webView.addGestureRecognizer(longPress)

        interactionRecognizers = [tap, longPress]
    }

    @objc private func handleUserTap() {
        interactionState.markUserInteraction()
    }

    @objc private func handleUserLongPress(_ recognizer: UILongPressGestureRecognizer) {
        // 只认开始，避免抬手时重复置位。
        guard recognizer.state == .began else { return }
        interactionState.markUserInteraction()
    }

    // MARK: 组装上下文

    private func makeContext(from navigationAction: WKNavigationAction,
                             in webView: WKWebView) -> DeepLinkNavigationContext? {
        guard let url = navigationAction.request.url else { return nil }
        // targetFrame == nil 表示 window.open / target=_blank，按主框架处理。
        let isMainFrame = navigationAction.targetFrame?.isMainFrame ?? true
        return DeepLinkNavigationContext(url: url,
                                         isMainFrame: isMainFrame,
                                         navigationType: navigationAction.navigationType,
                                         currentURL: webView.url)
    }
}

// MARK: - WKNavigationDelegate

extension DeepLinkWebViewClient: WKNavigationDelegate {
    func webView(_ webView: WKWebView,
                 decidePolicyFor navigationAction: WKNavigationAction,
                 decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        guard let url = navigationAction.request.url else {
            decisionHandler(.allow)
            return
        }

        // 宿主显式加载的本地页面：URL 匹配才消费标记。
        if let pending = pendingHostFileNavigation,
           pending == url.standardizedFileURL {
            pendingHostFileNavigation = nil
            decisionHandler(.allow)
            return
        }

        if navigationAction.navigationType == .linkActivated
            || navigationAction.navigationType == .formSubmitted {
            interactionState.markUserInteraction()
        }

        guard let context = makeContext(from: navigationAction, in: webView) else {
            decisionHandler(.allow)
            return
        }

        // UL 预检是异步的，decisionHandler 必须恰好调用一次。
        var hasDecided = false
        let decide: (WKNavigationActionPolicy) -> Void = { policy in
            guard !hasDecided else { return }
            hasDecided = true
            decisionHandler(policy)
        }

        engine.handle(context) { allow in
            decide(allow ? .allow : .cancel)
        }
    }
}

// MARK: - WKUIDelegate

extension DeepLinkWebViewClient: WKUIDelegate {
    func webView(_ webView: WKWebView,
                 createWebViewWith configuration: WKWebViewConfiguration,
                 for navigationAction: WKNavigationAction,
                 windowFeatures: WKWindowFeatures) -> WKWebView? {
        // JS window.open 只回调这里，不回调 decidePolicyFor。
        guard let url = navigationAction.request.url else { return nil }
        let scheme = url.scheme?.lowercased() ?? ""

        if scheme == "http" || scheme == "https" {
            webView.load(navigationAction.request)
            return nil
        }

        guard let context = makeContext(from: navigationAction, in: webView) else { return nil }
        engine.handle(context) { _ in }
        return nil
    }
}

// MARK: - UIGestureRecognizerDelegate

extension DeepLinkWebViewClient: UIGestureRecognizerDelegate {
    /// 与 WebKit 内部手势同时识别，避免观察手势抢掉点击。
    func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer,
                           shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
        true
    }
}

```

2. 在 ViewController 中接入

```Swift
/// ...
private var deepLinkClient: DeepLinkWebViewClient?

// ...

deepLinkClient = DeepLinkWebViewClient.attach(to: webView)

// ...

// 如果需要让 WebView 载入的第一个链接就支持唤起 APP，可以调用【非必须】
deepLinkClient.loadUrlByUser(url)

// ...
```

### 使用 Objective-C

1. 实现 `WKNavigationDelegate`

::: code-group

```objc [WebViewDeepLink.h]
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
#import <WebKit/WebKit.h>

NS_ASSUME_NONNULL_BEGIN

/// WebViewDeepLink —— WKWebView 唤起外部 App 的单文件 SDK（iOS 15+，ARC）。
///
/// ```objc
/// DeepLinkOptions *options = [DeepLinkOptions defaultOptions];
/// self.client = [DeepLinkWebViewClient attachToWebView:self.webView options:options];
/// [self.client loadURLByUser:url];
/// ```
///
/// 普通 http(s) 交给 WebKit；`loadURLByUser:` 的 https 先尝试 Universal Link。
/// SDK 不输出日志，只在打开失败时回调 `onOpenFailed`。行为与配置见 README。

#pragma mark - 配置

/// 业务侧对外跳的否决钩子；返回 NO 表示取消导航、不打开外部 App。
typedef BOOL (^ShouldOpenExternalURLBlock)(NSURL *URL);

/// 外部 App 打开失败（系统没有处理者）时的宿主回调。
typedef void (^OpenFailedBlock)(NSURL *URL);

/// 外跳策略配置。所有字段都有默认值，业务按需覆盖。
@interface DeepLinkOptions : NSObject

/// 无用户交互时是否允许外跳，默认 YES。
@property (nonatomic, assign) BOOL requireUserGesture;

/// 是否允许 iframe 在没有点击时自动外跳，默认 NO。
@property (nonatomic, assign) BOOL allowAutomaticSubframeDeepLinks;

/// loadURLByUser 之后，后续跨 host 的 https 是否做 UL 检查，默认 YES。
@property (nonatomic, assign) BOOL openUniversalLinksFromUserLoads;

/// 直接取消导航的 scheme，默认 file / content / intent。
@property (nonatomic, copy) NSSet<NSString *> *blockedSchemes;

/// 交还 WebView 的 scheme，默认 about / data / blob / javascript / view-source。
@property (nonatomic, copy) NSSet<NSString *> *webViewOwnedSchemes;

/// 业务否决外跳，默认 nil。
@property (nonatomic, copy, nullable) ShouldOpenExternalURLBlock shouldOpenExternalURL;

/// 打开失败时是否自动搜索 App Store，默认 YES；系统 scheme 不搜索。
@property (nonatomic, assign) BOOL autoOpenAppStoreOnFailure;

/// 打开失败回调；自动 App Store 也失败，或关闭自动搜索时调用。
@property (nonatomic, copy, nullable) OpenFailedBlock onOpenFailed;

/// 创建带默认值的配置。
+ (instancetype)defaultOptions;

@end

#pragma mark - 用户交互状态

/// WebView 级用户交互状态：粘性标记，不自动过期。
@interface UserInteractionState : NSObject

@property (nonatomic, assign, readonly) BOOL hasUserInteraction;

/// 是否由 loadURLByUser 授权了后续导航。
@property (nonatomic, assign, readonly) BOOL hasUserInitiatedLoad;

- (void)markUserInteraction;

- (void)markUserInitiatedLoad;

/// 加载新的内置页面时调用。
- (void)reset;

@end

#pragma mark - 分类

/// 分类结果对应的处理动作。
typedef NS_ENUM(NSInteger, DeepLinkAction) {
    /// 交给 WebView 继续加载。
    DeepLinkActionAllow = 0,
    /// 取消导航。
    DeepLinkActionCancel = 1,
    /// 取消导航并打开外部 App。
    DeepLinkActionOpenExternal = 2,
    /// 用 universalLinksOnly 向系统检查 https 是否为已关联的 Universal Link。
    DeepLinkActionCheckUniversalLink = 3,
};

/// 一次导航的分类输入。
@interface DeepLinkNavigationContext : NSObject

@property (nonatomic, copy, nullable) NSURL *URL;
/// targetFrame == nil 的新窗口按主框架处理。
@property (nonatomic, assign) BOOL isMainFrame;
@property (nonatomic, assign) WKNavigationType navigationType;
/// 导航前的 webView.URL；首个宿主加载时为 nil。
@property (nonatomic, copy, nullable) NSURL *currentURL;

@property (nonatomic, assign, readonly) BOOL hasNavigationGesture;

+ (instancetype)contextWithURL:(nullable NSURL *)URL
                   isMainFrame:(BOOL)isMainFrame
                navigationType:(WKNavigationType)navigationType
                    currentURL:(nullable NSURL *)currentURL;

- (instancetype)initWithURL:(nullable NSURL *)URL
                isMainFrame:(BOOL)isMainFrame
             navigationType:(WKNavigationType)navigationType
                 currentURL:(nullable NSURL *)currentURL;

@end

/// 分类器的输出。决策引擎按 action 执行。
@interface DeepLinkOutcome : NSObject

@property (nonatomic, readonly) DeepLinkAction action;
@property (nonatomic, readonly, copy, nullable) NSURL *URL;

+ (instancetype)allowOutcomeWithURL:(nullable NSURL *)URL;

+ (instancetype)cancelOutcomeWithURL:(nullable NSURL *)URL;

+ (instancetype)openExternalOutcomeWithURL:(NSURL *)URL;

+ (instancetype)checkUniversalLinkOutcomeWithURL:(NSURL *)URL;

@end

/// 纯逻辑分类器：只看输入与配置，不做 I/O。
@interface DeepLinkClassifier : NSObject

@property (nonatomic, strong, readonly) DeepLinkOptions *options;
@property (nonatomic, strong, readonly) UserInteractionState *interactionState;

- (instancetype)initWithOptions:(DeepLinkOptions *)options
               interactionState:(UserInteractionState *)interactionState;

- (instancetype)initWithOptions:(DeepLinkOptions *)options;

- (DeepLinkOutcome *)classifyContext:(DeepLinkNavigationContext *)context;

+ (BOOL)isNavigationGesture:(WKNavigationType)navigationType;

@end

#pragma mark - 决策执行

/// 打开 URL 的能力；单测可注入假实现。
@protocol URLOpening <NSObject>

/// universalLinksOnly 为 YES 时只打开已安装 App 的有效 Universal Link。
- (void)openURL:(NSURL *)URL
universalLinksOnly:(BOOL)universalLinksOnly
completionHandler:(void (^)(BOOL success))completionHandler;

@end

/// 基于 UIApplication 的默认实现。
@interface UIApplicationURLOpener : NSObject <URLOpening>
@end

/// App Store 搜索工具：itms-apps 主，https 备。
/// 引擎在打开失败时默认自动调用；宿主也可以主动调用。
@interface AppStoreSearch : NSObject

@property (nonatomic, strong, readonly) id<URLOpening> opener;

- (instancetype)initWithOpener:(id<URLOpening>)opener;

- (void)searchForScheme:(NSString *)scheme;

- (void)searchForScheme:(NSString *)scheme completion:(nullable void (^)(BOOL success))completion;

@end

/// 决策结果回调；引擎只返回最终 allow / cancel。
typedef void (^DeepLinkDecisionCompletion)(DeepLinkOutcome *outcome);

/// 执行分类结果：放行、取消、外跳与失败通知。
@interface DeepLinkDecisionEngine : NSObject

@property (nonatomic, strong, readonly) DeepLinkOptions *options;
@property (nonatomic, strong, readonly) UserInteractionState *interactionState;
@property (nonatomic, strong, readonly) DeepLinkClassifier *classifier;
@property (nonatomic, strong, readonly) id<URLOpening> opener;
@property (nonatomic, strong, readonly) AppStoreSearch *appStoreSearch;

- (instancetype)initWithOptions:(DeepLinkOptions *)options
               interactionState:(UserInteractionState *)interactionState
                          opener:(id<URLOpening>)opener;

- (instancetype)initWithOptions:(DeepLinkOptions *)options
                         opener:(id<URLOpening>)opener;

/// 外跳时先回调 cancel，再打开 App；失败时默认自动搜索 App Store。
- (void)handleNavigationContext:(DeepLinkNavigationContext *)context
                decisionHandler:(nullable DeepLinkDecisionCompletion)decisionHandler;

/// loadURLByUser 首次 URL 的预检。
/// 返回 YES 表示已发起检查；NO 表示未检查，调用方应直接加载。
- (BOOL)checkUniversalLinkIfNeeded:(NSURL *)URL
                        completion:(nullable void (^)(BOOL opened))completion;

@end

#pragma mark - WebView 接入

/// WKWebView 深链客户端：接管 delegate，并安装交互手势。
/// 宿主需要强引用返回值，否则 delegate 会失效。
@interface DeepLinkWebViewClient : NSObject <WKNavigationDelegate, WKUIDelegate>

@property (nonatomic, strong, readonly) DeepLinkOptions *options;
@property (nonatomic, strong, readonly) UserInteractionState *interactionState;
@property (nonatomic, strong, readonly) DeepLinkDecisionEngine *engine;
/// 弱引用，避免循环。
@property (nonatomic, weak, readonly, nullable) WKWebView *webView;

+ (instancetype)attachToWebView:(WKWebView *)webView;

+ (instancetype)attachToWebView:(WKWebView *)webView
                        options:(DeepLinkOptions *)options;

/// 宿主代用户发起导航：https 先尝试 Universal Link，失败才加载；
/// custom scheme 不先加载，直接外跳。
- (void)loadURLByUser:(NSURL *)URL;

/// 宿主加载本地页面；先重置交互状态，并为本导航开一次性 file 放行。
- (void)loadLocalFileURL:(NSURL *)URL allowingReadAccessToURL:(NSURL *)readAccessURL;

@end

NS_ASSUME_NONNULL_END
```

```objc [WebViewDeepLink.m]
#import "WebViewDeepLink.h"

static NSString *const InteractionRecognizerName = @"webview.deeplink.interaction";

#pragma mark - 配置

@implementation DeepLinkOptions

- (instancetype)init {
    self = [super init];
    if (self) {
        _requireUserGesture = YES;
        _allowAutomaticSubframeDeepLinks = NO;
        _openUniversalLinksFromUserLoads = YES;
        _autoOpenAppStoreOnFailure = YES;
        _blockedSchemes = [NSSet setWithObjects:@"file", @"content", @"intent", nil];
        _webViewOwnedSchemes = [NSSet setWithObjects:@"about", @"data", @"blob",
                                @"javascript", @"view-source", nil];
    }
    return self;
}

+ (instancetype)defaultOptions {
    return [[self alloc] init];
}

@end

#pragma mark - 用户交互状态

@implementation UserInteractionState {
    BOOL _hasUserInteraction;
    BOOL _hasUserInitiatedLoad;
}

- (BOOL)hasUserInteraction {
    return _hasUserInteraction;
}

- (BOOL)hasUserInitiatedLoad {
    return _hasUserInitiatedLoad;
}

- (void)markUserInteraction {
    _hasUserInteraction = YES;
}

- (void)markUserInitiatedLoad {
    _hasUserInitiatedLoad = YES;
}

- (void)reset {
    _hasUserInteraction = NO;
    _hasUserInitiatedLoad = NO;
}

@end

#pragma mark - 分类

@interface DeepLinkOutcome ()
@property (nonatomic, assign, readwrite) DeepLinkAction action;
@property (nonatomic, copy, readwrite, nullable) NSURL *URL;
@end

@implementation DeepLinkOutcome

+ (instancetype)allowOutcomeWithURL:(nullable NSURL *)URL {
    DeepLinkOutcome *outcome = [[self alloc] init];
    outcome.action = DeepLinkActionAllow;
    outcome.URL = URL;
    return outcome;
}

+ (instancetype)cancelOutcomeWithURL:(nullable NSURL *)URL {
    DeepLinkOutcome *outcome = [[self alloc] init];
    outcome.action = DeepLinkActionCancel;
    outcome.URL = URL;
    return outcome;
}

+ (instancetype)openExternalOutcomeWithURL:(NSURL *)URL {
    DeepLinkOutcome *outcome = [[self alloc] init];
    outcome.action = DeepLinkActionOpenExternal;
    outcome.URL = URL;
    return outcome;
}

+ (instancetype)checkUniversalLinkOutcomeWithURL:(NSURL *)URL {
    DeepLinkOutcome *outcome = [[self alloc] init];
    outcome.action = DeepLinkActionCheckUniversalLink;
    outcome.URL = URL;
    return outcome;
}

@end

@implementation DeepLinkNavigationContext

+ (instancetype)contextWithURL:(nullable NSURL *)URL
                   isMainFrame:(BOOL)isMainFrame
                navigationType:(WKNavigationType)navigationType
                    currentURL:(nullable NSURL *)currentURL {
    return [[self alloc] initWithURL:URL
                         isMainFrame:isMainFrame
                      navigationType:navigationType
                          currentURL:currentURL];
}

- (instancetype)initWithURL:(nullable NSURL *)URL
                isMainFrame:(BOOL)isMainFrame
             navigationType:(WKNavigationType)navigationType
                 currentURL:(nullable NSURL *)currentURL {
    self = [super init];
    if (self) {
        _URL = [URL copy];
        _isMainFrame = isMainFrame;
        _navigationType = navigationType;
        _currentURL = [currentURL copy];
    }
    return self;
}

- (BOOL)hasNavigationGesture {
    return [DeepLinkClassifier isNavigationGesture:self.navigationType];
}

@end

@implementation DeepLinkClassifier

- (instancetype)initWithOptions:(DeepLinkOptions *)options {
    return [self initWithOptions:options
               interactionState:[[UserInteractionState alloc] init]];
}

- (instancetype)initWithOptions:(DeepLinkOptions *)options
               interactionState:(UserInteractionState *)interactionState {
    self = [super init];
    if (self) {
        _options = options;
        _interactionState = interactionState;
    }
    return self;
}

- (DeepLinkOutcome *)classifyContext:(DeepLinkNavigationContext *)context {
    NSURL *URL = context.URL;
    NSString *scheme = URL.scheme.lowercaseString;

    // 没有 URL 或 scheme：交给 WebView，宿主无从判断。
    if (URL == nil || scheme.length == 0) {
        return [DeepLinkOutcome allowOutcomeWithURL:URL];
    }

    // file 先走本机页面放行规则。
    if ([scheme isEqualToString:@"file"] && [self isLocalFileNavigationForContext:context]) {
        return [DeepLinkOutcome allowOutcomeWithURL:URL];
    }

    if ([self.options.blockedSchemes containsObject:scheme]) {
        return [DeepLinkOutcome cancelOutcomeWithURL:URL];
    }

    // https 仅在用户加载后、跨 host 的主框架导航上检查 UL。
    if ([scheme isEqualToString:@"http"]) {
        return [DeepLinkOutcome allowOutcomeWithURL:URL];
    }
    if ([scheme isEqualToString:@"https"]) {
        if ([self shouldCheckUniversalLinkForContext:context]) {
            return [DeepLinkOutcome checkUniversalLinkOutcomeWithURL:URL];
        }
        return [DeepLinkOutcome allowOutcomeWithURL:URL];
    }

    if ([self.options.webViewOwnedSchemes containsObject:scheme]) {
        return [DeepLinkOutcome allowOutcomeWithURL:URL];
    }

    BOOL hasGesture = context.hasNavigationGesture;

    if (!context.isMainFrame && !hasGesture && !self.options.allowAutomaticSubframeDeepLinks) {
        return [DeepLinkOutcome cancelOutcomeWithURL:URL];
    }

    if (self.options.requireUserGesture
        && !hasGesture
        && !self.interactionState.hasUserInteraction) {
        return [DeepLinkOutcome cancelOutcomeWithURL:URL];
    }

    if (self.options.shouldOpenExternalURL != nil
        && !self.options.shouldOpenExternalURL(URL)) {
        return [DeepLinkOutcome cancelOutcomeWithURL:URL];
    }

    return [DeepLinkOutcome openExternalOutcomeWithURL:URL];
}

/// 仅处理用户加载后、主框架、WKNavigationTypeOther、跨 host 的 https。
- (BOOL)shouldCheckUniversalLinkForContext:(DeepLinkNavigationContext *)context {
    if (!self.options.openUniversalLinksFromUserLoads) {
        return NO;
    }
    if (!self.interactionState.hasUserInitiatedLoad) {
        return NO;
    }
    if (!context.isMainFrame) {
        return NO;
    }
    if (context.navigationType != WKNavigationTypeOther) {
        return NO;
    }
    NSString *targetHost = context.URL.host;
    if (targetHost.length == 0) {
        return NO;
    }
    NSString *currentHost = context.currentURL.host;
    return currentHost.length == 0
        || [currentHost caseInsensitiveCompare:targetHost] != NSOrderedSame;
}

+ (BOOL)isNavigationGesture:(WKNavigationType)navigationType {
    return navigationType == WKNavigationTypeLinkActivated
        || navigationType == WKNavigationTypeFormSubmitted;
}

#pragma mark - file:// 本机页面

/// 本机页面放行：宿主首屏加载，或当前 file 页面同目录内的导航。
- (BOOL)isLocalFileNavigationForContext:(DeepLinkNavigationContext *)context {
    if (context.URL == nil || ![context.URL.scheme.lowercaseString isEqualToString:@"file"]) {
        return NO;
    }
    if (context.currentURL == nil) {
        return context.navigationType == WKNavigationTypeOther;
    }
    if (![context.currentURL.scheme.lowercaseString isEqualToString:@"file"]) {
        return NO;
    }

    NSString *currentPath = (context.currentURL.URLByStandardizingPath ?: context.currentURL).path;
    NSString *targetPath = (context.URL.URLByStandardizingPath ?: context.URL).path;
    if (currentPath.length == 0 || targetPath.length == 0) {
        return NO;
    }
    // 实测 loadFileURL 触发回调时 webView.url 已是目标 URL，所以先允许路径相等。
    if ([targetPath isEqualToString:currentPath]) {
        return YES;
    }
    NSString *directory = [currentPath stringByDeletingLastPathComponent];
    if (directory.length == 0) {
        return NO;
    }
    // 目录前缀必须带分隔符，避免 /a/bc 误判成 /a/b 的子路径。
    NSString *prefix = [directory hasSuffix:@"/"] ? directory
                                                  : [directory stringByAppendingString:@"/"];
    return [targetPath hasPrefix:prefix];
}

@end

#pragma mark - 决策执行

@implementation UIApplicationURLOpener

- (void)openURL:(NSURL *)URL
universalLinksOnly:(BOOL)universalLinksOnly
completionHandler:(void (^)(BOOL success))completionHandler {
    NSDictionary<UIApplicationOpenExternalURLOptionsKey, id> *options =
        universalLinksOnly ? @{UIApplicationOpenURLOptionUniversalLinksOnly: @YES} : @{};
    void (^openBlock)(void) = ^{
        [UIApplication.sharedApplication openURL:URL
                                         options:options
                               completionHandler:^(BOOL success) {
            if (completionHandler != nil) {
                completionHandler(success);
            }
        }];
    };
    if ([NSThread isMainThread]) {
        openBlock();
    } else {
        dispatch_async(dispatch_get_main_queue(), openBlock);
    }
}

@end

@implementation AppStoreSearch

- (instancetype)initWithOpener:(id<URLOpening>)opener {
    self = [super init];
    if (self) {
        _opener = opener;
    }
    return self;
}

- (void)searchForScheme:(NSString *)scheme {
    [self searchForScheme:scheme completion:nil];
}

- (void)searchForScheme:(NSString *)scheme completion:(nullable void (^)(BOOL success))completion {
    if (scheme.length == 0) {
        if (completion != nil) {
            completion(NO);
        }
        return;
    }
    NSURL *itmsURL = [self appStoreURLWithScheme:@"itms-apps"
                                            host:@"itunes.apple.com"
                                            term:scheme];
    NSURL *httpsURL = [self appStoreURLWithScheme:@"https"
                                             host:@"apps.apple.com"
                                             term:scheme];
    if (itmsURL == nil && httpsURL == nil) {
        if (completion != nil) {
            completion(NO);
        }
        return;
    }

    __weak typeof(self) weakSelf = self;
    void (^finish)(BOOL) = ^(BOOL success) {
        if (completion != nil) {
            completion(success);
        }
    };
    if (itmsURL != nil) {
        [self.opener openURL:itmsURL universalLinksOnly:NO completionHandler:^(BOOL success) {
            if (success) {
                finish(YES);
                return;
            }
            AppStoreSearch *strongSelf = weakSelf;
            if (strongSelf == nil || httpsURL == nil) {
                finish(NO);
                return;
            }
            [strongSelf.opener openURL:httpsURL universalLinksOnly:NO completionHandler:^(BOOL httpsSuccess) {
                finish(httpsSuccess);
            }];
        }];
    } else {
        [self.opener openURL:httpsURL universalLinksOnly:NO completionHandler:^(BOOL success) {
            finish(success);
        }];
    }
}

- (nullable NSURL *)appStoreURLWithScheme:(NSString *)scheme
                                     host:(NSString *)host
                                     term:(NSString *)term {
    NSURLComponents *components = [[NSURLComponents alloc] init];
    components.scheme = scheme;
    components.host = host;
    components.path = @"/search";
    components.queryItems = @[[NSURLQueryItem queryItemWithName:@"term" value:term]];
    return components.URL;
}

@end

@implementation DeepLinkDecisionEngine

- (instancetype)initWithOptions:(DeepLinkOptions *)options
                         opener:(id<URLOpening>)opener {
    UserInteractionState *interactionState = [[UserInteractionState alloc] init];
    return [self initWithOptions:options
               interactionState:interactionState
                          opener:opener];
}

- (instancetype)initWithOptions:(DeepLinkOptions *)options
               interactionState:(UserInteractionState *)interactionState
                          opener:(id<URLOpening>)opener {
    self = [super init];
    if (self) {
        _options = options;
        _interactionState = interactionState;
        _opener = opener;
        _classifier = [[DeepLinkClassifier alloc] initWithOptions:options
                                                   interactionState:interactionState];
        _appStoreSearch = [[AppStoreSearch alloc] initWithOpener:opener];
    }
    return self;
}

- (void)handleNavigationContext:(DeepLinkNavigationContext *)context
                decisionHandler:(nullable DeepLinkDecisionCompletion)decisionHandler {
    DeepLinkOutcome *outcome = [self.classifier classifyContext:context];

    switch (outcome.action) {
        case DeepLinkActionAllow:
        case DeepLinkActionCancel:
            if (decisionHandler != nil) {
                decisionHandler(outcome);
            }
            break;
        case DeepLinkActionOpenExternal:
            // 先取消 WebView 导航，再由系统打开外部 App。
            if (decisionHandler != nil) {
                decisionHandler([DeepLinkOutcome cancelOutcomeWithURL:context.URL]);
            }
            [self openExternalURL:outcome.URL];
            break;
        case DeepLinkActionCheckUniversalLink:
            [self.opener openURL:outcome.URL
              universalLinksOnly:YES
              completionHandler:^(BOOL opened) {
                if (decisionHandler != nil) {
                    decisionHandler(opened
                        ? [DeepLinkOutcome cancelOutcomeWithURL:outcome.URL]
                        : [DeepLinkOutcome allowOutcomeWithURL:outcome.URL]);
                }
            }];
            break;
    }
}

#pragma mark - Universal Link 检查

- (BOOL)checkUniversalLinkIfNeeded:(NSURL *)URL
                        completion:(nullable void (^)(BOOL opened))completion {
    if (URL == nil) {
        return NO;
    }
    if (![URL.scheme.lowercaseString isEqualToString:@"https"]) {
        return NO;
    }
    if (!self.options.openUniversalLinksFromUserLoads) {
        return NO;
    }
    [self.opener openURL:URL
      universalLinksOnly:YES
      completionHandler:^(BOOL success) {
        if (completion != nil) {
            completion(success);
        }
    }];
    return YES;
}

#pragma mark - 外跳

- (void)openExternalURL:(NSURL *)URL {
    if (URL == nil) {
        return;
    }
    __weak typeof(self) weakSelf = self;
    [self.opener openURL:URL universalLinksOnly:NO completionHandler:^(BOOL success) {
        if (success) {
            return;
        }
        DeepLinkDecisionEngine *strongSelf = weakSelf;
        if (strongSelf == nil) {
            return;
        }
        [strongSelf handleOpenFailureForURL:URL];
    }];
}

/// 打开失败：默认搜索 App Store；系统 scheme 或关闭搜索时回调宿主。
- (void)handleOpenFailureForURL:(NSURL *)URL {
    NSString *scheme = URL.scheme.lowercaseString;
    NSSet<NSString *> *systemSchemes = [NSSet setWithObjects:
        @"mailto", @"tel", @"sms", @"facetime", @"maps", @"itms", @"itms-apps", nil];
    if (!self.options.autoOpenAppStoreOnFailure
        || scheme.length == 0
        || [systemSchemes containsObject:scheme]) {
        if (self.options.onOpenFailed != nil) {
            self.options.onOpenFailed(URL);
        }
        return;
    }

    __weak typeof(self) weakSelf = self;
    [self.appStoreSearch searchForScheme:scheme completion:^(BOOL success) {
        DeepLinkDecisionEngine *strongSelf = weakSelf;
        if (strongSelf != nil && !success && strongSelf.options.onOpenFailed != nil) {
            strongSelf.options.onOpenFailed(URL);
        }
    }];
}

@end

#pragma mark - WebView 接入

@interface DeepLinkWebViewClient () <UIGestureRecognizerDelegate>
@property (nonatomic, strong, readwrite) DeepLinkOptions *options;
@property (nonatomic, strong, readwrite) UserInteractionState *interactionState;
@property (nonatomic, strong, readwrite) DeepLinkDecisionEngine *engine;
@property (nonatomic, weak, readwrite, nullable) WKWebView *webView;
/// 宿主 loadFileURL 的一次性放行目标；命中后立即清除。
@property (nonatomic, copy, nullable) NSURL *pendingHostFileURL;
/// loadURLByUser 初始 URL 的一次性放行目标；首次导航不做 Universal Link 预检。
@property (nonatomic, copy, nullable) NSURL *pendingUserLoadURL;
@property (nonatomic, strong, nullable) UITapGestureRecognizer *tapRecognizer;
@property (nonatomic, strong, nullable) UILongPressGestureRecognizer *longPressRecognizer;
@end

@implementation DeepLinkWebViewClient

#pragma mark - 接入

+ (instancetype)attachToWebView:(WKWebView *)webView {
    return [self attachToWebView:webView options:[DeepLinkOptions defaultOptions]];
}

+ (instancetype)attachToWebView:(WKWebView *)webView
                        options:(DeepLinkOptions *)options {
    DeepLinkWebViewClient *client = [[self alloc] initWithWebView:webView options:options];
    webView.navigationDelegate = client;
    webView.UIDelegate = client;
    [client installInteractionRecognizers];
    return client;
}

- (instancetype)initWithWebView:(WKWebView *)webView
                        options:(DeepLinkOptions *)options {
    self = [super init];
    if (self) {
        _webView = webView;
        _options = options;
        _interactionState = [[UserInteractionState alloc] init];
        _engine = [[DeepLinkDecisionEngine alloc] initWithOptions:options
                                                   interactionState:_interactionState
                                                              opener:[[UIApplicationURLOpener alloc] init]];
    }
    return self;
}

- (void)dealloc {
    // 手势识别器不持有 target，client 释放前要摘掉，避免回调到已释放对象。
    WKWebView *webView = self.webView;
    if (webView == nil) {
        return;
    }
    if (self.tapRecognizer != nil) {
        [webView removeGestureRecognizer:self.tapRecognizer];
    }
    if (self.longPressRecognizer != nil) {
        [webView removeGestureRecognizer:self.longPressRecognizer];
    }
}

/// 安装交互手势：tap 与极短长按都置位，并与 WebKit 手势并存。
- (void)installInteractionRecognizers {
    if (self.webView == nil) {
        return;
    }

    // 重复 attach 时先摘掉本类此前装的手势，避免叠加。
    for (UIGestureRecognizer *recognizer in [self.webView.gestureRecognizers copy]) {
        if ([recognizer.name isEqualToString:InteractionRecognizerName]) {
            [self.webView removeGestureRecognizer:recognizer];
        }
    }

    UITapGestureRecognizer *tapRecognizer =
        [[UITapGestureRecognizer alloc] initWithTarget:self
                                                action:@selector(handleInteractionGesture:)];
    tapRecognizer.cancelsTouchesInView = NO;
    tapRecognizer.delegate = self;
    tapRecognizer.name = InteractionRecognizerName;
    [self.webView addGestureRecognizer:tapRecognizer];
    self.tapRecognizer = tapRecognizer;

    UILongPressGestureRecognizer *longPressRecognizer =
        [[UILongPressGestureRecognizer alloc] initWithTarget:self
                                                      action:@selector(handleInteractionGesture:)];
    longPressRecognizer.minimumPressDuration = 0.01;
    longPressRecognizer.cancelsTouchesInView = NO;
    longPressRecognizer.delegate = self;
    longPressRecognizer.name = InteractionRecognizerName;
    [self.webView addGestureRecognizer:longPressRecognizer];
    self.longPressRecognizer = longPressRecognizer;
}

- (void)handleInteractionGesture:(UIGestureRecognizer *)recognizer {
    if ([recognizer isKindOfClass:[UILongPressGestureRecognizer class]]) {
        // 长按只在 began 置位，避免抬手时重复置位。
        if (recognizer.state == UIGestureRecognizerStateBegan) {
            [self.interactionState markUserInteraction];
        }
        return;
    }
    if (recognizer.state == UIGestureRecognizerStateEnded
        || recognizer.state == UIGestureRecognizerStateRecognized) {
        [self.interactionState markUserInteraction];
    }
}

#pragma mark - 宿主入口

- (void)loadURLByUser:(NSURL *)URL {
    if (URL == nil) {
        return;
    }
    [self.interactionState markUserInteraction];
    [self.interactionState markUserInitiatedLoad];
    self.pendingUserLoadURL = nil;

    NSString *scheme = URL.scheme.lowercaseString;
    BOOL isHTTP = [scheme isEqualToString:@"http"] || [scheme isEqualToString:@"https"];
    if (!isHTTP) {
        // non-http(s) 直接决策，只有 Allow 才加载。
        DeepLinkNavigationContext *context =
            [DeepLinkNavigationContext contextWithURL:URL
                                             isMainFrame:YES
                                          navigationType:WKNavigationTypeOther
                                              currentURL:self.webView.URL];
        __weak typeof(self) weakSelf = self;
        [self.engine handleNavigationContext:context
                             decisionHandler:^(DeepLinkOutcome *outcome) {
            if (outcome.action != DeepLinkActionAllow) {
                return;
            }
            DeepLinkWebViewClient *strongSelf = weakSelf;
            if (strongSelf != nil) {
                [strongSelf.webView loadRequest:[NSURLRequest requestWithURL:URL]];
            }
        }];
        return;
    }

    // 首次 URL 是 https 时先尝试 Universal Link；http 直接加载。
    __weak typeof(self) weakSelf = self;
    BOOL checking = [self.engine checkUniversalLinkIfNeeded:URL completion:^(BOOL opened) {
        if (opened) {
            return;
        }
        DeepLinkWebViewClient *strongSelf = weakSelf;
        if (strongSelf == nil) {
            return;
        }
        // UL 检查失败后回到 WebView；这次回载跳过 decidePolicy 的重复检查。
        strongSelf.pendingUserLoadURL = [URL copy];
        [strongSelf.webView loadRequest:[NSURLRequest requestWithURL:URL]];
    }];
    if (!checking) {
        [self.webView loadRequest:[NSURLRequest requestWithURL:URL]];
    }
}

- (void)loadLocalFileURL:(NSURL *)URL allowingReadAccessToURL:(NSURL *)readAccessURL {
    if (URL == nil || self.webView == nil) {
        return;
    }
    // 进入新的浏览上下文：清空上一次页面留下的用户交互状态与宿主加载授权。
    [self.interactionState reset];
    self.pendingUserLoadURL = nil;
    self.pendingHostFileURL = [URL copy];
    [self.webView loadFileURL:URL allowingReadAccessToURL:readAccessURL];
}

#pragma mark - WKNavigationDelegate

- (void)webView:(WKWebView *)webView
    decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction
                    decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
    NSURL *URL = navigationAction.request.URL;

    // 宿主显式加载的本地页面：按宿主意图放行一次。
    if ([self consumePendingHostFileURLIfNeeded:URL]) {
        decisionHandler(WKNavigationActionPolicyAllow);
        return;
    }

    if (URL == nil) {
        decisionHandler(WKNavigationActionPolicyAllow);
        return;
    }

    // UL 检查失败后的回载：跳过重复检查，直接加载。
    if (self.pendingUserLoadURL != nil
        && [self.pendingUserLoadURL.absoluteString isEqualToString:URL.absoluteString]) {
        self.pendingUserLoadURL = nil;
        decisionHandler(WKNavigationActionPolicyAllow);
        return;
    }

    if ([DeepLinkClassifier isNavigationGesture:navigationAction.navigationType]) {
        [self.interactionState markUserInteraction];
    }

    // targetFrame == nil 表示 window.open / target=_blank，按主框架处理。
    BOOL isMainFrame = navigationAction.targetFrame == nil
        ? YES
        : navigationAction.targetFrame.isMainFrame;
    DeepLinkNavigationContext *context =
        [DeepLinkNavigationContext contextWithURL:URL
                                         isMainFrame:isMainFrame
                                      navigationType:navigationAction.navigationType
                                          currentURL:webView.URL];

    // Universal Link 检查是异步的：decisionHandler 只能调用一次。
    __block BOOL hasDecided = NO;
    [self.engine handleNavigationContext:context
                         decisionHandler:^(DeepLinkOutcome *outcome) {
        if (hasDecided) {
            return;
        }
        hasDecided = YES;
        WKNavigationActionPolicy policy = outcome.action == DeepLinkActionAllow
            ? WKNavigationActionPolicyAllow
            : WKNavigationActionPolicyCancel;
        decisionHandler(policy);
    }];
}

- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
    self.pendingHostFileURL = nil;
}

- (void)webView:(WKWebView *)webView
    didFailNavigation:(WKNavigation *)navigation
            withError:(NSError *)error {
    self.pendingHostFileURL = nil;
}

- (BOOL)consumePendingHostFileURLIfNeeded:(nullable NSURL *)URL {
    NSURL *pendingURL = self.pendingHostFileURL;
    if (pendingURL == nil || URL == nil) {
        return NO;
    }
    if (![URL.scheme.lowercaseString isEqualToString:@"file"]) {
        return NO;
    }
    NSString *requestPath = (URL.URLByStandardizingPath ?: URL).path;
    NSString *pendingPath = (pendingURL.URLByStandardizingPath ?: pendingURL).path;
    if (requestPath.length == 0 || ![requestPath isEqualToString:pendingPath]) {
        return NO;
    }
    self.pendingHostFileURL = nil;
    return YES;
}

#pragma mark - WKUIDelegate

/// 新窗口兜底：没有多标签能力，一律返回 nil。
/// JS window.open 只走这里，不回调 decidePolicyFor。
- (nullable WKWebView *)webView:(WKWebView *)webView
    createWebViewWithConfiguration:(WKWebViewConfiguration *)configuration
               forNavigationAction:(WKNavigationAction *)navigationAction
                    windowFeatures:(WKWindowFeatures *)windowFeatures {
    NSURL *URL = navigationAction.request.URL;
    NSString *scheme = URL.scheme.lowercaseString;
    if ([scheme isEqualToString:@"http"] || [scheme isEqualToString:@"https"]) {
        [webView loadRequest:navigationAction.request];
        return nil;
    }

    if ([DeepLinkClassifier isNavigationGesture:navigationAction.navigationType]) {
        [self.interactionState markUserInteraction];
    }

    DeepLinkNavigationContext *context =
        [DeepLinkNavigationContext contextWithURL:URL
                                         isMainFrame:YES
                                      navigationType:navigationAction.navigationType
                                          currentURL:webView.URL];
    [self.engine handleNavigationContext:context decisionHandler:nil];
    return nil;
}

#pragma mark - UIGestureRecognizerDelegate

- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer
shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
    // 与 WebKit 内部手势并存，否则点击链接、滚动都会被交互手势吞掉。
    return YES;
}

@end
```
:::

2. 在 ViewController 中接入

```objc
// ...

DeepLinkWebViewClient *_deepLinkClient;

// ...

_deepLinkClient = [DeepLinkWebViewClient attachToWebView:_webView options:options];

// ...

// 如果需要让 WebView 载入的第一个链接就支持唤起 APP，可以调用【非必须】
[_deepLinkClient loadURLByUser:URL];

// ...

```
