普通视图

发现新文章,点击刷新页面。
今天 — 2026年5月3日首页

每日一题-旋转字符串🟢

2026年5月3日 00:00

给定两个字符串, 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%覆盖率而写测试,那是自欺欺人

作者 kyriewen
2026年5月2日 18:46

你写了测试,覆盖率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

作者 去伪存真
2026年5月2日 18:39

背景

用 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 仿盘项目让我踩了三天坑

作者 竹林818
2026年5月2日 18:00

背景

上个月,我接手了一个"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实现

作者 滕青山
2026年5月2日 17:57

这篇只讲本项目里“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 算法。

2026年5月2日 17:51

最快的 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 进阶使用技巧全解析

作者 技术崽崽
2026年5月2日 17:22

不止有 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 请求真机调试:从"线上没日志"到四层问题定位

作者 Daybreak
2026年5月2日 15:29

同一个 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)

作者 endlesscheng
2026年5月2日 15:10

方法一:暴力

无论 $s$ 如何旋转,旋转后的字符串一定是 $s+s$ 的子串。

例如 $s=\texttt{abcde}$ 旋转若干次后得到 $t=\texttt{cdeab}$,这是 $s+s=\texttt{abcdeabcde}$ 的子串。

所以问题等价于:

  • 判断 $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 构建鸿蒙应用【一】:架构困境与四层结构化设计

作者 LeesonWong
2026年5月2日 12:53

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. 交错字符串:动态规划详解

作者 Wect
2026年5月2日 12:30

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

【宫水三叶】简单模拟题

作者 AC_OIer
2022年4月7日 07:39

模拟

由于每次旋转操作都是将最左侧字符移动到最右侧,因此如果 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 哦 ~

旋转字符串

2022年4月6日 10:09

方法一:模拟

思路

首先,如果 $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)$,其他搜索子字符串的方法会略有差异。

昨天 — 2026年5月2日首页

科氪 | 张雪机车再夺冠军!荣耀宣布推出张雪机车冠军联名款手表

2026年5月2日 22:14

在2026世界超级摩托车锦标赛(WSBK)匈牙利站WorldSSP组别第一回合正赛中,中国摩托车制造商张雪机车的法国车手瓦伦丁·德比斯通过最后时刻的反超绝杀夺得冠军。赛后,荣耀CEO李健发文祝贺,并宣布作为张雪机车WSBK的首席战略合作品牌,荣耀将推出双方的冠军联名款手表,以庆祝这一夺冠时刻。 此前在4月初,荣耀已正式成为张雪机车WSBK首席战略合作品牌。荣耀首席营销官关海涛曾代表品牌向车队赠送荣耀WIN系列手机及荣耀WIN游戏本,寄望该系列产品能为车手带来胜利。荣耀此次通过与冠军联名,旨在借助国际赛事热度与效应,进一步提升品牌在年轻用户群体及运动场景中的影响力。 5月2日消息,在刚刚结束的2026世界超级摩托车锦标赛(WSBK)匈牙利站WorldSSP组别第一回合正赛中,中国摩托车制造商张雪机车的法国车手瓦伦丁·德比斯,最后时刻反超绝杀,强势夺冠。 赛后,荣耀CEO李健发文祝贺张雪机车夺冠,称这是一场“让所有观众都无比骄傲的比赛”。 与此同时,李健还宣布,作为张雪机车WSBK的首席战略合作品牌,荣耀将推出与张雪机车的冠军联名款手表,来庆祝这个历史性的夺冠时刻。

官方图片

今年4月初,荣耀已正式成为张雪机车WSBK首席战略合作品牌。 荣耀首席营销官关海涛当时还代表品牌向车队赠送荣耀WIN系列手机及荣耀WIN游戏本。 关海涛表示,希望荣耀WIN系列能为车手带来胜利(WIN)。 借助国际赛事热度与冠军效应,荣耀此番联名动作有望进一步提升品牌在年轻用户与运动场景中的影响力。

官方图片

卓驭于贝贝:向物理AI转型,是生存法则的必然选择 | 最前线

2026年5月2日 21:17

文|肖漫

编辑|李勤

当下的智能汽车领域,物理 AI 已成为高频词汇,绝大多数智能驾驶算法厂商都在往“物理AI”转型。

卓驭在北京车展上也发布了面向移动物理 AI 的原生多模态基础模型。在卓驭科技副总裁于贝贝看来,算法厂商向物理AI转型不是为了迎合资本市场而编织的想象空间,而是一条关乎厂商存亡的生存法则。

“如果不上这条技术路线,很可能今后就跑不出来了。”于贝贝说。

在新的竞争维度上,算法厂商的对手不再仅仅是曾经的同行,还包括那些从数字 AI 领域跨界而来的巨头、具身智能公司等。

这场全新的竞赛让算法厂商进入全新维度的淘汰赛中,而此次能真正跑出来的玩家,其商业空间也将随着打开。

基于移动基座模型,卓驭已经开始尝试打破传统Tier 1“卖硬件、收开发费”的单一逻辑。在第二增长曲线中,通过将乘用车技术拓展至 Robotaxi、RoboVan 等 L4 级领域,卓驭正在探索一种基于订阅、利润分成以及“动作令牌(Action Token)”的新商业形态。

近日,36氪汽车与卓驭科技副总裁于贝贝聊了聊物理AI的底层逻辑、商业化可能性,以及在这场即将开始的淘汰赛中,卓驭又该如何建立护城河。

以下是36氪汽车和卓驭科技副总裁于贝贝的交流内容,经编辑:

36氪:能否详细介绍一下原生多模态基础模型?

于贝贝:原生多模态这个概念的提出,可以追溯到去年我们开始做VLA 1.0,那时的做法比较接近视觉与动作对齐的模型,将大语言模型从后面附加上去的,因此存在很多问题,比如对语言和语义理解的局限性,以及响应延迟等。

我们认为把所有信息都转译到一个语言空间里去理解,然后再尝试通过这个语言转译的结果去理解物理世界,是一种反常识的做法。

真正合理的路径是,视觉、音频、动作都是一个模态、规则或推理也是一个模态,这些都应该在预训练阶段就一并加入,让模型能够天生地、在多种模态的共同空间里去理解物理世界,这才是更合适的做法。

36氪:现在有把语言模态拿掉吗?

于贝贝:当前我们车端模型确实还没有开放语言这一路输入。这和小鹏发布的VLA 2.0其实是类似的,我们做的是类似方向的东西,都在向这个范式切换,底层的骨干网络已经改变了。

36氪:卓驭也进入了VLA2.0的阶段?

于贝贝:是的。业界正处于一个范式切换的转折点,摆在我们面前的选择是:到底是沿着以前做专家模型这类小模型的范式继续做下去,还是果断切到大模型的范式上来。

我们比较看好大模型的范式。如果放在移动物理AI的语境下来看,希望移动能力能够在各种各样的载具上使用,这本质上就到达了规模化应用的阶段。

大语言模型的历史经验告诉我们,以前做视觉语言模型时,也有人做专家模型,有人做通用模型,也就是所谓的基座模型。

现在来看,最终跑出来的是做基座模型的这一批人。以前那些专注于看病的专家模型,其实都没有真正跑出来。在物理AI领域,我们相信演进的规律是一样的,因此我们也会���定地走基础模型的范式。

36氪:厂商很多玩家都在这么干,但目前也还未能真正训练出一个可以让各种不同载体统一接入的模型,本质上大家仍然是在解决车上的问题。

于贝贝:这是分阶段推进的。2025年,大家基本上都切换到了数据驱动,这意味着模型的基础能力已经达到了大概70分的水平。此时,想把它再提升到90分,那20分的差距仍然需要做后训练、采集数据和做泛化,但是其间的差距已经从当初的40分到80分,缩小为现在70分到90分的差距了。

后续,随着模型基础能力进一步提高,我们的目标肯定是做到零样本泛化,也就是所谓的“开箱即用”。

如果模型能力能够开箱就达到95分,那么后面的后训练、泛化、开城等工作几乎都可以忽略不计。虽然现在还没有到开箱95分的水平,但已经达到了开箱70分。

36氪:在现阶段,卓驭是否已经把各种场景都统一到同一个模型里实际运行过了,并认为它已经可以在各个领域都量产且实现泛化,还是说处于一个比较早期的阶段?

于贝贝:在这个时间点,还远不能说已经做到了开箱即用。什么才是物理AI最终的终极范式,什么样的架构才能真正理解物理世界,目前业界尚无定论。

36氪:您怎么看待当前大多数方案厂商都在向物理AI方向转型的现象?这是不是向资本市场讲一个更有想象空间的故事?

于贝贝:我们认为这已经不单单是商业或战略上的选择,最终应该会上升为一种生存法则层面的事情。如果不上这条技术路线,很可能今后就跑不出来了。

这和大语言模型爆发前夜一样,以前涌现出很多看病的专家模型,但通用大模型一出来,就把它们都替代掉了,以前的那些最终都没有跑出来。

36氪:在这个范式下做一个通用模型,但在其他场景下的数据,或者其他前期训练所需的条件,是不是还不够充分?

于贝贝:我们现在在训练自己的基础模型时,30%的数据来自于车辆采集的真实数据,30%来自于机器人,另外40%来自于互联网。

这种移动能力的数据,事实上在互联网上,只需要获取第一人称视角的、在移动中的视频即可,这不一定非得是乘用车或商用车,也可以人走路时拍摄的视频,这类数据的规模庞大,并且相对容易获取。

很多企业都宣称要做移动物理AI,模型能力固然是一方面,但更重要的,具身智能必须部署到一个具体的硬件上去,它的分发过程是很难的。它不像数字AI,可以通过手机实现一传十、十传百的病毒式传播,从一个用户迅速扩展到上亿用户,传播极快。

所以,建立一个分发平台和分发网络,也是其中非常关键的一环,这关乎如何把这个能力具体地部署到移动载具、部署到物理实体上。

36氪:卓驭在分发上是怎么做的?

于贝贝:我们有自己的一套方法,比如与合作伙伴合作,定义硬件的标准,将这个硬件标准定义出来之后,通过合作伙伴进行硬件授权与分发,这属于硬件分发的部分。

在软件分发方面,比如我们的移动能力SDK,可以将模型能力封装成SDK,提供给那些不具备后训练模型能力的合作伙伴去使用。也可以将其包装成“移动AI”,也就是把模型做得足够好之后,将其开源,让其他方可以基于这个模型去做后训练,这又是一种分发方式。

还可以直接做成“移动智能体”,未来对于一些低安全、低实时性的应用,比如扫地机器人或割草机,只需要把视频流传输到云端,由云端计算好之后,直接下发一条轨迹给这个小机器,这或许就是另一种分发方式了。

36氪:这几种分发的方式,是否对应着卓驭的商业收费模式?

于贝贝:是的,而且它们面向的商业场景也都不太一样。

传统的方式,像做乘用车或商用车,就是销售硬件、销售软件许可,并收取开发费和非重复性工程费用,我们内部称作第一增长曲线的业务。

第二增长曲线,则是将乘用车上已经验证过的技术,拓展到Robotaxi、RoboVan等领域。虽然也卖硬件,也可能收取开发费,但一般不收取软件许可费。

软件部分是通过利润分成来获取收益的,比如L4级业务,作为服务提供方,需要持续参与软件的迭代,甚至要参与到运营中去,所以需要一个持续性的收入,这就演变成了订阅和分润的模式。

36氪:听起来第二增长曲线更挣钱。

于贝贝:相比第一增长曲线的收入,其利润结构是要更好的。

我们可能会有不同的算法分发方式,以“移动智能体”为例,这种分发方式就有点像是在分发所谓的“动作令牌”。

相当于某个消费级电子设备将视频流传输给云端推理的模型,模型再下发一条轨迹,其收费模式可能就是按照该消费级设备的使用次数、行驶里程来收取类似“动作令牌”的费用,这又是另一种形式的订阅。

36氪:后续运维各方面的东西,都是卓驭来做吗?

于贝贝:对于L2的系统,本身不涉及到运维。只有到了L4级别才涉及运维,需要有一个所谓的远程监控系统,始终监控着车辆的运行过程,在必要时进行远程接管接入。

这有点像过去的安吉星服务,使用这个服务时是需要交费的。一旦车辆启用了L4功能,无论是干线物流还是乘用车,只要启用了L4,就需要额外交一部分费用。

甚至以后,乘用车的传感器配置、算力配置都能够支持L4级别时,平时车主可能还是用L2+系统,当他需要启用L4功能时,就需要为L4模式下每公里的行驶,额外再支付一点费用,因为始终会有一个系统在监控着它。

36氪:你认为L2和L4会是完全不一样的商业模式?

于贝贝:没错,L2和L4是完全不同的商业模式。从我们的观点来看,我们认为L4应该是先在城区落地,然后再拓展到高速场景。

从工程安全角度来看,同样性质的一个事故,在高速上产生的伤害程度,要远比在城区产生的伤害严重得多。

36氪:行业玩家都在往物理AI方向做,这是新一轮淘汰赛的开始吗?

于贝贝:新一轮的行业洗牌可能即将开始。所有做自动驾驶的公司,应该都会在不久的将来,转变为移动物理AI公司。

如果是在移动物理AI这个赛道上进行竞争,这本身就变成了一种跨界竞争,甚至可能都不是这个行业内既有玩家之间的竞争了,还需要和一些本来做数字AI,现在也想转型做具身智能、做物理AI的玩家去竞争。

36氪:那卓驭的护城河究竟是什么?

于贝贝:我们认为有两点。第一,是模型能力。现在大家的迭代范式,乃至最终采用什么样的模型架构,都还没有定论。也许我们认为以后特别高级的3D DiT或V-JEPA等全新架构会跑出来,但这些都是未知数。

第二,分发能力其实是一个非常高的门槛。如何建立一个分发平台和分发网络,创建一个生态,联合不同的合作伙伴共同进行分发,这一定是一个非常高的门槛。

在硅谷,中美具身公司聊了聊了4个问题的解法

2026年5月2日 21:01

文|周鑫雨

编辑|杨轩

规模化落地,今年的具身公司都在谈这个。

数字竞速,不约而同出现在具身公司的产线、招股书、出货量上——2026年4月以来,智元机器人宣布第1万台机器人量产下线,5000到10000,只用了三个多月;宇树科技的IPO招股书也摊开了激进商业化的一角:2025年营收17.07亿元,出货量超过5500台。

激进的数字背后,是“低价、高性能”的中国机器人在全球的扩张。宇树科技创始人王兴兴曾在2025年世界机器人大会上提到,过去几年,宇树的海外营收一直占总营收的50%以上。

在这些具身玩家中,魔法原子MagicLab近期提出了一个相当激进的营收目标:2036年,要实现140亿美元的营收规模。

在全球范围内打响品牌,也让这家公司,将发布会开进了硅谷。美西时间2026年4月28日,在云集Adobe、TikTok、IBM等公司的圣何塞,魔法原子发起了全球具身智能创新大会(GEIS)。

魔法原子机器人MagicBot Z1现场给张艺兴表演。作者拍摄

在会上,魔法原子发布了从底层模型本体的一系列新产品:

世界模型Magic-Mix:魔法原子自研的“自主进化模型”。Magix-Mix由两个引擎构成:让机器人学会理解真实世界的Magic-WAM,以及可以离线生成大批量许年数据的Magic-Creator——这意味着,Mix可以在“数据生成-模型训练-真实世界反馈-数据在生成”的闭环中持续自主迭代。

Magic-Mix架构。图源:魔法原子

灵巧手MagicHand H01:搭载了20 DOF(自由度,人手约24-27 DOF)和44个高分辨率三维触觉传感器,主打工业制造、服务护理等场景的精细操作。

MagicHand H01。图源:魔法原子

人形机器人MagicBot X1:一款身高180cm、体重70kg、全身搭载31个主动DOF、极限关节扭矩达450N·m的机器人。基于无限续航双电系统,X1可以7*24连续作业。产品分为标准版和科研版,前者商业部署效率高、开箱即用,后者则面向高校、实验室、开发者和产业伙伴,支持底层二次开发和外形定制。

MagicBot X1。图源:魔法原子

在会上,Openmind、PrismaX、Chestnut Roborics等来自硅谷的具身大脑和本体公司,也出现在现场。有关大脑、本体、数据的解决方案,这些公司给出了不同的解决思路。

以下是《智能涌现》关于现场讨论的整理:

用机器合成数据训练,效果会比真实世界数据更好吗?

高质量数据的稀缺,一直是掣肘具身模型训练的瓶颈。当前真机数据采集一直存在成本高、周期长、场景覆盖等问题。

机器合成数据,就是解决方案之一。然而,合成数据的局限性在于真实信息的缺失,比如摩擦系数、延迟、触觉反馈等。这也造成业界对“sim-to-real-gap”的担忧。

混合数据训练,是当下中美具身智能企业提出的主流解决方案。比如,魔法原子总裁顾诗韬介绍,魔法原子日均采集约16000条数据,再通过数据合成实现1万倍的体量扩展。她提到,由于产品迭代快、60%-70%的工序依赖人工,新能源汽车制造业,是数据采集的富矿

判断使用真实数据,还是机器合成数据,行业的共识是:基于具体训练目的和应用场景。

亚马逊前沿AI与机器人研究院科学家Haozhi Qi提到,合成数据适用于让机器学习单一的反应基本技能,但难以让机器获得类似于做早餐之类的长程技能。此时,引入真实数据训练是有必要的,因为构建一个足够丰富的模拟环境,成本很高。

英伟达GEAR Lab高级研究科学家Zhengyi Luo则透露,团队目前采用50%的模拟数据,用于基础训练;15%的动捕数据、25%的互联网视频数据,用于理解人类的动作;同时,训练还会添加10%的高质量真实世界数据。他还提到,有些公司甚至会使用社交媒体上的数据,来指导机器人的本体设计。

VLA(视觉-语言-行动)是具身“大脑”最好的解决方案吗?

由于强大的任务泛化能力,当下VLA已经成为具身模型最主流的架构范式。

但事实上,当人类用手指旋转一个篮球时,只用依靠触觉和本体感知,并不需要视觉——这意味着,VLA在这两个感知系统上,存在短板。

在GEIS大会上,亚马逊前沿AI与机器人研究院科学家Haozhi Qi认为,VLA的流行,与硬件传感器的发展程度有关:当下,视觉传感器趋于成熟,但触觉传感器还在初级开发阶段。

因此,在他看来,具身系统需要通过其他感觉的输入,来补足不太成熟的传感系统,从而维持本体的操作。因此,通过视觉和语言补足触觉缺陷的VLA,成了当下最好的解决方案之一。不过,未来随着传感器和硬件层面的发展,算法也会随之迭代。

灵巧手的三大路线之争:连杆、腱绳与直驱

当下,有关灵巧手设计的核心迷思是:要不要像人手?围绕这一命题,诞生了连杆、腱绳、直驱三种设计方案。

其中,“连杆”最不像人手,但胜在成本低、易于控制;“腱绳”最像人手,可以做精细化操作,但成本高、控制难。“直驱”则是一种折中方案,将驱动单元直接集成在每个关节上,但成本不低,同时力传导效率和热管理上仍然面临工程层面的挑战。

混合架构路线,则是近期兴起的灵巧手技术解决方案。Chestnut Robotics创始人、前Tesla Optimus灵巧手核心成员Evan Tao介绍,当下团队已经选择了混合架构路线,以可以完成精细化操作的腱绳结构为主,辅以AI控制和自主学习系统。未来的方案,“都会在灵活度和工程可靠性之间寻求平衡。”他提到。

机器人如何真正规模化落地?

在数据层,引入真实世界数据,依然被认为是让机器人真正理解应用场景、学习复杂任务操作的关键。

比如,XGSynBot CEO Zizheng Li提到,他们采取的混合数据策略,依然引入了少量高质真实世界数据,控制成本的同时,也能提升模型能力和泛化水平。

在系统层,XGSynBot CEO Zizheng Li认为,机器人需要从“单一功能设备”向“多任务通用平台”演进,比如XGSynBot的机械臂,带有6个Quick-chage的模块化系统,这样做的好处是,一台机器人可以在不同工序间灵活切换,提高落地场景的广泛性。

最后,OpenMind创始人、斯坦福大学生物工程副教授Jan Liphardt总结:机器人进入真实世界,越早越好

他发现,实验室环境无法模拟所有复杂的现实场景,比如过亮的光线、泥泞潮湿的地面、生锈的门铰链、多个系统同时运行的负载——这些复杂的真实场景,往往导致机器人在离开实验室后,出现系统故障。

因此,机器人落地前,不应该仅仅待在实验室中。Jan Liphardt建议,尽早让机器人在家庭、学校、机场、幼儿园和其他公共场景的实际部署中,收集交互数据,持续迭代。

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.

一季度5家上市券商净利翻番,“百亿营收俱乐部”成员增至4家

2026年5月2日 18:30
A股上市券商2026年一季度的业绩情况出炉。整体来看,营业收入方面,50家上市券商中,一季度营收超过100亿元的券商,从去年同期的2家增至4家,除了中信证券和国泰海通,广发证券、华泰证券也进入“百亿营收俱乐部”。净利润方面,2026年一季度,华创云信、信达证券、财达证券、财通证券、东北证券等5家券商的净利润同比翻番。2026年一季度数据显示,50家上市券商中,营业收入超过100亿元规模的共有4家,与2025年同期的2家相较增加2家,广发证券、华泰证券进入一季度“百亿营收俱乐部”。(澎湃)
❌
❌