混合应用落地:用 OpenHarmony + Cordova 封装 Web 2048 游戏

演示如何在 HarmonyOS 上用 Cordova 容器封装 Web 前端,完成一个可运行、可发布的混合应用。文章聚焦于落地工程方法与关键桥接点,代码与文字比例约 4:6,所有代码片段均来自本项目真实文件。


为什么选择混合方案

对于已有 Web 小游戏(例如 2048),混合方案的优势在于:

  • 快速复用:直接复用现有 HTML/CSS/JS 代码。
  • 低成本发布:通过容器封装,适配 HarmonyOS 分发生态。
  • 渐进增强:可按需接入原生能力(导航、触觉、性能监测等)。

在本项目中,我们用 @magongshou/harmony-cordova 提供的 MainPage 承载 WebView 并管理页面生命周期,从而将 rawfile/www 下的前端项目原样装入。


工程结构与加载关系

  • Harmony 原生模块(ArkTS):
    • entry/src/main/ets/pages/Index.ets 通过 MainPage 组件加载 Web 入口页面,并将生命周期/返回键事件桥接给 Cordova。
  • Web 前端模块:
    • www/index.html 为 Web 入口文件,引用外部样式 css/style.css 与脚本 js/app.js

关键关系:ArkTS 的 MainPage 默认加载 rawfile/www/index.html,并把 pageShowEvent / pageHideEvent / pageBackPress 这类事件透传给 Cordova 容器,容器再与 WebView 协同管理路由与回退。

加载流程图

flowchart LR
  A[Index.ets (ArkTS)] --> B[MainPage(容器)] --> C[WebView]
  C --> D[rawfile/www/index.html]
  D --> E[css/style.css]
  D --> F[js/app.js]
  A -. pageShow/pageHide .-> B
  A -. pageBackPress .-> B
  B -. 生命周期/路由管理 .-> C

ArkTS 侧容器与桥接点

文件:entry/src/main/ets/pages/Index.ets

import {
  MainPage,
  pageBackPress,
  pageHideEvent,
  pageShowEvent,
  PluginEntry
} from '@magongshou/harmony-cordova/Index';

@Entry
@Component
struct Index {
  /**
   * ArkTS 侧的自定义插件配置
   * 配置插件名称和对象,详见自定义插件开发部分
   */
  cordovaPlugs: Array<PluginEntry> = [];

  aboutToAppear() {
  }

  /** 页面显示生命周期:通知 Cordova 页面已显示 */
  onPageShow() {
    pageShowEvent();
  }

  /** 返回键拦截:交由 Cordova 处理返回栈 */
  onBackPress() {
    pageBackPress();
    return true;  // 返回 true 拦截默认行为
  }

  /** 页面隐藏生命周期:通知 Cordova 页面已隐藏 */
  onPageHide() {
    pageHideEvent();
  }

  /** 构建页面 UI,装载 WebView(rawfile/www/index.html) */
  build() {
    RelativeContainer() {
      MainPage({
        isWebDebug: false,           // 是否开启 Web 调试
        cordovaPlugs: this.cordovaPlugs  // 传入插件配置(当前为空,可按需扩展)
      });
    }
    .height('100%')
    .width('100%')
  }
}

要点说明:

  • MainPage 是混合容器核心;默认载入 rawfile/www/index.html
  • pageShowEvent / pageHideEvent / pageBackPress 把原生生命周期与返回键事件交给 Cordova,由容器协调 Web 页面的路由与回栈。
  • cordovaPlugs 为空数组时,不注册自定义原生插件;后续可按需加入自定义插件对象,完成更深的 JS↔原生能力互通(例如文件系统、设备信息、振动、截图等)。

桥接机制详解:Index.ets → Cordova → WebView

  • 生命周期透传
    • onPageShow() 调用 pageShowEvent() → Cordova 接收并可通知 Web 层“页面前台”,便于暂停/恢复音效或计时。
    • onPageHide() 调用 pageHideEvent() → Cordova 接收并可通知 Web 层“页面后台”,便于持久化状态。
  • 系统返回键托管
    • onBackPress() 内部 pageBackPress() 交给 Cordova 决定回退行为(如:Web 历史后退、关闭弹窗、二次确认退出等)。ArkTS 返回 true 表示已拦截系统默认行为。
  • 插件注册入口
    • cordovaPlugs: Array<PluginEntry> 是 JS↔原生能力扩展的注册点;本项目现阶段保持为空,专注于封装与运行。
  • 时序说明
    1. ArkTS/Index.ets 触发生命周期 →
    2. page* 方法将事件投递给 Cordova →
    3. Cordova 根据事件类型与页面状态,协调 WebView 或(若存在)分发到注册的原生插件。
时序流程图
Index.ets (ArkTS) MainPage/Cordova 容器 WebView index.html (Web) 已注册插件(可选) onPageShow() 调用 pageShowEvent() 页面前台事件 前台通知/恢复逻辑 onBackPress() 调用 pageBackPress() 请求处理返回 Web 历史后退/关闭弹窗/二次确认 ArkTS 返回 true 已拦截系统默认行为 onPageHide() 调用 pageHideEvent() 页面后台事件 持久化状态/暂停动画与音效 分发事件/消息(onStart/onResume/onPause 等) 处理结果/回调 alt [存在插件处理] Index.ets (ArkTS) MainPage/Cordova 容器 WebView index.html (Web) 已注册插件(可选)

Cordova 插件基类与注册接口

文件:cordova/src/main/ets/components/CordovaPlugin.ets

import { CordovaInterface } from './CordovaInterface'
import { CallbackContext } from './CallbackContext'
import { CordovaWebView } from './CordovaWebView'
import { HashMap } from '@kit.ArkTS'
import { CordovaPreferences } from './CordovaPreferences'

export  class CordovaPlugin {
  protected webId?:string;
  protected cordovaWebView?: CordovaWebView;
  protected preferences?:CordovaPreferences;
  protected cordovaInterface?: CordovaInterface;
  protected serviceName?: string;
  protected mapWebIdToCustomPlugins?:HashMap<string, HashMap<string, CordovaPlugin>>;
  protected mapWebIdToWebView?:HashMap<string, CordovaWebView>;
  protected mapWebIdToInterface?:HashMap<string, CordovaInterface>;
  protected mapWebIdToWebTag?:HashMap<string, string>;

  privateInitialize(webId:string,
            serviceName: string,
            preferences:CordovaPreferences,
            cordovaInterface: CordovaInterface,
            cordovaWebView:CordovaWebView,
            mapWebIdToCustomPlugins:HashMap<string, HashMap<string, CordovaPlugin>>,
            mapWebIdToWebView:HashMap<string, CordovaWebView>,
            mapWebIdToInterface:HashMap<string, CordovaInterface>,
    mapWebIdToWebTag:HashMap<string, string>): void {
    this.webId = webId;
    this.serviceName = serviceName;
    this.preferences = preferences;
    this.cordovaInterface = cordovaInterface;
    this.cordovaWebView = cordovaWebView;
    this.mapWebIdToCustomPlugins = mapWebIdToCustomPlugins;
    this.mapWebIdToWebView = mapWebIdToWebView;
    this.mapWebIdToInterface = mapWebIdToInterface;
    this.mapWebIdToWebTag = mapWebIdToWebTag;
    this.initialize(cordovaInterface, cordovaWebView);
  }

  initialize(cordovaInterface: CordovaInterface, cordovaWebView:CordovaWebView):void{
    return;
  }
  execute(action: string, args: ESObject[], callbackContext: CallbackContext):boolean {
    return true;
  }
  onMessage(id:string, data:string):void {
  }
  onDestroy():void {
  }
  onPause(multitasking:boolean):void {
  }
  onResume(multitasking:boolean):void {
  }
  onStart():void {
  }
  onPageStart():void {
  }
  onPageEnd():void {
  }
}

export interface PluginEntry {
  pluginName: string
  pluginObject:CordovaPlugin
}

补充说明:

  • PluginEntry 定义了原生插件的注册结构(服务名与实例对象)。Index.ets 中的 cordovaPlugs: Array<PluginEntry> 即为注册入口。
  • execute(action, args, callbackContext) 是 JS 调用原生时的统一入口;onMessage 用于容器向插件广播消息;其余生命周期钩子可根据需要覆写。
代码结构图
classDiagram
  class CordovaPlugin {
    - webId?: string
    - cordovaWebView?: CordovaWebView
    - preferences?: CordovaPreferences
    - cordovaInterface?: CordovaInterface
    - serviceName?: string
    - mapWebIdToCustomPlugins?: HashMap<string, HashMap<string, CordovaPlugin>>
    - mapWebIdToWebView?: HashMap<string, CordovaWebView>
    - mapWebIdToInterface?: HashMap<string, CordovaInterface>
    - mapWebIdToWebTag?: HashMap<string, string>
    + initialize(cordovaInterface: CordovaInterface, cordovaWebView: CordovaWebView): void
    + execute(action: string, args: ESObject[], callbackContext: CallbackContext): boolean
    + onMessage(id: string, data: string): void
    + onDestroy(): void
    + onPause(multitasking: boolean): void
    + onResume(multitasking: boolean): void
    + onStart(): void
    + onPageStart(): void
    + onPageEnd(): void
  }

  class PluginEntry {
    + pluginName: string
    + pluginObject: CordovaPlugin
  }

  class CordovaInterface
  class CordovaWebView
  class CordovaPreferences
  class CallbackContext
  class HashMap

  CordovaPlugin --> CordovaInterface
  CordovaPlugin --> CordovaWebView
  CordovaPlugin --> CordovaPreferences
  CordovaPlugin ..> CallbackContext : uses
  PluginEntry --> CordovaPlugin

  CordovaPlugin ..> "mapWebIdToCustomPlugins: HashMap<string, HashMap<string, CordovaPlugin>>"
  CordovaPlugin ..> "mapWebIdToWebView: HashMap<string, CordovaWebView>"
  CordovaPlugin ..> "mapWebIdToInterface: HashMap<string, CordovaInterface>"
  CordovaPlugin ..> "mapWebIdToWebTag: HashMap<string, string>"

Web 侧入口

文件:cordova/src/main/resources/rawfile/www/index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>2048</title>
  <link rel="stylesheet" href="css/style.css" />
</head>
<body>
  <div class="wrap">
    <div class="head">
      <h1>2048</h1>
      <div class="stats">
        <div class="badge"><small>分数</small><b id="score">0</b></div>
        <div class="badge"><small>最佳</small><b id="best">0</b></div>
      </div>
    </div>
    <div class="toolbar">
      <button id="newGame">新游戏</button>
      <span class="tip">使用键盘方向键(↑↓←→)进行移动。</span>
    </div>
    <div class="board">
      <div id="grid" class="grid"></div>
    </div>
  </div>
  <script src="js/app.js"></script>
</body>
</html>

要点说明:

  • 页面只关心 UI 与游戏逻辑;不直接感知容器。
  • 容器负责加载此页面、托管生命周期与返回键。

样式片段

文件:cordova/src/main/resources/rawfile/www/css/style.css

.board { background: var(--board); padding: 10px; border-radius: 10px; position: relative; }
.grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.cell {
  background: var(--cell); border-radius: 6px; aspect-ratio: 1 / 1; display: flex; align-items: center; justify-content: center;
  font-weight: 700; font-size: 30px; color: var(--dark);
}
.n-0 { background: var(--cell); color: transparent; }
.n-2 { background: var(--tile-2); }
.n-4 { background: var(--tile-4); }
.n-8 { background: var(--tile-8); color: var(--light); }
.n-16 { background: var(--tile-16); color: var(--light); }
.n-32 { background: var(--tile-32); color: var(--light); }
.n-64 { background: var(--tile-64); color: var(--light); }
.n-128 { background: var(--tile-128); color: var(--light); font-size: 26px; }
.n-256 { background: var(--tile-256); color: var(--light); font-size: 26px; }
.n-512 { background: var(--tile-512); color: var(--light); font-size: 24px; }
.n-1024 { background: var(--tile-1024); color: var(--light); font-size: 20px; }
.n-2048 { background: var(--tile-2048); color: var(--light); font-size: 20px; }

要点说明:

  • 使用 CSS Grid 快速布局棋盘。
  • 通过不同数值 class(如 n-2, n-4, n-8)映射不同底色与字号,提升信息密度与辨识度。

JS 核心算法与输入

文件:cordova/src/main/resources/rawfile/www/js/app.js(节选)

// 初始化网格 DOM
function buildGridDOM() {
  elGrid.innerHTML = '';
  for (let i = 0; i < size * size; i++) {
    const d = document.createElement('div');
    d.className = 'cell n-0';
    elGrid.appendChild(d);
  }
}

function freshUI() {
  const cells = elGrid.children;
  for (let r = 0; r < size; r++) {
    for (let c = 0; c < size; c++) {
      const v = board[r][c];
      const idx = r * size + c;
      const cell = cells[idx];
      cell.textContent = v ? String(v) : '';
      cell.className = 'cell n-' + (v || 0);
    }
  }
  elScore.textContent = String(score);
  elBest.textContent = String(best);
}

function slideAndMerge(line) {
  // 移除 0
  const filtered = line.filter(v => v !== 0);
  let moved = filtered.length !== line.length;
  const out = [];
  for (let i = 0; i < filtered.length; i++) {
    if (i < filtered.length - 1 && filtered[i] === filtered[i + 1]) {
      const merged = filtered[i] * 2;
      out.push(merged);
      score += merged;
      i++;
      moved = true;
    } else {
      out.push(filtered[i]);
    }
  }
  while (out.length < size) out.push(0);
  // 检查是否有变化
  for (let i = 0; i < size; i++) {
    if (out[i] !== line[i]) moved = true;
  }
  return { line: out, moved };
}

function moveLeft() {
  let movedAny = false;
  for (let r = 0; r < size; r++) {
    const { line, moved } = slideAndMerge(board[r]);
    board[r] = line;
    movedAny = movedAny || moved;
  }
  return movedAny;
}

// 键盘输入与触摸滑动
window.addEventListener('keydown', (e) => {
  let moved = false;
  switch (e.key) {
    case 'ArrowLeft': e.preventDefault(); moved = moveLeft(); break;
    case 'ArrowRight': e.preventDefault(); moved = moveRight(); break;
    case 'ArrowUp': e.preventDefault(); moved = moveUp(); break;
    case 'ArrowDown': e.preventDefault(); moved = moveDown(); break;
    default: return;
  }
  afterMove(moved);
});

要点说明:

  • slideAndMerge 是核心:过滤 0 → 合并相邻 → 末尾补 0 → 返回是否发生变更。
  • 四个方向移动通过“行列变换 + 反转”技巧复用同一套逻辑,保证实现简洁且易测。
  • 输入层兼容键盘与触摸(移动端滑动阈值 30px)。

JS↔原生桥接的现状与扩展路径

本项目当前桥接点主要体现在 ArkTS 对页面生命周期与返回键的透传(见第 3 节 pageShowEvent/pageHideEvent/pageBackPress)。这能确保:

  • Web 页在容器内“正确地”响应前后台切换。
  • 统一由 Cordova 管理返回栈,避免出现系统返回键导致 Web 页意外退出的情况。

当需要“JS 调用原生能力”与“原生回调 JS”时,推荐路径:

  1. 在 ArkTS 侧实现并注册自定义插件对象,加入 cordovaPlugs: Array<PluginEntry> 数组。
  2. 在 Web 侧通过 cordova.exec(success, fail, service, action, [args]) 调用对应服务与方法;成功后容器回调 success

性能与体验建议

  • 渲染层尽量减少 DOM 重建:本项目仅更新已有节点的 classNametextContent
  • 动画建议采用 transform/opacity,避免频繁触发布局重排。
  • 移动端设置合理的滑动阈值(本项目为 30px),减少误触。
  • 按需降级:在低端设备上可暂时关闭复杂动画,优先保留核心玩法。

构建与运行

  • Harmony 工程启动后,MainPage 默认装载 rawfile/www/index.html,无需额外配置即可运行 2048。

  • Web 端调试:直接双击 www/index.html 也可运行,便于快速联调 UI 与逻辑。


小结与下一步

本文展示了将一个纯 Web 2048 游戏以最小成本封装到 HarmonyOS 的完整过程:

  • ArkTS 用 MainPage 容器装载前端页面,并桥接生命周期与返回键。
  • Web 侧三件套(HTML/CSS/JS)保持纯净,专注 UI 与游戏核心算法。
  • 当需要更强的原生能力时,再通过 cordovaPlugscordova.exec 渐进式接入。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

淼学派对

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值