iOS开发基础98-WebView 拦截 URL Scheme :以淘宝跳转为例

iOS WebView 拦截 URL Scheme 完全指南:以淘宝跳转为例

在 App 内用 WebView 加载网页时,经常需要跳转到第三方 App(如淘宝、京东、微信)。本文以"WebView 加载淘宝商品页,点击评论跳转淘宝客户端"为例,从 URL Scheme 原理讲起,详细讲解 Info.plist 白名单配置、WKWebView 拦截逻辑、openURL 方法变化、淘宝未安装的降级处理等关键细节,给出完整 OC 代码、Swift 版本对照和常见坑排查。


一、概述

一句话原理

URL Scheme 是 App 的"门牌号",每个 App 可以注册一个自定义协议(如 tbopen://),其他 App 通过这个协议就能打开它。WebView 拦截到这种协议的 URL 后,调用系统 API 打开对应 App,就实现了跳转。

实现目标

  1. App 内用 WKWebView 加载淘宝商品详情页。
  2. 用户点击"查看评论"等操作时,跳转到淘宝客户端查看详情。
  3. 淘宝未安装时,有降级处理(提示下载或继续在 WebView 浏览)。

二、核心原理

1. URL Scheme 是什么

URL Scheme 是 iOS 提供的 App 间通信机制:

  • 每个 App 可以在 Info.plist 中注册自己的 URL Scheme(如淘宝注册了 tbopentaobao)。
  • 其他 App 用 [[UIApplication sharedApplication] openURL:url] 就能打开对应 App。
  • 常见 Scheme:tbopen://(淘宝)、taobao://(淘宝)、tmall://(天猫)、openapp.jdmobile://(京东)、weixin://(微信)。

2. 白名单机制(LSApplicationQueriesSchemes)

iOS 9 引入了 URL Scheme 白名单机制:

  • canOpenURL: 方法只能返回白名单内 Scheme 的结果。
  • 不在白名单中的 Scheme,canOpenURL: 一律返回 NO。
  • 白名单最多 50 个 Scheme。
  • 目的:防止 App 扫描用户设备上安装了哪些 App(隐私保护)。

注意:白名单只影响 canOpenURL: 的查询结果,openURL: 本身不受白名单限制(但系统会有弹窗提示)。

3. WKWebView 拦截机制

WKWebView 的 decidePolicyForNavigationAction: 代理方法会在每次导航发生前调用:

  • 可以获取当前请求的 URL。
  • 通过 decisionHandler 决定允许(Allow)还是取消(Cancel)加载。
  • 拦截到特定 Scheme 后,取消 WebView 加载,转而用 openURL 打开第三方 App。

关键decisionHandler 必须调用且只能调用一次,不调用会导致 App 崩溃。


三、实现步骤

第一步:Info.plist 配置白名单

在 Info.plist 中添加 LSApplicationQueriesSchemes,声明要查询的 Scheme:

<key>LSApplicationQueriesSchemes</key>
<array>
    <string>tbopen</string>
    <string>taobao</string>
    <string>tmall</string>
    <!-- 其他需要的 Scheme -->
</array>

图形化操作:Info.plist → 右键 → Add Row → 输入 LSApplicationQueriesSchemes → 类型选 Array → 添加 item。

第二步:拦截 URL 并跳转

在 WKWebView 的导航代理中拦截 tbopen 开头的 URL:

- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
    
    NSURL *url = navigationAction.request.URL;
    NSString *scheme = url.scheme.lowercaseString;
    
    // 拦截 tbopen / taobao 等淘宝 Scheme
    if ([scheme isEqualToString:@"tbopen"] || [scheme isEqualToString:@"taobao"]) {
        // 取消 WebView 加载
        decisionHandler(WKNavigationActionPolicyCancel);
        
        // 检查是否安装了淘宝
        if ([[UIApplication sharedApplication] canOpenURL:url]) {
            // 打开淘宝客户端(iOS 10+ 推荐用法)
            [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:^(BOOL success) {
                if (!success) {
                    NSLog(@"打开淘宝失败");
                }
            }];
        } else {
            // 淘宝未安装,提示用户或降级处理
            [self showAlert:@"未安装淘宝" message:@"是否前往 App Store 下载?"];
        }
        return;
    }
    
    // 其他 URL 正常加载
    decisionHandler(WKNavigationActionPolicyAllow);
}

四、完整代码示例(OC)

// WebViewController.h
#import <UIKit/UIKit.h>
@interface WebViewController : UIViewController
@property (nonatomic, copy) NSString *urlString;
@end
// WebViewController.m
#import "WebViewController.h"
#import <WebKit/WebKit.h>

@interface WebViewController () <WKNavigationDelegate, WKUIDelegate>
@property (nonatomic, strong) WKWebView *webView;
@property (nonatomic, strong) UIProgressView *progressView;
@property (nonatomic, strong) UIBarButtonItem *backButton;
@property (nonatomic, strong) UIBarButtonItem *closeButton;
@end

@implementation WebViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    self.view.backgroundColor = [UIColor whiteColor];
    
    [self setupNavigationBar];
    [self setupWebView];
    [self loadURL];
}

#pragma mark - 设置导航栏

- (void)setupNavigationBar {
    self.backButton = [[UIBarButtonItem alloc] initWithTitle:@"返回" 
                                                        style:UIBarButtonItemStylePlain 
                                                       target:self 
                                                       action:@selector(backTapped)];
    self.closeButton = [[UIBarButtonItem alloc] initWithTitle:@"关闭" 
                                                         style:UIBarButtonItemStylePlain 
                                                        target:self 
                                                        action:@selector(closeTapped)];
    self.navigationItem.leftBarButtonItems = @[self.backButton, self.closeButton];
}

- (void)backTapped {
    if (self.webView.canGoBack) {
        [self.webView goBack];
    } else {
        [self closeTapped];
    }
}

- (void)closeTapped {
    [self.navigationController popViewControllerAnimated:YES];
}

#pragma mark - 设置 WebView

- (void)setupWebView {
    WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
    
    self.webView = [[WKWebView alloc] initWithFrame:CGRectZero configuration:config];
    self.webView.navigationDelegate = self;
    self.webView.UIDelegate = self;
    self.webView.translatesAutoresizingMaskIntoConstraints = NO;
    [self.view addSubview:self.webView];
    
    // 进度条
    self.progressView = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];
    self.progressView.translatesAutoresizingMaskIntoConstraints = NO;
    [self.view addSubview:self.progressView];
    
    // 约束
    UILayoutGuide *guide = self.view.safeAreaLayoutGuide;
    [NSLayoutConstraint activateConstraints:@[
        [self.webView.topAnchor constraintEqualToAnchor:guide.topAnchor],
        [self.webView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
        [self.webView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
        [self.webView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
        
        [self.progressView.topAnchor constraintEqualToAnchor:guide.topAnchor],
        [self.progressView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
        [self.progressView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
        [self.progressView.heightAnchor constraintEqualToConstant:2]
    ]];
    
    // KVO 监听加载进度
    [self.webView addObserver:self forKeyPath:@"estimatedProgress" options:NSKeyValueObservingOptionNew context:nil];
}

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    if ([keyPath isEqualToString:@"estimatedProgress"]) {
        self.progressView.progress = self.webView.estimatedProgress;
        self.progressView.hidden = (self.webView.estimatedProgress >= 1.0);
    }
}

#pragma mark - 加载 URL

- (void)loadURL {
    if (!self.urlString) return;
    NSURL *url = [NSURL URLWithString:self.urlString];
    NSURLRequest *request = [NSURLRequest requestWithURL:url];
    [self.webView loadRequest:request];
}

#pragma mark - WKNavigationDelegate

// 核心:拦截 URL Scheme
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
    
    NSURL *url = navigationAction.request.URL;
    NSString *scheme = url.scheme.lowercaseString;
    
    // 需要拦截的第三方 App Scheme 列表
    NSArray *interceptSchemes = @[@"tbopen", @"taobao", @"tmall", @"openapp.jdmobile"];
    
    if ([interceptSchemes containsObject:scheme]) {
        // 1. 取消 WebView 加载(必须)
        decisionHandler(WKNavigationActionPolicyCancel);
        
        // 2. 尝试打开第三方 App
        [self openThirdPartyApp:url];
        
        return;
    }
    
    // 其他 URL 正常加载
    decisionHandler(WKNavigationActionPolicyAllow);
}

- (void)webView:(WKWebView *)webView didStartProvisionalNavigation:(WKNavigation *)navigation {
    self.progressView.hidden = NO;
}

- (void)webView:(WKWebView *)webView didFinishNavigation:(WKNavigation *)navigation {
    self.progressView.hidden = YES;
}

- (void)webView:(WKWebView *)webView didFailNavigation:(WKNavigation *)navigation withError:(NSError *)error {
    self.progressView.hidden = YES;
    NSLog(@"加载失败:%@", error.localizedDescription);
}

#pragma mark - 打开第三方 App

- (void)openThirdPartyApp:(NSURL *)url {
    if ([[UIApplication sharedApplication] canOpenURL:url]) {
        // iOS 10+ 推荐用法
        if (@available(iOS 10.0, *)) {
            [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:^(BOOL success) {
                if (!success) {
                    [self showOpenFailedAlert];
                }
            }];
        } else {
            // iOS 9 及以下
            [[UIApplication sharedApplication] openURL:url];
        }
    } else {
        // 未安装对应 App
        [self showNotInstalledAlert:url];
    }
}

- (void)showNotInstalledAlert:(NSURL *)url {
    UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"提示" 
                                                                    message:@"未安装对应客户端,是否前往 App Store 下载?" 
                                                             preferredStyle:UIAlertControllerStyleAlert];
    [alert addAction:[UIAlertAction actionWithTitle:@"取消" style:UIAlertActionStyleCancel handler:nil]];
    [alert addAction:[UIAlertAction actionWithTitle:@"去下载" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
        // 跳转到 App Store(这里以淘宝为例)
        NSURL *appStoreURL = [NSURL URLWithString:@"https://itunes.apple.com/app/id387682726"];
        [[UIApplication sharedApplication] openURL:appStoreURL options:@{} completionHandler:nil];
    }]];
    [self presentViewController:alert animated:YES completion:nil];
}

- (void)showOpenFailedAlert {
    UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"提示" 
                                                                    message:@"打开失败,请稍后重试" 
                                                             preferredStyle:UIAlertControllerStyleAlert];
    [alert addAction:[UIAlertAction actionWithTitle:@"确定" style:UIAlertActionStyleDefault handler:nil]];
    [self presentViewController:alert animated:YES completion:nil];
}

#pragma mark - 生命周期

- (void)dealloc {
    [self.webView removeObserver:self forKeyPath:@"estimatedProgress"];
    NSLog(@"WebViewController dealloc");
}

@end

使用方式

WebViewController *webVC = [[WebViewController alloc] init];
webVC.urlString = @"https://detail.taobao.com/item.htm?id=XXXXXX";
[self.navigationController pushViewController:webVC animated:YES];

五、关键细节与坑

1. decisionHandler 必须调用

常见错误:拦截 URL 后只调用了 openURL:,忘记调用 decisionHandler,导致 App 崩溃。

// 错误写法:没有调用 decisionHandler
if ([scheme isEqualToString:@"tbopen"]) {
    [[UIApplication sharedApplication] openURL:url];
    // 没有调用 decisionHandler!会崩溃
}

// 正确写法:拦截后调用 Cancel
if ([scheme isEqualToString:@"tbopen"]) {
    decisionHandler(WKNavigationActionPolicyCancel); // 必须调用
    [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
    return;
}

decisionHandler 必须调用且只能调用一次,传 Cancel 表示取消这次导航,传 Allow 表示允许继续加载。

2. openURL 方法的变化

方法 系统版本 说明
openURL: iOS 2~9 旧方法,iOS 10 起废弃
openURL:options:completionHandler: iOS 10+ 推荐,有完成回调
// 推荐写法(iOS 10+)
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:^(BOOL success) {
    if (success) {
        NSLog(@"打开成功");
    } else {
        NSLog(@"打开失败");
    }
}];

3. Scheme 匹配要精确

常见错误:用 containsString:@"tbopen" 匹配 scheme,可能误匹配其他 URL。

// 不推荐:containsString 可能误匹配
if ([scheme containsString:@"tbopen"]) { ... }

// 推荐:精确匹配或前缀匹配
if ([scheme isEqualToString:@"tbopen"] || [scheme isEqualToString:@"taobao"]) { ... }

4. 淘宝未安装的降级处理

canOpenURL: 返回 NO 时(未安装淘宝),不能什么都不做,应该:

  • 提示用户未安装,引导去 App Store 下载。
  • 或者降级为在 WebView 中继续浏览(但 tbopen 协议 WebView 无法加载,需要转换成 https 链接)。

5. 白名单只影响 canOpenURL

LSApplicationQueriesSchemes 只限制 canOpenURL: 的查询结果,不限制 openURL:。但如果不把 scheme 加入白名单,canOpenURL: 永远返回 NO,就无法判断是否安装了对应 App。

6. 拦截 target="_blank" 的链接

有些网页链接是 target="_blank"(新窗口打开),WKWebView 默认不处理,需要实现 WKUIDelegate

- (WKWebView *)webView:(WKWebView *)webView createWebViewWithConfiguration:(WKWebViewConfiguration *)configuration forNavigationAction:(WKNavigationAction *)navigationAction windowFeatures:(WKWindowFeatures *)windowFeatures {
    // 新窗口请求,直接在当前 WebView 加载
    if (!navigationAction.targetFrame.isMainFrame) {
        [webView loadRequest:navigationAction.request];
    }
    return nil;
}

六、扩展:其他电商 App 的 URL Scheme

App URL Scheme 示例
淘宝 tbopen:// / taobao:// tbopen://item.taobao.com/item.htm?id=xxx
天猫 tmall:// tmall://item.tmall.com/item.htm?id=xxx
京东 openapp.jdmobile:// openapp.jdmobile://virtual?params=...
拼多多 pinduoduo:// pinduoduo://goods.html?goods_id=xxx
微信 weixin:// weixin://dl/business/?t=xxx
支付宝 alipays:// alipays://platformapi/startapp?appId=xxx

不同 App 的 Scheme 格式不同,需要参考各自的开放平台文档。


七、扩展:Universal Links(通用链接)

一句话原理

Universal Links 是 iOS 9+ 推出的更优雅的跳转方式,用普通的 https 链接就能打开 App,不需要配置 Scheme 白名单,用户没装 App 时自动在 Safari 中打开网页。

与 URL Scheme 的对比

对比 URL Scheme Universal Links
协议 自定义协议(tbopen://) 标准 https 链接
白名单 需要配置 不需要
未安装 App 无反应,需要自己处理 自动在 Safari 打开网页
体验 会弹窗确认 直接跳转,无弹窗
配置 简单(Info.plist) 需要服务端配置 apple-app-site-association 文件

淘宝等大厂通常同时支持 URL Scheme 和 Universal Links。如果 WebView 中加载的是淘宝的 https 链接,且开启了 Universal Links,系统会自动尝试打开淘宝 App,不需要手动拦截。


八、Swift 版本对照

import UIKit
import WebKit

class WebViewController: UIViewController, WKNavigationDelegate, WKUIDelegate {
    
    var urlString: String?
    private var webView: WKWebView!
    private var progressView: UIProgressView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white
        setupWebView()
        loadURL()
    }
    
    private func setupWebView() {
        let config = WKWebViewConfiguration()
        webView = WKWebView(frame: .zero, configuration: config)
        webView.navigationDelegate = self
        webView.uiDelegate = self
        webView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(webView)
        
        progressView = UIProgressView(progressViewStyle: .default)
        progressView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(progressView)
        
        NSLayoutConstraint.activate([
            webView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            webView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
            
            progressView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            progressView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            progressView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            progressView.heightAnchor.constraint(equalToConstant: 2)
        ])
        
        webView.addObserver(self, forKeyPath: "estimatedProgress", options: .new, context: nil)
    }
    
    private func loadURL() {
        guard let urlString = urlString, let url = URL(string: urlString) else { return }
        webView.load(URLRequest(url: url))
    }
    
    // MARK: - 拦截 URL Scheme
    func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
        
        guard let url = navigationAction.request.url,
              let scheme = url.scheme?.lowercased() else {
            decisionHandler(.allow)
            return
        }
        
        let interceptSchemes = ["tbopen", "taobao", "tmall", "openapp.jdmobile"]
        
        if interceptSchemes.contains(scheme) {
            decisionHandler(.cancel) // 必须调用
            openThirdPartyApp(url)
            return
        }
        
        decisionHandler(.allow)
    }
    
    private func openThirdPartyApp(_ url: URL) {
        if UIApplication.shared.canOpenURL(url) {
            UIApplication.shared.open(url, options: [:]) { success in
                if !success {
                    print("打开失败")
                }
            }
        } else {
            showNotInstalledAlert()
        }
    }
    
    private func showNotInstalledAlert() {
        let alert = UIAlertController(title: "提示", message: "未安装对应客户端", preferredStyle: .alert)
        alert.addAction(UIAlertAction(title: "确定", style: .default))
        present(alert, animated: true)
    }
    
    // MARK: - KVO
    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
        if keyPath == "estimatedProgress" {
            progressView.progress = Float(webView.estimatedProgress)
            progressView.isHidden = webView.estimatedProgress >= 1.0
        }
    }
    
    deinit {
        webView.removeObserver(self, forKeyPath: "estimatedProgress")
    }
}

九、常见问题与坑

Q1:拦截 URL 后 App 崩溃了?

大概率是没有调用 decisionHandlerdecidePolicyForNavigationAction: 中必须调用 decisionHandler 且只能调用一次。拦截后传 Cancel,不拦截传 Allow

Q2:canOpenURL 一直返回 NO?

检查 Info.plist 中是否添加了 LSApplicationQueriesSchemes 白名单。iOS 9+ 不在白名单中的 Scheme,canOpenURL: 一律返回 NO。

Q3:openURL 后没有反应?

  • 检查设备是否安装了淘宝客户端。
  • 检查 URL 是否正确(tbopen:// 格式是否合法)。
  • iOS 10+ 用 openURL:options:completionHandler:,看 completionHandler 的 success 值。
  • 某些 Scheme 需要特定参数才能正确跳转。

Q4:淘宝的 URL Scheme 格式是什么?

淘宝常用的 Scheme:

  • tbopen:// 通用跳转,后面跟网页 URL,如 tbopen://item.taobao.com/item.htm?id=xxx
  • taobao:// 直接跳转,如 taobao://item.taobao.com/item.htm?id=xxx

具体格式可能随淘宝版本变化,建议参考淘宝开放平台文档。

Q5:WKWebView 和 UIWebView 怎么选?

  • WKWebView:iOS 8+,性能好,内存占用低,是苹果推荐的 WebView。
  • UIWebView:iOS 2~12,已废弃,iOS 12 后不推荐使用,审核可能被拒。
  • 新项目一律用 WKWebView。

Q6:ATS(App Transport Security)需要配置吗?

iOS 9+ 默认禁止 HTTP 请求。淘宝商品页是 HTTPS,一般不需要额外配置。如果加载的是 HTTP 页面,需要在 Info.plist 中配置 NSAppTransportSecurity

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>

不建议全局开放 HTTP,最好用 NSExceptionDomains 针对特定域名开放。

Q7:怎么拦截 JavaScript 调用原生?

WKScriptMessageHandler

  1. 配置 WKUserContentController,添加脚本消息处理器。
  2. 实现 userContentController:didReceiveScriptMessage:
  3. 网页用 window.webkit.messageHandlers.xxx.postMessage(data) 调用原生。

Q8:WebView 中的视频无法全屏播放?

检查 WKWebViewConfigurationallowsInlineMediaPlayback 属性,设为 YES 允许内嵌播放,设为 NO 会自动全屏。

Q9:返回按钮怎么处理?

  • WebView 有历史记录时(canGoBack),调用 goBack 返回上一页。
  • 没有历史记录时,关闭 WebViewController。
  • 建议导航栏放"返回"和"关闭"两个按钮。

Q10:这种跳转方式苹果审核会拒吗?

正常使用 URL Scheme 跳转不会被拒。但要注意:

  • 不要用私有 API。
  • 白名单不要加太多无关的 Scheme(最多 50 个,且苹果可能问为什么需要这么多)。
  • 不要用 URL Scheme 做恶意引导(如强制跳转其他 App)。
  • 淘宝客/广告类 App 要遵守苹果的广告规范。

十、总结

  • 核心原理:URL Scheme 是 App 的"门牌号",WebView 拦截特定 Scheme 后调用 openURL 打开第三方 App。
  • 实现步骤
    1. Info.plist 配置 LSApplicationQueriesSchemes 白名单。
    2. WKWebView 的 decidePolicyForNavigationAction: 中拦截 URL。
    3. 拦截后调用 decisionHandler(.cancel) + openURL
    4. 未安装时降级处理(提示下载或继续浏览)。
  • 关键坑
    • decisionHandler 必须调用且只能调用一次。
    • iOS 10+ 用 openURL:options:completionHandler:
    • Scheme 精确匹配,不要用 containsString。
    • 白名单只影响 canOpenURL,不影响 openURL
  • 扩展:其他电商 Scheme(天猫、京东、拼多多)、Universal Links(更优雅的 https 跳转)、JavaScript 与原生交互。
  • 适用范围:不仅适用于淘宝,任何支持 URL Scheme 的第三方 App 都可以用这种方式跳转。

posted @ 2019-06-20 15:54  Mr.陳  阅读(21321)  评论(0)    收藏  举报