iOS需要支持H5自定义控制拍照行为及结果的相关笔记

需求

  1. iPhone 手机
  2. 使用UNI-APP开发的 H5 功能页面
  3. 需要支持拍摄照片后
    1. 自动加上水印
    2. 自动缩小尺寸
    3. 支持拍多张照片
    4. 支持默认启动前置摄像头

各种限制约束情况

  1. iOS 默认只支持调用相机自动返回照片

    iOS不支持像Android那样在WebChromeClientopenFileChooser函数里监听网页的input标签相关调用

  2. iOS 容器需要使用WKWebViews

    使用协议监听[NSURLProtocol registerClass:[HybridNSURLProtocol class]];

    需要用完就反注册掉,否则会影响其它POST请求导致丢失请求体Body.

  3. 要求JS启动拍照的代码uni.chooseImage尽可能的保持不变

    减少H5相关代码的修改

    理想状态是H5端代码0修改

  4. 要求服务器接收上传的图片的代码尽量不修改保持不动

    例如瑕疵方案1就是因为无法上传时获取原始文件名导致后台服务器无法正常保存图片, 就降级为了瑕疵方案.

H5和Native传递二进制图片的方案

  1. (暂不考虑)通过Base64性能太差,数据量会占用大量内存,可能会卡顿,
  2. (暂不考虑)通过LocalServer需要集成另外的组件,
  3. (最终方案)通过NSURLProtocol 来拦截指定规则的图片
    1. (尝试失败)想通过 自定义CustomScheme来减少对其它POST业务的影响
      可能方法不对,从网络上没找到DEMO,通过只言片语尝试没成功
    2. (最终方案)通过尽量晚的注册协议, 尽量早的注销协议,来尽量减少对其它POST业务的影响

参考资料

  1. 让 WKWebView 支持 NSURLProtocol | Wasteland

    registerSchemeForCustomProtocol: 这个方法名来猜测,它的作用的应该是注册一个自定义的 scheme,这样对于 WebKit 进程的所有网络请求,都会先检查是否有匹配的 scheme,有的话再走主进程的 NSURLProtocol 这一套流程

  2. H5 与 Native 交换图片技术方案 | 卡洛斯的博客

    技术选型

    iOS Local Web Server

    CocoaHTTPServer

    Android Local Web Server(仅供 Android 参考,要选能支持 Https 的服务器)

    AndroidAsync

    android-http-server

    nanohttpd

    说明微信在本地也是开启了一个本地web服务,来进行 H5 和 Native 的图片交换

  3. iOS 网络安全 - 利用 NSURLProtocol 处理重定向 - 简书

    那具体些,NSURLProtocol 能处理些什么问题呢?当你注册自定义 NSURLProtocol 后,就有机会对所有的请求进行统一的处理,基于这一点它可以让你:

    1.自定义网络请求和响应结果

    2.提供自定义的全局缓存支持|实现网络缓存。

    3.重定向网络请求,实现代理等功能。

    4.对 HTTP 返回内容进行Mocking 和Stub (方便前期测试) 。

    5.全局设置网络请求。

  4. 一站式解决WKWebView各类问题 - SegmentFault 思否

    一站式解决WKWebView各类问题 | 卡洛斯的博客

    关于 body 丢失的问题

    • 在请求之前把 url 上的 http/https scheme 替换成自定义的 customscheme,然后返回的 Html 中的子资源链接,使用 src='//xxx.com/a.js' 的形式去加载,这样也会让子资源带上 customscheme,通过这种方式 NSURLProtocol 只用注册和拦截 customscheme,针对 customscheme 丢失 body 信息没有任何影响,然后 H5 ajax 请求,还是可以走 http/https 来请求。这种也可以解决 body 丢失的问题,但是必须要求 H5 侧能够按照规范来,如果 H5 没有历史包袱,这种也是可行的,如果有历史包袱的话,也会有改动的成本。
    • 注入 ajax hook js 代码,对所有 XMLHTTPRequest 对象进行 hook,在 open,send 等方法处进行拦截,通过 JSAPI 把 url,数据转发到 Native 去发送请求。这种可以解决 body 丢失的问题,而且对 H5 ajax 请求是无侵入式的,但是这种方式相关开源实践很少。

    GitHub地址

    karosLi/KKJSBridge: 一站式解决 WKWebView 支持离线包,Ajax/Fetch 请求,表单请求和 Cookie 同步的问题 (基于 Ajax Hook,Fetch Hook 和 Cookie Hook)

  5. WKWebView 离线包方案比较 | 卡洛斯的博客

    付宝 mPaas 离线包方案(基于 WKWebView)探秘

    实际上支付宝 mPaas 离线包方案是基于 NSURLProtocol + Ajax Hook来实现的。

  6. iOS 端容器之 WKWebView 那些事 - SegmentFault 思否

    iOS 端容器之 WKWebView 那些事-阿里云开发者社区

    通过 [WKBrowsingContextController registerSchemeForCustomProtocol:] 来注册代理,为方便简称为代理方案1。

    通过 [WKWebViewConfiguration setURLSchemeHandler:forURLScheme:] 来注册,为方便简称为代理方案 2。

    代理方案 2

    此方案是基于苹果在 iOS 11 上开放的 [WKWebViewConfiguration setURLSchemeHandler:forURLScheme:] 接口做"扩展"来实现。对于 iOS 11.3 以后的设备,此方案具备较好实用性(WebKit 处理了部分 Body 传递问题)。

触发启动的方案

  1. (优化启动代码)需要修改H5的启动拍照的代码(H5需要修改)

    uni.chooseImage修改为aTestJSBridge.chooseImage

  2. (优化启动代码) 无需修改H5的启动拍照的代码(H5零改无感)

    在Native层直接注入Hook代码将uni.chooseImage函数替换为协议启动拍照的代码即可.

    uni.chooseImage = function(){/*实际代码*/}

解决方案3 - 直接Hook标准的INPUT标签的change事件

if (!HTMLInputElement.prototype.addEventListenerBak) {
    HTMLInputElement.prototype.addEventListenerBak = HTMLInputElement.prototype.addEventListener;
    HTMLInputElement.prototype.addEventListener = function(type, listener, other) {
        //检测到是 uni.chooseImage 创建的隐藏的特殊的 input 控件后,需要将其添加的 change 监听事件缓存下来,方便后续的行为模拟
        if (type === 'change' && this.accept.startsWith('image') && this.style.position === 'absolute' && this.style.visibility === 'hidden') {
            console.log(type, listener);
            console.dir(this);

            //将默认的点击行为修改为通过协议启动拍照的行为.
            this.click = function() {
                //TODO:
                console.log('click', this);
            }
        }
        //无论啥情况都要回调默认的实现
        HTMLInputElement.prototype.addEventListenerBak.call(this, type, listener, other)
    }
}

优点: 解决了悲催方案2的缺点,也能正常将将原始file对象存进缓存

悲催方案2 - 使用uni.downloadFile获取图片

iOS网页容器在网页加载前和后注入aTestJSBridge等相关桥接代码

aTestJSBridge.chooseImage = function (opt) {
    aTestJSBridge.lastChooseImageSuccessCallback = function (filePath) {
        var size = 0;
        const downloadTask = uni.downloadFile({
            url: filePath,
            success: (res) => {
                opt.success({
                    "tempFilePaths": [res.tempFilePath],
                    "tempFiles": [{"path": res.tempFilePath, "size": size, "type": "image/jpeg", "name": filePath.substring(filePath.lastIndexOf("\&") + 1, filePath.length)}]
                });
            }
        });
        downloadTask.onProgressUpdate((res) => {
            size = res.totalBytesExpectedToWrite;
        });
    }
    calliOSFunction("aTestJSBridge", "chooseImage", opt);
}

优点: uni.downloadFile可以将原始file对象存进缓存, 方便上传时能拿到对应的fileName原始图片名

缺点: UNI-APP发布H5之后,uni.downloadFile函数被精简掉了

  1. DEBUG调试的时候一切正常
  2. 发布之后出问题后发现uni.downloadFile函数已经被精简掉了.
  3. 所以此方案无效,无法线上使用.

瑕疵方案1 - 使用XMLHttpRequest获取图片

  1. iOS网页容器在网页加载前和后注入aTestJSBridge等相关桥接代码

    aTestJSBridge.chooseImage = function (opt) {
        azyk.lastChooseImageSuccessCallback = function(filePath){
            var oReq = new XMLHttpRequest();
            oReq.addEventListener("load", function(xxx){
                objectURL = URL.createObjectURL(xxx.target.response);
                var site = xxx.target.responseURL.lastIndexOf("\&");
                name = xxx.target.responseURL.substring(site+1,xxx.target.responseURL.length);
                opt.success({"tempFilePaths":[objectURL],"tempFiles":[{"path":objectURL,"size":xxx.target.response.size,"type":"image/png","name":name}]});
            });
            oReq.responseType = "blob";
            oReq.open("GET", filePath);
            oReq.send();
        }
        calliOSFunction("aTestJSBridge","chooseImage",opt);
    }
    

优点: 兼容性好, 不依赖uni.downloadFile函数

缺点: 上传的时候无法拿到原始文件名

  1. 研究UNI官方源码后发现通过INPUT标签选择到的文件对象会特地缓存起来

    import { fileToUrl } from 'uni-platform/helpers/file'

  2. 然后调用 uni.uploadFile时,能通过url直接命中缓存,就能读取到文件名了.

  3. 但是这个原生的获取方案,无法写入uni的缓存, 导致上传时获取不了文件名.

  4. 虽然获取文件名这个功能不算核心功能,但是还是需要后台代码进行修改, 终究不够无感.

相关分析

  1. H5核心代码: 用户点击拍照按钮, 然后拍照 再上传图片

    uni.chooseImage({
        success: (chooseImageRes) => {
            const tempFilePaths = chooseImageRes.tempFilePaths;
            uni.uploadFile({
                url: 'https://www.example.com/upload', //仅为示例,非真实的接口地址
                filePath: tempFilePaths[0],
                name: 'file',
                formData: {
                    'user': 'test'
                },
                success: (uploadFileRes) => {
                    console.log(uploadFileRes.data);
                }
            });
        }
    });
    
  2. uni.chooseImage源码流程

    源码所在路径: ?:\Program Files\HBuilderX\Bin\plugins\uniapp-cli\node_modules\@dcloudio\uni-h5\src\platforms\h5\service\api\media\choose-image.js

    1. 创建一个隐藏的input标签

      源码所在路径:?:\Program Files\HBuilderX\Bin\update\backup\plugins\uniapp-cli\node_modules\@dcloudio\uni-h5\src\platforms\h5\service\api\media\create_input.js

        if (count > 1) {
          inputEl.multiple = 'multiple'
        }
      
        // 经过测试,仅能限制只通过相机拍摄,不能限制只允许从相册选择。
        if (sourceType.length === 1 && sourceType[0] === 'camera') {
          inputEl.capture = 'camera'
        }
      
    2. 监听其change事件

    3. 然后自动调用其click事件

    4. 在其change事件触发时将用户选择的文件file对象通过fileToUrl(file)缓存起来

        imageInput.addEventListener('change', function (event) {
          const tempFiles = []
          const fileCount = event.target.files.length
          for (let i = 0; i < fileCount; i++) {
            const file = event.target.files[i]
            let filePath
            Object.defineProperty(file, 'path', {
              get () {
                filePath = filePath || fileToUrl(file)
                return filePath
              }
            })
            if (i < count) tempFiles.push(file)
          }
          const res = {
            errMsg: 'chooseImage:ok',
            get tempFilePaths () {
              return tempFiles.map(({ path }) => path)
            },
            tempFiles: tempFiles
          }
          invoke(callbackId, res)
      
          // TODO 用户取消选择时,触发 fail,目前尚未找到合适的方法。
        })
      
  3. import { fileToUrl } from 'uni-platform/helpers/file'

    源码所在路径:?:\Program Files\HBuilderX\Bin\plugins\uniapp-cli\node_modules\@dcloudio\uni-h5\src\platforms\h5\helpers\file.js

    1. fileToUrl

      1. 本质是对系统函数createObjectURL的二次封装
      2. 在此基础上增加了一次内部缓存files[url] = file
    2. urlToFile

      1. 主要目的是为了能通过url从内部缓存直接拿到原始对象var file = files[url]

        拿缓存的目的是缓存的原始对象会携带更多额外信息, 如文件名name等数据.

        通过XMLHttpRequest.GET 重新下载后则会丢失这些数据

      2. 没有缓存时再通过XMLHttpRequest.GET 重新下载一次拿到 file 类型的blob二进制对象

  4. 借助uni.downloadFile来模拟官方一样的写入缓存

    源码所在路径:D:\Program Files\HBuilderX\Bin\plugins\uniapp-cli\node_modules\@dcloudio\uni-h5\src\platforms\h5\service\api\network\download-file.js

    见《悲催方案2》

参考资料

  1. marcuswestin/WebViewJavascriptBridge: An iOS/OSX bridge for sending messages between Obj-C and JavaScript in UIWebViews/WebViews

    An iOS/OSX bridge for sending messages between Obj-C and JavaScript in WKWebViews, UIWebViews & WebViews.

  2. uni.uploadFile(OBJECT)

    files 参数是一个 file 对象的数组,file 对象的结构如下:

    参数名 类型 必填 说明
    name String multipart 提交时,表单的项目名,默认为 file
    file File 要上传的文件对象,仅H5(2.6.15+)支持
    uri String 文件的本地地址
  3. uni.downloadFile(OBJECT)

    success 返回参数说明

    参数 类型 说明
    tempFilePath String 临时文件路径,下载后的文件会存储到一个临时文件
    statusCode Number 开发者服务器返回的 HTTP 状态码
  4. uni.chooseImage(OBJECT)

    success 返回参数说明

    参数 类型 说明
    tempFilePaths Array 图片的本地文件路径列表
    tempFiles Array、Array 图片的本地文件列表,每一项是一个 File 对象

    File 对象结构如下

    参数 类型 说明
    path String 本地文件路径
    size Number 本地文件大小,单位:B
    name String 包含扩展名的文件名称,仅H5支持
    type String 文件类型,仅H5支持
  5. WKWebview 加载过程中的性能指标图解 - 简书

    %不是从 LoadRequest 开始的,甚至不是从 didStartProvisionalNavigation 开始。所以一个符合用户视角的进度条应该自己写 timer 来显示进度。从 decidePolicyForNavigationResponse 开始显示进度会有很长时间的空白和无进度条阶段,从这次用例来看大概 1s 的空白时间。

    对用户而言FMP才是最重要的。

posted @ 2021-12-23 22:34  Asion Tang  阅读(573)  评论(0编辑  收藏  举报