Intelligent Assistant
Chat with our virtual assistant to get answers promptly.
Hybrid app development is an app development technology between web apps and system apps. It has the advantages of good interaction experience for system apps and cross-platform development for web apps. The main principle is that the native side provides a unified API through the JSBridge channel, uses HTML/CSS to implement the GUI, and uses JavaScript to write service logic and call system APIs. As a result, the final page is displayed on WebView.

The HarmonyOS solution for hybrid apps focuses on the implementation of JSBridge for dual-end communication, extended APIs, and native components based on same-layer rendering. JSBridge is a bridge for bidirectional communication between the frontend and the ArkTS side. With JSBridge, frontend apps can access the extension APIs implemented on the ArkTS side to provide more services. In terms of the view layer, the same-layer rendering capability provided by the system can be used to change some frontend components with high performance requirements to ArkTS implementations to achieve better experience. The following figure shows the positions of the preceding three points in the framework.

The hybrid app HarmonyOS solution focuses on dual-end communication, APIs, and components development. Dual-end communication is the channel of ArkTS capabilities used by the JS and is the cornerstone of HarmonyOS development; HarmonyOS API provides a set of HarmonyOS implementations for APIs related to the JS platform; HarmonyOS component provides substitute components for Web components in same-layer rendering to improve component performance and interaction experience.
JSBridge functions as a bridge between the WebView process and the ArkUI main process. It is a bidirectional communication mechanism. HarmonyOS provides Web components and ArkWeb APIs such as @ohos.web.webview for Web development. JSBridge can be implemented through WebMessagePort and JavaScriptProxy.
- // Web component loading H5.
- Web({ src: this.param.path, controller: this.webController })
- .zoomAccess(false)
- .width(Const.WEB_CONSTANT_WIDTH)
- .aspectRatio(1)
- .margin({
- left: Const.WEB_CONSTANT_MARGIN_LEFT, right: Const.WEB_CONSTANT_MARGIN_RIGHT,
- top: Const.WEB_CONSTANT_MARGIN_TOP
- })
- .onErrorReceive((event) => {
- if (event?.error.getErrorInfo() === 'ERR_INTERNET_DISCONNECTED') {
- this.getUIContext().getPromptAction().showToast({
- message: $r('app.string.internet_err'),
- duration: Const.WEB_CONSTANT_DURATION
- });
- }
- if (event?.error.getErrorInfo() === 'ERR_CONNECTION_TIMED_OUT') {
- this.getUIContext().getPromptAction().showToast({
- message: $r('app.string.internet_err'),
- duration: Const.WEB_CONSTANT_DURATION
- });
- }
- })
- .onProgressChange((event) => {
- if (event?.newProgress === Const.WEB_CONSTANT_PROGRESS_MAX) {
- this.isLoading = false;
- clearInterval(this.intervalLoading);
- this.intervalLoading = -1;
- }
- })
- .javaScriptProxy({
- object: this.linkObj,
- name: 'linkObj',
- methodList: ['messageFromHtml'],
- controller: this.webController
- })
The frontend can call native.makePhoneCall() to implement. In addition, the parameters of this method support basic types, dictionary objects, and functions, facilitating the design of JSBridge. For details about how to use Web.javaScriptProxy() and WebviewController.registerJavaScriptProxy(), see Invoking Application Functions on the Frontend Page.
Through comparison, constructing JSBridge by injecting objects in javaScriptProxy is a better way. You are advised to design the JSBridge implementation based on the injection mechanism and consider the hierarchical design to improve the universality and flexibility. The following figure shows the hierarchical design idea.

- // Web component loading H5.
- Web({ src: this.param.path, controller: this.webController })
- .zoomAccess(false)
- .width(Const.WEB_CONSTANT_WIDTH)
- .aspectRatio(1)
- .margin({
- left: Const.WEB_CONSTANT_MARGIN_LEFT, right: Const.WEB_CONSTANT_MARGIN_RIGHT,
- top: Const.WEB_CONSTANT_MARGIN_TOP
- })
- .onErrorReceive((event) => {
- if (event?.error.getErrorInfo() === 'ERR_INTERNET_DISCONNECTED') {
- this.getUIContext().getPromptAction().showToast({
- message: $r('app.string.internet_err'),
- duration: Const.WEB_CONSTANT_DURATION
- });
- }
- if (event?.error.getErrorInfo() === 'ERR_CONNECTION_TIMED_OUT') {
- this.getUIContext().getPromptAction().showToast({
- message: $r('app.string.internet_err'),
- duration: Const.WEB_CONSTANT_DURATION
- });
- }
- })
- .onProgressChange((event) => {
- if (event?.newProgress === Const.WEB_CONSTANT_PROGRESS_MAX) {
- this.isLoading = false;
- clearInterval(this.intervalLoading);
- this.intervalLoading = -1;
- }
- })
- .javaScriptProxy({
- object: this.linkObj,
- name: 'linkObj',
- methodList: ['messageFromHtml'],
- controller: this.webController
- })
On the JS side, the nativeCall() method provides the packing and conversion capability. The following is an example:
- function openDialog() {
- linkObj.messageFromHtml(prizesArr[prizesPosition]);
- }
On the ArkTS side, runJavaScript() is used to execute JS methods.
- Button($r('app.string.btnValue'))
- .fontSize(Const.WEB_CONSTANT_BUTTON_FONT_SIZE)
- .fontColor($r('app.color.start_window_background'))
- .margin({ top: Const.WEB_CONSTANT_BUTTON_MARGIN_TOP })
- .width(Const.WEB_CONSTANT_BUTTON_WIDTH)
- .height(Const.WEB_CONSTANT_BUTTON_HEIGHT)
- .backgroundColor($r('app.color.blue'))
- .borderRadius(Const.WEB_CONSTANT_BUTTON_BORDER_RADIUS)
- .onClick(() => {
- this.webController.runJavaScript('startDraw()');
- })
The reasonableness of JSBridge's design directly impacts app performance. You can also consider whether to cache requests in batches and then send requests in a unified manner to reduce the number of requests, or cache unchanged request results.
In addition to W3C APIs, ArkTS API extensions can also be used to access devices in HTML5 service design as follows:

System high-level APIs encapsulate system APIs to better meet service requirements. The specification design of extended APIs is flexible. You are advised to restrict the format of API parameters and return value types. Use basic types or simple dictionary objects. Avoid using complex types of parameters or return values. You can refer to the mature applet framework, whose specification formats can be classified into the following types:
During the design, APIs can be aggregated to an object as an attribute field, facilitating unified parameter and return value processing and interception from the aspect perspective. The following figure shows the process.

HarmonyOS provides the same-layer rendering capability to directly render native components to the WebView layer, achieving greater flexibility and better performance. You can use the following attributes related to the same-layer rendering of the Web component: enableNativeEmbedMode is used to enable or disable the same-layer rendering; onNativeEmbedLifecycleChange is used to set the same-layer rendering lifecycle to CREATE, UPDATE, or DESTROY; onNativeEmbedGestureEvent is used to process interaction events. The same-layer rendering requires that the embed tag be explicitly used in the frontend page file, and the type in the embed tag must start with native/. Frameworks such as Vue can be used to further encapsulate embed tags to generate custom components, add more attributes, events, and methods, and synchronize with the ArkTS side through JSBridge. On the ArkTS side, you need to customize a native component or use a built-in component of the system to dynamically mount the component through the NodeContainer component. The principle of same-layer rendering is as follows:
For development, frontend page developers use the embed tag to indicate that native components are used; app developers use NodeContainer to associate the off-screen node tree and use the makeNode() API to render components on HTML5 pages.

Dynamically mounting or unmounting off-screen nodes.

(1) Initially build a NodeContainer object to represent an empty placeholder. If the content in NodeContainer is empty, the size is 0 during initialization and is not involved in layout.
(2) NodeController holds the BuilderNode object and returns this object to NodeContainer through the makeNode() API to implement dynamic mounting.
(3) The rebuild() method in NodeController triggers NodeContainer to call the makeNode() API again. If the return value is empty, the dynamic unmounting is implemented.
Example of using HTML5 together with embed tags:
- <div>
- <div id="bodyId">
- <embed id="nativeSearch" type = "native/component" width="100%" height="100%" src="view"/>
- </div>
- </div>
On the ArkTS side, NodeController can be extended to manage rendering nodes at the same layer in a unified manner. The following sample code indicates the implementation of the makeNode() API:
- import { PRODUCT_DATA } from '../viewmodel/GoodsViewModel';
- import { ProductDataModel } from '../model/GoodsModel';
- import { BuilderNode, FrameNode, NodeController, NodeRenderType } from '@kit.ArkUI';
- import { webview } from '@kit.ArkWeb';
-
- // Margin vertical
- const MARGIN_VERTICAL: number = 8;
- // Font weight
- const FONT_WEIGHT: number = 500;
- // Placeholder
- const PLACEHOLDER: ResourceStr = $r('app.string.embed_search');
-
- declare class Params {
- width: number;
- height: number;
- }
-
- declare class NodeControllerParams {
- surfaceId: string;
- type: string;
- renderType: NodeRenderType;
- embedId: string;
- width: number;
- height: number;
- }
-
- class SearchNodeController extends NodeController {
- private rootNode: BuilderNode<[Params]> | undefined | null = null;
- private embedId: string = "";
- private surfaceId: string = "";
- private renderType: NodeRenderType = NodeRenderType.RENDER_TYPE_DISPLAY;
- private componentWidth: number = 0;
- private componentHeight: number = 0;
- private componentType: string = "";
-
- /**
- * Set rendering parameters.
- *
- * @param params Rendering parameters.
- */
- setRenderOption(params: NodeControllerParams): void {
- this.surfaceId = params.surfaceId;
- this.renderType = params.renderType;
- this.embedId = params.embedId;
- this.componentWidth = params.width;
- this.componentHeight = params.height;
- this.componentType = params.type;
- }
-
- /**
- * Create a node.
- *
- * @param uiContext UIContext
- * @returns Node.
- */
- makeNode(uiContext: UIContext): FrameNode | null {
- this.rootNode = new BuilderNode(uiContext, { surfaceId: this.surfaceId, type: this.renderType });
- if (this.componentType === 'native/component') {
- this.rootNode.build(wrapBuilder(searchBuilder), { width: this.componentWidth, height: this.componentHeight });
- }
- return this.rootNode.getFrameNode();
- }
-
- setBuilderNode(rootNode: BuilderNode<Params[]> | null): void {
- this.rootNode = rootNode;
- }
-
- getBuilderNode(): BuilderNode<[Params]> | undefined | null {
- return this.rootNode;
- }
-
- updateNode(arg: Object): void {
- this.rootNode?.update(arg);
- }
-
- getEmbedId(): string {
- return this.embedId;
- }
-
- postEvent(event: TouchEvent | undefined): boolean {
- return this.rootNode?.postTouchEvent(event) as boolean;
- }
- }
- @Component
- struct SearchComponent {
- @Prop params: Params;
- controller: SearchController = new SearchController()
-
- build() {
- Column({ space: MARGIN_VERTICAL }) {
- Text($r("app.string.embed_mall"))
- .fontSize($r('app.string.ohos_id_text_size_body4'))
- .fontWeight(FONT_WEIGHT)
- .fontFamily('HarmonyHeiTi-Medium')
- Row() {
- Search({ placeholder: PLACEHOLDER, controller: this.controller })
- .backgroundColor(Color.White)
- }
- .width($r("app.string.embed_full_percent"))
- .margin($r("app.integer.embed_row_margin"))
-
- Grid() {
- ForEach(PRODUCT_DATA, (item: ProductDataModel, index: number) => {
- GridItem() {
- Column({ space: MARGIN_VERTICAL }) {
- Image(item.imageRes).width($r("app.integer.embed_image_size"))
- Row({ space: MARGIN_VERTICAL }) {
- Text(item.title)
- .fontSize($r('app.string.ohos_id_text_size_body1'))
- .width(100)
- .maxLines(1)
- .textOverflow({ overflow: TextOverflow.Ellipsis })
- Text(item.price)
- .fontSize($r('app.string.ohos_id_text_size_body1'))
- .width(50)
- .maxLines(1)
- }
- }
- .backgroundColor($r('app.color.ohos_id_color_background'))
- .alignItems(HorizontalAlign.Center)
- .justifyContent(FlexAlign.Center)
- .width($r("app.string.embed_full_percent"))
- .height($r("app.string.embed_full_percent"))
- .borderRadius($r('app.string.ohos_id_corner_radius_default_m'))
- }
- }, (item: ProductDataModel, index: number) => index.toString())
- }
- .columnsTemplate('1fr 1fr')
- .rowsTemplate('1fr 1fr 1fr')
- .rowsGap($r('app.string.ohos_id_elements_margin_vertical_m'))
- .columnsGap($r('app.string.ohos_id_elements_margin_vertical_m'))
- .width($r("app.string.embed_full_percent"))
- .height($r("app.string.embed_sixty_percent"))
- .backgroundColor($r('app.color.ohos_id_color_sub_background'))
- }
- .padding($r('app.string.ohos_id_card_margin_start'))
- .width(this.params.width)
- .height(this.params.height)
- }
- }
- @Builder
- function searchBuilder(params: Params) {
- SearchComponent({ params: params })
- .backgroundColor($r('app.color.ohos_id_color_sub_background'))
- }
-
- @Entry
- @Component
- struct Index {
- browserTabController: WebviewController = new webview.WebviewController();
- @State componentIdArr: Array<string> = [];
- private nodeControllerMap: Map<string, SearchNodeController> = new Map();
-
- build() {
- Stack() {
- ForEach(this.componentIdArr, (componentId: string) => {
- NodeContainer(this.nodeControllerMap.get(componentId));
- }, (embedId: string) => embedId)
- Web({ src: $rawfile("embed_view.html"), controller: this.browserTabController })
- .backgroundColor($r('app.color.ohos_id_color_sub_background'))
- .zoomAccess(false)
- .enableNativeEmbedMode(true)
- .onNativeEmbedLifecycleChange((embed) => {
- const componentId = embed.info?.id?.toString() as string
- if (embed.status === NativeEmbedStatus.CREATE) {
- let nodeController = new SearchNodeController();
- nodeController.setRenderOption({
- surfaceId: embed.surfaceId as string,
- type: embed.info?.type as string,
- renderType: NodeRenderType.RENDER_TYPE_TEXTURE,
- embedId: embed.embedId as string,
- width: this.getUIContext().px2vp(embed.info?.width),
- height: this.getUIContext().px2vp(embed.info?.height)
- });
- nodeController.rebuild();
- this.nodeControllerMap.set(componentId, nodeController);
- this.componentIdArr.push(componentId);
- } else if (embed.status === NativeEmbedStatus.UPDATE) {
- let nodeController = this.nodeControllerMap.get(componentId);
- nodeController?.updateNode({
- text: 'update',
- width: this.getUIContext().px2vp(embed.info?.width),
- height: this.getUIContext().px2vp(embed.info?.height)
- } as ESObject);
- nodeController?.rebuild();
- } else {
- let nodeController = this.nodeControllerMap.get(componentId);
- nodeController?.setBuilderNode(null);
- nodeController?.rebuild();
- }
- })
- .onNativeEmbedGestureEvent((touch) => {
- this.componentIdArr.forEach((componentId: string) => {
- let nodeController = this.nodeControllerMap.get(componentId);
- if (nodeController?.getEmbedId() === touch.embedId) {
- nodeController?.postEvent(touch.touchEvent);
- }
- })
- })
- }
- }
- }
During implementation, you can use the Map container to associate the embedType with the builder() function of the off-screen node. When makeNode() is executed, the builder() function corresponding to the embedType is called to create the rootNode, and the FrameNode associated with the rootNode is returned, in this way, off-screen nodes can be dynamically mounted and native components can be rendered in HTML5. For details about the same-layer rendering, see Using Same-Layer Rendering.