We use essential cookies for the website to function, as well as analytics cookies for analyzing and creating statistics of the website performance. To agree to the use of analytics cookies, click "Accept All". You can manage your preferences at any time by clicking "Cookie Settings" on the footer. More Information.

Only Essential Cookies
Accept All
Best PracticesApplication FrameworkArkWebArkWeb Rendering Framework Adaptation

ArkWeb Rendering Framework Adaptation

Overview

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.

HarmonyOS-based Hybrid Application Solution

Architecture

  1. Ark process: runs by the ArkTS engine and has the capability to call system APIs. Start the app from the Ark process, initialize the EntryAbility, and create the HarmonyOS app page. The Ark process can dynamically or statically create the WebView runtime environment and load HTML, CSS, or JS resource files.
  2. Webview process: supports standard W3C APIs by default and has restrictions on access to ArkTS resources. The WebView rendering capability is mainly provided by the Web component. You can configure whether to enable the same-layer rendering capability and whether to allow JavaScript execution using the attributes of the Web component.
  3. JSBridge: the communication mechanism of the preceding two processes that allows bidirectional data flow. The WebView process accesses the extension API through the JSBridge channel.

Solution

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.

Key Points

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.

Dual-End Communication

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.

  1. WebMessagePort is a basic message sending and receiving mechanism. It supports the string and ArrayBuffer message types. The encapsulation and parsing of specific service message content need to be designed from scratch, which is difficult to use and requires heavy workload.
  2. JavaScriptProxy mechanism injects the ArkUI main process object (for example, native) to the WebView and generates a proxy object in the window of the WebView. The service can directly call the methods of the proxy object and related operations take effect on the native object of the ArkUI main process. The code example is as follows:
    Collapse
    Word wrap
    Dark theme
    Copy code
    1. // Web component loading H5.
    2. Web({ src: this.param.path, controller: this.webController })
    3. .zoomAccess(false)
    4. .width(Const.WEB_CONSTANT_WIDTH)
    5. .aspectRatio(1)
    6. .margin({
    7. left: Const.WEB_CONSTANT_MARGIN_LEFT, right: Const.WEB_CONSTANT_MARGIN_RIGHT,
    8. top: Const.WEB_CONSTANT_MARGIN_TOP
    9. })
    10. .onErrorReceive((event) => {
    11. if (event?.error.getErrorInfo() === 'ERR_INTERNET_DISCONNECTED') {
    12. this.getUIContext().getPromptAction().showToast({
    13. message: $r('app.string.internet_err'),
    14. duration: Const.WEB_CONSTANT_DURATION
    15. });
    16. }
    17. if (event?.error.getErrorInfo() === 'ERR_CONNECTION_TIMED_OUT') {
    18. this.getUIContext().getPromptAction().showToast({
    19. message: $r('app.string.internet_err'),
    20. duration: Const.WEB_CONSTANT_DURATION
    21. });
    22. }
    23. })
    24. .onProgressChange((event) => {
    25. if (event?.newProgress === Const.WEB_CONSTANT_PROGRESS_MAX) {
    26. this.isLoading = false;
    27. clearInterval(this.intervalLoading);
    28. this.intervalLoading = -1;
    29. }
    30. })
    31. .javaScriptProxy({
    32. object: this.linkObj,
    33. name: 'linkObj',
    34. methodList: ['messageFromHtml'],
    35. controller: this.webController
    36. })

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.

  1. Communication channel: shields the specific communication mechanism from the upper layer and passes data between the web and ArkTS sides. It does not parse the service meaning of the data or pay attention to the passed data content. Data can be serialized into strings or passed as objects. The sample code of the communication channel implemented using the javaScriptProxy mechanism is as follows:
    Collapse
    Word wrap
    Dark theme
    Copy code
    1. // Web component loading H5.
    2. Web({ src: this.param.path, controller: this.webController })
    3. .zoomAccess(false)
    4. .width(Const.WEB_CONSTANT_WIDTH)
    5. .aspectRatio(1)
    6. .margin({
    7. left: Const.WEB_CONSTANT_MARGIN_LEFT, right: Const.WEB_CONSTANT_MARGIN_RIGHT,
    8. top: Const.WEB_CONSTANT_MARGIN_TOP
    9. })
    10. .onErrorReceive((event) => {
    11. if (event?.error.getErrorInfo() === 'ERR_INTERNET_DISCONNECTED') {
    12. this.getUIContext().getPromptAction().showToast({
    13. message: $r('app.string.internet_err'),
    14. duration: Const.WEB_CONSTANT_DURATION
    15. });
    16. }
    17. if (event?.error.getErrorInfo() === 'ERR_CONNECTION_TIMED_OUT') {
    18. this.getUIContext().getPromptAction().showToast({
    19. message: $r('app.string.internet_err'),
    20. duration: Const.WEB_CONSTANT_DURATION
    21. });
    22. }
    23. })
    24. .onProgressChange((event) => {
    25. if (event?.newProgress === Const.WEB_CONSTANT_PROGRESS_MAX) {
    26. this.isLoading = false;
    27. clearInterval(this.intervalLoading);
    28. this.intervalLoading = -1;
    29. }
    30. })
    31. .javaScriptProxy({
    32. object: this.linkObj,
    33. name: 'linkObj',
    34. methodList: ['messageFromHtml'],
    35. controller: this.webController
    36. })
  2. Channel: allows you to register multiple method channels. The JS implementation at this layer packs the API information objects (including the name, parameters, and return value types) at the method channel into the information data identified by the communication channel, and sends the data to the ArkTS side through the communication channel. The implementation on the ArkTS side includes two main features. One is to unpack the information data to obtain the API information and send the information to the method channel on the ArkTS side to call the specific API. The other one is to execute Call() in JS. The ArkTS side uses the WebviewController .runJavaScript() method to execute the callback function on the JS side.

    On the JS side, the nativeCall() method provides the packing and conversion capability. The following is an example:

    Collapse
    Word wrap
    Dark theme
    Copy code
    1. function openDialog() {
    2. linkObj.messageFromHtml(prizesArr[prizesPosition]);
    3. }

    On the ArkTS side, runJavaScript() is used to execute JS methods.

    Collapse
    Word wrap
    Dark theme
    Copy code
    1. Button($r('app.string.btnValue'))
    2. .fontSize(Const.WEB_CONSTANT_BUTTON_FONT_SIZE)
    3. .fontColor($r('app.color.start_window_background'))
    4. .margin({ top: Const.WEB_CONSTANT_BUTTON_MARGIN_TOP })
    5. .width(Const.WEB_CONSTANT_BUTTON_WIDTH)
    6. .height(Const.WEB_CONSTANT_BUTTON_HEIGHT)
    7. .backgroundColor($r('app.color.blue'))
    8. .borderRadius(Const.WEB_CONSTANT_BUTTON_BORDER_RADIUS)
    9. .onClick(() => {
    10. this.webController.runJavaScript('startDraw()');
    11. })
  3. Method channel: encapsulates a type of API formats into a method channel. APIs of the same method channel have consistent parameter specifications and return value specifications, for example, applet API specifications. In this way, API invoking information can be encapsulated into structured information objects for the channel to pass.

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.

HarmonyOS APIs

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:

  1. func(paramObj), in which paramObj contains data attributes of basic types and callback functions such as success, fail, or complete().
  2. on/offFunc(callback), which registers and removes the listening function.
  3. getXxManager(): obj, which obtains the global singleton manager of a certain feature, for example, the file manager. The methods of the manager also comply with the preceding two specifications.

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 Components

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:

Collapse
Word wrap
Dark theme
Copy code
  1. <div>
  2. <div id="bodyId">
  3. <embed id="nativeSearch" type = "native/component" width="100%" height="100%" src="view"/>
  4. </div>
  5. </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:

Collapse
Word wrap
Dark theme
Copy code
  1. import { PRODUCT_DATA } from '../viewmodel/GoodsViewModel';
  2. import { ProductDataModel } from '../model/GoodsModel';
  3. import { BuilderNode, FrameNode, NodeController, NodeRenderType } from '@kit.ArkUI';
  4. import { webview } from '@kit.ArkWeb';
  5. // Margin vertical
  6. const MARGIN_VERTICAL: number = 8;
  7. // Font weight
  8. const FONT_WEIGHT: number = 500;
  9. // Placeholder
  10. const PLACEHOLDER: ResourceStr = $r('app.string.embed_search');
  11. declare class Params {
  12. width: number;
  13. height: number;
  14. }
  15. declare class NodeControllerParams {
  16. surfaceId: string;
  17. type: string;
  18. renderType: NodeRenderType;
  19. embedId: string;
  20. width: number;
  21. height: number;
  22. }
  23. class SearchNodeController extends NodeController {
  24. private rootNode: BuilderNode<[Params]> | undefined | null = null;
  25. private embedId: string = "";
  26. private surfaceId: string = "";
  27. private renderType: NodeRenderType = NodeRenderType.RENDER_TYPE_DISPLAY;
  28. private componentWidth: number = 0;
  29. private componentHeight: number = 0;
  30. private componentType: string = "";
  31. /**
  32. * Set rendering parameters.
  33. *
  34. * @param params Rendering parameters.
  35. */
  36. setRenderOption(params: NodeControllerParams): void {
  37. this.surfaceId = params.surfaceId;
  38. this.renderType = params.renderType;
  39. this.embedId = params.embedId;
  40. this.componentWidth = params.width;
  41. this.componentHeight = params.height;
  42. this.componentType = params.type;
  43. }
  44. /**
  45. * Create a node.
  46. *
  47. * @param uiContext UIContext
  48. * @returns Node.
  49. */
  50. makeNode(uiContext: UIContext): FrameNode | null {
  51. this.rootNode = new BuilderNode(uiContext, { surfaceId: this.surfaceId, type: this.renderType });
  52. if (this.componentType === 'native/component') {
  53. this.rootNode.build(wrapBuilder(searchBuilder), { width: this.componentWidth, height: this.componentHeight });
  54. }
  55. return this.rootNode.getFrameNode();
  56. }
  57. setBuilderNode(rootNode: BuilderNode<Params[]> | null): void {
  58. this.rootNode = rootNode;
  59. }
  60. getBuilderNode(): BuilderNode<[Params]> | undefined | null {
  61. return this.rootNode;
  62. }
  63. updateNode(arg: Object): void {
  64. this.rootNode?.update(arg);
  65. }
  66. getEmbedId(): string {
  67. return this.embedId;
  68. }
  69. postEvent(event: TouchEvent | undefined): boolean {
  70. return this.rootNode?.postTouchEvent(event) as boolean;
  71. }
  72. }
  73. @Component
  74. struct SearchComponent {
  75. @Prop params: Params;
  76. controller: SearchController = new SearchController()
  77. build() {
  78. Column({ space: MARGIN_VERTICAL }) {
  79. Text($r("app.string.embed_mall"))
  80. .fontSize($r('app.string.ohos_id_text_size_body4'))
  81. .fontWeight(FONT_WEIGHT)
  82. .fontFamily('HarmonyHeiTi-Medium')
  83. Row() {
  84. Search({ placeholder: PLACEHOLDER, controller: this.controller })
  85. .backgroundColor(Color.White)
  86. }
  87. .width($r("app.string.embed_full_percent"))
  88. .margin($r("app.integer.embed_row_margin"))
  89. Grid() {
  90. ForEach(PRODUCT_DATA, (item: ProductDataModel, index: number) => {
  91. GridItem() {
  92. Column({ space: MARGIN_VERTICAL }) {
  93. Image(item.imageRes).width($r("app.integer.embed_image_size"))
  94. Row({ space: MARGIN_VERTICAL }) {
  95. Text(item.title)
  96. .fontSize($r('app.string.ohos_id_text_size_body1'))
  97. .width(100)
  98. .maxLines(1)
  99. .textOverflow({ overflow: TextOverflow.Ellipsis })
  100. Text(item.price)
  101. .fontSize($r('app.string.ohos_id_text_size_body1'))
  102. .width(50)
  103. .maxLines(1)
  104. }
  105. }
  106. .backgroundColor($r('app.color.ohos_id_color_background'))
  107. .alignItems(HorizontalAlign.Center)
  108. .justifyContent(FlexAlign.Center)
  109. .width($r("app.string.embed_full_percent"))
  110. .height($r("app.string.embed_full_percent"))
  111. .borderRadius($r('app.string.ohos_id_corner_radius_default_m'))
  112. }
  113. }, (item: ProductDataModel, index: number) => index.toString())
  114. }
  115. .columnsTemplate('1fr 1fr')
  116. .rowsTemplate('1fr 1fr 1fr')
  117. .rowsGap($r('app.string.ohos_id_elements_margin_vertical_m'))
  118. .columnsGap($r('app.string.ohos_id_elements_margin_vertical_m'))
  119. .width($r("app.string.embed_full_percent"))
  120. .height($r("app.string.embed_sixty_percent"))
  121. .backgroundColor($r('app.color.ohos_id_color_sub_background'))
  122. }
  123. .padding($r('app.string.ohos_id_card_margin_start'))
  124. .width(this.params.width)
  125. .height(this.params.height)
  126. }
  127. }
  128. @Builder
  129. function searchBuilder(params: Params) {
  130. SearchComponent({ params: params })
  131. .backgroundColor($r('app.color.ohos_id_color_sub_background'))
  132. }
  133. @Entry
  134. @Component
  135. struct Index {
  136. browserTabController: WebviewController = new webview.WebviewController();
  137. @State componentIdArr: Array<string> = [];
  138. private nodeControllerMap: Map<string, SearchNodeController> = new Map();
  139. build() {
  140. Stack() {
  141. ForEach(this.componentIdArr, (componentId: string) => {
  142. NodeContainer(this.nodeControllerMap.get(componentId));
  143. }, (embedId: string) => embedId)
  144. Web({ src: $rawfile("embed_view.html"), controller: this.browserTabController })
  145. .backgroundColor($r('app.color.ohos_id_color_sub_background'))
  146. .zoomAccess(false)
  147. .enableNativeEmbedMode(true)
  148. .onNativeEmbedLifecycleChange((embed) => {
  149. const componentId = embed.info?.id?.toString() as string
  150. if (embed.status === NativeEmbedStatus.CREATE) {
  151. let nodeController = new SearchNodeController();
  152. nodeController.setRenderOption({
  153. surfaceId: embed.surfaceId as string,
  154. type: embed.info?.type as string,
  155. renderType: NodeRenderType.RENDER_TYPE_TEXTURE,
  156. embedId: embed.embedId as string,
  157. width: this.getUIContext().px2vp(embed.info?.width),
  158. height: this.getUIContext().px2vp(embed.info?.height)
  159. });
  160. nodeController.rebuild();
  161. this.nodeControllerMap.set(componentId, nodeController);
  162. this.componentIdArr.push(componentId);
  163. } else if (embed.status === NativeEmbedStatus.UPDATE) {
  164. let nodeController = this.nodeControllerMap.get(componentId);
  165. nodeController?.updateNode({
  166. text: 'update',
  167. width: this.getUIContext().px2vp(embed.info?.width),
  168. height: this.getUIContext().px2vp(embed.info?.height)
  169. } as ESObject);
  170. nodeController?.rebuild();
  171. } else {
  172. let nodeController = this.nodeControllerMap.get(componentId);
  173. nodeController?.setBuilderNode(null);
  174. nodeController?.rebuild();
  175. }
  176. })
  177. .onNativeEmbedGestureEvent((touch) => {
  178. this.componentIdArr.forEach((componentId: string) => {
  179. let nodeController = this.nodeControllerMap.get(componentId);
  180. if (nodeController?.getEmbedId() === touch.embedId) {
  181. nodeController?.postEvent(touch.touchEvent);
  182. }
  183. })
  184. })
  185. }
  186. }
  187. }

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.

Search in Best Practices
Enter a keyword.