HarmonyOS 7 + ArkWeb onControllerAttached:折叠布局重建中的控制器租约与迟到脚本拒绝【鸿蒙心迹】
折叠屏适配常被理解为“窗口变宽后把一列改成两列”。当页面里嵌入 ArkWeb,这个判断还少了一层:布局分支变化可能让 Web 组件重新创建,旧 WebviewController 已经离开组件,先前发出的 runJavaScript 却仍可能在稍后返回。此时真正需要保持的不是一个控制器对象,而是命令属于哪个控制器租约、哪个页面加载代次。
本文使用演示工程 FoldWebLease,页面为 ArticleWorkspacePage,任务 ID 为 WEB-FOLD-0211。窗口从 392 vp 展开到 840 vp,页面由单栏阅读切换为左侧目录、右侧网页的工作区。控制器代次从 72 变为 73;脚本命令共 5 条,提交 3 条,拒绝旧结果 2 条;阅读模式恢复进度为 68%。这些是确定性的演示样本,用于说明状态机和配图字段,不冒充真机性能结论。

一、控制器存在,不代表已经可以控制网页
ArkWeb 的 WebviewController 可以在组件外持有,但与 Web 组件建立关联有明确时机。官方资料把 onControllerAttached 定义为控制器成功绑定到 Web 组件的阶段,并指出在它之前调用依赖网页的接口可能抛出初始化错误。onPageEnd 又是另一层事实:主 frame 页面加载完成,但收到回调不能保证下一帧已经反映最终 DOM 状态。
因此至少有三种“就绪”不能混为一谈:控制器对象已经构造;控制器已经绑定;主页面已经加载。若工具栏的“阅读模式”“字号 115%”“高亮目录”三个命令只检查对象非空,折叠切换时就可能撞上未绑定控制器。若只等待 onControllerAttached,脚本又可能在目标文档尚未加载时执行到空 DOM。
演示页把这三层拆成 CREATED、ATTACHED、PAGE_READY。只有最后一层可以发送需要访问 DOM 的脚本。布局由紧凑态切到展开态时,旧租约先进入 REVOKED,新控制器生成后重新经历三层状态。命令不随对象地址流转,而是携带 webEpoch 与 commandId 进入队列。
二、折叠切换为什么会放大迟到结果
单栏页面中,网页占据主视图;展开后,工程选择条件分支重建为目录和网页双栏。这个重建策略不是系统强制要求,而是 Demo 的架构选择。好处是两种形态的组件树清晰,代价是控制器绑定和页面加载会重新发生。
故障时间线可以这样推演:21:24:08.120,epoch 72 的控制器执行“读取当前阅读模式”;21:24:08.146,窗口宽度跨过断点,组件树开始重建;21:24:08.171,旧组件消失;21:24:08.238,epoch 73 的新控制器完成绑定;21:24:08.286,新页面 onPageEnd;21:24:08.301,epoch 72 的 Promise 才返回 fontScale=1.00。如果页面直接写入这个返回值,新工作区会把已经恢复为 1.15 的设置倒退成 1.00。
这不是“Promise 太慢”的问题,而是提交权没有随生命周期转移。即使网络和脚本都很快,只要控制器重建与异步返回发生竞态,就需要代次判断。延长防抖时间只能降低发生概率,不能证明结果属于当前页面。
第一段代码解决控制器租约。webEpoch 每次重建增加,绑定与页面就绪分别记录。revoke() 会清空当前队列,但已经发出的 Promise 仍要依靠 epoch 在回调处拒绝。
import { webview } from '@kit.ArkWeb';
type LeaseState = 'CREATED' | 'ATTACHED' | 'PAGE_READY' | 'REVOKED';
interface ScriptCommand {
commandId: string;
epoch: number;
script: string;
needsDom: boolean;
}
class WebControllerLease {
readonly controller: webview.WebviewController =
new webview.WebviewController();
private state: LeaseState = 'CREATED';
private queue: ScriptCommand[] = [];
constructor(readonly epoch: number) {}
markAttached(): void {
if (this.state !== 'REVOKED') this.state = 'ATTACHED';
}
markPageReady(): void {
if (this.state !== 'REVOKED') this.state = 'PAGE_READY';
}
enqueue(command: ScriptCommand): void {
if (command.epoch === this.epoch && this.state !== 'REVOKED') {
this.queue.push(command);
}
}
takeRunnable(): ScriptCommand[] {
const ready = this.state === 'PAGE_READY';
const selected = this.queue.filter((item) => !item.needsDom || ready);
this.queue = this.queue.filter((item) => !selected.includes(item));
return selected;
}
revoke(): void {
this.state = 'REVOKED';
this.queue = [];
}
}
这里没有用一个布尔值 isReady,因为绑定完成和 DOM 可用不是同一事件。takeRunnable() 也不承诺命令一定成功,只说明命令在当前租约下具备执行前提。真正提交结果时还要再检查一次 epoch,形成“执行前”和“返回后”两道门。
三、页面事件负责推进状态,不负责猜状态
第二段代码把 onControllerAttached、onPageEnd 和组件消失事件接入租约。页面使用 layoutEpoch=73 构造新租约,加载固定文档 docs_arkweb_42。脚本访问 DOM,因此只在 PAGE_READY 后派发。
@Entry
@Component
struct ArticleWorkspacePage {
@State private layoutEpoch: number = 73;
@State private phase: string = 'REBINDING';
private lease: WebControllerLease = new WebControllerLease(73);
private readonly articleUrl: string =
'https://docs.example.com/articles/docs_arkweb_42';
private flushCommands(): void {
this.lease.takeRunnable().forEach((command: ScriptCommand) => {
const issuedEpoch = command.epoch;
this.lease.controller.runJavaScript(command.script)
.then((result: string) => {
if (issuedEpoch !== this.layoutEpoch) {
console.info(`STALE_RESULT ${command.commandId}`);
return;
}
console.info(`SCRIPT_COMMITTED ${command.commandId} ${result}`);
})
.catch((error: Error) => {
console.error(`SCRIPT_FAILED ${command.commandId} ${error.message}`);
});
});
}
build() {
Web({ src: this.articleUrl, controller: this.lease.controller })
.javaScriptAccess(true)
.fileAccess(false)
.onControllerAttached(() => {
this.lease.markAttached();
this.phase = 'CONTROLLER_ATTACHED';
this.flushCommands();
})
.onPageEnd(() => {
this.lease.markPageReady();
this.phase = 'PAGE_READY';
this.flushCommands();
})
.onDisAppear(() => {
this.lease.revoke();
});
}
}
onPageEnd 只在主 frame 触发,不代表所有子资源或下一帧 DOM 都已完成。若脚本只读取稳定文档属性,可以在回调后执行;若要测量布局,脚本侧可通过两次 requestAnimationFrame 等待浏览器完成后续绘制,再返回 JSON。等待发生在 H5 上下文,结果提交仍要比较 epoch。
onDisAppear 的职责是撤销当前租约。不要在这里把全局页面状态直接清空,因为折叠重建时新租约可能已经开始创建。旧组件只能宣告“我不再拥有提交权”,不能替新组件决定最终状态。

上图是开发演示图,不是真实 IDE 截图。左侧目录列出 ArticleWorkspacePage.ets、WebControllerLease.ets 和 ScriptQueue.ets;中间代码停在 issuedEpoch !== layoutEpoch;右侧模拟器显示 WEB-FOLD-0211、epoch 73、恢复进度 68%;底部 HiLog 依次出现 ATTACHED epoch=73、PAGE_READY、STALE_RESULT JS-0211-02 和 COMMITTED 3/5。
四、脚本要返回结构化证据,而不是一句 OK
“设置阅读模式”通常包含多个动作:切换根节点 class、设置字号、恢复目录高亮、读取实际计算样式。脚本若只返回 true,ArkTS 侧无法判断结果来自哪个文档、采用了什么参数,也无法在诊断页解释旧结果为何被拒绝。
第三段代码构造命令 JS-0211-05。脚本返回文档 ID、字号比例、模式和实际路径;两次 requestAnimationFrame 用于把测量推迟到后续绘制。字符串中的固定数据与演示页面完全一致。
function buildReadingModeCommand(epoch: number): ScriptCommand {
const script = `(() => new Promise((resolve) => {
document.documentElement.dataset.readingMode = 'focus';
document.documentElement.style.fontSize = '115%';
requestAnimationFrame(() => requestAnimationFrame(() => {
resolve(JSON.stringify({
documentId: 'docs_arkweb_42',
mode: document.documentElement.dataset.readingMode,
fontScale: 1.15,
path: location.pathname
}));
}));
}))()`;
return {
commandId: 'JS-0211-05',
epoch,
script,
needsDom: true
};
}
应用接收结果后还要做结构校验。documentId 必须等于当前文档,fontScale 必须是有限数值并处于产品允许范围,mode 必须属于枚举。脚本输出来自网页上下文,不能因为是自家页面就跳过边界检查。
命令队列也要定义覆盖策略。连续拖动窗口时,“设置字号 110%”后面紧跟“设置字号 115%”,两条命令没有必要都执行;可以按 commandType 保留最后一条。导航命令则不能随意覆盖。演示中的 5 条命令有 3 条在 epoch 73 提交,2 条来自 epoch 72 的旧结果被拒绝,不把拒绝误算成执行失败。
五、运行页只展示当前租约的事实
主运行页在 21:24 显示窗口宽度从 392 vp 变为 840 vp,布局为 WIDE_WORKSPACE。顶部状态栏包含时间、Wi-Fi、5G、信号和 66% 电量;页面卡片显示 webEpoch 73、控制器已绑定、页面已就绪、恢复进度 68%,以及“脚本 3 / 5 已提交”。

这张图故意不显示旧控制器的详细对象信息,因为对象地址对用户和测试都没有稳定意义。真正可比较的是租约代次、命令 ID 和阶段。按钮“重新应用阅读模式”只会产生 epoch 73 的新命令,不会复用 epoch 72 队列。
状态转换写成:
COMPACT_ACTIVE → CAPTURING → REBINDING → CONTROLLER_ATTACHED → PAGE_READY → WIDE_ACTIVE
其中 CAPTURING 指捕获应用自己的网页视图设置,不涉及读取账号凭证或敏感网页内容。演示只保存文档 ID、阅读模式、字号和目录选择。Cookie、表单输入和网页历史应遵守各自的数据与隐私边界,不能顺手塞进布局快照。
六、诊断页要把“执行”和“提交”分开
异步脚本可能已经在旧网页中执行完,但它的返回值没有资格写入新页面。诊断页因此分两列:执行结果与提交结果。JS-0211-01、03、05 显示 COMMITTED;JS-0211-02、04 显示 STALE_RESULT,原因都是 epoch 72 != 73。

页面底部保留事件时间线:21:24:08.238 控制器绑定,21:24:08.286 页面完成,21:24:08.301 旧结果到达,21:24:08.318 当前结果提交。红色细圈只强调 72→73、拒绝 2 条和最终 WIDE_ACTIVE,让图承担解释竞态的作用。
HiLog 建议至少包含 commandId、issuedEpoch、currentEpoch、leaseState、documentId、durationMs 和 decision。若日志只有脚本文本,会泄露实现细节;若只有“success”,又失去排查价值。命令可以记录类型和摘要,不必记录完整脚本。
七、异常恢复不等于无限重放
若 runJavaScript 因控制器未初始化失败,不应原样无限重试。先判断当前租约是否仍有效;只有当前 epoch 的命令才可回到队列。若 onRenderExited 表示渲染进程异常退出,应用应保存必要的非敏感设置,并按官方建议重新加载页面,再经历新的页面就绪阶段。
网络错误也要与控制器错误分开。onErrorReceive 可以识别资源加载失败,主 frame 错误应让状态进入 PAGE_ERROR;此时 DOM 命令保持冻结,而不是因为控制器仍处于 ATTACHED 就继续执行。恢复加载成功后,新的 onPageEnd 才重新开放命令。
组件释放必须成对处理:布局重建前撤销旧租约;页面消失后不再提交旧 Promise;新控制器绑定后再调用依赖 Web 的接口;需要访问 DOM 的命令等待 onPageEnd;页面真正不再使用时清空命令与快照。控制器没有通用的“取消 Promise”按钮,epoch 就是应用侧最便宜的提交令牌。
八、这套设计的边界
如果布局可以只改变外层排列而不重建 Web 组件,优先保留同一个 Web 实例,问题会简单许多。本文讨论的是工程已经采用条件分支重建,或因页面类型切换必须更换 Web 容器的情况。租约机制不是鼓励频繁重建,而是在重建不可避免时收紧提交权。
onControllerAttached 也不是 DOM ready 事件,onPageEnd 不是所有资源完成和下一帧已绘制的保证。两者各自只解决一层就绪事实。把它们合成一个 isReady=true,就是下一次竞态的起点。
官方多设备适配指南强调应用应随窗口空间动态调整布局,并通过模拟器和远程真机验证形态变化。Demo 中 392 vp、840 vp、68% 进度和 3/5 命令是文图一致的演示值,真实项目应按目标设备、页面资源与交互协议重新测量,不能把这些数值当作平台阈值。
最终可执行的判断是:控制器对象只是能力入口,租约才是提交边界。每条脚本携带命令 ID 和 epoch,在绑定后执行、在页面就绪后访问 DOM、在返回后再次校验 epoch,折叠布局重建就不会把旧网页的答案写进新页面。
参考资料:
更多推荐




所有评论(0)