阅读视图

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

🏒 前端 AI 应用实战:用 Vue3 + Coze,把宠物一键变成冰球运动员!

不是 AI 不够强,而是你还没把它“接进前端”

这是一篇真正「前端视角」的 AI 应用落地实战,而不是模型科普。


🤔 为什么我要做这个「宠物冰球员」AI 应用?

最近刷掘金,你一定发现了一个现象 👇

  • AI 很火
  • 大模型很强
  • 但真正能跑起来的 前端 AI 应用很少

很多同学卡在这一步:

❌ 会 Vue / React
❌ 会调接口
❌ 但不知道 AI 项目整体该怎么搭

于是我做了这个项目。


🎯 项目一句话介绍

上传一张宠物照片,生成一张专属“冰球运动员形象照”

而且不是随便生成,而是可控的 AI👇

  • 🧢 队服编号
  • 🎨 队服颜色
  • 🏒 场上位置(守门员 / 前锋 / 后卫)
  • ✋ 持杆方式(左 / 右)
  • 🎭 绘画风格(写实 / 日漫 / 国漫 / 油画 / 素描)

📌 这是一个典型的「活动型 AI 应用」

非常适合:

  • 冰球协会宣传
  • 宠物社区裂变
  • 活动拉新
  • 朋友圈分享

🧠 整体架构:前端 + AI 是怎么配合的?

先上结论👇

前端负责“意图”,AI 负责“生成”

整体流程非常清晰:

Vue3 前端
  ↓
图片上传(Coze 文件 API)
  ↓
调用 Coze 工作流
  ↓
AI 生成图片
  ↓
前端展示结果

🧩 技术选型一览

模块 技术
前端 Vue3 + Composition API
AI 编排 Coze 工作流
网络 fetch / HTTP
上传 FormData
状态 ref 响应式

🖼️ 前端第一难点:图片上传 & 预览

AI 应用里,最容易被忽略的不是 AI,而是用户体验

❓ 一个问题

图片很大,用户点「生成」之后什么都没发生,会怎样?

答案是:
他以为你的网站卡死了


✅ 解决方案:本地预览(不等上传)

const reader = new FileReader()
reader.readAsDataURL(file)
reader.onload = e => {
  imgPreview.value = e.target.result
}

📌 这里的关键点是:

  • FileReader
  • readAsDataURL
  • base64 直接渲染

图片还没上传,用户已经“看见反馈”了


🎛️ 表单不是表单,而是「AI 参数面板」

很多人写表单是为了提交数据
但 AI 应用的表单,本质是 Prompt 的一部分

<select v-model="style">
  <option value="写实">写实</option>
  <option value="日漫">日漫</option>
  <option value="油画">油画</option>
</select>

最终在调用工作流时,变成:

parameters: {
  style,
  uniform_color,
  uniform_number,
  position,
  shooting_hand
}

💡 前端的职责不是“生成 AI”
💡 而是“让 AI 更听话”


🤖 AI 真正干活的地方:Coze 工作流

一个非常重要的认知👇

❌ AI 逻辑不应该写在前端
✅ AI 逻辑应该写在「工作流」里


🧩 我的 Coze 工作流结构(核心)

你搭建的工作流大致包含:

  • 📷 图片理解(imgUnderstand)
  • 🔍 特征提取
  • 📝 Prompt 生成
  • 🎨 图片生成
  • 🔗 输出图片 URL

👉 工作流地址(可直接参考)
🔗 www.coze.cn/work_flow?w…

📌 工作流 = AI 后端

前端只需要做一件事👇

fetch('https://api.coze.cn/v1/workflow/run', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${patToken}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    workflow_id,
    parameters
  })
})

📤 文件上传:前端 AI 项目的必修课

❓ 为什么不能直接把图片传给工作流?

因为:

  • 工作流不能直接接收本地文件
  • 必须先上传,换一个 file_id

✅ 正确姿势:FormData

const formdata = new FormData()
formdata.append('file', input.files[0])

返回结果中会拿到:

{
  "data": {
    "id": "file_xxx"
  }
}

然后在工作流参数里传👇

picture: JSON.stringify({ file_id })

📌 AI 应用用的还是老朋友:HTTP + 表单


⏳ 状态管理:AI 应用的“信任感来源”

AI ≠ 秒出结果
所以状态提示非常重要👇

status.value = "图片上传中..."
status.value = "正在生成..."

如果出错👇

if (ret.code !== 0) {
  status.value = ret.msg
}

一个没有状态提示的 AI 应用 = 不可用


⚠️ AI 应用的三个“隐藏坑”

1️⃣ AI 是慢的

  • loading 必须有
  • 按钮要禁用
  • 用户要知道现在在干嘛

2️⃣ AI 是不稳定的

  • 可能失败
  • 可能生成不符合预期
  • 可能 URL 为空

📌 前端必须兜底,而不是假设 AI 永远成功


3️⃣ AI 应用 ≠ CRUD

它更像一次:

用户意图 → AI 理解 → 内容生成 → 结果反馈


✅ 做完这个项目,你真正掌握了什么?

如果你完整跑通这套流程,你至少学会了👇

  • ✅ Vue3 Composition API 实战
  • ✅ 文件上传 & 图片预览
  • ✅ AI 工作流的正确使用方式
  • ✅ 前端如何“驱动 AI”
  • ✅ 一个完整 AI 应用的工程思路

✍️ 写在最后:前端 + AI 的真正价值

很多人担心👇

「前端会不会被 AI 取代?」

我的答案是:

❌ 只会写页面的前端会被取代
✅ 会设计 AI 交互体验的前端不会

AI 很强
AI 不知道用户要什么

而前端,正是连接「用户意图」和「AI 能力」的桥梁。

Qcon 上海 2025 支付宝 AI Agent编码助手实战:面向KMP原生跨端实现研发提效

这边文章是 Qcon 上海站 2025 来自支付宝的KMP分享总结, 主题为”AI Agent编码助手实战:面向KMP原生跨端实现研发提效”
文章参考: 支付宝 MYKMP 原生跨平台解决方案
文章参考 : AI Agent 编码助手实战:面向 KMP 原生跨端实现研发提效

AI Agent编码助手实战:面向KMP原生跨端实现研发提效

背景介绍:支付宝KMP原生跨端架构

本次分享首先对相关核心技术术语进行说明:

术语名称 术语介绍
KMP(Kotlin Multiplatform) JetBrains 基于 Kotlin 推出的一套跨端框架,允许开发者使用 Kotlin 语言编写一次业务逻辑代码,然后将其编译成适用于多个平台的原生应用、Web 应用或服务端应用。
CMP(Compose Multiplatform) JetBrains 提供的一套基于 Compose 基础库的声明式 UI 跨端框架,支持在 Android、iOS、桌面和 Web 开发共享 UI。
支付宝 KMP 原生跨端 在 “Kotlin + Compose Multiplatform” 的基础上,为支付宝终端开发者提供一整套完善的跨端框架能力。
AntUI 组件库 基于 Compose 编写的支付宝 UI 组件库,包含丰富且风格统一的 UI 组件。
OHOS、Harmony OHOS 是鸿蒙项目的开源操作系统基底,而 HarmonyOS 是基于 OHOS 打造的商用智能终端操作系统。

KMP原生跨端的核心优势在于显著减少为不同平台重复开发的工作量,同时能保持各平台原生的最佳用户体验。

支付宝在基础KMP架构上进行了深度扩展,构建了增强型跨端框架,其分层架构如下:

  • MY CMP -> UI与体验层
    • 双渲染管线:除CMP默认的Skiko引擎外,自研了Canvas渲染引擎,以在内存、滑动流畅性等方面实现性能优化。
    • AntUI高阶组件库:提供丰富的企业级UI组件。
    • 自动化能力:集成自动化埋点(无需手动添加点击等事件上报)、UI重组耗时检测工具。
    • 运行时监控:对线上ANR(应用无响应)、掉帧、无限重组等问题进行监控。
    • 原生组件嵌入:支持在Android、iOS、鸿蒙平台嵌入原生渲染的View。
    • 上层框架:封装了导航、事件、应用生命周期等统一框架。
  • MY KMP -> Kotlin跨平台层扩展
    • 平台API导出:将各原生平台常用API导出为Kotlin接口供开发者调用。
    • Runtime优化:对平台运行时进行优化,降低内存占用并提升加载性能。
    • 自研LLVM技术:支持编译插桩等高级操作。
    • 编译器优化:通过前后端编译器优化,显著减小产物包体积。
    • 鸿蒙通信通道简化:去除了传统KMP鸿蒙开发中必需的C语言桥接层,实现了Kotlin与eTS(鸿蒙开发语言)的直接高效通信。
  • 跨端基座
    • C++基础库:将网络库等原生C++能力封装并透出Kotlin接口。
    • 原生平台能力增强:在鸿蒙平台深度集成其Pipeline、事件中心、渲染、资源加载等原生能力至KMP框架。
    • Tecla API:基于自研IDL(接口描述语言)提供的跨端、跨技术栈API调用机制。开发者只需调用Kotlin接口,即可在安卓、iOS、鸿蒙三端使用支付宝的中间件能力。
  • 工程体系集成:将KMP框架无缝融入支付宝现有的工程研发体系,提升开发效率。

目前,该KMP跨端架构已在支付宝多个核心业务场景(如“我的”、理财、直播、消息页,以及出行服务、健康管家等独立APP)中落地,覆盖安卓、iOS、鸿蒙三大平台,均实现了与原生开发对标的高性能体验。整体已支撑亿级PV,成为支付宝内重点发展的主流原生跨端技术栈。

KMP研发现状、痛点与AI工具调研

尽管KMP技术带来效率提升,但其研发全流程仍存在若干痛点:

  1. 起步阶段:开发者需从头学习Kotlin、Compose及KMP/CMP特有语法,存在较高的学习成本。
  2. 开发阶段:开发者不熟悉框架提供的跨端API(如AntUI、Tecla)是否能满足需求及具体调用方式。
  3. 测试阶段:主要依赖人工测试,效率低下,缺乏自动化与AI辅助手段。
  4. 上线运维阶段:三端(尤其是KMP特有)的崩溃堆栈反解与分析耗时较长,问题定位与优化成本高。

针对上述痛点,我们对现有AI编码工具进行了调研,结论是:目前缺乏一款能与客户端基础框架深度结合、支持KMP技术栈、并适配支付宝终端研发工程体系的专用编码助手。

具体对比如下:

  • 内部两款热门助手:能力丰富,但不支持KMP跨端开发辅助。
  • Cursor:支持KMP技术栈,但缺乏转码等深度能力,无法融入支付宝工程体系,且不了解CMP在鸿蒙平台的特定知识。
  • Cline:与Cursor存在类似问题,且其推理步骤复杂度较高。

因此,我们期望打造一款具备跨端特色的AI编程伙伴,以解决实际研发问题,提升效率。

KMP编码助手:方案与实践

构建了KMP的编码助手,其核心目标是运用AI技术为KMP开发带来“二次加速”。以下从方案构思到核心功能实现进行剖析。

整体可行性评估与架构

项目初期,我们从四个维度评估了可行性:

  1. 图生码/设计稿生码:通过让大模型学习AntUI组件,验证了其能直接输出对应界面代码的可行性。
  2. 算法支撑:具备在终端研发场景下产出领域自研算法模型的能力,以增强生码效果。
  3. 生产资料支撑:拥有完整的KMP(含鸿蒙)技术栈研发能力、四端AntUI组件库的开发和维护能力,以及可通过Tecla透出的丰富基础框架能力,能为大模型提供充足的学习素材。
  4. 插件结合方式:确定以Android Studio(KMP研发主要IDE)的IntelliJ IDEA插件形式进行集成验证。

整体架构分为三层:

  • 客户端层:作为Agent与用户的交互界面(IDE插件)。
  • Agent框架层(核心):进行了工作流编排、任务分解、知识图谱构建、UI转换等核心改造。
  • 基础服务层:支撑AI能力的Prompt工程、RAG检索、MCP协议调用及代码补全等服务。

界面开发提效:从设计稿/图片到代码

为帮助开发者快速上手Compose UI,我们提供了两种生码方案:

设计稿生码

  • 效果:可将Sketch设计稿中的图层高精度(还原度90%以上)转换为Compose UI代码,并在IDE中实时预览。
  • 实现链路
    • 启动链路:通过Node服务连接Sketch应用、IDE插件和Webview。

    • 设计稿转IR:将设计稿元素转换为中间表示(IR),包括类型、参数、样式及视图层级信息。

    • IR转Compose:依据规则将IR映射为Compose组件与修饰符。

    • 优化与输出:通过人工规则与模型二次优化,对生成的代码进行组件化、数据驱动等重构,输出高质量的生产级代码。

  • 挑战:处理了设计稿不规范、IR与Compose属性映射差异(如margin)、DIV类型具体化、图片资源转换、CSS风格属性适配等一系列复杂问题。
  • 解决:利用大模型进行二次优化,将界面布局进行组件化以及数据驱动的封装,比如一个平铺列表,最终优化成 ServiceItem 组件,对应传参 ServiceData,最终代码就可以直接用于生产。

再来整体对比下,从原始设计稿,到原始 Compose UI,再到模型二次优化的界面效果。这里能感受到模型二次优化后,基本上能够还原设计稿组件,但是代码更加直接可用。

  • 稿生码的优点:
    • 转换后还原精度高,
  • 缺点
    • 不支持基于支付宝 AntUI 组件库还原,
    • 设计稿不够规范影响还原效果。

我们自然而然的会想有更加简便,且支持高阶 UI 组件库的方案,就是图生码。

图生码

  • 背景:设计稿生码不支持AntUI组件,且受设计稿规范度影响。图生码旨在实现更简便且支持高阶组件的生码方案。
  • 方案演进
    • 方案一(图到代码直出):将高阶 UI 组价库的知识按照统一格式,输入给 MLLM 学习后,直接将图片转换成 Compose 代码。
      • 问题: 让大模型读图直接输出代码。效果欠佳,细节处理差,且技术栈绑定Compose,难以扩展。
    • 方案二(图→IR→代码):采用自研图生码算法。使用后训练的多模态大模型识别图片中的基础组件和AntUI组件,输出IR,再复用设计稿生码的转换规则生成代码。(此方案更优)
      • 图生码算法能力建设的三个阶段
        1. 数据构造, 构建自动化流程,通过大模型生成随机Compose代码→渲染截图→生成精确的图文数据对,解决了训练数据匮乏问题。

        2. 模型训练, 采用LoRA(低秩适应)等参数高效微调技术,对多模态大模型进行SFT(监督微调)和强化学习,使其获得精准的UI页面解析能力,能识别AntUI高阶组件。

        3. 后处理增强, 针对模型幻觉导致的位置、颜色、布局偏差,结合传统图像算法进行校准,提升输出IR的精确度。

    • 优势与挑战:方案二效果更精准,直接支持AntUI,且IR协议可扩展至其他原生技术栈。当前挑战在于进一步提升AntUI组件识别准确度,并构造更多特殊案例数据。

逻辑开发与运维提效:智能问答与诊断

为帮助开发者快速上手KMP逻辑开发与解决线上问题,我们构建了基于RAG和MCP的智能助手。

基于RAG的智能问答

背景

  • 内部文档质量参差不齐,内容多且繁杂,较难查找阅读
  • 阅读;由于文档质量不高,导致机器人答疑质量不高

开发者常咨询这三类问题:

  1. Kotlin 跨端能力库中是否包含某项能力?
  2. 这个 API 能力调用代码该怎么写?
  3. AntUI 组件库是否支持包含某个组件?

RAG 检索问答基本流程:

  • RAG流程优化
    • 源数据处理:面对复杂的JSON源数据(如含千条API记录),利用自建Agent将其转化为格式规整、模型可读的Markdown文档。
    • 检索效果提升:以FAQ(问答对)形式替代传统的文本切片,并借助大模型从文档中提炼生成近4000条FAQ知识,提高召回准确率。
    • 体系性问题回答:将知识图谱的实体关系作为检索语料,使模型能理解模块与接口的层级关系,回答体系性问题。
    • FAQ增强:让模型为同一答案生成多种问法,提升问题命中的灵活性。

具体问题诊断与解决

  • KMP构建/闪退排查:构建“构建失败Agent”和“闪退日志Agent”。其工作流为:先运行脚本提取日志关键信息,再通过RAG从知识库召回解决方案,最后由Agent组织答案反馈给开发者。
  • KMP应用框架快速接入:该框架用于抹平三端生命周期差异。我们提供模板代码自动生成工具,开发者可一键将框架集成到项目中,将原本需3人日的接入工作自动化。

KMP 模块在三端平台构建失败,无法定位原因

针对开发者不熟悉多端尤其是鸿蒙平台的痛点,我们通过定制Agent工作流解决问题:
KMP 模块在三端平台构建失败,无法定位原因
KMP 核心产物需要同时三端构建,一旦出现构建失败问题,传统排查方式效率比较低下,花费的时间从几分钟到一小时不等。

这里我们通过 Agent 工作流的方式,帮助开发者主动触发构建,利用 KMP 日志分析脚本,提取关键日志,再结合现有构建知识库进行召回,最终由模型整理组织答案。从而加快构建失败问题的排查速度。

安卓/ iOS /鸿蒙,三端闪退如何排查

开发者可以直接将闪退日志输入给 Agent ,Agent 会触发闪退分析的工作流,先用 KMP 堆栈反解工具提取关键内容并解析,再将解析结果返回给 Agent,由 Agent 结合当前的项目代码上下文,给出原因和解决方案。

基于MCP的工具集成

如何将众多工具(堆栈分析、模板生成、文件操作等)整合到大Agent中?我们采用了本地MCP(Model Context Protocol)路由机制。

  • MCP作用:一种标准协议,使工具能适配不同大模型。通过编写MCP协议描述工具功能,Agent可根据用户提示词自动路由并调用相应工具。
  • 示例:当用户输入“分析鸿蒙闪退堆栈”并提供日志时,Agent能自动匹配并调用“闪退堆栈分析工具”的MCP,执行分析并返回根因与建议。
  • 架构扩展:除本地MCP工具集外,未来规划提供远程MCP市场和Agent市场。

未来展望

KMP编码助手将持续优化与创新,重点方向包括:

  1. 生码能力增强:支持Figma设计稿生码;优化图生码IR协议;探索智能Compose UI视觉验收。
  2. 声明式UI动态化:结合模型对数据与UI组件的理解,通过自研KMP JS运行时与动态化技术,实现数据驱动的动态界面渲染。
  3. 技术架构扩展:以KMP技术栈为核心,逐步将AI辅助能力扩展至其他原生技术栈(如纯Android、iOS开发)。
  4. 生态建设:建设开放的Agent与MCP工具市场。

总结:AIAgent重塑软件开发生命周期

最后再来看一下AI Agent面向软件开发整个的生命周期,你可以发现 agent正在以一个非常非常快的速度改变我们的工作方式. 从构思到开发到落地, agent在每一个环节都会驱动我们来进行一些创新.
比如

  • 需求分析里面我们可以让AI来给出UI/UX设计建议
  • 开发与编码阶段, 可以让agent来帮助我们进行代码审查和质量保证
  • 测试阶段也很重要, 可以让agent智能测试以及报告
  • 在部署与发布上, agent可以帮助我们进行一个自动化的配置
  • 在维护与运营阶段, agent可以帮助我们分析用户的反馈以及线上的性能监控和优化

简而言之, AIAgent正在引领一场软件开发的全新的变革, 这将会深深地改变我们之后的一个工作方式, 那在这里呢也也祝愿大家能够在AI人工智能席卷而来的浪潮里面抓住机遇勇于创新, 说不定会有意想不到的惊喜和收获.

HarmonyOS6 接入分享,原来也是三分钟的事情

HarmonyOS6 接入分享,原来也是三分钟的事情

前言

最近打算给准备开发的应用接入分享功能,考虑到模板市场上已经有现成的模版了,然后结合AI来接入,接入起来,也就是3分钟的事情。

新建工程

如果还没有工程的话,直接新建工程、创建项目

image-20251221211420491

组件市场直接接入

在devEco Studio 上的 组件市场上搜索,通用系统分享,然后直接下载到项目中即可。

image-20251221211738225

成功后,系统会自动同步和构建,这个时候会提示文件找不到  build-profile.json5

image-20251221212416142

这个时候手动删除配置即可

image-20251221212502217

AI工具直接帮你接入

然后使用自己习惯的AI编辑器打开当前工程,万少这边使用的是 Trae 海外版 + Gemini-3-pro 模型

image-20251221212707611

你点击刚才组件市场内的通用分享组件的官网链接,复制这个网址。

developer.huawei.com/consumer/cn…

image-20251221212813537


image-20251221212838563

最后,使用老奶奶也能听懂的自然语言帮你的AI编辑器帮你接入即可,如图所示。

image-20251221212951496

最后效果

image-20251221213721538

注意事项

  1. 如果需要接受比如分享到QQ后到回调
  2. 或者需要配置微博分享

都需要仔细查看组件官网的描述

developer.huawei.com/consumer/cn…

C# 正则表达式:量词与锚点——从“.*”到精确匹配

一、量词:告诉引擎“要重复多少次”

量词出现在一个“单元”后面,表示这个单元要重复多少次。

单元可以是:

  • 一个普通字符:a
  • 一个字符类:\d[A-Z]
  • 一个分组:(ab)

1. 常见量词一览

  • ?:0 或 1 次
  • *:0 次或多次
  • +:1 次或多次
  • {n}:恰好 n 次
  • {n,}:至少 n 次
  • {n,m}:n 到 m 次之间

示例:

using System.Text.RegularExpressions;

string pattern = @"^\d{3,5}$"; 
bool ok = Regex.IsMatch("1234", pattern); // True

2. 量词是作用在“前一项”上的

注意:ab+ 只会把 + 作用到 b 上:

  • 模式:ab+
    • 匹配:"ab"、"abb"、"abbbb"...
  • 模式:(ab)+
    • 匹配:"ab"、"abab"、"ababab"...

也就是说,当你想对一“串”东西使用量词,一定要用括号分组。


二、贪婪 vs 懒惰:**? 的根本区别

量词在默认情况下是“贪婪”的:

  • 在不影响匹配成功的前提下,尽可能多地吃字符。

1. 贪婪匹配:.*

string input = "<tag>content</tag><tag>more</tag>";
string pattern = @"<tag>.*</tag>";

Match m = Regex.Match(input, pattern);
Console.WriteLine(m.Value);

匹配结果:

<tag>content</tag><tag>more</tag>
  • .* 会尽可能多地吃字符,直到最后一个满足条件的 </tag>

2. 懒惰匹配:.*?

在量词后面再加一个 ?,就变成“懒惰”(即最多满足一次):

  • *?:尽可能少的 0 次或多次
  • +?:尽可能少的 1 次或多次
  • ??:尽可能少的 0 或 1 次
  • {n,m}?:在 n~m 之间,尽量少

改写上面的例子:

string pattern = @"<tag>.*?</tag>";

Match m = Regex.Match(input, pattern);
Console.WriteLine(m.Value);

匹配结果:

<tag>content</tag>

三、用量词写几个常见“格式”:

1. 简单日期:yyyy-MM-dd

^\d{4}-\d{2}-\d{2}$
  • 不考虑合法性,只看格式

2. 用户名:字母开头,后面 3~15 位字母数字下划线

^[A-Za-z]\w{3,15}$
  • [A-Za-z]:首字符必须是字母
  • \w{3,15}:后面 3~15 个字母数字下划线
  • 总长度:4~16

C#:

string pattern = @"^[A-Za-z]\w{3,15}$";
bool ok = Regex.IsMatch("User_001", pattern);

3. 整数和小数

简单版本的“非负整数或小数”:

^\d+(\.\d+)?$
  • \d+:至少一位数字
  • (\.\d+)?:可选的小数部分(. + 至少一位数字)

匹配:01233.140.5 不匹配:..53.(如果你想放宽,可以调整)。


四、锚点:决定“匹配的是不是整串”

锚点(Anchor)是一类特殊的“零宽”匹配,只匹配“位置”,不消耗字符。

1)^:开头,$:结尾

默认情况下:

  • ^ 匹配字符串的开头;
  • $ 匹配字符串的结尾。

示例:

^abc      // 匹配以 "abc" 开头的字符串
abc$      // 匹配以 "abc" 结尾的字符串
^abc$     // 字符串只能是 "abc"
Regex.IsMatch("abc123", @"^abc");   // True
Regex.IsMatch("123abc", @"abc$");   // True
Regex.IsMatch("xabcx", @"^abc$");   // False

2. 表单校验一定要写 ^$

// 不严谨
Regex.IsMatch("abc2025-12-18xyz", @"\d{4}-\d{2}-\d{2}"); 
// True,只要“包含”符合格式的子串就通过

// 严谨
Regex.IsMatch("abc2025-12-18xyz", @"^\d{4}-\d{2}-\d{2}$");
// False,整个字符串不是完整日期

3. 字符类里的 ^ 意义完全不同

^abc        // 锚点:开头
[^abc]      // 取反:匹配任何不是a、b、c的字符
  • ^[] 外:开头锚点
  • ^[] 里且在首位:表示“取反”,方括号内的字符任意组合的反面

五、单词边界:\b

\b 是“单词边界”(word boundary),匹配“从一个 \w 字符到一个非 \w 字符的边界”。

例子:

string text = "cat scat category";
string pattern = @"\bcat\b";

MatchCollection matches = Regex.Matches(text, pattern);
foreach (Match m in matches)
{
    Console.WriteLine(m.Value);
}

输出:

cat

解析:

  • "cat" 前后都是边界(左边是开头,右边是空格),满足 \b
  • "scat" 中的 cat 左边是 s,属于 \w,不会被 \bcat\b 匹配。
  • "category" 中的 cat 右边是 e,也是 \w,也不符合。

六、多行模式(Multiline)与单行模式(Singleline)

C# 中用 RegexOptions 可以控制 ^ / $. 的行为。

1. RegexOptions.Multiline:多行模式

默认情况:

string text = "first\nsecond\nthird";
string pattern = @"^second$";

Console.WriteLine(Regex.IsMatch(text, pattern)); // False

因为:

  • ^$ 在默认模式下只匹配整个字符串起始和结尾,不会感知行。

多行模式开启后:

bool ok = Regex.IsMatch(
    text,
    pattern,
    RegexOptions.Multiline
);
Console.WriteLine(ok); // True

此时:

  • ^ / $ 会匹配每一行的开头/结尾(以 \n 作为换行)。

2. RegexOptions.Singleline:单行模式 / DOTALL

默认情况下:

  • . 不匹配换行符。
string text = "line1\nline2";
string pattern = @".*";

Match m1 = Regex.Match(text, pattern);
Console.WriteLine(m1.Value);    // "line1"

开启 Singleline 后:

Match m2 = Regex.Match(text, pattern, RegexOptions.Singleline);
Console.WriteLine(m2.Value);    // "line1\nline2"

此时:

  • . 会匹配包括换行在内的任何字符。

总结一下:

  • Multiline:影响 ^ / $,让它们感知“行”
  • Singleline:影响 .,让 . 能匹配换行

它们可以一起用:

var regex = new Regex(
    pattern,
    RegexOptions.Multiline | RegexOptions.Singleline
);

结语

点个赞,关注我获取更多实用 C# 技术干货!如果觉得有用,记得收藏本文

前端跨页面通讯终极指南⑧:Cookie 用法全解析

前言

之前介绍了很多前端跨页面通讯的方案,今天介绍下Cookie,Cookie自身有“同源共享”的特性,但因为缺少数据变化的主动通知机制,只能使用“轮询”弥补这一缺陷。

本文将使用Cookie轮询,进行跨页面通讯。

1. Cookie 轮询基本原理

Cookie轮询通过“存储-定期检查-差异处理”的核心逻辑实现跨页面通讯,具体流程为:

  1. 一个页面将消息(如状态、指令等)结构化后存储到Cookie中;
  2. 其他同源页面通过定时任务定期读取目标Cookie;
  3. 对比当前Cookie值与历史基准值,若发现内容变化则读取并处理消息;
  4. 更新基准值,完成一次跨页面通讯闭环。

需特别注意:Cookie的domainpath配置是轮询生效的前提——写入方与轮询方需配置一致的作用域(如根路径/、根域名example.com),否则轮询方无法读取目标Cookie,导致通讯失效。

2. 案例代码

2.1 Cookie 轮询完整实现

代码如下所示:

// 设置 Cookie
function setCookie(name, value, days = 1) {
    const date = new Date();
    date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
    const expires = &#34;expires=&#34; + date.toUTCString();
    document.cookie = name + &#34;=&#34; + encodeURIComponent(value) + &#34;;&#34; + expires + &#34;;path=/&#34;;
    updateCookieDisplay();
}

// 获取 Cookie
function getCookie(name) {
    const nameEQ = name + &#34;=&#34;;
    const ca = document.cookie.split(';');
    for (let i = 0; i < ca.length; i++) {
        let c = ca[i];
        while (c.charAt(0) === ' ') c = c.substring(1, c.length);
        if (c.indexOf(nameEQ) === 0) return decodeURIComponent(c.substring(nameEQ.length, c.length));
    }
    return null;
}

// 删除 Cookie
function deleteCookie(name) {
    document.cookie = name + &#34;=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;&#34;;
    updateCookieDisplay();
}

// 发送消息(Cookie 方式)
function sendMessageByCookie(content) {
    // 确定通讯类型
    let communicationType = '';
    if (isChild) {
        communicationType = '子父通讯';
    } else {
        communicationType = '父子通讯';
    }
    
    const message = {
        id: Date.now(),
        content: content,
        sender: clientId,
        timestamp: Date.now(),
        method: 'cookie',
        communicationType: communicationType
    };
    
    const cookieValue = JSON.stringify(message);
    setCookie(COOKIE_NAME, cookieValue);
    addLog(`通过 Cookie 发送消息: ${content}`, '发送', communicationType);
}

image.png

3.2 总结

Cookie轮询是一种兼容性极强的跨页面通讯方案,无需依赖现代API,可在老旧浏览器中稳定运行。其核心优势是实现简单、配置灵活,适用于低频率消息同步场景(如登录状态、用户偏好设置)。使用时需重点关注Cookie作用域配置和轮询性能平衡,通过结构化消息设计和异常处理提升方案可靠性。

上线前不做 Code Review?你可能正在给团队埋雷!

一位前端同事花一周时间重构了一个核心组件,自测通过、性能优化、UI 完美。
上线 2 小时后,用户反馈“页面白屏”——原来漏掉了空状态处理。
紧急回滚、加班修复、产品信任受损……
而这一切,本可以在一次 15 分钟的 Code Review 中避免。

Code Review(代码审查)从来不是“找茬”,而是给前端团队上了一个最高效的质量保险。它不仅能提前拦截 Bug,更是知识共享、规范落地、新人成长的加速器。

一、什么是 Code Review

1.1 Code Review 的核心价值和目标

Code Review 翻译成中文,就是代码审查。在前端开发过程中,它不仅是保证代码质量的关键环节,更是团队知识共享技术统一工程规范落地的重要机制。团队成员通过规划化的代码审查流程,能够提前发现潜在问题提升代码的可以维护性

1.2 Code Review 发生在什么时候

通常发生在:

  • 开发者完成一个功能、修复一个 bug 或进行重构后
  • 将代码推送到远程仓库并发起 Pull Request(PR)  或 Merge Request(MR)
  • 在代码合并到主干分支(如 master / main之前

二、为什么要 Code Review

代码审查真的不是为了找茬,而是为了让团队能走得更远、更稳的关键一环。想一想,如果花了一周时间,优化了各种性能,写了一大堆代码,结果上线后发现了一个严重 bug,需要紧急修复。这不仅浪费了时间,还可能影响用户对产品团队和开发团队的信任。

下面列举几个 Code Review 的作用:

  • 提前发现缺陷
    在 Code Review 阶段发现的逻辑错误、业务理解偏差、性能隐患等时有发生,可以提前发现问题
  • 提高代码质量
    主要体现在代码健壮性、设计合理性、代码优雅性等方面,持续 Code Review 可以提升团队整体代码质量
  • 统一规范和风格
    集团编码规范自不必说,对于代码风格要不要统一,可能会有不同的看法,个人观点对于风格也不强求。不过代码风格的统一更有助于提升代码的可读性及让继任者快速上手
  • 团队共识
    通过多次讨论与交流,逐步达成团队共识,特别是对架构理解和设计原则的认知,在共识的基础上团队也会更有凝聚力,特别是在较多新人加入时尤为重要

三、怎么做 Code Review

3.1 代码提交者

3.1.1 使用自动化工具
  • ESLint + Prettier

下面是 VS Code 关于 Prettier 的配置示例:

{
    "editor.formatOnSave": true,
    "editor.defaultFormatter": "esbenp.prettier-vscode",
    "[vue]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[javascript]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[html]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[jsonc]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[json]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "[javascriptreact]": {
        "editor.defaultFormatter": "esbenp.prettier-vscode"
    },
    "eslint.codeActionsOnSave.mode": "problems",
    "eslint.validate": [
        "typescript",
        "javascript",
        "javascriptreact",
        "typescriptreact"
    ],
    "editor.codeActionsOnSave": {
        // 指定是否在保存文件时自动整理导入
        "source.organizeImports": "always"
    },
}

利用 Ctrl + S 自动格式化代码

  • Husky + Lint-staged

安装依赖

yarn add -D husky
yarn add -D lint-staged

husky

一个为 git 客户端增加 hook 的工具。安装后,它会自动在仓库中的 .husky/ 目录下增加相应的钩子;比如 pre-commit 钩子就会在你执行 git commit 的触发。我们可以在 pre-commit 中实现一些比如 lint 检查、单元测试、代码美化等操作。

package.json 需要添加 prepare 脚本

{
  "scripts": {
    "prepare": "husky install"
  }
}

做完以上工作,就可以使用 husky 创建一个 hook 了

npx husky add .husky/pre-commit "npx lint-staged"

lint-staged

一个仅仅过滤出 Git 代码暂存区文件(被 git add 的文件)的工具;这个很实用,因为我们如果对整个项目的代码做一个检查,可能耗时很长,如果是老项目,要对之前的代码做一个代码规范检查并修改的话,这可能就麻烦了,可能导致项目改动很大。所以这个 lint-staged,对团队项目和开源项目来说,是一个很好的工具,它是对个人要提交的代码的一个规范和约束。

此时我们已经实现了监听 Git hooks,接下来我们需要在 pre-commit 这个 hook 使用 Lint-staged 对代码进行 prettier 的自动化修复和 ESLint 的检查,如果发现不符合代码规范的文件则直接退出 commit。

并且 Lint-staged 只会对 Git 暂存区(git add 的代码)内的代码进行检查而不是全量代码,且会自动将 prettier 格式化后的代码添加到此次 commit 中。

在 package.json 中配置

{
 "husky": {
    "hooks": {
      "pre-commit": "lint-staged"
    }
  },
  "lint-staged": {
    "*.{ts,tsx,js}": [
      "eslint --fix",
      "prettier --write",
      "git add"
    ]
  },
}

合并代码的时候自动触发执行基于大模型的自动化代码审查工具。可以先根据 AI 提示选择性的整改一波,然后再找评审的人 CR。感兴趣的可以点击链接了解一下。

功能如下:

  1. 🚀 多模型支持
    • 兼容DeepSeek、ZhipuAI、OpenAI、Anthropic、通义千问和Ollama,想用哪个就用哪个。
  2. 📢消息即时主动
    • 结果审查一键直达钉钉、企业微信或飞书,代码问题无处可藏!
  3. 📅自动化日报生成
    • 基于 GitLab & GitHub & Gitea Commit 记录,自动整理每日开发进度,谁在摸鱼、谁在卷,一目了然 😼。
  4. 📊可视化仪表板
    • 集中展示所有代码审查记录,项目统计、开发者统计,数据说话,甩锅无门!
  5. 🎭 评论风格任你选
    • 专业型🤵:严谨严谨,正式专业。
    • 论型😈:毒舌吐槽,专治不服(“这代码是用脚写的吗?”)
    • 绅士型😍:温柔建议,如沐春风(“也许这里可以再优化一下呢~”)
    • 幽默型🤪:搞笑点评,快乐改码(“be if-else比我的相亲经历还曲折!”)
3.1.2 发起 Code Review 时间和代码量
  • 时间:尽量上线前一天发起评审
  • 代码量:最好在 400 行以下。根据数据分析发现,从代码行数来看,超过 400 行的 CR,缺陷发现率会急剧下降;从 CR 速度来看,超过500 行/小时后,Review 质量也会大大降低,一个高质量的 CR 最好控制在一个小时以内。

3.2 代码评审者

3.2.1 评审时重点关注内容

作为前端评审者,可以检查以下维度:

维度 检查点示例
功能正确性 逻辑是否覆盖所有场景?边界条件(空值、错误状态)是否处理?
可读性 & 可维护性 变量/函数命名是否清晰?组件职责是否单一?重复代码是否可复用?
TypeScript 安全 是否滥用 any / @ts-ignore?类型定义是否准确?
框架规范 React:key 是否合理?useEffect 依赖是否完整?Vue:响应式使用是否正确?
样式 & UI 是否避免全局 CSS 污染?是否适配移动端?是否符合设计系统?
性能 是否有不必要的重渲染?图片/资源是否优化?第三方库是否按需引入?
可访问性 (a11y) 是否使用语义化标签?表单是否有 label?键盘导航是否支持?
安全性 用户输入是否转义?是否避免 XSS(如 dangerouslySetInnerHTML)?
测试覆盖 关键逻辑是否有单元测试?用户路径是否有 E2E 测试?
3.2.2 怎么写 Review 评论

首先,不要吝啬你的赞美。代码写的好的地方要表扬!!

区分优先级:

  • 🔴 必须改:Bug、安全漏洞、破坏性变更
  • 🟡 建议改:可读性、性能优化、最佳实践
  • 🟢 可讨论:风格偏好(应由 Lint 工具统一)

用好 “What-Why-How” 法则

✅ 正确示范:

What: 这里直接操作 DOM (`document.getElementById`)  
Why: 在 React 中绕过虚拟 DOM 会导致状态不一致,且难以测试  
How: 建议改用 `useRef` 获取元素引用,或通过状态驱动 UI 更新

❌ 避免:

“这里写得不好” → 太模糊
“你应该用 Hooks 重写” → 没说明原因
“我以前都这么写的” → 主观经验,缺乏依据

总结

好的 Code Review 是团队进步的放大器,它可以:

  • 让新人成长得更快
  • 让系统变得更稳定
  • 让"技术债"更少
  • 让协作更高效

参考文章

感谢

  • 文中如有错误,欢迎在评论区批评指正。
  • 如果本文对你有帮助,就点赞、收藏支持下吧!感谢阅读。

从硬编码到 Schema 推断:前端表单开发的工程化转型

一、你的表单,是否正在失控?

想象一个场景,你正在开发一个“企业贷款申请”或“保险理赔”系统。

最初,页面只有 5 个字段,你写得优雅从容。随着业务迭代,表单像吹气球一样膨胀到了 50 多个字段: “如果用户选了‘个体工商户’,不仅要隐藏‘企业法人’字段,还得去动态请求‘经营地’的下拉列表,同时‘注册资本’的校验规则还要从‘必填’变成‘选填’……”

于是,你的 Vue 文件变成了这样:

  • <template> 里塞满了深层嵌套的 v-ifv-show
  • <script> 里到处是监听联动逻辑的 watch 和冗长的 if-else
  • 最痛苦的是: 当后端决定调整字段名,或者公司要求把这套逻辑复用到小程序时,你发现逻辑和 UI 已经像麻绳一样死死缠在一起,拆不开了。

“难道写表单,真的只能靠体力活吗?”

为了摆脱这种低效率重复,我们尝试将 中间件思想 引入 Vue 3,把复杂的业务规则从 UI 框架中剥离出来。今天,我就把这套“一次编写,到处复用”的工程化方案分享给你。


二、 核心思想:让数据自带“说明书”

传统模式下,前端像是一个**“搬运工”:拿到后端数据,手动判断哪个该显、哪个该隐。

而工程化模式下,前端更像是一个“组装厂”**:数据在进入 UI 层之前,先经过一套“中间件流水线”,数据会被自动标注上 UI 描述信息(Schema)。

1. 什么是 Schema 推断?

数据不再是冷冰冰的键值对,而是变成了一个包含“元数据”的对象。通过 TypeScript 的类型推断,我们让数据自己告诉页面:

  • 我应该用什么组件渲染(componentType
  • 我是否应该被显示(visible
  • 我依赖哪些字段(dependencies
  • 我的下拉选项去哪里拉取(request

2. UI 框架只是“皮肤”

既然逻辑都抽离到了框架无关的中间件里,那么 UI 层无论是用 Ant Design 还是 Element Plus,都只是换个“解析器”而已。


三、 实战:构建 Vue 3 自动化渲染引擎

1. 组件注册表

首先,我们要定义一个组件映射表,把抽象的字符串类型映射为具体的 Vue 组件。

TypeScript

// src-vue/components/FormRenderer/componentRegistry.ts
import NumberField from '../FieldRenderers/NumberField.vue'
import SelectField from '../FieldRenderers/SelectField.vue'
import TextField from '../FieldRenderers/TextField.vue'
import ModeToggle from '../FieldRenderers/ModeToggle.vue'

export const componentRegistry = {
  number: NumberField,
  select: SelectField,
  text: TextField,
  modeToggle: ModeToggle,
} as const

2. 组装线:自动渲染器(AutoFormRenderer)

这是我们的核心引擎。它不关心业务,只负责按照加工好的 _fieldOrder_schema 进行遍历。

<template>
  <a-row :gutter="[16,16]">
    <template v-for="key in orderedKeys" :key="key">
      <component
        v-if="shouldRender(key)"
        :is="resolveComponent(key)"
        :value="data[key]"
        :config="schema[key].fieldConfig"
        :dependencies="collectDeps(schema[key])"
        :request="schema[key].request"
        @update:value="onFieldChange(key, $event)"
      />
    </template>
  </a-row>
</template>

<script setup lang="ts">
const props = defineProps<{ data: any }>();
const schema = computed(() => props.data?._schema || {});
const orderedKeys = computed(() => props.data?._fieldOrder || Object.keys(props.data));

// 根据中间件注入的 visible 函数判断显隐
function shouldRender(key: string) {
  const s = schema.value[key];
  if (!s || s.fieldConfig?.hidden) return false;
  return s.visible ? s.visible(props.data) : true;
}

function resolveComponent(key: string) {
  const type = schema.value[key]?.componentType || 'text';
  return componentRegistry[type];
}
</script>

3. 原子化:会“思考”的字段组件

SelectField 为例,它不再是被动等待赋值,而是能感知依赖。当它依赖的字段(如“省份”)变化时,它会自动重新调用 request

<script setup lang="ts">
const props = defineProps(['value', 'dependencies', 'request']);
const options = ref([]);

async function loadOptions() {
  if (props.request) {
    options.value = await props.request(props.dependencies || {});
  }
}

// 深度监听依赖变化,实现联动效果
watch(() => props.dependencies, loadOptions, { deep: true, immediate: true });
</script>

四、 方案的“真香”时刻

1. 逻辑与 UI 的彻底解耦

所有的联动规则、校验逻辑、接口请求都定义在独立于框架的 src/core 下。如果你明天想把项目从 Vue 3 迁到 React,你只需要重写那几个基础字段组件,核心业务逻辑 一行都不用动

2. “洁癖型”提交

很多动态表单方案会将 visibleoptions 等 UI 状态混入业务数据,导致传给后端的 JSON 极其混乱。我们的方案在提交前会运行一次“清洗中间件”:

const cleanPayload = submitCompileOutputs(formData.compileOutputs);
// 自动剔除所有以 _ 开头的辅助字段和临时状态

后端拿到的永远是干净、纯粹的业务模型。

3. 开发体验的飞跃

现在,当后端新增一个字段时,你的工作流变成了:

  1. 在类型推断引擎里加一行规则。

  2. 刷新页面,字段已经按预定的位置和样式长好了。

    你不再需要去 .vue 文件里翻找几百行处的 template 插入 HTML,更不需要担心漏掉了哪个 v-if。


结语:不要为了用框架而用框架

很多时候,我们觉得 Vue 或 React 难维护,是因为我们将过重的业务决策交给了视图层

通过引入中间件和 Schema 推断,我们实际上在 UI 框架之上建立了一个“业务逻辑防火墙”。Vue 只负责监听交互和渲染结果,而变幻莫测的业务规则被关在了纯 TypeScript 编写的沙盒里。

这种“工程化”的思维,不仅是为了今天能快速复刻功能,更是为了明天业务变动时,我们能优雅地“配置升级”,而不是“推倒重来”。


你是如何处理复杂表单联动的?欢迎在评论区分享你的“避坑”指南!

TanStack Router 路径参数(Path Params)速查表

路径参数是 URL 中以 $ 开头的变量,用于捕获动态内容。

功能 语法示例 URL 匹配示例 捕获到的变量
标准参数 $postId /posts/123 { postId: '123' }
带前缀 post-{$id} /posts/post-abc { id: 'abc' }
带后缀 {$name}.pdf /files/cv.pdf { name: 'cv' }
通配符 (Splat) $ /files/a/b/c.txt { _splat: 'a/b/c.txt' }
可选参数 {-$lang} /about/en/about { lang: undefined }'en'

详细功能解析

1. 基础用法 (Standard Usage)

在文件路由中,文件名即路径。使用 $ 声明变量。

  • 获取参数

    • Loader 中:通过参数对象 params 访问。

    • 组件 中:使用 Route.useParams() 钩子。

  • 代码分割技巧:如果组件是单独定义的,可用 getRouteApi('/path').useParams() 来保持类型安全。

2. 前缀与后缀 (Prefixes and Suffixes)

这是 TanStack Router 的一大特色,允许你在动态部分前后添加固定文本。

  • 语法:用花括号 {} 包裹变量名。

  • 场景:比如文件名匹配 {$id}.json,或者带特定标识的 ID user-{$userId}

  • 通配符组合:你甚至可以写 storage-{$}/$ 来匹配极其复杂的路径结构。

3. 可选路径参数 (Optional Path Parameters) ✨ 重点

使用 {-$variable} 语法。这意味着该段路径可以存在也可以不存在

  • 匹配逻辑/posts/{-$category} 既能匹配 /posts(参数为 undefined),也能匹配 /posts/tech

  • 导航:在 Link 中,如果想去掉可选参数,将值设为 undefined 即可。

4. 国际化 (i18n) 应用场景

可选参数最强大的地方在于处理语言前缀。

  • 设计/{-$locale}/about

  • 效果

    • 用户访问 /about -> 默认语言(如中文)。

    • 用户访问 /en/about -> 英文。

  • 这样你不需要为每种语言创建文件夹,只需一个路由逻辑即可搞定。

5. 类型安全 (Type Safety)

TanStack Router 的核心优势。

  • 当你跳转(LinkMaps)到一个带参数的路由时,TypeScript 会强制要求你提供该参数。

  • 如果是可选参数,TS 会自动推断其类型为 string | undefined,提醒你做空值处理。


常见疑问解答

Q: 怎么在组件外获取参数?

使用全局的 useParams({ strict: false })。但建议尽可能在路由内部使用,以获得完整的类型提示。

Q: 参数里能包含特殊字符(如 @)吗?

默认会进行 URL 编码。如果你想让它直接显示,需要在 createRouter 的配置中设置 pathParamsAllowedCharacters: ['@']

Q: 导航时如何保留现有的参数?

在 Link 或 Maps 的 params 中使用函数式写法:

params={(prev) => ({ ...prev, newParam: 'value' })}


一句话总结:

路径参数不仅是 $id 这么简单,通过 前缀/后缀、可选标志 和 强类型校验,你可以极其优雅地处理复杂的 URL 结构(如文件系统预览或多语言站点),且完全不会写错路径。

【节点】[GammaToLinearSpaceExact节点]原理解析与实际应用

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

GammaToLinearSpaceExact节点是Unity URP Shader Graph中用于色彩空间转换的重要工具,专门处理从伽马空间到线性空间的精确转换。在现代实时渲染管线中,正确的色彩空间管理对于实现物理准确的渲染效果至关重要。

色彩空间基础概念

在深入了解GammaToLinearSpaceExact节点之前,需要理解伽马空间和线性空间的基本概念。伽马空间是指经过伽马校正的非线性色彩空间,而线性空间则是未经校正的、与物理光照计算相匹配的色彩空间。

伽马校正的历史背景

  • 伽马校正最初是为了补偿CRT显示器的非线性响应特性而引入的
  • 人类视觉系统对暗部细节的感知更为敏感,伽马编码可以更有效地利用有限的存储带宽
  • 现代数字图像和纹理通常默认存储在伽马空间中

线性空间的重要性

  • 物理光照计算基于线性关系,使用线性空间可以确保光照计算的准确性
  • 纹理过滤、混合和抗锯齿在线性空间中表现更加正确
  • 现代渲染管线普遍采用线性空间工作流以获得更真实的渲染结果

GammaToLinearSpaceExact节点技术细节

GammaToLinearSpaceExact节点实现了从伽马空间到线性空间的精确数学转换,其核心算法基于标准的伽马解码函数。

转换算法原理

该节点使用的转换公式基于sRGB标准的逆伽马校正:

如果输入值 <= 0.04045 线性值 = 输入值 / 12.92 否则 线性值 = ((输入值 + 0.055) / 1.055)^2.4

这个精确的转换公式确保了从伽马空间到线性空间的数学准确性,与简单的幂函数近似相比,在低亮度区域提供了更高的精度。

### 数值范围处理

GammaToLinearSpaceExact节点设计用于处理标准化的数值范围:

- 输入值通常应在[0,1]范围内,但节点也能处理超出此范围的值
- 输出值保持与输入相同的数值范围,但分布特性发生了变化
- 对于HDR(高动态范围)内容,节点同样适用,但需要注意色调映射的后续处理

## 端口详细说明

![](https://docs.unity.cn/cn/Packages-cn/com.unity.shadergraph@14.0/manual/images/GammaToLinearSpaceExactNodeThumb.png)

### 输入端口(In)

输入端口接受Float类型的数值,代表需要转换的伽马空间数值。这个输入可以是单个标量值,也可以是向量形式的色彩值(当连接到色彩输出时)。

输入数值的特性:

- 通常来自纹理采样、常量参数或其他Shader Graph节点的输出
- 如果输入已经是线性空间的数值,使用此节点会导致不正确的渲染结果
- 支持动态输入,可以在运行时根据不同的条件改变输入源

### 输出端口(Out)

输出端口提供转换后的线性空间数值,类型同样为Float。这个输出可以直接用于后续的光照计算、材质属性定义或其他需要线性空间数据的操作。

输出数值的特性:

- 保持了输入数值的相对亮度关系,但数值分布发生了变化
- 暗部区域的数值被扩展,亮部区域的数值被压缩
- 输出可以直接用于物理光照计算,如漫反射、高光反射等

## 实际应用场景

GammaToLinearSpaceExact节点在URP Shader Graph中有多种重要应用场景,正确使用该节点可以显著提升渲染质量。

### 纹理色彩校正

当使用存储在伽马空间中的纹理时,必须将其转换到线性空间才能进行正确的光照计算。

应用示例步骤:

- 从纹理采样节点获取颜色值
- 将采样结果连接到GammaToLinearSpaceExact节点的输入
- 使用转换后的线性颜色值进行光照计算
- 在最终输出前,可能需要使用LinearToGammaSpaceExact节点转换回伽马空间用于显示

这种工作流程确保了纹理颜色在光照计算中的物理准确性,特别是在处理漫反射贴图、自发光贴图等影响场景光照的纹理时尤为重要。

### 物理光照计算

所有基于物理的渲染计算都应在线性空间中执行,GammaToLinearSpaceExact节点在此过程中扮演关键角色。

光照计算应用:

- 将输入的灯光颜色从伽马空间转换到线性空间
- 处理环境光照和反射探针数据
- 计算漫反射和高光反射时确保颜色值的线性特性

通过在线性空间中执行光照计算,可以避免伽马空间中的非线性叠加导致的光照过亮或过暗问题,实现更加自然的明暗过渡和色彩混合。

### 后期处理效果

在实现屏幕后处理效果时,正确管理色彩空间对于保持效果的一致性至关重要。

后期处理中的应用:

- 色彩分级和色调映射
- 泛光和绽放效果
- 色彩校正和滤镜效果

在这些应用中,需要先将输入图像从伽马空间转换到线性空间进行处理,处理完成后再转换回伽马空间用于显示,确保处理过程中的色彩操作符合线性关系。

## 与其他色彩空间节点的对比

Unity Shader Graph提供了多个与色彩空间相关的节点,了解它们之间的区别对于正确选择和使用至关重要。

### GammaToLinearSpaceExact与GammaToLinearSpace

GammaToLinearSpace节点提供了类似的伽马到线性转换功能,但使用的是近似算法:

线性值 ≈ 输入值^2.2

对比分析:

- GammaToLinearSpaceExact使用精确的sRGB标准转换,在低亮度区域更加准确
- GammaToLinearSpace使用简化近似,计算效率更高但精度稍低
- 在需要最高视觉质量的场合推荐使用Exact版本,在性能敏感的场景可以考虑使用近似版本

### 与LinearToGammaSpaceExact的关系

LinearToGammaSpaceExact节点执行相反的转换过程,将线性空间值转换回伽马空间。

转换关系对应:

- GammaToLinearSpaceExact和LinearToGammaSpaceExact是互逆操作
- 在渲染管线的开始阶段使用GammaToLinearSpaceExact,在最终输出前使用LinearToGammaSpaceExact
- 这种配对使用确保了整个渲染流程的色彩空间一致性

## 性能考量与最佳实践

虽然GammaToLinearSpaceExact节点的计算开销相对较小,但在大规模使用时仍需考虑性能影响。

### 性能优化建议

合理使用GammaToLinearSpaceExact节点可以平衡视觉质量和渲染性能:

- 对于已经在线性空间中的纹理(如HDRi环境贴图),不需要使用此节点
- 在不需要最高精度的场合,可以考虑使用GammaToLinearSpace近似节点
- 避免在片段着色器中重复执行相同的转换,尽可能在顶点着色器或预处理阶段完成
- 利用Unity的纹理导入设置,直接将纹理标记为线性空间,避免运行时转换

### 常见错误与调试

使用GammaToLinearSpaceExact节点时常见的错误和调试方法:

色彩过暗或过亮问题:

- 检查是否重复应用了伽马校正
- 确认输入纹理的正确的色彩空间设置
- 验证整个渲染管线中色彩空间转换的一致性

性能问题诊断:

- 使用Unity的Frame Debugger分析着色器执行开销
- 检查是否有不必要的重复转换操作
- 评估使用纹理导入时预转换的可行性

## 完整示例:实现物理准确的漫反射着色

下面通过一个完整的示例展示GammaToLinearSpaceExact节点在实际着色器中的应用。

### 场景设置

创建一个简单的场景,包含:

- 一个定向光源
- 几个具有不同颜色的物体
- 使用标准URP渲染管线

### 着色器图构建

构建一个使用GammaToLinearSpaceExact节点的基本漫反射着色器:

节点连接流程:

- 使用Texture2D节点采样漫反射贴图
- 将采样结果连接到GammaToLinearSpaceExact节点的输入
- 将转换后的线性颜色连接到URP Lit节点的Base Color输入
- 配置适当的光照和材质参数

### 对比测试

创建两个版本的着色器进行对比:

- 版本A:正确使用GammaToLinearSpaceExact节点
- 版本B:直接使用伽马空间的纹理颜色

观察两个版本在相同光照条件下的表现差异:

- 版本A提供更加真实的光照响应和颜色饱和度
- 版本B可能出现不正确的亮度积累和色彩偏移
- 在高光区域和阴影过渡区域,版本A的表现更加自然

## 高级应用技巧

除了基本用法,GammaToLinearSpaceExact节点还可以与其他Shader Graph功能结合,实现更复杂的效果。

### 与自定义光照模型结合

在实现自定义光照模型时,正确管理色彩空间至关重要:

实现步骤:

- 将所有输入的颜色数据转换到线性空间
- 在线性空间中执行光照计算
- 将最终结果转换回伽马空间输出
- 确保所有中间计算保持线性关系

这种方法确保了自定义光照模型与URP内置光照的一致性,避免了因色彩空间不匹配导致的视觉异常。

### HDR内容处理

处理高动态范围内容时,GammaToLinearSpaceExact节点的使用需要特别注意:

HDR工作流考虑:

- HDR纹理通常已经在线性空间中,不需要额外转换
- 当混合LDR和HDR内容时,需要确保统一的色彩空间
- 在色调映射阶段前,所有计算应在线性空间中进行

通过正确应用GammaToLinearSpaceExact节点,可以确保HDR和LDR内容的无缝融合,实现更高品质的视觉表现。

## 平台兼容性说明

GammaToLinearSpaceExact节点在不同平台和渲染API上的行为基本一致,但仍有少量注意事项。

### 移动平台优化

在移动设备上使用GammaToLinearSpaceExact节点时:

- 大部分现代移动GPU能够高效处理sRGB转换
- 在性能较低的设备上,可以考虑使用近似版本
- 利用移动平台的sRGB纹理格式,可以减少显式转换的需要

### 不同图形API的表现

在各种图形API中,GammaToLinearSpaceExact节点的行为:

- 在支持sRGB帧缓冲的API上(如OpenGL ES 3.0+,Metal,Vulkan),Unity会自动处理帧缓冲的伽马校正
- 在旧版API上,可能需要手动管理最终的色彩空间转换
- 节点本身的数学计算在所有API上保持一致

了解这些平台特性有助于编写跨平台兼容的着色器,确保在不同设备上的一致视觉表现。

## 总结

GammaToLinearSpaceExact节点是Unity URP Shader Graph中实现物理准确渲染的关键组件。通过正确理解和使用该节点,开发者可以:

- 确保纹理和颜色数据在光照计算中的物理准确性
- 实现更加真实和一致的视觉表现
- 避免常见的色彩空间相关渲染问题
- 构建高质量、跨平台兼容的着色器效果

---
> [【Unity Shader Graph 使用与特效实现】](https://blog.csdn.net/chenghai37/category_13074589.html?fromshare=blogcolumn&sharetype=blogcolumn&sharerId=13074589&sharerefer=PC&sharesource=chenghai37&sharefrom=from_link)**专栏-直达**
(欢迎*点赞留言*探讨,更多人加入进来能更加完善这个探索的过程,🙏)

Flutter 勇闯2D像素游戏之路(四):与哥布林战斗的滑步魔法师

Flutter 勇闯2D像素游戏之路(一):一个 Hero 的诞生
Flutter 勇闯2D像素游戏之路(二):绘制加载游戏地图
Flutter 勇闯2D像素游戏之路(三):人物与地图元素的交互
Flutter 勇闯2D像素游戏之路(四):与哥布林战斗的滑步魔法师

前言

在上篇文章中,我们完成了和 地图元素 的交互,开箱、开门、被刺扎 无所不精。
那么本章给大家介绍,一款游戏的精髓 对战 相关元素的实现。
这一元素类比 谈恋爱,可以说是,给大多数玩家的第一印象 了。

对战元素的重要性

维度 对战元素的作用 对游戏的影响
玩家存在感 操作能立刻产生命中、受伤、击杀得到正反馈 强化游戏世界 的真实感
游戏节奏 形成紧张(战斗)与放松(探索/叙事)的结合 避免节奏单一,降低疲劳感
决策决断 引入风险与回报(打/绕、进/退、用/留) 从执行操作升级为策略选择
重复可玩性 敌人组合、走位、战况具有不确定性 提高重玩价值,延长游戏寿命
情绪驱动 胜利、失败、逆转带来情绪波动 增强成就感与沉浸感

总结
对战元素并不只是 战斗机制,而是连接玩家行为、系统设计与情绪体验的 核心枢纽,决定了一款游戏是否真正具备持续吸引力。

github源码游戏在线体验地址(体验版为网页,可能手感较差,推荐源码安装至手机体验) 皆在文章最后。

Myhero

一. 本章目标

二. 实现 HUD 面板

HUD(Heads-Up Display,抬头显示)是始终 固定在屏幕上 的游戏界面层,用于向玩家展示信息并接收操作。
例如 血条、技能按钮、摇杆和暂停按钮,它不参与世界碰撞、不随地图或相机移动,只负责 显示与输入

1. 素材

大家可以去下面 两个网站 中,找找自己心仪的,或者直接使用我上面的图片(在 仓库 中)。

爱给网: www.aigei.com/
itch : itch.io/

2. 人物血条

观察上述 心型血条 的精灵图,我们可以得到思路:

  • 从满血到空血,一共是四个阶段,我们就姑且让 每颗 ❤️ 承载 4点血

    // 每个心跳组件包含的生命值
    final int hpPerHeart = 4;
    
  • 因此,将每颗 ❤️,单独作为一个 HeartComponent,只负责单颗 ❤️ 扣血或加血的图片变化

    class HeartComponent extends SpriteComponent {
      final List<Sprite> sprites;
    
      HeartComponent(this.sprites) {
        sprite = sprites.last; // 默认满血
      }
    
      void setHpStage(int stage) {
        sprite = sprites[stage.clamp(0, sprites.length - 1)];
      }
    }
    
  • 最后通过 HeroHpHud 统一管理:

    • 添加管理 HeartComponent: 计算人物总心数 (❤️总颗数 = 总血量 / 每颗 ❤️血量),动态生成 component。
       // 心跳组件
       final List<HeartComponent> hearts = [];
      
       // 每个心跳组件的精灵图
       late final List<Sprite> heartSprites;
      
      ...
      
      for (int i = 0; i < hero.maxHp ~/ hpPerHeart; i++) {
        final double heartSize = 24;
        final double heartSpacing = heartSize + 1;
        final heart = HeartComponent(heartSprites)
          ..size = Vector2(heartSize, heartSize)
          ..position = Vector2(i * heartSpacing, 0);
        hearts.add(heart);
        add(heart);
      }
      
    • 动态更新血条:每帧更新时,获取人物血量,计算得出每颗 ❤️该展示的阶段。
      void update(double dt) {
          super.update(dt);
          final totalHearts = hearts.length;
          final clampedHp = hero.hp.clamp(0, hero.maxHp);
          for (int i = 0; i < totalHearts; i++) {
            final start = i * hpPerHeart;
            final filled = (clampedHp - start).clamp(0, hpPerHeart);
            hearts[i].setHpStage(filled);
          }
        }
      

3. 怪物血条

相对于 人物血条 的精心展示,怪物血条 可就太简单了,接下来我们就简单阐述一下步骤:

  • 绘制血条背景:绘制一个 半透明黑色的canvas ,帮助用户直观的感受怪物血量的减少。
     // 背景色
     final bgPaint = Paint()
        ..color = Colors.black.withOpacity(0.6);
    
      // 背景
      canvas.drawRect(
        Rect.fromLTWH(0, 0, size.x, size.y),
        bgPaint,
      );
    
  • 绘制真实血量条:在黑色背景相同位置,绘制一个真实血量条,且在不同 血量比例 动态变化颜色。
        // 血条色
        final hpPaint = Paint()
          ..color = _hpColor();
    
        // 当前血量
        final ratio = currentHp / maxHp;
        canvas.drawRect(
          Rect.fromLTWH(0, 0, size.x * ratio, size.y),
          hpPaint,
        );
    
        ...
    
      // 不同血量比例动态变化颜色
      Color _hpColor() {
        final ratio = currentHp / maxHp;
        if (ratio > 0.6) return Colors.green;
        if (ratio > 0.3) return Colors.orange;
        return Colors.red;
      }
    
  • 每帧更新血量变化
      void updateHp(int hp) {
        currentHp = hp.clamp(0, maxHp);
      }
    

4. 攻击技能按钮

像手机游戏中 攻击按钮的布局,大多数都是和 王者荣耀 大差不差的,因此我们也来实现一下:

(1)实现单个技能按钮 AttackButton
  • 构造参数
    • HeroComponent hero:传入按钮的使用者
    • String icon:传入按钮的图标
    • VoidCallback onPressed:传入按钮的执行函数
      AttackButton({
        required this.hero,
        required String icon,
        required VoidCallback onPressed,
      }) : iconName = icon,
           super(
             onPressed: onPressed,
             size: Vector2.all(72),
             anchor: Anchor.center,
           );
    
  • 加载icon ,绘制外边框:
    Future<void> onLoad() async {
        await super.onLoad();
        button = await Sprite.load(iconName);
    
        // 添加外部圆圈
        add(
          CircleComponent(
            radius: 36,
            position: size / 2,
            anchor: Anchor.center,
            paint: Paint()
              ..color = Colors.white38
              ..style = PaintingStyle.stroke
              ..strokeWidth = 4,
          ),
        );
      }
    
  • 重写 render , 裁剪 ⭕️ 多余部分:
    @override
    void render(Canvas canvas) {
      canvas.save();
      final path = Path()..addOval(size.toRect());
      canvas.clipPath(path);
      super.render(canvas);
      canvas.restore();
    }
    
(2)创建按钮组 AttackHud
  • 创建一个 buttonGroup 容器
        buttonGroup = PositionComponent()
          ..anchor = Anchor.center
          ..position = Vector2.zero();
    
  • 获取技能数量
        final attacks = hero.cfg.attack;
        final count = attacks.length;
    
  • 定义了一个 左下 → 正左 的扇形
    final radius = buttonSize + 32.0;
    final startDeg = 270.0;
    final endDeg = 180.0;
    
  • 动态创建按钮:
    • 第一个普通攻击放中间
    • 其他按钮靠扇形均匀分布
    • 创建 AttackButton 并挂载
    for (int i = 0; i < count; i++) {
            Vector2 position;
    
            // 普通攻击放中间
            if (i == 0) {
              position = Vector2.zero();
            } else {
              final skillIndex = i - 1;
              final skillCount = count - 1;
              
              // 均匀分布
              final t = skillCount <= 1 ? 0.5 : skillIndex / (skillCount - 1);
              final deg = startDeg + (endDeg - startDeg) * t;
              
              // 极坐标 → 屏幕坐标
              final rad = deg * math.pi / 180.0;
              position = Vector2(math.cos(rad), math.sin(rad)) * radius;
            }
    
            buttonGroup.add(
              AttackButton(
                hero: hero,
                icon: attacks[i].icon!,
                onPressed: () => _attack(i),
              )..position = position,
            );
          }
    
  • 调用人物攻击
    void _attack(int index) {
        hero.attack(index, MonsterComponent);
      }
    

三. 人物组件的抽象继承

1. 创建角色配置文件

将角色参数硬编码 在组件中,会导致组件与具体角色 强耦合,一旦涉及多角色体系或怪物规模化生成,代码将迅速💥🥚。
因此我们引入角色配置层,通过配置文件描述角色的 动画、属性与碰撞信息,由 角色基类 在运行时统一加载。
这样就使角色系统从 代码驱动 ➡ 数据驱动,提升了扩展性与维护性。

/// 角色配置
/// id 角色id
/// spritePath 角色sprite路径
/// cellSize 角色sprite单元格大小
/// componentSize 角色组件大小
/// maxHp 最大生命值
/// attackValue 攻击值
/// speed 移动速度
/// detectRadius 检测半径
/// attackRange 攻击范围
/// hitbox 人物体型碰撞框
/// animations 动画
/// attack 攻击列表
class CharacterConfig {
  final String id;
  final String spritePath;
  final Vector2 cellSize;
  final Vector2 componentSize;
  final int maxHp;
  final int attackValue;
  final double speed;
  final double detectRadius;
  final double attackRange;
  final HitboxSpec hitbox;
  final Map<Object, AnimationSpec> animations;
  final List<AttackSpec> attack;

  const CharacterConfig({
    required this.id,
    required this.spritePath,
    required this.cellSize,
    required this.componentSize,
    required this.maxHp,
    required this.attackValue,
    required this.speed,
    this.detectRadius = 500,
    this.attackRange = 60,
    required this.hitbox,
    required this.animations,
    required this.attack,
  });

  static CharacterConfig? byId(String id) => _characterConfigs[id];
}

有了这份驱动数据后,对驴画马 将原来的 HeroComponent 中的角色通用数据和方法,集中到 CharacterComponent,在单独继承实现HeroComponent 和其他扩展类,也是简简单单。

因此,人物拆分内容就不多赘述,仅作介绍,具体实现查看 仓库源码

2. 抽象角色组件 CharacterComponent

模块分类 功能点 已实现内容 说明
基础定义 角色基础组件 继承 SpriteAnimationComponent 具备精灵动画、位置、尺寸、朝向能力
游戏引用 HasGameReference<MyGame> 可访问 world、blockers、camera 等
配置系统 角色配置加载 CharacterConfig.byId(characterId) 角色属性、攻击配置数据驱动
贴图资源 spritePath / cellSize 统一从配置加载动画资源
基础属性 生命值系统 maxHp / hp / loseHp() 提供完整生命管理与死亡判定
攻击数值 attackValue 基础攻击力字段
移动速度 speed 用于位移 / 冲刺
状态系统 状态枚举 CharacterState idle / run / attack / hurt / dead
状态锁 isActionLocked 攻击 / 受伤 / 死亡期间禁止操作
状态切换 setState() 同步动画与状态
动画系统 动画加载 loadAnimations() 从 SpriteSheet 构建状态动画
攻击动画 playAttackAnimation() 播放攻击动画并自动回 idle
朝向控制 水平朝向 facingRight 统一攻击 / 移动方向
翻转逻辑 faceLeft / faceRight() 精灵水平翻转
攻击系统 攻击入口 attack(index, targetType) 角色统一攻击接口
Hitbox 解耦 AttackHitboxFactory.create() 攻击判定完全工厂化
攻击动画驱动 攻击前播放动画 动画与判定分离
碰撞体系 主体碰撞体 RectangleHitbox hitbox 用于世界实体碰撞
矩形碰撞检测 collidesWith(Rect) 提供矩形级碰撞判断
碰撞纠正 resolveOverlaps(dt) 解决人物卡死
移动系统 碰撞移动 moveWithCollision() 支持滑动的阻挡碰撞移动
回退机制 X/Y 分轴处理 防止角色卡死
环境交互 地形阻挡 game.blockers 墙体 / 障碍物阻挡
门交互 DoorComponent.attemptOpen() 带条件的交互碰撞
角色交互 角色间阻挡 与其他 CharacterComponent 碰撞 防止角色重叠
召唤物AI逻辑 死亡处理 updateSummonAI(dt) 寻找敌人、攻击、跟随主人、待机
生命周期 死亡处理 onDead()(抽象) 子类实现具体死亡行为
扩展能力 抽象基类 abstract class Hero / Monster / NPC 统一父类

3. 实现 HeroComponent

功能模块 已实现作用 说明
角色身份 明确为玩家角色 Hero 是可输入控制的 Character
钥匙系统 管理玩家持有的钥匙集合 keys 用于门、机关等条件交互
道具反馈 获取钥匙时 UI 提示 UiNotify.showToast 属于玩家反馈
动画初始化 加载并绑定角色动画 使用配置表中的 animations
初始状态设置 初始为 idle 状态 Hero 出生即待机
出生位置设置 设置初始坐标 通常只由 Hero 决定
碰撞体创建 创建并挂载角色 Hitbox Hero 的物理形态
相机绑定 相机跟随玩家 game.camera.follow(this)
输入处理 读取摇杆输入 Hero 独有,怪物不会有
状态切换 idle / run 状态管理 基于玩家输入
受击反馈 播放受击音效与动画 玩家专属体验反馈
受击状态恢复 受击后回到 idle 保证操作连贯性
死亡表现 播放死亡动画 与怪物死亡逻辑不同
重开流程 显示 Restart UI 只属于玩家死亡逻辑
UI 交互 与 HUD / Overlay 联动 Hero 是 UI 的核心数据源

4. 实现 MonsterComponent

功能模块 已实现作用 说明
角色身份 明确为怪物角色 由 AI 控制的 Character
出生点管理 固定出生坐标 birthPosition 决定怪物初始位置
怪物类型标识 monsterId 用于读取配置、区分怪物
动画初始化 加载并绑定怪物动画 来自 cfg.animations
初始状态设置 初始为 idle 出生即待机
碰撞体创建 创建并挂载 Hitbox 怪物物理边界
血条组件 头顶血条显示 MonsterHpBarComponent
血量同步 实时更新血条 每帧 hpBar.updateHp
简单AI 探测距离、追逐、攻击玩家和自主游荡 detectRadius、attackRange
状态切换 idle / run / hurt 由 AI 决定
受击反馈 播放受击动画 无 UI 提示
受击恢复 受击后回到 idle 保证 AI 连贯
死亡表现 播放死亡动画 不显示 UI
销毁逻辑 死亡后移除实体 removeFromParent()

四. 碰撞类攻击的实现

完成了 人物 那个基础要点,接下来免不了的就是 攻击逻辑 了,这是大多数游戏的核心。
而游戏的 人物攻击 一样都离不开 碰撞,甚至后者更甚之。

1.思路

无论是 近战、远程 和 冲刺,造成 伤害 第一要点,就是攻击产生的 矩形 碰撞到目标敌人了。
其次,在手机肉鸽游戏中,近战和远程 的攻击总会自动索敌,这也是一个 通用点
因此,得出上述逻辑之后,我们必然将共性点抽象为 基类,其他任意 碰撞产生伤害的攻击 继承实现 即可。

2. 攻击判定基类 AbstractAttackRect

(1) 基础属性管理
  • damage:伤害
  • owner:归属者
  • targetType:目标类型
  • duration:持续时长
  • removeOnHit:是否穿透
  • maxLockDistance:最大距离
(2) 命中检测机制
  • 提供 getAttackRect 接口支持自定义几何区域判定(如扇形、多边形)。

      /// 返回该组件用于判定的几何区域
      ui.Rect getAttackRect();
    
  • 内置目标去重机制 _hitTargets,防止单次攻击多段伤害异常。

      final Set<PositionComponent> _hitTargets = {};
    
  • 集成 CollisionCallbacks 支持物理引擎碰撞。

    @override
      void onCollisionStart(
        Set<Vector2> intersectionPoints,
        PositionComponent other,
      ) {
        super.onCollisionStart(intersectionPoints, other);
        _applyHit(other);
      }
    
      @override
      void onCollision(Set<Vector2> intersectionPoints, PositionComponent other) {
        super.onCollision(intersectionPoints, other);
        _applyHit(other);
      }
    

    ⚠️ 注意

    如果这里只依靠 FlameCollisionCallbacks 判断命中,是有问题的:

    • 已经站在攻击矩形里的敌人 ❌ 不会触发
    • 攻击生成瞬间就重叠 ❌ 不一定触发

    这就是 砍刀贴脸 = 没伤害 的经典 bug 来源

    究其原因,Flame 的碰撞模型核心是 发生碰撞 → 触发回调,只能告诉你 两个碰撞体是否接触
    但它不能可靠地回答: 当前攻击区域内 有哪些 目标

    因此,我们需要在 update 中手动判断,CollisionCallbacks只能作为辅助判断。

          @override
          void update(double dt) {
            super.update(dt);
            final ui.Rect attackRect = getAttackRect();
             if (targetType == HeroComponent) {
               for (final h in game.world.children.query<HeroComponent>()) {
                 if (h == owner) continue;
                 final ui.Rect targetRect = h.hitbox.toAbsoluteRect();
                 if (_shouldDamage(attackRect, targetRect)) {               _applyHit(h);
               }
             }
            } else if (targetType == MonsterComponent) {
              for (final m in game.world.children.query<MonsterComponent>()) {
                if (m == owner) continue;
                final ui.Rect targetRect = m.hitbox.toAbsoluteRect();
                if (_shouldDamage(attackRect, targetRect)) {               _applyHit(m);
                }
              }
            }
          }
    
(3) 智能索敌系统
  • autoLockNearestTarget:自动筛选最近的有效目标(排除自身、过滤距离)。
      /// 子类实现:当找到最近目标时的处理
      void onLockTargetFound(PositionComponent target);
    
      /// 子类实现:当未找到目标时的处理(如跟随摇杆方向)
      void onNoTargetFound();
    
      /// 自动锁定最近目标
      void autoLockNearestTarget() {
        final PositionComponent? target = _findNearestTarget();
        if (target != null) {
          onLockTargetFound(target);
        } else {
          onNoTargetFound();
        }
      }
    
  • angleToTarget:计算精准的攻击朝向。
      /// 计算到目标的朝向角度(弧度)
      double angleToTarget(PositionComponent target, Vector2 from) {
        final Vector2 origin = from;
        final Vector2 targetPos = target.position.clone();
        return math.atan2(targetPos.y - origin.y, targetPos.x - origin.x);
      }
    
(4) 生命周期控制
  • 支持命中即销毁 (removeOnHit) 或穿透模式。(子类通过传递参数控制)
      if (removeOnHit) {
        removeFromParent();
      }
    
  • 基于时间的自动销毁机制。
(5) 扩展说明
  • 所有攻击判定体(近战、子弹、AOE等)均应继承此类。
  • 子类需实现 getAttackRect 以定义具体的攻击区域形状。

3. 普通近战攻击组件 MeleeHitbox

(1) 矩形判定区域
  • 使用 RectangleHitbox 作为物理碰撞检测区域。
      @override
      ui.Rect getAttackRect() => hitbox.toAbsoluteRect();
    
  • 默认配置为被动碰撞类型 (CollisionType.passive)。
       hitbox = RectangleHitbox()..collisionType = CollisionType.passive;
    

    👉 表示:这个碰撞体只接收碰撞,不主动推动或阻挡别人

(2) 自动索敌转向
@override
  void onLockTargetFound(PositionComponent target) {
    final ui.Rect rect = getAttackRect();
    final Vector2 center = Vector2(
      rect.left + rect.width / 2,
      rect.top + rect.height / 2,
    );
    angle = angleToTarget(target, center);
  }
  • 重写 onLockTargetFound 实现攻击方向自动对准最近目标。
  • 通过调整组件旋转角度 (angle) 来指向目标中心。
(3) 生命周期管理
 double _timer = 0;

 @override
  void update(double dt) {
    ...

    _timer += dt;
    if (_timer >= duration) {
      removeFromParent();
    }
  }
  • 使用内部计时器 _timer 精确控制攻击持续时间。
  • 超时自动销毁,模拟瞬间挥砍效果。
(4) 位置修正
 // 将传入的左上角坐标转换为中心坐标以便旋转
 position: position + size / 2,
  • 构造时自动将左上角坐标转换为中心坐标,确保旋转围绕中心点进行。
(5) 适用场景
  • 刀剑挥砍、拳击等 短距离瞬间攻击
  • 需要自动吸附或转向目标的近身攻击。

4. 远程投射物攻击组件 BulletHitbox

(1) 直线弹道运动
  • 基于 directionconfig.speed 进行每帧位移。
  • 记录飞行距离 _distanceTraveled,超过射程 config.maxRange 自动销毁。
      @override
      void update(double dt) {
        super.update(dt);
    
        ...
    
        final moveStep = direction * config.speed * dt;
        position += moveStep;
        _distanceTraveled += moveStep.length;
    
        if (_distanceTraveled >= config.maxRange) {
          removeFromParent();
        }
      }
    
(2) 智能索敌与方向锁定
  • onLockTargetFound:发射时若检测到敌人,自动锁定方向朝向敌人。

      @override
      void onLockTargetFound(PositionComponent target) {
        // 设置从人物到最近敌人的直线方向
        final Vector2 origin = position.clone();
        final Vector2 targetPos = target.position.clone();
        direction = (targetPos - origin).normalized();
        _locked = true;
      }
    
  • onNoTargetFound:若无敌人,优先使用摇杆方向,否则保持初始方向。

  • _locked 机制:确保子弹一旦发射,方向即被锁定,不会随玩家后续操作改变轨迹。

    bool _locked = false;
    
    @override
      void onNoTargetFound() {
        // 子弹攻击:若无目标,且尚未锁定方向,则尝试使用摇杆方向
        // 如果摇杆也无输入,保持初始 direction
        if (!_locked && !game.joystick.delta.isZero()) {
          direction = game.joystick.delta.normalized();
        }
        // 无论是否使用了摇杆方向,只要进入这里(说明没找到敌人),就锁定方向。
        // 防止后续飞行中因为摇杆变动而改变方向。
        _locked = true;
      }
    
(3) 视觉表现

  • 支持静态贴图或帧动画 (SpriteAnimationComponent)。

      @override
      Future<void> onLoad() async {
        super.onLoad();
        if (config.spritePath != null) {
          final image = await game.images.load(config.spritePath!);
    
          if (config.animation != null) {
            final sheet = SpriteSheet(
              image: image,
              srcSize: config.textureSize ?? config.size,
            );
            final anim = sheet.createAnimation(
              row: config.animation!.row,
              stepTime: config.animation!.stepTime,
              from: config.animation!.from,
              to: config.animation!.to,
              loop: config.animation!.loop,
            );
            add(SpriteAnimationComponent(animation: anim, size: size));
          } else {
            final sprite = Sprite(image);
            add(SpriteComponent(sprite: sprite, size: size));
          }
        }
      }
    
(4) 碰撞特性
  • 使用 RectangleHitbox 并设为 CollisionType.active 主动检测碰撞。
    RectangleHitbox()..collisionType = CollisionType.active;
    
  • 支持穿透属性 (config.penetrate),决定命中后是否立即销毁。
    removeOnHit: !config.penetrate,
    

(5) 适用场景
  • 弓箭、魔法球、枪械子弹等远程攻击。
  • 需要直线飞行且射程受限的投射物。

5. 冲刺攻击组件 DashHitbox

滑步 其实就是游戏中常见的 冲撞技能
因此,我们依旧继承我们的攻击判定基类 AbstractAttackRect,并在此基础上实现位移就行了。

(1) 位移与物理运动
  • 直接驱动归属者 owner 进行高速位移。
  • 集成物理碰撞检测 moveWithCollision,防止穿墙。
  • 持续同步位置 position.setFrom(owner.position),确保攻击判定跟随角色。
if (_locked && !direction.isZero()) {
      final delta = direction * speed * dt;
      if (owner is CharacterComponent) {
         final char = owner as CharacterComponent;
         char.moveWithCollision(delta);

         if (delta.x > 0) char.faceRight();
         if (delta.x < 0) char.faceLeft();
      } else {
         owner.position += delta;
      }
    }

position.setFrom(owner.position);
(2) 摇杆操作与方向锁定
  • onNoTargetFound:优先使用摇杆方向,否则沿当前朝向冲刺。
  • _locked 机制:确保冲刺过程中方向恒定,不受中途操作影响。
@override
  void onNoTargetFound() {
    if (_locked) return;
    
    if (!game.joystick.delta.isZero()) {
      direction = game.joystick.delta.normalized();
    } else {
       if (owner is CharacterComponent) {
         direction = Vector2((owner as CharacterComponent).facingRight ? 1 : -1, 0);
       } else {
         direction = Vector2(1, 0); 
       }
    }
    _locked = true;
  }
(3) 持续伤害判定
  • removeOnHit: false:冲刺不会因命中敌人而停止 (穿透效果)。
  • 在持续时间 duration 内,对路径上接触的所有有效目标造成伤害。
_elapsedTime += dt;
if (_elapsedTime >= duration) {
  removeFromParent();
  return;
}
(4) 生命周期管理
  • 基于时间 _elapsedTime 控制冲刺时长,结束后自动销毁组件。
(5) 适用场景
  • 战士冲锋、刺客突进等位移技能。
  • 需要同时兼顾位移和伤害的技能机制。

五. 游戏音效

截屏2025-12-20 16.58.12.png

在游戏体验中,音效并不是装饰品,而是反馈系统的一部分
无论是攻击命中、角色受伤,还是场景交互,如果音效分散写在各个组件中,往往会造成资源重复加载、逻辑混乱、难以统一管理音量与状态的问题。

因此,我们对游戏音频进行统一封装,引入一个 AudioManager,集中负责 BGM 与音效(SFX)的加载、播放、暂停与语义化调用,让游戏逻辑只关心 发生了什么,而不关心音效怎么放

1. 封装 AudioManager


class AudioManager {
  static bool _inited = false;
  static String? _currentBgm;

  static double bgmVolume = 0.8;
  static double sfxVolume = 1.0;

  /// 必须在游戏启动时调用
  static Future<void> init() async {
    ...
  }

  // ================== SFX ==================

  static Future<void> playSfx(String file, {double? volume}) async {
    ...
  }

  // ================== BGM ==================

  static Future<void> playBgm(String file, {double? volume}) async {
    ...
  }

  static Future<void> stopBgm() async {
    ...
  }

  static Future<void> pauseBgm() async {
    ...
  }

  static Future<void> resumeBgm() async {
    ...
  }

  // ================== 语义化封装 ==================

  static Future<void> playDoorOpen() => playSfx('door_open.wav');
  static Future<void> playSwordClash() => playSfx('sword_clash_2.wav');
  static Future<void> playFireLighting() => playSfx('fire_lighting.wav');
  static Future<void> startBattleBgm() => playBgm('Goblins_Dance_(Battle).wav');
  static Future<void> startRegularBgm() => playBgm('Goblins_Den_(Regular).wav');
  static Future<void> playDoorKnock() => playSfx('door_knock.wav');
  static Future<void> playWhistle() => playSfx('whistle.wav');
  static Future<void> playHurt() => playSfx('Hurt.wav');
  static Future<void> playLaserGun() => playSfx('Laser_Gun.wav');
}

2. 音效使用

  • mygame 中初始化,并开始播放 bgm
      @override
      Future<void> onLoad() async {
        // 加载游戏资源
        super.onLoad();
        // 初始化音频管理器
        await AudioManager.init();
        // 播放BGM
        AudioManager.startRegularBgm();
        // 加载地图
        await _loadLevel();
      }
    
  • BulletHitbox子弹音效
  • DoorComponent: 敲门与关门音效
  • HeroComponent: 受击音效
  • ...

大家去网上找 音频 后,保存在 assets/aduio/ 下,在 AudioManager 中加载使用, 就可以在你想要的地方添加了。

六. 召唤术

在上述,有了 近战、远程和冲刺 的矩形判断之后,我们的小人就掌握了 普攻火球法术滑步
但是,我觉得那些还不够有意思,因为他是 魔法师
于是乎,会 召唤 小弟的滑步魔法师,他来了。

1. 构思

一开始,我打算新建一个 GenerateComponent 继承 CharacterComponent
这很简单,依葫芦画瓢 很快也就实现了,但是到了召唤物攻击逻辑时,就头疼了。
因为,我们之前所有逻辑都是围绕两个阵营的 hero 🆚 monster,新增第三方,就要重构了。
但是转念一想,其实这个召唤物和其他两个类没什么不同,索性哪个人物召唤的,召唤物就用哪个人物的类创建就行了。
所有逻辑都不需要改变了,对战逻辑完全符合,仅仅需要新增一段 召唤物AI逻辑 就可以了。

2. 实现

  • 新建召唤物生成工厂类 GenerateFactory
  • 所需属性
    • game:游戏容器,用于添加召唤物
    • center: 人物中心点
    • generateId: 召唤物id,用于定位配置资源
    • owner: 召唤者
    • enemyType: 敌对类型
    • count: 召唤物数量
    • radius: 角度
    • followDistance: 跟随距离
  • 确定生成物相对于人物的位置
    final step = 2 * math.pi / count;
    final start = -math.pi / 2;
    final list = <CharacterComponent>[];
    for (int i = 0; i < count; i++) {
      final angle = start + i * step;
      final pos = Vector2(
        center.x + radius * math.cos(angle),
        center.y + radius * math.sin(angle),
      );     
    
  • 生成所有召唤物
    // 根据拥有者类型决定生成物类型
    // Hero生成HeroComponent作为随从
    // Monster生成MonsterComponent作为随从
    if (owner is HeroComponent) {
     comp = HeroComponent(
       heroId: generateId, 
       birthPosition: position,
     );
    } else {
     comp = MonsterComponent(position, generateId);
    }
    
    // 设置召唤物通用属性
    comp.position = position;
    comp.isGenerate = true;
    comp.summonOwner = owner;
    comp.followDistance = followDistance;
    
    
    ...
    
    list.add(
       create(
         position: pos,
         generateId: generateId,
         owner: owner,
         enemyType: enemyType,
         followDistance: followDistance,
       ),
     );
    

七. 人机逻辑

在游戏中,敌人是否 像个人 ,很大程度上取决于人机逻辑(AI)。
咱们的 AI 并不追求复杂,而是要做到 感知、判断和反馈
能发现敌人、能决定行动、也能在不同状态之间自然切换。

1. 怪物索敌逻辑

  • 首先,寻找最近的 HeroComponent 作为目标
        PositionComponent? target;
        double distance = double.infinity;
    
        for (final h in monster.game.world.children.query<HeroComponent>()) {
          final d = (h.position - monster.position).length;
          if (d < distance) {
            distance = d;
            target = h;
          }
        }
    
  • 然后,如果超出 感知范围 或未找到 目标,则 游荡
    if (target == null || distance > monster.detectRadius) {
         if (monster.wanderDuration > 0) {
           monster.setState(CharacterState.run);
           final delta = monster.wanderDir * monster.speed * dt;
           monster.moveWithCollision(delta);
           monster.wanderDuration -= dt;
           monster.wanderDir.x >= 0 ? monster.faceRight() : monster.faceLeft();
         } else {
           monster.wanderCooldown -= dt;
           if (monster.wanderCooldown <= 0) {
             final angle = monster.rng.nextDouble() * 2 * math.pi;
             monster.wanderDir = Vector2(math.cos(angle), math.sin(angle));
             monster.wanderDuration = 0.6 + monster.rng.nextDouble() * 1.2;
             monster.wanderCooldown = 1.0 + monster.rng.nextDouble() * 2.0;
           } else {
             monster.setState(CharacterState.idle);
           }
         }
         return;
       }
    
  • 其次,如果在 感知范围内,就判断是否在可以发起 攻击攻击范围内
      // 进入攻击范围
      if (distance <= monster.attackRange) {
        monster.attack(0, HeroComponent);
        return;
      }
    
  • 最后,如果不在 攻击范围内,则 追逐
     // 追逐
     monster.setState(CharacterState.run);
    
     final toTarget = target!.position - monster.position;
     final direction = toTarget.normalized();
     final delta = direction * monster.speed * dt;
    
     monster.moveWithCollision(delta);
    
     direction.x >= 0 ? monster.faceRight() : monster.faceLeft();
    

2. 召唤物运行逻辑

  • 确定敌对类型:如果自己是 HeroComponent,则敌人是 MonsterComponent,反之亦然
      final bool isHero = component is HeroComponent;
    
  • 寻找最近的敌人
      PositionComponent? target;
      if (isHero) {
        // 寻找最近的Monster
        for (final m in component.game.world.children.query<MonsterComponent>()) {
          if (m == component.summonOwner) continue; // 排除主人(如果是)
          if (target == null ||
              (m.position - component.position).length <
                  (target!.position - component.position).length) {
            target = m;
          }
        }
      } else {
        // 寻找最近的Hero
        for (final h in component.game.world.children.query<HeroComponent>()) {
          if (h == component.summonOwner) continue;
          if (target == null ||
              (h.position - component.position).length <
                  (target!.position - component.position).length) {
            target = h;
          }
        }
      }
    
  • 如果在 攻击范围内,则发起 攻击追逐
    final toEnemy = target.position - component.position;
    final enemyDistance = toEnemy.length;
    if (enemyDistance <= detectRadius) {
      // 进入攻击范围
      if (enemyDistance <= attackRange) {
        component.attack(0, isHero ? MonsterComponent : HeroComponent);
        return;
      }
    
      // 追击敌人
      component.setState(CharacterState.run);
      final direction = toEnemy.normalized();
      final delta = direction * component.speed * dt;
      component.moveWithCollision(delta);
      direction.x >= 0 ? component.faceRight() : component.faceLeft();
      return;
    }
    
  • 如果附近没敌人,就跟随 召唤者
     if (component.summonOwner != null && component.summonOwner!.parent != null) {
       final toOwner = component.summonOwner!.position - component.position;
       final ownerDistance = toOwner.length;
       final double deadZone = 8.0;
    
       if (ownerDistance > component.followDistance + deadZone) {
         component.setState(CharacterState.run);
         final direction = toOwner.normalized();
         final delta = direction * component.speed * dt;
         component.moveWithCollision(delta);
         direction.x >= 0 ? component.faceRight() : component.faceLeft();
         return;
       }
     }
    

八. 游戏逻辑

196b61c4c7e648abaab520444fdd2f55.gif

终于终于,将本期内容介绍的差不多了,那么简单设计一下 体验版demo 的逻辑,完结基础篇吧。

1. 绘图

在图中,新增 名为 spawn_pointsobject layer图层,设置四个 怪物出生点一个胜利的终点

  • 胜利点属性
    • type:类型 goal
  • 怪物出生点属性
    • type :类型 monster_spawn

    • monsterId:怪物类型id

    • maxCount:该怪物点,最大怪物存活数量

    • perCount:该怪物点,每次生成怪物数量

    • productSpeed:该怪物点,生成怪物速度

2. 新增怪物出生点

/// 怪物生成点组件
///
/// - 支持定时按批次生成怪物
/// - 支持最大数量限制与开始/停止控制
/// - 位置与大小由关卡配置决定(用于调试显示)
class SpawnPointComponent extends PositionComponent
    with HasGameReference<MyGame> {
  /// 场景允许存在的最大怪物总数
  final int maxCount;

  /// 要生成的怪物类型 ID(与现有代码一致,使用字符串)
  final String monsterId;

  /// 每次生成的怪物数量
  final int perCount;

  /// 每次生成的时间间隔
  final Duration productSpeed;

  bool _running = false;
  double _timeSinceLastSpawn = 0;
  final Set<MonsterComponent> _spawned = {};

  SpawnPointComponent({
    required Vector2 position,
    required Vector2 size,
    required this.maxCount,
    required this.monsterId,
    required this.perCount,
    required this.productSpeed,
    Anchor anchor = Anchor.center,
    int priority = 0,
  }) : super(
          position: position,
          size: size,
          anchor: anchor,
          priority: priority,
        );

  @override
  Future<void> onLoad() async {
    debugMode = true;
  }

  /// 开始生成
  void start() {
    _running = true;
  }

  /// 停止生成并重置计时
  void stop() {
    _running = false;
    _timeSinceLastSpawn = 0;
  }

  @override
  void update(double dt) {
    super.update(dt);
    if (!_running) return;

    _timeSinceLastSpawn += dt;
    final intervalSeconds = productSpeed.inMicroseconds / 1e6;

    // 按间隔生成,避免长帧遗漏
    while (_timeSinceLastSpawn >= intervalSeconds) {
      _timeSinceLastSpawn -= intervalSeconds;
      _spawnBatch();
    }
  }

  void _spawnBatch() {
    // 仅统计由该生成点产生、且仍存在于场景中的怪物数量
    _spawned.removeWhere((m) => m.parent == null);
    final currentCount = _spawned.length;
    final allowance = maxCount - currentCount;
    if (allowance <= 0) return;

    final batch = math.min(perCount, allowance);
    for (int i = 0; i < batch; i++) {
      final monster = MonsterComponent(position.clone(), monsterId);
      monster.debugMode = true;
      game.world.add(monster);
      _spawned.add(monster);
    }
  }
}

3. 新增通关点

class GoalComponent extends SpriteAnimationComponent
    with HasGameReference<MyGame>, CollisionCallbacks {
  GoalComponent({required Vector2 position, required Vector2 size})
    : super(position: position, size: size);

  @override
  Future<void> onLoad() async {
    await super.onLoad();
    final image = await game.images.load('flag.png');
    final sheet = SpriteSheet(image: image, srcSize: Vector2(60, 60));
    animation = sheet.createAnimation(
      row: 0,
      stepTime: 0.12,
      from: 0,
      to: 4,
      loop: true,
    );
    add(RectangleHitbox());
  }

  @override
  void onCollisionStart(
    Set<Vector2> intersectionPoints,
    PositionComponent other,
  ) {
    super.onCollisionStart(intersectionPoints, other);
    if (other is HeroComponent) {
      AudioManager.playWhistle();
      UiNotify.showToast(game, '恭喜你完成了游戏!');
      other.onDead();
    }
  }
}

4. demo流程

graph TD
    Start[启动游戏] --> Init[初始化: 加载资源/音乐/地图]
    Init --> Spawn[生成: 英雄, 怪物, 道具]
    Spawn --> Loop{游戏循环}
    
    Loop --> Input[玩家输入: 摇杆/攻击]
    Input --> Update[状态更新: 移动/战斗/物理]
    Update --> Check{检测状态}
    
    Check -- &#34;HP <= 0&#34; --> Dead[死亡: 游戏结束]
    Check -- &#34;获得钥匙&#34; --> OpenDoor[交互: 开启门/宝箱]
    Check -- &#34;到达终点&#34; --> Win[胜利: 通关]
    Check -- &#34;继续&#34; --> Loop
    
    OpenDoor --> Loop
    
    Dead --> Restart[显示重开按钮]
    Win --> Restart
    Restart --> Init

九. 总结与展望

总结

本章主要介绍了 Flutter&Flame 开发 2D像素游戏 关于 攻击逻辑 的基础实践。
通过上述步骤,我们完成了人物hud界面、近战、远程、冲刺和召唤 这几类常见攻击元素的实现。

截至目前为止,游戏主要包括了以下内容:

  • 角色与动画:使用精灵图 (SpriteSheet) 创建角色,支持 idle/run 等动画状态切换。
  • 玩家交互:通过摇杆控制角色移动,并根据方向翻转动画。
  • 地图加载:通过 Tiled 绘制并在 Flame 中加载的 2d像素地图
  • 地图交互:通过组件化模式,新建了多个可供交互的组件如(门、钥匙、宝箱、地刺),为游戏增加了互动性。
  • 统一碰撞区检测:将角色与 需要产生碰撞 的物体统一管理,并实现碰撞时的 平滑侧移
  • 统一人物配置创建: 通过将角色数据配置为文件,达到以动态数据驱动模型的目的。
  • HUD界面: 包括 人物血量条技能按钮
  • 完善的攻击逻辑:通过统一基类实现近战、远程、冲刺 的攻击方式 和 独特 召唤 技能。

展望

  • 思考 🤔 一个有趣的游戏机制ing ...

  • 进阶这个demo版

  • 支持局域网多玩家联机功能。

🎮 MyHero 在线体验
🚪 github 源码
💻 个人门户网站


之前尝试的Demo预览

Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(六)

Flutter 实现一个容器内部元素可平移、缩放和旋转等功能(六)

Flutter: 3.35.6

前面有人提到在元素内部的那块判断怎么那么写的,看来对知识渴望的小伙伴还是有,这样挺好的。不至于说牢记部分知识,只需要大致了解一下有个印象后面如果哪里再用到了就可以根据这个印象去查阅资料。

接下来我们说一下判断原理。当我们知晓矩形的四个顶点坐标(包括任意旋转后),可以使用向量叉乘法来判断是否在矩形内部。

向量叉乘法的核心思想就是:如果一个点在凸多边形内部,那么它应该始终位于该多边形每条边的同一侧。

注:凸多边形定义为所有内角均小于180度,并且任意两点之间的连线都完全位于多边形内部或边界。

所以我们利用向量叉乘法来判断点位于线的哪一侧。假设矩形顶点按顺时针逆时针顺序为 A,B,C,D:

  • 对于边 AB,计算向量 AB 和 AP 的叉积。
  • 对于边 BC,计算向量 BC 和 BP 的叉积。
  • 对于边 CD,计算向量 CD 和 CP 的叉积。
  • 对于边 DA,计算向量 DA 和 DP 的叉积。

如果点 P 在所有边的‌同一侧‌(即所有叉积结果的符号相同),那么点 P 就在矩形内部。我们假设矩形某条边的顶点为(x1, y1), (x2, y2), 判断的点坐标为(x, y),那么就有:

(x2 - x1) * (y - y1) - (y2 - y1) * (x - x1) > 0

这样就可以判断在某侧,如果其他三条边也满足,那就是内侧了,要转换为下面代码中的形式,那就做一下加减乘除就行了:

  1. (x2 - x1) * (y - y1) - (y2 - y1) * (x - x1) > 0: 初始
  2. (x2 - x1) * (y - y1) / (y2 - y1) - (x - x1) > 0: 两边同时除以(y2 - y1)
  3. (x2 - x1) * (y - y1) / (y2 - y1) - x + x1 > 0: 展开括号
  4. (x2 - x1) * (y - y1) / (y2 - y1) + x1 > x: 将x移项

这样就得到了代码中的判断依据,至于循环遍历顶点的写法,就是为了获取相邻两个顶点,这个就可以带入square坐标和循环去算一下就行了,保证每次循环都是相邻的两个顶点。

我们使用的顶点坐标顺序是顺时针,第一次循环 i = 0,j = 3,那么i就是左上顶点,j就是左下顶点,两个顶点刚好构成矩形左边;第二次循环 j = i++,此时 j = 0,i = 1后续喜欢以此类推即可。

这样判断在内侧差不多就解释完了。接下来开始我们今天正文。前面我们就简单完成了多个元素的相应操作,剩下的就是一些优化和一些简单的扩展功能。

既然是多个元素,那么肯定就涉及到新增和删除,之前的新增都是在列表里面直接添加,现在我们单独提取一个方法用于新增。至于删除功能我们就定义在元素左上角为删除区域,触发方式为点击。之前我们对热区的数据模型中添加了 trigger 字段,用于表示当前区域触发操作的方式是什么,所以我们得对点击方法进行优化,并且在临时中间变量上面存储 trigger 字段,用于判断:

class ResponseAreaModel {
  // 其他省略...

  /// 当前响应操作的触发方式
  final TriggerMethod trigger;
}

/// 新增返回Records,用于记录状态和触发方式
(ElementStatus, TriggerMethod)? _onDownZone({
  required double x,
  required double y,
  required ElementModel item,
}) {
  // 先判断是否在响应对应操作的区域
  final (ElementStatus, TriggerMethod)? areaStatus = _getElementZone(x: x, y: y, item: item);
  if (areaStatus != null) {
    return areaStatus;
  } else if (_insideElement(x: x, y: y, item: item)) {
    // 因为加入旋转,所以单独抽取落点是否在元素内部的方法
    return (ElementStatus.move, TriggerMethod.move);
  }

  return null;
}

/// 新增返回Records,用于记录状态和触发方式
(ElementStatus, TriggerMethod)? _getElementZone({
  required double x,
  required double y,
  required ElementModel item,
}) {
  // 新增Records记录返回的状态和触发方式
  (ElementStatus, TriggerMethod)? tempStatus;

  for (var i = 0; i < ConstantsConfig.baseAreaList.length; i++) {
    // 其他省略...

    if (
      x >= dx - areaCW &&
      x <= dx + areaCW &&
      y >= dy - areaCH &&
      y <= dy + areaCH
    ) {
      tempStatus = (currentArea.status, currentArea.trigger);
      break;
    }
  }

  return tempStatus;
}

这样触发方式和状态都记录了,我们就开始实现删除功能,依然按照之前的步骤快速实现:

// 新增删除
ResponseAreaModel(
  areaWidth: 20,
  areaHeight: 20,
  xRatio: 0,
  yRatio: 0,
  status: ElementStatus.deleteStatus,
  icon: 'assets/images/icon_delete.png',
  trigger: TriggerMethod.down,
),
/// 处理删除元素
void _onDelete() {
  if (_currentElement == null) return;

  _elementList.removeWhere((item) => item.id == _currentElement?.id);
}
/// 按下事件
void _onPanDown(DragDownDetails details) {
  // 其他省略...

  // 遍历判断当前点击的位置是否落在了某个元素的响应区域
  for (var item in _elementList) {
    // 新增Records数据,存储元素状态和触发方式
    final (ElementStatus, TriggerMethod)? status = _onDownZone(x: dx, y: dy, item: item);

    if (status != null) {
      currentElement = item;
      temp = temp.copyWith(status: status.$1, trigger: status.$2);
      break;
    }
  }

  // 新增判断
  // 如果当前有选中的元素且和点击区域的currentElement是一个元素
  // 并且 temp 的 status对应的触发方式为点击,那么就响应对应的点击事件
  if (currentElement?.id == _currentElement?.id && temp.trigger == TriggerMethod.down) {
    if (temp.status == ElementStatus.deleteStatus) {
      _onDelete();
      // 因为是删除,就置空选中,让下面代码执行最后的清除
      currentElement = null;
    }
  }

  // 其他省略...
}

运行效果:

image01.gif

这样就简单实现了元素的删除功能。到此操作区域常用的功能差不多就完成,接下来我们考虑一些区域的自定义;例如我希望旋转的区域在右下角(现在在右上角),并且不使用缩放功能,还想自定义一个区域,这时候该如何实现呢?

允许传递配置,通过这份配置来决定元素应该有什么响应区域并且是否使用这些响应区域,然而操作这些内置的我们可以使用之前定义的 final ElementStatus status; 字段来确定要修改哪个区域,毕竟一个操作应该是对应一个区域;对于自定义区域,我们的ElementStatus是个枚举类型且为必传,这就限制了自定义区域,所以我们的改造一下,用户传递的自定义区域,status为自行设置的字符串,我们内部也同时更改为字符串(涉及更改的地方有一些,这里不做过多的说明,后续可以查阅源码):

/// 元素当前操作状态
/// 更改新增字符串的value属性
enum ElementStatus {
  move(value: 'move'),
  rotate(value: 'rotate'),
  scale(value: 'scale'),
  deleteStatus(value: 'deleteStatus'),;

  final String value;

  const ElementStatus({required this.value});
}

/// 大致说一些需要更改的地方
/// TemporaryModel 的 status 更改为字符串类型
/// ResponseAreaModel 的 status 更改为字符串类型
/// _onDownZone 方法中 Records 第一项也返回 String
/// _getElementZone 方法中 Records 第一项也返回 String

接下来我们确定自定义区域配置中需要的字段:

  • status:用于映射内置的区域,方便做更改(String 必须)
  • use:用于确定该 status 对应的内置区域是否使用(bool 非必须)
  • xRatio:用于确定区域位置(double 非必须,如果非内置的默认就是0)
  • yRatio:用于确定区域位置(double 非必须,如果非内置的默认就是0)
  • trigger:用于确定区域的触发方式(TriggerMethod 非必须,默认TriggerMethod.down)
  • icon:用于确定操作区域的展示icon(String 如果是内置的 status 就是非必须,如果不是内置的就是必须)
  • fn:自定义区域需要执行的方法(Function({required double x, required double y}) 如果是内置的就是非必须,如果不是内置的就必须)

基于上述开始进行编码:

/// 新增自定义区域配置
class CustomAreaConfig {
  const CustomAreaConfig({
    required this.status,
    this.use,
    this.xRatio,
    this.yRatio,
    this.trigger = TriggerMethod.down,
    this.icon,
    this.fn,
  });

  /// 区域的操作状态字符串,可以是内置的,如果是内置的就覆盖内置的属性
  final String status;
  /// 是否启用
  final bool? use;
  /// 自定义位置
  final double? xRatio;
  final double? yRatio;
  /// 区域响应操作的触发方式
  final TriggerMethod trigger;
  /// 自定义区域就是必传
  final String? icon;
  /// 自定义区域就是必传,点击对应的响应区域就执行自定义的方法
  final Function({required double x, required double y})? fn;
}
/// 新增自定义区域配置
final List<CustomAreaConfig> _customAreaList = [
  // 不使用缩放区域
  CustomAreaConfig(
    status: ElementStatus.scale.value,
    use: false,
  ),
  // 将旋转移到右下角
  CustomAreaConfig(
    status: ElementStatus.rotate.value,
    xRatio: 1,
    yRatio: 1,
  ),
];
/// 容器响应操作区域,之前是直接使用的常量里面的配置
List<ResponseAreaModel> _areaList = [];

/// 初始化响应区域
void _initArea() {
  List<ResponseAreaModel> areaList = [];

  for (var area in ConstantsConfig.baseAreaList) {
    final int index = _customAreaList.indexWhere((item) => item.status == area.status);

    if (index > -1) {
      final CustomAreaConfig customArea = _customAreaList[index];

      // 如果是不使用,则跳出本次循环
      if (customArea.use == false) {
        continue;
      }

      areaList.add(area.copyWith(
        xRatio: customArea.xRatio,
        yRatio: customArea.yRatio,
        icon: customArea.icon,
        fn: customArea.fn,
      ));
    } else {
      areaList.add(area);
    }
  }

  setState(() {
    _areaList = areaList;
  });
}
// 其他省略...

/// 抽取渲染的元素
class TransformItem extends StatelessWidget {
  const TransformItem({
    // 其他省略...

    required this.areaList,
  });

  // 其他省略...

  final List<ResponseAreaModel> areaList;

  @override
  Widget build(BuildContext context) {
    return Positioned(
      left: elementItem.x,
      top: elementItem.y,
      // 新增旋转功能
      child: Transform.rotate(
        angle: elementItem.rotationAngle,
        child: Container(
          // 其他省略...

          // 新增区域的渲染
          child: selected ? Stack(
            clipBehavior: Clip.none,
            children: [
              // 修改从外界传递区域列表
              ...areaList.map((item) => Positioned(
                // 其他省略...
              )),
            ],
          ) : null,
        ),
      )
    );
  }
}

其他编码不算核心,就不再展示了,反正就一个,之前从 ConstantsConfig.baseAreaList 拿的数据现在都直接使用 _areaList。

运行效果:

image02.gif

可以看到,我们将旋转区域移到右下角了,并且不使用缩放区域,这样就简单完成了区域自定义的配置。

感兴趣的也可以关注我的微信公众号【前端学习小营地】,不定时会分享一些小功能~

今天的分享就到此结束了,感谢阅读~拜拜~

TypeScript和JavaScript到底有什么区别?

两种语言的本质关系:从自由奔放到严谨规划

首先,让我们澄清一个最常见的误解:TypeScript不是JavaScript的替代品,而是它的超集

简单来说,所有合法的JavaScript代码都是合法的TypeScript代码。但TypeScript在此基础上,添加了强大的类型系统和其他现代语言特性。

想象一下:

  • JavaScript 就像一个自由奔放的艺术家,可以在画布上随意挥洒,不拘一格
  • TypeScript 则像是这位艺术家决定遵循一套建筑蓝图,让创作更加结构化和可预测

核心区别:类型系统的魔力

无类型 vs 静态类型

让我们通过一个简单的例子来看看这两者的核心区别:

// JavaScript版本:自由但危险
function calculateTotal(price, quantity) {
    return price * quantity;
}

// 这行代码能正常运行,但结果可能不是你想要的
const total = calculateTotal("100", 3); // 返回 "100100100"
// TypeScript版本:明确且安全
function calculateTotal(price: number, quantity: number): number {
    return price * quantity;
}

// 下面这行代码会在编译时就报错
const total = calculateTotal("100", 3); // 编译错误:Argument of type 'string' is not assignable to parameter of type 'number'

看出区别了吗?TypeScript在你写代码的时候就会检查类型错误,而JavaScript要到运行时才会发现问题。

接口和类型定义

TypeScript最强大的特性之一就是接口(Interface):

// 定义用户接口
interface User {
    id: number;
    name: string;
    email: string;
    age?: number; // 可选属性
}

// 使用接口
function createUser(userData: User): User {
    return {
        id: Date.now(),
        name: userData.name,
        email: userData.email,
        age: userData.age || 25
    };
}

// TypeScript会检查我是否提供了所有必需的属性
const newUser = createUser({
    name: "张三",
    email: "zhangsan@example.com"
});

开发体验对比:写代码时的不同感受

智能提示和自动补全

使用TypeScript时,IDE能够提供极其精准的智能提示:

interface Product {
    id: number;
    name: string;
    price: number;
    inStock: boolean;
}

const product: Product = {
    id: 1,
    name: "笔记本电脑",
    price: 7999,
    inStock: true
};

// 当我输入"product."时,IDE会提示所有可用的属性
console.log(product.name); // IDE知道这是string类型
console.log(product.price); // IDE知道这是number类型
// console.log(product.description); // 编译错误:属性'description'不存在

重构时的安心感

在大项目中重构代码时,TypeScript提供的安全保障是无价的:

// 假设我要修改API响应结构
interface ApiResponse<T> {
    success: boolean;
    data: T;
    timestamp: string;
    // 新增字段
    requestId?: string;
}

// TypeScript会告诉我所有需要更新的地方
function processResponse(response: ApiResponse<User>) {
    if (response.success) {
        console.log(response.data.name);
        console.log(response.requestId); // 我知道这是可选字段
    }
}

实战场景:从JavaScript迁移到TypeScript

让我分享一个实际的迁移经验。我曾经维护一个中型电商项目,最初使用纯JavaScript开发:

// 原来的JavaScript代码
function addToCart(item, quantity) {
    // item是什么结构?quantity应该是数字吗?
    cart.items.push({ item, quantity });
    updateCartTotal();
}

迁移到TypeScript后:

// TypeScript版本
interface CartItem {
    id: number;
    name: string;
    price: number;
    category: string;
}

interface CartEntry {
    item: CartItem;
    quantity: number;
    addedAt: Date;
}

function addToCart(item: CartItem, quantity: number): void {
    const cartEntry: CartEntry = {
        item,
        quantity,
        addedAt: new Date()
    };
    
    cart.items.push(cartEntry);
    updateCartTotal();
}

迁移过程虽然需要一些额外工作,但回报是巨大的:

  1. 代码错误减少了约40%
  2. 新成员上手时间缩短了50%
  3. 重构时的信心大大增强

学习曲线和成本考量

TypeScript的学习成本

是的,TypeScript需要学习一些新概念:

  • 类型注解
  • 接口和类型别名
  • 泛型
  • 枚举
  • 装饰器(可选)

但对于有JavaScript基础的开发者来说,这些概念并不难掌握。我建议从简单的类型注解开始,逐步深入。

项目中的成本效益分析

在中小型项目中,纯JavaScript可能更快速灵活。但在以下场景中,TypeScript的优势明显:

  1. 团队协作项目:类型系统作为文档,减少沟通成本
  2. 大型复杂应用:提前发现潜在错误
  3. 长期维护项目:代码可读性和可维护性更好
  4. 公共库开发:提供更好的开发者体验

我的实用建议

经过多年的实践,我总结了一些建议:

  1. 新项目优先考虑TypeScript,特别是团队项目
  2. 老项目逐步迁移,可以从配置文件开始,逐个模块转换
  3. 不要过度使用高级特性,保持代码简洁易懂
  4. 结合具体业务场景选择,简单的工具脚本可能不需要TypeScript

总结:选择合适的工具

回到最初的问题:TypeScript和JavaScript有什么区别?我认为最核心的区别是:

JavaScript给你自由,TypeScript给你安全和结构。

作为一名开发者,我的选择通常是:

  • 小型脚本、原型验证、学习实验 → JavaScript
  • 企业级应用、团队协作、长期维护项目 → TypeScript

两种语言各有适用场景,理解它们的差异不是为了争论孰优孰劣,而是为了在合适的场景使用合适的工具。

在2024年的前端开发中,TypeScript已经成为许多团队的标准选择。但这并不意味着JavaScript会消失——它们将长期共存,各自在适合的领域发光发热。

真正的技术选择,不在于追求最新最热,而在于找到最适合当前问题和团队的工具。  这就是我从六年开发经验中学到的最重要的一课。


小杨的实践心得:无论选择哪种语言,保持代码清晰、可维护才是最重要的。技术只是工具,解决问题才是目的。

⭐  写在最后

请大家不吝赐教,在下方评论或者私信我,十分感谢🙏🙏🙏.

✅ 认为我某个部分的设计过于繁琐,有更加简单或者更高逼格的封装方式

✅ 认为我部分代码过于老旧,可以提供新的API或最新语法

✅ 对于文章中部分内容不理解

✅ 解答我文章中一些疑问

✅ 认为某些交互,功能需要优化,发现BUG

✅ 想要添加新功能,对于整体的设计,外观有更好的建议

✅ 一起探讨技术加qq交流群:906392632

最后感谢各位的耐心观看,既然都到这了,点个 👍赞再走吧!

Vue3 项目:宠物照片变身冰球运动员的 AI 应用

Vue3 项目:宠物照片变身冰球运动员的 AI 应用

这是一个有趣的 AI 图像生成应用,使用 Vue 3 构建前端,用户上传宠物(或任何)照片,选择冰球运动员的队服颜色、编号、位置、持杆手和风格,然后调用 Coze 平台的 AI 工作流生成一张宠物穿冰球装备的趣味图像。

整个业务逻辑清晰、模块化,主要分为图片预览参数配置文件上传AI 生成四个核心部分。

1. 应用整体流程

  1. 用户选择本地图片文件。
  2. 前端立即预览图片(提升用户体验,避免上传等待时感到空白)。
  3. 用户配置冰球运动员参数(队服颜色、编号、位置、持杆、风格)。
  4. 点击“生成”按钮:
    • 先将图片上传到 Coze 服务器,获取文件 ID(file_id)。
    • 然后调用 Coze 工作流 API,传入 file_id 和其他参数。
    • Coze 工作流内部使用 AI 模型(可能是图像融合或文生图变体)生成新图片。
    • 返回生成的图片 URL,前端显示。
  5. 过程中通过状态文本提示用户进度(上传中 → 生成中 → 成功/失败)。

2. 图片预览模块:本地即时预览

  • 为什么需要预览?
    图片上传可能需要时间(尤其是大文件),即时预览让用户知道“操作已生效”,提升体验。同时避免用户误以为卡顿。

  • 实现原理

    • 使用 HTML <input type="file"> 监听 @change 事件。
    • 获取选中的文件后,使用浏览器内置的 FileReader API 将文件读取为 Base64 编码的 Data URL(字符串形式)。
    • 将这个字符串赋值给响应式变量 imgPreview,绑定到 <img :src="imgPreview"> 上即可显示。
  • 关键代码逻辑

    const updateImageData = () => {
      const file = uploadImage.value.files[0];
      const reader = new FileReader();
      reader.readAsDataURL(file);  // 异步读取
      reader.onload = (e) => {
        imgPreview.value = e.target.result;  // Base64 字符串,直接作为 src
      };
    };
    
  • 优点:纯前端操作,无需上传服务器,速度快。

3. 参数配置:响应式表单数据

  • 使用 Vue 3 的 ref 定义响应式变量:
    • uniform_number(编号,默认 7)
    • uniform_color(颜色,默认 “白”)
    • position(位置:0-守门员、1-前锋、2-后卫)
    • shooting_hand(持杆:0-左手、1-右手)
    • style(风格:写实、乐高、国漫 等)
  • 通过 v-model 双向绑定到表单控件,用户修改即时更新这些变量。
  • 这些参数后续会作为工作流输入,直接影响 AI 生成的图像效果。

4. 文件上传到 Coze:获取 file_id

  • 为什么先上传文件?
    Coze 工作流不支持直接接收文件字节流,而是需要一个文件 ID(file_id)。Coze 会自动将 file_id 转换为内部可访问的 URL 供 AI 使用。

  • API 详情

    • 端点:https://api.coze.cn/v1/files/upload
    • 方法:POST
    • Headers:Authorization: Bearer <PAT_TOKEN>
    • Body:FormData,键为 file,值为文件对象
    • 成功响应:{ code: 0, data: { id: "文件ID" } }
  • 实现逻辑

    • 创建 FormData,append 用户选择的文件。
    • 使用 fetch 发送请求。
    • 检查 code === 0,返回 file_id,否则显示错误消息。
  • 状态更新:上传前设置 status = "图片上传中...",成功后更新为“上传成功,正在生成中...”。

5. 调用 Coze 工作流:生成图像

  • API 详情
    • 端点:https://api.coze.cn/v1/workflow/run
    • 方法:POST
    • Headers:
      • Authorization: Bearer <PAT_TOKEN>
      • Content-Type: application/json
    • Body:
      {
        "workflow_id": "固定工作流 ID",
        "parameters": {
          "picture": "{\"file_id\": \"上传得到的ID\"}",
          "style": "用户选择的风格",
          "position": ...,
          // 其他参数
        }
      }
      
    • 注意:图片参数必须是 JSON 字符串化的对象 { "file_id": "xxx" },Coze 会自动解析为文件 URL。
  • 响应处理
    • 成功:ret.code === 0ret.data 是字符串(工作流输出),需 JSON.parse(ret.data) 获取实际数据,通常包含生成的图像 URL(如 data.data)。
    • 设置 imgUrl.value = imageUrl,前端显示新图片。
    • 更新状态为“生成成功”或错误消息。
  • 安全与配置
    • PAT Token(Personal Access Token)通过环境变量 VITE_PAT_TOKEN 注入,避免硬编码。
    • 工作流 ID 固定硬编码(实际项目可配置化)。

6. 用户体验优化点

  • 状态提示status 变量实时反馈进度和错误。
  • 错误处理:统一检查 API 的 code 字段,非 0 时显示 msg
  • 布局:左侧输入区(上传 + 参数),右侧输出区(预览 + 生成结果),响应式友好。
  • 扩展性:参数易增减,适合类似“宠物变身其他角色”的变体应用。

总结

这个项目巧妙结合了 Vue 3 的响应式系统、前端文件处理能力和 Coze 的 AI 工作流 API,实现了从上传到生成的完整闭环。

健康元:控股子公司获得NS-041片新增抑郁症临床试验批准通知书

36氪获悉,健康元公告,公司控股子公司丽珠制药厂收到国家药品监督管理局核准签发的关于NS-041片新增针对“治疗抑郁症”适应症的《药物临床试验批准通知书》。NS-041片是一款新型高选择性KCNQ2/3激动剂,目标用于癫痫和抑郁症等神经精神类疾病的治疗,系目前国内唯一获批同时开展癫痫和抑郁症临床研究的KCNQ2/3 靶向药物。但需注意,药物在获得临床试验批准通知书后,尚需开展临床试验并经国家药监局审评、审批通过后方可生产上市,存在诸多不确定性。

面试常考的最长递增子序列(LIS),到底该怎么想、怎么写?

🎯 开场灵魂拷问:你真的会“找最长递增子序列”吗?

面试官轻描淡写地抛出一句:

“给定一个数组,求它的最长递增子序列长度。”

你以为这是送分题?
结果一写代码才发现——

  • 暴力枚举?超时!
  • 动态规划?能过但慢如蜗牛!
  • 贪心 + 二分?脑子里一团浆糊:“这玩意儿怎么还能用二分?”

别慌,这不是你菜,而是你还没看到这场算法进化的“全貌”。

今天,我们就来拆解 LeetCode 第 300 题 —— 「最长递增子序列(LIS)」,带你从 暴力直觉 → 动态规划 → 贪心艺术,一步步攀登算法高峰。

✅ 学完你会明白:

  • 为什么 DP 是“基础课”,而贪心是“研究生选修”?
  • 为什么“末尾越小越好”能决定整个序列的命运?
  • 以及——二分查找,居然也能用来“维护梦想”?

🧩 第一章:题目解析 —— 先搞清楚“我们在找什么”

🔍 题目定义(LeetCode 300)

image.png

给你一个整数数组 nums,找出其中最长严格递增子序列的长度

📌 注意关键词:

  • 子序列 ≠ 子数组:可以不连续,但必须保持原顺序。
  • 严格递增[2,3,3] 不算,[2,3,4] 才行。
  • 只需要返回长度,不要求输出具体序列(除非面试官坏笑说:“那你打印一下?”)

🎯 示例:

输入: nums = [10,9,2,5,3,7,101,18]
输出: 4
解释: 最长递增子序列为 [2,3,7,18] 或 [2,5,7,101],长度为4

💡 思考起点:
我们不是要“列出所有可能”,而是要在 时间与空间的夹缝中,找到最优路径


🏗️ 方法一:动态规划(DP)—— 暴力中的优雅

⏱️ 时间复杂度:O(n²)
💾 空间复杂度:O(n)
🧠 适合初学者理解本质,也适合面试保底

🤔 核心思想:以我为中心,我能接谁?

我们换个视角思考:

对于每个位置 i,如果我能成为某个递增子序列的“结尾”,那这个序列最长能有多长?

于是我们定义状态:

dp[i] = nums[i] 结尾的最长递增子序列的长度

为什么这样定义?因为:

  • 子序列有“延续性”:... → a → b 成立的前提是 a < b
  • 所以 dp[i] 的值,取决于前面所有比 nums[i] 小的元素的 dp[j]

🔄 状态转移方程

对每个 i,遍历 j ∈ [0, i)

if nums[j] < nums[i]:
    dp[i] = max(dp[i], dp[j] + 1)

初始值:每个元素自己就是一个长度为 1 的子序列 → dp[i] = 1


✅ 代码实现(带注释版)

image.png


🧪 执行过程可视化(手把手教学)

nums = [10,9,2,5,3,7,101,18] 为例:

i nums[i] 可接的 j dp[i] 计算过程 最终 dp[i]
0 10 - 初始化为 1 1
1 9 <9 的前驱 1
2 2 同上 1
3 5 j=2 (2<5) dp[2]+1 = 2 2
4 3 j=2 (2<3) dp[2]+1 = 2 2
5 7 j=2,3,4 max(dp[j])+1 = 3 3
6 101 前面都小 max(dp[j])+1 = 4 4
7 18 前面都小 但只能接最长链 4

✅ 最终答案:max(dp) = 4


📉 复杂度分析

  • 时间:O(n²) —— 双重循环,适合 n ≤ 10³
  • 空间:O(n) —— dp 数组

🟢 优点:逻辑清晰,容易想到,适合面试“先写个能跑的” 🔴 缺点:数据一大就超时,比如 n = 10⁵?直接凉透


🚀 方法二:贪心 + 二分查找 —— 当算法开始“动脑筋”

⏱️ 时间复杂度:O(n log n)
💾 空间复杂度:O(n)
🧠 适合高手秀操作,也是大厂面试加分项

🤯 关键洞察:我们要的不是“当前最长”,而是“未来潜力最大”

动态规划的问题在于:它太“实在”了。
它记录的是“以某点结尾的最长长度”,但并不关心“这个序列将来能不能继续增长”。

而贪心策略的核心哲学是:

对于相同长度的递增子序列,末尾越小,未来的路就越宽!

举个例子:

  • 序列 A:[2,5,7],末尾是 7
  • 序列 B:[2,3,7],末尾也是 7,但中间更小

现在来了一个新数字 4

  • A 无法接(7 > 4)
  • B 虽然也不能直接接,但如果把 7 换成 4,就变成了 [2,3,4],未来还能接 5, 6...

所以,我们应该保留那些“末尾尽可能小”的候选序列


🛠️ 引入神器:tails 数组

我们定义一个辅助数组:

tails[k] = 长度为 k+1 的递增子序列中,最小的末尾元素

例如:

tails = [2, 3, 7, 18]

表示:

  • 有长度为1的 LIS,最小末尾是 2
  • 有长度为2的 LIS,最小末尾是 3
  • ...
  • 有长度为4的 LIS,最小末尾是 18

🎯 目标:维护这个 tails 数组,让它始终保持“末尾最小化”。


🔁 算法流程:见招拆招

遍历每个 num

  1. 如果 num > tails[last]
    → 说明它可以扩展当前最长序列!直接追加到末尾。

  2. 否则(num <= tails[last]
    → 它不能扩展最长序列,但可以“优化”某个较短序列的末尾。
    → 使用二分查找,找到第一个 ≥ num 的位置,把它替换掉。

🧠 替换的意义:我们用 num 更新了某个长度的“最佳末尾”,让未来更容易扩展。


✅ 代码实现(含详细注释)

image.png


🎬 执行过程动画演示(文字版)

nums = [10,9,2,5,3,7,101,18]

num tails 变化 说明
10 [10] 初始
9 [9] 9 < 10,替换第一个 ≥9 的元素
2 [2] 同上
5 [2,5] 5 > 2,可扩展,追加
3 [2,3] 3 < 5,替换第一个 ≥3 的位置(索引1)
7 [2,3,7] 7 > 3,追加
101 [2,3,7,101] 继续追加
18 [2,3,7,18] 18 < 101,替换最后一个

✅ 最终长度为 4,正确!


🧠 为什么 tails 是有序的?(关键证明)

很多人疑惑:为什么能用二分查找?

👉 因为 tails严格递增的!

反证法:

假设存在 i < jtails[i] >= tails[j]
那么长度为 j+1 的 LIS 的前 i+1 个元素,构成了一个长度为 i+1 的递增子序列,其末尾 < tails[j] ≤ tails[i]
但这与 tails[i] 是“长度为 i+1 的最小末尾”矛盾!

✅ 所以 tails 必然递增,可用二分!


📉 复杂度分析

  • 时间:O(n log n) —— 每个元素做一次 O(log n) 的二分
  • 空间:O(n) —— tails 最多存 n 个元素

🟢 优势:大规模数据也能轻松应对(n=10⁵ 也没问题) 🔴 劣势:tails 数组并不是真正的 LIS,只是长度正确(想还原序列?得额外记录)


🆚 两种方法对比:一场“稳扎稳打” vs “灵巧突袭”的较量

维度 动态规划(DP) 贪心 + 二分
🧠 思维难度 简单直观,易理解 抽象跳跃,需顿悟
⏱️ 时间复杂度 O(n²) O(n log n)
💾 空间复杂度 O(n) O(n)
✅ 是否稳定 是,适合保底 是,但难调试
🖨️ 能否还原子序列 ✅ 可通过 prev 数组还原 tails 不是真实序列
🎯 适用场景 小数据、教学、面试保底 大数据、性能敏感、进阶展示

💡 高阶技巧与拓展思考

✅ 技巧1:如何还原具体的 LIS?

  • DP 方案:额外维护 parent[i] 记录前驱节点,最后倒推路径
  • 贪心方案:不行(tails 是虚拟状态),但可通过“路径回溯 + 栈”模拟

✅ 技巧2:最长非递减子序列?

只需将条件改为 nums[j] <= nums[i],或在二分查找时找 第一个 > num 的位置进行替换。


✅ 技巧3:结合实际业务?

  • 用户行为轨迹中最长“正向成长”路径
  • 股票价格中的最长上涨周期
  • 文本相似度匹配中的最长公共递增子序列(LCIS)

🧩 面试视角:如果你是面试官,你会怎么问?

  1. “先说个你能想到的方法。” → 你答 DP,稳妥。
  2. “有没有更优解?” → 你答贪心 + 二分,加分。
  3. “为什么 tails 可以用二分?” → 你开始讲单调性,面试官点头。
  4. “那怎么还原具体序列?” → 你微微一笑:“我有备而来。”

🎯 这就是算法面试的“阶梯式挑战”:先活下来,再赢下来


🏁 终极总结:从“复制粘贴”到“创造思维”

方法 代表能力 适合阶段
动态规划 基础建模能力 初级 → 中级
贪心 + 二分 优化思维 & 数学直觉 中级 → 高级

🌟 编程的本质,是从“解决问题”到“优雅地解决问题”的进化。

下次当你看到“最长递增子序列”,不要再害怕:

  • 先写 DP 保命,
  • 再秀贪心翻盘,

💬 互动时间

欢迎在评论区留下你的想法:

  1. 你是怎么第一次理解“贪心 + 二分”这个思路的?
  2. 你在项目中遇到过类似 LIS 的实际问题吗?
  3. 你觉得 tails 数组像不像“人生梦想清单”?越早把目标调低,未来越容易实现 😂

👇 点赞 + 收藏 + 分享,让更多人告别“只会暴力”的时代!


中信证券:推动人民币升值的因素逐渐增多,可关注三条线索

36氪获悉,中信证券研究指出,推动人民币升值的因素逐渐增多,市场关注度也开始升温,投资者要逐步适应在一个人民币持续升值的环境下去做资产配置。从过去20年间7轮人民币升值周期来看,汇率并不是主导行业配置的决定性因素。然而,部分行业在持续升值预期形成的初期确实会有更好表现,市场可能会复制这样的肌肉记忆,同时从成本收入分析来看,约19%的行业会因为升值带来利润率提升,这些行业也会逐步被投资者重视起来。此外,为抑制过快单边升值趋势而做出的政策应对,反而是影响行业配置的更重要因素。

*ST名家:资本公积金转增股本实施完成,股票复牌

36氪获悉,*ST名家公告,公司资本公积金转增股本已全部完成,共计转增7.3亿股,总股本增至14.26亿股。其中,重整投资人受让6.64亿股为首发后限售股,债权人受让6600万股为无限售条件流通股。公司股票将于12月22日开市起复牌,复牌后调整后的除权参考价格为3.16元/股。

从 useState 到 useEffect:React Hooks 核心机制详解

react hook 介绍

一、React Hooks 是什么?

React Hooks 是 React 16.8 引入的一组以 use 开头的函数,让函数组件也能拥有状态(state)、生命周期和副作用处理能力——这些原本只有类组件才能做到。

🧑‍🍳 生活类比
以前,只有“有经验的厨师”(类组件)才能记住锅里煮了几分钟、盐放了多少;
现在,“新手厨师”(函数组件)只要学会用 useStateuseEffect 这些工具,也能轻松掌勺!

Hooks 的出现,不仅简化了组件逻辑,还避免了类组件中常见的 this 绑定问题,让代码更简洁、复用性更强。


二、useState:给组件一个“记忆”

useState 是最常用的 Hook,用于在函数组件中声明和更新状态。

const [count, setCount] = useState(0);
  • count:当前状态值(初始为 0
  • setCount:更新状态的函数

📦 生活例子
就像给一个计数器装上“数字记忆”——它记得现在是几,并且能通过按钮加一或重置。

关键要点:

  1. 初始化必须是同步的
    useState(initialValue) 的参数必须是立即可用的值。

    不能写 useState(fetchData())(异步不行)
    必须写 useState(0)useState({}) 等同步值
    类比:“今天几号?”你得马上回答,不能说“等我查下日历”。

  2. 更新状态有两种方式

    • 直接赋值setCount(5) → 把状态设为 5

    • 基于前值更新setCount(prev => prev + 1) → 在旧值基础上计算新值

      ⚠️ 当新状态依赖于旧状态时(比如多次快速点击),务必使用函数式更新,避免状态丢失。


三、纯函数 vs 副作用:React 组件的理想与现实

纯函数(Pure Function)

  • 相同输入 → 相同输出
  • 不修改外部变量或 DOM
  • 无网络请求、无时间依赖
  • 例子:add(1, 2) 永远返回 3

理想中的 React 组件就是一个纯函数:

(props) => <div>Hello, {props.name}!</div>

副作用(Side Effects)

但真实应用离不开“不纯”的操作:

  • 发起 API 请求
  • 订阅事件
  • 手动操作 DOM
  • 设置定时器

这些都属于 副作用——它们会“影响外部世界”或“依赖外部状态”。

useEffect:专门处理副作用的 Hook

useEffect(() => {
  // 副作用逻辑写在这里
}, [dependencies]);
  • 第一个参数:副作用函数
  • 第二个参数(可选):依赖数组,控制何时重新执行

🛠️ 作用:把副作用从渲染逻辑中抽离,让组件更接近“纯函数”,同时安全地处理异步或外部交互。


四、用 useEffect 实现异步数据请求

由于 useState 不能直接接收异步结果,我们采用“两步走”策略:

// 1. 先准备一个“空盒子”(同步初始化)
const [data, setData] = useState(null);

// 2. 组件挂载后,用 useEffect “去拿东西”
useEffect(() => {
  fetch('/api/user')
    .then(res => res.json())
    .then(setData);
}, []); // 空依赖数组 → 只在组件首次渲染时执行一次

📦 类比理解

  • useState(null) = 桌上先放一个空盒子
  • useEffect = 派人去仓库取货,回来后把东西放进盒子
  • 用户看到的是:先空白 → 稍后显示内容(可配合 loading 状态优化体验)

💡 最佳实践

  • 初始状态可设为 null[] 或 {},便于后续判断是否加载完成
  • 添加错误处理和 loading 状态,提升健壮性

五、React 与 Vue 的响应式哲学对比

维度 React Vue
状态更新 手动调用 setState / setXxx 自动追踪依赖,响应式系统自动更新
心智模型 “我告诉 UI 该变了” “数据变了,UI 自动跟着变”
类比 手动开关灯 声控灯(数据一动,视图就亮)
  • React 更显式:每一步更新都由开发者主动触发,逻辑清晰、可控性强。
  • Vue 更隐式:依赖收集 + 响应式代理自动完成更新,开发效率高,但调试复杂场景时可能不够透明。

两者没有绝对优劣,关键在于理解其背后的设计哲学:
React 强调“可预测性”Vue 追求“开发幸福感”


useEffect 和 useState

在现代前端开发中,React 函数式组件因其简洁、可读性强、逻辑复用便利等优势,已成为主流开发范式。而让函数组件具备状态管理副作用处理能力的核心,正是 React Hooks

本文将带你从最基础的 useState 入手,逐步理解为何需要 useEffect,以及如何正确使用它来构建健壮的交互逻辑。


一、“记忆”从何而来?——useState 如何赋予函数组件状态

在类组件时代,我们通过 this.state 管理内部状态;而在函数组件中,useState Hook 提供了同等能力。

基础用法

import { useState } from 'react';

export default function App() {
  const [num, setNum] = useState(1);
  
  return (
    <div onClick={() => setNum(num + 1)}>
      {num}
    </div>
  );
}
  • useState(1) 接收一个初始值(1),返回一个包含两个元素的数组:

    • 第一个元素 num:当前状态值;
    • 第二个元素 setNum:用于更新状态的函数。
  • 通过解构赋值,我们将其命名为 num 和 setNum,语义清晰。

🔄 数据流闭环
用户触发事件(如点击) → 调用 setNum 更新状态 → React 重新渲染组件 → 页面显示新值。
这形成了“事件 → 状态 → 渲染”的三角关系,是 React 响应式更新的核心机制。

💡 注意:这里的“事件”不仅指点击,还包括表单输入、定时器、API 回调等任何能触发状态变更的操作。


进阶用法:惰性初始化与函数式更新

import { useState } from 'react';

export default function App() {
  // 惰性初始化:仅在首次渲染时执行
  const [num, setNum] = useState(() => {
    const num1 = 1 + 2;
    const num2 = 2 + 3;
    return num1 + num2; // 返回 6
  });

  return (
    <div onClick={() => setNum(prev => {
      console.log('上一次的值:', prev);
      return prev + 1;
    })}>
      {num}
    </div>
  );
}

1. 惰性初始化(Lazy Initialization)

当初始状态需要复杂计算时,可传入一个纯函数作为参数。React 仅在组件首次渲染时调用它,避免重复计算。

必须是纯函数
纯函数要求相同输入必得相同输出,且无副作用(如不发起网络请求、不修改外部变量)。
❌ 错误示例:

const [data] = useState(() => fetch('/api').then(res => res.json())); // 异步不可用!

2. 函数式更新(Functional Update)

当新状态依赖于前一个状态时(如多次快速点击),应使用函数形式:

setNum(prev => prev + 1);

这能避免因闭包捕获旧值而导致的状态“滞后”问题。


二、为什么需要 useEffect?——副作用的必然引入

useState 只负责声明状态触发更新,但它无法处理副作用(Side Effects)。

什么是副作用?

副作用 = 任何在组件渲染之外发生的、影响或依赖外部系统的行为。

例如:

  • 发起 API 请求
  • 订阅 WebSocket
  • 操作 DOM
  • 设置/清除定时器
  • 修改全局变量

这些操作不能放在渲染逻辑中(会导致无限循环或性能问题),但又必须在特定时机执行。

纯函数的理想 vs 现实的妥协

理想中的 React 组件是一个纯函数

(props) => <div>Hello, {props.name}</div>

给定相同的 propsstate,总是返回相同的 JSX。

但现实应用离不开副作用。于是,useEffect 应运而生——它是 React 专门用于安全处理副作用的 Hook。


三、“行动”何时发生?——useEffect 的三种典型场景

useEffect 接收两个参数:

  1. 副作用函数:包含你要执行的逻辑;
  2. 依赖数组(可选) :控制该副作用何时重新执行。

场景 1:组件挂载时执行(模拟 componentDidMount

import { useState, useEffect } from 'react';

async function queryData() {
  return new Promise(resolve => {
    setTimeout(() => resolve(666), 2000);
  });
}

export default function App() {
  const [num, setNum] = useState(0);

  useEffect(() => {
    console.log('useEffect 执行');
    queryData().then(data => setNum(data));
  }, []); // 空依赖数组 → 仅在挂载时执行一次

  console.log('render 执行');

  return <div onClick={() => setNum(n => n + 1)}>{num}</div>;
}

🔍 执行顺序
控制台先输出 'render 执行',再输出 'useEffect 执行'
这是因为 React 优先完成 DOM 渲染,再异步执行副作用,避免阻塞 UI

此模式常用于初始化数据加载


场景 2:依赖项变化时执行(类似 Vue 的 watch

useEffect(() => {
  console.log('num 变化了:', num);
}, [num]); // 依赖 num

每当 num 更新,副作用函数就会重新执行。适用于监听状态变化并作出响应。


场景 3:清理副作用(防止内存泄漏)

考虑以下代码:

useEffect(() => {
  const timer = setInterval(() => {
    console.log(num); // 注意:这里会打印旧的 num!
  }, 1000);
}, [num]);

问题:每次 num 变化,都会创建一个新的定时器,而旧的未被清除 → 多个定时器同时运行 → 内存泄漏!

解决方案:返回清理函数

useEffect(() => {
  const timer = setInterval(() => {
    console.log(num);
  }, 1000);

  // 返回清理函数
  return () => {
    clearInterval(timer);
  };
}, [num]);

🧹 清理函数的作用

  • 下一次副作用执行前,清除上一次的资源;
  • 组件卸载时,自动调用以释放资源。

这对于定时器、事件监听、WebSocket 连接等场景至关重要。

验证卸载清理

return (
  <>
    <div onClick={() => setNum(n => n + 1)}>{num}</div>
    {num % 2 === 0 && <Demo />}
  </>
);

num 为奇数时,<Demo /> 被卸载,其内部的 useEffect 清理函数会自动执行。


结语:Hooks 的哲学 —— 显式优于隐式

React 通过 useStateuseEffect,将状态与副作用显式地暴露在函数组件中。虽然需要开发者手动管理更新与清理,但换来的是更高的可预测性与调试能力

  • useState 赋予函数“记忆”;
  • useEffect 赋予函数“行动力”;
  • 两者结合,让函数组件不再“无状态”,而是简洁、组合、强大的现代 React 开发基石。

掌握这两个 Hook,你就已经站在了 React Hooks 世界的大门之内。下一步,可以探索 useCallbackuseMemo、自定义 Hooks 等高级模式,构建更高效、可维护的应用。

手写 `instanceof`:深入理解 JavaScript 原型链与继承机制

在 JavaScript 的面向对象编程中,instanceof 是一个用于判断对象是否为某个构造函数实例的关键运算符。它不像类型检查那样关注数据的表面形式,而是深入到对象的原型链中,验证是否存在“血缘关系”。这种机制不仅支撑了 JavaScript 的继承体系,也为大型项目中的类型判断提供了可靠依据。本文将从原型链原理出发,手写一个 instanceof 实现,并探讨其在不同继承模式下的表现。


原型链:JavaScript 继承的基石

JavaScript 并没有传统意义上的“类”,而是通过原型(prototype)实现对象之间的继承关系。每个对象都有一个内部属性 [[Prototype]](可通过 __proto__ 访问),指向其构造函数的 prototype 对象。而 prototype 本身也是一个对象,它也可能拥有自己的原型,由此形成一条原型链,直到 null 为止。

const arr = [];
console.log(arr.__proto__ === Array.prototype); // true
console.log(arr.__proto__.__proto__ === Object.prototype); // true

这段代码展示了数组的原型链:arrArray.prototypeObject.prototypenull。正是这条链,使得数组可以调用 pushtoString 等方法。


instanceof 的工作原理

A instanceof B 的本质是:检查 B.prototype 是否出现在 A 的原型链上。如果存在,则返回 true,否则 false

基于这一逻辑,我们可以手动实现 instanceof

function isInstanceOf(A, B) {
  let proto = A.__proto__;
  while (proto) {
    if (proto === B.prototype) return true;
    proto = proto.__proto__;
  }
  return false;
}

该函数从 A 的直接原型开始,逐级向上遍历,直到找到 B.prototype 或到达链尾。这种方式完全复现了原生 instanceof 的行为。

例如:

function Animal() {}
function Dog() {}
Dog.prototype = new Animal();

const dog = new Dog();
console.log(isInstanceOf(dog, Dog));     // true
console.log(isInstanceOf(dog, Animal));  // true

由于 dog 的原型链包含 Dog.prototypeAnimal.prototype,因此对两者都返回 true,体现了继承的传递性。


构造函数绑定继承:属性的复用

早期 JavaScript 中,一种常见的继承方式是通过 callapply 在子类构造函数中调用父类构造函数,从而复制实例属性:

function Animal() {
  this.species = '动物';
}
function Cat(name, color) {
  Animal.apply(this);
  this.name = name;
  this.color = color;
}
const cat = new Cat('小白', '白色');
console.log(cat.species); // "动物"

这种方式能正确继承实例属性,但无法继承原型上的方法。因此,cat instanceof Animal 会返回 false,因为 cat 的原型链并未包含 Animal.prototype


原型链继承:方法的共享

为了让子类也能访问父类原型上的方法,开发者通常采用“父类实例作为子类原型”的模式:

function Animal() {}
Animal.prototype.species = '动物';

function Cat(name, color) {
  this.name = name;
  this.color = color;
}
Cat.prototype = new Animal();
Cat.prototype.constructor = Cat;

const cat = new Cat('小黑', '黑色');
console.log(cat.species); // "动物"

这里,Cat.prototype 被替换为 Animal 的一个实例,因此 cat 的原型链自然包含了 Animal.prototype。此时:

console.log(cat instanceof Cat);     // true
console.log(cat instanceof Animal);  // true

同时,修复 constructor 指向确保了类型标识的准确性,避免 cat.constructor 错误地指向 Animal


直接继承 prototype:简洁但有风险

另一种写法是直接让子类的 prototype 引用父类的 prototype

Cat.prototype = Animal.prototype;
Cat.prototype.constructor = Cat;

这种方式避免了创建多余的父类实例,节省内存。但由于是引用赋值,修改 Cat.prototype 会直接影响 Animal.prototype,破坏封装性。例如:

Cat.prototype.purr = function() { /* ... */ };
// 此时 Animal.prototype 也拥有了 purr 方法!

因此,这种模式虽简洁,但在多人协作或复杂系统中容易引发副作用,需谨慎使用。


手写 instanceof 的实际价值

在大型项目中,对象来源可能多样:可能是本地创建,也可能是远程 API 返回,或是第三方库生成。此时,仅靠 typeofObject.prototype.toString 难以准确判断其“身份”。而 instanceof(或其手写版本)能基于原型链提供可靠的类型验证:

if (obj instanceof User) {
  obj.login();
}

即使 User 类由不同模块定义,只要原型链一致,判断就有效。这在插件系统、组件通信、状态管理等场景中尤为重要。

此外,手写 instanceof 有助于深入理解 JavaScript 的对象模型。它揭示了“继承”并非语法糖,而是实实在在的指针链接。每一次 instanceof 判断,都是对这条链的一次遍历。


结语

instanceof 虽是一个简单的运算符,却承载着 JavaScript 面向对象设计的核心思想——基于原型的动态继承。通过手写其实现,我们不仅掌握了其工作原理,也更清晰地认识到不同继承方式对原型链结构的影响。

在现代开发中,尽管 ES6 的 class 语法让继承看起来更“传统”,但其底层依然依赖原型链。理解 instanceof 的本质,就是理解 JavaScript 如何在没有类的世界里,构建出灵活而强大的对象体系。这种理解,是写出健壮、可维护代码的重要基石。

❌