平台简介
新手指南
API协议
API文档
单点登录集成
小程序插件
APP SDK
常见问题
开放平台对接场景示例
开放平台API接口调用常见报错以及解决方法汇总
帮助中心 / 开放平台帮助文档 / APP内嵌问题
APP内嵌问题
第三方APP内嵌契约锁H5页面的常见问题及解决方法

1. WebView内嵌的H5认证页面不支持实时动态检测,或实时动态检测时唤起相机黑屏、无法预览拍摄画面

  • iOS

    WKWebView 从 iOS14.3 开始支持 getUserMedia() 的调用,但是 WKWebView.configuration 的属性 allowsInlineMediaPlayback 默认值是 NO,该属性决定了H5是否可以播放内联HTML5视频。需要将 allowsInlineMediaPlayback 属性的值置为 YES ,即可正常展示活体检测预览。注:如果使用SFSafariViewController加载H5页面,则不存在该问题。

      // info.plist中添加相机权限用途描述
      <key>NSCameraUsageDescription</key>
      <string>相机权限用途描述,文案可自行编辑</string>
      // 需要先初始化WKWebViewConfiguration并修改好参数,再初始化WKWebView并传入configuration,否则配置可能会不生效!
      WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
      config.allowsInlineMediaPlayback = YES; // 默认是NO,这个值决定了用内嵌HTML5播放视频还是用本地的全屏控制
      config.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeNone; // 音视频的播放不需要用户手势触发,即为自动播放
    
      // 初始化WKWebViewConfiguration后,再初始化WKWebView并传入config
      WKWebView *webview = [[WKWebView alloc] initWithFrame:frame configuration:config];
    
  • Android

    为了使腾讯云/旷世的实时动态检测能够正常生效,同时适配可能出现的实时动态检测降级到录制视频的场景,请参考如下代码进行兼容性配置,从而确保能够正常唤起相机。

    进行实时动态检测时,如遇到相机加载异常,请确保应用已经获取相机权限,或者通过监听 onPermissionRequest() 动态申请权限,该方法会在即将进行实时动态检测之前触发。

      // 监听WebChromeClient中onPermissionRequest(),判断当前是否授权相机权限
      @Override
      public void onPermissionRequest(PermissionRequest request) {
          if (Build.VERSION.SDK_INT > Build.VERSION_CODES.LOLLIPOP) {
              if (request != null && request.getOrigin() != null && QYSH5FaceVerifySDK.getInstance().isQiyuesuoFaceVerify(request.getOrigin().toString())) {
                  this.request = request;
                  ActivityCompat.requestPermissions(mActivity, new String[]{
                                  Manifest.permission.CAMERA,},
                          REQUEST_CODE_CAMERA_WEBRTC);
              }
          }
      }
    
      // 以下为部分参考代码,完整文件见下方附件
      // 设置 setWebChromeClient
      qysWebChromeClient = new QYSWebChromeClient(this.mWebview);
      mWebview.setWebChromeClient(qysWebChromeClient);
      mWebview.setWebViewClient(new WebViewClient());
    
      @Override
      protected void onActivityResult(int requestCode, int resultCode, Intent data) {
          super.onActivityResult(requestCode, resultCode, data);
          qysWebChromeClient.onActivityResult(requestCode,resultCode,data);
      }
    
      @Override
      public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
          super.onRequestPermissionsResult(requestCode, permissions, grantResults);
          qysWebChromeClient.onRequestPermissionsResult(requestCode,permissions,grantResults);
      }

    详细示例代码下载地址:https://dl.qiyuesuo.com/public/app/doc/webRTC-demo.zip

  • HarmonyOS NEXT

    为了使腾讯云/旷世的实时动态检测能够正常生效,同时适配可能出现的实时动态检测降级到录制视频的场景,请参考如下代码进行兼容性配置,从而确保能够正常唤起相机。

    首先在 model.json5 文件中增加申请ohos.permission.INTERNET、ohos.permission.CAMERA等权限,具体如下

      "requestPermissions":[
          {
              "name" : "ohos.permission.INTERNET"
          },
          {
              "name" : "ohos.permission.CAMERA"
          },
          {
              "name": "ohos.permission.MICROPHONE"
          },
          {
              "name": "ohos.permission.WRITE_MEDIA"
          },
          {
              "name": "ohos.permission.READ_MEDIA"
          },
          {
              "name": "ohos.permission.MEDIA_LOCATION"
          }
      ]

    然后进行实时动态检测时,通过监听 requestPermissionsFromUser 动态申请权限,该方法会在即将进行实时动态检测之前触发。

      let atManager = abilityAccessCtrl.createAtManager();
      // requestPermissionsFromUser会判断权限的授权状态来决定是否唤起弹窗
      const permissions: Array<Permissions> = ['ohos.permission.CAMERA', 'ohos.permission.MICROPHONE', 'ohos.permission.WRITE_MEDIA','ohos.permission.READ_MEDIA', 'ohos.permission.MEDIA_LOCATION'];
      atManager.requestPermissionsFromUser(context,permissions).then((data) => {
          let grantStatus: Array<number> = data.authResults;
          let length: number = grantStatus.length;
          for (let i = 0; i < length; i++) {
              if (grantStatus[i] === 0) {
                  // 用户授权,可以继续访问目标操作
              } else {
                  // 用户拒绝授权,提示用户必须授权才能访问当前页面的功能,并引导用户到系统设置中打开相应的权限
                  return;
              }
          }
      })
    
      // 详细示例代码下载地址:https://dl.qiyuesuo.com/public/app/doc/webRTC-demo-harmonyos.zip

2. WebView内嵌H5认证页,音视频双录无法正常进入

  • iOS

    WKWebView 从 iOS14.3 开始支持 getUserMedia() 的调用,但是 WKWebView.configuration 的属性 allowsInlineMediaPlayback 默认值是 NO,该属性决定了H5是否可以播放内联HTML5视频。需要将 allowsInlineMediaPlayback 属性的值置为 YES ,即可正常进入音视频双录并完成认证。注:如果使用SFSafariViewController加载H5页面,则不存在该问题。

      // info.plist中添加相机和麦克风权限用途描述
      <key>NSCameraUsageDescription</key>
      <string>相机权限用途描述,文案可自行编辑</string>
      <key>NSMicrophoneUsageDescription</key>
      <string>麦克风权限用途描述,文案可自行编辑</string>
      // 需要先初始化WKWebViewConfiguration并修改好参数,再初始化WKWebView并传入configuration,否则配置可能会不生效!
      WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
      config.allowsInlineMediaPlayback = YES; // 默认是NO,这个值决定了用内嵌HTML5播放视频还是用本地的全屏控制
      config.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeNone; // 音视频的播放不需要用户手势触发,即为自动播放
    
      // 初始化WKWebViewConfiguration后,再初始化WKWebView并传入config
      WKWebView *webview = [[WKWebView alloc] initWithFrame:frame configuration:config];
    
  • Android

    双录走 getUserMedia。原生 WebView 默认拒绝 onPermissionRequest,需先按请求资源动态申请系统权限,再 grant。CAMERA / RECORD_AUDIO 需动态申请;MODIFY_AUDIO_SETTINGS 为普通权限、不弹框,但不声明时部分机型无声音。并设置 setMediaPlaybackRequiresUserGesture(false),避免权限弹窗后预览黑屏。

      <uses-permission android:name="android.permission.CAMERA" />
      <uses-permission android:name="android.permission.RECORD_AUDIO" />
      <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
      webView.getSettings().setMediaPlaybackRequiresUserGesture(false); // 权限弹窗后手势已断,不设可能预览黑屏
    
      private PermissionRequest pendingRequest;
      private static final int REQUEST_CODE_WEB_MEDIA = 0xE1;
    
      @Override
      public void onPermissionRequest(final PermissionRequest request) {
          runOnUiThread(() -> {
              String[] resources = request.getResources();
              if (resources == null || resources.length == 0) {
                  request.deny();
                  return;
              }
              List<String> missing = new ArrayList<>();
              for (String resource : resources) {
                  if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(resource)
                          && ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
                          != PackageManager.PERMISSION_GRANTED) {
                      missing.add(Manifest.permission.CAMERA);
                  } else if (PermissionRequest.RESOURCE_AUDIO_CAPTURE.equals(resource)
                          && ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
                          != PackageManager.PERMISSION_GRANTED) {
                      missing.add(Manifest.permission.RECORD_AUDIO);
                  }
              }
              if (missing.isEmpty()) {
                  grantMedia(request);
                  return;
              }
              // TODO: 也可用自有权限框架替换下面两行
              pendingRequest = request;
              ActivityCompat.requestPermissions(this, missing.toArray(new String[0]), REQUEST_CODE_WEB_MEDIA);
          });
      }
    
      @Override
      public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions,
                                             @NonNull int[] grantResults) {
          super.onRequestPermissionsResult(requestCode, permissions, grantResults);
          if (requestCode != REQUEST_CODE_WEB_MEDIA || pendingRequest == null) {
              return;
          }
          grantMedia(pendingRequest);
          pendingRequest = null;
      }
    
      private void grantMedia(PermissionRequest request) {
          List<String> allowed = new ArrayList<>();
          for (String resource : request.getResources()) {
              if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(resource)
                      && ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA)
                      == PackageManager.PERMISSION_GRANTED) {
                  allowed.add(resource);
              } else if (PermissionRequest.RESOURCE_AUDIO_CAPTURE.equals(resource)
                      && ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO)
                      == PackageManager.PERMISSION_GRANTED) {
                  allowed.add(resource);
              }
          }
          if (allowed.isEmpty()) {
              request.deny();
          } else {
              request.grant(allowed.toArray(new String[0]));
          }
      }
  • HarmonyOS NEXT

    双录走 getUserMedia。需在 module.json5 声明相机、麦克风权限,并在 Web 组件的 onPermissionRequest 中先申请系统权限,再 grant;未处理该回调时,网页侧采集会失败或一直等待。

      {
        "name": "ohos.permission.CAMERA",
        "reason": "$string:camera_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      },
      {
        "name": "ohos.permission.MICROPHONE",
        "reason": "$string:microphone_reason",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }
      import abilityAccessCtrl, { Permissions } from '@ohos.abilityAccessCtrl';
      import common from '@ohos.app.ability.common';
    
      Web({ src: this.portalUrl, controller: this.webController })
        .onPermissionRequest((event) => {
          const request = event.request
          const resources = request.getAccessibleResource()
          const permissions: Permissions[] = []
          for (const resource of resources) {
            if (resource === ProtectedResourceType.VIDEO_CAPTURE) {
              permissions.push('ohos.permission.CAMERA')
            } else if (resource === ProtectedResourceType.AUDIO_CAPTURE) {
              permissions.push('ohos.permission.MICROPHONE')
            }
          }
          if (permissions.length === 0) {
            request.deny()
            return
          }
          const context = this.getUIContext().getHostContext() as common.UIAbilityContext
          abilityAccessCtrl.createAtManager()
            .requestPermissionsFromUser(context, permissions)
            .then((data) => {
              const granted = data.authResults.every((status) => status === 0)
              if (granted) {
                request.grant(resources)
              } else {
                request.deny()
              }
            })
            .catch(() => {
              request.deny()
            })
        })

3. WebView内嵌的H5页面无法正常的调用相机、相册

  • iOS

    根据苹果商店的审核条款,需要在 info.plist 中添加请求相关权限时的提示文案,否则可能会出现闪退的情况。目前契约锁的H5中可能使用到的权限包括「相机」「相册」「麦克风」。配置项如下(已配置项无需再次配置):

      // info.plist中添加相机、相册、麦克风权限用途描述
      <key>NSCameraUsageDescription</key>
      <string>相机权限用途描述,文案可自行编辑</string>
      <key>NSMicrophoneUsageDescription</key>
      <string>麦克风权限用途描述,文案可自行编辑</string>
      <key>NSPhotoLibraryAddUsageDescription</key>
      <string>相册权限用途描述,文案可自行编辑</string>
      <key>NSPhotoLibraryUsageDescription</key>
      <string>相册权限用途描述,文案可自行编辑</string>
  • Android

    H5页面在Android原生WebView中唤起系统相机或相册,在部分机型上可能会出现响应错误或者不响应的问题,可以通过重写 WebChromeClient->onShowFileChooser() ,根据 fileChooserParams.getAcceptTypes() 中的操作类型打开Android原生相机,相册。Android 6.0以上打开相机、相册前需要动态申请文件读取权限。

      // step1 Android 7.0以上文件获取Uri 配置fileprovider
      QYSFileProviderUtils.initAuthorityId(BuildConfig.APPLICATION_ID + ".qysdemo.fileprovider");
    
      // step2 可以参考Demo中QYSWebChromeClient中实现
      @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
      @Override
      public boolean onShowFileChooser(WebView webView, ValueCallback<Uri[]> filePathCallback, WebChromeClient.FileChooserParams fileChooserParams) {
          // 根据fileChooserParams.getAcceptTypes() 判断是需要唤起 相机/相机/文件/录制视频
          // 根据对应需要唤起的操作判断是否需要动态申请权限,如相机拍照后保存路径设置外部路径,需要读写权限
          // 有权限之后App通过Intent唤起本地相机/相机/文件/录制视频
          // 例如拍照完成后,获取到文件的uri,通过filePathCallback回传,filePathCallback.onReceiveValue(new Uri[]{uri});
          boolean hasPermission = PackageManager.PERMISSION_GRANTED == ContextCompat.checkSelfPermission(mActivity, Manifest.permission.WRITE_EXTERNAL_STORAGE);
          if (hasPermission){
              mUploadHandler = new QYSUploadHandler(mActivity,filePathCallback,fileChooserParams);
          }else {
              QYSWebChromeClient.this.filePathCallback = filePathCallback;
              QYSWebChromeClient.this.fileChooserParams = fileChooserParams;
              ActivityCompat.requestPermissions(mActivity, new String[]{
                      Manifest.permission.WRITE_EXTERNAL_STORAGE},
                      REQUEST_CODE_FILE_CHOOSER);
          }
          return true;
      }
    
      // step3 动态申请权限操作
      @Override
      public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
          super.onRequestPermissionsResult(requestCode, permissions, grantResults);
          qysWebChromeClient.onRequestPermissionsResult(requestCode,permissions,grantResults);
      }
    
      // step4 执行选择文件/拍照后上传操作
      @Override
      protected void onActivityResult(int requestCode, int resultCode, Intent data) {
          super.onActivityResult(requestCode, resultCode, data);
          qysWebChromeClient.onActivityResult(requestCode,resultCode,data);
      }
    
      // 备注:以上代码仅供参考,请根据自己的业务场景进行具体实现,详细示例代码下载地址:https://dl.qiyuesuo.com/public/app/doc/filecamera-demo.zip

4. APP内嵌契约锁认证服务H5,认证完成后无法自动返回APP原生页面

在生成认证链接时,可以把APP的URL Scheme链接作为 successPage 传入,认证结束后H5会跳转该URL Scheme,APP可通过监听WebView的路由跳转,拦截指定的URL Scheme,并做出相应处理。另外,successPage不仅限于URL Scheme链接,也可以通过传入指定的http链接并在WebView拦截来实现此功能。可参考下方代码示例,以APP URL Scheme demo:// 为例:

  • iOS

    在 WKNavigationDelegate 的 decidePolicyForNavigationAction 中拦截指定URL Scheme,实现该代理方法前需要先设置代理对象。

      - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
          if ([self handleAuthScheme:navigationAction.request.URL]) {
              decisionHandler(WKNavigationActionPolicyCancel);
              return;
          }
          decisionHandler(WKNavigationActionPolicyAllow);
      }
    
      - (BOOL)handleAuthScheme:(NSURL *)url {
          if (url.scheme.length == 0 || [url.scheme caseInsensitiveCompare:@"demo"] != NSOrderedSame) {
              return NO;
          }
    
          // TODO 自行处理此处业务逻辑
    
          return YES;
      }
  • Android

    Android 7及以上系统回调的是带 WebResourceRequest 的方法,两个重载都要实现。

      webView.setWebViewClient(new WebViewClient() {
          @Override
          public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) {
              if (request != null && handleAuthScheme(request.getUrl())) {
                  return true;
              }
              return super.shouldOverrideUrlLoading(view, request);
          }
    
          @Override
          public boolean shouldOverrideUrlLoading(WebView view, String url) {
              if (handleAuthScheme(Uri.parse(url))) {
                  return true;
              }
              return super.shouldOverrideUrlLoading(view, url);
          }
    
          private boolean handleAuthScheme(Uri uri) {
              if (uri == null || !"demo".equalsIgnoreCase(uri.getScheme())) {
                  return false;
              }
              // TODO 自行处理此处业务逻辑
              return true;
          }
      });
  • HarmonyOS NEXT

    在 onLoadIntercept 里判断scheme,返回 true 后不再继续加载。

      Web({ src: this.authUrl, controller: this.controller })
          .onLoadIntercept((event) => {
              if (this.handleAuthScheme(event.data.getRequestUrl())) {
                  return true;
              }
              return false;
          })
    
      private handleAuthScheme(requestUrl: string): boolean {
          if (!requestUrl || !requestUrl.toLowerCase().startsWith('demo://')) {
              return false;
          }
          // TODO 自行处理此处业务逻辑
          return true;
      }

5. WebView内嵌的H5页面无法跳转到微信、支付宝等第三方APP

在没有对WebView做任何处理的情况下,无法主动唤起微信、支付宝等第三方APP。在WebView中,可以通过拦截支付宝的认证链接,以打开URL Scheme的方式唤醒第三方APP。

  • iOS

      // info.plist中添加需要跳转的URL Scheme至白名单
      <key>LSApplicationQueriesSchemes</key>
      <array>
          <string>alipays</string>
          <string>weixin</string>
      </array>

    实现WKWebView中的代理方法

      #pragma mark - <WKNavigationDelegate>
      - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
          NSURL *url = navigationAction.request.URL;
          NSString *urlString = url.absoluteString;
          if (urlString && urlString.length) {
              if (([urlString hasPrefix:@"weixin://"] || [urlString hasPrefix:@"alipays://"]) && [[UIApplication sharedApplication] canOpenURL:url]) { // 第三方APP的URL Scheme
                  [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
                  decisionHandler(WKNavigationActionPolicyCancel);
                  return;
              }
          }
          decisionHandler(WKNavigationActionPolicyAllow);
      }
  • Android

    在没有对WebView做任何处理的情况下,无法主动唤起微信、支付宝等第三方APP。在WebView中,可以重写 WebViewClient 的 shouldOverrideUrlLoading 方法,以打开URL Scheme的方式唤醒第三方APP。

      @Override
      public boolean shouldOverrideUrlLoading(WebView view, String url) {
          if (url.startsWith("alipays://") || url.startsWith("weixin://")) {
              try {
                  final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
                  intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_SINGLE_TOP);
                  startActivity(intent);
              } catch (Exception e) {
                  // 处理没有安装支付宝/微信的情况
                  e.printStackTrace();
              }
              return true;
          }
          return super.shouldOverrideUrlLoading(view, url);
      }

6. WebView内嵌的H5认证页面跳转到支付宝认证完成后,无法自动跳回APP

第三方APP在直接或间接加载契约锁认证链接,并点击跳转至支付宝APP进行认证,完成后无法自动跳回第三方APP,若需要认证后自动跳回第三方APP,需要对认证链接按照【<auth_url>&appScheme=demo://】格式进行拼接appScheme参数(其中,"auth_url"为原始契约锁认证链接,"appScheme"为要拼接参数名,"demo://"为需跳回App的URL Scheme):

  • iOS

    WKWebView直接加载契约锁认证链接,直接将 "&appScheme=demo://" 拼接在原始认证链接之后:

      [wkWebView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://auth.qiyuesuo.com?<originalQuery>&appScheme=demo://"]]];

    WKWebView间接加载契约锁认证链接,需要实现WKWebView中的代理方法:

      #pragma mark - <WKNavigationDelegate>
      - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
          NSString *string = = navigationAction.request.URL.absoluteString;
          if ([string rangeOfString:@"https://auth.qiyuesuo.com"] != NSNotFound) {
              decisionHandler(WKNavigationActionPolicyCancel);
    
              // 截取认证链接后进行拼接
              NSString *appendUrl = [string stringByAppendingString:@"&appScheme=demo://"];
              [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:appendUrl]]];
              return;
          }
    
          ...
          // 其他第三方App业务逻辑代码
          ...
    
          decisionHandler(WKNavigationActionPolicyAllow);
      }
  • Android

    WebView直接加载契约锁认证链接,直接将 "&appScheme=demo://" 拼接在原始认证链接之后:

      webview.loadUrl("https://auth.qiyuesuo.com?<originalQuery>&appScheme=demo://");

    WebView间接加载契约锁认证链接:

      webview.setWebViewClient(new WebViewClient(){
          @Override
          public boolean shouldOverrideUrlLoading(WebView view, String url) {
              if (url.contains("https://auth.qiyuesuo.com")){
                  // 截取认证链接后进行拼接(此处仅为参考,拼接需符合http/https请求规范)
                  String appendUrl = url + "&appScheme=demo://";
                  view.loadUrl(appendUrl);
                  return true;
              }
              return super.shouldOverrideUrlLoading(view, url);
          }
      });

7. WebView内嵌的H5认证页面认证完成后无法自动返回,或点击退出认证按钮无反应

认证的H5在认证完成后,会调用JS方法 returnApp,APP内的WebView需要监听该JS方法的调用,以实现退出后的逻辑处理。

示例代码如下:

  • iOS

      // 为JS方法「returnApp」添加响应对象「jsHandler」,「jsHandler」可以是实现 <WKScriptMessageHandler> 协议的任意对象
      [webView.configuration.userContentController addScriptMessageHandler:jsHandler name:@"returnApp"];
    
      //「jsHandler」实现代理方法
      - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
          if ([message.name isEqualToString:@"returnApp"]) {
              // 在此处编写退出页面或其他退出逻辑的代码
          }
      }
  • Android

      protected void addJavascriptInterface(WebView mWebView) {
          mWebView.addJavascriptInterface(new JsToH5Api(), "android");
      }
    
      class JsToH5Api{
          @JavascriptInterface
          public void returnApp() {
              // 调用finish方法退出界面 或 处理自己的退出逻辑
              finish();
          }
      }

8. WebView内嵌的H5页面无法下载文件或下载文件乱码

契约锁前端H5页面中的文件下载,并不是通过跳转资源文件目录触发下载的形式实现的,而是通过下载接口(需要登录态)使用blob触发的下载事件。所以第三方APP内嵌契约锁H5时,原生想要截获文件下载事件就必须通过监听blob,从而触发APP原生相关的下载操作。

  • iOS

    WKWebView从iOS14.5开始新增了 WKNavigationActionPolicyDownload 的导航操作策略,可通过 WKNavigationDelegate 代理方法截获blob从而触发下载事件。

      #pragma mark - <WKNavigationDelegate>
      - (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler {
          NSURL *url = navigationAction.request.URL;
          if ([url.absoluteString hasPrefix:@"blob"]) { // 截获blob下载事件
              decisionHandler(WKNavigationActionPolicyDownload); // 仅在iOS14.5及更新版本支持WKNavigationActionPolicyDownload
              return;
          }
    
          ...
          // 其他第三方App业务逻辑代码
          ...
    
          decisionHandler(WKNavigationActionPolicyAllow);
      }
    
      // ↓↓↓以下代理方法均只适用于iOS14.5及更高版本↓↓↓
      #pragma mark - <WKNavigationDelegate>
      - (void)webView:(WKWebView *)webView navigationAction:(WKNavigationAction *)navigationAction didBecomeDownload:(WKDownload *)download API_AVAILABLE(ios(14.5)) {
          download.delegate = self;// 设置WKDownloadDelegate
      }
    
      #pragma mark - <WKDownloadDelegate>
      - (void)download:(WKDownload *)download decideDestinationUsingResponse:(NSURLResponse *)response suggestedFilename:(NSString *)suggestedFilename completionHandler:(void (^)(NSURL * _Nullable))completionHandler API_AVAILABLE(ios(14.5)) {
          NSURL *tmpDir = [[NSFileManager defaultManager] temporaryDirectory];    // 根据具体业务场景决定下载路径
          NSURL *url = [tmpDir URLByAppendingPathComponent:suggestedFilename];    // 此处仅为示例,实际场景中建议文件名去重,同样路径同样文件名会导致下载失败
          completionHandler(url);                                                 // 返回目标url
      }
    
      - (void)downloadDidFinish:(WKDownload *)download API_AVAILABLE(ios(14.5)) {    
          // 下载成功后的代理回调方法
      }
    
      - (void)download:(WKDownload *)download didFailWithError:(NSError *)error resumeData:(nullable NSData *)resumeData API_AVAILABLE(ios(14.5)) {
          // 下载失败后的代理回调方法
      }
  • Android

    Android 原生不支持blob协议,但是我们可以监听前端blob下载,自己实现blob转成Base64从而进行文件下载。

      // step 1 需要支持javaScript
      mWebview.getSettings().setJavaScriptEnabled(true);
      mWebview.getSettings().setJavaScriptCanOpenWindowsAutomatically(true);
    
      // step 2 注册 javaScript监听
      DownloadBlobFileJSInterface mDownloadBlobFileJSInterface = new DownloadBlobFileJSInterface();
      mWebview.addJavascriptInterface(mDownloadBlobFileJSInterface, "Android");
    
      // step 3 监听 DownloadListener,这里根据业务逻辑动态申请文件权限
      mWebview.setDownloadListener(new DownloadListener() {
          @Override
          public void onDownloadStart(String url, String userAgent, String contentDisposition, String mimetype, long contentLength) {
              if (url.startsWith("blob")) {
                  // 动态申请文件权限
                  mWebview.loadUrl(DownloadBlobFileJSInterface.getBase64StringFromBlobUrl(url));
              }
          }
      });
    
      // step 4 支持监听文件下载状态
      mDownloadBlobFileJSInterface.setDownloadListener(new DownloadBlobFileJSInterface.BlobDownloadListener() {
          @Override
          public void onSuccess(String absolutePath) {
              Log.d(getClass().getSimpleName(),String.format("文件下载成功,路径%s",absolutePath));
          }
    
          @Override
          public void onFaild(String message) {
              Log.d(getClass().getSimpleName(),String.format("文件下载失败,原因%s",message));
          }
      });
    
      // step 5 DownloadBlobFileJSInterface 实现,仅做参考
      public class DownloadBlobFileJSInterface {
          private BlobDownloadListener blobDownloadListener;
    
          @JavascriptInterface
          public void receiveBlobData(String base64Data,String fileName) {
              // 此处为保存文件路径,可根据实际场景自行调整
              File file = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + File.separator + fileName);
              try {
                  base64Data = base64Data.contains(",") ? base64Data.split(",")[1] : base64Data;
                  byte[] fileBytes = Base64.decode(base64Data, 0);
                  FileOutputStream os = new FileOutputStream(file, false);
                  os.write(fileBytes);
                  os.flush();
                  os.close();
                  if (blobDownloadListener != null) blobDownloadListener.onSuccess(file.getAbsolutePath());
              } catch (Exception e) {
                  e.printStackTrace();
                  if (blobDownloadListener != null) blobDownloadListener.onFaild(e.getMessage());
              }
          }
    
          public static String getBase64StringFromBlobUrl(String blobUrl) {
              return "javascript: " +
                      "var xhr = new XMLHttpRequest();" +
                      "xhr.open('GET', '" + blobUrl + "', true);" +
                      "xhr.responseType = 'blob';" +
                      "xhr.onload = function() {" +
                      "    var blob = xhr.response;" +
                      "    var reader = new FileReader();" +
                      "    reader.onloadend = function() {" +
                      "        var base64data = reader.result.split(',')[1];" +
                      "        var contentType = blob.type;" +
                      "        var contentDisposition = xhr.getResponseHeader('Content-Disposition');" +
                      "        var fileName = 'downloaded_file';" +
                      "        if (contentDisposition) {" +
                      "            var matches = /filename=\"([^\"]+)\"/.exec(contentDisposition);" +
                      "            if (matches != null && matches[1]) { fileName = matches[1]; }" +
                      "        }else{" +
                      "           var urlParts = xhr.responseURL.split('/');" +
                      "           var possibleFileName = urlParts[urlParts.length - 1];" +
                      "           if (possibleFileName) { fileName = possibleFileName; }" +
                      "        }" +
                      "        Android.receiveBlobData(base64data, fileName);" +
                      "    };" +
                      "    reader.readAsDataURL(blob);" +
                      "};" +
                      "xhr.send();";
          }
    
          public void setDownloadListener(BlobDownloadListener listener) {
              blobDownloadListener = listener;
          }
    
          public interface BlobDownloadListener {
              void onSuccess(String absolutePath);
    
              void onFaild(String message);
          }
      }

9. WebView的UserAgent相关问题

H5页面在某些场景下,需要判断当前是否处于APP的WebView内嵌环境下,此时需要第三方APP在WebView的UserAgent中,增加契约锁指定的UserAgent标识:QYSCustomerApp/private 。

  • iOS

    获取WKWebView的完整UserAgent需要执行 navigator.userAgent,建议最好在原始的完整UserAgent末尾拼接契约锁指定标识。

      [self.webView evaluateJavaScript:@"navigator.userAgent" completionHandler:^(id result, NSError *error) {
          // 获取原始UserAgent
          NSMutableString *userAgent = [result mutableCopy];
    
          // 判断标识是否已存在,若不存在则拼接契约锁指定的UA标识
          if (![userAgent containsString:@"QYSCustomerApp/private"]) { 
              [userAgent appendString:@" QYSCustomerApp/private"];
          }
    
          // 配置全局UserAgent
          [[NSUserDefaults standardUserDefaults] registerDefaults:@{@"UserAgent":[userAgent copy]}];
          [[NSUserDefaults standardUserDefaults] synchronize];
    
          // 配置当前WKWebView的UserAgent并且立即生效
          self.webView.customUserAgent = [userAgent copy];
      }];
  • Android

    获取WebView的完整UserAgent需要执行 webView.getSettings().getUserAgentString(),建议最好在原始的完整UserAgent末尾拼接契约锁指定标识,此设置放在webview.loadUrl()之前。

      String appendUa = " QYSCustomerApp/private";
      WebSettings settings = webView.getSettings();
      settings.setUserAgentString(settings.getUserAgentString() + appendUa);

10. Android APP无法跳转到契约锁APP

H5中存在部分操作无法在第三方APP内完成,需跳转至契约锁APP内完成相关操作;第三方APP的Android端在WebView中需要做以下处理,方可成功跳转至契约锁APP。

  • Android

    重写 WebViewClient 的 shouldOverrideUrlLoading 方法,通过契约锁的 URL Scheme 打开契约锁APP

      @Override
      public boolean shouldOverrideUrlLoading(WebView view, String url) {
              Uri uri = Uri.parse(url);
              if("qys".equals(uri.getScheme())){
                  try {
                      Intent intent = new Intent(Intent.ACTION_VIEW, uri);
                      intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                      startActivity(intent);
                  } catch (Exception e) {
                      e.printStackTrace();
                      // 未安装契约锁App,跳转应用市场下载
                      Uri downloadApkUri = Uri.parse("market://details?id=com.genyannetwork.qiyuesuo");
                      Intent intent =new Intent(Intent.ACTION_VIEW, downloadApkUri);
                      intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                      startActivity(intent);
                  }
                  return true;
              }
              return super.shouldOverrideUrlLoading(view, url);
      }

11. Android WebView内嵌的H5页面中的图片无法长按保存至本地

  • Android

    长按保存图片是浏览器的功能,原生WebView默认不支持,如果需要实现浏览器相似的功能,可通过监听WebView的 setOnLongClickListener 方法实现。

      mWebView.setOnLongClickListener(v -> {
          //获取所点击的内容
          final WebView.HitTestResult htr = ((WebView) v).getHitTestResult();
          //判断被点击的类型为图片
          if (htr.getType() == WebView.HitTestResult.IMAGE_TYPE || 
              htr.getType() == WebView.HitTestResult.IMAGE_ANCHOR_TYPE || 
              htr.getType() == WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE) {
              String imagePath = htr.getExtra();
              // TODO 下载当前图片到本地
              // yourSaveImageMethod(imagePath);
          }
          return false;
      });

12. iOS WKWebView内嵌的H5认证页面,每一次使用活体都需要弹窗询问权限

  • iOS

    WKWebView内嵌认证H5使用活体检测的场景下,默认会每一次都弹出获取摄像头权限的弹窗;如果不想每次认证都弹出,可以给WKWebView设置UIDelegate,并实现如下代理方法;需要注意的是,该代理方法仅适用于iOS15及更高版本。

      - (void)webView:(WKWebView *)webView requestMediaCapturePermissionForOrigin:(WKSecurityOrigin *)origin initiatedByFrame:(WKFrameInfo *)frame type:(WKMediaCaptureType)type decisionHandler:(void (^)(WKPermissionDecision decision))decisionHandler  API_AVAILABLE(ios(15.0)) {
          decisionHandler(WKPermissionDecisionGrant); // 可设置默认放行,也可根据APP自身需要调整
      }  

13. iOS WKWebView内嵌的H5页面部分头部内容被遮挡

  • iOS

    iOS的WKWebView的内部容器是UIScrollView,可能存在ScrollView内边距自适应的情况,需要禁用相关设置。

      viewController.webview.scrollView.contentInsetAdjustmentBehavior = UIScrollViewContentInsetAdjustmentNever;
      viewController.edgesForExtendedLayout = UIRectEdgeNone; // 视图不延伸至任何边缘