ArkWeb 真正麻烦的地方,从来不是把一个网页塞进 Web 组件里,而是当页面开始登录、调原生能力、下载文件、回传状态以后,Web 与 ArkTS 之间的边界会迅速变复杂。本文从一个真实混合内容工作台出发,把 JSBridge、Cookie 和文件流这三条最容易互相打架的链路一次讲透。

一、能打开网页,只代表 ArkWeb 项目刚刚开始

很多 ArkWeb Demo 的第一个版本都差不多:

private controller: webview.WebviewController = new webview.WebviewController()

build() {
  Web({
    src: 'https://example.com',
    controller: this.controller
  })
}

页面打开了,H5 能滚动,链接能点,看起来已经完成了七八成。

但一旦需求开始往真实业务靠,问题会迅速冒出来:

  • H5 想调用原生相册、定位、登录态怎么办?
  • 原生更新了用户信息,怎么主动通知 H5?
  • H5 登录以后写进去的 Cookie,下一次页面能不能继续使用?
  • 原生请求和 Web 请求是否看到同一份会话?
  • H5 触发文件下载以后,是让 Web 自己处理,还是交给原生文件系统?
  • 一个 100 MB 文件下载到一半,页面销毁了,任务怎么办?

这些问题表面上分属通信、网络和文件三个方向,工程里却经常同时出现。

我后面做 ArkWeb 项目时,会先把它拆成三个基础通道:

  1. 消息通道:Web 与 ArkTS 双向通信;
  2. 会话通道:Cookie 与登录态保持一致;
  3. 文件通道:下载、选择、保存和结果回传。

只有这三条链各自独立、最后又能协同,混合应用才真正有工程上的稳定性。

二、JSBridge 不要写成“一堆暴露给 H5 的函数”

JSBridge 最容易写坏的方式,是直接在一个对象里不断加方法:

chooseImage()
getUserInfo()
openPage()
downloadFile()
share()
scan()
...

开始只有三四个接口时没什么问题,半年以后 H5 和原生两边都不敢动。

原因很简单:方法名只是接口表面,真正缺失的是消息协议。

我更推荐把 JSBridge 看成一个轻量 RPC 通道,先统一消息结构,再决定具体业务怎么分发。

interface BridgeMessage {
  id: string
  type: string
  payload?: Record<string, Object>
}

interface BridgeResult {
  id: string
  success: boolean
  data?: Object
  message?: string
}

H5 不再关心“原生究竟有多少方法”,而是发送:

{
  "id": "req_1001",
  "type": "getUserInfo",
  "payload": {}
}

原生统一返回:

{
  "id": "req_1001",
  "success": true,
  "data": {
    "nickname": "Leo"
  }
}

这样做最明显的好处,是后续可以统一补上:

  • 超时控制;
  • 参数校验;
  • 日志;
  • 权限检查;
  • 错误码;
  • 异步回调。

这才是一个能长期维护的 Bridge。

三、注册时机比注册代码本身更重要

ArkWeb 提供 registerJavaScriptProxy(),可以把原生对象注入到 Web 页面,让 H5 调用 ArkTS 方法。

真正容易踩坑的不是“不会写”,而是注册太晚。

如果 H5 在页面初始化脚本里马上调用 Bridge,而原生还没完成代理对象注入,就会出现一种很烦的故障:

  • 大多数时候正常;
  • 冷启动偶发失败;
  • 快速刷新更容易复现;
  • 日志里还看不出明显异常。

我通常会在控制器已经挂接,但页面业务脚本还没大量执行时完成注册。

import { webview } from '@kit.ArkWeb'

class WebBridge {
  postMessage(message: string): string {
    console.info(`[JS -> ArkTS] ${message}`)
    return JSON.stringify({ success: true })
  }

  getAppInfo(): string {
    return JSON.stringify({
      platform: 'HarmonyOS',
      version: '7'
    })
  }
}

@Component
struct HybridPage {
  private controller: webview.WebviewController = new webview.WebviewController()
  private bridge: WebBridge = new WebBridge()

  build() {
    Web({ src: 'https://example.com', controller: this.controller })
      .javaScriptAccess(true)
      .onControllerAttached(() => {
        this.controller.registerJavaScriptProxy(
          this.bridge,
          'nativeBridge',
          ['postMessage', 'getAppInfo'],
          []
        )
      })
  }
}

工程里我还会再加一层 bridgeReady 状态,H5 只有收到 ready 信号以后才开始调用原生能力。

不要让“页面加载完成”替代“Bridge 准备完成”,这是两个不同状态。

四、ArkTS 主动通知 H5,最好统一走事件

H5 调原生只是半条链。

另一半是原生状态变化以后,怎么通知 Web。

比如:

  • 原生登录完成;
  • 文件下载完成;
  • App 从后台回到前台;
  • 系统主题变化;
  • 用户在原生设置页修改了配置。

这类通知不适合给每一种状态都定义一个单独 JS 函数。

我更喜欢让 H5 统一监听一个 CustomEvent:

async notifyWeb(type: string, data: Object): Promise<void> {
  const detail = JSON.stringify({ type, data })
  await this.controller.runJavaScript(`
    window.dispatchEvent(
      new CustomEvent('nativeMessage', { detail: ${detail} })
    )
  `)
}

H5 侧只保留一个入口:

window.addEventListener('nativeMessage', (event) => {
  const message = event.detail

  switch (message.type) {
    case 'loginChanged':
      refreshUser(message.data)
      break
    case 'downloadFinished':
      refreshFiles(message.data)
      break
  }
})

这样以后再扩展原生事件,不会把 Web 页面的全局方法空间越堆越乱。

五、Cookie 最大的问题不是“能不能取”,而是“谁是真正的来源”

混合应用里出现登录态问题,第一反应经常是 Cookie 丢了。

但实际排查下来,很多时候不是 Cookie API 不工作,而是项目里同时存在多套会话来源:

  • Web 登录写一份;
  • ArkTS 网络请求自己维护一份 Token;
  • Native 又缓存一份用户信息;
  • 刷新登录态时只更新了其中一个地方。

最后就会出现:

H5 里明明是登录状态,原生接口却 401。

或者反过来:

原生已经登录,Web 打开以后又要求重新登录。

所以 Cookie 同步前,先确定谁是会话事实来源。

我自己的项目里通常会做两层:

1. Web Cookie 由 ArkWeb Cookie 管理能力维护

Web 自己的请求继续遵循 Web 会话规则,不去手工拦截所有请求。

2. 原生登录信息由 SessionService 统一管理

如果业务确实需要 Web 与原生共享某些认证信息,则由 SessionService 明确执行同步,而不是每个页面自己读 Cookie。

这个边界一旦明确,很多奇怪问题都会少很多。

六、同步 Cookie 之前,先把“同步方向”说清楚

实际项目里经常听到一句话:

把 Cookie 同步一下。

但同步至少分三种:

  1. Web → 原生:H5 登录以后,原生需要获取认证信息;
  2. 原生 → Web:原生登录后,Web 打开就应该是已登录;
  3. Web → Web:多个 ArkWeb 页面之间保持同一会话。

这三种情况实现策略并不完全一样。

我会把同步动作收口成一个 CookieService,而不是散在页面生命周期中:

class CookieService {
  async syncAfterLogin(url: string): Promise<void> {
    // 1. 获取当前 Web 会话
    // 2. 提取业务允许同步的字段
    // 3. 更新原生 Session
    // 4. 记录同步时间和来源
  }

  async prepareBeforeLoad(url: string): Promise<void> {
    // 1. 检查原生登录态
    // 2. 必要时写入 Web Cookie
    // 3. 完成后再加载需要登录的页面
  }
}

这里有个经验很实用:不要把全部 Cookie 当成业务 Token。

Cookie 里可能还有灰度、偏好、统计、会话辅助字段。真正需要跨端同步的字段越少越好。

七、文件下载不要让 Web 和原生各做一半

混合应用另一个高频坑,是文件下载。

H5 页面里一个 <a download> 或接口返回文件,浏览器语义下看起来很自然。但到了 App 里,用户真正关心的是:

  • 文件下载到哪里;
  • 有没有进度;
  • 下载完能不能打开;
  • 应用退出以后还在不在;
  • 下载失败是否能继续。

这些事情更适合原生文件系统和任务体系处理。

所以我的做法是:H5 只描述下载意图,真正的下载任务交给 ArkTS。

Bridge 消息可以长这样:

{
  "id": "download_1001",
  "type": "downloadFile",
  "payload": {
    "url": "https://example.com/files/guide.pdf",
    "fileName": "guide.pdf"
  }
}

原生接到以后创建任务,并持续把进度发回 Web:

await this.bridge.notifyWeb('downloadProgress', {
  taskId: task.id,
  progress: 68
})

下载完成后再返回实际文件位置,而不是让 H5 猜路径。

八、文件选择与文件下载,权限方向正好相反

下载是“网络数据进入应用”。

文件选择则是“用户数据进入 Web”。

这两个流程不要共用一套逻辑。

文件选择我会坚持三个原则:

  • 必须由用户动作触发;
  • 只返回业务真正需要的数据;
  • 不把真实本地路径直接暴露给 H5。

如果 H5 只是要上传头像,Bridge 最好返回经过业务处理后的 URI 或临时句柄,而不是把应用沙箱结构完整暴露出去。

安全问题在 Demo 阶段看不出来,等页面来源变多、域名变多以后,就会变成真实风险。

九、JSBridge 最少要做一层来源限制

只要网页能执行 JS,就意味着注入进去的原生对象有可能被调用。

所以真正上线的 Bridge 至少要考虑:

  • URL 白名单;
  • 接口白名单;
  • 参数类型校验;
  • 高风险能力二次确认;
  • 日志中避免输出敏感数据。

尤其是:

  • 文件;
  • 相册;
  • 用户信息;
  • 登录 Token;
  • 外部跳转。

不要为了“调用方便”全部暴露给 Web。

工程上一个简单的判断是:

如果这个方法被任意网页调用,会不会出问题?

如果答案是“会”,那它就不该是一个无条件 JSBridge 方法。

十、我会把这三条链做成独立 Service

做到这里,如果所有逻辑还写在页面组件里,代码已经会非常难看。

所以我最终通常拆成:

pages/
  WebWorkbenchPage.ets
bridge/
  JsBridge.ets
services/
  CookieService.ets
  FileTransferService.ets
model/
  WebMessage.ets
utils/
  Logger.ets

页面只做四件事:

  • 显示 Web;
  • 展示状态;
  • 绑定生命周期;
  • 把事件转交给 Service。

JSBridge 只处理通信协议。

CookieService 只处理会话。

FileTransferService 只处理文件。

这种拆法最大的价值不是“目录漂亮”,而是问题定位会变得很直接。

十一、DevEco 联调时,我只看四类日志

混合应用最怕日志太多,最后什么也看不出来。

我会把日志统一成四类前缀:

[WEB] 页面加载
[BRIDGE] 双向消息
[COOKIE] 会话同步
[FILE] 文件任务

比如:

[WEB] onPageBegin https://example.com
[BRIDGE] register success
[COOKIE] synced 12 records
[FILE] guide.pdf progress=68
[FILE] guide.pdf completed

开发时,DevEco Studio 大概就是这种状态:

如果用户说“页面已经登录,但下载按钮没反应”,我会按顺序看:

  1. Web 有没有真正发消息;
  2. Bridge 有没有收到;
  3. FileTransferService 有没有创建任务;
  4. 进度有没有回传。

链路日志一旦有了,混合应用就不再是“黑盒网页”。

十二、首页最好直接暴露关键链路状态

我这次做测试页,没有把 JSBridge、Cookie、文件流全部藏在后台,而是在首页直接展示:

  • JSBridge 已连接;
  • Cookie 已同步多少条;
  • 文件能力当前是否可用;
  • 最近下载任务。

这么做很适合开发阶段。

因为只看 Web 页面本身,你很难判断原生能力是否真的准备完成。

而这几个状态放出来以后,一眼就能区分:

  • Web 页面问题;
  • Bridge 问题;
  • Session 问题;
  • 文件问题。

正式产品可以隐藏,调试版本建议保留。

十三、再做一个详情页,把“偶发问题”变成可验证问题

混合应用特别多偶发问题。

例如:

为什么刚才 JS 能调,现在调不到?

为什么 Cookie 突然少了一条?

文件为什么卡在 68%?

如果没有上下文,只能靠猜。

所以我后面又补了一个“桥接与文件流详情”页:

里面保留:

  • 最近一次 JS → ArkTS 消息;
  • 最近一次 ArkTS → JS 消息;
  • Cookie 同步结果;
  • 下载列表与进度;
  • 文件保存位置。

这类页面不是为了给普通用户看,而是给开发、测试和问题复盘留证据。

十四、这套结构最重要的是“边界清楚”

把整个项目重新整理以后,我最后保留下来的不是某个 API,而是四条工程规则。

JSBridge 是协议,不是函数集合

消息 ID、类型、参数、返回值、错误,都应该有统一约束。

Cookie 是会话问题,不是字符串问题

先明确谁维护登录事实,再谈同步。

文件是任务,不是 URL

从下载开始到最终保存,中间需要状态、进度、失败和生命周期。

页面只是装配层

不要把 Bridge、Cookie、文件系统全部写进一个 ArkUI 页面。

十五、本文小记

ArkWeb 做到后面,你会发现它其实不是“Web 页面容器”,而是一层跨运行时边界。

一边是 H5 的生命周期、网络和 JavaScript;另一边是 HarmonyOS 的 ArkTS、文件系统、系统能力与应用状态。

JSBridge、Cookie 和文件流,刚好是这条边界上最容易出问题的三块。

如果只把它们分别调通,Demo 能跑;如果把三条链的职责、状态和日志都收口,项目才真正能维护。

我自己现在再做 ArkWeb 项目,会先画三条链,再开始写页面。因为一旦通信、会话、文件三层想清楚,后面的代码反而没有那么难。

Logo

一站式 AI 云服务平台

更多推荐