阅读视图

发现新文章,点击刷新页面。

每日一题-旋转字符串🟢

给定两个字符串, s 和 goal。如果在若干次旋转操作之后,s 能变成 goal ,那么返回 true 。

s 的 旋转操作 就是将 s 最左边的字符移动到最右边。 

  • 例如, 若 s = 'abcde',在旋转一次之后结果就是'bcdea' 。

 

示例 1:

输入: s = "abcde", goal = "cdeab"
输出: true

示例 2:

输入: s = "abcde", goal = "abced"
输出: false

 

提示:

  • 1 <= s.length, goal.length <= 100
  • s 和 goal 由小写英文字母组成

前端测试:别为了100%覆盖率而写测试,那是自欺欺人

你写了测试,覆盖率100%,感觉稳了。结果上线后,用户点了个按钮,页面直接白屏。你纳闷:覆盖率不是100%吗?因为你测的都是“天气好不好”,没测“会不会地震”。今天我们就来聊聊前端测试的正确姿势——怎么测才能真的有用,而不是为了指标好看写一堆废话。

前言

前端测试常走两个极端:要么完全不测,上线随缘;要么为了覆盖率,测了等于没测(比如测个1 + 1 = 2)。真正有效的测试,不是越多越好,而是该测的测,不该测的别浪费生命

今天我们用“测试金字塔”模型,帮你理清单元测试、组件测试、E2E测试的分工。看完你会知道:哪部分代码必须测,哪部分可以跳过,哪部分用哪个工具。

一、测试金字塔:三分天下,各司其职

       /\
      /E2E\      ← 少而精,关键路径
     /------\
    /集成测试\    ← 中等,组件间交互
   /----------\
  /  单元测试   \  ← 多而快,纯逻辑
 /--------------\
  • 底座(单元测试):测最小的代码单元(函数、工具类)。多、快、便宜。
  • 中层(组件测试/集成测试):测几个单元结合后的行为(比如一个表单组件提交数据)。
  • 顶层(E2E测试):模拟真实用户,测整个流程(从打开页面到点击到结果)。

比例大概是:单元测试占70%,集成测试20%,E2E 10%。不是死规定,但原则:底层测试成本低,多写;顶层测试维护成本高,只写关键路径

二、单元测试:测逻辑,不测实现细节

单元测试的目标:给定输入,输出是否正确。不关心函数内部怎么实现的,只关心结果。

适合测的

  • 纯函数(输入输出确定,无副作用)。
  • 业务规则(比如calculateDiscount(price, level))。
  • 工具函数(formatDateparseQuery)。

不适合测的(测了也白测):

  • 框架内部逻辑(React的setState、Vue的响应式——那是框架的事)。
  • 简单的getter/setter。
  • 常量定义。

工具:Jest + Vitest(Vite项目推荐Vitest)。

例子

// 要测的函数
function formatPrice(price, currency = '¥') {
  return `${currency}${price.toFixed(2)}`;
}

// 测试
test('格式化价格', () => {
  expect(formatPrice(10.5)).toBe('¥10.50');
  expect(formatPrice(10.5, '$')).toBe('$10.50');
});

黄金法则:如果重构代码不破坏测试,说明你测的是行为,不是实现。

三、组件测试:测交互,不测样式

组件测试(React Testing Library / Vue Test Utils)的目标:模拟用户行为,检查组件渲染和交互是否正确。不关心DOM结构细节,只关心用户能看到什么、能做什么。

适合测的

  • 根据props渲染正确的内容。
  • 点击按钮触发正确回调。
  • 表单输入后数据变化。
  • 异步加载显示loading状态。

不适合测的

  • CSS样式(那是视觉回归测试的事,交给视觉测试工具)。
  • 内部state的具体值(优先测渲染结果)。
  • 第三方UI库的行为(假设它没问题)。

工具:React Testing Library + Jest(官方推荐),Vue Test Utils + Vitest。

例子(React Testing Library):

import { render, screen, fireEvent } from '@testing-library/react';
import Counter from './Counter';

test('点击按钮增加计数', () => {
  render(<Counter />);
  const button = screen.getByText('增加');
  fireEvent.click(button);
  expect(screen.getByText('计数: 1')).toBeInTheDocument();
});

原则:测用户能看到的东西,不要测内部实现。

四、E2E测试:测关键用户旅程,不测所有交互

E2E测试模拟真实浏览器,跑完整的用户流程。它最像真实用户,但也最慢、最脆弱(网络波动、页面改动容易挂)。

适合测的(3-5个核心流程):

  • 登录 → 访问个人主页 → 修改头像。
  • 搜索商品 → 加入购物车 → 结算 → 支付成功。
  • 未登录访问受保护页面 → 跳转到登录页。

不适合测的

  • 每个细节(比如每个按钮的悬浮效果)。
  • 容易变化的面包屑导航。
  • 第三方依赖的页面。

工具:Cypress(最友好)、Playwright(更可靠)、Puppeteer(较底层)。

例子(Cypress):

describe('用户登录', () => {
  it('输入正确账号密码后跳转到首页', () => {
    cy.visit('/login');
    cy.get('[data-cy=username]').type('user@example.com');
    cy.get('[data-cy=password]').type('password123');
    cy.get('[data-cy=submit]').click();
    cy.url().should('include', '/dashboard');
    cy.contains('欢迎回来', { timeout: 10000 });
  });
});

维护技巧:给关键元素加上data-cy属性,避免改样式或文本时测试挂掉。

五、测试覆盖率的谎言

很多团队追求100%覆盖率,结果工程师花大量时间测无关紧要的代码(比如测Redux的action creator是个纯对象)。覆盖率工具(Istanbul)只能告诉你“哪些代码没执行过”,不能告诉你“没测到的重要逻辑”。有时100%覆盖率,却漏掉了一个关键的空值判断。

正确的覆盖率指标

  • 核心业务逻辑达到80%以上就行。
  • UI组件覆盖率参考即可,不必强求。
  • 关注未覆盖的重要代码,而不是数字。

六、组合策略:一个电商网站的例子

  • 单元测试:计算折扣、格式化价格、校验表单规则。Jest跑得快,每次提交都跑。
  • 组件测试:商品卡片(渲染正确信息)、购物车弹窗(增加/删除商品)、地址表单(提交按钮禁用直到填写完整)。
  • E2E测试:1. 用户搜索“手机” → 2. 添加第一个商品到购物车 → 3. 登录 → 4. 结算 → 5. 确认订单。就这一个核心流程,保证不崩。

日常开发:单元测试 + 组件测试在CI里跑(每次push)。E2E测试单独流水线,部署前跑一次(因为慢)。

七、测试不是银弹,别为了写而写

  • 重构旧代码,没测试?先别补。补一个挂一个,浪费时间。优先补新功能。
  • 一个bug反复出现,才需要补测试
  • UI改得频繁的区域,不写E2E,写组件测试更稳。

测试是手段,不是目的。目的是信心:当你改完代码,测试全绿,你能放心上线。

八、总结:测试就像买保险

  • 单元测试:车险,便宜,必须买。
  • 组件测试:医疗险,中等,按需买。
  • E2E测试:地震险,贵,只买最关键的。

别买一大堆没用的险,也别裸奔。

我自己写的第一个skills--project-core-standards

背景

用 AI 写代码一段时间后,我发现一个很反直觉的问题:我们其实已经有一些“最佳实践”,但它们无法复用:

  • A 项目调教好的 AI,在 B 项目完全失忆
  • 规则散落在 prompt / 文档 / IDE 配置中,无法版本化
  • 每次新项目,都在重复“驯化 AI”

既然代码可以用 Git 管理、用 NPM 分发,为什么 AI 规范还停留在“复制粘贴”?

本质问题是:我一直把规则当“文本”,而不是“代码”。

把规则当代码看

如果把 AI 规则当作代码,它应该具备三个能力:

  • 可组合(Composable) → 不同规则可以拆分、复用
  • 可分发(Distributable) → 像 npm 包一样安装
  • 可演进(Versioned) → 有版本、有变更记录

否则它就不是工程资产,而只是碎片化经验沉淀。一个规范,如果不能被 install,那它本质上只是不成体系的经验。

Skill 的最小抽象模型

那问题来了:一个“可安装的 AI 规范”,在工程上到底长什么样?

最小结构其实非常简单:

my-skill/
├── SKILL.md
├── rules/
├── package.json

但真正的关键不是结构,而是它解决的问题。


1️⃣ rules目录 让 AI “分块理解”,而不是“整体吞咽”

传统方式是把所有规则写在一个 prompt 里,但这会导致:

上下文污染 + 规则冲突 + AI 记忆漂移

拆分之后:

  • behavior rules:开发行为约束
  • optimization rules:代码质量优化规则

AI 不再“理解一坨规则”,而是按职责加载规则上下文

2️⃣ SKILL.md 让 AI 知道“自己在哪个体系里”

AI 最大的问题不是不会写代码,而是:

不知道当前约束体系是什么

SKILL.md 本质是一个“运行时契约”:

name: project-core-standards
description: 项目的核心代码规范、行为准则与架构要求
version: 1.0.0
author: Admin

它定义的不是规则内容,而是:规则系统的身份边界

3️⃣ package.json 从“规则文件”升级为“能力模块”

一旦进入 npm 体系,规则就发生了本质变化: 从“文档”变成“可安装能力”

真实使用方式:一行命令安装自定义skills

这套自定义的skills最终是这样被使用的:

npx project-core-standards init

执行后,会进入一个交互式初始化流程:

Welcome to Project Core Standards Setup

Please select the IDEs you want to generate rules for:
[1] Cursor (.cursorrules)
[2] Windsurf (.windsurfrules)
[3] Antigravity / Gemini (GEMINI.md)
[4] GitHub Copilot (.github/copilot-instructions.md)
[5] Cline / Roo Code (.clinerules)
[6] Codex (.codexrules)
[A] All of the above

Enter your choices (e.g., 1,3 or A):

这一步的意义非常关键:同一套规则,可以适配所有主流 AI 编程环境**

也就是说:不再是“适配工具”,而是“统一规则源”

最终 Skill 的形态(project-core-standards)

最终,我把这套系统封装成了一个 npm 包: project-core-standards

它的核心结构如下:

---
name: project-core-standards
description: 项目的核心代码规范、行为准则与架构要求。适用于所有需要编写代码、重构或进行代码审查的场景。
version: "1.0.0"
author: "Admin"
---

两个核心规则(真正落地的部分),Skill 的真正价值,不是结构,而是规则本身。

1. Agent 行为与全局开发规范

涵盖核心开发底线:

- commit 规范化(Conventional Commits)
- pnpm 作为唯一包管理方式
- Vue 项目结构约束
- TypeScript 强制类型约束
- 数据库变更必须可追踪
- 组件必须可复用、不可重复造轮子

这个规则解决的是:AI 写代码“失控”的问题

2. 代码简化与优化专家原则

核心目标:保持功能不变的前提下优化代码质量

原则包括:

- 优先简化逻辑,而不是增加抽象
- 删除重复代码,而不是复制模式
- 提升可读性优先于“设计模式正确性”
- 避免过度工程化
- 保持结构一致性

这个规则解决的是: AI 过度设计 / 复杂化代码的问题

真正的难点:无损同步机制

分发不是问题,问题是: 如何更新规则,而不破坏项目已有定制? 这里的设计核心是 Marker:

<!-- BEGIN: project-core-standards -->
<!-- END: project-core-standards -->

同步逻辑:

  • 检测 marker → 精准替换区块
  • 无 marker → 自动安全注入

本质是: 局部 patch,而不是文件 overwrite

工程实现关键点,在 CLI 层:

  • 使用 INIT_CWD 定位真实项目路径
  • install 阶段自动触发同步
  • 基于 AST + regex 做安全替换

核心思想是:把 Git 的 diff 能力,搬进 AI 规则系统**

结语:当规则变成基础设施

引入 project-core-standards 后,开发流程变成:

以前:

新项目 → prompt 调教 → 规则迁移 → 人工同步

现在:

npx init → 自动生成规则体系

当 AI 成为开发流程的一部分,一个新的层级出现了:

  • 应用代码层
  • 工程工具层
  • AI 规则层(Skill)

而 Skill 的意义是: 让 AI 行为本身,变成可工程化管理的资产

未来可能会变成这样:不再“调教 AI”, 而是“安装开发规范”。想了解详细的规则内容可以点击这里查看。

wagmi v2 多链钱包切换:一个 Uniswap 仿盘项目让我踩了三天坑

背景

上个月,我接手了一个"Uniswap 精简版"项目——一个支持 Ethereum、Polygon、Arbitrum 三条链的 DEX 前端。项目用 wagmi v2 + RainbowKit 做钱包连接,React + Vite 开发。需求听起来很简单:用户连接钱包后,能选择任意一条链进行交易,并且钱包会自动切换到对应链。

我当时想,wagmi 不是有 useSwitchChainuseAccount 吗?直接调用就完事了。结果呢?我花了整整三天,经历了无数个"为什么钱包没反应"、"为什么链没切换但页面状态变了"的抓狂时刻。这篇文章,就是把我踩过的坑和最终的解决方案完整记录下来。

问题分析

一开始,我的思路很直接:用 useAccount 获取当前链 ID,用 useSwitchChain 切换链。代码大概长这样:

// 我最初的错误写法
const { chain } = useAccount();
const { switchChain } = useSwitchChain();

const handleChainChange = (targetChainId: number) => {
  if (chain?.id !== targetChainId) {
    switchChain({ chainId: targetChainId });
  }
};

看起来没问题对吧?但实际运行时,问题来了:

问题 1: 在 MetaMask 上切换链后,useAccount 返回的 chain 更新了,但 UI 上的交易对信息没有更新。我明明用了 useEffect 监听 chain 变化,但页面就是不刷新。

问题 2: 切换到一条不支持的链(比如用户自己添加了 BSC)时,useSwitchChain 会报错,但错误信息非常不友好,而且 chain 状态会被污染。

问题 3: 最诡异的是——当用户手动在钱包里切换链,而不是通过我写的按钮切换时,useSwitchChain 根本不会触发,但 useAccountchain 变了。这就导致我的代码里有两套"当前链":一套来自按钮操作,一套来自钱包事件,它们经常不同步。

排查了两天,我翻遍了 wagmi 的文档和 GitHub Issues,终于发现了关键点:wagmi v2 中 useAccountchain 是只读的,它只反映钱包当前连接的链,不会触发 React 组件的重新渲染(至少在特定场景下)。而 useSwitchChain 返回的 isSuccess 状态才是可靠的切换完成标志。

核心实现

1. 重新理解 wagmi v2 的状态管理

我做的第一件事,是抛弃了"用 useAccount 驱动 UI"的思维。wagmi v2 推荐的做法是:useChainId 获取当前链 ID,用 useSwitchChain 处理切换,用 useEffect 监听切换完成事件

这里有个坑:useChainId 返回的是 wagmi 配置中的当前链 ID,而不是钱包实际连接的链 ID。如果用户手动在钱包里切换,useChainId 不会自动更新!所以,我最终决定自己维护一个"同步的链状态"。

我创建了一个自定义 hook useSyncedChain

// hooks/useSyncedChain.ts
import { useChainId, useSwitchChain, useAccount, usePublicClient } from 'wagmi';
import { useEffect, useState, useCallback } from 'react';

export function useSyncedChain() {
  // 从 wagmi 获取基础状态
  const configChainId = useChainId(); // wagmi 配置中的链 ID
  const { chain: accountChain, isConnected } = useAccount(); // 钱包实际连接的链
  const { switchChain, isPending, error } = useSwitchChain();
  const publicClient = usePublicClient(); // 用来做链验证

  // 我们自己的"权威"链 ID
  const [activeChainId, setActiveChainId] = useState<number>(configChainId);

  // 核心逻辑:同步钱包状态和配置状态
  useEffect(() => {
    if (!isConnected || !accountChain) {
      // 未连接时,使用配置默认链
      setActiveChainId(configChainId);
      return;
    }

    // 如果钱包连接的链和配置链不同,说明用户手动切换了
    if (accountChain.id !== configChainId) {
      // 这里有个坑:不要直接 setActiveChainId,因为配置链可能不支持
      // 应该检查 accountChain 是否在我们支持的链列表中
      const supportedChains = [1, 137, 42161]; // Ethereum, Polygon, Arbitrum
      if (supportedChains.includes(accountChain.id)) {
        setActiveChainId(accountChain.id);
      } else {
        // 不支持的话,尝试切回默认链
        switchChain({ chainId: configChainId });
      }
    } else {
      setActiveChainId(configChainId);
    }
  }, [configChainId, accountChain, isConnected, switchChain]);

  // 封装的切换函数
  const switchToChain = useCallback(async (targetChainId: number) => {
    try {
      await switchChain({ chainId: targetChainId });
      // switchChain 成功后,wagmi 会自动更新 configChainId
      // 但为了保险,我们手动更新
      setActiveChainId(targetChainId);
    } catch (err) {
      console.error('切换链失败:', err);
      throw err;
    }
  }, [switchChain]);

  return {
    activeChainId,
    switchToChain,
    isSwitching: isPending,
    error,
  };
}

这个 hook 的核心思路是:不要信任任何一个单一来源,而是用钱包状态、配置状态、用户操作事件三者做交叉验证

2. 处理链切换后的数据刷新

链切换后,我们需要重新获取交易对数据、用户余额等。一开始我用 useEffect 监听 activeChainId,但发现会触发两次:一次是状态更新,一次是钱包实际切换完成。

后来我用了 wagmi 的 useWatchChainId 来做精细控制:

// hooks/useChainDataRefresh.ts
import { useEffect, useRef } from 'react';
import { useChainId } from 'wagmi';

export function useChainDataRefresh(callback: (chainId: number) => void) {
  const chainId = useChainId();
  const prevChainIdRef = useRef(chainId);

  useEffect(() => {
    // 只在链真正变化时触发,避免初始化时的重复调用
    if (prevChainIdRef.current !== chainId) {
      console.log(`链已切换: ${prevChainIdRef.current} -> ${chainId}`);
      callback(chainId);
      prevChainIdRef.current = chainId;
    }
  }, [chainId, callback]);
}

然后在组件中使用:

// 在 Swap 组件中
const { activeChainId, switchToChain, isSwitching } = useSyncedChain();
const { data: pairData, refetch: refetchPair } = useQuery({
  queryKey: ['pair', activeChainId, tokenA, tokenB],
  queryFn: () => fetchPairData(activeChainId, tokenA, tokenB),
  enabled: !!activeChainId && !!tokenA && !!tokenB,
});

useChainDataRefresh((newChainId) => {
  // 链切换后,重新获取数据
  refetchPair();
  // 同时重置用户输入状态
  setTokenA('');
  setTokenB('');
});

3. 处理钱包手动切换和 UI 同步

最头疼的是用户手动在 MetaMask 里切换链。wagmi v2 的 useAccount 会更新,但 useChainId 不会。我之前的 useSyncedChain hook 已经通过 accountChain 处理了这种情况,但还有一个细节:切换完成后,需要等待钱包确认,期间 UI 应该显示加载状态

我添加了一个"切换中"的状态管理:

// 在 useSyncedChain 中增加 pendingChainId
const [pendingChainId, setPendingChainId] = useState<number | null>(null);

const switchToChain = useCallback(async (targetChainId: number) => {
  setPendingChainId(targetChainId);
  try {
    await switchChain({ chainId: targetChainId });
    setPendingChainId(null);
    setActiveChainId(targetChainId);
  } catch (err) {
    setPendingChainId(null);
    throw err;
  }
}, [switchChain]);

// 在 UI 中显示加载
const isLoading = isSwitching || pendingChainId !== null;

4. 最终的多链切换组件

把所有逻辑整合到一个组件中:

// components/ChainSwitcher.tsx
import { useSyncedChain } from '../hooks/useSyncedChain';
import { useChainDataRefresh } from '../hooks/useChainDataRefresh';
import { useQuery } from '@tanstack/react-query';
import { useEffect } from 'react';

const SUPPORTED_CHAINS = [
  { id: 1, name: 'Ethereum', nativeCurrency: 'ETH' },
  { id: 137, name: 'Polygon', nativeCurrency: 'MATIC' },
  { id: 42161, name: 'Arbitrum', nativeCurrency: 'ETH' },
];

export function ChainSwitcher() {
  const { activeChainId, switchToChain, isSwitching, error } = useSyncedChain();

  // 链切换后刷新数据
  useChainDataRefresh((chainId) => {
    console.log('链已切换,刷新数据');
    // 这里可以触发其他数据获取
  });

  const handleChainClick = async (chainId: number) => {
    if (chainId === activeChainId) return;
    try {
      await switchToChain(chainId);
      // 切换成功后,UI 会自动更新,因为 activeChainId 变了
    } catch (err) {
      // 显示错误 toast
      alert(`切换失败: ${(err as Error).message}`);
    }
  };

  return (
    <div>
      <h2>选择链</h2>
      {SUPPORTED_CHAINS.map((chain) => (
        <button
          key={chain.id}
          onClick={() => handleChainClick(chain.id)}
          disabled={isSwitching}
          style={{
            fontWeight: chain.id === activeChainId ? 'bold' : 'normal',
            opacity: isSwitching ? 0.5 : 1,
          }}
        >
          {chain.name} ({chain.nativeCurrency})
          {isSwitching && ' 切换中...'}
        </button>
      ))}
      {error && <p style={{ color: 'red' }}>错误: {error.message}</p>}
    </div>
  );
}

完整代码

我把所有代码整合到一个可运行的示例中。假设你使用 Vite + React + TypeScript,安装依赖:

npm install wagmi viem @tanstack/react-query react
// main.tsx - 入口文件
import { WagmiProvider, createConfig, http } from 'wagmi';
import { mainnet, polygon, arbitrum } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RainbowKitProvider, getDefaultConfig } from '@rainbow-me/rainbowkit';
import { ChainSwitcher } from './components/ChainSwitcher';

const config = createConfig({
  chains: [mainnet, polygon, arbitrum],
  transports: {
    [mainnet.id]: http(),
    [polygon.id]: http(),
    [arbitrum.id]: http(),
  },
});

const queryClient = new QueryClient();

function App() {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          <ChainSwitcher />
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}

export default App;
// hooks/useSyncedChain.ts - 上面已给出完整代码
// hooks/useChainDataRefresh.ts - 上面已给出完整代码
// components/ChainSwitcher.tsx - 上面已给出完整代码

踩坑记录

坑 1:useAccountchain 在切换后不会立即更新 现象:调用 switchChain 后,useAccount 返回的 chain 还是旧的,导致 UI 显示错误。解决:用 useChainId 配合 useEffect 监听,而不是依赖 useAccountchain

坑 2:useSwitchChainisSuccess 有时为 false 现象:钱包已经切换成功,但 isSuccess 一直是 false。原因:wagmi v2 中 isSuccess 只在第一次成功时为 true,后续切换不会重置。解决:用 errorisPending 做判断,或者自己维护状态。

坑 3:在非浏览器环境(如测试时)调用 switchChain 会报错 现象:在 Node.js 或 React Native 中,window.ethereum 不存在,导致切换失败。解决:用 try-catch 包裹,并在错误时回退到配置默认链。

坑 4:链切换后,之前订阅的事件没有清理 现象:切换到 Polygon 后,Ethereum 上的事件监听还在运行,导致内存泄漏。解决:在 useEffect 中返回清理函数,或者用 wagmi 的 watchContractEvent 自动管理。

小结

多链切换的核心不是调用 switchChain,而是同步钱包状态、配置状态和用户操作状态。wagmi v2 提供了基础工具,但需要自己组合成可靠的解决方案。如果你也遇到类似问题,可以试试我写的 useSyncedChain hook,或者深入看看 wagmi 的源码——里面有很多有趣的细节。

接下来,你可以探索如何用 wagmi 的 watchChainId 做更精细的控制,或者结合 viem 的 publicClient 做链验证。

在线PDF拆分工具核心JS实现

这篇只讲本项目里“PDF拆分”工具的功能层 JavaScript 实现。主流程可以概括为:

选择 PDF -> 读取页数 -> 生成拆分页组 -> 复制指定页面 -> 生成多个 PDF -> 单文件下载或 ZIP 打包下载

工具基于 Vue 组织交互状态,核心 PDF 操作使用 pdf-lib,多文件结果打包使用 JSZip,页面预览和书签读取由 pdfjs-dist 辅助完成。

在线工具网址:see-tool.com/pdf-split
工具截图:
工具截图.png

1. 文件进入流程前先做 PDF 判断

文件选择和拖拽上传共用同一套入口。真正加载前,先判断文件类型:

export function isPdfSplitFile(file) {
  if (!file) {
    return false;
  }

  var fileType = String(file.type || "").toLowerCase();
  var fileName = String(file.name || "");
  return fileType === "application/pdf" || /\.pdf$/i.test(fileName);
}

这里同时判断 MIME 和文件后缀,是因为部分浏览器环境下 file.type 可能为空,只依赖 MIME 会误拦正常 PDF。

加载文件时,会把同一份原始字节切成两份用途:

var rawBytes = await file.arrayBuffer();
var splitBytes = rawBytes.slice(0);
var previewBytes = rawBytes.slice(0);
var sourceDoc = await PDFDocument.load(splitBytes);

splitBytes 用于后续拆分,previewBytes 用于预览和书签读取。这样拆分主链路和辅助信息链路互不影响。

2. 页码输入解析成统一的拆分页组

拆分逻辑不是直接处理输入框字符串,而是先转成统一结构:

{
  label: "1-3",
  indices: [0, 1, 2]
}

label 用于文件命名,indicespdf-lib 需要的零基页码数组。

页码范围解析支持逗号分隔,也支持倒序区间:

function buildPageIndices(start, end) {
  var indices = [];
  var page;

  if (start <= end) {
    for (page = start; page <= end; page += 1) {
      indices.push(page - 1);
    }
    return indices;
  }

  for (page = start; page >= end; page -= 1) {
    indices.push(page - 1);
  }

  return indices;
}

所以用户输入 1-3,5,8-6 时,会生成三个输出段:第 1 到 3 页、第 5 页、第 8 到 6 页。

3. 多种拆分模式最终都归一到 groups

工具支持按页码范围、每 N 页、每页单独、奇偶页、可视化选择、书签、平均拆成 N 份。虽然入口不同,但最终都会变成 groups

buildSplitGroups: function () {
  if (this.splitMode === "ranges") {
    return parsePdfSplitRangeGroups(this.rangeInput, this.totalPages);
  }

  if (this.splitMode === "everyN") {
    return buildPdfSplitCountGroups(
      this.totalPages,
      parsePdfSplitPositiveInt(this.everyNInput),
    );
  }

  if (this.splitMode === "everyPage") {
    return buildPdfSplitEveryPageGroups(this.totalPages);
  }

  if (this.splitMode === "evenOdd") {
    return buildPdfSplitEvenOddGroups(this.totalPages, this.evenOddMode);
  }

  if (this.splitMode === "visual") {
    return buildPdfSplitVisualGroups(this.selectedPages);
  }

  if (this.splitMode === "bookmarks") {
    return buildPdfSplitBookmarkGroups(this.bookmarkItems, this.totalPages);
  }

  if (this.splitMode === "nTimes") {
    return buildPdfSplitNPartsGroups(
      this.totalPages,
      parsePdfSplitPositiveInt(this.nTimesInput),
    );
  }

  return [];
}

这个设计的好处是,真正拆分 PDF 时不关心用户选择了哪种模式,只消费统一的页码分组。

4. 可视化选择会自动合并连续页

可视化模式下,用户点选的是离散页码。工具会先排序、去重,再把连续页合并成一个输出段:

export function buildPdfSplitVisualGroups(selectedPages) {
  var uniquePages = Array.isArray(selectedPages)
    ? selectedPages
        .map(function (page) {
          return Number(page);
        })
        .filter(function (page) {
          return Number.isInteger(page) && page > 0;
        })
        .sort(function (left, right) {
          return left - right;
        })
        .filter(function (page, index, source) {
          return index === 0 || page !== source[index - 1];
        })
    : [];

  if (!uniquePages.length) {
    throw createPdfSplitInputError("emptySelection");
  }

  var groups = [];
  var start = uniquePages[0];
  var end = uniquePages[0];

  for (var i = 1; i < uniquePages.length; i += 1) {
    if (uniquePages[i] === end + 1) {
      end = uniquePages[i];
      continue;
    }

    pushMergedSelectionGroup(groups, start, end);
    start = uniquePages[i];
    end = uniquePages[i];
  }

  pushMergedSelectionGroup(groups, start, end);
  return groups;
}

比如选择 1、2、3、7、9、10,结果会拆成 1-379-10 三个文件。

5. 书签拆分按顶层书签生成区间

书签模式先读取 PDF 的 outline,再把书签所在页转换成拆分区间。核心逻辑是:当前书签页作为开始页,下一个书签前一页作为结束页。

export function buildPdfSplitBookmarkGroups(bookmarks, totalPages) {
  var normalizedBookmarks = Array.isArray(bookmarks)
    ? bookmarks
        .filter(function (item) {
          return (
            item &&
            Number.isInteger(Number(item.pageNumber)) &&
            Number(item.pageNumber) >= 1 &&
            Number(item.pageNumber) <= totalPages
          );
        })
        .map(function (item) {
          return {
            title: String(item.title || "").trim() || "bookmark",
            pageNumber: Number(item.pageNumber),
          };
        })
        .sort(function (left, right) {
          return left.pageNumber - right.pageNumber;
        })
    : [];

  var groups = [];

  if (normalizedBookmarks[0].pageNumber > 1) {
    groups.push({
      label: "preface",
      indices: buildPageIndices(1, normalizedBookmarks[0].pageNumber - 1),
      title: "preface",
    });
  }

  for (var index = 0; index < normalizedBookmarks.length; index += 1) {
    var current = normalizedBookmarks[index];
    var next = normalizedBookmarks[index + 1];
    var start = current.pageNumber;
    var end = next ? next.pageNumber - 1 : totalPages;

    groups.push({
      label: current.title,
      indices: buildPageIndices(start, end),
      title: current.title,
    });
  }

  return groups;
}

如果第一个书签不在第一页,前面的内容会单独生成一个 preface 分段。

6. 真正拆分 PDF 的核心是 copyPages

拆分主函数先构建 groups,然后每个分组创建一个新的 PDF:

for (index = 0; index < groups.length; index += 1) {
  var group = groups[index];
  var outputDoc = await PDFDocument.create();
  var copiedPages = await outputDoc.copyPages(
    this.sourceDoc,
    group.indices,
  );

  copiedPages.forEach(function (page) {
    outputDoc.addPage(page);
  });

  var outputBytes = await outputDoc.save();
  var outputBlob = new Blob([outputBytes], {
    type: "application/pdf",
  });

  nextOutputs.push({
    name: this.buildOutputName(group, index, groups.length),
    blob: outputBlob,
    size: outputBlob.size,
  });
}

这里不是修改原 PDF,也不是切割二进制文件,而是把源文档里的指定页面复制到一个新文档。group.indices 决定当前输出文件包含哪些页。

7. 输出文件名根据拆分模式生成

文件名会先清理原 PDF 名称,再结合模式和页码标签生成:

export function buildPdfSplitOutputName(options) {
  var config = options || {};
  var baseName = safePdfSplitBaseName(config.baseName);
  var index = Number(config.index) || 0;
  var total = Number(config.total) || 0;
  var label = String(config.label || "");
  var mode = String(config.mode || "ranges");
  var sequence = String(index + 1).padStart(3, "0");
  var safeLabel = sanitizePdfSplitFileLabel(label) || sequence;

  if (mode === "everyPage") {
    return baseName + "_page_" + safeLabel + ".pdf";
  }

  if (mode === "bookmarks") {
    return baseName + "_" + sequence + "_" + safeLabel + ".pdf";
  }

  if (total === 1) {
    return baseName + "_split.pdf";
  }

  return baseName + "_split_" + sequence + "_p" + safeLabel + ".pdf";
}

这样拆出多个文件时,用户能从文件名看出顺序和页码范围。

8. 单结果直接下载,多结果打包 ZIP

导出时先判断结果数量。只有一个 PDF 时直接下载;多个 PDF 时放进 ZIP:

downloadResult: async function () {
  if (!this.outputs.length) {
    return;
  }

  if (this.outputs.length === 1) {
    this.downloadOutput(this.outputs[0]);
    return;
  }

  var zip = new JSZip();
  this.outputs.forEach(function (item) {
    zip.file(item.name, item.blob);
  });

  var zipBlob = await zip.generateAsync({
    type: "blob",
    compression: "DEFLATE",
    compressionOptions: {
      level: 6,
    },
  });

  this.downloadBlob(zipBlob, "split_result.zip");
}

浏览器下载统一通过 Blob 和临时 a 标签完成:

downloadBlob: function (blob, filename) {
  var url = URL.createObjectURL(blob);
  var link = document.createElement("a");

  link.href = url;
  link.download = filename;
  document.body.appendChild(link);
  link.click();
  document.body.removeChild(link);
  URL.revokeObjectURL(url);
}

整个 PDF 拆分功能的核心,就是把不同输入方式都转换成稳定的页码分组,再用 pdf-lib 复制页面生成新文档,最后根据结果数量决定直接下载还是打包下载。

最快的 JavaScript navmesh pathfinding3d 算法。

最快的 JavaScript 三维寻路库:pathfinding3d

好久不见,今天为大家带来的是,JavaScript目前最快的三维寻路库:pathfinding3d。性能是目前three-pathfindingthree-pathfinding-3d)的10-20倍 它不是仅限 Three.js 的插件,而是通用的 WASM 三维寻路引擎。只要你的 JavaScript 三维引擎能提供网格顶点与索引数据,就可以用本库构建导航区域、查询分组并搜索路径。

github:MrYang614/pathfinding3d: the fast navigation mesh pathfinding for 3d world. use rust and compile to wasm. supper for all 3d engine like:three.js babylonjs,playcanvas

特点

  • 极高性能:核心寻路管线由 Rust + WebAssembly 实现,性能约为 three-pathfinding-3d 的 10-20 倍量级。
  • 引擎无关:不限于 Three.js,可与 Babylon.js、PlayCanvas、Cesium、自研 WebGL/WebGPU 引擎及任意 JavaScript 三维场景配合使用。
  • 面向 3D NavMesh 流程:由三角网格数据创建区域,再通过分组、节点、A* 与漏斗通道生成平滑路径。
  • JavaScript 开销低:路径结果写入预分配的 Float32Array,减少对象分配与 GC 压力。
  • 前后端通用:通过 wasm-pack 打包,适用于 Web、Electron、Node.js 等 JavaScript 环境。

适用场景

  • 大型三维场景中的角色导航
  • Web 游戏、数字孪生、仿真、编辑器与可视化项目
  • 需要可复用寻路、又不想绑定 Three.js 的多引擎项目
  • 寻路查询需要比 three-pathfinding-3d 更快的项目

pathfinding3d 寻路算法与实现概览

本文从性能特征底层模块实现三维引擎兼容性三方面,说明 pathfinding3d 中 NavMesh 式三维寻路的算法结构与工程取舍。实现语言为 Rust,通过 WebAssembly 暴露给 JavaScript/TypeScript。


1. 整体管线

库采用经典的 导航网格(NavMesh) 工作流:将可走区域表示为三角面片及其邻接图,在图上做 A* 搜索得到多边形序列,再用 漏斗算法(String Pulling / Funnel) 把多边形通道拉直为空间折线。

flowchart LR
  subgraph build [构建阶段]
    Mesh[顶点 positions + 索引 indices]
    Weld[容差焊接顶点]
    Tri[三角面与邻接/Portal]
    Group[连通分量分组]
    Idx[GroupData + KD 树 + AABB]
    Mesh --> Weld --> Tri --> Group --> Idx
  end
  subgraph query [查询阶段]
    Pos[世界坐标]
    KD1[KD 最近邻 + 可选多边形判定]
    Astar[A* 在分组内图上搜索]
    Portals[Portal 序列]
    Funnel[3D 漏斗拉直]
    Out[Float32Array 路径点]
    Pos --> KD1 --> Astar --> Portals --> Funnel --> Out
  end
  Idx --> query

同一 Zone 内可能包含多个 Group(互不相连的三角面子图);get_group 决定点落在哪一组,find_path 仅在给定 group_id 内寻路。


2. 算法性能:为何快、快在哪里

2.1 执行环境与语言层

  • Rust + WASM:热点路径(网格构建、空间查询、A*、漏斗)在原生机器码或 WASM 中执行,避免纯 JavaScript 解释执行与频繁装箱的开销。项目 README 中与 three-pathfinding-3d 的对比属于同量级场景下的经验性描述;具体倍数随网格规模、图密度与硬件变化,应以实际基准测试为准。
  • 数值类型:内部大量使用 f64glam::DVec3)做几何与搜索,与 JS 侧 number 精度衔接自然;输出写入 Float32Array 时再做 f32 截断,减小返回路径的内存与带宽。

2.2 内存与垃圾回收

  • 预分配与复用:每个分组预先分配 AstarScratch(开放表、closed、g/h、父指针、touched 用于增量清空)与 PathScratch(Portal 缓冲、路径点、flat_points)。单次查询主要在这些缓冲上读写,避免每次 find_path 在堆上大量分配小对象。
  • A* 的 reset:通过 touched 只恢复本次搜索访问过的节点,在图较大但搜索范围局部时,比整表 memset 更省。
  • 启发式缓存h_score + h_seen 对每个节点到终点的欧氏距离只算一次,重复入堆时复用。

2.3 查询复杂度(定性)

环节 典型结构 说明
最近三角形 / 分组 三维 KD 树 + AABB 剪枝 最近邻平均接近 (O(\log n)),predicate 会过滤无效候选
A* 二叉堆 + 邻接表 与展开节点数相关;边数约为三角网格邻接规模
漏斗 线性于 Portal 数量 对每个 Portal 常数次方向与交点判断

3. 底层模块与具体实现

以下按源码模块对应说明(路径相对于仓库根目录 src/)。

3.1 builder.rs:从原始网格到 Zone

  1. 校验positions 长度为 3 的倍数,indices 为三角形索引三元组,且索引不越界。
  2. 容差焊接:用 tolerance 将顶点量化到整数格点 (x/tol, y/tol, z/tol),合并近似重合顶点,得到压缩后的 verticesremapped_indices
  3. 三角形对象:每个三角形记录 vertex_indices质心 centerneighboursportals(共享边上的两个顶点索引)。
  4. 邻接与 Portal:遍历每条无向边 HashMap<(min,max), tri_idx>,若同一条边被两个三角形使用则 bind_neighbour,双向记录邻接三角形 id 与共享边的顶点对。
  5. 分组(Group):对 group_id == -1 的三角形做 BFS(VecDeque)扩散,将连通分量标为 0..G-1,再按 group_id 聚合成 ZoneInput.groups

3.2 impls.rs:分组内图结构 GroupData

  • PolygonInput.id(构建时的三角形序号)映射到分组内的紧凑下标 id_to_index
  • neighbours_by_index 存储 NeighborLink { index, portal },供 A* 枚举邻居与后续取 相邻三角形之间的 Portal 边

3.3 pathfinding.rs:运行时索引与查询编排

  • GroupSpatialData:每个分组维护全体三角形 AABB、每个三角形的 AABB、以三角形 质心 为点集的 KD 树(项为分组内下标)。
  • 全局 node_tree:所有分组的 (group_idx, node_idx) 挂在同一棵 KD 树上,用于跨分组挑选最近三角形(如 get_group)。
  • compute_group
    • check_polygon == true:在最大距离平方阈值内,KD 搜索 + AABB + 到三角形平面距离 + 点是否在三角形内math 模块)。
    • 否则:最近质心 + 分组整体 AABB 约束。
  • get_closest_node_index:分组内 KD + AABB 距离剪枝;可选 is_vector_in_polygon(带 y 方向条带 + 三角形内测试)判定是否落在当前三角形上。
  • compute_path_points
    1. 起点、终点各求最近三角形下标;
    2. astar_search 得到中间三角形序列;
    3. 构造 Portal3 列表:起点到第一段若有合法 Portal 则加入;相邻三角形间用 portal_between_indices 取共享边两端世界坐标;最后以目标点闭合通道;
    4. judge_dir(叉积的 y 分量)统一 Portal 左右顺序;
    5. funnel3d_into 生成平滑折线到 path_scratch.points
    6. write_path_to_output 跳过第一个点(起点),将后续点写入调用方 Float32Array

3.4 astar.rs:分组内 A*

  • 状态BinaryHeapf = g + h 最大堆反转实现最小 fHeapNodef 相等时用 idx 打破平局,保证次序稳定。
  • 代价g 的增量为当前与邻居三角形 质心间距离平方之和。
  • 启发式h 为邻居三角形质心到 终点三角形质心欧氏距离(非平方),并对每个节点缓存。
  • 路径回溯parent 链从终点回到起点,再 reverse 得到从起点侧到终点侧的三角形序列(注意与 path 向量填充顺序一致)。

3.5 channel.rs:三维漏斗与辅助插点

  • funnel3d_into:在 Portal 序列上维护 apex、左右边界点与索引,用 judge_dir 判断“右转/左转”约束;必要时 insert 在一段 Portal 间按 线段最近点 在边上插值(distance_sq_segment_to_segment),并在 xz 平面上算交点参数 segment_fraction_xz,再 lerp 出三维点,减少拐角处路径贴边生硬的问题。
  • 退化:无 Portal 时路径退化为起点—终点直线。

3.6 math.rs:几何原语

  • 三角形内点:三边叉积的 y 分量 同号(与水平面 NavMesh 假设一致:可走面大致水平或判定主要依赖 xz 投影与 y 条带)。
  • is_vector_in_polygon:先限制查询点 y 在三角形 y 范围加 ±0.5,再调用 is_point_in_triangle
  • point_to_plane_distance:点到三角形所在平面的有符号距离,用于分组时“落在面上”的数值容差(如 0.01)。
  • distance_sq_segment_to_segment:两线段最近距离的平方及最近点,供漏斗 insert 使用。

3.7 kdtree.rs:三维 KD 树

  • 构建:按深度循环维度 x → y → z,对当前点集按该维排序,取中位点建节点,递归左右子树。
  • nearest_matching:标准 KD 最近邻遍历,维护 best_distance;仅在 distance < best_distance谓词为真时更新最优;利用 delta² < best_distance 决定是否搜索远侧子树。

3.8 utils.rs / lib.rs

  • Panic hook:改善 WASM 中 panic 的可读性(便于调试)。
  • 对外类型lib.rspub use pathfinding::PathfindingWasm,JS 通过 wasm-bindgen 调用。

4. 三维引擎兼容性

本库 不依赖任何渲染引擎对象(无 THREE.Mesh、无场景图),只要求调用方提供:

  • positions[x,y,z, ...]f32 扁平数组(与 WebGL 属性布局一致即可)。
  • indices:三角形索引 u32,每三个一组。

因此只要引擎能导出或拼接 世界空间 下的顶点与索引(Three.js、Babylon.js、PlayCanvas、Cesium、自研 WebGL/WebGPU 等),即可使用;坐标系与单位由数据决定,库内部不做左手/右手或 Y-up/Z-up 的强制转换。

集成时注意:

  1. 可走网格质量:非流形、重复面、过大容差会影响焊接与邻接;Disconnected 区域会落入不同 group_id,跨组需业务层处理(如传送或桥接网格)。
  2. “地面”假设judge_dir 与部分点在三角形内判定依赖 y 轴 与水平投影习惯;若可走面为任意朝向的陡坡,需在业务上评估是否适用或是否应预处理网格。
  3. 输出约定find_path 返回的点数对应 output 中写入的三元组个数;若缓冲区不足,返回值表示所需长度(见 README API 说明),需调用方扩容后重试。

5. 小结

pathfinding3dNavMesh 构建(焊接、邻接、连通分组)KD 树空间查询质心图上 A*带 Portal 的三维漏斗拉直 集中在 Rust/WASM 中,并通过 复用搜索缓冲区Float32Array 直写 控制 JS 侧开销,从而在浏览器与 Node 中提供通用的三维寻路后端;与具体三维引擎的耦合点仅有 网格顶点与索引的序列化格式


不止有 Agent:Cursor 进阶使用技巧全解析

不止有 Agent:Cursor 进阶使用技巧全解析

你是否也和我一样,最初被 Cursor 的 Agent 模式惊艳到,感觉拥有了一个不知疲倦的编程助手?但用了一段时间后,可能会陷入一个瓶颈:除了打开 Cmd+I 让 Agent 写代码,似乎挖掘不出它更多的潜力了。

其实,Agent 只是 Cursor 强大能力的冰山一角。当你把它从一个“代码生成器”视为一个“AI 开发团队”时,才能真正释放它的生产力。今天,我们就来深入挖掘 Cursor 那些被低估的进阶技巧,让你从“会用”到“精通”。

一、模式选择:Ask、Agent 与 Plan 的正确打开方式

很多效率问题,根源在于用错了模式。Cursor 提供了三种核心交互模式,理解它们的定位是高效使用的第一步。

Ask 模式:你的技术顾问
当你面对一个陌生的代码库,或者需要探索技术方案时,Ask 模式是你的最佳选择。它的核心价值在于探索、学习和理解,而不是直接修改代码。

  • 典型场景

    1. 接手新项目,询问:“这个项目的技术栈和目录结构是怎样的?”
    2. 遇到复杂逻辑,追问:“@file src/utils/auth.ts 中的 validateUser 函数是如何工作的?”
    3. 技术选型,咨询:“为项目添加国际化支持,next-intlreact-i18next 哪个更合适?”

黄金法则:在不确定的情况下,永远先用 Ask 模式探索,明确方案后再切换到 Agent 执行。宁可多问几轮,也别让 Agent 盲目修改代码。

Agent 模式:你的执行工程师
当你有了明确的目标和方案后,就该 Agent 上场了。它能理解你的意图,自主搜索代码、修改文件、执行命令,直到完成任务。

  • 高效提示词原则

    • 说“做什么”,而非“怎么做”

      • ✅ 好的例子:“在登录页面把错误提示改成更友好的文案。”
      • ❌ 不好的例子:“改一下登录页面。”
    • 提供可验证的目标:例如,要求 Agent 遵循项目中已有的测试模式来编写新的测试用例,这给了它一个客观的成功标准。

Plan 模式:你的架构师
面对涉及多个文件、有数据库变更或核心逻辑修改的复杂需求时,直接丢给 Agent 风险极高。Plan 模式的价值在于“磨刀不误砍柴工”。

  • 工作流程

    1. 你提出一个宏大需求,例如:“为电商项目添加完整的购物车功能。”
    2. Plan 模式会生成一个详细的实施计划,包括需要创建/修改的文件、数据库 Schema 变更、API 设计、潜在风险点等。
    3. 你作为架构师,审查并修改这个计划。
    4. 计划确认后,再让 Agent 按步骤执行。

何时必须用 Plan?

  • 影响文件超过 10 个。
  • 涉及数据库 Schema 变更。
  • 修改核心业务逻辑,风险级别高。

二、上下文管理:让 AI 看到“对”的代码

AI 输出质量的高低,很大程度上取决于你喂给它什么上下文。塞太多无关信息会稀释它的注意力,导致输出“牛头不对马嘴”。Cursor 的 @ 引用体系就是为了解决这个问题。

  • @文件名:精确注入单个文件内容。当你需要修改或分析特定文件时,这是最直接的方式。
  • @文件夹名:注入整个目录的结构信息,适合让 AI 分析某个模块的整体情况。
  • @codebase:触发语义搜索,让 Agent 自己在整个项目中寻找相关代码。当你不确定代码在哪时,用它来探索。
  • @doc:引入已索引的第三方文档,例如 React 或 Next.js 的官方文档,让 AI 的回答更权威。
  • @git:引用 Git 历史或 diff,方便进行代码审查或追溯变更。

使用建议:遵循“先精确,后宽泛”的原则。知道文件名就直接 @文件名,不确定时再用 @codebase

三、Rules:固化你的项目规范

你是否厌倦了每次开新会话都要跟 AI 重复解释项目规范?“我们用 Tailwind,别用 styled-components”、“API 统一放 src/api/ 目录”……

Rules 功能可以将你的编码规范、架构决策固化为 AI 的“持久记忆”。配置一次,永久生效。

  • Project Rules:存储在 .cursor/rules/ 目录下,与项目代码一起提交到 Git,团队成员共享。
  • User Rules:个人全局设置,适用于所有项目,比如你的代码风格偏好。

最佳实践

  • 当发现 Agent 反复犯同一个错误时,就是创建一条新规则的最佳时机。
  • 规则要具体可执行,像清晰的内部文档。
  • 每条规则保持在 500 行以内,过于复杂就拆分。

四、Cloud Agents 与 Automations:打造 24/7 的自动化团队

这是 Cursor 最具颠覆性的能力之一,将 AI 从“实时交互”解放为“后台自动化”。

Cloud Agents:你的后台任务执行者
对于耗时较长、不需要实时干预的任务,可以交给 Cloud Agent。它会在独立的云端沙盒环境中执行,完成后通过 Pull Request 的形式交付成果。

  • 适用场景

    • 为现有代码生成测试用例。
    • 修复定义明确的 Bug。
    • 编写未文档化模块的文档。

你可以从 Cursor 的网页界面、Slack、Linear 甚至 GitHub Issue 的评论中触发 Cloud Agent,然后安心地去处理其他工作,回来验收即可。

Automations:事件驱动的自动化流程
如果说 Cloud Agent 是你手动触发的,那么 Automations 就是为 AI 配置了“触发器”,满足条件就自动运行。

  • 触发方式

    • 定时触发:例如,每天凌晨 2 点自动检查依赖更新。
    • GitHub 事件:例如,当有新的 PR 打开时,自动运行代码风格检查。
    • Slack 事件:例如,当某个频道出现包含特定关键词的消息时,自动创建任务。

五、Bugbot:你的 AI 代码审查官

还在等待同事进行 Code Review?Bugbot 可以作为全自动的后台守卫,在你推送 PR 后自动运行,在代码行内直接留下评论。

  • 它能帮你发现

    • 逻辑错误与空指针风险。
    • 潜在的安全隐患。
    • 缺失的错误处理。
    • Race condition 等并发问题。

你还可以在项目的 .cursor/BUGBOT.md 文件中配置项目特有的检查规则,例如“所有 API 入参必须经过 zod 校验”,让审查标准与团队规范保持一致。

结语

Cursor 已经远远超越了一个简单的 AI 代码编辑器。通过灵活运用 Ask、Agent、Plan 三种模式,精准管理上下文,用 Rules 固化规范,并借助 Cloud Agents、Automations 和 Bugbot 实现自动化,你实际上是在指挥一个分工明确、7x24 小时待命的 AI 开发团队。

希望这些技巧能帮助你打破使用瓶颈,将开发效率提升到新的层次。

Mobile 端 AI 请求真机调试:从"线上没日志"到四层问题定位

同一个 Mobile 项目,expo start --web 跑得好好的,真机扫码后 AI 对话一直转圈,Vercel 线上日志一条都没有。请求根本没到服务端,但原因远不止"网络不通"这么简单。这篇文章从一次真机调试讲起,把 Vercel 路由冲突、Edge Runtime 识别、SSE 平台分流、环境变量管理、国内网络限制五个层面的问题一次性讲清楚。

1. 开篇:Web 能用,真机不行

我的项目是一个 AI 原生的类 Notion 应用,Web 端和 Mobile 端共享同一套 AI 请求逻辑。某天我在真机上测试 Mobile 端的 AI 对话功能,发送消息后一直转圈,最终走到 onError 回调。

切到 Web 端(expo start --web),同样的代码、同样的 AI 服务地址,一切正常。

更诡异的是——Vercel 线上日志里一条请求记录都没有。请求像凭空消失了一样。

"线上没日志"意味着两种可能:请求根本没到服务端,或者请求到了但没进入业务代码。 这个判断成了后续排查的分水岭。

2. 前景提要:项目的 AI 请求架构

在讲问题之前,先交代一下项目的 AI 请求链路,因为后面的每个问题都和这个架构有关。

2.1 Monorepo 结构

My-Notion/
├── apps/
│   ├── web/              # Next.js Web 应用
│   └── mobile/           # Expo React Native 应用
├── packages/
│   ├── ai/               # AI 核心逻辑(共享)
│   ├── business/         # 业务状态(共享)
│   └── convex/           # 数据库逻辑(共享)
└── services/
    └── ai/               # AI 网关(独立部署到 Vercel)
        ├── api/
        │   ├── chat.ts   # /api/chat 入口
        │   └── [[...route]].js  # catch-all 路由
        └── src/
            └── index.ts  # Hono 主应用

2.2 AI 请求链路

Mobile App
  └─ fetch("https://my-notion-ai.vercel.app/api/chat")
       └─ Vercel (services/ai)
            └─ DashScope (阿里云 AI 服务)

Mobile 端直接请求 services/ai 部署在 Vercel 上的 API,不经过 Web 端的 Next.js。这是因为 Expo React Native 不走 Next.js 的 API Route,需要独立的 AI 服务入口。

2.3 SSE 流式响应

AI 对话使用 SSE(Server-Sent Events)实现流式输出。但 React Native 对 ReadableStream 的支持不完整,需要按平台分流:

if (Platform.OS === "web") {
  // Web 端:ReadableStream 逐块读取,实现真正的流式
  const reader = response.body?.getReader();
  // ...
} else {
  // Native 端:response.text() 一次性读取
  const text = await response.text();
  processSSEBuffer(text + "\n", callbacks);
}

Web 端能实时看到 AI 逐字输出,Native 端则是等 DashScope 完全响应后一次性显示——不流式,但能用。

3. 第一层:Vercel 路由冲突——请求到了,但进不了业务代码

3.1 发现问题

services/ai/api/ 目录下有两个文件:

  • api/chat.ts — Hono 格式,声明了 export const runtime = "edge"export default app
  • api/[[...route]].js — Serverless catch-all,内容是:
const { handle } = require("@hono/node-server/vercel");
const app = require("../dist/services/ai/src/index.js").default;
module.exports = handle(app);

Vercel 的路由解析规则是:catch-all [[...route]] 会匹配所有 /api/* 请求,包括 /api/chat

这意味着,即使 chat.ts 声明了 export const runtime = "edge",Vercel 也不会把它当作独立的 Edge Function——因为 [[...route]].js 已经接管了 /api/chat 这个路由。

3.2 为什么 Web 端不受影响

Web 端有自己的 Next.js Route Handler 处理 /api/chat,根本不走 services/ai 的 Vercel 部署。所以 Web 端从来没触发过这个路由冲突。

3.3 catch-all 的问题

[[...route]].js 是 Node.js Serverless 函数,它 require("../dist/services/ai/src/index.js")。而 src/index.ts 使用了:

import "dotenv/config";
import { randomUUID } from "crypto";

这些是 Node.js 专用模块。在 Serverless Runtime 中:

  • 如果 dist/ 没有正确构建,require 直接失败 → 请求 500/502
  • 即使 dist/ 存在,Serverless 函数到 DashScope 国内节点的网络不稳定,可能超时

无论哪种情况,请求都不会进入 chat.ts 的业务代码,所以 Vercel 日志里看不到你的业务日志。

3.4 修复:删除 catch-all,改为原生 Edge Function

删除 api/[[...route]].js,让 api/chat.ts 作为独立 Edge Function 被 Vercel 识别。

同时将 api/chat.ts 从 Hono 格式改为 Vercel 原生 Edge Function 格式:

// 之前:Hono 格式
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
const app = new Hono().basePath("/api");
app.post("/chat", async (c) => { ... });
export default app;

// 之后:Vercel 原生 Edge Function
export const runtime = "edge";
export async function POST(request: Request): Promise<Response> { ... }

关键区别:

Hono 格式 Vercel 原生格式
入口 export default app export async function POST
Runtime 识别 可能被 catch-all 劫持 Vercel 直接识别为 Edge Function
SSE 输出 streamSSE() (Hono API) new ReadableStream() (Web 标准)
CORS app.use("*", cors()) 手动处理 OPTIONS + 响应头

3.5 SSE 输出格式的变化

Hono 的 streamSSE 输出格式:

event: content
data: {"type":"content","text":"..."}

原生 ReadableStream 手动编码的格式:

event: content
data: {"type":"content","text":"..."}

格式完全一致——客户端的 processSSEBufferdata: 前缀解析,忽略 event: 行,解析 JSON 里的 type 字段来分发。客户端代码无需任何修改。

4. 第二层:环境变量管理——本地开发走线上还是走本地

4.1 问题

检查 .env 发现:

EXPO_PUBLIC_AI_SERVICE_URL=https://my-notion-ai.vercel.app

本地开发时 AI 请求直接打到 Vercel 线上服务,而不是本地 services/ai 源码。如果改了 AI 逻辑想验证,必须先推代码等 Vercel 部署——开发效率极低。

4.2 Expo 环境变量优先级

Expo 遵循 .env.local > .env.production > .env 的优先级。之前踩过的坑:

  • .env.local 覆盖 .env,导致本地开发走线上地址
  • .env.production--no-dev 模式下覆盖 .env
  • localhost 在真机上指向手机自身,必须用局域网 IP

4.3 修复:启动命令区分本地和线上

.env 保持线上域名作为默认值,通过启动命令行内覆盖为本地地址:

{
  "scripts": {
    "dev": "expo start",
    "dev:local": "EXPO_PUBLIC_AI_SERVICE_URL=http://localhost:3001 expo start",
    "dev:all": "concurrently \"pnpm run dev\" \"pnpm run dev:convex\"",
    "dev:all:local": "concurrently \"pnpm run dev:local\" \"pnpm run dev:convex\""
  }
}

Expo 的优先级是 process.env(行内设置)> .env 文件,所以 dev:local 的行内变量会覆盖 .env 的值。

命令 AI 地址 场景
pnpm dev https://my-notion-ai.vercel.app(读 .env) 默认走线上
pnpm dev:local http://localhost:3001(行内覆盖) 走本地 AI 源码

真机调试时把 localhost:3001 换成局域网 IP 即可。

4.4 EAS Build 的环境变量

.env.gitignore 中,EAS 云端构建时无法读取。EXPO_PUBLIC_ 变量必须在 eas.jsonenv 字段中显式声明:

{
  "build": {
    "preview": {
      "env": {
        "EXPO_PUBLIC_AI_SERVICE_URL": "https://my-notion-ai.vercel.app"
      }
    },
    "production": {
      "env": {
        "EXPO_PUBLIC_AI_SERVICE_URL": "https://my-notion-ai.vercel.app"
      }
    }
  }
}

5. 第三层:国内网络限制——.vercel.app 域名被拦截

5.1 真相大白

路由冲突修复后,重新部署 services/ai 到 Vercel,真机测试——还是不行。

仔细一想:我的手机没开代理

.vercel.app 域名在国内被 DNS 污染/网关拦截,请求根本出不去。这就是为什么 Vercel 线上日志一条都没有——请求从手机发出后,在网络层就被拦截了,根本没到 Vercel。

Web 端没问题是因为电脑开了代理。

5.2 这个问题的本质

这不是代码问题,而是基础设施问题。在国内使用 Vercel 部署的服务,移动端用户大概率会遇到:

  • .vercel.app 域名被 DNS 污染,解析失败
  • 即使解析成功,HTTPS 连接也可能被网关重置
  • 表现为 fetch 超时或 Network request failed,没有任何服务端日志

5.3 长期方案

方案 复杂度 效果
services/ai 绑自定义域名 + Cloudflare CDN 完全解决
eas.json 中指向国内可达的代理地址 部分解决
自建国内服务器部署 AI 服务 完全解决

当前阶段,开发测试时开手机代理即可。后续上线需要绑定自定义域名。

6. 第四层:SSE 平台分流——Web 和 Native 的 ReadableStream 差异

6.1 问题

React Native 对 ReadableStream 的支持不完整。Web 端 response.body.getReader() 正常工作,但 Native 端可能导致 SSE 流读取卡住,AI 请求一直转圈。

6.2 修复:按平台分流

async function parseSSEStream(
  response: Response,
  callbacks: StreamCallbacks,
): Promise<void> {
  if (Platform.OS === "web") {
    await parseSSEStreamWeb(response, callbacks);   // ReadableStream 逐块读取
  } else {
    await parseSSEStreamNative(response, callbacks); // response.text() 一次性读取
  }
}

Web 端保持流式体验,Native 端牺牲流式效果换取稳定性。等 React Native 对 ReadableStream 的支持完善后,可以统一为流式方案。

6.3 Native 端 SSE 解析的注意事项

response.text() 会等整个响应完成后才返回。这意味着:

  • Native 端 AI 请求会一直等到 DashScope 完全响应后才一次性显示
  • 用户看到的是"转圈 → 突然出现完整回复",而不是"逐字输出"
  • 如果 DashScope 响应时间较长,用户可能以为请求卡死了

这是当前方案的已知限制,后续可以通过引入 react-native-sse 等第三方库实现原生端的流式体验。

7. tsconfig 的隐藏坑:WebWorker lib

7.1 问题

api/chat.ts 改为 Vercel 原生 Edge Function 格式后,使用了 request.json()new Response() 等 Web 标准 API。但 tsconfig.jsonlib 只有 ["ES2022"],缺少 "WebWorker"

TypeScript 不认识 Edge 环境下的 RequestResponsecrypto.randomUUID() 等全局类型,编译报错。

7.2 修复

{
  "compilerOptions": {
    "lib": ["ES2022", "WebWorker"]
  },
  "include": [
    "api/**/*",
    "src/**/*",
    ...
  ]
}

WebWorker lib 提供了 Edge Runtime 环境下的类型定义。同时 include 中加入 "api/**/*",确保 api/chat.ts 被 TypeScript 编译器覆盖。

7.3 为什么之前没报错

之前 api/chat.ts 使用 Hono 格式,c.req.json() 是 Hono 的方法,类型由 Hono 自己提供。改成原生 request.json() 后,类型来源从 Hono 切换到了 Web 标准 API,才触发了这个问题。

8. vercel.json 的配套修改

8.1 之前

{
  "version": 2,
  "buildCommand": "pnpm build",
  "functions": {
    "api/[[...route]].js": {
      "memory": 1024,
      "maxDuration": 60
    }
  }
}

functions 配置的是已删除的 [[...route]].js,Edge Function 不需要在这里声明。

8.2 之后

{
  "buildCommand": "pnpm build"
}

Edge Function 由 Vercel 自动识别(通过 export const runtime = "edge" 声明),不需要在 vercel.json 中额外配置。

9. 完整改动清单

文件 改动 解决的问题
services/ai/api/chat.ts Hono 格式 → Vercel 原生 Edge Function 路由冲突 + Runtime 识别
services/ai/api/[[...route]].js 删除 消除 catch-all 路由劫持
services/ai/vercel.json 移除 Serverless 函数配置 配套 catch-all 删除
services/ai/tsconfig.json WebWorker lib + api include Edge 环境类型定义
apps/mobile/package.json dev:local / dev:all:local 命令 本地开发走本地 AI
apps/mobile/.env AI 地址保持线上域名 默认走线上,本地开发用命令覆盖

10. 排查方法论总结

这次调试涉及四个层面的问题,每个层面的排查思路不同:

层面 现象 排查方法 根因类型
路由层 线上无业务日志 检查 Vercel 路由文件是否冲突 架构设计
环境变量 本地开发走线上 检查 .env 优先级和实际值 配置管理
网络层 请求超时/无响应 确认客户端网络环境(代理/DNS) 基础设施
运行时 SSE 解析卡住 检查平台 API 兼容性 平台差异

关键经验:

  1. "线上没日志"不等于"请求没到服务端" — 也可能是请求到了但被错误的路由/函数吞掉了
  2. Web 能用不代表 Native 能用ReadableStream、CORS、网络环境都有平台差异
  3. 环境变量优先级是隐式规则.env.local 覆盖 .env 这种行为,不看文档根本想不到
  4. Vercel 的路由解析有优先级 — catch-all 会劫持具体路由,即使你声明了 export const runtime = "edge"
  5. 国内 + Vercel = 必须考虑网络可达性.vercel.app 域名在国内不可达是基础设施问题,不是代码 Bug

11. Edge Runtime vs Serverless Runtime

这次调试反复涉及 Vercel 的两种运行时,最后做一个对比:

Edge Runtime Serverless Runtime
运行环境 V8 isolate(类似 Cloudflare Workers) Node.js(AWS Lambda)
冷启动 < 1ms 数百 ms 到数秒
最大执行时间 30s(免费)/ 60s(Pro) 10s(默认)/ 60s(Pro)/ 300s(Enterprise)
网络稳定性 边缘节点,全球分布 集中式,受区域网络影响
Node.js API 不支持(无 fs、crypto 等) 完整支持
适合场景 AI 流式响应、API 代理、短请求 长耗时任务、需要 Node.js API 的场景

AI 对话场景选择 Edge Runtime 的原因:

  • DashScope 国内节点到 Vercel Serverless(AWS)的网络出口不稳定,偶发 10-20s 超时
  • Edge Runtime 的边缘节点(如 hkg1 香港)到国内网络更稳定
  • SSE 流式响应需要长连接,Edge Runtime 的冷启动更快

但 RAG 相关路由因为依赖 convex@langchain(使用 Node.js API),仍需保留在 Serverless Runtime。


本文基于 My-Notion 项目的真实调试经历撰写——一个 AI 原生的个人版 Notion,采用 pnpm workspace Monorepo 架构,Web + Mobile 双端。欢迎 Star ⭐

两种方法:暴力 / KMP(Python/Java/C++/Go)

方法一:暴力

例如 $s=\texttt{abcde}$,旋转一次变成 $\texttt{bcdea}$,再旋转一次变成 $\texttt{cdeab}$。旋转后的字符串是 $s$ 的后缀加上 $s$ 的前缀,例如 $\texttt{cdeab} = \texttt{cde} + \texttt{ab}$。于是构造 $s+s = \texttt{abcdeabcde}$,这个字符串的任意长为 $n$ 的子串,都是 $s$ 的后缀加上 $s$ 的前缀。所以无论 $s$ 如何旋转,旋转后的字符串一定是 $s+s$ 的子串。

于是问题等价于:

  • 判断 $s+s$ 是否包含 $\textit{goal}$。

注意题目没有保证 $s$ 和 $\textit{goal}$ 长度相等,如果不等,直接返回 $\texttt{false}$。

class Solution:
    def rotateString(self, s: str, goal: str) -> bool:
        return len(s) == len(goal) and goal in s + s
class Solution {
    public boolean rotateString(String s, String goal) {
        return s.length() == goal.length() && (s + s).contains(goal);
    }
}
class Solution {
public:
    bool rotateString(string s, string goal) {
        return s.size() == goal.size() && (s + s).contains(goal);
    }
};
func rotateString(s, goal string) bool {
return len(s) == len(goal) && strings.Contains(s+s, goal)
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n^2)$,其中 $n$ 是 $s$ 的长度。
  • 空间复杂度:$\mathcal{O}(1)$。

方法二:字符串匹配

用 KMP、Z 函数、字符串哈希等算法,都可以 $\mathcal{O}(n)$ 判断 $s+s$ 是否包含 $\textit{goal}$。

下面用的 KMP 算法,原理见 KMP 算法讲解

# 下面是 KMP 模板。对于本题来说,找到一个就可以返回了。为方便大家使用,我保留了完整的模板。
# 在文本串 text 中查找模式串 pattern,返回所有成功匹配的位置(pattern[0] 在 text 中的下标)
def kmp_search(text: str, pattern: str) -> List[int]:
    m = len(pattern)
    pi = [0] * m
    cnt = 0
    for i in range(1, m):
        b = pattern[i]
        while cnt and pattern[cnt] != b:
            cnt = pi[cnt - 1]
        if pattern[cnt] == b:
            cnt += 1
        pi[i] = cnt

    pos = []
    cnt = 0
    for i, b in enumerate(text):
        while cnt and pattern[cnt] != b:
            cnt = pi[cnt - 1]
        if pattern[cnt] == b:
            cnt += 1
        if cnt == len(pattern):
            pos.append(i - m + 1)
            cnt = pi[cnt - 1]
    return pos

class Solution:
    def rotateString(self, s: str, goal: str) -> bool:
        return len(s) == len(goal) and len(kmp_search(s + s, goal)) > 0
class Solution {
    public boolean rotateString(String s, String goal) {
        return s.length() == goal.length() && 
               !kmpSearch((s + s).toCharArray(), goal.toCharArray()).isEmpty();
    }

    // 下面是 KMP 模板。对于本题来说,找到一个就可以返回了。为方便大家使用,我保留了完整的模板。
    // 在文本串 text 中查找模式串 pattern,返回所有成功匹配的位置(pattern[0] 在 text 中的下标)
    private List<Integer> kmpSearch(char[] text, char[] pattern) {
        int m = pattern.length;
        int[] pi = new int[m];
        int cnt = 0;
        for (int i = 1; i < m; i++) {
            char b = pattern[i];
            while (cnt > 0 && pattern[cnt] != b) {
                cnt = pi[cnt - 1];
            }
            if (pattern[cnt] == b) {
                cnt++;
            }
            pi[i] = cnt;
        }

        List<Integer> pos = new ArrayList<>();
        cnt = 0;
        for (int i = 0; i < text.length; i++) {
            char b = text[i];
            while (cnt > 0 && pattern[cnt] != b) {
                cnt = pi[cnt - 1];
            }
            if (pattern[cnt] == b) {
                cnt++;
            }
            if (cnt == m) {
                pos.add(i - m + 1);
                cnt = pi[cnt - 1];
            }
        }
        return pos;
    }
}
class Solution {
    // 下面是 KMP 模板。对于本题来说,找到一个就可以返回了。为方便大家使用,我保留了完整的模板。
    // 在文本串 text 中查找模式串 pattern,返回所有成功匹配的位置(pattern[0] 在 text 中的下标)
    vector<int> kmp_search(const string& text, const string& pattern) {
        int m = pattern.size();
        vector<int> pi(m);
        int cnt = 0;
        for (int i = 1; i < m; i++) {
            char b = pattern[i];
            while (cnt && pattern[cnt] != b) {
                cnt = pi[cnt - 1];
            }
            if (pattern[cnt] == b) {
                cnt++;
            }
            pi[i] = cnt;
        }
    
        vector<int> pos;
        cnt = 0;
        for (int i = 0; i < text.size(); i++) {
            char b = text[i];
            while (cnt && pattern[cnt] != b) {
                cnt = pi[cnt - 1];
            }
            if (pattern[cnt] == b) {
                cnt++;
            }
            if (cnt == m) {
                pos.push_back(i - m + 1);
                cnt = pi[cnt - 1];
            }
        }
        return pos;
    }

public:
    bool rotateString(string s, string goal) {
        return s.size() == goal.size() && !kmp_search(s + s, goal).empty();
    }
};
// 下面是 KMP 模板。对于本题来说,找到一个就可以返回了。为方便大家使用,我保留了完整的模板。
// 在文本串 text 中查找模式串 pattern,返回所有成功匹配的位置(pattern[0] 在 text 中的下标)
func kmpSearch(text, pattern string) (pos []int) {
m := len(pattern)
pi := make([]int, m)
cnt := 0
for i := 1; i < m; i++ {
b := pattern[i]
for cnt > 0 && pattern[cnt] != b {
cnt = pi[cnt-1]
}
if pattern[cnt] == b {
cnt++
}
pi[i] = cnt
}

cnt = 0
for i, b := range text {
for cnt > 0 && pattern[cnt] != byte(b) {
cnt = pi[cnt-1]
}
if pattern[cnt] == byte(b) {
cnt++
}
if cnt == m {
pos = append(pos, i-m+1)
cnt = pi[cnt-1]
}
}
return
}

func rotateString(s, goal string) bool {
return len(s) == len(goal) && kmpSearch(s+s, goal) != nil
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n)$,其中 $n$ 是 $s$ 的长度。
  • 空间复杂度:$\mathcal{O}(n)$。

专题训练

见下面字符串题单的「一、KMP」。

分类题单

如何科学刷题?

  1. 滑动窗口与双指针(定长/不定长/单序列/双序列/三指针/分组循环)
  2. 二分算法(二分答案/最小化最大值/最大化最小值/第K小)
  3. 单调栈(基础/矩形面积/贡献法/最小字典序)
  4. 网格图(DFS/BFS/综合应用)
  5. 位运算(基础/性质/拆位/试填/恒等式/思维)
  6. 图论算法(DFS/BFS/拓扑排序/基环树/最短路/最小生成树/网络流)
  7. 动态规划(入门/背包/划分/状态机/区间/状压/数位/数据结构优化/树形/博弈/概率期望)
  8. 常用数据结构(前缀和/差分/栈/队列/堆/字典树/并查集/树状数组/线段树)
  9. 数学算法(数论/组合/概率期望/博弈/计算几何/随机算法)
  10. 贪心与思维(基本贪心策略/反悔/区间/字典序/数学/思维/脑筋急转弯/构造)
  11. 链表、树与回溯(前后指针/快慢指针/DFS/BFS/直径/LCA)
  12. 字符串(KMP/Z函数/Manacher/字符串哈希/AC自动机/后缀数组/子序列自动机)

我的题解精选(已分类)

欢迎关注 B站@灵茶山艾府

Neo 构建鸿蒙应用【一】:架构困境与四层结构化设计

Neo 构建鸿蒙应用【一】:架构困境与四层结构化设计

Neo 框架连载 · AI 辅助撰写

在 AI 编程工具快速普及的今天,产品的试错成本大幅降低——把 IDEA 尽可能快地做出来才是最重要的,人工打磨细节和文章的 ROI 已经不高了。本系列文章均为人指导、AI 生成的内容,核心思路和设计决策来自人的判断,AI 负责快速落地。

软件工程不止编码

在鸿蒙应用开发过程中,工作内容远不止写代码。需求评审、三方 SDK 对接(鸿蒙化进度不受控时需要兜底方案)、功能测试、自动化测试、稳定性测试、CI/CD 部署……这些都是日常。

这些环节的质量,很大程度上取决于系统的初始设计。

平铺开发的典型症状

一般来说,组织沟通方式会通过系统设计表达出来——康威定律。基于 Spring 的微服务是特例,技术栈自带局部架构。但客户端不一样,尤其是鸿蒙客户端——技术栈太新,没有"自带架构"的框架可用。

没有明确设计的系统,功能基本是平铺开发,整体结构不超过三层:

  • 层层耦合,处处不内聚 — 页面里直接写网络请求,网络回调里直接操作 UI
  • 看似面向对象,实则面向过程 — 定义了 class,但方法之间是线性调用关系,没有职责划分
  • 补丁叠补丁 — 新需求来了,再加一层 if-else,再补一个回调
  • 霰弹式修改 — 一个业务变更要改七八个文件,每个文件改一两行

项目像一个穿着"百衲衣"的大胖子,某处破裂贴上胶布继续凑合用。按照 Martin Fowler 在《企业应用架构模式》中的观点:随着领域逻辑复杂度的提升,领域建模程度较低的项目,增加的工作量是近似指数级的。

三个客观现实:

  1. 紧迫的任务与受限的预算 — 中小团队没有时间做"理想"的重构
  2. 开发团队较低的组织程度和建模意识 — 没有框架约束,每人按自己的理解写代码
  3. 增加的工作量越来越不受控 — 前两者叠加的必然结果

前些年很火的 DDD(领域驱动建模),在中小团队中培训成本高到不可能落地。但我认为最适合领域建模的软件产品是客户端而非服务器——客户端所有代码跑在同一个进程里,没有网络边界作为天然屏障,一个烂模块会影响整个应用。

两种约束

面对以上问题,我提出两种约束——不是"最佳实践",而是划定底线

  • 约束一:相对完整的结构化设计,不能有过高的理解成本
  • 约束二:功能模块的规范定义,编排层次性的启动顺序

这两种约束的具体落地就是 Neo 框架。下面展开约束一。

四层结构化架构

┌─────────────────────────────────────┐
│            entry(应用入口)          │
│    页面入口 / 路由 / 一多方案适配      │
├─────────────────────────────────────┤
│           features(功能页面层)       │
│    只处理页面交互,不处理数据逻辑       │
├─────────────────────────────────────┤
│         domains(领域建模层)          │
│    数据获取、业务逻辑、跨功能服务       │
├─────────────────────────────────────┤
│           infra(基础设施层)          │
│    无状态,可迁移,三方 SDK            │
└─────────────────────────────────────┘

entry — 应用入口

功能聚集层。入口页面、路由、一多方案适配,既是所有页面和功能的门面,也是构建的集合。按照项目实际情况可以选择一多方案适配或多端独立方式适配。

features — 功能模块页面层

通过领域建模获取数据,自身只处理页面交互逻辑,不处理服务器、硬盘的数据。上层页面是相对抽象的,聚合内部功能的;下层负责具体功能。

domains — 领域建模层

这里并不一定要使用领域驱动建模的概念。具体业务领域是容易区分的,但公共能力很容易渗透到全局。上层的责任是编排各个领域,而非公共能力。

例如用户鉴权,很容易被拆分成用户数据结构在最下层、登录逻辑在上层。而更合理的情况是:上层有自己的值对象,登录鉴权的逻辑和数据结构内聚,完成这个过程后通知全局,自上而下分发状态。

infra — 基础设施层

最简单的理解就是当做二方和三方,尽可能按照可迁移到其他项目的思路设计。重点考虑无状态和可迁移——不是真的要迁移,而是最干净的基础设施是完全无状态的。

状态指的是由使用、登录获取的数据及其传递依赖。例如从个人登录信息 → 登录会议 SDK → 处理会议数据,这里就是状态的传递。无状态的模块不可以主动获取状态,需要数据应由调用方传入。

层级边界渗透

企业的最初和最终的目的是盈利,项目最初和最终的定位是工具。设计原则不管是 SOLID 还是七原则,都是局部"术"的层面,而真实的世界是混沌的。各层之间的设计都应考虑层级边界渗透的情况。

entry ↔ 下层

页面是相对抽象的,聚合内部功能的,主要是整体页面框架、路由、一多适配。与下层的边界较好区分。

features ↔ 下层

features → domains:页面逻辑还是业务逻辑?通常的经验是页面操作驱动业务逻辑,业务数据驱动页面逻辑。例如网络通话场景,通话状态的转移是 SDK 数据的变化,页面也会根据这个数据变化。但页面不存在通话就不存在了吗?现在的大部分通话场景都已支持悬浮窗,通话的数据要独立在自己的领域建模中。

features → infra:具体功能页面还是组件?某个组件是否需要复用,复用即在下层。

domains ↔ infra

渗透的重灾区。在没有建模的项目中,事实上的基础模块很多都是带业务状态的。这种很难改——改完容易出错,出错容易背锅,写的越多越错。逻辑的编排需要分清是通用逻辑还是业务逻辑,这部分可以适当用一些设计模式。

Neo 的四层在代码中的样子

以 Neo 的 SoulApp 示例为例:

entry/src/main/ets/
├── pages/                    # features — 页面交互
│   ├── IndexPage.ets         #   首页
│   ├── ChatPage.ets          #   聊天
│   ├── ExplorePage.ets       #   发现
│   └── ...
├── services/                 # domains — 领域服务
│   ├── business/             #   核心业务 (12个)
│   │   ├── AuthService       #   认证
│   │   ├── ChatService       #   聊天
│   │   └── ...
│   ├── feature/              #   功能服务 (5个)
│   └── lazy/                 #   非关键服务 (2个)
├── services/infra/           # infra — 基础设施 (8个)
│   ├── NetworkService
│   ├── DatabaseService
│   ├── CacheService
│   └── ...
├── modules/                  # entry — 模块注册
│   └── AppModule.ets         #   所有 Service 的编排入口
└── data/                     # 跨层数据模型
    ├── Models.ets
    └── MockData.ets

下一篇将展开约束二:Service、NeoModule、ServiceManager 和 Phase 如何实现模块化服务编排与渐进式启动。


系列文章

LeetCode 97. 交错字符串:动态规划详解

在LeetCode中等难度题目中,「交错字符串」是一道经典的动态规划应用题。它的核心是判断一个字符串是否能由另外两个字符串“交错”组成,看似简单却容易陷入思维误区,今天我们就来一步步拆解这道题,从题目理解到代码实现,把每一个细节讲透。

一、题目核心理解

先明确题目要求:给定三个字符串s1、s2、s3,验证s3是否是由s1和s2交错组成的。这里的「交错」有严格定义,我们用通俗的话翻译一下:

  • 将s1分割成若干个非空子串(比如s1 = a + b + c),s2分割成若干个非空子串(比如s2 = x + y);

  • 分割后两个字符串的子串数量相差不超过1(|n - m| ≤ 1);

  • 把这两组子串交替拼接,要么是「s1子串在前、s2子串在后」(a+x + b+y + c),要么是「s2子串在前、s1子串在后」(x+a + y+b),最终拼接结果等于s3。

举个例子:s1 = "aabcc",s2 = "dbbca",s3 = "aadbbcbcac",就是一个合法的交错组合——s1分割为"aa"+"bc"+"c",s2分割为"dbbc"+"a",交替拼接后得到"aa"+"dbbc"+"bc"+"a"+"c",正好等于s3。

而如果s3的长度不等于s1+s2的长度,那直接可以判定为false,这是最基础的边界条件。

二、解题思路:为什么用动态规划?

拿到这道题,首先会想到暴力解法——枚举s1和s2的所有分割方式,再判断拼接后是否等于s3。但这种方法的时间复杂度极高,因为分割方式的数量是指数级的,对于稍长的字符串完全不适用。

这时候就需要动态规划(DP)来优化。动态规划的核心是「状态定义+状态转移」,我们可以通过定义一个DP数组,记录“用s1的前i个字符和s2的前j个字符,能否组成s3的前i+j个字符”,这样就能把大问题拆解成小问题,逐步递推求解。

三、动态规划细节拆解

1. 状态定义

定义dp[i][j]:表示s1的前i个字符(s1[0..i-1])和s2的前j个字符(s2[0..j-1]),能否交错组成s3的前i+j个字符(s3[0..i+j-1])。

这里要注意下标细节:i和j从0开始,当i=0时,意味着不使用s1的任何字符,只使用s2的前j个字符;当j=0时,意味着不使用s2的任何字符,只使用s1的前i个字符。

2. 初始化

初始化的核心是处理“只使用s1”或“只使用s2”的情况:

  • dp[0][0] = true:s1的前0个字符(空字符串)和s2的前0个字符(空字符串),能组成s3的前0个字符(空字符串),这是基础条件。

  • 当i>0、j=0时:dp[i][0] = dp[i-1][0] && s1[i-1] === s3[i-1]。也就是说,只有前i-1个字符能组成s3的前i-1个字符,且s1的第i个字符(s1[i-1])等于s3的第i个字符(s3[i-1]),才能满足条件。

  • 当j>0、i=0时:类似上面,dp[0][j] = dp[0][j-1] && s2[j-1] === s3[j-1]。

3. 状态转移方程

对于任意i>0、j>0的情况,dp[i][j]的取值有两种可能,只要满足其中一种,就为true:

  1. 最后一个字符来自s1:此时需要满足「s1的前i-1个字符和s2的前j个字符能组成s3的前i+j-1个字符」(即dp[i-1][j]为true),并且s1的第i个字符(s1[i-1])等于s3的第i+j个字符(s3[i+j-1])。

  2. 最后一个字符来自s2:此时需要满足「s1的前i个字符和s2的前j-1个字符能组成s3的前i+j-1个字符」(即dp[i][j-1]为true),并且s2的第j个字符(s2[j-1])等于s3的第i+j个字符(s3[i+j-1])。

因此,状态转移方程可以写成:

dp[i][j] = (dp[i-1][j] && s1[i-1] === s3[i+j-1]) || (dp[i][j-1] && s2[j-1] === s3[i+j-1])

4. 最终结果

dp[s1.length][s2.length] 就是我们要的答案——表示s1的全部字符和s2的全部字符,能否交错组成s3的全部字符。

四、完整代码及逐行解读

下面是完整的TypeScript代码,结合上面的思路,逐行解读每一步的作用:

function isInterleave(s1: string, s2: string, s3: string): boolean {
  // 1. 边界条件:s3长度不等于s1+s2长度,直接返回false
  const l1: number = s1.length;
  const l2: number = s2.length;
  const l3: number = s3.length;
  if (l1 + l2 != l3) {
    return false;
  }

  // 2. 初始化DP数组:dp[i][j]表示s1前i个、s2前j个能否组成s3前i+j个
  const dp: boolean[][] = Array.from({ length: l1 + 1 }, () => new Array(l2 + 1).fill(false))
  dp[0][0] = true; // 空字符组成空字符,基础条件

  // 3. 填充DP数组:双重循环遍历所有i和j的组合
  for (let i = 0; i <= l1; i++) {
    for (let j = 0; j <= l2; j++) {
      const p = i + j - 1; // s3当前对应的下标(前i+j个字符的最后一个下标)
      // 情况1:最后一个字符来自s1(i>0才有可能)
      if (i > 0) {
        dp[i][j] = (dp[i - 1][j] && s1[i - 1] === s3[p]) || dp[i][j];
      }
      // 情况2:最后一个字符来自s2(j>0才有可能)
      if (j > 0) {
        dp[i][j] = (dp[i][j - 1] && s2[j - 1] === s3[p]) || dp[i][j];
      }
    }
  }

  // 4. 返回最终结果:s1全部和s2全部能否组成s3全部
  return dp[l1][l2];
};

五、关键注意点&优化方向

1. 下标细节(最容易踩坑)

一定要注意:s1的第i个字符对应的下标是i-1,s2的第j个字符对应的下标是j-1,s3的前i+j个字符的最后一个下标是i+j-1(即变量p)。很多人会在这里混淆下标,导致代码出错。

2. 空间优化

上面的代码使用了二维DP数组,空间复杂度是O(l1*l2)。但观察状态转移方程可以发现,dp[i][j]只依赖于dp[i-1][j](上一行)和dp[i][j-1](同一行前一列),因此可以优化为一维DP数组,将空间复杂度降低到O(min(l1, l2))。

优化思路:用一个一维数组dp[j],每次遍历i时,更新dp[j]的值,具体可以自行尝试(提示:遍历i时,dp[j]的更新需要注意顺序,避免覆盖未使用的值)。

3. 特殊测试用例

提交代码前,建议测试以下几个特殊用例,避免边界漏洞:

  • s1、s2均为空:s3也为空 → 返回true;s3非空 → 返回false。

  • 其中一个字符串为空:比如s1为空,判断s2是否等于s3;反之亦然。

  • 字符重复场景:比如s1="aabcc",s2="dbbca",s3="aadbbbaccc" → 返回false(最后一个c的来源不匹配)。

六、总结

「交错字符串」的核心是用动态规划将“分割拼接”的复杂问题,转化为“逐步判断字符匹配”的子问题。关键在于正确定义DP状态,理清状态转移的两种情况,同时注意下标细节。

这道题的DP思路具有通用性,类似“两个字符串拼接成第三个字符串”的问题,都可以尝试用类似的状态定义来解决。掌握了这道题,也能加深对动态规划“递推思想”的理解。

【宫水三叶】简单模拟题

模拟

由于每次旋转操作都是将最左侧字符移动到最右侧,因此如果 goal 可由 s 经过多步旋转而来,那么 goal 必然会出现在 s + s 中,即满足 (s + s).contains(goal),同时为了 s 本身过长导致的结果成立,我们需要先确保两字符串长度相等。

代码:

###Java

class Solution {
    public boolean rotateString(String s, String goal) {
        return s.length() == goal.length() && (s + s).contains(goal);
    }
}
  • 时间复杂度:$O(n)$
  • 空间复杂度:$O(n)$

关于 contains 操作的复杂度说明

看到不少同学对 contains 的复杂度写成 $O(n)$ 有疑问。

在 Java 中,contains 实际最终是通过 indexOf(char[] source, int sourceOffset, int sourceCount, char[] target, int targetOffset, int targetCount, int fromIndex) 来拿到子串在原串的下标,通过判断下标是否为 $-1$ 来得知子串是否在原串出现过。

我们知道一个较为普通的子串匹配算法的复杂度通为 $O(n*k)$,其中 $n$ 和 $k$ 分别是原串和子串的长度,而一些复杂度上较为优秀的算法可以做到 $O(n + k)$,例如 KMP

从复杂度上来看 KMP 似乎要更好,但实际上对于 indexOf 这一高频操作而言,KMP 的预处理逻辑和空间开销都是不可接受的。

因此在 OpenJDK 中的 indexOf 源码中,你看不到诸如 KMP 这一类「最坏情况下仍为线性复杂度」的算法实现。

但是 contains 的复杂度真的就是 $O(n * k)$ 吗?

其实并不是,这取决于 JVM 是否有针对 indexOf 的优化,在最为流行 HotSpot VM 中,就有对 indexOf 的优化。

使用以下两行命令执行 Main.java,会得到不同的用时。

###Java

// Main.java
import java.util.*;
class Main {
    static String ss = "1900216589537958049456207450268985232242852754963049829410964867980510717200606495004259179775210762723370289106970649635773837906542900276476226929871813370344374628795427969854262816333971458418647697497933767559786473164055741512717436542961770628985635269208255141092673831132865";
    static String pp = "830411595466023844647269831101019568881117264597716557501027220546437084223034983361631430958163646150071031688420479928498493050624766427709034028819288384316713084883575266906600102801186671777455503932259958027055697399984336592981698127456301551509241";
    static int cnt = (int) 1e8;
    static public void main(String[] args) {
        long start = System.currentTimeMillis();
        while (cnt-- > 0) ss.contains(pp);
        System.out.println(System.currentTimeMillis() - start);
    }
}

环境说明:

###Shell

➜  java -version
java version "1.8.0_131"
Java(TM) SE Runtime Environment (build 1.8.0_131-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.131-b11, mixed mode)

先执行 javac Main.java 进行编译后:

  1. 使用原始的 indexOf 实现进行匹配(执行多次,平均耗时为基准值 $X$):
java -XX:+UnlockDiagnosticVMOptions -XX:DisableIntrinsic=_indexOf Main
  1. 使用 HotSpot VM 优化的 indexOf 进行匹配(执行多次,平均耗时为基准值 $X$ 的 $[0.55, 0.65]$ 之间):
java Main

因此实际运行的 contains 操作的复杂度为多少并不好确定,但可以确定是要优于 $O(n * k)$ 的。


加练

今日份加餐 :【面试高频题】难度 2/5,结合二分的序列 DP 运用题 🍭🍭🍭🍭

或是考虑加练如下「模拟」题 🍭🍭🍭

题目 题解 难度 推荐指数
6. Z 字形变换 LeetCode 题解链接 中等 🤩🤩🤩
8. 字符串转换整数 (atoi) LeetCode 题解链接 中等 🤩🤩🤩
12. 整数转罗马数字 LeetCode 题解链接 中等 🤩🤩
59. 螺旋矩阵 II LeetCode 题解链接 中等 🤩🤩🤩🤩
65. 有效数字 LeetCode 题解链接 困难 🤩🤩🤩
73. 矩阵置零 LeetCode 题解链接 中等 🤩🤩🤩🤩
89. 格雷编码 LeetCode 题解链接 中等 🤩🤩🤩🤩
166. 分数到小数 LeetCode 题解链接 中等 🤩🤩🤩🤩
260. 只出现一次的数字 III LeetCode 题解链接 中等 🤩🤩🤩🤩
414. 第三大的数 LeetCode 题解链接 中等 🤩🤩🤩🤩
419. 甲板上的战舰 LeetCode 题解链接 中等 🤩🤩🤩🤩
443. 压缩字符串 LeetCode 题解链接 中等 🤩🤩🤩🤩
457. 环形数组是否存在循环 LeetCode 题解链接 中等 🤩🤩🤩🤩
528. 按权重随机选择 LeetCode 题解链接 中等 🤩🤩🤩🤩
539. 最小时间差 LeetCode 题解链接 中等 🤩🤩🤩🤩
726. 原子的数量 LeetCode 题解链接 困难 🤩🤩🤩🤩

注:以上目录整理来自 wiki,任何形式的转载引用请保留出处。


最后

如果有帮助到你,请给题解点个赞和收藏,让更多的人看到 ~ ("▔□▔)/

也欢迎你 关注我 和 加入我们的「组队打卡」小群 ,提供写「证明」&「思路」的高质量题解。

所有题解已经加入 刷题指南,欢迎 star 哦 ~

旋转字符串

方法一:模拟

思路

首先,如果 $s$ 和 $\textit{goal}$ 的长度不一样,那么无论怎么旋转,$s$ 都不能得到 $\textit{goal}$,返回 $\text{false}$。在长度一样(都为 $n$)的前提下,假设 $s$ 旋转 $i$ 位,则与 $\textit{goal}$ 中的某一位字符 $\textit{goal}[j]$ 对应的原 $s$ 中的字符应该为 $s[(i+j) \bmod n]$。在固定 $i$ 的情况下,遍历所有 $j$,若对应字符都相同,则返回 $\text{true}$。否则,继续遍历其他候选的 $i$。若所有的 $i$ 都不能使 $s$ 变成 $\textit{goal}$,则返回 $\text{false}$。

代码

###Python

class Solution:
    def rotateString(self, s: str, goal: str) -> bool:
        m, n = len(s), len(goal)
        if m != n:
            return False
        for i in range(n):
            for j in range(n):
                if s[(i + j) % n] != goal[j]:
                    break
            else:
                return True
        return False

###Java

class Solution {
    public boolean rotateString(String s, String goal) {
        int m = s.length(), n = goal.length();
        if (m != n) {
            return false;
        }
        for (int i = 0; i < n; i++) {
            boolean flag = true;
            for (int j = 0; j < n; j++) {
                if (s.charAt((i + j) % n) != goal.charAt(j)) {
                    flag = false;
                    break;
                }
            }
            if (flag) {
                return true;
            }
        }
        return false;
    }
}

###C#

public class Solution {
    public bool rotateString(string s, string goal) {
        int m = s.Length, n = goal.Length;
        if (m != n) {
            return false;
        }
        for (int i = 0; i < n; i++) {
            bool flag = true;
            for (int j = 0; j < n; j++) {
                if (s[(i + j) % n] != goal[j]) {
                    flag = false;
                    break;
                }
            }
            if (flag) {
                return true;
            }
        }
        return false;
    }
}

###C++

class Solution {
public:
    bool rotateString(string s, string goal) {
        int m = s.size(), n = goal.size();
        if (m != n) {
            return false;
        }
        for (int i = 0; i < n; i++) {
            bool flag = true;
            for (int j = 0; j < n; j++) {
                if (s[(i + j) % n] != goal[j]) {
                    flag = false;
                    break;
                }
            }
            if (flag) {
                return true;
            }
        }
        return false;
    }
};

###C

bool rotateString(char * s, char * goal){
    int m = strlen(s), n = strlen(goal);
    if (m != n) {
        return false;
    }
    for (int i = 0; i < n; i++) {
        bool flag = true;
        for (int j = 0; j < n; j++) {
            if (s[(i + j) % n] != goal[j]) {
                flag = false;
                break;
            }
        }
        if (flag) {
            return true;
        }
    }
    return false;
}

###JavaScript

var rotateString = function(s, goal) {
    const m = s.length, n = goal.length;
    if (m !== n) {
        return false;
    }
    for (let i = 0; i < n; i++) {
        let flag = true;
        for (let j = 0; j < n; j++) {
            if (s[(i + j) % n] !== goal[j]) {
                flag = false;
                break;
            }
        }
        if (flag) {
            return true;
        }
    }
    return false;
};

###go

func rotateString(s, goal string) bool {
    n := len(s)
    if n != len(goal) {
        return false
    }
next:
    for i := 0; i < n; i++ {
        for j := 0; j < n; j++ {
            if s[(i+j)%n] != goal[j] {
                continue next
            }
        }
        return true
    }
    return false
}

复杂度分析

  • 时间复杂度:$O(n^2)$,其中 $n$ 是字符串 $s$ 的长度。我们需要双重循环来判断。

  • 空间复杂度:$O(1)$。仅使用常数空间。

方法二:搜索子字符串

思路

首先,如果 $s$ 和 $\textit{goal}$ 的长度不一样,那么无论怎么旋转,$s$ 都不能得到 $\textit{goal}$,返回 $\text{false}$。字符串 $s + s$ 包含了所有 $s$ 可以通过旋转操作得到的字符串,只需要检查 $\textit{goal}$ 是否为 $s + s$ 的子字符串即可。具体可以参考「28. 实现 strStr() 的官方题解」的实现代码,本题解中采用直接调用库函数的方法。

代码

###Python

class Solution:
    def rotateString(self, s: str, goal: str) -> bool:
        return len(s) == len(goal) and goal in s + s

###Java

class Solution {
    public boolean rotateString(String s, String goal) {
        return s.length() == goal.length() && (s + s).contains(goal);
    }
}

###C#

public class Solution {
    public bool rotateString(string s, string goal) {
        return s.Length == goal.Length && (s + s).Contains(goal);
    }
}

###C++

class Solution {
public:
    bool rotateString(string s, string goal) {
        return s.size() == goal.size() && (s + s).find(goal) != string::npos;
    }
};

###C

bool rotateString(char * s, char * goal){
    int m = strlen(s), n = strlen(goal);
    if (m != n) {
        return false;
    }
    char * str = (char *)malloc(sizeof(char) * (m + n + 1));
    sprintf(str, "%s%s", goal, goal);
    return strstr(str, s) != NULL;
}

###JavaScript

var rotateString = function(s, goal) {
    return s.length === goal.length && (s + s).indexOf(goal) !== -1;
};

###go

func rotateString(s, goal string) bool {
    return len(s) == len(goal) && strings.Contains(s+s, goal)
}

复杂度分析

  • 时间复杂度:$O(n)$,其中 $n$ 是字符串 $s$ 的长度。$\text{KMP}$ 算法搜索子字符串的时间复杂度为 $O(n)$,其他搜索子字符串的方法会略有差异。

  • 空间复杂度:$O(n)$,其中 $n$ 是字符串 $s$ 的长度。$\text{KMP}$ 算法搜索子字符串的空间复杂度为 $O(n)$,其他搜索子字符串的方法会略有差异。

netstat Cheatsheet

Basic Syntax

Core netstat command forms and output controls.

Command Description
netstat Show active non-listening sockets
sudo netstat -a Show all sockets
sudo netstat -n Show numeric addresses and ports
sudo netstat -p Show PID and program name where available
sudo netstat -c Refresh output every second

Listening Ports

Find services that are accepting local connections.

Command Description
sudo netstat -tuln Show TCP and UDP listening sockets
sudo netstat -tulnp Show listening sockets with PID and process name
sudo netstat -tnlp Show listening TCP sockets only
sudo netstat -unlp Show listening UDP sockets only
sudo netstat -tulnp | grep ':80' Find the process listening on port 80

Connections and TCP States

Inspect active connections and common TCP states.

Command Description
sudo netstat -at Show all TCP sockets
sudo netstat -au Show all UDP sockets
sudo netstat -ant Show TCP sockets with numeric addresses
sudo netstat -ant | grep ESTABLISHED Show established TCP connections
sudo netstat -ant | grep TIME_WAIT Show TCP connections in TIME_WAIT
sudo netstat -atnc Watch TCP connection output continuously

Counts and Filters

Use shell filters with netstat output.

Command Description
sudo netstat -ant | wc -l Count TCP output rows
sudo netstat -ant | grep ':80' | wc -l Count TCP connections involving port 80
sudo netstat -ant | grep ESTABLISHED | wc -l Count established TCP connections
sudo netstat -tulnp | grep nginx Find sockets owned by nginx
sudo netstat -ant | grep 203.0.113.10 Filter connections by remote IP address

Routes, Interfaces, and Stats

Show routing, interface, and protocol information.

Command Description
netstat -rn Show the routing table with numeric addresses
netstat -r Show the routing table with name resolution
netstat -i Show interface counters
netstat -ie Show extended interface details
netstat -s Show protocol statistics
netstat -st Show TCP protocol statistics
netstat -su Show UDP protocol statistics

Modern Replacements

Use current Linux tools for new workflows and scripts.

netstat Command Modern Command
netstat -tuln ss -tuln
sudo netstat -tulnp sudo ss -tulnp
netstat -rn ip route
netstat -i ip -s link
netstat -s ss -s

Troubleshooting

Common netstat issues and quick fixes.

Issue Check
netstat: command not found Install the net-tools package or use ss
Process column is empty Run with sudo when using -p
Output is slow Add -n to disable name resolution
Port lookup matches too much Search with a colon, such as grep ':80'
Need listeners only Add -l with protocol flags such as -tuln

Related Guides

Use these guides for full walkthroughs and modern alternatives.

Guide Description
netstat Command in Linux Full netstat guide with examples
ss Command in Linux Modern socket inspection tool
ip Command in Linux Modern routes and interface management
How to Check Listening Ports in Linux Compare ss, netstat, and lsof
lsof Command in Linux Tie sockets and files back to processes

netstat Command in Linux: Network Connections and Statistics

When something on a server is holding port 80, a connection is refusing to close, or a service is not reachable from the outside, the first question is almost always the same: what is actually listening, and who is talking to whom. For years, the answer on Linux was the netstat command.

netstat is part of the classic net-tools package and prints network connections, listening ports, routing tables, interface counters, and per-protocol statistics. It has been deprecated in favor of ss and ip , but it is still installed on many systems and the tool many sysadmins reach for first. This guide explains how to read its output and which flags cover the cases you run into day to day.

Install netstat

On most modern distributions, net-tools is not installed by default. If netstat is not available, install it with your package manager.

On Ubuntu, Debian, and Derivatives:

Terminal
sudo apt install net-tools

On Fedora, RHEL, and Derivatives:

Terminal
sudo dnf install net-tools

Once the package is installed, verify the binary is on your path:

Terminal
netstat --version

netstat Syntax

The general form of the command is:

txt
netstat [OPTIONS]

Run without options, netstat prints a list of open non-listening sockets, which is rarely what you want. In practice, you almost always pass a combination of flags that describe what kind of sockets you care about (TCP, UDP, Unix), what state they are in, and how you want the output formatted.

List All Connections

To list every connection, both listening and established, use the -a option:

Terminal
sudo netstat -a

The output is divided into two parts. The top lists Internet sockets with columns for protocol, receive and send queue sizes, local and foreign address, and state. The bottom lists Unix domain sockets used for local inter-process communication.

Running netstat with sudo is recommended because without it, the command cannot read socket ownership for processes that belong to other users.

Show TCP and UDP Connections

The -t and -u flags filter the output by protocol. To show only TCP connections:

Terminal
sudo netstat -at

To show only UDP connections:

Terminal
sudo netstat -au

You can combine both flags to get TCP and UDP together:

Terminal
sudo netstat -atu

Show Listening Ports

When you want to know which services are accepting new connections, use the -l option. For TCP, it restricts the output to sockets in the LISTEN state. UDP does not use the same connection states, but UDP sockets that are ready to receive traffic are still shown.

Terminal
sudo netstat -tuln

The combined flags read as: show TCP (-t) and UDP (-u) sockets that are listening (-l), with numeric addresses and ports (-n). The output looks similar to this:

output
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.1:3306 0.0.0.0:* LISTEN
tcp6 0 0 :::80 :::* LISTEN
udp 0 0 0.0.0.0:68 0.0.0.0:*

From the output, we can see that SSH is listening on all interfaces on port 22, MySQL is bound to localhost only on port 3306, and a web server is listening on port 80 over IPv6.

Show the Process Using a Port

To find out which program owns a socket, add the -p option. It prints the PID and the process name next to each connection:

Terminal
sudo netstat -tulnp
output
Proto Recv-Q Send-Q Local Address Foreign Address State PID/Program name
tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN 812/sshd
tcp 0 0 127.0.0.1:3306 0.0.0.0:* LISTEN 1034/mysqld
tcp6 0 0 :::80 :::* LISTEN 1591/nginx: master

The PID/Program name column ties each listening port to a process. This is the quickest way to find out which service is holding a port when you get an Address already in use error.

To check one specific port, pipe the output to grep :

Terminal
sudo netstat -tulnp | grep ':80'

If the command prints a row, a process is listening on port 80. If it prints nothing, no TCP or UDP listener matched that port on the local system.

Show Numeric Output

By default, netstat resolves IP addresses to hostnames and port numbers to service names (for example, 22 becomes ssh). On a busy system, this can be slow because each address needs a DNS lookup.

The -n option disables name resolution and prints raw numbers:

Terminal
sudo netstat -n

Numeric output is also easier to pipe into other tools such as grep or awk, because the field values are predictable.

Continuous Monitoring

The -c option tells netstat to print the output every second until you stop it with Ctrl+C. It is useful when you want to watch a connection appear, change state, and close:

Terminal
sudo netstat -atnc

Each iteration prints the full table again, so it is best used together with a filter such as grep to isolate a single connection.

Show TCP Connection States

TCP connections move through states such as ESTABLISHED, LISTEN, TIME_WAIT, and CLOSE_WAIT. To show active TCP sockets with numeric addresses, run:

Terminal
sudo netstat -ant

The State column tells you where each connection is in the TCP lifecycle:

output
Proto Recv-Q Send-Q Local Address Foreign Address State
tcp 0 0 192.168.1.10:22 192.168.1.5:52710 ESTABLISHED
tcp 0 0 192.168.1.10:80 203.0.113.25:51422 TIME_WAIT
tcp 0 0 127.0.0.1:3306 127.0.0.1:41834 CLOSE_WAIT

ESTABLISHED means the connection is active. TIME_WAIT is normal after a connection closes. A large number of CLOSE_WAIT entries can mean the local application is not closing sockets correctly.

To show only established TCP connections, filter the output:

Terminal
sudo netstat -ant | grep ESTABLISHED

For new troubleshooting work, ss gives better built-in state filters, such as ss -tn state established.

Count Connections

On busy web servers, you may want a quick count instead of a full connection table. To count all current TCP connections that involve port 80, run:

Terminal
sudo netstat -ant | grep ':80' | wc -l

To count only established connections on port 80, add the TCP state to the filter:

Terminal
sudo netstat -ant | grep ':80' | grep ESTABLISHED | wc -l

These counts are useful as a quick signal, but they are not a replacement for application metrics. The number can change while the pipeline is running, especially on high-traffic systems.

Display the Routing Table

To print the kernel IP routing table, use the -r option:

Terminal
netstat -rn
output
Kernel IP routing table
Destination Gateway Genmask Flags MSS Window irtt Iface
0.0.0.0 192.168.1.1 0.0.0.0 UG 0 0 0 eth0
192.168.1.0 0.0.0.0 255.255.255.0 U 0 0 0 eth0

The first row is the default route, which sends all traffic not matching another rule to 192.168.1.1. The ip route command from ip is the modern equivalent and is what you should use on new systems.

Interface Statistics

The -i option prints a per-interface summary that includes received and transmitted packets, errors, drops, and the MTU:

Terminal
netstat -i
output
Kernel Interface table
Iface MTU RX-OK RX-ERR RX-DRP TX-OK TX-ERR TX-DRP Flg
eth0 1500 2345678 0 12 1987654 0 0 BMRU
lo 65536 55432 0 0 55432 0 0 LRU

A growing RX-ERR or TX-ERR column is a hint that you have a cabling, duplex, or driver issue on that interface.

Protocol Statistics

To print a summary of statistics for each protocol, use -s:

Terminal
netstat -s

The output is long and grouped by protocol (Ip, Tcp, Udp, and so on). It includes counters such as total packets received, segments retransmitted, and connection resets. You can narrow the output to a single protocol by combining -s with -t or -u:

Terminal
netstat -st

netstat vs ss

On modern Linux distributions, ss is the recommended replacement for netstat. It is faster and reads information directly from the kernel through Netlink, with richer filter support. The ip command replaces the routing and interface parts of netstat.

A few common translations:

  • netstat -tuln is equivalent to ss -tuln
  • netstat -tulnp is equivalent to sudo ss -tulnp
  • netstat -s maps to ss -s (a much shorter summary)
  • netstat -i maps to ip -s link
  • netstat -r maps to ip route

For a full walkthrough of the replacement tool, see the guide on the ss command .

Quick Reference

For a printable quick reference, see the netstat cheatsheet .

Command Description
netstat Show active non-listening sockets
sudo netstat -a Show all listening and non-listening sockets
sudo netstat -at Show all TCP sockets
sudo netstat -au Show all UDP sockets
sudo netstat -tuln Show TCP and UDP listening sockets with numeric output
sudo netstat -tulnp Show listening sockets with PID and process name
sudo netstat -tulnp | grep ':80' Find the process listening on port 80
sudo netstat -ant Show all TCP sockets with numeric addresses
sudo netstat -ant | grep ESTABLISHED Show established TCP connections
sudo netstat -ant | grep ':80' | wc -l Count TCP connections involving port 80
sudo netstat -atnc Refresh TCP connection output every second
netstat -rn Show the routing table with numeric addresses
netstat -i Show interface statistics
netstat -s Show protocol statistics
netstat -st Show TCP protocol statistics

Troubleshooting

netstat: command not found
The net-tools package is not installed. Install it with your package manager, or use ss instead.

No PID/Program name column shown
You need to run the command with sudo. Without root privileges, netstat cannot read process information for sockets owned by other users.

Output is slow or hangs
DNS resolution is slow or failing. Add the -n option to disable name lookups and print numeric addresses and ports.

A port shows up as LISTEN on :: but not on 0.0.0.0
The service is listening on the IPv6 wildcard address. On systems with dual-stack enabled, this accepts IPv4 traffic too. Check your IPv6 configuration if only IPv4 clients are failing.

FAQ

Is netstat deprecated?
Yes. The net-tools suite, which includes netstat, ifconfig, and route, has been deprecated for years in favor of the iproute2 tools ss and ip. It is still functional and still shipped by most distributions, but new scripts should target ss and ip.

What is the difference between netstat on Linux and Windows?
The command name is the same, and the general idea is the same, but the flags are different. This guide covers the Linux version from net-tools. On Windows, the closest equivalents to netstat -tulnp are netstat -ano and Get-NetTCPConnection in PowerShell.

How do I find what process is using a specific port?
Run sudo netstat -tulnp | grep ':PORT', replacing PORT with the port number. The last column shows the PID and program name. With ss, the same query is sudo ss -tulnp 'sport = :PORT'.

Why is a port still TIME_WAIT after I stopped the service?
TIME_WAIT is a normal TCP state that keeps a closed connection around for a short period so late packets can be handled safely. The kernel clears these entries automatically, usually within one or two minutes.

Conclusion

netstat remains useful when you need to inspect network connections on systems that still have net-tools installed. For new scripts and current Linux systems, prefer ss for sockets and ip for routes and interface statistics.

AI全自动实现Flutter蓝牙自动连接

AI辅助设计Flutter蓝牙自动连接系统

前言

一篇由AI代码实现,连文章也是AI写的文章。除了设计思想是我的,其它的都是AI实现的。AI时代,更注重的是什么,值钱的是什么,可能是问题的解决能力吧。一个好的方案设计吧。

一、项目背景与需求分析

1.1 业务场景描述

在现代工业物联网系统中,蓝牙连接已经成为一项不可或缺的基础功能。我们的工业物联网项目需要实现工业设备与外部蓝牙设备(如蓝牙音箱、打印机、传感器等)的自动连接功能。

与传统手机App不同,工业物联网环境对蓝牙连接有着特殊而严苛的要求:

1. 高可靠性要求 工业系统不能容忍频繁的连接失败。一次看似简单的蓝牙断连,可能导致重要的语音提示无法播放,影响整个物流调度流程。因此,我们需要设计一套完善的容错机制,确保系统在各种异常情况下都能恢复连接。

2. 低延迟特性 连接过程必须尽可能快速。我们不能允许用户等待数十秒甚至数分钟才能完成基本的蓝牙配对。AI在设计时充分考虑了这一点,通过预检查、缓存机制等方式缩短连接时间。

3. 多版本兼容 Android系统的碎片化是所有移动开发者面临的难题。不同版本的Android系统对蓝牙权限的处理方式截然不同,从Android 6.0到Android 14,每个版本都有其独特的权限模型。我们的系统必须能够优雅地适配所有这些版本。

1.2 技术选型分析

在项目初期,AI对现有的Flutter蓝牙生态进行了全面的调研和分析,最终选择了以下技术栈:

dependencies:
  flutter_bluetooth_serial: ^0.4.0    # 蓝牙串口通信
  permission_handler: ^11.0.0        # 权限管理

flutter_bluetooth_serial 是一个成熟稳定的Flutter蓝牙插件,它提供了丰富的蓝牙功能,包括:

  • 经典蓝牙(SPP)和低功耗蓝牙(BLE)支持
  • 设备发现和配对管理
  • 串口通信能力
  • 完善的API设计

permission_handler 是Flutter生态中最流行的权限管理库,它:

  • 统一了Android和iOS的权限处理逻辑
  • 提供了优雅的权限请求API
  • 支持权限状态检查和永久拒绝处理

1.3 核心设计理念

AI在设计这套蓝牙自动连接系统时,遵循了以下核心原则:

渐进式复杂度:从最简单的场景开始,逐步增加功能复杂性。初始版本只处理基本的连接,随后逐步添加权限管理、自动重连、多设备支持等功能。

防御性编程:任何外部调用都可能失败,因此我们必须对每一步操作都进行错误处理和状态检查。

用户体验优先:即使出现异常,也要给用户提供清晰的反馈,而不是让用户面对一个“黑屏”或“无响应”的系统。


二、权限管理系统深度解析

2.1 Android权限演进历史

要设计一个真正兼容所有Android版本的蓝牙系统,我们必须深入理解Android权限模型的演进历史。

Android 6.0(API 23)- 运行时权限时代 从Android 6.0开始,Google引入了运行时权限模型。蓝牙扫描不再是无条件的,而是需要用户显式授权位置权限。这是一个看似奇怪但合理的设计——因为蓝牙扫描可以被用来定位用户,所以Google将蓝牙扫描与位置权限绑定。

Android 10(API 29)- 背景位置限制 Android 10进一步收紧了位置权限的应用场景,使得在后台扫描蓝牙变得更加困难。

Android 12(API 31)- 全新蓝牙权限API 这是最重要的变革。Android 12引入了三个全新的蓝牙权限:

  • BLUETOOTH_SCAN - 蓝牙扫描权限
  • BLUETOOTH_CONNECT - 蓝牙连接权限
  • BLUETOOTH_ADVERTISE - 蓝牙广播权限

这些新权限取代了之前的位置权限要求,使得权限管理更加清晰和直观。

Android 13(API 33)- 进一步优化 Android 13对蓝牙权限进行了微调,使得开发者的体验更加顺畅。

2.2 权限检查与请求流程

下面是AI设计的完整权限处理流程:

Future<int> _checkBlue() async {
  Completer<int> _compCheckBlue = Completer();
  print('目标设备列表: $defaultDriverName');

  try {
    // ============================================
    // 第一阶段:位置权限处理(Android 12以下必须)
    // ============================================
    print('开始检查蓝牙权限');
    
    // 检查当前位置权限状态
    PermissionStatus locationStatus = await Permission.location.status;
    print('位置权限状态: $locationStatus');

    // 如果未授予位置权限,需要请求
    if (!locationStatus.isGranted) {
      print('位置权限未授予,请求权限');
      
      // 发起权限请求
      final locationResult = await Permission.location.request();
      print('请求位置权限结果: $locationResult');

      // 检查请求结果
      if (!locationResult.isGranted) {
        print('权限不足,请授予"附近设备"权限以使用蓝牙功能');
        _compCheckBlue.complete(BluetoothStatus['PERMISSION_DENIED']);
        return _compCheckBlue.future;
      }
    }

    // ============================================
    // 第二阶段:蓝牙扫描权限(Android 12+)
    // ============================================
    // 检查是否已授予蓝牙扫描权限
    if (await Permission.bluetoothScan.isGranted == false) {
      print('蓝牙扫描权限未授予,请求权限');
      
      // 请求蓝牙扫描权限
      final bluetoothScanResult = await Permission.bluetoothScan.request();
      print('请求蓝牙扫描权限结果: $bluetoothScanResult');

      // 检查请求结果
      if (!bluetoothScanResult.isGranted) {
        print('权限不足,请授予"附近设备"权限以使用蓝牙功能');
        _compCheckBlue.complete(BluetoothStatus['PERMISSION_DENIED']);
        return _compCheckBlue.future;
      }
    }

    // ============================================
    // 第三阶段:蓝牙连接权限(Android 12+)
    // ============================================
    // 这个权限特别重要:必须在requestEnable()之前授予
    // 否则会导致蓝牙无法正常开启
    if (await Permission.bluetoothConnect.isGranted == false) {
      print('需要蓝牙连接权限,正在请求...');
      
      final bluetoothConnectResult = await Permission.bluetoothConnect.request();
      print('请求蓝牙连接权限结果: $bluetoothConnectResult');

      if (!bluetoothConnectResult.isGranted) {
        print('权限不足,请授予"附近设备"权限以使用蓝牙功能');
        _compCheckBlue.complete(BluetoothStatus['PERMISSION_DENIED']);
        return _compCheckBlue.future;
      }
    }

    print('权限检查通过');

    // ============================================
    // 第四阶段:蓝牙硬件状态检查
    // ============================================
    
    // 检查设备是否支持蓝牙
    final isAvailable = await FlutterBluetoothSerial.instance.isAvailable;
    if (isAvailable == false) {
      print('蓝牙不可用,返回状态码: ${BluetoothStatus['BLUETOOTH_DISABLED']}');
      _compCheckBlue.complete(BluetoothStatus['BLUETOOTH_DISABLED']);
      return _compCheckBlue.future;
    }
    
    // 检查蓝牙是否已经开启
    final isEnabled = await FlutterBluetoothSerial.instance.isEnabled;
    if (isEnabled == false) {
      // 尝试请求用户开启蓝牙
      final requestResult = await FlutterBluetoothSerial.instance.requestEnable();
      print('请求启用蓝牙结果: $requestResult');

      // 用户拒绝开启蓝牙
      if (requestResult == false) {
        // 引导用户前往系统设置页面
        FlutterBluetoothSerial.instance.openSettings();
        print('蓝牙未打开,请在系统设置中手动开启蓝牙');
        _compCheckBlue.complete(BluetoothStatus['BLUETOOTH_DISABLED']);
        return _compCheckBlue.future;
      }
    }
    
    print('蓝牙状态: 打开');
    
    // 所有检查通过,返回设备未找到状态(等待后续扫描)
    _compCheckBlue.complete(BluetoothStatus['DEVICE_NOT_FOUND']);
  } catch (e) {
    // 捕获所有异常,防止程序崩溃
    print('蓝牙状态检查异常: $e');
    _compCheckBlue.complete(BluetoothStatus['PERMISSION_DENIED']);
  }

  return _compCheckBlue.future;
}

2.3 权限设计亮点

AI在设计权限系统时采用了以下策略:

1. 渐进式权限请求

我们没有一次性请求所有权限,而是分步骤逐一请求。这样做的好处是:

  • 用户更容易理解为什么需要这些权限
  • 如果某个权限被永久拒绝,我们可以立即告知用户,而不是等到后续操作才报错
  • 提供更好的用户体验和透明度

2. 状态缓存与即时检查

每次操作前都会检查权限状态,而不是依赖缓存的权限结果。这样可以避免因用户手动撤销权限而导致的异常。

3. 友好的错误提示

当权限被拒绝时,我们提供了清晰的错误信息和解决建议:

  • "请授予附近设备权限以使用蓝牙功能"
  • "设备蓝牙未打开,请在系统设置中开启蓝牙"

三、蓝牙设备扫描机制

3.1 扫描流程设计

设备扫描是蓝牙连接中最关键也是最复杂的步骤之一。AI设计了以下扫描流程:

Future<dynamic> _scanBlue() async {
  Completer<dynamic> _compScanBlue = Completer();
  
  // 第一步:进行蓝牙状态和权限检查
  await _checkBlue().then((value) async {
    int status = value;
    
    // 初始化扫描状态
    resultScan = ScanResult.NONE;
    _thisScan = false;
    _discoveredDevices.clear();
    
    // 停止之前的扫描(避免资源冲突和重复扫描)
    await _discoverySubscription?.cancel();
    _scanTimer?.cancel();
    
    // 第二步:启动蓝牙设备发现
    print('正在调用 startDiscovery()...');
    try {
      _discoverySubscription = FlutterBluetoothSerial.instance.startDiscovery().listen(
        (device) {
          // ============================================
          // 设备去重处理
          // ============================================
          // 使用Set集合确保每个设备只被处理一次
          if (_discoveredDevices.contains(device.device.address)) {
            return;
          }
          _discoveredDevices.add(device.device.address);
          
          // ============================================
          // 目标设备匹配
          // ============================================
          // 检查发现的设备是否符合我们的目标设备列表
          if (defaultDriverName.indexOf(device.device.name ?? '') > -1) {
            // 确保只处理第一个匹配的设备
            if (!_thisScan) {
              // 判断设备的配对状态
              resultScan = device.device.isBonded 
                ? ScanResult.BONDED    // 已与系统配对
                : ScanResult.UNPAIRED; // 未配对
              
              // 保存目标设备的MAC地址
              _defaultDriverMac = device.device.address.toString();
              _thisScan = true;
              
              print('找到目标设备: ${device.device.name}, 地址: ${device.device.address}');
            }
          }
        },
        // 错误处理
        onError: (error) {
          print('扫描出错: $error');
        },
        // 扫描完成处理
        onDone: () {
          print('扫描流结束');
        },
      );
      print('startDiscovery() 调用成功,监听已设置');
    } catch (e) {
      print('启动扫描异常: $e');
    }

    // 第三步:设置扫描超时定时器
    // 这是非常重要的保护机制,防止扫描无限进行浪费电量
    _scanTimer = Timer(Duration(seconds: scanDuration), () {
      _discoverySubscription?.cancel();
      // 返回扫描结果
      if (!_compScanBlue.isCompleted) {
        _compScanBlue.complete(resultScan == ScanResult.NONE ? status : resultScan);
      }
    });
  });

  return _compScanBlue.future;
}

3.2 扫描结果枚举

为了清晰地区分不同的扫描结果,AI定义了以下枚举:

/// 蓝牙扫描结果枚举
enum ScanResult {
  NONE,       // 未找到设备 - 扫描完成但未发现目标设备
  BONDED,     // 找到已配对设备 - 之前已与系统配对过
  UNPAIRED   // 找到未配对设备 - 需要进行配对操作
}

这个简单的枚举对整个连接流程至关重要,它决定了后续应该采取什么样的连接策略。

3.3 扫描设计亮点

1. 设备去重机制

使用 Set<String> 存储设备地址,可以自动去除重复发现。这是非常必要的,因为蓝牙扫描过程中,同一个设备可能会被多次发现。

2. 单次匹配策略

设置 _thisScan 标志,确保只处理第一个匹配的设备。这避免了当多个目标设备同时存在时的歧义。

3. 超时保护机制

使用 Timer 设置扫描超时,这是节约电量的关键。在物联网环境中,电力是宝贵资源,我们不能让蓝牙无限扫描下去。

4. 配对状态识别

在扫描阶段就区分已配对和未配对设备,可以让后续的连接策略更加精准。


四、连接策略实现

4.1 已配对设备连接

对于已经与系统配对的设备,连接过程相对简单:

/// 连接已配对的蓝牙设备
/// 适用场景:设备之前已经成功配对过
/// 优势:速度快,用户体验好
Future<int> _connectToBondedDevice() async {
  print('发现已配对设备,直接连接设备地址: $_defaultDriverMac');
  
  try {
    // 直接通过MAC地址建立连接
    final connection = await BluetoothConnection.toAddress(_defaultDriverMac);
    _connection = connection;
    print('蓝牙连接成功');
    return BluetoothStatus['CONNECTED_SUCCESS'];
  } catch (e) {
    print('蓝牙连接失败: $e');
    return BluetoothStatus['CONNECTION_FAILED'];
  }
}

适用场景

  • 设备之前已经成功配对过
  • 配对信息仍然保存在系统中
  • 需要快速重连

AI设计思路: 已配对设备的连接是最简单的场景,因为配对过程已经在之前完成,现在只需要建立连接即可。我们使用 BluetoothConnection.toAddress() 方法直接建立连接。

4.2 未配对设备连接

对于未配对的设备,需要先进行配对操作:

/// 配对并连接未配对的蓝牙设备
/// 适用场景:首次连接或配对信息已丢失
/// 流程:配对 -> 连接
Future<int> _bondAndConnectDevice() async {
  print('发现未配对设备,先配对设备地址: $_defaultDriverMac');
  
  try {
    // 第一步:设备配对
    // 这会触发系统的配对对话框(如果需要PIN码)
    await FlutterBluetoothSerial.instance.bondDeviceAtAddress(_defaultDriverMac);
    print('配对成功,开始蓝牙连接');
    
    // 第二步:建立连接
    final connection = await BluetoothConnection.toAddress(_defaultDriverMac);
    _connection = connection;
    print('配对和连接成功');
    return BluetoothStatus['CONNECTED_SUCCESS'];
  } catch (e) {
    print('配对或连接失败: $e');
    return BluetoothStatus['CONNECTION_FAILED'];
  }
}

适用场景

  • 首次连接新设备
  • 之前配对信息被清除
  • 需要用户确认配对

AI设计思路: 未配对设备的连接需要两个步骤:先配对,再连接。这里使用 bondDeviceAtAddress() 方法触发系统配对流程。这个方法可能会弹出系统配对对话框(如果设备需要PIN码)。

4.3 连接状态预检查

在实际连接之前,我们需要检查是否已经建立了连接:

/// 检查是否已连接到目标设备
Future<bool> _checkIfConnected() async {
  print('========== 检查目标设备连接状态 ==========');
  
  try {
    // 1. 检查内存中是否已有活跃连接
    // 这是最快的检查方式
    if (_connection != null && _connection!.isConnected) {
      print('已建立蓝牙连接');
      return true;
    }

    // 2. 获取已配对设备列表,检查目标设备是否已配对
    // 如果设备已经配对但未连接,我们可以快速重连
    final bondedDevices = await FlutterBluetoothSerial.instance.getBondedDevices();
    for (var device in bondedDevices) {
      if (defaultDriverName.contains(device.name)) {
        print('发现已配对的目标设备: ${device.name}');
        return true;
      }
    }

    print('未连接到目标设备');
    return false;
  } catch (e) {
    print('检查连接状态异常: $e');
    return false;
  }
}

五、自动重连机制

5.1 重连策略设计

这是实现“自动连接”功能的核心机制。通过智能的重连策略,我们可以确保在各种异常情况下都能恢复连接:

Future connBlue() async {
  await _connBlue().then((value) {
    int status;
    String? _errorMsg;

    status = value as int;

    // 根据不同状态码设置用户友好的错误信息
    if (status == BluetoothStatus['CONNECTION_FAILED']) {
      _errorMsg = '找到音箱, 连接失败';
    } else if (status == BluetoothStatus['CONNECTED_SUCCESS']) {
      _errorMsg = '找到音箱,连接成功';
    } else if (status == BluetoothStatus['CONNECTED']) {
      _errorMsg = '音箱已经连接';
    } else if (status == BluetoothStatus['DEVICE_NOT_FOUND']) {
      _errorMsg = '没有找到音箱,请确认音箱已经打开';
    } else if (status == BluetoothStatus['BLUETOOTH_DISABLED']) {
      _errorMsg = '设备蓝牙未打开,请在系统设置中开启蓝牙';
    } else if (status == BluetoothStatus['PERMISSION_DENIED']) {
      _errorMsg = '没有获取到所需权限(蓝牙扫描),请在系统设置中手动授予"附近设备"权限';
    } else {
      _errorMsg = '未知错误';
    }

    print(_errorMsg);

    // ============================================
    // 核心:自动重连逻辑
    // ============================================
    // 只有在以下可恢复的错误情况下才进行重试
    if ([
      BluetoothStatus['DEVICE_NOT_FOUND'],    // 设备未找到
      BluetoothStatus['BLUETOOTH_DISABLED'], // 蓝牙未开启
      BluetoothStatus['PERMISSION_DENIED'],   // 权限被拒(可能用户后来授予了)
      BluetoothStatus['CONNECTION_FAILED']    // 连接失败(可能是暂时性的)
    ].contains(status)) {
      // 等待指定间隔后自动重试
      Timer(Duration(seconds: scanTimeInterval), () async {
        connBlue();  // 递归调用,形成循环直到成功
      });
    } else {
      // 对于已连接等状态,不需要重试
      print('连接成功或不需要重试,流程结束');
    }
  });
}

5.2 重连参数配置

class BlueTooth {
  // 自动连接设备名称列表
  // 可以配置多个设备名,系统会依次尝试连接
  List defaultDriverName = [];
  
  // 每次扫描的持续时间(秒)
  // 建议值:5-15秒
  // 太短可能找不到设备,太长浪费电量
  int scanDuration = 10;
  
  // 两次扫描之间的间隔时间(秒)
  // 建议值:10-30秒
  // 这个间隔要足够让用户打开设备
  int scanTimeInterval = 15;
}

5.3 重连设计亮点

1. 智能重试条件

我们只对可恢复的错误进行重试:

  • DEVICE_NOT_FOUND:设备未找到,可能设备刚打开
  • BLUETOOTH_DISABLED:蓝牙被关闭,可能用户刚打开
  • PERMISSION_DENIED:权限被拒绝,可能用户后来授予了
  • CONNECTION_FAILED:连接失败,可能是暂时性网络问题

对于永久性错误(如未知错误),我们选择不重试,避免无意义的循环。

2. 可配置间隔

允许自定义重试间隔,平衡用户体验和功耗。在不同场景下可以调整这个值:

  • 系统启动时:较短间隔(5-10秒)
  • 日常维护:正常间隔(15秒)
  • 低电量模式:较长间隔(30秒以上)

3. 递归重试

使用递归调用实现持续重连,直到成功或用户干预。这种设计简单而有效,不需要额外的心跳机制。


六、完整连接流程图

┌──────────────────────────────────────────────────────────────────────┐
│                           connBlue() 入口                            │
│                    系统启动时自动调用                              │
└──────────────────────────────────────────────────────────────────────┘
                                    │
                                    ▼
┌──────────────────────────────────────────────────────────────────────┐
│                    _checkIfConnected() 检查连接状态                   │
│  ┌────────────────────────────────────────────────────────────────┐  │
│  │  检查方式:                                                    │  │
│  │  1. 检查内存中的连接对象是否有效 (_connection.isConnected)     │  │
│  │  2. 获取已配对设备列表,检查目标设备是否已配对                 │  │
│  └────────────────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────────────────┘
                                    │
                    ┌───────────────┴───────────────┐
                    │                               │
                    ▼                               ▼
            ┌─────────────┐               ┌─────────────────┐
            │  已连接/已配对 │               │   未连接/未配对  │
            │  (return true) │               │ (return false)  │
            └─────────────┘               └─────────────────┘
                    │                               │
                    ▼                               ▼
        ┌───────────────────┐           ┌─────────────────────────────┐
        │ 返回 CONNECTED    │           │     _scanBlue() 开始扫描    │
        │ 直接结束流程      │           │  ┌─────────────────────────┐│
        └───────────────────┘           │  │ 1. 权限检查              ││
                                        │  │ 2. 设备扫描              ││
                                        │  │ 3. 目标匹配              ││
                                        │  │ 4. 超时保护              ││
                                        │  └─────────────────────────┘│
                                        └─────────────────────────────┘
                                                            │
                                            ┌───────────────┴───────────────┐
                                            ▼                               ▼
                                    ┌─────────────┐               ┌─────────────┐
                                    │ ScanResult  │               │ ScanResult   │
                                    │   BONDED    │               │  UNPAIRED    │
                                    │ (已配对)    │               │ (未配对)     │
                                    └─────────────┘               └─────────────┘
                                            │                               │
                                            ▼                               ▼
                            ┌───────────────────────────┐ ┌─────────────────────────────┐
                            │ _connectToBondedDevice() │ │ _bondAndConnectDevice()     │
                            │   直接连接已配对设备      │ │   1. 配对设备               │
                            │   速度快,用户体验好      │ │   2. 建立连接               │
                            └───────────────────────────┘ └─────────────────────────────┘
                                            │                               │
                                            └───────────────┬───────────────┘
                                                            │
                                                            ▼
                                            ┌─────────────────────────────────┐
                                            │     判断连接结果状态码          │
                                            │  CONNECTED_SUCCESS (201)       │
                                            │  CONNECTION_FAILED (400)        │
                                            └─────────────────────────────────┘
                                                            │
                        ┌───────────────────────────────────┼───────────────────────────────────┐
                        │                                   │                                   │
                        ▼                                   ▼                                   ▼
                ┌─────────────┐                   ┌─────────────┐                   ┌─────────────┐
                │   201成功   │                   │  400失败    │                   │  其他错误   │
                │  流程结束  │                   │  等待重试   │                   │   (结束)    │
                └─────────────┘                   └─────────────┘                   └─────────────┘
                                                    │
                                                    ▼
                                            ┌─────────────────┐
                                            │ 等待scanTimeInterval秒  │
                                            │    自动重试     │
                                            └─────────────────┘
                                                    │
                                                    ▼
                                        ┌─────────────────────────┐
                                        │    connBlue() 递归     │
                                        │    (回到起点)          │
                                        └─────────────────────────┘

七、状态码定义与错误处理

7.1 状态码设计

AI设计了HTTP风格的状态码,便于理解和记忆:

/// 蓝牙连接状态码
const Map BluetoothStatus = {
  "CONNECTED": 200,           // 已连接到目标设备
  "CONNECTED_SUCCESS": 201,   // 连接成功
  "CONNECTION_FAILED": 400,   // 连接失败
  "DEVICE_NOT_FOUND": 404,    // 未找到设备
  "BLUETOOTH_DISABLED": 401, // 蓝牙未打开/不可用
  "PERMISSION_DENIED": 403,   // 权限不足
  "UNKNOWN_ERROR": 500,       // 未知错误
};

设计思路

  • 2xx系列表示成功
  • 4xx系列表示客户端错误(设备未找到、权限问题等)
  • 5xx系列表示服务器或未知错误

7.2 错误信息映射

// 状态码到错误信息的映射
String getErrorMessage(int status) {
  switch (status) {
    case 200: return '已连接到目标设备';
    case 201: return '连接成功';
    case 400: return '连接失败,请检查设备';
    case 404: return '未找到设备,请确认设备已开启';
    case 401: return '蓝牙未打开,请在系统设置中开启';
    case 403: return '权限不足,请授予蓝牙权限';
    default: return '未知错误';
  }
}

八、使用示例与最佳实践

8.1 基础用法

// 创建蓝牙连接器实例
final bluetooth = BlueTooth();

// 设置目标设备名称
// 系统会自动连接列表中的第一个匹配设备
bluetooth.defaultDriverName = ['Speaker001', 'BT-Speaker', 'MyBluetooth'];

// 配置参数(可选)
bluetooth.scanDuration = 10;      // 每次扫描10秒
bluetooth.scanTimeInterval = 15; // 重试间隔15秒

// 启动自动连接
bluetooth.connBlue();

8.2 在应用启动时自动连接

void main() {
  runApp(MyApp());
  
  // 应用启动后自动尝试蓝牙连接
  // 使用addPostFrameCallback确保在Widget树构建完成后执行
  WidgetsBinding.instance.addPostFrameCallback((_) {
    final bluetooth = BlueTooth();
    bluetooth.defaultDriverName = ['Speaker001'];
    bluetooth.connBlue();
  });
}

8.3 监听连接状态

// 可以在外部监听连接状态变化
class BluetoothStatusNotifier extends ChangeNotifier {
  bool _isConnected = false;
  
  bool get isConnected => _isConnected;
  
  void updateStatus(bool connected) {
    _isConnected = connected;
    notifyListeners();
  }
}

九、总结与优化建议

9.1 设计亮点总结

特性 实现方式 优势
权限管理 分步请求+状态检查 兼容Android 6.0到14.0所有版本
设备扫描 Set去重+超时保护 效率高,资源占用少
差异化连接 Bonded/Unpaired分支 针对性强,成功率高
自动重连 递归+定时器 稳定可靠,无需人工干预
日志系统 完整的状态记录 便于问题排查

9.2 后续优化方向

1. 连接状态持久化 记录成功连接的设备信息(MAC地址、设备名称),下次启动时优先尝试连接历史设备,而不是每次都扫描。

2. 多设备支持 扩展系统支持同时连接多个蓝牙设备(音箱+打印机+传感器等),实现更丰富的系统功能。

3. 电量优化 根据设备电量水平动态调整扫描频率和重试间隔,在低电量模式下降低扫描频率以节省电力。

4. UI反馈 添加连接状态的可视化界面,让用户清楚地知道当前连接状态,以及何时需要手动干预。

5. 连接质量监控 添加连接质量检测,在连接不稳定时主动断连并重连,提供更稳定的连接体验。


这套蓝牙自动连接系统已经在工业物联网中稳定运行,通过AI的辅助设计,成功处理了Android碎片化带来的各种兼容性问题,实现了真正意义上“即插即用”的自动连接体验。系统能够在启动后自动连接蓝牙设备,无需用户任何干预,大大提升了物联网系统的用户体验和功能完整性。

别再拼 JSON 了:HarmonyOS UDMF 跨应用数据流转实践

做跨应用数据流转,最容易写歪。

我之前接过一个看起来挺小的需求:在一个资料管理类应用里,用户长按一张资料卡片,可以把标题、摘要、来源链接带到另一个应用里;如果接收方是富文本编辑器,就尽量保留链接;如果只是普通输入框,至少要落成一段可读文本。产品说得很轻松,“就跟复制粘贴差不多”。真写起来才发现,复制一段字符串只是最糙的那种做法。

一开始我们做得也简单:把业务对象转成 JSON,再塞到剪贴板或者路由参数里。自己应用内跳转没问题,一跨应用就开始出幺蛾子:有的地方只能拿到纯文本,有的地方把 JSON 原样贴出来,文件路径到了接收方读不了,用户一取消操作,页面状态还以为已经分享成功。更麻烦的是,后来又加了拖拽入口,同一份数据要走复制、拖拽、分享面板几套逻辑,越写越像补丁。

这类场景就不太适合继续“拼字符串”。HarmonyOS 里 UDMF(Unified Data Management Framework,统一数据管理框架)真正有价值的地方,不是让你少写几行代码,而是把跨应用流转这件事变成一套标准化数据契约:数据是什么类型、里面有哪些记录、接收方怎么识别、失败时怎么回退,都可以在一条链路里管住。

image.png

为什么 UDMF 值得单独拿出来讲

很多人第一次看 UDMF,会把它理解成“一个跨应用临时存储区”。这个理解不算完全错,但会把工程设计带偏。

如果只是临时存储,那就很容易写成这样:

// 不推荐:把业务对象直接塞成一段 JSON 字符串
const text = JSON.stringify(card)

然后接收方再尝试 JSON.parse。自己家的两个应用也许能跑,换成系统输入框、文档应用、备忘录、第三方编辑器,就完全没法保证体验。对方不关心你内部的字段名,它只关心“这是不是一段纯文本”“这是不是一个链接”“这是不是一个文件”。

UDMF 要解决的是这个问题:用统一数据对象 UnifiedData 承载一组标准化记录,比如纯文本、超链接、文件、图片等。数据提供方负责把业务对象翻译成这些标准记录;数据访问方按统一数据类型去识别,而不是按业务字段硬猜。

工程上我更愿意把它看成四层:

  • 业务对象层:ShareCardFileMetaContactBrief 这种自己应用内部的数据。
  • 标准化记录层:把业务对象拆成 PlainText、Hyperlink、File 等接收方能理解的记录。
  • 数据通路层:通过 UDMF 写入、查询、更新、删除。
  • UI 状态层:只关心“正在准备、已写入、失败、已清理”,不要直接抱着业务对象乱传。

这几个层次分开以后,后面加拖拽、复制、粘贴、跨应用读取,才不会每个入口都重新写一套转换逻辑。

先定一份业务侧的数据契约

我不太建议一上来就写 UDMF API。先把你真正要流转的业务数据收窄。跨应用数据不是数据库同步,别想着把整个详情页对象都丢出去。

比如资料卡片可以压成这样:

export interface ShareCard {
  id: string
  title: string
  summary: string
  sourceUrl?: string
  sourceName?: string
  createdAt: number
}

export interface ShareResult {
  key: string
  exportedAt: number
}

这里故意没有放用户 token、内部权限位、完整编辑历史。跨应用流转的数据,默认都要按“别人可能看见”处理。就算当前只是同公司两个应用之间共享,也别把登录态、手机号、身份证号这种东西混进去。后面排查问题时,你会感谢现在的克制。

把业务对象转成 UnifiedData

下面这段是我在项目里会放到 adapter 层的写法。它不直接碰页面状态,只做一件事:把业务对象翻译成 UDMF 认识的数据。

// common/udmf/CardUdmfAdapter.ets
import { unifiedDataChannel } from '@kit.ArkData'

export interface ShareCard {
  id: string
  title: string
  summary: string
  sourceUrl?: string
  sourceName?: string
  createdAt: number
}

export class CardUdmfAdapter {
  static toUnifiedData(card: ShareCard): unifiedDataChannel.UnifiedData {
    const text = new unifiedDataChannel.PlainText()

    // 给普通输入框、备忘录、IM 输入框一个可读兜底。
    // 这里不要塞一坨 JSON,用户真的可能直接看到它。
    text.textContent = [
      card.title,
      card.summary,
      card.sourceUrl ? `来源:${card.sourceUrl}` : ''
    ].filter((item: string) => item.length > 0).join('\n')

    const data = new unifiedDataChannel.UnifiedData(text)

    // 如果项目 API 版本支持更多标准化记录,可以继续追加 Hyperlink / File 等。
    // 实际落地时建议保留 PlainText 作为兜底记录,接收方能力弱也能拿到内容。
    return data
  }
}

有些同学会嫌这个 PlainText 太保守,觉得都上高级 API 了,怎么还写纯文本。恰恰相反,纯文本兜底是跨应用体验里最稳的一层。你可以在支持的版本里追加更丰富的记录,但不要把唯一出口做成内部 JSON。用户把内容拖到一个普通文本框里,能看到一段自然文本,比看到 { "id": "xxx" } 强太多。

封一层 Repository,别让页面直接调 insertData

页面里直接调 unifiedDataChannel.insertData,短 demo 没问题,项目里很快就乱。我的习惯是单独封一个 UdmfRepository,把回调、异常、key 管理都收进去。

// common/udmf/UdmfRepository.ets
import { unifiedDataChannel, uniformTypeDescriptor } from '@kit.ArkData'
import { BusinessError } from '@kit.BasicServicesKit'
import { CardUdmfAdapter, ShareCard } from './CardUdmfAdapter'

export class UdmfRepository {
  private lastSharedKey: string = ''

  async shareCard(card: ShareCard): Promise<string> {
    const unifiedData = CardUdmfAdapter.toUnifiedData(card)

    const options: unifiedDataChannel.Options = {
      intention: unifiedDataChannel.Intention.DATA_HUB
    }

    return new Promise((resolve, reject) => {
      try {
        unifiedDataChannel.insertData(options, unifiedData, (err: BusinessError | undefined, key: string) => {
          if (err) {
            reject(err)
            return
          }

          this.lastSharedKey = key
          resolve(key)
        })
      } catch (e) {
        reject(e as BusinessError)
      }
    })
  }

  async queryPlainTexts(): Promise<string[]> {
    const options: unifiedDataChannel.Options = {
      intention: unifiedDataChannel.Intention.DATA_HUB
    }

    return new Promise((resolve, reject) => {
      try {
        unifiedDataChannel.queryData(options, (err: BusinessError | undefined, dataList: unifiedDataChannel.UnifiedData[]) => {
          if (err) {
            reject(err)
            return
          }

          const result: string[] = []

          dataList.forEach((data: unifiedDataChannel.UnifiedData) => {
            const records = data.getRecords()
            records.forEach((record: unifiedDataChannel.UnifiedRecord) => {
              // 接收方不要假设第 0 条就是纯文本,按类型拿。
              if (record.getType() === uniformTypeDescriptor.UniformDataType.PLAIN_TEXT) {
                const plainText = record as unifiedDataChannel.PlainText
                result.push(plainText.textContent)
              }
            })
          })

          resolve(result)
        })
      } catch (e) {
        reject(e as BusinessError)
      }
    })
  }

  async deleteLastShared(): Promise<void> {
    if (this.lastSharedKey.length === 0) {
      return
    }

    const options: unifiedDataChannel.Options = {
      key: this.lastSharedKey
    }

    return new Promise((resolve, reject) => {
      try {
        unifiedDataChannel.deleteData(options, (err: BusinessError | undefined) => {
          if (err) {
            reject(err)
            return
          }

          this.lastSharedKey = ''
          resolve()
        })
      } catch (e) {
        reject(e as BusinessError)
      }
    })
  }
}

这里有两个细节我会比较坚持。

一个是保存 insertData 返回的 key。很多 demo 只展示写入和查询,没强调这个 key。真实项目里,没有 key 就很难做更新、删除、清理,也不好定位日志。数据一旦进入通路,后续生命周期就不能靠“我觉得它应该没了”。

另一个是查询时按 getType() 过滤。不要偷懒写 records[0] as PlainText。你今天只放一条纯文本,明天就可能加一条链接记录、一条图片记录。数组顺序一变,接收方就出错。跨应用数据最怕这种隐式约定。

页面只管理状态,不参与数据拼装

页面层最好别知道 UDMF 里面到底塞了什么。它只负责触发动作、展示状态、处理失败提示。

// pages/ShareCardPage.ets
import { UdmfRepository } from '../common/udmf/UdmfRepository'
import { ShareCard } from '../common/udmf/CardUdmfAdapter'
import { BusinessError } from '@kit.BasicServicesKit'

@Entry
@Component
struct ShareCardPage {
  private udmfRepo: UdmfRepository = new UdmfRepository()

  @State card: ShareCard = {
    id: 'doc_20260430_001',
    title: 'HarmonyOS 图片处理链路复盘',
    summary: '一张图从相册进来,到预览、压缩、导出,中间其实有不少内存坑。',
    sourceUrl: 'https://juejin.cn/',
    sourceName: '技术笔记',
    createdAt: Date.now()
  }

  @State sharing: boolean = false
  @State message: string = '未共享'
  @State lastKey: string = ''

  private async share(): Promise<void> {
    if (this.sharing) {
      return
    }

    this.sharing = true
    this.message = '正在准备数据...'

    try {
      const key = await this.udmfRepo.shareCard(this.card)
      this.lastKey = key
      this.message = '已写入标准化数据通路'
    } catch (e) {
      const err = e as BusinessError
      this.message = this.toUserMessage(err)
      console.error(`[UDMF] share failed, code=${err.code}, message=${err.message}`)
    } finally {
      this.sharing = false
    }
  }

  private async cleanup(): Promise<void> {
    try {
      await this.udmfRepo.deleteLastShared()
      this.lastKey = ''
      this.message = '已清理上一次共享数据'
    } catch (e) {
      const err = e as BusinessError
      this.message = '清理失败,稍后再试'
      console.error(`[UDMF] cleanup failed, code=${err.code}, message=${err.message}`)
    }
  }

  private toUserMessage(err: BusinessError): string {
    // 这里别把底层错误直接甩给用户。
    // 错误码留日志,前台给能理解的话。
    if (err.code === 401) {
      return '当前接口权限或能力不可用'
    }
    return '共享失败,请稍后重试'
  }

  build() {
    Column({ space: 16 }) {
      Text(this.card.title)
        .fontSize(22)
        .fontWeight(FontWeight.Bold)

      Text(this.card.summary)
        .fontSize(15)
        .fontColor('#666666')

      Button(this.sharing ? '处理中...' : '写入 UDMF 数据通路')
        .enabled(!this.sharing)
        .onClick(() => {
          this.share()
        })

      Button('清理上一次共享数据')
        .enabled(this.lastKey.length > 0)
        .onClick(() => {
          this.cleanup()
        })

      Text(this.message)
        .fontSize(14)
        .fontColor('#666666')
    }
    .padding(24)
    .width('100%')
  }
}

这段代码看着不复杂,但它把几个坑避开了:重复点击、错误码外泄、页面直接拼数据、失败后状态不回收。很多线上问题不是 API 不会用,而是这些边角没收住。

文件类数据别直接扔沙箱路径

UDMF 做文本和链接比较直观,到了文件、图片,坑会多一些。

有些项目会把应用沙箱里的路径直接塞出去,接收方拿到之后发现读不了。这个问题不是 UDMF 的锅,是权限和访问边界没想清楚。跨应用数据流转时,要确认接收方拿到的是它有能力访问的数据,不能把自己应用私有目录里的路径当成公共文件地址。

我的处理方式一般是:

  • 能用文本、链接解决的,不要硬塞文件。
  • 文件必须流转时,先确认文件来源和访问方式,比如用户选择的媒体资源、应用生成的可共享临时文件。
  • 不把长期私有文件直接暴露出去,必要时生成一份临时副本。
  • 数据记录里保留标题、大小、类型等元信息,接收方即使文件读取失败,也能做友好提示。
  • 分享完成或页面退出后,清理临时副本,别让缓存目录变成垃圾堆。

说白了,UDMF 是数据通路,不是权限魔法。你传出去的东西,接收方有没有资格读,还是要你自己设计清楚。

把生命周期画出来,问题会少一半

我后来给团队里定了个小规矩:凡是跨应用数据流转,都要画一个状态图。不用很复杂,至少把准备、写入、成功、失败、清理这几个状态列出来。

image.png

实际代码里可以对应成这样:

export enum ShareTaskState {
  IDLE = 'IDLE',
  BUILDING = 'BUILDING',
  INSERTING = 'INSERTING',
  SHARED = 'SHARED',
  CLEANUP = 'CLEANUP',
  FAILED = 'FAILED'
}

export interface ShareTaskSnapshot {
  state: ShareTaskState
  key?: string
  errorCode?: number
  errorMessage?: string
  updatedAt: number
}

页面和日志都围绕这个状态走,排查问题会轻松很多。比如用户反馈“我点了分享没反应”,你能从日志里看到它到底是构建数据失败、写入通路失败,还是写入成功但接收方没有识别。不要等线上问题来了,才从一堆 console.info('success') 里猜。

常见坑位,我踩过的几类

1. 把 UDMF 当长期数据库用

UDMF 适合跨应用流转,不适合承载你自己的长期业务数据。应用内部状态还是该放 Preferences、关系型数据库、文件系统或者服务端。UDMF 里只放“需要交给别人”的那一份数据,而且要有清理策略。

2. 只做发送方,不做接收方自测

很多问题发送方看不出来。你写入成功了,不代表别人能读懂。至少要准备几个接收场景:

  • 普通文本输入框。
  • 富文本编辑器。
  • 自家另一个测试应用。
  • 不支持你期望类型的兜底场景。

能贴成自然文本,基本盘就稳了;能识别链接和文件,是增强体验。

3. 接收方强依赖记录顺序

前面也提过,别写 records[0]。标准化数据对象是一组记录,接收方应该按类型和业务标签识别。今天顺序对,不代表下个版本还对。

4. 错误提示直接展示底层 message

底层错误对用户没意义。用户要知道的是“是不是没权限”“是不是内容太大”“是不是稍后再试”。错误码和原始 message 留日志,前台提示做一次翻译。

5. 大对象不做预算

跨应用流转不是越全越好。几十 KB 的文本摘要和一个链接,体验很好;几 MB 的 JSON、几十张图片元信息、完整编辑历史,一旦失败很难补救。大对象要么拆,要么走文件,要么只传索引和摘要。

6. 忘了清理 key

如果你需要更新或删除写入的数据,就要保存 key。页面销毁、用户撤销、任务失败,都要考虑 key 还在不在。别让“临时数据”变成没人管的数据。

性能和稳定性上的几个取舍

UDMF 链路里,我最关心的不是单次 API 调用耗时,而是用户连续操作时系统是否稳定。

用户快速点三次分享按钮,页面旋转一次,再从最近任务回来,这些场景比单次 demo 更接近真实情况。建议做几个小护栏:

export class ShareActionGate {
  private running: boolean = false
  private lastActionAt: number = 0

  canRun(): boolean {
    const now = Date.now()

    if (this.running) {
      return false
    }

    // 简单节流,避免用户连续触发多次写入。
    if (now - this.lastActionAt < 800) {
      return false
    }

    this.running = true
    this.lastActionAt = now
    return true
  }

  finish(): void {
    this.running = false
  }
}

别小看这种门闩。很多“偶现重复分享”“偶现状态错乱”,最后都和连续触发有关。你可以做得更细,比如给每次分享分配 taskId,异步回调回来时只允许最新 task 更新 UI。这个思路和图片处理、播放器状态机是一样的:异步任务不要裸奔。

另一个取舍是数据大小。我的建议是:跨应用默认传轻量内容,重内容只传可访问引用。比如一篇笔记,给标题、摘要、链接;一个文件,给可访问 URI 和文件元信息;一批图片,给数量、封面和入口,不要把所有东西一次性塞进通路里。

更适合落地的场景

UDMF 不一定适合所有业务,但下面几类挺典型:

  • 笔记、资料、收藏类应用:把卡片拖到文档或备忘录,保留标题、摘要、来源。
  • 办公协作应用:在多个内部应用之间传递审批单摘要、任务链接、文件引用。
  • 内容生产工具:把素材从素材库拖到编辑器,接收方按图片、链接、纯文本分别处理。
  • 教育类应用:题目、错题、讲解片段在题库和笔记之间流转。
  • 设备协同入口:同一份标准化数据在不同端上被识别,而不是每个端写一套字段解析。

判断一个场景该不该用 UDMF,我一般看两个问题:这份数据是不是要离开当前应用?接收方是不是可能不止一个?只要两个答案都是“是”,就别再只想着字符串拼接了。

结尾:跨应用数据流转,先像个产品能力,再像个 API 调用

UDMF 这类 API,最怕写成“我会调用 insertData 了”。调用成功只是第一步,真正要考虑的是:用户看到的是什么,接收方能不能理解,失败时怎么降级,敏感字段有没有出去,临时数据谁来清理。

我的经验是,先把业务对象收窄,再转成标准化记录;先保证纯文本兜底,再做链接、文件、图片这些增强;先把 key、状态、错误、清理链路想清楚,再把入口挂到按钮、拖拽、粘贴里。这样写出来的代码不一定最炫,但上线后少出奇怪问题。

鸿蒙的高级 API 很多,UDMF 算是比较容易被低估的一个。它不只是“跨应用共享数据”,更像是给应用之间约了一套听得懂的话。这个约定做扎实了,后面做拖拽、富文本、文件流转,才不会每加一个入口就重写一遍胶水代码。

前端开发者做多步 Agent:别让 AI 边想边乱跑,用 Plan-Act-Observe 稳住 4 步任务

作者:前端转 AI 深度实践者

【省流助手/核心观点】:多步 Agent 最怕的不是不会调用工具,而是没有计划地乱调用工具。一个更可靠的 Agent 应该遵循 Plan-Act-Observe:先把任务拆成结构化步骤,再执行当前步骤,观察工具结果,把结果写回计划,并根据观察决定下一步。对前端开发者来说,这很像把复杂交互拆成流程节点:每一步有目标、有状态、有输入输出、有失败处理,而不是把所有逻辑塞进一个巨大的 handleUserInput


第 25 篇我们做了一个最小 Agent Loop。

它已经能完成这样的闭环:

用户输入
-> 模型判断是否调用工具
-> 程序执行工具
-> 工具结果回到上下文
-> 模型生成最终回答

这对简单问题已经够用。

比如:

帮我查一下订单 A1001 到哪了。

Agent 调一次 getOrderStatus,再组织答案,就能完成任务。

但真实用户不会总是问这么简单的问题。

他们更可能问:

帮我查一下订单 A1001 的物流,如果还没送达,再看一下售后政策,告诉我能不能申请延迟补偿。

这个问题突然变成了多步任务:

  1. 查订单状态。
  2. 判断是否送达。
  3. 如果没送达,查延迟补偿政策。
  4. 结合订单和政策给出建议。

这时候,如果 Agent 只是“边想边跑”,很容易跑偏。

1. 痛点:没有计划的 Agent,就像没看需求就开写代码

前端开发者应该很熟悉这种场景:

需求还没拆清楚,就开始写组件。

写着写着发现:

  • 状态放错地方了。
  • 接口顺序不对。
  • 错误态没处理。
  • 中间结果没有保存。
  • 最后发现第一步设计就错了。

多步 Agent 也是一样。

如果没有计划,它可能会:

  • 先查政策,再查订单,顺序反了。
  • 查完订单后忘记判断是否送达。
  • 明明订单已签收,还继续查延迟补偿。
  • 工具失败了还继续往下走。
  • 最终回答时说不清依据。

所以多步 Agent 的第一件事不是“多调几个工具”,而是先把任务拆清楚。

这就是 Plan。

2. 错误做法:让模型每一步临场发挥

一种常见写法是把所有控制权交给模型:

async function runFreeAgent(userInput: string) {
  let context = userInput;

  for (let i = 0; i < 5; i++) {
    const output = await llm.chat(`
你是一个自主 Agent,请根据当前上下文决定下一步。

上下文:
${context}
`);

    const toolResult = await runTool(output.toolCall);
    context += JSON.stringify(toolResult);
  }

  return context;
}

这段代码看起来很“自主”,但工程上很难维护:

  1. 不知道任务一开始被拆成了几步。
  2. 不知道当前执行到哪一步。
  3. 不知道某一步失败后该停还是继续。
  4. 不知道哪些步骤应该被跳过。
  5. 最终回答很难追溯依据。

多步 Agent 不是越自由越好。

真正可交付的系统,要让每一步都能被看见、被控制、被复盘。

3. 正确做法:先把任务变成结构化计划

Plan-Act-Observe 可以翻译成:

Plan:先拆解任务
Act:执行当前步骤
Observe:记录结果,并影响后续步骤

先定义一个计划步骤:

type StepStatus =
  | "pending"
  | "running"
  | "done"
  | "skipped"
  | "failed";

type PlanStep = {
  id: string;
  goal: string;
  toolName: string;
  args: Record<string, unknown>;
  status: StepStatus;
  observation?: unknown;
  error?: unknown;
  skipReason?: string;
};

对刚才那个用户问题,一个最小计划可以长这样:

const plan: PlanStep[] = [
  {
    id: "step_1",
    goal: "查询订单 A1001 的物流状态",
    toolName: "getOrderStatus",
    args: { orderId: "A1001" },
    status: "pending"
  },
  {
    id: "step_2",
    goal: "查询延迟送达补偿政策",
    toolName: "searchPolicy",
    args: { keyword: "延迟补偿" },
    status: "pending"
  }
];

这份计划有几个好处:

  • 每一步目标清楚。
  • 每一步要调用哪个工具清楚。
  • 每一步参数清楚。
  • 当前执行状态清楚。
  • 后面可以记录执行结果。

前端同学可以把它类比成多步骤表单:

Step 1:填写基础信息
Step 2:选择配送方式
Step 3:确认订单
Step 4:支付

每一步都有状态:未开始、进行中、完成、失败、跳过。

Agent 计划也是一样。

4. Act:一次只执行当前步骤

执行计划时,不要一次把所有步骤全部跑完。

更稳的方式是一次只拿一个 pending 步骤:

function getNextStep(plan: PlanStep[]) {
  return plan.find((step) => step.status === "pending") ?? null;
}

然后执行这个步骤:

type ToolResult =
  | {
      ok: true;
      data: unknown;
    }
  | {
      ok: false;
      errorType: string;
      message: string;
    };

async function act(step: PlanStep): Promise<ToolResult> {
  return runTool({
    toolName: step.toolName,
    args: step.args
  });
}

这件事看起来简单,但它让系统变得可控。

因为你随时知道:

  • 当前执行到哪一步。
  • 调用了哪个工具。
  • 用了什么参数。
  • 失败时应该标记哪一步。

多步 Agent 最怕“做了很多事,但没人知道它做到哪了”。

5. Observe:工具结果必须写回计划

执行工具之后,要把结果写回计划。

function observe(step: PlanStep, toolResult: ToolResult) {
  if (toolResult.ok) {
    step.status = "done";
    step.observation = toolResult.data;
    return;
  }

  step.status = "failed";
  step.error = {
    errorType: toolResult.errorType,
    message: toolResult.message
  };
}

Observe 不是“拿到结果就行”。

Observe 是把结果变成系统状态。只有状态被正确记录,后续步骤才能基于它做判断。

6. 观察结果应该能改变后续计划

计划不是死的。

我们的任务里有一句条件:

如果还没送达,再看一下售后政策。

如果第一步查到订单已签收,第二步其实应该跳过。

type OrderStatus = {
  status: "shipping" | "delivered" | "not_found";
  eta?: string;
};

function updatePlanAfterObservation(plan: PlanStep[]) {
  const orderStep = plan.find((step) => step.id === "step_1");
  if (!orderStep || orderStep.status !== "done") return;

  const order = orderStep.observation as OrderStatus;

  if (order.status === "delivered") {
    for (const step of plan.slice(1)) {
      if (step.status === "pending") {
        step.status = "skipped";
        step.skipReason = "订单已签收,不需要继续查询延迟补偿。";
      }
    }
  }
}

这才是 Observe 的价值。

它不是为了记日志而记日志,而是让工具结果影响下一步。

7. 一个最小 Plan Agent 长这样

下面是一版完整但仍然很小的执行器:

type PlanAgentResult = {
  ok: boolean;
  answer: string;
  plan: PlanStep[];
};

async function runPlanAgent(
  userInput: string,
  maxSteps = 4
): Promise<PlanAgentResult> {
  const plan = createPlan(userInput);
  let steps = 0;

  while (steps < maxSteps) {
    steps += 1;
    const step = getNextStep(plan);

    if (!step) {
      return generateFinalAnswer(plan);
    }

    step.status = "running";

    const toolResult = await act(step);
    observe(step, toolResult);

    if (step.status === "failed") {
      return generateFinalAnswer(plan);
    }

    updatePlanAfterObservation(plan);
  }

  return {
    ok: false,
    answer: "超过最大执行步数,Agent 已停止。",
    plan
  };
}

这段代码没有炫技,但结构非常清楚:

  • 先有计划。
  • 找到下一步。
  • 执行当前动作。
  • 观察结果。
  • 根据结果更新计划。
  • 没有下一步就回答。

如果以后接入真实模型,这个结构仍然成立。

只是 createPlan 可以由模型生成,generateFinalAnswer 也可以由模型根据计划结果生成。

8. 补上 createPlan 和 final answer 的最小实现

学习阶段不一定要一上来就让模型生成计划。

你可以先用规则把流程跑通。

function createPlan(userInput: string): PlanStep[] {
  if (!userInput.includes("A1001")) {
    return [
      {
        id: "step_1",
        goal: "告知用户当前示例只支持订单 A1001",
        toolName: "none",
        args: {},
        status: "skipped",
        skipReason: "当前示例只处理订单 A1001"
      }
    ];
  }

  return [
    {
      id: "step_1",
      goal: "查询订单 A1001 的物流状态",
      toolName: "getOrderStatus",
      args: { orderId: "A1001" },
      status: "pending"
    },
    {
      id: "step_2",
      goal: "查询延迟送达补偿政策",
      toolName: "searchPolicy",
      args: { keyword: "延迟补偿" },
      status: "pending"
    }
  ];
}

最终回答也可以先用规则生成:

function generateFinalAnswer(plan: PlanStep[]): PlanAgentResult {
  const failed = plan.find((step) => step.status === "failed");
  if (failed) {
    return {
      ok: false,
      answer: `任务在「${failed.goal}」失败:${JSON.stringify(
        failed.error
      )}`,
      plan
    };
  }

  const orderStep = plan.find((step) => step.id === "step_1");
  const policyStep = plan.find((step) => step.id === "step_2");
  const runnableSteps = plan.filter((step) => step.status !== "skipped");

  if (runnableSteps.length === 0) {
    return {
      ok: false,
      answer:
        plan
          .map((step) => step.skipReason)
          .filter(Boolean)
          .join("\n") || "当前任务没有可执行步骤。",
      plan
    };
  }

  const lines = [
    orderStep?.observation
      ? `订单查询结果:${JSON.stringify(orderStep.observation)}`
      : "没有订单查询结果。"
  ];

  if (policyStep?.status === "skipped") {
    lines.push(`政策查询已跳过:${policyStep.skipReason}`);
  } else if (policyStep?.observation) {
    lines.push(`政策查询结果:${JSON.stringify(policyStep.observation)}`);
  }

  return {
    ok: true,
    answer: lines.join("\n"),
    plan
  };
}

这不是最终产品文案,但它能帮你先验证流程。

等 Plan-Act-Observe 跑稳后,再让模型接管计划生成和最终表达,会更容易排查问题。

9. 前端页面怎么展示计划?

多步 Agent 如果只展示最终回答,用户不知道系统做了什么,开发者也很难排查。

可以把计划展示成步骤列表:

function AgentPlanView({ plan }: { plan: PlanStep[] }) {
  return (
    <ol>
      {plan.map((step) => (
        <li key={step.id}>
          <strong>{step.goal}</strong>
          <span>{step.status}</span>
          {step.skipReason && <p>{step.skipReason}</p>}
          {step.error && <pre>{JSON.stringify(step.error, null, 2)}</pre>}
        </li>
      ))}
    </ol>
  );
}

这类 UI 在开发环境、运营后台、企业内部工具里很有价值。

因为它能回答几个关键问题:

  • Agent 原计划做什么?
  • 当前执行到哪一步?
  • 哪一步失败了?
  • 哪一步被跳过了?
  • 最终答案基于哪些观察结果?

10. 生产环境避坑指南

1. 不要让计划无限长

初期计划控制在 2 到 4 步更稳。

计划越长,错误传播越严重,成本和延迟也越高。

2. 关键步骤失败后不要继续编

如果查订单失败,就不要继续基于空数据查补偿政策。

关键步骤失败时,应该停止并说明失败原因。

3. 跳过步骤要写明原因

不要只把状态改成 skipped

要写清 skipReason,否则排查时不知道是业务条件触发,还是系统漏执行。

4. 高风险步骤必须二次确认

如果计划里包含取消订单、发起退款、发送邮件、删除数据,一定要在执行前确认。

Plan 可以建议高风险步骤,但不能自动越过权限和确认。

5. 每一步都要可回放

记录每一步的 goaltoolNameargsstatusobservationerror

否则多步 Agent 一旦出错,就会变成“它好像自己做了很多事,但没人知道具体发生了什么”。

11. 常见误区

误区 1:计划越详细越好

不一定。初期计划 2 到 4 步最好。太长的计划会增加错误传播和维护成本。

误区 2:生成计划后就不能改

计划应该能根据观察结果调整。否则 Observe 就只是记录日志,没有真正参与决策。

误区 3:工具失败后继续执行后续步骤

如果关键步骤失败,应该停下来说明失败原因,而不是继续编一个完整答案。

误区 4:所有计划都必须由模型生成

不需要。学习阶段可以先用规则生成计划。真实项目里,也可以把固定业务流程写死,只让模型处理自然语言理解和答案表达。

12. 给前端开发者的落地清单

如果你要在团队里做多步 Agent,可以从这份清单开始:

  1. 定义任务类型。
  2. 为每种任务设计最短计划。
  3. 每一步都要有 idgoalstatus
  4. 每一步只调用一个清晰工具。
  5. 工具结果写入 observation
  6. 失败写入 error
  7. 可跳过步骤写入 skippedskipReason
  8. 最终答案必须基于 plan 里的观察结果。
  9. 记录完整执行日志。
  10. 用测试用例覆盖完成、跳过、失败三种路径。

这份清单看起来像工程流程,而不是 AI 魔法。

这正是重点。

Agent 工程越往后走,越不是让模型随便发挥,而是给模型一个清晰、可观察、可回放的工作台。

结语

多步 Agent 不能边想边乱跑。

它需要先计划,再行动,再观察。

Plan 让任务有结构。
Act 让系统真正执行。
Observe 让结果回到状态,并影响下一步。

这就是 Agent 从“能调工具”走向“能完成任务”的关键一步。

对前端开发者来说,这不是陌生领域。你早就熟悉流程、状态、副作用和错误处理。现在只是把这些工程能力,用在 AI Agent 上。

会写 Prompt 只是开始。

会设计可控的多步执行流程,才是 AI 工程真正的成长信号。

Vercel Serverless 调国内 AI 接口 504?Edge Runtime 救了我

Mobile 端 AI 对话请求在 Vercel 上稳定 504 超时,本地却秒回。CORS 报错是假的,区域配置也没用。最终发现是 Vercel Serverless(AWS Lambda)到国内 DashScope 的网络出口根本不通。一行 export const runtime = "edge" 切到 Cloudflare 边缘网络,3 秒完成。这篇文章把排查过程、根因分析和解决方案一次性讲清楚。

0. 前景提要:项目架构与问题背景

先交代项目架构,方便理解后续为什么 Web 端和 Mobile 端表现不同。

项目结构

My-Notion/                    # pnpm workspace Monorepo
├── apps/
│   ├── web/                  # Web 端(Next.js)
│   └── mobile/               # Mobile 端(Expo / React Native)
├── packages/
│   └── ai/                   # AI 核心逻辑(共享包)
│       ├── server/           #   streamChat、streamRAG、ConvexDataSource...
│       ├── config/           #   模型配置、Base URL
│       ├── tools/            #   WebSearch 等工具
│       └── rag/              #   向量检索逻辑
└── services/
    └── ai/                   # AI 网关(Hono),独立部署到 Vercel
        ├── api/              #   Vercel Serverless / Edge 入口
        └── src/              #   路由、Convex 数据源、Sentry

为什么 Mobile 不直接用 Web 端的 API

Web 端的 AI 路由(/api/chat/api/rag-stream)是 Next.js API Route,跑在 apps/web 这个 Vercel 项目里。Mobile 端不能直接调这些路由,原因有三个:

  1. SSE 流式传输:Mobile 端需要 Server-Sent Events 格式的流式响应,Web 端的 /api/chat 用的是 NDJSON 格式,不兼容
  2. 密钥隔离:AI 服务的 LLM_API_KEY 不应该暴露在 Mobile 客户端,需要一个中间层代理
  3. 独立扩缩:AI 请求是重 IO 操作,和 Web 页面服务混在一起会互相影响

所以 Mobile 端的 AI 请求走独立部署的 services/ai(基于 Hono 的轻量 Node.js 服务),部署在 my-notion-ai.vercel.app

两条 AI 链路

Web 端:
  浏览器 → apps/web (Next.js API Route) → DashScope
           ↑ 同一个 Vercel 项目,Serverless Function

Mobile 端:
  App → services/ai (Hono) → DashScope
        ↑ 独立 Vercel 项目,Serverless Function

关键点:两条链路都跑在 Vercel Serverless(AWS Lambda)上,但它们是不同的 Vercel 项目,函数冷启动、预热策略、网络出口可能不同。这解释了为什么"Web 端偶尔慢,Mobile 端必超时"。

DashScope 是什么

DashScope 是阿里云的大模型服务平台,提供 OpenAI 兼容接口。项目用的模型是通义千问(Qwen),Base URL 是 https://dashscope.aliyuncs.com/compatible-mode/v1——这是一个国内节点

这就是问题的伏笔:Vercel 的服务器在海外,DashScope 的服务在国内,中间隔着一条不稳定的网络链路。

1. 开篇:文档正常,AI 炸了

项目是 Web + Mobile 双端架构,共享 packages/ai 核心逻辑。Mobile 端的 AI 请求走独立部署的 services/ai(Hono),域名是 my-notion-ai.vercel.app

上线后发现问题:

  • Mobile 文档功能(Convex)完全正常
  • Mobile AI 对话请求长期 pending,最终 504
  • Web 端 AI 功能偶尔也慢

浏览器网络面板显示的是 CORS 错误,但 OPTIONS /api/chat 返回 204,预检请求没问题。真正挂的是 POST /api/chat

CORS 报错是服务器 500/504 后的表象,不是根因。浏览器只在请求失败时才告诉你"可能是 CORS",实际上后端已经炸了。

2. 排查:五层剥洋葱

2.1 第一层:前端代码

检查 Mobile 端的请求逻辑——URL 正确、Header 正确、Body 格式正确。没有根本性错误。

结论:问题不在 Mobile 前端。

2.2 第二层:CORS

OPTIONS /api/chat 返回 204GET /api/health 返回 {"status":"ok"}。CORS 中间件 app.use("*", cors()) 全局开启,配置正确。

结论:CORS 不是根因,只是请求失败的表层表现。

2.3 第三层:路由与部署入口

最初 /api/health 返回 404,经过以下修复后恢复正常:

  • 调整 Hono 路由前缀
  • 修正 Vercel catch-all API 入口
  • 清理错误的 vercel.json 重写规则

结论:路由问题已修复,但 AI 请求仍然超时。

2.4 第四层:模块加载

Vercel 尝试以 CJS 模式加载 ESM 产物,以及无法解析 workspace:* 依赖中的 .ts 源码。修复方式:

  • 创建 CJS 包装器 api/[[...route]].js 加载 dist/ 产物
  • 本地化 ConvexDataSource 逻辑,消除运行时对 workspace 源码的依赖

修复后 /api/health 稳定返回 200。

结论:模块加载问题已修复,但 POST /api/chat 仍然超时。

2.5 第五层:网络出口——真正的根因

/api/chat 路由中增加了分阶段日志和首包超时保护:

const CHAT_FIRST_EVENT_TIMEOUT_MS = 20_000;

const firstEventTimer = setTimeout(() => {
  if (!didReceiveFirstEvent) {
    didTimeoutBeforeFirstEvent = true;
    abortController.abort();
  }
}, CHAT_FIRST_EVENT_TIMEOUT_MS);

Vercel Runtime Logs 显示:

  • request_received ✅ 打出了
  • model_request_started ✅ 打出了
  • first_event_received ❌ 始终没出

请求进入了服务,也发起了对 DashScope 的调用,但首包永远收不到。300 秒后 Vercel 强制超时,返回 504 FUNCTION_INVOCATION_TIMEOUT

结论:Vercel Serverless 到 DashScope 的网络出口链路不稳定,请求卡在等待上游响应阶段。

3. 验证:本地秒回,线上卡死

本地启动 services/ai,测试 /api/chat

curl -s -N -X POST http://localhost:3001/api/chat \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"你好"}],"model":"qwen-plus"}' \
  --max-time 30

服务端日志:

[services/ai][chat][xxx] request_received
[services/ai][chat][xxx] model_request_started
[services/ai][chat][xxx] first_event_received  {"elapsedMs":657}    ← 657ms 首包
[services/ai][chat][xxx] stream_completed      {"elapsedMs":1714}   ← 1.7s 完成

本地 1.7 秒完成,首包 657ms。DashScope 服务本身完全正常。

100% 确认:问题在 Vercel 运行环境到 DashScope 的网络链路,不在代码。

4. 尝试修复:换区域,没用

Vercel 默认把函数跑在美东(iad1),到国内 DashScope 的链路确实很远。手动把 Function Region 改到香港(hkg1),确认配置生效后重新测试。

结果:仍然 504 超时。

区域确实有影响,但不是唯一根因。Vercel 的 Serverless Function 跑在 AWS Lambda 上,即使入口区域是 hkg1,网络出口的路由仍然可能绕远或不稳定。你无法控制 AWS 内部的流量调度。

5. 尝试修复:换 DashScope Endpoint,Key 不通用

DashScope 提供三个区域的 OpenAI 兼容接口:

区域 Base URL
北京(国内) https://dashscope.aliyuncs.com/compatible-mode/v1
弗吉尼亚(美国) https://dashscope-us.aliyuncs.com/compatible-mode/v1
新加坡(国际) https://dashscope-intl.aliyuncs.com/compatible-mode/v1

心想换成新加坡国际站 endpoint,从 Vercel 到新加坡应该更通。结果:

401 Incorrect API key provided

国内站和国际站的 API Key 完全隔离,互不通用。 你的 Key 是国内站申请的,只能用国内站 endpoint。要用国际站,得重新注册阿里云国际站账号、开通百炼、申请新 Key。

6. 最终方案:Edge Runtime

6.1 关键洞察

Vercel 上有两种运行代码的方式,它们跑在完全不同的基础设施上:

Serverless Function Edge Function
底层 AWS Lambda Cloudflare Workers
运行时 完整 Node.js V8 引擎(浏览器级)
冷启动 500ms ~ 几秒 < 5ms
网络出口 AWS 区域内网 Cloudflare 边缘网络
超时限制 10~300 秒 30 秒

Serverless 走 AWS 的网络出口到 DashScope 不通,不代表 Edge 走 Cloudflare 的网络出口也不通。 这是两条完全不同的网络路径。

6.2 实操:一行声明切换

services/ai/api/chat.ts 中创建 Edge 版入口:

import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
import { cors } from "hono/cors";
import OpenAI from "openai";

export const runtime = "edge";        // 关键:声明为 Edge Runtime
export const preferredRegion = "hkg1"; // 优先在香港执行

const app = new Hono().basePath("/api");
app.use("*", cors());

app.post("/chat", async (c) => {
  const openai = new OpenAI({
    apiKey: process.env.LLM_API_KEY,
    baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1",
  });

  return streamSSE(c, async (stream) => {
    const response = await openai.chat.completions.create({
      model: "qwen-plus",
      messages: [...],
      stream: true,
    });

    for await (const chunk of response) {
      const text = chunk.choices[0]?.delta?.content;
      if (text) {
        stream.writeSSE({ event: "content", data: JSON.stringify({ type: "content", text }) });
      }
    }
    stream.writeSSE({ event: "done", data: JSON.stringify({ type: "done" }) });
  });
});

export default app;

Vercel 的路由规则中,具体路径(api/chat.ts)优先于 catch-all(api/[[...route]].js),所以 /api/chat 走 Edge,其他路由继续走 Serverless。

6.3 结果

curl -s -N -X POST https://my-notion-ai.vercel.app/api/chat \
  -H "Content-Type: application/json" \
  -d '{"messages":[{"role":"user","content":"请用中文详细解释什么是向量数据库,至少200字"}],"model":"qwen-plus"}' \
  --max-time 30 -w '\nHTTP_CODE: %{http_code}\nTIME_TOTAL: %{time_total}s\n'
HTTP_CODE: 200
TIME_TOTAL: 3.658670s

从 300 秒超时到 3.66 秒完成。问题彻底解决。

7. 为什么 Web 端"偶尔慢"

Web 端的 AI 路由也跑在 Vercel Serverless 上,用的是同一个 AWS 网络出口。那为什么 Web 端只是"偶尔慢"而不是"必超时"?

原因有两个:

  1. Web 端是 Next.js 项目,Vercel 对 Next.js 有更好的优化(函数预热、增量静态生成),冷启动更快
  2. 偶尔慢 = 同一根因,只是因为 Next.js 的优化偶尔让请求抢在超时前完成

把 Web 端的 /api/chat/api/editor-ai/streamText 也迁移到 Edge Runtime 后,稳定性进一步提升。

8. Edge Runtime 的限制

Edge 不是万能的。它的核心限制是只能用 Web 标准 API

可用 不可用
fetchRequestResponse fs(文件系统)
ReadableStream require()(只能用 import
crypto.randomUUID() Node.js crypto.createHash()
setTimeout http/net 模块
openai SDK convex SDK
ai (Vercel AI SDK) @langchain/core
@clerk/nextjs/server serpapi

所以 /api/rag-stream/api/rag-complete 不能跑在 Edge 上——它们依赖 convex@langchain,内部用了 Node.js API。

桶导出陷阱

即使你的路由只用 streamChat,但如果通过桶导出 import:

import { streamChat } from "@notion/ai/server";

整个 server/index.ts 会被加载,包括 streamRAG(依赖 @langchain)和 ConvexDataSource(依赖 convex)。Edge 环境下,import 阶段就会报错,不管你调不调用。

解决方案:内联 OpenAI 调用,不走桶导出。这就是为什么 Edge 版的 /api/chat 直接用 openai SDK,而不是 import { streamChat } from "@notion/ai/server"

9. DashScope Base URL 也做了可配置化

顺便做了一个小改进——把 DashScope 的 Base URL 改为环境变量可配置:

// packages/ai/config/baseurl.ts
export const DASHSCOPE_BASE_URL =
  process.env.DASHSCOPE_BASE_URL ||
  "https://dashscope.aliyuncs.com/compatible-mode/v1";

这样如果后续注册了国际站 Key,只需设置环境变量 DASHSCOPE_BASE_URL=https://dashscope-intl.aliyuncs.com/compatible-mode/v1,不用改代码。

10. 总结:Serverless 调国内 API 的排查清单

如果你的 Vercel Serverless Function 调国内服务(DashScope、通义千问、文心一言等)遇到超时,按这个清单排查:

步骤 检查项 方法
1 前端代码是否正确 本地 curl 直接测后端 API
2 CORS 是否配置 检查 OPTIONS 请求是否返回 204
3 路由是否挂载 检查 health 接口是否正常
4 模块是否加载成功 检查 Vercel Runtime Logs 有无启动错误
5 上游是否可达 加分阶段日志,确认首包是否收到
6 本地是否正常 本地跑同一条链路对比
7 换 Edge Runtime export const runtime = "edge"

核心经验:

  • 浏览器 CORS 报错 ≠ CORS 问题,大概率是后端 500/504
  • Vercel Serverless 和 Edge 走不同的网络出口,一个不通不代表另一个也不通
  • 分阶段日志是排查超时问题的利器——没有日志,你只能猜
  • Edge Runtime 不是银弹,依赖 Node.js API 的路由不能迁移
  • 桶导出会把不兼容的代码拉进 Edge 环境,需要内联或拆分入口

一行 export const runtime = "edge",省了迁移服务器、注册国际站、改 DNS 的全部成本。


本文基于 My-Notion 项目的真实踩坑经历撰写——一个 AI 原生的个人版 Notion,Web + Mobile 双端架构,AI 服务部署在 Vercel 上。欢迎 Star ⭐

❌