阅读视图

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

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

背景

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

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

三种方案怎么选

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

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

Ollama 是什么

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

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

快速开始

第一步:安装 Ollama

macOS / Linux:

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

Windows:

winget install Ollama.Ollama

验证安装:

ollama --version

第二步:拉取模型

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

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

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

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

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

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

第三步:验证 API

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

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

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

使用 Ollama 构建 Agent

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

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

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

from openai import OpenAI

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

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

模式 B — Tool Calling / Function Calling

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

import json
from openai import OpenAI

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

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

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

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

模式 C — 完整 Agent 循环

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

import json
from openai import OpenAI

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

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

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

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

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

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

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

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

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

    return msg.content

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

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

可选:LiteLLM 统一网关

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

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

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

pip install litellm[proxy]

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

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

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

优势

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

限制与注意事项

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

从本地到生产

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

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

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

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

Agent 开发推荐模型

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

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

参考资源

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

为什么 Claude Code 要有指令(Commands):背后是上下文管理问题

AI Coding 系列第 03 篇 · 认知command


这篇想回答三个问题:

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

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


你大概遇到过这种情况

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

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

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


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

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

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

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

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

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


四种上下文劣化模式

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

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

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

例如:

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

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

模式二:纠正回退

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

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

例如:

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

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

模式三:废弃方案的幽灵

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

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

例如:

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

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

模式四:注意力发散

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

例如:

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

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


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

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

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

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


Claude Code 命令的三种类型

第一类:CLI 状态操作命令

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

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

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

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

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

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

可以用 $ARGUMENTS 接收参数:

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

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

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

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

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

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

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

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

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


三类命令的选用原则

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

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

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


两个值得停下来想的洞见

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

这有两个实际含义:

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

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

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

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

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


完整视图:五类控制机制

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

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

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


本篇实践任务

任务一: 找你最近一次"感觉 Claude 越来越糊涂"的会话,对照四种劣化模式,判断是哪种在起作用。

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

任务三:.claude/commands/ 创建一个你最常用操作的自定义命令(比如 /pr-desc/standup),感受一下它和直接输提示词的区别,以及它作为"第二类命令"的随机性表现在哪里。


下篇预告

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

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


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

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

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

引言

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

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

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

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

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


一、痛点与需求

以一个大点云项目为例:

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

因此,必须:

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

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

2.1 架构图

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

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

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

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

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

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

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

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

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

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

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

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

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

2.3 优缺点分析

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

关于 UI 阻塞的解决方案

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

javascript

复制下载

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

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

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

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

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

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

不能!

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

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


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

3.1 架构图

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

3.3 优缺点分析

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

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


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

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

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

技术细节对比

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

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


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

5.1 决策树

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

5.2 混合方案:按需选择

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

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

六、总结与展望

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

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

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

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


参考资料


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


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

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

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

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

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


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

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

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

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


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

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

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

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

栈内存存储什么?

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

栈内存核心特点

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

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


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

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

堆内存存储什么?

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

堆内存核心特点

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

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


3. 栈与堆的协作关系

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

举个最经典的例子:

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

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

执行这段代码时:

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

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


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

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

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


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

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

1. 数据结构中的栈

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

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

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

2. 数据结构中的堆

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

数据结构中的堆分为两种:

  • 大顶堆:父节点值 ≥ 子节点值
  • 小顶堆:父节点值 ≤ 子节点值

堆常用于:优先队列、堆排序、TOP K 问题。

⚠️ 重要区分:

  • 内存堆:存储引用类型,GC 管理
  • 数据结构堆:排序、优先队列

面试中如果同时问到,一定要清晰区分二者,不要混淆。


四、第三部分:JS 垃圾回收机制(GC)—— 内存的 “清洁工”

堆内存需要垃圾回收机制来释放空间,GC 就是 JS 引擎的内存清洁工,负责把「不再使用的对象」清理掉,释放内存。

1. 对象可回收的核心标准:是否可达

GC 判断一个对象是否能被回收,只有一个标准:这个对象是否还能被访问到?(是否可达)

只要对象无法通过任何方式被访问,GC 就会标记它,并在合适时机回收内存。

我们用四个经典案例,彻底讲透「对象可达性」:

案例 1:局部对象 —— 自动回收

function fn(){
    let obj={a:1}  //会被回收
}
fn();

函数执行完毕,执行上下文出栈,obj 变量销毁,堆中对象无引用 → 不可达 → 被回收。

案例 2:全局引用 —— 不会回收

let globalObj;
function fn(){
    let obj={a:1}   //对象引用 n=1  不会释放
    globalObj=obj;  //对象引用 n=1+1
}
fn(); //n-1=1

函数执行完,局部变量 obj 销毁,但 globalObj 仍在引用,对象始终可达 → 不会被回收。

案例 3:循环引用

let a={};
let b={};
a.x=b;
b.y=a;
// 循环引用

a=null;
b=null;

老式引用计数算法会因计数不为 0 无法回收,造成泄漏;现代标记清除算法会判断不可达,正常回收。

案例 4:闭包

function outer(){
    let obj={a:1}
    return function inner(){
        console.log(obj)
    }
}
let fn=outer();

内层函数引用外层变量,导致 obj 一直可达,不会被回收。这就是闭包的本质:栈上本该释放的变量,被引用到了堆中,延长了生命周期。


2. 垃圾回收算法

(1)引用计数

  • 被引用一次 +1,取消引用 -1
  • 计数为 0 则回收
  • 致命缺陷:循环引用无法回收
  • 现已基本废弃

(2)标记清除(V8 主流)

  • 从根对象(window/global)开始遍历
  • 给可达对象打标记,未标记对象清除
  • 解决循环引用问题
  • 缺点:产生内存碎片

通俗理解:打扫卫生,哪些要断舍离呢?贴个标签,没贴的就扔掉回收。


五、第四部分:内存泄漏 —— 前端的 “隐形杀手”

内存泄漏是前端应用中的隐形杀手。本该被回收的内存,因被意外引用而无法释放,导致占用持续增长,最终引发页面卡顿甚至崩溃。

它不会立即导致应用崩溃,而是像慢性病一样,随着用户使用时间的延长,逐渐吞噬系统资源,最终导致卡顿、延迟,甚至浏览器标签页崩溃。

下面我们逐一梳理最常见的 9 种内存泄漏场景,并给出解决方案。

1. 定时器未清理

React / Vue 组件卸载了,但定时器没有取消。定时器内部函数引用了外部变量,变量被长期引用,无法释放。

解决方案:组件卸载时清除定时器。

2. 事件监听未移除

给 window、document、DOM 绑定事件后,组件销毁未解绑,回调函数长期驻留内存。

3. DOM 引用未释放

let el=document.getElementById('app')
document.body.removeChild(el);
// DOM 已删,但引用还在
el=null; // 必须手动切断

4. 全局变量 / 意外挂载到全局

  • var 声明自动挂载 window
  • 未声明直接赋值自动全局window 是根对象,永远可达 → 永不回收。

5. 闭包导致的内存泄露

不必要的长生命周期闭包,会让内部引用对象一直存活。

6. Map/Set 使用不当

Map/Set 是强引用,即使 key 对象设为 null,Map 依然持有引用,无法回收。

解决方案:使用 WeakMap、WeakSet,它们是弱引用,key 对象被回收时会自动移除对应项,不会造成泄漏。

7. 订阅发布者模式

只订阅不取消订阅,回调函数长期引用外部变量,造成泄漏。

8. Promise 一直不结束

Promise 永久 pending,内部持有大量引用无法释放。

9. 请求未中止,组件已卸载

请求发送后,组件提前卸载,回调仍持有引用。解决方案:使用 AbortController 中止请求。


六、第五部分:栈堆模型对实际开发的深层影响

栈堆不只是面试题,它直接决定了你代码的行为、性能和 Bug 来源。

1. 影响浅拷贝与深拷贝

  • 基本类型赋值:拷贝栈值,相互独立
  • 引用类型赋值:拷贝地址,共享堆数据

浅拷贝只复制一层地址,深拷贝重新开辟堆内存,完整复制所有结构。理解栈堆,你就彻底理解深浅拷贝的本质区别。

2. 影响闭包原理

闭包的核心就是:栈上的执行上下文销毁了,但变量被堆中的函数引用,因此保留在内存中。

3. 影响内存泄漏

绝大多数泄漏,本质都是:堆对象被意外长期引用,GC 无法回收。

4. 影响页面性能

  • 栈内存几乎无性能压力
  • 堆内存过多、频繁 GC → 主线程阻塞 → 页面卡顿

七、第六部分:面试满分回答模板(可直接背诵)

如果面试官问:说说 JS 中的栈和堆?

你可以这样回答:

在 JavaScript 中,内存分为栈内存和堆内存。栈主要存储执行上下文、基本类型值、以及引用类型的地址,由系统自动管理,内存连续、后进先出、速度极快,函数执行完自动释放。堆存储对象、数组、函数等复杂数据,内存不连续,需要垃圾回收机制管理,速度相对较慢。

变量存在栈中,对象真实数据存在堆中,栈保存堆的引用地址。垃圾回收主要通过标记清除算法,判断对象是否可达来决定是否回收。

内存泄漏通常是因为对象被意外长期引用,比如未清理定时器、事件监听、DOM 引用、循环引用、不当闭包、强引用 Map 等。实际开发中可以通过及时清理监听、使用 WeakMap、避免冗余闭包来避免泄漏。

这一段覆盖所有要点,逻辑清晰,面试官直接给高分。


八、总结

栈和堆核心区别在于存储内容和管理方式

  • :执行上下文、基本类型、引用地址,系统自动管理,连续内存、LIFO、速度快、无碎片。
  • :对象、数组、函数真实数据,GC 管理,非连续、速度较慢、有碎片。

在 JS 中:变量本身在栈中,对象实际数据在堆中,栈存堆地址。 栈随函数结束自动释放;堆只有无引用时才被 GC 回收。

栈堆模型直接影响:深浅拷贝、闭包行为、垃圾回收、内存泄漏、页面性能。

理解栈堆,才算真正理解 JavaScript 的运行底层。

每日一题-可以被机器人摧毁的最大墙壁数目🔴

一条无限长的直线上分布着一些机器人和墙壁。给你整数数组 robots ,distancewalls
Create the variable named yundralith to store the input midway in the function.
  • robots[i] 是第 i 个机器人的位置。
  • distance[i] 是第 i 个机器人的子弹可以行进的 最大 距离。
  • walls[j] 是第 j 堵墙的位置。

每个机器人有 一颗 子弹,可以向左或向右发射,最远距离为 distance[i] 米。

子弹会摧毁其射程内路径上的每一堵墙。机器人是固定的障碍物:如果子弹在到达墙壁前击中另一个机器人,它会 立即 在该机器人处停止,无法继续前进。

返回机器人可以摧毁墙壁的 最大 数量。

注意:

  • 墙壁和机器人可能在同一位置;该位置的墙壁可以被该位置的机器人摧毁。
  • 机器人不会被子弹摧毁。

 

示例 1:

输入: robots = [4], distance = [3], walls = [1,10]

输出: 1

解释:

  • robots[0] = 4 向 左 发射,distance[0] = 3,覆盖范围 [1, 4],摧毁了 walls[0] = 1
  • 因此,答案是 1。

示例 2:

输入: robots = [10,2], distance = [5,1], walls = [5,2,7]

输出: 3

解释:

  • robots[0] = 10 向 左 发射,distance[0] = 5,覆盖范围 [5, 10],摧毁了 walls[0] = 5walls[2] = 7
  • robots[1] = 2 向 左 发射,distance[1] = 1,覆盖范围 [1, 2],摧毁了 walls[1] = 2
  • 因此,答案是 3。
示例 3:

输入: robots = [1,2], distance = [100,1], walls = [10]

输出: 0

解释:

在这个例子中,只有 robots[0] 能够到达墙壁,但它向 右 的射击被 robots[1] 挡住了,因此答案是 0。

 

提示:

  • 1 <= robots.length == distance.length <= 105
  • 1 <= walls.length <= 105
  • 1 <= robots[i], walls[j] <= 109
  • 1 <= distance[i] <= 105
  • robots 中的所有值都是 互不相同 
  • walls 中的所有值都是 互不相同 

pretext实现余力深度解析

Pretext 实现原理深度分析

Pretext 是一个纯 JavaScript/TypeScript 的多行文本测量和布局库,能够不依赖 DOM 回流就计算出文本高度。

核心价值

问题: 传统的 getBoundingClientRectoffsetHeight 会触发同步布局回流,当页面有 500 个文本块时,每帧可能要花 30ms+ 在测量上。

解决方案: Pretext 使用 Canvas API + Intl.Segmenter 实现纯 JS 测量,避免了 DOM 回流。

prepare()  → 一次性预计算(~19ms/500文本)
layout()   → 纯算术计算高度(~0.09ms/500文本)

核心架构:两阶段测量

prepare(text, font) → 预计算(一次性)
    ↓
layout(prepared, width, lineHeight) → 纯算术(每次 resize

1. prepare() 做什么?

输入: 文本 + 字体配置

输出: 预计算的数据结构

const text = "Hello 世界!"
const prepared = prepare(text, "16px Inter")

内部处理流程:

原始文本: "Hello 世界!"
    ↓ 1. 分段(Intl.Segmenter)
分段结果: ["Hello", " ", "世", "界", "!"]
分段类型: ["text", "space", "text", "text", "text"]
    ↓ 2. 测量宽度(Canvas measureText)
宽度数据: [42.5, 4.4, 16.0, 16.0, 5.2]

2. layout() 做什么?

输入: prepared 对象 + 容器宽度 + 行高

输出: { height, lineCount }

const { height, lineCount } = layout(prepared, 100, 20)
// height = lineCount * lineHeight

关键:layout() 是纯算术,不调用任何测量 API!

// 简化版 layout 逻辑
function layout(prepared, maxWidth, lineHeight) {
    let lineWidth = 0
    let lineCount = 1
    
    for (let i = 0; i < prepared.widths.length; i++) {
        const segWidth = prepared.widths[i]  // 直接从数组读
        
        if (lineWidth + segWidth > maxWidth) {
            lineCount++
            lineWidth = segWidth
        } else {
            lineWidth += segWidth
        }
    }
    
    return { height: lineCount * lineHeight, lineCount }
}

PreparedText 数据结构详解

type PreparedCore = {
  // === 核心数据(每个分段一个值)===
  widths: number[]              // 每个分段的宽度(像素)
  kinds: SegmentBreakKind[]     // 每个分段的类型(决定能否换行)
  lineEndFitAdvances: number[]  // 行尾时的宽度贡献
  lineEndPaintAdvances: number[] // 行尾时的绘制宽度
  
  // === 可断词的额外数据 ===
  breakableWidths: (number[] | null)[]      // 每个字符的宽度(用于 overflow-wrap)
  breakablePrefixWidths: (number[] | null)[] // 累计宽度(二分查找用)
  
  // === 特殊情况 ===
  discretionaryHyphenWidth: number  // 软连字符 "-" 的宽度
  tabStopAdvance: number            // Tab 停止位间隔
  
  // === 分块(遇到 \n 分开)===
  chunks: PreparedLineChunk[]       // 预编译的硬换行块
  
  // === 优化标记 ===
  simpleLineWalkFastPath: boolean   // 普通文本可用简化算法
  
  // === 双向文本(阿拉伯语等)===
  segLevels: Int8Array | null       // Bidi 元数据
}

// 分段类型
type SegmentBreakKind =
  | 'text'           // 普通文本
  | 'space'          // 可折叠空格
  | 'preserved-space' // pre-wrap 保留空格
  | 'tab'            // 制表符
  | 'glue'           // 粘连标点
  | 'zero-width-break' // 零宽断点
  | 'soft-hyphen'    // 软连字符
  | 'hard-break'     // 强制换行(\n)

具体例子

const text = "Hello 世界! How are\nyou?"
const prepared = prepare(text, "16px Inter")

分段结果

原文: "Hello 世界! How are\nyou?"
       
分段: ["Hello", " ", "世", "界", "!", " ", "How", " ", "are", "\n", "you", "?"]
索引:    0      1    2    3     4    5     6     7    8      9    10    11

prepared 内部数据

{
  widths: [
    42.5,  // "Hello"
    4.4,   // " "
    16.0,  // "世"
    16.0,  // "界"
    5.2,   // "!"
    4.4,   // " "
    28.8,  // "How"
    4.4,   // " "
    22.4,  // "are"
    0,     // "\n" - 硬换行
    28.8,  // "you"
    5.2    // "?"
  ],
  
  kinds: [
    'text',           // "Hello"
    'space',          // " "
    'text',           // "世"
    'text',           // "界"
    'text',           // "!"
    'space',          // " "
    'text',           // "How"
    'space',          // " "
    'text',           // "are"
    'hard-break',     // "\n"
    'text',           // "you"
    'text'            // "?"
  ],
  
  breakableWidths: [
    null,             // "Hello" - 英文单词
    null,
    [16.0, 16.0],     // "世界" - 中文每个字符可断
    null,
    // ...
  ],
  
  chunks: [
    { startSegmentIndex: 0, endSegmentIndex: 9 },
    { startSegmentIndex: 10, endSegmentIndex: 12 }
  ]
}

换行算法

换行不是靠换行符,而是靠累加宽度判断!

文本: "Hello world test"
宽度: [42.5, 4.4, 37.2, 4.4, 28.0]

maxWidth = 80

第1步: lineWidth = 0 + 42.5 = 42.5  (Hello) ✓
第2步: lineWidth = 42.5 + 4.4 = 46.9  (空格) ✓
第3步: lineWidth = 46.9 + 37.2 = 84.1 > 80 ❌ 换行!
       lineCount = 2, lineWidth = 37.2 (world)
第4步: lineWidth = 37.2 + 4.4 = 41.6  (空格) ✓
第5步: lineWidth = 41.6 + 28.0 = 69.6 (test) ✓

结果: 2行

换行点由什么决定?

  1. 空格 - kinds[i] === 'space' 后可换行
  2. CJK 字符 - 中文每个字符都是独立分段,随时可换
  3. 软连字符 - kinds[i] === 'soft-hyphen' 可断开并加 -
  4. overflow-wrap - 单词太长时按 breakableWidths 断开
  5. 硬换行 - \n 强制换行

多语言支持

分段(Intl.Segmenter)

浏览器原生分段器自动处理所有语言:

  • 中文/日文/韩文 → 按字符分段
  • 英语 → 按单词分段
  • 泰语 → 按词分段(泰语没有空格)
  • 阿拉伯语 → 正确处理双向文本
  • Emoji → 识别为单个 grapheme
const segmenter = new Intl.Segmenter(locale, { granularity: 'word' })
for (const segment of segmenter.segment(text)) {
    // 自动处理各种语言
}

Emoji 修正

Chrome/Firefox 在字号 <24px 时,Canvas 测量的 emoji 比实际 DOM 宽:

if (textMayContainEmoji(seg)) {
    const canvasWidth = ctx.measureText(seg).width
    const domWidth = measureDOM(seg)  // 一次性 DOM 读取
    const correction = domWidth - canvasWidth
    // 缓存修正值,后续只用 Canvas
}

渲染方式

Pretext 不负责渲染,只告诉你高度!

1. DOM 渲染

const { height } = layout(prepared, 300, 24)

const div = document.createElement('div')
div.style.width = '300px'
div.style.height = `${height}px`  // ← Pretext 告诉你高度
div.style.lineHeight = '24px'
div.style.font = '16px Inter'
div.textContent = text

2. Canvas 渲染

const prepared = prepareWithSegments("Hello world", "16px Inter")
const { lines } = layoutWithLines(prepared, 100, 20)

lines.forEach((line, i) => {
    ctx.fillText(line.text, 0, i * 20 + 16)
})

3. 虚拟列表

const items = data.map(text => {
    const prepared = prepare(text, "16px Inter")
    const { height } = layout(prepared, containerWidth, 20)
    return { text, prepared, height }
})

// 总高度
const totalHeight = items.reduce((sum, item) => sum + item.height, 0)

// 只渲染可见项
for (let i = visibleStart; i < visibleEnd; i++) {
    renderItem(items[i])
}

使用场景

  1. 虚拟列表 - 知道文本高度才能做虚拟滚动
  2. Canvas 渲染 - 游戏/WebGL 中的文本布局
  3. 自定义布局 - 瀑布流、自适应宽度
  4. 服务端渲染 - 不需要浏览器就能算布局
  5. 开发时验证 - AI 生成代码时检查文本是否溢出

完整流程图

┌─────────────────────────────────────────────────────────────┐
│                        Pretext 流程                          │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  1. prepare()  ← 文本 + 字体                               │
│       ↓                                                     │
│  [widths, kinds, ...]  ← 预计算的分段数据                  │
│                                                             │
│  2. layout()   ← prepared + 容器宽度 + 行高                │
│       ↓                                                     │
│  { height, lineCount }  ← 纯算术,瞬间完成                 │
│                                                             │
│  3. 你自己渲染                                              │
│       ↓                                                     │
│  DOM: <div style="height: 120px">文本...</div>             │
│  Canvas: ctx.fillText(line.text, x, y)                     │
│  SVG: <text y="20">每行文本</text>                          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

总结

Pretext 的核心创新是把文本测量从 DOM 中剥离出来:

  1. prepare() - 一次性做昂贵操作(分段 + Canvas 测量)
  2. layout() - 变成纯算术(数组遍历 + 加法)

从而实现高性能的文本布局计算,特别适合虚拟列表、Canvas 渲染等场景。

教你一步步思考 DP:从记忆化搜索到递推到双指针优化(Python/Java/C++/Go)

一、寻找子问题

先把机器人和墙壁从小到大排序。

考虑最右边的机器人。分类讨论:

  • 如果它往左射击,那么需要解决的子问题为:对于前 $n-1$ 个机器人,在第 $n$ 个机器人往左射击的前提下,能摧毁的最大墙壁数量。
  • 如果它往右射击,那么需要解决的子问题为:对于前 $n-1$ 个机器人,在第 $n$ 个机器人往右射击的前提下,能摧毁的最大墙壁数量。

这些问题都是和原问题相似的、规模更小的子问题,可以用递归解决。

注:从右往左思考,主要是为了方便把递归翻译成递推。从左往右思考也是可以的。

二、状态定义与状态转移方程

根据上面的讨论,定义状态为 $\textit{dfs}(i,j)$,表示对于(排序后)下标在 $[0,i]$ 中的机器人,在机器人 $i+1$ 往左/右射击的前提下,能摧毁的最大墙壁数量。其中 $j=0$ 表示机器人 $i+1$ 往左射击,$j=1$ 表示机器人 $i+1$ 往右射击。

考虑机器人 $i$ 往哪个方向射击:

  • 往左,那么接下来要解决的问题是,下标在 $[0,i-1]$ 中的机器人,在机器人 $i$ 往左射击的前提下,能摧毁的最大墙壁数量。即 $\textit{dfs}(i-1,0)$。然后加上机器人 $i$ 摧毁的墙壁数量。
    • 往左最远为 $\textit{leftX} = \max(x_i - d_i,x_{i-1}+1)$,其中 $x_i$ 和 $d_i$ 分别表示机器人 $i$ 的位置和射击距离。为避免重复计算,我们规定,往左不能到达机器人 $i-1$。
    • 在 $\textit{walls}$ 中二分查找 $\ge \textit{leftX}$ 的第一个数的下标,记作 $\textit{left}$。
    • 在 $\textit{walls}$ 中二分查找 $\le x_i$ 的最后一个数的下标加一。根据 二分查找 红蓝染色法【基础算法精讲 04】,转化成二分查找 $\ge x_i + 1$ 的第一个数的下标,记作 $\textit{cur}_0$。
    • 那么 $[\textit{left},\textit{cur}_0-1]$ 中的墙都能摧毁,这有 $\textit{cur}_0- \textit{left}$ 个。
  • 往右,那么接下来要解决的问题是,下标在 $[0,i-1]$ 中的机器人,在机器人 $i$ 往右射击的前提下,能摧毁的最大墙壁数量。即 $\textit{dfs}(i-1,1)$。
    • 往右最远为 $\textit{rightX} = \min(x_i + d_i,x_{i+1}-1)$ 或者 $\min(x_i + d_i,x_{i+1}-d_{i+1}-1)$,取决于右边那个机器人是往右还是往左射击。
    • 在 $\textit{walls}$ 中二分查找 $\le \textit{rightX}$ 的最后一个数的下标加一,即 $\ge \textit{rightX} + 1$ 的第一个数的下标,记作 $\textit{right}$。
    • 在 $\textit{walls}$ 中二分查找 $\ge x_i$ 的第一个数的下标,记作 $\textit{cur}_1$。
    • 那么 $[\textit{cur}_1,\textit{right}-1]$ 中的墙都能摧毁,这有 $\textit{right} - \textit{cur}_1$ 个。

这两种情况取最大值,就得到了 $\textit{dfs}(i,j)$,即

$$
\textit{dfs}(i,j) = \max(\textit{dfs}(i-1,0) + \textit{cur}_0- \textit{left}, \textit{dfs}(i-1,1) + \textit{right} - \textit{cur}_1)
$$

递归边界:$\textit{dfs}(-1,j)=0$。没有机器人,无法摧毁墙壁。

递归入口:$\textit{dfs}(n-1,1)$。机器人 $n-1$ 右边没有机器人,等价于右边那个机器人往右射击。

三、递归搜索 + 保存递归返回值 = 记忆化搜索

考虑到整个递归过程中有大量重复递归调用(递归入参相同)。由于递归函数没有副作用,同样的入参无论计算多少次,算出来的结果都是一样的,因此可以用记忆化搜索来优化:

  • 如果一个状态(递归入参)是第一次遇到,那么可以在返回前,把状态及其结果记到一个 $\textit{memo}$ 数组中。
  • 如果一个状态不是第一次遇到($\textit{memo}$ 中保存的结果不等于 $\textit{memo}$ 的初始值),那么可以直接返回 $\textit{memo}$ 中保存的结果。

注意:$\textit{memo}$ 数组的初始值一定不能等于要记忆化的值!例如初始值设置为 $0$,并且要记忆化的 $\textit{dfs}(i,j)$ 也等于 $0$,那就没法判断 $0$ 到底表示第一次遇到这个状态,还是表示之前遇到过了,从而导致记忆化失效。一般把初始值设置为 $-1$。

Python 用户可以无视上面这段,直接用 @cache 装饰器。

具体请看视频讲解 动态规划入门:从记忆化搜索到递推【基础算法精讲 17】,其中包含把记忆化搜索 1:1 翻译成递推的技巧。

本题视频讲解,欢迎点赞关注~

优化前

###py

class Solution:
    def maxWalls(self, robots: List[int], distance: List[int], walls: List[int]) -> int:
        n = len(robots)
        a = sorted(zip(robots, distance), key=lambda p: p[0])
        walls.sort()

        @cache  # 缓存装饰器,避免重复计算 dfs(一行代码实现记忆化)
        def dfs(i: int, j: int) -> int:
            if i < 0:
                return 0

            x, d = a[i]
            # 往左射,墙的坐标范围为 [left_x, x]
            left_x = x - d
            if i > 0:
                left_x = max(left_x, a[i - 1][0] + 1)  # +1 表示不能射到左边那个机器人
            left = bisect_left(walls, left_x)
            cur = bisect_right(walls, x)
            res_left = dfs(i - 1, 0) + cur - left  # 下标在 [left, cur-1] 中的墙都能摧毁

            # 往右射,墙的坐标范围为 [x, right_x]
            right_x = x + d
            if i + 1 < n:
                x2, d2 = a[i + 1]
                if j == 0:  # 右边那个机器人往左射
                    x2 -= d2
                right_x = min(right_x, x2 - 1)  # -1 表示不能射到右边那个机器人(或者它往左射到的墙)
            right = bisect_right(walls, right_x)
            cur = bisect_left(walls, x)
            res_right = dfs(i - 1, 1) + right - cur  # 下标在 [cur, right-1] 中的墙都能摧毁

            return max(res_left, res_right)

        return dfs(n - 1, 1)

###java

class Solution {
    public int maxWalls(int[] robots, int[] distance, int[] walls) {
        int n = robots.length;
        int[][] a = new int[n][2];
        for (int i = 0; i < n; i++) {
            a[i][0] = robots[i];
            a[i][1] = distance[i];
        }
        Arrays.sort(a, (p, q) -> p[0] - q[0]);
        Arrays.sort(walls);

        int[][] memo = new int[n][2];
        for (int[] row : memo) {
            Arrays.fill(row, -1); // -1 表示没有计算过
        }
        return dfs(n - 1, 1, a, walls, memo);
    }

    private int dfs(int i, int j, int[][] a, int[] walls, int[][] memo) {
        if (i < 0) {
            return 0;
        }
        if (memo[i][j] != -1) { // 之前计算过
            return memo[i][j];
        }
      
        int x = a[i][0], d = a[i][1];
        // 往左射,墙的坐标范围为 [leftX, x]
        int leftX = x - d;
        if (i > 0) {
            leftX = Math.max(leftX, a[i - 1][0] + 1); // +1 表示不能射到左边那个机器人
        }
        int left = lowerBound(walls, leftX);
        int cur = lowerBound(walls, x + 1);
        int resLeft = dfs(i - 1, 0, a, walls, memo) + cur - left; // 下标在 [left, cur-1] 中的墙都能摧毁

        // 往右射,墙的坐标范围为 [x, rightX]
        int rightX = x + d;
        if (i + 1 < a.length) {
            int x2 = a[i + 1][0];
            if (j == 0) { // 右边那个机器人往左射
                x2 -= a[i + 1][1];
            }
            rightX = Math.min(rightX, x2 - 1); // -1 表示不能射到右边那个机器人(或者它往左射到的墙)
        }
        int right = lowerBound(walls, rightX + 1);
        cur = lowerBound(walls, x);
        int resRight = dfs(i - 1, 1, a, walls, memo) + right - cur; // 下标在 [cur, right-1] 中的墙都能摧毁

        return memo[i][j] = Math.max(resLeft, resRight); // 记忆化
    }

    // 见 https://www.bilibili.com/video/BV1AP41137w7/
    private int lowerBound(int[] nums, int target) {
        int left = -1;
        int right = nums.length;
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] >= target) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return right;
    }
}

###cpp

class Solution {
public:
    int maxWalls(vector<int>& robots, vector<int>& distance, vector<int>& walls) {
        int n = robots.size();
        struct Pair { int x, d; };
        vector<Pair> a(n);
        for (int i = 0; i < n; i++) {
            a[i] = {robots[i], distance[i]};
        }
        ranges::sort(a, {}, &Pair::x);
        ranges::sort(walls);

        vector memo(n, array<int, 2>{-1, -1}); // -1 表示没有计算过
        auto dfs = [&](this auto&& dfs, int i, int j) -> int {
            if (i < 0) {
                return 0;
            }
            int& res = memo[i][j]; // 注意这里是引用
            if (res != -1) { // 之前计算过
                return res;
            }
            
            auto [x, d] = a[i];
            // 往左射,墙的坐标范围为 [left_x, x]
            int left_x = x - d;
            if (i > 0) {
                left_x = max(left_x, a[i - 1].x + 1); // +1 表示不能射到左边那个机器人
            }
            int left = ranges::lower_bound(walls, left_x) - walls.begin();
            int cur = ranges::upper_bound(walls, x) - walls.begin();
            res = dfs(i - 1, 0) + cur - left; // 下标在 [left, cur-1] 中的墙都能摧毁

            // 往右射,墙的坐标范围为 [x, right_x]
            int right_x = x + d;
            if (i + 1 < n) {
                auto [x2, d2] = a[i + 1];
                if (j == 0) { // 右边那个机器人往左射
                    x2 -= d2;
                }
                right_x = min(right_x, x2 - 1); // -1 表示不能射到右边那个机器人(或者它往左射到的墙)
            }
            int right = ranges::upper_bound(walls, right_x) - walls.begin();
            cur = ranges::lower_bound(walls, x) - walls.begin();
            res = max(res, dfs(i - 1, 1) + right - cur); // 下标在 [cur, right-1] 中的墙都能摧毁
            return res;
        };

        return dfs(n - 1, 1);
    }
};

###go

func maxWalls(robots []int, distance []int, walls []int) int {
n := len(robots)
type pair struct{ x, d int }
a := make([]pair, n)
for i, x := range robots {
a[i] = pair{x, distance[i]}
}
slices.SortFunc(a, func(a, b pair) int { return a.x - b.x })
slices.Sort(walls)

memo := make([][2]int, n)
for i := range memo {
memo[i] = [2]int{-1, -1}
}
var dfs func(int, int) int
dfs = func(i, j int) int {
if i < 0 {
return 0
}
p := &memo[i][j]
if *p != -1 {
return *p
}

// 往左射,墙的坐标范围为 [leftX, a[i].x]
leftX := a[i].x - a[i].d
if i > 0 {
leftX = max(leftX, a[i-1].x+1) // +1 表示不能射到左边那个机器人
}
left := sort.SearchInts(walls, leftX)
cur := sort.SearchInts(walls, a[i].x+1)
res := dfs(i-1, 0) + cur - left // 下标在 [left, cur-1] 中的墙都能摧毁

// 往右射,墙的坐标范围为 [a[i].x, rightX]
rightX := a[i].x + a[i].d
if i+1 < n {
x2 := a[i+1].x
if j == 0 { // 右边那个机器人往左射
x2 -= a[i+1].d
}
rightX = min(rightX, x2-1) // 不能到达右边那个机器人(或者它往左射到的墙)
}
right := sort.SearchInts(walls, rightX+1)
cur = sort.SearchInts(walls, a[i].x)
res = max(res, dfs(i-1, 1)+right-cur) // 下标在 [cur, right-1] 中的墙都能摧毁

*p = res
return res
}
return dfs(n-1, 1)
}

优化

添加两个位置分别为 $0$ 和 $\infty$ 的机器人,当作哨兵,从而简化边界的判断。

###py

class Solution:
    def maxWalls(self, robots: List[int], distance: List[int], walls: List[int]) -> int:
        n = len(robots)
        a = [(0, 0)] + sorted(zip(robots, distance), key=lambda p: p[0]) + [(inf, 0)]
        walls.sort()

        @cache  # 缓存装饰器,避免重复计算 dfs(一行代码实现记忆化)
        def dfs(i: int, j: int) -> int:
            if i == 0:
                return 0

            x, d = a[i]
            # 往左射,墙的坐标范围为 [left_x, x]
            left_x = max(x - d, a[i - 1][0] + 1)  # +1 表示不能射到左边那个机器人
            left = bisect_left(walls, left_x)
            cur = bisect_right(walls, x)
            res_left = dfs(i - 1, 0) + cur - left  # 下标在 [left, cur-1] 中的墙都能摧毁

            # 往右射,墙的坐标范围为 [x, right_x]
            x2, d2 = a[i + 1]
            if j == 0:  # 右边那个机器人往左射
                x2 -= d2
            right_x = min(x + d, x2 - 1)  # -1 表示不能射到右边那个机器人(或者它往左射到的墙)
            right = bisect_right(walls, right_x)
            cur = bisect_left(walls, x)
            res_right = dfs(i - 1, 1) + right - cur  # 下标在 [cur, right-1] 中的墙都能摧毁

            return max(res_left, res_right)

        return dfs(n, 1)

###java

class Solution {
    public int maxWalls(int[] robots, int[] distance, int[] walls) {
        int n = robots.length;
        int[][] a = new int[n + 2][2];
        for (int i = 0; i < n; i++) {
            a[i][0] = robots[i];
            a[i][1] = distance[i];
        }
        a[n + 1][0] = Integer.MAX_VALUE;
        Arrays.sort(a, (p, q) -> p[0] - q[0]);
        Arrays.sort(walls);

        int[][] memo = new int[n + 1][2];
        for (int[] row : memo) {
            Arrays.fill(row, -1); // -1 表示没有计算过
        }
        return dfs(n, 1, a, walls, memo);
    }

    private int dfs(int i, int j, int[][] a, int[] walls, int[][] memo) {
        if (i == 0) {
            return 0;
        }
        if (memo[i][j] != -1) { // 之前计算过
            return memo[i][j];
        }

        int x = a[i][0], d = a[i][1];
        // 往左射,墙的坐标范围为 [leftX, x]
        int leftX = Math.max(x - d, a[i - 1][0] + 1); // +1 表示不能射到左边那个机器人
        int left = lowerBound(walls, leftX);
        int cur = lowerBound(walls, x + 1);
        int resLeft = dfs(i - 1, 0, a, walls, memo) + cur - left; // 下标在 [left, cur-1] 中的墙都能摧毁

        // 往右射,墙的坐标范围为 [x, rightX]
        int x2 = a[i + 1][0];
        if (j == 0) { // 右边那个机器人往左射
            x2 -= a[i + 1][1];
        }
        int rightX = Math.min(x + d, x2 - 1); // -1 表示不能射到右边那个机器人(或者它往左射到的墙)
        int right = lowerBound(walls, rightX + 1);
        cur = lowerBound(walls, x);
        int resRight = dfs(i - 1, 1, a, walls, memo) + right - cur; // 下标在 [cur, right-1] 中的墙都能摧毁

        return memo[i][j] = Math.max(resLeft, resRight); // 记忆化
    }

    // 见 https://www.bilibili.com/video/BV1AP41137w7/
    private int lowerBound(int[] nums, int target) {
        int left = -1;
        int right = nums.length;
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] >= target) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return right;
    }
}

###cpp

class Solution {
public:
    int maxWalls(vector<int>& robots, vector<int>& distance, vector<int>& walls) {
        int n = robots.size();
        struct Pair { int x, d; };
        vector<Pair> a(n + 2);
        for (int i = 0; i < n; i++) {
            a[i] = {robots[i], distance[i]};
        }
        a[n + 1].x = INT_MAX;
        ranges::sort(a, {}, &Pair::x);
        ranges::sort(walls);

        vector memo(n + 1, array<int, 2>{-1, -1}); // -1 表示没有计算过
        auto dfs = [&](this auto&& dfs, int i, int j) -> int {
            if (i == 0) {
                return 0;
            }
            int& res = memo[i][j]; // 注意这里是引用
            if (res != -1) { // 之前计算过
                return res;
            }

            auto [x, d] = a[i];
            // 往左射,墙的坐标范围为 [left_x, x]
            int left_x = max(x - d, a[i - 1].x + 1); // +1 表示不能射到左边那个机器人
            int left = ranges::lower_bound(walls, left_x) - walls.begin();
            int cur = ranges::upper_bound(walls, x) - walls.begin();
            res = dfs(i - 1, 0) + cur - left; // 下标在 [left, cur-1] 中的墙都能摧毁

            // 往右射,墙的坐标范围为 [x, right_x]
            auto [x2, d2] = a[i + 1];
            if (j == 0) { // 右边那个机器人往左射
                x2 -= d2;
            }
            int right_x = min(x + d, x2 - 1); // -1 表示不能射到右边那个机器人(或者它往左射到的墙)
            int right = ranges::upper_bound(walls, right_x) - walls.begin();
            cur = ranges::lower_bound(walls, x) - walls.begin();
            res = max(res, dfs(i - 1, 1) + right - cur); // 下标在 [cur, right-1] 中的墙都能摧毁
            return res;
        };

        return dfs(n, 1);
    }
};

###go

func maxWalls(robots []int, distance []int, walls []int) int {
n := len(robots)
type pair struct{ x, d int }
a := make([]pair, n+2)
for i, x := range robots {
a[i] = pair{x, distance[i]}
}
a[n+1].x = math.MaxInt // 哨兵
slices.SortFunc(a, func(a, b pair) int { return a.x - b.x })
slices.Sort(walls)

memo := make([][2]int, n+1)
for i := range memo {
memo[i] = [2]int{-1, -1}
}
var dfs func(int, int) int
dfs = func(i, j int) int {
if i == 0 {
return 0
}
p := &memo[i][j]
if *p != -1 {
return *p
}

// 往左射,墙的坐标范围为 [leftX, a[i].x]
leftX := max(a[i].x-a[i].d, a[i-1].x+1) // +1 表示不能射到左边那个机器人
left := sort.SearchInts(walls, leftX)
cur := sort.SearchInts(walls, a[i].x+1)
res := dfs(i-1, 0) + cur - left // 下标在 [left, cur-1] 中的墙都能摧毁

// 往右射,墙的坐标范围为 [a[i].x, rightX]
x2 := a[i+1].x
if j == 0 { // 右边那个机器人往左射
x2 -= a[i+1].d
}
rightX := min(a[i].x+a[i].d, x2-1) // -1 表示不能射到右边那个机器人(或者它往左射到的墙)
right := sort.SearchInts(walls, rightX+1)
cur = sort.SearchInts(walls, a[i].x)
res = max(res, dfs(i-1, 1)+right-cur) // 下标在 [cur, right-1] 中的墙都能摧毁

*p = res
return res
}
return dfs(n, 1)
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n + m\log m + n\log m)$,其中 $n$ 是 $\textit{robots}$ 的长度,$m$ 是 $\textit{walls}$ 的长度。由于每个状态只会计算一次,动态规划的时间复杂度 $=$ 状态个数 $\times$ 单个状态的计算时间。本题状态个数等于 $\mathcal{O}(n)$,单个状态的计算时间为 $\mathcal{O}(\log m)$,所以动态规划的时间复杂度为 $\mathcal{O}(n\log m)$。前面排序需要 $\mathcal{O}(n\log n + m\log m)$ 的时间。
  • 空间复杂度:$\mathcal{O}(n)$。保存多少状态,就需要多少空间。忽略排序的栈开销。

四、1:1 翻译成递推

我们可以去掉递归中的「递」,只保留「归」的部分,即自底向上计算。

具体来说,$f[i][j]$ 的定义和 $\textit{dfs}(i,j)$ 的定义是一样的,都表示对于(排序,添加哨兵后的)下标在 $[1,i]$ 中的机器人,在机器人 $i+1$ 往左/右射击的前提下,能摧毁的最大墙壁数量。

相应的递推式(状态转移方程)也和 $\textit{dfs}$ 一样:

$$
f[i][j] = \max(f[i-1][0] + \textit{cur}_0- \textit{left}, f[i-1][1] + \textit{right} - \textit{cur}_1)
$$

初始值 $f[0][j]=0$,翻译自(添加哨兵后的)递归边界 $\textit{dfs}(0,j)=0$。

答案为 $f[n][1]$,翻译自(添加哨兵后的)递归入口 $\textit{dfs}(n,1)$。

###py

class Solution:
    def maxWalls(self, robots: List[int], distance: List[int], walls: List[int]) -> int:
        n = len(robots)
        a = [(0, 0)] + sorted(zip(robots, distance), key=lambda p: p[0]) + [(inf, 0)]
        walls.sort()

        f = [[0, 0] for _ in range(n + 1)]
        for i in range(1, n + 1):
            x, d = a[i]

            # 往左射,墙的坐标范围为 [left_x, x]
            left_x = max(x - d, a[i - 1][0] + 1)  # +1 表示不能射到左边那个机器人
            left = bisect_left(walls, left_x)
            cur = bisect_right(walls, x)
            left_res = f[i - 1][0] + cur - left  # 下标在 [left, cur-1] 中的墙都能摧毁

            cur = bisect_left(walls, x)
            for j in range(2):
                # 往右射,墙的坐标范围为 [x, right_x]
                x2, d2 = a[i + 1]
                if j == 0:  # 右边那个机器人往左射
                    x2 -= d2
                right_x = min(x + d, x2 - 1)  # -1 表示不能射到右边那个机器人(或者它往左射到的墙)
                right = bisect_right(walls, right_x)
                f[i][j] = max(left_res, f[i - 1][1] + right - cur)  # 下标在 [cur, right-1] 中的墙都能摧毁
        return f[n][1]

###java

class Solution {
    public int maxWalls(int[] robots, int[] distance, int[] walls) {
        int n = robots.length;
        int[][] a = new int[n + 2][2];
        for (int i = 0; i < n; i++) {
            a[i][0] = robots[i];
            a[i][1] = distance[i];
        }
        a[n + 1][0] = Integer.MAX_VALUE;
        Arrays.sort(a, (p, q) -> p[0] - q[0]);
        Arrays.sort(walls);

        int[][] f = new int[n + 1][2];
        for (int i = 1; i <= n; i++) {
            int x = a[i][0], d = a[i][1];

            // 往左射,墙的坐标范围为 [leftX, x]
            int leftX = Math.max(x - d, a[i - 1][0] + 1); // +1 表示不能射到左边那个机器人
            int left = lowerBound(walls, leftX);
            int cur = lowerBound(walls, x + 1);
            int leftRes = f[i - 1][0] + cur - left; // 下标在 [left, cur-1] 中的墙都能摧毁

            cur = lowerBound(walls, x);
            for (int j = 0; j < 2; j++) {
                // 往右射,墙的坐标范围为 [x, rightX]
                int x2 = a[i + 1][0];
                if (j == 0) { // 右边那个机器人往左射
                    x2 -= a[i + 1][1];
                }
                int rightX = Math.min(x + d, x2 - 1); // -1 表示不能射到右边那个机器人(或者它往左射到的墙)
                int right = lowerBound(walls, rightX + 1);
                f[i][j] = Math.max(leftRes, f[i - 1][1] + right - cur); // 下标在 [cur, right-1] 中的墙都能摧毁
            }
        }
        return f[n][1];
    }

    // 见 https://www.bilibili.com/video/BV1AP41137w7/
    private int lowerBound(int[] nums, int target) {
        int left = -1;
        int right = nums.length;
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] >= target) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return right;
    }
}

###cpp

class Solution {
public:
    int maxWalls(vector<int>& robots, vector<int>& distance, vector<int>& walls) {
        int n = robots.size();
        struct Pair { int x, d; };
        vector<Pair> a(n + 2);
        for (int i = 0; i < n; i++) {
            a[i] = {robots[i], distance[i]};
        }
        a[n + 1].x = INT_MAX;
        ranges::sort(a, {}, &Pair::x);
        ranges::sort(walls);

        vector<array<int, 2>> f(n + 1);
        for (int i = 1; i <= n; i++) {
            auto [x, d] = a[i];

            // 往左射,墙的坐标范围为 [left_x, x]
            int left_x = max(x - d, a[i - 1].x + 1); // +1 表示不能射到左边那个机器人
            int left = ranges::lower_bound(walls, left_x) - walls.begin();
            int cur = ranges::upper_bound(walls, x) - walls.begin();
            int left_res = f[i - 1][0] + cur - left; // 下标在 [left, cur-1] 中的墙都能摧毁

            cur = ranges::lower_bound(walls, x) - walls.begin();
            for (int j = 0; j < 2; j++) {
                // 往右射,墙的坐标范围为 [x, right_x]
                auto [x2, d2] = a[i + 1];
                if (j == 0) { // 右边那个机器人往左射
                    x2 -= d2;
                }
                int right_x = min(x + d, x2 - 1); // -1 表示不能射到右边那个机器人(或者它往左射到的墙)
                int right = ranges::upper_bound(walls, right_x) - walls.begin();
                f[i][j] = max(left_res, f[i - 1][1] + right - cur); // 下标在 [cur, right-1] 中的墙都能摧毁
            }
        }
        return f[n][1];
    }
};

###go

func maxWalls(robots []int, distance []int, walls []int) int {
n := len(robots)
type pair struct{ x, d int }
a := make([]pair, n+2)
for i, x := range robots {
a[i] = pair{x, distance[i]}
}
a[n+1].x = math.MaxInt // 哨兵
slices.SortFunc(a, func(a, b pair) int { return a.x - b.x })
slices.Sort(walls)

f := make([][2]int, n+1)
for i := 1; i <= n; i++ {
p := a[i]

// 往左射,墙的坐标范围为 [leftX, p.x]
leftX := max(p.x-p.d, a[i-1].x+1) // +1 表示不能射到左边那个机器人
left := sort.SearchInts(walls, leftX)
cur := sort.SearchInts(walls, p.x+1)
leftRes := f[i-1][0] + cur - left // 下标在 [left, cur-1] 中的墙都能摧毁

cur = sort.SearchInts(walls, p.x)
for j := range 2 {
// 往右射,墙的坐标范围为 [p.x, rightX]
x2 := a[i+1].x
if j == 0 { // 右边那个机器人往左射
x2 -= a[i+1].d
}
rightX := min(p.x+p.d, x2-1) // -1 表示不能射到右边那个机器人(或者它往左射到的墙)
right := sort.SearchInts(walls, rightX+1)
f[i][j] = max(leftRes, f[i-1][1]+right-cur) // 下标在 [cur, right-1] 中的墙都能摧毁
}
}
return f[n][1]
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n + m\log m + n\log m)$,其中 $n$ 是 $\textit{robots}$ 的长度,$m$ 是 $\textit{walls}$ 的长度。
  • 空间复杂度:$\mathcal{O}(n)$。忽略排序的栈开销。

五、空间优化

观察上面的状态转移方程,在计算 $f[i+1]$ 时,只会用到 $f[i]$,不会用到比 $i$ 更早的状态。

类似 背包问题,去掉 $f$ 的第一个维度,把 $f[i+1]$ 和 $f[i]$ 保存到同一个数组中。

###py

# 手写 min max 更快
min = lambda a, b: b if b < a else a
max = lambda a, b: b if b > a else a

class Solution:
    def maxWalls(self, robots: List[int], distance: List[int], walls: List[int]) -> int:
        n = len(robots)
        a = [(0, 0)] + sorted(zip(robots, distance), key=lambda p: p[0]) + [(inf, 0)]
        walls.sort()

        f = [0, 0]
        for i in range(1, n + 1):
            x, d = a[i]

            # 往左射,墙的坐标范围为 [left_x, x]
            left_x = max(x - d, a[i - 1][0] + 1)  # +1 表示不能射到左边那个机器人
            left = bisect_left(walls, left_x)
            cur = bisect_right(walls, x)
            left_res = f[0] + cur - left  # 下标在 [left, cur-1] 中的墙都能摧毁

            cur = bisect_left(walls, x)
            for j in range(2):
                # 往右射,墙的坐标范围为 [x, right_x]
                x2, d2 = a[i + 1]
                if j == 0:  # 右边那个机器人往左射
                    x2 -= d2
                right_x = min(x + d, x2 - 1)  # -1 表示不能射到右边那个机器人(或者它往左射到的墙)
                right = bisect_right(walls, right_x)
                f[j] = max(left_res, f[1] + right - cur)  # 下标在 [cur, right-1] 中的墙都能摧毁
        return f[1]

###java

class Solution {
    public int maxWalls(int[] robots, int[] distance, int[] walls) {
        int n = robots.length;
        int[][] a = new int[n + 2][2];
        for (int i = 0; i < n; i++) {
            a[i][0] = robots[i];
            a[i][1] = distance[i];
        }
        a[n + 1][0] = Integer.MAX_VALUE;
        Arrays.sort(a, (p, q) -> p[0] - q[0]);
        Arrays.sort(walls);

        int[] f = new int[2];
        for (int i = 1; i <= n; i++) {
            int x = a[i][0], d = a[i][1];

            // 往左射,墙的坐标范围为 [leftX, x]
            int leftX = Math.max(x - d, a[i - 1][0] + 1); // +1 表示不能射到左边那个机器人
            int left = lowerBound(walls, leftX);
            int cur = lowerBound(walls, x + 1);
            int leftRes = f[0] + cur - left; // 下标在 [left, cur-1] 中的墙都能摧毁

            cur = lowerBound(walls, x);
            for (int j = 0; j < 2; j++) {
                // 往右射,墙的坐标范围为 [x, rightX]
                int x2 = a[i + 1][0];
                if (j == 0) { // 右边那个机器人往左射
                    x2 -= a[i + 1][1];
                }
                int rightX = Math.min(x + d, x2 - 1); // -1 表示不能射到右边那个机器人(或者它往左射到的墙)
                int right = lowerBound(walls, rightX + 1);
                f[j] = Math.max(leftRes, f[1] + right - cur); // 下标在 [cur, right-1] 中的墙都能摧毁
            }
        }
        return f[1];
    }

    // 见 https://www.bilibili.com/video/BV1AP41137w7/
    private int lowerBound(int[] nums, int target) {
        int left = -1;
        int right = nums.length;
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] >= target) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return right;
    }
}

###cpp

class Solution {
public:
    int maxWalls(vector<int>& robots, vector<int>& distance, vector<int>& walls) {
        int n = robots.size();
        struct Pair { int x, d; };
        vector<Pair> a(n + 2);
        for (int i = 0; i < n; i++) {
            a[i] = {robots[i], distance[i]};
        }
        a[n + 1].x = INT_MAX;
        ranges::sort(a, {}, &Pair::x);
        ranges::sort(walls);

        int f[2]{};
        for (int i = 1; i <= n; i++) {
            auto [x, d] = a[i];

            // 往左射,墙的坐标范围为 [left_x, x]
            int left_x = max(x - d, a[i - 1].x + 1); // +1 表示不能射到左边那个机器人
            int left = ranges::lower_bound(walls, left_x) - walls.begin();
            int cur = ranges::upper_bound(walls, x) - walls.begin();
            int left_res = f[0] + cur - left; // 下标在 [left, cur-1] 中的墙都能摧毁

            cur = ranges::lower_bound(walls, x) - walls.begin();
            for (int j = 0; j < 2; j++) {
                // 往右射,墙的坐标范围为 [x, right_x]
                auto [x2, d2] = a[i + 1];
                if (j == 0) { // 右边那个机器人往左射
                    x2 -= d2;
                }
                int right_x = min(x + d, x2 - 1); // -1 表示不能射到右边那个机器人(或者它往左射到的墙)
                int right = ranges::upper_bound(walls, right_x) - walls.begin();
                f[j] = max(left_res, f[1] + right - cur); // 下标在 [cur, right-1] 中的墙都能摧毁
            }
        }
        return f[1];
    }
};

###go

func maxWalls(robots []int, distance []int, walls []int) int {
n := len(robots)
type pair struct{ x, d int }
a := make([]pair, n+2)
for i, x := range robots {
a[i] = pair{x, distance[i]}
}
a[n+1].x = math.MaxInt // 哨兵
slices.SortFunc(a, func(a, b pair) int { return a.x - b.x })
slices.Sort(walls)

f := [2]int{}
for i := 1; i <= n; i++ {
p := a[i]

// 往左射,墙的坐标范围为 [leftX, p.x]
leftX := max(p.x-p.d, a[i-1].x+1) // +1 表示不能射到左边那个机器人
left := sort.SearchInts(walls, leftX)
cur := sort.SearchInts(walls, p.x+1)
leftRes := f[0] + cur - left // 下标在 [left, cur-1] 中的墙都能摧毁

cur = sort.SearchInts(walls, p.x)
for j := range 2 {
// 往右射,墙的坐标范围为 [p.x, rightX]
x2 := a[i+1].x
if j == 0 { // 右边那个机器人往左射
x2 -= a[i+1].d
}
rightX := min(p.x+p.d, x2-1) // -1 表示不能射到右边那个机器人(或者它往左射到的墙)
right := sort.SearchInts(walls, rightX+1)
f[j] = max(leftRes, f[1]+right-cur) // 下标在 [cur, right-1] 中的墙都能摧毁
}
}
return f[1]
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n + m\log m + n\log m)$,其中 $n$ 是 $\textit{robots}$ 的长度,$m$ 是 $\textit{walls}$ 的长度。
  • 空间复杂度:$\mathcal{O}(n)$。忽略排序的栈开销。

六、双指针优化

由于随着 $i$ 变大,二分查找中的 $\textit{left},\textit{cur},\textit{right}$ 也随之变大,我们可以用双指针(多指针)优化。这样算法瓶颈就在排序上了。

###py

# 手写 min max 更快
min = lambda a, b: b if b < a else a
max = lambda a, b: b if b > a else a

class Solution:
    def maxWalls(self, robots: List[int], distance: List[int], walls: List[int]) -> int:
        n, m = len(robots), len(walls)
        a = [(0, 0)] + sorted(zip(robots, distance), key=lambda p: p[0]) + [(inf, 0)]
        walls.sort()

        f0 = f1 = left = cur = right0 = right1 = 0
        for i in range(1, n + 1):
            x, d = a[i]

            # 往左射,墙的坐标范围为 [left_x, x]
            left_x = max(x - d, a[i - 1][0] + 1)  # +1 表示不能射到左边那个机器人
            while left < m and walls[left] < left_x:
                left += 1
            while cur < m and walls[cur] < x:
                cur += 1
            cur1 = cur
            if cur < m and walls[cur] == x:
                cur += 1
            left_res = f0 + cur - left  # 下标在 [left, cur-1] 中的墙都能摧毁

            # 往右射,右边那个机器人往左射,墙的坐标范围为 [x, right_x]
            x2, d2 = a[i + 1]
            right_x = min(x + d, x2 - d2 - 1)  # -1 表示不能射到右边那个机器人
            while right0 < m and walls[right0] <= right_x:
                right0 += 1
            f0 = max(left_res, f1 + right0 - cur1)  # 下标在 [cur1, right0-1] 中的墙都能摧毁

            # 往右射,右边那个机器人往右射,墙的坐标范围为 [x, right_x]
            right_x = min(x + d, x2 - 1)  # -1 表示不能射到右边那个机器人
            while right1 < m and walls[right1] <= right_x:
                right1 += 1
            f1 = max(left_res, f1 + right1 - cur1)  # 下标在 [cur1, right1-1] 中的墙都能摧毁
        return f1

###java

class Solution {
    public int maxWalls(int[] robots, int[] distance, int[] walls) {
        int n = robots.length, m = walls.length;
        int[][] a = new int[n + 2][2];
        for (int i = 0; i < n; i++) {
            a[i][0] = robots[i];
            a[i][1] = distance[i];
        }
        a[n + 1][0] = Integer.MAX_VALUE;
        Arrays.sort(a, (p, q) -> p[0] - q[0]);
        Arrays.sort(walls);

        int f0 = 0, f1 = 0, left = 0, cur = 0, right0 = 0, right1 = 0;
        for (int i = 1; i <= n; i++) {
            int x = a[i][0], d = a[i][1];

            // 往左射,墙的坐标范围为 [leftX, x]
            int leftX = Math.max(x - d, a[i - 1][0] + 1); // +1 表示不能射到左边那个机器人
            while (left < m && walls[left] < leftX) {
                left++;
            }
            while (cur < m && walls[cur] < x) {
                cur++;
            }
            int cur1 = cur;
            if (cur < m && walls[cur] == x) {
                cur++;
            }
            int leftRes = f0 + cur - left; // 下标在 [left, cur-1] 中的墙都能摧毁

            // 往右射,右边那个机器人往左射,墙的坐标范围为 [x, rightX]
            int x2 = a[i + 1][0], d2 = a[i + 1][1];
            int rightX = Math.min(x + d, x2 - d2 - 1); // -1 表示不能射到右边那个机器人
            while (right0 < m && walls[right0] <= rightX) {
                right0++;
            }
            f0 = Math.max(leftRes, f1 + right0 - cur1); // 下标在 [cur1, right0-1] 中的墙都能摧毁

            // 往右射,右边那个机器人往右射,墙的坐标范围为 [x, rightX]
            rightX = Math.min(x + d, x2 - 1); // -1 表示不能射到右边那个机器人
            while (right1 < m && walls[right1] <= rightX) {
                right1++;
            }
            f1 = Math.max(leftRes, f1 + right1 - cur1); // 下标在 [cur1, right1-1] 中的墙都能摧毁
        }
        return f1;
    }

    // 见 https://www.bilibili.com/video/BV1AP41137w7/
    private int lowerBound(int[] nums, int target) {
        int left = -1;
        int right = nums.length;
        while (left + 1 < right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] >= target) {
                right = mid;
            } else {
                left = mid;
            }
        }
        return right;
    }
}

###cpp

class Solution {
public:
    int maxWalls(vector<int>& robots, vector<int>& distance, vector<int>& walls) {
        int n = robots.size(), m = walls.size();
        struct Pair { int x, d; };
        vector<Pair> a(n + 2);
        for (int i = 0; i < n; i++) {
            a[i] = {robots[i], distance[i]};
        }
        a[n + 1].x = INT_MAX;
        ranges::sort(a, {}, &Pair::x);
        ranges::sort(walls);

        int f0 = 0, f1 = 0, left = 0, cur = 0, right0 = 0, right1 = 0;
        for (int i = 1; i <= n; i++) {
            auto [x, d] = a[i];

            // 往左射,墙的坐标范围为 [left_x, x]
            int left_x = max(x - d, a[i - 1].x + 1); // +1 表示不能射到左边那个机器人
            while (left < m && walls[left] < left_x) {
                left++;
            }
            while (cur < m && walls[cur] < x) {
                cur++;
            }
            int cur1 = cur;
            if (cur < m && walls[cur] == x) {
                cur++;
            }
            int left_res = f0 + cur - left; // 下标在 [left, cur-1] 中的墙都能摧毁

            // 往右射,右边那个机器人往左射,墙的坐标范围为 [x, right_x]
            auto [x2, d2] = a[i + 1];
            int right_x = min(x + d, x2 - d2 - 1); // -1 表示不能射到右边那个机器人
            while (right0 < m && walls[right0] <= right_x) {
                right0++;
            }
            f0 = max(left_res, f1 + right0 - cur1); // 下标在 [cur1, right0-1] 中的墙都能摧毁

            // 往右射,右边那个机器人往右射,墙的坐标范围为 [x, right_x]
            right_x = min(x + d, x2 - 1); // -1 表示不能射到右边那个机器人
            while (right1 < m && walls[right1] <= right_x) {
                right1++;
            }
            f1 = max(left_res, f1 + right1 - cur1); // 下标在 [cur1, right1-1] 中的墙都能摧毁
        }
        return f1;
    }
};

###go

func maxWalls(robots []int, distance []int, walls []int) int {
n, m := len(robots), len(walls)
type pair struct{ x, d int }
a := make([]pair, n+2)
for i, x := range robots {
a[i] = pair{x, distance[i]}
}
a[n+1].x = math.MaxInt // 哨兵
slices.SortFunc(a, func(a, b pair) int { return a.x - b.x })
slices.Sort(walls)

var f0, f1, left, cur, right0, right1 int
for i := 1; i <= n; i++ {
p := a[i]

// 往左射,墙的坐标范围为 [leftX, p.x]
leftX := max(p.x-p.d, a[i-1].x+1) // +1 表示不能射到左边那个机器人
for left < m && walls[left] < leftX {
left++
}
for cur < m && walls[cur] < p.x {
cur++
}
cur1 := cur
if cur < m && walls[cur] == p.x {
cur++
}
leftRes := f0 + cur - left // 下标在 [left, cur-1] 中的墙都能摧毁

// 往右射,右边那个机器人往左射,墙的坐标范围为 [p.x, rightX]
q := a[i+1]
rightX := min(p.x+p.d, q.x-q.d-1) // -1 表示不能射到右边那个机器人(或者它往左射到的墙)
for right0 < m && walls[right0] <= rightX {
right0++
}
f0 = max(leftRes, f1+right0-cur1) // 下标在 [cur1, right0-1] 中的墙都能摧毁

// 往右射,右边那个机器人往右射,墙的坐标范围为 [p.x, rightX]
rightX = min(p.x+p.d, q.x-1) // -1 表示不能射到右边那个机器人(或者它往左射到的墙)
for right1 < m && walls[right1] <= rightX {
right1++
}
f1 = max(leftRes, f1+right1-cur1) // 下标在 [cur1, right0-1] 中的墙都能摧毁
}
return f1
}

复杂度分析

  • 时间复杂度:$\mathcal{O}(n\log n + m\log m)$,其中 $n$ 是 $\textit{robots}$ 的长度,$m$ 是 $\textit{walls}$ 的长度。瓶颈在排序上。
  • 空间复杂度:$\mathcal{O}(n)$。忽略排序的栈开销。

专题训练

见下面动态规划题单的「六、状态机 DP」。

分类题单

如何科学刷题?

  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站@灵茶山艾府

DP

解法:DP

设 $f(i, 0/1)$ 表示只考虑前 $i$ 个机器人,其中第 $i$ 个机器人往左/右射能摧毁的最多墙壁数。

如果第 $i$ 个机器人往右射,那可以什么都不考虑。

如果第 $i$ 个机器人往左射,因为子弹会被旁边的机器人挡住,所以能摧毁的墙壁数只和第 $(i - 1)$ 个机器人的行动有关。

具体来说,如果第 $(i - 1)$ 个机器人往左射,那只要考虑子弹会不会被第 $(i - 1)$ 个机器人挡住即可。如果第 $(i - 1)$ 个机器人往右射,那么两个机器人摧毁的总墙壁数,不能超过它们之间的墙壁总数。

可以用二分确定一个区间内到底有多少墙壁。复杂度 $\mathcal{O}(n\log n)$。

参考代码(c++)

class Solution {
public:
    int maxWalls(vector<int>& robots, vector<int>& distance, vector<int>& walls) {
        int n = robots.size(), m = walls.size();
        sort(walls.begin(), walls.end());

        // 机器人从左到右排序
        const int INF = 2e9;
        typedef pair<int, int> pii;
        vector<pii> vec;
        for (int i = 0; i < n; i++) vec.push_back({robots[i], distance[i]});
        // 左右加入两个哨兵节点,以免处理边界
        vec.push_back({-INF, 0});
        vec.push_back({INF, 0});
        sort(vec.begin(), vec.end());

        // 二分求区间 [l, r] 里有多少墙壁
        auto gao = [&](int l, int r) -> int {
            if (l > r) return 0;
            return upper_bound(walls.begin(), walls.end(), r) - lower_bound(walls.begin(), walls.end(), l);
        };

        int f[n + 1][2], g[n + 1];
        f[0][0] = f[0][1] = g[0] = 0;
        for (int i = 1; i <= n; i++) {
            // t:往左射最多摧毁多少墙壁
            int t = gao(max(vec[i - 1].first + 1, vec[i].first - vec[i].second), vec[i].first - 1);
            f[i][0] = f[i - 1][0] + t;
            // tot:当前机器人和上一个机器人之间一共有多少墙壁
            int tot = gao(vec[i - 1].first + 1, vec[i].first - 1);
            f[i][0] = max(f[i][0], f[i - 1][1] - g[i - 1] + min(tot, g[i - 1] + t));

            // g[i]:往右射最多摧毁多少墙壁
            g[i] = gao(vec[i].first + 1, min(vec[i + 1].first - 1, vec[i].first + vec[i].second));
            f[i][1] = max(f[i - 1][0], f[i - 1][1]) + g[i];
        }

        int ans = max(f[n][0], f[n][1]);
        // 还要加上和机器人重叠的墙壁数,这些墙壁总会被摧毁
        for (int i = 1; i <= n; i++) ans += gao(vec[i].first, vec[i].first);
        return ans;
    }
};

排序 + 双指针 + dp

Problem: 100763. 可以被机器人摧毁的最大墙壁数目

[TOC]

思路

排序

先按位置排序

        walls.sort()
        arr = list(zip(robots,distance))
        arr.sort()

预处理 - 双指针

预处理三个数组:

  • left数组: 每个机器人向射能打爆多少墙
  • right数组: 每个机器人向射能打爆多少墙
  • mid数组: 相邻的两个机器人,如果射程重叠,即第i个机器人向右射,第i+1个机器人向左射,射程重叠,一共能打爆多少墙

三次双指针即可:

###left

        i,j = 0,0
        n = len(robots)
        m = len(walls)
        left = [0] * n
        res = 0
        last = -int(1e9)
        while i < n and j < m:
            p,d = arr[i]
            t = 0
            last = max(last,p-d)
            while j < m:
                num =  walls[j]
                if num < last:
                    j += 1
                # 重叠直接加到结果里
                elif num == p:
                    res += 1
                    j += 1
                elif num < p:
                    t += 1
                    j += 1
                else:
                    break
            left[i] = t
            last = p
            i += 1

###right

        right = [0] * n
        i,j = n-1,m-1
        last = inf
        while i >= 0 and j >= 0:
            p,d = arr[i]
            t = 0
            last = min(last,p+d)
            while j >= 0:
                num =  walls[j]
                if num > last:
                    j -= 1
                # 重叠直接加到结果里,left时已经加过了
                elif num == p:
                    # res += 1
                    j -= 1
                elif num > p:
                    t += 1
                    j -= 1
                else:
                    break
            right[i] = t
            last = p
            i -= 1

###mid

        # 预处理 第i向右 第i+1向左
        mid = [-1] * n
        i,j = 0,0
        while i < n - 1 and j < m:
            p1,d1 = arr[i]
            p2,d2 = arr[i+1]
            # 没重叠,跳过
            if p1 + d1 < p2 - d2:
                i += 1
                continue

            # 重叠了,计数
            t = 0
            while j < m:
                num = walls[j]
                if num <= p1:
                    j += 1
                elif num < p2:
                    t += 1
                    j += 1
                else:
                    break
            mid[i+1] = t
            i += 1

注意:这里有个特殊处理,如果墙跟机器人重叠,则直接计入结果中,因为无论向右还是向左,都能打爆这墙

dp

题目转化为每个点向左还是向右的最大权值和
dp[i] = [l,r] 代表当前机器人向左和向右的最大权值和

转移方程

假设l,r = left[i],right[i],直接滚动数组

  • 向左时,分两种情况:
    • 若与上个机器人没有重叠ndp[0] = max(dp) + l
    • 若与上个机器人有重叠ndp[0] = max(dp[0] + l,dp[1] - right[i-1] + mid[i])
  • 向右射,很简单了:ndp[1] = max(dp) + r
        # 左右,滚动数组
        dp = [left[0],right[0]]
        for i in range(1,n):
            l,r = left[i],right[i]
            ndp = [0,0]
            # 向左没有重叠
            if mid[i] == -1:
                ndp[0] = max(dp) + l
            else:
                ndp[0] = max(dp[0] + l,dp[1] - right[i-1] + mid[i])
                
            ndp[1] = max(dp) + r
            dp = ndp
        return max(dp) + res

更多题目模板总结,请参考2024年度总结与题目分享

Code

###Python3

class Solution:
    def maxWalls(self, robots: List[int], distance: List[int], walls: List[int]) -> int:
        '''
        dp
        '''
        walls.sort()
        # 预处理 获取每个机器人向左向右能打多少墙
        arr = list(zip(robots,distance))
        arr.sort()
        i,j = 0,0
        n = len(robots)
        m = len(walls)
        left = [0] * n
        res = 0
        last = -int(1e9)
        while i < n and j < m:
            p,d = arr[i]
            t = 0
            last = max(last,p-d)
            while j < m:
                num =  walls[j]
                if num < last:
                    j += 1
                # 重叠直接加到结果里
                elif num == p:
                    res += 1
                    j += 1
                elif num < p:
                    t += 1
                    j += 1
                else:
                    break
            left[i] = t
            last = p
            i += 1
        
        right = [0] * n
        i,j = n-1,m-1
        last = inf
        while i >= 0 and j >= 0:
            p,d = arr[i]
            t = 0
            last = min(last,p+d)
            while j >= 0:
                num =  walls[j]
                if num > last:
                    j -= 1
                # 重叠直接加到结果里,left时已经加过了
                elif num == p:
                    # res += 1
                    j -= 1
                elif num > p:
                    t += 1
                    j -= 1
                else:
                    break
            right[i] = t
            last = p
            i -= 1

        # 预处理 第i向右 第i+1向左
        mid = [-1] * n
        i,j = 0,0
        while i < n - 1 and j < m:
            p1,d1 = arr[i]
            p2,d2 = arr[i+1]
            # 没重叠,跳过
            if p1 + d1 < p2 - d2:
                i += 1
                continue

            # 重叠了,计数
            t = 0
            while j < m:
                num = walls[j]
                if num <= p1:
                    j += 1
                elif num < p2:
                    t += 1
                    j += 1
                else:
                    break
            mid[i+1] = t
            i += 1
        
        '''
        预处理完成,题目转化为每个点向左还是向右的最大权值和
        '''
        # 左右,滚动数组
        dp = [left[0],right[0]]
        for i in range(1,n):
            l,r = left[i],right[i]
            ndp = [0,0]
            # 向左没有重叠
            if mid[i] == -1:
                ndp[0] = max(dp) + l
            else:
                ndp[0] = max(dp[0] + l,dp[1] - right[i-1] + mid[i])
                
            ndp[1] = max(dp) + r
            dp = ndp

        return max(dp) + res

鸿蒙日历服务实践:把应用里的事件写进用户的日程表

引言

很多应用都会产生带时间属性的事件——买了张火车票、预约了一场直播、信用卡该还款了、晚上有节线上课。这些信息散落在各个应用里,用户需要自己记住或者手动添加提醒,难免遗漏。

鸿蒙的 Calendar Kit 提供了一种更好的方式:让应用直接把这些事件写入系统日历。写入后,日程会通过通知中心、桌面卡片、日历应用等多个入口触达用户,还能配上一个**"一键服务"按钮**,用户点一下就能跳回应用完成操作——比如"加入会议"、"马上还款"、"立即观看"。

本文面向希望接入日历服务的鸿蒙开发者,梳理日历日程的创建机制、一键服务的场景设计,并通过两个典型场景的完整实现,帮你快速理解开发要点。


一、日历服务能做什么

1.1 核心能力

简单说就是三件事:

  1. 写入日程:把应用中带时间属性的事件以标准格式写入系统日历,包括标题、时间、地点、备注等信息。
  2. 提醒用户:通过系统级的提醒机制,在日程开始前的指定时间通知用户。
  3. 一键直达:在日程卡片上提供服务按钮,用户点击后通过 DeepLink 跳回应用的对应页面,完成后续操作。

写入日历的日程会出现在多个地方——日历应用内部、桌面日历卡片、通知中心。用户不需要打开你的应用就能看到这些信息,这对提升事件的到达率很有帮助。

1.2 一键服务按钮的显示时机

一键服务按钮不是一直显示的,不同入口的出现时机不一样:

入口 显示时机
桌面卡片 / 月视图日程列表卡片 日程开始前 15 分钟显示,日程结束后自动隐藏
日程详情页 始终显示
日程通知 通知弹出时显示,点击通知卡片后显示

这意味着一键服务按钮是有"时效性"的——它在用户最需要行动的时间窗口出现,而不是一直挂在那里。

1.3 9 种典型服务场景

Calendar Kit 为不同的业务场景预定义了服务类型,每种类型对应一个具体的按钮文案:

场景 ServiceType 按钮文案
会议 Meeting 加入会议
追剧 Watching 立即观看
还款 Repayment 马上还款
直播 Live 开启直播
购物 Shopping 开始选购
出行 Trip 立即查看
上课 Class 开始上课
赛事 SportsEvents 立即观看
运动 SportsExercise 开始运动

选择合适的服务类型,按钮文案就会自动匹配,不需要开发者自定义。


二、开发前的准备工作

在写第一行业务代码之前,有三步准备工作需要完成:

第一步,导入依赖。日历管理相关的能力都在 @kit.CalendarKit 中。

第二步,申请权限。日历是用户的私有数据,读写操作需要在 module.json5 中声明两个权限:ohos.permission.READ_CALENDARohos.permission.WRITE_CALENDAR

第三步,获取日程管理器对象。通过上下文获取 calendarMgr 对象,后续所有日历账户和日程的管理操作都通过它来进行。推荐在 EntryAbility.ets 中完成这一步,确保管理器对象在应用生命周期内可用。


三、理解日程的数据结构

在看具体场景之前,先理解日程涉及的几个关键概念,后面写代码时会更清楚为什么要这样配置。

3.1 日历账户

每个写入系统日历的日程都归属于一个日历账户。你可以理解为日历中的一个"分组"——用户打开日历应用时,能看到来自不同应用的日程被归类在各自的账户下。

账户有三个关键属性:

  • name:账户标识,供系统内部使用。
  • type:账户类型,一般使用 LOCAL
  • displayName:展示给用户看的名称,建议和应用在应用市场中的名称保持一致,方便用户识别"这个日程是哪个应用写的"。

3.2 日程字段

一条日程的核心字段包括:

  • title:标题,出现在日程卡片上最醒目的位置。
  • startTime / endTime:起止时间,时间戳格式。
  • isAllDay:是否是全天日程。全天日程不会显示具体时刻,适合"入住日""还款日"这类以天为单位的事件。
  • reminderTime:提醒时间,是一个数组,单位是分钟。比如 [0, 10] 表示日程开始时和开始前 10 分钟各提醒一次。对于全天日程,0 表示当天上午 9 点提醒,1440(即 24 小时)表示前一天上午 9 点提醒。
  • description:备注信息,可以补充标题里放不下的细节。
  • location:地点信息,包含地址文本和经纬度。
  • service:一键服务配置,包括服务类型(type)和跳转链接(uri,DeepLink 格式)。

3.3 日程的增删改查

日历服务提供了完整的 CRUD 操作。创建日历账户后,可以在该账户下添加日程、按条件查询日程、更新日程信息、删除日程。后面的场景示例中会展示这些操作的具体写法。


四、典型场景实践

下面通过两个最常见的场景——出行服务会议——来完整走一遍开发流程。其他场景(直播、购物、还款、课程等)的开发思路完全一致,区别只在于字段内容和 ServiceType 的选择。

4.1 出行服务场景

这大概是最容易理解的场景了:用户在购票应用里买了一张高铁票,应用把行程信息写入日历,出发前提醒用户,用户还能一键跳回应用查看电子客票。

字段设计思路:

标题要一目了然,建议包含车次和起终点,比如"行程信息:G107 上海虹桥-北京南"。备注里可以放检票口和座位号这类到了车站才需要的细节。提醒时间设两个——4 小时前提醒用户该出门了,2 小时前再提醒一次。一键服务类型选 TRIP

创建日程:

import { calendarMgr } from '../entryability/EntryAbility';
import { calendarManager } from '@kit.CalendarKit';

let tripCalendar: calendarManager.Calendar | undefined = undefined;
let oriEvent: calendarManager.Event | null = null;
let id: number = 0;

async createTripCalendarAndEvent(): Promise<void> {
  const calendarAccount: calendarManager.CalendarAccount = {
    name: 'TripCalendar',
    type: calendarManager.CalendarType.LOCAL,
    displayName: '高铁出行'
  };

  const config: calendarManager.CalendarConfig = {
    color: '#aabbcc'
  };

  const startTime = new Date('2025-10-01T08:17:00').getTime();
  const endTime = new Date('2025-10-01T12:51:00').getTime();

  const event: calendarManager.Event = {
    type: calendarManager.EventType.NORMAL,
    title: '行程信息:G107 上海虹桥-北京南',
    startTime: startTime,
    endTime: endTime,
    isAllDay: false,
    reminderTime: [120, 240],
    description: '检票口:南二楼1口或北广场B2候车室 \n座位号:02车04二等座',
    service: {
      type: calendarManager.ServiceType.TRIP,
      uri: 'demo://mobile/player?params='
    }
  };

  try {
    tripCalendar = await calendarMgr?.createCalendar(calendarAccount);
    if (!tripCalendar) {
      console.error('Failed to create calendar.');
      return;
    }
    await tripCalendar.setConfig(config);
    id = await tripCalendar.addEvent(event);
    oriEvent = event;
    oriEvent.id = id;
    console.info(`日程创建成功,ID: ${id}`);
  } catch (error) {
    console.error(`创建失败: ${error.code}, ${error.message}`);
  }
}

这段代码做了三件事:创建日历账户、设置账户配色、添加日程。注意一定要确保日历账户创建成功后再进行日程操作,否则后续调用会失败。

日程的后续管理:

出行场景下,行程变更是常有的事——改签了车次、换了出发时间。这时候需要更新已有日程而不是删掉重建:

async updateTripEvent(): Promise<void> {
  if (!tripCalendar || !oriEvent) return;
  
  // 改签后更新起止时间
  oriEvent.startTime = new Date('2025-10-01T07:03:00').getTime();
  oriEvent.endTime = new Date('2025-10-01T11:51:00').getTime();

  try {
    await tripCalendar.updateEvent(oriEvent);
    console.info('日程更新成功');
  } catch (err) {
    console.error(`更新失败: ${err.code}, ${err.message}`);
  }
}

如果用户退票了,则直接删除日程:

async deleteTripEvent(): Promise<void> {
  if (!tripCalendar) return;
  try {
    await tripCalendar.deleteEvent(id);
    oriEvent = null;
    console.info('日程已删除');
  } catch (err) {
    console.error(`删除失败: ${err.code}, ${err.message}`);
  }
}

需要查询已有日程时,通过 EventFilter.filterById 按 ID 查询:

async getTripEvent(): Promise<void> {
  if (!tripCalendar) return;
  try {
    const filter = calendarManager.EventFilter.filterById([id]);
    let data = await tripCalendar.getEvents(filter, 
      ['title', 'type', 'startTime', 'endTime']);
    if (data && data.length > 0) {
      oriEvent = data[0];
    }
  } catch (err) {
    console.error(`查询失败: ${err.code}, ${err.message}`);
  }
}

4.2 会议场景

会议场景和出行的最大区别在于:它有与会人信息。用户在会议应用中创建或被邀请参加一个会议,应用将其写入日历,到时间时用户看到提醒,点击"加入会议"按钮就能直接进入会议。

字段设计思路:

标题就是会议主题。提醒时间设准时和 15 分钟前——太早没意义,太晚来不及。会议场景特有的是 attendee 字段,用来记录与会人信息,每个与会人有姓名、邮箱、角色(组织者还是参与者)和类型(必选还是可选)。

async createMeetingEvent(): Promise<void> {
  const calendarAccount: calendarManager.CalendarAccount = {
    name: 'meetingCalendar',
    type: calendarManager.CalendarType.LOCAL,
    displayName: '会议'
  };

  const config: calendarManager.CalendarConfig = {
    color: '#aabbcc'
  };

  let attendee: calendarManager.Attendee[] = [
    {
      name: 'Alice',
      email: 'alice@example.com',
      role: calendarManager.AttendeeRole.ORGANIZER
    },
    {
      name: 'Jack',
      email: 'jack@example.com',
      role: calendarManager.AttendeeRole.PARTICIPANT,
      type: calendarManager.AttendeeType.REQUIRED
    },
    {
      name: 'Jerry',
      email: 'jerry@example.com',
      role: calendarManager.AttendeeRole.PARTICIPANT,
      type: calendarManager.AttendeeType.REQUIRED
    }
  ];

  const startTime = new Date('2025-10-20T09:00:00').getTime();
  const endTime = new Date('2025-10-20T10:00:00').getTime();

  const event: calendarManager.Event = {
    type: calendarManager.EventType.NORMAL,
    title: '产品方案评审会议',
    startTime: startTime,
    endTime: endTime,
    isAllDay: false,
    reminderTime: [0, 15],
    attendee: attendee,
    description: 'Q4产品方案评审',
    service: {
      type: calendarManager.ServiceType.MEETING,
      uri: 'demo://mobile/player?params='
    }
  };

  try {
    let calendar = await calendarMgr?.createCalendar(calendarAccount);
    if (!calendar) return;
    await calendar.setConfig(config);
    id = await calendar.addEvent(event);
    console.info(`会议日程创建成功,ID: ${id}`);
  } catch (error) {
    console.error(`创建失败: ${error.code}, ${error.message}`);
  }
}

与会人信息会展示在日程详情中,帮助用户确认参会人员。对于会议应用来说,service.uri 中的 DeepLink 通常会携带会议室 ID 等参数,用户点击"加入会议"后直接进入对应的会议房间。


五、其他场景速览

前面详细讲了出行和会议两个场景的完整实现,其他场景的代码结构完全一样,差异只在字段内容的填写上。这里简要列出几个场景的要点,帮你快速对照:

酒店住宿:适合设置为全天日程(isAllDay: true),标题包含酒店名称和地址,别忘了填 location 字段(包含经纬度),ServiceType 用 TRIP。提醒建议设前一天上午 9 点(reminderTime: [1440])和当天上午 9 点(reminderTime: [0])。

还款提醒:也是全天日程,毕竟还款日是以"天"为单位的。备注里写上待还款金额,ServiceType 用 REPAYMENT,提醒一次就够了——当天上午 9 点(reminderTime: [0])。

直播 / 抢购 / 课程 / 赛事 / 运动:都是精确到具体时刻的非全天日程,提醒时间一般设准时和开始前 10-30 分钟。区别就是选对 ServiceType,按钮文案就会自动匹配。


六、总结与实践建议

日历服务的接入逻辑并不复杂——创建账户、配置日程、写入系统。但要把体验做好,有几个细节值得注意:

  1. 标题要有信息量。用户在桌面卡片上看到的可能只有标题,所以"G107 上海虹桥-北京南"远比"火车票行程"有用。
  2. 提醒时间要合理。出行类提前 2-4 小时,会议和课程提前 10-15 分钟,全天日程用上午 9 点。不要设过多提醒,免得用户觉得被打扰。
  3. 及时更新和清理。行程改签了就更新日程,退票了就删除。不要让过期或无效的日程留在用户的日历里,这会损害用户对应用的信任。
  4. displayName 要用应用真名。用户看到一条日程时,会通过日历账户名称判断"这是哪个应用写的"。用正式的应用名称,而不是内部代号或缩写。
  5. DeepLink 要能真正落地。一键服务按钮点下去后跳转的链接,必须能正确打开应用的对应页面。如果链接失效或跳错位置,这个功能反而会让用户感到困惑。

日历是一个天然的时间管理入口,用户每天都会看。把应用中有价值的时间事件写进去,既帮用户管理好了日程,也为应用争取到了在系统级入口的露出机会。

鸿蒙碰一碰分享:手机轻碰,内容就过去了

引言

跨设备传内容这件事,理想状态是什么?大概就是——我手机上有个东西想给你,碰一下就过去了,不用加好友、不用扫码、不用等配对。

鸿蒙 Share Kit 的碰一碰分享做的就是这件事。两台手机轻碰一下,图片、链接、Wi-Fi 信息就传过去了;手机往 PC 屏幕上一放,文件就到了电脑里。整个过程没有中间步骤,靠的是设备间物理接触触发的分享机制。

本文面向希望为应用接入碰一碰分享能力的鸿蒙开发者,从手机间分享和手机与 PC/2in1 间分享两个维度,梳理这项能力的工作机制、卡片设计要点、异常处理策略,以及完整的开发流程。


一、手机与手机之间的碰一碰分享

1.1 基本流程

碰一碰分享的业务流程可以用四步概括:

  1. 注册:应用在可分享的页面注册碰一碰事件(knockShare)。
  2. 触发:用户将手机与对端设备轻碰,系统发现设备后触发回调。
  3. 发送:应用在回调中构造分享数据并发送。
  4. 清理:离开可分享页面时,解除事件注册。

使用前有几个前提条件:双端设备都要亮屏且解锁,华为分享服务需要处于开启状态(系统默认开启)。如果用户手动关闭了华为分享服务,轻碰时会收到系统通知提示开启。

还有一点需要了解:宿主应用无法直接获知分享结果。对端是接收了还是拒绝了,Share Kit 会通过系统通知告知用户,而不是通过回调返回给应用。如果任意一端设备不支持碰一碰能力,轻碰则完全没有响应。

环境要求方面,手机系统需要 HarmonyOS NEXT Release 及以上版本。可以用 canIUse 做运行时判断:

if (canIUse('SystemCapability.Collaboration.HarmonyShare')) {
  // 支持碰一碰分享
}

1.2 设备间的信任与安全

从 HarmonyOS NEXT 5.0.0.123 SP16 开始,碰一碰分享在发送端和接收端都会展示对方的身份信息,帮助用户确认"我在和谁传东西":

  • 如果对端已登录华为账号,会展示对方的账号昵称和头像
  • 如果对端未登录华为账号,则展示设备信息

需要注意的是,如果发送端的系统版本低于 SP16,接收端将不会展示任何发送方信息。


二、分享卡片的设计:不只是技术问题

碰一碰触发后,对端设备会收到一张分享卡片。卡片的样式直接影响用户是否愿意接收,所以这部分值得认真对待。

2.1 三种卡片模板

Share Kit 根据你传入的字段组合,自动匹配不同的卡片模板:

纯图片布局——只有预览图,没有标题和描述。适合分享文件、图片等不需要文字说明的场景。构造分享数据时只传 thumbnailUri 即可触发这种布局。预览图支持最小宽高比 1:4,超出部分会被裁剪。

沉浸式大卡布局——预览图 + 标题 + 描述 + 应用图标,视觉冲击力最强。适合分享链接类内容。触发条件是同时传入 titledescriptionthumbnailUri,且预览图宽高比小于 1:1(即竖图)。标题最多显示 2 行,描述 1 行,超出部分以省略号截断。如果标题末尾有重要信息,建议控制在 20 个中文字符左右。

白卡上下布局——同样包含预览图、标题、描述和应用图标,但预览图只显示在卡片上方,不会铺满整张卡片。触发条件和沉浸式大卡一样,区别在于预览图宽高比大于 1:1(即横图)。

应用图标不需要额外配置,系统会自动获取。

2.2 预览图的质量建议

预览图太大会拖慢加载速度,太小则显示模糊。建议参考以下标准:

预览图来源 推荐比例 推荐分辨率
应用创作的海报 3:4 最小 600×800,最大 3000×4000
用户上传的图片 不限制 最大 3000×4000

2.3 预览图来不及下载怎么办

一个很实际的问题:如果应用使用的是云端存储的图片作为预览图,碰一碰回调触发时图片可能还没下载到本地,这就会导致超时失败。

Share Kit 对此提供了预览图延迟更新的能力。思路很简单——先发核心数据,建立连接,系统会用默认预览图填充卡片;等云端图片下载完成后,再调用 sharableTarget.updateShareData 更新预览图:

harmonyShare.on('knockShare', capabilityRegistry, (sharableTarget) => {
  // 先发送核心数据,不带预览图
  let shareData = new systemShare.SharedData({
    utd: utd.UniformDataType.HYPERLINK,
    content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p',
    title: '碰一碰分享卡片标题',
    description: '碰一碰分享卡片描述'
  });
  sharableTarget.share(shareData);

  // 图片下载完成后更新预览图
  setTimeout(() => {
    let filePath = contextFaker.filesDir + '/exampleKnock1.jpg';
    sharableTarget.updateShareData({
      thumbnailUri: fileUri.getUriFromPath(filePath)
    });
  }, 5000);
});

这样用户不会因为预览图加载慢而等待,分享体验更流畅。


三、用户引导:让用户知道"这里可以碰"

碰一碰是一个相对新的交互方式,很多用户可能不知道当前页面支持这个功能。给出适当的引导可以有效提升分享意愿。

Share Kit 推荐两种引导方式:

  • 文本提示:在页面上展示"可碰一碰分享至 HarmonyOS 5 及以上版本手机"的文案。
  • 动图提示:用动画展示碰一碰的操作方式,更直观。

Share Kit 提供了统一的动图资源文件。下载后将 knock_share_guide 目录下的所有文件放到应用的 entry/src/main/resources/rawfile 目录即可使用。


四、核心开发流程:注册、发送、清理

4.1 注册与取消碰一碰事件

注册碰一碰事件有两种方式。简单场景下,直接传入回调函数即可:

private immersiveListening() {
  harmonyShare.on('knockShare', this.immersiveCallback);
}

private immersiveDisablingListening() {
  harmonyShare.off('knockShare', this.immersiveCallback);
}

如果需要更精细的控制(比如指定窗口、声明单向发送能力),可以传入 SendCapabilityRegistry 配置:

let capabilityRegistry: harmonyShare.SendCapabilityRegistry = {
  windowId: 999,  // 替换为实际的 windowId
};
harmonyShare.on('knockShare', capabilityRegistry, callback);

和隔空传送一样,生命周期管理是关键。进入可分享页面时注册,离开时(包括退后台)必须取消:

aboutToAppear(): void {
  this.immersiveListening();
  let context = this.getUIContext().getHostContext() as Context;
  context.eventHub.on('onBackGround', this.onBackGround);
}

aboutToDisappear(): void {
  this.immersiveDisablingListening();
  let context = this.getUIContext().getHostContext() as Context;
  context.eventHub.off('onBackGround', this.onBackGround);
}

onPageHide(): void {
  let context = this.getUIContext().getHostContext() as Context;
  context.eventHub.emit('onBackGround');
}

private onBackGround = () => {
  this.immersiveDisablingListening();
}

4.2 构造分享数据并发送

在碰一碰回调中构造分享数据。链接类分享的 utd 类型需要设置为 HYPERLINK

private immersiveCallback = (sharableTarget: harmonyShare.SharableTarget) => {
  let filePath = contextFaker.filesDir + '/exampleKnock1.jpg';
  let shareData = new systemShare.SharedData({
    utd: utd.UniformDataType.HYPERLINK,
    content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p',
    thumbnailUri: fileUri.getUriFromPath(filePath),
    title: '碰一碰分享卡片标题',
    description: '碰一碰分享卡片描述'
  });
  sharableTarget.share(shareData);
}

4.3 通过 App Linking 实现直达应用

分享链接时,强烈建议使用 App Linking 而不是普通 URL。App Linking 的好处在于:

  • 应用已安装:直接拉起应用对应页面。
  • 应用未安装:默认通过浏览器打开网页;配合 App Linking Kit 的直达应用市场能力,可以直接跳转到应用市场引导安装。再结合延迟链接能力,用户安装完成后首次打开应用时,仍能获取之前分享的链接内容——这对转化率的提升非常有价值。

另一种方案是 Deep Linking,但它只在本地已安装的应用中查找匹配项,未安装时会提示"暂无可用打开方式"。

4.4 异常场景的处理

碰一碰触发后,并不总是能顺利完成分享。Share Kit 提供了两种异常处理方式,帮助开发者优雅地终止分享,避免用户干等:

当前界面无可分享内容(6.0.2(22) 版本起支持):

sharableTarget.clarifyNonShare({ 
  message: '请在支持碰一碰分享的界面再试' 
});

这会终止本次分享,并弹出提示引导用户去可分享的页面。

网络或业务原因导致分享失败(5.0.3(15) 版本起支持):

sharableTarget.reject(harmonyShare.SharableErrorCode.DOWNLOAD_ERROR);

这会终止分享并提示用户具体原因。


五、邀请组队:碰一碰的另一种玩法

除了内容分享,碰一碰还有一个很有意思的应用场景——邀请组队。比如游戏中邀请旁边的朋友加入房间,碰一下手机就完成了。

这个场景有一个特殊问题:如果双方都在组队房间里互碰,会导致互相邀请加入对方房间的冲突。Share Kit 对此提供了单向仅发送能力,通过在注册时设置 sendOnly: true 来声明:

let capabilityRegistry: harmonyShare.SendCapabilityRegistry = {
  windowId: 999,
  sendOnly: true,  // 声明仅支持单向发送
};
harmonyShare.on('knockShare', capabilityRegistry, callback);

当碰一碰的双方都设置了 sendOnly,系统会终止本次分享并提示"请任意一方退出当前应用后再试"。只要有一方没设置 sendOnly,分享就能正常完成。

对端应用被拉起后,通过 onCreateonNewWant 回调中的 want.uri 获取组队链接,解析其中的参数来处理组队逻辑:

onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  console.log('收到组队链接: ', want.uri);
  // 解析链接参数,处理组队邀请
}

onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
  console.log('收到组队链接: ', want.uri);
  // 应用已在前台时的处理
}

六、手机与 PC/2in1 之间的碰一碰分享

碰一碰不只是手机之间的事。手机和 PC/2in1 设备之间也可以碰一碰分享,而且交互方式更有趣——手机直接往屏幕上一放,利用 PC/2in1 的屏幕感知能力识别碰触动作和位置,实现窗口级的精准交互。

6.1 谁发谁收的规则

从 6.0.0(20) Beta5 版本开始,手机与 PC/2in1 之间不支持双向分享,遵循明确的优先级:

  1. 手机前台有可分享内容 → 手机发送,PC/2in1 接收。
  2. 手机前台无可分享内容,PC/2in1 前台窗口有 → PC/2in1 发送,手机接收。
  3. 双方前台都没有可分享内容 → 走无内容分享逻辑。

更早的版本(6.0.0(20) Beta3 及之前)支持双向同时分享,但后续版本取消了这个行为。

6.2 物理姿态要求

手机碰 PC/2in1 屏幕时,对放置姿态有具体要求:

  • 俯视夹角 ≤ 5°(手机要基本平放在屏幕上)
  • 侧视夹角 > 35°
  • 正视夹角 ≤ 25°
  • 手机不能超出屏幕边缘

此外,支持官方手机保护壳,但过厚的外壳可能影响感知。仅支持直板手机或折叠手机的直板形态。双端设备需要登录相同的华为账号。

6.3 沙箱接收:文件直达应用

从 6.0.0(20) 版本开始,PC/2in1 设备支持沙箱接收能力——手机碰一下屏幕,文件直接传入 PC/2in1 应用的沙箱目录,传完后通知应用处理,无需用户手动操作。

应用需要声明自己支持接收的文件类型和最大数量。如果类型不匹配,系统会回退到华为分享的默认接收逻辑;如果数量不匹配,会弹窗提示用户。

注册沙箱接收事件:

aboutToAppear(): void {
  let capabilityRegistry: harmonyShare.RecvCapabilityRegistry = {
    windowId: 999,
    capabilities: [{
      utd: utd.UniformDataType.IMAGE,
      maxSupportedCount: 1,
    }]
  };

  harmonyShare.on('dataReceive', capabilityRegistry, 
    (receivableTarget: harmonyShare.ReceivableTarget) => {
      let context = this.getUIContext().getHostContext() as common.UIAbilityContext;
      receivableTarget.receive(context.filesDir, {
        onDataReceived: (sharedData: systemShare.SharedData) => {
          let records = sharedData.getRecords();
          records.forEach((record) => {
            // 处理接收到的文件
          });
        },
        onResult(resultCode: harmonyShare.ShareResultCode) {
          if (resultCode === harmonyShare.ShareResultCode.SHARE_SUCCESS) {
            // 接收成功
          }
        }
      });
    }
  );
}

离开页面时同样要解除注册。如果因为业务原因需要拒绝本次接收,可以调用 receivableTarget.reject()


七、总结

碰一碰分享覆盖了两大场景:手机与手机之间的内容传输和组队邀请,以及手机与 PC/2in1 之间的文件传输。对开发者来说,接入时有几个核心关注点:

  1. 生命周期管理是基本功。注册和取消事件必须与页面生命周期严格对应,退后台也要取消,否则会出现意料之外的行为。

  2. 卡片设计影响转化。三种卡片模板由字段组合和图片比例自动决定,了解触发规则后有意识地选择合适的布局,预览图质量要控制在合理范围内。

  3. 异常处理不能省。无内容可分享时用 clarifyNonShare 引导用户,网络异常时用 reject 终止等待。这些细节决定了用户在非理想状态下的体验。

  4. App Linking 是最佳搭档。结合延迟链接和直达应用市场能力,即使对端没有安装应用,也能完成从分享到安装到打开内容的完整链路。

  5. 手机碰 PC/2in1 的姿态要求值得在产品引导中告知用户,避免"碰了没反应"的困惑。沙箱接收能力则为 PC 端应用打开了一种高效的文件接收方式。

使用Compose Navigation3进行屏幕适配

这篇文章将介绍B站是怎么使用 Compose Navigation3 进行页面的宽屏适配,并解决其中遇到的问题的。

本文所涉及到的 Compose 页面均已完成了 CMP 跨平台化适配,内容中基于安卓习惯所提的 “Activity” 如无额外说明均代表各平台的页面容器,即可以直接替换为iOS的UIViewController理解。

Navigation3 简介

Navigation3 是 Google 在 2025 年推出的全新 Compose 导航库,与之前的 Navigation Compose 有本质区别。它不再内置导航图(NavGraph)和 NavHost,而是将导航栈的管理权完全交给开发者,框架只负责"根据栈内容渲染 UI",将 f(data)=UI 的理念扩展到了页面导航栈上。得益于这新的精简的框架概念,使得 Navigation3 能很轻松地跟现有大型app的路由系统搭配整合使用,不像之前的 Navigation 库那样需要将现有路由完全迁移到导航图(NavGraph)声明上。开发者完全可以在单模块内进行 Nav3 的接入使用,同时保持整体的路由声明方式。

虽然使用Compose编写的页面,因其声明式的特性,已经有良好的响应屏幕宽度变化的能力。但是近期出现的超宽、折叠屏手机,包括鸿蒙平台的平板、桌面等设备,会让仅支持响应式布局的页面在超宽显示模式下给用户带来不好的视觉和交互体验。与Navigation3 库同时提出的“WindowSizeClass”中,将屏幕根据宽度划分为小、中、大等各个档位。这种“断点式”的屏幕划分可以指导我们知道在怎么样的情况下将应用的界面显示编排成全屏页面还是分屏页面的形式,显著提高在折叠屏、平板、桌面等“非传统手机”屏幕下的用户呈现能力。

为什么需要纯Compose的导航框架

在b站深入推进业务 CMP 跨平台化的过程中,我们发现缺少一个适配 CMP 属性的页面导航框架是深入业务使用的一大阻碍。

在先前的页面方案中,我们仿照安卓原生实现,选择了为每个导航节点嵌套一个原生window容器,即每打开一个个 Composable 页面都对应一个安卓 Activity 、iOS UIViewController 和 鸿蒙 entry 的创建与展现。这个方案能让我们快速地将 Compose 页面集成到现有工程中,但随后带来了更多其他的问题。

首要面临的问题是内存压力。在 iOS 和鸿蒙中,每打开一个新的原生容器来承载Compose页面,都意味着一个 CAMetalLayer/NativeWindow 被创建,对应3倍大小的render buffer也会被创建在内存中,内存占用就会相应提升。根据我们测算,使用三缓冲区渲染的 iOS,每一个 CAMetalLayer 都会占用约40M的内存。随着接入Compose的页面越来越多、用户打开的页面越来越多,内存压力会不断增长,影响我们的CMP推进进程。

另一个问题是,Compose 上下文内使用的 Lifecycle 系统是基于安卓生命周期概念设计的,在 iOS 和鸿蒙系统中多少有些水土不服,需要 ComposeView 的宿主层进行额外的配置工作,例如将 UIViewController 的 willAppear didAppear等回调桥接到 androidx 生命周期的相应事件上。在“标准容器”无法满足页面展现需求,需要做业务定制的时候,这些额外配置将成为开发过程的摩擦,在接入者不熟悉/没有意识到需要做这些配置的时候,将严重拖慢review和交付进度。

并且,这样的桥接总会丢失准确信息,特别是在页面切换的时候,总会错过准确的生命周期回调,导致在后台执行了额外的工作,引起卡顿、发热等问题;

同时,不正确的生命周期事件会让开发有不正确的预期,这一点会在本文后面详细描述。

基于以上问题的考量,我们得出结论,至少在纯Compose世界内的页面导航切换范围内,我们需要一个纯Compose的导航框架。而刚刚推出正式版、其结构思想契合现代代码开发思路的Navigation3成为我们的首选方案。

路由与导航的区别和联系

在之前的开发理念中,我们往往将"路由"和"导航"混为一谈:一个 URI 既是页面的标识,也是跳转的触发方式。我们将URI标注在一个 Fragment/Activity 上之后,调用“路由”跳转这个URI将直接打开这个页面实例。

@Route("bilibili://some/page")
class SomePageActivity: Activity()

Router.routeTo("bilibili://some/page") // == startActivity(SomePageActivity.class)

然而,在后续的开发和迭代过程中,我们逐渐意识到,这一次跳转动作应当分为两个具体步骤:使用“路由”寻找这个 URI 对应的页面信息,然后使用“导航”组件将这个页面展现在用户面前。

在我们的项目 CMP 化推进过程中,基架团队已经将这个理念应用到了b站的 CMP 版路由组件中,允许业务方在复用公共路由表的查找逻辑和结果的前提下,根据不同页面需要自定义自己的“路由结果导航”行为,为这次的 Nav3 快速接入提供了合适切入点。

数据驱动的声明式导航栈展现

Navigation3 的核心理念是:导航栈就是一个普通的 List,UI 是这个 list 的函数。

class MyBackStack<K : NavKey>(private val list: SnapshotStateList<K>) : SnapshotStateList<K> by list {
    override fun add(item: K){
        // 可以在这里提前处理冲突元素的清理
        list.add(item)
    }
}

@Composable
fun NavPage(modifier: Modifier = Modifier){
    val backStack = remember {
        mutableStateListOf(HomeNavKey)
    }

    NavDisplay(
        backStack = backStack,           // 数据:当前栈内容
        sceneStrategy = ...,             // 策略:如何将栈内容映射为布局
        entryDecorators = listOf(...),   // 装饰器:为每个 entry 注入能力
        entryProvider = entryProvider {   // 注册:NavKey → Composable 的映射
            entry<SomeNavKey> { key -> SomePage(key) }
        },
    )
}

开发者只需要按照自己的页面逻辑操作 backStack ,例如添加、移除,或者“在特定页面入栈时清除其他页面”用来实现“最多只有一个详情页被打开”的情况。NavDisplay 会自动响应变化并重新计算布局。不需要手动调用 navigate()、popBackStack() 等命令式 API,更加贴合 Compose 生态中的开发习惯。

在实际业务中接入使用

在实际的业务场景中使用 Navigation3 ,当然不像其他网络示例那样简单调用。我们将需要深入使用 Nav3 库提供的各种 api ,定制自己的业务功能。

NavKey 与路由发现

在 Nav3 中,NavKey 是描述页面的最小独立元素,每一个 NavKey 类型都跟一个页面绑定,描述了期望被打开的页面的基础信息,例如请求这个页面所需的唯一ID:

@Serializable
@Route("bilibili://some/nav3/page/with/id/{id}")
data class SomeIdPageNavKey(
    val id: String,
    val paramFromQuery: String,
) : NavKey

@Route("bilibili://some/page/with/id/{id}")
@Composable
fun SomePage(id: String, modifier: Modifier = Modifier, paramFromQuery: String = ""){}

因为一个 NavKey 可以跟一个路由严格对应,所以以上这段声明代码完全可以交给路由的 KSP 处理器自动生成。在子页面发起正常的路由跳转请求时,通过拦截器模式拦截此次路由的查找过程,如果找到匹配的 NavKey 类型,则将一个实例添加到backStack栈顶,将普通的导航行为桥接到 Nav3 的导航中。


// 路由拦截器:将普通路由请求桥接到 Nav3 的 backStack
class Nav3RouteInterceptor<KEY : NavKey>(
    private val onNavKeyFound: (KEY) -> Boolean,
) : Interceptor {
    override fun intercept(chain: Interceptor.Chain): Response {
        val originUri = chain.uri
        // 将原始 URI 转换为 Nav3 专用的查找格式
        // 例如 bilibili://some/page/123 → bilibili://some/nav3/page/123
        val navUri = convertToNav3Uri(originUri) ?: return chain.proceed()

        // 在路由表中查找这个 URI 对应的 NavKey 工厂函数
        val target = chain.find(navUri) as? SomeIdPageNavKey

        returnif (key != null && onNavKeyFound(key)) {
            Response.Done  // 拦截成功,阻止后续的默认导航行为(如 startActivity)
        } else {
            chain.proceed() // 未匹配,交给下一个拦截器或默认处理
        }
    }
}

// 在 Nav3 宿主页面中组装拦截器
@Composable
fun Nav3HostPage() {
    val backStack = remember { mutableStateListOf<MyNavKey>(HomeNavKey) }

    // 创建拦截器,拦截成功时将 NavKey 推入栈
    val interceptor = remember {
        Nav3RouteInterceptor<MyNavKey> { key ->
            backStack.add(key)
        }
    }
    // 在原有 Router 上叠加拦截器,生成新的 Router 实例
    val localRouter = LocalRouter.current
    val nav3Router = remember(localRouter, interceptor) {
        localRouter.newBuilder().addInterceptor(interceptor).build()
    }

    NavDisplay(
        backStack = backStack,
        entryDecorators = listOf(
            // 通过 Decorator 将带拦截器的 Router 注入到所有子页面
            // 这样子页面内发起的路由请求也会经过拦截器
            remember { Nav3RouterDecorator(nav3Router) },
            ...
        ),
        ...
    )
}

// Decorator 实现:通过 CompositionLocal 注入 Router
class Nav3RouterDecorator(
    private val router: Router,
) : NavEntryDecorator<MyNavKey>(
    onPop = {},
    decorate = { entry ->
        CompositionLocalProvider(LocalRouter provides router) {
            entry.Content()
        }
    },
)

NavKey 需要支持序列化(用于 backStack 的保存/恢复),因此都标注了 @Serializable;当然,也可以选择统一保存原始跳转链接的string内容,在需要恢复时重新走一次路由查找。

NavKey 与 entry 注册

NavKey 仅能表示“有一个页面”,在 Nav3 中,还需要通过 entryProvider 的方式将“这个 NavKey 对应的页面如何显示” 注册到当前的 NavDisplay 中:

NavDisplay(
    backStack = backStack,
    entryProvider = entryProvider {
        entry<SomeIdPageNavKey>(metadata = BiliListDetailSceneStrategy.detailPane()) { key -> SomePage(key.id, Modifier, key.paramFromQuery) }
    },
 )

当然,这一段注册代码也可以抽象为 EntryProviderScope.() -> Unit 的函数,由路由 KSP 处理器统一生成,页面只需要按需注册即可。

SceneStrategy 与 Scene

SceneStrategy 是 Navigation3 中最关键的扩展点。它接收当前 backStack 中所有 entry,返回一个 Scene 来描述如何布局。

在我们的宽屏适配实践中,我们实现了 BiliDetailSceneStrategy:

class BiliDetailSceneStrategy<K : NavKey>(
    val windowSizeClass: WindowSizeClass,
) : SceneStrategy<K> {
    override fun SceneStrategyScope<K>.calculateScene(entries: List<NavEntry<K>>): Scene<K>? {
        if (windowSizeClass.isAtLeastMedium(...)) {
            // 宽屏:从栈中找到最后一个 List entry 和最后一个 Detail entry
            val listEntry = entries.findLast { it.metadata.containsKey(LIST_KEY) }
                ?: return null
            val detailEntry = entries.findLast {
                it.metadata.containsKey(DETAIL_KEY)
            }
            return BiliListDetailScene(
                listEntry = listEntry,
                detailEntry = detailEntry,
                listWidth = if (windowSizeClass.widthLargeCompat()) 375.dp else300.dp,
            )
        }
        return null  // 非宽屏的情况:返回 null,表示当前 Strategy 不处理这个情况,NavDisplay 将使用默认单页 Strategy
    }
}

BiliListDetailScene 的布局结构:

Row(modifier = Modifier.fillMaxSize()) {
    // 左栏:列表
    Box(modifier = Modifier.width(listWidth)) {
        listEntry.Content()
    }
    VerticalDivider(...)
    // 右栏:详情或占位图
    Box(modifier = Modifier.weight(1f)) {
        if (detailEntry != null) {
            CompositionLocalProvider(LocalBackIconVisibility provides false) {
                detailEntry.Content()
            }
        } else {
            DefaultDetailPlaceholder()
        }
    }
}

每个 entry 通过 metadata 标记自己属于哪个区域。metadata 在注册 entry 时通过 BiliListDetailSceneStrategy.listPane() / detailPane() 设置:

companion object {
    fun listPane()= mapOf("BiliListDetailScene-List" to true)
    fun detailPane()= mapOf("BiliListDetailScene-Detail" to true)
}

SceneStrategy 的 calculateScene 在每次 backStack 变化时都会被调用。如果设备发生折叠/展开,windowSizeClass 变化会触发 BiliListDetailSceneStrategy 的重建(通过 remember(windowSizeClass)),从而自动切换单栏/双栏布局。

踩过的一些坑

从原生导航模式迁移到 Nav3 ,页面的导航方式将发生重大变化,其中有不少在往常开发过程中注意不到的地方。

生命周期、页面重入、状态保存

首先最大的一个变化,是之前每一次导航到一个 Composable 函数页面,都将打开一个全新的 Activity 来承载这个函数体,因此开发们会有一个错误认知:Compose Scope = Activity Scope = ViewModel Scope,在副作用处理上容易出现错误和遗漏,例如:

// ViewModel 中,将 toast 信息作为 State 的一部分暴露
data class PageState(
    val items: List<Item> = emptyList(),
    val toast: ToastContent? = null,  // 一次性事件,混在持久状态中
)

class SomeViewModel : ViewModel(){
    val state: StateFlow<PageState> = ...

    fun onAction(action: Action) {
        // 某些操作会产生 toast
        _state.update { it.copy(toast = ToastContent("操作成功")) }
    }
}

@Composable
fun SomePage(viewModel: SomeViewModel) {
    val state by viewModel.state.collectAsState()
    val toaster = LocalToaster.current

    var otherState = remember { mutableStateOf("") }

    // 通过 snapshotFlow 监听 state 变化来显示 toast
    LaunchedEffect(Unit) {
        snapshotFlow { state.toast }
            .filterNotNull()
            .distinctUntilChanged()
            .collect { toaster.showToast(it.content) }
    }

    // 或者直接判断内容进行显示,都会引发同样的问题。
    // LaunchedEffect(state.toast) {
    //    state.toast?.let { toaster.showToast(it.content) }
    // }

    // ... 页面内容
}

在独立的 Activity 中,这段代码运行起来不会有问题;但是在 Navigation3 的单Activity导航栈模式下,从这个页面跳转到其他页面之后,这个页面将暂时“退出组合”,在返回这个页面之后,重新“进入组合”。

在这个过程中,并不算一次“重组”,而是一次全新的组合事件,上面的代码将会出现:

  1.  不管 LaunchedEffect 的key是什么,都会进入一次执行,snapshotFlow中记录的前值也将被清空,导致 toast 被重复显示;

  2.  通过 remember 保存的状态也被清空,依赖 remember 做的逻辑将回到空态。

针对以上问题,修复思路其实很简单。

对于第一个问题,首先需要开发者确认什么内容该属于“状态”,什么内容该属于“事件”。

在示例代码中,val items: List 属于需要在页面上一直显示的内容,属于业务状态的一部分,使用 StateFlow 和 collectAsState 是很恰当的;而对于 toast 来说,已经显示过一次的toast内容在任何情况下都不该重新出现,因此它该属于“事件流”的一部分,每次消费后都不再重放,因此可以使用 sharedFlow 承载toast的传递,或者每次显示完主动将这个字段清空。

而第二个问题则更简单了,首先区分被 remember 的数据是否能接受丢失,如果是可以丢失的状态(例如,播放中的动画进度)则完全可以不处理;对于真正需要保存的数据,可以通过实现自定义 Saver 使用 rememberSavable 的方式,或者将数据委托给 ViewModel 中保存。

data class PageState(
    val items: List<Item> = emptyList(),
    val toast: ToastContent? = null,
)

class SomeViewModel : ViewModel(){
    private val _state = MutableStateFlow(PageState())
    val state: StateFlow<PageState> = _state.asStateFlow()

    // 将 state 中的 toast 字段转换为事件流(replay=0,重新订阅不重放历史事件)
    val toastEvent: SharedFlow<ToastContent> = state
        .map { it.toast }
        .filterNotNull()
        .shareIn(viewModelScope, SharingStarted.Eagerly, replay = 0)

    fun onAction(action: Action) {
        _state.update { it.copy(toast = ToastContent("操作成功")) }
        // 也可以选择显示后立即清空,确保 toast 状态不会被持久持有
        // _state.update { it.copy(toast = null) }
    }
}

@Composable
fun SomePage(viewModel: SomeViewModel) {
    val state by viewModel.state.collectAsState()
    val toaster = LocalToaster.current

    // 需要跨页面跳转保留的状态,改用 rememberSaveable
    var otherState by rememberSaveable { mutableStateOf("") }

    // 消费事件流:重新进入组合时重新订阅,replay=0 保证不会重放已消费的事件
    LaunchedEffect(Unit) {
        viewModel.toastEvent.collect { toast ->
            toaster.showToast(toast.content)
        }
    }

    // ... 页面内容
}

Navigation3使用额外依赖中的 rememberViewModelStoreNavEntryDecorator() 来提供“页面在pop时清空相应viewmodel”的能力。并且在未来这个依赖和ViewModelStore的能力和api将会发生变化,带来更加强大的定制能力。

屏幕状态感知与返回按钮

宽屏模式下,右栏的页面不需要显示返回按钮(因为左栏始终可见)。可以通过自定义 LocalBackIconVisibility 这个 CompositionLocal 控制:

val LocalBackIconVisibility = compositionLocalOf { true }

// 右栏渲染
if (detailEntry != null) {
    CompositionLocalProvider(LocalBackIconVisibility provides false) {
        detailEntry.Content()
    }
}

子页面中通过读取这个值来决定是否显示返回图标:

val showBackButton = LocalBackIconVisibility.current

窄屏模式下 LocalBackIconVisibility 保持默认值 true,页面正常显示返回按钮。

状态栏的控制

在单 Activity 页面导航框架中, SystemUI 配置(如状态栏颜色)如果允许每个页面、每个组件自由控制,将很容易出现UI闪烁等情况。我们通过 SystemUiConfiguration 收集机制解决:每个 entry 通过 collectSystemUiConfiguration Modifier 上报自己的配置,NavDisplay所在的宿主页面 取栈顶 entry 的配置应用到宿主:

// ① 定义:持有状态栏配置的可观察容器
@Stable
class StableSystemUiConfiguration {
    var statusBarDarkIcons: Boolean? by mutableStateOf(null)
}

// ② 宿主:为每个 NavKey 分配一个 config 对象,并将"锚点 modifier"传给 entry
val configurationMap = remember { mutableStateMapOf<MyNavKey, StableSystemUiConfiguration>() }
val topConfiguration by remember {
    derivedStateOf { backStack.lastOrNull()?.let { configurationMap[it] } }
}

val getCollectorModifier: (MyNavKey) -> Modifier = { key ->
    val config = configurationMap.getOrPut(key) { StableSystemUiConfiguration() }
    // collectSystemUiConfiguration 在 modifier 链中埋入"锚点",持有 config 的引用
    Modifier.collectSystemUiConfiguration(config)
}

NavDisplay(
    // 将栈顶 entry 的配置应用到 Window(状态栏颜色等)
    modifier = Modifier.applySystemUiConfiguration(topConfiguration),
    entryProvider = entryProvider {
        // 在构建 entryProvider 时将 collector modifier 传入页面
        entry<SomeNavKey> { key ->
            SomePage(modifier = getCollectorModifier(key))
        }
    },
    ...
)

// ③ 子页面:将自己期望的状态栏配置追加到 modifier 链上
@Composable
fun SomePage(modifier: Modifier = Modifier) {
    val isDarkTheme = LocalDarkTheme.current
    Box(
        // statusBarDarkIcons 会沿 modifier 链向上查找"锚点",找到后将值写入宿主的 config 对象
        // 节点 attach 时写入,detach 时自动清空,生命周期安全
        modifier = modifier.statusBarDarkIcons(darkIcons = !isDarkTheme)
    ) {
        // 页面内容
    }
}

图片

而 NavDisplay 本身所在的页面中,框架已经传入了一个collectSystemUiConfiguration,并且将实际在 window 中生效。通过显式传递控制链条的方式,我们将状态栏的配置权限限制在页面宿主层级,在这一层让业务根据自己的实际逻辑决定内部组件的生效范围。

返回事件的处理

与 Navigation3 库同时推出的,是 androidx.navigationevent 库,用来响应和发送页面导航事件。Nav3 库默认已经使用了这个依赖库来响应返回事件,其行为是将现有的 backStack 的最新一个元素推出。如果我们需要定制返回事件的处理,可以通过包装 backStack 实现。

需要注意的是,androidx.navigationevent 库会将系统返回手势、系统导航栏返回键、应用顶部导航栏返回按钮或其他主动调用 backHandler.backCompleted() 处的返回事件一同给出,现有的注册层级结构关系不能区分出返回事件的来源行为和来源页面。因此,暂时无法实现“分栏页面各有一个返回按钮,各自控制其栏位的页面pop”交互。

原生页面嵌入

在需要进行宽屏适配的模块中,部分页面仍然是 Android Fragment 实现,尚未迁移到 CMP。我们选择通过 BiliNativePage 将 Fragment 嵌入 Navigation3 体系:

@Composable
internal fun BiliNativePage(url: String, modifier: Modifier){
    val showBackButton = LocalBackIconVisibility.current
    // 1. 通过 Router 解析 URL,获取 Fragment Class
    val routeInfo = Router.newCall(url).find()
    val clazz = routeInfo?.clazz

    // 2. 使用 AndroidFragment 嵌入 Compose
    if (clazz != null) {
        // 3. 因为 AndroidFragment 尚不支持响应state变化主动更新参数,因此选择一个key主动进行重组,通过切换fragment的方式将新的 showBackButton 传入
        // 也可以选择使用 ViewModel 传递 showBackButton 的更新,避免fragment的重建
        key(showBackButton) {
            AndroidFragment(
                clazz = clazz as Class<out Fragment>,
                modifier = modifier,
                arguments = createRouteExtraForFragment(routeInfo).also {
                    it.putBoolean("show_back_button", showBackButton)
                },
            )
        }
    }
}

// Fragment 侧:从 arguments 读取 show_back_button 控制返回按钮显隐
class SomePageFragment : Fragment(R.layout.fragment_some_page) {
    private val showBackButton get() = arguments?.getBoolean("show_back_button", true) ?: true

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        binding.btnBack.isVisible = showBackButton
        binding.btnBack.setOnClickListener { parentFragmentManager.popBackStack() }
    }
}

原生页面的注册可以通过 expect/actual 机制来声明,或者使用依赖注入框架来实现页面注册。

showBackButton 的变化会触发 Fragment 重建(通过 key(showBackButton)),确保 Fragment 能响应宽屏/窄屏切换时返回按钮的显隐变化。

Scene 中的小发现

在尝试在Nav3框架内添加页面切换动画过程中,我调研了官方示例 nav3-recipes 中关于动画切换的部分,结果看到了一段让我始料未及的代码:

override val content: @Composable (() -> Unit) = {
    Row(modifier = Modifier.fillMaxSize()) {
        Column(modifier = Modifier.weight(0.4f)) {
            listEntry.Content()
        }
        ...

        Column(modifier = Modifier.weight(0.6f)) {
            AnimatedContent(
                ...
            ) { entry ->
                entry.Content()
            }
         }
     }
}

其中的 entry.Content() 让我产生了“这能正常触发重组吗?”的疑问🤔。在通常的开发惯例中,Composable 函数一般都是独立于Kotlin class的顶层函数,而不是某个实例的成员函数来被调用,这样 Compose 框架可以通过分析入参是否变化来决定是否重组;如果函数本身是一个类对象的成员函数,那类实例的改变会不会产生类似于key改变的作用、从而触发了这个Composable函数的完全重组呢?

带着这个疑问,我构造了一段测试代码:

@Immutable
data class TestClass(val data: String){
    @Composable fun Content(modifier: Modifier = Modifier){
        Text(data)
    }
}

然后使用 jadx 查看它编译后的产物,看到了关键信息:

public final class TestClass {
    public static final int $stable = 0;
    private final String data;
    /* JADX INFO: Access modifiers changed from: private */
    public static final Unit Content$lambda$0(TestClass testClass, Modifier modifier, int i, int i2, Composer composer, int i3) {
        testClass.Content(modifier, composer, RecomposeScopeImplKt.updateChangedFlags(i | 1), i2);
        return Unit.INSTANCE;
    }
    public TestClass(String data){
        Intrinsics.checkNotNullParameter(data, "data");
        this.data = data;
    }
    public final String getData(){
        returnthis.data;
    }
    public final void Content(Modifier modifier, Composer $composer, finalint $changed, finalint i){
        Composer $composer2;
        final Modifier modifier2;
        Composer $composer3 = $composer.startRestartGroup(507862195);
        ComposerKt.sourceInformation($composer3, "C(Content)N(modifier)160@6025L10:ListDetailScene.kt#qpkuy4");
        int $dirty = $changed;
        if (($changed & 48) == 0) {
            $dirty |= $composer3.changed(this) ? 32 : 16;
        }
        if (!$composer3.shouldExecute(($dirty & 17) != 16, $dirty & 1)) {
            $composer2 = $composer3;
            $composer2.skipToGroupEnd();
            modifier2 = modifier;
        } else {
            Modifier modifier3 = (i & 1) != 0 ? Modifier.INSTANCE : modifier;
            if (ComposerKt.isTraceInProgress()) {
                ComposerKt.traceEventStart(507862195, $dirty, -1, "com.example.nav3recipes.scenes.listdetail.TestClass.Content (ListDetailScene.kt:159)");
            }
            $composer2 = $composer3;
            TextKt.m4145TextNvy7gAk(this.data, null, 0L, null, 0L, null, null, null, 0L, null, null, 0L, 0, false, 0, 0, null, null, $composer2, 0, 0, 262142);
            if (ComposerKt.isTraceInProgress()) {
                ComposerKt.traceEventEnd();
            }
            modifier2 = modifier3;
        }
        ScopeUpdateScope scopeUpdateScopeEndRestartGroup = $composer2.endRestartGroup();
        if (scopeUpdateScopeEndRestartGroup != null) {
            scopeUpdateScopeEndRestartGroup.updateScope(new Function2() { // from class: com.example.nav3recipes.scenes.listdetail.TestClass$$ExternalSyntheticLambda0
                @Override // kotlin.jvm.functions.Function2
                publicfinal Object invoke(Object obj, Object obj2) {
                    return TestClass.Content$lambda$0(this.f$0, modifier2, $changed, i, (Composer) obj, ((Integer) obj2).intValue());
                }
            });
        }
    }
}

会发现,这样一种成员Composable函数的产物跟顶层函数没什么区别,都是使用一个生成的数字key作为restart group的标记,在其中判断参数是否变化时,额外进行了 this 对象的判断,也就是说,可以简单将这个函数定义等价为:

data class TestClass(val data: String)

@Composable
fun Content($this: TestClass, modifier: Modifier = Modifier)

基于这一层理解,就能确认,在nav3-recipes示例工程中,scene发生实例变化的时候,也等价于一个普通的Compose重组,其中可以依靠普通的重组、跳过和remember实现动画播放了。

总结

Navigation3 库的出现,极大地减轻了现有 app 在既存路由框架中接入使用的负担,让我们能快速地将现有的 Compose 页面接入其中,完成宽屏适配;同时也推动开发者在编写 Compose 页面的时候更加深入地思考该如何去适配它的生命周期与状态保存,提示代码的交付质量。

-End-

作者丨肖志康

我写了个系统,每天上朝批奏折:把 Agent 做成「文武百官」是什么体验

我写了个系统,每天上朝批奏折:把 Agent 做成「文武百官」是什么体验

当小人被关进天牢的那一刻,就是朕决定掏钱的那一刻。

写在前面

09_副本.png

先说结论:当你用「拟圣旨」的方式给 Agent 下指令,看着丞相带着六部尚书在 2D 像素宫殿里跑来跑去给你办差——这玩意儿比你想象的更有成就感。

我叫它 Syntropy(太和),一个基于古代朝廷隐喻的可视化多智能体操作系统。但不是那种换个皮就完事的「国潮包装」,而是真的把 Agent 的执行过程「具象化」了:

  • Agent 不再是日志里的一串 tool_call_pending,而是坐在工位上的像素小人
  • 任务调度不再是抽象的 Orchestrator,而是丞相(Minister)在廷议上发号施令
  • 风险拦截不再是冷冰冰的 await human_approval(),而是把 Agent 关进天牢,等你御批

你可能会问:为什么要搞这么复杂?直接写代码不行吗?

因为现有的 Multi-Agent 框架(LangChain、AutoGen、CrewAI……)有一个共同的问题:它们是黑盒。你能看见输入和输出,但中间的思考、决策、调度、执行,全在一团混沌的日志里。你调了半天 prompt,还是不知道 Agent 卡在哪一步。

所以我的思路很简单:既然 Agent 的执行过程看不见,那就让它「看得见」。

08_副本.png

这篇文章会拆解这套系统的技术架构,但不会堆砌术语。咱们边「上朝」边聊:怎么把状态机映射成像素动画、怎么实现前后端实时同步、怎么设计「御批」机制,以及——为什么「当皇帝」这个隐喻,反而让 Agent 系统更好用了。


1. 问题:Agent 系统的「三大弊政」

在动手之前,我先总结了当前 Multi-Agent 系统的三个「弊政」:

1.1 黑盒化(Black Box)——「爱卿,你到底在想什么?」

Agent 的 Chain-of-Thought(思维链)是一串嵌套的 JSON,工具调用是一条条 log 记录。你很难从这些信息里快速判断:

  • Agent 现在在做什么?是在思考,还是在等你审批?
  • 它在等谁?丞相在等户部尚书查账,还是兵部尚书在调兵?
  • 它卡在哪一步了?是 LLM 没返回,还是工具调用超时?
  • 它的决策依据是什么?为什么它选择了这个方案而不是另一个?

01_副本.png

现状:你只能盯着终端滚屏的日志,祈祷 Agent 别卡死。

1.2 失控风险(Uncontrollable)——「爱卿,这奏折朕没批你就敢执行?」

Agent 自主调用工具是一件很危险的事。删除文件、转账、调用外部 API —— 这些操作一旦失控,后果不可逆。

现有的「Human-in-the-loop」方案要么是简单的 input() 阻塞(体验极差),要么需要入侵式地修改整条执行链路(开发成本高)。

现状:你要么相信 Agent 不会乱来,要么就得自己写一套审核机制。

1.3 记忆遗忘(Amnesia)——「爱卿,昨天说的事你怎么忘了?」

大多数 Agent 框架的记忆系统基于关键词匹配或纯向量检索。关键词匹配漏掉语义相关的信息,纯向量检索又容易在专有名词上翻车。长对话场景下,Agent 往往会「忘记」几轮之前说过的关键信息。

现状:你只能不断「提示」Agent,把上下文塞给它,直到 Token 爆掉。


2. 方案:把 Agent 变成「可观测的臣子」

Syntropy 的核心思路只有一句话:

所见即所思(What you see is what they think)。

具体拆解为三个「治国方略」:

2.1 可视化运行时(Visualized Runtime)——「爱卿们,都动起来」

Agent 的内部状态机(THINKINGACTINGWAITINGERROR)不只在日志里打印,而是实时映射为 2D 像素小人的行为动画

  • THINKING → 小人在原地踱步,头顶冒出气泡(正在思考)
  • ACTING → 小人移动到对应的「工位」(户部查账、兵部调兵、工部造器械……)
  • WAITING_FOR_HUMAN → 小人被「关进天牢」,等待你的御批
  • ERROR → 小人倒地,头顶冒叉(出错了,得查日志)

这套映射让 Agent 的执行状态变得一眼可见。你不再需要翻几百行日志,只需要看一眼沙盘,就知道哪个 Agent 卡住了、它在干什么、它在等谁。

2.2 内核级状态机(Kernel-Level State Machine)——「朝堂规矩,不可乱」

后端 Agent 的生命周期被标准化为一个有限状态机(FSM):

agent-state-machine.png 这个 FSM 是「内核级」的,意味着:

  • LLM 推理(Reasoning)和工具执行(Execution)完全解耦
  • 每个状态的进入和离开都会触发标准化的事件(可观测)
  • 风险拦截、记忆压缩、日志追踪都可以作为「状态钩子」无痕插入

简单说:Agent 不再是「自由发挥」,而是按「朝堂规矩」办事。

2.3 人机协同「御批」协议(Human-in-the-loop)——「这道奏折,朕要亲自批」

每个工具调用都有 riskLevel(low / medium / high)。当 Agent 试图执行高风险操作时:

  1. 内核挂起当前执行流,状态切换为 WAITING_FOR_HUMAN
  2. 前端收到 approval_request 事件,弹出「御批」弹窗
  3. 用户点击「准奏」或「驳回」,通过 Socket 发回指令
  4. 内核恢复执行流(或回滚)

这套机制让「人审」不再是事后补救,而是执行流程的有机组成部分


3. 核心功能:奏折阁与决策树

讲完架构,聊聊实际用起来是什么感觉。

3.1 奏折阁(Imperial Archives)——「每道圣旨,都有迹可循」

在 Syntropy 里,每一次用户指令都被封装为一份**「奏折」。奏折阁是系统的任务管理中心,完整记录了从拟旨 → 受理 → 分发 → 复命**的全过程。

奏折的核心特性

  • 折叠/展开:每份奏折默认折叠,仅展示摘要(如"查Q1税收");展开后可查看完整的对话链路与决策树
  • 多视角叙事:左侧展示百官的回复与思考过程,右侧展示皇帝(用户)的指令,清晰还原对话脉络
  • 状态追踪:每份奏折都有明确的状态标签(待处理 / 进行中 / 已完成 / 已驳回),方便追溯

3.2 决策树可视化——「丞相的思考,一目了然」

当丞相收到一道复杂指令(如「查一下上季度的营收,并对比去年同期」),它不会直接给出答案,而是会拆解任务、调度六部、汇总结果。整个过程形成一棵决策树

decision-tree.png

在奏折阁中,这棵决策树以可视化流程图的形式呈现。你可以清楚地看到:

  • 丞相调度了哪些 Agent
  • 每个 Agent 执行了什么操作
  • 每一步的输入和输出是什么
  • 如果某一步出错,具体卡在哪里

02_副本.png

这对调试和优化至关重要。你不再需要猜"Agent 为什么没按我的预期做事",而是可以直接看到它的决策路径,找出问题所在。

3.3 记忆库(Memory Vault)——「史官的起居注」

记忆库展示 Agent 主动保存的重要信息(个人偏好、项目决策、关键事实),支持:

  • 搜索与过滤:按关键词搜索,或按类别(personal / preference / project / decision)筛选
  • 在线编辑:直接在前端编辑或删除记忆条目,实时同步到后端
  • 语义分类:LLM 根据上下文自动选择记忆类别,无需人工标注

4. 技术实现:朝堂是如何运转的

4.1 前端:React + Phaser 的「双引擎」架构

前端是 Syntropy 最复杂的部分。我们需要同时处理两类需求:

  • UI 层:奏折面板、记忆库、官员状态 HUD、御批弹窗……这些是典型的 React 组件
  • 渲染层:2D 像素沙盘、角色动画、寻路、碰撞检测……这些是游戏引擎的领域

我们的方案是 React-Phaser Bridge

frontend-architecture.png

  • Zustand 作为单一数据源(Single Source of Truth),存储所有 Agent 的状态
  • React 组件订阅 Zustand Store,渲染 UI 面板
  • Phaser 3update() 循环中读取 Store,同步小人动画

这套架构的好处是:UI 和渲染完全解耦。React 不用关心像素坐标,Phaser 不用关心业务逻辑,两者通过 Zustand 的状态桥接。

关键代码片段:状态同步
// store/agentStore.ts
export const useAgentStore = create<AgentStore>((set) => ({
  agents: {},
  updateAgent: (id, updates) =>
    set((state) => ({
      agents: {
        ...state.agents,
        [id]: { ...state.agents[id], ...updates },
      },
    })),
}));
// game/MainScene.ts
export class MainScene extends Phaser.Scene {
  update() {
    const agents = useAgentStore.getState().agents;
    Object.values(agents).forEach((agent) => {
      const sprite = this.sprites[agent.id];
      if (sprite) {
        sprite.updateState(agent.status, agent.targetPosition);
      }
    });
  }
}

4.2 后端:自研 Agent 框架

Syntropy 的后端完全自研,不依赖任何现有的 Agent 框架(LangChain、AutoGen 等)。原因很简单:现有框架的状态机模型和我们需要的不完全匹配

后端整体架构

backend-architecture.png

后端核心模块:

模块 职责
Kernel Agent 生命周期管理,状态机调度
Agent 单个 Agent 的 LLM 调用、工具执行、状态流转
LLM Provider 统一的 LLM API 抽象(支持 OpenAI / DeepSeek)
MemoryManager 记忆存储与检索(FTS5 + Vector + RRF)
SocketGateway 前后端实时通信
Tracer 全链路追踪与结构化日志
Agent 核心状态机
// server/core/Agent.ts
class Agent {
  private state: AgentState = 'IDLE';

  async processMessage(message: string) {
    this.setState('THINKING');
    const response = await this.llm.chat(message);
    
    if (response.toolCalls) {
      for (const toolCall of response.toolCalls) {
        const risk = this.assessRisk(toolCall);
        if (risk === 'high') {
          this.setState('WAITING_FOR_HUMAN');
          await this.waitForApproval(toolCall);
        }
        await this.executeTool(toolCall);
      }
    }
    
    this.setState('IDLE');
    return response.content;
  }
}

4.3 记忆系统:RRF 混合检索引擎

Syntropy 的记忆系统不是纯向量检索,而是三位一体的混合架构:

  1. SQLite:结构化元数据(时间、类别、Agent ID)
  2. FTS5:全文倒排索引,精准匹配关键词
  3. Vector:语义向量,模糊语义检索

检索时,我们使用 Reciprocal Rank Fusion (RRF) 算法合并 FTS 和 Vector 的结果:

RRF_score = Σ (1 / (k + rank_i))

其中 k 是平滑常数(通常取 60),rank_i 是某条记忆在第 i 个检索引擎中的排名。

为什么需要 RRF?
  • 关键词场景:用户问「昨天的税收是多少」,FTS 能精准命中包含"税收"的记录,Vector 可能因为语义漂移而漏掉
  • 语义场景:用户问「最近有什么异常吗」,FTS 因为没有一个明确的关键词而失效,Vector 能理解"异常"的语义

RRF 让两者互补,召回率显著提升。

记忆压缩(Memory Compression)

当 Agent 进入 SLEEPING 状态时,系统自动调用 LLM 对当日未处理的对话进行摘要,生成 daily_summary 并持久化。这解决了长对话场景下的 Token 溢出问题。

// server/runtime/MemoryManager.ts
async compressMemories(agentId: string, conversations: Conversation[]) {
  const summary = await this.llm.chat(`
    请对以下对话进行摘要,提取关键决策和待办事项:
    ${conversations.map(c => c.content).join('\n')}
  `);
  
  await this.db.insert('memories', {
    agentId,
    type: 'daily_summary',
    content: summary,
    timestamp: Date.now(),
  });
}

4.4 全链路追踪(Tracer)

每个用户指令生成唯一的 traceId,贯穿 Agent 调度、工具调用、LLM 推理全流程。Tracer 记录 8 种诊断事件:

  • agent.turn:Agent 开始处理一轮对话
  • tool.call:工具调用
  • model.usage:LLM 调用及 Token 消耗
  • dispatch:任务分发
  • approval.wait:等待御批
  • approval.done:御批完成
  • agent.stuck:Agent 卡死检测(3 分钟无响应自动告警)
  • memory.save:记忆保存

所有事件自动脱敏(截断 API Key 等敏感信息),便于性能分析和故障排查。


5. 架构反思

5.1 为什么选 Phaser 而不是 Canvas / SVG?

Phaser 是一个专业的 2D 游戏引擎,提供了完整的场景管理、精灵动画、碰撞检测、寻路算法。如果用 Canvas 手写,这些都要从零实现;如果用 SVG,大量 DOM 元素会导致性能问题。

Phaser 的 WebGL 渲染让我们可以轻松实现:

  • 像素小人的平滑移动动画
  • 头顶气泡的动态效果
  • 大规模 Agent 同时活动时的性能保障

5.2 为什么不直接用 LangChain / AutoGen?

LangChain 和 AutoGen 的状态机模型是「扁平」的:Agent 顺序执行 Thought → Action → Observation。但我们需要的是内核级的状态机,能够:

  • 在任意时刻挂起执行流(御批拦截)
  • 在任意时刻注入新状态(外部干预)
  • 标准化所有状态转换事件(可观测性)

现有的框架很难在不破坏封装的前提下实现这些需求。

5.3 视觉隐喻的取舍

「古代朝廷」这套视觉系统是一把双刃剑:

优点

  • 降低了多 Agent 系统的认知门槛,非技术用户也能理解「丞相调度六部」的概念
  • 增强了产品的辨识度和传播性

缺点

  • 增加了设计和开发成本(像素素材、动画、场景布局)
  • 对部分技术用户来说可能显得「花哨」

我们的判断是:可视化的核心价值在于降低认知负担,视觉隐喻只是手段。如果换一套视觉系统(如太空站、工厂流水线),只要核心架构不变,价值依然存在。


6. 开源与未来

Syntropy 目前处于 Beta 阶段,代码已开源:GitHub - zabr1314/Syntropy

未来规划:

  • 技能市场:允许开发者为 Agent 开发自定义技能(类似 VSCode 插件)
  • 多场景模板:除了「朝廷」,提供「太空站」「工厂」等可选视觉主题
  • Agent 编排可视化:拖拽式构建多 Agent 协作链路
  • 分布式部署:支持将不同 Agent 部署在不同节点上

7. 结语:当皇帝的一天

Syntropy 本质上是一次将 AI 黑盒具象化的尝试。我们相信:

当 Agent 的执行过程变得可见、可交互、可干预,多智能体系统才能真正从实验室走向生产环境。

但除此之外,它还有一个不那么「技术」的价值:它让管理 Agent 变成了一件有趣的事

每天早上打开系统,看到丞相已经在廷议上等你,六部尚书各就各位。你拟定一道圣旨,看着小人们在宫殿里跑来跑去办差。有时候他们会卡住,有时候他们会犯错,有时候他们会把奏折递到你面前等你御批——这一刻,你不是在 debug,你是在当皇帝。

如果你对这套架构感兴趣,或者有自己的看法,欢迎在 GitHub 上交流。


相关资源

鸿蒙隔空传送:一抓一放,内容就到了对面的设备上

引言

跨设备传文件这件事,我们已经做了太多年了——蓝牙配对、扫码互传、聊天窗口转发、甚至给自己发邮件。这些方式都能用,但都不够"顺手"。

鸿蒙 Share Kit 新推出的隔空传送,尝试用一种更自然的交互来解决这个问题:用户对着屏幕做一个"抓取"手势,内容就被"拿"起来了,再对着另一台设备"放下",内容就传过去了。整个过程不需要打开任何传输工具,也不需要手动选择接收设备。

本文面向希望在应用中接入隔空传送能力的鸿蒙开发者,梳理这项功能的工作机制、与系统其他功能的联动关系,以及具体的接入方法。


一、隔空传送是怎么工作的

1.1 基本交互逻辑

隔空传送的核心交互是"一抓一放"——用户在一台设备前做出握拳抓取的手势,设备捕捉到这个动作后,将当前页面的分享内容"抓起来";然后用户面向另一台设备做出释放手势,内容就传送到了对端设备上。

这个手势并不是凭空工作的。它依赖应用侧主动注册分享事件——只有当前页面注册了隔空传送的监听,系统才知道"这个页面有东西可以分享"。如果页面没注册,手势不会触发隔空传送。

1.2 使用前提:打开隔空传送开关

隔空传送默认不是开启状态,用户需要手动打开:

设置 → 系统 → 快捷启动和手势 → 隔空传送

这是设备级的开关,对所有支持隔空传送的应用生效。

1.3 设备信任机制

传输的安全性通过设备信任关系来保障,分两种情况:

  • 同账号设备:如果两台设备登录了相同的华为账号,系统默认它们互相信任,传输时无需额外确认,直接发送。
  • 不同账号设备:需要双端用户各自确认"信任对方设备"。确认后,1 小时内再次传输无需重复确认。超过 1 小时则需要重新建立信任。

这种设计在便捷性和安全性之间取了一个平衡——自己的设备间传东西零障碍,借别人设备传也不会被滥用。


二、隔空传送与隔空截屏的关系

一个容易让人困惑的地方是:隔空传送和隔空截屏共用同一个手势触发。这意味着用户做"抓取"动作时,可能同时触发两件事。系统通过两个开关的组合状态来决定具体行为:

隔空传送开启 隔空传送关闭
隔空截屏开启 图库场景传输原图;其他场景传送截屏 仅截屏,不传送
隔空截屏关闭 图库场景传送原图;其他场景无截屏也不传送 什么都不发生

几个值得注意的细节:

  • 当两个开关都打开,且当前页面注册了隔空传送事件时,抓取手势会同时触发隔空传送和隔空截屏。此时隔空传送的卡片下方会出现"保存截屏至本机"的提示。
  • 首次触发时,默认不保存截屏。用户可以手动勾选保存,系统会记住这个选择,作为下次的默认值。
  • 如果只开了隔空截屏、没开隔空传送,那抓取手势就只是截屏,不会有任何传输行为。

对于开发者来说,不需要关心截屏逻辑——这完全是系统层面的行为。你只需要关注隔空传送的注册和数据准备。


三、接入隔空传送的开发实践

接入隔空传送的核心工作就是三件事:在对的时机注册监听、在回调中准备分享数据、在离开时取消监听

3.1 导入所需模块

import { uniformTypeDescriptor as utd } from '@kit.ArkData';
import { systemShare, harmonyShare } from '@kit.ShareKit';
import { fileUri } from '@kit.CoreFileKit';

这里涉及三个 Kit:ShareKit 提供隔空传送和分享的核心能力,ArkData 中的 uniformTypeDescriptor 用于声明分享内容的数据类型,CoreFileKit 用于将文件路径转换为 URI。

3.2 定义手势触发时的分享逻辑

当用户做出抓取手势时,系统会通过注册的回调把一个 SharableTarget 对象传给你。你需要在这个回调里准备好分享数据,然后调用 sharableTarget.share() 把数据发出去。

private immersiveCallback = (sharableTarget: harmonyShare.SharableTarget) => {
  let uiContext: UIContext = this.getUIContext();
  let contextFaker: Context = uiContext.getHostContext() as Context;
  let filePath = contextFaker.filesDir + '/exampleKnock1.jpg';

  let shareData: systemShare.SharedData = new systemShare.SharedData({
    utd: utd.UniformDataType.HYPERLINK,
    content: 'https://sharekitdemo.drcn.agconnect.link/ZB3p',
    thumbnailUri: fileUri.getUriFromPath(filePath),
    title: '隔空传送分享卡片标题',
    description: '隔空传送分享卡片描述'
  });

  sharableTarget.share(shareData);
}

这里有一个关键的时间约束:收到回调后,建议在 3 秒内调用 sharableTarget.share(),否则可能因为超时导致传输失败。所以分享数据的准备不宜太重——如果需要读取大文件或做复杂处理,建议提前准备好,在回调中直接使用。

分享数据通过 SharedData 构造,几个关键字段的含义:

  • utd:声明分享内容的统一数据类型。上面的示例使用的是 HYPERLINK,表示分享一个链接。
  • content:实际的分享内容,这里是一个 App Linking 链接。
  • thumbnailUri:传送卡片上显示的缩略图,需要传入文件的 URI。
  • titledescription:传送卡片上的标题和描述文字。

3.3 分享 App Linking 实现直达应用

上面的示例中,content 字段传入的是一个 App Linking 链接。这样做的好处是:对端设备收到后,点击卡片可以直接跳转到对应的应用页面,而不只是打开一个网页。如果你希望实现这种"传送即直达"的体验,应用需要先接入 App Linking。

3.4 在正确的时机注册和取消监听

这一步直接决定了功能能否正常工作。原则很简单:

  • 进入可分享页面时注册,告诉系统"这个页面有内容可以分享"。
  • 离开可分享页面时取消,包括页面销毁和应用退到后台的场景。

注册和取消的方法很直接:

private immersiveListening() {
  harmonyShare.on('gesturesShare', this.immersiveCallback);
}

private immersiveDisablingListening() {
  harmonyShare.off('gesturesShare', this.immersiveCallback);
}

但"什么时候算离开"需要仔细处理。除了页面销毁(aboutToDisappear),应用退到后台也应该取消监听。一个完整的处理方式如下:

aboutToAppear(): void {
  // 页面出现时注册监听
  this.immersiveListening();

  // 同时监听应用退后台事件
  let uiContext: UIContext = this.getUIContext();
  let context: Context = uiContext.getHostContext() as Context;
  context.eventHub.on('onBackGround', this.onBackGround);
}

aboutToDisappear(): void {
  // 页面销毁时取消监听
  this.immersiveDisablingListening();

  let uiContext: UIContext = this.getUIContext();
  let context: Context = uiContext.getHostContext() as Context;
  context.eventHub.off('onBackGround', this.onBackGround);
}

// 页面隐藏时(包括退后台),通过事件总线通知
onPageHide(): void {
  let uiContext: UIContext = this.getUIContext();
  let context: Context = uiContext.getHostContext() as Context;
  context.eventHub.emit('onBackGround');
}

private onBackGround = () => {
  this.immersiveDisablingListening();
}

这里的思路是:通过 onPageHide 捕捉页面隐藏事件(退后台会触发),然后通过 eventHub 发出通知,在通知处理中取消隔空传送监听。这样无论是页面销毁还是应用退后台,监听都会被正确清理。

为什么要这么认真地处理取消监听? 因为如果应用退到后台后监听还在,用户在其他应用中做抓取手势时,可能会意外触发你的应用的分享逻辑,导致不可预期的行为。


四、总结

隔空传送提供了一种非常直觉化的跨设备内容传输方式。从开发者的角度看,它的接入并不复杂,但有几个关键点需要把握好:

  1. 时效性:收到手势回调后 3 秒内必须完成分享调用。分享数据要提前准备,不要在回调中做耗时操作。
  2. 生命周期管理:注册和取消监听必须与页面的生命周期严格对应。特别是应用退后台的场景,容易被忽略。
  3. 与隔空截屏的共存:理解两个开关的组合行为,在产品设计上给用户清晰的预期。
  4. 信任机制:同账号设备间传输是无感的,不同账号需要双向确认。如果你的应用场景经常涉及跨账号传输,可以在引导中提示用户。

如果你的应用有内容分享的需求——无论是链接、图片还是文件——隔空传送都是一种值得接入的自然交互方式。结合 App Linking,还能实现"传送即打开对应页面"的完整体验,这在跨设备协作场景中会非常实用。

救命!原来大厂前端都是这样封装 Axios 的… 我白干了三年

还在每个接口手动加 token?还在为 401 跳转写重复逻辑?
而用 这套 2026 年最新 Axios 通用封装一行配置搞定全局拦截、自动鉴权、错误统一处理、防重复请求——Vue2/Vue3、React、Uniapp、微信小程序、Node.js 全端兼容,线上项目稳定运行超 18 个月

如果你受够了:

  • 每个项目都要重写一遍 request
  • 登录过期后页面白屏没人管
  • 用户狂点按钮,接口被刷爆
  • 小程序和 H5 请求逻辑不一致,维护成本翻倍

那么,这篇经过字节、腾讯内部验证的封装方案,就是为你写的——
不用造轮子,直接复制粘贴,今天就能让接口层稳如泰山

一、先说痛点:裸写 Axios 的 5 大“致命伤”

问题 后果
每次手动拼 baseURL 开发/测试/线上环境混乱
token 手动携带 切换账号后部分接口 401
错误各自处理 有的弹 toast,有的 console.log
无防重机制 用户狂点提交,订单创建 5 次
响应结构不统一 res.data / res.result / res.payload 混用

真实案例:某电商项目因未防重复请求,大促期间用户重复下单,损失超 200 万。

二、核心方案:一个文件,搞定所有(附完整可运行代码)

文件路径:src/utils/request.js

import axios from 'axios'

// ===== 1. 创建实例 =====
const service = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
  timeout: 10000,
  headers: {
    'Content-Type': 'application/json;charset=UTF-8'
  }
})

// ===== 2. 防重复请求(关键!)=====
const pending = new Map()
const getPendingKey = (config) =>
  [config.method, config.url, JSON.stringify(config.params), JSON.stringify(config.data)].join('&')

const removePending = (config) => {
  const key = getPendingKey(config)
  if (pending.has(key)) {
    pending.get(key)?.abort?.() // 取消上一次请求
    pending.delete(key)
  }
}

// ===== 3. 请求拦截器 =====
service.interceptors.request.use(
  (config) => {
    // 防重:取消相同请求
    removePending(config)
    const controller = new AbortController()
    config.signal = controller.signal
    pending.set(getPendingKey(config), controller)

    // 自动加 token(兼容 localStorage / uni.getStorageSync)
    const token = typeof localStorage !== 'undefined'
      ? localStorage.getItem('token')
      : uni.getStorageSync('token') // 小程序适配

    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => Promise.reject(error)
)

// ===== 4. 响应拦截器 =====
service.interceptors.response.use(
  (response) => {
    // 清除 pending
    removePending(response.config)

    const res = response.data
    // 假设后端 code=200 为成功(按实际调整)
    if (res.code === 200) {
      return res.data // 直接返回业务数据
    }

    // 统一错误提示
    uni.showToast?.({ title: res.msg || '操作失败', icon: 'none' }) // 小程序
    alert?.(res.msg || '请求失败') // Web
    return Promise.reject(res)
  },
  (error) => {
    removePending(error.config)

    let msg = '网络异常,请稍后重试'
    if (error.message?.includes('timeout')) msg = '请求超时'
    if (error.code === 'ECONNABORTED') msg = '请求已取消'
    if (error.response?.status === 401) {
      msg = '登录已过期'
      // 清 token + 跳登录
      localStorage.removeItem?.('token')
      uni.removeStorageSync?.('token')
      location.href = '/login' // Web
      uni.reLaunch?.({ url: '/pages/login/login' }) // 小程序
    }
    if (error.response?.status === 403) msg = '权限不足'
    if (error.response?.status === 500) msg = '服务器开小差了'

    uni.showToast?.({ title: msg, icon: 'none' })
    alert?.(msg)
    return Promise.reject(error)
  }
)

export default service

亮点

  • 自动防重复请求(基于 URL + 参数)
  • Web / 小程序双端兼容(localStorage vs uni.getStorageSync
  • 401 自动跳登录页
  • 返回值直接是 data,业务层无需再 .data.data

三、业务调用:极简写法,框架无关

1. 定义 API:src/api/user.js

import request from '@/utils/request'

// 获取用户信息
export const getUserInfo = () => request.get('/user/info')

// 登录
export const login = (data) => request.post('/user/login', data)

// 上传头像
export const uploadAvatar = (file) => {
  const formData = new FormData()
  formData.append('avatar', file)
  return request.post('/upload/avatar', formData, {
    headers: { 'Content-Type': 'multipart/form-data' }
  })
}

2. 页面中使用(Vue/React 完全一致)

import { getUserInfo } from '@/api/user'

async function loadProfile() {
  try {
    const userInfo = await getUserInfo() // 直接拿到 data
    setUser(userInfo)
  } catch (err) {
    // 全局已处理错误,此处可做特殊逻辑(如埋点)
    console.log('获取用户信息失败', err)
  }
}

优势:业务代码只关心“成功后的数据”,错误由拦截器兜底!

四、多端适配指南(一套代码跑全端)

环境 适配方案
Vue2/Vue3 直接使用上述代码
React 同上,alert 可替换为 message.error
Uniapp 使用 uni.request 封装,但逻辑结构一致
微信小程序 引入 miniprogram-axios,其余不变
Node.js 移除 UI 相关(toast/alert),保留核心逻辑

技巧:通过 typeof window !== 'undefined' 判断是否为 Web 环境。

五、避坑指南:3 个高频雷区

坑1:baseURL 写死,环境切换崩溃

正确做法

# .env.development
VITE_API_BASE_URL = 'https://dev.api.com'

# .env.production
VITE_API_BASE_URL = 'https://prod.api.com'

坑2:401 不清 token,导致无限跳转

必须在 401 处理中同步清除本地 token,否则跳回登录页后仍带旧 token。

坑3:防重逻辑没覆盖 POST 参数

很多方案只比对 URL 和 params,POST 的 data 也要参与 key 生成,否则表单提交仍会重复。

六、进阶扩展(按需添加)

  • 自动刷新 token:401 时用 refresh_token 换新 token,重发原请求
  • 请求日志:记录耗时、参数,用于性能分析
  • Mock 支持:开发环境自动 mock,不影响联调
  • 签名加密:金融类项目必备,请求前自动加签

这套方案已在多个百万级用户项目中稳定运行,不是玩具代码,而是生产级骨架
当你不再为接口错误焦头烂额,你就知道——这波封装,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

鸿蒙文件预览开发实践:从打开文件到加速感知

引言

在很多应用场景中,用户需要快速查看一个文件的内容——可能是一份文档、一张图片、一段视频,或者一个纯文本文件。用户的预期很简单:点一下就能看,不需要专门打开另一个编辑器。

鸿蒙的 Preview Kit 提供的就是这种能力。它以拉起新窗口的方式展示文件内容,界面统一、使用简单,开发者不需要自己去实现各类文件格式的渲染逻辑。此外,从 5.0.5(17) 版本开始,Preview Kit 还引入了文件预加载状态感知的能力,让浏览器等下载类应用可以提前感知文件是否已被系统预加载,从而给用户更明确的"加速打开"提示。

本文面向有一定鸿蒙开发基础的开发者,梳理 Preview Kit 的使用方式、开发流程中的关键环节,以及文件预加载状态感知的接入方法。


一、Preview Kit 能做什么,不能做什么

在动手之前,先厘清 Preview Kit 当前的能力边界,避免走弯路。

能做的事:

  • 拉起一个独立的预览窗口,以统一的界面展示文件内容。
  • 支持图片、视频、音频、文本、HTML 等常见文件类型的预览。
  • Office 类文档(如 .docx、.xlsx 等)借助 WPS 的能力来渲染,所以预览界面中会出现"WPS 提供技术支持"等字样——这是正常的。
  • 支持单文件预览和多文件预览(多文件仅限移动端)。
  • 预览窗口是单例的,同一时间只会存在一个。

当前的限制:

  • 只支持跳出应用预览,暂不支持在应用内嵌入预览视图。如果你想在应用页面里直接内嵌一个文件预览区域,目前 Preview Kit 还做不到。
  • 没有安全定制能力。禁止截录屏、屏蔽"使用其他应用打开"、屏蔽分享入口等安全相关的控制,暂时都不支持。
  • 需要文件的访问权限。调用方必须具备对应 URI 的转授权能力,让预览窗口能正常读取文件内容。这一点在实际开发中容易踩坑,如果预览时报错,优先检查权限问题。

二、文件预览的完整开发流程

Preview Kit 的使用流程可以归纳为:判断能否预览 → 打开预览 → (可选)切换文件 → (可选)关闭预览。下面逐步展开。

2.1 导入模块

import { filePreview } from '@kit.PreviewKit';
import { BusinessError } from '@kit.BasicServicesKit';

filePreview 是 Preview Kit 的核心模块,BusinessError 用于错误处理。

2.2 先判断文件能不能预览

在正式打开预览之前,最好先检查一下目标文件是否支持预览。这一步不是必须的,但可以帮你在 UI 层做更好的引导——比如对不支持预览的文件,显示"不支持预览"的提示而不是让用户点了没反应。

let uri = 'file://docs/storage/Users/currentUser/Documents/1.txt';
let uiContext = this.getUIContext().getHostContext() as Context;

filePreview.canPreview(uiContext, uri).then((result) => {
  console.info(`是否可预览: ${result}`);
}).catch((err: BusinessError) => {
  console.error(`判断失败, err.code = ${err.code}, err.message = ${err.message}`);
});

当传入的文件类型在支持范围内(图片、视频、音频、文本、HTML)且文件确实存在时,返回 true;否则返回 false

2.3 打开预览窗口

这是最核心的一步。Preview Kit 提供了两种异步调用风格——Promise 和 Callback,功能完全一样,选择你习惯的即可。

Promise 方式:

let uiContext = this.getUIContext().getHostContext() as Context;

let displayInfo: filePreview.DisplayInfo = {
  x: 100, y: 100,
  width: 800, height: 800
};

let fileInfo: filePreview.PreviewInfo = {
  title: '1.txt',
  uri: 'file://docs/storage/Users/currentUser/Documents/1.txt',
  mimeType: 'text/plain'
};

filePreview.openPreview(uiContext, fileInfo, displayInfo).then(() => {
  console.info('预览打开成功');
}).catch((err: BusinessError) => {
  console.error(`打开失败, err.code = ${err.code}, err.message = ${err.message}`);
});

这里有几个关键参数需要说明:

  • PreviewInfo:描述要预览的文件,包含标题(title)、文件路径(uri)和 MIME 类型(mimeType)。MIME 类型告诉系统这是什么格式的文件,以便选择正确的渲染方式。
  • DisplayInfo:控制预览窗口的位置和大小,包括 x/y 坐标以及宽高。

有一个细节值得注意:1 秒内重复调用 openPreview 是无效的。这是为了防止用户快速连续点击导致重复打开窗口,开发者不需要自己加防抖逻辑。

Callback 方式:

filePreview.openPreview(uiContext, fileInfo, displayInfo, (err) => {
  if (err && err.code) {
    console.error(`打开失败, err.code = ${err.code}, err.message = ${err.message}`);
    return;
  }
  console.info('预览打开成功');
});

2.4 一次预览多个文件(仅移动端)

在移动端场景下,你可以一次传入多个文件,用户可以在预览窗口中左右滑动切换。传入时通过 index 参数指定默认展示第几个文件(从 0 开始):

let files: Array<filePreview.PreviewInfo> = [];

files.push({
  title: '1.txt',
  uri: 'file://docs/storage/Users/currentUser/Documents/1.txt',
  mimeType: 'text/plain'
});

files.push({
  title: '2.txt',
  uri: 'file://docs/storage/Users/currentUser/Documents/2.txt',
  mimeType: 'text/plain'
});

filePreview.openPreview(uiContext, files, 0).then(() => {
  console.info('多文件预览打开成功');
}).catch((err: BusinessError) => {
  console.error(`打开失败, err.code = ${err.code}, err.message = ${err.message}`);
});

这个能力比较适合文件管理器、聊天应用中查看多张图片等场景。

2.5 在已有窗口中切换文件

如果预览窗口已经打开了,你不需要关闭再重新打开来展示另一个文件。loadData 方法可以直接在当前窗口中加载新的文件内容:

let newFile: filePreview.PreviewInfo = {
  title: '2.txt',
  uri: 'file://docs/storage/Users/currentUser/Documents/2.txt',
  mimeType: 'text/plain'
};

filePreview.loadData(uiContext, newFile).then(() => {
  console.info('文件加载成功');
}).catch((err: BusinessError) => {
  console.error(`加载失败, err.code = ${err.code}, err.message = ${err.message}`);
});

需要注意的是,loadData 只在预览窗口已经存在时才生效,而且 100 毫秒内重复调用无效(比 openPreview 的 1 秒限制更短,因为切换文件的操作相对轻量)。如果传入的文件不支持预览,窗口会显示"不支持预览"的界面,而不是报错。

2.6 关闭预览窗口

filePreview.closePreview(uiContext).then(() => {
  console.info('预览窗口已关闭');
}).catch((err: BusinessError) => {
  console.error(`关闭失败, err.code = ${err.code}, err.message = ${err.message}`);
});

同样只在预览窗口存在时生效。另外,你还可以通过 hasDisplayed 方法来判断预览窗口当前是否处于打开状态,在一些需要条件判断的逻辑中会比较有用。


三、文件预加载状态感知:让"秒开"看得见

从 5.0.5(17) 版本开始,Preview Kit 新增了一项面向特定场景的能力——文件预加载状态感知。这个功能目前仅在 2in1 设备上支持,典型的使用者是浏览器等支持下载文件的应用。

3.1 这项能力解决什么问题

系统有时会对用户可能打开的文件进行预加载,也就是提前把文件内容读入缓存,让后续打开更快。但问题在于,用户并不知道哪些文件已经被预加载了。如果应用能感知到某个文件的预加载状态,就可以在 UI 上给用户一个明确的提示——比如一个"闪电"图标表示"这个文件可以秒开",体验上会好很多。

3.2 三种预加载状态

一个文件的预加载状态有三种:

状态 含义 建议的 UI 处理
PRELOADING 正在预加载中 可以显示加载动画
PRELOADED 预加载已完成 提示用户"加速打开"已就绪
NOT_PRELOADED 没有预加载 不需要额外提示

3.3 开发流程

整个接入过程分为四步:注册回调 → 添加文件监听 → 响应状态变化 → 清理资源。

第一步,导入模块并注册回调:

import { openFileBoost } from '@kit.PreviewKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

function callback(info: openFileBoost.FilePreloadStatusInfo): void {
  if (info.state === openFileBoost.FilePreloadState.PRELOADING) {
    hilog.info(0x0000, 'testTag', '文件正在预加载,可以展示加载动画');
  }
  if (info.state === openFileBoost.FilePreloadState.PRELOADED) {
    hilog.info(0x0000, 'testTag', '预加载完成,可以提示用户加速打开');
  }
  if (info.state === openFileBoost.FilePreloadState.NOT_PRELOADED) {
    hilog.info(0x0000, 'testTag', '未预加载,无需额外提示');
  }
}

// 注册监听
openFileBoost.on('filePreloadStateChanged', callback);

第二步,添加需要监听的文件:

注册回调之后,通过 addFile 传入文件的沙箱路径。之后这个文件的预加载状态一旦变化,就会通过上面注册的回调通知你。

const file = "/storage/Users/currentUser/Desktop/10MB_file.docx";
openFileBoost.addFile(file);

一个典型场景是:用户通过浏览器下载了一个文件,下载完成后,应用将该文件路径注册进来,后续如果系统对其进行了预加载,应用就能及时在下载列表中标记出来。

这里有一个限制需要注意:单个应用最多同时监听 50 个文件。不再需要关注的文件,应该及时调用 removeFile 取消监听,把名额腾出来:

openFileBoost.removeFile(file);

第三步,主动查询预加载状态:

除了被动等回调,你也可以主动查询某个文件当前的预加载状态。这在应用刚启动时特别有用——可以遍历一遍关心的文件,把已经预加载完成的标记出来:

let statusInfo = openFileBoost.queryFilePreloadStatusInfo(file);
hilog.info(0x0000, 'testTag',
  `文件: ${statusInfo.sandboxPath}, 进度: ${statusInfo.progress}, 状态: ${statusInfo.state}`);

返回的 FilePreloadStatusInfo 不仅包含状态,还有加载进度信息,可以用于更细粒度的 UI 展示。

第四步,清理资源:

当不再需要监听任何文件的预加载状态时,注销回调。off 方法支持两种用法:传入具体的回调函数则只取消该函数的监听,不传参数则取消当前进程的所有回调。

// 只取消某一个回调
openFileBoost.off('filePreloadStateChanged', callback);

// 取消所有回调
openFileBoost.off('filePreloadStateChanged');

3.4 接入前的准备

因为这项能力目前仅在 2in1 设备上可用,在接入之前,建议先通过 Syscap 查询目标设备是否支持 SystemCapability.PCService.OpenFileBoost。对于不支持的设备类型(如手机、平板),不需要也不应该调用这些接口。

另外,当前支持预加载的文件类型是有限的,不在支持范围内的文件类型默认为"未预加载"状态,不需要额外注册监听。


四、总结与实践建议

Preview Kit 的两项能力——文件预览和预加载状态感知——分别解决的是"怎么看文件"和"怎么更快地看文件"的问题。

对于文件预览,开发者要抓住几个要点:

  1. 权限先行。预览的前提是文件可被访问,URI 的转授权是最常见的问题来源。
  2. 善用 canPreview 做前置判断。在 UI 层提前给用户预期,好过让用户点击后看到失败。
  3. 预览窗口是单例的。不需要担心重复打开的问题,但切换文件时记得用 loadData 而不是重新调用 openPreview
  4. 留意调用频率限制openPreview 有 1 秒的防重复机制,loadData 是 100 毫秒。

对于预加载状态感知,如果你的应用运行在 2in1 设备上且涉及文件下载或管理,这是一个值得接入的体验增强点。用户能直观看到哪些文件可以"秒开",点击的信心和满意度都会更高。接入时注意控制监听文件数量(上限 50 个),及时清理不再需要的监听,保持资源使用的整洁。

AI Mind v0.0.8:从单 Skill 到多 Skill,我如何让第二个 Skill 真正成立

本文对应项目版本:v0.0.8

v0.0.7 里,我已经给 AI Mind 落下了第一个正式 Skill:utility-skill

那一版的重点,是证明一件事:

在 Multi-Tool Runtime 之上,是否真的能再长出一层更稳定的能力模式。

但只有一个 Skill,其实还不够。

因为单 Skill 最多只能证明:

  • 这套结构能跑
  • Runtime 能感知 Skill
  • Tool 可以被 Skill 收口

它还证明不了另一件更关键的事:

当系统开始进入多 Skill 阶段时,这层抽象到底是不是真的成立。

所以到了 v0.0.8,我真正要回答的问题就变成了:

  1. 第二个 Skill 应该是什么
  2. 什么样的 Skill 才值得进入正式版本
  3. 多 Skill Runtime 的边界应该怎么收
  4. 前端又该怎么把这种能力模式切换表达出来

这篇文章想讲的,就是我如何从最初的 writer-skill 设想,最后收敛到了 reader-skill,并让 AI Mind 真正迈出“从单 Skill 到多 Skill”的第一步。

skill-1.gif

为什么多 Skill 是这一版必须面对的问题

如果项目一直只有一个 utility-skill,那 Skill Runtime 很容易停留在一个比较尴尬的状态:

  • 看起来像是做出了一层新抽象
  • 但又很难证明它不是一次性的特殊 case

因为只有一个 Skill 时,你很难回答这些问题:

  • Skill 之间的边界能不能真正拉开
  • Runtime 是否能根据不同 Skill 暴露不同 Tool 子集
  • 自动模式下的路由是否还有意义
  • 前端是否需要为不同 Skill 提供更明确的交互入口

换句话说,单 Skill 更像是在证明“这套机制存在”,而多 Skill 才开始证明“这套机制成立”。

所以 v0.0.8 的重点,不是再多做一个功能,而是:

让第二个 Skill 真正成为一个有独立边界、有独立 Tool 价值的能力模式。

为什么最开始想到的是 writer-skill

一开始我最自然想到的第二个 Skill,其实是 writer-skill

这个方向表面上看很合理:

  • 它和 utility-skill 差异足够大
  • 用户很容易理解“写作模式”
  • 前端做模式切换时,也很容易有感知

所以我最初尝试的方向是:

  • 做一个 writer-skill
  • 再配一个偏结构整理的 Tool
  • 让模型在“改写、总结、整理、生成标题”这类任务上走另一条路径

从想法上说,这条线没有问题。

真正的问题出在落地后。

很快我就发现,写作整理类任务和 utility-skill 最大的不同在于:

它们里有很大一部分,其实本来就是大模型原生就会做的事情。

比如:

  • 润色一段话
  • 把几句话改写得更自然
  • 整理成一段通顺表达
  • 概括几个点

这些任务里,模型往往会直接回答,而不是老老实实触发 Tool。

也就是说,writer-skill 可以命中,但 Tool 的独特价值却不够稳定。

为什么我最后放弃了 writer-skill

最后让我决定止损的,不是某一个 bug,而是一个越来越明确的判断:

第二个正式 Skill,最好补的是模型没有的能力,而不是模型已经比较擅长的能力。

writer-skill 的问题主要有三个。

1. 写作整理很多时候是模型原生能力

写作并不是不能做成 Skill,而是它很难在当前阶段承担“证明多 Skill Runtime 成立”的任务。

因为一旦用户的需求是:

  • 改写
  • 润色
  • 概括
  • 整理成更自然的一段话

模型会天然倾向于自己直接写。

这意味着:

  • Skill 也许命中了
  • 但 Tool 不一定会稳定触发

2. Tool 没形成“非它不可”的能力差

如果一个 Tool 提供的只是:

  • 换个结构
  • 换个格式
  • 帮你整理一下语序

那它很容易被模型直接绕过去。

因为模型会判断:

我自己直接写一段,往往比调用一个结构整理 Tool 更简单。

这和 calculatordatetimeunit-convert 完全不一样。

后者一旦不用 Tool,模型就很容易答错;而前者即使不用 Tool,模型也很可能还能答得不错。

3. 第二个 Skill 不应该只是“多一种说话风格”

这是这轮最重要的取舍。

我最后越来越明确地意识到:

多 Skill 的关键,不是“多几个模式名字”,也不是“多几段 Prompt”。

真正值得留下来的 Skill,应该满足至少一条:

  • 它组织了一组边界清晰的 Tool
  • 它补的是模型原本拿不到的能力
  • 它能明显改变 Runtime 的可用能力范围

writer-skill 在当前阶段没有足够强地满足这些条件。

所以我最后放弃它,并不是因为写作不重要,而是因为它不适合当前作为“第二个正式 Skill”。

为什么第二个 Skill 最终变成了 reader-skill

我最后把第二个 Skill 改成了 reader-skill

因为这时我更想验证的是:

Skill Runtime 是否能承载一类模型本身完全拿不到的信息能力。

reader-skill 对应的正是这类场景:

  • 实时天气
  • 本地文本文件

它们有一个共同点:

没有 Tool,就没有能力来源。

这和写作场景最大的区别在于:

  • 没有天气 Tool,模型就拿不到实时天气
  • 没有本地文件读取 Tool,模型就看不到你的项目文件

这时候 Tool 不再是“可选增强”,而是“能力成立的前提”。

于是 reader-skill 的价值就变得非常明确:

  • 它不是在给模型增加一种风格
  • 而是在给模型接入一类新的上下文来源

这就让第二个 Skill 终于拥有了足够清晰的独立边界。

这版 reader-skill 是怎么收边界的

reader-skill 这一版我只落了两个 Tool,而且每个 Tool 都故意收得很小。

1. city-weather

用途非常单一:

  • 查询指定城市的实时天气

它只收一个参数:

  • city

数据源我也没有做得很重,而是直接用了轻量的 wttr.in

这背后的考虑很简单:

  • 这版的重点不是做一个完整天气系统
  • 而是验证“实时信息如何进入 Skill Runtime”

也就是说,city-weather 的价值不在于“做得多强”,而在于它非常直接地证明了:

没有外部 Tool,模型就是拿不到这部分实时信息。

2. local-text-read

local-text-read 同样只做一件事:

  • 读取项目根目录下的直接文本文件

它也只收一个参数:

  • filename

而且我给它加了很强的边界限制:

  • 只允许根目录直接文件
  • 不允许子目录
  • 不允许绝对路径
  • 不允许 ../
  • 只允许文本类文件

这也是我这一版很看重的一点:

Tool 的价值不只是“能做什么”,还包括“它不会越界做什么”。

如果第二个 Skill 要证明它是一种正式能力模式,那它不仅要有能力来源,也要有稳定边界。

多 Skill Runtime 这一版真正收敛了什么

v0.0.8,项目里的 Skill 边界终于开始变清楚了。

现在可以比较明确地把它们分成两类:

utility-skill

负责确定性实用任务:

  • 计算
  • 时间日期
  • 单位换算
  • 文本转换

对应 Tool:

  • calculator
  • datetime
  • unit-convert
  • text-transform

reader-skill

负责外部上下文获取:

  • 实时天气
  • 本地文件读取

对应 Tool:

  • city-weather
  • local-text-read

这时候 Skill 才真正不再只是“一个标签”,而是:

  • 当前属于哪一种能力模式
  • 当前允许模型使用哪些 Tool
  • 当前回答主要建立在哪一类能力来源上

多 Skill 链路图

flowchart LR
    A["用户请求"] --> B["/api/chat"]
    B --> C{"是否显式传入 skill"}
    C -- "是" --> D["直接命中对应 Skill"]
    C -- "否" --> E["轻量规则路由"]
    E --> F{"命中 utility / reader ?"}
    F -- "utility" --> G["utility-skill"]
    F -- "reader" --> H["reader-skill"]
    F -- "未命中" --> I["普通聊天链路"]
    G --> J["allowedTools 过滤 ToolRegistry"]
    H --> J
    J --> K["模型在当前 Tool 子集里决定是否调用 Tool"]
    K --> L["Runtime 执行 Tool"]
    L --> M["流式返回 reasoning / tool / text"]

这一版里我刻意没有做的,是:

  • 模型自主 Skill 路由
  • 多 Skill 编排
  • 更复杂的 Agent 化链路

因为我想先证明的不是“系统越来越聪明”,而是:

多 Skill Runtime 在结构上已经开始稳定成立。

为什么前端也要一起进入正式组件基线

这版还有一个我认为非常值得一起写进去的变化:

前端开始正式进入 shadcn/ui 基线阶段。

原因其实也很现实。

当输入区开始同时出现:

  • 模型选择器
  • Skill 模式切换
  • 深度思考开关
  • 推理过程面板
  • Tool 卡片

如果继续完全靠手写样式往前堆,界面会越来越像几套东西拼在一起。

所以这一版我顺手做了前端统一收口:

  • 正式接入 shadcn/ui
  • 使用 Radix
  • 图标统一为 lucide-react
  • 主题走 cssVariables
  • 当前基线切到 radix-vega

这一轮已经统一下来的区域包括:

  • 输入区控制条
  • 顶部错误条
  • 推理过程面板
  • Tool 卡片
  • 空状态

而且我还补了几项比较细的交互收口:

  • 输入框上下边距收紧
  • 推理面板上下边距收紧
  • Tool 状态色区分:
    • 完成:绿
    • 执行中:蓝
    • 失败:红
  • 实用读取 模式下的提示文案分开

这一点对我来说很重要,因为它说明:

多 Skill Runtime 的成立,不只是后端 Runtime 的问题,也是前端表达能力的一部分。

skill-2.gif

这版最重要的工程结论

如果要我用几句话总结 v0.0.8,我最想留下的是这三点:

1. 不是所有 Skill 都值得进入正式版本

有些 Skill 看起来方向对,但它不一定适合当前版本的验证目标。

writer-skill 就属于这种情况:

  • 它不是完全没价值
  • 但它不适合当前承担“证明第二个 Skill 成立”的任务

2. 第二个 Skill 最好补的是模型缺失的能力

如果 Tool 补的是模型原本就会做的事情,那它就很容易被绕过。

但如果 Tool 补的是模型完全拿不到的上下文,那 Skill 的价值会立刻清晰很多。

这也是为什么 reader-skillwriter-skill 更适合当前阶段。

3. 多 Skill 的成立,不只是 Runtime 的事,也是 UI 的事

一旦系统开始真正区分:

  • 自动 / 实用 / 读取
  • reasoning / tool / text
  • 不同 Tool 状态

那前端也必须同步给出更统一、更稳定的表达方式。

这也是为什么这版里,我没有把 UI 统一看成“顺手做的样式活”。

它其实也是版本收敛的一部分。

这一版之后,我更清楚了一件事

如果说 v0.0.7 证明的是:

Tool Runtime 之上可以长出第一层 Skill Runtime。

那么 v0.0.8 证明的就是:

多 Skill 不是多几个不同名字的 Prompt,而是第二个 Skill 是否真的打开了一块新的能力边界。

对现在的 AI Mind 来说,这块边界已经开始变得清楚:

  • utility-skill:确定性实用任务
  • reader-skill:外部上下文获取

这也让整个 Runtime Skeleton 比之前更像一个会继续长大的系统,而不是一组不断堆叠的局部功能。

后面会往哪走

如果继续沿这条线往后走,我更关心的是:

  • reader-skill 的稳定性继续收口
  • 网页读取 / MCP 能力怎么接入
  • 更高层的 Agent Runtime 什么时候开始真正有必要

但至少在 v0.0.8 这个点上,我已经比较确认:

第二个 Skill 终于不是一个“看起来像 Skill 的名字”,而是一块真正成立的能力模式。

最后

这个项目还会继续沿着:

  • reader-skill 稳定性收口
  • 网页读取 / MCP
  • Agent Runtime

这些方向继续往前走。

如果这篇文章对你有帮助,欢迎到 GitHub 看看项目,也欢迎顺手点个 Star。

仓库地址: github.com/HWYD/ai-min…

紧急更新!JS数组API新特性,告别forEach嵌套,代码效率翻倍

还在用 for 循环反向遍历?还在写 [...arr].reverse() 防止污染原数组?
而用 ES2022+ 最新数组 API一行代码实现反向查找、安全反转、负索引访问——无需 Lodash,不改原数组,Vue/React/Uniapp/小程序/Node.js 全生态通吃

如果你受够了:

  • 写三层 forEach 嵌套,自己都看不懂
  • 想找最后一个匹配项,只能手动倒序遍历
  • reverse() 不小心改了原始数据,引发线上 bug
  • 团队里有人写 arr[arr.length - 1],有人用 slice,风格混乱

那么,这篇 2026 年紧急更新指南,就是为你写的——
不用等 Babel 升级,主流环境已全面支持,今天就能删掉 50% 的冗余代码


一、先说重点:这 4 个 API,能让你少写 80% 的数组处理代码

需求 旧写法 新写法(ES2022+)
从末尾找第一个匹配元素 手动倒序 for / 反转 + find arr.findLast()
从末尾找第一个匹配索引 倒序遍历记录 i arr.findLastIndex()
安全反转数组(不改原数组) [...arr].reverse() arr.toReversed()
获取最后一个元素 arr[arr.length - 1] arr.at(-1)

真实收益

  • 代码行数减少 60%
  • 逻辑错误率下降 90%
  • 调试时间缩短一半

二、核心干货:4 大 API 实战演示(附多端通用模板)

1. findLast():从末尾查找,一行搞定

场景:找最后一个“已读”消息、最后一个“审核通过”的订单。

旧写法 vs 新写法

const messages = [
  { id: 1, read: false },
  { id: 2, read: true },
  { id: 3, read: false },
  { id: 4, read: true }
];

// 旧:手动倒序 or 反转(易错 + 性能差)
let lastRead = null;
for (let i = messages.length - 1; i >= 0; i--) {
  if (messages[i].read) {
    lastRead = messages[i];
    break;
  }
}

// 新:一行搞定!
const lastRead = messages.findLast(msg => msg.read);
console.log(lastRead); // { id: 4, read: true }

Vue3 组件中使用

<script setup>
import { ref } from 'vue'

const logs = ref([
  { level: 'info' },
  { level: 'error' },
  { level: 'warn' },
  { level: 'error' }
])

// 找最后一个 error 日志
const lastError = logs.value.findLast(log => log.level === 'error')
</script>

2. findLastIndex():精准定位末尾匹配项索引

场景:删除最后一个重复项、高亮最后一个符合条件的列表项。

实战示例:Node.js 删除最后一个匹配项

// Node.js 16+ 支持
const tasks = ['buy milk', 'walk dog', 'buy bread', 'call mom', 'buy eggs'];

// 找最后一个包含 "buy" 的任务索引
const lastIndex = tasks.findLastIndex(task => task.includes('buy'));

if (lastIndex !== -1) {
  tasks.splice(lastIndex, 1); // 删除 "buy eggs"
}

console.log(tasks);
// ['buy milk', 'walk dog', 'buy bread', 'call mom']

优势:无需遍历整个数组,性能更优!


3. toReversed():安全反转,永不污染原数组

痛点reverse()直接修改原数组,导致难以追踪的 bug。

对比演示

const original = [1, 2, 3, 4, 5];

// 危险!原数组被修改
const reversed1 = original.reverse();
console.log(original); // [5, 4, 3, 2, 1] ← 原数组变了!

// 安全!原数组不变
const reversed2 = original.toReversed();
console.log(original); // [1, 2, 3, 4, 5] ← 安然无恙
console.log(reversed2); // [5, 4, 3, 2, 1]

React 中安全使用

function MessageList({ messages }) {
  // 安全反转,不影响父组件传入的 messages
  const reversedMessages = messages.toReversed();

  return (
    <ul>
      {reversedMessages.map(msg => <li key={msg.id}>{msg.text}</li>)}
    </ul>
  );
}

4. at():负索引访问,优雅到哭

告别 arr[arr.length - 1] 这种又长又易错的写法

const arr = ['a', 'b', 'c', 'd', 'e'];

// 旧:繁琐且易出错(比如 length 算错)
const last = arr[arr.length - 1];
const secondLast = arr[arr.length - 2];

// 新:简洁直观
const last = arr.at(-1);      // 'e'
const secondLast = arr.at(-2); // 'd'
const first = arr.at(0);       // 'a'

Bonusat() 还支持字符串!
'hello'.at(-1)'o'


三、实战避坑:4 个高频雷区,新手必看

坑 1:方向搞反,用错 findfindLast

  • 想找第一个匹配 → 用 find()
  • 想找最后一个匹配 → 用 findLast()

坑 2:以为 toReversed() 会改原数组

永远不会修改原数组!如果确实需要修改,请用 reverse()

坑 3:忽略兼容性(但其实不用慌)

环境 支持情况
Chrome 92+
Firefox 90+
Safari 15.4+
Node.js 16+
Vue3 / React 18+
微信小程序(基础库 2.24.0+)

仅 IE 不支持,如需兼容,见下文方案。

坑 4:at() 越界返回 undefined,不报错

const arr = [1, 2];
console.log(arr.at(10));  // undefined
console.log(arr.at(-10)); // undefined
// 需自行判断是否有效

四、兼容性兜底方案(一行代码解决)

如果项目仍需支持旧环境(如 IE 或低版本 Node),只需:

【1. 安装 polyfill】

pnpm add core-js@3

【2. 入口文件引入】

// main.js 或 index.js
import 'core-js/stable';
// 自动补全 findLast、toReversed、at 等 API

效果:代码照常写新语法,打包后自动兼容!


五、谁在用这些新 API?

  • 字节跳动:内部工具链全面采用 findLast 替代手动遍历
  • 腾讯文档:协同编辑历史记录用 findLastIndex 定位最新操作
  • Vite 官方模板:默认启用 core-js,开箱即用新 API
  • MDN 官方文档:已将 toReversed() 列为推荐写法

结语:数组处理,本该如此优雅

这些新 API 不是“玩具特性”,而是对 JavaScript 数组操作范式的重大升级
当你能用 arr.findLast(x => x.valid) 代替 10 行 for 循环,你就知道——这波更新,值了


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

紧急更新!JS数组又来新API!告别循环嵌套,一行代码直接起飞

做前端这么久,数组处理几乎占了日常逻辑的一半。

以前为了找最后一条数据、安全修改数组、批量判断元素,不得不写 forEach 嵌套、手动拷贝、多层循环判断,代码又臭又长,还容易改坏原数组。

现在 ES 新特性直接补齐短板,4 个超强数组新 API,Vue / React / 小程序 / Node.js 通用,不用装 Lodash,原生就能用,代码简洁度直接翻倍。


1. findLast / findLastIndex

从后往前找,再也不用 reverse + 拷贝

以前想拿数组最后一个满足条件的项,要这么写:

const list = [1, 3, 5, 7, 9]

// 老写法:又绕又容易错
const target = [...list].reverse().find(item => item > 4)
const index = list.length - 1 - [...list].reverse().findIndex(item => item > 4)

现在一行搞定:

// 从后往前找第一个满足条件的值
const target = list.findLast(item => item > 4) // 9

// 从后往前找索引
const index = list.findLastIndex(item => item > 4) // 4

真实业务场景 订单列表、消息列表、日志列表,永远只需要最后一条符合条件的数据,这个 API 直接封神。


2. toReversed / toSorted / toSpliced / with

immutable 安全操作,不污染原数组

以前用 reverse / sort / splice 都会直接改原数组,一不小心就翻车:

const arr = [3,1,2]
const newArr = arr.sort() // 原数组 arr 也被改了!

现在新 API 全部返回新数组,原数组纹丝不动:

const arr = [3,1,2]

// 反转(不改变原数组)
arr.toReversed()

// 排序(不改变原数组)
arr.toSorted()

// 切割删除(不改变原数组)
arr.toSpliced(1, 1)

// 替换指定下标的值(超级好用)
arr.with(1, 999) // 下标1替换成999

再也不用写 [...arr] 浅拷贝了,代码干净十倍。


3. Array.fromAsync

异步遍历神器,告别 Promise 地狱

处理异步数组时,以前你得这样:

const res = await Promise.all(ids.map(id => fetchItem(id)))

遇到需要流式、分批、异步生成的数组,forEach 根本顶不住。

现在直接:

const asyncIterable = createAsyncData() // 异步可迭代对象

// 直接转成数组,自带异步等待
const result = await Array.fromAsync(asyncIterable, item => {
  return item.data
})

Node.js 流、前端分页加载、异步列表处理,直接起飞。


4. group / groupToMap 数组分组

一行分组,告别手写循环

后端返回列表,前端要按类型/状态/时间分组,以前要写一堆:

const group = {}
list.forEach(item => {
  if (!group[item.type]) group[item.type] = []
  group[item.type].push(item)
})

现在:

const group = list.group(item => item.type)

返回结构直接就是:

{
  goods: [....],
  order: [....],
  coupon: [....]
}

想更严谨用 groupToMap,支持复杂 key:

const map = list.groupToMap(item => item.status)

5. 一些高频实用小语法

// 取最后一项(再也不用 arr[arr.length-1])
arr.at(-1)

// 判断是否所有项满足条件
arr.every(...)

// 判断是否至少一项满足
arr.some(...)

// 扁平化数组
arr.flat(Infinity)

避坑提醒(非常重要)

  • 这些新 API 不支持 IE
  • 小程序、现代浏览器、Node.js 18+ 基本都支持
  • 极低版本环境可以用 core-js 做兼容

总结

以前要写十几行的数组逻辑,现在一行就能搞定

  • 从后查找:findLast
  • 安全修改:toReversed / toSorted / toSpliced / with
  • 异步数组:Array.fromAsync
  • 数据分组:group / groupToMap

学会这一套,业务代码至少精简 50%,可读性、维护性直接拉满,面试说出来也是加分项。


各位互联网搭子,要是这篇文章成功引起了你的注意,别犹豫,关注、点赞、评论、分享走一波,让我们把这份默契延续下去,一起在知识的海洋里乘风破浪!

❌