Skip to content
开发者工具
使用AI助手和工具加速开发

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

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

Android ​

原理 ​


使用 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
    }
}
  1. 接入 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)
  1. 在 AndroidManifest.xml 里增加对 http(s) 处理器的可见性
XML
<!-- Android 11+ 包可见性:让 queryIntentActivities 能看到 http(s) 的处理者 -->
<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。
 *
 * <p>判定一次导航算不算「用户意图」:本次导航带手势、是服务端重定向、或落在用户刚操作过的
 * 激活窗口内。https App Link 还要看 handler:只声明 host、没有 path 的按落地页处理,
 * 先留在 WebView;带 path 的直接 App Link 与 custom scheme / intent:// 一样三者都认。
 *
 * <p>需要激活窗口是因为:很多支付页在加载完成后由脚本自动跳深链(location.href = "xxx://…"),
 * 这一跳没有用户手势,只凭 hasGesture() 会漏掉。LINE Pay 的 liff.line.me 属于落地页,
 * 不会被窗口直接拉起。
 *
 * <p>子框架(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;
    }

    /**
     * 宿主代用户发起一次导航(地址栏回车、原生按钮等)。
     *
     * <p>这类操作的触摸落在原生控件上,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 两层语义。
     *
     * <p>两层状态与浏览器一致:sticky 表示这个 WebView 是否被用户操作过(只置位,不会自己
     * 复位);transient 表示最近一次操作后的有效窗口,默认 10 秒,可被消费或过期。
     *
     * <p>WebView 不提供读取浏览器内部激活状态的 API,只给每次导航一个 hasGesture() 布尔,
     * 所以这里用宿主侧的触摸/按键事件自己记时间戳——是近似,不是浏览器内部状态的复刻。
     * 窗口取 10 秒:Chromium 自己的激活窗口是 5 秒,而实测「点支付 → 脚本发出深链」需要
     * 2.75–5.36 秒,5 秒会漏掉最慢的一次。
     */
    public static final class UserActivationTracker {

        /**
         * 用户意图窗口时长,触摸与宿主发起的导航共用。
         *
         * <p>取值依据:收银台从点支付到脚本发出深链实测 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;
        }

        /**
         * 宿主代用户发起导航(地址栏回车、原生按钮等)。
         *
         * <p>与触摸共用同一个时限:这类操作的触摸落在原生控件上,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;
        }
    }
}
  1. 接入 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)
  1. 在 AndroidManifest.xml 里增加对 http(s) 处理器的可见性(同 Kotlin)
XML
<!-- Android 11+ 包可见性:让 queryIntentActivities 能看到 http(s) 的处理者 -->
<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 ​

原理 ​


使用 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
    }
}
  1. 在 ViewController 中接入
Swift
/// ...
private var deepLinkClient: DeepLinkWebViewClient?

// ...

deepLinkClient = DeepLinkWebViewClient.attach(to: webView)

// ...

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

// ...

使用 Objective-C ​

  1. 实现 WKNavigationDelegate
objc
#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
#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
  1. 在 ViewController 中接入
objc
// ...

DeepLinkWebViewClient *_deepLinkClient;

// ...

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

// ...

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

// ...

此页面的内容有帮助吗?

感谢您帮助改进 PayerMax 产品文档!

Released under the MIT License.