import { BuilderNode, FrameNode, NodeController } from '@ohos.arkui.node';
import { UIContext } from '@ohos.arkui.UIContext';
import { WebHostModel } from './WebHostModel';
import { RenderRecovery } from '../RenderRecovery';
/** 渲染进程连续崩溃恢复次数上限,超过则判定不可恢复并关闭容器(对齐 Android)。 */
const MAX_RENDER_RECOVERY: number = 3;
/**
* 崩溃恢复计数的冷却窗口(毫秒)。距上次崩溃超过此窗口视为页面已稳定运行过,
* 本次崩溃按全新问题处理并重置连续计数,避免长时间运行中的偶发(非连续)崩溃
* 累计触顶被误判为"不可恢复"。对齐 iOS renderRecoveryResetWindow。
*/
const RENDER_RECOVERY_RESET_WINDOW: number = 30000;
/**
* 承载 SDK Web 组件的**统一命令式 Builder + NodeController**(离线 Web 组件)。
*
* Page(open 模式)、Component(嵌入模式)、以及预热池都用这同一套 `@Builder` 构建,
* 从而保证「池预热态」的组件能被真实页 `NodeContainer` 绑定复用(事件绑定同构)。
*
* 所有 Web 事件回调转发到 `model.handlers`,联动状态写回 `model`(`@Observed`,容器响应式读取)。
*/
/** 统一 Web 组件 Builder:所有链式属性/事件与原 Page/Component 声明等价。 */
@Builder
function tencentQianWebBuilder(model: WebHostModel): void {
Web({ src: model.url, controller: model.controller })
.javaScriptAccess(model.handlers!.webSettings.javaScriptAccess)
.domStorageAccess(model.handlers!.webSettings.domStorageAccess)
.mediaPlayGestureAccess(model.handlers!.webSettings.mediaPlaybackRequiresUserGesture)
.fileAccess(model.handlers!.webSettings.fileAccess)
.databaseAccess(model.handlers!.webSettings.databaseAccess)
.textZoomRatio(model.handlers!.webSettings.textZoomAtio)
.zoomAccess(model.handlers!.webSettings.zoomAccess)
.multiWindowAccess(model.handlers!.webSettings.multiWindowAccess)
.wideViewModeAccess(model.handlers!.webSettings.wideViewModeAccess)
.overviewModeAccess(model.handlers!.webSettings.overviewModeAccess)
.mixedMode(model.handlers!.mixedMode === 'All' ? MixedMode.All : MixedMode.None)
.javaScriptOnDocumentStart(model.handlers!.documentStartScripts as Array<ScriptItem>)
.onControllerAttached(() => {
model.handlers!.onControllerAttached();
})
.onLoadIntercept((event: ESObject) => model.handlers!.onLoadIntercept(event))
.onPageBegin((event: ESObject) => {
model.isPageLoading = true;
model.progressVisible = true;
model.progress = 0;
if (model.onProgressChanged !== null) {
model.onProgressChanged(0, true);
}
model.lastVisitedUrl = RenderRecovery.captureLastUrl(event, model.lastVisitedUrl);
model.handlers!.onPageBegin(event);
})
.onPageEnd((event: ESObject) => {
model.isPageLoading = false;
model.progress = 100;
// 页面成功加载完成 → 崩溃恢复计数清零,但**必须门控稳定运行窗口**:
// OOM 型必崩页面每次都是"加载完成 → 运行几秒 → 再崩",若无条件清零,则 onPageEnd
// 会把刚累加的计数抹掉,renderRecoveryCount 永远停在 1,MAX_RENDER_RECOVERY 上限形同
// 虚设、陷入无限 reload。仅当距上次崩溃超过 RENDER_RECOVERY_RESET_WINDOW(真正稳定运行过)
// 才清零;否则保留计数,让连续崩溃能累加到上限触发 onRenderUnrecoverable。
// 从未崩溃过的正常页面:lastRenderTerminateAtMs 初始 0,差值远超窗口,照常清零(本就是 0,no-op)。
if (Date.now() - model.lastRenderTerminateAtMs > RENDER_RECOVERY_RESET_WINDOW) {
model.renderRecoveryCount = 0;
}
// 恢复成功后 timer 已执行完毕,复位 id 保持状态干净(clearTimeout 已触发 id 为 no-op)。
model.pendingRecoverTimerId = -1;
if (model.onProgressChanged !== null) {
model.onProgressChanged(100, true);
// 短暂保留 100% 后淡出
setTimeout(() => {
if (model.onProgressChanged !== null) {
model.onProgressChanged(100, false);
}
}, 200);
}
try {
// 池复用(HEAVY 预热)后的首个页面加载完成:清掉预热残留历史(warmupUrl / about:blank),
// 使 signingUrl 成为唯一历史项,避免第一次点返回退回预热页。仅执行一次,不影响页内导航。
if (model.clearHistoryOnce) {
model.clearHistoryOnce = false;
try {
model.controller.clearHistory();
} catch (_e) {
// ignore
}
}
model.canGoBack = model.controller.accessBackward();
model.title = model.controller.getTitle();
if (model.onTitleChanged !== null) {
model.onTitleChanged(model.title, model.canGoBack);
}
} catch (_e) {
// ignore
}
model.handlers!.onPageEnd(event);
})
.onProgressChange((event: ESObject) => {
// 对齐 Android animateProgressTo:仅在整页加载周期(onPageBegin 后、onPageEnd 前)
// 驱动进度条。SPA 软导航(pushState/hashchange)不触发 onPageBegin,isPageLoading=false,
// 其加载子资源触发的 onProgressChange 一律不显示,避免切路由时进度条重现。
if (!model.isPageLoading) {
model.handlers!.onProgressChange(event);
return;
}
const np: number = (event as ESObject).newProgress as number;
model.progress = np;
model.progressVisible = np < 100;
if (model.onProgressChanged !== null) {
model.onProgressChanged(np, model.progressVisible);
}
model.handlers!.onProgressChange(event);
})
.onPermissionRequest((event: ESObject) => model.handlers!.onPermissionRequest(event))
.onShowFileSelector((event: ESObject) => model.handlers!.onShowFileSelector(event))
.onAlert((event: ESObject) => model.handlers!.onAlert(event))
.onConfirm((event: ESObject) => model.handlers!.onConfirm(event))
.onPrompt((event: ESObject) => model.handlers!.onPrompt(event))
.onDownloadStart((event: ESObject) => model.handlers!.onDownloadStart(event))
.onErrorReceive((event: ESObject) => model.handlers!.onErrorReceive(event))
.onHttpErrorReceive((event: ESObject) => model.handlers!.onHttpErrorReceive(event))
.onSslErrorEventReceive((event: ESObject) => model.handlers!.onSslErrorEventReceive(event))
.onRenderExited((event: ESObject) => {
model.handlers!.onRenderExited(event);
// 借出态实例崩溃:即便 reload 成功也已是"渲染进程死过的"僵尸,作废池避免归还后污染复用。
// 对齐 Android 借出态 invalidatePooledWebView(自建实例 fromPool=false,不触发)。
if (model.fromPool && model.onPoolInvalidateRequest !== null) {
try {
model.onPoolInvalidateRequest();
} catch (_e) {
// ignore
}
model.fromPool = false;
}
// 冷却重置:距上次崩溃超过 RENDER_RECOVERY_RESET_WINDOW 视为页面已稳定运行过,
// 本次按全新问题处理,重置连续计数,避免偶发崩溃被误判为"不可恢复"。
const nowMs: number = Date.now();
if (nowMs - model.lastRenderTerminateAtMs > RENDER_RECOVERY_RESET_WINDOW) {
model.renderRecoveryCount = 0;
}
model.lastRenderTerminateAtMs = nowMs;
// 连续崩溃达上限 → 判定不可恢复,停止 reload 并关闭容器,避免死循环 reload 风暴。
model.renderRecoveryCount++;
if (model.renderRecoveryCount > MAX_RENDER_RECOVERY) {
if (model.onRenderUnrecoverable !== null) {
model.onRenderUnrecoverable();
}
return;
}
const fallback: string = model.lastVisitedUrl.length > 0 ? model.lastVisitedUrl : model.url;
// 取消上一次未执行的延迟恢复(连续崩溃场景),避免累积多个 timer。
model.cancelPendingRecover();
// 恢复链 refresh → loadUrl 均失败(崩溃后 controller 常已失效)时,通知容器关闭,
// 消除持久白屏兜底缺口。对齐 Android 恢复失败 finish()。
// 保存 timer id:用户在 1s 延迟窗口内关闭页面时,容器 aboutToDisappear 会 clearTimeout 取消,
// 避免回调访问已销毁/已归还的 controller(野指针)。
model.pendingRecoverTimerId = RenderRecovery.recover(model.controller, fallback, () => {
model.pendingRecoverTimerId = -1;
if (model.onRenderUnrecoverable !== null) {
model.onRenderUnrecoverable();
}
});
})
.width('100%')
.height('100%');
}
const TENCENT_QIAN_WEB_WRAP: WrappedBuilder<[WebHostModel]> = wrapBuilder<[WebHostModel]>(tencentQianWebBuilder);
/**
* 承载离线 Web 组件的 NodeController。容器把它交给 `NodeContainer(...)` 绑定即上树渲染。
*/
export class WebNodeController extends NodeController {
private builderNode: BuilderNode<[WebHostModel]> | null = null;
private model: WebHostModel | null = null;
/** 命令式创建离线 Web 组件(触发渲染进程;未挂载时 Hidden/Inactive)。 */
public init(uiContext: UIContext, model: WebHostModel): void {
this.model = model;
const node: BuilderNode<[WebHostModel]> = new BuilderNode<[WebHostModel]>(uiContext);
node.build(TENCENT_QIAN_WEB_WRAP, model);
this.builderNode = node;
}
makeNode(_uiContext: UIContext): FrameNode | null {
return this.builderNode !== null ? this.builderNode.getFrameNode() : null;
}
public getModel(): WebHostModel | null {
return this.model;
}
/** 销毁离线节点(调用前须确保已从 NodeContainer 解绑,避免白屏)。 */
public disposeNode(): void {
try {
if (this.builderNode !== null) {
this.builderNode.dispose();
}
} catch (_e) {
// ignore
}
this.builderNode = null;
this.model = null;
}
}
|