之前有做个一个vue+Android的项目,本来是用uniapp来做的,但uniapp出现了影响项目基本功能的bug,在社区提问也没有得到回答,索性将vue项目打包后通过webview+h5的方式通过封装JSBridge实现Android原生和h5进行双向通信而实现业务需求,现在了解到鸿蒙webview也可以通过同样的方式来实现,在阅读官方文档后发现实现逻辑基本上没有啥变化,故在此记录下在鸿蒙next中实现原生ArkTS和H5的混合开发。

演示效果

实现

学习鸿蒙开发的这段时间里了解到鸿蒙开发规范之一:三层目录架构,本套示例也是基于三层目录设计的,但本篇博文的的重点不在这里,所以本文所使用到的其它技能点将粗略描述,可以通过点击对应链接查阅官方文档(主要是我觉得官方文档写的比我写的好,表述的也比我更清楚)

目录结构

申请INTERNET权限

Web组件中如果有请求网络的需求,申请INTERNET权限是必不可少的,在编写这个示例的时候,h5页面是先在vscode下使用npm run dev启动的本地服务,在web组件中设置src地址访问的(方便随时预览h5效果,不用反复打包),最后基础功能开发完成后才进行打包放入鸿蒙项目资源;在这里就得注意,如果是通过前者方式,必须申请网络权限,最终打包后放入资源文件夹中通过本地的方式请求网页可以不用申请网络权限(仅限于h5页面中也没有全球网络的前提,否则请求不通的)

在web组件所在的module中找到module.json5,在“requestPermissions”中添加如下代码:

"requestPermissions": [
      {
        "name": "ohos.permission.INTERNET",
        "reason": "$string:request_internet_reason"
      }
]

其中的reason通过$string引用当前module下的自定义字符串,在string中添加自定义字符串字段

{
  "string": [
    // ... other
    {
      "name": "request_internet_reason",
      "value": "申请请求网络"
    }
  ]
}

配置Web组件

Web组件需要用到以下属性(其他属性参考Web组件

属性名

参数类型

必填

说明

javaScriptProxy

JavaScriptProxy

参与注册的对象。只能声明方法,不能声明属性。

imageAccess

boolean

设置是否允许自动加载图片资源。默认值:true。

javaScriptAccess

boolean

是否允许执行JavaScript脚本。默认值:true。

domStorageAccess

boolean

设置是否开启文档对象模型存储接口(DOM Storage API)权限。默认值:false。

zoomAccess

boolean

设置是否支持手势进行缩放。默认值:true。

以及初始化WebviewController,后续的H5页面和原生页面间的数据通讯页需要通过WebViewController创建。

代码如下:

import { webview } from "@kit.ArkWeb";
import {JSProxy} from "utils"
import { BusinessError } from "@kit.BasicServicesKit";

@Component
export struct WebPage {
  webController : webview.WebviewController = new webview.WebviewController();
  jsProxy:JSProxy = new JSProxy()
  webSrc:string | ResourceStr = 'http://192.168.1.13:8080'
  // webSrc:string | ResourceStr = $rawfile("html/index.html") // 后续打包后使用此方法

  build() {
    Row() {
      Web({src:this.webSrc,controller:this.webController})
        .javaScriptProxy(this.jsProxy.GenerateJSProxy(this.webController,this.getUIContext()))
        .domStorageAccess(true)
        .javaScriptAccess(true)
        .imageAccess(true)
        .zoomAccess(false)
        .height("100%")
        .width("100%")
        .height('100%')
  }
}

以上代码中我通过在utils模块内编写JSProxy类,通过编写GenerateJSProxy来完成JavaScriptProxy的配置,在commons/utils模块中创建JSProxy.ets,并在模块的Index.ets中导出

代码如下:

import { promptAction } from '@kit.ArkUI'
import { PageRouter } from "./PageRouter"

interface PickerParams {        // 选择器参数
  title: string,
  time: number,
  success: (res: object) => void,   // js中成功回调
  fall: () => void                  // 失败回调
}

interface NavParams {        // navigation阐述
  name: string,
  params?: object
}

class JSProxy{
  GenerateJSProxy(webController: WebviewController, context: UIContext): JavaScriptProxy {
    return {
      object: {
        "showToast": (msg: string) => {                    // etx弹出原生Toast
          promptAction.showToast({ message: msg })
        },
        "showTimePicker": (params: PickerParams) => {        // ets弹出时间选择器
          context.showTimePickerDialog({
            onAccept(res: TimePickerResult) {
              params.success(res)
            }
          })
        },
        "navigationTo": (params: NavParams) => {        // ets页面跳转
          PageRouter.navigationTo(params.name, params.params)
        }
      },
      name: "HMJSBridge",
      methodList: ["showToast", "showTimePicker", "navigationTo"],
      controller: webController
    }
  }
}
export { JSProxy }

代码中定义了三个方法("showToast", "showTimePicker", "navigationTo")供给h5端调用,在web端通过window.HMJSBridge.[method]调用。可以基于此做出更多功能的扩展,比如下载、上传、三方平台登录等。

针对navigationTo编写一个用于跳转的目标页面,并在该页面中获取传递的参数值。

import { JSON } from "@kit.ArkTS"

@Builder
export function NativeNextBuilder() {
  NativeNextPage()
}

@Component
struct NativeNextPage {
  pageNavStack:NavPathStack = LocalStorage.getShared().get("pageNavStack") as NavPathStack
  @State params:object|null = null
  aboutToAppear(): void {
    this.params =  this.pageNavStack.getParamByName("NativeNextPage")
  }
  build() {
    NavDestination() {
      Column() {
        Text("这个页面是再H5内跳转的原生页面").fontSize(16)
        Divider().margin(20)
        Text("跳转页面携带的params:" + JSON.stringify(this.params))
      }.height("100%").width("100%")
    }
    .title("原生页面")
  }
}

PageRouter类实际是对NavPathStack的简单封装,web页面的上层还有一个组件Tabs,用于原生页面和Web页面间的切换,NavPathStack 的初始化是在Tabs页面完成的,后续会提到。现在,h5已经可以正常显示了,通过配置JavaScriptProxy,与Web页面进行JavaScript交互。接下来编写h5端

编写H5页面和功能

应用支持web能力了应该是和浏览器无异了,主要是需要web页面和原生程序更好的融合,所以要做的是web端可以请求原生接口以及ets可以和js相互通讯。本实例使用Vue做的,实现方式都大同小异

简单编写调用ets中注入的三个方法,将其命名为HMSDK.js(相当于)

    // HMSDK.js
    function HMToast(message) {
        if (window.HMJSBridge) {
            window.HMJSBridge.showToast(message);
        } else {
            console.warn("HMJSBridge is not available");
        }
    }

    function HMTimePicker(callback) {
        if (window.HMJSBridge) {
            window.HMJSBridge.showTimePicker(callback);
        } else {
            console.warn("HMJSBridge is not available");
        }
    }

    function HMNavigation(params) {
        if (window.HMJSBridge) {
            window.HMJSBridge.navigationTo(params);
        } else {
            console.warn("HMJSBridge is not available");
        }
    }

    function HMNativeText(){
        return window.HMJSBridge ? window.HMJSBridge.nativeText() : null;
    }
export default {
    HMToast,
    HMTimePicker,
    HMNavigation,
    HMNativeText
}

编写页面

<template>
  <div class="hello">
    <h3>{{ msg }}</h3>
    <div class="label">向arkTS传递数据</div>
    <div class="box">
      <div class="content" style="display: flex;flex-direction: column;align-items: flex-end;">
        <input class="h5-input" type="text" placeholder="输入文本" v-model="sendMsg"/>
        <button @click="sendMessage" >发送数据</button>
      </div>
    </div>
    <div class="label">ArkTs传递的数据</div>
    <div class="box">
      <div class="content">
        <p style="font-size: 16;color: #383838;">{{ nativateText }}</p>
      </div>
    </div>
    <div class="label">调用原生接口</div>
    <div class="box">
      <div class="content">
        <div style="display: flex;justify-content: space-between;width: 100%;align-items: center;">
          <button @click="showTimePicker">原生时间选择器</button><span v-if="time">选中的时间:{{ time }}</span>
        </div>
        <button @click="showToast">弹出原生Toast</button>
        <button @click="navTo">原生navigation跳转</button>
      </div>
    </div>
  </div>
</template>

<script>
import HMSDK from "@/utils/HMSDK.js";
var h5Port = null;
export default {
  name: 'HelloWorld',
  props: {
    msg: String
  },
  data() {
    return {
      time: '',
      nativateText: '',
      sendMsg:""
    }
  },
  methods: {
    // 这里可以添加方法来处理按钮点击事件
    showToast() {
      HMSDK.HMToast("Hello World");
    },
    showTimePicker() {
      HMSDK.HMTimePicker({
        success: (res) => {
          this.time = res.hour + ':' + res.minute;
        }
      });
    },
    navTo() {
      HMSDK.HMNavigation({
        name: 'NativeNextPage',
        params: {
          key: 'Hello'
        }
      });
    },
    sendMessage(){
        // sendMessage
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.box {
  border: 1px dashed #ccc;
  padding: 10px;  
  margin: 10px;
  min-height: 30px;
  box-sizing: border-box;
}

.label {
  font-size: 14px;
  color: #999;
  margin-bottom: 10px;
  font-weight: bold;
  text-align: left;
  line-height: 1.5;
  padding: 0 10px;
  margin-top: 20px;
}

.h5-input{
  box-sizing: border-box;
  height: 32px;
  line-height: 32px;
  outline: none;
  padding: 4px;
  font-size: 14;
  border-radius: 4px;
  border: 1px solid #41b883;
  color: #35495e;
  width: 96%;
  margin: 10px;
}
.h5-input:focus{
  border: 1px solid #41b883;
  box-shadow: 0 0 5px rgb(65, 184, 131);
}

button{
  outline: none;
  display: block;
  border: none;
  background: #35495e;
  color: #fff;
  padding: 8px 16px;
  border-radius: 4px;
  cursor: pointer;
  font-size: 14px;
  margin: 10px;

}
button:active{
  background: #35495e;
}

.content {
  font-size: 16px;
  color: #333;
  display: flex;
  justify-content: center;
  align-items: flex-start;
  flex-direction: column;
  box-sizing: border-box;
}

h4 {
  margin: 40px 0 0;
}
</style>

页面编写完成后,通过npm run 运行服务后可以在ets中配置web组件的地方将src改为服务地址,现在已经可以通过点击h5页面上的按钮预览效果了,此步骤已经完成了web端调用ets接口的功能,另外,通过在jsProxy中定义的PickerParams的success回调,可以在h5中调用这个回调函数获取到时间选择器选择后返回的值,并将值渲染到h5页面上:

showTimePicker() {
   HMSDK.HMTimePicker({
      // ... other Params
      success: (res) => {
        this.time = res.hour + ':' + res.minute;
      }
    });
},

(这种方式是不是和uniapp一模一样🤭)

H5和ets双向通信

通过前面的实例,js已经可以通过jsProxy请求ets的接口了,两端的相互通信可以通过WebMessagePort接口创建消息端口来实现(实现方法和官方文档一致)

在WebPage中web组件的onPageEnd回调中创建通过webViewController创建WebMessagePort,通过` LocalStorage.getShared().setOrCreate("messagePorts", this.ports) `共享在wbe页面创建好的WebMessagePort实例实现在不同的ets页面中使用

Web({src:this.webSrc,controller:this.webController})
        // 其他属性
        .onPageEnd(()=> {
          // 页面加载完成后将controller共享
          try {
            // 创建两个消息端口。
            this.ports = this.webController.createWebMessagePorts();
            // 将另一个消息端口0发送到HTML侧,由HTML侧保存并使用。
            this.webController.postMessage('__init_port__', [this.ports[0]], '*');
            LocalStorage.getShared().setOrCreate("messagePorts", this.ports)
          } catch (error) {
            console.error(`ErrorCode: ${(error as BusinessError).code},  Message: ${(error as BusinessError).message}`);
          }
        })
    }

在methods中创建receivedMessage和sendMessage方法并在mounted中调用receivedMessage方法监听信息端口


import HMSDK from "@/utils/HMSDK.js";
var h5Port = null;
export default {
  // ... other options
  methods: {
    // ... other methods
    receivedMessage() {
      let that = this
      window.addEventListener('message', function (event) {        
        if (event.data === '__init_port__') {    
          if (event.ports[0] !== null) {
            h5Port = event.ports[0]; // 保存从应用侧发送过来的端口。
            h5Port.start(); // 
            h5Port.addEventListener('message', function (event) {
              that.nativateText = event.data;
            }.bind(this));
          }
        }
      })
    },
    sendMessage() {
      if (h5Port) {
        h5Port.postMessage(this.sendMsg);
        HMSDK.HMToast("到native页面查看效果");
      } else {
        HMSDK.HMToast("h5Port还没有初始化");
      }
    }
  },
  mounted() {
    this.receivedMessage();
  }
}

创建一个原生页面,通过LocalStorage.getShared()获取通过WebPage创建好的WebMessagePort实例,用于展示h5和ets间通讯的效果

import { webview } from "@kit.ArkWeb";
import { promptAction } from "@kit.ArkUI";

@Component
export struct NativePage {
  @State message: string = '当前页面是ArkUI原生页面';
  @State inputValue:string = ""
  @State h5Message:string = ""

  ports: webview.WebMessagePort[] = LocalStorage.getShared().get("messagePorts") as webview.WebMessagePort[]

  aboutToAppear(): void {
    this.listenMessage()
  }

  listenMessage(){
    this.ports[1].onMessageEvent((result: webview.WebMessage) => {
      let msg = ''
      if (typeof (result) === 'string') {
        console.info(`从h5获取到信息: ${result}`);
        msg = msg + result;
      }//else if + 其它类型判断
      
      this.h5Message = msg;
      console.log(msg)
    })
  }

  build() {
    Column(){
      Text().width("100%").fontSize(16)
      Row(){
        TextInput({placeholder:"输入内容将会同步到H5",text:this.inputValue})
          .enableKeyboardOnFocus(false).layoutWeight(1)
          .onChange((value:string)=>{
            this.inputValue = value
          })
        Button("发送").onClick(()=>{
          this.ports[1].postMessageEvent(this.inputValue)
          focusControl.requestFocus('button')
          promptAction.showToast({ message: "到Web页面查看效果" })
        }).key('button').width(80)
      }.alignItems(VerticalAlign.Center).margin({top:12})
      Column(){
        Text("此处显示的是H5中输入的内容")
        Row(){
          Text(this.h5Message).fontSize(14).fontColor("#666666")
        }.width("100%").height(60)
        .margin({top:12}).border({style:BorderStyle.Dashed,color:"#999999",width:1}).justifyContent(FlexAlign.Center)
      }.margin({top:30})
    }.width("100%")
    .height("100%")
    .padding(12)
    .justifyContent(FlexAlign.Start)
  }
}

至此完成了vue和ets双向通信的功能,最后,如果项目由离线打包的需求,将Vue项目通过npm打包后在web模块的资源文件夹中新建rawfile文件夹,并将打包好的vue项目拷贝到该文件夹下,通过$rawfile("html/index.html")修改Web组件的src地址。

完整代码 https://gitcode.com/mtyee/JSBridge.git

 

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐