阅读视图

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

每日一题-股票平滑下跌阶段的数目🟡

给你一个整数数组 prices ,表示一支股票的历史每日股价,其中 prices[i] 是这支股票第 i 天的价格。

一个 平滑下降的阶段 定义为:对于 连续一天或者多天 ,每日股价都比 前一日股价恰好少 1 ,这个阶段第一天的股价没有限制。

请你返回 平滑下降阶段 的数目。

 

示例 1:

输入:prices = [3,2,1,4]
输出:7
解释:总共有 7 个平滑下降阶段:
[3], [2], [1], [4], [3,2], [2,1] 和 [3,2,1]
注意,仅一天按照定义也是平滑下降阶段。

示例 2:

输入:prices = [8,6,7,7]
输出:4
解释:总共有 4 个连续平滑下降阶段:[8], [6], [7] 和 [7]
由于 8 - 6 ≠ 1 ,所以 [8,6] 不是平滑下降阶段。

示例 3:

输入:prices = [1]
输出:1
解释:总共有 1 个平滑下降阶段:[1]

 

提示:

  • 1 <= prices.length <= 105
  • 1 <= prices[i] <= 105

还在重复下载资源?HTTP 缓存让二次访问 “零请求”,用户体验翻倍

🚀 性能优化的“节流大师”:HTTP 缓存机制,让你的网站快到飞起!

前端性能优化专栏 - 第五篇

在上一篇中,我们学会了如何利用资源提示符(Resource Hints)让浏览器“未卜先知”,提前准备资源。但如果用户已经访问过我们的网站,我们还能不能更快?当然可以!

今天,我们要聊的是前端性能优化的终极“节流大师”——HTTP 缓存机制。它的核心目标是复用资源、减少延迟、节省带宽,让用户在二次访问时,几乎可以瞬间打开页面。


⚠️ 缓存机制的运行原理

HTTP 缓存就像是浏览器给自己准备的一个“百宝箱”。浏览器在请求资源前,会先检查这个百宝箱。

整个缓存机制由一系列 HTTP 头字段(如 Cache-ControlExpiresETagLast-Modified)共同决定,并可分为强缓存协商缓存两大类。

  • 首次请求: 服务器提供资源及缓存策略。

  • 再次访问: 浏览器依据策略决定是否使用缓存。

    • 命中强缓存 → 无需请求服务器,直接使用本地资源。
    • 命中协商缓存 → 向服务器发送验证请求,确认资源是否可用。

image.png

✨ 强缓存机制:最快的“秒开”体验

强缓存是性能优化的最高境界。当缓存未过期时,浏览器直接使用本地资源无需发送任何网络请求

核心字段:Cache-Control 与 Expires

强缓存主要依赖两个 HTTP 头部字段:Cache-Control(HTTP/1.1 规范)和 Expires(HTTP/1.0 规范)。

优先级: Cache-Control > Expires

Cache-Control 指令:现代缓存的“指挥官”

Cache-Control 提供了更灵活、更强大的缓存控制能力:

指令 含义 场景
max-age=31536000 缓存有效期(秒),例如缓存一年 静态资源
public 允许代理服务器和浏览器缓存 所有人可见的资源
private 仅允许用户浏览器缓存 包含用户信息的资源
no-cache 不使用强缓存,进入协商缓存阶段 主入口文件(如 index.html
no-store 完全禁用缓存,每次都从服务器获取 敏感或动态数据
immutable 资源永久不变,浏览器无需重新验证 带版本号的静态资源
// 最佳实践:版本化静态资源
Cache-Control:public, max-age=31536000, immutable

应用场景: 带有版本号的静态资源(如 app.v1.jslogo.v2.png),一旦文件内容改变,版本号也会变,从而绕过强缓存。

Expires 头部字段:过时的“日历”
  • 格式: Expires: Tue, 09 Nov 2024 21:09:28 GMT
  • 问题: 指定绝对过期时间,但它依赖于客户端本地时间。如果用户修改了本地时间,就可能出现时钟误差,导致缓存失效或误用。

⚠️ 注意: 在现代 Web 中,Expires 已被 Cache-Control 替代。当两者同时存在时,Cache-Control 优先级更高。

🔄 协商缓存机制:谨慎的“验证官”

当强缓存失效(过期)或被禁用(Cache-Control: no-cache)后,浏览器会进入协商缓存阶段。

此时,浏览器会向服务器发送验证请求,服务器根据资源状态决定是返回缓存还是新内容:

  • 304 Not Modified → 资源未修改,浏览器使用本地缓存。
  • 200 OK → 资源已修改,服务器返回新内容。

Last-Modified / If-Modified-Since:基于时间的验证

这是最原始的协商缓存方式:

  1. 服务器响应: 附带资源最后修改时间:Last-Modified: Mon, 10 Nov 2025 12:00:00 GMT
  2. 浏览器请求: 再次请求时,携带:If-Modified-Since: Mon, 10 Nov 2025 12:00:00 GMT
  3. 服务器比较: 如果资源未修改,返回 304 Not Modified

⚠️ 缺点: 只能精确到秒。如果在 1 秒内资源被修改了多次,或者服务器时间与文件系统时间不一致,可能导致误判

ETag / If-None-Match:基于指纹的验证

ETag(Entity Tag)是资源的唯一指纹内容的哈希值,是更精确的验证方式:

  1. 服务器响应: 附带资源指纹:ETag: "filehash123"
  2. 浏览器请求: 再次请求时,携带:If-None-Match: "filehash123"
  3. 服务器比较: 比较哈希值,如果一致,返回 304 Not Modified

优点: 精度更高,解决了 Last-Modified 的时间误差问题。 优先级: ETag 高于 Last-Modified

🔧 Service Worker:可编程的缓存策略

HTTP 缓存是被动的,它依赖于服务器的 HTTP 头部配置。而 Service Worker 则提供了一种主动、可编程的缓存方案。

Service Worker 是一个独立于网页运行的后台脚本,它可以拦截网络请求,并结合 Cache API 实现完全自定义的缓存逻辑,从而实现离线访问动态缓存更新

Service Worker 常见的缓存策略:

策略名称 核心逻辑 优势 典型适用场景
Network First 先请求网络 → 网络失败则用缓存 优先获取最新内容,离线有兜底 新闻列表、动态数据(需最新)
Cache First 先读缓存 → 缓存无则请求网络并更新缓存 加载速度快,减少网络请求 静态资源(图片、CSS、JS)
Stale-While-Revalidate 先返回缓存旧资源 → 后台请求更新缓存 速度快,兼顾资源新鲜度 用户头像、非核心静态资源

Service Worker 示意图

✅ 最佳实践:缓存策略的“组合拳”

合理的缓存策略是性能优化的基石,我们应该根据资源的特性,打出“组合拳”:

  1. 静态资源(带版本号): 采用最强缓存。

    • Cache-Control:max-age=31536000, immutable
  2. 主入口文件(如 index.html): 禁用强缓存,启用协商缓存。

    • Cache-Control:no-cache (确保每次都能获取最新的 HTML,从而加载最新的 JS/CSS 版本)
  3. 动态数据(API): 按业务灵活配置。

    • 敏感数据:no-store
    • 可容忍延迟数据:max-age
  4. 离线与复杂策略: 使用 Service Worker 实现更高级的缓存控制。


下一篇预告: 缓存解决了“二次访问”的速度问题,但对于全球用户来说,“首次访问”的速度依然依赖于物理距离。下一篇我们将探讨如何利用内容分发网络(CDN) ,将资源部署到离用户最近的地方,实现真正的全球加速!敬请期待!

webpack编译中为什么不建议load替换ast中节点删除consolg.log

在 Webpack 编译过程中,不建议仅通过 loader 直接替换 / 删除代码中的 console.log核心原因是「loader 替换的局限性」会导致代码稳定性、可维护性、调试效率受损,且存在更优的替代方案。以下从「核心问题、场景风险、正确方案」三个维度详细解析:

一、loader 替换 console.log 的核心问题

Loader 的核心作用是转换模块的源代码(如 ts→js、less→css),其设计逻辑是 “按模块处理、文本替换”,但用它处理 console.log 会暴露以下致命问题:

1. 语法鲁棒性极差:易破坏合法代码

console.log 的写法高度灵活(参数、嵌套、动态调用),loader 基于 “正则 / 简单字符串替换” 无法精准识别,极易误改合法代码:

// 1. 合法场景被误替换
const console = { log: (msg) => alert(msg) }; // 自定义 console 对象
console.log("正常业务逻辑"); // loader 替换后变成 undefined.log(...),直接报错

// 2. 嵌套场景替换不彻底
if (debug) {
  console.log("调试信息", { a: 1, b: 2 }); // 正则仅替换 console.log 会残留参数,导致语法错误
}

// 3. 动态调用场景失效
const log = console.log;
log("动态调用的日志"); // loader 无法识别,漏替换

本质问题:loader 是 “文本层面” 的替换,而非 “语法层面” 的分析,无法区分「原生 console.log」和「自定义 console 对象 / 动态调用」,最终导致代码语法错误或逻辑异常。

2. 破坏开发 / 生产环境的一致性

  • 开发环境:需要保留 console.log 用于调试;
  • 生产环境:需要移除 console.log 减少体积、避免信息泄露;

若用 loader 硬编码替换,需在 webpack.config.js 中区分环境配置,且 loader 一旦配置错误,会导致:

  • 开发环境日志被意外删除,调试效率骤降;
  • 生产环境日志未被删除,泄露敏感信息(如接口参数、用户 ID)。

3. 无法精细化控制:一刀切的替换不灵活

实际项目中,console.log 并非 “全删 / 全留”,而是需要精细化控制:

  • 保留 console.error/console.warn(生产环境需监控错误);
  • 仅删除开发调试的 console.log,保留业务关键日志;
  • 按环境 / 模块 / 日志级别区分(如测试环境保留、生产环境删除);

loader 基于简单替换无法实现上述逻辑,只能 “全替换” 或 “全不替换”,灵活性为 0。

4. 性能损耗:增加编译开销

Loader 处理每个模块时都要执行正则匹配 / 替换,项目越大(模块越多),编译时间越长;而专业的优化插件(如 terser-webpack-plugin)是在 “代码压缩阶段” 批量处理,效率远高于 loader 逐模块替换。

二、更致命的场景风险

  1. 第三方依赖被误改:loader 会处理所有模块(包括 node_modules),若第三方库中使用 console.log 做关键逻辑(如调试、错误提示),被替换后会导致依赖功能异常,且难以排查;
  2. SourceMap 错位:loader 替换代码后,行号 / 列号与原始代码不一致,生产环境报错时,SourceMap 无法精准定位问题,增加调试难度;
  3. 无法回滚:一旦 loader 配置上线,若发现替换错误,需重新编译发布,而插件方案可通过配置快速开关,成本更低。

三、Webpack 中处理 console.log 的正确方案

核心原则:在代码压缩 / 优化阶段处理,而非 loader 转换阶段,以下是行业主流方案:

1. 生产环境:用 TerserWebpackPlugin 精准移除(推荐)

terser-webpack-plugin 是 Webpack 内置的代码压缩插件(替代旧的 uglifyjs-webpack-plugin),支持语法层面分析,可安全移除 console,且支持精细化配置:

// webpack.config.js
const TerserPlugin = require('terser-webpack-plugin');

module.exports = {
  mode: 'production',
  optimization: {
    minimizer: [
      new TerserPlugin({
        terserOptions: {
          compress: {
            // 核心配置:移除 console
            drop_console: true, // 全量移除 console(包括 log/error/warn)
            // 精细化配置:仅移除 log,保留 error/warn
            // pure_funcs: ['console.log'] 
          },
          // 保留 SourceMap,避免报错定位错位
          sourceMap: true 
        }
      })
    ]
  }
};

优势

  • 语法分析:仅移除原生 console.log,不影响自定义 console 对象;
  • 批量处理:压缩阶段一次性处理,编译效率高;
  • 灵活配置:可按日志级别区分保留 / 删除;
  • 不影响第三方依赖:默认仅处理业务代码(可通过 test 配置范围)。

2. 开发 / 测试环境:用 ESLint 约束(而非删除)

开发阶段无需删除 console.log,但可通过 ESLint 提示 / 禁止调试日志,避免提交到生产环境:

// .eslintrc.js
module.exports = {
  rules: {
    // 警告:提示开发者删除 console.log
    'no-console': ['warn', { allow: ['error', 'warn'] }],
    // 或严格模式:禁止所有 console(仅生产环境启用)
    // 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off'
  }
};

3. 按需控制:用环境变量 + 封装日志函数(最佳实践)

项目中封装统一的日志工具,按环境控制日志输出,从根源避免 console.log 泛滥:

// src/utils/logger.js
const logger = {
  log: (...args) => {
    if (process.env.NODE_ENV !== 'production') {
      console.log('[LOG]', ...args);
    }
  },
  error: (...args) => {
    console.error('[ERROR]', ...args); // 生产环境保留错误日志
  }
};

module.exports = logger;

业务代码中使用:

import logger from './utils/logger';

logger.log('调试信息'); // 生产环境自动不输出
logger.error('业务错误'); // 生产环境保留

优势

  • 完全可控:按环境 / 日志级别灵活调整;
  • 无编译风险:不修改原生代码,仅控制输出;
  • 便于扩展:可接入日志监控平台(如 Sentry)。

四、总结:为什么 loader 是错误选择?

维度 loader 替换 TerserPlugin + 封装日志
语法安全性 极低(易误改代码) 极高(语法层面分析)
灵活性 无(一刀切) 高(按级别 / 环境配置)
编译性能 差(逐模块替换) 优(压缩阶段批量处理)
可维护性 差(配置分散) 优(集中配置,易开关)
调试友好性 差(SourceMap 错位) 优(保留 SourceMap)

核心结论

loader 的设计目标是 “转换代码格式”,而非 “优化代码逻辑 / 删除日志”;处理 console.log 应交给「代码压缩插件」(生产环境)+「日志封装函数」(全环境),既保证代码稳定性,又能灵活控制日志输出。

Agent时代下,ToB前端的UI和交互会往哪走?

1. 前言

最近在负责 Agent 项目前端部分的落地,深感 Agent + ToB 不仅仅是功能的叠加,更是一个需要前端开发、交互设计与业务逻辑深度融合的全新领域。因此,我想通过这篇文章梳理一下架构设计层面的思考。

在过去二十年里,ToB软件已经形成了一套成熟且稳固的交互范式(GUI图形用户界面)。为了承载复杂的业务逻辑,业界构建了深度的树状导航、高密度的业务逻辑和繁复的表单系统。这种设计通过将业务流程抽象为标准化的功能模块,有效地解决了信息化的问题。

此时,用户与系统的关系是典型的 “人适应系统” :用户必须接受培训,记忆固定的操作路径,通过“点击 + 键盘”的机械指令来驱动软件。

近年,AI Agent 技术开始被引入 ToB 领域。但目前的普遍做法,仅仅是在现有界面上叠加一个聊天机器人(Chatbot)。但是,AI Agent 的出现不是为了在界面上加一个聊天框,而是为了打破“人去寻找功能”的旧模式。

未来,Agent 必然不再是一个边缘的辅助工具,而是系统的核心驱动力,可以深入介入用户的作业流,ToB 前端软件的人机交互应当如何重构?

本文分以下几个方面来深入探讨这个问题:

  • 静态界面变迁:静态渲染 => 混合渲染(生成式 UI + 静态渲染)
  • 人机交互变迁:功能导航 => 意图引导
  • 前端开发变迁:页面构建 => 原子组件 + 能力架构

最后,我还会加入一些杂七杂八但是重要的思考

以下内容纯属个人预测,如有指教请在评论区指正!


2. 定义:Agent是个啥?有啥用?

2.1 先定义:我说的Agent是什么?

我说的AI Agent不是我们熟知的聊天机器人或任务助手。

聊天机器人或者任务助手其本质是反应式的。它们在预设的规则或模型内,针对用户的明确指令执行单一、具体的任务 ,比如基于知识库的业务问答

而AI Agent则代表了一种主动式的、具备自主性的新形态(常用Cursor的朋友应该能够明白我在说啥)。  

一个真正的AI Agent具备以下核心特征:

  • 感知: 能够感知并处理其数字环境中的信息,包括视觉、文本和各类数据输入 。  
  • 规划: 能够将一个复杂、高阶的目标分解为一系列可执行的、具体的子任务 。这是其区别于普通LLM的关键能力。
  • 工具使用: 能够调用外部工具(如API、代码执行环境、数据库查询)来获取信息或执行动作,从而与外部世界进行交互。  
  • 记忆: 能够记忆过去的交互、行动和结果,并利用这些记忆来指导未来的决策,实现上下文的连续性和学习改进 。  

2.2 ToB软件对于AI Agent的核心诉求: 在复杂工作流程中提升用户生产力

ToB Web 应用对 AI 智能体的需求,可以总结为在复杂工作流程中提升用户生产力

引入AI Agent的最终目标,并非仅仅加速单个任务,而是从根本上重构涉及业务整体工作流 。这一理念的转变,要求前端交互设计需要进行变革。前端不再仅仅是用户与单一功能的交互界面,而是用户与一个持续进行的、复杂的业务流程进行协作的平台。  


3. 静态界面变迁: 从静态渲染 => 混合渲染(生成式 UI + 静态渲染)

Agent所代表的生成式 UI 并非要消灭现有的菜单和页面,而是作为一种高维度的补充。它让软件能够像乐高积木一样,根据用户的即时需求,实时重组最适合的交互形态。

3.1 静态页面的“确定性”与意图的“无限性”

1、静态渲染的局限性:被“确定性”锁死的路由

现有的 ToB 架构建立在 “确定性路由” 之上。前端工程师预先定义好 /dashboard/report 等路由,并在每个路由下硬编码固定的组件布局。这种模式的前提是:用户的需求是可枚举的,操作路径是线性的。

这种模式有两个缺陷:

  • 路径冗余:用户想要完成一个跨模块的任务(例如:“把 A 项目的数据经过 B模块的处理后导出”),往往需要在三四个页面间跳转、复制、粘贴。
  • 僵尸组件:为了覆盖长尾需求,ToB 软件的页面里塞满了平时根本用不到的高级筛选器和按钮,导致界面臃肿。

然而,Agent 的核心价值在于处理发散性意图。当用户提出“请分析上季度华东区销售异常的原因,并给出整改建议”时,这一需求涉及数据查询、图表分析、文本推理等多个维度。传统的静态页面无法预知并覆盖此类组合式需求。若强行通过跳转多个页面来解决,会让用户很不爽。

2、生成式 UI 的核心逻辑: 图形界面(GUI)和语言界面(LUI)的融合

生成式 UI 指的是:系统根据对用户意图的理解和当前上下文,在运行时动态决策并渲染出的用户界面。

在 Ant Design X 的范式中,这被称为“富交互组件的流式渲染”。其工作流包含三个阶段:

  • 意图识别 :Agent 识别用户需求,判断单纯的文本回复不足以解决问题,需要调用特定的 UI 组件。
  • 结构化描述 :Agent 输出一段包含组件类型和Props的 JSON 描述
  • 动态渲染 :前端接收 Schema,映射到本地的“原子组件库”,在对话流中即时渲染出可交互的卡片。

未来ToB软件的界面,可能不再是密密麻麻的菜单和按钮。而是一个全局的、智能的 语言用户界面(LUI) 将成为最高优的交互入口。用户可以通过自然语言直接下达指令,而GUI则会变成为:

  • 歧义消除与能力展示区:  当Agent对指令理解不清时,会通过GUI提供选项让用户选择。同时,GUI也直观地展示了当前软件“能做什么”,为用户提供输入指令的灵感。
  • 过程的监控区:  Agent的执行计划、步骤和状态,会以可视化的方式在GUI中呈现。 比如任务规划List(以流程图或任务列表的形式,展示Agent为了完成用户目标而制定的详细计划)、实时日志流(实时显示Agent正在执行哪一步、调用了哪个工具、工具返回了什么结果)。
  • 结果的呈现区:  Agent执行任务后的结果(如生成的报表、更新后的数据列表)依然通过确定的GUI来展示。

这标志着前端开发重心的转移:从构建静态页面转向定义组件能力

3.2 具体有哪些新的UI形态?

为了让 Agent 的抽象工作过程变得透明、可控和可信,一系列新的UI模式应运而生。这些模式可以根据其核心功能分为三大类 。

3.2.1 为了提高透明度和可观测性的新形态

这类模式的核心目标是打开Agent的“黑盒”,让用户能够理解其“思考”过程。

1、双栏视图

这是展示Agent思考过程的关键模式。界面被分为两栏,一栏实时展示Agent的内部推理过程;另一栏则展示其采取的具体行动。  

例子: Cursor的主界面一般被分成两块,一边是代码编辑区(具体行动);另外一边是思考和推理过程。

2、多智能体流程图

在由多个Agent协作的系统中,使用流程图或活动图来展示任务的分配和流转,清晰地标明哪个Agent在何时执行了何种操作。用户可以通过可展开的卡片深入了解每个Agent的具体贡献 。

例子: 秘塔AI搜索研究版\ image.png

3、来源引用与追溯

对于使用检索增强生成(RAG)技术的Agent,UI必须将生成的内容链接回其引用的原始文档或数据源。这极大地增强了信息的可信度,并允许用户进行事实核查 。  

例子: Gemini的深度研究,使用了大量展示数据源的交互。


3.2.2 为了更好地和Agent交互与协作的新形态

这类新UI和交互目的是将Agent的能力无缝地融入用户的工作流中。

1、协作画布

AI能力被直接嵌入用户的主要工作区,如设计工具或文档编辑器。用户通过行内建议、斜杠命令(/)或专用的AI功能块与Agent交互,整个过程无需离开当前的工作上下文,保持了心流体验 。  

例子: copliot中的自动代码补全

2、提示工程化表单

将复杂的自然语言提示(Prompt)工程抽象成结构化的表单,用户只需填写几个关键字段,系统就会在后台将这些输入转换成一个优化过的、高质量的提示,然后提交给Agent。这有效降低了用户使用AI的门槛 。

例子: 抖音“创作灵感”工具的主动引导。抖音的“AI脚本生成”功能早期面临用户输入模糊的问题(如用户仅输入“拍一条美食视频”)。通过“提示工程化表单”方案优化后,交互流程变为:

  1. 用户输入“拍一条美食视频”,意图分类模型判断明确度0.4(模糊)。
  2. 系统生成候选引导项:
  3. “选项1:家常菜教程(步骤清晰,适合新手)”
  4. “选项2:探店测评(突出口感描述,适合推荐)”
  5. “选项3:创意摆盘(视觉化强,适合短视频)”
  6. 用户点击“选项1”,系统自动补全提示词:“帮我生成一条家常菜教程的视频脚本,目标人群是厨房新手,需要包含食材准备、步骤拆解和小贴士。”
3、生成式UI

AI不仅填充数据,还能动态生成或修改UI组件本身。

例子: 用户可以要求一些办公软件中的Agent“在表格中增加一列,显示每个订单的利润率”,Agent会直接修改表格组件的结构;或者要求“生成一个展示用户增长趋势的图表”,Agent会在仪表盘上创建一个新的图表组件


3.2.3 增加用户对Agent的掌控的UI

1、人在回路

Agent的工作流在执行关键或高风险操作(如撤销git 暂存区内容时)之前会被明确地暂停。UI会清晰地展示Agent将要执行的动作及其可能带来的后果,并要求用户进行明确的批准后才能继续 。  

例子:Cursor 和 Copilot 在agent模式下,是可以自动执行终端命令并读取执行结果的的。默认情况下,绝大多数终端命令(尤其是删除、安装依赖等)都需要用户点击 "Run Command"

2、可编辑计划与“check point”回溯

在Agent开始执行任务之前,UI会展示其完整的行动计划。用户可以审阅、编辑、重新排序甚至取消计划中的某些步骤。

通过“check point”,用户可以“回溯”到Agent执行过程中的任一历史状态,并从该点开始探索一条不同的执行路径 。

例子:Gemini的研究模式 或者是 Cursor 的 plan模式\ image.png

  • 中断与恢复控制: UI必须始终提供一个明确的“停止”按钮,允许用户随时中断正在运行的Agent。中断后,UI应提供恢复、重试或放弃任务的选项 。

4. 人机交互变迁:“操作员” => “指挥官”

在过去几十年里, GUI(图形用户界面)将用户规训成了软件的 “操作员”。而在 Agent 时代,交互设计的终极目标是将人还原为 “决策者”

4.1 Agent时代的B端交互需要解放用户

4.1.1 交互逻辑:从“过程导向”到“结果导向”

在传统的 GUI(图形用户界面)中,交互是指令式的。 用户必须清楚地知道每一个步骤:点击菜单 A \to 选择子项 B \to 输入参数 C \to 点击确认。 这是一种“手把手教机器做事”的模式,对于一个有明确目的复杂工作流来说很繁琐

在 Agent 介入后,交互变成了意图式的。用户不再关注步骤,只关注结果:“帮我把这周异常的监控数据发给XXX”。系统内部自动完成了“检索数据 \to 过滤异常 \to 格式化报表 \to 查找联系人 \to 发送邮件”这一系列复杂工作流。

用户不再需要记忆软件的操作路径,只需要清晰地定义目标。

4.1.2 反馈机制:从“单向执行”到“双向对齐”

在 GUI中,反馈通常是二元的:成功 or 失败。机器默认用户是永远正确的,只管执行。

但在意图式交互中,由于自然语言天然存在模糊性(例如用户说“清理一下”,是指删除媒体文件还是清理一下数据库?),交互的重心从“执行”转移到了“对齐”。

包括但不限于以下反馈机制:

  • 授权:  用户可以设定Agent的权限范围,决定哪些操作它可以自动执行,哪些需要审批。 比如 Copliot 可以设置哪些命令行命令是不可以自动执行的。
  • “急停”与接管:  在Agent执行任务的任何时刻,用户都应该可以暂停任务,审查状态,甚至手动接管后续步骤。
  • 纠错与反馈:  当Agent执行出错或不符合预期时,用户可以方便地提供反馈,帮助Agent学习和改进。

这种交互更像人类之间的协作:听懂 \to 确认 \to 行动,而不是冰冷的 点击 \to 运行


4.2 具体有哪些新的交互模式?

交互的内核正从“指令驱动”向“意图驱动”迁移。面对 AI 输出的不确定性与生成特性,旧的“点击-响应”模式已捉襟见肘。为了解决这些新问题,衍生出了三大类全新的交互模式。

4.2.1 “协商式”交互:精准对齐意图

这类模式的核心是为了解决自然语言天然的“模糊性”。Agent 不再闷头执行指令,而是像一个经验丰富的助手,通过多轮沟通来“对齐”需求,确保做正确的事。

1、主动追问与澄清

当用户的指令太宽泛或缺少关键参数时,Agent 不应该“瞎猜”(那会带来不可控的风险),而应该采取兜底策略,主动抛出选项或反问,引导用户补全信息。

例子: Cursor的 Plan 模式
用户的编码意图不够清晰,方案有模糊的监视空间,plan模式就会主动向用户进行询问

2、上下文锚定

这是“LUI+ GUI”协同工作的典型场景。用户无需费力用语言去描述一个复杂的对象,只需通过“圈选”或“指代”来锁定上下文,再辅以语言指令。

例子: Photoshop 的 Generative Fill(创成式填充)
用户用套索工具圈选画面中的一只狗(GUI 操作),然后在输入框里敲下“换成一只猫”(LUI 操作),用户不用费劲描述“请把画面左下角坐标 (x,y) 处的那只黑色小狗...”,极大降低了表达成本。

4.2.2 “共创式”交互:人机实时协作

这类模式旨在打破传统交互“你输完指令、我慢慢跑进度条”的回合制感,让用户和 Agent 能够实时配合,实现人机“同频”。

1、流式输出与即时干预

大模型推理需要时间,界面除了用“打字机效果”缓解等待焦虑外,更关键的是要允许用户随时插嘴叫停

例子: Cursor的“Stop Generating”与实时纠偏

代码写的有问题,此时你不需要等它全写完再提意见,而是直接点停止,并且重新提交prompt,cursor会自动撤销原来有问题的代码生成。 这种机制把“事后验收”变成了“事中纠偏”,极大地压缩了试错成本。

2、预测性推荐

Agent 基于当前上下文和用户画像,预判你的下一步意图,并直接把“下一步动作”封装成按钮递到你手边。

例子: Cursor、Gemini、元宝啥的都有这个功能


4.2.3 “方便修正结果”的交互

AI 的产出往往是概率性的“半成品”。交互设计必须提供极低成本的修改能力,让用户通过简单的操作将结果调整至完美。

1、基于 GUI 的修正

用户不需要为了修改一点小细节而重新组织Prompt,而是直接在生成的 GUI 组件上动手改。Agent 能感知到这些修改,并自动同步到底层逻辑。

例子: Claude 的 Artifacts 预览修改

Agent 生成了一个网页按钮,颜色你不喜欢。你不需要打字说“把按钮改成深蓝色”,而是直接用右侧预览窗口里的取色器吸取一个蓝色。 此时,Agent 会自动重写左侧的代码以匹配你的视觉修改。这种“所见即所得”的修改方式,远比语言描述高效。

2、给用户选择题!

针对创意类或没有标准答案的任务,Agent 不做“填空题”而是做“选择题”。它一次性提供多个草稿,用户负责做决策。

例子: Midjourney 的 U/V 模式。

输入提示词后,系统先出 4 张缩略图。用户点击“V2”(基于第 2 张图生成变体),系统会保留第 2 张图的构图,仅在细节上微调。 这是一种典型的通过选择表达审美。在难以用语言描述清楚“具体哪里感觉不对”的时候,让用户做选择题是最高效的。


5. 前端开发的变迁:页面构建 => 原子组件 + 能力架构

前端开发的核心变迁可以用以下两个公式描述

传统模式: UI=f(State)UI = f(State)。状态变了,界面变。但 ff (渲染逻辑) 是写死的。

Agent 模式: UI=f(Intent,Context,Components)UI = f(Intent, Context, Components)。意图决定展示什么组件,上下文决定填充什么数据。

5.1 组件设计的变革:从“Props 定义”到“语义自述”

在传统的 Vue 或 React 开发中,我们定义一个组件(比如一个日期选择器),最关注的是它的 Props:需要传什么值,触发什么事件。这是写给 人类程序员 看的。

但在 Agent 体系下,组件的第一受众变成了 AI 模型。AI 不看源码,它需要知道这个组件“是干什么的?”、“数据结构是啥?”、“什么时候该用它?”。

因此,前端组件的开发标准增加了两个新维度:

  1. 语义描述: 你需要用自然语言告诉 Agent:“这是一个日期范围选择器,适用于需要筛选特定时间段数据的场景。”这种描述不再是注释,而是会作为系统提示词(System Prompt)的一部分实时喂给 AI。
  2. Schema 标准化: 组件的输入必须严格遵循 JSON Schema 标准。因为 LLM(大模型)输出的是结构化数据,前端必须确保组件能无缝“消化” AI 生成的配置参数,具备极强的鲁棒性,避免因参数错误导致页面崩溃。
  3. 唯一可寻址性:每个组件实例都应拥有一个稳定且唯一的标识符,Agent可以通过这个ID精确地引用它。
  4. API驱动的状态:组件的状态不仅应能通过用户的直接交互(如点击、输入)来改变,还必须能通过编程接口被读取和修改。例如,Agent需要能够通过调用API来对表格组件应用一个筛选条件。

这一转变意味着,前端组件开发不再仅仅是关于UI还原和交互处理,更是一项API设计工作。组件需要定义清晰的API供Agent调用,还需要能够文档化和测试其对Agent暴露的API。

5.2 前端数据通信的变革:从“请求-响应”到“流式驱动”

传统的 CRUD 应用中,数据流是离散的:发起请求 \rightarrow 等待 \rightarrow 拿到全量 JSON \rightarrow 渲染。

但在 Agent 交互中,这种模式会让用户体验崩溃。因为 AI 的推理和生成是缓慢的。如果等 10 秒钟才一次性刷出结果,用户早就关掉了页面。

前端的数据通信架构正在向 流式驱动 转型:

  • 增量渲染能力: 前端不能傻等完整的 JSON。当 Agent 刚刚吐出 {"type": "chart", "tit... 时,前端就应该渲染出一个带有标题骨架的图表占位符。随着数据流 ...le": "Q3 Revenue"} 的到达,标题被实时填入。
  • 状态的颗粒度更细: 我们需要处理一种全新的状态—— “思考中” 。这不同于传统的 loading。Loading 是单纯的转圈,而 Thinking 需要展示 Agent 正在调用的工具、正在阅读的文档,甚至允许用户在这个过程中打断。

这对前端的状态管理库提出了很高的要求:它必须能像处理水流一样处理数据,而不是像处理砖块一样。

5.3 路由系统的变革:从“URL 映射”到“意图路由”

传统前端应用中/user/detail/xxx等 严格对应着一个组件树。

但在混合渲染的架构中,URL 的统治力开始下降。当用户说“看看张三的绩效”时,系统可能不需要跳转 URL,而是直接在当前的对话流中“挂载”一个绩效卡片组件。

前端的路由逻辑发生裂变:

  • 宏观路由(保留): 依然使用 URL 管理大的模块(如“工作台”、“设置页”),保证浏览器的刷新和回退机制正常工作。
  • 微观路由(新增): 在页面内部,引入一个 “意图导航”。它根据 AI 识别出的意图,动态决定在当前视图中加载哪个组件。

这意味着前端架构必须支持动态组件注册沙箱隔离,防止 AI 随意生成的组件把主应用搞挂。

5.4 容错设计的变革:从“防止代码逻辑错误”到“给Agent的幻觉进行容错”

传统前端的主要捕获代码逻辑错误(比如 undefined is not a function)。

现在的挑战在于:代码没报错,但 AI一本正经地胡说八道。 比如,AI 返回了一个不存在的枚举值,或者生成了一段格式错误的 JSON。

前端开发必须构建一层容错机制:

  • 校验: 在渲染 AI 生成的内容前,必须进行严格的 Schema 校验。
  • 降级: 如果 AI 想要渲染一个复杂的图表但参数错了,前端应该能自动降级显示为纯文本,或者是提示用户“数据解析失败,请重试”,而不是直接白屏。
  • 可控: 在 UI 上提供“重新生成”或“手动编辑”的入口,把最终的纠错权交还给用户。

5.5 总结

前端并没有死,它只是向上进化了。

我们不再专注于于像素级的对齐、编写确定的组件(这些 AI 已经能写得很好了),而是开始关注组件的语义、数据流的实时性、以及系统的鲁棒性

我们将从构建界面的工程师,转变为构建 AI 运行时环境的工程师。这才是 Agent 时代前端开发的真正价值所在。


6. 一些系统性的思考问题

6.1 Agent进步那么快,会话式交互(自然语言LUI)会完全取代GUI吗?

不会!

所有交互创新都是为了这一个目的:“让输入更简单,让结果更易懂”。

Agent时代的到来似乎冲昏了一些产品或者交互的头脑?认为 LUI(语言交互)会取代 GUI。

我现在可以举一个反例:在游戏过程中,你想让飞机升高10米,实际上用上下左右的按键会比在聊天界面输入“这台飞机马上升高10米”更高效得多。

1、GUI 的本质:直接操作
  • 定义: 用户直接作用于屏幕上的对象。比如:你看到一个滑块,你拖动它;你看到方向键,你点击它。

  • 心理模型: “手眼协调” 。它模拟的是物理世界(比如开车、开飞机)。

  • 优势:

    • 低认知负荷: 是Recognition,看到按钮就知道能干嘛,不需要记忆指令。
    • 零延迟反馈: 按下即响应,形成了毫秒级的“感知-行动”闭环。
    • 高确定性: 按钮点了就是对应某个动作,不存在歧义。
2、LUI (自然语言) 的本质:代理委托
  • 定义: 用户通过语言描述意图,中间层(Agent)理解并执行。

  • 心理模型: “人际交流” 。它模拟的是你跟一个副驾驶说话。

  • 优势:

    • 无限的表达空间: 按钮数量有限,语言的组合无限。
    • 高抽象能力: 可以处理复杂的逻辑(比如“把所有电量低于 20% 的无人机召回”)。
3、案例分析

案例:在游戏过程中,你想让飞机升高10米,实际上用上下左右的按键会比在聊天界面输入“这台飞机马上升高10米”更高效得多。

刚才“飞机升高”案例,为什么 GUI 完胜? 因为 “空间运动”是低维度的、物理直觉的任务。 人脑对空间的理解是直接的,不需要经过“语言中枢”的转译。

  • GUI 路径: 眼睛看到位置 -> 大脑空间计算 -> 手指移动。(快,生物本能)
  • LUI 路径: 眼睛看到位置 -> 大脑空间计算 -> 转化为语义("升高") -> 量化数值("10米") -> 组织语言 -> 输入 -> AI 解析 -> 执行。(慢,且容易累)
4、方法论

到底是GUI好还是LUI好?以下给出两个维度进行评估:

维度 GUI (图形交互) LUI (自然语言/AI)
操作粒度 原子级操作。适合微调、实时控制。(如:向左偏一点点) 意图级操作。适合批处理、复杂指令。(如:按顺时针巡检A区)
学习曲线 初学者难,专家快。需要学习界面布局,但熟练后可形成“肌肉记忆”。 初学者快,专家慢。无需学习(会说话就行),但无法形成肌肉记忆,每次都要组织语言。
信息带宽 输入低,输出高。鼠标只有一个坐标,但屏幕能显示千万像素。 输入高,输出低。一句话包含丰富信息,但文本输出往往难以一目了然。
容错率 低风险。通常有限制,难以产生严重越界操作。 高风险。可能因为歧义(幻觉)导致灾难性后果(如 AI 误解了“降落”和“坠落”)。

回到刚才的那个原则:“让输入更简单,让结果更易懂”。

  • 对于在游戏中移动飞机:推摇杆是输入最简单(符合直觉),看飞机动了是结果最易懂(视觉反馈)。
  • 对于数据查询:说句话是输入最简单(省去筛选),看生成的图表是结果最易懂。

真正高效的交互应该应该采用 GUI + LUI 的方式:

  • 保留专业的控制面板或者图表(GUI),用于高频、实时、精细操作。

  • 嵌入“副驾驶” (LUI):用于跨菜单调用功能、复杂数据分析等意图级操作。

  • 关键点: AI 的输出,最好是 GUI 组件。

    • 错误做法: 用户问“表中哪个设备目前待更换?”,AI 回答文字“3号飞机待更换”。
    • 正确做法: 用户问“表中哪个设备目前待更换?”,AI 直接把表格上的 3 号设备高亮闪烁,并在侧边栏弹出它的详细状态卡片。

6.2 交互元素的“考古分析”

image.png

要讲计算机上的人机交互变革,肯定离不开施乐+苹果当年发扬光大的GUI。GUI和NUI在现在似乎稀松平常,但是当年实际上隐含着很多深层的思考和价值,这极大地改变了我们现在的世界。

很有必要考考古,才能指导未来的设计!

6.2.1 PC GUI

所以要讲Agent的交互元素的思考,肯定要先讲清楚GUI时代的内容

1、Icon 图标
  • 思考: 在命令行(CLI)时代,用户必须记住命令(如 deleterm)。图标的出现利用了人类从物理世界继承来的视觉经验——比如用一个“垃圾桶”代表删除,用一个“放大镜”代表搜索。这本质上是一种语义的视觉压缩
  • 价值: 它是认知负荷的极度减负。它建立了一座通用的桥梁,跨越了语言障碍(不识字的人也能看懂图标),让计算机的操作从“背诵语法”变成了“看图说话”,极大地降低了数字世界的准入门槛。
2、 Menu 菜单
  • 思考: 软件的功能往往成百上千,如果全部铺在屏幕上,用户会崩溃。菜单本质上是一种信息的分类 。它通过层级结构(文件、编辑、视图)将混乱的功能有序折叠。它解决的是 “有限屏幕”与“无限软件功能”之间的矛盾
  • 价值: 它提供了可发现性安全感。用户不需要预先知道软件能做什么,只要打开菜单逛一逛就能知道。它为用户建立了一张“功能地图”,让用户在面对庞大系统时,能通过逻辑推演找到目标,而不是盲目乱撞。
3、Pointing 指点设备(鼠标/光标)
  • 思考: 在键盘交互时代,操作是线性的、离散的。而鼠标引入了二维空间坐标的概念。它将人类的手部动作(物理位移)映射到屏幕上的光标运动(虚拟位移)。这引入了费茨定律 :操作效率取决于目标的大小和距离。它是“手眼协调”在数字世界的第一次完美投射。
  • 价值: 用户可以跳过屏幕上的任何内容,直接随机访问任何他想操作的那个像素点,而不必像填写表格一样按 Tab 键逐个切换。
4、Windows 窗口
  • 思考: 在单任务时代(DOS),屏幕就是全部。窗口的发明,创造了 **“屏幕中的屏幕” ** 。它模拟了物理世界中的“办公桌桌面”:你可以同时摊开好几份文件,有的在上面,有的被压在下面。它在心理学上契合了人类的 工作记忆 模型——我们需要同时关注多个相关联的信息源。
  • 价值: 它实现了多任务并行与信息隔离。用户可以在一个窗口看文档,在另一个窗口写代码,无需频繁切换上下文导致思路中断。它通过空间复用,让有限的显示器承载了逻辑上无限的信息流。
5、Toast 轻提示:
  • 思考: 弹窗 model 会强行打断用户,强迫用户点击“确定”。
  • 价值: 它是非阻断式的反馈。它承认“用户的注意力很宝贵”,只在视觉边缘告知结果(如“保存成功”),让交互的主流程不被打断。

6.2.2 移动互联网NUI(触控与感知)

在移动端,屏幕变小了,鼠标消失了,交互从“指向”变成了“直接操纵”。

1、手势(滑动/捏合):
  • 思考: PC GUI里的滚动条是一个独立的控件,用户需要把鼠标移过去拖动它。而手势让用户直接作用于内容本身
  • 价值: 它是“动作”与“结果”的零距离融合。用户想翻页,直接拨动页面,而不是拨动控制器(滚动条)。这极大降低了操作的抽象程度。
2、下拉刷新
  • 思考: 这是一个极其经典的发明。它利用了自然的物理隐喻(像拉动弹簧一样)。
  • 价值: 它创造了一个**“无界面”的按钮**。用户不需要寻找“刷新”图标,而是通过一种符合直觉的动律(Ritual)来触发更新,同时通过回弹动画告知用户“系统正在处理”。
3、侧滑操作(列表项左滑/右滑)

插一句,玩过“探探”的朋友们有没有想过左滑右滑不感兴趣的匹配对象的交互逻辑?以下解析应该可以帮助你们

  • 思考: 在 PC GUI 时代,处理列表数据(如邮件)通常需要三步:1. 选中某一行;2. 移动鼠标去找工具栏上的“删除”图标;3. 点击确认。而侧滑操作利用了 “扔掉” 的物理隐喻。
  • 价值: 它是高频决策的极速通道。它将“选择对象”与“执行动作”合二为一。用户不需要把视线从内容(邮件标题)移开去寻找按钮,手指一滑即处理。这种交互极大地提升了信息筛选和清理的效率(如 探探 的左滑右滑,Mail 的滑动归档)。
4、Haptic Touch 长按呼出
  • 思考: 移动设备没有鼠标右键,屏幕只有二维平面(X轴和Y轴)。长按操作引入了时间维度(或压力维度,如 3D Touch),模拟了Z轴的深度。它类似于在物理世界中“注视”或“用力按压”一个物体以探究其内部结构。
  • 价值: 它是渐进式的信息披露。它允许界面保持简洁(不展示复杂的菜单按钮),但又隐藏了深度的操作选项(如快捷方式、预览)。它解决了移动端“屏幕太小”与“功能太多”的矛盾,让用户在不离开当前页面的情况下获取上下文信息。
5、无限滚动(瀑布流)
  • 思考: WIMP 时代的网页浏览基于“分页器”,看完了要点“下一页”,这强迫用户进行一次“是否继续”的理性决策,打断了体验。而无限滚动创造了 “流(Stream)” 的隐喻,内容像水流一样源源不断。
  • 价值: 它创造了沉浸式的无意识消费。消除了“翻页”带来的认知中断,极大地延长了用户停留时间。对于内容型产品,这是将用户留存率最大化的“交互黑洞”。
6、底部的导航栏
  • 思考: PC GUI的导航栏通常在顶部或左侧(为了配合 F 型阅读顺序)。但移动设备的交互核心限制是 “拇指热区”。底部导航栏是对人体工程学的妥协与尊重。
  • 价值: 在高频切换的核心功能区,用户不需要调整握持姿势,拇指仅需微动即可完成路由跳转。这是硬件形态(单手握持)反向定义软件交互的典型案例。

【节点】[Adjustment-ReplaceColor节点]原理解析与实际应用

【Unity Shader Graph 使用与特效实现】专栏-直达

ReplaceColor节点是Unity ShaderGraph中Artistic类别下的重要颜色调整工具,能够将输入颜色中的指定颜色值替换为目标颜色,并通过参数控制实现平滑的过渡效果。该节点在游戏开发、影视制作和UI设计等领域应用广泛,为开发者提供了强大的颜色处理能力。

节点的核心功能基于输入颜色与源颜色之间的距离计算,在特定范围内实现颜色替换。与简单的颜色替换不同,该节点引入了Range和Fuzziness两个关键参数,使颜色替换不再是生硬的切换,而是能够创建自然的渐变过渡。

在实际应用中,ReplaceColor节点常用于以下场景:

  • 替换材质中的特定颜色元素
  • 实现色键效果(绿幕抠像)
  • 动态调整游戏元素的颜色主题
  • 创建特殊视觉效果和风格化渲染

该节点属于ShaderGraph的Artistic类别,在默认颜色模式下会显示对应的类别颜色标识,便于开发者快速识别节点类型。

端口与参数详解

ReplaceColor节点包含六个主要端口,每个端口都具有特定的功能和作用。

输入端口配置

In端口

  • 类型:Vector 3
  • 绑定:无
  • 描述:作为颜色替换操作的输入源,可连接纹理采样节点、颜色节点或其他颜色计算节点的输出。该端口接收RGB颜色值,通常来自材质的基础纹理或计算得到的颜色结果。

From端口

  • 类型:Vector 3
  • 绑定:Color
  • 描述:定义需要被替换的目标颜色值。该端口通常连接到颜色属性或固定的颜色值,用于指定在输入颜色中寻找的特定颜色。

To端口

  • 类型:Vector 3
  • 绑定:Color
  • 描述:定义替换后的目标颜色值。当输入颜色与From颜色匹配时,将使用此颜色值进行替换。

Range端口

  • 类型:Float
  • 绑定:无
  • 描述:控制颜色匹配的容差范围。该参数决定了在From颜色周围多大的颜色范围内进行替换操作,数值越大,替换的颜色范围越广。

Fuzziness端口

  • 类型:Float
  • 绑定:无
  • 描述:软化选择范围周围的边缘,实现平滑过渡效果。通过调整此参数可以避免替换边缘出现锯齿现象,创建自然的颜色渐变。

输出端口配置

Out端口

  • 类型:Vector 3
  • 绑定:无
  • 描述:输出经过颜色替换处理后的最终结果。该端口可连接到主节点的颜色输入或其他后续处理节点。

核心算法解析

ReplaceColor节点的内部实现基于颜色距离计算和线性插值算法。深入理解其核心算法对于有效使用该节点至关重要。

颜色距离计算

节点首先计算输入颜色(In)与源颜色(From)之间的欧几里得距离:

float Distance = distance(From, In);

该距离值决定了当前像素颜色与目标替换颜色的相似程度。距离越小,说明颜色越接近,替换效果越明显。

插值因子计算

获得颜色距离后,节点通过以下公式计算插值因子:

float factor = saturate((Distance - Range) / max(Fuzziness, 1e-5));

此计算确保在Range范围内的颜色会被完全替换,而在Range到Range+Fuzziness范围内的颜色会产生平滑过渡。

最终输出计算

通过lerp函数在目标颜色(To)和输入颜色(In)之间进行插值:

Out = lerp(To, In, factor);

当factor为0时,输出完全使用To颜色;当factor为1时,输出保持原始In颜色;在0到1之间时,输出为两种颜色的混合结果。

参数调节技巧

Range参数调节

Range参数决定颜色替换的敏感度范围:

  • 小数值(0-0.1):仅替换与From颜色几乎完全相同的像素,适用于精确颜色匹配
  • 中等数值(0.1-0.3):替换相似颜色范围,适用于大多数常规应用
  • 大数值(0.3以上):替换广泛的颜色范围,可能影响非目标区域

Fuzziness参数调节

Fuzziness参数控制替换边缘的柔和度:

  • 低模糊度(0-0.05):产生硬边缘,适合需要清晰边界的效果
  • 中等模糊度(0.05-0.2):创建自然过渡,避免锯齿现象
  • 高模糊度(0.2以上):产生非常柔和的边缘,适合创建羽化效果

参数组合策略

在实际应用中,Range和Fuzziness的组合使用可产生不同的视觉效果:

  • 精确替换:小Range + 低Fuzziness
  • 平滑过渡:中等Range + 中等Fuzziness
  • 区域影响:大Range + 高Fuzziness

实际应用案例

游戏元素颜色动态替换

在游戏开发中,ReplaceColor节点常用于动态改变游戏元素的颜色主题。例如,可根据游戏状态改变角色的服装颜色或环境的色调。

实现步骤:

  1. 将角色纹理连接到In端口
  2. 设置需要替换的原始颜色到From端口
  3. 通过脚本控制To端口的颜色值
  4. 调整Range和Fuzziness以获得理想的替换效果

绿幕抠像效果

ReplaceColor节点可实现类似绿幕抠像的效果,将特定的背景颜色替换为透明或其他背景。

关键技术点:

  • 精确设置绿幕颜色到From端口
  • 使用较小的Range值确保只影响背景区域
  • 通过Fuzziness控制边缘的平滑度

UI元素主题切换

在UI设计中,可使用ReplaceColor节点实现动态主题切换。通过替换UI元素中的特定颜色,可快速实现白天/黑夜模式或不同色彩主题的切换。

性能优化建议

在ShaderGraph的Heatmap颜色模式下,可直观查看节点的性能成本。ReplaceColor节点通常具有中等性能开销,主要取决于颜色距离计算的复杂度。

优化策略

  • 预处理纹理:尽可能在纹理制作阶段优化颜色分布,减少需要处理的颜色范围
  • 合理使用参数:避免使用过大的Range值,这会增加计算量
  • 考虑平台差异:在移动平台上应更加谨慎地使用复杂的颜色替换效果

性能监控

通过以下方法监控节点性能:

  1. 切换到Heatmap颜色模式查看节点相对性能成本
  2. 在目标平台上实际测试着色器性能
  3. 使用Unity的性能分析工具进行详细分析

常见问题解决方案

颜色替换不精确

当颜色替换效果不理想时,可能的原因和解决方案包括:

  • 颜色空间问题:确保所有颜色值在相同的颜色空间中处理
  • 光照影响:考虑场景光照对颜色感知的影响,可能需要结合其他颜色调整节点
  • 参数设置不当:重新调整Range和Fuzziness参数

边缘锯齿问题

解决替换边缘的锯齿现象:

  • 适当增加Fuzziness参数值
  • 结合抗锯齿技术
  • 使用更高分辨率的纹理

性能问题处理

当颜色替换操作导致性能下降时:

  • 减少同时使用的ReplaceColor节点数量
  • 优化Range参数,使用尽可能小的有效范围
  • 考虑使用LOD技术,在远距离使用简化的着色器版本

与其他节点配合使用

ReplaceColor节点可与其他ShaderGraph节点组合使用,创建更复杂的效果。

与Blend节点配合

将ReplaceColor节点与Blend节点结合,可实现多层颜色的混合和替换效果。这种组合特别适用于创建复杂的材质效果和动态纹理变化。

与Mask节点组合

结合Color Mask节点使用,可更精确地控制颜色替换的区域和范围。通过遮罩技术,可限制ReplaceColor节点只在特定区域生效。

在调整节点中的位置

在Artistic类别中,ReplaceColor节点与其他调整节点如Hue、Saturation、Contrast等共同构成了完整的颜色调整工具集。理解各节点的特性和适用场景,有助于构建更高效的着色器图形。

高级应用技巧

动态参数控制

通过脚本动态控制ReplaceColor节点的参数,可实现实时的颜色变化效果。这种技术在交互式应用和游戏中特别有用。

实现方法:

  1. 在ShaderGraph中创建对应的材质属性
  2. 通过C#脚本修改材质属性值
  3. 实现基于游戏逻辑的颜色动态变化

多级颜色替换

通过串联多个ReplaceColor节点,可实现复杂的多级颜色替换效果。这种方法适用于需要同时替换多种颜色的场景。

注意事项:

  • 节点顺序影响最终结果
  • 注意性能开销的累积
  • 考虑颜色之间的相互影响

总结与最佳实践

ReplaceColor节点是ShaderGraph中功能强大的颜色处理工具,通过合理使用其参数和组合其他节点,可创建各种视觉效果。

使用建议

  • 从简单开始:先使用基本设置,逐步调整参数
  • 测试不同光照条件:确保在各种光照环境下效果一致
  • 考虑目标平台:根据运行平台调整效果复杂度和性能要求

【Unity Shader Graph 使用与特效实现】专栏-直达 (欢迎点赞留言探讨,更多人加入进来能更加完善这个探索的过程,🙏)

数学解法,Java

解题思路

此处撰写解题思路

代码

###java

class Solution {
    public long getDescentPeriods(int[] prices) {
        long n = prices.length;
        int j = prices.length, pn = 0;
        for(int i = 1; i < j; i++){
            if(prices[i-1]-1 == prices[i]){
                pn++;
                n += pn;
            }else{
                pn = 0;
            }
        }
        return n;
    }
}

图片.png

js:动态规划

解题思路

如果是平滑阶段就累加上一次的状态,否则就为1

demo:
5: [5] => 1
5, 4: [4], [5,4] => 2
5, 4, 3: [3], [4,3], [5,4,3] => 3
5, 4, 3, 2: [2], [3,2], [4,3,2], [5,4,3,2] => 4

total为每个位置相加:1 + 2 + 3 + 4 = 10

代码

时间: O(n)
空间: O(n)

###javascript

/**
 * @param {number[]} prices
 * @return {number}
 */
var getDescentPeriods = function(prices) {
    let n = new Array(prices.length).fill(1)
    
    for (let i = 1; i < prices.length; i++) {
        if (prices[i - 1] - prices[i] === 1) {
            n[i] += n[i - 1]
        }
    }
    
    
    return n.reduce((a, b) => a + b)
};

空间优化:
因为我们在判断是否是平滑阶段的时候只需要上一个位置的状态,所以我们可以把上一个位置之前的状态全部不需要缓存,我们只需要用一个变量 prev 缓存上一个位置的状态即可

时间: O(n)
空间: O(1)

###javascript

/**
 * @param {number[]} prices
 * @return {number}
 */
var getDescentPeriods = function(prices) {
    let n = 1, prev = 1
    
    for (let i = 1; i < prices.length; i++) {
        if (prices[i - 1] - prices[i] === 1) {
            prev++
        } else {
            prev = 1
        }
        n += prev
    }
    
    
    return n
};

简洁写法(Python/Java/C++/C/Go/JS/Rust)

题意:计算 $\textit{prices}$ 有多少个连续子数组是连续递减的。

示例 1 的 $\textit{prices}=[3,2,1,4]$,按照子数组的右端点下标分组,有这些连续递减子数组:

  • 右端点 $i=0$:$[3]$。
  • 右端点 $i=1$:$[3,2],[2]$。
  • 右端点 $i=2$:$[3,2,1],[2,1],[1]$。
  • 右端点 $i=3$:$[4]$。

在遍历 $\textit{prices}$ 的同时,统计当前这段连续递减的长度 $\textit{dec}$,那么右端点为 $i$ 的连续递减子数组个数就是 $\textit{dec}$,加到答案中。

  • 如果 $\textit{prices}[i] = \textit{prices}[i-1]-1$,连续递减,$\textit{dec}$ 增加一。
  • 否则,连续递减中断,重新统计,$\textit{dec} = 1$。
class Solution:
    def getDescentPeriods(self, prices: List[int]) -> int:
        ans = dec = 0
        for i, p in enumerate(prices):
            if i > 0 and p == prices[i - 1] - 1:
                dec += 1  # 连续递减
            else:
                dec = 1  # 连续递减中断,重新统计
            ans += dec  # dec 是右端点为 i 的连续递减子数组个数
        return ans
class Solution {
    public long getDescentPeriods(int[] prices) {
        long ans = 0;
        int dec = 0;
        for (int i = 0; i < prices.length; i++) {
            if (i > 0 && prices[i] == prices[i - 1] - 1) {
                dec++; // 连续递减
            } else {
                dec = 1; // 连续递减中断,重新统计
            }
            ans += dec; // dec 是右端点为 i 的连续递减子数组个数
        }
        return ans;
    }
}
class Solution {
public:
    long long getDescentPeriods(vector<int>& prices) {
        long long ans = 0;
        int dec = 0;
        for (int i = 0; i < prices.size(); i++) {
            if (i > 0 && prices[i] == prices[i - 1] - 1) {
                dec++; // 连续递减
            } else {
                dec = 1; // 连续递减中断,重新统计
            }
            ans += dec; // dec 是右端点为 i 的连续递减子数组个数
        }
        return ans;
    }
};
long long getDescentPeriods(int* prices, int pricesSize) {
    long long ans = 0;
    int dec = 0;
    for (int i = 0; i < pricesSize; i++) {
        if (i > 0 && prices[i] == prices[i - 1] - 1) {
            dec++; // 连续递减
        } else {
            dec = 1; // 连续递减中断,重新统计
        }
        ans += dec; // dec 是右端点为 i 的连续递减子数组个数
    }
    return ans;
}
func getDescentPeriods(prices []int) (ans int64) {
dec := 0
for i, p := range prices {
if i > 0 && p == prices[i-1]-1 {
dec++ // 连续递减
} else {
dec = 1 // 连续递减中断,重新统计
}
ans += int64(dec) // dec 是右端点为 i 的连续递减子数组个数
}
return
}
var getDescentPeriods = function(prices) {
    let ans = 0, dec = 0;
    for (let i = 0; i < prices.length; i++) {
        if (i > 0 && prices[i] === prices[i - 1] - 1) {
            dec++; // 连续递减
        } else {
            dec = 1; // 连续递减中断,重新统计
        }
        ans += dec; // dec 是右端点为 i 的连续递减子数组个数
    }
    return ans;
};
impl Solution {
    pub fn get_descent_periods(prices: Vec<i32>) -> i64 {
        let mut ans = 0;
        let mut dec = 0;
        for (i, &p) in prices.iter().enumerate() {
            if i > 0 && p == prices[i - 1] - 1 {
                dec += 1; // 连续递减
            } else {
                dec = 1; // 连续递减中断,重新统计
            }
            ans += dec as i64; // dec 是右端点为 i 的连续递减子数组个数
        }
        ans
    }
}

复杂度分析

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

相似题目

3255. 长度为 K 的子数组的能量值 II

专题训练

见下面双指针题单的「六、分组循环」。

分类题单

如何科学刷题?

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

老司机 iOS 周报 #360 | 2025-12-15

老司机 iOS 周报 #360 | 2025-12-15

ios-weekly
老司机 iOS 周报,只为你呈现有价值的信息。

你也可以为这个项目出一份力,如果发现有价值的信息、文章、工具等可以到 Issues 里提给我们,我们会尽快处理。记得写上推荐的理由哦。有建议和意见也欢迎到 Issues 提出。

文章

🌟 Teaching AI to Read Xcode Builds

@zhangferry:Xcode 原始构建日志对人和 AI 都不够友好,仅提供扁平信息输出。作者通过截获 Xcode 与 SWBBuildService 的通信,挖掘出日志之外的结构化数据,包括构建依赖、详细耗时等核心信息,现在随着 swift-build 的开源,可以更系统的了解这些构建信息,利用它们可以实现这些功能:

  • 精准排错:链接错误源于 NetworkKit 模块构建失败,其依赖的 CoreUtilities 正常,问题仅集中在 NetworkKit 本身
  • 慢构建分析:47 秒构建中仅 12 秒是实际编译,其余时间用于等待代码签名;8 核 CPU 仅达成 3.2 倍并行,XX 模块是主要瓶颈
  • 主动提醒:近一个月构建时间上涨 40%,与 Analytics 模块新增 12 个 Swift 文件相关
  • 自然语言查询:支持 “上次构建最慢的模块是什么?”“上周比这周多多少警告?” 等直接提问

利用这项能力对 Wikipedia iOS 应用完成 “体检”,找到多个编译耗时瓶颈,AI 还结合结果给出了模块拆分、并行处理的优化建议。

未来潜力更值得期待:若 Apple 官方支持实时获取构建消息,AI 可在构建中途发现异常(比如 “某模块比平时慢 2 倍,是否暂停检查?”),还能实时监控 CI 构建进度,甚至自动修复问题。作者基于 swift-build 开发了 Argus,已经实现部分实时功能。

🐕 豆包手机为什么会被其他厂商抵制?它的工作原理是什么?

@EyreFree:豆包手机因采用底层系统权限实现 AI 自动化操作,遭微信、淘宝等厂商抵制。其核心工作原理为:通过 aikernel 进程与 autoaction APK 协同,利用 GPU 缓冲区读取屏幕数据、注入输入事件,借助独立虚拟屏幕后台运行,无需截屏或无障碍服务,还能绕过部分应用反截屏限制。AI 操作主要依赖云端推理,本地每 3-5 秒向字节服务器发送 250K 左右图片,接收 1K 左右操作指令。这种模式虽提升自动化效率,但存在隐私安全隐患,易被灰产利用,且冲击现有移动互联网商业逻辑,相关规范与监管仍需完善。感兴趣的同学可以结合视频 【老戴】豆包手机到底在看你什么?我抓到了它的真实工作流程 一起看看。

🐢 How we built the v0 iOS app

@含笑饮砒霜:Vercel 首款 iOS 应用 v0 的移动端负责人 Fernando Rojo 详细分享了应用的构建过程:团队以角逐苹果设计奖为目标,经多轮试验选定 React Native 和 Expo 技术栈,核心聚焦打造优质聊天体验,通过自定义钩子(如 useFirstMessageAnimation、useMessageBlankSize 等)、依赖 LegendList 等开源库实现消息动画、空白区域处理、键盘适配、漂浮作曲家等功能,解决了动态消息高度、滚动异常、原生交互适配等难题;同时在 Web 与原生应用间共享类型和辅助函数,通过自研 API 层保障跨端一致性,优先采用原生元素并针对 React Native 原生问题提交补丁优化;未来团队计划开源相关研究成果,持续改进 React Native 生态。

🐎 Opening up the Tuist Registry

@Kyle-Ye:Tuist Registry 宣布完全开放,无需认证或创建账户即可使用。作为 Swift 生态首个完全开放的 Package Registry,目前已托管近 10,000 个包和 160,000+ 个版本。使用 Registry 的团队可获得高达 91% 的磁盘空间节省(从 6.6 GB 降至 600 MB),CI 缓存恢复时间从 2 分钟缩短至 20 秒以内。开发者只需运行 tuist registry setupswift package-registry set 命令即可配置,支持标准 Xcode 项目、Tuist 生成项目和 Swift Package。未认证用户每分钟可发起 10,000 次请求,对于大多数项目已足够使用。

🐕 Initializing @Observable classes within the SwiftUI hierarchy

@AidenRao:本文探讨了在 SwiftUI 中正确初始化和管理 @Observable 对象的几种模式。作者通过清晰的代码示例,层层递进地讲解了使用 @State 的必要性、如何通过 .task 修饰符避免不必要的初始化开销,以及如何利用 environment 实现跨场景的状态共享。如果你对 @Observable 的生命周期管理还有疑惑,这篇文章会给你清晰的答案。

🐕 Demystifying the profraw format

@david-clang:本文深入剖析了 LLVM 用于代码覆盖率分析的 .profraw 二进制文件格式及其生成机制。其核心原理分为编译时插桩与运行时记录两步:

  1. 编译时,Clang 的 -fprofile-instr-generate 选项会在程序中插入计数器 (__profc_*) 和元数据 (__profd_*) 到特定的 ELF 节中,并在关键执行点插入 Load/Add/Store 指令进行实时计量。
  2. 运行时,LLVM 运行时库通过 atexit() 钩子,在程序终止时自动将内存中这些节的最终数据序列化到 .profraw 文件中。该文件依次包含 Header、Data 段(元数据)、Counters 段(执行次数)和 Names 段,完整记录了代码执行轨迹。

工具

🐕 CodeEdit

@JonyFang:CodeEdit 是一个面向 macOS 的开源代码编辑器项目,使用 Swift/SwiftUI 开发并由社区维护。README 将其定位为纯 macOS 原生编辑器,并以 TextEdit 与 Xcode 作为两端参照:在保持更简洁的使用体验的同时,按需扩展到更完整的编辑与开发能力,并遵循 Apple Human Interface Guidelines。项目目前处于开发中,官方提示暂不建议用于生产环境,可通过 pre-release 版本试用并在 Issues/Discussions 反馈。README 列出的功能包括语法高亮、代码补全、项目级查找替换、代码片段、内置终端、任务运行、调试、Git 集成、代码评审与扩展等。

代码

🐕 mlx-swift-lm

@Barney:MLX Swift LM 是一个面向 Apple 平台的 Swift 包 , 让开发者能够轻松构建 LLM 和 VLM 应用。通过一行命令即可从 Hugging Face Hub 加载数千个模型 , 支持 LoRA 微调和量化优化。提供 MLXLLM、MLXVLM、MLXLMCommon、MLXEmbedders 四个库 , 涵盖语言模型、视觉模型和嵌入模型的完整实现。开发者只需几行代码就能创建对话系统 , 所有功能通过 Swift Package Manager 便捷集成。

内推

重新开始更新「iOS 靠谱内推专题」,整理了最近明确在招人的岗位,供大家参考

具体信息请移步:https://www.yuque.com/iosalliance/article/bhutav 进行查看(如有招聘需求请联系 iTDriverr)

关注我们

我们是「老司机技术周报」,一个持续追求精品 iOS 内容的技术公众号,欢迎关注。

关注有礼,关注【老司机技术周报】,回复「2024」,领取 2024 及往年内参

同时也支持了 RSS 订阅:https://github.com/SwiftOldDriver/iOS-Weekly/releases.atom

说明

🚧 表示需某工具,🌟 表示编辑推荐

预计阅读时间:🐎 很快就能读完(1 - 10 mins);🐕 中等 (10 - 20 mins);🐢 慢(20+ mins)

Swift 6.2 列传(第十一篇):梅若华的执念与“浪子回头”的异步函数

在这里插入图片描述

0. 🐼楔子:量子纠缠与发际线的危机

在这个万物互联、元宇宙崩塌又重建的赛博纪元,大熊猫侯佩正面临着熊生最大的危机——不是由于长期熬夜写代码导致的黑眼圈(反正原本就是黑的),而是他引以为傲的头顶毛发密度。

“我再次声明,这不是秃,这是为了让 CPU 散热更高效而进化的‘高性能空气动力学穹顶’!”侯佩一边对着全息镜子梳理那几根珍贵的绒毛,一边往嘴里塞了一根钛合金风味竹笋

在这里插入图片描述

为了修复一个名为“时空并发竞争”的超级 Bug,侯佩启动了最新的 Neural-Link 6.2 沉浸式代码审查系统。光芒一闪,数据流如同瀑布般冲刷而下,侯佩只觉得天旋地转,再睁眼时,已不在那个充满冷气机嗡嗡声的机房,而是一片阴风怒号的荒野。

在本次大冒险中,您将学到如下内容:

    1. 🐼楔子:量子纠缠与发际线的危机
    1. 🌪️ 荒野遇盲女,九阴白骨爪
    1. 🕸️ 默认在调用者的 Actor 上运行非隔离异步函数
    • 📜 曾经的困惑(Swift 6.2 之前)
    1. 🛠️ 新的规矩:浪子回头(SE-0461)
    1. 🚪 逃生舱:如果你非要让他走 (@concurrent)
    1. 🧬 深度解析:为什么这很重要?
    1. 🏁 结局:风沙散去,墓碑显现

这里没有 WiFi 信号,只有遍地的白骨和漫天的黄沙。

在这里插入图片描述


1. 🌪️ 荒野遇盲女,九阴白骨爪

“哎呀,这导航又把我带到哪了?我就说高德地图在四维空间里不靠谱!”侯佩挠了挠头,路痴属性稳定发挥。

忽然,一阵凄厉的破空声传来。

“贼汉子,哪里跑!”

一道黑影如鬼魅般袭来,那是一双惨白如玉的手爪,五指如钩,直取侯佩的天灵盖。侯佩大惊失色,虽然他肉厚抗揍,但这九阴白骨爪的阴寒之气要是抓实了,恐怕不仅发际线不保,连头盖骨都要变成标本。

“女侠饶命!我只是个路过的熊猫,身上只有9元竹笋,没有《九阴真经》啊!”侯佩一个懒驴打滚,堪堪避开。

那黑衣女子长发披肩,双目虽盲,但听声辨位之术已臻化境。她正是被逐出桃花岛、漂泊半生的梅超风(本名梅若华)。

在这里插入图片描述

梅超风停下身形,空洞的眼神望向侯佩的方向,神色凄苦:“你这声音……憨傻中透着一股油腻,不像江南七怪,倒像是一头……很胖的熊?”

“是国宝!而且是很帅的国宝!”侯佩整理了一下领结(虽然没穿衣服),“梅姐姐,你这招式虽然凌厉,但好像总是无法在正确的线程上命中目标啊?是不是觉得内力运转时,总是莫名其妙地‘跳’到了别的地方?”

梅超风身躯一震:“你怎么知道?我苦练九阴真经,每当运功至关键时刻(异步调用),真气便会不受控制地散逸到荒野之外(后台线程),无法与我本体(Actor)合二为一。难道……你是师父派来指点我的?”

在这里插入图片描述

侯佩咬了一口竹笋,推了推并不存在的眼镜:“咳咳,算是吧。今天我就借着 SE-0461(Run nonisolated async functions on the caller's actor by default) 号秘籍,来解开你这半生漂泊的心结。”

2. 🕸️ 默认在调用者的 Actor 上运行非隔离异步函数

侯佩盘腿坐在一堆骷髅头上,开始了他的技术讲座。

“梅姐姐,你现在的武功(代码逻辑),就像 Swift 6.2 之前的情况。”

侯佩在沙地上画了一个架构图:

“在 SE-0461 提案之前,一个 nonisolated async 函数(非隔离异步函数),就像是一个生性凉薄的浪子。不管是谁召唤它,哪怕是位高权重的 MainActor(桃花岛主),这浪子一旦开始干活(执行),就会立刻跳槽,跑到通用的后台线程池里去瞎混。”

“这就是为什么你觉得真气(数据)总是游离在你的掌控之外。”

在这里插入图片描述

📜 曾经的困惑(Swift 6.2 之前)

让我们来看看这段令无数英雄竞折腰的代码:

// 一个负责测量数据的结构体,它没有任何 Actor 隔离,是个自由人
struct Measurements {
    // 这是一个 nonisolated async 函数
    // 就像当年的陈玄风,虽然功夫高,但心不在桃花岛
    func fetchLatest() async throws -> [Double] {
        let url = URL(string: "https://hws.dev/readings.json")!
        // 这里发生了异步等待
        let (data, _) = try await URLSession.shared.data(from: url)
        return try JSONDecoder().decode([Double].self, from: data)
    }
}

接着,我们有一个桃花岛气象站,它是被 @MainActor 严格管辖的领地:

@MainActor
struct WeatherStation {
    let measurements = Measurements()

    // 这是一个在主线程(桃花岛)运行的方法
    func getAverageTemperature() async throws -> Double {
        // ⚠️ 重点来了:
        // 在 Swift 6.2 之前,虽然这行代码是在 MainActor 里写的
        // 但 fetchLatest() 会立刻跳出 MainActor,跑去后台线程执行
        let readings = try await measurements.fetchLatest()
        
        let average = readings.reduce(0, +) / Double(readings.count)
        return average
    }
}

let station = WeatherStation()
try await print(station.getAverageTemperature())

梅超风听得入神:“你是说,以前哪怕我在桃花岛(MainActor)召唤陈玄风(fetchLatest),他也会立刻跑到大漠(后台线程)去练功?”

在这里插入图片描述

“没错!”侯佩一拍大腿,“这就是 SE-0338 当年定下的规矩——非隔离异步函数‘不在任何 Actor 的执行器上运行’。这导致了无数的数据竞争逻辑混淆,就像你和陈玄风偷了经书私奔,结果把自己练得人不人鬼不鬼。”

3. 🛠️ 新的规矩:浪子回头(SE-0461)

“但是!”侯佩话锋一转,眼中闪烁着智慧的光芒(也有可能是饿出来的绿光),“Swift 6.2 带来了 SE-0461,一切都变了。”

新的规则是:非隔离异步函数现在默认在“调用者的 Actor”上运行。

“这意味着什么?”侯佩指着天空,“意味着如果你身在桃花岛(MainActor),你召唤的招式(fetchLatest)就会老老实实地呆在桃花岛(MainActor)执行,不再四处乱跑了!”

在 Swift 6.2 及以后:

  • getAverageTemperature@MainActor
  • 它调用了 measurements.fetchLatest()
  • fetchLatest 是非隔离的。
  • 结果: fetchLatest 会自动继承调用者的上下文,直接在 @MainActor 上运行

梅超风空洞的眼中似乎流下了一行清泪:“若当年有此规则,我和师兄便不会离岛,也不会落得如此下场……”

在这里插入图片描述

“是啊,”侯佩感叹道,“这叫上下文亲和性(Context Affinity)。这不仅减少了线程切换的开销(就像省去了跑路的盘缠),还让代码逻辑更符合直觉——你在哪调用的,它就在哪跑。”

4. 🚪 逃生舱:如果你非要让他走 (@concurrent)

梅超风忽然神色一冷:“但若是我真的想让他走呢?若是我为了练就绝世武功,必须让他去极寒之地(后台线程)吸取地气呢?”

“问得好!”侯佩竖起大拇指(如果熊猫有的话),“如果你怀念旧的行为,或者为了性能考虑(比如不想阻塞主线程),想明确地把这个函数‘逐出师门’,你可以使用新的关键字:@concurrent。”

struct Measurements {
    // 加上 @concurrent,就是给了他一封休书
    // 告诉编译器:这个函数必须并发执行,不要粘着调用者!
    @concurrent func fetchLatest() async throws -> [Double] {
        // ... 代码同上
    }
}

“加上 @concurrent,就像是你对他喊了一句:‘滚!’。于是,他又变回了那个在后台线程游荡的浪子。”

在这里插入图片描述

5. 🧬 深度解析:为什么这很重要?

侯佩看着梅超风似懂非懂的样子,决定再深入解释一下(以此展示自己深厚的技术功底):

  1. 直觉一致性:以前开发者在 @MainActor 的 View Model 里写个辅助函数,总以为它是安全的,结果它悄悄跑到了后台,访问 UI 属性时直接 Crash。现在,它乖乖听话了。
  2. 性能优化:少了无谓的 Actor 之间的“跳跃”(Hopping),程序的任督二脉打通了,运行更流畅。
  3. Sendable 检查:由于现在函数可能在 Actor 内部运行,编译器在检查数据安全性(Sendable)时的策略也会更智能。

在这里插入图片描述

6. 🏁 结局:风沙散去,墓碑显现

梅超风听完,仰天长啸,啸声中充满了释然。她枯瘦的手掌缓缓放下,一身戾气似乎消散了不少。

“原来如此,原来是我一直执着于‘非隔离’的自由,却忘了‘隔离’才是归宿。”她喃喃自语,身影逐渐变得透明,仿佛要融入这片虚拟的代码荒原。

“喂!梅姐姐,别走啊!我还没问你《九阴真经》里有没有治疗脱发的方子呢!”侯佩伸手去抓,却抓了个空。

场景开始剧烈震动,荒野崩塌,地面裂开。一座巨大的黑色石碑缓缓升起,挡住了侯佩的去路。石碑上刻着一行闪着红光的代码,散发着危险的气息。

在这里插入图片描述

侯佩凑近一看,只见石碑上写着几个大字:Actor-isolated Deinit

与此同时,梅超风消失的地方,传来最后一句话:“在这个世界,生有时,死亦有时。当一个 Actor 走向毁灭(deinit)时,你该如何安全地处理它的遗产?

在这里插入图片描述

侯佩只觉得背后一凉,因为他看到石碑后伸出了一只手……


欲知后事如何,且看下回分解:

🐼 Swift 6.2 列传(第十二篇):杨不悔的“临终”不悔与 Isolated Deinit (Introducing Isolated synchronous deinit - SE-0371)

下集预告: 当一个 Actor 对象被销毁时,如何确保它能安全地访问内部的数据?如果你在 deinit 里写了并发代码,会不会导致程序直接炸裂?SE-0371 将教你如何给 Actor 的临终遗言加上一把安全的锁。侯佩能从这座“析构之墓”中逃脱吗?敬请期待!

粉笔与华图达成战略合作,双方将互相参股、成立合资公司

36氪获悉,12月14日,粉笔与华图山鼎联合宣布达成深度战略合作,将在服务深度、成本结构与业务拓展等方面推动资源整合。股权层面,双方合作包括但不限于战略投资或控股、互相参股、共同成立合资公司等措施,同时还将相互派驻董事,搭建常态化沟通桥梁。

静默打印程序实现

背景

需求需要实现一个静默打印插件,辅助 Web 后台系统以实现静默打印标签,提升仓库侧工作效率。原插件稳定性、兼容性差,无源码无法扩展修改,支持多种标签格式,后续所有标签会转移至 PDF 格式,所以新的插件系统只需要支持 PDF 打印即可。

技术选型

桌面框架选择 Electron

  • 优点:前端友好、快速开发、文档、生态完善、兼容性好
  • 缺点:资源占用高、打包体积大

渲染层选择 React(个人喜好)。

包管理工具使用 npm,由于开发 React Native 时被 pnpm 的各种差异、兼容性搞的头疼,所以在开发非 Web 应用时更倾向于使用 npm。

最终使用开源的 electron-react-boilerplate 作为基础框架进行开发。

整体架构设计

主进程实现功能:

  • 静默打印
  • 对外接口服务
  • 异常重启
  • 日志记录(electron-log
  • 系统通知(electron 内置 Notification
  • 自动更新(electron-updater
  • 数据库交互(@seald-io/nedb
  • 外部程序调用

渲染层实现功能:

  • 界面交互
  • 打印设置
  • 数据查询(打印记录查询查看)

主进程(核心层)通过启动本地服务器提供对外接口调用,触发接口调用时主进程调用命令行或外部程序实现打印功能。

Web 开发大家都很熟了,所以下面只说主进程中的静默打印、对外接口服务的功能开发。

静默打印

使用 Electron 自带打印功能时,需要通过 BrowserWindow.webContents.print() 进行打印,这个方法只能打印当前浏览器窗口的内容,而不能指定文件进行打印。

如果需要打印 PDF 文件,一种解决办法是在渲染层将指定 PDF 文件渲染为图片然后打印,这种方法兼容性好,支持常见的操作系统(Mac/Windows);缺点也很明显:渲染多页 PDF 文件耗时长,并发打印支持差,并且需要主进程、渲染层合作处理,代码分散。

另一个方法是使用三方库,这些库内部调用系统命令或外部程序以实现打印 PDF 的功能,这种方法性能较好、支持并发,且只需要在主进程处理,缺点就是可定制性差。

这里使用三方库的方式,由于没有找到可靠的通用 PDF 打印库,所以针对 Mac 系统使用 unix-print,针对 Windows 系统使用 pdf-to-printer

它们各自的实现原理如下:

  • Mac 系统中内置了通用打印系统 CUPS,使用命令行 ls command 就可以实现打印 PDF,unix-print 就是对 ls 命令的一层包装

  • pdf-to-printer 中内置了 SumatraPDF 可执行程序,这是一个开源轻量的阅读器,支持以命令行方式调用以打印 PDF,pdf-to-printer 同样是对这些命令的一层包装。

这两个库虽然整体能够在外部封装统一接口来进行对齐,但还有一些差异是无法抹平的:

  • CUPS 支持设定自定义的纸张规格,这在处理非标准尺寸的 PDF 标签时是很有用的,而 SumatraPDF 不支持

    SumatraPDF 要实现这样的功能只能在 Windows 系统中添加自定义打印规格,并在打印时选择指定规格,这样就多了人工操作的步骤,非常不便。

  • CUPS 支持 lpstat 命令来查询打印任务,SumatraPDF 不支持

为了增强 Windows 端的打印功能,使用 .Net 8 实现了一个 Windows 控制台应用程序,实现逻辑如下:

  1. 启动时,启动一个管道,通过管道实现交互(这样可以避免每次调用程序时创建进程的开销)
  2. 根据入参,调用特定方法
  3. 打印功能:读取 PDF,转换为位图并调用 .Net 内置 SKD 打印,这种方式兼容性好、实现简单,缺点是位图放大时不如 PostScript 生成的清晰,体积也更大,不过在打印标签的场景上通常不会有太大问题
  4. .Net 还内置有其他实用功能,比如获取打印机列表、获取打印队列、自定义纸张规格等。

在开发 Windows 控制台程序时,曾考虑过使用三方包集成,这种方式无疑时最好的,但查询到仍在积极开发状态的 PDF 打印 Nuget 包基本采用付费授权,而一些开源的(比如 PdfiumViewer)包在很久前都已经停止开发了。

对外接口服务

使用 express 启动本地 HTTP 服务,进行 cors 设置,定义通用响应结构,为了后续扩展其他功能,这里以不同的路由前缀区分模块,比如:

  • /print/* 表示打印功能相关 API
  • /docs/* 表示文档相关 API

同时,为了检查服务器是否正常工作,提供 /ping 端点让外部调用。

考虑相关功能,应该提供以下 API:

  • /print/printer-list: 获取系统打印机列表
  • /print/printer-setting: 设置打印机打印参数
  • /print/printer-jobs: 获取指定打印机打印队列
  • /print/print-urls: 打印以 URL 格式提供的 PDF 文件
  • /print/print-base64: 打印以 base64 格式提供的 PDF 文件
  • /print/print-formdata: 打印以 FormData 格式提供的 PDF 文件

开发本地服务器供 Web 调用时,需要注意 Chrome 的兼容性,最近 Chrome 142 版本推出了本地网络访问权限提示。本地网络访问权限限制了 Web 访问本地服务器的能力,如果你的网站是以 https 提供服务的,Chrome 会在调用本地服务器时弹出权限提示,同意即可;但如果你的网站是以 http 提供服务的,那访问本地服务器时会静默失败,目前的方法是引导用户关闭 Chrome 对本地网络访问的限制。

安博通:筹划发行H股并在香港联交所上市

12月14日,安博通(688168.SH)公告称,公司拟在境外发行股份(H股)并在香港联合交易所有限公司上市。公司董事会同意授权公司管理层启动本次H股上市的前期筹备工作,授权期限为自董事会审议通过之日起12个月内。(每经网)

Next.js第十三章(缓存组件)

缓存组件(Cache Components)

什么是Cache Components?

Cache Components 是Next.js(16)版本特有的机制,实现了静态内容 动态内容 缓存内容的混合编排。保留了静态内容的加载速度,又具备动态渲染的灵活性,解决了静态内容(加载快但无法实时更新数据)动态内容(加载慢但可以实时更新数据)权衡的问题。

  • 静态内容: 构建(npm run build)时进行预渲染,例如 「本地文件」「模块导入」「纯计算」(无网络请求、无用户相关数据),会被直接编译成HTML瞬间加载、立即响应。

  • 动态内容:用户发起请求时才开始渲染的内容,依赖 “实时数据” 或 “用户个性化信息”,每次请求都可能生成不同结果,不会被缓存。例如「实时数据源」(如实时接口、数据库实时查询)或「用户请求上下文」(如 Cookie、请求头、URL 参数)

  • 缓存内容:缓存内容的本质就是缓存动态数据,缓存之后会被纳入静态外壳(Static Shell),静态外壳就类似于毛坯房,会提前把结构搭建好,后续在通过(流式传输)填充里面的动态内容。

传统方案 Cache Components
静态页面:数据无法实时更新 支持缓存内容重新验证,动态内容流式补充
动态页面:初始加载慢、服务器压力大 静态外壳优先返回,动态内容并行渲染
客户端渲染:bundle 体积大、首屏慢 服务器预渲染核心内容,客户端仅补充动态部分

启用Cache Components

Cache Components 为可选功能,需在 Next 配置文件中显式启用:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  cacheComponents: true, // 启用缓存组件
};

export default nextConfig;
1. 静态内容展示

适用场景:仅依赖同步 I/O(如 fs.readFileSync)、模块导入、纯计算的组件

import fs from 'node:fs'

export default async function Home() {
    const data = fs.readFileSync('data.json', 'utf-8') //本地文件读取
    const json = JSON.parse(data)
    const impData = await import('../../../data.json') //模块导入
    const names = impData.list.map(item=>item.name).join(',') //纯计算
    console.log(json)
    console.log(impData)
    console.log(names)
    return (
        <div>
            <h1>Home</h1>
            <ul>
                {json.list.map((item: any) => (
                    <li key={item.id}>{item.name} - {item.age}</li>
                ))}
            </ul>
        </div>
    )
}

image.png

2.1 动态内容展示

适用场景:fetch请求、cookies、headers等动态数据

动态内容必须配合Suspense使用。

import { Suspense } from "react"
import { cookies } from "next/headers"

const DynamicContent = async () => {
    const data = await fetch('https://www.mocklib.com/mock/random/name') //随机生成一个名称
    const json = await data.json()
    console.log(json)
    const cookieStore = await cookies() //获取cookie
    console.log(cookieStore)
    return (
        <div>
            <h2>动态内容</h2>
            <main>
                <ul>
                    <li>名称:{json.name}</li>
                </ul>
            </main>
        </div>
    )
}

export default async function Home() {

    return (
        <div>
            <h1>Home</h1>
            <Suspense fallback={<div>动态内容Loading...</div>}>
                <DynamicContent />
            </Suspense>
        </div>
    )
}

image.png

2.2 实现原理

Next.js 会通过(Partial Prerendering/PPR)技术,实现静态外壳(Static Shell)渲染,提供占位符,当用户请求时,再通过流式传输(Streaming)填充里面的动态内容,以此提升首屏加载速度和用户体验。

image.png

我们观察上图

  • <h1>Home</h1>: 纯静态内容,属于静态外壳的一部分,构建 / 请求时直接渲染,浏览器能立即显示。

  • <template id="B:0"></template> 动态内容的容器模板,后续用来挂载异步加载的动态内容

  • <div>动态内容Loading...</div>:占位符(fallback),属于静态外壳的一部分,在动态内容加载完成前显示。

image.png

  • 这个 <div> 初始为 hidden,是服务器异步渲染完成的动态内容,等待客户端脚本触发后替换到占位符位置。

  • id="S:0" 与前面的 <template id="B:0"> 一一对应,是 “动态内容 - 占位符” 的关联标识。

image.png

$RC("B:0", "S:0") // 关键调用:关联 B:0 占位符和 S:0 动态内容
  • RCReactContentReplace):找到id="B:0"的占位符和id="S:0"的动态内容,将其加入替换队列RC(React Content Replace):找到 id="B:0" 的占位符和 id="S:0" 的动态内容,将其加入替换队列 RB。
  • $RV(React Content Render):在动画帧 / 超时后执行替换,移除加载占位符,将动态内容插入到页面中,完成最终渲染。
2.3 非确定操作

例如: 随机数时间戳等非确定操作,每次请求都可能生成不同结果。

直接使用就会报错如下:

Error: Route "/home" used Math.random() before accessing either uncached data (e.g. fetch()) or Request data (e.g. cookies(), headers(), connection(), and searchParams). Accessing random values synchronously in a Server Component requires reading one of these data sources first. Alternatively, consider moving this expression into a Client Component or Cache Component. See more info here: nextjs.org/docs/messag… at DynamicContent (page.tsx:5:25) at Home (page.tsx:27:17)

解决方案:

使用Suspense包裹,然后使用connection表示不要预渲染这部分。

Next.js默认会尝试尽可能多地静态预渲染页面内容。但像 Math.random() 这样的值每次调用结果都不同,如果在预渲染时执行,那这个"随机值"就被固定了,失去了意义。 通过在 Math.random() 之前调用 await connection(),你明确告诉 Next.js:

  • 不要预渲染这部分
  • 等真正有用户请求时再执行
import { Suspense } from "react"
import { connection } from "next/server"

const DynamicContent = async () => {
    await connection() //使用connection表示不要预渲染这部分
    const random = Math.random()
    const now = Date.now()
    console.log(random, now)
    return (
        <div>
            <h2>动态内容</h2>
            <main>
                <ul>
                    <li>名称:{random}</li>
                    <li>时间:{now}</li>
                </ul>
            </main>
        </div>
    )
}

export default async function Home() {

    return (
        <div>
            <h1>Home</h1>
            <Suspense fallback={<div>动态内容Loading...</div>}>
                <DynamicContent />
            </Suspense>
        </div>
    )
}
3. 缓存内容展示

缓存组件,可以使用use cache声明这是一个缓存组件,然后使用cacheLife声明缓存时间。

cacheLife参数:

  • stale:客户端在此时间内直接使用缓存,不向服务器发请求(单位:秒)
  • revalidate:超过此时间后,服务器收到请求时会在后台重新生成内容(单位:秒)
  • expire:超过此时间无访问,缓存完全失效,下次请求需要等待重新计算(单位:秒)

预设参数:

Profile 适用场景 stale revalidate expire
seconds 实时数据(股票、比分) 30秒 1秒 1分钟
minutes 频繁更新(社交动态) 5分钟 1分钟 1小时
hours 每日多次更新(库存、天气) 5分钟 1小时 1天
days 每日更新(博客文章) 5分钟 1天 1周
weeks 每周更新(播客) 5分钟 1周 30天
max 很少变化(法律页面) 5分钟 30天 1年
import { Suspense } from "react"
import { cacheLife } from "next/cache"

const DynamicContent = async () => {
    'use cache'
    cacheLife("hours") //使用预设参数
    //cacheLife({stale: 30, revalidate: 1, expire: 1}) //使用自定义参数
    const data = await fetch('https://www.mocklib.com/mock/random/name')
    const json = await data.json()
    console.log(json)
    return (
        <div>
            <h2>动态内容</h2>
            <main>
                <ul>
                    <li>名称:{json.name}</li>
                </ul>
            </main>
        </div>
    )
}

export default async function Home() {

    return (
        <div>
            <h1>Home</h1>
            <Suspense fallback={<div>动态内容Loading...</div>}>
                <DynamicContent />
            </Suspense>
        </div>
    )
}

8.gif

“受控组件”的诅咒:为什么你需要 React Hook Form + Zod 来拯救你的键盘?

前言:你的手指累吗?

上一篇咱们聊了把 Tabs 状态放进 URL 里,这就好比给了你的应用一个“身份证”,走到哪都在。

但在这个 Tab 下面,往往藏着前端开发最大的噩梦——表单

你是不是还在这么写代码? 一个简单的注册页,你定义了 username, password, email, confirmPassword 四个 useState。 然后写了四个 handleXxxChange 函数。 每次用户敲一个键盘,React 就重新渲染一次组件(Re-render)。 如果表单有 50 项,你在第一个输入框打字,整个页面卡得像是在放 PPT。

还有那个该死的校验逻辑:

if (!email.includes('@')) { ... }
if (password.length < 8) { ... }
if (password !== confirmPassword) { ... }

这些 if-else 像面条一样缠绕在你的组件里。写到最后,你都不知道自己是在写 UI 还是在写逻辑判断。

兄弟,放下你手里的 useState。是时候引入 React Hook FormZod 这对“黄金搭档”了。

观念重塑:受控 vs 非受控

React 官方文档早期教我们要用“受控组件”(Controlled Components),也就是 value 绑定 state,onChange 更新 state。

但这在复杂表单场景下,简直是性能杀手

React Hook Form (RHF) 的核心哲学是回归 HTML 的本质:非受控组件(Uncontrolled Components) 。 它利用 ref 直接接管原生 DOM 元素。

  • 你打字时,React 渲染。
  • 只有校验报错或提交时,React 才介入。

这就好比,以前你是傀儡师,手要把着每一个木偶的关节动(受控);现在你给了木偶一个指令“往前走”(非受控),它自己就走了,你只管终点。

实战演练:从 50 行代码缩减到 10 行

假设我们要写一个带有校验的用户表单。

❌ 痛苦的传统写法(useState):

  const [values, setValues] = useState({ name: '', age: 0 });
  const [errors, setErrors] = useState({});

  const handleChange = (e) => {
    // 1. 更新状态(触发重渲染)
    setValues({ ...values, [e.target.name]: e.target.value });
    // 2. 还要在这里手写校验逻辑,或者等提交时校验
    // ...逻辑省略,已经想吐了
  };

  return (
    <form>
      <input name="name" value={values.name} onChange={handleChange} />
      <input name="age" value={values.age} onChange={handleChange} />
    </form>
  );
};

✅ 爽翻天的 RHF 写法:


const BetterForm = () => {
  // register: 注册器,相当于 ref + onChange 的语法糖
  // handleSubmit: 帮你处理 `e.preventDefault` 和数据收集
  // formState: 所有的错误状态都在这
  const { register, handleSubmit, formState: { errors } } = useForm();

  const onSubmit = (data) => console.log(data); // data 直接就是 {name:..., age:...}

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* 也就是一句话的事 */}
      <input {...register("name", { required: true })} />
      {errors.name && <span>名字必填</span>}
      
      <input {...register("age")} />
      
      <button type="submit">提交</button>
    </form>
  );
};

看到没有?没有 useState,没有 handleChangeregister 函数给你的 Input 注入了原本需要手写的 name, onBlur, onChange, ref。一切都在黑盒里自动完成了。

灵魂伴侣:Zod 登场

RHF 解决了数据收集和性能问题,但校验规则如果写在 JSX 里(比如 { required: true, minLength: 10 }),还是很乱。

这时候,你需要 Zod。 Zod 是一个 TypeScript 优先的 Schema 声明库。简单说,就是把校验逻辑从 UI 里抽离出来,变成一份“说明书”

1. 定义说明书 (Schema)


const schema = z.object({
  username: z.string().min(2, "名字太短了,再长点"),
  email: z.string().email("这根本不是邮箱"),
  age: z.number().min(18, "未成年人请绕道").max(100),
  // 甚至可以做复杂的依赖校验
  password: z.string().min(6),
  confirm: z.string()
}).refine((data) => data.password === data.confirm, {
  message: "两次密码不对啊兄弟",
  path: ["confirm"], // 错误显示在 confirm 字段下
});

2. 连接 RHF 和 Zod

我们需要一个“中间人”:@hookform/resolvers

import { zodResolver } from '@hookform/resolvers/zod';

const BestForm = () => {
  const { 
    register, 
    handleSubmit, 
    formState: { errors, isDirty } // isDirty 很有用!
  } = useForm({
    resolver: zodResolver(schema) // ✨ 注入灵魂
  });

  return (
    <form onSubmit={handleSubmit(saveData)}>
      <input {...register("username")} />
      <p className="text-red-500">{errors.username?.message}</p>
      
      {/* ...其他字段... */}
      
      <button type="submit">提交</button>
    </form>
  );
};

现在,你的 JSX 极其干净。所有的校验逻辑都在 schema 对象里。想改规则?去改 schema 就行,不用动组件。

回应开头:防止“手滑关网页”

还记得前言里说的那个悲剧吗?用户填了一半,手滑关了 Tab。

RHF 提供了一个神属性:isDirty(表单是否被弄脏了/是否被修改过)。

配合 React Router 的 useBlocker (v6.x) 或者传统的 window.onbeforeunload,我们可以轻松实现拦截


// 一个简单的拦截 Hook
const usePreventLeave = (isDirty) => {
  useEffect(() => {
    const handleBeforeUnload = (e) => {
      if (isDirty) {
        e.preventDefault();
        // 现代浏览器通常不支持自定义文本,但这会触发浏览器的默认弹窗
        e.returnValue = ''; 
      }
    };
    
    window.addEventListener('beforeunload', handleBeforeUnload);
    return () => window.removeEventListener('beforeunload', handleBeforeUnload);
  }, [isDirty]);
};

// 在组件里使用
const MyForm = () => {
  const { formState: { isDirty } } = useForm();
  
  // 只要用户改了一个字,isDirty 就变成 true,防守模式开启
  usePreventLeave(isDirty); 
  
  return <form>...</form>;
}

现在,只要用户动了表单,试图关闭或刷新页面时,浏览器就会弹出一个无情的警告框:“您有未保存的更改,确定要离开吗?”。 PM 再也不用担心用户投诉数据丢了。


总结

React Hook Form + Zod 是现代 React 开发的“工业标准”。

  • RHF 负责 DOM 交互和性能优化,让你的页面丝滑顺畅。
  • Zod 负责数据结构和逻辑校验,保证你的数据干净可靠。
  • TypeScript 自动推导类型,让你写代码有自动补全。

当你熟练掌握这一套组合拳,你会发现写表单不再是折磨,甚至有一种解压的快感。

好了,我要去把那个嵌套了 10 层 if-else 校验的屎山给重构了。

438783fa67714fda959e8e52adb9245d.webp


下期预告:表单数据量如果不止 50 项,而是 10,000 项呢? 比如一个超长的“用户管理列表”,或者一个即时滚动的“日志监控台”。如果你把这 10,000 个 div 直接渲染出来,浏览器会直接死机。 下一篇,我们来聊聊 “虚拟滚动 (Virtual Scrolling) 技术” 。教你如何用 react-window 欺骗用户的眼睛,让无限列表像德芙一样纵享丝滑。

将flutter打成aar包嵌入到安卓

Flutter Module 打包成 AAR 并集成到 Android 项目

一、创建 Flutter Module

如果你 还没有 Flutter Module

flutter create -t module flutter_module

二、构建 Flutter AAR

执行 AAR 构建命令

flutter build aar

构建产物位置

flutter_module/build/host/outputs/repo/

构建控制台输出

在下面的配置中会用到

  1. Open <host>\app\build.gradle
  2. Ensure you have the repositories configured, otherwise add them:

      String storageUrl = System.env.FLUTTER_STORAGE_BASE_URL ?: "https://storage.googleapis.com"
      repositories {
        maven {
            url 'E:\lumosproject\module\lumos\build\host\outputs\repo'
        }
        maven {
            url "$storageUrl/download.flutter.io"
        }
      }

  3. Make the host app depend on the Flutter module:

    dependencies {
      debugImplementation 'com.example.lumos:flutter_debug:1.0'
      profileImplementation 'com.example.lumos:flutter_profile:1.0'
      releaseImplementation 'com.example.lumos:flutter_release:1.0'
    }


  4. Add the `profile` build type:

    android {
      buildTypes {
        profile {
          initWith debug
        }
      }
    }


三、Android 宿主项目集成 AAR

1. 修改MyApp/app/build.gradle

你目前现有的 Android 项目可能支持 mips 或 x86 之类的架构,然而,Flutter 当前仅支持 为 x86_64armeabi-v7a 和 arm64-v8a 构建预编(AOT)的库。

可以考虑使用 abiFilters 这个 Android Gradle 插件 API 来指定 APK 中支持的架构,从而避免 libflutter.so 无法生成而导致应用运行时崩溃,具体操作如下:

Groovy 版
android {
    defaultConfig {
        ndk {
            // Filter for architectures supported by Flutter
            abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
        }
    }
}
Kotlin DSL
android {
    defaultConfig {
        ndk {
            // Filter for architectures supported by Flutter
            abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86_64")
        }
    }
}

2. 配置 settings.gradle(.kts)

在国内,需要使用镜像站点代替 storage.googleapis.com。有关镜像的详细信息,参见 在中国网络环境下使用 Flutter 页面。

Groovy 版
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    String storageUrl = System.env.FLUTTER_STORAGE_BASE_URL ?: "https://storage.flutter-io.cn"
    repositories {

        google()
        mavenCentral()
        maven {
            url 'E:/lumosproject/module/lumos/build/host/outputs/repo'
        }
        maven {
            url "$storageUrl/download.flutter.io"
        }
    }
}
Kotlin DSL
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS)
    repositories {
        google()
        mavenCentral()
        maven("https://storage.googleapis.com/download.flutter.io")
    }
}

3. 添加依赖(app/build.gradle)

这里添加的依赖都是build aar 控制台输出的内容

groovy版
dependencies {
   debugImplementation 'com.example.untitled:flutter_debug:1.0'
   profileImplementation 'com.example.untitled:flutter_profile:1.0'
   releaseImplementation 'com.example.untitled:flutter_release:1.0'
}
Kotlin DSL
dependencies {
debugImplementation("com.example.flutter_module:flutter_debug:1.0")    
releaseImplementation("com.example.flutter_module:flutter_release:1.0")   
add("profileImplementation", "com.example.flutter_module:flutter_profile:1.0") 
}

4. 添加profile build type(app/build.gradle)

buildTypes中添加

groovy版
profile {
    initWith debug
}
Kotlin DSL
create("profile") { initWith(getByName("debug")) }

四、Android 启动 Flutter 页面

1.在 AndroidManifest.xml 中添加 FlutterActivity

Flutter 提供了 FlutterActivity,用于在 Android 应用内部展示一个 Flutter 的交互界面。和其他的 Activity 一样,FlutterActivity 必须在项目的 AndroidManifest.xml 文件中注册。将下边的 XML 代码添加到你的 AndroidManifest.xml 文件中的 application 标签内

<activity
  android:name="io.flutter.embedding.android.FlutterActivity"
  android:theme="@style/LaunchTheme"   android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
  android:hardwareAccelerated="true"
  android:windowSoftInputMode="adjustResize"
  />

2. 加载 FlutterActivity

确保使用如下的语句导入:

import io.flutter.embedding.android.FlutterActivity;
MyButton(onClick = {
    startActivity(
        FlutterActivity.createDefaultIntent(this)
    )
})

@Composable
fun MyButton(onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("Launch Flutter!")
    }
}

上述的例子假定了你的 Dart 代码入口是调用 main(),并且你的 Flutter 初始路由是 '/'。 Dart 代码入口不能通过 Intent 改变,但是初始路由可以通过 Intent 来修改。下面的例子讲解了如何打开一个自定义 Flutter 初始路由的 FlutterActivity

MyButton(onClick = {
  startActivity(
    FlutterActivity
      .withNewEngine()
      .initialRoute("/my_route")
      .build(this)
  )
})

@Composable
fun MyButton(onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("Launch Flutter!")
    }
}

3. 使用缓存 Engine(推荐)

每一个 FlutterActivity 默认会创建它自己的 FlutterEngine。每一个 FlutterEngine 会有一个明显的预热时间。这意味着加载一个标准的 FlutterActivity 时,在你的 Flutter 交互页面可见之前会有一个短暂的延迟。想要最小化这个延迟时间,你可以在抵达你的 FlutterActivity 之前,初始化一个 FlutterEngine,然后使用这个已经预热好的 FlutterEngine

class MyApplication : Application() {
  lateinit var flutterEngine : FlutterEngine

  override fun onCreate() {
    super.onCreate()

    // Instantiate a FlutterEngine.
    flutterEngine = FlutterEngine(this)

    // Start executing Dart code to pre-warm the FlutterEngine.
    flutterEngine.dartExecutor.executeDartEntrypoint(
      DartExecutor.DartEntrypoint.createDefault()
    )

    // Cache the FlutterEngine to be used by FlutterActivity.
    FlutterEngineCache
      .getInstance()
      .put("my_engine_id", flutterEngine)
  }
}
使用
myButton.setOnClickListener {
  startActivity(
    FlutterActivity
      .withCachedEngine("my_engine_id")
      .build(this)
  )
}
为缓存的 FlutterEngine 设置初始路由
class MyApplication : Application() {
  lateinit var flutterEngine : FlutterEngine
  override fun onCreate() {
    super.onCreate()
    // Instantiate a FlutterEngine.
    flutterEngine = FlutterEngine(this)
    // Configure an initial route.
    flutterEngine.navigationChannel.setInitialRoute("your/route/here");
    // Start executing Dart code to pre-warm the FlutterEngine.
    flutterEngine.dartExecutor.executeDartEntrypoint(
      DartExecutor.DartEntrypoint.createDefault()
    )
    // Cache the FlutterEngine to be used by FlutterActivity or FlutterFragment.
    FlutterEngineCache
      .getInstance()
      .put("my_engine_id", flutterEngine)
  }
}
通过设置路由的方式可以配置缓存引擎在执行 Dart 入口点之前使用自定义初始路由

五、Flutter 与 Android 通信

MethodChannel方式

Flutter

添加到合适的地方

static const methodChannel = MethodChannel('com.bluetoothCharacteristic');
methodChannel.setMethodCallHandler(_handleMethod);
Future<dynamic> _handleMethod(MethodCall call) async {
  switch (call.method) {
    case 'bluetoothCharacteristic':
      // 设置设备写入特征
      _device.setWriteCharacteristic(
        call.arguments as BluetoothCharacteristic,
      );
      break;
    default:
      throw PlatformException(code: 'Unrecognized Method');
  }
}

Android


private lateinit var methodChannel: MethodChannel
methodChannel = MethodChannel(
    flutterEngine.dartExecutor.binaryMessenger,
    "com.bluetoothCharacteristic"
)
使用
Button(
    onClick = {
        val intent = FlutterActivity
            .withCachedEngine("my_engine_id")
            .build(activity)
        methodChannel.invokeMethod(
            "bluetoothCharacteristic",
            "00002a37-0000-1000-8000-00805f9b34fb"
        );
        activity.startActivity(intent)
    },
    modifier = Modifier.padding(16.dp)
) {
    Text("跳转到flutter页面")
}

MethodChannel 中的 com.bluetoothCharacteristic 和methodChannel.invokeMethod中的bluetoothCharacterstic必须和 Flutter 中保持一致,否则接收不到数据。

EventChannel方式

EventChannel的使用方式大致和MethodChannel相同,我这里就把主要代码复制下来了。

class MainActivity : ComponentActivity() {
    private lateinit var flutterEngine: FlutterEngine
    private lateinit var eventChannel: EventChannel
    private var eventDataSink: EventChannel.EventSink? = null
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        // 创建 FlutterEngine
        flutterEngine = FlutterEngine(this)
        //设置初始路由
        flutterEngine.navigationChannel.setInitialRoute("/settings")
        // 启动 Flutter 引擎
        flutterEngine.dartExecutor.executeDartEntrypoint(
            DartExecutor.DartEntrypoint.createDefault()
        )
        // 设置 EventChannel 用于实时数据传输
        eventChannel = EventChannel(flutterEngine.dartExecutor, "com.example.flutter_aar_demo/event_channel")
        eventChannel.setStreamHandler(object : EventChannel.StreamHandler {
            override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
                eventDataSink = events
                startRealTimeDataTransmission()
            }
            override fun onCancel(arguments: Any?) {
                eventDataSink = null
            }
        })
        // 缓存 FlutterEngine
        FlutterEngineCache.getInstance().put("my_engine_id", flutterEngine)
    }

我这里的安卓使用的是groovy的创建方式Kotlin DSL的方式是我直接从官网复制的代码可能有不正确的地方具体请查看将 Flutter module 集成到 Android 项目

拿捏 React 组件通讯:从父子到跨组件的「传功秘籍」

前言

你有没有过这种崩溃时刻?写 React 组件时,想让父子组件互通数据,结果代码越写越乱;兄弟组件想传个值,绕了八层父组件还没搞定…… 为啥别人的 React 组件传值丝滑?别慌!秘密都在这!今天咱们用 「武侠传功」 的思路,React 组件通讯的坑,一篇帮你填平!

一、父子组件:父传子的「单向秘籍」

想象一下:父组件武林盟主,手里有本 《九阴真经》(变量),想传给子组件这个小徒弟。但规矩是:

徒弟只能看,不能改!

传功步骤:

  1. 父组件发功:在子组件标签上绑定属性(把秘籍递出去)
  2. 子组件接功:通过 props 接收(双手接住秘籍)

看个例子:

父组件(盟主)把 state.name 传给子组件:

// 父组件 Parent.jsx
import Child from "./Child";
export default function Parent(props) {
    const state = {
        name: 'henry' // 这是要传的「秘籍」
    };
    return (
        <div>
            <h2>父组件</h2>
            <Child msg = {state.name}/> {/* 绑定属性 msg,把秘籍递出去 */}
        </div>
    );
}

子组件(徒弟)用 props 接收,但不能修改(秘籍是只读的!):

// 子组件 Child.jsx
export default function Child(props) {
    console.log(props); // 能看到 {msg: 'henry'}
    // props.msg = 'harvest'; // ❌ 报错!props 是只读的,不能改!
    return (
        {/* 展示接收到的「秘籍」 */}
        <div>子组件 -- {props.msg}</div> 
    );
}

打印结果如下:

image.png

如果修改值会报错(只读,不能改!):

image.png

二、子父组件:子传父的「反向传功」

这次反过来:子组件是徒弟,练出了新招式(变量),想传给父组件盟主。但徒弟不能直接塞给盟主,得让盟主递个「接收袋」(函数),徒弟把招式装进去

传功步骤:

  1. 父组件递袋:定义接收数据的函数(准备好接收袋)
  2. 父传子袋:把函数通过 props 传给子组件(把袋子递过去)
  3. 子组件装招:调用函数并传数据(把新招式装进袋子)

代码例子:

父组件准备「接收袋」getNum,传给子组件:

// 父组件 Parent.jsx
import Child from "./Child"
import { useState } from "react";
export default function Parent() {
    let [count, setCount] = useState(1);
    // 定义「接收袋」:收到数据后更新自己的状态
    const getNum = (n) => {
        setCount(n);
    }
    return (
        <div>
            <h2>父组件二 -- {count}</h2>
            <Child getNum = {getNum}/> {/* 把袋子递过去 */}
        </div>
    );
}

子组件调用 getNum,把自己的 state.num 传过去:

// 子组件 Child.jsx
export default function Child(props) {
    const state = {
        num: 100 // 自己练的新招式
    };
    function send() {
        props.getNum(state.num); // 把新招式装进袋子
    }
    return (
        <div>
            <h3>子组件二</h3>
            <button onClick={send}>发送</button> {/* 点按钮传功 */}
        </div>
    )
}

点击发送前:

image.png

点击发送后:

image.png

这样我们就成功的把子组件的变量传给了父组件。这里我们用到了useState,至于它的作用我们暂时先不讲,我们先搞懂通讯。

三、兄弟组件:「父组件当中间商」

兄弟组件像两个师兄弟,想互相传功?得先把招式传给盟主父组件(中间商),再让父组件转给另一个兄弟。

传功步骤:

  1. 弟 1 传父:弟 1 把数据传给父组件
  2. 父传弟 2:父组件把数据传给弟 2

依旧代码:

父组件当中间商,接收 Child1 的数据,再传给 Child2:

// 父组件 Parent.jsx
import { useState } from "react";
import Child1 from "./Child1"
import Child2 from "./Child2"
export default function Parent(props) {
    let [message, setMessage] = useState();
    // 接收 Child1 的数据
    const getMeg = (msg) => {
        setMessage(msg);
    }
    return (
        <div>
            <h2>父组件三</h2>
            <Child1 getMeg = {getMeg} /> {/* 收 Child1 的数据 */}
            <Child2 msg = {message}/> {/* 把数据传给 Child2 */}
        </div>
    )
}

Child1(兄) 传数据给父:

// Child1.jsx
export default function Child1(props) {
    const state = {
        msg: '3.1'
    };
    function send() {
        props.getMeg(state.msg); // 传给父组件
    }
    return (
        <div>
            <h3>子组件3.1</h3>
            <button onClick={send}>发送</button>
        </div>
    )
}

Child2(弟) 接收父组件传来的数据:

// Child2.jsx
export default function Child2(props) {
    return (
        <div>
            <h3>子组件3.2 -- {props.msg}</h3> {/* 展示兄传来的数据 */}
        </div>
    )
}

发送前:

image.png

发送后:

image.png

如此这般,子组件3.2就成功接收到了子组件3.1的传递信息。

四、跨组件通信:「Context 全局广播」

如果组件嵌套了好多层(比如父→子→孙→重孙),一层层传功太麻烦了!这时候用 Context 就像 「武林广播」:父组件把数据广播出去,所有后代组件都能直接收到。

广播步骤:

  1. 父组件建广播台:用 createContext 创建上下文对象,用 Provider 广播数据
  2. 后代组件收广播:用 useContext 接收广播的内容

还是代码:

父组件建广播台,广播「父组件的数据」:

// 父组件 Parent.jsx
import Child1 from "./Child1";
import { createContext } from 'react';
// 创建上下文对象(广播台)
export const Context = createContext() 
export default function Parent() {
    return (
        <div>
            <h2>父组件四</h2>
            {/* 用 Provider 广播数据,value 是要传的内容 */}
            <Context.Provider value={'父组件的数据'}>
                <Child1/>
            </Context.Provider>
        </div>
    );
}

孙组件直接收广播(不用经过子组件):

// 孙组件 Child2.jsx
import { useContext } from 'react'
import { Context } from './Parent' // 引入广播台
export default function Child2() {
    const msg = useContext(Context) // 直接接收广播内容
    return (
        <div>
            <h4>孙子组件 --- {msg}</h4> {/* 展示广播的内容 */}
        </div>
    );
}

另外附上子组件代码:

import Child2 from "./Child2"
export default function Child1() {
    return (
        <div>
            <h3>子组件</h3>
            <Child2/>
        </div>
    );
}

结果孙子组件成功得到父组件的数据:

image.png

五、温馨提示

别忘了App.jsxmain.jsx文件!前面的所有结果都需要这两个大佬的支持。

// App.jsx
// import Parent from "./demo1/Parent"
// import Parent from "./demo2/Parent"
// import Parent from "./demo3/Parent"
import Parent from "./demo4/Parent"

export default function App() {
    return (
        <Parent></Parent>
    )
}
// main.jsx
import { createRoot } from 'react-dom/client'
import App from './App.jsx'

createRoot(document.getElementById('root')).render(
    <App />
)

这两份是创建React自带的,但是还是提醒一下大家别忘了!

六、总结:组件通讯「武功谱」

通讯场景 方法 核心思路
父子组件 props 传值 父传子,子只读
子父组件 props 传函数 父给函数,子调用传数据
兄弟组件 父组件中转 子→父→另一个子
跨组件 Context(上下文) 父广播,后代直接收

结语

其实 React 组件通讯的核心,从来都不是死记硬背步骤,而是找准数据的流向。父子传值用 props 单向传递,子父传值借函数反向搭桥,兄弟组件靠父组件当 “中转站”,跨层级通信就用 Context 打破嵌套壁垒。

这些方法没有优劣之分,只有场景之别。新手阶段先把 props 和函数传值练熟,再逐步尝试 Context,甚至后续学习的 Redux 等状态管理库,都是在这个基础上的延伸。

记住:

组件通讯本质,就是让数据在合适的组件间有序流动。现在就打开编辑器,把这些例子敲一遍,你会发现,那些曾经让你头疼的传值问题,早就迎刃而解了。

❌