让 VK 小程序调用 HarmonyOS 原生能力:一次跨端 Bridge SDK 的设计与实践

本文记录一次阶段性的跨端 SDK 实践:在不修改 VK Mini App 业务代码的前提下,让它运行在 HarmonyOS 宿主中,并复用闪光灯、权限、触感、系统栏、侧滑返回等原生能力。

文中的应用标识、用户数据、令牌、业务名称和内部路径均已移除;示例代码经过简化,仅用于说明通用设计。

一、我们真正要解决的是什么问题?

VK Mini App 本质上仍然是 Web 应用。页面通常使用 HTML、CSS、JavaScript 或 React 开发,调用原生能力时则通过 vkBridge.send() 发出请求:

const info = await bridge.send('VKWebAppFlashGetInfo');
await bridge.send('VKWebAppFlashSetLevel', { level: 1 });

在 VK 官方客户端中,这些调用由 VK 客户端接收,再转成 Android 或 iOS 原生操作。但当同一个 Mini App 被放进 HarmonyOS 元服务或其他宿主容器时,原来的客户端能力并不存在:网页仍然会发送 VK Bridge 请求,却没有对应的原生实现。

最直接的做法是修改 Mini App:识别 HarmonyOS 环境,然后换一套 API。这个方案短期能跑,长期却会带来明显问题:

  • Mini App 需要维护多套平台判断;
  • 已有业务代码被平台适配代码侵入;
  • 每增加一个宿主,都要继续修改网页;
  • 接口参数、返回结构和错误语义容易逐渐分叉。

我们选择了另一条路:让网页继续调用原来的 VK API,在容器侧实现一个协议兼容层。对于 Mini App 来说,它仍然运行在熟悉的 VK Bridge 环境中;对于 HarmonyOS 宿主来说,Bridge 请求会被转换成相应的原生能力或宿主业务回调。

这件事的意义并不只是“点亮一次闪光灯”,而是建立一层可持续扩展的跨端底座:

上层小程序只依赖稳定协议,底层宿主可以替换实现。

二、这个 SDK 不是简单的 API 重命名

一个可靠的 Bridge 适配层至少要处理五件事:

  1. 找到网页发出的 VK 请求;
  2. 判断该请求是否应该由本地接管;
  3. 校验参数并调用正确的 HarmonyOS 或宿主能力;
  4. 按 VK 协议组装成功或失败事件;
  5. 在页面刷新、跳转或容器关闭后,避免把旧结果发给新页面。

整体调用链可以简化为:

VK Mini App
  │  bridge.send('VKWebAppXXX', params)
  ▼
VK 页面与 iframe 的 postMessage 通道
  ▼
请求拦截脚本
  ▼
ArkTS JavaScript Proxy
  ▼
Bridge Proxy → Event Registry → 对应 Handler
  ├─ HarmonyOS 系统/硬件能力
  ├─ 宿主业务回调
  └─ 未接管时继续交给 VK
  ▼
VKWebAppXXXResult / VKWebAppXXXFailed
  ▼
原 Promise resolve / reject

关键点在于“选择性接管”。SDK 维护一份当前会话真正支持的 Handler 列表:

  • 找到 Handler:阻止原消息继续传播,由 SDK 处理并回包;
  • 找不到 Handler:不拦截,让消息继续进入真实 VK 链路;
  • Handler 是否注册,可以根据宿主实际提供的能力动态决定。

因此它更像协议适配器,而不是另一套与 VK 平行的新 API。

三、接口实现实际上分成三类

“适配 VK API”不等于“所有接口都直接调用鸿蒙硬件”。当前实现分为三种类型。

1. HarmonyOS 系统或设备能力

这类接口可以直接映射到 HarmonyOS 原生能力:

VK 接口底层能力
VKWebAppFlashGetInfo查询设备手电筒支持情况和当前状态
VKWebAppFlashSetLevel相机权限及手电筒开关
VKWebAppGetGrantedPermissions查询 HarmonyOS 已授予权限
VKWebAppSetViewSettings状态栏、导航栏等窗口设置
VKWebAppAudioPauseWebView 媒体暂停能力
三个 Taptic 接口HarmonyOS 振动反馈
VKWebAppSetSwipeSettings容器的原生侧滑返回控制

2. 宿主业务能力

有些能力不是操作系统能够凭空提供的,例如用户授权令牌和业务用户资料:

VK 接口实现方式
VKWebAppGetAuthToken调用宿主已有的 OAuth 或后端鉴权服务
VKWebAppGetUserInfo调用宿主用户服务,并转换为 VK 字段
VKWebAppSendToClient通过回调把消息交给宿主业务处理

SDK 的职责是校验参数、隔离会话、转换字段和保护错误边界,不应该生成假 Token 或伪造用户资料。

3. VK 原生透传

如果容器正在真实 VK 页面中运行,而宿主没有实现某项能力,合理的选择可能不是立即失败,而是让 VK 继续处理。

VKWebAppGetUserInfo 就是一个实际案例。某个 Mini App 会在启动时自动调用它。如果 SDK 无条件拦截,而宿主又没有传入用户资料回调,就只能返回 NOT_SUPPORTED。当网页错误地把 { error_code, error_reason } 对象直接渲染到 React 页面时,还会触发 React 运行时错误并导致白屏。

最终策略是:

宿主提供 onUserInfoRequest
  → SDK 注册 Handler
  → 使用宿主的真实用户服务

宿主未提供 onUserInfoRequest
  → SDK 不注册 Handler
  → 请求继续交给 VK 容器

这是“能力探测”比“接口永远存在”更重要的一个例子。

四、当前阶段实现了哪些接口?

目前共拆分为 9 个业务模块、12 个 VK 事件:

模块VK 事件关键返回值
FlashVKWebAppFlashGetInfo{ is_available, level }
FlashVKWebAppFlashSetLevel{ result: true }
HapticVKWebAppTapticImpactOccurred{ result: true }
HapticVKWebAppTapticNotificationOccurred{ result: true }
HapticVKWebAppTapticSelectionChanged{ result: true }
PermissionVKWebAppGetGrantedPermissions{ permissions: string[] }
ViewVKWebAppSetViewSettings{ result: true }
AudioVKWebAppAudioPause{ result: true }
Client MessageVKWebAppSendToClient{ result: true }
SwipeVKWebAppSetSwipeSettings{ result: true }
AuthVKWebAppGetAuthToken{ access_token, scope }
UserVKWebAppGetUserInfoVK UserInfo 字段

这里有一个很重要的认识:返回 { result: true } 只能表示本次原生操作成功执行,不能代替肉眼和真机验证。例如系统栏可能并不由当前页面拥有,即使设置调用成功,界面上也未必能看到颜色变化;闪光灯则必须确认物理灯是否真正亮起。

五、如何扩展一个 VK 接口?

我们把每一个接口实现为独立 Handler。下面是经过简化的结构:

interface FlashSetLevelParams {
  level: number;
}

interface OperationResult {
  result: boolean;
}

class FlashHandler implements BridgeHandler {
  constructor(
    readonly context: BridgeContext,
    private readonly flash: FlashCapability
  ) {}

  @VkEvent('VKWebAppFlashSetLevel')
  async setLevel(params: FlashSetLevelParams): Promise<OperationResult> {
    assertObject(params);
    assertNumber(params.level, 'level');

    if (params.level < 0 || params.level > 1) {
      throw new BridgeError('INVALID_ARGUMENT', 'level must be between 0 and 1');
    }

    await this.flash.setLevel(params.level);
    return { result: true };
  }

  dispose(): void {
    this.flash.release();
  }
}

这个结构刻意把协议和设备操作分开:

  • Handler 负责 VK 参数、字段和错误语义;
  • Capability 负责 HarmonyOS API、权限和资源释放;
  • Runtime 负责页面代次、会话状态和回包;
  • Registry 负责接口注册与支持列表。

这样做的好处是,后续兼容其他小程序平台时,可以复用同一个 FlashCapability,只需新增另一套协议 Handler。

六、闪光灯接口为什么比想象中复杂?

1. 查询状态

VK 期望 VKWebAppFlashGetInfo 返回:

{
  "is_available": true,
  "level": 0
}

其中 level 是数字,不是字符串。当前适配把设备状态归一化为:

  • 0:关闭;
  • 1:打开。

2. 设置状态

网页仍然只需要:

await bridge.send('VKWebAppFlashSetLevel', { level: 1 });

但原生侧不能在收到请求后立刻返回成功,而应该等待以下流程结束:

校验 level
  → 检查设备是否支持手电筒
  → 展示用途说明
  → 请求系统 CAMERA 权限
  → 用户允许后调用原生手电筒 API
  → 操作完成后回包

第一次访问相机时,用户可能先看到应用自定义的用途说明,再看到 HarmonyOS 系统权限框。只有系统真正授予权限并且设备操作成功,网页才能收到成功结果。

HarmonyOS 官方文档同样要求在使用相机能力前处理权限,并提供了手电筒支持检测及模式设置接口。实现时还要关注相机会话与手电筒之间的资源占用关系。

3. 生命周期

如果网页在等待权限期间刷新,旧请求不应该向新页面回包。因此异步请求开始时需要保存页面代次:

const documentId = context.getDocumentId();
const result = await capability.execute();

if (context.getDocumentId() !== documentId) {
  return; // 页面已经变化,丢弃旧结果
}

await context.send(buildSuccessEvent(result));

这类生命周期问题在 Demo 中不明显,但在真正的 SDK 中非常关键。

七、宿主能力应该通过回调注入

Token 和用户资料不属于 SDK 自己。比较安全的接入方式,是让宿主显式提供回调:

MiniAppContainer({
  app: {
    appId: 'host-business-id',
    vendor: 'vk',
    url: 'https://vk.example/app/YOUR_APP_ID'
  },

  onAuthTokenRequest: async (request) => {
    // 调用宿主已有的鉴权服务。
    return authService.getToken(request.appId, request.scope);
  },

  onUserInfoRequest: async (request) => {
    // 调用宿主已有的用户服务。
    return userService.getUser(request.userId, request.userIds);
  },

  onClientMessage: (fragment) => {
    businessRouter.handle(fragment);
  }
});

这样设计有几个安全收益:

  • SDK 不保存应用密钥;
  • SDK 不生成测试 Token 冒充真实数据;
  • 后端异常不会原样泄漏给网页;
  • 不同容器会话拥有各自的回调和状态;
  • 宿主可以独立替换鉴权或用户服务。

还要特别区分两个容易混淆的 ID:宿主内部业务标识和 VK 协议里的数值 app_id 并不是同一个字段,不能强行比较或互相替换。

八、Bridge 安全不能只靠 TypeScript 类型

网页发来的数据属于运行时输入。即使 TypeScript 接口声明了 level: number,恶意或错误页面仍然可以传入字符串、null、小数或超出范围的值。

因此每个 Handler 都需要显式校验:

assertObject(params);
assertNumber(params.level, 'level');
assertRange(params.level, 0, 1);

此外,还需要处理以下边界:

来源校验

消息必须来自目标 iframe,并且 origin、iframe 地址与 VK 启动参数相互匹配。不能因为事件名看起来正确,就接收任意网页发来的消息。

请求关联

每个请求都需要唯一 replyId,回包时发送给最初的 event.sourceevent.origin,而不是广播给整个页面。

错误清洗

宿主回调可能抛出包含接口地址、数据库信息或内部堆栈的异常。SDK 应将它转成稳定的公开错误,例如 SYSTEM_ERROR,不要把原始错误详情交给网页。

Token 日志

结构化日志中不记录 Token。即便调试开关能够输出完整 Bridge 消息,也应只在受控测试环境短时间开启。

九、我们踩过的几个典型问题

1. 返回成功,但硬件没有动作

早期实现可能只完成了协议回包,没有等待真正的手电筒调用。解决方法是让 Promise 覆盖完整权限及硬件操作,而不是“收到请求就成功”。

2. 参数看起来更新了,页面效果却没有变化

VKWebAppSetViewSettings 返回成功,不代表当前 WebView 一定拥有最外层系统栏。调试这类接口必须先确认 UI 所有权,再检查参数和返回值。

3. 本地页面有新按钮,手机上却还是旧版本

WebView 会缓存相同 URL 的资源。给 URL 增加查询参数,例如 ?v=view-settings-test,能够改变资源地址并绕过旧缓存,但它不是永久的缓存治理方案。生产环境应使用带内容哈希的静态资源和正确缓存策略。

4. Node 版本显示正确,构建工具仍使用旧版本

Windows 下 node --versionnpm.cmd 内部解析到的 Node 可能不是同一个。排查时要同时确认 where.exe node、实际执行文件路径和当前工作目录。

5. 接口失败导致整个 React 页面白屏

Bridge 的失败对象不能直接作为 React 子节点渲染。前端应该显示 error_reasonJSON.stringify(error)。SDK 侧则要避免无能力时无条件截断本来可以由 VK 完成的请求。

十、如何测试这种 SDK?

建议把验证分成四层。

第一层:Handler 单元测试

检查参数校验、字段转换和错误码:

  • 非法 level 是否在调用硬件前被拒绝;
  • 用户资料的驼峰字段是否转换成 VK 下划线字段;
  • 宿主抛出的私有异常是否被清洗。

第二层:代理链路测试

构造完整 Bridge JSON,验证:

  • 已注册方法返回 intercept: true
  • 未注册方法返回 intercept: false
  • 成功事件、失败事件和 request_id 是否正确;
  • 页面切换后旧结果是否被丢弃。

第三层:Web Demo 测试

网页只使用官方形式调用:

const result = await bridge.send('VKWebAppGetUserInfo', {});
output.textContent = JSON.stringify(result, null, 2);

这一层验证 Mini App 是否完全不需要感知 HarmonyOS 适配层。

第四层:真机测试

以下内容无法被单元测试替代:

  • 系统权限弹窗;
  • 闪光灯是否真正点亮;
  • 触感强弱;
  • 状态栏和导航栏效果;
  • 侧滑返回手势;
  • 登录、iframe、刷新和返回后的完整链路。

日志排查也要分层:先看容器是否创建,再看 WebView 是否加载,最后看具体 Bridge 请求。不要看到“白屏”就默认是端口映射或原生崩溃,前端运行时异常同样会造成白屏。

十一、这次实践能学到什么?

1. 协议兼容比平台判断更可持续

上层继续使用既有协议,底层通过适配器提供能力,能显著减少业务改造。未来适配其他小程序生态时,也可以复用 Runtime 和 Capability 层。

2. “支持接口”必须等于“真的有能力处理”

如果支持列表声明了一个接口,后续请求就应该有真实实现。动态注册比统一返回 NOT_SUPPORTED 更符合渐进增强原则。

3. SDK 的难点常常不在 API 调用本身

真正消耗精力的是权限、来源校验、异步回包、页面代次、会话释放、错误清洗和兼容旧链路。

4. 不要伪造业务能力

闪光灯可以由系统控制,但用户 Token 和用户资料必须有可信来源。为了截图临时返回假 Token 可以用于受控调试,却不能留在正式实现中。

5. 返回值和用户体验是两套验证

自动化测试保证协议正确,真机测试保证设备行为正确;两者缺一不可。

十二、后续可以如何演进?

完成第一批接口后,下一阶段可以围绕以下方向推进:

  1. 按“纯协议、宿主回调、系统硬件、复杂 UI”给待适配接口分级;
  2. 建立统一的 Capability 探测机制,而不是为每个接口写特殊判断;
  3. 为扫码、定位、相册等 P0 能力复用权限和生命周期框架;
  4. 增加协议契约测试,自动对照 VK 类型定义;
  5. 建立多厂商 Vendor 层,让同一底座支持更多小程序平台;
  6. 完善缓存、域名、网络和 WebView 渲染异常的诊断能力。

当这套底座逐渐稳定后,新增接口不再是“再写一个临时 JSBridge”,而是沿着固定路径扩展:

确认官方协议
  → 划分能力归属
  → 参数与结果建模
  → 实现 Handler / Capability
  → 注册或按能力动态启用
  → 单元测试、代理测试、网页测试、真机测试

这也是这项工作的最大价值:把一次性的兼容代码,变成可以持续演进的 SDK 工程能力。

参考资料


如果你也在做 Web 小程序与原生容器的融合,建议先不要急着批量实现 API。先把请求识别、动态注册、页面生命周期、统一错误和测试链路搭稳,后面的每一个接口都会容易很多。

Logo

一站式 AI 云服务平台

更多推荐