import webview from '@ohos.web.webview';
import common from '@ohos.app.ability.common';
import { TencentQianWebViewConfig } from './model/TencentQianWebViewConfig';
import {
OnSignResult,
OnSdkEvent,
OnRenderUnrecoverable,
OnTitleChanged,
OnProgressChanged
} from './model/SignCallback';
import {
TencentQianWebViewConfigurator,
TencentQianWebViewHandlers
} from './TencentQianWebViewConfigurator';
import { TencentQianWebViewController } from './TencentQianWebViewController';
import { WebHostModel } from './internal/prewarm/WebHostModel';
import { WebNodeController } from './internal/prewarm/TencentQianWebNode';
import { HarmonyWebViewPool } from './internal/prewarm/HarmonyWebViewPool';
/**
* 容器组件形态(灵活嵌入)。
*
* 封装一个完整配置的 Web 组件,内部已挂载 SDK 的全部能力:
* UA 注入、scheme 拦截、权限处理、文件选择、JS 弹窗、下载、错误事件回调等,
* 客户可在自己的页面任意位置嵌入,与自有 UI 自由组合。
*
* Web 组件改由离线组件(`BuilderNode` + `NodeContainer`)承载,
* 与 Page / 预热池共用同一 `@Builder`(`TencentQianWebNode`)。嵌入模式为客户自持场景,
* **默认自建自管**、随生命周期 `dispose`(不接预热池,池由 `open()` 容器模式复用)。
*
* **可选池复用(`reusePrewarmPool`,默认 false)**:客户显式开启后,本组件也会尝试复用
* `prewarm(HEAVY)` 预热的池实例(UA 配置匹配时),关闭时按借还池归还(不销毁)。
* ⚠️ 仅当组件是「一次性、非缓存」的主签署入口时才建议开启——嵌入模式生命周期由客户
* UI 树掌控,若页面被 tab 切换 / `@Reusable` / 路由缓存,`aboutToDisappear` 不等于
* 「签署结束」,归还后实例可能被他处借走导致状态错乱。默认关闭以保证边界清晰。
*
* **适用场景**:客户需要自定义页面布局(顶栏 / 底栏 / 侧边栏等)。
*
* **不包含**:顶栏 / 进度条 / 关闭确认对话框(由客户自行实现);
* 如需开箱即用的完整签署页,改用 `TencentQianWebView.open()` 容器模式。
*
* **典型用法**:
* ```ts
* @Component
* struct MyPage {
* build() {
* Column() {
* Row() { Text('我的签署') }.height(56)
*
* TencentQianWebViewComponent({
* url: this.signUrl,
* context: getContext(this) as common.UIAbilityContext,
* onSignResult: (result) => { console.log(result); }
* })
* .layoutWeight(1)
* }
* }
* }
* ```
*/
@Component
export struct TencentQianWebViewComponent {
/** 目标 H5 URL。必填。 */
@Prop url: string = '';
/** UIAbility/页面 context。必填。 */
context: common.UIAbilityContext | null = null;
/** 容器配置。可省,使用默认值。 */
config: TencentQianWebViewConfig = new TencentQianWebViewConfig();
/** 签署结果回调。必填。 */
onSignResult: OnSignResult = (_r): void => {
// no-op default
};
/** SDK 事件回调。可选。 */
onSdkEvent?: OnSdkEvent = undefined;
/**
* 渲染进程连续崩溃且 SDK 放弃自动恢复(永久白屏终态)时触发。可选。
*
* 宿主据此显示错误页 / 返回上级 / 提示重试(配合 `controller.reload()`)。
* 与 `onSdkEvent` 的 `RenderProcessGoneEvent`(每次崩溃)语义互补。
*/
onRenderUnrecoverable?: OnRenderUnrecoverable = undefined;
/**
* H5 标题 / 可后退状态变化(`onPageEnd` 快照)。可选。
*
* 供宿主自绘顶栏更新标题与返回按钮禁用态。实时可后退值请用 `controller.canGoBack()`。
*/
onTitleChanged?: OnTitleChanged = undefined;
/**
* 加载进度变化(`progress` 0~100;`visible` 是否应显示)。可选。供宿主自绘进度条。
*/
onProgressChanged?: OnProgressChanged = undefined;
/**
* 嵌入模式 Web 控制器。可选。
*
* 宿主创建 `TencentQianWebViewController` 实例传入后,可在自绘的返回按钮 / 重试按钮里主动
* 操控内部 WebView(`canGoBack` / `goBack` / `handleBack` / `reload`)。不传则无法主动控制。
*/
controller?: TencentQianWebViewController = undefined;
/**
* 是否复用 `prewarm(HEAVY)` 预热的池实例。默认 false(自建自管)。
*
* ⚠️ 仅建议在「一次性、非缓存」的主签署入口场景开启(见类文档风险说明)。
*/
reusePrewarmPool: boolean = false;
// —— internal state ——
private webviewController: webview.WebviewController = new webview.WebviewController();
@State private handlers: TencentQianWebViewHandlers | null = null;
/** 承载离线 Web 组件的 NodeController(null 表示尚未初始化 / 缺 context)。 */
@State private nodeController: WebNodeController | null = null;
/** 本组件 Web 实例是否借自预热池(决定关闭时归还还是销毁)。 */
private fromPool: boolean = false;
aboutToAppear(): void {
if (this.context === null) {
console.error('[TencentQianWebViewComponent] context is null, please pass the UIAbilityContext');
return;
}
// 仅当客户显式开启 reusePrewarmPool 时尝试复用池实例(UA 配置匹配);否则自建自管
if (this.reusePrewarmPool) {
const pooled: WebNodeController | null =
HarmonyWebViewPool.obtain(this.config.injectUserAgent, this.config.userAgentExtra);
if (pooled !== null && pooled.getModel() !== null) {
this.reusePooled(pooled);
return;
}
}
this.buildOwnNode();
}
aboutToDisappear(): void {
// 复位宿主控制器为 no-op,防止组件销毁后宿主误调用命令触及已销毁/已归还的 controller。
if (this.controller !== undefined && this.controller !== null) {
this.controller.detachInternal();
}
if (this.handlers !== null) {
try {
this.handlers.networkStateInjector.detach();
} catch (_e) {
// ignore
}
}
// 断开 model 对本组件的联动回调,防止池 model 长期持有已销毁组件引用(内存泄漏)。
if (this.nodeController !== null) {
const model: WebHostModel | null = this.nodeController.getModel();
if (model !== null) {
model.clearContainerListeners();
}
}
if (this.fromPool) {
// 借还池(同 Page/Android):先解绑 NodeContainer,再归还(about:blank + clearHistory,不销毁)
this.nodeController = null;
HarmonyWebViewPool.recycle();
} else {
// 自建:销毁离线节点(一次性,不归还池)
if (this.nodeController !== null) {
this.nodeController.disposeNode();
this.nodeController = null;
}
}
}
/** 复用预热池实例:用池 controller 生成真实 handlers,热替换 model.handlers/url 并重新激活。 */
private reusePooled(pooled: WebNodeController): void {
const model: WebHostModel = pooled.getModel()!;
this.webviewController = model.controller;
// aboutToAppear 已保证 context 非 null 才会走到复用/自建分支
this.handlers = TencentQianWebViewConfigurator.apply(
this.webviewController,
this.context!,
this.config,
this.onSignResult,
this.onSdkEvent
);
// detach 预热 handlers 的网络监听,切到真实 handlers
const old: TencentQianWebViewHandlers | null = model.handlers;
if (old !== null) {
try {
old.networkStateInjector.detach();
} catch (_e) {
// ignore
}
}
model.handlers = this.handlers;
model.url = this.url;
// 池实例已在预热时加载过 warmupUrl(历史栈残留);标记首个 onPageEnd 清历史,
// 使 signingUrl 成为唯一历史项,避免第一次返回退回预热页。
model.clearHistoryOnce = true;
// 标记来自池 + 绑定作废回调:借出态渲染进程崩溃时作废池实例(避免僵尸复用),
// 并同步组件 fromPool=false(后续销毁不再走归还)。对齐 Android。
model.fromPool = true;
model.onPoolInvalidateRequest = (): void => {
// 借出态崩溃:只脱池(不再被 obtain 复用 / 不再被 recycle 收回),**不销毁正在显示的活节点**。
// controller 崩溃后仍有效,容器在同一 controller 上 refresh() 原地恢复(鸿蒙无需分离节点/重建);
// 活节点由容器继续持有,关闭时(fromPool=false → disposeNode)再销毁收口。对齐 Android invalidateIfPooled。
HarmonyWebViewPool.unpoolBorrowed();
this.fromPool = false;
};
// 绑定进度 / 标题 / 不可恢复联动回调到 model(转发给宿主 props),并把命令注入宿主控制器。
this.bindModelListeners(model);
this.nodeController = pooled;
this.fromPool = true;
// controller 已 attach(预热时),手动执行真实 handlers 的 attach(UA + 网络监听)
try {
this.handlers.onControllerAttached();
} catch (_e) {
// ignore
}
this.attachController();
// 加载真实签署页(命中预热热身的连接/内核缓存)
try {
this.webviewController.loadUrl(this.url);
} catch (_e) {
// ignore
}
}
/** 自建离线组件(无池可复用 / 未开启复用时)。 */
private buildOwnNode(): void {
// aboutToAppear 已保证 context 非 null 才会走到复用/自建分支
this.handlers = TencentQianWebViewConfigurator.apply(
this.webviewController,
this.context!,
this.config,
this.onSignResult,
this.onSdkEvent
);
const model: WebHostModel = new WebHostModel(this.webviewController);
model.url = this.url;
model.handlers = this.handlers;
// 绑定进度 / 标题 / 不可恢复联动回调到 model(转发给宿主 props)。
this.bindModelListeners(model);
const ctrl: WebNodeController = new WebNodeController();
ctrl.init(this.getUIContext(), model);
this.nodeController = ctrl;
this.fromPool = false;
// 把命令注入宿主控制器(controller 已就绪)。
this.attachController();
}
/**
* 绑定进度 / 标题 / 不可恢复联动回调到 model,转发给宿主对应 props。
*
* 与 Page 模式 `bindModelListeners` 同构,区别在于嵌入模式无内置顶栏 / 进度条,
* 联动信号直接透传给宿主自绘 UI(宿主未传对应 prop 时静默忽略)。
*/
private bindModelListeners(model: WebHostModel): void {
model.onProgressChanged = (progress: number, visible: boolean): void => {
if (this.onProgressChanged !== undefined) {
this.onProgressChanged(progress, visible);
}
};
model.onTitleChanged = (title: string, canGoBack: boolean): void => {
if (this.onTitleChanged !== undefined) {
this.onTitleChanged(title, canGoBack);
}
};
model.onRenderUnrecoverable = (): void => {
if (this.onRenderUnrecoverable !== undefined) {
this.onRenderUnrecoverable();
}
};
}
/**
* 把内部 WebView 命令(后退查询 / 后退 / 重载)注入宿主控制器。
*
* 宿主未传 `controller` 时为 no-op。命令实时走 `webviewController`:`canGoBack` 用
* `accessBackward()` 覆盖 SPA 软路由的历史深度;`goBack` 前置判断避免首屏无历史时报错。
*/
private attachController(): void {
if (this.controller === undefined || this.controller === null) {
return;
}
this.controller.attachInternal(
(): boolean => {
try {
return this.webviewController.accessBackward();
} catch (_e) {
return false;
}
},
(): void => {
try {
if (this.webviewController.accessBackward()) {
this.webviewController.backward();
}
} catch (_e) {
// ignore
}
},
(): void => {
try {
this.webviewController.refresh();
} catch (_e) {
// ignore
}
}
);
}
build() {
Column() {
if (this.nodeController === null) {
Text('TencentQianWebView: missing context')
.fontSize(14)
.fontColor('#ff0000')
.width('100%')
.height('100%')
.textAlign(TextAlign.Center);
} else {
NodeContainer(this.nodeController)
.width('100%')
.height('100%');
}
}
.width('100%')
.height('100%');
}
}
|