普通视图

发现新文章,点击刷新页面。
今天 — 2026年4月4日掘金 前端

从“瞎猫碰死耗子”到彻底通透:一个前端开发者的闭包渡劫实录

作者 荒野码农
2026年4月3日 23:07

一、缘起:一次“反直觉”的Vuex重构

几年前,我在维护一个多版本并行的SaaS项目时,踩到了一个经典的坑:两个不同版本的页面共用同一个Vuex Store,导致修改A版本的数据,B版本也跟着“抽风”。

当时的我,根本不懂什么是闭包,只知道“两个页面不该共享同一份数据”。于是,我凭着直觉写了一个“工厂函数”:

function createVersionStore() {
  return {
    state: { data: {} },
    mutations: { ... }
  };
}

// 为每个版本创建独立的Store实例
const storeV1 = new Vuex.Store(createVersionStore());
const storeV2 = new Vuex.Store(createVersionStore());

当时只觉得“这样就能隔离数据了”,完全没想到,这个“灵光一闪”的操作,竟然暗合了闭包最精髓的设计模式——函数工厂

直到今天,当我彻底搞懂闭包后,才恍然大悟:原来当年那个“瞎猫碰死耗子”的解决方案,正是闭包在工程化开发中的最佳实践。

二、闭包到底是什么?

很多教程会把闭包讲得很玄乎,什么“函数套函数”“作用域链”“垃圾回收”……但对我来说,真正理解闭包,是从三个关键认知突破开始的:

认知突破1:闭包不是“刻意写的”,而是“自然形成的”
闭包的本质,是函数 + 函数被创建时的环境。只要一个函数能访问到它外部作用域的变量,闭包就自动形成了。

最经典的例子:

function outer() {
  let count = 0;
  return function inner() {
    count++;
    console.log(count);
  };
}

const counter = outer();
counter(); // 1
counter(); // 2

这里,inner函数“记住”了outer里的count变量,即使outer已经执行完毕。这就是闭包——函数带着它的“背包”(外部环境)去流浪。

认知突破2:闭包的“生死”由引用决定
闭包之所以能“长生不老”,是因为内部函数持有了外部变量的引用。只要内部函数还活着,外部变量就不会被垃圾回收。

但这也带来了内存泄漏的风险。比如:

function leak() {
  let bigData = new Array(1000000).fill('data');
  return function() {
    console.log('hello'); // 没用到bigData,但bigData仍被闭包持有
  };
}

const fn = leak();
// bigData永远无法被回收,除非fn = null

所以,闭包的“销毁”很简单:断开所有对内部函数的引用,垃圾回收器会自动清理。

认知突破3:闭包不是单例,每次调用都是“新实例”
这是我最容易混淆的点。很多人以为闭包是“全局共享”的,其实不然。

每次调用外部函数,都会创建一套全新的闭包环境。就像工厂生产产品,每次createCounter()都会生成一个独立的计数器:

const c1 = createCounter();
const c2 = createCounter();

c1(); // 1
c2(); // 1(互不干扰!)

这正是我当年Vuex方案的底层原理——用闭包实现数据隔离

三、闭包的三大“陷阱”与破解之道

理解了原理,还要知道闭包在实际开发中的“坑”。

陷阱1:循环中的闭包(var的诅咒)

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 1000);
}
// 输出:3, 3, 3(而不是0,1,2

原因:var是函数作用域,所有定时器共享同一个i。循环结束时i=3,所以都输出3。

解决方案:用let(块级作用域)或立即执行函数(IIFE):

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 1000);
}
// 输出:0, 1, 2

陷阱2:React Hooks的“闭包陷阱”
在React中,闭包会“记住”组件渲染时的状态快照:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      console.log(count); // 永远是0!
      setCount(count + 1);
    }, 1000);
  }, []); // 依赖为空,只执行一次
}

原因:useEffect只在首次渲染时执行,闭包捕获的是count=0的快照。后续状态更新不会重新创建闭包。

解决方案:用函数式更新setCount(c => c + 1),或把count加入依赖数组。

陷阱3:this指向丢失
闭包不保存this,只保存词法作用域:

const obj = {
  name: '张三',
  getName: function() {
    return function() {
      console.log(this.name); // undefined(this指向window)
    };
  }
};

obj.getName()();

解决方案:用箭头函数或that = this保存上下文。

四、闭包的终极真相:环境引用 vs 值快照

这是我最深的认知突破。

很多人(包括曾经的我)以为闭包捕获的是“变量的副本”,其实不完全对。

真相是:闭包捕获的是“环境的引用”,但对基本类型表现为“值快照”,对引用类型表现为“共享数据”。

  • 基本类型(数字、字符串) :闭包像是拍了一张“快照”,后续外部变化不影响闭包内的值。
  • 引用类型(对象、数组) :闭包持有的是“指针”,修改对象会影响所有持有该引用的闭包。

但关键在于:如果对象是在外部函数内部创建的,每次调用都会生成新对象,闭包之间依然隔离。

这正是我当年Vuex方案的精髓——用函数工厂生成独立的数据空间

五、闭包的工程化价值:从理论到实践

闭包不是面试造火箭的玩具,而是解决实际问题的利器:

  • 数据私有化:模拟私有变量,防止外部污染。
  • 模块化开发:Vuex/Pinia的Store工厂、Webpack的模块系统,底层都是闭包。
  • 函数柯里化:动态生成定制函数。
  • 事件处理与回调:保存上下文状态。

最让我感慨的是,当年那个“不懂闭包”的我,凭着“不想写重复代码”的直觉,竟然写出了符合行业标准的解决方案。这说明,好的工程直觉,往往比死记硬背理论更重要

六、结语:闭包不是魔法,是思维工具

闭包不是什么神秘的魔法,它只是JavaScript函数作用域的自然结果。理解闭包,不是为了应付面试,而是为了写出更健壮、更可维护的代码。

从“瞎猫碰死耗子”到“彻底通透”,我的闭包之旅告诉我:真正的掌握,不是记住定义,而是能在实际问题中识别并运用它

希望我的经历,能帮你少走一些弯路。毕竟,闭包这东西,一旦通了,就再也回不去了。


互动话题:你在项目中用过闭包解决过什么实际问题?欢迎在评论区分享!

参考资料:MDN闭包文档、Vue/Pinia源码、JavaScript高级程序设计

作者:一个从Vuex工厂函数悟出闭包真谛的前端开发者

Tauri Android 打包原理与实战指南

2026年4月3日 21:18

Tauri Android 打包原理与实战指南

基于 JoyaLand 项目的实际打包经验整理,记录原理、流程与踩坑解决方案。


一、Tauri Android 打包架构原理

1.1 整体架构

┌─────────────────────────────────────────────┐
│              JoyaLand Android App            │
├──────────────────┬──────────────────────────┤
│   前端层 (WebView) │   原生层 (Rust/JNI)      │
│  ┌────────────┐  │  ┌────────────────────┐  │
│  │ HTML/CSS   │  │  │  tauri-android     │  │
│  │ JavaScript │◄─┼─►│  (Kotlin/Java)     │  │
│  │ Canvas 2D  │  │  ├────────────────────┤  │
│  └────────────┘  │  │  libapp_lib.so     │  │
│                  │  │  (Rust 编译产物)    │  │
└──────────────────┴──────────────────────────┘
         ▲
         │ Vite 构建的静态资源
         │ (dist/ 目录)

Tauri Android 的核心思路:

  • 前端:使用系统 WebView 渲染 HTML/JS/CSS(无需打包 Chromium)
  • 后端:Rust 代码编译为 .so 动态库,通过 JNI 被 Kotlin/Java 调用
  • 桥接:Tauri 的 IPC 机制连接前后端(命令调用、事件通知)

1.2 构建工具链

Vite (前端构建)
    ↓ 生成 dist/
Cargo (Rust 编译)
    ↓ 交叉编译 4 个 ABI 的 .so 文件
Gradle (Android 构建)
    ↓ 打包 APK/AAB
apksigner (APK 签名)
    ↓
最终 APK

1.3 支持的 Android ABI

ABI Rust Target 对应设备
arm64-v8a aarch64-linux-android 现代 64 位 ARM 手机(主流)
armeabi-v7a armv7-linux-androideabi 旧款 32 位 ARM 手机
x86 i686-linux-android 模拟器(32 位)
x86_64 x86_64-linux-android 模拟器(64 位)/ 部分平板

二、环境准备

2.1 必要工具

工具 作用 安装来源
Rust + Cargo 编译原生代码 rustup.rs
Android SDK Android 构建工具 Android Studio / 命令行
Android NDK 交叉编译工具链 SDK Manager
JDK 8+ Gradle 运行环境 Oracle / OpenJDK
Gradle Android 构建系统 gradle.org 或系统安装
Node.js + npm 前端依赖管理 nodejs.org

2.2 Rust Android 编译目标

rustup target add aarch64-linux-android
rustup target add armv7-linux-androideabi
rustup target add i686-linux-android
rustup target add x86_64-linux-android

2.3 环境变量

# Windows PowerShell
$env:ANDROID_HOME = "D:\Android\Sdk"
$env:PATH += ";D:\Android\Sdk\platform-tools"

三、打包完整流程

3.1 初始化 Android 项目

npx tauri android init

执行后会:

  1. 检测 ANDROID_HOME 环境变量,找到 SDK 路径
  2. 自动检测并使用已安装的 NDK(如 D:\Android\Sdk\ndk\29.0.x
  3. 安装 4 个 Android Rust 编译目标(如未安装)
  4. src-tauri/gen/android/ 生成 Android Gradle 项目

生成的目录结构:

src-tauri/gen/android/
├── app/
│   ├── build.gradle.kts
│   ├── src/main/
│   │   ├── java/com/liupe/joyaland/generated/  ← Tauri 自动生成的 Kotlin 桥接代码
│   │   └── jniLibs/                            ← 编译好的 .so 文件(symlink)
│   └── proguard-rules.pro
├── buildSrc/                                   ← 包含自定义 BuildTask(调用 Cargo)
├── gradle/wrapper/
│   └── gradle-wrapper.properties               ← Gradle 版本配置
├── gradlew.bat                                 ← Gradle Wrapper 启动脚本
└── settings.gradle

3.2 构建 APK

$env:ANDROID_HOME = "D:\Android\Sdk"
npx tauri android build --apk

构建过程分为以下阶段:

阶段 1:前端构建
npm run build → Vite → dist/
  • 执行 tauri.conf.json 中配置的 beforeBuildCommand
  • 生成静态资源到 dist/ 目录
阶段 2:Rust 交叉编译(最耗时)
Cargo → 4 个 .so 文件
  • 为每个 ABI 分别运行 cargo build --release --target <abi>
  • 使用 NDK 中的交叉编译工具链(如 aarch64-linux-android24-clang
  • 首次编译需要 20-60 分钟,后续增量编译 1-5 分钟

编译产物位置:

src-tauri/target/aarch64-linux-android/release/libapp_lib.so
src-tauri/target/armv7-linux-androideabi/release/libapp_lib.so
src-tauri/target/i686-linux-android/release/libapp_lib.so
src-tauri/target/x86_64-linux-android/release/libapp_lib.so
阶段 3:Gradle 打包
gradlew assembleUniversalRelease → APK
  • Gradle 调用 buildSrc 中的 BuildTask 触发各 ABI 的 Rust 编译
  • Kotlin 代码编译(Tauri 桥接层)
  • 资源合并、R8 代码压缩混淆
  • 打包为 APK

输出位置:

src-tauri/gen/android/app/build/outputs/apk/universal/release/
    app-universal-release-unsigned.apk
阶段 4:APK 签名
# 1. 生成签名密钥(只需一次)
keytool -genkey -v -keystore joyaland-release.keystore `
  -alias joyaland -keyalg RSA -keysize 2048 -validity 10000 `
  -storepass <密码> -keypass <密码> `
  -dname "CN=JoyaLand, OU=Dev, O=liupe, L=Beijing, S=Beijing, C=CN"

# 2. 签名 APK
apksigner sign --ks joyaland-release.keystore `
  --ks-key-alias joyaland `
  --ks-pass pass:<密码> --key-pass pass:<密码> `
  --out JoyaLand-v1.0.0.apk `
  app-universal-release-unsigned.apk

⚠️ 重要joyaland-release.keystore 文件必须妥善保管。发布到应用商店后,更新版本必须使用同一密钥签名,否则无法覆盖安装。


四、实际遇到的问题与解决方案

问题 1:ANDROID_HOME 未设置导致初始化失败

现象

Error: ANDROID_HOME is not set

或自动探测到错误路径(C:\Users\xxx\AppData\Local\Android\Sdk),与实际使用的 SDK 路径不符。

原因

  • 系统环境变量未配置,或配置了不同路径的 SDK
  • Tauri 会自动探测系统默认路径,不一定是用户实际使用的路径

解决方案

# 每次打包前手动设置(临时)
$env:ANDROID_HOME = "D:\Android\Sdk"

# 或永久设置系统环境变量(推荐)
[System.Environment]::SetEnvironmentVariable("ANDROID_HOME", "D:\Android\Sdk", "User")

问题 2:Rust target 下载失败(文件重命名错误)

现象

error: component download failed for rust-std-i686-linux-android: 
could not rename downloaded file from '...partial' to '...': 
系统找不到指定的文件。(os error 2)

原因

  • 文件已存在于缓存中,但 rustup 下载流程中遇到并发/临时文件问题
  • 实际上 target 已经安装成功,只是报了错误

解决方案

# 验证是否实际已安装
rustup target list --installed
# 如果列表中有目标,则忽略错误继续即可

问题 3:Gradle 网络超时(无法下载 gradle-8.14.3-bin.zip)

现象

Downloading https://services.gradle.org/distributions/gradle-8.14.3-bin.zip
Exception in thread "main" java.io.IOException: ... failed: timeout

原因

  • 国内网络无法访问 services.gradle.org
  • gradle-wrapper.properties 配置的是从网络下载 Gradle

解决方案:直接修改 gradlew.bat,绕过 Wrapper,调用本地已安装的 Gradle

修改 src-tauri/gen/android/gradlew.bat

@if "%DEBUG%" == "" @echo off

if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi

@rem 直接使用本地安装的 Gradle,跳过网络下载
set GRADLE_CMD=D:\tool\gradle-8.14\bin\gradle.bat

if not exist "%GRADLE_CMD%" (
  echo ERROR: Gradle not found at %GRADLE_CMD%
  exit /b 1
)

"%GRADLE_CMD%" %*

:end
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

问题 4:Kotlin 增量编译失败(不同盘符路径冲突)

现象

java.lang.IllegalArgumentException: this and base files have different roots: 
C:\Users\xxx\.cargo\registry\...\tauri-2.10.3\...\ActivityCallback.kt 
and E:\WeChatProjects\JoyaLand\src-tauri\gen\android

原因

  • Kotlin 增量编译器要求所有源文件在同一个根路径下
  • Tauri 的 Kotlin 源文件在 C 盘(Cargo 注册表),项目在 E 盘,跨盘符导致相对路径计算失败

解决方案

  • 此错误会自动 fallback 到非增量编译模式(Using fallback strategy: Compile without Kotlin daemon
  • 不影响最终 APK 生成,可以忽略

若要根本解决,可将整个项目放在与 Cargo 注册表相同的盘符下(如 C 盘)。


问题 5:Gradle 版本不匹配

现象

  • 本地安装 Gradle 8.14,但 gradle-wrapper.properties 要求 8.14.3
  • 直接用 gradlew 会下载 8.14.3,网络超时失败

解决方案

  • 直接修改 gradlew.bat 调用本地 Gradle 8.14(见问题 3 的解决方案)
  • Gradle 8.14 与 8.14.3 兼容,实际构建无影响

问题 6:JDK 版本过高导致警告

现象

Kotlin does not yet support 24 JDK target, falling back to Kotlin JVM_22 JVM target
Java compiler version 24 has deprecated support for compiling with source/target version 8.

原因

  • 系统 JDK 版本为 24,Kotlin 最高支持 JVM 22 目标
  • Android 项目的 sourceCompatibility 设置为 Java 8,JDK 24 已弃用此设置

影响:仅为警告,不影响 APK 构建,可正常使用。

根本解决(可选):安装 JDK 17 或 JDK 21 并配置 JAVA_HOME


问题 7:APK 为未签名版本,无法安装

现象

  • 构建完成后的 APK 文件名为 app-universal-release-unsigned.apk
  • 直接安装到手机报错

解决方案:使用 apksigner 工具签名(见第三节阶段 4)


五、关键配置文件说明

tauri.conf.json(部分)

{
  "build": {
    "frontendDist": "../dist",
    "devUrl": "http://localhost:1420",
    "beforeDevCommand": "npm run dev",
    "beforeBuildCommand": "npm run build"
  },
  "app": {
    "windows": [{
      "width": 450,
      "height": 950,
      "resizable": false,
      "center": true
    }]
  }
}

gradle-wrapper.properties

distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME

在网络受限环境下,此文件的 distributionUrl 会导致超时。已通过修改 gradlew.bat 绕过。


六、常用命令速查

# 设置 Android SDK 路径(每次打包前执行)
$env:ANDROID_HOME = "D:\Android\Sdk"

# 初始化 Android 项目(只需一次)
npx tauri android init

# 构建 APK
npx tauri android build --apk

# 构建 debug APK(开发调试用)
npx tauri android build --apk --debug

# 签名 APK
$buildTools = "D:\Android\Sdk\build-tools\36.1.0"
& "$buildTools\apksigner.bat" sign `
  --ks joyaland-release.keystore `
  --ks-key-alias joyaland `
  --ks-pass pass:joyaland123 `
  --key-pass pass:joyaland123 `
  --out JoyaLand-v1.0.0.apk `
  app-universal-release-unsigned.apk

# 验证签名
& "$buildTools\apksigner.bat" verify --verbose JoyaLand-v1.0.0.apk

# 通过 ADB 安装到已连接设备
adb install JoyaLand-v1.0.0.apk

七、首次 vs 增量构建时间对比

构建类型 前端构建 Rust 编译 Gradle 打包 总计
首次构建 ~1 秒 ~20-60 分钟 ~5-10 分钟 ~30-70 分钟
增量构建(无 Rust 变更) ~1 秒 ~1-2 分钟 ~2-3 分钟 ~3-5 分钟
增量构建(有 Rust 变更) ~1 秒 ~3-10 分钟 ~2-3 分钟 ~5-15 分钟

Rust 编译缓存保存在 src-tauri/target/ 目录,体积较大(数 GB),请勿随意删除。


八、输出文件位置

文件 路径
未签名 APK src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release-unsigned.apk
已签名 APK JoyaLand-v1.0.0.apk(项目根目录)
签名密钥 joyaland-release.keystore(项目根目录,请备份)
Rust 编译缓存 src-tauri/target/
Android 项目 src-tauri/gen/android/

当 AI 已经做出判断,谁来按那个确认键?

作者 yuki_uix
2026年4月3日 21:17

上一篇我得出了一个结论:AI 在电商链路里真正有价值的地方,是认知负担最重和人工成本最高的两类场景。售后工单是后者的典型——规则明确、量大、重复,AI 来做意图识别再合适不过。

但写完那篇之后,我一直在想一个没展开的问题:

AI 做出判断之后,然后呢?

这个"然后",大多数产品设计都跳过了。技术团队花了很多精力让模型的意图识别更准,却很少认真想:当 AI 已经判断出"这个客户想退货",界面接下来应该发生什么?自动触发退货流程?等客服确认?还是让客户自己再按一次?

这条边界——什么时候 AI 该自己执行,什么时候必须等人——不是技术问题,是设计问题。

你有没有在这里停留,然后思考一下,下一步应该是什么样的?


从一个工单说起

想象这样一条售后消息:

"我上周买的外套,收到发现颜色和图片差太多了,能退吗?"

对一个训练充分的意图分类模型来说,这条消息的处理不难:退货意图,原因是色差,属于"货不对板"类目,符合平台退货政策。置信度 0.94。

好,模型判断完了。现在问题来了:

界面该做什么?

选项 A:直接自动发起退货申请,给客户发确认短信。
选项 B:在客服工作台标注"建议:退货申请(置信度 94%)",等客服点击确认。
选项 C:给客户发一条消息:"您是否需要申请退货?",等客户自己确认。
选项 D:根据置信度动态决定——高于某个阈值自动执行,低于阈值转人工。

这四个选项,背后是四种完全不同的产品逻辑。没有哪个天然正确,但选哪个会直接影响:客户体验、客服工作量、出错之后谁来担责、以及商家对这套系统的信任程度。


工单的完整旅程:AI 介入的三个阶段

在讨论"自动执行还是人工确认"之前,先把一条工单从进来到处理完的完整路径摊开来看。

image.png

这张图里有几个细节值得注意:

第一,置信度和风险是两个独立维度,不能只看置信度高低;

第二,无论哪条路径,操作记录都是必须的,不是可选项;

第三,人工处理的结果应该回流到模型,这个闭环在很多产品里是缺失的。

四类工单,四种处置

把意图清晰度和出错风险交叉,能得到四种典型工单,每一种的正确处置方式都不一样:

工单类型 示例消息 AI 置信度 出错风险 建议处置
意图明确 · 低风险 "这个能退货吗,我不喜欢" 高(~95%) 低(可撤销) 自动发起退货申请,显示撤销入口
意图明确 · 高风险 "我要投诉,这个产品质量有问题" 高(~90%) 高(涉及品牌声誉) 工作台标注意图 + 建议回复模板,客服确认后发送
意图模糊 · 可引导 "东西有点问题,怎么处理" 中(~65%) 列出 2-3 个意图候选,客服快速选择;或给客户发引导消息
意图不明 · 情绪激动 "太差劲了!!!退退退!!!" 低(~40%) 高(情绪化客户需要人工安抚) 直接转人工,标注"情绪风险",优先级提升

第四类是最容易被忽视的。"退退退"这个词在字面上是退货意图,模型可能识别出高置信度的退货分类——但这条消息需要的不是触发退货流程,而是先安抚情绪。纯文本意图识别不等于理解语境,这是 AI 介入工单处理时最容易翻车的地方。

「 做了一个可以玩的版本,→ 在线体验

出错了,界面怎么兜底

AI 判断出错不是概率问题,是必然会发生的事。问题不是"怎么避免出错",而是"出错之后系统怎么行动"。

常见的出错场景有三种:

场景一:自动执行了错误动作。 客户说"我想换个颜色",AI 识别为退货并自动发起了退货申请。客户收到退货确认短信,困惑,打电话进来。这时候客服看到的界面应该是:清晰标注"系统于 10 分钟前自动发起退货申请",一键撤销,同时自动生成一条道歉模板消息。如果这个撤销入口藏在三层菜单里,出错的代价就从"小麻烦"变成了"客户愤怒"。

场景二:置信度虚高,判断方向错了。 "我朋友说这个质量不好,我有点担心"——这条消息的关键词触发了模型的"质量投诉"分类,置信度 88%。但实际上客户只是在表达顾虑,还没有购买,根本没有工单可以处理。这类情况,界面的兜底方式是:在工作台显示判断依据("触发词:质量不好"),让客服能快速理解为什么 AI 这么判断,并在纠正之后把这条记录标注为"误判样本"。

场景三:正确意图,错误时机。 客户下单后两小时内发消息"我想取消订单",AI 正确识别为取消意图,自动触发取消流程——但这时候订单已经进入打包环节,取消会触发额外的仓储费用。AI 不知道订单状态,做了一个技术上正确但业务上错误的决定。这个场景说明:意图识别和动作执行之间,需要一层业务规则校验,不能让模型的输出直接触发操作。


我尝试建立一个判断框架

反复想这个问题之后,我觉得影响"自动执行 vs 人工确认"这条边界的,主要是三个变量:

1. 出错的代价有多高?

同样是退货场景,"误触发了一个客户不想退的退货申请",代价是:客户困惑、需要撤销、产生额外沟通。麻烦,但可以修复。

换一个场景:AI 判断某个账号存在异常交易,自动冻结——如果判断错了,代价是:正常用户被误封,投诉升级,信任崩塌。不可轻易修复。

出错代价越高,越需要人工确认作为缓冲。 这个逻辑不复杂,但容易被"模型准确率已经很高了"这个理由绕过。准确率 99% 听起来很高,但如果每天处理一万条工单,就有一百条出错——这一百条落在真实用户身上,每一条都是一个完整的糟糕体验。

2. 可逆性如何?

自动执行之后,这个动作能撤销吗?

退货申请发出了,可以撤销。优惠券发出去了,不好收回。退款打出去了,追回来很麻烦。物流揽件指令发出去了,基本不可逆。

可逆性越低,越需要在执行前确认。 这和出错代价是两个维度——有些事出错代价不高,但就是无法撤销;有些事代价很高,但可以事后补救。两者叠加才是完整的风险评估。

3. 这个判断需要上下文吗?

有些工单,AI 光看消息文本就能判断得很准。但有些情况,真正的意图藏在文本之外:

  • 客户历史上退过几次货了?
  • 这个订单是否处于促销期,退货会触发特殊规则?
  • 客服备注里有没有这个客户的特殊情况?

如果正确判断需要的上下文,模型当前不具备,那置信度数字本身就是虚高的——模型不知道自己不知道什么。这种情况,再高的置信度也不该触发自动执行。


用这个框架重新看那个工单

回到开头那条退货消息,套进三个变量:

  • 出错代价:中等(触发了不该触发的退货申请,可以撤销,但产生摩擦)
  • 可逆性:高(申请发出后客户可以主动取消)
  • 上下文依赖:低("颜色和图片差太多"意图明确,不需要额外信息)

这个组合,倾向于可以自动执行,但要给客户一个明确的撤销入口。客服不需要介入每一条,但系统要在操作后保留一个清晰的"撤销窗口"和操作记录。

换一条消息试试:

"我买的东西有点问题,你们怎么说?"

  • 出错代价:不确定(不知道"问题"是什么,处理方式差异很大)
  • 可逆性:取决于后续动作
  • 上下文依赖:极高("有点问题"几乎没有信息量)

这个组合,模型的置信度无论多高,都不该自动执行任何流程。正确的界面行为是:标注"意图不明确,建议人工介入",并把可能的意图选项列出来,让客服快速选择而不是从头处理。


界面设计的几个具体含义

这个框架落到界面上,会带来几个具体的设计要求,是我觉得目前大多数产品做得不够的地方:

置信度要可见,但不能只是一个数字。

"置信度 94%"对普通客服来说没有意义。更有用的呈现是:把这个数字翻译成行动建议——"建议直接处理"、"建议确认后处理"、"建议人工介入"。数字留给系统日志,界面上给人看的是判断,不是概率。

自动执行之后,操作记录必须显眼。

如果 AI 自动触发了某个流程,这个动作不能藏在日志里。它应该在工作台上有明显的呈现:"系统已自动发起退货申请 · 10分钟内可撤销"。人工覆盖的成本越低,越敢放权给 AI 自动执行。

人工覆盖不该是"报错",是正常流程的一部分。

很多系统设计里,人工覆盖 AI 判断是一个"异常路径"——操作步骤多、界面不顺畅、有时候还要填理由。这个设计隐含了一个假设:AI 是对的,人工推翻是例外。

但实际上,人工覆盖是正常的。模型不可能永远对,边缘案例永远存在。界面应该让"我不同意这个判断"这个操作和"我同意"一样顺畅——一个点击,不需要解释,不需要走审批流。


商家后台的同一个问题

这个框架不只适用于售后工单,商家后台里同样存在大量"AI 已判断,然后呢"的设计问题。

比如 AI 检测到某个 SKU 的库存即将断货,预测三天内售罄——界面该做什么?

自动触发补货申请?发一条通知让运营确认?还是只在数据看板上标注一个预警色,等运营自己发现?

套进同样的框架:

  • 出错代价:高(错误补货会导致积压或资金占用)
  • 可逆性:低(补货指令发出之后,供应链已经启动)
  • 上下文依赖:高(补货决策依赖当前促销计划、账期、仓库容量……这些数据模型不一定都有)

这个组合,答案很清晰:不该自动执行,应该是"高优先级提醒 + 一键确认" 。AI 做信息聚合和预测,人来做最终决策。界面的工作是把"确认"这个动作做得足够顺畅,减少决策摩擦,而不是代替决策。


还没想清楚的地方

置信度阈值该怎么定,谁来定?

我说的"高于某个阈值自动执行",这个阈值应该是固定的系统参数,还是让商家自己配置?不同规模的商家、不同品类的商品,对出错的容忍度差异很大。把这个权力交给商家配置,是更诚实的设计,但也带来了新的认知负担——商家未必知道 94% 和 87% 的置信度在实际操作里意味着什么。

当 AI 频繁被人工覆盖,系统该怎么反应?

如果某类工单的 AI 判断被客服推翻的比例很高,这是一个明确的信号:要么模型在这个类目表现差,要么界面的行动建议设计有问题,要么这类工单本来就不适合 AI 介入。这个反馈机制,应该是自动的,而不是靠数据团队定期去看日志才能发现。


与这个系列的关系

第一篇建立了一个框架:AI 在哪两类场景真正有价值。这篇往前走了一步:当 AI 真的介入之后,界面的责任不是消失,而是变了——从"帮用户完成操作",变成"在 AI 和人之间建立一个合理的权力分配机制"。

下一篇打算进入决策层,聚焦导购 Agent——那里的问题方向相反:不是"AI 判断了,人怎么接管",而是"用户说不清楚自己要什么,AI 怎么开始"。


这篇是观察和思考的笔记,框架还很粗糙。如果你在做类似的产品或界面设计,欢迎交流——特别是那个阈值配置的问题,我还没想到好的解法。

本地存储全家桶:从localStorage到IndexedDB,把数据塞进用户浏览器

作者 kyriewen
2026年4月3日 20:36

你有没有想过,为什么刷新页面后,有些网站还能记住你的登录状态?为什么购物车里的商品关掉浏览器再打开还在?今天我们就来聊聊浏览器里的“记忆术”——本地存储。从简单的钥匙串localStorage,到能装下整个图书馆的IndexedDB,总有一款适合你。

前言

想象一下,你每次去网吧上网,都要重新登录所有账号、重新设置主题、重新添加购物车——是不是想砸电脑?还好,浏览器有“记忆功能”。它能在你的电脑里存点东西,下次再来时直接拿出来用。

这个“记忆功能”就是Web存储。今天我们就来盘点一下浏览器提供的几种存储方式:localStorage、sessionStorage、cookie,以及能存视频、存大文件的IndexedDB。看完你就能根据场景选对工具,再也不用担心数据“蒸发”了。

一、localStorage:永不过期的便利贴

localStorage是一个挂在window上的对象,它存的数据没有过期时间,除非你手动清除或者用户清理浏览器缓存,否则会一直待在那里。

基本用法

// 存数据(键值对,值必须是字符串)
localStorage.setItem('username', '张三');
localStorage.setItem('theme', 'dark');

// 取数据
const name = localStorage.getItem('username'); // '张三'

// 删除某条
localStorage.removeItem('theme');

// 全部清空
localStorage.clear();

// 获取存储数量
console.log(localStorage.length);

存对象怎么办?

localStorage只能存字符串,所以对象要先转成JSON:

const user = { name: '张三', age: 18 };
localStorage.setItem('user', JSON.stringify(user));

// 读取时解析
const stored = JSON.parse(localStorage.getItem('user'));

容量限制

大多数浏览器限制5MB~10MB,够存一些配置、用户信息、小量缓存。

特点总结

  • 同步:操作是同步的,会阻塞主线程(但一般很快)。
  • 同源:同一域名下所有页面共享(包括不同标签页)。
  • 永久:除非手动清除。
  • 仅客户端:不会自动发送到服务器。

二、sessionStorage:标签页关闭就消失的临时工

sessionStoragelocalStorage的API一模一样,但生命周期不同:它只存在于当前标签页。关掉标签页,数据就没了。刷新页面还在,但新开标签页(即使是同一个网站)会得到一个新的sessionStorage。

// 用法完全一样
sessionStorage.setItem('tempData', '临时值');

适用场景:表单临时草稿、当前页面的中间状态、不希望跨页面共享的敏感信息。

三、cookie:老前辈,但有点“重”

cookie是最早的浏览器存储机制,但如今除了会话管理(登录态)和少量用户追踪,大部分场景已被localStorage替代。

特点

  • 容量小:每个cookie 4KB 左右。
  • 自动携带:每次HTTP请求都会把cookie发给服务器(增加带宽消耗)。
  • 可设置过期时间。
  • 可标记HttpOnly(禁止JS读取,防XSS)、Secure(仅HTTPS)、SameSite(防CSRF)。
// 设置cookie(繁琐)
document.cookie = "username=张三; expires=Thu, 18 Dec 2026 12:00:00 UTC; path=/";

// 读取cookie(需要自己解析)
console.log(document.cookie);

现在主流做法:用localStorage存非敏感数据,用httpOnly cookie存登录凭证

四、IndexedDB:浏览器里的“小数据库”

如果你要存的东西很大(几百MB),或者需要复杂的查询、索引、事务,那么localStorage就不够用了。这时候请出IndexedDB——一个运行在浏览器里的非关系型数据库。

特点

  • 容量大:通常250MB+,甚至更多(取决于浏览器)。
  • 异步API:基于Promise或回调,不阻塞主线程。
  • 支持索引、游标、事务。
  • 可以存储File、Blob、ArrayBuffer等二进制数据。

快速上手

IndexedDB的API比较原始,不过我们可以封装一下。

// 1. 打开/创建数据库
const request = indexedDB.open('MyDatabase', 1);

request.onupgradeneeded = (event) => {
  const db = event.target.result;
  // 创建一个对象仓库(类似表),指定主键
  const store = db.createObjectStore('users', { keyPath: 'id' });
  // 创建索引,用于快速查询
  store.createIndex('name', 'name', { unique: false });
};

request.onsuccess = (event) => {
  const db = event.target.result;
  console.log('数据库打开成功');
  // 后续增删改查都用这个db对象
};

request.onerror = (event) => {
  console.error('数据库打开失败', event.target.error);
};

增删改查

// 添加数据(在onsuccess里拿到db)
const transaction = db.transaction(['users'], 'readwrite');
const store = transaction.objectStore('users');
const addRequest = store.add({ id: 1, name: '张三', age: 18 });

addRequest.onsuccess = () => console.log('添加成功');
addRequest.onerror = (e) => console.error('添加失败', e.target.error);

// 查询
const getRequest = store.get(1);
getRequest.onsuccess = () => console.log(getRequest.result);

// 更新(使用put,如果存在则覆盖)
store.put({ id: 1, name: '李四', age: 20 });

// 删除
store.delete(1);

使用游标遍历

const range = IDBKeyRange.bound(1, 10); // id从1到10
store.openCursor(range).onsuccess = (e) => {
  const cursor = e.target.result;
  if (cursor) {
    console.log(cursor.value);
    cursor.continue(); // 继续下一个
  }
};

现代封装:localForage

原生IndexedDB API太啰嗦,推荐用localForage这个库,它提供了类似localStorage的简洁API,但背后自动选择IndexedDB、WebSQL或localStorage。

// 使用localForage
import localforage from 'localforage';

await localforage.setItem('user', { name: '张三' });
const user = await localforage.getItem('user');

五、四种存储方式对比

特性 localStorage sessionStorage cookie IndexedDB
容量 5-10MB 5-10MB 4KB 几百MB
生命周期 永久 标签页关闭 可设置过期 永久
跨标签页
异步 同步 同步 同步 异步
自动发到服务器 是(每次请求)
数据类型 字符串 字符串 字符串 任意(结构化克隆)
查询能力 索引、游标

六、选型指南:到底用哪个?

  • 简单键值对,少量数据localStorage,比如用户偏好设置、主题、是否首次访问。
  • 临时数据,只在一个页面用sessionStorage,比如多步骤表单的暂存。
  • 登录凭证httpOnly cookie(安全)配合后端。
  • 大量结构化数据、离线应用IndexedDB,比如邮件客户端、笔记应用、缓存API数据。
  • 需要与后端自动同步cookieAuthorization头(用localStorage存token也行,但要注意XSS)。

七、避坑指南

1. localStorage 的同步阻塞

大量数据存取会阻塞UI,建议不要存超过几MB,或改用IndexedDB。

2. 隐私模式

Safari的隐私模式下,localStorage和IndexedDB可能不可用或容量极低,要写try-catch降级。

3. 序列化问题

localStorage存对象会丢失原型链、函数、Symbol、循环引用。用JSON.stringify前确保数据可序列化。

4. 安全提醒

永远不要把敏感信息(如密码、token)明文存在localStorage,因为任何JS都能读到(XSS攻击)。token建议用httpOnly cookie或短时效+refresh机制。

5. IndexedDB 版本升级

当修改数据库结构时,需要增加版本号,并在onupgradeneeded里处理旧数据迁移,否则会报错。

八、总结:存储就像选工具箱

  • localStorage:日常杂货,随手放。
  • sessionStorage:临时工,关窗走人。
  • cookie:老古董,特殊场合用。
  • IndexedDB:重武器,存大文件、复杂查询。

掌握了这些,你就可以在浏览器里随心所欲地存数据了。明天我们将继续前端工程化的旅程,聊聊Cookie与Session的区别,以及现代认证方案JWT。

如果你觉得今天的存储全家桶够实用,点个赞让更多人看到。我们明天见!

🔍 探究pretext,从不定高虚拟列表入手,到手写一个mini pretext

作者 wifi歪f
2026年4月3日 19:19

github.com/chenglou/pr…

认识pretext

它是一个通过计算可以得到文本的高度的库,prepare方法传入文本内容和字体大小可以计算出每个文字的排版,layout传入prepare、容器宽度和行高,返回整个容器高度和行数。这个库对于一些需要提前知道高度的场景非常有用。但是这个库只能在前端使用,因为涉及到了canva。

  • prepare():做一次性分析和测量
  • layout():只基于缓存结果做纯算术布局

官方文档明确说明,不要在同样的文本和配置上反复执行 prepare()。例如窗口宽度变化时,应该只重新执行 layout()。

场景:

  • 虚拟滚动列表
  • ai流式输出
  • canvas 渲染

像不定高的虚拟列表的场景,通常需要高度占位,然后滚动后再缓存,有了这个库可以做到内容虚拟列表内容的提前精确计算。解决了滚动过快导致计算不准确的问题。

快速上手

  1. 安装依赖
pnpm i @chenglou/pretext
  1. App.tsx
import { prepare, layout } from '@chenglou/pretext'

const text = `
AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀AGI 春天到了. بدأت الرحلة 🚀‎
`

export default function App() {
  const prepared = prepare(text, '16px Inter', {
    whiteSpace: 'pre-wrap' // 'normal' | 'pre-wrap'
  })
  const { height, lineCount } = layout(prepared, 200, 20)
  return (
    <div style={{ display: 'flex' }}>
      <div style={{ lineHeight: '20px', width: '200px' }}>{text}</div>
      <div>
        <div>
          计算高度: {height}px, {lineCount} lines
        </div>
        <div>
          真实lines:
          {text.split('').length}
        </div>
      </div>
    </div>
  )
}
  1. 效果

可以看到真实的dom高度和计算出来的dom高度一样 在这里插入图片描述

实现不定高虚拟滚动列表

import { useEffect, useMemo, useRef, useState } from 'react'
import { prepare, layout } from '@chenglou/pretext'

const fetchData = async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts')
  return response.json()
}

// 上下额外渲染的缓冲项数量,减少滚动时白屏
const BUFFER_COUNT = 5
// 文本排版和实际渲染统一使用的字号
const FONT_SIZE = 16
// 文本排版和实际渲染统一使用的字体
const FONT_FAMILY = 'Inter'
// 传给 pretext layout 的固定行高(px)
const LINE_HEIGHT = 20
// 每个 item 底部边框高度(对应 border-b 的 1px)
const ITEM_BORDER_Y = 1

// 在前缀和数组中找第一个 > target 的位置(upper bound)
const getFirstGreaterIndex = (arr, target) => {
  let left = 0
  let right = arr.length

  while (left < right) {
    const mid = (left + right) >> 1
    if (arr[mid] <= target) {
      left = mid + 1
    } else {
      right = mid
    }
  }

  return left
}

// 在前缀和数组中找第一个 >= target 的位置(lower bound)
const getFirstGreaterOrEqualIndex = (arr, target) => {
  let left = 0
  let right = arr.length

  while (left < right) {
    const mid = (left + right) >> 1
    if (arr[mid] < target) {
      left = mid + 1
    } else {
      right = mid
    }
  }

  return left
}

export default function Scroll() {
  const containerRef = useRef(null)
  const [screenHeight, setScreenHeight] = useState(0)
  const [textLayoutWidth, setTextLayoutWidth] = useState(200)
  const [scrollTop, setScrollTop] = useState(0)
  const [listData, setListData] = useState([])

  useEffect(() => {
    const container = containerRef.current
    if (!container) return

    const updateMetrics = () => {
      setScreenHeight(container.clientHeight)
      // 文本可用宽度应与真实渲染宽度一致,避免高度预估偏大或偏小
      setTextLayoutWidth(Math.max(1, container.clientWidth))
    }

    updateMetrics()

    const observer = new ResizeObserver(updateMetrics)
    observer.observe(container)

    return () => {
      observer.disconnect()
    }
  }, [])

  useEffect(() => {
    fetchData().then((res) => {
      setListData(
        res.map((item) => ({
          uid: item.id,
          value: item.body
        }))
      )
    })
  }, [])

  // 预计算每条文本在固定宽度下的排版高度,用于不定高虚拟列表
  const measuredList = useMemo(
    () =>
      listData.map((item) => {
        const text = String(item.value ?? '')
        const prepared = prepare(text, `${FONT_SIZE}px ${FONT_FAMILY}`)
        const { height, lineCount } = layout(
          prepared,
          textLayoutWidth,
          LINE_HEIGHT
        )
        const itemHeight = Math.ceil(height) + ITEM_BORDER_Y

        return {
          ...item,
          lineCount,
          textHeight: height,
          itemHeight
        }
      }),
    [listData, textLayoutWidth]
  )

  const totalItemCount = measuredList.length

  // 前缀和:prefixHeights[i] 表示前 i 项累计高度
  const prefixHeights = useMemo(() => {
    const result = new Array(totalItemCount + 1).fill(0)
    for (let i = 0; i < totalItemCount; i += 1) {
      result[i + 1] = result[i] + measuredList[i].itemHeight
    }
    return result
  }, [measuredList, totalItemCount])

  const containerHeight = useMemo(
    () => prefixHeights[prefixHeights.length - 1] ?? 0,
    [prefixHeights]
  )

  // 通过 scrollTop 在前缀和中二分定位当前顶部命中的 item
  const topIndex = useMemo(() => {
    if (totalItemCount === 0) return 0
    const hit = getFirstGreaterIndex(prefixHeights, scrollTop) - 1
    return Math.min(totalItemCount - 1, Math.max(0, hit))
  }, [prefixHeights, scrollTop, totalItemCount])

  const startIndex = useMemo(
    () => Math.max(0, topIndex - BUFFER_COUNT),
    [topIndex]
  )

  // 视口底部对应的结束索引,再加 buffer 做预渲染
  const endIndex = useMemo(() => {
    const viewportBottom = scrollTop + screenHeight
    const visibleEndIndex = getFirstGreaterOrEqualIndex(
      prefixHeights,
      viewportBottom
    )
    return Math.min(totalItemCount, visibleEndIndex + BUFFER_COUNT)
  }, [prefixHeights, scrollTop, screenHeight, totalItemCount])

  const renderedItems = useMemo(
    () => measuredList.slice(startIndex, endIndex),
    [measuredList, startIndex, endIndex]
  )

  // 当前渲染窗口整体向下偏移到 startIndex 的真实起点高度
  const offset = useMemo(
    () => prefixHeights[startIndex] ?? 0,
    [prefixHeights, startIndex]
  )

  const handleScroll = (e) => {
    setScrollTop(e.target.scrollTop)
  }

  return (
    <div
      ref={containerRef}
      className='w-60 h-160 border m-auto mt-10 relative overflow-auto'
      onScroll={handleScroll}
    >
      <div
        className='absolute top-0 left-0 right-0 w-full -z-1'
        style={{ height: `${containerHeight}px` }}
      ></div>

      <div
        className='w-full overflow-hidden'
        style={{
          transform: `translate3D(0, ${offset}px, 0)`,
          fontSize: `${FONT_SIZE}px`,
          fontFamily: FONT_FAMILY,
          lineHeight: `${LINE_HEIGHT}px`
        }}
      >
        {renderedItems.map((item) => (
          <div
            className='w-full border-b bg-amber-200'
            key={item.uid}
          >
            {item.value}
          </div>
        ))}
      </div>
    </div>
  )
}

原理

不用 DOM + CSS layout(如 line-heightwhite-space 等),用 Canvas API 自己测量文本 → 手动换行 → 计算高度

核心API:ctx.measureText,返回文本宽度

const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");

let text = ctx.measureText("Hello world");
console.log(text.width); // 56;

对比css的优势:

  • 不参与 DOM layout
  • 不受 CSS flow 影响

你可以:

  • 精确测量每个字符串宽度
  • 自己决定在哪里换行
  • 自己累加高度

实现一个mini版

  • 核心方法:
// Intl.Segmenter:它是浏览器内置的“文本切分器”
// 作用:把字符串切成“人眼看到的一个个字符单位”
// 例如:"👨‍👩‍👧‍👦" 会被拆成 ["👨", "", "👩", "", "👧", "", "👦"]
const segmenter = new Intl.Segmenter('zh', {
  granularity: 'grapheme'
})

function tokenize(text) {
  const tokens = []

  // segment就是一个一个字符了,包含了中文、英文、emoji等
  for (const { segment } of segmenter.segment(text)) {
    if (/[\u4e00-\u9fff]/.test(segment)) {
      // 判断是中文字符
      tokens.push(segment)
    } else if (/\s/.test(segment)) {
      // 判断是否包含空白字符(空格、换行、制表符等)
      tokens.push(segment)
    } else {
      // 英文需要合并成词(优化)
      const last = tokens[tokens.length - 1]
      if (last && /[a-zA-Z0-9]/.test(last)) {
        tokens[tokens.length - 1] += segment
      } else {
        tokens.push(segment)
      }
    }
  }

  return tokens
}
function wrapText(text, maxWidth, ctx) {
  // 保存最终的分行结果
  let lines = []
  // 当前正在拼接的一行文本
  let currentLine = ''
  // 将文本进行分词,为了兼容中文
  const tokens = tokenize(text)

  // 逐字符尝试拼接,确保按真实渲染宽度换行
  for (let token of tokens) {
    if (token === '\n') {
      lines.push(currentLine)
      currentLine = ''
      continue
    }

    // 先假设把当前字符放进本行,再测量宽度
    const testLine = (currentLine + token).trimEnd()
    // 获取到当前行加上新字符的宽度
    const testWidth = ctx.measureText(testLine).width

    // 超过最大宽度时,当前字符需要换到下一行
    if (testWidth > maxWidth) {
      if (currentLine.length > 0) {
        // 当前行已有内容:先收集当前行,再让新行从当前字符开始
        lines.push(currentLine.trimEnd())
        currentLine = token.trimStart()
      } else {
        // 当前lines没有内容,但是单个字符宽度都超过 maxWidth
        // 这种情况下只能强制单字符成行,避免死循环
        lines.push(token)
        currentLine = ''
      }
    } else {
      // 没超宽:继续在当前行累积
      currentLine = testLine
    }
  }

  // 循环结束后,把最后一行(如果有内容)补进结果
  if (currentLine.length > 0) {
    lines.push(currentLine)
  }

  return lines
}

// 返回布局的文本信息
function layoutText(text, maxWidth, lineHeight, ctx) {
  const lines = wrapText(text, maxWidth, ctx)
  const totalHeight = lines.length * lineHeight

  const lineMetrics = lines.map((line) => ({
    text: line,
    width: ctx.measureText(line).width,
    height: lineHeight
  }))

  return {
    lines: lineMetrics,
    lineHeight,
    totalHeight
  }
}
  • 使用:
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')

ctx.font = '16px Arial'

const result = layoutText(
    '你好啊,世界!我是一个前端开发,我会vue、react等技术。还有后端哦',
    120,
    20,
    ctx
)
console.log(result)
  • 测试:

在这里插入图片描述在这里插入图片描述

需要注意的是使用layoutText计算出来的lines和页面上的dom不一致的原因是因为浏览器排版布局和canvas布局是有差异的,但是总高度是一致的。

完整版代码

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta
      name="viewport"
      content="width=device-width, initial-scale=1.0"
    />
    <title>Document</title>
  </head>
  <style>
    div {
      font-size: 16px;
      font-family: Arial;
      width: 120px;
      line-height: 20px;
    }
  </style>
  <div>你好啊,世界!我是一个前端开发,我会vue、react等技术。还有后端哦</div>
  <!-- Hello world, this is a canvas layout demo. -->
  <body>
    <script>
      // Intl.Segmenter:它是浏览器内置的“文本切分器”
      // 作用:把字符串切成“人眼看到的一个个字符单位”
      // 例如:"👨‍👩‍👧‍👦" 会被拆成 ["👨", "", "👩", "", "👧", "", "👦"]
      const segmenter = new Intl.Segmenter('zh', {
        granularity: 'grapheme'
      })

      function tokenize(text) {
        const tokens = []
        for (const { segment } of segmenter.segment(text)) {
          // segment就是一个一个字符了,包含了中文、英文、emoji等
          if (/[\u4e00-\u9fff]/.test(segment)) {
            // 判断是中文字符
            tokens.push(segment)
          } else if (/\s/.test(segment)) {
            // 判断是否包含空白字符(空格、换行、制表符等)
            tokens.push(segment)
          } else {
            // 英文需要合并成词(优化)
            const last = tokens[tokens.length - 1]
            if (last && /[a-zA-Z0-9]/.test(last)) {
              tokens[tokens.length - 1] += segment
            } else {
              tokens.push(segment)
            }
          }
        }

        return tokens
      }
      function wrapText(text, maxWidth, ctx) {
        // 保存最终的分行结果
        let lines = []
        // 当前正在拼接的一行文本
        let currentLine = ''
        // 将文本进行分词,为了兼容中文
        const tokens = tokenize(text)

        // 逐字符尝试拼接,确保按真实渲染宽度换行
        for (let token of tokens) {
          if (token === '\n') {
            lines.push(currentLine)
            currentLine = ''
            continue
          }

          // 先假设把当前字符放进本行,再测量宽度
          const testLine = (currentLine + token).trimEnd()
          // 获取到当前行加上新字符的宽度
          const testWidth = ctx.measureText(testLine).width

          // 超过最大宽度时,当前字符需要换到下一行
          if (testWidth > maxWidth) {
            if (currentLine.length > 0) {
              // 当前行已有内容:先收集当前行,再让新行从当前字符开始
              lines.push(currentLine.trimEnd())
              currentLine = token.trimStart()
            } else {
              // 当前lines没有内容,但是单个字符宽度都超过 maxWidth
              // 这种情况下只能强制单字符成行,避免死循环
              lines.push(token)
              currentLine = ''
            }
          } else {
            // 没超宽:继续在当前行累积
            currentLine = testLine
          }
        }

        // 循环结束后,把最后一行(如果有内容)补进结果
        if (currentLine.length > 0) {
          lines.push(currentLine)
        }

        return lines
      }

      // 返回布局的文本信息
      function layoutText(text, maxWidth, lineHeight, ctx) {
        const lines = wrapText(text, maxWidth, ctx)
        const totalHeight = lines.length * lineHeight

        const lineMetrics = lines.map((line) => ({
          text: line,
          width: ctx.measureText(line).width,
          height: lineHeight
        }))

        return {
          lines: lineMetrics,
          lineHeight,
          totalHeight
        }
      }
    </script>
    <script>
      const canvas = document.createElement('canvas')
      const ctx = canvas.getContext('2d')

      ctx.font = '16px Arial'

      const result = layoutText(
        '你好啊,世界!我是一个前端开发,我会vue、react等技术。还有后端哦',
        120,
        20,
        ctx
      )

      console.log(result)
    </script>
  </body>
</html>

pnpm 与 node_modules:硬链接、软连接(符号链接)、Junction 速记

作者 珑墨
2026年4月3日 18:47

pnpm 与 node_modules:硬链接、软连接(符号链接)、Junction 速记

本文说明在 pnpm 布局下,node_modules 里常见的 硬链接软连接(符号链接)、以及 Windows 上的 目录联接(Junction) 各是什么、各出现在哪一层,方便对照记忆。


1. 三个关键词(先对齐名字)

中文常叫法 英文 在说什么
软连接 / 软链接 Symbolic link(符号链接) 文件系统里有一项,内容是“另一段路径”;访问时转到目标路径。目标删了容易断链
(Windows 目录)联接 Junction 只用于目录,也是“指向另一个目录路径”,效果和目录级软链很像,实现细节与限制略有不同。
硬链接 Hard link 同一个文件在磁盘上只有一份数据,但有多个路径名指向它;删一个名字,只要还有别的名字,内容仍在

日常口语里说的 「软连接」≈「符号链接」≈ symbolic link,和 硬链接 是两套不同机制。


2. 在 pnpm + Windows 下,大致对应关系

以本仓库为例(node_modules 顶层依赖如 axios):

  1. node_modules\axios(整包目录)

    • 常见类型:Junction(本机实测为 Junction,不是 SymbolicLink)。
    • 作用:和 「目录软链」 同类——整块包目录接到本项目内的
      node_modules\.pnpm\axios@<版本>\node_modules\axios\
    • 记忆:软连接那一类思路(指路),只是 Windows 常用 Junction 实现目录指路。
  2. node_modules\.pnpm\... 里的目录

    • 一般是普通目录结构(虚拟仓库布局),不是“整个文件夹是一个硬链接”。
  3. .pnpm\...\包\ 下面的具体文件(如 README.md

    • 常与全局 pnpm-store\v3\files\... 里对应内容互为 硬链接,多项目同版本时共享同一份磁盘数据
    • 可用 fsutil hardlink list "<文件完整路径>" 查看是否有多条路径指向同一文件。

口诀:

  • 目录级「软」node_modules\包名 → Junction → 本项目 .pnpm\...(整块目录指路)。
  • 文件级「硬」.pnpm 内文件 ↔ pnpm-store 内文件(同内容、多路径名)。

3. 示意图

flowchart LR
  subgraph concepts["概念层"]
    SOFT["软连接 / 符号链接\n(及 Windows 目录 Junction)\n= 路径指路"]
    HARD["硬链接\n= 同一文件多个名字"]
  end

  subgraph project["本项目"]
    NM["node_modules/axios\n→ Junction(类软链)"]
    PNPM[".pnpm/axios@x.y.z/.../axios/\n真实目录树"]
    NM --> PNPM
  end

  subgraph store["全局 pnpm-store"]
    ST["v3/files/.../哈希文件"]
  end

  PNPM -->|"包内文件常与之硬链接"| ST

  SOFT -.->|"对应这种指路关系"| NM
  HARD -.->|"对应文件与 store"| PNPM

从外到内读一条路径:

node_modules/axios/README.md
        │
        │  Junction(目录级「软」思路:axios 文件夹指向 .pnpm 里那一包)
        ▼
.pnpm/axios@1.7.2/node_modules/axios/README.md
        │
        │  硬链接:与 pnpm-store/v3/files/... 常共享同一块数据
        ▼
pnpm-store/v3/files/.../(内容寻址,无友好文件名)

4. 本机如何自查

目录是不是「软」类链接(含 Junction / SymbolicLink):

Get-Item "...\node_modules\axios" | Format-List LinkType, Target
  • JunctionSymbolicLink:目录指路。
  • LinkType 为空:多为普通目录。

文件是否有多条硬链接:

fsutil hardlink list "...\node_modules\.pnpm\axios@1.7.2\node_modules\axios\README.md"

多行输出表示多个路径名指向同一份文件数据


5. 易混点

  • 软连接 ≠ .lnk 快捷方式.lnk 是壳层文件;软链/Junction 是文件系统层面的重解析,多数程序会跟随解析。
  • 「node_modules 根下全是软链」不严谨:是顶层依赖包名那一级目录常为 Junction;不是根下每个零散文件各一条链。
  • Linux/macOS 上 pnpm 可能多用 symbolic linkWindows 上常见 Junction,理解时都可归入「目录指路」这一类,与 硬链接(文件去重) 区分开即可。

文档随本仓库 pnpm 默认行为整理;实际以本机 Get-Item / fsutil 结果为准。

Python 速记手册

2026年4月4日 04:38

Python 速记手册(含可运行 Demo 01~11)

6b41cd17ac5d47b5959ab5f226a4ec29(1).jpeg

适合人群:

  • 前端/全栈转 Python,需要“先跑起来”的最小路径
  • 只想快速掌握:语法、JSON、文件、请求、写接口、脚本自动化、Excel

git地址在这里:https://gitee.com/mslimyjj/old-ling-python/tree/master/python-cheatsheet-demos



0. 快速开始(先跑起来)

建议在 python-cheatsheet-demos/ 目录中运行命令(相对路径/生成文件最直观)。

python 01_basics.py

Windows:没有 python 命令怎么办

如果你的环境里 python 命令不可用,可以使用 Python 官方“嵌入式版本”(无需安装、不污染系统环境)。

python-cheatsheet-demos/ 目录执行:

$ver='3.11.8'
$zip="python-$ver-embed-amd64.zip"
$url="https://www.python.org/ftp/python/$ver/$zip"
$dest="..\.python-embed"
New-Item -ItemType Directory -Force -Path $dest | Out-Null
Invoke-WebRequest -Uri $url -OutFile "$dest\$zip"
Expand-Archive -Force -Path "$dest\$zip" -DestinationPath $dest
& "$dest\python.exe" 01_basics.py

后续统一用它运行 demo:

& "..\.python-embed\python.exe" 05_requests_demo.py

一键安装依赖

如果你用系统 Python:

pip install -r requirements.txt

如果你用嵌入式 Python:

& "..\.python-embed\python.exe" -m pip install -r requirements.txt

1. Demo 01:基础语法(变量 / 条件 / 循环 / 函数)

文件:01_basics.py

name = "张三"      # str
age = 20           # int
is_ok = True       # bool
arr = [1, 2, 3]    # list  = JS Array
obj = {"a": 1}     # dict  = JS Object

print(name, age, is_ok, arr, obj)

if age > 18:
    print("成年")
elif age == 18:
    print("刚成年")
else:
    print("未成年")

for item in [1, 2, 3]:
    print("item:", item)

for i in range(10):
    print("i:", i)


def add(a, b):
    return a + b


res = add(1, 2)
print("add:", res)

运行:

& "..\.python-embed\python.exe" 01_basics.py

2. Demo 02:list / dict(前端最熟)

文件:02_list_dict.py

arr = [1, 2, 3]
arr.append(4)
last = arr.pop()
print(arr, "popped:", last)
print("len:", len(arr))
print("first:", arr[0])

user = {
    "name": "tom",
    "age": 20,
}

print("name1:", user["name"])
print("name2:", user.get("name"))
print("missing:", user.get("addr"))

user["addr"] = "北京"
print(user)

你可以把 list 当成 JS 的数组,把 dict 当成 JS 的对象。

运行:

& "..\.python-embed\python.exe" 02_list_dict.py

3. Demo 03:JSON(前后端交互必备)

文件:03_json_demo.py

import json

data = json.loads('{"name":"tom","age":20}')
print(data, type(data))

str_data = json.dumps(data, ensure_ascii=False)
print(str_data, type(str_data))
  • json.loads:字符串 -> 对象
  • json.dumps:对象 -> 字符串

运行:

& "..\.python-embed\python.exe" 03_json_demo.py

4. Demo 04:文件读写(批量处理神器)

文件:04_file_io.py

import json

with open("test.txt", "w", encoding="utf-8") as f:
    f.write("hello\n")

with open("test.txt", "r", encoding="utf-8") as f:
    content = f.read()
print("content:", content)

payload = {"name": "tom", "age": 20}
with open("data.json", "w", encoding="utf-8") as f:
    json.dump(payload, f, ensure_ascii=False, indent=2)

with open("data.json", "r", encoding="utf-8") as f:
    data = json.load(f)
print("json:", data)

运行:

& "..\.python-embed\python.exe" 04_file_io.py

输出会读写当前目录的:

  • test.txt
  • data.json

5. Demo 05:requests(抓接口 / mock)

文件:05_requests_demo.py

import requests

res = requests.get("https://httpbin.org/get", params={"q": "test"}, timeout=10)
print("get status:", res.status_code)
print(res.json()["args"])

res = requests.post("https://httpbin.org/post", json={"username": "admin"}, timeout=10)
print("post status:", res.status_code)
print(res.json()["json"])

这个 demo 用 https://httpbin.org 作为测试服务,演示 GET/POST JSON。

运行:

& "..\.python-embed\python.exe" 05_requests_demo.py

6. Demo 06:FastAPI(快速写接口)

文件:06_fastapi_main.py

from fastapi import FastAPI

app = FastAPI()


@app.get("/api/user")
def get_user():
    return {"name": "tom", "age": 20}

安装依赖(如果还没装):

pip install fastapi uvicorn

运行:

uvicorn 06_fastapi_main:app --reload

访问:

  • http://localhost:8000/api/user
  • http://localhost:8000/docs

7. Demo 07:小脚本(遍历目录 / 批量重命名)

文件:07_scripts_os.py

import os

for file in os.listdir("./"):
    print(file)

imgs_dir = "./imgs"
if os.path.isdir(imgs_dir):
    for i, file in enumerate(os.listdir(imgs_dir)):
        src = os.path.join(imgs_dir, file)
        dst = os.path.join(imgs_dir, f"img{i}.png")
        if os.path.isfile(src):
            os.rename(src, dst)
            print("renamed:", src, "->", dst)

运行:

& "..\.python-embed\python.exe" 07_scripts_os.py

注意:批量重命名会操作 ./imgs 目录下文件名,运行前确认目录存在并且文件可改名。


8. Demo 08:对接 AI(HTTP 调用套路)

文件:08_ai_call.py

import requests


def call_ai(prompt: str):
    res = requests.post(
        "http://localhost:8000/ai",
        json={"prompt": prompt},
        timeout=30,
    )
    res.raise_for_status()
    return res.json()


code = call_ai("生成一个Vue3按钮组件")
print(code)

这个 demo 会调用:POST http://localhost:8000/ai

如果你本地没有启动这个服务,会出现连接被拒绝(这属于正常现象)。

运行:

& "..\.python-embed\python.exe" 08_ai_call.py

9. Demo 09:抓取网页请求并保存到 send.txt

文件:09_capture_requests.py

import argparse
import re
import sys
from urllib.parse import urlparse


def _normalize_url(u: str) -> str:
    try:
        p = urlparse(u)
        if not p.scheme or not p.netloc:
            return u
        return p._replace(fragment="").geturl()
    except Exception:
        return u


def _looks_like_api(u: str) -> bool:
    low = u.lower()
    if any(x in low for x in ("/api", "/graphql", "/v1/", "/v2/", "/rpc")):
        return True
    if any(low.endswith(x) for x in (".json", ".xml")):
        return True
    return False


def _capture_with_playwright(url: str, timeout_ms: int, only_api: bool) -> list[str]:
    try:
        from playwright.sync_api import sync_playwright  # type: ignore
    except Exception as e:
        raise RuntimeError("missing_playwright") from e

    seen: set[str] = set()
    out: list[str] = []

    def on_request(req):
        u = _normalize_url(req.url)
        if only_api and (not _looks_like_api(u)):
            return
        if u not in seen:
            seen.add(u)
            out.append(u)

    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context()
        page = context.new_page()
        page.on("request", on_request)
        page.goto(url, wait_until="networkidle", timeout=timeout_ms)
        try:
            page.wait_for_timeout(1500)
        except Exception:
            pass
        context.close()
        browser.close()

    return out


def _extract_from_html(url: str, timeout_sec: int, only_api: bool) -> list[str]:
    import requests

    html = requests.get(url, timeout=timeout_sec).text
    candidates = set(re.findall(r"https?://[^\s\"'>]+", html))
    cleaned = [_normalize_url(u) for u in candidates]
    if only_api:
        cleaned = [u for u in cleaned if _looks_like_api(u)]
    cleaned.sort()
    return cleaned


def main(argv: list[str]) -> int:
    parser = argparse.ArgumentParser()
    parser.add_argument("url")
    parser.add_argument("--out", default="send.txt")
    parser.add_argument("--timeout", type=int, default=30)
    parser.add_argument("--only-api", action="store_true")
    parser.add_argument("--mode", choices=["auto", "playwright", "html"], default="auto")
    args = parser.parse_args(argv)

    url = args.url
    out_path = args.out
    timeout_sec = args.timeout
    only_api = bool(args.only_api)
    mode = args.mode

    urls: list[str] = []
    if mode in ("auto", "playwright"):
        try:
            urls = _capture_with_playwright(url, timeout_sec * 1000, only_api)
        except RuntimeError as e:
            if str(e) != "missing_playwright" or mode == "playwright":
                raise
            urls = []

    if (not urls) and mode in ("auto", "html"):
        urls = _extract_from_html(url, timeout_sec, only_api)

    with open(out_path, "w", encoding="utf-8") as f:
        for u in urls:
            f.write(u + "\n")

    print(f"saved {len(urls)} urls -> {out_path}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))

目标:给一个网页 URL,把它“页面里出现的 URL / 或页面运行时发出的请求”抓出来,写到 send.txt

9.1 推荐模式:Playwright(最接近浏览器真实请求)

安装:

pip install playwright
python -m playwright install chromium

运行(只保留更像接口的 URL):

python 09_capture_requests.py https://example.com --only-api --out send.txt

9.2 兜底模式:HTML 提取(抓不到 XHR/fetch)

如果你不安装 Playwright,脚本会自动降级为“从 HTML 源码中提取 URL”。

你也可以强制:

python 09_capture_requests.py https://example.com --mode html --out send.txt

参数速记:

  • --mode playwright:强制用浏览器抓
  • --mode html:只解析 HTML
  • --only-api:只保留更像接口的 URL(包含 /api/graphql/v1//v2/.json 等)

10. Demo 10:3 秒后“键盘输入” Helloween(不是 print)

文件:10_sleep_print.py

import time

from pynput.keyboard import Controller


time.sleep(3)
Controller().type("Helloween")

这个 demo 用 pynput 控制键盘:

  • 等待 3 秒
  • 向“当前获得焦点的窗口”键入 Helloween

使用方法:先把光标点到你想输入的位置(例如记事本/浏览器输入框),再运行脚本。

运行:

& "..\.python-embed\python.exe" 10_sleep_print.py

11. Demo 11:生成 user.xlsx(随机 10 个姓名)

文件:11_generate_user_excel.py

import os
import random

from openpyxl import Workbook


def random_cn_name() -> str:
    surnames = list("赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜戚谢邹喻柏水窦章云苏潘葛范彭郎鲁韦昌马苗凤花方俞任袁柳酆鲍史唐费廉岑薛雷贺倪汤滕殷罗毕郝邬安常乐于时傅皮卞齐康伍余元卜顾孟平黄和穆萧尹姚邵湛汪祁毛禹狄米贝明臧计伏成戴谈宋茅庞熊纪舒屈项祝董梁杜阮蓝闵席季麻强贾路娄危江童颜郭梅盛林刁钟徐邱骆高夏蔡田樊胡凌霍虞万支柯昝管卢莫经房裘缪干解应宗丁宣贲邓郁单杭洪包诸左石崔吉钮龚程嵇邢滑裴陆荣翁荀羊於惠甄曲家封芮羿储靳汲邴糜松井段富巫乌焦巴弓牧隗山谷车侯宓蓬全郗班仰秋仲伊宫宁仇栾暴甘钭厉戎祖武符刘景詹束龙叶幸司韶郜黎蓟薄印宿白怀蒲邰从鄂索咸籍赖卓蔺屠蒙池乔阴郁胥能苍双闻莘党翟谭贡劳逄姬申扶堵冉宰郦雍却璩桑桂濮牛寿通边扈燕冀郏浦尚农温别庄晏柴瞿阎充慕连茹习宦艾鱼容向古易慎戈廖庾终暨居衡步都耿满弘匡国文寇广禄阙东欧殳沃利蔚越夔隆师巩厍聂晁勾敖融冷訾辛阚那简饶空曾毋沙乜养鞠须丰巢关蒯相查后荆红游竺权逯盖益桓公万俟司马上官欧阳夏侯诸葛闻人东方赫连皇甫尉迟公羊澹台公冶宗政濮阳淳于单于太叔申屠公孙仲孙轩辕令狐钟离宇文长孙慕容司徒司空")
    given_chars = list("一乙二十丁厂七卜人入八九几儿了力乃刀又三于干亏士工土才下寸大丈与万上小口山巾千乞川亿个夕久么勺丸凡及广亡门义之尸弓己已子卫也女飞刃习叉马乡丰王井开夫天元无云专扎艺木五支厅不太犬区历尤友匹车巨牙屯比互切瓦止少日中贝内水冈见手午牛毛气升长仁什片仆化仇币仍仅斤爪反介父从今凶分乏公仓月氏勿欠风丹匀乌凤勾文六方火为斗忆计订户认心尺引丑巴孔队办以允予劝双书幻玉刊末未示击打巧正扑扒功扔去甘世古节本术可丙左厉右石布龙平灭轧东卡北占业旧帅归且旦目叶甲申叮电号田由史只央兄叼叫另叨叹四生失禾丘付仗代仙们仪白仔他斥瓜乎丛令用甩印乐句匆册犯外处冬鸟务包饥主市立闪兰半汁汇头汉宁它讨写让礼训必议讯记永司尼民出辽奶奴召加皮边发孕圣对台矛纠母幼丝式刑动扛寺吉扣考托老执巩圾扩扫地扬场耳共芒亚芝朽朴机权过臣再协西压厌在有百存而页匠夸夺灰达列死成夹轨邪划迈毕至此贞师尘尖劣光当早吐吓虫曲团同吊吃因吸吗屿帆岁回岂则刚网肉年朱先丢舌竹迁乔伟传乒乓休伍伏优伐延件任伤价伦份华仰仿伙伪自伊血向似后行舟全会杀合兆企众爷伞创肌朵杂危旬旨负各名多争色壮冲妆冰庄庆亦刘齐交次衣产决充妄闭问闯羊并关米灯州汗污江池汤忙兴宇守宅字安讲军许论农讽设访那迅尽导异孙阵阳收阶阴防如妇好她妈戏羽观欢买红驮纤级约纪驰巡")
    surname = random.choice(surnames)
    given_len = random.choice([1, 2])
    given = "".join(random.choice(given_chars) for _ in range(given_len))
    return surname + given


def main():
    wb = Workbook()
    ws = wb.active
    ws.title = "user"

    ws.append(["id", "name"])
    for i in range(1, 11):
        ws.append([i, random_cn_name()])

    out_path = os.path.join(os.path.dirname(__file__), "user.xlsx")
    wb.save(out_path)
    print("saved ->", out_path)


if __name__ == "__main__":
    main()

这个 demo 用 openpyxl 生成 Excel:

  • 输出文件:user.xlsx
  • Sheet:user
  • 表头:idname
  • 数据:随机 10 个姓名

运行:

& "..\.python-embed\python.exe" 11_generate_user_excel.py

12. 你可以怎么用这套 demo

  • 当成“Python 最小工具箱”:复制某个脚本改两行就能完成临时需求
  • 当成“速记手册”:忘了 with open(...) / requests.get(...) 直接回来抄
  • 当成“前端同学上手模板”:从 05/06/09 开始就能快速进入“接口 + 自动化”节奏
昨天 — 2026年4月3日掘金 前端

省市区县乡镇街道三级四级联动数据源:2026年民政部和统计局已不再公布行政区划代码,可改用直接调国家地名信息库的接口

作者 xiangyuecn
2026年4月3日 23:29

国家地名信息库

国家地名信息库链接: dmfw.mca.gov.cn

接口服务文档:dmfw.mca.gov.cn/interface.h…

正常程序中使用时,将数据缓存起来存入文件或者数据库,比调用接口更稳定。

//直接在浏览器控制台执行,可以测试接口结果
/* 接口说明
code不填时获取省级,填了时获取对应的区划和下级数据
maxLevel=1只获取一级数据 maxLevel=2获取两级数据 maxLevel=3获取三级数据
*/
//获取全国省市区三级,将近300KB数据接口会比较慢
var response=await fetch("https://dmfw.mca.gov.cn/9095/xzqh/getList?code=&maxLevel=3");
var data=await response.json();
console.log(data);

//只获取获取省级数据,速度比较快
var response=await fetch("https://dmfw.mca.gov.cn/9095/xzqh/getList?code=&maxLevel=1");
var data=await response.json();
console.log(data);

//获取湖北省 省、市、区 三级
var response=await fetch("https://dmfw.mca.gov.cn/9095/xzqh/getList?code=420000000000&maxLevel=3");
var data=await response.json();
console.log(data);

//获取武汉市 市、区县、乡镇街道 三级
var response=await fetch("https://dmfw.mca.gov.cn/9095/xzqh/getList?code=420100000000&maxLevel=3");
var data=await response.json();
console.log(data);

注意:当直接获取省市区三级数据时,以下城市只有两级:

  1. 直辖市(如:北京、天津、上海、重庆)
  2. 不设区的市(如:东莞、中山、儋州、嘉峪关)
  3. 省直辖县级行政单位(如:济源、仙桃、琼海、胡杨河)

其他省市都有三级结构。

数据信息

统计局自2024年下半年起就不再公开统计用区划代码,改用国家地名信息库数据。

民政部公告相关链接:www.mca.gov.cn/n156/n186/i…

摘自民政部的公告:自2026年起,本栏目不再公布行政区划代码相关信息。请前往民政部门户网站首页的国家地名信息库版块查询相关信息。

已整合的开源库:github.com/xiangyuecn/…

开源库:已将四级数据整合到了单个csv文件中,同时提供标注拼音、坐标和四级边界范围。提供工具生成多级联动数据和代码,也支持将数据导入MySQL、MSSQL、PgSQL、Oracle等数据库中

【2026-04-03】国家地名信息库行政区划数据截止日期为2025年12月31日。

最近爆火的 Harness Engineering 被我提炼成了 SKILL,小白也能快速上手

作者 Justin3go
2026年4月3日 20:53

✨文章摘要(AI生成)

笔者分享了将 Harness Engineering 知识提炼为可复用 Agent Skill 的经验。在系统阅读了 Anthropic、OpenAI、Martin Fowler、LangChain 等来源的文章后,提炼出 Harness 设计的七个核心层:项目搭建、上下文工程、约束与防护、多 Agent 架构、评估与反馈、长时间任务、诊断。最终产出的 harness-engineering 技能覆盖三大场景——新项目搭建、Agent 行为诊断、持续改进,采用渐进式披露架构。定量评估显示有技能时断言通过率 100%,无技能时 83%。核心洞察:Agent 表现不好,80% 的原因不在模型,在 Harness。

为什么写这个

最近两年,笔者在使用各种AI编码助手(Claude Code、Cursor、Copilot等)的过程中,反复遇到一个问题:Agent时好时坏,虽然整体来说随着模型能力进步是向好的,但是向好的过程是曲折波动的。

有时候它写的代码完美契合项目风格,有时候它像个第一天入职的实习生——不知道项目结构、不遵守约定、还把之前商量好的决策忘得一干二净。

然后开始从 Prompt Engineering 中使用结构化、few shot、few example 等技巧,来让 AI 的输出更加稳定。 后面又使用 Context Engineering 来让 Agent 的上下文更加丰富,来让 Agent 的表现更加稳定。

最近几周,一个更系统的词汇出现了:Harness Engineering。

Agent表现不好,80%的原因不在模型,在Harness。 - Anthropic

什么是Harness?简单说:

  • 模型 = CPU(算力本身)
  • 上下文窗口 = RAM(工作记忆)
  • Harness = 操作系统(调度、约束、反馈、文件系统——一切让CPU有效工作的基础设施)

你不会指望一个CPU在没有操作系统的裸机上高效运行。同理,你也不该指望一个模型在没有Harness的项目里稳定输出。

我学到了什么

笔者系统阅读了以下来源的文章:

  • Anthropic — 构建高效Agent、多Agent研究系统、长时间运行Agent的Harness设计
  • OpenAI — AGENTS.md设计模式、Context Engineering最佳实践
  • Martin Fowler — Harness Engineering的工程哲学("Relocating Rigor")
  • LangChain — Agent框架 vs 运行时 vs Harness的分类学
  • philschmid — 2026年Agent Harness的重要性
  • 独立开发者实践 — Hermes Agent的自演化、Vue Lynx的设计笔记驱动开发
  • 学术论文 — 自然语言Agent Harness的形式化研究

读完之后,我发现这些文章虽然角度各异,但核心思想收敛到了七个层

层级 解决什么问题 一句话总结
项目搭建 Agent不知道项目是什么 AGENTS.md是目录,不是百科全书
上下文工程 Agent看到的信息不对 给地图,不给手册
约束与防护 Agent犯重复的错 每犯一次错,加一条规则
多Agent架构 单Agent搞不定复杂任务 分工明确,协议清晰
评估与反馈 不知道Agent做得好不好 让AI检查AI
长时间任务 Agent跑着跑着就走偏了 进度文件 + 上下文重置
诊断 用户骂Agent不好用 问题在Harness,不在模型

所以我做了个技能

读完这些文章,笔者意识到这些模式完全是可复用的。不管你的项目是React前端、Python后端还是Rust CLI工具——Harness的设计原则是通用的。

于是我把这些知识提炼成了一个 Agent Skill,名叫 harness-engineering

它做什么

这个技能有三个核心使用场景:

场景一:新项目搭建

当你启动一个新项目,告诉Agent"帮我搭建Harness工程",它会:

  1. 评估你的项目类型、技术栈、团队规模
  2. 创建 AGENTS.md(表of目录式的Agent导航文件)
  3. 建立 docs/ 目录(架构、约定、数据模型等)
  4. 配置约束层(lint规则、类型检查、pre-commit hooks)
  5. 设置评估与反馈机制

场景二:Agent表现不佳时的诊断

这是最有意思的场景。当你开始抱怨——

  • "它怎么又犯同样的错误?"
  • "它根本不遵守我们的约定!"
  • "它写的代码质量太差了"

这个技能会被触发,引导Agent去诊断Harness层的缺失,而不是怪模型:

你的抱怨 大概率原因 修复方式
总犯同一个错 没有约束阻止它 加一条lint规则
不遵守约定 约定没写下来或Agent找不到 写入docs/,在AGENTS.md中引用
忘记之前的决定 跨会话上下文未持久化 用progress.md记录决策
代码质量差 没有好代码的示例 在DESIGN_NOTES.md中加示例

场景三:持续改进

每次发现新的可复用Harness模式,更新到技能中,让它在其他项目中也能受益。

它怎么组织的

技能采用渐进式加载架构:

harness-engineering/
├── SKILL.md              # 入口文件(<60行),路由到具体参考文档
└── references/
    ├── 01-project-setup.md       # 项目搭建
    ├── 02-context-engineering.md  # 上下文工程
    ├── 03-constraints.md          # 约束与防护
    ├── 04-multi-agent.md          # 多Agent架构
    ├── 05-eval-feedback.md        # 评估与反馈
    ├── 06-long-running.md         # 长时间任务
    └── 07-diagnosis.md            # 诊断

SKILL.md本身非常精简——它就像一个路由器,根据当前场景指引Agent去读对应的参考文档。这遵循了Harness Engineering本身的原则:渐进式披露,按需加载

几个让我印象深刻的模式

有几个模式特别触动笔者,感同身受,这里单独拿出来聊聊。

"给地图,不给手册"

这个观点从推文中看到。传统做法是给Agent写详细的分步指令(手册),但这让Agent变得脆弱——任何偏差都会导致它不知所措。

更好的做法是给Agent一张地图

# 不好的写法(手册)
Step 1: 打开 src/auth/login.ts
Step 2: 找到 handleLogin 函数
Step 3: 在第42行添加...

# 好的写法(地图)
Auth系统在 src/auth/。登录流程:login.ts → validate.ts → session.ts。
限流中间件在 src/middleware/rateLimit.ts——参考它的模式。
每次修改auth都要在 src/auth/__tests__/ 里加测试。

地图让Agent能自主导航,手册让它成为脆弱的执行机器。

"每犯一次错,加一条规则"

这个模式来自多篇文章的交叉验证。核心思想:

  1. Agent犯了一个错
  2. 你修复了这个错
  3. 然后你加一条规则,永远阻止这类错再次发生

这条规则可以是lint规则、类型约束、测试用例,或者只是文档中的一条约定。随着时间推移,Harness积累了越来越多的规则,Agent的错误率对已知模式趋近于零。

这其实就是Martin Fowler说的 "Relocating Rigor"——把人类通过Code Review、经验、直觉实施的质量把关,迁移到自动化检查中。Agent在被检查的边界内自由运行。

Harness = 数据集

这个观点来自Anthropic。每次Agent交互都是一个训练信号:

  • 它尝试了什么
  • 什么成功了
  • 什么失败了
  • 修复方案是什么

这些痕迹(traces)就是你的竞争优势。它们是让你的Harness随时间越来越好的数据——不是微调模型,而是优化操作系统。

技能评估:有没有用?

笔者遵循skill-creator的流程,对这个技能做了定量评估。设计了3组测试场景,每组跑with-skill和without-skill两个版本:

测试场景 有技能 无技能
新项目搭建 6/6 ✅ 4/6
Agent行为诊断 6/6 ✅ 5/6
跨模块依赖问题 6/6 ✅ 6/6
合计 18/18 (100%) 15/18 (83%)

有技能的版本在所有场景下都通过了全部断言。无技能的版本在"新项目搭建"场景下缺失较多——它不知道要创建AGENTS.md、不知道docs/应该怎么组织、不会设置渐进式披露的上下文架构。

当然,17%的差距不算巨大。但关键是:有技能时Agent的输出一致且完整,无技能时看运气。对于一个工程实践类技能来说,一致性比偶尔的惊艳更有价值。

怎么安装

这个技能可通过 GitHub 安装:

npx skills add 10xChengTu/harness-engineering

安装后,当你在Claude Code、OpenCode或其他支持Skills的Agent中工作时:

  • 启动新项目 → 技能自动触发,引导搭建Harness
  • 遇到Agent质量问题 → 开始抱怨时技能会介入诊断
  • 主动询问 → "帮我改进这个项目的Harness"

最后

Harness Engineering目前还是一个非常早期的领域。模型在变强,今天需要的约束明天可能就多余了——所以这个技能本身也遵循一个核心原则:为删除而构建

如果你也在用AI Agent做开发,不妨试试给你的项目加上Harness。从最简单的开始——一个AGENTS.md文件、几条lint规则、一个progress.md。然后观察Agent的表现变化。

你大概率会和笔者有同样的感受:不是模型不行,是我们没给它一个好的工作环境。

本文涉及的所有参考文章和完整技能源码,均可在GitHub 仓库中找到。

使用 Ollama 在本地运行 AI Agent — 不需要 API Key

作者 王小酱
2026年4月3日 19:20

背景

如果你的目标是学习 Agent 概念、测试架构设计、做技术调研,根本不需要付费 API。Ollama 可以让你在本地电脑上运行开源大模型,并且提供 OpenAI 兼容的 API 接口,主流 Agent 框架都能直接对接。

本文介绍如何用 Ollama 搭建一个完全本地化的 AI Agent 开发环境。无需 API Key,无需联网,无需任何费用。

三种方案怎么选

方案 适用场景 成本 前置条件
Ollama(本文) 学习、调研、架构实验、离线开发 免费 本地机器 8GB+ 内存
Claude Agent SDK 需要 Claude 级别智能的内部原型验证 共享订阅额度 Claude Code CLI + Enterprise 登录
LLM Gateway API 生产环境、对外服务 按 token 计费 审批 API Key

简单原则:先用 Ollama 验证想法、理解 Agent 运行机制。需要 Claude 级别推理能力时切换到 Agent SDK。上生产时申请 LLM Gateway API。

Ollama 是什么

Ollama 是一个开源的本地大模型运行工具,封装了 llama.cpp,提供简单的 CLI 和内置的 OpenAI 兼容 API 服务(http://localhost:11434)。核心特点:

  • 支持 Llama、Qwen、Mistral、DeepSeek、Gemma、Phi 等主流模型系列
  • GPU 加速:NVIDIA (CUDA)、Apple Silicon (Metal)、AMD (ROCm)
  • 内置 Tool Calling / Function Calling 支持(需要模型本身支持)
  • OpenAI 兼容 API — 改个 base_url 就能用,框架代码不用改
  • CLI 管理模型:ollama pullollama runollama list

快速开始

第一步:安装 Ollama

macOS / Linux:

curl -fsSL https://ollama.com/install.sh | sh

Windows:

winget install Ollama.Ollama

验证安装:

ollama --version

第二步:拉取模型

Agent 开发需要支持 Tool Calling 的模型,推荐:

# 推荐:Qwen3.5 9B — 最新一代,原生视觉 + Tool Calling + Thinking,256K 上下文
ollama pull qwen3.5:9b

# 轻量替代:Qwen3.5 4B — 8GB 内存机器也能跑
ollama pull qwen3.5:4b

# 更强推理能力(需要 24GB+ 内存):Qwen3.5 27B
ollama pull qwen3.5:27b

# MoE 选项 — 总参数 35B 但仅激活 3B,22GB 设备可运行
ollama pull qwen3.5:35b

硬件参考:qwen3.5:9b 约 6.6GB(大部分 16GB 机器轻松运行)。qwen3.5:4b 约 3.4GB(8GB 内存笔记本也行)。qwen3.5:27b 和 35b 需要 24GB+ 内存/显存。16GB 的 Apple Silicon Mac 可以流畅运行 9B 模型。

第三步:验证 API

Ollama 启动后自动提供 API 服务,测试一下:

curl http://localhost:11434/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "qwen3.5:9b",
    "messages": [
      {"role": "user", "content": "你好!"}
    ]
  }'

收到 JSON 回复就说明环境就绪。

使用 Ollama 构建 Agent

Ollama 的 OpenAI 兼容 API 意味着你可以无缝接入任何支持 OpenAI 格式的框架。以下是最常用的几种模式。

模式 A — 直接用 OpenAI SDK(最简单)

官方 OpenAI Python SDK 可以直接对接 Ollama,只需改 base_url

from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:11434/v1",
    api_key="ollama",  # SDK 要求填写,但 Ollama 不校验
)

response = client.chat.completions.create(
    model="qwen3.5:9b",
    messages=[
        {"role": "system", "content": "你是一个有帮助的助手。"},
        {"role": "user", "content": "用3句话解释什么是 AI Agent。"},
    ],
)
print(response.choices[0].message.content)

模式 B — Tool Calling / Function Calling

Agent 开发的核心 — 让模型自主决定何时调用外部工具:

import json
from openai import OpenAI

client = OpenAI(
    base_url="http://localhost:11434/v1",
    api_key="ollama",
)

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "获取指定城市的当前天气",
            "parameters": {
                "type": "object",
                "properties": {
                    "city": {"type": "string", "description": "城市名称"}
                },
                "required": ["city"],
            },
        },
    }
]

response = client.chat.completions.create(
    model="qwen3.5:9b",
    messages=[{"role": "user", "content": "香港今天天气怎么样?"}],
    tools=tools,
)

msg = response.choices[0].message
if msg.tool_calls:
    for call in msg.tool_calls:
        print(f"模型要调用: {call.function.name}")
        print(f"参数: {call.function.arguments}")
else:
    print(msg.content)

模式 C — 完整 Agent 循环

一个最小化的 Agent 循环,展示完整流程:用户输入 → 模型推理 → 工具执行 → 模型综合回答:

import json
from openai import OpenAI

client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")

# 模拟工具实现
def get_weather(city: str) -&gt; str:
    return json.dumps({"city": city, "temp": "28°C", "condition": "晴"})

def search_docs(query: str) -&gt; str:
    return json.dumps({"results": [f"找到关于 '{query}' 的文档"]})

TOOL_MAP = {"get_weather": get_weather, "search_docs": search_docs}

tools = [
    {
        "type": "function",
        "function": {
            "name": "get_weather",
            "description": "获取指定城市的当前天气",
            "parameters": {
                "type": "object",
                "properties": {"city": {"type": "string"}},
                "required": ["city"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "search_docs",
            "description": "按关键词搜索内部文档",
            "parameters": {
                "type": "object",
                "properties": {"query": {"type": "string"}},
                "required": ["query"],
            },
        },
    },
]

def agent_run(user_input: str):
    messages = [
        {"role": "system", "content": "你是一个有帮助的助手,需要时使用工具。"},
        {"role": "user", "content": user_input},
    ]

    # 第一步:模型初次调用
    response = client.chat.completions.create(
        model="qwen3.5:9b", messages=messages, tools=tools
    )
    msg = response.choices[0].message
    messages.append(msg)

    # 第二步:如果模型请求调用工具,执行它
    if msg.tool_calls:
        for call in msg.tool_calls:
            fn = TOOL_MAP.get(call.function.name)
            if fn:
                result = fn(**json.loads(call.function.arguments))
                messages.append({
                    "role": "tool",
                    "tool_call_id": call.id,
                    "content": result,
                })

        # 第三步:模型综合工具结果生成最终回答
        final = client.chat.completions.create(
            model="qwen3.5:9b", messages=messages, tools=tools
        )
        return final.choices[0].message.content

    return msg.content

# 试试
print(agent_run("香港今天天气怎么样?"))
print(agent_run("帮我搜索 Agent 架构相关文档"))

这就是所有 Agent 框架背后的基本模式。理解了这个循环,就可以逐步构建更复杂的 Agent。

可选:LiteLLM 统一网关

大部分本地开发场景 Ollama 就够了。但如果你需要:

  • 在多个模型之间路由(例如本地 Ollama + 云端备选)
  • 添加日志、成本追踪、限流
  • 用同一套代码测试多种模型后端

可以用 LiteLLM 作为 Ollama 前面的代理:

pip install litellm[proxy]

# 启动代理,指向本地 Ollama
litellm --model ollama/qwen3.5:9b --port 8000

然后应用指向 http://localhost:8000,代码还是标准 OpenAI SDK 格式,不需要任何改动。

当你的 Agent 代码不变但想通过配置而非代码来切换本地(Ollama)和云端(LLM Gateway)模型时,LiteLLM 很有用。

优势

  1. 零成本:不需要 API Key,不产生 token 费用,不消耗订阅额度。本机硬件跑多少都行。
  2. 完全隐私:数据完全在本地,不需要联网。用什么数据做实验都没有隐私顾虑。
  3. 理解原理:直接使用开源模型,能真正理解 Tool Calling、上下文管理、Agent 循环是怎么运作的,不被商业 API 的抽象挡住。
  4. 框架无关:Ollama 的 OpenAI 兼容 API 支持 LangChain、LlamaIndex、CrewAI、AutoGen、smolagents 等几乎所有主流框架。
  5. 快速迭代:没有速率限制,没有网络延迟。启动模型、测试、调整、重复。

限制与注意事项

  1. 模型能力差距:开源 8B–32B 模型在复杂推理、长上下文、精确 Tool Calling 上与 Claude Sonnet/Opus 有明显差距。工具参数生成和指令遵循的出错率更高。
  2. Tool Calling 可靠性:并非所有模型的 Tool Calling 都一样好。Qwen3.5、Llama 3.3、GLM-4 支持最好。小模型(<9B)容易出现工具调用幻觉或参数遗漏。Agent 任务建议 temperature 设为 0–0.2。
  3. 硬件要求:本地跑模型需要 CPU/GPU 和内存资源。8B 模型大部分机器没问题。32B+ 模型需要较强硬件(32GB+ 内存或 24GB+ 显存的独立显卡)。
  4. 不适合生产:本方案用于开发、学习和研究。生产服务请使用 LLM Gateway API,有 SLA、计费和监控保障。
  5. 无内置 MCP 支持:与 Claude Code 不同,Ollama 不原生集成 MCP Server。需要在 Agent 代码中自行实现 MCP 客户端逻辑,或使用支持 MCP 的框架。

从本地到生产

在本地用 Ollama 验证 Agent 设计后,切换到生产环境非常简单,因为 API 接口格式一致:

  1. 切换到 Claude Agent SDK 做内部测试 — 只需更换认证方式,代码结构不变。
  2. 切换到 LLM Gateway API 上生产 — 把 base_url 改成网关地址,api_key 填网关密钥。同样的 OpenAI SDK 代码,不同的端点。
# 本地开发(Ollama)
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")

# 生产环境(LLM Gateway)
client = OpenAI(base_url="https://&lt;gateway-url&gt;/v1", api_key="your-gateway-key")

Agent 逻辑、工具定义、提示词完全不变。这就是基于 OpenAI 兼容 API 标准构建的核心好处。

Agent 开发推荐模型

模型 参数量 Tool Calling 适用场景 最低内存
qwen3.5:9b 9B 日常 Agent 开发、实验、代码任务 8GB
qwen3.5:4b 4B 一般 硬件受限时的轻量测试 8GB
qwen3.5:27b 27B 很好 复杂推理、接近生产级测试 24GB
qwen3.5:35b 35B (MoE, 3B 激活) 很好 较强能力 + 中等硬件要求 22GB
llama3.3:8b 8B 通用任务 8GB

运行 ollama list 查看已安装模型,ollama pull &lt;model&gt; 下载新模型。

参考资源

连载03-commands ---一起吃透 Claude Code,告别 AI coding 迷茫

2026年4月3日 17:59

为什么 Claude Code 要有指令(Commands):本质是上下文管理

AI Coding 系列第 03 篇 · 上下文与 Commands


这篇想回答三个问题:

  1. 为什么一开始很好用的 Claude Code,会在长会话里越来越"跑偏"?
  2. 为什么有些纠正你明明说过很多次,它还是会回退?
  3. 为什么 Claude Code 要用 /clear/compact/memory 这种命令,而不是全做成按钮?

如果你已经是高频用户,这篇不会提供很多新奇技巧,但会把这些现象背后的机制串成一套可操作的框架。对刚进入稳定开发阶段的用户,这比记住几个命令更重要。


先给结论

Claude Code 里的很多命令,看起来像快捷操作,实际上是在帮你管理三个东西:

  1. Claude 现在看到了什么
  2. Claude 接下来应该忘掉什么
  3. 哪些规则不应该继续留在“会衰减的对话历史”里

如果只把 /clear/compact/memory 当成“方便一点的小功能”,你会低估它们的价值。它们真正解决的,是长会话里最常见、也最让人误判的协作问题:Claude 不是突然变差了,而是上下文开始劣化了。


你大概遇到过这种情况

开始一个新任务,头几轮对话质量很高——Claude 理解你的意图,给出准确的方案,代码风格和项目一致。

但聊着聊着,情况变了:它开始重提已经否掉的方案,重复犯你纠正过的错误,或者在一个局部问题上越绕越深,忘了你们最初要解决什么。

这不是 Claude 变笨了,也不是你的 Prompt 写差了。这是上下文劣化——一个有规律、可以预测、也可以干预的现象。


一个贯穿全文的真实场景

假设你在做一个登录模块,从头到尾大概会经历这样一条线:

  1. 前期你和 Claude 一起讨论技术方案,聊过 Session、JWT、Redis、PostgreSQL,最后决定先用 PostgreSQL + JWT,把主流程跑通。
  2. 接着你开始写代码,发现 Claude 老是顺手加 console.log,你纠正了它两次。
  3. 做到一半你又决定把 ORM 从 Sequelize 换成 Prisma,因为前者在这个项目里太重。
  4. 再往后,对话已经很长了,你只是问一个事务边界问题,Claude 却开始同时聊缓存、日志、前端错误提示和部署建议。

如果你不理解上下文管理,这整条链看起来像是:“Claude 前面挺聪明,后面越来越不靠谱。”

但如果从上下文角度看,这其实是四类不同问题叠在一起:

  • 前面讨论过但已经否掉的方案,还残留在历史里
  • 你纠正过的规则,还停留在会衰减的对话层
  • 已经废弃的 Sequelize 路线,还在干扰后续回答
  • 对话长度过大以后,Claude 的注意力开始发散

也正因为它们是四类不同问题,所以不能指望一个万能 Prompt 解决。你需要的不是“再说清楚一点”,而是把正确的动作放在正确的层级上:该 /compact 的时候压缩,该 /clear 的时候重开,该进 CLAUDE.md 的规则就不要继续留在聊天记录里。


为什么 Claude Code 用命令,而不是全做成按钮

刚接触 Claude Code 的人常问:为什么不把这些能力做成 GUI?为什么要用 /clear/compact 这种命令?

因为 Claude Code 管理的不是"功能开关",而是 Claude 此刻正在处理的内容。

举个对比:你在 VS Code 里点"开启 dark mode",改的是软件的配置文件,和编辑器当前打开了什么文件没关系。但你在 Claude Code 里输入 /clear,清掉的是 Claude 脑子里正在处理的所有东西——之前讨论过的方案、做过的决策、来来覆去的对话,全部抹掉,让它重新认识你。这不是改配置,是在决定 Claude 此刻"知道什么、记得什么"。

命令形式在这里有几个优势:它可重复,你可以稳定复现同一种操作;它可传达,你可以直接告诉同事"先 /clear 再开始";它也更容易固化成工作流,比如"任务切换先 /clear,长会话中途 /compact,提交前 /review"。对还在快速迭代的 AI 工具来说,命令也比 GUI 更容易快速交付新能力。

所以 Claude Code 的命令系统,本质上是一套让你主动管理 AI 工作现场的操控面板


四种上下文劣化模式

Claude Code 的 4 种上下文劣化转存失败,建议直接上传图片文件

模式一:早期探索污染后期决策

任务开始时,你和 Claude 在讨论方案,思路是发散的。"要不要用 Redis?""用 JWT 还是 Session?""这个表要不要拆?"——这些都是探索性的讨论,很多想法最终被否决了。

问题是:这些被否决的想法还留在上下文里,和最终确定的方案并排存在。Claude 看到的是一串讨论记录,它会对"用 Redis"和"不用 Redis"这两种可能性都保持某种权重。对话越长,早期探索的内容就越像已确认的决策。

例如:

你:认证这块先别上 Redis,先用 PostgreSQL 把主流程跑通。
Claude:好,先按 PostgreSQL 方案实现。
...
(十几轮后)
Claude:为了提高性能,建议把 session 放到 Redis。

你明明在第 5 轮确认了用 PostgreSQL,第 25 轮它开始建议你考虑一下 Redis 的方案。

模式二:纠正回退

你发现 Claude 用错了某个写法,纠正了它。它承认了,改对了。五轮之后,它犯了同样的错误。

这是因为"纠正"发生在对话的第 8 轮,而你现在在第 15 轮。纠正的内容在上下文里的位置越来越靠后,在 lost-in-the-middle 的注意力分布下,它的权重持续降低,直到 Claude 实际上已经"忘了"那次纠正,重新回到了训练数据里的默认行为。

例如:

你:不要用 console.log,用 logger.info。
Claude:好的,我改成 logger.info。
...
(五轮后)
Claude:这里我先加一个 console.log 方便排查。

你已经说了三次"不要用 console.log,用 logger.info",但它还是偶尔会写 console.log

模式三:废弃方案的幽灵

你尝试了一种实现方式,做到一半发现不对,放弃了,换了另一种方式。旧的代码删掉了,但关于旧方案的讨论还留在上下文里。

这段"废弃的历史"会持续影响 Claude 的输出——它可能在新方案里混入旧方案的逻辑,或者在你遇到问题时建议你回到旧方案,因为旧方案在它的上下文里看起来也是一个"被讨论过的合理选项"。

例如:

你:Sequelize 这条路不走了,切到 Prisma。
Claude:明白,后续都按 Prisma 来。
...
(后来你问一个查询问题)
Claude:你可以在 Sequelize 的 include 里这样写...

你已经换掉了 ORM,但 Claude 还在参考旧 ORM 的写法给你示例。

模式四:注意力发散

随着上下文增长,Claude 需要处理的信息量越来越大。注意力是有限的——分给了 A 就少给了 B。越是长对话,Claude 越难在某一个具体问题上保持高度聚焦。它的回答开始变得面面俱到但不够深入,或者在你问一个具体问题时夹带了很多你没问的背景讨论。

例如:

你:只看这个接口的事务边界,哪里可能有问题?
Claude:这个接口本身有事务问题。另外认证模块、日志方案、缓存策略、
      前端错误提示也建议一起调整...

任务越来越复杂,但 Claude 的回答越来越泛,不够犀利。


理解了劣化,命令就有了意义

这四种模式,对应的干预手段是不同的:

劣化模式 干预手段 原因
早期探索污染 /compact 明确声明保留哪些决策 压缩时主动过滤探索内容,只留结论
纠正回退 把纠正写进 CLAUDE.md 从"对话历史"变成"系统注入",每轮强制生效
废弃方案幽灵 /clear 重开一个干净会话 彻底清除废弃历史,而不是试图覆盖它
注意力发散 拆任务 + 每个任务独立会话 每个会话只有一个聚焦点

但这些命令本质上是不同类型的东西,可靠性和适用场景差别很大——这是大多数人没意识到的。


5 条最实用的决策规则

如果你只想带走最实用的部分,可以先记这 5 条:

  1. 任务已经切换,就先 /clear。不要让上一个任务的残留背景继续污染当前任务。
  2. 任务没切,但会话已经很长,就 /compact。而且最好写清楚“只保留哪些已确认决策”。
  3. 同一条规则纠正两次以上,就别再聊了,直接写进 CLAUDE.md
  4. 你要复用的是“提示词流程”,就做成自定义 command;你要复用的是“带权限约束的能力”,就做成 skill。
  5. 当 Claude 开始“什么都懂一点但什么都答不深”时,优先怀疑上下文过载,而不是先怀疑模型突然变差。

这 5 条的价值,在于它们能把“模糊感觉”迅速翻译成明确动作。真正影响协作质量的,不是你知不知道这些概念,而是你能不能在出现问题的当下做对动作。


Claude Code 命令的三种类型

Claude Code 的 3 类指令 / Commands转存失败,建议直接上传图片文件

第一类:CLI 状态操作命令

这些命令直接操作 Claude Code 进程的内部状态,不经过 AI 模型,执行的是确定性的代码逻辑。

/clear    → 直接清空内存里的对话历史数组
/cost     → 读取 token 计数器,格式化输出
/model    → 修改当前会话的模型配置
/memory   → 读取和展示 Memory 目录下的文件内容
/help     → 输出命令列表

关键特性:结果确定,不依赖 Claude 的理解。 /clear 必然清空,/cost 必然显示费用,不会因为你的 Prompt 写得好不好而有差异。这类命令是系统层面的操作,不是 AI 行为。

第二类:自定义 Slash Command(提示词模板)

这类命令存放在 .claude/commands/ 目录下,每个命令是一个 .md 文件。文件名就是命令名,文件内容就是命令触发时注入给 Claude 的提示词。

.claude/
  commands/
    review.md      → /review 命令
    pr-desc.md     → /pr-desc 命令
    standup.md     → /standup 命令

可以用 $ARGUMENTS 接收参数:

<!-- .claude/commands/review.md -->
对以下代码做专项 review,聚焦:$ARGUMENTS

检查顺序:
1. 安全漏洞(SQL 注入、权限校验缺失)
2. 边界条件和错误处理
3. 项目规范符合性(参考 CLAUDE.md)

每个问题标注严重等级:blocking / warning / suggestion

关键特性:本质上是一次对话,经过 AI 模型处理,结果有随机性。 你写的是提示词,不是程序——好的自定义命令写法和好的 Prompt 写法是一回事:具体、有约束、有明确的输出格式。

第三类:Skills(能力包触发)

Skills 比自定义命令更重,有完整的元数据配置:限制工具权限(allowed-tools)、指定触发条件(when_to_use)、选择模型(model)、设置上下文隔离(context: fork)。

---
name: security-audit
description: 安全审查
when_to_use: 审查代码安全漏洞时
allowed-tools:
  - Read
  - Grep
  - Glob
model: claude-opus-4-5
context: fork
---
 ${target} 执行安全审查...

Skills 触发时,会在隔离的子上下文里运行,工具权限是物理隔离的(不是靠 Claude 自律),结束后把结果返回主会话。

关键特性:比自定义命令更结构化,工具权限有硬约束,可以和主会话隔离运行。 适合封装有副作用、需要权限控制的操作。第 05 篇会专门讲 Skills 的设计。


三类命令的选用原则

需要确定性结果,不想靠 Claude 判断 → 第一类 CLI 命令。清空上下文、查费用、切模型,这些操作不该有歧义。

想复用一套工作流程,不需要特殊权限控制 → 第二类自定义命令。把反复用到的提示词结构固化下来,/standup/pr-desc/review 这类日常命令都适合。注意:它还是提示词,不是代码。

封装有风险的操作,或者需要隔离运行 → 第三类 Skills。权限隔离只有 Skills 能做到。


两个值得停下来想的洞见

什么时候该 /clear、/compact、改 CLAUDE.md转存失败,建议直接上传图片文件

把命令类型和劣化模式放在一起,有两个反直觉的结论。

洞见一:在对话里纠正 Claude 是徒劳的——这是机制决定的,不是你说得不够清楚

模式二"纠正回退"的根本原因不是 Claude 不配合,而是你在用错误的工具纠正它。

在对话里说"不要用 console.log",这条纠正被写进了"会随时间衰减的历史"——位置越来越靠后,注意力权重持续降低,最终必然回退。这不是偶然的,是 lost-in-the-middle 的机制决定的。

更重要的是,这件事其实很容易自己验证。你可以做一个小实验:

  1. 在纯对话里告诉 Claude:"不要用 console.log,用 logger.info。"
  2. 继续推进几轮任务,再让它生成新代码。
  3. 然后把同一条规则写进 CLAUDE.md,再重复一次类似流程。

大多数时候你会发现,两种方式的持久性差别非常明显。前者更容易回退,后者更稳定。这比单纯讲原理更有说服力,因为你能亲手看到规则所在层级不同,稳定性就不同。

真正有效的纠正只有一种:把规则从对话历史移进系统注入层。

# CLAUDE.md
- 日志统一用 logger.info/warn/error,禁止 console.log
- 所有异步函数必须有 try-catch,不依赖外层中间件捕获
- 禁止使用 any,类型必须明确

写进 CLAUDE.md 的规则,在每次对话开始时被系统自动注入,优先级高于对话历史,不会随对话长度衰减。这是 CLAUDE.md 存在的真实原因——不是"项目文档",是绕过对话历史衰减的唯一可靠手段

判断标准很简单:如果你对同一件事纠正了两次以上,就不该继续在对话里纠正,而应该把它写进 CLAUDE.md。

洞见二:/compact 不是无损压缩,它本身就是一次 AI 调用

很多人以为 /compact 是把历史"存档"了,实际上 Claude Code 在压缩时会调用模型生成摘要——这意味着压缩结果的质量,取决于 Claude 怎么理解这段历史。

这里不需要依赖源码猜。单从行为上你就能判断出来:/compact 不是简单的机械压缩,而是在"理解历史之后生成摘要"。

为什么这么说?因为如果它只是确定性的算法压缩,那么你补不补"保留说明",结果应该差异很小;但实际使用中,空着用和带明确保留说明用,摘要质量往往差很多。这更像是模型在根据你的提示重新组织历史,而不是程序在做无损归档。

你在 /compact 后面附加的保留说明,本质上就是在告诉 Claude:哪些内容应该成为压缩后的锚点。有没有写、写了什么,会直接影响压缩后的摘要长什么样。

这有两个实际含义:

第一,/compact 不在第一类"确定性命令"里——尽管它看起来是内置命令,但压缩结果是 AI 行为,不是代码行为,存在质量差异。

第二,空着用和带保留说明用,结果可以差很多:

❌ /compact
   → Claude 自己判断什么重要,探索性讨论和已确认决策同等对待

✅ /compact 只保留已确认的决策:JWT 方案、Prisma 数据库表结构、
            错误处理用 AppError 类。探索阶段被否决的方案不需要保留。
   → Claude 围绕这些锚点生成摘要,后续对话里这些决策记得最清楚

如果你一直在空着用 /compact,本质上是在让 Claude 替你决定什么值得记住。


两个可以立刻自己验证的小实验

如果你想判断这篇文章讲的是不是“经验之谈”,最好的办法不是相信我,而是自己试一下。

实验一:同一条规则,留在对话里 vs 写进 CLAUDE.md

找一条你平时经常纠正的规则,比如:

不要用 console.log,用 logger.info

先只在对话里说这条规则,继续推进几轮任务,再让 Claude 生成新代码。然后把同一条规则写进 CLAUDE.md,重新开始一个类似任务,再观察它的稳定性。

你大概率会看到一个很明显的差别:
留在对话里的规则更容易回退;写进 CLAUDE.md 的规则更稳定。

这个实验最重要的启发不是“CLAUDE.md 很有用”,而是:规则所在的层级不同,稳定性就不同。

实验二:空着 /compact vs 带保留说明 /compact

找一段讨论过很多方案的长会话。先在类似场景里直接执行:

/compact

再换一次,在压缩时明确写:

/compact 只保留已确认的决策:JWT 方案、Prisma 表结构、错误处理规范。
探索阶段被否掉的方案不保留。

然后比较压缩后的后续表现。你通常会发现,后者更不容易把探索阶段的噪音继续带下去。

这个实验说明的不是“提示词可以调得更好”,而是:/compact 本身就在重新组织历史,所以你不该把它当成无脑归档。


完整视图:五类控制机制

把命令扩展到所有控制机制,共五类:

类型 触发方式 经过 AI 可靠性
CLI 状态命令(/clear/cost 等) 手动输入 确定性
自定义 Slash Command 手动输入 依赖提示词质量
Skills 命令或自然语言触发 工具权限有硬约束
Hooks(PreToolUse 等) 工具执行事件自动触发 确定性
键盘快捷键(Plan Mode 等) 键盘操作 确定性

不经过 AI 的机制(CLI 命令、Hooks、快捷键)是确定性的,适合做强约束;经过 AI 的机制(自定义命令、Skills)有随机性,需要好的提示词设计,但表达能力更强。


这篇文章的边界

这里讲的现象,并不是 Claude Code 独有的怪癖,而是长上下文 AI 协作的一类共性问题。不同模型、不同客户端、不同版本,细节会有差别,但下面这几个事实不会变:

  • 对话历史不是长期稳定记忆
  • 早期探索内容会污染后续判断
  • 留在对话层的规则会衰减
  • 当上下文过长时,注意力一定会分散

所以这篇文章真正想讲的,不是“背命令表”,而是一个更底层的判断:

Claude Code 的核心问题,不是你会不会再写一个更长的 Prompt,而是你有没有把信息放在正确的层级上。

项目背景、长期规则、任务上下文、阶段性探索,这四种东西不应该混在一起管理。Claude Code 之所以有这些 Commands,本质上就是为了让你把它们拆开。


本篇实践任务

任务一: 找你最近一次“感觉 Claude 越来越糊涂”的会话,对照四种劣化模式,判断到底是哪一种在起作用,不要再笼统地归因于“模型变差”。

任务二: 检查你现在的 CLAUDE.md,有没有把“曾经在对话里纠正过不止一次”的规则写进去?把它们补进去,下次对话观察差异。

任务三: 做一次对照实验:一段长任务会话里分别试试“空着 /compact”和“带保留说明 /compact”,比较后续回答质量。

任务四:.claude/commands/ 里创建一个你最常用操作的自定义 command,比如 /pr-desc/standup,感受一下它和直接输提示词的区别。


下篇预告

第 04 篇:CLAUDE.md 完整指南——让 Claude 真正理解你的项目

你已经知道 CLAUDE.md 是"系统注入的长期记忆",优先级高于对话历史,是纠正回退的唯一可靠手段。但写什么进去、怎么写才能真正影响 Claude 的行为而不只是让它"读到",是另一个问题。下一篇专门讲这个。


AI Coding 系列持续更新。上下文劣化有规律,干预就有方法。

从桌面端到高性能三维:大点云渲染的两种 Electron 架构实战

2026年4月3日 17:36

当 Three.js 遇上百万级点云,Web 内存与计算双双告急。我们用 C++ 扛起计算,用两种架构打通数据共享——同进程 N-API 与跨进程 gRPC+mmap,究竟谁更胜一筹?

引言

点云是三维世界中最原始、最直观的数据形式。一个中等规模的激光雷达扫描,动辄数百万乃至上亿个点。在 Electron 中直接使用 Three.js 加载 PLY/PCD 文件,往往瞬间耗尽内存,帧率跌至个位数。

根本原因在于:JavaScript 的单线程与 GC 压力,以及大块几何数据在 JS 堆中的双重拷贝。为了破局,业界普遍将计算密集、内存敏感的任务下沉到 C++,然后通过高效的数据共享机制将处理好的顶点数据传递给 Three.js。

本文将深入剖析两种架构方案:

  1. 同进程 N-API:C++ 代码以 Node 原生模块的形式直接运行在 Electron 主进程或渲染进程中。
  2. 跨进程 gRPC + mmap:C++ 作为独立后台服务,通过 gRPC 通信,通过 mmap 共享内存零拷贝交换数据。

我们不仅会对比优劣,更会给出核心代码实现,让你能真正落地到自己的项目中。


一、痛点与需求

以一个大点云项目为例:

  • 点云文件:.las 格式,包含 2000 万个点,每个点有 XYZ、RGB、强度等属性。
  • 需求:实时旋转/缩放,无卡顿;支持动态筛选(按强度、分类值)。
  • 瓶颈:
    • JS 解析 2000 万个点 → 内存爆炸(每点至少 24 字节,仅位置就需要 480MB)
    • Three.js BufferGeometry 创建过程会再拷贝一次 → 内存翻倍
    • 主线程解析 + 渲染 → UI 冻结

因此,必须:

  1. C++ 负责解析、滤波、LOD 生成
  2. C++ 与 JS 共享同一块内存,避免数据拷贝。
  3. Three.js 直接消费共享内存中的顶点数据

二、方案一:同进程 N-API(Node Addon)

2.1 架构图

┌─────────────────────────────────────────┐
│           Electron Main/Renderer        │
│  ┌───────────────────────────────────┐  │
│  │            React UI               │  │
│  └───────────────┬───────────────────┘  │
│                  │ 调用 N-API 导出函数   │
│  ┌───────────────▼───────────────────┐  │
│  │       C++ Addon (N-API)           │  │
│  │  - 点云解析                        │  │
│  │  - 滤波/采样                       │  │
│  │  - LOD 生成                        │  │
│  └───────────────┬───────────────────┘  │
│                  │ 返回 ArrayBuffer     │
│  ┌───────────────▼───────────────────┐  │
│  │       Three.js Renderer           │  │
│  │  BufferAttribute 直接引用 ArrayBuffer│
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

2.2 核心实现:C++ Addon 返回可转移的 ArrayBuffer

步骤 1:编写 C++ 点云解析函数

使用 N-API 创建 ArrayBuffer,填充顶点数据后返回给 JS。

#include <napi.h>
#include <vector>
#include <fstream>
#include "lasreader.hpp"  // LASlib 等

struct Point {
    float x, y, z;
    uint8_t r, g, b;
};

Napi::Value ParsePointCloud(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();
    std::string filepath = info[0].As<Napi::String>().Utf8Value();

    // 1. C++ 中解析点云,获取点数量
    LASreader* reader = LASreader::open(filepath.c_str());
    uint64_t num_points = reader->npoints;
    
    // 2. 计算内存大小(每个点 3*float + 3*uint8 = 12+3 = 15 字节,对齐到 16 字节)
    size_t buffer_size = num_points * sizeof(Point);
    
    // 3. 创建 N-API ArrayBuffer,内存由 C++ 分配(可使用 napi_create_external_arraybuffer 避免额外拷贝)
    void* data = malloc(buffer_size);
    Point* points = static_cast<Point*>(data);
    
    // 4. 填充数据
    size_t idx = 0;
    while (reader->read_point()) {
        points[idx].x = reader->point.get_x();
        points[idx].y = reader->point.get_y();
        points[idx].z = reader->point.get_z();
        points[idx].r = reader->point.get_r();
        points[idx].g = reader->point.get_g();
        points[idx].b = reader->point.get_b();
        ++idx;
    }
    reader->close();
    delete reader;

    // 5. 创建 ArrayBuffer,并绑定释放回调
    Napi::ArrayBuffer buffer = Napi::ArrayBuffer::New(env, data, buffer_size,
        [](Napi::Env env, void* finalize_data) {
            free(finalize_data);
        });
    
    // 6. 返回给 JS(同时可附带点数量等元数据)
    Napi::Object result = Napi::Object::New(env);
    result.Set("buffer", buffer);
    result.Set("numPoints", Napi::Number::New(env, num_points));
    return result;
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
    exports.Set("parsePointCloud", Napi::Function::New(env, ParsePointCloud));
    return exports;
}
NODE_API_MODULE(pointcloud_addon, Init)

步骤 2:React + Three.js 中消费 ArrayBuffer

// 在渲染进程中(确保 nodeIntegration 开启或通过 preload 暴露)
const addon = require('pointcloud-addon');

async function loadPointCloud(filePath) {
    const { buffer, numPoints } = addon.parsePointCloud(filePath);
    
    // 关键:将 ArrayBuffer 转为 Float32Array 和 Uint8Array 视图,但不复制数据
    const positions = new Float32Array(buffer, 0, numPoints * 3);
    const colors = new Uint8Array(buffer, numPoints * 12, numPoints * 3);
    
    // 创建 Three.js BufferGeometry
    const geometry = new THREE.BufferGeometry();
    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3, true));
    
    // 使用 PointsMaterial 渲染
    const material = new THREE.PointsMaterial({ vertexColors: true, size: 0.1 });
    const points = new THREE.Points(geometry, material);
    scene.add(points);
}

2.3 优缺点分析

优点 缺点
✅ 零拷贝:C++ 分配的 ArrayBuffer 直接被 Three.js 使用,无内存复制 ❌ 主线程阻塞:若直接在渲染进程调用会卡 UI(但可通过 worker_threads 解决)
✅ 延迟极低:函数调用开销微秒级 ❌ 崩溃风险:C++ Addon 内存越界会导致整个 Electron 进程崩溃
✅ 开发简单:无需跨进程通信,调试方便 ❌ Node 版本绑定:需要针对 Electron 的 Node 版本编译原生模块
✅ 部署单一:只有一个 .exe/.app 文件 ❌ 内存释放不可控:依赖 GC 触发 finalize,大对象可能延迟释放

关于 UI 阻塞的解决方案

直接在渲染进程中调用 addon.parsePointCloud 会同步执行 C++ 代码,若解析耗时超过 16ms,页面就会掉帧。正确的做法是将解析任务放到主进程的 worker_threads 中执行,解析完成后通过 postMessage 将 ArrayBuffer 传回渲染进程(结构化克隆会转移所有权,依然零拷贝)。

javascript

复制下载

// 主进程中创建一个 worker 线程
const { Worker } = require('worker_threads');

const worker = new Worker(`
  const { parentPort } = require('worker_threads');
  const addon = require('pointcloud-addon');
  
  parentPort.on('message', (filePath) => {
    const { buffer, numPoints } = addon.parsePointCloud(filePath);
    // 直接转移 ArrayBuffer 所有权,无需拷贝
    parentPort.postMessage({ buffer, numPoints }, [buffer]);
  });
`, { eval: true });

worker.on('message', ({ buffer, numPoints }) => {
  // 通过 IPC 发送给渲染进程
  mainWindow.webContents.send('pointcloud-data', buffer, numPoints);
});

渲染进程收到后,直接使用 new Float32Array(buffer) 创建视图即可。这样 C++ 解析完全在后台线程,UI 永不阻塞

注意:worker_threads 是 Node.js 的线程,不是 Web Worker。Web Worker 无法加载原生模块,因此不适用于此场景。

C++ addon 调用放在 worker_threads 中执行 能避免 程序崩溃吗?

不能!

为什么 worker_threads 也无法隔离崩溃?

即便将 C++ addon 调用放在 worker_threads 中执行,由于 worker 线程与主进程共享同一内存空间,原生代码的崩溃依然会连带杀死整个进程。Worker 线程的隔离是 JS 层面的,而非操作系统级的进程隔离。


三、方案二:独立 C++ 服务 + gRPC + mmap 共享内存

3.1 架构图

┌─────────────────────────┐         gRPC(控制面)        ┌─────────────────────────┐
│     Electron 主进程      │◄────────────────────────────►│    独立 C++ 服务         │
│  ┌─────────────────────┐ │                              │  - 点云解析              │
│  │  gRPC Client        │ │                              │  - 滤波/重采样           │
│  │  (Node.js)          │ │                              │  - LOD 生成              │
│  └──────────┬──────────┘ │                              │  - 写入 mmap             │
│             │ IPC         │                              └────────────┬────────────┘
│  ┌──────────▼──────────┐ │                                           │
│  │  Renderer (React)   │ │                              ┌────────────▼────────────┐
│  │  - Three.js         │ │   mmap 共享内存(数据面)     │   /dev/shm/pointcloud   │
│  │  - 读取 mmap 数据    │◄─────────────────────────────►│   (顶点 + 索引 + 元数据)  │
│  └─────────────────────┘ │                              └─────────────────────────┘
└─────────────────────────┘

3.2 核心实现:三步骤打通数据流

3.2.1 C++ 服务:解析点云并写入 mmap

使用 boost::interprocess 或 POSIX shm_open + mmap。为了跨平台,推荐使用 boost

#include <boost/interprocess/shared_memory_object.hpp>
#include <boost/interprocess/mapped_region.hpp>
#include <atomic>

struct SharedPointCloudHeader {
    std::atomic<uint64_t> version{0};
    std::atomic<bool> ready{false};
    uint64_t num_points;
    uint64_t data_offset;  // 顶点数据起始偏移
};

class PointCloudServer {
public:
    void LoadAndShare(const std::string& las_path) {
        // 1. 解析点云到内存 vector
        std::vector<Point> points = ParseLAS(las_path);
        
        // 2. 计算总大小
        size_t header_size = sizeof(SharedPointCloudHeader);
        size_t data_size = points.size() * sizeof(Point);
        size_t total_size = header_size + data_size;
        
        // 3. 创建共享内存对象
        boost::interprocess::shared_memory_object shm(
            boost::interprocess::open_or_create,
            "pointcloud_shm",
            boost::interprocess::read_write
        );
        shm.truncate(total_size);
        
        // 4. 映射到本进程地址空间
        boost::interprocess::mapped_region region(shm, boost::interprocess::read_write);
        
        // 5. 写入 header
        SharedPointCloudHeader* header = static_cast<SharedPointCloudHeader*>(region.get_address());
        header->num_points = points.size();
        header->data_offset = header_size;
        header->version.fetch_add(1, std::memory_order_release);
        
        // 6. 写入顶点数据
        void* data_ptr = static_cast<char*>(region.get_address()) + header_size;
        memcpy(data_ptr, points.data(), data_size);
        
        // 7. 标记 ready
        header->ready.store(true, std::memory_order_release);
    }
    
    // gRPC 服务接口:返回共享内存名称和大小
    grpc::Status LoadModel(grpc::ServerContext*, const LoadRequest* req, LoadResponse* resp) {
        LoadAndShare(req->file_path());
        resp->set_shm_name("pointcloud_shm");
        resp->set_shm_size(total_size);
        resp->set_data_offset(header_size);
        return grpc::Status::OK;
    }
};

3.2.2 Electron 主进程:gRPC 调用获取元数据

// main.js 中
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const { ipcMain } = require('electron');

const packageDefinition = protoLoader.loadSync('pointcloud.proto');
const proto = grpc.loadPackageDefinition(packageDefinition);
const client = new proto.PointCloudService('localhost:50051', grpc.credentials.createInsecure());

ipcMain.handle('load-pointcloud', async (event, filePath) => {
    return new Promise((resolve, reject) => {
        client.LoadModel({ file_path: filePath }, (err, response) => {
            if (err) reject(err);
            else resolve({
                shmName: response.shm_name,
                shmSize: response.shm_size,
                dataOffset: response.data_offset
            });
        });
    });
});

3.2.3 渲染进程:通过 mmap-io 读取共享内存并传给 Three.js

// 渲染进程中(通过 preload 暴露的 API)
const mmap = require('@cathodique/mmap-io');
const fs = require('fs');

async function loadAndRender(filePath) {
    // 1. 通过 IPC 触发 C++ 服务加载
    const { shmName, shmSize, dataOffset } = await window.electronAPI.loadPointcloud(filePath);
    
    // 2. 打开共享内存(Linux /dev/shm,Windows 不同)
    const fd = fs.openSync(`/dev/shm/${shmName}`, 'r');
    const buffer = mmap.map(fd, mmap.PROT_READ, mmap.MAP_SHARED, shmSize, 0);
    
    // 3. 解析 header(前 24 字节)
    const version = buffer.readBigUInt64LE(0);
    const ready = buffer.readUInt8(8) === 1;
    const numPoints = Number(buffer.readBigUInt64LE(16));
    
    if (!ready) throw new Error('Data not ready');
    
    // 4. 从 dataOffset 位置读取顶点数据
    const pointSize = 32;  // 假设 Point 结构体大小
    const positions = new Float32Array(numPoints * 3);
    const colors = new Uint8Array(numPoints * 3);
    
    for (let i = 0; i < numPoints; i++) {
        const base = dataOffset + i * pointSize;
        positions[i*3] = buffer.readFloatLE(base);
        positions[i*3+1] = buffer.readFloatLE(base + 4);
        positions[i*3+2] = buffer.readFloatLE(base + 8);
        colors[i*3] = buffer.readUInt8(base + 12);
        colors[i*3+1] = buffer.readUInt8(base + 13);
        colors[i*3+2] = buffer.readUInt8(base + 14);
    }
    
    // 5. 创建 Three.js 几何体(注意:这里从 buffer 拷贝到了新 ArrayBuffer,可优化?见下文)
    const geometry = new THREE.BufferGeometry();
    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3, true));
    
    const points = new THREE.Points(geometry, new THREE.PointsMaterial({ vertexColors: true }));
    scene.add(points);
}

性能陷阱:上面代码中,positionscolors 是从 mmap buffer 中逐点读取并创建的新 Float32Array,这仍然存在一次拷贝。要实现真正的零拷贝,需要让 Three.js 直接使用 mmap 映射的原始 buffer。但 Three.js 的 BufferAttribute 只接受 ArrayBufferBuffer 视图,并且要求该内存生命周期与几何体一致。我们可以利用 SharedArrayBuffer 或者直接传递 mmap 得到的 Buffer 对象,只要保证在几何体销毁前不被 unmap。

// 零拷贝版本:直接使用 mmap 返回的 Buffer 创建 Float32Array 视图
const totalFloats = numPoints * 3;
const positionsView = new Float32Array(buffer, dataOffset, totalFloats);
const colorsView = new Uint8Array(buffer, dataOffset + totalFloats * 4, numPoints * 3);
geometry.setAttribute('position', new THREE.BufferAttribute(positionsView, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colorsView, 3, true));
// 注意:需要确保 buffer 在几何体使用期间一直保持映射,不能提前 unmap

3.3 优缺点分析

优点 缺点
进程隔离:C++ 崩溃不影响 Electron UI 架构复杂:需要管理 C++ 服务的生命周期
非阻塞渲染:解析在后台进行,UI 可显示进度 部署麻烦:需打包两个可执行文件
真正零拷贝:mmap 让多进程共享同一物理内存 跨平台兼容性:Windows 下 mmap 行为不同,需封装
可扩展:未来可升级为远程服务,支持多机集群 调试困难:gRPC + mmap 联合调试工具链不成熟
内存可控:C++ 服务可独立释放内存 延迟略高:首次加载需 gRPC 调用(~1ms)

适用场景:点云规模极大(5000 万点以上),需要后台预处理、多任务排队,且对 UI 流畅度要求严苛。


四、核心问题:Buffer 如何从 C++ 共享到 Three.js?

无论哪种方案,最终目标都是让 Three.js 的 BufferAttribute 能直接访问 C++ 中分配的内存,避免复制。两种方案的技术本质:

  • N-API 方案:C++ 通过 napi_create_external_arraybuffer 分配内存,JS 拿到 ArrayBuffer 后,Three.js 可直接创建 BufferAttribute内存所有权归 JS(GC 时调用 free)。
  • mmap 方案:C++ 与 JS 通过操作系统共享内存机制映射同一块物理内存,JS 侧通过 BufferSharedArrayBuffer 访问。内存所有权归 OS,双方均可读写。

技术细节对比

方面 N-API 外部 ArrayBuffer mmap 共享内存
数据拷贝次数 0 次(C++ 直接写入 ArrayBuffer 内存) 0 次(双方映射同一页)
内存释放 由 JS GC 触发 finalize 回调 显式调用 munmap 或进程退出时释放
并发安全 单进程,无需额外同步 多进程,必须使用原子操作或互斥锁
跨语言友好 仅限 Node.js 环境 任何支持 POSIX API 的语言
最大数据量 受 V8 堆限制(64 位下约 4GB) 受物理内存 + 操作系统限制
实现复杂度 低(N-API 标准接口) 高(需处理权限、命名冲突、多进程同步)

结论:对于绝大多数桌面端点云应用,N-API 方案已经足够,且更简单。只有当点云超过 4GB 或需要多进程同时访问时才考虑 mmap。


五、实战决策:我应该选哪种?

5.1 决策树

是否单点云超过 2GB?
├─ 是 → mmap 方案(突破 V8 堆限制)
└─ 否 → 是否需要支持多进程并发访问?
    ├─ 是 → mmap 方案
    └─ 否 → 是否可接受 C++ 模块崩溃导致 Electron 闪退?
        ├─ 是 → N-API 方案(最简单)
        └─ 否 → mmap 方案(进程隔离更安全)

5.2 混合方案:按需选择

实际项目中,可采用双模式:默认使用 N-API(性能最优),当检测到点云过大时,降级为独立服务模式。

async function loadPointCloud(filePath) {
    const fileSize = getFileSize(filePath);
    if (fileSize < 1e9) { // < 1GB
        return loadWithNapi(filePath);
    } else {
        return loadWithGrpcMmap(filePath);
    }
}

好的,我将把“如何实现内存释放”和“clear 如何触发调用”这两个回答整合成一篇完整的技术说明,保持逻辑连贯,避免重复。


六, 跨进程方案(gRPC + mmap)中的内存释放与触发机制

gRPC + mmap 架构中,共享内存的生命周期由 C++ 服务端和 Electron 客户端共同管理。内存释放不是单个操作,而是一套需要双方配合的流程。下面分两部分阐述:释放操作本身释放的触发时机

6.1、内存释放的三大步骤(“怎么释放”)

C++ 服务端需要依次执行以下三个系统调用,才能彻底销毁一块共享内存:

步骤 函数 作用 备注
1 munmap 解除当前进程对共享内存的映射 调用后本进程不能再访问该内存
2 close 关闭由 shm_open 获得的文件描述符 回收进程内的句柄资源
3 shm_unlink 删除共享内存对象的名称 类似 unlink 删除文件;当所有进程都解除映射后,OS 才真正回收物理内存

重要shm_unlink 只是删除了共享内存的“名字”。即使调用了它,如果还有其它进程(如 Electron)仍然映射着这块内存,其内容依然有效。只有所有进程都执行了 munmap 并关闭了引用,操作系统才会回收物理内存。这保证了 Electron 使用期间数据的稳定性。

下面是一个典型的 clear() 方法实现:

#include <sys/mman.h>   // munmap, shm_unlink
#include <unistd.h>     // close

class SharedMemoryCache {
public:
    void clear(const std::string& shm_name) {
        // 1. 解除映射
        if (mapped_ptr) {
            munmap(mapped_ptr, shm_size);
            mapped_ptr = nullptr;
        }
        // 2. 关闭文件描述符
        if (shm_fd != -1) {
            close(shm_fd);
            shm_fd = -1;
        }
        // 3. 删除共享内存对象
        if (!shm_name.empty()) {
            shm_unlink(shm_name.c_str());
        }
        shm_size = 0;
    }
private:
    void* mapped_ptr = nullptr;
    int shm_fd = -1;
    size_t shm_size = 0;
};

Electron 侧也要解映射
在 Node.js 中,使用 @cathodique/mmap-io 等库时,需要显式调用 mmap.unmap(buffer) 并关闭文件描述符。建议监听进程退出事件做兜底清理:

process.on('exit', () => {
    if (mmapBuffer) mmap.unmap(mmapBuffer);
    if (fd) fs.closeSync(fd);
});

6.2、clear() 的触发方式(“何时调用”)

C++ 服务端的 clear() 不会自动执行,必须由明确的逻辑触发。有三种主要方式:

Electron 主动请求释放(最推荐)

通过 gRPC 暴露 Release 接口,让 Electron 在不再需要某块共享内存时主动调用。

定义 proto

service PointCloudService {
  rpc LoadModel(LoadRequest) returns (LoadResponse);
  rpc ReleaseModel(ReleaseRequest) returns (ReleaseResponse);
}
message ReleaseRequest { string shm_name = 1; }

Electron 调用(例如用户关闭点云窗口时):

await window.electronAPI.releaseModel('pointcloud_shm');

C++ 服务实现

grpc::Status ReleaseModel(grpc::ServerContext*, const ReleaseRequest* req, ReleaseResponse*) {
    SharedMemoryCache::instance().clear(req->shm_name());
    return grpc::Status::OK;
}

✅ 优点:释放时机精确,资源回收及时,无浪费。

C++ 服务内部自动触发

场景 A:加载新模型时自动替换
LoadModel 接口中,如果已有旧共享内存,先 clear() 再创建新的。

if (!current_shm_name.empty()) {
    SharedMemoryCache::instance().clear(current_shm_name);
}
// 然后创建新共享内存...

✅ 优点:无需额外 API,适合单模型应用(一次只打开一个点云)。

场景 B:LRU 缓存淘汰
当服务需要同时缓存多个点云时,可设置容量上限,超过后自动清理最久未使用的。

void evictIfNeeded() {
    if (cache_.size() > MAX_CACHE_COUNT) {
        auto oldest = cache_.begin();
        oldest->second->clear();
        cache_.erase(oldest);
    }
}

✅ 优点:多文档应用(如历史记录)下自动管理内存。

进程退出时自动清理(兜底机制)

在 C++ 服务的析构函数或 atexit 中遍历所有共享内存并调用 clear()

SharedMemoryCache::~SharedMemoryCache() {
    for (auto& entry : all_caches_) {
        entry.clear();
    }
}

✅ 优点:即使 Electron 忘记调用 Release,正常退出时也能清理。
⚠️ 注意:进程被 kill -9 强制终止时不会执行,但操作系统最终会回收所有内存。


推荐组合策略

在实际项目中,建议采用混合触发,兼顾灵活性与安全性:

触发方式 使用场景 作用
Electron 主动调用 Release 用户明确关闭模型、切换文件 主力释放机制,及时回收
加载新模型时自动替换 单模型应用(每次只加载一个点云) 防止旧数据遗留
进程退出时清理 任何情况 兜底,确保无泄漏

典型调用链路

用户点击“关闭点云” 
  → React 调用 window.electronAPI.closeModel()
  → Electron 主进程通过 gRPC 调用 C++ 服务的 ReleaseModel
  → C++ 服务端执行 SharedMemoryCache::clear(shm_name)
      → munmap → close → shm_unlink
  → 共享内存被彻底销毁

通过清晰的释放操作和完善的触发机制,gRPC + mmap 方案既能提供高性能零拷贝数据共享,又能保证内存资源被安全、及时地回收。


七、总结与展望

在 Electron + Three.js 的大点云渲染场景中,将计算下沉到 C++,并实现零拷贝数据共享是性能突破的关键。本文提供的两种方案各有千秋:

  • N-API 同进程方案:适合 1000 万点以下,追求极致简单和低延迟的项目。
  • gRPC+mmap 跨进程方案:适合超大规模、要求进程隔离、支持后台队列的专业级应用。

未来,随着 WebGPU 的成熟和 SharedArrayBuffer 的普及,我们甚至可以直接在 C++ 中操作 GPU 缓冲区,进一步降低 CPU 开销。但就目前而言,这套混合架构已经能在普通消费级电脑上流畅渲染 5000 万点云。

如果你正在开发类似的桌面三维工具,希望这篇文章能帮你少走弯路。欢迎在评论区交流你的实践心得!


参考资料


如果你觉得这篇文章有帮助,欢迎点赞收藏👍 有问题可以在评论区交流!


作者:红波 | 专注智驾、机器人标注工具与可视化开发 | 技术栈:TS/Vue/WebGPU/WebGL/ThreeJS/Go/Rust

JS 栈与堆内存全解析(含内存泄漏 / 闭包 / GC)

2026年4月3日 17:07

你是否在面试中被问过:JavaScript 的基本类型和引用类型存储在哪里?你是否遇到过页面越用越卡、内存持续飙高,却找不到原因?你是否理解闭包、浅拷贝、内存泄漏和栈堆的底层关系?

在前端开发中,栈(Stack)与堆(Heap)是 JavaScript 内存模型的核心基石,也是面试高频考点、性能优化的关键。很多同学只知其名,不知其理,导致在实际开发中踩坑无数。

今天,我们就结合你的核心知识点,从零到一吃透 JS 栈堆内存、垃圾回收、内存泄漏、闭包 等硬核知识点,全文逻辑闭环、内容详实,无论是面试还是实战优化都直接能用。


一、开篇三问:你真的了解 JS 内存吗?

在深入栈堆之前,我们先抛出三个灵魂问题,带着问题学习更有方向:

  1. 为什么 let a = 1let a = {} 赋值、拷贝的表现完全不同?
  2. 为什么函数执行完,局部变量就消失了,而闭包变量能一直保留?
  3. 为什么项目跑久了会卡顿?内存泄漏到底和栈堆有什么关系?

这三个问题的答案,全部藏在 JavaScript 的栈堆内存模型 里。接下来我们逐层拆解,从内存分类、存储规则、管理方式,到垃圾回收、内存泄漏,一次性讲透。


二、第一部分:JavaScript 内存模型 —— 栈与堆

在 JavaScript 中,引擎会把内存划分为 ** 栈内存(Stack)堆内存(Heap)** 两部分,二者分工明确、各司其职,共同支撑 JS 代码的运行。

1. 栈内存(Stack):系统自动管理的高速内存

栈内存是 JavaScript 中执行代码、存储简单数据的核心区域,它的管理方式完全由系统自动完成,开发者无需手动干预。

栈内存存储什么?

  • 执行上下文(全局上下文、函数执行上下文)
  • 基本数据类型(Number、String、Boolean、Null、Undefined、Symbol、BigInt)
  • 函数调用记录(调用栈)
  • 引用类型的内存地址(指针)

栈内存核心特点

  1. 操作速度极快栈是 CPU 最友好的内存结构,读写只需要移动指针,效率远高于堆内存。
  2. 内存空间连续栈内存是一块连续的存储空间,遵循 LIFO(后进先出) 原则,和数据结构中的栈完全一致。
  3. 系统自动分配与释放函数执行时入栈,执行完毕后,栈内数据自动出栈销毁,不需要垃圾回收机制(GC) 参与。
  4. 存储空间小、固定大小栈内存大小有限,不适合存储大型、复杂的数据。

一句话总结栈内存:小而快、自动管、存简单值 / 地址


2. 堆内存(Heap):GC 管理的动态内存

堆内存用于存储复杂、占用空间大的数据,它的管理方式和栈内存完全不同。

堆内存存储什么?

  • 对象(Object)
  • 数组(Array)
  • 函数(函数的调用逻辑存在栈中,函数体本身存在堆中)
  • 所有引用类型的真实数据

堆内存核心特点

  1. 内存空间不连续堆内存是散乱分配的,动态申请空间,会产生内存碎片
  2. 操作速度较慢分配和回收都需要计算,读写效率低于栈。
  3. 手动 / GC 自动管理底层语言(C/C++)需要 new 申请、delete 释放;JavaScript 不需要手动操作,由 GC(垃圾回收机制) 自动管理。
  4. 存储空间大、动态大小可以存储任意大小的复杂数据。

一句话总结堆内存:大而慢、GC 管、存真实对象数据


3. 栈与堆的协作关系

在 JavaScript 中,变量本身在栈中,而对象实际存在堆中,栈里存的是指向堆的引用地址。

举个最经典的例子:

// 基本类型:直接存在栈内存
let num = 100;
let str = "前端";

// 引用类型:栈存地址,堆存真实数据
let obj = { name: "掘金" };
let arr = [1, 2, 3];

执行这段代码时:

  • 栈内存:存储 numstr 的值,存储 objarr堆内存引用地址
  • 堆内存:存储 {name:"掘金"}[1,2,3] 的真实数据

这就是 JS 内存最核心的规则:栈里存的是简单类型值 或 引用地址;堆里存的是对象的真实数据。


4. 栈与堆性能差异深度对比

  • 栈快(只需要挪指针),堆慢
  • 栈连续内存(LIFO,无需 GC),堆非连续(随机分配,需要 GC)
  • 闭包,本该在栈释放的数据,被引用到了堆中

从生命周期来看:栈中的数据随着函数执行结束自动释放,而堆中的数据只要引用存在就不会被回收。


三、第二部分:数据结构中的栈与堆

除了内存模型,栈和堆也是数据结构中的常客,面试中经常会把「内存栈堆」和「数据结构栈堆」放在一起问,我们必须区分清楚。

1. 数据结构中的栈

栈是一种线性数据结构,严格遵循 LIFO(Last In First Out,后进先出) 原则。

  • 只能从一端添加 / 删除数据(栈顶)
  • 经典应用:函数调用栈、括号匹配、浏览器后退功能

它和内存中的栈内存规则完全一致,这也是栈内存得名的原因。

2. 数据结构中的堆

堆是一种非线性的树形数据结构,和内存中的堆完全不是一个概念!

数据结构中的堆分为两种:

  • 大顶堆:父节点值 ≥ 子节点值
  • 小顶堆:父节点值 ≤ 子节点值

堆常用于:优先队列、堆排序、TOP K 问题。

⚠️ 重要区分:

  • 内存堆:存储引用类型,GC 管理
  • 数据结构堆:排序、优先队列

面试中如果同时问到,一定要清晰区分二者,不要混淆。


四、第三部分:JS 垃圾回收机制(GC)—— 内存的 “清洁工”

堆内存需要垃圾回收机制来释放空间,GC 就是 JS 引擎的内存清洁工,负责把「不再使用的对象」清理掉,释放内存。

1. 对象可回收的核心标准:是否可达

GC 判断一个对象是否能被回收,只有一个标准:这个对象是否还能被访问到?(是否可达)

只要对象无法通过任何方式被访问,GC 就会标记它,并在合适时机回收内存。

我们用四个经典案例,彻底讲透「对象可达性」:

案例 1:局部对象 —— 自动回收

function fn(){
    let obj={a:1}  //会被回收
}
fn();

函数执行完毕,执行上下文出栈,obj 变量销毁,堆中对象无引用 → 不可达 → 被回收。

案例 2:全局引用 —— 不会回收

let globalObj;
function fn(){
    let obj={a:1}   //对象引用 n=1  不会释放
    globalObj=obj;  //对象引用 n=1+1
}
fn(); //n-1=1

函数执行完,局部变量 obj 销毁,但 globalObj 仍在引用,对象始终可达 → 不会被回收。

案例 3:循环引用

let a={};
let b={};
a.x=b;
b.y=a;
// 循环引用

a=null;
b=null;

老式引用计数算法会因计数不为 0 无法回收,造成泄漏;现代标记清除算法会判断不可达,正常回收。

案例 4:闭包

function outer(){
    let obj={a:1}
    return function inner(){
        console.log(obj)
    }
}
let fn=outer();

内层函数引用外层变量,导致 obj 一直可达,不会被回收。这就是闭包的本质:栈上本该释放的变量,被引用到了堆中,延长了生命周期。


2. 垃圾回收算法

(1)引用计数

  • 被引用一次 +1,取消引用 -1
  • 计数为 0 则回收
  • 致命缺陷:循环引用无法回收
  • 现已基本废弃

(2)标记清除(V8 主流)

  • 从根对象(window/global)开始遍历
  • 给可达对象打标记,未标记对象清除
  • 解决循环引用问题
  • 缺点:产生内存碎片

通俗理解:打扫卫生,哪些要断舍离呢?贴个标签,没贴的就扔掉回收。


五、第四部分:内存泄漏 —— 前端的 “隐形杀手”

内存泄漏是前端应用中的隐形杀手。本该被回收的内存,因被意外引用而无法释放,导致占用持续增长,最终引发页面卡顿甚至崩溃。

它不会立即导致应用崩溃,而是像慢性病一样,随着用户使用时间的延长,逐渐吞噬系统资源,最终导致卡顿、延迟,甚至浏览器标签页崩溃。

下面我们逐一梳理最常见的 9 种内存泄漏场景,并给出解决方案。

1. 定时器未清理

React / Vue 组件卸载了,但定时器没有取消。定时器内部函数引用了外部变量,变量被长期引用,无法释放。

解决方案:组件卸载时清除定时器。

2. 事件监听未移除

给 window、document、DOM 绑定事件后,组件销毁未解绑,回调函数长期驻留内存。

3. DOM 引用未释放

let el=document.getElementById('app')
document.body.removeChild(el);
// DOM 已删,但引用还在
el=null; // 必须手动切断

4. 全局变量 / 意外挂载到全局

  • var 声明自动挂载 window
  • 未声明直接赋值自动全局window 是根对象,永远可达 → 永不回收。

5. 闭包导致的内存泄露

不必要的长生命周期闭包,会让内部引用对象一直存活。

6. Map/Set 使用不当

Map/Set 是强引用,即使 key 对象设为 null,Map 依然持有引用,无法回收。

解决方案:使用 WeakMap、WeakSet,它们是弱引用,key 对象被回收时会自动移除对应项,不会造成泄漏。

7. 订阅发布者模式

只订阅不取消订阅,回调函数长期引用外部变量,造成泄漏。

8. Promise 一直不结束

Promise 永久 pending,内部持有大量引用无法释放。

9. 请求未中止,组件已卸载

请求发送后,组件提前卸载,回调仍持有引用。解决方案:使用 AbortController 中止请求。


六、第五部分:栈堆模型对实际开发的深层影响

栈堆不只是面试题,它直接决定了你代码的行为、性能和 Bug 来源。

1. 影响浅拷贝与深拷贝

  • 基本类型赋值:拷贝栈值,相互独立
  • 引用类型赋值:拷贝地址,共享堆数据

浅拷贝只复制一层地址,深拷贝重新开辟堆内存,完整复制所有结构。理解栈堆,你就彻底理解深浅拷贝的本质区别。

2. 影响闭包原理

闭包的核心就是:栈上的执行上下文销毁了,但变量被堆中的函数引用,因此保留在内存中。

3. 影响内存泄漏

绝大多数泄漏,本质都是:堆对象被意外长期引用,GC 无法回收。

4. 影响页面性能

  • 栈内存几乎无性能压力
  • 堆内存过多、频繁 GC → 主线程阻塞 → 页面卡顿

七、第六部分:面试满分回答模板(可直接背诵)

如果面试官问:说说 JS 中的栈和堆?

你可以这样回答:

在 JavaScript 中,内存分为栈内存和堆内存。栈主要存储执行上下文、基本类型值、以及引用类型的地址,由系统自动管理,内存连续、后进先出、速度极快,函数执行完自动释放。堆存储对象、数组、函数等复杂数据,内存不连续,需要垃圾回收机制管理,速度相对较慢。

变量存在栈中,对象真实数据存在堆中,栈保存堆的引用地址。垃圾回收主要通过标记清除算法,判断对象是否可达来决定是否回收。

内存泄漏通常是因为对象被意外长期引用,比如未清理定时器、事件监听、DOM 引用、循环引用、不当闭包、强引用 Map 等。实际开发中可以通过及时清理监听、使用 WeakMap、避免冗余闭包来避免泄漏。

这一段覆盖所有要点,逻辑清晰,面试官直接给高分。


八、总结

栈和堆核心区别在于存储内容和管理方式

  • :执行上下文、基本类型、引用地址,系统自动管理,连续内存、LIFO、速度快、无碎片。
  • :对象、数组、函数真实数据,GC 管理,非连续、速度较慢、有碎片。

在 JS 中:变量本身在栈中,对象实际数据在堆中,栈存堆地址。 栈随函数结束自动释放;堆只有无引用时才被 GC 回收。

栈堆模型直接影响:深浅拷贝、闭包行为、垃圾回收、内存泄漏、页面性能。

理解栈堆,才算真正理解 JavaScript 的运行底层。

pretext实现余力深度解析

作者 Tonyzz
2026年4月2日 10:14

Pretext 实现原理深度分析

Pretext 是一个纯 JavaScript/TypeScript 的多行文本测量和布局库,能够不依赖 DOM 回流就计算出文本高度。

核心价值

问题: 传统的 getBoundingClientRectoffsetHeight 会触发同步布局回流,当页面有 500 个文本块时,每帧可能要花 30ms+ 在测量上。

解决方案: Pretext 使用 Canvas API + Intl.Segmenter 实现纯 JS 测量,避免了 DOM 回流。

prepare()  → 一次性预计算(~19ms/500文本)
layout()   → 纯算术计算高度(~0.09ms/500文本)

核心架构:两阶段测量

prepare(text, font) → 预计算(一次性)
    ↓
layout(prepared, width, lineHeight) → 纯算术(每次 resize

1. prepare() 做什么?

输入: 文本 + 字体配置

输出: 预计算的数据结构

const text = "Hello 世界!"
const prepared = prepare(text, "16px Inter")

内部处理流程:

原始文本: "Hello 世界!"
    ↓ 1. 分段(Intl.Segmenter)
分段结果: ["Hello", " ", "世", "界", "!"]
分段类型: ["text", "space", "text", "text", "text"]
    ↓ 2. 测量宽度(Canvas measureText)
宽度数据: [42.5, 4.4, 16.0, 16.0, 5.2]

2. layout() 做什么?

输入: prepared 对象 + 容器宽度 + 行高

输出: { height, lineCount }

const { height, lineCount } = layout(prepared, 100, 20)
// height = lineCount * lineHeight

关键:layout() 是纯算术,不调用任何测量 API!

// 简化版 layout 逻辑
function layout(prepared, maxWidth, lineHeight) {
    let lineWidth = 0
    let lineCount = 1
    
    for (let i = 0; i < prepared.widths.length; i++) {
        const segWidth = prepared.widths[i]  // 直接从数组读
        
        if (lineWidth + segWidth > maxWidth) {
            lineCount++
            lineWidth = segWidth
        } else {
            lineWidth += segWidth
        }
    }
    
    return { height: lineCount * lineHeight, lineCount }
}

PreparedText 数据结构详解

type PreparedCore = {
  // === 核心数据(每个分段一个值)===
  widths: number[]              // 每个分段的宽度(像素)
  kinds: SegmentBreakKind[]     // 每个分段的类型(决定能否换行)
  lineEndFitAdvances: number[]  // 行尾时的宽度贡献
  lineEndPaintAdvances: number[] // 行尾时的绘制宽度
  
  // === 可断词的额外数据 ===
  breakableWidths: (number[] | null)[]      // 每个字符的宽度(用于 overflow-wrap)
  breakablePrefixWidths: (number[] | null)[] // 累计宽度(二分查找用)
  
  // === 特殊情况 ===
  discretionaryHyphenWidth: number  // 软连字符 "-" 的宽度
  tabStopAdvance: number            // Tab 停止位间隔
  
  // === 分块(遇到 \n 分开)===
  chunks: PreparedLineChunk[]       // 预编译的硬换行块
  
  // === 优化标记 ===
  simpleLineWalkFastPath: boolean   // 普通文本可用简化算法
  
  // === 双向文本(阿拉伯语等)===
  segLevels: Int8Array | null       // Bidi 元数据
}

// 分段类型
type SegmentBreakKind =
  | 'text'           // 普通文本
  | 'space'          // 可折叠空格
  | 'preserved-space' // pre-wrap 保留空格
  | 'tab'            // 制表符
  | 'glue'           // 粘连标点
  | 'zero-width-break' // 零宽断点
  | 'soft-hyphen'    // 软连字符
  | 'hard-break'     // 强制换行(\n)

具体例子

const text = "Hello 世界! How are\nyou?"
const prepared = prepare(text, "16px Inter")

分段结果

原文: "Hello 世界! How are\nyou?"
       
分段: ["Hello", " ", "世", "界", "!", " ", "How", " ", "are", "\n", "you", "?"]
索引:    0      1    2    3     4    5     6     7    8      9    10    11

prepared 内部数据

{
  widths: [
    42.5,  // "Hello"
    4.4,   // " "
    16.0,  // "世"
    16.0,  // "界"
    5.2,   // "!"
    4.4,   // " "
    28.8,  // "How"
    4.4,   // " "
    22.4,  // "are"
    0,     // "\n" - 硬换行
    28.8,  // "you"
    5.2    // "?"
  ],
  
  kinds: [
    'text',           // "Hello"
    'space',          // " "
    'text',           // "世"
    'text',           // "界"
    'text',           // "!"
    'space',          // " "
    'text',           // "How"
    'space',          // " "
    'text',           // "are"
    'hard-break',     // "\n"
    'text',           // "you"
    'text'            // "?"
  ],
  
  breakableWidths: [
    null,             // "Hello" - 英文单词
    null,
    [16.0, 16.0],     // "世界" - 中文每个字符可断
    null,
    // ...
  ],
  
  chunks: [
    { startSegmentIndex: 0, endSegmentIndex: 9 },
    { startSegmentIndex: 10, endSegmentIndex: 12 }
  ]
}

换行算法

换行不是靠换行符,而是靠累加宽度判断!

文本: "Hello world test"
宽度: [42.5, 4.4, 37.2, 4.4, 28.0]

maxWidth = 80

第1步: lineWidth = 0 + 42.5 = 42.5  (Hello) ✓
第2步: lineWidth = 42.5 + 4.4 = 46.9  (空格) ✓
第3步: lineWidth = 46.9 + 37.2 = 84.1 > 80 ❌ 换行!
       lineCount = 2, lineWidth = 37.2 (world)
第4步: lineWidth = 37.2 + 4.4 = 41.6  (空格) ✓
第5步: lineWidth = 41.6 + 28.0 = 69.6 (test) ✓

结果: 2行

换行点由什么决定?

  1. 空格 - kinds[i] === 'space' 后可换行
  2. CJK 字符 - 中文每个字符都是独立分段,随时可换
  3. 软连字符 - kinds[i] === 'soft-hyphen' 可断开并加 -
  4. overflow-wrap - 单词太长时按 breakableWidths 断开
  5. 硬换行 - \n 强制换行

多语言支持

分段(Intl.Segmenter)

浏览器原生分段器自动处理所有语言:

  • 中文/日文/韩文 → 按字符分段
  • 英语 → 按单词分段
  • 泰语 → 按词分段(泰语没有空格)
  • 阿拉伯语 → 正确处理双向文本
  • Emoji → 识别为单个 grapheme
const segmenter = new Intl.Segmenter(locale, { granularity: 'word' })
for (const segment of segmenter.segment(text)) {
    // 自动处理各种语言
}

Emoji 修正

Chrome/Firefox 在字号 <24px 时,Canvas 测量的 emoji 比实际 DOM 宽:

if (textMayContainEmoji(seg)) {
    const canvasWidth = ctx.measureText(seg).width
    const domWidth = measureDOM(seg)  // 一次性 DOM 读取
    const correction = domWidth - canvasWidth
    // 缓存修正值,后续只用 Canvas
}

渲染方式

Pretext 不负责渲染,只告诉你高度!

1. DOM 渲染

const { height } = layout(prepared, 300, 24)

const div = document.createElement('div')
div.style.width = '300px'
div.style.height = `${height}px`  // ← Pretext 告诉你高度
div.style.lineHeight = '24px'
div.style.font = '16px Inter'
div.textContent = text

2. Canvas 渲染

const prepared = prepareWithSegments("Hello world", "16px Inter")
const { lines } = layoutWithLines(prepared, 100, 20)

lines.forEach((line, i) => {
    ctx.fillText(line.text, 0, i * 20 + 16)
})

3. 虚拟列表

const items = data.map(text => {
    const prepared = prepare(text, "16px Inter")
    const { height } = layout(prepared, containerWidth, 20)
    return { text, prepared, height }
})

// 总高度
const totalHeight = items.reduce((sum, item) => sum + item.height, 0)

// 只渲染可见项
for (let i = visibleStart; i < visibleEnd; i++) {
    renderItem(items[i])
}

使用场景

  1. 虚拟列表 - 知道文本高度才能做虚拟滚动
  2. Canvas 渲染 - 游戏/WebGL 中的文本布局
  3. 自定义布局 - 瀑布流、自适应宽度
  4. 服务端渲染 - 不需要浏览器就能算布局
  5. 开发时验证 - AI 生成代码时检查文本是否溢出

完整流程图

┌─────────────────────────────────────────────────────────────┐
│                        Pretext 流程                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. prepare()  ← 文本 + 字体                               │
│       ↓                                                     │
│  [widths, kinds, ...]  ← 预计算的分段数据                  │
│                                                             │
│  2. layout()   ← prepared + 容器宽度 + 行高                │
│       ↓                                                     │
│  { height, lineCount }  ← 纯算术,瞬间完成                 │
│                                                             │
│  3. 你自己渲染                                              │
│       ↓                                                     │
│  DOM: <div style="height: 120px">文本...</div>             │
│  Canvas: ctx.fillText(line.text, x, y)                     │
│  SVG: <text y="20">每行文本</text>                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

总结

Pretext 的核心创新是把文本测量从 DOM 中剥离出来:

  1. prepare() - 一次性做昂贵操作(分段 + Canvas 测量)
  2. layout() - 变成纯算术(数组遍历 + 加法)

从而实现高性能的文本布局计算,特别适合虚拟列表、Canvas 渲染等场景。

鸿蒙日历服务实践:把应用里的事件写进用户的日程表

2026年4月3日 17:17

引言

很多应用都会产生带时间属性的事件——买了张火车票、预约了一场直播、信用卡该还款了、晚上有节线上课。这些信息散落在各个应用里,用户需要自己记住或者手动添加提醒,难免遗漏。

鸿蒙的 Calendar Kit 提供了一种更好的方式:让应用直接把这些事件写入系统日历。写入后,日程会通过通知中心、桌面卡片、日历应用等多个入口触达用户,还能配上一个**"一键服务"按钮**,用户点一下就能跳回应用完成操作——比如"加入会议"、"马上还款"、"立即观看"。

本文面向希望接入日历服务的鸿蒙开发者,梳理日历日程的创建机制、一键服务的场景设计,并通过两个典型场景的完整实现,帮你快速理解开发要点。


一、日历服务能做什么

1.1 核心能力

简单说就是三件事:

  1. 写入日程:把应用中带时间属性的事件以标准格式写入系统日历,包括标题、时间、地点、备注等信息。
  2. 提醒用户:通过系统级的提醒机制,在日程开始前的指定时间通知用户。
  3. 一键直达:在日程卡片上提供服务按钮,用户点击后通过 DeepLink 跳回应用的对应页面,完成后续操作。

写入日历的日程会出现在多个地方——日历应用内部、桌面日历卡片、通知中心。用户不需要打开你的应用就能看到这些信息,这对提升事件的到达率很有帮助。

1.2 一键服务按钮的显示时机

一键服务按钮不是一直显示的,不同入口的出现时机不一样:

入口 显示时机
桌面卡片 / 月视图日程列表卡片 日程开始前 15 分钟显示,日程结束后自动隐藏
日程详情页 始终显示
日程通知 通知弹出时显示,点击通知卡片后显示

这意味着一键服务按钮是有"时效性"的——它在用户最需要行动的时间窗口出现,而不是一直挂在那里。

1.3 9 种典型服务场景

Calendar Kit 为不同的业务场景预定义了服务类型,每种类型对应一个具体的按钮文案:

场景 ServiceType 按钮文案
会议 Meeting 加入会议
追剧 Watching 立即观看
还款 Repayment 马上还款
直播 Live 开启直播
购物 Shopping 开始选购
出行 Trip 立即查看
上课 Class 开始上课
赛事 SportsEvents 立即观看
运动 SportsExercise 开始运动

选择合适的服务类型,按钮文案就会自动匹配,不需要开发者自定义。


二、开发前的准备工作

在写第一行业务代码之前,有三步准备工作需要完成:

第一步,导入依赖。日历管理相关的能力都在 @kit.CalendarKit 中。

第二步,申请权限。日历是用户的私有数据,读写操作需要在 module.json5 中声明两个权限:ohos.permission.READ_CALENDARohos.permission.WRITE_CALENDAR

第三步,获取日程管理器对象。通过上下文获取 calendarMgr 对象,后续所有日历账户和日程的管理操作都通过它来进行。推荐在 EntryAbility.ets 中完成这一步,确保管理器对象在应用生命周期内可用。


三、理解日程的数据结构

在看具体场景之前,先理解日程涉及的几个关键概念,后面写代码时会更清楚为什么要这样配置。

3.1 日历账户

每个写入系统日历的日程都归属于一个日历账户。你可以理解为日历中的一个"分组"——用户打开日历应用时,能看到来自不同应用的日程被归类在各自的账户下。

账户有三个关键属性:

  • name:账户标识,供系统内部使用。
  • type:账户类型,一般使用 LOCAL
  • displayName:展示给用户看的名称,建议和应用在应用市场中的名称保持一致,方便用户识别"这个日程是哪个应用写的"。

3.2 日程字段

一条日程的核心字段包括:

  • title:标题,出现在日程卡片上最醒目的位置。
  • startTime / endTime:起止时间,时间戳格式。
  • isAllDay:是否是全天日程。全天日程不会显示具体时刻,适合"入住日""还款日"这类以天为单位的事件。
  • reminderTime:提醒时间,是一个数组,单位是分钟。比如 [0, 10] 表示日程开始时和开始前 10 分钟各提醒一次。对于全天日程,0 表示当天上午 9 点提醒,1440(即 24 小时)表示前一天上午 9 点提醒。
  • description:备注信息,可以补充标题里放不下的细节。
  • location:地点信息,包含地址文本和经纬度。
  • service:一键服务配置,包括服务类型(type)和跳转链接(uri,DeepLink 格式)。

3.3 日程的增删改查

日历服务提供了完整的 CRUD 操作。创建日历账户后,可以在该账户下添加日程、按条件查询日程、更新日程信息、删除日程。后面的场景示例中会展示这些操作的具体写法。


四、典型场景实践

下面通过两个最常见的场景——出行服务会议——来完整走一遍开发流程。其他场景(直播、购物、还款、课程等)的开发思路完全一致,区别只在于字段内容和 ServiceType 的选择。

4.1 出行服务场景

这大概是最容易理解的场景了:用户在购票应用里买了一张高铁票,应用把行程信息写入日历,出发前提醒用户,用户还能一键跳回应用查看电子客票。

字段设计思路:

标题要一目了然,建议包含车次和起终点,比如"行程信息:G107 上海虹桥-北京南"。备注里可以放检票口和座位号这类到了车站才需要的细节。提醒时间设两个——4 小时前提醒用户该出门了,2 小时前再提醒一次。一键服务类型选 TRIP

创建日程:

import { calendarMgr } from '../entryability/EntryAbility';
import { calendarManager } from '@kit.CalendarKit';

let tripCalendar: calendarManager.Calendar | undefined = undefined;
let oriEvent: calendarManager.Event | null = null;
let id: number = 0;

async createTripCalendarAndEvent(): Promise<void> {
  const calendarAccount: calendarManager.CalendarAccount = {
    name: 'TripCalendar',
    type: calendarManager.CalendarType.LOCAL,
    displayName: '高铁出行'
  };

  const config: calendarManager.CalendarConfig = {
    color: '#aabbcc'
  };

  const startTime = new Date('2025-10-01T08:17:00').getTime();
  const endTime = new Date('2025-10-01T12:51:00').getTime();

  const event: calendarManager.Event = {
    type: calendarManager.EventType.NORMAL,
    title: '行程信息:G107 上海虹桥-北京南',
    startTime: startTime,
    endTime: endTime,
    isAllDay: false,
    reminderTime: [120, 240],
    description: '检票口:南二楼1口或北广场B2候车室 \n座位号:02车04二等座',
    service: {
      type: calendarManager.ServiceType.TRIP,
      uri: 'demo://mobile/player?params='
    }
  };

  try {
    tripCalendar = await calendarMgr?.createCalendar(calendarAccount);
    if (!tripCalendar) {
      console.error('Failed to create calendar.');
      return;
    }
    await tripCalendar.setConfig(config);
    id = await tripCalendar.addEvent(event);
    oriEvent = event;
    oriEvent.id = id;
    console.info(`日程创建成功,ID: ${id}`);
  } catch (error) {
    console.error(`创建失败: ${error.code}, ${error.message}`);
  }
}

这段代码做了三件事:创建日历账户、设置账户配色、添加日程。注意一定要确保日历账户创建成功后再进行日程操作,否则后续调用会失败。

日程的后续管理:

出行场景下,行程变更是常有的事——改签了车次、换了出发时间。这时候需要更新已有日程而不是删掉重建:

async updateTripEvent(): Promise<void> {
  if (!tripCalendar || !oriEvent) return;
  
  // 改签后更新起止时间
  oriEvent.startTime = new Date('2025-10-01T07:03:00').getTime();
  oriEvent.endTime = new Date('2025-10-01T11:51:00').getTime();

  try {
    await tripCalendar.updateEvent(oriEvent);
    console.info('日程更新成功');
  } catch (err) {
    console.error(`更新失败: ${err.code}, ${err.message}`);
  }
}

如果用户退票了,则直接删除日程:

async deleteTripEvent(): Promise<void> {
  if (!tripCalendar) return;
  try {
    await tripCalendar.deleteEvent(id);
    oriEvent = null;
    console.info('日程已删除');
  } catch (err) {
    console.error(`删除失败: ${err.code}, ${err.message}`);
  }
}

需要查询已有日程时,通过 EventFilter.filterById 按 ID 查询:

async getTripEvent(): Promise<void> {
  if (!tripCalendar) return;
  try {
    const filter = calendarManager.EventFilter.filterById([id]);
    let data = await tripCalendar.getEvents(filter, 
      ['title', 'type', 'startTime', 'endTime']);
    if (data && data.length > 0) {
      oriEvent = data[0];
    }
  } catch (err) {
    console.error(`查询失败: ${err.code}, ${err.message}`);
  }
}

4.2 会议场景

会议场景和出行的最大区别在于:它有与会人信息。用户在会议应用中创建或被邀请参加一个会议,应用将其写入日历,到时间时用户看到提醒,点击"加入会议"按钮就能直接进入会议。

字段设计思路:

标题就是会议主题。提醒时间设准时和 15 分钟前——太早没意义,太晚来不及。会议场景特有的是 attendee 字段,用来记录与会人信息,每个与会人有姓名、邮箱、角色(组织者还是参与者)和类型(必选还是可选)。

async createMeetingEvent(): Promise<void> {
  const calendarAccount: calendarManager.CalendarAccount = {
    name: 'meetingCalendar',
    type: calendarManager.CalendarType.LOCAL,
    displayName: '会议'
  };

  const config: calendarManager.CalendarConfig = {
    color: '#aabbcc'
  };

  let attendee: calendarManager.Attendee[] = [
    {
      name: 'Alice',
      email: 'alice@example.com',
      role: calendarManager.AttendeeRole.ORGANIZER
    },
    {
      name: 'Jack',
      email: 'jack@example.com',
      role: calendarManager.AttendeeRole.PARTICIPANT,
      type: calendarManager.AttendeeType.REQUIRED
    },
    {
      name: 'Jerry',
      email: 'jerry@example.com',
      role: calendarManager.AttendeeRole.PARTICIPANT,
      type: calendarManager.AttendeeType.REQUIRED
    }
  ];

  const startTime = new Date('2025-10-20T09:00:00').getTime();
  const endTime = new Date('2025-10-20T10:00:00').getTime();

  const event: calendarManager.Event = {
    type: calendarManager.EventType.NORMAL,
    title: '产品方案评审会议',
    startTime: startTime,
    endTime: endTime,
    isAllDay: false,
    reminderTime: [0, 15],
    attendee: attendee,
    description: 'Q4产品方案评审',
    service: {
      type: calendarManager.ServiceType.MEETING,
      uri: 'demo://mobile/player?params='
    }
  };

  try {
    let calendar = await calendarMgr?.createCalendar(calendarAccount);
    if (!calendar) return;
    await calendar.setConfig(config);
    id = await calendar.addEvent(event);
    console.info(`会议日程创建成功,ID: ${id}`);
  } catch (error) {
    console.error(`创建失败: ${error.code}, ${error.message}`);
  }
}

与会人信息会展示在日程详情中,帮助用户确认参会人员。对于会议应用来说,service.uri 中的 DeepLink 通常会携带会议室 ID 等参数,用户点击"加入会议"后直接进入对应的会议房间。


五、其他场景速览

前面详细讲了出行和会议两个场景的完整实现,其他场景的代码结构完全一样,差异只在字段内容的填写上。这里简要列出几个场景的要点,帮你快速对照:

酒店住宿:适合设置为全天日程(isAllDay: true),标题包含酒店名称和地址,别忘了填 location 字段(包含经纬度),ServiceType 用 TRIP。提醒建议设前一天上午 9 点(reminderTime: [1440])和当天上午 9 点(reminderTime: [0])。

还款提醒:也是全天日程,毕竟还款日是以"天"为单位的。备注里写上待还款金额,ServiceType 用 REPAYMENT,提醒一次就够了——当天上午 9 点(reminderTime: [0])。

直播 / 抢购 / 课程 / 赛事 / 运动:都是精确到具体时刻的非全天日程,提醒时间一般设准时和开始前 10-30 分钟。区别就是选对 ServiceType,按钮文案就会自动匹配。


六、总结与实践建议

日历服务的接入逻辑并不复杂——创建账户、配置日程、写入系统。但要把体验做好,有几个细节值得注意:

  1. 标题要有信息量。用户在桌面卡片上看到的可能只有标题,所以"G107 上海虹桥-北京南"远比"火车票行程"有用。
  2. 提醒时间要合理。出行类提前 2-4 小时,会议和课程提前 10-15 分钟,全天日程用上午 9 点。不要设过多提醒,免得用户觉得被打扰。
  3. 及时更新和清理。行程改签了就更新日程,退票了就删除。不要让过期或无效的日程留在用户的日历里,这会损害用户对应用的信任。
  4. displayName 要用应用真名。用户看到一条日程时,会通过日历账户名称判断"这是哪个应用写的"。用正式的应用名称,而不是内部代号或缩写。
  5. DeepLink 要能真正落地。一键服务按钮点下去后跳转的链接,必须能正确打开应用的对应页面。如果链接失效或跳错位置,这个功能反而会让用户感到困惑。

日历是一个天然的时间管理入口,用户每天都会看。把应用中有价值的时间事件写进去,既帮用户管理好了日程,也为应用争取到了在系统级入口的露出机会。

鸿蒙碰一碰分享:手机轻碰,内容就过去了

2026年4月3日 16:55

引言

跨设备传内容这件事,理想状态是什么?大概就是——我手机上有个东西想给你,碰一下就过去了,不用加好友、不用扫码、不用等配对。

鸿蒙 Share Kit 的碰一碰分享做的就是这件事。两台手机轻碰一下,图片、链接、Wi-Fi 信息就传过去了;手机往 PC 屏幕上一放,文件就到了电脑里。整个过程没有中间步骤,靠的是设备间物理接触触发的分享机制。

本文面向希望为应用接入碰一碰分享能力的鸿蒙开发者,从手机间分享和手机与 PC/2in1 间分享两个维度,梳理这项能力的工作机制、卡片设计要点、异常处理策略,以及完整的开发流程。


一、手机与手机之间的碰一碰分享

1.1 基本流程

碰一碰分享的业务流程可以用四步概括:

  1. 注册:应用在可分享的页面注册碰一碰事件(knockShare)。
  2. 触发:用户将手机与对端设备轻碰,系统发现设备后触发回调。
  3. 发送:应用在回调中构造分享数据并发送。
  4. 清理:离开可分享页面时,解除事件注册。

使用前有几个前提条件:双端设备都要亮屏且解锁,华为分享服务需要处于开启状态(系统默认开启)。如果用户手动关闭了华为分享服务,轻碰时会收到系统通知提示开启。

还有一点需要了解:宿主应用无法直接获知分享结果。对端是接收了还是拒绝了,Share Kit 会通过系统通知告知用户,而不是通过回调返回给应用。如果任意一端设备不支持碰一碰能力,轻碰则完全没有响应。

环境要求方面,手机系统需要 HarmonyOS NEXT Release 及以上版本。可以用 canIUse 做运行时判断:

if (canIUse('SystemCapability.Collaboration.HarmonyShare')) {
  // 支持碰一碰分享
}

1.2 设备间的信任与安全

从 HarmonyOS NEXT 5.0.0.123 SP16 开始,碰一碰分享在发送端和接收端都会展示对方的身份信息,帮助用户确认"我在和谁传东西":

  • 如果对端已登录华为账号,会展示对方的账号昵称和头像
  • 如果对端未登录华为账号,则展示设备信息

需要注意的是,如果发送端的系统版本低于 SP16,接收端将不会展示任何发送方信息。


二、分享卡片的设计:不只是技术问题

碰一碰触发后,对端设备会收到一张分享卡片。卡片的样式直接影响用户是否愿意接收,所以这部分值得认真对待。

2.1 三种卡片模板

Share Kit 根据你传入的字段组合,自动匹配不同的卡片模板:

纯图片布局——只有预览图,没有标题和描述。适合分享文件、图片等不需要文字说明的场景。构造分享数据时只传 thumbnailUri 即可触发这种布局。预览图支持最小宽高比 1:4,超出部分会被裁剪。

沉浸式大卡布局——预览图 + 标题 + 描述 + 应用图标,视觉冲击力最强。适合分享链接类内容。触发条件是同时传入 titledescriptionthumbnailUri,且预览图宽高比小于 1:1(即竖图)。标题最多显示 2 行,描述 1 行,超出部分以省略号截断。如果标题末尾有重要信息,建议控制在 20 个中文字符左右。

白卡上下布局——同样包含预览图、标题、描述和应用图标,但预览图只显示在卡片上方,不会铺满整张卡片。触发条件和沉浸式大卡一样,区别在于预览图宽高比大于 1:1(即横图)。

应用图标不需要额外配置,系统会自动获取。

2.2 预览图的质量建议

预览图太大会拖慢加载速度,太小则显示模糊。建议参考以下标准:

预览图来源 推荐比例 推荐分辨率
应用创作的海报 3:4 最小 600×800,最大 3000×4000
用户上传的图片 不限制 最大 3000×4000

2.3 预览图来不及下载怎么办

一个很实际的问题:如果应用使用的是云端存储的图片作为预览图,碰一碰回调触发时图片可能还没下载到本地,这就会导致超时失败。

Share Kit 对此提供了预览图延迟更新的能力。思路很简单——先发核心数据,建立连接,系统会用默认预览图填充卡片;等云端图片下载完成后,再调用 sharableTarget.updateShareData 更新预览图:

harmonyShare.on('knockShare', capabilityRegistry, (sharableTarget) => {
  // 先发送核心数据,不带预览图
  let shareData = new systemShare.SharedData({
    utd: utd.UniformDataType.HYPERLINK,
    content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p',
    title: '碰一碰分享卡片标题',
    description: '碰一碰分享卡片描述'
  });
  sharableTarget.share(shareData);

  // 图片下载完成后更新预览图
  setTimeout(() => {
    let filePath = contextFaker.filesDir + '/exampleKnock1.jpg';
    sharableTarget.updateShareData({
      thumbnailUri: fileUri.getUriFromPath(filePath)
    });
  }, 5000);
});

这样用户不会因为预览图加载慢而等待,分享体验更流畅。


三、用户引导:让用户知道"这里可以碰"

碰一碰是一个相对新的交互方式,很多用户可能不知道当前页面支持这个功能。给出适当的引导可以有效提升分享意愿。

Share Kit 推荐两种引导方式:

  • 文本提示:在页面上展示"可碰一碰分享至 HarmonyOS 5 及以上版本手机"的文案。
  • 动图提示:用动画展示碰一碰的操作方式,更直观。

Share Kit 提供了统一的动图资源文件。下载后将 knock_share_guide 目录下的所有文件放到应用的 entry/src/main/resources/rawfile 目录即可使用。


四、核心开发流程:注册、发送、清理

4.1 注册与取消碰一碰事件

注册碰一碰事件有两种方式。简单场景下,直接传入回调函数即可:

private immersiveListening() {
  harmonyShare.on('knockShare', this.immersiveCallback);
}

private immersiveDisablingListening() {
  harmonyShare.off('knockShare', this.immersiveCallback);
}

如果需要更精细的控制(比如指定窗口、声明单向发送能力),可以传入 SendCapabilityRegistry 配置:

let capabilityRegistry: harmonyShare.SendCapabilityRegistry = {
  windowId: 999,  // 替换为实际的 windowId
};
harmonyShare.on('knockShare', capabilityRegistry, callback);

和隔空传送一样,生命周期管理是关键。进入可分享页面时注册,离开时(包括退后台)必须取消:

aboutToAppear(): void {
  this.immersiveListening();
  let context = this.getUIContext().getHostContext() as Context;
  context.eventHub.on('onBackGround', this.onBackGround);
}

aboutToDisappear(): void {
  this.immersiveDisablingListening();
  let context = this.getUIContext().getHostContext() as Context;
  context.eventHub.off('onBackGround', this.onBackGround);
}

onPageHide(): void {
  let context = this.getUIContext().getHostContext() as Context;
  context.eventHub.emit('onBackGround');
}

private onBackGround = () => {
  this.immersiveDisablingListening();
}

4.2 构造分享数据并发送

在碰一碰回调中构造分享数据。链接类分享的 utd 类型需要设置为 HYPERLINK

private immersiveCallback = (sharableTarget: harmonyShare.SharableTarget) => {
  let filePath = contextFaker.filesDir + '/exampleKnock1.jpg';
  let shareData = new systemShare.SharedData({
    utd: utd.UniformDataType.HYPERLINK,
    content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p',
    thumbnailUri: fileUri.getUriFromPath(filePath),
    title: '碰一碰分享卡片标题',
    description: '碰一碰分享卡片描述'
  });
  sharableTarget.share(shareData);
}

4.3 通过 App Linking 实现直达应用

分享链接时,强烈建议使用 App Linking 而不是普通 URL。App Linking 的好处在于:

  • 应用已安装:直接拉起应用对应页面。
  • 应用未安装:默认通过浏览器打开网页;配合 App Linking Kit 的直达应用市场能力,可以直接跳转到应用市场引导安装。再结合延迟链接能力,用户安装完成后首次打开应用时,仍能获取之前分享的链接内容——这对转化率的提升非常有价值。

另一种方案是 Deep Linking,但它只在本地已安装的应用中查找匹配项,未安装时会提示"暂无可用打开方式"。

4.4 异常场景的处理

碰一碰触发后,并不总是能顺利完成分享。Share Kit 提供了两种异常处理方式,帮助开发者优雅地终止分享,避免用户干等:

当前界面无可分享内容(6.0.2(22) 版本起支持):

sharableTarget.clarifyNonShare({ 
  message: '请在支持碰一碰分享的界面再试' 
});

这会终止本次分享,并弹出提示引导用户去可分享的页面。

网络或业务原因导致分享失败(5.0.3(15) 版本起支持):

sharableTarget.reject(harmonyShare.SharableErrorCode.DOWNLOAD_ERROR);

这会终止分享并提示用户具体原因。


五、邀请组队:碰一碰的另一种玩法

除了内容分享,碰一碰还有一个很有意思的应用场景——邀请组队。比如游戏中邀请旁边的朋友加入房间,碰一下手机就完成了。

这个场景有一个特殊问题:如果双方都在组队房间里互碰,会导致互相邀请加入对方房间的冲突。Share Kit 对此提供了单向仅发送能力,通过在注册时设置 sendOnly: true 来声明:

let capabilityRegistry: harmonyShare.SendCapabilityRegistry = {
  windowId: 999,
  sendOnly: true,  // 声明仅支持单向发送
};
harmonyShare.on('knockShare', capabilityRegistry, callback);

当碰一碰的双方都设置了 sendOnly,系统会终止本次分享并提示"请任意一方退出当前应用后再试"。只要有一方没设置 sendOnly,分享就能正常完成。

对端应用被拉起后,通过 onCreateonNewWant 回调中的 want.uri 获取组队链接,解析其中的参数来处理组队逻辑:

onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  console.log('收到组队链接: ', want.uri);
  // 解析链接参数,处理组队邀请
}

onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  console.log('收到组队链接: ', want.uri);
  // 应用已在前台时的处理
}

六、手机与 PC/2in1 之间的碰一碰分享

碰一碰不只是手机之间的事。手机和 PC/2in1 设备之间也可以碰一碰分享,而且交互方式更有趣——手机直接往屏幕上一放,利用 PC/2in1 的屏幕感知能力识别碰触动作和位置,实现窗口级的精准交互。

6.1 谁发谁收的规则

从 6.0.0(20) Beta5 版本开始,手机与 PC/2in1 之间不支持双向分享,遵循明确的优先级:

  1. 手机前台有可分享内容 → 手机发送,PC/2in1 接收。
  2. 手机前台无可分享内容,PC/2in1 前台窗口有 → PC/2in1 发送,手机接收。
  3. 双方前台都没有可分享内容 → 走无内容分享逻辑。

更早的版本(6.0.0(20) Beta3 及之前)支持双向同时分享,但后续版本取消了这个行为。

6.2 物理姿态要求

手机碰 PC/2in1 屏幕时,对放置姿态有具体要求:

  • 俯视夹角 ≤ 5°(手机要基本平放在屏幕上)
  • 侧视夹角 > 35°
  • 正视夹角 ≤ 25°
  • 手机不能超出屏幕边缘

此外,支持官方手机保护壳,但过厚的外壳可能影响感知。仅支持直板手机或折叠手机的直板形态。双端设备需要登录相同的华为账号。

6.3 沙箱接收:文件直达应用

从 6.0.0(20) 版本开始,PC/2in1 设备支持沙箱接收能力——手机碰一下屏幕,文件直接传入 PC/2in1 应用的沙箱目录,传完后通知应用处理,无需用户手动操作。

应用需要声明自己支持接收的文件类型和最大数量。如果类型不匹配,系统会回退到华为分享的默认接收逻辑;如果数量不匹配,会弹窗提示用户。

注册沙箱接收事件:

aboutToAppear(): void {
  let capabilityRegistry: harmonyShare.RecvCapabilityRegistry = {
    windowId: 999,
    capabilities: [{
      utd: utd.UniformDataType.IMAGE,
      maxSupportedCount: 1,
    }]
  };

  harmonyShare.on('dataReceive', capabilityRegistry, 
    (receivableTarget: harmonyShare.ReceivableTarget) => {
      let context = this.getUIContext().getHostContext() as common.UIAbilityContext;
      receivableTarget.receive(context.filesDir, {
        onDataReceived: (sharedData: systemShare.SharedData) => {
          let records = sharedData.getRecords();
          records.forEach((record) => {
            // 处理接收到的文件
          });
        },
        onResult(resultCode: harmonyShare.ShareResultCode) {
          if (resultCode === harmonyShare.ShareResultCode.SHARE_SUCCESS) {
            // 接收成功
          }
        }
      });
    }
  );
}

离开页面时同样要解除注册。如果因为业务原因需要拒绝本次接收,可以调用 receivableTarget.reject()


七、总结

碰一碰分享覆盖了两大场景:手机与手机之间的内容传输和组队邀请,以及手机与 PC/2in1 之间的文件传输。对开发者来说,接入时有几个核心关注点:

  1. 生命周期管理是基本功。注册和取消事件必须与页面生命周期严格对应,退后台也要取消,否则会出现意料之外的行为。

  2. 卡片设计影响转化。三种卡片模板由字段组合和图片比例自动决定,了解触发规则后有意识地选择合适的布局,预览图质量要控制在合理范围内。

  3. 异常处理不能省。无内容可分享时用 clarifyNonShare 引导用户,网络异常时用 reject 终止等待。这些细节决定了用户在非理想状态下的体验。

  4. App Linking 是最佳搭档。结合延迟链接和直达应用市场能力,即使对端没有安装应用,也能完成从分享到安装到打开内容的完整链路。

  5. 手机碰 PC/2in1 的姿态要求值得在产品引导中告知用户,避免"碰了没反应"的困惑。沙箱接收能力则为 PC 端应用打开了一种高效的文件接收方式。

使用Compose Navigation3进行屏幕适配

2026年4月3日 16:51

这篇文章将介绍B站是怎么使用 Compose Navigation3 进行页面的宽屏适配,并解决其中遇到的问题的。

本文所涉及到的 Compose 页面均已完成了 CMP 跨平台化适配,内容中基于安卓习惯所提的 “Activity” 如无额外说明均代表各平台的页面容器,即可以直接替换为iOS的UIViewController理解。

Navigation3 简介

Navigation3 是 Google 在 2025 年推出的全新 Compose 导航库,与之前的 Navigation Compose 有本质区别。它不再内置导航图(NavGraph)和 NavHost,而是将导航栈的管理权完全交给开发者,框架只负责"根据栈内容渲染 UI",将 f(data)=UI 的理念扩展到了页面导航栈上。得益于这新的精简的框架概念,使得 Navigation3 能很轻松地跟现有大型app的路由系统搭配整合使用,不像之前的 Navigation 库那样需要将现有路由完全迁移到导航图(NavGraph)声明上。开发者完全可以在单模块内进行 Nav3 的接入使用,同时保持整体的路由声明方式。

虽然使用Compose编写的页面,因其声明式的特性,已经有良好的响应屏幕宽度变化的能力。但是近期出现的超宽、折叠屏手机,包括鸿蒙平台的平板、桌面等设备,会让仅支持响应式布局的页面在超宽显示模式下给用户带来不好的视觉和交互体验。与Navigation3 库同时提出的“WindowSizeClass”中,将屏幕根据宽度划分为小、中、大等各个档位。这种“断点式”的屏幕划分可以指导我们知道在怎么样的情况下将应用的界面显示编排成全屏页面还是分屏页面的形式,显著提高在折叠屏、平板、桌面等“非传统手机”屏幕下的用户呈现能力。

为什么需要纯Compose的导航框架

在b站深入推进业务 CMP 跨平台化的过程中,我们发现缺少一个适配 CMP 属性的页面导航框架是深入业务使用的一大阻碍。

在先前的页面方案中,我们仿照安卓原生实现,选择了为每个导航节点嵌套一个原生window容器,即每打开一个个 Composable 页面都对应一个安卓 Activity 、iOS UIViewController 和 鸿蒙 entry 的创建与展现。这个方案能让我们快速地将 Compose 页面集成到现有工程中,但随后带来了更多其他的问题。

首要面临的问题是内存压力。在 iOS 和鸿蒙中,每打开一个新的原生容器来承载Compose页面,都意味着一个 CAMetalLayer/NativeWindow 被创建,对应3倍大小的render buffer也会被创建在内存中,内存占用就会相应提升。根据我们测算,使用三缓冲区渲染的 iOS,每一个 CAMetalLayer 都会占用约40M的内存。随着接入Compose的页面越来越多、用户打开的页面越来越多,内存压力会不断增长,影响我们的CMP推进进程。

另一个问题是,Compose 上下文内使用的 Lifecycle 系统是基于安卓生命周期概念设计的,在 iOS 和鸿蒙系统中多少有些水土不服,需要 ComposeView 的宿主层进行额外的配置工作,例如将 UIViewController 的 willAppear didAppear等回调桥接到 androidx 生命周期的相应事件上。在“标准容器”无法满足页面展现需求,需要做业务定制的时候,这些额外配置将成为开发过程的摩擦,在接入者不熟悉/没有意识到需要做这些配置的时候,将严重拖慢review和交付进度。

并且,这样的桥接总会丢失准确信息,特别是在页面切换的时候,总会错过准确的生命周期回调,导致在后台执行了额外的工作,引起卡顿、发热等问题;

同时,不正确的生命周期事件会让开发有不正确的预期,这一点会在本文后面详细描述。

基于以上问题的考量,我们得出结论,至少在纯Compose世界内的页面导航切换范围内,我们需要一个纯Compose的导航框架。而刚刚推出正式版、其结构思想契合现代代码开发思路的Navigation3成为我们的首选方案。

路由与导航的区别和联系

在之前的开发理念中,我们往往将"路由"和"导航"混为一谈:一个 URI 既是页面的标识,也是跳转的触发方式。我们将URI标注在一个 Fragment/Activity 上之后,调用“路由”跳转这个URI将直接打开这个页面实例。

@Route("bilibili://some/page")
class SomePageActivity: Activity()

Router.routeTo("bilibili://some/page") // == startActivity(SomePageActivity.class)

然而,在后续的开发和迭代过程中,我们逐渐意识到,这一次跳转动作应当分为两个具体步骤:使用“路由”寻找这个 URI 对应的页面信息,然后使用“导航”组件将这个页面展现在用户面前。

在我们的项目 CMP 化推进过程中,基架团队已经将这个理念应用到了b站的 CMP 版路由组件中,允许业务方在复用公共路由表的查找逻辑和结果的前提下,根据不同页面需要自定义自己的“路由结果导航”行为,为这次的 Nav3 快速接入提供了合适切入点。

数据驱动的声明式导航栈展现

Navigation3 的核心理念是:导航栈就是一个普通的 List,UI 是这个 list 的函数。

class MyBackStack<K : NavKey>(private val list: SnapshotStateList<K>) : SnapshotStateList<K> by list {
    override fun add(item: K){
        // 可以在这里提前处理冲突元素的清理
        list.add(item)
    }
}

@Composable
fun NavPage(modifier: Modifier = Modifier){
    val backStack = remember {
        mutableStateListOf(HomeNavKey)
    }

    NavDisplay(
        backStack = backStack,           // 数据:当前栈内容
        sceneStrategy = ...,             // 策略:如何将栈内容映射为布局
        entryDecorators = listOf(...),   // 装饰器:为每个 entry 注入能力
        entryProvider = entryProvider {   // 注册:NavKey → Composable 的映射
            entry<SomeNavKey> { key -> SomePage(key) }
        },
    )
}

开发者只需要按照自己的页面逻辑操作 backStack ,例如添加、移除,或者“在特定页面入栈时清除其他页面”用来实现“最多只有一个详情页被打开”的情况。NavDisplay 会自动响应变化并重新计算布局。不需要手动调用 navigate()、popBackStack() 等命令式 API,更加贴合 Compose 生态中的开发习惯。

在实际业务中接入使用

在实际的业务场景中使用 Navigation3 ,当然不像其他网络示例那样简单调用。我们将需要深入使用 Nav3 库提供的各种 api ,定制自己的业务功能。

NavKey 与路由发现

在 Nav3 中,NavKey 是描述页面的最小独立元素,每一个 NavKey 类型都跟一个页面绑定,描述了期望被打开的页面的基础信息,例如请求这个页面所需的唯一ID:

@Serializable
@Route("bilibili://some/nav3/page/with/id/{id}")
data class SomeIdPageNavKey(
    val id: String,
    val paramFromQuery: String,
) : NavKey

@Route("bilibili://some/page/with/id/{id}")
@Composable
fun SomePage(id: String, modifier: Modifier = Modifier, paramFromQuery: String = ""){}

因为一个 NavKey 可以跟一个路由严格对应,所以以上这段声明代码完全可以交给路由的 KSP 处理器自动生成。在子页面发起正常的路由跳转请求时,通过拦截器模式拦截此次路由的查找过程,如果找到匹配的 NavKey 类型,则将一个实例添加到backStack栈顶,将普通的导航行为桥接到 Nav3 的导航中。


// 路由拦截器:将普通路由请求桥接到 Nav3 的 backStack
class Nav3RouteInterceptor<KEY : NavKey>(
    private val onNavKeyFound: (KEY) -> Boolean,
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val originUri = chain.uri
        // 将原始 URI 转换为 Nav3 专用的查找格式
        // 例如 bilibili://some/page/123 → bilibili://some/nav3/page/123
        val navUri = convertToNav3Uri(originUri) ?: return chain.proceed()

        // 在路由表中查找这个 URI 对应的 NavKey 工厂函数
        val target = chain.find(navUri) as? SomeIdPageNavKey

        returnif (key != null && onNavKeyFound(key)) {
            Response.Done  // 拦截成功,阻止后续的默认导航行为(如 startActivity)
        } else {
            chain.proceed() // 未匹配,交给下一个拦截器或默认处理
        }
    }
}

// 在 Nav3 宿主页面中组装拦截器
@Composable
fun Nav3HostPage() {
    val backStack = remember { mutableStateListOf<MyNavKey>(HomeNavKey) }

    // 创建拦截器,拦截成功时将 NavKey 推入栈
    val interceptor = remember {
        Nav3RouteInterceptor<MyNavKey> { key ->
            backStack.add(key)
        }
    }
    // 在原有 Router 上叠加拦截器,生成新的 Router 实例
    val localRouter = LocalRouter.current
    val nav3Router = remember(localRouter, interceptor) {
        localRouter.newBuilder().addInterceptor(interceptor).build()
    }

    NavDisplay(
        backStack = backStack,
        entryDecorators = listOf(
            // 通过 Decorator 将带拦截器的 Router 注入到所有子页面
            // 这样子页面内发起的路由请求也会经过拦截器
            remember { Nav3RouterDecorator(nav3Router) },
            ...
        ),
        ...
    )
}

// Decorator 实现:通过 CompositionLocal 注入 Router
class Nav3RouterDecorator(
    private val router: Router,
) : NavEntryDecorator<MyNavKey>(
    onPop = {},
    decorate = { entry ->
        CompositionLocalProvider(LocalRouter provides router) {
            entry.Content()
        }
    },
)

NavKey 需要支持序列化(用于 backStack 的保存/恢复),因此都标注了 @Serializable;当然,也可以选择统一保存原始跳转链接的string内容,在需要恢复时重新走一次路由查找。

NavKey 与 entry 注册

NavKey 仅能表示“有一个页面”,在 Nav3 中,还需要通过 entryProvider 的方式将“这个 NavKey 对应的页面如何显示” 注册到当前的 NavDisplay 中:

NavDisplay(
    backStack = backStack,
    entryProvider = entryProvider {
        entry<SomeIdPageNavKey>(metadata = BiliListDetailSceneStrategy.detailPane()) { key -> SomePage(key.id, Modifier, key.paramFromQuery) }
    },
 )

当然,这一段注册代码也可以抽象为 EntryProviderScope.() -> Unit 的函数,由路由 KSP 处理器统一生成,页面只需要按需注册即可。

SceneStrategy 与 Scene

SceneStrategy 是 Navigation3 中最关键的扩展点。它接收当前 backStack 中所有 entry,返回一个 Scene 来描述如何布局。

在我们的宽屏适配实践中,我们实现了 BiliDetailSceneStrategy:

class BiliDetailSceneStrategy<K : NavKey>(
    val windowSizeClass: WindowSizeClass,
) : SceneStrategy<K> {
    override fun SceneStrategyScope<K>.calculateScene(entries: List<NavEntry<K>>): Scene<K>? {
        if (windowSizeClass.isAtLeastMedium(...)) {
            // 宽屏:从栈中找到最后一个 List entry 和最后一个 Detail entry
            val listEntry = entries.findLast { it.metadata.containsKey(LIST_KEY) }
                ?: return null
            val detailEntry = entries.findLast {
                it.metadata.containsKey(DETAIL_KEY)
            }
            return BiliListDetailScene(
                listEntry = listEntry,
                detailEntry = detailEntry,
                listWidth = if (windowSizeClass.widthLargeCompat()) 375.dp else300.dp,
            )
        }
        return null  // 非宽屏的情况:返回 null,表示当前 Strategy 不处理这个情况,NavDisplay 将使用默认单页 Strategy
    }
}

BiliListDetailScene 的布局结构:

Row(modifier = Modifier.fillMaxSize()) {
    // 左栏:列表
    Box(modifier = Modifier.width(listWidth)) {
        listEntry.Content()
    }
    VerticalDivider(...)
    // 右栏:详情或占位图
    Box(modifier = Modifier.weight(1f)) {
        if (detailEntry != null) {
            CompositionLocalProvider(LocalBackIconVisibility provides false) {
                detailEntry.Content()
            }
        } else {
            DefaultDetailPlaceholder()
        }
    }
}

每个 entry 通过 metadata 标记自己属于哪个区域。metadata 在注册 entry 时通过 BiliListDetailSceneStrategy.listPane() / detailPane() 设置:

companion object {
    fun listPane()= mapOf("BiliListDetailScene-List" to true)
    fun detailPane()= mapOf("BiliListDetailScene-Detail" to true)
}

SceneStrategy 的 calculateScene 在每次 backStack 变化时都会被调用。如果设备发生折叠/展开,windowSizeClass 变化会触发 BiliListDetailSceneStrategy 的重建(通过 remember(windowSizeClass)),从而自动切换单栏/双栏布局。

踩过的一些坑

从原生导航模式迁移到 Nav3 ,页面的导航方式将发生重大变化,其中有不少在往常开发过程中注意不到的地方。

生命周期、页面重入、状态保存

首先最大的一个变化,是之前每一次导航到一个 Composable 函数页面,都将打开一个全新的 Activity 来承载这个函数体,因此开发们会有一个错误认知:Compose Scope = Activity Scope = ViewModel Scope,在副作用处理上容易出现错误和遗漏,例如:

// ViewModel 中,将 toast 信息作为 State 的一部分暴露
data class PageState(
    val items: List<Item> = emptyList(),
    val toast: ToastContent? = null,  // 一次性事件,混在持久状态中
)

class SomeViewModel : ViewModel(){
    val state: StateFlow<PageState> = ...

    fun onAction(action: Action) {
        // 某些操作会产生 toast
        _state.update { it.copy(toast = ToastContent("操作成功")) }
    }
}

@Composable
fun SomePage(viewModel: SomeViewModel) {
    val state by viewModel.state.collectAsState()
    val toaster = LocalToaster.current

    var otherState = remember { mutableStateOf("") }

    // 通过 snapshotFlow 监听 state 变化来显示 toast
    LaunchedEffect(Unit) {
        snapshotFlow { state.toast }
            .filterNotNull()
            .distinctUntilChanged()
            .collect { toaster.showToast(it.content) }
    }

    // 或者直接判断内容进行显示,都会引发同样的问题。
    // LaunchedEffect(state.toast) {
    //    state.toast?.let { toaster.showToast(it.content) }
    // }

    // ... 页面内容
}

在独立的 Activity 中,这段代码运行起来不会有问题;但是在 Navigation3 的单Activity导航栈模式下,从这个页面跳转到其他页面之后,这个页面将暂时“退出组合”,在返回这个页面之后,重新“进入组合”。

在这个过程中,并不算一次“重组”,而是一次全新的组合事件,上面的代码将会出现:

  1.  不管 LaunchedEffect 的key是什么,都会进入一次执行,snapshotFlow中记录的前值也将被清空,导致 toast 被重复显示;

  2.  通过 remember 保存的状态也被清空,依赖 remember 做的逻辑将回到空态。

针对以上问题,修复思路其实很简单。

对于第一个问题,首先需要开发者确认什么内容该属于“状态”,什么内容该属于“事件”。

在示例代码中,val items: List 属于需要在页面上一直显示的内容,属于业务状态的一部分,使用 StateFlow 和 collectAsState 是很恰当的;而对于 toast 来说,已经显示过一次的toast内容在任何情况下都不该重新出现,因此它该属于“事件流”的一部分,每次消费后都不再重放,因此可以使用 sharedFlow 承载toast的传递,或者每次显示完主动将这个字段清空。

而第二个问题则更简单了,首先区分被 remember 的数据是否能接受丢失,如果是可以丢失的状态(例如,播放中的动画进度)则完全可以不处理;对于真正需要保存的数据,可以通过实现自定义 Saver 使用 rememberSavable 的方式,或者将数据委托给 ViewModel 中保存。

data class PageState(
    val items: List<Item> = emptyList(),
    val toast: ToastContent? = null,
)

class SomeViewModel : ViewModel(){
    private val _state = MutableStateFlow(PageState())
    val state: StateFlow<PageState> = _state.asStateFlow()

    // 将 state 中的 toast 字段转换为事件流(replay=0,重新订阅不重放历史事件)
    val toastEvent: SharedFlow<ToastContent> = state
        .map { it.toast }
        .filterNotNull()
        .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 0)

    fun onAction(action: Action) {
        _state.update { it.copy(toast = ToastContent("操作成功")) }
        // 也可以选择显示后立即清空,确保 toast 状态不会被持久持有
        // _state.update { it.copy(toast = null) }
    }
}

@Composable
fun SomePage(viewModel: SomeViewModel) {
    val state by viewModel.state.collectAsState()
    val toaster = LocalToaster.current

    // 需要跨页面跳转保留的状态,改用 rememberSaveable
    var otherState by rememberSaveable { mutableStateOf("") }

    // 消费事件流:重新进入组合时重新订阅,replay=0 保证不会重放已消费的事件
    LaunchedEffect(Unit) {
        viewModel.toastEvent.collect { toast ->
            toaster.showToast(toast.content)
        }
    }

    // ... 页面内容
}

Navigation3使用额外依赖中的 rememberViewModelStoreNavEntryDecorator() 来提供“页面在pop时清空相应viewmodel”的能力。并且在未来这个依赖和ViewModelStore的能力和api将会发生变化,带来更加强大的定制能力。

屏幕状态感知与返回按钮

宽屏模式下,右栏的页面不需要显示返回按钮(因为左栏始终可见)。可以通过自定义 LocalBackIconVisibility 这个 CompositionLocal 控制:

val LocalBackIconVisibility = compositionLocalOf { true }

// 右栏渲染
if (detailEntry != null) {
    CompositionLocalProvider(LocalBackIconVisibility provides false) {
        detailEntry.Content()
    }
}

子页面中通过读取这个值来决定是否显示返回图标:

val showBackButton = LocalBackIconVisibility.current

窄屏模式下 LocalBackIconVisibility 保持默认值 true,页面正常显示返回按钮。

状态栏的控制

在单 Activity 页面导航框架中, SystemUI 配置(如状态栏颜色)如果允许每个页面、每个组件自由控制,将很容易出现UI闪烁等情况。我们通过 SystemUiConfiguration 收集机制解决:每个 entry 通过 collectSystemUiConfiguration Modifier 上报自己的配置,NavDisplay所在的宿主页面 取栈顶 entry 的配置应用到宿主:

// ① 定义:持有状态栏配置的可观察容器
@Stable
class StableSystemUiConfiguration {
    var statusBarDarkIcons: Boolean? by mutableStateOf(null)
}

// ② 宿主:为每个 NavKey 分配一个 config 对象,并将"锚点 modifier"传给 entry
val configurationMap = remember { mutableStateMapOf<MyNavKey, StableSystemUiConfiguration>() }
val topConfiguration by remember {
    derivedStateOf { backStack.lastOrNull()?.let { configurationMap[it] } }
}

val getCollectorModifier: (MyNavKey) -> Modifier = { key ->
    val config = configurationMap.getOrPut(key) { StableSystemUiConfiguration() }
    // collectSystemUiConfiguration 在 modifier 链中埋入"锚点",持有 config 的引用
    Modifier.collectSystemUiConfiguration(config)
}

NavDisplay(
    // 将栈顶 entry 的配置应用到 Window(状态栏颜色等)
    modifier = Modifier.applySystemUiConfiguration(topConfiguration),
    entryProvider = entryProvider {
        // 在构建 entryProvider 时将 collector modifier 传入页面
        entry<SomeNavKey> { key ->
            SomePage(modifier = getCollectorModifier(key))
        }
    },
    ...
)

// ③ 子页面:将自己期望的状态栏配置追加到 modifier 链上
@Composable
fun SomePage(modifier: Modifier = Modifier) {
    val isDarkTheme = LocalDarkTheme.current
    Box(
        // statusBarDarkIcons 会沿 modifier 链向上查找"锚点",找到后将值写入宿主的 config 对象
        // 节点 attach 时写入,detach 时自动清空,生命周期安全
        modifier = modifier.statusBarDarkIcons(darkIcons = !isDarkTheme)
    ) {
        // 页面内容
    }
}

图片

而 NavDisplay 本身所在的页面中,框架已经传入了一个collectSystemUiConfiguration,并且将实际在 window 中生效。通过显式传递控制链条的方式,我们将状态栏的配置权限限制在页面宿主层级,在这一层让业务根据自己的实际逻辑决定内部组件的生效范围。

返回事件的处理

与 Navigation3 库同时推出的,是 androidx.navigationevent 库,用来响应和发送页面导航事件。Nav3 库默认已经使用了这个依赖库来响应返回事件,其行为是将现有的 backStack 的最新一个元素推出。如果我们需要定制返回事件的处理,可以通过包装 backStack 实现。

需要注意的是,androidx.navigationevent 库会将系统返回手势、系统导航栏返回键、应用顶部导航栏返回按钮或其他主动调用 backHandler.backCompleted() 处的返回事件一同给出,现有的注册层级结构关系不能区分出返回事件的来源行为和来源页面。因此,暂时无法实现“分栏页面各有一个返回按钮,各自控制其栏位的页面pop”交互。

原生页面嵌入

在需要进行宽屏适配的模块中,部分页面仍然是 Android Fragment 实现,尚未迁移到 CMP。我们选择通过 BiliNativePage 将 Fragment 嵌入 Navigation3 体系:

@Composable
internal fun BiliNativePage(url: String, modifier: Modifier){
    val showBackButton = LocalBackIconVisibility.current
    // 1. 通过 Router 解析 URL,获取 Fragment Class
    val routeInfo = Router.newCall(url).find()
    val clazz = routeInfo?.clazz

    // 2. 使用 AndroidFragment 嵌入 Compose
    if (clazz != null) {
        // 3. 因为 AndroidFragment 尚不支持响应state变化主动更新参数,因此选择一个key主动进行重组,通过切换fragment的方式将新的 showBackButton 传入
        // 也可以选择使用 ViewModel 传递 showBackButton 的更新,避免fragment的重建
        key(showBackButton) {
            AndroidFragment(
                clazz = clazz as Class<out Fragment>,
                modifier = modifier,
                arguments = createRouteExtraForFragment(routeInfo).also {
                    it.putBoolean("show_back_button", showBackButton)
                },
            )
        }
    }
}

// Fragment 侧:从 arguments 读取 show_back_button 控制返回按钮显隐
class SomePageFragment : Fragment(R.layout.fragment_some_page) {
    private val showBackButton get() = arguments?.getBoolean("show_back_button", true) ?: true

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.btnBack.isVisible = showBackButton
        binding.btnBack.setOnClickListener { parentFragmentManager.popBackStack() }
    }
}

原生页面的注册可以通过 expect/actual 机制来声明,或者使用依赖注入框架来实现页面注册。

showBackButton 的变化会触发 Fragment 重建(通过 key(showBackButton)),确保 Fragment 能响应宽屏/窄屏切换时返回按钮的显隐变化。

Scene 中的小发现

在尝试在Nav3框架内添加页面切换动画过程中,我调研了官方示例 nav3-recipes 中关于动画切换的部分,结果看到了一段让我始料未及的代码:

override val content: @Composable (() -> Unit) = {
    Row(modifier = Modifier.fillMaxSize()) {
        Column(modifier = Modifier.weight(0.4f)) {
            listEntry.Content()
        }
        ...

        Column(modifier = Modifier.weight(0.6f)) {
            AnimatedContent(
                ...
            ) { entry ->
                entry.Content()
            }
         }
     }
}

其中的 entry.Content() 让我产生了“这能正常触发重组吗?”的疑问🤔。在通常的开发惯例中,Composable 函数一般都是独立于Kotlin class的顶层函数,而不是某个实例的成员函数来被调用,这样 Compose 框架可以通过分析入参是否变化来决定是否重组;如果函数本身是一个类对象的成员函数,那类实例的改变会不会产生类似于key改变的作用、从而触发了这个Composable函数的完全重组呢?

带着这个疑问,我构造了一段测试代码:

@Immutable
data class TestClass(val data: String){
    @Composable fun Content(modifier: Modifier = Modifier){
        Text(data)
    }
}

然后使用 jadx 查看它编译后的产物,看到了关键信息:

public final class TestClass {
    public static final int $stable = 0;
    private final String data;
    /* JADX INFO: Access modifiers changed from: private */
    public static final Unit Content$lambda$0(TestClass testClass, Modifier modifier, int i, int i2, Composer composer, int i3) {
        testClass.Content(modifier, composer, RecomposeScopeImplKt.updateChangedFlags(i | 1), i2);
        return Unit.INSTANCE;
    }
    public TestClass(String data){
        Intrinsics.checkNotNullParameter(data, "data");
        this.data = data;
    }
    public final String getData(){
        returnthis.data;
    }
    public final void Content(Modifier modifier, Composer $composer, finalint $changed, finalint i){
        Composer $composer2;
        final Modifier modifier2;
        Composer $composer3 = $composer.startRestartGroup(507862195);
        ComposerKt.sourceInformation($composer3, "C(Content)N(modifier)160@6025L10:ListDetailScene.kt#qpkuy4");
        int $dirty = $changed;
        if (($changed & 48) == 0) {
            $dirty |= $composer3.changed(this) ? 32 : 16;
        }
        if (!$composer3.shouldExecute(($dirty & 17) != 16, $dirty & 1)) {
            $composer2 = $composer3;
            $composer2.skipToGroupEnd();
            modifier2 = modifier;
        } else {
            Modifier modifier3 = (i & 1) != 0 ? Modifier.INSTANCE : modifier;
            if (ComposerKt.isTraceInProgress()) {
                ComposerKt.traceEventStart(507862195, $dirty, -1, "com.example.nav3recipes.scenes.listdetail.TestClass.Content (ListDetailScene.kt:159)");
            }
            $composer2 = $composer3;
            TextKt.m4145TextNvy7gAk(this.data, null, 0L, null, 0L, null, null, null, 0L, null, null, 0L, 0, false, 0, 0, null, null, $composer2, 0, 0, 262142);
            if (ComposerKt.isTraceInProgress()) {
                ComposerKt.traceEventEnd();
            }
            modifier2 = modifier3;
        }
        ScopeUpdateScope scopeUpdateScopeEndRestartGroup = $composer2.endRestartGroup();
        if (scopeUpdateScopeEndRestartGroup != null) {
            scopeUpdateScopeEndRestartGroup.updateScope(new Function2() { // from class: com.example.nav3recipes.scenes.listdetail.TestClass$$ExternalSyntheticLambda0
                @Override // kotlin.jvm.functions.Function2
                publicfinal Object invoke(Object obj, Object obj2) {
                    return TestClass.Content$lambda$0(this.f$0, modifier2, $changed, i, (Composer) obj, ((Integer) obj2).intValue());
                }
            });
        }
    }
}

会发现,这样一种成员Composable函数的产物跟顶层函数没什么区别,都是使用一个生成的数字key作为restart group的标记,在其中判断参数是否变化时,额外进行了 this 对象的判断,也就是说,可以简单将这个函数定义等价为:

data class TestClass(val data: String)

@Composable
fun Content($this: TestClass, modifier: Modifier = Modifier)

基于这一层理解,就能确认,在nav3-recipes示例工程中,scene发生实例变化的时候,也等价于一个普通的Compose重组,其中可以依靠普通的重组、跳过和remember实现动画播放了。

总结

Navigation3 库的出现,极大地减轻了现有 app 在既存路由框架中接入使用的负担,让我们能快速地将现有的 Compose 页面接入其中,完成宽屏适配;同时也推动开发者在编写 Compose 页面的时候更加深入地思考该如何去适配它的生命周期与状态保存,提示代码的交付质量。

-End-

作者丨肖志康

我写了个系统,每天上朝批奏折:把 Agent 做成「文武百官」是什么体验

作者 芋圆ai
2026年4月3日 16:47

我写了个系统,每天上朝批奏折:把 Agent 做成「文武百官」是什么体验

当小人被关进天牢的那一刻,就是朕决定掏钱的那一刻。

写在前面

09_副本.png

先说结论:当你用「拟圣旨」的方式给 Agent 下指令,看着丞相带着六部尚书在 2D 像素宫殿里跑来跑去给你办差——这玩意儿比你想象的更有成就感。

我叫它 Syntropy(太和),一个基于古代朝廷隐喻的可视化多智能体操作系统。但不是那种换个皮就完事的「国潮包装」,而是真的把 Agent 的执行过程「具象化」了:

  • Agent 不再是日志里的一串 tool_call_pending,而是坐在工位上的像素小人
  • 任务调度不再是抽象的 Orchestrator,而是丞相(Minister)在廷议上发号施令
  • 风险拦截不再是冷冰冰的 await human_approval(),而是把 Agent 关进天牢,等你御批

你可能会问:为什么要搞这么复杂?直接写代码不行吗?

因为现有的 Multi-Agent 框架(LangChain、AutoGen、CrewAI……)有一个共同的问题:它们是黑盒。你能看见输入和输出,但中间的思考、决策、调度、执行,全在一团混沌的日志里。你调了半天 prompt,还是不知道 Agent 卡在哪一步。

所以我的思路很简单:既然 Agent 的执行过程看不见,那就让它「看得见」。

08_副本.png

这篇文章会拆解这套系统的技术架构,但不会堆砌术语。咱们边「上朝」边聊:怎么把状态机映射成像素动画、怎么实现前后端实时同步、怎么设计「御批」机制,以及——为什么「当皇帝」这个隐喻,反而让 Agent 系统更好用了。


1. 问题:Agent 系统的「三大弊政」

在动手之前,我先总结了当前 Multi-Agent 系统的三个「弊政」:

1.1 黑盒化(Black Box)——「爱卿,你到底在想什么?」

Agent 的 Chain-of-Thought(思维链)是一串嵌套的 JSON,工具调用是一条条 log 记录。你很难从这些信息里快速判断:

  • Agent 现在在做什么?是在思考,还是在等你审批?
  • 它在等谁?丞相在等户部尚书查账,还是兵部尚书在调兵?
  • 它卡在哪一步了?是 LLM 没返回,还是工具调用超时?
  • 它的决策依据是什么?为什么它选择了这个方案而不是另一个?

01_副本.png

现状:你只能盯着终端滚屏的日志,祈祷 Agent 别卡死。

1.2 失控风险(Uncontrollable)——「爱卿,这奏折朕没批你就敢执行?」

Agent 自主调用工具是一件很危险的事。删除文件、转账、调用外部 API —— 这些操作一旦失控,后果不可逆。

现有的「Human-in-the-loop」方案要么是简单的 input() 阻塞(体验极差),要么需要入侵式地修改整条执行链路(开发成本高)。

现状:你要么相信 Agent 不会乱来,要么就得自己写一套审核机制。

1.3 记忆遗忘(Amnesia)——「爱卿,昨天说的事你怎么忘了?」

大多数 Agent 框架的记忆系统基于关键词匹配或纯向量检索。关键词匹配漏掉语义相关的信息,纯向量检索又容易在专有名词上翻车。长对话场景下,Agent 往往会「忘记」几轮之前说过的关键信息。

现状:你只能不断「提示」Agent,把上下文塞给它,直到 Token 爆掉。


2. 方案:把 Agent 变成「可观测的臣子」

Syntropy 的核心思路只有一句话:

所见即所思(What you see is what they think)。

具体拆解为三个「治国方略」:

2.1 可视化运行时(Visualized Runtime)——「爱卿们,都动起来」

Agent 的内部状态机(THINKINGACTINGWAITINGERROR)不只在日志里打印,而是实时映射为 2D 像素小人的行为动画

  • THINKING → 小人在原地踱步,头顶冒出气泡(正在思考)
  • ACTING → 小人移动到对应的「工位」(户部查账、兵部调兵、工部造器械……)
  • WAITING_FOR_HUMAN → 小人被「关进天牢」,等待你的御批
  • ERROR → 小人倒地,头顶冒叉(出错了,得查日志)

这套映射让 Agent 的执行状态变得一眼可见。你不再需要翻几百行日志,只需要看一眼沙盘,就知道哪个 Agent 卡住了、它在干什么、它在等谁。

2.2 内核级状态机(Kernel-Level State Machine)——「朝堂规矩,不可乱」

后端 Agent 的生命周期被标准化为一个有限状态机(FSM):

agent-state-machine.png 这个 FSM 是「内核级」的,意味着:

  • LLM 推理(Reasoning)和工具执行(Execution)完全解耦
  • 每个状态的进入和离开都会触发标准化的事件(可观测)
  • 风险拦截、记忆压缩、日志追踪都可以作为「状态钩子」无痕插入

简单说:Agent 不再是「自由发挥」,而是按「朝堂规矩」办事。

2.3 人机协同「御批」协议(Human-in-the-loop)——「这道奏折,朕要亲自批」

每个工具调用都有 riskLevel(low / medium / high)。当 Agent 试图执行高风险操作时:

  1. 内核挂起当前执行流,状态切换为 WAITING_FOR_HUMAN
  2. 前端收到 approval_request 事件,弹出「御批」弹窗
  3. 用户点击「准奏」或「驳回」,通过 Socket 发回指令
  4. 内核恢复执行流(或回滚)

这套机制让「人审」不再是事后补救,而是执行流程的有机组成部分


3. 核心功能:奏折阁与决策树

讲完架构,聊聊实际用起来是什么感觉。

3.1 奏折阁(Imperial Archives)——「每道圣旨,都有迹可循」

在 Syntropy 里,每一次用户指令都被封装为一份**「奏折」。奏折阁是系统的任务管理中心,完整记录了从拟旨 → 受理 → 分发 → 复命**的全过程。

奏折的核心特性

  • 折叠/展开:每份奏折默认折叠,仅展示摘要(如"查Q1税收");展开后可查看完整的对话链路与决策树
  • 多视角叙事:左侧展示百官的回复与思考过程,右侧展示皇帝(用户)的指令,清晰还原对话脉络
  • 状态追踪:每份奏折都有明确的状态标签(待处理 / 进行中 / 已完成 / 已驳回),方便追溯

3.2 决策树可视化——「丞相的思考,一目了然」

当丞相收到一道复杂指令(如「查一下上季度的营收,并对比去年同期」),它不会直接给出答案,而是会拆解任务、调度六部、汇总结果。整个过程形成一棵决策树

decision-tree.png

在奏折阁中,这棵决策树以可视化流程图的形式呈现。你可以清楚地看到:

  • 丞相调度了哪些 Agent
  • 每个 Agent 执行了什么操作
  • 每一步的输入和输出是什么
  • 如果某一步出错,具体卡在哪里

02_副本.png

这对调试和优化至关重要。你不再需要猜"Agent 为什么没按我的预期做事",而是可以直接看到它的决策路径,找出问题所在。

3.3 记忆库(Memory Vault)——「史官的起居注」

记忆库展示 Agent 主动保存的重要信息(个人偏好、项目决策、关键事实),支持:

  • 搜索与过滤:按关键词搜索,或按类别(personal / preference / project / decision)筛选
  • 在线编辑:直接在前端编辑或删除记忆条目,实时同步到后端
  • 语义分类:LLM 根据上下文自动选择记忆类别,无需人工标注

4. 技术实现:朝堂是如何运转的

4.1 前端:React + Phaser 的「双引擎」架构

前端是 Syntropy 最复杂的部分。我们需要同时处理两类需求:

  • UI 层:奏折面板、记忆库、官员状态 HUD、御批弹窗……这些是典型的 React 组件
  • 渲染层:2D 像素沙盘、角色动画、寻路、碰撞检测……这些是游戏引擎的领域

我们的方案是 React-Phaser Bridge

frontend-architecture.png

  • Zustand 作为单一数据源(Single Source of Truth),存储所有 Agent 的状态
  • React 组件订阅 Zustand Store,渲染 UI 面板
  • Phaser 3update() 循环中读取 Store,同步小人动画

这套架构的好处是:UI 和渲染完全解耦。React 不用关心像素坐标,Phaser 不用关心业务逻辑,两者通过 Zustand 的状态桥接。

关键代码片段:状态同步
// store/agentStore.ts
export const useAgentStore = create<AgentStore>((set) => ({
  agents: {},
  updateAgent: (id, updates) =>
    set((state) => ({
      agents: {
        ...state.agents,
        [id]: { ...state.agents[id], ...updates },
      },
    })),
}));
// game/MainScene.ts
export class MainScene extends Phaser.Scene {
  update() {
    const agents = useAgentStore.getState().agents;
    Object.values(agents).forEach((agent) => {
      const sprite = this.sprites[agent.id];
      if (sprite) {
        sprite.updateState(agent.status, agent.targetPosition);
      }
    });
  }
}

4.2 后端:自研 Agent 框架

Syntropy 的后端完全自研,不依赖任何现有的 Agent 框架(LangChain、AutoGen 等)。原因很简单:现有框架的状态机模型和我们需要的不完全匹配

后端整体架构

backend-architecture.png

后端核心模块:

模块 职责
Kernel Agent 生命周期管理,状态机调度
Agent 单个 Agent 的 LLM 调用、工具执行、状态流转
LLM Provider 统一的 LLM API 抽象(支持 OpenAI / DeepSeek)
MemoryManager 记忆存储与检索(FTS5 + Vector + RRF)
SocketGateway 前后端实时通信
Tracer 全链路追踪与结构化日志
Agent 核心状态机
// server/core/Agent.ts
class Agent {
  private state: AgentState = 'IDLE';

  async processMessage(message: string) {
    this.setState('THINKING');
    const response = await this.llm.chat(message);
    
    if (response.toolCalls) {
      for (const toolCall of response.toolCalls) {
        const risk = this.assessRisk(toolCall);
        if (risk === 'high') {
          this.setState('WAITING_FOR_HUMAN');
          await this.waitForApproval(toolCall);
        }
        await this.executeTool(toolCall);
      }
    }
    
    this.setState('IDLE');
    return response.content;
  }
}

4.3 记忆系统:RRF 混合检索引擎

Syntropy 的记忆系统不是纯向量检索,而是三位一体的混合架构:

  1. SQLite:结构化元数据(时间、类别、Agent ID)
  2. FTS5:全文倒排索引,精准匹配关键词
  3. Vector:语义向量,模糊语义检索

检索时,我们使用 Reciprocal Rank Fusion (RRF) 算法合并 FTS 和 Vector 的结果:

RRF_score = Σ (1 / (k + rank_i))

其中 k 是平滑常数(通常取 60),rank_i 是某条记忆在第 i 个检索引擎中的排名。

为什么需要 RRF?
  • 关键词场景:用户问「昨天的税收是多少」,FTS 能精准命中包含"税收"的记录,Vector 可能因为语义漂移而漏掉
  • 语义场景:用户问「最近有什么异常吗」,FTS 因为没有一个明确的关键词而失效,Vector 能理解"异常"的语义

RRF 让两者互补,召回率显著提升。

记忆压缩(Memory Compression)

当 Agent 进入 SLEEPING 状态时,系统自动调用 LLM 对当日未处理的对话进行摘要,生成 daily_summary 并持久化。这解决了长对话场景下的 Token 溢出问题。

// server/runtime/MemoryManager.ts
async compressMemories(agentId: string, conversations: Conversation[]) {
  const summary = await this.llm.chat(`
    请对以下对话进行摘要,提取关键决策和待办事项:
    ${conversations.map(c => c.content).join('\n')}
  `);
  
  await this.db.insert('memories', {
    agentId,
    type: 'daily_summary',
    content: summary,
    timestamp: Date.now(),
  });
}

4.4 全链路追踪(Tracer)

每个用户指令生成唯一的 traceId,贯穿 Agent 调度、工具调用、LLM 推理全流程。Tracer 记录 8 种诊断事件:

  • agent.turn:Agent 开始处理一轮对话
  • tool.call:工具调用
  • model.usage:LLM 调用及 Token 消耗
  • dispatch:任务分发
  • approval.wait:等待御批
  • approval.done:御批完成
  • agent.stuck:Agent 卡死检测(3 分钟无响应自动告警)
  • memory.save:记忆保存

所有事件自动脱敏(截断 API Key 等敏感信息),便于性能分析和故障排查。


5. 架构反思

5.1 为什么选 Phaser 而不是 Canvas / SVG?

Phaser 是一个专业的 2D 游戏引擎,提供了完整的场景管理、精灵动画、碰撞检测、寻路算法。如果用 Canvas 手写,这些都要从零实现;如果用 SVG,大量 DOM 元素会导致性能问题。

Phaser 的 WebGL 渲染让我们可以轻松实现:

  • 像素小人的平滑移动动画
  • 头顶气泡的动态效果
  • 大规模 Agent 同时活动时的性能保障

5.2 为什么不直接用 LangChain / AutoGen?

LangChain 和 AutoGen 的状态机模型是「扁平」的:Agent 顺序执行 Thought → Action → Observation。但我们需要的是内核级的状态机,能够:

  • 在任意时刻挂起执行流(御批拦截)
  • 在任意时刻注入新状态(外部干预)
  • 标准化所有状态转换事件(可观测性)

现有的框架很难在不破坏封装的前提下实现这些需求。

5.3 视觉隐喻的取舍

「古代朝廷」这套视觉系统是一把双刃剑:

优点

  • 降低了多 Agent 系统的认知门槛,非技术用户也能理解「丞相调度六部」的概念
  • 增强了产品的辨识度和传播性

缺点

  • 增加了设计和开发成本(像素素材、动画、场景布局)
  • 对部分技术用户来说可能显得「花哨」

我们的判断是:可视化的核心价值在于降低认知负担,视觉隐喻只是手段。如果换一套视觉系统(如太空站、工厂流水线),只要核心架构不变,价值依然存在。


6. 开源与未来

Syntropy 目前处于 Beta 阶段,代码已开源:GitHub - zabr1314/Syntropy

未来规划:

  • 技能市场:允许开发者为 Agent 开发自定义技能(类似 VSCode 插件)
  • 多场景模板:除了「朝廷」,提供「太空站」「工厂」等可选视觉主题
  • Agent 编排可视化:拖拽式构建多 Agent 协作链路
  • 分布式部署:支持将不同 Agent 部署在不同节点上

7. 结语:当皇帝的一天

Syntropy 本质上是一次将 AI 黑盒具象化的尝试。我们相信:

当 Agent 的执行过程变得可见、可交互、可干预,多智能体系统才能真正从实验室走向生产环境。

但除此之外,它还有一个不那么「技术」的价值:它让管理 Agent 变成了一件有趣的事

每天早上打开系统,看到丞相已经在廷议上等你,六部尚书各就各位。你拟定一道圣旨,看着小人们在宫殿里跑来跑去办差。有时候他们会卡住,有时候他们会犯错,有时候他们会把奏折递到你面前等你御批——这一刻,你不是在 debug,你是在当皇帝。

如果你对这套架构感兴趣,或者有自己的看法,欢迎在 GitHub 上交流。


相关资源

鸿蒙隔空传送:一抓一放,内容就到了对面的设备上

2026年4月3日 16:39

引言

跨设备传文件这件事,我们已经做了太多年了——蓝牙配对、扫码互传、聊天窗口转发、甚至给自己发邮件。这些方式都能用,但都不够"顺手"。

鸿蒙 Share Kit 新推出的隔空传送,尝试用一种更自然的交互来解决这个问题:用户对着屏幕做一个"抓取"手势,内容就被"拿"起来了,再对着另一台设备"放下",内容就传过去了。整个过程不需要打开任何传输工具,也不需要手动选择接收设备。

本文面向希望在应用中接入隔空传送能力的鸿蒙开发者,梳理这项功能的工作机制、与系统其他功能的联动关系,以及具体的接入方法。


一、隔空传送是怎么工作的

1.1 基本交互逻辑

隔空传送的核心交互是"一抓一放"——用户在一台设备前做出握拳抓取的手势,设备捕捉到这个动作后,将当前页面的分享内容"抓起来";然后用户面向另一台设备做出释放手势,内容就传送到了对端设备上。

这个手势并不是凭空工作的。它依赖应用侧主动注册分享事件——只有当前页面注册了隔空传送的监听,系统才知道"这个页面有东西可以分享"。如果页面没注册,手势不会触发隔空传送。

1.2 使用前提:打开隔空传送开关

隔空传送默认不是开启状态,用户需要手动打开:

设置 → 系统 → 快捷启动和手势 → 隔空传送

这是设备级的开关,对所有支持隔空传送的应用生效。

1.3 设备信任机制

传输的安全性通过设备信任关系来保障,分两种情况:

  • 同账号设备:如果两台设备登录了相同的华为账号,系统默认它们互相信任,传输时无需额外确认,直接发送。
  • 不同账号设备:需要双端用户各自确认"信任对方设备"。确认后,1 小时内再次传输无需重复确认。超过 1 小时则需要重新建立信任。

这种设计在便捷性和安全性之间取了一个平衡——自己的设备间传东西零障碍,借别人设备传也不会被滥用。


二、隔空传送与隔空截屏的关系

一个容易让人困惑的地方是:隔空传送和隔空截屏共用同一个手势触发。这意味着用户做"抓取"动作时,可能同时触发两件事。系统通过两个开关的组合状态来决定具体行为:

隔空传送开启 隔空传送关闭
隔空截屏开启 图库场景传输原图;其他场景传送截屏 仅截屏,不传送
隔空截屏关闭 图库场景传送原图;其他场景无截屏也不传送 什么都不发生

几个值得注意的细节:

  • 当两个开关都打开,且当前页面注册了隔空传送事件时,抓取手势会同时触发隔空传送和隔空截屏。此时隔空传送的卡片下方会出现"保存截屏至本机"的提示。
  • 首次触发时,默认不保存截屏。用户可以手动勾选保存,系统会记住这个选择,作为下次的默认值。
  • 如果只开了隔空截屏、没开隔空传送,那抓取手势就只是截屏,不会有任何传输行为。

对于开发者来说,不需要关心截屏逻辑——这完全是系统层面的行为。你只需要关注隔空传送的注册和数据准备。


三、接入隔空传送的开发实践

接入隔空传送的核心工作就是三件事:在对的时机注册监听、在回调中准备分享数据、在离开时取消监听

3.1 导入所需模块

import { uniformTypeDescriptor as utd } from '@kit.ArkData';
import { systemShare, harmonyShare } from '@kit.ShareKit';
import { fileUri } from '@kit.CoreFileKit';

这里涉及三个 Kit:ShareKit 提供隔空传送和分享的核心能力,ArkData 中的 uniformTypeDescriptor 用于声明分享内容的数据类型,CoreFileKit 用于将文件路径转换为 URI。

3.2 定义手势触发时的分享逻辑

当用户做出抓取手势时,系统会通过注册的回调把一个 SharableTarget 对象传给你。你需要在这个回调里准备好分享数据,然后调用 sharableTarget.share() 把数据发出去。

private immersiveCallback = (sharableTarget: harmonyShare.SharableTarget) => {
  let uiContext: UIContext = this.getUIContext();
  let contextFaker: Context = uiContext.getHostContext() as Context;
  let filePath = contextFaker.filesDir + '/exampleKnock1.jpg';

  let shareData: systemShare.SharedData = new systemShare.SharedData({
    utd: utd.UniformDataType.HYPERLINK,
    content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p',
    thumbnailUri: fileUri.getUriFromPath(filePath),
    title: '隔空传送分享卡片标题',
    description: '隔空传送分享卡片描述'
  });

  sharableTarget.share(shareData);
}

这里有一个关键的时间约束:收到回调后,建议在 3 秒内调用 sharableTarget.share(),否则可能因为超时导致传输失败。所以分享数据的准备不宜太重——如果需要读取大文件或做复杂处理,建议提前准备好,在回调中直接使用。

分享数据通过 SharedData 构造,几个关键字段的含义:

  • utd:声明分享内容的统一数据类型。上面的示例使用的是 HYPERLINK,表示分享一个链接。
  • content:实际的分享内容,这里是一个 App Linking 链接。
  • thumbnailUri:传送卡片上显示的缩略图,需要传入文件的 URI。
  • titledescription:传送卡片上的标题和描述文字。

3.3 分享 App Linking 实现直达应用

上面的示例中,content 字段传入的是一个 App Linking 链接。这样做的好处是:对端设备收到后,点击卡片可以直接跳转到对应的应用页面,而不只是打开一个网页。如果你希望实现这种"传送即直达"的体验,应用需要先接入 App Linking。

3.4 在正确的时机注册和取消监听

这一步直接决定了功能能否正常工作。原则很简单:

  • 进入可分享页面时注册,告诉系统"这个页面有内容可以分享"。
  • 离开可分享页面时取消,包括页面销毁和应用退到后台的场景。

注册和取消的方法很直接:

private immersiveListening() {
  harmonyShare.on('gesturesShare', this.immersiveCallback);
}

private immersiveDisablingListening() {
  harmonyShare.off('gesturesShare', this.immersiveCallback);
}

但"什么时候算离开"需要仔细处理。除了页面销毁(aboutToDisappear),应用退到后台也应该取消监听。一个完整的处理方式如下:

aboutToAppear(): void {
  // 页面出现时注册监听
  this.immersiveListening();

  // 同时监听应用退后台事件
  let uiContext: UIContext = this.getUIContext();
  let context: Context = uiContext.getHostContext() as Context;
  context.eventHub.on('onBackGround', this.onBackGround);
}

aboutToDisappear(): void {
  // 页面销毁时取消监听
  this.immersiveDisablingListening();

  let uiContext: UIContext = this.getUIContext();
  let context: Context = uiContext.getHostContext() as Context;
  context.eventHub.off('onBackGround', this.onBackGround);
}

// 页面隐藏时(包括退后台),通过事件总线通知
onPageHide(): void {
  let uiContext: UIContext = this.getUIContext();
  let context: Context = uiContext.getHostContext() as Context;
  context.eventHub.emit('onBackGround');
}

private onBackGround = () => {
  this.immersiveDisablingListening();
}

这里的思路是:通过 onPageHide 捕捉页面隐藏事件(退后台会触发),然后通过 eventHub 发出通知,在通知处理中取消隔空传送监听。这样无论是页面销毁还是应用退后台,监听都会被正确清理。

为什么要这么认真地处理取消监听? 因为如果应用退到后台后监听还在,用户在其他应用中做抓取手势时,可能会意外触发你的应用的分享逻辑,导致不可预期的行为。


四、总结

隔空传送提供了一种非常直觉化的跨设备内容传输方式。从开发者的角度看,它的接入并不复杂,但有几个关键点需要把握好:

  1. 时效性:收到手势回调后 3 秒内必须完成分享调用。分享数据要提前准备,不要在回调中做耗时操作。
  2. 生命周期管理:注册和取消监听必须与页面的生命周期严格对应。特别是应用退后台的场景,容易被忽略。
  3. 与隔空截屏的共存:理解两个开关的组合行为,在产品设计上给用户清晰的预期。
  4. 信任机制:同账号设备间传输是无感的,不同账号需要双向确认。如果你的应用场景经常涉及跨账号传输,可以在引导中提示用户。

如果你的应用有内容分享的需求——无论是链接、图片还是文件——隔空传送都是一种值得接入的自然交互方式。结合 App Linking,还能实现"传送即打开对应页面"的完整体验,这在跨设备协作场景中会非常实用。

❌
❌