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

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

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

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

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

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

javascript 复制代码
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. 在页面刷新、跳转或容器关闭后,避免把旧结果发给新页面。

整体调用链可以简化为:

text 复制代码
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 状态栏、导航栏等窗口设置
VKWebAppAudioPause WebView 媒体暂停能力
三个 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 运行时错误并导致白屏。

最终策略是:

text 复制代码
宿主提供 onUserInfoRequest
  → SDK 注册 Handler
  → 使用宿主的真实用户服务

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

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

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

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

模块 VK 事件 关键返回值
Flash VKWebAppFlashGetInfo { is_available, level }
Flash VKWebAppFlashSetLevel { result: true }
Haptic VKWebAppTapticImpactOccurred { result: true }
Haptic VKWebAppTapticNotificationOccurred { result: true }
Haptic VKWebAppTapticSelectionChanged { result: true }
Permission VKWebAppGetGrantedPermissions { permissions: string[] }
View VKWebAppSetViewSettings { result: true }
Audio VKWebAppAudioPause { result: true }
Client Message VKWebAppSendToClient { result: true }
Swipe VKWebAppSetSwipeSettings { result: true }
Auth VKWebAppGetAuthToken { access_token, scope }
User VKWebAppGetUserInfo VK UserInfo 字段

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

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

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

typescript 复制代码
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 返回:

json 复制代码
{
  "is_available": true,
  "level": 0
}

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

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

2. 设置状态

网页仍然只需要:

javascript 复制代码
await bridge.send('VKWebAppFlashSetLevel', { level: 1 });

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

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

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

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

3. 生命周期

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

typescript 复制代码
const documentId = context.getDocumentId();
const result = await capability.execute();

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

await context.send(buildSuccessEvent(result));

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

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

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

typescript 复制代码
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 都需要显式校验:

typescript 复制代码
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 测试

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

javascript 复制代码
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",而是沿着固定路径扩展:

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

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

参考资料


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

相关推荐
阳光宅男@李光熠1 小时前
【电子通识】一起学习TDK的EMC基础——电池兼容设计方法概述
java·前端·数据库
南雨北斗2 小时前
vue3项目中的env.d.ts文件
前端
烈风逍遥2 小时前
第五篇:通用 LLM 流式对话:前后端联接的完整实现
前端·后端·架构
aixingpan2 小时前
aixingpan.cn API开发文档:api_docs_trichart_natal_progression_sec_transit2接口指南
前端·php
江华森2 小时前
Web 安全实战:验证机制、会话管理、SQL 注入、XSS 与 CSRF
前端
江华森2 小时前
HTTPS 实战:TLS 握手成本、自制 CA 与证书、三大常见故障
前端
江华森2 小时前
HTTP 再邂逅:报文结构、请求方法、状态码与 Cookie/Session
前端
szephyr2 小时前
前端错误监控实战:用 Sentry 把线上报错从“用户反馈“变成“主动发现“
前端·sentry·稳定性·前端监控·错误监控