Skip to content

Latest commit

 

History

History
824 lines (622 loc) · 30.5 KB

File metadata and controls

824 lines (622 loc) · 30.5 KB

问题分析与经验总结

概述

本项目在将 Web 版 Markdown 阅读器打包为 Tauri v2 桌面应用 + Capacitor 移动应用的过程中,遇到了大量"看似正确但实际不工作"的问题。本文档记录所有 root cause 及教训,避免重蹈覆辙。

完整打包技能文档:见 skills/tauri-capacitor-build.md,包含架构概览、完整 workflow 模板、常见问题速查表、发版流程、检查清单等。


1. 权限标识符张冠李戴(耗时最长)

错误写法

{ "identifier": "fs:allow-read",  "allow": [{ "path": "**" }] }
{ "identifier": "fs:allow-write", "allow": [{ "path": "**" }] }

正确写法

{ "identifier": "fs:allow-read-file",  "allow": [{ "path": "**" }] }
{ "identifier": "fs:allow-write-file", "allow": [{ "path": "**" }] }
{ "identifier": "fs:allow-read-text-file", "allow": [{ "path": "**" }] }

Root Cause

Tauri v2 tauri-plugin-fs 的权限 identifier 与 Rust 命令名一一对应,且命令名非常容易被混淆:

前端调用的命令 所需权限 identifier 容易错写成
plugin:fs|read_file (读取整个文件) fs:allow-read-file fs:allow-read(对应 read 命令——文件描述符式读取,完全不同)
plugin:fs|read_text_file fs:allow-read-text-file ❌ 未加
plugin:fs|write_file (写整个文件) fs:allow-write-file fs:allow-write(对应 write 命令——文件描述符式写入)
plugin:fs|read_dir fs:allow-read-dir ✅ 恰好正确
plugin:fs|exists fs:allow-exists ✅ 恰好正确

自动生成的权限文件位于插件目录下的 permissions/autogenerated/commands/,命名规则为:

  • 命令 read_file → identifier allow-read-file(全称加连字符)

教训

  • 永远不要猜测 permission identifier。必须到插件源码的 permissions/autogenerated/commands/ 下查看实际生成的文件名。
  • fs:allow-readfs:allow-read-file,前者是 "读取文件描述符"的权限,后者才是"读取整个文件"的权限。
  • dialog:allow-confirm 在 v2.7.1 中已废弃,实际是 allow-message 的别名。

2. dialog:default 权限集覆盖不全

现象

dialog.confirm not allowed. Command not found

Root Cause

检查 dialog-2.7.1/permissions/default.toml

permissions = ["allow-message", "allow-save", "allow-open"]

dialog:default 不包含 allow-askallow-confirm。需要显式添加。

教训

  • *:default 权限集不等于"所有权限",只包含最基础的子集。使用前必须检查其定义。

3. Tauri 拖拽事件不可靠

现象

拖拽文件到窗口,tauri://drag-drop / tauri://drag-enter / tauri://drag-leave 事件在 Windows 上不触发。

Root Cause

Tauri v2 的 IPC 拖拽事件实现存在平台兼容性问题,Windows 上经常收不到事件。

解决方案

  1. tauri.conf.json 中设置 "dragDropEnabled": false
  2. 使用 HTML5 原生 DOM 事件:
    • document.addEventListener('dragover', handler)
    • document.addEventListener('drop', handler)
  3. 通过 e.dataTransfer.files 获取 File 对象,用 FileReader 读取

教训

  • Tauri IPC 事件(tauri://*)不是 100% 可靠的,尤其是拖拽相关事件在 Windows 上存在问题。
  • 优先使用 HTML5 原生 API,只有在原生 API 无法满足需求时再退而求其次使用 Tauri IPC。

4. const 声明导致运行时赋值失败

现象

图片格式检测后,isBinary 变量无法更新。

Root Cause

const isBinary = false;
// ... later ...
isBinary = true;  // TypeError: Assignment to constant variable

因为后续检测发现是图片格式,需要将 isBinary 改为 true,但 const 禁止重新赋值。

教训

  • 任何设计为「先检测再可能更新」的标志变量,必须使用 let 而非 const
  • 代码审查时特别注意 const/let 的选择。

5. 异步操作的竞态条件(Service Worker + Session Restore)

现象

Session restore 恢复的是旧的缓存数据,而不是当前正确数据。

Root Cause

Service Worker 清理(caches.delete())是异步操作,而 restoreLastSession() 在 SW 清理完成之前就开始执行。清理和恢复之间没有同步保证。

修复

await swCleanup(); // 先等待清理完成
await restoreLastSession(); // 再恢复

教训

  • 涉及异步操作时,必须显式使用 await 保证执行顺序。
  • 不能假设两个异步函数的执行顺序(即使它们在代码中相邻)。

6. 清空/重置操作遗漏状态字段

现象

点击"清空文档"后,旧文件的导入列表、图片列表、图片索引仍然残留。

Root Cause

clearDocument() 只清空了 state.fileContentstate.fileName 等显示相关字段,但遗漏了:

  • state.importedFiles
  • state.imageFiles
  • state.imageIndex
  • state._currentImgDataUrl

下次打开文件时,这些残留数据会被用于导航和渲染,导致显示异常。

教训

  • 重置函数必须逐一对照 state 对象的所有字段,确保无遗漏。
  • 建议将 state 初始化提取为独立函数,重置时直接调用。

7. 导航功能只覆盖部分文件类型

现象

图片文件有左右箭头导航(navigateImage),但 Markdown/PDF/Word 等其他文件没有。

Root Cause

初始设计只考虑了图片的"同目录兄弟文件"导航,没有实现通用的"导入文件列表"导航。

修复

添加 navigateDoc(idx)updateDocNav() 函数,基于 state.importedFiles 数组实现通用的 prev/next 导航,适用于所有文件类型。

教训

  • 功能设计时应该先考虑通用方案,再为特殊类型(如图片)做特化优化。
  • 导航功能应基于统一的文件列表,而不是为每种文件类型单独实现。

8. pickFile vs pickFiles(本次修复)

现象

左上菜单"打开文件"只能选择一个文件。

Root Cause

FileAPI.pickFile(ACCEPT_EXTS)   // 单文件

应改为:

FileAPI.pickFiles(ACCEPT_EXTS)  // 多文件

还要遍历返回的数组,逐个调用 loadFile()

教训

  • API 命名中 pickFile(单数)和 pickFiles(复数)暗示了它们的区别。
  • 调用前应检查 API 返回类型:pickFile 返回单个对象或 null,pickFiles 返回数组。

9. 图片导航时 readAsArrayBuffer 返回空缓冲区

现象

导航到某些同目录图片时显示空白。

Root Cause

navigateImage()readAsArrayBuffer 返回了 byteLength = 0 的缓冲区,但没有做有效性检查,直接传入渲染函数。

修复

if (!raw || raw.byteLength === 0) {
  showToast('无法加载图片');
  return;
}

教训

  • 所有 I/O 操作结果都必须做空值/空长度检查。
  • "forbidden path" 和"空缓冲区"是不同的问题,需要分别处理。

总结检查清单

在 Tauri v2 项目中遇到"功能不工作"时,按以下顺序排查:

  1. 权限检查 — 确认 permission identifier 与实际调用的 Rust 命令名完全匹配(到插件源码 permissions/autogenerated/commands/ 下核实)
  2. scope 检查 — scope 中的 allow/deny 路径模式是否正确(** 通配)
  3. *:default 检查 — 查看该插件的 default.toml 确认默认权限集实际包含哪些子权限
  4. 命令名检查 — 前端 plugin:<plugin>|<command> 中的 command 名是否与 Rust 端 #[tauri::command] 函数的名称一致
  5. console 日志 — 用 _d() 输出关键变量的值(如权限、路径、缓冲区长度),打开调试面板对比
  6. 异步顺序 — 检查 async/await 执行链,确认没有遗漏 await
  7. 常量和变量 — 确认需要修改的标志变量用 let 而非 const
  8. 状态完整性 — 重置函数应逐个字段清空,最好用独立的初始化函数
  9. 通用 vs 特化 — 优先实现通用方案(如统一导航),再为特殊类型做优化

10. Cargo 镜像源导致 CI 打包超时

现象

GitHub Actions macOS/Linux runner 打包时 curl SSL 连接超时。

Root Cause

src-tauri/.cargo/config.toml 配置了 rsproxy.cn(国内镜像源)作为 crates.io 替代。GitHub runner 位于海外,无法访问该镜像。

教训

  • 镜像源配置不要提交到仓库.cargo/config.toml 属于开发者本地环境配置,应加入 .gitignore
  • 需要镜像的开发者在本地单独配置 ~/.cargo/config.toml(全局)或 src-tauri/.cargo/config.toml(项目级但不入库)。

11. Capacitor 升级后 npm ci 失败(lock 文件不同步)

现象

npm ci 报错 package.json and package-lock.json are in sync. Missing: @capacitor/ios from lock file

Root Cause

package.json 中新增了 @capacitor/ios 依赖,但没有在本地执行 npm install 更新 package-lock.jsonnpm ci 要求两者严格一致。

解决方案

  • CI 中用 npm install 代替 npm ci(会自动更新 lock 文件),或将新依赖从 package.json 移除,在 CI 中内联安装。
  • 最佳实践:移动端平台依赖(@capacitor/ios@capacitor/android)不要写入 package.json,在 CI 中按需安装即可。

12. Android Gradle 编译需要 Java 21+

现象

gradlew assembleRelease 报错 invalid source release: 21

Root Cause

Capacitor v8 / Gradle 8.14+ 要求 Java 21+,CI 中配置的 Java 17 不够。

教训

  • Capacitor 大版本升级后,检查 Gradle 和 Java 版本要求。
  • CI 中 Android 构建配置固定 java-version: 21

13. Android gradlew 缺少执行权限

现象

./gradlew: Permission denied

Root Cause

Git 在 Windows 上克隆时不会保留 Unix 可执行权限位。CI runner(Linux/macOS)需要显式 chmod +x

教训

  • 在 CI 构建 Android 前加 chmod +x gradlew

14. Capacitor v8 iOS 不再使用 CocoaPods

现象

pod install 报错 No Podfile found in the project directory

Root Cause

Capacitor v8 的 iOS 项目改用 Xcode 原生项目管理,不再生成 Podfile,也不再使用 .xcworkspace,而是直接使用 .xcodeproj

正确构建命令

xcodebuild -project App.xcodeproj \
  -scheme App \
  -configuration Release \
  -sdk iphoneos \
  -archivePath $PWD/build/App.xcarchive \
  CODE_SIGNING_REQUIRED=NO \
  CODE_SIGNING_ALLOWED=NO \
  archive

教训

  • Capacitor 大版本升级后,iOS 构建方式可能根本性变化,必须查阅对应版本文档。

15. iOS xcarchive 是目录,不是文件

现象

Release 页面上传了几十个内部文件(Info.plist、App、js 等),体积巨大。

Root Cause

.xcarchive 是一个目录(Xcode archive bundle),upload-artifact 遍历目录上传了所有内容。Release 的 ios-app/**/* glob 进一步递归展开了所有文件。

解决方案

构建后先压缩再上传:

cd ios/App/build
zip -r ios-app.zip App.xcarchive

教训

  • macOS 的 .xcarchive.app.framework 都是目录,上传前必须 zip。

16. GitHub Actions tag 触发机制

现象

git push origin main --tags 后工作流未触发。

Root Cause

git push --tags 只推送本地有但远程没有的 tag。如果 tag 已存在(之前已推送),不会产生新的 push 事件,工作流不会触发。

解决方案

  • 新建 tag:git tag v1.0.2 && git push origin v1.0.2
  • 或在 Actions 页面手动触发(workflow_dispatch

17. 多个 workflow 共享 Release 导致冲突

现象

两个 workflow 各自创建 Release,产物混在一起或冲突。

解决方案

将桌面端(Tauri)和移动端(Capacitor)构建合并到同一个 workflow 文件中,用 needs: [build, android, ios] 统一等待所有构建完成后创建 Release。


CI/CD 配置检查清单

在 GitHub Actions 中搭建 Tauri + Capacitor 多平台构建时,逐项确认:

  1. Cargo 镜像 — 项目内无 .cargo/config.toml 中的镜像源配置
  2. package-lock.json — 与 package.json 同步(本地跑 npm install 确认)
  3. Java 版本 — Android 用 Java 21+(Capacitor v8 要求)
  4. gradlew 权限 — 构建前 chmod +x gradlew
  5. iOS 构建 — Capacitor v8 用 -project App.xcodeproj(非 -workspace App.xcworkspace),不需要 pod install
  6. xcarchive — 上传前 zip 压缩,避免上传内部文件
  7. Artifact 路径 — 精确到文件,避免 glob 匹配到目录内容
  8. Release 触发 — 打新 tag 或手动触发,旧 tag 不会重新触发
  9. 统一 Release — 多个构建 job 合并到一个 workflow,用 needs 串联
  10. npm ci vs npm install — CI 中优先用 npm ci,若依赖不同步则用 npm install
  11. Shell 兼容性 — Windows runner 默认 PowerShell,含 bash 语法的步骤必须加 shell: bash
  12. APK 签名 — CI 每次环境不同,debug keystore 需用 actions/cache 缓存保证签名一致
  13. 版本号自动化 — 从 git tag 自动提取版本号,CI 中用 Node.js 同步更新所有配置文件,避免手动改版本
  14. NSIS 配置 — Tauri v2 内置 NSIS,无需外部安装;保留 wix 配置,NSIS 和 WiX 配置互不影响
  15. 版本号格式 — 回退版本号必须符合 WiX 最严格要求(纯数字),不要用 0.0.0-dev,用 0.0.0

18. Android WebView 不支持 iframe + blob URL 渲染 PDF

现象

APK 打开 PDF 后不报错,但内容为空白。

Root Cause

renderPdf() 使用 <iframe> + blob: URL 方案,依赖浏览器内置 PDF 查看器。Android WebView 没有内置 PDF 查看器,blob URL 无法渲染。

解决方案

集成 pdf.js(Mozilla PDF.js),在 Capacitor 环境下用 <canvas> 逐页渲染:

if (FileAPI.platform === 'capacitor') {
  // 使用 pdf.js 渲染
  const pdfjsLib = await import(baseUrl + 'lib/pdf.min.mjs');
  // ... 渲染到 canvas
} else {
  // 桌面端继续用 iframe
}

教训

  • 不要假设所有平台都有相同的浏览器能力。Android WebView 是精简版 Chromium,缺少很多桌面 Chrome 的功能。
  • 移动端文件格式渲染(PDF/Word/Excel)必须有独立的渲染方案,不能依赖浏览器内置能力。
  • pdf.js 的 ESM 格式需要用 import() 动态加载,路径要用绝对路径(baseUrl + 'lib/pdf.min.mjs'),相对路径在 Capacitor 中可能解析失败。

19. PDF 渲染分辨率需要考虑 devicePixelRatio

现象

PDF 在手机上能显示但分辨率很低,文字模糊看不清。

Root Cause

渲染 scale 只考虑了屏幕宽度,没有乘以 window.devicePixelRatio。手机屏幕 DPI 通常是 2-3x,不乘以 dpr 会导致渲染分辨率不足。

修复

var scale = baseScale * (window.devicePixelRatio || 1);

教训

  • 移动端 canvas 渲染必须考虑 devicePixelRatio,否则在高 DPI 屏幕上模糊。
  • 桌面端 devicePixelRatio 通常为 1,移动端通常为 2-3。

20. APK 签名一致性需要缓存 keystore

现象

每次 CI 构建的 APK 签名不同,无法覆盖安装升级。

Root Cause

CI 每次运行都是全新环境,debug keystore 由 keytool 新生成,导致签名不一致。

解决方案

使用 actions/cache@v4 缓存 ~/.android/debug.keystore

- name: Setup debug keystore
  uses: actions/cache@v4
  with:
    path: ~/.android/debug.keystore
    key: android-debug-keystore

- name: Generate debug keystore (if not cached)
  run: |
    if [ ! -f "$HOME/.android/debug.keystore" ]; then
      keytool -genkeypair -v -keystore $HOME/.android/debug.keystore ...
    fi

同时在 build.gradle 中配置固定签名路径:

signingConfigs {
    debug {
        storeFile file(System.getenv("ANDROID_KEYSTORE") ?: "${System.getProperty('user.home')}/.android/debug.keystore")
    }
}

教训

  • CI 环境中任何「自动生成」的文件(keystore、证书、配置)都需要缓存或固定,否则每次构建结果不一致。
  • debug keystore 是 APK 签名的基础,必须保证跨构建一致性。

21. 自动版本号管理(从 tag 提取)

现象

每次发版都要手动修改 tauri.conf.jsonCargo.tomlpackage.json 三个文件的版本号,容易遗漏或不同步。

解决方案

在 CI 中自动从 git tag 提取版本号,构建前同步更新所有配置文件:

- name: Resolve version
  id: ver
  shell: bash
  run: |
    echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT

- name: Sync version to config files
  shell: bash
  run: |
    VERSION="${{ steps.ver.outputs.version }}"
    node -e "
      const fs = require('fs');
      const tauri = JSON.parse(fs.readFileSync('src-tauri/tauri.conf.json','utf8'));
      tauri.version = '$VERSION';
      fs.writeFileSync('src-tauri/tauri.conf.json', JSON.stringify(tauri, null, 2));
    "

发版只需:git tag v1.0.9 && git push origin v1.0.9

教训

  • 版本号是「单一事实来源」(Single Source of Truth),应该只从一个地方(git tag)派生。
  • 用 Node.js 更新 JSON 文件比 sed 更可靠(跨平台兼容)。
  • workflow_dispatch 可以加 inputs.version 参数支持手动指定版本号。

22. Windows runner shell 兼容性

现象

Windows 构建 job 报错 Missing '(' after 'if' in if statement

Root Cause

GitHub Actions Windows runner 默认使用 PowerShell,而 shell 脚本用的是 bash 语法。没有指定 shell: bash 时,PowerShell 无法解析 bash 语法。

修复

所有包含 bash 语法的步骤必须显式声明 shell: bash

- name: Resolve version
  shell: bash    # 必须加!
  run: |
    if [ ... ]; then

教训

  • GitHub Actions 的 shell 默认值因 runner 而异:Ubuntu/macOS 默认 bash,Windows 默认 pwsh
  • 跨平台 workflow 中,所有 shell 脚本步骤都应该显式声明 shell: bash,不要依赖默认值。
  • run: | 块中的 bash 语法在 PowerShell 中会直接报错,不会被跳过。

23. PDF 查看器需要基本交互功能

现象

PDF 能显示但无法缩放、翻页,用户体验极差。

Root Cause

初版只渲染了 canvas 页面,没有添加任何交互控件。移动端用户无法缩放查看细节,也无法快速跳转页面。

解决方案

添加完整的 PDF 查看器工具栏:

  • 翻页:上一页 / 下一页按钮 + 滚动时自动更新页码
  • 缩放:放大 / 缩小按钮 + 适应宽度按钮
  • 页码:显示当前页 / 总页数
  • 关闭:关闭 PDF 查看器返回主界面

教训

  • 「能用」和「好用」是两回事。文件格式支持不能只停留在「能渲染」,必须有基本的阅读体验。
  • 移动端 PDF 查看器的核心功能:缩放、翻页、页码指示。缺少任何一个都严重影响体验。
  • 工具栏应该固定在顶部(position:fixed),不随页面滚动。

移动端文件格式支持检查清单

在 Tauri + Capacitor 多平台项目中支持文件格式时,逐项确认:

  1. PDF — 桌面端用 iframe + blob URL,移动端必须用 pdf.js
  2. PDF 分辨率 — 渲染 scale 必须乘以 devicePixelRatio
  3. PDF 交互 — 必须有缩放、翻页、页码显示
  4. PDF 双指缩放 — 动态修改 viewport(user-scalable=yes)+ 手动监听 touch 事件 + CSS transform 实时预览
  5. PDF 标注 — 双 canvas 架构(渲染层 + 标注层),标注坐标基于 canvas 内部坐标
  6. PDF 便签 — 用 HTML div 元素(非 canvas),支持事件和 tooltip
  7. Word/Excel/PPT — 桌面端用 mammoth/docx-preview/xlsx/pptxjs,移动端需验证兼容性
  8. 图片 — 各平台原生支持,但大图需要压缩处理
  9. 文件读取 — Capacitor 的 readAsArrayBuffer_uri 路径支持不完整,需要特殊处理
  10. 文件选择 — Capacitor 回退到 Web <input type="file">,不支持目录选择
  11. 保存文件 — Capacitor 不支持原生保存对话框,需要回退到 Blob 下载

24. WiX/MSI 版本号预发布标识必须是纯数字

现象

MSI 构建报错:optional pre-release identifier in app version must be numeric-only and cannot be greater than 65535 for msi target

Root Cause

WiX/MSI 对版本号格式有严格要求:主版本.次版本.修订号[.预发布],其中预发布标识必须是纯数字(且 ≤ 65535)。0.0.0-dev 中的 dev 是非数字字符串,WiX 拒绝接受。NSIS 没有这个限制。

解决方案

CI 中非 tag 触发时的版本回退值从 0.0.0-dev 改为 0.0.0(纯数字):

- name: Resolve version
  shell: bash
  run: |
    if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.version }}" ]; then
      echo "version=${{ inputs.version }}" >> $GITHUB_OUTPUT
    elif [[ "${GITHUB_REF}" == refs/tags/v* ]]; then
      echo "version=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
    else
      echo "version=0.0.0" >> $GITHUB_OUTPUT  # 不是 0.0.0-dev!
    fi

教训

  • WiX 版本号规则:MAJOR.MINOR.BUILD[.PRERELEASE],PRERELEASE 部分只能是数字。
  • NSIS 对版本号宽容得多,所以只构建 NSIS 时不会发现这个问题。
  • 项目中所有版本号回退值都必须检查是否符合最严格的格式要求。

25. tauri.conf.json 中 wix 配置不能删除

现象

恢复了版本号后 MSI 仍然构建失败,报各种 WiX 相关错误。

Root Cause

v1.0.6 提交时为了配置 NSIS 选项,把 tauri.conf.json 中的整个 windows 块替换成了:

"windows": {
  "nsis": { "displayLanguageSelector": false, "installerIcon": "icons/icon.ico" }
}

删除了原有的 "wix": { "language": "zh-CN" } 配置。没有 wix 配置后,Tauri 构建 MSI 时走了不同的代码路径导致失败。

正确写法

NSIS 和 WiX 配置需要同时存在:

"windows": {
  "nsis": {
    "displayLanguageSelector": false,
    "installerIcon": "icons/icon.ico"
  },
  "wix": {
    "language": "zh-CN"
  }
}

教训

  • tauri.conf.jsonbundle.windowsnsiswix 是独立的配置节,互不影响,不能互相替代。
  • 删除任何配置前,先确认该配置是否影响其他构建目标。

26. Android WebView 中 viewport meta 禁止双指缩放

现象

APK 中 PDF 能正常显示,但双指缩放手势无反应。

Root Cause

<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">

maximum-scale=1.0, user-scalable=no 直接禁止了 WebView 中的所有缩放手势,包括双指缩放。

解决方案

PDF 查看器打开时动态修改 viewport,关闭时恢复:

// 保存原始值
var origViewport = document.querySelector('meta[name="viewport"]');
var origContent = origViewport.content;
// PDF 查看器打开时
origViewport.content = 'width=device-width, initial-scale=1.0, minimum-scale=0.5, maximum-scale=5.0, user-scalable=yes';
// PDF 查看器关闭时
origViewport.content = origContent;

教训

  • user-scalable=no 在移动端 web app 中常见,但会阻止所有缩放手势。
  • 如果只需要在特定页面允许缩放,应该动态修改 viewport 而不是全局放开。
  • 动态修改 viewport 是合法的,WebView 会实时响应变化。

27. Canvas 渲染的 PDF 需要手动处理双指缩放

现象

即使 viewport 允许缩放了,PDF canvas 内容仍然不会跟随双指手势缩放。

Root Cause

viewport 缩放作用于整个页面(所有元素等比放大),而 PDF 是用 canvas 渲染的固定分辨率图像。用户期望的是 PDF 内容本身缩放(类似原生 PDF 阅读器),不是整个 UI 放大。

解决方案

手动监听双指触摸事件,计算缩放比例:

var pinchState = null;
var pinchStartScale = currentScale;

// touchstart: 记录初始双指距离
scrollContainer.addEventListener('touchstart', function(e) {
  if (e.touches.length === 2) {
    e.preventDefault();
    var dx = e.touches[0].clientX - e.touches[1].clientX;
    var dy = e.touches[0].clientY - e.touches[1].clientY;
    pinchStartScale = currentScale;
    pinchState = { dist: Math.sqrt(dx * dx + dy * dy) };
  }
}, { passive: false });

// touchmove: 用 CSS transform 实时预览(零延迟)
scrollContainer.addEventListener('touchmove', function(e) {
  if (e.touches.length === 2 && pinchState) {
    e.preventDefault();
    var dx = e.touches[0].clientX - e.touches[1].clientX;
    var dy = e.touches[0].clientY - e.touches[1].clientY;
    var dist = Math.sqrt(dx * dx + dy * dy);
    var ratio = dist / pinchState.dist;
    var previewScale = Math.min(Math.max(pinchStartScale * ratio, 0.25), 5);
    var ratioCSS = previewScale / currentScale;
    pagesContainer.style.transform = 'scale(' + ratioCSS + ')';
    document.getElementById('pdfZoomLevel').textContent = Math.round(previewScale * 100) + '%';
    pinchPending = previewScale;
  }
}, { passive: false });

// touchend: 松手后用新 scale 重新渲染 canvas(保证清晰度)
scrollContainer.addEventListener('touchend', function(e) {
  if (e.touches.length < 2 && pinchState) {
    pagesContainer.style.transform = '';
    if (pinchPending && Math.abs(pinchPending - currentScale) > 0.01) {
      currentScale = pinchPending;
      renderAllPages();  // 重新渲染 canvas
    }
    pinchState = null;
    pinchPending = false;
  }
});

关键技巧

  • 双指拖动过程中用 CSS transform:scale() 实时预览(GPU 加速,零延迟)
  • 松手后才用新 scale 重新渲染 canvas(保证高清晰度,但有短暂渲染时间)
  • 这种"CSS 实时预览 + canvas 重新渲染"的两阶段策略是移动端 PDF 缩放的最佳实践

教训

  • viewport 缩放和内容缩放是两个不同的概念,不能混淆。
  • canvas 元素不会自动跟随 viewport 缩放,需要手动处理触摸事件。
  • e.preventDefault() + { passive: false } 是拦截触摸事件的必要配置。
  • 渲染所有页面的 canvas 是耗时操作,只在手势结束时执行,不能在手势过程中执行。

28. PDF 标注/便签等交互功能的架构设计

设计决策

在 canvas 上叠加 annotation overlay(另一个 canvas),而不是直接在 PDF canvas 上绘制:

  • PDF canvas:只读渲染,缩放时完全重绘
  • Annotation canvas:独立的半透明层,用绝对定位覆盖在 PDF canvas 上
  • 便签 marker:HTML 元素(div),用绝对定位放在页面容器上

为什么用双 canvas 而不是单 canvas

  1. 缩放时需要重绘 PDF,annotations 不需要重新计算坐标
  2. 清除标注不需要重新渲染 PDF 内容
  3. 标注的坐标基于 PDF 原始尺寸(devicePixelRatio 缩放前),缩放时自动对齐

教训

  • 交互式 canvas 应用(标注、绘图)应该用多层 canvas,渲染层和交互层分离。
  • 便签/标记等 UI 元素用 HTML div 比 canvas 更方便(支持事件、样式、tooltip)。
  • 标注坐标存储时应使用 canvas 内部坐标(高分辨率),而不是 CSS 像素坐标。

29. 全平台内容双指缩放实现

现象

只有 PDF 查看器支持双指缩放,其他格式(Markdown/HTML/TXT/图片等)无法双指缩放。

Root Cause

双指缩放只在 PDF 查看器的 wrapper 上实现了触摸事件处理,通用内容区域(contentArea)没有对应的实现。

解决方案

contentArea 上添加触摸事件监听,复用已有的 applyZoomreapplyZoom 函数:

contentArea.addEventListener('touchstart', function(e) {
  if (e.touches.length === 2 && !document.getElementById('pdfViewerWrapper')) {
    e.preventDefault();
    gPinchStartZoom = state.zoomLevel;
    gPinchState = { dist: Math.sqrt(dx * dx + dy * dy) };
  }
}, { passive: false });

contentArea.addEventListener('touchmove', function(e) {
  if (e.touches.length === 2 && gPinchState) {
    e.preventDefault();
    var target = mdContent.style.display !== 'none' ? mdContent : htmlFrame;
    target.style.transform = 'scale(' + previewZoom + ')';
    target.style.transformOrigin = 'top left';
  }
}, { passive: false });

关键技巧

  • 检查 !document.getElementById('pdfViewerWrapper') 避免与 PDF 查看器的缩放冲突
  • 复用 state.zoomLevelreapplyZoom() 保持与按钮缩放一致
  • CSS transform 实时预览 + 松手后持久化到 state.zoomLevel

教训

  • 功能扩展时要检查是否遗漏了其他内容类型。
  • 复用已有状态管理(state.zoomLevel)比创建独立状态更可靠。

30. 自动恢复会话去掉确认弹窗

现象

每次打开应用都弹出"恢复上次阅读?"确认框,用户需要手动点击"恢复"。

Root Cause

restoreLastSession() 函数中使用 openSheet() 显示确认对话框,等待用户选择。

解决方案

去掉确认对话框,直接恢复:

  • 有缓存内容(IndexedDB)→ 直接渲染
  • 无缓存(二进制文件)→ 在空状态显示"恢复上次阅读"按钮(showRestoreButtonIfAvailable

教训

  • 移动端用户体验要求"打开即用",减少确认步骤。
  • 二进制文件(PDF/图片)无法缓存到 IndexedDB,只能提示重新选择文件。

31. 版本号同步需要覆盖所有配置文件

现象

CI 自动更新了 tauri.conf.jsonCargo.tomlpackage.json 的版本号,但应用内"关于"对话框仍显示硬编码的 v2.0

Root Cause

index.html 中有三个硬编码的版本号:

  • About 对话框:v2.0
  • __BUILD_ID__20260619-0500
  • HTML 中的 buildId div:20260619-0500

CI 的版本同步脚本没有覆盖 index.html

解决方案

  1. index.html 中定义 __APP_VERSION__ 占位符:
const __APP_VERSION__ = '__APP_VERSION__';
  1. About 对话框使用动态版本:
v${typeof __APP_VERSION__ !== 'undefined' && __APP_VERSION__ !== '__APP_VERSION__' ? __APP_VERSION__ : 'dev'}
  1. CI 同步脚本增加 index.html 替换:
let html = fs.readFileSync('public/index.html', 'utf8');
html = html.replace(/__APP_VERSION__/g, '$VERSION');
fs.writeFileSync('public/index.html', html);
  1. 启动时更新 buildId div 的文本内容。

教训

  • 版本号是「单一事实来源」,所有显示版本的地方都必须从同一来源派生。
  • 新增配置文件时,检查 CI 同步脚本是否覆盖了该文件。
  • 占位符替换比硬编码更可靠,但要确保所有 CI job 都执行替换。