《成为大前端》系列 6. WebView加载本地资源

2,469 阅读5分钟

前提说明

在 Camera 模块中,我们提供了takePicturetakeVideo方法,这两个方法返回给 JS 端的都是本地图片视频资源的 uri,并且系统和系统版本不一样提供的 uri 也不一样。

这类 uri 在 WebView 中无法直接加载。

有一种方法,是直接读取资源转换成 base64,传递给 WebView 中读取和显示。但是缺点也很明 显,比如资源过大传递的base64过大转换成本等等。

因此,前面CameraImage模块使用的都是 uri,同时也引入一些问题。

这些 uri 由 native 返回,会包含以下协议种类:

  • assets-library://...
  • content://..
  • file://...

但是 WebView 目前不支持加载这些协议,WebView 通常只支持 http 和 https,因此我们开着手 解决这个问题,让图片和视频都能加载并显示出来。

iOS

实践 iOS 端 WKWebView 如何加载本地资源,涉及到以下几点:

  • WKWebView 的 WKURLSchemeHandler
  • Photos: 苹果系统用于访问系统相册的资源

在使用到的代码上需要引入:

import Photos
import MobileCoreServices

WKURLSchemeHandler

在 WebViewController 的 viewDidLoad,添加如下一行代码:

let config = WKWebViewConfiguration()
...
// 注册assets-library协议的handler
config.setURLSchemeHandler(self, forURLScheme: "assets-library")
...

给 WebViewController 添加 extension,实现 WKURLSchemeHandler

extension WebViewController : WKURLSchemeHandler {

    func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
        ...
    }

    func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
        ...
    }
}

WKURLSchemeHandler 是系统提供我们用于拦截浏览器请求的自定义 scheme 的资源,通过实现这个协议可以实现拦截:

extension WebViewController : WKURLSchemeHandler {

    func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
        let req = urlSchemeTask.request
        guard let url = req.url, let scheme = url.scheme else {
            urlSchemeTask.didWKWebViewError(code: NSURLErrorUnsupportedURL)
            return
        }
    }

    func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
        ...
    }
}

我们前面讲过了,iOS 的 JSBridge 的 Image 和 Camera 的模块返回的是:

  • file://...
  • assets-library://...

这两种资源,因此我们拦下这两种资源的请求,然后读取本地的资源返回,最终代码是:

extension WebViewController : WKURLSchemeHandler {

    func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
        let req = urlSchemeTask.request
        guard let url = req.url, let scheme = url.scheme else {
            urlSchemeTask.didWKWebViewError(code: NSURLErrorUnsupportedURL)
            return
        }

        // 如果scheme是相册的
        if scheme == "assets-library" {

            // 通过Photos框架,读取PHAsset
            let result = PHAsset.fetchAssets(withALAssetURLs: [url], options: nil)
            guard let asset = result.firstObject else {
                // 如果不成功,返回失败
                urlSchemeTask.didWKWebViewError(code: NSURLErrorResourceUnavailable)
                return
            }

            // 出来图片
            if asset.mediaType == .image {
                let options = PHImageRequestOptions()
                options.isNetworkAccessAllowed = true
                options.deliveryMode = .highQualityFormat
                options.isSynchronous = false
                options.version = .current

                // 请求资源数据
                PHImageManager.default().requestImageData(for: asset, options: options) { data, _, _, _ in
                    guard let data = data else {
                        urlSchemeTask.didWKWebViewError(code: NSURLErrorResourceUnavailable)
                        return
                    }
                    let mimeType = asset.mimeType()

                    // WebView不支持heic,转成jpeg
                    if mimeType == "image/heic" {
                        guard let image = UIImage(data: data), let jpegData = image.jpegData(compressionQuality: 1.0) else {
                            urlSchemeTask.didWKWebViewError(code: NSURLErrorCannotDecodeRawData)
                            return
                        }
                        let resp = URLResponse(
                            url: url,
                            mimeType: "image/jpeg",
                            expectedContentLength: jpegData.count,
                            textEncodingName: nil)
                        urlSchemeTask.didReceive(resp)
                        urlSchemeTask.didReceive(jpegData)
                        urlSchemeTask.didFinish()
                    }

                    // 其他图片
                    else {
                        let resp = URLResponse(
                            url: url,
                            mimeType: mimeType,
                            expectedContentLength: data.count,
                            textEncodingName: nil)
                        urlSchemeTask.didReceive(resp)
                        urlSchemeTask.didReceive(data)
                        urlSchemeTask.didFinish()
                    }
                }
            }
            // 视频处理
            else if asset.mediaType == .video {
                let options = PHVideoRequestOptions()
                options.isNetworkAccessAllowed = true
                options.deliveryMode = .highQualityFormat
                options.version = .current

                // 请求video的数据
                PHImageManager.default().requestAVAsset(forVideo: asset, options: options) { avAsset, _, _ in

                    // 转成AVURLAsset,读取data
                    guard let urlAsset = avAsset as? AVURLAsset, let data = try? Data(contentsOf: urlAsset.url) else {
                        urlSchemeTask.didWKWebViewError(code: NSURLErrorCannotDecodeRawData)
                        return
                    }
                    // 返回
                    let mimeType = asset.mimeType()
                    let resp = URLResponse(
                        url: url,
                        mimeType: mimeType,
                        expectedContentLength: data.count,
                        textEncodingName: nil)
                    urlSchemeTask.didReceive(resp)
                    urlSchemeTask.didReceive(data)
                    urlSchemeTask.didFinish()
                }
            } else {
                urlSchemeTask.didWKWebViewError(code: NSURLErrorResourceUnavailable)
            }
        }
    }

    func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {

    }
}

fileprivate extension WKURLSchemeTask {
    func didWKWebViewError(code: Int) {
        let error = NSError(domain: "WKWebViewError", code: code, userInfo: nil)
        self.didFailWithError(error)
    }
}

Android

实践 Android 端 WebView 如何加载本地资源,涉及到以下几点:

  • WebViewClient 拦截资源请求
  • ContentResolver:内容访问程序

扩展阅读:

内容提供程序 developer.android.com/guide/topic…

WebViewClient

还记得前面我们有一句代码:

webView.webViewClient = WebViewClient()

WebViewClient 是系统提供我们用于控制浏览器的行为,WebViewClient 类是系统提供的默认实现。我们 通过继承这个类可以实现很多控制,推荐大家网上搜索学习,这里直接进入正题:

继承 WebViewClient,需要提供参数 context,后面会使用到

class CustomWebViewClient(val context: Context) : WebViewClient() {

    override fun shouldInterceptRequest(view: WebView, url: String): WebResourceResponse? {
      ...
    }
}

使用新的 CustomWebViewClient

webView.webViewClient = CustomWebViewClient(this)

原理

我们覆盖shouldInterceptRequest方法,这个方法可以让我拦截到这个 WebView 下的所有网络请求, 请求链接就是参数 url,拦下之后我们可以控制返回给 webView 的结果

但是,我们前面讲过了,Android 的 JSBridge 的 Image 和 Camera 的模块返回的是:

  • file://...
  • content://...

这两种资源,因此我们需要转换一个思路,由于我们可以拦截 http 的请求,那么我们把资源原本的链接拼到 http 的链接中,比如:

http://...?file=...

file 参数就是我的原链接,但是我们如何定义这个链接呢,简单的做法,使用一个基本不会被外界使用的域名:

加上资源链接为:content://media/240534,转换后的链接是:

file.local?file=content://media/2405…

那么加载图片时,使用:

<img src="http://file.local?file=content://media/240534" />

这个链接的请求会被拦截下来,file://协议的资源同理

file.local?file=file://...

那么 Native 端的具体实现:

class CustomWebViewClient(val context: Context) : WebViewClient() {

    override fun shouldInterceptRequest(view: WebView, url: String): WebResourceResponse? {
        Log.e("WebView", "intercept $url")
        val uri = Uri.parse(url)
        // 解析拦截的url,判断域名
        if (uri.host != "file.local") {
            // 返回null,那么WebView会真的请求网络
            return null
        }

        // 拿到file参数,否则也返回null
        val filePath = uri.getQueryParameter("file") ?: return null
        Log.e("WebView", "file $filePath")

        // file协议
        if (filePath.startsWith("file://")) {
            // 去掉file://前缀创建File对象
            val file = File(filePath.substring("file://".length))
            // 通过ext得到mime
            val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)
            // 创建FileInputStream
            val fis = FileInputStream(file)
            // 返回本地的内容的Response
            return WebResourceResponse(mimeType, StandardCharsets.UTF_8.name(), fis)
        }
        // content协议
        else if (filePath.startsWith("content://")) {
            // 创建Uri
            val fileUri = Uri.parse(filePath)
            // 获取mimeType
            val mimeType = context.contentResolver.getType(fileUri)
            // 通过contentResolver创建InputStream
            val fis = context.contentResolver.openInputStream(fileUri)
            // 返回本地的内容Response
            return WebResourceResponse(mimeType, StandardCharsets.UTF_8.name(), fis)
        }

        // 其他情况
        return null
    }

}

JS

由于index.html,这次我们使用新的页面去实现这个功能image.html,内容如下:

<body>
  <script src="./jsbridge.js"></script>
  <script type="text/javascript">
    function toLocalUrl(file) {
      // android返回file.local域名的资源
      if (window.androidBridge) {
        return "http://file.local?file=" + encodeURI(file);
      } 
      // iOS返回原始资源
      else {
        return file;
      }
    }

    function onClickButton(button) {
      var img = document.getElementById("image");
      var video = document.getElementById("video");
      switch (button.innerText) {
        case "Camera.takePicture":
          JSBridge.Camera.takePicture(result => {
            if (result.success) {
              JSBridge.UI.toast(result.file);
              img.src = toHttpLocalUrl(result.file);
            }
          });
          break;
        case "Camera.takeVideo":
          JSBridge.Camera.takeVideo(result => {
            if (result.success) {
              JSBridge.UI.toast(result.file);
              video.innerHTML =
                "<source src='" +
                toHttpLocalUrl(result.file) +
                "' type='" +
                result.type +
                "'>";
            }
          });
          break;
        case "Image.pickPhotos":
          JSBridge.Image.pickPhotos(result => {
            if (result.success) {
              JSBridge.UI.toast(result.file);
              if (result.type.indexOf("video/") === 0) {
                video.innerHTML =
                  "<source src='" +
                  toHttpLocalUrl(result.file) +
                  "' type='" +
                  result.type +
                  "'>";
              } else {
                img.src = toHttpLocalUrl(result.file);
              }
            }
          });
          break;
      }
    }
  </script>
  <button onclick="onClickButton(this)">Camera.takePicture</button>
  <button onclick="onClickButton(this)">Camera.takeVideo</button>
  <button onclick="onClickButton(this)">Image.pickPhotos</button>
  <img id="image" />
  <video id="video" controls></video>
</body>

Android 改下链接为 image.html

class MainActivity : WebActivity() {

    override fun getLoadUrl(): String {
        return "http://192.168.31.101:8000/image.html"
    }
}

运行效果:选择图片和视频后会显示出来。