跳至主要內容

混合开发

Mr.LRH大约 16 分钟

混合开发

Hybrid App:混合了 Native(原生)技术和 web 技术进行开发的移动应用。主要有以三种方式:

  • 基于WebView UI(JSBridge): 通过JSBridge完成Web端Native端的通讯,从而赋予Web端原生能力
  • 基于Native UI(ReactNative、Weex): 在赋予Web端原生能力的基础之上,通过JSBridge将 js 解析成虚拟节点树(Virtual Dom)传递到Native端并使用原生渲染
  • 小程序方案(微信、支付宝小程序等): 通过更加定制化的JSBridge,并使用双 WebView双线程的模式隔离了JS逻辑UI渲染,形成了特殊的开发模式,加强了 Web 端与 Native 端混合程度,提高了页面性能及开发体验。

Hybrid App 的本质

在原生(Native) APP 中,使用WebView作为容器,来承载一个 Web 页面。

原生(Native)和 Web 端的双向通讯层(跨语言解决方案) —— JSBridge

  • 定义: 用 JavaScript 搭建起来的桥, 一端是 Web 端, 一端是 Native。
  • 目的: 让 Native 端可以调用 Web 的 JavaScript 代码, 让 Web 端可以调用 Native 的原生代码

Web端通知Native端

Web 端通知 Native 端三种常见方案:

1. API 注入

通过向Window注入对象的方式来提供可被 Web 端调用的方法。Android 端可直接获取注入对象,调用方法;IOS 端则为相对固定写法(window.webkit.messageHandlers.方法名.postMessage(入参对象))

  • 相同点

    • 通过WebView来完成网页的加载
    • 通过向Window注入对象的方式来提供可被 Web 端调用的方法
    • 可以直接调用 Web 端挂载到Window对象下的方法
  • 不同点

    AndroidIOS
    注入对象可提供注入对象名固定为webkit
    JS 调用 Native 方式可直接获取注入对象,调用方法相对固定写法(window.webkit.messageHandlers.方法名.postMessage(入参对象))
    传递数据格式只能接受基本类型数据可以接受任意类型数据
    返回值可直接接受返回值无法直接获取返回值

2. WebView 中prompt/console/alert拦截

通常拦截prompt,因为在前端中使用频率低。(prompt()方法用于显示可提示用户进行输入的对话框)

3. WebView URL Scheme 跳转拦截方式

第二种、第三种原理类似,通过对 WebView 信息冒泡传递的拦截,从而达到通讯。

解析: 原理 --> 定制协议 --> 拦截协议 --> 参数传递 --> 回调机制

  • 原理: 在 WebView 中发出的网络请求,客户端都能进行监听和捕获

  • 定制协议

    制定一套 URL Scheme 规则,可将协议类型的请求订制为: xxcommand://xxxx?param1=1&param2=2。(通常请求会带有对应的协议开头,例如https://xxx.com 或者file://1.jpg。)

    • xxcommand:// 只是一种规则,可以根据业务进行制定,使其具有含义。不同的协议头代表着不同的含义,这样便能清楚知道每个协议的适用范围。(xxcommand://为通用工具协议,xxapp:// 为每个 App 单独的业务协议。)
    • 不要使用location.href发送,因为其自身机制存在问题,同时并发多次请求会被合并成为一次,导致协议被忽略,而并发协议其实是非常常见的功能。我们会使用创建 iframe 发送请求的方式。
    • 在客户端中设置域名白名单或者限制,避免公司内部业务协议被第三方直接调用。
  • 拦截协议: 客户端可以通过 API 对 WebView 发出的请求进行拦截

    • Android: shouldOverrideUrlLoading
    • IOS: shouldStartLoadWithRequest

    当解析到请求 URL 头为制定的协议时,便不发起对应的资源请求,而是解析参数,并进行相关功能或者方法的调用,完成协议功能的映射。

  • 协议回调

    由于协议的本质其实是发送请求,这属于异步的过程,因此需要处理对应的回调机制。采用的方式是 JS的事件系统,会用到两个基础 API window.addEventListenerwindow.dispatchEvent

    • 发送协议时,通过协议的唯一标识注册自定义事件,并将回调绑定到对应的事件上。

    • 客户端完成对应的功能后,调用Bridgedispatch API,直接携带 data 触发该协议的自定义事件。

      Bridge.getNetwork(data => {}) // 业务调用API
      // Bridge层功能:生成唯一标识 handler;注册自定义事件;拼接参数并发送协议
      const handler = 1
      window.addEventListener(`getNetwork_${handler}`, callback, false)
      Bridge.send(`xxcommand://getNetwork?handler=${handler}`)
      // Native层获取网络状态后通过bridge再次传回,将网络状态通过事件直接出发自定义事件并传递参数
      event.data = network
      window.dispatchEvent(event)
      
    • 应该避免事件的多次重复绑定,因此当唯一标识重置时,需要removeEventListener对应的事件。

  • 参数传递方式

    由于 WebView 对 URL 会有长度的限制,当需要传递的参数过长时,可能会导致被截断。所以使用函数调用的方式。这里的原理主要是基于: Native 可以直接调用 JS 方法并直接获取函数的返回值。只需要对每条协议标记一个唯一标识,并把参数存入参数池中,客户端再通过该唯一标识从参数池中获取对应的参数即可。

Native端通知Web端

Native 可以通过WebView API直接执行 JS 代码

  • Android

    • loadUrl (版本 4.4-)

      // 调用js中的JSBridge.trigger方法, 该方法的弊端是无法获取函数返回值
      webView.loadUrl("javascript:JSBridge.trigger('NativeCall')")
      

      当系统低于 4.4 时,evaluateJavascript 是无法使用的,因此单纯的使用 loadUrl 无法获取 JS 返回值,需要使用prompt的方法进行兼容,让 H5 端通过prompt进行数据的发送,客户端进行拦截并获取数据。

    • evaluateJavascript (版本 4.4+)

      // 4.4+后使用该方法便可调用并获取函数返回值;
      mWebView.evaluateJavascript("javascript:JSBridge.trigger('NativeCall')", new ValueCallback<String>() {
        @Override
        public void onReceiveValue(String value) {
          //此处为 js 返回的结果
        }
      });
      
  • IOS: stringByEvaluatingJavaScriptFromString

    webview.stringByEvaluatingJavaScriptFromString("alert('NativeCall')")
    

JSBridge 的接入

  • JS 部分(bridge): 在 JS 环境中注入 bridge 的实现代码,包含了协议的拼装/发送/参数池/回调池等一些基础功能。
  • Native 部分(SDK): 在客户端中 bridge 的功能映射代码,实现了 URL 拦截与解析/环境信息的注入/通用功能映射等功能。

将这两部分封装成一个 Native SDK,由客户端统一引入。客户端在初始化一个 WebView 打开页面时,如果页面地址在白名单中,会直接在 HTML 的头部注入对应的 bridge.js。这样的做法有以下的好处:

  • 双方的代码统一维护,避免出现版本分裂的情况。有更新时,只要由客户端更新 SDK 即可,不会出现版本兼容的问题;
  • App 的接入十分方便,只需要按文档接入最新版本的 SDK,即可直接运行整套 Hybrid 方案,便于在多个 App 中快速的落地;
  • H5 端无需关注,这样有利于将 bridge 开放给第三方页面使用。

协议的调用,一定是需要确保执行在 bridge.js 成功注入后。由于客户端的注入行为属于一个附加的异步行为,从 H5 方很难去捕捉准确的完成时机,因此需要通过客户端监听页面完成后,基于上面的事件回调机制通知 H5 端,页面中即可通过window.addEventListener('bridgeReady', e => {})进行初始化。

APP 中 H5 的接入方式

  • 在线 H5: 将 H5 代码部署到服务器上,只要把对应的 URL 地址 给到客户端,用 WebView 打开该 URL,即可嵌入。

    • 优点:
      • 独立性强,有非常独立的开发/调试/更新/上线能力
      • 资源放在服务器上,完全不会影响客户端的包体积
      • 接入成本很低,完全的热更新机制
    • 缺点:
      • 完全的网络依赖,在离线的情况下无法打开页面
      • 首屏加载速度依赖于网络,网络较慢时,首屏加载也较慢

    通常,这种方式更适用在一些比较轻量级的页面上,例如一些帮助页、提示页、使用攻略等页面。这些页面的特点是功能性不强,不太需要复杂的功能协议,且不需要离线使用。在一些第三方页面接入上,也会使用这种方式,例如页面调用微信 JS-SDK。

  • 内置包 H5: 本地化的嵌入方式,将代码进行打包后下发到客户端,并由客户端直接解压到本地储存中。通常运用在一些比较大和比较重要的模块上。

    • 优点:
      • 由于其本地化,首屏加载速度快,用户体验更为接近原生
      • 可以不依赖网络,离线运行
    • 缺点:
      • 开发流程/更新机制复杂化,需要客户端,甚至服务端的共同协作
      • 会相应的增加 App 包体积

WebView URL Scheme 跳转拦截实现

bridge.js 架构

核心代码:

  • 最重要的开放 API: nativeCallpostMessage
  • 客户端获取参数函数: getParam
  • 事件回调系统中的 addEventfireEvent
  • 用于发送协议的 send

使用示例(获取网络状态为例)

Bridge.nativeCall('getNetwork', {}, e => {
  if (e.data && e.data.type === 0) {
    // 无网络状态
  } else {
    // type: wifi/2g/3g/4g
  }
})

H5 --> Native

  • H5

    • 生成唯一 handler 标识,从 0 开始累加
    • 将参数按 handler 值的规则存入参数池(_paramsStore)中
    • handler 注册自定义事件,绑定 callback,并将 callback 也存入 _callbackStore 中,注册自定义事件 addEvent()。储存的目的主要是为了事件解绑时使用
    • iframe的形式发送协议,并携带唯一标识 handler,发送协议请求 send()
    let i = 0
    function nativeCall(scheme = throwError(), params, callback) {
      // 对参数进行字符串化,并进行编码
      params = params ? decode(JSON.stringify(params)) : ''
      // 生成唯一 handler 标识
      const handler = i++,
        handlerKey = getHandlerKey(handler)
      // 将参数存储进参数池
      _paramsStore.save(handlerKey.p, params)
      if (isFn(callback)) {
        // 将回调储存进回调池
        _callbackStore.save(handlerKey.c, callback)
        // 注册自定义事件,并绑定回调:回调会在接受到 postMessage 时被触发执行
        addEvent(handlerKey.e, e => {
          const { data, handler } = e.data
          // 完成一次完整交互时,将自定义事件解绑
          removeEvent(handler)
          callback.call(MTJs, data)
        })
      }
      // 发送协议请求
      send(`${scheme}?handler=${handler}`)
    }
    
    function send(scheme) {
      setTimeout(() => {
        // 创建 iframe 并设置 src
        const iframe = document.createElement('iframe')
        iframe.src = scheme
        iframe.style.display = 'none'
        document.body.appendChild(iframe)
        // 延迟删除节点
        setTimeout(() => {
          iframe.parentNode.removeChild(iframe)
        }, 300)
      }, 0)
      return this
    }
    
  • Native

    • 客户端接收到请求后,会使用 handler 调用 getParam 从参数池中获取对应的参数。

      function getParam(handler = throwError()) {
        const key = getHandlerKey(handler).p
        return _paramsStore.get(key)
      }
      
    • 执行协议对应的功能

Native --> H5

  • Native: Native 完成功能后,直接调用 Bridge.postMessage(handler, data),将执行结果和之前nativeCall传过来的标识回传给H5
  • H5: H5 在接收到唯一标识后初始化对应的自定义事件,挂载数据后触发。
function postMessage(e) {
  // 客户端携带 handler 和 data 调用 postMessage
  const { handler, data } = e
  // 获取对象的自定义事件名 e__handler
  const evName = getHandlerKey(handler).e
  // 创建并触发自定义事件
  fireEvent(evName, e)
  return this
}

function fireEvent(evName, data) {
  // 创建自定义事件对象
  let eventItem
  if (isFn(doc.CustomEvent)) {
    eventItem = new doc.CustomEvent(evName, {
      bubbles: true,
      cancelable: true,
    })
  } else if (isFn(doc.createEvent)) {
    eventItem = doc.createEvent('Event')
    eventItem.initEvent(evName, true, true)
  }
  // 将数据挂载到事件对象中
  if (data && eventItem) {
    eventItem.data = data
  }
  // 触发自定义事件
  if (eventItem) {
    win.dispatchEvent(eventItem)
  } else {
    log('Bridge Error: dispatchEvent')
  }
  return this
}

Android 兼容性

问题: 在 Android 4.4 以下时,使用的 loadUrl 进行 js 函数的调用,此时是无法获取函数的返回值的,即:Android 4.4- 时,安卓并无法通过 · 这个函数来获取到协议的参数,需要做兼容性的处理。

解决方法: WebView 中的 prompt 拦截

  • 当安卓接受到协议,并拿到 handler

  • 使用无兼容性问题的 loadUrl 执行 js:Bridge.getParam(handler) ,直接将返回值直接通过 js 中的 prompt 发出:

    webview.loadUrl("javascript:prompt('Bridge:commonJsExecute#1', Bridge.getParam(1))")
    
  • 通过重写 onJsPrompt 这个方法,拦截上一步发出的 prompt 的内容,并解析出相应的参数

    @Override
    public boolean onJsPrompt(WebView webiew, String url, String message, String defaultValue) {
      // 获取 prompt 内容 message,匹配并解析出对应的参数字符串,并解析
    }
    

协议的制定

可将协议分成功能协议业务协议

功能协议

用于完善整套方案的基础功能的一些通用协议,以 command://作为通用头,封装在 SDK 之中,可以在全线 App、全线 WebView 中使用。

  • 初始化机制

    由于 bridge.js 注入的异步性,需要由客户端在注入完成后通知 H5。通用的初始化事件,这里约定为 init

    // 用于标记避免事件被重复触发
    // 由于客户端中是通过监听 WebView 的生命周期钩子来触发的,而 iframe 之类的操作会导致这些钩子的多次触发,因此需要双方各做一层防御性措施。
    let isLoaded = false
    window.addEventListener(
      '_init_',
      e => {
        if (isLoaded) return false
        isLoaded = true
        // 代码逻辑...
      },
      false
    )
    

    通过该事件,直接初始化传给 H5 一些环境参数和系统信息等

    e.data = {
      name: 'CloudFilter', // 应用名
      appVersion: '1.0.0', // app版本号
      version: '1.112', // H5包版本号
      platform: 2, // 平台 - 1: ios; 2: Android
      area: '中国大陆', // 地区
      language: 'zh', // 语言
      env: 2, // 当前App环境 - 0: release; 1: pre; 2: dev
      data: {}, // 参数池
    }
    

    可以约定更多的页面生命周期事件。例如因为 App 很经常性的隐藏到后台,因此在被激活时,我们可以设置个生命周期: resume,可以用于告知 H5 页面被激活。

  • 包更新机制

    问题: Hybrid 模块 的其中一种方式是将前端代码打包后内置于 App 本地,以便拥有最快的启动性能和离线访问能力。这种方式最大的麻烦点:代码的更新

    解决方法: 需要一套新的热更新机制,这套机制需要由客户端/前端/服务端三端提供对应的资源,共同协作完成整套流程。

    • 资源

      • H5: 每个代码包都有一个唯一且递增的版本号
      • Native: 提供包下载且解压到对应目录的服务,前端可以由下面这个协议来调用该功能。
      Bridge.nativeCall('downloadModule', {
        module: appName, // 应用名称
        url: zipUrl, // 最新包线上地址
      })
      
      • 服务端: 提供一个接口,可以获取线上最新代码包的版本号和下载地址。
    • 流程

      • 前端更新代码打包后按版本号上传至指定的服务器上
      • 每次打开页面时,H5 请求接口获取线上最新代码包版本号,并与本地包进行版本号比对,当线上的版本号 大于 本地包版本号时,发起包下载协议
      • 客户端接收到协议后,直接去线上地址下载最新的代码包,并解压替换到当前目录文件
  • 环境系统 和 多语言系统

    可以通过 init 中携带的数据 data.env 来匹配

    环境机制通常主要用于匹配后端的环境,正式环境和测试环境对应不同的接口。还需要注意代码包的更新,包更新条件要包含三个方面: 版本号、环境和 App 版本,在不同环境不同 App 版本下,也应该更新到相应的最新代码包。

  • 事件中转站

    当页面中有弹窗或者 SPA 切换页面时,安卓的返回实体键应该能完成对应的回退,而不是因为 WebView 没有 history 就直接关闭。

    可以定制一个事件中心(eventListeners ),用于监听客户端的实体返回键:

    // 事件中心
    addEvent('_eventListeners', e => {
      const type = e.data.type
      switch (type) {
        case 'back':
          // 关闭弹窗: this.closeDialog()
          // 退回页面: this.goLastPage()
          break
        case 'hideLoading':
          // 隐藏loading: this.hideLoading()
          break
        default:
          break
      }
    })
    
  • 数据传递机制

    很多场景需要做到 Native 与 H5 保持数据的同步,此时就可以使用类似上面的原理,制定一套数据传递协议:

    // 推送数据
    Bridge.nativeCall('putData', {
      a: 1,
      b: 2,
    })
    // 监听数据通道
    addEvent('getData', e => {
      // type: 代表数据类型,可自行约定
      // data: 数据
      const { type, data } = e.data
      switch (type) {
        case 'list':
          // 获取客户端传递过来的列表数据 data
          break
        default:
          break
      }
    })
    

    Hybrid 模块通常需要从对应的入口进入,因此有一种可以优化的方式:由 App 在启动时先去获取线上数据,在进入 WebView 后直接通过 init 或者触发 getData 直接发送给 H5,这样能减少请求数量,优化用户体验。

  • 代理请求

    H5 中最常用的就是请求,通常我们可以直接使用 ajax,但是这里有几个问题比较棘手:

    • 最常见的请求跨域
    • 数据算法加密
    • 用户登录校验

    客户端的请求便不会出现这些问题,因此我们可以由客户端代理我们发出的请求,可以定制 4 个协议: getProxypostProxygetProxyLoginedpostProxyLogined,其中带有 Logined 的协议代表着在请求时会自动携带已登录用户的 tokenuid 等参数,使用在一些需要登录信息的接口上。这样做的好处是:

    • H5 方就无需处理繁多的各项复杂信息,不需要进行跨端传输
    • 能够对 H5 与 Native 的请求出口进行统一,方便加工处理
    Bridge.nativeCall('getProxy', { url, data, headers }, e => {
      if (e.data && e.data.code == 110) {
        // 请求失败
      } else {
        // 请求成功,返回数据 e.data
      }
    })
    
  • 更多通用性协议

    定义更多的通用性协议,这里有个原则可以遵守,即协议应该是基础性功能,应该是纯净的,适用于所有的业务方

    • getNetwork:获取网络状态;
    • openApp:唤起其它 App;
    • setShareInfocallShare:分享内容到第三方平台;
    • link:使用新的 WebView 打开页面;
    • closeWebview:关闭 WebView;
    • setStoragegetStorage:设置与获取缓存数据;
    • loading:调用客户端通用 Loading;
    • setWebviewTitle:设置 WebView 标题;
    • saveImage:保存图片到本地;
    • ...

业务协议

这类协议区别于功能协议,它们会杂合一定程度的业务逻辑,而这些逻辑只是针对于特定的项目。

// 示例
Bridge.nativeCall(
  'app://getResultImage',
  {
    category_id,
    material_id: materialId,
    subcat_id: subcatId,
    origin_image,
    zip_url,
  },
  data => {
    // 获取处理后的效果图路径
  }
)

参考:

上次编辑于:
贡献者: lrh21g