Skip to content

HTML5+ WebView 接入 ​

接入方式 ​

本文介绍手动创建 HTML5+ WebView 的接入方式。页面中直接使用组件的方案见 <web-view> 组件接入。

本文使用 uni-app App-vue + HTML5+ WebView,面向 Android / iOS App。创建子 WebView 承载直播间,使用 URL 拦截接收 H5 消息,使用 evalJS 执行 H5 代码。

方向H5 侧uni-app / Native 侧
H5 调 NativehtmlToFlutterEvent.postMessage 返回 PromiseoverrideUrlLoading 接收消息,业务处理后 evalJS 回传结果
Native 调 H5flutterToHtmlEvent 接收对象并返回结果evalJS 发起调用;H5 将执行结果通过同一通道送回

H5 业务入口及事件名称与 共用协议 一致。示例通过自定义 appbridge:// 通道、请求编号和 ack 完成消息传输。业务消息通过桥接通道发送,不拼接到直播间观看 URL。本方案适用于 App-vue,不适用于浏览器 H5、小程序或 App-nvue。

一、注入脚本示例 ​

下载 bridgeRuntime.js,将其内容与下面的脚本按先后顺序放入同一个 static/uniAppBridge.js 文件。前者提供 Promise 和 callback 配对,下面这段实现 URL 传输。

每条消息先进入发送队列,Native 拦截后立即确认,再发送下一条,避免多次设置 location.href 导致消息相互覆盖。确认仅表示 Native 已收到消息,业务结果另行回传。

javascript
(function () {
  if (window.AppBridgeScheme) return;
  var queue = [];
  var current = null;

  function flush() {
    if (current || !queue.length) return;
    current = queue.shift();
    var item = current;
    item.timer = setTimeout(function () {
      if (item.kind === 'request') {
        window.AppBridgeAdapter.fail(item.id, 'Native 未确认接收消息');
      }
      current = null;
      flush();
    }, 2000);
    window.location.href = 'appbridge://dispatch?message=' +
      encodeURIComponent(JSON.stringify(item.message));
  }

  function enqueue(message) {
    queue.push({ id: message.id, kind: message.kind, message: message });
    flush();
  }

  window.AppBridgeScheme = {
    ack: function (id) {
      if (!current || current.id !== id) return;
      clearTimeout(current.timer);
      current = null;
      flush();
    },
    sendResult: function (id, result) {
      enqueue({ kind: 'h5Result', id: id, result: result });
    }
  };

  window.AppBridgeAdapter.install(function (id, payload) {
    enqueue({ kind: 'request', id: id, payload: payload });
  });
  enqueue({ kind: 'ready', id: 'ready_' + Date.now() });
})();

ack 的 2 秒和共用脚本的 10 秒都是示例超时,按目标设备和业务调整。脚本作为 App 本地资源随包发布,不依赖直播间修改业务代码。

二、创建 WebView 并安装收发通道示例 ​

下面是 App-vue 页面的 <script>。query.url 是上一页使用 encodeURIComponent 编码传入的观看 URL。账号打通时,请传入服务端刚生成的完整登录 URL。

第三节和第四节的业务方法需要补入同一个 methods 中,方法之间使用逗号分隔。

javascript
export default {
  onLoad: function (query) {
    this.watchUrl = decodeURIComponent(query.url || '');
    this.liveView = null;
    this.pageGeneration = 0;
    this.bridgeReady = false;
    this.h5Pending = new Map();
    this.sequence = 0;
    this.closing = false;
  },
  onReady: function () {
    // #ifdef APP-PLUS
    var self = this;
    if (!/^https:\/\/live\.example\.com(?::443)?\//.test(this.watchUrl)) return;
    this.previousUA = plus.navigator.getUserAgent();
    this.platform = uni.getSystemInfoSync().platform === 'ios' ? 'iOS' : 'Android';
    plus.navigator.setUserAgent(this.previousUA + ' channel/APP' + this.platform +
      ' version/1.0.0 package/com.example.live' +
      ' wxappid/example_wechat_appid scheme/examplelive');

    var child = plus.webview.create('about:blank', 'live_' + Date.now(), {
      top: '0px', bottom: '0px'
    });
    this.liveView = child;
    // 拦截示例通道和所有非观看域名的导航;可信域名替换为自己的配置。
    child.overrideUrlLoading({
      mode: 'reject',
      match: '^(?!https://live\\.example\\.com(?::443)?(?:/|$)).*'
    }, function (event) {
      if (event.url.indexOf('appbridge://dispatch?message=') === 0) {
        self.receiveBridgeURL(event.url);
      }
    });
    child.addEventListener('loading', function () {
      self.bridgeReady = false;
      self.pageGeneration++;
      self.cancelH5Requests('页面已导航');
    });
    child.appendJsFile('_www/static/uniAppBridge.js');
    this.$scope.$getAppWebview().append(child);
    child.loadURL(this.watchUrl);

    this.appLifecycleListener = function (state) {
      self.callH5('appLifecycle', state).catch(function () {});
    };
    uni.$on('live-app-lifecycle', this.appLifecycleListener);
    // #endif
  },
  onBackPress: function (options) {
    // closePage 中的 uni.navigateBack 也会触发此钩子,必须放行。
    if (options && options.from === 'navigateBack') return false;
    if (this.closing) return true;
    if (!this.liveView) return false;
    var self = this;
    this.liveView.canBack(function (event) {
      if (event.canBack) self.liveView.back();
      else self.closePage();
    });
    return true;
  },
  onUnload: function () {
    this.cancelH5Requests('页面已关闭');
    if (this.appLifecycleListener) uni.$off('live-app-lifecycle', this.appLifecycleListener);
    if (this.liveView) this.liveView.close();
    this.liveView = null;
    // #ifdef APP-PLUS
    if (this.previousUA) plus.navigator.setUserAgent(this.previousUA);
    // #endif
  },
  methods: {
    receiveBridgeURL: function (url) {
      var self = this;
      var message;
      try {
        message = JSON.parse(decodeURIComponent(url.split('?message=')[1]));
      } catch (error) { return; }
      var child = this.liveView;
      var generation = this.pageGeneration;
      if (!child || typeof message.id !== 'string') return;
      child.evalJS('window.AppBridgeScheme.ack(' + JSON.stringify(message.id) + ');');
      if (message.kind === 'ready') {
        this.bridgeReady = true;
      } else if (message.kind === 'h5Result') {
        var pending = this.h5Pending.get(message.id);
        if (!pending) return;
        clearTimeout(pending.timer);
        this.h5Pending.delete(message.id);
        pending.resolve(message.result);
      } else if (message.kind === 'request') {
        this.handleNativeRequest(message.payload).then(function (result) {
          if (self.liveView !== child || self.pageGeneration !== generation) return;
          child.evalJS('window.AppBridgeAdapter.receive(' + JSON.stringify(message.id) +
            ',' + JSON.stringify(JSON.stringify(result)) + ');');
        });
      }
    },
    cancelH5Requests: function (reason) {
      if (!this.h5Pending) return;
      this.h5Pending.forEach(function (pending) {
        clearTimeout(pending.timer);
        pending.reject(new Error(reason));
      });
      this.h5Pending.clear();
    },
    closePage: async function () {
      if (this.closing) return;
      this.closing = true;
      try { await this.callH5('pageLifecycle', 'dispose'); } catch (error) {}
      uni.navigateBack(); // onUnload 释放子 WebView。
    },
    // 在此继续添加下文的 handleNativeRequest 和 callH5 方法。
  }
};

plus.navigator.setUserAgent 是应用级设置,此示例在关闭时恢复。已有多个 WebView 并行工作的 App 应统一管理 UA;需要严格按容器隔离配置时,可用 纯原生容器 封装组件来实现。

appendJsFile 在页面加载时注入本地脚本。App 收到 ready 后再主动调用 H5;网页完整导航后需重新完成桥接初始化。

三、H5 调 Native示例:查询应用信息、打开页面 ​

1. H5 发起请求并读取结果示例 ​

javascript
async function onGetAppInfoClick() {
  if (!window.htmlToFlutterEvent) throw new Error('App 通道尚未就绪');
  var responseText = await window.htmlToFlutterEvent.postMessage(JSON.stringify({
    event: 'getAppInfo', data: {}
  }));
  var result = JSON.parse(responseText);
  if (Number(result.code) !== 200) throw new Error(result.message);
  console.log(result.data.appName, result.data.platform);
}

async function onOpenNativePageClick() {
  if (!window.htmlToFlutterEvent) throw new Error('App 通道尚未就绪');
  var responseText = await window.htmlToFlutterEvent.postMessage(JSON.stringify({
    event: 'openNativePage',
    data: { page: 'orderDetail', params: { orderId: 'order_12345' } }
  }));
  var result = JSON.parse(responseText);
  if (Number(result.code) !== 200) throw new Error(result.message);
  return result.data;
}

2. uni-app 分发业务并回传结果示例 ​

将该方法加入上一节的 methods。H5 调用后,receiveBridgeURL 会调用这里,取得结果后执行 AppBridgeAdapter.receive,完成 H5 正在等待的 Promise。

javascript
handleNativeRequest: async function (payload) {
  var request;
  try {
    request = JSON.parse(payload);
    if (!request || typeof request.event !== 'string') throw new Error('Invalid event');
  } catch (error) {
    return { event: '', timestamp: new Date().toISOString(),
      code: 400, message: '消息格式错误', data: null };
  }
  var result = {
    event: request.event, timestamp: new Date().toISOString(),
    code: 200, message: '', data: null
  };
  if (request.event === 'getAppInfo') {
    result.data = {
      appName: '示例应用', packageName: 'com.example.live',
      version: '1.0.0', buildNumber: '1', platform: this.platform,
      scheme: 'examplelive://'
    };
  } else if (request.event === 'openNativePage') {
    var data = request.data || {};
    var params = data.params || {};
    if (data.page !== 'orderDetail' || typeof params.orderId !== 'string' || !params.orderId.trim()) {
      result.code = 400;
      result.message = '页面或订单参数错误';
    } else {
      try {
        // 此路由需在接入项目的 pages.json 中注册。
        await new Promise(function (resolve, reject) {
          uni.navigateTo({
            url: '/pages/order/detail?orderId=' + encodeURIComponent(params.orderId),
            success: resolve,
            fail: reject
          });
        });
        result.data = { page: 'orderDetail' };
      } catch (error) {
        result.code = 500;
        result.message = '无法打开 App 页面';
      }
    }
  } else {
    result.code = 400;
    result.message = '未知事件';
  }
  return result;
}

结果中的 data 就是 H5 上面读取的应用信息。实际项目应从应用配置取得这些值,并在此补齐页面所需的其他 Native 事件。可选 callback 由共用脚本处理,业务层不再重复调用。

openNativePage 是自定义事件示例,使用统一的 data.page、data.params,并将 orderDetail 映射到 /pages/order/detail。接入项目应实现 pages/order/detail.vue、在 pages.json 中注册它,并在目标页的 onLoad(query) 中读取 query.orderId。200 表示已发起路由跳转,参数缺失返回 400,路由失败返回 500。完整字段见 共用协议。

handleNativeRequest 与 closePage、callH5 放在同一个 methods 对象内,彼此同级。

四、Native 调 H5示例:查询视频尺寸 ​

1. uni-app 执行 H5 函数并等待结果示例 ​

HTML5+ 的 evalJS 不直接返回 JavaScript 业务结果。因此这里在执行 H5 函数后,通过注入脚本的 sendResult 把结果送回,使用请求编号完成本地 Promise。

将此方法加入同一个 methods:

javascript
callH5: function (event, data) {
  var self = this;
  if (!this.liveView || !this.bridgeReady) {
    return Promise.reject(new Error('H5 通道尚未就绪'));
  }
  var id = 'native_' + this.pageGeneration + '_' + (++this.sequence);
  var message = { event: event, timestamp: new Date().toISOString(), data: data };
  return new Promise(function (resolve, reject) {
    var timer = setTimeout(function () {
      self.h5Pending.delete(id);
      reject(new Error('H5 返回结果超时'));
    }, 3000);
    self.h5Pending.set(id, { resolve: resolve, reject: reject, timer: timer });
    var source = '(function(id,message){' +
      'try {' +
        'var result = typeof window.flutterToHtmlEvent === "function"' +
          '? window.flutterToHtmlEvent(message)' +
          ': {code:400,message:"H5 处理函数不存在",data:null};' +
        'window.AppBridgeScheme.sendResult(id,result);' +
      '} catch(error) {' +
        'window.AppBridgeScheme.sendResult(id,{code:500,message:"H5 执行失败",data:null});' +
      '}' +
    '})(' + JSON.stringify(id) + ',' + JSON.stringify(message) + ');';
    self.liveView.evalJS(source);
  });
}

页面收到 ready 后即可在按钮等业务入口中调用:

javascript
// 放入 methods,或者在页面现有的业务方法中执行。
queryVideoSize: async function () {
  var result = await this.callH5('getVideoInfo', {});
  if (Number(result.code) !== 200) throw new Error(result.message);
  console.log(result.data.width, result.data.height);
}

2. H5 接收调用并返回对象示例 ​

现有直播间已经提供 flutterToHtmlEvent。以下是自建测试 H5 的接收示例;连接真实直播间时,App 直接调用已有函数,不覆盖它。

javascript
window.flutterToHtmlEvent = function (message) {
  var result = {
    event: message.event, timestamp: Date.now(),
    code: 200, message: '', data: null
  };
  if (message.event === 'getVideoInfo') {
    var video = document.querySelector('video');
    if (video) result.data = { width: video.videoWidth, height: video.videoHeight };
    else { result.code = 401; result.message = '播放器尚未就绪'; }
  } else if (message.event === 'appLifecycle') {
    console.log('App 生命周期', message.data);
  } else if (message.event === 'pageLifecycle') {
    console.log('页面生命周期', message.data);
  } else {
    result.code = 400;
    result.message = '未知事件';
  }
  return result;
};

此示例按现有 H5 的同步返回对象设计。若自建 H5 处理函数返回 Promise,应在执行脚本中等待完成后再调用 sendResult。

五、App 前后台与关闭示例 ​

在 App 的 App.vue 实际生命周期中发送通知;上面 WebView 页面已注册监听,会调用 H5 的 flutterToHtmlEvent:

javascript
export default {
  onShow: function () { uni.$emit('live-app-lifecycle', 'resumed'); },
  onHide: function () { uni.$emit('live-app-lifecycle', 'paused'); }
};

系统返回键先检查 WebView 浏览历史,无历史则发送 pageLifecycle / dispose 后退出页面。完整导航时清理旧文档请求;App 页面卸载时移除生命周期监听并释放子 WebView。

uni.navigateBack() 同样触发 onBackPress,示例通过 options.from 放行程序返回,避免再次拦截关闭流程。iOS 侧滑返回不会触发该钩子;如果业务要求关闭前必须通知 H5,应另行管理侧滑手势并统一关闭入口,不能仅依赖这里的返回拦截。官方返回逻辑说明、页面生命周期说明

联调检查 ​

分别验证 H5 查询应用信息及调用 openNativePage 打开订单详情页、uni-app 查询 H5 视频尺寸、前后台通知、返回关闭和页面刷新。重点检查 URL 拦截是否生效、消息确认是否收到、连续请求是否丢失,以及导航后旧结果是否被丢弃。