# 接入使用指南

> **快速定位**:先判断你属于哪类客户，再跳到对应章节。

```mermaid
flowchart TD
    Start[要接入电子签 H5] --> Q1{是否已有原生 App?}

    Q1 -- 否,纯 H5 / UniApp --> NO[走 H5 集成方文档\n或 docs/qianuni-uniapp-guide.md]
    Q1 -- 是,有原生 / Hybrid App --> Q2{愿意使用本 SDK?}

    Q2 -- 是,想最快集成 --> NEW[§1 新用户:容器模式\n调一行 API,SDK 全包]
    Q2 -- 是,但已有自有 WebView --> EXIST[§2 老用户:配置模式\n在自有的 WebView 上叠加能力]
    Q2 -- 否,必须自己实现 --> DIY[§3 自行适配\n按清单逐项配置 WebView]
```

---

## §1 新用户：容器模式（推荐，最快接入）

**适用场景**：你从零开始集成电子签 H5，没有现成的 WebView 容器，希望 1 行调用就能打开签署页。

### 工作原理

SDK 会：
1. 启动一个内置的 **全屏签署页面**（含顶栏、进度条、关闭按钮）
2. 在内部的 WebView 上自动套用 **所有兼容性配置**（DOM Storage、Cookie、第三方 Cookie、权限、文件选择、UA 注入等）
3. 拦截 `qianapp://` 协议，解析为 `SignResult` 回调给客户
4. 签署完成后自动关闭页面

### Android（Kotlin / Java）

```kotlin
// Kotlin（推荐）
TencentQianWebView.open(
    activity = this,
    url = "https://quick.qian.tencent.com/sign?flowId=xxx",
    config = TencentQianWebViewConfig(topBarTitle = "电子签署"),
    callback = object : SignCallback {
        override fun onSignResult(result: SignResult) {
            // result.action  → "sign" / "reject_sign" / "fill" / 等
            // result.result → "success" / "fail"
            // result.flowId  → 流程 ID
            // result.extras  → 自定义透传字段
        }
        override fun onEvent(event: SdkEvent) {
            // 选接，用于日志/埋点
        }
    }
)
```

```java
// Java（与 Kotlin 版 API 对等的纯 Java 实现，适用于不引入 Kotlin 运行时的存量工程）
TencentQianWebView.open(
    activity,
    "https://quick.qian.tencent.com/sign?flowId=xxx",
    TencentQianWebViewConfig.builder().topBarTitle("电子签署").build(),
    new SignCallback() {
        @Override public void onSignResult(@NonNull SignResult result) { /* ... */ }
        @Override public void onEvent(@NonNull SdkEvent event) { /* ... */ }
    }
);
```

### iOS（Swift / Objective-C）

```swift
// Swift（来自 ios-swift/，模块名 TencentQianWebViewSDK）
import TencentQianWebViewSDK

TencentQianWebView.open(
    from: self,
    url: "https://quick.qian.tencent.com/sign?flowId=xxx",
    config: TencentQianWebViewConfig(topBarTitle: "电子签署"),
    callbacks: SignCallbacks(
        onSignResult: { result in
            // result.action / result.result / result.flowId
        },
        onEvent: { event in
            // 选接
        }
    )
)
```

```objc
// Objective-C（来自 ios-objective-c/，模块名 TencentQianWebView）
@import TencentQianWebView;

[TQWebView openFromPresenter:self
                         url:@"https://quick.qian.tencent.com/sign?flowId=xxx"
                      config:[TQWebViewConfig defaultConfig]
                   callbacks:[TQSignCallbacks callbacksWithOnSignResult:^(TQSignResult *r) {
                       // r.action / r.result
                   } onEvent:nil]
                    animated:YES
                  completion:nil];
```

### HarmonyOS（ArkTS）

```typescript
// 容器模式（页面形态）
TencentQianWebView.open(
  getContext(this) as common.UIAbilityContext,
  'https://quick.qian.tencent.com/sign?flowId=xxx',
  new TencentQianWebViewConfig(),
  (result: SignResult) => {
    // result.action / result.result
  }
);
```

> 容器模式也支持 **组件形态**（`TencentQianWebViewComponent`），适合嵌入自定义布局，详见 HarmonyOS 文档。

---

## §2 老用户：配置模式—在自有 WebView 上叠加能力

**适用场景**：你已经有自己的 WebView 容器（自定义顶栏、导航、业务封装等），只想让 SDK 叠加电子签 H5 的兼容能力，**不替换**现有 WebView。

### 工作原理

SDK 不会创建新页面，而是：
1. 在你传入的 WebView 上 **注入全部能力**（DOM Storage、Cookie、第三方 Cookie、Scheme 拦截、权限、文件选择、下载等）
2. 返回一个 `Handle` 句柄，退出时调用 `handle.detach()` 或 `handle.detachAndDestroy()` 清理

### 关键约束

| 约束 | 说明 |
|------|------|
| **替换 delegate** | SDK 会替换 `webView.webViewClient` / `webView.navigationDelegate`，**暂不支持链式保留**客户原 delegate。客户应在 `apply` **之前**不要自行赋值。 |
| **Activity 类型** | Android 要求 `ComponentActivity`（`AppCompatActivity` / `FragmentActivity`），因为需要 `registerForActivityResult`。 |
| **生命周期** | `apply` 必须在 `onCreate()` 中调用（Android 的 `registerForActivityResult` 要求 `CREATED` 之前注册）。 |
| **二次 apply** | 对同一个 WebView 二次 `apply` 会替换前次注入，不堆叠。 |

### Android 接入

```kotlin
// 1. 创建配置
val config = TencentQianWebViewConfig(
    injectUserAgent = true,
    externalSchemes = listOf("weixin", "alipays")
)

// 2. 在自有 WebView 上套用
val handle = TencentQianWebViewConfigurator.apply(
    activity = this,          // 必须是 ComponentActivity
    webView = myWebView,      // 你已有的 WebView 实例
    config = config,
    callback = object : SignCallback {
        override fun onSignResult(result: SignResult) { /* 处理结果 */ }
    }
)

// 3. 加载签署页（SDK 已配置好所有能力）
myWebView.loadUrl(signUrl)

// 4. 退出页面时：清理 SDK 注入
// 方式 A：仅解绑 SDK，保留 WebView 实例
handle.detach()

// 方式 B：解绑 + 销毁 WebView（推荐）
handle.detachAndDestroy()
```

### iOS 接入（Swift / Objective-C）

```swift
// Swift（来自 ios-swift/，模块名 TencentQianWebViewSDK）
import TencentQianWebViewSDK

// 1. 创建配置
let config = TencentQianWebViewConfig(topBarTitle: "电子签署")

// 2. 在自有 WKWebView 上套用
let handle = TencentQianWebViewConfigurator.apply(
    webView: myWebView,      // 你已有的 WKWebView 实例
    hostController: self,    // 可选，用于弹窗 present
    config: config,
    callbacks: SignCallbacks(
        onSignResult: { result in /* 处理结果 */ },
        onEvent: { event in /* 日志/埋点 */ }
    )
)

// 3. 加载签署页
handle.load(signUrl)  // 或 myWebView.load(URLRequest(url: signUrl))

// 4. 退出页面时
handle.detachAndDestroy()
```

```objc
// Objective-C（来自 ios-objective-c/，模块名 TencentQianWebView）
@import TencentQianWebView;

TQWebViewConfig *config = [TQWebViewConfig defaultConfig];
config.userAgentExtra = @"qianwv/1.0.0";

TQWebViewHandle *handle = [TQWebViewConfigurator applyToWebView:myWebView
                                                 hostController:self
                                                         config:config
                                                      callbacks:[TQSignCallbacks callbacksWithOnSignResult:^(TQSignResult *r) {
                                                          // 处理结果
                                                      } onEvent:nil]];

// 3. 加载签署页
[handle loadURL:[NSURL URLWithString:signUrl]];

// 4. 退出页面时
[handle detachAndDestroy];
```

### HarmonyOS 配置模式

HarmonyOS 的配置模式更为独特：`apply()` 返回一组 `TencentQianWebViewHandlers`，客户需要**逐个绑定到自有 Web 组件的回调**上。

```typescript
// 1. 创建你的 Web 组件和 controller
private controller: webview.WebviewController = new webview.WebviewController();

// 2. 调用 Configurator 获取 handlers
aboutToAppear(): void {
  const handlers = TencentQianWebViewConfigurator.apply(
    this.controller,
    getContext(this) as common.UIAbilityContext,
    new TencentQianWebViewConfig(),
    (result: SignResult) => { /* 处理结果 */ }
  );
  // 保存 handlers 供 build() 中使用
  this.handlers = handlers;
}

// 3. 在 build() 中将 handlers 绑定到 Web 组件
build() {
  Web({ src: this.url, controller: this.controller })
    .javaScriptAccess(this.handlers.webSettings.javaScriptAccess)
    .domStorageAccess(this.handlers.webSettings.domStorageAccess)
    .mediaPlayGestureAccess(this.handlers.webSettings.mediaPlaybackRequiresUserGesture)
    // ... 其他属性绑定
    .onLoadIntercept((event) => this.handlers.onLoadIntercept(event))
    .onPermissionRequest((event) => this.handlers.onPermissionRequest(event))
    .onShowFileSelector((event) => this.handlers.onShowFileSelector(event))
    .onAlert((event) => this.handlers.onAlert(event))
    .onConfirm((event) => this.handlers.onConfirm(event))
    .onDownloadStart((event) => this.handlers.onDownloadStart(event));
}
```

---

## §3 自行适配：不依赖 SDK，自己完成 WebView 配置

**适用场景**：你的项目不允许引入第三方 SDK，或你希望完全掌控 WebView 的生命周期。

> 以下清单列出 SDK 内部已实现的所有配置项。按此清单逐项实现，即可达到与 SDK 相同的兼容效果。

### 3.1 Android 自行适配清单

#### AndroidManifest 声明

```xml
<!-- 网络 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<!-- 相机 + 麦克风（人脸核身必备） -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

<!-- 老版本存储权限（API <= 32） -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
    android:maxSdkVersion="32" />

<!-- Android 11+ 声明外部 App 可见性 -->
<queries>
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <data android:scheme="weixin" />
    </intent>
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <data android:scheme="alipays" />
    </intent>
    <intent>
        <action android:name="android.intent.action.VIEW" />
        <data android:scheme="https" />
    </intent>
    <intent>
        <action android:name="android.media.action.VIDEO_CAPTURE" />
    </intent>
    <intent>
        <action android:name="android.media.action.IMAGE_CAPTURE" />
    </intent>
</queries>

<!-- application 节点配置 -->
<application
    android:usesCleartextTraffic="true"  <!-- 调试阶段建议开，生产可关 -->
    android:hardwareAccelerated="true">   <!-- 默认即开，确认不要被覆盖 -->
    <activity android:name=".YourSignActivity"
        android:exported="false" />
</application>
```

#### WebSettings（DOM Storage + Cookie + 标准配置）

| 配置项 | 代码 | 原因 |
|--------|------|------|
| **DOM Storage** | `settings.domStorageEnabled = true` | 电子签 H5 用 localStorage 存会话 token |
| **JavaScript** | `settings.javaScriptEnabled = true` | H5 应用基础 |
| **Cookie** | `CookieManager.getInstance().setAcceptCookie(true)` | session cookie |
| **第三方 Cookie** | `CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true)` | **人脸核身必须**（跨域跳第三方域名） |
| **混合内容** | `settings.mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE` | 默认兼容模式 |
| **视图适配** | `settings.useWideViewPort = true` | 按 viewport meta 缩放 |
| **视图适配** | `settings.loadWithOverviewMode = true` | 配合上项 |
| **字体缩放** | `settings.textZoom = 100` | 防系统大字撑爆布局 |
| **双指缩放** | `settings.setSupportZoom(true)` + `settings.builtInZoomControls(true)` | 老机型人脸核身页可能需缩放 |
| **缩放控件** | `settings.displayZoomControls = false` | 隐藏 +/- 浮标 |
| **自动播放** | `settings.mediaPlaybackRequiresUserGesture = false` | 视频流自动播放 |
| **文件访问** | `settings.allowFileAccess = false` | 安全 |
| **Content 访问** | `settings.allowContentAccess = false` | 安全 |
| **禁止多窗口** | `settings.javaScriptCanOpenWindowsAutomatically = false` | 防诡异跳出 |
| **禁止多窗口** | `settings.setSupportMultipleWindows(false)` | 防 target=\_blank |
| **编码** | `settings.defaultTextEncodingName = "UTF-8"` | 标准 |
| **UA 注入** | `settings.userAgentString = "$base qianwv/1.0.0"` | 可选，便于排查（`1.0.0` 应替换为实际集成的 SDK 版本号） |
| **渲染优先级** | `Build.VERSION_CODES.R` 以下：`settings.setRenderPriority(HIGH)` | 老设备性能 |

#### WebViewClient 必须实现

| 回调 | 需要做的事 |
|------|-----------|
| `shouldOverrideUrlLoading` | 优先级：① `qianapp://` → 解析回调 → 返回 true 取消导航；② `weixin`/`alipays` 等外部 scheme → `Intent.ACTION_VIEW` 交出；③ `http(s)` → 返回 false 走默认；④ 其余 → 返回 true 丢弃 |
| `onReceivedSslError` | **必须 cancel**，不能 proceed |
| `onReceivedError` | 仅 `isForMainFrame == true` 时上报错误事件 |
| `onReceivedHttpError` | 仅 `isForMainFrame == true` 时上报 4xx/5xx 事件 |

#### WebChromeClient 必须实现

| 回调 | 需要做的事 |
|------|-----------|
| `onPermissionRequest` | 向系统申请 CAMERA + RECORD_AUDIO 运行时权限，拿到后 `request.grant()` |
| `onShowFileChooser` | 实现文件选择器。重要：处理 `<input capture>` 属性（Android WebView 默认忽略）和人脸核身老录制模式 MIME 兜底（`video/kyc`、`video/faceid`） |
| `onJsAlert` | 必须显式实现，用 AlertDialog 弹出，否则流程卡死 |
| `onJsConfirm` | 必须显式实现，用 AlertDialog 弹确定/取消 |
| `onJsPrompt` | 走父类默认即可 |

#### 下载监听

必须实现 `setDownloadListener`，否则「下载合同 PDF」无反应。

策略：
- Android 10+：直接 DownloadManager（无需存储权限）
- Android 6-9：动态申请 `WRITE_EXTERNAL_STORAGE`，获授权后 DownloadManager
- 兜底：`Intent.ACTION_VIEW` 转发系统浏览器
- 同步 Cookie（可选，默认不开）

#### qianapp:// 协议解析

必须自己实现 URL 解析器，解析逻辑见 [qianapp-protocol.md](./qianapp-protocol.md)。

核心规则：
- scheme = `qianapp` → 开始解析
- 识别 `flowId`、`action`、`result`、`from` 四个顶层字段
- 其余 query 参数透传到 `extras` Map
- 未知值原样保留（不映射枚举）
- 解析失败返回全空结果

### 3.2 iOS 自行适配清单

#### Info.plist 声明

```xml
<key>NSCameraUsageDescription</key>
<string>用于身份核验、拍摄证件照片及签署过程中的拍照上传</string>
<key>NSMicrophoneUsageDescription</key>
<string>用于身份核验过程中的活体检测</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>用于选取并上传图片或证件照</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>用于保存合同文件或签署凭证</string>

<key>LSApplicationQueriesSchemes</key>
<array>
    <string>weixin</string>
    <string>weixinULAPI</string>
    <string>alipay</string>
    <string>alipays</string>
</array>

<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoadsInWebContent</key>
    <true/>
</dict>
```

#### WKWebViewConfiguration 配置

```swift
let config = WKWebViewConfiguration()
config.allowsInlineMediaPlayback = true
config.mediaTypesRequiringUserActionForPlayback = []
config.processPool = sharedProcessPool  // 单例，跨 WKWebView 共享 Cookie
config.websiteDataStore = .default()

// JS 启用
let prefs = WKWebpagePreferences()
prefs.allowsContentJavaScript = true
config.defaultWebpagePreferences = prefs

// UA 注入（可选）
config.applicationNameForUserAgent = "qianwv/1.0.0"

let webView = WKWebView(frame: .zero, configuration: config)
```

> OC 客户可参考 `ios-objective-c/` SDK 源码中 `TQWKConfigurationFactory` 的实现，等价的 OC 写法为 `[TQWKConfigurationFactory makeConfiguration:config]`。

#### WKNavigationDelegate 必须实现

| 回调 | 需要做的事 |
|------|-----------|
| `decidePolicyFor navigationAction` | ① `qianapp://` → 解析回调 → `.cancel`；② `weixin`/`alipays` 等外部 scheme → `UIApplication.shared.open` → `.cancel`；③ `http(s)` → `.allow`；④ 其余 → `.cancel` + 事件上报 |
| `didFailProvisionalNavigation` | 错误事件上报 |

#### WKUIDelegate 必须实现

| 回调 | 需要做的事 |
|------|-----------|
| `requestMediaCapturePermissionFor` (iOS 15+) | `.grant`（前提：Info.plist 已声明） |
| `runJavaScriptAlertPanel` | 弹原生 alert，调用 completionHandler |
| `runJavaScriptConfirmPanel` | 弹原生 confirm，传回 true/false |
| `runJavaScriptTextInputPanel` | 弹输入框，传回结果 |
| `runOpenPanel` (iOS 14+) | 文件选择器实现 |

#### PDF 下载

WKWebView 默认只预览 PDF，用户无法保存。建议方案：

```swift
// 在 decidePolicyFor navigationResponse 中：
// 识别 Content-Disposition: attachment 或 MIME = application/pdf
// → UIApplication.shared.open(url) 跳到 Safari 让用户点击分享保存
// 或者：复制链接到剪贴板 + 提示
```

#### qianapp:// 协议解析

与 Android 端完全相同的解析逻辑，见 [qianapp-protocol.md](./qianapp-protocol.md)。

### 3.3 HarmonyOS 自行适配清单

> **架构差异**：HarmonyOS 的 ArkWeb 是 ArkUI 声明式组件，所有配置必须在 `Web({})` 的链式属性中逐项绑定，无法像 Android/iOS 一样通过运行时替换 delegate 来统一注入。

#### module.json5 权限声明

```json5
{
  "module": {
    "requestPermissions": [
      { "name": "ohos.permission.INTERNET" },
      { "name": "ohos.permission.GET_NETWORK_INFO" },
      {
        "name": "ohos.permission.CAMERA",
        "reason": "$string:camera_permission_reason",
        "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
      },
      {
        "name": "ohos.permission.MICROPHONE",
        "reason": "$string:microphone_permission_reason",
        "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
      }
    ]
  }
}
```

#### Web 组件链式属性（声明式配置）

在 `build()` 中创建 `Web` 组件时，逐项绑定以下属性：

| 属性 | 推荐值 | 说明 |
|------|--------|------|
| `.javaScriptAccess(true)` | `true` | 启用 JS |
| `.domStorageAccess(true)` | `true` | DOM Storage 持久化 |
| `.mediaPlayGestureAccess(false)` | `false` | 视频流自动播放 |
| `.fileAccess(false)` | `false` | 安全：禁 file:// 主框架 |
| `.databaseAccess(true)` | `true` | 数据库 |
| `.textZoomRatio(100)` | `100` | 防系统大字撑爆布局 |
| `.zoomAccess(true)` | `true` | 双指缩放 |
| `.multiWindowAccess(false)` | `false` | 防 target=\_blank 诡异跳出 |
| `.wideViewModeAccess(true)` | `true` | 按 viewport meta 缩放 |
| `.overviewModeAccess(true)` | `true` | 配合上项 |
| `.mixedMode(MixedMode.None)` | `All` 或 `None` | 是否允许 https 加载 http 子资源 |
| `.javaScriptOnDocumentStart(...)` | 注入网络状态检测脚本 | 参见 SDK `NetworkStateInjector` |

#### Cookie 配置

```typescript
import webview from '@ohos.web.webview';

// 创建 WebController 之前调用一次（全局生效）
webview.WebCookieManager.putAcceptCookieEnabled(true);
```

#### UA 注入

必须在 `Web({})` 组件的 `onControllerAttached` 回调中完成：

```typescript
Web({ src: url, controller: this.controller })
  .onControllerAttached(() => {
    // 读取默认 UA，拼接 qianwv/1.0.0
    const defaultUa = controller.getUserAgent();
    controller.setCustomUserAgent(defaultUa + ' qianwv/1.0.0');
  })
```

#### URL 拦截（onLoadIntercept）

必须实现 `onLoadIntercept` 回调，处理优先级：

```typescript
onLoadIntercept((event) => {
  const url = event.data.getRequestUrl();
  if (!event.data.isMainFrame()) return false;

  // ① qianapp:// → 解析为 SignResult → 回调 → 取消导航
  if (url.startsWith('qianapp://')) {
    const result = SignResultParser.parse(url);
    onSignResult(result);
    return true;  // 取消导航
  }

  const scheme = url.split(':')[0]?.toLowerCase();

  // ② weixin / alipays 等白名单 → startAbility 跳出 App
  if (['weixin', 'alipays'].includes(scheme) || config.externalSchemes.includes(scheme)) {
    try {
      context.startAbility({ action: 'ohos.want.action.viewData', uri: url });
    } catch (_e) {}
    return true;
  }

  // ③ http(s) → 放行
  if (scheme === 'http' || scheme === 'https') return false;

  // ④ blob: → 不支持，触发 DownloadFailed 事件
  if (scheme === 'blob') return true;

  // ⑤ 其余未知 scheme → 丢弃
  return true;
})
```

#### 权限处理（onPermissionRequest）

```typescript
onPermissionRequest((event) => {
  const request = event.request;
  const resources = request.getAccessibleResource();

  // 将 Web 资源标识映射为鸿蒙权限
  const perms: Permissions[] = [];
  for (const r of resources) {
    if (r === 'TYPE_VIDEO_CAPTURE') perms.push('ohos.permission.CAMERA');
    if (r === 'TYPE_AUDIO_CAPTURE') perms.push('ohos.permission.MICROPHONE');
  }

  if (perms.length === 0) {
    request.deny();
    return;
  }

  // 弹系统权限弹窗
  const atManager = abilityAccessCtrl.createAtManager();
  atManager.requestPermissionsFromUser(context, perms).then((result) => {
    if (result.authResults.every(r => r === 0)) {
      request.grant(resources);
    } else {
      request.deny();
    }
  });
})
```

#### 文件选择器（onShowFileSelector）

鸿蒙需要区分三种场景：

- **`isCapture` 为 true**：使用 `@ohos.multimedia.cameraPicker.pick()` 拉系统相机拍照/录像
- **accept 含 `image/` 或 `video/`**：使用 `PhotoViewPicker` 拉图库
- **其他**：使用 `DocumentViewPicker` 拉文档选择器

```typescript
onShowFileSelector((event) => {
  const params = event.fileSelector;
  const accept = params.getAcceptType();   // 扩展名/MIME 数组
  const isCapture = params.isCapture();    // 是否 H5 要求即时拍摄
  const mode = params.getMode();           // 0=单选 1=多选

  // 选择后回调
  const onResult = (uris: string[]) => {
    event.result.handleFileList(uris);
  };

  // 具体实现参考 SDK FileChooserHandler.pick()
  // 根据 isCapture / accept 类型选择合适的系统选择器
})
```

#### JS 弹窗（onAlert / onConfirm / onPrompt）

| 回调 | 需要做的事 |
|------|-----------|
| `onAlert` | 必须返回 `true`。弹 `showDialog`，用户点确定后调用 `result.handleConfirm()` |
| `onConfirm` | 必须返回 `true`。弹双按钮 dialog，确定→`handleConfirm()`，取消→`handleCancel()` |
| `onPrompt` | **必须返回 `true`**（否则 ArkWeb 默认拦截返回 null）。弹含 TextInput 的 dialog，用户输入后调 `result.handlePromptConfirm(value)` |

#### 下载处理（onDownloadStart）

```typescript
onDownloadStart((event) => {
  const url = event.url;
  // 使用 @ohos.request.downloadFile 下载到缓存目录
  // 下载完成后弹 DocumentViewPicker.save 让用户选择目标位置
  request.downloadFile(context, { url, filePath: cachePath }).then((task) => {
    task.on('complete', () => {
      // 弹保存选择器，让用户选定目标路径
    });
    task.on('fail', () => {
      // 上报 DownloadFailed 事件
    });
  });
})
```

#### 错误处理

| 回调 | 需要做的事 |
|------|-----------|
| `onErrorReceive` | 仅 `isMainFrame()` 时上报错误事件 |
| `onHttpErrorReceive` | 仅 `isMainFrame()` 时上报 4xx/5xx 事件 |
| `onSslErrorEventReceive` | **必须 cancel**（`handler.handleCancel()`），不能放行 |
| `onRenderExited` | 根据 `renderExitReason` 决定是否重载：`0`=崩溃，需 `controller.refresh()` |

#### qianapp:// 协议解析

解析规则与 Android/iOS 完全相同，解析逻辑详见 [qianapp-protocol.md](./qianapp-protocol.md)。

> **自行适配提示**：可以直接复用 SDK 的 `SignResultParser` 类（纯函数、无依赖），无需自己重写解析逻辑。

---

### 3.4 快速自检

无论你采用哪种方式接入，集成完成后请先打开 **兼容性自检页**：

```
https://quick.beta.qian.tencent.cn/compatibilityTest
```

自检项覆盖：UA 识别与容器标识、`qianapp://` 协议拦截、文件选择器（含 `<input capture>` 行为差异）、JS 弹窗（alert/confirm/prompt）、外部 scheme 拉起（微信 / 支付宝 / 浏览器）、系统能力降级（权限被拒、弱网等）、Cookie / 生命周期等核心维度。

**目标**：全部检测项绿灯。若自检页通过但实际签署仍有问题，检查 flowId 是否过期、URL 是否正确。

---

## §3.5 预热（可选，提升首屏速度）

**适用场景**：希望进一步压低签署页首屏时间。预热与容器 / 配置模式**正交**——不预热时行为与现状完全一致，预热是纯增量收益。

### 两个维度

- **网络预热**：提前做 DNS / 连接 / 资源，命中缓存（数据源：在线 `warmup-manifest.json`）。
- **实例池**：提前创建 WebView 并热身，`open()` 透明复用，省创建时间 + 命中 warmup 网络缓存（容器模式生效）。

### 意图三档（客户只表达"意图强度"，SDK 按端能力落地）

| 档位 | 语义 | 何时调用 | 是否需要 bizType |
|------|-----------|---------|:---------------:|
| `MINIMAL` | 纯 DNS 预解析（零成本） | App 启动 / 尚不知具体业务 | 否 |
| `LIGHT` | 把内核 / 进程首次初始化提前（收益 ~100–500ms，**仅首次**） | 列表曝光 / 已知业务 | 是 |
| `HEAVY` | 离屏池实例加载 `warmupUrl` 热网络缓存 | 强意图（hover / 停留 / 即将点击） | 是 |

> 档位**深度递增且自动补做**：直接调 `HEAVY` 时，SDK 会自动补做未执行的 `MINIMAL`(DNS) 与 `LIGHT`(内核预初始化)；重复调用由内部单调去重挡住，无需客户关心顺序。

### 用法

> **接入位置提示**：`configure()` **可省**——首次 `prewarm` 会以默认配置（`PROD`）懒初始化；仅在需要自定义环境 / UA / 预热语言时才调用。`MINIMAL` 只是纯 DNS 预解析、收益有限，**大多数客户无需在集成方 App 启动（`Application.onCreate` / `didFinishLaunching`）时接预热**——更推荐在**进入签署业务入口前**（列表曝光 / 进详情页）按意图档位 `LIGHT` / `HEAVY` 触发，才是真正的提速点。

```kotlin
// Android Kotlin —— 进入签署业务模块前（如列表页 / 详情页）
// configure() 可省；需自定义环境 / UA / 语言时才调（不必放在 App 启动）
TencentQianWebViewPrewarmer.configure(PrewarmConfig(environment = PrewarmEnvironment.PROD))
// MINIMAL 仅纯 DNS、收益小；真正提速靠 LIGHT / HEAVY，按意图触发即可
// Android LIGHT / HEAVY 需传 context（建池 / 加载 warmup.html 依赖它；缺省静默退化为仅 DNS）
TencentQianWebViewPrewarmer.prewarm(PrewarmLevel.LIGHT, "single-sign", context)
TencentQianWebViewPrewarmer.prewarm(PrewarmLevel.HEAVY, "single-sign", context)
```

```java
// Android Java
TencentQianWebViewPrewarmer.configure(
        PrewarmConfig.builder().environment(PrewarmEnvironment.PROD).build());
TencentQianWebViewPrewarmer.prewarm(PrewarmLevel.LIGHT, "single-sign", context);
TencentQianWebViewPrewarmer.prewarm(PrewarmLevel.HEAVY, "single-sign", context);
```

```swift
// iOS Swift —— 进入签署业务入口前
TencentQianWebViewPrewarmer.configure(PrewarmConfig(environment: .prod))
TencentQianWebViewPrewarmer.prewarm(.light, bizType: "single-sign")
TencentQianWebViewPrewarmer.prewarm(.heavy, bizType: "single-sign")
```

```objc
// iOS Objective-C
TQPrewarmConfig *cfg = [TQPrewarmConfig defaultConfig];
cfg.environment = TQPrewarmEnvironmentProd;
[TQWebViewPrewarmer configure:cfg];
[TQWebViewPrewarmer prewarm:TQPrewarmLevelLight bizType:@"single-sign"];
[TQWebViewPrewarmer prewarm:TQPrewarmLevelHeavy bizType:@"single-sign"];
```

```typescript
// HarmonyOS ArkTS —— 进入签署业务入口前（configure + 轻档）
const cfg = new PrewarmConfig();
cfg.environment = PrewarmEnvironment.PROD;
TencentQianWebViewPrewarmer.configure(cfg);
TencentQianWebViewPrewarmer.prewarm(PrewarmLevel.LIGHT, 'single-sign');

// ⚠️ HarmonyOS HEAVY 需传 uiContext（离屏 Web 组件依赖 UIContext），
//    只能在 onWindowStageCreate 之后取得，故 HEAVY 通常在页面里调用：
TencentQianWebViewPrewarmer.prewarm(PrewarmLevel.HEAVY, 'single-sign', this.getUIContext());
```

> **鸿蒙 `uiContext` 说明**：`MINIMAL`/`LIGHT` 不需要 `uiContext`（可在 `EntryAbility.onCreate` 调）；
> 仅 `HEAVY` 建离屏 Web 组件时必填，须在页面/组件内用 `this.getUIContext()` 取得。缺失时 `HEAVY` 会
> 静默降级（不建池、不影响 `open()`）。Android/iOS 的 `prewarm` 无此参数。

### 触发时机建议

```
App 启动 / 进列表  → MINIMAL（零成本，随便调）
列表曝光 / 已知业务 → LIGHT（中等，控制频率）
hover / 停留 / 即将点击 → HEAVY（重，精准打，勿对所有列表项都上）
```

### 实例池生命周期与主动释放

`open()` 打开容器时透明复用池实例，关闭时按端能力分化处理：

| 端 | 池实例用途 | 页面容器 | 关闭时 |
|----|---------|---------|--------|
| **Android / 鸿蒙** | 池实例可复用到页面（有 `clearHistory`，能清掉预热残留历史后干净归还） | 直接复用池实例 | **默认不销毁**，`about:blank`+清历史后归还池，下次 `open` 复用 |
| **iOS** | **池实例只加载 `warmup.html` 做网络/进程预热，永不复用到页面**（`WKWebView` 无 clear-history API，复用会导致 `warmup.html` 残留在返回历史中且无法清除） | `open()` 每次新建独立 `WKWebView` | 页面 WebView 关闭即销毁；预热收益靠共享 `processPool`（进程复用）+ `.default()` DataStore（`warmup.html` 与真实页同 host，磁盘缓存跨实例命中）承接 |

**默认策略：Android/鸿蒙 回收只归还不销毁**，供后续 `open` 复用。客户在**签署流程结束、不再需要签署**时，应主动调用 `releaseAll()` 及时回收预热态 WebView 占用的内存（鸿蒙每实例约 200MB）。**iOS 无需手动回收页面 WebView**（关闭即销毁），`release()` / `releaseAll()` 仅释放预热池实例：

```kotlin
// Android Kotlin：业务中签署流程彻底结束、后续不再需要时
TencentQianWebViewPrewarmer.releaseAll()   // 或 release() 仅释放池实例、保留去重状态
```

- `release()`：销毁池实例。
- `releaseAll()`：销毁池实例 + 重置去重状态（下次可从头 prewarm）。
- 未 prewarm 时 `open()` 自建自管（创建 + 关闭销毁），与不预热完全一致。

### UA 一致性（复用前提）

池实例按 **`PrewarmConfig` 的 `injectUserAgent` / `userAgentExtra`** 创建并热身 `warmupUrl`。仅当 `open()` 传入的 `TencentQianWebViewConfig` 的 UA 配置**与预热时一致**，才会复用池实例（否则容器自建，避免 `warmupUrl` 缓存 key 不一致而 miss）。**若你在 `open()` 用了自定义 `userAgentExtra`，请在 `PrewarmConfig` 里设置相同的值**：

```kotlin
// Android Kotlin：预热与 open 的 UA 保持一致，才能命中复用
TencentQianWebViewPrewarmer.configure(
    PrewarmConfig(userAgentExtra = "myapp/3.2.1"))
// open 时：
TencentQianWebView.open(context, url, TencentQianWebViewConfig(userAgentExtra = "myapp/3.2.1"), callback)
```

### 嵌入组件的可选池复用（仅 HarmonyOS `TencentQianWebViewComponent`）

嵌入模式（`TencentQianWebViewComponent`）**默认自建自管、不接预热池**（其生命周期由客户 UI 树掌控，`aboutToDisappear` 不等于"签署结束"，默认归还有状态错乱风险）。如确认组件是「一次性、非缓存」的主签署入口，可显式开启 `reusePrewarmPool`：

```typescript
// HarmonyOS ArkTS
TencentQianWebViewComponent({
  url: this.signUrl,
  context: getContext(this) as common.UIAbilityContext,
  reusePrewarmPool: true,   // 默认 false；仅一次性、非缓存的主签署入口才建议开启
  onSignResult: (r) => { /* ... */ }
})
```

### 多语言预热（可选）

签署页支持多语言时，可通过 `PrewarmConfig.preferredLanguage`（如 `"en-US"` / `"zh-CN"`）**显式指定**要预热的语言资源。**多数客户无需设置**——留空即可：

- **Android / iOS**：留空时由 `warmup.html` 自动判定语言（读页面 `localStorage` / 系统语言，与真实签署页共享存储天然对齐）；设了则以 `?lang=` 传入、优先级最高。
- **HarmonyOS**：按 `i18nAutoDetect` 决定——`true` 时用 `preferredLanguage`（留空则用系统语言）、`false` 时恒 `zh-CN`；`localesEnabled` 时预取对应 `locales`。

> ⚠️ **`preferredLanguage` 仅在签署页开启 `i18nAutoDetect` 时生效**。若页面 `i18nAutoDetect=false`（真实页恒渲染 zh-CN），三端都会忽略 `preferredLanguage`、按 zh-CN 预热——这是刻意与真实页对齐，避免预热用不上的语言资源造成缓存 miss。

仅当"页面开启了语言自动探测、且 App 已明确知道用户语言"时才建议设置：

```kotlin
// Android Kotlin：锁定英文预热
TencentQianWebViewPrewarmer.configure(
    PrewarmConfig(environment = PrewarmEnvironment.PROD, preferredLanguage = "en-US")
)
```

```java
// Android Java
TencentQianWebViewPrewarmer.configure(
        PrewarmConfig.builder().preferredLanguage("en-US").build());
```

```swift
// iOS Swift
TencentQianWebViewPrewarmer.configure(PrewarmConfig(preferredLanguage: "en-US"))
```

```objc
// iOS Objective-C
TQPrewarmConfig *cfg = [TQPrewarmConfig defaultConfig];
cfg.preferredLanguage = @"en-US";
[TQWebViewPrewarmer configure:cfg];
```

```typescript
// HarmonyOS ArkTS
const cfg = new PrewarmConfig();
cfg.preferredLanguage = 'en-US';
TencentQianWebViewPrewarmer.configure(cfg);
```

> 语言值须与签署页 `supportedLngs` 一致（当前 `zh-CN` / `en-US`）；不匹配时 SDK 自动前缀归一化（`en-GB`→`en-US`），仍无法匹配则回退 `zh-CN`。

### 环境与注意事项

- **环境**：`PrewarmConfig.environment` 选 `PROD/BETA`（默认 PROD），SDK 自动选对应 manifest；也可传 `manifestUrl` 覆盖。**务必与 `open(url)` 的环境一致**，否则预热白费（SDK 会 log 警告，不阻断）。
- **内存**：`HEAVY` 会常驻一个预热态 WebView（池容量固定 1，`poolSize > 1` 会被 clamp；鸿蒙每实例约 200MB）；内存告警时 SDK 自动释放。**Android/鸿蒙 借还池默认不主动销毁**——签署流程结束后请主动调 `release()` / `releaseAll()` 回收。
- **best-effort**：预热任何失败都静默降级，绝不影响正常签署；不调用预热则完全等同现状。
- **平台差异（无需关心，SDK 内部处理）**：`MINIMAL` 三端纯 DNS；`LIGHT` = 内核/进程首次初始化（鸿蒙 `initializeWebEngine`+预连接、不建池；Android/iOS 离屏预建空实例）；`HEAVY` = 离屏池实例加载 `warmupUrl`（鸿蒙 `NodeContainer`+`BuilderNode` 离屏组件、需 `uiContext`；Android 离屏 WebView；iOS 预热实例加载 `warmup.html` 写共享磁盘缓存）。复用范围：Android/鸿蒙 池实例可复用到页面（跨流程借还）；iOS 池实例只跑 `warmup.html`、不复用到页面，收益靠共享 `processPool`/`.default()` DataStore 承接。

---

## §4 更多阅读

| 文档 | 内容 |
|------|------|
| [integration-checklist.md](./integration-checklist.md) | Android + iOS + HarmonyOS 自检清单（权限/Manifest/Info.plist/module.json5） |
| [qianapp-protocol.md](./qianapp-protocol.md) | `qianapp://` 协议规范（Action/Result 枚举值） |
| [troubleshooting.md](./troubleshooting.md) | 排错决策树 + 已踩坑速查 + FAQ |
| [qianuni-uniapp-guide.md](./qianuni-uniapp-guide.md) | UniApp web-view 接入 |
